chore(v2): restructure monorepo to src/ layout with uv
Aligns the repo with the python-project-spec-design.md template chosen for V2.0.0. Big move, no logic changes. The 3 pre-existing test failures (test_recipes::test_update_recipe, test_recipes:: test_recipe_versioning, test_tasks::test_reorder_tasks, plus the client test_save_measurement_proxy) survive unchanged. Layout changes - server/ -> src/backend/ - server/middleware/ -> src/backend/api/middleware/ - server/routers/ -> src/backend/api/routers/ - server/models/ -> src/backend/models/orm/ - server/schemas/ -> src/backend/models/api/ - server/uploads/ -> uploads/ (project root, mounted volume) - server/tests/ -> src/backend/tests/ - client/ -> src/frontend/flask_app/ (Flask kept; React deroga is documented in CLAUDE.md, justified by tablet UX, USB caliper/barcode workflow and Fabric.js integration) Tooling - pyproject.toml: monorepo with [project] core deps and optional-dependencies server / client / dev. Replaces both server/requirements.txt and client/requirements.txt. - uv.lock + .python-version (3.11) committed for reproducible builds. - Dockerfile (root, backend) and Dockerfile.frontend rewritten to use uv sync --frozen --no-dev --extra server|client; legacy Dockerfiles preserved as Dockerfile.legacy for reference but excluded from build context via .dockerignore. - docker-compose.dev.yml + docker-compose.yml: build context now ".", dockerfile pointing to the root files. Code adjustments forced by the move - Every "from config|database|models|schemas|services|routers|middleware import ..." rewritten to its src.backend.* equivalent (50+ files including indented inline imports inside test bodies). - src/backend/migrations/env.py: insert project root into sys.path so alembic can resolve src.backend.* imports regardless of cwd. - src/backend/config.py: env_file ../../.env (was ../.env), upload_path resolves project root via parents[2]. - src/backend/tests/conftest.py + tests: import ... from src.backend.* instead of bare names; old per-directory pytest.ini files removed in favor of root pyproject.toml [tool.pytest.ini_options]. - .gitignore: uploads/ at root, src/frontend/flask_app/static/css/ tailwind.css path; .dockerignore tightened. - CLAUDE.md: rewrote sections "Layout del repository", "Comandi di Sviluppo", "Database & Migrations", "Test", "i18n", and all path references throughout the architecture sections. Verified - uv lock resolves 77 packages; uv sync --extra server --extra client --extra dev installs cleanly. - uv run pytest: 171 passed, 4 pre-existing failures. - uv run alembic -c src/backend/migrations/alembic.ini check loads config and metadata (errors only on the absent local MySQL). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,678 @@
|
||||
"""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 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, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
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
|
||||
from src.backend.services.measurement_service import calculate_pass_fail
|
||||
|
||||
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
|
||||
|
||||
|
||||
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 password != settings.setup_password:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Invalid setup password",
|
||||
)
|
||||
|
||||
|
||||
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("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:
|
||||
# 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"],
|
||||
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 -------------------------------------------------------
|
||||
# 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 = 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)
|
||||
|
||||
# ---- 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": 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."""
|
||||
_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"}
|
||||
Reference in New Issue
Block a user