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:
+5
-2
@@ -1,4 +1,7 @@
|
|||||||
|
.venv/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
assets.db
|
*.db
|
||||||
uploads/
|
*.db.real
|
||||||
|
*.xlsx
|
||||||
|
.env
|
||||||
|
|||||||
+250
-16
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+1143
-16
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user