"""Logical operations for fingerprint rules.""" from datetime import datetime from sound_recordings.models import fingerprint_rules from sound_recordings.models import ownership from sound_recordings.models.fingerprint_rules import get_profile_type_by_id from sound_recordings.utils import features from sound_recordings.utils import snowflake as snowflake_util class UnknownResourceType(Exception): """Unexpected object type.""" pass def match_bulk(obj_type, obj_ids): """Match active rules attached to objects. Args: obj_type (str): Anchor node type obj_ids list(int): Anchor node ids Returns: list[dict]: id: Anchor node id rules: Rules with active indicators """ if obj_type not in ['Vendor', 'Subaccount', 'Track']: raise UnknownResourceType() base_rules = fingerprint_rules.get_bulk_rules(obj_type, obj_ids) results = {} for obj_id in obj_ids: results[obj_id] = { 'base_rules': base_rules[obj_id], 'top_rules': [], 'extra_rules': [] } if obj_type == 'Track': # get/assign vendor/subaccount ids tracks_ownership = ownership.get_bulk_track_owners(obj_ids) for track_id, data in tracks_ownership.items(): results[track_id]['vendor_id'] = data['vendor_id'] results[track_id]['subaccount_id'] = data['subaccount_id'] # extract unique vendor/subaccount ids track_vendor_ids = list(set([ data['vendor_id'] for _, data in tracks_ownership.items() ])) track_subaccount_ids = list(set([ data['subaccount_id'] for _, data in tracks_ownership.items() if data['subaccount_id'] ])) # get top level rules top_rules = fingerprint_rules.get_bulk_rules('Vendor', track_vendor_ids) extra_rules = fingerprint_rules.get_bulk_rules( 'Subaccount', track_subaccount_ids ) # assign vendor/subaccount rules to each tracks for _, track_data in results.items(): for vendor_id, vendor_rules in top_rules.items(): if track_data['vendor_id'] == vendor_id: track_data['top_rules'] = vendor_rules for subaccount_id, subaccount_rules in extra_rules.items(): if track_data['subaccount_id'] == subaccount_id: track_data['extra_rules'] = subaccount_rules elif obj_type == 'Subaccount': # get/assign vendor/subaccount ids subaccount_ownership = ownership.get_bulk_subaccount_owner(obj_ids) for subaccount_id, data in subaccount_ownership.items(): results[subaccount_id]['vendor_id'] = data['vendor_id'] results[subaccount_id]['subaccount_id'] = subaccount_id # extract unique vendor ids subaccount_vendor_ids = list(set([ data['vendor_id'] for _, data in subaccount_ownership.items() ])) # get top level rules top_rules = fingerprint_rules.get_bulk_rules('Vendor', subaccount_vendor_ids) # assign vendor rules to each subaccount for _, subaccount_data in results.items(): for vendor_id, vendor_rules in top_rules.items(): if subaccount_data['vendor_id'] == vendor_id: subaccount_data['top_rules'] = vendor_rules # format rules and response formatted_results = [] for entity_id, data in results.items(): formatted_results.append({ 'id': entity_id, 'rules': _format_rules( obj_type, data['base_rules'], data['top_rules'], data['extra_rules'] ) }) return formatted_results def upsert(obj_type, obj_id, new_rules, profile, affected_services=None): """Full update rules list logic. Args: obj_type (str): Anchor node type obj_id (int): Anchor node id new_rules (list[dict]): complete set of rules profile (profile_type, profile_id): Request Profile affected_services (list[str], optional): If provided, only rules for these services will be considered for upserts/deletions. Rules for services not in this list will remain untouched. If None, all current rules are candidates for upserts/deletions (full replacement). Returns: tuple: 0 (int): num new rules created 1 (int): num current rules deleted """ if features.is_has_fingerprint_rule_in_snowflake_enabled(): return _upsert_snowflake( obj_type, obj_id, new_rules, profile, affected_services ) # group new rules by their composite key (start, end, territory, policy, service) new_rules_map = { _generate_rule_key(rule): rule for rule in new_rules } # query rules in DB, group them by the same composite key as new_rules_map all_current_rules = fingerprint_rules.get_rules(obj_type, obj_id) all_current_rules_map = { _generate_rule_key(rule): rule for rule in all_current_rules } # if affected_services is specified (even if empty), only consider rules for # those services when determining what to delete; other services are left untouched if affected_services is not None: services_filter = set(affected_services) service_filtered_rules_map = { key: rule for key, rule in all_current_rules_map.items() if rule['service'] in services_filter } else: service_filtered_rules_map = all_current_rules_map # create rules that do not exist already # (only for the filtered set if affected_services is specified) rules_to_create = [ rule for key, rule in new_rules_map.items() if key not in service_filtered_rules_map ] fingerprint_rules.create_rules(obj_type, obj_id, rules_to_create, profile) # soft delete rules not included with this request # (only from the filtered set if affected_services is specified) rules_to_delete = [ rule['element_id'] for key, rule in service_filtered_rules_map.items() if key not in new_rules_map ] fingerprint_rules.delete_rules(obj_type, obj_id, rules_to_delete, profile) return ( len(rules_to_create), len(rules_to_delete) ) def _upsert_snowflake(obj_type, obj_id, new_rules, profile, affected_services): """Full update of an anchor's rules against Snowflake. Mirrors the Neo4j upsert logic but reads and writes Snowflake. """ new_rules_map = { _generate_rule_key(rule): rule for rule in new_rules } with snowflake_util.transaction() as tx: # One timestamp for the whole upsert modified_at = tx.fetchone('SELECT CURRENT_TIMESTAMP()')[0] all_current_rules = fingerprint_rules.get_rules_snowflake( tx, obj_type, obj_id) all_current_rules_map = { _generate_rule_key(rule): rule for rule in all_current_rules } # only consider the given services for deletion when filtering; # other services are left untouched (matches the Neo4j path) if affected_services is not None: services_filter = set(affected_services) service_filtered_rules_map = { key: rule for key, rule in all_current_rules_map.items() if rule['service'] in services_filter } else: service_filtered_rules_map = all_current_rules_map rules_to_create = [ rule for key, rule in new_rules_map.items() if key not in service_filtered_rules_map ] rules_to_delete = [ rule for key, rule in service_filtered_rules_map.items() if key not in new_rules_map ] fingerprint_rules.create_rules_snowflake( tx, obj_type, obj_id, rules_to_create, profile, modified_at) fingerprint_rules.delete_rules_snowflake( tx, obj_type, obj_id, rules_to_delete, profile, modified_at) return ( len(rules_to_create), len(rules_to_delete) ) def fetch_history(obj_type, obj_id, filters=None): """Fetch rules history. Args: obj_type (str): Anchor node type obj_id (int): Anchor node id filters (dict): filters for service/policy Returns: list[dict]: history rules """ return _format_history_events( fingerprint_rules.get_history(obj_type, obj_id, filters) ) def _format_history_events(history): results = [] if len(history['created']) > 0: results += _extract_events(history['created'], False) if len(history['deleted']) > 0: results += _extract_events(history['deleted'], True) results.sort(key=lambda item: item['timestamp']) return results def _extract_events(events, is_deleted): results = [] for event in events: if is_deleted: deleted_event = { 'territory': event['territory'], 'service': event['service'], 'policy': event['policy'], 'start': event['period_start'].isoformat() if event['period_start'] else None, # noqa:E501 'end': event['period_end'].isoformat() if event['period_end'] else None, 'timestamp': event['last_modified_at'].isoformat(), # extract profile info from last_modified_by, # which can be in format of either profile_id_profile_type # or service/endpoint/profile_id/profile_type depending on # whether optimized FF is enabled # noqa:E501 'profile_type': ( event['last_modified_by'].split('/')[-1] if '/' in event['last_modified_by'] else get_profile_type_by_id( event['last_modified_by'].split('_')[-1] ) ), 'profile_id': ( event['last_modified_by'].split('/')[-2] if '/' in event['last_modified_by'] else event['last_modified_by'].split('_')[0] ), 'event_type': 'deleted' } # produce create event from deleted based on created_ props implied_event = dict(deleted_event) implied_event['timestamp'] = event['created_at'].isoformat() # extract profile info from created_by, # which can be in format of either profile_id_profile_type # or service/endpoint/profile_id/profile_type depending on # whether optimized FF is enabled # noqa:E501 implied_event['profile_type'] = ( event['created_by'].split('/')[-1] if '/' in event['created_by'] else get_profile_type_by_id(event['created_by'].split('_')[-1]) ) implied_event['profile_id'] = ( event['created_by'].split('/')[-2] if '/' in event['created_by'] else event['created_by'].split('_')[0] ) implied_event['event_type'] = 'created' results.append(deleted_event) results.append(implied_event) else: created_event = { 'territory': event['territory'], 'service': event['service'], 'policy': event['policy'], 'start': event['period_start'].isoformat() if event['period_start'] else None, # noqa:E501 'end': event['period_end'].isoformat() if event['period_end'] else None, 'timestamp': event['last_modified_at'].isoformat(), 'profile_type': ( event['last_modified_by'].split('/')[-1] if '/' in event['last_modified_by'] else get_profile_type_by_id( event['last_modified_by'].split('_')[-1] ) ), 'profile_id': ( event['last_modified_by'].split('/')[-2] if '/' in event['last_modified_by'] else event['last_modified_by'].split('_')[0] ), 'event_type': 'created' } results.append(created_event) return results def _rule_group_attrs(rule): fields = ['service', 'territory'] return '|'.join([ str(rule.get(field)) for field in fields ]) def _generate_rule_key(rule): fields = [ 'start', 'end', 'territory', 'policy', 'service' ] return '|'.join([ str(_rule_key_value(rule.get(field))) for field in fields ]) def _rule_key_value(value): """Normalize a rule field for identity comparison.""" if isinstance(value, datetime): return value.date() return value def _format_base_rule(rules): results = [] for rule in rules: data = { 'active': rule['active'], 'policy': rule['policy'], 'service': rule['service'], 'territory': rule['territory'], 'start': rule['start'].isoformat() if rule['start'] else None, 'end': rule['end'].isoformat() if rule['end'] else None, 'created_at': rule['created_at'].isoformat(), # extract profile info from created_by and last_modified_by, # which can be in format of either profile_id_profile_type # or service/endpoint/profile_id/profile_type depending on # whether optimized FF is enabled # noqa:E501 'created_by_profile_id': ( rule['created_by'].split('/')[-2] if '/' in rule['created_by'] else rule['created_by'].split('_')[0] ), 'created_by_profile_type': ( rule['created_by'].split('/')[-1] if '/' in rule['created_by'] else get_profile_type_by_id( rule['created_by'].split('_')[-1] ) ), 'last_modified_at': rule['last_modified_at'].isoformat(), 'last_modified_by_profile_id': ( rule['last_modified_by'].split('/')[-2] if '/' in rule['last_modified_by'] else rule['last_modified_by'].split('_')[0] ), 'last_modified_by_profile_type': ( rule['last_modified_by'].split('/')[-1] if '/' in rule['last_modified_by'] else get_profile_type_by_id( rule['last_modified_by'].split('_')[-1] ) ), # TODO: once the Neo4J → Snowflake migration is complete and # HAS_FINGERPRINT_RULE gains IS_START_ABSOLUTE / IS_END_ABSOLUTE # columns, read the actual values from the database here. 'is_start_absolute': False, 'is_end_absolute': False, } results.append(data) return results def _format_rules(obj_type, base_rules, top_rules, extra_rules): if not base_rules: return base_rules # default rules to active for rule in base_rules: rule['active'] = True if obj_type == 'Vendor': return _format_base_rule(base_rules) # get all carveouts by group key carveout_groups = { _rule_group_attrs(rule) for rule in top_rules + extra_rules if rule['policy'] == 'carveout' } # apply active or inactive logic (default is active) for rule in base_rules: key = _rule_group_attrs(rule) # carveout exist at higher level if key in carveout_groups: rule['active'] = False return _format_base_rule(base_rules)