Files
TieMeasureFlow/src/backend/api/routers/setup.py
T
Adriano f8f5f248ff setup: init-db e reset-db stampano head in un thread (TieMeasureFlow#80)
_stamp_head esegue env.py di alembic, che fa asyncio.run() e dentro il loop
di FastAPI dava RuntimeError (500). Ora gira con asyncio.to_thread. Nuovo
test_setup_stamp.py senza monkeypatch di _stamp_head, su file SQLite.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 14:25:24 +02:00

759 lines
27 KiB
Python

"""Setup page router - database init, admin creation, demo seed.
Protected by SETUP_PASSWORD env var. When empty/None, all endpoints return 404.
NOT protected by API Key auth (standalone access).
"""
import asyncio
import hmac
import random
from datetime import datetime, timedelta
from src.backend.utils.tempo import iso_utc, utcnow
from pathlib import Path
from alembic import command
from alembic.config import Config
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, select
from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.api.routers.users import VALID_ROLES
from src.backend.config import settings
from src.backend.database import Base, engine, async_session_factory
from src.backend.models.orm import (
User, Recipe, RecipeVersion, RecipeTask, RecipeSubtask, Measurement,
)
from src.backend.models.orm.station import Station, StationRecipeAssignment
from src.backend.services.auth_service import hash_password, issue_api_key, revoke_all_keys
from src.backend.services.measurement_service import calculate_pass_fail
router = APIRouter(prefix="/api/setup", tags=["setup"])
_backend_dir = Path(__file__).resolve().parent.parent.parent
_templates_dir = _backend_dir / "templates"
_alembic_ini = _backend_dir / "migrations" / "alembic.ini"
templates = Jinja2Templates(directory=str(_templates_dir))
def _stamp_head() -> None:
"""Mark the schema as fully migrated - the equivalent of `alembic stamp
head` - after `Base.metadata.create_all()`.
A database that got its tables this way instead of through
`upgrade head` has no row in `alembic_version`; without this, its next
deploy would try to replay migrations 001-013 and fail on a column that
is already there (see migration 000_base_schema.py).
"""
command.stamp(Config(str(_alembic_ini)), "head")
# ---------------------------------------------------------------------------
# Request schemas
# ---------------------------------------------------------------------------
class PasswordBody(BaseModel):
password: str
class CreateAdminBody(BaseModel):
password: str
admin_username: str
admin_password: str
admin_email: str
admin_display_name: str
class ResetDbBody(BaseModel):
password: str
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
# ---------------------------------------------------------------------------
def _check_setup_enabled() -> None:
"""Raise 404 if setup password is not configured."""
if not settings.setup_password:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
def _check_password(password: str) -> None:
"""Verify the provided password matches the setup password."""
_check_setup_enabled()
if not hmac.compare_digest(password, settings.setup_password):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid setup password",
)
def _password_matches(password: str | None) -> bool:
"""Constant-time password check that tolerates a missing password."""
if not password or not settings.setup_password:
return False
return hmac.compare_digest(password, settings.setup_password)
async def _admin_exists() -> bool:
"""Whether at least one admin user has been created.
False on a database with no `users` table yet, so init-db and the first
seed are never blocked by a query against a table that does not exist.
"""
async with engine.connect() as conn:
has_users = await conn.run_sync(
lambda sync_conn: sa_inspect(sync_conn).has_table("users")
)
if not has_users:
return False
async with async_session_factory() as session:
result = await session.execute(select(User).where(User.is_admin == True)) # noqa: E712
return result.scalars().first() is not None
async def _require_not_initialised() -> None:
"""Refuse privileged/destructive setup endpoints once an admin exists.
reset-db, seed, manage-user, change-user-password and init-db all reach
into a live database once setup is done: reset-db wipes it, seed adds
demo users and data, manage-user/change-user-password can take over an
account, init-db races Alembic. All of them are meant for the one-time
bring-up before there is an admin to log in as.
`settings.setup_allow_after_init` is the explicit override for scripted
re-provisioning of a demo/test deploy.
"""
if settings.setup_allow_after_init:
return
if await _admin_exists():
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="setup already completed",
)
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
async def _seed_default_station(session: AsyncSession, admin_user: User) -> None:
"""Ensure ST-DEFAULT station exists and all active recipes are assigned to it.
Idempotent: safe to call multiple times. Skips creation if the station
already exists and only adds assignments for recipes not yet assigned.
"""
existing = await session.execute(
select(Station).where(Station.code == "ST-DEFAULT")
)
default_station = existing.scalar_one_or_none()
if default_station is None:
default_station = Station(
code="ST-DEFAULT",
name="Default Station",
location="Initial seed - change me",
created_by=admin_user.id,
)
session.add(default_station)
await session.flush()
await session.refresh(default_station)
# Collect already-assigned recipe IDs to avoid unique-constraint violations.
existing_assignments = await session.execute(
select(StationRecipeAssignment.recipe_id).where(
StationRecipeAssignment.station_id == default_station.id
)
)
existing_ids = {row[0] for row in existing_assignments}
recipes_result = await session.execute(
select(Recipe).where(Recipe.active == True)
)
for r in recipes_result.scalars().all():
if r.id not in existing_ids:
session.add(StationRecipeAssignment(
station_id=default_station.id,
recipe_id=r.id,
assigned_by=admin_user.id,
))
await session.flush()
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@router.get("", response_class=HTMLResponse)
async def setup_page(request: Request):
"""Serve the setup page HTML."""
_check_setup_enabled()
return templates.TemplateResponse(request, "setup/setup.html")
@router.get("/status")
async def setup_status(password: str | None = None):
"""Database status. Without a valid password, only whether setup is done.
The table listing is only returned with the setup password: it is
internal detail, not something to hand to whoever happens to load the
page.
"""
_check_setup_enabled()
initialised = await _admin_exists()
if not _password_matches(password):
return {"initialised": initialised}
async with engine.connect() as conn:
table_names: list[str] = await conn.run_sync(
lambda sync_conn: sa_inspect(sync_conn).get_table_names()
)
return {
"initialised": initialised,
"tables_exist": len(table_names) > 0,
"table_count": len(table_names),
"tables": table_names,
}
@router.post("/init-db")
async def init_db(body: PasswordBody):
"""Create all database tables."""
_check_password(body.password)
await _require_not_initialised()
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
# #80: env.py di alembic fa asyncio.run(), vietato dentro il loop di FastAPI;
# in un thread non c'e' un loop attivo e funziona.
await asyncio.to_thread(_stamp_head)
return {"status": "ok", "message": "Database tables created successfully"}
@router.post("/create-admin")
async def create_admin(body: CreateAdminBody):
"""Create an admin user with all roles."""
_check_password(body.password)
async with async_session_factory() as session:
async with session.begin():
user = User(
username=body.admin_username,
password_hash=hash_password(body.admin_password),
email=body.admin_email,
display_name=body.admin_display_name,
roles=["Maker", "MeasurementTec", "Metrologist"],
is_admin=True,
)
session.add(user)
await session.flush()
api_key = await issue_api_key(session, user)
return {
"status": "ok",
"message": f"Admin user '{body.admin_username}' created",
"api_key": api_key,
}
@router.post("/seed")
async def seed_demo_data(body: PasswordBody):
"""Seed database with demo users and a sample recipe."""
_check_password(body.password)
await _require_not_initialised()
async with async_session_factory() as session:
async with session.begin():
# ---- Users --------------------------------------------------------
users_data = [
{
"username": "admin",
"password": "admin123",
"display_name": "Administrator",
"email": "admin@tiemeasureflow.local",
"roles": ["Maker", "MeasurementTec", "Metrologist"],
"is_admin": True,
},
{
"username": "maker1",
"password": "maker123",
"display_name": "Mario Rossi",
"email": "maker1@tiemeasureflow.local",
"roles": ["Maker"],
"is_admin": False,
},
{
"username": "tec1",
"password": "tec123",
"display_name": "Luca Bianchi",
"email": "tec1@tiemeasureflow.local",
"roles": ["MeasurementTec"],
"is_admin": False,
},
{
"username": "metro1",
"password": "metro123",
"display_name": "Anna Verdi",
"email": "metro1@tiemeasureflow.local",
"roles": ["Metrologist"],
"is_admin": False,
},
]
created_users: list[User] = []
for u in users_data:
# Skip users that already exist
existing = await session.execute(
select(User).where(User.username == u["username"])
)
existing_user = existing.scalar_one_or_none()
if existing_user:
created_users.append(existing_user)
continue
user = User(
username=u["username"],
password_hash=hash_password(u["password"]),
display_name=u["display_name"],
email=u["email"],
roles=u["roles"],
is_admin=u["is_admin"],
)
session.add(user)
created_users.append(user)
# Flush to get auto-generated user IDs
await session.flush()
admin_user = created_users[0]
# ---- Recipe -------------------------------------------------------
# Skip if recipe already exists
existing_recipe = await session.execute(
select(Recipe).where(Recipe.code == "DEMO-001")
)
if existing_recipe.scalar_one_or_none():
await _seed_default_station(session, admin_user)
return {
"status": "ok",
"message": "Demo data already exists, skipped",
"users_created": [u["username"] for u in users_data],
"recipe_created": "DEMO-001",
"measurements_created": 0,
}
recipe = Recipe(
code="DEMO-001",
name="Demo Measurement Recipe",
description="Sample recipe with shaft and surface measurements for demonstration purposes.",
created_by=admin_user.id,
)
session.add(recipe)
await session.flush()
version = RecipeVersion(
recipe_id=recipe.id,
version_number=1,
is_current=True,
created_by=admin_user.id,
change_notes="Initial demo version",
)
session.add(version)
await session.flush()
# ---- Task 1: Shaft Diameter ----------------------------------------
task1 = RecipeTask(
version_id=version.id,
order_index=0,
title="Shaft Diameter Measurement",
directive="Measure the main shaft diameters at the indicated positions.",
description="Use the caliper to measure each diameter. Ensure the shaft is clean and at room temperature.",
)
session.add(task1)
await session.flush()
subtasks_1 = [
RecipeSubtask(
task_id=task1.id,
marker_number=1,
description="D1 - Main shaft diameter",
measurement_type="diameter",
nominal=25.000,
utl=25.050,
uwl=25.030,
lwl=24.970,
ltl=24.950,
unit="mm",
),
RecipeSubtask(
task_id=task1.id,
marker_number=2,
description="D2 - Bearing seat diameter",
measurement_type="diameter",
nominal=30.000,
utl=30.021,
uwl=30.015,
lwl=29.985,
ltl=29.979,
unit="mm",
),
RecipeSubtask(
task_id=task1.id,
marker_number=3,
description="D3 - Shoulder diameter",
measurement_type="diameter",
nominal=35.000,
utl=35.039,
uwl=35.025,
lwl=34.975,
ltl=34.961,
unit="mm",
),
]
session.add_all(subtasks_1)
# ---- Task 2: Surface Flatness --------------------------------------
task2 = RecipeTask(
version_id=version.id,
order_index=1,
title="Surface Flatness Measurement",
directive="Measure surface heights at the marked reference points.",
description="Place the part on the granite surface plate. Zero the indicator at the reference point, then measure each position.",
)
session.add(task2)
await session.flush()
subtasks_2 = [
RecipeSubtask(
task_id=task2.id,
marker_number=1,
description="F1 - Center reference height",
measurement_type="height",
nominal=0.000,
utl=0.020,
uwl=0.010,
lwl=-0.010,
ltl=-0.020,
unit="mm",
),
RecipeSubtask(
task_id=task2.id,
marker_number=2,
description="F2 - Edge height (left)",
measurement_type="height",
nominal=0.000,
utl=0.030,
uwl=0.015,
lwl=-0.015,
ltl=-0.030,
unit="mm",
),
RecipeSubtask(
task_id=task2.id,
marker_number=3,
description="F3 - Edge height (right)",
measurement_type="height",
nominal=0.000,
utl=0.030,
uwl=0.015,
lwl=-0.015,
ltl=-0.030,
unit="mm",
),
]
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 = utcnow()
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)
# ---- Default Station ----------------------------------------------
await _seed_default_station(session, admin_user)
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": iso_utc(u.created_at) or None,
"last_login": iso_utc(u.last_login) or 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)
await _require_not_initialised()
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,
)
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)
await _require_not_initialised()
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)
await revoke_all_keys(session, user.id) # 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:
await revoke_all_keys(session, user.id) # 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."""
_check_password(body.password)
await _require_not_initialised()
if not body.confirm:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="You must set confirm=true to reset the database",
)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await conn.run_sync(Base.metadata.create_all)
# #80: env.py di alembic fa asyncio.run(), vietato dentro il loop di FastAPI;
# in un thread non c'e' un loop attivo e funziona.
await asyncio.to_thread(_stamp_head)
return {"status": "ok", "message": "Database reset successfully"}