2880 lines
109 KiB
Python
2880 lines
109 KiB
Python
"""
|
|
Canteen Asset Tracker — Admin Server.
|
|
|
|
Standalone FastAPI server for admin-only operations.
|
|
Uses the same SQLite database as the main canteen-asset-tracker server
|
|
(via CANTEEN_DB_PATH env var), sharing assets.db for all CRUD operations
|
|
on users, customers, locations, rooms, geofences, settings, activity log,
|
|
stats, CSV exports, and database management.
|
|
|
|
Designed to run on a separate port from the main field-worker server.
|
|
"""
|
|
import csv
|
|
import hashlib
|
|
import httpx
|
|
import io
|
|
import json as _json
|
|
import os
|
|
import secrets
|
|
import sqlite3
|
|
import uuid
|
|
from contextlib import asynccontextmanager
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
from fastapi import FastAPI, HTTPException, Query, Request, UploadFile, File
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
|
|
from fastapi.staticfiles import StaticFiles
|
|
from pydantic import BaseModel
|
|
|
|
from cantaloupe_sync import router as cantaloupe_router, create_sync_tables
|
|
from exif_module import router as exif_router, create_exif_tables
|
|
|
|
# ─── 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 / ".." / "canteen-asset-tracker" / "uploads")))
|
|
ICON_MAX_SIZE = 2 * 1024 * 1024 # 2 MB
|
|
ICON_ALLOWED_EXTS = {".png", ".jpg", ".jpeg", ".svg"}
|
|
PHOTO_ALLOWED_EXTS = {".png", ".jpg", ".jpeg"}
|
|
|
|
def _load_categories() -> set:
|
|
"""Load valid category names from the categories lookup table."""
|
|
try:
|
|
conn = sqlite3.connect(DB_PATH)
|
|
names = {r[0] for r in conn.execute("SELECT name FROM categories").fetchall()}
|
|
conn.close()
|
|
return names if names else {"Other"}
|
|
except Exception:
|
|
return {"Furniture", "Appliances", "Utensils & Serveware", "Equipment", "Other"}
|
|
|
|
VALID_CATEGORIES = _load_categories()
|
|
VALID_STATUSES = {"active", "maintenance", "retired"}
|
|
VALID_ROLES = {"admin", "technician", "readonly"}
|
|
# Helpers for comma-separated multi-role support
|
|
def roles_contain(role_str: str, target: str) -> bool:
|
|
"""Check if a comma-separated role string includes target."""
|
|
return target in [r.strip() for r in (role_str or "").split(",")]
|
|
|
|
def validate_roles(role_str: str) -> str:
|
|
"""Accept comma-separated role combos. Normalise: strip whitespace, dedupe, sort."""
|
|
parts = [r.strip().lower() for r in role_str.split(",")]
|
|
parts = [r for r in parts if r] # drop empties
|
|
if not parts:
|
|
raise HTTPException(status_code=422, detail="At least one role is required")
|
|
invalid = [r for r in parts if r not in VALID_ROLES]
|
|
if invalid:
|
|
raise HTTPException(
|
|
status_code=422,
|
|
detail=f"Invalid role(s): {', '.join(invalid)}. Must be one of: {', '.join(sorted(VALID_ROLES))}",
|
|
)
|
|
return ",".join(sorted(set(parts)))
|
|
|
|
|
|
# ─── Database ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
def get_db() -> sqlite3.Connection:
|
|
"""Return a new DB connection with WAL + foreign keys enabled."""
|
|
conn = sqlite3.connect(DB_PATH)
|
|
conn.execute("PRAGMA journal_mode=WAL")
|
|
conn.execute("PRAGMA foreign_keys=ON")
|
|
conn.row_factory = sqlite3.Row
|
|
return conn
|
|
|
|
|
|
def _create_tables(conn: sqlite3.Connection):
|
|
"""Create all tables if they don't exist (same schema as main server)."""
|
|
conn.executescript("""
|
|
CREATE TABLE IF NOT EXISTS users (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
username TEXT UNIQUE NOT NULL,
|
|
password_hash TEXT NOT NULL,
|
|
role TEXT NOT NULL DEFAULT 'technician',
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS customers (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT UNIQUE NOT NULL,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS customer_contacts (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
|
|
name TEXT,
|
|
phone TEXT,
|
|
email TEXT
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS locations (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
customer_id INTEGER REFERENCES customers(id),
|
|
name TEXT,
|
|
address TEXT DEFAULT '',
|
|
building_name TEXT DEFAULT '',
|
|
building_number TEXT DEFAULT '',
|
|
floor TEXT DEFAULT '',
|
|
trailer_number TEXT DEFAULT '',
|
|
site_hours TEXT DEFAULT '',
|
|
access_notes TEXT DEFAULT '',
|
|
walking_directions TEXT DEFAULT '',
|
|
map_link TEXT DEFAULT '',
|
|
latitude REAL DEFAULT NULL,
|
|
longitude REAL DEFAULT NULL,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS rooms (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
location_id INTEGER NOT NULL REFERENCES locations(id) ON DELETE CASCADE,
|
|
name TEXT,
|
|
floor TEXT DEFAULT '',
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS categories (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT UNIQUE NOT NULL,
|
|
icon TEXT DEFAULT ''
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS key_names (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT UNIQUE NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS key_types (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT UNIQUE NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS badge_types (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT UNIQUE NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS makes (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT UNIQUE NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS models (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
make_id INTEGER NOT NULL REFERENCES makes(id),
|
|
name TEXT NOT NULL,
|
|
icon_path TEXT
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS assets (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
machine_id TEXT NOT NULL UNIQUE,
|
|
serial_number TEXT DEFAULT '',
|
|
name TEXT NOT NULL,
|
|
description TEXT DEFAULT '',
|
|
category TEXT NOT NULL DEFAULT 'Other',
|
|
status TEXT NOT NULL DEFAULT 'active',
|
|
make TEXT DEFAULT '',
|
|
model TEXT DEFAULT '',
|
|
address TEXT DEFAULT '',
|
|
building_name TEXT DEFAULT '',
|
|
building_number TEXT DEFAULT '',
|
|
floor TEXT DEFAULT '',
|
|
room TEXT DEFAULT '',
|
|
trailer_number TEXT DEFAULT '',
|
|
walking_directions TEXT DEFAULT '',
|
|
map_link TEXT DEFAULT '',
|
|
parking_location TEXT DEFAULT '',
|
|
photo_path TEXT,
|
|
customer_id INTEGER REFERENCES customers(id),
|
|
location_id INTEGER REFERENCES locations(id),
|
|
assigned_to INTEGER REFERENCES users(id),
|
|
latitude REAL DEFAULT NULL,
|
|
longitude REAL DEFAULT NULL,
|
|
geofence_radius_meters INTEGER DEFAULT 50,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS checkins (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
asset_id INTEGER NOT NULL REFERENCES assets(id) ON DELETE CASCADE,
|
|
user_id INTEGER REFERENCES users(id),
|
|
latitude REAL,
|
|
longitude REAL,
|
|
accuracy REAL,
|
|
photo_path TEXT,
|
|
notes TEXT DEFAULT '',
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS asset_keys (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
asset_id INTEGER NOT NULL REFERENCES assets(id) ON DELETE CASCADE,
|
|
key_name TEXT,
|
|
key_type TEXT
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS asset_badges (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
asset_id INTEGER NOT NULL REFERENCES assets(id) ON DELETE CASCADE,
|
|
badge_name TEXT
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS settings (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
key TEXT UNIQUE NOT NULL,
|
|
value TEXT
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS activity_log (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER REFERENCES users(id),
|
|
action TEXT,
|
|
entity_type TEXT,
|
|
entity_id INTEGER,
|
|
details TEXT,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS geofences (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT,
|
|
points TEXT,
|
|
color TEXT,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS geofence_users (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
geofence_id INTEGER NOT NULL REFERENCES geofences(id) ON DELETE CASCADE,
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
UNIQUE(geofence_id, user_id)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS visits (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER REFERENCES users(id),
|
|
asset_id INTEGER REFERENCES assets(id) ON DELETE CASCADE,
|
|
checkin_time TEXT,
|
|
checkout_time TEXT,
|
|
duration_minutes INTEGER,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS sessions (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
|
token TEXT UNIQUE NOT NULL,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS cantaloupe_sync_batches (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
status TEXT NOT NULL DEFAULT 'pending',
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
approved_at TEXT,
|
|
file_path TEXT,
|
|
row_count INTEGER DEFAULT 0,
|
|
diff_summary TEXT,
|
|
raw_data TEXT,
|
|
error_message TEXT
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_checkins_asset_id ON checkins(asset_id);
|
|
CREATE INDEX IF NOT EXISTS idx_checkins_created_at ON checkins(created_at);
|
|
CREATE INDEX IF NOT EXISTS idx_assets_category ON assets(category);
|
|
CREATE INDEX IF NOT EXISTS idx_assets_machine_id ON assets(machine_id);
|
|
CREATE INDEX IF NOT EXISTS idx_csb_status ON cantaloupe_sync_batches(status);
|
|
CREATE INDEX IF NOT EXISTS idx_csb_created ON cantaloupe_sync_batches(created_at);
|
|
""")
|
|
|
|
|
|
def _seed_if_empty(conn: sqlite3.Connection, table: str, columns: tuple, rows: list):
|
|
"""Insert seed rows if table is empty."""
|
|
existing = conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0]
|
|
if existing == 0:
|
|
placeholders = ", ".join(["?"] * len(columns))
|
|
col_names = ", ".join(columns)
|
|
conn.executemany(
|
|
f"INSERT INTO {table} ({col_names}) VALUES ({placeholders})",
|
|
rows,
|
|
)
|
|
|
|
|
|
def _seed_data(conn: sqlite3.Connection):
|
|
"""Insert default seed data for lookup tables."""
|
|
_seed_if_empty(conn, "categories", ("name", "icon"), [
|
|
("Furniture", "🪑"), ("Appliances", "🔌"),
|
|
("Utensils & Serveware", "🍽️"), ("Equipment", "⚙️"), ("Other", "📦"),
|
|
])
|
|
_seed_if_empty(conn, "key_names", ("name",), [
|
|
("MK500",), ("Green Dot",), ("Red Key",), ("Blue Key",),
|
|
("Master Key",), ("Padlock Key",),
|
|
])
|
|
_seed_if_empty(conn, "key_types", ("name",), [
|
|
("Round Short",), ("Barrel",), ("Standard",), ("Flat",), ("Tubular",),
|
|
])
|
|
_seed_if_empty(conn, "badge_types", ("name",), [
|
|
("Disney Contractor Base",), ("Visitor Badge",), ("Employee Badge",),
|
|
("Contractor Badge",), ("Temporary Pass",),
|
|
])
|
|
_seed_if_empty(conn, "makes", ("name",), [
|
|
("Canteen",), ("Hobart",), ("Vollrath",), ("Metro",),
|
|
("Rubbermaid",), ("Cambro",), ("Other",),
|
|
])
|
|
# Ensure default admin user exists
|
|
conn.execute(
|
|
"INSERT OR IGNORE INTO users (username, password_hash, role) VALUES (?, ?, ?)",
|
|
("admin", "057ba03d6c44104863dc7361fe4578965d1887360f90a0895882e58a6248fc86", "admin"),
|
|
)
|
|
|
|
|
|
def init_db(conn: sqlite3.Connection):
|
|
"""Ensure all tables exist, run migrations if needed, seed default data."""
|
|
_create_tables(conn)
|
|
create_sync_tables(conn)
|
|
_seed_data(conn)
|
|
# Add lat/lng columns if missing (v3 migration)
|
|
cursor = conn.execute("PRAGMA table_info(assets)")
|
|
asset_cols = {row[1] for row in cursor.fetchall()}
|
|
if "latitude" not in asset_cols:
|
|
conn.execute("ALTER TABLE assets ADD COLUMN latitude REAL DEFAULT NULL")
|
|
if "longitude" not in asset_cols:
|
|
conn.execute("ALTER TABLE assets ADD COLUMN longitude REAL DEFAULT NULL")
|
|
if "geofence_radius_meters" not in asset_cols:
|
|
conn.execute("ALTER TABLE assets ADD COLUMN geofence_radius_meters INTEGER DEFAULT 50")
|
|
# v4 migration: dex_report_date, deployed, pulled_date
|
|
if "dex_report_date" not in asset_cols:
|
|
conn.execute("ALTER TABLE assets ADD COLUMN dex_report_date TEXT DEFAULT NULL")
|
|
if "deployed" not in asset_cols:
|
|
conn.execute("ALTER TABLE assets ADD COLUMN deployed TEXT DEFAULT NULL")
|
|
if "pulled_date" not in asset_cols:
|
|
conn.execute("ALTER TABLE assets ADD COLUMN pulled_date TEXT DEFAULT NULL")
|
|
# v5 migration: install_date
|
|
if "install_date" not in asset_cols:
|
|
conn.execute("ALTER TABLE assets ADD COLUMN install_date TEXT DEFAULT NULL")
|
|
cursor = conn.execute("PRAGMA table_info(locations)")
|
|
loc_cols = {row[1] for row in cursor.fetchall()}
|
|
if "latitude" not in loc_cols:
|
|
conn.execute("ALTER TABLE locations ADD COLUMN latitude REAL DEFAULT NULL")
|
|
if "longitude" not in loc_cols:
|
|
conn.execute("ALTER TABLE locations ADD COLUMN longitude REAL DEFAULT NULL")
|
|
conn.commit()
|
|
|
|
|
|
# ─── App / Middleware ───────────────────────────────────────────────────────
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
|
|
conn = get_db()
|
|
init_db(conn)
|
|
conn.close()
|
|
create_exif_tables()
|
|
yield
|
|
|
|
|
|
app = FastAPI(title="Canteen Asset Tracker — Admin API", lifespan=lifespan)
|
|
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=["*"],
|
|
allow_credentials=True,
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
# Mount Cantaloupe sync routes
|
|
app.include_router(cantaloupe_router)
|
|
app.include_router(exif_router)
|
|
|
|
# ─── Auth Middleware ────────────────────────────────────────────────────────
|
|
|
|
|
|
@app.middleware("http")
|
|
async def auth_middleware(request: Request, call_next):
|
|
"""Require valid Bearer token for all /api/* routes except login."""
|
|
path = request.url.path
|
|
|
|
if os.environ.get("CANTEEN_SKIP_AUTH") == "1":
|
|
return await call_next(request)
|
|
|
|
if not path.startswith("/api/") or path == "/api/auth/login" or path.startswith("/api/lookup-business") or path.startswith("/api/admin/reports/"):
|
|
return await call_next(request)
|
|
|
|
auth_header = request.headers.get("Authorization", "")
|
|
if not auth_header.startswith("Bearer "):
|
|
return JSONResponse(
|
|
status_code=401,
|
|
content={"detail": "Authentication required"},
|
|
)
|
|
|
|
token = auth_header[7:]
|
|
conn = get_db()
|
|
row = conn.execute(
|
|
"SELECT u.id, u.username, u.role FROM users u "
|
|
"JOIN sessions s ON u.id = s.user_id WHERE s.token = ? AND s.expires_at > datetime('now')",
|
|
(token,),
|
|
).fetchone()
|
|
conn.close()
|
|
|
|
if row is None:
|
|
return JSONResponse(
|
|
status_code=401,
|
|
content={"detail": "Invalid or expired token"},
|
|
)
|
|
|
|
request.state.current_user = {
|
|
"id": row["id"],
|
|
"username": row["username"],
|
|
"role": row["role"],
|
|
}
|
|
request.state.user_id = row["id"]
|
|
|
|
return await call_next(request)
|
|
|
|
|
|
# ─── Global Error Handling ──────────────────────────────────────────────────
|
|
|
|
|
|
@app.exception_handler(HTTPException)
|
|
async def http_exception_handler(request: Request, exc: HTTPException):
|
|
return JSONResponse(
|
|
status_code=exc.status_code,
|
|
content={"detail": exc.detail},
|
|
)
|
|
|
|
|
|
@app.exception_handler(Exception)
|
|
async def generic_exception_handler(request: Request, exc: Exception):
|
|
import traceback
|
|
traceback.print_exc()
|
|
return JSONResponse(
|
|
status_code=500,
|
|
content={"detail": "Internal server error"},
|
|
)
|
|
|
|
|
|
# ─── Helper functions ──────────────────────────────────────────────────────
|
|
|
|
|
|
def _hash_password(password: str) -> str:
|
|
return hashlib.sha256(password.encode()).hexdigest()
|
|
|
|
|
|
def _generate_token() -> str:
|
|
return secrets.token_hex(32)
|
|
|
|
|
|
def row_to_dict(row: sqlite3.Row) -> dict:
|
|
return dict(row)
|
|
|
|
|
|
def _user_to_dict(row: sqlite3.Row) -> dict:
|
|
d = row_to_dict(row)
|
|
d.pop("password_hash", None)
|
|
return d
|
|
|
|
|
|
def _log_activity(conn: sqlite3.Connection, action: str, entity_type: str,
|
|
entity_id: int, details: str = "", user_id: int = None):
|
|
conn.execute(
|
|
"INSERT INTO activity_log (user_id, action, entity_type, entity_id, details) "
|
|
"VALUES (?, ?, ?, ?, ?)",
|
|
(user_id, action, entity_type, entity_id, details),
|
|
)
|
|
|
|
|
|
def _validate_ref(conn: sqlite3.Connection, table: str, id_val: int, label: str):
|
|
if id_val is not None:
|
|
row = conn.execute(f"SELECT id FROM {table} WHERE id = ?", (id_val,)).fetchone()
|
|
if row is None:
|
|
raise HTTPException(status_code=422, detail=f"{label} with id {id_val} not found")
|
|
|
|
|
|
def _validate_enum_table(conn: sqlite3.Connection, table: str, value: str, label: str):
|
|
if value:
|
|
row = conn.execute(
|
|
f"SELECT id FROM {table} WHERE name = ?", (value,)
|
|
).fetchone()
|
|
if row is None:
|
|
raise HTTPException(
|
|
status_code=422,
|
|
detail=f"Invalid {label} '{value}'. Must exist in {table} table.",
|
|
)
|
|
|
|
|
|
def _geofence_row(row: sqlite3.Row, conn: Optional[sqlite3.Connection] = None) -> dict:
|
|
"""Serialize a geofence row, parsing points JSON and including assigned users."""
|
|
d = dict(row)
|
|
if isinstance(d.get("points"), str):
|
|
try:
|
|
d["points"] = _json.loads(d["points"])
|
|
except (_json.JSONDecodeError, TypeError):
|
|
pass
|
|
close_conn = False
|
|
if conn is None:
|
|
conn = get_db()
|
|
close_conn = True
|
|
try:
|
|
user_rows = conn.execute(
|
|
"""SELECT u.id, u.username, u.role FROM geofence_users gu
|
|
JOIN users u ON u.id = gu.user_id
|
|
WHERE gu.geofence_id = ? ORDER BY u.username""",
|
|
(d["id"],),
|
|
).fetchall()
|
|
d["assigned_users"] = [dict(r) for r in user_rows]
|
|
finally:
|
|
if close_conn:
|
|
conn.close()
|
|
return d
|
|
|
|
|
|
def _sync_geofence_users(conn: sqlite3.Connection, geofence_id: int, user_ids: list[int]):
|
|
"""Replace assigned users for a geofence with the given list of user IDs."""
|
|
if user_ids:
|
|
placeholders = ", ".join(["?"] * len(user_ids))
|
|
existing = conn.execute(
|
|
f"SELECT id FROM users WHERE id IN ({placeholders})",
|
|
user_ids,
|
|
).fetchall()
|
|
existing_ids = {r["id"] for r in existing}
|
|
for uid in user_ids:
|
|
if uid not in existing_ids:
|
|
raise HTTPException(
|
|
status_code=422,
|
|
detail=f"User with id {uid} not found",
|
|
)
|
|
|
|
conn.execute("DELETE FROM geofence_users WHERE geofence_id = ?", (geofence_id,))
|
|
for uid in user_ids:
|
|
conn.execute(
|
|
"INSERT INTO geofence_users (geofence_id, user_id) VALUES (?, ?)",
|
|
(geofence_id, uid),
|
|
)
|
|
|
|
|
|
def _get_customer_contacts(conn: sqlite3.Connection, cust_id: int) -> list:
|
|
rows = conn.execute(
|
|
"SELECT id, customer_id, name, phone, email FROM customer_contacts WHERE customer_id = ?",
|
|
(cust_id,),
|
|
).fetchall()
|
|
return [row_to_dict(r) for r in rows]
|
|
|
|
|
|
def _get_location_rooms(conn: sqlite3.Connection, loc_id: int) -> list:
|
|
rows = conn.execute(
|
|
"SELECT id, location_id, name, floor, created_at, updated_at FROM rooms WHERE location_id = ? ORDER BY name",
|
|
(loc_id,),
|
|
).fetchall()
|
|
return [row_to_dict(r) for r in rows]
|
|
|
|
|
|
def _save_upload(upload: UploadFile, subdir: str, allowed_exts: set, max_size: int) -> str:
|
|
"""Save uploaded file to uploads/{subdir}/ with a UUID filename."""
|
|
ext = Path(upload.filename or "").suffix.lower()
|
|
if not ext or ext not in allowed_exts:
|
|
allowed = ", ".join(sorted(allowed_exts))
|
|
raise HTTPException(400, f"Invalid file type. Allowed: {allowed}")
|
|
|
|
contents = upload.file.read()
|
|
if len(contents) > max_size:
|
|
mb = max_size // (1024 * 1024)
|
|
raise HTTPException(413, f"File too large. Maximum size: {mb} MB")
|
|
|
|
dest_dir = UPLOADS_DIR / subdir
|
|
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
fname = f"{uuid.uuid4().hex}{ext}"
|
|
(dest_dir / fname).write_bytes(contents)
|
|
|
|
return f"/uploads/{subdir}/{fname}"
|
|
|
|
|
|
# ─── Pydantic Models ────────────────────────────────────────────────────────
|
|
|
|
|
|
class CustomerContact(BaseModel):
|
|
name: Optional[str] = ""
|
|
phone: Optional[str] = ""
|
|
email: Optional[str] = ""
|
|
|
|
|
|
class CustomerCreate(BaseModel):
|
|
name: str
|
|
contacts: Optional[list[CustomerContact]] = []
|
|
|
|
|
|
class CustomerUpdate(BaseModel):
|
|
name: Optional[str] = None
|
|
contacts: Optional[list[CustomerContact]] = None
|
|
|
|
|
|
class LocationCreate(BaseModel):
|
|
customer_id: Optional[int] = None
|
|
name: str
|
|
address: Optional[str] = ""
|
|
building_name: Optional[str] = ""
|
|
building_number: Optional[str] = ""
|
|
floor: Optional[str] = ""
|
|
trailer_number: Optional[str] = ""
|
|
site_hours: Optional[str] = ""
|
|
access_notes: Optional[str] = ""
|
|
walking_directions: Optional[str] = ""
|
|
map_link: Optional[str] = ""
|
|
latitude: Optional[float] = None
|
|
longitude: Optional[float] = None
|
|
|
|
|
|
class LocationUpdate(BaseModel):
|
|
customer_id: Optional[int] = None
|
|
name: Optional[str] = None
|
|
address: Optional[str] = None
|
|
building_name: Optional[str] = None
|
|
building_number: Optional[str] = None
|
|
floor: Optional[str] = None
|
|
trailer_number: Optional[str] = None
|
|
site_hours: Optional[str] = None
|
|
access_notes: Optional[str] = None
|
|
walking_directions: Optional[str] = None
|
|
map_link: Optional[str] = None
|
|
latitude: Optional[float] = None
|
|
longitude: Optional[float] = None
|
|
|
|
|
|
class RoomCreate(BaseModel):
|
|
location_id: int
|
|
name: str
|
|
floor: Optional[str] = ""
|
|
|
|
|
|
class RoomUpdate(BaseModel):
|
|
name: Optional[str] = None
|
|
floor: Optional[str] = None
|
|
location_id: Optional[int] = None
|
|
|
|
|
|
class UserCreate(BaseModel):
|
|
username: str
|
|
password: str
|
|
role: Optional[str] = "technician"
|
|
|
|
|
|
class UserUpdate(BaseModel):
|
|
role: Optional[str] = None
|
|
password: Optional[str] = None
|
|
|
|
|
|
class GeofenceCreate(BaseModel):
|
|
name: str
|
|
points: list
|
|
color: Optional[str] = "#3388ff"
|
|
user_ids: Optional[list[int]] = None
|
|
|
|
|
|
class GeofenceUpdate(BaseModel):
|
|
name: Optional[str] = None
|
|
points: Optional[list] = None
|
|
color: Optional[str] = None
|
|
user_ids: Optional[list[int]] = None
|
|
|
|
|
|
# ─── Health ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
@app.get("/health")
|
|
def health():
|
|
return {"status": "ok"}
|
|
|
|
|
|
# ─── Users API ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
@app.post("/api/users", status_code=201)
|
|
def create_user(body: UserCreate):
|
|
username = body.username.strip()
|
|
if not username:
|
|
raise HTTPException(status_code=422, detail="Username must not be empty")
|
|
password = body.password
|
|
if not password:
|
|
raise HTTPException(status_code=422, detail="Password must not be empty")
|
|
role = validate_roles(body.role or "technician")
|
|
|
|
conn = get_db()
|
|
password_hash = _hash_password(password)
|
|
try:
|
|
cursor = conn.execute(
|
|
"INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)",
|
|
(username, password_hash, role),
|
|
)
|
|
conn.commit()
|
|
uid = cursor.lastrowid
|
|
_log_activity(conn, "created", "user", uid, f"User '{username}' created")
|
|
conn.commit()
|
|
except sqlite3.IntegrityError:
|
|
conn.close()
|
|
raise HTTPException(status_code=409, detail=f"User '{username}' already exists")
|
|
|
|
row = conn.execute("SELECT * FROM users WHERE id = ?", (uid,)).fetchone()
|
|
conn.close()
|
|
return _user_to_dict(row)
|
|
|
|
|
|
@app.get("/api/users")
|
|
def list_users():
|
|
conn = get_db()
|
|
rows = conn.execute("SELECT * FROM users ORDER BY username").fetchall()
|
|
conn.close()
|
|
return [_user_to_dict(r) for r in rows]
|
|
|
|
|
|
@app.get("/api/users/{user_id}")
|
|
def get_user(user_id: int):
|
|
conn = get_db()
|
|
row = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone()
|
|
if row is None:
|
|
conn.close()
|
|
raise HTTPException(status_code=404, detail="User not found")
|
|
conn.close()
|
|
return _user_to_dict(row)
|
|
|
|
|
|
@app.put("/api/users/{user_id}")
|
|
def update_user(user_id: int, body: UserUpdate):
|
|
conn = get_db()
|
|
existing = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone()
|
|
if existing is None:
|
|
conn.close()
|
|
raise HTTPException(status_code=404, detail="User not found")
|
|
|
|
if body.role is not None:
|
|
role = validate_roles(body.role)
|
|
conn.execute("UPDATE users SET role = ? WHERE id = ?", (role, user_id))
|
|
_log_activity(conn, "updated", "user", user_id,
|
|
f"User '{existing['username']}' role changed to '{role}'")
|
|
conn.commit()
|
|
|
|
if body.password is not None:
|
|
pw = body.password.strip()
|
|
if not pw:
|
|
conn.close()
|
|
raise HTTPException(status_code=422, detail="Password must not be empty")
|
|
password_hash = _hash_password(pw)
|
|
conn.execute("UPDATE users SET password_hash = ? WHERE id = ?", (password_hash, user_id))
|
|
_log_activity(conn, "updated", "user", user_id,
|
|
f"User '{existing['username']}' password changed")
|
|
conn.commit()
|
|
|
|
conn.commit()
|
|
row = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone()
|
|
conn.close()
|
|
return _user_to_dict(row)
|
|
|
|
|
|
@app.delete("/api/users/{user_id}", status_code=204)
|
|
def delete_user(user_id: int):
|
|
conn = get_db()
|
|
existing = conn.execute("SELECT id, username FROM users WHERE id = ?", (user_id,)).fetchone()
|
|
if existing is None:
|
|
conn.close()
|
|
raise HTTPException(status_code=404, detail="User not found")
|
|
conn.execute("DELETE FROM users WHERE id = ?", (user_id,))
|
|
conn.commit()
|
|
_log_activity(conn, "deleted", "user", user_id,
|
|
f"User '{existing['username']}' deleted")
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
|
|
@app.get("/api/users/{user_id}/geofences")
|
|
def list_user_geofences(user_id: int):
|
|
"""List geofences assigned to a user (their service areas)."""
|
|
conn = get_db()
|
|
existing = conn.execute("SELECT id FROM users WHERE id = ?", (user_id,)).fetchone()
|
|
if existing is None:
|
|
conn.close()
|
|
raise HTTPException(status_code=404, detail="User not found")
|
|
rows = conn.execute(
|
|
"""SELECT g.* FROM geofences g
|
|
JOIN geofence_users gu ON gu.geofence_id = g.id
|
|
WHERE gu.user_id = ? ORDER BY g.name""",
|
|
(user_id,),
|
|
).fetchall()
|
|
result = [_geofence_row(r, conn) for r in rows]
|
|
conn.close()
|
|
return result
|
|
|
|
|
|
# ─── Login (needed for token-based access) ──────────────────────────────────
|
|
|
|
|
|
@app.post("/api/auth/login")
|
|
def login(body: dict):
|
|
"""Authenticate a user and return a session token."""
|
|
username = body.get("username", "")
|
|
password = body.get("password", "")
|
|
conn = get_db()
|
|
row = conn.execute(
|
|
"SELECT * FROM users WHERE username = ?", (username,)
|
|
).fetchone()
|
|
if row is None:
|
|
conn.close()
|
|
raise HTTPException(status_code=401, detail="Invalid username or password")
|
|
|
|
password_hash = _hash_password(password)
|
|
if password_hash != row["password_hash"]:
|
|
conn.close()
|
|
raise HTTPException(status_code=401, detail="Invalid username or password")
|
|
|
|
token = _generate_token()
|
|
remember_me = body.get("remember_me", False)
|
|
expiry = "+30 days" if remember_me else "+1 day"
|
|
conn.execute(
|
|
"INSERT INTO sessions (user_id, token, expires_at) VALUES (?, ?, datetime('now', ?))",
|
|
(row["id"], token, expiry),
|
|
)
|
|
conn.commit()
|
|
_log_activity(conn, "login", "user", row["id"],
|
|
f"User '{username}' logged in")
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
result = _user_to_dict(row)
|
|
result["token"] = token
|
|
return result
|
|
|
|
|
|
# ─── Auth: Me (validate token) ───────────────────────────────────────────────
|
|
|
|
|
|
@app.get("/api/auth/me")
|
|
def auth_me(request: Request):
|
|
"""Return the current authenticated user from the Authorization header."""
|
|
auth_header = request.headers.get("Authorization", "")
|
|
if not auth_header.startswith("Bearer "):
|
|
raise HTTPException(status_code=401, detail="Missing or invalid Authorization header")
|
|
token = auth_header[7:]
|
|
conn = get_db()
|
|
row = conn.execute(
|
|
"SELECT u.* FROM users u JOIN sessions s ON u.id = s.user_id WHERE s.token = ? AND s.expires_at > datetime('now')",
|
|
(token,),
|
|
).fetchone()
|
|
conn.close()
|
|
if row is None:
|
|
raise HTTPException(status_code=401, detail="Invalid or expired token")
|
|
return _user_to_dict(row)
|
|
|
|
|
|
# ─── Customers API ──────────────────────────────────────────────────────────
|
|
|
|
|
|
@app.post("/api/customers", status_code=201)
|
|
def create_customer(body: CustomerCreate):
|
|
name = body.name.strip()
|
|
if not name:
|
|
raise HTTPException(status_code=422, detail="Customer name must not be empty")
|
|
conn = get_db()
|
|
try:
|
|
cursor = conn.execute("INSERT INTO customers (name) VALUES (?)", (name,))
|
|
cust_id = cursor.lastrowid
|
|
if body.contacts:
|
|
for c in body.contacts:
|
|
conn.execute(
|
|
"INSERT INTO customer_contacts (customer_id, name, phone, email) VALUES (?, ?, ?, ?)",
|
|
(cust_id, c.name or "", c.phone or "", c.email or ""),
|
|
)
|
|
conn.commit()
|
|
_log_activity(conn, "created", "customer", cust_id,
|
|
f"Customer '{name}' created")
|
|
conn.commit()
|
|
except sqlite3.IntegrityError:
|
|
conn.close()
|
|
raise HTTPException(status_code=409, detail=f"Customer '{name}' already exists")
|
|
|
|
row = conn.execute("SELECT * FROM customers WHERE id = ?", (cust_id,)).fetchone()
|
|
result = row_to_dict(row)
|
|
result["contacts"] = _get_customer_contacts(conn, cust_id)
|
|
conn.close()
|
|
return result
|
|
|
|
|
|
@app.get("/api/customers")
|
|
def list_customers():
|
|
conn = get_db()
|
|
rows = conn.execute("SELECT * FROM customers ORDER BY name").fetchall()
|
|
result = []
|
|
for r in rows:
|
|
d = row_to_dict(r)
|
|
d["contacts"] = _get_customer_contacts(conn, r["id"])
|
|
result.append(d)
|
|
conn.close()
|
|
return result
|
|
|
|
|
|
@app.get("/api/customers/{cust_id}")
|
|
def get_customer(cust_id: int):
|
|
conn = get_db()
|
|
row = conn.execute("SELECT * FROM customers WHERE id = ?", (cust_id,)).fetchone()
|
|
if row is None:
|
|
conn.close()
|
|
raise HTTPException(status_code=404, detail="Customer not found")
|
|
result = row_to_dict(row)
|
|
result["contacts"] = _get_customer_contacts(conn, cust_id)
|
|
conn.close()
|
|
return result
|
|
|
|
|
|
@app.put("/api/customers/{cust_id}")
|
|
def update_customer(cust_id: int, body: CustomerUpdate):
|
|
conn = get_db()
|
|
existing = conn.execute("SELECT id FROM customers WHERE id = ?", (cust_id,)).fetchone()
|
|
if existing is None:
|
|
conn.close()
|
|
raise HTTPException(status_code=404, detail="Customer not found")
|
|
|
|
if body.name is not None:
|
|
name = body.name.strip()
|
|
if not name:
|
|
conn.close()
|
|
raise HTTPException(status_code=422, detail="Customer name must not be empty")
|
|
conn.execute(
|
|
"UPDATE customers SET name = ?, updated_at = datetime('now') WHERE id = ?",
|
|
(name, cust_id),
|
|
)
|
|
|
|
if body.contacts is not None:
|
|
conn.execute("DELETE FROM customer_contacts WHERE customer_id = ?", (cust_id,))
|
|
for c in body.contacts:
|
|
conn.execute(
|
|
"INSERT INTO customer_contacts (customer_id, name, phone, email) VALUES (?, ?, ?, ?)",
|
|
(cust_id, c.name or "", c.phone or "", c.email or ""),
|
|
)
|
|
|
|
conn.commit()
|
|
row = conn.execute("SELECT * FROM customers WHERE id = ?", (cust_id,)).fetchone()
|
|
result = row_to_dict(row)
|
|
result["contacts"] = _get_customer_contacts(conn, cust_id)
|
|
_log_activity(conn, "updated", "customer", cust_id,
|
|
f"Customer '{result['name']}' updated")
|
|
conn.commit()
|
|
conn.close()
|
|
return result
|
|
|
|
|
|
@app.delete("/api/customers/{cust_id}", status_code=204)
|
|
def delete_customer(cust_id: int):
|
|
conn = get_db()
|
|
existing = conn.execute("SELECT id FROM customers WHERE id = ?", (cust_id,)).fetchone()
|
|
if existing is None:
|
|
conn.close()
|
|
raise HTTPException(status_code=404, detail="Customer not found")
|
|
conn.execute("DELETE FROM customers WHERE id = ?", (cust_id,))
|
|
_log_activity(conn, "deleted", "customer", cust_id,
|
|
f"Customer {cust_id} deleted")
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
|
|
# ─── Locations API ──────────────────────────────────────────────────────────
|
|
|
|
|
|
@app.post("/api/locations", status_code=201)
|
|
def create_location(body: LocationCreate):
|
|
name = body.name.strip()
|
|
if not name:
|
|
raise HTTPException(status_code=422, detail="Location name must not be empty")
|
|
conn = get_db()
|
|
if body.customer_id is not None:
|
|
_validate_ref(conn, "customers", body.customer_id, "Customer")
|
|
|
|
cursor = conn.execute(
|
|
"""INSERT INTO locations (customer_id, name, address, building_name, building_number,
|
|
floor, trailer_number, site_hours, access_notes, walking_directions, map_link,
|
|
latitude, longitude)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
(body.customer_id, name, body.address or "", body.building_name or "",
|
|
body.building_number or "", body.floor or "", body.trailer_number or "",
|
|
body.site_hours or "", body.access_notes or "",
|
|
body.walking_directions or "", body.map_link or "",
|
|
body.latitude, body.longitude),
|
|
)
|
|
conn.commit()
|
|
loc_id = cursor.lastrowid
|
|
_log_activity(conn, "created", "location", loc_id,
|
|
f"Location '{name}' created")
|
|
conn.commit()
|
|
row = conn.execute("SELECT * FROM locations WHERE id = ?", (loc_id,)).fetchone()
|
|
result = row_to_dict(row)
|
|
result["rooms"] = []
|
|
conn.close()
|
|
return result
|
|
|
|
|
|
@app.get("/api/locations")
|
|
def list_locations(customer_id: Optional[int] = Query(None)):
|
|
conn = get_db()
|
|
if customer_id is not None:
|
|
rows = conn.execute(
|
|
"SELECT * FROM locations WHERE customer_id = ? ORDER BY name",
|
|
(customer_id,),
|
|
).fetchall()
|
|
else:
|
|
rows = conn.execute("SELECT * FROM locations ORDER BY name").fetchall()
|
|
result = []
|
|
for r in rows:
|
|
d = row_to_dict(r)
|
|
d["rooms"] = _get_location_rooms(conn, r["id"])
|
|
result.append(d)
|
|
conn.close()
|
|
return result
|
|
|
|
|
|
@app.get("/api/locations/{loc_id}")
|
|
def get_location(loc_id: int):
|
|
conn = get_db()
|
|
row = conn.execute("SELECT * FROM locations WHERE id = ?", (loc_id,)).fetchone()
|
|
if row is None:
|
|
conn.close()
|
|
raise HTTPException(status_code=404, detail="Location not found")
|
|
result = row_to_dict(row)
|
|
result["rooms"] = _get_location_rooms(conn, loc_id)
|
|
conn.close()
|
|
return result
|
|
|
|
|
|
_LOCATION_FIELDS = [
|
|
"customer_id", "name", "address", "building_name", "building_number",
|
|
"floor", "trailer_number", "site_hours", "access_notes",
|
|
"walking_directions", "map_link", "latitude", "longitude",
|
|
]
|
|
|
|
|
|
@app.put("/api/locations/{loc_id}")
|
|
def update_location(loc_id: int, body: LocationUpdate):
|
|
conn = get_db()
|
|
existing = conn.execute("SELECT id, name FROM locations WHERE id = ?", (loc_id,)).fetchone()
|
|
if existing is None:
|
|
conn.close()
|
|
raise HTTPException(status_code=404, detail="Location not found")
|
|
|
|
updates = {}
|
|
for field in _LOCATION_FIELDS:
|
|
val = getattr(body, field, None)
|
|
if val is not None:
|
|
if field == "name":
|
|
name = val.strip()
|
|
if not name:
|
|
conn.close()
|
|
raise HTTPException(status_code=422, detail="Location name must not be empty")
|
|
updates[field] = name
|
|
elif field == "customer_id":
|
|
if val is not None:
|
|
_validate_ref(conn, "customers", val, "Customer")
|
|
updates[field] = val
|
|
elif field in ("latitude", "longitude"):
|
|
updates[field] = val
|
|
else:
|
|
updates[field] = val or ""
|
|
|
|
if updates:
|
|
updates["updated_at"] = "datetime('now')"
|
|
set_clause = ", ".join(
|
|
f"{k} = {v}" if k == "updated_at" else f"{k} = ?"
|
|
for k, v in updates.items()
|
|
)
|
|
values = [v for k, v in updates.items() if k != "updated_at"]
|
|
conn.execute(
|
|
f"UPDATE locations SET {set_clause} WHERE id = ?",
|
|
values + [loc_id],
|
|
)
|
|
conn.commit()
|
|
_log_activity(conn, "updated", "location", loc_id,
|
|
f"Location '{existing['name']}' updated")
|
|
conn.commit()
|
|
|
|
row = conn.execute("SELECT * FROM locations WHERE id = ?", (loc_id,)).fetchone()
|
|
result = row_to_dict(row)
|
|
result["rooms"] = _get_location_rooms(conn, loc_id)
|
|
conn.close()
|
|
return result
|
|
|
|
|
|
@app.delete("/api/locations/{loc_id}", status_code=204)
|
|
def delete_location(loc_id: int):
|
|
conn = get_db()
|
|
existing = conn.execute("SELECT id, name FROM locations WHERE id = ?", (loc_id,)).fetchone()
|
|
if existing is None:
|
|
conn.close()
|
|
raise HTTPException(status_code=404, detail="Location not found")
|
|
conn.execute("DELETE FROM locations WHERE id = ?", (loc_id,))
|
|
conn.commit()
|
|
_log_activity(conn, "deleted", "location", loc_id,
|
|
f"Location '{existing['name']}' deleted")
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
|
|
# ─── Rooms API ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
@app.get("/api/rooms")
|
|
def list_rooms(location_id: Optional[int] = Query(None)):
|
|
conn = get_db()
|
|
if location_id is not None:
|
|
rows = conn.execute(
|
|
"SELECT * FROM rooms WHERE location_id = ? ORDER BY name",
|
|
(location_id,),
|
|
).fetchall()
|
|
else:
|
|
rows = conn.execute("SELECT * FROM rooms ORDER BY name").fetchall()
|
|
conn.close()
|
|
return [row_to_dict(r) for r in rows]
|
|
|
|
|
|
@app.get("/api/rooms/{room_id}")
|
|
def get_room(room_id: int):
|
|
conn = get_db()
|
|
row = conn.execute("SELECT * FROM rooms WHERE id = ?", (room_id,)).fetchone()
|
|
conn.close()
|
|
if row is None:
|
|
raise HTTPException(status_code=404, detail="Room not found")
|
|
return row_to_dict(row)
|
|
|
|
|
|
@app.post("/api/rooms", status_code=201)
|
|
def create_room(body: RoomCreate):
|
|
conn = get_db()
|
|
_validate_ref(conn, "locations", body.location_id, "Location")
|
|
|
|
cursor = conn.execute(
|
|
"INSERT INTO rooms (location_id, name, floor) VALUES (?, ?, ?)",
|
|
(body.location_id, body.name.strip(), body.floor or ""),
|
|
)
|
|
conn.commit()
|
|
room_id = cursor.lastrowid
|
|
_log_activity(conn, "created", "room", room_id,
|
|
f"Room '{body.name.strip()}' created in location {body.location_id}")
|
|
conn.commit()
|
|
row = conn.execute("SELECT * FROM rooms WHERE id = ?", (room_id,)).fetchone()
|
|
conn.close()
|
|
return row_to_dict(row)
|
|
|
|
|
|
@app.put("/api/rooms/{room_id}")
|
|
def update_room(room_id: int, body: RoomUpdate):
|
|
conn = get_db()
|
|
existing = conn.execute("SELECT id, name FROM rooms WHERE id = ?", (room_id,)).fetchone()
|
|
if existing is None:
|
|
conn.close()
|
|
raise HTTPException(status_code=404, detail="Room not found")
|
|
|
|
updates = {}
|
|
if body.name is not None:
|
|
updates["name"] = body.name.strip()
|
|
if body.floor is not None:
|
|
updates["floor"] = body.floor
|
|
if body.location_id is not None:
|
|
_validate_ref(conn, "locations", body.location_id, "Location")
|
|
updates["location_id"] = body.location_id
|
|
|
|
if updates:
|
|
updates["updated_at"] = "datetime('now')"
|
|
set_clause = ", ".join(
|
|
f"{k} = {v}" if k == "updated_at" else f"{k} = ?"
|
|
for k, v in updates.items()
|
|
)
|
|
values = [v for k, v in updates.items() if k != "updated_at"]
|
|
conn.execute(
|
|
f"UPDATE rooms SET {set_clause} WHERE id = ?",
|
|
values + [room_id],
|
|
)
|
|
conn.commit()
|
|
_log_activity(conn, "updated", "room", room_id,
|
|
f"Room '{existing['name']}' updated")
|
|
conn.commit()
|
|
|
|
row = conn.execute("SELECT * FROM rooms WHERE id = ?", (room_id,)).fetchone()
|
|
conn.close()
|
|
return row_to_dict(row)
|
|
|
|
|
|
@app.delete("/api/rooms/{room_id}", status_code=204)
|
|
def delete_room(room_id: int):
|
|
conn = get_db()
|
|
existing = conn.execute("SELECT id, name FROM rooms WHERE id = ?", (room_id,)).fetchone()
|
|
if existing is None:
|
|
conn.close()
|
|
raise HTTPException(status_code=404, detail="Room not found")
|
|
conn.execute("DELETE FROM rooms WHERE id = ?", (room_id,))
|
|
conn.commit()
|
|
_log_activity(conn, "deleted", "room", room_id,
|
|
f"Room '{existing['name']}' deleted")
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
|
|
# ─── Settings API ───────────────────────────────────────────────────────────
|
|
|
|
|
|
_SETTINGS_SCHEMAS = {
|
|
"categories": {"table": "categories", "columns": ["name", "icon"]},
|
|
"makes": {"table": "makes", "columns": ["name"]},
|
|
"models": {"table": "models", "columns": ["make_id", "name", "icon_path"]},
|
|
"key_names": {"table": "key_names", "columns": ["name"]},
|
|
"key_types": {"table": "key_types", "columns": ["name"]},
|
|
"badge_types": {"table": "badge_types", "columns": ["name"]},
|
|
}
|
|
|
|
|
|
def _settings_list(conn: sqlite3.Connection, table: str):
|
|
if table == "models":
|
|
rows = conn.execute("SELECT * FROM models ORDER BY name").fetchall()
|
|
else:
|
|
rows = conn.execute(f"SELECT * FROM {table} ORDER BY name").fetchall()
|
|
return [row_to_dict(r) for r in rows]
|
|
|
|
|
|
def _settings_create(conn: sqlite3.Connection, entity: str, body: dict):
|
|
schema = _SETTINGS_SCHEMAS[entity]
|
|
table = schema["table"]
|
|
columns = schema["columns"]
|
|
values = []
|
|
for col in columns:
|
|
val = body.get(col, "" if col != "make_id" else None)
|
|
if col == "make_id" and val is None:
|
|
raise HTTPException(status_code=422, detail="make_id is required for models")
|
|
values.append(val if val is not None else "")
|
|
placeholders = ", ".join(["?"] * len(columns))
|
|
col_names = ", ".join(columns)
|
|
try:
|
|
cursor = conn.execute(
|
|
f"INSERT INTO {table} ({col_names}) VALUES ({placeholders})", values
|
|
)
|
|
conn.commit()
|
|
eid = cursor.lastrowid
|
|
_log_activity(conn, "created", entity, eid,
|
|
f"{entity[:-1].title()} '{body.get('name', '')}' created")
|
|
conn.commit()
|
|
row = conn.execute(f"SELECT * FROM {table} WHERE id = ?", (eid,)).fetchone()
|
|
return row_to_dict(row)
|
|
except sqlite3.IntegrityError:
|
|
raise HTTPException(status_code=409, detail=f"Entry already exists in {entity}")
|
|
|
|
|
|
def _settings_update(conn: sqlite3.Connection, entity: str, eid: int, body: dict):
|
|
schema = _SETTINGS_SCHEMAS[entity]
|
|
table = schema["table"]
|
|
columns = schema["columns"]
|
|
|
|
existing = conn.execute(f"SELECT id, name FROM {table} WHERE id = ?", (eid,)).fetchone()
|
|
if existing is None:
|
|
raise HTTPException(status_code=404, detail=f"{entity[:-1]} not found")
|
|
|
|
updates = {}
|
|
for col in columns:
|
|
if col in body:
|
|
updates[col] = body[col]
|
|
|
|
if updates:
|
|
set_clause = ", ".join(f"{k} = ?" for k in updates)
|
|
values = list(updates.values())
|
|
conn.execute(
|
|
f"UPDATE {table} SET {set_clause} WHERE id = ?", values + [eid]
|
|
)
|
|
conn.commit()
|
|
_log_activity(conn, "updated", entity, eid,
|
|
f"{entity[:-1].title()} '{existing['name']}' updated")
|
|
conn.commit()
|
|
|
|
row = conn.execute(f"SELECT * FROM {table} WHERE id = ?", (eid,)).fetchone()
|
|
return row_to_dict(row)
|
|
|
|
|
|
def _settings_get(conn: sqlite3.Connection, entity: str, eid: int):
|
|
schema = _SETTINGS_SCHEMAS[entity]
|
|
table = schema["table"]
|
|
row = conn.execute(f"SELECT * FROM {table} WHERE id = ?", (eid,)).fetchone()
|
|
if row is None:
|
|
raise HTTPException(status_code=404, detail=f"{entity[:-1]} not found")
|
|
return row_to_dict(row)
|
|
|
|
|
|
def _settings_delete(conn: sqlite3.Connection, entity: str, eid: int):
|
|
schema = _SETTINGS_SCHEMAS[entity]
|
|
table = schema["table"]
|
|
existing = conn.execute(f"SELECT id, name FROM {table} WHERE id = ?", (eid,)).fetchone()
|
|
if existing is None:
|
|
raise HTTPException(status_code=404, detail=f"{entity[:-1]} not found")
|
|
conn.execute(f"DELETE FROM {table} WHERE id = ?", (eid,))
|
|
conn.commit()
|
|
_log_activity(conn, "deleted", entity, eid,
|
|
f"{entity[:-1].title()} '{existing['name']}' deleted")
|
|
conn.commit()
|
|
|
|
|
|
_SETTINGS_ENTITIES = ["categories", "makes", "models", "key_names", "key_types", "badge_types"]
|
|
|
|
for _ent in _SETTINGS_ENTITIES:
|
|
|
|
@app.get(f"/api/settings/{_ent}", name=f"list_{_ent}")
|
|
def _list(entity=_ent):
|
|
conn = get_db()
|
|
result = _settings_list(conn, entity)
|
|
conn.close()
|
|
return result
|
|
|
|
@app.get(f"/api/settings/{_ent}/{{eid}}", name=f"get_{_ent}")
|
|
def _get(eid: int, entity=_ent):
|
|
conn = get_db()
|
|
try:
|
|
result = _settings_get(conn, entity, eid)
|
|
conn.close()
|
|
return result
|
|
except HTTPException:
|
|
conn.close()
|
|
raise
|
|
|
|
@app.post(f"/api/settings/{_ent}", status_code=201, name=f"create_{_ent}")
|
|
async def _create(request: Request, entity=_ent):
|
|
body = await request.json()
|
|
conn = get_db()
|
|
try:
|
|
result = _settings_create(conn, entity, body)
|
|
conn.close()
|
|
return result
|
|
except HTTPException:
|
|
conn.close()
|
|
raise
|
|
|
|
@app.put(f"/api/settings/{_ent}/{{eid}}", name=f"update_{_ent}")
|
|
async def _update(eid: int, request: Request, entity=_ent):
|
|
body = await request.json()
|
|
conn = get_db()
|
|
try:
|
|
result = _settings_update(conn, entity, eid, body)
|
|
conn.close()
|
|
return result
|
|
except HTTPException:
|
|
conn.close()
|
|
raise
|
|
|
|
@app.delete(f"/api/settings/{_ent}/{{eid}}", status_code=204, name=f"delete_{_ent}")
|
|
def _delete(eid: int, entity=_ent):
|
|
conn = get_db()
|
|
try:
|
|
_settings_delete(conn, entity, eid)
|
|
conn.close()
|
|
except HTTPException:
|
|
conn.close()
|
|
raise
|
|
|
|
|
|
# ─── Geofences API ──────────────────────────────────────────────────────────
|
|
|
|
|
|
@app.post("/api/geofences", status_code=201)
|
|
def create_geofence(body: GeofenceCreate):
|
|
conn = get_db()
|
|
points_json = _json.dumps(body.points)
|
|
cursor = conn.execute(
|
|
"INSERT INTO geofences (name, points, color) VALUES (?, ?, ?)",
|
|
(body.name, points_json, body.color or "#3388ff"),
|
|
)
|
|
gid = cursor.lastrowid
|
|
|
|
if body.user_ids:
|
|
try:
|
|
_sync_geofence_users(conn, gid, body.user_ids)
|
|
except Exception:
|
|
conn.close()
|
|
raise HTTPException(status_code=422, detail="One or more user IDs are invalid")
|
|
|
|
_log_activity(conn, "created", "geofence", gid, f"Geofence '{body.name}' created")
|
|
conn.commit()
|
|
|
|
row = conn.execute("SELECT * FROM geofences WHERE id = ?", (gid,)).fetchone()
|
|
result = _geofence_row(row, conn)
|
|
conn.close()
|
|
return result
|
|
|
|
|
|
@app.get("/api/geofences")
|
|
def list_geofences():
|
|
conn = get_db()
|
|
rows = conn.execute("SELECT * FROM geofences ORDER BY name").fetchall()
|
|
result = [_geofence_row(r, conn) for r in rows]
|
|
conn.close()
|
|
return result
|
|
|
|
|
|
@app.put("/api/geofences/{geofence_id}")
|
|
def update_geofence(geofence_id: int, body: GeofenceUpdate):
|
|
conn = get_db()
|
|
existing = conn.execute(
|
|
"SELECT id, name FROM geofences WHERE id = ?", (geofence_id,)
|
|
).fetchone()
|
|
if existing is None:
|
|
conn.close()
|
|
raise HTTPException(status_code=404, detail="Geofence not found")
|
|
|
|
updates = {}
|
|
if body.name is not None:
|
|
updates["name"] = body.name
|
|
if body.points is not None:
|
|
updates["points"] = _json.dumps(body.points)
|
|
if body.color is not None:
|
|
updates["color"] = body.color
|
|
|
|
if updates:
|
|
updates["updated_at"] = "datetime('now')"
|
|
set_clause = ", ".join(
|
|
f"{k} = {v}" if k == "updated_at" else f"{k} = ?"
|
|
for k, v in updates.items()
|
|
)
|
|
values = [v for k, v in updates.items() if k != "updated_at"]
|
|
conn.execute(
|
|
f"UPDATE geofences SET {set_clause} WHERE id = ?",
|
|
values + [geofence_id],
|
|
)
|
|
conn.commit()
|
|
|
|
if body.user_ids is not None:
|
|
_sync_geofence_users(conn, geofence_id, body.user_ids)
|
|
conn.commit()
|
|
|
|
_log_activity(conn, "updated", "geofence", geofence_id,
|
|
f"Geofence '{existing['name']}' updated")
|
|
conn.commit()
|
|
|
|
row = conn.execute(
|
|
"SELECT * FROM geofences WHERE id = ?", (geofence_id,)
|
|
).fetchone()
|
|
result = _geofence_row(row, conn)
|
|
conn.close()
|
|
return result
|
|
|
|
|
|
@app.delete("/api/geofences/{geofence_id}", status_code=204)
|
|
def delete_geofence(geofence_id: int):
|
|
conn = get_db()
|
|
existing = conn.execute(
|
|
"SELECT id, name FROM geofences WHERE id = ?", (geofence_id,)
|
|
).fetchone()
|
|
if existing is None:
|
|
conn.close()
|
|
raise HTTPException(status_code=404, detail="Geofence not found")
|
|
conn.execute("DELETE FROM geofences WHERE id = ?", (geofence_id,))
|
|
conn.commit()
|
|
_log_activity(conn, "deleted", "geofence", geofence_id,
|
|
f"Geofence '{existing['name']}' deleted")
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
|
|
# ─── Activity Feed API ──────────────────────────────────────────────────────
|
|
|
|
|
|
@app.get("/api/activity")
|
|
def list_activity(
|
|
user_id: Optional[int] = Query(None),
|
|
entity_type: Optional[str] = Query(None),
|
|
date_from: Optional[str] = Query(None),
|
|
date_to: Optional[str] = Query(None),
|
|
limit: int = Query(100, ge=1, le=1000),
|
|
offset: int = Query(0, ge=0),
|
|
):
|
|
conn = get_db()
|
|
conditions = []
|
|
params = []
|
|
|
|
if user_id is not None:
|
|
conditions.append("a.user_id = ?")
|
|
params.append(user_id)
|
|
if entity_type is not None:
|
|
conditions.append("a.entity_type = ?")
|
|
params.append(entity_type)
|
|
if date_from:
|
|
conditions.append("a.created_at >= ?")
|
|
params.append(date_from)
|
|
if date_to:
|
|
conditions.append("a.created_at <= ?")
|
|
params.append(date_to + " 23:59:59")
|
|
|
|
where = " AND ".join(conditions)
|
|
sql = """SELECT a.*, u.username AS user_name
|
|
FROM activity_log a
|
|
LEFT JOIN users u ON a.user_id = u.id"""
|
|
if where:
|
|
sql += f" WHERE {where}"
|
|
sql += " ORDER BY a.created_at DESC, a.id DESC LIMIT ? OFFSET ?"
|
|
params.extend([limit, offset])
|
|
|
|
rows = conn.execute(sql, params).fetchall()
|
|
conn.close()
|
|
return [row_to_dict(r) for r in rows]
|
|
|
|
|
|
# ─── Stats API ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
@app.get("/api/stats")
|
|
def get_stats():
|
|
conn = get_db()
|
|
total_assets = conn.execute("SELECT COUNT(*) FROM assets").fetchone()[0]
|
|
total_checkins = conn.execute("SELECT COUNT(*) FROM checkins").fetchone()[0]
|
|
total_users = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
|
|
total_customers = conn.execute("SELECT COUNT(*) FROM customers").fetchone()[0]
|
|
total_locations = conn.execute("SELECT COUNT(*) FROM locations").fetchone()[0]
|
|
|
|
cats = conn.execute(
|
|
"SELECT category, COUNT(*) AS cnt FROM assets GROUP BY category ORDER BY cnt DESC"
|
|
).fetchall()
|
|
by_category = {r["category"]: r["cnt"] for r in cats}
|
|
|
|
statuses = conn.execute(
|
|
"SELECT status, COUNT(*) AS cnt FROM assets GROUP BY status ORDER BY cnt DESC"
|
|
).fetchall()
|
|
by_status = {r["status"]: r["cnt"] for r in statuses}
|
|
|
|
top_visited = conn.execute(
|
|
"""SELECT a.name, a.machine_id, COUNT(*) AS visit_count,
|
|
MAX(v.checkin_time) AS last_visit_date
|
|
FROM visits v JOIN assets a ON v.asset_id = a.id
|
|
GROUP BY v.asset_id ORDER BY visit_count DESC LIMIT 10"""
|
|
).fetchall()
|
|
top_visited_list = [
|
|
{"name": r["name"], "machine_id": r["machine_id"],
|
|
"visit_count": r["visit_count"], "last_visit_date": r["last_visit_date"]}
|
|
for r in top_visited
|
|
]
|
|
|
|
time_on_site_rows = conn.execute(
|
|
"""SELECT u.username, COUNT(v.id) AS visit_count,
|
|
SUM(CASE WHEN v.checkout_time IS NOT NULL AND v.checkin_time IS NOT NULL
|
|
THEN (julianday(v.checkout_time) - julianday(v.checkin_time)) * 1440
|
|
ELSE 0 END) AS total_minutes
|
|
FROM visits v JOIN users u ON v.user_id = u.id
|
|
WHERE u.role LIKE '%technician%'
|
|
GROUP BY v.user_id ORDER BY total_minutes DESC"""
|
|
).fetchall()
|
|
time_on_site = [
|
|
{"username": r["username"], "visit_count": r["visit_count"],
|
|
"total_minutes": round(r["total_minutes"] or 0, 1)}
|
|
for r in time_on_site_rows
|
|
]
|
|
|
|
makes = conn.execute(
|
|
"SELECT make, COUNT(*) AS cnt FROM assets WHERE make != '' GROUP BY make ORDER BY cnt DESC"
|
|
).fetchall()
|
|
by_make = {r["make"]: r["cnt"] for r in makes}
|
|
|
|
conn.close()
|
|
return {
|
|
"total_assets": total_assets,
|
|
"total_checkins": total_checkins,
|
|
"total_users": total_users,
|
|
"total_customers": total_customers,
|
|
"total_locations": total_locations,
|
|
"by_category": by_category,
|
|
"by_status": by_status,
|
|
"top_visited": top_visited_list,
|
|
"time_on_site": time_on_site,
|
|
"by_make": by_make,
|
|
}
|
|
|
|
|
|
# ─── CSV Export API ─────────────────────────────────────────────────────────
|
|
|
|
|
|
_ASSET_CSV_FIELDS = [
|
|
"id", "machine_id", "serial_number", "name", "description", "category",
|
|
"status", "make", "model", "address", "building_name", "building_number",
|
|
"floor", "room", "trailer_number", "walking_directions", "map_link",
|
|
"parking_location", "photo_path", "customer_id", "location_id",
|
|
"assigned_to", "latitude", "longitude", "geofence_radius_meters",
|
|
"created_at", "updated_at",
|
|
]
|
|
|
|
_CHECKIN_CSV_FIELDS = [
|
|
"id", "asset_id", "user_id", "latitude", "longitude", "accuracy",
|
|
"photo_path", "notes", "created_at",
|
|
]
|
|
|
|
|
|
def _generate_csv(rows, fieldnames):
|
|
"""Yield CSV rows as a string generator for StreamingResponse."""
|
|
output = io.StringIO()
|
|
writer = csv.DictWriter(output, fieldnames=fieldnames)
|
|
writer.writeheader()
|
|
yield output.getvalue()
|
|
output.seek(0)
|
|
output.truncate(0)
|
|
for row in rows:
|
|
writer.writerow(row_to_dict(row))
|
|
yield output.getvalue()
|
|
output.seek(0)
|
|
output.truncate(0)
|
|
|
|
|
|
@app.get("/api/export/assets")
|
|
def export_assets_csv():
|
|
conn = get_db()
|
|
rows = conn.execute("SELECT * FROM assets ORDER BY created_at DESC").fetchall()
|
|
conn.close()
|
|
return StreamingResponse(
|
|
_generate_csv(rows, _ASSET_CSV_FIELDS),
|
|
media_type="text/csv",
|
|
headers={"Content-Disposition": "attachment; filename=assets.csv"},
|
|
)
|
|
|
|
|
|
@app.get("/api/export/checkins")
|
|
def export_checkins_csv(asset_id: Optional[int] = Query(None)):
|
|
conn = get_db()
|
|
if asset_id is not None:
|
|
rows = conn.execute(
|
|
"SELECT * FROM checkins WHERE asset_id = ? ORDER BY created_at DESC, id DESC",
|
|
(asset_id,),
|
|
).fetchall()
|
|
else:
|
|
rows = conn.execute("SELECT * FROM checkins ORDER BY created_at DESC").fetchall()
|
|
conn.close()
|
|
return StreamingResponse(
|
|
_generate_csv(rows, _CHECKIN_CSV_FIELDS),
|
|
media_type="text/csv",
|
|
headers={"Content-Disposition": "attachment; filename=checkins.csv"},
|
|
)
|
|
|
|
|
|
# ─── Asset GPS Management ──────────────────────────────────────────────────
|
|
|
|
|
|
@app.get("/api/assets")
|
|
def list_assets_admin(
|
|
q: Optional[str] = Query(None),
|
|
status: Optional[str] = Query(None),
|
|
category: Optional[str] = Query(None),
|
|
customer_id: Optional[int] = Query(None),
|
|
limit: int = Query(500, ge=1, le=5000),
|
|
offset: int = Query(0, ge=0),
|
|
):
|
|
"""List all assets with optional filters and search."""
|
|
conn = get_db()
|
|
conditions = []
|
|
params = []
|
|
|
|
if q:
|
|
conditions.append("(name LIKE ? OR machine_id LIKE ? OR company LIKE ? OR serial_number LIKE ?)")
|
|
like = f"%{q}%"
|
|
params.extend([like, like, like, like])
|
|
if status:
|
|
conditions.append("status = ?")
|
|
params.append(status)
|
|
if category:
|
|
conditions.append("category = ?")
|
|
params.append(category)
|
|
if customer_id is not None:
|
|
conditions.append("customer_id = ?")
|
|
params.append(customer_id)
|
|
|
|
where = " AND ".join(conditions)
|
|
sql = "SELECT * FROM assets"
|
|
count_sql = "SELECT COUNT(*) FROM assets"
|
|
if where:
|
|
sql += f" WHERE {where}"
|
|
count_sql += f" WHERE {where}"
|
|
|
|
total = conn.execute(count_sql, params).fetchone()[0]
|
|
sql += " ORDER BY name ASC LIMIT ? OFFSET ?"
|
|
rows = conn.execute(sql, params + [limit, offset]).fetchall()
|
|
conn.close()
|
|
|
|
result = [row_to_dict(r) for r in rows]
|
|
return JSONResponse({
|
|
"assets": result,
|
|
"total": total,
|
|
"limit": limit,
|
|
"offset": offset,
|
|
})
|
|
|
|
|
|
@app.put("/api/assets/{asset_id}")
|
|
def update_asset_admin(asset_id: int, body: dict):
|
|
"""Update any fields on an asset (admin). Body can include any asset column."""
|
|
conn = get_db()
|
|
existing = conn.execute("SELECT * FROM assets WHERE id = ?", (asset_id,)).fetchone()
|
|
if existing is None:
|
|
conn.close()
|
|
raise HTTPException(status_code=404, detail="Asset not found")
|
|
|
|
# Whitelist of editable fields
|
|
EDITABLE_FIELDS = {
|
|
"name", "machine_id", "serial_number", "description", "category", "status",
|
|
"make", "model", "address", "building_name", "building_number", "floor", "room",
|
|
"trailer_number", "walking_directions", "map_link", "parking_location",
|
|
"company", "place", "location_area",
|
|
"install_date", "dex_report_date", "pulled_date", "deployed",
|
|
"customer_id", "location_id", "assigned_to",
|
|
"latitude", "longitude", "geofence_radius_meters",
|
|
}
|
|
|
|
updates = {}
|
|
for field, val in body.items():
|
|
if field not in EDITABLE_FIELDS:
|
|
continue
|
|
if val is not None:
|
|
updates[field] = val
|
|
else:
|
|
updates[field] = None
|
|
|
|
if not updates:
|
|
conn.close()
|
|
raise HTTPException(status_code=422, detail="No valid fields to update")
|
|
|
|
if "category" in updates and updates["category"]:
|
|
row = conn.execute("SELECT id FROM categories WHERE name = ?", (updates["category"],)).fetchone()
|
|
if row is None:
|
|
conn.close()
|
|
raise HTTPException(status_code=422, detail=f"Invalid category '{updates['category']}'")
|
|
|
|
if "status" in updates and updates["status"]:
|
|
if updates["status"] not in VALID_STATUSES:
|
|
conn.close()
|
|
raise HTTPException(status_code=422, detail=f"Invalid status. Must be one of: {', '.join(sorted(VALID_STATUSES))}")
|
|
|
|
updates["updated_at"] = "datetime('now')"
|
|
set_clause = ", ".join(
|
|
f"{k} = {v}" if k == "updated_at" else f"{k} = ?"
|
|
for k, v in updates.items()
|
|
)
|
|
values = [v for k, v in updates.items() if k != "updated_at"]
|
|
conn.execute(
|
|
f"UPDATE assets SET {set_clause} WHERE id = ?",
|
|
values + [asset_id],
|
|
)
|
|
conn.commit()
|
|
|
|
row = conn.execute("SELECT * FROM assets WHERE id = ?", (asset_id,)).fetchone()
|
|
_log_activity(conn, "updated", "asset", asset_id,
|
|
f"Asset '{row['name']}' fields updated via admin")
|
|
conn.commit()
|
|
conn.close()
|
|
return row_to_dict(row)
|
|
|
|
|
|
@app.get("/api/assets/search")
|
|
def search_assets_admin(machine_id: str = Query(...)):
|
|
"""Search for an asset by machine_id (admin)."""
|
|
conn = get_db()
|
|
row = conn.execute(
|
|
"SELECT * FROM assets WHERE machine_id = ?", (machine_id,)
|
|
).fetchone()
|
|
conn.close()
|
|
if row is None:
|
|
raise HTTPException(status_code=404, detail="Asset not found")
|
|
return row_to_dict(row)
|
|
|
|
|
|
@app.get("/api/assets/{asset_id}")
|
|
def get_asset_admin(asset_id: int):
|
|
"""Get a single asset by ID (admin)."""
|
|
conn = get_db()
|
|
row = conn.execute("SELECT * FROM assets WHERE id = ?", (asset_id,)).fetchone()
|
|
conn.close()
|
|
if row is None:
|
|
raise HTTPException(status_code=404, detail="Asset not found")
|
|
return row_to_dict(row)
|
|
|
|
@app.delete("/api/assets/{asset_id}/gps", status_code=204)
|
|
def clear_asset_gps(asset_id: int):
|
|
"""Clear GPS coordinates from an asset (sets latitude/longitude to NULL)."""
|
|
conn = get_db()
|
|
existing = conn.execute("SELECT id, name FROM assets WHERE id = ?", (asset_id,)).fetchone()
|
|
if existing is None:
|
|
conn.close()
|
|
raise HTTPException(status_code=404, detail="Asset not found")
|
|
conn.execute(
|
|
"UPDATE assets SET latitude = NULL, longitude = NULL, updated_at = datetime('now') WHERE id = ?",
|
|
(asset_id,),
|
|
)
|
|
_log_activity(conn, "updated", "asset", asset_id,
|
|
f"GPS data cleared for '{existing['name']}'")
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
|
|
|
|
# ─── Batch Status Update API ─────────────────────────────────────────────────
|
|
|
|
|
|
class BatchStatusUpdate(BaseModel):
|
|
ids: list[int]
|
|
status: str
|
|
|
|
|
|
@app.post("/api/assets/batch-status")
|
|
def batch_update_status(body: BatchStatusUpdate):
|
|
"""Update the status of multiple assets at once."""
|
|
if not body.ids:
|
|
raise HTTPException(status_code=422, detail="No asset IDs provided")
|
|
if body.status not in VALID_STATUSES:
|
|
raise HTTPException(
|
|
status_code=422,
|
|
detail=f"Invalid status. Must be one of: {', '.join(sorted(VALID_STATUSES))}",
|
|
)
|
|
|
|
conn = get_db()
|
|
placeholders = ",".join(["?"] * len(body.ids))
|
|
|
|
# Verify all assets exist
|
|
existing = conn.execute(
|
|
f"SELECT id, name FROM assets WHERE id IN ({placeholders})", body.ids
|
|
).fetchall()
|
|
|
|
if len(existing) != len(body.ids):
|
|
conn.close()
|
|
raise HTTPException(status_code=404, detail="One or more asset IDs not found")
|
|
|
|
# Update all assets
|
|
conn.execute(
|
|
f"UPDATE assets SET status = ?, updated_at = datetime('now') WHERE id IN ({placeholders})",
|
|
[body.status] + body.ids,
|
|
)
|
|
_log_activity(conn, "updated", "asset", 0,
|
|
f"Batch status change: {len(body.ids)} assets set to '{body.status}'")
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
return {"updated": len(body.ids), "status": body.status}
|
|
|
|
|
|
# ─── Icon Upload API ────────────────────────────────────────────────────────
|
|
|
|
|
|
@app.post("/api/upload/icon", status_code=201)
|
|
async def upload_icon(file: UploadFile = File(...)):
|
|
path = _save_upload(file, "icons", ICON_ALLOWED_EXTS, ICON_MAX_SIZE)
|
|
return {"path": path}
|
|
|
|
|
|
# ─── Reset Database API ─────────────────────────────────────────────────────
|
|
|
|
|
|
@app.post("/api/reset-database")
|
|
def reset_database(request: Request):
|
|
"""Drop all data tables and recreate them. Requires admin role.
|
|
|
|
Must include X-Confirm-Reset: yes header to prevent accidental triggers."""
|
|
# Admin-only check
|
|
user = getattr(request.state, "current_user", None)
|
|
auth_skipped = os.environ.get("CANTEEN_SKIP_AUTH") == "1"
|
|
if not auth_skipped and (not user or not roles_contain(user.get("role", ""), "admin")):
|
|
raise HTTPException(status_code=403, detail="Only admins can reset the database")
|
|
|
|
# Confirmation header safety check
|
|
if request.headers.get("X-Confirm-Reset", "").lower() != "yes":
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Must include X-Confirm-Reset: yes header to confirm database reset"
|
|
)
|
|
|
|
conn = get_db()
|
|
try:
|
|
# Disable FK checks for clean drop
|
|
conn.execute("PRAGMA foreign_keys=OFF")
|
|
username = user.get("username", "Unknown") if user else "Unknown"
|
|
_log_activity(conn, "reset", "database", 0,
|
|
f"Database reset by '{username}'")
|
|
conn.commit()
|
|
tables = [
|
|
"visits", "activity_log", "geofence_users", "geofences",
|
|
"asset_badges", "asset_keys", "checkins", "sessions",
|
|
"models", "makes", "badge_types", "key_types", "key_names",
|
|
"categories", "rooms", "locations", "customer_contacts",
|
|
"customers", "assets", "users", "settings",
|
|
]
|
|
for t in tables:
|
|
conn.execute(f"DROP TABLE IF EXISTS {t}")
|
|
conn.execute("PRAGMA foreign_keys=ON")
|
|
conn.commit()
|
|
|
|
init_db(conn)
|
|
conn.commit()
|
|
except Exception as e:
|
|
conn.close()
|
|
raise HTTPException(status_code=500, detail=f"Reset failed: {str(e)}")
|
|
conn.close()
|
|
return {"detail": "Database reset complete — all tables recreated with seed data."}
|
|
|
|
|
|
# ─── Business Name Lookup (Google Maps via Playwright) ────────────────────
|
|
|
|
_playwright_browser = None
|
|
|
|
def _get_browser():
|
|
"""Lazy singleton Playwright browser (reuse across requests)."""
|
|
global _playwright_browser
|
|
if _playwright_browser is None or not _playwright_browser.is_connected():
|
|
from playwright.sync_api import sync_playwright
|
|
_pw = sync_playwright().start()
|
|
_playwright_browser = _pw.chromium.launch(channel='chrome', headless=True)
|
|
return _playwright_browser
|
|
|
|
|
|
def lookup_business_name(address: str) -> str:
|
|
"""Use headless Chrome + Google Maps to find the business at an address.
|
|
|
|
Returns the business name from the 'At this place' section, or empty string.
|
|
"""
|
|
if not address or not address.strip():
|
|
return ""
|
|
try:
|
|
browser = _get_browser()
|
|
context = browser.new_context(
|
|
user_agent=(
|
|
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
|
|
"(KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36"
|
|
),
|
|
locale="en-US",
|
|
)
|
|
page = context.new_page()
|
|
# Build the Google Maps search URL
|
|
q = address.strip().replace(" ", "+")
|
|
page.goto(f"https://www.google.com/maps/search/{q}", timeout=15000)
|
|
# Wait for the page to settle — the "At this place" section appears
|
|
page.wait_for_timeout(3000)
|
|
|
|
# Try to find business name via several selectors
|
|
business_name = ""
|
|
try:
|
|
# Method 1: article with aria-label in At this place section
|
|
articles = page.query_selector_all('div[role="article"][aria-label]')
|
|
for art in articles:
|
|
label = art.get_attribute("aria-label") or ""
|
|
label = label.strip()
|
|
if label and label != address.strip():
|
|
business_name = label
|
|
break
|
|
except Exception:
|
|
pass
|
|
|
|
if not business_name:
|
|
try:
|
|
# Method 2: look for the heading after "At this place"
|
|
el = page.query_selector('h2:has-text("At this place") + div [role="article"]')
|
|
if el:
|
|
business_name = (el.get_attribute("aria-label") or "")
|
|
except Exception:
|
|
pass
|
|
|
|
if not business_name:
|
|
try:
|
|
# Method 3: page title sometimes has business name
|
|
title = page.title()
|
|
if title and "Google Maps" not in title:
|
|
business_name = title.replace(" - Google Maps", "").strip()
|
|
except Exception:
|
|
pass
|
|
|
|
context.close()
|
|
return business_name.strip()
|
|
except Exception as e:
|
|
print(f"[lookup] Error looking up '{address}': {e}")
|
|
return ""
|
|
|
|
|
|
@app.post("/api/lookup-business")
|
|
def lookup_business(body: dict):
|
|
"""Look up business name at an address using Google Maps."""
|
|
address = (body.get("address") or "").strip()
|
|
if not address:
|
|
raise HTTPException(status_code=422, detail="address is required")
|
|
name = lookup_business_name(address)
|
|
return {"address": address, "business_name": name}
|
|
|
|
|
|
@app.post("/api/lookup-business/batch")
|
|
def lookup_business_batch(body: dict):
|
|
"""Batch lookup — takes assets: [{id, address}] and returns results.
|
|
|
|
Max 50 assets per call to keep response time reasonable.
|
|
"""
|
|
assets = body.get("assets", [])
|
|
if not assets:
|
|
raise HTTPException(status_code=422, detail="assets array is required")
|
|
# Limit to 50
|
|
assets = assets[:50]
|
|
results = []
|
|
total = len(assets)
|
|
for i, asset in enumerate(assets):
|
|
aid = asset.get("id")
|
|
addr = (asset.get("address") or "").strip()
|
|
if not addr:
|
|
results.append({"id": aid, "address": addr, "business_name": ""})
|
|
continue
|
|
name = lookup_business_name(addr)
|
|
results.append({"id": aid, "address": addr, "business_name": name})
|
|
return {"results": results, "total": total}
|
|
|
|
|
|
# ─── Machine Replacement History ───────────────────────────────────────────
|
|
|
|
|
|
@app.get("/api/admin/replacements/history")
|
|
def get_replacement_history():
|
|
"""Return historical machine replacement events.
|
|
|
|
Detects replacements by:
|
|
1) Parsing all sync batch diff_summaries for new_assets & removed_assets
|
|
that share similar location names (same batch or adjacent batches).
|
|
2) Cross-referencing assets grouped by location_id where one is active
|
|
("On") and another is inactive ("Incompatible", "Action Required"),
|
|
suggesting a swap.
|
|
"""
|
|
conn = get_db()
|
|
try:
|
|
db = conn # alias for clarity
|
|
|
|
# ── Method 1: Parse sync batch diff_summaries ──
|
|
batch_replacements = []
|
|
batches = db.execute(
|
|
"SELECT id, created_at, diff_summary, status "
|
|
"FROM cantaloupe_sync_batches ORDER BY id"
|
|
).fetchall()
|
|
|
|
# All new_assets ever seen, keyed by batch id
|
|
all_new_by_batch = {}
|
|
all_removed_by_batch = {}
|
|
for b in batches:
|
|
diff = b["diff_summary"]
|
|
if isinstance(diff, str):
|
|
try:
|
|
diff = _json.loads(diff)
|
|
except _json.JSONDecodeError:
|
|
continue
|
|
if not isinstance(diff, dict):
|
|
continue
|
|
bid = b["id"]
|
|
new_list = diff.get("new_assets", []) or []
|
|
rem_list = diff.get("removed_assets", []) or []
|
|
if new_list:
|
|
all_new_by_batch[bid] = new_list
|
|
if rem_list:
|
|
all_removed_by_batch[bid] = rem_list
|
|
|
|
# Also check inline replacements from diff
|
|
inline_reps = diff.get("replacements", []) or []
|
|
for rep in inline_reps:
|
|
batch_replacements.append({
|
|
"batch_id": bid,
|
|
"batch_date": b["created_at"],
|
|
"batch_status": b["status"],
|
|
"old_machine_id": rep.get("old_machine_id", ""),
|
|
"new_machine_id": rep.get("new_machine_id", ""),
|
|
"location": rep.get("location_address", ""),
|
|
"customer": rep.get("customer_name", ""),
|
|
"old_name": rep.get("old_asset_name", ""),
|
|
"new_name": rep.get("new_asset_name", ""),
|
|
"confidence": rep.get("confidence", "unknown"),
|
|
"source": "inline_sync_replacements",
|
|
})
|
|
|
|
# Cross-reference: new_assets in batch N vs removed_assets in batch N (or N-1)
|
|
batch_ids = sorted(set(list(all_new_by_batch.keys()) + list(all_removed_by_batch.keys())))
|
|
for bid in batch_ids:
|
|
new_items = all_new_by_batch.get(bid, [])
|
|
rem_items = all_removed_by_batch.get(bid, [])
|
|
|
|
# Also check previous batch for removals
|
|
prev_rem_items = all_removed_by_batch.get(bid - 1, [])
|
|
combined_rem = rem_items + prev_rem_items
|
|
|
|
if not new_items or not combined_rem:
|
|
continue
|
|
|
|
# Build location name -> machines mapping
|
|
new_by_loc = {}
|
|
for n in new_items:
|
|
loc = (n.get("location") or n.get("name", "")).strip().lower()
|
|
if loc:
|
|
new_by_loc.setdefault(loc, []).append(n)
|
|
|
|
rem_by_loc = {}
|
|
for r in combined_rem:
|
|
loc = (r.get("location") or r.get("name", "")).strip().lower()
|
|
if loc:
|
|
rem_by_loc.setdefault(loc, []).append(r)
|
|
|
|
# Try fuzzy match — share first 20+ chars
|
|
for nloc, new_list in new_by_loc.items():
|
|
for rloc, rem_list in rem_by_loc.items():
|
|
# Exact or prefix match
|
|
min_len = min(len(nloc), len(rloc))
|
|
if min_len < 10:
|
|
continue
|
|
match_ratio = sum(1 for a, b in zip(nloc, rloc) if a == b) / min_len
|
|
if match_ratio < 0.7:
|
|
continue
|
|
|
|
for new_item in new_list:
|
|
for rem_item in rem_list:
|
|
batch_replacements.append({
|
|
"batch_id": bid,
|
|
"batch_date": next(
|
|
(b["created_at"] for b in batches if b["id"] == bid), ""),
|
|
"batch_status": next(
|
|
(b["status"] for b in batches if b["id"] == bid), ""),
|
|
"old_machine_id": rem_item.get("machine_id", ""),
|
|
"new_machine_id": new_item.get("machine_id", ""),
|
|
"location": new_item.get("location") or new_item.get("name", ""),
|
|
"customer": new_item.get("customer", ""),
|
|
"old_name": rem_item.get("name", ""),
|
|
"new_name": new_item.get("name", ""),
|
|
"confidence": "medium" if match_ratio >= 0.85 else "low",
|
|
"source": "batch_diff_crossref",
|
|
})
|
|
|
|
# ── Method 2: Location-group based (live assets at same location_id) ──
|
|
# Only for small locations (2-5 machines) where it's likely a replacement,
|
|
# not a multi-machine site like a large warehouse/hotel.
|
|
loc_replacements = []
|
|
locations_with_multi = db.execute("""
|
|
SELECT location_id, COUNT(*) as cnt
|
|
FROM assets
|
|
WHERE location_id IS NOT NULL
|
|
GROUP BY location_id
|
|
HAVING cnt BETWEEN 2 AND 5
|
|
""").fetchall()
|
|
|
|
# Also get location names
|
|
loc_names = {}
|
|
loc_rows = db.execute("SELECT id, name FROM locations").fetchall()
|
|
for lr in loc_rows:
|
|
loc_names[lr["id"]] = lr["name"]
|
|
|
|
for loc_row in locations_with_multi:
|
|
lid = loc_row["location_id"]
|
|
assets_at_loc = db.execute("""
|
|
SELECT id, machine_id, name, status, created_at, updated_at,
|
|
install_date, pulled_date, deployed, building_name,
|
|
building_number, floor, room
|
|
FROM assets
|
|
WHERE location_id = ?
|
|
ORDER BY machine_id
|
|
""", (lid,)).fetchall()
|
|
|
|
# Find pairs where one is active ("On") and another is inactive
|
|
active = [a for a in assets_at_loc if a["status"] == "On"]
|
|
inactive = [a for a in assets_at_loc
|
|
if a["status"] in ("Incompatible", "Off", "Action Required", "retired")]
|
|
|
|
if not active or not inactive:
|
|
continue
|
|
|
|
# Only flag pairs where the building_name suggests the same physical spot
|
|
for act in active:
|
|
for inact in inactive:
|
|
# Check if they share building info (likely same spot)
|
|
same_building = (
|
|
act["building_name"] and inact["building_name"]
|
|
and act["building_name"].strip().lower() == inact["building_name"].strip().lower()
|
|
)
|
|
same_floor_room = (
|
|
act["floor"] and inact["floor"]
|
|
and act["floor"].strip() == inact["floor"].strip()
|
|
and act["room"] and inact["room"]
|
|
and act["room"].strip() == inact["room"].strip()
|
|
)
|
|
|
|
if not same_building and not same_floor_room:
|
|
continue
|
|
|
|
loc_name = loc_names.get(lid) or act["name"]
|
|
loc_replacements.append({
|
|
"batch_id": None,
|
|
"batch_date": None,
|
|
"batch_status": None,
|
|
"old_machine_id": inact["machine_id"],
|
|
"new_machine_id": act["machine_id"],
|
|
"location": loc_name,
|
|
"customer": "",
|
|
"old_name": inact["name"],
|
|
"new_name": act["name"],
|
|
"confidence": "low",
|
|
"source": "location_group",
|
|
"old_status": inact["status"],
|
|
"new_status": act["status"],
|
|
})
|
|
|
|
# ── Merge & sort ──
|
|
seen = set()
|
|
all_reps = []
|
|
|
|
for entry in batch_replacements + loc_replacements:
|
|
key = (entry["old_machine_id"], entry["new_machine_id"])
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
all_reps.append(entry)
|
|
|
|
# Sort: by batch_date descending, then by location
|
|
all_reps.sort(key=lambda x: (
|
|
x["batch_date"] or "9999-12-31",
|
|
x["location"] or "",
|
|
), reverse=True)
|
|
|
|
return {
|
|
"replacements": all_reps,
|
|
"total": len(all_reps),
|
|
"sources": {
|
|
"sync_batch_inline": len([r for r in all_reps if r["source"] == "inline_sync_replacements"]),
|
|
"batch_crossref": len([r for r in all_reps if r["source"] == "batch_diff_crossref"]),
|
|
"location_group": len([r for r in all_reps if r["source"] == "location_group"]),
|
|
},
|
|
}
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
# WORK ORDER REPORTS — reads extraction DB directly
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
EXTRACTION_PROJECT = Path.home() / "projects" / "ms-field-service-extraction"
|
|
EXTRACTION_DB = (
|
|
EXTRACTION_PROJECT
|
|
/ "data-samples"
|
|
/ "msfs_backup"
|
|
/ "fieldservicecanteen.crm.dynamics.com_9fc6c50c-b097-f011-b4cb-7ced8d1b15a2_data.db"
|
|
)
|
|
|
|
STATUS_LABELS_REPORT = {
|
|
"690970000": "Unscheduled",
|
|
"690970001": "Scheduled",
|
|
"690970002": "In Progress",
|
|
"690970003": "Completed",
|
|
"690970004": "Posted",
|
|
"690970005": "Cancelled",
|
|
"690959000": "Postponed",
|
|
}
|
|
|
|
|
|
def _get_extraction_db_report() -> sqlite3.Connection | None:
|
|
"""Return read-only connection to the MS Field Service extraction DB."""
|
|
if not EXTRACTION_DB.exists():
|
|
return None
|
|
conn = sqlite3.connect(str(EXTRACTION_DB))
|
|
conn.row_factory = sqlite3.Row
|
|
return conn
|
|
|
|
|
|
def _status_label_report(code) -> str:
|
|
return STATUS_LABELS_REPORT.get(str(code), f"Unknown ({code})")
|
|
|
|
|
|
@app.get("/api/admin/reports/workorder-kpis")
|
|
async def reports_workorder_kpis(
|
|
date_from: str = Query("", description="Start date ISO (YYYY-MM-DD)"),
|
|
date_to: str = Query("", description="End date ISO (YYYY-MM-DD)"),
|
|
technician: str = Query("", description="Filter by technician name"),
|
|
status: str = Query("", description="Filter by status code (comma-separated)"),
|
|
):
|
|
"""
|
|
Return aggregated KPI data for field tech work orders.
|
|
Filters: date_from, date_to (on createdon), technician, status.
|
|
"""
|
|
conn = _get_extraction_db_report()
|
|
if not conn:
|
|
raise HTTPException(503, "Extraction DB not available")
|
|
try:
|
|
cur = conn.cursor()
|
|
|
|
# ── Build WHERE clause ──
|
|
where_parts = []
|
|
params = []
|
|
|
|
if date_from:
|
|
where_parts.append("w.createdon >= ?")
|
|
params.append(date_from)
|
|
if date_to:
|
|
where_parts.append("w.createdon < date(?, '+1 day')")
|
|
params.append(date_to)
|
|
if technician:
|
|
tech_names = [t.strip() for t in technician.split(",") if t.strip()]
|
|
if tech_names:
|
|
tech_conditions = []
|
|
for name in tech_names:
|
|
like = f"%{name}%"
|
|
tech_conditions.append(
|
|
"(COALESCE(w.\"hsl_bookedresource1!name\",'') LIKE ? "
|
|
"OR COALESCE(w.\"hsl_bookedresource2!name\",'') LIKE ? "
|
|
"OR COALESCE(w.\"hsl_bookedresource3!name\",'') LIKE ? "
|
|
"OR EXISTS (SELECT 1 FROM bookableresourcebooking b2 WHERE b2.\"msdyn_workorder!name\" = w.msdyn_name AND b2.\"resource!name\" LIKE ?))"
|
|
)
|
|
params.extend([like, like, like, like])
|
|
where_parts.append("(" + " OR ".join(tech_conditions) + ")")
|
|
if status:
|
|
codes = [s.strip() for s in status.split(",") if s.strip()]
|
|
if codes:
|
|
placeholders = ", ".join(["?"] * len(codes))
|
|
where_parts.append(f"w.msdyn_systemstatus IN ({placeholders})")
|
|
params.extend(codes)
|
|
|
|
where_sql = " AND ".join(where_parts) if where_parts else "1=1"
|
|
|
|
# ── KPI 1: Total WOs ──
|
|
cur.execute(
|
|
f"SELECT COUNT(*) FROM msdyn_workorder w WHERE {where_sql}", params
|
|
)
|
|
total_workorders = cur.fetchone()[0]
|
|
|
|
# ── KPI 2: By Status ──
|
|
cur.execute(
|
|
f"""
|
|
SELECT w.msdyn_systemstatus, COUNT(*) as cnt
|
|
FROM msdyn_workorder w
|
|
WHERE {where_sql}
|
|
GROUP BY w.msdyn_systemstatus
|
|
ORDER BY cnt DESC
|
|
""",
|
|
params,
|
|
)
|
|
by_status_raw = cur.fetchall()
|
|
by_status = []
|
|
for r in by_status_raw:
|
|
code = str(r["msdyn_systemstatus"])
|
|
by_status.append({
|
|
"code": code,
|
|
"label": _status_label_report(code),
|
|
"count": r["cnt"],
|
|
})
|
|
|
|
# ── KPI 3: WOs Over Time (daily) ──
|
|
cur.execute(
|
|
f"""
|
|
SELECT date(w.createdon) as day, COUNT(*) as cnt
|
|
FROM msdyn_workorder w
|
|
WHERE {where_sql}
|
|
GROUP BY day
|
|
ORDER BY day
|
|
""",
|
|
params,
|
|
)
|
|
over_time = [{"day": r["day"], "count": r["cnt"]} for r in cur.fetchall()]
|
|
|
|
# ── KPI 4: By Technician ──
|
|
cur.execute(
|
|
f"""
|
|
SELECT t.technician, COUNT(*) as cnt
|
|
FROM (
|
|
SELECT COALESCE(w.\"hsl_bookedresource1!name\", w.\"hsl_bookedresource2!name\", w.\"hsl_bookedresource3!name\",
|
|
(SELECT b2.\"resource!name\" FROM bookableresourcebooking b2 WHERE b2.\"msdyn_workorder!name\" = w.msdyn_name AND b2.\"resource!name\" IS NOT NULL LIMIT 1)
|
|
) AS technician
|
|
FROM msdyn_workorder w
|
|
WHERE {where_sql}
|
|
) t
|
|
WHERE t.technician IS NOT NULL AND t.technician != ''
|
|
GROUP BY t.technician
|
|
ORDER BY cnt DESC
|
|
LIMIT 30
|
|
""",
|
|
params,
|
|
)
|
|
by_technician = [{"name": r["technician"], "count": r["cnt"]} for r in cur.fetchall()]
|
|
|
|
# ── KPI 5: By Work Type ──
|
|
cur.execute(
|
|
f"""
|
|
SELECT w.\"msdyn_workordertype!name\" AS work_type, COUNT(*) as cnt
|
|
FROM msdyn_workorder w
|
|
WHERE {where_sql}
|
|
GROUP BY work_type
|
|
ORDER BY cnt DESC
|
|
""",
|
|
params,
|
|
)
|
|
by_work_type = [{"name": r["work_type"] or "Unspecified", "count": r["cnt"]} for r in cur.fetchall()]
|
|
|
|
# ── KPI 6: By Priority ──
|
|
cur.execute(
|
|
f"""
|
|
SELECT w.\"msdyn_priority!name\" AS priority, COUNT(*) as cnt
|
|
FROM msdyn_workorder w
|
|
WHERE {where_sql}
|
|
GROUP BY priority
|
|
ORDER BY cnt DESC
|
|
""",
|
|
params,
|
|
)
|
|
by_priority = [{"name": r["priority"] or "Unspecified", "count": r["cnt"]} for r in cur.fetchall()]
|
|
|
|
# ── KPI 7: Technician Performance (detailed) — use simpler subquery ──
|
|
cur.execute(
|
|
f"""
|
|
SELECT
|
|
t.technician,
|
|
COUNT(*) as total,
|
|
SUM(CASE WHEN t.msdyn_systemstatus IN (690970003, 690970004) THEN 1 ELSE 0 END) as completed,
|
|
SUM(CASE WHEN t.msdyn_systemstatus = 690970002 THEN 1 ELSE 0 END) as in_progress,
|
|
SUM(CASE WHEN t.msdyn_systemstatus = 690970001 THEN 1 ELSE 0 END) as scheduled
|
|
FROM (
|
|
SELECT
|
|
COALESCE(w.\"hsl_bookedresource1!name\", w.\"hsl_bookedresource2!name\", w.\"hsl_bookedresource3!name\",
|
|
(SELECT b2.\"resource!name\" FROM bookableresourcebooking b2 WHERE b2.\"msdyn_workorder!name\" = w.msdyn_name AND b2.\"resource!name\" IS NOT NULL LIMIT 1)
|
|
) AS technician,
|
|
w.msdyn_systemstatus
|
|
FROM msdyn_workorder w
|
|
WHERE {where_sql}
|
|
) t
|
|
WHERE t.technician IS NOT NULL AND t.technician != ''
|
|
GROUP BY t.technician
|
|
ORDER BY total DESC
|
|
LIMIT 30
|
|
""",
|
|
params,
|
|
)
|
|
tech_perf = []
|
|
for r in cur.fetchall():
|
|
tech_perf.append({
|
|
"name": r["technician"],
|
|
"total": r["total"],
|
|
"completed": r["completed"],
|
|
"in_progress": r["in_progress"],
|
|
"scheduled": r["scheduled"],
|
|
})
|
|
|
|
# ── KPI 8: By Status per Tech (stacked) ──
|
|
cur.execute(
|
|
f"""
|
|
SELECT
|
|
t.technician,
|
|
t.status_code,
|
|
COUNT(*) as cnt
|
|
FROM (
|
|
SELECT
|
|
COALESCE(w.\"hsl_bookedresource1!name\", w.\"hsl_bookedresource2!name\", w.\"hsl_bookedresource3!name\",
|
|
(SELECT b2.\"resource!name\" FROM bookableresourcebooking b2 WHERE b2.\"msdyn_workorder!name\" = w.msdyn_name AND b2.\"resource!name\" IS NOT NULL LIMIT 1)
|
|
) AS technician,
|
|
w.msdyn_systemstatus AS status_code
|
|
FROM msdyn_workorder w
|
|
WHERE {where_sql}
|
|
) t
|
|
WHERE t.technician IS NOT NULL AND t.technician != ''
|
|
GROUP BY t.technician, t.status_code
|
|
ORDER BY t.technician, t.status_code
|
|
""",
|
|
params,
|
|
)
|
|
status_by_tech = {}
|
|
for r in cur.fetchall():
|
|
tech = r["technician"]
|
|
if tech not in status_by_tech:
|
|
status_by_tech[tech] = {}
|
|
code = str(r["status_code"])
|
|
status_by_tech[tech][code] = r["cnt"]
|
|
|
|
# ── KPI 9: Summary stats ──
|
|
cur.execute(
|
|
f"""
|
|
SELECT
|
|
COUNT(*) as total,
|
|
SUM(CASE WHEN w.msdyn_systemstatus IN (690970003, 690970004) THEN 1 ELSE 0 END) as completed_posted,
|
|
SUM(CASE WHEN w.msdyn_systemstatus = 690970002 THEN 1 ELSE 0 END) as in_progress,
|
|
SUM(CASE WHEN w.msdyn_systemstatus = 690970001 THEN 1 ELSE 0 END) as scheduled,
|
|
COUNT(DISTINCT COALESCE(w.\"hsl_bookedresource1!name\", w.\"hsl_bookedresource2!name\", w.\"hsl_bookedresource3!name\",
|
|
(SELECT b2.\"resource!name\" FROM bookableresourcebooking b2 WHERE b2.\"msdyn_workorder!name\" = w.msdyn_name AND b2.\"resource!name\" IS NOT NULL LIMIT 1)
|
|
)) as unique_techs
|
|
FROM msdyn_workorder w
|
|
WHERE {where_sql}
|
|
""",
|
|
params,
|
|
)
|
|
summary = dict(cur.fetchone())
|
|
|
|
# ── KPI 10: Avg days to complete (for completed/posted) ──
|
|
cur.execute(
|
|
f"""
|
|
SELECT AVG(julianday(w.msdyn_completedon) - julianday(w.createdon)) as avg_days
|
|
FROM msdyn_workorder w
|
|
WHERE {where_sql}
|
|
AND w.msdyn_systemstatus IN (690970003, 690970004)
|
|
AND w.msdyn_completedon IS NOT NULL
|
|
""",
|
|
params,
|
|
)
|
|
avg_days = cur.fetchone()[0]
|
|
summary["avg_days_to_complete"] = round(avg_days, 1) if avg_days else None
|
|
|
|
return {
|
|
"total_workorders": total_workorders,
|
|
"summary": summary,
|
|
"by_status": by_status,
|
|
"by_technician": by_technician,
|
|
"by_work_type": by_work_type,
|
|
"by_priority": by_priority,
|
|
"over_time": over_time,
|
|
"technician_performance": tech_perf,
|
|
"status_by_tech": status_by_tech,
|
|
}
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
# WORK ORDERS LIST — reads extraction DB directly (same DB as reports)
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
@app.get("/api/admin/workorders/list")
|
|
async def admin_workorders_list(
|
|
q: str = Query("", description="Search term (WO#, account, city)"),
|
|
status: str = Query("", description="Comma-separated status codes"),
|
|
tech: str = Query("", description="Technician name filter"),
|
|
date_from: str = Query("", description="Date filter start (YYYY-MM-DD)"),
|
|
date_to: str = Query("", description="Date filter end (YYYY-MM-DD)"),
|
|
limit: int = Query(50, ge=1, le=500),
|
|
offset: int = Query(0, ge=0),
|
|
):
|
|
"""Fetch filtered, paginated work orders from the extraction DB."""
|
|
conn = _get_extraction_db_report()
|
|
if not conn:
|
|
raise HTTPException(503, "Extraction DB not available")
|
|
try:
|
|
cur = conn.cursor()
|
|
params: list = []
|
|
where_clauses: list[str] = []
|
|
|
|
if q:
|
|
like = f"%{q}%"
|
|
where_clauses.append(
|
|
"(w.msdyn_name LIKE ? OR a.name LIKE ? OR a.address1_city LIKE ? OR w.\"msdyn_customerasset!name\" LIKE ? OR w.\"msdyn_serviceaccount!name\" LIKE ?)"
|
|
)
|
|
params.extend([like, like, like, like, like])
|
|
|
|
if status:
|
|
codes = [s.strip() for s in status.split(",") if s.strip()]
|
|
if codes:
|
|
placeholders = ",".join(["?" for _ in codes])
|
|
where_clauses.append(f"w.msdyn_systemstatus IN ({placeholders})")
|
|
params.extend(codes)
|
|
|
|
if tech:
|
|
like = f"%{tech}%"
|
|
where_clauses.append(
|
|
"(COALESCE(w.\"hsl_bookedresource1!name\",'') LIKE ? "
|
|
"OR COALESCE(w.\"hsl_bookedresource2!name\",'') LIKE ? "
|
|
"OR COALESCE(w.\"hsl_bookedresource3!name\",'') LIKE ? "
|
|
"OR EXISTS (SELECT 1 FROM bookableresourcebooking b2 WHERE b2.\"msdyn_workorder!name\" = w.msdyn_name AND b2.\"resource!name\" LIKE ?))"
|
|
)
|
|
params.extend([like, like, like, like])
|
|
|
|
if date_from:
|
|
where_clauses.append("w.createdon >= ?")
|
|
params.append(date_from)
|
|
if date_to:
|
|
where_clauses.append("w.createdon < date(?, '+1 day')")
|
|
params.append(date_to)
|
|
|
|
where_sql = " AND ".join(where_clauses) if where_clauses else "1=1"
|
|
|
|
# Count
|
|
cur.execute(
|
|
f"SELECT COUNT(*) FROM msdyn_workorder w LEFT JOIN account a ON w.\"msdyn_serviceaccount!id\" = a.accountid WHERE {where_sql}",
|
|
params,
|
|
)
|
|
total = cur.fetchone()[0]
|
|
|
|
# Data
|
|
data_sql = f"""
|
|
SELECT
|
|
w.msdyn_workorderid,
|
|
w.msdyn_name,
|
|
w."msdyn_serviceaccount!name" AS account_name,
|
|
w.msdyn_workordersummary,
|
|
w.createdon,
|
|
w.msdyn_completedon,
|
|
w.msdyn_systemstatus,
|
|
w."msdyn_priority!name" AS priority,
|
|
w."msdyn_workordertype!name" AS work_type,
|
|
w."msdyn_primaryincidenttype!name" AS incident_type,
|
|
w."msdyn_serviceterritory!name" AS territory,
|
|
w."msdyn_customerasset!name" AS customer_asset_name,
|
|
w.msdyn_city,
|
|
w.msdyn_stateorprovince,
|
|
(SELECT GROUP_CONCAT(DISTINCT t)
|
|
FROM (
|
|
SELECT b2."resource!name" AS t FROM bookableresourcebooking b2 WHERE b2."msdyn_workorder!name" = w.msdyn_name
|
|
UNION
|
|
SELECT w2."hsl_bookedresource1!name" FROM msdyn_workorder w2 WHERE w2.msdyn_workorderid = w.msdyn_workorderid AND w2."hsl_bookedresource1!name" IS NOT NULL
|
|
UNION
|
|
SELECT w2."hsl_bookedresource2!name" FROM msdyn_workorder w2 WHERE w2.msdyn_workorderid = w.msdyn_workorderid AND w2."hsl_bookedresource2!name" IS NOT NULL
|
|
UNION
|
|
SELECT w2."hsl_bookedresource3!name" FROM msdyn_workorder w2 WHERE w2.msdyn_workorderid = w.msdyn_workorderid AND w2."hsl_bookedresource3!name" IS NOT NULL
|
|
)) AS technicians,
|
|
a.name AS account_display_name,
|
|
a.address1_city,
|
|
a.address1_stateorprovince
|
|
FROM msdyn_workorder w
|
|
LEFT JOIN account a ON w."msdyn_serviceaccount!id" = a.accountid
|
|
WHERE {where_sql}
|
|
ORDER BY w.createdon DESC, w.msdyn_name
|
|
LIMIT ? OFFSET ?
|
|
"""
|
|
data_params = params + [limit, offset]
|
|
cur.execute(data_sql, data_params)
|
|
rows = cur.fetchall()
|
|
|
|
workorders = []
|
|
for r in rows:
|
|
workorders.append({
|
|
"id": r["msdyn_workorderid"],
|
|
"name": r["msdyn_name"],
|
|
"account_name": r["account_name"],
|
|
"account_display_name": r["account_display_name"],
|
|
"summary": r["msdyn_workordersummary"],
|
|
"status_code": str(r["msdyn_systemstatus"]),
|
|
"status": _status_label_report(r["msdyn_systemstatus"]),
|
|
"priority": r["priority"],
|
|
"work_type": r["work_type"],
|
|
"incident_type": r["incident_type"],
|
|
"territory": r["territory"],
|
|
"customer_asset_name": r["customer_asset_name"],
|
|
"technicians": r["technicians"],
|
|
"created_on": r["createdon"],
|
|
"completed_on": r["msdyn_completedon"],
|
|
"city": r["msdyn_city"] or r["address1_city"],
|
|
"state": r["msdyn_stateorprovince"] or r["address1_stateorprovince"],
|
|
})
|
|
|
|
return {
|
|
"results": workorders,
|
|
"total": total,
|
|
"offset": offset,
|
|
"limit": limit,
|
|
}
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@app.get("/api/admin/workorders/lookup")
|
|
async def admin_workorders_lookup(name: str = Query("", description="Work order name/number")):
|
|
"""Return full details for a single work order by name."""
|
|
if not name:
|
|
raise HTTPException(400, "Work order name is required")
|
|
conn = _get_extraction_db_report()
|
|
if not conn:
|
|
raise HTTPException(503, "Extraction DB not available")
|
|
try:
|
|
cur = conn.cursor()
|
|
cur.execute("""
|
|
SELECT
|
|
w.*,
|
|
a.name AS account_display_name,
|
|
a.address1_line1,
|
|
a.address1_city,
|
|
a.address1_stateorprovince,
|
|
a.address1_postalcode,
|
|
a.telephone1,
|
|
a.websiteurl,
|
|
i.title AS incident_title,
|
|
i.msdyn_servicerequesttype AS incident_type_code,
|
|
(SELECT GROUP_CONCAT(DISTINCT t)
|
|
FROM (
|
|
SELECT b2."resource!name" AS t FROM bookableresourcebooking b2 WHERE b2."msdyn_workorder!name" = w.msdyn_name
|
|
UNION
|
|
SELECT w2."hsl_bookedresource1!name" FROM msdyn_workorder w2 WHERE w2.msdyn_workorderid = w.msdyn_workorderid AND w2."hsl_bookedresource1!name" IS NOT NULL
|
|
UNION
|
|
SELECT w2."hsl_bookedresource2!name" FROM msdyn_workorder w2 WHERE w2.msdyn_workorderid = w.msdyn_workorderid AND w2."hsl_bookedresource2!name" IS NOT NULL
|
|
UNION
|
|
SELECT w2."hsl_bookedresource3!name" FROM msdyn_workorder w2 WHERE w2.msdyn_workorderid = w.msdyn_workorderid AND w2."hsl_bookedresource3!name" IS NOT NULL
|
|
)) AS technicians
|
|
FROM msdyn_workorder w
|
|
LEFT JOIN account a ON w."msdyn_serviceaccount!id" = a.accountid
|
|
LEFT JOIN msdyn_workorderincident woi ON woi."msdyn_workorder!id" = w.msdyn_workorderid
|
|
LEFT JOIN incident i ON i.incidentid = woi."msdyn_incident!id"
|
|
WHERE w.msdyn_name = ?
|
|
LIMIT 1
|
|
""", [name])
|
|
row = cur.fetchone()
|
|
if not row:
|
|
raise HTTPException(404, f"Work order '{name}' not found")
|
|
|
|
# Map row to dict (all columns)
|
|
keys = [d[0] for d in cur.description]
|
|
data = dict(zip(keys, row))
|
|
|
|
# Add readable status
|
|
data["status_label"] = _status_label_report(data.get("msdyn_systemstatus"))
|
|
|
|
return {"found": True, "data": data}
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@app.get("/api/admin/workorders/technicians")
|
|
async def admin_workorders_technicians():
|
|
"""Return list of all technicians found in work orders."""
|
|
conn = _get_extraction_db_report()
|
|
if not conn:
|
|
raise HTTPException(503, "Extraction DB not available")
|
|
try:
|
|
cur = conn.cursor()
|
|
cur.execute("""
|
|
SELECT DISTINCT t FROM (
|
|
SELECT b."resource!name" AS t FROM bookableresourcebooking b WHERE b."resource!name" IS NOT NULL AND b."resource!name" != ''
|
|
UNION
|
|
SELECT w."hsl_bookedresource1!name" FROM msdyn_workorder w WHERE w."hsl_bookedresource1!name" IS NOT NULL AND w."hsl_bookedresource1!name" != ''
|
|
UNION
|
|
SELECT w."hsl_bookedresource2!name" FROM msdyn_workorder w WHERE w."hsl_bookedresource2!name" IS NOT NULL AND w."hsl_bookedresource2!name" != ''
|
|
UNION
|
|
SELECT w."hsl_bookedresource3!name" FROM msdyn_workorder w WHERE w."hsl_bookedresource3!name" IS NOT NULL AND w."hsl_bookedresource3!name" != ''
|
|
) ORDER BY t
|
|
""")
|
|
techs = [r[0] for r in cur.fetchall()]
|
|
return {"technicians": techs}
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@app.get("/api/admin/workorders/by-asset")
|
|
async def admin_workorders_by_asset(
|
|
name: str = Query("", description="Asset name (msdyn_customerasset!name) to search work orders for"),
|
|
):
|
|
"""Return work orders linked to a specific customer asset by name."""
|
|
if not name:
|
|
raise HTTPException(400, "Asset name is required")
|
|
conn = _get_extraction_db_report()
|
|
if not conn:
|
|
raise HTTPException(503, "Extraction DB not available")
|
|
try:
|
|
cur = conn.cursor()
|
|
cur.execute("""
|
|
SELECT
|
|
w.msdyn_name, w."msdyn_customerasset!name" AS asset_name,
|
|
w."msdyn_serviceaccount!name" AS account_name,
|
|
w.msdyn_workordersummary,
|
|
w."msdyn_workordertype!name" AS work_type,
|
|
w."msdyn_priority!name" AS priority,
|
|
w.msdyn_systemstatus,
|
|
w.createdon, w.msdyn_completedon,
|
|
a.name AS account_display_name,
|
|
a.address1_city, a.address1_stateorprovince
|
|
FROM msdyn_workorder w
|
|
LEFT JOIN account a ON w."msdyn_serviceaccount!id" = a.accountid
|
|
WHERE w."msdyn_customerasset!name" = ?
|
|
ORDER BY w.createdon DESC
|
|
""", [name])
|
|
rows = cur.fetchall()
|
|
workorders = []
|
|
for r in rows:
|
|
workorders.append({
|
|
"name": r["msdyn_name"],
|
|
"asset_name": r["asset_name"],
|
|
"account_name": r["account_name"] or r["account_display_name"],
|
|
"summary": r["msdyn_workordersummary"],
|
|
"work_type": r["work_type"],
|
|
"priority": r["priority"],
|
|
"status_code": str(r["msdyn_systemstatus"]),
|
|
"status": _status_label_report(r["msdyn_systemstatus"]),
|
|
"created_on": r["createdon"],
|
|
"completed_on": r["msdyn_completedon"],
|
|
"city": r["address1_city"],
|
|
"state": r["address1_stateorprovince"],
|
|
})
|
|
return {"results": workorders, "total": len(workorders)}
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
# ADB MSFS SYNC — proxies to the extraction viewer's sync API on port 8910
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
EXTRACTION_SYNC_BASE = "http://localhost:8910"
|
|
|
|
|
|
@app.get("/api/admin/msfs-sync/info")
|
|
async def admin_msfs_sync_info():
|
|
"""Return ADB connection status, last sync metadata, and DB info."""
|
|
try:
|
|
async with httpx.AsyncClient(timeout=10) as client:
|
|
resp = await client.get(f"{EXTRACTION_SYNC_BASE}/api/sync/info")
|
|
return resp.json()
|
|
except httpx.RequestError as e:
|
|
return {
|
|
"adb_connected": False,
|
|
"extraction_server": False,
|
|
"error": f"Cannot reach extraction server on port 8910: {e}",
|
|
}
|
|
|
|
|
|
@app.post("/api/admin/msfs-sync/start")
|
|
async def admin_msfs_sync_start():
|
|
"""Start a background ADB sync task."""
|
|
try:
|
|
async with httpx.AsyncClient(timeout=30) as client:
|
|
resp = await client.post(f"{EXTRACTION_SYNC_BASE}/api/sync/start")
|
|
return resp.json()
|
|
except httpx.RequestError as e:
|
|
raise HTTPException(502, f"Extraction server unreachable: {e}")
|
|
|
|
|
|
@app.get("/api/admin/msfs-sync/task/{task_id}")
|
|
async def admin_msfs_sync_task(task_id: str):
|
|
"""Poll status of a running sync task."""
|
|
try:
|
|
async with httpx.AsyncClient(timeout=10) as client:
|
|
resp = await client.get(f"{EXTRACTION_SYNC_BASE}/api/sync/task/{task_id}")
|
|
if resp.status_code == 404:
|
|
raise HTTPException(404, "Task not found")
|
|
return resp.json()
|
|
except httpx.RequestError as e:
|
|
raise HTTPException(502, f"Extraction server unreachable: {e}")
|
|
|
|
|
|
@app.post("/api/admin/msfs-sync/launch-app")
|
|
async def admin_msfs_sync_launch():
|
|
"""Launch MS Field Service app, navigate to Check for Updates via UI nav.
|
|
|
|
Taps globe icon in top-right, waits 15s, taps Check for Updates button,
|
|
then waits for the phone's offline DB to stabilize.
|
|
"""
|
|
try:
|
|
async with httpx.AsyncClient(timeout=30) as client:
|
|
resp = await client.post(f"{EXTRACTION_SYNC_BASE}/api/sync/check-for-updates")
|
|
return resp.json()
|
|
except httpx.RequestError as e:
|
|
raise HTTPException(502, f"Extraction server unreachable: {e}")
|
|
|
|
|
|
@app.post("/api/admin/msfs-sync/cancel/{task_id}")
|
|
async def admin_msfs_sync_cancel(task_id: str):
|
|
"""Cancel a running sync task."""
|
|
try:
|
|
async with httpx.AsyncClient(timeout=10) as client:
|
|
resp = await client.post(f"{EXTRACTION_SYNC_BASE}/api/sync/cancel/{task_id}")
|
|
return resp.json()
|
|
except httpx.RequestError as e:
|
|
raise HTTPException(502, f"Extraction server unreachable: {e}")
|
|
|
|
|
|
# ─── SPA Frontend (catch-all — MUST be last route) ────────────────────────
|
|
|
|
FRONTEND_INDEX = Path(__file__).parent / "static" / "index.html"
|
|
|
|
|
|
@app.get("/")
|
|
@app.get("/{full_path:path}")
|
|
async def serve_frontend(full_path: str = ""):
|
|
"""Serve the admin SPA — all routes return index.html."""
|
|
if FRONTEND_INDEX.is_file():
|
|
return FileResponse(str(FRONTEND_INDEX))
|
|
return JSONResponse({"detail": "Frontend not found"}, status_code=404)
|