Compare commits

...

3 Commits

Author SHA1 Message Date
Adriano 1a0431366f 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>
2026-04-25 12:26:47 +02:00
Adriano 86df67f2e5 perf: scale workers + per-tablet rate limiting for 20 concurrent users
The default 2-worker gunicorn could only serve 2 concurrent tablet requests,
queueing the rest, and the rate limiter saw every tablet as the same Nginx
container IP, so 20 users would have collectively burned through the
100 req/min general bucket.

- gunicorn: 5 workers x 4 gthread, --forwarded-allow-ips=*, access log
- uvicorn: 4 workers, --proxy-headers, --forwarded-allow-ips=*
- RateLimitMiddleware: resolve real client IP from
  X-Forwarded-For -> X-Real-IP -> request.client.host
- Bump rate_limit_general 100 -> 300 req/min/IP (per tablet now)
- Flask: ProxyFix(x_for=1, x_proto=1, x_host=1) so request.remote_addr
  is the tablet IP, not the Nginx IP
- APIClient: forward X-Forwarded-For + X-Real-IP to FastAPI for both
  JSON and multipart/files calls; safe no-op outside request context
- 12 new tests (7 server + 5 client) covering header precedence,
  forwarding behavior and ProxyFix install

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 12:07:43 +02:00
Adriano ea8e4687b5 merge: rev04 Phase 1 — stations and per-tablet identity (feature/rev04-phase1-stations into V2.0.0) 2026-04-25 11:54:51 +02:00
174 changed files with 2762 additions and 314 deletions
+42 -10
View File
@@ -1,15 +1,47 @@
__pycache__
*.pyc
*.pyo
.pytest_cache
# Build context exclusions: keep image small and rebuilds fast.
# VCS
.git
.gitignore
.env
*.md
node_modules
.gitattributes
# Local envs and caches
.venv
venv
*.egg-info
.env
__pycache__/
*.pyc
*.pyo
*.pyd
.pytest_cache/
.coverage
htmlcov
.mypy_cache
htmlcov/
.mypy_cache/
*.egg-info
# IDE / editor
.vscode/
.idea/
*.swp
*~
# Node
node_modules/
# Local-only notes (Markdown docs are still copied if a Dockerfile asks)
PIANO_IMPLEMENTAZIONE.md
*.docx
# Legacy Dockerfiles preserved in tree but not needed in builds
src/backend/Dockerfile.legacy
src/frontend/flask_app/Dockerfile.legacy
# Uploads are a runtime volume, never baked in.
uploads/
# Claude Code
.claude/
.omc/
# Competitor analysis (local only)
Concorrente/
+11 -11
View File
@@ -33,18 +33,18 @@ env/
Thumbs.db
desktop.ini
# Uploads (server-side files)
server/uploads/images/*
server/uploads/pdfs/*
server/uploads/logos/*
server/uploads/reports/*
!server/uploads/images/.gitkeep
!server/uploads/pdfs/.gitkeep
!server/uploads/logos/.gitkeep
!server/uploads/reports/.gitkeep
# Uploads (server-side files, now at project root)
uploads/images/*
uploads/pdfs/*
uploads/logos/*
uploads/reports/*
!uploads/images/.gitkeep
!uploads/pdfs/.gitkeep
!uploads/logos/.gitkeep
!uploads/reports/.gitkeep
# TailwindCSS output
client/static/css/tailwind.css
src/frontend/flask_app/static/css/tailwind.css
# Node
node_modules/
@@ -63,7 +63,7 @@ node_modules/
htmlcov/
# Debug files
client/static/js/fabric-debug.js
src/frontend/flask_app/static/js/fabric-debug.js
# Misc
nul
+1
View File
@@ -0,0 +1 @@
3.11
+102 -35
View File
@@ -5,10 +5,65 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Panoramica
TieMeasureFlow by Tielogic - Sistema di gestione task per misurazioni con calibro manuale.
Monorepo con **server FastAPI** (backend API, porta 8000) e **client Flask** (frontend tablet, porta 5000), orchestrati con Docker Compose + Nginx reverse proxy + MySQL 8.0.
Monorepo con **backend FastAPI** (porta 8000) e **frontend Flask** (porta 5000), orchestrati con Docker Compose + Nginx reverse proxy + MySQL 8.0.
## Layout del repository (V2.0.0)
A partire dalla migrazione V2.0.0 (struttura conforme alla spec `python-project-spec-design.md`):
```
TieMeasureFlow/
├── pyproject.toml # Dipendenze monorepo (uv)
├── uv.lock # Lock file riproducibile
├── .python-version # 3.11
├── Dockerfile # Backend (uv + uvicorn)
├── Dockerfile.frontend # Frontend (uv + gunicorn + Tailwind + Babel)
├── docker-compose.dev.yml # Dev (Nginx)
├── docker-compose.yml # Prod (Traefik + SSL)
├── nginx/
├── uploads/ # Volume montato in /app/uploads
├── docs/
└── src/
├── backend/
│ ├── main.py # Entry FastAPI
│ ├── config.py
│ ├── database.py
│ ├── api/
│ │ ├── routers/ # 11 router REST
│ │ └── middleware/ # api_key, rate_limit, security_headers, logging
│ ├── models/
│ │ ├── orm/ # SQLAlchemy
│ │ └── api/ # Pydantic schemas
│ ├── services/ # Logica business
│ ├── migrations/ # Alembic
│ ├── templates/ # Setup page
│ └── tests/ # pytest
└── frontend/
└── flask_app/ # Flask + Jinja2 + Alpine.js (deroga vs spec React,
├── app.py # giustificata: tablet UX server-side, USB calipers,
├── config.py # workflow operatore con Fabric.js)
├── blueprints/
├── services/
├── templates/
├── static/
├── translations/
└── tests/
```
Dipendenze gestite con **uv** (no `requirements.txt`):
- `[project] dependencies` = core condivisi (pydantic, dotenv)
- `[project.optional-dependencies] server` = backend FastAPI
- `[project.optional-dependencies] client` = frontend Flask
- `[project.optional-dependencies] dev` = pytest, httpx, aiosqlite
## Comandi di Sviluppo
### Setup iniziale
```bash
cp .env.example .env
uv sync --extra server --extra client --extra dev # installa tutto
```
### Avvio servizi (Docker)
```bash
docker compose -f docker-compose.dev.yml up -d # Sviluppo (Nginx, porta 80)
@@ -20,51 +75,63 @@ docker compose ps # Stato servizi
### Avvio manuale (senza Docker)
```bash
# Server (terminale 1)
cd server && uvicorn main:app --reload --host 0.0.0.0 --port 8000
# Backend (terminale 1)
uv run uvicorn src.backend.main:app --reload --host 0.0.0.0 --port 8000
# Client (terminale 2)
cd client && flask run --host 0.0.0.0 --port 5000
# Frontend (terminale 2) — gunicorn richiede cwd interna per app:create_app()
cd src/frontend/flask_app && uv run --project ../../.. gunicorn --bind 0.0.0.0:5000 app:create_app()
# TailwindCSS watch (terminale 3)
cd client && npx tailwindcss -i static/css/input.css -o static/css/tailwind.css --watch
cd src/frontend/flask_app && npx tailwindcss -i static/css/input.css -o static/css/tailwind.css --watch
```
### Database & Migrations
```bash
# alembic.ini è in server/migrations/, serve il flag -c
cd server && alembic -c migrations/alembic.ini upgrade head # Applica migrazioni
cd server && alembic -c migrations/alembic.ini revision --autogenerate -m "descrizione" # Genera migrazione
cd server && alembic -c migrations/alembic.ini downgrade -1 # Rollback ultima
docker compose exec server alembic -c migrations/alembic.ini upgrade head # Via Docker
# alembic.ini in src/backend/migrations/, serve il flag -c
uv run alembic -c src/backend/migrations/alembic.ini upgrade head # Applica migrazioni
uv run alembic -c src/backend/migrations/alembic.ini revision --autogenerate -m "descrizione" # Genera
uv run alembic -c src/backend/migrations/alembic.ini downgrade -1 # Rollback
docker compose exec server uv run alembic -c src/backend/migrations/alembic.ini upgrade head # Via Docker
```
Nota: `env.py` sovrascrive la URL di alembic.ini con quella da `.env` (`settings.database_url`). `script_location = %(here)s` usa path relativo.
Nota: `env.py` aggiunge la project root a `sys.path`, sovrascrive la URL di alembic.ini con quella da `.env` (`settings.database_url`). `script_location = %(here)s` usa path relativo.
### Test
```bash
# Server (usa SQLite in-memory via aiosqlite, no MySQL richiesto)
cd server && pytest # Tutti i test
cd server && pytest tests/test_auth.py # Singolo modulo
cd server && pytest tests/test_auth.py::test_login_success # Singolo test
cd server && pytest --cov # Con copertura
# Tutti i test (backend + frontend)
uv run pytest
# Client
cd client && pytest
cd client && pytest tests/test_auth.py
# Solo backend (SQLite in-memory via aiosqlite, no MySQL richiesto)
uv run pytest src/backend/tests/
uv run pytest src/backend/tests/test_auth.py
uv run pytest src/backend/tests/test_auth.py::test_login_success
uv run pytest --cov src/backend
# Solo frontend
uv run pytest src/frontend/flask_app/tests/
```
### Gestione dipendenze (uv)
```bash
uv add <pacchetto> # core (entrambi)
uv add --optional server <pacchetto> # solo backend
uv add --optional client <pacchetto> # solo frontend
uv add --optional dev <pacchetto> # solo dev/test
uv sync --extra server --extra client --extra dev # reinstalla
uv lock # rigenera uv.lock
```
### i18n (Traduzioni)
```bash
# Estrai stringhe
cd client && pybabel extract -F babel.cfg -k _ -o translations/messages.pot .
cd src/frontend/flask_app && uv run pybabel extract -F babel.cfg -k _ -o translations/messages.pot .
# Aggiorna catalogo
cd client && pybabel update -i translations/messages.pot -d translations
cd src/frontend/flask_app && uv run pybabel update -i translations/messages.pot -d translations
# Compila .po → .mo
cd client && pybabel compile -d translations
cd src/frontend/flask_app && uv run pybabel compile -d translations
# oppure
cd client && python compile_translations.py
cd src/frontend/flask_app && uv run python compile_translations.py
```
### Setup iniziale
@@ -78,7 +145,7 @@ Browser/Tablet → Nginx (:80/443) → Flask Client (:5000) → APIClient → Fa
```
Il client Flask è un frontend server-side che comunica col backend via REST API. Ogni richiesta dal client al server include l'header `X-API-Key` per autenticazione.
### Server (FastAPI) — `server/`
### Server (FastAPI) — `src/backend/`
- **main.py**: entry point, lifespan async (`@asynccontextmanager`), registra middleware e 10 router. Health: `GET /api/health`
- **config.py**: `Settings` (pydantic_settings.BaseSettings), legge da `../.env`. Rate limits: login 5/min, general 100/min
- **database.py**: SQLAlchemy 2.0 async engine con `AsyncSession`, pool 10+20 overflow, `pool_recycle=3600`, `expire_on_commit=False`
@@ -87,10 +154,10 @@ Il client Flask è un frontend server-side che comunica col backend via REST API
- **middleware/**: Stack order (outermost→innermost): AccessLogMiddleware → CORSMiddleware → SecurityHeadersMiddleware → RateLimitMiddleware. Nota: `add_middleware()` in Starlette wrappa l'app, quindi l'ultimo aggiunto (AccessLog) è il più esterno. Il commento in `main.py` dice "outermost" per RateLimit ma è fuorviante. api_key.py (auth dependency `get_current_user()`), rate_limit.py (sliding window 60s per-IP, in-memory dicts), security_headers.py (CSP con `unsafe-eval` per Plotly.js, HSTS solo con SSL), logging.py (audit trail async su DB, esclude /api/health, /docs, /openapi.json, /redoc)
- **models/**: User (include `email`, `language_pref`, `theme_pref`), Recipe (`image_path` per preview), RecipeVersion, RecipeTask, RecipeSubtask (`image_path` per immagine specifica), Measurement (`synced_to_csv`, `input_method`), AccessLog, SystemSetting, RecipeVersionAudit
- **schemas/**: Pydantic v2 per validazione I/O API
- **migrations/**: Alembic con `alembic.ini` e `env.py` nella directory `server/migrations/`
- **migrations/**: Alembic con `alembic.ini` e `env.py` nella directory `src/backend/migrations/`
- **tests/**: pytest + pytest-asyncio, SQLite in-memory (`sqlite+aiosqlite://`, StaticPool), WeasyPrint mockato via `sys.modules`, rate limit reset tra test
### Client (Flask) — `client/`
### Client (Flask) — `src/frontend/flask_app/`
- **app.py**: factory pattern `create_app()`, CSRF (`WTF_CSRF_TIME_LIMIT=3600`), Babel i18n (`default_locale="it"`)
- **blueprints/**: auth (login/logout/session), maker (editor ricette con Fabric.js), measure (esecuzione misurazioni), statistics (dashboard SPC con Plotly.js), admin (gestione utenti CRUD, cambio password, toggle attivo — solo `is_admin`)
- **services/api_client.py**: singleton `APIClient` — wrapper HTTP (get/post/put/delete) con gestione errori normalizzata, timeout 30s, header X-API-Key da session
@@ -99,7 +166,7 @@ Il client Flask è un frontend server-side che comunica col backend via REST API
- **translations/**: Flask-Babel, cataloghi .po/.mo per IT/EN. Locale selector: `session["language"]` → Accept-Language → `"it"`
- **config.py**: `PERMANENT_SESSION_LIFETIME=28800` (8h), cookie secure in produzione, `BABEL_DEFAULT_TIMEZONE="Europe/Rome"`
## Template Structure (`client/templates/base.html`)
## Template Structure (`src/frontend/flask_app/templates/base.html`)
Ordine blocchi in `base.html`:
```
@@ -127,7 +194,7 @@ In alternativa, usare il filtro custom `|tojson_attr` (registrato in `app.py`) c
Per selettori CSS in `x-data`: usare `meta[name=csrf-token]` senza virgolette interne.
## Fabric.js Annotation Editor (`client/static/js/annotation-editor.js`)
## Fabric.js Annotation Editor (`src/frontend/flask_app/static/js/annotation-editor.js`)
Editor annotazioni su disegni tecnici (Fabric.js 5.3.1, ~1200 righe). Pattern critici:
@@ -152,16 +219,16 @@ Le ricette usano un versioning condizionale. L'endpoint `PUT /api/recipes/{id}`
La stessa logica si applica nel task router: aggiungere un task a una ricetta con measurements crea una nuova versione.
La versione corrente ha `is_current=True`, le precedenti `False`. Audit trail in `recipe_version_audit` (CREATE, UPDATE, ACTIVATE, RETIRE). Logica in `server/services/recipe_service.py`.
La versione corrente ha `is_current=True`, le precedenti `False`. Audit trail in `recipe_version_audit` (CREATE, UPDATE, ACTIVATE, RETIRE). Logica in `src/backend/services/recipe_service.py`.
### Calcolo Pass/Fail
Ogni subtask ha 4 limiti di tolleranza: UTL (upper tolerance), UWL (upper warning), LWL (lower warning), LTL (lower tolerance) più un valore nominale. Il calcolo in `server/services/measurement_service.py`:
Ogni subtask ha 4 limiti di tolleranza: UTL (upper tolerance), UWL (upper warning), LWL (lower warning), LTL (lower tolerance) più un valore nominale. Il calcolo in `src/backend/services/measurement_service.py`:
- Fuori UTL/LTL → **fail**
- Fuori UWL/LWL ma dentro UTL/LTL → **warning**
- Dentro UWL/LWL → **pass**
### SPC (Statistical Process Control)
Calcoli in `server/services/spc_service.py` usando solo `math` e `statistics` stdlib (no numpy/scipy): summary (conteggi pass/warning/fail), capability (Cp, Cpk, Pp, Ppk), control chart (UCL/LCL = mean ± 3σ), histogram (20 bin + curva normale).
Calcoli in `src/backend/services/spc_service.py` usando solo `math` e `statistics` stdlib (no numpy/scipy): summary (conteggi pass/warning/fail), capability (Cp, Cpk, Pp, Ppk), control chart (UCL/LCL = mean ± 3σ), histogram (20 bin + curva normale).
### Autenticazione
1. Login con username/password → server ritorna `api_key` (64 char random)
@@ -187,7 +254,7 @@ Upload in `uploads/{recipe_id}/{version_id}/`. Tipi ammessi: JPEG, PNG, GIF, Web
## Test Infrastructure
### Server (`server/tests/conftest.py`)
### Server (`src/backend/tests/conftest.py`)
- SQLite in-memory `sqlite+aiosqlite://` con `StaticPool` (singola connessione condivisa tra fixture, app e test)
- WeasyPrint mockato prima di qualsiasi import server: `sys.modules["weasyprint"] = MagicMock()`
- Rate limit buckets resettati tra test (walk middleware stack → clear dicts)
@@ -196,7 +263,7 @@ Upload in `uploads/{recipe_id}/{version_id}/`. Tipi ammessi: JPEG, PNG, GIF, Web
- Helper: `auth_headers(user)``{"X-API-Key": user.api_key}`
- Client httpx: `AsyncClient` con `ASGITransport(app=app)`, override di `get_db` dependency
### Client (`client/tests/conftest.py`)
### Client (`src/frontend/flask_app/tests/conftest.py`)
- `api_client` patchato in 4 blueprint (auth, maker, measure, statistics). **admin NON è patchato** — i test admin devono gestire il mock manualmente
- `logged_in_client` fixture pre-popola session con `api_key`, `user_id`, `language`, `theme` + user dict
- CSRF disabilitato nei test: `WTF_CSRF_ENABLED=False`
@@ -232,7 +299,7 @@ Variabili d'ambiente in `.env` (copiare da `.env.example`):
- DB: `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD`, `DB_ROOT_PASSWORD` (Docker)
- Server: `SERVER_HOST`, `SERVER_PORT`, `SERVER_SECRET_KEY`, `SERVER_CORS_ORIGINS`
- Client: `CLIENT_HOST`, `CLIENT_PORT`, `CLIENT_SECRET_KEY`, `API_SERVER_URL`
- Upload: `UPLOAD_DIR` (default `"uploads"`, relativo a `server/`), `MAX_UPLOAD_SIZE_MB`
- Upload: `UPLOAD_DIR` (default `"uploads"`, relativo a `src/backend/`), `MAX_UPLOAD_SIZE_MB`
- Docker: `NGINX_PORT`, `NGINX_SSL_PORT`
- Setup: `SETUP_PASSWORD` (vuota = endpoint disabilitato)
- SSL: `SSL_CERTFILE`, `SSL_KEYFILE`
+40
View File
@@ -0,0 +1,40 @@
FROM python:3.11-slim AS base
# Install uv (fast Python package manager) from official slim image.
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/
# System libs required by WeasyPrint at runtime.
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 lockfile + project metadata first to maximize Docker layer cache.
COPY pyproject.toml uv.lock ./
COPY .python-version ./
# Install ONLY backend deps from the locked dependencies. --frozen ensures
# we never resolve at build time; --no-dev keeps the image lean.
RUN uv sync --frozen --no-dev --extra server
# Now copy the actual sources.
COPY src/ ./src/
# Uploads directory (mounted as a volume in production).
RUN mkdir -p /app/uploads/images /app/uploads/pdfs /app/uploads/logos /app/uploads/reports
EXPOSE 8000
# Entry point: run Alembic migrations then start Uvicorn through uv so
# it uses the pinned interpreter and venv from `uv sync`.
CMD ["sh", "-c", \
"uv run alembic -c src/backend/migrations/alembic.ini upgrade head && \
uv run uvicorn src.backend.main:app \
--host 0.0.0.0 --port 8000 --workers 4 \
--proxy-headers --forwarded-allow-ips='*'"]
+42
View File
@@ -0,0 +1,42 @@
FROM python:3.11-slim AS base
# uv from the official slim image (fast Python package manager).
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/
# Node.js 20 is needed at build time to compile TailwindCSS.
RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \
&& 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
# Resolve Python deps from the project lockfile (only the `client` extra).
COPY pyproject.toml uv.lock ./
COPY .python-version ./
RUN uv sync --frozen --no-dev --extra client
# Copy the Flask app sources.
COPY src/frontend/flask_app/ ./flask_app/
# Build TailwindCSS (one-shot; no watcher in production image).
WORKDIR /app/flask_app
RUN npm install tailwindcss@3 && \
npx tailwindcss -i static/css/input.css -o static/css/tailwind.css --minify
# Compile Flask-Babel translation catalogs.
RUN uv run --project /app pybabel compile -d translations
EXPOSE 5000
# Gunicorn behind Nginx/Traefik. Worker count and proxy trust kept in sync
# with the per-tablet rate-limiting fix (see V2.0.0 perf commit).
CMD ["uv", "run", "--project", "/app", "gunicorn", \
"--workers", "5", \
"--threads", "4", \
"--worker-class", "gthread", \
"--timeout", "60", \
"--bind", "0.0.0.0:5000", \
"--access-logfile", "-", \
"--forwarded-allow-ips", "*", \
"app:create_app()"]
Binary file not shown.
-4
View File
@@ -1,4 +0,0 @@
[pytest]
testpaths = tests
python_files = test_*.py
python_functions = test_*
-15
View File
@@ -1,15 +0,0 @@
# Flask
flask>=3.0.0
flask-babel>=4.0.0
flask-wtf>=1.2.0
# HTTP Client (to call FastAPI server)
requests>=2.31.0
urllib3>=2.0.0
# Utilities
python-dotenv>=1.0.0
# Testing
pytest>=8.0.0
coverage>=7.0.0
+3 -3
View File
@@ -24,7 +24,7 @@ services:
server:
build:
context: ./server
context: .
dockerfile: Dockerfile
container_name: tmflow-server
restart: unless-stopped
@@ -43,8 +43,8 @@ services:
client:
build:
context: ./client
dockerfile: Dockerfile
context: .
dockerfile: Dockerfile.frontend
container_name: tmflow-client
restart: unless-stopped
env_file:
+3 -3
View File
@@ -24,7 +24,7 @@ services:
server:
build:
context: ./server
context: .
dockerfile: Dockerfile
container_name: tmflow-server
restart: unless-stopped
@@ -52,8 +52,8 @@ services:
client:
build:
context: ./client
dockerfile: Dockerfile
context: .
dockerfile: Dockerfile.frontend
container_name: tmflow-client
restart: unless-stopped
env_file:
+73
View File
@@ -0,0 +1,73 @@
[project]
name = "tiemeasureflow"
version = "2.0.0"
description = "TieMeasureFlow by Tielogic — manual caliper measurement task management for industrial QA stations."
requires-python = ">=3.11"
authors = [
{ name = "Adriano Dal Pastro", email = "adrianodalpastro@tielogic.com" },
]
# Shared core deps used by both backend and the Flask frontend.
dependencies = [
"pydantic>=2.0.0",
"pydantic-settings>=2.0.0",
"python-dotenv>=1.0.0",
]
[project.optional-dependencies]
# Backend (FastAPI + DB + reports).
server = [
"fastapi>=0.110.0",
"uvicorn[standard]>=0.30.0",
"sqlalchemy[asyncio]>=2.0.0",
"asyncmy>=0.2.0",
"alembic>=1.13.0",
"bcrypt>=4.0.0",
"pillow>=10.0.0",
"python-multipart>=0.0.6",
"jinja2>=3.1.0",
"plotly>=5.0.0",
"kaleido>=0.2.0",
"weasyprint>=62.0",
]
# Frontend (Flask tablet UI).
client = [
"flask>=3.0.0",
"flask-babel>=4.0.0",
"flask-wtf>=1.2.0",
"requests>=2.31.0",
"urllib3>=2.0.0",
"gunicorn>=21.0.0",
]
# Dev / test (covers both server and client tests).
dev = [
"pytest>=8.0.0",
"pytest-asyncio>=0.23.0",
"httpx>=0.27.0",
"aiosqlite>=0.20.0",
"coverage>=7.0.0",
]
[project.scripts]
# Backend
server = "uvicorn:run" # placeholder, real CMD lives in Dockerfile
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
# Source layout will be src/backend/ and src/frontend/flask_app/ after the
# folder restructure (Phase 2 of the V2.0.0 plan).
packages = ["src/backend", "src/frontend"]
[tool.uv]
# Pin the resolver to the deps we declared; reproducible builds.
package = false
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["src/backend/tests", "src/frontend/flask_app/tests"]
-19
View File
@@ -1,19 +0,0 @@
"""SQLAlchemy models for TieMeasureFlow."""
from models.user import User
from models.recipe import Recipe, RecipeVersion
from models.task import RecipeTask, RecipeSubtask
from models.measurement import Measurement
from models.access_log import AccessLog
from models.setting import SystemSetting, RecipeVersionAudit
__all__ = [
"User",
"Recipe",
"RecipeVersion",
"RecipeTask",
"RecipeSubtask",
"Measurement",
"AccessLog",
"SystemSetting",
"RecipeVersionAudit",
]
-5
View File
@@ -1,5 +0,0 @@
[pytest]
asyncio_mode = auto
testpaths = tests
python_files = test_*.py
python_functions = test_*
@@ -23,4 +23,4 @@ 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 2"]
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='*'"]
@@ -1,5 +1,5 @@
"""FastAPI middleware for TieMeasureFlow."""
from middleware.api_key import (
from src.backend.api.middleware.api_key import (
get_current_user,
require_role,
require_admin,
@@ -8,9 +8,9 @@ from middleware.api_key import (
require_metrologist,
require_admin_user,
)
from middleware.logging import AccessLogMiddleware
from middleware.rate_limit import RateLimitMiddleware
from middleware.security_headers import SecurityHeadersMiddleware
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",
@@ -3,8 +3,8 @@ from fastapi import Depends, HTTPException, Request, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from database import get_db
from models.user import User
from src.backend.database import get_db
from src.backend.models.orm.user import User
async def get_current_user(
@@ -6,8 +6,8 @@ from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware
from sqlalchemy import insert
from database import async_session_factory
from models.access_log import AccessLog
from src.backend.database import async_session_factory
from src.backend.models.orm.access_log import AccessLog
class AccessLogMiddleware(BaseHTTPMiddleware):
@@ -11,7 +11,7 @@ from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import JSONResponse
from config import settings
from src.backend.config import settings
class RateLimitMiddleware(BaseHTTPMiddleware):
@@ -32,6 +32,25 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
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
@@ -68,7 +87,7 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
return True, 0
async def dispatch(self, request: Request, call_next: Callable) -> Response:
client_ip = request.client.host if request.client else "unknown"
client_ip = self._client_ip(request)
now = time.time()
path = request.url.path
@@ -8,7 +8,7 @@ from typing import Callable
from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware
from config import settings
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
@@ -2,16 +2,16 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from database import get_db
from middleware.api_key import get_current_user
from models.user import User
from schemas.user import (
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 services.auth_service import authenticate_user, login_user, logout_user
from src.backend.services.auth_service import authenticate_user, login_user, logout_user
router = APIRouter(prefix="/api/auth", tags=["auth"])
@@ -7,9 +7,9 @@ from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from fastapi.responses import FileResponse
from PIL import Image
from config import settings
from middleware.api_key import get_current_user, require_maker
from models.user import User
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"])
@@ -8,23 +8,23 @@ from fastapi.responses import StreamingResponse
from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from database import get_db
from middleware.api_key import (
from src.backend.database import get_db
from src.backend.api.middleware.api_key import (
get_current_user,
require_measurement_tec,
require_metrologist,
)
from models.measurement import Measurement
from models.recipe import RecipeVersion
from models.setting import SystemSetting
from models.user import User
from schemas.measurement import (
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 services.measurement_service import save_measurement
from src.backend.services.measurement_service import save_measurement
router = APIRouter(prefix="/api/measurements", tags=["measurements"])
@@ -2,17 +2,17 @@
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from database import get_db
from middleware.api_key import get_current_user, require_maker, require_measurement_tec
from models.user import User
from schemas.recipe import (
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 services import recipe_service
from src.backend.services import recipe_service
router = APIRouter(prefix="/api/recipes", tags=["recipes"])
@@ -5,10 +5,10 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.responses import Response
from sqlalchemy.ext.asyncio import AsyncSession
from database import get_db
from middleware.api_key import require_metrologist
from models.user import User
from services.report_service import generate_measurement_report, generate_spc_report
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"])
@@ -5,11 +5,11 @@ from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from config import settings
from database import get_db
from middleware.api_key import get_current_user, require_admin_user
from models.setting import SystemSetting
from models.user import User
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"])
@@ -15,14 +15,14 @@ from pydantic import BaseModel
from sqlalchemy import inspect as sa_inspect, select
from sqlalchemy.ext.asyncio import AsyncSession
from config import settings
from database import Base, engine, async_session_factory
from models import (
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 models.station import Station, StationRecipeAssignment
from services.auth_service import hash_password
from services.measurement_service import calculate_pass_fail
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"])
@@ -2,10 +2,10 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from database import get_db
from middleware.api_key import get_current_user, require_admin_user
from models.user import User
from schemas.station import (
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,
@@ -13,7 +13,7 @@ from schemas.station import (
StationRecipeAssignmentResponse,
RecipeSummary,
)
from services import station_service
from src.backend.services import station_service
router = APIRouter(prefix="/api/stations", tags=["stations"])
@@ -5,19 +5,19 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import and_, select
from sqlalchemy.ext.asyncio import AsyncSession
from database import get_db
from middleware.api_key import require_metrologist
from models.measurement import Measurement
from models.recipe import RecipeVersion
from models.task import RecipeSubtask, RecipeTask
from models.user import User
from schemas.statistics import (
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 services.spc_service import (
from src.backend.services.spc_service import (
compute_capability,
compute_control_chart,
compute_histogram,
@@ -4,12 +4,12 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from database import get_db
from middleware.api_key import require_maker, get_current_user
from models.recipe import Recipe, RecipeVersion
from models.task import RecipeSubtask, RecipeTask
from models.user import User
from schemas.task import (
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,
@@ -18,7 +18,7 @@ from schemas.task import (
TaskResponse,
TaskUpdate,
)
from services import recipe_service
from src.backend.services import recipe_service
router = APIRouter(tags=["tasks"])
@@ -159,7 +159,7 @@ async def create_task(
if has_measurements:
# Copy-on-write: create new version preserving measurement data
from schemas.recipe import RecipeUpdate
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
)
@@ -3,11 +3,11 @@ from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from database import get_db
from middleware.api_key import require_admin_user
from models.user import User
from schemas.user import UserCreate, UserPasswordChange, UserResponse, UserUpdate
from services.auth_service import create_user, hash_password, regenerate_api_key
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"])
+11 -5
View File
@@ -23,9 +23,9 @@ class Settings(BaseSettings):
upload_dir: str = "uploads"
max_upload_size_mb: int = 50
# Rate Limiting (requests per minute)
# Rate Limiting (requests per minute, per real client IP)
rate_limit_login: int = 5
rate_limit_general: int = 100
rate_limit_general: int = 300
# SSL (Production)
ssl_certfile: str | None = None
@@ -49,10 +49,16 @@ class Settings(BaseSettings):
@property
def upload_path(self) -> Path:
"""Absolute path to upload directory."""
return Path(__file__).parent / self.upload_dir
"""Absolute path to upload directory.
model_config = {"env_file": "../.env", "env_file_encoding": "utf-8", "extra": "ignore"}
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()
@@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import (
)
from sqlalchemy.orm import DeclarativeBase
from config import settings
from src.backend.config import settings
# Create async engine
engine = create_async_engine(
+16 -16
View File
@@ -5,22 +5,22 @@ from collections.abc import AsyncGenerator
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from config import settings
from database import init_db
from middleware.logging import AccessLogMiddleware
from middleware.rate_limit import RateLimitMiddleware
from middleware.security_headers import SecurityHeadersMiddleware
from routers.auth import router as auth_router
from routers.users import router as users_router
from routers.recipes import router as recipes_router
from routers.tasks import router as tasks_router
from routers.measurements import router as measurements_router
from routers.files import router as files_router
from routers.settings import router as settings_router
from routers.reports import router as reports_router
from routers.statistics import router as statistics_router
from routers.setup import router as setup_router
from routers.stations import router as stations_router
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
@@ -17,20 +17,25 @@ if config.config_file_name is not None:
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from config import settings
from database import Base
# 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 models.user import User # noqa: F401
from models.recipe import Recipe, RecipeVersion # noqa: F401
from models.task import RecipeTask, RecipeSubtask # noqa: F401
from models.measurement import Measurement # noqa: F401
from models.access_log import AccessLog # noqa: F401
from models.setting import SystemSetting, RecipeVersionAudit # noqa: F401
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
@@ -1,19 +1,19 @@
"""Pydantic schemas for TieMeasureFlow API."""
from schemas.measurement import (
from src.backend.models.api.measurement import (
MeasurementBatchCreate,
MeasurementCreate,
MeasurementListResponse,
MeasurementQuery,
MeasurementResponse,
)
from schemas.recipe import (
from src.backend.models.api.recipe import (
RecipeCreate,
RecipeListResponse,
RecipeResponse,
RecipeUpdate,
RecipeVersionResponse,
)
from schemas.statistics import (
from src.backend.models.api.statistics import (
AlertData,
CapabilityData,
ControlChartData,
@@ -23,7 +23,7 @@ from schemas.statistics import (
SummaryData,
TrendData,
)
from schemas.task import (
from src.backend.models.api.task import (
SubtaskCreate,
SubtaskResponse,
SubtaskUpdate,
@@ -32,7 +32,7 @@ from schemas.task import (
TaskResponse,
TaskUpdate,
)
from schemas.user import (
from src.backend.models.api.user import (
LoginRequest,
LoginResponse,
UserCreate,
@@ -5,7 +5,7 @@ from typing import Optional, TYPE_CHECKING
from pydantic import BaseModel, ConfigDict, Field
if TYPE_CHECKING:
from schemas.task import TaskResponse
from src.backend.models.api.task import TaskResponse
class RecipeCreate(BaseModel):
@@ -71,6 +71,6 @@ class RecipeListResponse(BaseModel):
# Forward reference imports for model_rebuild
from schemas.task import TaskResponse # noqa: E402
from src.backend.models.api.task import TaskResponse # noqa: E402
RecipeVersionResponse.model_rebuild()
RecipeResponse.model_rebuild()
+19
View File
@@ -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",
]
@@ -5,7 +5,7 @@ from typing import Optional
from sqlalchemy import BigInteger, DateTime, ForeignKey, Index, Integer, JSON, String, func
from sqlalchemy.orm import Mapped, mapped_column
from database import Base
from src.backend.database import Base
class AccessLog(Base):
@@ -8,7 +8,7 @@ from sqlalchemy import (
)
from sqlalchemy.orm import Mapped, mapped_column
from database import Base
from src.backend.database import Base
class Measurement(Base):
@@ -5,10 +5,10 @@ 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 database import Base
from src.backend.database import Base
if TYPE_CHECKING:
from models.task import RecipeTask
from src.backend.models.orm.task import RecipeTask
class Recipe(Base):
@@ -5,7 +5,7 @@ from typing import Optional
from sqlalchemy import DateTime, Enum, ForeignKey, Index, Integer, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column
from database import Base
from src.backend.database import Base
class SystemSetting(Base):
@@ -10,10 +10,10 @@ 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 database import Base
from src.backend.database import Base
if TYPE_CHECKING:
from models.recipe import Recipe
from src.backend.models.orm.recipe import Recipe
class Station(Base):
@@ -6,10 +6,10 @@ from sqlalchemy import (
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from database import Base
from src.backend.database import Base
if TYPE_CHECKING:
from models.recipe import RecipeVersion
from src.backend.models.orm.recipe import RecipeVersion
class RecipeTask(Base):
@@ -5,7 +5,7 @@ from typing import Optional
from sqlalchemy import Boolean, DateTime, Enum, Integer, JSON, String, func
from sqlalchemy.orm import Mapped, mapped_column
from database import Base
from src.backend.database import Base
class User(Base):
@@ -6,7 +6,7 @@ import bcrypt
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from models.user import User
from src.backend.models.orm.user import User
def hash_password(password: str) -> str:
@@ -4,8 +4,8 @@ from decimal import Decimal
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from models.measurement import Measurement
from models.task import RecipeSubtask
from src.backend.models.orm.measurement import Measurement
from src.backend.models.orm.task import RecipeSubtask
def calculate_pass_fail(
@@ -7,12 +7,12 @@ from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from models.measurement import Measurement
from models.recipe import Recipe, RecipeVersion
from models.setting import RecipeVersionAudit
from models.task import RecipeSubtask, RecipeTask
from models.user import User
from schemas.recipe import RecipeCreate, RecipeUpdate
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
# ---------------------------------------------------------------------------
@@ -10,13 +10,13 @@ from sqlalchemy import and_, select
from sqlalchemy.ext.asyncio import AsyncSession
from weasyprint import HTML
from config import settings
from models.measurement import Measurement
from models.recipe import Recipe, RecipeVersion
from models.setting import SystemSetting
from models.task import RecipeSubtask, RecipeTask
from models.user import User
from services.spc_service import (
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,
@@ -9,7 +9,7 @@ import math
import statistics as stats
from datetime import datetime
from schemas.statistics import (
from src.backend.models.api.statistics import (
CapabilityData,
ControlChartData,
HistogramData,
@@ -10,10 +10,10 @@ from fastapi import HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from models.recipe import Recipe
from models.station import Station, StationRecipeAssignment
from models.user import User
from schemas.station import StationCreate, StationUpdate
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(
@@ -30,17 +30,17 @@ if "weasyprint" not in sys.modules:
# Ensure the server package is importable
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from database import Base, get_db
from main import app
from middleware.rate_limit import RateLimitMiddleware
from models.user import User
from models.recipe import Recipe, RecipeVersion
from models.task import RecipeTask, RecipeSubtask
from models.measurement import Measurement
from models.access_log import AccessLog
from models.setting import SystemSetting, RecipeVersionAudit
from models.station import Station, StationRecipeAssignment
from services.auth_service import hash_password, generate_api_key
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
@@ -2,7 +2,7 @@
import pytest
from httpx import AsyncClient
from tests.conftest import _create_user, auth_headers
from src.backend.tests.conftest import _create_user, auth_headers
class TestLogin:
@@ -8,8 +8,8 @@ from unittest.mock import patch
import pytest
from httpx import AsyncClient
from models.user import User
from tests.conftest import auth_headers
from src.backend.models.orm.user import User
from src.backend.tests.conftest import auth_headers
class TestUploadFile:
@@ -45,7 +45,7 @@ class TestUploadFile:
)
# Patch settings.upload_path to use tmp_path
with patch("routers.files.settings") as mock_settings:
with patch("src.backend.api.routers.files.settings") as mock_settings:
mock_settings.upload_path = tmp_path
mock_settings.max_upload_size_mb = 50
@@ -82,7 +82,7 @@ class TestUploadFile:
# Create a large file content
large_content = b"\x00" * (51 * 1024 * 1024) # 51MB
with patch("routers.files.settings") as mock_settings:
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())
@@ -109,7 +109,7 @@ class TestGetFile:
test_file = tmp_path / "testfile.txt"
test_file.write_text("hello test")
with patch("routers.files.settings") as mock_settings:
with patch("src.backend.api.routers.files.settings") as mock_settings:
mock_settings.upload_path = tmp_path
resp = await client.get(
@@ -123,7 +123,7 @@ class TestGetFile:
self, client: AsyncClient, maker_user: User, tmp_path: Path
):
"""Requesting a non-existent file returns 404."""
with patch("routers.files.settings") as mock_settings:
with patch("src.backend.api.routers.files.settings") as mock_settings:
mock_settings.upload_path = tmp_path
resp = await client.get(
@@ -144,7 +144,7 @@ class TestDeleteFile:
test_file = tmp_path / "deleteme.txt"
test_file.write_text("delete me")
with patch("routers.files.settings") as mock_settings:
with patch("src.backend.api.routers.files.settings") as mock_settings:
mock_settings.upload_path = tmp_path
resp = await client.delete(
@@ -163,7 +163,7 @@ class TestFilenameSanitization:
self, client: AsyncClient, maker_user: User, tmp_path: Path
):
"""Path traversal attempt returns 403 or 404."""
with patch("routers.files.settings") as mock_settings:
with patch("src.backend.api.routers.files.settings") as mock_settings:
mock_settings.upload_path = tmp_path
resp = await client.get(
@@ -3,10 +3,10 @@ import pytest
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
from models.user import User
from models.recipe import RecipeVersion
from models.task import RecipeTask, RecipeSubtask
from tests.conftest import auth_headers, create_test_recipe
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(
@@ -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"
@@ -3,8 +3,8 @@ import pytest
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
from models.user import User
from tests.conftest import auth_headers, create_test_recipe
from src.backend.models.orm.user import User
from src.backend.tests.conftest import auth_headers, create_test_recipe
class TestListRecipes:
@@ -9,8 +9,8 @@ import pytest
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
from models.user import User
from tests.conftest import auth_headers, create_test_recipe
from src.backend.models.orm.user import User
from src.backend.tests.conftest import auth_headers, create_test_recipe
class TestMeasurementReport:
@@ -29,7 +29,7 @@ class TestMeasurementReport:
fake_pdf = b"%PDF-1.4 fake content"
with patch(
"routers.reports.generate_measurement_report",
"src.backend.api.routers.reports.generate_measurement_report",
new_callable=AsyncMock,
return_value=fake_pdf,
):
@@ -75,7 +75,7 @@ class TestSPCReport:
fake_pdf = b"%PDF-1.4 spc report content"
with patch(
"routers.reports.generate_spc_report",
"src.backend.api.routers.reports.generate_spc_report",
new_callable=AsyncMock,
return_value=fake_pdf,
):
@@ -9,7 +9,7 @@ from unittest.mock import patch
import pytest
from httpx import AsyncClient
from tests.conftest import _create_user, auth_headers
from src.backend.tests.conftest import _create_user, auth_headers
class TestCORSHeaders:
@@ -126,7 +126,7 @@ class TestFilenameSanitization:
async def test_dotfile_rejected(self, client: AsyncClient, db_session):
"""Filenames starting with '.' are rejected."""
from routers.files import sanitize_filename
from src.backend.api.routers.files import sanitize_filename
from fastapi import HTTPException
with pytest.raises(HTTPException) as exc_info:
@@ -136,7 +136,7 @@ class TestFilenameSanitization:
async def test_empty_filename_rejected(self, client: AsyncClient, db_session):
"""Empty filenames are rejected."""
from routers.files import sanitize_filename
from src.backend.api.routers.files import sanitize_filename
from fastapi import HTTPException
with pytest.raises(HTTPException) as exc_info:
@@ -3,9 +3,9 @@ import pytest
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
from models.setting import SystemSetting
from models.user import User
from tests.conftest import auth_headers
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:
@@ -2,8 +2,8 @@
import pytest
from httpx import AsyncClient
from config import settings
from tests.conftest import test_engine, TestSessionFactory
from src.backend.config import settings
from src.backend.tests.conftest import test_engine, TestSessionFactory
# ---------------------------------------------------------------------------
@@ -20,7 +20,7 @@ def enable_setup(monkeypatch):
# 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 routers.setup as setup_mod
import src.backend.api.routers.setup as setup_mod
monkeypatch.setattr(setup_mod, "engine", test_engine)
monkeypatch.setattr(setup_mod, "async_session_factory", TestSessionFactory)
@@ -52,7 +52,7 @@ async def test_seed_measurements_have_correct_distribution(client: AsyncClient,
await _seed(client)
from sqlalchemy import select, func
from models.measurement import Measurement
from src.backend.models.orm.measurement import Measurement
result = await db_session.execute(
select(Measurement.pass_fail, func.count()).group_by(Measurement.pass_fail)
@@ -74,7 +74,7 @@ async def test_seed_measurements_have_lot_and_serial(client: AsyncClient, db_ses
await _seed(client)
from sqlalchemy import select, func
from models.measurement import Measurement
from src.backend.models.orm.measurement import Measurement
result = await db_session.execute(
select(func.count()).where(Measurement.lot_number.is_(None))
@@ -89,7 +89,7 @@ async def test_seed_measurements_input_methods(client: AsyncClient, db_session):
await _seed(client)
from sqlalchemy import select, func
from models.measurement import Measurement
from src.backend.models.orm.measurement import Measurement
result = await db_session.execute(
select(Measurement.input_method, func.count()).group_by(Measurement.input_method)
@@ -4,8 +4,8 @@ from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from models.station import Station, StationRecipeAssignment
from tests.conftest import _create_user, create_test_recipe
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):
@@ -2,7 +2,7 @@
import pytest
from pydantic import ValidationError
from schemas.station import (
from src.backend.models.api.station import (
StationCreate, StationUpdate, StationResponse,
StationRecipeAssignmentCreate, StationRecipeAssignmentResponse,
StationWithRecipesResponse,
@@ -18,10 +18,10 @@ from httpx import AsyncClient
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from config import settings
from models.station import Station, StationRecipeAssignment
from models.recipe import Recipe
from tests.conftest import test_engine, TestSessionFactory
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"
@@ -31,7 +31,7 @@ SETUP_PWD = "test-setup-pwd"
def enable_setup(monkeypatch):
"""Enable setup endpoints and redirect engine/session to test SQLite DB."""
monkeypatch.setattr(settings, "setup_password", SETUP_PWD)
import routers.setup as setup_mod
import src.backend.api.routers.setup as setup_mod
monkeypatch.setattr(setup_mod, "engine", test_engine)
monkeypatch.setattr(setup_mod, "async_session_factory", TestSessionFactory)
@@ -3,14 +3,14 @@ import pytest
from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from models.station import Station
from schemas.station import StationCreate, StationUpdate
from services.station_service import (
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 tests.conftest import _create_user, create_test_recipe
from src.backend.tests.conftest import _create_user, create_test_recipe
async def test_create_station_ok(db_session: AsyncSession):
@@ -102,7 +102,7 @@ async def test_list_recipes_only_returns_active(db_session: AsyncSession):
async def test_delete_station_cascades_assignments(db_session: AsyncSession):
from sqlalchemy import select
from models.station import StationRecipeAssignment
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")
@@ -2,7 +2,7 @@
import pytest
from httpx import AsyncClient
from tests.conftest import auth_headers, create_test_recipe
from src.backend.tests.conftest import auth_headers, create_test_recipe
async def test_list_stations_requires_auth(client: AsyncClient):
@@ -3,9 +3,9 @@ import pytest
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
from models.user import User
from models.measurement import Measurement
from tests.conftest import auth_headers, create_test_recipe
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(
@@ -3,8 +3,8 @@ import pytest
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
from models.user import User
from tests.conftest import auth_headers, create_test_recipe
from src.backend.models.orm.user import User
from src.backend.tests.conftest import auth_headers, create_test_recipe
class TestListTasks:
@@ -3,8 +3,8 @@ import pytest
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
from models.user import User
from tests.conftest import _create_user, auth_headers
from src.backend.models.orm.user import User
from src.backend.tests.conftest import _create_user, auth_headers
class TestListUsers:
@@ -22,4 +22,12 @@ RUN pybabel compile -d translations
EXPOSE 5000
CMD ["gunicorn", "--workers", "2", "--bind", "0.0.0.0:5000", "app:create_app()"]
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()"]
@@ -6,6 +6,7 @@ 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
@@ -26,6 +27,11 @@ def create_app() -> Flask:
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)

Some files were not shown because too many files have changed in this diff Show More