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:
2026-04-25 12:26:47 +02:00
parent 86df67f2e5
commit 1a0431366f
174 changed files with 2568 additions and 308 deletions
View File
+26
View File
@@ -0,0 +1,26 @@
"""FastAPI middleware for TieMeasureFlow."""
from src.backend.api.middleware.api_key import (
get_current_user,
require_role,
require_admin,
require_maker,
require_measurement_tec,
require_metrologist,
require_admin_user,
)
from src.backend.api.middleware.logging import AccessLogMiddleware
from src.backend.api.middleware.rate_limit import RateLimitMiddleware
from src.backend.api.middleware.security_headers import SecurityHeadersMiddleware
__all__ = [
"get_current_user",
"require_role",
"require_admin",
"require_maker",
"require_measurement_tec",
"require_metrologist",
"require_admin_user",
"AccessLogMiddleware",
"RateLimitMiddleware",
"SecurityHeadersMiddleware",
]
+71
View File
@@ -0,0 +1,71 @@
"""API Key authentication dependency for FastAPI."""
from fastapi import Depends, HTTPException, Request, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.database import get_db
from src.backend.models.orm.user import User
async def get_current_user(
request: Request,
db: AsyncSession = Depends(get_db),
) -> User:
"""Extract API key from header and return the authenticated user.
The API key is sent in the X-API-Key header on every request.
Login endpoint is excluded from this check.
"""
api_key = request.headers.get("X-API-Key")
if not api_key:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Missing API key in X-API-Key header",
)
result = await db.execute(
select(User).where(User.api_key == api_key, User.active == True)
)
user = result.scalar_one_or_none()
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or inactive API key",
)
# Store user_id in request state for access logging middleware
request.state.user_id = user.id
return user
def require_role(role: str):
"""Dependency factory that checks if user has a specific role."""
async def check_role(user: User = Depends(get_current_user)) -> User:
if not user.has_role(role) and not user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Role '{role}' required",
)
return user
return check_role
def require_admin():
"""Dependency that checks if user is admin."""
async def check_admin(user: User = Depends(get_current_user)) -> User:
if not user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin access required",
)
return user
return check_admin
# Pre-built role dependencies for convenience
require_maker = require_role("Maker")
require_measurement_tec = require_role("MeasurementTec")
require_metrologist = require_role("Metrologist")
require_admin_user = require_admin()
+54
View File
@@ -0,0 +1,54 @@
"""Access logging middleware for FastAPI."""
import time
from typing import Callable
from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware
from sqlalchemy import insert
from src.backend.database import async_session_factory
from src.backend.models.orm.access_log import AccessLog
class AccessLogMiddleware(BaseHTTPMiddleware):
"""Middleware that logs every API request to the access_logs table."""
# Paths to exclude from logging (health checks, static files)
EXCLUDED_PATHS = {"/api/health", "/docs", "/openapi.json", "/redoc"}
async def dispatch(self, request: Request, call_next: Callable) -> Response:
# Skip excluded paths
if request.url.path in self.EXCLUDED_PATHS:
return await call_next(request)
start_time = time.time()
response = await call_next(request)
duration_ms = (time.time() - start_time) * 1000
# Extract user info from request state (set by auth middleware)
user_id = getattr(request.state, "user_id", None)
# Log asynchronously (don't block response)
try:
async with async_session_factory() as session:
await session.execute(
insert(AccessLog).values(
user_id=user_id,
action=f"{request.method} {request.url.path}",
details={
"method": request.method,
"path": request.url.path,
"query": str(request.query_params),
"status_code": response.status_code,
"duration_ms": round(duration_ms, 2),
},
ip_address=request.client.host if request.client else None,
user_agent=request.headers.get("user-agent", "")[:500],
)
)
await session.commit()
except Exception:
# Don't fail the request if logging fails
pass
return response
+123
View File
@@ -0,0 +1,123 @@
"""Rate limiting middleware for FastAPI.
Implements in-memory sliding window rate limiting per client IP.
Configurable limits for login and general endpoints.
"""
import time
from collections import defaultdict
from typing import Callable
from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import JSONResponse
from src.backend.config import settings
class RateLimitMiddleware(BaseHTTPMiddleware):
"""Middleware that enforces per-IP rate limits using a sliding window.
- Login endpoint (/api/auth/login): limited to `rate_limit_login` req/min.
- All other endpoints: limited to `rate_limit_general` req/min.
Returns HTTP 429 with Retry-After header when limit exceeded.
"""
LOGIN_PATH = "/api/auth/login"
WINDOW_SECONDS = 60
def __init__(self, app) -> None:
super().__init__(app)
# {ip: [timestamp, ...]} per bucket
self._login_requests: dict[str, list[float]] = defaultdict(list)
self._general_requests: dict[str, list[float]] = defaultdict(list)
self._request_count = 0 # Counter for triggering eviction
@staticmethod
def _client_ip(request: Request) -> str:
"""Resolve the originating client IP, honoring proxy headers.
Order of precedence: ``X-Forwarded-For`` (first hop), ``X-Real-IP``,
``request.client.host``. Required because Nginx and the Flask client
sit between the tablet and the API; without parsing these headers
every tablet shares one bucket.
"""
xff = request.headers.get("x-forwarded-for")
if xff:
first = xff.split(",")[0].strip()
if first:
return first
real = request.headers.get("x-real-ip")
if real:
return real.strip()
return request.client.host if request.client else "unknown"
def _clean_window(self, timestamps: list[float], now: float) -> list[float]:
"""Remove timestamps outside the current sliding window."""
cutoff = now - self.WINDOW_SECONDS
return [t for t in timestamps if t > cutoff]
def _evict_stale_ips(self, bucket: dict[str, list[float]], now: float) -> None:
"""Remove IP entries with no timestamps in the current window (memory leak prevention)."""
cutoff = now - self.WINDOW_SECONDS
stale_ips = [ip for ip, timestamps in bucket.items() if not timestamps or max(timestamps) <= cutoff]
for ip in stale_ips:
del bucket[ip]
def _check_rate_limit(
self,
bucket: dict[str, list[float]],
client_ip: str,
limit: int,
now: float,
) -> tuple[bool, int]:
"""Check if a request is within the rate limit.
Returns:
Tuple of (allowed, retry_after_seconds).
"""
bucket[client_ip] = self._clean_window(bucket[client_ip], now)
if len(bucket[client_ip]) >= limit:
# Calculate seconds until the oldest request falls out of window
oldest = bucket[client_ip][0]
retry_after = int(oldest + self.WINDOW_SECONDS - now) + 1
return False, max(retry_after, 1)
bucket[client_ip].append(now)
return True, 0
async def dispatch(self, request: Request, call_next: Callable) -> Response:
client_ip = self._client_ip(request)
now = time.time()
path = request.url.path
# Periodic eviction: every 100 requests, remove stale IP buckets
self._request_count += 1
if self._request_count % 100 == 0:
self._evict_stale_ips(self._login_requests, now)
self._evict_stale_ips(self._general_requests, now)
# Check login-specific rate limit
if path == self.LOGIN_PATH and request.method == "POST":
allowed, retry_after = self._check_rate_limit(
self._login_requests, client_ip, settings.rate_limit_login, now
)
if not allowed:
return JSONResponse(
status_code=429,
content={"detail": "Too many login attempts. Please try again later."},
headers={"Retry-After": str(retry_after)},
)
# Check general rate limit
allowed, retry_after = self._check_rate_limit(
self._general_requests, client_ip, settings.rate_limit_general, now
)
if not allowed:
return JSONResponse(
status_code=429,
content={"detail": "Too many requests. Please try again later."},
headers={"Retry-After": str(retry_after)},
)
return await call_next(request)
@@ -0,0 +1,42 @@
"""Security headers middleware for FastAPI.
Adds standard security headers to every HTTP response to mitigate
common web vulnerabilities (clickjacking, XSS, MIME sniffing, etc.).
"""
from typing import Callable
from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware
from src.backend.config import settings
# Content Security Policy - allows CDN resources used by the client
# Note: 'unsafe-eval' required for Plotly.js runtime evaluation in SPC charts
CSP = (
"default-src 'self'; "
"script-src 'self' 'unsafe-inline' 'unsafe-eval' "
"https://cdn.tailwindcss.com https://cdn.jsdelivr.net https://cdn.plot.ly; "
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; "
"font-src 'self' https://fonts.gstatic.com; "
"img-src 'self' data: blob:; "
"connect-src 'self'"
)
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
"""Middleware that injects security headers into every response."""
async def dispatch(self, request: Request, call_next: Callable) -> Response:
response = await call_next(request)
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
response.headers["X-XSS-Protection"] = "1; mode=block"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
response.headers["Content-Security-Policy"] = CSP
# Add HSTS header only when running with HTTPS (SSL configured)
if settings.ssl_certfile and settings.ssl_keyfile:
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
return response
View File
+62
View File
@@ -0,0 +1,62 @@
"""Authentication router - login, logout, profile."""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.database import get_db
from src.backend.api.middleware.api_key import get_current_user
from src.backend.models.orm.user import User
from src.backend.models.api.user import (
LoginRequest,
LoginResponse,
UserProfileUpdate,
UserResponse,
)
from src.backend.services.auth_service import authenticate_user, login_user, logout_user
router = APIRouter(prefix="/api/auth", tags=["auth"])
@router.post("/login", response_model=LoginResponse)
async def login(data: LoginRequest, db: AsyncSession = Depends(get_db)):
"""Login with username and password, receive API key."""
user = await authenticate_user(db, data.username, data.password)
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid username or password",
)
api_key = await login_user(db, user)
return LoginResponse(
user=UserResponse.model_validate(user),
api_key=api_key,
)
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
async def logout(
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Logout - invalidate API key."""
await logout_user(db, user)
@router.get("/me", response_model=UserResponse)
async def get_profile(user: User = Depends(get_current_user)):
"""Get current user profile."""
return UserResponse.model_validate(user)
@router.put("/me", response_model=UserResponse)
async def update_profile(
data: UserProfileUpdate,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Update own profile (display_name, language, theme)."""
update_data = data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(user, field, value)
await db.flush()
await db.refresh(user)
return UserResponse.model_validate(user)
+234
View File
@@ -0,0 +1,234 @@
"""File upload/download/delete router for images and PDFs."""
import os
import re
from pathlib import Path
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from fastapi.responses import FileResponse
from PIL import Image
from src.backend.config import settings
from src.backend.api.middleware.api_key import get_current_user, require_maker
from src.backend.models.orm.user import User
router = APIRouter(prefix="/api/files", tags=["files"])
# Allowed MIME types
ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp"}
ALLOWED_PDF_TYPE = "application/pdf"
ALLOWED_TYPES = ALLOWED_IMAGE_TYPES | {ALLOWED_PDF_TYPE}
def validate_file_type(content_type: str) -> bool:
"""Validate if file type is allowed."""
return content_type in ALLOWED_TYPES
def validate_file_size(file_size: int) -> bool:
"""Validate if file size is within limits."""
max_bytes = settings.max_upload_size_mb * 1024 * 1024
return file_size <= max_bytes
def sanitize_filename(filename: str) -> str:
"""Sanitize an uploaded filename to prevent path traversal and injection.
- Strips directory path separators (/ and \\).
- Only allows alphanumeric characters, dots, hyphens, and underscores.
- Rejects filenames starting with '.' (hidden files).
- Rejects empty filenames.
Returns:
The sanitized filename.
Raises:
HTTPException: If filename is empty or starts with '.'.
"""
# Strip any path separators to prevent directory traversal
filename = filename.replace("/", "").replace("\\", "")
# Remove any characters not in the allowed set
filename = re.sub(r"[^a-zA-Z0-9._-]", "", filename)
if not filename:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Filename is empty or contains only invalid characters.",
)
if filename.startswith("."):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Filenames starting with '.' are not allowed.",
)
return filename
def generate_thumbnail(image_path: Path, thumbnail_path: Path, max_size: tuple[int, int] = (200, 200)):
"""Generate a thumbnail for an image."""
try:
with Image.open(image_path) as img:
img.thumbnail(max_size, Image.Resampling.LANCZOS)
img.save(thumbnail_path, quality=85)
except Exception as e:
# If thumbnail generation fails, we continue without it
print(f"Thumbnail generation failed: {e}")
@router.post("/upload")
async def upload_file(
file: UploadFile = File(...),
recipe_id: int | None = None,
version_id: int | None = None,
user: User = Depends(require_maker),
):
"""Upload an image or PDF file.
Files are stored in uploads/{recipe_id}/{version_id}/
Thumbnails are generated for images.
"""
# Validate file type
if not validate_file_type(file.content_type):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"File type {file.content_type} not allowed. Must be image or PDF.",
)
# Read file content
content = await file.read()
file_size = len(content)
# Validate file size
if not validate_file_size(file_size):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"File size {file_size} bytes exceeds maximum {settings.max_upload_size_mb}MB",
)
# Determine storage path
if recipe_id is not None and version_id is not None:
storage_dir = settings.upload_path / str(recipe_id) / str(version_id)
else:
storage_dir = settings.upload_path / "general"
storage_dir.mkdir(parents=True, exist_ok=True)
# Sanitize the uploaded filename
safe_filename = sanitize_filename(file.filename or "")
# Generate unique filename if file already exists
file_path = storage_dir / safe_filename
counter = 1
while file_path.exists():
name_parts = safe_filename.rsplit(".", 1)
if len(name_parts) == 2:
file_path = storage_dir / f"{name_parts[0]}_{counter}.{name_parts[1]}"
else:
file_path = storage_dir / f"{safe_filename}_{counter}"
counter += 1
# Write file
file_path.write_bytes(content)
# Generate thumbnail for images
thumbnail_path = None
if file.content_type in ALLOWED_IMAGE_TYPES:
thumbnail_dir = storage_dir / "thumbnails"
thumbnail_dir.mkdir(exist_ok=True)
thumbnail_path = thumbnail_dir / file_path.name
generate_thumbnail(file_path, thumbnail_path)
# Return relative path from uploads directory
relative_path = file_path.relative_to(settings.upload_path)
thumbnail_relative = (
thumbnail_path.relative_to(settings.upload_path)
if thumbnail_path
else None
)
return {
"file_path": str(relative_path).replace("\\", "/"),
"thumbnail_path": str(thumbnail_relative).replace("\\", "/") if thumbnail_relative else None,
"file_type": file.content_type,
"file_size": file_size,
}
@router.get("/{file_path:path}")
async def get_file(
file_path: str,
user: User = Depends(get_current_user),
):
"""Serve a file from the uploads directory.
Requires authentication but no specific role.
"""
# Construct full path
full_path = settings.upload_path / file_path
# Security: ensure path is within uploads directory
try:
full_path = full_path.resolve()
if not str(full_path).startswith(str(settings.upload_path.resolve())):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied",
)
except Exception:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="File not found",
)
# Check if file exists
if not full_path.exists() or not full_path.is_file():
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="File not found",
)
return FileResponse(full_path)
@router.delete("/{file_path:path}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_file(
file_path: str,
user: User = Depends(require_maker),
):
"""Delete a file from the uploads directory.
Also deletes associated thumbnail if it exists.
"""
# Construct full path
full_path = settings.upload_path / file_path
# Security: ensure path is within uploads directory
try:
full_path = full_path.resolve()
if not str(full_path).startswith(str(settings.upload_path.resolve())):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied",
)
except Exception:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="File not found",
)
# Check if file exists
if not full_path.exists() or not full_path.is_file():
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="File not found",
)
# Delete thumbnail if it exists
if full_path.parent.name != "thumbnails":
thumbnail_path = full_path.parent / "thumbnails" / full_path.name
if thumbnail_path.exists():
thumbnail_path.unlink()
# Delete file
full_path.unlink()
+275
View File
@@ -0,0 +1,275 @@
"""Measurements router - create, query, export measurements."""
import csv
import io
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.responses import StreamingResponse
from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.database import get_db
from src.backend.api.middleware.api_key import (
get_current_user,
require_measurement_tec,
require_metrologist,
)
from src.backend.models.orm.measurement import Measurement
from src.backend.models.orm.recipe import RecipeVersion
from src.backend.models.orm.setting import SystemSetting
from src.backend.models.orm.user import User
from src.backend.models.api.measurement import (
MeasurementBatchCreate,
MeasurementCreate,
MeasurementListResponse,
MeasurementResponse,
)
from src.backend.services.measurement_service import save_measurement
router = APIRouter(prefix="/api/measurements", tags=["measurements"])
@router.post("/", response_model=MeasurementResponse)
async def create_measurement(
data: MeasurementCreate,
user: User = Depends(require_measurement_tec),
db: AsyncSession = Depends(get_db),
):
"""Create a single measurement with auto-calculated pass/fail."""
try:
measurement = await save_measurement(
db=db,
subtask_id=data.subtask_id,
version_id=data.version_id,
measured_by=user.id,
value=data.value,
lot_number=data.lot_number,
serial_number=data.serial_number,
input_method=data.input_method,
)
return MeasurementResponse.model_validate(measurement)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
)
@router.post("/batch", response_model=list[MeasurementResponse])
async def create_measurement_batch(
data: MeasurementBatchCreate,
user: User = Depends(require_measurement_tec),
db: AsyncSession = Depends(get_db),
):
"""Create multiple measurements in a batch."""
measurements = []
try:
for measurement_data in data.measurements:
measurement = await save_measurement(
db=db,
subtask_id=measurement_data.subtask_id,
version_id=measurement_data.version_id,
measured_by=user.id,
value=measurement_data.value,
lot_number=measurement_data.lot_number,
serial_number=measurement_data.serial_number,
input_method=measurement_data.input_method,
)
measurements.append(measurement)
return [MeasurementResponse.model_validate(m) for m in measurements]
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
)
@router.get("/", response_model=MeasurementListResponse)
async def get_measurements(
recipe_id: int | None = Query(None),
version_id: int | None = Query(None),
subtask_id: int | None = Query(None),
measured_by: int | None = Query(None),
lot_number: str | None = Query(None),
serial_number: str | None = Query(None),
date_from: datetime | None = Query(None),
date_to: datetime | None = Query(None),
pass_fail: str | None = Query(None, pattern="^(pass|warning|fail)$"),
page: int = Query(1, ge=1),
per_page: int = Query(50, ge=1, le=500),
user: User = Depends(require_metrologist),
db: AsyncSession = Depends(get_db),
):
"""Query measurements with filters and pagination."""
# Build filter conditions
filters = []
if recipe_id is not None:
# Filter by recipe via subquery on RecipeVersion
version_ids = select(RecipeVersion.id).where(
RecipeVersion.recipe_id == recipe_id
)
filters.append(Measurement.version_id.in_(version_ids))
if version_id is not None:
filters.append(Measurement.version_id == version_id)
if subtask_id is not None:
filters.append(Measurement.subtask_id == subtask_id)
if measured_by is not None:
filters.append(Measurement.measured_by == measured_by)
if lot_number is not None:
filters.append(Measurement.lot_number == lot_number)
if serial_number is not None:
filters.append(Measurement.serial_number == serial_number)
if date_from is not None:
filters.append(Measurement.measured_at >= date_from)
if date_to is not None:
filters.append(Measurement.measured_at <= date_to)
if pass_fail is not None:
filters.append(Measurement.pass_fail == pass_fail)
# Build query
query = select(Measurement).where(and_(*filters) if filters else True)
# Count total
count_query = select(func.count()).select_from(Measurement).where(
and_(*filters) if filters else True
)
total_result = await db.execute(count_query)
total = total_result.scalar_one()
# Paginate
offset = (page - 1) * per_page
query = query.order_by(Measurement.measured_at.desc()).limit(per_page).offset(offset)
result = await db.execute(query)
measurements = result.scalars().all()
pages = (total + per_page - 1) // per_page
return MeasurementListResponse(
items=[MeasurementResponse.model_validate(m) for m in measurements],
total=total,
page=page,
per_page=per_page,
pages=pages,
)
@router.get("/export/csv")
async def export_measurements_csv(
recipe_id: int | None = Query(None),
version_id: int | None = Query(None),
subtask_id: int | None = Query(None),
measured_by: int | None = Query(None),
lot_number: str | None = Query(None),
serial_number: str | None = Query(None),
date_from: datetime | None = Query(None),
date_to: datetime | None = Query(None),
pass_fail: str | None = Query(None, pattern="^(pass|warning|fail)$"),
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Export measurements to CSV with configurable delimiter and decimal separator."""
# Check user has MeasurementTec or Metrologist role
if not (user.has_role("MeasurementTec") or user.has_role("Metrologist")):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="MeasurementTec or Metrologist role required",
)
# Get CSV settings from system_settings
delimiter_result = await db.execute(
select(SystemSetting).where(SystemSetting.setting_key == "csv_delimiter")
)
delimiter_setting = delimiter_result.scalar_one_or_none()
delimiter = delimiter_setting.setting_value if delimiter_setting else ","
decimal_result = await db.execute(
select(SystemSetting).where(SystemSetting.setting_key == "csv_decimal_separator")
)
decimal_setting = decimal_result.scalar_one_or_none()
decimal_separator = decimal_setting.setting_value if decimal_setting else "."
# Build filter conditions (same as get_measurements)
filters = []
if recipe_id is not None:
version_ids = select(RecipeVersion.id).where(
RecipeVersion.recipe_id == recipe_id
)
filters.append(Measurement.version_id.in_(version_ids))
if version_id is not None:
filters.append(Measurement.version_id == version_id)
if subtask_id is not None:
filters.append(Measurement.subtask_id == subtask_id)
if measured_by is not None:
filters.append(Measurement.measured_by == measured_by)
if lot_number is not None:
filters.append(Measurement.lot_number == lot_number)
if serial_number is not None:
filters.append(Measurement.serial_number == serial_number)
if date_from is not None:
filters.append(Measurement.measured_at >= date_from)
if date_to is not None:
filters.append(Measurement.measured_at <= date_to)
if pass_fail is not None:
filters.append(Measurement.pass_fail == pass_fail)
# Query all matching measurements
query = select(Measurement).where(and_(*filters) if filters else True)
query = query.order_by(Measurement.measured_at.desc())
result = await db.execute(query)
measurements = result.scalars().all()
# Generate CSV
output = io.StringIO()
writer = csv.writer(output, delimiter=delimiter)
# Header
writer.writerow([
"id",
"subtask_id",
"version_id",
"measured_by",
"value",
"pass_fail",
"deviation",
"lot_number",
"serial_number",
"input_method",
"measured_at",
"synced_to_csv",
])
# Rows
for m in measurements:
# Format decimals with configured separator
value_str = str(m.value).replace(".", decimal_separator)
deviation_str = (
str(m.deviation).replace(".", decimal_separator)
if m.deviation is not None
else ""
)
writer.writerow([
m.id,
m.subtask_id,
m.version_id,
m.measured_by,
value_str,
m.pass_fail,
deviation_str,
m.lot_number or "",
m.serial_number or "",
m.input_method,
m.measured_at.isoformat(),
m.synced_to_csv,
])
# Return as streaming response
output.seek(0)
return StreamingResponse(
iter([output.getvalue()]),
media_type="text/csv",
headers={
"Content-Disposition": f"attachment; filename=measurements_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
},
)
+166
View File
@@ -0,0 +1,166 @@
"""Recipe router - CRUD, versioning, barcode lookup."""
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.database import get_db
from src.backend.api.middleware.api_key import get_current_user, require_maker, require_measurement_tec
from src.backend.models.orm.user import User
from src.backend.models.api.recipe import (
RecipeCreate,
RecipeListResponse,
RecipeResponse,
RecipeUpdate,
RecipeVersionResponse,
)
from src.backend.services import recipe_service
router = APIRouter(prefix="/api/recipes", tags=["recipes"])
# ---------------------------------------------------------------------------
# Helper: build RecipeResponse with current_version attached
# ---------------------------------------------------------------------------
def _recipe_to_response(recipe) -> RecipeResponse:
"""Convert a Recipe ORM object to RecipeResponse, injecting current_version."""
current = None
for v in (recipe.versions or []):
if v.is_current:
current = v
break
resp = RecipeResponse.model_validate(recipe)
if current is not None:
resp.current_version = RecipeVersionResponse.model_validate(current)
return resp
# ---------------------------------------------------------------------------
# Recipes CRUD
# ---------------------------------------------------------------------------
@router.post("", response_model=RecipeResponse, status_code=201)
async def create_recipe(
data: RecipeCreate,
user: User = Depends(require_maker),
db: AsyncSession = Depends(get_db),
):
"""Create a new recipe with its initial version (v1). Requires Maker role."""
recipe = await recipe_service.create_recipe(db, data, user)
return _recipe_to_response(recipe)
@router.get("", response_model=RecipeListResponse)
async def list_recipes(
page: int = Query(1, ge=1),
per_page: int = Query(20, ge=1, le=100),
search: str | None = Query(None, max_length=200),
_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""List active recipes with pagination and optional search. All authenticated users."""
result = await recipe_service.list_recipes(db, page=page, per_page=per_page, search=search)
result["items"] = [_recipe_to_response(r) for r in result["items"]]
return result
@router.get("/code/{code}", response_model=RecipeResponse)
async def get_recipe_by_code(
code: str,
_user: User = Depends(require_measurement_tec),
db: AsyncSession = Depends(get_db),
):
"""Look up a recipe by its barcode/code. Requires MeasurementTec role."""
recipe = await recipe_service.get_recipe_by_code(db, code)
return _recipe_to_response(recipe)
@router.get("/{recipe_id}", response_model=RecipeResponse)
async def get_recipe(
recipe_id: int,
_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Get recipe detail with current version and all tasks/subtasks."""
recipe = await recipe_service.get_recipe_detail(db, recipe_id)
return _recipe_to_response(recipe)
@router.put("/{recipe_id}", response_model=RecipeResponse)
async def update_recipe(
recipe_id: int,
data: RecipeUpdate,
user: User = Depends(require_maker),
db: AsyncSession = Depends(get_db),
):
"""Update a recipe. Creates a new version only if the current one has measurements.
Otherwise updates in-place. Requires Maker role.
"""
current = await recipe_service.get_current_version(db, recipe_id)
has_measurements = await recipe_service.version_has_measurements(db, current.id)
if has_measurements:
# Copy-on-write: create new version to preserve measurement data
await recipe_service.create_new_version(db, recipe_id, data, user)
else:
# No measurements yet: update in-place
await recipe_service.update_current_version(db, recipe_id, data)
recipe = await recipe_service.get_recipe_detail(db, recipe_id)
return _recipe_to_response(recipe)
@router.delete("/{recipe_id}", response_model=RecipeResponse)
async def delete_recipe(
recipe_id: int,
user: User = Depends(require_maker),
db: AsyncSession = Depends(get_db),
):
"""Deactivate a recipe (soft delete). Requires Maker role."""
recipe = await recipe_service.deactivate_recipe(db, recipe_id, user)
return RecipeResponse.model_validate(recipe)
# ---------------------------------------------------------------------------
# Versions
# ---------------------------------------------------------------------------
@router.get("/{recipe_id}/versions", response_model=list[RecipeVersionResponse])
async def list_versions(
recipe_id: int,
_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""List all versions of a recipe. Requires Maker or Metrologist role."""
versions = await recipe_service.list_versions(db, recipe_id)
return [RecipeVersionResponse.model_validate(v) for v in versions]
@router.get(
"/{recipe_id}/versions/{version_number}",
response_model=RecipeVersionResponse,
)
async def get_version(
recipe_id: int,
version_number: int,
_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Get a specific version with its tasks. Requires Maker or Metrologist role."""
version = await recipe_service.get_version_detail(db, recipe_id, version_number)
return RecipeVersionResponse.model_validate(version)
@router.get("/{recipe_id}/versions/{version_number}/measurement-count")
async def get_measurement_count(
recipe_id: int,
version_number: int,
_user: User = Depends(require_maker),
db: AsyncSession = Depends(get_db),
):
"""Count measurements on a specific version. Requires Maker role.
Useful to warn before creating a new version if the current one has measurements.
"""
count = await recipe_service.get_measurement_count(db, recipe_id, version_number)
return {"recipe_id": recipe_id, "version_number": version_number, "measurement_count": count}
+81
View File
@@ -0,0 +1,81 @@
"""Reports router — PDF report generation endpoints for Metrologist."""
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.responses import Response
from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.database import get_db
from src.backend.api.middleware.api_key import require_metrologist
from src.backend.models.orm.user import User
from src.backend.services.report_service import generate_measurement_report, generate_spc_report
router = APIRouter(prefix="/api/reports", tags=["reports"])
@router.get("/spc")
async def download_spc_report(
recipe_id: int = Query(...),
subtask_id: int = Query(...),
version_id: int | None = Query(None),
date_from: datetime | None = Query(None),
date_to: datetime | None = Query(None),
operator_id: int | None = Query(None),
lot_number: str | None = Query(None),
serial_number: str | None = Query(None),
user: User = Depends(require_metrologist),
db: AsyncSession = Depends(get_db),
) -> Response:
"""Generate and download SPC PDF report."""
try:
pdf_bytes = await generate_spc_report(
db, recipe_id, subtask_id,
version_id, date_from, date_to,
operator_id, lot_number, serial_number,
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Report generation failed: {str(e)}",
)
filename = f"spc_report_recipe{recipe_id}_st{subtask_id}.pdf"
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.get("/measurements")
async def download_measurement_report(
recipe_id: int = Query(...),
subtask_id: int | None = Query(None),
version_id: int | None = Query(None),
date_from: datetime | None = Query(None),
date_to: datetime | None = Query(None),
operator_id: int | None = Query(None),
lot_number: str | None = Query(None),
serial_number: str | None = Query(None),
user: User = Depends(require_metrologist),
db: AsyncSession = Depends(get_db),
) -> Response:
"""Generate and download measurement table PDF report."""
try:
pdf_bytes = await generate_measurement_report(
db, recipe_id, subtask_id,
version_id, date_from, date_to,
operator_id, lot_number, serial_number,
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Report generation failed: {str(e)}",
)
filename = f"measurements_report_recipe{recipe_id}.pdf"
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
+153
View File
@@ -0,0 +1,153 @@
"""System settings router - read/update settings, upload company logo."""
from pathlib import Path
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.config import settings
from src.backend.database import get_db
from src.backend.api.middleware.api_key import get_current_user, require_admin_user
from src.backend.models.orm.setting import SystemSetting
from src.backend.models.orm.user import User
router = APIRouter(prefix="/api/settings", tags=["settings"])
@router.get("/")
async def get_settings(
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Get all system settings as a dictionary.
Returns key-value pairs of all settings.
Available to all authenticated users.
"""
result = await db.execute(select(SystemSetting))
settings_list = result.scalars().all()
settings_dict = {
setting.setting_key: setting.setting_value
for setting in settings_list
}
return settings_dict
@router.put("/")
async def update_settings(
settings_data: dict[str, str],
user: User = Depends(require_admin_user),
db: AsyncSession = Depends(get_db),
):
"""Update multiple system settings.
Expects a JSON object with setting_key: setting_value pairs.
Only admins can update settings.
"""
updated_keys = []
for key, value in settings_data.items():
# Check if setting exists
result = await db.execute(
select(SystemSetting).where(SystemSetting.setting_key == key)
)
setting = result.scalar_one_or_none()
if setting is None:
# Create new setting (assume type 'string' for new settings)
setting = SystemSetting(
setting_key=key,
setting_value=value,
setting_type="string",
updated_by=user.id,
)
db.add(setting)
else:
# Update existing setting
setting.setting_value = value
setting.updated_by = user.id
updated_keys.append(key)
await db.flush()
return {
"message": f"Updated {len(updated_keys)} settings",
"updated_keys": updated_keys,
}
@router.post("/logo")
async def upload_company_logo(
file: UploadFile = File(...),
user: User = Depends(require_admin_user),
db: AsyncSession = Depends(get_db),
):
"""Upload company logo.
Saves logo to uploads/logos/ and updates company_logo_path setting.
Only admins can upload the logo.
"""
# Validate file type (must be image)
allowed_types = {"image/jpeg", "image/png", "image/gif", "image/webp", "image/svg+xml"}
if file.content_type not in allowed_types:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"File type {file.content_type} not allowed. Must be an image.",
)
# Read file content
content = await file.read()
file_size = len(content)
# Validate file size
max_bytes = settings.max_upload_size_mb * 1024 * 1024
if file_size > max_bytes:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"File size {file_size} bytes exceeds maximum {settings.max_upload_size_mb}MB",
)
# Create logos directory
logos_dir = settings.upload_path / "logos"
logos_dir.mkdir(parents=True, exist_ok=True)
# Use a fixed filename for the company logo
file_extension = Path(file.filename).suffix
logo_filename = f"company_logo{file_extension}"
logo_path = logos_dir / logo_filename
# Write file
logo_path.write_bytes(content)
# Update setting in database
relative_path = logo_path.relative_to(settings.upload_path)
logo_path_str = str(relative_path).replace("\\", "/")
result = await db.execute(
select(SystemSetting).where(SystemSetting.setting_key == "company_logo_path")
)
setting = result.scalar_one_or_none()
if setting is None:
setting = SystemSetting(
setting_key="company_logo_path",
setting_value=logo_path_str,
setting_type="string",
description="Path to company logo file",
updated_by=user.id,
)
db.add(setting)
else:
setting.setting_value = logo_path_str
setting.updated_by = user.id
await db.flush()
return {
"message": "Company logo uploaded successfully",
"logo_path": logo_path_str,
"file_size": file_size,
}
+678
View File
@@ -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"}
+142
View File
@@ -0,0 +1,142 @@
"""Stations router - CRUD + recipe assignments."""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.database import get_db
from src.backend.api.middleware.api_key import get_current_user, require_admin_user
from src.backend.models.orm.user import User
from src.backend.models.api.station import (
StationCreate,
StationUpdate,
StationResponse,
StationRecipeAssignmentCreate,
StationRecipeAssignmentResponse,
RecipeSummary,
)
from src.backend.services import station_service
router = APIRouter(prefix="/api/stations", tags=["stations"])
@router.get("", response_model=list[StationResponse])
async def list_stations(
active_only: bool = False,
admin: User = Depends(require_admin_user),
db: AsyncSession = Depends(get_db),
):
"""List all stations (admin only)."""
stations = await station_service.list_stations(db, active_only=active_only)
return [StationResponse.model_validate(s) for s in stations]
@router.post("", response_model=StationResponse, status_code=status.HTTP_201_CREATED)
async def create_new_station(
data: StationCreate,
admin: User = Depends(require_admin_user),
db: AsyncSession = Depends(get_db),
):
"""Create a station (admin only)."""
station = await station_service.create_station(db, data, admin)
return StationResponse.model_validate(station)
# NOTE: this literal-prefix route must stay above the /{station_id} routes.
# The int-typed station_id param already guards against "by-code" being
# matched as a station id, but keeping the explicit order avoids surprises
# during refactors (e.g. if someone regroups handlers by HTTP method).
@router.get("/by-code/{code}/recipes", response_model=list[RecipeSummary])
async def list_recipes_by_station_code(
code: str,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Operator view: active recipes assigned to the station with this code.
Used by the Flask client at startup / on select_recipe page.
Any authenticated user can call this; filtering is by station code from
the client's STATION_CODE environment variable.
"""
station = await station_service.get_station_by_code(db, code)
if station is None or not station.active:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Station not found",
)
recipes = await station_service.list_station_recipes(db, station.id)
return [RecipeSummary.model_validate(r) for r in recipes]
@router.get("/{station_id}", response_model=StationResponse)
async def get_single_station(
station_id: int,
admin: User = Depends(require_admin_user),
db: AsyncSession = Depends(get_db),
):
"""Get a station by id (admin only)."""
station = await station_service.get_station(db, station_id)
return StationResponse.model_validate(station)
@router.put("/{station_id}", response_model=StationResponse)
async def update_existing_station(
station_id: int,
data: StationUpdate,
admin: User = Depends(require_admin_user),
db: AsyncSession = Depends(get_db),
):
"""Update a station (admin only)."""
station = await station_service.update_station(db, station_id, data)
return StationResponse.model_validate(station)
@router.delete("/{station_id}", status_code=status.HTTP_204_NO_CONTENT)
async def remove_station(
station_id: int,
admin: User = Depends(require_admin_user),
db: AsyncSession = Depends(get_db),
):
"""Delete a station (admin only). Cascades to assignments."""
await station_service.delete_station(db, station_id)
@router.get("/{station_id}/recipes", response_model=list[RecipeSummary])
async def list_assigned_recipes(
station_id: int,
admin: User = Depends(require_admin_user),
db: AsyncSession = Depends(get_db),
):
"""Admin view: recipes assigned to this station (active only)."""
recipes = await station_service.list_station_recipes(db, station_id)
return [RecipeSummary.model_validate(r) for r in recipes]
@router.post(
"/{station_id}/recipes",
response_model=StationRecipeAssignmentResponse,
status_code=status.HTTP_201_CREATED,
)
async def assign_recipe_to_station(
station_id: int,
data: StationRecipeAssignmentCreate,
admin: User = Depends(require_admin_user),
db: AsyncSession = Depends(get_db),
):
"""Assign a recipe to a station (admin only)."""
assignment = await station_service.assign_recipe(
db, station_id, data.recipe_id, admin,
)
return StationRecipeAssignmentResponse.model_validate(assignment)
@router.delete(
"/{station_id}/recipes/{recipe_id}",
status_code=status.HTTP_204_NO_CONTENT,
)
async def unassign_recipe_from_station(
station_id: int,
recipe_id: int,
admin: User = Depends(require_admin_user),
db: AsyncSession = Depends(get_db),
):
"""Remove a recipe assignment (admin only)."""
await station_service.unassign_recipe(db, station_id, recipe_id)
+236
View File
@@ -0,0 +1,236 @@
"""Statistics router - SPC endpoints for Metrologist dashboard."""
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import and_, select
from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.database import get_db
from src.backend.api.middleware.api_key import require_metrologist
from src.backend.models.orm.measurement import Measurement
from src.backend.models.orm.recipe import RecipeVersion
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
from src.backend.models.orm.user import User
from src.backend.models.api.statistics import (
CapabilityData,
ControlChartData,
HistogramData,
SummaryData,
)
from src.backend.services.spc_service import (
compute_capability,
compute_control_chart,
compute_histogram,
compute_summary,
)
router = APIRouter(prefix="/api/statistics", tags=["statistics"])
async def _query_measurements(
db: AsyncSession,
recipe_id: int,
version_id: int | None = None,
subtask_id: int | None = None,
date_from: datetime | None = None,
date_to: datetime | None = None,
operator_id: int | None = None,
lot_number: str | None = None,
serial_number: str | None = None,
) -> list[Measurement]:
"""Query measurements with common filters.
Returns list of Measurement ORM objects ordered by measured_at.
"""
filters = []
# recipe_id filter via RecipeVersion subquery
if version_id is not None:
filters.append(Measurement.version_id == version_id)
else:
version_ids = select(RecipeVersion.id).where(
RecipeVersion.recipe_id == recipe_id
)
filters.append(Measurement.version_id.in_(version_ids))
if subtask_id is not None:
filters.append(Measurement.subtask_id == subtask_id)
if date_from is not None:
filters.append(Measurement.measured_at >= date_from)
if date_to is not None:
filters.append(Measurement.measured_at <= date_to)
if operator_id is not None:
filters.append(Measurement.measured_by == operator_id)
if lot_number is not None:
filters.append(Measurement.lot_number == lot_number)
if serial_number is not None:
filters.append(Measurement.serial_number == serial_number)
query = (
select(Measurement)
.where(and_(*filters) if filters else True)
.order_by(Measurement.measured_at.asc())
)
result = await db.execute(query)
return list(result.scalars().all())
async def _get_subtask_tolerances(
db: AsyncSession,
subtask_id: int,
) -> dict:
"""Get tolerance limits for a specific subtask."""
result = await db.execute(
select(RecipeSubtask).where(RecipeSubtask.id == subtask_id)
)
subtask = result.scalar_one_or_none()
if subtask is None:
return {"utl": None, "uwl": None, "lwl": None, "ltl": None, "nominal": None}
return {
"utl": float(subtask.utl) if subtask.utl is not None else None,
"uwl": float(subtask.uwl) if subtask.uwl is not None else None,
"lwl": float(subtask.lwl) if subtask.lwl is not None else None,
"ltl": float(subtask.ltl) if subtask.ltl is not None else None,
"nominal": float(subtask.nominal) if subtask.nominal is not None else None,
}
@router.get("/summary", response_model=SummaryData)
async def get_summary(
recipe_id: int = Query(...),
version_id: int | None = Query(None),
subtask_id: int | None = Query(None),
date_from: datetime | None = Query(None),
date_to: datetime | None = Query(None),
operator_id: int | None = Query(None),
lot_number: str | None = Query(None),
serial_number: str | None = Query(None),
user: User = Depends(require_metrologist),
db: AsyncSession = Depends(get_db),
) -> SummaryData:
"""Get pass/fail/warning summary for filtered measurements."""
measurements = await _query_measurements(
db, recipe_id, version_id, subtask_id,
date_from, date_to, operator_id, lot_number, serial_number,
)
pass_fail_values = [m.pass_fail for m in measurements]
return compute_summary(pass_fail_values)
@router.get("/capability", response_model=CapabilityData)
async def get_capability(
recipe_id: int = Query(...),
subtask_id: int = Query(...),
version_id: int | None = Query(None),
date_from: datetime | None = Query(None),
date_to: datetime | None = Query(None),
operator_id: int | None = Query(None),
lot_number: str | None = Query(None),
serial_number: str | None = Query(None),
user: User = Depends(require_metrologist),
db: AsyncSession = Depends(get_db),
) -> CapabilityData:
"""Get capability indices (Cp/Cpk/Pp/Ppk) for a specific subtask."""
measurements = await _query_measurements(
db, recipe_id, version_id, subtask_id,
date_from, date_to, operator_id, lot_number, serial_number,
)
values = [float(m.value) for m in measurements]
tol = await _get_subtask_tolerances(db, subtask_id)
return compute_capability(values, tol["utl"], tol["ltl"], tol["nominal"])
@router.get("/control-chart", response_model=ControlChartData)
async def get_control_chart(
recipe_id: int = Query(...),
subtask_id: int = Query(...),
version_id: int | None = Query(None),
date_from: datetime | None = Query(None),
date_to: datetime | None = Query(None),
operator_id: int | None = Query(None),
lot_number: str | None = Query(None),
serial_number: str | None = Query(None),
user: User = Depends(require_metrologist),
db: AsyncSession = Depends(get_db),
) -> ControlChartData:
"""Get control chart data with UCL/LCL and OOC detection."""
measurements = await _query_measurements(
db, recipe_id, version_id, subtask_id,
date_from, date_to, operator_id, lot_number, serial_number,
)
values = [float(m.value) for m in measurements]
timestamps = [m.measured_at for m in measurements]
tol = await _get_subtask_tolerances(db, subtask_id)
return compute_control_chart(
values, timestamps,
tol["utl"], tol["uwl"], tol["lwl"], tol["ltl"], tol["nominal"],
)
@router.get("/histogram", response_model=HistogramData)
async def get_histogram(
recipe_id: int = Query(...),
subtask_id: int = Query(...),
version_id: int | None = Query(None),
date_from: datetime | None = Query(None),
date_to: datetime | None = Query(None),
operator_id: int | None = Query(None),
lot_number: str | None = Query(None),
serial_number: str | None = Query(None),
n_bins: int = Query(20, ge=5, le=100),
user: User = Depends(require_metrologist),
db: AsyncSession = Depends(get_db),
) -> HistogramData:
"""Get histogram data with normal curve overlay."""
measurements = await _query_measurements(
db, recipe_id, version_id, subtask_id,
date_from, date_to, operator_id, lot_number, serial_number,
)
values = [float(m.value) for m in measurements]
return compute_histogram(values, n_bins)
@router.get("/subtasks")
async def get_recipe_subtasks(
recipe_id: int = Query(...),
version_id: int | None = Query(None),
user: User = Depends(require_metrologist),
db: AsyncSession = Depends(get_db),
) -> list[dict]:
"""Get subtasks for a recipe (for filter dropdown).
Returns subtask id, marker_number, description, and parent task title.
"""
# Get version_id: use provided or find current version
if version_id is None:
ver_result = await db.execute(
select(RecipeVersion.id).where(
RecipeVersion.recipe_id == recipe_id,
RecipeVersion.is_current == True,
)
)
version_id = ver_result.scalar_one_or_none()
if version_id is None:
return []
# Get tasks and subtasks for this version
tasks_result = await db.execute(
select(RecipeTask).where(RecipeTask.version_id == version_id)
.order_by(RecipeTask.order_index)
)
tasks = tasks_result.scalars().all()
subtasks_list = []
for task in tasks:
for st in sorted(task.subtasks, key=lambda s: s.marker_number):
subtasks_list.append({
"id": st.id,
"marker_number": st.marker_number,
"description": st.description,
"task_title": task.title,
"nominal": float(st.nominal) if st.nominal is not None else None,
"utl": float(st.utl) if st.utl is not None else None,
"ltl": float(st.ltl) if st.ltl is not None else None,
})
return subtasks_list
+392
View File
@@ -0,0 +1,392 @@
"""Task and Subtask router - CRUD, reorder, copy-on-write integration."""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from src.backend.database import get_db
from src.backend.api.middleware.api_key import require_maker, get_current_user
from src.backend.models.orm.recipe import Recipe, RecipeVersion
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
from src.backend.models.orm.user import User
from src.backend.models.api.task import (
SubtaskCreate,
SubtaskResponse,
SubtaskUpdate,
TaskCreate,
TaskReorderRequest,
TaskResponse,
TaskUpdate,
)
from src.backend.services import recipe_service
router = APIRouter(tags=["tasks"])
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
async def _get_current_version_for_recipe(
db: AsyncSession, recipe_id: int
) -> RecipeVersion:
"""Return the current version of a recipe or raise 404."""
result = await db.execute(
select(RecipeVersion)
.where(
RecipeVersion.recipe_id == recipe_id,
RecipeVersion.is_current == True, # noqa: E712
)
.options(
selectinload(RecipeVersion.tasks).selectinload(RecipeTask.subtasks)
)
)
version = result.scalar_one_or_none()
if version is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No current version found for this recipe",
)
return version
async def _get_task_or_404(db: AsyncSession, task_id: int) -> RecipeTask:
"""Return a task with its subtasks or raise 404."""
result = await db.execute(
select(RecipeTask)
.where(RecipeTask.id == task_id)
.options(
selectinload(RecipeTask.subtasks),
selectinload(RecipeTask.version),
)
)
task = result.scalar_one_or_none()
if task is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Task not found"
)
return task
async def _get_subtask_or_404(db: AsyncSession, subtask_id: int) -> RecipeSubtask:
"""Return a subtask or raise 404."""
result = await db.execute(
select(RecipeSubtask).where(RecipeSubtask.id == subtask_id)
)
subtask = result.scalar_one_or_none()
if subtask is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Subtask not found"
)
return subtask
async def _ensure_version_is_current(db: AsyncSession, version_id: int) -> RecipeVersion:
"""Verify a version is the current one (editable). Raise 409 otherwise."""
result = await db.execute(
select(RecipeVersion).where(RecipeVersion.id == version_id)
)
version = result.scalar_one_or_none()
if version is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Version not found"
)
if not version.is_current:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Cannot modify a non-current version. Create a new version first.",
)
return version
# ---------------------------------------------------------------------------
# Task endpoints
# ---------------------------------------------------------------------------
@router.get("/api/recipes/{recipe_id}/tasks", response_model=list[TaskResponse])
async def list_tasks(
recipe_id: int,
_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""List all tasks for the current version of a recipe. All authenticated users."""
version = await _get_current_version_for_recipe(db, recipe_id)
tasks = sorted(version.tasks, key=lambda t: t.order_index)
return [TaskResponse.model_validate(t) for t in tasks]
@router.get("/api/tasks/{task_id}", response_model=TaskResponse)
async def get_task(
task_id: int,
_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Get a single task with its subtasks. All authenticated users."""
task = await _get_task_or_404(db, task_id)
return TaskResponse.model_validate(task)
@router.post(
"/api/recipes/{recipe_id}/tasks",
response_model=TaskResponse,
status_code=201,
)
async def create_task(
recipe_id: int,
data: TaskCreate,
user: User = Depends(require_maker),
db: AsyncSession = Depends(get_db),
):
"""Add a task to the current version of a recipe.
This creates a NEW version via copy-on-write, then appends the task to
the new version. Requires Maker role.
"""
# Ensure recipe exists and is active
result = await db.execute(select(Recipe).where(Recipe.id == recipe_id))
recipe = result.scalar_one_or_none()
if recipe is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Recipe not found")
if not recipe.active:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Cannot modify an inactive recipe",
)
# Check if current version has measurements
current = await recipe_service.get_current_version(db, recipe_id)
has_measurements = await recipe_service.version_has_measurements(db, current.id)
if has_measurements:
# Copy-on-write: create new version preserving measurement data
from src.backend.models.api.recipe import RecipeUpdate
new_version = await recipe_service.create_new_version(
db, recipe_id, RecipeUpdate(change_notes=f"Added task: {data.title}"), user
)
else:
# No measurements: use current version directly
new_version = current
# Determine order_index (append at end)
max_order = max((t.order_index for t in new_version.tasks), default=-1)
new_task = RecipeTask(
version_id=new_version.id,
order_index=max_order + 1,
title=data.title,
directive=data.directive,
description=data.description,
file_path=data.file_path,
file_type=data.file_type,
annotations_json=data.annotations_json,
)
db.add(new_task)
await db.flush()
# Create subtasks if provided
for sub_data in data.subtasks:
sub = RecipeSubtask(
task_id=new_task.id,
marker_number=sub_data.marker_number,
description=sub_data.description,
measurement_type=sub_data.measurement_type,
nominal=sub_data.nominal,
utl=sub_data.utl,
uwl=sub_data.uwl,
lwl=sub_data.lwl,
ltl=sub_data.ltl,
unit=sub_data.unit,
image_path=sub_data.image_path,
)
db.add(sub)
await db.flush()
await db.refresh(new_task, attribute_names=["subtasks"])
return TaskResponse.model_validate(new_task)
@router.put("/api/tasks/reorder", response_model=list[TaskResponse])
async def reorder_tasks(
data: TaskReorderRequest,
_user: User = Depends(require_maker),
db: AsyncSession = Depends(get_db),
):
"""Reorder tasks by providing ordered task IDs. All tasks must belong to the
same current version. Requires Maker role.
"""
if not data.task_ids:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="task_ids must not be empty",
)
# Fetch all tasks
result = await db.execute(
select(RecipeTask)
.where(RecipeTask.id.in_(data.task_ids))
.options(selectinload(RecipeTask.subtasks))
)
tasks_map = {t.id: t for t in result.scalars().all()}
# Validate all ids found
for tid in data.task_ids:
if tid not in tasks_map:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Task {tid} not found",
)
# Validate all belong to the same version and version is current
version_ids = {t.version_id for t in tasks_map.values()}
if len(version_ids) != 1:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="All tasks must belong to the same version",
)
await _ensure_version_is_current(db, version_ids.pop())
# Apply new order
for idx, tid in enumerate(data.task_ids):
tasks_map[tid].order_index = idx
await db.flush()
ordered = [tasks_map[tid] for tid in data.task_ids]
return [TaskResponse.model_validate(t) for t in ordered]
@router.put("/api/tasks/{task_id}", response_model=TaskResponse)
async def update_task(
task_id: int,
data: TaskUpdate,
_user: User = Depends(require_maker),
db: AsyncSession = Depends(get_db),
):
"""Update a task. The task must belong to the current version. Requires Maker role."""
task = await _get_task_or_404(db, task_id)
await _ensure_version_is_current(db, task.version_id)
update_data = data.model_dump(exclude_unset=True)
if not update_data:
return TaskResponse.model_validate(task)
for field, value in update_data.items():
setattr(task, field, value)
await db.flush()
await db.refresh(task, attribute_names=["subtasks"])
return TaskResponse.model_validate(task)
@router.delete("/api/tasks/{task_id}", status_code=204)
async def delete_task(
task_id: int,
_user: User = Depends(require_maker),
db: AsyncSession = Depends(get_db),
):
"""Delete a task and its subtasks. The task must belong to the current version.
Requires Maker role.
"""
task = await _get_task_or_404(db, task_id)
await _ensure_version_is_current(db, task.version_id)
await db.delete(task)
await db.flush()
# ---------------------------------------------------------------------------
# Subtask endpoints
# ---------------------------------------------------------------------------
@router.post(
"/api/tasks/{task_id}/subtasks",
response_model=SubtaskResponse,
status_code=201,
)
async def create_subtask(
task_id: int,
data: SubtaskCreate,
_user: User = Depends(require_maker),
db: AsyncSession = Depends(get_db),
):
"""Add a subtask to a task. The task must belong to the current version.
Requires Maker role.
"""
task = await _get_task_or_404(db, task_id)
await _ensure_version_is_current(db, task.version_id)
# Check marker_number uniqueness within the task
existing_markers = {s.marker_number for s in task.subtasks}
if data.marker_number in existing_markers:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Marker number {data.marker_number} already exists on this task",
)
subtask = RecipeSubtask(
task_id=task_id,
marker_number=data.marker_number,
description=data.description,
measurement_type=data.measurement_type,
nominal=data.nominal,
utl=data.utl,
uwl=data.uwl,
lwl=data.lwl,
ltl=data.ltl,
unit=data.unit,
image_path=data.image_path,
)
db.add(subtask)
await db.flush()
await db.refresh(subtask)
return SubtaskResponse.model_validate(subtask)
@router.put("/api/subtasks/{subtask_id}", response_model=SubtaskResponse)
async def update_subtask(
subtask_id: int,
data: SubtaskUpdate,
_user: User = Depends(require_maker),
db: AsyncSession = Depends(get_db),
):
"""Update a subtask. Its parent task must belong to the current version.
Requires Maker role.
"""
subtask = await _get_subtask_or_404(db, subtask_id)
# Validate task belongs to current version
task = await _get_task_or_404(db, subtask.task_id)
await _ensure_version_is_current(db, task.version_id)
update_data = data.model_dump(exclude_unset=True)
if not update_data:
return SubtaskResponse.model_validate(subtask)
for field, value in update_data.items():
setattr(subtask, field, value)
await db.flush()
await db.refresh(subtask)
return SubtaskResponse.model_validate(subtask)
@router.delete("/api/subtasks/{subtask_id}", status_code=204)
async def delete_subtask(
subtask_id: int,
_user: User = Depends(require_maker),
db: AsyncSession = Depends(get_db),
):
"""Delete a subtask. Its parent task must belong to the current version.
Requires Maker role.
"""
subtask = await _get_subtask_or_404(db, subtask_id)
# Validate task belongs to current version
task = await _get_task_or_404(db, subtask.task_id)
await _ensure_version_is_current(db, task.version_id)
await db.delete(subtask)
await db.flush()
+144
View File
@@ -0,0 +1,144 @@
"""Users router - CRUD operations (admin only)."""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.database import get_db
from src.backend.api.middleware.api_key import require_admin_user
from src.backend.models.orm.user import User
from src.backend.models.api.user import UserCreate, UserPasswordChange, UserResponse, UserUpdate
from src.backend.services.auth_service import create_user, hash_password, regenerate_api_key
router = APIRouter(prefix="/api/users", tags=["users"])
@router.get("", response_model=list[UserResponse])
async def list_users(
admin: User = Depends(require_admin_user),
db: AsyncSession = Depends(get_db),
):
"""List all users (admin only)."""
result = await db.execute(select(User).order_by(User.username))
users = result.scalars().all()
return [UserResponse.model_validate(u) for u in users]
@router.post("", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
async def create_new_user(
data: UserCreate,
admin: User = Depends(require_admin_user),
db: AsyncSession = Depends(get_db),
):
"""Create a new user (admin only)."""
# Check username uniqueness
existing = await db.execute(
select(User).where(User.username == data.username)
)
if existing.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Username '{data.username}' already exists",
)
# Validate roles
valid_roles = {"Maker", "MeasurementTec", "Metrologist"}
for role in data.roles:
if role not in valid_roles:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Invalid role '{role}'. Valid roles: {valid_roles}",
)
user = await create_user(
db,
username=data.username,
password=data.password,
display_name=data.display_name,
email=data.email,
roles=data.roles,
is_admin=data.is_admin,
language_pref=data.language_pref,
theme_pref=data.theme_pref,
)
return UserResponse.model_validate(user)
@router.put("/{user_id}", response_model=UserResponse)
async def update_user(
user_id: int,
data: UserUpdate,
admin: User = Depends(require_admin_user),
db: AsyncSession = Depends(get_db),
):
"""Update user (admin only)."""
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
update_data = data.model_dump(exclude_unset=True)
# Validate roles if provided
if "roles" in update_data:
valid_roles = {"Maker", "MeasurementTec", "Metrologist"}
for role in update_data["roles"]:
if role not in valid_roles:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Invalid role '{role}'. Valid roles: {valid_roles}",
)
for field, value in update_data.items():
setattr(user, field, value)
await db.flush()
await db.refresh(user)
return UserResponse.model_validate(user)
@router.put("/{user_id}/password", status_code=status.HTTP_200_OK)
async def change_user_password(
user_id: int,
data: UserPasswordChange,
admin: User = Depends(require_admin_user),
db: AsyncSession = Depends(get_db),
):
"""Change user password (admin only)."""
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
user.password_hash = hash_password(data.password)
await db.flush()
return {"message": f"Password changed for user {user.username}"}
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def deactivate_user(
user_id: int,
admin: User = Depends(require_admin_user),
db: AsyncSession = Depends(get_db),
):
"""Soft-delete user (set active=False)."""
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
if user.id == admin.id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot deactivate yourself",
)
user.active = False
user.api_key = None
await db.flush()
@router.post("/{user_id}/regenerate-key")
async def regenerate_user_key(
user_id: int,
admin: User = Depends(require_admin_user),
db: AsyncSession = Depends(get_db),
):
"""Regenerate API key for a user (admin only)."""
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
new_key = await regenerate_api_key(db, user_id)
return {"api_key": new_key, "message": f"API key regenerated for user {user.username}"}