"""Vendor model in Neo4j.""" import textwrap from connector_neo4j import get_session from owsresponse import response from account.constants import error def create_or_update_vendor(details): """v1 Create or update a vendor with details.""" session = get_session() # this is a part of handler transaction. result = session.run( """ MERGE (v:Vendor { id: toInteger($vendor_id) }) ON CREATE SET v.createdAt = datetime(), v.createdBy = 'ows-account-create_or_update_vendor' SET v.name = TRIM(coalesce($company, $name)), v.isDistributor = $is_distributor, v.status = $status, v.vendorId = toInteger($vendor_id), v.labelIdentifier = $label_identifier, v.country = $country, v.lastModifiedAt = datetime(), v.lastModifiedBy = 'ows-account-create_or_update_vendor', v.uuid = $vendor_uuid, v.source = $source, v:Label:Orchard RETURN v """, **details, ) if not result.peek(): raise Exception(f"Failed to create or update Vendor {details['vendor_id']}.") return response.Response('Vendor successfully created or updated.') def update_vendor(vendor_id, details): """v2 update vendor with variable details. Args: details (dict): map of properties to be updated. the properties included must match node properties. """ session = get_session() result = session.run( """ MATCH (v:Vendor { id: toInteger($vendor_id) }) SET v += $details SET v.lastModifiedAt = localdatetime(), v.lastModifiedBy = 'ows-account-v2-update' RETURN v """, vendor_id=vendor_id, details=details, ) if not result.peek(): raise Exception(f'Failed to update Vendor {vendor_id} in Neo4j.') return response.Response('Vendor successfully updated.') def add_company_brand(vendor_id, company_brand): """Add Vendor to this Company Brand.""" session = get_session() result = session.run( """ MATCH (v:Vendor:Label:Orchard { id: toInteger($vendor_id) }), (cp:CompanyBrand {name: $company_brand}) MERGE (cp)-[rel:HAS_LABEL]->(v) ON CREATE SET rel.createdAt = datetime() SET rel.lastModifiedAt = datetime() RETURN cp """, vendor_id=vendor_id, company_brand=company_brand, ) if not result.peek(): raise Exception(f'Failed to add company_brand: {company_brand} for vendor: {vendor_id}.') return response.Response('Company Brand successfully added to Vendor.') def get_company_brand(vendor_id): """Get Company Brand for the vendor.""" session = get_session() result = session.run( """ MATCH (c:CompanyBrand)-[:HAS_LABEL]->(v:Vendor:Label:Orchard { id: toInteger($vendor_id) }) RETURN c.name as name """, vendor_id=vendor_id, ) if not result.peek(): raise Exception('This vendor {vendor_id} does not have an associated Company Brand.') return response.Response(result.single()['name']) def get_company_brand_tx(tx, vendor_id) -> response.Response: """Get Company Brand for the vendor as a managed transaction. Args: tx: neo4j transaction from a Session. vendor_id: vendor id to get company brand for. Returns: response.Response: company brand name. """ query = """ MATCH (c:CompanyBrand)-[:HAS_LABEL]->(v:Vendor:Label:Orchard { id: toInteger($vendor_id) }) RETURN c.name as name""" result = tx.run(query, vendor_id=vendor_id) if not result.peek(): return response.create_not_found_response(error.ERROR_MESSAGE_NOT_FOUND) return response.Response(result.single()['name']) def add_service_tier(vendor_id, service_tier_uuid): """Add Vendor to this Service Tier.""" session = get_session() result = session.run( """ MATCH (v:Vendor:Label:Orchard { id: toInteger($vendor_id) }), (st:ServiceTier {uuid: $service_tier_uuid}) MERGE (v)-[rel:IN_SERVICE_TIER]->(st) ON CREATE SET rel.createdAt = datetime() RETURN st """, vendor_id=vendor_id, service_tier_uuid=service_tier_uuid, ) if not result.peek(): raise Exception( f'Failed to connect service_tier {service_tier_uuid} to vendor {vendor_id}.' ) return response.Response('Service Tier successfully added to Vendor.') def get_vendor_service_tier(vendor_id): """Get Service Tier for the Vendor.""" session = get_session() result = session.run( """ MATCH (v:Vendor:Label:Orchard { id: toInteger($vendor_id) }) OPTIONAL MATCH (v)-[:IN_SERVICE_TIER]->(st:ServiceTier) RETURN st """, vendor_id=vendor_id, ) record = result.single() if not record: return response.create_not_found_response(error.ERROR_MESSAGE_NOT_FOUND) service_tier = record.data()['st'] if not service_tier: # ows-product is expecting the response format below rather than just handling any 404 # https://github.com/theorchard/ows-product/blob/master/product/models/ows_account.py#L97-L98 # TODO ticket to fix that pattern and standardize this response return response.create_error_response( 'service_tier_not_found', 'Service Tier Not Found', status=404 ) return response.Response(service_tier) def get_vendor(vendor_id): """Get vendor by vendor_id.""" session = get_session() result = session.run( """ MATCH (v:Vendor:Label:Orchard { id: toInteger($vendor_id) }) RETURN v as vendor """, vendor_id=vendor_id, ) if not result.peek(): return response.create_not_found_response(error.ERROR_MESSAGE_NOT_FOUND) return response.Response(result.single()['vendor']) def profile_has_access_to_vendor(profile_id, profile_type, vendor_id): """Check if requesting Profile has access or admin-access to Vendor. Args: profile_id: id of profile checking for access profile_type: type of profile checking for access vendor_id: id of Vendor being requested access to Returns: True if Profile contains a has_access_to or has_admin_access_to relationship with vendor, False otherwise """ session = get_session() # WITH p, true as ignored -- is to make the planner use profile index. result = session.run( """ MATCH (p:Profile) WHERE p.profileId = toInteger($profile_id) AND p.profileType = $profile_type WITH p, true as ignored MATCH (p)-[:HAS_ACCESS_TO|HAS_ADMIN_ACCESS_TO]->(v:Vendor) WHERE (v.id = toInteger($vendor_id) OR v.id = '*') RETURN CASE WHEN p IS NULL THEN false ELSE true END as p """, profile_id=profile_id, profile_type=profile_type, vendor_id=vendor_id, ) if result.peek() is None or not result.single()['p']: return False return True def profile_has_access_to_vendor_tx(tx, profile_id, profile_type, vendor_id): """Check if requesting Profile has access or admin-access to Vendor in a managed transaction. Args: tx: neo4j transaction from a Session. profile_id: id of profile checking for access profile_type: type of profile checking for access vendor_id: id of Vendor being requested access to Returns: bool: True if Profile contains a has_access_to or has_admin_access_to relationship with vendor, False otherwise """ # WITH p, true as ignored -- is to make the planner use profile index. query = """ MATCH (p:Profile) WHERE p.profileId = toInteger($profile_id) AND p.profileType = $profile_type WITH p, true as ignored MATCH (p)-[:HAS_ACCESS_TO|HAS_ADMIN_ACCESS_TO]->(v:Vendor) WHERE (v.id = toInteger($vendor_id) OR v.id = '*') RETURN CASE WHEN p IS NULL THEN false ELSE true END as p """ result = tx.run(query, profile_id=profile_id, profile_type=profile_type, vendor_id=vendor_id) if result.peek() is None or not result.single()['p']: return False return True def get_neo4j_vendor_info(vendor_id): """Get neo4j exclusive info for an existing vendor. Args: vendor_id: existing vendor_id Returns: Response: country, source, company_brand, and service_tier_uuid for vendor """ session = get_session() result = session.run( """ MATCH (v:Vendor:Label:Orchard { id: toInteger($vendor_id) }) OPTIONAL MATCH (v)-[:IN_SERVICE_TIER]->(st:ServiceTier) OPTIONAL MATCH (pc:ParentCompany)<-[:BELONGS_TO]-(c:CompanyBrand)-[:HAS_LABEL]->(v) RETURN { source: v.source, parent_company: pc.name, service_tier_uuid: st.uuid, service_tier_name: st.name, vendor_uuid: v.uuid } as data """, vendor_id=vendor_id, ) if not result.peek(): return response.create_not_found_response(error.ERROR_MESSAGE_NOT_FOUND) return response.Response(result.single()['data']) def get_neo4j_vendor_info_tx(tx, vendor_id): """Get neo4j exclusive info for an existing vendor in a managed transaction. Args: tx: neo4j transaction from a Session. vendor_id: existing vendor_id Returns: Response: country, source, company_brand, and service_tier_uuid for vendor """ query = """ MATCH (v:Vendor:Label:Orchard { id: toInteger($vendor_id) }) OPTIONAL MATCH (v)-[:IN_SERVICE_TIER]->(st:ServiceTier) OPTIONAL MATCH (pc:ParentCompany)<-[:BELONGS_TO]-(c:CompanyBrand)-[:HAS_LABEL]->(v) RETURN { source: v.source, parent_company: pc.name, service_tier_uuid: st.uuid, service_tier_name: st.name, vendor_uuid: v.uuid } as data """ result = tx.run(query, vendor_id=vendor_id) if not result.peek(): return response.create_not_found_response(error.ERROR_MESSAGE_NOT_FOUND) return response.Response(result.single()['data']) def update_service_tier(vendor_id, service_tier_uuid): """Update service tier for vendor. Args: vendor_id: vendor id to update service_tier_uuid: uuid of service tier to be attached any previous relationship will be soft delted. Returns: Response: success or failed to update message """ session = get_session() result = session.run( """ MATCH (v:Vendor:Label:Orchard { id: toInteger($vendor_id) }) MATCH (st:ServiceTier {uuid: $service_tier_uuid}) MERGE (v)-[rel:IN_SERVICE_TIER]->(st) ON CREATE SET rel.createdAt = localdatetime() ON MATCH SET rel.updatedOn = localdatetime() WITH v, st OPTIONAL MATCH (v)-[drel:IN_SERVICE_TIER]->(dst:ServiceTier) WHERE dst.uuid <> $service_tier_uuid SET drel.updatedOn = localdatetime() WITH v, drel, st CALL apoc.refactor.setType(drel, 'DELETED_IN_SERVICE_TIER') YIELD input, output OPTIONAL MATCH (v)-[erel:DELETED_IN_SERVICE_TIER]->(est:ServiceTier) WHERE est.uuid = $service_tier_uuid DELETE erel RETURN v, st """, vendor_id=vendor_id, service_tier_uuid=service_tier_uuid, ) # raise exception if no updates are found, we expect at least to set updatedOn. if not result.consume().counters.contains_updates: raise Exception(f'Failed to update Service tier for vendor {vendor_id}.') return response.Response('Service tier successfully updated for vendor.') def update_company_brand(vendor_id, company_brand_uuid): """Update company brand for vendor. Args: vendor_id: vendor id to update company_brand_uuid: company brand to be attached to vendor previous relationship will be soft delted. Returns: Response: success or failed to update message """ session = get_session() result = session.run( """ MATCH (v:Vendor:Label:Orchard { id: toInteger($vendor_id) }) MATCH (cb:CompanyBrand {uuid: $company_brand_uuid }) MERGE (cb)-[rel:HAS_LABEL]->(v) ON CREATE SET rel.createdAt = localdatetime(), rel.lastModifiedAt = datetime() ON MATCH SET rel.lastModifiedAt = datetime() WITH v, cb OPTIONAL MATCH (dcb:CompanyBrand)-[drel:HAS_LABEL]->(v) WHERE dcb.uuid <> $company_brand_uuid SET drel.lastModifiedAt = datetime() WITH v, drel, cb CALL apoc.refactor.setType(drel, 'DELETED_HAS_LABEL') YIELD input, output OPTIONAL MATCH (ecb:CompanyBrand)-[erel:DELETED_HAS_LABEL]->(v) WHERE ecb.uuid = $company_brand_uuid DELETE erel RETURN v, cb """, vendor_id=vendor_id, company_brand_uuid=company_brand_uuid, ) # raise exception if no updates are found, we expect at least to set lastModifiedAt. if not result.consume().counters.contains_updates: raise Exception(f'Failed to update Company Brand for vendor {vendor_id}.') return response.Response('Company Brand successfully updated for vendor.') def get_service_tiers(): """Return all service tiers from neo4j.""" session = get_session() result = session.run( """MATCH (s:ServiceTier) RETURN s.uuid as uuid, s.displayName as displayName""" ) if not result.peek(): return response.create_not_found_response(error.ERROR_MESSAGE_NOT_FOUND) return response.Response(result.data()) def accessible_vendors( profile_id: str, profile_type: str, vendor_uuids: list[str], identity_id: str | None ) -> list[str]: """Get accessible vendors for the identity and profile. Args: profile_id (str): profile id profile_type (str): profile type vendor_uuids (list[str]): list of vendor uuids identity_id (str | None): identity uuid Returns: list[str]: list of accessible vendor uuids """ kwargs = { 'vendorUuids': vendor_uuids, 'profileId': profile_id, 'profileType': profile_type, } if identity_id: kwargs['identityId'] = identity_id identity_profile_clause = ( # match profile 'MATCH (p:Profile {profileType: $profileType, profileId: toInteger($profileId)})' + ( # if we have an identity id, load the identity '<-[:HAS_PROFILE]-(i:Identity {id: $identityId})' if identity_id # if not, load the identity that connects the profile else '<-[:HAS_PROFILE]-(i:Identity)' ) ) # Construct the query using f-string and textwrap.dedent query = textwrap.dedent(f""" {identity_profile_clause} // Collect all profiles for the identity, including p WITH i, p, [p] + [ (i)-[:HAS_PROFILE]->(otherProfile:Profile) WHERE otherProfile <> p | otherProfile ] AS allProfiles // Check if any profile has full catalog access WITH i, allProfiles, coalesce( any(profile IN allProfiles WHERE profile.fullCatalogAccess = true AND exists((profile)-[:HAS_ACCESS_TO|HAS_ADMIN_ACCESS_TO]->(:Vendor {{id: '*'}})) ), false ) AS hasFullAccess CALL apoc.when( hasFullAccess, 'RETURN $vendorUuids AS uuids', ' // Check direct access for all profiles UNWIND $allProfiles AS profile OPTIONAL MATCH (profile)-[:HAS_ACCESS_TO|HAS_ADMIN_ACCESS_TO]->(v1:Vendor) WHERE v1.uuid IN $vendorUuids RETURN apoc.coll.toSet(COLLECT(DISTINCT v1.uuid)) AS uuids ', {{allProfiles: allProfiles, vendorUuids: $vendorUuids}} ) YIELD value RETURN value.uuids AS uuids """) session = get_session() result = session.run(query, **kwargs) record = result.single() if record is None or not record.get('uuids'): return [] return record['uuids']