"""Input validation schemas.""" from marshmallow import Schema from marshmallow import ValidationError from marshmallow import post_load from marshmallow import pre_load from marshmallow import validate from marshmallow import validates_schema from sound_recordings.validation import constants from webargs import fields class FingerprintRuleURLSchema(Schema): """Model for fingerprint rule URLs.""" obj_type = fields.Str(validate=validate.OneOf(constants.RULE_OBJECT_SCOPES)) obj_id = fields.Int() @post_load def formatting(self, data, many, **kwargs): """Format url params for logic layer to handle.""" return { 'obj_type': data['obj_type'][:-1].title(), 'obj_id': int(data['obj_id']) } class FingerprintRuleSchema(Schema): """Model for a fingerprint rule.""" start = fields.AwareDateTime(required=True, allow_none=True) end = fields.AwareDateTime(required=True, allow_none=True) territory = fields.Str( required=True, validate=validate.OneOf(constants.RULE_TERRITORIES) ) service = fields.Str( required=True, validate=validate.OneOf(constants.RULE_SERVICES) ) policy = fields.Str( required=True, validate=validate.OneOf(constants.RULE_POLICIES) ) # TODO: once the Neo4J → Snowflake migration is complete and # HAS_FINGERPRINT_RULE / DELETED_HAS_FINGERPRINT_RULE tables gain # IS_START_ABSOLUTE and IS_END_ABSOLUTE columns, these values should # be persisted to (and read from) the database instead of being mocked. is_start_absolute = fields.Bool(load_default=False) is_end_absolute = fields.Bool(load_default=False) @validates_schema def validate_rule(self, data, **kwargs): """Validate individual rule object. Args: data (dict): single object from request body list Raises: ValidationError """ messages = dict() # group errors by field with problem def add_message(field, message): if field not in messages: messages[field] = [] messages[field].append(message) start = data['start'] end = data['end'] if data.get('is_start_absolute') and not start: add_message('is_start_absolute', 'can only be true if start is not null') if data.get('is_end_absolute') and not end: add_message('is_end_absolute', 'can only be true if end is not null') # check timezone if start and start.utcoffset(): add_message('start', 'must be in UTC timezone') if end and end.utcoffset(): add_message('end', 'must be in UTC timezone') # sixtyseconds is only valid for youtube if data['policy'] == 'sixtyseconds' and data['service'] != 'youtube': add_message('policy', 'sixtyseconds policy is only valid for the youtube service') # carveouts cannot have start or end if data['policy'] == 'carveout': if start: add_message('start', 'cannot be set when policy == carveout') if end: add_message('end', 'cannot be set when policy == carveout') else: # enforce start when not carveout if start is None: add_message('start', 'must be set when policy != carveout') # start and end must not overlap or equal if start and end: if start > end: add_message('start', 'cannot be after end') add_message('end', 'cannot be before start') if start == end: add_message('start', 'cannot equal end') add_message('end', 'cannot equal start') if messages: raise ValidationError(messages) @post_load(pass_collection=True) def rule_list_validation(self, data, many, **kwargs): """Validate group of rule objects. Args: data (list): rule objects many (bool): True Returns: list: unmodified input data """ # group rules to validate per territory-service pair groups = self._group_rules(data) # validate rules in the context of their group scope for _, rules in groups.items(): self._validate_policies(rules) self._validate_dates(rules) return data def _group_rules(self, rules): """Group rules into territory-service pair that can conflict. Args: rules (list): rule objects Returns: dict: rules keyed by territory-service plus "*" """ expanded_rules = list() for idx, rule in enumerate(rules): service = rule['service'] territory = rule['territory'] # copy rule for every service and territory pair if service == constants.ALL and territory == constants.ALL: for y in constants.RULE_SERVICES: expanded_rules += [ {**rule, 'service': y, 'territory': x, 'index': idx} for x in constants.RULE_TERRITORIES if x != constants.ALL and y != constants.ALL ] # copy rule for every service elif service == constants.ALL: expanded_rules += [ {**rule, 'service': x, 'index': idx} for x in constants.RULE_SERVICES if x != constants.ALL ] # copy rule for every territory elif territory == constants.ALL: expanded_rules += [ {**rule, 'territory': x, 'index': idx} for x in constants.RULE_TERRITORIES if x != constants.ALL ] # add "normal" rule with no need to copy else: expanded_rules.append({**rule, 'index': idx}) # group all rules by service + territory pair groups = dict() for rule in expanded_rules: key = rule['service'] + ':' + rule['territory'] if key not in groups: groups[key] = [] groups[key].append({**rule, 'group': key}) return groups def _validate_policies(self, rules): """Validate policy combinations. Args: rules (list): data to examine Raises: ValidationError """ # no validation needed for 0 or 1 rules if len(rules) < 2: return # identify all rules with carveout policy carveouts = [ x for x in rules if x['policy'] == 'carveout' ] messages = dict() def add_message(index, field, message): if index not in messages: messages[index] = {} if field not in messages[index]: messages[index][field] = [] messages[index][field].append(message) # determine error for rule in carveouts: if len(carveouts) == len(rules): error = f"cannot set multiple carveout rules in {rule['group']}" else: error = f"cannot mix carveout rules with other rules in {rule['group']}" add_message(rule['index'], 'policy', error) if messages: raise ValidationError(messages) def _validate_dates(self, rules): """Validate policy datestimes. Args: rules (list): data to examine Raises: ValidationError """ messages = dict() # sort rules by start time rules = sorted( rules, key=lambda y: y['start'] ) def add_message(index, field, message): if index not in messages: messages[index] = {} if field not in messages[index]: messages[index][field] = [] messages[index][field].append(message) for idx, rule in enumerate(rules[:-1]): # only allow last rule to have no end if rule['end'] is None: add_message( rule['index'], 'end', f"cannot be empty for non-last rule in {rule['group']}" ) # end of rule cannot overlap with start of next rule else: next_rule = rules[idx + 1] if rule['end'] > next_rule['start']: add_message( rule['index'], 'end', f"after rule {next_rule['index']} start in {rule['group']}" ) add_message( next_rule['index'], 'start', f"before rule {rule['index']} end in {rule['group']}" ) if messages: raise ValidationError(messages) class FingerprintBulkRuleURLSchema(Schema): """Model for fingerprint bulks rules URLs.""" obj_type = fields.Str(validate=validate.OneOf(constants.RULE_OBJECT_SCOPES)) @post_load def formatting(self, data, many, **kwargs): """Format url params for logic layer to handle.""" return { 'obj_type': data['obj_type'][:-1].title() } class FingerprintBulkRuleQuerySchema(Schema): """Query parameters for bulk fingerprint rules endpoint. When `services` is provided as a comma-separated list of service identifiers, only rules for those services will be affected by the bulk operation. Rules for services not in this list will remain untouched. If `services` is omitted, all services are affected (full replacement). The same full-replacement behavior can be requested explicitly by passing `services=*` or `services=*,`. In those cases, the value is treated as a request to replace rules for all services, regardless of any additional values after the `*`. """ services = fields.DelimitedList( fields.Str(validate=validate.OneOf(constants.RULE_SERVICES)), delimiter=',', load_default=None ) class FingerprintBulkRuleSchema(Schema): """Model for a fingerprint bulk rules input.""" root = fields.Dict(required=True) @pre_load def validate_root_dict(self, data, **kwargs): """Format payload data. It should have the root key to properly handle schema validation. """ if not isinstance(data, dict): raise ValidationError('Input must be a dictionary at the root level.') deserialized = {} for key, values in data.items(): if not key.isdigit(): raise ValidationError({key: 'Keys must be numeric strings.'}) deserialized[key] = FingerprintRuleSchema(many=True).load(values) return {'root': deserialized} @post_load def formatting(self, data, many, **kwargs): """Return formated data at the @pre_load to the initial format.""" return data['root']