diff --git a/server/routers/setup.py b/server/routers/setup.py index ff79539..bb2a257 100644 --- a/server/routers/setup.py +++ b/server/routers/setup.py @@ -3,21 +3,24 @@ Protected by SETUP_PASSWORD env var. When empty/None, all endpoints return 404. NOT protected by API Key auth (standalone access). """ +import random import secrets +from datetime import datetime, timedelta from pathlib import Path from fastapi import APIRouter, HTTPException, Request, status from fastapi.responses import HTMLResponse from fastapi.templating import Jinja2Templates from pydantic import BaseModel -from sqlalchemy import inspect as sa_inspect +from sqlalchemy import inspect as sa_inspect, select from config import settings from database import Base, engine, async_session_factory from models import ( - User, Recipe, RecipeVersion, RecipeTask, RecipeSubtask, + User, Recipe, RecipeVersion, RecipeTask, RecipeSubtask, Measurement, ) from services.auth_service import hash_password +from services.measurement_service import calculate_pass_fail router = APIRouter(prefix="/api/setup", tags=["setup"]) @@ -46,6 +49,28 @@ class ResetDbBody(BaseModel): confirm: bool +class ManageUserBody(BaseModel): + password: str # setup password + user_id: int | None = None # None = create, int = update + username: str + user_password: str | None = None # Required for create, optional for update + display_name: str + email: str | None = None + roles: list[str] = [] + is_admin: bool = False + + +class ChangeUserPasswordBody(BaseModel): + password: str # setup password + user_id: int + new_password: str + + +class ToggleUserActiveBody(BaseModel): + password: str # setup password + user_id: int + + # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- @@ -66,6 +91,76 @@ def _check_password(password: str) -> None: ) +def _generate_measurements_for_subtask( + subtask: RecipeSubtask, + version_id: int, + measured_by: int, + lot_numbers: list[str], + serial_numbers: list[str], + base_date: datetime, + count: int = 30, +) -> list[Measurement]: + """Generate realistic demo measurements with Gaussian distribution.""" + nominal = float(subtask.nominal) + uwl = float(subtask.uwl) + ltl = float(subtask.ltl) + utl = float(subtask.utl) + lwl = float(subtask.lwl) + + sigma = (uwl - nominal) / 2.5 + + measurements = [] + for i in range(count): + # ~80% pass, ~13% warning, ~7% fail + r = random.random() + if r < 0.07: + # Fail: outside tolerance limits + if random.random() < 0.5: + value = random.uniform(utl, utl + (utl - nominal)) + else: + value = random.uniform(ltl - (nominal - ltl), ltl) + elif r < 0.20: + # Warning: between warning and tolerance + if random.random() < 0.5: + value = random.uniform(uwl, utl) + else: + value = random.uniform(ltl, lwl) + else: + # Pass: Gaussian around nominal + value = random.gauss(nominal, sigma * 0.7) + # Clamp to within warning limits + value = max(lwl, min(uwl, value)) + + value = round(value, 6) + + pass_fail, deviation = calculate_pass_fail(value, subtask) + + # Distribute across time, lots, serials + days_offset = random.uniform(0, 30) + hours_offset = random.uniform(8, 17) # working hours + measured_at = base_date - timedelta(days=days_offset) + timedelta(hours=hours_offset) + + lot = lot_numbers[i % len(lot_numbers)] + serial = serial_numbers[i % len(serial_numbers)] + input_method = "usb_caliper" if random.random() < 0.7 else "manual" + + m = Measurement( + subtask_id=subtask.id, + version_id=version_id, + measured_by=measured_by, + value=value, + pass_fail=pass_fail, + deviation=deviation, + lot_number=lot, + serial_number=serial, + input_method=input_method, + measured_at=measured_at, + ) + measurements.append(m) + + return measurements + + # --------------------------------------------------------------------------- # Endpoints # --------------------------------------------------------------------------- @@ -312,14 +407,188 @@ async def seed_demo_data(body: PasswordBody): ] session.add_all(subtasks_2) + # ---- Measurements ------------------------------------------------- + await session.flush() # Get subtask IDs + + random.seed(42) # Reproducible demo data + + tec_user = created_users[2] # tec1 + lot_numbers = ["LOT-2025-001", "LOT-2025-002", "LOT-2025-003", "LOT-2025-004"] + serial_numbers = [f"SN-{i:04d}" for i in range(1, 13)] + base_date = datetime.now() + + all_subtasks = subtasks_1 + subtasks_2 + measurements_created = 0 + for st in all_subtasks: + ms = _generate_measurements_for_subtask( + subtask=st, + version_id=version.id, + measured_by=tec_user.id, + lot_numbers=lot_numbers, + serial_numbers=serial_numbers, + base_date=base_date, + count=30, + ) + session.add_all(ms) + measurements_created += len(ms) + return { "status": "ok", "message": "Demo data seeded successfully", "users_created": [u["username"] for u in users_data], "recipe_created": "DEMO-001", + "measurements_created": measurements_created, } +@router.post("/list-users") +async def list_users(body: PasswordBody): + """List all users.""" + _check_password(body.password) + + async with async_session_factory() as session: + result = await session.execute(select(User).order_by(User.id)) + users = result.scalars().all() + return { + "status": "ok", + "users": [ + { + "id": u.id, + "username": u.username, + "display_name": u.display_name, + "email": u.email, + "roles": u.roles or [], + "is_admin": u.is_admin, + "active": u.active, + "created_at": u.created_at.isoformat() if u.created_at else None, + "last_login": u.last_login.isoformat() if u.last_login else None, + } + for u in users + ], + } + + +@router.post("/manage-user") +async def manage_user(body: ManageUserBody): + """Create or update a user. user_id=null creates new, user_id=int updates.""" + _check_password(body.password) + + VALID_ROLES = {"Maker", "MeasurementTec", "Metrologist"} + invalid = set(body.roles) - VALID_ROLES + if invalid: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid roles: {', '.join(invalid)}", + ) + + async with async_session_factory() as session: + async with session.begin(): + if body.user_id is None: + # Create + if not body.user_password: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Password is required when creating a new user", + ) + # Check username uniqueness + existing = await session.execute( + select(User).where(User.username == body.username) + ) + if existing.scalar_one_or_none(): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"Username '{body.username}' already exists", + ) + user = User( + username=body.username, + password_hash=hash_password(body.user_password), + display_name=body.display_name, + email=body.email, + roles=body.roles, + is_admin=body.is_admin, + api_key=secrets.token_hex(32), + ) + session.add(user) + await session.flush() + return {"status": "ok", "message": f"User '{body.username}' created", "user_id": user.id} + else: + # Update + result = await session.execute( + select(User).where(User.id == body.user_id) + ) + user = result.scalar_one_or_none() + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"User ID {body.user_id} not found", + ) + # Check username uniqueness (exclude self) + existing = await session.execute( + select(User).where( + User.username == body.username, User.id != body.user_id + ) + ) + if existing.scalar_one_or_none(): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"Username '{body.username}' already taken", + ) + user.username = body.username + user.display_name = body.display_name + user.email = body.email + user.roles = body.roles + user.is_admin = body.is_admin + if body.user_password: + user.password_hash = hash_password(body.user_password) + return {"status": "ok", "message": f"User '{body.username}' updated", "user_id": user.id} + + +@router.post("/change-user-password") +async def change_user_password(body: ChangeUserPasswordBody): + """Change a user's password and invalidate their API key.""" + _check_password(body.password) + + async with async_session_factory() as session: + async with session.begin(): + result = await session.execute( + select(User).where(User.id == body.user_id) + ) + user = result.scalar_one_or_none() + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"User ID {body.user_id} not found", + ) + user.password_hash = hash_password(body.new_password) + user.api_key = None # Invalidate sessions + + return {"status": "ok", "message": f"Password changed for '{user.username}'"} + + +@router.post("/toggle-user-active") +async def toggle_user_active(body: ToggleUserActiveBody): + """Toggle a user's active status. Invalidates API key when deactivated.""" + _check_password(body.password) + + async with async_session_factory() as session: + async with session.begin(): + result = await session.execute( + select(User).where(User.id == body.user_id) + ) + user = result.scalar_one_or_none() + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"User ID {body.user_id} not found", + ) + user.active = not user.active + if not user.active: + user.api_key = None # Invalidate sessions on deactivation + new_status = "active" if user.active else "inactive" + + return {"status": "ok", "message": f"User '{user.username}' is now {new_status}", "active": user.active} + + @router.post("/reset-db") async def reset_db(body: ResetDbBody): """Drop all tables and recreate them. Requires confirm=true.""" diff --git a/server/templates/setup/setup.html b/server/templates/setup/setup.html index 38342d8..9bee60a 100644 --- a/server/templates/setup/setup.html +++ b/server/templates/setup/setup.html @@ -224,6 +224,123 @@ .mt-4 { margin-top: 16px; } .text-sm { font-size: 0.82rem; } .text-muted { color: #94a3b8; } + + /* --- User Management ----------------------------------------------- */ + .user-table { + width: 100%; + border-collapse: collapse; + font-size: 0.85rem; + margin-top: 12px; + } + .user-table th { + text-align: left; + padding: 8px 10px; + background: #f8fafc; + color: #475569; + font-weight: 600; + border-bottom: 2px solid #e2e8f0; + font-size: 0.78rem; + text-transform: uppercase; + letter-spacing: 0.03em; + } + .user-table td { + padding: 8px 10px; + border-bottom: 1px solid #f1f5f9; + vertical-align: middle; + } + .user-table tr:hover td { background: #f8fafc; } + + .badge { + display: inline-block; + padding: 2px 8px; + border-radius: 12px; + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.02em; + } + .badge-maker { background: #dbeafe; color: #1d4ed8; } + .badge-measurement { background: #dcfce7; color: #166534; } + .badge-metrologist { background: #fef3c7; color: #92400e; } + .badge-admin { background: #fce7f3; color: #9d174d; } + .badge-active { background: #dcfce7; color: #166534; } + .badge-inactive { background: #fee2e2; color: #991b1b; } + + .btn-sm { + padding: 4px 10px; + font-size: 0.75rem; + border-radius: 6px; + border: none; + cursor: pointer; + font-weight: 500; + font-family: inherit; + transition: background .15s; + } + .btn-sm-primary { background: #2563eb; color: #fff; } + .btn-sm-primary:hover { background: #1d4ed8; } + .btn-sm-warning { background: #f59e0b; color: #fff; } + .btn-sm-warning:hover { background: #d97706; } + .btn-sm-danger { background: #ef4444; color: #fff; } + .btn-sm-danger:hover { background: #dc2626; } + .btn-sm-success { background: #10b981; color: #fff; } + .btn-sm-success:hover { background: #059669; } + + .action-btns { display: flex; gap: 4px; flex-wrap: wrap; } + + /* --- Modal --------------------------------------------------------- */ + .modal-overlay { + position: fixed; + inset: 0; + background: rgba(0,0,0,.4); + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + animation: fadeInOverlay .2s ease-out; + } + @keyframes fadeInOverlay { from { opacity: 0; } to { opacity: 1; } } + + .modal { + background: #fff; + border-radius: 12px; + box-shadow: 0 8px 30px rgba(0,0,0,.2); + padding: 24px; + width: 90%; + max-width: 480px; + max-height: 90vh; + overflow-y: auto; + animation: scaleIn .2s ease-out; + } + @keyframes scaleIn { from { transform: scale(.95); opacity: 0; } to { transform: scale(1); opacity: 1; } } + + .modal h2 { margin-bottom: 16px; } + .modal .btn { margin-top: 8px; } + + .modal-actions { + display: flex; + gap: 8px; + margin-top: 16px; + } + .modal-actions .btn { flex: 1; } + + /* --- Checkbox group ------------------------------------------------ */ + .checkbox-group { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin-bottom: 14px; + } + .checkbox-group label { + display: flex; + align-items: center; + gap: 6px; + font-size: 0.85rem; + margin-bottom: 0; + cursor: pointer; + } + .checkbox-group input[type="checkbox"] { + width: 16px; height: 16px; + accent-color: #2563eb; + }
@@ -300,14 +417,26 @@- Populate the database with sample users and a demo recipe. + Populate the database with sample users, a demo recipe, and 180 measurements for SPC testing. Creates users: admin, maker1, tec1, metro1.
+ Create, edit, and manage user accounts. Changes take effect immediately. +
+ +Unlock the setup page to load users.
+@@ -321,6 +450,58 @@
No users found. Use Seed Demo Data or create one.
'; + return; + } + var html = '| ID | Username | Display Name | ' + + 'Roles | Status | Actions | ' + + '
|---|---|---|---|---|---|
| ' + u.id + ' | ' + + '' + escHtml(u.username) + ' | ' + + '' + escHtml(u.display_name) + ' | ' + + '' + roles + ' | ' + + '' + statusBadge + ' | ' + + ''
+ + ''
+ + ''
+ + ''
+ + ' |