"""Office 当地 Admin list management + person → office resolution. An office's 当地 Admin is its local IT contact: the person a new hire in that city meets on day one. The list is read by the person-anchored flows only — * the Day-1 welcome email CC audience, * the onboarding hardware-preparation recipient, * the offboarding handover suggestion, all of which key off where a *person* sits. **This grants no asset authority.** Until migration 535 this table (then named ``asset_office_admins``) was the source of the ``asset_admin`` role and of every regional write gate in the asset domain. That authority now derives from ``asset_location_admins`` — see :mod:`app.domains.asset.location_admin_sync`. Editing an office's 当地 Admin list changes no role and no permission; nothing in this module touches ``auth_user_roles``. Why the split: assets live at a *location* (a managed, resolver-gated value on every asset row) while people live at an *office* (a street address with a phone number). Deriving asset authority from the office table meant translating between the two on every permission check, through a ladder of city and country string inference that had a wrong answer available at each rung. """ from __future__ import annotations import uuid from collections.abc import Iterable from sqlalchemy import delete, insert, select, update from sqlalchemy.ext.asyncio import AsyncSession from app.domains.asset.location_resolver import LocationIndex from app.domains.asset.models import AssetOfficeAddress, AssetOfficeLocalAdmin from app.identity.person_models import IdentityPerson async def replace_office_local_admins( db: AsyncSession, office_id: uuid.UUID, person_ids: Iterable[uuid.UUID], *, primary_person_id: uuid.UUID | None = None, ) -> list[uuid.UUID]: """Set the full 当地 Admin list for an office to ``person_ids``. Returns the person_ids whose membership actually changed. Caller commits the transaction. The primary is the first entry unless ``primary_person_id`` says otherwise. It is stored only as the ``is_primary`` flag on the join row — the denormalised mirror column is gone (migration 540). """ person_id_list = [pid for pid in person_ids if pid is not None] seen: set[uuid.UUID] = set() deduped: list[uuid.UUID] = [] for pid in person_id_list: if pid not in seen: seen.add(pid) deduped.append(pid) person_id_list = deduped # Validate every submitted person is active and non-deleted, so a # departed person can't be parked in the list and silently become # the "local contact" a new hire is told to find. if person_id_list: from app.core.exceptions import ValidationError # noqa: PLC0415 rows = await db.execute( select(IdentityPerson.id).where( IdentityPerson.id.in_(person_id_list), IdentityPerson.deleted_at.is_(None), (IdentityPerson.employment_status == "active") | (IdentityPerson.employment_status.is_(None)), ) ) valid_ids = {row[0] for row in rows.all()} invalid = [pid for pid in person_id_list if pid not in valid_ids] if invalid: raise ValidationError( "office local admin candidates include inactive / " "departed / unknown persons: " + ", ".join(str(p) for p in invalid), ) if primary_person_id is not None and primary_person_id not in person_id_list: from app.core.exceptions import ValidationError # noqa: PLC0415 raise ValidationError( "primary_person_id must be one of the submitted local admins", ) if primary_person_id is None and person_id_list: primary_person_id = person_id_list[0] existing_rows = await db.execute( select(AssetOfficeLocalAdmin.person_id).where( AssetOfficeLocalAdmin.office_id == office_id, ) ) existing_set: set[uuid.UUID] = {row[0] for row in existing_rows.all()} new_set: set[uuid.UUID] = set(person_id_list) to_remove = existing_set - new_set to_add = new_set - existing_set if to_remove: await db.execute( delete(AssetOfficeLocalAdmin).where( AssetOfficeLocalAdmin.office_id == office_id, AssetOfficeLocalAdmin.person_id.in_(to_remove), ) ) for pid in to_add: await db.execute( insert(AssetOfficeLocalAdmin).values( office_id=office_id, person_id=pid, is_primary=(pid == primary_person_id), ) ) if new_set: await db.execute( update(AssetOfficeLocalAdmin) .where(AssetOfficeLocalAdmin.office_id == office_id) .values(is_primary=False) ) await db.execute( update(AssetOfficeLocalAdmin) .where( AssetOfficeLocalAdmin.office_id == office_id, AssetOfficeLocalAdmin.person_id == primary_person_id, ) .values(is_primary=True) ) return list(to_remove | to_add) def _local_admin_row(person_id, is_primary, display_name, work_email) -> dict: return { "person_id": person_id, "person_name": display_name, "work_email": work_email, "is_primary": bool(is_primary), } async def list_office_local_admins( db: AsyncSession, office_id: uuid.UUID, ) -> list[dict]: """``[{person_id, person_name, work_email, is_primary}]``, primary first. Departed and soft-deleted people are filtered out so the UI doesn't render a ghost contact. """ rows = await db.execute( select( AssetOfficeLocalAdmin.person_id, AssetOfficeLocalAdmin.is_primary, IdentityPerson.display_name, IdentityPerson.work_email, ) .join(IdentityPerson, IdentityPerson.id == AssetOfficeLocalAdmin.person_id) .where( AssetOfficeLocalAdmin.office_id == office_id, IdentityPerson.deleted_at.is_(None), (IdentityPerson.employment_status == "active") | (IdentityPerson.employment_status.is_(None)), ) .order_by( AssetOfficeLocalAdmin.is_primary.desc(), IdentityPerson.display_name, ) ) return [_local_admin_row(*r) for r in rows.all()] async def get_office_primary_local_admin( db: AsyncSession, office_id: uuid.UUID, ) -> IdentityPerson | None: """The one 当地 Admin a single-recipient flow should address. Reads the join table, which since migration 540 is the only place this answer is stored — the mirror column it replaced was one code path writing a fact three others read. Returns the row flagged ``is_primary``; failing that, the sole administrator when there is exactly one. ``None`` when the office has several with no primary — the callers all have an IT fallback, and telling an arbitrary person they are the contact is worse than telling nobody. Departed and soft-deleted people are already filtered out by :func:`list_office_local_admins`. """ admins = await list_office_local_admins(db, office_id) if not admins: return None chosen = next((a for a in admins if a["is_primary"]), None) if chosen is None and len(admins) == 1: chosen = admins[0] if chosen is None: return None return await db.get(IdentityPerson, chosen["person_id"]) async def list_office_local_admin_ids_bulk( db: AsyncSession, office_ids: Iterable[uuid.UUID], ) -> dict[uuid.UUID, list[dict]]: """Bulk variant for the office list endpoint — one query for all.""" office_id_list = list(office_ids) if not office_id_list: return {} rows = await db.execute( select( AssetOfficeLocalAdmin.office_id, AssetOfficeLocalAdmin.person_id, AssetOfficeLocalAdmin.is_primary, IdentityPerson.display_name, IdentityPerson.work_email, ) .join(IdentityPerson, IdentityPerson.id == AssetOfficeLocalAdmin.person_id) .where( AssetOfficeLocalAdmin.office_id.in_(office_id_list), IdentityPerson.deleted_at.is_(None), (IdentityPerson.employment_status == "active") | (IdentityPerson.employment_status.is_(None)), ) .order_by( AssetOfficeLocalAdmin.is_primary.desc(), IdentityPerson.display_name, ) ) out: dict[uuid.UUID, list[dict]] = {oid: [] for oid in office_id_list} for r in rows.all(): out[r.office_id].append( _local_admin_row(r.person_id, r.is_primary, r.display_name, r.work_email) ) return out # ── Person → office resolution ────────────────────────────────────── # # Used by the person-anchored HR flows to find "the office this person # sits in". Note this is NOT a permission primitive — the asset region # gate resolves people to *locations* # (``location_admin_sync.resolve_person_location_ids_bulk``). def _norm_city(value: str | None) -> str | None: """Normalize a city string for office matching. HRIS / SuccessFactors writes mainland-China cities with the administrative suffix (``北京市``, ``杭州市``) while the office master stores the bare city name. Strip a single trailing ``市`` (and surrounding whitespace) so the two sides compare equal. Non-Chinese cities (Tokyo, Singapore) are returned unchanged. """ if not value: return None value = value.strip() if value.endswith("市"): value = value[:-1] return value or None async def _office_city_index(db: AsyncSession) -> dict[str, uuid.UUID]: rows = ( await db.execute( select(AssetOfficeAddress.id, AssetOfficeAddress.city) .order_by(AssetOfficeAddress.id) ) ).all() result: dict[str, uuid.UUID] = {} for office_id, city in rows: normalized = _norm_city(city) if normalized is not None: # Deterministic collision rule: if legacy rows such as 北京 # and 北京市 normalize to one city, the lowest office UUID # wins. result.setdefault(normalized, office_id) return result async def resolve_person_office_ids_bulk( db: AsyncSession, person_ids: Iterable[uuid.UUID], ) -> dict[uuid.UUID, uuid.UUID | None]: """``{person_id: office_id | None}`` from the HRIS work location. ``work_location_name`` first, then ``city``. ``work_location_code`` is deliberately absent: it is an ISO code (CN/US/SG) and cannot legitimately match a city name, so including it only ever produced false positives against an office seeded with a 2-letter city. """ ids = set(person_ids) if not ids: return {} office_by_city = await _office_city_index(db) people = ( await db.execute( select( IdentityPerson.id, IdentityPerson.work_location_name, IdentityPerson.city, ).where(IdentityPerson.id.in_(ids)) ) ).all() result: dict[uuid.UUID, uuid.UUID | None] = {person_id: None for person_id in ids} for person_id, work_location, city in people: for value in (work_location, city): office_id = office_by_city.get(_norm_city(value) or "") if office_id is not None: result[person_id] = office_id break return result async def get_person_office_id( db: AsyncSession, person_id: uuid.UUID, ) -> uuid.UUID | None: """Single-person form of :func:`resolve_person_office_ids_bulk`.""" return (await resolve_person_office_ids_bulk(db, [person_id])).get(person_id) # ── Office → location ─────────────────────────────────────────────── async def resolve_office_location_ids_bulk( db: AsyncSession, office_ids: Iterable[uuid.UUID], *, index: LocationIndex | None = None, ) -> dict[uuid.UUID, uuid.UUID | None]: """``{office_id: location_id | None}`` — which managed location an office physically sits in. Needed by the one remaining surface where an operator names an *office* but the authority question is about a *location*: recycle-to-office, where an asset is shipped to a street address and the regional gate has to decide whether that address is in the caller's region. Derived from ``office.city`` through the same :class:`ManagedLocationResolver` every other location string goes through — deliberately not a stored bridge column. The old ``asset_office_addresses.asset_location`` column was exactly such a bridge, and keeping a second place where the office↔location relationship is recorded is how the two drift apart. ``None`` means the office's city resolves to no managed location; callers must treat that as out-of-region for every regional admin. """ ids = set(office_ids) if not ids: return {} if index is None: index = await LocationIndex.load(db) rows = ( await db.execute( select(AssetOfficeAddress.id, AssetOfficeAddress.city).where( AssetOfficeAddress.id.in_(ids), ) ) ).all() result: dict[uuid.UUID, uuid.UUID | None] = {oid: None for oid in ids} for office_id, city in rows: result[office_id] = index.resolve_id(city) return result async def get_office_location_id( db: AsyncSession, office_id: uuid.UUID, *, index: LocationIndex | None = None, ) -> uuid.UUID | None: """Single-office form of :func:`resolve_office_location_ids_bulk`.""" resolved = await resolve_office_location_ids_bulk( db, [office_id], index=index, ) return resolved.get(office_id) async def resolve_office_location_primary_admin( db: AsyncSession, office_id: uuid.UUID, ) -> IdentityPerson | None: """The asset administrator responsible for what sits at an office. Distinct from :func:`get_office_primary_local_admin`, and the distinction is the whole point of the split: the 当地 Admin is a contact for the PEOPLE at an office, while this is whoever administers the LOCATION that office stands in — the person a device shipped there becomes the responsibility of. ``None`` when the office's city names no managed location, or that location has no single accountable administrator; callers fall back to their IT contact. """ from app.domains.asset.location_admin_sync import ( # noqa: PLC0415 resolve_location_primary_admins_bulk, ) location_id = await get_office_location_id(db, office_id) if location_id is None: return None primaries = await resolve_location_primary_admins_bulk(db, [location_id]) admin_id = primaries.get(location_id) if admin_id is None: return None return await db.get(IdentityPerson, admin_id)