"""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 """ from typing import Any import sentry_sdk from connector_neo4j import Neo4jSession from ddtrace import tracer from flask import Response as FlaskResponse, g, jsonify, request from owsrequest import error_response, flask_request from owsrequest.constants import headers from owsrequest.flask_request import request_context_from_headers from owsresponse import response, status as ows_status from owsresponse.adaptors.flask import flaskify from notifications import config from notifications.api import app from notifications.constants import context, error, header from notifications.constants.stream import ORCHARD_UNIQUE_FEED_ID from notifications.constants.subscriptions import ( GETSTREAM_FEED_TYPES, MAP_OLD_FEED_TYPES_TO_NEW, MAP_SUBSCRIPTION_APPID_TO_PROFILE_TYPE, ) from notifications.logic import ( audience_email, bulk_ingestion_email, bulk_ingestion_slack, collaborators_email, content_review_email, distribution_email, email, identity_verification, nr_delivery_email, nr_ownership_delivery_email, phys_delivery_email, stream, subscriptions, ) from notifications.models import identity, subscriptions as subscriptions_model from notifications.utils.api_utils import is_orchard_syst, validate_request_data from notifications.utils.datetime_json_encoder import Encoder from notifications.utils.request_params import ( get_data_params, get_header_params, validate_params, validate_user_feed_name, ) from notifications.validation import relationship as rel_validation from notifications.validation.relationship import ( DEFAULT_NODE_ID, FollowsList, external_to_internal_entity, internal_to_external_entity, ) from notifications.validation.schemas.audience_ad_reporting_notification import ( AudienceAdReportingNotification, ) from notifications.validation.schemas.audience_deleted_fans_report import ( AudienceDeletedFansReport as AudienceDeletedFansReportSchema, ) from notifications.validation.schemas.audience_export_notification import ( AudienceExportNotification as AudienceExportNotificationSchema, ) from notifications.validation.schemas.audience_shopify_notification import ( AudienceShopifyNotification as AudienceShopifyNotificationSchema, ) from notifications.validation.schemas.bulk_ingest_begin import ( BulkIngestBegin as BulkIngestBeginSchema, ) from notifications.validation.schemas.bulk_ingest_failure import ( BulkIngestFailure as BulkIngestFailureSchema, ) from notifications.validation.schemas.bulk_ingest_product_failure import ( BulkIngestProductFailure as BulkIngestProductFailureSchema, ) from notifications.validation.schemas.bulk_ingest_success import ( BulkIngestSuccess as BulkIngestSuccessSchema, ) from notifications.validation.schemas.collaborators_statement_period_closed import ( CollaboratorsBulkStatementPeriodClosed, ) from notifications.validation.schemas.content_review_escalation_completed import ( ContentReviewEscalationCompleted, ) from notifications.validation.schemas.content_review_failure_notification import ( ContentReviewFailureNotification, ) from notifications.validation.schemas.content_review_support_de_escalation import ( ContentReviewSupportDeEscalation, ) from notifications.validation.schemas.content_review_support_escalation import ( ContentReviewSupportEscalation, ) from notifications.validation.schemas.distribution_scheduled_update_failed import ( DistributionScheduledUpdateFailedNotification, ) from notifications.validation.schemas.distribution_scheduled_update_processed import ( DistributionScheduledUpdateNotification, ) from notifications.validation.schemas.distribution_scheduled_update_warning import ( DistributionScheduledUpdateWarningNotification, ) from notifications.validation.schemas.distribution_ws_scheduled_update import ( DistributionWsScheduledUpdateFailedNotification, DistributionWsScheduledUpdateProcessedNotification, ) from notifications.validation.schemas.edit_user_subscription import EditUserSubscription from notifications.validation.schemas.identity_verification import IdentityVerificationSchema from notifications.validation.schemas.notification_subscription import ( LABEL_SUBSCRIPTION, NotificationSubscription as NotificationSubscriptionSchema, NotificationSubscriptionParams as NotificationSubscriptionParamsSchema, ) from notifications.validation.schemas.participant_activity import SocialSpike as SocialSpikeSchema from notifications.validation.schemas.participant_data_report import ( ParticipantDataReport as ParticipantDataReportSchema, ) from notifications.validation.schemas.product_approval_activity import ProductApproval from notifications.validation.schemas.product_rejection_activity import ProductRejection from notifications.validation.schemas.sound_recording_activity import ( PlaylistPlacement as PlaylistPlacementSchema, TrendingTrack as TrendingTrackSchema, ) from notifications.validation.schemas.sound_recording_data_report import ( SoundRecordingDataReport as SoundRecordingDataReportSchema, ) from notifications.validation.schemas.store_activity import StreamsUpdated as StreamsUpdatedSchema @app.route('/activity', methods=['POST']) @Neo4jSession(use_v2=True, database=config.NEO4J_DB_NAME) def add_activity() -> FlaskResponse: """Add an activity to a feed. Params: feed_name (str): Feed name in Getstream. Eg: label_release_approval, label_video_product_rejection, label_video_product_approval feed_id (str): Key in that feed group. Eg: vendor_7123 or subaccount_12354 payload (dict): Payload that is sent to Getstream feed. Eg: { 'actor': 'The Orchard Activity Detector', 'verb': 'Detected', 'object': 'Approved Releases', 'approved_releases': releases } { actor: 'OA Approver', verb: 'Rejected', object: 'Video Product', projectId, productId, videoTitle, upc, isrc, artistName, reason } Returns: flask.Response: A 201 response if the activity has been added. """ params = get_data_params(request) keys = ['feed_name', 'feed_id', 'payload'] validation = validate_params(params, keys) if not validation or not validation.message: return flaskify(validation) feed_name, feed_id, payload = validation.message if tracer.enabled: span = tracer.current_root_span() if span: span.set_tag('activity.feed_name', feed_name) span.set_tag('activity.feed_id', feed_id) g.add_log_tags(feed_name=feed_name, feed_id=feed_id) if feed_id == 'orchard': # The trending tracks feed still uses the old notification model # where the user's feed is used to follow the vendor's feed in # getstream, hence there's no need to fanout to individual users. g.log.info('Sending legacy notification activity', resources=g.log_tags) result = stream.add_activity(feed_name, feed_id, payload) if not result: sentry_sdk.capture_message( 'Legacy activity send failed so wont send user activities.', data=params, stack=True ) return flaskify(result) subscription_feed_type = MAP_OLD_FEED_TYPES_TO_NEW.get(feed_name) # Use new activity logic only for vendor/subaccount with Feature Flag ON. # https://app.split.io/org/5b4b5c30-21c9-11ea-a4e7-0a9b522eabbd/ws/5b5212f0-21c9-11ea-a4e7-0a9b522eabbd/splits/88f7be20-66c5-11eb-8584-0252cb9da36f/env/5b654cd0-21c9-11ea-a4e7-0a9b522eabbd/definition feed_id_account_type, feed_id_account_id = feed_id.split('_') # All good. Convert activity for vendor_x to activity for user a, b, c. origin = f'{feed_name}:{feed_id}' payload['original_feed'] = origin g.log.info('Processing activity', resources=g.log_tags) user_feed_results = stream.add_label_activities_to_user( activity_type=feed_id_account_type.capitalize(), activity_id=int(feed_id_account_id), subscription_feed_type=subscription_feed_type, payload=payload, ) if not user_feed_results: return flaskify(user_feed_results) return flaskify(user_feed_results) @app.route('/activity/product_rejection', methods=['POST']) @validate_request_data(ProductRejection()) @Neo4jSession(use_v2=True, database=config.NEO4J_DB_NAME) def add_product_rejection_activity(validated_json: dict[str, Any]) -> FlaskResponse: """Add single product's rejection activity to a feed. Params: product_id (int): Product id. rejection_reasons (list): Reason for rejection [ { "title": "Artwork", "comments": "Logos in artwork" }, { "title": "General Comments", "comments": "Audio file lío is missing" } ] Returns: flask.Response: A 201 response if the activity has been added. """ product_id = request.json.get('product_id') if tracer.enabled: span = tracer.current_root_span() if span: span.set_tag('product.id', product_id) result = stream.add_product_rejection_activity( product_id, request.json.get('rejection_reasons') ) return flaskify(result) @app.route('/activity/product_approval', methods=['POST']) @validate_request_data(ProductApproval()) @Neo4jSession(use_v2=True, database=config.NEO4J_DB_NAME) def add_product_approval_activity(validated_json: dict[str, Any]) -> FlaskResponse: """Add single product's approval activity to a feed. Params: product_id (int): Product id. Returns: flask.Response: A 201 response if the activity has been added. """ product_id = request.json.get('product_id') if tracer.enabled: span = tracer.current_root_span() if span: span.set_tag('product.id', product_id) result = stream.add_product_approval_activity(product_id) return flaskify(result) @app.route('/subscribe', methods=['POST']) @Neo4jSession(transaction=True, use_v2=True, database=config.NEO4J_DB_NAME) def subscribe() -> FlaskResponse: """Subscribe a user to a feed. Returns: flask.Response: A 200 response if the user has been subscribed. """ params = get_header_params(request) params.update(get_data_params(request)) keys = [header.ORCHARD_USER_ID, 'user_feed_name', 'feed_name', 'feed_id'] validation = validate_params(params, keys) if not validation or not validation.message: return flaskify(validation) user_id, user_feed_name, feed_name, feed_id = validation.message user_feed_id = params.get('user_feed_id') if feed_id != ORCHARD_UNIQUE_FEED_ID: feed_id_account_type, feed_id_account_id = feed_id.split('_') account_type, account_id = flask_request.get_grass_headers(request) if account_type and account_id: # verify that grass headers and the vendor/subaccount that you # are subscribing are same. if feed_id_account_type != account_type or feed_id_account_id != account_id: return flaskify( response.create_error_response( status=ows_status.UNAUTHORIZED, code=error.ERROR_CODE_AUTHORIZATION, message=error.ERROR_MESSAGE_FORBIDDEN_USER, ) ) result = stream.subscribe(user_feed_name, user_id, feed_name, feed_id, user_feed_id) if result and result.message and 'alw:' in user_id and feed_name in MAP_OLD_FEED_TYPES_TO_NEW: # write to new system till all notifications use the new endpoints # do same logic as Toggle ON on settings App for consistency. # this creates some new HAS_FOLLOWED_<> relationships. vend_contact_id = int(user_id.replace('alw:', '')) subscribe_result = subscriptions.subscription_for_ws_user( int(vend_contact_id), MAP_OLD_FEED_TYPES_TO_NEW[feed_name], feed_id_account_type.capitalize(), int(feed_id_account_id), ) if not subscribe_result or not subscribe_result.message: return flaskify(subscribe_result) result.message['subscriptions_result'] = subscribe_result.message return flaskify(result) @app.route('/unsubscribe', methods=['POST']) @Neo4jSession(transaction=True, use_v2=True, database=config.NEO4J_DB_NAME) def unsubscribe() -> FlaskResponse: """Unsubscribe a user from a feed. Returns: flask.Response: A 200 response if the user has been unsubscribed. """ params = get_header_params(request) params.update(get_data_params(request)) keys = [header.ORCHARD_USER_ID, 'user_feed_name', 'feed_name', 'feed_id'] validation = validate_params(params, keys) if not validation or not validation.message: return flaskify(validation) user_id, user_feed_name, feed_name, feed_id = validation.message user_feed_id = params.get('user_feed_id') result = stream.unsubscribe(user_feed_name, user_id, feed_name, feed_id, user_feed_id) if result and result.message and 'alw:' in user_id and feed_name in MAP_OLD_FEED_TYPES_TO_NEW: feed_id_account_type, feed_id_account_id = feed_id.split('_') # write to new system till all notifications use the new endpoints # do same logic as Toggle OFF on settings App for consistency. # this creates some DELETED_HAS_FOLLOWED_<> relationships. vend_contact_id = int(user_id.replace('alw:', '')) unsubscribe_result = subscriptions.subscription_for_ws_user( int(vend_contact_id), MAP_OLD_FEED_TYPES_TO_NEW[feed_name], feed_id_account_type.capitalize(), int(feed_id_account_id), toggle_on=False, ) if not unsubscribe_result or not unsubscribe_result.message: return flaskify(unsubscribe_result) result.message['unsubscrbe_result'] = unsubscribe_result.message return flaskify(result) @app.route('/user/notifications', methods=['GET']) def get_user_notifications() -> FlaskResponse: """Get a user's notifications. Returns: flask.Response: containing the user's notifications. """ params = get_header_params(request) keys = [header.ORCHARD_USER_ID] validation = validate_params(params, keys) user_feed_name_validation = validate_user_feed_name(request) if not validation or not validation.message: return flaskify(validation) if not user_feed_name_validation or not user_feed_name_validation.message: return flaskify(user_feed_name_validation) (user_id,) = validation.message user_feed_name = user_feed_name_validation.message user_feed_id = request.args.get('user_feed_id') return flaskify( stream.get_user_notifications(user_id, user_feed_name, user_feed_id), encoder=Encoder ) @app.route('/user/subscriptions', methods=['GET']) def get_user_subscriptions() -> FlaskResponse: """Get a user's subscriptions. Returns: flask.Response: containing the user's subscriptions. """ params = get_header_params(request) keys = [header.ORCHARD_USER_ID] validation = validate_params(params, keys) user_feed_name_validation = validate_user_feed_name(request) if not validation or not validation.message: return flaskify(validation) if not user_feed_name_validation or not user_feed_name_validation.message: return flaskify(user_feed_name_validation) (user_id,) = validation.message user_feed_name = user_feed_name_validation.message user_feed_id = request.args.get('user_feed_id') return flaskify( stream.get_user_subscriptions(user_id, user_feed_name, user_feed_id), encoder=Encoder ) @app.route('/feed/subscribers', methods=['GET']) def get_feed_subscribers() -> FlaskResponse: """Get a feed's subscribers. Returns: flask.Response: containing the feed's subscribers. """ keys = ['feed_name', 'feed_id'] validation = validate_params(request.args, keys) if not validation or not validation.message: return flaskify(validation) feed_name, feed_id = validation.message return flaskify(stream.get_feed_subscribers(feed_name, feed_id), encoder=Encoder) @app.route(('/activity/social_spike'), methods=['POST'], endpoint='add_particpant_activity_handler') @validate_request_data(SocialSpikeSchema()) @Neo4jSession(use_v2=True, database=config.NEO4J_DB_NAME) def add_particpant_activity_handler(validated_json: dict[str, Any]) -> FlaskResponse: """Add activity for a participant to a feed in GetStream. Kwargs: validated_json (dict[str, Any]): Validated JSON data. date (str): ISO formatted date of event new_followers (int): number of follower increase chartmetric_id (int): chartmetric artist identifier network (string): social network that was event origin Returns: Response: - 201 activity added - 404 no partitipant found """ return flaskify( stream.add_social_spike_activity( validated_json['date'], request.json.get('network'), request.json.get('new_followers'), request.json.get('chartmetric_id'), ) ) @app.route(('/activity/trending_track'), methods=['POST'], endpoint='add_trending_track_handler') @validate_request_data(TrendingTrackSchema()) @Neo4jSession(use_v2=True, database=config.NEO4J_DB_NAME) def add_trending_track_handler(validated_json: dict[str, Any]) -> FlaskResponse: """Add activity for a sound recording's child track trending. Kwargs: validated_json (dict[str, Any]): Validated JSON data. date (str): ISO formatted date of trending spike region (str): human readable region identifier dsp (str): service spike occured on percent_diff (int): percent increase in streams over previous day day_streams (int): streams during day of spike track: id (int): unique internal ID of track isrc (str): unique identifier of parent sound recording vendor_id (int): id of label owning track subaccount_id (int): id of subaccount owning track """ return flaskify( stream.add_trending_track_activity( validated_json['date'], request.json.get('dsp'), request.json.get('region'), request.json.get('percent_diff'), request.json.get('day_streams'), request.json.get('track'), ) ) @app.route( ('/activity/playlist_placement'), methods=['POST'], endpoint='add_playlist_placement_handler' ) @validate_request_data(PlaylistPlacementSchema()) @Neo4jSession(use_v2=True, database=config.NEO4J_DB_NAME) def add_playlist_placement_handler(validated_json: dict[str, Any]) -> FlaskResponse: """Add activity for a sound recording playlist placement. Kwargs: validated_json (dict[str, Any]): Validated JSON data. timestamp (str): ISO formatted time when event occured playlist: id (str): unique identifier of playlist rank (int): relative importance of playlist dsp (str): service playlist exists on store_id (int): identifier for service playlist exists on name (str): name of playlist sound_recording: isrc (str): unique identifier of sound recording tracks (list): id (int): unique internal identifier of track vendor_id (int): unique internal identifier of vendor subaccount_id (int): unique internal identifier of subaccount Returns: Response: - 201 some activity added (dupes are ignored) - 404 no sound recording found """ g.log.info(f'playlist payload: {request.json}') playlist = request.json.get('playlist') sound_recording = request.json.get('sound_recording') return flaskify( stream.add_playlist_placement_activity( validated_json['timestamp'], playlist, sound_recording ) ) @app.route(('/activity/streams_updated'), methods=['POST'], endpoint='add_streams_updated_handler') @validate_request_data(StreamsUpdatedSchema()) @Neo4jSession(use_v2=True, database=config.NEO4J_DB_NAME) def add_streams_updated_handler(validated_json: dict[str, Any]) -> FlaskResponse: """Add activity for streams updated. Kwargs: validated_json (dict[str, Any]): Validated JSON data. timestamp (str): timestamp of event in ISO format store_id (int): store (DSP) id available_date (str): date of stream data availability in ISO format """ g.log.info(f'Streams updated payload: {request.json}') result = stream.add_streams_updated_activity( timestamp=request.json.get('timestamp'), store_id=request.json.get('store_id'), available_date=request.json.get('available_date'), ) return flaskify(result) @app.route( ('/collaborators/bulk-statement-period-closed-email'), methods=['POST'], endpoint='collaborators_bulk_statement_period_closed_handler', ) @validate_request_data(CollaboratorsBulkStatementPeriodClosed()) @Neo4jSession(use_v2=True, database=config.NEO4J_DB_NAME) def collaborators_bulk_statement_period_closed_handler( validated_json: dict[str, Any], ) -> FlaskResponse: """Send collaborators statement period closed emails for many periods.""" g.log.info(f'statement period payload: {request.json}') items = request.json.get('items', []) return flaskify(collaborators_email.bulk_statement_period_closed_email(items)) @app.route( '/subscription/', methods=['POST'], endpoint='create_subscription_handler' ) @flask_request.request_context_from_headers() def create_subscription_handler(entity_type: str) -> FlaskResponse: """Create given relationship between a Profile and an entity. Headers: Orchard-Profile-Type (str): The type of profile (ArtistInsights, etc). Orchard-Profile-Id (int): The id of the profile. Body: {entity_type}_id (str): The id of the entity node see SCHEMA_MAP at /notifications/validation/relationship.py relationship (str): The relationship type (HAS_FOLLOWED, etc) automatic (bool): If relationship was created via automatic process Returns: Response: 201 created, 200 already exists """ # check param if this was an 'automatic' follow automatic = request.json.get('automatic', False) if not isinstance(automatic, bool): return flaskify( response.create_error_response( error.ERROR_CODE_VALIDATION_ERROR, "Field 'automatic' must be of type boolean" ) ) # backwards compatible for when only 'participant_id' was valid param name id_type = f'{entity_type}_id' old_style_id = request.json.get(id_type) new_style_id = request.json.get('id') if old_style_id and new_style_id: return flaskify( response.create_error_response( error.ERROR_CODE_VALIDATION_ERROR, f'Cannot set both {id_type} and id' ) ) entity_id = new_style_id or old_style_id if not entity_id: return flaskify( response.create_error_response(error.ERROR_CODE_VALIDATION_ERROR, 'Missing id in body') ) # validate profile -> entity relationship and get further config details rel_response = rel_validation.validate_relationship( request.json.get('relationship'), g.request_context.profile_type, g.request_context.profile_id, entity_type, entity_id, ) if not rel_response or not rel_response.message: return flaskify(rel_response) valid_data = rel_response.message @Neo4jSession(transaction=True, use_v2=True, database=config.NEO4J_DB_NAME) def call_logic_layer() -> FlaskResponse: error = _swap_entity_id(valid_data) if error: return error return flaskify( subscriptions.create_subscription( valid_data['profile_type'], valid_data['profile_id'], valid_data['entity_node_type'], valid_data['entity_id'], valid_data['relationship'], automatic, ) ) return call_logic_layer() @app.route( ('/subscription///relationship/'), methods=['GET'], endpoint='get_subscription_handler', ) @flask_request.request_context_from_headers() def get_subscription_handler(entity_type: str, entity_id: str, relationship: str) -> FlaskResponse: """Check to see if a relationship exists for Profile and an entity. Headers: Orchard-Profile-Type (str): The type of profile (ArtistInsights, etc). Orchard-Profile-Id (int): The id of the profile. Args: entity_type (str): The type of entity to follow see SCHEMA_MAP at /notifications/validation/relationship.py entity_id (str): The id of the entity node relationship (str): The relationship type (HAS_FOLLOWED, etc) Returns: Response: 200 exists, 404 not exists. """ # validate profile -> entity relationship and get further config details rel_response = rel_validation.validate_relationship( relationship, g.request_context.profile_type, g.request_context.profile_id, entity_type, entity_id, ) if not rel_response or not rel_response.message: return flaskify(rel_response) valid_data = rel_response.message @Neo4jSession(use_v2=True, database=config.NEO4J_DB_NAME) def call_logic_layer() -> FlaskResponse: error = _swap_entity_id(valid_data) if error: return error return flaskify( subscriptions.has_subscription( valid_data['profile_type'], valid_data['profile_id'], valid_data['entity_node_type'], valid_data['entity_id'], valid_data['relationship'], ) ) return call_logic_layer() @app.route( ('/subscription//relationship/'), methods=['GET'], endpoint='list_subscription_handler', ) @flask_request.request_context_from_headers() @validate_request_data(FollowsList(), data_source='args') def list_subscription_handler( entity_type: str, relationship: str, validated_args: dict[str, Any] ) -> FlaskResponse: """List all entities for a Profile with given relationship type. Headers: Orchard-Profile-Type (str): The type of profile (ArtistInsights, etc). Orchard-Profile-Id (int): The id of the profile. Args: entity_type (str): The type of entity to follow see SCHEMA_MAP at /notifications/validation/relationship.py relationship (str): The relationship type (HAS_FOLLOWED, etc) Kwargs: validated_args (dict[str, Any]): Validated args data. ids (str): Comma seperated list of IDs to filter inclusively offset (int): Fetch data starting at index, default 0 limit (int): Maxium results from offset, default None for all state (str): Deleted status of follows to fetch order_by (str): Sort by attribute, default last_modified order_dir (str): Sort direction, asc or desc, default desc Returns: Response: 200 with a list of Participants. """ # validate profile -> entity relationship and get further config details rel_response = rel_validation.validate_relationship( relationship, g.request_context.profile_type, g.request_context.profile_id, entity_type ) if not rel_response or not rel_response.message: return flaskify(rel_response) valid_data = rel_response.message # get comma seperated list of IDs to filter by max_ids = 50 ids_arg = request.args.get('ids') entity_ids = ids_arg.split(',') if ids_arg else [] if len(entity_ids) > max_ids: return flaskify( response.create_error_response( error.ERROR_CODE_VALIDATION_ERROR, f'maximum {max_ids} ids per request' ) ) # cast IDs to proper data type (input is always str) try: entity_ids = [rel_validation.cast_entity_id(entity_type, x) for x in entity_ids] except ValueError as e: return flaskify( response.create_error_response( status=ows_status.BAD_REQUEST, code=error.ERROR_CODE_VALIDATION_ERROR, message=f'ids param error: {e}', ) ) # determine entity name-id setup if need to query for real IDs entity_id_name = rel_validation.get_entity_id_name(entity_type) entity_node_name = rel_validation.external_to_internal_entity(entity_type) @Neo4jSession(use_v2=True, database=config.NEO4J_DB_NAME) def call_logic_layer() -> FlaskResponse: ids_filter = _resolve_entity_ids(entity_id_name, entity_node_name, entity_ids) logic_args = validated_args logic_args['ids'] = ids_filter return flaskify( subscriptions.get_subscriptions( valid_data['profile_type'], valid_data['profile_id'], valid_data['entity_node_type'], valid_data['relationship'], logic_args, ) ) return call_logic_layer() @app.route( ('/subscription///relationship/'), methods=['DELETE'], endpoint='delete_subscription_handler', ) @flask_request.request_context_from_headers() def delete_subscription_handler( entity_type: str, entity_id: str, relationship: str ) -> FlaskResponse: """Soft delete a relationship between Profile and an entity. Headers: Orchard-Profile-Type (str): The type of profile (ArtistInsights, etc). Orchard-Profile-Id (int): The id of the profile. Args: entity_type (str): The type of entity to follow see SCHEMA_MAP at /notifications/validation/relationship.py entity_id (str): The id of the entity node relationship (str): The relationship type (HAS_FOLLOWED, etc) Returns: Response: 204 deleted, 404 not exists. """ # validate profile -> entity relationship and get further config details rel_response = rel_validation.validate_relationship( relationship, g.request_context.profile_type, g.request_context.profile_id, entity_type, entity_id, ) if not rel_response or not rel_response.message: return flaskify(rel_response) valid_data = rel_response.message @Neo4jSession(transaction=True, use_v2=True, database=config.NEO4J_DB_NAME) def call_logic_layer() -> FlaskResponse: error = _swap_entity_id(valid_data) if error: return error return flaskify( subscriptions.soft_delete_subscription( valid_data['profile_type'], valid_data['profile_id'], valid_data['entity_node_type'], valid_data['entity_id'], valid_data['relationship'], ) ) return call_logic_layer() def _get_followed_entities(followed_entity: str) -> list[str | None]: """Interpret followed_entity param for notification subscriptions endpoints. Args: followed_entity (str): user input for 'followed_entity' in request Returns: list: internal neo4j node type(s) to modify """ # follow all entity types if not followed_entity: return [None] # special case of 'label' conneting both 'vendor' and 'sub_account' elif followed_entity == LABEL_SUBSCRIPTION: followed_entities = ['vendor', 'sub_account'] # follow single entity type elif followed_entity: followed_entities = [followed_entity] return [external_to_internal_entity(x) for x in followed_entities] @app.route('/notifications/unsubscribe', methods=['POST']) @flask_request.request_context_from_headers() @validate_request_data(NotificationSubscriptionSchema()) @Neo4jSession(transaction=True, use_v2=True, database=config.NEO4J_DB_NAME) def unsubscribe_by_notification_type(validated_json: dict[str, Any]) -> FlaskResponse: """Disable subscription by notification type. Headers: Orchard-Profile-Type (str): The type of profile (ArtistInsights, etc). Orchard-Profile-Id (int): The id of the profile. Kwargs: validated_json (dict[str, Any]): Validated JSON data. notification_type (str): Notification type (email, etc) feed_type (str): Name of feed (social_spike, etc) followed_entity (str): Optional event source Returns: Response: 204 modified, 400 bad request. """ if g.request_context.context_type != context.PROFILE_CONTEXT_TYPE: return flaskify(error_response.create_error_incomplete_profile_headers()) notification_type = request.json.get('notification_type') feed_type = request.json.get('feed_type') followed_entity = request.json.get('followed_entity', None) g.add_log_tags( notification_type=notification_type, feed_type=feed_type, followed_entity=followed_entity ) g.log.info('Unsubscribing by notification type', resources=g.log_tags) if feed_type in GETSTREAM_FEED_TYPES: result = subscriptions.unsubscribe_by_notification_type_gs( g.request_context.profile_type, g.request_context.profile_id, feed_type ) return flaskify(result) for entity in _get_followed_entities(followed_entity): result = subscriptions.unsubscribe_by_notification_type( g.request_context.profile_type, int(g.request_context.profile_id), notification_type, feed_type, entity, ) return flaskify(result) @app.route('/notifications/subscribe', methods=['POST']) @flask_request.request_context_from_headers() @validate_request_data(NotificationSubscriptionParamsSchema(), data_source='args') @validate_request_data(NotificationSubscriptionSchema()) @Neo4jSession(transaction=True, use_v2=True, database=config.NEO4J_DB_NAME) def subscribe_by_notification_type( validated_json: dict[str, Any], validated_args: dict[str, Any] ) -> FlaskResponse: """Disable subscription by notification type. Headers: Orchard-Profile-Type (str): The type of profile (ArtistInsights, etc). Orchard-Profile-Id (int): The id of the profile. Kwargs: validated_json (dict[str, Any]): Validated JSON data. notification_type (str): Notification type (email, etc) feed_type (str): Name of feed (social_spike, etc) followed_entity (str): Optional event source validated_args (dict[str, Any]): Validated args data. undelete (bool): Optional URL param to undelete: default False Returns: Response: 201 created, 400 bad request. """ if g.request_context.context_type != context.PROFILE_CONTEXT_TYPE: return flaskify(error_response.create_error_incomplete_profile_headers()) notification_type = request.json.get('notification_type') feed_type = request.json.get('feed_type') followed_entity = request.json.get('followed_entity', None) g.add_log_tags( notification_type=notification_type, feed_type=feed_type, followed_entity=followed_entity ) g.log.info('Subscribing by notification type', resources=g.log_tags) if feed_type in GETSTREAM_FEED_TYPES: result = subscriptions.subscribe_by_notification_type_gs( g.request_context.profile_type, g.request_context.profile_id, feed_type ) return flaskify(result) result: response.Response | None = None for entity in _get_followed_entities(followed_entity): result = subscriptions.subscribe_by_notification_type( g.request_context.profile_type, int(g.request_context.profile_id), notification_type, feed_type, entity, request.args.get('undelete', 'true') == 'true', ) return flaskify( response.Response(status=result.status if result is not None else ows_status.OK) ) @app.route( '/profile/profile_id//profile_type//notifications', methods=['GET'], ) @flask_request.request_context_from_headers() @Neo4jSession(use_v2=True, database=config.NEO4J_DB_NAME) def get_all_subscriptions_for_profile(profile_type: str, profile_id: str) -> FlaskResponse: """Get all active subscriptions for a given profile. Args: profile_type (str): Profile type (e.g. LabelProfile). profile_id (str): Profile id. Returns: Response: a list of all active subscriptions. """ if g.request_context.context_type == headers.CONTEXT_TYPE_PROFILE and ( g.request_context.profile_type != profile_type or g.request_context.profile_id != profile_id ): return flaskify( response.create_error_response( status=ows_status.UNAUTHORIZED, code=error.ERROR_CODE_AUTHORIZATION, message=error.ERROR_MESSAGE_FORBIDDEN_USER, ) ) def format_message( message: list[dict[str, Any]], map_entity: bool = True ) -> list[dict[str, Any]]: return [ { k: internal_to_external_entity(v) if k == 'followed_entity' and map_entity else v for k, v in x.items() } for x in message ] profile_id_ = int(profile_id) result = subscriptions.get_all_notifications_for_profile(profile_type, profile_id_) formatted_message = format_message(result) result_gs = subscriptions.get_all_notifications_for_profile_gs(profile_type, profile_id_) formatted_message.extend(format_message(result_gs, False)) return flaskify(response.Response(status=ows_status.OK, message=formatted_message)) @app.route('/subscriptions/all', methods=['GET']) @flask_request.request_context_from_headers() @Neo4jSession(use_v2=True, database=config.NEO4J_DB_NAME) def get_all_subscriptions() -> FlaskResponse: """Get all subscriptions that are there in the system. This is not user dependent. This is to show unique subscriptions listing. Params: app_id (str): Application name to filter subscriptions for selected app. app_ids (list): Application names to filter subscriptions for selected app. notification_type (str): filter based on Type of notification ie. Email only or Push. Returns: Response: a list of all subscriptions. """ if g.request_context.context_type != headers.CONTEXT_TYPE_PROFILE: return flaskify( response.create_error_response( status=ows_status.UNAUTHORIZED, code=error.ERROR_CODE_AUTHORIZATION, message=error.ERROR_MESSAGE_FORBIDDEN_USER, ) ) app_id = request.args.get('app_id') app_ids = request.args.get('app_ids') apps = app_ids.split(',') if app_ids else [] if app_id: apps.append(app_id) notification_type = request.args.get('notification_type') return flaskify(subscriptions.get_all_subscriptions(apps, notification_type)) @app.route('/identity/verify', methods=['POST']) @request_context_from_headers() @validate_request_data(IdentityVerificationSchema()) def verify_identity(validated_json: dict[str, Any]) -> FlaskResponse: """Send an email inviting user to proceed with identity verification. Kwargs: validated_json (dict[str, Any]): Validated JSON data. """ if not is_orchard_syst(g.request_context): return flaskify(response.Response(status=ows_status.FORBIDDEN)) return flaskify(identity_verification.verify(**validated_json)) @app.route('/identity//subscriptions', methods=['GET']) @flask_request.request_context_from_headers() @Neo4jSession(use_v2=True, database=config.NEO4J_DB_NAME) def get_all_subscriptions_for_identity(identity_id: str) -> FlaskResponse: """Get all active subscriptions for a given identity. Args: identity_id (str): Identity UUID. profile_types (list): List of profile types to filter. app_id (str): Application name to filter only selected subscriptions. app_ids (list): List of application names to filter only selected subscriptions. Returns: Response: a list of all active subscriptions with list of followedEntity. """ if g.request_context.context_type != headers.CONTEXT_TYPE_PROFILE: return flaskify( response.create_error_response( status=ows_status.UNAUTHORIZED, code=error.ERROR_CODE_AUTHORIZATION, message=error.ERROR_MESSAGE_FORBIDDEN_USER, ) ) profile_types = request.args.getlist('profile_types') if not identity.can_administer_profile( g.request_context.identity_id, identity_id, profile_types ): return flaskify( response.create_error_response( error.ERROR_CODE_AUTHORIZATION, 'Admin is not authorized to administer this identity', ) ) app_id = request.args.get('app_id') app_ids = request.args.get('app_ids') apps = app_ids.split(',') if app_ids else [] if app_id: apps.append(app_id) return flaskify( subscriptions.get_all_subscriptions_for_identity(identity_id, profile_types, apps) ) @app.route( '/identity//subscriptions/', methods=['PUT'] ) @flask_request.request_context_from_headers() @validate_request_data(EditUserSubscription()) @Neo4jSession(transaction=True, use_v2=True, database=config.NEO4J_DB_NAME) def edit_subscription_for_identity( identity_id: str, subscription_name: str, validated_json: dict[str, Any] ) -> FlaskResponse: """Update subscriptions for a given identity and subscription_name. Args: identity_id (str): Identity UUID. subscription_name (str): name of the subscription. Kwargs: validated_json (dict[str, Any]): Validated JSON data. follow_all_resources (bool): indicate if it is ON for all labels. follow_resources (list): List of uuids if it is not follow_all_resources. automatic (bool): If this due to automatic process or explicitly by user. Returns: Response: success or error response. """ if g.request_context.context_type != headers.CONTEXT_TYPE_PROFILE: return flaskify( response.create_error_response( status=ows_status.UNAUTHORIZED, code=error.ERROR_CODE_AUTHORIZATION, message=error.ERROR_MESSAGE_FORBIDDEN_USER, ) ) follow_all_resources = request.json.get('follow_all_resources') follow_resources = request.json.get('follow_resources') automatic = request.json.get('automatic', False) overwrite = request.json.get('overwrite', False) if follow_all_resources: # explicitly reset it to not have strange combinations because of bad input. follow_resources = [] subscription = subscriptions_model.get_subscription_by_param(subscription_name) profile_types: list[str] = [] if ( subscription and subscription.message and subscription.message['appId'] in MAP_SUBSCRIPTION_APPID_TO_PROFILE_TYPE ): profile_types = [MAP_SUBSCRIPTION_APPID_TO_PROFILE_TYPE[subscription.message['appId']]] if not identity.can_administer_profile( g.request_context.identity_id, identity_id, profile_types ): return flaskify( response.create_error_response( error.ERROR_CODE_AUTHORIZATION, 'Admin is not authorized to administer this identity', ) ) return flaskify( subscriptions.edit_subscription_for_identity( identity_id, subscription_name, follow_all_resources, follow_resources, automatic, overwrite, ) ) @app.route('/identity//report/participant-data', methods=['POST']) @validate_request_data(ParticipantDataReportSchema()) @Neo4jSession(transaction=True, use_v2=True, database=config.NEO4J_DB_NAME) def report_participant_data(identity_id: str, validated_json: dict[str, Any]) -> FlaskResponse: """Send an email when a user has reported a participant data issue. Args: identity_id (str): Identity id of user reporting data issue. Kwargs: validated_json (dict[str, Any]): Validated JSON data. """ return flaskify(email.report_participant_data_issue(identity_id, request.json)) @app.route('/identity//report/sound-recording-data', methods=['POST']) @validate_request_data(SoundRecordingDataReportSchema()) @Neo4jSession(transaction=True, use_v2=True, database=config.NEO4J_DB_NAME) def report_sound_recording_data(identity_id: str, validated_json: dict[str, Any]) -> FlaskResponse: """Send an email when a user has reported a sound recording data issue. Args: identity_id (str): Identity id of user reporting sound recording data issue. Kwargs: validated_json (dict[str, Any]): Validated JSON data. """ return flaskify(email.report_sound_recording_data_issue(identity_id, request.json)) @app.route('/nr-delivery-order//completed', methods=['POST']) def nr_delivery_order_complete(order_id: str) -> FlaskResponse: """Email the user who requested this delivery order. Args: order_id (str): Nr Delivery Order Id. """ return flaskify(nr_delivery_email.order_completed(order_id)) @app.route('/nr-delivery-order//failed', methods=['POST']) def nr_delivery_order_failed(order_id: str) -> FlaskResponse: """Email the user who requested this delivery order. Args: order_id (str): Nr Delivery Order Id. """ return flaskify(nr_delivery_email.order_failed(order_id)) @app.route('/nr-ownership-delivery-order//completed', methods=['POST']) def nr_ownership_delivery_order_complete(order_id: str) -> FlaskResponse: """Email the user who requested this delivery order. Args: order_id (str): Nr Ownership Delivery Order Id. """ return flaskify(nr_ownership_delivery_email.order_completed(order_id)) @app.route('/nr-ownership-delivery-order//failed', methods=['POST']) def nr_ownership_delivery_order_failed(order_id: str) -> FlaskResponse: """Email the user who requested this delivery order. Args: order_id (str): Nr Ownership Delivery Order Id. """ return flaskify(nr_ownership_delivery_email.order_failed(order_id)) @app.route('/content-review/failure-notify', methods=['POST']) @validate_request_data(ContentReviewFailureNotification()) def content_review_failed(validated_json: dict[str, Any]) -> FlaskResponse: """Email the user who reviewed this product in content review. Kwargs: - validated_json (dict[str, Any]): Validated JSON data. - identity_id (str): Identity Id of the user to notify. - upc (str): UPC of product. - product_name (str): Name of product. """ return flaskify( content_review_email.report_product_review_failure( upc=request.json.get('upc'), product_name=request.json.get('product_name'), identity_id=request.json.get('identity_id'), ) ) @app.route('/content-review/notify-escalation-complete', methods=['POST']) @validate_request_data(ContentReviewEscalationCompleted()) def content_review_escalation_completed(validated_json: dict[str, Any]) -> FlaskResponse: """Email the user who escalated this product in content review. Kwargs: - validated_json (dict[str, Any]): Validated JSON data. - identity_id (str): Identity Id of the user to notify. - upc (str): UPC of product. - resolution (str): Resolution of product (approve/ reject). """ return flaskify( content_review_email.review_escalation_completed( upc=request.json.get('upc'), identity_id=request.json.get('identity_id'), resolution=request.json.get('resolution'), notes=request.json.get('notes'), escalation_type=request.json.get('escalation_type'), escalation_note=request.json.get('escalation_note'), ) ) @app.route('/content-review/escalate', methods=['POST']) @validate_request_data(ContentReviewSupportEscalation()) def content_review_escalate(validated_json: dict[str, Any]) -> FlaskResponse: """Email the escalation target for this queue move in content review. Kwargs: - validated_json (dict[str, Any]): Validated JSON data. - moved_to_target_email (str): Email to notify. - moved_by_user_name (str): Name of movedBy user. - upc (str): UPC of product. - product_name (str): Name of product. - label_id (int): Label ID. - label_name (str): Name of label. - review_queue_id (int): ID of review queue item. - target_group (str): Escalation target group. - move_note: (str): Move note. - escalation_type (str): Escalation type name. - primary_artist (str): Primary Artist. - sale_start_date (str): Sales Start Date. """ return flaskify( content_review_email.escalate( moved_to_target_email=request.json.get('moved_to_target_email'), moved_by_user_name=request.json.get('moved_by_user_name'), upc=request.json.get('upc'), product_name=request.json.get('product_name'), label_id=request.json.get('label_id'), label_name=request.json.get('label_name'), review_queue_id=request.json.get('review_queue_id'), target_group=request.json.get('target_group'), move_note=request.json.get('move_note'), escalation_type=request.json.get('escalation_type'), primary_artist=request.json.get('primary_artist'), sale_start_date=request.json.get('sale_start_date'), ) ) @app.route('/content-review/de-escalate', methods=['POST']) @validate_request_data(ContentReviewSupportDeEscalation()) def content_review_de_escalate(validated_json: dict[str, Any]) -> FlaskResponse: """Email the assigned reviewer for this queue move in content review. Kwargs: - validated_json (dict[str, Any]): Validated JSON data. - moved_to_target_email (str): Email to notify. - upc (str): UPC of product. - review_queue_id (int): ID of the review queue item. - product_name (str): Name of product. - label_id (int): Label ID. - label_name (str): Name of label. - primary_artist (str): Primary Artist. - sale_start_date (str): Sales Start Date. - escalation_type (str): Escalation type name. - move_note (str): Move note. """ return flaskify( content_review_email.de_escalate( moved_to_target_email=request.json.get('moved_to_target_email'), upc=request.json.get('upc'), review_queue_id=request.json.get('review_queue_id'), product_name=request.json.get('product_name'), label_id=request.json.get('label_id'), label_name=request.json.get('label_name'), primary_artist=request.json.get('primary_artist'), sale_start_date=request.json.get('sale_start_date'), escalation_type=request.json.get('escalation_type'), move_note=request.json.get('move_note'), ) ) @app.route('/physical-delivery-order//completed', methods=['POST']) def phys_delivery_order_complete(order_id: str) -> FlaskResponse: """Email the user who requested this delivery order. Args: order_id (str): Physical Delivery Order Id. """ return flaskify(phys_delivery_email.order_completed(order_id)) @app.route('/physical-delivery-order//failed', methods=['POST']) def phys_delivery_order_failed(order_id: str) -> FlaskResponse: """Email the user who requested this delivery order. Args: order_id (str): Physical Delivery Order Id. """ return flaskify(phys_delivery_email.order_failed(order_id)) @app.route('/audience/shopify-store-sync-completed', methods=['POST']) @validate_request_data(AudienceShopifyNotificationSchema()) def audience_shopify_store_sync_completed(validated_json: dict[str, Any]) -> FlaskResponse: """Email the user that their Shopify store sync has been completed. Kwargs: - validated_json (dict[str, Any]): Validated JSON data. - identity_id (str): Identity Id of the user to notify. - store_id (str): Audience Shopify Store Id. - store_domain (str): Shopify Store domain. - is_multiartist_store (bool): Whether the Shopify store contains products of multiple Artists. """ result = audience_email.shopify_store_sync_completed( identity_id=request.json.get('identity_id'), store_id=request.json.get('store_id'), store_domain=request.json.get('store_domain'), is_multiartist_store=request.json.get('is_multiartist_store'), ) return flaskify(result) @app.route('/audience/audience-file-exported', methods=['POST']) @validate_request_data(AudienceExportNotificationSchema()) def audience_file_exported(validated_json: dict[str, Any]) -> FlaskResponse: """Email the user that their audience file has been exported.""" result = audience_email.audience_file_exported( identity_id=request.json['identity_id'], audience_name=request.json['audience_name'], filename=request.json['filename'], password=request.json['password'], ) return flaskify(result) @app.route('/audience/audience-deleted-fans-report', methods=['POST']) @validate_request_data(AudienceDeletedFansReportSchema()) def audience_deleted_fans_report(validated_json: dict[str, Any]) -> FlaskResponse: """Email the CRM team about deleted fans.""" result = audience_email.deleted_fans_report( deleted_fans_count=request.json['deleted_fans_count'], date=request.json['date'], recipients=request.json['recipients'], bcc_recipients=request.json['bcc_recipients'], ) return flaskify(result) @app.route('/audience/ad-reporting-sync-completed', methods=['POST']) @validate_request_data(AudienceAdReportingNotification()) def audience_ad_reporting_sync_completed(validated_json: dict[str, Any]) -> FlaskResponse: """Email to the user that the Ad Reporting data they have connected is ready to use.""" result = audience_email.ad_reporting_data_sync_completed( identity_id=request.json['identity_id'], ad_reporting_platform=request.json['ad_reporting_platform'], ad_accounts=request.json['ad_accounts'], ) return flaskify(result) @app.route('/distribution/scheduled_update_processed', methods=['POST']) @request_context_from_headers() @validate_request_data(DistributionScheduledUpdateNotification()) def scheduled_update_processed(validated_json: dict[str, Any]) -> FlaskResponse: """Send an email notifying that a scheduled metadata update completed.""" if not is_orchard_syst(g.request_context): return flaskify(response.Response(status=ows_status.FORBIDDEN)) result = distribution_email.scheduled_update_processed(request.json) return flaskify(result) @app.route('/distribution/scheduled_update_failed', methods=['POST']) @request_context_from_headers() @validate_request_data(DistributionScheduledUpdateFailedNotification()) def scheduled_update_failed(validated_json: dict[str, Any]) -> FlaskResponse: """Send an email notifying that a scheduled metadata update failed.""" if not is_orchard_syst(g.request_context): return flaskify(response.Response(status=ows_status.FORBIDDEN)) result = distribution_email.scheduled_update_failed(request.json) return flaskify(result) @app.route('/distribution/scheduled_update_warning', methods=['POST']) @request_context_from_headers() @validate_request_data(DistributionScheduledUpdateWarningNotification()) def scheduled_update_warning(validated_json: dict[str, Any]) -> FlaskResponse: """Send an email notifying that a scheduled metadata update will go out soon.""" if not is_orchard_syst(g.request_context): return flaskify(response.Response(status=ows_status.FORBIDDEN)) result = distribution_email.scheduled_update_warning(request.json) return flaskify(result) @app.route('/distribution/ws_scheduled_update_failed', methods=['POST']) @request_context_from_headers() @validate_request_data(DistributionWsScheduledUpdateFailedNotification()) def ws_scheduled_update_failed(validated_json: dict[str, Any]) -> FlaskResponse: """Send an email notifying that a ws scheduled metadata update failed.""" if not is_orchard_syst(g.request_context): return flaskify(response.Response(status=ows_status.FORBIDDEN)) result = distribution_email.ws_scheduled_update_failed(request.json) return flaskify(result) @app.route('/distribution/ws_scheduled_update_processed', methods=['POST']) @request_context_from_headers() @validate_request_data(DistributionWsScheduledUpdateProcessedNotification()) def ws_scheduled_update_processed(validated_json: dict[str, Any]) -> FlaskResponse: """Send an email notifying that a ws scheduled metadata update processed.""" if not is_orchard_syst(g.request_context): return flaskify(response.Response(status=ows_status.FORBIDDEN)) result = distribution_email.ws_scheduled_update_processed(request.json) return flaskify(result) @app.route('/bulk/ingest/begin', methods=['POST']) @request_context_from_headers() @validate_request_data(BulkIngestBeginSchema()) def bulk_begin(validated_json: dict[str, Any]) -> FlaskResponse: """Send notification that a bulk operation has started.""" try: bulk_ingestion_slack.bulk_ingest_started(request.json) except Exception as e: g.log.error(f'Failed to send bulk ingest success to Slack: {e}') return flaskify(response.Response(status=ows_status.OK)) @app.route('/bulk/ingest/success', methods=['POST']) @request_context_from_headers() @validate_request_data(BulkIngestSuccessSchema()) def bulk_success(validated_json: dict[str, Any]) -> FlaskResponse: """Send an email notifying that a bulk operation was successful.""" result = bulk_ingestion_email.bulk_ingest_succeeded(request.json) if request.json.get('execution_arn') and request.json.get('vendor_id'): try: bulk_ingestion_slack.bulk_ingest_succeeded(request.json) except Exception as e: g.log.error(f'Failed to send bulk ingest success to Slack: {e}') return flaskify(result) @app.route('/bulk/ingest/product/failure', methods=['POST']) @request_context_from_headers() @validate_request_data(BulkIngestProductFailureSchema()) def bulk_product_failure(validated_json: dict[str, Any]) -> FlaskResponse: """Send notification that a bulk product ingestion failed.""" try: bulk_ingestion_slack.bulk_ingest_product_failed(request.json) except Exception as e: g.log.error(f'Failed to send bulk ingest product failure to Slack: {e}') return flaskify(response.Response(status=ows_status.OK)) @app.route('/bulk/ingest/failure', methods=['POST']) @request_context_from_headers() @validate_request_data(BulkIngestFailureSchema()) def bulk_failure(validated_json: dict[str, Any]) -> FlaskResponse: """Send an email notifying that a bulk operation failed.""" result = bulk_ingestion_email.bulk_ingest_failed(request.json) if request.json.get('execution_arn') and request.json.get('vendor_id'): try: bulk_ingestion_slack.bulk_ingest_failed(request.json) except Exception as e: g.log.error(f'Failed to send bulk ingest failure to Slack: {e}') return flaskify(result) @app.route(config.HEALTH_CHECK, methods=['GET']) def health() -> FlaskResponse: """Check the health of the application.""" return jsonify({'status': 'ok'}) @app.errorhandler(ows_status.INTERNAL_ERROR) def exception_handler(error: str) -> FlaskResponse: """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 = 'The server encountered an internal error and was unable to complete your request.' g.log.exception(error) return flaskify(response.create_fatal_response(message)) def _resolve_entity_ids(id_name: str, node_name: str, ids: list) -> list: """Resolve set of entity ids to real ids. Args: id_name (str): attribute name of non-default id node_name (str): node name in graph ids (list): values of attribute id_name to match on """ if ids and id_name != DEFAULT_NODE_ID: ids = subscriptions.resolve_node_ids(node_name, id_name, ids) return ids def _swap_entity_id(entity_data: dict[str, Any]) -> None: """Resolve and swap unique id if configured to use non-default id. Args: entity_data (dict): response from relationship validation Response: flask.wrappers.Response: on failure """ if entity_data['entity_node_id_name'] != DEFAULT_NODE_ID: node_type = entity_data['entity_node_type'] node_id_name = entity_data['entity_node_id_name'] node_id = entity_data['entity_id'] result = subscriptions.resolve_node_ids(node_type, node_id_name, [node_id]) if not result: return flaskify( response.create_error_response( status=ows_status.NOT_FOUND, code=error.ERROR_CODE_NOT_FOUND, message=f'{node_type}.{node_id_name} = {node_id} not found', ) ) if len(result) > 1: return flaskify( response.create_error_response( status=ows_status.CONFLICT, code=error.ERROR_CODE_VALIDATION_ERROR, message=f'{node_type}.{node_id_name} = {node_id} multiple found', ) ) entity_data['entity_id'] = result[0]