feat: per-field approval backend + geotagger-style frontend
Backend (cantaloupe_sync.py):
- GET /api/admin/cantaloupe/batches/{id}/field-changes — flat list of per-field diffs
- POST /api/admin/cantaloupe/batches/{id}/apply-field-changes — apply selected changes only
- PerFieldApprovalRequest/FieldChangeItem Pydantic models
- Activity log entries for apply-field-changes
Frontend (static/index.html):
- toggleFieldReview() — loads field changes via API, shows/hides review area
- renderFieldChanges() — geotagger-style cards with checkboxes per field, grouped by machine
- applyFieldChanges() — POST selected changes with confirmation modal
- Auto-select blank-fill changes (is_blank_fill flag)
- Sticky apply bar at bottom with live selected count
- Toggle all, toggle per-asset, toggle per-field helpers
- Review Changes button in pending review card
This commit is contained in:
+199
-3
@@ -134,10 +134,15 @@ COLUMN_HEURISTICS = [
|
||||
(["last dex report time", "dex report time", "last dex", "dex report", "dex time"], "dex_report_date"),
|
||||
(["deployed"], "deployed"),
|
||||
(["pulled date", "pulled", "removal date"], "pulled_date"),
|
||||
# Cantaloupe Machine List export overrides (before generic single-word)
|
||||
(["place"], "name"), # Place → name (breakroom/site name)
|
||||
(["state"], "_state"), # State → prefixed, NOT overwrite status
|
||||
(["class"], "category"), # Class → category (cleaner than Type values)
|
||||
(["type", "asset type", "equipment type"], "_type"), # Type → prefixed (dirty values)
|
||||
# Single-word/generic heuristics (last, after specific multi-word ones)
|
||||
(["serial", "s/n"], "serial_number"),
|
||||
(["category", "type", "asset type", "equipment type", "class"], "category"),
|
||||
(["status", "state", "condition"], "status"),
|
||||
(["category"], "category"),
|
||||
(["status", "condition"], "status"), # removed 'state' to prevent col 33 override
|
||||
(["make", "manufacturer", "brand", "vendor"], "make"),
|
||||
(["address", "street"], "address"),
|
||||
(["floor", "level", "story"], "floor"),
|
||||
@@ -240,7 +245,11 @@ def parse_excel(file_path: str) -> tuple[list[str], list[dict]]:
|
||||
if i < len(headers):
|
||||
# Convert to native Python types
|
||||
if isinstance(val, datetime):
|
||||
val = val.isoformat()
|
||||
# Use space-separated format so SQLite comparison works
|
||||
# datetime('now') outputs '2026-05-18 22:04:00' (space)
|
||||
# .isoformat() outputs '2026-05-18T22:04:00' (T) which
|
||||
# breaks lexicographic comparison ('T' > ' ' in ASCII)
|
||||
val = val.strftime('%Y-%m-%d %H:%M:%S')
|
||||
elif val is not None and not isinstance(val, (str, int, float, bool)):
|
||||
val = str(val)
|
||||
row_dict[headers[i]] = val if val is not None else ""
|
||||
@@ -1195,3 +1204,190 @@ def resolve_replacements(batch_id: int, body: ReplacementActionRequest):
|
||||
raise HTTPException(status_code=500, detail=f"Resolve replacements failed: {e}")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ─── Per-field granular approval ──────────────────────────────────────────────
|
||||
|
||||
|
||||
class FieldChangeItem(BaseModel):
|
||||
machine_id: str
|
||||
field: str
|
||||
|
||||
|
||||
class PerFieldApprovalRequest(BaseModel):
|
||||
changes: list[FieldChangeItem]
|
||||
|
||||
|
||||
@router.get("/batches/{batch_id}/field-changes")
|
||||
def get_field_changes(batch_id: int):
|
||||
"""Return flat list of per-field changes for UI rendering.
|
||||
|
||||
Each entry: {machine_id, name, field, old_value, new_value, is_blank_fill}
|
||||
"""
|
||||
conn = _get_db()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT diff_summary FROM cantaloupe_sync_batches WHERE id = ?",
|
||||
(batch_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="Batch not found")
|
||||
|
||||
diff = row["diff_summary"]
|
||||
if isinstance(diff, str):
|
||||
try:
|
||||
diff = _json.loads(diff)
|
||||
except _json.JSONDecodeError:
|
||||
diff = {}
|
||||
if not isinstance(diff, dict):
|
||||
diff = {}
|
||||
|
||||
changed = diff.get("changed_assets", [])
|
||||
result = []
|
||||
for asset in changed:
|
||||
mid = asset.get("machine_id", "")
|
||||
name = asset.get("name", "")
|
||||
for ch in asset.get("changes", []):
|
||||
old_val = ch.get("old_value")
|
||||
new_val = ch.get("new_value")
|
||||
is_blank = old_val is None or str(old_val).strip() == ""
|
||||
result.append({
|
||||
"machine_id": mid,
|
||||
"name": name,
|
||||
"field": ch.get("field", ""),
|
||||
"old_value": old_val,
|
||||
"new_value": new_val,
|
||||
"is_blank_fill": is_blank,
|
||||
})
|
||||
|
||||
# Also add new assets (field-level doesn't apply to inserts)
|
||||
new_assets = diff.get("new_assets", [])
|
||||
replacements = diff.get("replacements", [])
|
||||
|
||||
return {
|
||||
"batch_id": batch_id,
|
||||
"field_changes": result,
|
||||
"new_asset_count": len(new_assets),
|
||||
"replacement_count": len(replacements),
|
||||
"import_count": diff.get("import_count", 0),
|
||||
"live_asset_count": diff.get("live_asset_count", 0),
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.post("/batches/{batch_id}/apply-field-changes")
|
||||
def apply_field_changes(batch_id: int, body: PerFieldApprovalRequest, request: Request):
|
||||
"""Apply only the selected field changes to existing assets.
|
||||
|
||||
New assets and replacements are NOT handled by this endpoint — use the
|
||||
existing approve/replacement endpoints for those.
|
||||
|
||||
Body: {changes: [{machine_id: str, field: str}, ...]}
|
||||
Only fields listed are applied (old_value→new_value for existing assets).
|
||||
"""
|
||||
conn = _get_db()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM cantaloupe_sync_batches WHERE id = ?",
|
||||
(batch_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="Batch not found")
|
||||
if row["status"] != "pending":
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Cannot apply changes in status '{row['status']}'. Only 'pending' batches can be modified.",
|
||||
)
|
||||
|
||||
# Parse diff and raw data
|
||||
diff = row["diff_summary"]
|
||||
if isinstance(diff, str):
|
||||
try:
|
||||
diff = _json.loads(diff)
|
||||
except _json.JSONDecodeError:
|
||||
diff = {}
|
||||
if not isinstance(diff, dict):
|
||||
diff = {}
|
||||
|
||||
raw_data = row["raw_data"]
|
||||
if isinstance(raw_data, str):
|
||||
raw_data = _json.loads(raw_data)
|
||||
if not raw_data:
|
||||
raise HTTPException(status_code=400, detail="Batch has no raw data")
|
||||
|
||||
# Index imported rows by machine_id
|
||||
imported_by_mid: dict[str, dict] = {}
|
||||
for item in raw_data:
|
||||
mid = str(item.get("machine_id", "")).strip()
|
||||
if mid:
|
||||
imported_by_mid[mid] = item
|
||||
|
||||
stats = {"assets_updated": 0, "fields_applied": 0}
|
||||
|
||||
# Group approved changes by machine_id
|
||||
approved_by_mid: dict[str, set[str]] = {}
|
||||
for change in body.changes:
|
||||
mid = change.machine_id.strip()
|
||||
if mid not in approved_by_mid:
|
||||
approved_by_mid[mid] = set()
|
||||
approved_by_mid[mid].add(change.field)
|
||||
|
||||
for mid, approved_fields in approved_by_mid.items():
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM assets WHERE machine_id = ?",
|
||||
(mid,),
|
||||
).fetchone()
|
||||
if not existing:
|
||||
continue # skip new assets (handled by approve endpoint)
|
||||
|
||||
imported = imported_by_mid.get(mid)
|
||||
if not imported:
|
||||
continue
|
||||
|
||||
updates = {}
|
||||
for field in approved_fields:
|
||||
val = imported.get(field)
|
||||
if val is not None and str(val).strip() != "":
|
||||
updates[field] = str(val).strip()
|
||||
|
||||
if not updates:
|
||||
continue
|
||||
|
||||
updates["updated_at"] = "datetime('now')"
|
||||
set_clause = ", ".join(
|
||||
f"{k} = {v}" if k == "updated_at" else f"{k} = ?"
|
||||
for k, v in updates.items()
|
||||
)
|
||||
values = [v for k, v in updates.items() if k != "updated_at"]
|
||||
conn.execute(
|
||||
f"UPDATE assets SET {set_clause} WHERE id = ?",
|
||||
values + [existing["id"]],
|
||||
)
|
||||
stats["assets_updated"] += 1
|
||||
stats["fields_applied"] += len(approved_fields)
|
||||
|
||||
conn.commit()
|
||||
|
||||
# Log activity
|
||||
user_id = getattr(request.state, "user_id", None)
|
||||
conn.execute(
|
||||
"INSERT INTO activity_log (user_id, action, entity_type, entity_id, details) "
|
||||
"VALUES (?, 'apply_field_changes', 'cantaloupe_batch', ?, ?)",
|
||||
(user_id, batch_id, _json.dumps(stats)),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
return {
|
||||
"batch_id": batch_id,
|
||||
"applied_stats": stats,
|
||||
"message": f"Applied {stats['fields_applied']} field change(s) across {stats['assets_updated']} asset(s).",
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
raise HTTPException(status_code=500, detail=f"Apply field changes failed: {e}")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
Reference in New Issue
Block a user