"""Logic for product transfer job CRUD (PORT-67).""" import datetime from oto import response from project_manager.models import product_transfer_history as history_model from project_manager.models import project_transfer_job as job_model def _required_int(body, field): val = body.get(field) if val is None: return None, f"'{field}' is required." try: return int(val), None except (TypeError, ValueError): return None, f"'{field}' must be an integer." def _validate_create_body(body): """Return (params dict, error message) from a create request body. The originating vendor/subaccount are NOT taken from the body; they are resolved from the project's current owner by the handler and passed in separately to create_transfer_job. """ params = {} for field in ('project_id', 'destination_vendor_id'): val, err = _required_int(body, field) if err: return None, err params[field] = val params['destination_subaccount_id'] = body.get('destination_subaccount_id') today = datetime.date.today() params['revenue_cutoff_date'] = today.replace(day=1) - datetime.timedelta(days=1) return params, None def list_transfer_jobs(request_args, identity_id): """Return paginated list of transfer jobs.""" try: limit = int(request_args.get('limit', 100)) offset = int(request_args.get('offset', 0)) except (TypeError, ValueError): return response.create_error_response( code='bad_request', message="'limit' and 'offset' must be integers.", status=400) originating_vendor_id = request_args.get('originating_vendor_id') destination_vendor_id = request_args.get('destination_vendor_id') if originating_vendor_id is not None: try: originating_vendor_id = int(originating_vendor_id) except (TypeError, ValueError): return response.create_error_response( code='bad_request', message="'originating_vendor_id' must be an integer.", status=400) if destination_vendor_id is not None: try: destination_vendor_id = int(destination_vendor_id) except (TypeError, ValueError): return response.create_error_response( code='bad_request', message="'destination_vendor_id' must be an integer.", status=400) project_id = request_args.get('project_id') if project_id is not None: try: project_id = int(project_id) except (TypeError, ValueError): return response.create_error_response( code='bad_request', message="'project_id' must be an integer.", status=400) result = job_model.get_transfer_jobs( status=request_args.get('status'), originating_vendor_id=originating_vendor_id, destination_vendor_id=destination_vendor_id, project_id=project_id, limit=limit, offset=offset, ) if isinstance(result, response.Response) and result.status != 200: return result items, total = result return response.Response(message={'items': items, 'total_count': total}, status=200) def fetch_job_for_auth(job_id): """Return the bare job dict for ownership checks, or a 404/error Response.""" job = job_model.get_transfer_job(job_id) if isinstance(job, response.Response): return job if job is None: return response.create_not_found_response( message=f'Transfer job {job_id} does not exist.') return job def get_transfer_job(job_id): """Return single job or 404.""" job = job_model.get_transfer_job(job_id) if isinstance(job, response.Response): return job if job is None: return response.create_not_found_response( message=f'Transfer job {job_id} does not exist.') return response.Response(message=job, status=200) def get_transfer_job_products(job_id): """Return product list for a job (used by SFN lambdas).""" job = job_model.get_transfer_job(job_id) if isinstance(job, response.Response): return job if job is None: return response.create_not_found_response( message=f'Transfer job {job_id} does not exist.') products = history_model.get_products_for_job(job_id) if isinstance(products, response.Response): return products return response.Response(message=products, status=200) def get_transfer_job_attachments(job_id): """Return distinct UPCs and ISRCs for every release in the job. Used by the transfer SFN's accounting lambda to drive bulk-remove on the originating account's contract terms and bulk-add on the destination contracts. The two lists are resolved from art_relations.releases and art_relations.track at request time so the lambda always sees current data. """ job = job_model.get_transfer_job(job_id) if isinstance(job, response.Response): return job if job is None: return response.create_not_found_response( message=f'Transfer job {job_id} does not exist.') upcs = history_model.get_upcs_for_job(job_id) if isinstance(upcs, response.Response): return upcs isrcs = history_model.get_isrcs_for_job(job_id) if isinstance(isrcs, response.Response): return isrcs return response.Response( message={'upcs': upcs, 'isrcs': isrcs}, status=200) def create_transfer_job(body, identity_id, originating_vendor_id, originating_subaccount_id, originating_artist_id): """Create a new transfer job and snapshot its releases. `originating_vendor_id` and `originating_subaccount_id` are resolved by the handler from the project's current owner (via check_project_ownership) and passed in here; the request body must NOT carry them. A product_transfer_history row is inserted for each release in the project with destination_artist_id NULL; the transfer SFN populates destination_artist_id per release after processing. """ params, err = _validate_create_body(body) if err: return response.create_error_response( code='bad_request', message=err, status=400) params['originating_vendor_id'] = originating_vendor_id params['originating_subaccount_id'] = originating_subaccount_id params['originating_artist_id'] = originating_artist_id releases = job_model.get_releases_for_project(params['project_id']) if isinstance(releases, response.Response): return releases if not releases: return response.create_error_response( code='bad_request', message=f"No releases found for project {params['project_id']}.", status=400, ) job = job_model.create_transfer_job(params, identity_id) if isinstance(job, response.Response): return job job_id = job['project_transfer_job_id'] snapshot_err = history_model.snapshot_releases_for_job(job_id, releases) if isinstance(snapshot_err, response.Response): return snapshot_err return response.Response(message=job, status=201) def set_destination_artists(job_id, body): """Bulk-set destination_artist_id on snapshot rows for a job. Called by the transfer SFN once per job (or once per Map iteration that handles many releases) after destination artists have been resolved. The SFN treats destination_artist_id NULL/NOT NULL as its own idempotency check; calling this with already-populated values is a no-op from the SFN's perspective. Body: { "updates": [ { "release_id": int, "destination_artist_id": int }, ... ] } All updates apply in a single transaction; if any release_id has no live snapshot row for the job, the whole batch is rejected. """ raw_updates = body.get('updates') if not isinstance(raw_updates, list) or not raw_updates: return response.create_error_response( code='bad_request', message="'updates' must be a non-empty list.", status=400) parsed = [] for i, u in enumerate(raw_updates): if not isinstance(u, dict): return response.create_error_response( code='bad_request', message=f'updates[{i}] must be an object.', status=400) release_id, err = _required_int(u, 'release_id') if err: return response.create_error_response( code='bad_request', message=f'updates[{i}]: {err}', status=400) destination_artist_id, err = _required_int(u, 'destination_artist_id') if err: return response.create_error_response( code='bad_request', message=f'updates[{i}]: {err}', status=400) parsed.append({ 'release_id': release_id, 'destination_artist_id': destination_artist_id, 'destination_video_artist_id': u.get('destination_video_artist_id'), }) job = job_model.get_transfer_job(job_id) if isinstance(job, response.Response): return job if job is None: return response.create_not_found_response( message=f'Transfer job {job_id} does not exist.') result = history_model.set_destination_artists(job_id, parsed) if isinstance(result, response.Response): return result updated, missing = result if missing: return response.create_not_found_response( message=(f'No snapshot row for job {job_id} releases {missing}; ' f'no rows updated.')) return response.Response( message={'updated_count': len(updated), 'updated': updated}, status=200) _UPDATABLE_STATUSES = ('PROCESSING', 'COMPLETED', 'FAILED') _TERMINAL_STATUSES = ('COMPLETED', 'FAILED') _PATCHABLE_FIELDS = frozenset( ['status', 'failure_reason', 'revenue_cutoff_date', 'transfer_completed_on', 'sfn_execution_arn', 'destination_artist_id']) def update_transfer_job(job_id, body): """Partially update a transfer job's mutable fields. Accepted body keys: status, failure_reason, revenue_cutoff_date, transfer_completed_on, sfn_execution_arn. At least one must be present. """ known = {k: v for k, v in body.items() if k in _PATCHABLE_FIELDS} if not known: return response.create_error_response( code='bad_request', message=(f"Request body must include at least one of: " f"{sorted(_PATCHABLE_FIELDS)}."), status=400) fields = {} status = known.get('status') if status is not None: if status not in _UPDATABLE_STATUSES: return response.create_error_response( code='bad_request', message=f"'status' must be one of {_UPDATABLE_STATUSES}.", status=400) fields[job_model.ProjectTransferJob.status] = status failure_reason = known.get('failure_reason') if status == 'FAILED' and not failure_reason: return response.create_error_response( code='bad_request', message="'failure_reason' is required when status is 'FAILED'.", status=400) if 'failure_reason' in known: fields[job_model.ProjectTransferJob.failure_reason] = failure_reason or None raw_date = known.get('revenue_cutoff_date') if raw_date is not None: try: fields[job_model.ProjectTransferJob.revenue_cutoff_date] = ( datetime.date.fromisoformat(raw_date)) except (TypeError, ValueError): return response.create_error_response( code='bad_request', message="'revenue_cutoff_date' must be an ISO 8601 date (YYYY-MM-DD).", status=400) raw_completed_on = known.get('transfer_completed_on') if raw_completed_on is not None: try: fields[job_model.ProjectTransferJob.transfer_completed_on] = ( datetime.datetime.fromisoformat(raw_completed_on).replace(tzinfo=None)) except (TypeError, ValueError): return response.create_error_response( code='bad_request', message="'transfer_completed_on' must be an ISO 8601 datetime.", status=400) elif status == 'COMPLETED': fields[job_model.ProjectTransferJob.transfer_completed_on] = ( datetime.datetime.now(tz=datetime.timezone.utc).replace(tzinfo=None)) sfn_arn = known.get('sfn_execution_arn') if sfn_arn is not None: if len(sfn_arn) > 2048: return response.create_error_response( code='bad_request', message="'sfn_execution_arn' must be 2048 characters or fewer.", status=400) fields[job_model.ProjectTransferJob.sfn_execution_arn] = sfn_arn destination_artist_id = known.get('destination_artist_id') if 'destination_artist_id' in known: if destination_artist_id is not None and type(destination_artist_id) is not int: return response.create_error_response( code='bad_request', message="'destination_artist_id' must be an integer or null.", status=400) fields[job_model.ProjectTransferJob.destination_artist_id] = destination_artist_id job = job_model.get_transfer_job(job_id) if isinstance(job, response.Response): return job if job is None: return response.create_not_found_response( message=f'Transfer job {job_id} does not exist.') if job['status'] in _TERMINAL_STATUSES: return response.create_error_response( code='bad_request', message=(f"Job {job_id} is already {job['status']} and cannot be updated."), status=400) updated = job_model.update_transfer_job(job_id, fields) if isinstance(updated, response.Response): return updated if updated is None: return response.create_not_found_response( message=f'Transfer job {job_id} does not exist.') return response.Response(message=updated, status=200) def execute_content_transfer(job_id, identity_id): """Execute Step 3 of the SFN: update project, releases, and product_video. Fetches the job and its product snapshot, validates that all destination_artist_id values are populated, then delegates to the model to run all writes in a single transaction. """ job = job_model.get_transfer_job(job_id) if isinstance(job, response.Response): return job if job is None: return response.create_not_found_response( message=f'Transfer job {job_id} does not exist.') products = history_model.get_products_for_job(job_id) if isinstance(products, response.Response): return products if not products: return response.create_error_response( code='bad_request', message=f'No products found for transfer job {job_id}.', status=400) missing_artist = [p['release_id'] for p in products if p['destination_artist_id'] is None] if missing_artist: return response.create_error_response( code='unprocessable_entity', message=( f'destination_artist_id is not populated for releases {missing_artist} ' f'on job {job_id}. EnsureDestinationArtists must run first.' ), status=422) destination_artist_id = job['destination_artist_id'] if destination_artist_id is None: destination_artist_id = products[0]['destination_artist_id'] result = job_model.execute_content_transfer( job_id=job_id, project_id=job['project_id'], destination_vendor_id=job['destination_vendor_id'], destination_subaccount_id=job['destination_subaccount_id'], destination_artist_id=destination_artist_id, products=products, ) if isinstance(result, response.Response): return result job_model.set_executed_by_identity_id(job_id, identity_id) return response.Response(message=result, status=200) def delete_transfer_job(job_id, identity_id): """Soft-delete a QUEUED job and cascade to its product_transfer_history rows.""" job = job_model.get_transfer_job(job_id) if isinstance(job, response.Response): return job if job is None: return response.create_not_found_response( message=f'Transfer job {job_id} does not exist.') if job['status'] != 'QUEUED': return response.create_error_response( code='bad_request', message=f"Only QUEUED jobs may be deleted. Job {job_id} is {job['status']}.", status=400, ) result = job_model.soft_delete_transfer_job(job_id, identity_id) if isinstance(result, response.Response): return result cascade = history_model.soft_delete_for_job(job_id, identity_id) if isinstance(cascade, response.Response): return cascade return response.Response(message=None, status=204)