Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4988fdaa21 | |||
| ae6685a401 | |||
| fb2db1e76a | |||
| d58834bc56 | |||
| 750cde68c9 |
@@ -2,3 +2,8 @@ __pycache__/
|
|||||||
*.pyc
|
*.pyc
|
||||||
assets.db
|
assets.db
|
||||||
uploads/
|
uploads/
|
||||||
|
.venv/
|
||||||
|
*.db
|
||||||
|
*.xlsx
|
||||||
|
admin.db
|
||||||
|
canteen_admin.db
|
||||||
|
|||||||
+45
-17
@@ -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 ───────────────────────────────────────────────────────────────
|
||||||
@@ -291,8 +310,11 @@ def _seed_if_empty(conn: sqlite3.Connection, table: str, columns: tuple, rows: l
|
|||||||
def _seed_data(conn: sqlite3.Connection):
|
def _seed_data(conn: sqlite3.Connection):
|
||||||
"""Insert default seed data for lookup tables."""
|
"""Insert default seed data for lookup tables."""
|
||||||
_seed_if_empty(conn, "categories", ("name", "icon"), [
|
_seed_if_empty(conn, "categories", ("name", "icon"), [
|
||||||
("Furniture", "🪑"), ("Appliances", "🔌"),
|
("Bev", "🥤"), ("Snack", "🍿"),
|
||||||
("Utensils & Serveware", "🍽️"), ("Equipment", "⚙️"), ("Other", "📦"),
|
("Food", "🍔"), ("Equipment", "🔧"),
|
||||||
|
("Appliances", "🔌"), ("Markets", "🏪"),
|
||||||
|
("Coffee", "☕"),
|
||||||
|
("Other", "📦"),
|
||||||
])
|
])
|
||||||
_seed_if_empty(conn, "key_names", ("name",), [
|
_seed_if_empty(conn, "key_names", ("name",), [
|
||||||
("MK500",), ("Green Dot",), ("Red Key",), ("Blue Key",),
|
("MK500",), ("Green Dot",), ("Red Key",), ("Blue Key",),
|
||||||
@@ -358,6 +380,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 +396,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 ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -683,12 +707,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 +757,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 +1515,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 = [
|
||||||
@@ -1515,6 +1529,19 @@ def get_stats():
|
|||||||
).fetchall()
|
).fetchall()
|
||||||
by_make = {r["make"]: r["cnt"] for r in makes}
|
by_make = {r["make"]: r["cnt"] for r in makes}
|
||||||
|
|
||||||
|
# Find asset categories not in the categories lookup table
|
||||||
|
unmapped = conn.execute(
|
||||||
|
"""SELECT a.category AS name, COUNT(*) AS cnt
|
||||||
|
FROM assets a LEFT JOIN categories c ON a.category = c.name
|
||||||
|
WHERE c.id IS NULL AND a.category IS NOT NULL AND a.category != ''
|
||||||
|
GROUP BY a.category ORDER BY cnt DESC"""
|
||||||
|
).fetchall()
|
||||||
|
category_health = {
|
||||||
|
"unmapped": [{"name": r["name"], "count": r["cnt"]} for r in unmapped],
|
||||||
|
"total_categories": len(by_category),
|
||||||
|
"unmapped_count": len(unmapped),
|
||||||
|
}
|
||||||
|
|
||||||
conn.close()
|
conn.close()
|
||||||
return {
|
return {
|
||||||
"total_assets": total_assets,
|
"total_assets": total_assets,
|
||||||
@@ -1527,6 +1554,7 @@ def get_stats():
|
|||||||
"top_visited": top_visited_list,
|
"top_visited": top_visited_list,
|
||||||
"time_on_site": time_on_site,
|
"time_on_site": time_on_site,
|
||||||
"by_make": by_make,
|
"by_make": by_make,
|
||||||
|
"category_health": category_health,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1657,7 +1685,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
|
||||||
|
|||||||
+1296
File diff suppressed because it is too large
Load Diff
Executable
+15
@@ -0,0 +1,15 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# 🚀 Dev instance — admin server on port 8092, points to dev DB
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
PORT=8092
|
||||||
|
MAIN_DEV_DIR="$HOME/projects/canteen-asset-tracker-dev"
|
||||||
|
CANTEEN_DB_PATH="${MAIN_DEV_DIR}/assets.dev.db"
|
||||||
|
|
||||||
|
echo "🚀 Starting DEV admin server on http://0.0.0.0:${PORT}"
|
||||||
|
echo " DB: ${CANTEEN_DB_PATH}"
|
||||||
|
exec python -m uvicorn admin_server:app \
|
||||||
|
--host 0.0.0.0 \
|
||||||
|
--port "$PORT" \
|
||||||
|
--log-level info
|
||||||
+1403
-29
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user