"""Logic for Physical Products. Perform CRUD operations against the physical product schema. """ from datetime import datetime from oto import response import sentry_sdk from sqlalchemy.exc import OperationalError from vector_utils.aws_utils import sqs from ows_product_physical import config from ows_product_physical.connector.mysql import db_session from ows_product_physical.constant import error from ows_product_physical.constant import field from ows_product_physical.constant import validation as validation_msg from ows_product_physical.logic import carveins from ows_product_physical.logic import ownership from ows_product_physical.models import inventory_persister from ows_product_physical.models import ows_assets from ows_product_physical.models import ows_carveouts from ows_product_physical.models import ows_marketing from ows_product_physical.models import ows_product from ows_product_physical.models import ows_product_configuration from ows_product_physical.models import ows_project from ows_product_physical.models import ows_salesgoals from ows_product_physical.models import persister from ows_product_physical.models import product_physical_supply_chain_info from ows_product_physical.models import releases from ows_product_physical.models.sql.product_physical import QUERY_DB_HEARTBEAT from ows_product_physical.validation import validation from ows_product_physical import features def healthcheck(): """Health check handler. Checks database connection. 200 if ok, otherwise 503 service unavailable. Returns: Response: the system status. """ with db_session() as session: try: session.execute(QUERY_DB_HEARTBEAT) except OperationalError as exception: sentry_sdk.capture_exception(exception) return response.create_error_response( code=error.INTERNAL_ERROR, message='could not connect to mysql', status=503) return response.Response(message={'status': 'ok'}, status=200) def _validate_sale_start_date(sale_start_date, time_delta=60, error_code=None): is_valid_sale_start_date =\ validation.date_is_after(sale_start_date, time_delta) if error_code is None: error_code = error.RELEASE_STATUS_SALE_START_DATE_ERROR if not is_valid_sale_start_date: return response.create_error_response( error_code, {field.SALE_START_DATE: validation_msg.SUBMITTED_PRODUCT_SALE_START_DATE_MSG}) return is_valid_sale_start_date def _upc_availability_error(): return response.create_error_response( error.VALIDATION_ERROR, validation.add_error_to_errors( {}, field.DISPLAY_UPC, 'available', True, 'UPC is not available.')) def _product_code_uniqueness_error(): return response.create_error_response( error.VALIDATION_ERROR, validation.add_error_to_errors( {}, field.PRODUCT_CODE, 'used', True, 'Product code is in use')) def _is_assign_display_upc_set_and_allowed(fields, existing_fields=None): """Validate a display_upc assignment request. Args: fields (dict): Product fields to save. existing_fields (dict): Existing product fields. Return: Response: A response object. """ existing_fields = existing_fields or {} if not fields.get('assign_display_upc'): return response.Response() if fields.get('display_upc') or existing_fields.get('display_upc'): return response.Response( status=400, message=( 'If assign_display_upc is set, display_upc must not be set.')) return response.Response() def create_product( fields, account_type=None, account_id=None): """Create a product. Args: fields (dict): Product fields to save. account_type (str): subaccount or vendor. account_id (str): Account id. Return: Response: A dict containing the product_id of the new product. """ project_id = fields['project_id'] ownership_response = ownership.check_project_ownership( project_id, account_type, account_id) if not ownership_response: return ownership_response project = ows_project.get_project_by_id(project_id) if not project: return project vendor_id = project.message.get('vendor_id') subaccount_id = project.message.get('subaccount_id') or None # # If display_upc is enabled, we accept display_upc rather than upc. user_entered_upc = fields.get(field.DISPLAY_UPC) placeholder_upc = ows_product.generate_placeholder_upc() if not placeholder_upc: return placeholder_upc fields[field.UPC] = placeholder_upc.message if user_entered_upc: validation_response = ows_product.is_user_entered_upc_available( user_entered_upc, vendor_id, subaccount_id) if not validation_response: return _upc_availability_error() # This line doesn't do anything and should be removed. fields[field.DISPLAY_UPC] = user_entered_upc is_assign_display_upc_set_and_allowed_response = \ _is_assign_display_upc_set_and_allowed(fields) if not is_assign_display_upc_set_and_allowed_response: return is_assign_display_upc_set_and_allowed_response product_code = fields.get(field.PRODUCT_CODE) if product_code: uniqueness_response = check_product_code_uniqueness( vendor_id, subaccount_id, product_code) if not uniqueness_response: return uniqueness_response product = persister.create_product( fields, vendor_id=vendor_id, subaccount_id=subaccount_id, project_code=project.message.get('project_code'), artist_id=project.message.get('artist_id')) # Set default carveins for product ows_carveouts.set_carveins_for_physical_product( product.message.get('product_id'), vendor_id) # `product_physical_supply_chain_metadata` INSERT if features.is_ccm_physical_supplychaininfo_oa_enabled(): persister.create_product_physical_supply_chain_metadata( fields, product.message.get('product_id') ) return product def create(fields, account_type=None, account_id=None): """Create handler. Args: fields (dict): containing the post request json payload as a dictionary account_type (str): a grass header parameter (subaccount|vendor) account_id (str): a grass header parameter Return: Response: the response of the create operation """ product = create_product(fields, account_type, account_id) if product.status == 201: product_id = product.message.get('product_id') # Product highlights INSERT if field.PRODUCT_HIGHLIGHTS in fields: # `product_highlights` INSERT try: ows_marketing.create_product_highlight( product_id, fields.get(field.PRODUCT_HIGHLIGHTS) ) except Exception as exception: # silently capture exception and send to sentry sentry_sdk.capture_exception(exception) product = fetch_by_id(product_id, account_type, account_id) return product def _check_move_product( existing_product, new_project_id, account_type, account_id): """Check that we can move product and return new project for it.""" ownership_response = ownership.check_project_ownership( new_project_id, account_type, account_id) if not ownership_response: return ownership_response return ows_project.get_project_by_id(new_project_id) def update(product_id, fields, account_type=None, account_id=None): """Update a physical product. Args: product_id (int): id of product to update fields (dict): containing the post request json payload as a dictionary account_type (str|None): a grass header parameter (subaccount|vendor) account_id (str|None): a grass header parameter Return: Response: the response of the update operation """ existing_product_response = fetch_by_id( product_id, account_type, account_id) if not existing_product_response: return existing_product_response existing_product = existing_product_response.message country_of_origin = _check_country_of_origin_exists( existing_product, fields) if not country_of_origin: return country_of_origin product_code = fields.get(field.PRODUCT_CODE) project_id = existing_product['project_id'] project = ows_project.get_project_by_id(project_id) vendor_id = project.message.get('vendor_id') subaccount_id = project.message.get('subaccount_id') or None new_project_id = fields.get(field.PROJECT_ID) sale_start_date = fields.get(field.SALE_START_DATE) if (field.PROJECT_ID in fields) and (new_project_id != project_id): new_project = _check_move_product( existing_product, new_project_id, account_type, account_id) if not new_project: return new_project new_subaccount_id = new_project.message.get('subaccount_id') or None if subaccount_id != new_subaccount_id: fields[field.SUBACCOUNT_ID] = new_subaccount_id if not product_code: # this will trigger uniqueness check product_code = existing_product[field.PRODUCT_CODE] if not fields.get('assign_display_upc'): # if we changed sub account we must force update display upc # it will trigger logic below to check its unique if it is # needed fields[field.DISPLAY_UPC] = existing_product[field.DISPLAY_UPC] subaccount_id = new_subaccount_id if product_code: uniqueness_response = check_product_code_uniqueness( vendor_id, subaccount_id, product_code, product_id) if not uniqueness_response: return uniqueness_response if field.PRODUCT_HIGHLIGHTS in fields: try: persister.update_product_highlights( product_id, fields.get(field.PRODUCT_HIGHLIGHTS)) except Exception as exception: sentry_sdk.capture_exception(exception) if field.DISPLAY_UPC in fields: upc = fields[field.DISPLAY_UPC] if upc: if not ows_product.is_display_upc_available( upc, vendor_id, subaccount_id): return _upc_availability_error() # `product_physical_supply_chain_metadata` UPDATE if sale_start_date and features.is_ccm_physical_supplychaininfo_oa_enabled(): persister.update_product_physical_supply_chain_metadata( fields, product_id, existing_product['upc'] ) is_assign_display_upc_set_and_allowed_response = \ _is_assign_display_upc_set_and_allowed(fields, existing_product) if not is_assign_display_upc_set_and_allowed_response: return is_assign_display_upc_set_and_allowed_response resp = persister.update_product(product_id, fields) if not resp: return resp return fetch_by_id(product_id) def update_product_status( product_id, product_status, account_type=None, account_id=None): """Update a physical product. Args: product_id (int): id of product to update product_status (str): the status to update this product to account_type (str|None): a grass header parameter (subaccount|vendor) account_id (str|None): a grass header parameter Return: Response: the response of the update operation """ result = persister.get_product_by_id(product_id) if not result: return result project_id = result.message.get(field.PROJECT_ID) ownership_response = ownership.check_project_ownership( project_id, account_type, account_id) if not ownership_response: return ownership_response display_upc = result.message.get(field.DISPLAY_UPC) if not display_upc: return response.create_error_response( code=error.RELEASE_STATUS_UPC_ERROR, message='You must add a UPC before submitting a product.') project = ows_project.get_project_by_id(project_id) if not project: return project artwork = ows_assets.get_artwork_asset(product_id) if not artwork: return artwork upc = result.message.get(field.UPC) carvein_ids_response = carveins.get_carveins_for_upc(upc) if not carvein_ids_response: return carvein_ids_response carvein_ids = carvein_ids_response.message days = carveins.get_days_required_for_carveins(carvein_ids) error_code = carveins.get_days_message_required_for_carveins( carvein_ids) is_valid_sale_start_date = _validate_sale_start_date( result.message.get(field.SALE_START_DATE), days, error_code) japan_distribution = result.message.get(field.JAPAN_DISTRIBUTION) if japan_distribution != 'yes_only' and carveins.US_STORE in carvein_ids: if result.message.get(field.DISCOUNT) is None: return response.create_error_response( error.DISCOUNT_ERROR, {field.DISCOUNT: error.DISCOUNT_REQUIRED_ERROR}) first_week_error = has_first_week_estimate(product_id) if not first_week_error: return first_week_error if not is_valid_sale_start_date: return is_valid_sale_start_date return persister.update_product_status( product_id, product_status, project.message.get('vendor_id')) def has_first_week_estimate(product_id): """Determines whether the user entered a first week estimate.""" salesgoals_response = ows_salesgoals.get_goals(product_id) if not salesgoals_response: return salesgoals_response UNITED_STATES_COUNTRY_ID = 1 target_market_goals = salesgoals_response\ .message['target_market_goals'] goals = [goal for goal in target_market_goals if goal['country']['country_id'] == UNITED_STATES_COUNTRY_ID] first_week_estimate = 0 if len(goals): first_week_estimate = goals[0]['first_week_estimate'] or 0 if first_week_estimate <= 0: return response.create_error_response( error.FIRST_WEEK_ESTIMATE_ERROR, {field.FIRST_WEEK_ESTIMATE: error.FIRST_WEEK_ESTIMATE_ERROR_MESSAGE}) return True def fetch_by_id(product_id, account_type=None, account_id=None): """Logic for fetching a product by id. Args: product_id (int): primary key for the product we want to fetch. account_type (str): a grass header parameter (subaccount|vendor). account_id (str): a grass header parameter. Return: Response: JSON object representing the product in response body. """ product_response = persister.get_product_by_id(product_id) if not product_response: return product_response product = product_response.message product_highlights_response = \ ows_marketing.get_product_highlight_by_product_id(product_id) if product_highlights_response.status not in [200, 404]: return product_highlights_response product_highlights = product_highlights_response.message add_product_highlights(product, product_highlights) ownership_response = ownership.check_project_ownership( product.get(field.PROJECT_ID), account_type, account_id) if not ownership_response: return ownership_response return response.Response(message=product) def add_product_highlights(product, product_highlights): """Add product highlights to a product object. Args: product (Response): product response object. product_highlights (Response): product highlights response object. Returns: Response: A response object of combination of product and product_highlights """ if product_highlights: product[field.PRODUCT_HIGHLIGHTS] = product_highlights.get( 'description') or None else: product[field.PRODUCT_HIGHLIGHTS] = None def check_product_code_uniqueness( vendor_id, subaccount_id, product_code, product_id=''): """Check product's code uniqueness for selected account. Args: product_id (int): id of product to update product_code (str): the product code of the product vendor_id (str|None): a project's vendor_id subaccount_id(int|None): a project's subaccount_id Returns: Response: A response object with validation error boolean: true if product code is unique """ releases = persister.get_release_by_product_code( product_code, product_id=product_id) if not releases: return releases if subaccount_id: return check_subaccount_product_code_uniqueness( subaccount_id, releases.message) return check_vendor_product_code_uniqueness( vendor_id, releases.message) def check_subaccount_product_code_uniqueness(subaccount_id, releases): """Check product's code uniqueness for subaccount. Args: releases (dict): a list of releases id subaccount_id (str|int): a project's subaccount_id Returns: Response: A response object with validation error boolean: true if product code is unique """ for release in releases: if release['subaccount_id'] == int(subaccount_id): return _product_code_uniqueness_error() return True def check_vendor_product_code_uniqueness(vendor_id, releases): """Check product's code uniqueness for vendor. Making a call to ows-product microservice to check ownership of selected releases. Args: releases (dict): a list of releases ids vendor_id (str): a project's vendor_id Returns: Response: A response object with validation error boolean: true if product code is unique """ for release in releases: subaccount = release['subaccount_id'] ownership_response = ows_product.check_product_ownership( release['release_id'], 'vendor', vendor_id ) if not ownership_response: return ownership_response if not subaccount and ownership_response.message: return _product_code_uniqueness_error() return True def delete(release_id, account_type=None, account_id=None): """Delete a release given a release_id. Args: release_id (int): a product's release id account_type (str): a grass header parameter (subaccount|vendor). account_id (str): a grass header parameter. Returns: Response: A response object with the result of the delete request """ release = persister.get_product_by_id(release_id) if not release: return release project_id = release.message.get('project_id') ownership_check = ownership.check_project_ownership( project_id, account_type, account_id) if not ownership_check: return ownership_check release_status = release.message.get('release_status') if not release_status == field.DEFAULT_RELEASE_STATUS_STATUS: error_message = 'Only products in `label_processing` can be deleted.' return response.create_error_response( message=error_message, code=error.RELEASE_STATUS_ERROR, status=400) return persister.delete_product(release_id) def copy_product(product_id, fields): """Copy a product. Args: product_id (int): The product_id (release_id) of the product to copy. fields (dict): Fields that override original product fields when copying the product. """ existing_product_response = persister.get_product_by_id(product_id) if not existing_product_response: return existing_product_response new_product = existing_product_response.message new_product.pop('product_id') new_product.pop('upc') new_product.pop('display_upc') new_product.pop('manufacturer_upc') new_product.update(release_status='label_processing') new_product.update(fields) new_product.pop('pricing') new_product.pop('discount') return create_product(new_product) def get_inventory(product_id, account_type=None, account_id=None): """Retrieve a physical product's inventory. Args: product_id (int): id of product account_type (str|None): a grass header parameter (subaccount|vendor) account_id (str|None): a grass header parameter Return: Response: the response of the get operation """ release = persister.get_product_by_id(product_id) if not release: return release if account_type and account_id: ownership_response = ows_product.check_product_ownership( product_id, account_type, account_id) if not ownership_response.message: return response.create_error_response( message=error.OWNERSHIP_ERROR_MESSAGE.format(account_type), status=error.FORBIDDEN_CODE, code=error.OWNERSHIP_ERROR) elif account_type or account_id: return response.create_error_response( code=error.BAD_REQUEST_ERROR, message=error.GRASS_HEADER_IS_NOT_PRESENT_MESSAGE) return inventory_persister.get_product_inventory_by_product_id(product_id) def product_physical_insert_defaults(product_id): """Set Defaults of supply chain for physical product. Args: product_id (int): product id of existing product Returns: Response: A response with supply chain info for physical product. """ product_physical_supply_chain = product_physical_supply_chain_info. \ get_product_physical_supply_chain_info(product_id) if product_physical_supply_chain: return response.Response( status=200, message=product_physical_supply_chain) else: product_response = ows_product.get_product(product_id) if not product_response: return product_response distribution_format_record = \ ows_product_configuration.get_distribution_format_by_id( product_response.message['distribution_format_id']) if not distribution_format_record: return distribution_format_record distribution_format_media_id = distribution_format_record. \ message['distribution_format_media_id'] supply_chain_defaults = \ ows_product_configuration.get_supply_chain_defaults() if not supply_chain_defaults: return supply_chain_defaults repsonse_result = _insert_supply_chain_defaults( distribution_format_media_id, supply_chain_defaults, product_id) if not repsonse_result: return response.Response( status=400, message='Supply Chain Defaults were not set for the product') return response.Response(status=200, message=repsonse_result) def _insert_supply_chain_defaults( distribution_format_media_id, supply_chain_defaults, product_id): """Set defaults for supply chain in product_physical_supply_chain_info table. Args: supply_chain_defaults(dict): value of returnability defaults storewise. distribution_format_media_id(int): distribution format media id. product_id(int): product id. Return: Response: A response object. """ store_ids = supply_chain_defaults.message.keys() response_data = [] for store_id in store_ids: defaults_data = {} assign_default_supply_chain = True for store_id_default in supply_chain_defaults.message[store_id]: if store_id_default['is_returnable'] == 0: returnable = 'N' else: returnable = 'Y' defaults_data = { 'product_id': product_id, 'returnability': returnable, 'return_disposition': store_id_default[ 'return_disposition'], 'store_id': store_id, 'updated_date': datetime.now().strftime( '%Y-%m-%d %H:%M:%S') } if not store_id_default['distribution_format_media_id']: default_supply_chain = defaults_data if store_id_default['distribution_format_media_id'] == \ distribution_format_media_id: supply_chain_default_response = \ product_physical_supply_chain_info.create(defaults_data) assign_default_supply_chain = False if supply_chain_default_response: response_data.append(supply_chain_default_response.message) break if assign_default_supply_chain: supply_chain_default_response = \ product_physical_supply_chain_info.create(default_supply_chain) response_data.append(supply_chain_default_response.message) return response_data def get_supply_chain_defaults_by_product_id( product_id, account_type=None, account_id=None): """Retrieve a physical product's supply chain info. Args: product_id (int): id of product Return: Response: the response of the get operation """ if account_type and account_id: ownership_response = ows_product.check_product_ownership( product_id, account_type, account_id) if not ownership_response.message: return response.create_error_response( message=error.OWNERSHIP_ERROR_MESSAGE.format(account_type), status=error.FORBIDDEN_CODE, code=error.OWNERSHIP_ERROR) product_result = persister.get_product_by_id(product_id) if not product_result: return product_result product_physical_supply_chain = product_physical_supply_chain_info. \ get_product_physical_supply_chain_info(product_id) if not product_physical_supply_chain: return response.create_error_response( status=error.NOT_FOUND, code=error.NOT_FOUND_MESSAGE, message=error.SUPPLY_CHAIN_INFO_EMPTY) return response.Response(status=200, message=product_physical_supply_chain) def update_product_supply_chain_info( supplychain_info, product_id, account_type=None, account_id=None): """Set Defaults of supply chain for physical product. Args: supplychain_info (dict): supply chain info to be updated product_id (int): product id of existing product account_type (str|None): a grass header parameter (subaccount|vendor) account_id (str|None): a grass header parameter Returns: Response: A response with supply chain info for physical product. """ supplychain_info_data = supplychain_info.get('supplychain_info') if not supplychain_info_data: return response.Response( status=400, message=error.REQUEST_DATA_EMPTY) if account_type and account_id: ownership_response = ows_product.check_product_ownership( product_id, account_type, account_id) if not ownership_response.message: return response.create_error_response( message=error.OWNERSHIP_ERROR_MESSAGE.format(account_type), status=error.FORBIDDEN_CODE, code=error.OWNERSHIP_ERROR) existing_product = persister.get_product_by_id(product_id) if not existing_product: return existing_product update_supplychain_info = product_physical_supply_chain_info. \ update_product_supply_chain_info_by_product_store_id( product_id, supplychain_info_data) return update_supplychain_info def set_supply_chain_info_by_product_id( product_id, data, account_type=None, account_id=None): """Inserts a physical product's supply chain info. Args: product_id (int): id of product data (dict): Contains data to be inserted. Return: Response: the response of the inserted operation """ supplychain_info = data.get('supplychain_info') if not supplychain_info: return response.create_error_response( message=error.REQUEST_DATA_EMPTY, status=error.FORBIDDEN_CODE, code=error.BAD_REQUEST_ERROR) if account_type and account_id: ownership_response = ows_product.check_product_ownership( product_id, account_type, account_id) if not ownership_response.message: return response.create_error_response( message=error.OWNERSHIP_ERROR_MESSAGE.format(account_type), status=error.FORBIDDEN_CODE, code=error.OWNERSHIP_ERROR) product_response = persister.get_product_by_id(product_id=product_id) if not product_response: return product_response supplychain_exists = product_physical_supply_chain_info. \ get_product_physical_supply_chain_info(product_id) if supplychain_exists == []: supplychain_info_set = product_physical_supply_chain_info. \ set_supply_chain_info_by_product_id( product_id, supplychain_info) return supplychain_info_set return response.create_error_response( code=error.VALIDATION_ERROR, message=error.SUPPLYCHAIN_AVAILABLE.format(product_id), status=422) def _set_claimed_upc_as_used(): """Retrieves one message from SQS queue and obtain the UPC in it. It then tries to update it to be used in art_relations.upcs table. Returns: str or None """ queue_name = config.SQS_QUEUE_NAME.format(env=config.ENVIRONMENT) messages = sqs.get_messages_from_sqs(queue_name, 1) upc = messages[0]['Body'] receipt_handles = [messages[0]['ReceiptHandle']] sqs.delete_messages_from_sqs(queue_name, receipt_handles) if releases.set_upc_to_used(upc): return upc return None def _check_country_of_origin_exists(existing_product, fields): """Check if country of origin already exists in DB or in request json. Return: Response: validation error when country of origin is not found """ if not existing_product['country_of_origin']\ and not fields.get(field.COUNTRY_OF_ORIGIN): return response.create_error_response( error.VALIDATION_ERROR, validation.add_error_to_errors( {}, field.COUNTRY_OF_ORIGIN, 'required', True, f"'{field.COUNTRY_OF_ORIGIN}' is a required property")) return response.Response()