"""Handlers. Requests are redirected to handlers, which are responsible for getting information from the URL and passing it down to the logic layer. The way each layer talks to each other is through Response objects which defines the type status of the data and the data itself. See: project_manager.response for more details. """ import json from flask import g from flask import jsonify from flask import request from oto import response from oto.adaptors.flask import flaskify from owsrequest import flask_request from sqlalchemy.exc import SQLAlchemyError from project_manager.api import app from project_manager.auth import only_for_identity from project_manager.constant import authorization from project_manager.constant import endpoint_const from project_manager.constant import error_const from project_manager.constant import field_const from project_manager.constant import header_const from project_manager.constant import http_status_codes from project_manager.constant import pagination_const from project_manager.logic import label_copy_export from project_manager.logic import project_manager from project_manager.logic import project_transfer from project_manager.models import persister from project_manager.util import authorization as pdp_authorization from project_manager.util import handler_util from project_manager.util import json_encoder from project_manager.validation import validators @app.route(endpoint_const.HEALTH_CHECK) def health(): """Check the health of the application.""" return jsonify({'status': 'ok'}) @app.route(endpoint_const.DB_HEALTH_CHECK) def health_db(): """Check health of db connectivity. Sentry alert should be raised if service cannot connect to db. """ persister.check_db_connectivity() return jsonify({'status': 'ok'}) @app.route('/product/genres', methods=['GET']) @handler_util.validate_header( request, validators.GET_PRODUCT_GENRES_HEADER_VALIDATOR) def get_product_genres(): """Get the list of genre options for products. Returns: Response: Flask response """ product_genres_response = project_manager.get_product_genres() return flaskify(product_genres_response) @app.route('/public/product/genres', methods=['GET']) @handler_util.validate_header( request, validators.GET_PRODUCT_GENRES_HEADER_VALIDATOR) def get_public_product_genres(): """Get the list of genre options for products. Returns: Response: Flask response """ headers = {'Cache-Control': 'max-age=3600'} product_genres_response = project_manager.get_product_genres() return flaskify(product_genres_response, headers) @app.route('/project//product/imprints', methods=['GET']) @handler_util.validate_header( request, validators.GET_PROJECT_IMPRINTS_HEADER_VALIDATOR) def get_product_imprints(project_id): """Get product imprint options. Get the list of imprint options for products belonging to the project with id project_id. Args: project_id (int): Project id. Returns: Response: Flask response. """ access_check_response = handler_util.access_check(dict(request.headers)) if not access_check_response: return flaskify(access_check_response) account_type = access_check_response.message.get('account_type') account_id = access_check_response.message.get('account_id') product_imprints_response = project_manager.get_product_imprints( project_id, account_type=account_type, account_id=account_id) return flaskify(product_imprints_response) @app.route('/product/genres//subgenres', methods=['GET']) @handler_util.validate_header( request, validators.GET_PRODUCT_SUBGENRES_HEADER_VALIDATOR) def get_product_subgenres(genre_id): """Get the list of subgenre options for products given a genre id. Returns: Response: Flask response """ product_subgenres_response = project_manager.get_product_subgenres( genre_id) return flaskify(product_subgenres_response) @app.route('/public/product/genres//subgenres', methods=['GET']) @handler_util.validate_header( request, validators.GET_PRODUCT_SUBGENRES_HEADER_VALIDATOR) def get_public_product_subgenres(genre_id): """Get the list of subgenre options for products given a genre id. Returns: Response: Flask response """ headers = {'Cache-Control': 'max-age=3600'} product_subgenres_response = project_manager.get_product_subgenres( genre_id) return flaskify(product_subgenres_response, headers) @app.route('/product/types', methods=['GET']) @handler_util.validate_header( request, validators.GET_PRODUCT_TYPES_HEADER_VALIDATOR) def get_product_types(): """Get the list of type options for products. @todo: Make this actually talk to the logic layer to fetch the options Returns: Response: Flask response """ product_type_response = project_manager.get_product_types() return flaskify(product_type_response) @app.route('/project', methods=['POST']) @handler_util.validate_header( request, validators.POST_PROJECT_HEADER_VALIDATOR) @handler_util.validate_body( request, validators.POST_PROJECT_BODY_VALIDATOR) def post_project(): """Post a project.""" grass_account_type = request.headers.get(header_const.GRASS_ACCOUNT_TYPE) grass_account_id = request.headers.get(header_const.GRASS_ACCOUNT_ID) user = request.headers.get(header_const.ORCHARD_USER_ID) project_data = request.get_json() project = project_manager.add_project( project_code=project_data[field_const.PROJECT_CODE], project_name=project_data.get(field_const.PROJECT_NAME), artist_id=project_data.get(field_const.ARTIST_ID), subaccount_id=project_data.get(field_const.SUBACCOUNT_ID), grass_account_type=grass_account_type, grass_account_id=grass_account_id, project_highlights=project_data.get(field_const.PROJECT_HIGHLIGHTS), vendor_id=project_data.get(field_const.VENDOR_ID), description=project_data.get(field_const.DESCRIPTION), user=user, artist=project_data.get(field_const.ARTIST) ) # Additional message for the logs. g.ows.log.extra_message = str(project_data) return flaskify( project, encoder=json_encoder.CustomJSONEncoder) @app.route('/project/', methods=['PUT']) @handler_util.validate_header( request, validators.PUT_PROJECT_HEADER_VALIDATOR) @handler_util.validate_body( request, validators.PUT_PROJECT_BODY_VALIDATOR) def update_project(project_id): """Update Project. Updates project in project table. Args: project_id (int): project_id Returns: Response: Flask response """ account_type = request.headers.get(header_const.GRASS_ACCOUNT_TYPE) account_id = request.headers.get(header_const.GRASS_ACCOUNT_ID) user = request.headers.get(header_const.ORCHARD_USER_ID) if account_id: account_id = int(account_id) project_data = request.get_json() project = project_manager.update_project( account_type, account_id, project_id, project_data, user) # Additional message for the logs. g.ows.log.extra_message = str(project_data) return flaskify(project, encoder=json_encoder.CustomJSONEncoder) @app.route('/project', methods=['GET']) @handler_util.validate_project_parameter(request) @handler_util.validate_header(request, validators.GET_PROJECT_HEADER_VALIDATOR) def get_project_by_parameter(): """Get project by parameter. Gets project from project table Returns: Response: Flask response """ orchard_user_id = request.headers.get(header_const.ORCHARD_USER_ID) if orchard_user_id: is_oa = orchard_user_id.startswith(header_const.OA_USER_PREFIX) if not is_oa: return flaskify(response.create_error_response( code=error_const.ERROR_CODE_AUTHORIZATION, message=error_const.ERROR_MESSAGE_FORBIDDEN_USER, status=http_status_codes.FORBIDDEN)) project_code = request.args.get('project_code') subaccount_id = int(request.args.get('subaccount_id', 0)) account_id = int(request.args.get('account_id', 0)) project = project_manager.get_project_by_project_code( account_id, subaccount_id, project_code) return flaskify(project, encoder=json_encoder.CustomJSONEncoder) @app.route('/project/', methods=['GET']) @handler_util.validate_header(request, validators.GET_PROJECT_HEADER_VALIDATOR) def get_project(project_id): """Get project. Gets project from project table Args: project_id (int): project id Returns: Response: Flask response """ access_check_response = handler_util.access_check(dict(request.headers)) if access_check_response.status != 200: return flaskify(access_check_response) account_id = access_check_response.message.get('account_id') account_type = access_check_response.message.get('account_type') include_deletions = bool(request.args.get('include_deletions', False)) project = project_manager.get_project( account_type, account_id, project_id, include_deletions) return flaskify( project, encoder=json_encoder.CustomJSONEncoder) @app.route('/project/dataloader', methods=['POST']) def dataload_projects(): """Bulk-fetch projects by id for the Project entity dataloader. Returns: flask.Response: 200 with {'projects': [...]} aligned to the posted ids. """ project_ids = request.get_json() if not isinstance(project_ids, list) or not all( isinstance(item, int) for item in project_ids ): return flaskify( response.create_error_response( error_const.ERROR_CODE_INVALID_INPUT, error_const.ERROR_MESSAGE_INVALID_DATALOAD ) ) access_check_response = handler_util.access_check(dict(request.headers)) if access_check_response.status != 200: return flaskify(access_check_response) account_type = access_check_response.message.get('account_type') account_id = access_check_response.message.get('account_id') return flaskify( project_manager.get_bulk_projects( account_type, account_id, project_ids), encoder=json_encoder.CustomJSONEncoder) @app.route('/project/available', methods=['GET']) def get_project_codes_available_for_use(): """Check if a list of project codes are in use. Returns: Response: Flask response """ data = request.get_json() project_codes = data.get('project_codes') subaccount_uuid = data.get('subaccount_uuid', None) account_uuid = data.get('account_uuid', None) projects = project_manager.get_available_project_codes( account_uuid, subaccount_uuid, project_codes) return flaskify(projects, encoder=json_encoder.CustomJSONEncoder) @app.route('/project/', methods=['DELETE']) @handler_util.validate_header(request, validators.GET_PROJECT_HEADER_VALIDATOR) def delete_project(project_id): """Delete a project with no associated products. Args: project_id (int): project id Returns: Response: Flask response """ access_check_response = handler_util.access_check(dict(request.headers)) if access_check_response.status != 200: return flaskify(access_check_response) account_id = access_check_response.message.get('account_id') account_type = access_check_response.message.get('account_type') return flaskify(project_manager.delete_project( project_id, account_type, account_id)) @app.route('/project//hard-delete', methods=['DELETE']) @only_for_identity(authorization.HARD_DELETE_PROJECT_AUTHORIZED_IDENTITIES) def hard_delete_project(project_id): """Hard delete a project with no associated products. Args: project_id (int): project id Returns: Response: Flask response """ # Pass null account type and id to skip project ownership check return flaskify(project_manager.delete_project( project_id=project_id, account_type=None, account_id=None, hard_delete=True )) @app.route('/project//products', methods=['GET']) @handler_util.validate_header( request, validators.GET_PROJECT_PRODUCTS_HEADER_VALIDATOR) def get_products_for_project(project_id): """Get products for project. Gets displayable product information for a project Args: project_id (int): project id Returns: Response: Flask response """ account_type = request.headers.get(header_const.GRASS_ACCOUNT_TYPE) account_id = request.headers.get(header_const.GRASS_ACCOUNT_ID) if account_id: account_id = int(account_id) products_response = project_manager.get_products_for_project( account_type, account_id, project_id) return flaskify( products_response, encoder=json_encoder.CustomJSONEncoder) @app.route('/project//product/', methods=['GET']) # noqa @handler_util.validate_header( request, validators.GET_PROJECT_PRODUCT_HEADER_VALIDATOR) def get_product_for_project(project_id, product_id): """Get product for project given a specific product. Gets specific displayable product information for a project. Args: project_id (int): project id product_id (int): product id Returns: Response: Flask response """ account_type = request.headers.get(header_const.GRASS_ACCOUNT_TYPE) account_id = request.headers.get(header_const.GRASS_ACCOUNT_ID) if account_id: account_id = int(account_id) product_response = project_manager.get_product_for_project( account_type, account_id, project_id, product_id, ) return flaskify( product_response, encoder=json_encoder.CustomJSONEncoder) @handler_util.validate_header( request, validators.GET_PROJECTS_HEADER_VALIDATOR) @app.route('/projects', methods=['GET']) def get_projects(): """Get projects. Gets list of projects for vendor id or vendor and subaccount ids. Args: page_offset (int): record index used to start page_limit (int): number of records to fetch subaccount_id (int): optional subaccount_id Returns: list of project dicts """ page_offset = request.args.get( 'page_offset', default=pagination_const.PAGE_OFFSET_DEFAULT, type=int) page_limit = request.args.get( 'page_limit', default=pagination_const.PAGE_LIMIT_DEFAULT, type=int) subaccount_id = request.args.get('subaccount_id', default=None, type=int) vendor_id = None if page_limit > pagination_const.PAGE_LIMIT_MAX_ACCEPTABLE: res = response.create_error_response( code=error_const.VALIDATION_ERROR, message='Page limit is greater than the max acceptable', status=400) return flaskify( res, encoder=json_encoder.CustomJSONEncoder) user_id = request.headers.get(header_const.ORCHARD_USER_ID) or '' if user_id.startswith('oa:'): vendor_id = request.args.get('vendor_id', default=None, type=int) else: header_account_id = request.headers.get(header_const.GRASS_ACCOUNT_ID) header_account_type = request.headers.get( header_const.GRASS_ACCOUNT_TYPE) if header_account_type == header_const.GRASS_ACCOUNT_TYPE_VENDOR: vendor_id = header_account_id elif header_account_type == header_const.GRASS_ACCOUNT_TYPE_SUBACCOUNT: subaccount_id = header_account_id else: res = response.create_error_response( code=error_const.VALIDATION_ERROR, message='Request header account type incorrect', status=400) return flaskify( res, encoder=json_encoder.CustomJSONEncoder) res = project_manager.get_projects( vendor_id, subaccount_id, page_offset, page_limit) return flaskify( res, encoder=json_encoder.CustomJSONEncoder) @app.route( '/ownership///project/', methods=['HEAD']) @handler_util.validate_header( request, validators.CHECK_PROJECT_OWNERSHIP_HEADER_VALIDATOR) def check_project_ownership(account_type, account_id, project_id): """Check if a vendor or subaccount owns a project. Args: account_type (str): Account type to verify project ownership for. account_id (int): Account id to verify project ownership for. project_id (int): Project id of project to verify ownership of. Returns: Response: Flask response. """ if ((account_type not in header_const.GRASS_ACCOUNT_TYPES) or not account_id): return flaskify( response.Response(status=http_status_codes.BAD_REQUEST)) ownership_response = project_manager.check_project_ownership( project_id, **{account_type + '_id': account_id}) if not ownership_response: return flaskify( response.Response(status=ownership_response.status)) project_vendor_id, project_subaccount_id = ownership_response.message access_response = flask_request.verify_grass_access( request, vendor=project_vendor_id, subaccount=project_subaccount_id) if not access_response: return flaskify( response.Response(status=access_response.status)) return flaskify( response.Response(status=ownership_response.status)) @app.route('/report/', methods=['POST']) @handler_util.validate_header( request, validators.POST_REPORT_HEADER_VALIDATOR) def post_report_generation(project_id): """Trigger metadata report generation for single project. Args: project_id (int): project id Returns: Response: Flask response """ result = label_copy_export.trigger_project_metadata_generation( headers=dict(request.headers), project_id=project_id) return flaskify(result, encoder=json_encoder.CustomJSONEncoder) @app.route('/report/', methods=['GET']) @handler_util.validate_header( request, validators.GET_REPORT_HEADER_VALIDATOR) def get_report_metadata(project_id): """Get metadata report for single project. Args: project_id (int): project id Returns: Response: Flask response with download_url and timestamp or errors. """ access_check_response = handler_util.access_check(dict(request.headers)) if not access_check_response: return flaskify( access_check_response, encoder=json_encoder.CustomJSONEncoder) header_account_type = access_check_response.message.get('account_type') header_account_id = access_check_response.message.get('account_id') result = label_copy_export.get_generated_metadata_for_project( project_id=project_id, account_id=header_account_id, account_type=header_account_type) return flaskify(result, encoder=json_encoder.CustomJSONEncoder) @app.route('/project//document', methods=['GET']) def get_project_document(project_id): """Get single project with some artist info. Should have no grass headers since this is intended for internal use Args: project_id (int): project id Returns: Response: Flask response """ account_type = request.headers.get(header_const.GRASS_ACCOUNT_TYPE) account_id = request.headers.get(header_const.GRASS_ACCOUNT_ID) if account_id or account_type: response_object = response.create_fatal_response( message=error_const.ERROR_CODE_BAD_GRASS_REQUEST) return flaskify( response_object, encoder=json_encoder.CustomJSONEncoder) with_tenant_uuids = request.args.get("with_tenant_uuids", False, type=json.loads) result = persister.get_project_and_artist_info_by_id(project_id, with_tenant_uuids) return flaskify(result, encoder=json_encoder.CustomJSONEncoder) @app.errorhandler(SQLAlchemyError) def handle_sqlalchemy_error(error_obj): """Error Handler.""" return handler_util.get_error_json( request, error_const.DATABASE_ERROR, str(error_obj), 500) @app.errorhandler(500) def internal_server_error(error): """Handle response when errors are raised. Args: error (HTTPException): The HTTPException created by flask when handling a 500. Returns: Response: internal server error message. """ response_object = response.create_fatal_response( message=error_const.ERROR_MSG_INTERNAL_SERVER) return flaskify( response_object, encoder=json_encoder.CustomJSONEncoder) @app.errorhandler(404) def page_not_found(error): """Handle response when route does not exist. Args: error (HTTPException): The HTTPException created by flask when handling a 404. Returns: Response: 404 Not Found message. """ response_object = response.create_not_found_response( message=error_const.ERROR_MSG_NOT_FOUND) return flaskify( response_object, encoder=json_encoder.CustomJSONEncoder) @app.route('/project//mkt_priority', methods=['POST']) def set_mkt_priority(project_id): """Set an mkt priority of project. Must be an oa user or a microservice-to-microservice call. Returns: flask.Response: on successful, 200 status with JSON body. """ headers_response = flask_request.verify_grass_headers(request, required=False) if not headers_response: return flaskify(headers_response) orchard_user_id = request.headers.get(header_const.ORCHARD_USER_ID) user_id = None if orchard_user_id: is_oa = orchard_user_id.startswith(header_const.OA_USER_PREFIX) if not is_oa: return flaskify(response.create_fatal_response( error_const.ERROR_MESSAGE_FORBIDDEN_USER)) user_id = int(orchard_user_id.split(':')[1]) data = request.get_json() return flaskify(project_manager.set_mkt_priority( project_id, data['country_id'], data['priority'], user_id, )) @app.route('/project//mkt_priority', methods=['GET']) def get_mkt_priority(project_id): """Fetch an mkt priority of project. Returns: flask.Response: on successful, 200 status with JSON body. """ headers_response = flask_request.verify_grass_headers(request) if not headers_response: return flaskify(headers_response) return flaskify(project_manager.get_mkt_priority( project_id)) @app.route('/project/mkt_priority/dataloader', methods=['POST']) def dataload_mkt_priority(): """Fetch bulk project mkt priorities. Returns: flask.Response: on successful, 200 status with JSON body. """ project_ids = request.get_json() if not isinstance(project_ids, list) or not all( isinstance(item, int) for item in project_ids ): return flaskify( response.create_error_response( error_const.ERROR_CODE_INVALID_INPUT, error_const.ERROR_MESSAGE_INVALID_DATALOAD ) ) headers_response = flask_request.verify_grass_headers(request) if not headers_response: return flaskify(headers_response) return flaskify(project_manager.get_bulk_mkt_priority( project_ids)) @app.route('/project//mkt_priority/', methods=['DELETE']) def delete_mkt_priority(project_id, projection_id): """Delete a mkt priority of project. Must be an oa user or a microservice-to-microservice call. Returns: flask.Response: on successful, 200 status with JSON body. """ headers_response = flask_request.verify_grass_headers(request, required=False) if not headers_response: return flaskify(headers_response) orchard_user_id = request.headers.get(header_const.ORCHARD_USER_ID) if orchard_user_id: is_oa = orchard_user_id.startswith(header_const.OA_USER_PREFIX) if not is_oa: return flaskify(response.create_fatal_response( error_const.ERROR_MESSAGE_FORBIDDEN_USER)) return flaskify(project_manager.delete_mkt_priority( project_id, projection_id)) @app.route('/project//mkt_priority', methods=['DELETE']) def delete_mkt_priority_for_project(project_id): """Delete all marketing priorities of a project. Must be an oa user or a microservice-to-microservice call. Returns: flask.Response: on successful, 200 status with JSON body. """ headers_response = flask_request.verify_grass_headers(request, required=False) if not headers_response: return flaskify(headers_response) orchard_user_id = request.headers.get(header_const.ORCHARD_USER_ID) if orchard_user_id: is_oa = orchard_user_id.startswith(header_const.OA_USER_PREFIX) if not is_oa: return flaskify(response.create_fatal_response( error_const.ERROR_MESSAGE_FORBIDDEN_USER)) return flaskify(project_manager.bulk_delete_mkt_priority_for_project( project_id)) @app.route('/transfer/jobs', methods=['GET']) @pdp_authorization.authorize_transfer_job(action='view', job_scoped=False) def list_transfer_jobs(identity): """List product transfer jobs.""" return flaskify(project_transfer.list_transfer_jobs(request.args, identity)) @app.route('/transfer/job', methods=['POST']) @pdp_authorization.authorize_transfer_job( action='create', job_scoped=False, defer_pdp=True) def create_transfer_job(identity): """Create a new product transfer job.""" body = request.get_json(force=True) or {} project_id = body.get('project_id') if not isinstance(project_id, int): return flaskify(response.create_error_response( code='bad_request', message="'project_id' is required and must be an integer.", status=http_status_codes.BAD_REQUEST)) project_data = project_manager.get_project_by_id(project_id) if not project_data: return flaskify(project_data) originating_vendor_id = project_data['vendor_id'] originating_subaccount_id = None if project_data['subaccount_id'] != 0: originating_subaccount_id = project_data['subaccount_id'] originating_artist_id = project_data['artist_id'] if not pdp_authorization.pdp_authorize_project_transfer( action='create', originating_vendor_id=originating_vendor_id, originating_subaccount_id=originating_subaccount_id): return flaskify(response.create_error_response( code=error_const.ERROR_CODE_AUTHORIZATION, message=error_const.ERROR_MESSAGE_FORBIDDEN_USER, status=http_status_codes.FORBIDDEN)) return flaskify(project_transfer.create_transfer_job( body, identity, originating_vendor_id=originating_vendor_id, originating_subaccount_id=originating_subaccount_id, originating_artist_id=originating_artist_id)) @app.route('/transfer/job/', methods=['GET']) @pdp_authorization.authorize_transfer_job(action='view') def get_transfer_job(job_id, job, identity): """Return a single transfer job with nested products.""" return flaskify(project_transfer.get_transfer_job(job_id)) @app.route('/transfer/job/', methods=['PATCH']) def update_transfer_job(job_id): """Partially update a transfer job's mutable fields. Service-to-service: requires a valid JWT identity but no Grass scoping. Accepted body keys: status, failure_reason, revenue_cutoff_date, transfer_completed_on, sfn_execution_arn. """ context = getattr(g, 'request_context', None) identity = getattr(context, 'jwt_identity_id', None) if context else None if not identity: return flaskify(response.create_error_response( code=error_const.ERROR_CODE_AUTHORIZATION, message='Missing or invalid JWT identity.', status=http_status_codes.UNAUTHORIZED)) body = request.get_json(force=True) or {} return flaskify(project_transfer.update_transfer_job(job_id, body)) @app.route('/transfer/job/', methods=['DELETE']) @pdp_authorization.authorize_transfer_job(action='create') def delete_transfer_job(job_id, job, identity): """Soft-delete a QUEUED transfer job. History rows are preserved.""" return flaskify(project_transfer.delete_transfer_job(job_id, identity)) @app.route('/transfer/job//products', methods=['GET']) @pdp_authorization.authorize_transfer_job(action='view') def get_transfer_job_products(job_id, job, identity): """Return product transfer history rows for a job.""" return flaskify(project_transfer.get_transfer_job_products(job_id)) @app.route('/transfer/job//products', methods=['PATCH']) @pdp_authorization.authorize_transfer_job( action='create') def set_destination_artists(job_id, job, identity): """Bulk-set destination_artist_id on snapshot rows for a job. Called by the transfer SFN once destination artists have been resolved on the destination vendor; the SFN can batch all release updates for a job into a single call. Service-to-service: requires a valid JWT identity but no Grass scoping (the lambdas do not act on behalf of an end user). PDP authorization is enforced via the lambda's M2M identity, which carries the transfer_creator role assignment. Body: { "updates": [ { "release_id": int, "destination_artist_id": int }, ... ] } """ body = request.get_json(force=True) or {} return flaskify( project_transfer.set_destination_artists(job_id, body)) @app.route('/transfer/batch/execute', methods=['POST']) @pdp_authorization.authorize_transfer_job( action='execute_batch', job_scoped=False) def execute_transfer_batch(identity): """Trigger the Step Functions execution that processes all QUEUED transfer jobs. Backs the operator "Process all jobs" button. Gated by the project_transfer.execute_batch action, which maps to the any-tenant transfer_operator derived role. The action is not scoped to any one vendor, so the PDP call is made with no tenant attributes; the transfer_operator role grants access globally. TODO(PORT-69): wire the SFN start_execution call once the state machine ARN is provisioned. Until then this endpoint returns 501 to signal that the SFN trigger is not yet implemented. """ return flaskify(response.create_error_response( code='not_implemented', message='SFN trigger not yet implemented (PORT-69 follow-up).', status=501)) @app.route('/transfer/job//execute-content-transfer', methods=['POST']) @pdp_authorization.authorize_transfer_job(action='execute_batch', job_scoped=False) def execute_content_transfer(job_id, identity): """Execute the content transfer for a job.""" return flaskify(project_transfer.execute_content_transfer(job_id, identity)) @app.route('/transfer/job//attachments', methods=['GET']) @pdp_authorization.authorize_transfer_job(action='execute_batch', job_scoped=False) def get_transfer_job_attachments(job_id, identity): """Return UPCs and ISRCs for every release attached to a transfer job. SFN-only. Called by the accounting lambda (Step 7) to resolve which UPCs and ISRCs to bulk-remove from the originating account's contract terms and bulk-add to the destination contracts in ows-royalties. Gated by the project_transfer.execute_batch action (transfer_operator derived role). Service-to-service: no Grass headers required. """ return flaskify(project_transfer.get_transfer_job_attachments(job_id))