"""Application 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. Please note: the Orchard uses the term handlers over views as convention for clarity See: owsresponse for more details. """ from http import HTTPStatus from botocore.exceptions import ClientError from flask import Response, g, jsonify, request from jsonschema import ValidationError, validate from owsresponse import response from owsresponse.adaptors.flask import flaskify from requests import RequestException from sqlalchemy.exc import SQLAlchemyError from werkzeug.exceptions import HTTPException from spec.jsonschema import assets as assets_schema, jobs as jobs_validation from spec.jsonschema.approval import CHANGE_APPROVAL_POST_SCHEMA from spec.jsonschema.jobs import ( MANAGE_JOB_POST_SCHEMA, build_query_param_schema_get_jobs, ) from spec.jsonschema.product_video import DISASSOCIATE_TRACKS_POST_SCHEMA from spec.jsonschema.product_video_sizing_rules import SIZING_RULE_POST_SCHEMA from spec.jsonschema.thumbnails import UPLOAD_CUSTOM_THUMBNAIL_POST_SCHEMA from video import config from video.api import app from video.constants import error as error_constants, header, job_types from video.constants.thumbnails import ( THUMBNAILS_COOKIE_DOMAIN, THUMBNAILS_COOKIE_POLICY_RESOURCE, ) from video.exceptions import OARequired from video.logic import ( approval as approval_logic, asset as asset_logic, cloudfront as cloudfront_logic, context as context_logic, ingest as ingest_logic, job as job_logic, metadata as metadata_logic, product as product_logic, product_video_sizing_rule as product_video_sizing_rules_logic, stream as stream_logic, thumbnails as thumbnails_logic, track as track_metadata, video_asset, video_dashboard_item as video_dashboard_item_logic, video_product_copy as video_product_copy_logic, video_type as video_type_logic, ) from video.utils.date_time_encoder import DateTimeEncoder from video.utils.handlers import is_jwt_identity_authorized @app.route(config.HEALTH_CHECK, methods=["GET"]) def health() -> Response: """Check the health of the application.""" return jsonify({"status": "ok"}) @app.route("/job/", methods=["GET"]) def get_job(job_id: int) -> Response: """Get a single job by its id. Args: job_id (int): Id of job to get. """ get_jobs_response = job_logic.get_jobs({"id": job_id}) if not get_jobs_response: return flaskify( response.create_error_response( code=error_constants.ERROR_CODE_NOT_FOUND, status=HTTPStatus.NOT_FOUND, message="Job not found", ) ) job = get_jobs_response[0] response_schema = jobs_validation.build_response_schema_jobs(job["type"]) validate(job, response_schema) return jsonify(job) @app.route("/jobs", methods=["GET"]) def get_jobs() -> Response: """Get all jobs meeting the filtering criteria in the query params.""" get_jobs_params = request.args.to_dict() validate(get_jobs_params, build_query_param_schema_get_jobs()) job_id = get_jobs_params.get("job_id") job_parent_id = get_jobs_params.get("job_parent_id") job_data_filters = { "id": job_id and int(job_id), "parent_id": job_parent_id and int(job_parent_id), } get_jobs_response = job_logic.get_jobs(job_data_filters) for job in get_jobs_response: job_type = job["type"] if job_type in job_types.DEPRECATED_JOB_TYPES: continue if job_type == job_types.TRANSFER_FROM_BROWSER_TO_S3: keys_to_pop = ["key_path", "bucket"] inputs = job.get("inputs", {}) outputs = job.get( "inputs", {} ) # TODO: likely a copy-paste bug, should be job.get("outputs", {}) for key_to_pop in keys_to_pop: inputs.pop(key_to_pop, None) outputs.pop(key_to_pop, None) response_schema = jobs_validation.build_response_schema_jobs(job_type) validate(job, response_schema) return jsonify( { "items": get_jobs_response, "pagination": { "type": "standard", "offset": "0", "limit": config.QUERY_RECORD_LIMIT, "total_records": len(get_jobs_response), }, } ) @app.route("/job", methods=["POST"]) def manage_job() -> Response: """Create a job and add inputs, outputs, and statuses.""" data = request.get_json() validate(data, MANAGE_JOB_POST_SCHEMA) return jsonify(job_logic.manage_job(data)) @app.route("/new_assets/", methods=["GET"]) def get_new_assets(dashboard_item_id: int) -> Response: """Get new assets from video dashboard item table. Args: dashboard_item_id (int): ID of last video dashboard item encoded. """ offset = int(request.args.get("offset", config.PAGE_OFFSET_DEFAULT)) items, total_records = video_dashboard_item_logic.get_new_assets( dashboard_item_id, offset ) return jsonify( { "items": items, "pagination": { "type": "standard", "offset": offset, "total_records": total_records, }, } ) @app.route("/max_dashboard_item_id", methods=["GET"]) def get_max_dashboard_item_id() -> Response: """Get most recent id from video dashboard item table.""" get_max_dashboard_item_id_response = ( video_dashboard_item_logic.get_max_dashboard_item_id() ) return jsonify({"max_dashboard_item_id": get_max_dashboard_item_id_response}) @app.route("/stream/product//hls", methods=["GET"]) def get_hls_streaming_url(product_id: int) -> Response: """Get a HLS URL for a product. Args: product_id (int): ID of the product. """ url = stream_logic.get_hls_streaming_url( product_id, referrer=request.referrer, user_id=request.headers.get(header.ORCHARD_USER_ID), ) return flaskify(response.Response({"url": url})) @app.route("/create_product", methods=["POST"]) def create_product() -> Response: """Create a product.""" data = request.get_json() account_type = request.headers.get(header.GRASS_ACCOUNT_TYPE) account_id = request.headers.get(header.GRASS_ACCOUNT_ID) user_id = request.headers.get(header.ORCHARD_USER_ID) if user_id and user_id.startswith(header.OA_USER_PREFIX): subaccount_id = data.get("subaccount_id") account_id = data.get("account_id") if subaccount_id: account_id = subaccount_id account_type = header.GRASS_ACCOUNT_TYPE_SUBACCOUNT else: account_type = header.GRASS_ACCOUNT_TYPE_VENDOR return flaskify( response.Response(product_logic.create_product(data, account_type, account_id)) ) @app.route("/product/", methods=["DELETE"]) def delete_product(product_id: int) -> Response: """Delete a product.""" account_type = request.headers.get(header.GRASS_ACCOUNT_TYPE) account_id = request.headers.get(header.GRASS_ACCOUNT_ID) return flaskify( response.Response(product_logic.delete(product_id, account_type, account_id)) ) @app.route("/product/disassociate-tracks", methods=["POST"]) def disassociate_tracks() -> Response: """Clear associated_track_id on video products referencing the given tracks.""" data = request.get_json() validate(data, DISASSOCIATE_TRACKS_POST_SCHEMA) product_logic.disassociate_tracks(data["track_ids"]) return flaskify(response.Response()) @app.route("/metadata//track", methods=["GET"]) def get_tracks_metadata(product_id: int) -> Response: """Get Video tracks metadata.""" tuids_str = request.args.get("tuids") tuids: list[int] | None = ( [int(t) for t in tuids_str.split(",")] if tuids_str else None ) get_track_metadata_response = track_metadata.get_tracks_metadata(product_id, tuids) return flaskify(response.Response(get_track_metadata_response)) @app.route("/metadata//track", methods=["POST"]) def create_track_metadata(product_id: int) -> Response: """Save track metadata.""" video_data = request.get_json() set_track_metadata_response = track_metadata.create_track_metadata( product_id, video_data ) return flaskify( response.Response(set_track_metadata_response, status=HTTPStatus.CREATED) ) @app.route("/metadata/", methods=["GET"]) def load_metadata(product_id: int) -> Response: """Load metadata.""" account_type = request.headers.get(header.GRASS_ACCOUNT_TYPE) account_id = request.headers.get(header.GRASS_ACCOUNT_ID) return flaskify( response.Response( metadata_logic.load_metadata(product_id, account_type, account_id) ), encoder=DateTimeEncoder, ) @app.route("/metadata/", methods=["POST"]) def save_metadata(product_id: int) -> Response: """Save metadata.""" data = request.get_json() account_type = request.headers.get(header.GRASS_ACCOUNT_TYPE) account_id = request.headers.get(header.GRASS_ACCOUNT_ID) user_id = request.headers.get(header.ORCHARD_USER_ID) if ( user_id and user_id.startswith(header.OA_USER_PREFIX) and (data.get("subaccount_id") or data.get("account_id")) ): # This means this is Switchboard Orchard ingest impersonating # a specific vendor or subaccount subaccount_id = data.get("subaccount_id") account_id = data.get("account_id") if subaccount_id: account_id = subaccount_id account_type = header.GRASS_ACCOUNT_TYPE_SUBACCOUNT else: account_type = header.GRASS_ACCOUNT_TYPE_VENDOR return flaskify( response.Response( metadata_logic.save_metadata( product_id, data, account_type, account_id, user_id ) ), encoder=DateTimeEncoder, ) @app.route("/migrate_artist/", methods=["PUT"]) def migrate_artist(product_id: int) -> Response: """Update the primary_artist of a product_video. Args: product_id (int): Product id of video product. Returns: Response: Flask response. """ data = request.get_json() account_type = request.headers.get(header.GRASS_ACCOUNT_TYPE) account_id = request.headers.get(header.GRASS_ACCOUNT_ID) artist_id = data.get("artist_id") if not artist_id: return flaskify( response.create_error_response( code=error_constants.ERROR_CODE_VALIDATION_ERROR, message=error_constants.ERROR_MESSAGE_MISSING_ARTIST_ID, ) ) return flaskify( response.Response( product_logic.migrate_artist( artist_id, product_id, account_type, account_id ) ), encoder=DateTimeEncoder, ) @app.route("/metadata/bulk-ingest", methods=["POST"]) def bulk_ingest_metadata() -> Response: """Bulk ingest metadata.""" data = request.get_json() return flaskify( response.Response(metadata_logic.bulk_ingest_metadata(data)), encoder=DateTimeEncoder, ) @app.route("/product//submit", methods=["POST"]) def submit(product_id: int) -> Response: """User submitting their product for review.""" account_type = request.headers.get(header.GRASS_ACCOUNT_TYPE) account_id = request.headers.get(header.GRASS_ACCOUNT_ID) return flaskify( response.Response(product_logic.submit(product_id, account_type, account_id)), encoder=DateTimeEncoder, ) @app.route("/product//approval", methods=["GET"]) def get_approvals(product_id: int) -> Response: """OA User approval.""" return flaskify(response.Response(approval_logic.get(product_id))) @app.route("/product//approval", methods=["POST"]) def change_approval(product_id: int) -> Response: """OA User approval.""" orchard_user_id = request.headers.get(header.ORCHARD_USER_ID, "") must_be_oa(orchard_user_id) data = request.get_json() validate(data, CHANGE_APPROVAL_POST_SCHEMA) return flaskify( response.Response( approval_logic.change( product_id, orchard_user_id, data, ) ), encoder=DateTimeEncoder, ) @app.route("/product//get_available_channels", methods=["GET"]) def get_available_channels(product_id: int) -> Response: """Get available channels.""" account_type = request.headers.get(header.GRASS_ACCOUNT_TYPE) account_id = request.headers.get(header.GRASS_ACCOUNT_ID) user_id = request.headers.get(header.ORCHARD_USER_ID) return flaskify( response.Response( metadata_logic.get_available_channels( product_id, account_type, account_id, user_id ) ) ) @app.route("/product//migrate_project", methods=["POST"]) def migrate_project(product_id: int) -> Response: """Transfer the project of product.""" account_type = request.headers.get(header.GRASS_ACCOUNT_TYPE) account_id = request.headers.get(header.GRASS_ACCOUNT_ID) data = request.get_json() project_id = data.get("project_id") if not project_id: return flaskify( response.create_error_response( code=error_constants.ERROR_CODE_VALIDATION_ERROR, message=error_constants.ERROR_MESSAGE_MISSING_PROJECT_ID, ) ) return flaskify( response.Response( metadata_logic.migrate_project( product_id, project_id, account_type, account_id ) ), encoder=DateTimeEncoder, ) @app.route("/project//get_available_channels", methods=["POST"]) def get_available_channels_by_project_id(project_id: int) -> Response: """Get available channels for project.""" if request.headers.get(header.GRASS_ACCOUNT_TYPE) or request.headers.get( header.GRASS_ACCOUNT_ID ): return flaskify( response.create_fatal_response( message=error_constants.ERROR_CODE_BAD_GRASS_REQUEST ) ) data = request.get_json() subaccount_id = data.get("subaccount_id") account_id = data.get("account_id") if subaccount_id: account_id = subaccount_id account_type = header.GRASS_ACCOUNT_TYPE_SUBACCOUNT else: account_type = header.GRASS_ACCOUNT_TYPE_VENDOR return flaskify( response.Response( metadata_logic.get_available_channels_by_project_id( project_id, account_type, account_id, ) ) ) def _is_internal_caller() -> bool: """True for OA admins and internal services, False for Workstation users. The re-encode workflow is an admin and automation tool, not a self-service action. Workstation vendor and label users always reach the service through grass, which stamps a Grass-Account-Type or an alw: label Orchard-User-Id; OA admins carry an oa: id, and internal services carry neither. """ orchard_user_id = request.headers.get(header.ORCHARD_USER_ID, "") if orchard_user_id.startswith(header.OA_USER_PREFIX): return True has_grass_account = bool(request.headers.get(header.GRASS_ACCOUNT_TYPE)) return not has_grass_account and not orchard_user_id @app.route("/workflow/", methods=["POST"]) def setup_workflow(workflow_job_type: str) -> Response: """Create skeleton of the jobs to be run in processing pipeline. Note: a workflow is a high level definition of a set of video pipeline jobs to be used passed to the AWS State Machine so that the daemon has all the correct ids and job types to report back to ows-video about. Args: workflow_job_type: a string that identifies a set of jobs. """ workflows = { job_types.WORKFLOW_INGEST_FROM_BROWSER: ( job_logic.setup_ingest_from_browser_workflow ), job_types.WORKFLOW_INGEST_FROM_GOOGLE_DRIVE: ( job_logic.setup_ingest_from_google_drive_workflow ), job_types.WORKFLOW_INGEST_FROM_DROPBOX: ( job_logic.setup_ingest_from_dropbox_workflow ), job_types.WORKFLOW_INGEST_FROM_S3: (job_logic.setup_ingest_from_s3_workflow), job_types.WORKFLOW_APPROVAL: job_logic.setup_approval_workflow, job_types.WORKFLOW_INGESTION_REENCODE: ( job_logic.setup_ingestion_reencode_workflow ), } if workflow_job_type not in workflows.keys(): return flaskify( response.create_error_response( code=error_constants.ERROR_CODE_NOT_FOUND, status=HTTPStatus.NOT_FOUND, message="Workflow not found", ) ) if ( workflow_job_type == job_types.WORKFLOW_INGESTION_REENCODE and not _is_internal_caller() ): return flaskify( response.create_error_response( error_constants.ERROR_DONT_HAVE_PERMISSIONS, error_constants.ERROR_MESSAGE_FORBIDDEN_USER, status=HTTPStatus.FORBIDDEN, ) ) request_data = request.get_json() workflow_setup_response = workflows[workflow_job_type](request_data) for job in workflow_setup_response: response_schema = jobs_validation.build_response_schema_jobs(job["type"]) validate(job, response_schema) return jsonify(workflow_setup_response) @app.route("/thumbnails/job/", methods=["GET"]) def get_available_thumbnails(job_id: int) -> Response: """Get the list of available thumbnails for a job. Args: job_id (int): ID of the job. """ resp = flaskify( response.Response( { "items": thumbnails_logic.get_available_thumbnails( job_id, image_size=request.args.get("image_size") ) } ) ) cloudfront_logic.set_signed_cookies( resp, THUMBNAILS_COOKIE_DOMAIN, THUMBNAILS_COOKIE_POLICY_RESOURCE ) return resp @app.route("/thumbnails/product/", methods=["GET"]) def get_product_thumbnail(product_id: int) -> Response: """Get the thumbnail associated with a product. Args: product_id (int): ID of the product. """ url = thumbnails_logic.get_product_thumbnail( product_id, image_size=request.args.get("image_size"), expire_at=int(request.args.get("expire_at", 0)), ) return flaskify(response.Response({"url": url})) @app.route("/thumbnails/upload-token", methods=["POST"]) def generate_upload_token_for_custom_thumbnail() -> Response: """Generate a S3 token to upload a custom thumbnail.""" data = request.get_json() if not isinstance(data, dict): return flaskify( response.create_error_response( message="request body must be a JSON object", status=HTTPStatus.BAD_REQUEST, code=error_constants.ERROR_CODE_VALIDATION_ERROR, ) ) validate(data, UPLOAD_CUSTOM_THUMBNAIL_POST_SCHEMA) return flaskify( response.Response( thumbnails_logic.generate_upload_token_for_custom_thumbnail(data) ) ) @app.route("/delivery//status", methods=["GET"]) def get_asset_delivery_status(_: int) -> Response: """Get status of ripper ripping asset.""" raise NotImplementedError( "The requested endpoint is not implemented and can be removed." ) @app.route( "/workflow//transfer//progress", methods=["GET"], ) def get_cloud_transfer_progress(workflow_job_id: int, filename: str) -> Response: """Get cloud transfer progress.""" return jsonify(job_logic.get_cloud_transfer_progress(workflow_job_id, filename)) @app.route("/ingest", methods=["POST"]) def ingest() -> Response: """Check if there are files to ingest and if so setup the workflows.""" return flaskify( response.Response({"items": ingest_logic.ingest(request.get_json())}), encoder=DateTimeEncoder, ) @app.route("/product/assets", methods=["GET"]) def get_product_assets() -> Response: """Get the assets associated with a product.""" product_ids = request.args.get("product_ids") if not product_ids: return flaskify( response.create_error_response( code=error_constants.ERROR_CODE_VALIDATION_ERROR, message="Product IDs are missing", status=HTTPStatus.BAD_REQUEST, ) ) try: product_id_list = [int(p) for p in product_ids.split(",")] except ValueError: return flaskify( response.create_error_response( code=error_constants.ERROR_CODE_VALIDATION_ERROR, message="product_ids must be integers", status=HTTPStatus.BAD_REQUEST, ) ) return flaskify(response.Response(asset_logic.get_product_assets(product_id_list))) @app.route("/assets-bulk", methods=["GET"]) def get_video_asset_details_for_bulk_products() -> Response: """Get asset details for many products. Args: product_ids: List of product IDs separated by comma, up to 1000. asset_types: List of video asset types separated by comma. Returns: flask.Response: Response body with asset details for many products. """ try: jwt_identity_id = g.request_context.jwt_identity_id if not jwt_identity_id: return flaskify( response.create_error_response( error_constants.ERROR_CODE_BAD_REQUEST, error_constants.ERROR_CODE_AUTHORIZATION, ) ) if not is_jwt_identity_authorized(jwt_identity_id): return flaskify( response.create_error_response( error_constants.ERROR_DONT_HAVE_PERMISSIONS, error_constants.ERROR_MESSAGE_FORBIDDEN_USER, status=HTTPStatus.FORBIDDEN, ) ) product_ids = request.args.get("product_ids") asset_types = request.args.get("asset_types") product_ids_list = ( [int(p) for p in product_ids.split(",")] if product_ids else [] ) asset_types_list = ( [asset_type.upper() for asset_type in asset_types.split(",")] if asset_types else [] ) validate(product_ids_list, assets_schema.BULK_ASSET_PRODUCT_IDS) validate(asset_types_list, assets_schema.BULK_ASSET_ASSET_TYPES) except (ValueError, ValidationError) as e: return flaskify( response.create_error_response( code=error_constants.ERROR_CODE_VALIDATION_ERROR, message=str(e) ) ) return flaskify( response.Response( asset_logic.get_product_assets(product_ids_list, asset_types_list) ), ) @app.route("/video-asset", methods=["POST"]) def upsert_video_asset() -> Response: """Upsert a video asset.""" return flaskify( response.Response(video_asset.upsert_video_asset(request.get_json())) ) @app.route("/video-asset/bulk-ingest", methods=["POST"]) def bulk_upsert_video_asset() -> Response: """Upsert a video asset.""" return flaskify( response.Response(video_asset.bulk_upsert_video_assets(request.get_json())) ) @app.route("/output_video_outer_resolutions", methods=["GET"]) def get_output_video_outer_sizing_rules() -> Response: """Get available output video resolutions, used for UI drop-down.""" return flaskify( response.Response( product_video_sizing_rules_logic.load_output_video_outer_resolutions() ) ) @app.route("/sizing_rules", methods=["GET"]) def get_all_sizing_rules() -> Response: """Get video sizing rules.""" return flaskify( response.Response(product_video_sizing_rules_logic.load_resolution_overrides()) ) @app.route("/sizing_rule/", methods=["GET"]) def get_sizing_rule(item_id: int) -> Response: """Get video sizing rule by id.""" return flaskify( response.Response( product_video_sizing_rules_logic.load_resolution_override(item_id) ) ) @app.route("/sizing_rule/", methods=["DELETE"]) def delete_sizing_rule(item_id: int) -> Response: """Delete video sizing rule by id.""" orchard_user_id = request.headers.get(header.ORCHARD_USER_ID, "") must_be_oa(orchard_user_id) return flaskify( response.Response( product_video_sizing_rules_logic.delete_sizing_rule( item_id, orchard_user_id ) ) ) @app.route("/sizing_rule", methods=["POST"]) def save_sizing_rule() -> Response: """Get video sizing by id.""" orchard_user_id = request.headers.get(header.ORCHARD_USER_ID, "") must_be_oa(orchard_user_id) data = request.get_json() validate(data, SIZING_RULE_POST_SCHEMA) return flaskify( response.Response( product_video_sizing_rules_logic.save_sizing_rule(data, orchard_user_id) ) ) @app.route("/create_bundle", methods=["POST"]) def create_bundle() -> Response: """Create Bundele Product.""" payload = request.get_json() audio_product_id = payload["audioProductId"] bundle_type = payload["bundleType"] selected_thumbnail_id = payload["selectedThumbnailId"] volume_data = payload["volumes"] product_id = video_product_copy_logic.create_bundle( audio_product_id, bundle_type, selected_thumbnail_id, volume_data ) return flaskify( response.Response({"product_id": product_id}, status=HTTPStatus.CREATED) ) @app.route("/video-types-all", methods=["GET"]) def get_all_video_types() -> Response: """Get all video types.""" return flaskify(response.Response(video_type_logic.get_all_video_types())) @app.after_request def after_request(resp: Response) -> Response: """Prepare the response.""" return resp @app.before_request def before_request() -> None: """Prepare for request.""" context_logic.create_context() @app.errorhandler(ValidationError) def validation_error_exception_handler(validation_error: ValidationError) -> Response: """Handle all thrown ValidationErrors. Args: validation_error (ValidationError): A thrown ValidationError. Returns: flask.Response: Error response. """ return flaskify( response.create_error_response( code=error_constants.ERROR_CODE_VALIDATION_ERROR, message={ "validator": validation_error.validator, "validator_value": validation_error.validator_value, "message": validation_error.message, }, status=HTTPStatus.BAD_REQUEST, ) ) @app.errorhandler(SQLAlchemyError) def sqlalchemy_error_exception_handler(error: SQLAlchemyError) -> Response: """Handle all SQLAlchemyErrors.""" g.log.exception(error) return flaskify(response.create_fatal_response()) @app.errorhandler(ClientError) def botocore_client_error_exception_handler(error: ClientError) -> Response: """Handle botocore ClientErrors.""" if (error.response.get("Error") or {}).get("Code") == "NoSuchKey": g.log.warning(error) return flaskify( response.create_error_response( code=error_constants.ERROR_CODE_BAD_REQUEST, message="Oopsies, I don't know how to respond to that ¯\\_(ツ)_/¯", status=HTTPStatus.BAD_REQUEST, ) ) return exception_handler(error) @app.errorhandler(Exception) def exception_handler(error: Exception) -> Response: """Handle error when uncaught exception is raised. Default exception handler. Note: Exception will also be sent to Sentry if config.SENTRY is set. Returns: flask.Response: A 500 response with JSON 'code' & 'message' payload. """ message = "I have erred and am unable to complete your request." g.log.exception(error) return flaskify(response.create_fatal_response(message)) @app.errorhandler(HTTPException) def http_exception_handler(exc: HTTPException) -> Response: """Handle error when HTTPException is raised. Returns: Response: A HTTP status code response with JSON 'code' & 'message' payload. """ if exc.code is not None and 400 <= exc.code < 500: g.log.warning(exc) else: g.log.exception(exc) _status_error_codes = { 400: "bad_request", 403: "authorization_error", 404: "not_found", } default_error_code = _status_error_codes.get( exc.code or 0, str(exc.description).replace(" ", "_") ) error_code = getattr(exc, "error_code", default_error_code) message = ( exc.description if isinstance(exc.description, (list, dict)) else str(exc.description) ) return flaskify( response.create_error_response( code=error_code, message=message, status=exc.code or 500, ) ) @app.errorhandler(RequestException) def request_exception_handler(error: RequestException) -> Response: """Handle request errors.""" g.log.exception(error) return flaskify( response.create_error_response( code=type(error).__name__, message=str(error), status=HTTPStatus.BAD_GATEWAY, ) ) def must_be_oa(user_id: str) -> None: """Raise OARequired if not an OA user.""" if not user_id.startswith("oa:"): raise OARequired()