"""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: oto.response for more details. """ import json import re from functools import wraps from json import JSONDecodeError from flask import g, jsonify, request from owsresponse import response from owsresponse import status as owsresponse_status from owsresponse.adaptors.flask import flaskify from owsrequest import flask_request from werkzeug.exceptions import HTTPException from product_review import config from product_review.api import app from product_review.constants.auth import PDP_AUTH_GLOBAL_FF from product_review.constants.auth import QUEUE_GLOBAL from product_review.constants import error from product_review.models import splitio from product_review.logic import account_note as account_note_logic from product_review.logic import approval as approval_logic from product_review.logic import canned_response as canned_response_logic from product_review.logic import product as product_logic from product_review.logic import rejection as rejection_logic from product_review.logic import review_queue as review_queue_logic from product_review.logic import unsubmit as unsubmit_logic from product_review.logic import score_history as score_history_logic from product_review.util import authorization from product_review.util import exception as exception_util _LOWER_SNAKE_CASE_ALPHANUMERIC_PATTERN = re.compile(r"^(?:[a-z0-9]+)(?:_[a-z0-9]+)*$") def _ensure_lower_snake_cased_alphanumeric(value): """Ensure value is a lower snake cased alphanumeric string. Raises exception if value is not a lower snake cased alphanumeric string. """ if isinstance(value, str) and _LOWER_SNAKE_CASE_ALPHANUMERIC_PATTERN.fullmatch( value ): return raise exception_util.raise_exception_for_ows_response( status=owsresponse_status.BAD_REQUEST, message=f"'{value}' must be a lower snake cased alphanumeric string.", ) def require_header_orchard_identity_id(f): """Require presence of Orchard-Identity-Id header.""" @wraps(f) def decorated_function(*args, **kwargs): validate_request_info(headers=["Orchard-Identity-Id"]) return f(*args, **kwargs) return decorated_function def require_standalone_authorization( *, action, resource_type, resource_id=0, feature=PDP_AUTH_GLOBAL_FF): """Decorator to verify standalone access to a resource. 1. First, conduct an authorization check via access rules, based on profile headers. 2. Next, if the preliminary check failed and the ff is enabled, check the PDP resource policy via the PDP authorization SDK. Args: action (string): action to perform (view/ review) resource_type (string): type of resource resource_id: (number): optional ID of resource feature (string): optional name of split.io feature flag Returns: function: The decorated route handler. If authorized, return result of the original route handler. Alternatively, Return a Flask response with - 401 Unauthorized: Rule-based (non-PDP) authorization failed - 403 Forbidden: PDP authorization failed """ def decorator(func): @wraps(func) def decorated_function(*args, **kwargs): access_rule_decision = flask_request.verify_rules_access_standalone(request) pdp_enabled = splitio.is_enabled_pdp_auth_check(feature) if not access_rule_decision and not pdp_enabled: return flaskify( response.create_error_response( code=error.ERROR_CODE_AUTHORIZATION, message='Unauthorized', status=401, ) ) # once above access rule decision is made, conditionally authorize via PDP elif pdp_enabled: authorized = authorization.pdp_authorize_resource_without_attributes( action=action, resource_id=resource_id, resource_type=resource_type ) if not authorized: return flaskify( response.create_error_response( code=error.ERROR_CODE_FORBIDDEN, message=error.ERROR_MESSAGE_FORBIDDEN_USER, status=403 )) return func(*args, **kwargs) return decorated_function return decorator def require_auth_rules_access_standalone(f): """Decorator to check owsrequest rules access standalone authorization. Verifies that the user has the necessary access permissions for the endpoint. If authorization fails, returns a 401 Unauthorized response. Args: f: The function to decorate. Returns: The decorated function that checks access rules before execution. """ @wraps(f) def decorated_function(*args, **kwargs): access_rule_decision = flask_request.verify_rules_access_standalone(request) if not access_rule_decision: return flaskify(response.create_error_response( code=error.ERROR_CODE_AUTHORIZATION, message='Unauthorized', status=401, )) return f(*args, **kwargs) return decorated_function def validate_request_info(params=[], headers=[]): """Validate presence of headers and parameters.""" validation_errors = [] header_to_request_context_attribute = { "Orchard-Identity-Id": "identity_id", "Orchard-Profile-Type": "profile_type", "Orchard-Profile-Id": "profile_id", } for param in params: if not request.is_json or not request.json or not request.json.get(param): validation_errors.append(f"[{param}] missing from POST json") for header in headers: if getattr(g.request_context, header_to_request_context_attribute[header]): continue validation_errors.append(f"[{header}] missing from request headers") if validation_errors: exception_util.raise_exception_for_ows_response( status=owsresponse_status.BAD_REQUEST, code=None, message=", ".join(validation_errors), ) return None @app.route(config.HEALTH_CHECK, methods=["GET"]) def health(): """Check the health of the application.""" return jsonify({"status": "ok"}) @app.route("/hello-authorized/", methods=["GET"]) @require_standalone_authorization(action="view", resource_type=QUEUE_GLOBAL) def authorization_health(): """Check the health of the authorization system.""" return jsonify({"status": "authorization ok"}) @app.route("/review-queue", methods=["GET"]) @require_standalone_authorization(action="view", resource_type=QUEUE_GLOBAL) def get_review_queue(): """Get list of items in queue. Returns: Response: Flask response. """ page_offset = request.args.get("page_offset") page_limit = request.args.get("page_limit") status = request.args.get("status") review_queue_id = request.args.get("review_queue_id") product_id = request.args.get("product_id") data = review_queue_logic.get_review_queue_items( status=status, page_limit=page_limit, page_offset=page_offset, review_queue_id=review_queue_id, product_id=product_id, ) return flaskify(data) @app.route("/review-queue/dataloader", methods=["POST"]) @require_standalone_authorization(action="view", resource_type=QUEUE_GLOBAL) def get_review_queue_items_dataloader(): """Fetch review queue items by review queue IDs. Returns: Response: Flask response. """ review_queue_ids = request.get_json() data = review_queue_logic.get_review_queue_items_by_id(review_queue_ids) return flaskify(data) @app.route("/review-queue/target-group/dataloader", methods=["POST"]) @require_standalone_authorization(action="view", resource_type=QUEUE_GLOBAL) def get_target_group_by_item_ids_dataloader(): """Fetch target groups off of review queue item. Returns: Response: Flask response. """ review_queue_ids = request.get_json() data = review_queue_logic.get_target_group_by_item_id(review_queue_ids) return flaskify(data) @app.route("/review-queue/product/", methods=["POST"]) @require_standalone_authorization(action="review", resource_type=QUEUE_GLOBAL) def insert_review_queue(product_id): """Insert a product into the review queue.""" queue_name = request.args.get("queue_name") or "initial" identity_id = request.args.get("identity_id") or None submission_datetime = request.args.get("submission_datetime") or None new_record = review_queue_logic.insert_review_queue_item( product_id, queue_name, identity_id, submission_datetime) return flaskify(new_record) @app.route("/review-queue/product/", methods=["DELETE"]) @require_standalone_authorization(action="review", resource_type=QUEUE_GLOBAL) def unsubmit_product(product_id): """Remove a product from the queue.""" return unsubmit_product_with_reason(product_id, "user_unsubmit") @app.route("/review-queue/product//unsubmit", methods=["POST"]) @require_standalone_authorization(action="review", resource_type=QUEUE_GLOBAL) def unsubmit_product_with_reason(product_id, reason=None): """Remove a product from the queue with a reason.""" if reason is None: validate_request_info(params=["reason"]) reason = request.json.get("reason") result = unsubmit_logic.unsubmit_product(product_id, reason) if isinstance(result, response.Response): return flaskify(result) return flaskify(response.Response(status=204)) @app.route("/review-queue/product//eligibility-check", methods=["GET"]) @require_standalone_authorization(action="view", resource_type=QUEUE_GLOBAL) def review_queue_product_eligibility_check(product_id): """Return a response for a product inclusion eligibility check.""" check_result = product_logic.check_product_queue_eligibility(product_id) result = "eligible" if check_result else "ineligible" return flaskify(response.Response(message=result)) @app.route("/review-queue//lock", methods=["PATCH"]) @require_standalone_authorization(action="review", resource_type=QUEUE_GLOBAL) def lock_review_queue_item(review_queue_id): """Lock a review queue item. Returns: Response: Flask response. """ return flaskify(response.Response(review_queue_logic.lock(review_queue_id))) @app.route("/review-queue//unlock", methods=["PATCH"]) @require_standalone_authorization(action="review", resource_type=QUEUE_GLOBAL) def unlock_review_queue_item(review_queue_id): """Unlock a review queue item. Returns: Response: Flask response. """ return flaskify(response.Response(review_queue_logic.unlock(review_queue_id))) @app.route("/review-queue//queue", methods=["PATCH"]) @require_standalone_authorization(action="review", resource_type=QUEUE_GLOBAL) def move_review_queue_item_to_queue(review_queue_id): """Move a review queue item to a different queue. Returns: Response: Flask response. """ validate_request_info( params=["queue_name"], headers=["Orchard-Identity-Id"], ) queue_name = request.json.get("queue_name") move_note = request.json.get("move_note") moved_to_target_id = request.json.get("moved_to_target_id") _ensure_lower_snake_cased_alphanumeric(queue_name) return flaskify( response.Response( review_queue_logic.move_review_queue_item_to_queue( review_queue_id=review_queue_id, queue_name=queue_name, move_note=move_note, moved_to_target_id=moved_to_target_id, ) ) ) @app.route("/review-queue//queue/move-history", methods=["GET"]) @require_standalone_authorization(action="view", resource_type=QUEUE_GLOBAL) def get_review_queue_item_queue_move_history(review_queue_id): """Get the queue move history for a review queue item. Returns: Response: Flask response. """ return flaskify( response.Response(review_queue_logic.get_queue_move_history(review_queue_id)) ) @app.route("/approve/", methods=["POST"]) @require_standalone_authorization(action="review", resource_type=QUEUE_GLOBAL) def approve_by_id(review_queue_id): """Approve a product by id.""" validate_request_info( headers=["Orchard-Identity-Id", "Orchard-Profile-Type", "Orchard-Profile-Id"], ) request_data = request.json if request.data else {} approval_logic.approve_via_workflow( review_queue_id, g.request_context.identity_id, request_data.get("note"), request_data.get("canned_response_id"), request_data.get("tracks"), request_data.get("canned_response_ids") or [], ) return flaskify(response.Response()) @app.route("/review-queue//approval", methods=["POST"]) @require_standalone_authorization(action="review", resource_type=QUEUE_GLOBAL) def create_approval(review_queue_id): """Create an approval for a review queue item.""" validate_request_info(params=["user_id"]) approval_logic.approve_review_queue_item( review_queue_id, request.json.get("note"), request.json.get("user_id"), request.json.get("created_datetime") or None, ) return flaskify(response.Response()) @app.route("/review-queue//rejection", methods=["POST"]) @require_standalone_authorization(action="review", resource_type=QUEUE_GLOBAL) def create_rejection(review_queue_id): """Create a rejection for a review queue item.""" validate_request_info(params=["user_id", "note"]) rejection_logic.reject_review_queue_item( review_queue_id, request.json.get("note"), request.json.get("user_id"), request.json.get("created_datetime") or None, request.json.get("will_not_deliver") or False, ) return flaskify(response.Response()) @app.route("/reject/", methods=["POST"]) @require_standalone_authorization(action="review", resource_type=QUEUE_GLOBAL) def reject_by_id(review_queue_id): """Reject by review queue id.""" validate_request_info( params=["note"], headers=["Orchard-Identity-Id", "Orchard-Profile-Type", "Orchard-Profile-Id"], ) rejection_logic.reject_via_workflow( review_queue_id, g.request_context.identity_id, request.json.get("note"), request.json.get("canned_response_ids", []), request.json.get("will_not_deliver", False), ) return flaskify(response.Response()) @app.route("/reject/", methods=["GET"]) @require_auth_rules_access_standalone def get_rejection(product_id): """Get rejection notes for the given product id.""" return flaskify(rejection_logic.get_rejection(product_id)) @app.route("/review-history/", methods=["GET"]) @require_standalone_authorization(action="view", resource_type=QUEUE_GLOBAL) def get_product_history(product_id): """Get review history for the given product id.""" page_offset = request.args.get("page_offset") page_limit = request.args.get("page_limit") return flaskify( review_queue_logic.get_product_review_history( product_id, page_offset, page_limit ) ) @app.route("/status/", methods=["GET"]) @require_standalone_authorization(action="view", resource_type=QUEUE_GLOBAL) def get_status(product_id): """Get the current state for the given product id.""" return flaskify(review_queue_logic.get_status(product_id)) @app.route("/review//email-notification", methods=["POST"]) @require_standalone_authorization(action="review", resource_type=QUEUE_GLOBAL) def send_email_notification(review_id): """Send email for completed review id.""" return flaskify(review_queue_logic.send_email_notification(review_id)) @app.route("/review//apply-revisions-legacy", methods=["POST"]) @require_standalone_authorization(action="review", resource_type=QUEUE_GLOBAL) def apply_revisions_if_applicable(review_id): """Apply revisions to a product in vapi. Returns: Response: Flask response. """ return flaskify( response.Response(approval_logic.apply_revisions_if_applicable(review_id)) ) @app.route("/target-groups", methods=["GET"]) @require_standalone_authorization(action="view", resource_type=QUEUE_GLOBAL) def get_all_target_groups(): """Get all target groups and include associated move target items. Returns: Response: Flask response. """ return flaskify(response.Response(review_queue_logic.get_all_target_groups())) @app.route("/canned-response/", methods=["GET"]) @require_standalone_authorization(action="view", resource_type=QUEUE_GLOBAL) def get_canned_response_item(canned_response_id): """Get a canned response item. Returns: Response: Flask response. """ language_code = request.args.get("language_code") return flaskify( canned_response_logic.get_canned_response(canned_response_id, language_code) ) @app.route("/canned-responses", methods=["GET"]) @require_standalone_authorization(action="view", resource_type=QUEUE_GLOBAL) def get_canned_response_items(): """Get list of canned response items. Returns: Response: Flask response. """ page_offset = request.args.get("page_offset") page_limit = request.args.get("page_limit") category_id = request.args.get("category_id") modified_by_user_id = request.args.get("modified_by_user_id") review_context = request.args.get("review_context") language_code = request.args.get("language_code") data = canned_response_logic.get_canned_response_items( page_limit=page_limit, page_offset=page_offset, category_id=category_id, modified_by_user_id=modified_by_user_id, review_context=review_context, language_code=language_code, ) return flaskify(data) @app.route("/canned-response-categories", methods=["GET"]) @require_standalone_authorization(action="view", resource_type=QUEUE_GLOBAL) def get_canned_response_categories(): """Get canned response categories. Returns: Response: Flask response. """ return flaskify(canned_response_logic.get_canned_response_categories()) @require_standalone_authorization(action="view", resource_type=QUEUE_GLOBAL) @app.route("/canned-response-note/dataloader", methods=["POST"]) def dataload_canned_response_notes(): """Dataload canned response notes.""" keys = request.get_json() return flaskify( response.Response(canned_response_logic.dataload_canned_response_notes(keys)) ) @require_standalone_authorization(action="view", resource_type=QUEUE_GLOBAL) @app.route("/account-note/vendor/", methods=["GET"]) def get_account_note(vendor_id): """Get an account note. Returns: Response: Flask response. """ return flaskify(account_note_logic.get_account_note(vendor_id)) @app.route("/subaccount-note/subaccount/", methods=["GET"]) @require_standalone_authorization(action="view", resource_type=QUEUE_GLOBAL) def get_subaccount_note(subaccount_id): """Get a subaccount note. Returns: Response: Flask response. """ return flaskify(account_note_logic.get_subaccount_note(subaccount_id)) @require_standalone_authorization(action="view", resource_type=QUEUE_GLOBAL) @app.route("/account-note/dataloader", methods=["POST"]) def dataload_account_notes(): """Dataload account notes.""" account_ids = request.get_json() return flaskify( response.Response(account_note_logic.dataload_account_notes(account_ids)) ) @require_standalone_authorization(action="view", resource_type=QUEUE_GLOBAL) @app.route("/subaccount-note/dataloader", methods=["POST"]) def dataload_subaccount_notes(): """Dataload subaccount notes.""" subaccount_ids = request.get_json() return flaskify( response.Response(account_note_logic.dataload_subaccount_notes(subaccount_ids)) ) @require_standalone_authorization(action="review", resource_type=QUEUE_GLOBAL) @require_header_orchard_identity_id @app.route("/account-note/vendor/", methods=["PUT"]) def add_account_note(vendor_id): """Add/ update an account note. Returns: Response: Flask response. """ note = request.json.get("note") updated_by = g.request_context.identity_id return flaskify(account_note_logic.add_account_note(vendor_id, note, updated_by)) @require_standalone_authorization(action="review", resource_type=QUEUE_GLOBAL) @require_header_orchard_identity_id @app.route("/subaccount-note/subaccount/", methods=["PUT"]) def add_subaccount_note(subaccount_id): """Add/ update a subaccount note. Returns: Response: Flask response. """ note = request.json.get("note") updated_by = g.request_context.identity_id return flaskify( account_note_logic.add_subaccount_note(subaccount_id, note, updated_by) ) @require_standalone_authorization(action="review", resource_type=QUEUE_GLOBAL) @app.route("/score/", methods=["POST"]) def add_score(review_queue_id): """Add a score history entry. Returns: Response: Flask response. """ return flaskify(score_history_logic.score_review_queue_item( review_queue_id, request.json.get("validation_result"), request.json.get("failed_validations", None), request.json.get("confidence_score", None), request.json.get("score_reasoning", None), request.json.get("auto_approved", False), request.json.get("score_version", None), )) @require_standalone_authorization(action="view", resource_type=QUEUE_GLOBAL) @app.route("/score/", methods=["GET"]) def get_score(review_queue_id): """Get score by review_queue_id. Returns: Response: Flask response. """ return flaskify(score_history_logic.get_score(review_queue_id)) @app.errorhandler(Exception) def exception_handler(error): """Handle error when uncaught exception is raised. Default exception handler. Returns: flask.Response: An error response. """ # pass through HTTP errors if isinstance(error, HTTPException): g.log.exception(error) return error # now you're handling non-HTTP exceptions only parsed_error = str(error) http_response_status = owsresponse_status.INTERNAL_ERROR response_code = "bad_request" message = "Bad Request" try: parsed_error = json.loads(parsed_error) http_response_status = parsed_error.get("status") or http_response_status response_code = parsed_error.get("code") or response_code message = parsed_error.get("message") or message except JSONDecodeError: message = parsed_error pass if http_response_status in { owsresponse_status.NOT_FOUND, owsresponse_status.CONFLICT, }: g.log.warning(error) else: g.log.exception(error) return flaskify( response.create_error_response( response_code, message, status=http_response_status ) )