mirror of
https://github.com/hansjone/netx.git
synced 2026-10-08 23:33:21 +08:00
790 lines
25 KiB
Python
790 lines
25 KiB
Python
"""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"'},
|
||
)
|