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() {
${esc(asset.machine_id)} — ${esc(asset.name || '—')}
+ ${assetChanges.length} change(s)
+