"""Leave-availability lookup API — the HR data surface for the External API. POST /api/v1/open/hr/absences/lookup hr.absences:read An **availability** feed for downstream systems that need to know whether a person is out (the original driver: a TiDB Cloud on-call scheduler checking its on-callers before assigning a shift). It answers, per person, "are they on leave in this window?" — nothing more. The credential / token / rate-limit / audit plane is the standalone External API feature (:mod:`app.api_clients`); this module only adds the HR data layer and gates on the leave scope. It lives in the hr domain because the data it serves (``hr_leave_absences``, mirrored daily from SF EC Time-Off) is hr's; the only cross-cutting import is :func:`require_api_client`. Design contract (documented for integrators in ``docs/api/external-api.md`` §B.4): * **Lookup, not listing** — the caller MUST name the people by work email (≤200 per call). There is no "list everyone's leave" endpoint; leave data is sensitive and this surface has no per-client population filter, so it is least-privilege by construction (you can only ask about people you already know). * **Coverage is explicit** — Helix only mirrors SF EC Time-Off, which administers the CN entity; overseas staff have no leave accounts. A person with no leave data comes back ``coverage="no_data"`` (NOT ``on leave: false``) so a scheduler never silently treats "we don't know" as "available". Tri-state: ``covered`` / ``no_data`` / ``unknown_person``. * **Coarsened output** — a span carries a date range, a coarse ``category`` (``leave``), the ``approval_status``, and the day count. Never the leave reason or the specific absence type (sick / parental) — a scheduler does not need it and it is private. * **Freshness** — data refreshes on the daily ``hr.leave_account_sync`` cron; ``meta.as_of`` reports the feed's last sync time so the caller can decide whether to trust it for a same-day decision. """ from __future__ import annotations from datetime import UTC, date from fastapi import APIRouter, Depends, Request from pydantic import BaseModel, Field, field_validator from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from app.api_clients.auth import ApiClientContext, require_api_client from app.api_clients.scopes import SCOPE_HR_ABSENCES_READ from app.core.database import get_db from app.core.exceptions import ValidationError from app.core.response import success_response from app.domains.hr.leave_models import HrLeaveAbsence, HrLeaveAccount from app.identity.person_models import IdentityPerson router = APIRouter( prefix="/api/v1/open/hr", tags=["open-hr"], ) _MAX_EMAILS = 200 _MAX_WINDOW_DAYS = 366 #: approval_status values that mean "the person is (or will be) out" for #: this window. PENDING_CANCELLATION = a cancellation is requested but not #: yet applied, so the leave still stands — conservatively counted as out. #: A NULL status (a tenant that stripped SF's approvalStatus field) is #: treated as unavailable-but-unknown rather than dropped, so the feed #: never under-reports. Anything else (rejected / cancelled / withdrawn / #: an unrecognised value) is excluded. _UNAVAILABLE_STATUSES = frozenset({"APPROVED", "PENDING", "PENDING_CANCELLATION"}) _LOOKUP_EXAMPLE = { "success": True, "data": [ { "email": "zhangsan@pingcap.com", "coverage": "covered", "unavailable_spans": [ { "start": "2026-08-12", "end": "2026-08-14", "days": 3.0, "category": "leave", "status": "APPROVED", } ], }, { "email": "overseas@pingcap.com", "coverage": "no_data", "unavailable_spans": [], }, ], "meta": { "trace_id": "…", "as_of": "2026-08-06T02:10:00+00:00", "from": "2026-08-10", "to": "2026-08-17", "window_days": 8, }, "errors": [], } class AbsenceLookupRequest(BaseModel): """Body for the availability lookup. ``emails`` names the people to check (work email, case-insensitive, 1..200). ``from_`` / ``to`` bound the window (inclusive, ≤366 days). """ emails: list[str] = Field(min_length=1, max_length=_MAX_EMAILS) from_: date = Field(alias="from") to: date model_config = {"populate_by_name": True} @field_validator("emails") @classmethod def _clean_emails(cls, v: list[str]) -> list[str]: cleaned: list[str] = [] seen: set[str] = set() for raw in v: e = (raw or "").strip().lower() if e and e not in seen: seen.add(e) cleaned.append(e) if not cleaned: raise ValueError("emails must contain at least one non-empty value") return cleaned def _category_for(_time_type: str | None) -> str: """Coarse availability category. Every SF EC Time-Off ``EmployeeTime`` row is a leave request (business trips live in a different SF entity), so this is ``leave`` today; kept as a function so a future source (Feishu trip/outing) can widen the vocabulary without changing the wire contract's shape.""" return "leave" @router.post( "/absences/lookup", summary="Look up leave availability for named people", responses={200: {"content": {"application/json": {"example": _LOOKUP_EXAMPLE}}}}, ) async def lookup_absences( body: AbsenceLookupRequest, request: Request, ctx: ApiClientContext = Depends(require_api_client(SCOPE_HR_ABSENCES_READ)), db: AsyncSession = Depends(get_db), ) -> dict: if body.to < body.from_: raise ValidationError("`to` must be on or after `from`.") window_days = (body.to - body.from_).days + 1 if window_days > _MAX_WINDOW_DAYS: raise ValidationError( f"window must be ≤ {_MAX_WINDOW_DAYS} days (got {window_days}).", ) emails = body.emails # Resolve emails → non-deleted persons (case-insensitive). One person # per email; if an email resolves to none, it is `unknown_person`. person_rows = (await db.execute( select(IdentityPerson).where( func.lower(IdentityPerson.work_email).in_(emails), IdentityPerson.deleted_at.is_(None), ) )).scalars().all() person_by_email: dict[str, IdentityPerson] = {} for p in person_rows: key = (p.work_email or "").strip().lower() # If two rows share an email, prefer the active one so coverage is # computed against the live record. if key not in person_by_email or ( p.employment_status == "active" and person_by_email[key].employment_status != "active" ): person_by_email[key] = p person_ids = [str(p.id) for p in person_by_email.values()] # Which resolved persons have ANY leave account → "covered" (we have a # leave data source for them); zero accounts → "no_data". covered_ids: set[str] = set() if person_ids: rows = (await db.execute( select(HrLeaveAccount.person_id) .where( HrLeaveAccount.person_id.in_(person_ids), HrLeaveAccount.deleted_at.is_(None), ) .distinct() )).scalars().all() covered_ids = {str(pid) for pid in rows} # Absences overlapping the window for the resolved persons. spans_by_person: dict[str, list[HrLeaveAbsence]] = {} if person_ids: abs_rows = (await db.execute( select(HrLeaveAbsence).where( HrLeaveAbsence.person_id.in_(person_ids), HrLeaveAbsence.deleted_at.is_(None), HrLeaveAbsence.start_date.is_not(None), HrLeaveAbsence.end_date.is_not(None), HrLeaveAbsence.start_date <= body.to, HrLeaveAbsence.end_date >= body.from_, ) )).scalars().all() for a in abs_rows: status = a.approval_status if status is not None and status not in _UNAVAILABLE_STATUSES: continue # rejected / cancelled / unrecognised — not out spans_by_person.setdefault(a.person_id, []).append(a) data: list[dict] = [] for email in emails: person = person_by_email.get(email) if person is None: data.append({ "email": email, "coverage": "unknown_person", "unavailable_spans": [], }) continue pid = str(person.id) coverage = "covered" if pid in covered_ids else "no_data" # Only rows with a parseable span reached spans_by_person (the query # filters start/end NOT NULL); the explicit per-row guard both # satisfies the type checker (narrows date|None → date) and defends # against a future query change. spans: list[dict] = [] for a in sorted( spans_by_person.get(pid, []), key=lambda a: (a.start_date or date.min, a.end_date or date.min), ): if a.start_date is None or a.end_date is None: continue spans.append({ "start": a.start_date.isoformat(), "end": a.end_date.isoformat(), "days": ( float(a.quantity_days) if a.quantity_days is not None else None ), "category": _category_for(a.time_type), "status": a.approval_status or "unknown", }) data.append({ "email": email, "coverage": coverage, "unavailable_spans": spans, }) # Feed freshness — the most recent absence sync timestamp. Null when the # table is empty (no sync has run yet). as_of_dt = (await db.execute( select(func.max(HrLeaveAbsence.synced_at)).where( HrLeaveAbsence.deleted_at.is_(None), ) )).scalar() as_of = None if as_of_dt is not None: if as_of_dt.tzinfo is None: as_of_dt = as_of_dt.replace(tzinfo=UTC) as_of = as_of_dt.astimezone(UTC).isoformat() return success_response( data=data, request=request, meta={ "as_of": as_of, "from": body.from_.isoformat(), "to": body.to.isoformat(), "window_days": window_days, }, ) __all__ = ["router"]