"""Lambda function module.""" import json from typing import Dict, List, Optional, Tuple, Union import uuid from xml.dom.minidom import parseString import boto3 import config from config import graphql_gateway from constants.error_message import SWEAR_WORD_FOUND from constants.queries import PRODUCT_BY_UPC from constants.rc_fields_format import FIELD_NAME_EMAIL_FORMAT from constants.status import EMAIL_FRIENDLY_STATUS from ddex_ingester_common.constants.country_codes import ( ALL_COUNTRY_CODES, WORLDWIDE, ) from ddex_ingester_common.constants.ddex_providers import ( ALTAFONTE, AWAL, RISING_88, SME, SME_ANALYTICS_PROVIDER, SOM_LIVRE_VENDOR_ID, ) from ddex_ingester_common.constants.genre_mapping import ( GENRE_ID_TO_NAME, SUBGENRE_ID_TO_NAME) from ddex_ingester_common.constants.release_type import VIDEO_RELEASE_TYPES from ddex_ingester_common.constants.status import IN_CONTENT from ddex_ingester_common.helpers.s3_ddex import load_ddex_json from ddex_ingester_common.logging import utils as logging_utils from ddex_ingester_common.models.s3.body import ( Body as S3Context ) from ddex_ingester_common.models.state_machine.body import ( Body as StateMachineContext ) from ddex_ingester_common.release_correction.release_correction_constants import ( # noqa RELEASE_CORRECTION_PRODUCT_ARTIST_FIELDS as RC_PRODUCT_ARTIST_FIELDS, RELEASE_CORRECTION_TRACK_ARTIST_FIELDS as RC_TRACK_ARTIST_FIELDS, RELEASE_CORRECTION_TRACK_WRITER_FIELDS as RC_TRACK_WRITER_FIELDS ) from ddex_ingester_common.release_correction.release_correction_diffs import ( ReleaseCorrectionDiffDetail) from ddex_ingester_common.release_correction.release_corrections_s3 import ( load_lambda_rc_json_files, load_rc_json) from ddex_ingester_common.schemas.s3_schema import S3Schema from ddex_ingester_common.schemas.state_machine_schema import ( StateMachineSchema ) from ddex_ingester_common.utils import ses from helpers import email as email_helper from lambdacommon.graphql import graphql from mako.lookup import TemplateLookup from marshmallow.utils import get_value logger = logging_utils.get_logger(config.app_logger) def handler(event: dict, context: object): """Report errors handler.""" logger.info(f'Triggered report_errors: {event}') # If the execution fails on parse_ddex we have no context data if not event.get('key') and not event.get('bucket'): handle_no_context_failure(event) return event context = StateMachineSchema().load(event) if context.is_purged_release: logger.info('Detected purged release.') if not context.errors: logger.info('No errors found for purged release.') return exit_without_email(context) # Purged DDEX is not detailed enough for a standard failure email else: logger.info('Found errors for purged release.') handle_no_context_failure(event) return event s3_data = S3Schema().load(load_ddex_json(event)) correlation_id = context.correlation_id or str(uuid.uuid4()) context.correlation_id = correlation_id logging_utils.update_logger_correlation_id(logger, correlation_id) logging_utils.update_logger_with_message_ids( logger, context.message_id, context.message_thread_id, context.execution_name ) graphql_gateway.set_headers( { 'Orchard-User-Id': config.OA_USER, 'Correlation-Id': correlation_id, } ) logger.info(f'Processing a DDEX with {context.product.status} status.') try: graphql_response = retrieve_product_data(context.product.upc) context.product.not_for_distribution =\ graphql_response.get('notForDistribution', None) except graphql.GraphQLError as graphql_error: raise graphql_error # Video products require their own path before checking Audio. if context.product.release_type in VIDEO_RELEASE_TYPES: logger.info('Handling video product path') # Check first for unsupported workflow s3_files = load_lambda_rc_json_files(event) logger.info(f'Data from S3 release correction files: {s3_files}') s3_release_corrections = [] for file in s3_files: if file.get('changes'): for change in file.get('changes'): s3_release_corrections.append(change) if context.product.status == IN_CONTENT: logger.info('Starting video workflow for in_content update.') # If the update has no changes we should not send an email if not s3_release_corrections: logger.info('Update to in_content video has no changes.') return exit_without_email(context) email_payload, email_subject = build_video_update_payload( context, s3_data, s3_release_corrections) # An update to an in_content video will always fail on validate_product elif not context.errors: logger.info('No errors found for video product.') return exit_without_email(context) else: email_payload, email_subject = video_product_path( context, s3_data, graphql_response ) # Assume audio product from here forwards. elif context.product.status == IN_CONTENT: # in_content DDEX always require an email, success or fail. email_payload, email_subject = in_content_ddex_email_path( context, s3_data, graphql_response ) if not email_payload: logger.info('No email payload determined. No email required.') return exit_without_email(context) else: # If we have no errors then we expect success and do not email. if not context.errors: return exit_without_email(context) # If we do have errors, process and email them appropriately. email_payload, email_subject = processing_ddex_email_path( context, s3_data, graphql_response ) subject_prefix = get_subject_prefix(context) if subject_prefix: email_subject = subject_prefix + ' ' + email_subject # Build recipient list recipients = [] cc_addresses = [] recipients, contact_details = build_recipient_list( context, graphql_response ) if not is_qa_env() and context.ddex_provider != SME_ANALYTICS_PROVIDER: if contact_details.get('assignedToEmail'): cc_addresses.append(contact_details.get('assignedToEmail')) logger.info('Production environment detected.' f' Using Label Manager email: {cc_addresses}') try: send_email(recipients, email_payload, email_subject, cc_addresses) except Exception as ex: raise EmailClientError('Failed to send email') from ex return StateMachineSchema().dump(context) def handle_no_context_failure(event: dict): """Send a failure email in cases where we have no context data. Our logic is coded to add as much detail to the email as possible but it's very strongly tied to the context object. This function handles the case where we don't have enough data to build the context object. An example of this is when an ingest fails on parse_ddex. At that stage the context is not yet populated but we still need to report the failure. """ logger.info('Starting flow to send email for no context failure.') upc, grid, catalog_number = get_ids_from_ddex(event) ddex_provider = event.get('ddex_provider', '') if ddex_provider == SME_ANALYTICS_PROVIDER: sme_product = 'SME Analytics Product' else: sme_product = 'SME Interop Product' recipients = check_for_email_cc_list(ddex_provider) email_subject = f'Failure to ingest {sme_product} {grid}' if upc: email_subject = f'Failure to ingest {sme_product} {upc}' error_message = None try: error_cause = eval(event.get('errors', {}).get('Cause', '')) if error_cause: error_message = error_cause.get('errorMessage') except Exception as ex: logger.info(f'Failed to read error message: {ex}') additional_details = get_no_context_known_errors_details(error_message) mylookup = TemplateLookup(directories=['constants/templates']) base_template = mylookup.get_template('no_context_failure.mak') email_payload = base_template.render( upc=upc, grid=grid, sony_product_id=catalog_number, error_message=error_message, additional_details=additional_details, # Rest of the table fields title=None, artist=None, format=None, owner=None, label=None, status=None, nfd=None, ).replace('\n', '') try: send_email(recipients, email_payload, email_subject, []) except Exception as ex: raise EmailClientError('Failed to send email') from ex logger.info('Successfully sent email.') def video_product_path( context: StateMachineContext, s3_data: S3Context, graphql_response: Dict) -> Tuple[str, str]: """Handle update attempts to Video products.""" if context.errors: email_payload, email_subject = create_email_template_for_known_error( context, context.errors, s3_data, graphql_response ) if context.ddex_provider == SME_ANALYTICS_PROVIDER: sme_product = 'SME Analytics Product' else: sme_product = 'SME Interop Product' if not email_payload: email_payload = build_general_fail_payload( context, s3_data ) email_subject = f'Failure to ingest {sme_product}'\ ' {0} - {1} - {2}'\ .format(context.product.upc, context.product.display_artist_name, context.product.product_name ) return email_payload, email_subject def processing_ddex_email_path( context: StateMachineContext, s3_data: S3Context, graphql_response: Dict) -> Tuple[str, str]: """Process context for a DDEX item undergoing ingestion.""" logger.info('Entered IN_PROCESSING product path.') if context.ddex_provider == SME_ANALYTICS_PROVIDER: sme_product = 'SME Analytics Product' else: sme_product = 'SME Interop Product' # We have errors but don't know if we need to treat them specially. if context.errors: email_payload, email_subject =\ create_email_template_for_known_error( context, context.errors, s3_data, graphql_response ) if not email_payload: # Do the generic email template email_payload = build_general_fail_payload( context, s3_data ) email_subject = f'Failure to ingest {sme_product}'\ ' {0} - {1} - {2}'\ .format(context.product.upc, context.product.display_artist_name, context.product.product_name ) return email_payload, email_subject def create_email_template_for_known_error( context: StateMachineContext, errors: Union[Dict, List], s3_data: S3Context, graphql_response: Dict) -> Tuple[str, str]: """Find any error codes we wish to raise to client as emails.""" for error in (errors if type(errors) is list else [errors]): email_payload, email_subject =\ generate_known_error_template( context, error, s3_data, graphql_response ) if email_payload: break return email_payload, email_subject def generate_known_error_template( context: StateMachineContext, errors: Union[Dict, List], s3_data: S3Context, graphql_response: Dict) -> Tuple[str, str]: """Return email content if known error is found.""" email_payload = None email_subject = None if not isinstance(errors, list): errors = [errors] if context.ddex_provider == SME_ANALYTICS_PROVIDER: sme_product = 'SME Analytics Product' else: sme_product = 'SME Interop Product' for error in errors: error_type = error.get('Error') error_cause = error.get('Cause') logger.info( f'Checking for a known error. ' f'Error Type: {error_type} Error Cause: {error_cause}') if (error_type == 'OrchardProductNotFoundException'): email_payload = build_no_orchard_product_payload(context, s3_data) email_subject = f'Failure to find {sme_product}'\ ' {0} - {1} - {2}'\ .format(context.product.upc, context.product.display_artist_name, context.product.product_name ) elif (error_type == 'LookupSwitchboardDealException'): email_payload = build_known_error_payload( context, s3_data, 'no_swb_deal_found.mak') email_subject = f'No Switchboard Deal for {sme_product}'\ ' {0} - {1} - {2}'\ .format(context.product.upc, context.product.display_artist_name, context.product.product_name ) elif (error_type == 'LookupSwitchboardInactiveDealException'): email_payload = build_known_error_payload( context, s3_data, 'inactive_swb_deal_found.mak') email_subject = ( f'No Active Switchboard Deal for {sme_product}' # noqa ' {0} - {1} - {2}' .format( context.product.upc, context.product.display_artist_name, context.product.product_name )) elif error_type == 'SubmitVideoProductValidationException': email_payload = build_submit_video_validation_failure_payload( context, s3_data) email_subject = f'Failure to submit {sme_product}'\ ' {0} - {1} - {2}'\ .format(context.product.upc, context.product.display_artist_name, context.product.product_name ) elif error_type == 'ProjectCodeMismatchException': email_payload = build_project_code_mismatch_payload( context, s3_data, graphql_response) email_subject = f'Failure to ingest {sme_product}'\ ' {0} - {1} - {2}'\ .format(context.product.upc, context.product.display_artist_name, context.product.product_name ) elif error_type == 'ReleaseCorrectionUpdateException': email_payload = build_release_correction_fail_payload( context, s3_data, graphql_response) email_subject = f'Invalid Update to In Content {sme_product}'\ ' {0} - {1} - {2}'\ .format(context.product.upc, context.product.display_artist_name, context.product.product_name ) elif error_type == 'ValidationRuleException': email_payload = build_validation_rule_rejection_payload( context, s3_data, graphql_response) email_subject = f'Validation failure on {sme_product}'\ ' {0} - {1} - {2}'\ .format(context.product.upc, context.product.display_artist_name, context.product.product_name ) elif error_type == 'SetTrackMetadataException': email_payload = build_set_track_metadata_fail_payload( context, s3_data, graphql_response, error) email_subject = f'Failure to ingest {sme_product}'\ ' {0} - {1} - {2}'\ .format(context.product.upc, context.product.display_artist_name, context.product.product_name ) elif error_type == 'PollVideoFatalException': if 'Cannot convert video to a standard resolution' in error_cause: # We have logic to fix the video asset resolution # This exception will get terraform to trigger that logic raise TriggerVideoResolutionFixException() email_payload = build_poll_video_fatal_exception_payload( context, s3_data, graphql_response, error) email_subject = f'Failure to ingest {sme_product}'\ ' {0} - {1} - {2}'\ .format(context.product.upc, context.product.display_artist_name, context.product.product_name ) elif error_type in [ 'SetProductException', 'SetProjectException', 'ProcessParticipantsException', ]: cause_string = eval(error_cause) error_message = cause_string['errorMessage'] email_payload = build_general_fail_payload( context, s3_data, errors=[error_message]) email_subject = f'Failure to ingest {sme_product} ' \ f'{context.product.upc} - ' \ f'{context.product.display_artist_name} - ' \ f'{context.product.product_name}' elif error_type == 'ProductValidationException': errors_list = extract_submit_validation_errors(error) if errors_list: # We have built a list of submit validation errors to email. email_payload = build_submit_validation_errors_payload( context, errors_list, s3_data) email_subject = f'Failure to submit {sme_product}'\ ' {0} - {1} - {2}'\ .format(context.product.upc, context.product.display_artist_name, context.product.product_name ) elif error_type == 'Exception': error_message = None try: cause_string = eval(error_cause) error_message = check_invalid_language_code( cause_string['errorMessage'] ) except Exception as e: logger.info(f'Failed to parse error message: {str(e)}') if error_message: email_payload = build_general_fail_payload( context, s3_data, errors=[error_message]) email_subject = f'Failure to ingest {sme_product} ' \ f'{context.product.upc} - ' \ f'{context.product.display_artist_name} - ' \ f'{context.product.product_name}' return email_payload, email_subject def in_content_ddex_email_path( context: StateMachineContext, s3_data: S3Context, graphql_response: Dict) -> Tuple[str, str]: """Process context for a DDEX item that is in_content.""" logger.info('Entered IN_CONTENT product path.') email_payload = None email_subject = None if context.ddex_provider == SME_ANALYTICS_PROVIDER: sme_product = 'SME Analytics Product' else: sme_product = 'SME Interop Product' event_dict = { 'key': context.key, 'bucket': context.bucket } s3_release_corrections = load_rc_json(event_dict).get('changes', []) if context.errors: # See if we know why in_content failed email_payload, email_subject =\ create_email_template_for_known_error( context, context.errors, s3_data, graphql_response ) # General fail path if not email_payload: logger.info('Unknown in_content error, send general fail.') email_payload = build_in_content_fail_payload( context, s3_data ) email_subject = f'Invalid Update to In Content {sme_product}'\ ' {0} - {1} - {2}'\ .format(context.product.upc, context.product.display_artist_name, context.product.product_name ) else: if context.ddex_provider == SME_ANALYTICS_PROVIDER: logger.info('Skipp success email payload generation') return email_payload, email_subject email_payload = build_in_content_success_payload( context, s3_data, graphql_response, s3_release_corrections ) email_subject = f'Update to In Content {sme_product}'\ ' {0} - {1} - {2}'\ .format(context.product.upc, context.product.display_artist_name, context.product.product_name ) return email_payload, email_subject def build_recipient_list( context: StateMachineContext, graphql_response: Dict) -> Tuple[List[str], Dict]: """Grab support CC list and retrieve Label Manager email.""" recipients = check_for_email_cc_list(context.ddex_provider) contact_details = {} # Add deal coordinators to the recipients if context.deal_coordinators: recipients.extend(context.deal_coordinators) # Add email addresses for that vendor/subaccount if context.product and context.product.vendor_id: vendor_id = context.product.vendor_id subaccount_id = context.product.subaccount_id rows = email_helper.get_email_addresses_for_major_label( logger, vendor_id, subaccount_id) if rows: for row in rows: recipients.append(row['email_address']) upc = context.product.upc try: contact_details = retrieve_contact_details(upc, graphql_response) except graphql.GraphQLError as graphql_error: raise graphql_error logger.info(f'Retrieved Label Manager contact email: {contact_details}') return recipients, contact_details def check_for_email_cc_list(ddex_provider: str) -> List: """Retrieve cc list from environment if it exists.""" if ddex_provider == SME_ANALYTICS_PROVIDER: cc_list = json.loads(config.REPORT_ERRORS_SME_ANALYTICS_DUMMY_EMAIL) else: cc_list = json.loads(config.REPORT_ERRORS_CC) if (cc_list): return cc_list return [] def retrieve_product_data(upc: str) -> Dict: """Query GraphQL and retrieve product data.""" logger.info( f'Checking for product with UPC: {upc}' ) result = graphql_gateway.execute( PRODUCT_BY_UPC, { 'upc': upc } )['data']['productByUpc'] logger.info(f'Product data: {result}') return {} if not result else result def retrieve_contact_details(upc: str, graphql_response: Dict) -> Dict: """Retrieve contact email.""" logger.info( f'Checking for contact email with UPC: {upc}') result = graphql_response.get('label', {}) """ If the vendor for the UPC is a D3 (i.e. it is a vendor that has subaccounts) then we expect to see a Vendor object that will have the assignedToEmail for the Label Manager for the D3. We want that assignedToEmail. """ logger.info(f'GraphQL output: {result}') if 'assignedToEmail' in result.get('vendor', {}): contact_details = result.get('vendor', {}) else: # Otherwise we grab details from Label contact_details = result logger.info(f'Contact details: {contact_details}') return contact_details def retrieve_release_date(upc: str, graphql_response: Dict) -> Dict: """Retrieve release date.""" logger.info( f'Checking for release date with UPC: {upc}') result = graphql_response.get('releaseDate') logger.info(f'GraphQL output: {result}') return result def extract_submit_validation_errors(errors: Dict) -> List: """Extract submit validation errors from context. ProductValidation lambda returns all errors in a list format e.g. { 'Error': 'ProductValidationException', 'ErrorMessage': [ [ 'Artwork is missing' ], [ 'ISRC: US23452354243: P-line is missing', 'ISRC: US23452354243: Localizations are missing' ] ] } """ collected_errors = [] if errors.get('Error') == 'ProductValidationException': error_messages = errors.get('ErrorMessage') for error_message in error_messages or []: if type(error_message) is list: collected_errors.extend(error_message) else: collected_errors.append(error_message) return collected_errors def send_email( recipients: List[str], email_payload: str, email_subject: str, cc_addresses: List): """Send email to given contact. Args: recipients (list): Recipient list email_payload (string): Email text content containing errors email_subject (string): Subject of email to be sent cc_addresses (list): CC list recipients Returns: list """ logger.info( f'Sending an email to: {recipients}' f' With CC: {cc_addresses}' f' With subject: {email_subject}' f' With payload {email_payload}' ) ses.Email().send( email_subject, config.EMAIL_FROM_ADDRESS, recipients, email_payload, None, cc_addresses ) def generate_payload_template( template_name: str, context: StateMachineContext, s3_data: S3Context, **kwargs: Dict) -> str: """Generate payload templates.""" mylookup = TemplateLookup(directories=['constants/templates']) base_template = mylookup.get_template(template_name) complete_template = base_template.render( upc=context.product.upc, sony_product_id=context.product.catalog_number, title=context.product.product_name, artist=context.product.display_artist_name, format=s3_data.product.release_type, owner=context.maintenance_owner, label=context.orchard_label, status=EMAIL_FRIENDLY_STATUS.get(context.product.status), thread_id=context.message_thread_id, grid=context.product.grid, nfd=context.product.not_for_distribution, # Fields specific to some templates errors=kwargs.get('errors'), vidops='vidops@theorchard.com', orch_proj_code=kwargs.get('orch_proj_code'), ddex_proj_code=kwargs.get('ddex_proj_code'), old_start_date=kwargs.get('old_start_date'), new_start_date=kwargs.get('new_start_date'), start_date=kwargs.get('start_date'), orig_release_date=kwargs.get('orig_release_date'), release_date=kwargs.get('release_date'), carveouts_updated=kwargs.get('carveouts_updated'), carveouts_old=kwargs.get('carveouts_old'), carveouts_new=kwargs.get('carveouts_new'), carveouts_added=kwargs.get('carveouts_added'), carveouts_removed=kwargs.get('carveouts_removed'), rc_errors=kwargs.get('rc_errors'), rc_audited_updates=kwargs.get('rc_audited_updates'), product_artist_updates=kwargs.get('product_artist_updates'), track_artist_updates=kwargs.get('track_artist_updates'), rc_warnings=kwargs.get('rc_warnings'), new_artwork=kwargs.get('new_artwork'), audio_asset_updates=kwargs.get('audio_asset_updates'), publisher_warnings=kwargs.get('publisher_warnings', []), genre_warnings=kwargs.get('genre_warnings', []), video_update_warnings=kwargs.get('video_update_warnings', []), ).replace('\n', '') return complete_template def build_no_orchard_product_payload( context: StateMachineContext, s3_data: S3Context) -> str: """Retrieve and build HTML template when no orchard product found.""" template_name = 'no_product_found.mak' complete_template = generate_payload_template( template_name, context, s3_data, ) return complete_template def build_known_error_payload( context: StateMachineContext, s3_data: S3Context, template_name: str) -> str: """Retrieve and build HTML template for known error.""" complete_template = generate_payload_template( template_name, context, s3_data, ) return complete_template def build_general_fail_payload( context: StateMachineContext, s3_data: S3Context, errors: List = []) -> str: """Retrieve and build HTML template for a generic fail email.""" template_name = 'generic_failure.mak' complete_template = generate_payload_template( template_name, context, s3_data, errors=errors, ) return complete_template def build_submit_video_validation_failure_payload( context: StateMachineContext, s3_data: S3Context) -> str: """Retrieve and build HTML template for a video validation failure.""" template_name = 'submit_video_validation_fail.mak' complete_template = generate_payload_template( template_name, context, s3_data, ) return complete_template def build_submit_validation_errors_payload( context: StateMachineContext, errors_list: Dict, s3_data: S3Context) -> str: """Retrieve and build HTML template using extracted data.""" template_name = 'submit_validation_fail.mak' complete_template = generate_payload_template( template_name, context, s3_data, errors=errors_list, ) return complete_template def build_in_content_fail_payload( context: StateMachineContext, s3_data: S3Context) -> str: """Retrieve and build HTML template for in content processing failure.""" template_name = 'update_failure.mak' complete_template = generate_payload_template( template_name, context, s3_data, ) return complete_template def build_in_content_success_payload( context: StateMachineContext, s3_data: S3Context, graphql_response: Dict, s3_release_corrections: List[Dict]) -> str: """Retrieve and build HTML template for in content processing success.""" template_name = 'update_success.mak' rc_warnings = [] rc_audited_updates = [] product_artist_updates = [] track_artist_updates = [] track_artist_isrc_updates = set() RC_TRACK_ARTISTS = {**RC_TRACK_ARTIST_FIELDS, **RC_TRACK_WRITER_FIELDS} for change in s3_release_corrections: detail = ReleaseCorrectionDiffDetail(*change) if detail.field_name in RC_PRODUCT_ARTIST_FIELDS.keys() and\ detail.db_table_name == 'releases': if not product_artist_updates: product_artist_updates.append( 'Product Artists have been updated.') elif detail.field_name in RC_TRACK_ARTISTS.keys() and\ detail.db_table_name == 'track': if detail.isrc and detail.isrc not in track_artist_isrc_updates: track_artist_isrc_updates.add(detail.isrc) track_artist_updates.append( f'Track Artists have been updated. ({detail.isrc})') elif detail.field_name == 'trackGrats': for tuid in detail.new.keys(): message = { 'field_name': f'Instant grat (TUID: {tuid})', 'old_value': detail.old.get(tuid) or 'No Value Found', 'new_value': detail.new.get(tuid) or 'No Value Found' } rc_audited_updates.append(message) elif detail.field_name == 'genreId': message = { 'field_name': 'Genre', 'old_value': GENRE_ID_TO_NAME.get( detail.old, 'No value found'), 'new_value': GENRE_ID_TO_NAME.get( format_detail_values_for_email(detail.new)) } rc_audited_updates.append(message) elif detail.field_name == 'subgenreId': message = { 'field_name': 'Subgenre', 'old_value': SUBGENRE_ID_TO_NAME.get( detail.old, 'No value found'), 'new_value': SUBGENRE_ID_TO_NAME.get( format_detail_values_for_email(detail.new)) } rc_audited_updates.append(message) elif detail.email_customer: message = { 'field_name': format_correction_field_name(detail), 'old_value': detail.old or 'No value found', 'new_value': format_detail_values_for_email(detail.new) } rc_audited_updates.append(message) orig_start_date = context.product.original_values.sale_start_date updated_start_date = graphql_response.get('saleStartDate') orig_start_date = compare_start_dates( orig_start_date, updated_start_date ) orig_release_date = context.product.original_values.release_date release_date = retrieve_release_date(context.product.upc, graphql_response) orig_release_date = compare_release_dates( orig_release_date, release_date ) orig_carveouts = context.product.original_values.carveout_country_codes new_carveouts = format_graphql_carveouts( graphql_response.get('productTerritoryCarveouts')) carveout_data = format_carveout_changes(orig_carveouts, new_carveouts) logger.info(f'Carveout data identified: {carveout_data}') context_warnings = context.warnings or [] publisher_warnings = [] genre_warnings = [] for warning in context_warnings: if warning.get('field') == 'publisherNames': retrieve_publisher_exception(warning, publisher_warnings) continue if warning.get('field') == 'genre': genre_warnings.append(warning) continue rc_warnings.append( FIELD_NAME_EMAIL_FORMAT.get(warning.get('field')) or warning.get('field').title() ) new_artwork_assets = context.product.artwork audio_assets = [] for track in s3_data.tracks: if track.asset: audio_assets.append( f'Audio asset updated. ({track.isrc})') # Check all values that trigger a change notification within the template. if not (orig_release_date or rc_audited_updates or product_artist_updates or track_artist_updates or carveout_data.get('carveouts_updated') or rc_warnings or audio_assets or new_artwork_assets): logger.info('No updates found for in_content update path.') return None complete_template = generate_payload_template( template_name, context, s3_data, orig_release_date=orig_release_date, release_date=release_date, old_start_date=orig_start_date, new_start_date=updated_start_date, carveouts_updated=carveout_data.get('carveouts_updated'), carveouts_old=carveout_data.get('carveouts_old'), carveouts_new=carveout_data.get('carveouts_new'), carveouts_added=carveout_data.get('carveouts_added'), carveouts_removed=carveout_data.get('carveouts_removed'), rc_audited_updates=sorted(rc_audited_updates, key=get_field_name), product_artist_updates=product_artist_updates, track_artist_updates=track_artist_updates, rc_warnings=rc_warnings, new_artwork=new_artwork_assets, audio_asset_updates=audio_assets, publisher_warnings=publisher_warnings, genre_warnings=genre_warnings ) return complete_template def format_carveout_changes( orig_carveouts: List[str], new_carveouts: List[str]) -> Dict: """Return formatted carveout territory differences for email payload.""" carveout_data = { 'carveouts_updated': True, 'carveouts_old': '', 'carveouts_new': '', 'carveouts_added': '', 'carveouts_removed': '' } orig_carveouts_sorted = sorted( orig_carveouts ) if orig_carveouts else [] new_carveouts_sorted = sorted( new_carveouts ) if new_carveouts else [] if not carveouts_changed(orig_carveouts_sorted, new_carveouts_sorted): # Mako email templates like None better than True carveout_data['carveouts_updated'] = None return carveout_data added = sorted(list( set(new_carveouts_sorted) - set(orig_carveouts_sorted))) removed = sorted(list( set(orig_carveouts_sorted) - set(new_carveouts_sorted))) carveout_data['carveouts_old'] = list_to_string(orig_carveouts_sorted) carveout_data['carveouts_new'] = list_to_string(new_carveouts_sorted) carveout_data['carveouts_added'] = list_to_string(added) carveout_data['carveouts_removed'] = list_to_string(removed) return carveout_data def list_to_string(carveout_list: List[str]) -> str: """Format list of strings for processing.""" return ', '.join(carveout_list) def build_video_update_payload( context: StateMachineContext, s3_data: S3Context, release_corrections: List[ReleaseCorrectionDiffDetail]) -> str: """Build error email for failed Video updates.""" logger.info('Building Video Update payload for invalid video workflows.') template_name = 'fail_update_video.mak' vidops_email = retrieve_vidops_email() if context.ddex_provider == SME_ANALYTICS_PROVIDER: sme_product = 'SME Analytics Product' else: sme_product = 'SME Interop Product' update_warnings = [] for update in release_corrections or []: detail = ReleaseCorrectionDiffDetail(*update) field = FIELD_NAME_EMAIL_FORMAT.get( detail.field_name, detail.field_name.title()) if detail.field_name == 'genreId': warning = { 'field': field, 'current_value': GENRE_ID_TO_NAME.get( detail.old, 'No value found'), 'new_value': GENRE_ID_TO_NAME.get( format_detail_values_for_email(detail.new)) } elif detail.field_name == 'subgenreId': warning = { 'field': field, 'current_value': SUBGENRE_ID_TO_NAME.get( detail.old, 'No value found'), 'new_value': SUBGENRE_ID_TO_NAME.get( format_detail_values_for_email(detail.new)) } else: warning = { 'field': field, 'current_value': detail.old, 'new_value': detail.new } update_warnings.append(warning) logger.info(f'Video Update warnings: {update_warnings}') complete_template = generate_payload_template( template_name, context, s3_data, vidops=vidops_email, video_update_warnings=update_warnings, ) email_subject = 'Failure to update {3} {4}'\ ' {0} - {1} - {2}'\ .format(context.product.upc, context.product.display_artist_name, context.product.product_name, EMAIL_FRIENDLY_STATUS.get(context.product.status), sme_product) return complete_template, email_subject def build_project_code_mismatch_payload( context: StateMachineContext, s3_data: S3Context, graphql_response: Dict) -> str: """Build error email for project code mismatch during update.""" template_name = 'fail_project_code_mismatch.mak' vidops_email = retrieve_vidops_email() ddex_proj_code = s3_data.project.project_code orch_proj_code = graphql_response['project']['projectCode'] complete_template = generate_payload_template( template_name, context, s3_data, vidops=vidops_email, orch_proj_code=orch_proj_code, ddex_proj_code=ddex_proj_code, ) return complete_template def format_detail_values_for_email(detail: any) -> any: """Apply RC value data formatting rules here.""" # Subgenre is a list with a single int. if type(detail) is list and len(detail) == 1: return detail[0] return detail def build_release_correction_fail_payload( context: StateMachineContext, s3_data: S3Context, graphql_response: Dict) -> str: """Build error email for failure during RC update.""" template_name = 'release_correction_failure.mak' event_dict = { 'key': context.key, 'bucket': context.bucket } s3_release_corrections = load_rc_json(event_dict) # Set to avoid duplicates rc_errors = set( format_correction_field_name(ReleaseCorrectionDiffDetail(*change)) for change in s3_release_corrections.get('changes') ) context_warnings = context.warnings or [] publisher_warnings = [] genre_warnings = [] for warning in context_warnings: if warning.get('warning_type') == 'GenreMappingNotFoundException': continue if warning.get('field') == 'publisherNames': retrieve_publisher_exception(warning, publisher_warnings) continue if warning.get('field') == 'genre': genre_warnings.append(warning) continue rc_errors.add( FIELD_NAME_EMAIL_FORMAT.get(warning.get('field')) or warning.get('field').title() ) if context.product.original_values: current_carveouts = context.product.original_values.carveout_country_codes # noqa else: current_carveouts = format_graphql_carveouts( graphql_response.get('productTerritoryCarveouts')) new_carveouts = format_carveouts(s3_data) carveout_data = format_carveout_changes(current_carveouts, new_carveouts) complete_template = generate_payload_template( template_name, context, s3_data, rc_errors=rc_errors, publisher_warnings=publisher_warnings, genre_warnings=genre_warnings, carveouts_updated=carveout_data.get('carveouts_updated'), carveouts_old=carveout_data.get('carveouts_old'), carveouts_new=carveout_data.get('carveouts_new'), carveouts_added=carveout_data.get('carveouts_added'), carveouts_removed=carveout_data.get('carveouts_removed'), ) return complete_template def build_validation_rule_rejection_payload( context: StateMachineContext, s3_data: S3Context, graphql_response: Dict) -> str: """Build error email for validation rule rejection.""" template_name = 'validation_rule_rejection.mak' rule_errors = [] errors = context.errors for error in (errors if type(errors) is list else [errors]): if error and error['Error'] == 'ValidationRuleException': error_cause = eval(error['Cause']) validation_rules = eval(error_cause['errorMessage']) for rule in validation_rules or []: rule_errors.append(rule) complete_template = generate_payload_template( template_name, context, s3_data, errors=rule_errors, ) return complete_template def build_set_track_metadata_fail_payload( context: StateMachineContext, s3_data: S3Context, graphql_response: Dict, error: Dict) -> str: """Build error email for validation rule rejection.""" errors = [] error_message = eval(error['Cause'])['errorMessage'] # For known errors, the failing lambda adds the track ISRC to the message if SWEAR_WORD_FOUND in error_message: errors.append('Swear word found on the lyrics of non explicit track ' f'with ISRC: {eval(error_message)["ISRC"]}.') if errors: return build_general_fail_payload( context, s3_data, errors=errors) return None def build_poll_video_fatal_exception_payload( context: StateMachineContext, s3_data: S3Context, graphql_response: Dict, error: Dict) -> str: """Build error email for poll video errors.""" errors = None error_message = eval(error['Cause'])['errorMessage'] # Split the errors from the exception message split_errors = error_message.split('Errors:', 1) try: errors = [ error.strip() for error in split_errors[1].strip().split('|') ] except IndexError: return None if errors: return build_general_fail_payload( context, s3_data, errors=errors) return None def carveouts_changed(old: List[str], new: List[str]) -> bool: """Check if carveouts have changed.""" return old != new def compare_start_dates(old: str, new: str) -> Union[None, bool]: """Check if start dates have changed.""" return None if str(old) == str(new) else old def compare_release_dates(old: str, new: str) -> Union[None, bool]: """Check if release dates have changed. Will set release date to None if no change has happened This tells the template engine not to use these values. """ return None if str(old) == str(new) else old def is_qa_env() -> bool: """Check environment variables for QA environment prefix.""" return config.ENVIRONMENT == 'qa' def retrieve_vidops_email() -> str: """Retrieve vidops email address from global variables.""" return config.VIDOPS_EMAIL_ADDRESS def exit_without_email(context: StateMachineContext) -> StateMachineSchema: """Handle a success situation where no emails are required.""" logger.info('No email required. Ending lambda.') return StateMachineSchema().dump(context) def format_correction_field_name(detail: ReleaseCorrectionDiffDetail) -> str: """Format release correction field name for the email.""" field_name = detail.field_name formatted_field = \ FIELD_NAME_EMAIL_FORMAT.get(field_name) or field_name.title() if detail.isrc: formatted_field += f' ({detail.isrc})' return formatted_field def get_field_name(rc_audited_update: Dict) -> str: """Get field name for rc_audited_updates, used to sort by field name.""" return rc_audited_update['field_name'] def retrieve_publisher_exception( warning: Dict, publisher_warnings: List[Dict]): """Retrieve Publisher warning data from context.warning object.""" ex = warning.get('exception') curr_val_string = '' for pub in ex.get('current_values'): curr_val_string += pub + '
' new_val_string = '' for pub in ex.get('new_values'): new_val_string += pub + '
' publisher_warnings.append({ 'isrc': f'Publishers ({ex.get("isrc")})', 'current_values': curr_val_string, 'new_values': new_val_string, }) logger.info(f'Added publisher warning: {publisher_warnings}') def retrieve_nested_messages( data_structure: Union[Dict, List], message_key: str = '') -> List[str]: """Retrieve the error messages from a nested data structure.""" if not data_structure: return [] messages = [] if type(data_structure) is not list: data_structure = [data_structure] for data in data_structure: if type(data) is str: messages.append(data) elif type(data) is dict: if data.get(message_key): messages.append(data[message_key]) return messages def get_product_deal_terms(s3_data: S3Context) -> Optional[List]: """Get deal term for R0 if not then R1 else None.""" for deal in s3_data.deals: if 'R0' in deal.release_references: return deal.deal_terms if 'R1' in deal.release_references: return deal.deal_terms return None def format_carveouts(s3_data: S3Context) -> Optional[Dict]: """Extract excluded territories from product deal terms.""" deal_terms = get_product_deal_terms(s3_data) available_territories = [] for deal_term in deal_terms: if deal_term.takedown: return None if deal_term.territories: if WORLDWIDE in deal_term.territories: available_territories = ALL_COUNTRY_CODES else: available_territories.extend(deal_term.territories) elif deal_term.excluded_territories: available_territories.extend( list(set( ALL_COUNTRY_CODES) - set(deal_term.excluded_territories))) excluded_territories = list( set(ALL_COUNTRY_CODES) - set(available_territories)) return excluded_territories def format_graphql_carveouts(graphql_data: List[Dict]) -> List[str]: """Extract excluded territories from GraphQL productTerritoryCarveouts.""" excluded_country_codes = [d['countryCode'] for d in graphql_data] return excluded_country_codes def get_no_context_known_errors_details(error_message: str) -> str: """Return email text that provides more details for known errors.""" if not error_message: return '' details = check_invalid_language_code(error_message, only_details=True) if details: return f'

{details}

' return '' def check_invalid_language_code( error_message: str, only_details: bool = False) -> str | None: """Check if the error message is of type invalid language code.""" details = "Please update the Product's Metadata Language in the source system and try the ingestion again." # noqa: E501 if 'Invalid language code ' in error_message: if only_details: return details else: return f'{error_message}. {details}' return None def get_ids_from_ddex(event: dict) -> (str, str): """Get ids directly from the DDEX file if the schema failed to parse.""" bucket = get_value( event, 'detail.requestParameters.bucketName', None) key = get_value( event, 'detail.requestParameters.key', None) if not bucket and 'bucket' in event: bucket = event['bucket'] if not key and 'key' in event: key = event['key'] logger.info(f'Bucket: {bucket} Key: {key}') s3_client = boto3.client('s3') file = s3_client.get_object(Bucket=bucket, Key=key) upc = None grid = None catalog_number = None try: doc = parseString(file.get('Body').read()) release_list = doc.getElementsByTagName('Release') for release in release_list: if release.getAttribute('IsMainRelease'): release_id = release.getElementsByTagName('ReleaseId')[0] grid_node = release_id.getElementsByTagName('GRid')[0] upc_node = release_id.getElementsByTagName('ICPN')[0] c_nr_node = release_id.getElementsByTagName('CatalogNumber')[0] grid = grid_node.firstChild.data upc = upc_node.firstChild.data catalog_number = c_nr_node.firstChild.data logger.info( f'UPC: {upc} GRID: {grid} Catalog Number: {catalog_number}' ) break except Exception: # "sme_ddex/A10301A0005148130K_20230914202005146/A10301A0005148130K.xml" grid = key.split('/')[2].split('.')[0] if key else None logger.info(f'{key} is a malformed XML or not an XML file.') return upc, grid, catalog_number def get_subject_prefix(context: StateMachineContext) -> str: """Return the prefix for the email subject.""" if context.product.vendor_id == SOM_LIVRE_VENDOR_ID: return '[Som Livre]' ddex_provider_prefix = { AWAL: 'AWAL', RISING_88: '88rising', ALTAFONTE: 'Altafonte', SME_ANALYTICS_PROVIDER: 'SME Analytics', } if context.ddex_provider != SME: return '[' + ddex_provider_prefix[context.ddex_provider] + ']' return '' class EmailClientError(Exception): """Email Client exception.""" class TriggerVideoResolutionFixException(Exception): """Exception detected by terraform to trigger video resolution fix."""