"""ritmogestion Data Ingestion Workflow tasks.""" import csv from datetime import datetime, timedelta import os import re import boto3 from boto3.s3.transfer import TransferConfig from garcon import task from garcon_contrib.dynamo_feed_status import garcon_feed_status import requests from feed_ingestion.flows.ritmogestion import config from feed_ingestion.tasks import bootstrap as reload from feed_ingestion.util import task_status STOP_RESPONSE = {'stop': True} # https://docs.python.org/3/library/datetime.html#datetime.date.isocalendar # Monday is 0 and Sunday is 6. FRIDAY = 4 def week_number(date: datetime): """Return week number in terms of ritmogestion.""" # this source starts week on Friday. So let's shift it WEEK_START_SHIFT = timedelta(days=3) week_day = date.weekday() if FRIDAY <= week_day: date += WEEK_START_SHIFT else: date -= WEEK_START_SHIFT isocalendar_week_number = date.isocalendar()[1] return isocalendar_week_number @task.decorate(timeout=600) @reload.reset_dynamodb_status_on_reload(config.feed_name) def bootstrap(activity, date, dw_config=None): """Bootstrap workflow by getting the correct configurations. Args: activity (ActivityWorker): The activity worker. date (str): Reporting date (YYYY-MM-DD). reload (str or None): If 'True' delete all feed statuses in DynamoDB. Returns: dict: Initial context of the workflow. """ if not date: date_obj = datetime.now() else: date_obj = datetime.strptime(date, '%Y-%m-%d') feed_name = config.feed_name activity.logger.info( 'Bootstrapping {feed_name}...'.format(feed_name=feed_name)) if not date_obj.weekday() == FRIDAY: message = f'Weekday for {date_obj} should be Friday' activity.logger.warning(message) return {'message': message, 'stop': True} drop_file_name = config.s3['drop_filename'].format(datestamp=date_obj) fixed_file_name = config.s3['fixed_filename'].format(datestamp=date_obj) s3_archive_path = config.s3['archive_path'].format( datestamp=date_obj) s3_path = 's3://{}/{}'.format(config.s3_bucket, s3_archive_path) s3_full_path_fixed = s3_path + fixed_file_name return dict( date=date_obj.strftime('%Y-%m-%d'), feed_name=feed_name, secrets_path=config.secrets_path, year=date_obj.year, week_number=week_number(date_obj), s3_archive_path=s3_archive_path, s3_path=s3_path, s3_full_path_fixed=s3_full_path_fixed, drop_file_name=drop_file_name, fixed_file_name=fixed_file_name, fact_analytics_table='fact_analytics', fact_analytics_error_table='fact_analytics_error', staging_raw_table=config.staging_raw_tablename) def _download_file_from_http_server(year, week_num, logger): with requests.session() as session: session.get(f'{config.source_url}/') login_data = { 'login[username]': config.source_username, 'login[password]': config.source_password, } url = f'{config.source_url}/login' logger.info(f'Sending post to {url}') login_response = session.post( url, data=login_data ) login_response.raise_for_status() filter_data = { 'listas_filter[tipo_lista][text]': config.report_code, 'listas_filter[añodesde][text]': year, 'listas_filter[semanadesde][text]': week_num, 'listas_filter[añohasta][text]': year, 'listas_filter[semanahasta][text]': week_num, 'listas_filter[semanaactualdesde][text]': '', 'listas_filter[semanaactualhasta][text]': '', 'listas_filter[artista][text]': '', 'listas_filter[titulo][text]': '', 'listas_filter[sello][text]': '', } url = f'{config.source_url}/listas/filter/action' logger.info(f'Sending post to {url} payload: {filter_data}') action_response = session.post( url, data=filter_data, timeout=config.REQUEST_TIMEOUT_SECONDS ) action_response.raise_for_status() filename = config.source_local_filename url = f'{config.source_url}/listas.csv' logger.info(f'Sending get to {url}') response = session.get(url, timeout=config.REQUEST_TIMEOUT_SECONDS) try: response.raise_for_status() except requests.exceptions.HTTPError: FileNotFoundError('Report file was not found.') with open(filename, 'wb') as fp: fp.write(response.content) return response, filename @task.decorate(timeout=3600) def fetch_from_http( activity, feed_name, date, s3_archive_path, drop_file_name, fixed_file_name, year, week_number): """Download a feed file from the drop location into the archive folder. Args: activity (ActivityWorker): The activity worker. feed_name (str): The name of the feed. date (str): Reporting date (YYYY-MM-DD). s3_archive_path (str): Destination S3 path to the archive location. drop_file_name (str): The dropped file name. fixed_file_name (str): The fixed file name. year (int): the year of the date week_number (int): week number of the date """ activity.logger.info('Starting fetch_from_http task...') assert year assert week_number assert drop_file_name assert fixed_file_name task_id = 'fetch_from_http' source_key_name = '{}{}'.format(s3_archive_path, drop_file_name) fixed_key_name = '{}{}'.format(s3_archive_path, fixed_file_name) client = boto3.client('s3') if not task_status.is_completed_task(feed_name, date, task_id): # delete from S3 if exists client.delete_object(Bucket=config.s3_bucket, Key=source_key_name) activity.logger.info( '{file} was removed from S3 archive location.'.format( file=drop_file_name)) # download file activity.logger.info('Downloading file {}...'.format(drop_file_name)) try: response, local_filename = _download_file_from_http_server( year, week_number, activity.logger) except requests.exceptions.ReadTimeout: garcon_feed_status.set_overall_status( feed_name, date, garcon_feed_status.STATUS_NOT_AVAILABLE) garcon_feed_status.set_missing_files( feed_name, date, [drop_file_name]) return {'stop': True, 'message': 'Report file is not available'} if response.ok: activity.logger.info('{} downloaded to a local dir'.format( drop_file_name)) # upload dowloaded file to S3 and delete it from disk source_file = os.path.realpath(local_filename) client.upload_file( source_file, config.s3_bucket, source_key_name, Config=TransferConfig()) activity.logger.info('{} uploaded to S3'.format( os.path.join(config.s3_bucket, source_key_name))) fixed_file = config.fixed_local_filename fix_csv(source_filename=source_file, target_filename=fixed_file) client.upload_file( fixed_file, config.s3_bucket, fixed_key_name, Config=TransferConfig()) activity.logger.info('{} uploaded to S3'.format( os.path.join(config.s3_bucket, fixed_key_name))) os.remove(fixed_file) task_status.mark_completed_task(feed_name, date, task_id) elif not response.ok and response.reason == 'Not Found': garcon_feed_status.set_overall_status( feed_name, date, garcon_feed_status.STATUS_NOT_AVAILABLE) garcon_feed_status.set_missing_files( feed_name, date, [drop_file_name]) return {'stop': True} else: raise Exception( 'Can not download file {file} because of {reason}'.format( file=drop_file_name, reason=response.reason)) file_size = client.get_object( Bucket=config.s3_bucket, Key=fixed_key_name)['ContentLength'] source_files_dict = {'files': [{ 'file_name': fixed_file_name, 'found': True, 'file_size': file_size}]} # we're using one file for this workflow, but for StageLoader compatibility # we have to pass source_files_dict along return {'source_files_dict': source_files_dict} def fix_csv(source_filename, target_filename): """Fix ritmogestion CSV file. 1. fix rows (see fix_csv_row) 2. removes pre-header (5 lines) 3. removes summary footer (1 line) """ with open(source_filename, encoding='ISO-8859-1') as source, \ open(target_filename, mode='w', encoding='utf-8') as target: csv_writer = csv.writer(target, delimiter=';') rows = [] for line in source: # fix broken CSV format rows.append(fix_csv_row(line)) # skip 6 lines of header and 1 line of footer csv_reader = csv.reader(rows[6:-1], delimiter=';', quotechar='"') csv_writer.writerows(csv_reader) def fix_csv_row(line): """Fix ritmogestion CSV row. File from ritmogestion is not valid CSV file. Sample of incorrect row: "932";""BESMAYA; MALMÖ 040"";"MATAR LA PENA"; should be encoded as: "932";"BESMAYA; MALMÖ 040";"MATAR LA PENA"; Also it provides incorrect number format: "12.345.678,01" instead of "12345678.01" """ fix_quoted = re.sub(r'""([^;][^"]+)"";', r'"\1";', line) fix_num = re.sub(r'\.(\d\d\d)', r'\1', fix_quoted) fix_fractial = re.sub(r'(\d+),(\d+)', r'\1.\2', fix_num) return fix_fractial