"""Per-assignment expiry warning evaluator (PR3-2). The ``apikey.expiry_check`` scheduled-job handler iterates every ``status='active'`` :class:`ApiKeyAssignment` once per day; this module holds the per-assignment decision and dispatch. Two thresholds, monotonically lower: * ``threshold_days=7`` — first warning ("your key expires next week") * ``threshold_days=1`` — final warning ("your key expires tomorrow") A 1-day fire **suppresses** the same-day 7-day fire: once we're in the 1-day window, the 7-day card is stale noise. The dedup table :class:`ApiKeyExpiryAlertsDedup` UNIQUE-constrains ``(assignment_id, valid_until_iso, threshold_days)``, so re-running the cron the next day on the same window is a no-op. When admin extends the validity, ``valid_until`` changes → fresh dedup row → cron re-fires in the new window (desired). Notification fan-out: assignee + their direct manager. Failure to reach Feishu / email **does not** roll back the dedup row — the row's purpose is "did we *try* to alert?" If the row landed, the cron will not retry the next day; the user can extend through the standard ``/apikeys/assignments/{id}/extend`` flow regardless of whether the notification card was delivered. This matches the ``apikey_budget_alerts_dedup`` posture (see its docstring on ApiKeyBudgetAlertsDedup). """ from __future__ import annotations import logging import uuid from datetime import UTC, datetime from datetime import date as date_type from typing import Any from sqlalchemy import and_, select from sqlalchemy.ext.asyncio import AsyncSession from app.core.exceptions import format_exception_for_log from app.domains.apikey.models import ( ApiKeyAssignment, ApiKeyExpiryAlertsDedup, ApiKeyProvider, ) from app.identity.person_models import IdentityPerson from app.notifications import NotificationEnvelope, NotificationService logger = logging.getLogger(__name__) # Thresholds in descending order of urgency. Iteration order matters: # we evaluate the most-urgent threshold first and short-circuit on # fire, so a fresh window doesn't fan out both cards on the same tick. _THRESHOLDS: tuple[int, ...] = (1, 7) async def evaluate_assignment( session: AsyncSession, *, assignment: ApiKeyAssignment, today: date_type, ) -> dict[str, Any]: """Check + fire the expiry-warning cards for one assignment. Returns a per-assignment summary dict for the cron's aggregate persistence. Silent skip cases: * Assignment has no ``valid_until`` (perpetual key — admin-created). * ``days_left`` doesn't sit in any threshold window. * The matching dedup row already exists (already alerted in this validity window). * Assignee is offboarding-pending or already left (offboarding cascade handles the key; no point reminding the leaver to extend). """ summary: dict[str, Any] = { "assignment_id": str(assignment.id), "request_number": assignment.request_number, "fired": [], "skipped": [], } valid_until = assignment.valid_until if valid_until is None: summary["skipped"].append({"reason": "no-valid-until"}) return summary days_left = (valid_until - today).days summary["days_left"] = days_left if days_left < 0: # Already past expiry — the enclosing expiry job handles the # durable status + audit transition. Nothing to warn about. summary["skipped"].append({"reason": "already-expired"}) return summary # Pick the most-urgent threshold whose window we're inside. A # ``days_left=1`` fire short-circuits the 7-day fire; ``days_left=7`` # fires only when we haven't already crossed into the 1-day window. matched_threshold: int | None = None for threshold in _THRESHOLDS: if days_left <= threshold: matched_threshold = threshold break if matched_threshold is None: summary["skipped"].append({"reason": f"days-left-{days_left}-no-threshold"}) return summary applicant = await session.get(IdentityPerson, assignment.assigned_to) if applicant is None: summary["skipped"].append({"reason": "applicant-not-found"}) return summary if applicant.employment_status in ("left", "leaver_pending"): summary["skipped"].append({ "reason": f"applicant-{applicant.employment_status}", }) return summary # Dedup check: SELECT-then-INSERT. We previously tried "try INSERT, # catch IntegrityError, rollback" but ``session.rollback()`` wipes # the ENTIRE session — including dedup rows written by earlier # iterations of the same sweep — making the cron quasi-idempotent # at the per-assignment level but non-idempotent at the sweep # level. The cron's outer ``execute_job`` lock serialises ticks # so the SELECT-then-INSERT race window is essentially zero in # practice; the UNIQUE constraint on the table is defence-in-depth # for the future-manual-trigger scenario, where it'd surface as # an IntegrityError aborting that single evaluation cleanly # (caller catches it as a per-assignment error in the sweep). existing_dedup = ( await session.execute( select(ApiKeyExpiryAlertsDedup).where( and_( ApiKeyExpiryAlertsDedup.assignment_id == assignment.id, ApiKeyExpiryAlertsDedup.valid_until_iso == valid_until, ApiKeyExpiryAlertsDedup.threshold_days == matched_threshold, ), ), ) ).scalar_one_or_none() if existing_dedup is not None: summary["skipped"].append({ "reason": "already-alerted", "threshold_days": matched_threshold, }) return summary session.add(ApiKeyExpiryAlertsDedup( id=uuid.uuid4(), assignment_id=assignment.id, valid_until_iso=valid_until, threshold_days=matched_threshold, last_alerted_at=datetime.now(UTC), )) await session.flush() provider = await session.get(ApiKeyProvider, assignment.provider_id) provider_name = provider.name if provider else "(unknown provider)" recipients: list[IdentityPerson] = [applicant] manager_id = applicant.manager_id if manager_id is not None and manager_id != applicant.id: manager = await session.get(IdentityPerson, manager_id) # Round-49: filter out leaver managers so an expiry notification # doesn't land in a deactivated mailbox and tie up the outbox # retry queue (mirrors the equivalent ``employment_status != # 'left'`` filter on the budget-alerts manager lookup). if ( manager is not None and (manager.employment_status or "") != "left" ): recipients.append(manager) delivered: list[dict[str, Any]] = [] notifier = NotificationService(session) for recipient in recipients: try: await notifier.dispatch( NotificationEnvelope( recipient=recipient, title=( f"API Key 即将到期({matched_threshold} 天):" f"{provider_name}" ), body_text=( f"{applicant.display_name or applicant.work_email} " f"的 API Key 申请单 {assignment.request_number}" f"({provider_name})将在 {valid_until.isoformat()} " f"到期(还剩 {days_left} 天)。如果仍在使用,请尽快" "点击 Helix 「我的 API Key」页的「续期」按钮延长有效期。" ), template_key="apikey.expiry_warning", url="/me/api-keys", related_resource_type="api_key_assignment", related_resource_id=assignment.id, context={ # The SAME card goes to the applicant AND their # manager, so the template names the applicant # explicitly rather than saying "your key" — the # manager would read that as their own key. "applicant_name": ( applicant.display_name or applicant.work_email or "" ), "request_number": assignment.request_number, "provider_name": provider_name, "valid_until": valid_until.isoformat(), "days_left": days_left, "threshold_days": matched_threshold, }, ), trace_id=f"apikey.expiry_check:{assignment.id}", ) delivered.append({ "person_id": str(recipient.id), "ok": True, }) except Exception as exc: # noqa: BLE001 — dispatch failures don't reverse dedup logger.exception( "apikey.expiry_check: dispatch failed for assignment " "%s recipient %s", assignment.id, recipient.id, ) delivered.append({ "person_id": str(recipient.id), "ok": False, "error": format_exception_for_log(exc, max_len=200), }) summary["fired"].append({ "threshold_days": matched_threshold, "valid_until": valid_until.isoformat(), "days_left": days_left, "recipients": delivered, }) return summary async def sweep_expiry_alerts( session: AsyncSession, *, today: date_type, ) -> dict[str, Any]: """Cron entrypoint: scan every active assignment, fan out ``evaluate_assignment``, aggregate. Single-row failures don't abort the sweep — they accumulate into ``summary['errors']`` so a single bad assignment doesn't lose the rest of the org's reminders. The cron handler raises if the error count crosses a threshold (see ``handlers.run_apikey_expiry_check``). """ summary: dict[str, Any] = { "today": today.isoformat(), "evaluated": 0, "fired": 0, "skipped": 0, "errors": [], "details": [], } rows = ( await session.execute( select(ApiKeyAssignment).where( ApiKeyAssignment.status == "active", ApiKeyAssignment.valid_until.is_not(None), ), ) ).scalars().all() for assignment in rows: summary["evaluated"] += 1 try: result = await evaluate_assignment( session, assignment=assignment, today=today, ) except Exception as exc: # noqa: BLE001 — keep sweeping logger.exception( "apikey.expiry_check: evaluate failed for %s", assignment.request_number, ) summary["errors"].append({ "request_number": assignment.request_number, "error": format_exception_for_log(exc, max_len=300), }) continue fired_count = len(result.get("fired") or []) if fired_count: summary["fired"] += fired_count else: summary["skipped"] += 1 summary["details"].append({ "request_number": result["request_number"], "fired": result.get("fired") or [], "skipped": result.get("skipped") or [], "days_left": result.get("days_left"), }) # Cap details so a large org doesn't bloat the cron's # last_result_summary column — same posture as # apikey.budget_check (200 row cap). if len(summary["details"]) > 200: summary["details_truncated"] = True summary["details"] = summary["details"][:200] return summary