"""iTunes release status polling.""" from xml.parsers import expat import defusedxml.ElementTree from availability import datastructures as ds from availability import exceptions from availability.connectors import loggly from availability.connectors.stores import itunes from availability.constants import models logger = loggly.get_current_logger() TAG_ITUNES_TRANSPORTER = 'itunes_transporter' TAG_UPLOAD_STATUS = 'upload_status' TAG_UPLOAD_STATUS_INFO = 'upload_status_info' TAG_CONTENT_STATUS_INFO = 'content_status_info' TAG_STORE_STATUS = 'store_status' ATTR_APPLE_IDENTIFIER = 'apple_identifier' ATTR_VENDOR_IDENTIFIER = 'vendor_identifier' ATTR_STATUS = 'status' ATTR_CONTENT_STATUS = 'content_status' ATTR_ON_STORE = 'on_store' ATTR_READY_FOR_STORE = 'ready_for_store' ATTR_ITUNES_CONNECT_STATUS = 'itunes_connect_status' VALUE_NA = 'N/A' # Possible iTMSTransporter status values (from different tags inside # the itunes_transporter tag). # # Tag: upload_status_info, attr: status. UPLOAD_STATUS_UPLOAD_FAILED = 'Upload Failed' UPLOAD_STATUS_INT_TRANSFER = 'Internal Transfer' UPLOAD_STATUS_INT_TRANSFER_ERR = 'Internal Transfer Error' UPLOAD_STATUS_UPLOADING = 'Uploading' UPLOAD_STATUS_READY_TO_IMPORT = 'Ready to Import' UPLOAD_STATUS_IMPORTING = 'Importing' UPLOAD_STATUS_IMPORT_ERR = 'Import Error' UPLOAD_STATUS_IMPORTED = 'Imported' UPLOAD_STATUS_NOT_PROCESS = 'Not to be Processed' # Tag: content_status_info, attr: content_status. CONTENT_STATUS_UNPOLISHED = 'Unpolished' CONTENT_STATUS_POLISHED = 'Polished' # Tag: content_status_info, attr: content_review_status. ITUNES_CONNECT_STATUS_PARTIAL = 'Partial' ITUNES_CONNECT_STATUS_READY = 'Ready' ITUNES_CONNECT_STATUS_LIVE = 'Live' # The sequence of store internal status tags (in the order they should appear) # with valid values for known attributes. STATUS_TRANSITION_SEQ = ( (TAG_UPLOAD_STATUS_INFO, ( (ATTR_STATUS, ( UPLOAD_STATUS_UPLOAD_FAILED, UPLOAD_STATUS_INT_TRANSFER_ERR, UPLOAD_STATUS_INT_TRANSFER, UPLOAD_STATUS_UPLOADING, UPLOAD_STATUS_READY_TO_IMPORT, UPLOAD_STATUS_IMPORTING, UPLOAD_STATUS_IMPORT_ERR, UPLOAD_STATUS_IMPORTED, UPLOAD_STATUS_NOT_PROCESS, )), )), (TAG_CONTENT_STATUS_INFO, ( (ATTR_CONTENT_STATUS, ( CONTENT_STATUS_UNPOLISHED, CONTENT_STATUS_POLISHED, )), (ATTR_ITUNES_CONNECT_STATUS, ( ITUNES_CONNECT_STATUS_PARTIAL, ITUNES_CONNECT_STATUS_READY, ITUNES_CONNECT_STATUS_LIVE, )), )), ) # Store internal (detailed) to our DB (simplified) status mapping. STATUS_MAPPING = { UPLOAD_STATUS_IMPORT_ERR: models.RELEASE_STATUS_INGESTION_FAILED, UPLOAD_STATUS_UPLOAD_FAILED: models.RELEASE_STATUS_INGESTION_FAILED, UPLOAD_STATUS_INT_TRANSFER_ERR: models.RELEASE_STATUS_INGESTION_FAILED, UPLOAD_STATUS_NOT_PROCESS: models.RELEASE_STATUS_INGESTION_FAILED, UPLOAD_STATUS_INT_TRANSFER: models.RELEASE_STATUS_INGESTING, UPLOAD_STATUS_UPLOADING: models.RELEASE_STATUS_INGESTING, UPLOAD_STATUS_READY_TO_IMPORT: models.RELEASE_STATUS_INGESTING, UPLOAD_STATUS_IMPORTING: models.RELEASE_STATUS_INGESTING, UPLOAD_STATUS_IMPORTED: models.RELEASE_STATUS_INGESTING, CONTENT_STATUS_POLISHED: models.RELEASE_STATUS_INGESTING, CONTENT_STATUS_UNPOLISHED: models.RELEASE_STATUS_INGESTING, ITUNES_CONNECT_STATUS_PARTIAL: models.RELEASE_STATUS_READY_TO_GO_LIVE, ITUNES_CONNECT_STATUS_READY: models.RELEASE_STATUS_PREORDER, ITUNES_CONNECT_STATUS_LIVE: models.RELEASE_STATUS_LIVE, } ERROR_PARSING = 'Error parsing XML: {!s}' ERROR_NO_ATTR = 'no {:s} attribute found' def get_status_from_store(*, product_id, **kwargs): """Get release status from iTunes. Queries store, parses XML, and sets proper ReleaseStatus.db_release_status. Args: product_id (int): Owner's unique product ID. Returns: ReleaseStatus: Release status in the iTunes store , for example ReleaseStatus( store_release_id='1000000001', store_release_status='On Store', live_countries=['US','CA'], db_release_status='live') """ # Get XML response from the iTMSTransporter tool. store_response = itunes.query_store(product_id) logger.info('Itunes response for product {} received'.format(product_id)) # Get all information that is available from the XML itself. release_status = release_status_from_xml( xml_str=store_response, product_id=product_id) logger.info('Release status parsed from store response') # Assign internal status from store status according to configured mapping. release_status.db_release_status = STATUS_MAPPING[ release_status.store_release_status] logger.info('Internal status assigned according to mapping') return release_status def release_status_from_xml(*, xml_str, product_id): """Parse XML response from the iTMSTransporter. Also ensure supplied product_id matches the one in response from the store. Args: xml_str (str): xml response as str from iTMSTransporter stdout. product_id (int): Owner's unique product ID. Returns: A ReleaseStatus instance without store_id and db_release_status set. For example: ReleaseStatus( store_id=None, store_release_id='1000000001', store_release_status='On Store', live_countries=['US','CA']) Raises: StoreResponseParseError: An error occurred while parsing response. """ # These are used only within this function, so defined as locals. error_no_tag = 'no {:s} tag found' error_vendor_attr = ( 'missing or not matching {attr:s}' '(expected {expected!s}, received {received:s})') try: root = defusedxml.ElementTree.fromstring(xml_str, forbid_dtd=True) except (TypeError, expat.ExpatError, defusedxml.ElementTree.ParseError) as exc: raise exceptions.StoreResponseParseError(ERROR_PARSING.format(exc)) if root.tag != TAG_ITUNES_TRANSPORTER: msg = error_no_tag.format(TAG_ITUNES_TRANSPORTER) raise exceptions.StoreResponseParseError(ERROR_PARSING.format(msg)) upload_status = _xml_element_find_one(root=root, name=TAG_UPLOAD_STATUS) if not upload_status: msg = error_no_tag.format(TAG_UPLOAD_STATUS) raise exceptions.StoreResponseParseError(ERROR_PARSING.format(msg)) release_status = ds.ReleaseStatus() release_status.store_release_status = _get_explicit_status(upload_status) # It does not make sense to check for Apple ID nor live countries until # the release is successfully imported by the store. if release_status.store_release_status not in ( UPLOAD_STATUS_IMPORTED, CONTENT_STATUS_UNPOLISHED, CONTENT_STATUS_POLISHED, ITUNES_CONNECT_STATUS_PARTIAL, ITUNES_CONNECT_STATUS_READY, ITUNES_CONNECT_STATUS_LIVE, ): return release_status apple_id = int(upload_status.get(ATTR_APPLE_IDENTIFIER, 0)) if not apple_id: msg = ERROR_NO_ATTR.format(ATTR_APPLE_IDENTIFIER) raise exceptions.StoreResponseParseError(ERROR_PARSING.format(msg)) # Compare our product ID to what we've received in the response. raw_vendor_id = upload_status.get(ATTR_VENDOR_IDENTIFIER, None) if (not raw_vendor_id or ( raw_vendor_id and int(raw_vendor_id) != product_id)): msg = error_vendor_attr.format( attr=ATTR_VENDOR_IDENTIFIER, expected=product_id, received=raw_vendor_id) raise exceptions.StoreResponseParseError(ERROR_PARSING.format(msg)) release_status.store_release_id = str(apple_id) # We don't store 'ready_for_store' countries yet, just 'on_store'. _, release_status.live_countries = _get_countries(upload_status) return release_status def _get_countries(root): """Get 'ready for store' and 'on store' countries. Args: root (ElementTree.Element): Element to search in (no recursion). Raises: StoreResponseParseError on unknown XML element structure. Returns: tuple: A tuple of countries lists: 'ready for store' and 'on store'. """ path = '{}/{}'.format(TAG_CONTENT_STATUS_INFO, TAG_STORE_STATUS) store_status_tags = root.findall(path) num_tags = len(store_status_tags) if num_tags == 0: return [], [] if num_tags > 1: msg = '{num} {path} tags found, expected 1'.format( path=path, num=num_tags) raise exceptions.StoreResponseParseError(ERROR_PARSING.format(msg)) store_status = store_status_tags[0] countries = {} for attr in ATTR_READY_FOR_STORE, ATTR_ON_STORE: value = store_status.get(attr, VALUE_NA) if value == VALUE_NA: countries[attr] = [] continue countries[attr] = list(set( [c.strip() for c in value.split(',') if c])) return countries[ATTR_READY_FOR_STORE], countries[ATTR_ON_STORE] def _get_explicit_status(root): """ Get store's and internal release status according to known xml structure. This function does not perform checks to determine live or ready-to-go-live countries and is able to determine statuses up to UPLOAD_STATUS_IMPORTED. Args: root (ElementTree.Element): Element to search in (no recursion). Raises: StoreResponseParseError on unknown XML element structure. Returns: str: Store internal status. """ # Check each attribute respecting the ordering: tags that are expected to # appear earlier are checked first. store_status = None for tag_name, attrs in STATUS_TRANSITION_SEQ: for attr_name, valid_values in attrs: element = _xml_element_find_one(root=root, name=tag_name) if element is None: # The next expected element not found, do not check further. break attr_value = element.get(attr_name, None) if attr_value is None: msg = ERROR_NO_ATTR.format(attr_name) logger.info(ERROR_PARSING.format(msg)) break if attr_value not in valid_values: msg = ('unknown status in tag "{tag:s} attribute "{attr:s}" ' 'value "{value:s}"'.format( tag=tag_name, attr=attr_name, value=attr_value)) logger.info(ERROR_PARSING.format(msg)) break store_status = attr_value if store_status is None: msg = 'unable to find known tags sequence to determine status from' raise exceptions.StoreResponseParseError(ERROR_PARSING.format(msg)) return store_status def _xml_element_find_one(*, root, name): """Find exactly one element in ElementTree Element instance. Args: root (ElementTree.Element): Element to search in (no recursion). name (str): name of the element to search for. Returns: ElementTree.Element: Element if one was found, None otherwise. """ elements = root.findall(name) if len(elements) == 1: return elements[0] return None