"""Logic for re-queue jobs (encoding queue detail).""" import hashlib from datetime import UTC, datetime from typing import Any from vectororder import config from vectororder.constants import ( STATUS_BY_CATEGORY, error as error_consts, fields, jobs as consts, jobs as jobs_constants, ) from vectororder.exceptions import ( DeliveryFileNotAvailableError, DeliveryFileNotFoundError, ProductNotFoundError, ProductNotInContent, VendorNotFoundError, ) from vectororder.logic.schemas import ( JobDeliveryXmlPayload, JobEligibility, JobIneligibilityReason, ) from vectororder.models import ( assets_model, carveouts_model, dms_delivery_spec as dms_delivery_spec_model, job_model, product_model, s3_file, store_model, vendor as vendor_model, ) from vectororder.models.schemas import ( DeliveryType, EncodingOrderType, Job, JobStatus, Product, TrackType, ) def _get_error(code: str, message: str, ids: list[int]) -> dict[str, str | list[int]]: """Get error description. Args: code (str): error code message (str): error message ids (list[int]): job IDs Returns: dict: error description """ return { fields.RESPONSE_ERROR_CODE: code, fields.RESPONSE_ERROR_MESSAGE: message, fields.RESPONSE_ERROR_JOB_IDS: ids, } def _process_check_results( error: dict[str, str | list[int]], errors: list[dict[str, str | list[int]]], job_statuses: dict[int, JobStatus] | None = None, ) -> None: """Process each check results. Append error to errors list. Remove validation failed jobs from statuses list. Args: error (dict): error description errors (list): error list job_statuses (dict) or None: key (int) - job ID, value (str) - status """ errors.append(error) if job_statuses: for job_id in error[fields.RESPONSE_ERROR_JOB_IDS]: del job_statuses[int(job_id)] def _get_statuses(job_ids: list[int]) -> dict[int, JobStatus]: """Get job statuses. Args: job_ids (list[int]): Vector job IDs (encoding_queue_detail_id) Returns: dict: key (int) - job ID, value (str) - status """ job_statuses = job_model.get_jobs(job_ids) result_statuses: dict[int, JobStatus] = {} if not job_statuses: return result_statuses for job_id, job in job_statuses.items(): status = job.status if status in {JobStatus.ENCODING, JobStatus.DELIVERING}: start_date = ( job.encoding_started if status == JobStatus.ENCODING else job.delivery_started ) if not start_date: continue if ( datetime.now(UTC) - start_date.astimezone(UTC) ).total_seconds() / 60 > config.STATUS_STUCK_MINUTES: status = ( JobStatus.ENCODING_STUCK if status == JobStatus.ENCODING else JobStatus.DELIVERING_STUCK ) result_statuses[job_id] = status return result_statuses def _check_exist(job_ids: list[int], job_statuses: dict[int, JobStatus]) -> list[int]: """Check if jobs exist by using statuses list. Args: job_ids (list[int]): Vector job IDs (encoding_queue_detail_id) job_statuses (dict): key (int) - job ID, value (str) - status Returns: list[int]: list of job IDs that do not exist """ non_existing_ids = [] if len(job_ids) > len(job_statuses): non_existing_ids = list(set(job_ids) - set(job_statuses.keys())) return non_existing_ids def _check_duplicates(job_statuses: dict[int, JobStatus]) -> list[int]: """Check if newer jobs with the same UPC and store ID exist. Args: job_statuses (dict): key (int) - job ID, value (str) - status Returns: list[int]: list of job IDs that have newer duplicate """ if not job_statuses: return [] job_ids = list(job_statuses.keys()) return job_model.get_jobs_duplicates(job_ids) def _check_statuses(action: str, job_statuses: dict[int, JobStatus]) -> list[int]: """Check if jobs have correct status for the current operation. Args: action (str): operation name job_statuses (dict): key (int) - job ID, value (str) - status Returns: list[int]: list of job IDs with incorrect statuses """ allowed_statuses = consts.ALLOWED_STATUSES[action] return [ job_id for job_id, status in job_statuses.items() if status not in allowed_statuses ] def _validate( action: str, job_ids: list[int] ) -> tuple[dict[int, JobStatus], list[dict[str, str | list[int]]]]: """Perform several checks for job IDs. 1. if jobs exist 2. check job's statuses 3. for re-encode and re-deliver filter duplicates Args: action (str): An action to perform job_ids (list[int]): Vector job IDs (encoding_queue_detail_id)status Returns: tuple """ job_statuses = _get_statuses(job_ids) errors: list[dict[str, str | list[int]]] = [] if not job_statuses: return job_statuses, errors non_existing_ids = _check_exist(job_ids, job_statuses) if non_existing_ids: _process_check_results( _get_error( error_consts.ERROR_CODE_JOBS_NOT_EXIST, error_consts.ERROR_MESSAGE_JOBS_NOT_EXIST, non_existing_ids, ), errors, ) invalid_job_ids = _check_statuses(action, job_statuses) if invalid_job_ids: _process_check_results( _get_error( error_consts.ERROR_CODE_INCORRECT_STATUS, error_consts.ERROR_MESSAGE_INCORRECT_STATUS, invalid_job_ids, ), errors, job_statuses, ) if action in consts.CHECK_DUPLICATES_ACTIONS: dup_job_ids = _check_duplicates(job_statuses) if dup_job_ids: _process_check_results( _get_error( error_consts.ERROR_CODE_OLDER_DUPLICATE, error_consts.ERROR_MESSAGE_OLDER_DUPLICATE, dup_job_ids, ), errors, job_statuses, ) return job_statuses, errors def perform_status_action(action: str, job_ids: list[int]) -> dict[str, Any]: """Perform actions on Vector jobs. Args: action (str): An action to perform job_ids (list[int]): Vector job IDs (encoding_queue_detail_id) Returns: response.Response: operations result DB error, validation errors or OK """ job_statuses, errors = _validate(action, job_ids) valid_ids = list(job_statuses.keys()) if valid_ids: if action == consts.JOB_ACTION_REENCODE: job_model.set_reencode(valid_ids) elif action == consts.JOB_ACTION_REDELIVER: job_model.set_redeliver(valid_ids) elif action == consts.JOB_ACTION_CANCEL: job_model.set_cancel(valid_ids) # return validation errors if errors: return {fields.RESPONSE_ERRORS: errors} # else return OK, no content return {} def _get_carveouts_distro( encoding_order_type: EncodingOrderType, product: Product ) -> int | None: has_music = any(t.track_type == TrackType.MUSIC for t in product.tracks) has_video = any(t.track_type == TrackType.VIDEO for t in product.tracks) match encoding_order_type: case EncodingOrderType.RELEASE: if has_video and not has_music: return 3 return 1 case EncodingOrderType.RINGTONE: if has_music: return 2 return None def _evaluate_eligibility( upc: int, store_id: int, delivery_type: DeliveryType, encoding_order_type: EncodingOrderType, ) -> JobEligibility: """ Evaluate delivery eligibility for a given UPC, store, and delivery type. Args: upc (int): Product UPC. store_id (int): Target store ID. delivery_type (DeliveryType): Type of delivery. Returns: JobEligibility: Eligibility status and reason. """ store = store_model.get_store(store_id) try: product = product_model.get_product(upc, store) except ProductNotFoundError: return JobEligibility( is_eligible=False, reason=JobIneligibilityReason.PRODUCT_NOT_FOUND ) except ProductNotInContent: return JobEligibility( is_eligible=False, reason=JobIneligibilityReason.RELEASE_NOT_IN_CONTENT ) try: vendor = vendor_model.get_vendor(upc) except VendorNotFoundError: return JobEligibility( is_eligible=False, reason=JobIneligibilityReason.VENDOR_NOT_FOUND ) if vendor.api_vendor_id is not None: return JobEligibility( is_eligible=False, reason=JobIneligibilityReason.VENDOR_IS_API ) # intentionally outside the delivery_type branch — applies to all delivery types distro = _get_carveouts_distro(encoding_order_type, product) if distro is None: return JobEligibility( is_eligible=False, reason=JobIneligibilityReason.PRODUCT_TYPE_ORDER_TYPE_MISMATCH, ) match delivery_type: case DeliveryType.COMPLETE_ALBUM: if product.not_for_distribution not in ("N", "iTunesRingtone"): return JobEligibility( is_eligible=False, reason=JobIneligibilityReason.NOT_FOR_DISTRIBUTION, ) if product.context_type != "physical": if not product.has_track_with_offer_type: return JobEligibility( is_eligible=False, reason=JobIneligibilityReason.NO_TRACK_WITH_OFFER_TYPE, ) if not product_model.get_release_distribution_rights( product.tracks, store ): return JobEligibility( is_eligible=False, reason=JobIneligibilityReason.TRACK_OFFER_TYPE_MISMATCH_SERVICE_DISTRIBUTION_FEATURES, ) else: if not store.is_physical: return JobEligibility( is_eligible=False, reason=JobIneligibilityReason.PHYSICAL_RELEASE, ) ( has_empty_allowed_territories_list, has_empty_allowed_territories_list_reason, ) = carveouts_model.has_empty_allowed_territories_list( upc=upc, store_id=store_id, distro=distro ) if has_empty_allowed_territories_list: return JobEligibility( is_eligible=False, reason=has_empty_allowed_territories_list_reason ) if store_model.is_hd_only(store_id) or store_model.has_hd_encoding_profile( store_id ): if not product.is_digital_audio: return JobEligibility( is_eligible=False, reason=JobIneligibilityReason.NOT_DIGITAL_AUDIO, ) if not assets_model.is_hd_product(product.product_id): return JobEligibility( is_eligible=False, reason=JobIneligibilityReason.NOT_HD ) return JobEligibility(is_eligible=True, reason=None) case DeliveryType.METADATA_UPDATE: delivery_spec = dms_delivery_spec_model.get_dms_delivery_spec( store_id, encoding_order_type ) if not delivery_spec.metadata_update: return JobEligibility( is_eligible=False, reason=JobIneligibilityReason.DMS_NO_METADATA_UPDATES, ) if not job_model.has_delivered_job( upc=upc, store_id=store_id ) and not job_model.has_delivery_history(upc=upc, store_id=store_id): return JobEligibility( is_eligible=False, reason=JobIneligibilityReason.NOT_PREVIOUSLY_DELIVERED, ) return JobEligibility(is_eligible=True, reason=None) case _: raise ValueError(f"Unhandled delivery type: {delivery_type.value}") def get_job_eligibility(job_id: int) -> JobEligibility: """Get the eligibility of a Vector job. Args: job_id (int): Vector job id Returns: JobEligibility """ job = job_model.get_job(job_id) return _evaluate_eligibility( job.upc, job.store_id, job.delivery_type, job.encoding_order_type ) def get_product_eligibility( upc: int, store_id: int, delivery_type: DeliveryType, encoding_order_type: EncodingOrderType, ) -> JobEligibility: """ Checks delivery eligibility for a given UPC, store, and delivery type. Args: upc (int): The product UPC. store_id (int): ID of the store. delivery_type (DeliveryType): Type of delivery encoding_order_type (EncodingOrderType): Type of encoding order Returns: JobEligibility """ return _evaluate_eligibility(upc, store_id, delivery_type, encoding_order_type) def _generate_job_delivery_xml_link(job: Job) -> str: """ Validates job delivered XML availability and generates presigned link if possible. Args: job (Job): Job for which presigned link is generated. Returns: str: Presigned download URL for Delivery XML """ if job.status not in STATUS_BY_CATEGORY.completed.statuses: raise DeliveryFileNotFoundError( f"Job status is {job.status.value}. XML is available only for completed statuses" ) delivered_xml_file_path = jobs_constants.XML_FILE_PATH_PATTERN.format( hashlib.md5(str(job.job_id).encode()).hexdigest() ) is_file_exists = s3_file.check_s3_file_exists( config.DELIVERY_XML_S3_BUCKET_NAME, delivered_xml_file_path ) if not is_file_exists: raise DeliveryFileNotFoundError("Delivery XML file does not exist") storage_class = s3_file.get_s3_file_storage_class( config.DELIVERY_XML_S3_BUCKET_NAME, delivered_xml_file_path ) if storage_class in { "GLACIER", "DEEP_ARCHIVE", }: raise DeliveryFileNotAvailableError("Delivery XML file requires restoration") return s3_file.create_presigned_url( config.DELIVERY_XML_S3_BUCKET_NAME, delivered_xml_file_path, jobs_constants.XML_DOWNLOAD_PRESIGNED_LINK_EXPIRATION_TIME, jobs_constants.XML_DOWNLOAD_PRESIGNED_LINK_CONTENT_DISPOSITION, ) def get_job_delivery_xml_payload(job_id: int) -> JobDeliveryXmlPayload: """Get a delivered XML of a Vector job. Args: job_id (int): Vector job id Returns: JobDeliveryXmlPayload """ job = job_model.get_job(job_id) return JobDeliveryXmlPayload(presigned_link=_generate_job_delivery_xml_link(job))