"""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."""