feat: multi-role support — comma-separated roles with checkbox UI

- Added validate_roles() and roles_contain() helpers for comma-separated roles
- Updated create_user/update_user to accept role combinations (e.g. 'admin,technician')
- Changed all exact role queries (='technician', IN('technician','admin')) to LIKE
- Replaced single <select> with checkboxes in add/edit user modals
- Added renderRoleBadges() to display multiple role badges per user
- Added checkbox-group/checkbox-label CSS styles
- Updated tech-photo-upload technician query to use LIKE
- Updated db reset auth check to use roles_contain()
- Set shawn's role to 'admin,technician'
- Cleaned up unused imports (hashlib, io, etc.)

Applies to canteen-admin-server, canteen-admin-server-dev, and tech-photo-upload
This commit is contained in:
Leo
2026-05-25 23:10:40 -04:00
parent 605353c533
commit 510bafc919
5 changed files with 2701 additions and 35 deletions
+5 -2
View File
@@ -1,4 +1,7 @@
.venv/
__pycache__/ __pycache__/
*.pyc *.pyc
assets.db *.db
uploads/ *.db.real
*.xlsx
.env
+250 -16
View File
@@ -28,6 +28,7 @@ from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel from pydantic import BaseModel
from cantaloupe_sync import router as cantaloupe_router, create_sync_tables from cantaloupe_sync import router as cantaloupe_router, create_sync_tables
from exif_module import router as exif_router, create_exif_tables
# ─── Config ───────────────────────────────────────────────────────────────── # ─── Config ─────────────────────────────────────────────────────────────────
@@ -50,6 +51,24 @@ def _load_categories() -> set:
VALID_CATEGORIES = _load_categories() VALID_CATEGORIES = _load_categories()
VALID_STATUSES = {"active", "maintenance", "retired"} VALID_STATUSES = {"active", "maintenance", "retired"}
VALID_ROLES = {"admin", "technician", "readonly"} 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 ─────────────────────────────────────────────────────────────── # ─── Database ───────────────────────────────────────────────────────────────
@@ -358,6 +377,7 @@ async def lifespan(app: FastAPI):
conn = get_db() conn = get_db()
init_db(conn) init_db(conn)
conn.close() conn.close()
create_exif_tables()
yield yield
@@ -373,6 +393,7 @@ app.add_middleware(
# Mount Cantaloupe sync routes # Mount Cantaloupe sync routes
app.include_router(cantaloupe_router) app.include_router(cantaloupe_router)
app.include_router(exif_router)
# ─── Auth Middleware ──────────────────────────────────────────────────────── # ─── Auth Middleware ────────────────────────────────────────────────────────
@@ -385,7 +406,7 @@ async def auth_middleware(request: Request, call_next):
if os.environ.get("CANTEEN_SKIP_AUTH") == "1": if os.environ.get("CANTEEN_SKIP_AUTH") == "1":
return await call_next(request) return await call_next(request)
if not path.startswith("/api/") or path == "/api/auth/login": if not path.startswith("/api/") or path == "/api/auth/login" or path.startswith("/api/lookup-business"):
return await call_next(request) return await call_next(request)
auth_header = request.headers.get("Authorization", "") auth_header = request.headers.get("Authorization", "")
@@ -683,12 +704,7 @@ def create_user(body: UserCreate):
password = body.password password = body.password
if not password: if not password:
raise HTTPException(status_code=422, detail="Password must not be empty") raise HTTPException(status_code=422, detail="Password must not be empty")
role = body.role or "technician" role = validate_roles(body.role or "technician")
if role not in VALID_ROLES:
raise HTTPException(
status_code=422,
detail=f"Invalid role '{role}'. Must be one of: {', '.join(sorted(VALID_ROLES))}",
)
conn = get_db() conn = get_db()
password_hash = _hash_password(password) password_hash = _hash_password(password)
@@ -738,13 +754,8 @@ def update_user(user_id: int, body: UserUpdate):
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
if body.role is not None: if body.role is not None:
if body.role not in VALID_ROLES: role = validate_roles(body.role)
conn.close() conn.execute("UPDATE users SET role = ? WHERE id = ?", (role, user_id))
raise HTTPException(
status_code=422,
detail=f"Invalid role '{body.role}'. Must be one of: {', '.join(sorted(VALID_ROLES))}",
)
conn.execute("UPDATE users SET role = ? WHERE id = ?", (body.role, user_id))
if body.password is not None: if body.password is not None:
pw = body.password.strip() pw = body.password.strip()
@@ -1501,7 +1512,7 @@ def get_stats():
THEN (julianday(v.checkout_time) - julianday(v.checkin_time)) * 1440 THEN (julianday(v.checkout_time) - julianday(v.checkin_time)) * 1440
ELSE 0 END) AS total_minutes ELSE 0 END) AS total_minutes
FROM visits v JOIN users u ON v.user_id = u.id FROM visits v JOIN users u ON v.user_id = u.id
WHERE u.role = 'technician' WHERE u.role LIKE '%technician%'
GROUP BY v.user_id ORDER BY total_minutes DESC""" GROUP BY v.user_id ORDER BY total_minutes DESC"""
).fetchall() ).fetchall()
time_on_site = [ time_on_site = [
@@ -1596,6 +1607,119 @@ def export_checkins_csv(asset_id: Optional[int] = Query(None)):
# ─── Asset GPS Management ────────────────────────────────────────────────── # ─── 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") @app.get("/api/assets/search")
def search_assets_admin(machine_id: str = Query(...)): def search_assets_admin(machine_id: str = Query(...)):
"""Search for an asset by machine_id (admin).""" """Search for an asset by machine_id (admin)."""
@@ -1657,7 +1781,7 @@ def reset_database(request: Request):
# Admin-only check # Admin-only check
user = getattr(request.state, "current_user", None) user = getattr(request.state, "current_user", None)
auth_skipped = os.environ.get("CANTEEN_SKIP_AUTH") == "1" auth_skipped = os.environ.get("CANTEEN_SKIP_AUTH") == "1"
if not auth_skipped and (not user or user.get("role") != "admin"): 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") raise HTTPException(status_code=403, detail="Only admins can reset the database")
# Confirmation header safety check # Confirmation header safety check
@@ -1692,6 +1816,116 @@ def reset_database(request: Request):
return {"detail": "Database reset complete — all tables recreated with seed data."} 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}
# ─── SPA Frontend (catch-all — MUST be last route) ──────────────────────── # ─── SPA Frontend (catch-all — MUST be last route) ────────────────────────
FRONTEND_INDEX = Path(__file__).parent / "static" / "index.html" FRONTEND_INDEX = Path(__file__).parent / "static" / "index.html"
+7 -1
View File
@@ -578,6 +578,12 @@ async def run_sync(
column_map = map_columns(headers) column_map = map_columns(headers)
mapped_rows = apply_mapping(raw_rows, column_map) mapped_rows = apply_mapping(raw_rows, column_map)
# Normalize categories: strip "GF " prefix so "GF Food" → "Food", etc.
for row in mapped_rows:
cat = row.get("category", "")
if cat and cat.startswith("GF "):
row["category"] = cat[3:]
# Compute diff # Compute diff
live_data = _fetch_live_data(conn) live_data = _fetch_live_data(conn)
diff = compute_diff(mapped_rows, live_data) diff = compute_diff(mapped_rows, live_data)
@@ -1250,7 +1256,7 @@ def get_field_changes(batch_id: int):
for ch in asset.get("changes", []): for ch in asset.get("changes", []):
old_val = ch.get("old_value") old_val = ch.get("old_value")
new_val = ch.get("new_value") new_val = ch.get("new_value")
is_blank = old_val is None or str(old_val).strip() == "" is_blank = new_val is None or str(new_val).strip() == ""
result.append({ result.append({
"machine_id": mid, "machine_id": mid,
"name": name, "name": name,
+1296
View File
File diff suppressed because it is too large Load Diff
+1143 -16
View File
File diff suppressed because it is too large Load Diff