"""Split related logic.""" from typing import List, cast from ddtrace.trace import tracer from collaborator.constants import error from collaborator.constants.split_type import SplitTypeId from collaborator.models.ows import ows_account, ows_product, ows_track from collaborator.models.rds.collaborator_persister import CollaboratorPersister from collaborator.models.rds.split_persister import SplitPersister from collaborator.models.snowflake.split_persister import ( SplitPersister as SnowflakeSplitPersister, ) from collaborator.schemas.split import ( ReplacementSplitsSchema, ReplaceSplitsBody, ReplaceSplitsRequestSchema, ) from collaborator.utils.error import OwsError from collaborator.utils.helpers import check_vendors_authorization, get_from_response from collaborator.utils.split import filter_replacement_splits_by_type from collaborator.utils.spool import uwsgi_spool_task from collaborator.utils.typing import AuthorizedResources, User def check_can_access_splits_data( authorized_resources: AuthorizedResources, tuids: List ): """Check a profile has read access to the split data for tracks.""" tracks = ows_track.get_tracks_batched(tuids) upcs = list({track["upc"] for track in tracks}) products_response = ows_product.get_products_by_upc(upcs) products_items = get_from_response(products_response, "items") vendor_ids = [product["vendor_id"] for product in products_items] check_vendors_authorization(authorized_resources, vendor_ids) def check_can_modify_split_data( authorized_resources: AuthorizedResources, body: ReplaceSplitsBody, ) -> int: """Check the caller can modify the splits in ``body``. Every track and collaborator referenced by the body must belong to the same vendor — a single request cannot span multiple vendors. The caller must be authorised for that vendor. Returns the derived vendor ID. """ # Get track vendor IDs tuids = [track.tuid for track in body.tracks] track_response = ows_track.get_tracks_batched(tuids) upcs = list({t["upc"] for t in track_response}) products_response = ows_product.get_products_by_upc(upcs) products_items = get_from_response(products_response, "items") track_vendor_ids = {p["vendor_id"] for p in products_items} # Get collaborator vendor IDs collaborator_ids = list( {split.collaborator_id for track in body.tracks for split in track.splits} ) collaborator_vendor_ids = set() if collaborator_ids: collaborators = CollaboratorPersister.get_by_ids( collaborator_ids, throw_if_not_found=True ) collaborator_vendor_ids = {c["vendor_id"] for c in collaborators} # Check between the tracks and collaborators, there is only one unique vendor ID vendor_ids = track_vendor_ids | collaborator_vendor_ids if len(vendor_ids) != 1: raise OwsError( code=error.ERROR_CODE_SPLIT_VENDOR_MISMATCH, message=error.ERROR_MESSAGE_SPLIT_VENDOR_MISMATCH, ) # Check the caller is authorised for the vendor (vendor_id,) = vendor_ids check_vendors_authorization(authorized_resources, [vendor_id]) return vendor_id def get_for_identifiers(split_type: SplitTypeId, identifiers: List[str]) -> dict: """Get splits based on split type and identifiers. Args: split_type (SplitTypeId): type of split. identifiers (List[int]): identifiers of the resources. Returns: dict: splits grouped by identifier """ splits = SplitPersister.get_for_identifiers(split_type, identifiers) return { identifier: [split for split in splits if split["identifier"] == identifier] for identifier in identifiers } def replace_track_splits( authorized_resources: AuthorizedResources, body: ReplaceSplitsBody, user: User, ) -> list: """Replace a list of splits. Authorises the caller against the data in ``body`` (single-vendor invariant) before mutating anything. Args: authorized_resources (AuthorizedResources): the caller's authorised resources, used to verify they can modify the referenced splits. body (ReplaceSplitsBody): the validated request body. user (User): The user who is updating the splits. Returns: list: list of dictionaries with updated splits """ vendor_id = check_can_modify_split_data(authorized_resources, body) has_direct_payments = ows_account.has_direct_payments(str(vendor_id)) updated_splits, deleted_split_ids = SplitPersister.replace_track_splits( body.model_dump(), user, has_direct_payments ) with tracer.trace("logic.split.replace_track_splits"): uwsgi_spool_task( SnowflakeSplitPersister.replace_splits, updated_splits, deleted_split_ids, ) return updated_splits def replace_splits( authorized_resources: AuthorizedResources, body: ReplaceSplitsRequestSchema, user: User, ) -> list: """Replace a list of splits. Authorises the caller against the data in ``body`` (single-vendor invariant) before mutating anything. Args: authorized_resources (AuthorizedResources): the caller's authorised resources, used to verify they can modify the referenced splits. body (ReplaceSplitsRequestSchema): the validated request body. user (User): The user who is updating the splits. Returns: list: list of dictionaries with updated splits """ _check_replace_can_modify_split_data( authorized_resources, body.replacements, body.vendor_id, ) final_splits, deleted_split_ids = SplitPersister.replace_splits( replacements=body.replacements, dp_splits_agreed=body.dp_splits_agreed or False, vendor_id=body.vendor_id, has_direct_payments=ows_account.has_direct_payments(str(body.vendor_id)), user=user, ) final_split_data = [split.to_dict() for split in final_splits] with tracer.trace("logic.split.replace_splits"): uwsgi_spool_task( SnowflakeSplitPersister.replace_splits, final_split_data, deleted_split_ids, ) return final_split_data def _check_vendor_mistmatch(vendor_ids: set[int]): """Check there is only one unique vendor ID.""" if len(vendor_ids) != 1: raise OwsError( code=error.ERROR_CODE_SPLIT_VENDOR_MISMATCH, message=error.ERROR_MESSAGE_SPLIT_VENDOR_MISMATCH, ) def _check_replace_can_modify_split_data( authorized_resources: AuthorizedResources, replacements: list[ReplacementSplitsSchema], vendor_id: int, ) -> None: """Check the caller of the replace op can modify the splits in ``body``. Every track and collaborator referenced by the body must belong to the same vendor — a single request cannot span multiple vendors. The caller must be authorised for that vendor. Returns the derived vendor ID. """ check_vendors_authorization(authorized_resources, [vendor_id]) running_vendor_ids = set([vendor_id]) running_vendor_ids = _verify_track_splits_belong_to_vendor( replacements, running_vendor_ids ) _verify_subaccount_splits_belong_to_vendor(replacements, running_vendor_ids) def _verify_track_splits_belong_to_vendor( replacements: list[ReplacementSplitsSchema], running_vendor_ids: set[int], ) -> set: """Check the caller of the replace op can modify the splits in ``body``. Every track and collaborator referenced by the body must belong to the same vendor — a single request cannot span multiple vendors. The caller must be authorised for that vendor. Returns the derived vendor ID. """ replacements = filter_replacement_splits_by_type(replacements, SplitTypeId.TRACK) if not replacements: return running_vendor_ids # Get track vendor IDs tuids = [split.identifier for split in replacements] track_response = ows_track.get_tracks_batched(tuids) upcs = list({t["upc"] for t in track_response}) products_response = ows_product.get_products_by_upc(upcs) products_items = get_from_response(products_response, "items") vendor_ids = running_vendor_ids | {p["vendor_id"] for p in products_items} _check_vendor_mistmatch(vendor_ids) # Get collaborator vendor IDs collaborator_ids = { split.collaborator_id for replacement_split in replacements for split in replacement_split.splits } if collaborator_ids: collaborators = CollaboratorPersister.get_by_ids( list(collaborator_ids), throw_if_not_found=True ) vendor_ids = vendor_ids | {c["vendor_id"] for c in collaborators} _check_vendor_mistmatch(vendor_ids) return vendor_ids def _verify_subaccount_splits_belong_to_vendor( replacements: list[ReplacementSplitsSchema], running_vendor_ids: set[int], ) -> set: """Check the caller of the replace op can modify the splits in ``body``. Every subaccount referenced by the body must belong to the same vendor — a single request cannot span multiple vendors. The caller must be authorised for that vendor. Returns the derived vendor ID. """ replacements = filter_replacement_splits_by_type( replacements, SplitTypeId.SUBACCOUNT ) if not replacements: return running_vendor_ids # TODO: Implement a bulk subaccounts endpoint on ows-account. subaccounts = [ ows_account.get_subaccount(split.identifier) for split in replacements ] vendor_ids: set[int] = running_vendor_ids | { cast(int, subaccount.get("vendor_id")) for subaccount in subaccounts if subaccount.get("vendor_id") is not None } _check_vendor_mistmatch(vendor_ids) return vendor_ids