"""Model class for Profile graph nodes.""" from datetime import datetime import textwrap from connector_neo4j import get_session from ddtrace import tracer from neo4j.exceptions import ConstraintError from owsresponse import response from users import constants from users.exceptions.incomplete_result_error import IncompleteResultError from users.utils import api_utils def get_profiles_by_type(orchard_identity, profile_type): """Get profiles associated with this orchard_identity of type profile_type. Args: orchard_identity (str): Identity node id. profile_type (str): Profile.profile_type property Returns: list: of dicts of profile nodes. """ session = get_session() query = """MATCH (u:Identity)-[:HAS_PROFILE]->(p:Profile) WHERE u.id = $orchard_identity AND p.profileType = $profile_type RETURN p as profile""" result = session.run(query, orchard_identity=orchard_identity, profile_type=profile_type) profiles = [] for each in result: profiles.append(api_utils.to_snake(dict(each.get('profile').items()))) return response.Response(profiles) def get_linked_label_profiles(orchard_identity: str) -> list[dict]: """Get LabelProfiles with active vendor access for an Identity. Returns only profiles with a HAS_ACCESS_TO relationship to a Vendor or Subaccount, excluding soft-deleted ones (DELETED_HAS_ACCESS_TO). Args: orchard_identity (str): Identity node id. Returns: list: of dicts of profile nodes. """ session = get_session() query = """MATCH (u:Identity)-[:HAS_PROFILE]->(p:Profile)-[:HAS_ACCESS_TO]->(:Vendor|SubAccount) WHERE u.id = $orchard_identity AND p.profileType = 'LabelProfile' RETURN p as profile""" result = session.run(query, orchard_identity=orchard_identity) profiles = [] for each in result: profiles.append(api_utils.to_snake(dict(each.get('profile').items()))) return profiles def _add_resource_to_settings_profile(tx, orchard_identity_id, resource_type, resource_id): has_admin_access_relationship = 'HAS_ADMIN_ACCESS_TO' query = f"""MATCH (r:{resource_type}) WITH r MATCH (i:Identity)-[:HAS_PROFILE]->(sp:Profile) WHERE i.id = $orchard_identity_id AND sp.profileType = 'SettingsProfile' AND r.id = $resource_id MERGE (sp)-[rel:{has_admin_access_relationship}]->(r) SET rel.createdAt = datetime() WITH sp, r, rel OPTIONAL MATCH (sp)-[delRel:DELETED_{has_admin_access_relationship}]->(r) DELETE delRel RETURN sp, rel """ result = tx.run( query, orchard_identity_id=orchard_identity_id, resource_id=resource_id ).single() if not result or not result.get('rel'): raise IncompleteResultError( f'Failed to give Settings profile access to {resource_type} {resource_id}' ) def create_profile_to_resource_relationship( orchard_identity_id, profile_type, profile_id, resource_type, resource_id, is_enabled_setting_profile, roles=[], ): """Create relationship between 2 existing profile and a resource. This is same as what we have in ows-permissions. But adding one here for auto-create insightsprofile instead of calling ows-permissions. Args: profile_id (int): the id of the Profile to update profile_type (string): the type of Profile (e.g. ArtistProfile, LabelProfile) resource_id (int): the id of the Resource resource_type (string): the type of Resource roles (list): A string list of roles for this Profile Returns: Response: with dict containing relationship details. """ has_access_relationship = constants.PROFILE_TO_RESOURCE_RELATIONSHIP session = get_session() undo_query = f"""MATCH (p:Profile)-[r:DELETED_{has_access_relationship}]-> (a:{resource_type}) WHERE p.profileType = $profile_type AND p.profileId = $profile_id AND a.id = $resource_id CALL apoc.refactor.setType(r, '{has_access_relationship}') YIELD input,output RETURN output""" session.run( undo_query, profile_type=profile_type, profile_id=profile_id, resource_type=resource_type, resource_id=resource_id, ) query = f"""MATCH (p:Profile) MATCH(r:{resource_type}) WHERE p.profileType = $profile_type AND p.profileId = $profile_id AND r.id = $resource_id MERGE (p)-[rel:{has_access_relationship}]->(r) SET rel.roles = $roles, rel.createdAt = datetime() RETURN p, rel""" result = session.run( query, profile_type=profile_type, profile_id=profile_id, resource_type=resource_type, resource_id=resource_id, roles=roles, ) data = result.single() if not data or not data.get('rel'): raise Exception(constants.ERROR_MESSAGE_CREATE_RELATIONSHIP_FAILED) if 'administrator' in roles: _add_resource_to_settings_profile(session, orchard_identity_id, resource_type, resource_id) return response.Response(api_utils.to_snake(dict(data['rel'].items()))) def get_profiles(orchard_identity): """Get all profiles associated with this orchard_identity. Args: orchard_identity (str): Identity node id. Returns: Response: with a dict of node details. """ session = get_session() query = """MATCH (u:Identity)-[:HAS_PROFILE]->(p:Profile) WHERE u.id = $orchard_identity OR u.auth0UserId = $orchard_identity RETURN p as profile""" result = session.run(query, orchard_identity=orchard_identity) profiles = [] for each in result: profiles.append(api_utils.to_snake(dict(each.get('profile').items()))) return response.Response(profiles) def get_profiles_for_applications(orchard_identity, resource_type=None, resource_uuid=None): """Get profiles with application access. Get all Settings profiles, as well as non-Settings Profiles that have Access or Admin Access to at least one Resource, that are associated with this orchard_identity. Args: orchard_identity (str): Identity node id. resource_type (str): optionally filter by resource. Options: Vendor, SubAccount, Collaborator, LabelParticipant resource_uuid (str): optionally filter by resource. Returns: Response: with a dict of node details. """ session = get_session() resource_filter = '' if resource_type is not None and resource_uuid is not None: resource_filter = f' AND (r:{resource_type} AND r.uuid = "{resource_uuid}")' # Non-settings profiles will only be returned if they have Access or AdminAccess to a Resource query = f"""MATCH (u:Identity)-[:HAS_PROFILE]-> (p1:Profile{{profileType:'SettingsProfile'}}) WHERE u.id = $orchard_identity OR u.auth0UserId = $orchard_identity RETURN p1 as profile UNION MATCH (u:Identity)-[:HAS_PROFILE]->(p2:Profile)-[:HAS_ACCESS_TO|HAS_ADMIN_ACCESS_TO]->(r) WHERE (u.id = $orchard_identity OR u.auth0UserId = $orchard_identity){resource_filter} RETURN p2 as profile""" result = session.run(query, orchard_identity=orchard_identity) profiles = [] for each in result: profiles.append(api_utils.to_snake(dict(each.get('profile').items()))) return response.Response(profiles) @tracer.wrap() def get_profiles_for_applications_tx( tx, orchard_identity, resource_type=None, resource_uuid=None ) -> response.Response: """Get profiles with application access, as a managed transaction. Get all Settings profiles, as well as non-Settings Profiles that have Access or Admin Access to at least one Resource, that are associated with this orchard_identity. Args: orchard_identity (str): Identity node id. resource_type (str): optionally filter by resource. Options: Vendor, SubAccount, Collaborator, LabelParticipant resource_uuid (str): optionally filter by resource. Returns: Response: with a dict of node details. """ resource_filter = '' if resource_type is not None and resource_uuid is not None: resource_filter = f' AND (r:{resource_type} AND r.uuid = "{resource_uuid}")' # Non-settings profiles will only be returned if they have Access or AdminAccess to a Resource query = f"""MATCH (u:Identity)-[:HAS_PROFILE]-> (p1:Profile{{profileType:'SettingsProfile'}}) WHERE u.id = $orchard_identity OR u.auth0UserId = $orchard_identity RETURN p1 as profile UNION MATCH (u:Identity)-[:HAS_PROFILE]->(p2:Profile)-[:HAS_ACCESS_TO|HAS_ADMIN_ACCESS_TO]->(r) WHERE (u.id = $orchard_identity OR u.auth0UserId = $orchard_identity){resource_filter} RETURN p2 as profile""" result = tx.run(query, orchard_identity=orchard_identity) profiles = [] for each in result: profiles.append(api_utils.to_snake(dict(each.get('profile').items()))) return response.Response(profiles) def get_by_profile_id_and_type(profile_id, profile_type): """Find a profile by the unique constraint of (profile_id, profile_type). Args: profile_id (int): Profile.profile_id property profile_type (str): Profile.profile_type property Returns: dict of the Profile, or 404 not found. """ session = get_session() query = """MATCH (p:Profile { profileId: $profile_id, profileType: $profile_type }) RETURN p as profile""" result = session.run(query, profile_id=profile_id, profile_type=profile_type) if not result.peek(): return response.create_not_found_response(message=constants.ERROR_MESSAGE_PROFILE_NOT_FOUND) data = result.peek().data() result = dict(data['profile'].items()) return response.Response(api_utils.to_snake(result)) def get_by_uuid(uuid): """Find a profile by the unique constraint of (uuid). Args: uuid (str): Profile.uuid property Returns: dict of the Profile, or 404 not found. """ session = get_session() query = """MATCH (p:Profile { uuid: $uuid }) RETURN p as profile""" result = session.run(query, uuid=uuid) profiles = [] for each in result: profiles.append(api_utils.to_snake(dict(each.get('profile').items()))) if not profiles: return response.create_not_found_response(message=constants.ERROR_MESSAGE_PROFILE_NOT_FOUND) return response.Response(profiles[0]) def create_settings_profile(orchard_identity_id, profile_name, brand): """Create a new Settings Profile and link it to an identity. Args: orchard_identity_id (str): Identity node id. profile_name (str): Setting profile name. brand (str): Settings profile brand. Returns: Response: dict of the newly created Settings Profile. """ session = get_session() query = """MATCH (i:Identity) WHERE i.id = $orchard_identity_id MERGE (incId:IncrementId {nodeName: 'Profile'}) ON CREATE SET incId.id = 1 WITH incId, i CALL apoc.atomic.add(incId, 'id', 1, 3) YIELD newValue as profileId WITH i, profileId CREATE (p:Profile { profileId: profileId, profileType: 'SettingsProfile' }) SET p.profileName = $profile_name, p.brand = $brand, p.uuid = randomUUID(), p.createdAt = datetime(), p.fullCatalogAccess = false, p.lastModifiedBy = 'ows-users' MERGE (i)-[rel:HAS_PROFILE]->(p) SET rel.createdAt = datetime() RETURN p as settingsProfile""" try: result = session.run( query, orchard_identity_id=orchard_identity_id, profile_name=profile_name, brand=brand ) data = result.peek().data() except ConstraintError: return response.create_error_response( code=constants.ERROR_CODE_VALIDATION_ERROR, message=constants.ERROR_MESSAGE_PROFILE_EXISTS, ) return response.Response( status=201, message=api_utils.to_snake(dict(data['settingsProfile'].items())), ) def create_profile(profile): """Create a new Profile. This method assumes validation has already occurred in the logic layer: - identity exists - profile properties are valid Args: profile (dict): object representing the profile to be created - profile_id (int): the id of the Profile relative to `profile_type`. Should be always int or valid for conversion to int. - profile_name (str): the name of the Profile - profile_type (str): the type of profile e.g. (LabelProfile, ArtistProfile, etc) - roles (list): list of strings reprenting roles e.g. ['catalog', 'analytics'] - brand (str): the brand of the Profile Returns: Response: dict of the newly created profile. """ # write the node session = get_session() profile['createdAt'] = datetime.utcnow() is_orchadmin_profile = profile.get('profile_type') == constants.ORCHADMIN_PROFILE profile['fullCatalogAccess'] = is_orchadmin_profile default_uuid = ', p.uuid = randomUUID()' if not profile.get('uuid') else '' # profile id exists if profile.get('profile_id') is not None: # Convert profile_id to int if possible try: profile_id = int(profile.get('profile_id')) except (TypeError, ValueError): return response.create_error_response( code='500', message='Failed to convert profile_id to int.' ) create_query = f"""CREATE (p:Profile) SET p = $profile_data{default_uuid} SET p.profileId = {profile_id} RETURN p as node""" try: create_result = session.run(create_query, profile_data=api_utils.to_camel(profile)) data = create_result.peek().data() except ConstraintError: return response.create_error_response( code=constants.ERROR_CODE_VALIDATION_ERROR, message=constants.ERROR_MESSAGE_PROFILE_EXISTS, ) # profile id does not exist else: profile_data = api_utils.to_camel(profile) del profile_data['profileType'] create_query = f""" MERGE (i:IncrementId {{nodeName: 'Profile'}}) ON CREATE SET i.id = 1 WITH i CALL apoc.atomic.add(i, 'id', 1, 3) YIELD newValue as profileId WITH profileId CREATE (p:Profile {{ profileId: toInteger(profileId), profileType: $profile_type }}) SET p += $profile_data{default_uuid} RETURN p as node """ try: create_result = session.run( create_query, profile_type=profile.get('profile_type'), profile_data=profile_data ) data = create_result.peek().data() except ConstraintError: return response.create_error_response( code=constants.ERROR_CODE_VALIDATION_ERROR, message=constants.ERROR_MESSAGE_PROFILE_EXISTS, ) return response.Response(status=201, message=api_utils.to_snake(dict(data['node'].items()))) def delete_profile(profile_id, profile_type): """Delete a Profile and it's relationships from the graph. Note: this assumes validation has occurred that this profile exists. Args: - profile_id (str): the id of the Profile - profile_type (str): the type of profile e.g. (LabelProfile, ArtistProfile, etc) Returns: Response: success response with no message """ session = get_session() delete_query = """MATCH (p:Profile { profileId: $profile_id, profileType: $profile_type }) DETACH DELETE p""" result = session.run(delete_query, profile_id=profile_id, profile_type=profile_type) # neo4j client will execute but won't return errors unless results read [x for x in result] return response.Response(status=204) def delete_profile_by_uuid(profile_uuid): """Delete a Profile and it's relationships from the graph. Note: this assumes validation has occurred that this profile exists. Args: - profile_uuid (str): Optionally you can send only profile's 'uuid. Returns: Response: success response with no message """ session = get_session() delete_query = """MATCH (p:Profile { uuid: $profile_uuid }) DETACH DELETE p""" result = session.run(delete_query, profile_uuid=profile_uuid) # neo4j client will execute but won't return errors unless results read [x for x in result] return response.Response(status=204) def update_profile(orchard_identity_id, profile_id, profile_type, profile_data): """Update profile graph node. Args: profile_id (int): the id of the Profile to update profile_type (string): the type of Profile (e.g. ArtistProfile, LabelProfile) profile_data (dict): The dictionary of data to update the Profile with profile_name (str): The name of the Profile roles (list): A string list of roles for this Profile Returns: flask.Response containing the updated user object from Auth0 """ session = get_session() try: profile_data = api_utils.to_camel(profile_data) update_query = textwrap.dedent( """ MATCH (p:Profile) WHERE p.profileId = $profile_id AND p.profileType = $profile_type SET p += $profile_data RETURN p as node """ ) result = session.run( update_query, profile_id=profile_id, profile_type=profile_type, profile_data=profile_data, ) return response.Response(api_utils.to_snake(dict(result.peek().data()['node'].items()))) except IncompleteResultError as err: return response.create_error_response('update profile failed', err.message) def update_profile_by_uuid(profile_uuid, profile_data): """Update profile graph node. Args: - profile_uuid (str): Optionally you can send only profile's 'uuid. - profile_data (dict): The dictionary of data to update the Profile with profile_name (str): The name of the Profile roles (list): A string list of roles for this Profile Returns: flask.Response containing the updated user object from Auth0 """ session = get_session() try: profile_data = api_utils.to_camel(profile_data) update_query = textwrap.dedent( """ MATCH (p:Profile) WHERE p.uuid = $profile_uuid SET p += $profile_data RETURN p as node """ ) result = session.run(update_query, profile_uuid=profile_uuid, profile_data=profile_data) return response.Response(api_utils.to_snake(dict(result.peek().data()['node'].items()))) except IncompleteResultError as err: return response.create_error_response('update profile failed', err.message) def link_identity_to_profile(orchard_identity_id, profile_id, profile_type): """Create a new Profile and link it to the Identity. Args: orchard_identity_id (str): Identity node id (Auth0 user id). profile_id (int): The id of the profile. Returns: Response: the relationship. """ session = get_session() undo_query = """MATCH (i:Identity)-[r:DELETED_HAS_PROFILE]-> (p:Profile) WHERE i.id = $orchard_identity_id AND p.profileId = $profile_id AND p.profileType = $profile_type CALL apoc.refactor.setType(r, 'HAS_PROFILE') YIELD input,output RETURN output""" session.run( undo_query, orchard_identity_id=orchard_identity_id, profile_id=profile_id, profile_type=profile_type, ) link_query = """MATCH (i:Identity),(p:Profile) WHERE i.id = $orchard_identity_id AND p.profileId = $profile_id AND p.profileType = $profile_type MERGE (i)-[r:HAS_PROFILE]->(p) SET r.createdAt = datetime() RETURN r""" session.run( link_query, orchard_identity_id=orchard_identity_id, profile_id=profile_id, profile_type=profile_type, ) return response.Response() def link_identity_to_profile_by_uuid(orchard_identity_id, profile_uuid): """Create a new Profile and link it to the Identity. Args: orchard_identity_id (str): Identity node id (Auth0 user id). profile_uuid (str): profile's 'uuid. Returns: Response: the relationship. """ session = get_session() undo_query = """MATCH (i:Identity)-[r:DELETED_HAS_PROFILE]-> (p:Profile) WHERE i.id = $orchard_identity_id AND p.uuid = $profile_uuid CALL apoc.refactor.setType(r, 'HAS_PROFILE') YIELD input,output RETURN output""" session.run(undo_query, orchard_identity_id=orchard_identity_id, profile_uuid=profile_uuid) link_query = """MATCH (i:Identity),(p:Profile) WHERE i.id = $orchard_identity_id AND p.uuid = $profile_uuid MERGE (i)-[r:HAS_PROFILE]->(p) RETURN r""" session.run(link_query, orchard_identity_id=orchard_identity_id, profile_uuid=profile_uuid) return response.Response() def soft_delete_profile_to_identity_relationship(orchard_identity_id, profile_id, profile_type): """Delete existing relationship between identity and profiles. Args: schema (dict): schema object. Returns: Response: empty response with status 204. """ relationship_name = 'HAS_PROFILE' session = get_session() try: soft_delete_query = f"""MATCH (i:Identity)-[rel:{relationship_name}]-> (p:Profile) WHERE i.id = $orchard_identity_id AND p.profileType = $profile_type AND p.profileId = $profile_id SET rel.dateDeleted = localdatetime() WITH rel CALL apoc.refactor.setType(rel, 'DELETED_{relationship_name}') YIELD input, output RETURN input, output""" result = session.run( soft_delete_query, relationship_name=relationship_name, orchard_identity_id=orchard_identity_id, profile_id=profile_id, profile_type=profile_type, ) data = result.single() if not data or not data.get('input') or not data.get('output'): return response.create_fatal_response( constants.ERROR_MESSAGE_DELETE_RELATIONSHIP_FAILED ) return response.Response(status=204) except IncompleteResultError as err: return response.create_error_response( constants.ERROR_MESSAGE_DELETE_RELATIONSHIP_FAILED, err.message ) def soft_delete_profile_to_identity_relationship_by_uuid(orchard_identity_id, profile_uuid): """Delete existing relationship between identity and profiles. Args: identity_id (str): Unique identity identifier. profile_uuid (str): Optionally you can send only profile's 'uuid. Returns: Response: empty response with status 204. """ relationship_name = 'HAS_PROFILE' session = get_session() try: soft_delete_query = f"""MATCH (i:Identity)-[rel:{relationship_name}]-> (p:Profile) WHERE i.id = $orchard_identity_id AND p.uuid = $profile_uuid SET rel.dateDeleted = localdatetime() WITH rel CALL apoc.refactor.setType(rel, 'DELETED_{relationship_name}') YIELD input, output RETURN input, output""" result = session.run( soft_delete_query, orchard_identity_id=orchard_identity_id, profile_uuid=profile_uuid ) data = result.single() if not data or not data.get('input') or not data.get('output'): return response.create_fatal_response( constants.ERROR_MESSAGE_DELETE_RELATIONSHIP_FAILED ) return response.Response(status=204) except IncompleteResultError as err: return response.create_error_response( constants.ERROR_MESSAGE_DELETE_RELATIONSHIP_FAILED, err.message )