Compare commits
10 Commits
ea8e4687b5
..
V2.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 85a00dea1b | |||
| e4eb4cd932 | |||
| 4de7d78b66 | |||
| 2a2d40bec9 | |||
| 6e284b0c0c | |||
| 742cc1fb58 | |||
| 563b7789f4 | |||
| e4b29c0b2d | |||
| 1a0431366f | |||
| 86df67f2e5 |
+38
-10
@@ -1,15 +1,43 @@
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.pyo
|
||||
.pytest_cache
|
||||
# Build context exclusions: keep image small and rebuilds fast.
|
||||
|
||||
# VCS
|
||||
.git
|
||||
.gitignore
|
||||
.env
|
||||
*.md
|
||||
node_modules
|
||||
.gitattributes
|
||||
|
||||
# Local envs and caches
|
||||
.venv
|
||||
venv
|
||||
*.egg-info
|
||||
.env
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov
|
||||
.mypy_cache
|
||||
htmlcov/
|
||||
.mypy_cache/
|
||||
*.egg-info
|
||||
|
||||
# IDE / editor
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*~
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
|
||||
# Local-only notes
|
||||
docs/
|
||||
*.docx
|
||||
|
||||
# Uploads are a runtime volume, never baked in.
|
||||
uploads/
|
||||
|
||||
# Claude Code
|
||||
.claude/
|
||||
.omc/
|
||||
|
||||
# Competitor analysis (local only)
|
||||
Concorrente/
|
||||
|
||||
+4
-1
@@ -26,7 +26,10 @@ API_SERVER_URL=http://localhost:8000
|
||||
STATION_CODE=ST-DEFAULT
|
||||
|
||||
# --- 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
|
||||
|
||||
# --- Setup Page ---
|
||||
|
||||
+15
-11
@@ -33,21 +33,24 @@ env/
|
||||
Thumbs.db
|
||||
desktop.ini
|
||||
|
||||
# Uploads (server-side files)
|
||||
server/uploads/images/*
|
||||
server/uploads/pdfs/*
|
||||
server/uploads/logos/*
|
||||
server/uploads/reports/*
|
||||
!server/uploads/images/.gitkeep
|
||||
!server/uploads/pdfs/.gitkeep
|
||||
!server/uploads/logos/.gitkeep
|
||||
!server/uploads/reports/.gitkeep
|
||||
# Uploads (server-side files, now at project root)
|
||||
uploads/images/*
|
||||
uploads/pdfs/*
|
||||
uploads/logos/*
|
||||
uploads/reports/*
|
||||
uploads/general/
|
||||
!uploads/images/.gitkeep
|
||||
!uploads/pdfs/.gitkeep
|
||||
!uploads/logos/.gitkeep
|
||||
!uploads/reports/.gitkeep
|
||||
|
||||
# TailwindCSS output
|
||||
client/static/css/tailwind.css
|
||||
src/frontend/flask_app/static/css/tailwind.css
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
src/frontend/flask_app/package.json
|
||||
src/frontend/flask_app/package-lock.json
|
||||
|
||||
# Flask-Babel compiled
|
||||
*.mo
|
||||
@@ -63,10 +66,11 @@ node_modules/
|
||||
htmlcov/
|
||||
|
||||
# Debug files
|
||||
client/static/js/fabric-debug.js
|
||||
src/frontend/flask_app/static/js/fabric-debug.js
|
||||
|
||||
# Misc
|
||||
nul
|
||||
|
||||
# Competitor analysis (local only)
|
||||
Concorrente/
|
||||
docker-compose.override.yml
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
3.11
|
||||
@@ -5,10 +5,65 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
## Panoramica
|
||||
|
||||
TieMeasureFlow by Tielogic - Sistema di gestione task per misurazioni con calibro manuale.
|
||||
Monorepo con **server FastAPI** (backend API, porta 8000) e **client Flask** (frontend tablet, porta 5000), orchestrati con Docker Compose + Nginx reverse proxy + MySQL 8.0.
|
||||
Monorepo con **backend FastAPI** (porta 8000) e **frontend Flask** (porta 5000), orchestrati con Docker Compose + Nginx reverse proxy + MySQL 8.0.
|
||||
|
||||
## Layout del repository (V2.0.0)
|
||||
|
||||
A partire dalla migrazione V2.0.0 (struttura conforme alla spec `python-project-spec-design.md`):
|
||||
|
||||
```
|
||||
TieMeasureFlow/
|
||||
├── pyproject.toml # Dipendenze monorepo (uv)
|
||||
├── uv.lock # Lock file riproducibile
|
||||
├── .python-version # 3.11
|
||||
├── Dockerfile # Backend (uv + uvicorn)
|
||||
├── Dockerfile.frontend # Frontend (uv + gunicorn + Tailwind + Babel)
|
||||
├── docker-compose.dev.yml # Dev (Nginx)
|
||||
├── docker-compose.yml # Prod (Traefik + SSL)
|
||||
├── nginx/
|
||||
├── uploads/ # Volume montato in /app/uploads
|
||||
├── docs/
|
||||
└── src/
|
||||
├── backend/
|
||||
│ ├── main.py # Entry FastAPI
|
||||
│ ├── config.py
|
||||
│ ├── database.py
|
||||
│ ├── api/
|
||||
│ │ ├── routers/ # 11 router REST
|
||||
│ │ └── middleware/ # api_key, rate_limit, security_headers, logging
|
||||
│ ├── models/
|
||||
│ │ ├── orm/ # SQLAlchemy
|
||||
│ │ └── api/ # Pydantic schemas
|
||||
│ ├── services/ # Logica business
|
||||
│ ├── migrations/ # Alembic
|
||||
│ ├── templates/ # Setup page
|
||||
│ └── tests/ # pytest
|
||||
└── frontend/
|
||||
└── flask_app/ # Flask + Jinja2 + Alpine.js (deroga vs spec React,
|
||||
├── app.py # giustificata: tablet UX server-side, USB calipers,
|
||||
├── config.py # workflow operatore con Fabric.js)
|
||||
├── blueprints/
|
||||
├── services/
|
||||
├── templates/
|
||||
├── static/
|
||||
├── translations/
|
||||
└── tests/
|
||||
```
|
||||
|
||||
Dipendenze gestite con **uv** (no `requirements.txt`):
|
||||
- `[project] dependencies` = core condivisi (pydantic, dotenv)
|
||||
- `[project.optional-dependencies] server` = backend FastAPI
|
||||
- `[project.optional-dependencies] client` = frontend Flask
|
||||
- `[project.optional-dependencies] dev` = pytest, httpx, aiosqlite
|
||||
|
||||
## Comandi di Sviluppo
|
||||
|
||||
### Setup iniziale
|
||||
```bash
|
||||
cp .env.example .env
|
||||
uv sync --extra server --extra client --extra dev # installa tutto
|
||||
```
|
||||
|
||||
### Avvio servizi (Docker)
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml up -d # Sviluppo (Nginx, porta 80)
|
||||
@@ -20,51 +75,63 @@ docker compose ps # Stato servizi
|
||||
|
||||
### Avvio manuale (senza Docker)
|
||||
```bash
|
||||
# Server (terminale 1)
|
||||
cd server && uvicorn main:app --reload --host 0.0.0.0 --port 8000
|
||||
# Backend (terminale 1)
|
||||
uv run uvicorn src.backend.main:app --reload --host 0.0.0.0 --port 8000
|
||||
|
||||
# Client (terminale 2)
|
||||
cd client && flask run --host 0.0.0.0 --port 5000
|
||||
# Frontend (terminale 2) — gunicorn richiede cwd interna per app:create_app()
|
||||
cd src/frontend/flask_app && uv run --project ../../.. gunicorn --bind 0.0.0.0:5000 app:create_app()
|
||||
|
||||
# TailwindCSS watch (terminale 3)
|
||||
cd client && npx tailwindcss -i static/css/input.css -o static/css/tailwind.css --watch
|
||||
cd src/frontend/flask_app && npx tailwindcss -i static/css/input.css -o static/css/tailwind.css --watch
|
||||
```
|
||||
|
||||
### Database & Migrations
|
||||
```bash
|
||||
# alembic.ini è in server/migrations/, serve il flag -c
|
||||
cd server && alembic -c migrations/alembic.ini upgrade head # Applica migrazioni
|
||||
cd server && alembic -c migrations/alembic.ini revision --autogenerate -m "descrizione" # Genera migrazione
|
||||
cd server && alembic -c migrations/alembic.ini downgrade -1 # Rollback ultima
|
||||
docker compose exec server alembic -c migrations/alembic.ini upgrade head # Via Docker
|
||||
# alembic.ini in src/backend/migrations/, serve il flag -c
|
||||
uv run alembic -c src/backend/migrations/alembic.ini upgrade head # Applica migrazioni
|
||||
uv run alembic -c src/backend/migrations/alembic.ini revision --autogenerate -m "descrizione" # Genera
|
||||
uv run alembic -c src/backend/migrations/alembic.ini downgrade -1 # Rollback
|
||||
docker compose exec server uv run alembic -c src/backend/migrations/alembic.ini upgrade head # Via Docker
|
||||
```
|
||||
Nota: `env.py` sovrascrive la URL di alembic.ini con quella da `.env` (`settings.database_url`). `script_location = %(here)s` usa path relativo.
|
||||
Nota: `env.py` aggiunge la project root a `sys.path`, sovrascrive la URL di alembic.ini con quella da `.env` (`settings.database_url`). `script_location = %(here)s` usa path relativo.
|
||||
|
||||
### Test
|
||||
```bash
|
||||
# Server (usa SQLite in-memory via aiosqlite, no MySQL richiesto)
|
||||
cd server && pytest # Tutti i test
|
||||
cd server && pytest tests/test_auth.py # Singolo modulo
|
||||
cd server && pytest tests/test_auth.py::test_login_success # Singolo test
|
||||
cd server && pytest --cov # Con copertura
|
||||
# Tutti i test (backend + frontend)
|
||||
uv run pytest
|
||||
|
||||
# Client
|
||||
cd client && pytest
|
||||
cd client && pytest tests/test_auth.py
|
||||
# Solo backend (SQLite in-memory via aiosqlite, no MySQL richiesto)
|
||||
uv run pytest src/backend/tests/
|
||||
uv run pytest src/backend/tests/test_auth.py
|
||||
uv run pytest src/backend/tests/test_auth.py::test_login_success
|
||||
uv run pytest --cov src/backend
|
||||
|
||||
# Solo frontend
|
||||
uv run pytest src/frontend/flask_app/tests/
|
||||
```
|
||||
|
||||
### Gestione dipendenze (uv)
|
||||
```bash
|
||||
uv add <pacchetto> # core (entrambi)
|
||||
uv add --optional server <pacchetto> # solo backend
|
||||
uv add --optional client <pacchetto> # solo frontend
|
||||
uv add --optional dev <pacchetto> # solo dev/test
|
||||
uv sync --extra server --extra client --extra dev # reinstalla
|
||||
uv lock # rigenera uv.lock
|
||||
```
|
||||
|
||||
### i18n (Traduzioni)
|
||||
```bash
|
||||
# Estrai stringhe
|
||||
cd client && pybabel extract -F babel.cfg -k _ -o translations/messages.pot .
|
||||
cd src/frontend/flask_app && uv run pybabel extract -F babel.cfg -k _ -o translations/messages.pot .
|
||||
|
||||
# Aggiorna catalogo
|
||||
cd client && pybabel update -i translations/messages.pot -d translations
|
||||
cd src/frontend/flask_app && uv run pybabel update -i translations/messages.pot -d translations
|
||||
|
||||
# Compila .po → .mo
|
||||
cd client && pybabel compile -d translations
|
||||
cd src/frontend/flask_app && uv run pybabel compile -d translations
|
||||
# oppure
|
||||
cd client && python compile_translations.py
|
||||
cd src/frontend/flask_app && uv run python compile_translations.py
|
||||
```
|
||||
|
||||
### Setup iniziale
|
||||
@@ -78,7 +145,7 @@ Browser/Tablet → Nginx (:80/443) → Flask Client (:5000) → APIClient → Fa
|
||||
```
|
||||
Il client Flask è un frontend server-side che comunica col backend via REST API. Ogni richiesta dal client al server include l'header `X-API-Key` per autenticazione.
|
||||
|
||||
### Server (FastAPI) — `server/`
|
||||
### Server (FastAPI) — `src/backend/`
|
||||
- **main.py**: entry point, lifespan async (`@asynccontextmanager`), registra middleware e 10 router. Health: `GET /api/health`
|
||||
- **config.py**: `Settings` (pydantic_settings.BaseSettings), legge da `../.env`. Rate limits: login 5/min, general 100/min
|
||||
- **database.py**: SQLAlchemy 2.0 async engine con `AsyncSession`, pool 10+20 overflow, `pool_recycle=3600`, `expire_on_commit=False`
|
||||
@@ -87,10 +154,10 @@ Il client Flask è un frontend server-side che comunica col backend via REST API
|
||||
- **middleware/**: Stack order (outermost→innermost): AccessLogMiddleware → CORSMiddleware → SecurityHeadersMiddleware → RateLimitMiddleware. Nota: `add_middleware()` in Starlette wrappa l'app, quindi l'ultimo aggiunto (AccessLog) è il più esterno. Il commento in `main.py` dice "outermost" per RateLimit ma è fuorviante. api_key.py (auth dependency `get_current_user()`), rate_limit.py (sliding window 60s per-IP, in-memory dicts), security_headers.py (CSP con `unsafe-eval` per Plotly.js, HSTS solo con SSL), logging.py (audit trail async su DB, esclude /api/health, /docs, /openapi.json, /redoc)
|
||||
- **models/**: User (include `email`, `language_pref`, `theme_pref`), Recipe (`image_path` per preview), RecipeVersion, RecipeTask, RecipeSubtask (`image_path` per immagine specifica), Measurement (`synced_to_csv`, `input_method`), AccessLog, SystemSetting, RecipeVersionAudit
|
||||
- **schemas/**: Pydantic v2 per validazione I/O API
|
||||
- **migrations/**: Alembic con `alembic.ini` e `env.py` nella directory `server/migrations/`
|
||||
- **migrations/**: Alembic con `alembic.ini` e `env.py` nella directory `src/backend/migrations/`
|
||||
- **tests/**: pytest + pytest-asyncio, SQLite in-memory (`sqlite+aiosqlite://`, StaticPool), WeasyPrint mockato via `sys.modules`, rate limit reset tra test
|
||||
|
||||
### Client (Flask) — `client/`
|
||||
### Client (Flask) — `src/frontend/flask_app/`
|
||||
- **app.py**: factory pattern `create_app()`, CSRF (`WTF_CSRF_TIME_LIMIT=3600`), Babel i18n (`default_locale="it"`)
|
||||
- **blueprints/**: auth (login/logout/session), maker (editor ricette con Fabric.js), measure (esecuzione misurazioni), statistics (dashboard SPC con Plotly.js), admin (gestione utenti CRUD, cambio password, toggle attivo — solo `is_admin`)
|
||||
- **services/api_client.py**: singleton `APIClient` — wrapper HTTP (get/post/put/delete) con gestione errori normalizzata, timeout 30s, header X-API-Key da session
|
||||
@@ -99,7 +166,7 @@ Il client Flask è un frontend server-side che comunica col backend via REST API
|
||||
- **translations/**: Flask-Babel, cataloghi .po/.mo per IT/EN. Locale selector: `session["language"]` → Accept-Language → `"it"`
|
||||
- **config.py**: `PERMANENT_SESSION_LIFETIME=28800` (8h), cookie secure in produzione, `BABEL_DEFAULT_TIMEZONE="Europe/Rome"`
|
||||
|
||||
## Template Structure (`client/templates/base.html`)
|
||||
## Template Structure (`src/frontend/flask_app/templates/base.html`)
|
||||
|
||||
Ordine blocchi in `base.html`:
|
||||
```
|
||||
@@ -127,7 +194,7 @@ In alternativa, usare il filtro custom `|tojson_attr` (registrato in `app.py`) c
|
||||
|
||||
Per selettori CSS in `x-data`: usare `meta[name=csrf-token]` senza virgolette interne.
|
||||
|
||||
## Fabric.js Annotation Editor (`client/static/js/annotation-editor.js`)
|
||||
## Fabric.js Annotation Editor (`src/frontend/flask_app/static/js/annotation-editor.js`)
|
||||
|
||||
Editor annotazioni su disegni tecnici (Fabric.js 5.3.1, ~1200 righe). Pattern critici:
|
||||
|
||||
@@ -152,16 +219,16 @@ Le ricette usano un versioning condizionale. L'endpoint `PUT /api/recipes/{id}`
|
||||
|
||||
La stessa logica si applica nel task router: aggiungere un task a una ricetta con measurements crea una nuova versione.
|
||||
|
||||
La versione corrente ha `is_current=True`, le precedenti `False`. Audit trail in `recipe_version_audit` (CREATE, UPDATE, ACTIVATE, RETIRE). Logica in `server/services/recipe_service.py`.
|
||||
La versione corrente ha `is_current=True`, le precedenti `False`. Audit trail in `recipe_version_audit` (CREATE, UPDATE, ACTIVATE, RETIRE). Logica in `src/backend/services/recipe_service.py`.
|
||||
|
||||
### Calcolo Pass/Fail
|
||||
Ogni subtask ha 4 limiti di tolleranza: UTL (upper tolerance), UWL (upper warning), LWL (lower warning), LTL (lower tolerance) più un valore nominale. Il calcolo in `server/services/measurement_service.py`:
|
||||
Ogni subtask ha 4 limiti di tolleranza: UTL (upper tolerance), UWL (upper warning), LWL (lower warning), LTL (lower tolerance) più un valore nominale. Il calcolo in `src/backend/services/measurement_service.py`:
|
||||
- Fuori UTL/LTL → **fail**
|
||||
- Fuori UWL/LWL ma dentro UTL/LTL → **warning**
|
||||
- Dentro UWL/LWL → **pass**
|
||||
|
||||
### SPC (Statistical Process Control)
|
||||
Calcoli in `server/services/spc_service.py` usando solo `math` e `statistics` stdlib (no numpy/scipy): summary (conteggi pass/warning/fail), capability (Cp, Cpk, Pp, Ppk), control chart (UCL/LCL = mean ± 3σ), histogram (20 bin + curva normale).
|
||||
Calcoli in `src/backend/services/spc_service.py` usando solo `math` e `statistics` stdlib (no numpy/scipy): summary (conteggi pass/warning/fail), capability (Cp, Cpk, Pp, Ppk), control chart (UCL/LCL = mean ± 3σ), histogram (20 bin + curva normale).
|
||||
|
||||
### Autenticazione
|
||||
1. Login con username/password → server ritorna `api_key` (64 char random)
|
||||
@@ -187,7 +254,7 @@ Upload in `uploads/{recipe_id}/{version_id}/`. Tipi ammessi: JPEG, PNG, GIF, Web
|
||||
|
||||
## Test Infrastructure
|
||||
|
||||
### Server (`server/tests/conftest.py`)
|
||||
### Server (`src/backend/tests/conftest.py`)
|
||||
- SQLite in-memory `sqlite+aiosqlite://` con `StaticPool` (singola connessione condivisa tra fixture, app e test)
|
||||
- WeasyPrint mockato prima di qualsiasi import server: `sys.modules["weasyprint"] = MagicMock()`
|
||||
- Rate limit buckets resettati tra test (walk middleware stack → clear dicts)
|
||||
@@ -196,7 +263,7 @@ Upload in `uploads/{recipe_id}/{version_id}/`. Tipi ammessi: JPEG, PNG, GIF, Web
|
||||
- Helper: `auth_headers(user)` → `{"X-API-Key": user.api_key}`
|
||||
- Client httpx: `AsyncClient` con `ASGITransport(app=app)`, override di `get_db` dependency
|
||||
|
||||
### Client (`client/tests/conftest.py`)
|
||||
### Client (`src/frontend/flask_app/tests/conftest.py`)
|
||||
- `api_client` patchato in 4 blueprint (auth, maker, measure, statistics). **admin NON è patchato** — i test admin devono gestire il mock manualmente
|
||||
- `logged_in_client` fixture pre-popola session con `api_key`, `user_id`, `language`, `theme` + user dict
|
||||
- CSRF disabilitato nei test: `WTF_CSRF_ENABLED=False`
|
||||
@@ -232,7 +299,7 @@ Variabili d'ambiente in `.env` (copiare da `.env.example`):
|
||||
- DB: `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD`, `DB_ROOT_PASSWORD` (Docker)
|
||||
- Server: `SERVER_HOST`, `SERVER_PORT`, `SERVER_SECRET_KEY`, `SERVER_CORS_ORIGINS`
|
||||
- Client: `CLIENT_HOST`, `CLIENT_PORT`, `CLIENT_SECRET_KEY`, `API_SERVER_URL`
|
||||
- Upload: `UPLOAD_DIR` (default `"uploads"`, relativo a `server/`), `MAX_UPLOAD_SIZE_MB`
|
||||
- Upload: `UPLOAD_DIR` (default `"uploads"`, relativo a `src/backend/`), `MAX_UPLOAD_SIZE_MB`
|
||||
- Docker: `NGINX_PORT`, `NGINX_SSL_PORT`
|
||||
- Setup: `SETUP_PASSWORD` (vuota = endpoint disabilitato)
|
||||
- SSL: `SSL_CERTFILE`, `SSL_KEYFILE`
|
||||
|
||||
+40
@@ -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='*'"]
|
||||
@@ -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()"]
|
||||
@@ -1,6 +1,9 @@
|
||||
# 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)
|
||||
- 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
|
||||
- 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
|
||||
- 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)
|
||||
|
|
||||
Flask Client :5000 (rendering server-side, Jinja2 + Alpine.js)
|
||||
| X-API-Key
|
||||
FastAPI Server :8000 (API REST asincrona)
|
||||
Flask Frontend :5000 (rendering server-side, Jinja2 + Alpine.js)
|
||||
| X-API-Key + X-Forwarded-For
|
||||
FastAPI Backend :8000 (API REST asincrona)
|
||||
|
|
||||
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
|
||||
|
||||
### Backend (server/)
|
||||
### Backend (`src/backend/`)
|
||||
|
||||
| Componente | Versione | Ruolo |
|
||||
|---|---|---|
|
||||
| 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 |
|
||||
| MySQL | 8.0 | Database relazionale |
|
||||
| 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 |
|
||||
| Pillow | ultima stabile | Thumbnail automatici upload |
|
||||
|
||||
### Frontend (client/)
|
||||
### Frontend (`src/frontend/flask_app/`)
|
||||
|
||||
| Componente | Versione | Ruolo |
|
||||
|---|---|---|
|
||||
| Flask | 3.x | Framework web server-side |
|
||||
| gunicorn | 21+ | WSGI server (5 workers × 4 thread gthread) |
|
||||
| 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 |
|
||||
| Plotly.js | CDN | Grafici SPC interattivi |
|
||||
| Fabric.js | 5.3.1 (CDN) | Editor annotazioni disegni tecnici |
|
||||
| html5-qrcode | CDN | Scanner barcode/QR camera |
|
||||
| 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
|
||||
|
||||
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
|
||||
# 1. Clona il repository
|
||||
git clone <repository-url>
|
||||
git clone ssh://git@git.tielogic.xyz:222/Adriano/TieMeasureFlow.git
|
||||
cd TieMeasureFlow
|
||||
|
||||
# 2. Configura le variabili d'ambiente
|
||||
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)
|
||||
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
|
||||
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)
|
||||
# Apri http://localhost/api/setup nel browser
|
||||
# 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:
|
||||
@@ -97,8 +115,9 @@ L'applicazione sarà disponibile su:
|
||||
- Frontend: http://localhost
|
||||
- API: http://localhost/api
|
||||
- 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
|
||||
- **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)
|
||||
- **Gestione utenti** — crea, modifica, attiva/disattiva account dalla stessa pagina
|
||||
|
||||
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)
|
||||
@@ -121,8 +142,9 @@ Se `SETUP_PASSWORD` è vuota o assente nel `.env`, l'endpoint è disabilitato.
|
||||
### Requisiti
|
||||
|
||||
- Python 3.11 o superiore
|
||||
- Node.js 18 o superiore
|
||||
- Node.js 18 o superiore (per TailwindCSS)
|
||||
- MySQL 8.0
|
||||
- [uv](https://docs.astral.sh/uv/) installato
|
||||
|
||||
### 1. Database MySQL
|
||||
|
||||
@@ -139,35 +161,37 @@ SQL
|
||||
|
||||
```bash
|
||||
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
|
||||
cd server
|
||||
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
|
||||
uv sync --extra server --extra client --extra dev
|
||||
```
|
||||
|
||||
### 4. Client Flask
|
||||
### 4. Backend FastAPI
|
||||
|
||||
```bash
|
||||
cd client
|
||||
python -m venv venv
|
||||
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
|
||||
uv run alembic -c src/backend/migrations/alembic.ini upgrade head
|
||||
uv run uvicorn src.backend.main:app --reload --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
### 5. TailwindCSS (watch per sviluppo)
|
||||
### 5. Frontend Flask
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
@@ -185,9 +209,9 @@ I ruoli sono combinabili (array JSON per utente). Il flag `is_admin` è separato
|
||||
| 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 |
|
||||
| **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 |
|
||||
| **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/
|
||||
├── server/ # FastAPI Backend
|
||||
│ ├── main.py # Entry point, lifespan, middleware, 10 router
|
||||
│ ├── config.py # Settings (pydantic_settings.BaseSettings)
|
||||
│ ├── database.py # SQLAlchemy 2.0 async engine
|
||||
│ ├── models/ # ORM: User, Recipe, RecipeVersion, RecipeTask,
|
||||
│ │ # RecipeSubtask, Measurement, AccessLog,
|
||||
│ │ # SystemSetting, RecipeVersionAudit
|
||||
│ ├── schemas/ # Pydantic v2 per validazione I/O API
|
||||
│ ├── routers/ # auth, users, recipes, tasks, measurements,
|
||||
│ │ # files, settings, statistics, reports, setup
|
||||
│ ├── services/ # recipe_service, measurement_service,
|
||||
│ │ # spc_service, report_service, auth_service
|
||||
│ ├── middleware/ # api_key, rate_limit, security_headers, logging
|
||||
│ ├── migrations/ # Alembic (alembic.ini + env.py)
|
||||
│ └── templates/ # Template HTML pagina setup
|
||||
├── client/ # Flask Frontend
|
||||
│ ├── app.py # Factory pattern, CSRF, Babel
|
||||
│ ├── blueprints/ # auth, maker, measure, statistics, admin
|
||||
│ ├── services/ # APIClient singleton (proxy verso FastAPI)
|
||||
│ ├── templates/ # Jinja2 + Alpine.js
|
||||
│ ├── static/
|
||||
│ │ ├── css/ # TailwindCSS compilato
|
||||
│ │ └── js/ # numpad, caliper, barcode, csv-export,
|
||||
│ │ # spc-charts, annotation-editor/viewer
|
||||
│ └── translations/ # Flask-Babel .po/.mo IT/EN
|
||||
├── nginx/ # Configurazione Nginx (dev)
|
||||
├── docs/ # Documentazione tecnica
|
||||
│ ├── API.md # Riferimento API REST
|
||||
│ ├── DEPLOYMENT.md # Guida deployment VPS (Traefik, SSL, DNS)
|
||||
│ └── USER_GUIDE.md # Manuale utente per ruolo
|
||||
├── docker-compose.yml # Produzione (Traefik, SSL)
|
||||
├── docker-compose.dev.yml # Sviluppo (Nginx, porta 80)
|
||||
└── .env.example # Template variabili d'ambiente
|
||||
├── pyproject.toml # Dipendenze monorepo (uv)
|
||||
├── 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)
|
||||
│ ├── database.py # SQLAlchemy 2.0 async engine
|
||||
│ ├── api/
|
||||
│ │ ├── routers/ # auth, users, recipes, tasks, measurements,
|
||||
│ │ │ # files, settings, statistics, reports,
|
||||
│ │ │ # setup, stations
|
||||
│ │ └── middleware/ # api_key, rate_limit, security_headers, logging
|
||||
│ ├── 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,
|
||||
│ │ # spc_service, report_service,
|
||||
│ │ # auth_service, station_service
|
||||
│ ├── migrations/ # Alembic (alembic.ini + env.py)
|
||||
│ ├── templates/ # Pagina setup (Jinja2)
|
||||
│ └── tests/ # pytest + httpx + aiosqlite
|
||||
└── frontend/
|
||||
└── flask_app/ # Flask Frontend
|
||||
├── app.py # Factory + ProxyFix + CSRF + Babel
|
||||
├── config.py # STATION_CODE, API_SERVER_URL, ecc.
|
||||
├── compile_translations.py
|
||||
├── blueprints/ # auth, maker, measure, statistics, admin
|
||||
├── services/ # APIClient (proxy verso FastAPI con XFF)
|
||||
├── templates/ # Jinja2 + Alpine.js
|
||||
├── static/
|
||||
│ ├── css/ # TailwindCSS compilato
|
||||
│ └── js/ # numpad, caliper, barcode, csv-export,
|
||||
│ # spc-charts, annotation-editor/viewer
|
||||
├── translations/ # Flask-Babel .po/.mo IT/EN
|
||||
└── tests/
|
||||
```
|
||||
|
||||
---
|
||||
@@ -238,7 +276,7 @@ TieMeasureFlow/
|
||||
|
||||
| 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 logs -f server` | Segui log server in tempo reale |
|
||||
| `docker compose logs -f client` | Segui log client in tempo reale |
|
||||
@@ -248,47 +286,62 @@ TieMeasureFlow/
|
||||
|
||||
### 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
|
||||
cd server
|
||||
alembic -c migrations/alembic.ini upgrade head # Applica migrazioni
|
||||
alembic -c migrations/alembic.ini revision --autogenerate -m "descrizione" # Genera migrazione
|
||||
alembic -c migrations/alembic.ini downgrade -1 # Rollback ultima
|
||||
uv run alembic -c src/backend/migrations/alembic.ini upgrade head # Applica migrazioni
|
||||
uv run alembic -c src/backend/migrations/alembic.ini revision --autogenerate -m "descrizione" # Genera
|
||||
uv run alembic -c src/backend/migrations/alembic.ini downgrade -1 # Rollback ultima
|
||||
```
|
||||
|
||||
Via Docker:
|
||||
|
||||
```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)
|
||||
|
||||
```bash
|
||||
cd client
|
||||
pybabel extract -F babel.cfg -k _ -o translations/messages.pot . # Estrai stringhe
|
||||
pybabel update -i translations/messages.pot -d translations # Aggiorna catalogo
|
||||
pybabel compile -d translations # Compila .po → .mo
|
||||
cd src/frontend/flask_app
|
||||
uv run --project ../../.. pybabel extract -F babel.cfg -k _ -o translations/messages.pot . # Estrai
|
||||
uv run --project ../../.. pybabel update -i translations/messages.pot -d translations # Aggiorna
|
||||
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
|
||||
|
||||
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
|
||||
# Server
|
||||
cd server && pytest # Tutti i test
|
||||
cd server && pytest tests/test_auth.py # Singolo modulo
|
||||
cd server && pytest tests/test_auth.py::test_login_success # Singolo test
|
||||
cd server && pytest --cov # Con copertura
|
||||
# Tutti i test (backend + frontend)
|
||||
uv run pytest
|
||||
|
||||
# Client
|
||||
cd client && pytest
|
||||
# Solo backend
|
||||
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
|
||||
@@ -304,8 +357,11 @@ Copia `.env.example` in `.env` e configura:
|
||||
| `SERVER_CORS_ORIGINS` | Origini CORS ammesse |
|
||||
| `CLIENT_SECRET_KEY` | Chiave segreta Flask (sessioni, CSRF) |
|
||||
| `API_SERVER_URL` | URL del backend visto dal client (es. `http://server:8000`) |
|
||||
| `UPLOAD_DIR` | Percorso upload file (default: `uploads`, relativo a `server/`) |
|
||||
| `MAX_UPLOAD_SIZE_MB` | Limite dimensione upload |
|
||||
| `STATION_CODE` | **Per-tablet** — codice stazione (es. `ST-001`). Senza, il client mostra errore configurazione. |
|
||||
| `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) |
|
||||
| `SETUP_PASSWORD` | Password pagina setup (vuota = endpoint disabilitato) |
|
||||
| `SSL_CERTFILE`, `SSL_KEYFILE` | Certificato SSL (solo setup manuale) |
|
||||
@@ -314,16 +370,35 @@ Copia `.env.example` in `.env` e configura:
|
||||
|
||||
## Documentazione
|
||||
|
||||
Indice completo: [`docs/README.md`](docs/README.md).
|
||||
|
||||
### Stato e direzione
|
||||
|
||||
| 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/architecture/STATO_PROGETTO.md`](docs/architecture/STATO_PROGETTO.md) | Snapshot V2.0.0: cosa funziona oggi, test status, decisioni architetturali |
|
||||
| [`docs/architecture/ROADMAP.md`](docs/architecture/ROADMAP.md) | Cosa resta da fare (Fasi 2-7 rev04, decisioni cliente aperte, stime) |
|
||||
|
||||
### 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
|
||||
|
||||
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.
|
||||
|
||||
@@ -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()"]
|
||||
@@ -1,4 +0,0 @@
|
||||
[pytest]
|
||||
testpaths = tests
|
||||
python_files = test_*.py
|
||||
python_functions = test_*
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -24,7 +24,7 @@ services:
|
||||
|
||||
server:
|
||||
build:
|
||||
context: ./server
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: tmflow-server
|
||||
restart: unless-stopped
|
||||
@@ -43,8 +43,8 @@ services:
|
||||
|
||||
client:
|
||||
build:
|
||||
context: ./client
|
||||
dockerfile: Dockerfile
|
||||
context: .
|
||||
dockerfile: Dockerfile.frontend
|
||||
container_name: tmflow-client
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
|
||||
+3
-3
@@ -24,7 +24,7 @@ services:
|
||||
|
||||
server:
|
||||
build:
|
||||
context: ./server
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: tmflow-server
|
||||
restart: unless-stopped
|
||||
@@ -52,8 +52,8 @@ services:
|
||||
|
||||
client:
|
||||
build:
|
||||
context: ./client
|
||||
dockerfile: Dockerfile
|
||||
context: .
|
||||
dockerfile: Dockerfile.frontend
|
||||
container_name: tmflow-client
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
|
||||
+184
@@ -23,6 +23,7 @@ TieMeasureFlow provides a REST API built with FastAPI for managing measurement t
|
||||
- [Measurements](#measurements)
|
||||
- [Files](#files)
|
||||
- [Settings](#settings)
|
||||
- [Stations](#stations)
|
||||
- [Statistics](#statistics)
|
||||
- [Reports](#reports)
|
||||
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
|
||||
|
||||
All statistics endpoints **require Metrologist role**.
|
||||
|
||||
+4
-3
@@ -134,10 +134,11 @@ SSL_KEYFILE=
|
||||
| `CLIENT_HOST` | string | 0.0.0.0 | Flask client bind address |
|
||||
| `CLIENT_PORT` | int | 5000 | Flask client port |
|
||||
| `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 |
|
||||
| `RATE_LIMIT_LOGIN` | int | 5 | Login requests per minute |
|
||||
| `RATE_LIMIT_GENERAL` | int | 100 | General requests per minute |
|
||||
| `RATE_LIMIT_LOGIN` | int | 5 | Login requests per minute, per real client IP |
|
||||
| `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_KEYFILE` | string | (empty) | Path to SSL private key (production) |
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ TieMeasureFlow supports complete internationalization (i18n) with:
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
client/
|
||||
src/frontend/flask_app/
|
||||
├── translations/ # Flask-Babel translations
|
||||
│ ├── babel.cfg # Extraction config
|
||||
│ ├── it/LC_MESSAGES/
|
||||
@@ -99,7 +99,7 @@ flash(_("Profilo aggiornato con successo"))
|
||||
After editing .po files:
|
||||
|
||||
```bash
|
||||
cd client
|
||||
cd src/frontend/flask_app
|
||||
python compile_translations.py
|
||||
```
|
||||
|
||||
@@ -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
@@ -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) |
|
||||
| **Measurement** | Individual recorded value with automatic pass/fail/warning status |
|
||||
| **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
|
||||
|
||||
@@ -175,23 +177,25 @@ TieMeasureFlow has four primary roles. Users can have multiple roles simultaneou
|
||||
|
||||
### Admin
|
||||
|
||||
**Purpose:** System administration and user management
|
||||
**Purpose:** System administration, user management and station deployment
|
||||
|
||||
**Permissions:**
|
||||
- Create/edit/delete users
|
||||
- Assign roles to users
|
||||
- Regenerate user API keys
|
||||
- Create/edit/delete stations and assign recipes to them
|
||||
- Configure system settings
|
||||
- Upload company logo
|
||||
- Manage CSV export settings
|
||||
|
||||
**Access:**
|
||||
- Menu: **Admin** → User Management
|
||||
- Can see: All users and system settings
|
||||
- Menu: **Admin** → Utenti / Stazioni
|
||||
- Can see: All users, all stations and system settings
|
||||
|
||||
**Typical Tasks:**
|
||||
- Onboard new users
|
||||
- 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
|
||||
- Upload company branding
|
||||
- Manage system access
|
||||
@@ -276,6 +280,12 @@ When you edit a recipe (add/remove tasks, change tolerances), a **new version**
|
||||
|
||||
## 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
|
||||
|
||||
**Method 1: Search**
|
||||
@@ -503,6 +513,82 @@ If user loses their API key:
|
||||
4. New key is generated and displayed
|
||||
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
|
||||
|
||||
#### Configure CSV Export
|
||||
|
||||
@@ -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.
|
||||
@@ -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 `"..."` 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.
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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."""
|
||||
from middleware.api_key import (
|
||||
from src.backend.api.middleware.api_key import (
|
||||
get_current_user,
|
||||
require_role,
|
||||
require_admin,
|
||||
@@ -8,9 +8,9 @@ from middleware.api_key import (
|
||||
require_metrologist,
|
||||
require_admin_user,
|
||||
)
|
||||
from middleware.logging import AccessLogMiddleware
|
||||
from middleware.rate_limit import RateLimitMiddleware
|
||||
from middleware.security_headers import SecurityHeadersMiddleware
|
||||
from src.backend.api.middleware.logging import AccessLogMiddleware
|
||||
from src.backend.api.middleware.rate_limit import RateLimitMiddleware
|
||||
from src.backend.api.middleware.security_headers import SecurityHeadersMiddleware
|
||||
|
||||
__all__ = [
|
||||
"get_current_user",
|
||||
@@ -3,8 +3,8 @@ from fastapi import Depends, HTTPException, Request, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database import get_db
|
||||
from models.user import User
|
||||
from src.backend.database import get_db
|
||||
from src.backend.models.orm.user import User
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
@@ -6,8 +6,8 @@ from fastapi import Request, Response
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from sqlalchemy import insert
|
||||
|
||||
from database import async_session_factory
|
||||
from models.access_log import AccessLog
|
||||
from src.backend.database import async_session_factory
|
||||
from src.backend.models.orm.access_log import AccessLog
|
||||
|
||||
|
||||
class AccessLogMiddleware(BaseHTTPMiddleware):
|
||||
@@ -11,7 +11,7 @@ from fastapi import Request, Response
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
from config import settings
|
||||
from src.backend.config import settings
|
||||
|
||||
|
||||
class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||
@@ -32,6 +32,25 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||
self._general_requests: dict[str, list[float]] = defaultdict(list)
|
||||
self._request_count = 0 # Counter for triggering eviction
|
||||
|
||||
@staticmethod
|
||||
def _client_ip(request: Request) -> str:
|
||||
"""Resolve the originating client IP, honoring proxy headers.
|
||||
|
||||
Order of precedence: ``X-Forwarded-For`` (first hop), ``X-Real-IP``,
|
||||
``request.client.host``. Required because Nginx and the Flask client
|
||||
sit between the tablet and the API; without parsing these headers
|
||||
every tablet shares one bucket.
|
||||
"""
|
||||
xff = request.headers.get("x-forwarded-for")
|
||||
if xff:
|
||||
first = xff.split(",")[0].strip()
|
||||
if first:
|
||||
return first
|
||||
real = request.headers.get("x-real-ip")
|
||||
if real:
|
||||
return real.strip()
|
||||
return request.client.host if request.client else "unknown"
|
||||
|
||||
def _clean_window(self, timestamps: list[float], now: float) -> list[float]:
|
||||
"""Remove timestamps outside the current sliding window."""
|
||||
cutoff = now - self.WINDOW_SECONDS
|
||||
@@ -68,7 +87,7 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||
return True, 0
|
||||
|
||||
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
client_ip = self._client_ip(request)
|
||||
now = time.time()
|
||||
path = request.url.path
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ from typing import Callable
|
||||
from fastapi import Request, Response
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
from config import settings
|
||||
from src.backend.config import settings
|
||||
|
||||
# Content Security Policy - allows CDN resources used by the client
|
||||
# Note: 'unsafe-eval' required for Plotly.js runtime evaluation in SPC charts
|
||||
@@ -2,16 +2,16 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database import get_db
|
||||
from middleware.api_key import get_current_user
|
||||
from models.user import User
|
||||
from schemas.user import (
|
||||
from src.backend.database import get_db
|
||||
from src.backend.api.middleware.api_key import get_current_user
|
||||
from src.backend.models.orm.user import User
|
||||
from src.backend.models.api.user import (
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
UserProfileUpdate,
|
||||
UserResponse,
|
||||
)
|
||||
from services.auth_service import authenticate_user, login_user, logout_user
|
||||
from src.backend.services.auth_service import authenticate_user, login_user, logout_user
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
@@ -7,9 +7,9 @@ from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
|
||||
from fastapi.responses import FileResponse
|
||||
from PIL import Image
|
||||
|
||||
from config import settings
|
||||
from middleware.api_key import get_current_user, require_maker
|
||||
from models.user import User
|
||||
from src.backend.config import settings
|
||||
from src.backend.api.middleware.api_key import get_current_user, require_maker
|
||||
from src.backend.models.orm.user import User
|
||||
|
||||
router = APIRouter(prefix="/api/files", tags=["files"])
|
||||
|
||||
@@ -8,23 +8,23 @@ from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy import and_, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database import get_db
|
||||
from middleware.api_key import (
|
||||
from src.backend.database import get_db
|
||||
from src.backend.api.middleware.api_key import (
|
||||
get_current_user,
|
||||
require_measurement_tec,
|
||||
require_metrologist,
|
||||
)
|
||||
from models.measurement import Measurement
|
||||
from models.recipe import RecipeVersion
|
||||
from models.setting import SystemSetting
|
||||
from models.user import User
|
||||
from schemas.measurement import (
|
||||
from src.backend.models.orm.measurement import Measurement
|
||||
from src.backend.models.orm.recipe import RecipeVersion
|
||||
from src.backend.models.orm.setting import SystemSetting
|
||||
from src.backend.models.orm.user import User
|
||||
from src.backend.models.api.measurement import (
|
||||
MeasurementBatchCreate,
|
||||
MeasurementCreate,
|
||||
MeasurementListResponse,
|
||||
MeasurementResponse,
|
||||
)
|
||||
from services.measurement_service import save_measurement
|
||||
from src.backend.services.measurement_service import save_measurement
|
||||
|
||||
router = APIRouter(prefix="/api/measurements", tags=["measurements"])
|
||||
|
||||
@@ -97,10 +97,17 @@ async def get_measurements(
|
||||
pass_fail: str | None = Query(None, pattern="^(pass|warning|fail)$"),
|
||||
page: int = Query(1, ge=1),
|
||||
per_page: int = Query(50, ge=1, le=500),
|
||||
user: User = Depends(require_metrologist),
|
||||
user: User = Depends(get_current_user),
|
||||
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
|
||||
filters = []
|
||||
if recipe_id is not None:
|
||||
@@ -2,17 +2,17 @@
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database import get_db
|
||||
from middleware.api_key import get_current_user, require_maker, require_measurement_tec
|
||||
from models.user import User
|
||||
from schemas.recipe import (
|
||||
from src.backend.database import get_db
|
||||
from src.backend.api.middleware.api_key import get_current_user, require_maker, require_measurement_tec
|
||||
from src.backend.models.orm.user import User
|
||||
from src.backend.models.api.recipe import (
|
||||
RecipeCreate,
|
||||
RecipeListResponse,
|
||||
RecipeResponse,
|
||||
RecipeUpdate,
|
||||
RecipeVersionResponse,
|
||||
)
|
||||
from services import recipe_service
|
||||
from src.backend.services import recipe_service
|
||||
|
||||
router = APIRouter(prefix="/api/recipes", tags=["recipes"])
|
||||
|
||||
@@ -5,10 +5,10 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from fastapi.responses import Response
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database import get_db
|
||||
from middleware.api_key import require_metrologist
|
||||
from models.user import User
|
||||
from services.report_service import generate_measurement_report, generate_spc_report
|
||||
from src.backend.database import get_db
|
||||
from src.backend.api.middleware.api_key import require_metrologist
|
||||
from src.backend.models.orm.user import User
|
||||
from src.backend.services.report_service import generate_measurement_report, generate_spc_report
|
||||
|
||||
router = APIRouter(prefix="/api/reports", tags=["reports"])
|
||||
|
||||
@@ -5,11 +5,11 @@ from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config import settings
|
||||
from database import get_db
|
||||
from middleware.api_key import get_current_user, require_admin_user
|
||||
from models.setting import SystemSetting
|
||||
from models.user import User
|
||||
from src.backend.config import settings
|
||||
from src.backend.database import get_db
|
||||
from src.backend.api.middleware.api_key import get_current_user, require_admin_user
|
||||
from src.backend.models.orm.setting import SystemSetting
|
||||
from src.backend.models.orm.user import User
|
||||
|
||||
router = APIRouter(prefix="/api/settings", tags=["settings"])
|
||||
|
||||
@@ -15,14 +15,14 @@ from pydantic import BaseModel
|
||||
from sqlalchemy import inspect as sa_inspect, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config import settings
|
||||
from database import Base, engine, async_session_factory
|
||||
from models import (
|
||||
from src.backend.config import settings
|
||||
from src.backend.database import Base, engine, async_session_factory
|
||||
from src.backend.models.orm import (
|
||||
User, Recipe, RecipeVersion, RecipeTask, RecipeSubtask, Measurement,
|
||||
)
|
||||
from models.station import Station, StationRecipeAssignment
|
||||
from services.auth_service import hash_password
|
||||
from services.measurement_service import calculate_pass_fail
|
||||
from src.backend.models.orm.station import Station, StationRecipeAssignment
|
||||
from src.backend.services.auth_service import hash_password
|
||||
from src.backend.services.measurement_service import calculate_pass_fail
|
||||
|
||||
router = APIRouter(prefix="/api/setup", tags=["setup"])
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database import get_db
|
||||
from middleware.api_key import get_current_user, require_admin_user
|
||||
from models.user import User
|
||||
from schemas.station import (
|
||||
from src.backend.database import get_db
|
||||
from src.backend.api.middleware.api_key import get_current_user, require_admin_user
|
||||
from src.backend.models.orm.user import User
|
||||
from src.backend.models.api.station import (
|
||||
StationCreate,
|
||||
StationUpdate,
|
||||
StationResponse,
|
||||
@@ -13,7 +13,7 @@ from schemas.station import (
|
||||
StationRecipeAssignmentResponse,
|
||||
RecipeSummary,
|
||||
)
|
||||
from services import station_service
|
||||
from src.backend.services import station_service
|
||||
|
||||
router = APIRouter(prefix="/api/stations", tags=["stations"])
|
||||
|
||||
@@ -5,19 +5,19 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import and_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database import get_db
|
||||
from middleware.api_key import require_metrologist
|
||||
from models.measurement import Measurement
|
||||
from models.recipe import RecipeVersion
|
||||
from models.task import RecipeSubtask, RecipeTask
|
||||
from models.user import User
|
||||
from schemas.statistics import (
|
||||
from src.backend.database import get_db
|
||||
from src.backend.api.middleware.api_key import require_metrologist
|
||||
from src.backend.models.orm.measurement import Measurement
|
||||
from src.backend.models.orm.recipe import RecipeVersion
|
||||
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
||||
from src.backend.models.orm.user import User
|
||||
from src.backend.models.api.statistics import (
|
||||
CapabilityData,
|
||||
ControlChartData,
|
||||
HistogramData,
|
||||
SummaryData,
|
||||
)
|
||||
from services.spc_service import (
|
||||
from src.backend.services.spc_service import (
|
||||
compute_capability,
|
||||
compute_control_chart,
|
||||
compute_histogram,
|
||||
@@ -4,12 +4,12 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from database import get_db
|
||||
from middleware.api_key import require_maker, get_current_user
|
||||
from models.recipe import Recipe, RecipeVersion
|
||||
from models.task import RecipeSubtask, RecipeTask
|
||||
from models.user import User
|
||||
from schemas.task import (
|
||||
from src.backend.database import get_db
|
||||
from src.backend.api.middleware.api_key import require_maker, get_current_user
|
||||
from src.backend.models.orm.recipe import Recipe, RecipeVersion
|
||||
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
||||
from src.backend.models.orm.user import User
|
||||
from src.backend.models.api.task import (
|
||||
SubtaskCreate,
|
||||
SubtaskResponse,
|
||||
SubtaskUpdate,
|
||||
@@ -18,7 +18,7 @@ from schemas.task import (
|
||||
TaskResponse,
|
||||
TaskUpdate,
|
||||
)
|
||||
from services import recipe_service
|
||||
from src.backend.services import recipe_service
|
||||
|
||||
router = APIRouter(tags=["tasks"])
|
||||
|
||||
@@ -159,7 +159,7 @@ async def create_task(
|
||||
|
||||
if has_measurements:
|
||||
# Copy-on-write: create new version preserving measurement data
|
||||
from schemas.recipe import RecipeUpdate
|
||||
from src.backend.models.api.recipe import RecipeUpdate
|
||||
new_version = await recipe_service.create_new_version(
|
||||
db, recipe_id, RecipeUpdate(change_notes=f"Added task: {data.title}"), user
|
||||
)
|
||||
@@ -3,11 +3,11 @@ from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database import get_db
|
||||
from middleware.api_key import require_admin_user
|
||||
from models.user import User
|
||||
from schemas.user import UserCreate, UserPasswordChange, UserResponse, UserUpdate
|
||||
from services.auth_service import create_user, hash_password, regenerate_api_key
|
||||
from src.backend.database import get_db
|
||||
from src.backend.api.middleware.api_key import require_admin_user
|
||||
from src.backend.models.orm.user import User
|
||||
from src.backend.models.api.user import UserCreate, UserPasswordChange, UserResponse, UserUpdate
|
||||
from src.backend.services.auth_service import create_user, hash_password, regenerate_api_key
|
||||
|
||||
router = APIRouter(prefix="/api/users", tags=["users"])
|
||||
|
||||
@@ -23,9 +23,9 @@ class Settings(BaseSettings):
|
||||
upload_dir: str = "uploads"
|
||||
max_upload_size_mb: int = 50
|
||||
|
||||
# Rate Limiting (requests per minute)
|
||||
# Rate Limiting (requests per minute, per real client IP)
|
||||
rate_limit_login: int = 5
|
||||
rate_limit_general: int = 100
|
||||
rate_limit_general: int = 300
|
||||
|
||||
# SSL (Production)
|
||||
ssl_certfile: str | None = None
|
||||
@@ -49,10 +49,21 @@ class Settings(BaseSettings):
|
||||
|
||||
@property
|
||||
def upload_path(self) -> Path:
|
||||
"""Absolute path to upload directory."""
|
||||
return Path(__file__).parent / self.upload_dir
|
||||
"""Absolute path to upload directory.
|
||||
|
||||
model_config = {"env_file": "../.env", "env_file_encoding": "utf-8", "extra": "ignore"}
|
||||
After the V2.0.0 restructure, uploads live at the project root
|
||||
(mounted as a Docker volume), not inside the backend tree.
|
||||
"""
|
||||
# Path(__file__) = src/backend/config.py → parents[2] = project root
|
||||
return Path(__file__).resolve().parents[2] / self.upload_dir
|
||||
|
||||
# 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()
|
||||
@@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import (
|
||||
)
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from config import settings
|
||||
from src.backend.config import settings
|
||||
|
||||
# Create async engine
|
||||
engine = create_async_engine(
|
||||
@@ -5,22 +5,22 @@ from collections.abc import AsyncGenerator
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from config import settings
|
||||
from database import init_db
|
||||
from middleware.logging import AccessLogMiddleware
|
||||
from middleware.rate_limit import RateLimitMiddleware
|
||||
from middleware.security_headers import SecurityHeadersMiddleware
|
||||
from routers.auth import router as auth_router
|
||||
from routers.users import router as users_router
|
||||
from routers.recipes import router as recipes_router
|
||||
from routers.tasks import router as tasks_router
|
||||
from routers.measurements import router as measurements_router
|
||||
from routers.files import router as files_router
|
||||
from routers.settings import router as settings_router
|
||||
from routers.reports import router as reports_router
|
||||
from routers.statistics import router as statistics_router
|
||||
from routers.setup import router as setup_router
|
||||
from routers.stations import router as stations_router
|
||||
from src.backend.config import settings
|
||||
from src.backend.database import init_db
|
||||
from src.backend.api.middleware.logging import AccessLogMiddleware
|
||||
from src.backend.api.middleware.rate_limit import RateLimitMiddleware
|
||||
from src.backend.api.middleware.security_headers import SecurityHeadersMiddleware
|
||||
from src.backend.api.routers.auth import router as auth_router
|
||||
from src.backend.api.routers.users import router as users_router
|
||||
from src.backend.api.routers.recipes import router as recipes_router
|
||||
from src.backend.api.routers.tasks import router as tasks_router
|
||||
from src.backend.api.routers.measurements import router as measurements_router
|
||||
from src.backend.api.routers.files import router as files_router
|
||||
from src.backend.api.routers.settings import router as settings_router
|
||||
from src.backend.api.routers.reports import router as reports_router
|
||||
from src.backend.api.routers.statistics import router as statistics_router
|
||||
from src.backend.api.routers.setup import router as setup_router
|
||||
from src.backend.api.routers.stations import router as stations_router
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -17,20 +17,25 @@ if config.config_file_name is not None:
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
from config import settings
|
||||
from database import Base
|
||||
# Add the project root (4 levels up from this file) to sys.path so that
|
||||
# `src.backend.*` imports resolve when alembic is invoked from anywhere.
|
||||
_PROJECT_ROOT = Path(__file__).resolve().parents[3]
|
||||
if str(_PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_PROJECT_ROOT))
|
||||
|
||||
from src.backend.config import settings
|
||||
from src.backend.database import Base
|
||||
|
||||
# Override alembic.ini URL with .env settings (keep in sync)
|
||||
config.set_main_option("sqlalchemy.url", settings.database_url)
|
||||
|
||||
# Import all models so they register with Base.metadata
|
||||
from models.user import User # noqa: F401
|
||||
from models.recipe import Recipe, RecipeVersion # noqa: F401
|
||||
from models.task import RecipeTask, RecipeSubtask # noqa: F401
|
||||
from models.measurement import Measurement # noqa: F401
|
||||
from models.access_log import AccessLog # noqa: F401
|
||||
from models.setting import SystemSetting, RecipeVersionAudit # noqa: F401
|
||||
from src.backend.models.orm.user import User # noqa: F401
|
||||
from src.backend.models.orm.recipe import Recipe, RecipeVersion # noqa: F401
|
||||
from src.backend.models.orm.task import RecipeTask, RecipeSubtask # noqa: F401
|
||||
from src.backend.models.orm.measurement import Measurement # noqa: F401
|
||||
from src.backend.models.orm.access_log import AccessLog # noqa: F401
|
||||
from src.backend.models.orm.setting import SystemSetting, RecipeVersionAudit # noqa: F401
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
"""Pydantic schemas for TieMeasureFlow API."""
|
||||
from schemas.measurement import (
|
||||
from src.backend.models.api.measurement import (
|
||||
MeasurementBatchCreate,
|
||||
MeasurementCreate,
|
||||
MeasurementListResponse,
|
||||
MeasurementQuery,
|
||||
MeasurementResponse,
|
||||
)
|
||||
from schemas.recipe import (
|
||||
from src.backend.models.api.recipe import (
|
||||
RecipeCreate,
|
||||
RecipeListResponse,
|
||||
RecipeResponse,
|
||||
RecipeUpdate,
|
||||
RecipeVersionResponse,
|
||||
)
|
||||
from schemas.statistics import (
|
||||
from src.backend.models.api.statistics import (
|
||||
AlertData,
|
||||
CapabilityData,
|
||||
ControlChartData,
|
||||
@@ -23,7 +23,7 @@ from schemas.statistics import (
|
||||
SummaryData,
|
||||
TrendData,
|
||||
)
|
||||
from schemas.task import (
|
||||
from src.backend.models.api.task import (
|
||||
SubtaskCreate,
|
||||
SubtaskResponse,
|
||||
SubtaskUpdate,
|
||||
@@ -32,7 +32,7 @@ from schemas.task import (
|
||||
TaskResponse,
|
||||
TaskUpdate,
|
||||
)
|
||||
from schemas.user import (
|
||||
from src.backend.models.api.user import (
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
UserCreate,
|
||||
@@ -5,7 +5,7 @@ from typing import Optional, TYPE_CHECKING
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from schemas.task import TaskResponse
|
||||
from src.backend.models.api.task import TaskResponse
|
||||
|
||||
|
||||
class RecipeCreate(BaseModel):
|
||||
@@ -71,6 +71,6 @@ class RecipeListResponse(BaseModel):
|
||||
|
||||
|
||||
# Forward reference imports for model_rebuild
|
||||
from schemas.task import TaskResponse # noqa: E402
|
||||
from src.backend.models.api.task import TaskResponse # noqa: E402
|
||||
RecipeVersionResponse.model_rebuild()
|
||||
RecipeResponse.model_rebuild()
|
||||
@@ -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.orm import Mapped, mapped_column
|
||||
|
||||
from database import Base
|
||||
from src.backend.database import Base
|
||||
|
||||
|
||||
class AccessLog(Base):
|
||||
@@ -8,7 +8,7 @@ from sqlalchemy import (
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from database import Base
|
||||
from src.backend.database import Base
|
||||
|
||||
|
||||
class Measurement(Base):
|
||||
@@ -5,10 +5,10 @@ from typing import TYPE_CHECKING, Optional
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, Text, UniqueConstraint, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from database import Base
|
||||
from src.backend.database import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from models.task import RecipeTask
|
||||
from src.backend.models.orm.task import RecipeTask
|
||||
|
||||
|
||||
class Recipe(Base):
|
||||
@@ -5,7 +5,7 @@ from typing import Optional
|
||||
from sqlalchemy import DateTime, Enum, ForeignKey, Index, Integer, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from database import Base
|
||||
from src.backend.database import Base
|
||||
|
||||
|
||||
class SystemSetting(Base):
|
||||
@@ -10,10 +10,10 @@ from typing import TYPE_CHECKING, Optional
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from database import Base
|
||||
from src.backend.database import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from models.recipe import Recipe
|
||||
from src.backend.models.orm.recipe import Recipe
|
||||
|
||||
|
||||
class Station(Base):
|
||||
@@ -6,10 +6,10 @@ from sqlalchemy import (
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from database import Base
|
||||
from src.backend.database import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from models.recipe import RecipeVersion
|
||||
from src.backend.models.orm.recipe import RecipeVersion
|
||||
|
||||
|
||||
class RecipeTask(Base):
|
||||
@@ -5,7 +5,7 @@ from typing import Optional
|
||||
from sqlalchemy import Boolean, DateTime, Enum, Integer, JSON, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from database import Base
|
||||
from src.backend.database import Base
|
||||
|
||||
|
||||
class User(Base):
|
||||
@@ -6,7 +6,7 @@ import bcrypt
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from models.user import User
|
||||
from src.backend.models.orm.user import User
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
+2
-2
@@ -4,8 +4,8 @@ from decimal import Decimal
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from models.measurement import Measurement
|
||||
from models.task import RecipeSubtask
|
||||
from src.backend.models.orm.measurement import Measurement
|
||||
from src.backend.models.orm.task import RecipeSubtask
|
||||
|
||||
|
||||
def calculate_pass_fail(
|
||||
@@ -7,12 +7,12 @@ from sqlalchemy import func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from models.measurement import Measurement
|
||||
from models.recipe import Recipe, RecipeVersion
|
||||
from models.setting import RecipeVersionAudit
|
||||
from models.task import RecipeSubtask, RecipeTask
|
||||
from models.user import User
|
||||
from schemas.recipe import RecipeCreate, RecipeUpdate
|
||||
from src.backend.models.orm.measurement import Measurement
|
||||
from src.backend.models.orm.recipe import Recipe, RecipeVersion
|
||||
from src.backend.models.orm.setting import RecipeVersionAudit
|
||||
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
||||
from src.backend.models.orm.user import User
|
||||
from src.backend.models.api.recipe import RecipeCreate, RecipeUpdate
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -10,13 +10,13 @@ from sqlalchemy import and_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from weasyprint import HTML
|
||||
|
||||
from config import settings
|
||||
from models.measurement import Measurement
|
||||
from models.recipe import Recipe, RecipeVersion
|
||||
from models.setting import SystemSetting
|
||||
from models.task import RecipeSubtask, RecipeTask
|
||||
from models.user import User
|
||||
from services.spc_service import (
|
||||
from src.backend.config import settings
|
||||
from src.backend.models.orm.measurement import Measurement
|
||||
from src.backend.models.orm.recipe import Recipe, RecipeVersion
|
||||
from src.backend.models.orm.setting import SystemSetting
|
||||
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
||||
from src.backend.models.orm.user import User
|
||||
from src.backend.services.spc_service import (
|
||||
compute_capability,
|
||||
compute_control_chart,
|
||||
compute_histogram,
|
||||
@@ -9,7 +9,7 @@ import math
|
||||
import statistics as stats
|
||||
from datetime import datetime
|
||||
|
||||
from schemas.statistics import (
|
||||
from src.backend.models.api.statistics import (
|
||||
CapabilityData,
|
||||
ControlChartData,
|
||||
HistogramData,
|
||||
@@ -10,10 +10,10 @@ from fastapi import HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from models.recipe import Recipe
|
||||
from models.station import Station, StationRecipeAssignment
|
||||
from models.user import User
|
||||
from schemas.station import StationCreate, StationUpdate
|
||||
from src.backend.models.orm.recipe import Recipe
|
||||
from src.backend.models.orm.station import Station, StationRecipeAssignment
|
||||
from src.backend.models.orm.user import User
|
||||
from src.backend.models.api.station import StationCreate, StationUpdate
|
||||
|
||||
|
||||
async def create_station(
|
||||
@@ -30,17 +30,17 @@ if "weasyprint" not in sys.modules:
|
||||
# Ensure the server package is importable
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from database import Base, get_db
|
||||
from main import app
|
||||
from middleware.rate_limit import RateLimitMiddleware
|
||||
from models.user import User
|
||||
from models.recipe import Recipe, RecipeVersion
|
||||
from models.task import RecipeTask, RecipeSubtask
|
||||
from models.measurement import Measurement
|
||||
from models.access_log import AccessLog
|
||||
from models.setting import SystemSetting, RecipeVersionAudit
|
||||
from models.station import Station, StationRecipeAssignment
|
||||
from services.auth_service import hash_password, generate_api_key
|
||||
from src.backend.database import Base, get_db
|
||||
from src.backend.main import app
|
||||
from src.backend.api.middleware.rate_limit import RateLimitMiddleware
|
||||
from src.backend.models.orm.user import User
|
||||
from src.backend.models.orm.recipe import Recipe, RecipeVersion
|
||||
from src.backend.models.orm.task import RecipeTask, RecipeSubtask
|
||||
from src.backend.models.orm.measurement import Measurement
|
||||
from src.backend.models.orm.access_log import AccessLog
|
||||
from src.backend.models.orm.setting import SystemSetting, RecipeVersionAudit
|
||||
from src.backend.models.orm.station import Station, StationRecipeAssignment
|
||||
from src.backend.services.auth_service import hash_password, generate_api_key
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# In-memory SQLite engine for tests
|
||||
@@ -2,7 +2,7 @@
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from tests.conftest import _create_user, auth_headers
|
||||
from src.backend.tests.conftest import _create_user, auth_headers
|
||||
|
||||
|
||||
class TestLogin:
|
||||
@@ -8,8 +8,8 @@ from unittest.mock import patch
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from models.user import User
|
||||
from tests.conftest import auth_headers
|
||||
from src.backend.models.orm.user import User
|
||||
from src.backend.tests.conftest import auth_headers
|
||||
|
||||
|
||||
class TestUploadFile:
|
||||
@@ -45,7 +45,7 @@ class TestUploadFile:
|
||||
)
|
||||
|
||||
# Patch settings.upload_path to use tmp_path
|
||||
with patch("routers.files.settings") as mock_settings:
|
||||
with patch("src.backend.api.routers.files.settings") as mock_settings:
|
||||
mock_settings.upload_path = tmp_path
|
||||
mock_settings.max_upload_size_mb = 50
|
||||
|
||||
@@ -82,7 +82,7 @@ class TestUploadFile:
|
||||
# Create a large file content
|
||||
large_content = b"\x00" * (51 * 1024 * 1024) # 51MB
|
||||
|
||||
with patch("routers.files.settings") as mock_settings:
|
||||
with patch("src.backend.api.routers.files.settings") as mock_settings:
|
||||
mock_settings.max_upload_size_mb = 50
|
||||
mock_settings.upload_path = Path(tempfile.mkdtemp())
|
||||
|
||||
@@ -109,7 +109,7 @@ class TestGetFile:
|
||||
test_file = tmp_path / "testfile.txt"
|
||||
test_file.write_text("hello test")
|
||||
|
||||
with patch("routers.files.settings") as mock_settings:
|
||||
with patch("src.backend.api.routers.files.settings") as mock_settings:
|
||||
mock_settings.upload_path = tmp_path
|
||||
|
||||
resp = await client.get(
|
||||
@@ -123,7 +123,7 @@ class TestGetFile:
|
||||
self, client: AsyncClient, maker_user: User, tmp_path: Path
|
||||
):
|
||||
"""Requesting a non-existent file returns 404."""
|
||||
with patch("routers.files.settings") as mock_settings:
|
||||
with patch("src.backend.api.routers.files.settings") as mock_settings:
|
||||
mock_settings.upload_path = tmp_path
|
||||
|
||||
resp = await client.get(
|
||||
@@ -144,7 +144,7 @@ class TestDeleteFile:
|
||||
test_file = tmp_path / "deleteme.txt"
|
||||
test_file.write_text("delete me")
|
||||
|
||||
with patch("routers.files.settings") as mock_settings:
|
||||
with patch("src.backend.api.routers.files.settings") as mock_settings:
|
||||
mock_settings.upload_path = tmp_path
|
||||
|
||||
resp = await client.delete(
|
||||
@@ -163,7 +163,7 @@ class TestFilenameSanitization:
|
||||
self, client: AsyncClient, maker_user: User, tmp_path: Path
|
||||
):
|
||||
"""Path traversal attempt returns 403 or 404."""
|
||||
with patch("routers.files.settings") as mock_settings:
|
||||
with patch("src.backend.api.routers.files.settings") as mock_settings:
|
||||
mock_settings.upload_path = tmp_path
|
||||
|
||||
resp = await client.get(
|
||||
@@ -3,10 +3,10 @@ import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from models.user import User
|
||||
from models.recipe import RecipeVersion
|
||||
from models.task import RecipeTask, RecipeSubtask
|
||||
from tests.conftest import auth_headers, create_test_recipe
|
||||
from src.backend.models.orm.user import User
|
||||
from src.backend.models.orm.recipe import RecipeVersion
|
||||
from src.backend.models.orm.task import RecipeTask, RecipeSubtask
|
||||
from src.backend.tests.conftest import auth_headers, create_test_recipe
|
||||
|
||||
|
||||
async def _get_subtask_and_version(
|
||||
@@ -211,6 +211,43 @@ class TestListMeasurements:
|
||||
assert "total" in data
|
||||
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(
|
||||
self,
|
||||
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 sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from models.user import User
|
||||
from tests.conftest import auth_headers, create_test_recipe
|
||||
from src.backend.models.orm.user import User
|
||||
from src.backend.tests.conftest import auth_headers, create_test_recipe
|
||||
|
||||
|
||||
class TestListRecipes:
|
||||
@@ -9,8 +9,8 @@ import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from models.user import User
|
||||
from tests.conftest import auth_headers, create_test_recipe
|
||||
from src.backend.models.orm.user import User
|
||||
from src.backend.tests.conftest import auth_headers, create_test_recipe
|
||||
|
||||
|
||||
class TestMeasurementReport:
|
||||
@@ -29,7 +29,7 @@ class TestMeasurementReport:
|
||||
|
||||
fake_pdf = b"%PDF-1.4 fake content"
|
||||
with patch(
|
||||
"routers.reports.generate_measurement_report",
|
||||
"src.backend.api.routers.reports.generate_measurement_report",
|
||||
new_callable=AsyncMock,
|
||||
return_value=fake_pdf,
|
||||
):
|
||||
@@ -75,7 +75,7 @@ class TestSPCReport:
|
||||
|
||||
fake_pdf = b"%PDF-1.4 spc report content"
|
||||
with patch(
|
||||
"routers.reports.generate_spc_report",
|
||||
"src.backend.api.routers.reports.generate_spc_report",
|
||||
new_callable=AsyncMock,
|
||||
return_value=fake_pdf,
|
||||
):
|
||||
@@ -9,7 +9,7 @@ from unittest.mock import patch
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from tests.conftest import _create_user, auth_headers
|
||||
from src.backend.tests.conftest import _create_user, auth_headers
|
||||
|
||||
|
||||
class TestCORSHeaders:
|
||||
@@ -126,7 +126,7 @@ class TestFilenameSanitization:
|
||||
|
||||
async def test_dotfile_rejected(self, client: AsyncClient, db_session):
|
||||
"""Filenames starting with '.' are rejected."""
|
||||
from routers.files import sanitize_filename
|
||||
from src.backend.api.routers.files import sanitize_filename
|
||||
from fastapi import HTTPException
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
@@ -136,7 +136,7 @@ class TestFilenameSanitization:
|
||||
|
||||
async def test_empty_filename_rejected(self, client: AsyncClient, db_session):
|
||||
"""Empty filenames are rejected."""
|
||||
from routers.files import sanitize_filename
|
||||
from src.backend.api.routers.files import sanitize_filename
|
||||
from fastapi import HTTPException
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
@@ -3,9 +3,9 @@ import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from models.setting import SystemSetting
|
||||
from models.user import User
|
||||
from tests.conftest import auth_headers
|
||||
from src.backend.models.orm.setting import SystemSetting
|
||||
from src.backend.models.orm.user import User
|
||||
from src.backend.tests.conftest import auth_headers
|
||||
|
||||
|
||||
class TestGetSettings:
|
||||
@@ -2,8 +2,8 @@
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from config import settings
|
||||
from tests.conftest import test_engine, TestSessionFactory
|
||||
from src.backend.config import settings
|
||||
from src.backend.tests.conftest import test_engine, TestSessionFactory
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -20,7 +20,7 @@ def enable_setup(monkeypatch):
|
||||
# setup.py imports engine and async_session_factory directly from database
|
||||
# and uses them instead of get_db dependency injection. Patch them to use
|
||||
# the test in-memory SQLite engine/session instead of real MySQL.
|
||||
import routers.setup as setup_mod
|
||||
import src.backend.api.routers.setup as setup_mod
|
||||
monkeypatch.setattr(setup_mod, "engine", test_engine)
|
||||
monkeypatch.setattr(setup_mod, "async_session_factory", TestSessionFactory)
|
||||
|
||||
@@ -52,7 +52,7 @@ async def test_seed_measurements_have_correct_distribution(client: AsyncClient,
|
||||
await _seed(client)
|
||||
|
||||
from sqlalchemy import select, func
|
||||
from models.measurement import Measurement
|
||||
from src.backend.models.orm.measurement import Measurement
|
||||
|
||||
result = await db_session.execute(
|
||||
select(Measurement.pass_fail, func.count()).group_by(Measurement.pass_fail)
|
||||
@@ -74,7 +74,7 @@ async def test_seed_measurements_have_lot_and_serial(client: AsyncClient, db_ses
|
||||
await _seed(client)
|
||||
|
||||
from sqlalchemy import select, func
|
||||
from models.measurement import Measurement
|
||||
from src.backend.models.orm.measurement import Measurement
|
||||
|
||||
result = await db_session.execute(
|
||||
select(func.count()).where(Measurement.lot_number.is_(None))
|
||||
@@ -89,7 +89,7 @@ async def test_seed_measurements_input_methods(client: AsyncClient, db_session):
|
||||
await _seed(client)
|
||||
|
||||
from sqlalchemy import select, func
|
||||
from models.measurement import Measurement
|
||||
from src.backend.models.orm.measurement import Measurement
|
||||
|
||||
result = await db_session.execute(
|
||||
select(Measurement.input_method, func.count()).group_by(Measurement.input_method)
|
||||
@@ -4,8 +4,8 @@ from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from models.station import Station, StationRecipeAssignment
|
||||
from tests.conftest import _create_user, create_test_recipe
|
||||
from src.backend.models.orm.station import Station, StationRecipeAssignment
|
||||
from src.backend.tests.conftest import _create_user, create_test_recipe
|
||||
|
||||
|
||||
async def test_create_station(db_session: AsyncSession):
|
||||
@@ -2,7 +2,7 @@
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from schemas.station import (
|
||||
from src.backend.models.api.station import (
|
||||
StationCreate, StationUpdate, StationResponse,
|
||||
StationRecipeAssignmentCreate, StationRecipeAssignmentResponse,
|
||||
StationWithRecipesResponse,
|
||||
@@ -18,10 +18,10 @@ from httpx import AsyncClient
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config import settings
|
||||
from models.station import Station, StationRecipeAssignment
|
||||
from models.recipe import Recipe
|
||||
from tests.conftest import test_engine, TestSessionFactory
|
||||
from src.backend.config import settings
|
||||
from src.backend.models.orm.station import Station, StationRecipeAssignment
|
||||
from src.backend.models.orm.recipe import Recipe
|
||||
from src.backend.tests.conftest import test_engine, TestSessionFactory
|
||||
|
||||
|
||||
SETUP_PWD = "test-setup-pwd"
|
||||
@@ -31,7 +31,7 @@ SETUP_PWD = "test-setup-pwd"
|
||||
def enable_setup(monkeypatch):
|
||||
"""Enable setup endpoints and redirect engine/session to test SQLite DB."""
|
||||
monkeypatch.setattr(settings, "setup_password", SETUP_PWD)
|
||||
import routers.setup as setup_mod
|
||||
import src.backend.api.routers.setup as setup_mod
|
||||
monkeypatch.setattr(setup_mod, "engine", test_engine)
|
||||
monkeypatch.setattr(setup_mod, "async_session_factory", TestSessionFactory)
|
||||
|
||||
@@ -3,14 +3,14 @@ import pytest
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from models.station import Station
|
||||
from schemas.station import StationCreate, StationUpdate
|
||||
from services.station_service import (
|
||||
from src.backend.models.orm.station import Station
|
||||
from src.backend.models.api.station import StationCreate, StationUpdate
|
||||
from src.backend.services.station_service import (
|
||||
create_station, update_station, delete_station,
|
||||
assign_recipe, unassign_recipe, list_station_recipes,
|
||||
get_station_by_code,
|
||||
)
|
||||
from tests.conftest import _create_user, create_test_recipe
|
||||
from src.backend.tests.conftest import _create_user, create_test_recipe
|
||||
|
||||
|
||||
async def test_create_station_ok(db_session: AsyncSession):
|
||||
@@ -102,7 +102,7 @@ async def test_list_recipes_only_returns_active(db_session: AsyncSession):
|
||||
|
||||
async def test_delete_station_cascades_assignments(db_session: AsyncSession):
|
||||
from sqlalchemy import select
|
||||
from models.station import StationRecipeAssignment
|
||||
from src.backend.models.orm.station import StationRecipeAssignment
|
||||
admin = await _create_user(db_session, username="a9", is_admin=True)
|
||||
station = await create_station(db_session, StationCreate(code="ST-DEL", name="D"), admin)
|
||||
r = await create_test_recipe(db_session, user_id=admin.id, code="REC-DEL")
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user