"""Wire shapes for the approval-flow registry. Owner identity (name + work email) is included on the admin surface: the owner is a colleague and the whole point of the row is being able to reach them. The **all-staff** payload is narrower — see :func:`serialize_published` — because publishing a register of who to ask is not the same as publishing a directory of everyone's mailbox against a flow they may not even have confirmed yet. """ from __future__ import annotations import uuid from datetime import date, datetime from typing import Any from app.domains.approval_registry import constants as const from app.domains.approval_registry.models import ( ApprovalFlow, ApprovalFlowEvent, ApprovalFlowRequest, FlowConfirmationItem, FlowConfirmationRound, ) def _iso(value: datetime | date | None) -> str | None: return None if value is None else value.isoformat() def _person(person: Any) -> dict[str, Any] | None: if person is None: return None return { "id": str(person.id), "name": person.display_name or person.work_email, "email": person.work_email, "department": person.department, "employment_status": person.employment_status, } def feishu_admins(flow: ApprovalFlow) -> list[dict[str, Any]]: """The definition's 流程管理员, as stored by the sweep. Shape-checked rather than trusted: the column is JSON written by a sweep that reads a vendor payload, and one malformed row must not 500 the all-staff page. Departed admins are kept and flagged, never dropped — 「这条流程的管理员已经离职了」 is the answer somebody needs to act on, and silently hiding them turns it back into 「还没人认领」. """ return admin_entries(flow.feishu_admins) def admin_entries(raw: Any) -> list[dict[str, Any]]: """The same shape check, over the raw column value. Split out from :func:`feishu_admins` so the drift predicate can be asked about a column tuple as well as an ORM row — the coverage summary selects columns rather than entities, and re-implementing 「谁是管理员」 there is exactly how two surfaces end up counting different things. """ if not isinstance(raw, list): return [] out: list[dict[str, Any]] = [] for entry in raw: if not isinstance(entry, dict): continue name = entry.get("name") or entry.get("email") if not name: continue out.append({ "name": name, "email": entry.get("email"), "person_id": entry.get("person_id"), "active": bool(entry.get("active", True)), }) return out def feishu_admin_drift_from( owner_person_id: uuid.UUID | None, raw_admins: Any, ) -> bool: """Does the Feishu console still name somebody else as 流程管理员? True means the two systems disagree about who runs this flow and **only a human in the Feishu console can fix it** — the approval v4 definition surface is create / get / subscribe with no update verb, so 流程管理员 has no write API at all (see ``constants.DRIFT_RENAG_DAYS`` for the full reasoning). The most common cause is the half-year round: an owner presses 「转交给 X」, the register updates, the console does not, and the successor holds the accountability here without the console rights the row now says they have. Takes raw values rather than a row so the wire shape, the coverage summary (which selects columns) and the digest job all decide it with the same two comparisons. Two deliberate exclusions, each of which would otherwise make the read cry wolf: * **No owner here** — 「没人认领」 is already its own, louder read, and its fix is to name somebody, not to go edit the console. * **No admin resolving to a person.** Three distinct states collapse into this one and all three are correctly silent: a definition Feishu genuinely reports with no 流程管理员 cannot disagree with us; an admin known only by name (contractor, deleted account) can never equal an owner id; and a row nobody has ever ASKED the console about has an empty list because sweeps before migration 559 omitted ``with_admin_id`` — reading that silence as drift would have flagged most of the register on the day this shipped. ``feishu_admins_synced_at`` is deliberately NOT consulted, despite being the domain's canonical 「问过了吗」 marker. It would be dead code: ``service.apply_feishu_admins`` is the only writer of either column and always stamps both, so a NULL timestamp and an empty list are the same row. A guard whose removal changes no outcome reads as protection the reader then trusts — the emptiness check below is the real one, and it is the one the tests exercise. """ if owner_person_id is None: return False admin_ids = { str(a["person_id"]) for a in admin_entries(raw_admins) if a.get("person_id") } if not admin_ids: return False return str(owner_person_id) not in admin_ids def feishu_admin_drift(flow: ApprovalFlow) -> bool: """:func:`feishu_admin_drift_from` for an ORM row.""" return feishu_admin_drift_from(flow.owner_person_id, flow.feishu_admins) def owner_state(flow: ApprovalFlow) -> str: """One word for "how much do we trust this owner". ``unassigned`` → nobody named. ``departed`` → named, but they have left; the same operational hole as unassigned, kept distinct because the fix differs. ``unconfirmed`` → a machine's candidate nobody has stood behind — whether it guessed from approval traffic or read the 流程管理员 off the Feishu definition. ``stale`` → confirmed, but too long ago. ``confirmed`` → a person said yes, recently. A Feishu-declared admin is better evidence than an inference and still lands in ``unconfirmed``, on purpose: the console says who administers the flow, which is not the same claim as that person having agreed to answer for it here. """ owner = flow.owner if flow.owner_person_id is None or owner is None: return "unassigned" if owner.employment_status != "active" or owner.deleted_at is not None: return "departed" if flow.owner_source in ( const.OWNER_SOURCE_INFERRED, const.OWNER_SOURCE_FEISHU_ADMIN, ): return "unconfirmed" if flow.owner_confirmed_at is None: return "unconfirmed" delta = datetime.now(tz=flow.owner_confirmed_at.tzinfo) - flow.owner_confirmed_at if delta.days > const.CONFIRMATION_VALID_DAYS: return "stale" return "confirmed" def serialize_flow(flow: ApprovalFlow) -> dict[str, Any]: return { "id": str(flow.id), "approval_code": flow.approval_code, "name": flow.name, "category": flow.category, # Kept next to ``category`` rather than replaced by it: the # console's grouping and the API's disagree in this tenant, and # collapsing them would destroy the record of what upstream # actually said. "source_group": flow.source_group, "status": flow.status, "feishu_status": flow.feishu_status, "description": flow.description, "owner": _person(flow.owner), "owner_source": flow.owner_source, "owner_state": owner_state(flow), "owner_department": flow.owner_department, "owner_assigned_at": _iso(flow.owner_assigned_at), "owner_confirmed_at": _iso(flow.owner_confirmed_at), "inference_meta": flow.inference_meta, "feishu_admins": feishu_admins(flow), "feishu_admins_synced_at": _iso(flow.feishu_admins_synced_at), # 「飞书后台还没改」 —— an IT-only fact, so it rides the admin # shape and not ``serialize_published``: telling every employee # that the console names somebody else invites them to go ask # that person, who is exactly the person this flow is no longer # supposed to route to. "feishu_admin_drift": feishu_admin_drift(flow), "feishu_admin_drift_notified_at": _iso( flow.feishu_admin_drift_notified_at, ), "published": flow.published, "source": flow.source, "observed_instances": flow.observed_instances, "last_instance_at": _iso(flow.last_instance_at), "first_seen_at": _iso(flow.first_seen_at), "last_discovered_at": _iso(flow.last_discovered_at), "last_synced_at": _iso(flow.last_synced_at), "last_sync_error": flow.last_sync_error, "created_at": _iso(flow.created_at), "updated_at": _iso(flow.updated_at), } def serialize_published(flow: ApprovalFlow) -> dict[str, Any]: """The all-staff shape: which flow, what it is for, who to ask. Field-by-field allowlist, not a filtered row. Three things are withheld on purpose: the sync bookkeeping (nobody outside IT needs to know when a cron last ran), the inference evidence (publishing "we guessed this from 7 approvals" invites arguments the register cannot settle), and the owner's department snapshot. The owner's *state* IS published, so a reader can tell a confirmed owner from a candidate rather than being handed a name with unstated confidence. ``feishu_admins`` IS published, and it is the one addition that needed thinking about, because the docstring above says publishing a register of who-to-ask is not the same as publishing everyone's mailbox. These names are different: Feishu already shows them to every person who opens the flow, and withholding them here would mean the page keeps saying 「还没人认领」 about a flow whose manager the reader could look up in two clicks. A directory that is less informed than the thing it indexes has no reason to exist. """ owner = flow.owner return { "id": str(flow.id), "name": flow.name, "category": flow.category or flow.source_group, "description": flow.description, "status": flow.status, "owner_name": ( (owner.display_name or owner.work_email) if owner is not None else None ), "owner_email": owner.work_email if owner is not None else None, "owner_state": owner_state(flow), "feishu_admins": feishu_admins(flow), } def serialize_event(event: ApprovalFlowEvent) -> dict[str, Any]: return { "id": str(event.id), "event_type": event.event_type, "actor_name": event.actor_name, "actor_person_id": ( str(event.actor_person_id) if event.actor_person_id else None ), "body": event.body, "meta": event.meta, "created_at": _iso(event.created_at), } def serialize_round( round_row: FlowConfirmationRound, summary: dict[str, Any] | None = None, ) -> dict[str, Any]: return { "id": str(round_row.id), "label": round_row.label, "status": round_row.status, "opened_at": _iso(round_row.opened_at), "due_at": _iso(round_row.due_at), "closed_at": _iso(round_row.closed_at), "notified_owners": round_row.notified_owners, "summary": summary, } def serialize_round_item( item: FlowConfirmationItem, *, flow: ApprovalFlow | None = None, owner_name: str | None = None, successor_name: str | None = None, ) -> dict[str, Any]: return { "id": str(item.id), "flow_id": str(item.flow_id), "flow_name": flow.name if flow is not None else None, "flow_category": (flow.category or flow.source_group) if flow else None, # The person who was ASKED, which is not necessarily the flow's # current owner — if the answer was "hand it over", the item has # to keep saying who was asked or the round reads as though the # successor had answered. "owner_person_id": ( str(item.owner_person_id) if item.owner_person_id else None ), "owner_name": owner_name, "state": item.state, "decided_at": _iso(item.decided_at), "decided_via": item.decided_via, "transferred_to_name": successor_name, "note": item.note, "notified_at": _iso(item.notified_at), "escalated_at": _iso(item.escalated_at), } def serialize_request(row: ApprovalFlowRequest) -> dict[str, Any]: return { "id": str(row.id), "proposed_name": row.proposed_name, "purpose": row.purpose, "proposed_category": row.proposed_category, "requester": _person(row.requester), "proposed_owner": _person(row.proposed_owner), "status": row.status, "decided_at": _iso(row.decided_at), "decision_note": row.decision_note, "registered_flow_id": ( str(row.registered_flow_id) if row.registered_flow_id else None ), "registered_at": _iso(row.registered_at), "created_at": _iso(row.created_at), "updated_at": _iso(row.updated_at), }