Files
canteen-admin-server/cantaloupe_sync.py
T
Leo 510bafc919 feat: multi-role support — comma-separated roles with checkbox UI
- Added validate_roles() and roles_contain() helpers for comma-separated roles
- Updated create_user/update_user to accept role combinations (e.g. 'admin,technician')
- Changed all exact role queries (='technician', IN('technician','admin')) to LIKE
- Replaced single <select> with checkboxes in add/edit user modals
- Added renderRoleBadges() to display multiple role badges per user
- Added checkbox-group/checkbox-label CSS styles
- Updated tech-photo-upload technician query to use LIKE
- Updated db reset auth check to use roles_contain()
- Set shawn's role to 'admin,technician'
- Cleaned up unused imports (hashlib, io, etc.)

Applies to canteen-admin-server, canteen-admin-server-dev, and tech-photo-upload
2026-05-25 23:11:00 -04:00

1400 lines
53 KiB
Python

"""
Cantaloupe Sync — staged import pipeline for Cantaloupe machine data.
Parses Cantaloupe Excel exports, maps columns to canteen schema,
computes diffs against live data, and provides approve/reject workflow.
Depends on:
- openpyxl for Excel parsing
- The shared SQLite DB (same CANTEEN_DB_PATH as admin_server)
Exports a FastAPI APIRouter for mounting into admin_server.py.
"""
import json as _json
import logging
import os
import sqlite3
import tempfile
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
import openpyxl
from fastapi import APIRouter, HTTPException, Query, Request, UploadFile, File
from pydantic import BaseModel
# ─── Replacement models ──────────────────────────────────────────────────────
class ReplacementActionItem(BaseModel):
new_machine_id: str
old_machine_id: str
action: str # "approve" or "reject"
class ReplacementActionRequest(BaseModel):
replacements: list[ReplacementActionItem]
logger = logging.getLogger("cantaloupe_sync")
# ─── Shared DB access ───────────────────────────────────────────────────────
DB_PATH = os.environ.get("CANTEEN_DB_PATH", str(Path(__file__).parent / "assets.db"))
def _get_db() -> sqlite3.Connection:
conn = sqlite3.connect(DB_PATH)
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON")
conn.row_factory = sqlite3.Row
return conn
# ─── Router ──────────────────────────────────────────────────────────────────
router = APIRouter(prefix="/api/admin/cantaloupe", tags=["cantaloupe"])
# ─── Table creation (called from admin_server init_db) ──────────────────────
def create_sync_tables(conn: sqlite3.Connection):
"""Create cantaloupe_sync_batches table if not exists."""
conn.executescript("""
CREATE TABLE IF NOT EXISTS cantaloupe_sync_batches (
id INTEGER PRIMARY KEY AUTOINCREMENT,
status TEXT NOT NULL DEFAULT 'pending',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
approved_at TEXT,
file_path TEXT,
row_count INTEGER DEFAULT 0,
diff_summary TEXT, -- JSON: {new_assets, removed_assets, changed_assets, ...}
raw_data TEXT, -- JSON: full export rows
error_message TEXT
);
CREATE INDEX IF NOT EXISTS idx_csb_status ON cantaloupe_sync_batches(status);
CREATE INDEX IF NOT EXISTS idx_csb_created ON cantaloupe_sync_batches(created_at);
""")
# ─── Pydantic models ────────────────────────────────────────────────────────
class BatchSummary(BaseModel):
id: int
status: str
created_at: str
approved_at: Optional[str] = None
file_path: Optional[str] = None
row_count: int
diff_summary: Optional[dict] = None
error_message: Optional[str] = None
class BatchDetail(BatchSummary):
raw_data: Optional[list] = None
diff_rows: Optional[dict] = None # {new: [...], removed: [...], changed: [...]}
# ─── Column mapping (heuristic) ─────────────────────────────────────────────
# Canonical canteen asset columns (excluding auto-generated id/created_at/updated_at)
ASSET_COLUMNS = [
"machine_id", "serial_number", "name", "description", "category",
"status", "make", "model", "address", "building_name", "building_number",
"floor", "room", "trailer_number", "walking_directions", "map_link",
"parking_location", "photo_path", "latitude", "longitude",
"geofence_radius_meters",
"dex_report_date", "deployed", "pulled_date",
]
# Heuristic keywords → canonical column. Order matters — first match wins.
COLUMN_HEURISTICS = [
# machine_id is the primary key for upserts
(["asset id", "assetid", "machine id", "machineid", "machine_id", "equipment id", "unit id", "asset number"], "machine_id"),
# Multi-word / specific heuristics first to avoid greedy single-word matches
(["location name", "location_name", "site name"], "_location_name"),
(["building name", "building_name", "bldg name"], "building_name"),
(["building number", "building_number", "building_no", "bldg number", "bldg #", "building #"], "building_number"),
(["trailer number", "trailer_number", "trailer_no", "trailer #", "mobile unit"], "trailer_number"),
(["walking directions", "walking_directions", "how to get there"], "walking_directions"),
(["map link", "map_link", "google maps", "map url", "location url"], "map_link"),
(["parking location", "parking_location", "parking spot"], "parking_location"),
(["street address", "location address", "site address"], "address"),
(["asset name", "equipment name"], "name"),
(["model number", "model_no", "model #"], "model"),
(["serial number", "serialnumber", "serial_no", "s/n"], "serial_number"),
(["room number", "room #", "suite"], "room"),
(["customer name", "client name", "account name", "company"], "_customer_name"),
(["photo path", "photo_path", "image path"], "photo_path"),
(["route number", "route #", "delivery route"], "_route"),
(["sales rep", "salesperson", "account manager"], "_sales_rep"),
(["qr code", "tag number", "tag #"], "_barcode"),
(["geofence radius", "geofence_radius", "geo radius"], "geofence_radius_meters"),
(["gps lat", "lat"], "latitude"),
(["gps lng", "gps long", "long", "lng", "lon"], "longitude"),
(["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"], "category"),
(["status", "condition"], "status"), # removed 'state' to prevent col 33 override
(["make", "manufacturer", "brand", "vendor"], "make"),
(["address", "street"], "address"),
(["floor", "level", "story"], "floor"),
(["latitude", "gps"], "latitude"),
(["longitude", "geo"], "longitude"),
(["geofence", "radius"], "geofence_radius_meters"),
(["customer", "client", "account"], "_customer_name"),
(["location", "site"], "_location_name"),
(["name"], "name"),
(["description", "notes", "detail", "comments"], "description"),
(["model"], "model"),
(["building"], "building_name"),
(["room"], "room"),
(["trailer"], "trailer_number"),
(["walking", "directions"], "walking_directions"),
(["map"], "map_link"),
(["parking"], "parking_location"),
(["photo", "image", "picture"], "photo_path"),
(["route"], "_route"),
(["sales", "rep"], "_sales_rep"),
(["barcode", "tag", "qr"], "_barcode"),
]
def _normalize(s: str) -> str:
"""Lowercase, strip, collapse whitespace. Replace underscores with spaces
so 'machine_id' matches 'machine id'."""
return " ".join(str(s).lower().strip().replace("_", " ").replace("#", " ").split())
def map_columns(excel_headers: list[str]) -> dict[str, str]:
"""
Heuristically map Excel column headers to canonical canteen columns.
Returns dict: {excel_column_name: canonical_column_name}
"""
mapping = {}
discovered = []
unmatched = []
for header in excel_headers:
norm = _normalize(header)
if not norm:
continue
matched = False
norm_words = set(norm.split())
for keywords, canonical in COLUMN_HEURISTICS:
for kw in keywords:
kw_norm = _normalize(kw)
if not kw_norm:
continue
kw_words = set(kw_norm.split())
# Match: all keyword words must be present in the header.
# This prevents short headers like "name" from matching
# multi-word keywords like "location name".
if kw_words.issubset(norm_words):
mapping[header] = canonical
discovered.append(f"{header}{canonical}")
matched = True
break
if matched:
break
if not matched:
unmatched.append(header)
# Log discoveries
logger.info("Column mapping (%d headers):", len(excel_headers))
for d in discovered:
logger.info("%s", d)
for u in unmatched:
logger.info(" ✗ Unmapped: %s", u)
return mapping
def parse_excel(file_path: str) -> tuple[list[str], list[dict]]:
"""
Parse Cantaloupe Excel export.
Returns (headers, rows) where rows are list of dicts (raw Excel values).
"""
wb = openpyxl.load_workbook(file_path, read_only=True, data_only=True)
ws = wb.active
rows_iter = ws.iter_rows(values_only=True)
try:
headers_raw = next(rows_iter)
except StopIteration:
wb.close()
raise HTTPException(status_code=400, detail="Excel file is empty (no header row)")
headers = [str(h).strip() if h is not None else f"__col_{i}" for i, h in enumerate(headers_raw)]
data_rows = []
for row in rows_iter:
if all(v is None or str(v).strip() == "" for v in row):
continue # skip empty rows
row_dict = {}
for i, val in enumerate(row):
if i < len(headers):
# Convert to native Python types
if isinstance(val, datetime):
# 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 ""
else:
row_dict[f"__col_{i}"] = val if val is not None else ""
data_rows.append(row_dict)
wb.close()
return headers, data_rows
def apply_mapping(rows: list[dict], column_map: dict[str, str]) -> list[dict]:
"""
Apply column mapping to transform Excel rows into canteen-shaped dicts.
Returns list of dicts with canonical column keys.
Extra/custom fields are preserved with a `_raw_` prefix.
"""
mapped = []
for row in rows:
item = {}
for excel_col, value in row.items():
canonical = column_map.get(excel_col)
if canonical:
# If multiple Excel columns map to same canonical, first wins
if canonical not in item:
item[canonical] = value
else:
# Preserve unmapped columns
item[f"_raw_{excel_col}"] = value
mapped.append(item)
return mapped
# ─── Diff engine ────────────────────────────────────────────────────────────
def _fetch_live_data(conn: sqlite3.Connection) -> dict[str, list[dict]]:
"""Fetch all current assets, customers, locations from live DB."""
assets = [dict(r) for r in conn.execute("SELECT * FROM assets ORDER BY machine_id").fetchall()]
customers = [dict(r) for r in conn.execute("SELECT * FROM customers ORDER BY name").fetchall()]
locations = [dict(r) for r in conn.execute("SELECT * FROM locations ORDER BY id").fetchall()]
return {"assets": assets, "customers": customers, "locations": locations}
def detect_replacements(
imported_rows: list[dict],
live_assets: list[dict],
live_customers: list[dict],
) -> dict:
"""
Detect potential machine replacements where a new Asset ID appears
at the same customer + address as an existing asset not in the import.
Confidence levels:
- "high": exact address match (same street address string)
- "medium": same customer + same building_name, or same street name
"""
import re
# Build lookups
live_assets_by_mid = {str(a.get("machine_id", "")).strip(): a for a in live_assets}
imported_mids = set()
# customer name (lower) → id
cust_name_to_id: dict[str, int] = {}
for c in live_customers:
cname = str(c.get("name", "")).strip()
if cname:
cust_name_to_id[cname.lower()] = c["id"]
# Collect all imported machine IDs
for row in imported_rows:
mid = str(row.get("machine_id", "")).strip()
if mid:
imported_mids.add(mid)
replacements: list[dict] = []
seen_pairs: set[tuple[str, str]] = set()
for row in imported_rows:
new_mid = str(row.get("machine_id", "")).strip()
if not new_mid:
continue
if new_mid in live_assets_by_mid:
continue # already exists, not a replacement
cust_name = str(row.get("_customer_name", "")).strip()
addr = str(row.get("address", "")).strip()
bldg = str(row.get("building_name", "")).strip()
if not cust_name:
continue
cust_id = cust_name_to_id.get(cust_name.lower())
if cust_id is None:
continue # unknown customer, can't match
# Scan live assets at the same customer, not in the import
for old_mid, live in live_assets_by_mid.items():
if old_mid == new_mid:
continue
if live.get("customer_id") != cust_id:
continue
if old_mid in imported_mids:
continue # this asset IS in the import, not a leftover
# Avoid duplicate pairs
pair = (new_mid, old_mid)
if pair in seen_pairs:
continue
live_addr = str(live.get("address", "")).strip()
live_bldg = str(live.get("building_name", "")).strip()
confidence = None
# High: exact address match
if addr and live_addr and addr.lower() == live_addr.lower():
confidence = "high"
# Medium: same building_name
elif bldg and live_bldg and bldg.lower() == live_bldg.lower():
confidence = "medium"
# Medium: same street name (numbers and suffixes stripped)
elif addr and live_addr and _same_street(addr, live_addr):
confidence = "medium"
if confidence:
seen_pairs.add(pair)
replacements.append({
"id": len(replacements) + 1,
"new_machine_id": new_mid,
"old_machine_id": old_mid,
"location_address": addr or live_addr or "",
"customer_name": cust_name,
"old_asset_name": str(live.get("name", "")).strip(),
"new_asset_name": str(row.get("name", "")).strip(),
"confidence": confidence,
})
return {"replacements": replacements}
def _same_street(addr1: str, addr2: str) -> bool:
"""Check if two addresses share the same street name, ignoring numbers and suffixes."""
import re
suffixes = {
"street", "st", "drive", "dr", "road", "rd", "avenue", "ave",
"blvd", "boulevard", "lane", "ln", "way", "court", "ct", "circle",
"cir", "place", "pl", "highway", "hwy", "suite", "ste", "unit",
"floor", "fl", "build", "bldg",
}
a1 = re.sub(r"\d+", "", addr1.lower())
a2 = re.sub(r"\d+", "", addr2.lower())
for suffix in suffixes:
a1 = re.sub(rf"\b{suffix}\b", "", a1)
a2 = re.sub(rf"\b{suffix}\b", "", a2)
a1 = re.sub(r"\s+", " ", a1).strip().strip(",").strip("#").strip()
a2 = re.sub(r"\s+", " ", a2).strip().strip(",").strip("#").strip()
return bool(a1 and a2 and a1 == a2)
def compute_diff(imported_rows: list[dict], live_data: dict) -> dict:
"""
Compare imported Cantaloupe rows against live data.
Returns diff dict:
{
"new_assets": [...], # machine_ids not in live DB
"removed_assets": [...], # in live DB but not in import
"changed_assets": [...], # both exist but fields differ
"unchanged_assets": [...], # match exactly
"new_customers": [...],
"new_locations": [...],
"import_count": N,
"live_asset_count": N,
}
Each entry in changed_assets: {machine_id, field, old_value, new_value}
"""
live_assets = {a["machine_id"]: a for a in live_data["assets"]}
live_customers = {c["name"].lower(): c for c in live_data["customers"]}
imported_machine_ids = set()
new_assets = []
changed_assets = []
unchanged_assets = []
new_customers = []
new_locations = []
# Fields to compare for assets (skip id, created_at, updated_at, customer_id, location_id)
COMPARE_FIELDS = [
"serial_number", "name", "description", "category", "status",
"make", "model", "address", "building_name", "building_number",
"floor", "room", "trailer_number", "walking_directions", "map_link",
"parking_location", "photo_path", "latitude", "longitude",
"geofence_radius_meters",
"dex_report_date", "deployed", "pulled_date",
]
for row in imported_rows:
mid = str(row.get("machine_id", "")).strip()
if not mid:
continue # skip rows without a machine_id
imported_machine_ids.add(mid)
# Check for new customer
cust_name = str(row.get("_customer_name", "")).strip()
if cust_name and cust_name.lower() not in live_customers:
if cust_name not in new_customers:
new_customers.append(cust_name)
# Check for new location
loc_name = str(row.get("_location_name", "")).strip()
if loc_name:
# locations are matched by address+name later in approve step;
# just flag for now
new_locations.append(loc_name)
if mid in live_assets:
live = live_assets[mid]
changes = []
for field in COMPARE_FIELDS:
old_val = live.get(field)
new_val = row.get(field)
# Normalize comparison
old_str = str(old_val).strip() if old_val is not None else ""
new_str = str(new_val).strip() if new_val is not None else ""
if old_str != new_str:
changes.append({
"field": field,
"old_value": old_val,
"new_value": new_val,
})
if changes:
changed_assets.append({
"machine_id": mid,
"name": live.get("name", ""),
"changes": changes,
})
else:
unchanged_assets.append(mid)
else:
new_assets.append({
"machine_id": mid,
"name": str(row.get("name", "")).strip(),
"customer": str(row.get("_customer_name", "")).strip(),
"location": str(row.get("_location_name", "")).strip(),
})
# Removed: in live but not in import
removed_assets = [
{"machine_id": mid, "name": a.get("name", "")}
for mid, a in live_assets.items()
if mid not in imported_machine_ids
]
return {
"new_assets": new_assets,
"removed_assets": removed_assets,
"changed_assets": changed_assets,
"unchanged_assets": unchanged_assets,
"new_customers": list(set(new_customers)),
"new_locations": list(set(new_locations)),
"import_count": len(imported_rows),
"live_asset_count": len(live_assets),
}
# ─── Route: Run sync ────────────────────────────────────────────────────────
@router.post("/sync")
async def run_sync(
request: Request,
file: Optional[UploadFile] = File(None),
file_path: Optional[str] = Query(None, description="Server-side path to Excel file"),
):
"""
Import an Excel file into a staged batch for review.
Accepts either:
- An uploaded Excel file (multipart form)
- A server-side file path (query param)
Parses the Excel, maps columns to canteen schema, computes diffs,
stores everything in a new cantaloupe_sync_batches row with status='pending'.
"""
if file and file.filename:
# Save uploaded file to temp
suffix = Path(file.filename).suffix or ".xlsx"
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=suffix)
try:
content = await file.read()
tmp.write(content)
tmp.close()
excel_path = tmp.name
display_path = f"upload:{file.filename}"
except Exception as e:
tmp.close()
Path(tmp.name).unlink(missing_ok=True)
raise HTTPException(status_code=400, detail=f"Failed to read uploaded file: {e}")
elif file_path:
p = Path(file_path)
if not p.exists():
raise HTTPException(status_code=400, detail=f"File not found: {file_path}")
excel_path = str(p)
display_path = file_path
else:
raise HTTPException(
status_code=400,
detail="No file provided. Upload an Excel file to import asset data."
)
conn = _get_db()
try:
# Parse Excel
try:
headers, raw_rows = parse_excel(excel_path)
except Exception as e:
raise HTTPException(status_code=400, detail=f"Failed to parse Excel: {e}")
if not raw_rows:
raise HTTPException(status_code=400, detail="Excel file contains no data rows")
# Map columns
column_map = map_columns(headers)
mapped_rows = apply_mapping(raw_rows, column_map)
# Normalize categories: strip "GF " prefix so "GF Food" → "Food", etc.
for row in mapped_rows:
cat = row.get("category", "")
if cat and cat.startswith("GF "):
row["category"] = cat[3:]
# Compute diff
live_data = _fetch_live_data(conn)
diff = compute_diff(mapped_rows, live_data)
# Detect machine replacements
replacements = detect_replacements(
mapped_rows, live_data["assets"], live_data["customers"]
)
diff["replacements"] = replacements.get("replacements", [])
# Store batch
raw_json = _json.dumps(mapped_rows, default=str, ensure_ascii=False)
diff_json = _json.dumps(diff, default=str, ensure_ascii=False)
cursor = conn.execute(
"""INSERT INTO cantaloupe_sync_batches
(status, file_path, row_count, diff_summary, raw_data)
VALUES ('pending', ?, ?, ?, ?)""",
(display_path, len(raw_rows), diff_json, raw_json),
)
conn.commit()
batch_id = cursor.lastrowid
# Fetch back
row = conn.execute(
"SELECT * FROM cantaloupe_sync_batches WHERE id = ?", (batch_id,)
).fetchone()
result = _batch_to_dict(row)
logger.info(
"Sync batch %d created: %d rows, %d new, %d changed, %d removed, %d unchanged",
batch_id, diff["import_count"],
len(diff["new_assets"]), len(diff["changed_assets"]),
len(diff["removed_assets"]), len(diff["unchanged_assets"]),
)
return result
finally:
conn.close()
# Clean up temp file if we created one
if file and file.filename and excel_path:
Path(excel_path).unlink(missing_ok=True)
# ─── Helper: serialize batch row ────────────────────────────────────────────
def _batch_to_dict(row: sqlite3.Row) -> dict:
d = dict(row)
# Parse JSON fields
for field in ("diff_summary", "raw_data"):
if isinstance(d.get(field), str):
try:
d[field] = _json.loads(d[field])
except (_json.JSONDecodeError, TypeError):
pass
return d
# ─── Route: List batches ────────────────────────────────────────────────────
@router.get("/batches")
def list_batches(
status: Optional[str] = Query(None, description="Filter by status"),
limit: int = Query(50, ge=1, le=500),
offset: int = Query(0, ge=0),
):
"""List all sync batches, newest first."""
conn = _get_db()
try:
where = ""
params = []
if status:
where = "WHERE status = ?"
params.append(status)
rows = conn.execute(
f"SELECT id, status, created_at, approved_at, file_path, row_count, diff_summary, error_message "
f"FROM cantaloupe_sync_batches {where} "
f"ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?",
params + [limit, offset],
).fetchall()
return [_batch_to_dict(r) for r in rows]
finally:
conn.close()
# ─── Route: Get batch detail ────────────────────────────────────────────────
@router.get("/batches/{batch_id}")
def get_batch(batch_id: int):
"""Get full batch detail including row-level diff and raw imported data."""
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")
batch = _batch_to_dict(row)
# Compute row-level diff display
diff = batch.get("diff_summary", {}) if isinstance(batch.get("diff_summary"), dict) else {}
batch["diff_rows"] = {
"new": diff.get("new_assets", []),
"removed": diff.get("removed_assets", []),
"changed": diff.get("changed_assets", []),
}
# Replacement stats
replacements = diff.get("replacements", []) if isinstance(diff, dict) else []
batch["replacement_count"] = len(replacements)
batch["unresolved_replacements"] = len([r for r in replacements if not r.get("status")])
# Include raw data (imported rows)
raw = batch.get("raw_data")
if isinstance(raw, str):
try:
raw = _json.loads(raw)
except _json.JSONDecodeError:
pass
batch["raw_data"] = raw
return batch
finally:
conn.close()
# ─── Route: Approve batch ───────────────────────────────────────────────────
@router.post("/batches/{batch_id}/approve")
def approve_batch(
batch_id: int,
request: Request,
force: bool = Query(False, description="Force approval even with unresolved replacements"),
):
"""
Apply staged data to live tables:
- Upsert assets (match on machine_id)
- Upsert customers (match on name)
- Upsert locations (match on address + building_name + building_number)
Checks for unresolved replacement candidates and blocks unless forced.
Sets status='approved' and records approved_at.
"""
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 approve batch in status '{row['status']}'. Only 'pending' batches can be approved.",
)
# Check for unresolved replacements
diff = row["diff_summary"]
if isinstance(diff, str):
try:
diff = _json.loads(diff)
except _json.JSONDecodeError:
diff = {}
if isinstance(diff, dict):
replacements = diff.get("replacements", [])
unresolved = [r for r in replacements if not r.get("status")]
if unresolved and not force:
raise HTTPException(
status_code=409,
detail=f"Cannot approve: {len(unresolved)} replacement(s) are unresolved. Resolve them first or use ?force=true.",
)
# Parse raw data
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 to apply")
imported_rows = raw_data if isinstance(raw_data, list) else []
# Collect stats
stats = {
"assets_upserted": 0,
"customers_created": 0,
"locations_created": 0,
}
# Track customer name → id mapping
customer_cache = {}
for item in imported_rows:
machine_id = str(item.get("machine_id", "")).strip()
if not machine_id:
continue
# 1. Upsert customer (match on name)
cust_name = str(item.get("_customer_name", "")).strip()
cust_id = None
if cust_name:
if cust_name in customer_cache:
cust_id = customer_cache[cust_name]
else:
cust_row = conn.execute(
"SELECT id FROM customers WHERE LOWER(name) = LOWER(?)",
(cust_name,),
).fetchone()
if cust_row:
cust_id = cust_row["id"]
else:
cursor = conn.execute(
"INSERT INTO customers (name) VALUES (?)", (cust_name,)
)
cust_id = cursor.lastrowid
stats["customers_created"] += 1
customer_cache[cust_name] = cust_id
# 2. Upsert location (match on address + building_name + building_number)
loc_name = str(item.get("_location_name", "")).strip()
loc_id = None
if loc_name:
loc_row = conn.execute(
"SELECT id FROM locations WHERE LOWER(name) = LOWER(?)",
(loc_name,),
).fetchone()
if loc_row:
loc_id = loc_row["id"]
else:
cursor = conn.execute(
"INSERT INTO locations (name, customer_id) VALUES (?, ?)",
(loc_name, cust_id),
)
loc_id = cursor.lastrowid
stats["locations_created"] += 1
# Also try matching on address
addr = str(item.get("address", "")).strip()
if addr and not loc_id:
loc_row = conn.execute(
"SELECT id FROM locations WHERE LOWER(address) = LOWER(?) AND address != ''",
(addr,),
).fetchone()
if loc_row:
loc_id = loc_row["id"]
# 3. Upsert asset (match on machine_id)
existing = conn.execute(
"SELECT id FROM assets WHERE machine_id = ?", (machine_id,)
).fetchone()
if existing:
# Update existing asset
updates = {}
field_map = {
"serial_number": "serial_number",
"name": "name",
"description": "description",
"category": "category",
"status": "status",
"make": "make",
"model": "model",
"address": "address",
"building_name": "building_name",
"building_number": "building_number",
"floor": "floor",
"room": "room",
"trailer_number": "trailer_number",
"walking_directions": "walking_directions",
"map_link": "map_link",
"parking_location": "parking_location",
"photo_path": "photo_path",
"dex_report_date": "dex_report_date",
"deployed": "deployed",
"pulled_date": "pulled_date",
}
for import_key, db_col in field_map.items():
val = item.get(import_key)
if val is not None and str(val).strip() != "":
updates[db_col] = str(val).strip()
# Handle numeric fields
for num_field in ("latitude", "longitude", "geofence_radius_meters"):
val = item.get(num_field)
if val is not None and str(val).strip() != "":
try:
updates[num_field] = float(val)
except (ValueError, TypeError):
pass
if cust_id:
updates["customer_id"] = cust_id
if loc_id:
updates["location_id"] = loc_id
# Set Disney flag based on customer name
if cust_name:
updates["is_disney"] = 1 if cust_name.startswith("D-") else 0
if updates:
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"]],
)
else:
# Insert new asset
name = str(item.get("name", machine_id)).strip()
category = str(item.get("category", "Other")).strip()
status = str(item.get("status", "active")).strip()
# Validate enums against DB categories table
VALID_STATUSES = {"active", "maintenance", "retired"}
try:
valid_cats = {r[0] for r in conn.execute(
"SELECT name FROM categories").fetchall()}
except Exception:
valid_cats = {"Other"}
if category not in valid_cats:
category = "Other"
if status not in VALID_STATUSES:
status = "active"
conn.execute(
"""INSERT INTO assets
(machine_id, serial_number, name, description, category, status,
make, model, address, building_name, building_number,
floor, room, trailer_number, walking_directions, map_link,
parking_location, photo_path,
customer_id, location_id, is_disney,
latitude, longitude, geofence_radius_meters,
dex_report_date, deployed, pulled_date)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
machine_id,
str(item.get("serial_number", "")).strip() or "",
name,
str(item.get("description", "")).strip() or "",
category,
status,
str(item.get("make", "")).strip() or "",
str(item.get("model", "")).strip() or "",
str(item.get("address", "")).strip() or "",
str(item.get("building_name", "")).strip() or "",
str(item.get("building_number", "")).strip() or "",
str(item.get("floor", "")).strip() or "",
str(item.get("room", "")).strip() or "",
str(item.get("trailer_number", "")).strip() or "",
str(item.get("walking_directions", "")).strip() or "",
str(item.get("map_link", "")).strip() or "",
str(item.get("parking_location", "")).strip() or "",
str(item.get("photo_path", "")).strip() or "",
cust_id,
loc_id,
1 if cust_name and cust_name.startswith("D-") else 0,
_safe_float(item.get("latitude")),
_safe_float(item.get("longitude")),
_safe_int(item.get("geofence_radius_meters"), 50),
str(item.get("dex_report_date", "")).strip() or "",
str(item.get("deployed", "")).strip() or "",
str(item.get("pulled_date", "")).strip() or "",
),
)
stats["assets_upserted"] += 1
# Mark batch as approved
approved_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
conn.execute(
"UPDATE cantaloupe_sync_batches SET status = 'approved', approved_at = ? WHERE id = ?",
(approved_at, batch_id),
)
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 (?, 'approved', 'cantaloupe_batch', ?, ?)",
(user_id, batch_id, _json.dumps(stats)),
)
# Fetch updated batch
updated = conn.execute(
"SELECT * FROM cantaloupe_sync_batches WHERE id = ?", (batch_id,)
).fetchone()
result = _batch_to_dict(updated)
result["applied_stats"] = stats
return result
except HTTPException:
raise
except Exception as e:
conn.rollback()
raise HTTPException(status_code=500, detail=f"Approve failed: {e}")
finally:
conn.close()
def _safe_float(val) -> Optional[float]:
"""Convert to float, return None on failure."""
if val is None:
return None
try:
return float(val)
except (ValueError, TypeError):
return None
def _safe_int(val, default: Optional[int] = None) -> Optional[int]:
"""Convert to int, return default on failure."""
if val is None:
return default
try:
return int(float(val))
except (ValueError, TypeError):
return default
# ─── Route: Reject batch ────────────────────────────────────────────────────
@router.post("/batches/{batch_id}/reject")
def reject_batch(batch_id: int, request: Request):
"""
Reject a pending batch:
- Sets status='rejected'
- Clears raw_data to save space
"""
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 reject batch in status '{row['status']}'. Only 'pending' batches can be rejected.",
)
# Clear raw_data, keep diff_summary for audit trail
conn.execute(
"UPDATE cantaloupe_sync_batches SET status = 'rejected', raw_data = NULL WHERE id = ?",
(batch_id,),
)
# 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 (?, 'rejected', 'cantaloupe_batch', ?, 'Batch rejected')",
(user_id, batch_id),
)
conn.commit()
updated = conn.execute(
"SELECT * FROM cantaloupe_sync_batches WHERE id = ?", (batch_id,)
).fetchone()
return _batch_to_dict(updated)
except HTTPException:
raise
except Exception as e:
conn.rollback()
raise HTTPException(status_code=500, detail=f"Reject failed: {e}")
finally:
conn.close()
# ─── Route: Get replacements ────────────────────────────────────────────────
@router.get("/batches/{batch_id}/replacements")
def get_replacements(batch_id: int):
"""Return replacement candidates for a sync batch."""
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 = {}
replacements = diff.get("replacements", [])
return {"batch_id": batch_id, "replacements": replacements}
finally:
conn.close()
# ─── Route: Resolve replacements ────────────────────────────────────────────
@router.post("/batches/{batch_id}/replacements")
def resolve_replacements(batch_id: int, body: ReplacementActionRequest):
"""Approve or reject individual replacement candidates.
Approve: retires the old asset (status='retired') and sets
install_date = today on the new asset. Reject: records the
decision without modifying assets.
"""
conn = _get_db()
try:
batch_row = conn.execute(
"SELECT * FROM cantaloupe_sync_batches WHERE id = ?", (batch_id,)
).fetchone()
if batch_row is None:
raise HTTPException(status_code=404, detail="Batch not found")
if batch_row["status"] != "pending":
raise HTTPException(
status_code=400,
detail=f"Cannot resolve replacements in status '{batch_row['status']}'. Only 'pending' batches can be modified.",
)
# Parse diff_summary
diff = batch_row["diff_summary"]
if isinstance(diff, str):
try:
diff = _json.loads(diff)
except _json.JSONDecodeError:
diff = {}
if not isinstance(diff, dict):
diff = {}
replacements = diff.get("replacements", [])
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
resolved = []
# Build index of replacements by (new_mid, old_mid)
repl_index: dict[tuple[str, str], dict] = {}
for r in replacements:
key = (str(r.get("new_machine_id", "")).strip(),
str(r.get("old_machine_id", "")).strip())
repl_index[key] = r
for item in body.replacements:
key = (str(item.new_machine_id).strip(),
str(item.old_machine_id).strip())
repl = repl_index.get(key)
if repl is None:
raise HTTPException(
status_code=404,
detail=f"Replacement not found: new={item.new_machine_id} old={item.old_machine_id}",
)
if item.action not in ("approve", "reject"):
raise HTTPException(
status_code=400,
detail=f"Invalid action '{item.action}'. Must be 'approve' or 'reject'.",
)
if item.action == "approve":
# Retire the old asset
old_asset = conn.execute(
"SELECT id, name, description FROM assets WHERE machine_id = ?",
(item.old_machine_id,),
).fetchone()
if old_asset:
old_desc = (old_asset["description"] or "").strip()
link_note = f"[Replaced by {item.new_machine_id} on {today}]"
new_desc = (old_desc + "\n" + link_note).strip() if old_desc else link_note
conn.execute(
"UPDATE assets SET status = 'retired', description = ?, updated_at = datetime('now') WHERE id = ?",
(new_desc, old_asset["id"]),
)
# Set install_date on the new asset
new_asset = conn.execute(
"SELECT id, name, description FROM assets WHERE machine_id = ?",
(item.new_machine_id,),
).fetchone()
if new_asset:
new_desc = (new_asset["description"] or "").strip()
link_note = f"[Replaced {item.old_machine_id} on {today}]"
new_desc = (new_desc + "\n" + link_note).strip() if new_desc else link_note
conn.execute(
"UPDATE assets SET install_date = ?, description = ?, updated_at = datetime('now') WHERE id = ?",
(today, new_desc, new_asset["id"]),
)
repl["status"] = "approved"
else:
repl["status"] = "rejected"
repl["resolved_at"] = today
resolved.append(repl)
# Persist updated replacements in diff_summary
diff["replacements"] = replacements
conn.execute(
"UPDATE cantaloupe_sync_batches SET diff_summary = ? WHERE id = ?",
(_json.dumps(diff, default=str, ensure_ascii=False), batch_id),
)
conn.commit()
return {"batch_id": batch_id, "replacements": replacements, "resolved_count": len(body.replacements)}
except HTTPException:
raise
except Exception as e:
conn.rollback()
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 = new_val is None or str(new_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()