chore(v2): restructure monorepo to src/ layout with uv

Aligns the repo with the python-project-spec-design.md template chosen
for V2.0.0. Big move, no logic changes. The 3 pre-existing test
failures (test_recipes::test_update_recipe, test_recipes::
test_recipe_versioning, test_tasks::test_reorder_tasks, plus the
client test_save_measurement_proxy) survive unchanged.

Layout changes
- server/        -> src/backend/
- server/middleware/ -> src/backend/api/middleware/
- server/routers/    -> src/backend/api/routers/
- server/models/     -> src/backend/models/orm/
- server/schemas/    -> src/backend/models/api/
- server/uploads/    -> uploads/ (project root, mounted volume)
- server/tests/      -> src/backend/tests/
- client/            -> src/frontend/flask_app/ (Flask kept; React
  deroga is documented in CLAUDE.md, justified by tablet UX, USB
  caliper/barcode workflow and Fabric.js integration)

Tooling
- pyproject.toml: monorepo with [project] core deps and
  optional-dependencies server / client / dev. Replaces both
  server/requirements.txt and client/requirements.txt.
- uv.lock + .python-version (3.11) committed for reproducible builds.
- Dockerfile (root, backend) and Dockerfile.frontend rewritten to use
  uv sync --frozen --no-dev --extra server|client; legacy Dockerfiles
  preserved as Dockerfile.legacy for reference but excluded from build
  context via .dockerignore.
- docker-compose.dev.yml + docker-compose.yml: build context now ".",
  dockerfile pointing to the root files.

Code adjustments forced by the move
- Every "from config|database|models|schemas|services|routers|middleware
  import ..." rewritten to its src.backend.* equivalent (50+ files
  including indented inline imports inside test bodies).
- src/backend/migrations/env.py: insert project root into sys.path so
  alembic can resolve src.backend.* imports regardless of cwd.
- src/backend/config.py: env_file ../../.env (was ../.env), upload_path
  resolves project root via parents[2].
- src/backend/tests/conftest.py + tests: import ... from src.backend.*
  instead of bare names; old per-directory pytest.ini files removed in
  favor of root pyproject.toml [tool.pytest.ini_options].
- .gitignore: uploads/ at root, src/frontend/flask_app/static/css/
  tailwind.css path; .dockerignore tightened.
- CLAUDE.md: rewrote sections "Layout del repository", "Comandi di
  Sviluppo", "Database & Migrations", "Test", "i18n", and all path
  references throughout the architecture sections.

Verified
- uv lock resolves 77 packages; uv sync --extra server --extra client
  --extra dev installs cleanly.
- uv run pytest: 171 passed, 4 pre-existing failures.
- uv run alembic -c src/backend/migrations/alembic.ini check loads
  config and metadata (errors only on the absent local MySQL).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-25 12:26:47 +02:00
parent 86df67f2e5
commit 1a0431366f
174 changed files with 2568 additions and 308 deletions
+26
View File
@@ -0,0 +1,26 @@
"""FastAPI middleware for TieMeasureFlow."""
from src.backend.api.middleware.api_key import (
get_current_user,
require_role,
require_admin,
require_maker,
require_measurement_tec,
require_metrologist,
require_admin_user,
)
from src.backend.api.middleware.logging import AccessLogMiddleware
from src.backend.api.middleware.rate_limit import RateLimitMiddleware
from src.backend.api.middleware.security_headers import SecurityHeadersMiddleware
__all__ = [
"get_current_user",
"require_role",
"require_admin",
"require_maker",
"require_measurement_tec",
"require_metrologist",
"require_admin_user",
"AccessLogMiddleware",
"RateLimitMiddleware",
"SecurityHeadersMiddleware",
]
+71
View File
@@ -0,0 +1,71 @@
"""API Key authentication dependency for FastAPI."""
from fastapi import Depends, HTTPException, Request, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.database import get_db
from src.backend.models.orm.user import User
async def get_current_user(
request: Request,
db: AsyncSession = Depends(get_db),
) -> User:
"""Extract API key from header and return the authenticated user.
The API key is sent in the X-API-Key header on every request.
Login endpoint is excluded from this check.
"""
api_key = request.headers.get("X-API-Key")
if not api_key:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Missing API key in X-API-Key header",
)
result = await db.execute(
select(User).where(User.api_key == api_key, User.active == True)
)
user = result.scalar_one_or_none()
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or inactive API key",
)
# Store user_id in request state for access logging middleware
request.state.user_id = user.id
return user
def require_role(role: str):
"""Dependency factory that checks if user has a specific role."""
async def check_role(user: User = Depends(get_current_user)) -> User:
if not user.has_role(role) and not user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Role '{role}' required",
)
return user
return check_role
def require_admin():
"""Dependency that checks if user is admin."""
async def check_admin(user: User = Depends(get_current_user)) -> User:
if not user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin access required",
)
return user
return check_admin
# Pre-built role dependencies for convenience
require_maker = require_role("Maker")
require_measurement_tec = require_role("MeasurementTec")
require_metrologist = require_role("Metrologist")
require_admin_user = require_admin()
+54
View File
@@ -0,0 +1,54 @@
"""Access logging middleware for FastAPI."""
import time
from typing import Callable
from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware
from sqlalchemy import insert
from src.backend.database import async_session_factory
from src.backend.models.orm.access_log import AccessLog
class AccessLogMiddleware(BaseHTTPMiddleware):
"""Middleware that logs every API request to the access_logs table."""
# Paths to exclude from logging (health checks, static files)
EXCLUDED_PATHS = {"/api/health", "/docs", "/openapi.json", "/redoc"}
async def dispatch(self, request: Request, call_next: Callable) -> Response:
# Skip excluded paths
if request.url.path in self.EXCLUDED_PATHS:
return await call_next(request)
start_time = time.time()
response = await call_next(request)
duration_ms = (time.time() - start_time) * 1000
# Extract user info from request state (set by auth middleware)
user_id = getattr(request.state, "user_id", None)
# Log asynchronously (don't block response)
try:
async with async_session_factory() as session:
await session.execute(
insert(AccessLog).values(
user_id=user_id,
action=f"{request.method} {request.url.path}",
details={
"method": request.method,
"path": request.url.path,
"query": str(request.query_params),
"status_code": response.status_code,
"duration_ms": round(duration_ms, 2),
},
ip_address=request.client.host if request.client else None,
user_agent=request.headers.get("user-agent", "")[:500],
)
)
await session.commit()
except Exception:
# Don't fail the request if logging fails
pass
return response
+123
View File
@@ -0,0 +1,123 @@
"""Rate limiting middleware for FastAPI.
Implements in-memory sliding window rate limiting per client IP.
Configurable limits for login and general endpoints.
"""
import time
from collections import defaultdict
from typing import Callable
from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import JSONResponse
from src.backend.config import settings
class RateLimitMiddleware(BaseHTTPMiddleware):
"""Middleware that enforces per-IP rate limits using a sliding window.
- Login endpoint (/api/auth/login): limited to `rate_limit_login` req/min.
- All other endpoints: limited to `rate_limit_general` req/min.
Returns HTTP 429 with Retry-After header when limit exceeded.
"""
LOGIN_PATH = "/api/auth/login"
WINDOW_SECONDS = 60
def __init__(self, app) -> None:
super().__init__(app)
# {ip: [timestamp, ...]} per bucket
self._login_requests: dict[str, list[float]] = defaultdict(list)
self._general_requests: dict[str, list[float]] = defaultdict(list)
self._request_count = 0 # Counter for triggering eviction
@staticmethod
def _client_ip(request: Request) -> str:
"""Resolve the originating client IP, honoring proxy headers.
Order of precedence: ``X-Forwarded-For`` (first hop), ``X-Real-IP``,
``request.client.host``. Required because Nginx and the Flask client
sit between the tablet and the API; without parsing these headers
every tablet shares one bucket.
"""
xff = request.headers.get("x-forwarded-for")
if xff:
first = xff.split(",")[0].strip()
if first:
return first
real = request.headers.get("x-real-ip")
if real:
return real.strip()
return request.client.host if request.client else "unknown"
def _clean_window(self, timestamps: list[float], now: float) -> list[float]:
"""Remove timestamps outside the current sliding window."""
cutoff = now - self.WINDOW_SECONDS
return [t for t in timestamps if t > cutoff]
def _evict_stale_ips(self, bucket: dict[str, list[float]], now: float) -> None:
"""Remove IP entries with no timestamps in the current window (memory leak prevention)."""
cutoff = now - self.WINDOW_SECONDS
stale_ips = [ip for ip, timestamps in bucket.items() if not timestamps or max(timestamps) <= cutoff]
for ip in stale_ips:
del bucket[ip]
def _check_rate_limit(
self,
bucket: dict[str, list[float]],
client_ip: str,
limit: int,
now: float,
) -> tuple[bool, int]:
"""Check if a request is within the rate limit.
Returns:
Tuple of (allowed, retry_after_seconds).
"""
bucket[client_ip] = self._clean_window(bucket[client_ip], now)
if len(bucket[client_ip]) >= limit:
# Calculate seconds until the oldest request falls out of window
oldest = bucket[client_ip][0]
retry_after = int(oldest + self.WINDOW_SECONDS - now) + 1
return False, max(retry_after, 1)
bucket[client_ip].append(now)
return True, 0
async def dispatch(self, request: Request, call_next: Callable) -> Response:
client_ip = self._client_ip(request)
now = time.time()
path = request.url.path
# Periodic eviction: every 100 requests, remove stale IP buckets
self._request_count += 1
if self._request_count % 100 == 0:
self._evict_stale_ips(self._login_requests, now)
self._evict_stale_ips(self._general_requests, now)
# Check login-specific rate limit
if path == self.LOGIN_PATH and request.method == "POST":
allowed, retry_after = self._check_rate_limit(
self._login_requests, client_ip, settings.rate_limit_login, now
)
if not allowed:
return JSONResponse(
status_code=429,
content={"detail": "Too many login attempts. Please try again later."},
headers={"Retry-After": str(retry_after)},
)
# Check general rate limit
allowed, retry_after = self._check_rate_limit(
self._general_requests, client_ip, settings.rate_limit_general, now
)
if not allowed:
return JSONResponse(
status_code=429,
content={"detail": "Too many requests. Please try again later."},
headers={"Retry-After": str(retry_after)},
)
return await call_next(request)
@@ -0,0 +1,42 @@
"""Security headers middleware for FastAPI.
Adds standard security headers to every HTTP response to mitigate
common web vulnerabilities (clickjacking, XSS, MIME sniffing, etc.).
"""
from typing import Callable
from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware
from src.backend.config import settings
# Content Security Policy - allows CDN resources used by the client
# Note: 'unsafe-eval' required for Plotly.js runtime evaluation in SPC charts
CSP = (
"default-src 'self'; "
"script-src 'self' 'unsafe-inline' 'unsafe-eval' "
"https://cdn.tailwindcss.com https://cdn.jsdelivr.net https://cdn.plot.ly; "
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; "
"font-src 'self' https://fonts.gstatic.com; "
"img-src 'self' data: blob:; "
"connect-src 'self'"
)
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
"""Middleware that injects security headers into every response."""
async def dispatch(self, request: Request, call_next: Callable) -> Response:
response = await call_next(request)
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
response.headers["X-XSS-Protection"] = "1; mode=block"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
response.headers["Content-Security-Policy"] = CSP
# Add HSTS header only when running with HTTPS (SSL configured)
if settings.ssl_certfile and settings.ssl_keyfile:
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
return response