From c35e2a1af9c83d277f63375b66752c9d14f2a448 Mon Sep 17 00:00:00 2001 From: Leo Date: Sat, 23 May 2026 18:24:39 -0400 Subject: [PATCH] feat: per-field approval backend + geotagger-style frontend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- cantaloupe_sync.py | 202 ++++++++++++++++++++++++++++++++++++++++++- static/index.html | 207 ++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 404 insertions(+), 5 deletions(-) diff --git a/cantaloupe_sync.py b/cantaloupe_sync.py index c12169f..0fb5343 100644 --- a/cantaloupe_sync.py +++ b/cantaloupe_sync.py @@ -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() diff --git a/static/index.html b/static/index.html index e63db95..3fe3312 100644 --- a/static/index.html +++ b/static/index.html @@ -2294,8 +2294,10 @@ function renderCsPendingReview() {
${remCount}
Removed
-
- + +
+ +
`; @@ -2538,6 +2540,207 @@ async function rejectBatch(batchId) { } } +// ── Per-Field Review (geotagger-style) ── +let _csFieldReviewState = null; // { batchId, changes, selected: Set('mid:field') } +let _csFieldReviewOpen = false; + +async function toggleFieldReview(batchId) { + const area = document.getElementById('csFieldReviewArea'); + if (!area) return; + + if (_csFieldReviewOpen) { + area.style.display = 'none'; + _csFieldReviewOpen = false; + return; + } + + _csFieldReviewState = { batchId, changes: [], selected: new Set() }; + + try { + const data = await api(`/api/admin/cantaloupe/batches/${batchId}/field-changes`); + _csFieldReviewState.changes = data.field_changes || []; + renderFieldChanges(area); + area.style.display = 'block'; + _csFieldReviewOpen = true; + } catch (e) { + showToast('Failed to load field changes: ' + e.message, true); + } +} + +function renderFieldChanges(area) { + const state = _csFieldReviewState; + if (!state || !state.changes.length) { + area.innerHTML = '
No field-level changes to review.
'; + return; + } + + let html = '
'; + html += '
'; + html += `🧩 ${state.changes.length} Field Change(s)`; + html += '
'; + html += ''; + html += ''; + html += '
'; + + // Group by machine_id for display + const grouped = {}; + for (const ch of state.changes) { + const key = ch.machine_id + '|' + ch.name; + if (!grouped[key]) grouped[key] = { machine_id: ch.machine_id, name: ch.name, changes: [] }; + grouped[key].changes.push(ch); + } + + html += '
'; + + for (const [key, asset] of Object.entries(grouped)) { + const assetChanges = asset.changes; + const allSelected = assetChanges.every(c => state.selected.has(c.machine_id + ':' + c.field)); + const anySelected = assetChanges.some(c => state.selected.has(c.machine_id + ':' + c.field)); + + html += `
`; + html += `
+ + ${esc(asset.machine_id)} — ${esc(asset.name || '—')} + ${assetChanges.length} change(s) +
`; + + for (const ch of assetChanges) { + const selKey = ch.machine_id + ':' + ch.field; + const checked = state.selected.has(selKey); + const isBlank = ch.is_blank_fill; + html += `
`; + html += ``; + html += `${esc(ch.field)}`; + html += `${esc(String(ch.old_value ?? '—'))}`; + html += ``; + html += `${esc(String(ch.new_value ?? '—'))}`; + if (isBlank) html += `BLANK FILL`; + html += `
`; + } + html += '
'; + } + + html += '
'; // close scroll container + + // Sticky apply bar at bottom + const selectedCount = state.selected.size; + html += `
+ ${selectedCount} field change(s) selected +
+ +
+
`; + + area.innerHTML = html; +} + +function toggleAssetChanges(machineId, checked) { + const state = _csFieldReviewState; + if (!state) return; + for (const ch of state.changes) { + if (ch.machine_id === machineId) { + const key = machineId + ':' + ch.field; + if (checked) state.selected.add(key); + else state.selected.delete(key); + } + } + // Refresh all checkboxes within this asset card + for (const ch of state.changes) { + if (ch.machine_id === machineId) { + const key = machineId + ':' + ch.field; + const cb = document.getElementById('fcb_' + key); + if (cb) cb.checked = checked; + } + } + updateFieldCount(); +} + +function toggleFieldChange(machineId, field, checked) { + const state = _csFieldReviewState; + if (!state) return; + const key = machineId + ':' + field; + if (checked) state.selected.add(key); + else state.selected.delete(key); + updateFieldCount(); +} + +function toggleAllFieldChanges() { + const state = _csFieldReviewState; + if (!state) return; + const allSelected = state.changes.every(c => state.selected.has(c.machine_id + ':' + c.field)); + for (const ch of state.changes) { + const key = ch.machine_id + ':' + ch.field; + if (allSelected) state.selected.delete(key); + else state.selected.add(key); + } + renderFieldChanges(document.getElementById('csFieldReviewArea')); +} + +function selectAllBlankFills() { + const state = _csFieldReviewState; + if (!state) return; + for (const ch of state.changes) { + if (ch.is_blank_fill) { + state.selected.add(ch.machine_id + ':' + ch.field); + } + } + renderFieldChanges(document.getElementById('csFieldReviewArea')); +} + +function updateFieldCount() { + const state = _csFieldReviewState; + const countEl = document.getElementById('csFieldCount'); + const btn = document.getElementById('csApplyFieldBtn'); + if (!state) return; + const count = state.selected.size; + if (countEl) countEl.textContent = count; + if (btn) { + btn.disabled = count === 0; + btn.style.opacity = count === 0 ? '0.5' : '1'; + } +} + +async function applyFieldChanges(batchId) { + const state = _csFieldReviewState; + if (!state || state.selected.size === 0) { + showToast('No field changes selected', true); + return; + } + + const changes = []; + for (const key of state.selected) { + // key is 'machineId:field' + const pipeIdx = key.indexOf(':'); + if (pipeIdx > 0) { + changes.push({ + machine_id: key.substring(0, pipeIdx), + field: key.substring(pipeIdx + 1), + }); + } + } + + const confirmed = await showModal('Apply Changes', + `Apply ${changes.length} selected field change(s) to live data? Blank-fill changes are pre-selected.`, + 'Apply', 'btn-green'); + if (!confirmed) return; + + try { + const result = await api(`/api/admin/cantaloupe/batches/${batchId}/apply-field-changes`, { + method: 'POST', + body: JSON.stringify({ changes }), + headers: { 'Content-Type': 'application/json' }, + }); + showToast(result.message || 'Field changes applied'); + // Close review area, refresh + const area = document.getElementById('csFieldReviewArea'); + if (area) area.style.display = 'none'; + _csFieldReviewOpen = false; + await loadCantaloupeSync(); + } catch (e) { + showToast('Apply failed: ' + e.message, true); + } +} + // ── Replacement Resolution ── async function resolveReplacement(batchId, newMid, oldMid, action) { const label = action === 'approve' ? 'Approve' : 'Reject';