Fix syntax errors: fix duplicate execute() call indentation and f-string continuation issue
This commit is contained in:
@@ -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,
|
||||
check-ins with GPS, stats, and CSV export.
|
||||
@@ -47,7 +47,7 @@ except Exception:
|
||||
import piexif
|
||||
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 fastapi import FastAPI, HTTPException, Query, Request, UploadFile, File, Form, Response
|
||||
@@ -57,13 +57,13 @@ from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
|
||||
# ─── Config ─────────────────────────────────────────────────────────────────
|
||||
# --- Config -----------------------------------------------------------------
|
||||
|
||||
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")))
|
||||
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",
|
||||
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:
|
||||
_MSFS_BY_ID[_cid] = _entry
|
||||
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:
|
||||
"""Load valid category names from the categories lookup table."""
|
||||
@@ -98,7 +98,7 @@ VALID_CATEGORIES = _load_categories()
|
||||
VALID_STATUSES = {"active", "maintenance", "retired"}
|
||||
|
||||
|
||||
# ─── Database ───────────────────────────────────────────────────────────────
|
||||
# --- Database ---------------------------------------------------------------
|
||||
|
||||
|
||||
def get_db() -> sqlite3.Connection:
|
||||
@@ -466,7 +466,7 @@ def _migrate_v1_to_v2(conn: sqlite3.Connection):
|
||||
|
||||
|
||||
def init_db(conn: sqlite3.Connection):
|
||||
"""Create tables and indexes if they don't exist. Runs v1→v2 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
|
||||
cursor = conn.execute(
|
||||
"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)
|
||||
return
|
||||
|
||||
# Fresh install or already migrated — create all tables
|
||||
# Fresh install or already migrated - create all tables
|
||||
_create_v2_tables(conn)
|
||||
_seed_data(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.
|
||||
_ensure_unique_machine_id(conn)
|
||||
conn.execute(
|
||||
@@ -529,7 +529,7 @@ def init_db(conn: sqlite3.Connection):
|
||||
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)
|
||||
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")
|
||||
se_exists = False
|
||||
|
||||
@@ -549,7 +549,7 @@ def init_db(conn: sqlite3.Connection):
|
||||
conn.commit()
|
||||
|
||||
|
||||
# ─── App / Middleware ───────────────────────────────────────────────────────
|
||||
# --- App / Middleware -------------------------------------------------------
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -571,7 +571,7 @@ app.add_middleware(
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# ─── Auth Middleware ──────────────────────────────────────────────────────────
|
||||
# --- Auth Middleware ----------------------------------------------------------
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
@@ -584,7 +584,7 @@ async def auth_middleware(request: Request, call_next):
|
||||
if os.environ.get("CANTEEN_SKIP_AUTH") == "1":
|
||||
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":
|
||||
return await call_next(request)
|
||||
|
||||
@@ -607,7 +607,7 @@ async def auth_middleware(request: Request, call_next):
|
||||
}
|
||||
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 = {
|
||||
"id": 1,
|
||||
"username": "admin",
|
||||
@@ -616,7 +616,7 @@ async def auth_middleware(request: Request, call_next):
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
# ─── Global Error Handling ───────────────────────────────────────────────────
|
||||
# --- Global Error Handling ---------------------------------------------------
|
||||
|
||||
|
||||
@app.exception_handler(HTTPException)
|
||||
@@ -630,7 +630,7 @@ async def http_exception_handler(request: Request, exc: HTTPException):
|
||||
|
||||
@app.exception_handler(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
|
||||
traceback.print_exc()
|
||||
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:
|
||||
@@ -662,7 +662,7 @@ def _sanitize_name(name: str) -> str:
|
||||
return clean
|
||||
|
||||
|
||||
# ─── Pydantic Models ────────────────────────────────────────────────────────
|
||||
# --- Pydantic Models --------------------------------------------------------
|
||||
|
||||
|
||||
class AssetKey(BaseModel):
|
||||
@@ -763,7 +763,7 @@ class VisitCreate(BaseModel):
|
||||
|
||||
|
||||
|
||||
# ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
# --- Helpers ----------------------------------------------------------------
|
||||
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
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")
|
||||
@@ -871,7 +871,7 @@ def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
# ─── Customer & Location listing (for filter dropdowns) ─────────────────────
|
||||
# --- Customer & Location listing (for filter dropdowns) ---------------------
|
||||
|
||||
|
||||
@app.get("/api/customers")
|
||||
@@ -940,7 +940,7 @@ def list_places():
|
||||
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):
|
||||
@@ -1061,7 +1061,7 @@ def create_asset(body: AssetCreate):
|
||||
return result
|
||||
|
||||
|
||||
# ─── Task 5: GET /api/assets ───────────────────────────────────────────────
|
||||
# --- Task 5: GET /api/assets -----------------------------------------------
|
||||
|
||||
|
||||
@app.get("/api/assets")
|
||||
@@ -1162,7 +1162,7 @@ def list_assets(
|
||||
)
|
||||
params.extend(matching_prefixes)
|
||||
else:
|
||||
# Branch name not found — return empty result
|
||||
# Branch name not found - return empty result
|
||||
conditions.append("1=0")
|
||||
|
||||
where = " AND ".join(conditions)
|
||||
@@ -1212,14 +1212,14 @@ def search_by_machine_id(machine_id: str = Query(...)):
|
||||
return row_to_dict(row)
|
||||
|
||||
|
||||
# ── Task 5b: GET /api/branches ─────────────────────────────────────────────
|
||||
# -- Task 5b: GET /api/branches ---------------------------------------------
|
||||
|
||||
|
||||
@app.get("/api/branches")
|
||||
def list_branches():
|
||||
"""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.
|
||||
"""
|
||||
cache = _build_asset_branch_cache()
|
||||
@@ -1364,7 +1364,6 @@ def get_asset(asset_id: int):
|
||||
asset_place = result.get("place", "")
|
||||
if asset_company:
|
||||
# Try exact match first, then LIKE
|
||||
cur_ext.execute("""
|
||||
cur_ext.execute("""
|
||||
SELECT name, address1_line1, address1_city, address1_stateorprovince,
|
||||
address1_postalcode, address1_country, telephone1, emailaddress1,
|
||||
@@ -1425,7 +1424,7 @@ def get_asset(asset_id: int):
|
||||
return result
|
||||
|
||||
|
||||
# ─── Navigation endpoint ────────────────────────────────────────────────────
|
||||
# --- Navigation endpoint ----------------------------------------------------
|
||||
|
||||
@app.get("/api/assets/{asset_id}/navigation")
|
||||
def get_navigation(
|
||||
@@ -1460,7 +1459,7 @@ def get_navigation(
|
||||
|
||||
dest_lat, dest_lng = row["latitude"], row["longitude"]
|
||||
|
||||
# ── Try OSRM first ──────────────────────────────────────────────────
|
||||
# -- Try OSRM first --------------------------------------------------
|
||||
osrm_profile = "driving" if mode == "driving" else "foot"
|
||||
route_coords = None
|
||||
route_duration_s = None
|
||||
@@ -1497,10 +1496,10 @@ def get_navigation(
|
||||
if steps:
|
||||
walking_directions = steps
|
||||
except Exception:
|
||||
# OSRM failed — fall through to Haversine below
|
||||
# OSRM failed - fall through to Haversine below
|
||||
pass
|
||||
|
||||
# ── Haversine distance (always computed for fallback / reference) ────
|
||||
# -- Haversine distance (always computed for fallback / reference) ----
|
||||
R = 6371000 # Earth radius in meters
|
||||
phi1 = math.radians(lat)
|
||||
phi2 = math.radians(dest_lat)
|
||||
@@ -1557,7 +1556,7 @@ def get_navigation(
|
||||
return result
|
||||
|
||||
|
||||
# ─── Task 6: PUT / DELETE /api/assets/{id} ─────────────────────────────────
|
||||
# --- Task 6: PUT / DELETE /api/assets/{id} ---------------------------------
|
||||
|
||||
|
||||
_TEXT_FIELDS = [
|
||||
@@ -1661,7 +1660,7 @@ def delete_asset(asset_id: int):
|
||||
conn.close()
|
||||
|
||||
|
||||
# ─── Task 8: POST /api/checkins ─────────────────────────────────────────────
|
||||
# --- Task 8: POST /api/checkins ---------------------------------------------
|
||||
|
||||
|
||||
@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()
|
||||
_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(
|
||||
"SELECT machine_id, name FROM assets WHERE id = ?",
|
||||
(body.asset_id,)
|
||||
@@ -1727,7 +1726,7 @@ def create_checkin(body: CheckinCreate, request: Request):
|
||||
return row_to_dict(row)
|
||||
|
||||
|
||||
# ─── Task 9: GET /api/checkins ──────────────────────────────────────────────
|
||||
# --- Task 9: GET /api/checkins ----------------------------------------------
|
||||
|
||||
|
||||
@app.get("/api/checkins")
|
||||
@@ -1811,7 +1810,7 @@ def delete_checkin(checkin_id: int):
|
||||
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"}
|
||||
|
||||
@@ -1860,7 +1859,7 @@ class LoginRequest(BaseModel):
|
||||
password: str
|
||||
remember_me: bool = False
|
||||
|
||||
# ─── Phase C: Auth API ───────────────────────────────────────────────────────
|
||||
# --- Phase C: Auth API -------------------------------------------------------
|
||||
|
||||
|
||||
@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")
|
||||
@@ -1976,7 +1975,7 @@ def proximity_check(
|
||||
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,
|
||||
@@ -1993,7 +1992,7 @@ def _auto_log_visit(conn: sqlite3.Connection, user_id: int, asset_id: int,
|
||||
).fetchone()
|
||||
|
||||
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(
|
||||
"""SELECT id, created_at FROM checkins
|
||||
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
|
||||
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):
|
||||
"""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.
|
||||
"""
|
||||
try:
|
||||
@@ -2413,7 +2412,7 @@ async def ocr_sticker(file: UploadFile = File(...), exif_data: str = Form(None))
|
||||
except Exception:
|
||||
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:
|
||||
ocr_path = UPLOADS_DIR / saved_path.split("/", 2)[-1]
|
||||
else:
|
||||
@@ -2429,7 +2428,7 @@ async def ocr_sticker(file: UploadFile = File(...), exif_data: str = Form(None))
|
||||
ocr_source = "none"
|
||||
ollama_text = ""
|
||||
|
||||
# Try Ollama vision model first (Windows PC) — most accurate
|
||||
# Try Ollama vision model first (Windows PC) - most accurate
|
||||
if _HAS_OLLAMA:
|
||||
try:
|
||||
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)."
|
||||
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 = {
|
||||
"raw_text": text.strip()[:1000],
|
||||
"ocr_source": ocr_source,
|
||||
@@ -2524,7 +2523,7 @@ async def ocr_sticker(file: UploadFile = File(...), exif_data: str = Form(None))
|
||||
result["confidence"] = "none"
|
||||
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
|
||||
db_path = DB_PATH
|
||||
db_matches = []
|
||||
@@ -2591,7 +2590,7 @@ async def ocr_sticker(file: UploadFile = File(...), exif_data: str = Form(None))
|
||||
if updated_count > 0:
|
||||
result["photo_saved"] = updated_count
|
||||
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
|
||||
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
|
||||
conn.close()
|
||||
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:
|
||||
result["path"] = saved_path
|
||||
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)
|
||||
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):
|
||||
"""Request body for the connect-label endpoint."""
|
||||
@@ -2738,7 +2737,7 @@ async def connect_label(
|
||||
Accepts multipart form data with optional photo upload.
|
||||
Creates an asset with the provided machine_id, name, GPS coords.
|
||||
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
|
||||
machine_id = _sanitize_machine_id(machine_id)
|
||||
@@ -2812,13 +2811,13 @@ async def connect_label(
|
||||
return result
|
||||
|
||||
|
||||
# ─── Reverse Geocode (Nominatim) ────────────────────────────────────────────
|
||||
# --- Reverse Geocode (Nominatim) --------------------------------------------
|
||||
|
||||
def reverse_geocode(lat: float, lng: float) -> dict | None:
|
||||
"""Call OpenStreetMap Nominatim to reverse geocode GPS coords.
|
||||
|
||||
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:
|
||||
url = (
|
||||
@@ -2879,7 +2878,7 @@ def geocode(lat: float = Query(...), lng: float = Query(...)):
|
||||
return result
|
||||
|
||||
|
||||
# ─── Disney Park Classification ──────────────────────────────────────────────
|
||||
# --- Disney Park Classification ----------------------------------------------
|
||||
|
||||
|
||||
@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}
|
||||
|
||||
|
||||
# ─── Service Entrances ────────────────────────────────────────────────────────
|
||||
# --- Service Entrances --------------------------------------------------------
|
||||
|
||||
|
||||
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_DB = (
|
||||
EXTRACTION_PROJECT
|
||||
@@ -3088,7 +3087,7 @@ def _get_extraction_db() -> sqlite3.Connection | None:
|
||||
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
|
||||
|
||||
|
||||
@@ -3106,7 +3105,7 @@ def _build_asset_branch_cache() -> dict[str, str]:
|
||||
result: dict[str, str] = {}
|
||||
conn = _get_extraction_db()
|
||||
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
|
||||
return result
|
||||
|
||||
@@ -3153,7 +3152,7 @@ def _build_asset_branch_cache() -> dict[str, str]:
|
||||
return result
|
||||
|
||||
|
||||
# ── TSP Solver ───────────────────────────────────────────────────────────
|
||||
# -- TSP Solver -----------------------------------------------------------
|
||||
|
||||
|
||||
def _haversine(lat1, lon1, lat2, lon2):
|
||||
@@ -3213,7 +3212,7 @@ def _solve_tsp(points, origin_idx=0):
|
||||
return route
|
||||
|
||||
|
||||
# ── Work Order Search ──────────────────────────────────────────────────────
|
||||
# -- Work Order 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."""
|
||||
conn = _get_extraction_db()
|
||||
if not conn:
|
||||
raise HTTPException(503, "Database not available — sync extraction DB missing")
|
||||
raise HTTPException(503, "Database not available - sync extraction DB missing")
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
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 ?)")
|
||||
params.extend([like, like, like])
|
||||
|
||||
# Status filter — map label back to code
|
||||
# Status filter - map label back to code
|
||||
if status:
|
||||
status_label_map = {v: k for k, v in STATUS_LABELS.items()}
|
||||
code = status_label_map.get(status)
|
||||
@@ -3328,7 +3327,7 @@ async def workorders_search(
|
||||
conn.close()
|
||||
|
||||
|
||||
# ── Work Order Lookup (by ID) ──────────────────────────────────────────────
|
||||
# -- Work Order Lookup (by ID) ----------------------------------------------
|
||||
|
||||
|
||||
class WorkorderLookupRequest(BaseModel):
|
||||
@@ -3346,7 +3345,7 @@ async def workorders_lookup(body: WorkorderLookupRequest):
|
||||
|
||||
conn = _get_extraction_db()
|
||||
if not conn:
|
||||
raise HTTPException(503, "Database not available — sync extraction DB missing")
|
||||
raise HTTPException(503, "Database not available - sync extraction DB missing")
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
placeholders = ",".join(["?"] * len(ids))
|
||||
@@ -3510,7 +3509,7 @@ async def workorders_lookup(body: WorkorderLookupRequest):
|
||||
conn.close()
|
||||
|
||||
|
||||
# ── Today's Active Work Orders ──────────────────────────────────────────────
|
||||
# -- Today's Active Work Orders ----------------------------------------------
|
||||
|
||||
|
||||
@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)"),
|
||||
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.
|
||||
Omit tech entirely to return all technicians.
|
||||
@@ -3530,7 +3529,7 @@ async def workorders_today(
|
||||
"""
|
||||
conn = _get_extraction_db()
|
||||
if not conn:
|
||||
raise HTTPException(503, "Database not available — sync extraction DB missing")
|
||||
raise HTTPException(503, "Database not available - sync extraction DB missing")
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
today = date.today().isoformat()
|
||||
@@ -3661,7 +3660,7 @@ async def workorders_today(
|
||||
conn.close()
|
||||
|
||||
|
||||
# ── List Technicians ──────────────────────────────────────────────────────
|
||||
# -- List Technicians ------------------------------------------------------
|
||||
|
||||
|
||||
@app.get("/api/workorders/technicians")
|
||||
@@ -3669,7 +3668,7 @@ async def workorders_technicians():
|
||||
"""Return distinct technician names from bookings."""
|
||||
conn = _get_extraction_db()
|
||||
if not conn:
|
||||
raise HTTPException(503, "Database not available — sync extraction DB missing")
|
||||
raise HTTPException(503, "Database not available - sync extraction DB missing")
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
@@ -3694,7 +3693,7 @@ async def workorders_technicians():
|
||||
conn.close()
|
||||
|
||||
|
||||
# ── Work Order List (filterable, paginated) ─────────────────────────────────
|
||||
# -- Work Order List (filterable, paginated) ---------------------------------
|
||||
|
||||
|
||||
@app.get("/api/workorders/list")
|
||||
@@ -3716,7 +3715,7 @@ async def workorders_list(
|
||||
"""
|
||||
conn = _get_extraction_db()
|
||||
if not conn:
|
||||
raise HTTPException(503, "Database not available — sync extraction DB missing")
|
||||
raise HTTPException(503, "Database not available - sync extraction DB missing")
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
params: list = []
|
||||
@@ -3744,7 +3743,7 @@ async def workorders_list(
|
||||
where_clauses.append(f"w.msdyn_systemstatus IN ({placeholders})")
|
||||
params.extend(status_codes)
|
||||
|
||||
# Technician filter — check bookings table AND hsl_bookedresource fields
|
||||
# Technician filter - check bookings table AND hsl_bookedresource fields
|
||||
if tech:
|
||||
tech_names = [t.strip() for t in tech.split(",") if t.strip()]
|
||||
if tech_names:
|
||||
@@ -3760,7 +3759,7 @@ async def workorders_list(
|
||||
where_clauses.append("(" + " OR ".join(tech_conditions) + ")")
|
||||
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:
|
||||
like = f"%{location}%"
|
||||
where_clauses.append(
|
||||
@@ -3903,7 +3902,7 @@ async def workorders_list(
|
||||
conn.close()
|
||||
|
||||
|
||||
# ── Route Optimization ──────────────────────────────────────────────────────
|
||||
# -- Route Optimization ------------------------------------------------------
|
||||
|
||||
def _lookup_asset_gps_by_machine_id(machine_id: str) -> dict | None:
|
||||
"""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()
|
||||
if not conn:
|
||||
raise HTTPException(503, "Database not available — sync extraction DB missing")
|
||||
raise HTTPException(503, "Database not available - sync extraction DB missing")
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
placeholders = ",".join(["?"] * len(wo_ids))
|
||||
cur.execute(
|
||||
f"""
|
||||
cur.execute(f"""
|
||||
SELECT
|
||||
w.msdyn_workorderid, w.msdyn_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
|
||||
WHERE w.msdyn_name IN ({placeholders})
|
||||
OR w.msdyn_workorderid IN ({placeholders})
|
||||
""",
|
||||
wo_ids + wo_ids,
|
||||
)
|
||||
""", wo_ids + wo_ids)
|
||||
rows = cur.fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -3994,7 +3990,7 @@ async def route_optimize(body: RouteOptimizeRequest):
|
||||
except (ValueError, TypeError):
|
||||
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
|
||||
asset_lat = r["asset_lat"]
|
||||
asset_lng = r["asset_lng"]
|
||||
@@ -4117,7 +4113,7 @@ async def route_optimize(body: RouteOptimizeRequest):
|
||||
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")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user