"""GraphQL logic.""" import json from connectors.logging import log from connectors.graphql import ( GraphQLBackoffConnector, GraphQLError ) from constants import graphql_queries from constants.participant_fields import ( TRACK_PERFORMER_ROLE, TRACK_PERFORMER_TYPE, PARTICIPANT_ROLES ) from utils.concurrency_utils import prepare_errors_dict from utils.graphql_utils import ( format_label_participant, format_track_contributor, format_product_id_by_upc, format_track_localizations_update, format_product_localization_update, ) from config import ( ALLOW_NULL_ARTIST_IDS, LOG_FREQUENCY, LOGGER_LEVEL, PRINT_STACK_TRACES, ) if PRINT_STACK_TRACES: from traceback_with_variables import activate_by_import # noqa # create GraphQL connector graphql_conn = GraphQLBackoffConnector() def set_track_contributions( tuid: str, vendor_id: int, subaccount_id: int, participations: list, performers: list = None, publishers: list = None, task_id: int = None) -> tuple: """Set the track contributions. Args: tuid (str): The track TUID. vendor_id (int): The vendor ID. subaccount_id (int): The subaccount ID. participations (list): The participations data. performers (list, optional): The performers data. Defaults to None. publishers (list, optional): The publishers data. Defaults to None. task_id (int, optional): The task ID if called from a multi-threaded process. Defaults to None. Returns: dict: The response data from the GraphQL mutation. list: A list of errors. """ # Init error = None data = None # Get counts of elements in compound objects participation_count = len(participations) performer_count = len(performers) if performers else 0 publisher_count = len(publishers) if publishers else 0 # Log to user msg = f"Writing " \ f"{participation_count} participations, {performer_count} " \ f"performers, and {publisher_count} publishers to TUID: {tuid}, " \ f"VENDOR_ID: {vendor_id}, SUBACCOUNT_ID: {subaccount_id}" if task_id: msg = f'Task {task_id}: {msg}' log.debug(msg) # Format the payload update_track_contributor_data = format_track_contributor( tuid=tuid, participations=participations, performers=performers, publishers=publishers, ) # DEBUG LOG - Print payload msg = f'\nPayload: \n{json.dumps(update_track_contributor_data, indent=4)}' if task_id: msg = f'Task {task_id}: {msg}' log.debug(msg) try: # Adjust header for each label graphql_conn.set_headers( {'GRASS-ACCOUNT-ID': str(vendor_id)}) # Get or create participant set_track_response = graphql_conn.execute_query( graphql_queries.SET_TRACK_PARTICIPANTS_PERFORMERS_PUBLISHERS, update_track_contributor_data ) # ['data']['product'] except GraphQLError as ge: # Push error metadata to method result object error = { 'tuid': tuid, 'participants': participations, 'performers': performers, 'publishers': publishers, 'status': 'graphql_error', 'error_code': ge.code, 'status_text': 'GraphQL Error', 'message': ge.message, 'payload': update_track_contributor_data, 'calling_func': 'set_track_contributions()', } # Notify user of GraphQL error msg = f'GraphQL Call Error - \'{ge.message}\' on vendor ' \ f'{vendor_id}, TUID {tuid}: ' \ '"Set Track Participants" call failed - Error Code: ' \ f'graphql_error - {ge.code} : GraphQL ' \ f'Error : {ge.message}' if task_id: msg = f'Task {task_id}: {msg}' log.error(msg) # Good response else: # Strange edge case where the response is None if not set_track_response: if task_id: msg = f'Task {task_id}: ' msg = f'No response for TUID {tuid}.' log.error(msg) error = { 'tuid': tuid, 'participants': participations, 'performers': performers, 'publishers': publishers, 'status': 'edge_case_error', 'status_text': 'No Response', 'message': msg, 'payload': update_track_contributor_data, 'calling_func': 'set_track_contributions()', } return data, error data = set_track_response['saveTracks'] return data, error def get_or_create_label_participant( name, vendor_id, subaccount_id, spotify_id=None, apple_music_id=None, task_id=None) -> tuple: """Create a single label participant. Args: name (str): The name of the participant. vendor_id (int): The vendor ID. subaccount_id (int): The subaccount ID. spotify_id (str): The Spotify ID. apple_music_id (str|int): The Apple Music ID. task_id (int): The task ID if called from a multi-threaded process. Returns: tuple (str, str, str, str, dict): The participant ID, UUID, Spotify ID, Apple Music ID, and error data. """ # Init uuid = None error = None participant_id = None # Log to user msg = f"Cementing participant ID for '{name}' on VENDOR_ID: " \ f'{vendor_id}, SUBACCOUNT_ID: {subaccount_id} with SPOTIFY_ID: ' \ f'{spotify_id} and APPLE_MUSIC_ID: {apple_music_id}' if task_id: msg = f'Task {task_id}: {msg}' log.debug(msg) # Format the payload create_label_participant_data = format_label_participant( name=name, vendor_id=vendor_id, subaccount_id=subaccount_id, spotify_id=spotify_id, apple_music_id=str(apple_music_id) if apple_music_id else None, allow_nulls=ALLOW_NULL_ARTIST_IDS ) # DEBUG LOG - Print payload msg = f'\nPayload: \n{json.dumps(create_label_participant_data, indent=4)}' if task_id: msg = f'Task {task_id}: {msg}' log.debug(msg) try: # Adjust header for each label graphql_conn.set_headers({'GRASS-ACCOUNT-ID': str(vendor_id)}) # Get or create participant create_participant_response = graphql_conn.execute_query( graphql_queries.GET_OR_CREATE_LABEL_PARTICIPANT, create_label_participant_data ) # ['data']['product'] except GraphQLError as ge: # Push error metadata to method result object error = { 'name': name, 'vendor_id': vendor_id, 'subaccount_id': subaccount_id, 'status': 'graphql_error', 'error_code': ge.code, 'status_text': 'GraphQL Error', 'message': ge.message, 'payload': create_label_participant_data, 'calling_func': 'get_or_create_label_participant()', } # Notify user of GraphQL error msg = f'GraphQL Call Error - \'{name}\' on vendor {vendor_id}, ' \ f'subaccount {subaccount_id} ' \ f'error {error}' \ 'label participant creation call failed - Error Code: ' \ f'graphql_error - {ge.code} : GraphQL ' \ f'Error : {ge.message}' if task_id: msg = f'Task {task_id}: {msg}' log.error(msg) else: # Split due to length data = create_participant_response['createLabelParticipant'] uuid = data.get('uuid') participant_id = data.get('id') spotify_id = data.get('spotifyId') apple_music_id = data.get('appleMusicId') # Thank you copilot! This is sugar for logging log.debug(f'-- Participant: {name} {"-" * (79 - len(name) - 18)}') if uuid: log.debug(f'Participant UUID: {uuid}') if participant_id: log.debug(f'Participant ID: {participant_id}') if spotify_id or apple_music_id: log.debug(f'Spotify ID: {spotify_id}') log.debug(f'Apple Music ID: {apple_music_id}') if not uuid and not participant_id: log.error(f'No UUID or participant ID returned for {name}.') return participant_id, uuid, spotify_id, apple_music_id, error def transform_participations( participants: dict, participant_ids_by_vendor: dict, forced_vendor_id: int = None, forced_subaccount_id: int = None) -> tuple: """Transform data into GraphQL payload format. Args: participants (dict): The participants data. participant_ids_by_vendor (dict): The participant ID's by vendor ID (and subaccount) with role_name. forced_vendor_id (int): (Optional) A vendor ID to hard-code. If not provided, the vendor ID from the participants data will be used. forced_subaccount_id (int): (Optional) A subaccount ID to hard-code. If not provided, 0 (zero) will be used. Returns: dict: A dict of appropriately formatted key, value pairs. """ count = 0 participant_count = len(participants) log_frequency = max(participant_count // LOG_FREQUENCY, 1) log.info('Transforming participations...') log.info(f'Logging every {log_frequency} tracks.') data = {} errors = {} # Artists who failed GET/SET Participant ID for count, row in enumerate(participants): msg = f'Processing participations for track {count+1} of ' \ f'{participant_count} ({(count+1)/participant_count*100:.2f}%) ' log.debug(msg) if LOGGER_LEVEL != 'DEBUG' and count % log_frequency == 0: log.info(msg) # Grab the tuid from the row tuid = row["Orchard TUID"] vendor_id = forced_vendor_id or row['Vendor ID'] subaccount_id = forced_subaccount_id or row.get("Subaccount ID") or 0 if tuid not in data: data[tuid] = { "vendor_id": vendor_id, "subaccount_id": subaccount_id, "participations": [], } if 'participations' not in data[tuid]: data[tuid]['participations'] = [] if data[tuid]['vendor_id'] != vendor_id and not forced_vendor_id: msg = f'Vendor ID mismatch for Track {tuid}. ' \ f'Expected {data[tuid]["vendor_id"]}, ' \ f'got {vendor_id}.' log.error(msg) error = { 'tuid': tuid, 'expected_vendor_id': data[tuid]['vendor_id'], 'encountered_vendor_id': vendor_id, 'message': msg, 'calling_func': 'transform_participations()' } errors = prepare_errors_dict(errors, 'tuid', tuid) errors['tuid'][tuid].append(error) continue # Participations AKA Track Artist / Featuring / etc. for role_name in PARTICIPANT_ROLES.keys(): # break up all pipe delimiters # # ..., and remove extraneous spaces artists = [ artist.strip() for artist in row[role_name].split('|') if artist] # removes duplicates that will cause the GQL query to fail artists = list(set(artists)) # Get all UUID's for artists, and fashion `participations` dict for artist_name in artists: try: # Fetch the matching artist object artist_object = [ artist for artist in participant_ids_by_vendor if artist['name'] == artist_name ].pop(0) # Get the first (and only) one except IndexError: msg = f'Artist {artist_name} not found in participant ' \ f'ID list for vendor {vendor_id}.' log.error(msg) error = { 'tuid': tuid, 'vendor_id': vendor_id, 'subaccount_id': subaccount_id, 'artist_name': artist_name, 'message': msg, 'calling_func': 'transform_participations()' } errors = prepare_errors_dict(errors, 'tuid', tuid) errors['tuid'][tuid].append(error) continue data[tuid]['participations'].append({ "role": PARTICIPANT_ROLES[role_name], "labelParticipantUuid": artist_object['id'] }) return data, errors def transform_publishers( particpants: dict, forced_vendor_id: int = None, forced_subaccount_id: int = None) -> tuple: """Transform and align the publisher data. Args: particpants (dict): The particpants data. (Contains publisher(s)) forced_vendor_id (int): (Optional) A vendor ID to hard-code. If not provided, the vendor ID from the participants data will be used. forced_subaccount_id (int): (Optional) A subaccount ID to hard-code. If not provided, 0 (zero) will be used. Returns: dict: The transformed data. list: A list of failed publishers. """ data = {} errors = {} participant_count = len(particpants) log_frequency = max(participant_count // LOG_FREQUENCY, 1) log.info('Transforming publishers...') log.info(f'Logging every {log_frequency} tracks.') for count, row in enumerate(particpants): msg = f'Processing publishers for Track {count+1} of ' \ f'{participant_count} ({(count+1)/participant_count*100:.2f}%) ' log.debug(msg) if LOGGER_LEVEL != 'DEBUG' and count % log_frequency == 0: log.info(msg) tuid = row["Orchard TUID"] vendor_id = forced_vendor_id or row['Vendor ID'] subaccount_id = \ forced_subaccount_id or row.get("Subaccount ID") or 0 if tuid not in data: data[tuid] = { "subaccount_id": subaccount_id, "vendor_id": vendor_id, "publishers": [] } if 'publishers' not in data[tuid]: data[tuid]['publishers'] = [] if data[tuid]['vendor_id'] != vendor_id and not forced_vendor_id: msg = f'Vendor ID mismatch for Track {tuid}. ' \ f'Expected {data[tuid]["vendor_id"]}, ' \ f'got {vendor_id}.' log.error(msg) error = { 'tuid': tuid, 'expected_vendor_id': data[tuid]['vendor_id'], 'vendor_id': forced_vendor_id, 'message': msg, 'calling_func': 'transform_publishers()' } # Prepare the errors dictionary errors = prepare_errors_dict(errors, 'tuid', tuid) errors['tuid'][tuid].append(error) continue # Split up all pipe delimiters, and remove extraneous spaces publishers = [ publisher.strip() for publisher in row["Publisher(s)"].split('|') ] # removes unnecessary duplicate entries publishers = list(set(publishers)) for p in publishers: if p != '': data[tuid]['publishers'].append({"name": p}) return data, errors def lookup_type(value: str) -> int: """Look up the type value and get the type ID. Args: value (str): The type value. Returns: int: The ID. """ # Reverse the TRACK_PERFORMER_TYPE constant for lookup type_lookup = { item['type'].lower(): item['code'] for item in TRACK_PERFORMER_TYPE} # Lowercase the value for comparison value = value.lower() # Return the code if the value exists in the dictionary if value in type_lookup.keys(): return type_lookup[value] raise ValueError(f"Type '{value}' not found in TRACK_PERFORMER_TYPE") def lookup_role(value: str) -> int: """Look up the role value and get the role ID. Args: value (str): The role value. Returns: int: The role ID. """ # Reverse the TRACK_PERFORMER_ROLE constant for lookup role_lookup = { item['role'].lower(): item['id'] for item in TRACK_PERFORMER_ROLE } # Lowercase the value for comparison value = value.lower() # Return the code if the value exists in the dictionary if value in role_lookup.keys(): return role_lookup[value] raise ValueError(f"Role '{value}' not found in TRACK_PERFORMER_ROLE") def transform_performers( performers: dict, forced_vendor_id: int = None, forced_subaccount_id: int = None) -> tuple: """Transform and align the contributor data. Args: performers (dict)): The performer data. data (df): The existing data to transform. vendor_id (int): (Optional) A vendor ID to hard-code. Returns: dict: The transformed data. list: A list of failed contributors. """ data = {} errors = {} performer_count = len([{ k: v for k, v in row.items() if 'Performer' in k and v } for row in performers ]) log_frequency = max(performer_count // LOG_FREQUENCY, 1) log.info('Transforming performers...') log.info(f'Logging every {log_frequency} tracks.') for count, row in enumerate(performers): # Log msg = f'Processing performers for Track {count+1} of ' \ f'{performer_count} ({(count+1)/performer_count*100:.2f}%) ' log.debug(msg) if LOGGER_LEVEL != 'DEBUG' and count % log_frequency == 0: log.info(msg) tuid = row["Orchard TUID"] vendor_id = forced_vendor_id or row['Vendor ID'] subaccount_id = \ forced_subaccount_id or row.get("Subaccount ID") or 0 if tuid not in data: data[tuid] = { "vendor_id": vendor_id, "subaccount_id": subaccount_id, "performers": [], } if 'performers' not in data[tuid]: data[tuid]['performers'] = [] # SANITY CHECK - No changing vendor ID's elif data[tuid]['vendor_id'] != vendor_id and not forced_vendor_id: msg = f'Vendor ID mismatch for Track {tuid}. ' \ f'Expected "{data[tuid]["vendor_id"]}" ' \ f'but got "{vendor_id}".' log.error(msg) error = { 'tuid': tuid, 'vendor_id': vendor_id, 'expected_vendor_id': data[tuid]["vendor_id"], 'message': msg, 'calling_func': 'transform_performers()' } errors = prepare_errors_dict(errors, 'tuid', tuid) errors['tuid'][tuid].append(error) perf = {} # Create a new performer performers_row = { k: v for k, v in row.items() if 'Performer' in k and v } # Every correct performer has 3 filled columns performer_count = len(performers_row.keys()) / 3 # If performer count is not a whole number, log an error and skip if performer_count % 1 != 0: msg = f"Performer data is not formatted correctly for Track " \ f"{tuid}." log.error(msg) error = { 'tuid': tuid, 'vendor_id': vendor_id, 'error': 'Performer data is not formatted correctly.', 'msg': msg, 'calling_func': 'transform_performers()' } errors = prepare_errors_dict(errors, 'tuid', tuid) errors['tuid'][tuid].append(error) continue # Putting the try here, as opposed to in the column check fails the # whole row if there is an error in the performer data. try: for i in range(1, int(performer_count+1)): perf = {} perf['type'] = \ lookup_type(performers_row[f'Performer {i} Type']) perf['name'] = performers_row[f'Performer {i} Legal Name'] perf['roleId'] = int( lookup_role(performers_row[f'Performer {i} Main Role'])) if perf: # is the performer dict not empty? data[tuid]['performers'].append(perf) else: msg = f"No 'Performer {i}' data found for Track {tuid}." log.error(msg) error = { 'tuid': tuid, 'vendor_id': vendor_id, 'error': f"No 'Performer {i}' data found.", 'msg': msg, 'calling_func': 'transform_performers()' } errors = prepare_errors_dict(errors, 'tuid', tuid) errors['tuid'][tuid].append(error) except ValueError as e: msg = \ f"Error processing performer data for Track {tuid}: {str(e)}." log.error(msg) error = { 'tuid': tuid, 'vendor_id': vendor_id, 'error': str(e), 'message': msg, 'calling_func': 'transform_performers()' } errors = prepare_errors_dict(errors, 'tuid', tuid) errors['tuid'][tuid].append(error) continue # continues with the next row. return data, errors def get_product_id_by_upc( upc: str, vendor_id: int = None, task_id: int = None) -> dict: """Get a product ID by UPC. Args: upc (str): The UPC. vendor_id (int, optional): The vendor ID. Defaults to None. task_id (int, optional): The task ID if called from a multi-threaded process. Defaults to None. Returns: dict: The response data from the GraphQL mutation. list: A list of errors. """ try: # Just call the get_product_by_upc method and return the product ID product, error = get_product_by_upc(upc, vendor_id, task_id) except Exception as e: error = { 'upc': upc, 'vendor_id': vendor_id, 'status': 'exception', 'status_text': 'Exception', 'message': str(e), 'payload': upc, 'calling_func': 'get_product_id_by_upc()', } return None, error else: product_id = product.get('productId') return product_id, {} def get_product_by_upc(upc: str) -> dict: """Get a product ID by UPC. Args: upc (str): The UPC. task_id (int, optional): The task ID if called from a multi-threaded process. Defaults to None. Returns: dict: The response data from the GraphQL mutation. """ payload = format_product_id_by_upc(upc) product_result = graphql_conn.execute_query( graphql_queries.GET_PRODUCT_BY_UPC, payload ) return product_result['productByUpc'] def update_track_localizations( tuid: int, localizations: list, participations: list, vendor_id: int = None, subaccount_id: int = None, task_id: int = None ) -> dict: """Update track localizations. Args: tuid (int): The track TUID. vendor_id (int): The vendor ID. subaccount_id (int): The subaccount ID. localizations (list): The localizations data. participations (list): The participations data. task_id (int, optional): The task ID if called from a multi-threaded process. Defaults to None. Returns: dict: The response data from the GraphQL mutation. """ payload = format_track_localizations_update( tuid=tuid, localizations=localizations, participations=participations, ) if vendor_id: graphql_conn.set_headers({ 'GRASS-ACCOUNT-ID': str(vendor_id), 'GRASS-ACCOUNT-TYPE': 'vendor' }) response = graphql_conn.execute_query( graphql_queries.SET_TRACK_LOCALIZATIONS, payload ) return response['saveTracks'] def update_product_localizations( product_id: int, localizations: list, vendor_id: int, subaccount_id: int, task_id: int = None): """Update product localizations. Args: product_id (int): The release ID. vendor_id (int): The vendor ID. subaccount_id (int): The subaccount ID. localizations (list): The localizations data. task_id (int, optional): The task ID if called from a multi-threaded process. Defaults to None. Returns: dict: The response data from the GraphQL mutation """ payload = format_product_localization_update( product_id=product_id, product_localizations=localizations ) if vendor_id: graphql_conn.set_headers({ 'GRASS-ACCOUNT-ID': str(vendor_id), 'GRASS-ACCOUNT-TYPE': 'vendor' }) response = graphql_conn.execute_query( graphql_queries.SET_RELEASE_LOCALIZATIONS, payload ) return response['updateProduct']