11 Commits

Author SHA1 Message Date
Adriano 85a00dea1b fix(api): allow MeasurementTec to list measurements for task_complete
GET /api/measurements required Metrologist, but the operator workflow
calls it from task_complete.html to render the post-recipe riepilogo —
silently empty for every non-Metrologist user. Open to any authenticated
user; payload carries no PII beyond numeric values + recipe ref.

Regression test added.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 10:42:16 +02:00
Adriano e4eb4cd932 docs: refresh stato + roadmap with smoke-test findings; fix sort syntax
STATO_PROGETTO.md
- Bumped snapshot date to 2026-04-27.
- Added "Hardening post-restructure (smoke test 2026-04-26)" section
  recording the four runtime regressions surfaced by the local smoke
  test (env_file path, missing Station import, UPLOAD_DIR default,
  apostrophe-in-translation in Alpine), plus the recipe-assignment
  modal UX rework and the new node-based JS syntax test guard.
- Added "Smoke test status" section listing the verified end-to-end
  flow (MySQL up, alembic, seed, login, admin pages, MeasurementTec
  workflow, hot reload).
- Bumped frontend test count 44 → 46 to reflect
  test_template_js_syntax.py.

ROADMAP.md
- Added a tech-debt entry for the user-reported task_complete
  riepilogo rendering anomaly (still under investigation: the curl
  fetch returns the table populated, but the user reports an empty
  body in the browser).
- Added a tech-debt entry for the still-pending Docker container
  smoke test of the new uv-based Dockerfiles.

src/frontend/flask_app/templates/measure/task_complete.html
- Replaced sort(attribute='task_info.order_index,subtask.marker_number')
  with two chained stable sorts. Jinja's sort filter does not accept a
  comma-separated multi-attribute string; the previous form sorted on
  a non-existent attribute and only worked by accident because the
  API already returned rows in the desired order.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 13:10:58 +02:00
Adriano 4de7d78b66 docs: document stations end-to-end (user guide + API + deployment)
Stations were the headline V2.0.0 feature but had no user-facing
documentation outside the architecture page. Filled the gap across
the three operational docs.

USER_GUIDE.md
- New entries in "Key Concepts": Station and Station assignment.
- New "Recipes you see are filtered by station" subsection in the
  MeasurementTec workflow, explaining why the Select Recipe page may
  legitimately show fewer recipes than expected and what the
  "Stazione non configurata" error means at the operator level.
- New "Station Management" section under Admin Workflow covering:
  the mental model, station create/edit/delete, the two-column
  recipe-assignment modal, the immutable-code rule, the role of the
  ST-DEFAULT seed station, and the tablet deployment cheat sheet.
- Admin role description updated to mention stations.

DEPLOYMENT.md
- Environment Variables Reference: added STATION_CODE row and noted
  that an empty value triggers the deliberate fail-fast HTTP 503 on
  /measure/select. Updated RATE_LIMIT_GENERAL default (300, per the
  V2.0.0 perf change). Clarified UPLOAD_DIR resolves against the
  project root.

API.md
- New "Stations" endpoint section listing all eight routes with
  request/response examples and the 401/403/404/409 error contract:
  GET / POST /stations, GET /stations/{id}, PUT /stations/{id},
  DELETE /stations/{id}, GET /stations/{id}/recipes,
  GET /stations/by-code/{code}/recipes (the operator-facing one used
  by the Flask client), POST /stations/{id}/recipes,
  DELETE /stations/{id}/recipes/{recipe_id}.
- TOC updated with the new "Stations" anchor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 17:26:43 +02:00
Adriano 2a2d40bec9 fix(admin): two more apostrophe-in-translation regressions + UX rework
UX rework — recipe assignment modal on /admin/stations
- Replace the single "select recipe + Assegna" dropdown with a two-
  column layout: Ricette disponibili (left) and Assegnate alla
  stazione (right), each row with an inline action button. Top search
  filter narrows both columns at once. Empty states explain *why* the
  list is empty (no recipes in system, all already assigned, no match
  for the filter).
- Rationale: the old dropdown silently hid every option once a recipe
  was assigned, leaving the user unable to tell whether the system
  was broken or simply out of unassigned recipes.

Apostrophe regressions
- /admin/stations alert/errorMsg literals reworded with double-quoted
  outer JS strings ("Errore nella eliminazione" / "...assegnazione").
- /admin/users toggle confirm modal: x-text expression contained
  '{{ _('… l\'utente') }}'. Inside a Jinja-rendered HTML attribute,
  the apostrophe in "l'utente" closed the JS literal early, killing
  the binding. Fixed by using &quot; as the JS string delimiter so
  the inner apostrophe is harmless.

Alpine x-if templates can't host nested templates
- Replaced two nested-template empty-state blocks with x-text bound
  to computed getters (unassignedEmptyMessage,
  assignedEmptyMessage). Alpine errored with
  "Cannot set properties of null (setting '_x_dataStack')" when the
  outer template's child wasn't a single root element.

Test guard widened
- src/frontend/flask_app/tests/test_template_js_syntax.py now also
  parses every Alpine attribute (x-*, @*, :*) on the rendered HTML
  and runs `node --check` on each expression wrapped in `void (…)`.
  Previously it only inspected inline <script> bodies, which is why
  the x-text bug on /admin/users slipped through. Verified the
  extended test catches the original l'utente regression by reverting
  + running + restoring.

Layout regression — UPLOAD_DIR defaulted to server/uploads
- The previous .env.example shipped UPLOAD_DIR=server/uploads, which
  matched the V1.x layout but pointed outside the new project tree.
  Updated to UPLOAD_DIR=uploads so files land in the project-root
  uploads/ volume that src/backend/config.py.upload_path resolves.
- Added uploads/general/ to .gitignore (per-user uploads, not source).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 14:49:23 +02:00
Adriano 6e284b0c0c fix(admin): apostrophe-in-translation broke /admin/stations Alpine bindings
Two error messages on /admin/stations rendered Italian translations
that contain an apostrophe inside a single-quoted JS literal:

  alert(result.detail || '{{ _("Errore nell\'eliminazione") }}');

Jinja outputs the apostrophe verbatim, so the JS string closed
prematurely:

  alert(result.detail || 'Errore nell'eliminazione');

That syntax error blew up the entire <script> block, which means
stationManagement() was never defined and EVERY Alpine binding on the
page failed silently. Symptom: clicking "Nuova Stazione" did nothing,
DevTools showed "Alpine Expression Error: openCreateModal is not
defined".

Fix: swap outer JS quotes to double quotes and reword the IT string so
the apostrophe disappears anyway ("Errore nella eliminazione",
"Errore nella assegnazione"). Same for the assignment-error path.

Regression guard: src/frontend/flask_app/tests/test_template_js_syntax.py
renders /admin/stations and /admin/users with the IT locale forced,
extracts every inline <script>, and runs `node --check` on each. The
test is skipped if `node` is not on PATH so CI without Node still
passes. Verified the test catches the original bug (revert + run +
fail) before re-applying the fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 12:32:32 +02:00
Adriano 742cc1fb58 fix(v2): two regressions surfaced by the local smoke test
Both came from the src/ restructure and only show up at runtime, so
the test suite had not caught them.

- src/backend/config.py: env_file was "../../.env", which pydantic-
  settings resolves against the *cwd*, not the file. Running uvicorn
  or alembic from the project root therefore looked for
  ../../.env one level above the repo and silently fell back to the
  default DB_PASSWORD ("change_me_in_production"), hiding the real
  password. Now resolved as Path(__file__).resolve().parents[2] /
  ".env" so the lookup is always against the project root regardless
  of cwd.

- src/backend/models/orm/__init__.py: Station and
  StationRecipeAssignment were never imported here, so anything that
  triggers Base.metadata.create_all without first importing the
  setup router (which has its own Station import) ended up with no
  stations / station_recipe_assignments tables. Verified locally:
  /api/setup/seed used to fail with "Table tiemeasureflow.stations
  doesn't exist" before this fix.

- .gitignore: ignore src/frontend/flask_app/package.json and
  package-lock.json (local npm-install artifacts; the Dockerfile
  installs tailwindcss directly).

Smoke verified end-to-end: uvicorn + gunicorn + MySQL, login + admin
stations + select_recipe + admin users all 200 OK.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 12:19:36 +02:00
Adriano 563b7789f4 docs(readme): rewrite for V2.0.0 — uv, src/ layout, stations, docs index
Brings the entry-point README in line with the V2.0.0 restructure:

- Replace all server/ + client/ paths with src/backend/ +
  src/frontend/flask_app/.
- Replace pip install -r requirements.txt with the uv workflow
  (uv sync --extra server --extra client --extra dev).
- Manual setup section uses uv run uvicorn / uv run flask /
  uv run alembic / uv run pybabel, all driven from the project root.
- Document the V2.0.0 additions: STATION_CODE per-tablet, /admin/
  stations GUI, gunicorn 5x4 + uvicorn 4 worker scaling, X-Forwarded
  -For-aware rate limiting (RATE_LIMIT_GENERAL default 300).
- Add tooling section (uv, pyproject.toml, uv.lock, .python-version,
  pytest stack).
- Documentation section now points at the new docs/ index plus the
  STATO_PROGETTO + ROADMAP architecture pair as the canonical "what
  is done / what is next" references.
- Variabili d'Ambiente: add STATION_CODE, RATE_LIMIT_LOGIN,
  RATE_LIMIT_GENERAL.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 12:48:51 +02:00
Adriano e4b29c0b2d chore(docs): consolidate documentation, drop dead files
Cleanup
- Remove src/backend/Dockerfile.legacy and
  src/frontend/flask_app/Dockerfile.legacy (history is in git, build
  uses the new uv-based root Dockerfile / Dockerfile.frontend).
- Remove src/frontend/flask_app/verify_i18n.py (had hardcoded paths
  pointing at the old client/ tree).

Group docs/
- New docs/README.md indexes everything in one place.
- New docs/architecture/STATO_PROGETTO.md: snapshot of what works in
  V2.0.0 (inherited V1.0.7 features, rev04 Phase 1 stations,
  worker scaling, src/ restructure, test status, stack, decisions).
- New docs/architecture/ROADMAP.md: what's next — Phases 2-7 of the
  rev04 migration with status, open client decisions (D-0.1 through
  D-0.10), tech debt and time estimates for M1 / M2.
- Move PIANO_IMPLEMENTAZIONE.md (90KB V1.0.0 plan) to
  docs/archive/2026-02-06-piano-implementazione-v1.md (historical).
- Move Schema sviluppo SW TieFlow_rev04-2026.docx to docs/specs/
  with ISO date filename so the customer spec is now tracked.
- Move src/frontend/flask_app/I18N_SETUP.md to docs/I18N_SETUP.md
  and rewrite paths to the new src/frontend/flask_app/ tree.

.dockerignore: simplified now that legacy Dockerfiles are gone;
docs/ stays excluded from the build context.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 12:43:57 +02:00
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
184 changed files with 3853 additions and 644 deletions
+38 -10
View File
@@ -1,15 +1,43 @@
__pycache__ # Build context exclusions: keep image small and rebuilds fast.
*.pyc
*.pyo # VCS
.pytest_cache
.git .git
.gitignore .gitignore
.env .gitattributes
*.md
node_modules # Local envs and caches
.venv .venv
venv venv
*.egg-info .env
__pycache__/
*.pyc
*.pyo
*.pyd
.pytest_cache/
.coverage .coverage
htmlcov htmlcov/
.mypy_cache .mypy_cache/
*.egg-info
# IDE / editor
.vscode/
.idea/
*.swp
*~
# Node
node_modules/
# Local-only notes
docs/
*.docx
# Uploads are a runtime volume, never baked in.
uploads/
# Claude Code
.claude/
.omc/
# Competitor analysis (local only)
Concorrente/
+4 -1
View File
@@ -26,7 +26,10 @@ API_SERVER_URL=http://localhost:8000
STATION_CODE=ST-DEFAULT STATION_CODE=ST-DEFAULT
# --- File Storage --- # --- File Storage ---
UPLOAD_DIR=server/uploads # Resolved against the project root in src/backend/config.py.
# Default "uploads" maps to <project_root>/uploads, mounted as a Docker
# volume in production.
UPLOAD_DIR=uploads
MAX_UPLOAD_SIZE_MB=50 MAX_UPLOAD_SIZE_MB=50
# --- Setup Page --- # --- Setup Page ---
+15 -11
View File
@@ -33,21 +33,24 @@ env/
Thumbs.db Thumbs.db
desktop.ini desktop.ini
# Uploads (server-side files) # Uploads (server-side files, now at project root)
server/uploads/images/* uploads/images/*
server/uploads/pdfs/* uploads/pdfs/*
server/uploads/logos/* uploads/logos/*
server/uploads/reports/* uploads/reports/*
!server/uploads/images/.gitkeep uploads/general/
!server/uploads/pdfs/.gitkeep !uploads/images/.gitkeep
!server/uploads/logos/.gitkeep !uploads/pdfs/.gitkeep
!server/uploads/reports/.gitkeep !uploads/logos/.gitkeep
!uploads/reports/.gitkeep
# TailwindCSS output # TailwindCSS output
client/static/css/tailwind.css src/frontend/flask_app/static/css/tailwind.css
# Node # Node
node_modules/ node_modules/
src/frontend/flask_app/package.json
src/frontend/flask_app/package-lock.json
# Flask-Babel compiled # Flask-Babel compiled
*.mo *.mo
@@ -63,10 +66,11 @@ node_modules/
htmlcov/ htmlcov/
# Debug files # Debug files
client/static/js/fabric-debug.js src/frontend/flask_app/static/js/fabric-debug.js
# Misc # Misc
nul nul
# Competitor analysis (local only) # Competitor analysis (local only)
Concorrente/ Concorrente/
docker-compose.override.yml
+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 ## Panoramica
TieMeasureFlow by Tielogic - Sistema di gestione task per misurazioni con calibro manuale. 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 ## Comandi di Sviluppo
### Setup iniziale
```bash
cp .env.example .env
uv sync --extra server --extra client --extra dev # installa tutto
```
### Avvio servizi (Docker) ### Avvio servizi (Docker)
```bash ```bash
docker compose -f docker-compose.dev.yml up -d # Sviluppo (Nginx, porta 80) 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) ### Avvio manuale (senza Docker)
```bash ```bash
# Server (terminale 1) # Backend (terminale 1)
cd server && uvicorn main:app --reload --host 0.0.0.0 --port 8000 uv run uvicorn src.backend.main:app --reload --host 0.0.0.0 --port 8000
# Client (terminale 2) # Frontend (terminale 2) — gunicorn richiede cwd interna per app:create_app()
cd client && flask run --host 0.0.0.0 --port 5000 cd src/frontend/flask_app && uv run --project ../../.. gunicorn --bind 0.0.0.0:5000 app:create_app()
# TailwindCSS watch (terminale 3) # 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 ### Database & Migrations
```bash ```bash
# alembic.ini è in server/migrations/, serve il flag -c # alembic.ini in src/backend/migrations/, serve il flag -c
cd server && alembic -c migrations/alembic.ini upgrade head # Applica migrazioni uv run alembic -c src/backend/migrations/alembic.ini upgrade head # Applica migrazioni
cd server && alembic -c migrations/alembic.ini revision --autogenerate -m "descrizione" # Genera migrazione uv run alembic -c src/backend/migrations/alembic.ini revision --autogenerate -m "descrizione" # Genera
cd server && alembic -c migrations/alembic.ini downgrade -1 # Rollback ultima uv run alembic -c src/backend/migrations/alembic.ini downgrade -1 # Rollback
docker compose exec server alembic -c migrations/alembic.ini upgrade head # Via Docker 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 ### Test
```bash ```bash
# Server (usa SQLite in-memory via aiosqlite, no MySQL richiesto) # Tutti i test (backend + frontend)
cd server && pytest # Tutti i test uv run pytest
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
# Client # Solo backend (SQLite in-memory via aiosqlite, no MySQL richiesto)
cd client && pytest uv run pytest src/backend/tests/
cd client && pytest tests/test_auth.py 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) ### i18n (Traduzioni)
```bash ```bash
# Estrai stringhe # 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 # 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 # Compila .po → .mo
cd client && pybabel compile -d translations cd src/frontend/flask_app && uv run pybabel compile -d translations
# oppure # oppure
cd client && python compile_translations.py cd src/frontend/flask_app && uv run python compile_translations.py
``` ```
### Setup iniziale ### 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. 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` - **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 - **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` - **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) - **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 - **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 - **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 - **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"`) - **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`) - **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 - **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"` - **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"` - **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`: 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. 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: 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 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 ### 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 UTL/LTL → **fail**
- Fuori UWL/LWL ma dentro UTL/LTL → **warning** - Fuori UWL/LWL ma dentro UTL/LTL → **warning**
- Dentro UWL/LWL → **pass** - Dentro UWL/LWL → **pass**
### SPC (Statistical Process Control) ### 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 ### Autenticazione
1. Login con username/password → server ritorna `api_key` (64 char random) 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 ## 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) - 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()` - WeasyPrint mockato prima di qualsiasi import server: `sys.modules["weasyprint"] = MagicMock()`
- Rate limit buckets resettati tra test (walk middleware stack → clear dicts) - 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}` - Helper: `auth_headers(user)``{"X-API-Key": user.api_key}`
- Client httpx: `AsyncClient` con `ASGITransport(app=app)`, override di `get_db` dependency - 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 - `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 - `logged_in_client` fixture pre-popola session con `api_key`, `user_id`, `language`, `theme` + user dict
- CSRF disabilitato nei test: `WTF_CSRF_ENABLED=False` - 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) - 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` - Server: `SERVER_HOST`, `SERVER_PORT`, `SERVER_SECRET_KEY`, `SERVER_CORS_ORIGINS`
- Client: `CLIENT_HOST`, `CLIENT_PORT`, `CLIENT_SECRET_KEY`, `API_SERVER_URL` - 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` - Docker: `NGINX_PORT`, `NGINX_SSL_PORT`
- Setup: `SETUP_PASSWORD` (vuota = endpoint disabilitato) - Setup: `SETUP_PASSWORD` (vuota = endpoint disabilitato)
- SSL: `SSL_CERTFILE`, `SSL_KEYFILE` - 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()"]
+165 -90
View File
@@ -1,6 +1,9 @@
# TieMeasureFlow by Tielogic # TieMeasureFlow by Tielogic
Sistema di gestione task per misurazioni con calibro manuale. Soluzione tablet-first, multi-ruolo, con statistiche SPC (Statistical Process Control) integrate. Sistema di gestione task per misurazioni con calibro manuale. Soluzione tablet-first, multi-ruolo, con statistiche SPC (Statistical Process Control) integrate, identità per-stazione e rate limiting per-tablet.
> **Versione corrente:** V2.0.0 (in sviluppo) — branch default `V2.0.0`.
> Per stato dettagliato e prossimi passi vedi [`docs/architecture/STATO_PROGETTO.md`](docs/architecture/STATO_PROGETTO.md) e [`docs/architecture/ROADMAP.md`](docs/architecture/ROADMAP.md).
--- ---
@@ -14,8 +17,10 @@ Caratteristiche principali:
- Calcolo pass/fail/warning su quattro limiti di tolleranza (UTL, UWL, LWL, LTL) - Calcolo pass/fail/warning su quattro limiti di tolleranza (UTL, UWL, LWL, LTL)
- SPC: Cp, Cpk, Pp, Ppk, control chart UCL/LCL, istogramma con curva normale — puro stdlib (no numpy) - SPC: Cp, Cpk, Pp, Ppk, control chart UCL/LCL, istogramma con curva normale — puro stdlib (no numpy)
- Annotazioni grafiche su disegni tecnici (Fabric.js) con viewer sincronizzato all'esecuzione - Annotazioni grafiche su disegni tecnici (Fabric.js) con viewer sincronizzato all'esecuzione
- Identità per-stazione (`STATION_CODE`): ogni tablet vede solo le ricette assegnate alla propria stazione, gestione assegnazioni via GUI admin
- Interfaccia completamente localizzata IT/EN, dark mode, ottimizzata per tablet touch - Interfaccia completamente localizzata IT/EN, dark mode, ottimizzata per tablet touch
- Autenticazione API Key, rate limiting sliding window, audit log persistente - Autenticazione API Key, rate limiting sliding window per-IP reale (X-Forwarded-For-aware), audit log persistente
- Capacità testata per ~20 tablet contemporanei (gunicorn 5 workers × 4 thread + uvicorn 4 workers async)
--- ---
@@ -26,25 +31,25 @@ Browser/Tablet
| |
Reverse Proxy (Nginx — sviluppo | Traefik+SSL — produzione) Reverse Proxy (Nginx — sviluppo | Traefik+SSL — produzione)
| |
Flask Client :5000 (rendering server-side, Jinja2 + Alpine.js) Flask Frontend :5000 (rendering server-side, Jinja2 + Alpine.js)
| X-API-Key | X-API-Key + X-Forwarded-For
FastAPI Server :8000 (API REST asincrona) FastAPI Backend :8000 (API REST asincrona)
| |
MySQL 8.0 MySQL 8.0
``` ```
Il client Flask non espone mai le credenziali al browser: ogni chiamata al backend avviene server-side con l'header `X-API-Key` estratto dalla sessione Flask. Il frontend Flask non espone mai le credenziali al browser: ogni chiamata al backend avviene server-side con l'header `X-API-Key` estratto dalla sessione Flask. L'IP reale del tablet è propagato in `X-Forwarded-For` per il rate limiter.
--- ---
## Stack Tecnologico ## Stack Tecnologico
### Backend (server/) ### Backend (`src/backend/`)
| Componente | Versione | Ruolo | | Componente | Versione | Ruolo |
|---|---|---| |---|---|---|
| FastAPI | ultima stabile | Framework API REST asincrono | | FastAPI | ultima stabile | Framework API REST asincrono |
| SQLAlchemy 2.0 | async | ORM con pool connessioni | | SQLAlchemy 2.0 | async | ORM con pool connessioni 10+20 |
| asyncmy | ultima stabile | Driver MySQL asincrono | | asyncmy | ultima stabile | Driver MySQL asincrono |
| MySQL | 8.0 | Database relazionale | | MySQL | 8.0 | Database relazionale |
| Alembic | ultima stabile | Migrazioni schema | | Alembic | ultima stabile | Migrazioni schema |
@@ -54,35 +59,47 @@ Il client Flask non espone mai le credenziali al browser: ogni chiamata al backe
| bcrypt | ultima stabile | Hashing password | | bcrypt | ultima stabile | Hashing password |
| Pillow | ultima stabile | Thumbnail automatici upload | | Pillow | ultima stabile | Thumbnail automatici upload |
### Frontend (client/) ### Frontend (`src/frontend/flask_app/`)
| Componente | Versione | Ruolo | | Componente | Versione | Ruolo |
|---|---|---| |---|---|---|
| Flask | 3.x | Framework web server-side | | Flask | 3.x | Framework web server-side |
| gunicorn | 21+ | WSGI server (5 workers × 4 thread gthread) |
| Jinja2 | incluso in Flask | Template engine | | Jinja2 | incluso in Flask | Template engine |
| Alpine.js | 3.x (CDN) | Reattivita leggera lato client | | Alpine.js | 3.x (CDN) | Reattività leggera lato client |
| TailwindCSS | 3.x | CSS utility-first | | TailwindCSS | 3.x | CSS utility-first |
| Plotly.js | CDN | Grafici SPC interattivi | | Plotly.js | CDN | Grafici SPC interattivi |
| Fabric.js | 5.3.1 (CDN) | Editor annotazioni disegni tecnici | | Fabric.js | 5.3.1 (CDN) | Editor annotazioni disegni tecnici |
| html5-qrcode | CDN | Scanner barcode/QR camera |
| Flask-Babel | ultima stabile | i18n IT/EN | | Flask-Babel | ultima stabile | i18n IT/EN |
### Tooling
| Componente | Ruolo |
|---|---|
| **uv** | Package manager Python (no `requirements.txt`) |
| `pyproject.toml` | Dipendenze monorepo con extra `server`/`client`/`dev` |
| `uv.lock` | Lockfile per build riproducibili |
| `.python-version` | Pin Python 3.11 |
| pytest + pytest-asyncio + httpx + aiosqlite | Test stack |
--- ---
## Quick Start con Docker ## Quick Start con Docker
Docker Compose è il metodo raccomandato. Gestisce database, migrazioni e configurazione Nginx in un solo comando. Docker Compose è il metodo raccomandato. Gestisce database, migrazioni, build delle immagini con `uv` e configurazione Nginx in un solo comando.
```bash ```bash
# 1. Clona il repository # 1. Clona il repository
git clone <repository-url> git clone ssh://git@git.tielogic.xyz:222/Adriano/TieMeasureFlow.git
cd TieMeasureFlow cd TieMeasureFlow
# 2. Configura le variabili d'ambiente # 2. Configura le variabili d'ambiente
cp .env.example .env cp .env.example .env
# Modifica .env: credenziali DB, chiavi segrete, SETUP_PASSWORD # Modifica .env: credenziali DB, chiavi segrete, SETUP_PASSWORD, STATION_CODE per ogni tablet
# 3. Avvia i servizi (ambiente di sviluppo) # 3. Avvia i servizi (ambiente di sviluppo)
docker compose -f docker-compose.dev.yml up -d docker compose -f docker-compose.dev.yml up -d --build
# 4. Verifica lo stato dei container # 4. Verifica lo stato dei container
docker compose -f docker-compose.dev.yml ps docker compose -f docker-compose.dev.yml ps
@@ -90,6 +107,7 @@ docker compose -f docker-compose.dev.yml ps
# 5. Setup iniziale (solo al primo avvio) # 5. Setup iniziale (solo al primo avvio)
# Apri http://localhost/api/setup nel browser # Apri http://localhost/api/setup nel browser
# Usa SETUP_PASSWORD configurata in .env # Usa SETUP_PASSWORD configurata in .env
# Lo script seed crea anche la stazione ST-DEFAULT con tutte le ricette assegnate
``` ```
L'applicazione sarà disponibile su: L'applicazione sarà disponibile su:
@@ -97,8 +115,9 @@ L'applicazione sarà disponibile su:
- Frontend: http://localhost - Frontend: http://localhost
- API: http://localhost/api - API: http://localhost/api
- Pagina setup: http://localhost/api/setup - Pagina setup: http://localhost/api/setup
- Admin stazioni: http://localhost/admin/stations (solo `is_admin`)
Per il deployment in produzione (Traefik + SSL) consulta [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md). Per il deployment in produzione (Traefik + SSL) consulta [`docs/DEPLOYMENT.md`](docs/DEPLOYMENT.md).
--- ---
@@ -108,12 +127,14 @@ Dopo il primo avvio, la pagina `/api/setup` (protetta da `SETUP_PASSWORD`) perme
- **Initialize Database** — crea tutte le tabelle - **Initialize Database** — crea tutte le tabelle
- **Create Admin User** — crea l'utente amministratore con credenziali da `.env` - **Create Admin User** — crea l'utente amministratore con credenziali da `.env`
- **Seed Demo Data** — carica ricette, misurazioni e utenti di esempio - **Seed Demo Data** — carica ricette, misurazioni e utenti di esempio + crea stazione `ST-DEFAULT` con tutte le ricette assegnate
- **Reset Database** — elimina e ricrea tutte le tabelle (attenzione: cancella tutti i dati) - **Reset Database** — elimina e ricrea tutte le tabelle (attenzione: cancella tutti i dati)
- **Gestione utenti** — crea, modifica, attiva/disattiva account dalla stessa pagina - **Gestione utenti** — crea, modifica, attiva/disattiva account dalla stessa pagina
Se `SETUP_PASSWORD` è vuota o assente nel `.env`, l'endpoint è disabilitato. Se `SETUP_PASSWORD` è vuota o assente nel `.env`, l'endpoint è disabilitato.
Per gestire stazioni e assegnazioni ricette dopo il setup: `/admin/stations` (richiede login admin).
--- ---
## Setup Manuale (Senza Docker) ## Setup Manuale (Senza Docker)
@@ -121,8 +142,9 @@ Se `SETUP_PASSWORD` è vuota o assente nel `.env`, l'endpoint è disabilitato.
### Requisiti ### Requisiti
- Python 3.11 o superiore - Python 3.11 o superiore
- Node.js 18 o superiore - Node.js 18 o superiore (per TailwindCSS)
- MySQL 8.0 - MySQL 8.0
- [uv](https://docs.astral.sh/uv/) installato
### 1. Database MySQL ### 1. Database MySQL
@@ -139,35 +161,37 @@ SQL
```bash ```bash
cp .env.example .env cp .env.example .env
# Imposta DB_HOST, DB_USER, DB_PASSWORD, DB_NAME, SERVER_SECRET_KEY, CLIENT_SECRET_KEY, SETUP_PASSWORD # Imposta DB_HOST, DB_USER, DB_PASSWORD, DB_NAME, SERVER_SECRET_KEY,
# CLIENT_SECRET_KEY, SETUP_PASSWORD, STATION_CODE
``` ```
### 3. Server FastAPI ### 3. Installa dipendenze (uv)
```bash ```bash
cd server uv sync --extra server --extra client --extra dev
python -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
pip install -r requirements.txt
alembic -c migrations/alembic.ini upgrade head
uvicorn main:app --reload --host 0.0.0.0 --port 8000
``` ```
### 4. Client Flask ### 4. Backend FastAPI
```bash ```bash
cd client uv run alembic -c src/backend/migrations/alembic.ini upgrade head
python -m venv venv uv run uvicorn src.backend.main:app --reload --host 0.0.0.0 --port 8000
source venv/bin/activate # Windows: venv\Scripts\activate
pip install -r requirements.txt
pybabel compile -d translations
flask run --host 0.0.0.0 --port 5000
``` ```
### 5. TailwindCSS (watch per sviluppo) ### 5. Frontend Flask
```bash ```bash
cd client # Compila i cataloghi i18n una volta
cd src/frontend/flask_app && uv run --project ../../.. pybabel compile -d translations && cd -
# Avvia (development)
cd src/frontend/flask_app && uv run --project ../../.. flask run --host 0.0.0.0 --port 5000
```
### 6. TailwindCSS (watch per sviluppo)
```bash
cd src/frontend/flask_app
npx tailwindcss -i static/css/input.css -o static/css/tailwind.css --watch npx tailwindcss -i static/css/input.css -o static/css/tailwind.css --watch
``` ```
@@ -185,9 +209,9 @@ I ruoli sono combinabili (array JSON per utente). Il flag `is_admin` è separato
| Ruolo | Descrizione | | Ruolo | Descrizione |
|---|---| |---|---|
| **Maker** | Crea e gestisce ricette di misurazione: caricamento disegni (PDF/immagini), annotazioni Fabric.js, definizione task/subtask, configurazione tolleranze, versioning copy-on-write | | **Maker** | Crea e gestisce ricette di misurazione: caricamento disegni (PDF/immagini), annotazioni Fabric.js, definizione task/subtask, configurazione tolleranze, versioning copy-on-write |
| **MeasurementTec** | Esegue misurazioni: scansione barcode per selezione ricetta, interfaccia task-driven, input da calibro USB HID o numpad touch, validazione real-time pass/warning/fail | | **MeasurementTec** | Esegue misurazioni: scansione barcode per selezione ricetta, interfaccia task-driven, input da calibro USB HID o numpad touch, validazione real-time pass/warning/fail. Vede solo le ricette assegnate alla propria stazione (`STATION_CODE`) |
| **Metrologist** | Analisi qualità: dashboard SPC (X-bar, R, Cp, Cpk, Pp, Ppk), filtri multi-dimensionali, export report PDF, analisi capability e control chart | | **Metrologist** | Analisi qualità: dashboard SPC (X-bar, R, Cp, Cpk, Pp, Ppk), filtri multi-dimensionali, export report PDF, analisi capability e control chart |
| **Admin** (flag) | Gestione sistema: CRUD utenti, cambio password, attivazione/disattivazione account | | **Admin** (flag) | Gestione sistema: CRUD utenti, cambio password, attivazione/disattivazione account, **CRUD stazioni e assegnazioni ricette** |
--- ---
@@ -195,39 +219,53 @@ I ruoli sono combinabili (array JSON per utente). Il flag `is_admin` è separato
``` ```
TieMeasureFlow/ TieMeasureFlow/
├── server/ # FastAPI Backend ├── pyproject.toml # Dipendenze monorepo (uv)
│ ├── main.py # Entry point, lifespan, middleware, 10 router ├── uv.lock # Lockfile riproducibile
├── .python-version # 3.11
├── Dockerfile # Backend (uv + uvicorn)
├── Dockerfile.frontend # Frontend (uv + gunicorn + Tailwind + Babel)
├── docker-compose.dev.yml # Sviluppo (Nginx, porta 80)
├── docker-compose.yml # Produzione (Traefik, SSL)
├── nginx/ # Config Nginx (dev)
├── uploads/ # Volume Docker file caricati
├── docs/ # Documentazione (vedi indice docs/README.md)
└── src/
├── backend/ # FastAPI Backend
│ ├── main.py # Entry point, lifespan, middleware, 11 router
│ ├── config.py # Settings (pydantic_settings.BaseSettings) │ ├── config.py # Settings (pydantic_settings.BaseSettings)
│ ├── database.py # SQLAlchemy 2.0 async engine │ ├── database.py # SQLAlchemy 2.0 async engine
│ ├── models/ # ORM: User, Recipe, RecipeVersion, RecipeTask, │ ├── api/
│ │ # RecipeSubtask, Measurement, AccessLog, ├── routers/ # auth, users, recipes, tasks, measurements,
│ │ # SystemSetting, RecipeVersionAudit │ │ # files, settings, statistics, reports,
│ ├── schemas/ # Pydantic v2 per validazione I/O API │ │ │ # setup, stations
│ ├── routers/ # auth, users, recipes, tasks, measurements, │ │ └── middleware/ # api_key, rate_limit, security_headers, logging
│ │ # files, settings, statistics, reports, setup │ ├── models/
│ │ ├── orm/ # SQLAlchemy: User, Recipe, RecipeVersion,
│ │ │ # RecipeTask, RecipeSubtask, Measurement,
│ │ │ # AccessLog, SystemSetting,
│ │ │ # RecipeVersionAudit, Station,
│ │ │ # StationRecipeAssignment
│ │ └── api/ # Pydantic v2 schemas request/response
│ ├── services/ # recipe_service, measurement_service, │ ├── services/ # recipe_service, measurement_service,
# spc_service, report_service, auth_service # spc_service, report_service,
│ ├── middleware/ # api_key, rate_limit, security_headers, logging │ │ # auth_service, station_service
│ ├── migrations/ # Alembic (alembic.ini + env.py) │ ├── migrations/ # Alembic (alembic.ini + env.py)
── templates/ # Template HTML pagina setup ── templates/ # Pagina setup (Jinja2)
├── client/ # Flask Frontend │ └── tests/ # pytest + httpx + aiosqlite
── app.py # Factory pattern, CSRF, Babel ── frontend/
── blueprints/ # auth, maker, measure, statistics, admin ── flask_app/ # Flask Frontend
│ ├── services/ # APIClient singleton (proxy verso FastAPI) ├── app.py # Factory + ProxyFix + CSRF + Babel
│ ├── templates/ # Jinja2 + Alpine.js ├── config.py # STATION_CODE, API_SERVER_URL, ecc.
├── static/ ├── compile_translations.py
│ │ ├── css/ # TailwindCSS compilato ├── blueprints/ # auth, maker, measure, statistics, admin
│ │ └── js/ # numpad, caliper, barcode, csv-export, ├── services/ # APIClient (proxy verso FastAPI con XFF)
│ │ # spc-charts, annotation-editor/viewer ├── templates/ # Jinja2 + Alpine.js
│ └── translations/ # Flask-Babel .po/.mo IT/EN ├── static/
├── nginx/ # Configurazione Nginx (dev) ├── css/ # TailwindCSS compilato
├── docs/ # Documentazione tecnica │ └── js/ # numpad, caliper, barcode, csv-export,
│ ├── API.md # Riferimento API REST │ # spc-charts, annotation-editor/viewer
│ ├── DEPLOYMENT.md # Guida deployment VPS (Traefik, SSL, DNS) ├── translations/ # Flask-Babel .po/.mo IT/EN
│ └── USER_GUIDE.md # Manuale utente per ruolo └── tests/
├── docker-compose.yml # Produzione (Traefik, SSL)
├── docker-compose.dev.yml # Sviluppo (Nginx, porta 80)
└── .env.example # Template variabili d'ambiente
``` ```
--- ---
@@ -238,7 +276,7 @@ TieMeasureFlow/
| Comando | Descrizione | | Comando | Descrizione |
|---|---| |---|---|
| `docker compose -f docker-compose.dev.yml up -d` | Avvia servizi in sviluppo | | `docker compose -f docker-compose.dev.yml up -d --build` | Avvia servizi in sviluppo (build incluso) |
| `docker compose -f docker-compose.dev.yml down` | Ferma e rimuove i container | | `docker compose -f docker-compose.dev.yml down` | Ferma e rimuove i container |
| `docker compose logs -f server` | Segui log server in tempo reale | | `docker compose logs -f server` | Segui log server in tempo reale |
| `docker compose logs -f client` | Segui log client in tempo reale | | `docker compose logs -f client` | Segui log client in tempo reale |
@@ -248,47 +286,62 @@ TieMeasureFlow/
### Alembic (migrations) ### Alembic (migrations)
Nota: `alembic.ini` si trova dentro `server/migrations/`, è richiesto il flag `-c`. `alembic.ini` si trova in `src/backend/migrations/`, è richiesto il flag `-c`. `env.py` aggiunge la project root a `sys.path` per risolvere `src.backend.*`.
```bash ```bash
cd server uv run alembic -c src/backend/migrations/alembic.ini upgrade head # Applica migrazioni
alembic -c migrations/alembic.ini upgrade head # Applica migrazioni uv run alembic -c src/backend/migrations/alembic.ini revision --autogenerate -m "descrizione" # Genera
alembic -c migrations/alembic.ini revision --autogenerate -m "descrizione" # Genera migrazione uv run alembic -c src/backend/migrations/alembic.ini downgrade -1 # Rollback ultima
alembic -c migrations/alembic.ini downgrade -1 # Rollback ultima
``` ```
Via Docker: Via Docker:
```bash ```bash
docker compose exec server alembic -c migrations/alembic.ini upgrade head docker compose exec server uv run alembic -c src/backend/migrations/alembic.ini upgrade head
``` ```
### i18n (Traduzioni) ### i18n (Traduzioni)
```bash ```bash
cd client cd src/frontend/flask_app
pybabel extract -F babel.cfg -k _ -o translations/messages.pot . # Estrai stringhe uv run --project ../../.. pybabel extract -F babel.cfg -k _ -o translations/messages.pot . # Estrai
pybabel update -i translations/messages.pot -d translations # Aggiorna catalogo uv run --project ../../.. pybabel update -i translations/messages.pot -d translations # Aggiorna
pybabel compile -d translations # Compila .po → .mo uv run --project ../../.. pybabel compile -d translations # Compila .po → .mo
```
### 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
``` ```
--- ---
## Testing ## Testing
Il server usa SQLite in-memory tramite `aiosqlite`: i test girano senza MySQL installato. Il backend usa SQLite in-memory tramite `aiosqlite`: i test girano senza MySQL installato.
```bash ```bash
# Server # Tutti i test (backend + frontend)
cd server && pytest # Tutti i test uv run pytest
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
# Client # Solo backend
cd client && pytest 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/
``` ```
Stato corrente: **171 pass, 4 fail pre-esistenti** (vedi `docs/architecture/STATO_PROGETTO.md`).
--- ---
## Variabili d'Ambiente ## Variabili d'Ambiente
@@ -304,8 +357,11 @@ Copia `.env.example` in `.env` e configura:
| `SERVER_CORS_ORIGINS` | Origini CORS ammesse | | `SERVER_CORS_ORIGINS` | Origini CORS ammesse |
| `CLIENT_SECRET_KEY` | Chiave segreta Flask (sessioni, CSRF) | | `CLIENT_SECRET_KEY` | Chiave segreta Flask (sessioni, CSRF) |
| `API_SERVER_URL` | URL del backend visto dal client (es. `http://server:8000`) | | `API_SERVER_URL` | URL del backend visto dal client (es. `http://server:8000`) |
| `UPLOAD_DIR` | Percorso upload file (default: `uploads`, relativo a `server/`) | | `STATION_CODE` | **Per-tablet** — codice stazione (es. `ST-001`). Senza, il client mostra errore configurazione. |
| `MAX_UPLOAD_SIZE_MB` | Limite dimensione upload | | `UPLOAD_DIR` | Percorso upload file (default: `uploads`, project root) |
| `MAX_UPLOAD_SIZE_MB` | Limite dimensione upload (default 50) |
| `RATE_LIMIT_LOGIN` | Login req/min/IP (default 5) |
| `RATE_LIMIT_GENERAL` | Richieste req/min/IP (default 300, per-tablet) |
| `NGINX_PORT`, `NGINX_SSL_PORT` | Porte Nginx (solo compose dev) | | `NGINX_PORT`, `NGINX_SSL_PORT` | Porte Nginx (solo compose dev) |
| `SETUP_PASSWORD` | Password pagina setup (vuota = endpoint disabilitato) | | `SETUP_PASSWORD` | Password pagina setup (vuota = endpoint disabilitato) |
| `SSL_CERTFILE`, `SSL_KEYFILE` | Certificato SSL (solo setup manuale) | | `SSL_CERTFILE`, `SSL_KEYFILE` | Certificato SSL (solo setup manuale) |
@@ -314,16 +370,35 @@ Copia `.env.example` in `.env` e configura:
## Documentazione ## Documentazione
Indice completo: [`docs/README.md`](docs/README.md).
### Stato e direzione
| Documento | Contenuto | | Documento | Contenuto |
|---|---| |---|---|
| [docs/API.md](docs/API.md) | Riferimento completo API REST (endpoint, parametri, schemi) | | [`docs/architecture/STATO_PROGETTO.md`](docs/architecture/STATO_PROGETTO.md) | Snapshot V2.0.0: cosa funziona oggi, test status, decisioni architetturali |
| [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md) | Guida deployment VPS: Docker, Traefik, SSL, DNS, firewall | | [`docs/architecture/ROADMAP.md`](docs/architecture/ROADMAP.md) | Cosa resta da fare (Fasi 2-7 rev04, decisioni cliente aperte, stime) |
| [docs/USER_GUIDE.md](docs/USER_GUIDE.md) | Manuale utente per ruolo (Maker, MeasurementTec, Metrologist) |
### Riferimenti operativi
| Documento | Contenuto |
|---|---|
| [`docs/API.md`](docs/API.md) | Riferimento completo API REST (endpoint, parametri, schemi) |
| [`docs/DEPLOYMENT.md`](docs/DEPLOYMENT.md) | Guida deployment VPS: Docker, Traefik, SSL, DNS, firewall |
| [`docs/USER_GUIDE.md`](docs/USER_GUIDE.md) | Manuale utente per ruolo (Maker, MeasurementTec, Metrologist) |
| [`docs/I18N_SETUP.md`](docs/I18N_SETUP.md) | Setup e workflow traduzioni (Flask-Babel + Alpine.js) |
### Piani dettagliati
| Documento | Contenuto |
|---|---|
| [`docs/superpowers/plans/2026-04-17-rev04-master-roadmap.md`](docs/superpowers/plans/2026-04-17-rev04-master-roadmap.md) | Master plan rev04 (M1 + M2, decisioni aperte, stime) |
| [`docs/superpowers/plans/2026-04-17-rev04-phase1-stations.md`](docs/superpowers/plans/2026-04-17-rev04-phase1-stations.md) | Piano TDD Fase 1 stazioni (completato) |
--- ---
## Licenza ## Licenza
Proprietary - Tielogic. All rights reserved. Proprietary Tielogic. All rights reserved.
Questo software è di proprietà esclusiva di Tielogic ed è protetto dalle leggi sul copyright. Non è consentita la distribuzione, modifica o utilizzo senza autorizzazione scritta. Questo software è di proprietà esclusiva di Tielogic ed è protetto dalle leggi sul copyright. Non è consentita la distribuzione, modifica o utilizzo senza autorizzazione scritta.
-25
View File
@@ -1,25 +0,0 @@
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", "2", "--bind", "0.0.0.0:5000", "app:create_app()"]
-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
-118
View File
@@ -1,118 +0,0 @@
#!/usr/bin/env python
"""Verify i18n setup for TieMeasureFlow."""
import json
from pathlib import Path
def check_file(path: Path, description: str) -> bool:
"""Check if a file exists and report."""
exists = path.exists()
status = "[OK]" if exists else "[FAIL]"
print(f"{status} {description}: {path}")
return exists
def check_json_valid(path: Path) -> bool:
"""Check if JSON file is valid."""
try:
with open(path, 'r', encoding='utf-8') as f:
json.load(f)
return True
except Exception as e:
print(f" ERROR: {e}")
return False
def main():
"""Verify i18n setup."""
print("=== TieMeasureFlow i18n Verification ===\n")
root = Path(__file__).parent
all_good = True
# Check Flask-Babel files
print("Flask-Babel (Server-side):")
files = [
(root / "translations/babel.cfg", "Babel config"),
(root / "translations/it/LC_MESSAGES/messages.po", "Italian .po"),
(root / "translations/it/LC_MESSAGES/messages.mo", "Italian .mo"),
(root / "translations/en/LC_MESSAGES/messages.po", "English .po"),
(root / "translations/en/LC_MESSAGES/messages.mo", "English .mo"),
]
for path, desc in files:
if not check_file(path, desc):
all_good = False
# Count messages in .po files
if (root / "translations/it/LC_MESSAGES/messages.po").exists():
with open(root / "translations/it/LC_MESSAGES/messages.po", 'r', encoding='utf-8') as f:
content = f.read()
msgid_count = content.count('msgid "') - 1 # Exclude header
print(f" Italian: {msgid_count} messages")
if (root / "translations/en/LC_MESSAGES/messages.po").exists():
with open(root / "translations/en/LC_MESSAGES/messages.po", 'r', encoding='utf-8') as f:
content = f.read()
msgid_count = content.count('msgid "') - 1
print(f" English: {msgid_count} messages")
# Check Alpine.js i18n files
print("\nAlpine.js i18n (Client-side):")
json_files = [
(root / "static/js/locales/it.json", "Italian locale"),
(root / "static/js/locales/en.json", "English locale"),
]
for path, desc in json_files:
if check_file(path, desc):
if not check_json_valid(path):
all_good = False
else:
with open(path, 'r', encoding='utf-8') as f:
data = json.load(f)
keys = count_keys(data)
print(f" {keys} total keys")
# Check app.py integration
print("\nApp Integration:")
app_py = root / "app.py"
if check_file(app_py, "app.py"):
with open(app_py, 'r', encoding='utf-8') as f:
content = f.read()
checks = [
("from flask_babel import Babel", "Flask-Babel imported"),
("def get_locale()", "get_locale() function"),
("Babel(app, locale_selector=get_locale)", "Babel initialized"),
("def set_language(lang)", "set_language endpoint"),
]
for check_str, check_desc in checks:
found = check_str in content
status = "[OK]" if found else "[FAIL]"
print(f" {status} {check_desc}")
if not found:
all_good = False
# Summary
print("\n" + "="*40)
if all_good:
print("[OK] i18n setup verified successfully!")
print("\nNext steps:")
print("1. Use _() in Python code and templates")
print("2. Use $t() in Alpine.js components")
print("3. Test language switching: /set-language/en or /set-language/it")
else:
print("[FAIL] Some checks failed - review errors above")
return 1
return 0
def count_keys(obj, depth=0):
"""Recursively count keys in nested dict."""
if not isinstance(obj, dict):
return 0
count = len(obj)
for value in obj.values():
if isinstance(value, dict):
count += count_keys(value, depth + 1)
return count
if __name__ == '__main__':
exit(main())
+3 -3
View File
@@ -24,7 +24,7 @@ services:
server: server:
build: build:
context: ./server context: .
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: tmflow-server container_name: tmflow-server
restart: unless-stopped restart: unless-stopped
@@ -43,8 +43,8 @@ services:
client: client:
build: build:
context: ./client context: .
dockerfile: Dockerfile dockerfile: Dockerfile.frontend
container_name: tmflow-client container_name: tmflow-client
restart: unless-stopped restart: unless-stopped
env_file: env_file:
+3 -3
View File
@@ -24,7 +24,7 @@ services:
server: server:
build: build:
context: ./server context: .
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: tmflow-server container_name: tmflow-server
restart: unless-stopped restart: unless-stopped
@@ -52,8 +52,8 @@ services:
client: client:
build: build:
context: ./client context: .
dockerfile: Dockerfile dockerfile: Dockerfile.frontend
container_name: tmflow-client container_name: tmflow-client
restart: unless-stopped restart: unless-stopped
env_file: env_file:
+184
View File
@@ -23,6 +23,7 @@ TieMeasureFlow provides a REST API built with FastAPI for managing measurement t
- [Measurements](#measurements) - [Measurements](#measurements)
- [Files](#files) - [Files](#files)
- [Settings](#settings) - [Settings](#settings)
- [Stations](#stations)
- [Statistics](#statistics) - [Statistics](#statistics)
- [Reports](#reports) - [Reports](#reports)
7. [Pagination](#pagination) 7. [Pagination](#pagination)
@@ -1101,6 +1102,189 @@ Content-Type: multipart/form-data
--- ---
### Stations
Stations model the physical measurement posts on the shop floor. Each Flask client identifies itself through `STATION_CODE`, and the operator only sees recipes assigned to that station. See the user guide section "Admin Workflow → Station Management" for the operational model.
#### GET `/stations`
List stations. **Admin only.**
Query parameters:
- `active_only` (bool, default `false`): if `true`, return only stations where `active = true`.
Response `200`:
```json
[
{
"id": 1,
"code": "ST-DEFAULT",
"name": "Default Station",
"location": "Initial seed - change me",
"notes": null,
"active": true,
"created_by": 5,
"created_at": "2026-04-26T10:12:06"
}
]
```
Errors:
- 401: Missing or invalid API key
- 403: Admin role required
---
#### POST `/stations`
Create a station. **Admin only.**
Request body:
```json
{
"code": "ST-LINEA-A",
"name": "Linea A — Tornitura alberi",
"location": "Reparto 2 — Cella 3",
"notes": "Provisioned 2026-04-26 by Adriano",
"active": true
}
```
`code` (1-100 chars) and `name` (1-255 chars) are required. `location` is optional (≤ 255 chars). `active` defaults to `true`.
Response `201`: same shape as `GET /stations` items.
Errors:
- 400: Invalid payload (missing `code`/`name`, exceeded length)
- 403: Admin role required
- 409: Station with that code already exists
---
#### GET `/stations/{station_id}`
Get a single station by id. **Admin only.**
Response `200`: same as the list item shape.
Errors:
- 403: Admin role required
- 404: Station not found
---
#### PUT `/stations/{station_id}`
Update a station's editable fields. **Admin only.**
Request body (all fields optional):
```json
{
"name": "Linea A — riconfigurata",
"location": "Reparto 2 — Cella 4",
"notes": "Moved cell on 2026-05-12",
"active": false
}
```
Note: the `code` field is not in the schema. Codes are immutable on purpose (changing the code would orphan every tablet pointing at it).
Response `200`: the updated station.
Errors:
- 400: Invalid payload
- 403: Admin role required
- 404: Station not found
---
#### DELETE `/stations/{station_id}`
Delete a station. **Admin only.**
The deletion cascades to every row in `station_recipe_assignments` for that station. Existing measurements are NOT affected (measurements link to recipe versions, not stations).
Response `204`: no body.
Errors:
- 403: Admin role required
- 404: Station not found
---
#### GET `/stations/{station_id}/recipes`
Admin view of the recipes currently assigned to a station. Returns the same projection used by the assignment modal in `/admin/stations`. **Admin only.**
Response `200`:
```json
[
{
"id": 2,
"code": "DEMO-001",
"name": "Demo Measurement Recipe",
"active": true
}
]
```
Errors:
- 403: Admin role required
- 404: Station not found
---
#### GET `/stations/by-code/{code}/recipes`
**Operator view** (any authenticated user, no admin requirement). Returns the active recipes assigned to the station whose code matches `{code}`. The Flask client calls this endpoint at every page load of `/measure/select`, passing its own `STATION_CODE`.
Response `200`: same shape as `GET /stations/{station_id}/recipes`.
Errors:
- 401: Missing or invalid API key
- 404: Station not found OR station is not active (operator-facing endpoint deliberately treats both cases as 404 to avoid leaking the existence of disabled stations)
---
#### POST `/stations/{station_id}/recipes`
Assign an existing recipe to a station. **Admin only.**
Request body:
```json
{ "recipe_id": 2 }
```
Response `201`:
```json
{
"id": 1,
"station_id": 1,
"recipe_id": 2,
"assigned_by": 5,
"assigned_at": "2026-04-26T10:12:06"
}
```
Errors:
- 403: Admin role required
- 404: Station or recipe not found
- 409: Recipe already assigned to this station
---
#### DELETE `/stations/{station_id}/recipes/{recipe_id}`
Remove an existing assignment. **Admin only.**
Response `204`: no body.
Errors:
- 403: Admin role required
- 404: Station/recipe not found, or no assignment between them
---
### Statistics ### Statistics
All statistics endpoints **require Metrologist role**. All statistics endpoints **require Metrologist role**.
+4 -3
View File
@@ -134,10 +134,11 @@ SSL_KEYFILE=
| `CLIENT_HOST` | string | 0.0.0.0 | Flask client bind address | | `CLIENT_HOST` | string | 0.0.0.0 | Flask client bind address |
| `CLIENT_PORT` | int | 5000 | Flask client port | | `CLIENT_PORT` | int | 5000 | Flask client port |
| `SERVER_CORS_ORIGINS` | string | http://localhost:5000 | Comma-separated CORS origins | | `SERVER_CORS_ORIGINS` | string | http://localhost:5000 | Comma-separated CORS origins |
| `UPLOAD_DIR` | string | uploads | Directory for file uploads | | `UPLOAD_DIR` | string | uploads | Directory for file uploads (resolved against the project root) |
| `MAX_UPLOAD_SIZE_MB` | int | 50 | Maximum upload file size in MB | | `MAX_UPLOAD_SIZE_MB` | int | 50 | Maximum upload file size in MB |
| `RATE_LIMIT_LOGIN` | int | 5 | Login requests per minute | | `RATE_LIMIT_LOGIN` | int | 5 | Login requests per minute, per real client IP |
| `RATE_LIMIT_GENERAL` | int | 100 | General requests per minute | | `RATE_LIMIT_GENERAL` | int | 300 | General requests per minute, per real client IP (post-V2.0.0; was 100 in V1.0.x) |
| `STATION_CODE` | string | (empty) | **Per-tablet** code identifying the station this Flask client serves. Must match a station created in the admin UI. Empty = the client refuses `/measure/select` with HTTP 503 "Stazione non configurata". |
| `SSL_CERTFILE` | string | (empty) | Path to SSL certificate (production) | | `SSL_CERTFILE` | string | (empty) | Path to SSL certificate (production) |
| `SSL_KEYFILE` | string | (empty) | Path to SSL private key (production) | | `SSL_KEYFILE` | string | (empty) | Path to SSL private key (production) |
+2 -2
View File
@@ -10,7 +10,7 @@ TieMeasureFlow supports complete internationalization (i18n) with:
## Directory Structure ## Directory Structure
``` ```
client/ src/frontend/flask_app/
├── translations/ # Flask-Babel translations ├── translations/ # Flask-Babel translations
│ ├── babel.cfg # Extraction config │ ├── babel.cfg # Extraction config
│ ├── it/LC_MESSAGES/ │ ├── it/LC_MESSAGES/
@@ -99,7 +99,7 @@ flash(_("Profilo aggiornato con successo"))
After editing .po files: After editing .po files:
```bash ```bash
cd client cd src/frontend/flask_app
python compile_translations.py python compile_translations.py
``` ```
+43
View File
@@ -0,0 +1,43 @@
# Documentazione TieMeasureFlow
Indice della documentazione del progetto.
## Stato e direzione
| Documento | Scopo |
|---|---|
| [`architecture/STATO_PROGETTO.md`](architecture/STATO_PROGETTO.md) | Cosa è fatto oggi (V2.0.0). Snapshot del sistema, componenti e capacità. |
| [`architecture/ROADMAP.md`](architecture/ROADMAP.md) | Cosa resta da fare. Fasi 2-7 della migrazione rev04 verso V1.1.0/M1 demo cliente. |
## Riferimenti operativi
| Documento | Scopo |
|---|---|
| [`API.md`](API.md) | Riferimento endpoint REST esposti dal backend FastAPI. |
| [`DEPLOYMENT.md`](DEPLOYMENT.md) | Guida deploy su VPS (Hostinger/Tielogic con Traefik + Let's Encrypt). |
| [`USER_GUIDE.md`](USER_GUIDE.md) | Manuale utente (operatore, maker, metrologist, admin). |
| [`I18N_SETUP.md`](I18N_SETUP.md) | Setup e workflow traduzioni (Flask-Babel + Alpine.js). |
## Piani dettagliati TDD (rev04)
| Documento | Scopo |
|---|---|
| [`superpowers/plans/2026-04-17-rev04-master-roadmap.md`](superpowers/plans/2026-04-17-rev04-master-roadmap.md) | Master plan rev04 V1.0.7 → V1.1.0, le 7 fasi e le decisioni aperte. |
| [`superpowers/plans/2026-04-17-rev04-phase1-stations.md`](superpowers/plans/2026-04-17-rev04-phase1-stations.md) | Piano TDD dettagliato Fase 1 (stazioni e identità per-tablet). **COMPLETATO.** |
## Specifiche esterne (binari)
| Documento | Scopo |
|---|---|
| [`specs/2026-04-16-schema-sviluppo-rev04.docx`](specs/2026-04-16-schema-sviluppo-rev04.docx) | Schema sviluppo SW TieFlow rev04-2026 fornito dal committente. |
## Storico
| Documento | Scopo |
|---|---|
| [`archive/2026-02-06-piano-implementazione-v1.md`](archive/2026-02-06-piano-implementazione-v1.md) | Piano implementazione V1.0.0 originale (riferimento storico). |
## Ulteriori riferimenti nel repo
- [`/CLAUDE.md`](../CLAUDE.md) — guidance per Claude Code (architettura, comandi, pattern critici).
- [`/README.md`](../README.md) — entry point del progetto.
+89 -3
View File
@@ -37,6 +37,8 @@ TieMeasureFlow is a web-based measurement management system that enables teams t
| **Subtask** | Individual measurement point with tolerance limits (UTL/UWL/LWL/LTL) | | **Subtask** | Individual measurement point with tolerance limits (UTL/UWL/LWL/LTL) |
| **Measurement** | Individual recorded value with automatic pass/fail/warning status | | **Measurement** | Individual recorded value with automatic pass/fail/warning status |
| **Lot/Serial** | Traceability fields linking measurements to physical parts | | **Lot/Serial** | Traceability fields linking measurements to physical parts |
| **Station** | A physical measurement post (typically one tablet on a production line). Each station has a unique code (`STATION_CODE`) and a list of assigned recipes |
| **Station assignment** | Many-to-many link between a station and the recipes available to the operators using that station's tablet |
### Architecture ### Architecture
@@ -175,23 +177,25 @@ TieMeasureFlow has four primary roles. Users can have multiple roles simultaneou
### Admin ### Admin
**Purpose:** System administration and user management **Purpose:** System administration, user management and station deployment
**Permissions:** **Permissions:**
- Create/edit/delete users - Create/edit/delete users
- Assign roles to users - Assign roles to users
- Regenerate user API keys - Regenerate user API keys
- Create/edit/delete stations and assign recipes to them
- Configure system settings - Configure system settings
- Upload company logo - Upload company logo
- Manage CSV export settings - Manage CSV export settings
**Access:** **Access:**
- Menu: **Admin** → User Management - Menu: **Admin** → Utenti / Stazioni
- Can see: All users and system settings - Can see: All users, all stations and system settings
**Typical Tasks:** **Typical Tasks:**
- Onboard new users - Onboard new users
- Reset lost API keys - Reset lost API keys
- Roll out a new tablet: create the matching station, assign recipes, hand the `STATION_CODE` to devops
- Configure locale/format settings - Configure locale/format settings
- Upload company branding - Upload company branding
- Manage system access - Manage system access
@@ -276,6 +280,12 @@ When you edit a recipe (add/remove tasks, change tolerances), a **new version**
## MeasurementTec Workflow ## MeasurementTec Workflow
### Recipes you see are filtered by station
Each tablet/PC running the Flask client is configured at deployment time with a `STATION_CODE` environment variable (for example `ST-LINEA-A`). Whenever you open **Select Recipe**, the page only lists recipes that the admin has assigned to that station. If your tablet shows fewer recipes than you expect, ask the admin to assign the missing recipes to your station — see **Admin Workflow → Station Management**.
If the page shows "Stazione non configurata" (HTTP 503), the deployment is missing the `STATION_CODE` setting; this is a deploy-time configuration issue, not something the operator can fix from the UI.
### Select a Recipe ### Select a Recipe
**Method 1: Search** **Method 1: Search**
@@ -503,6 +513,82 @@ If user loses their API key:
4. New key is generated and displayed 4. New key is generated and displayed
5. Provide new key to user (display once only) 5. Provide new key to user (display once only)
### Station Management
Stations are how TieMeasureFlow enforces "this tablet is responsible for these measurements". Each physical measurement post in the shop floor is modelled as a station; each tablet's Flask client identifies itself through a `STATION_CODE` env var that must match a station's code in the database.
The page is reachable from the navbar entry **"Stazioni"** (workstation icon) for any user with the admin flag, or directly at `/admin/stations`.
#### Mental model
- **One station = one tablet/PC** in the shop floor. The station's identity (`STATION_CODE`) is configured **once at deploy time** in the tablet's `.env`, never changed at runtime.
- A station has a list of **assigned recipes**. The operator using that tablet sees exactly that list — no more, no less.
- A recipe can be assigned to several stations (e.g. a calibration recipe everyone needs).
- A station can be temporarily **disabled** (`active = false`) to take a line offline without losing its history; tablets pointing at a disabled station get HTTP 404 from the recipe endpoint.
#### Create a Station
1. Navigate to **Admin****Stazioni**
2. Click **Nuova Stazione**
3. Fill in the modal:
- **Codice** (required, unique): `ST-LINEA-A`. This is the value the tablet will set as `STATION_CODE` in its `.env`. Use ASCII letters, digits and hyphens only — no spaces, no accented characters. Once created the code cannot be changed (changing it would break every tablet pointing at it).
- **Nome** (required): human-readable description, e.g. `Linea A — Tornitura alberi`.
- **Postazione** (optional): physical location, e.g. `Reparto 2 — Cella 3`.
- **Note** (optional): free text, useful for tracking who set up the station.
- **Attiva** (default checked): uncheck only when retiring the station.
4. Click **Crea Stazione**
Naming convention: prefix every station code with `ST-` and use a stable identifier that survives shop-floor reorganisations.
#### Assign Recipes to a Station
1. From the stations table, click the **checklist icon** on the row of the target station
2. The "Ricette Assegnate" modal opens with two columns:
- **Ricette disponibili** (left): every recipe in the system not yet assigned to this station. Each row has an inline **+ Assegna** button that immediately moves the recipe to the right column.
- **Assegnate alla stazione** (right): the list the operator at this station's tablet will see. The **X** button removes the assignment.
3. Use the search field at the top to narrow either column by recipe code or name
4. Empty-state hints tell you why a column is empty:
- "Tutte le ricette sono già assegnate" — nothing more to assign
- "Nessuna ricetta nel sistema" — create at least one recipe first (Maker workflow)
- "Nessun risultato per il filtro" — clear the search
The assignment audit trail (`assigned_by`, `assigned_at`) is stored on the server but not currently shown in the UI.
#### Edit a Station
1. Click the **pencil icon** (or the row itself) for the target station
2. Update **Nome**, **Postazione**, **Note** or the **Attiva** flag
3. Click **Salva Modifiche**
The **Codice** field is read-only on edit because the value is contractually bound to the `STATION_CODE` set on already-deployed tablets. To rename a station you must delete it and create a new one with the new code, then update every affected tablet's `.env`.
#### Delete a Station
1. Click the **trash icon** for the station
2. Confirm in the modal
Deletion cascades to **all recipe assignments** for that station. Existing measurements collected by tablets at that station are not affected (measurements are linked to recipe versions, not stations).
#### The `ST-DEFAULT` station
The first time `/api/setup/seed` runs, it creates a station called `ST-DEFAULT` and assigns every demo recipe to it. This is intended for single-tablet demos and dev setups where no per-station segmentation is needed.
In multi-station production deployments you should either:
- Delete `ST-DEFAULT` once your real stations are configured, or
- Disable it (`Attiva` off), to prevent a misconfigured tablet from accidentally inheriting the default assignment set.
#### Tablet deployment cheat sheet
For each new physical tablet:
1. Admin creates the station in the UI (e.g. `ST-LINEA-A`)
2. Admin assigns the relevant recipes
3. Devops sets `STATION_CODE=ST-LINEA-A` in the tablet's `.env`
4. Tablet container starts; the operator opens **Measure → Select Recipe** and sees the curated list
If step 3 is missed, **Select Recipe** shows the page "Stazione non configurata" — this is the intentional fail-fast behaviour to prevent a tablet from silently falling back to the wrong recipe set.
### System Settings ### System Settings
#### Configure CSV Export #### Configure CSV Export
+95
View File
@@ -0,0 +1,95 @@
# Roadmap TieMeasureFlow — V2.0.0 → V1.1.0 (rev04 / M1 demo cliente)
> Aggiornare ad ogni Fase chiusa.
## Riferimenti
- Master plan dettagliato: [`../superpowers/plans/2026-04-17-rev04-master-roadmap.md`](../superpowers/plans/2026-04-17-rev04-master-roadmap.md)
- Spec sorgente: [`../specs/2026-04-16-schema-sviluppo-rev04.docx`](../specs/2026-04-16-schema-sviluppo-rev04.docx)
- Stato corrente: [`STATO_PROGETTO.md`](STATO_PROGETTO.md)
## Strategia: due milestone
| Milestone | Scope | Obiettivo |
|---|---|---|
| **M1 — Demo cliente** | Fasi 1-5 + deploy "demo" | Sistema testabile end-to-end col cliente per raccogliere feedback |
| **M2 — Produzione** | Fasi 6-7 + correzioni post-feedback + GAIA live | Rollout su tablet/PC reali |
## Stato Fasi (M1)
| Fase | Scope | Stato | Branch / Commit |
|---|---|---|---|
| **1** | Stazioni + identità per-tablet | ✅ **COMPLETATA** | `V2.0.0` (merge `ea8e468` da `feature/rev04-phase1-stations`) |
| 2 | Ruolo Capoturno (Supervisor) + override token breve | ⏳ Da iniziare | — |
| 3 | Editor ricetta a blocchi (preparation + measurement) | ⏳ Da iniziare | — |
| 4 | Workflow operatore (retry/timer/autologout/avvio produzione) | ⏳ Da iniziare | — |
| 5 (M1) | `ImportOnlyGaiaClient` + UI import dati cliente reali | ⏳ Da iniziare | — |
| Deploy M1 | VPS demo (compose + Traefik + LE, no registry) | ⏳ Da iniziare | — |
## Stato Fasi (M2)
| Fase | Scope | Stato |
|---|---|---|
| 5 (M2) | GAIA reale (protocollo TBD, polling, comandi produzione) | ⏳ Bloccata da decisioni cliente (D-0.1, D-0.2) |
| 6 | Deploy B industriale (registry privato + Watchtower + STATION_ID per-tablet + CI release) | ⏳ Pianificata |
| 7 | Hardening, security review, E2E sito pilota, docs aggiornati, i18n delta | ⏳ Pianificata |
## Decisioni aperte (bloccanti per M2 / future fasi)
Da: master plan §0 "Precondizioni e Decisioni Aperte". Da risolvere col cliente prima della Fase 5/6.
| ID | Decisione | Stato | Bloccante per |
|---|---|---|---|
| D-0.1 | Protocollo integrazione GAIA (REST / DB shared / OPC-UA / file) | **Aperta** | Fase 5 reale (M2) |
| D-0.2 | Credenziali e rete GAIA (VPN / firewall / whitelist IP) | **Aperta** | Fase 5 reale (M2) |
| D-0.3 | Target hardware "tablet" (Windows / Linux industriale / Android) | **Aperta** | Fase 6 (deploy B) |
| D-0.4 | Cicalino/luce avviso (audio HTML5 / hardware USB / entrambi) | **Rimandata a M2** | Fase 4 finale |
| D-0.5 | Parametri runtime modificabili vs versione immutabile | **Aperta** (raccomandato B: separare volatili) | Fase 3 |
| D-0.6 | Auth capoturno durante override (modale / PIN / RFID) | **Aperta** | Fase 2 |
| D-0.7 | Timeout auto-logout | **Risolta** | — |
| D-0.8 | Naming ruolo capoturno | **Proposta:** `Supervisor` | Fase 2 |
| D-0.9 | Tag versione immagine docker | **Proposta:** SemVer + `latest` | Fase 6 |
| D-0.10 | Registry esposto su Internet o solo VPN | **Proposta:** solo VPN cliente | Fase 6 |
## Tech debt da chiudere
| Item | Priorità | Note |
|---|---|---|
| 3 test backend pre-esistenti rotti (`test_recipes`, `test_tasks`) | Media | Investigare prima di Fase 3 (toccano recipe + task router). |
| 1 test client pre-esistente rotto (`test_save_measurement_proxy`) | Bassa | Probabilmente CSRF/payload. Risolvere con Fase 4. |
| Pagina `task_complete` riepilogo: utente segnala riga vuota in alcuni scenari | Media | Da debuggare (rendering corretto via curl ma utente vede vuoto in browser, possibile interazione con sessione lot/serial). |
| `.env` rename a convenzione spec (SERVICE_NAME, SERVICE_DOMAIN, API_KEY) | Bassa | Rinviato (impatto deploy). |
| Header `X-API-Key` rename a `X-Api-Key` | Bassa | Vedere se M2 lo richiede. |
| Envelope risposta `{success,data,error}` | Bassa | Eventuale API v2 in M2. |
| `Dockerfile.frontend`: `pybabel compile` via `uv run` non testato in build reale | Alta | Verificare al primo `docker compose build`. |
| Smoke test in container Docker (non solo locale uvicorn+gunicorn) | Alta | Validare che i Dockerfile riscritti con `uv` buildino e girino correttamente prima di chiudere V2.0.0. |
## Open per scelta utente prima della prossima sessione
1. **Quale fase iniziare adesso?** Opzioni: Fase 2 (Supervisor — sblocca workflow), Fase 3 (block editor — independente), Fase 5 (import GAIA dati reali — sblocca demo).
2. **Revisione decisioni aperte col cliente** — D-0.1 / D-0.2 / D-0.3 / D-0.6 prima di pianificare Fase 5 e 6.
3. **Smoke test Docker** della nuova struttura V2.0.0 (`docker compose -f docker-compose.dev.yml up --build`) per validare i Dockerfile riscritti.
4. **Test di carico** (k6/locust) a 20 VU su `/measure/save-measurement` per validare la scalatura worker (capacità annunciata: 20-30 tablet contemporanei).
## Stima tempi residui M1 (post-Fase 1)
| Task | Stima full-time |
|---|---|
| Fase 2 — Supervisor + override | 1 settimana |
| Fase 3 — Block editor | 1.5 settimane |
| Fase 4 — Workflow operatore | 2 settimane |
| Fase 5 (M1) — Import-only GAIA | 1 settimana |
| Deploy M1 demo | 0.5 settimane |
| **Totale M1 residuo** | **~6 settimane** |
## Stima tempi M2 (dopo feedback)
| Task | Stima |
|---|---|
| Aggiustamenti post-feedback | variabile (1-2 sett.) |
| Fase 5 reale GAIA | 1-2 settimane |
| Fase 6 deploy B | 1 settimana |
| Fase 7 hardening | 1-2 settimane |
| **Totale M2** | **~4-7 settimane** |
**Totale fino a produzione:** ~10-13 settimane full-time da oggi (2026-04-25), assumendo decisioni aperte risolte in tempo utile.
+145
View File
@@ -0,0 +1,145 @@
# Stato Progetto TieMeasureFlow — V2.0.0
> Snapshot al 2026-04-27. Aggiornare ad ogni milestone.
## Versione corrente
**V2.0.0** (in sviluppo, branch `V2.0.0` come default su `git.tielogic.xyz`).
Versione precedente di produzione: `V1.0.7`.
## Sintesi esecutiva
Il sistema base (V1.0.7) è completo e collaudato: ricette, task, misurazioni, SPC, report PDF, gestione utenti, dashboard metrologist. La V2.0.0 in corso aggiunge il primo blocco della migrazione **rev04** (stazioni per-tablet) e ristruttura l'intero monorepo secondo lo standard `python-project-spec-design.md` (uv + `src/backend/` + `src/frontend/flask_app/`).
## Cosa funziona oggi (V2.0.0 — branch corrente)
### Funzionalità ereditate da V1.0.7
- Autenticazione username/password + API key per-utente, ruoli combinabili (Maker, MeasurementTec, Metrologist) + flag `is_admin`.
- Recipe versioning copy-on-write: una nuova versione si crea solo se la corrente ha già measurements; altrimenti update in-place.
- Editor ricette (Maker) con annotation editor Fabric.js (~1200 LOC, collaudato su tablet).
- Workflow operatore tablet: select_recipe → task_list → task_execute → task_complete, con barcode scanner e numpad touch (input USB calibro con burst detection).
- Calcolo pass/fail con limiti UTL/UWL/LWL/LTL.
- Dashboard SPC: capability (Cp/Cpk/Pp/Ppk), control chart (UCL/LCL = mean ± 3σ), istogramma con curva normale, calcoli puro stdlib (no numpy).
- Report PDF (WeasyPrint + Kaleido SVG).
- Setup page protetta da `SETUP_PASSWORD` per inizializzazione DB e seed.
- i18n IT/EN (Flask-Babel + Alpine.js JSON).
- Tema light/dark via `Alpine.store('theme')` + localStorage.
### Aggiunte V2.0.0 (rev04 Fase 1 — Stazioni per-tablet)
- Tabelle `stations` + `station_recipe_assignments` (Alembic migration `002_add_stations.py`).
- Modelli ORM: `Station`, `StationRecipeAssignment` con vincolo unique `(station_id, recipe_id)`.
- Schemas Pydantic: `StationCreate/Update/Response`, `StationRecipeAssignmentCreate/Response`, `RecipeSummary`.
- Service `station_service` (CRUD + assegnazioni + cascade delete).
- Router `/api/stations` con CRUD admin + endpoint operatore `GET /api/stations/by-code/{code}/recipes`.
- Seed automatico `ST-DEFAULT` con tutte le ricette esistenti (idempotente).
- Variabile env client `STATION_CODE` letta da `Config`, helper `APIClient.get_station_recipes()`.
- Filtro `select_recipe`: il client mostra solo le ricette assegnate alla propria stazione, errore se `STATION_CODE` non configurato.
- **GUI admin completa** in `/admin/stations`: tabella con search, modal create/edit, modal gestione assegnazioni ricette, conferma eliminazione, link in navbar (desktop + mobile).
- 47 nuovi test (32 server + 15 client) tutti pass.
### Aggiunte V2.0.0 (performance + multi-utente)
- Gunicorn 5 workers × 4 thread (gthread) — capacità ~20 richieste concorrenti Flask, regge 20+ tablet.
- Uvicorn 4 workers + `--proxy-headers --forwarded-allow-ips='*'`.
- Rate limit middleware: identificazione IP reale via `X-Forwarded-For``X-Real-IP``request.client.host`.
- Rate limit general 100 → 300 req/min/IP (per-tablet ora, non più condiviso).
- Flask `ProxyFix(x_for=1, x_proto=1, x_host=1)` per IP reale dietro Nginx.
- `APIClient` propaga `X-Forwarded-For` + `X-Real-IP` (sia JSON che multipart).
- 12 test aggiuntivi (7 server + 5 client).
### Aggiunte V2.0.0 (struttura monorepo)
- `pyproject.toml` unico con extra `server`/`client`/`dev`. Niente più `requirements.txt`.
- `uv.lock` (77 pacchetti) + `.python-version` (3.11) committati per build riproducibili.
- Layout `src/backend/` + `src/frontend/flask_app/` (vedi sotto).
- `Dockerfile` (root) + `Dockerfile.frontend` riscritti con `uv sync --frozen --no-dev --extra server|client`.
- `docker-compose.{dev,}.yml` con build context `.`.
- Alembic env.py aggiunge project root a `sys.path`; `script_location = %(here)s` resta valido.
- `.dockerignore` aggiornato.
### Hardening post-restructure (smoke test 2026-04-26)
Sequenza di smoke test in locale (uvicorn + gunicorn + MySQL Docker) ha fatto emergere quattro regressioni che sarebbero rimaste invisibili al test suite:
- **`src/backend/config.py`**: `env_file` era cwd-relative (`../../.env`). Rotto fuori da `src/backend/`. Risolto con percorso assoluto `Path(__file__).resolve().parents[2] / ".env"`.
- **`src/backend/models/orm/__init__.py`**: `Station` e `StationRecipeAssignment` non erano esportati, quindi `Base.metadata.create_all` non creava le tabelle stations. Aggiunti agli import.
- **`.env.example`**: `UPLOAD_DIR=server/uploads` era residuo della vecchia struttura → file landavano fuori dall'albero di progetto. Aggiornato a `UPLOAD_DIR=uploads`.
- **Apostrofi italiani in template Alpine** (`l'utente`, `nell'eliminazione`, `nell'assegnazione`): chiudevano prematuramente JS string literals dentro `x-text` e blocchi `<script>`. Riscritti con delimitatori `&quot;...&quot;` o riformulazione testuale.
Inoltre **UX rework** della modale assegnazione ricette su `/admin/stations`: dropdown sostituita da layout a 2 colonne (disponibili / assegnate) con bottone inline `+ Assegna`, search filter, empty state esplicativo (mostrava silenziosamente lista vuota se tutte le ricette erano già assegnate).
Test guard aggiunto: `test_template_js_syntax.py` valida ogni inline `<script>` E ogni espressione Alpine (`x-*`, `@*`, `:*`) della pagina con `node --check`. Cattura automaticamente il bug-class apostrofo. Skip se Node non è installato.
## Layout repository (V2.0.0)
```
TieMeasureFlow/
├── pyproject.toml + uv.lock + .python-version
├── Dockerfile (backend) + Dockerfile.frontend
├── docker-compose.dev.yml + docker-compose.yml
├── nginx/
├── uploads/ # volume Docker
├── docs/ # raggruppata e indicizzata
│ ├── README.md (indice)
│ ├── API.md / DEPLOYMENT.md / USER_GUIDE.md / I18N_SETUP.md
│ ├── architecture/ # questo file + ROADMAP.md
│ ├── archive/ # piani storici
│ ├── specs/ # spec esterne (.docx)
│ └── superpowers/plans/ # piani TDD dettagliati
└── src/
├── backend/
│ ├── main.py / config.py / database.py
│ ├── api/{routers,middleware}/
│ ├── models/{orm,api}/
│ ├── services/
│ ├── migrations/
│ ├── templates/
│ └── tests/
└── frontend/
└── flask_app/
├── app.py / config.py / compile_translations.py
├── blueprints/ (auth, maker, measure, statistics, admin)
├── services/ (api_client.py)
├── templates/ + static/ + translations/
└── tests/
```
## Smoke test status
Validazione end-to-end in locale (2026-04-26):
- ✅ MySQL container Docker up, schema creato, alembic stamp head OK
- ✅ uvicorn `--reload` su :8000, `/api/health` risponde
- ✅ Seed `/api/setup/seed` con `SETUP_PASSWORD=adriano77` → admin + 4 utenti demo + DEMO-001 + ST-DEFAULT con assegnazione automatica
- ✅ Login `admin/admin123` via web, sessione persistente
-`/admin/stations`: tabella, modal create/edit, modal gestione assegnazioni a 2 colonne con search, eliminazione con cascade
-`/admin/users`, `/maker/recipes`, `/measure/select` (filtrato per stazione), `/statistics/dashboard`
- ✅ Workflow MeasurementTec end-to-end: select_recipe → task_list → task_execute → task_complete (riepilogo con misure)
- ✅ Hot reload Flask + uvicorn `--reload` + Tailwind watch attivi durante lo sviluppo
## Test status
| Backend (`src/backend/tests/`) | 127 | 3 | Fail pre-esistenti: `test_recipes` (2) + `test_tasks` (1). Nessuno introdotto dalla V2.0.0. |
| Frontend (`src/frontend/flask_app/tests/`) | 46 | 1 | +2 test post-restructure (`test_template_js_syntax.py`). Fail pre-esistente: `test_save_measurement_proxy`. |
| **Totale** | **173** | **4** | Tutti i fallimenti tracciati come tech debt da risolvere. |
## Stack confermato
- **Backend:** FastAPI + SQLAlchemy 2.0 async + MySQL 8 + Alembic + Pydantic v2 + WeasyPrint + Plotly/Kaleido.
- **Frontend:** Flask + Jinja2 + Alpine.js + TailwindCSS + Fabric.js 5.3.1 + html5-qrcode + Plotly.js + Flask-Babel.
- **Deploy:** Docker Compose. Dev = Nginx; Prod = Traefik + Let's Encrypt SSL.
- **Tooling:** uv (package mgmt), pytest + pytest-asyncio + httpx + aiosqlite (test).
## Decisioni architetturali rilevanti
| Decisione | Stato | Note |
|---|---|---|
| Frontend Flask invece di React (deroga vs spec §8) | **Confermata** | Tablet UX server-side, USB calipers/barcode, Fabric.js editor, i18n Babel collaudato. Vedi conversazione 2026-04-25. |
| NATS messaging (spec §7) | **Skippato** | Monorepo single-host, no microservizi. Nessuno stub `nats_client/` creato. |
| Envelope risposta `{success,data,error}` (spec §6) | **Rimandato** | Costo 4-5gg refactor + rotture client. Eventuale v2 API in M2. |
| Header `X-API-Key` vs spec `X-Api-Key` | **Mantenuto attuale** | Rinominare costa 50+ punti di codice + breaking per deploy. Rivedere in M2. |
| Variabili `.env` (DB_HOST, SERVER_PORT, ...) | **Mantenute attuali** | Rename a SERVICE_NAME/SERVICE_DOMAIN/API_KEY rinviato (impatta deploy esistenti). |
## Branch git
- **Default:** `V2.0.0` (lavoro corrente)
- **Mantenuti:** `V1.0.0``V1.0.7` (release branches storiche)
- **Mergiato e chiuso:** `feature/rev04-phase1-stations` (in `V2.0.0` con commit `ea8e468`)
Binary file not shown.
+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"]
-26
View File
@@ -1,26 +0,0 @@
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 2"]
-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_*
@@ -1,5 +1,5 @@
"""FastAPI middleware for TieMeasureFlow.""" """FastAPI middleware for TieMeasureFlow."""
from middleware.api_key import ( from src.backend.api.middleware.api_key import (
get_current_user, get_current_user,
require_role, require_role,
require_admin, require_admin,
@@ -8,9 +8,9 @@ from middleware.api_key import (
require_metrologist, require_metrologist,
require_admin_user, require_admin_user,
) )
from middleware.logging import AccessLogMiddleware from src.backend.api.middleware.logging import AccessLogMiddleware
from middleware.rate_limit import RateLimitMiddleware from src.backend.api.middleware.rate_limit import RateLimitMiddleware
from middleware.security_headers import SecurityHeadersMiddleware from src.backend.api.middleware.security_headers import SecurityHeadersMiddleware
__all__ = [ __all__ = [
"get_current_user", "get_current_user",
@@ -3,8 +3,8 @@ from fastapi import Depends, HTTPException, Request, status
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from database import get_db from src.backend.database import get_db
from models.user import User from src.backend.models.orm.user import User
async def get_current_user( async def get_current_user(
@@ -6,8 +6,8 @@ from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware from starlette.middleware.base import BaseHTTPMiddleware
from sqlalchemy import insert from sqlalchemy import insert
from database import async_session_factory from src.backend.database import async_session_factory
from models.access_log import AccessLog from src.backend.models.orm.access_log import AccessLog
class AccessLogMiddleware(BaseHTTPMiddleware): class AccessLogMiddleware(BaseHTTPMiddleware):
@@ -11,7 +11,7 @@ from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import JSONResponse from starlette.responses import JSONResponse
from config import settings from src.backend.config import settings
class RateLimitMiddleware(BaseHTTPMiddleware): class RateLimitMiddleware(BaseHTTPMiddleware):
@@ -32,6 +32,25 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
self._general_requests: dict[str, list[float]] = defaultdict(list) self._general_requests: dict[str, list[float]] = defaultdict(list)
self._request_count = 0 # Counter for triggering eviction 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]: def _clean_window(self, timestamps: list[float], now: float) -> list[float]:
"""Remove timestamps outside the current sliding window.""" """Remove timestamps outside the current sliding window."""
cutoff = now - self.WINDOW_SECONDS cutoff = now - self.WINDOW_SECONDS
@@ -68,7 +87,7 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
return True, 0 return True, 0
async def dispatch(self, request: Request, call_next: Callable) -> Response: 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() now = time.time()
path = request.url.path path = request.url.path
@@ -8,7 +8,7 @@ from typing import Callable
from fastapi import Request, Response from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware 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 # Content Security Policy - allows CDN resources used by the client
# Note: 'unsafe-eval' required for Plotly.js runtime evaluation in SPC charts # Note: 'unsafe-eval' required for Plotly.js runtime evaluation in SPC charts
@@ -2,16 +2,16 @@
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from database import get_db from src.backend.database import get_db
from middleware.api_key import get_current_user from src.backend.api.middleware.api_key import get_current_user
from models.user import User from src.backend.models.orm.user import User
from schemas.user import ( from src.backend.models.api.user import (
LoginRequest, LoginRequest,
LoginResponse, LoginResponse,
UserProfileUpdate, UserProfileUpdate,
UserResponse, 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"]) 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 fastapi.responses import FileResponse
from PIL import Image from PIL import Image
from config import settings from src.backend.config import settings
from middleware.api_key import get_current_user, require_maker from src.backend.api.middleware.api_key import get_current_user, require_maker
from models.user import User from src.backend.models.orm.user import User
router = APIRouter(prefix="/api/files", tags=["files"]) router = APIRouter(prefix="/api/files", tags=["files"])
@@ -8,23 +8,23 @@ from fastapi.responses import StreamingResponse
from sqlalchemy import and_, func, select from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from database import get_db from src.backend.database import get_db
from middleware.api_key import ( from src.backend.api.middleware.api_key import (
get_current_user, get_current_user,
require_measurement_tec, require_measurement_tec,
require_metrologist, require_metrologist,
) )
from models.measurement import Measurement from src.backend.models.orm.measurement import Measurement
from models.recipe import RecipeVersion from src.backend.models.orm.recipe import RecipeVersion
from models.setting import SystemSetting from src.backend.models.orm.setting import SystemSetting
from models.user import User from src.backend.models.orm.user import User
from schemas.measurement import ( from src.backend.models.api.measurement import (
MeasurementBatchCreate, MeasurementBatchCreate,
MeasurementCreate, MeasurementCreate,
MeasurementListResponse, MeasurementListResponse,
MeasurementResponse, MeasurementResponse,
) )
from services.measurement_service import save_measurement from src.backend.services.measurement_service import save_measurement
router = APIRouter(prefix="/api/measurements", tags=["measurements"]) router = APIRouter(prefix="/api/measurements", tags=["measurements"])
@@ -97,10 +97,17 @@ async def get_measurements(
pass_fail: str | None = Query(None, pattern="^(pass|warning|fail)$"), pass_fail: str | None = Query(None, pattern="^(pass|warning|fail)$"),
page: int = Query(1, ge=1), page: int = Query(1, ge=1),
per_page: int = Query(50, ge=1, le=500), per_page: int = Query(50, ge=1, le=500),
user: User = Depends(require_metrologist), user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""Query measurements with filters and pagination.""" """Query measurements with filters and pagination.
Available to any authenticated user. The MeasurementTec workflow needs
this endpoint to render `task_complete.html` after running through a
recipe; the Metrologist dashboard uses the same endpoint with broader
filters. Measurements carry no PII beyond numeric values + a recipe
reference, so role-gating beyond authentication isn't justified.
"""
# Build filter conditions # Build filter conditions
filters = [] filters = []
if recipe_id is not None: if recipe_id is not None:
@@ -2,17 +2,17 @@
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from database import get_db from src.backend.database import get_db
from middleware.api_key import get_current_user, require_maker, require_measurement_tec from src.backend.api.middleware.api_key import get_current_user, require_maker, require_measurement_tec
from models.user import User from src.backend.models.orm.user import User
from schemas.recipe import ( from src.backend.models.api.recipe import (
RecipeCreate, RecipeCreate,
RecipeListResponse, RecipeListResponse,
RecipeResponse, RecipeResponse,
RecipeUpdate, RecipeUpdate,
RecipeVersionResponse, RecipeVersionResponse,
) )
from services import recipe_service from src.backend.services import recipe_service
router = APIRouter(prefix="/api/recipes", tags=["recipes"]) 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 fastapi.responses import Response
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from database import get_db from src.backend.database import get_db
from middleware.api_key import require_metrologist from src.backend.api.middleware.api_key import require_metrologist
from models.user import User from src.backend.models.orm.user import User
from services.report_service import generate_measurement_report, generate_spc_report from src.backend.services.report_service import generate_measurement_report, generate_spc_report
router = APIRouter(prefix="/api/reports", tags=["reports"]) 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 import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from config import settings from src.backend.config import settings
from database import get_db from src.backend.database import get_db
from middleware.api_key import get_current_user, require_admin_user from src.backend.api.middleware.api_key import get_current_user, require_admin_user
from models.setting import SystemSetting from src.backend.models.orm.setting import SystemSetting
from models.user import User from src.backend.models.orm.user import User
router = APIRouter(prefix="/api/settings", tags=["settings"]) 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 import inspect as sa_inspect, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from config import settings from src.backend.config import settings
from database import Base, engine, async_session_factory from src.backend.database import Base, engine, async_session_factory
from models import ( from src.backend.models.orm import (
User, Recipe, RecipeVersion, RecipeTask, RecipeSubtask, Measurement, User, Recipe, RecipeVersion, RecipeTask, RecipeSubtask, Measurement,
) )
from models.station import Station, StationRecipeAssignment from src.backend.models.orm.station import Station, StationRecipeAssignment
from services.auth_service import hash_password from src.backend.services.auth_service import hash_password
from services.measurement_service import calculate_pass_fail from src.backend.services.measurement_service import calculate_pass_fail
router = APIRouter(prefix="/api/setup", tags=["setup"]) router = APIRouter(prefix="/api/setup", tags=["setup"])
@@ -2,10 +2,10 @@
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from database import get_db from src.backend.database import get_db
from middleware.api_key import get_current_user, require_admin_user from src.backend.api.middleware.api_key import get_current_user, require_admin_user
from models.user import User from src.backend.models.orm.user import User
from schemas.station import ( from src.backend.models.api.station import (
StationCreate, StationCreate,
StationUpdate, StationUpdate,
StationResponse, StationResponse,
@@ -13,7 +13,7 @@ from schemas.station import (
StationRecipeAssignmentResponse, StationRecipeAssignmentResponse,
RecipeSummary, RecipeSummary,
) )
from services import station_service from src.backend.services import station_service
router = APIRouter(prefix="/api/stations", tags=["stations"]) 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 import and_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from database import get_db from src.backend.database import get_db
from middleware.api_key import require_metrologist from src.backend.api.middleware.api_key import require_metrologist
from models.measurement import Measurement from src.backend.models.orm.measurement import Measurement
from models.recipe import RecipeVersion from src.backend.models.orm.recipe import RecipeVersion
from models.task import RecipeSubtask, RecipeTask from src.backend.models.orm.task import RecipeSubtask, RecipeTask
from models.user import User from src.backend.models.orm.user import User
from schemas.statistics import ( from src.backend.models.api.statistics import (
CapabilityData, CapabilityData,
ControlChartData, ControlChartData,
HistogramData, HistogramData,
SummaryData, SummaryData,
) )
from services.spc_service import ( from src.backend.services.spc_service import (
compute_capability, compute_capability,
compute_control_chart, compute_control_chart,
compute_histogram, compute_histogram,
@@ -4,12 +4,12 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from database import get_db from src.backend.database import get_db
from middleware.api_key import require_maker, get_current_user from src.backend.api.middleware.api_key import require_maker, get_current_user
from models.recipe import Recipe, RecipeVersion from src.backend.models.orm.recipe import Recipe, RecipeVersion
from models.task import RecipeSubtask, RecipeTask from src.backend.models.orm.task import RecipeSubtask, RecipeTask
from models.user import User from src.backend.models.orm.user import User
from schemas.task import ( from src.backend.models.api.task import (
SubtaskCreate, SubtaskCreate,
SubtaskResponse, SubtaskResponse,
SubtaskUpdate, SubtaskUpdate,
@@ -18,7 +18,7 @@ from schemas.task import (
TaskResponse, TaskResponse,
TaskUpdate, TaskUpdate,
) )
from services import recipe_service from src.backend.services import recipe_service
router = APIRouter(tags=["tasks"]) router = APIRouter(tags=["tasks"])
@@ -159,7 +159,7 @@ async def create_task(
if has_measurements: if has_measurements:
# Copy-on-write: create new version preserving measurement data # 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( new_version = await recipe_service.create_new_version(
db, recipe_id, RecipeUpdate(change_notes=f"Added task: {data.title}"), user 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 import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from database import get_db from src.backend.database import get_db
from middleware.api_key import require_admin_user from src.backend.api.middleware.api_key import require_admin_user
from models.user import User from src.backend.models.orm.user import User
from schemas.user import UserCreate, UserPasswordChange, UserResponse, UserUpdate from src.backend.models.api.user import UserCreate, UserPasswordChange, UserResponse, UserUpdate
from services.auth_service import create_user, hash_password, regenerate_api_key from src.backend.services.auth_service import create_user, hash_password, regenerate_api_key
router = APIRouter(prefix="/api/users", tags=["users"]) router = APIRouter(prefix="/api/users", tags=["users"])
+16 -5
View File
@@ -23,9 +23,9 @@ class Settings(BaseSettings):
upload_dir: str = "uploads" upload_dir: str = "uploads"
max_upload_size_mb: int = 50 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_login: int = 5
rate_limit_general: int = 100 rate_limit_general: int = 300
# SSL (Production) # SSL (Production)
ssl_certfile: str | None = None ssl_certfile: str | None = None
@@ -49,10 +49,21 @@ class Settings(BaseSettings):
@property @property
def upload_path(self) -> Path: def upload_path(self) -> Path:
"""Absolute path to upload directory.""" """Absolute path to upload directory.
return Path(__file__).parent / self.upload_dir
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
# Always resolve .env against the project root regardless of cwd
# (pydantic-settings would otherwise treat the path as cwd-relative).
model_config = {
"env_file": str(Path(__file__).resolve().parents[2] / ".env"),
"env_file_encoding": "utf-8",
"extra": "ignore",
}
settings = Settings() settings = Settings()
@@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import (
) )
from sqlalchemy.orm import DeclarativeBase from sqlalchemy.orm import DeclarativeBase
from config import settings from src.backend.config import settings
# Create async engine # Create async engine
engine = 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 import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from config import settings from src.backend.config import settings
from database import init_db from src.backend.database import init_db
from middleware.logging import AccessLogMiddleware from src.backend.api.middleware.logging import AccessLogMiddleware
from middleware.rate_limit import RateLimitMiddleware from src.backend.api.middleware.rate_limit import RateLimitMiddleware
from middleware.security_headers import SecurityHeadersMiddleware from src.backend.api.middleware.security_headers import SecurityHeadersMiddleware
from routers.auth import router as auth_router from src.backend.api.routers.auth import router as auth_router
from routers.users import router as users_router from src.backend.api.routers.users import router as users_router
from routers.recipes import router as recipes_router from src.backend.api.routers.recipes import router as recipes_router
from routers.tasks import router as tasks_router from src.backend.api.routers.tasks import router as tasks_router
from routers.measurements import router as measurements_router from src.backend.api.routers.measurements import router as measurements_router
from routers.files import router as files_router from src.backend.api.routers.files import router as files_router
from routers.settings import router as settings_router from src.backend.api.routers.settings import router as settings_router
from routers.reports import router as reports_router from src.backend.api.routers.reports import router as reports_router
from routers.statistics import router as statistics_router from src.backend.api.routers.statistics import router as statistics_router
from routers.setup import router as setup_router from src.backend.api.routers.setup import router as setup_router
from routers.stations import router as stations_router from src.backend.api.routers.stations import router as stations_router
@asynccontextmanager @asynccontextmanager
@@ -17,20 +17,25 @@ if config.config_file_name is not None:
import sys import sys
from pathlib import Path from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) # Add the project root (4 levels up from this file) to sys.path so that
from config import settings # `src.backend.*` imports resolve when alembic is invoked from anywhere.
from database import Base _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) # Override alembic.ini URL with .env settings (keep in sync)
config.set_main_option("sqlalchemy.url", settings.database_url) config.set_main_option("sqlalchemy.url", settings.database_url)
# Import all models so they register with Base.metadata # Import all models so they register with Base.metadata
from models.user import User # noqa: F401 from src.backend.models.orm.user import User # noqa: F401
from models.recipe import Recipe, RecipeVersion # noqa: F401 from src.backend.models.orm.recipe import Recipe, RecipeVersion # noqa: F401
from models.task import RecipeTask, RecipeSubtask # noqa: F401 from src.backend.models.orm.task import RecipeTask, RecipeSubtask # noqa: F401
from models.measurement import Measurement # noqa: F401 from src.backend.models.orm.measurement import Measurement # noqa: F401
from models.access_log import AccessLog # noqa: F401 from src.backend.models.orm.access_log import AccessLog # noqa: F401
from models.setting import SystemSetting, RecipeVersionAudit # noqa: F401 from src.backend.models.orm.setting import SystemSetting, RecipeVersionAudit # noqa: F401
target_metadata = Base.metadata target_metadata = Base.metadata
@@ -1,19 +1,19 @@
"""Pydantic schemas for TieMeasureFlow API.""" """Pydantic schemas for TieMeasureFlow API."""
from schemas.measurement import ( from src.backend.models.api.measurement import (
MeasurementBatchCreate, MeasurementBatchCreate,
MeasurementCreate, MeasurementCreate,
MeasurementListResponse, MeasurementListResponse,
MeasurementQuery, MeasurementQuery,
MeasurementResponse, MeasurementResponse,
) )
from schemas.recipe import ( from src.backend.models.api.recipe import (
RecipeCreate, RecipeCreate,
RecipeListResponse, RecipeListResponse,
RecipeResponse, RecipeResponse,
RecipeUpdate, RecipeUpdate,
RecipeVersionResponse, RecipeVersionResponse,
) )
from schemas.statistics import ( from src.backend.models.api.statistics import (
AlertData, AlertData,
CapabilityData, CapabilityData,
ControlChartData, ControlChartData,
@@ -23,7 +23,7 @@ from schemas.statistics import (
SummaryData, SummaryData,
TrendData, TrendData,
) )
from schemas.task import ( from src.backend.models.api.task import (
SubtaskCreate, SubtaskCreate,
SubtaskResponse, SubtaskResponse,
SubtaskUpdate, SubtaskUpdate,
@@ -32,7 +32,7 @@ from schemas.task import (
TaskResponse, TaskResponse,
TaskUpdate, TaskUpdate,
) )
from schemas.user import ( from src.backend.models.api.user import (
LoginRequest, LoginRequest,
LoginResponse, LoginResponse,
UserCreate, UserCreate,
@@ -5,7 +5,7 @@ from typing import Optional, TYPE_CHECKING
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
if TYPE_CHECKING: if TYPE_CHECKING:
from schemas.task import TaskResponse from src.backend.models.api.task import TaskResponse
class RecipeCreate(BaseModel): class RecipeCreate(BaseModel):
@@ -71,6 +71,6 @@ class RecipeListResponse(BaseModel):
# Forward reference imports for model_rebuild # 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() RecipeVersionResponse.model_rebuild()
RecipeResponse.model_rebuild() RecipeResponse.model_rebuild()
+22
View File
@@ -0,0 +1,22 @@
"""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
from src.backend.models.orm.station import Station, StationRecipeAssignment
__all__ = [
"User",
"Recipe",
"RecipeVersion",
"RecipeTask",
"RecipeSubtask",
"Measurement",
"AccessLog",
"SystemSetting",
"RecipeVersionAudit",
"Station",
"StationRecipeAssignment",
]
@@ -5,7 +5,7 @@ from typing import Optional
from sqlalchemy import BigInteger, DateTime, ForeignKey, Index, Integer, JSON, String, func from sqlalchemy import BigInteger, DateTime, ForeignKey, Index, Integer, JSON, String, func
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from database import Base from src.backend.database import Base
class AccessLog(Base): class AccessLog(Base):
@@ -8,7 +8,7 @@ from sqlalchemy import (
) )
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from database import Base from src.backend.database import Base
class Measurement(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 import Boolean, DateTime, ForeignKey, Index, Integer, String, Text, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from database import Base from src.backend.database import Base
if TYPE_CHECKING: if TYPE_CHECKING:
from models.task import RecipeTask from src.backend.models.orm.task import RecipeTask
class Recipe(Base): class Recipe(Base):
@@ -5,7 +5,7 @@ from typing import Optional
from sqlalchemy import DateTime, Enum, ForeignKey, Index, Integer, String, Text, func from sqlalchemy import DateTime, Enum, ForeignKey, Index, Integer, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from database import Base from src.backend.database import Base
class SystemSetting(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 import Boolean, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from database import Base from src.backend.database import Base
if TYPE_CHECKING: if TYPE_CHECKING:
from models.recipe import Recipe from src.backend.models.orm.recipe import Recipe
class Station(Base): class Station(Base):
@@ -6,10 +6,10 @@ from sqlalchemy import (
) )
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from database import Base from src.backend.database import Base
if TYPE_CHECKING: if TYPE_CHECKING:
from models.recipe import RecipeVersion from src.backend.models.orm.recipe import RecipeVersion
class RecipeTask(Base): class RecipeTask(Base):
@@ -5,7 +5,7 @@ from typing import Optional
from sqlalchemy import Boolean, DateTime, Enum, Integer, JSON, String, func from sqlalchemy import Boolean, DateTime, Enum, Integer, JSON, String, func
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from database import Base from src.backend.database import Base
class User(Base): class User(Base):
@@ -6,7 +6,7 @@ import bcrypt
from sqlalchemy import select, update from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession 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: def hash_password(password: str) -> str:
@@ -4,8 +4,8 @@ from decimal import Decimal
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from models.measurement import Measurement from src.backend.models.orm.measurement import Measurement
from models.task import RecipeSubtask from src.backend.models.orm.task import RecipeSubtask
def calculate_pass_fail( def calculate_pass_fail(
@@ -7,12 +7,12 @@ from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from models.measurement import Measurement from src.backend.models.orm.measurement import Measurement
from models.recipe import Recipe, RecipeVersion from src.backend.models.orm.recipe import Recipe, RecipeVersion
from models.setting import RecipeVersionAudit from src.backend.models.orm.setting import RecipeVersionAudit
from models.task import RecipeSubtask, RecipeTask from src.backend.models.orm.task import RecipeSubtask, RecipeTask
from models.user import User from src.backend.models.orm.user import User
from schemas.recipe import RecipeCreate, RecipeUpdate 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 sqlalchemy.ext.asyncio import AsyncSession
from weasyprint import HTML from weasyprint import HTML
from config import settings from src.backend.config import settings
from models.measurement import Measurement from src.backend.models.orm.measurement import Measurement
from models.recipe import Recipe, RecipeVersion from src.backend.models.orm.recipe import Recipe, RecipeVersion
from models.setting import SystemSetting from src.backend.models.orm.setting import SystemSetting
from models.task import RecipeSubtask, RecipeTask from src.backend.models.orm.task import RecipeSubtask, RecipeTask
from models.user import User from src.backend.models.orm.user import User
from services.spc_service import ( from src.backend.services.spc_service import (
compute_capability, compute_capability,
compute_control_chart, compute_control_chart,
compute_histogram, compute_histogram,
@@ -9,7 +9,7 @@ import math
import statistics as stats import statistics as stats
from datetime import datetime from datetime import datetime
from schemas.statistics import ( from src.backend.models.api.statistics import (
CapabilityData, CapabilityData,
ControlChartData, ControlChartData,
HistogramData, HistogramData,
@@ -10,10 +10,10 @@ from fastapi import HTTPException, status
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from models.recipe import Recipe from src.backend.models.orm.recipe import Recipe
from models.station import Station, StationRecipeAssignment from src.backend.models.orm.station import Station, StationRecipeAssignment
from models.user import User from src.backend.models.orm.user import User
from schemas.station import StationCreate, StationUpdate from src.backend.models.api.station import StationCreate, StationUpdate
async def create_station( async def create_station(
@@ -30,17 +30,17 @@ if "weasyprint" not in sys.modules:
# Ensure the server package is importable # Ensure the server package is importable
sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from database import Base, get_db from src.backend.database import Base, get_db
from main import app from src.backend.main import app
from middleware.rate_limit import RateLimitMiddleware from src.backend.api.middleware.rate_limit import RateLimitMiddleware
from models.user import User from src.backend.models.orm.user import User
from models.recipe import Recipe, RecipeVersion from src.backend.models.orm.recipe import Recipe, RecipeVersion
from models.task import RecipeTask, RecipeSubtask from src.backend.models.orm.task import RecipeTask, RecipeSubtask
from models.measurement import Measurement from src.backend.models.orm.measurement import Measurement
from models.access_log import AccessLog from src.backend.models.orm.access_log import AccessLog
from models.setting import SystemSetting, RecipeVersionAudit from src.backend.models.orm.setting import SystemSetting, RecipeVersionAudit
from models.station import Station, StationRecipeAssignment from src.backend.models.orm.station import Station, StationRecipeAssignment
from services.auth_service import hash_password, generate_api_key from src.backend.services.auth_service import hash_password, generate_api_key
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# In-memory SQLite engine for tests # In-memory SQLite engine for tests
@@ -2,7 +2,7 @@
import pytest import pytest
from httpx import AsyncClient from httpx import AsyncClient
from tests.conftest import _create_user, auth_headers from src.backend.tests.conftest import _create_user, auth_headers
class TestLogin: class TestLogin:
@@ -8,8 +8,8 @@ from unittest.mock import patch
import pytest import pytest
from httpx import AsyncClient from httpx import AsyncClient
from models.user import User from src.backend.models.orm.user import User
from tests.conftest import auth_headers from src.backend.tests.conftest import auth_headers
class TestUploadFile: class TestUploadFile:
@@ -45,7 +45,7 @@ class TestUploadFile:
) )
# Patch settings.upload_path to use tmp_path # 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.upload_path = tmp_path
mock_settings.max_upload_size_mb = 50 mock_settings.max_upload_size_mb = 50
@@ -82,7 +82,7 @@ class TestUploadFile:
# Create a large file content # Create a large file content
large_content = b"\x00" * (51 * 1024 * 1024) # 51MB 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.max_upload_size_mb = 50
mock_settings.upload_path = Path(tempfile.mkdtemp()) mock_settings.upload_path = Path(tempfile.mkdtemp())
@@ -109,7 +109,7 @@ class TestGetFile:
test_file = tmp_path / "testfile.txt" test_file = tmp_path / "testfile.txt"
test_file.write_text("hello test") 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 mock_settings.upload_path = tmp_path
resp = await client.get( resp = await client.get(
@@ -123,7 +123,7 @@ class TestGetFile:
self, client: AsyncClient, maker_user: User, tmp_path: Path self, client: AsyncClient, maker_user: User, tmp_path: Path
): ):
"""Requesting a non-existent file returns 404.""" """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 mock_settings.upload_path = tmp_path
resp = await client.get( resp = await client.get(
@@ -144,7 +144,7 @@ class TestDeleteFile:
test_file = tmp_path / "deleteme.txt" test_file = tmp_path / "deleteme.txt"
test_file.write_text("delete me") 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 mock_settings.upload_path = tmp_path
resp = await client.delete( resp = await client.delete(
@@ -163,7 +163,7 @@ class TestFilenameSanitization:
self, client: AsyncClient, maker_user: User, tmp_path: Path self, client: AsyncClient, maker_user: User, tmp_path: Path
): ):
"""Path traversal attempt returns 403 or 404.""" """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 mock_settings.upload_path = tmp_path
resp = await client.get( resp = await client.get(
@@ -3,10 +3,10 @@ import pytest
from httpx import AsyncClient from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from models.user import User from src.backend.models.orm.user import User
from models.recipe import RecipeVersion from src.backend.models.orm.recipe import RecipeVersion
from models.task import RecipeTask, RecipeSubtask from src.backend.models.orm.task import RecipeTask, RecipeSubtask
from tests.conftest import auth_headers, create_test_recipe from src.backend.tests.conftest import auth_headers, create_test_recipe
async def _get_subtask_and_version( async def _get_subtask_and_version(
@@ -211,6 +211,43 @@ class TestListMeasurements:
assert "total" in data assert "total" in data
assert data["total"] >= 3 assert data["total"] >= 3
async def test_measurement_tec_can_list_measurements(
self,
client: AsyncClient,
measurement_tec_user: User,
db_session: AsyncSession,
):
"""Regression: MeasurementTec must be able to list measurements.
The Flask client renders task_complete.html by calling GET
/api/measurements?version_id=X right after the operator finishes a
recipe. The endpoint used to require Metrologist, which silently
produced an empty riepilogo for every operator without that role.
"""
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
)
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,
},
)
resp = await client.get(
f"/api/measurements/?version_id={version_id}",
headers=auth_headers(measurement_tec_user),
)
assert resp.status_code == 200, resp.text
data = resp.json()
assert data["total"] >= 3
async def test_measurement_by_recipe_filter( async def test_measurement_by_recipe_filter(
self, self,
client: AsyncClient, client: AsyncClient,
@@ -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 httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from models.user import User from src.backend.models.orm.user import User
from tests.conftest import auth_headers, create_test_recipe from src.backend.tests.conftest import auth_headers, create_test_recipe
class TestListRecipes: class TestListRecipes:
@@ -9,8 +9,8 @@ import pytest
from httpx import AsyncClient from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from models.user import User from src.backend.models.orm.user import User
from tests.conftest import auth_headers, create_test_recipe from src.backend.tests.conftest import auth_headers, create_test_recipe
class TestMeasurementReport: class TestMeasurementReport:
@@ -29,7 +29,7 @@ class TestMeasurementReport:
fake_pdf = b"%PDF-1.4 fake content" fake_pdf = b"%PDF-1.4 fake content"
with patch( with patch(
"routers.reports.generate_measurement_report", "src.backend.api.routers.reports.generate_measurement_report",
new_callable=AsyncMock, new_callable=AsyncMock,
return_value=fake_pdf, return_value=fake_pdf,
): ):
@@ -75,7 +75,7 @@ class TestSPCReport:
fake_pdf = b"%PDF-1.4 spc report content" fake_pdf = b"%PDF-1.4 spc report content"
with patch( with patch(
"routers.reports.generate_spc_report", "src.backend.api.routers.reports.generate_spc_report",
new_callable=AsyncMock, new_callable=AsyncMock,
return_value=fake_pdf, return_value=fake_pdf,
): ):
@@ -9,7 +9,7 @@ from unittest.mock import patch
import pytest import pytest
from httpx import AsyncClient from httpx import AsyncClient
from tests.conftest import _create_user, auth_headers from src.backend.tests.conftest import _create_user, auth_headers
class TestCORSHeaders: class TestCORSHeaders:
@@ -126,7 +126,7 @@ class TestFilenameSanitization:
async def test_dotfile_rejected(self, client: AsyncClient, db_session): async def test_dotfile_rejected(self, client: AsyncClient, db_session):
"""Filenames starting with '.' are rejected.""" """Filenames starting with '.' are rejected."""
from routers.files import sanitize_filename from src.backend.api.routers.files import sanitize_filename
from fastapi import HTTPException from fastapi import HTTPException
with pytest.raises(HTTPException) as exc_info: with pytest.raises(HTTPException) as exc_info:
@@ -136,7 +136,7 @@ class TestFilenameSanitization:
async def test_empty_filename_rejected(self, client: AsyncClient, db_session): async def test_empty_filename_rejected(self, client: AsyncClient, db_session):
"""Empty filenames are rejected.""" """Empty filenames are rejected."""
from routers.files import sanitize_filename from src.backend.api.routers.files import sanitize_filename
from fastapi import HTTPException from fastapi import HTTPException
with pytest.raises(HTTPException) as exc_info: with pytest.raises(HTTPException) as exc_info:
@@ -3,9 +3,9 @@ import pytest
from httpx import AsyncClient from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from models.setting import SystemSetting from src.backend.models.orm.setting import SystemSetting
from models.user import User from src.backend.models.orm.user import User
from tests.conftest import auth_headers from src.backend.tests.conftest import auth_headers
class TestGetSettings: class TestGetSettings:
@@ -2,8 +2,8 @@
import pytest import pytest
from httpx import AsyncClient from httpx import AsyncClient
from config import settings from src.backend.config import settings
from tests.conftest import test_engine, TestSessionFactory 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 # setup.py imports engine and async_session_factory directly from database
# and uses them instead of get_db dependency injection. Patch them to use # and uses them instead of get_db dependency injection. Patch them to use
# the test in-memory SQLite engine/session instead of real MySQL. # 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, "engine", test_engine)
monkeypatch.setattr(setup_mod, "async_session_factory", TestSessionFactory) 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) await _seed(client)
from sqlalchemy import select, func from sqlalchemy import select, func
from models.measurement import Measurement from src.backend.models.orm.measurement import Measurement
result = await db_session.execute( result = await db_session.execute(
select(Measurement.pass_fail, func.count()).group_by(Measurement.pass_fail) 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) await _seed(client)
from sqlalchemy import select, func from sqlalchemy import select, func
from models.measurement import Measurement from src.backend.models.orm.measurement import Measurement
result = await db_session.execute( result = await db_session.execute(
select(func.count()).where(Measurement.lot_number.is_(None)) 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) await _seed(client)
from sqlalchemy import select, func from sqlalchemy import select, func
from models.measurement import Measurement from src.backend.models.orm.measurement import Measurement
result = await db_session.execute( result = await db_session.execute(
select(Measurement.input_method, func.count()).group_by(Measurement.input_method) 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.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from models.station import Station, StationRecipeAssignment from src.backend.models.orm.station import Station, StationRecipeAssignment
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(db_session: AsyncSession): async def test_create_station(db_session: AsyncSession):
@@ -2,7 +2,7 @@
import pytest import pytest
from pydantic import ValidationError from pydantic import ValidationError
from schemas.station import ( from src.backend.models.api.station import (
StationCreate, StationUpdate, StationResponse, StationCreate, StationUpdate, StationResponse,
StationRecipeAssignmentCreate, StationRecipeAssignmentResponse, StationRecipeAssignmentCreate, StationRecipeAssignmentResponse,
StationWithRecipesResponse, StationWithRecipesResponse,
@@ -18,10 +18,10 @@ from httpx import AsyncClient
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from config import settings from src.backend.config import settings
from models.station import Station, StationRecipeAssignment from src.backend.models.orm.station import Station, StationRecipeAssignment
from models.recipe import Recipe from src.backend.models.orm.recipe import Recipe
from tests.conftest import test_engine, TestSessionFactory from src.backend.tests.conftest import test_engine, TestSessionFactory
SETUP_PWD = "test-setup-pwd" SETUP_PWD = "test-setup-pwd"
@@ -31,7 +31,7 @@ SETUP_PWD = "test-setup-pwd"
def enable_setup(monkeypatch): def enable_setup(monkeypatch):
"""Enable setup endpoints and redirect engine/session to test SQLite DB.""" """Enable setup endpoints and redirect engine/session to test SQLite DB."""
monkeypatch.setattr(settings, "setup_password", SETUP_PWD) 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, "engine", test_engine)
monkeypatch.setattr(setup_mod, "async_session_factory", TestSessionFactory) monkeypatch.setattr(setup_mod, "async_session_factory", TestSessionFactory)
@@ -3,14 +3,14 @@ import pytest
from fastapi import HTTPException from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from models.station import Station from src.backend.models.orm.station import Station
from schemas.station import StationCreate, StationUpdate from src.backend.models.api.station import StationCreate, StationUpdate
from services.station_service import ( from src.backend.services.station_service import (
create_station, update_station, delete_station, create_station, update_station, delete_station,
assign_recipe, unassign_recipe, list_station_recipes, assign_recipe, unassign_recipe, list_station_recipes,
get_station_by_code, 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): 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): async def test_delete_station_cascades_assignments(db_session: AsyncSession):
from sqlalchemy import select 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) admin = await _create_user(db_session, username="a9", is_admin=True)
station = await create_station(db_session, StationCreate(code="ST-DEL", name="D"), admin) 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") r = await create_test_recipe(db_session, user_id=admin.id, code="REC-DEL")

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