"""HTTP API for business state monitoring (/v1/biz-state).""" from __future__ import annotations from typing import Any from fastapi import APIRouter, BackgroundTasks, Depends, File, Form, Query, UploadFile 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 execute_enqueued_batch 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 BatchAliasIn(BaseModel): alias: str = "" 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 = "" force_refresh: bool = False 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, force_refresh=bool(body.force_refresh), ) @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.get("/tasks/{task_id}/commands") def api_plan_task_commands( task_id: str, enabled_only: bool = Query(True), include_aux: bool = Query(True), db: Session = Depends(get_db), ) -> dict[str, Any]: """List planned collect CLIs for a task (bindings expanded; no device login).""" return svc.plan_task_collect_commands( db, task_id, enabled_only=enabled_only, include_aux=include_aux ) @router.get("/tasks/{task_id}/commands/export") def api_export_task_commands( task_id: str, enabled_only: bool = Query(True), include_aux: bool = Query(True), db: Session = Depends(get_db), ) -> StreamingResponse: """Download planned collect commands as a .txt file.""" text = svc.export_task_commands_text( db, task_id, enabled_only=enabled_only, include_aux=include_aux ) safe = "".join(ch if ch.isalnum() or ch in "-_" else "_" for ch in task_id)[:40] return StreamingResponse( iter([text.encode("utf-8")]), media_type="text/plain; charset=utf-8", headers={ "Content-Disposition": f'attachment; filename="biz_state_commands_{safe}.txt"' }, ) @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.post("/tasks/{task_id}/pause") def api_pause_task(task_id: str, db: Session = Depends(get_db)) -> dict[str, Any]: """Pause periodic schedule; manual collect remains allowed.""" return svc.set_task_status(db, task_id, "paused") @router.post("/tasks/{task_id}/start") def api_start_task(task_id: str, db: Session = Depends(get_db)) -> dict[str, Any]: """Enable periodic schedule (requires bindings for non–cutover-HF tasks).""" return svc.set_task_status(db, task_id, "running") @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} tid = task_id # Enqueue synchronously so collect_running flips before the HTTP response # (schedule may stay paused; manual collect is still allowed). from .biz_state.claim import enqueue_collect result = enqueue_collect(tid, manual=True) if not result.get("queued"): return { "ok": bool(result.get("ok", False)), "started": False, "queued": False, "reason": result.get("reason") or "enqueue_failed", "task_id": tid, } batch_id = str(result.get("batch_id") or "") if batch_id: background_tasks.add_task( lambda bid=batch_id, t=tid: execute_enqueued_batch(batch_id=bid, task_id=t) ) return { "ok": True, "started": True, "queued": True, "batch_id": batch_id, "task_id": tid, "collect_running": True, } @router.post("/tasks/{task_id}/collect/stop") def api_collect_stop(task_id: str, db: Session = Depends(get_db)) -> dict[str, Any]: """Stop queued or running collect for this task (best-effort mid-command).""" from .biz_state.collect_stop import request_stop_collect task = db.get(BizStateTask, task_id) if not task: from fastapi import HTTPException raise HTTPException(status_code=404, detail="task not found") return request_stop_collect(task_id) @router.post("/import") async def api_import_log_standalone( background_tasks: BackgroundTasks, file: UploadFile = File(...), vendor_key: str = Form("zte"), ne_name: str = Form(""), note: str = Form(""), ) -> dict[str, Any]: """Standalone offline import: create an import task (no NE) and parse the log.""" from fastapi import HTTPException from .biz_state.import_runner import execute_import, start_standalone_import from .biz_state.log_split import import_max_bytes, read_upload_stream fname = str(file.filename or "import.log") try: data = read_upload_stream(file.file, max_bytes=import_max_bytes()) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc result = start_standalone_import( filename=fname, data=data, vendor_key=str(vendor_key or "").strip() or "zte", ne_name=str(ne_name or "").strip(), note=str(note or "").strip(), ) if not result.get("queued"): return { "ok": bool(result.get("ok", False)), "started": False, "queued": False, "reason": result.get("reason") or "enqueue_failed", "task_id": result.get("task_id") or "", "created_task": bool(result.get("created_task")), "segments_preview": result.get("segments_preview") or {"files": result.get("files"), "segments": result.get("segments")}, } segments = result.pop("segments", []) bid = str(result.get("batch_id") or "") tid = str(result.get("task_id") or "") vk = str(result.get("vendor_key") or "zte") vendor = str(result.get("vendor") or "") device_type = str(result.get("device_type") or "") background_tasks.add_task( lambda: execute_import( batch_id=bid, task_id=tid, segments=list(segments), vendor_key=vk, vendor=vendor, device_type=device_type, filename=fname, ) ) return { "ok": True, "started": True, "queued": True, "batch_id": bid, "task_id": tid, "vendor_key": vk, "filename": result.get("filename") or fname, "ne_name": result.get("ne_name") or "", "created_task": True, "segments_preview": result.get("segments_preview") or {}, "collect_running": True, } @router.post("/tasks/{task_id}/import") async def api_import_log( task_id: str, background_tasks: BackgroundTasks, file: UploadFile = File(...), vendor_key: str = Form(""), db: Session = Depends(get_db), ) -> dict[str, Any]: """Upload a CLI transcript into an existing task and parse offline.""" from fastapi import HTTPException from .biz_state.import_runner import execute_import, start_import_from_upload from .biz_state.log_split import import_max_bytes, read_upload_stream task = db.get(BizStateTask, task_id) if not task: 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, } fname = str(file.filename or "import.log") try: data = read_upload_stream(file.file, max_bytes=import_max_bytes()) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc result = start_import_from_upload( task_id=task_id, filename=fname, data=data, vendor_key=str(vendor_key or "").strip(), ) if not result.get("queued"): return { "ok": bool(result.get("ok", False)), "started": False, "queued": False, "reason": result.get("reason") or "enqueue_failed", "task_id": task_id, "segments_preview": result.get("segments_preview") or result.get("files"), } segments = result.pop("segments", []) bid = str(result.get("batch_id") or "") vk = str(result.get("vendor_key") or "") vendor = str(result.get("vendor") or "") device_type = str(result.get("device_type") or "") background_tasks.add_task( lambda: execute_import( batch_id=bid, task_id=task_id, segments=list(segments), vendor_key=vk, vendor=vendor, device_type=device_type, filename=fname, ) ) return { "ok": True, "started": True, "queued": True, "batch_id": bid, "task_id": task_id, "vendor_key": vk, "filename": result.get("filename") or fname, "segments_preview": result.get("segments_preview") or {}, "collect_running": True, } @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.patch("/batches/{batch_id}/alias") def api_set_batch_alias( batch_id: str, body: BatchAliasIn, db: Session = Depends(get_db) ) -> dict[str, Any]: return svc.set_batch_alias(db, batch_id, alias=str(body.alias or "")) @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}/metrics/{metric_id}") def api_list_batch_metric_rows( batch_id: str, metric_id: str, page: int = Query(1, ge=1), page_size: int = Query(50, ge=1, le=200), kw: str = Query(""), column: str = Query(""), db: Session = Depends(get_db), ) -> dict[str, Any]: return svc.list_batch_metric_rows( db, batch_id, metric_id, page=page, page_size=page_size, kw=kw, column=column, ) @router.get("/batches/{batch_id}/commands/{command_id}") def api_get_batch_command( batch_id: str, command_id: str, db: Session = Depends(get_db) ) -> dict[str, Any]: return svc.get_batch_command(db, batch_id, command_id) @router.get("/batches/{batch_id}/commands/{command_id}/raw.txt") def api_download_batch_command_raw( batch_id: str, command_id: str, db: Session = Depends(get_db) ) -> StreamingResponse: """Download one command's raw CLI output as plain text.""" detail = svc.get_batch_command(db, batch_id, command_id) raw = str(detail.get("raw_text") or "") cmd = str(detail.get("raw_command") or "command") safe = "".join(ch if ch.isalnum() or ch in "-_." else "_" for ch in cmd)[:80] or "command" filename = f"{command_id}_{safe}.txt" return StreamingResponse( iter([raw.encode("utf-8")]), media_type="text/plain; charset=utf-8", headers={"Content-Disposition": f'attachment; filename="{filename}"'}, ) @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 # Split sheets share metric_id; identity is sheet_id (export/import must keep both). sheet_id: str = "" title: 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" # Empty = all template sheets; non-empty = only these sheet_id values enabled_sheet_ids: list[str] = Field(default_factory=list) # auto|always|never|sample|keys — how to persist matching (success) rows store_unchanged: str = "auto" 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, sync: bool = Query(False, description="If true, block until compare finishes (tests/debug)"), db: Session = Depends(get_db), ) -> dict[str, Any]: """Start a compare run. Default is async (returns status=running immediately).""" if sync: return cmp_svc.run_compare(db, job_id) return cmp_svc.enqueue_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.post("/compare/runs/{run_id}/cancel") def api_cancel_run(run_id: str, db: Session = Depends(get_db)) -> dict[str, Any]: """Cancel a stuck/running compare so a new run can start.""" return cmp_svc.cancel_compare_run(db, run_id) @router.delete("/compare/runs/{run_id}") def api_delete_run(run_id: str, db: Session = Depends(get_db)) -> dict[str, Any]: return cmp_svc.delete_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 = "", qf: 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, qf=qf, 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"'}, )