"""HTTP API for business state monitoring (/v1/biz-state).""" from __future__ import annotations from typing import Any from fastapi import APIRouter, BackgroundTasks, Depends from fastapi.responses import StreamingResponse from pydantic import BaseModel, Field from sqlalchemy.orm import Session from .db import get_db from .biz_state import service as svc from .biz_state.collect_runner import dispatch_collect from .lldp_shared import resolve_vendor_key from .models import BizStateTask router = APIRouter(prefix="/v1/biz-state", tags=["biz-state"]) class ProfileOverrideIn(BaseModel): title: str | None = None command_template: str | None = None description: str | None = None sample_output: str | None = None enabled: bool | None = None class TaskItemIn(BaseModel): source_profile_id: str = "" kind: str = "catalog" enabled: bool = True title: str = "" command_override: str = "" sort_order: int | None = None bindings: list[dict[str, str]] = Field(default_factory=list) class TaskCreateIn(BaseModel): source: str = "managed" ne_id: str ne_name: str = "" ne_ip: str = "" vendor: str = "" device_type: str = "" note: str = "" purpose: str = "" status: str = "draft" interval_sec: int = 3600 retention_days: int = 30 daily_keep_enabled: bool = False daily_keep_count: int = 10 items: list[TaskItemIn] = Field(default_factory=list) class TaskPatchIn(BaseModel): note: str | None = None purpose: str | None = None interval_sec: int | None = None retention_days: int | None = None daily_keep_enabled: bool | None = None daily_keep_count: int | None = None status: str | None = None items: list[TaskItemIn] | None = None class BatchBaselineIn(BaseModel): marked: bool = True class BatchBulkDeleteIn(BaseModel): batch_ids: list[str] = Field(default_factory=list) class PreviewIn(BaseModel): vendor: str = "" device_type: str = "" items: list[TaskItemIn] = Field(default_factory=list) class DiscoverIn(BaseModel): source: str = "managed" ne_id: str = "" task_id: str = "" discover_profile_id: str = "" collect_profile_id: str = "" placeholder: str = "" class BindingsIn(BaseModel): bindings: list[dict[str, str]] = Field(default_factory=list) @router.get("/profiles") def api_list_profiles( vendor: str = "", device_type: str = "", kind: str = "", db: Session = Depends(get_db), ) -> dict[str, Any]: vkey = resolve_vendor_key(vendor, device_type) if (vendor or device_type) else "" items = svc.list_profiles_public(db, vendor_key=vkey) if kind: items = [p for p in items if str(p.get("kind") or "") == kind] return {"items": items} @router.patch("/profiles/{profile_id}") def api_patch_profile( profile_id: str, body: ProfileOverrideIn, db: Session = Depends(get_db), ) -> dict[str, Any]: return svc.upsert_profile_override(db, profile_id, body.model_dump(exclude_unset=True)) @router.post("/tasks/preview-items") def api_preview_items( body: PreviewIn, db: Session = Depends(get_db), ) -> dict[str, Any]: items = [i.model_dump() for i in body.items] return { "items": svc.preview_items( db, vendor=body.vendor, device_type=body.device_type, items=items ) } @router.post("/discover") def api_discover(body: DiscoverIn, db: Session = Depends(get_db)) -> dict[str, Any]: from .biz_state.discover import discover_params return discover_params( db, source=body.source, ne_id=body.ne_id, task_id=body.task_id, discover_profile_id=body.discover_profile_id, collect_profile_id=body.collect_profile_id, placeholder=body.placeholder, ) @router.put("/tasks/{task_id}/items/{item_id}/bindings") def api_set_bindings( task_id: str, item_id: str, body: BindingsIn, db: Session = Depends(get_db), ) -> dict[str, Any]: return svc.set_item_bindings(db, task_id, item_id, list(body.bindings or [])) @router.get("/tasks") def api_list_tasks(purpose: str = "", db: Session = Depends(get_db)) -> dict[str, Any]: return {"items": svc.list_tasks(db, purpose=purpose or None)} @router.post("/tasks") def api_create_task(body: TaskCreateIn, db: Session = Depends(get_db)) -> dict[str, Any]: return svc.create_task(db, body.model_dump()) @router.get("/tasks/{task_id}") def api_get_task(task_id: str, db: Session = Depends(get_db)) -> dict[str, Any]: return svc.get_task(db, task_id) @router.patch("/tasks/{task_id}") def api_patch_task( task_id: str, body: TaskPatchIn, db: Session = Depends(get_db) ) -> dict[str, Any]: return svc.update_task(db, task_id, body.model_dump(exclude_unset=True)) @router.delete("/tasks/{task_id}") def api_delete_task(task_id: str, db: Session = Depends(get_db)) -> dict[str, Any]: svc.delete_task(db, task_id) return {"ok": True} @router.post("/tasks/{task_id}/collect") def api_collect_now( task_id: str, background_tasks: BackgroundTasks, db: Session = Depends(get_db), ) -> dict[str, Any]: task = db.get(BizStateTask, task_id) if not task: from fastapi import HTTPException raise HTTPException(status_code=404, detail="task not found") if bool(task.collect_running): return {"ok": True, "started": False, "reason": "already_collecting", "task_id": task_id} # Allow one-shot from draft/paused task.last_collect_ended_at = None db.commit() background_tasks.add_task(dispatch_collect, task_id) return {"ok": True, "started": True, "task_id": task_id} @router.get("/tasks/{task_id}/batches") def api_list_batches( task_id: str, limit: int = 50, db: Session = Depends(get_db) ) -> dict[str, Any]: return {"items": svc.list_batches(db, task_id, limit=limit)} @router.post("/tasks/{task_id}/purge") def api_purge_task(task_id: str, db: Session = Depends(get_db)) -> dict[str, Any]: """Apply retention policy now (age + optional daily keep). Protected batches skipped.""" return svc.run_purge_for_task(db, task_id) @router.post("/batches/bulk-delete") def api_bulk_delete_batches( body: BatchBulkDeleteIn, db: Session = Depends(get_db) ) -> dict[str, Any]: return svc.delete_batches_bulk(db, list(body.batch_ids or [])) @router.post("/batches/{batch_id}/baseline") def api_set_batch_baseline( batch_id: str, body: BatchBaselineIn, db: Session = Depends(get_db) ) -> dict[str, Any]: return svc.set_batch_baseline(db, batch_id, marked=bool(body.marked)) @router.delete("/batches/{batch_id}") def api_delete_batch(batch_id: str, db: Session = Depends(get_db)) -> dict[str, Any]: return svc.delete_batch(db, batch_id) @router.get("/batches/{batch_id}") def api_get_batch(batch_id: str, db: Session = Depends(get_db)) -> dict[str, Any]: return svc.get_batch(db, batch_id) @router.get("/batches/{batch_id}/export") def api_export_batch(batch_id: str, db: Session = Depends(get_db)) -> StreamingResponse: data = svc.export_batch_zip(db, batch_id) return StreamingResponse( iter([data]), media_type="application/zip", headers={"Content-Disposition": f'attachment; filename="biz_state_{batch_id}.zip"'}, ) # ---- Phase2: templates / port maps / compare jobs ---- from .biz_state import compare_service as cmp_svc # noqa: E402 class FieldRuleIn(BaseModel): field: str compare: str = "" normalize: str = "" ignore: bool = False tolerance: float | None = None class RowFilterIn(BaseModel): """Leaf or nested (any/all) row filter — kept open for nested dicts.""" model_config = {"extra": "allow"} field: str | None = None op: str | None = None value: Any = None any: list[dict[str, Any]] | None = None all: list[dict[str, Any]] | None = None class TemplateMetricIn(BaseModel): metric_id: str key_fields: list[str] = Field(default_factory=list) iface_fields: list[str] = Field(default_factory=list) compare_fields: list[str] = Field(default_factory=list) display_fields: list[str] = Field(default_factory=list) row_filters: list[dict[str, Any]] = Field(default_factory=list) field_rules: list[FieldRuleIn] = Field(default_factory=list) class TemplateIn(BaseModel): name: str = "" note: str = "" # Preferred: multi-metric sheets metrics: list[TemplateMetricIn] | None = None # Template-owned interface type aliases (GE→gei, …) iface_normalize_rules: list[dict[str, str]] | None = None # Legacy single-metric fields (still accepted) metric_id: str = "lldp_neighbor" key_fields: list[str] = Field(default_factory=list) iface_fields: list[str] = Field(default_factory=list) compare_fields: list[str] = Field(default_factory=list) ignore_fields: list[str] = Field(default_factory=list) display_fields: list[str] = Field(default_factory=list) row_filters: list[dict[str, Any]] = Field(default_factory=list) field_rules: list[FieldRuleIn] = Field(default_factory=list) class TemplatePatchIn(BaseModel): name: str | None = None note: str | None = None metrics: list[TemplateMetricIn] | None = None iface_normalize_rules: list[dict[str, str]] | None = None metric_id: str | None = None key_fields: list[str] | None = None iface_fields: list[str] | None = None compare_fields: list[str] | None = None ignore_fields: list[str] | None = None display_fields: list[str] | None = None row_filters: list[dict[str, Any]] | None = None field_rules: list[FieldRuleIn] | None = None class MappingRowIn(BaseModel): before_if: str after_if: str class MappingIn(BaseModel): name: str = "" note: str = "" rows: list[MappingRowIn] = Field(default_factory=list) class ValidateMappingIn(BaseModel): mapping_id: str before_batch_id: str after_batch_id: str template_id: str = "" class CompareJobIn(BaseModel): name: str = "" template_id: str = "" mapping_id: str = "" before_task_id: str = "" after_task_id: str = "" before_batch_id: str = "" after_batch_id: str = "" mode: str = "manual" note: str = "" @router.get("/compare/metrics") def api_list_compare_metrics() -> dict[str, Any]: return { "items": cmp_svc.list_metric_schemas(), "row_filter_presets": cmp_svc.list_row_filter_presets(), } @router.get("/compare/templates") def api_list_templates(db: Session = Depends(get_db)) -> dict[str, Any]: return {"items": cmp_svc.list_templates(db)} @router.post("/compare/templates") def api_create_template(body: TemplateIn, db: Session = Depends(get_db)) -> dict[str, Any]: return cmp_svc.create_template(db, body.model_dump()) @router.patch("/compare/templates/{template_id}") def api_patch_template( template_id: str, body: TemplatePatchIn, db: Session = Depends(get_db) ) -> dict[str, Any]: return cmp_svc.update_template(db, template_id, body.model_dump(exclude_unset=True)) @router.delete("/compare/templates/{template_id}") def api_delete_template(template_id: str, db: Session = Depends(get_db)) -> dict[str, Any]: cmp_svc.delete_template(db, template_id) return {"ok": True} @router.get("/compare/mappings") def api_list_mappings(db: Session = Depends(get_db)) -> dict[str, Any]: return {"items": cmp_svc.list_mappings(db)} @router.post("/compare/mappings") def api_create_mapping(body: MappingIn, db: Session = Depends(get_db)) -> dict[str, Any]: return cmp_svc.create_mapping(db, body.model_dump()) @router.patch("/compare/mappings/{mapping_id}") def api_patch_mapping( mapping_id: str, body: MappingIn, db: Session = Depends(get_db) ) -> dict[str, Any]: return cmp_svc.update_mapping(db, mapping_id, body.model_dump(exclude_unset=True)) @router.delete("/compare/mappings/{mapping_id}") def api_delete_mapping(mapping_id: str, db: Session = Depends(get_db)) -> dict[str, Any]: cmp_svc.delete_mapping(db, mapping_id) return {"ok": True} @router.post("/compare/mappings/validate") def api_validate_mapping(body: ValidateMappingIn, db: Session = Depends(get_db)) -> dict[str, Any]: return cmp_svc.validate_mapping( db, mapping_id=body.mapping_id, before_batch_id=body.before_batch_id, after_batch_id=body.after_batch_id, template_id=body.template_id, ) @router.get("/compare/jobs") def api_list_jobs(db: Session = Depends(get_db)) -> dict[str, Any]: return {"items": cmp_svc.list_jobs(db)} @router.post("/compare/jobs") def api_create_job(body: CompareJobIn, db: Session = Depends(get_db)) -> dict[str, Any]: return cmp_svc.create_job(db, body.model_dump()) @router.patch("/compare/jobs/{job_id}") def api_patch_job(job_id: str, body: CompareJobIn, db: Session = Depends(get_db)) -> dict[str, Any]: return cmp_svc.update_job(db, job_id, body.model_dump(exclude_unset=True)) @router.delete("/compare/jobs/{job_id}") def api_delete_job(job_id: str, db: Session = Depends(get_db)) -> dict[str, Any]: cmp_svc.delete_job(db, job_id) return {"ok": True} @router.post("/compare/jobs/{job_id}/run") def api_run_job(job_id: str, db: Session = Depends(get_db)) -> dict[str, Any]: return cmp_svc.run_compare(db, job_id) @router.get("/compare/jobs/{job_id}/runs") def api_list_runs(job_id: str, limit: int = 20, db: Session = Depends(get_db)) -> dict[str, Any]: return {"items": cmp_svc.list_runs(db, job_id, limit=limit)} @router.get("/compare/runs/{run_id}") def api_get_run(run_id: str, db: Session = Depends(get_db)) -> dict[str, Any]: return cmp_svc.get_run(db, run_id) @router.get("/compare/runs/{run_id}/diffs") def api_list_run_diffs( run_id: str, metric_id: str = "", kind: str = "diff", kw: str = "", page: int = 1, page_size: int = 100, db: Session = Depends(get_db), ) -> dict[str, Any]: return cmp_svc.list_run_diffs( db, run_id, metric_id=metric_id, kind=kind, kw=kw, page=page, page_size=page_size, ) @router.get("/compare/runs/{run_id}/export") def api_export_run(run_id: str, db: Session = Depends(get_db)) -> StreamingResponse: data = cmp_svc.export_run_zip(db, run_id) return StreamingResponse( iter([data]), media_type="application/zip", headers={"Content-Disposition": f'attachment; filename="biz_compare_{run_id}.zip"'}, )