"""Build and resolve the mover review packet. The packet is what a role change now fires into. It answers one question — *this person moved; what did they take with them that somebody should look at?* — and it answers it over everything they hold, not just their cloud grants. Three decisions live here rather than in the coordinator, because each one is a judgement that should be readable in one place: **Not every move deserves a packet.** A title change is recorded on the forensic timeline and stops there. Access follows who you report to and which org you sit in, not the string in your title, and a promotion that opened a 238-item review would teach reviewers to close them unread — the same reasoning that stops a title change flipping cloud grants in ``role_change_coordinator`` (PR #3315, rule F). **A manager change inside one department is not a department change.** Both are access-relevant, but they raise different questions about the same holdings: moving under a new lead keeps you on the same systems, moving to a different org usually does not. ``move_kind`` records which happened, deterministically, from the before/after blob — never from the model — and every downstream surface reads it. **Re-scanning must never lose a decision.** Items are keyed on ``(review_id, resource_type, resource_id)``. A second scan re-finds the same holdings, inserts only what is new, and leaves every recorded decision exactly where it was. This matters because the scan is re-runnable by design: it is the only way a packet built while a domain was failing can become complete later. """ from __future__ import annotations import logging from datetime import UTC, date, datetime from typing import Any from uuid import UUID from sqlalchemy import func, select, update from sqlalchemy.dialects.mysql import insert as mysql_insert from sqlalchemy.ext.asyncio import AsyncSession from app.domains.hr.models import ( ITEM_DECISIONS, MOVE_DEPARTMENT, MOVE_MANAGER, MOVE_TITLE, ROLE_REVIEW_OPEN, ROLE_REVIEW_RESOLVED, ROLE_REVIEW_SUPERSEDED, HrRoleChangeReview, HrRoleChangeReviewItem, ) from app.hr_lifecycle.mover_holdings import REVIEW, scan_person_holdings from app.identity.person_models import IdentityPerson logger = logging.getLogger(__name__) def classify_move(before: dict[str, Any], after: dict[str, Any]) -> str | None: """``MOVE_*`` for this transition, or ``None`` when it warrants no packet. Ordered by blast radius, most significant first: a move that changes both department and manager is a department change, because that is the question worth asking about it. """ if before.get("dept_code") != after.get("dept_code"): return MOVE_DEPARTMENT if before.get("manager_person_id") != after.get("manager_person_id"): return MOVE_MANAGER if before.get("job_title") != after.get("job_title"): return MOVE_TITLE return None #: Move kinds that open a packet. ``MOVE_TITLE`` is deliberately absent #: — see the module docstring. PACKET_WORTHY = (MOVE_DEPARTMENT, MOVE_MANAGER) async def build_review_packet( db: AsyncSession, person_id: UUID, *, before: dict[str, Any], after: dict[str, Any], effective_date: date, lifecycle_event_id: UUID | None = None, ) -> HrRoleChangeReview | None: """Create (or refresh) the packet for one role change. Returns ``None`` when the move warrants no packet — a title-only change, or a person who has since been soft-deleted. Idempotent on ``(person_id, effective_date)``: called twice for the same move it re-scans and tops up the item list rather than opening a second packet. """ move_kind = classify_move(before, after) if move_kind not in PACKET_WORTHY: return None person = await db.scalar( select(IdentityPerson).where( IdentityPerson.id == person_id, IdentityPerson.deleted_at.is_(None), ) ) if person is None: return None review = await db.scalar( select(HrRoleChangeReview).where( HrRoleChangeReview.person_id == person_id, HrRoleChangeReview.effective_date == effective_date, ) ) if review is None: # Any earlier packet still open is about an org position this # person has already left. Leaving it open asks a reviewer to # decide access questions against a world that moved on, and it # double-counts in every "how many open reviews" number. await db.execute( update(HrRoleChangeReview) .where( HrRoleChangeReview.person_id == person_id, HrRoleChangeReview.status == ROLE_REVIEW_OPEN, HrRoleChangeReview.effective_date < effective_date, ) .values( status=ROLE_REVIEW_SUPERSEDED, resolved_at=datetime.now(UTC), ) ) review = HrRoleChangeReview( person_id=person_id, lifecycle_event_id=lifecycle_event_id, effective_date=effective_date, move_kind=move_kind, status=ROLE_REVIEW_OPEN, ) db.add(review) await db.flush() else: # A re-run may know more than the first pass did (a domain that # was failing now answers). The classification can only sharpen. review.move_kind = move_kind if lifecycle_event_id is not None: review.lifecycle_event_id = lifecycle_event_id review.before_dept_code = before.get("dept_code") review.after_dept_code = after.get("dept_code") review.before_job_title = before.get("job_title") review.after_job_title = after.get("job_title") review.before_manager_person_id = _as_uuid(before.get("manager_person_id")) review.after_manager_person_id = _as_uuid(after.get("manager_person_id")) review.before_dept_path = await _dept_path(db, before.get("dept_code")) review.after_dept_path = await _dept_path(db, after.get("dept_code")) review.reviewer_person_id = await _resolve_reviewer(db, person, after) scan = await scan_person_holdings(db, person_id) review.failed_scan_types = scan.failed_types or None await _upsert_items(db, review, scan.holdings) await refresh_counts(db, review) # Classify what this MOVE calls into question, as opposed to what the # person happens to hold. Runs inline on every build and every # re-scan, so a packet built while the policy table was empty starts # meaning something the day policies are configured — nobody has to # remember to re-trigger anything. # # Swallowed like every other coordinator-adjacent step: a delta the # engine could not compute leaves the packet at ``('none', 0)``, # which is precisely the un-pushable state. A packet that exists # with no delta is far better than no packet. try: from app.hr_lifecycle.mover_delta import annotate_review await annotate_review(db, review) except Exception: # noqa: BLE001 — never lose the packet over the delta logger.exception( "mover_delta: annotation failed for review=%s (packet kept, " "delta left un-computed and therefore un-pushable)", review.id, ) return review def _as_uuid(value: Any) -> UUID | None: if value in (None, ""): return None try: return UUID(str(value)) except (ValueError, AttributeError, TypeError): return None async def _dept_path(db: AsyncSession, dept_code: str | None) -> str | None: """Human-readable org path for a department source id. Stored on the packet rather than resolved on read: a reviewer opens this months later, and by then the org tree has moved. A code with no name is worse than useless on a card — it reads as an internal identifier leaking into a human surface. """ if not dept_code: return None from app.identity.person_models import IdentityOrgMembership row = await db.scalar( select(IdentityOrgMembership.org_path) .where(IdentityOrgMembership.org_node_source_id == dept_code) .limit(1) ) if row: return str(row)[:512] name = await db.scalar( select(IdentityOrgMembership.org_node_name) .where(IdentityOrgMembership.org_node_source_id == dept_code) .limit(1) ) return str(name)[:512] if name else None async def _resolve_reviewer( db: AsyncSession, person: IdentityPerson, after: dict[str, Any], ) -> UUID | None: """Who is asked. The NEW manager, if there is a live one. Returns ``None`` rather than guessing when the move left the person without a manager, or their new manager has themselves departed. The caller routes an unclaimed packet to the IT admin pool — a reviewer field pointing at someone who cannot act is worse than an empty one, because it looks handled. """ candidate = _as_uuid(after.get("manager_person_id")) or person.manager_person_id if candidate is None: return None ok = await db.scalar( select(IdentityPerson.id).where( IdentityPerson.id == candidate, IdentityPerson.deleted_at.is_(None), IdentityPerson.employment_status == "active", ) ) return ok async def _upsert_items( db: AsyncSession, review: HrRoleChangeReview, holdings: list[Any], ) -> None: """Insert holdings that are new to this packet; touch nothing else. ``ON DUPLICATE KEY UPDATE`` on a column that is already equal to itself is the portable way to say "insert if absent" without a per-row SELECT — and, critically, without an ``UPDATE`` that could reach ``decision``. A re-scan must be unable to un-answer a question somebody already answered. """ if not holdings: return rows = [ { "id": str(_new_uuid()), "review_id": str(review.id), "resource_type": h.resource_type, "resource_id": str(h.resource_id), "label": (h.label or "")[:512], "disposition": h.disposition, "detail": h.detail or None, } for h in holdings ] stmt = mysql_insert(HrRoleChangeReviewItem).values(rows) await db.execute( stmt.on_duplicate_key_update( # Label and detail may legitimately have changed since the # first scan (a domain renamed, a plan renamed). Decisions # never appear here. label=stmt.inserted.label, detail=stmt.inserted.detail, ) ) def _new_uuid() -> UUID: import uuid as _uuid return _uuid.uuid4() async def refresh_counts( db: AsyncSession, review: HrRoleChangeReview, ) -> HrRoleChangeReview: """Recompute the packet's tallies and close it when nothing is left. Counted in SQL rather than from the ORM collection: a packet can carry hundreds of items, and the page that shows "12 / 238 decided" must not require loading 238 objects to say so. """ total, reviewable, decided = ( await db.execute( select( func.count(), func.sum( func.if_(HrRoleChangeReviewItem.disposition == REVIEW, 1, 0), ), func.sum( func.if_( (HrRoleChangeReviewItem.disposition == REVIEW) & HrRoleChangeReviewItem.decision.is_not(None), 1, 0, ), ), ).where(HrRoleChangeReviewItem.review_id == review.id) ) ).one() review.total_items = int(total or 0) review.reviewable_items = int(reviewable or 0) review.decided_items = int(decided or 0) if ( review.status == ROLE_REVIEW_OPEN and review.reviewable_items > 0 and review.decided_items >= review.reviewable_items ): review.status = ROLE_REVIEW_RESOLVED review.resolved_at = datetime.now(UTC) elif ( review.status == ROLE_REVIEW_RESOLVED and review.decided_items < review.reviewable_items ): # A re-scan found holdings nobody has ruled on yet. Re-opening is # the honest state: the packet's own definition of "done" is no # longer met, and leaving it resolved would hide the new items # behind a closed row. review.status = ROLE_REVIEW_OPEN review.resolved_at = None return review async def record_decision( db: AsyncSession, item: HrRoleChangeReviewItem, *, decision: str, decided_by: UUID | None, note: str | None = None, ) -> HrRoleChangeReviewItem: """Record one human decision. Executes nothing. The platform takes no action on a mover's access from here — issue #623, unchanged. ``revoke`` and ``transfer`` are answers a human then carries out on the resource's own admin surface, which already has that domain's approval, audit and idempotency behaviour. A second execution path bolted onto a review table would have none of those. """ if decision not in ITEM_DECISIONS: raise ValueError(f"unknown decision {decision!r}") if item.disposition != REVIEW: raise ValueError( "this holding is informational and takes no decision", ) item.decision = decision item.decided_by_person_id = decided_by item.decided_at = datetime.now(UTC) item.decision_note = (note or None) and note[:500] return item __all__ = [ "PACKET_WORTHY", "build_review_packet", "classify_move", "record_decision", "refresh_counts", ]