"""Lambda function module.""" from io import BytesIO import json import os import uuid import boto3 import config from config import graphql_gateway from config import ( MAX_ARTWORK_IMAGE_SIZE, MIN_ARTWORK_IMAGE_SIZE, RESIZED_ARTWORK_QUALITY) from constants import queries from ddex_ingester_common.helpers.asset import ( copy_asset, create_asset_token, ) from ddex_ingester_common.helpers.s3_ddex import load_ddex_json from ddex_ingester_common.lambda_exceptions import LambdaException from ddex_ingester_common.logging import utils as logging_utils from ddex_ingester_common.release_correction.release_correction import ( is_release_correction) from ddex_ingester_common.schemas.s3_schema import S3Schema from ddex_ingester_common.schemas.state_machine_schema import ( StateMachineSchema ) from PIL import Image from PIL import ImageFile # Fixes the error: "OSError: image file is truncated (0 bytes not processed)" ImageFile.LOAD_TRUNCATED_IMAGES = True # Fixes the error: Image size (X pixels) exceeds limit of Y pixels, # could be decompression bomb DOS attack. errorType DecompressionBombError Image.MAX_IMAGE_PIXELS = None logger = logging_utils.get_logger(config.app_logger) def handler(event, context): """Handle uploading product artwork.""" logger.info(f'Triggered handle_artwork: {event}') s3_data = S3Schema().load(load_ddex_json(event)) state_machine_data = StateMachineSchema().load(event) correlation_id = state_machine_data.correlation_id or str(uuid.uuid4()) state_machine_data.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, # Will be used by some ows-assets routes for feature flag checks 'Grass-Account-Id': state_machine_data.product.vendor_id, 'Grass-Account-Type': 'vendor' } ) artwork = s3_data.product.artwork # Get our source key and bucket src_bucket = s3_data.bucket src_key = f'{s3_data.key}{artwork.filepath}{artwork.filename}' logger.info(f'Artwork source bucket: {src_bucket}') logger.info(f'Artwork key: {src_key}') # Make sure the artwork image has the correct dimensions process_artwork_size(artwork) # Ask for a token from ows-assets asset_token = create_asset_token(graphql_gateway, 'image') # Construct metadata and copy our asset to target bucket from ows-assets filename = asset_token['filename'] + os.path.splitext(artwork.filename)[1] target_bucket = asset_token['bucket'] logger.info(f'Target bucket: {target_bucket}') logger.info(f'Target filename/key: {filename}') is_correction = is_release_correction(state_machine_data) if is_correction: if not state_machine_data.error_correction or \ not state_machine_data.error_correction.release_correction_id: raise LambdaException( 'error correction is empty or missing required data') error_correction_items = construct_error_correction_items( state_machine_data.product.product_id ) product_correction_detail_data = \ format_correction_detail_data( state_machine_data.product.product_id, state_machine_data.error_correction.release_correction_id, error_correction_items ) graphql_gateway.execute( queries.create_product_correction_detail, {'data': product_correction_detail_data} ) s3_metadata = construct_s3_metadata( state_machine_data.product, artwork, '1' if is_correction else '0' ) copy_asset(s3_metadata, artwork, target_bucket, filename) artwork.ows_assets_filename = filename state_machine_data.product.artwork = artwork return StateMachineSchema().dump(state_machine_data) def construct_s3_metadata( product, artwork, is_correction) -> dict: """Construct metadata dict for s3.""" return { 'asset_type': format_asset_type(artwork), 'product_id': str(product.product_id), 'upc': product.upc, 'track_unique_id': '0', 'original_filename': artwork.filename, 'is_correction': is_correction, } def construct_error_correction_items(product_id) -> dict: """Construct error correction items object.""" return [ { 'field_name': 'coverart', 'key_value': True, 'key_id': product_id, 'table_name': 'releases', } ] def format_asset_type(artwork): """Format asset type from asset artwork.""" return os.path.splitext(artwork.filename)[1].strip('.').upper() def format_correction_detail_data(product_id, release_correction_id, items): """Format correction detail payload. Args: product_id (int): Product ID release_correction_id (str): Release Correction ID items (list): List of correction detail items Returns: dict """ corrections = [] for item in items: corrections.append({ 'fieldName': item['field_name'], 'keyValue': json.dumps(item['key_value']), 'keyId': item['key_id'], 'tableName': item['table_name'] }) return { 'productId': product_id, 'releaseCorrectionId': release_correction_id, 'corrections': corrections } def load_artwork_image(artwork): """Get artwork image data from S3 file. Args: artwork (artwork): artwork schema object Returns: Image data as binary string """ s3_client = boto3.client('s3') image_data = s3_client.get_object( Bucket=artwork.bucket, Key=artwork.key )['Body'].read() return image_data def upload_s3_file(bucket, key, data): """Upload file to S3. Args: bucket (string): S3 bucket key (string): S3 key data (binary string): file data """ s3_client = boto3.client('s3') s3_client.put_object( Bucket=bucket, Key=key, Body=data ) def rename_original_file(artwork, image_data): """Put image data on a new S3 file with '_original' added to the name. Args: artwork (artwork): artwork schema object image_data (file): artwork image data """ bucket = artwork.bucket key = artwork.key split_key = key.split('.') file_path = split_key[0] + '_original' + '.' + split_key[1] upload_s3_file(bucket, file_path, image_data) def resize_image(image_object, width): """Resize a pillow image object. Args: image_object (pillow Image object): artwork image object width (int) : Width of image object in px Returns: Resized image data as binary string """ if width < MIN_ARTWORK_IMAGE_SIZE: resized_image = image_object.resize( (MIN_ARTWORK_IMAGE_SIZE, MIN_ARTWORK_IMAGE_SIZE)) elif width > MAX_ARTWORK_IMAGE_SIZE: resized_image = image_object.resize( (MAX_ARTWORK_IMAGE_SIZE, MAX_ARTWORK_IMAGE_SIZE)) else: raise LambdaException('Failed to resize image.') resized_image_file = BytesIO() resized_image.save( resized_image_file, image_object.format, quality=RESIZED_ARTWORK_QUALITY ) return resized_image_file.getvalue() def process_artwork_size(artwork): """Resize artwork image if necessary. Args: artwork (artwork): artwork schema object """ image_data = load_artwork_image(artwork) image_object = Image.open(BytesIO(image_data)) width, height = image_object.size """ Only resize square images smaller than MIN_ARTWORK_IMAGE_SIZE px Or larger than MAX_ARTWORK_IMAGE_SIZE px """ if width == height and ( width < MIN_ARTWORK_IMAGE_SIZE or width > MAX_ARTWORK_IMAGE_SIZE ): logger.info(f'Resizing artwork with width: {width}' f' and height: {height}') rename_original_file(artwork, image_data) resized_image_data = resize_image(image_object, width) upload_s3_file(artwork.bucket, artwork.key, resized_image_data)