diff --git a/admin_server.py b/admin_server.py index 9ce5c96..f72c406 100644 --- a/admin_server.py +++ b/admin_server.py @@ -26,6 +26,8 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse, StreamingResponse from pydantic import BaseModel +from cantaloupe_sync import router as cantaloupe_router, create_sync_tables + # ─── Config ───────────────────────────────────────────────────────────────── VALID_CATEGORIES = {"Furniture", "Appliances", "Utensils & Serveware", "Equipment", "Other"} @@ -241,10 +243,24 @@ def _create_tables(conn: sqlite3.Connection): created_at TEXT NOT NULL DEFAULT (datetime('now')) ); + 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, + raw_data TEXT, + error_message TEXT + ); + CREATE INDEX IF NOT EXISTS idx_checkins_asset_id ON checkins(asset_id); CREATE INDEX IF NOT EXISTS idx_checkins_created_at ON checkins(created_at); CREATE INDEX IF NOT EXISTS idx_assets_category ON assets(category); CREATE INDEX IF NOT EXISTS idx_assets_machine_id ON assets(machine_id); + 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); """) @@ -293,6 +309,7 @@ def _seed_data(conn: sqlite3.Connection): def init_db(conn: sqlite3.Connection): """Ensure all tables exist, run migrations if needed, seed default data.""" _create_tables(conn) + create_sync_tables(conn) _seed_data(conn) # Add lat/lng columns if missing (v3 migration) cursor = conn.execute("PRAGMA table_info(assets)") @@ -334,6 +351,9 @@ app.add_middleware( allow_headers=["*"], ) +# Mount Cantaloupe sync routes +app.include_router(cantaloupe_router) + # ─── Auth Middleware ──────────────────────────────────────────────────────── diff --git a/cantaloupe_sync.py b/cantaloupe_sync.py new file mode 100644 index 0000000..1cf5e34 --- /dev/null +++ b/cantaloupe_sync.py @@ -0,0 +1,870 @@ +""" +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 + +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", +] + +# 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"), + (["serial", "serial number", "serialnumber", "serial_no", "s/n"], "serial_number"), + (["name", "asset name", "equipment name", "description"], "name"), + (["description", "notes", "detail", "comments"], "description"), + (["category", "type", "asset type", "equipment type", "class"], "category"), + (["status", "state", "condition"], "status"), + (["make", "manufacturer", "brand", "vendor"], "make"), + (["model", "model number", "model_no", "model #"], "model"), + (["address", "street", "street address", "location address", "site address"], "address"), + (["building name", "building_name", "bldg name", "site name"], "building_name"), + (["building number", "building_number", "building_no", "bldg number", "bldg #", "building #"], "building_number"), + (["floor", "level", "story"], "floor"), + (["room", "room number", "room #", "suite"], "room"), + (["trailer", "trailer number", "trailer_no", "trailer #", "mobile unit"], "trailer_number"), + (["walking directions", "walking_directions", "directions", "how to get there"], "walking_directions"), + (["map link", "map_link", "google maps", "map url", "location url"], "map_link"), + (["parking", "parking location", "parking_location", "parking spot"], "parking_location"), + (["latitude", "lat", "gps lat"], "latitude"), + (["longitude", "long", "lng", "lon", "gps lng", "gps long"], "longitude"), + (["geofence", "geo radius", "radius", "geofence radius", "geofence_radius"], "geofence_radius_meters"), + (["customer", "client", "account", "company", "customer name"], "_customer_name"), + (["location name", "location_name", "site", "site name", "location"], "_location_name"), + (["photo", "photo path", "photo_path", "image", "picture"], "photo_path"), + (["route", "route number", "route #", "delivery route"], "_route"), + (["sales", "sales rep", "salesperson", "account manager"], "_sales_rep"), + (["barcode", "qr code", "tag", "tag number", "tag #"], "_barcode"), +] + + +def _normalize(s: str) -> str: + """Lowercase, strip, collapse whitespace.""" + return " ".join(str(s).lower().strip().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 + for keywords, canonical in COLUMN_HEURISTICS: + for kw in keywords: + # Match if the header contains the keyword or vice versa + if kw in norm or norm in kw: + 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): + val = val.isoformat() + 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 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", + ] + + 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"), +): + """ + Run a full Cantaloupe sync from an Excel file. + + 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: + # Try subprocess call to cantaloupe export + try: + import subprocess + result = subprocess.run( + ["python", "-m", "cantaloupe", "export", "--scheduled"], + capture_output=True, text=True, timeout=120, + cwd=str(Path(__file__).parent), + ) + if result.returncode != 0: + raise HTTPException( + status_code=400, + detail=f"Cantaloupe export failed: {result.stderr[:500]}" + ) + # Parse output to find the exported file path + for line in result.stdout.splitlines() + result.stderr.splitlines(): + line = line.strip() + if line and Path(line).exists() and line.endswith((".xlsx", ".xls")): + excel_path = line + display_path = line + break + else: + raise HTTPException( + status_code=400, + detail="Cantaloupe export ran but produced no recognizable file path. Provide file upload or file_path parameter." + ) + except FileNotFoundError: + raise HTTPException( + status_code=400, + detail="No file provided and cantaloupe module not found. Upload an Excel file or provide file_path." + ) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"Cantaloupe export error: {e}") + + 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) + + # Compute diff + live_data = _fetch_live_data(conn) + diff = compute_diff(mapped_rows, live_data) + + # 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", []), + } + + # 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): + """ + 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) + 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.", + ) + + # 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", + } + 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 + + 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 + VALID_CATEGORIES = {"Furniture", "Appliances", "Utensils & Serveware", "Equipment", "Other"} + VALID_STATUSES = {"active", "maintenance", "retired"} + if category not in VALID_CATEGORIES: + 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, + latitude, longitude, geofence_radius_meters) + 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, + _safe_float(item.get("latitude")), + _safe_float(item.get("longitude")), + _safe_int(item.get("geofence_radius_meters"), 50), + ), + ) + + 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() diff --git a/requirements.txt b/requirements.txt index a00c8f1..8b8488e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,4 @@ fastapi uvicorn[standard] pytest httpx +openpyxl diff --git a/run.sh b/run.sh index 660d842..fd0f3f6 100644 --- a/run.sh +++ b/run.sh @@ -5,9 +5,8 @@ set -euo pipefail PORT="${PORT:-8090}" HOST="${HOST:-0.0.0.0}" -CANTEEN_DB_PATH="${CANTEEN_DB_PATH:-$(dirname "$0")/../canteen-asset-tracker/assets.db}" -exec env CANTEEN_DB_PATH="$CANTEEN_DB_PATH" python -m uvicorn admin_server:app \ +exec python -m uvicorn admin_server:app \ --host "$HOST" \ --port "$PORT" \ --reload \