"""iTunes Tickets tasks.""" import csv from datetime import datetime from io import StringIO import os from os.path import getsize import re import shlex import subprocess from subprocess import CalledProcessError from tempfile import TemporaryDirectory import xml.etree.ElementTree as ET from boto3.exceptions import S3UploadFailedError from garcon import task from garcon_contrib.dynamo_feed_status import garcon_feed_status from feed_ingestion.flows import ITunesTicketsSF from feed_ingestion.flows.helpers import get_sf_config from feed_ingestion.flows.itunes_tickets import config from feed_ingestion.tasks import bootstrap as reload from feed_ingestion.tasks import check_ingested_status from feed_ingestion.tasks import check_status from feed_ingestion.util.aws import s3 STOP_RESPONSE = {'stop': True} @task.decorate(timeout=1000) @reload.reset_dynamodb_status_on_reload(config.feed_name) @check_ingested_status(config.feed_name) def bootstrap(activity, date, dw_config=None): """Bootstrap workflow by injecting initial context from config. Args: activity (ActivityWorker): The Garcon activity worker. date (str): Reporting date (YYYY-MM-DD). dw_config (dict): Dictionary of data warehouse config options. Returns: dict: Initial context for the workflow. """ date_obj = (datetime.strptime( date, '%Y-%m-%d') if date else datetime.today()) date = date_obj.strftime('%Y-%m-%d') return { 'feed_name': config.feed_name, 'secrets_path': config.secrets_path, 'date': date, 'archive_path': config.s3['archive_path'].format(date=date_obj), 'preprocessed_path': config.s3['preprocessed_path'].format( date=date_obj), 'temp_table_names': { table_type: config.snowflake_table_names[ 'temp_staging_raw'].format( table_type=table_type, date=date_obj) for table_type in config.table_types} } @task.decorate(timeout=600) @check_status() def grab_drop_files(activity, feed_name, date, archive_path): """Grab report and upload it to archive location. Args: activity (ActivityWorker): The activity worker. feed_name (str): Feed name of workflow execution for status updates. date (str): Reporting date (YYYY-MM-DD). archive_path (str): archive path on S3 for report. """ with TemporaryDirectory() as temp_dir: out_file_path = os.path.join(temp_dir, config.source_filename) # Transporter User Guide 2.1 # https://help.apple.com/itc/transporteruserguide/en.lproj/static.html with open(out_file_path, 'w') as out_file: cmd = shlex.split( '{} -m queryTickets -u {} -p @env:ITMS_TRANSPORTER_PASSWORD ' '-v critical -Xmx3906m'.format( config.itms_transporter_bin, config.itms_transporter_login)) try: # Capture stderr to prevent buffer overflow # text=True ensures proper text stream handling subprocess.run( cmd, check=True, stdout=out_file, stderr=subprocess.PIPE, text=True) except CalledProcessError as exc: garcon_feed_status.set_overall_status( feed_name, date, garcon_feed_status.STATUS_NOT_AVAILABLE) error_message = ( 'Transporter error. ' 'Cannot write file to {path}: {exception_body}'.format( path=out_file_path, exception_body=exc)) activity.logger.error(error_message) # Log stderr output for debugging (truncated to avoid log spam) if exc.stderr: activity.logger.debug( f'iTMSTransporter stderr: {exc.stderr[:1000]}...') return {'stop': True, 'message': error_message} if getsize(out_file_path) == 0: garcon_feed_status.set_overall_status( feed_name, date, garcon_feed_status.STATUS_NOT_AVAILABLE) error_message = "Downloaded file '{path}' size is 0B".format( path=out_file_path) activity.logger.error(error_message) return {'stop': True, 'message': error_message} with open(out_file_path, 'r') as out_file: try: s3.upload_on_s3( config.data_bucket, archive_path, config.source_filename, out_file, config.expected_bucket_owner) except S3UploadFailedError as exc: garcon_feed_status.set_overall_status( feed_name, date, garcon_feed_status.STATUS_NOT_AVAILABLE) error_message = ( 'Cannot upload file to {path}. {exception_body}'.format( path=archive_path, exception_body=exc)) activity.logger.error(error_message) return {'stop': True, 'message': error_message} activity.logger.info( '{filename} uploaded to {path}'.format( filename=config.source_filename, path=archive_path)) return { 'source_files_dict': { 'files': [ {'file_name': config.source_filename} ] } } @task.decorate(timeout=36000) @check_status() def process_drop_files( activity, feed_name, date, archive_path, preprocessed_path, source_files_dict): """Process drop files and upload results on S3. Args: activity (ActivityWorker): The activity worker. feed_name (str): Feed name of workflow execution for status updates. date (str): Reporting date (YYYY-MM-DD). archive_path (str): archive path on S3 for report. preprocessed_path (str): preprocessed files path on S3. source_files_dict (dict): List of files to download. """ s3_path = 's3://{}/{}'.format(config.data_bucket, archive_path) tickets = [] notes = [] defect_codes = [] for _, content in s3.get_source_files_content(s3_path, source_files_dict): new_tickets, new_notes, new_defect_codes = _get_tickets_and_notes( content, date) tickets.extend(new_tickets) notes.extend(new_notes) defect_codes.extend(new_defect_codes) tickets_csv = _create_csv(tickets, config.ticket_fieldnames, '\t') notes_csv = _create_csv(notes, config.note_fieldnames, '\t') defect_codes_csv = _create_csv( defect_codes, config.defect_code_fieldnames, '\t') tickets_upload_path = 's3://{}/{}{}'.format( config.data_bucket, preprocessed_path, config.preprocessed_tickets_filename) _upload_preprocessed_file( activity, feed_name, date, tickets_csv, tickets_upload_path) notes_upload_path = 's3://{}/{}{}'.format( config.data_bucket, preprocessed_path, config.preprocessed_notes_filename) _upload_preprocessed_file( activity, feed_name, date, notes_csv, notes_upload_path) defect_codes_upload_path = 's3://{}/{}{}'.format( config.data_bucket, preprocessed_path, config.preprocessed_defect_codes_filename) _upload_preprocessed_file( activity, feed_name, date, defect_codes_csv, defect_codes_upload_path) activity.logger.info('Successfully processed drop files for {}'.format( date)) @task.decorate(timeout=600) def drop_temp_table(activity, temp_table_name, secrets_path): """Drop temp staging raw table. Args: activity (ActivityWorker): The Garcon activity worker. temp_table_name (str): Table name to drop. secrets_path (str): Secrets manager path of the flow. """ with ITunesTicketsSF(get_sf_config(secrets_path)) as executor: executor.drop_table(temp_table_name) activity.logger.info('{} was dropped'.format(temp_table_name)) def _upload_preprocessed_file(activity, feed_name, date, csv_obj, path): """Upload preprocessed files on S3. Args: activity (ActivityWorker): The activity worker. feed_name (str): Feed name of workflow execution for status updates. date (str): Reporting date (YYYY-MM-DD). csv_obj (StringIO): CSV object to upload. path (str): Upload path. """ try: s3.upload_processed_to_s3( csv_obj, path, expected_bucket_owner=config.expected_bucket_owner ) except S3UploadFailedError as e: garcon_feed_status.set_overall_status( feed_name, date, garcon_feed_status.STATUS_NOT_AVAILABLE) activity.logger.error( 'Cannot upload notes file to {path}. {exception_body}'.format( path=path, exception_body=e)) raise e def _create_csv(items, fieldnames, delimiter): """Create a CSV object from items list. Args: items (list): list of items. fieldnames (list): CSV fieldnames. delimiter (str): CSV delimiter. Return: StringIO: csv object. """ csv_obj = StringIO() writer = csv.DictWriter( csv_obj, fieldnames=fieldnames, delimiter=delimiter) writer.writeheader() writer.writerows(items) return csv_obj def _get_tickets_and_notes(content, date): """Parse source files and get tickets and notes. Args: content (str): source file content. date (str): ingestion date. Returns: tuple: Tuple of lists with tickets, notes and defect codes . """ def remove_nonprintable_chars(s): control_chars_re = re.compile(r'[\x00-\x1f\x7f-\x9f]') return control_chars_re.sub('', s) xml_parser = ET.XMLParser(encoding='utf-8') xml = ET.fromstring(remove_nonprintable_chars(content), parser=xml_parser) tickets = [] notes = [] defect_codes = [] for ticket in xml.findall('ticket'): new_ticket = _get_ticket(ticket, date) tickets.append(new_ticket) for note in ticket.find('notes').findall('note'): notes.append(_get_note(new_ticket, note)) for defect_code in ticket.find('defectCodes').findall('defectCode'): defect_codes.append(_get_defect_code(new_ticket, defect_code)) return tickets, notes, defect_codes def _get_defect_code(ticket, defect_code): """Parse defect code section and get defect code data. Args: ticket (dict): ticket data. defect_code (Element): defect code xml element. Returns: dict: Defect code object. """ return { 'file_date': ticket['file_date'], 'ticketid': ticket['ticketid'], 'defect_code': defect_code.text } def _get_note(ticket, note): """Parse note section and get note data. Args: ticket (dict): ticket data. note (Element): note xml element. Returns: dict: Note object. """ # 2020-04-25: A slew of characters are introduced # that break snowflake's CSV reader # The below is to clean up the characters note_text = ( note.find('note').text .replace(' ', ' ') .replace('\n', ' ') .replace('\r', ' ') ) if note_text == '\\': note_text = note_text.replace('\\', '\\\\') return { 'file_date': ticket['file_date'], 'ticketid': ticket['ticketid'], 'note_datetime': note.find('date').text if note.find('date') is not None else None, 'note_text': note_text, } def _get_ticket(ticket, date): """Parse ticket section and get ticket data. Args: ticket (Element): ticket xml element. date (str): Ingestion date. Returns: dict: ticket object. """ new_ticket = { 'file_date': date, 'ticketid': ticket.find('ticketId').text, 'contenttickettype': ticket.find('contentTicketType').text if ticket.find('contentTicketType') is not None else None, 'contentadamid': ticket.find('contentAdamId').text, 'contentvendorid': ticket.find('contentVendorId').text if ticket.find('contentVendorId') is not None else None, 'contentupc': ticket.find('contentUPC').text if ticket.find('contentUPC') is not None else None, 'contenttype': ticket.find('contentType').text, 'contentlanguage': ticket.find('contentLanguage').text if ticket.find('contentLanguage') is not None else None, 'contentgenre': ticket.find('contentGenre').text if ticket.find('contentGenre') is not None else None, 'name': ticket.find('name').text, 'discnumber': ticket.find('discNumber').text if ticket.find('discNumber') is not None else None, 'tracknumber': ticket.find('trackNumber').text if ticket.find('trackNumber') is not None else None, 'contentprovider': ticket.find('contentProvider').text, 'openedby': ticket.find('openedBy').text if ticket.find('openedBy') is not None else None, 'contentticketstate': ticket.find('contentTicketState').text if ticket.find('contentTicketState') is not None else None, 'created': ticket.find('created').text, 'lastmodified': ticket.find('lastModified').text } new_ticket['orchard_upc'] = None if new_ticket['contentupc'] is not None: new_ticket['orchard_upc'] = new_ticket['contentupc'] elif new_ticket['contentvendorid'] is not None: new_ticket['orchard_upc'] = new_ticket['contentvendorid'] return new_ticket