Fix syntax errors: fix duplicate execute() call indentation and f-string continuation issue

This commit is contained in:
2026-07-28 23:31:52 -04:00
parent b1b64501c4
commit e493f0f51a
+81 -85
View File
@@ -1,5 +1,5 @@
""" """
Canteen Asset Geolocation Tool FastAPI server. Canteen Asset Geolocation Tool - FastAPI server.
Single-file backend: SQLite storage, asset CRUD, machine_id search, Single-file backend: SQLite storage, asset CRUD, machine_id search,
check-ins with GPS, stats, and CSV export. check-ins with GPS, stats, and CSV export.
@@ -47,7 +47,7 @@ except Exception:
import piexif import piexif
from PIL import Image as PILImage from PIL import Image as PILImage
# ─── Asset matcher (photo OCR DB lookup) ───────────────────────────────── # --- Asset matcher (photo OCR -> DB lookup) ---------------------------------
from classify_makes import normalize_identifier, find_asset_by_normalized_id from classify_makes import normalize_identifier, find_asset_by_normalized_id
from fastapi import FastAPI, HTTPException, Query, Request, UploadFile, File, Form, Response from fastapi import FastAPI, HTTPException, Query, Request, UploadFile, File, Form, Response
@@ -57,13 +57,13 @@ from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional, List from typing import Optional, List
# ─── Config ───────────────────────────────────────────────────────────────── # --- Config -----------------------------------------------------------------
DB_PATH = os.environ.get("CANTEEN_DB_PATH", str(Path(__file__).parent / "assets.db")) DB_PATH = os.environ.get("CANTEEN_DB_PATH", str(Path(__file__).parent / "assets.db"))
UPLOADS_DIR = Path(os.environ.get("CANTEEN_UPLOADS_DIR", str(Path(__file__).parent / "uploads"))) UPLOADS_DIR = Path(os.environ.get("CANTEEN_UPLOADS_DIR", str(Path(__file__).parent / "uploads")))
STATIC_DIR = Path(__file__).parent / "static" STATIC_DIR = Path(__file__).parent / "static"
# ─── MSFS Data (merged asset enrichment from Dynamics 365 Field Service) ─── # --- MSFS Data (merged asset enrichment from Dynamics 365 Field Service) ---
MSFS_DATA_PATH = os.environ.get( MSFS_DATA_PATH = os.environ.get(
"MSFS_DATA_PATH", "MSFS_DATA_PATH",
str(Path.home() / "projects/ms-field-service-extraction/web/static/data/merged-assets.json"), str(Path.home() / "projects/ms-field-service-extraction/web/static/data/merged-assets.json"),
@@ -82,7 +82,7 @@ if os.path.exists(MSFS_DATA_PATH):
if _cid: if _cid:
_MSFS_BY_ID[_cid] = _entry _MSFS_BY_ID[_cid] = _entry
except Exception: except Exception:
pass # Non-fatal MSFS enrichment just won't be available pass # Non-fatal - MSFS enrichment just won't be available
def _load_categories() -> set: def _load_categories() -> set:
"""Load valid category names from the categories lookup table.""" """Load valid category names from the categories lookup table."""
@@ -98,7 +98,7 @@ VALID_CATEGORIES = _load_categories()
VALID_STATUSES = {"active", "maintenance", "retired"} VALID_STATUSES = {"active", "maintenance", "retired"}
# ─── Database ─────────────────────────────────────────────────────────────── # --- Database ---------------------------------------------------------------
def get_db() -> sqlite3.Connection: def get_db() -> sqlite3.Connection:
@@ -466,7 +466,7 @@ def _migrate_v1_to_v2(conn: sqlite3.Connection):
def init_db(conn: sqlite3.Connection): def init_db(conn: sqlite3.Connection):
"""Create tables and indexes if they don't exist. Runs v1v2 migration if needed.""" """Create tables and indexes if they don't exist. Runs v1->v2 migration if needed."""
# Check if assets table exists and has old schema # Check if assets table exists and has old schema
cursor = conn.execute( cursor = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='assets'" "SELECT name FROM sqlite_master WHERE type='table' AND name='assets'"
@@ -478,11 +478,11 @@ def init_db(conn: sqlite3.Connection):
_migrate_v1_to_v2(conn) _migrate_v1_to_v2(conn)
return return
# Fresh install or already migrated create all tables # Fresh install or already migrated - create all tables
_create_v2_tables(conn) _create_v2_tables(conn)
_seed_data(conn) _seed_data(conn)
_migrate_sessions_expires_at(conn) _migrate_sessions_expires_at(conn)
# Asset indexes created here (not in _create_v2_tables) to avoid # Asset indexes - created here (not in _create_v2_tables) to avoid
# failing during migration when old v1 assets table lacks machine_id. # failing during migration when old v1 assets table lacks machine_id.
_ensure_unique_machine_id(conn) _ensure_unique_machine_id(conn)
conn.execute( conn.execute(
@@ -529,7 +529,7 @@ def init_db(conn: sqlite3.Connection):
se_cols = conn.execute("PRAGMA table_info(service_entrances)").fetchall() se_cols = conn.execute("PRAGMA table_info(service_entrances)").fetchall()
se_name_col = next((c for c in se_cols if c[1] == 'name'), None) se_name_col = next((c for c in se_cols if c[1] == 'name'), None)
if se_name_col and not se_name_col[3]: # notnull == 0 if se_name_col and not se_name_col[3]: # notnull == 0
# Table exists but with wrong schema recreate it (new table, safe to drop) # Table exists but with wrong schema - recreate it (new table, safe to drop)
conn.execute("DROP TABLE service_entrances") conn.execute("DROP TABLE service_entrances")
se_exists = False se_exists = False
@@ -549,7 +549,7 @@ def init_db(conn: sqlite3.Connection):
conn.commit() conn.commit()
# ─── App / Middleware ─────────────────────────────────────────────────────── # --- App / Middleware -------------------------------------------------------
@asynccontextmanager @asynccontextmanager
@@ -571,7 +571,7 @@ app.add_middleware(
allow_headers=["*"], allow_headers=["*"],
) )
# ─── Auth Middleware ────────────────────────────────────────────────────────── # --- Auth Middleware ----------------------------------------------------------
@app.middleware("http") @app.middleware("http")
@@ -584,7 +584,7 @@ async def auth_middleware(request: Request, call_next):
if os.environ.get("CANTEEN_SKIP_AUTH") == "1": if os.environ.get("CANTEEN_SKIP_AUTH") == "1":
return await call_next(request) return await call_next(request)
# Public paths no auth required # Public paths - no auth required
if not path.startswith("/api/") or path == "/api/auth/login": if not path.startswith("/api/") or path == "/api/auth/login":
return await call_next(request) return await call_next(request)
@@ -607,7 +607,7 @@ async def auth_middleware(request: Request, call_next):
} }
return await call_next(request) return await call_next(request)
# No valid token use default admin user (field tool mode) # No valid token - use default admin user (field tool mode)
request.state.current_user = { request.state.current_user = {
"id": 1, "id": 1,
"username": "admin", "username": "admin",
@@ -616,7 +616,7 @@ async def auth_middleware(request: Request, call_next):
return await call_next(request) return await call_next(request)
# ─── Global Error Handling ─────────────────────────────────────────────────── # --- Global Error Handling ---------------------------------------------------
@app.exception_handler(HTTPException) @app.exception_handler(HTTPException)
@@ -630,7 +630,7 @@ async def http_exception_handler(request: Request, exc: HTTPException):
@app.exception_handler(Exception) @app.exception_handler(Exception)
async def generic_exception_handler(request: Request, exc: Exception): async def generic_exception_handler(request: Request, exc: Exception):
"""Catch-all for unhandled exceptions log and return 500.""" """Catch-all for unhandled exceptions - log and return 500."""
import traceback import traceback
traceback.print_exc() traceback.print_exc()
return JSONResponse( return JSONResponse(
@@ -639,7 +639,7 @@ async def generic_exception_handler(request: Request, exc: Exception):
) )
# ─── Input sanitization helpers ────────────────────────────────────────────── # --- Input sanitization helpers ----------------------------------------------
def _sanitize_machine_id(machine_id: str) -> str: def _sanitize_machine_id(machine_id: str) -> str:
@@ -662,7 +662,7 @@ def _sanitize_name(name: str) -> str:
return clean return clean
# ─── Pydantic Models ──────────────────────────────────────────────────────── # --- Pydantic Models --------------------------------------------------------
class AssetKey(BaseModel): class AssetKey(BaseModel):
@@ -763,7 +763,7 @@ class VisitCreate(BaseModel):
# ─── Helpers ──────────────────────────────────────────────────────────────── # --- Helpers ----------------------------------------------------------------
def row_to_dict(row: sqlite3.Row) -> dict: def row_to_dict(row: sqlite3.Row) -> dict:
@@ -801,7 +801,7 @@ def _sync_geofence_users(conn: sqlite3.Connection, geofence_id: int, user_ids: l
"""Replace assigned users for a geofence with the given list of user IDs. """Replace assigned users for a geofence with the given list of user IDs.
Validates all user_ids exist before modifying the junction table. Validates all user_ids exist before modifying the junction table.
Does NOT commit caller manages transaction boundaries. Does NOT commit - caller manages transaction boundaries.
""" """
# Validate all user IDs exist # Validate all user IDs exist
if user_ids: if user_ids:
@@ -863,7 +863,7 @@ def _validate_enum_table(conn: sqlite3.Connection, table: str, value: str, label
) )
# ─── Task 3: Health ──────────────────────────────────────────────────────── # --- Task 3: Health --------------------------------------------------------
@app.get("/health") @app.get("/health")
@@ -871,7 +871,7 @@ def health():
return {"status": "ok"} return {"status": "ok"}
# ─── Customer & Location listing (for filter dropdowns) ───────────────────── # --- Customer & Location listing (for filter dropdowns) ---------------------
@app.get("/api/customers") @app.get("/api/customers")
@@ -940,7 +940,7 @@ def list_places():
return [r["place"] for r in rows] return [r["place"] for r in rows]
# ─── Task 4: POST /api/assets ────────────────────────────────────────────── # --- Task 4: POST /api/assets ----------------------------------------------
def _build_asset_insert(body: AssetCreate, machine_id: str, name: str): def _build_asset_insert(body: AssetCreate, machine_id: str, name: str):
@@ -1061,7 +1061,7 @@ def create_asset(body: AssetCreate):
return result return result
# ─── Task 5: GET /api/assets ─────────────────────────────────────────────── # --- Task 5: GET /api/assets -----------------------------------------------
@app.get("/api/assets") @app.get("/api/assets")
@@ -1162,7 +1162,7 @@ def list_assets(
) )
params.extend(matching_prefixes) params.extend(matching_prefixes)
else: else:
# Branch name not found return empty result # Branch name not found - return empty result
conditions.append("1=0") conditions.append("1=0")
where = " AND ".join(conditions) where = " AND ".join(conditions)
@@ -1212,14 +1212,14 @@ def search_by_machine_id(machine_id: str = Query(...)):
return row_to_dict(row) return row_to_dict(row)
# ── Task 5b: GET /api/branches ───────────────────────────────────────────── # -- Task 5b: GET /api/branches ---------------------------------------------
@app.get("/api/branches") @app.get("/api/branches")
def list_branches(): def list_branches():
"""Return distinct branch/territory names that assets belong to. """Return distinct branch/territory names that assets belong to.
Uses the connect_id prefix branch mapping from the extraction DB. Uses the connect_id prefix -> branch mapping from the extraction DB.
Returns sorted list of {name, machine_count} objects. Returns sorted list of {name, machine_count} objects.
""" """
cache = _build_asset_branch_cache() cache = _build_asset_branch_cache()
@@ -1364,7 +1364,6 @@ def get_asset(asset_id: int):
asset_place = result.get("place", "") asset_place = result.get("place", "")
if asset_company: if asset_company:
# Try exact match first, then LIKE # Try exact match first, then LIKE
cur_ext.execute("""
cur_ext.execute(""" cur_ext.execute("""
SELECT name, address1_line1, address1_city, address1_stateorprovince, SELECT name, address1_line1, address1_city, address1_stateorprovince,
address1_postalcode, address1_country, telephone1, emailaddress1, address1_postalcode, address1_country, telephone1, emailaddress1,
@@ -1425,7 +1424,7 @@ def get_asset(asset_id: int):
return result return result
# ─── Navigation endpoint ──────────────────────────────────────────────────── # --- Navigation endpoint ----------------------------------------------------
@app.get("/api/assets/{asset_id}/navigation") @app.get("/api/assets/{asset_id}/navigation")
def get_navigation( def get_navigation(
@@ -1460,7 +1459,7 @@ def get_navigation(
dest_lat, dest_lng = row["latitude"], row["longitude"] dest_lat, dest_lng = row["latitude"], row["longitude"]
# ── Try OSRM first ────────────────────────────────────────────────── # -- Try OSRM first --------------------------------------------------
osrm_profile = "driving" if mode == "driving" else "foot" osrm_profile = "driving" if mode == "driving" else "foot"
route_coords = None route_coords = None
route_duration_s = None route_duration_s = None
@@ -1497,10 +1496,10 @@ def get_navigation(
if steps: if steps:
walking_directions = steps walking_directions = steps
except Exception: except Exception:
# OSRM failed fall through to Haversine below # OSRM failed - fall through to Haversine below
pass pass
# ── Haversine distance (always computed for fallback / reference) ──── # -- Haversine distance (always computed for fallback / reference) ----
R = 6371000 # Earth radius in meters R = 6371000 # Earth radius in meters
phi1 = math.radians(lat) phi1 = math.radians(lat)
phi2 = math.radians(dest_lat) phi2 = math.radians(dest_lat)
@@ -1557,7 +1556,7 @@ def get_navigation(
return result return result
# ─── Task 6: PUT / DELETE /api/assets/{id} ───────────────────────────────── # --- Task 6: PUT / DELETE /api/assets/{id} ---------------------------------
_TEXT_FIELDS = [ _TEXT_FIELDS = [
@@ -1661,7 +1660,7 @@ def delete_asset(asset_id: int):
conn.close() conn.close()
# ─── Task 8: POST /api/checkins ───────────────────────────────────────────── # --- Task 8: POST /api/checkins ---------------------------------------------
@app.post("/api/checkins", status_code=201) @app.post("/api/checkins", status_code=201)
@@ -1704,7 +1703,7 @@ def create_checkin(body: CheckinCreate, request: Request):
row = conn.execute("SELECT created_at FROM checkins WHERE id = ?", (checkin_id,)).fetchone() row = conn.execute("SELECT created_at FROM checkins WHERE id = ?", (checkin_id,)).fetchone()
_auto_log_visit(conn, user_id, body.asset_id, row["created_at"]) _auto_log_visit(conn, user_id, body.asset_id, row["created_at"])
# Activity log include machine_id and name for clarity # Activity log - include machine_id and name for clarity
asset_info = conn.execute( asset_info = conn.execute(
"SELECT machine_id, name FROM assets WHERE id = ?", "SELECT machine_id, name FROM assets WHERE id = ?",
(body.asset_id,) (body.asset_id,)
@@ -1727,7 +1726,7 @@ def create_checkin(body: CheckinCreate, request: Request):
return row_to_dict(row) return row_to_dict(row)
# ─── Task 9: GET /api/checkins ────────────────────────────────────────────── # --- Task 9: GET /api/checkins ----------------------------------------------
@app.get("/api/checkins") @app.get("/api/checkins")
@@ -1811,7 +1810,7 @@ def delete_checkin(checkin_id: int):
conn.close() conn.close()
# ─── Task 10: GET /api/stats ──────────────────────────────────────────────── # --- Task 10: GET /api/stats ------------------------------------------------
@@ -1823,7 +1822,7 @@ def delete_checkin(checkin_id: int):
# ─── Phase C: Helpers ──────────────────────────────────────────────────────── # --- Phase C: Helpers --------------------------------------------------------
VALID_ROLES = {"admin", "technician", "readonly"} VALID_ROLES = {"admin", "technician", "readonly"}
@@ -1860,7 +1859,7 @@ class LoginRequest(BaseModel):
password: str password: str
remember_me: bool = False remember_me: bool = False
# ─── Phase C: Auth API ─────────────────────────────────────────────────────── # --- Phase C: Auth API -------------------------------------------------------
@app.post("/api/auth/login") @app.post("/api/auth/login")
@@ -1941,7 +1940,7 @@ def logout(request: Request):
# ─── Phase 0: Proximity Check ───────────────────────────────────────────────── # --- Phase 0: Proximity Check -------------------------------------------------
@app.get("/api/proximity") @app.get("/api/proximity")
@@ -1976,7 +1975,7 @@ def proximity_check(
return results return results
# ─── Phase C: Visits API & Auto-visit Logging ──────────────────────────────── # --- Phase C: Visits API & Auto-visit Logging --------------------------------
def _auto_log_visit(conn: sqlite3.Connection, user_id: int, asset_id: int, def _auto_log_visit(conn: sqlite3.Connection, user_id: int, asset_id: int,
@@ -1993,7 +1992,7 @@ def _auto_log_visit(conn: sqlite3.Connection, user_id: int, asset_id: int,
).fetchone() ).fetchone()
if prev is None: if prev is None:
# No prior visit check if there are at least 2 check-ins in the window # No prior visit - check if there are at least 2 check-ins in the window
rows = conn.execute( rows = conn.execute(
"""SELECT id, created_at FROM checkins """SELECT id, created_at FROM checkins
WHERE user_id = ? AND asset_id = ? WHERE user_id = ? AND asset_id = ?
@@ -2129,7 +2128,7 @@ def get_visit_stats():
# ─── File Uploads ─────────────────────────────────────────────────────────── # --- File Uploads -----------------------------------------------------------
ICON_MAX_SIZE = 2 * 1024 * 1024 # 2 MB ICON_MAX_SIZE = 2 * 1024 * 1024 # 2 MB
PHOTO_MAX_SIZE = 20 * 1024 * 1024 # 20 MB PHOTO_MAX_SIZE = 20 * 1024 * 1024 # 20 MB
@@ -2244,7 +2243,7 @@ def _save_upload_bytes(contents: bytes, filename: str | None, subdir: str, allow
def _re_embed_exif(filepath: Path, exif_json: str): def _re_embed_exif(filepath: Path, exif_json: str):
"""Re-embed EXIF data (from client-side exifr.parse) into a saved JPEG. """Re-embed EXIF data (from client-side exifr.parse) into a saved JPEG.
This is a defense against EXIF being stripped during upload the client This is a defense against EXIF being stripped during upload - the client
reads EXIF from the original file before sending, and we write it back. reads EXIF from the original file before sending, and we write it back.
""" """
try: try:
@@ -2413,7 +2412,7 @@ async def ocr_sticker(file: UploadFile = File(...), exif_data: str = Form(None))
except Exception: except Exception:
saved_path = None # fall through to temp-file path below saved_path = None # fall through to temp-file path below
# Save file for OCR use permanent path if available, otherwise temp # Save file for OCR - use permanent path if available, otherwise temp
if saved_path: if saved_path:
ocr_path = UPLOADS_DIR / saved_path.split("/", 2)[-1] ocr_path = UPLOADS_DIR / saved_path.split("/", 2)[-1]
else: else:
@@ -2429,7 +2428,7 @@ async def ocr_sticker(file: UploadFile = File(...), exif_data: str = Form(None))
ocr_source = "none" ocr_source = "none"
ollama_text = "" ollama_text = ""
# Try Ollama vision model first (Windows PC) most accurate # Try Ollama vision model first (Windows PC) - most accurate
if _HAS_OLLAMA: if _HAS_OLLAMA:
try: try:
import urllib.request as _ourl import urllib.request as _ourl
@@ -2496,7 +2495,7 @@ async def ocr_sticker(file: UploadFile = File(...), exif_data: str = Form(None))
detail = "No OCR service available (Tesseract not installed, Ollama not connected)." detail = "No OCR service available (Tesseract not installed, Ollama not connected)."
raise HTTPException(status_code=422, detail=detail) raise HTTPException(status_code=422, detail=detail)
# Build response search for identifiers in the OCR text # Build response - search for identifiers in the OCR text
result: dict = { result: dict = {
"raw_text": text.strip()[:1000], "raw_text": text.strip()[:1000],
"ocr_source": ocr_source, "ocr_source": ocr_source,
@@ -2524,7 +2523,7 @@ async def ocr_sticker(file: UploadFile = File(...), exif_data: str = Form(None))
result["confidence"] = "none" result["confidence"] = "none"
result["detail"] = "No machine ID pattern found in image. Try again with better lighting." result["detail"] = "No machine ID pattern found in image. Try again with better lighting."
# 2. Cross-reference OCR text against DB find matched assets by # 2. Cross-reference OCR text against DB - find matched assets by
# serial_number, connect_id, equipment_id, or barcode # serial_number, connect_id, equipment_id, or barcode
db_path = DB_PATH db_path = DB_PATH
db_matches = [] db_matches = []
@@ -2591,7 +2590,7 @@ async def ocr_sticker(file: UploadFile = File(...), exif_data: str = Form(None))
if updated_count > 0: if updated_count > 0:
result["photo_saved"] = updated_count result["photo_saved"] = updated_count
except Exception: except Exception:
pass # Non-critical don't fail OCR if photo save fails pass # Non-critical - don't fail OCR if photo save fails
# Auto-update serial_number on matched assets from OCR text # Auto-update serial_number on matched assets from OCR text
if db_matches and any(m.get("serial_number") == "" for m in db_matches): if db_matches and any(m.get("serial_number") == "" for m in db_matches):
@@ -2635,14 +2634,14 @@ async def ocr_sticker(file: UploadFile = File(...), exif_data: str = Form(None))
result["gps_saved"] = True result["gps_saved"] = True
conn.close() conn.close()
except Exception: except Exception:
pass # Non-critical don't fail OCR if GPS save fails pass # Non-critical - don't fail OCR if GPS save fails
if saved_path: if saved_path:
result["path"] = saved_path result["path"] = saved_path
return result return result
# ─── Match raw text against DB (for barcode scanner / client-side OCR) ──── # --- Match raw text against DB (for barcode scanner / client-side OCR) ----
@app.post("/api/match-text", status_code=200) @app.post("/api/match-text", status_code=200)
async def match_text(text: str = Form(...)): async def match_text(text: str = Form(...)):
@@ -2711,7 +2710,7 @@ async def match_text(text: str = Form(...)):
} }
# ─── T4: Connect Label unified photo + OCR + GPS endpoint ───────────────── # --- T4: Connect Label - unified photo + OCR + GPS endpoint -----------------
class ConnectLabelRequest(BaseModel): class ConnectLabelRequest(BaseModel):
"""Request body for the connect-label endpoint.""" """Request body for the connect-label endpoint."""
@@ -2738,7 +2737,7 @@ async def connect_label(
Accepts multipart form data with optional photo upload. Accepts multipart form data with optional photo upload.
Creates an asset with the provided machine_id, name, GPS coords. Creates an asset with the provided machine_id, name, GPS coords.
If photo is uploaded, it saves the file and sets photo_path. If photo is uploaded, it saves the file and sets photo_path.
Validates machine_id format (XXXXX-XXXXXX last 5 digits). Validates machine_id format (XXXXX-XXXXXX -> last 5 digits).
""" """
# If machine_id/name come as form fields, use those; otherwise try query params # If machine_id/name come as form fields, use those; otherwise try query params
machine_id = _sanitize_machine_id(machine_id) machine_id = _sanitize_machine_id(machine_id)
@@ -2812,13 +2811,13 @@ async def connect_label(
return result return result
# ─── Reverse Geocode (Nominatim) ──────────────────────────────────────────── # --- Reverse Geocode (Nominatim) --------------------------------------------
def reverse_geocode(lat: float, lng: float) -> dict | None: def reverse_geocode(lat: float, lng: float) -> dict | None:
"""Call OpenStreetMap Nominatim to reverse geocode GPS coords. """Call OpenStreetMap Nominatim to reverse geocode GPS coords.
Returns a dict with address fields mapped to our schema, or None on failure. Returns a dict with address fields mapped to our schema, or None on failure.
Does NOT raise HTTPException callers handle the None case. Does NOT raise HTTPException - callers handle the None case.
""" """
try: try:
url = ( url = (
@@ -2879,7 +2878,7 @@ def geocode(lat: float = Query(...), lng: float = Query(...)):
return result return result
# ─── Disney Park Classification ────────────────────────────────────────────── # --- Disney Park Classification ----------------------------------------------
@app.get("/api/disney/stats") @app.get("/api/disney/stats")
@@ -2934,7 +2933,7 @@ def set_asset_disney_park(asset_id: int, body: dict):
return {"status": "ok", "asset_id": asset_id, "disney_park": park} return {"status": "ok", "asset_id": asset_id, "disney_park": park}
# ─── Service Entrances ──────────────────────────────────────────────────────── # --- Service Entrances --------------------------------------------------------
class ServiceEntranceCreate(BaseModel): class ServiceEntranceCreate(BaseModel):
@@ -3051,10 +3050,10 @@ def list_all_service_entrances():
# ═══════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════
# WORK ORDER & ROUTE OPTIMIZATION reads extraction DB directly # WORK ORDER & ROUTE OPTIMIZATION - reads extraction DB directly
# ═══════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════
# ── Extraction DB (MS Field Service sync) ──────────────────────────────── # -- Extraction DB (MS Field Service sync) --------------------------------
EXTRACTION_PROJECT = Path.home() / "projects" / "ms-field-service-extraction" EXTRACTION_PROJECT = Path.home() / "projects" / "ms-field-service-extraction"
EXTRACTION_DB = ( EXTRACTION_DB = (
EXTRACTION_PROJECT EXTRACTION_PROJECT
@@ -3088,7 +3087,7 @@ def _get_extraction_db() -> sqlite3.Connection | None:
return conn return conn
# ── Asset Branch Cache (connect_id prefix branch/territory name) ──────── # -- Asset Branch Cache (connect_id prefix -> branch/territory name) --------
_BRANCH_CACHE: dict[str, str] | None = None _BRANCH_CACHE: dict[str, str] | None = None
@@ -3106,7 +3105,7 @@ def _build_asset_branch_cache() -> dict[str, str]:
result: dict[str, str] = {} result: dict[str, str] = {}
conn = _get_extraction_db() conn = _get_extraction_db()
if not conn: if not conn:
print("⚠️ Extraction DB not available branch cache will be empty") print("⚠️ Extraction DB not available - branch cache will be empty")
_BRANCH_CACHE = result _BRANCH_CACHE = result
return result return result
@@ -3153,7 +3152,7 @@ def _build_asset_branch_cache() -> dict[str, str]:
return result return result
# ── TSP Solver ─────────────────────────────────────────────────────────── # -- TSP Solver -----------------------------------------------------------
def _haversine(lat1, lon1, lat2, lon2): def _haversine(lat1, lon1, lat2, lon2):
@@ -3213,7 +3212,7 @@ def _solve_tsp(points, origin_idx=0):
return route return route
# ── Work Order Search ────────────────────────────────────────────────────── # -- Work Order Search ------------------------------------------------------
@app.get("/api/workorders/search") @app.get("/api/workorders/search")
@@ -3228,7 +3227,7 @@ async def workorders_search(
"""Search work orders by name, account, or city, with optional status and date filters.""" """Search work orders by name, account, or city, with optional status and date filters."""
conn = _get_extraction_db() conn = _get_extraction_db()
if not conn: if not conn:
raise HTTPException(503, "Database not available sync extraction DB missing") raise HTTPException(503, "Database not available - sync extraction DB missing")
try: try:
cur = conn.cursor() cur = conn.cursor()
params: list = [] params: list = []
@@ -3240,7 +3239,7 @@ async def workorders_search(
where_clauses.append("(w.msdyn_name LIKE ? OR a.name LIKE ? OR a.address1_city LIKE ?)") where_clauses.append("(w.msdyn_name LIKE ? OR a.name LIKE ? OR a.address1_city LIKE ?)")
params.extend([like, like, like]) params.extend([like, like, like])
# Status filter map label back to code # Status filter - map label back to code
if status: if status:
status_label_map = {v: k for k, v in STATUS_LABELS.items()} status_label_map = {v: k for k, v in STATUS_LABELS.items()}
code = status_label_map.get(status) code = status_label_map.get(status)
@@ -3328,7 +3327,7 @@ async def workorders_search(
conn.close() conn.close()
# ── Work Order Lookup (by ID) ────────────────────────────────────────────── # -- Work Order Lookup (by ID) ----------------------------------------------
class WorkorderLookupRequest(BaseModel): class WorkorderLookupRequest(BaseModel):
@@ -3346,7 +3345,7 @@ async def workorders_lookup(body: WorkorderLookupRequest):
conn = _get_extraction_db() conn = _get_extraction_db()
if not conn: if not conn:
raise HTTPException(503, "Database not available sync extraction DB missing") raise HTTPException(503, "Database not available - sync extraction DB missing")
try: try:
cur = conn.cursor() cur = conn.cursor()
placeholders = ",".join(["?"] * len(ids)) placeholders = ",".join(["?"] * len(ids))
@@ -3510,7 +3509,7 @@ async def workorders_lookup(body: WorkorderLookupRequest):
conn.close() conn.close()
# ── Today's Active Work Orders ────────────────────────────────────────────── # -- Today's Active Work Orders ----------------------------------------------
@app.get("/api/workorders/today") @app.get("/api/workorders/today")
@@ -3522,7 +3521,7 @@ async def workorders_today(
work_type: str = Query("", description="Filter by work type (Install/Repair/PM/Emergency)"), work_type: str = Query("", description="Filter by work type (Install/Repair/PM/Emergency)"),
date_range: str = Query("today", description="Date range: today, week, month, all"), date_range: str = Query("today", description="Date range: today, week, month, all"),
): ):
"""Fetch today's active work orders via bookableresourcebooking. """Fetch todays active work orders via bookableresourcebooking.
Pass ?tech=Shawn+Canada&tech=John+Doe to filter by one or more technicians. Pass ?tech=Shawn+Canada&tech=John+Doe to filter by one or more technicians.
Omit tech entirely to return all technicians. Omit tech entirely to return all technicians.
@@ -3530,7 +3529,7 @@ async def workorders_today(
""" """
conn = _get_extraction_db() conn = _get_extraction_db()
if not conn: if not conn:
raise HTTPException(503, "Database not available sync extraction DB missing") raise HTTPException(503, "Database not available - sync extraction DB missing")
try: try:
cur = conn.cursor() cur = conn.cursor()
today = date.today().isoformat() today = date.today().isoformat()
@@ -3661,7 +3660,7 @@ async def workorders_today(
conn.close() conn.close()
# ── List Technicians ────────────────────────────────────────────────────── # -- List Technicians ------------------------------------------------------
@app.get("/api/workorders/technicians") @app.get("/api/workorders/technicians")
@@ -3669,7 +3668,7 @@ async def workorders_technicians():
"""Return distinct technician names from bookings.""" """Return distinct technician names from bookings."""
conn = _get_extraction_db() conn = _get_extraction_db()
if not conn: if not conn:
raise HTTPException(503, "Database not available sync extraction DB missing") raise HTTPException(503, "Database not available - sync extraction DB missing")
try: try:
cur = conn.cursor() cur = conn.cursor()
cur.execute( cur.execute(
@@ -3694,7 +3693,7 @@ async def workorders_technicians():
conn.close() conn.close()
# ── Work Order List (filterable, paginated) ───────────────────────────────── # -- Work Order List (filterable, paginated) ---------------------------------
@app.get("/api/workorders/list") @app.get("/api/workorders/list")
@@ -3716,7 +3715,7 @@ async def workorders_list(
""" """
conn = _get_extraction_db() conn = _get_extraction_db()
if not conn: if not conn:
raise HTTPException(503, "Database not available sync extraction DB missing") raise HTTPException(503, "Database not available - sync extraction DB missing")
try: try:
cur = conn.cursor() cur = conn.cursor()
params: list = [] params: list = []
@@ -3744,7 +3743,7 @@ async def workorders_list(
where_clauses.append(f"w.msdyn_systemstatus IN ({placeholders})") where_clauses.append(f"w.msdyn_systemstatus IN ({placeholders})")
params.extend(status_codes) params.extend(status_codes)
# Technician filter check bookings table AND hsl_bookedresource fields # Technician filter - check bookings table AND hsl_bookedresource fields
if tech: if tech:
tech_names = [t.strip() for t in tech.split(",") if t.strip()] tech_names = [t.strip() for t in tech.split(",") if t.strip()]
if tech_names: if tech_names:
@@ -3760,7 +3759,7 @@ async def workorders_list(
where_clauses.append("(" + " OR ".join(tech_conditions) + ")") where_clauses.append("(" + " OR ".join(tech_conditions) + ")")
params.extend(tech_names * 4) params.extend(tech_names * 4)
# Location filter matches city, functional location, or account name # Location filter - matches city, functional location, or account name
if location: if location:
like = f"%{location}%" like = f"%{location}%"
where_clauses.append( where_clauses.append(
@@ -3903,7 +3902,7 @@ async def workorders_list(
conn.close() conn.close()
# ── Route Optimization ────────────────────────────────────────────────────── # -- Route Optimization ------------------------------------------------------
def _lookup_asset_gps_by_machine_id(machine_id: str) -> dict | None: def _lookup_asset_gps_by_machine_id(machine_id: str) -> dict | None:
"""Lookup GPS from the canteen-asset-tracker assets table by machine_id.""" """Lookup GPS from the canteen-asset-tracker assets table by machine_id."""
@@ -3942,12 +3941,11 @@ async def route_optimize(body: RouteOptimizeRequest):
conn = _get_extraction_db() conn = _get_extraction_db()
if not conn: if not conn:
raise HTTPException(503, "Database not available sync extraction DB missing") raise HTTPException(503, "Database not available - sync extraction DB missing")
try: try:
cur = conn.cursor() cur = conn.cursor()
placeholders = ",".join(["?"] * len(wo_ids)) placeholders = ",".join(["?"] * len(wo_ids))
cur.execute( cur.execute(f"""
f"""
SELECT SELECT
w.msdyn_workorderid, w.msdyn_name, w.msdyn_workorderid, w.msdyn_name,
w."msdyn_serviceaccount!name" AS account_name, w."msdyn_serviceaccount!name" AS account_name,
@@ -3972,9 +3970,7 @@ async def route_optimize(body: RouteOptimizeRequest):
LEFT JOIN msdyn_customerasset ca ON w."msdyn_customerasset!id" = ca.msdyn_customerassetid LEFT JOIN msdyn_customerasset ca ON w."msdyn_customerasset!id" = ca.msdyn_customerassetid
WHERE w.msdyn_name IN ({placeholders}) WHERE w.msdyn_name IN ({placeholders})
OR w.msdyn_workorderid IN ({placeholders}) OR w.msdyn_workorderid IN ({placeholders})
""", """, wo_ids + wo_ids)
wo_ids + wo_ids,
)
rows = cur.fetchall() rows = cur.fetchall()
finally: finally:
conn.close() conn.close()
@@ -3994,7 +3990,7 @@ async def route_optimize(body: RouteOptimizeRequest):
except (ValueError, TypeError): except (ValueError, TypeError):
pass pass
# Machine GPS prefer msdyn_customerasset GPS, fall back to canteen assets table # Machine GPS - prefer msdyn_customerasset GPS, fall back to canteen assets table
machine_gps = None machine_gps = None
asset_lat = r["asset_lat"] asset_lat = r["asset_lat"]
asset_lng = r["asset_lng"] asset_lng = r["asset_lng"]
@@ -4117,7 +4113,7 @@ async def route_optimize(body: RouteOptimizeRequest):
return result return result
# ─── Static Files (mounted last to not shadow routes) ────────────────────── # --- Static Files (mounted last to not shadow routes) ----------------------
app.mount("/uploads", StaticFiles(directory=str(UPLOADS_DIR)), name="uploads") app.mount("/uploads", StaticFiles(directory=str(UPLOADS_DIR)), name="uploads")