"""Release Model.""" import datetime import backoff from flask import g from oto import response import pymysql.err from pytz import timezone from sqlalchemy import BigInteger from sqlalchemy import Column from sqlalchemy import Date from sqlalchemy import DateTime from sqlalchemy import Enum from sqlalchemy import Integer from sqlalchemy import String from product_digital.connectors import mysql from product_digital.connectors.sentry import send_to_sentry from product_digital.constants import error from product_digital.constants import header from product_digital.constants import timezones from product_digital.models.sql import product_digital as product_digital_sql from product_digital.utils import error_handling STATUS_TYPES = [ 'orchard_processing', 'label_confirmation', 'transfer_to_content', 'label_processing', 'in_content'] STATUS_IN_PROGRESS_WORKSTATION = ['label_processing', 'transfer_to_content'] STATUS_IN_PROGRESS_OA = ['label_processing', 'in_content', 'transfer_to_content'] STATUS_IN_CONTENT = 'in_content' PROPERTIES_FOR_UPDATE = [ 'artist_id', 'c_line', 'description', 'genre_id', 'itunes_previewable', 'label', 'manufacturer_upc', 'meta_language', 'p_line', 'preorder_date', 'product_code', 'delivered_version', 'release_date', 'release_format', 'release_status', 'release_name', 'sale_start_date', 'special_instructions', 'vendor_release_identifier', 'version', ] NOT_FOR_DISTRIBUTION_VALUES = [ 'N', 'AccountingDummy', 'EditoriallySuspectContent', 'NotforFurtherDistribution', 'TVSeasonAccountingDummy', 'LabelRCRevenueDummy', 'iTunesRingtone', 'CatalogDuplicate', 'YouTubeRemap', 'PhysicalProduct', 'IncompleteAssets', 'SwitchboardDummy', 'SMEAnalyticsDummy', 'MissingAssets', 'AWALNotOurDistribution', 'KNRAccountingDummy', 'ReviewedWontDeliver', 'BulkIngestInProgress', ] NOT_FOR_DISTRIBUTION_N = 'N' class Release(mysql.BaseModel): """Release Model. Represents Release metadata """ __tablename__ = 'releases' release_id = Column( Integer, primary_key=True, autoincrement=True, nullable=False) artist_id = Column(Integer) c_line = Column(String) description = Column(String) deletions = Column(Enum("Y", "N"), default='N') display_upc = Column(String) distribution_format_id = Column(Integer) genre_id = Column(Integer) ingestion_completed = Column(DateTime) itunes_previewable = Column(Enum('yes', 'no')) label = Column(String) manufacturer_upc = Column(String) meta_language = Column(String) not_for_distribution = Column(Enum(*NOT_FOR_DISTRIBUTION_VALUES), default='N') p_line = Column(String) preorder_date = Column(Date) product_code = Column(String) product_type_id = Column(Integer) project_id = Column(BigInteger) delivered_version = Column(String(255)) release_date = Column(Date) release_format = Column('format', String) release_name = Column(String) release_status = Column(Enum(*STATUS_TYPES), default='orchard_processing') sale_start_date = Column(Date) special_instructions = Column(String) subaccount_id = Column(Integer) upc = Column(BigInteger, nullable=False) vendor_catalog_number = Column(String) vendor_release_identifier = Column(String) version = Column(String(255)) def to_dict(self): """Return a dictionary of this release's properties.""" return { 'artist_id': self.artist_id, 'c_line': self.c_line, 'description': self.description, "deletions": self.deletions, 'display_upc': self.display_upc, 'distribution_format_id': self.distribution_format_id, 'format': self.release_format, 'genre_id': self.genre_id, 'ingestion_completed': self.ingestion_completed, 'itunes_previewable': self.itunes_previewable, 'label': self.label, 'manufacturer_upc': self.manufacturer_upc, 'meta_language': self.meta_language, 'not_for_distribution': self.not_for_distribution, 'p_line': self.p_line, 'preorder_date': self.preorder_date, 'product_code': self.product_code, 'product_type_id': self.product_type_id, 'delivered_version': self.delivered_version, 'project_id': self.project_id, 'release_date': self.release_date, 'release_id': self.release_id, 'release_name': self.release_name, 'release_status': self.release_status, 'sale_start_date': self.sale_start_date, 'special_instructions': self.special_instructions, 'subaccount_id': self.subaccount_id, 'upc': self.upc, 'vendor_catalog_number': self.vendor_catalog_number, 'vendor_release_identifier': self.vendor_release_identifier, 'version': self.version} def create(release_data): """Create a new release instance. Returns: Response: Response containing data for the release created """ if not release_data.get('upc'): g.ows.log.info('Assigning display_upc and upc...') upc_response = get_upc() if not upc_response: return upc_response release_data['upc'] = upc_response.message g.ows.log.info( 'Assigned display_upc and upc {}'.format(release_data['upc'])) release_data['display_upc'] = release_data['upc'] release_data['upc'] = int(release_data['upc']) release = Release(**release_data) with mysql.db_session() as session: session.add(release) session.flush() return response.Response(message=release.to_dict(), status=201) @mysql.wrap_db_errors def update(release_id, release_data, orchard_user_id=''): """Update an existing release with the given values. Args: release_id (int): id of the release to update. release_data (dict): properties to update. orchard_user_id (str): orchard user id. Returns: response.Response: object representing the outcome of the update. """ release_status = release_data.get('release_status') if release_status and release_status not in STATUS_TYPES: del release_data['release_status'] release_status = None with mysql.db_session() as session: existing_release = session.query(Release).get(release_id) if not existing_release: return response.Response(status=404) # OA users may still execute updates for dates when release_status == STATUS_IN_CONTENT if not orchard_user_id or not orchard_user_id.startswith(header.OA_USER_PREFIX): if existing_release.release_status == STATUS_IN_CONTENT and ( release_data.get('release_date') or release_data.get('sale_start_date') is not None): return response.create_error_response( code=error.ERROR_CODE_BAD_REQUEST, message=error.DISABLED_RELEASE_DATE_SALE_START_DATE, status=403 ) # You can only change a product's release status to # transfer_to_content if the existing release's release status is # label_processing. if release_status and \ existing_release.release_status != 'label_processing': del release_data['release_status'] for prop_name in release_data: if prop_name not in PROPERTIES_FOR_UPDATE: continue setattr(existing_release, prop_name, release_data.get(prop_name)) if 'project_code' in release_data: existing_release.vendor_catalog_number = release_data[ 'project_code'] project_id = release_data.get('project_id') if project_id and (project_id != existing_release.project_id): existing_release.project_id = release_data['project_id'] existing_release.subaccount_id = release_data['subaccount_id'] session.merge(existing_release) new_properties = existing_release.to_dict() return response.Response(status=200, message=new_properties) @backoff.on_exception( backoff.expo, pymysql.err.InternalError, max_tries=5, giveup=error_handling.non_deadlock_error, jitter=backoff.full_jitter) def get_upc(): """Get next available UPC and return. Calls stored procedure to retrieve the next available UPC and mark as used. Returns: Response: Response containing the upc """ with mysql.db_session() as session: session.connection( execution_options={'isolation_level': 'SERIALIZABLE'}) try: upc = _get_upc_stored_procedure(session) return response.Response(message=upc) except pymysql.err.InternalError as pymysql_internal_error: # re-raise deadlock so it can be retried if not error_handling.non_deadlock_error(pymysql_internal_error): raise pymysql_internal_error except Exception as err: # noqa send_to_sentry(err, {}, 500, error.ERROR_MESSAGE_UPC_FETCH_FAILED) return response.create_error_response( code=error.INTERNAL_ERROR, message='mysql error', status=500) def _get_upc_stored_procedure(session): """Call stored procedure to reserve a upc. Returns: String: The upc that has been reserved """ return str(session.execute(product_digital_sql.RESERVE_UPC).scalar()) @mysql.db_session_wrap @mysql.wrap_db_errors def get_release(release_id, session, for_update=False): """Return basic information for a release. Args: release_id (int): release_id for the release to be retrieved. Returns: Response: Response containing the release information. """ if for_update: release = (session.query(Release).with_for_update().get(release_id)) else: release = session.query(Release).get(release_id) if not release: return response.create_not_found_response() release_dictionary = release.to_dict() return response.Response(message=release_dictionary) def get_release_instance(release_id, session): """Return release instance. Args: release_id (int): release_id for the release to be retrieved. Returns: Release: Release information. """ return session.query(Release).get(release_id) @mysql.wrap_db_errors def update_not_for_distribution(release_id, not_for_distribution, release_status=None): """Update a product's not_for_distribution flag and product status. Args: release_id (int): id of the release to update. not_for_distribution (string): value to set it to. release_status (string): product status to set it to. Returns: response.Response: object representing the outcome of the update. """ if release_status and release_status not in STATUS_TYPES: return response.create_error_response( error.VALIDATION_ERROR, error.ERROR_MESSAGE_INVALID_RELEASE_STATUS, status=response.status.FORBIDDEN) if not_for_distribution not in NOT_FOR_DISTRIBUTION_VALUES: return response.create_error_response( error.VALIDATION_ERROR, error.ERROR_MESSAGE_INVALID_NOT_FOR_DISTRIBUTION, status=response.status.FORBIDDEN) with mysql.db_session() as session: existing_release = session.query(Release).get(release_id) if not existing_release: return response.Response(status=404) existing_release.not_for_distribution = not_for_distribution if release_status: existing_release.release_status = release_status session.merge(existing_release) result = { 'not_for_distribution': not_for_distribution, } if release_status: result['release_status'] = release_status return response.Response(status=200, message=result) @mysql.wrap_db_errors def update_release_status(release_id, release_status): """Update the release_status on a release with the given value. Args: release_id (int): id of the release to update. release_status (string): value to set it to. Returns: response.Response: object representing the outcome of the update. """ if release_status not in STATUS_TYPES: return response.create_error_response( error.VALIDATION_ERROR, error.ERROR_MESSAGE_INVALID_RELEASE_STATUS, status=response.status.FORBIDDEN) with mysql.db_session() as session: existing_release = session.query(Release).get(release_id) if not existing_release: return response.Response(status=404) tz = timezone(timezones.TZ_NEW_YORK) existing_release.ingestion_completed = datetime.datetime.now(tz) existing_release.release_status = release_status session.merge(existing_release) result = { 'release_status': release_status } return response.Response(status=200, message=result) @mysql.wrap_db_errors def UNSAFE_update_display_upc(release_id, display_upc): """Update display_upc on a release with the given value. This is for UNSAFE method only and should be used with caution. The main concern is that just by changing display_upc, we might create a conflict with another release that has the same upc. Args: release_id (int): id of the release to update. display_upc (string): value to set it to. Returns: response.Response: object representing the outcome of the update. """ with mysql.db_session() as session: existing_release = session.query(Release).get(release_id) if not existing_release: return response.Response(status=404) existing_release.display_upc = display_upc session.merge(existing_release) result = { 'display_upc': display_upc } return response.Response(status=200, message=result) @mysql.wrap_db_errors def UNSAFE_soft_delete(release_id): """Update deletions on a release to 'Y'. This is for UNSAFE method only and should be used with caution. Args: release_id (int): id of the release to update. Returns: response.Response: object representing the outcome of the update. """ with mysql.db_session() as session: existing_release = session.query(Release).get(release_id) if not existing_release: return response.Response(status=404) existing_release.deletions = 'Y' session.merge(existing_release) result = { 'deletions': 'Y' } return response.Response(status=200, message=result) def delete(product_id, session): """Delete release entry.""" session.query(Release) \ .filter(Release.release_id == product_id) \ .delete()