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 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
# Installa dipendenze sistema per WeasyPrint
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libpango-1.0-0 \
|
||||
libpangocairo-1.0-0 \
|
||||
libcairo2 \
|
||||
libgdk-pixbuf-2.0-0 \
|
||||
libffi-dev \
|
||||
shared-mime-info \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
# Crea directory uploads
|
||||
RUN mkdir -p uploads/images uploads/pdfs uploads/logos uploads/reports
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
# Entry point: Alembic upgrade + Uvicorn
|
||||
CMD ["sh", "-c", "alembic -c migrations/alembic.ini upgrade head && uvicorn main:app --host 0.0.0.0 --port 8000 --workers 4 --proxy-headers --forwarded-allow-ips='*'"]
|
||||
@@ -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}"}
|
||||
@@ -0,0 +1,64 @@
|
||||
"""TieMeasureFlow Server Configuration."""
|
||||
from pathlib import Path
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Application settings loaded from environment variables."""
|
||||
|
||||
# Database
|
||||
db_host: str = "localhost"
|
||||
db_port: int = 3306
|
||||
db_name: str = "tiemeasureflow"
|
||||
db_user: str = "tmflow"
|
||||
db_password: str = "change_me_in_production"
|
||||
|
||||
# Server
|
||||
server_host: str = "0.0.0.0"
|
||||
server_port: int = 8000
|
||||
server_secret_key: str = "change-this-to-a-random-secret-key"
|
||||
server_cors_origins: str = "http://localhost:5000"
|
||||
|
||||
# File Storage
|
||||
upload_dir: str = "uploads"
|
||||
max_upload_size_mb: int = 50
|
||||
|
||||
# Rate Limiting (requests per minute, per real client IP)
|
||||
rate_limit_login: int = 5
|
||||
rate_limit_general: int = 300
|
||||
|
||||
# SSL (Production)
|
||||
ssl_certfile: str | None = None
|
||||
ssl_keyfile: str | None = None
|
||||
|
||||
# Setup page (empty = disabled)
|
||||
setup_password: str | None = None
|
||||
|
||||
@property
|
||||
def database_url(self) -> str:
|
||||
"""Async MySQL connection string."""
|
||||
return (
|
||||
f"mysql+asyncmy://{self.db_user}:{self.db_password}"
|
||||
f"@{self.db_host}:{self.db_port}/{self.db_name}"
|
||||
)
|
||||
|
||||
@property
|
||||
def cors_origins(self) -> list[str]:
|
||||
"""Parse CORS origins from comma-separated string."""
|
||||
return [origin.strip() for origin in self.server_cors_origins.split(",")]
|
||||
|
||||
@property
|
||||
def upload_path(self) -> Path:
|
||||
"""Absolute path to upload directory.
|
||||
|
||||
After the V2.0.0 restructure, uploads live at the project root
|
||||
(mounted as a Docker volume), not inside the backend tree.
|
||||
"""
|
||||
# Path(__file__) = src/backend/config.py → parents[2] = project root
|
||||
return Path(__file__).resolve().parents[2] / self.upload_dir
|
||||
|
||||
# ../../.env reaches the project root from src/backend/.
|
||||
model_config = {"env_file": "../../.env", "env_file_encoding": "utf-8", "extra": "ignore"}
|
||||
|
||||
|
||||
settings = Settings()
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Async SQLAlchemy database engine and session management."""
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncSession,
|
||||
async_sessionmaker,
|
||||
create_async_engine,
|
||||
)
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from src.backend.config import settings
|
||||
|
||||
# Create async engine
|
||||
engine = create_async_engine(
|
||||
settings.database_url,
|
||||
echo=False,
|
||||
pool_size=10,
|
||||
max_overflow=20,
|
||||
pool_recycle=3600,
|
||||
)
|
||||
|
||||
# Session factory
|
||||
async_session_factory = async_sessionmaker(
|
||||
engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""Base class for all SQLAlchemy models."""
|
||||
pass
|
||||
|
||||
|
||||
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
"""Dependency for FastAPI - yields an async database session."""
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
async def init_db() -> None:
|
||||
"""Create all tables (dev only - use Alembic in production)."""
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
@@ -0,0 +1,81 @@
|
||||
"""TieMeasureFlow Server - FastAPI Entry Point."""
|
||||
from contextlib import asynccontextmanager
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from src.backend.config import settings
|
||||
from src.backend.database import init_db
|
||||
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
|
||||
from src.backend.api.routers.auth import router as auth_router
|
||||
from src.backend.api.routers.users import router as users_router
|
||||
from src.backend.api.routers.recipes import router as recipes_router
|
||||
from src.backend.api.routers.tasks import router as tasks_router
|
||||
from src.backend.api.routers.measurements import router as measurements_router
|
||||
from src.backend.api.routers.files import router as files_router
|
||||
from src.backend.api.routers.settings import router as settings_router
|
||||
from src.backend.api.routers.reports import router as reports_router
|
||||
from src.backend.api.routers.statistics import router as statistics_router
|
||||
from src.backend.api.routers.setup import router as setup_router
|
||||
from src.backend.api.routers.stations import router as stations_router
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
"""Application lifespan: startup and shutdown events."""
|
||||
# Startup
|
||||
# Ensure upload directories exist
|
||||
for subdir in ["images", "pdfs", "logos", "reports"]:
|
||||
(settings.upload_path / subdir).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
yield
|
||||
|
||||
# Shutdown (cleanup if needed)
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="TieMeasureFlow API",
|
||||
description="API per gestione task misurazioni con calibro manuale",
|
||||
version="0.1.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# Rate limiting middleware (outermost - checked first)
|
||||
app.add_middleware(RateLimitMiddleware)
|
||||
|
||||
# Security headers middleware
|
||||
app.add_middleware(SecurityHeadersMiddleware)
|
||||
|
||||
# CORS middleware
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
allow_headers=["Content-Type", "X-API-Key", "Accept"],
|
||||
)
|
||||
|
||||
# Access logging middleware
|
||||
app.add_middleware(AccessLogMiddleware)
|
||||
|
||||
# Register routers
|
||||
app.include_router(auth_router)
|
||||
app.include_router(users_router)
|
||||
app.include_router(recipes_router)
|
||||
app.include_router(tasks_router)
|
||||
app.include_router(measurements_router)
|
||||
app.include_router(files_router)
|
||||
app.include_router(settings_router)
|
||||
app.include_router(statistics_router)
|
||||
app.include_router(reports_router)
|
||||
app.include_router(setup_router)
|
||||
app.include_router(stations_router)
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health_check() -> dict:
|
||||
"""Health check endpoint."""
|
||||
return {"status": "ok", "service": "TieMeasureFlow API", "version": "0.1.0"}
|
||||
@@ -0,0 +1,36 @@
|
||||
[alembic]
|
||||
script_location = %(here)s
|
||||
sqlalchemy.url = mysql+asyncmy://tmflow:change_me_in_production@localhost:3306/tiemeasureflow
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Alembic environment configuration for async SQLAlchemy."""
|
||||
import asyncio
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy import pool
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
# Alembic Config object
|
||||
config = context.config
|
||||
|
||||
# Logging configuration
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
# Import all models so Alembic can detect them
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add the project root (4 levels up from this file) to sys.path so that
|
||||
# `src.backend.*` imports resolve when alembic is invoked from anywhere.
|
||||
_PROJECT_ROOT = Path(__file__).resolve().parents[3]
|
||||
if str(_PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_PROJECT_ROOT))
|
||||
|
||||
from src.backend.config import settings
|
||||
from src.backend.database import Base
|
||||
|
||||
# Override alembic.ini URL with .env settings (keep in sync)
|
||||
config.set_main_option("sqlalchemy.url", settings.database_url)
|
||||
|
||||
# Import all models so they register with Base.metadata
|
||||
from src.backend.models.orm.user import User # noqa: F401
|
||||
from src.backend.models.orm.recipe import Recipe, RecipeVersion # noqa: F401
|
||||
from src.backend.models.orm.task import RecipeTask, RecipeSubtask # noqa: F401
|
||||
from src.backend.models.orm.measurement import Measurement # noqa: F401
|
||||
from src.backend.models.orm.access_log import AccessLog # noqa: F401
|
||||
from src.backend.models.orm.setting import SystemSetting, RecipeVersionAudit # noqa: F401
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""Run migrations in 'offline' mode."""
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def do_run_migrations(connection):
|
||||
"""Run migrations with connection."""
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
async def run_async_migrations() -> None:
|
||||
"""Run migrations in 'online' mode with async engine."""
|
||||
connectable = create_async_engine(
|
||||
config.get_main_option("sqlalchemy.url"),
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
async with connectable.connect() as connection:
|
||||
await connection.run_sync(do_run_migrations)
|
||||
|
||||
await connectable.dispose()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""Run migrations in 'online' mode."""
|
||||
asyncio.run(run_async_migrations())
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@@ -0,0 +1,26 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -0,0 +1,27 @@
|
||||
"""add image_path to recipe and subtask
|
||||
|
||||
Revision ID: 001_image_path
|
||||
Revises:
|
||||
Create Date: 2026-02-20
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '001_image_path'
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column('recipes', sa.Column('image_path', sa.String(500), nullable=True))
|
||||
op.add_column('recipe_subtasks', sa.Column('image_path', sa.String(500), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('recipe_subtasks', 'image_path')
|
||||
op.drop_column('recipes', 'image_path')
|
||||
@@ -0,0 +1,53 @@
|
||||
"""add stations and station_recipe_assignments tables
|
||||
|
||||
Revision ID: 002_add_stations
|
||||
Revises: 001_image_path
|
||||
Create Date: 2026-04-17
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = '002_add_stations'
|
||||
down_revision: Union[str, None] = '001_image_path'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'stations',
|
||||
sa.Column('id', sa.Integer, primary_key=True, autoincrement=True),
|
||||
sa.Column('code', sa.String(100), nullable=False),
|
||||
sa.Column('name', sa.String(255), nullable=False),
|
||||
sa.Column('location', sa.String(255), nullable=True),
|
||||
sa.Column('notes', sa.Text, nullable=True),
|
||||
sa.Column('active', sa.Boolean, nullable=False, server_default='1'),
|
||||
sa.Column('created_by', sa.Integer, sa.ForeignKey('users.id'), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime, nullable=False, server_default=sa.func.now()),
|
||||
sa.UniqueConstraint('code', name='uq_stations_code'),
|
||||
sa.Index('ix_stations_active', 'active'),
|
||||
mysql_engine='InnoDB',
|
||||
mysql_charset='utf8mb4',
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
'station_recipe_assignments',
|
||||
sa.Column('id', sa.Integer, primary_key=True, autoincrement=True),
|
||||
sa.Column('station_id', sa.Integer, sa.ForeignKey('stations.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('recipe_id', sa.Integer, sa.ForeignKey('recipes.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('assigned_by', sa.Integer, sa.ForeignKey('users.id'), nullable=False),
|
||||
sa.Column('assigned_at', sa.DateTime, nullable=False, server_default=sa.func.now()),
|
||||
sa.UniqueConstraint('station_id', 'recipe_id', name='uq_station_recipe'),
|
||||
sa.Index('ix_sra_station', 'station_id'),
|
||||
sa.Index('ix_sra_recipe', 'recipe_id'),
|
||||
mysql_engine='InnoDB',
|
||||
mysql_charset='utf8mb4',
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table('station_recipe_assignments')
|
||||
op.drop_table('stations')
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Pydantic schemas for TieMeasureFlow API."""
|
||||
from src.backend.models.api.measurement import (
|
||||
MeasurementBatchCreate,
|
||||
MeasurementCreate,
|
||||
MeasurementListResponse,
|
||||
MeasurementQuery,
|
||||
MeasurementResponse,
|
||||
)
|
||||
from src.backend.models.api.recipe import (
|
||||
RecipeCreate,
|
||||
RecipeListResponse,
|
||||
RecipeResponse,
|
||||
RecipeUpdate,
|
||||
RecipeVersionResponse,
|
||||
)
|
||||
from src.backend.models.api.statistics import (
|
||||
AlertData,
|
||||
CapabilityData,
|
||||
ControlChartData,
|
||||
HistogramData,
|
||||
StatisticsQuery,
|
||||
StatisticsResponse,
|
||||
SummaryData,
|
||||
TrendData,
|
||||
)
|
||||
from src.backend.models.api.task import (
|
||||
SubtaskCreate,
|
||||
SubtaskResponse,
|
||||
SubtaskUpdate,
|
||||
TaskCreate,
|
||||
TaskReorderRequest,
|
||||
TaskResponse,
|
||||
TaskUpdate,
|
||||
)
|
||||
from src.backend.models.api.user import (
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
UserCreate,
|
||||
UserProfileUpdate,
|
||||
UserResponse,
|
||||
UserUpdate,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# User schemas
|
||||
"UserCreate",
|
||||
"UserUpdate",
|
||||
"UserProfileUpdate",
|
||||
"UserResponse",
|
||||
"LoginRequest",
|
||||
"LoginResponse",
|
||||
# Recipe schemas
|
||||
"RecipeCreate",
|
||||
"RecipeUpdate",
|
||||
"RecipeResponse",
|
||||
"RecipeVersionResponse",
|
||||
"RecipeListResponse",
|
||||
# Task schemas
|
||||
"TaskCreate",
|
||||
"TaskUpdate",
|
||||
"TaskResponse",
|
||||
"TaskReorderRequest",
|
||||
"SubtaskCreate",
|
||||
"SubtaskUpdate",
|
||||
"SubtaskResponse",
|
||||
# Measurement schemas
|
||||
"MeasurementCreate",
|
||||
"MeasurementBatchCreate",
|
||||
"MeasurementResponse",
|
||||
"MeasurementListResponse",
|
||||
"MeasurementQuery",
|
||||
# Statistics schemas
|
||||
"StatisticsQuery",
|
||||
"ControlChartData",
|
||||
"HistogramData",
|
||||
"CapabilityData",
|
||||
"TrendData",
|
||||
"SummaryData",
|
||||
"AlertData",
|
||||
"StatisticsResponse",
|
||||
]
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Pydantic schemas for Measurement operations."""
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class MeasurementCreate(BaseModel):
|
||||
"""Schema for creating a single measurement."""
|
||||
subtask_id: int
|
||||
version_id: int
|
||||
value: float
|
||||
lot_number: Optional[str] = Field(None, max_length=100)
|
||||
serial_number: Optional[str] = Field(None, max_length=100)
|
||||
input_method: str = Field("manual", pattern="^(usb_caliper|manual)$")
|
||||
|
||||
|
||||
class MeasurementBatchCreate(BaseModel):
|
||||
"""Schema for creating multiple measurements at once."""
|
||||
measurements: list[MeasurementCreate]
|
||||
|
||||
|
||||
class MeasurementResponse(BaseModel):
|
||||
"""Schema for measurement response."""
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
subtask_id: int
|
||||
version_id: int
|
||||
measured_by: int
|
||||
value: float
|
||||
pass_fail: str
|
||||
deviation: Optional[float] = None
|
||||
lot_number: Optional[str] = None
|
||||
serial_number: Optional[str] = None
|
||||
input_method: str
|
||||
measured_at: datetime
|
||||
synced_to_csv: bool
|
||||
|
||||
|
||||
class MeasurementListResponse(BaseModel):
|
||||
"""Schema for paginated measurement list."""
|
||||
items: list[MeasurementResponse]
|
||||
total: int
|
||||
page: int
|
||||
per_page: int
|
||||
pages: int
|
||||
|
||||
|
||||
class MeasurementQuery(BaseModel):
|
||||
"""Schema for measurement query filters."""
|
||||
recipe_id: Optional[int] = None
|
||||
version_id: Optional[int] = None
|
||||
subtask_id: Optional[int] = None
|
||||
measured_by: Optional[int] = None
|
||||
lot_number: Optional[str] = None
|
||||
serial_number: Optional[str] = None
|
||||
date_from: Optional[datetime] = None
|
||||
date_to: Optional[datetime] = None
|
||||
pass_fail: Optional[str] = Field(None, pattern="^(pass|warning|fail)$")
|
||||
page: int = Field(1, ge=1)
|
||||
per_page: int = Field(50, ge=1, le=500)
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Pydantic schemas for Recipe and RecipeVersion operations."""
|
||||
from datetime import datetime
|
||||
from typing import Optional, TYPE_CHECKING
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from src.backend.models.api.task import TaskResponse
|
||||
|
||||
|
||||
class RecipeCreate(BaseModel):
|
||||
"""Schema for creating a new recipe."""
|
||||
code: str = Field(..., min_length=1, max_length=100)
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
description: Optional[str] = None
|
||||
image_path: Optional[str] = Field(None, max_length=500)
|
||||
# Optional task-level fields for the initial technical drawing
|
||||
file_path: Optional[str] = Field(None, max_length=500)
|
||||
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
|
||||
annotations_json: Optional[dict] = None
|
||||
|
||||
|
||||
class RecipeUpdate(BaseModel):
|
||||
"""Schema for updating a recipe (creates new version)."""
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||
description: Optional[str] = None
|
||||
image_path: Optional[str] = Field(None, max_length=500)
|
||||
change_notes: Optional[str] = None
|
||||
# Task-level fields: saved to the first task of the new version
|
||||
file_path: Optional[str] = Field(None, max_length=500)
|
||||
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
|
||||
annotations_json: Optional[dict] = None
|
||||
|
||||
|
||||
class RecipeVersionResponse(BaseModel):
|
||||
"""Schema for recipe version response."""
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
recipe_id: int
|
||||
version_number: int
|
||||
is_current: bool
|
||||
created_by: int
|
||||
created_at: datetime
|
||||
change_notes: Optional[str] = None
|
||||
tasks: list["TaskResponse"] = []
|
||||
|
||||
|
||||
class RecipeResponse(BaseModel):
|
||||
"""Schema for recipe response."""
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
code: str
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
image_path: Optional[str] = None
|
||||
created_by: int
|
||||
created_at: datetime
|
||||
active: bool
|
||||
current_version: Optional[RecipeVersionResponse] = None
|
||||
|
||||
|
||||
class RecipeListResponse(BaseModel):
|
||||
"""Schema for paginated recipe list."""
|
||||
items: list[RecipeResponse]
|
||||
total: int
|
||||
page: int
|
||||
per_page: int
|
||||
pages: int
|
||||
|
||||
|
||||
# Forward reference imports for model_rebuild
|
||||
from src.backend.models.api.task import TaskResponse # noqa: E402
|
||||
RecipeVersionResponse.model_rebuild()
|
||||
RecipeResponse.model_rebuild()
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Pydantic schemas for Station and StationRecipeAssignment."""
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class StationCreate(BaseModel):
|
||||
code: str = Field(..., min_length=1, max_length=100)
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
location: Optional[str] = Field(default=None, max_length=255)
|
||||
notes: Optional[str] = None
|
||||
active: bool = True
|
||||
|
||||
|
||||
class StationUpdate(BaseModel):
|
||||
name: Optional[str] = Field(default=None, min_length=1, max_length=255)
|
||||
location: Optional[str] = Field(default=None, max_length=255)
|
||||
notes: Optional[str] = None
|
||||
active: Optional[bool] = None
|
||||
|
||||
|
||||
class StationResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
code: str
|
||||
name: str
|
||||
location: Optional[str]
|
||||
notes: Optional[str]
|
||||
active: bool
|
||||
created_by: int
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class StationRecipeAssignmentCreate(BaseModel):
|
||||
recipe_id: int = Field(..., gt=0)
|
||||
|
||||
|
||||
class StationRecipeAssignmentResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
station_id: int
|
||||
recipe_id: int
|
||||
assigned_by: int
|
||||
assigned_at: datetime
|
||||
|
||||
|
||||
class RecipeSummary(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
code: str
|
||||
name: str
|
||||
active: bool
|
||||
|
||||
|
||||
class StationWithRecipesResponse(StationResponse):
|
||||
recipes: list[RecipeSummary] = Field(default_factory=list)
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Pydantic schemas for SPC statistics responses."""
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class StatisticsQuery(BaseModel):
|
||||
"""Common query parameters for statistics endpoints."""
|
||||
recipe_id: int
|
||||
version_id: Optional[int] = None
|
||||
subtask_id: Optional[int] = None
|
||||
date_from: Optional[datetime] = None
|
||||
date_to: Optional[datetime] = None
|
||||
operator_id: Optional[int] = None
|
||||
lot_number: Optional[str] = None
|
||||
serial_number: Optional[str] = None
|
||||
|
||||
|
||||
class ControlChartData(BaseModel):
|
||||
"""Data for X-bar/R control chart."""
|
||||
values: list[float]
|
||||
timestamps: list[datetime]
|
||||
mean: float
|
||||
ucl: float # Upper Control Limit
|
||||
lcl: float # Lower Control Limit
|
||||
utl: Optional[float] = None
|
||||
uwl: Optional[float] = None
|
||||
lwl: Optional[float] = None
|
||||
ltl: Optional[float] = None
|
||||
nominal: Optional[float] = None
|
||||
out_of_control: list[int] = [] # Indices of OOC points
|
||||
|
||||
|
||||
class HistogramData(BaseModel):
|
||||
"""Data for histogram with normal curve."""
|
||||
bins: list[float]
|
||||
counts: list[int]
|
||||
normal_x: list[float]
|
||||
normal_y: list[float]
|
||||
mean: float
|
||||
std_dev: float
|
||||
n: int
|
||||
|
||||
|
||||
class CapabilityData(BaseModel):
|
||||
"""Capability indices."""
|
||||
cp: Optional[float] = None
|
||||
cpk: Optional[float] = None
|
||||
pp: Optional[float] = None
|
||||
ppk: Optional[float] = None
|
||||
mean: float
|
||||
std_dev: float
|
||||
n: int
|
||||
utl: Optional[float] = None
|
||||
ltl: Optional[float] = None
|
||||
nominal: Optional[float] = None
|
||||
|
||||
|
||||
class TrendData(BaseModel):
|
||||
"""Temporal trend data for capability indices."""
|
||||
dates: list[datetime]
|
||||
cpk_values: list[Optional[float]]
|
||||
ppk_values: list[Optional[float]]
|
||||
n_per_period: list[int]
|
||||
|
||||
|
||||
class SummaryData(BaseModel):
|
||||
"""Pass/fail/warning summary counts."""
|
||||
total: int
|
||||
pass_count: int
|
||||
warning_count: int
|
||||
fail_count: int
|
||||
pass_rate: float
|
||||
warning_rate: float
|
||||
fail_rate: float
|
||||
|
||||
|
||||
class AlertData(BaseModel):
|
||||
"""Western Electric rules violations."""
|
||||
rule: str
|
||||
description: str
|
||||
points: list[int] # Indices of violating points
|
||||
severity: str = Field(..., pattern="^(info|warning|critical)$")
|
||||
|
||||
|
||||
class StatisticsResponse(BaseModel):
|
||||
"""Combined statistics response."""
|
||||
summary: SummaryData
|
||||
capability: Optional[CapabilityData] = None
|
||||
control_chart: Optional[ControlChartData] = None
|
||||
histogram: Optional[HistogramData] = None
|
||||
trend: Optional[TrendData] = None
|
||||
alerts: list[AlertData] = []
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Pydantic schemas for RecipeTask and RecipeSubtask operations."""
|
||||
from typing import Any, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class SubtaskCreate(BaseModel):
|
||||
"""Schema for creating a subtask."""
|
||||
marker_number: int = Field(..., ge=1)
|
||||
description: str = Field(..., min_length=1, max_length=500)
|
||||
measurement_type: Optional[str] = Field(None, max_length=100)
|
||||
nominal: Optional[float] = None
|
||||
utl: Optional[float] = None
|
||||
uwl: Optional[float] = None
|
||||
lwl: Optional[float] = None
|
||||
ltl: Optional[float] = None
|
||||
unit: str = Field("mm", max_length=20)
|
||||
image_path: Optional[str] = Field(None, max_length=500)
|
||||
|
||||
|
||||
class SubtaskUpdate(BaseModel):
|
||||
"""Schema for updating a subtask."""
|
||||
marker_number: Optional[int] = Field(None, ge=1)
|
||||
description: Optional[str] = Field(None, min_length=1, max_length=500)
|
||||
measurement_type: Optional[str] = Field(None, max_length=100)
|
||||
nominal: Optional[float] = None
|
||||
utl: Optional[float] = None
|
||||
uwl: Optional[float] = None
|
||||
lwl: Optional[float] = None
|
||||
ltl: Optional[float] = None
|
||||
unit: Optional[str] = Field(None, max_length=20)
|
||||
image_path: Optional[str] = Field(None, max_length=500)
|
||||
|
||||
|
||||
class SubtaskResponse(BaseModel):
|
||||
"""Schema for subtask response."""
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
task_id: int
|
||||
marker_number: int
|
||||
description: str
|
||||
measurement_type: Optional[str] = None
|
||||
nominal: Optional[float] = None
|
||||
utl: Optional[float] = None
|
||||
uwl: Optional[float] = None
|
||||
lwl: Optional[float] = None
|
||||
ltl: Optional[float] = None
|
||||
unit: str
|
||||
image_path: Optional[str] = None
|
||||
|
||||
|
||||
class TaskCreate(BaseModel):
|
||||
"""Schema for creating a task."""
|
||||
title: str = Field(..., min_length=1, max_length=255)
|
||||
directive: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
file_path: Optional[str] = Field(None, max_length=500)
|
||||
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
|
||||
annotations_json: Optional[dict[str, Any]] = None
|
||||
subtasks: list[SubtaskCreate] = []
|
||||
|
||||
|
||||
class TaskUpdate(BaseModel):
|
||||
"""Schema for updating a task."""
|
||||
title: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||
directive: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
file_path: Optional[str] = Field(None, max_length=500)
|
||||
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
|
||||
annotations_json: Optional[dict[str, Any]] = None
|
||||
|
||||
|
||||
class TaskResponse(BaseModel):
|
||||
"""Schema for task response."""
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
version_id: int
|
||||
recipe_id: Optional[int] = None
|
||||
order_index: int
|
||||
title: str
|
||||
directive: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
file_path: Optional[str] = None
|
||||
file_type: Optional[str] = None
|
||||
annotations_json: Optional[dict[str, Any]] = None
|
||||
subtasks: list[SubtaskResponse] = []
|
||||
|
||||
|
||||
class TaskReorderRequest(BaseModel):
|
||||
"""Schema for reordering tasks."""
|
||||
task_ids: list[int] = Field(..., min_length=1)
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Pydantic schemas for User operations."""
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
"""Schema for creating a new user."""
|
||||
username: str = Field(..., min_length=3, max_length=100)
|
||||
password: str = Field(..., min_length=6, max_length=128)
|
||||
email: Optional[str] = Field(None, max_length=255)
|
||||
display_name: str = Field(..., min_length=1, max_length=255)
|
||||
roles: list[str] = Field(default_factory=list)
|
||||
is_admin: bool = False
|
||||
language_pref: str = Field("it", pattern="^(it|en)$")
|
||||
theme_pref: str = Field("light", pattern="^(light|dark)$")
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
"""Schema for updating a user (admin)."""
|
||||
email: Optional[str] = Field(None, max_length=255)
|
||||
display_name: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||
roles: Optional[list[str]] = None
|
||||
is_admin: Optional[bool] = None
|
||||
active: Optional[bool] = None
|
||||
language_pref: Optional[str] = Field(None, pattern="^(it|en)$")
|
||||
theme_pref: Optional[str] = Field(None, pattern="^(light|dark)$")
|
||||
|
||||
|
||||
class UserProfileUpdate(BaseModel):
|
||||
"""Schema for user self-update (profile)."""
|
||||
display_name: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||
language_pref: Optional[str] = Field(None, pattern="^(it|en)$")
|
||||
theme_pref: Optional[str] = Field(None, pattern="^(light|dark)$")
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
"""Schema for user response."""
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
username: str
|
||||
email: Optional[str] = None
|
||||
display_name: str
|
||||
roles: list[str]
|
||||
is_admin: bool
|
||||
language_pref: str
|
||||
theme_pref: str
|
||||
active: bool
|
||||
created_at: datetime
|
||||
last_login: Optional[datetime] = None
|
||||
|
||||
|
||||
class UserPasswordChange(BaseModel):
|
||||
"""Schema for changing user password (admin only)."""
|
||||
password: str = Field(..., min_length=6, max_length=128)
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
"""Schema for login request."""
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class LoginResponse(BaseModel):
|
||||
"""Schema for login response."""
|
||||
user: UserResponse
|
||||
api_key: str
|
||||
message: str = "Login successful"
|
||||
@@ -0,0 +1,19 @@
|
||||
"""SQLAlchemy models for TieMeasureFlow."""
|
||||
from src.backend.models.orm.user import User
|
||||
from src.backend.models.orm.recipe import Recipe, RecipeVersion
|
||||
from src.backend.models.orm.task import RecipeTask, RecipeSubtask
|
||||
from src.backend.models.orm.measurement import Measurement
|
||||
from src.backend.models.orm.access_log import AccessLog
|
||||
from src.backend.models.orm.setting import SystemSetting, RecipeVersionAudit
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
"Recipe",
|
||||
"RecipeVersion",
|
||||
"RecipeTask",
|
||||
"RecipeSubtask",
|
||||
"Measurement",
|
||||
"AccessLog",
|
||||
"SystemSetting",
|
||||
"RecipeVersionAudit",
|
||||
]
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Access log model for tracking user actions."""
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import BigInteger, DateTime, ForeignKey, Index, Integer, JSON, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from src.backend.database import Base
|
||||
|
||||
|
||||
class AccessLog(Base):
|
||||
__tablename__ = "access_logs"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[Optional[int]] = mapped_column(
|
||||
Integer, ForeignKey("users.id"), nullable=True
|
||||
)
|
||||
action: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
|
||||
details: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
|
||||
ip_address: Mapped[Optional[str]] = mapped_column(String(45), nullable=True)
|
||||
user_agent: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, nullable=False, server_default=func.now()
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_access_log_user_time", "user_id", "created_at"),
|
||||
{"mysql_engine": "InnoDB", "mysql_charset": "utf8mb4"},
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<AccessLog user={self.user_id} action={self.action}>"
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Measurement model for storing individual measurements."""
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import (
|
||||
BigInteger, Boolean, DECIMAL, DateTime, Enum, ForeignKey,
|
||||
Integer, String, func,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from src.backend.database import Base
|
||||
|
||||
|
||||
class Measurement(Base):
|
||||
__tablename__ = "measurements"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
subtask_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("recipe_subtasks.id"), nullable=False, index=True
|
||||
)
|
||||
version_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("recipe_versions.id"), nullable=False, index=True
|
||||
)
|
||||
measured_by: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("users.id"), nullable=False, index=True
|
||||
)
|
||||
|
||||
# Measurement data
|
||||
value: Mapped[float] = mapped_column(DECIMAL(12, 6), nullable=False)
|
||||
pass_fail: Mapped[str] = mapped_column(
|
||||
Enum("pass", "warning", "fail", name="pass_fail_enum"), nullable=False
|
||||
)
|
||||
deviation: Mapped[Optional[float]] = mapped_column(DECIMAL(12, 6), nullable=True)
|
||||
|
||||
# Traceability
|
||||
lot_number: Mapped[Optional[str]] = mapped_column(String(100), nullable=True, index=True)
|
||||
serial_number: Mapped[Optional[str]] = mapped_column(String(100), nullable=True, index=True)
|
||||
|
||||
# Input method
|
||||
input_method: Mapped[str] = mapped_column(
|
||||
Enum("usb_caliper", "manual", name="input_method_enum"),
|
||||
nullable=False,
|
||||
default="manual",
|
||||
)
|
||||
|
||||
# Timestamp
|
||||
measured_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, nullable=False, server_default=func.now(), index=True
|
||||
)
|
||||
|
||||
# CSV sync flag
|
||||
synced_to_csv: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Measurement subtask={self.subtask_id} value={self.value} {self.pass_fail}>"
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Recipe and RecipeVersion models with immutable versioning."""
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, Text, UniqueConstraint, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from src.backend.database import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from src.backend.models.orm.task import RecipeTask
|
||||
|
||||
|
||||
class Recipe(Base):
|
||||
__tablename__ = "recipes"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
code: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
image_path: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||
created_by: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, nullable=False, server_default=func.now()
|
||||
)
|
||||
active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
|
||||
|
||||
# Relationships
|
||||
versions: Mapped[list["RecipeVersion"]] = relationship(
|
||||
back_populates="recipe", cascade="all, delete-orphan", lazy="selectin"
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Recipe {self.code} '{self.name}'>"
|
||||
|
||||
|
||||
class RecipeVersion(Base):
|
||||
__tablename__ = "recipe_versions"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
recipe_id: Mapped[int] = mapped_column(Integer, ForeignKey("recipes.id"), nullable=False)
|
||||
version_number: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
is_current: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
|
||||
# Audit
|
||||
created_by: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, nullable=False, server_default=func.now()
|
||||
)
|
||||
change_notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
|
||||
# Relationships
|
||||
recipe: Mapped["Recipe"] = relationship(back_populates="versions")
|
||||
tasks: Mapped[list["RecipeTask"]] = relationship(
|
||||
back_populates="version", cascade="all, delete-orphan", lazy="selectin"
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("recipe_id", "version_number", name="uq_recipe_version"),
|
||||
Index("ix_recipe_version_current", "recipe_id", "is_current"),
|
||||
{"mysql_engine": "InnoDB", "mysql_charset": "utf8mb4"},
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<RecipeVersion recipe={self.recipe_id} v{self.version_number}>"
|
||||
@@ -0,0 +1,60 @@
|
||||
"""SystemSetting and RecipeVersionAudit models."""
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import DateTime, Enum, ForeignKey, Index, Integer, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from src.backend.database import Base
|
||||
|
||||
|
||||
class SystemSetting(Base):
|
||||
__tablename__ = "system_settings"
|
||||
|
||||
setting_key: Mapped[str] = mapped_column(String(100), primary_key=True)
|
||||
setting_value: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
setting_type: Mapped[str] = mapped_column(
|
||||
Enum("string", "number", "boolean", "json", name="setting_type_enum"),
|
||||
nullable=False,
|
||||
default="string",
|
||||
)
|
||||
description: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, nullable=False, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
updated_by: Mapped[Optional[int]] = mapped_column(
|
||||
Integer, ForeignKey("users.id"), nullable=True
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<SystemSetting {self.setting_key}={self.setting_value}>"
|
||||
|
||||
|
||||
class RecipeVersionAudit(Base):
|
||||
__tablename__ = "recipe_version_audit"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
recipe_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("recipes.id"), nullable=False
|
||||
)
|
||||
old_version_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
new_version_id: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
changed_by: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("users.id"), nullable=False
|
||||
)
|
||||
change_type: Mapped[str] = mapped_column(
|
||||
Enum("CREATE", "UPDATE", "ACTIVATE", "RETIRE", name="change_type_enum"),
|
||||
nullable=False,
|
||||
)
|
||||
change_reason: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, nullable=False, server_default=func.now()
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_recipe_audit_recipe_time", "recipe_id", "created_at"),
|
||||
{"mysql_engine": "InnoDB", "mysql_charset": "utf8mb4"},
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<RecipeVersionAudit recipe={self.recipe_id} {self.change_type}>"
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Station and StationRecipeAssignment models.
|
||||
|
||||
A Station represents a physical control point (typically one per tablet/PC).
|
||||
Recipes are assigned to stations so that each station only sees the products
|
||||
it is supposed to inspect.
|
||||
"""
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from src.backend.database import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from src.backend.models.orm.recipe import Recipe
|
||||
|
||||
|
||||
class Station(Base):
|
||||
__tablename__ = "stations"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
code: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
location: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
|
||||
created_by: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, nullable=False, server_default=func.now()
|
||||
)
|
||||
|
||||
assignments: Mapped[list["StationRecipeAssignment"]] = relationship(
|
||||
back_populates="station", cascade="all, delete-orphan", lazy="selectin"
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("code", name="uq_stations_code"),
|
||||
{"mysql_engine": "InnoDB", "mysql_charset": "utf8mb4"},
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Station {self.code} '{self.name}'>"
|
||||
|
||||
|
||||
class StationRecipeAssignment(Base):
|
||||
__tablename__ = "station_recipe_assignments"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
station_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("stations.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
recipe_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("recipes.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
assigned_by: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
assigned_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, nullable=False, server_default=func.now()
|
||||
)
|
||||
|
||||
station: Mapped["Station"] = relationship(back_populates="assignments")
|
||||
recipe: Mapped["Recipe"] = relationship(lazy="selectin")
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("station_id", "recipe_id", name="uq_station_recipe"),
|
||||
{"mysql_engine": "InnoDB", "mysql_charset": "utf8mb4"},
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<StationRecipeAssignment station={self.station_id} recipe={self.recipe_id}>"
|
||||
@@ -0,0 +1,84 @@
|
||||
"""RecipeTask and RecipeSubtask models."""
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from sqlalchemy import (
|
||||
DECIMAL, Enum, ForeignKey, Index, Integer, JSON, String, Text, UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from src.backend.database import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from src.backend.models.orm.recipe import RecipeVersion
|
||||
|
||||
|
||||
class RecipeTask(Base):
|
||||
__tablename__ = "recipe_tasks"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
version_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("recipe_versions.id"), nullable=False
|
||||
)
|
||||
order_index: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
directive: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
file_path: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||
file_type: Mapped[Optional[str]] = mapped_column(
|
||||
Enum("image", "pdf", name="file_type_enum"), nullable=True
|
||||
)
|
||||
annotations_json: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
|
||||
|
||||
# Relationships
|
||||
version: Mapped["RecipeVersion"] = relationship(back_populates="tasks")
|
||||
subtasks: Mapped[list["RecipeSubtask"]] = relationship(
|
||||
back_populates="task", cascade="all, delete-orphan", lazy="selectin"
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
{"mysql_engine": "InnoDB", "mysql_charset": "utf8mb4"},
|
||||
)
|
||||
|
||||
@property
|
||||
def recipe_id(self) -> int | None:
|
||||
"""Shortcut: recipe_id via the version relationship."""
|
||||
if self.version:
|
||||
return self.version.recipe_id
|
||||
return None
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<RecipeTask #{self.order_index} '{self.title}'>"
|
||||
|
||||
|
||||
class RecipeSubtask(Base):
|
||||
__tablename__ = "recipe_subtasks"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
task_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("recipe_tasks.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
marker_number: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
description: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
measurement_type: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||
|
||||
# Tolerance values
|
||||
nominal: Mapped[Optional[float]] = mapped_column(DECIMAL(12, 6), nullable=True)
|
||||
utl: Mapped[Optional[float]] = mapped_column(DECIMAL(12, 6), nullable=True) # Upper Tolerance Limit
|
||||
uwl: Mapped[Optional[float]] = mapped_column(DECIMAL(12, 6), nullable=True) # Upper Warning Limit
|
||||
lwl: Mapped[Optional[float]] = mapped_column(DECIMAL(12, 6), nullable=True) # Lower Warning Limit
|
||||
ltl: Mapped[Optional[float]] = mapped_column(DECIMAL(12, 6), nullable=True) # Lower Tolerance Limit
|
||||
|
||||
unit: Mapped[str] = mapped_column(String(20), nullable=False, default="mm")
|
||||
image_path: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||
|
||||
# Relationships
|
||||
task: Mapped["RecipeTask"] = relationship(back_populates="subtasks")
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("task_id", "marker_number", name="uq_task_marker"),
|
||||
Index("ix_subtask_task_id", "task_id"),
|
||||
{"mysql_engine": "InnoDB", "mysql_charset": "utf8mb4"},
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<RecipeSubtask #{self.marker_number} '{self.description}'>"
|
||||
@@ -0,0 +1,48 @@
|
||||
"""User model with combinable roles and admin flag."""
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, Enum, Integer, JSON, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from src.backend.database import Base
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
username: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True)
|
||||
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
email: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
display_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
|
||||
# Roles: combinable JSON array of "Maker", "MeasurementTec", "Metrologist"
|
||||
roles: Mapped[list] = mapped_column(JSON, nullable=False, default=list)
|
||||
# Admin flag separate from roles
|
||||
is_admin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
|
||||
# User preferences
|
||||
language_pref: Mapped[str] = mapped_column(
|
||||
Enum("it", "en", name="language_enum"), nullable=False, default="it"
|
||||
)
|
||||
theme_pref: Mapped[str] = mapped_column(
|
||||
Enum("light", "dark", name="theme_enum"), nullable=False, default="light"
|
||||
)
|
||||
|
||||
# Status
|
||||
active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
api_key: Mapped[Optional[str]] = mapped_column(String(64), unique=True, nullable=True, index=True)
|
||||
|
||||
# Timestamps
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, nullable=False, server_default=func.now()
|
||||
)
|
||||
last_login: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
def has_role(self, role: str) -> bool:
|
||||
"""Check if user has a specific role."""
|
||||
return role in (self.roles or [])
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<User {self.username} roles={self.roles}>"
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Authentication service - password hashing, API key management."""
|
||||
import secrets
|
||||
from datetime import datetime
|
||||
|
||||
import bcrypt
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.backend.models.orm.user import User
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""Hash a password using bcrypt."""
|
||||
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""Verify a password against its hash."""
|
||||
return bcrypt.checkpw(
|
||||
plain_password.encode("utf-8"), hashed_password.encode("utf-8")
|
||||
)
|
||||
|
||||
|
||||
def generate_api_key() -> str:
|
||||
"""Generate a secure 64-character API key."""
|
||||
return secrets.token_urlsafe(48) # 64 chars base64
|
||||
|
||||
|
||||
async def authenticate_user(
|
||||
db: AsyncSession, username: str, password: str
|
||||
) -> User | None:
|
||||
"""Authenticate user by username and password."""
|
||||
result = await db.execute(
|
||||
select(User).where(User.username == username, User.active == True)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None or not verify_password(password, user.password_hash):
|
||||
return None
|
||||
return user
|
||||
|
||||
|
||||
async def login_user(db: AsyncSession, user: User) -> str:
|
||||
"""Generate API key and update last_login for user."""
|
||||
api_key = generate_api_key()
|
||||
await db.execute(
|
||||
update(User)
|
||||
.where(User.id == user.id)
|
||||
.values(api_key=api_key, last_login=datetime.utcnow())
|
||||
)
|
||||
await db.flush()
|
||||
return api_key
|
||||
|
||||
|
||||
async def logout_user(db: AsyncSession, user: User) -> None:
|
||||
"""Invalidate user's API key."""
|
||||
await db.execute(
|
||||
update(User).where(User.id == user.id).values(api_key=None)
|
||||
)
|
||||
await db.flush()
|
||||
|
||||
|
||||
async def create_user(
|
||||
db: AsyncSession,
|
||||
username: str,
|
||||
password: str,
|
||||
display_name: str,
|
||||
email: str | None = None,
|
||||
roles: list[str] | None = None,
|
||||
is_admin: bool = False,
|
||||
language_pref: str = "it",
|
||||
theme_pref: str = "light",
|
||||
) -> User:
|
||||
"""Create a new user with hashed password."""
|
||||
user = User(
|
||||
username=username,
|
||||
password_hash=hash_password(password),
|
||||
display_name=display_name,
|
||||
email=email,
|
||||
roles=roles or [],
|
||||
is_admin=is_admin,
|
||||
language_pref=language_pref,
|
||||
theme_pref=theme_pref,
|
||||
)
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
await db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
async def regenerate_api_key(db: AsyncSession, user_id: int) -> str:
|
||||
"""Regenerate API key for a user."""
|
||||
new_key = generate_api_key()
|
||||
await db.execute(
|
||||
update(User).where(User.id == user_id).values(api_key=new_key)
|
||||
)
|
||||
await db.flush()
|
||||
return new_key
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Measurement service - pass/fail calculation, data storage."""
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.backend.models.orm.measurement import Measurement
|
||||
from src.backend.models.orm.task import RecipeSubtask
|
||||
|
||||
|
||||
def calculate_pass_fail(
|
||||
value: float, subtask: RecipeSubtask
|
||||
) -> tuple[str, float | None]:
|
||||
"""Calculate pass/fail status and deviation based on tolerances.
|
||||
|
||||
Returns (status, deviation) where status is 'pass', 'warning', or 'fail'.
|
||||
|
||||
Logic:
|
||||
- If value is outside UTL/LTL -> 'fail'
|
||||
- If value is outside UWL/LWL but inside UTL/LTL -> 'warning'
|
||||
- Otherwise -> 'pass'
|
||||
"""
|
||||
deviation = None
|
||||
if subtask.nominal is not None:
|
||||
deviation = float(Decimal(str(value)) - Decimal(str(subtask.nominal)))
|
||||
|
||||
# Check fail (outside tolerance limits)
|
||||
if subtask.utl is not None and value > float(subtask.utl):
|
||||
return "fail", deviation
|
||||
if subtask.ltl is not None and value < float(subtask.ltl):
|
||||
return "fail", deviation
|
||||
|
||||
# Check warning (outside warning limits)
|
||||
if subtask.uwl is not None and value > float(subtask.uwl):
|
||||
return "warning", deviation
|
||||
if subtask.lwl is not None and value < float(subtask.lwl):
|
||||
return "warning", deviation
|
||||
|
||||
return "pass", deviation
|
||||
|
||||
|
||||
async def save_measurement(
|
||||
db: AsyncSession,
|
||||
subtask_id: int,
|
||||
version_id: int,
|
||||
measured_by: int,
|
||||
value: float,
|
||||
lot_number: str | None = None,
|
||||
serial_number: str | None = None,
|
||||
input_method: str = "manual",
|
||||
) -> Measurement:
|
||||
"""Save a single measurement with auto-calculated pass/fail."""
|
||||
# Get subtask for tolerance values
|
||||
result = await db.execute(
|
||||
select(RecipeSubtask).where(RecipeSubtask.id == subtask_id)
|
||||
)
|
||||
subtask = result.scalar_one_or_none()
|
||||
if subtask is None:
|
||||
raise ValueError(f"Subtask {subtask_id} not found")
|
||||
|
||||
pass_fail, deviation = calculate_pass_fail(value, subtask)
|
||||
|
||||
measurement = Measurement(
|
||||
subtask_id=subtask_id,
|
||||
version_id=version_id,
|
||||
measured_by=measured_by,
|
||||
value=value,
|
||||
pass_fail=pass_fail,
|
||||
deviation=deviation,
|
||||
lot_number=lot_number,
|
||||
serial_number=serial_number,
|
||||
input_method=input_method,
|
||||
)
|
||||
db.add(measurement)
|
||||
await db.flush()
|
||||
await db.refresh(measurement)
|
||||
return measurement
|
||||
@@ -0,0 +1,585 @@
|
||||
"""Recipe service - business logic for copy-on-write versioning."""
|
||||
import math
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from src.backend.models.orm.measurement import Measurement
|
||||
from src.backend.models.orm.recipe import Recipe, RecipeVersion
|
||||
from src.backend.models.orm.setting import RecipeVersionAudit
|
||||
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
||||
from src.backend.models.orm.user import User
|
||||
from src.backend.models.api.recipe import RecipeCreate, RecipeUpdate
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _get_recipe_or_404(db: AsyncSession, recipe_id: int) -> Recipe:
|
||||
"""Return a recipe or raise 404."""
|
||||
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")
|
||||
return recipe
|
||||
|
||||
|
||||
async def _get_current_version(
|
||||
db: AsyncSession, recipe_id: int
|
||||
) -> Optional[RecipeVersion]:
|
||||
"""Return the current version for a recipe (with tasks/subtasks loaded)."""
|
||||
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)
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def _copy_tasks_to_version(
|
||||
db: AsyncSession,
|
||||
source_version: RecipeVersion,
|
||||
target_version: RecipeVersion,
|
||||
) -> None:
|
||||
"""Deep-copy all tasks and subtasks from *source* to *target* version."""
|
||||
for task in source_version.tasks:
|
||||
new_task = RecipeTask(
|
||||
version_id=target_version.id,
|
||||
order_index=task.order_index,
|
||||
title=task.title,
|
||||
directive=task.directive,
|
||||
description=task.description,
|
||||
file_path=task.file_path,
|
||||
file_type=task.file_type,
|
||||
annotations_json=task.annotations_json,
|
||||
)
|
||||
db.add(new_task)
|
||||
await db.flush() # get new_task.id
|
||||
|
||||
for sub in task.subtasks:
|
||||
new_sub = RecipeSubtask(
|
||||
task_id=new_task.id,
|
||||
marker_number=sub.marker_number,
|
||||
description=sub.description,
|
||||
measurement_type=sub.measurement_type,
|
||||
nominal=sub.nominal,
|
||||
utl=sub.utl,
|
||||
uwl=sub.uwl,
|
||||
lwl=sub.lwl,
|
||||
ltl=sub.ltl,
|
||||
unit=sub.unit,
|
||||
image_path=sub.image_path,
|
||||
)
|
||||
db.add(new_sub)
|
||||
|
||||
await db.flush()
|
||||
|
||||
|
||||
async def _write_audit(
|
||||
db: AsyncSession,
|
||||
recipe_id: int,
|
||||
old_version_id: Optional[int],
|
||||
new_version_id: int,
|
||||
changed_by: int,
|
||||
change_type: str,
|
||||
change_reason: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Persist an audit record for a version change."""
|
||||
audit = RecipeVersionAudit(
|
||||
recipe_id=recipe_id,
|
||||
old_version_id=old_version_id,
|
||||
new_version_id=new_version_id,
|
||||
changed_by=changed_by,
|
||||
change_type=change_type,
|
||||
change_reason=change_reason,
|
||||
)
|
||||
db.add(audit)
|
||||
await db.flush()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def create_recipe(
|
||||
db: AsyncSession,
|
||||
data: RecipeCreate,
|
||||
user: User,
|
||||
) -> Recipe:
|
||||
"""Create a recipe together with its first version (v1).
|
||||
|
||||
Returns the newly created Recipe with the current version loaded.
|
||||
"""
|
||||
# Check code uniqueness
|
||||
existing = await db.execute(
|
||||
select(Recipe).where(Recipe.code == data.code)
|
||||
)
|
||||
if existing.scalar_one_or_none() is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Recipe code '{data.code}' already exists",
|
||||
)
|
||||
|
||||
recipe = Recipe(
|
||||
code=data.code,
|
||||
name=data.name,
|
||||
description=data.description,
|
||||
image_path=data.image_path,
|
||||
created_by=user.id,
|
||||
)
|
||||
db.add(recipe)
|
||||
await db.flush()
|
||||
|
||||
version = RecipeVersion(
|
||||
recipe_id=recipe.id,
|
||||
version_number=1,
|
||||
is_current=True,
|
||||
created_by=user.id,
|
||||
change_notes="Initial version",
|
||||
)
|
||||
db.add(version)
|
||||
await db.flush()
|
||||
|
||||
await _write_audit(
|
||||
db,
|
||||
recipe_id=recipe.id,
|
||||
old_version_id=None,
|
||||
new_version_id=version.id,
|
||||
changed_by=user.id,
|
||||
change_type="CREATE",
|
||||
change_reason="Recipe created",
|
||||
)
|
||||
|
||||
# Reload recipe with versions + tasks eagerly loaded
|
||||
result = await db.execute(
|
||||
select(Recipe)
|
||||
.where(Recipe.id == recipe.id)
|
||||
.options(
|
||||
selectinload(Recipe.versions)
|
||||
.selectinload(RecipeVersion.tasks)
|
||||
.selectinload(RecipeTask.subtasks)
|
||||
)
|
||||
)
|
||||
return result.scalar_one()
|
||||
|
||||
|
||||
async def create_new_version(
|
||||
db: AsyncSession,
|
||||
recipe_id: int,
|
||||
data: RecipeUpdate,
|
||||
user: User,
|
||||
) -> RecipeVersion:
|
||||
"""Copy-on-write: create a new version by cloning current tasks/subtasks.
|
||||
|
||||
1. Fetch current version (with tasks + subtasks)
|
||||
2. Create a new RecipeVersion with incremented version_number
|
||||
3. Deep-copy all tasks + subtasks into the new version
|
||||
4. Apply updates from *data* to the recipe header
|
||||
5. Flip is_current: old -> False, new -> True
|
||||
6. Write audit trail
|
||||
"""
|
||||
recipe = await _get_recipe_or_404(db, recipe_id)
|
||||
if not recipe.active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Cannot update an inactive recipe",
|
||||
)
|
||||
|
||||
current = await _get_current_version(db, recipe_id)
|
||||
if current is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="No current version found for this recipe",
|
||||
)
|
||||
|
||||
old_version_id = current.id
|
||||
new_version_number = current.version_number + 1
|
||||
|
||||
# Mark old version as non-current
|
||||
await db.execute(
|
||||
update(RecipeVersion)
|
||||
.where(RecipeVersion.id == current.id)
|
||||
.values(is_current=False)
|
||||
)
|
||||
|
||||
# Create new version
|
||||
new_version = RecipeVersion(
|
||||
recipe_id=recipe_id,
|
||||
version_number=new_version_number,
|
||||
is_current=True,
|
||||
created_by=user.id,
|
||||
change_notes=data.change_notes,
|
||||
)
|
||||
db.add(new_version)
|
||||
await db.flush()
|
||||
|
||||
# Deep-copy tasks + subtasks
|
||||
await _copy_tasks_to_version(db, source_version=current, target_version=new_version)
|
||||
|
||||
# Apply task-level updates (file_path, annotations_json) to the first task
|
||||
if data.file_path is not None or data.annotations_json is not None:
|
||||
# Reload new version's tasks
|
||||
result_tasks = await db.execute(
|
||||
select(RecipeTask)
|
||||
.where(RecipeTask.version_id == new_version.id)
|
||||
.order_by(RecipeTask.order_index)
|
||||
)
|
||||
tasks = list(result_tasks.scalars().all())
|
||||
|
||||
if tasks:
|
||||
first_task = tasks[0]
|
||||
if data.file_path is not None:
|
||||
first_task.file_path = data.file_path
|
||||
# Auto-detect file_type from extension
|
||||
if data.file_type:
|
||||
first_task.file_type = data.file_type
|
||||
elif data.file_path.lower().endswith(".pdf"):
|
||||
first_task.file_type = "pdf"
|
||||
else:
|
||||
first_task.file_type = "image"
|
||||
if data.annotations_json is not None:
|
||||
first_task.annotations_json = data.annotations_json
|
||||
else:
|
||||
# No tasks yet — create a default task to hold the drawing
|
||||
default_task = RecipeTask(
|
||||
version_id=new_version.id,
|
||||
order_index=0,
|
||||
title="Technical Drawing",
|
||||
file_path=data.file_path,
|
||||
file_type=data.file_type or (
|
||||
"pdf" if data.file_path and data.file_path.lower().endswith(".pdf")
|
||||
else "image"
|
||||
),
|
||||
annotations_json=data.annotations_json,
|
||||
)
|
||||
db.add(default_task)
|
||||
|
||||
await db.flush()
|
||||
|
||||
# Apply header updates
|
||||
update_fields: dict = {}
|
||||
if data.name is not None:
|
||||
update_fields["name"] = data.name
|
||||
if data.description is not None:
|
||||
update_fields["description"] = data.description
|
||||
if data.image_path is not None:
|
||||
update_fields["image_path"] = data.image_path
|
||||
if update_fields:
|
||||
await db.execute(
|
||||
update(Recipe).where(Recipe.id == recipe_id).values(**update_fields)
|
||||
)
|
||||
|
||||
# Audit
|
||||
await _write_audit(
|
||||
db,
|
||||
recipe_id=recipe_id,
|
||||
old_version_id=old_version_id,
|
||||
new_version_id=new_version.id,
|
||||
changed_by=user.id,
|
||||
change_type="UPDATE",
|
||||
change_reason=data.change_notes,
|
||||
)
|
||||
|
||||
await db.flush()
|
||||
|
||||
# Reload the version with tasks for the response
|
||||
result = await db.execute(
|
||||
select(RecipeVersion)
|
||||
.where(RecipeVersion.id == new_version.id)
|
||||
.options(
|
||||
selectinload(RecipeVersion.tasks).selectinload(RecipeTask.subtasks)
|
||||
)
|
||||
)
|
||||
return result.scalar_one()
|
||||
|
||||
|
||||
async def get_current_version(
|
||||
db: AsyncSession, recipe_id: int
|
||||
) -> RecipeVersion:
|
||||
"""Return the current version with tasks and subtasks.
|
||||
|
||||
Raises 404 if the recipe or its current version cannot be found.
|
||||
"""
|
||||
await _get_recipe_or_404(db, recipe_id)
|
||||
version = await _get_current_version(db, recipe_id)
|
||||
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_measurement_count(
|
||||
db: AsyncSession, recipe_id: int, version_number: int
|
||||
) -> int:
|
||||
"""Return the number of measurements recorded against a specific version."""
|
||||
# Resolve version_id from recipe_id + version_number
|
||||
result = await db.execute(
|
||||
select(RecipeVersion.id).where(
|
||||
RecipeVersion.recipe_id == recipe_id,
|
||||
RecipeVersion.version_number == version_number,
|
||||
)
|
||||
)
|
||||
version_id = result.scalar_one_or_none()
|
||||
if version_id is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Version {version_number} not found for recipe {recipe_id}",
|
||||
)
|
||||
|
||||
count_result = await db.execute(
|
||||
select(func.count()).select_from(Measurement).where(
|
||||
Measurement.version_id == version_id
|
||||
)
|
||||
)
|
||||
return count_result.scalar_one()
|
||||
|
||||
|
||||
async def version_has_measurements(db: AsyncSession, version_id: int) -> bool:
|
||||
"""Check if any measurements exist for the given version."""
|
||||
result = await db.execute(
|
||||
select(func.count()).select_from(Measurement).where(
|
||||
Measurement.version_id == version_id
|
||||
)
|
||||
)
|
||||
return result.scalar_one() > 0
|
||||
|
||||
|
||||
async def update_current_version(
|
||||
db: AsyncSession,
|
||||
recipe_id: int,
|
||||
data: RecipeUpdate,
|
||||
) -> RecipeVersion:
|
||||
"""Update recipe header in-place on the current version (no copy-on-write)."""
|
||||
# Apply header updates (name, description, image_path)
|
||||
update_fields: dict = {}
|
||||
if data.name is not None:
|
||||
update_fields["name"] = data.name
|
||||
if data.description is not None:
|
||||
update_fields["description"] = data.description
|
||||
if data.image_path is not None:
|
||||
update_fields["image_path"] = data.image_path
|
||||
if update_fields:
|
||||
await db.execute(
|
||||
update(Recipe).where(Recipe.id == recipe_id).values(**update_fields)
|
||||
)
|
||||
|
||||
# Apply task-level file updates to first task (same logic as create_new_version)
|
||||
current = await _get_current_version(db, recipe_id)
|
||||
if data.file_path is not None or data.annotations_json is not None:
|
||||
result_tasks = await db.execute(
|
||||
select(RecipeTask)
|
||||
.where(RecipeTask.version_id == current.id)
|
||||
.order_by(RecipeTask.order_index)
|
||||
)
|
||||
tasks = list(result_tasks.scalars().all())
|
||||
if tasks:
|
||||
first_task = tasks[0]
|
||||
if data.file_path is not None:
|
||||
first_task.file_path = data.file_path
|
||||
if data.file_type:
|
||||
first_task.file_type = data.file_type
|
||||
elif data.file_path.lower().endswith(".pdf"):
|
||||
first_task.file_type = "pdf"
|
||||
else:
|
||||
first_task.file_type = "image"
|
||||
if data.annotations_json is not None:
|
||||
first_task.annotations_json = data.annotations_json
|
||||
else:
|
||||
# No tasks yet — create a default task to hold the drawing
|
||||
default_task = RecipeTask(
|
||||
version_id=current.id,
|
||||
order_index=0,
|
||||
title="Technical Drawing",
|
||||
file_path=data.file_path,
|
||||
file_type=data.file_type or (
|
||||
"pdf" if data.file_path and data.file_path.lower().endswith(".pdf")
|
||||
else "image"
|
||||
),
|
||||
annotations_json=data.annotations_json,
|
||||
)
|
||||
db.add(default_task)
|
||||
|
||||
await db.flush()
|
||||
|
||||
# Reload version with tasks for response
|
||||
result = await db.execute(
|
||||
select(RecipeVersion)
|
||||
.where(RecipeVersion.id == current.id)
|
||||
.options(
|
||||
selectinload(RecipeVersion.tasks).selectinload(RecipeTask.subtasks)
|
||||
)
|
||||
)
|
||||
return result.scalar_one()
|
||||
|
||||
|
||||
async def list_recipes(
|
||||
db: AsyncSession,
|
||||
page: int = 1,
|
||||
per_page: int = 20,
|
||||
search: Optional[str] = None,
|
||||
) -> dict:
|
||||
"""Return a paginated list of active recipes.
|
||||
|
||||
Each recipe includes its current version (with tasks).
|
||||
"""
|
||||
base_filter = [Recipe.active == True] # noqa: E712
|
||||
if search:
|
||||
like_pattern = f"%{search}%"
|
||||
base_filter.append(
|
||||
(Recipe.name.ilike(like_pattern)) | (Recipe.code.ilike(like_pattern))
|
||||
)
|
||||
|
||||
# Total count
|
||||
count_stmt = select(func.count()).select_from(Recipe).where(*base_filter)
|
||||
total = (await db.execute(count_stmt)).scalar_one()
|
||||
|
||||
pages = max(1, math.ceil(total / per_page))
|
||||
offset = (page - 1) * per_page
|
||||
|
||||
# Fetch recipes with versions and tasks for thumbnail display
|
||||
stmt = (
|
||||
select(Recipe)
|
||||
.where(*base_filter)
|
||||
.options(
|
||||
selectinload(Recipe.versions)
|
||||
.selectinload(RecipeVersion.tasks)
|
||||
)
|
||||
.order_by(Recipe.name.asc())
|
||||
.offset(offset)
|
||||
.limit(per_page)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
recipes = result.scalars().all()
|
||||
|
||||
return {
|
||||
"items": recipes,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
"pages": pages,
|
||||
}
|
||||
|
||||
|
||||
async def get_recipe_detail(db: AsyncSession, recipe_id: int) -> Recipe:
|
||||
"""Return a single recipe with its current version + tasks loaded."""
|
||||
result = await db.execute(
|
||||
select(Recipe)
|
||||
.where(Recipe.id == recipe_id)
|
||||
.options(
|
||||
selectinload(Recipe.versions)
|
||||
.selectinload(RecipeVersion.tasks)
|
||||
.selectinload(RecipeTask.subtasks)
|
||||
)
|
||||
)
|
||||
recipe = result.scalar_one_or_none()
|
||||
if recipe is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Recipe not found"
|
||||
)
|
||||
return recipe
|
||||
|
||||
|
||||
async def get_recipe_by_code(db: AsyncSession, code: str) -> Recipe:
|
||||
"""Return a recipe by its barcode/code, with current version loaded."""
|
||||
result = await db.execute(
|
||||
select(Recipe)
|
||||
.where(Recipe.code == code, Recipe.active == True) # noqa: E712
|
||||
.options(
|
||||
selectinload(Recipe.versions)
|
||||
.selectinload(RecipeVersion.tasks)
|
||||
.selectinload(RecipeTask.subtasks)
|
||||
)
|
||||
)
|
||||
recipe = result.scalar_one_or_none()
|
||||
if recipe is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"No active recipe found with code '{code}'",
|
||||
)
|
||||
return recipe
|
||||
|
||||
|
||||
async def deactivate_recipe(
|
||||
db: AsyncSession, recipe_id: int, user: User
|
||||
) -> Recipe:
|
||||
"""Soft-delete a recipe by setting active=False."""
|
||||
recipe = await _get_recipe_or_404(db, recipe_id)
|
||||
if not recipe.active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Recipe is already inactive",
|
||||
)
|
||||
|
||||
await db.execute(
|
||||
update(Recipe).where(Recipe.id == recipe_id).values(active=False)
|
||||
)
|
||||
|
||||
# Audit
|
||||
current = await _get_current_version(db, recipe_id)
|
||||
if current is not None:
|
||||
await _write_audit(
|
||||
db,
|
||||
recipe_id=recipe_id,
|
||||
old_version_id=current.id,
|
||||
new_version_id=current.id,
|
||||
changed_by=user.id,
|
||||
change_type="RETIRE",
|
||||
change_reason="Recipe deactivated",
|
||||
)
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(recipe)
|
||||
return recipe
|
||||
|
||||
|
||||
async def list_versions(
|
||||
db: AsyncSession, recipe_id: int
|
||||
) -> list[RecipeVersion]:
|
||||
"""Return all versions of a recipe, ordered by version_number desc."""
|
||||
await _get_recipe_or_404(db, recipe_id)
|
||||
|
||||
result = await db.execute(
|
||||
select(RecipeVersion)
|
||||
.where(RecipeVersion.recipe_id == recipe_id)
|
||||
.order_by(RecipeVersion.version_number.desc())
|
||||
.options(
|
||||
selectinload(RecipeVersion.tasks).selectinload(RecipeTask.subtasks)
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_version_detail(
|
||||
db: AsyncSession, recipe_id: int, version_number: int
|
||||
) -> RecipeVersion:
|
||||
"""Return a specific version of a recipe with tasks loaded."""
|
||||
await _get_recipe_or_404(db, recipe_id)
|
||||
|
||||
result = await db.execute(
|
||||
select(RecipeVersion)
|
||||
.where(
|
||||
RecipeVersion.recipe_id == recipe_id,
|
||||
RecipeVersion.version_number == version_number,
|
||||
)
|
||||
.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=f"Version {version_number} not found for recipe {recipe_id}",
|
||||
)
|
||||
return version
|
||||
@@ -0,0 +1,429 @@
|
||||
"""Report generation service — PDF reports via Jinja2 + Plotly/Kaleido + WeasyPrint."""
|
||||
import base64
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import plotly.graph_objects as go
|
||||
import plotly.io as pio
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from sqlalchemy import and_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from weasyprint import HTML
|
||||
|
||||
from src.backend.config import settings
|
||||
from src.backend.models.orm.measurement import Measurement
|
||||
from src.backend.models.orm.recipe import Recipe, RecipeVersion
|
||||
from src.backend.models.orm.setting import SystemSetting
|
||||
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
||||
from src.backend.models.orm.user import User
|
||||
from src.backend.services.spc_service import (
|
||||
compute_capability,
|
||||
compute_control_chart,
|
||||
compute_histogram,
|
||||
compute_summary,
|
||||
)
|
||||
|
||||
# Jinja2 environment for report templates
|
||||
_templates_dir = Path(__file__).parent.parent / "templates" / "reports"
|
||||
_jinja_env = Environment(
|
||||
loader=FileSystemLoader(str(_templates_dir)),
|
||||
autoescape=True,
|
||||
)
|
||||
|
||||
|
||||
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 (duplicated from statistics router)."""
|
||||
filters = []
|
||||
|
||||
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_company_info(db: AsyncSession) -> dict:
|
||||
"""Read company logo path and company name from system_settings."""
|
||||
info = {"logo_base64": None, "company_name": "TieMeasureFlow"}
|
||||
|
||||
# Company name
|
||||
result = await db.execute(
|
||||
select(SystemSetting).where(SystemSetting.setting_key == "company_name")
|
||||
)
|
||||
setting = result.scalar_one_or_none()
|
||||
if setting:
|
||||
info["company_name"] = setting.setting_value
|
||||
|
||||
# Company logo
|
||||
result = await db.execute(
|
||||
select(SystemSetting).where(SystemSetting.setting_key == "company_logo_path")
|
||||
)
|
||||
setting = result.scalar_one_or_none()
|
||||
if setting and setting.setting_value:
|
||||
logo_path = settings.upload_path / setting.setting_value
|
||||
if logo_path.exists():
|
||||
with open(logo_path, "rb") as f:
|
||||
logo_bytes = f.read()
|
||||
# Detect mime type
|
||||
suffix = logo_path.suffix.lower()
|
||||
mime = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".svg": "image/svg+xml"}.get(suffix, "image/png")
|
||||
info["logo_base64"] = f"data:{mime};base64,{base64.b64encode(logo_bytes).decode()}"
|
||||
|
||||
return info
|
||||
|
||||
|
||||
async def _get_recipe_info(db: AsyncSession, recipe_id: int) -> dict:
|
||||
"""Get recipe code and name."""
|
||||
result = await db.execute(select(Recipe).where(Recipe.id == recipe_id))
|
||||
recipe = result.scalar_one_or_none()
|
||||
if recipe:
|
||||
return {"code": recipe.code, "name": recipe.name, "id": recipe.id}
|
||||
return {"code": "???", "name": "Unknown", "id": recipe_id}
|
||||
|
||||
|
||||
async def _get_subtask_info(db: AsyncSession, subtask_id: int) -> dict | None:
|
||||
"""Get subtask details including tolerances."""
|
||||
result = await db.execute(
|
||||
select(RecipeSubtask).where(RecipeSubtask.id == subtask_id)
|
||||
)
|
||||
st = result.scalar_one_or_none()
|
||||
if st is None:
|
||||
return None
|
||||
return {
|
||||
"id": st.id,
|
||||
"marker_number": st.marker_number,
|
||||
"description": st.description,
|
||||
"unit": st.unit,
|
||||
"nominal": float(st.nominal) if st.nominal is not None else None,
|
||||
"utl": float(st.utl) if st.utl is not None else None,
|
||||
"uwl": float(st.uwl) if st.uwl is not None else None,
|
||||
"lwl": float(st.lwl) if st.lwl is not None else None,
|
||||
"ltl": float(st.ltl) if st.ltl is not None else None,
|
||||
}
|
||||
|
||||
|
||||
async def _get_operator_map(db: AsyncSession, user_ids: set[int]) -> dict[int, str]:
|
||||
"""Build a map of user_id -> display name."""
|
||||
if not user_ids:
|
||||
return {}
|
||||
result = await db.execute(select(User).where(User.id.in_(user_ids)))
|
||||
users = result.scalars().all()
|
||||
return {u.id: u.display_name or u.username for u in users}
|
||||
|
||||
|
||||
def _generate_control_chart_svg(chart_data, subtask_info: dict | None) -> str:
|
||||
"""Generate control chart SVG using Plotly + Kaleido."""
|
||||
if not chart_data.values:
|
||||
return ""
|
||||
|
||||
fig = go.Figure()
|
||||
|
||||
# Measurement values
|
||||
x_vals = list(range(1, len(chart_data.values) + 1))
|
||||
fig.add_trace(go.Scatter(
|
||||
x=x_vals, y=chart_data.values,
|
||||
mode="lines+markers",
|
||||
name="Values",
|
||||
line=dict(color="#2563EB", width=2),
|
||||
marker=dict(size=5),
|
||||
))
|
||||
|
||||
# Mean line
|
||||
fig.add_hline(y=chart_data.mean, line=dict(color="#64748B", dash="dash", width=1.5),
|
||||
annotation_text=f"Mean: {chart_data.mean:.4f}")
|
||||
|
||||
# UCL / LCL
|
||||
fig.add_hline(y=chart_data.ucl, line=dict(color="#EF4444", dash="dot", width=1),
|
||||
annotation_text=f"UCL: {chart_data.ucl:.4f}")
|
||||
fig.add_hline(y=chart_data.lcl, line=dict(color="#EF4444", dash="dot", width=1),
|
||||
annotation_text=f"LCL: {chart_data.lcl:.4f}")
|
||||
|
||||
# Tolerance limits
|
||||
if chart_data.utl is not None:
|
||||
fig.add_hline(y=chart_data.utl, line=dict(color="#DC2626", width=1.5),
|
||||
annotation_text=f"UTL: {chart_data.utl}")
|
||||
if chart_data.ltl is not None:
|
||||
fig.add_hline(y=chart_data.ltl, line=dict(color="#DC2626", width=1.5),
|
||||
annotation_text=f"LTL: {chart_data.ltl}")
|
||||
|
||||
# Out of control points
|
||||
if chart_data.out_of_control:
|
||||
ooc_x = [x_vals[i] for i in chart_data.out_of_control]
|
||||
ooc_y = [chart_data.values[i] for i in chart_data.out_of_control]
|
||||
fig.add_trace(go.Scatter(
|
||||
x=ooc_x, y=ooc_y,
|
||||
mode="markers",
|
||||
name="Out of control",
|
||||
marker=dict(color="#EF4444", size=10, symbol="x"),
|
||||
))
|
||||
|
||||
fig.update_layout(
|
||||
title="Control Chart",
|
||||
xaxis_title="Measurement #",
|
||||
yaxis_title="Value",
|
||||
template="plotly_white",
|
||||
width=700, height=350,
|
||||
margin=dict(l=60, r=30, t=40, b=40),
|
||||
showlegend=False,
|
||||
)
|
||||
|
||||
svg_bytes = pio.to_image(fig, format="svg", engine="kaleido")
|
||||
return svg_bytes.decode("utf-8")
|
||||
|
||||
|
||||
def _generate_histogram_svg(hist_data, subtask_info: dict | None) -> str:
|
||||
"""Generate histogram SVG using Plotly + Kaleido."""
|
||||
if not hist_data.bins:
|
||||
return ""
|
||||
|
||||
fig = go.Figure()
|
||||
|
||||
# Histogram bars
|
||||
bin_centers = [(hist_data.bins[i] + hist_data.bins[i + 1]) / 2 for i in range(len(hist_data.counts))]
|
||||
bin_width = hist_data.bins[1] - hist_data.bins[0] if len(hist_data.bins) > 1 else 1
|
||||
|
||||
fig.add_trace(go.Bar(
|
||||
x=bin_centers, y=hist_data.counts,
|
||||
width=bin_width * 0.9,
|
||||
marker_color="#2563EB",
|
||||
opacity=0.7,
|
||||
name="Frequency",
|
||||
))
|
||||
|
||||
# Normal curve overlay
|
||||
if hist_data.normal_x and hist_data.normal_y:
|
||||
fig.add_trace(go.Scatter(
|
||||
x=hist_data.normal_x, y=hist_data.normal_y,
|
||||
mode="lines",
|
||||
name="Normal curve",
|
||||
line=dict(color="#EF4444", width=2),
|
||||
))
|
||||
|
||||
# Tolerance lines
|
||||
if subtask_info:
|
||||
if subtask_info.get("utl") is not None:
|
||||
fig.add_vline(x=subtask_info["utl"], line=dict(color="#DC2626", width=1.5, dash="dash"),
|
||||
annotation_text="UTL")
|
||||
if subtask_info.get("ltl") is not None:
|
||||
fig.add_vline(x=subtask_info["ltl"], line=dict(color="#DC2626", width=1.5, dash="dash"),
|
||||
annotation_text="LTL")
|
||||
if subtask_info.get("nominal") is not None:
|
||||
fig.add_vline(x=subtask_info["nominal"], line=dict(color="#22C55E", width=1.5),
|
||||
annotation_text="Nom")
|
||||
|
||||
fig.update_layout(
|
||||
title="Histogram",
|
||||
xaxis_title="Value",
|
||||
yaxis_title="Frequency",
|
||||
template="plotly_white",
|
||||
width=700, height=350,
|
||||
margin=dict(l=60, r=30, t=40, b=40),
|
||||
bargap=0.05,
|
||||
showlegend=False,
|
||||
)
|
||||
|
||||
svg_bytes = pio.to_image(fig, format="svg", engine="kaleido")
|
||||
return svg_bytes.decode("utf-8")
|
||||
|
||||
|
||||
async def generate_spc_report(
|
||||
db: AsyncSession,
|
||||
recipe_id: int,
|
||||
subtask_id: int,
|
||||
version_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,
|
||||
) -> bytes:
|
||||
"""Generate SPC PDF report and return bytes.
|
||||
|
||||
Includes: summary, capability indices, control chart SVG, histogram SVG.
|
||||
"""
|
||||
# 1. Query measurements
|
||||
measurements = await _query_measurements(
|
||||
db, recipe_id, version_id, subtask_id,
|
||||
date_from, date_to, operator_id, lot_number, serial_number,
|
||||
)
|
||||
|
||||
# 2. Compute SPC data
|
||||
pass_fail_values = [m.pass_fail for m in measurements]
|
||||
values = [float(m.value) for m in measurements]
|
||||
timestamps = [m.measured_at for m in measurements]
|
||||
|
||||
summary = compute_summary(pass_fail_values)
|
||||
|
||||
subtask_info = await _get_subtask_info(db, subtask_id)
|
||||
tol_utl = subtask_info["utl"] if subtask_info else None
|
||||
tol_ltl = subtask_info["ltl"] if subtask_info else None
|
||||
tol_uwl = subtask_info["uwl"] if subtask_info else None
|
||||
tol_lwl = subtask_info["lwl"] if subtask_info else None
|
||||
tol_nominal = subtask_info["nominal"] if subtask_info else None
|
||||
|
||||
capability = compute_capability(values, tol_utl, tol_ltl, tol_nominal)
|
||||
control_chart = compute_control_chart(
|
||||
values, timestamps, tol_utl, tol_uwl, tol_lwl, tol_ltl, tol_nominal
|
||||
)
|
||||
histogram = compute_histogram(values)
|
||||
|
||||
# 3. Generate SVG charts
|
||||
control_chart_svg = _generate_control_chart_svg(control_chart, subtask_info)
|
||||
histogram_svg = _generate_histogram_svg(histogram, subtask_info)
|
||||
|
||||
# 4. Get company info and recipe info
|
||||
company = await _get_company_info(db)
|
||||
recipe_info = await _get_recipe_info(db, recipe_id)
|
||||
|
||||
# 5. Build filter description
|
||||
filters_desc = []
|
||||
if version_id:
|
||||
filters_desc.append(f"Version: {version_id}")
|
||||
if date_from:
|
||||
filters_desc.append(f"From: {date_from.strftime('%Y-%m-%d')}")
|
||||
if date_to:
|
||||
filters_desc.append(f"To: {date_to.strftime('%Y-%m-%d')}")
|
||||
if lot_number:
|
||||
filters_desc.append(f"Lot: {lot_number}")
|
||||
if serial_number:
|
||||
filters_desc.append(f"Serial: {serial_number}")
|
||||
|
||||
# 6. Render HTML template
|
||||
template = _jinja_env.get_template("spc_report.html")
|
||||
html_content = template.render(
|
||||
company=company,
|
||||
recipe=recipe_info,
|
||||
subtask=subtask_info,
|
||||
summary=summary,
|
||||
capability=capability,
|
||||
control_chart_svg=control_chart_svg,
|
||||
histogram_svg=histogram_svg,
|
||||
filters_desc=filters_desc,
|
||||
generated_at=datetime.now(),
|
||||
n_measurements=len(measurements),
|
||||
)
|
||||
|
||||
# 7. Convert HTML to PDF
|
||||
pdf_bytes = HTML(string=html_content).write_pdf()
|
||||
return pdf_bytes
|
||||
|
||||
|
||||
async def generate_measurement_report(
|
||||
db: AsyncSession,
|
||||
recipe_id: int,
|
||||
subtask_id: int | None = None,
|
||||
version_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,
|
||||
) -> bytes:
|
||||
"""Generate measurement table PDF report and return bytes."""
|
||||
# 1. Query measurements
|
||||
measurements = await _query_measurements(
|
||||
db, recipe_id, version_id, subtask_id,
|
||||
date_from, date_to, operator_id, lot_number, serial_number,
|
||||
)
|
||||
|
||||
# 2. Get recipe info
|
||||
recipe_info = await _get_recipe_info(db, recipe_id)
|
||||
company = await _get_company_info(db)
|
||||
|
||||
# 3. Get subtask map and operator map
|
||||
subtask_ids = {m.subtask_id for m in measurements}
|
||||
operator_ids = {m.measured_by for m in measurements}
|
||||
|
||||
subtask_map = {}
|
||||
for sid in subtask_ids:
|
||||
info = await _get_subtask_info(db, sid)
|
||||
if info:
|
||||
subtask_map[sid] = info
|
||||
|
||||
operator_map = await _get_operator_map(db, operator_ids)
|
||||
|
||||
# 4. Build table rows
|
||||
rows = []
|
||||
for i, m in enumerate(measurements, 1):
|
||||
st_info = subtask_map.get(m.subtask_id, {})
|
||||
rows.append({
|
||||
"num": i,
|
||||
"subtask": f"#{st_info.get('marker_number', '?')} — {st_info.get('description', '?')}",
|
||||
"value": f"{float(m.value):.4f}",
|
||||
"unit": st_info.get("unit", "mm"),
|
||||
"pass_fail": m.pass_fail,
|
||||
"measured_at": m.measured_at.strftime("%Y-%m-%d %H:%M"),
|
||||
"operator": operator_map.get(m.measured_by, f"ID {m.measured_by}"),
|
||||
"lot_number": m.lot_number or "—",
|
||||
"serial_number": m.serial_number or "—",
|
||||
})
|
||||
|
||||
# 5. Summary
|
||||
pass_fail_values = [m.pass_fail for m in measurements]
|
||||
summary = compute_summary(pass_fail_values)
|
||||
|
||||
# 6. Build filter description
|
||||
filters_desc = []
|
||||
if subtask_id:
|
||||
si = subtask_map.get(subtask_id)
|
||||
if si:
|
||||
filters_desc.append(f"Point: #{si['marker_number']} — {si['description']}")
|
||||
if version_id:
|
||||
filters_desc.append(f"Version: {version_id}")
|
||||
if date_from:
|
||||
filters_desc.append(f"From: {date_from.strftime('%Y-%m-%d')}")
|
||||
if date_to:
|
||||
filters_desc.append(f"To: {date_to.strftime('%Y-%m-%d')}")
|
||||
if lot_number:
|
||||
filters_desc.append(f"Lot: {lot_number}")
|
||||
if serial_number:
|
||||
filters_desc.append(f"Serial: {serial_number}")
|
||||
|
||||
# 7. Render template
|
||||
template = _jinja_env.get_template("measurement_report.html")
|
||||
html_content = template.render(
|
||||
company=company,
|
||||
recipe=recipe_info,
|
||||
rows=rows,
|
||||
summary=summary,
|
||||
filters_desc=filters_desc,
|
||||
generated_at=datetime.now(),
|
||||
n_measurements=len(measurements),
|
||||
)
|
||||
|
||||
# 8. Convert to PDF
|
||||
pdf_bytes = HTML(string=html_content).write_pdf()
|
||||
return pdf_bytes
|
||||
@@ -0,0 +1,261 @@
|
||||
"""SPC (Statistical Process Control) computation service.
|
||||
|
||||
Pure functions — no DB dependencies. Receives lists of floats and tolerance limits,
|
||||
returns structured data matching schemas in schemas/statistics.py.
|
||||
|
||||
Uses only Python stdlib (math, statistics). No numpy/scipy needed.
|
||||
"""
|
||||
import math
|
||||
import statistics as stats
|
||||
from datetime import datetime
|
||||
|
||||
from src.backend.models.api.statistics import (
|
||||
CapabilityData,
|
||||
ControlChartData,
|
||||
HistogramData,
|
||||
SummaryData,
|
||||
)
|
||||
|
||||
|
||||
def compute_summary(
|
||||
pass_fail_values: list[str],
|
||||
) -> SummaryData:
|
||||
"""Compute pass/fail/warning summary from a list of pass_fail strings.
|
||||
|
||||
Args:
|
||||
pass_fail_values: List of "pass", "warning", or "fail" strings.
|
||||
|
||||
Returns:
|
||||
SummaryData with counts and rates.
|
||||
"""
|
||||
total = len(pass_fail_values)
|
||||
if total == 0:
|
||||
return SummaryData(
|
||||
total=0,
|
||||
pass_count=0,
|
||||
warning_count=0,
|
||||
fail_count=0,
|
||||
pass_rate=0.0,
|
||||
warning_rate=0.0,
|
||||
fail_rate=0.0,
|
||||
)
|
||||
|
||||
pass_count = pass_fail_values.count("pass")
|
||||
warning_count = pass_fail_values.count("warning")
|
||||
fail_count = pass_fail_values.count("fail")
|
||||
|
||||
return SummaryData(
|
||||
total=total,
|
||||
pass_count=pass_count,
|
||||
warning_count=warning_count,
|
||||
fail_count=fail_count,
|
||||
pass_rate=round(pass_count / total * 100, 2),
|
||||
warning_rate=round(warning_count / total * 100, 2),
|
||||
fail_rate=round(fail_count / total * 100, 2),
|
||||
)
|
||||
|
||||
|
||||
def compute_capability(
|
||||
values: list[float],
|
||||
utl: float | None,
|
||||
ltl: float | None,
|
||||
nominal: float | None,
|
||||
) -> CapabilityData:
|
||||
"""Compute capability indices Cp, Cpk, Pp, Ppk.
|
||||
|
||||
- Cp = (UTL - LTL) / (6 * sigma_within)
|
||||
- Cpk = min((UTL - mean) / (3 * sigma), (mean - LTL) / (3 * sigma))
|
||||
- Pp/Ppk: same formulas using population std dev (same data, no subgrouping)
|
||||
|
||||
Args:
|
||||
values: Measured values.
|
||||
utl: Upper Tolerance Limit (None if not defined).
|
||||
ltl: Lower Tolerance Limit (None if not defined).
|
||||
nominal: Nominal value (None if not defined).
|
||||
|
||||
Returns:
|
||||
CapabilityData with indices and statistics.
|
||||
"""
|
||||
n = len(values)
|
||||
if n < 2:
|
||||
return CapabilityData(
|
||||
cp=None, cpk=None, pp=None, ppk=None,
|
||||
mean=values[0] if n == 1 else 0.0,
|
||||
std_dev=0.0, n=n,
|
||||
utl=utl, ltl=ltl, nominal=nominal,
|
||||
)
|
||||
|
||||
mean = stats.mean(values)
|
||||
# Population std dev for Pp/Ppk
|
||||
std_dev_pop = stats.pstdev(values)
|
||||
# Sample std dev for Cp/Cpk
|
||||
std_dev_sample = stats.stdev(values)
|
||||
|
||||
cp = cpk = pp = ppk = None
|
||||
|
||||
if utl is not None and ltl is not None and std_dev_sample > 0:
|
||||
cp = round((utl - ltl) / (6 * std_dev_sample), 4)
|
||||
|
||||
if std_dev_sample > 0:
|
||||
cpk_values = []
|
||||
if utl is not None:
|
||||
cpk_values.append((utl - mean) / (3 * std_dev_sample))
|
||||
if ltl is not None:
|
||||
cpk_values.append((mean - ltl) / (3 * std_dev_sample))
|
||||
if cpk_values:
|
||||
cpk = round(min(cpk_values), 4)
|
||||
|
||||
if utl is not None and ltl is not None and std_dev_pop > 0:
|
||||
pp = round((utl - ltl) / (6 * std_dev_pop), 4)
|
||||
|
||||
if std_dev_pop > 0:
|
||||
ppk_values = []
|
||||
if utl is not None:
|
||||
ppk_values.append((utl - mean) / (3 * std_dev_pop))
|
||||
if ltl is not None:
|
||||
ppk_values.append((mean - ltl) / (3 * std_dev_pop))
|
||||
if ppk_values:
|
||||
ppk = round(min(ppk_values), 4)
|
||||
|
||||
return CapabilityData(
|
||||
cp=cp, cpk=cpk, pp=pp, ppk=ppk,
|
||||
mean=round(mean, 6),
|
||||
std_dev=round(std_dev_sample, 6),
|
||||
n=n,
|
||||
utl=utl, ltl=ltl, nominal=nominal,
|
||||
)
|
||||
|
||||
|
||||
def compute_control_chart(
|
||||
values: list[float],
|
||||
timestamps: list[datetime],
|
||||
utl: float | None,
|
||||
uwl: float | None,
|
||||
lwl: float | None,
|
||||
ltl: float | None,
|
||||
nominal: float | None,
|
||||
) -> ControlChartData:
|
||||
"""Compute control chart data with UCL/LCL and out-of-control detection.
|
||||
|
||||
UCL = mean + 3*sigma
|
||||
LCL = mean - 3*sigma
|
||||
Out-of-control: points outside UCL/LCL.
|
||||
|
||||
Args:
|
||||
values: Measured values in chronological order.
|
||||
timestamps: Corresponding timestamps.
|
||||
utl/uwl/lwl/ltl: Tolerance/warning limits.
|
||||
nominal: Nominal value.
|
||||
|
||||
Returns:
|
||||
ControlChartData with values, limits, and OOC indices.
|
||||
"""
|
||||
n = len(values)
|
||||
if n == 0:
|
||||
return ControlChartData(
|
||||
values=[], timestamps=[], mean=0.0, ucl=0.0, lcl=0.0,
|
||||
utl=utl, uwl=uwl, lwl=lwl, ltl=ltl, nominal=nominal,
|
||||
out_of_control=[],
|
||||
)
|
||||
|
||||
mean = stats.mean(values)
|
||||
|
||||
if n >= 2:
|
||||
sigma = stats.stdev(values)
|
||||
else:
|
||||
sigma = 0.0
|
||||
|
||||
ucl = mean + 3 * sigma
|
||||
lcl = mean - 3 * sigma
|
||||
|
||||
# Detect out-of-control points (outside UCL/LCL)
|
||||
out_of_control = []
|
||||
for i, v in enumerate(values):
|
||||
if v > ucl or v < lcl:
|
||||
out_of_control.append(i)
|
||||
|
||||
return ControlChartData(
|
||||
values=values,
|
||||
timestamps=timestamps,
|
||||
mean=round(mean, 6),
|
||||
ucl=round(ucl, 6),
|
||||
lcl=round(lcl, 6),
|
||||
utl=utl,
|
||||
uwl=uwl,
|
||||
lwl=lwl,
|
||||
ltl=ltl,
|
||||
nominal=nominal,
|
||||
out_of_control=out_of_control,
|
||||
)
|
||||
|
||||
|
||||
def compute_histogram(
|
||||
values: list[float],
|
||||
n_bins: int = 20,
|
||||
) -> HistogramData:
|
||||
"""Compute histogram bin data and normal curve overlay.
|
||||
|
||||
Args:
|
||||
values: Measured values.
|
||||
n_bins: Number of histogram bins (default 20).
|
||||
|
||||
Returns:
|
||||
HistogramData with bins, counts, and normal curve points.
|
||||
"""
|
||||
n = len(values)
|
||||
if n == 0:
|
||||
return HistogramData(
|
||||
bins=[], counts=[], normal_x=[], normal_y=[],
|
||||
mean=0.0, std_dev=0.0, n=0,
|
||||
)
|
||||
|
||||
mean = stats.mean(values)
|
||||
std_dev = stats.pstdev(values) if n >= 2 else 0.0
|
||||
|
||||
min_val = min(values)
|
||||
max_val = max(values)
|
||||
|
||||
# Avoid zero-width range
|
||||
if max_val == min_val:
|
||||
max_val = min_val + 1.0
|
||||
|
||||
bin_width = (max_val - min_val) / n_bins
|
||||
bins = [round(min_val + i * bin_width, 6) for i in range(n_bins + 1)]
|
||||
|
||||
# Count values per bin
|
||||
counts = [0] * n_bins
|
||||
for v in values:
|
||||
idx = int((v - min_val) / bin_width)
|
||||
if idx >= n_bins:
|
||||
idx = n_bins - 1
|
||||
counts[idx] += 1
|
||||
|
||||
# Normal curve overlay (100 points)
|
||||
normal_x: list[float] = []
|
||||
normal_y: list[float] = []
|
||||
if std_dev > 0:
|
||||
n_curve_points = 100
|
||||
x_min = mean - 4 * std_dev
|
||||
x_max = mean + 4 * std_dev
|
||||
x_step = (x_max - x_min) / (n_curve_points - 1)
|
||||
|
||||
for i in range(n_curve_points):
|
||||
x = x_min + i * x_step
|
||||
# Normal PDF: (1 / (sigma * sqrt(2*pi))) * exp(-0.5 * ((x-mu)/sigma)^2)
|
||||
y = (1.0 / (std_dev * math.sqrt(2 * math.pi))) * math.exp(
|
||||
-0.5 * ((x - mean) / std_dev) ** 2
|
||||
)
|
||||
# Scale to match histogram: y * n * bin_width
|
||||
y_scaled = y * n * bin_width
|
||||
normal_x.append(round(x, 6))
|
||||
normal_y.append(round(y_scaled, 4))
|
||||
|
||||
return HistogramData(
|
||||
bins=bins,
|
||||
counts=counts,
|
||||
normal_x=normal_x,
|
||||
normal_y=normal_y,
|
||||
mean=round(mean, 6),
|
||||
std_dev=round(std_dev, 6),
|
||||
n=n,
|
||||
)
|
||||
@@ -0,0 +1,155 @@
|
||||
"""Business logic for stations and recipe assignments.
|
||||
|
||||
Routers must call into these functions rather than manipulating models directly.
|
||||
All functions are async and accept an AsyncSession; they flush but do NOT commit
|
||||
(commit is handled by the FastAPI get_db dependency).
|
||||
"""
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.backend.models.orm.recipe import Recipe
|
||||
from src.backend.models.orm.station import Station, StationRecipeAssignment
|
||||
from src.backend.models.orm.user import User
|
||||
from src.backend.models.api.station import StationCreate, StationUpdate
|
||||
|
||||
|
||||
async def create_station(
|
||||
db: AsyncSession, data: StationCreate, creator: User,
|
||||
) -> Station:
|
||||
existing = await db.execute(select(Station).where(Station.code == data.code))
|
||||
if existing.scalar_one_or_none() is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Station code '{data.code}' already exists",
|
||||
)
|
||||
station = Station(
|
||||
code=data.code,
|
||||
name=data.name,
|
||||
location=data.location,
|
||||
notes=data.notes,
|
||||
active=data.active,
|
||||
created_by=creator.id,
|
||||
)
|
||||
db.add(station)
|
||||
await db.flush()
|
||||
await db.refresh(station)
|
||||
return station
|
||||
|
||||
|
||||
async def get_station(db: AsyncSession, station_id: int) -> Station:
|
||||
result = await db.execute(select(Station).where(Station.id == station_id))
|
||||
station = result.scalar_one_or_none()
|
||||
if station is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Station not found",
|
||||
)
|
||||
return station
|
||||
|
||||
|
||||
async def get_station_by_code(db: AsyncSession, code: str) -> Optional[Station]:
|
||||
result = await db.execute(select(Station).where(Station.code == code))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def list_stations(db: AsyncSession, active_only: bool = False) -> list[Station]:
|
||||
query = select(Station).order_by(Station.code)
|
||||
if active_only:
|
||||
query = query.where(Station.active == True) # noqa: E712
|
||||
result = await db.execute(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def update_station(
|
||||
db: AsyncSession, station_id: int, data: StationUpdate,
|
||||
) -> Station:
|
||||
station = await get_station(db, station_id)
|
||||
for field, value in data.model_dump(exclude_unset=True).items():
|
||||
setattr(station, field, value)
|
||||
await db.flush()
|
||||
await db.refresh(station)
|
||||
return station
|
||||
|
||||
|
||||
async def delete_station(db: AsyncSession, station_id: int) -> None:
|
||||
station = await get_station(db, station_id)
|
||||
# Explicitly delete assignments so the ORM cascade fires within the
|
||||
# current session (SQLite test DB does not enforce FK CASCADE without
|
||||
# PRAGMA foreign_keys = ON; production MySQL handles it at DB level too,
|
||||
# but explicit ORM deletion is engine-agnostic and safer).
|
||||
assignments = await db.execute(
|
||||
select(StationRecipeAssignment).where(
|
||||
StationRecipeAssignment.station_id == station_id
|
||||
)
|
||||
)
|
||||
for assignment in assignments.scalars().all():
|
||||
await db.delete(assignment)
|
||||
await db.delete(station)
|
||||
await db.flush()
|
||||
|
||||
|
||||
async def assign_recipe(
|
||||
db: AsyncSession, station_id: int, recipe_id: int, assigner: User,
|
||||
) -> StationRecipeAssignment:
|
||||
await get_station(db, station_id)
|
||||
recipe_row = await db.execute(select(Recipe).where(Recipe.id == recipe_id))
|
||||
if recipe_row.scalar_one_or_none() is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Recipe not found",
|
||||
)
|
||||
existing = await db.execute(
|
||||
select(StationRecipeAssignment).where(
|
||||
StationRecipeAssignment.station_id == station_id,
|
||||
StationRecipeAssignment.recipe_id == recipe_id,
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none() is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Recipe already assigned to this station",
|
||||
)
|
||||
assignment = StationRecipeAssignment(
|
||||
station_id=station_id, recipe_id=recipe_id, assigned_by=assigner.id,
|
||||
)
|
||||
db.add(assignment)
|
||||
await db.flush()
|
||||
await db.refresh(assignment)
|
||||
return assignment
|
||||
|
||||
|
||||
async def unassign_recipe(
|
||||
db: AsyncSession, station_id: int, recipe_id: int,
|
||||
) -> None:
|
||||
result = await db.execute(
|
||||
select(StationRecipeAssignment).where(
|
||||
StationRecipeAssignment.station_id == station_id,
|
||||
StationRecipeAssignment.recipe_id == recipe_id,
|
||||
)
|
||||
)
|
||||
assignment = result.scalar_one_or_none()
|
||||
if assignment is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Assignment not found",
|
||||
)
|
||||
await db.delete(assignment)
|
||||
await db.flush()
|
||||
|
||||
|
||||
async def list_station_recipes(
|
||||
db: AsyncSession, station_id: int,
|
||||
) -> list[Recipe]:
|
||||
"""Return active recipes assigned to this station, ordered by code."""
|
||||
await get_station(db, station_id)
|
||||
result = await db.execute(
|
||||
select(Recipe)
|
||||
.join(StationRecipeAssignment, StationRecipeAssignment.recipe_id == Recipe.id)
|
||||
.where(
|
||||
StationRecipeAssignment.station_id == station_id,
|
||||
Recipe.active == True, # noqa: E712
|
||||
)
|
||||
.order_by(Recipe.code)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
@@ -0,0 +1,190 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
@page {
|
||||
size: A4;
|
||||
margin: 2cm 1.5cm;
|
||||
@bottom-center {
|
||||
content: "Page " counter(page) " / " counter(pages);
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-size: 8pt;
|
||||
color: #64748B;
|
||||
}
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: 'Inter', 'Helvetica Neue', Arial, sans-serif;
|
||||
font-size: 10pt;
|
||||
color: #1e293b;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.mono { font-family: 'JetBrains Mono', 'Courier New', monospace; }
|
||||
|
||||
/* Header */
|
||||
.report-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 3px solid #2563EB;
|
||||
padding-bottom: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.report-header .logo-area {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.report-header .logo-area img {
|
||||
max-height: 50px;
|
||||
max-width: 120px;
|
||||
}
|
||||
.report-header .company-name {
|
||||
font-size: 14pt;
|
||||
font-weight: 700;
|
||||
color: #2563EB;
|
||||
}
|
||||
.report-header .report-title {
|
||||
font-size: 11pt;
|
||||
font-weight: 600;
|
||||
color: #64748B;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.report-footer {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
text-align: center;
|
||||
font-size: 7pt;
|
||||
color: #94a3b8;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
/* Section */
|
||||
.section { margin-bottom: 16px; }
|
||||
.section-title {
|
||||
font-size: 11pt;
|
||||
font-weight: 700;
|
||||
color: #2563EB;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
padding-bottom: 4px;
|
||||
margin-bottom: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 9pt;
|
||||
}
|
||||
th {
|
||||
background-color: #2563EB;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
td {
|
||||
padding: 5px 8px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
tr:nth-child(even) td { background-color: #f8fafc; }
|
||||
|
||||
/* Badge colors */
|
||||
.pass { color: #059669; font-weight: 600; }
|
||||
.warning { color: #d97706; font-weight: 600; }
|
||||
.fail { color: #dc2626; font-weight: 600; }
|
||||
|
||||
.pass-bg { background-color: #ecfdf5; }
|
||||
.warning-bg { background-color: #fffbeb; }
|
||||
.fail-bg { background-color: #fef2f2; }
|
||||
|
||||
/* Capability colors */
|
||||
.cap-good { color: #059669; }
|
||||
.cap-marginal { color: #d97706; }
|
||||
.cap-poor { color: #dc2626; }
|
||||
|
||||
/* Filters info box */
|
||||
.info-box {
|
||||
background-color: #f1f5f9;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 4px;
|
||||
padding: 8px 12px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 9pt;
|
||||
}
|
||||
.info-box .label { font-weight: 600; color: #64748B; }
|
||||
|
||||
/* Chart container */
|
||||
.chart-container {
|
||||
text-align: center;
|
||||
margin: 10px 0;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
.chart-container svg {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/* Summary cards */
|
||||
.summary-grid {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.summary-card {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 10px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
.summary-card .number {
|
||||
font-size: 18pt;
|
||||
font-weight: 700;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
.summary-card .label {
|
||||
font-size: 8pt;
|
||||
color: #64748B;
|
||||
text-transform: uppercase;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* Page break */
|
||||
.page-break { page-break-before: always; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Header -->
|
||||
<div class="report-header">
|
||||
<div class="logo-area">
|
||||
{% if company.logo_base64 %}
|
||||
<img src="{{ company.logo_base64 }}" alt="Logo">
|
||||
{% endif %}
|
||||
<span class="company-name">{{ company.company_name }}</span>
|
||||
</div>
|
||||
<div class="report-title">
|
||||
{% block report_title %}Report{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
{% block content %}{% endblock %}
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="report-footer">
|
||||
Generated {{ generated_at.strftime('%Y-%m-%d %H:%M') }} — Powered by TieMeasureFlow
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,75 @@
|
||||
{% extends "base_report.html" %}
|
||||
|
||||
{% block report_title %}Measurement Report{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Recipe info + filters -->
|
||||
<div class="info-box">
|
||||
<span class="label">Recipe:</span> {{ recipe.code }} — {{ recipe.name }}
|
||||
{% if filters_desc %}
|
||||
<br>
|
||||
<span class="label">Filters:</span> {{ filters_desc | join(' | ') }}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Summary -->
|
||||
<div class="section">
|
||||
<div class="section-title">Summary</div>
|
||||
<div class="summary-grid">
|
||||
<div class="summary-card">
|
||||
<div class="number mono">{{ summary.total }}</div>
|
||||
<div class="label">Total</div>
|
||||
</div>
|
||||
<div class="summary-card pass-bg">
|
||||
<div class="number mono pass">{{ summary.pass_count }}</div>
|
||||
<div class="label">Pass ({{ summary.pass_rate }}%)</div>
|
||||
</div>
|
||||
<div class="summary-card warning-bg">
|
||||
<div class="number mono warning">{{ summary.warning_count }}</div>
|
||||
<div class="label">Warning ({{ summary.warning_rate }}%)</div>
|
||||
</div>
|
||||
<div class="summary-card fail-bg">
|
||||
<div class="number mono fail">{{ summary.fail_count }}</div>
|
||||
<div class="label">Fail ({{ summary.fail_rate }}%)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Measurements Table -->
|
||||
<div class="section">
|
||||
<div class="section-title">Measurements ({{ n_measurements }})</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Measurement Point</th>
|
||||
<th>Value</th>
|
||||
<th>Unit</th>
|
||||
<th>Result</th>
|
||||
<th>Date</th>
|
||||
<th>Operator</th>
|
||||
<th>Lot</th>
|
||||
<th>Serial</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in rows %}
|
||||
<tr class="{% if row.pass_fail == 'fail' %}fail-bg{% elif row.pass_fail == 'warning' %}warning-bg{% endif %}">
|
||||
<td class="mono">{{ row.num }}</td>
|
||||
<td>{{ row.subtask }}</td>
|
||||
<td class="mono">{{ row.value }}</td>
|
||||
<td>{{ row.unit }}</td>
|
||||
<td>
|
||||
<span class="{{ row.pass_fail }}">{{ row.pass_fail | upper }}</span>
|
||||
</td>
|
||||
<td class="mono">{{ row.measured_at }}</td>
|
||||
<td>{{ row.operator }}</td>
|
||||
<td>{{ row.lot_number }}</td>
|
||||
<td>{{ row.serial_number }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,114 @@
|
||||
{% extends "base_report.html" %}
|
||||
|
||||
{% block report_title %}SPC Report{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Recipe info + filters -->
|
||||
<div class="info-box">
|
||||
<span class="label">Recipe:</span> {{ recipe.code }} — {{ recipe.name }}
|
||||
{% if subtask %}
|
||||
| <span class="label">Measurement Point:</span> #{{ subtask.marker_number }} — {{ subtask.description }}
|
||||
{% endif %}
|
||||
{% if filters_desc %}
|
||||
<br>
|
||||
<span class="label">Filters:</span> {{ filters_desc | join(' | ') }}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Summary -->
|
||||
<div class="section">
|
||||
<div class="section-title">Summary</div>
|
||||
<div class="summary-grid">
|
||||
<div class="summary-card">
|
||||
<div class="number mono">{{ summary.total }}</div>
|
||||
<div class="label">Total</div>
|
||||
</div>
|
||||
<div class="summary-card pass-bg">
|
||||
<div class="number mono pass">{{ summary.pass_rate }}%</div>
|
||||
<div class="label">Pass ({{ summary.pass_count }})</div>
|
||||
</div>
|
||||
<div class="summary-card warning-bg">
|
||||
<div class="number mono warning">{{ summary.warning_rate }}%</div>
|
||||
<div class="label">Warning ({{ summary.warning_count }})</div>
|
||||
</div>
|
||||
<div class="summary-card fail-bg">
|
||||
<div class="number mono fail">{{ summary.fail_rate }}%</div>
|
||||
<div class="label">Fail ({{ summary.fail_count }})</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Capability Indices -->
|
||||
{% if capability and capability.cp is not none %}
|
||||
<div class="section">
|
||||
<div class="section-title">Capability Indices</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Index</th>
|
||||
<th>Value</th>
|
||||
<th>Rating</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for idx_name, idx_val in [('Cp', capability.cp), ('Cpk', capability.cpk), ('Pp', capability.pp), ('Ppk', capability.ppk)] %}
|
||||
<tr>
|
||||
<td style="font-weight: 600;">{{ idx_name }}</td>
|
||||
<td class="mono">{{ idx_val if idx_val is not none else '—' }}</td>
|
||||
<td>
|
||||
{% if idx_val is not none %}
|
||||
{% if idx_val >= 1.33 %}
|
||||
<span class="cap-good">Capable</span>
|
||||
{% elif idx_val >= 1.0 %}
|
||||
<span class="cap-marginal">Marginal</span>
|
||||
{% else %}
|
||||
<span class="cap-poor">Not Capable</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Statistics -->
|
||||
<div class="info-box" style="margin-top: 8px;">
|
||||
<span class="label">n:</span> <span class="mono">{{ capability.n }}</span>
|
||||
| <span class="label">Mean:</span> <span class="mono">{{ capability.mean }}</span>
|
||||
| <span class="label">Std Dev:</span> <span class="mono">{{ capability.std_dev }}</span>
|
||||
{% if subtask %}
|
||||
<br>
|
||||
<span class="label">Tolerances:</span>
|
||||
{% if subtask.nominal is not none %}Nom: <span class="mono">{{ subtask.nominal }}</span> {% endif %}
|
||||
{% if subtask.utl is not none %}UTL: <span class="mono">{{ subtask.utl }}</span> {% endif %}
|
||||
{% if subtask.ltl is not none %}LTL: <span class="mono">{{ subtask.ltl }}</span> {% endif %}
|
||||
{% if subtask.uwl is not none %}UWL: <span class="mono">{{ subtask.uwl }}</span> {% endif %}
|
||||
{% if subtask.lwl is not none %}LWL: <span class="mono">{{ subtask.lwl }}</span> {% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Control Chart -->
|
||||
{% if control_chart_svg %}
|
||||
<div class="section">
|
||||
<div class="section-title">Control Chart</div>
|
||||
<div class="chart-container">
|
||||
{{ control_chart_svg | safe }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Histogram -->
|
||||
{% if histogram_svg %}
|
||||
<div class="section">
|
||||
<div class="section-title">Histogram</div>
|
||||
<div class="chart-container">
|
||||
{{ histogram_svg | safe }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,934 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>TieMeasureFlow — Setup</title>
|
||||
<style>
|
||||
/* --- Reset & Base -------------------------------------------------- */
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: linear-gradient(135deg, #f0f4ff 0%, #e8ecf4 100%);
|
||||
color: #1e293b;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 40px 16px;
|
||||
}
|
||||
|
||||
/* --- Typography ---------------------------------------------------- */
|
||||
h1 { font-size: 1.75rem; font-weight: 700; color: #0f172a; }
|
||||
h2 { font-size: 1.15rem; font-weight: 600; color: #334155; margin-bottom: 12px; }
|
||||
p { line-height: 1.5; color: #64748b; }
|
||||
|
||||
.brand-sub { font-size: 0.85rem; color: #64748b; margin-top: 4px; }
|
||||
.mono { font-family: 'JetBrains Mono', 'Fira Code', monospace; }
|
||||
|
||||
/* --- Layout -------------------------------------------------------- */
|
||||
.container { width: 100%; max-width: 600px; }
|
||||
|
||||
.logo-header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
.logo-header .icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 56px; height: 56px;
|
||||
background: #2563eb;
|
||||
border-radius: 14px;
|
||||
color: #fff;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* --- Card ---------------------------------------------------------- */
|
||||
.card {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,.08), 0 4px 16px rgba(0,0,0,.04);
|
||||
padding: 24px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.card.danger { border-left: 4px solid #dc2626; }
|
||||
|
||||
/* --- Form elements ------------------------------------------------- */
|
||||
label {
|
||||
display: block;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
color: #475569;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
input[type="text"],
|
||||
input[type="password"],
|
||||
input[type="email"] {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
color: #1e293b;
|
||||
background: #f8fafc;
|
||||
transition: border-color .15s, box-shadow .15s;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: #2563eb;
|
||||
box-shadow: 0 0 0 3px rgba(37,99,235,.15);
|
||||
}
|
||||
|
||||
/* --- Buttons ------------------------------------------------------- */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-family: inherit;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background .15s, transform .1s, opacity .15s;
|
||||
width: 100%;
|
||||
}
|
||||
.btn:active { transform: scale(.98); }
|
||||
.btn:disabled { opacity: .55; cursor: not-allowed; transform: none; }
|
||||
|
||||
.btn-primary { background: #2563eb; color: #fff; }
|
||||
.btn-primary:hover:not(:disabled) { background: #1d4ed8; }
|
||||
|
||||
.btn-success { background: #16a34a; color: #fff; }
|
||||
.btn-success:hover:not(:disabled) { background: #15803d; }
|
||||
|
||||
.btn-danger { background: #dc2626; color: #fff; }
|
||||
.btn-danger:hover:not(:disabled) { background: #b91c1c; }
|
||||
|
||||
.btn-outline {
|
||||
background: transparent;
|
||||
color: #2563eb;
|
||||
border: 1.5px solid #2563eb;
|
||||
}
|
||||
.btn-outline:hover:not(:disabled) { background: #eff6ff; }
|
||||
|
||||
/* --- Status pills -------------------------------------------------- */
|
||||
.status-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.status-bar.ok { background: #f0fdf4; color: #166534; border: 1px solid #bbf7d0; }
|
||||
.status-bar.warn { background: #fffbeb; color: #92400e; border: 1px solid #fde68a; }
|
||||
.status-bar.err { background: #fef2f2; color: #991b1b; border: 1px solid #fecaca; }
|
||||
|
||||
.dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
||||
.dot.green { background: #16a34a; }
|
||||
.dot.yellow { background: #eab308; }
|
||||
.dot.red { background: #dc2626; }
|
||||
|
||||
/* --- Table list ---------------------------------------------------- */
|
||||
.table-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.table-chip {
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
padding: 3px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.78rem;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
/* --- Checkbox ------------------------------------------------------ */
|
||||
.checkbox-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 12px 0 16px;
|
||||
font-size: 0.85rem;
|
||||
color: #64748b;
|
||||
}
|
||||
.checkbox-row input[type="checkbox"] {
|
||||
width: 16px; height: 16px;
|
||||
accent-color: #dc2626;
|
||||
}
|
||||
|
||||
/* --- Toast --------------------------------------------------------- */
|
||||
#toast-container {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-width: 380px;
|
||||
}
|
||||
.toast {
|
||||
padding: 12px 18px;
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,.15);
|
||||
animation: slideIn .25s ease-out;
|
||||
word-break: break-word;
|
||||
}
|
||||
.toast.success { background: #16a34a; }
|
||||
.toast.error { background: #dc2626; }
|
||||
.toast.info { background: #2563eb; }
|
||||
|
||||
@keyframes slideIn {
|
||||
from { opacity: 0; transform: translateX(30px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
@keyframes fadeOut {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0; transform: translateY(-10px); }
|
||||
}
|
||||
|
||||
/* --- Spinner ------------------------------------------------------- */
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
width: 16px; height: 16px;
|
||||
border: 2px solid rgba(255,255,255,.3);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: spin .6s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* --- Separator ----------------------------------------------------- */
|
||||
.sep { border: none; border-top: 1px solid #e2e8f0; margin: 18px 0; }
|
||||
|
||||
/* --- Helpers ------------------------------------------------------- */
|
||||
.hidden { display: none !important; }
|
||||
.mt-2 { margin-top: 8px; }
|
||||
.mt-4 { margin-top: 16px; }
|
||||
.text-sm { font-size: 0.82rem; }
|
||||
.text-muted { color: #94a3b8; }
|
||||
|
||||
/* --- User Management ----------------------------------------------- */
|
||||
.user-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.85rem;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.user-table th {
|
||||
text-align: left;
|
||||
padding: 8px 10px;
|
||||
background: #f8fafc;
|
||||
color: #475569;
|
||||
font-weight: 600;
|
||||
border-bottom: 2px solid #e2e8f0;
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
.user-table td {
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.user-table tr:hover td { background: #f8fafc; }
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.badge-maker { background: #dbeafe; color: #1d4ed8; }
|
||||
.badge-measurement { background: #dcfce7; color: #166534; }
|
||||
.badge-metrologist { background: #fef3c7; color: #92400e; }
|
||||
.badge-admin { background: #fce7f3; color: #9d174d; }
|
||||
.badge-active { background: #dcfce7; color: #166534; }
|
||||
.badge-inactive { background: #fee2e2; color: #991b1b; }
|
||||
|
||||
.btn-sm {
|
||||
padding: 4px 10px;
|
||||
font-size: 0.75rem;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
transition: background .15s;
|
||||
}
|
||||
.btn-sm-primary { background: #2563eb; color: #fff; }
|
||||
.btn-sm-primary:hover { background: #1d4ed8; }
|
||||
.btn-sm-warning { background: #f59e0b; color: #fff; }
|
||||
.btn-sm-warning:hover { background: #d97706; }
|
||||
.btn-sm-danger { background: #ef4444; color: #fff; }
|
||||
.btn-sm-danger:hover { background: #dc2626; }
|
||||
.btn-sm-success { background: #10b981; color: #fff; }
|
||||
.btn-sm-success:hover { background: #059669; }
|
||||
|
||||
.action-btns { display: flex; gap: 4px; flex-wrap: wrap; }
|
||||
|
||||
/* --- Modal --------------------------------------------------------- */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,.4);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
animation: fadeInOverlay .2s ease-out;
|
||||
}
|
||||
@keyframes fadeInOverlay { from { opacity: 0; } to { opacity: 1; } }
|
||||
|
||||
.modal {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 30px rgba(0,0,0,.2);
|
||||
padding: 24px;
|
||||
width: 90%;
|
||||
max-width: 480px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
animation: scaleIn .2s ease-out;
|
||||
}
|
||||
@keyframes scaleIn { from { transform: scale(.95); opacity: 0; } to { transform: scale(1); opacity: 1; } }
|
||||
|
||||
.modal h2 { margin-bottom: 16px; }
|
||||
.modal .btn { margin-top: 8px; }
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.modal-actions .btn { flex: 1; }
|
||||
|
||||
/* --- Checkbox group ------------------------------------------------ */
|
||||
.checkbox-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.checkbox-group label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.checkbox-group input[type="checkbox"] {
|
||||
width: 16px; height: 16px;
|
||||
accent-color: #2563eb;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Toast container -->
|
||||
<div id="toast-container"></div>
|
||||
|
||||
<!-- ================================================================== -->
|
||||
<!-- LOGIN SCREEN -->
|
||||
<!-- ================================================================== -->
|
||||
<div class="container" id="login-screen">
|
||||
<div class="logo-header">
|
||||
<div class="icon">TM</div>
|
||||
<h1>TieMeasureFlow</h1>
|
||||
<p class="brand-sub">System Setup</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Setup Password</h2>
|
||||
<p class="text-sm text-muted" style="margin-bottom:16px;">
|
||||
Enter the <span class="mono">SETUP_PASSWORD</span> from your <span class="mono">.env</span> file to continue.
|
||||
</p>
|
||||
<label for="setup-pwd">Password</label>
|
||||
<input type="password" id="setup-pwd" placeholder="Enter setup password" autofocus>
|
||||
<button class="btn btn-primary" id="btn-login" onclick="doLogin()">Unlock Setup</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ================================================================== -->
|
||||
<!-- SETUP PANEL (hidden until login) -->
|
||||
<!-- ================================================================== -->
|
||||
<div class="container hidden" id="setup-panel">
|
||||
<div class="logo-header">
|
||||
<div class="icon">TM</div>
|
||||
<h1>TieMeasureFlow</h1>
|
||||
<p class="brand-sub">System Setup Panel</p>
|
||||
</div>
|
||||
|
||||
<!-- 1. DB Status --------------------------------------------------- -->
|
||||
<div class="card" id="card-status">
|
||||
<h2>Database Status</h2>
|
||||
<div id="status-content">
|
||||
<p class="text-sm text-muted">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2. Init DB ----------------------------------------------------- -->
|
||||
<div class="card">
|
||||
<h2>Initialize Database</h2>
|
||||
<p class="text-sm text-muted" style="margin-bottom:14px;">
|
||||
Create all required tables in the database. Safe to run multiple times.
|
||||
</p>
|
||||
<button class="btn btn-primary" id="btn-init" onclick="initDb()">Create Tables</button>
|
||||
</div>
|
||||
|
||||
<!-- 3. Create Admin ------------------------------------------------ -->
|
||||
<div class="card">
|
||||
<h2>Create Admin User</h2>
|
||||
<p class="text-sm text-muted" style="margin-bottom:14px;">
|
||||
Create a new administrator with all roles enabled.
|
||||
</p>
|
||||
<label for="admin-username">Username</label>
|
||||
<input type="text" id="admin-username" placeholder="admin">
|
||||
<label for="admin-password">Password</label>
|
||||
<input type="password" id="admin-password" placeholder="Strong password">
|
||||
<label for="admin-email">Email</label>
|
||||
<input type="email" id="admin-email" placeholder="admin@example.com">
|
||||
<label for="admin-displayname">Display Name</label>
|
||||
<input type="text" id="admin-displayname" placeholder="Administrator">
|
||||
<button class="btn btn-success" id="btn-admin" onclick="createAdmin()">Create Admin</button>
|
||||
</div>
|
||||
|
||||
<!-- 4. Seed Demo --------------------------------------------------- -->
|
||||
<div class="card">
|
||||
<h2>Seed Demo Data</h2>
|
||||
<p class="text-sm text-muted" style="margin-bottom:14px;">
|
||||
Populate the database with sample users, a demo recipe, and 180 measurements for SPC testing.
|
||||
Creates users: <span class="mono">admin</span>, <span class="mono">maker1</span>,
|
||||
<span class="mono">tec1</span>, <span class="mono">metro1</span>.
|
||||
</p>
|
||||
<button class="btn btn-success" id="btn-seed" onclick="seedData()">Load Demo Data</button>
|
||||
</div>
|
||||
|
||||
<!-- 5. User Management ------------------------------------------------ -->
|
||||
<div class="card" id="card-users">
|
||||
<h2>User Management</h2>
|
||||
<p class="text-sm text-muted" style="margin-bottom:14px;">
|
||||
Create, edit, and manage user accounts. Changes take effect immediately.
|
||||
</p>
|
||||
<button class="btn btn-primary" style="width:auto; margin-bottom:14px;" onclick="openCreateUserModal()">+ New User</button>
|
||||
<div id="users-table-container">
|
||||
<p class="text-sm text-muted">Unlock the setup page to load users.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 6. Reset DB ---------------------------------------------------- -->
|
||||
<div class="card danger">
|
||||
<h2 style="color:#dc2626;">Reset Database</h2>
|
||||
<p class="text-sm" style="color:#991b1b; margin-bottom:8px;">
|
||||
This will <strong>DROP ALL TABLES</strong> and recreate them. All data will be permanently lost.
|
||||
</p>
|
||||
<div class="checkbox-row">
|
||||
<input type="checkbox" id="reset-confirm">
|
||||
<label for="reset-confirm" style="margin-bottom:0; cursor:pointer;">I understand this action is irreversible</label>
|
||||
</div>
|
||||
<button class="btn btn-danger" id="btn-reset" onclick="resetDb()" disabled>Reset Database</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ================================================================== -->
|
||||
<!-- MODALS -->
|
||||
<!-- ================================================================== -->
|
||||
|
||||
<!-- Create / Edit User Modal -->
|
||||
<div class="modal-overlay hidden" id="modal-user">
|
||||
<div class="modal">
|
||||
<h2 id="modal-user-title">New User</h2>
|
||||
<input type="hidden" id="mu-user-id">
|
||||
<label for="mu-username">Username</label>
|
||||
<input type="text" id="mu-username" placeholder="username">
|
||||
<label for="mu-password">Password <span id="mu-pwd-hint" class="text-muted text-sm">(required)</span></label>
|
||||
<input type="password" id="mu-password" placeholder="Password">
|
||||
<label for="mu-displayname">Display Name</label>
|
||||
<input type="text" id="mu-displayname" placeholder="Full Name">
|
||||
<label for="mu-email">Email</label>
|
||||
<input type="email" id="mu-email" placeholder="user@example.com">
|
||||
<label>Roles</label>
|
||||
<div class="checkbox-group">
|
||||
<label><input type="checkbox" id="mu-role-maker" value="Maker"> Maker</label>
|
||||
<label><input type="checkbox" id="mu-role-tec" value="MeasurementTec"> MeasurementTec</label>
|
||||
<label><input type="checkbox" id="mu-role-metro" value="Metrologist"> Metrologist</label>
|
||||
</div>
|
||||
<div class="checkbox-group">
|
||||
<label><input type="checkbox" id="mu-admin"> Administrator</label>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-outline" onclick="closeModal('modal-user')">Cancel</button>
|
||||
<button class="btn btn-primary" id="btn-save-user" onclick="saveUser()">Save User</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Change Password Modal -->
|
||||
<div class="modal-overlay hidden" id="modal-password">
|
||||
<div class="modal">
|
||||
<h2>Change Password</h2>
|
||||
<input type="hidden" id="mp-user-id">
|
||||
<p class="text-sm text-muted" style="margin-bottom:14px;">
|
||||
Changing password for: <strong id="mp-username"></strong>
|
||||
</p>
|
||||
<label for="mp-new-password">New Password</label>
|
||||
<input type="password" id="mp-new-password" placeholder="New password">
|
||||
<label for="mp-confirm-password">Confirm Password</label>
|
||||
<input type="password" id="mp-confirm-password" placeholder="Confirm password">
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-outline" onclick="closeModal('modal-password')">Cancel</button>
|
||||
<button class="btn btn-primary" onclick="changePassword()">Change Password</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ================================================================== -->
|
||||
<!-- JAVASCRIPT -->
|
||||
<!-- ================================================================== -->
|
||||
<script>
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* State */
|
||||
/* ------------------------------------------------------------------ */
|
||||
let setupPassword = '';
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Toast notifications */
|
||||
/* ------------------------------------------------------------------ */
|
||||
function showToast(message, type) {
|
||||
type = type || 'info';
|
||||
const container = document.getElementById('toast-container');
|
||||
const el = document.createElement('div');
|
||||
el.className = 'toast ' + type;
|
||||
el.textContent = message;
|
||||
container.appendChild(el);
|
||||
setTimeout(function() {
|
||||
el.style.animation = 'fadeOut .3s ease-out forwards';
|
||||
setTimeout(function() { el.remove(); }, 300);
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Loading helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
function setLoading(btnId, loading) {
|
||||
const btn = document.getElementById(btnId);
|
||||
if (!btn) return;
|
||||
if (loading) {
|
||||
btn.disabled = true;
|
||||
btn._origText = btn.innerHTML;
|
||||
btn.innerHTML = '<span class="spinner"></span> Working...';
|
||||
} else {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = btn._origText || btn.innerHTML;
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* API helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
async function apiPost(path, body) {
|
||||
const resp = await fetch('/api/setup' + path, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (!resp.ok) {
|
||||
throw new Error(data.detail || 'Request failed (' + resp.status + ')');
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Check DB status */
|
||||
/* ------------------------------------------------------------------ */
|
||||
async function checkStatus() {
|
||||
const container = document.getElementById('status-content');
|
||||
try {
|
||||
const resp = await fetch('/api/setup/status');
|
||||
if (!resp.ok) throw new Error('Status endpoint returned ' + resp.status);
|
||||
const data = await resp.json();
|
||||
|
||||
let html = '';
|
||||
if (data.tables_exist) {
|
||||
html += '<div class="status-bar ok"><span class="dot green"></span>'
|
||||
+ 'Database initialized — ' + data.table_count + ' table(s)</div>';
|
||||
html += '<div class="table-list">';
|
||||
data.tables.forEach(function(t) {
|
||||
html += '<span class="table-chip">' + escHtml(t) + '</span>';
|
||||
});
|
||||
html += '</div>';
|
||||
} else {
|
||||
html += '<div class="status-bar warn"><span class="dot yellow"></span>'
|
||||
+ 'No tables found. Click "Create Tables" below.</div>';
|
||||
}
|
||||
container.innerHTML = html;
|
||||
} catch (e) {
|
||||
container.innerHTML = '<div class="status-bar err"><span class="dot red"></span>'
|
||||
+ 'Could not fetch status: ' + escHtml(e.message) + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Login */
|
||||
/* ------------------------------------------------------------------ */
|
||||
async function doLogin() {
|
||||
const pwd = document.getElementById('setup-pwd').value.trim();
|
||||
if (!pwd) { showToast('Please enter a password', 'error'); return; }
|
||||
|
||||
setLoading('btn-login', true);
|
||||
try {
|
||||
const resp = await fetch('/api/setup/status');
|
||||
if (resp.status === 404) {
|
||||
showToast('Setup is disabled. Set SETUP_PASSWORD in .env', 'error');
|
||||
return;
|
||||
}
|
||||
// Validate password via init-db (idempotent, no tables needed)
|
||||
await apiPost('/init-db', { password: pwd });
|
||||
setupPassword = pwd;
|
||||
document.getElementById('login-screen').classList.add('hidden');
|
||||
document.getElementById('setup-panel').classList.remove('hidden');
|
||||
await checkStatus();
|
||||
await loadUsers();
|
||||
showToast('Setup panel unlocked', 'success');
|
||||
} catch (e) {
|
||||
if (e.message.indexOf('403') !== -1 || e.message.indexOf('Invalid') !== -1) {
|
||||
showToast('Invalid setup password', 'error');
|
||||
} else {
|
||||
showToast('Connection error: ' + e.message, 'error');
|
||||
}
|
||||
} finally {
|
||||
setLoading('btn-login', false);
|
||||
}
|
||||
}
|
||||
|
||||
/* Allow Enter key on password field */
|
||||
document.getElementById('setup-pwd').addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') doLogin();
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Init DB */
|
||||
/* ------------------------------------------------------------------ */
|
||||
async function initDb() {
|
||||
setLoading('btn-init', true);
|
||||
try {
|
||||
const data = await apiPost('/init-db', { password: setupPassword });
|
||||
showToast(data.message, 'success');
|
||||
await checkStatus();
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
} finally {
|
||||
setLoading('btn-init', false);
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Create Admin */
|
||||
/* ------------------------------------------------------------------ */
|
||||
async function createAdmin() {
|
||||
const username = document.getElementById('admin-username').value.trim();
|
||||
const password = document.getElementById('admin-password').value;
|
||||
const email = document.getElementById('admin-email').value.trim();
|
||||
const display = document.getElementById('admin-displayname').value.trim();
|
||||
|
||||
if (!username || !password || !email || !display) {
|
||||
showToast('All fields are required', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading('btn-admin', true);
|
||||
try {
|
||||
const data = await apiPost('/create-admin', {
|
||||
password: setupPassword,
|
||||
admin_username: username,
|
||||
admin_password: password,
|
||||
admin_email: email,
|
||||
admin_display_name: display,
|
||||
});
|
||||
showToast(data.message, 'success');
|
||||
// Clear form
|
||||
document.getElementById('admin-username').value = '';
|
||||
document.getElementById('admin-password').value = '';
|
||||
document.getElementById('admin-email').value = '';
|
||||
document.getElementById('admin-displayname').value = '';
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
} finally {
|
||||
setLoading('btn-admin', false);
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Seed Demo Data */
|
||||
/* ------------------------------------------------------------------ */
|
||||
async function seedData() {
|
||||
setLoading('btn-seed', true);
|
||||
try {
|
||||
const data = await apiPost('/seed', { password: setupPassword });
|
||||
var msg = data.message + ' (users: ' + data.users_created.join(', ');
|
||||
if (data.measurements_created) msg += ', measurements: ' + data.measurements_created;
|
||||
msg += ')';
|
||||
showToast(msg, 'success');
|
||||
await loadUsers();
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
} finally {
|
||||
setLoading('btn-seed', false);
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Reset DB */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/* Enable reset button only when checkbox is checked */
|
||||
document.getElementById('reset-confirm').addEventListener('change', function() {
|
||||
document.getElementById('btn-reset').disabled = !this.checked;
|
||||
});
|
||||
|
||||
async function resetDb() {
|
||||
if (!document.getElementById('reset-confirm').checked) {
|
||||
showToast('Please confirm the checkbox first', 'error');
|
||||
return;
|
||||
}
|
||||
if (!confirm('Are you absolutely sure? This will destroy ALL data!')) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading('btn-reset', true);
|
||||
try {
|
||||
const data = await apiPost('/reset-db', {
|
||||
password: setupPassword,
|
||||
confirm: true,
|
||||
});
|
||||
showToast(data.message, 'success');
|
||||
document.getElementById('reset-confirm').checked = false;
|
||||
document.getElementById('btn-reset').disabled = true;
|
||||
await checkStatus();
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
} finally {
|
||||
setLoading('btn-reset', false);
|
||||
}
|
||||
}
|
||||
|
||||
/* ================================================================== */
|
||||
/* USER MANAGEMENT */
|
||||
/* ================================================================== */
|
||||
|
||||
function escHtml(str) {
|
||||
if (!str) return '';
|
||||
var d = document.createElement('div');
|
||||
d.textContent = str;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function roleBadge(role) {
|
||||
var cls = 'badge ';
|
||||
if (role === 'Maker') cls += 'badge-maker';
|
||||
else if (role === 'MeasurementTec') cls += 'badge-measurement';
|
||||
else if (role === 'Metrologist') cls += 'badge-metrologist';
|
||||
return '<span class="' + cls + '">' + escHtml(role) + '</span> ';
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
var container = document.getElementById('users-table-container');
|
||||
try {
|
||||
var data = await apiPost('/list-users', { password: setupPassword });
|
||||
if (!data.users || data.users.length === 0) {
|
||||
container.innerHTML = '<p class="text-sm text-muted">No users found. Use Seed Demo Data or create one.</p>';
|
||||
return;
|
||||
}
|
||||
var html = '<table class="user-table"><thead><tr>'
|
||||
+ '<th>ID</th><th>Username</th><th>Display Name</th>'
|
||||
+ '<th>Roles</th><th>Status</th><th>Actions</th>'
|
||||
+ '</tr></thead><tbody>';
|
||||
data.users.forEach(function(u) {
|
||||
var roles = '';
|
||||
(u.roles || []).forEach(function(r) { roles += roleBadge(r); });
|
||||
if (u.is_admin) roles += '<span class="badge badge-admin">Admin</span> ';
|
||||
var statusBadge = u.active
|
||||
? '<span class="badge badge-active">Active</span>'
|
||||
: '<span class="badge badge-inactive">Inactive</span>';
|
||||
var toggleLabel = u.active ? 'Deactivate' : 'Activate';
|
||||
var toggleClass = u.active ? 'btn-sm-danger' : 'btn-sm-success';
|
||||
html += '<tr>'
|
||||
+ '<td class="mono">' + u.id + '</td>'
|
||||
+ '<td class="mono">' + escHtml(u.username) + '</td>'
|
||||
+ '<td>' + escHtml(u.display_name) + '</td>'
|
||||
+ '<td>' + roles + '</td>'
|
||||
+ '<td>' + statusBadge + '</td>'
|
||||
+ '<td><div class="action-btns">'
|
||||
+ '<button class="btn-sm btn-sm-primary" onclick="openEditUserModal(' + u.id + ')">Edit</button>'
|
||||
+ '<button class="btn-sm btn-sm-warning" onclick="openPasswordModal(' + u.id + ', \'' + escHtml(u.username).replace(/'/g, "\\'") + '\')">Password</button>'
|
||||
+ '<button class="btn-sm ' + toggleClass + '" onclick="toggleActive(' + u.id + ', \'' + escHtml(u.username).replace(/'/g, "\\'") + '\', ' + u.active + ')">' + toggleLabel + '</button>'
|
||||
+ '</div></td></tr>';
|
||||
});
|
||||
html += '</tbody></table>';
|
||||
container.innerHTML = html;
|
||||
} catch (e) {
|
||||
container.innerHTML = '<div class="status-bar err"><span class="dot red"></span>' + escHtml(e.message) + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
/* -- Modal helpers ------------------------------------------------- */
|
||||
function closeModal(id) {
|
||||
document.getElementById(id).classList.add('hidden');
|
||||
}
|
||||
|
||||
function openCreateUserModal() {
|
||||
document.getElementById('modal-user-title').textContent = 'New User';
|
||||
document.getElementById('mu-user-id').value = '';
|
||||
document.getElementById('mu-username').value = '';
|
||||
document.getElementById('mu-password').value = '';
|
||||
document.getElementById('mu-displayname').value = '';
|
||||
document.getElementById('mu-email').value = '';
|
||||
document.getElementById('mu-role-maker').checked = false;
|
||||
document.getElementById('mu-role-tec').checked = false;
|
||||
document.getElementById('mu-role-metro').checked = false;
|
||||
document.getElementById('mu-admin').checked = false;
|
||||
document.getElementById('mu-pwd-hint').textContent = '(required)';
|
||||
document.getElementById('modal-user').classList.remove('hidden');
|
||||
}
|
||||
|
||||
/* Store user data for edit lookup */
|
||||
var _usersCache = [];
|
||||
|
||||
async function openEditUserModal(userId) {
|
||||
/* Reload users to get fresh data */
|
||||
try {
|
||||
var data = await apiPost('/list-users', { password: setupPassword });
|
||||
_usersCache = data.users || [];
|
||||
} catch (e) {
|
||||
showToast('Failed to load user: ' + e.message, 'error');
|
||||
return;
|
||||
}
|
||||
var user = _usersCache.find(function(u) { return u.id === userId; });
|
||||
if (!user) { showToast('User not found', 'error'); return; }
|
||||
|
||||
document.getElementById('modal-user-title').textContent = 'Edit User';
|
||||
document.getElementById('mu-user-id').value = user.id;
|
||||
document.getElementById('mu-username').value = user.username;
|
||||
document.getElementById('mu-password').value = '';
|
||||
document.getElementById('mu-displayname').value = user.display_name;
|
||||
document.getElementById('mu-email').value = user.email || '';
|
||||
document.getElementById('mu-role-maker').checked = (user.roles || []).indexOf('Maker') !== -1;
|
||||
document.getElementById('mu-role-tec').checked = (user.roles || []).indexOf('MeasurementTec') !== -1;
|
||||
document.getElementById('mu-role-metro').checked = (user.roles || []).indexOf('Metrologist') !== -1;
|
||||
document.getElementById('mu-admin').checked = user.is_admin;
|
||||
document.getElementById('mu-pwd-hint').textContent = '(leave blank to keep current)';
|
||||
document.getElementById('modal-user').classList.remove('hidden');
|
||||
}
|
||||
|
||||
async function saveUser() {
|
||||
var userId = document.getElementById('mu-user-id').value;
|
||||
var username = document.getElementById('mu-username').value.trim();
|
||||
var pwd = document.getElementById('mu-password').value;
|
||||
var displayName = document.getElementById('mu-displayname').value.trim();
|
||||
var email = document.getElementById('mu-email').value.trim();
|
||||
|
||||
if (!username || !displayName) {
|
||||
showToast('Username and Display Name are required', 'error');
|
||||
return;
|
||||
}
|
||||
if (!userId && !pwd) {
|
||||
showToast('Password is required for new users', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
var roles = [];
|
||||
if (document.getElementById('mu-role-maker').checked) roles.push('Maker');
|
||||
if (document.getElementById('mu-role-tec').checked) roles.push('MeasurementTec');
|
||||
if (document.getElementById('mu-role-metro').checked) roles.push('Metrologist');
|
||||
|
||||
setLoading('btn-save-user', true);
|
||||
try {
|
||||
var body = {
|
||||
password: setupPassword,
|
||||
user_id: userId ? parseInt(userId) : null,
|
||||
username: username,
|
||||
display_name: displayName,
|
||||
email: email || null,
|
||||
roles: roles,
|
||||
is_admin: document.getElementById('mu-admin').checked,
|
||||
};
|
||||
if (pwd) body.user_password = pwd;
|
||||
var data = await apiPost('/manage-user', body);
|
||||
showToast(data.message, 'success');
|
||||
closeModal('modal-user');
|
||||
await loadUsers();
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
} finally {
|
||||
setLoading('btn-save-user', false);
|
||||
}
|
||||
}
|
||||
|
||||
function openPasswordModal(userId, username) {
|
||||
document.getElementById('mp-user-id').value = userId;
|
||||
document.getElementById('mp-username').textContent = username;
|
||||
document.getElementById('mp-new-password').value = '';
|
||||
document.getElementById('mp-confirm-password').value = '';
|
||||
document.getElementById('modal-password').classList.remove('hidden');
|
||||
}
|
||||
|
||||
async function changePassword() {
|
||||
var userId = document.getElementById('mp-user-id').value;
|
||||
var newPwd = document.getElementById('mp-new-password').value;
|
||||
var confirmPwd = document.getElementById('mp-confirm-password').value;
|
||||
|
||||
if (!newPwd) { showToast('Password cannot be empty', 'error'); return; }
|
||||
if (newPwd !== confirmPwd) { showToast('Passwords do not match', 'error'); return; }
|
||||
|
||||
try {
|
||||
var data = await apiPost('/change-user-password', {
|
||||
password: setupPassword,
|
||||
user_id: parseInt(userId),
|
||||
new_password: newPwd,
|
||||
});
|
||||
showToast(data.message, 'success');
|
||||
closeModal('modal-password');
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleActive(userId, username, currentlyActive) {
|
||||
var action = currentlyActive ? 'deactivate' : 'activate';
|
||||
if (!confirm('Are you sure you want to ' + action + ' user "' + username + '"?')) return;
|
||||
|
||||
try {
|
||||
var data = await apiPost('/toggle-user-active', {
|
||||
password: setupPassword,
|
||||
user_id: userId,
|
||||
});
|
||||
showToast(data.message, 'success');
|
||||
await loadUsers();
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,273 @@
|
||||
"""Shared test fixtures for server tests.
|
||||
|
||||
Uses SQLite async (aiosqlite) as in-memory test database.
|
||||
Overrides FastAPI's get_db dependency to inject the test session.
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from collections.abc import AsyncGenerator
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncSession,
|
||||
async_sessionmaker,
|
||||
create_async_engine,
|
||||
)
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mock heavy optional dependencies that require system libraries.
|
||||
# WeasyPrint needs GTK/Pango which may not be available in test environments.
|
||||
# We mock it before any server code is imported.
|
||||
# ---------------------------------------------------------------------------
|
||||
if "weasyprint" not in sys.modules:
|
||||
_mock_weasyprint = MagicMock()
|
||||
sys.modules["weasyprint"] = _mock_weasyprint
|
||||
|
||||
# Ensure the server package is importable
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from src.backend.database import Base, get_db
|
||||
from src.backend.main import app
|
||||
from src.backend.api.middleware.rate_limit import RateLimitMiddleware
|
||||
from src.backend.models.orm.user import User
|
||||
from src.backend.models.orm.recipe import Recipe, RecipeVersion
|
||||
from src.backend.models.orm.task import RecipeTask, RecipeSubtask
|
||||
from src.backend.models.orm.measurement import Measurement
|
||||
from src.backend.models.orm.access_log import AccessLog
|
||||
from src.backend.models.orm.setting import SystemSetting, RecipeVersionAudit
|
||||
from src.backend.models.orm.station import Station, StationRecipeAssignment
|
||||
from src.backend.services.auth_service import hash_password, generate_api_key
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# In-memory SQLite engine for tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
TEST_DATABASE_URL = "sqlite+aiosqlite://"
|
||||
|
||||
test_engine = create_async_engine(
|
||||
TEST_DATABASE_URL,
|
||||
echo=False,
|
||||
connect_args={"check_same_thread": False},
|
||||
# StaticPool keeps a single connection for in-memory SQLite so that
|
||||
# create_all, fixtures, and the app share the same database.
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
|
||||
TestSessionFactory = async_sessionmaker(
|
||||
test_engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(autouse=True)
|
||||
async def setup_database():
|
||||
"""Create all tables before each test and drop them after."""
|
||||
async with test_engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
yield
|
||||
async with test_engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_rate_limits():
|
||||
"""Clear rate limit buckets between tests to avoid 429 in test suite.
|
||||
|
||||
The RateLimitMiddleware is added to the module-level ``app`` object and
|
||||
persists across tests. Walk the ASGI middleware stack to find the
|
||||
instance and clear its per-IP sliding-window dictionaries.
|
||||
"""
|
||||
middleware = app.middleware_stack
|
||||
while middleware is not None:
|
||||
if isinstance(middleware, RateLimitMiddleware):
|
||||
middleware._login_requests.clear()
|
||||
middleware._general_requests.clear()
|
||||
break
|
||||
middleware = getattr(middleware, "app", None)
|
||||
yield
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def db_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
"""Yield a fresh async session for direct DB manipulation in tests."""
|
||||
async with TestSessionFactory() as session:
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def client(db_session: AsyncSession) -> AsyncGenerator[AsyncClient, None]:
|
||||
"""Yield an httpx AsyncClient wired to the FastAPI app with test DB."""
|
||||
|
||||
async def _override_get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
yield db_session
|
||||
|
||||
app.dependency_overrides[get_db] = _override_get_db
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://testserver") as ac:
|
||||
yield ac
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# User factory helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _create_user(
|
||||
session: AsyncSession,
|
||||
username: str,
|
||||
password: str = "TestPass123",
|
||||
display_name: str | None = None,
|
||||
roles: list[str] | None = None,
|
||||
is_admin: bool = False,
|
||||
active: bool = True,
|
||||
) -> User:
|
||||
"""Insert a user into the test DB and return it with an API key set."""
|
||||
api_key = generate_api_key()
|
||||
user = User(
|
||||
username=username,
|
||||
password_hash=hash_password(password),
|
||||
display_name=display_name or username.title(),
|
||||
roles=roles or [],
|
||||
is_admin=is_admin,
|
||||
active=active,
|
||||
api_key=api_key,
|
||||
language_pref="en",
|
||||
theme_pref="light",
|
||||
)
|
||||
session.add(user)
|
||||
await session.flush()
|
||||
await session.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def admin_user(db_session: AsyncSession) -> User:
|
||||
"""An active admin user with API key."""
|
||||
return await _create_user(
|
||||
db_session,
|
||||
username="admin",
|
||||
display_name="Admin User",
|
||||
roles=["Maker", "MeasurementTec", "Metrologist"],
|
||||
is_admin=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def maker_user(db_session: AsyncSession) -> User:
|
||||
"""An active Maker user with API key."""
|
||||
return await _create_user(
|
||||
db_session,
|
||||
username="maker",
|
||||
display_name="Maker User",
|
||||
roles=["Maker"],
|
||||
)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def measurement_tec_user(db_session: AsyncSession) -> User:
|
||||
"""An active MeasurementTec user with API key."""
|
||||
return await _create_user(
|
||||
db_session,
|
||||
username="measurement_tec",
|
||||
display_name="MeasurementTec User",
|
||||
roles=["MeasurementTec"],
|
||||
)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def metrologist_user(db_session: AsyncSession) -> User:
|
||||
"""An active Metrologist user with API key."""
|
||||
return await _create_user(
|
||||
db_session,
|
||||
username="metrologist",
|
||||
display_name="Metrologist User",
|
||||
roles=["Metrologist"],
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Recipe helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def create_test_recipe(
|
||||
session: AsyncSession,
|
||||
user_id: int,
|
||||
code: str = "REC-001",
|
||||
name: str = "Test Recipe",
|
||||
) -> Recipe:
|
||||
"""Create a recipe with one version (v1 current), one task, and one subtask.
|
||||
|
||||
Returns the Recipe ORM object.
|
||||
"""
|
||||
recipe = Recipe(
|
||||
code=code,
|
||||
name=name,
|
||||
description="A recipe for testing",
|
||||
created_by=user_id,
|
||||
)
|
||||
session.add(recipe)
|
||||
await session.flush()
|
||||
|
||||
version = RecipeVersion(
|
||||
recipe_id=recipe.id,
|
||||
version_number=1,
|
||||
is_current=True,
|
||||
created_by=user_id,
|
||||
change_notes="Initial version",
|
||||
)
|
||||
session.add(version)
|
||||
await session.flush()
|
||||
|
||||
task = RecipeTask(
|
||||
version_id=version.id,
|
||||
order_index=0,
|
||||
title="Test Task",
|
||||
directive="Measure the part",
|
||||
description="First measurement task",
|
||||
)
|
||||
session.add(task)
|
||||
await session.flush()
|
||||
|
||||
subtask = RecipeSubtask(
|
||||
task_id=task.id,
|
||||
marker_number=1,
|
||||
description="Diameter measurement",
|
||||
measurement_type="diameter",
|
||||
nominal=10.0,
|
||||
utl=10.5,
|
||||
uwl=10.3,
|
||||
lwl=9.7,
|
||||
ltl=9.5,
|
||||
unit="mm",
|
||||
)
|
||||
session.add(subtask)
|
||||
await session.flush()
|
||||
|
||||
await session.refresh(recipe, attribute_names=["versions"])
|
||||
return recipe
|
||||
|
||||
|
||||
def auth_headers(user: User) -> dict[str, str]:
|
||||
"""Return headers dict with the user's API key for authenticated requests."""
|
||||
return {"X-API-Key": user.api_key}
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Tests for authentication router (/api/auth)."""
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from src.backend.tests.conftest import _create_user, auth_headers
|
||||
|
||||
|
||||
class TestLogin:
|
||||
"""POST /api/auth/login tests."""
|
||||
|
||||
async def test_login_success(self, client: AsyncClient, db_session):
|
||||
"""Successful login returns user data and API key."""
|
||||
user = await _create_user(
|
||||
db_session, username="loginuser", password="SecurePass1"
|
||||
)
|
||||
resp = await client.post(
|
||||
"/api/auth/login",
|
||||
json={"username": "loginuser", "password": "SecurePass1"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["user"]["username"] == "loginuser"
|
||||
assert "api_key" in data
|
||||
assert len(data["api_key"]) > 0
|
||||
|
||||
async def test_login_wrong_password(self, client: AsyncClient, db_session):
|
||||
"""Login with wrong password returns 401."""
|
||||
await _create_user(
|
||||
db_session, username="wrongpw", password="CorrectPass1"
|
||||
)
|
||||
resp = await client.post(
|
||||
"/api/auth/login",
|
||||
json={"username": "wrongpw", "password": "WrongPass1"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
assert "Invalid" in resp.json()["detail"]
|
||||
|
||||
async def test_login_unknown_user(self, client: AsyncClient):
|
||||
"""Login with non-existent username returns 401."""
|
||||
resp = await client.post(
|
||||
"/api/auth/login",
|
||||
json={"username": "nonexistent", "password": "Anything1"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
async def test_login_inactive_user(self, client: AsyncClient, db_session):
|
||||
"""Login with inactive user returns 401."""
|
||||
await _create_user(
|
||||
db_session,
|
||||
username="inactive_user",
|
||||
password="InactivePass1",
|
||||
active=False,
|
||||
)
|
||||
resp = await client.post(
|
||||
"/api/auth/login",
|
||||
json={"username": "inactive_user", "password": "InactivePass1"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
async def test_login_missing_fields(self, client: AsyncClient):
|
||||
"""Login with missing fields returns 422."""
|
||||
resp = await client.post("/api/auth/login", json={"username": "only"})
|
||||
assert resp.status_code == 422
|
||||
|
||||
async def test_login_returns_api_key(self, client: AsyncClient, db_session):
|
||||
"""Login response contains a valid api_key string."""
|
||||
await _create_user(
|
||||
db_session, username="apikey_user", password="KeyPass123"
|
||||
)
|
||||
resp = await client.post(
|
||||
"/api/auth/login",
|
||||
json={"username": "apikey_user", "password": "KeyPass123"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
api_key = resp.json()["api_key"]
|
||||
# API key should be a non-empty string (base64url, ~64 chars)
|
||||
assert isinstance(api_key, str)
|
||||
assert len(api_key) >= 32
|
||||
|
||||
|
||||
class TestHealthAndAuth:
|
||||
"""Health check and auth requirement tests."""
|
||||
|
||||
async def test_health_check_no_auth(self, client: AsyncClient):
|
||||
"""Health check endpoint works without authentication."""
|
||||
resp = await client.get("/api/health")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["status"] == "ok"
|
||||
assert "version" in data
|
||||
|
||||
async def test_endpoint_requires_api_key(self, client: AsyncClient):
|
||||
"""Protected endpoint returns 401 without API key."""
|
||||
resp = await client.get("/api/users")
|
||||
assert resp.status_code == 401
|
||||
assert "API key" in resp.json()["detail"] or "Missing" in resp.json()["detail"]
|
||||
@@ -0,0 +1,175 @@
|
||||
"""Tests for files router (/api/files)."""
|
||||
import io
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from src.backend.models.orm.user import User
|
||||
from src.backend.tests.conftest import auth_headers
|
||||
|
||||
|
||||
class TestUploadFile:
|
||||
"""POST /api/files/upload tests."""
|
||||
|
||||
async def test_upload_image(
|
||||
self, client: AsyncClient, maker_user: User, tmp_path: Path
|
||||
):
|
||||
"""Maker can upload a valid image."""
|
||||
# Create a minimal valid JPEG (1x1 pixel)
|
||||
jpeg_bytes = (
|
||||
b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01"
|
||||
b"\x00\x01\x00\x00\xff\xdb\x00C\x00\x08\x06\x06\x07\x06"
|
||||
b"\x05\x08\x07\x07\x07\t\t\x08\n\x0c\x14\r\x0c\x0b\x0b"
|
||||
b"\x0c\x19\x12\x13\x0f\x14\x1d\x1a\x1f\x1e\x1d\x1a\x1c"
|
||||
b"\x1c $.\' \",#\x1c\x1c(7),01444\x1f\'9=82<.342\xff\xc0"
|
||||
b"\x00\x0b\x08\x00\x01\x00\x01\x01\x01\x11\x00\xff\xc4"
|
||||
b"\x00\x1f\x00\x00\x01\x05\x01\x01\x01\x01\x01\x01\x00"
|
||||
b"\x00\x00\x00\x00\x00\x00\x00\x01\x02\x03\x04\x05\x06"
|
||||
b"\x07\x08\t\n\x0b\xff\xc4\x00\xb5\x10\x00\x02\x01\x03"
|
||||
b"\x03\x02\x04\x03\x05\x05\x04\x04\x00\x00\x01}\x01\x02"
|
||||
b"\x03\x00\x04\x11\x05\x12!1A\x06\x13Qa\x07\"q\x142\x81"
|
||||
b"\x91\xa1\x08#B\xb1\xc1\x15R\xd1\xf0$3br\x82\t\n\x16"
|
||||
b"\x17\x18\x19\x1a%&\'()*456789:CDEFGHIJSTUVWXYZcdefghij"
|
||||
b"stuvwxyz\x83\x84\x85\x86\x87\x88\x89\x8a\x92\x93\x94"
|
||||
b"\x95\x96\x97\x98\x99\x9a\xa2\xa3\xa4\xa5\xa6\xa7\xa8"
|
||||
b"\xa9\xaa\xb2\xb3\xb4\xb5\xb6\xb7\xb8\xb9\xba\xc2\xc3"
|
||||
b"\xc4\xc5\xc6\xc7\xc8\xc9\xca\xd2\xd3\xd4\xd5\xd6\xd7"
|
||||
b"\xd8\xd9\xda\xe1\xe2\xe3\xe4\xe5\xe6\xe7\xe8\xe9\xea"
|
||||
b"\xf1\xf2\xf3\xf4\xf5\xf6\xf7\xf8\xf9\xfa\xff\xda\x00"
|
||||
b"\x08\x01\x01\x00\x00?\x00T\xdb\xae\x8e\xd3H\xa5(?"
|
||||
b"\xff\xd9"
|
||||
)
|
||||
|
||||
# Patch settings.upload_path to use tmp_path
|
||||
with patch("src.backend.api.routers.files.settings") as mock_settings:
|
||||
mock_settings.upload_path = tmp_path
|
||||
mock_settings.max_upload_size_mb = 50
|
||||
|
||||
resp = await client.post(
|
||||
"/api/files/upload",
|
||||
headers=auth_headers(maker_user),
|
||||
files={"file": ("test.jpg", io.BytesIO(jpeg_bytes), "image/jpeg")},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "file_path" in data
|
||||
assert data["file_type"] == "image/jpeg"
|
||||
assert data["file_size"] > 0
|
||||
|
||||
async def test_upload_invalid_type(
|
||||
self, client: AsyncClient, maker_user: User
|
||||
):
|
||||
"""Uploading a disallowed file type returns 400."""
|
||||
resp = await client.post(
|
||||
"/api/files/upload",
|
||||
headers=auth_headers(maker_user),
|
||||
files={
|
||||
"file": ("malware.exe", io.BytesIO(b"MZ\x00\x00"), "application/x-msdownload")
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "not allowed" in resp.json()["detail"]
|
||||
|
||||
async def test_upload_too_large(
|
||||
self, client: AsyncClient, maker_user: User
|
||||
):
|
||||
"""Uploading a file that exceeds size limit returns 400."""
|
||||
# Create a large file content
|
||||
large_content = b"\x00" * (51 * 1024 * 1024) # 51MB
|
||||
|
||||
with patch("src.backend.api.routers.files.settings") as mock_settings:
|
||||
mock_settings.max_upload_size_mb = 50
|
||||
mock_settings.upload_path = Path(tempfile.mkdtemp())
|
||||
|
||||
resp = await client.post(
|
||||
"/api/files/upload",
|
||||
headers=auth_headers(maker_user),
|
||||
files={
|
||||
"file": ("big.jpg", io.BytesIO(large_content), "image/jpeg")
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 400
|
||||
assert "exceeds" in resp.json()["detail"]
|
||||
|
||||
|
||||
class TestGetFile:
|
||||
"""GET /api/files/{file_path} tests."""
|
||||
|
||||
async def test_get_file(
|
||||
self, client: AsyncClient, maker_user: User, tmp_path: Path
|
||||
):
|
||||
"""Authenticated user can retrieve an uploaded file."""
|
||||
# Create a test file in tmp_path
|
||||
test_file = tmp_path / "testfile.txt"
|
||||
test_file.write_text("hello test")
|
||||
|
||||
with patch("src.backend.api.routers.files.settings") as mock_settings:
|
||||
mock_settings.upload_path = tmp_path
|
||||
|
||||
resp = await client.get(
|
||||
"/api/files/testfile.txt",
|
||||
headers=auth_headers(maker_user),
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
|
||||
async def test_get_file_not_found(
|
||||
self, client: AsyncClient, maker_user: User, tmp_path: Path
|
||||
):
|
||||
"""Requesting a non-existent file returns 404."""
|
||||
with patch("src.backend.api.routers.files.settings") as mock_settings:
|
||||
mock_settings.upload_path = tmp_path
|
||||
|
||||
resp = await client.get(
|
||||
"/api/files/nonexistent.png",
|
||||
headers=auth_headers(maker_user),
|
||||
)
|
||||
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
class TestDeleteFile:
|
||||
"""DELETE /api/files/{file_path} tests."""
|
||||
|
||||
async def test_delete_file(
|
||||
self, client: AsyncClient, maker_user: User, tmp_path: Path
|
||||
):
|
||||
"""Maker can delete a file."""
|
||||
test_file = tmp_path / "deleteme.txt"
|
||||
test_file.write_text("delete me")
|
||||
|
||||
with patch("src.backend.api.routers.files.settings") as mock_settings:
|
||||
mock_settings.upload_path = tmp_path
|
||||
|
||||
resp = await client.delete(
|
||||
"/api/files/deleteme.txt",
|
||||
headers=auth_headers(maker_user),
|
||||
)
|
||||
|
||||
assert resp.status_code == 204
|
||||
assert not test_file.exists()
|
||||
|
||||
|
||||
class TestFilenameSanitization:
|
||||
"""Path traversal protection tests."""
|
||||
|
||||
async def test_path_traversal_blocked(
|
||||
self, client: AsyncClient, maker_user: User, tmp_path: Path
|
||||
):
|
||||
"""Path traversal attempt returns 403 or 404."""
|
||||
with patch("src.backend.api.routers.files.settings") as mock_settings:
|
||||
mock_settings.upload_path = tmp_path
|
||||
|
||||
resp = await client.get(
|
||||
"/api/files/../../etc/passwd",
|
||||
headers=auth_headers(maker_user),
|
||||
)
|
||||
|
||||
# Should be blocked by security check
|
||||
assert resp.status_code in (403, 404)
|
||||
@@ -0,0 +1,278 @@
|
||||
"""Tests for measurements router (/api/measurements)."""
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.backend.models.orm.user import User
|
||||
from src.backend.models.orm.recipe import RecipeVersion
|
||||
from src.backend.models.orm.task import RecipeTask, RecipeSubtask
|
||||
from src.backend.tests.conftest import auth_headers, create_test_recipe
|
||||
|
||||
|
||||
async def _get_subtask_and_version(
|
||||
client: AsyncClient, user: User, recipe_id: int
|
||||
) -> tuple[int, int]:
|
||||
"""Helper: return (subtask_id, version_id) from the recipe's current version."""
|
||||
tasks_resp = await client.get(
|
||||
f"/api/recipes/{recipe_id}/tasks", headers=auth_headers(user)
|
||||
)
|
||||
task = tasks_resp.json()[0]
|
||||
subtask_id = task["subtasks"][0]["id"]
|
||||
version_id = task["version_id"]
|
||||
return subtask_id, version_id
|
||||
|
||||
|
||||
class TestCreateMeasurement:
|
||||
"""POST /api/measurements/ tests."""
|
||||
|
||||
async def test_create_measurement(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
measurement_tec_user: User,
|
||||
db_session: AsyncSession,
|
||||
):
|
||||
"""MeasurementTec can create a measurement."""
|
||||
recipe = await create_test_recipe(
|
||||
db_session, measurement_tec_user.id
|
||||
)
|
||||
subtask_id, version_id = await _get_subtask_and_version(
|
||||
client, measurement_tec_user, recipe.id
|
||||
)
|
||||
|
||||
resp = await client.post(
|
||||
"/api/measurements/",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
json={
|
||||
"subtask_id": subtask_id,
|
||||
"version_id": version_id,
|
||||
"value": 10.1,
|
||||
"input_method": "manual",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["value"] == pytest.approx(10.1, rel=1e-4)
|
||||
assert data["pass_fail"] in ("pass", "warning", "fail")
|
||||
assert data["measured_by"] == measurement_tec_user.id
|
||||
|
||||
async def test_measurement_requires_auth(self, client: AsyncClient):
|
||||
"""Creating measurement without auth returns 401."""
|
||||
resp = await client.post(
|
||||
"/api/measurements/",
|
||||
json={
|
||||
"subtask_id": 1,
|
||||
"version_id": 1,
|
||||
"value": 10.0,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
async def test_measurement_validation(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
measurement_tec_user: User,
|
||||
):
|
||||
"""Measurement with non-existent subtask returns 400."""
|
||||
resp = await client.post(
|
||||
"/api/measurements/",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
json={
|
||||
"subtask_id": 99999,
|
||||
"version_id": 99999,
|
||||
"value": 10.0,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
class TestMeasurementPassFail:
|
||||
"""Pass/fail calculation tests."""
|
||||
|
||||
async def test_measurement_pass(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
measurement_tec_user: User,
|
||||
db_session: AsyncSession,
|
||||
):
|
||||
"""Value within tolerance -> pass."""
|
||||
recipe = await create_test_recipe(
|
||||
db_session, measurement_tec_user.id
|
||||
)
|
||||
subtask_id, version_id = await _get_subtask_and_version(
|
||||
client, measurement_tec_user, recipe.id
|
||||
)
|
||||
|
||||
# nominal=10.0, utl=10.5, uwl=10.3, lwl=9.7, ltl=9.5
|
||||
resp = await client.post(
|
||||
"/api/measurements/",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
json={
|
||||
"subtask_id": subtask_id,
|
||||
"version_id": version_id,
|
||||
"value": 10.0,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["pass_fail"] == "pass"
|
||||
|
||||
async def test_measurement_warning(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
measurement_tec_user: User,
|
||||
db_session: AsyncSession,
|
||||
):
|
||||
"""Value outside warning but inside tolerance -> warning."""
|
||||
recipe = await create_test_recipe(
|
||||
db_session, measurement_tec_user.id
|
||||
)
|
||||
subtask_id, version_id = await _get_subtask_and_version(
|
||||
client, measurement_tec_user, recipe.id
|
||||
)
|
||||
|
||||
# uwl=10.3 < 10.4 < utl=10.5 -> warning
|
||||
resp = await client.post(
|
||||
"/api/measurements/",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
json={
|
||||
"subtask_id": subtask_id,
|
||||
"version_id": version_id,
|
||||
"value": 10.4,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["pass_fail"] == "warning"
|
||||
|
||||
async def test_measurement_fail(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
measurement_tec_user: User,
|
||||
db_session: AsyncSession,
|
||||
):
|
||||
"""Value outside tolerance limits -> fail."""
|
||||
recipe = await create_test_recipe(
|
||||
db_session, measurement_tec_user.id
|
||||
)
|
||||
subtask_id, version_id = await _get_subtask_and_version(
|
||||
client, measurement_tec_user, recipe.id
|
||||
)
|
||||
|
||||
# value > utl=10.5 -> fail
|
||||
resp = await client.post(
|
||||
"/api/measurements/",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
json={
|
||||
"subtask_id": subtask_id,
|
||||
"version_id": version_id,
|
||||
"value": 10.6,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["pass_fail"] == "fail"
|
||||
|
||||
|
||||
class TestListMeasurements:
|
||||
"""GET /api/measurements/ tests."""
|
||||
|
||||
async def test_list_measurements(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
measurement_tec_user: User,
|
||||
metrologist_user: User,
|
||||
db_session: AsyncSession,
|
||||
):
|
||||
"""Metrologist can list measurements with pagination."""
|
||||
recipe = await create_test_recipe(
|
||||
db_session, measurement_tec_user.id
|
||||
)
|
||||
subtask_id, version_id = await _get_subtask_and_version(
|
||||
client, measurement_tec_user, recipe.id
|
||||
)
|
||||
|
||||
# Create some measurements
|
||||
for val in [9.8, 10.0, 10.2]:
|
||||
await client.post(
|
||||
"/api/measurements/",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
json={
|
||||
"subtask_id": subtask_id,
|
||||
"version_id": version_id,
|
||||
"value": val,
|
||||
},
|
||||
)
|
||||
|
||||
# List as Metrologist
|
||||
resp = await client.get(
|
||||
"/api/measurements/",
|
||||
headers=auth_headers(metrologist_user),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "items" in data
|
||||
assert "total" in data
|
||||
assert data["total"] >= 3
|
||||
|
||||
async def test_measurement_by_recipe_filter(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
measurement_tec_user: User,
|
||||
metrologist_user: User,
|
||||
db_session: AsyncSession,
|
||||
):
|
||||
"""Filtering measurements by recipe_id returns only matching records."""
|
||||
recipe = await create_test_recipe(
|
||||
db_session, measurement_tec_user.id
|
||||
)
|
||||
subtask_id, version_id = await _get_subtask_and_version(
|
||||
client, measurement_tec_user, recipe.id
|
||||
)
|
||||
|
||||
await client.post(
|
||||
"/api/measurements/",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
json={
|
||||
"subtask_id": subtask_id,
|
||||
"version_id": version_id,
|
||||
"value": 10.0,
|
||||
},
|
||||
)
|
||||
|
||||
resp = await client.get(
|
||||
"/api/measurements/",
|
||||
headers=auth_headers(metrologist_user),
|
||||
params={"recipe_id": recipe.id},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["total"] >= 1
|
||||
|
||||
|
||||
class TestMeasurementStatistics:
|
||||
"""Measurement deviation / statistics edge case tests."""
|
||||
|
||||
async def test_measurement_has_deviation(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
measurement_tec_user: User,
|
||||
db_session: AsyncSession,
|
||||
):
|
||||
"""Measurement response includes deviation from nominal."""
|
||||
recipe = await create_test_recipe(
|
||||
db_session, measurement_tec_user.id
|
||||
)
|
||||
subtask_id, version_id = await _get_subtask_and_version(
|
||||
client, measurement_tec_user, recipe.id
|
||||
)
|
||||
|
||||
resp = await client.post(
|
||||
"/api/measurements/",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
json={
|
||||
"subtask_id": subtask_id,
|
||||
"version_id": version_id,
|
||||
"value": 10.2,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
# deviation = 10.2 - nominal(10.0) = 0.2
|
||||
assert data["deviation"] is not None
|
||||
assert abs(data["deviation"] - 0.2) < 0.001
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Tests for RateLimitMiddleware honoring proxy headers (X-Forwarded-For)."""
|
||||
from src.backend.api.middleware.rate_limit import RateLimitMiddleware
|
||||
|
||||
|
||||
class _FakeRequest:
|
||||
"""Minimal stand-in for starlette.requests.Request."""
|
||||
|
||||
def __init__(self, headers: dict[str, str], host: str | None = "10.0.0.1"):
|
||||
self.headers = headers
|
||||
|
||||
class _Client:
|
||||
pass
|
||||
|
||||
if host is None:
|
||||
self.client = None
|
||||
else:
|
||||
self.client = _Client()
|
||||
self.client.host = host
|
||||
|
||||
|
||||
def test_client_ip_uses_x_forwarded_for_first_hop():
|
||||
req = _FakeRequest(
|
||||
headers={"x-forwarded-for": "203.0.113.5, 10.0.0.2"},
|
||||
host="10.0.0.1",
|
||||
)
|
||||
assert RateLimitMiddleware._client_ip(req) == "203.0.113.5"
|
||||
|
||||
|
||||
def test_client_ip_strips_whitespace():
|
||||
req = _FakeRequest(headers={"x-forwarded-for": " 198.51.100.7 "})
|
||||
assert RateLimitMiddleware._client_ip(req) == "198.51.100.7"
|
||||
|
||||
|
||||
def test_client_ip_falls_back_to_x_real_ip():
|
||||
req = _FakeRequest(headers={"x-real-ip": "203.0.113.99"}, host="10.0.0.1")
|
||||
assert RateLimitMiddleware._client_ip(req) == "203.0.113.99"
|
||||
|
||||
|
||||
def test_client_ip_falls_back_to_request_client_host():
|
||||
req = _FakeRequest(headers={}, host="172.18.0.5")
|
||||
assert RateLimitMiddleware._client_ip(req) == "172.18.0.5"
|
||||
|
||||
|
||||
def test_client_ip_returns_unknown_without_client():
|
||||
req = _FakeRequest(headers={}, host=None)
|
||||
assert RateLimitMiddleware._client_ip(req) == "unknown"
|
||||
|
||||
|
||||
def test_x_forwarded_for_overrides_x_real_ip():
|
||||
req = _FakeRequest(
|
||||
headers={
|
||||
"x-forwarded-for": "203.0.113.5",
|
||||
"x-real-ip": "10.0.0.2",
|
||||
},
|
||||
host="10.0.0.1",
|
||||
)
|
||||
assert RateLimitMiddleware._client_ip(req) == "203.0.113.5"
|
||||
|
||||
|
||||
def test_empty_x_forwarded_for_falls_through():
|
||||
req = _FakeRequest(
|
||||
headers={"x-forwarded-for": "", "x-real-ip": "203.0.113.42"},
|
||||
host="10.0.0.1",
|
||||
)
|
||||
assert RateLimitMiddleware._client_ip(req) == "203.0.113.42"
|
||||
@@ -0,0 +1,252 @@
|
||||
"""Tests for recipes router (/api/recipes)."""
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.backend.models.orm.user import User
|
||||
from src.backend.tests.conftest import auth_headers, create_test_recipe
|
||||
|
||||
|
||||
class TestListRecipes:
|
||||
"""GET /api/recipes tests."""
|
||||
|
||||
async def test_list_recipes(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
maker_user: User,
|
||||
db_session: AsyncSession,
|
||||
):
|
||||
"""Authenticated user can list recipes."""
|
||||
await create_test_recipe(db_session, maker_user.id)
|
||||
resp = await client.get(
|
||||
"/api/recipes", headers=auth_headers(maker_user)
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "items" in data
|
||||
assert "total" in data
|
||||
assert data["total"] >= 1
|
||||
assert len(data["items"]) >= 1
|
||||
|
||||
async def test_search_recipes(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
maker_user: User,
|
||||
db_session: AsyncSession,
|
||||
):
|
||||
"""Search parameter filters recipes by name or code."""
|
||||
await create_test_recipe(
|
||||
db_session, maker_user.id, code="SEARCH-001", name="Searchable Recipe"
|
||||
)
|
||||
await create_test_recipe(
|
||||
db_session, maker_user.id, code="OTHER-002", name="Other Recipe"
|
||||
)
|
||||
resp = await client.get(
|
||||
"/api/recipes",
|
||||
headers=auth_headers(maker_user),
|
||||
params={"search": "Searchable"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] >= 1
|
||||
names = [item["name"] for item in data["items"]]
|
||||
assert "Searchable Recipe" in names
|
||||
|
||||
|
||||
class TestGetRecipe:
|
||||
"""GET /api/recipes/{recipe_id} tests."""
|
||||
|
||||
async def test_get_recipe(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
maker_user: User,
|
||||
db_session: AsyncSession,
|
||||
):
|
||||
"""Get single recipe detail with current version."""
|
||||
recipe = await create_test_recipe(db_session, maker_user.id)
|
||||
resp = await client.get(
|
||||
f"/api/recipes/{recipe.id}", headers=auth_headers(maker_user)
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["code"] == "REC-001"
|
||||
assert data["name"] == "Test Recipe"
|
||||
assert data["current_version"] is not None
|
||||
assert data["current_version"]["version_number"] == 1
|
||||
|
||||
async def test_recipe_not_found(
|
||||
self, client: AsyncClient, maker_user: User
|
||||
):
|
||||
"""Non-existent recipe returns 404."""
|
||||
resp = await client.get(
|
||||
"/api/recipes/99999", headers=auth_headers(maker_user)
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
class TestCreateRecipe:
|
||||
"""POST /api/recipes tests."""
|
||||
|
||||
async def test_create_recipe_as_maker(
|
||||
self, client: AsyncClient, maker_user: User
|
||||
):
|
||||
"""Maker can create a recipe.
|
||||
|
||||
Note: The POST response may not include ``current_version`` due to
|
||||
async lazy-loading limitations with the in-memory SQLite test engine.
|
||||
We verify the created recipe via a follow-up GET which uses
|
||||
``selectinload`` and therefore loads the full object graph correctly.
|
||||
"""
|
||||
resp = await client.post(
|
||||
"/api/recipes",
|
||||
headers=auth_headers(maker_user),
|
||||
json={
|
||||
"code": "NEW-001",
|
||||
"name": "New Recipe",
|
||||
"description": "Created in test",
|
||||
},
|
||||
)
|
||||
# Accept either 201 (success) or 500 (lazy-loading limitation with
|
||||
# SQLite in-memory; the recipe IS created, the serialisation fails)
|
||||
assert resp.status_code in (201, 500)
|
||||
|
||||
# Verify via GET which uses full selectinload
|
||||
list_resp = await client.get(
|
||||
"/api/recipes",
|
||||
headers=auth_headers(maker_user),
|
||||
params={"search": "NEW-001"},
|
||||
)
|
||||
assert list_resp.status_code == 200
|
||||
items = list_resp.json()["items"]
|
||||
assert len(items) >= 1
|
||||
recipe = items[0]
|
||||
assert recipe["code"] == "NEW-001"
|
||||
assert recipe["name"] == "New Recipe"
|
||||
assert recipe["active"] is True
|
||||
# Should have current version v1
|
||||
assert recipe["current_version"] is not None
|
||||
assert recipe["current_version"]["version_number"] == 1
|
||||
|
||||
async def test_create_recipe_forbidden(
|
||||
self, client: AsyncClient, measurement_tec_user: User
|
||||
):
|
||||
"""MeasurementTec without Maker role cannot create recipes."""
|
||||
resp = await client.post(
|
||||
"/api/recipes",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
json={
|
||||
"code": "FORBIDDEN-001",
|
||||
"name": "Forbidden Recipe",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
async def test_create_recipe_duplicate_code(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
maker_user: User,
|
||||
db_session: AsyncSession,
|
||||
):
|
||||
"""Duplicate recipe code returns 409."""
|
||||
await create_test_recipe(db_session, maker_user.id, code="DUP-001")
|
||||
resp = await client.post(
|
||||
"/api/recipes",
|
||||
headers=auth_headers(maker_user),
|
||||
json={"code": "DUP-001", "name": "Duplicate"},
|
||||
)
|
||||
assert resp.status_code == 409
|
||||
|
||||
|
||||
class TestUpdateRecipe:
|
||||
"""PUT /api/recipes/{recipe_id} tests."""
|
||||
|
||||
async def test_update_recipe(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
maker_user: User,
|
||||
db_session: AsyncSession,
|
||||
):
|
||||
"""Updating a recipe creates a new version (copy-on-write).
|
||||
|
||||
We verify the new version exists by listing versions, since
|
||||
SQLAlchemy identity-map staleness with StaticPool can cause
|
||||
``is_current`` to appear stale in some responses.
|
||||
"""
|
||||
recipe = await create_test_recipe(db_session, maker_user.id)
|
||||
resp = await client.put(
|
||||
f"/api/recipes/{recipe.id}",
|
||||
headers=auth_headers(maker_user),
|
||||
json={
|
||||
"name": "Updated Recipe",
|
||||
"change_notes": "Updated name in test",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
# Verify via versions list which always does a clean query
|
||||
versions_resp = await client.get(
|
||||
f"/api/recipes/{recipe.id}/versions",
|
||||
headers=auth_headers(maker_user),
|
||||
)
|
||||
assert versions_resp.status_code == 200
|
||||
versions = versions_resp.json()
|
||||
version_numbers = [v["version_number"] for v in versions]
|
||||
assert 1 in version_numbers
|
||||
assert 2 in version_numbers
|
||||
|
||||
|
||||
class TestDeleteRecipe:
|
||||
"""DELETE /api/recipes/{recipe_id} tests."""
|
||||
|
||||
async def test_delete_recipe(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
maker_user: User,
|
||||
db_session: AsyncSession,
|
||||
):
|
||||
"""Soft-delete deactivates a recipe."""
|
||||
recipe = await create_test_recipe(db_session, maker_user.id)
|
||||
resp = await client.delete(
|
||||
f"/api/recipes/{recipe.id}", headers=auth_headers(maker_user)
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["active"] is False
|
||||
|
||||
|
||||
class TestRecipeVersioning:
|
||||
"""Versioning-related tests."""
|
||||
|
||||
async def test_recipe_versioning(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
maker_user: User,
|
||||
db_session: AsyncSession,
|
||||
):
|
||||
"""Multiple updates create consecutive versions."""
|
||||
recipe = await create_test_recipe(db_session, maker_user.id)
|
||||
|
||||
# Update once -> v2
|
||||
await client.put(
|
||||
f"/api/recipes/{recipe.id}",
|
||||
headers=auth_headers(maker_user),
|
||||
json={"change_notes": "Version 2"},
|
||||
)
|
||||
|
||||
# Update again -> v3
|
||||
await client.put(
|
||||
f"/api/recipes/{recipe.id}",
|
||||
headers=auth_headers(maker_user),
|
||||
json={"change_notes": "Version 3"},
|
||||
)
|
||||
|
||||
# List versions
|
||||
resp = await client.get(
|
||||
f"/api/recipes/{recipe.id}/versions",
|
||||
headers=auth_headers(maker_user),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
versions = resp.json()
|
||||
version_numbers = [v["version_number"] for v in versions]
|
||||
assert 1 in version_numbers
|
||||
assert 2 in version_numbers
|
||||
assert 3 in version_numbers
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Tests for reports router (/api/reports).
|
||||
|
||||
Note: Report generation requires WeasyPrint/Kaleido and Jinja2 templates.
|
||||
These tests mock the PDF generation to test endpoint plumbing and auth.
|
||||
"""
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.backend.models.orm.user import User
|
||||
from src.backend.tests.conftest import auth_headers, create_test_recipe
|
||||
|
||||
|
||||
class TestMeasurementReport:
|
||||
"""GET /api/reports/measurements tests."""
|
||||
|
||||
async def test_generate_measurement_report(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
metrologist_user: User,
|
||||
db_session: AsyncSession,
|
||||
):
|
||||
"""Metrologist can generate a measurement report (mocked PDF)."""
|
||||
recipe = await create_test_recipe(
|
||||
db_session, metrologist_user.id
|
||||
)
|
||||
|
||||
fake_pdf = b"%PDF-1.4 fake content"
|
||||
with patch(
|
||||
"src.backend.api.routers.reports.generate_measurement_report",
|
||||
new_callable=AsyncMock,
|
||||
return_value=fake_pdf,
|
||||
):
|
||||
resp = await client.get(
|
||||
"/api/reports/measurements",
|
||||
headers=auth_headers(metrologist_user),
|
||||
params={"recipe_id": recipe.id},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.headers["content-type"] == "application/pdf"
|
||||
assert b"%PDF" in resp.content
|
||||
|
||||
async def test_report_requires_auth(self, client: AsyncClient):
|
||||
"""Report endpoints require authentication."""
|
||||
resp = await client.get(
|
||||
"/api/reports/measurements",
|
||||
params={"recipe_id": 1},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
class TestSPCReport:
|
||||
"""GET /api/reports/spc tests."""
|
||||
|
||||
async def test_generate_spc_report(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
metrologist_user: User,
|
||||
db_session: AsyncSession,
|
||||
):
|
||||
"""Metrologist can generate an SPC report (mocked PDF)."""
|
||||
recipe = await create_test_recipe(
|
||||
db_session, metrologist_user.id
|
||||
)
|
||||
|
||||
# Get subtask_id
|
||||
tasks_resp = await client.get(
|
||||
f"/api/recipes/{recipe.id}/tasks",
|
||||
headers=auth_headers(metrologist_user),
|
||||
)
|
||||
subtask_id = tasks_resp.json()[0]["subtasks"][0]["id"]
|
||||
|
||||
fake_pdf = b"%PDF-1.4 spc report content"
|
||||
with patch(
|
||||
"src.backend.api.routers.reports.generate_spc_report",
|
||||
new_callable=AsyncMock,
|
||||
return_value=fake_pdf,
|
||||
):
|
||||
resp = await client.get(
|
||||
"/api/reports/spc",
|
||||
headers=auth_headers(metrologist_user),
|
||||
params={
|
||||
"recipe_id": recipe.id,
|
||||
"subtask_id": subtask_id,
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.headers["content-type"] == "application/pdf"
|
||||
|
||||
async def test_spc_report_requires_metrologist(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
maker_user: User,
|
||||
db_session: AsyncSession,
|
||||
):
|
||||
"""Non-Metrologist cannot generate SPC reports."""
|
||||
recipe = await create_test_recipe(db_session, maker_user.id)
|
||||
resp = await client.get(
|
||||
"/api/reports/spc",
|
||||
headers=auth_headers(maker_user),
|
||||
params={"recipe_id": recipe.id, "subtask_id": 1},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
@@ -0,0 +1,161 @@
|
||||
"""Security integration tests for FastAPI server.
|
||||
|
||||
Covers CORS, rate limiting, security headers, API key auth,
|
||||
path traversal protection, filename sanitization, and OPTIONS preflight.
|
||||
"""
|
||||
import time
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from src.backend.tests.conftest import _create_user, auth_headers
|
||||
|
||||
|
||||
class TestCORSHeaders:
|
||||
"""CORS header tests."""
|
||||
|
||||
async def test_cors_headers_present(self, client: AsyncClient):
|
||||
"""Response includes CORS headers for allowed origin."""
|
||||
resp = await client.get(
|
||||
"/api/health",
|
||||
headers={"Origin": "http://localhost:5000"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert "access-control-allow-origin" in resp.headers
|
||||
|
||||
|
||||
class TestSecurityHeaders:
|
||||
"""Security header tests."""
|
||||
|
||||
async def test_security_headers_present(self, client: AsyncClient):
|
||||
"""Response includes all standard security headers."""
|
||||
resp = await client.get("/api/health")
|
||||
assert resp.status_code == 200
|
||||
assert resp.headers.get("x-content-type-options") == "nosniff"
|
||||
assert resp.headers.get("x-frame-options") == "DENY"
|
||||
assert resp.headers.get("x-xss-protection") == "1; mode=block"
|
||||
assert "strict-origin" in resp.headers.get("referrer-policy", "")
|
||||
assert "default-src" in resp.headers.get("content-security-policy", "")
|
||||
|
||||
|
||||
class TestAPIKeyAuth:
|
||||
"""API key authentication tests."""
|
||||
|
||||
async def test_protected_endpoint_requires_api_key(self, client: AsyncClient):
|
||||
"""Protected endpoint returns 401 without API key."""
|
||||
resp = await client.get("/api/users")
|
||||
assert resp.status_code == 401
|
||||
detail = resp.json().get("detail", "")
|
||||
assert "API key" in detail or "Missing" in detail
|
||||
|
||||
async def test_invalid_api_key_returns_401(self, client: AsyncClient):
|
||||
"""Invalid API key returns 401."""
|
||||
resp = await client.get(
|
||||
"/api/users",
|
||||
headers={"X-API-Key": "totally-invalid-key-12345"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
detail = resp.json().get("detail", "")
|
||||
assert "Invalid" in detail or "inactive" in detail
|
||||
|
||||
|
||||
class TestRateLimiting:
|
||||
"""Rate limiting tests."""
|
||||
|
||||
async def test_login_rate_limit_returns_429(self, client: AsyncClient, db_session):
|
||||
"""Exceeding login rate limit returns 429."""
|
||||
# Default rate_limit_login = 5 per minute
|
||||
# Send 6 requests to exceed the limit
|
||||
for i in range(6):
|
||||
resp = await client.post(
|
||||
"/api/auth/login",
|
||||
json={"username": f"user{i}", "password": "pass"},
|
||||
)
|
||||
|
||||
# The 6th request (or beyond) should be rate limited
|
||||
# Send one more to be sure
|
||||
resp = await client.post(
|
||||
"/api/auth/login",
|
||||
json={"username": "overflow", "password": "pass"},
|
||||
)
|
||||
assert resp.status_code == 429
|
||||
assert "Too many" in resp.json().get("detail", "")
|
||||
|
||||
async def test_rate_limit_retry_after_header(self, client: AsyncClient):
|
||||
"""429 response includes Retry-After header."""
|
||||
# Exhaust login rate limit
|
||||
for i in range(7):
|
||||
resp = await client.post(
|
||||
"/api/auth/login",
|
||||
json={"username": f"retry{i}", "password": "pass"},
|
||||
)
|
||||
|
||||
# Final request should be 429 with Retry-After
|
||||
resp = await client.post(
|
||||
"/api/auth/login",
|
||||
json={"username": "retrycheck", "password": "pass"},
|
||||
)
|
||||
assert resp.status_code == 429
|
||||
assert "retry-after" in resp.headers
|
||||
retry_val = int(resp.headers["retry-after"])
|
||||
assert retry_val >= 1
|
||||
|
||||
|
||||
class TestPathTraversal:
|
||||
"""Path traversal protection in file endpoints."""
|
||||
|
||||
async def test_path_traversal_blocked(self, client: AsyncClient, db_session):
|
||||
"""Path traversal in file endpoint is blocked (403 or 404)."""
|
||||
user = await _create_user(
|
||||
db_session,
|
||||
username="fileuser",
|
||||
roles=["Maker", "MeasurementTec"],
|
||||
)
|
||||
|
||||
resp = await client.get(
|
||||
"/api/files/../../etc/passwd",
|
||||
headers=auth_headers(user),
|
||||
)
|
||||
# Should be 403 (access denied) or 404 (not found), never 200
|
||||
assert resp.status_code in (403, 404)
|
||||
|
||||
|
||||
class TestFilenameSanitization:
|
||||
"""Filename sanitization tests."""
|
||||
|
||||
async def test_dotfile_rejected(self, client: AsyncClient, db_session):
|
||||
"""Filenames starting with '.' are rejected."""
|
||||
from src.backend.api.routers.files import sanitize_filename
|
||||
from fastapi import HTTPException
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
sanitize_filename(".htaccess")
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "." in exc_info.value.detail
|
||||
|
||||
async def test_empty_filename_rejected(self, client: AsyncClient, db_session):
|
||||
"""Empty filenames are rejected."""
|
||||
from src.backend.api.routers.files import sanitize_filename
|
||||
from fastapi import HTTPException
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
sanitize_filename("///")
|
||||
assert exc_info.value.status_code == 400
|
||||
|
||||
|
||||
class TestOptionsPreflight:
|
||||
"""OPTIONS preflight request tests."""
|
||||
|
||||
async def test_options_preflight(self, client: AsyncClient):
|
||||
"""OPTIONS request returns CORS preflight headers."""
|
||||
resp = await client.options(
|
||||
"/api/health",
|
||||
headers={
|
||||
"Origin": "http://localhost:5000",
|
||||
"Access-Control-Request-Method": "GET",
|
||||
"Access-Control-Request-Headers": "X-API-Key",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert "access-control-allow-methods" in resp.headers
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Tests for settings router (/api/settings)."""
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.backend.models.orm.setting import SystemSetting
|
||||
from src.backend.models.orm.user import User
|
||||
from src.backend.tests.conftest import auth_headers
|
||||
|
||||
|
||||
class TestGetSettings:
|
||||
"""GET /api/settings/ tests."""
|
||||
|
||||
async def test_get_settings(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
admin_user: User,
|
||||
db_session: AsyncSession,
|
||||
):
|
||||
"""Authenticated user can retrieve settings."""
|
||||
# Seed a setting
|
||||
setting = SystemSetting(
|
||||
setting_key="test_setting",
|
||||
setting_value="test_value",
|
||||
setting_type="string",
|
||||
)
|
||||
db_session.add(setting)
|
||||
await db_session.flush()
|
||||
|
||||
resp = await client.get(
|
||||
"/api/settings/", headers=auth_headers(admin_user)
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert isinstance(data, dict)
|
||||
assert data.get("test_setting") == "test_value"
|
||||
|
||||
async def test_get_settings_empty(
|
||||
self, client: AsyncClient, maker_user: User
|
||||
):
|
||||
"""Empty settings returns empty dict."""
|
||||
resp = await client.get(
|
||||
"/api/settings/", headers=auth_headers(maker_user)
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {} or isinstance(resp.json(), dict)
|
||||
|
||||
async def test_get_setting_by_key(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
admin_user: User,
|
||||
db_session: AsyncSession,
|
||||
):
|
||||
"""Settings dict contains specific key after creation."""
|
||||
setting = SystemSetting(
|
||||
setting_key="csv_delimiter",
|
||||
setting_value=";",
|
||||
setting_type="string",
|
||||
)
|
||||
db_session.add(setting)
|
||||
await db_session.flush()
|
||||
|
||||
resp = await client.get(
|
||||
"/api/settings/", headers=auth_headers(admin_user)
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["csv_delimiter"] == ";"
|
||||
|
||||
|
||||
class TestUpdateSettings:
|
||||
"""PUT /api/settings/ tests."""
|
||||
|
||||
async def test_update_settings(
|
||||
self, client: AsyncClient, admin_user: User
|
||||
):
|
||||
"""Admin can update/create settings."""
|
||||
resp = await client.put(
|
||||
"/api/settings/",
|
||||
headers=auth_headers(admin_user),
|
||||
json={
|
||||
"company_name": "Tielogic Srl",
|
||||
"csv_delimiter": ",",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["message"].startswith("Updated")
|
||||
assert "company_name" in data["updated_keys"]
|
||||
assert "csv_delimiter" in data["updated_keys"]
|
||||
|
||||
# Verify persistence
|
||||
get_resp = await client.get(
|
||||
"/api/settings/", headers=auth_headers(admin_user)
|
||||
)
|
||||
settings = get_resp.json()
|
||||
assert settings["company_name"] == "Tielogic Srl"
|
||||
|
||||
async def test_settings_require_admin(
|
||||
self, client: AsyncClient, maker_user: User
|
||||
):
|
||||
"""Non-admin user cannot update settings."""
|
||||
resp = await client.put(
|
||||
"/api/settings/",
|
||||
headers=auth_headers(maker_user),
|
||||
json={"some_key": "some_value"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
@@ -0,0 +1,294 @@
|
||||
"""Tests for setup router - demo measurements and user management."""
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from src.backend.config import settings
|
||||
from src.backend.tests.conftest import test_engine, TestSessionFactory
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SETUP_PWD = "test-setup-password"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def enable_setup(monkeypatch):
|
||||
"""Enable setup endpoints and redirect engine/session to test DB."""
|
||||
monkeypatch.setattr(settings, "setup_password", SETUP_PWD)
|
||||
# setup.py imports engine and async_session_factory directly from database
|
||||
# and uses them instead of get_db dependency injection. Patch them to use
|
||||
# the test in-memory SQLite engine/session instead of real MySQL.
|
||||
import src.backend.api.routers.setup as setup_mod
|
||||
monkeypatch.setattr(setup_mod, "engine", test_engine)
|
||||
monkeypatch.setattr(setup_mod, "async_session_factory", TestSessionFactory)
|
||||
|
||||
|
||||
async def _seed(client: AsyncClient) -> dict:
|
||||
"""Run seed and return response data."""
|
||||
resp = await client.post("/api/setup/init-db", json={"password": SETUP_PWD})
|
||||
assert resp.status_code == 200
|
||||
resp = await client.post("/api/setup/seed", json={"password": SETUP_PWD})
|
||||
assert resp.status_code == 200
|
||||
return resp.json()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Feature 1: Demo Measurements in Seed
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_seed_creates_measurements(client: AsyncClient):
|
||||
"""Seed should create 180 measurements (30 per subtask x 6 subtasks)."""
|
||||
data = await _seed(client)
|
||||
assert data["measurements_created"] == 180
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_seed_measurements_have_correct_distribution(client: AsyncClient, db_session):
|
||||
"""Measurements should have a mix of pass, warning, and fail."""
|
||||
await _seed(client)
|
||||
|
||||
from sqlalchemy import select, func
|
||||
from src.backend.models.orm.measurement import Measurement
|
||||
|
||||
result = await db_session.execute(
|
||||
select(Measurement.pass_fail, func.count()).group_by(Measurement.pass_fail)
|
||||
)
|
||||
counts = dict(result.all())
|
||||
|
||||
# Should have all three statuses
|
||||
assert "pass" in counts
|
||||
assert "warning" in counts
|
||||
assert "fail" in counts
|
||||
# Pass should be the majority
|
||||
assert counts["pass"] > counts["warning"]
|
||||
assert counts["pass"] > counts["fail"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_seed_measurements_have_lot_and_serial(client: AsyncClient, db_session):
|
||||
"""All measurements should have lot and serial numbers."""
|
||||
await _seed(client)
|
||||
|
||||
from sqlalchemy import select, func
|
||||
from src.backend.models.orm.measurement import Measurement
|
||||
|
||||
result = await db_session.execute(
|
||||
select(func.count()).where(Measurement.lot_number.is_(None))
|
||||
)
|
||||
null_lots = result.scalar()
|
||||
assert null_lots == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_seed_measurements_input_methods(client: AsyncClient, db_session):
|
||||
"""Measurements should have mixed input methods."""
|
||||
await _seed(client)
|
||||
|
||||
from sqlalchemy import select, func
|
||||
from src.backend.models.orm.measurement import Measurement
|
||||
|
||||
result = await db_session.execute(
|
||||
select(Measurement.input_method, func.count()).group_by(Measurement.input_method)
|
||||
)
|
||||
methods = dict(result.all())
|
||||
assert "usb_caliper" in methods
|
||||
assert "manual" in methods
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_seed_is_reproducible(client: AsyncClient):
|
||||
"""Seed uses random.seed(42), so results should be deterministic."""
|
||||
data = await _seed(client)
|
||||
assert data["measurements_created"] == 180
|
||||
assert data["recipe_created"] == "DEMO-001"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Feature 2: User Management - List
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_users_after_seed(client: AsyncClient):
|
||||
"""After seed, list-users should return 4 users."""
|
||||
await _seed(client)
|
||||
resp = await client.post("/api/setup/list-users", json={"password": SETUP_PWD})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data["users"]) == 4
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_users_wrong_password(client: AsyncClient):
|
||||
"""list-users with wrong password should return 403."""
|
||||
resp = await client.post("/api/setup/list-users", json={"password": "wrong"})
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Feature 2: User Management - Create / Update
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_user(client: AsyncClient):
|
||||
"""Create a new user via manage-user endpoint."""
|
||||
resp = await client.post("/api/setup/init-db", json={"password": SETUP_PWD})
|
||||
assert resp.status_code == 200
|
||||
|
||||
resp = await client.post("/api/setup/manage-user", json={
|
||||
"password": SETUP_PWD,
|
||||
"user_id": None,
|
||||
"username": "newuser",
|
||||
"user_password": "pass123",
|
||||
"display_name": "New User",
|
||||
"email": "new@test.com",
|
||||
"roles": ["Maker"],
|
||||
"is_admin": False,
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["user_id"] is not None
|
||||
assert "created" in data["message"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_user_duplicate_username(client: AsyncClient):
|
||||
"""Creating a user with a duplicate username should return 409."""
|
||||
await _seed(client)
|
||||
|
||||
resp = await client.post("/api/setup/manage-user", json={
|
||||
"password": SETUP_PWD,
|
||||
"user_id": None,
|
||||
"username": "admin",
|
||||
"user_password": "pass123",
|
||||
"display_name": "Duplicate",
|
||||
"roles": [],
|
||||
})
|
||||
assert resp.status_code == 409
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_user_without_password(client: AsyncClient):
|
||||
"""Creating a user without a password should return 400."""
|
||||
resp = await client.post("/api/setup/init-db", json={"password": SETUP_PWD})
|
||||
|
||||
resp = await client.post("/api/setup/manage-user", json={
|
||||
"password": SETUP_PWD,
|
||||
"user_id": None,
|
||||
"username": "nopassuser",
|
||||
"display_name": "No Pass",
|
||||
"roles": [],
|
||||
})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_user(client: AsyncClient):
|
||||
"""Update an existing user via manage-user endpoint."""
|
||||
await _seed(client)
|
||||
|
||||
# Get users to find the ID
|
||||
resp = await client.post("/api/setup/list-users", json={"password": SETUP_PWD})
|
||||
users = resp.json()["users"]
|
||||
maker = next(u for u in users if u["username"] == "maker1")
|
||||
|
||||
resp = await client.post("/api/setup/manage-user", json={
|
||||
"password": SETUP_PWD,
|
||||
"user_id": maker["id"],
|
||||
"username": "maker1",
|
||||
"display_name": "Updated Name",
|
||||
"email": "updated@test.com",
|
||||
"roles": ["Maker", "MeasurementTec"],
|
||||
"is_admin": False,
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
assert "updated" in resp.json()["message"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_user_invalid_role(client: AsyncClient):
|
||||
"""Creating a user with an invalid role should return 400."""
|
||||
resp = await client.post("/api/setup/init-db", json={"password": SETUP_PWD})
|
||||
|
||||
resp = await client.post("/api/setup/manage-user", json={
|
||||
"password": SETUP_PWD,
|
||||
"user_id": None,
|
||||
"username": "badrole",
|
||||
"user_password": "pass123",
|
||||
"display_name": "Bad Role",
|
||||
"roles": ["InvalidRole"],
|
||||
})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Feature 2: User Management - Password Change
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_change_password(client: AsyncClient):
|
||||
"""Changing password should succeed and invalidate api_key."""
|
||||
await _seed(client)
|
||||
|
||||
resp = await client.post("/api/setup/list-users", json={"password": SETUP_PWD})
|
||||
users = resp.json()["users"]
|
||||
tec = next(u for u in users if u["username"] == "tec1")
|
||||
|
||||
resp = await client.post("/api/setup/change-user-password", json={
|
||||
"password": SETUP_PWD,
|
||||
"user_id": tec["id"],
|
||||
"new_password": "newpass123",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
assert "changed" in resp.json()["message"].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_change_password_user_not_found(client: AsyncClient):
|
||||
"""Changing password for non-existent user should return 404."""
|
||||
resp = await client.post("/api/setup/init-db", json={"password": SETUP_PWD})
|
||||
|
||||
resp = await client.post("/api/setup/change-user-password", json={
|
||||
"password": SETUP_PWD,
|
||||
"user_id": 99999,
|
||||
"new_password": "newpass123",
|
||||
})
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Feature 2: User Management - Toggle Active
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_toggle_user_active(client: AsyncClient):
|
||||
"""Toggle active should flip the user's active status."""
|
||||
await _seed(client)
|
||||
|
||||
resp = await client.post("/api/setup/list-users", json={"password": SETUP_PWD})
|
||||
users = resp.json()["users"]
|
||||
maker = next(u for u in users if u["username"] == "maker1")
|
||||
assert maker["active"] is True
|
||||
|
||||
# Deactivate
|
||||
resp = await client.post("/api/setup/toggle-user-active", json={
|
||||
"password": SETUP_PWD,
|
||||
"user_id": maker["id"],
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["active"] is False
|
||||
|
||||
# Re-activate
|
||||
resp = await client.post("/api/setup/toggle-user-active", json={
|
||||
"password": SETUP_PWD,
|
||||
"user_id": maker["id"],
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["active"] is True
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Test the Station and StationRecipeAssignment ORM models."""
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.backend.models.orm.station import Station, StationRecipeAssignment
|
||||
from src.backend.tests.conftest import _create_user, create_test_recipe
|
||||
|
||||
|
||||
async def test_create_station(db_session: AsyncSession):
|
||||
admin = await _create_user(db_session, username="admin1", is_admin=True)
|
||||
station = Station(
|
||||
code="ST-001",
|
||||
name="Linea 1",
|
||||
location="Reparto Nord",
|
||||
created_by=admin.id,
|
||||
)
|
||||
db_session.add(station)
|
||||
await db_session.flush()
|
||||
await db_session.refresh(station)
|
||||
assert station.id is not None
|
||||
assert station.active is True
|
||||
assert station.created_at is not None
|
||||
|
||||
|
||||
async def test_station_code_is_unique(db_session: AsyncSession):
|
||||
admin = await _create_user(db_session, username="admin2", is_admin=True)
|
||||
db_session.add(Station(code="ST-DUP", name="A", created_by=admin.id))
|
||||
await db_session.flush()
|
||||
db_session.add(Station(code="ST-DUP", name="B", created_by=admin.id))
|
||||
with pytest.raises(IntegrityError):
|
||||
await db_session.flush()
|
||||
await db_session.rollback()
|
||||
|
||||
|
||||
async def test_assign_recipe_to_station(db_session: AsyncSession):
|
||||
admin = await _create_user(db_session, username="admin3", is_admin=True)
|
||||
station = Station(code="ST-002", name="Linea 2", created_by=admin.id)
|
||||
db_session.add(station)
|
||||
await db_session.flush()
|
||||
recipe = await create_test_recipe(db_session, user_id=admin.id, code="REC-X")
|
||||
assignment = StationRecipeAssignment(
|
||||
station_id=station.id, recipe_id=recipe.id, assigned_by=admin.id,
|
||||
)
|
||||
db_session.add(assignment)
|
||||
await db_session.flush()
|
||||
result = await db_session.execute(
|
||||
select(StationRecipeAssignment).where(
|
||||
StationRecipeAssignment.station_id == station.id
|
||||
)
|
||||
)
|
||||
assignments = result.scalars().all()
|
||||
assert len(assignments) == 1
|
||||
assert assignments[0].recipe_id == recipe.id
|
||||
|
||||
|
||||
async def test_duplicate_assignment_is_rejected(db_session: AsyncSession):
|
||||
admin = await _create_user(db_session, username="admin4", is_admin=True)
|
||||
station = Station(code="ST-003", name="Linea 3", created_by=admin.id)
|
||||
db_session.add(station)
|
||||
await db_session.flush()
|
||||
recipe = await create_test_recipe(db_session, user_id=admin.id, code="REC-Y")
|
||||
db_session.add(StationRecipeAssignment(
|
||||
station_id=station.id, recipe_id=recipe.id, assigned_by=admin.id,
|
||||
))
|
||||
await db_session.flush()
|
||||
db_session.add(StationRecipeAssignment(
|
||||
station_id=station.id, recipe_id=recipe.id, assigned_by=admin.id,
|
||||
))
|
||||
with pytest.raises(IntegrityError):
|
||||
await db_session.flush()
|
||||
await db_session.rollback()
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Tests for Station Pydantic schemas."""
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from src.backend.models.api.station import (
|
||||
StationCreate, StationUpdate, StationResponse,
|
||||
StationRecipeAssignmentCreate, StationRecipeAssignmentResponse,
|
||||
StationWithRecipesResponse,
|
||||
)
|
||||
|
||||
|
||||
def test_station_create_valid():
|
||||
data = StationCreate(code="ST-001", name="Linea 1", location="Reparto Nord")
|
||||
assert data.code == "ST-001"
|
||||
assert data.active is True
|
||||
|
||||
|
||||
def test_station_create_rejects_empty_code():
|
||||
with pytest.raises(ValidationError):
|
||||
StationCreate(code="", name="X")
|
||||
|
||||
|
||||
def test_station_create_rejects_too_long_code():
|
||||
with pytest.raises(ValidationError):
|
||||
StationCreate(code="A" * 101, name="X")
|
||||
|
||||
|
||||
def test_station_update_all_optional():
|
||||
data = StationUpdate()
|
||||
assert data.name is None
|
||||
|
||||
|
||||
def test_station_assignment_create():
|
||||
data = StationRecipeAssignmentCreate(recipe_id=42)
|
||||
assert data.recipe_id == 42
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Verify /api/setup/seed creates a default station with all recipes assigned.
|
||||
|
||||
DEVIATION FROM PLAN: The real setup endpoint is NOT a single /api/setup/initialize.
|
||||
The setup is split into separate endpoints:
|
||||
- POST /api/setup/init-db (creates tables)
|
||||
- POST /api/setup/seed (seeds users + demo recipe + stations)
|
||||
|
||||
The seed body is {"password": "..."} with no load_demo_data flag (demo data
|
||||
is always seeded). The station seed is added inside /api/setup/seed.
|
||||
|
||||
The seed endpoint uses async_session_factory directly (not get_db dependency
|
||||
injection), so we must monkeypatch routers.setup.engine and
|
||||
routers.setup.async_session_factory to point at the test SQLite engine,
|
||||
exactly as done in test_setup.py.
|
||||
"""
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.backend.config import settings
|
||||
from src.backend.models.orm.station import Station, StationRecipeAssignment
|
||||
from src.backend.models.orm.recipe import Recipe
|
||||
from src.backend.tests.conftest import test_engine, TestSessionFactory
|
||||
|
||||
|
||||
SETUP_PWD = "test-setup-pwd"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def enable_setup(monkeypatch):
|
||||
"""Enable setup endpoints and redirect engine/session to test SQLite DB."""
|
||||
monkeypatch.setattr(settings, "setup_password", SETUP_PWD)
|
||||
import src.backend.api.routers.setup as setup_mod
|
||||
monkeypatch.setattr(setup_mod, "engine", test_engine)
|
||||
monkeypatch.setattr(setup_mod, "async_session_factory", TestSessionFactory)
|
||||
|
||||
|
||||
async def _seed(client: AsyncClient) -> dict:
|
||||
"""Init DB tables then run seed; return seed response JSON."""
|
||||
resp = await client.post("/api/setup/init-db", json={"password": SETUP_PWD})
|
||||
assert resp.status_code == 200, resp.text
|
||||
resp = await client.post("/api/setup/seed", json={"password": SETUP_PWD})
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.json()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_setup_seed_creates_default_station_and_assigns_recipes(
|
||||
client: AsyncClient,
|
||||
db_session: AsyncSession,
|
||||
):
|
||||
"""After seeding, ST-DEFAULT station must exist and all active recipes assigned."""
|
||||
await _seed(client)
|
||||
|
||||
# Default station must exist and be active.
|
||||
result = await db_session.execute(
|
||||
select(Station).where(Station.code == "ST-DEFAULT")
|
||||
)
|
||||
default = result.scalar_one_or_none()
|
||||
assert default is not None, "ST-DEFAULT station was not created"
|
||||
assert default.active is True
|
||||
|
||||
# All active recipes must be assigned to the default station.
|
||||
active_recipes_result = await db_session.execute(
|
||||
select(Recipe).where(Recipe.active == True)
|
||||
)
|
||||
active_recipes = active_recipes_result.scalars().all()
|
||||
assert len(active_recipes) > 0, "demo seed must create at least one active recipe"
|
||||
|
||||
assignments_result = await db_session.execute(
|
||||
select(StationRecipeAssignment).where(
|
||||
StationRecipeAssignment.station_id == default.id
|
||||
)
|
||||
)
|
||||
n_assignments = len(assignments_result.scalars().all())
|
||||
assert n_assignments == len(active_recipes), (
|
||||
f"Expected {len(active_recipes)} assignment(s), got {n_assignments}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_setup_seed_station_idempotent(
|
||||
client: AsyncClient,
|
||||
db_session: AsyncSession,
|
||||
):
|
||||
"""Running seed twice must not duplicate ST-DEFAULT or its assignments."""
|
||||
# First run — creates everything.
|
||||
await _seed(client)
|
||||
|
||||
# Second run — recipe DEMO-001 already exists → seed returns early.
|
||||
# ST-DEFAULT must still exist (not re-created, not duplicated).
|
||||
resp = await client.post("/api/setup/seed", json={"password": SETUP_PWD})
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
stations_result = await db_session.execute(
|
||||
select(Station).where(Station.code == "ST-DEFAULT")
|
||||
)
|
||||
stations = stations_result.scalars().all()
|
||||
assert len(stations) == 1, f"Expected exactly 1 ST-DEFAULT, got {len(stations)}"
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Tests for station_service business logic."""
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.backend.models.orm.station import Station
|
||||
from src.backend.models.api.station import StationCreate, StationUpdate
|
||||
from src.backend.services.station_service import (
|
||||
create_station, update_station, delete_station,
|
||||
assign_recipe, unassign_recipe, list_station_recipes,
|
||||
get_station_by_code,
|
||||
)
|
||||
from src.backend.tests.conftest import _create_user, create_test_recipe
|
||||
|
||||
|
||||
async def test_create_station_ok(db_session: AsyncSession):
|
||||
admin = await _create_user(db_session, username="a1", is_admin=True)
|
||||
station = await create_station(
|
||||
db_session, StationCreate(code="ST-100", name="Pilot"), admin
|
||||
)
|
||||
assert station.id is not None
|
||||
assert station.code == "ST-100"
|
||||
|
||||
|
||||
async def test_create_station_duplicate_code(db_session: AsyncSession):
|
||||
admin = await _create_user(db_session, username="a2", is_admin=True)
|
||||
await create_station(db_session, StationCreate(code="ST-DUP", name="A"), admin)
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await create_station(db_session, StationCreate(code="ST-DUP", name="B"), admin)
|
||||
assert exc.value.status_code == 409
|
||||
|
||||
|
||||
async def test_update_station(db_session: AsyncSession):
|
||||
admin = await _create_user(db_session, username="a3", is_admin=True)
|
||||
station = await create_station(db_session, StationCreate(code="ST-U", name="Old"), admin)
|
||||
updated = await update_station(
|
||||
db_session, station.id, StationUpdate(name="New name"),
|
||||
)
|
||||
assert updated.name == "New name"
|
||||
assert updated.code == "ST-U"
|
||||
|
||||
|
||||
async def test_update_missing_station(db_session: AsyncSession):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await update_station(db_session, 9999, StationUpdate(name="x"))
|
||||
assert exc.value.status_code == 404
|
||||
|
||||
|
||||
async def test_assign_and_list_recipes(db_session: AsyncSession):
|
||||
admin = await _create_user(db_session, username="a4", is_admin=True)
|
||||
station = await create_station(db_session, StationCreate(code="ST-R", name="R"), admin)
|
||||
r1 = await create_test_recipe(db_session, user_id=admin.id, code="REC-R1")
|
||||
r2 = await create_test_recipe(db_session, user_id=admin.id, code="REC-R2")
|
||||
await assign_recipe(db_session, station.id, r1.id, admin)
|
||||
await assign_recipe(db_session, station.id, r2.id, admin)
|
||||
recipes = await list_station_recipes(db_session, station.id)
|
||||
assert {r.code for r in recipes} == {"REC-R1", "REC-R2"}
|
||||
|
||||
|
||||
async def test_assign_same_recipe_twice_is_409(db_session: AsyncSession):
|
||||
admin = await _create_user(db_session, username="a5", is_admin=True)
|
||||
station = await create_station(db_session, StationCreate(code="ST-D", name="D"), admin)
|
||||
r = await create_test_recipe(db_session, user_id=admin.id, code="REC-D")
|
||||
await assign_recipe(db_session, station.id, r.id, admin)
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await assign_recipe(db_session, station.id, r.id, admin)
|
||||
assert exc.value.status_code == 409
|
||||
|
||||
|
||||
async def test_unassign_recipe(db_session: AsyncSession):
|
||||
admin = await _create_user(db_session, username="a6", is_admin=True)
|
||||
station = await create_station(db_session, StationCreate(code="ST-UN", name="UN"), admin)
|
||||
r = await create_test_recipe(db_session, user_id=admin.id, code="REC-UN")
|
||||
await assign_recipe(db_session, station.id, r.id, admin)
|
||||
await unassign_recipe(db_session, station.id, r.id)
|
||||
recipes = await list_station_recipes(db_session, station.id)
|
||||
assert recipes == []
|
||||
|
||||
|
||||
async def test_get_station_by_code(db_session: AsyncSession):
|
||||
admin = await _create_user(db_session, username="a7", is_admin=True)
|
||||
await create_station(db_session, StationCreate(code="ST-FIND", name="F"), admin)
|
||||
found = await get_station_by_code(db_session, "ST-FIND")
|
||||
assert found is not None
|
||||
assert found.name == "F"
|
||||
missing = await get_station_by_code(db_session, "ST-NOPE")
|
||||
assert missing is None
|
||||
|
||||
|
||||
async def test_list_recipes_only_returns_active(db_session: AsyncSession):
|
||||
admin = await _create_user(db_session, username="a8", is_admin=True)
|
||||
station = await create_station(db_session, StationCreate(code="ST-A", name="A"), admin)
|
||||
active = await create_test_recipe(db_session, user_id=admin.id, code="REC-AC")
|
||||
inactive = await create_test_recipe(db_session, user_id=admin.id, code="REC-IN")
|
||||
inactive.active = False
|
||||
await db_session.flush()
|
||||
await assign_recipe(db_session, station.id, active.id, admin)
|
||||
await assign_recipe(db_session, station.id, inactive.id, admin)
|
||||
recipes = await list_station_recipes(db_session, station.id)
|
||||
assert [r.code for r in recipes] == ["REC-AC"]
|
||||
|
||||
|
||||
async def test_delete_station_cascades_assignments(db_session: AsyncSession):
|
||||
from sqlalchemy import select
|
||||
from src.backend.models.orm.station import StationRecipeAssignment
|
||||
admin = await _create_user(db_session, username="a9", is_admin=True)
|
||||
station = await create_station(db_session, StationCreate(code="ST-DEL", name="D"), admin)
|
||||
r = await create_test_recipe(db_session, user_id=admin.id, code="REC-DEL")
|
||||
await assign_recipe(db_session, station.id, r.id, admin)
|
||||
await delete_station(db_session, station.id)
|
||||
remaining = await db_session.execute(
|
||||
select(StationRecipeAssignment).where(StationRecipeAssignment.station_id == station.id)
|
||||
)
|
||||
assert remaining.scalars().all() == []
|
||||
@@ -0,0 +1,203 @@
|
||||
"""Integration tests for /api/stations endpoints."""
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from src.backend.tests.conftest import auth_headers, create_test_recipe
|
||||
|
||||
|
||||
async def test_list_stations_requires_auth(client: AsyncClient):
|
||||
resp = await client.get("/api/stations")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
async def test_create_station_as_admin(client: AsyncClient, admin_user):
|
||||
resp = await client.post(
|
||||
"/api/stations",
|
||||
headers=auth_headers(admin_user),
|
||||
json={"code": "ST-API", "name": "Via API"},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
body = resp.json()
|
||||
assert body["code"] == "ST-API"
|
||||
assert body["active"] is True
|
||||
assert body["id"] > 0
|
||||
|
||||
|
||||
async def test_create_station_non_admin_is_403(client: AsyncClient, maker_user):
|
||||
resp = await client.post(
|
||||
"/api/stations",
|
||||
headers=auth_headers(maker_user),
|
||||
json={"code": "ST-NO", "name": "No"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
async def test_update_station(client: AsyncClient, admin_user):
|
||||
created = await client.post(
|
||||
"/api/stations",
|
||||
headers=auth_headers(admin_user),
|
||||
json={"code": "ST-UP", "name": "Old"},
|
||||
)
|
||||
station_id = created.json()["id"]
|
||||
resp = await client.put(
|
||||
f"/api/stations/{station_id}",
|
||||
headers=auth_headers(admin_user),
|
||||
json={"name": "New", "active": False},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["name"] == "New"
|
||||
assert body["active"] is False
|
||||
|
||||
|
||||
async def test_delete_station(client: AsyncClient, admin_user):
|
||||
created = await client.post(
|
||||
"/api/stations",
|
||||
headers=auth_headers(admin_user),
|
||||
json={"code": "ST-D", "name": "D"},
|
||||
)
|
||||
sid = created.json()["id"]
|
||||
resp = await client.delete(
|
||||
f"/api/stations/{sid}", headers=auth_headers(admin_user),
|
||||
)
|
||||
assert resp.status_code == 204
|
||||
again = await client.get(
|
||||
f"/api/stations/{sid}", headers=auth_headers(admin_user),
|
||||
)
|
||||
assert again.status_code == 404
|
||||
|
||||
|
||||
async def test_assign_and_unassign_recipe(
|
||||
client: AsyncClient, admin_user, db_session,
|
||||
):
|
||||
recipe = await create_test_recipe(db_session, user_id=admin_user.id, code="REC-AS")
|
||||
await db_session.commit()
|
||||
created = await client.post(
|
||||
"/api/stations",
|
||||
headers=auth_headers(admin_user),
|
||||
json={"code": "ST-ASSIGN", "name": "A"},
|
||||
)
|
||||
sid = created.json()["id"]
|
||||
a = await client.post(
|
||||
f"/api/stations/{sid}/recipes",
|
||||
headers=auth_headers(admin_user),
|
||||
json={"recipe_id": recipe.id},
|
||||
)
|
||||
assert a.status_code == 201
|
||||
r = await client.get(
|
||||
f"/api/stations/{sid}/recipes",
|
||||
headers=auth_headers(admin_user),
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert [rec["code"] for rec in r.json()] == ["REC-AS"]
|
||||
u = await client.delete(
|
||||
f"/api/stations/{sid}/recipes/{recipe.id}",
|
||||
headers=auth_headers(admin_user),
|
||||
)
|
||||
assert u.status_code == 204
|
||||
r2 = await client.get(
|
||||
f"/api/stations/{sid}/recipes",
|
||||
headers=auth_headers(admin_user),
|
||||
)
|
||||
assert r2.json() == []
|
||||
|
||||
|
||||
async def test_list_recipes_by_station_code(
|
||||
client: AsyncClient, admin_user, measurement_tec_user, db_session,
|
||||
):
|
||||
recipe = await create_test_recipe(db_session, user_id=admin_user.id, code="REC-BC")
|
||||
await db_session.commit()
|
||||
created = await client.post(
|
||||
"/api/stations",
|
||||
headers=auth_headers(admin_user),
|
||||
json={"code": "ST-BC", "name": "BC"},
|
||||
)
|
||||
sid = created.json()["id"]
|
||||
await client.post(
|
||||
f"/api/stations/{sid}/recipes",
|
||||
headers=auth_headers(admin_user),
|
||||
json={"recipe_id": recipe.id},
|
||||
)
|
||||
resp = await client.get(
|
||||
"/api/stations/by-code/ST-BC/recipes",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert [r["code"] for r in resp.json()] == ["REC-BC"]
|
||||
|
||||
|
||||
async def test_list_recipes_by_unknown_code_404(
|
||||
client: AsyncClient, measurement_tec_user,
|
||||
):
|
||||
resp = await client.get(
|
||||
"/api/stations/by-code/ST-DOES-NOT-EXIST/recipes",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
async def test_admin_can_list_stations(client: AsyncClient, admin_user):
|
||||
await client.post(
|
||||
"/api/stations",
|
||||
headers=auth_headers(admin_user),
|
||||
json={"code": "ST-L1", "name": "A"},
|
||||
)
|
||||
await client.post(
|
||||
"/api/stations",
|
||||
headers=auth_headers(admin_user),
|
||||
json={"code": "ST-L2", "name": "B", "active": False},
|
||||
)
|
||||
resp = await client.get("/api/stations", headers=auth_headers(admin_user))
|
||||
assert resp.status_code == 200
|
||||
codes = {s["code"] for s in resp.json()}
|
||||
assert {"ST-L1", "ST-L2"}.issubset(codes)
|
||||
|
||||
resp_active = await client.get(
|
||||
"/api/stations?active_only=true", headers=auth_headers(admin_user),
|
||||
)
|
||||
assert resp_active.status_code == 200
|
||||
active_codes = {s["code"] for s in resp_active.json()}
|
||||
assert "ST-L1" in active_codes
|
||||
assert "ST-L2" not in active_codes
|
||||
|
||||
|
||||
async def test_assign_recipe_not_found_returns_404(
|
||||
client: AsyncClient, admin_user,
|
||||
):
|
||||
created = await client.post(
|
||||
"/api/stations",
|
||||
headers=auth_headers(admin_user),
|
||||
json={"code": "ST-NR", "name": "NR"},
|
||||
)
|
||||
sid = created.json()["id"]
|
||||
resp = await client.post(
|
||||
f"/api/stations/{sid}/recipes",
|
||||
headers=auth_headers(admin_user),
|
||||
json={"recipe_id": 99999},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
async def test_duplicate_assignment_returns_409(
|
||||
client: AsyncClient, admin_user, db_session,
|
||||
):
|
||||
recipe = await create_test_recipe(db_session, user_id=admin_user.id, code="REC-DUP")
|
||||
await db_session.commit()
|
||||
created = await client.post(
|
||||
"/api/stations",
|
||||
headers=auth_headers(admin_user),
|
||||
json={"code": "ST-DUP-A", "name": "Dup"},
|
||||
)
|
||||
sid = created.json()["id"]
|
||||
first = await client.post(
|
||||
f"/api/stations/{sid}/recipes",
|
||||
headers=auth_headers(admin_user),
|
||||
json={"recipe_id": recipe.id},
|
||||
)
|
||||
assert first.status_code == 201
|
||||
second = await client.post(
|
||||
f"/api/stations/{sid}/recipes",
|
||||
headers=auth_headers(admin_user),
|
||||
json={"recipe_id": recipe.id},
|
||||
)
|
||||
assert second.status_code == 409
|
||||
@@ -0,0 +1,213 @@
|
||||
"""Tests for statistics router (/api/statistics)."""
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.backend.models.orm.user import User
|
||||
from src.backend.models.orm.measurement import Measurement
|
||||
from src.backend.tests.conftest import auth_headers, create_test_recipe
|
||||
|
||||
|
||||
async def _seed_measurements(
|
||||
client: AsyncClient,
|
||||
tec_user: User,
|
||||
recipe_id: int,
|
||||
values: list[float],
|
||||
) -> tuple[int, int]:
|
||||
"""Create measurements and return (subtask_id, version_id)."""
|
||||
tasks_resp = await client.get(
|
||||
f"/api/recipes/{recipe_id}/tasks", headers=auth_headers(tec_user)
|
||||
)
|
||||
task = tasks_resp.json()[0]
|
||||
subtask_id = task["subtasks"][0]["id"]
|
||||
version_id = task["version_id"]
|
||||
|
||||
for val in values:
|
||||
await client.post(
|
||||
"/api/measurements/",
|
||||
headers=auth_headers(tec_user),
|
||||
json={
|
||||
"subtask_id": subtask_id,
|
||||
"version_id": version_id,
|
||||
"value": val,
|
||||
},
|
||||
)
|
||||
return subtask_id, version_id
|
||||
|
||||
|
||||
class TestSummary:
|
||||
"""GET /api/statistics/summary tests."""
|
||||
|
||||
async def test_get_statistics_by_recipe(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
measurement_tec_user: User,
|
||||
metrologist_user: User,
|
||||
db_session: AsyncSession,
|
||||
):
|
||||
"""Metrologist can get summary statistics."""
|
||||
recipe = await create_test_recipe(
|
||||
db_session, measurement_tec_user.id
|
||||
)
|
||||
await _seed_measurements(
|
||||
client, measurement_tec_user, recipe.id,
|
||||
[10.0, 10.1, 10.4, 10.6, 9.4], # pass, pass, warning, fail, fail
|
||||
)
|
||||
|
||||
resp = await client.get(
|
||||
"/api/statistics/summary",
|
||||
headers=auth_headers(metrologist_user),
|
||||
params={"recipe_id": recipe.id},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 5
|
||||
assert data["pass_count"] >= 0
|
||||
assert data["warning_count"] >= 0
|
||||
assert data["fail_count"] >= 0
|
||||
assert data["pass_rate"] >= 0
|
||||
# Total rates should sum to ~100
|
||||
total_rate = data["pass_rate"] + data["warning_rate"] + data["fail_rate"]
|
||||
assert abs(total_rate - 100.0) < 0.1
|
||||
|
||||
async def test_statistics_require_metrologist(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
maker_user: User,
|
||||
db_session: AsyncSession,
|
||||
):
|
||||
"""Non-Metrologist cannot access statistics."""
|
||||
recipe = await create_test_recipe(db_session, maker_user.id)
|
||||
resp = await client.get(
|
||||
"/api/statistics/summary",
|
||||
headers=auth_headers(maker_user),
|
||||
params={"recipe_id": recipe.id},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
async def test_statistics_empty(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
metrologist_user: User,
|
||||
db_session: AsyncSession,
|
||||
):
|
||||
"""Statistics on recipe with no measurements returns zeros."""
|
||||
recipe = await create_test_recipe(
|
||||
db_session, metrologist_user.id
|
||||
)
|
||||
resp = await client.get(
|
||||
"/api/statistics/summary",
|
||||
headers=auth_headers(metrologist_user),
|
||||
params={"recipe_id": recipe.id},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 0
|
||||
assert data["pass_count"] == 0
|
||||
|
||||
|
||||
class TestCapability:
|
||||
"""GET /api/statistics/capability tests."""
|
||||
|
||||
async def test_capability_indices(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
measurement_tec_user: User,
|
||||
metrologist_user: User,
|
||||
db_session: AsyncSession,
|
||||
):
|
||||
"""Capability endpoint returns Cp/Cpk/Pp/Ppk indices."""
|
||||
recipe = await create_test_recipe(
|
||||
db_session, measurement_tec_user.id
|
||||
)
|
||||
subtask_id, _ = await _seed_measurements(
|
||||
client, measurement_tec_user, recipe.id,
|
||||
[9.9, 10.0, 10.1, 9.8, 10.2, 10.0, 9.95, 10.05, 10.1, 9.9],
|
||||
)
|
||||
|
||||
resp = await client.get(
|
||||
"/api/statistics/capability",
|
||||
headers=auth_headers(metrologist_user),
|
||||
params={
|
||||
"recipe_id": recipe.id,
|
||||
"subtask_id": subtask_id,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["n"] == 10
|
||||
assert data["mean"] is not None
|
||||
assert data["std_dev"] is not None
|
||||
# With UTL=10.5 and LTL=9.5, spread=1.0, Cp should be > 0
|
||||
if data["cp"] is not None:
|
||||
assert data["cp"] > 0
|
||||
|
||||
|
||||
class TestSPCData:
|
||||
"""GET /api/statistics/control-chart tests."""
|
||||
|
||||
async def test_spc_data(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
measurement_tec_user: User,
|
||||
metrologist_user: User,
|
||||
db_session: AsyncSession,
|
||||
):
|
||||
"""Control chart returns values, mean, UCL, LCL."""
|
||||
recipe = await create_test_recipe(
|
||||
db_session, measurement_tec_user.id
|
||||
)
|
||||
subtask_id, _ = await _seed_measurements(
|
||||
client, measurement_tec_user, recipe.id,
|
||||
[10.0, 10.1, 9.9, 10.05, 9.95],
|
||||
)
|
||||
|
||||
resp = await client.get(
|
||||
"/api/statistics/control-chart",
|
||||
headers=auth_headers(metrologist_user),
|
||||
params={
|
||||
"recipe_id": recipe.id,
|
||||
"subtask_id": subtask_id,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data["values"]) == 5
|
||||
assert data["mean"] is not None
|
||||
assert data["ucl"] is not None
|
||||
assert data["lcl"] is not None
|
||||
assert data["ucl"] > data["mean"]
|
||||
assert data["lcl"] < data["mean"]
|
||||
|
||||
|
||||
class TestDateFilter:
|
||||
"""Date filter tests."""
|
||||
|
||||
async def test_statistics_date_filter(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
measurement_tec_user: User,
|
||||
metrologist_user: User,
|
||||
db_session: AsyncSession,
|
||||
):
|
||||
"""Date filter parameters are accepted without error."""
|
||||
recipe = await create_test_recipe(
|
||||
db_session, measurement_tec_user.id
|
||||
)
|
||||
await _seed_measurements(
|
||||
client, measurement_tec_user, recipe.id,
|
||||
[10.0, 10.1],
|
||||
)
|
||||
|
||||
resp = await client.get(
|
||||
"/api/statistics/summary",
|
||||
headers=auth_headers(metrologist_user),
|
||||
params={
|
||||
"recipe_id": recipe.id,
|
||||
"date_from": "2020-01-01T00:00:00",
|
||||
"date_to": "2099-12-31T23:59:59",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
# All measurements should be within this wide range
|
||||
assert resp.json()["total"] >= 2
|
||||
@@ -0,0 +1,219 @@
|
||||
"""Tests for tasks router (/api/recipes/{id}/tasks, /api/tasks, /api/subtasks)."""
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.backend.models.orm.user import User
|
||||
from src.backend.tests.conftest import auth_headers, create_test_recipe
|
||||
|
||||
|
||||
class TestListTasks:
|
||||
"""GET /api/recipes/{recipe_id}/tasks tests."""
|
||||
|
||||
async def test_list_tasks(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
maker_user: User,
|
||||
db_session: AsyncSession,
|
||||
):
|
||||
"""Authenticated user can list tasks for a recipe."""
|
||||
recipe = await create_test_recipe(db_session, maker_user.id)
|
||||
resp = await client.get(
|
||||
f"/api/recipes/{recipe.id}/tasks",
|
||||
headers=auth_headers(maker_user),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert isinstance(data, list)
|
||||
assert len(data) >= 1
|
||||
assert data[0]["title"] == "Test Task"
|
||||
assert len(data[0]["subtasks"]) >= 1
|
||||
|
||||
|
||||
class TestCreateTask:
|
||||
"""POST /api/recipes/{recipe_id}/tasks tests."""
|
||||
|
||||
async def test_create_task(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
maker_user: User,
|
||||
db_session: AsyncSession,
|
||||
):
|
||||
"""Maker can add a task (triggers copy-on-write new version)."""
|
||||
recipe = await create_test_recipe(db_session, maker_user.id)
|
||||
resp = await client.post(
|
||||
f"/api/recipes/{recipe.id}/tasks",
|
||||
headers=auth_headers(maker_user),
|
||||
json={
|
||||
"title": "New Task",
|
||||
"directive": "Inspect surface",
|
||||
"subtasks": [
|
||||
{
|
||||
"marker_number": 1,
|
||||
"description": "Surface roughness",
|
||||
"nominal": 5.0,
|
||||
"utl": 5.5,
|
||||
"ltl": 4.5,
|
||||
"unit": "um",
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["title"] == "New Task"
|
||||
assert len(data["subtasks"]) == 1
|
||||
assert data["subtasks"][0]["description"] == "Surface roughness"
|
||||
|
||||
|
||||
class TestUpdateTask:
|
||||
"""PUT /api/tasks/{task_id} tests."""
|
||||
|
||||
async def test_update_task(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
maker_user: User,
|
||||
db_session: AsyncSession,
|
||||
):
|
||||
"""Maker can update a task on the current version."""
|
||||
recipe = await create_test_recipe(db_session, maker_user.id)
|
||||
# Get task list to find the task ID
|
||||
tasks_resp = await client.get(
|
||||
f"/api/recipes/{recipe.id}/tasks",
|
||||
headers=auth_headers(maker_user),
|
||||
)
|
||||
task_id = tasks_resp.json()[0]["id"]
|
||||
|
||||
resp = await client.put(
|
||||
f"/api/tasks/{task_id}",
|
||||
headers=auth_headers(maker_user),
|
||||
json={"title": "Updated Task Title"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["title"] == "Updated Task Title"
|
||||
|
||||
|
||||
class TestDeleteTask:
|
||||
"""DELETE /api/tasks/{task_id} tests."""
|
||||
|
||||
async def test_delete_task(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
maker_user: User,
|
||||
db_session: AsyncSession,
|
||||
):
|
||||
"""Maker can delete a task from the current version."""
|
||||
recipe = await create_test_recipe(db_session, maker_user.id)
|
||||
tasks_resp = await client.get(
|
||||
f"/api/recipes/{recipe.id}/tasks",
|
||||
headers=auth_headers(maker_user),
|
||||
)
|
||||
task_id = tasks_resp.json()[0]["id"]
|
||||
|
||||
resp = await client.delete(
|
||||
f"/api/tasks/{task_id}", headers=auth_headers(maker_user)
|
||||
)
|
||||
assert resp.status_code == 204
|
||||
|
||||
|
||||
class TestCreateSubtask:
|
||||
"""POST /api/tasks/{task_id}/subtasks tests."""
|
||||
|
||||
async def test_create_subtask(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
maker_user: User,
|
||||
db_session: AsyncSession,
|
||||
):
|
||||
"""Maker can add a subtask to a task."""
|
||||
recipe = await create_test_recipe(db_session, maker_user.id)
|
||||
tasks_resp = await client.get(
|
||||
f"/api/recipes/{recipe.id}/tasks",
|
||||
headers=auth_headers(maker_user),
|
||||
)
|
||||
task_id = tasks_resp.json()[0]["id"]
|
||||
|
||||
resp = await client.post(
|
||||
f"/api/tasks/{task_id}/subtasks",
|
||||
headers=auth_headers(maker_user),
|
||||
json={
|
||||
"marker_number": 2,
|
||||
"description": "Width measurement",
|
||||
"nominal": 20.0,
|
||||
"utl": 20.5,
|
||||
"ltl": 19.5,
|
||||
"unit": "mm",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["marker_number"] == 2
|
||||
assert data["description"] == "Width measurement"
|
||||
|
||||
|
||||
class TestUpdateSubtask:
|
||||
"""PUT /api/subtasks/{subtask_id} tests."""
|
||||
|
||||
async def test_update_subtask(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
maker_user: User,
|
||||
db_session: AsyncSession,
|
||||
):
|
||||
"""Maker can update a subtask."""
|
||||
recipe = await create_test_recipe(db_session, maker_user.id)
|
||||
tasks_resp = await client.get(
|
||||
f"/api/recipes/{recipe.id}/tasks",
|
||||
headers=auth_headers(maker_user),
|
||||
)
|
||||
subtask_id = tasks_resp.json()[0]["subtasks"][0]["id"]
|
||||
|
||||
resp = await client.put(
|
||||
f"/api/subtasks/{subtask_id}",
|
||||
headers=auth_headers(maker_user),
|
||||
json={"description": "Updated description", "nominal": 12.0},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["description"] == "Updated description"
|
||||
|
||||
|
||||
class TestReorderTasks:
|
||||
"""PUT /api/tasks/reorder tests."""
|
||||
|
||||
async def test_reorder_tasks(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
maker_user: User,
|
||||
db_session: AsyncSession,
|
||||
):
|
||||
"""Maker can reorder tasks by providing task IDs in desired order."""
|
||||
recipe = await create_test_recipe(db_session, maker_user.id)
|
||||
|
||||
# Add a second task via copy-on-write
|
||||
add_resp = await client.post(
|
||||
f"/api/recipes/{recipe.id}/tasks",
|
||||
headers=auth_headers(maker_user),
|
||||
json={"title": "Second Task", "subtasks": []},
|
||||
)
|
||||
assert add_resp.status_code == 201
|
||||
|
||||
# Get tasks from new version
|
||||
tasks_resp = await client.get(
|
||||
f"/api/recipes/{recipe.id}/tasks",
|
||||
headers=auth_headers(maker_user),
|
||||
)
|
||||
tasks = tasks_resp.json()
|
||||
assert len(tasks) >= 2
|
||||
|
||||
task_ids = [t["id"] for t in tasks]
|
||||
# Reverse the order
|
||||
reversed_ids = list(reversed(task_ids))
|
||||
|
||||
resp = await client.put(
|
||||
"/api/tasks/reorder",
|
||||
headers=auth_headers(maker_user),
|
||||
json={"task_ids": reversed_ids},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
reordered = resp.json()
|
||||
assert [t["id"] for t in reordered] == reversed_ids
|
||||
@@ -0,0 +1,165 @@
|
||||
"""Tests for users router (/api/users)."""
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.backend.models.orm.user import User
|
||||
from src.backend.tests.conftest import _create_user, auth_headers
|
||||
|
||||
|
||||
class TestListUsers:
|
||||
"""GET /api/users tests."""
|
||||
|
||||
async def test_list_users_admin(
|
||||
self, client: AsyncClient, admin_user: User
|
||||
):
|
||||
"""Admin can list all users."""
|
||||
resp = await client.get("/api/users", headers=auth_headers(admin_user))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert isinstance(data, list)
|
||||
# At least the admin user
|
||||
assert len(data) >= 1
|
||||
usernames = [u["username"] for u in data]
|
||||
assert "admin" in usernames
|
||||
|
||||
async def test_list_users_forbidden(
|
||||
self, client: AsyncClient, maker_user: User
|
||||
):
|
||||
"""Non-admin user cannot list users."""
|
||||
resp = await client.get("/api/users", headers=auth_headers(maker_user))
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
class TestGetUser:
|
||||
"""GET /api/users/{user_id} is not directly exposed; use get_me or list."""
|
||||
|
||||
async def test_get_me(self, client: AsyncClient, maker_user: User):
|
||||
"""GET /api/auth/me returns current user profile."""
|
||||
resp = await client.get(
|
||||
"/api/auth/me", headers=auth_headers(maker_user)
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["username"] == "maker"
|
||||
assert data["display_name"] == "Maker User"
|
||||
assert "Maker" in data["roles"]
|
||||
|
||||
|
||||
class TestCreateUser:
|
||||
"""POST /api/users tests."""
|
||||
|
||||
async def test_create_user(
|
||||
self, client: AsyncClient, admin_user: User
|
||||
):
|
||||
"""Admin can create a new user."""
|
||||
resp = await client.post(
|
||||
"/api/users",
|
||||
headers=auth_headers(admin_user),
|
||||
json={
|
||||
"username": "newuser",
|
||||
"password": "NewPass123",
|
||||
"display_name": "New User",
|
||||
"roles": ["MeasurementTec"],
|
||||
"is_admin": False,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["username"] == "newuser"
|
||||
assert data["display_name"] == "New User"
|
||||
assert "MeasurementTec" in data["roles"]
|
||||
assert data["active"] is True
|
||||
|
||||
async def test_create_user_duplicate_username(
|
||||
self, client: AsyncClient, admin_user: User
|
||||
):
|
||||
"""Creating a user with existing username returns 409."""
|
||||
resp = await client.post(
|
||||
"/api/users",
|
||||
headers=auth_headers(admin_user),
|
||||
json={
|
||||
"username": "admin",
|
||||
"password": "DupPass123",
|
||||
"display_name": "Dup",
|
||||
"roles": [],
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 409
|
||||
|
||||
|
||||
class TestUpdateUser:
|
||||
"""PUT /api/users/{user_id} tests."""
|
||||
|
||||
async def test_update_user(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
admin_user: User,
|
||||
maker_user: User,
|
||||
):
|
||||
"""Admin can update another user."""
|
||||
resp = await client.put(
|
||||
f"/api/users/{maker_user.id}",
|
||||
headers=auth_headers(admin_user),
|
||||
json={"display_name": "Updated Maker"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["display_name"] == "Updated Maker"
|
||||
|
||||
|
||||
class TestDeleteUser:
|
||||
"""DELETE /api/users/{user_id} tests."""
|
||||
|
||||
async def test_delete_user(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
admin_user: User,
|
||||
db_session: AsyncSession,
|
||||
):
|
||||
"""Admin can soft-delete another user."""
|
||||
target = await _create_user(
|
||||
db_session, username="to_delete", display_name="Delete Me"
|
||||
)
|
||||
resp = await client.delete(
|
||||
f"/api/users/{target.id}", headers=auth_headers(admin_user)
|
||||
)
|
||||
assert resp.status_code == 204
|
||||
|
||||
async def test_delete_self_forbidden(
|
||||
self, client: AsyncClient, admin_user: User
|
||||
):
|
||||
"""Admin cannot deactivate themselves."""
|
||||
resp = await client.delete(
|
||||
f"/api/users/{admin_user.id}", headers=auth_headers(admin_user)
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "yourself" in resp.json()["detail"]
|
||||
|
||||
|
||||
class TestProfile:
|
||||
"""PUT /api/auth/me tests."""
|
||||
|
||||
async def test_update_me(self, client: AsyncClient, maker_user: User):
|
||||
"""User can update own profile."""
|
||||
resp = await client.put(
|
||||
"/api/auth/me",
|
||||
headers=auth_headers(maker_user),
|
||||
json={"display_name": "My New Name", "theme_pref": "dark"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["display_name"] == "My New Name"
|
||||
assert data["theme_pref"] == "dark"
|
||||
|
||||
async def test_change_password_not_in_profile(
|
||||
self, client: AsyncClient, maker_user: User
|
||||
):
|
||||
"""Profile update schema does not accept password field (422)."""
|
||||
resp = await client.put(
|
||||
"/api/auth/me",
|
||||
headers=auth_headers(maker_user),
|
||||
json={"password": "HackedPass1"},
|
||||
)
|
||||
# The field is simply ignored (exclude_unset), so no change should occur
|
||||
# but the request itself is valid (empty update)
|
||||
assert resp.status_code == 200
|
||||
@@ -0,0 +1,37 @@
|
||||
# FastAPI + ASGI
|
||||
fastapi>=0.110.0
|
||||
uvicorn[standard]>=0.30.0
|
||||
|
||||
# Database
|
||||
sqlalchemy[asyncio]>=2.0.0
|
||||
asyncmy>=0.2.0
|
||||
alembic>=1.13.0
|
||||
|
||||
# Validation
|
||||
pydantic>=2.0.0
|
||||
pydantic-settings>=2.0.0
|
||||
|
||||
# Security
|
||||
bcrypt>=4.0.0
|
||||
|
||||
# File handling
|
||||
pillow>=10.0.0
|
||||
python-multipart>=0.0.6
|
||||
|
||||
# Templating (setup page)
|
||||
jinja2>=3.1.0
|
||||
|
||||
# Reports
|
||||
plotly>=5.0.0
|
||||
kaleido>=0.2.0
|
||||
weasyprint>=62.0
|
||||
|
||||
# Utilities
|
||||
python-dotenv>=1.0.0
|
||||
|
||||
# Testing
|
||||
pytest>=8.0.0
|
||||
pytest-asyncio>=0.23.0
|
||||
httpx>=0.27.0
|
||||
aiosqlite>=0.20.0
|
||||
coverage>=7.0.0
|
||||
@@ -0,0 +1,33 @@
|
||||
FROM python:3.11-slim AS base
|
||||
|
||||
# Installa Node.js per Tailwind CSS build
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl \
|
||||
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
||||
&& apt-get install -y --no-install-recommends nodejs \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt gunicorn
|
||||
|
||||
COPY . .
|
||||
|
||||
# Install and build Tailwind CSS
|
||||
RUN npm install tailwindcss@3 && npx tailwindcss -i static/css/input.css -o static/css/tailwind.css --minify
|
||||
|
||||
# Compile Flask-Babel translations
|
||||
RUN pybabel compile -d translations
|
||||
|
||||
EXPOSE 5000
|
||||
|
||||
CMD ["gunicorn", \
|
||||
"--workers", "5", \
|
||||
"--threads", "4", \
|
||||
"--worker-class", "gthread", \
|
||||
"--timeout", "60", \
|
||||
"--bind", "0.0.0.0:5000", \
|
||||
"--access-logfile", "-", \
|
||||
"--forwarded-allow-ips", "*", \
|
||||
"app:create_app()"]
|
||||
@@ -0,0 +1,240 @@
|
||||
# TieMeasureFlow i18n Setup Guide
|
||||
|
||||
## Overview
|
||||
|
||||
TieMeasureFlow supports complete internationalization (i18n) with:
|
||||
- **Server-side**: Flask-Babel for Python/Jinja2 templates
|
||||
- **Client-side**: alpinejs-i18n for JavaScript/Alpine.js
|
||||
- **Supported languages**: Italian (IT - default) and English (EN)
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
client/
|
||||
├── translations/ # Flask-Babel translations
|
||||
│ ├── babel.cfg # Extraction config
|
||||
│ ├── it/LC_MESSAGES/
|
||||
│ │ ├── messages.po # Italian strings
|
||||
│ │ └── messages.mo # Compiled binary (generated)
|
||||
│ └── en/LC_MESSAGES/
|
||||
│ ├── messages.po # English translations
|
||||
│ └── messages.mo # Compiled binary (generated)
|
||||
├── static/js/locales/ # Alpine.js i18n
|
||||
│ ├── it.json # Italian client-side strings
|
||||
│ └── en.json # English client-side strings
|
||||
└── compile_translations.py # Utility to compile .po → .mo
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
### Language Selection
|
||||
|
||||
1. **User session**: Language stored in `session["language"]` (persistent)
|
||||
2. **Browser fallback**: Uses `Accept-Language` header if no session
|
||||
3. **Default**: Italian (IT)
|
||||
|
||||
### Server-Side (Flask-Babel)
|
||||
|
||||
**In Python code:**
|
||||
```python
|
||||
from flask_babel import gettext as _
|
||||
|
||||
flash(_("Profilo aggiornato con successo"))
|
||||
```
|
||||
|
||||
**In Jinja2 templates:**
|
||||
```jinja2
|
||||
<h1>{{ _('Accedi al sistema') }}</h1>
|
||||
|
||||
{# With variables #}
|
||||
<p>{{ _('Benvenuto, %(name)s!', name=user.display_name) }}</p>
|
||||
```
|
||||
|
||||
### Client-Side (Alpine.js i18n)
|
||||
|
||||
**Load i18n in base template:**
|
||||
```html
|
||||
<script src="/static/js/lib/alpinejs-i18n.min.js"></script>
|
||||
<script>
|
||||
// Initialize i18n with current language
|
||||
Alpine.plugin(AlpineI18n);
|
||||
Alpine.store('i18n', {
|
||||
locale: '{{ current_language }}',
|
||||
fallbackLocale: 'it',
|
||||
messages: {}
|
||||
});
|
||||
|
||||
// Load locale files
|
||||
fetch('/static/js/locales/{{ current_language }}.json')
|
||||
.then(r => r.json())
|
||||
.then(data => Alpine.store('i18n').messages['{{ current_language }}'] = data);
|
||||
</script>
|
||||
```
|
||||
|
||||
**Use in templates:**
|
||||
```html
|
||||
<button @click="submit" x-text="$t('numpad.submit')"></button>
|
||||
|
||||
<p x-text="$t('auth.welcome', {name: userName})"></p>
|
||||
```
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Add New Strings
|
||||
|
||||
**Python/Templates:**
|
||||
1. Wrap strings with `_()` or `{{ _() }}`
|
||||
2. Extract strings: `pybabel extract -F translations/babel.cfg -o translations/messages.pot .`
|
||||
3. Update catalogs: `pybabel update -i translations/messages.pot -d translations`
|
||||
4. Edit `translations/*/LC_MESSAGES/messages.po` files
|
||||
5. Compile: `python compile_translations.py`
|
||||
|
||||
**JavaScript/Alpine:**
|
||||
1. Add keys to `static/js/locales/it.json` (Italian strings)
|
||||
2. Add translations to `static/js/locales/en.json`
|
||||
3. No compilation needed (loaded at runtime)
|
||||
|
||||
### 2. Compile Translations
|
||||
|
||||
After editing .po files:
|
||||
|
||||
```bash
|
||||
cd client
|
||||
python compile_translations.py
|
||||
```
|
||||
|
||||
This generates `.mo` binary files that Flask-Babel uses at runtime.
|
||||
|
||||
### 3. Language Switching
|
||||
|
||||
**Endpoint**: `GET /set-language/<lang>`
|
||||
|
||||
**Example**:
|
||||
```html
|
||||
<a href="{{ url_for('set_language', lang='en') }}">English</a>
|
||||
<a href="{{ url_for('set_language', lang='it') }}">Italiano</a>
|
||||
```
|
||||
|
||||
**With Alpine.js**:
|
||||
```html
|
||||
<button @click="$store.i18n.locale = 'en'; window.location.href='/set-language/en'">
|
||||
English
|
||||
</button>
|
||||
```
|
||||
|
||||
## Translation Keys
|
||||
|
||||
### Server-Side (messages.po)
|
||||
|
||||
56 strings covering:
|
||||
- **Login**: "Accedi al sistema", "Credenziali non valide", etc.
|
||||
- **Navbar**: "Misure", "Ricette", "Statistiche", etc.
|
||||
- **Profile**: "Profilo Utente", "Salva Modifiche", etc.
|
||||
- **Flash messages**: "Benvenuto, %(name)s!", "Logout effettuato", etc.
|
||||
- **Common UI**: "Caricamento...", "Salva", "Annulla", "Conferma", etc.
|
||||
- **Measurements**: "Conforme", "Non Conforme", etc.
|
||||
|
||||
### Client-Side (locales/*.json)
|
||||
|
||||
Structured keys:
|
||||
```json
|
||||
{
|
||||
"numpad": { "title", "clear", "submit", "decimal", "close" },
|
||||
"measurement": { "pass", "warning", "fail", "value", "next" },
|
||||
"common": { "loading", "save", "cancel", "confirm", "delete", "edit", ... },
|
||||
"theme": { "light", "dark", "toggle" },
|
||||
"language": { "switch", "it", "en" },
|
||||
"auth": { "login", "username", "password", "signIn", ... },
|
||||
"nav": { "measurements", "recipes", "statistics", ... },
|
||||
"profile": { "title", "displayName", "language", "theme", ... }
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
**config.py** must define:
|
||||
```python
|
||||
BABEL_DEFAULT_LOCALE = 'it'
|
||||
BABEL_TRANSLATION_DIRECTORIES = 'translations'
|
||||
LANGUAGES = {
|
||||
'it': 'Italiano',
|
||||
'en': 'English'
|
||||
}
|
||||
```
|
||||
|
||||
**app.py** provides:
|
||||
- `get_locale()`: Language selector function
|
||||
- `/set-language/<lang>`: Language switch endpoint
|
||||
- `inject_globals()`: Exposes `current_language` and `languages` to templates
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Always use Italian as msgid**: Italian strings are the canonical keys
|
||||
2. **Keep client/server keys synced**: Use similar naming across .po and .json
|
||||
3. **Test both languages**: Switch languages and verify all pages
|
||||
4. **Recompile after editing**: Run `compile_translations.py` after changing .po files
|
||||
5. **Use variables for dynamic content**: `_('Welcome, %(name)s!', name=user)` or `$t('auth.welcome', {name})`
|
||||
6. **Avoid hardcoded strings**: Always use translation functions
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Translations not appearing:**
|
||||
- Verify `.mo` files exist: `find translations -name "*.mo"`
|
||||
- Check session language: `session["language"]` or browser console
|
||||
- Recompile: `python compile_translations.py`
|
||||
- Restart Flask server
|
||||
|
||||
**Client-side i18n not working:**
|
||||
- Check browser console for fetch errors
|
||||
- Verify JSON files exist: `static/js/locales/{it,en}.json`
|
||||
- Check Alpine.js i18n plugin is loaded
|
||||
- Verify `Alpine.store('i18n')` is initialized
|
||||
|
||||
**Missing translations show as keys:**
|
||||
- Add missing keys to .po or .json files
|
||||
- Recompile .po files if server-side
|
||||
- Reload page if client-side
|
||||
|
||||
## Commands Reference
|
||||
|
||||
```bash
|
||||
# Extract translatable strings from code
|
||||
pybabel extract -F translations/babel.cfg -o translations/messages.pot .
|
||||
|
||||
# Initialize new language (first time)
|
||||
pybabel init -i translations/messages.pot -d translations -l en
|
||||
|
||||
# Update existing catalogs with new strings
|
||||
pybabel update -i translations/messages.pot -d translations
|
||||
|
||||
# Compile .po to .mo (required after editing)
|
||||
python compile_translations.py
|
||||
|
||||
# Check translation statistics
|
||||
msgfmt --statistics translations/it/LC_MESSAGES/messages.po
|
||||
msgfmt --statistics translations/en/LC_MESSAGES/messages.po
|
||||
```
|
||||
|
||||
## Future Expansion
|
||||
|
||||
To add a new language (e.g., German):
|
||||
|
||||
1. **Server-side**:
|
||||
```bash
|
||||
pybabel init -i translations/messages.pot -d translations -l de
|
||||
# Edit translations/de/LC_MESSAGES/messages.po
|
||||
python compile_translations.py
|
||||
```
|
||||
|
||||
2. **Client-side**:
|
||||
- Create `static/js/locales/de.json`
|
||||
- Add to config: `LANGUAGES = {..., 'de': 'Deutsch'}`
|
||||
|
||||
3. **Update loader**: Modify base template to support new locale
|
||||
|
||||
## Resources
|
||||
|
||||
- Flask-Babel: https://python-babel.github.io/flask-babel/
|
||||
- Babel: http://babel.pocoo.org/
|
||||
- Alpine.js i18n: https://github.com/alpine-collective/alpine-i18n
|
||||
- PO file format: https://www.gnu.org/software/gettext/manual/html_node/PO-Files.html
|
||||
@@ -0,0 +1,106 @@
|
||||
"""TieMeasureFlow Client - Flask Entry Point."""
|
||||
import json
|
||||
import os
|
||||
|
||||
from flask import Flask, redirect, url_for, session, request
|
||||
from flask_babel import Babel
|
||||
from flask_wtf.csrf import CSRFProtect
|
||||
from markupsafe import Markup
|
||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||
|
||||
from config import Config
|
||||
|
||||
|
||||
def get_locale():
|
||||
"""Get user's preferred language from session or Accept-Language header."""
|
||||
# 1. User preference in session
|
||||
if "language" in session:
|
||||
return session["language"]
|
||||
# 2. Browser Accept-Language
|
||||
return request.accept_languages.best_match(
|
||||
Config.LANGUAGES.keys(), default="it"
|
||||
)
|
||||
|
||||
|
||||
def create_app() -> Flask:
|
||||
"""Application factory."""
|
||||
app = Flask(__name__)
|
||||
app.config.from_object(Config)
|
||||
|
||||
# Trust one reverse-proxy hop (Nginx in dev, Traefik in prod) so that
|
||||
# request.remote_addr returns the real tablet IP rather than the proxy IP.
|
||||
# The APIClient forwards that IP to FastAPI for accurate rate limiting.
|
||||
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)
|
||||
|
||||
# Initialize CSRF protection
|
||||
csrf = CSRFProtect(app)
|
||||
|
||||
# Initialize Flask-Babel
|
||||
Babel(app, locale_selector=get_locale)
|
||||
|
||||
# Register blueprints
|
||||
from blueprints.auth import auth_bp
|
||||
from blueprints.measure import measure_bp
|
||||
from blueprints.maker import maker_bp
|
||||
from blueprints.statistics import statistics_bp
|
||||
from blueprints.admin import admin_bp
|
||||
|
||||
app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(measure_bp, url_prefix="/measure")
|
||||
app.register_blueprint(maker_bp, url_prefix="/maker")
|
||||
app.register_blueprint(statistics_bp, url_prefix="/statistics")
|
||||
app.register_blueprint(admin_bp, url_prefix="/admin")
|
||||
|
||||
@app.route("/")
|
||||
def index():
|
||||
"""Root redirect to login or dashboard based on session."""
|
||||
if "user" in session:
|
||||
return redirect(url_for("measure.select_recipe"))
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
@app.route("/set-language/<lang>")
|
||||
def set_language(lang):
|
||||
"""Set user's preferred language and store in session."""
|
||||
if lang in Config.LANGUAGES:
|
||||
session["language"] = lang
|
||||
return redirect(request.referrer or url_for("auth.login"))
|
||||
|
||||
@app.template_filter("tojson_attr")
|
||||
def tojson_attr_filter(value):
|
||||
"""JSON encode safe for HTML attributes (x-data, etc.).
|
||||
|
||||
Unlike |tojson, this escapes double quotes to " so the output
|
||||
can be safely embedded inside double-quoted HTML attributes.
|
||||
The browser decodes the entities before Alpine.js evaluates them.
|
||||
"""
|
||||
rv = json.dumps(value, ensure_ascii=False)
|
||||
rv = (
|
||||
rv.replace("&", "\\u0026")
|
||||
.replace("<", "\\u003c")
|
||||
.replace(">", "\\u003e")
|
||||
.replace("'", "\\u0027")
|
||||
.replace('"', """)
|
||||
)
|
||||
return Markup(rv)
|
||||
|
||||
@app.context_processor
|
||||
def inject_globals():
|
||||
"""Inject global variables into all templates."""
|
||||
return {
|
||||
"current_user": session.get("user"),
|
||||
"current_theme": session.get("theme", "light"),
|
||||
"current_language": get_locale(),
|
||||
"languages": Config.LANGUAGES,
|
||||
"company_logo": session.get("company_logo"),
|
||||
}
|
||||
|
||||
return app
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = create_app()
|
||||
app.run(
|
||||
host=os.getenv("CLIENT_HOST", "0.0.0.0"),
|
||||
port=int(os.getenv("CLIENT_PORT", "5000")),
|
||||
debug=True,
|
||||
)
|
||||
@@ -0,0 +1,200 @@
|
||||
"""Admin blueprint - user management."""
|
||||
from flask import Blueprint, flash, jsonify, redirect, render_template, request, session, url_for
|
||||
from flask_babel import gettext as _
|
||||
|
||||
from blueprints.auth import login_required
|
||||
from services.api_client import api_client
|
||||
|
||||
admin_bp = Blueprint("admin", __name__, url_prefix="/admin")
|
||||
|
||||
|
||||
def admin_required(f):
|
||||
"""Decorator to require admin privileges."""
|
||||
from functools import wraps
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
user = session.get("user", {})
|
||||
if not user.get("is_admin"):
|
||||
flash(_("Accesso non autorizzato"), "error")
|
||||
return redirect(url_for("measure.select_recipe"))
|
||||
return f(*args, **kwargs)
|
||||
return decorated
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# PAGINE (GET)
|
||||
# ============================================================================
|
||||
|
||||
@admin_bp.route("/users")
|
||||
@login_required
|
||||
@admin_required
|
||||
def user_list():
|
||||
"""User management page."""
|
||||
resp = api_client.get("/api/users")
|
||||
|
||||
if isinstance(resp, dict) and resp.get("error"):
|
||||
flash(_("Errore nel caricamento degli utenti: %(error)s", error=resp.get("detail", "")), "error")
|
||||
users = []
|
||||
elif isinstance(resp, list):
|
||||
users = resp
|
||||
else:
|
||||
users = []
|
||||
|
||||
return render_template("admin/users.html", users=users)
|
||||
|
||||
|
||||
@admin_bp.route("/stations")
|
||||
@login_required
|
||||
@admin_required
|
||||
def station_list():
|
||||
"""Station management page."""
|
||||
resp = api_client.get("/api/stations")
|
||||
if isinstance(resp, dict) and resp.get("error"):
|
||||
flash(_("Errore nel caricamento delle stazioni: %(error)s", error=resp.get("detail", "")), "error")
|
||||
stations = []
|
||||
elif isinstance(resp, list):
|
||||
stations = resp
|
||||
else:
|
||||
stations = []
|
||||
|
||||
recipes_resp = api_client.get("/api/recipes")
|
||||
if isinstance(recipes_resp, list):
|
||||
all_recipes = recipes_resp
|
||||
elif isinstance(recipes_resp, dict) and isinstance(recipes_resp.get("items"), list):
|
||||
all_recipes = recipes_resp["items"]
|
||||
else:
|
||||
all_recipes = []
|
||||
|
||||
return render_template("admin/stations.html", stations=stations, all_recipes=all_recipes)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# API PROXY AJAX (JSON)
|
||||
# ============================================================================
|
||||
|
||||
@admin_bp.route("/api/users", methods=["POST"])
|
||||
@login_required
|
||||
@admin_required
|
||||
def api_create_user():
|
||||
"""Proxy: Create new user."""
|
||||
data = request.get_json(silent=True) or {}
|
||||
resp = api_client.post("/api/users", data=data)
|
||||
|
||||
if isinstance(resp, dict) and resp.get("error"):
|
||||
return jsonify(resp), resp.get("status_code", 500)
|
||||
|
||||
return jsonify(resp), 201
|
||||
|
||||
|
||||
@admin_bp.route("/api/users/<int:user_id>", methods=["PUT"])
|
||||
@login_required
|
||||
@admin_required
|
||||
def api_update_user(user_id: int):
|
||||
"""Proxy: Update user."""
|
||||
data = request.get_json(silent=True) or {}
|
||||
resp = api_client.put(f"/api/users/{user_id}", data=data)
|
||||
|
||||
if isinstance(resp, dict) and resp.get("error"):
|
||||
return jsonify(resp), resp.get("status_code", 500)
|
||||
|
||||
return jsonify(resp), 200
|
||||
|
||||
|
||||
@admin_bp.route("/api/users/<int:user_id>/password", methods=["PUT"])
|
||||
@login_required
|
||||
@admin_required
|
||||
def api_change_password(user_id: int):
|
||||
"""Proxy: Change user password."""
|
||||
data = request.get_json(silent=True) or {}
|
||||
resp = api_client.put(f"/api/users/{user_id}/password", data=data)
|
||||
|
||||
if isinstance(resp, dict) and resp.get("error"):
|
||||
return jsonify(resp), resp.get("status_code", 500)
|
||||
|
||||
return jsonify(resp), 200
|
||||
|
||||
|
||||
@admin_bp.route("/api/users/<int:user_id>/toggle-active", methods=["POST"])
|
||||
@login_required
|
||||
@admin_required
|
||||
def api_toggle_active(user_id: int):
|
||||
"""Proxy: Toggle user active status."""
|
||||
data = request.get_json(silent=True) or {}
|
||||
active = data.get("active", True)
|
||||
resp = api_client.put(f"/api/users/{user_id}", data={"active": active})
|
||||
|
||||
if isinstance(resp, dict) and resp.get("error"):
|
||||
return jsonify(resp), resp.get("status_code", 500)
|
||||
|
||||
return jsonify(resp), 200
|
||||
|
||||
|
||||
# --- Stations ---
|
||||
|
||||
@admin_bp.route("/api/stations", methods=["POST"])
|
||||
@login_required
|
||||
@admin_required
|
||||
def api_create_station():
|
||||
"""Proxy: Create a new station."""
|
||||
data = request.get_json(silent=True) or {}
|
||||
resp = api_client.post("/api/stations", data=data)
|
||||
if isinstance(resp, dict) and resp.get("error"):
|
||||
return jsonify(resp), resp.get("status_code", 500)
|
||||
return jsonify(resp), 201
|
||||
|
||||
|
||||
@admin_bp.route("/api/stations/<int:station_id>", methods=["PUT"])
|
||||
@login_required
|
||||
@admin_required
|
||||
def api_update_station(station_id: int):
|
||||
"""Proxy: Update a station."""
|
||||
data = request.get_json(silent=True) or {}
|
||||
resp = api_client.put(f"/api/stations/{station_id}", data=data)
|
||||
if isinstance(resp, dict) and resp.get("error"):
|
||||
return jsonify(resp), resp.get("status_code", 500)
|
||||
return jsonify(resp), 200
|
||||
|
||||
|
||||
@admin_bp.route("/api/stations/<int:station_id>", methods=["DELETE"])
|
||||
@login_required
|
||||
@admin_required
|
||||
def api_delete_station(station_id: int):
|
||||
"""Proxy: Delete a station."""
|
||||
resp = api_client.delete(f"/api/stations/{station_id}")
|
||||
if isinstance(resp, dict) and resp.get("error"):
|
||||
return jsonify(resp), resp.get("status_code", 500)
|
||||
return jsonify({"deleted": True}), 200
|
||||
|
||||
|
||||
@admin_bp.route("/api/stations/<int:station_id>/recipes", methods=["GET"])
|
||||
@login_required
|
||||
@admin_required
|
||||
def api_list_station_recipes(station_id: int):
|
||||
"""Proxy: List recipes assigned to a station."""
|
||||
resp = api_client.get(f"/api/stations/{station_id}/recipes")
|
||||
if isinstance(resp, dict) and resp.get("error"):
|
||||
return jsonify(resp), resp.get("status_code", 500)
|
||||
return jsonify(resp), 200
|
||||
|
||||
|
||||
@admin_bp.route("/api/stations/<int:station_id>/recipes", methods=["POST"])
|
||||
@login_required
|
||||
@admin_required
|
||||
def api_assign_recipe(station_id: int):
|
||||
"""Proxy: Assign a recipe to a station."""
|
||||
data = request.get_json(silent=True) or {}
|
||||
resp = api_client.post(f"/api/stations/{station_id}/recipes", data=data)
|
||||
if isinstance(resp, dict) and resp.get("error"):
|
||||
return jsonify(resp), resp.get("status_code", 500)
|
||||
return jsonify(resp), 201
|
||||
|
||||
|
||||
@admin_bp.route("/api/stations/<int:station_id>/recipes/<int:recipe_id>", methods=["DELETE"])
|
||||
@login_required
|
||||
@admin_required
|
||||
def api_unassign_recipe(station_id: int, recipe_id: int):
|
||||
"""Proxy: Remove a recipe assignment from a station."""
|
||||
resp = api_client.delete(f"/api/stations/{station_id}/recipes/{recipe_id}")
|
||||
if isinstance(resp, dict) and resp.get("error"):
|
||||
return jsonify(resp), resp.get("status_code", 500)
|
||||
return jsonify({"deleted": True}), 200
|
||||
@@ -0,0 +1,160 @@
|
||||
"""Authentication blueprint - login, logout, profile."""
|
||||
from functools import wraps
|
||||
|
||||
from flask import Blueprint, abort, flash, redirect, render_template, request, session, url_for
|
||||
from flask_babel import gettext as _
|
||||
|
||||
from services.api_client import api_client
|
||||
|
||||
auth_bp = Blueprint("auth", __name__, url_prefix="/auth")
|
||||
|
||||
|
||||
def login_required(f):
|
||||
"""Decorator to require login for protected routes."""
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
if not session.get("api_key"):
|
||||
flash(_("Effettua il login per continuare"), "warning")
|
||||
return redirect(url_for("auth.login"))
|
||||
return f(*args, **kwargs)
|
||||
return decorated
|
||||
|
||||
|
||||
def role_required(*roles):
|
||||
"""Decorator to require one of the specified roles.
|
||||
|
||||
Usage:
|
||||
@role_required("MeasurementTec", "Metrologist")
|
||||
def my_view(): ...
|
||||
"""
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
user = session.get("user", {})
|
||||
user_roles = user.get("roles", [])
|
||||
if not any(r in user_roles for r in roles):
|
||||
abort(403)
|
||||
return f(*args, **kwargs)
|
||||
return decorated
|
||||
return decorator
|
||||
|
||||
|
||||
@auth_bp.route("/login", methods=["GET", "POST"])
|
||||
def login():
|
||||
"""Login page and form handler."""
|
||||
# Se già loggato, redirect a home
|
||||
if session.get("api_key"):
|
||||
try:
|
||||
return redirect(url_for("measure.select_recipe"))
|
||||
except Exception:
|
||||
return redirect(url_for("auth.profile"))
|
||||
|
||||
if request.method == "POST":
|
||||
username = request.form.get("username", "").strip()
|
||||
password = request.form.get("password", "")
|
||||
|
||||
if not username or not password:
|
||||
flash(_("Inserisci username e password"), "error")
|
||||
return render_template("auth/login.html")
|
||||
|
||||
try:
|
||||
response = api_client.post("/api/auth/login", data={"username": username, "password": password})
|
||||
|
||||
if response.get("error"):
|
||||
flash(response.get("detail", _("Credenziali non valide")), "error")
|
||||
return render_template("auth/login.html")
|
||||
|
||||
# Salva in sessione
|
||||
session["api_key"] = response["api_key"]
|
||||
session["user"] = response["user"]
|
||||
session["user_id"] = response["user"]["id"]
|
||||
|
||||
# Applica preferenze utente
|
||||
user = response["user"]
|
||||
if user.get("language_pref"):
|
||||
session["language"] = user["language_pref"]
|
||||
if user.get("theme_pref"):
|
||||
session["theme"] = user["theme_pref"]
|
||||
|
||||
# Carica logo aziendale dalle impostazioni di sistema
|
||||
settings_resp = api_client.get("/api/settings")
|
||||
if not settings_resp.get("error"):
|
||||
logo_path = settings_resp.get("company_logo_path")
|
||||
if logo_path:
|
||||
session["company_logo"] = logo_path
|
||||
|
||||
flash(_("Benvenuto, %(name)s!", name=user.get("display_name", username)), "success")
|
||||
|
||||
# Redirect dopo login
|
||||
try:
|
||||
return redirect(url_for("measure.select_recipe"))
|
||||
except Exception:
|
||||
return redirect(url_for("auth.profile"))
|
||||
|
||||
except Exception as e:
|
||||
flash(_("Errore di connessione al server: %(error)s", error=str(e)), "error")
|
||||
return render_template("auth/login.html")
|
||||
|
||||
return render_template("auth/login.html")
|
||||
|
||||
|
||||
@auth_bp.route("/logout", methods=["GET", "POST"])
|
||||
def logout():
|
||||
"""Clear session and redirect to login."""
|
||||
# Invalida il token sul server
|
||||
if session.get("api_key"):
|
||||
try:
|
||||
api_client.post("/api/auth/logout")
|
||||
except Exception:
|
||||
pass # Ignora errori durante logout
|
||||
|
||||
session.clear()
|
||||
flash(_("Logout effettuato"), "info")
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
|
||||
@auth_bp.route("/profile", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def profile():
|
||||
"""User profile page - change display name, language, theme."""
|
||||
if request.method == "POST":
|
||||
display_name = request.form.get("display_name", "").strip()
|
||||
language_pref = request.form.get("language_pref", "").strip()
|
||||
theme_pref = request.form.get("theme_pref", "").strip()
|
||||
|
||||
try:
|
||||
data = {}
|
||||
if display_name:
|
||||
data["display_name"] = display_name
|
||||
if language_pref:
|
||||
data["language_pref"] = language_pref
|
||||
if theme_pref:
|
||||
data["theme_pref"] = theme_pref
|
||||
|
||||
response = api_client.put("/api/auth/me", data=data)
|
||||
|
||||
if response.get("error"):
|
||||
flash(response.get("detail", _("Errore durante l'aggiornamento del profilo")), "error")
|
||||
else:
|
||||
# Aggiorna sessione
|
||||
session["user"] = response
|
||||
if language_pref:
|
||||
session["language"] = language_pref
|
||||
if theme_pref:
|
||||
session["theme"] = theme_pref
|
||||
|
||||
flash(_("Profilo aggiornato con successo"), "success")
|
||||
return redirect(url_for("auth.profile"))
|
||||
|
||||
except Exception as e:
|
||||
flash(_("Errore di connessione al server: %(error)s", error=str(e)), "error")
|
||||
|
||||
# GET - carica dati profilo
|
||||
user_data = api_client.get("/api/auth/me")
|
||||
if user_data.get("error"):
|
||||
flash(_("Errore nel caricamento del profilo: %(error)s", error=user_data.get("detail", "")), "error")
|
||||
user_data = session.get("user", {})
|
||||
else:
|
||||
session["user"] = user_data
|
||||
|
||||
return render_template("auth/profile.html", user=user_data)
|
||||
@@ -0,0 +1,426 @@
|
||||
"""Maker blueprint - recipe creation and editing."""
|
||||
import requests as http_requests
|
||||
|
||||
from flask import Blueprint, Response, flash, jsonify, redirect, render_template, request, session, url_for
|
||||
from flask_babel import gettext as _
|
||||
|
||||
from blueprints.auth import login_required, role_required
|
||||
from config import Config
|
||||
from services.api_client import api_client
|
||||
|
||||
maker_bp = Blueprint("maker", __name__, url_prefix="/maker")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# PAGINE (GET)
|
||||
# ============================================================================
|
||||
|
||||
@maker_bp.route("/recipes")
|
||||
@login_required
|
||||
@role_required("Maker")
|
||||
def recipe_list():
|
||||
"""List all recipes with filters."""
|
||||
page = request.args.get("page", 1, type=int)
|
||||
per_page = request.args.get("per_page", 100, type=int)
|
||||
search = request.args.get("search", "", type=str)
|
||||
|
||||
params = {"page": page, "per_page": per_page}
|
||||
if search:
|
||||
params["search"] = search
|
||||
|
||||
resp = api_client.get("/api/recipes", params=params)
|
||||
|
||||
if isinstance(resp, dict) and resp.get("error"):
|
||||
flash(_("Errore nel caricamento delle ricette: %(error)s", error=resp.get("detail", "")), "error")
|
||||
recipes = []
|
||||
total = 0
|
||||
pages = 0
|
||||
else:
|
||||
recipes = resp.get("items", []) if isinstance(resp, dict) else resp
|
||||
total = resp.get("total", 0) if isinstance(resp, dict) else len(recipes)
|
||||
pages = resp.get("pages", 1) if isinstance(resp, dict) else 1
|
||||
|
||||
return render_template(
|
||||
"maker/recipe_list.html",
|
||||
recipes=recipes,
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
total=total,
|
||||
pages=pages,
|
||||
search=search
|
||||
)
|
||||
|
||||
|
||||
@maker_bp.route("/recipes/new")
|
||||
@login_required
|
||||
@role_required("Maker")
|
||||
def recipe_new():
|
||||
"""Create new recipe."""
|
||||
return render_template("maker/recipe_editor.html", recipe=None)
|
||||
|
||||
|
||||
@maker_bp.route("/recipes/<int:recipe_id>/edit")
|
||||
@login_required
|
||||
@role_required("Maker")
|
||||
def recipe_edit(recipe_id: int):
|
||||
"""Edit existing recipe."""
|
||||
# Carica recipe con dettagli completi
|
||||
resp = api_client.get(f"/api/recipes/{recipe_id}")
|
||||
|
||||
if resp.get("error"):
|
||||
flash(_("Errore nel caricamento della ricetta: %(error)s", error=resp.get("detail", "")), "error")
|
||||
return redirect(url_for("maker.recipe_list"))
|
||||
|
||||
recipe = resp
|
||||
|
||||
# Carica anche le versioni
|
||||
versions_resp = api_client.get(f"/api/recipes/{recipe_id}/versions")
|
||||
if isinstance(versions_resp, list):
|
||||
recipe["versions"] = versions_resp
|
||||
else:
|
||||
recipe["versions"] = []
|
||||
|
||||
return render_template("maker/recipe_editor.html", recipe=recipe)
|
||||
|
||||
|
||||
@maker_bp.route("/recipes/<int:recipe_id>/tasks")
|
||||
@login_required
|
||||
@role_required("Maker")
|
||||
def task_editor(recipe_id: int):
|
||||
"""Task/subtask editor with tolerances."""
|
||||
# Carica recipe con task/subtask
|
||||
resp = api_client.get(f"/api/recipes/{recipe_id}")
|
||||
|
||||
if resp.get("error"):
|
||||
flash(_("Errore nel caricamento della ricetta: %(error)s", error=resp.get("detail", "")), "error")
|
||||
return redirect(url_for("maker.recipe_list"))
|
||||
|
||||
recipe = resp
|
||||
|
||||
# Carica tasks con subtasks
|
||||
tasks_resp = api_client.get(f"/api/recipes/{recipe_id}/tasks")
|
||||
if isinstance(tasks_resp, list):
|
||||
recipe["tasks"] = tasks_resp
|
||||
else:
|
||||
recipe["tasks"] = []
|
||||
|
||||
return render_template("maker/task_editor.html", recipe=recipe)
|
||||
|
||||
|
||||
@maker_bp.route("/recipes/<int:recipe_id>/tasks/<int:task_id>/drawing")
|
||||
@login_required
|
||||
@role_required("Maker")
|
||||
def task_drawing(recipe_id: int, task_id: int):
|
||||
"""Annotation editor for a specific task."""
|
||||
# Load recipe for breadcrumb context
|
||||
recipe_resp = api_client.get(f"/api/recipes/{recipe_id}")
|
||||
if recipe_resp.get("error"):
|
||||
flash(_("Errore nel caricamento della ricetta: %(error)s", error=recipe_resp.get("detail", "")), "error")
|
||||
return redirect(url_for("maker.recipe_list"))
|
||||
|
||||
# Load task details
|
||||
task_resp = api_client.get(f"/api/tasks/{task_id}")
|
||||
if task_resp.get("error"):
|
||||
flash(_("Task non trovato: %(error)s", error=task_resp.get("detail", "")), "error")
|
||||
return redirect(url_for("maker.task_editor", recipe_id=recipe_id))
|
||||
|
||||
return render_template(
|
||||
"maker/task_drawing.html",
|
||||
recipe=recipe_resp,
|
||||
task=task_resp,
|
||||
)
|
||||
|
||||
|
||||
@maker_bp.route("/recipes/<int:recipe_id>/preview")
|
||||
@login_required
|
||||
@role_required("Maker")
|
||||
def recipe_preview(recipe_id: int):
|
||||
"""Preview recipe as MeasurementTec would see it."""
|
||||
# Carica recipe completo
|
||||
resp = api_client.get(f"/api/recipes/{recipe_id}")
|
||||
|
||||
if resp.get("error"):
|
||||
flash(_("Errore nel caricamento della ricetta: %(error)s", error=resp.get("detail", "")), "error")
|
||||
return redirect(url_for("maker.recipe_list"))
|
||||
|
||||
recipe = resp
|
||||
|
||||
# Carica tasks con subtasks
|
||||
tasks_resp = api_client.get(f"/api/recipes/{recipe_id}/tasks")
|
||||
if isinstance(tasks_resp, list):
|
||||
recipe["tasks"] = tasks_resp
|
||||
else:
|
||||
recipe["tasks"] = []
|
||||
|
||||
return render_template("maker/recipe_preview.html", recipe=recipe)
|
||||
|
||||
|
||||
@maker_bp.route("/recipes/<int:recipe_id>/versions")
|
||||
@login_required
|
||||
@role_required("Maker")
|
||||
def version_history(recipe_id: int):
|
||||
"""Version history with diff."""
|
||||
# Carica recipe base
|
||||
resp = api_client.get(f"/api/recipes/{recipe_id}")
|
||||
|
||||
if resp.get("error"):
|
||||
flash(_("Errore nel caricamento della ricetta: %(error)s", error=resp.get("detail", "")), "error")
|
||||
return redirect(url_for("maker.recipe_list"))
|
||||
|
||||
recipe = resp
|
||||
|
||||
# Carica versioni
|
||||
versions_resp = api_client.get(f"/api/recipes/{recipe_id}/versions")
|
||||
if isinstance(versions_resp, list):
|
||||
versions = versions_resp
|
||||
else:
|
||||
versions = []
|
||||
flash(_("Errore nel caricamento delle versioni: %(error)s",
|
||||
error=versions_resp.get("detail", "") if isinstance(versions_resp, dict) else ""), "warning")
|
||||
|
||||
return render_template("maker/version_history.html", recipe=recipe, versions=versions)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# API PROXY AJAX (JSON)
|
||||
# ============================================================================
|
||||
|
||||
@maker_bp.route("/api/recipes", methods=["POST"])
|
||||
@login_required
|
||||
@role_required("Maker")
|
||||
def api_create_recipe():
|
||||
"""Proxy: Create new recipe."""
|
||||
data = request.get_json(silent=True) or {}
|
||||
|
||||
resp = api_client.post("/api/recipes", data=data)
|
||||
|
||||
if resp.get("error"):
|
||||
return jsonify(resp), resp.get("status_code", 500)
|
||||
|
||||
return jsonify(resp), 201
|
||||
|
||||
|
||||
@maker_bp.route("/api/recipes/<int:recipe_id>", methods=["PUT"])
|
||||
@login_required
|
||||
@role_required("Maker")
|
||||
def api_update_recipe(recipe_id: int):
|
||||
"""Proxy: Update recipe (creates new version)."""
|
||||
data = request.get_json(silent=True) or {}
|
||||
|
||||
resp = api_client.put(f"/api/recipes/{recipe_id}", data=data)
|
||||
|
||||
if resp.get("error"):
|
||||
return jsonify(resp), resp.get("status_code", 500)
|
||||
|
||||
return jsonify(resp), 200
|
||||
|
||||
|
||||
@maker_bp.route("/api/recipes/<int:recipe_id>", methods=["DELETE"])
|
||||
@login_required
|
||||
@role_required("Maker")
|
||||
def api_delete_recipe(recipe_id: int):
|
||||
"""Proxy: Delete recipe (soft delete)."""
|
||||
resp = api_client.delete(f"/api/recipes/{recipe_id}")
|
||||
|
||||
if resp.get("error"):
|
||||
return jsonify(resp), resp.get("status_code", 500)
|
||||
|
||||
return jsonify(resp), 200
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# TASK API PROXY
|
||||
# ============================================================================
|
||||
|
||||
@maker_bp.route("/api/recipes/<int:recipe_id>/tasks", methods=["POST"])
|
||||
@login_required
|
||||
@role_required("Maker")
|
||||
def api_create_task(recipe_id: int):
|
||||
"""Proxy: Create new task."""
|
||||
data = request.get_json(silent=True) or {}
|
||||
|
||||
resp = api_client.post(f"/api/recipes/{recipe_id}/tasks", data=data)
|
||||
|
||||
if resp.get("error"):
|
||||
return jsonify(resp), resp.get("status_code", 500)
|
||||
|
||||
return jsonify(resp), 201
|
||||
|
||||
|
||||
@maker_bp.route("/api/tasks/<int:task_id>", methods=["PUT"])
|
||||
@login_required
|
||||
@role_required("Maker")
|
||||
def api_update_task(task_id: int):
|
||||
"""Proxy: Update task."""
|
||||
data = request.get_json(silent=True) or {}
|
||||
|
||||
resp = api_client.put(f"/api/tasks/{task_id}", data=data)
|
||||
|
||||
if resp.get("error"):
|
||||
return jsonify(resp), resp.get("status_code", 500)
|
||||
|
||||
return jsonify(resp), 200
|
||||
|
||||
|
||||
@maker_bp.route("/api/tasks/<int:task_id>", methods=["DELETE"])
|
||||
@login_required
|
||||
@role_required("Maker")
|
||||
def api_delete_task(task_id: int):
|
||||
"""Proxy: Delete task."""
|
||||
resp = api_client.delete(f"/api/tasks/{task_id}")
|
||||
|
||||
if resp.get("error"):
|
||||
return jsonify(resp), resp.get("status_code", 500)
|
||||
|
||||
# 204 No Content returns empty dict
|
||||
return jsonify(resp), 204
|
||||
|
||||
|
||||
@maker_bp.route("/api/tasks/reorder", methods=["PUT"])
|
||||
@login_required
|
||||
@role_required("Maker")
|
||||
def api_reorder_tasks():
|
||||
"""Proxy: Reorder tasks."""
|
||||
data = request.get_json(silent=True) or {}
|
||||
|
||||
resp = api_client.put("/api/tasks/reorder", data=data)
|
||||
|
||||
if resp.get("error"):
|
||||
return jsonify(resp), resp.get("status_code", 500)
|
||||
|
||||
return jsonify(resp), 200
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# SUBTASK API PROXY
|
||||
# ============================================================================
|
||||
|
||||
@maker_bp.route("/api/tasks/<int:task_id>/subtasks", methods=["POST"])
|
||||
@login_required
|
||||
@role_required("Maker")
|
||||
def api_create_subtask(task_id: int):
|
||||
"""Proxy: Create new subtask."""
|
||||
data = request.get_json(silent=True) or {}
|
||||
|
||||
resp = api_client.post(f"/api/tasks/{task_id}/subtasks", data=data)
|
||||
|
||||
if resp.get("error"):
|
||||
return jsonify(resp), resp.get("status_code", 500)
|
||||
|
||||
return jsonify(resp), 201
|
||||
|
||||
|
||||
@maker_bp.route("/api/subtasks/<int:subtask_id>", methods=["PUT"])
|
||||
@login_required
|
||||
@role_required("Maker")
|
||||
def api_update_subtask(subtask_id: int):
|
||||
"""Proxy: Update subtask."""
|
||||
data = request.get_json(silent=True) or {}
|
||||
|
||||
resp = api_client.put(f"/api/subtasks/{subtask_id}", data=data)
|
||||
|
||||
if resp.get("error"):
|
||||
return jsonify(resp), resp.get("status_code", 500)
|
||||
|
||||
return jsonify(resp), 200
|
||||
|
||||
|
||||
@maker_bp.route("/api/subtasks/<int:subtask_id>", methods=["DELETE"])
|
||||
@login_required
|
||||
@role_required("Maker")
|
||||
def api_delete_subtask(subtask_id: int):
|
||||
"""Proxy: Delete subtask."""
|
||||
resp = api_client.delete(f"/api/subtasks/{subtask_id}")
|
||||
|
||||
if resp.get("error"):
|
||||
return jsonify(resp), resp.get("status_code", 500)
|
||||
|
||||
# 204 No Content returns empty dict
|
||||
return jsonify(resp), 204
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# FILE API PROXY
|
||||
# ============================================================================
|
||||
|
||||
@maker_bp.route("/api/upload", methods=["POST"])
|
||||
@login_required
|
||||
@role_required("Maker")
|
||||
def api_upload_file():
|
||||
"""Proxy: Upload file (multipart forward)."""
|
||||
# Controlla se c'è un file
|
||||
if "file" not in request.files:
|
||||
return jsonify({"error": True, "detail": _("Nessun file caricato")}), 400
|
||||
|
||||
file = request.files["file"]
|
||||
if file.filename == "":
|
||||
return jsonify({"error": True, "detail": _("Nome file vuoto")}), 400
|
||||
|
||||
# Prepara multipart data
|
||||
recipe_id = request.form.get("recipe_id")
|
||||
version_id = request.form.get("version_id")
|
||||
|
||||
files = {"file": (file.filename, file.stream, file.content_type)}
|
||||
data = {}
|
||||
if recipe_id:
|
||||
data["recipe_id"] = recipe_id
|
||||
if version_id:
|
||||
data["version_id"] = version_id
|
||||
|
||||
# Forward multipart request
|
||||
resp = api_client.post("/api/files/upload", data=data, files=files)
|
||||
|
||||
if resp.get("error"):
|
||||
return jsonify(resp), resp.get("status_code", 500)
|
||||
|
||||
return jsonify(resp), 201
|
||||
|
||||
|
||||
@maker_bp.route("/api/files/<path:file_path>", methods=["GET"])
|
||||
@login_required
|
||||
def api_get_file(file_path: str):
|
||||
"""Proxy: Serve file from API server (browser can't send X-API-Key)."""
|
||||
api_key = session.get("api_key", "")
|
||||
base_url = Config.API_SERVER_URL.rstrip("/")
|
||||
resp = http_requests.get(
|
||||
f"{base_url}/api/files/{file_path}",
|
||||
headers={"X-API-Key": api_key},
|
||||
timeout=30,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
return Response(resp.text, status=resp.status_code)
|
||||
return Response(
|
||||
resp.content,
|
||||
content_type=resp.headers.get("content-type", "application/octet-stream"),
|
||||
)
|
||||
|
||||
|
||||
@maker_bp.route("/api/files/<path:file_path>", methods=["DELETE"])
|
||||
@login_required
|
||||
@role_required("Maker")
|
||||
def api_delete_file(file_path: str):
|
||||
"""Proxy: Delete file."""
|
||||
resp = api_client.delete(f"/api/files/{file_path}")
|
||||
|
||||
if resp.get("error"):
|
||||
return jsonify(resp), resp.get("status_code", 500)
|
||||
|
||||
# 204 No Content returns empty dict
|
||||
return jsonify(resp), 204
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# VERSION API PROXY
|
||||
# ============================================================================
|
||||
|
||||
@maker_bp.route("/api/recipes/<int:recipe_id>/versions/<int:version_number>/measurement-count", methods=["GET"])
|
||||
@login_required
|
||||
@role_required("Maker")
|
||||
def api_get_measurement_count(recipe_id: int, version_number: int):
|
||||
"""Proxy: Get measurement count for a specific version."""
|
||||
resp = api_client.get(f"/api/recipes/{recipe_id}/versions/{version_number}/measurement-count")
|
||||
|
||||
if resp.get("error"):
|
||||
return jsonify(resp), resp.get("status_code", 500)
|
||||
|
||||
return jsonify(resp), 200
|
||||
@@ -0,0 +1,343 @@
|
||||
"""MeasurementTec blueprint - recipe selection and measurement execution."""
|
||||
import requests as http_requests
|
||||
|
||||
from flask import (
|
||||
Blueprint, Response, flash, jsonify, redirect, render_template,
|
||||
request, session, url_for,
|
||||
)
|
||||
from flask_babel import gettext as _
|
||||
|
||||
from blueprints.auth import login_required, role_required
|
||||
from config import Config
|
||||
from services.api_client import api_client
|
||||
|
||||
measure_bp = Blueprint("measure", __name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Route: Recipe selection
|
||||
# ---------------------------------------------------------------------------
|
||||
@measure_bp.route("/select")
|
||||
@login_required
|
||||
@role_required("MeasurementTec")
|
||||
def select_recipe():
|
||||
"""Recipe selection page with search and barcode support."""
|
||||
# Fail-fast if STATION_CODE is not configured
|
||||
if not Config.STATION_CODE:
|
||||
return render_template("errors/station_not_configured.html"), 503
|
||||
|
||||
# Load recipes filtered by station
|
||||
try:
|
||||
resp = api_client.get_station_recipes(Config.STATION_CODE)
|
||||
except Exception as e:
|
||||
return render_template(
|
||||
"errors/station_not_configured.html", error=str(e),
|
||||
), 502
|
||||
|
||||
if isinstance(resp, dict) and resp.get("error"):
|
||||
flash(
|
||||
_("Errore nel caricamento delle ricette: %(detail)s",
|
||||
detail=resp.get("detail", "")),
|
||||
"error",
|
||||
)
|
||||
recipes = []
|
||||
else:
|
||||
# API may return paginated envelope or plain list
|
||||
recipes = resp.get("items", resp) if isinstance(resp, dict) else resp
|
||||
|
||||
# Auto-fill from query params
|
||||
auto_recipe_code = request.args.get("recipe", "")
|
||||
auto_lot = request.args.get("lot", session.get("lot_number", ""))
|
||||
auto_serial = request.args.get("serial", session.get("serial_number", ""))
|
||||
|
||||
return render_template(
|
||||
"measure/select_recipe.html",
|
||||
recipes=recipes,
|
||||
station_code=Config.STATION_CODE,
|
||||
auto_recipe_code=auto_recipe_code,
|
||||
auto_lot=auto_lot,
|
||||
auto_serial=auto_serial,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Route: Task list for a recipe
|
||||
# ---------------------------------------------------------------------------
|
||||
@measure_bp.route("/tasks/<int:recipe_id>")
|
||||
@login_required
|
||||
@role_required("MeasurementTec")
|
||||
def task_list(recipe_id: int):
|
||||
"""Task list for selected recipe."""
|
||||
# Persist lot/serial from query params into session
|
||||
lot_number = request.args.get(
|
||||
"lot_number", session.get("lot_number", ""),
|
||||
)
|
||||
serial_number = request.args.get(
|
||||
"serial_number", session.get("serial_number", ""),
|
||||
)
|
||||
if lot_number:
|
||||
session["lot_number"] = lot_number
|
||||
if serial_number:
|
||||
session["serial_number"] = serial_number
|
||||
|
||||
# Load recipe details
|
||||
recipe_resp = api_client.get(f"/api/recipes/{recipe_id}")
|
||||
if recipe_resp.get("error"):
|
||||
flash(
|
||||
_("Ricetta non trovata: %(detail)s",
|
||||
detail=recipe_resp.get("detail", "")),
|
||||
"error",
|
||||
)
|
||||
return redirect(url_for("measure.select_recipe"))
|
||||
|
||||
# Load tasks for this recipe
|
||||
tasks_resp = api_client.get(f"/api/recipes/{recipe_id}/tasks")
|
||||
if isinstance(tasks_resp, dict) and tasks_resp.get("error"):
|
||||
flash(
|
||||
_("Errore nel caricamento dei task: %(detail)s",
|
||||
detail=tasks_resp.get("detail", "")),
|
||||
"error",
|
||||
)
|
||||
tasks = []
|
||||
else:
|
||||
tasks = tasks_resp if isinstance(tasks_resp, list) else tasks_resp.get("items", [])
|
||||
|
||||
return render_template(
|
||||
"measure/task_list.html",
|
||||
recipe=recipe_resp,
|
||||
tasks=tasks,
|
||||
lot_number=lot_number,
|
||||
serial_number=serial_number,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Route: Task execution (measurement input)
|
||||
# ---------------------------------------------------------------------------
|
||||
@measure_bp.route("/execute/<int:task_id>")
|
||||
@login_required
|
||||
@role_required("MeasurementTec")
|
||||
def task_execute(task_id: int):
|
||||
"""Execute measurements for a task."""
|
||||
# Load task + subtasks
|
||||
task_resp = api_client.get(f"/api/tasks/{task_id}")
|
||||
if task_resp.get("error"):
|
||||
flash(
|
||||
_("Task non trovato: %(detail)s",
|
||||
detail=task_resp.get("detail", "")),
|
||||
"error",
|
||||
)
|
||||
return redirect(url_for("measure.select_recipe"))
|
||||
|
||||
lot_number = session.get("lot_number", "")
|
||||
serial_number = session.get("serial_number", "")
|
||||
|
||||
# Load all task IDs for this recipe (ordered) for auto-advance
|
||||
recipe_id = task_resp.get("recipe_id")
|
||||
all_task_ids = []
|
||||
if recipe_id:
|
||||
tasks_resp = api_client.get(f"/api/recipes/{recipe_id}/tasks")
|
||||
if isinstance(tasks_resp, list):
|
||||
sorted_tasks = sorted(tasks_resp, key=lambda t: t.get("order_index", 0))
|
||||
all_task_ids = [t["id"] for t in sorted_tasks]
|
||||
|
||||
return render_template(
|
||||
"measure/task_execute.html",
|
||||
task=task_resp,
|
||||
lot_number=lot_number,
|
||||
serial_number=serial_number,
|
||||
all_task_ids=all_task_ids,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Route: Task completion summary
|
||||
# ---------------------------------------------------------------------------
|
||||
@measure_bp.route("/complete/<int:recipe_id>")
|
||||
@login_required
|
||||
@role_required("MeasurementTec")
|
||||
def task_complete(recipe_id: int):
|
||||
"""Task completion summary with all measurements."""
|
||||
# Retrieve version_id from query params
|
||||
version_id = request.args.get("version_id")
|
||||
|
||||
# Load recipe for context
|
||||
recipe_resp = api_client.get(f"/api/recipes/{recipe_id}")
|
||||
if recipe_resp.get("error"):
|
||||
flash(
|
||||
_("Ricetta non trovata: %(detail)s",
|
||||
detail=recipe_resp.get("detail", "")),
|
||||
"error",
|
||||
)
|
||||
return redirect(url_for("measure.select_recipe"))
|
||||
|
||||
# Load tasks+subtasks for this recipe to build subtask and task lookup
|
||||
tasks_resp = api_client.get(f"/api/recipes/{recipe_id}/tasks")
|
||||
subtask_map = {}
|
||||
subtask_task_map = {} # subtask_id → task info
|
||||
if isinstance(tasks_resp, list):
|
||||
for task in tasks_resp:
|
||||
for st in task.get("subtasks", []):
|
||||
subtask_map[st["id"]] = st
|
||||
subtask_task_map[st["id"]] = {
|
||||
"id": task["id"],
|
||||
"title": task.get("title", ""),
|
||||
"order_index": task.get("order_index", 0),
|
||||
}
|
||||
|
||||
# Load measurements if version_id provided
|
||||
measurements = []
|
||||
if version_id:
|
||||
meas_resp = api_client.get(
|
||||
"/api/measurements",
|
||||
params={"version_id": version_id, "per_page": 500},
|
||||
)
|
||||
if not (isinstance(meas_resp, dict) and meas_resp.get("error")):
|
||||
raw = (
|
||||
meas_resp if isinstance(meas_resp, list)
|
||||
else meas_resp.get("items", [])
|
||||
)
|
||||
# Enrich each measurement with nested subtask data
|
||||
for m in raw:
|
||||
st = subtask_map.get(m.get("subtask_id"), {})
|
||||
m["subtask"] = st
|
||||
m["task_info"] = subtask_task_map.get(m.get("subtask_id"), {})
|
||||
# Compute deviation if not present
|
||||
if m.get("deviation") is None and st.get("nominal") is not None:
|
||||
try:
|
||||
m["deviation"] = m["value"] - st["nominal"]
|
||||
except (TypeError, KeyError):
|
||||
m["deviation"] = 0.0
|
||||
measurements = raw
|
||||
|
||||
lot_number = session.get("lot_number", "")
|
||||
serial_number = session.get("serial_number", "")
|
||||
|
||||
return render_template(
|
||||
"measure/task_complete.html",
|
||||
recipe=recipe_resp,
|
||||
measurements=measurements,
|
||||
lot_number=lot_number,
|
||||
serial_number=serial_number,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Route: Barcode lookup (AJAX)
|
||||
# ---------------------------------------------------------------------------
|
||||
@measure_bp.route("/lookup-barcode", methods=["POST"])
|
||||
@login_required
|
||||
@role_required("MeasurementTec")
|
||||
def lookup_barcode():
|
||||
"""Look up a recipe by barcode/code. Returns JSON for AJAX calls."""
|
||||
data = request.get_json(silent=True) or {}
|
||||
code = data.get("code", "").strip()
|
||||
|
||||
if not code:
|
||||
return jsonify({"error": True, "detail": _("Codice non fornito")}), 400
|
||||
|
||||
resp = api_client.get(f"/api/recipes/code/{code}")
|
||||
if resp.get("error"):
|
||||
return jsonify({
|
||||
"error": True,
|
||||
"detail": resp.get("detail", _("Ricetta non trovata")),
|
||||
}), 404
|
||||
|
||||
return jsonify(resp)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Route: Save lot/serial to session (AJAX)
|
||||
# ---------------------------------------------------------------------------
|
||||
@measure_bp.route("/save-traceability", methods=["POST"])
|
||||
@login_required
|
||||
@role_required("MeasurementTec")
|
||||
def save_traceability():
|
||||
"""Save lot_number and serial_number to session."""
|
||||
data = request.get_json(silent=True) or {}
|
||||
lot = data.get("lot_number", "").strip()
|
||||
serial = data.get("serial_number", "").strip()
|
||||
|
||||
if lot:
|
||||
session["lot_number"] = lot
|
||||
if serial:
|
||||
session["serial_number"] = serial
|
||||
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Route: Save measurement (AJAX proxy to FastAPI)
|
||||
# ---------------------------------------------------------------------------
|
||||
@measure_bp.route("/save-measurement", methods=["POST"])
|
||||
@login_required
|
||||
@role_required("MeasurementTec")
|
||||
def save_measurement():
|
||||
"""Save a single measurement value via API proxy.
|
||||
|
||||
Expects JSON body:
|
||||
subtask_id: int
|
||||
task_id: int
|
||||
value: float
|
||||
pass_fail: str ('pass' | 'warning' | 'fail')
|
||||
deviation: float
|
||||
lot_number: str (optional)
|
||||
serial_number: str (optional)
|
||||
|
||||
Returns JSON with the created measurement or error.
|
||||
"""
|
||||
data = request.get_json(silent=True) or {}
|
||||
|
||||
# Validate required fields
|
||||
subtask_id = data.get("subtask_id")
|
||||
version_id = data.get("version_id")
|
||||
value = data.get("value")
|
||||
|
||||
if subtask_id is None or version_id is None or value is None:
|
||||
return jsonify({
|
||||
"error": True,
|
||||
"detail": _("Dati mancanti: subtask_id, version_id e value sono obbligatori"),
|
||||
}), 400
|
||||
|
||||
# Build payload for the FastAPI backend
|
||||
payload = {
|
||||
"subtask_id": subtask_id,
|
||||
"version_id": version_id,
|
||||
"value": value,
|
||||
"lot_number": data.get("lot_number", session.get("lot_number", "")),
|
||||
"serial_number": data.get("serial_number", session.get("serial_number", "")),
|
||||
"input_method": data.get("input_method", "manual"),
|
||||
}
|
||||
|
||||
resp = api_client.post("/api/measurements", data=payload)
|
||||
|
||||
if resp.get("error"):
|
||||
status_code = resp.get("status_code", 500)
|
||||
return jsonify({
|
||||
"error": True,
|
||||
"detail": resp.get("detail", _("Errore nel salvataggio")),
|
||||
}), status_code if status_code >= 400 else 500
|
||||
|
||||
return jsonify(resp), 201
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Route: File proxy (browser can't send X-API-Key directly)
|
||||
# ---------------------------------------------------------------------------
|
||||
@measure_bp.route("/api/files/<path:file_path>", methods=["GET"])
|
||||
@login_required
|
||||
def api_get_file(file_path: str):
|
||||
"""Proxy: Serve file from API server (browser can't send X-API-Key)."""
|
||||
api_key = session.get("api_key", "")
|
||||
base_url = Config.API_SERVER_URL.rstrip("/")
|
||||
resp = http_requests.get(
|
||||
f"{base_url}/api/files/{file_path}",
|
||||
headers={"X-API-Key": api_key},
|
||||
timeout=30,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
return Response(resp.text, status=resp.status_code)
|
||||
return Response(
|
||||
resp.content,
|
||||
content_type=resp.headers.get("content-type", "application/octet-stream"),
|
||||
)
|
||||
@@ -0,0 +1,178 @@
|
||||
"""Metrologist blueprint - SPC statistics dashboard and API proxies."""
|
||||
import requests as http_requests
|
||||
|
||||
from flask import Blueprint, jsonify, render_template, request, session
|
||||
from flask_babel import gettext as _
|
||||
|
||||
from blueprints.auth import login_required, role_required
|
||||
from config import Config
|
||||
from services.api_client import api_client
|
||||
|
||||
statistics_bp = Blueprint("statistics", __name__)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# PAGINE
|
||||
# ============================================================================
|
||||
|
||||
@statistics_bp.route("/dashboard")
|
||||
@login_required
|
||||
@role_required("Metrologist")
|
||||
def dashboard():
|
||||
"""SPC dashboard overview — loads recipes for filter dropdown."""
|
||||
resp = api_client.get("/api/recipes", params={"per_page": 100})
|
||||
if isinstance(resp, dict) and resp.get("error"):
|
||||
recipes = []
|
||||
else:
|
||||
recipes = resp.get("items", []) if isinstance(resp, dict) else []
|
||||
|
||||
return render_template("statistics/dashboard.html", recipes=recipes)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# PROXY API AJAX → FastAPI
|
||||
# ============================================================================
|
||||
|
||||
def _proxy_statistics(endpoint: str):
|
||||
"""Forward query params to a FastAPI statistics endpoint."""
|
||||
params = {}
|
||||
for key in [
|
||||
"recipe_id", "version_id", "subtask_id",
|
||||
"date_from", "date_to", "operator_id",
|
||||
"lot_number", "serial_number", "n_bins",
|
||||
]:
|
||||
val = request.args.get(key)
|
||||
if val is not None and val != "":
|
||||
params[key] = val
|
||||
|
||||
resp = api_client.get(f"/api/statistics/{endpoint}", params=params)
|
||||
|
||||
if isinstance(resp, dict) and resp.get("error"):
|
||||
return jsonify({"error": True, "detail": resp.get("detail", "")}), resp.get("status_code", 500)
|
||||
|
||||
return jsonify(resp)
|
||||
|
||||
|
||||
@statistics_bp.route("/api/summary")
|
||||
@login_required
|
||||
@role_required("Metrologist")
|
||||
def api_summary():
|
||||
"""Proxy: summary pass/fail/warning."""
|
||||
return _proxy_statistics("summary")
|
||||
|
||||
|
||||
@statistics_bp.route("/api/capability")
|
||||
@login_required
|
||||
@role_required("Metrologist")
|
||||
def api_capability():
|
||||
"""Proxy: capability indices."""
|
||||
return _proxy_statistics("capability")
|
||||
|
||||
|
||||
@statistics_bp.route("/api/control-chart")
|
||||
@login_required
|
||||
@role_required("Metrologist")
|
||||
def api_control_chart():
|
||||
"""Proxy: control chart data."""
|
||||
return _proxy_statistics("control-chart")
|
||||
|
||||
|
||||
@statistics_bp.route("/api/histogram")
|
||||
@login_required
|
||||
@role_required("Metrologist")
|
||||
def api_histogram():
|
||||
"""Proxy: histogram data."""
|
||||
return _proxy_statistics("histogram")
|
||||
|
||||
|
||||
@statistics_bp.route("/api/subtasks")
|
||||
@login_required
|
||||
@role_required("Metrologist")
|
||||
def api_subtasks():
|
||||
"""Proxy: get subtasks for recipe filter."""
|
||||
params = {}
|
||||
recipe_id = request.args.get("recipe_id")
|
||||
if recipe_id:
|
||||
params["recipe_id"] = recipe_id
|
||||
version_id = request.args.get("version_id")
|
||||
if version_id:
|
||||
params["version_id"] = version_id
|
||||
|
||||
resp = api_client.get("/api/statistics/subtasks", params=params)
|
||||
|
||||
if isinstance(resp, dict) and resp.get("error"):
|
||||
return jsonify({"error": True, "detail": resp.get("detail", "")}), resp.get("status_code", 500)
|
||||
|
||||
# Response is a list
|
||||
if isinstance(resp, list):
|
||||
return jsonify(resp)
|
||||
|
||||
return jsonify(resp)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# PROXY REPORT PDF → FastAPI
|
||||
# ============================================================================
|
||||
|
||||
def _proxy_report(endpoint: str):
|
||||
"""Forward query params to a FastAPI reports endpoint and return PDF."""
|
||||
params = {}
|
||||
for key in [
|
||||
"recipe_id", "version_id", "subtask_id",
|
||||
"date_from", "date_to", "operator_id",
|
||||
"lot_number", "serial_number",
|
||||
]:
|
||||
val = request.args.get(key)
|
||||
if val is not None and val != "":
|
||||
params[key] = val
|
||||
|
||||
base_url = Config.API_SERVER_URL.rstrip("/")
|
||||
headers = {}
|
||||
api_key = session.get("api_key")
|
||||
if api_key:
|
||||
headers["X-API-Key"] = api_key
|
||||
|
||||
try:
|
||||
resp = http_requests.get(
|
||||
f"{base_url}/api/reports/{endpoint}",
|
||||
headers=headers,
|
||||
params=params,
|
||||
timeout=120,
|
||||
)
|
||||
except (http_requests.ConnectionError, http_requests.Timeout) as e:
|
||||
return jsonify({"error": True, "detail": str(e)}), 502
|
||||
|
||||
if not resp.ok:
|
||||
try:
|
||||
err = resp.json()
|
||||
detail = err.get("detail", f"HTTP {resp.status_code}")
|
||||
except Exception:
|
||||
detail = f"HTTP {resp.status_code}"
|
||||
return jsonify({"error": True, "detail": detail}), resp.status_code
|
||||
|
||||
from flask import Response as FlaskResponse
|
||||
return FlaskResponse(
|
||||
resp.content,
|
||||
mimetype="application/pdf",
|
||||
headers={
|
||||
"Content-Disposition": resp.headers.get(
|
||||
"Content-Disposition", f'attachment; filename="report.pdf"'
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@statistics_bp.route("/api/report-spc")
|
||||
@login_required
|
||||
@role_required("Metrologist")
|
||||
def api_report_spc():
|
||||
"""Proxy: download SPC PDF report."""
|
||||
return _proxy_report("spc")
|
||||
|
||||
|
||||
@statistics_bp.route("/api/report-measurements")
|
||||
@login_required
|
||||
@role_required("Metrologist")
|
||||
def api_report_measurements():
|
||||
"""Proxy: download measurements PDF report."""
|
||||
return _proxy_report("measurements")
|
||||
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env python
|
||||
"""Compile .po files to .mo files for Flask-Babel."""
|
||||
from pathlib import Path
|
||||
from babel.messages.mofile import write_mo
|
||||
from babel.messages.pofile import read_po
|
||||
|
||||
def compile_po_file(po_path: Path) -> None:
|
||||
"""Compile a single .po file to .mo."""
|
||||
mo_path = po_path.with_suffix('.mo')
|
||||
|
||||
print(f"Compiling {po_path} -> {mo_path}")
|
||||
|
||||
with open(po_path, 'rb') as po_file:
|
||||
catalog = read_po(po_file)
|
||||
|
||||
with open(mo_path, 'wb') as mo_file:
|
||||
write_mo(mo_file, catalog)
|
||||
|
||||
print(f" {len(catalog)} messages compiled")
|
||||
|
||||
def main():
|
||||
"""Find and compile all .po files in translations directory."""
|
||||
translations_dir = Path(__file__).parent / 'translations'
|
||||
|
||||
if not translations_dir.exists():
|
||||
print(f"ERROR: {translations_dir} does not exist")
|
||||
return
|
||||
|
||||
po_files = list(translations_dir.rglob('*.po'))
|
||||
|
||||
if not po_files:
|
||||
print(f"No .po files found in {translations_dir}")
|
||||
return
|
||||
|
||||
print(f"Found {len(po_files)} .po file(s)")
|
||||
|
||||
for po_file in po_files:
|
||||
compile_po_file(po_file)
|
||||
|
||||
print("\n✓ All translations compiled successfully")
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,32 @@
|
||||
"""TieMeasureFlow Client Configuration."""
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv(os.path.join(os.path.dirname(__file__), '..', '.env'))
|
||||
|
||||
|
||||
class Config:
|
||||
"""Flask client configuration."""
|
||||
|
||||
# Flask
|
||||
SECRET_KEY = os.getenv("CLIENT_SECRET_KEY", "change-this-to-another-random-secret-key")
|
||||
DEBUG = os.getenv("FLASK_DEBUG", "0") == "1"
|
||||
|
||||
# API Server connection
|
||||
API_SERVER_URL = os.getenv("API_SERVER_URL", "http://localhost:8000")
|
||||
|
||||
# Session
|
||||
SESSION_COOKIE_HTTPONLY = True
|
||||
SESSION_COOKIE_SAMESITE = "Lax"
|
||||
SESSION_COOKIE_SECURE = not DEBUG # Only secure cookies in production (HTTPS)
|
||||
PERMANENT_SESSION_LIFETIME = 28800 # 8 hours
|
||||
WTF_CSRF_TIME_LIMIT = 3600 # 1 hour
|
||||
|
||||
# Station identity: each deployed client container sets this to the station
|
||||
# code it belongs to. Empty/None means "not configured".
|
||||
STATION_CODE: str | None = os.getenv("STATION_CODE") or None
|
||||
|
||||
# Babel i18n
|
||||
BABEL_DEFAULT_LOCALE = "it"
|
||||
BABEL_DEFAULT_TIMEZONE = "Europe/Rome"
|
||||
LANGUAGES = {"it": "Italiano", "en": "English"}
|
||||
@@ -0,0 +1,161 @@
|
||||
"""API Client - wrapper for HTTP requests to FastAPI server."""
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
from flask import has_request_context, request, session
|
||||
|
||||
from config import Config
|
||||
|
||||
|
||||
class APIClient:
|
||||
"""HTTP client for TieMeasureFlow API server."""
|
||||
|
||||
def __init__(self):
|
||||
self.base_url = Config.API_SERVER_URL.rstrip("/")
|
||||
self.timeout = 30
|
||||
|
||||
@staticmethod
|
||||
def _real_client_ip() -> str | None:
|
||||
"""Best-effort tablet IP for downstream rate limiting.
|
||||
|
||||
Behind Nginx + ProxyFix, ``request.remote_addr`` already resolves to
|
||||
the tablet IP. Outside a request context (background tasks, tests),
|
||||
return None so we don't forge a bogus header.
|
||||
"""
|
||||
if not has_request_context():
|
||||
return None
|
||||
return request.remote_addr
|
||||
|
||||
@property
|
||||
def _headers(self) -> dict[str, str]:
|
||||
"""Build request headers with API key + real client IP from session."""
|
||||
headers = {"Content-Type": "application/json"}
|
||||
api_key = session.get("api_key")
|
||||
if api_key:
|
||||
headers["X-API-Key"] = api_key
|
||||
client_ip = self._real_client_ip()
|
||||
if client_ip:
|
||||
headers["X-Forwarded-For"] = client_ip
|
||||
headers["X-Real-IP"] = client_ip
|
||||
return headers
|
||||
|
||||
def _handle_response(self, response: requests.Response) -> dict[str, Any]:
|
||||
"""Parse response and return normalized dict.
|
||||
|
||||
Returns:
|
||||
- If 2xx and not 204: response JSON
|
||||
- If 204 No Content: empty dict
|
||||
- If 4xx/5xx: {"error": True, "status_code": ..., "detail": "..."}
|
||||
"""
|
||||
if response.ok:
|
||||
if response.status_code == 204:
|
||||
return {}
|
||||
return response.json()
|
||||
|
||||
# Non-OK response (4xx/5xx)
|
||||
try:
|
||||
error_body = response.json()
|
||||
detail = error_body.get("detail", str(error_body))
|
||||
# FastAPI 422 returns detail as a list of validation errors
|
||||
if isinstance(detail, list):
|
||||
detail = "; ".join(
|
||||
e.get("msg", str(e)) for e in detail if isinstance(e, dict)
|
||||
) or str(detail)
|
||||
except Exception:
|
||||
detail = response.text or f"HTTP {response.status_code}"
|
||||
|
||||
return {
|
||||
"error": True,
|
||||
"status_code": response.status_code,
|
||||
"detail": detail
|
||||
}
|
||||
|
||||
def get(self, endpoint: str, params: dict | None = None) -> dict[str, Any]:
|
||||
"""GET request to API server."""
|
||||
try:
|
||||
response = requests.get(
|
||||
f"{self.base_url}{endpoint}",
|
||||
headers=self._headers,
|
||||
params=params,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
return self._handle_response(response)
|
||||
except (requests.ConnectionError, requests.Timeout) as e:
|
||||
return {
|
||||
"error": True,
|
||||
"status_code": 0,
|
||||
"detail": f"Errore di connessione al server: {str(e)}"
|
||||
}
|
||||
|
||||
def post(self, endpoint: str, data: dict | None = None, files: dict | None = None) -> dict[str, Any]:
|
||||
"""POST request to API server."""
|
||||
try:
|
||||
if files:
|
||||
headers = {"X-API-Key": session.get("api_key", "")}
|
||||
client_ip = self._real_client_ip()
|
||||
if client_ip:
|
||||
headers["X-Forwarded-For"] = client_ip
|
||||
headers["X-Real-IP"] = client_ip
|
||||
response = requests.post(
|
||||
f"{self.base_url}{endpoint}",
|
||||
headers=headers,
|
||||
data=data,
|
||||
files=files,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
else:
|
||||
response = requests.post(
|
||||
f"{self.base_url}{endpoint}",
|
||||
headers=self._headers,
|
||||
json=data,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
return self._handle_response(response)
|
||||
except (requests.ConnectionError, requests.Timeout) as e:
|
||||
return {
|
||||
"error": True,
|
||||
"status_code": 0,
|
||||
"detail": f"Errore di connessione al server: {str(e)}"
|
||||
}
|
||||
|
||||
def put(self, endpoint: str, data: dict | None = None) -> dict[str, Any]:
|
||||
"""PUT request to API server."""
|
||||
try:
|
||||
response = requests.put(
|
||||
f"{self.base_url}{endpoint}",
|
||||
headers=self._headers,
|
||||
json=data,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
return self._handle_response(response)
|
||||
except (requests.ConnectionError, requests.Timeout) as e:
|
||||
return {
|
||||
"error": True,
|
||||
"status_code": 0,
|
||||
"detail": f"Errore di connessione al server: {str(e)}"
|
||||
}
|
||||
|
||||
def delete(self, endpoint: str) -> dict[str, Any]:
|
||||
"""DELETE request to API server."""
|
||||
try:
|
||||
response = requests.delete(
|
||||
f"{self.base_url}{endpoint}",
|
||||
headers=self._headers,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
return self._handle_response(response)
|
||||
except (requests.ConnectionError, requests.Timeout) as e:
|
||||
return {
|
||||
"error": True,
|
||||
"status_code": 0,
|
||||
"detail": f"Errore di connessione al server: {str(e)}"
|
||||
}
|
||||
|
||||
# --- Domain helpers ---
|
||||
|
||||
def get_station_recipes(self, station_code: str) -> dict[str, Any]:
|
||||
"""Return the list of active recipes assigned to the given station."""
|
||||
return self.get(f"/api/stations/by-code/{station_code}/recipes")
|
||||
|
||||
|
||||
api_client = APIClient()
|
||||
@@ -0,0 +1,29 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Tablet Touch Targets (44px minimum) */
|
||||
@layer components {
|
||||
.touch-target {
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
button,
|
||||
.btn,
|
||||
[role="button"],
|
||||
a.inline-flex {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
/* Flash message dismiss button */
|
||||
.fixed button[x-data] {
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Numpad Component Styles
|
||||
* Additional styles not covered by TailwindCSS utility classes
|
||||
*/
|
||||
|
||||
/* Touch optimization - prevent text selection and improve tap responsiveness */
|
||||
.numpad-container button {
|
||||
touch-action: manipulation;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Ensure touch-manipulation class works */
|
||||
.touch-manipulation {
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
/* Prevent tap highlight on mobile devices */
|
||||
.numpad-container button:active {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
/* Improve button focus for accessibility */
|
||||
.numpad-container button:focus-visible {
|
||||
outline: 2px solid var(--color-primary, #0D47A1);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Ensure consistent sizing on all devices */
|
||||
.numpad-container {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
/* Value display - ensure proper spacing and alignment */
|
||||
.value-display {
|
||||
min-height: 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Prevent zoom on double-tap for iOS Safari */
|
||||
@media (max-width: 768px) {
|
||||
.numpad-container button {
|
||||
touch-action: manipulation;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark mode adjustments for better contrast */
|
||||
.dark .value-display {
|
||||
background-color: var(--bg-card, rgb(51, 65, 85));
|
||||
border-color: var(--border-color, rgb(71, 85, 105));
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
/* ============================================================
|
||||
TieMeasureFlow - Theme System
|
||||
CSS Custom Properties for Light / Dark mode
|
||||
============================================================ */
|
||||
|
||||
/* ---- Light Theme (Default) ---- */
|
||||
:root {
|
||||
--color-primary: #2563EB;
|
||||
--color-primary-dark: #1E40AF;
|
||||
--color-primary-light: #3B82F6;
|
||||
--color-secondary: #64748B;
|
||||
--color-accent: #1E40AF;
|
||||
|
||||
--color-pass: #059669;
|
||||
--color-warning: #D97706;
|
||||
--color-fail: #DC2626;
|
||||
|
||||
--bg-primary: #F8FAFC;
|
||||
--bg-secondary: #F1F5F9;
|
||||
--bg-card: #FFFFFF;
|
||||
--bg-card-hover: #F8FAFC;
|
||||
|
||||
--text-primary: #0F172A;
|
||||
--text-secondary: #475569;
|
||||
--text-muted: #94A3B8;
|
||||
|
||||
--border-color: #E2E8F0;
|
||||
--border-focus: #2563EB;
|
||||
|
||||
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.07), 0 2px 4px -2px rgba(0, 0, 0, 0.05);
|
||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.08), 0 4px 6px -4px rgba(0, 0, 0, 0.04);
|
||||
|
||||
--scrollbar-track: #F1F5F9;
|
||||
--scrollbar-thumb: #CBD5E1;
|
||||
--scrollbar-thumb-hover: #94A3B8;
|
||||
}
|
||||
|
||||
/* ---- Dark Theme ---- */
|
||||
.dark {
|
||||
--color-primary: #60A5FA;
|
||||
--color-primary-dark: #3B82F6;
|
||||
--color-primary-light: #93C5FD;
|
||||
--color-accent: #93C5FD;
|
||||
|
||||
--color-pass: #34D399;
|
||||
--color-warning: #FBBF24;
|
||||
--color-fail: #F87171;
|
||||
|
||||
--bg-primary: #0F172A;
|
||||
--bg-secondary: #1E293B;
|
||||
--bg-card: #334155;
|
||||
--bg-card-hover: #3B4A63;
|
||||
|
||||
--text-primary: #F1F5F9;
|
||||
--text-secondary: #94A3B8;
|
||||
--text-muted: #64748B;
|
||||
|
||||
--border-color: #475569;
|
||||
--border-focus: #60A5FA;
|
||||
|
||||
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3);
|
||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -2px rgba(0, 0, 0, 0.3);
|
||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5), 0 4px 6px -4px rgba(0, 0, 0, 0.3);
|
||||
|
||||
--scrollbar-track: #1E293B;
|
||||
--scrollbar-thumb: #475569;
|
||||
--scrollbar-thumb-hover: #64748B;
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
Global Transitions
|
||||
============================================================ */
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
transition-property: background-color, border-color, color, box-shadow;
|
||||
transition-duration: 0s;
|
||||
}
|
||||
|
||||
/* Enable smooth transitions only after page load (avoid flash) */
|
||||
html.theme-transitions *,
|
||||
html.theme-transitions *::before,
|
||||
html.theme-transitions *::after {
|
||||
transition-duration: 0.3s;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
Focus Ring
|
||||
============================================================ */
|
||||
|
||||
*:focus-visible {
|
||||
outline: 2px solid var(--border-focus);
|
||||
outline-offset: 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
input:focus-visible,
|
||||
select:focus-visible,
|
||||
textarea:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px var(--bg-card), 0 0 0 4px var(--border-focus);
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
Custom Scrollbar
|
||||
============================================================ */
|
||||
|
||||
/* Webkit (Chrome, Safari, Edge) */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--scrollbar-track);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--scrollbar-thumb);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--scrollbar-thumb-hover);
|
||||
}
|
||||
|
||||
/* Firefox */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
Utility: Card
|
||||
============================================================ */
|
||||
|
||||
.tmf-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: box-shadow 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.tmf-card:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.tmf-card-header {
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tmf-card-body {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.tmf-card-footer {
|
||||
padding: 0.75rem 1.25rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 0 0 0.75rem 0.75rem;
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
Utility: Badges (Pass / Warning / Fail)
|
||||
============================================================ */
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.125rem 0.625rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
border-radius: 9999px;
|
||||
line-height: 1.5;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badge-pass {
|
||||
background: rgba(5, 150, 105, 0.1);
|
||||
color: #059669;
|
||||
border: 1px solid rgba(5, 150, 105, 0.25);
|
||||
}
|
||||
|
||||
.dark .badge-pass {
|
||||
background: rgba(5, 150, 105, 0.15);
|
||||
color: #34D399;
|
||||
border-color: rgba(5, 150, 105, 0.3);
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
background: rgba(217, 119, 6, 0.1);
|
||||
color: #D97706;
|
||||
border: 1px solid rgba(217, 119, 6, 0.25);
|
||||
}
|
||||
|
||||
.dark .badge-warning {
|
||||
background: rgba(217, 119, 6, 0.15);
|
||||
color: #FBBF24;
|
||||
border-color: rgba(217, 119, 6, 0.3);
|
||||
}
|
||||
|
||||
.badge-fail {
|
||||
background: rgba(220, 38, 38, 0.1);
|
||||
color: #DC2626;
|
||||
border: 1px solid rgba(220, 38, 38, 0.25);
|
||||
}
|
||||
|
||||
.dark .badge-fail {
|
||||
background: rgba(220, 38, 38, 0.15);
|
||||
color: #F87171;
|
||||
border-color: rgba(220, 38, 38, 0.3);
|
||||
}
|
||||
|
||||
.badge-neutral {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
Utility: Buttons
|
||||
============================================================ */
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--color-primary);
|
||||
color: #FFFFFF;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.dark .btn-primary {
|
||||
background: #3B82F6;
|
||||
color: #FFFFFF;
|
||||
border-color: #3B82F6;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: var(--color-primary-dark);
|
||||
border-color: var(--color-primary-dark);
|
||||
}
|
||||
|
||||
.dark .btn-primary:hover:not(:disabled) {
|
||||
background: #2563EB;
|
||||
border-color: #2563EB;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--color-fail);
|
||||
color: #FFFFFF;
|
||||
border-color: var(--color-fail);
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: #B91C1C;
|
||||
border-color: #B91C1C;
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
Utility: Form Inputs
|
||||
============================================================ */
|
||||
|
||||
.tmf-input {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0.5rem;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.tmf-input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.tmf-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--border-focus);
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15);
|
||||
}
|
||||
|
||||
.dark .tmf-input:focus {
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
|
||||
.tmf-label {
|
||||
display: block;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 0.375rem;
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
Utility: Measurement Value Display
|
||||
============================================================ */
|
||||
|
||||
.measure-value {
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.025em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
Utility: Table
|
||||
============================================================ */
|
||||
|
||||
.tmf-table {
|
||||
width: 100%;
|
||||
font-size: 0.875rem;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.tmf-table th {
|
||||
padding: 0.625rem 1rem;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.tmf-table td {
|
||||
padding: 0.625rem 1rem;
|
||||
color: var(--text-primary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.tmf-table tbody tr:hover {
|
||||
background: var(--bg-card-hover);
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
Alpine.js x-cloak (hide until Alpine loads)
|
||||
============================================================ */
|
||||
|
||||
[x-cloak] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
Tablet Touch Targets (44px minimum per WCAG 2.5.5)
|
||||
============================================================ */
|
||||
|
||||
.touch-target {
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
button,
|
||||
.btn,
|
||||
[role="button"],
|
||||
a.inline-flex {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
/* Flash message dismiss button */
|
||||
.fixed button[x-data] {
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
||||
<!-- Stylized Caliper Icon for Favicon -->
|
||||
|
||||
<!-- Caliper outer frame -->
|
||||
<rect x="2" y="3" width="5" height="26" rx="1.5" fill="#2563EB"/>
|
||||
<rect x="2" y="3" width="19" height="5" rx="1.5" fill="#2563EB"/>
|
||||
<rect x="2" y="24" width="19" height="5" rx="1.5" fill="#2563EB"/>
|
||||
|
||||
<!-- Sliding jaw -->
|
||||
<rect x="12" y="8" width="4.5" height="6.5" rx="1" fill="#1E40AF"/>
|
||||
<rect x="12" y="17.5" width="4.5" height="6.5" rx="1" fill="#1E40AF"/>
|
||||
|
||||
<!-- Depth rod -->
|
||||
<rect x="4" y="14" width="12" height="3.5" rx="1" fill="#3B82F6" opacity="0.55"/>
|
||||
|
||||
<!-- Scale markings -->
|
||||
<rect x="8" y="5" width="1" height="2.5" rx="0.5" fill="#FFFFFF" opacity="0.65"/>
|
||||
<rect x="11" y="5" width="1" height="2.5" rx="0.5" fill="#FFFFFF" opacity="0.65"/>
|
||||
<rect x="14" y="5" width="1" height="2.5" rx="0.5" fill="#FFFFFF" opacity="0.65"/>
|
||||
<rect x="17" y="5" width="1" height="2.5" rx="0.5" fill="#FFFFFF" opacity="0.65"/>
|
||||
|
||||
<!-- Flow arrow -->
|
||||
<path d="M22 16 C25 16, 26.5 11, 29 11"
|
||||
stroke="#3B82F6" stroke-width="2.2" stroke-linecap="round" fill="none"/>
|
||||
<path d="M27 8.5 L29.5 11 L27 13.5"
|
||||
stroke="#3B82F6" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,41 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 220 44" fill="none">
|
||||
<!-- Stylized Caliper Icon -->
|
||||
<g transform="translate(2, 2)">
|
||||
<!-- Caliper outer frame -->
|
||||
<rect x="0" y="4" width="6" height="32" rx="1.5" fill="#2563EB"/>
|
||||
<rect x="0" y="4" width="24" height="6" rx="1.5" fill="#2563EB"/>
|
||||
<rect x="0" y="30" width="24" height="6" rx="1.5" fill="#2563EB"/>
|
||||
|
||||
<!-- Caliper sliding jaw -->
|
||||
<rect x="14" y="10" width="5" height="8" rx="1" fill="#1E40AF"/>
|
||||
<rect x="14" y="22" width="5" height="8" rx="1" fill="#1E40AF"/>
|
||||
|
||||
<!-- Caliper depth rod -->
|
||||
<rect x="2" y="18" width="16" height="4" rx="1" fill="#3B82F6" opacity="0.6"/>
|
||||
|
||||
<!-- Scale markings -->
|
||||
<rect x="7" y="7" width="1" height="3" rx="0.5" fill="#FFFFFF" opacity="0.7"/>
|
||||
<rect x="10" y="7" width="1" height="3" rx="0.5" fill="#FFFFFF" opacity="0.7"/>
|
||||
<rect x="13" y="7" width="1" height="3" rx="0.5" fill="#FFFFFF" opacity="0.7"/>
|
||||
<rect x="16" y="7" width="1" height="3" rx="0.5" fill="#FFFFFF" opacity="0.7"/>
|
||||
<rect x="19" y="7" width="1" height="3" rx="0.5" fill="#FFFFFF" opacity="0.7"/>
|
||||
|
||||
<!-- Flow arrow (smooth curve) -->
|
||||
<path d="M26 20 C30 20, 32 14, 36 14 C40 14, 40 20, 36 20"
|
||||
stroke="#2563EB" stroke-width="2.5" stroke-linecap="round" fill="none"/>
|
||||
<path d="M34 17 L37 20 L34 23" stroke="#2563EB" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||
</g>
|
||||
|
||||
<!-- Text: TieMeasureFlow -->
|
||||
<g transform="translate(48, 0)">
|
||||
<!-- "Tie" in bold primary -->
|
||||
<text x="0" y="28" font-family="Inter, system-ui, sans-serif" font-size="20" font-weight="700" fill="#2563EB"
|
||||
letter-spacing="-0.5">Tie</text>
|
||||
<!-- "Measure" in medium dark -->
|
||||
<text x="32" y="28" font-family="Inter, system-ui, sans-serif" font-size="20" font-weight="500" fill="#1E40AF"
|
||||
letter-spacing="-0.5">Measure</text>
|
||||
<!-- "Flow" in bold primary -->
|
||||
<text x="120" y="28" font-family="Inter, system-ui, sans-serif" font-size="20" font-weight="700" fill="#2563EB"
|
||||
letter-spacing="-0.5">Flow</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* TieMeasureFlow - Alpine.js Theme Initialization
|
||||
*
|
||||
* This script MUST load before Alpine.js (which uses defer).
|
||||
* It reads the saved theme from localStorage or the system preference,
|
||||
* applies the 'dark' class to <html> immediately (no flash), and
|
||||
* registers an Alpine.store('theme') for reactive toggling.
|
||||
*/
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// ---- Immediate theme application (before paint) ----
|
||||
|
||||
var STORAGE_KEY = 'tmf-theme';
|
||||
|
||||
/**
|
||||
* Resolve the initial dark-mode state.
|
||||
* Priority: localStorage > system preference > light (default).
|
||||
*/
|
||||
function getInitialDark() {
|
||||
var stored = null;
|
||||
try {
|
||||
stored = localStorage.getItem(STORAGE_KEY);
|
||||
} catch (_) {
|
||||
// localStorage may be unavailable (private browsing, etc.)
|
||||
}
|
||||
|
||||
if (stored === 'dark') return true;
|
||||
if (stored === 'light') return false;
|
||||
|
||||
// Fall back to OS preference
|
||||
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
var isDark = getInitialDark();
|
||||
|
||||
// Apply immediately to prevent flash of wrong theme
|
||||
if (isDark) {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
|
||||
// ---- Enable smooth transitions after first paint ----
|
||||
|
||||
// Use requestAnimationFrame to wait one frame, then enable transitions
|
||||
// so the initial theme application does not animate.
|
||||
if (typeof requestAnimationFrame === 'function') {
|
||||
requestAnimationFrame(function () {
|
||||
requestAnimationFrame(function () {
|
||||
document.documentElement.classList.add('theme-transitions');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Alpine.js Store Registration ----
|
||||
|
||||
// Alpine.store is registered via the alpine:init event, which fires
|
||||
// when Alpine begins initialization but before it processes x-data.
|
||||
|
||||
document.addEventListener('alpine:init', function () {
|
||||
Alpine.store('theme', {
|
||||
dark: isDark,
|
||||
|
||||
toggle: function () {
|
||||
this.dark = !this.dark;
|
||||
this._apply();
|
||||
},
|
||||
|
||||
set: function (dark) {
|
||||
this.dark = dark;
|
||||
this._apply();
|
||||
},
|
||||
|
||||
_apply: function () {
|
||||
if (this.dark) {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, this.dark ? 'dark' : 'light');
|
||||
} catch (_) {
|
||||
// Silently ignore storage errors
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Listen for system preference changes ----
|
||||
|
||||
if (window.matchMedia) {
|
||||
try {
|
||||
var mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
mq.addEventListener('change', function (e) {
|
||||
// Only auto-switch if the user hasn't manually set a preference
|
||||
var stored = null;
|
||||
try {
|
||||
stored = localStorage.getItem(STORAGE_KEY);
|
||||
} catch (_) {}
|
||||
|
||||
if (!stored) {
|
||||
var newDark = e.matches;
|
||||
if (typeof Alpine !== 'undefined' && Alpine.store('theme')) {
|
||||
Alpine.store('theme').set(newDark);
|
||||
} else {
|
||||
// Alpine not loaded yet, apply directly
|
||||
if (newDark) {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (_) {
|
||||
// Older browsers may not support addEventListener on matchMedia
|
||||
}
|
||||
}
|
||||
|
||||
})();
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user