"""HTTP API for cutover migration monitor (/v1/biz-migration).""" from __future__ import annotations from typing import Any from fastapi import APIRouter, Depends from pydantic import BaseModel, Field from sqlalchemy.orm import Session from .biz_migration import service as svc from .biz_migration import monitor_templates as mon_tpl from .db import get_db router = APIRouter(prefix="/v1/biz-migration", tags=["biz-migration"]) class ProjectIn(BaseModel): name: str old_task_id: str = "" new_task_id: str = "" old_hf_task_id: str = "" new_hf_task_id: str = "" old_baseline_batch_id: str = "" new_baseline_batch_id: str = "" mapping_id: str = "" mapping_name: str = "" mapping_rows: list[dict[str, Any]] = Field(default_factory=list) monitor_template_id: str = "" collect_metric_ids: list[str] = Field(default_factory=list) metric_interval_sec: dict[str, int] = Field(default_factory=dict) hf_interval_sec: int = 60 hf_start_at: str | None = None hf_end_at: str | None = None create_hf: bool = True collect_now: bool = False retention_days: int = 7 old_ne: dict[str, Any] | None = None new_ne: dict[str, Any] | None = None status: str = "active" note: str = "" class ProjectPatchIn(BaseModel): name: str | None = None old_task_id: str | None = None new_task_id: str | None = None old_hf_task_id: str | None = None new_hf_task_id: str | None = None old_baseline_batch_id: str | None = None new_baseline_batch_id: str | None = None mapping_id: str | None = None monitor_template_id: str | None = None collect_metric_ids: list[str] | None = None metric_interval_sec: dict[str, int] | None = None hf_interval_sec: int | None = None hf_start_at: str | None = None hf_end_at: str | None = None status: str | None = None note: str | None = None class BatchIn(BaseModel): batch_label: str = "batch" expect_set: dict[str, Any] = Field(default_factory=dict) status: str = "pending" note: str = "" class MonitorTemplateIn(BaseModel): name: str = "" compare_template_id: str = "" collect_metric_ids: list[str] = Field(default_factory=list) defaults: dict[str, Any] = Field(default_factory=dict) sheet_overrides: list[dict[str, Any]] = Field(default_factory=list) note: str = "" class MonitorTemplatePatchIn(BaseModel): name: str | None = None compare_template_id: str | None = None collect_metric_ids: list[str] | None = None defaults: dict[str, Any] | None = None sheet_overrides: list[dict[str, Any]] | None = None note: str | None = None class BatchPatchIn(BaseModel): batch_label: str | None = None expect_set: dict[str, Any] | None = None status: str | None = None note: str | None = None class EvaluateIn(BaseModel): old_batch_id: str = "" new_batch_id: str = "" @router.get("/projects") def api_list_projects(db: Session = Depends(get_db)): return {"items": svc.list_projects(db)} @router.get("/ne-portrait") def api_ne_portrait( source: str = "managed", ne_id: str = "", limit: int = 30, db: Session = Depends(get_db), ): """Portrait biz_state task + recent batches for a NE (baseline picker).""" if not str(ne_id or "").strip(): return {"source": source, "ne_id": "", "task": None, "batches": [], "hint": "ne_id_required"} return svc.list_ne_portrait_options(db, source=source, ne_id=ne_id, limit=limit) @router.post("/projects") def api_create_project(body: ProjectIn, db: Session = Depends(get_db)): return svc.create_project(db, body.model_dump()) @router.get("/projects/{project_id}") def api_get_project(project_id: str, db: Session = Depends(get_db)): return svc.get_project(db, project_id) @router.get("/monitor-context") def api_monitor_context( project_id: str = "", task_id: str = "", db: Session = Depends(get_db), ): """Fat definition bundle for AI / ops (templates, mapping, tasks).""" return svc.get_monitor_context(db, project_id=project_id, task_id=task_id) @router.patch("/projects/{project_id}") def api_patch_project(project_id: str, body: ProjectPatchIn, db: Session = Depends(get_db)): return svc.patch_project(db, project_id, body.model_dump(exclude_unset=True)) @router.delete("/projects/{project_id}") def api_delete_project(project_id: str, db: Session = Depends(get_db)): return svc.delete_project(db, project_id) @router.get("/projects/{project_id}/baseline-ports") def api_baseline_ports(project_id: str, db: Session = Depends(get_db)): """List old-baseline interface_brief ports for expect-set selection.""" return svc.list_baseline_ports(db, project_id) @router.get("/projects/{project_id}/baseline-expect") def api_baseline_expect(project_id: str, db: Session = Depends(get_db)): """Multi-metric baseline keys for expect-set picking (from monitor template sheets).""" return svc.list_baseline_expect_objects(db, project_id) class EnsureHighfreqIn(BaseModel): interval_sec: int | None = None retention_days: int = 7 collect_now: bool = True old_ne: dict[str, Any] | None = None new_ne: dict[str, Any] | None = None collect_metric_ids: list[str] | None = None hf_start_at: str | None = None hf_end_at: str | None = None @router.post("/projects/{project_id}/ensure-port-highfreq") @router.post("/projects/{project_id}/ensure-highfreq") def api_ensure_port_highfreq( project_id: str, body: EnsureHighfreqIn | None = None, db: Session = Depends(get_db), ): """Create/bind HF biz_state tasks into hf slots (portrait slots untouched).""" payload = body.model_dump(exclude_unset=True) if body else {} # Persist schedule / collect override before ensure patch: dict[str, Any] = {} if "collect_metric_ids" in payload and payload["collect_metric_ids"] is not None: patch["collect_metric_ids"] = payload["collect_metric_ids"] if "hf_start_at" in payload: patch["hf_start_at"] = payload.get("hf_start_at") if "hf_end_at" in payload: patch["hf_end_at"] = payload.get("hf_end_at") if "interval_sec" in payload and payload["interval_sec"] is not None: patch["hf_interval_sec"] = payload["interval_sec"] if patch: svc.patch_project(db, project_id, patch) return svc.ensure_highfreq( db, project_id, interval_sec=payload.get("interval_sec"), retention_days=int(payload.get("retention_days") or 7), collect_now=bool(payload.get("collect_now", True)), old_ne=payload.get("old_ne"), new_ne=payload.get("new_ne"), ) @router.post("/projects/{project_id}/collect-now") def api_collect_now(project_id: str, db: Session = Depends(get_db)): """Trigger immediate collect on project's bound biz_state tasks.""" return svc.collect_project_now(db, project_id) @router.get("/projects/{project_id}/batches") def api_list_batches(project_id: str, db: Session = Depends(get_db)): return {"items": svc.list_batches(db, project_id)} @router.post("/projects/{project_id}/batches") def api_create_batch(project_id: str, body: BatchIn, db: Session = Depends(get_db)): return svc.create_batch(db, project_id, body.model_dump()) @router.post("/batches/{batch_id}/finish") def api_finish_batch( batch_id: str, mark_done: bool = False, db: Session = Depends(get_db), ): """本批完成:终验 + 小结 + 红单(允许带红继续).""" return svc.finish_batch(db, batch_id, mark_done=mark_done) @router.get("/projects/{project_id}/red-tickets") def api_list_red_tickets( project_id: str, status: str = "", limit: int = 200, db: Session = Depends(get_db), ): return svc.list_red_tickets(db, project_id, status=status, limit=limit) class RedTicketPatchIn(BaseModel): note: str = "" @router.post("/red-tickets/{ticket_id}/resolve") def api_resolve_red_ticket( ticket_id: str, body: RedTicketPatchIn | None = None, db: Session = Depends(get_db), ): note = body.note if body else "" return svc.resolve_red_ticket(db, ticket_id, note=note) @router.patch("/batches/{batch_id}") def api_patch_batch(batch_id: str, body: BatchPatchIn, db: Session = Depends(get_db)): return svc.patch_batch(db, batch_id, body.model_dump(exclude_unset=True)) @router.post("/batches/{batch_id}/evaluate") def api_evaluate(batch_id: str, body: EvaluateIn | None = None, db: Session = Depends(get_db)): payload = body.model_dump() if body else {} return svc.run_evaluate( db, batch_id=batch_id, old_batch_id=str(payload.get("old_batch_id") or ""), new_batch_id=str(payload.get("new_batch_id") or ""), ) @router.get("/batches/{batch_id}/runs") def api_list_runs(batch_id: str, limit: int = 20, db: Session = Depends(get_db)): return {"items": svc.list_runs(db, batch_id, limit=limit)} @router.get("/batches/{batch_id}/board") def api_board(batch_id: str, run_id: str = "", db: Session = Depends(get_db)): return svc.board(db, batch_id, run_id=run_id) @router.get("/runs/{run_id}") def api_get_run(run_id: str, db: Session = Depends(get_db)): return svc.get_run(db, run_id) @router.get("/runs/{run_id}/diffs") def api_list_diffs( run_id: str, metric_id: str = "", verdict: str = "", color: str = "", kw: str = "", offset: int = 0, limit: int = 100, db: Session = Depends(get_db), ): return svc.list_run_diffs( db, run_id, metric_id=metric_id, verdict=verdict, color=color, kw=kw, offset=offset, limit=limit, ) @router.get("/monitor-templates") def api_list_monitor_templates(db: Session = Depends(get_db)): return {"items": mon_tpl.list_monitor_templates(db)} @router.post("/monitor-templates") def api_create_monitor_template(body: MonitorTemplateIn, db: Session = Depends(get_db)): return mon_tpl.create_monitor_template(db, body.model_dump()) @router.get("/monitor-templates/{template_id}") def api_get_monitor_template(template_id: str, db: Session = Depends(get_db)): return mon_tpl.get_monitor_template(db, template_id) @router.patch("/monitor-templates/{template_id}") def api_patch_monitor_template( template_id: str, body: MonitorTemplatePatchIn, db: Session = Depends(get_db), ): return mon_tpl.update_monitor_template( db, template_id, body.model_dump(exclude_unset=True) ) @router.delete("/monitor-templates/{template_id}") def api_delete_monitor_template(template_id: str, db: Session = Depends(get_db)): mon_tpl.delete_monitor_template(db, template_id) return {"ok": True}