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,
|
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 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
|
# 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")
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user