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,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",
|
||||
]
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
},
|
||||
)
|
||||
@@ -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}
|
||||
@@ -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}"'},
|
||||
)
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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"}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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}"}
|
||||
Reference in New Issue
Block a user