"""Common utility functions.""" import os import re from urllib import parse import boto3 from lambdacommon.common_config import logger from config import EMAIL_SENDER from config import ERROR_ATTACHMENT_EMAIL_RECIPIENT from config import S3_BUCKET_FOLDER from config import WORKFLOW_LINK from src.constants.constants import EMAIL_ATTACHMENT_ERROR_BODY from src.constants.constants import EMAIL_ATTACHMENT_ERROR_SUBJECT from src.constants.constants import Error from src.constants.constants import FILE_KEY_REGEXP from src.constants.constants import FileType from src.constants.constants import MAX_ALLOWED_FILE_SIZE from src.constants.constants import Status def parse_file_key(key: str) -> dict: """Parse file key. Args: key (str): S3 file key Returns: dict: empty dict or attachment attributes (label_id, filename etc.) """ key = parse.unquote_plus(key) match = re.search(FILE_KEY_REGEXP, key) if match is None: return {} return match.groupdict() def get_file_name_from_key(key: str) -> str: """Extract file name from S3 file key. Args: key (str): file key from S3 event Returns: str: file name part of the file key """ file_name = key.split('/')[-1] return parse.unquote_plus(file_name) def get_quarter_periods_by_period(period_id: int) -> list[int]: """Find out all period ids in quarter by period id. Args: period_id (int): period id Returns: list: list of quarter period ids """ period_id = int(period_id) fractional_part = period_id / 3 - int(period_id / 3) if fractional_part == 0: # last period in quarter period_list = [period_id - 2, period_id - 1, period_id] elif round(fractional_part, 1) == 0.7: # middle period in quarter period_list = [period_id - 1, period_id, period_id + 1] else: # first period in quarter period_list = [period_id, period_id + 1, period_id + 2] return period_list def format_quarter_file_name(file_name: str, period_id: int) -> str: """Format file name for quarter with defined pattern. Args: file_name (str): original file name, e.g. test.txt period_id (int): period id, e.g 217 Returns: str: Formatted file name for sorting, e.g. test.txt(217) """ pattern = '{file_name}({period_id})' return pattern.format(file_name=file_name, period_id=period_id) def get_attachment_primary_key_value( account_type: str, account_id: int, period_ids: list[int] ) -> str: """Get formatted attachment primary key value. Args: account_type (str): account type from GRASS. account_id (int): account id (vendor id). period_ids (list): list of accounting periods. Returns: str: Formatted primary key value """ primary_key_account_type_id = '{}{}'.format(account_type, account_id) periods_str = '_'.join(str(period_id) for period_id in period_ids) return '{}_{}'.format(primary_key_account_type_id, periods_str) def s3_event_get_attachment_details(s3_event: dict, file_attrs: dict) -> dict: """Get attachment details from s3_event. Args: s3_event (dict): AWS S3 event object. file_attrs (dict): Attachment file attributes Returns: dict: standardized dictionary with needed attachment fields. """ try: s3_object = s3_event['Records'][0]['s3']['object'] s3_bucket = s3_event['Records'][0]['s3']['bucket'] key = parse.unquote_plus(s3_object['key']) data = { 'file_key': key, 'file_name': file_attrs['filename'], 'original_file_name': file_attrs['filename'], 'file_type': file_attrs['extension'], 'bucket_name': s3_bucket['name'], 'file_size': s3_object['size'], 'etag': s3_object['eTag'], 'account_type': file_attrs['account_type'], 'contract_id': int(file_attrs['contract_id']) if file_attrs['contract_id'] else None, 'label_id': int(file_attrs['label_id']), 'period_ids': int(file_attrs['period_id']), 'status': Status.STATUS_COMPLETED, 'upload_date': s3_event['Records'][0]['eventTime'], } return data except KeyError as e: raise KeyError(Error.MISSING_KEY_EXCEPTION.format(key=str(e))) except Exception as e: raise Exception(Error.CANNOT_PARSE_BODY.format(error=str(e))) def validate_size(size: int) -> bool: """Validate file size. Args: size (int): S3 file size Returns: bool: validation result """ logger.info(f'Validating file size: {size} bytes') return size <= MAX_ALLOWED_FILE_SIZE def validate_extension(extension: str) -> bool: """Validate file extension. Args: extension (str): S3 file extension Returns: bool: validation result """ logger.info(f'Validating file extension: {extension}') return extension.lower() in set(FileType) def validate_path(key: str) -> bool: """Validate file path. Args: key (str): S3 file key Returns: bool: validation result """ logger.info(f'Validating file path: {key}') head, _tail = os.path.split(key) return head == S3_BUCKET_FOLDER def validate_file(s3_event: dict) -> Error | None: """Validate file attributes. Args: s3_event (dict): Attributes of the file (s3.object) Returns: Error: Error enum member if validation fails, None on success """ s3_object = s3_event['Records'][0]['s3']['object'] if not validate_path(s3_object['key']): return Error.INVALID_PATH_MSG if not validate_size(s3_object['size']): return Error.INVALID_SIZE_MSG parsed_attrs = parse_file_key(s3_object['key']) if not parsed_attrs: return Error.INVALID_NAME_MSG if not validate_extension(parsed_attrs['extension']): return Error.INVALID_EXTENSION_MSG return None def parse_s3_event(event: dict) -> dict: """Parse S3 event to extract file name and upload timestamp. Args: event (dict): S3 event Returns: dict: full_file_name and upload_timestamp """ s3_record = event['Records'][0] key = s3_record['s3']['object']['key'] full_file_name = key.split('/')[-1] upload_timestamp = s3_record['eventTime'] return { 'full_file_name': parse.unquote_plus(full_file_name), 'upload_timestamp': upload_timestamp, } def prepare_error_email_body(event: dict, validation_error: Error) -> str: """Prepare the error notification email body. Args: event (dict): S3 event validation_error (Error): validation error enum Returns: str: formatted HTML email body """ context = parse_s3_event(event) context['error_detail'] = '
{}
'.format(validation_error.value) context['workflow_link'] = WORKFLOW_LINK return EMAIL_ATTACHMENT_ERROR_BODY.format(**context) def send_error_email(event: dict, validation_error: Error) -> None: """Send an error notification email via AWS SES. Args: event (dict): S3 event validation_error (Error): validation error enum """ if not ERROR_ATTACHMENT_EMAIL_RECIPIENT: logger.error('ERROR_ATTACHMENT_EMAIL_RECIPIENT not configured, skipping email.') return body = prepare_error_email_body(event, validation_error) client = boto3.client('ses') client.send_email( Source=EMAIL_SENDER, Destination={'ToAddresses': [ERROR_ATTACHMENT_EMAIL_RECIPIENT]}, Message={ 'Subject': {'Data': EMAIL_ATTACHMENT_ERROR_SUBJECT, 'Charset': 'utf-8'}, 'Body': {'Html': {'Data': body, 'Charset': 'utf-8'}}, }, ReplyToAddresses=[EMAIL_SENDER], ) logger.info('Error notification email sent.')