from datetime import datetime, timedelta from typing import Any from delivery_metadata.constants import DownloadStreamRights from delivery_metadata.models import Model from delivery_metadata.models.schemas import ( CustomPricing, DeliveryMetadata, ReleasePricing, Track, ) from delivery_metadata.utils.datetime import ( get_datetime_from_string, get_datetime_from_string_or_none, ) DEFAULT_START_DATE = datetime.min DEFAULT_END_DATE = datetime.max class TerritoryPricing(Model): territories: set[str] start_date: datetime end_date: datetime | None price_code: str | None custom_price: CustomPricing | None def __eq__(self, other: Any) -> bool: if not isinstance(other, TerritoryPricing): return False return ( self.start_date == other.start_date and self.end_date == other.end_date and self.price_code == other.price_code and self.custom_price == other.custom_price ) def __hash__(self) -> int: return hash( ( tuple(sorted(self.territories)), self.start_date, self.end_date, self.price_code, self.custom_price, ) ) class InstantGratRights(Model): tuids: set[int] start_date: datetime end_date: datetime def __hash__(self) -> int: return hash( ( tuple(sorted(self.tuids)), self.start_date, self.end_date, ) ) def __eq__(self, other: Any) -> bool: if not isinstance(other, InstantGratRights): return False return ( self.tuids == other.tuids and self.start_date == other.start_date and self.end_date == other.end_date ) class TerritoryRights(Model): territories: list[str] start_date: datetime end_date: datetime | None price_code: str | None custom_price: CustomPricing | None distribution_rights: set[DownloadStreamRights] is_preorder: bool = False instant_gratification_tracks: set[int] | None = None class DeliveryRights(Model): product_rights: list[TerritoryRights] track_rights: dict[int, list[TerritoryRights]] # key is track_id def get_delivery_rights(delivery_metadata: DeliveryMetadata) -> DeliveryRights: """Get delivery rights.""" return DeliveryRights( product_rights=get_product_delivery_rights(delivery_metadata), track_rights=get_track_delivery_rights(delivery_metadata), ) def get_product_delivery_rights( delivery_metadata: DeliveryMetadata, ) -> list[TerritoryRights]: """Get product rights.""" normalized_product_delivery_rights = [] # Create normalized (end date + 1) preorder / instant grat nodes # and append to timeline, if supported if delivery_metadata.delivery.store.is_preorder_supported: normalized_product_delivery_rights.extend( _generate_release_preorder_term_rights(delivery_metadata) ) # Create normalized (end_date == datetime.max) regular release nodes and append to timeline normalized_product_delivery_rights.extend( _generate_sale_start_term_rights(delivery_metadata) ) # Split by pricing if any normalized_product_delivery_rights = _split_rights_terms_by_price( normalized_product_delivery_rights, delivery_metadata.product.release_territory_pricing, ) # Denormalize end_date, set end_date or None (if datetime.max) # and sort final result by start_date, territories[0], and finally end_date denormalized_product_delivery_rights = _denormalize_territory_rights_and_sort( normalized_product_delivery_rights ) return denormalized_product_delivery_rights def get_track_delivery_rights( delivery_metadata: DeliveryMetadata, ) -> dict[int, list[TerritoryRights]]: """Get track delivery rights.""" track_delivery_rights = {} for track in delivery_metadata.product.tracks: normalized_track_sale_start_term_rights = _generate_sale_start_term_rights( delivery_metadata, track ) # Split timeline by pricing data, if any normalized_track_sale_start_term_rights = _split_rights_terms_by_price( normalized_track_sale_start_term_rights, track.track_territory_pricing ) # Denormalize end_date, set end_date or None (if datetime.max) # and sort final result by start_date, territories[0], and finally end_date denormalized_track_territory_rights = _denormalize_territory_rights_and_sort( normalized_track_sale_start_term_rights ) track_delivery_rights[track.track_id] = denormalized_track_territory_rights return track_delivery_rights def _denormalize_territory_rights_and_sort( normalized_territory_rights_list: list[TerritoryRights], ) -> list[TerritoryRights]: # First sort rights by start_date and end_date normalized_territory_rights_list.sort( key=lambda final_right: ( final_right.start_date, final_right.territories[0], # Sort by first territory code for consistency final_right.end_date or DEFAULT_END_DATE, ) ) # Denormalize end_date, set end_date or None (if datetime.max) denormalized_territory_rights_lists: list[TerritoryRights] = [] for right in normalized_territory_rights_list: denormalized_territory_rights_lists.append( TerritoryRights( territories=right.territories, start_date=right.start_date, end_date=_denormalize_end_date_inclusive(right.end_date), price_code=right.price_code, custom_price=right.custom_price, distribution_rights=right.distribution_rights, is_preorder=right.is_preorder, instant_gratification_tracks=right.instant_gratification_tracks, ) ) return denormalized_territory_rights_lists def _generate_release_preorder_term_rights( delivery_metadata: DeliveryMetadata, ) -> list[TerritoryRights]: """Get preorder rights terms, and integrate instant grats data if any.""" if not delivery_metadata.product.preorder_date: return [] # Territory dates with a start_date < sale start date ARE NOT preorder, # First: check if an allowed territory has a start_date <= preorder date, # and remove from this list if so. # Second: for remaining territories with start_date > preorder date, # set end_date to day before territory start_date territory_dates = delivery_metadata.territory_codes_with_start_date preorder_list_by_territory: list[TerritoryRights] = [ TerritoryRights( territories=[territory.country_code], start_date=get_datetime_from_string( delivery_metadata.product.preorder_date ), end_date=_get_and_normalize_preorder_end_date( delivery_metadata, territory.country_code ), price_code=None, custom_price=None, distribution_rights=delivery_metadata.product.distribution_rights, is_preorder=True, ) for territory in delivery_metadata.delivery.allowed_territories if get_datetime_from_string(territory_dates[territory.country_code]) > get_datetime_from_string(delivery_metadata.product.preorder_date) ] merged_preorder_list: list[TerritoryRights] = _merge_equal_rights( preorder_list_by_territory ) # Split preorder by instant grat rights if any instant_grat_data = _format_and_normalize_instant_grat_data(delivery_metadata) preorder_term_rights: list[TerritoryRights] = ( _split_preorder_by_instant_grat_rights(merged_preorder_list, instant_grat_data) ) return preorder_term_rights def _get_and_normalize_preorder_end_date( delivery_metadata: DeliveryMetadata, territory: str ) -> datetime: """Get preorder end date for a territory. At this point, territory_start_date > preorder_date is guaranteed. If territory_start_date is different from regular_start_date, preorder end_date is territory_start_date, otherwise it is regular_start_date ****NOTE: End_date should be exclusive, so don't subtract 1 day.**** """ regular_start_date = get_datetime_from_string( delivery_metadata.product.sale_start_date ) territory_start_date = get_datetime_from_string( delivery_metadata.territory_codes_with_start_date[territory] ) return ( territory_start_date if territory_start_date != regular_start_date else regular_start_date ) def _split_preorder_by_instant_grat_rights( root_preorder_list: list[TerritoryRights], instant_grat_data: list[InstantGratRights], ) -> list[TerritoryRights]: if not instant_grat_data: return root_preorder_list all_active_preorder_rights = _get_active_preorder_rights_with_instant_grats( root_preorder_list, instant_grat_data ) contiguous_merged_rights: list[TerritoryRights] = _merge_adjacent_rights( all_active_preorder_rights ) equal_merged_rights: list[TerritoryRights] = _merge_equal_rights( contiguous_merged_rights ) # Sort final result by start_date and end_date equal_merged_rights.sort( key=lambda final_right: ( final_right.start_date, final_right.territories[0], # Sort by first territory code for consistency final_right.end_date or DEFAULT_END_DATE, ) ) return equal_merged_rights def _get_active_preorder_rights_with_instant_grats( root_preorder_list: list[TerritoryRights], instant_grats: list[InstantGratRights], ) -> list[TerritoryRights]: splice_points = _get_splice_points(root_preorder_list, instant_grats=instant_grats) all_active_preorder_rights = [] for i in range(len(splice_points) - 1): term_start, term_end_exclude = splice_points[i], splice_points[i + 1] # Find active territory rights in this interval active_rights = [ tr for tr in root_preorder_list if tr.start_date <= term_start < (tr.end_date or DEFAULT_END_DATE) ] # Find active Instant Grats in this interval # Should only ever be one active Instant Grat per interval, if exists active_instant_grats = [ ig for ig in instant_grats if ig.start_date <= term_start < ig.end_date ] for rights in active_rights: # No Instant Grats in a preorder interval is OK if not active_instant_grats: all_active_preorder_rights.append( TerritoryRights( territories=rights.territories, start_date=term_start, end_date=term_end_exclude, price_code=rights.price_code, custom_price=rights.custom_price, distribution_rights=rights.distribution_rights, is_preorder=rights.is_preorder, instant_gratification_tracks=None, ) ) continue for grat in active_instant_grats: all_active_preorder_rights.append( TerritoryRights( territories=rights.territories, start_date=term_start, end_date=term_end_exclude, price_code=rights.price_code, custom_price=rights.custom_price, distribution_rights=rights.distribution_rights, is_preorder=rights.is_preorder, instant_gratification_tracks=grat.tuids, ) ) return all_active_preorder_rights def _format_and_normalize_instant_grat_data( delivery_metadata: DeliveryMetadata, ) -> list[InstantGratRights]: """Format instant grats data from tracks to list[InstantGratRights], grouping tuids by date. End dates are fabricated & normalized because they are not provided in data from DB. """ grouped_instant_grats: list[dict[str, Any]] = _format_instant_grat_data( delivery_metadata ) if not grouped_instant_grats: return [] # Sort by start date and fabricate end_dates normalized_instant_grats: list[InstantGratRights] = ( _fabricate_instant_grat_end_dates_normalized( grouped_instant_grats, ) ) return normalized_instant_grats def _format_instant_grat_data( delivery_metadata: DeliveryMetadata, ) -> list[dict[str, Any]]: """Format instant grats data from tracks to a list of dicts, grouping tuids by date.""" grouped_instant_grats: list[dict[str, Any]] = [] for track in delivery_metadata.product.tracks: if not track.instant_grats: continue instant_grat = { "tuids": {track.track_id}, "start_date": get_datetime_from_string(track.instant_grats.date), } # Add grat to existing instant grats list if the date is the same for grat in grouped_instant_grats: if grat["start_date"] == instant_grat["start_date"]: grat["tuids"].add(track.track_id) break else: grouped_instant_grats.append(instant_grat) return grouped_instant_grats def _fabricate_instant_grat_end_dates_normalized( instant_grats: list[dict[str, Any]], ) -> list[InstantGratRights]: """Fabricate end_dates for instant grats.""" # First, sort by date instant_grats.sort(key=lambda ig: ig["start_date"]) # Then, fabricate end_date fabricated_instant_grats: list[InstantGratRights] = [] prev_tuids = set() for i, grat in enumerate(instant_grats): # Fabricate end_date to be next instant_grat start_date if before the last element if i < len(instant_grats) - 1: fabricated_end_date = instant_grats[i + 1]["start_date"] # Fabricate end_date to be datetime.max if last element, to be calculated later else: fabricated_end_date = DEFAULT_END_DATE # Combine tuids from previous instant grats to current grat node # Instant Grats are cumulative over preorder period prev_tuids.update(grat["tuids"]) new_instant_grat = InstantGratRights( tuids=prev_tuids.copy(), start_date=grat["start_date"], end_date=fabricated_end_date, ) fabricated_instant_grats.append(new_instant_grat) return fabricated_instant_grats def _generate_sale_start_term_rights( delivery_metadata: DeliveryMetadata, track: Track | None = None ) -> list[TerritoryRights]: """Get sale start date terms. Normalizes end_date to datetime.max. """ regular_release_date_terms = [] territory_dates = _format_territories_data(delivery_metadata, track) for start_date_str, territories in territory_dates.items(): regular_release_date_terms.append( TerritoryRights( territories=territories, start_date=start_date_str, end_date=datetime.max, price_code=None, custom_price=None, distribution_rights=track.distribution_rights if track else delivery_metadata.product.distribution_rights, ) ) return regular_release_date_terms def _format_territories_data( delivery_metadata: DeliveryMetadata, track: Track | None = None ) -> dict[datetime, list[str]]: """Reformat territories data from {territory_code: start_date} to {start_date: [territory_codes]} If track is provided, use track's instant grat date as start_date if it exists. """ territories_with_start_date = delivery_metadata.territory_codes_with_start_date territories_dict: dict[datetime, list[str]] = {} for key, value in sorted(territories_with_start_date.items()): territory_start_date = get_datetime_from_string(value) instant_grat_start_date = ( get_datetime_from_string(track.instant_grats.date) if track and track.instant_grats and delivery_metadata.delivery.store.is_preorder_supported else None ) track_start_date = ( instant_grat_start_date if instant_grat_start_date and instant_grat_start_date <= territory_start_date else territory_start_date ) territories_dict.setdefault(track_start_date, []).append(key) # Sort territories_dict by start_date return dict(sorted(territories_dict.items())) def _split_rights_terms_by_price( normalized_territory_rights_list: list[TerritoryRights], pricing: set[ReleasePricing], ) -> list[TerritoryRights]: """Split Territory Rights based on price data. Steps: 1. Identify all unique splice points from both rights and pricing data. 2. For each splice interval, identify all active rights and pricing nodes and create new TerritoryRights nodes for each combination of overlapping territories. 3. Merge adjacent, contiguous rights with same metadata (price_code, custom_price, distribution_rights, territories). 4. Merge territories with same metadata (price_code, custom_price, distribution_rights, start/end date). """ if not pricing: return normalized_territory_rights_list all_active_rights: list[TerritoryRights] = ( _get_all_active_rights_with_pricing_intervals( normalized_territory_rights_list, pricing ) ) contiguous_merged_rights: list[TerritoryRights] = _merge_adjacent_rights( all_active_rights ) equal_merged_rights: list[TerritoryRights] = _merge_equal_rights( contiguous_merged_rights ) # Sort final result by start_date and end_date equal_merged_rights.sort( key=lambda final_right: ( final_right.start_date, final_right.territories[0], # Sort by first territory code for consistency final_right.end_date or DEFAULT_END_DATE, ) ) return equal_merged_rights def _get_splice_points( territory_rights_list: list[TerritoryRights], pricing: list[TerritoryPricing] | None = None, instant_grats: list[InstantGratRights] | None = None, ) -> list[datetime]: """Get all unique splice points from rights, pricing, and/or instant grats data.""" splice_points = set() for territory_rights in territory_rights_list: splice_points.add(territory_rights.start_date) splice_points.add( territory_rights.end_date if territory_rights.end_date else DEFAULT_END_DATE # Make mypy happy ) if pricing: for price_info in pricing: splice_points.add(price_info.start_date) splice_points.add( price_info.end_date if price_info.end_date else DEFAULT_END_DATE # Make mypy happy ) if instant_grats: for grat in instant_grats: splice_points.add(grat.start_date) # Only add end_date if not datetime.max # The last instant grat will inherit each territory's last preorder end_date if grat.end_date != DEFAULT_END_DATE: splice_points.add(grat.end_date) return sorted(splice_points) def _get_all_active_rights_with_pricing_intervals( normalized_territory_rights_list: list[TerritoryRights], pricing: set[ReleasePricing], ) -> list[TerritoryRights]: """For each splice interval, identify all active rights and pricing nodes. Create new TerritoryRights nodes for each combination of overlapping territories. """ normalized_territory_pricing = _format_and_normalize_territory_pricing(pricing) splice_points = _get_splice_points( normalized_territory_rights_list, pricing=normalized_territory_pricing ) all_active_rights = [] for i in range(len(splice_points) - 1): term_start, term_end_exclude = splice_points[i], splice_points[i + 1] # Find active territory rights in this interval active_rights = [ tr for tr in normalized_territory_rights_list if tr.start_date <= term_start < (tr.end_date or DEFAULT_END_DATE) ] # Find active pricing in this interval active_prices = [ p for p in normalized_territory_pricing if p.start_date <= term_start < (p.end_date or DEFAULT_END_DATE) ] for rights in active_rights: for price in active_prices: overlap_territories = set(rights.territories) & price.territories if not overlap_territories: continue all_active_rights.append( TerritoryRights( territories=sorted(overlap_territories), start_date=term_start, end_date=term_end_exclude, price_code=price.price_code, custom_price=price.custom_price, distribution_rights=rights.distribution_rights, is_preorder=rights.is_preorder, instant_gratification_tracks=rights.instant_gratification_tracks, ) ) # TODO - check for no price overlap? Raise exception? return all_active_rights def _merge_adjacent_rights( all_active_rights: list[TerritoryRights], ) -> list[TerritoryRights]: """ Merge adjacent rights with same metadata and contiguous date ranges (price_code, custom_price, distribution_rights, territories, is_preorder, instant_gratification). """ # Step 1: Group territories by metadata, excluding dates grouped_contiguous_rights: dict[ tuple[ tuple[str, ...], str | None, CustomPricing | None, tuple[DownloadStreamRights, ...], bool, tuple[int, ...] | None, ], list[TerritoryRights], ] = {} for rights in all_active_rights: contiguous_key = ( tuple(rights.territories), rights.price_code, rights.custom_price, tuple(rights.distribution_rights), rights.is_preorder, tuple(rights.instant_gratification_tracks) if rights.instant_gratification_tracks else None, ) grouped_contiguous_rights.setdefault(contiguous_key, []).append(rights) # Step 2: Merge contiguous intervals in each group merged_rights: list[TerritoryRights] = [] for group in grouped_contiguous_rights.values(): # Sort groups by start_date group.sort(key=lambda right: right.start_date) merged = [group[0]] for current in group[1:]: prev = merged[-1] if prev.end_date == current.start_date: # Merge intervals by extending the end_date merged[-1] = TerritoryRights( territories=prev.territories, start_date=prev.start_date, end_date=current.end_date, price_code=prev.price_code, custom_price=prev.custom_price, distribution_rights=prev.distribution_rights, is_preorder=prev.is_preorder, instant_gratification_tracks=prev.instant_gratification_tracks, ) else: merged.append(current) merged_rights.extend(merged) return merged_rights def _merge_equal_rights( contiguous_merged_rights: list[TerritoryRights], ) -> list[TerritoryRights]: """ Merge territories with same metadata (price_code, custom_price, distribution_rights, start/end date, is_preorder, instant_gratification). """ # Step 1: Merge intervals with same date ranges and metadata, combining territories merged_rights: list[TerritoryRights] = [] grouped_date_rights: dict[ tuple[ datetime, datetime | None, str | None, CustomPricing | None, tuple[DownloadStreamRights, ...], bool, tuple[int, ...] | None, ], list[TerritoryRights], ] = {} for rights in contiguous_merged_rights: dates_key = ( rights.start_date, rights.end_date, rights.price_code, rights.custom_price, tuple(rights.distribution_rights), rights.is_preorder, tuple(rights.instant_gratification_tracks) if rights.instant_gratification_tracks else None, ) grouped_date_rights.setdefault(dates_key, []).append(rights) # Step 2: Combine territories in each group for ( start_date, end_date, price_code, custom_price, distribution_rights, is_preorder, instant_gratification_tracks, ), group in grouped_date_rights.items(): territories = { territory for rights in group for territory in rights.territories } merged_rights.append( TerritoryRights( territories=sorted(territories), start_date=start_date, end_date=end_date, price_code=price_code, custom_price=custom_price, distribution_rights=set(distribution_rights), is_preorder=is_preorder, instant_gratification_tracks=set(instant_gratification_tracks) if instant_gratification_tracks else None, ) ) return merged_rights def _format_and_normalize_territory_pricing( pricing: set[ReleasePricing], ) -> list[TerritoryPricing]: """Format pricing data to list[TerritoryPricing], grouping territories by price/date. end_dates are fabricated to ensure no overlaps within the same territory. """ ### Step 1: Fabricate end dates for overlapping pricing ### # Group pricing by territory pricing_by_territory: dict[str, set[ReleasePricing]] = {} for price in pricing: pricing_by_territory.setdefault(price.country_code.country_code, set()).add( price ) normalized_pricing: set[ReleasePricing] = set() for territory, prices in pricing_by_territory.items(): # Sort prices for this territory by normalized start_date sorted_prices = sorted( prices, key=lambda pr: _normalize_start_date( get_datetime_from_string_or_none(pr.start_date) ), ) for i, price in enumerate(sorted_prices): current_end = get_datetime_from_string_or_none(price.end_date) fabricated_end = current_end # Only fabricate end_date for prices before last price if i < len(sorted_prices) - 1: next_start = get_datetime_from_string_or_none( sorted_prices[i + 1].start_date ) # Fabricate end date if there is an overlap # e.g. current_end is None with another start date after it, # or current_end is >= next_start # New end_date is inclusive to match inclusive DB values if next_start and (current_end is None or current_end >= next_start): fabricated_end = next_start - timedelta(days=1) # Add price (with potentially fabricated end date) to normalized set normalized_pricing.add( ReleasePricing( price_code=price.price_code, country_code=price.country_code, custom_pricing=price.custom_pricing, start_date=price.start_date, end_date=fabricated_end.strftime("%Y-%m-%d %H:%M:%S") if fabricated_end else None, ) ) ### Step 2: Format to normalized list[TerritoryPricing], grouping territories by price/date ### territory_pricing: list[TerritoryPricing] = [] for p in normalized_pricing: territory_price = TerritoryPricing( territories={p.country_code.country_code}, start_date=_normalize_start_date( get_datetime_from_string_or_none(p.start_date) ), end_date=_normalize_end_date_exclusive( get_datetime_from_string_or_none(p.end_date) ), price_code=p.price_code, custom_price=p.custom_pricing, ) # Add country to existing territory listing if the pricing/date info is the same # The defined __eq__ method in TerritoryPricing is used here if territory_price in territory_pricing: index = territory_pricing.index(territory_price) territory_price_dict = territory_pricing[index].model_dump() territory_pricing.pop(index) territory_price_dict["territories"].add(p.country_code.country_code) territory_pricing.append(TerritoryPricing(**territory_price_dict)) else: territory_pricing.append(territory_price) territory_pricing.sort( key=lambda price_info: ( price_info.start_date, price_info.end_date, price_info.price_code, ) ) return territory_pricing def _normalize_end_date_exclusive(end_date: datetime | None) -> datetime: """Normalize end date to be exclusive (+1 day), or datetime.max (if None).""" return end_date + timedelta(days=1) if end_date else DEFAULT_END_DATE def _normalize_start_date(start_date: datetime | None) -> datetime: """Normalize start date to be datetime.min if None.""" return start_date if start_date else DEFAULT_START_DATE def _denormalize_end_date_inclusive(end_date: datetime | None) -> datetime | None: """Denormalize end date, set end_date or None (if datetime.max/datetime.min).""" return end_date if end_date and end_date != DEFAULT_END_DATE else None