"""Validation helpers for allowed relationships in Neo4J.""" from typing import Any, Type from marshmallow import Schema, ValidationError, fields from marshmallow.validate import Equal, OneOf, Range from owsresponse import response from notifications.constants import error DEFAULT_NODE_ID = 'id' RELATIONSHIP_FROM_PROFILE = 'profile' RELATIONSHIP_FROM_IDENTITY = 'identity' class RelationshipSchema(Schema): """Base abstract relationship schema. Properties: - profile_type (str): profile type name (e.g. InsightsProfile) - profile_id (int): profile id - entity_id (str): entity id to connect to profile - relationship (str): relationship name (e.g. HAS_FOLLOWED) - relationship_from (str): Node where the relationship starts. It can be Identity or profile Constants: - entity_node_type (str): node type in graph to connect profile node to - entity_node_id_name (str): node attribute identifier in graph """ profile_type = fields.Str(required=True, validate=OneOf([])) profile_id = fields.Int(required=True) entity_id = fields.Str(required=False, allow_none=True) relationship = fields.Str(required=True, validate=Equal('HAS_FOLLOWED')) entity_node_type = fields.Constant(None, load_only=True) entity_node_id_name = fields.Constant(DEFAULT_NODE_ID, load_only=True) class ParticipantFollow(RelationshipSchema): """Participant to Profile relationship schema. Override Properties: - profile_type (str): profile type name (e.g. InsightsProfile) Override Constants: - entity_node_type (str): node type in graph to connect profile node to """ profile_type = fields.Str( required=True, validate=OneOf(['InsightsProfile', 'ArtistProfile', 'LabelProfile']) ) entity_node_type = fields.Constant('GlobalParticipant', load_only=True) class ProductFollow(RelationshipSchema): """Product to Profile relationship schema. Override Properties: - profile_type (str): profile type name (e.g. InsightsProfile) Override Constants: - entity_node_type (str): node type in graph to connect profile node to - entity_node_id_name (str): node attribute identifier in graph """ profile_type = fields.Str( required=True, validate=OneOf(['InsightsProfile', 'ArtistProfile', 'LabelProfile']) ) entity_node_type = fields.Constant('Product:Orchard', load_only=True) entity_node_id_name = fields.Constant('upc', load_only=True) class SoundRecordingFollow(RelationshipSchema): """SoundRecording to Profile relationship schema. Override Properties: - profile_type (str): profile type name (e.g. InsightsProfile) Override Constants: - entity_node_type (str): node type in graph to connect profile node to - entity_node_id_name (str): node attribute identifier in graph """ profile_type = fields.Str( required=True, validate=OneOf(['InsightsProfile', 'ArtistProfile', 'LabelProfile']) ) entity_node_type = fields.Constant('GlobalSoundRecording', load_only=True) entity_node_id_name = fields.Constant('isrc', load_only=True) class VendorFollow(RelationshipSchema): """Vendor to Profile relationship schema. Override Properties: - profile_type (str): profile type name (e.g. InsightsProfile) - entity_id (str): entity id to connect to profile, cast as int Override Constants: - entity_node_type (str): node type in graph to connect profile node to """ profile_type = fields.Str(required=True, validate=OneOf(['LabelProfile', 'InsightsProfile'])) entity_id = fields.Int(required=False, allow_none=True, strict=False) entity_node_type = fields.Constant('Vendor', load_only=True) class SubAccountFollow(RelationshipSchema): """Subaccount to Profile relationship schema. Override Properties: - profile_type (str): profile type name (e.g. InsightsProfile) - entity_id (str): entity id to connect to profile, cast as int Override Constants: - entity_node_type (str): node type in graph to connect profile node to """ profile_type = fields.Str(required=True, validate=OneOf(['LabelProfile', 'InsightsProfile'])) entity_id = fields.Int(required=False, allow_none=True, strict=False) entity_node_type = fields.Constant('Subaccount', load_only=True) class ChannelFollow(RelationshipSchema): """Channel to Profile relationship schema. Override Properties: - profile_type (str): profile type name (e.g. InsightsProfile) Override Constants: - entity_node_type (str): node type in graph to connect profile node to """ profile_type = fields.Str( required=True, validate=OneOf(['InsightsProfile', 'ArtistProfile', 'LabelProfile']) ) entity_node_type = fields.Constant('Channel', load_only=True) entity_node_id_name = fields.Constant('channelId', load_only=True) class CollaboratorFollow(RelationshipSchema): """Collaborator to Profile relationship schema. Override Properties: - profile_type (str): profile type name (must be MoneyhubProfile) - relationship (str): relationship name Override Constants: - entity_node_type (str): node type in graph to connect profile node to """ profile_type = fields.Str(required=True, validate=OneOf(['MoneyhubProfile'])) entity_node_type = fields.Constant('Collaborator', load_only=True) entity_id = fields.Int(required=True) relationship = fields.Str( required=True, validate=Equal('HAS_FOLLOWED_COLLABORATORS_STATEMENT_PERIOD_CLOSED') ) subscription_name = fields.Constant('collaborators_statement_period_closed', load_only=True) def create_custom_follow_schema_for_ws(subscription_name: str, entity_type: str) -> Type[Schema]: """HAS_FOLLOWED_ between profile and Vendor/Subaccount. Return (Schema) : With following properties - profile_type (str): profile type name (e.g. LabelProfile) - profile_id (int): profile id - entity_id (str): entity id to connect to profile - relationship (str): relationship name (e.g. HAS_FOLLOWED_EMAIL_NOTIFICATION_NEW_RELEASE) - entity_node_type (str): node type in graph to connect profile node to - entity_node_id_name (str): node attribute identifier in graph - relationship_from (str): Node where the relationship starts. It can be Identity or profile """ relationship_name = f'HAS_FOLLOWED_{subscription_name.upper()}' schema_structure: dict[str, fields.Field] = { 'profile_type': fields.Str(required=True, validate=OneOf(['LabelProfile'])), 'profile_id': fields.Int(required=True), 'relationship': fields.Str(required=True, validate=Equal(relationship_name)), 'relationship_from': fields.Constant(RELATIONSHIP_FROM_PROFILE, load_only=True), 'entity_id': fields.Str(required=True, allow_none=True), 'entity_node_type': fields.Constant(entity_type, required=True), 'entity_node_id_name': fields.Constant(DEFAULT_NODE_ID, load_only=True), } return Schema.from_dict(schema_structure) def create_has_auto_follow_schema_for_ws(entity_type: str, subscription_name: str) -> Type[Schema]: """HAS_AUTO_FOLLOWED relationship between Identity and Subscription. Return (Schema) : With following properties - profile_type (str): profile type name (e.g. LabelProfile) - profile_id (int): profile id - entity_id (str): entity id to connect to profile - relationship (str): relationship name (e.g. HAS_FOLLOWED) - relationship_from (str): Node where the relationship starts. It can be Identity or profile - subscription_name (str): Name of the subscription node. - entity_node_type (str): node type in graph to connect profile node to - entity_node_id_name (str): node attribute identifier in graph """ relationship = 'HAS_AUTO_FOLLOWED' schema_structure: dict[str, fields.Field] = { 'profile_type': fields.Str(required=True, validate=OneOf(['LabelProfile'])), 'profile_id': fields.Int(required=True), 'relationship': fields.Str(required=True, validate=Equal(relationship)), 'relationship_from': fields.Constant(RELATIONSHIP_FROM_IDENTITY, load_only=True), 'subscription_name': fields.Constant(subscription_name, load_only=True), 'entity_id': fields.Str(required=True, allow_none=True), 'entity_node_type': fields.Constant(entity_type, required=True), 'entity_node_id_name': fields.Constant(DEFAULT_NODE_ID, load_only=True), } return Schema.from_dict(schema_structure) class FollowsList(Schema): """Query params when listing follows for a profile. Properties: - ids (str): Comma seperated list of IDs to filter inclusively - offset (int): Fetch data starting at index, default 0 - limit (int): Maxium results from offset, default None for all - state (str): Deleted status of follows to fetch - order_by (str): Sort by attribute, default last_modified - order_dir (str): Sort direction, asc or desc, default desc """ ids = fields.Str(required=False, load_default=None) sub_type = fields.Str(required=False, load_default=None) offset = fields.Int( required=False, load_default=0, validate=[Range(min=0, error='Value must be 0 or greater')] ) limit = fields.Int( required=False, load_default=None, validate=[Range(min=1, error='Value must be 1 or greater')], ) state = fields.Str( required=False, validate=OneOf(['deleted', 'undeleted', 'all']), load_default='undeleted' ) order_by = fields.Str( required=False, validate=OneOf(['last_modified', 'created_at']), load_default='last_modified', ) order_dir = fields.Str(required=False, validate=OneOf(['desc', 'asc']), load_default='desc') SCHEMA_MAP = { 'participant': ParticipantFollow, 'product': ProductFollow, 'sound_recording': SoundRecordingFollow, 'vendor': VendorFollow, 'sub_account': SubAccountFollow, 'channel': ChannelFollow, 'collaborator': CollaboratorFollow, } def get_entity_id_name(entity_type: str) -> str: """Get name of attribute that is unique ID. Args: entity_type (str): External entity name Returns: str: attribute name serving as identifier """ schema = SCHEMA_MAP[entity_type]() return schema.declared_fields['entity_node_id_name'].load_default def cast_entity_id(entity_type: str, id_value: str) -> Any: # noqa: ANN401 """Cast value according to entity_id for entity type. Args: entity_type (str): External entity name id_value (str): Value to cast Returns: any: id_value cast according to schema entity_id field type Raises: ValueError: Unable to cast id_value """ schema = SCHEMA_MAP[entity_type]() return schema.declared_fields['entity_id'].serialize('id', {'id': id_value}) def external_to_internal_entity(entity_type: str) -> str: """Translate external name to internal name of entity. Args: entity_type (str): External entity name Returns: str: Internal entity name according to schema mappings """ schema = SCHEMA_MAP[entity_type]() return schema.declared_fields['entity_node_type'].load_default def internal_to_external_entity(entity_type: str) -> str: """Translate internal name to internal name of entity. Args: entity_type (str): Internal entity name Returns: str: External entity name according to schema mappings """ for external_name, schema in SCHEMA_MAP.items(): if schema().declared_fields['entity_node_type'].load_default == entity_type: return external_name raise KeyError(f'{entity_type} does not match any relationship schema') def validate_relationship( relationship: str, profile_type: str, profile_id: int, entity_type: str, entity_id: str | None = None, ) -> response.Response: """Validate the provided combination of profile and entity. We only allow specific types of profile and participants to be connected in Neo4j and GetStream. Args: relationship (str): relationship name (e.g. HAS_FOLLOWED) profile_type (str): profile type name (e.g. InsightsProfile) profile_id (int): profile id entity_type (str): entity name (e.g. participant) entity_id (str): entity id to be type cast if needed Returns: response.Response: response with sanitized relationship data """ try: if entity_type not in SCHEMA_MAP: return response.create_error_response( code=error.ERROR_MESSAGE_INVALID_RELATIONSHIP, message=f'{entity_type} is invalid entity type', ) schema = SCHEMA_MAP[entity_type]() return response.Response( schema.load( { 'profile_type': profile_type, 'profile_id': profile_id, 'relationship': relationship, 'entity_id': entity_id, } ) ) except ValidationError as err: return response.create_error_response( code=error.ERROR_CODE_VALIDATION_ERROR, message=err.messages )