"""Module with ownership model.""" from collections import defaultdict from copy import deepcopy from datetime import datetime import decimal import itertools from operator import itemgetter import time from boto3.dynamodb.conditions import Key from botocore.exceptions import ClientError from oto import response from sqlalchemy.exc import SQLAlchemyError from masters_registry import config from masters_registry import utils from masters_registry.connectors import dynamodb from masters_registry.connectors import mysql from masters_registry.connectors import sentry from masters_registry.constant import field_const from masters_registry.constant import opcode_const from masters_registry.models import users from masters_registry.models.sql import track audit_table = dynamodb.dynamodb_resource.Table(config.DYNAMODB_MASTERS_AUDIT) new_audit_table = dynamodb.dynamodb_resource.Table( config.DYNAMODB_MASTERS_AUDIT_NEW) active_table = dynamodb.dynamodb_resource.Table(config.DYNAMODB_MASTERS_ACTIVE) def get_ownership(isrc): """Get ownership info from dynamodb for isrc. Args: isrc (str): international standard recording code. Returns: response.Response: ownership information. """ if not isrc: return response.Response(message={}) dynamo_response = active_table.get_item(Key={field_const.ISRC: isrc}) item = dynamo_response.get(field_const.DYNAMO_ITEM_FIELD, {}) return response.Response(item) # TODO move track-related things to separate module def get_track(tuid): """Get track info from art_relations for a single tuid. Args: tuid (int): track id Returns: response.Response: dict """ tracks = list(get_tracks((tuid, )).message.values()) if not tracks: return response.Response(message={}) return response.Response(message=tracks[0]) def get_tracks(tuids): """Get track info from art_relations for a set of tuids. Args: tuids (list): track.ids. Returns: response.Response: containing dict of dict message outer dict keyed by tuid inner dicts keyed by fieldname """ if not tuids: return response.Response(message={}) with mysql.mr_session_scope() as session: rows = session.execute( track.SELECT_TRACKS_BY_TUIDS, {field_const.TUIDS: tuids}) return response.Response( message={row[field_const.TUID]: dict(row) for row in rows}) def create_record( opcode, isrc, territory, correlation_id, user, tuid=None, reason=None): """Helper function to create an insertable object for DynamoDB. The first part of the records defines the unique transaction (where ISRC_TERRITORY is the combination of an ISRC and a territory, and the timestamp (in ms) allows us to get the chronological order in which a specific succession of transactions has happened). Args: opcode (str): The opcode determining the action entered into the audit table. isrc (str): international standard recording code. tuid (int): track unique identifier. territory (str): 2-code territory to add. correlation_id (str): The correlation id for logging the microservice instance. user (str): The user id for audit table logging. Returns: dict: A structure containing the key/value pairs as expected by the dynamodb table. """ correlation_id_field = field_const.CORRELATION_ID.lower().replace( '-', '_') record = { field_const.ISRC_TERRITORY: '{isrc}_{territory}'.format( isrc=isrc, territory=territory), # TODO fix to use utils.get_timestamp_for_dynamo field_const.TIMESTAMP: decimal.Decimal(str(time.time() * 1000)), field_const.USER: user, field_const.OPCODE: opcode, correlation_id_field: correlation_id } if tuid is not None: record[field_const.TUID] = tuid if reason is not None: record[field_const.LOCK_REASON] = reason return record def get_isrcs(upcs): """Get ISRCS from art_relations for a set of upcs Args: upcs (list): track.upcs Returns: response.Response: containing dict of dict message outer dict keyed by upc """ if not upcs: return response.Response(message={}) try: with mysql.mr_session_scope() as session: rows = session.execute( track.SELECT_ISRC_RECORDS_BY_UPCS, {field_const.UPCS: upcs}) except SQLAlchemyError as ex: if sentry.sentry_client: sentry.sentry_client.captureException() return response.create_fatal_response(str(ex)) rows_by_upc = defaultdict(list) for row in rows: rows_by_upc[row[field_const.UPC]].append(dict(row)) return response.Response(message=rows_by_upc) def get_existing_isrcs_in_tracks(isrcs): """Get all ISRCs that exist in art_relations.track table Args: isrcs (list(str)): list of international standard recording codes Returns: set: existing ISRCs """ if not isrcs: return set() try: with mysql.mr_session_scope() as session: rows = session.execute( track.SELECT_ISRCS, {field_const.ISRCS: isrcs}) except SQLAlchemyError as ex: return response.create_fatal_response(str(ex)) existing_isrcs = set(itertools.chain.from_iterable(rows)) return existing_isrcs def get_existing_isrcs_in_active_table(isrcs): """Get all ISRCs that exist in active table Args: isrcs (list(str)): list of international standard recording codes Returns: list: existing ISRCs """ if not isrcs: return [] # splitting ISRCs into batches because # we can't get more than 100 items at once isrcs_batches = [] for i in range(0, len(isrcs), config.DYNAMODB_MAX_KEYS): isrcs_batches.append(isrcs[i:i + config.DYNAMODB_MAX_KEYS]) existing_isrcs = [] for batch in isrcs_batches: keys = [{field_const.ISRC: isrc} for isrc in batch] while True: response = dynamodb.client.batch_get_item( RequestItems={ config.DYNAMODB_MASTERS_ACTIVE: { 'Keys': keys, 'ConsistentRead': True } } ) unprocessed_keys = response['UnprocessedKeys'] response_items = ( response['Responses'][config.DYNAMODB_MASTERS_ACTIVE]) for item in response_items: existing_isrcs.append(item) if not unprocessed_keys: break keys = unprocessed_keys[config.DYNAMODB_MASTERS_ACTIVE]['Keys'] return existing_isrcs def update_multiple_ownerships(items): """Insert multiple items into audit_table items (dict): item created by create_record """ with new_audit_table.batch_writer() as table: for item in items: table.put_item(Item=item) def create_initial_record(isrc, territories=None): """Create an initial record in the active table. This method throws an exception when the active table already has a record for the current ISRC. It's simply a sanity check on our end. Args: isrc (str): the irsc to use as part of the active table. territories (dict): claimed territories info. """ try: active_table.put_item( Item={ field_const.ISRC: isrc, field_const.TERRITORIES: territories or {}, field_const.LOCKED_TERRITORIES: {}, field_const.TIMESTAMP: utils.get_timestamp_for_dynamo() }, ConditionExpression='attribute_not_exists({})'.format( field_const.ISRC)) except ClientError: pass def create_audit_record( opcode, isrc, territories, correlation_id, user, tuid=None, reason=None, source=None, conflict=None): """Helper function to create an insertable object for DynamoDB. Args: opcode (str): The opcode determining the action entered into the audit table. isrc (str): international standard recording code. territories (list): 2-code territories to add. correlation_id (str): The correlation id for logging the microservice instance. user (str): The user id for audit table logging. tuid (int): track unique identifier. reason (str): lock reason source (str): Bulk update UPC tool or manual edit. conflict (dict): The information about created or resolved conflict. Example: { 'status': 'CONFLICT_CREATED', 'conflicting_tuids': [2322323] } Returns: dict: A structure containing the key/value pairs as expected by the dynamodb table. """ if tuid is not None: territories = {a2_code: tuid for a2_code in territories} record = { field_const.ISRC: '{isrc}'.format(isrc=isrc), # TODO fix to use utils.get_timestamp_for_dynamo field_const.TIMESTAMP: decimal.Decimal(str(time.time() * 1000)), field_const.USER: user, field_const.OPCODE: opcode, field_const.TERRITORIES: territories, field_const.CORRELATION_ID_FIELD: correlation_id } if reason is not None: record[field_const.LOCK_REASON] = reason if conflict is not None: record[field_const.CONFLICT] = conflict if source is not None: record[field_const.SOURCE] = source return record def insert_audit_record( opcode, isrc, territories, correlation_id, user, tuid=None, reason=None, source=None, conflict=None): """Create and insert an audit record into DynamoDB. Args: opcode (str): The opcode determining the action entered into the audit table. isrc (str): international standard recording code. territories (list|dict): should be a list of 2-letter codes for territories OR a dict {'two_letter_code': 'tuid', ...} correlation_id (str): The correlation id for logging the microservice instance. user (str): The user id for audit table logging. tuid (int): track unique identifier. reason (str): lock reason source (str): Bulk update UPC tool or manual edit. conflict (dict): The information about created or resolved conflict. Example: { 'status': 'CONFLICT_CREATED', 'conflicting_tuids': [2322323] } Returns: dict: A structure containing the key/value pairs as expected by the dynamodb table. """ record = create_audit_record( opcode, isrc, territories, correlation_id, user, tuid=tuid, reason=reason, source=source, conflict=conflict) new_audit_table.put_item(Item=record) return record def create_active_record(isrc, tuid, territories, user): """Creates new active record with given territories claimed for given tuid Args: isrc (str): international standard recording code. tuid (int): track unique identifier. territories (list): 2-code territories. user (str): Orchard user id. """ claimed_territories = {} for territory in territories: claimed_territories[territory] = [{field_const.TUID: tuid}] create_initial_record(isrc, claimed_territories) def update_active_record(isrc, territories, lock_reason=None, tuid=None): """Updates active record. Args: isrc (str): international standard recording code territories (dict): dict containing list of 2-code territories to: claim, remove, lock and unlock. Example: { 'to_claim': ['QA'], 'to_remove': ['CA'] 'to_lock': ['PL'], 'to_unlock': ['US'], } lock_reason (str): lock reason tuid (int): track unique identifier """ territories_to_claim = territories.get('to_claim') territories_to_unlock = territories.get('to_unlock') territories_to_remove = territories.get('to_remove') territories_to_lock = territories.get('to_lock') if territories_to_claim and tuid is None: raise ValueError('TUID must not be empty') if territories_to_lock and lock_reason is None: raise ValueError('lock reason must not be empty') if territories_to_unlock: active_record_remove_attributes( isrc, territories_to_unlock, 'locked_territories') if territories_to_remove: active_record_remove_attributes( isrc, territories_to_remove, 'territories') if territories_to_claim: active_record_add_attributes( isrc, territories_to_claim, 'territories', {field_const.TUID: tuid} ) if territories_to_lock: active_record_add_attributes( isrc, territories_to_lock, 'locked_territories', {field_const.REASON: lock_reason} ) def active_record_remove_attributes(isrc, territories, field): """Removes given territories from given field Args: isrc (str): international standard recording code. territories (list): 2-code territories to add. field (str): territories or locked_territories """ expressions = [] attribute_names = {} for index, territory in enumerate(territories): expressions.append( '{field}.#t{index}'.format(field=field, index=index)) attribute_names.update( {'#t{index}'.format(index=index): territory}) if dynamodb.batch_update_item_fields( active_table, {field_const.ISRC: isrc}, 'REMOVE', expressions, attribute_names, attribute_values=None): expressions = [] attribute_names = {} dynamodb.batch_update_item_fields( active_table, {field_const.ISRC: isrc}, 'REMOVE', expressions, attribute_names, attribute_values=None, force=True) def active_record_add_attributes(isrc, territories, field, value): """Adds given value for each territory in given field Args: isrc (str): international standard recording code. territories (list): 2-code territories to add. field (str): territories or locked_territories value (dict): Example: {'ISRC': 111} or {'reason': 'Lock reason'} """ expressions = [] attribute_names = {} attribute_values = {} for index, territory in enumerate(territories): expressions.append( '{field}.#t{index}=:tr{index}'.format(field=field, index=index)) attribute_names.update({'#t{index}'.format(index=index): territory}) attribute_values.update({':tr{index}'.format(index=index): value}) if dynamodb.batch_update_item_fields( active_table, {field_const.ISRC: isrc}, 'SET', expressions, attribute_names, attribute_values): expressions = [] attribute_names = {} attribute_values = {} dynamodb.batch_update_item_fields( active_table, {field_const.ISRC: isrc}, 'SET', expressions, attribute_names, attribute_values, force=True) def create_ownership(isrc, territories, tuid, correlation_id, user): """Helper function for creating a new active record and adding territories. Just a wrapper around `create_active record` and `insert_audit_record` Args: isrc (str): international standard recording code. territories (list): a2 codes of territories to add tuid (int): track unique identifier correlation_id (str): The correlation id for logging the microservice instance. user (str): The user id for audit table logging. Returns: Response: TUID and territories added """ create_active_record(isrc, tuid, territories, user) insert_audit_record( opcode_const.ADD, isrc, territories, correlation_id, user, tuid) return response.Response({ field_const.TUID: tuid, field_const.TERRITORIES: territories}) def get_ownership_audit(isrc): """Get audit table records for ownership from dynamodb for isrc. Args: isrc (str): international standard recording code. Returns: response.Response: ownership audit information. """ paginator = dynamodb.client.get_paginator('query') items_paginator = paginator.paginate( TableName=config.DYNAMODB_MASTERS_AUDIT_NEW, KeyConditionExpression=Key(field_const.ISRC).eq(isrc)) page_items = [page.get('Items', []) for page in items_paginator] items = deepcopy(list(itertools.chain.from_iterable(page_items))) return response.Response(items) def get_lock_history_for_ownership(ownership_info): """Extend ownership with information when the locks were created or updated Args: ownership_info (dict): ownership info for ISRC Returns: Response """ audit_records = get_ownership_audit(ownership_info[field_const.ISRC]) if not audit_records.message: return audit_records audit_records = audit_records.message lock_records, lock_user_names = _get_lock_records_and_user_names( audit_records) for lock_info in ownership_info[field_const.LOCKED_TERRITORIES]: lock_history = _build_lock_reason_history( lock_info[field_const.REASON], lock_records, lock_user_names) lock_info.update(lock_history) return response.Response() def get_lock_history_for_single_reason(reason, isrc): """Get information when a lock was created and updated Args: reason (str): Lock reason isrc (str): international standard recording code. Returns: Response: dict with information about lock """ audit_records = get_ownership_audit(isrc) if not audit_records: return audit_records audit_records = audit_records.message lock_records, lock_user_names = _get_lock_records_and_user_names( audit_records) return response.Response( _build_lock_reason_history(reason, lock_records, lock_user_names)) def _get_lock_records_and_user_names(audit_records): """Helper function for filtering audit records and getting user names Args: audit_records (list): ownership audit records Returns: tuple: containing lock/unlock records list and dict with user names and ids """ audit_records = sorted( audit_records, key=itemgetter(field_const.TIMESTAMP)) lock_users = set() lock_records = [] for record in audit_records: if record[field_const.OPCODE] in [ opcode_const.LOCK, opcode_const.UNLOCK]: lock_records.append(record) lock_users.add(record[field_const.USER]) lock_user_names = users.get_orchard_user_names(lock_users) lock_user_names = lock_user_names.message if lock_user_names else {} return lock_records, lock_user_names def _build_lock_reason_history(lock_reason, audit_records, lock_user_names): """Get information when a lock was created and updated Args: lock_reason (str): Lock reason audit_records (list): List of audit records from dymamo db lock_user_names (dict): Dict with user ids and names Returns: dict: information about lock Example: { 'created': { 'user': 'Arnold Schwarzenegger', 'timestamp': '1984-05-13T01:52:04' }, 'updated': {} } """ current_state = set() lock_reason_history = [] for record in audit_records: record_territories = set(record[field_const.TERRITORIES]) if (record[field_const.OPCODE] == opcode_const.LOCK and record[field_const.LOCK_REASON] == lock_reason): current_state.update(record_territories) lock_reason_history.append({ 'timestamp': record[field_const.TIMESTAMP], 'user': record[field_const.USER] }) if (record[field_const.OPCODE] == opcode_const.UNLOCK and record_territories.intersection(current_state)): current_state.difference_update(record_territories) lock_reason_history.append({ 'timestamp': record[field_const.TIMESTAMP], 'user': record[field_const.USER] }) if not lock_reason_history: return {} lock_reason_history = sorted( lock_reason_history, key=itemgetter('timestamp')) result = { 'created': { 'datetime': datetime.utcfromtimestamp( float(lock_reason_history[0]['timestamp'])/1000).isoformat(), 'user': lock_user_names.get(lock_reason_history[0]['user'], '') }, 'updated': { 'datetime': datetime.utcfromtimestamp( float(lock_reason_history[-1]['timestamp']/1000)).isoformat(), 'user': lock_user_names.get(lock_reason_history[-1]['user'], '') } if len(lock_reason_history) > 1 else {} } return result def get_upc_by_tuid(tuid): """Find track UPC by it's TUID in the AR database Args: tuid: TUID Returns: Response: UPC """ if not tuid: return response.Response(message={}) try: with mysql.mr_session_scope() as session: result = session.execute( track.SELECT_UPC_BY_TUID, {field_const.TUID: tuid}) row = result.fetchone() if not row: return response.create_not_found_response() return response.Response( message={field_const.UPC: row[field_const.UPC]}) except SQLAlchemyError as ex: return response.create_fatal_response(str(ex))