Files
TieMeasureFlow/server/routers/setup.py
T
Adriano c9d9c0f9dd feat: V1.0.1 - Setup page, Docker, README
Add password-protected setup page (/api/setup) for DB initialization,
admin creation, and demo data seeding. Dockerize the full stack with
server, client, nginx reverse proxy, and MySQL services. Add project
README with architecture overview, quick start, and VPS deployment guide.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 18:00:29 +01:00

339 lines
11 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 secrets
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 config import settings
from database import Base, engine, async_session_factory
from models import (
User, Recipe, RecipeVersion, RecipeTask, RecipeSubtask,
)
from services.auth_service import hash_password
router = APIRouter(prefix="/api/setup", tags=["setup"])
_templates_dir = Path(__file__).resolve().parent.parent / "templates"
templates = Jinja2Templates(directory=str(_templates_dir))
# ---------------------------------------------------------------------------
# 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
# ---------------------------------------------------------------------------
# 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 password != settings.setup_password:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid setup password",
)
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@router.get("", response_class=HTMLResponse)
async def setup_page(request: Request):
"""Serve the setup page HTML."""
_check_setup_enabled()
return templates.TemplateResponse("setup/setup.html", {"request": request})
@router.get("/status")
async def setup_status():
"""Return database table status (no password required, but setup must be enabled)."""
_check_setup_enabled()
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 {
"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)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
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)
api_key = secrets.token_hex(32)
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,
api_key=api_key,
)
session.add(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)
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:
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"],
api_key=secrets.token_hex(32),
)
session.add(user)
created_users.append(user)
# Flush to get auto-generated user IDs
await session.flush()
admin_user = created_users[0]
# ---- Recipe -------------------------------------------------------
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)
return {
"status": "ok",
"message": "Demo data seeded successfully",
"users_created": [u["username"] for u in users_data],
"recipe_created": "DEMO-001",
}
@router.post("/reset-db")
async def reset_db(body: ResetDbBody):
"""Drop all tables and recreate them. Requires confirm=true."""
_check_password(body.password)
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)
return {"status": "ok", "message": "Database reset successfully"}