"""Stable, privacy-safe audiences for region-scoped asset intelligence.""" from __future__ import annotations import hashlib from dataclasses import dataclass from uuid import UUID from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.auth.models import AuthRole, AuthUserRole from app.core.rbac import ( ASSET_GLOBAL_ADMIN_ROLE, FINANCE_ROLE, GLOBAL_ADMIN_ROLE, MANAGEMENT_ROLE, ) from app.domains.asset.models import AssetLocation, AssetLocationAdmin from app.identity.person_models import IdentityPerson GLOBAL_ASSET_AUDIENCE_ROLES = frozenset({ GLOBAL_ADMIN_ROLE, ASSET_GLOBAL_ADMIN_ROLE, FINANCE_ROLE, MANAGEMENT_ROLE, }) @dataclass(frozen=True) class RegionalAssetAudience: """One unique location set; never contains a person identifier.""" key: str location_ids: frozenset[UUID] def regional_asset_audience( location_ids: set[UUID] | frozenset[UUID], ) -> RegionalAssetAudience: """Derive the same opaque key for the same non-empty location set. The key is a digest of the location ids, so it changed when authority moved off offices (migration 535). Snapshots produced under the old office-keyed audiences are unreachable from the new keys — deliberately: their contents were scoped by the old office resolution and re-pointing a key at differently-scoped history would be worse than a one-cycle gap. The daily job rebuilds every audience on its next run. """ normalized = frozenset(location_ids) if not normalized: raise ValueError("Regional asset audience requires at least one location") material = ",".join(sorted(str(value) for value in normalized)) digest = hashlib.sha256(material.encode("ascii")).hexdigest()[:24] return RegionalAssetAudience(f"asset_region:{digest}", normalized) async def regional_asset_audiences(db: AsyncSession) -> tuple[RegionalAssetAudience, ...]: """Return unique active regional scopes, excluding global administrators.""" privileged_people = set( ( await db.execute( select(AuthUserRole.user_id) .join(AuthRole, AuthRole.id == AuthUserRole.role_id) .where(AuthRole.name.in_(GLOBAL_ASSET_AUDIENCE_ROLES)) ) ).scalars() ) rows = ( await db.execute( select(AssetLocationAdmin.person_id, AssetLocationAdmin.location_id) .join(IdentityPerson, IdentityPerson.id == AssetLocationAdmin.person_id) .join(AssetLocation, AssetLocation.id == AssetLocationAdmin.location_id) .where( IdentityPerson.deleted_at.is_(None), ( (IdentityPerson.employment_status == "active") | IdentityPerson.employment_status.is_(None) ), AssetLocation.is_active.is_(True), ) ) ).all() by_person: dict[UUID, set[UUID]] = {} for person_id, location_id in rows: if person_id not in privileged_people: by_person.setdefault(person_id, set()).add(location_id) unique_location_sets = {frozenset(value) for value in by_person.values() if value} return tuple( sorted( (regional_asset_audience(value) for value in unique_location_sets), key=lambda value: value.key, ) ) async def active_location_ids_managed_by( db: AsyncSession, person_id: UUID, ) -> frozenset[UUID]: """Use the same active-location boundary as scheduled audience production. Narrower than ``location_admin_sync.get_location_ids_managed_by``, which the write gate uses: that one deliberately keeps a deactivated location in scope so its leftover assets stay manageable. An audience keyed on a retired location would instead keep producing a briefing about a place the company no longer operates, so this one filters. """ return frozenset( ( await db.execute( select(AssetLocationAdmin.location_id) .join( IdentityPerson, IdentityPerson.id == AssetLocationAdmin.person_id, ) .join( AssetLocation, AssetLocation.id == AssetLocationAdmin.location_id, ) .where( AssetLocationAdmin.person_id == person_id, IdentityPerson.deleted_at.is_(None), ( (IdentityPerson.employment_status == "active") | IdentityPerson.employment_status.is_(None) ), AssetLocation.is_active.is_(True), ) ) ).scalars() ) __all__ = [ "RegionalAssetAudience", "GLOBAL_ASSET_AUDIENCE_ROLES", "active_location_ids_managed_by", "regional_asset_audience", "regional_asset_audiences", ]