"""set-preorder-info.""" from datetime import datetime from typing import Dict, List, NamedTuple import uuid import config from config import graphql_gateway from constants.constants import ALTAFONTE_CUTOVER_DATE from constants.instant_grat_stores import ( INSTANT_GRAT_STORE_IDS ) from constants.queries import ( GET_PRODUCT_BY_UPC, SET_INSTANT_GRATS, UPDATE_PRODUCT, ) from ddex_ingester_common.constants.ddex_providers import ALTAFONTE from ddex_ingester_common.constants.swb_deal_types import FOR_DISTRIBUTION 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.s3.deal import Deal from ddex_ingester_common.models.s3.deal_term import DealTerm from ddex_ingester_common.models.state_machine.body import ( Body as StateMachineContext) from ddex_ingester_common.release_correction.release_correction import ( is_release_correction) from ddex_ingester_common.release_correction.release_correction_constants import ( # noqa RELEASE_CORRECTION_SCHEDULING_FIELDS ) from ddex_ingester_common.release_correction.release_correction_diffs import ( ReleaseCorrectionDiffDetail) from ddex_ingester_common.release_correction.release_corrections_s3 import ( load_rc_json, write_rc_json) from ddex_ingester_common.schemas.s3_schema import S3Schema from ddex_ingester_common.schemas.state_machine_schema import ( StateMachineSchema ) from set_main_release_date import retrieve_release_dates logger = logging_utils.get_logger(config.app_logger) def handler(event: Dict, context: Dict) -> Dict: """Lambda entry point.""" s3_data = S3Schema().load(load_ddex_json(event)) context = StateMachineSchema().load(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, s3_data.message_id, s3_data.message_thread_id, s3_data.execution_name ) graphql_gateway.set_headers( { 'Orchard-User-Id': config.OA_USER, 'Correlation-Id': correlation_id, } ) deals = s3_data.deals logger.info('Setting preorder deal data using deals: ' f"{S3Schema().dump(s3_data)['deals']}") if not has_preorder_deals(deals): logger.info('No preorder deals found. Exiting lambda.') return StateMachineSchema().dump(context) if before_altafonte_cutover(s3_data, context.ddex_provider): logger.info('Sales start date before Altafonte cutover exiting Lambda') return StateMachineSchema().dump(context) earliest_preorder_term = retrieve_earliest_preorder(deals) graphql_response = retrieve_product_information(context.product.upc) invalid_dates = collect_invalid_preview_messages(earliest_preorder_term) special_instructions = None if invalid_dates: special_instructions = generate_special_instructions( invalid_dates, graphql_response.get('specialInstructions') ) previewable = is_previewable(earliest_preorder_term) preorder_date = define_preorder_date(earliest_preorder_term) instant_grats = build_instant_grats(context, deals) if is_release_correction(context, check_nfd=False): s3_release_corrections = load_rc_json(event) diff_for_release_corrections( context, s3_data, graphql_response, s3_release_corrections, previewable, preorder_date, instant_grats) if context.product.not_for_distribution == FOR_DISTRIBUTION: return StateMachineSchema().dump(context) update_preorder_data( context, preorder_date, previewable, special_instructions) if instant_grats.get('trackGrats'): set_instant_grats(instant_grats) return StateMachineSchema().dump(context) def strip_time_component(dt: str) -> str: """Strip time if datetime or attempt to slice string.""" date = None if not dt: return None try: dt = datetime.strptime(dt, '%Y-%m-%d %H:%M:%S') date = dt.date() except ValueError: date = dt[0:10] return str(date) def has_preorder_deals(deals: List[Deal]) -> bool: """Check if preorder deals exist in deal terms.""" for deal in deals: for term in deal.deal_terms: if term.pre_order: return True return False def get_earliest_date(one: str, two: str) -> str: """None-safe date comparison between two strings.""" if not one or not two: return one or two if one > two: return two return one def retrieve_earliest_preorder(deals: Deal) -> DealTerm: """Retrieve earliest pre-order deal terms object.""" earliest_deal_terms = {} earliest_date_seen = None for deal in deals: for term in deal.deal_terms: if not term.pre_order: continue start_date = term.start_date start_date_time = term.start_date_time early_date = None early_date = get_earliest_date(start_date, start_date_time) if not earliest_date_seen and early_date: earliest_date_seen = early_date earliest_deal_terms = term if early_date and early_date < earliest_date_seen: earliest_date_seen = early_date earliest_deal_terms = term return earliest_deal_terms def is_previewable(earliest_preorder: DealTerm) -> bool: """Set preview Y/N for product.""" pre_order_date = get_earliest_date( earliest_preorder.start_date, earliest_preorder.start_date_time) if not earliest_preorder.clip_preview_date: return False clip_preview_date = earliest_preorder.clip_preview_date return clip_preview_date <= pre_order_date def collect_invalid_preview_messages( preorder_deal: DealTerm) -> List[str]: """Return invalid preview dates as special instruction string.""" invalid = [] track_preview_date = strip_time_component( preorder_deal.track_listing_preview_date) cover_art_preview_date = strip_time_component( preorder_deal.cover_art_preview_date) pre_order_date = strip_time_component( get_earliest_date( preorder_deal.start_date, preorder_deal.start_date_time)) if track_preview_date and\ track_preview_date > pre_order_date: invalid.append( f'NOTE: The SME Track Listing Preview Date {track_preview_date}' + f' is AFTER preorder date {pre_order_date}') if cover_art_preview_date and\ cover_art_preview_date > pre_order_date: invalid.append( f'NOTE: The SME Covert Art Preview Date {cover_art_preview_date}' + f' is AFTER preorder date {pre_order_date}') return invalid def retrieve_product_information(upc: str) -> Dict: """Retrieve product details from GraphQL.""" logger.info( f'Running get products by upc with UPC: {upc}') result = graphql_gateway.execute( GET_PRODUCT_BY_UPC, { 'upc': upc } )['data']['productByUpc'] logger.info( f'Product information found: {result}' ) return result def generate_special_instructions( invalid_dates: List[str], special_instructions: str) -> str: """Add special instructions to existing instructions.""" invalid_strings = '' for field in invalid_dates: invalid_strings += field + '\n' if special_instructions: return invalid_strings + special_instructions else: return invalid_strings def update_preorder_data( context: StateMachineContext, earliest_date: str, previewable: bool, special_instructions: str): """Call GraphQL to update product preorder data.""" product_id = context.product.product_id formatted_previewable = format_previewable_field(previewable) payload = { 'data': { 'productId': product_id, 'preorderDate': earliest_date, 'previewable': formatted_previewable } } if special_instructions: logger.info( f'Adding special instructions to update product :' f'{special_instructions}') payload['data']['specialInstructions'] = special_instructions logger.info(f'Running update product with payload:\n{payload}') graphql_gateway.execute( UPDATE_PRODUCT, payload )['data']['updateProduct'] def define_preorder_date(term: DealTerm) -> str: """Define PreorderDate based on DealTerm information.""" return get_earliest_date( term.start_date, strip_time_component(term.start_date_time)) def build_instant_grats(context, deals: List[Deal]) -> Dict: """Build instant grats from Deals.""" grats = collect_instant_grat_terms(deals) instant_grats = { 'productId': context.product.product_id, } trackGrats = [] existing_grat_tuids = set() for grat in grats or []: start_date = grat.instant_gratifications.start_date datetime = grat.instant_gratifications.start_date_time stripped_datetime = strip_time_component(datetime) date = get_earliest_date(start_date, stripped_datetime) references = grat.instant_gratifications.references for reference in references: instant_grat = create_instant_grat(context, reference, date) if instant_grat['tuid'] not in existing_grat_tuids: trackGrats.append(instant_grat) existing_grat_tuids.add(instant_grat['tuid']) instant_grats['trackGrats'] = trackGrats return instant_grats def collect_instant_grat_terms(deals: List[Deal]) -> List[DealTerm]: """Return all instant grat DealTerms from Deals.""" collected_terms = [] for deal in deals: for term in deal.deal_terms: if term.pre_order and term.instant_gratifications: collected_terms.append(term) return collected_terms def create_instant_grat( context: object, reference: str, date: str, store_ids=INSTANT_GRAT_STORE_IDS) -> List[Dict]: """Create individual instant grats from provided data.""" trackGrat = {} tuid = None for track in context.tracks: if track.resource_reference == reference: tuid = track.tuid break if not tuid: raise ValueError(f'No tuid found for resource_reference: {reference}') trackGrat['tuid'] = tuid trackGrat['grats'] = [] for store_id in store_ids.values(): trackGrat['grats'].append( { 'date': date, 'storeId': store_id } ) return trackGrat def set_instant_grats(instant_grats: Dict): """Set instant grats in GraphQL.""" payload = {'data': instant_grats} logger.info(f'Running set instant grats with payload:\n{payload}') response = graphql_gateway.execute( SET_INSTANT_GRATS, payload ) logger.info(f'Instant grat response: {response}') def diff_for_release_corrections( context: StateMachineContext, s3_data: S3Context, graphql_response: Dict, s3_release_corrections: Dict, previewable: bool, preorder_date: Dict, instant_grats: Dict): """Check for items requiring release correction.""" logger.info('Checking for differences between Orchard and DDEX to add to release correction') # noqa # Check invalid fields before proceeding. rc_details = [] event_details = {'bucket': context.bucket, 'key': context.key} check_for_unsupported_updates( context, s3_data, graphql_response, s3_release_corrections, previewable, preorder_date, instant_grats, event_details) fields = { 'previewable': ReleaseCorrectionValuePair( old=graphql_response.get('previewable'), new=format_previewable_field(previewable)), 'preorderDate': ReleaseCorrectionValuePair( old=graphql_response.get('preorderDate'), new=preorder_date), 'trackGrats': ReleaseCorrectionValuePair( old=format_graphql_instant_grats(graphql_response.get('tracks')), new=format_s3_instant_grats(instant_grats.get('trackGrats'))), } for key in fields.keys(): if fields[key].new and fields[key].old != fields[key].new: rc_details.append(create_release_correction_detail( key, fields[key].old, fields[key].new)) if rc_details: s3_release_corrections['changes'].extend(rc_details) write_rc_json(event_details, s3_release_corrections) def create_release_correction_detail( field_name: str, old_value: any, new_value: any) -> ReleaseCorrectionDiffDetail: """Create ReleaseCorrectionDiffDetail using given values.""" logger.info(f'Creating Release Correction Detail for field: {field_name}' f' with old value: {old_value}' f' and new value: {new_value}') rc_product_field = RELEASE_CORRECTION_SCHEDULING_FIELDS[field_name] return ReleaseCorrectionDiffDetail( field_name=field_name, isrc=None, old=old_value, new=new_value, db_table_name=rc_product_field.db_table_name, db_field_name=rc_product_field.db_field_name, email_customer=rc_product_field.email_customer, accept_update=rc_product_field.accept_update) def format_previewable_field(previewable: bool) -> str: """Translate bool into usable previewable string.""" return 'yes' if previewable else 'no' def format_s3_instant_grats(instant_grats: List) -> Dict: """Format S3 instant grats for comparison.""" formatted_grats = {} for grat in instant_grats: formatted_grats[str(grat.get('tuid'))] =\ grat.get('grats', [])[0].get('date') return formatted_grats def format_graphql_instant_grats(tracks: List) -> Dict: """Format GraphQL instant grats for comparison.""" formatted_grats = {} for track in tracks: for grat in track.get('instantGrats') or []: formatted_grats[grat.get('track').get('tuid')] =\ strip_time_component(grat.get('date')) return formatted_grats def check_for_unsupported_updates( context: StateMachineContext, s3_data: S3Context, graphql_response: Dict, s3_release_corrections: Dict, previewable: bool, preorder_date: str, instant_grats: Dict, event: Dict): """Check if unsupported fields are updated.""" error_message = '' rc_details = [] if context.product.not_for_distribution == FOR_DISTRIBUTION: gql_preview = graphql_response.get('previewable') formatted_previewable = format_previewable_field(previewable) if gql_preview != formatted_previewable: field_name = 'previewable' rc_details.append(create_release_correction_detail( field_name, gql_preview, formatted_previewable)) # Write details to json before we raise exception. s3_release_corrections['changes'].extend(rc_details) error_message += ( f'Product Previewable status update attempt. DDEX status ' f'{formatted_previewable} ' f'does not match The Orchard\'s status ' # noqa f'{gql_preview}\n') gql_preorder_date = graphql_response.get('preorderDate') if preorder_date != gql_preorder_date: field_name = 'preorderDate' rc_details.append(create_release_correction_detail( field_name, preorder_date, gql_preorder_date)) # Write details to json before we raise exception. s3_release_corrections['changes'].extend(rc_details) error_message += ( f'Product preorder date update attempt. DDEX product ' f'{preorder_date} ' f'does not match The Orchard\'s product ' # noqa f'{gql_preorder_date}\n') # Instant grats check gql_grats = format_graphql_instant_grats( graphql_response.get('tracks')) formatted_grats = format_s3_instant_grats( instant_grats.get('trackGrats')) if gql_grats != formatted_grats: field_name = 'trackGrats' rc_details.append(create_release_correction_detail( field_name, gql_grats, formatted_grats)) # Write details to json before we raise exception. s3_release_corrections['changes'].extend(rc_details) error_message += ( f'Product TrackGrats update attempt. DDEX values ' f'{formatted_grats} ' f'does not match The Orchard\'s grats ' # noqa f'{gql_grats}\n') if error_message: # Remove last \n error_message = error_message[:-1] write_rc_json(event, s3_release_corrections) raise ReleaseCorrectionUpdateException(error_message) def before_altafonte_cutover(s3_data: S3Context, ddex_provider: str): """Check if a date is before the Altafonte preorder cutover date.""" if ddex_provider != ALTAFONTE: return False sales_start_date = retrieve_release_dates( s3_data.deals, ddex_provider ).get('sales_start_date') if not sales_start_date: return False cutover_date = datetime.strptime(ALTAFONTE_CUTOVER_DATE, '%Y-%m-%d') sale_date = datetime.strptime(sales_start_date, '%Y-%m-%d') return sale_date < cutover_date class ReleaseCorrectionUpdateException(Exception): """Release correction update exception.""" class ReleaseCorrectionValuePair(NamedTuple): """Contains the current and incoming values for a DDEX field.""" new: any old: any