mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 02:00:46 +08:00
1342 lines
46 KiB
Python
1342 lines
46 KiB
Python
"""Compare templates, port mappings, jobs, and runs."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
import json
|
|
import zipfile
|
|
from datetime import datetime
|
|
from typing import Any
|
|
from uuid import uuid4
|
|
|
|
from fastapi import HTTPException
|
|
from sqlalchemy.orm import Session
|
|
|
|
from ..models import (
|
|
BizCompareDiff,
|
|
BizCompareJob,
|
|
BizCompareRun,
|
|
BizCompareTemplate,
|
|
BizPortMapping,
|
|
BizPortMappingRow,
|
|
BizStateBatch,
|
|
BizStateLldpNeighbor,
|
|
)
|
|
from ..timeutil import utcnow_naive
|
|
from .compare_engine import compare_rows, mapping_stats
|
|
from .profiles import metric_field_map
|
|
|
|
|
|
def _utcnow() -> datetime:
|
|
return utcnow_naive()
|
|
|
|
|
|
_DIFF_CHUNK = 2000
|
|
_SEARCH_TEXT_MAX = 4000
|
|
|
|
|
|
def _diff_search_text(d: dict[str, Any]) -> str:
|
|
parts = [str(d.get("kind") or "")]
|
|
for key in ("key", "before", "after", "mapped_before", "changes"):
|
|
val = d.get(key)
|
|
if val:
|
|
try:
|
|
parts.append(json.dumps(val, ensure_ascii=False, default=str, separators=(",", ":")))
|
|
except Exception:
|
|
parts.append(str(val))
|
|
return " ".join(parts)[:_SEARCH_TEXT_MAX]
|
|
|
|
|
|
def _top_changed_fields(diffs: list[dict[str, Any]], *, limit: int = 8) -> list[dict[str, Any]]:
|
|
field_counts: dict[str, int] = {}
|
|
for d in diffs:
|
|
if str(d.get("kind") or "") != "changed":
|
|
continue
|
|
for fname in d.get("changes") or {}:
|
|
field_counts[str(fname)] = field_counts.get(str(fname), 0) + 1
|
|
return sorted(
|
|
[{"field": k, "count": v} for k, v in field_counts.items()],
|
|
key=lambda x: (-int(x["count"]), str(x["field"])),
|
|
)[:limit]
|
|
|
|
|
|
def _persist_sheet_diffs(
|
|
db: Session,
|
|
*,
|
|
run_id: str,
|
|
metric_id: str,
|
|
diffs: list[dict[str, Any]],
|
|
) -> None:
|
|
"""Bulk-insert diff rows; avoids embedding million-row arrays in summary_json."""
|
|
buf: list[dict[str, Any]] = []
|
|
for i, d in enumerate(diffs):
|
|
buf.append(
|
|
{
|
|
"id": uuid4().hex,
|
|
"run_id": run_id,
|
|
"metric_id": metric_id,
|
|
"seq": i,
|
|
"kind": str(d.get("kind") or ""),
|
|
"key_json": dict(d.get("key") or {}),
|
|
"before_json": dict(d.get("before") or {}),
|
|
"after_json": dict(d.get("after") or {}),
|
|
"mapped_before_json": dict(d.get("mapped_before") or {}),
|
|
"changes_json": dict(d.get("changes") or {}),
|
|
"search_text": _diff_search_text(d),
|
|
}
|
|
)
|
|
if len(buf) >= _DIFF_CHUNK:
|
|
db.bulk_insert_mappings(BizCompareDiff, buf)
|
|
buf.clear()
|
|
if buf:
|
|
db.bulk_insert_mappings(BizCompareDiff, buf)
|
|
|
|
|
|
def _diff_row_out(r: BizCompareDiff) -> dict[str, Any]:
|
|
return {
|
|
"kind": r.kind,
|
|
"key": r.key_json or {},
|
|
"before": r.before_json or {},
|
|
"after": r.after_json or {},
|
|
"mapped_before": r.mapped_before_json or {},
|
|
"changes": r.changes_json or {},
|
|
}
|
|
|
|
|
|
def _run_has_diff_rows(db: Session, run_id: str) -> bool:
|
|
return (
|
|
db.query(BizCompareDiff.id).filter(BizCompareDiff.run_id == run_id).limit(1).first()
|
|
is not None
|
|
)
|
|
|
|
|
|
def _filter_inline_diffs(
|
|
diffs: list[dict[str, Any]],
|
|
*,
|
|
kind: str,
|
|
kw: str,
|
|
) -> list[dict[str, Any]]:
|
|
kind_n = (kind or "diff").strip().lower()
|
|
kw_n = (kw or "").strip().lower()
|
|
out: list[dict[str, Any]] = []
|
|
for d in diffs:
|
|
dk = str(d.get("kind") or "")
|
|
if kind_n == "diff":
|
|
if dk == "unchanged":
|
|
continue
|
|
elif kind_n != "all" and dk != kind_n:
|
|
continue
|
|
if kw_n:
|
|
blob = _diff_search_text(d).lower()
|
|
if kw_n not in blob:
|
|
continue
|
|
out.append(d)
|
|
return out
|
|
|
|
|
|
def _sheet_meta_from_summary(summary: dict[str, Any], run: BizCompareRun, tpl: Any) -> list[dict[str, Any]]:
|
|
sheets = list(summary.get("sheets") or [])
|
|
if sheets:
|
|
return sheets
|
|
return [
|
|
{
|
|
"metric_id": run.metric_id,
|
|
"key_fields": list((tpl.key_fields if tpl else None) or []),
|
|
"iface_fields": list((tpl.iface_fields if tpl else None) or []),
|
|
"compare_fields": list((tpl.compare_fields if tpl else None) or []),
|
|
"mode": "fields",
|
|
"summary": {
|
|
k: summary.get(k, 0)
|
|
for k in ("added", "removed", "changed", "unchanged", "before_count", "after_count")
|
|
},
|
|
"diffs": list(run.diffs_json or []),
|
|
}
|
|
]
|
|
|
|
|
|
def _str_list(raw: Any) -> list[str]:
|
|
return [str(x).strip() for x in (raw or []) if str(x).strip()]
|
|
|
|
|
|
def _sheet_def(
|
|
*,
|
|
metric_id: str,
|
|
key_fields: list[str],
|
|
iface_fields: list[str] | None = None,
|
|
compare_fields: list[str] | None = None,
|
|
) -> dict[str, Any]:
|
|
mid = str(metric_id or "").strip()
|
|
keys = _str_list(key_fields)
|
|
ifaces = _str_list(iface_fields)
|
|
# compare_fields empty → presence-only mode (intentional)
|
|
compare = _str_list(compare_fields) if compare_fields is not None else []
|
|
# Keys are identity only; strip them from compare so UI/engine stay clear
|
|
key_set = set(keys)
|
|
compare = [f for f in compare if f not in key_set]
|
|
return {
|
|
"metric_id": mid,
|
|
"key_fields": keys,
|
|
"iface_fields": ifaces,
|
|
"compare_fields": compare,
|
|
}
|
|
|
|
|
|
def _default_lldp_sheet() -> dict[str, Any]:
|
|
fields = metric_field_map().get("lldp_neighbor") or []
|
|
keys = [f.name for f in fields if f.is_key] or ["local_if", "remote_sys", "remote_if"]
|
|
ifaces = [f.name for f in fields if f.is_interface] or ["local_if"]
|
|
# Value checks: non-key state/meta (e.g. remote_ip / protocol)
|
|
compare = [f.name for f in fields if not f.is_key and f.role in ("state", "meta", "identity")]
|
|
if not compare:
|
|
compare = [n for n in ("remote_ip", "protocol") if n not in keys]
|
|
return _sheet_def(
|
|
metric_id="lldp_neighbor",
|
|
key_fields=keys,
|
|
iface_fields=ifaces,
|
|
compare_fields=compare,
|
|
)
|
|
|
|
|
|
def _default_vrf_sheet() -> dict[str, Any]:
|
|
fields = metric_field_map().get("vrf_route_summary") or []
|
|
keys = [f.name for f in fields if f.is_key] or ["vrf", "source"]
|
|
ifaces = [f.name for f in fields if f.is_interface]
|
|
compare = [f.name for f in fields if not f.is_key and f.role in ("state", "meta", "identity")]
|
|
if not compare:
|
|
compare = [n for n in ("networks",) if n not in keys]
|
|
return _sheet_def(
|
|
metric_id="vrf_route_summary",
|
|
key_fields=keys,
|
|
iface_fields=ifaces,
|
|
compare_fields=compare,
|
|
)
|
|
|
|
|
|
def _default_sheet_for_metric(metric_id: str, *, compare_roles: tuple[str, ...] = ("state",)) -> dict[str, Any]:
|
|
fields = metric_field_map().get(metric_id) or []
|
|
keys = [f.name for f in fields if f.is_key]
|
|
ifaces = [f.name for f in fields if f.is_interface]
|
|
compare = [f.name for f in fields if (not f.is_key) and f.role in compare_roles]
|
|
return _sheet_def(
|
|
metric_id=metric_id,
|
|
key_fields=keys,
|
|
iface_fields=ifaces,
|
|
compare_fields=compare,
|
|
)
|
|
|
|
|
|
def _default_zte_status_sheets() -> list[dict[str, Any]]:
|
|
return [
|
|
_default_sheet_for_metric("isis_adjacency", compare_roles=("state",)),
|
|
_default_sheet_for_metric("interface_brief", compare_roles=("state",)),
|
|
_default_sheet_for_metric("arp", compare_roles=("state",)),
|
|
_default_sheet_for_metric("nd6_cache", compare_roles=("state",)),
|
|
_default_sheet_for_metric("bgp_peer", compare_roles=("state",)),
|
|
]
|
|
|
|
|
|
def _normalize_sheet(raw: Any) -> dict[str, Any] | None:
|
|
if not isinstance(raw, dict):
|
|
return None
|
|
mid = str(raw.get("metric_id") or "").strip()
|
|
keys = _str_list(raw.get("key_fields"))
|
|
if not mid or not keys:
|
|
return None
|
|
return _sheet_def(
|
|
metric_id=mid,
|
|
key_fields=keys,
|
|
iface_fields=_str_list(raw.get("iface_fields")),
|
|
compare_fields=_str_list(raw.get("compare_fields")),
|
|
)
|
|
|
|
|
|
def _legacy_sheets(t: BizCompareTemplate) -> list[dict[str, Any]]:
|
|
mid = str(t.metric_id or "").strip()
|
|
keys = _str_list(t.key_fields)
|
|
if not mid or not keys:
|
|
return []
|
|
ignore = set(_str_list(t.ignore_fields))
|
|
compare = [f for f in _str_list(t.compare_fields) if f not in ignore]
|
|
return [
|
|
_sheet_def(
|
|
metric_id=mid,
|
|
key_fields=keys,
|
|
iface_fields=_str_list(t.iface_fields),
|
|
compare_fields=compare,
|
|
)
|
|
]
|
|
|
|
|
|
def template_metrics(t: BizCompareTemplate) -> list[dict[str, Any]]:
|
|
"""Resolved metric sheets for a template (metrics_json or legacy single)."""
|
|
raw = list(t.metrics_json or [])
|
|
out: list[dict[str, Any]] = []
|
|
seen: set[str] = set()
|
|
for item in raw:
|
|
sheet = _normalize_sheet(item)
|
|
if not sheet:
|
|
continue
|
|
mid = sheet["metric_id"]
|
|
if mid in seen:
|
|
continue
|
|
seen.add(mid)
|
|
out.append(sheet)
|
|
if out:
|
|
return out
|
|
return _legacy_sheets(t)
|
|
|
|
|
|
def _apply_sheets_to_row(t: BizCompareTemplate, sheets: list[dict[str, Any]]) -> None:
|
|
t.metrics_json = sheets
|
|
first = sheets[0] if sheets else None
|
|
if first:
|
|
t.metric_id = first["metric_id"]
|
|
t.key_fields = list(first["key_fields"])
|
|
t.iface_fields = list(first["iface_fields"])
|
|
t.compare_fields = list(first["compare_fields"])
|
|
t.ignore_fields = []
|
|
else:
|
|
t.metric_id = ""
|
|
t.key_fields = []
|
|
t.iface_fields = []
|
|
t.compare_fields = []
|
|
t.ignore_fields = []
|
|
|
|
|
|
def _parse_metrics_body(body: dict[str, Any]) -> list[dict[str, Any]]:
|
|
"""Accept ``metrics`` list or legacy single-metric fields."""
|
|
if "metrics" in body and body.get("metrics") is not None:
|
|
sheets: list[dict[str, Any]] = []
|
|
seen: set[str] = set()
|
|
for raw in list(body.get("metrics") or []):
|
|
sheet = _normalize_sheet(raw)
|
|
if not sheet:
|
|
continue
|
|
mid = sheet["metric_id"]
|
|
if mid in seen:
|
|
raise HTTPException(status_code=400, detail=f"duplicate_metric:{mid}")
|
|
seen.add(mid)
|
|
sheets.append(sheet)
|
|
if not sheets:
|
|
raise HTTPException(status_code=400, detail="metrics_required")
|
|
return sheets
|
|
|
|
mid = str(body.get("metric_id") or "").strip()
|
|
keys = _str_list(body.get("key_fields"))
|
|
if not mid:
|
|
raise HTTPException(status_code=400, detail="metric_id_required")
|
|
if not keys:
|
|
raise HTTPException(status_code=400, detail="key_fields_required")
|
|
ignore = set(_str_list(body.get("ignore_fields")))
|
|
compare = [f for f in _str_list(body.get("compare_fields")) if f not in ignore]
|
|
return [
|
|
_sheet_def(
|
|
metric_id=mid,
|
|
key_fields=keys,
|
|
iface_fields=_str_list(body.get("iface_fields")),
|
|
compare_fields=compare,
|
|
)
|
|
]
|
|
|
|
|
|
def _template_out(t: BizCompareTemplate) -> dict[str, Any]:
|
|
sheets = template_metrics(t)
|
|
first = sheets[0] if sheets else None
|
|
return {
|
|
"id": t.id,
|
|
"name": t.name,
|
|
"metrics": sheets,
|
|
"metric_ids": [s["metric_id"] for s in sheets],
|
|
# legacy mirrors (first sheet)
|
|
"metric_id": (first or {}).get("metric_id") or t.metric_id or "",
|
|
"key_fields": list((first or {}).get("key_fields") or t.key_fields or []),
|
|
"iface_fields": list((first or {}).get("iface_fields") or t.iface_fields or []),
|
|
"compare_fields": list((first or {}).get("compare_fields") or t.compare_fields or []),
|
|
"ignore_fields": [],
|
|
"note": t.note,
|
|
"updated_at": t.updated_at.isoformat() + "Z" if t.updated_at else None,
|
|
}
|
|
|
|
|
|
def ensure_default_cutover_template(db: Session) -> BizCompareTemplate:
|
|
row = (
|
|
db.query(BizCompareTemplate)
|
|
.filter(BizCompareTemplate.name == "Cutover default")
|
|
.one_or_none()
|
|
)
|
|
if row:
|
|
# Upgrade legacy single-sheet cutover if needed
|
|
sheets = template_metrics(row)
|
|
if len(sheets) < 2:
|
|
_apply_sheets_to_row(row, [_default_lldp_sheet(), _default_vrf_sheet()])
|
|
row.note = "Built-in multi-metric cutover template (LLDP + VRF)"
|
|
row.updated_at = _utcnow()
|
|
db.commit()
|
|
db.refresh(row)
|
|
return row
|
|
sheets = [_default_lldp_sheet(), _default_vrf_sheet()]
|
|
row = BizCompareTemplate(
|
|
id=uuid4().hex,
|
|
name="Cutover default",
|
|
note="Built-in multi-metric cutover template (LLDP + VRF)",
|
|
created_at=_utcnow(),
|
|
updated_at=_utcnow(),
|
|
)
|
|
_apply_sheets_to_row(row, sheets)
|
|
db.add(row)
|
|
db.commit()
|
|
db.refresh(row)
|
|
return row
|
|
|
|
|
|
def ensure_default_lldp_template(db: Session) -> BizCompareTemplate:
|
|
row = (
|
|
db.query(BizCompareTemplate)
|
|
.filter(BizCompareTemplate.name == "LLDP default")
|
|
.one_or_none()
|
|
)
|
|
if row:
|
|
if not template_metrics(row):
|
|
_apply_sheets_to_row(row, [_default_lldp_sheet()])
|
|
row.updated_at = _utcnow()
|
|
db.commit()
|
|
db.refresh(row)
|
|
return row
|
|
row = BizCompareTemplate(
|
|
id=uuid4().hex,
|
|
name="LLDP default",
|
|
note="Built-in template for LLDP neighbor cutover compare",
|
|
created_at=_utcnow(),
|
|
updated_at=_utcnow(),
|
|
)
|
|
_apply_sheets_to_row(row, [_default_lldp_sheet()])
|
|
db.add(row)
|
|
db.commit()
|
|
db.refresh(row)
|
|
return row
|
|
|
|
|
|
def ensure_default_vrf_template(db: Session) -> BizCompareTemplate:
|
|
row = (
|
|
db.query(BizCompareTemplate)
|
|
.filter(BizCompareTemplate.name == "VRF route summary default")
|
|
.one_or_none()
|
|
)
|
|
if row:
|
|
if not template_metrics(row):
|
|
_apply_sheets_to_row(row, [_default_vrf_sheet()])
|
|
row.updated_at = _utcnow()
|
|
db.commit()
|
|
db.refresh(row)
|
|
return row
|
|
row = BizCompareTemplate(
|
|
id=uuid4().hex,
|
|
name="VRF route summary default",
|
|
note="Built-in template for per-VRF route summary cutover compare",
|
|
created_at=_utcnow(),
|
|
updated_at=_utcnow(),
|
|
)
|
|
_apply_sheets_to_row(row, [_default_vrf_sheet()])
|
|
db.add(row)
|
|
db.commit()
|
|
db.refresh(row)
|
|
return row
|
|
|
|
|
|
def ensure_default_zte_status_template(db: Session) -> BizCompareTemplate:
|
|
name = "ZTE status default"
|
|
row = db.query(BizCompareTemplate).filter(BizCompareTemplate.name == name).one_or_none()
|
|
sheets = _default_zte_status_sheets()
|
|
if row:
|
|
existing = template_metrics(row)
|
|
want = {s["metric_id"] for s in sheets}
|
|
have = {s["metric_id"] for s in existing}
|
|
if want - have:
|
|
_apply_sheets_to_row(row, sheets)
|
|
row.note = "Built-in ZTE status cutover (ISIS/IF/ARP/ND6/BGP)"
|
|
row.updated_at = _utcnow()
|
|
db.commit()
|
|
db.refresh(row)
|
|
return row
|
|
row = BizCompareTemplate(
|
|
id=uuid4().hex,
|
|
name=name,
|
|
note="Built-in ZTE status cutover (ISIS/IF/ARP/ND6/BGP)",
|
|
created_at=_utcnow(),
|
|
updated_at=_utcnow(),
|
|
)
|
|
_apply_sheets_to_row(row, sheets)
|
|
db.add(row)
|
|
db.commit()
|
|
db.refresh(row)
|
|
return row
|
|
|
|
|
|
def ensure_default_templates(db: Session) -> None:
|
|
ensure_default_cutover_template(db)
|
|
ensure_default_lldp_template(db)
|
|
ensure_default_vrf_template(db)
|
|
ensure_default_zte_status_template(db)
|
|
|
|
|
|
def list_templates(db: Session) -> list[dict[str, Any]]:
|
|
ensure_default_templates(db)
|
|
rows = db.query(BizCompareTemplate).order_by(BizCompareTemplate.name.asc()).all()
|
|
return [_template_out(t) for t in rows]
|
|
|
|
|
|
def list_metric_schemas() -> list[dict[str, Any]]:
|
|
"""Field catalog for template editors (key / iface / compare pickers)."""
|
|
out: list[dict[str, Any]] = []
|
|
for metric_id, fields in sorted(metric_field_map().items()):
|
|
if metric_id in ("vrf_list",):
|
|
continue
|
|
out.append(
|
|
{
|
|
"metric_id": metric_id,
|
|
"fields": [
|
|
{
|
|
"name": f.name,
|
|
"display_name": f.display_name or f.name,
|
|
"dtype": f.dtype,
|
|
"is_key": bool(f.is_key),
|
|
"is_interface": bool(f.is_interface),
|
|
"role": f.role,
|
|
}
|
|
for f in fields
|
|
],
|
|
}
|
|
)
|
|
return out
|
|
|
|
|
|
def create_template(db: Session, body: dict[str, Any]) -> dict[str, Any]:
|
|
sheets = _parse_metrics_body(body)
|
|
t = BizCompareTemplate(
|
|
id=uuid4().hex,
|
|
name=str(body.get("name") or sheets[0]["metric_id"])[:256],
|
|
note=str(body.get("note") or "")[:512],
|
|
created_at=_utcnow(),
|
|
updated_at=_utcnow(),
|
|
)
|
|
_apply_sheets_to_row(t, sheets)
|
|
db.add(t)
|
|
db.commit()
|
|
return _template_out(t)
|
|
|
|
|
|
def update_template(db: Session, template_id: str, body: dict[str, Any]) -> dict[str, Any]:
|
|
t = db.get(BizCompareTemplate, template_id)
|
|
if not t:
|
|
raise HTTPException(status_code=404, detail="template_not_found")
|
|
if "name" in body:
|
|
t.name = str(body.get("name") or "")[:256]
|
|
if "note" in body:
|
|
t.note = str(body.get("note") or "")[:512]
|
|
if any(k in body for k in ("metrics", "metric_id", "key_fields", "iface_fields", "compare_fields", "ignore_fields")):
|
|
# Prefer explicit metrics; otherwise merge into current sheets from legacy keys
|
|
if "metrics" in body and body.get("metrics") is not None:
|
|
sheets = _parse_metrics_body(body)
|
|
else:
|
|
# Patch first sheet (or create) from legacy fields
|
|
sheets = list(template_metrics(t))
|
|
if not sheets:
|
|
sheets = _parse_metrics_body(body)
|
|
else:
|
|
first = dict(sheets[0])
|
|
if "metric_id" in body and body.get("metric_id") is not None:
|
|
mid = str(body.get("metric_id") or "").strip()
|
|
if mid:
|
|
first["metric_id"] = mid
|
|
if "key_fields" in body:
|
|
keys = _str_list(body.get("key_fields"))
|
|
if not keys:
|
|
raise HTTPException(status_code=400, detail="key_fields_required")
|
|
first["key_fields"] = keys
|
|
if "iface_fields" in body:
|
|
first["iface_fields"] = _str_list(body.get("iface_fields"))
|
|
if "compare_fields" in body or "ignore_fields" in body:
|
|
ignore = set(_str_list(body.get("ignore_fields"))) if "ignore_fields" in body else set()
|
|
compare = _str_list(body.get("compare_fields")) if "compare_fields" in body else list(first.get("compare_fields") or [])
|
|
first["compare_fields"] = [f for f in compare if f not in ignore]
|
|
sheets[0] = _normalize_sheet(first) or first
|
|
_apply_sheets_to_row(t, sheets)
|
|
t.updated_at = _utcnow()
|
|
db.commit()
|
|
return _template_out(t)
|
|
|
|
|
|
def delete_template(db: Session, template_id: str) -> None:
|
|
t = db.get(BizCompareTemplate, template_id)
|
|
if not t:
|
|
raise HTTPException(status_code=404, detail="template_not_found")
|
|
db.delete(t)
|
|
db.commit()
|
|
|
|
|
|
def _mapping_out(db: Session, m: BizPortMapping) -> dict[str, Any]:
|
|
rows = (
|
|
db.query(BizPortMappingRow)
|
|
.filter(BizPortMappingRow.mapping_id == m.id)
|
|
.order_by(BizPortMappingRow.before_if.asc())
|
|
.all()
|
|
)
|
|
return {
|
|
"id": m.id,
|
|
"name": m.name,
|
|
"note": m.note,
|
|
"rows": [{"id": r.id, "before_if": r.before_if, "after_if": r.after_if} for r in rows],
|
|
"updated_at": m.updated_at.isoformat() + "Z" if m.updated_at else None,
|
|
}
|
|
|
|
|
|
def list_mappings(db: Session) -> list[dict[str, Any]]:
|
|
rows = db.query(BizPortMapping).order_by(BizPortMapping.name.asc()).all()
|
|
return [_mapping_out(db, m) for m in rows]
|
|
|
|
|
|
def create_mapping(db: Session, body: dict[str, Any]) -> dict[str, Any]:
|
|
m = BizPortMapping(
|
|
id=uuid4().hex,
|
|
name=str(body.get("name") or "port map")[:256],
|
|
note=str(body.get("note") or "")[:512],
|
|
created_at=_utcnow(),
|
|
updated_at=_utcnow(),
|
|
)
|
|
db.add(m)
|
|
db.flush()
|
|
_replace_mapping_rows(db, m.id, list(body.get("rows") or []))
|
|
db.commit()
|
|
return _mapping_out(db, m)
|
|
|
|
|
|
def update_mapping(db: Session, mapping_id: str, body: dict[str, Any]) -> dict[str, Any]:
|
|
m = db.get(BizPortMapping, mapping_id)
|
|
if not m:
|
|
raise HTTPException(status_code=404, detail="mapping_not_found")
|
|
if "name" in body:
|
|
m.name = str(body.get("name") or "")[:256]
|
|
if "note" in body:
|
|
m.note = str(body.get("note") or "")[:512]
|
|
if "rows" in body:
|
|
_replace_mapping_rows(db, m.id, list(body.get("rows") or []))
|
|
m.updated_at = _utcnow()
|
|
db.commit()
|
|
return _mapping_out(db, m)
|
|
|
|
|
|
def _replace_mapping_rows(db: Session, mapping_id: str, rows_in: list[dict[str, Any]]) -> None:
|
|
db.query(BizPortMappingRow).filter(BizPortMappingRow.mapping_id == mapping_id).delete()
|
|
seen: set[str] = set()
|
|
for raw in rows_in:
|
|
before = str(raw.get("before_if") or "").strip()
|
|
after = str(raw.get("after_if") or "").strip()
|
|
if not before or not after:
|
|
continue
|
|
if before in seen:
|
|
raise HTTPException(status_code=400, detail=f"duplicate_before_if:{before}")
|
|
seen.add(before)
|
|
db.add(
|
|
BizPortMappingRow(
|
|
id=uuid4().hex,
|
|
mapping_id=mapping_id,
|
|
before_if=before[:128],
|
|
after_if=after[:128],
|
|
)
|
|
)
|
|
|
|
|
|
def delete_mapping(db: Session, mapping_id: str) -> None:
|
|
m = db.get(BizPortMapping, mapping_id)
|
|
if not m:
|
|
raise HTTPException(status_code=404, detail="mapping_not_found")
|
|
db.query(BizPortMappingRow).filter(BizPortMappingRow.mapping_id == mapping_id).delete()
|
|
db.delete(m)
|
|
db.commit()
|
|
|
|
|
|
def _port_map_dict(db: Session, mapping_id: str) -> dict[str, str]:
|
|
if not mapping_id:
|
|
return {}
|
|
rows = db.query(BizPortMappingRow).filter(BizPortMappingRow.mapping_id == mapping_id).all()
|
|
return {str(r.before_if): str(r.after_if) for r in rows if r.before_if and r.after_if}
|
|
|
|
|
|
def _load_metric_rows(db: Session, *, batch_id: str, metric_id: str) -> list[dict[str, Any]]:
|
|
if metric_id == "lldp_neighbor":
|
|
rows = (
|
|
db.query(BizStateLldpNeighbor)
|
|
.filter(BizStateLldpNeighbor.batch_id == batch_id)
|
|
.all()
|
|
)
|
|
return [
|
|
{
|
|
"local_if": n.local_if,
|
|
"remote_sys": n.remote_sys,
|
|
"remote_if": n.remote_if,
|
|
"remote_ip": n.remote_ip,
|
|
"protocol": n.protocol,
|
|
}
|
|
for n in rows
|
|
]
|
|
if metric_id == "vrf_route_summary":
|
|
from ..models import BizStateVrfRouteSummary
|
|
|
|
rows = (
|
|
db.query(BizStateVrfRouteSummary)
|
|
.filter(BizStateVrfRouteSummary.batch_id == batch_id)
|
|
.all()
|
|
)
|
|
return [
|
|
{"vrf": r.vrf, "source": r.source, "networks": r.networks}
|
|
for r in rows
|
|
]
|
|
# Generic tabular metrics (ISIS / interface / ARP / ND6 / BGP …)
|
|
from ..models import BizStateMetricRow
|
|
|
|
rows = (
|
|
db.query(BizStateMetricRow)
|
|
.filter(
|
|
BizStateMetricRow.batch_id == batch_id,
|
|
BizStateMetricRow.metric_id == metric_id,
|
|
)
|
|
.order_by(BizStateMetricRow.seq.asc(), BizStateMetricRow.id.asc())
|
|
.all()
|
|
)
|
|
if rows:
|
|
return [dict(r.data_json or {}) for r in rows]
|
|
# Known metric with zero rows is OK; unknown metric still errors
|
|
if metric_id in metric_field_map():
|
|
return []
|
|
raise HTTPException(status_code=400, detail=f"unsupported_metric:{metric_id}")
|
|
|
|
|
|
def validate_mapping(
|
|
db: Session,
|
|
*,
|
|
mapping_id: str,
|
|
before_batch_id: str,
|
|
after_batch_id: str,
|
|
template_id: str = "",
|
|
) -> dict[str, Any]:
|
|
ensure_default_templates(db)
|
|
tpl = db.get(BizCompareTemplate, template_id) if template_id else ensure_default_cutover_template(db)
|
|
if not tpl:
|
|
raise HTTPException(status_code=404, detail="template_not_found")
|
|
sheets = template_metrics(tpl)
|
|
if not sheets:
|
|
raise HTTPException(status_code=400, detail="template_has_no_metrics")
|
|
pmap = _port_map_dict(db, mapping_id)
|
|
# Validate against first sheet that has iface fields (or first sheet)
|
|
target = next((s for s in sheets if s.get("iface_fields")), sheets[0])
|
|
before = _load_metric_rows(db, batch_id=before_batch_id, metric_id=target["metric_id"])
|
|
after = _load_metric_rows(db, batch_id=after_batch_id, metric_id=target["metric_id"])
|
|
return mapping_stats(
|
|
before_rows=before,
|
|
after_rows=after,
|
|
iface_fields=list(target.get("iface_fields") or []),
|
|
port_map=pmap,
|
|
)
|
|
|
|
|
|
def _job_out(j: BizCompareJob) -> dict[str, Any]:
|
|
return {
|
|
"id": j.id,
|
|
"name": j.name,
|
|
"template_id": j.template_id,
|
|
"mapping_id": j.mapping_id,
|
|
"before_task_id": j.before_task_id,
|
|
"after_task_id": j.after_task_id,
|
|
"before_batch_id": j.before_batch_id,
|
|
"after_batch_id": j.after_batch_id,
|
|
"mode": j.mode,
|
|
"status": j.status,
|
|
"note": j.note,
|
|
"updated_at": j.updated_at.isoformat() + "Z" if j.updated_at else None,
|
|
}
|
|
|
|
|
|
def list_jobs(db: Session) -> list[dict[str, Any]]:
|
|
rows = db.query(BizCompareJob).order_by(BizCompareJob.updated_at.desc()).all()
|
|
return [_job_out(j) for j in rows]
|
|
|
|
|
|
def create_job(db: Session, body: dict[str, Any]) -> dict[str, Any]:
|
|
ensure_default_templates(db)
|
|
template_id = str(body.get("template_id") or "").strip()
|
|
if not template_id:
|
|
tpl = ensure_default_cutover_template(db)
|
|
template_id = tpl.id
|
|
else:
|
|
if not db.get(BizCompareTemplate, template_id):
|
|
raise HTTPException(status_code=404, detail="template_not_found")
|
|
j = BizCompareJob(
|
|
id=uuid4().hex,
|
|
name=str(body.get("name") or "compare")[:256],
|
|
template_id=template_id,
|
|
mapping_id=str(body.get("mapping_id") or ""),
|
|
before_task_id=str(body.get("before_task_id") or ""),
|
|
after_task_id=str(body.get("after_task_id") or ""),
|
|
before_batch_id=str(body.get("before_batch_id") or ""),
|
|
after_batch_id=str(body.get("after_batch_id") or ""),
|
|
mode=str(body.get("mode") or "manual")[:16],
|
|
status="ready",
|
|
note=str(body.get("note") or "")[:512],
|
|
created_at=_utcnow(),
|
|
updated_at=_utcnow(),
|
|
)
|
|
if j.mode == "auto":
|
|
j.status = "auto"
|
|
db.add(j)
|
|
db.commit()
|
|
return _job_out(j)
|
|
|
|
|
|
def update_job(db: Session, job_id: str, body: dict[str, Any]) -> dict[str, Any]:
|
|
j = db.get(BizCompareJob, job_id)
|
|
if not j:
|
|
raise HTTPException(status_code=404, detail="job_not_found")
|
|
for key in (
|
|
"name",
|
|
"template_id",
|
|
"mapping_id",
|
|
"before_task_id",
|
|
"after_task_id",
|
|
"before_batch_id",
|
|
"after_batch_id",
|
|
"mode",
|
|
"status",
|
|
"note",
|
|
):
|
|
if key in body and body.get(key) is not None:
|
|
setattr(j, key, str(body.get(key) or ""))
|
|
j.updated_at = _utcnow()
|
|
db.commit()
|
|
return _job_out(j)
|
|
|
|
|
|
def delete_job(db: Session, job_id: str) -> None:
|
|
j = db.get(BizCompareJob, job_id)
|
|
if not j:
|
|
raise HTTPException(status_code=404, detail="job_not_found")
|
|
run_ids = [
|
|
rid for (rid,) in db.query(BizCompareRun.id).filter(BizCompareRun.job_id == job_id).all()
|
|
]
|
|
if run_ids:
|
|
db.query(BizCompareDiff).filter(BizCompareDiff.run_id.in_(run_ids)).delete(
|
|
synchronize_session=False
|
|
)
|
|
db.query(BizCompareRun).filter(BizCompareRun.job_id == job_id).delete()
|
|
db.delete(j)
|
|
db.commit()
|
|
|
|
|
|
def _resolve_after_batch(db: Session, job: BizCompareJob) -> str:
|
|
if job.mode != "auto":
|
|
return str(job.after_batch_id or "")
|
|
task_id = str(job.after_task_id or "")
|
|
if not task_id:
|
|
return str(job.after_batch_id or "")
|
|
latest = (
|
|
db.query(BizStateBatch)
|
|
.filter(
|
|
BizStateBatch.task_id == task_id,
|
|
BizStateBatch.status.in_(("success", "partial")),
|
|
)
|
|
.order_by(BizStateBatch.started_at.desc())
|
|
.first()
|
|
)
|
|
return str(latest.id) if latest else ""
|
|
|
|
|
|
def _run_sheet(
|
|
db: Session,
|
|
*,
|
|
sheet: dict[str, Any],
|
|
before_batch_id: str,
|
|
after_batch_id: str,
|
|
port_map: dict[str, str],
|
|
) -> dict[str, Any]:
|
|
key_fields = list(sheet.get("key_fields") or [])
|
|
iface_fields = list(sheet.get("iface_fields") or [])
|
|
compare_fields = list(sheet.get("compare_fields") or [])
|
|
mode = "presence" if not compare_fields else "fields"
|
|
before_rows = _load_metric_rows(db, batch_id=before_batch_id, metric_id=sheet["metric_id"])
|
|
after_rows = _load_metric_rows(db, batch_id=after_batch_id, metric_id=sheet["metric_id"])
|
|
result = compare_rows(
|
|
before_rows=before_rows,
|
|
after_rows=after_rows,
|
|
key_fields=key_fields,
|
|
iface_fields=iface_fields,
|
|
compare_fields=compare_fields,
|
|
port_map=port_map,
|
|
)
|
|
return {
|
|
"metric_id": sheet["metric_id"],
|
|
"key_fields": key_fields,
|
|
"iface_fields": iface_fields,
|
|
"compare_fields": compare_fields,
|
|
"mode": mode,
|
|
"summary": result["summary"],
|
|
"diffs": result["diffs"],
|
|
"mapping_stats": result["mapping_stats"],
|
|
}
|
|
|
|
|
|
def run_compare(db: Session, job_id: str, *, force_after_batch_id: str = "") -> dict[str, Any]:
|
|
j = db.get(BizCompareJob, job_id)
|
|
if not j:
|
|
raise HTTPException(status_code=404, detail="job_not_found")
|
|
tpl = db.get(BizCompareTemplate, j.template_id)
|
|
if not tpl:
|
|
raise HTTPException(status_code=404, detail="template_not_found")
|
|
before_batch_id = str(j.before_batch_id or "")
|
|
after_batch_id = str(force_after_batch_id or "").strip() or _resolve_after_batch(db, j)
|
|
if not before_batch_id or not after_batch_id:
|
|
raise HTTPException(status_code=400, detail="before_and_after_batch_required")
|
|
if not db.get(BizStateBatch, before_batch_id) or not db.get(BizStateBatch, after_batch_id):
|
|
raise HTTPException(status_code=404, detail="batch_not_found")
|
|
|
|
sheets_cfg = template_metrics(tpl)
|
|
if not sheets_cfg:
|
|
raise HTTPException(status_code=400, detail="template_has_no_metrics")
|
|
|
|
pmap = _port_map_dict(db, j.mapping_id)
|
|
sheet_results: list[dict[str, Any]] = []
|
|
agg = {
|
|
"before_count": 0,
|
|
"after_count": 0,
|
|
"added": 0,
|
|
"removed": 0,
|
|
"changed": 0,
|
|
"unchanged": 0,
|
|
}
|
|
mapping_by_metric: dict[str, Any] = {}
|
|
for sheet in sheets_cfg:
|
|
one = _run_sheet(
|
|
db,
|
|
sheet=sheet,
|
|
before_batch_id=before_batch_id,
|
|
after_batch_id=after_batch_id,
|
|
port_map=pmap,
|
|
)
|
|
sheet_results.append(one)
|
|
s = one["summary"]
|
|
for k in agg:
|
|
agg[k] += int(s.get(k) or 0)
|
|
mapping_by_metric[one["metric_id"]] = one["mapping_stats"]
|
|
|
|
first = sheet_results[0]
|
|
field_counts: dict[str, int] = {}
|
|
for s in sheet_results:
|
|
for d in list(s.get("diffs") or []):
|
|
if str(d.get("kind") or "") != "changed":
|
|
continue
|
|
for fname in d.get("changes") or {}:
|
|
field_counts[str(fname)] = field_counts.get(str(fname), 0) + 1
|
|
top_fields = sorted(
|
|
[{"field": k, "count": v} for k, v in field_counts.items()],
|
|
key=lambda x: (-int(x["count"]), str(x["field"])),
|
|
)[:8]
|
|
|
|
run_id = uuid4().hex
|
|
# Persist counts/meta only — diffs go to biz_compare_diff rows
|
|
summary_payload = {
|
|
**agg,
|
|
"sheet_count": len(sheet_results),
|
|
"top_changed_fields": top_fields,
|
|
"sheets": [
|
|
{
|
|
"metric_id": s["metric_id"],
|
|
"key_fields": s["key_fields"],
|
|
"iface_fields": s["iface_fields"],
|
|
"compare_fields": s["compare_fields"],
|
|
"mode": s["mode"],
|
|
"summary": s["summary"],
|
|
}
|
|
for s in sheet_results
|
|
],
|
|
}
|
|
|
|
run = BizCompareRun(
|
|
id=run_id,
|
|
job_id=j.id,
|
|
template_id=tpl.id,
|
|
mapping_id=j.mapping_id,
|
|
before_batch_id=before_batch_id,
|
|
after_batch_id=after_batch_id,
|
|
metric_id=first["metric_id"],
|
|
status="success",
|
|
summary_json=summary_payload,
|
|
diffs_json=[],
|
|
mapping_stats_json=mapping_by_metric,
|
|
message="",
|
|
created_at=_utcnow(),
|
|
)
|
|
db.add(run)
|
|
db.flush()
|
|
for s in sheet_results:
|
|
_persist_sheet_diffs(
|
|
db,
|
|
run_id=run_id,
|
|
metric_id=str(s["metric_id"]),
|
|
diffs=list(s.get("diffs") or []),
|
|
)
|
|
j.updated_at = _utcnow()
|
|
if j.mode == "manual":
|
|
j.after_batch_id = after_batch_id
|
|
db.commit()
|
|
return get_run(db, run.id)
|
|
|
|
|
|
def _csv_cell(v: Any) -> str:
|
|
s = "" if v is None else str(v)
|
|
if any(ch in s for ch in ",\"\n\r"):
|
|
return '"' + s.replace('"', '""') + '"'
|
|
return s
|
|
|
|
|
|
def _sheet_csv(sheet: dict[str, Any]) -> str:
|
|
keys = list(sheet.get("key_fields") or [])
|
|
compare = list(sheet.get("compare_fields") or [])
|
|
headers = ["kind", *keys]
|
|
for f in compare:
|
|
headers.append(f"{f}__pre")
|
|
headers.append(f"{f}__post")
|
|
lines = [",".join(_csv_cell(h) for h in headers)]
|
|
for d in list(sheet.get("diffs") or []):
|
|
kind = str(d.get("kind") or "")
|
|
pre = dict(d.get("mapped_before") or d.get("before") or {})
|
|
post = dict(d.get("after") or {})
|
|
key = dict(d.get("key") or {})
|
|
row = [kind]
|
|
for k in keys:
|
|
row.append(key.get(k, pre.get(k, post.get(k, ""))))
|
|
for f in compare:
|
|
if kind == "added":
|
|
row.append("")
|
|
row.append(post.get(f, ""))
|
|
elif kind == "removed":
|
|
row.append(pre.get(f, ""))
|
|
row.append("")
|
|
else:
|
|
row.append(pre.get(f, ""))
|
|
row.append(post.get(f, ""))
|
|
lines.append(",".join(_csv_cell(x) for x in row))
|
|
return "\ufeff" + "\n".join(lines) + "\n"
|
|
|
|
|
|
def _enrich_summary(summary: dict[str, Any], sheets: list[dict[str, Any]]) -> dict[str, Any]:
|
|
added = int(summary.get("added") or 0)
|
|
removed = int(summary.get("removed") or 0)
|
|
changed = int(summary.get("changed") or 0)
|
|
unchanged = int(summary.get("unchanged") or 0)
|
|
before_count = int(summary.get("before_count") or 0)
|
|
after_count = int(summary.get("after_count") or 0)
|
|
total = added + removed + changed + unchanged
|
|
matched = changed + unchanged
|
|
diff_count = added + removed + changed
|
|
pass_rate = round((unchanged / matched) * 100, 1) if matched else (100.0 if total == 0 else 0.0)
|
|
diff_rate = round((diff_count / total) * 100, 1) if total else 0.0
|
|
|
|
sheet_cards: list[dict[str, Any]] = []
|
|
for sh in sheets:
|
|
ss = dict(sh.get("summary") or {})
|
|
sa = int(ss.get("added") or 0)
|
|
sr = int(ss.get("removed") or 0)
|
|
sc = int(ss.get("changed") or 0)
|
|
su = int(ss.get("unchanged") or 0)
|
|
st = sa + sr + sc + su
|
|
sm = sc + su
|
|
sheet_cards.append(
|
|
{
|
|
"metric_id": sh.get("metric_id") or "",
|
|
"mode": sh.get("mode") or ("presence" if not sh.get("compare_fields") else "fields"),
|
|
"added": sa,
|
|
"removed": sr,
|
|
"changed": sc,
|
|
"unchanged": su,
|
|
"before_count": int(ss.get("before_count") or 0),
|
|
"after_count": int(ss.get("after_count") or 0),
|
|
"diff_count": sa + sr + sc,
|
|
"pass_rate": round((su / sm) * 100, 1) if sm else (100.0 if st == 0 else 0.0),
|
|
}
|
|
)
|
|
|
|
top_fields = list(summary.get("top_changed_fields") or [])
|
|
if not top_fields:
|
|
# Legacy runs that still embed diffs in summary_json
|
|
field_counts: dict[str, int] = {}
|
|
for sh in sheets:
|
|
for d in list(sh.get("diffs") or []):
|
|
if str(d.get("kind") or "") != "changed":
|
|
continue
|
|
for fname in d.get("changes") or {}:
|
|
field_counts[str(fname)] = field_counts.get(str(fname), 0) + 1
|
|
top_fields = sorted(
|
|
[{"field": k, "count": v} for k, v in field_counts.items()],
|
|
key=lambda x: (-int(x["count"]), str(x["field"])),
|
|
)[:8]
|
|
|
|
return {
|
|
"added": added,
|
|
"removed": removed,
|
|
"changed": changed,
|
|
"unchanged": unchanged,
|
|
"before_count": before_count,
|
|
"after_count": after_count,
|
|
"sheet_count": int(summary.get("sheet_count") or len(sheets) or 0),
|
|
"total_rows": total,
|
|
"matched_rows": matched,
|
|
"diff_count": diff_count,
|
|
"pass_rate": pass_rate,
|
|
"diff_rate": diff_rate,
|
|
"ok": diff_count == 0,
|
|
"sheet_cards": sheet_cards,
|
|
"top_changed_fields": top_fields,
|
|
}
|
|
|
|
|
|
def get_run(db: Session, run_id: str) -> dict[str, Any]:
|
|
r = db.get(BizCompareRun, run_id)
|
|
if not r:
|
|
raise HTTPException(status_code=404, detail="run_not_found")
|
|
tpl = db.get(BizCompareTemplate, r.template_id) if r.template_id else None
|
|
summary = dict(r.summary_json or {})
|
|
raw_sheets = _sheet_meta_from_summary(summary, r, tpl)
|
|
# Never return full diffs in run detail (million-row safe)
|
|
sheets = [
|
|
{
|
|
"metric_id": sh.get("metric_id") or "",
|
|
"key_fields": list(sh.get("key_fields") or []),
|
|
"iface_fields": list(sh.get("iface_fields") or []),
|
|
"compare_fields": list(sh.get("compare_fields") or []),
|
|
"mode": sh.get("mode") or ("presence" if not sh.get("compare_fields") else "fields"),
|
|
"summary": dict(sh.get("summary") or {}),
|
|
}
|
|
for sh in raw_sheets
|
|
]
|
|
enriched = _enrich_summary(summary, raw_sheets)
|
|
stored = "rows" if _run_has_diff_rows(db, run_id) else "inline"
|
|
return {
|
|
"id": r.id,
|
|
"job_id": r.job_id,
|
|
"template_id": r.template_id,
|
|
"mapping_id": r.mapping_id,
|
|
"before_batch_id": r.before_batch_id,
|
|
"after_batch_id": r.after_batch_id,
|
|
"metric_id": r.metric_id,
|
|
"status": r.status,
|
|
"summary": enriched,
|
|
"sheets": sheets,
|
|
"diffs": [],
|
|
"diffs_stored": stored,
|
|
"mapping_stats": r.mapping_stats_json or {},
|
|
"message": r.message,
|
|
"created_at": r.created_at.isoformat() + "Z" if r.created_at else None,
|
|
"template": _template_out(tpl) if tpl else None,
|
|
}
|
|
|
|
|
|
def list_run_diffs(
|
|
db: Session,
|
|
run_id: str,
|
|
*,
|
|
metric_id: str = "",
|
|
kind: str = "diff",
|
|
kw: str = "",
|
|
page: int = 1,
|
|
page_size: int = 100,
|
|
) -> dict[str, Any]:
|
|
r = db.get(BizCompareRun, run_id)
|
|
if not r:
|
|
raise HTTPException(status_code=404, detail="run_not_found")
|
|
page_n = max(1, int(page or 1))
|
|
size_n = max(1, min(500, int(page_size or 100)))
|
|
kind_n = (kind or "diff").strip().lower()
|
|
kw_n = (kw or "").strip()
|
|
|
|
summary = dict(r.summary_json or {})
|
|
tpl = db.get(BizCompareTemplate, r.template_id) if r.template_id else None
|
|
sheets = _sheet_meta_from_summary(summary, r, tpl)
|
|
mid = (metric_id or "").strip() or (sheets[0].get("metric_id") if sheets else r.metric_id) or ""
|
|
|
|
if _run_has_diff_rows(db, run_id):
|
|
q = db.query(BizCompareDiff).filter(
|
|
BizCompareDiff.run_id == run_id,
|
|
BizCompareDiff.metric_id == mid,
|
|
)
|
|
if kind_n == "diff":
|
|
q = q.filter(BizCompareDiff.kind.in_(("added", "removed", "changed")))
|
|
elif kind_n != "all":
|
|
q = q.filter(BizCompareDiff.kind == kind_n)
|
|
if kw_n:
|
|
q = q.filter(BizCompareDiff.search_text.ilike(f"%{kw_n}%"))
|
|
total = q.count()
|
|
rows = (
|
|
q.order_by(BizCompareDiff.seq.asc(), BizCompareDiff.id.asc())
|
|
.offset((page_n - 1) * size_n)
|
|
.limit(size_n)
|
|
.all()
|
|
)
|
|
return {
|
|
"total": total,
|
|
"page": page_n,
|
|
"page_size": size_n,
|
|
"metric_id": mid,
|
|
"items": [_diff_row_out(x) for x in rows],
|
|
}
|
|
|
|
# Legacy: diffs embedded in summary_json / diffs_json
|
|
sheet = next((s for s in sheets if str(s.get("metric_id") or "") == mid), None)
|
|
if sheet is None and sheets:
|
|
sheet = sheets[0]
|
|
mid = str(sheet.get("metric_id") or mid)
|
|
inline = list((sheet or {}).get("diffs") or [])
|
|
if not inline and mid == r.metric_id:
|
|
inline = list(r.diffs_json or [])
|
|
filtered = _filter_inline_diffs(inline, kind=kind_n, kw=kw_n)
|
|
total = len(filtered)
|
|
start = (page_n - 1) * size_n
|
|
page_items = filtered[start : start + size_n]
|
|
return {
|
|
"total": total,
|
|
"page": page_n,
|
|
"page_size": size_n,
|
|
"metric_id": mid,
|
|
"items": page_items,
|
|
}
|
|
|
|
|
|
def _iter_sheet_diffs(db: Session, run_id: str, metric_id: str) -> list[dict[str, Any]]:
|
|
"""Load all diffs for one sheet (export). Prefer row table; fall back to inline."""
|
|
if _run_has_diff_rows(db, run_id):
|
|
out: list[dict[str, Any]] = []
|
|
offset = 0
|
|
while True:
|
|
rows = (
|
|
db.query(BizCompareDiff)
|
|
.filter(BizCompareDiff.run_id == run_id, BizCompareDiff.metric_id == metric_id)
|
|
.order_by(BizCompareDiff.seq.asc(), BizCompareDiff.id.asc())
|
|
.offset(offset)
|
|
.limit(_DIFF_CHUNK)
|
|
.all()
|
|
)
|
|
if not rows:
|
|
break
|
|
out.extend(_diff_row_out(x) for x in rows)
|
|
offset += len(rows)
|
|
if len(rows) < _DIFF_CHUNK:
|
|
break
|
|
return out
|
|
r = db.get(BizCompareRun, run_id)
|
|
if not r:
|
|
return []
|
|
summary = dict(r.summary_json or {})
|
|
sheets = list(summary.get("sheets") or [])
|
|
for sh in sheets:
|
|
if str(sh.get("metric_id") or "") == metric_id:
|
|
return list(sh.get("diffs") or [])
|
|
if metric_id == r.metric_id:
|
|
return list(r.diffs_json or [])
|
|
return []
|
|
|
|
|
|
def export_run_zip(db: Session, run_id: str) -> bytes:
|
|
detail = get_run(db, run_id)
|
|
buf = io.BytesIO()
|
|
with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
|
s = detail.get("summary") or {}
|
|
manifest = [
|
|
f"run_id={detail.get('id')}",
|
|
f"job_id={detail.get('job_id')}",
|
|
f"before_batch_id={detail.get('before_batch_id')}",
|
|
f"after_batch_id={detail.get('after_batch_id')}",
|
|
f"created_at={detail.get('created_at')}",
|
|
f"pass_rate={s.get('pass_rate')}%",
|
|
f"diff_count={s.get('diff_count')}",
|
|
f"added={s.get('added')} removed={s.get('removed')} "
|
|
f"changed={s.get('changed')} unchanged={s.get('unchanged')}",
|
|
f"before_count={s.get('before_count')} after_count={s.get('after_count')}",
|
|
"",
|
|
"sheets:",
|
|
]
|
|
for card in list(s.get("sheet_cards") or []):
|
|
manifest.append(
|
|
f"- {card.get('metric_id')}: diff={card.get('diff_count')} "
|
|
f"pass={card.get('pass_rate')}% "
|
|
f"+{card.get('added')}/-{card.get('removed')}/~{card.get('changed')}/= {card.get('unchanged')}"
|
|
)
|
|
zf.writestr("manifest.txt", "\n".join(manifest) + "\n")
|
|
for sheet in list(detail.get("sheets") or []):
|
|
mid = str(sheet.get("metric_id") or "sheet")
|
|
safe = "".join(ch if ch.isalnum() or ch in "-_" else "_" for ch in mid)[:80] or "sheet"
|
|
sheet_full = {
|
|
**sheet,
|
|
"diffs": _iter_sheet_diffs(db, run_id, mid),
|
|
}
|
|
zf.writestr(f"tables/{safe}.csv", _sheet_csv(sheet_full))
|
|
sum_lines = ["metric_id,mode,before,after,added,removed,changed,unchanged,diff_count,pass_rate"]
|
|
for card in list(s.get("sheet_cards") or []):
|
|
sum_lines.append(
|
|
",".join(
|
|
_csv_cell(x)
|
|
for x in (
|
|
card.get("metric_id"),
|
|
card.get("mode"),
|
|
card.get("before_count"),
|
|
card.get("after_count"),
|
|
card.get("added"),
|
|
card.get("removed"),
|
|
card.get("changed"),
|
|
card.get("unchanged"),
|
|
card.get("diff_count"),
|
|
card.get("pass_rate"),
|
|
)
|
|
)
|
|
)
|
|
zf.writestr("tables/_summary.csv", "\ufeff" + "\n".join(sum_lines) + "\n")
|
|
return buf.getvalue()
|
|
|
|
|
|
def list_runs(db: Session, job_id: str, *, limit: int = 20) -> list[dict[str, Any]]:
|
|
rows = (
|
|
db.query(BizCompareRun)
|
|
.filter(BizCompareRun.job_id == job_id)
|
|
.order_by(BizCompareRun.created_at.desc())
|
|
.limit(max(1, min(100, int(limit))))
|
|
.all()
|
|
)
|
|
return [
|
|
{
|
|
"id": r.id,
|
|
"before_batch_id": r.before_batch_id,
|
|
"after_batch_id": r.after_batch_id,
|
|
"status": r.status,
|
|
"summary": {
|
|
k: (r.summary_json or {}).get(k, 0)
|
|
for k in ("added", "removed", "changed", "unchanged", "sheet_count")
|
|
},
|
|
"created_at": r.created_at.isoformat() + "Z" if r.created_at else None,
|
|
}
|
|
for r in rows
|
|
]
|
|
|
|
|
|
def try_auto_compare_for_task(db: Session, task_id: str, batch_id: str) -> int:
|
|
"""When a new after batch lands, run auto jobs pinned to that after task."""
|
|
jobs = (
|
|
db.query(BizCompareJob)
|
|
.filter(BizCompareJob.mode == "auto", BizCompareJob.after_task_id == task_id)
|
|
.all()
|
|
)
|
|
n = 0
|
|
for j in jobs:
|
|
if not j.before_batch_id:
|
|
continue
|
|
try:
|
|
run_compare(db, j.id, force_after_batch_id=batch_id)
|
|
n += 1
|
|
except Exception:
|
|
continue
|
|
return n
|