Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 563b7789f4 | |||
| e4b29c0b2d | |||
| 1a0431366f | |||
| 86df67f2e5 | |||
| ea8e4687b5 |
+38
-10
@@ -1,15 +1,43 @@
|
|||||||
__pycache__
|
# Build context exclusions: keep image small and rebuilds fast.
|
||||||
*.pyc
|
|
||||||
*.pyo
|
# VCS
|
||||||
.pytest_cache
|
|
||||||
.git
|
.git
|
||||||
.gitignore
|
.gitignore
|
||||||
.env
|
.gitattributes
|
||||||
*.md
|
|
||||||
node_modules
|
# Local envs and caches
|
||||||
.venv
|
.venv
|
||||||
venv
|
venv
|
||||||
*.egg-info
|
.env
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
.pytest_cache/
|
||||||
.coverage
|
.coverage
|
||||||
htmlcov
|
htmlcov/
|
||||||
.mypy_cache
|
.mypy_cache/
|
||||||
|
*.egg-info
|
||||||
|
|
||||||
|
# IDE / editor
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*~
|
||||||
|
|
||||||
|
# Node
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Local-only notes
|
||||||
|
docs/
|
||||||
|
*.docx
|
||||||
|
|
||||||
|
# Uploads are a runtime volume, never baked in.
|
||||||
|
uploads/
|
||||||
|
|
||||||
|
# Claude Code
|
||||||
|
.claude/
|
||||||
|
.omc/
|
||||||
|
|
||||||
|
# Competitor analysis (local only)
|
||||||
|
Concorrente/
|
||||||
|
|||||||
+11
-11
@@ -33,18 +33,18 @@ env/
|
|||||||
Thumbs.db
|
Thumbs.db
|
||||||
desktop.ini
|
desktop.ini
|
||||||
|
|
||||||
# Uploads (server-side files)
|
# Uploads (server-side files, now at project root)
|
||||||
server/uploads/images/*
|
uploads/images/*
|
||||||
server/uploads/pdfs/*
|
uploads/pdfs/*
|
||||||
server/uploads/logos/*
|
uploads/logos/*
|
||||||
server/uploads/reports/*
|
uploads/reports/*
|
||||||
!server/uploads/images/.gitkeep
|
!uploads/images/.gitkeep
|
||||||
!server/uploads/pdfs/.gitkeep
|
!uploads/pdfs/.gitkeep
|
||||||
!server/uploads/logos/.gitkeep
|
!uploads/logos/.gitkeep
|
||||||
!server/uploads/reports/.gitkeep
|
!uploads/reports/.gitkeep
|
||||||
|
|
||||||
# TailwindCSS output
|
# TailwindCSS output
|
||||||
client/static/css/tailwind.css
|
src/frontend/flask_app/static/css/tailwind.css
|
||||||
|
|
||||||
# Node
|
# Node
|
||||||
node_modules/
|
node_modules/
|
||||||
@@ -63,7 +63,7 @@ node_modules/
|
|||||||
htmlcov/
|
htmlcov/
|
||||||
|
|
||||||
# Debug files
|
# Debug files
|
||||||
client/static/js/fabric-debug.js
|
src/frontend/flask_app/static/js/fabric-debug.js
|
||||||
|
|
||||||
# Misc
|
# Misc
|
||||||
nul
|
nul
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
3.11
|
||||||
@@ -5,10 +5,65 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
## Panoramica
|
## Panoramica
|
||||||
|
|
||||||
TieMeasureFlow by Tielogic - Sistema di gestione task per misurazioni con calibro manuale.
|
TieMeasureFlow by Tielogic - Sistema di gestione task per misurazioni con calibro manuale.
|
||||||
Monorepo con **server FastAPI** (backend API, porta 8000) e **client Flask** (frontend tablet, porta 5000), orchestrati con Docker Compose + Nginx reverse proxy + MySQL 8.0.
|
Monorepo con **backend FastAPI** (porta 8000) e **frontend Flask** (porta 5000), orchestrati con Docker Compose + Nginx reverse proxy + MySQL 8.0.
|
||||||
|
|
||||||
|
## Layout del repository (V2.0.0)
|
||||||
|
|
||||||
|
A partire dalla migrazione V2.0.0 (struttura conforme alla spec `python-project-spec-design.md`):
|
||||||
|
|
||||||
|
```
|
||||||
|
TieMeasureFlow/
|
||||||
|
├── pyproject.toml # Dipendenze monorepo (uv)
|
||||||
|
├── uv.lock # Lock file riproducibile
|
||||||
|
├── .python-version # 3.11
|
||||||
|
├── Dockerfile # Backend (uv + uvicorn)
|
||||||
|
├── Dockerfile.frontend # Frontend (uv + gunicorn + Tailwind + Babel)
|
||||||
|
├── docker-compose.dev.yml # Dev (Nginx)
|
||||||
|
├── docker-compose.yml # Prod (Traefik + SSL)
|
||||||
|
├── nginx/
|
||||||
|
├── uploads/ # Volume montato in /app/uploads
|
||||||
|
├── docs/
|
||||||
|
└── src/
|
||||||
|
├── backend/
|
||||||
|
│ ├── main.py # Entry FastAPI
|
||||||
|
│ ├── config.py
|
||||||
|
│ ├── database.py
|
||||||
|
│ ├── api/
|
||||||
|
│ │ ├── routers/ # 11 router REST
|
||||||
|
│ │ └── middleware/ # api_key, rate_limit, security_headers, logging
|
||||||
|
│ ├── models/
|
||||||
|
│ │ ├── orm/ # SQLAlchemy
|
||||||
|
│ │ └── api/ # Pydantic schemas
|
||||||
|
│ ├── services/ # Logica business
|
||||||
|
│ ├── migrations/ # Alembic
|
||||||
|
│ ├── templates/ # Setup page
|
||||||
|
│ └── tests/ # pytest
|
||||||
|
└── frontend/
|
||||||
|
└── flask_app/ # Flask + Jinja2 + Alpine.js (deroga vs spec React,
|
||||||
|
├── app.py # giustificata: tablet UX server-side, USB calipers,
|
||||||
|
├── config.py # workflow operatore con Fabric.js)
|
||||||
|
├── blueprints/
|
||||||
|
├── services/
|
||||||
|
├── templates/
|
||||||
|
├── static/
|
||||||
|
├── translations/
|
||||||
|
└── tests/
|
||||||
|
```
|
||||||
|
|
||||||
|
Dipendenze gestite con **uv** (no `requirements.txt`):
|
||||||
|
- `[project] dependencies` = core condivisi (pydantic, dotenv)
|
||||||
|
- `[project.optional-dependencies] server` = backend FastAPI
|
||||||
|
- `[project.optional-dependencies] client` = frontend Flask
|
||||||
|
- `[project.optional-dependencies] dev` = pytest, httpx, aiosqlite
|
||||||
|
|
||||||
## Comandi di Sviluppo
|
## Comandi di Sviluppo
|
||||||
|
|
||||||
|
### Setup iniziale
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
uv sync --extra server --extra client --extra dev # installa tutto
|
||||||
|
```
|
||||||
|
|
||||||
### Avvio servizi (Docker)
|
### Avvio servizi (Docker)
|
||||||
```bash
|
```bash
|
||||||
docker compose -f docker-compose.dev.yml up -d # Sviluppo (Nginx, porta 80)
|
docker compose -f docker-compose.dev.yml up -d # Sviluppo (Nginx, porta 80)
|
||||||
@@ -20,51 +75,63 @@ docker compose ps # Stato servizi
|
|||||||
|
|
||||||
### Avvio manuale (senza Docker)
|
### Avvio manuale (senza Docker)
|
||||||
```bash
|
```bash
|
||||||
# Server (terminale 1)
|
# Backend (terminale 1)
|
||||||
cd server && uvicorn main:app --reload --host 0.0.0.0 --port 8000
|
uv run uvicorn src.backend.main:app --reload --host 0.0.0.0 --port 8000
|
||||||
|
|
||||||
# Client (terminale 2)
|
# Frontend (terminale 2) — gunicorn richiede cwd interna per app:create_app()
|
||||||
cd client && flask run --host 0.0.0.0 --port 5000
|
cd src/frontend/flask_app && uv run --project ../../.. gunicorn --bind 0.0.0.0:5000 app:create_app()
|
||||||
|
|
||||||
# TailwindCSS watch (terminale 3)
|
# TailwindCSS watch (terminale 3)
|
||||||
cd client && npx tailwindcss -i static/css/input.css -o static/css/tailwind.css --watch
|
cd src/frontend/flask_app && npx tailwindcss -i static/css/input.css -o static/css/tailwind.css --watch
|
||||||
```
|
```
|
||||||
|
|
||||||
### Database & Migrations
|
### Database & Migrations
|
||||||
```bash
|
```bash
|
||||||
# alembic.ini è in server/migrations/, serve il flag -c
|
# alembic.ini in src/backend/migrations/, serve il flag -c
|
||||||
cd server && alembic -c migrations/alembic.ini upgrade head # Applica migrazioni
|
uv run alembic -c src/backend/migrations/alembic.ini upgrade head # Applica migrazioni
|
||||||
cd server && alembic -c migrations/alembic.ini revision --autogenerate -m "descrizione" # Genera migrazione
|
uv run alembic -c src/backend/migrations/alembic.ini revision --autogenerate -m "descrizione" # Genera
|
||||||
cd server && alembic -c migrations/alembic.ini downgrade -1 # Rollback ultima
|
uv run alembic -c src/backend/migrations/alembic.ini downgrade -1 # Rollback
|
||||||
docker compose exec server alembic -c migrations/alembic.ini upgrade head # Via Docker
|
docker compose exec server uv run alembic -c src/backend/migrations/alembic.ini upgrade head # Via Docker
|
||||||
```
|
```
|
||||||
Nota: `env.py` sovrascrive la URL di alembic.ini con quella da `.env` (`settings.database_url`). `script_location = %(here)s` usa path relativo.
|
Nota: `env.py` aggiunge la project root a `sys.path`, sovrascrive la URL di alembic.ini con quella da `.env` (`settings.database_url`). `script_location = %(here)s` usa path relativo.
|
||||||
|
|
||||||
### Test
|
### Test
|
||||||
```bash
|
```bash
|
||||||
# Server (usa SQLite in-memory via aiosqlite, no MySQL richiesto)
|
# Tutti i test (backend + frontend)
|
||||||
cd server && pytest # Tutti i test
|
uv run pytest
|
||||||
cd server && pytest tests/test_auth.py # Singolo modulo
|
|
||||||
cd server && pytest tests/test_auth.py::test_login_success # Singolo test
|
|
||||||
cd server && pytest --cov # Con copertura
|
|
||||||
|
|
||||||
# Client
|
# Solo backend (SQLite in-memory via aiosqlite, no MySQL richiesto)
|
||||||
cd client && pytest
|
uv run pytest src/backend/tests/
|
||||||
cd client && pytest tests/test_auth.py
|
uv run pytest src/backend/tests/test_auth.py
|
||||||
|
uv run pytest src/backend/tests/test_auth.py::test_login_success
|
||||||
|
uv run pytest --cov src/backend
|
||||||
|
|
||||||
|
# Solo frontend
|
||||||
|
uv run pytest src/frontend/flask_app/tests/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Gestione dipendenze (uv)
|
||||||
|
```bash
|
||||||
|
uv add <pacchetto> # core (entrambi)
|
||||||
|
uv add --optional server <pacchetto> # solo backend
|
||||||
|
uv add --optional client <pacchetto> # solo frontend
|
||||||
|
uv add --optional dev <pacchetto> # solo dev/test
|
||||||
|
uv sync --extra server --extra client --extra dev # reinstalla
|
||||||
|
uv lock # rigenera uv.lock
|
||||||
```
|
```
|
||||||
|
|
||||||
### i18n (Traduzioni)
|
### i18n (Traduzioni)
|
||||||
```bash
|
```bash
|
||||||
# Estrai stringhe
|
# Estrai stringhe
|
||||||
cd client && pybabel extract -F babel.cfg -k _ -o translations/messages.pot .
|
cd src/frontend/flask_app && uv run pybabel extract -F babel.cfg -k _ -o translations/messages.pot .
|
||||||
|
|
||||||
# Aggiorna catalogo
|
# Aggiorna catalogo
|
||||||
cd client && pybabel update -i translations/messages.pot -d translations
|
cd src/frontend/flask_app && uv run pybabel update -i translations/messages.pot -d translations
|
||||||
|
|
||||||
# Compila .po → .mo
|
# Compila .po → .mo
|
||||||
cd client && pybabel compile -d translations
|
cd src/frontend/flask_app && uv run pybabel compile -d translations
|
||||||
# oppure
|
# oppure
|
||||||
cd client && python compile_translations.py
|
cd src/frontend/flask_app && uv run python compile_translations.py
|
||||||
```
|
```
|
||||||
|
|
||||||
### Setup iniziale
|
### Setup iniziale
|
||||||
@@ -78,7 +145,7 @@ Browser/Tablet → Nginx (:80/443) → Flask Client (:5000) → APIClient → Fa
|
|||||||
```
|
```
|
||||||
Il client Flask è un frontend server-side che comunica col backend via REST API. Ogni richiesta dal client al server include l'header `X-API-Key` per autenticazione.
|
Il client Flask è un frontend server-side che comunica col backend via REST API. Ogni richiesta dal client al server include l'header `X-API-Key` per autenticazione.
|
||||||
|
|
||||||
### Server (FastAPI) — `server/`
|
### Server (FastAPI) — `src/backend/`
|
||||||
- **main.py**: entry point, lifespan async (`@asynccontextmanager`), registra middleware e 10 router. Health: `GET /api/health`
|
- **main.py**: entry point, lifespan async (`@asynccontextmanager`), registra middleware e 10 router. Health: `GET /api/health`
|
||||||
- **config.py**: `Settings` (pydantic_settings.BaseSettings), legge da `../.env`. Rate limits: login 5/min, general 100/min
|
- **config.py**: `Settings` (pydantic_settings.BaseSettings), legge da `../.env`. Rate limits: login 5/min, general 100/min
|
||||||
- **database.py**: SQLAlchemy 2.0 async engine con `AsyncSession`, pool 10+20 overflow, `pool_recycle=3600`, `expire_on_commit=False`
|
- **database.py**: SQLAlchemy 2.0 async engine con `AsyncSession`, pool 10+20 overflow, `pool_recycle=3600`, `expire_on_commit=False`
|
||||||
@@ -87,10 +154,10 @@ Il client Flask è un frontend server-side che comunica col backend via REST API
|
|||||||
- **middleware/**: Stack order (outermost→innermost): AccessLogMiddleware → CORSMiddleware → SecurityHeadersMiddleware → RateLimitMiddleware. Nota: `add_middleware()` in Starlette wrappa l'app, quindi l'ultimo aggiunto (AccessLog) è il più esterno. Il commento in `main.py` dice "outermost" per RateLimit ma è fuorviante. api_key.py (auth dependency `get_current_user()`), rate_limit.py (sliding window 60s per-IP, in-memory dicts), security_headers.py (CSP con `unsafe-eval` per Plotly.js, HSTS solo con SSL), logging.py (audit trail async su DB, esclude /api/health, /docs, /openapi.json, /redoc)
|
- **middleware/**: Stack order (outermost→innermost): AccessLogMiddleware → CORSMiddleware → SecurityHeadersMiddleware → RateLimitMiddleware. Nota: `add_middleware()` in Starlette wrappa l'app, quindi l'ultimo aggiunto (AccessLog) è il più esterno. Il commento in `main.py` dice "outermost" per RateLimit ma è fuorviante. api_key.py (auth dependency `get_current_user()`), rate_limit.py (sliding window 60s per-IP, in-memory dicts), security_headers.py (CSP con `unsafe-eval` per Plotly.js, HSTS solo con SSL), logging.py (audit trail async su DB, esclude /api/health, /docs, /openapi.json, /redoc)
|
||||||
- **models/**: User (include `email`, `language_pref`, `theme_pref`), Recipe (`image_path` per preview), RecipeVersion, RecipeTask, RecipeSubtask (`image_path` per immagine specifica), Measurement (`synced_to_csv`, `input_method`), AccessLog, SystemSetting, RecipeVersionAudit
|
- **models/**: User (include `email`, `language_pref`, `theme_pref`), Recipe (`image_path` per preview), RecipeVersion, RecipeTask, RecipeSubtask (`image_path` per immagine specifica), Measurement (`synced_to_csv`, `input_method`), AccessLog, SystemSetting, RecipeVersionAudit
|
||||||
- **schemas/**: Pydantic v2 per validazione I/O API
|
- **schemas/**: Pydantic v2 per validazione I/O API
|
||||||
- **migrations/**: Alembic con `alembic.ini` e `env.py` nella directory `server/migrations/`
|
- **migrations/**: Alembic con `alembic.ini` e `env.py` nella directory `src/backend/migrations/`
|
||||||
- **tests/**: pytest + pytest-asyncio, SQLite in-memory (`sqlite+aiosqlite://`, StaticPool), WeasyPrint mockato via `sys.modules`, rate limit reset tra test
|
- **tests/**: pytest + pytest-asyncio, SQLite in-memory (`sqlite+aiosqlite://`, StaticPool), WeasyPrint mockato via `sys.modules`, rate limit reset tra test
|
||||||
|
|
||||||
### Client (Flask) — `client/`
|
### Client (Flask) — `src/frontend/flask_app/`
|
||||||
- **app.py**: factory pattern `create_app()`, CSRF (`WTF_CSRF_TIME_LIMIT=3600`), Babel i18n (`default_locale="it"`)
|
- **app.py**: factory pattern `create_app()`, CSRF (`WTF_CSRF_TIME_LIMIT=3600`), Babel i18n (`default_locale="it"`)
|
||||||
- **blueprints/**: auth (login/logout/session), maker (editor ricette con Fabric.js), measure (esecuzione misurazioni), statistics (dashboard SPC con Plotly.js), admin (gestione utenti CRUD, cambio password, toggle attivo — solo `is_admin`)
|
- **blueprints/**: auth (login/logout/session), maker (editor ricette con Fabric.js), measure (esecuzione misurazioni), statistics (dashboard SPC con Plotly.js), admin (gestione utenti CRUD, cambio password, toggle attivo — solo `is_admin`)
|
||||||
- **services/api_client.py**: singleton `APIClient` — wrapper HTTP (get/post/put/delete) con gestione errori normalizzata, timeout 30s, header X-API-Key da session
|
- **services/api_client.py**: singleton `APIClient` — wrapper HTTP (get/post/put/delete) con gestione errori normalizzata, timeout 30s, header X-API-Key da session
|
||||||
@@ -99,7 +166,7 @@ Il client Flask è un frontend server-side che comunica col backend via REST API
|
|||||||
- **translations/**: Flask-Babel, cataloghi .po/.mo per IT/EN. Locale selector: `session["language"]` → Accept-Language → `"it"`
|
- **translations/**: Flask-Babel, cataloghi .po/.mo per IT/EN. Locale selector: `session["language"]` → Accept-Language → `"it"`
|
||||||
- **config.py**: `PERMANENT_SESSION_LIFETIME=28800` (8h), cookie secure in produzione, `BABEL_DEFAULT_TIMEZONE="Europe/Rome"`
|
- **config.py**: `PERMANENT_SESSION_LIFETIME=28800` (8h), cookie secure in produzione, `BABEL_DEFAULT_TIMEZONE="Europe/Rome"`
|
||||||
|
|
||||||
## Template Structure (`client/templates/base.html`)
|
## Template Structure (`src/frontend/flask_app/templates/base.html`)
|
||||||
|
|
||||||
Ordine blocchi in `base.html`:
|
Ordine blocchi in `base.html`:
|
||||||
```
|
```
|
||||||
@@ -127,7 +194,7 @@ In alternativa, usare il filtro custom `|tojson_attr` (registrato in `app.py`) c
|
|||||||
|
|
||||||
Per selettori CSS in `x-data`: usare `meta[name=csrf-token]` senza virgolette interne.
|
Per selettori CSS in `x-data`: usare `meta[name=csrf-token]` senza virgolette interne.
|
||||||
|
|
||||||
## Fabric.js Annotation Editor (`client/static/js/annotation-editor.js`)
|
## Fabric.js Annotation Editor (`src/frontend/flask_app/static/js/annotation-editor.js`)
|
||||||
|
|
||||||
Editor annotazioni su disegni tecnici (Fabric.js 5.3.1, ~1200 righe). Pattern critici:
|
Editor annotazioni su disegni tecnici (Fabric.js 5.3.1, ~1200 righe). Pattern critici:
|
||||||
|
|
||||||
@@ -152,16 +219,16 @@ Le ricette usano un versioning condizionale. L'endpoint `PUT /api/recipes/{id}`
|
|||||||
|
|
||||||
La stessa logica si applica nel task router: aggiungere un task a una ricetta con measurements crea una nuova versione.
|
La stessa logica si applica nel task router: aggiungere un task a una ricetta con measurements crea una nuova versione.
|
||||||
|
|
||||||
La versione corrente ha `is_current=True`, le precedenti `False`. Audit trail in `recipe_version_audit` (CREATE, UPDATE, ACTIVATE, RETIRE). Logica in `server/services/recipe_service.py`.
|
La versione corrente ha `is_current=True`, le precedenti `False`. Audit trail in `recipe_version_audit` (CREATE, UPDATE, ACTIVATE, RETIRE). Logica in `src/backend/services/recipe_service.py`.
|
||||||
|
|
||||||
### Calcolo Pass/Fail
|
### Calcolo Pass/Fail
|
||||||
Ogni subtask ha 4 limiti di tolleranza: UTL (upper tolerance), UWL (upper warning), LWL (lower warning), LTL (lower tolerance) più un valore nominale. Il calcolo in `server/services/measurement_service.py`:
|
Ogni subtask ha 4 limiti di tolleranza: UTL (upper tolerance), UWL (upper warning), LWL (lower warning), LTL (lower tolerance) più un valore nominale. Il calcolo in `src/backend/services/measurement_service.py`:
|
||||||
- Fuori UTL/LTL → **fail**
|
- Fuori UTL/LTL → **fail**
|
||||||
- Fuori UWL/LWL ma dentro UTL/LTL → **warning**
|
- Fuori UWL/LWL ma dentro UTL/LTL → **warning**
|
||||||
- Dentro UWL/LWL → **pass**
|
- Dentro UWL/LWL → **pass**
|
||||||
|
|
||||||
### SPC (Statistical Process Control)
|
### SPC (Statistical Process Control)
|
||||||
Calcoli in `server/services/spc_service.py` usando solo `math` e `statistics` stdlib (no numpy/scipy): summary (conteggi pass/warning/fail), capability (Cp, Cpk, Pp, Ppk), control chart (UCL/LCL = mean ± 3σ), histogram (20 bin + curva normale).
|
Calcoli in `src/backend/services/spc_service.py` usando solo `math` e `statistics` stdlib (no numpy/scipy): summary (conteggi pass/warning/fail), capability (Cp, Cpk, Pp, Ppk), control chart (UCL/LCL = mean ± 3σ), histogram (20 bin + curva normale).
|
||||||
|
|
||||||
### Autenticazione
|
### Autenticazione
|
||||||
1. Login con username/password → server ritorna `api_key` (64 char random)
|
1. Login con username/password → server ritorna `api_key` (64 char random)
|
||||||
@@ -187,7 +254,7 @@ Upload in `uploads/{recipe_id}/{version_id}/`. Tipi ammessi: JPEG, PNG, GIF, Web
|
|||||||
|
|
||||||
## Test Infrastructure
|
## Test Infrastructure
|
||||||
|
|
||||||
### Server (`server/tests/conftest.py`)
|
### Server (`src/backend/tests/conftest.py`)
|
||||||
- SQLite in-memory `sqlite+aiosqlite://` con `StaticPool` (singola connessione condivisa tra fixture, app e test)
|
- SQLite in-memory `sqlite+aiosqlite://` con `StaticPool` (singola connessione condivisa tra fixture, app e test)
|
||||||
- WeasyPrint mockato prima di qualsiasi import server: `sys.modules["weasyprint"] = MagicMock()`
|
- WeasyPrint mockato prima di qualsiasi import server: `sys.modules["weasyprint"] = MagicMock()`
|
||||||
- Rate limit buckets resettati tra test (walk middleware stack → clear dicts)
|
- Rate limit buckets resettati tra test (walk middleware stack → clear dicts)
|
||||||
@@ -196,7 +263,7 @@ Upload in `uploads/{recipe_id}/{version_id}/`. Tipi ammessi: JPEG, PNG, GIF, Web
|
|||||||
- Helper: `auth_headers(user)` → `{"X-API-Key": user.api_key}`
|
- Helper: `auth_headers(user)` → `{"X-API-Key": user.api_key}`
|
||||||
- Client httpx: `AsyncClient` con `ASGITransport(app=app)`, override di `get_db` dependency
|
- Client httpx: `AsyncClient` con `ASGITransport(app=app)`, override di `get_db` dependency
|
||||||
|
|
||||||
### Client (`client/tests/conftest.py`)
|
### Client (`src/frontend/flask_app/tests/conftest.py`)
|
||||||
- `api_client` patchato in 4 blueprint (auth, maker, measure, statistics). **admin NON è patchato** — i test admin devono gestire il mock manualmente
|
- `api_client` patchato in 4 blueprint (auth, maker, measure, statistics). **admin NON è patchato** — i test admin devono gestire il mock manualmente
|
||||||
- `logged_in_client` fixture pre-popola session con `api_key`, `user_id`, `language`, `theme` + user dict
|
- `logged_in_client` fixture pre-popola session con `api_key`, `user_id`, `language`, `theme` + user dict
|
||||||
- CSRF disabilitato nei test: `WTF_CSRF_ENABLED=False`
|
- CSRF disabilitato nei test: `WTF_CSRF_ENABLED=False`
|
||||||
@@ -232,7 +299,7 @@ Variabili d'ambiente in `.env` (copiare da `.env.example`):
|
|||||||
- DB: `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD`, `DB_ROOT_PASSWORD` (Docker)
|
- DB: `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD`, `DB_ROOT_PASSWORD` (Docker)
|
||||||
- Server: `SERVER_HOST`, `SERVER_PORT`, `SERVER_SECRET_KEY`, `SERVER_CORS_ORIGINS`
|
- Server: `SERVER_HOST`, `SERVER_PORT`, `SERVER_SECRET_KEY`, `SERVER_CORS_ORIGINS`
|
||||||
- Client: `CLIENT_HOST`, `CLIENT_PORT`, `CLIENT_SECRET_KEY`, `API_SERVER_URL`
|
- Client: `CLIENT_HOST`, `CLIENT_PORT`, `CLIENT_SECRET_KEY`, `API_SERVER_URL`
|
||||||
- Upload: `UPLOAD_DIR` (default `"uploads"`, relativo a `server/`), `MAX_UPLOAD_SIZE_MB`
|
- Upload: `UPLOAD_DIR` (default `"uploads"`, relativo a `src/backend/`), `MAX_UPLOAD_SIZE_MB`
|
||||||
- Docker: `NGINX_PORT`, `NGINX_SSL_PORT`
|
- Docker: `NGINX_PORT`, `NGINX_SSL_PORT`
|
||||||
- Setup: `SETUP_PASSWORD` (vuota = endpoint disabilitato)
|
- Setup: `SETUP_PASSWORD` (vuota = endpoint disabilitato)
|
||||||
- SSL: `SSL_CERTFILE`, `SSL_KEYFILE`
|
- SSL: `SSL_CERTFILE`, `SSL_KEYFILE`
|
||||||
|
|||||||
+40
@@ -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
|
# TieMeasureFlow by Tielogic
|
||||||
|
|
||||||
Sistema di gestione task per misurazioni con calibro manuale. Soluzione tablet-first, multi-ruolo, con statistiche SPC (Statistical Process Control) integrate.
|
Sistema di gestione task per misurazioni con calibro manuale. Soluzione tablet-first, multi-ruolo, con statistiche SPC (Statistical Process Control) integrate, identità per-stazione e rate limiting per-tablet.
|
||||||
|
|
||||||
|
> **Versione corrente:** V2.0.0 (in sviluppo) — branch default `V2.0.0`.
|
||||||
|
> Per stato dettagliato e prossimi passi vedi [`docs/architecture/STATO_PROGETTO.md`](docs/architecture/STATO_PROGETTO.md) e [`docs/architecture/ROADMAP.md`](docs/architecture/ROADMAP.md).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -14,8 +17,10 @@ Caratteristiche principali:
|
|||||||
- Calcolo pass/fail/warning su quattro limiti di tolleranza (UTL, UWL, LWL, LTL)
|
- Calcolo pass/fail/warning su quattro limiti di tolleranza (UTL, UWL, LWL, LTL)
|
||||||
- SPC: Cp, Cpk, Pp, Ppk, control chart UCL/LCL, istogramma con curva normale — puro stdlib (no numpy)
|
- SPC: Cp, Cpk, Pp, Ppk, control chart UCL/LCL, istogramma con curva normale — puro stdlib (no numpy)
|
||||||
- Annotazioni grafiche su disegni tecnici (Fabric.js) con viewer sincronizzato all'esecuzione
|
- Annotazioni grafiche su disegni tecnici (Fabric.js) con viewer sincronizzato all'esecuzione
|
||||||
|
- Identità per-stazione (`STATION_CODE`): ogni tablet vede solo le ricette assegnate alla propria stazione, gestione assegnazioni via GUI admin
|
||||||
- Interfaccia completamente localizzata IT/EN, dark mode, ottimizzata per tablet touch
|
- Interfaccia completamente localizzata IT/EN, dark mode, ottimizzata per tablet touch
|
||||||
- Autenticazione API Key, rate limiting sliding window, audit log persistente
|
- Autenticazione API Key, rate limiting sliding window per-IP reale (X-Forwarded-For-aware), audit log persistente
|
||||||
|
- Capacità testata per ~20 tablet contemporanei (gunicorn 5 workers × 4 thread + uvicorn 4 workers async)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -26,25 +31,25 @@ Browser/Tablet
|
|||||||
|
|
|
|
||||||
Reverse Proxy (Nginx — sviluppo | Traefik+SSL — produzione)
|
Reverse Proxy (Nginx — sviluppo | Traefik+SSL — produzione)
|
||||||
|
|
|
|
||||||
Flask Client :5000 (rendering server-side, Jinja2 + Alpine.js)
|
Flask Frontend :5000 (rendering server-side, Jinja2 + Alpine.js)
|
||||||
| X-API-Key
|
| X-API-Key + X-Forwarded-For
|
||||||
FastAPI Server :8000 (API REST asincrona)
|
FastAPI Backend :8000 (API REST asincrona)
|
||||||
|
|
|
|
||||||
MySQL 8.0
|
MySQL 8.0
|
||||||
```
|
```
|
||||||
|
|
||||||
Il client Flask non espone mai le credenziali al browser: ogni chiamata al backend avviene server-side con l'header `X-API-Key` estratto dalla sessione Flask.
|
Il frontend Flask non espone mai le credenziali al browser: ogni chiamata al backend avviene server-side con l'header `X-API-Key` estratto dalla sessione Flask. L'IP reale del tablet è propagato in `X-Forwarded-For` per il rate limiter.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Stack Tecnologico
|
## Stack Tecnologico
|
||||||
|
|
||||||
### Backend (server/)
|
### Backend (`src/backend/`)
|
||||||
|
|
||||||
| Componente | Versione | Ruolo |
|
| Componente | Versione | Ruolo |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| FastAPI | ultima stabile | Framework API REST asincrono |
|
| FastAPI | ultima stabile | Framework API REST asincrono |
|
||||||
| SQLAlchemy 2.0 | async | ORM con pool connessioni |
|
| SQLAlchemy 2.0 | async | ORM con pool connessioni 10+20 |
|
||||||
| asyncmy | ultima stabile | Driver MySQL asincrono |
|
| asyncmy | ultima stabile | Driver MySQL asincrono |
|
||||||
| MySQL | 8.0 | Database relazionale |
|
| MySQL | 8.0 | Database relazionale |
|
||||||
| Alembic | ultima stabile | Migrazioni schema |
|
| Alembic | ultima stabile | Migrazioni schema |
|
||||||
@@ -54,35 +59,47 @@ Il client Flask non espone mai le credenziali al browser: ogni chiamata al backe
|
|||||||
| bcrypt | ultima stabile | Hashing password |
|
| bcrypt | ultima stabile | Hashing password |
|
||||||
| Pillow | ultima stabile | Thumbnail automatici upload |
|
| Pillow | ultima stabile | Thumbnail automatici upload |
|
||||||
|
|
||||||
### Frontend (client/)
|
### Frontend (`src/frontend/flask_app/`)
|
||||||
|
|
||||||
| Componente | Versione | Ruolo |
|
| Componente | Versione | Ruolo |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Flask | 3.x | Framework web server-side |
|
| Flask | 3.x | Framework web server-side |
|
||||||
|
| gunicorn | 21+ | WSGI server (5 workers × 4 thread gthread) |
|
||||||
| Jinja2 | incluso in Flask | Template engine |
|
| Jinja2 | incluso in Flask | Template engine |
|
||||||
| Alpine.js | 3.x (CDN) | Reattivita leggera lato client |
|
| Alpine.js | 3.x (CDN) | Reattività leggera lato client |
|
||||||
| TailwindCSS | 3.x | CSS utility-first |
|
| TailwindCSS | 3.x | CSS utility-first |
|
||||||
| Plotly.js | CDN | Grafici SPC interattivi |
|
| Plotly.js | CDN | Grafici SPC interattivi |
|
||||||
| Fabric.js | 5.3.1 (CDN) | Editor annotazioni disegni tecnici |
|
| Fabric.js | 5.3.1 (CDN) | Editor annotazioni disegni tecnici |
|
||||||
|
| html5-qrcode | CDN | Scanner barcode/QR camera |
|
||||||
| Flask-Babel | ultima stabile | i18n IT/EN |
|
| Flask-Babel | ultima stabile | i18n IT/EN |
|
||||||
|
|
||||||
|
### Tooling
|
||||||
|
|
||||||
|
| Componente | Ruolo |
|
||||||
|
|---|---|
|
||||||
|
| **uv** | Package manager Python (no `requirements.txt`) |
|
||||||
|
| `pyproject.toml` | Dipendenze monorepo con extra `server`/`client`/`dev` |
|
||||||
|
| `uv.lock` | Lockfile per build riproducibili |
|
||||||
|
| `.python-version` | Pin Python 3.11 |
|
||||||
|
| pytest + pytest-asyncio + httpx + aiosqlite | Test stack |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Quick Start con Docker
|
## Quick Start con Docker
|
||||||
|
|
||||||
Docker Compose è il metodo raccomandato. Gestisce database, migrazioni e configurazione Nginx in un solo comando.
|
Docker Compose è il metodo raccomandato. Gestisce database, migrazioni, build delle immagini con `uv` e configurazione Nginx in un solo comando.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Clona il repository
|
# 1. Clona il repository
|
||||||
git clone <repository-url>
|
git clone ssh://git@git.tielogic.xyz:222/Adriano/TieMeasureFlow.git
|
||||||
cd TieMeasureFlow
|
cd TieMeasureFlow
|
||||||
|
|
||||||
# 2. Configura le variabili d'ambiente
|
# 2. Configura le variabili d'ambiente
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
# Modifica .env: credenziali DB, chiavi segrete, SETUP_PASSWORD
|
# Modifica .env: credenziali DB, chiavi segrete, SETUP_PASSWORD, STATION_CODE per ogni tablet
|
||||||
|
|
||||||
# 3. Avvia i servizi (ambiente di sviluppo)
|
# 3. Avvia i servizi (ambiente di sviluppo)
|
||||||
docker compose -f docker-compose.dev.yml up -d
|
docker compose -f docker-compose.dev.yml up -d --build
|
||||||
|
|
||||||
# 4. Verifica lo stato dei container
|
# 4. Verifica lo stato dei container
|
||||||
docker compose -f docker-compose.dev.yml ps
|
docker compose -f docker-compose.dev.yml ps
|
||||||
@@ -90,6 +107,7 @@ docker compose -f docker-compose.dev.yml ps
|
|||||||
# 5. Setup iniziale (solo al primo avvio)
|
# 5. Setup iniziale (solo al primo avvio)
|
||||||
# Apri http://localhost/api/setup nel browser
|
# Apri http://localhost/api/setup nel browser
|
||||||
# Usa SETUP_PASSWORD configurata in .env
|
# Usa SETUP_PASSWORD configurata in .env
|
||||||
|
# Lo script seed crea anche la stazione ST-DEFAULT con tutte le ricette assegnate
|
||||||
```
|
```
|
||||||
|
|
||||||
L'applicazione sarà disponibile su:
|
L'applicazione sarà disponibile su:
|
||||||
@@ -97,8 +115,9 @@ L'applicazione sarà disponibile su:
|
|||||||
- Frontend: http://localhost
|
- Frontend: http://localhost
|
||||||
- API: http://localhost/api
|
- API: http://localhost/api
|
||||||
- Pagina setup: http://localhost/api/setup
|
- Pagina setup: http://localhost/api/setup
|
||||||
|
- Admin stazioni: http://localhost/admin/stations (solo `is_admin`)
|
||||||
|
|
||||||
Per il deployment in produzione (Traefik + SSL) consulta [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md).
|
Per il deployment in produzione (Traefik + SSL) consulta [`docs/DEPLOYMENT.md`](docs/DEPLOYMENT.md).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -108,12 +127,14 @@ Dopo il primo avvio, la pagina `/api/setup` (protetta da `SETUP_PASSWORD`) perme
|
|||||||
|
|
||||||
- **Initialize Database** — crea tutte le tabelle
|
- **Initialize Database** — crea tutte le tabelle
|
||||||
- **Create Admin User** — crea l'utente amministratore con credenziali da `.env`
|
- **Create Admin User** — crea l'utente amministratore con credenziali da `.env`
|
||||||
- **Seed Demo Data** — carica ricette, misurazioni e utenti di esempio
|
- **Seed Demo Data** — carica ricette, misurazioni e utenti di esempio + crea stazione `ST-DEFAULT` con tutte le ricette assegnate
|
||||||
- **Reset Database** — elimina e ricrea tutte le tabelle (attenzione: cancella tutti i dati)
|
- **Reset Database** — elimina e ricrea tutte le tabelle (attenzione: cancella tutti i dati)
|
||||||
- **Gestione utenti** — crea, modifica, attiva/disattiva account dalla stessa pagina
|
- **Gestione utenti** — crea, modifica, attiva/disattiva account dalla stessa pagina
|
||||||
|
|
||||||
Se `SETUP_PASSWORD` è vuota o assente nel `.env`, l'endpoint è disabilitato.
|
Se `SETUP_PASSWORD` è vuota o assente nel `.env`, l'endpoint è disabilitato.
|
||||||
|
|
||||||
|
Per gestire stazioni e assegnazioni ricette dopo il setup: `/admin/stations` (richiede login admin).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Setup Manuale (Senza Docker)
|
## Setup Manuale (Senza Docker)
|
||||||
@@ -121,8 +142,9 @@ Se `SETUP_PASSWORD` è vuota o assente nel `.env`, l'endpoint è disabilitato.
|
|||||||
### Requisiti
|
### Requisiti
|
||||||
|
|
||||||
- Python 3.11 o superiore
|
- Python 3.11 o superiore
|
||||||
- Node.js 18 o superiore
|
- Node.js 18 o superiore (per TailwindCSS)
|
||||||
- MySQL 8.0
|
- MySQL 8.0
|
||||||
|
- [uv](https://docs.astral.sh/uv/) installato
|
||||||
|
|
||||||
### 1. Database MySQL
|
### 1. Database MySQL
|
||||||
|
|
||||||
@@ -139,35 +161,37 @@ SQL
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
# Imposta DB_HOST, DB_USER, DB_PASSWORD, DB_NAME, SERVER_SECRET_KEY, CLIENT_SECRET_KEY, SETUP_PASSWORD
|
# Imposta DB_HOST, DB_USER, DB_PASSWORD, DB_NAME, SERVER_SECRET_KEY,
|
||||||
|
# CLIENT_SECRET_KEY, SETUP_PASSWORD, STATION_CODE
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. Server FastAPI
|
### 3. Installa dipendenze (uv)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd server
|
uv sync --extra server --extra client --extra dev
|
||||||
python -m venv venv
|
|
||||||
source venv/bin/activate # Windows: venv\Scripts\activate
|
|
||||||
pip install -r requirements.txt
|
|
||||||
alembic -c migrations/alembic.ini upgrade head
|
|
||||||
uvicorn main:app --reload --host 0.0.0.0 --port 8000
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4. Client Flask
|
### 4. Backend FastAPI
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd client
|
uv run alembic -c src/backend/migrations/alembic.ini upgrade head
|
||||||
python -m venv venv
|
uv run uvicorn src.backend.main:app --reload --host 0.0.0.0 --port 8000
|
||||||
source venv/bin/activate # Windows: venv\Scripts\activate
|
|
||||||
pip install -r requirements.txt
|
|
||||||
pybabel compile -d translations
|
|
||||||
flask run --host 0.0.0.0 --port 5000
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 5. TailwindCSS (watch per sviluppo)
|
### 5. Frontend Flask
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd client
|
# Compila i cataloghi i18n una volta
|
||||||
|
cd src/frontend/flask_app && uv run --project ../../.. pybabel compile -d translations && cd -
|
||||||
|
|
||||||
|
# Avvia (development)
|
||||||
|
cd src/frontend/flask_app && uv run --project ../../.. flask run --host 0.0.0.0 --port 5000
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. TailwindCSS (watch per sviluppo)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd src/frontend/flask_app
|
||||||
npx tailwindcss -i static/css/input.css -o static/css/tailwind.css --watch
|
npx tailwindcss -i static/css/input.css -o static/css/tailwind.css --watch
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -185,9 +209,9 @@ I ruoli sono combinabili (array JSON per utente). Il flag `is_admin` è separato
|
|||||||
| Ruolo | Descrizione |
|
| Ruolo | Descrizione |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **Maker** | Crea e gestisce ricette di misurazione: caricamento disegni (PDF/immagini), annotazioni Fabric.js, definizione task/subtask, configurazione tolleranze, versioning copy-on-write |
|
| **Maker** | Crea e gestisce ricette di misurazione: caricamento disegni (PDF/immagini), annotazioni Fabric.js, definizione task/subtask, configurazione tolleranze, versioning copy-on-write |
|
||||||
| **MeasurementTec** | Esegue misurazioni: scansione barcode per selezione ricetta, interfaccia task-driven, input da calibro USB HID o numpad touch, validazione real-time pass/warning/fail |
|
| **MeasurementTec** | Esegue misurazioni: scansione barcode per selezione ricetta, interfaccia task-driven, input da calibro USB HID o numpad touch, validazione real-time pass/warning/fail. Vede solo le ricette assegnate alla propria stazione (`STATION_CODE`) |
|
||||||
| **Metrologist** | Analisi qualità: dashboard SPC (X-bar, R, Cp, Cpk, Pp, Ppk), filtri multi-dimensionali, export report PDF, analisi capability e control chart |
|
| **Metrologist** | Analisi qualità: dashboard SPC (X-bar, R, Cp, Cpk, Pp, Ppk), filtri multi-dimensionali, export report PDF, analisi capability e control chart |
|
||||||
| **Admin** (flag) | Gestione sistema: CRUD utenti, cambio password, attivazione/disattivazione account |
|
| **Admin** (flag) | Gestione sistema: CRUD utenti, cambio password, attivazione/disattivazione account, **CRUD stazioni e assegnazioni ricette** |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -195,39 +219,53 @@ I ruoli sono combinabili (array JSON per utente). Il flag `is_admin` è separato
|
|||||||
|
|
||||||
```
|
```
|
||||||
TieMeasureFlow/
|
TieMeasureFlow/
|
||||||
├── server/ # FastAPI Backend
|
├── pyproject.toml # Dipendenze monorepo (uv)
|
||||||
│ ├── main.py # Entry point, lifespan, middleware, 10 router
|
├── uv.lock # Lockfile riproducibile
|
||||||
│ ├── config.py # Settings (pydantic_settings.BaseSettings)
|
├── .python-version # 3.11
|
||||||
│ ├── database.py # SQLAlchemy 2.0 async engine
|
├── Dockerfile # Backend (uv + uvicorn)
|
||||||
│ ├── models/ # ORM: User, Recipe, RecipeVersion, RecipeTask,
|
├── Dockerfile.frontend # Frontend (uv + gunicorn + Tailwind + Babel)
|
||||||
│ │ # RecipeSubtask, Measurement, AccessLog,
|
├── docker-compose.dev.yml # Sviluppo (Nginx, porta 80)
|
||||||
│ │ # SystemSetting, RecipeVersionAudit
|
├── docker-compose.yml # Produzione (Traefik, SSL)
|
||||||
│ ├── schemas/ # Pydantic v2 per validazione I/O API
|
├── nginx/ # Config Nginx (dev)
|
||||||
│ ├── routers/ # auth, users, recipes, tasks, measurements,
|
├── uploads/ # Volume Docker file caricati
|
||||||
│ │ # files, settings, statistics, reports, setup
|
├── docs/ # Documentazione (vedi indice docs/README.md)
|
||||||
│ ├── services/ # recipe_service, measurement_service,
|
└── src/
|
||||||
│ │ # spc_service, report_service, auth_service
|
├── backend/ # FastAPI Backend
|
||||||
│ ├── middleware/ # api_key, rate_limit, security_headers, logging
|
│ ├── main.py # Entry point, lifespan, middleware, 11 router
|
||||||
│ ├── migrations/ # Alembic (alembic.ini + env.py)
|
│ ├── config.py # Settings (pydantic_settings.BaseSettings)
|
||||||
│ └── templates/ # Template HTML pagina setup
|
│ ├── database.py # SQLAlchemy 2.0 async engine
|
||||||
├── client/ # Flask Frontend
|
│ ├── api/
|
||||||
│ ├── app.py # Factory pattern, CSRF, Babel
|
│ │ ├── routers/ # auth, users, recipes, tasks, measurements,
|
||||||
│ ├── blueprints/ # auth, maker, measure, statistics, admin
|
│ │ │ # files, settings, statistics, reports,
|
||||||
│ ├── services/ # APIClient singleton (proxy verso FastAPI)
|
│ │ │ # setup, stations
|
||||||
│ ├── templates/ # Jinja2 + Alpine.js
|
│ │ └── middleware/ # api_key, rate_limit, security_headers, logging
|
||||||
│ ├── static/
|
│ ├── models/
|
||||||
│ │ ├── css/ # TailwindCSS compilato
|
│ │ ├── orm/ # SQLAlchemy: User, Recipe, RecipeVersion,
|
||||||
│ │ └── js/ # numpad, caliper, barcode, csv-export,
|
│ │ │ # RecipeTask, RecipeSubtask, Measurement,
|
||||||
│ │ # spc-charts, annotation-editor/viewer
|
│ │ │ # AccessLog, SystemSetting,
|
||||||
│ └── translations/ # Flask-Babel .po/.mo IT/EN
|
│ │ │ # RecipeVersionAudit, Station,
|
||||||
├── nginx/ # Configurazione Nginx (dev)
|
│ │ │ # StationRecipeAssignment
|
||||||
├── docs/ # Documentazione tecnica
|
│ │ └── api/ # Pydantic v2 schemas request/response
|
||||||
│ ├── API.md # Riferimento API REST
|
│ ├── services/ # recipe_service, measurement_service,
|
||||||
│ ├── DEPLOYMENT.md # Guida deployment VPS (Traefik, SSL, DNS)
|
│ │ # spc_service, report_service,
|
||||||
│ └── USER_GUIDE.md # Manuale utente per ruolo
|
│ │ # auth_service, station_service
|
||||||
├── docker-compose.yml # Produzione (Traefik, SSL)
|
│ ├── migrations/ # Alembic (alembic.ini + env.py)
|
||||||
├── docker-compose.dev.yml # Sviluppo (Nginx, porta 80)
|
│ ├── templates/ # Pagina setup (Jinja2)
|
||||||
└── .env.example # Template variabili d'ambiente
|
│ └── 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 |
|
| Comando | Descrizione |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `docker compose -f docker-compose.dev.yml up -d` | Avvia servizi in sviluppo |
|
| `docker compose -f docker-compose.dev.yml up -d --build` | Avvia servizi in sviluppo (build incluso) |
|
||||||
| `docker compose -f docker-compose.dev.yml down` | Ferma e rimuove i container |
|
| `docker compose -f docker-compose.dev.yml down` | Ferma e rimuove i container |
|
||||||
| `docker compose logs -f server` | Segui log server in tempo reale |
|
| `docker compose logs -f server` | Segui log server in tempo reale |
|
||||||
| `docker compose logs -f client` | Segui log client in tempo reale |
|
| `docker compose logs -f client` | Segui log client in tempo reale |
|
||||||
@@ -248,47 +286,62 @@ TieMeasureFlow/
|
|||||||
|
|
||||||
### Alembic (migrations)
|
### Alembic (migrations)
|
||||||
|
|
||||||
Nota: `alembic.ini` si trova dentro `server/migrations/`, è richiesto il flag `-c`.
|
`alembic.ini` si trova in `src/backend/migrations/`, è richiesto il flag `-c`. `env.py` aggiunge la project root a `sys.path` per risolvere `src.backend.*`.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd server
|
uv run alembic -c src/backend/migrations/alembic.ini upgrade head # Applica migrazioni
|
||||||
alembic -c migrations/alembic.ini upgrade head # Applica migrazioni
|
uv run alembic -c src/backend/migrations/alembic.ini revision --autogenerate -m "descrizione" # Genera
|
||||||
alembic -c migrations/alembic.ini revision --autogenerate -m "descrizione" # Genera migrazione
|
uv run alembic -c src/backend/migrations/alembic.ini downgrade -1 # Rollback ultima
|
||||||
alembic -c migrations/alembic.ini downgrade -1 # Rollback ultima
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Via Docker:
|
Via Docker:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose exec server alembic -c migrations/alembic.ini upgrade head
|
docker compose exec server uv run alembic -c src/backend/migrations/alembic.ini upgrade head
|
||||||
```
|
```
|
||||||
|
|
||||||
### i18n (Traduzioni)
|
### i18n (Traduzioni)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd client
|
cd src/frontend/flask_app
|
||||||
pybabel extract -F babel.cfg -k _ -o translations/messages.pot . # Estrai stringhe
|
uv run --project ../../.. pybabel extract -F babel.cfg -k _ -o translations/messages.pot . # Estrai
|
||||||
pybabel update -i translations/messages.pot -d translations # Aggiorna catalogo
|
uv run --project ../../.. pybabel update -i translations/messages.pot -d translations # Aggiorna
|
||||||
pybabel compile -d translations # Compila .po → .mo
|
uv run --project ../../.. pybabel compile -d translations # Compila .po → .mo
|
||||||
|
```
|
||||||
|
|
||||||
|
### Gestione dipendenze (uv)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv add <pacchetto> # core (entrambi)
|
||||||
|
uv add --optional server <pacchetto> # solo backend
|
||||||
|
uv add --optional client <pacchetto> # solo frontend
|
||||||
|
uv add --optional dev <pacchetto> # solo dev/test
|
||||||
|
uv sync --extra server --extra client --extra dev # reinstalla
|
||||||
|
uv lock # rigenera uv.lock
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
Il server usa SQLite in-memory tramite `aiosqlite`: i test girano senza MySQL installato.
|
Il backend usa SQLite in-memory tramite `aiosqlite`: i test girano senza MySQL installato.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Server
|
# Tutti i test (backend + frontend)
|
||||||
cd server && pytest # Tutti i test
|
uv run pytest
|
||||||
cd server && pytest tests/test_auth.py # Singolo modulo
|
|
||||||
cd server && pytest tests/test_auth.py::test_login_success # Singolo test
|
|
||||||
cd server && pytest --cov # Con copertura
|
|
||||||
|
|
||||||
# Client
|
# Solo backend
|
||||||
cd client && pytest
|
uv run pytest src/backend/tests/
|
||||||
|
uv run pytest src/backend/tests/test_auth.py
|
||||||
|
uv run pytest src/backend/tests/test_auth.py::test_login_success
|
||||||
|
uv run pytest --cov src/backend
|
||||||
|
|
||||||
|
# Solo frontend
|
||||||
|
uv run pytest src/frontend/flask_app/tests/
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Stato corrente: **171 pass, 4 fail pre-esistenti** (vedi `docs/architecture/STATO_PROGETTO.md`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Variabili d'Ambiente
|
## Variabili d'Ambiente
|
||||||
@@ -304,8 +357,11 @@ Copia `.env.example` in `.env` e configura:
|
|||||||
| `SERVER_CORS_ORIGINS` | Origini CORS ammesse |
|
| `SERVER_CORS_ORIGINS` | Origini CORS ammesse |
|
||||||
| `CLIENT_SECRET_KEY` | Chiave segreta Flask (sessioni, CSRF) |
|
| `CLIENT_SECRET_KEY` | Chiave segreta Flask (sessioni, CSRF) |
|
||||||
| `API_SERVER_URL` | URL del backend visto dal client (es. `http://server:8000`) |
|
| `API_SERVER_URL` | URL del backend visto dal client (es. `http://server:8000`) |
|
||||||
| `UPLOAD_DIR` | Percorso upload file (default: `uploads`, relativo a `server/`) |
|
| `STATION_CODE` | **Per-tablet** — codice stazione (es. `ST-001`). Senza, il client mostra errore configurazione. |
|
||||||
| `MAX_UPLOAD_SIZE_MB` | Limite dimensione upload |
|
| `UPLOAD_DIR` | Percorso upload file (default: `uploads`, project root) |
|
||||||
|
| `MAX_UPLOAD_SIZE_MB` | Limite dimensione upload (default 50) |
|
||||||
|
| `RATE_LIMIT_LOGIN` | Login req/min/IP (default 5) |
|
||||||
|
| `RATE_LIMIT_GENERAL` | Richieste req/min/IP (default 300, per-tablet) |
|
||||||
| `NGINX_PORT`, `NGINX_SSL_PORT` | Porte Nginx (solo compose dev) |
|
| `NGINX_PORT`, `NGINX_SSL_PORT` | Porte Nginx (solo compose dev) |
|
||||||
| `SETUP_PASSWORD` | Password pagina setup (vuota = endpoint disabilitato) |
|
| `SETUP_PASSWORD` | Password pagina setup (vuota = endpoint disabilitato) |
|
||||||
| `SSL_CERTFILE`, `SSL_KEYFILE` | Certificato SSL (solo setup manuale) |
|
| `SSL_CERTFILE`, `SSL_KEYFILE` | Certificato SSL (solo setup manuale) |
|
||||||
@@ -314,16 +370,35 @@ Copia `.env.example` in `.env` e configura:
|
|||||||
|
|
||||||
## Documentazione
|
## Documentazione
|
||||||
|
|
||||||
|
Indice completo: [`docs/README.md`](docs/README.md).
|
||||||
|
|
||||||
|
### Stato e direzione
|
||||||
|
|
||||||
| Documento | Contenuto |
|
| Documento | Contenuto |
|
||||||
|---|---|
|
|---|---|
|
||||||
| [docs/API.md](docs/API.md) | Riferimento completo API REST (endpoint, parametri, schemi) |
|
| [`docs/architecture/STATO_PROGETTO.md`](docs/architecture/STATO_PROGETTO.md) | Snapshot V2.0.0: cosa funziona oggi, test status, decisioni architetturali |
|
||||||
| [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md) | Guida deployment VPS: Docker, Traefik, SSL, DNS, firewall |
|
| [`docs/architecture/ROADMAP.md`](docs/architecture/ROADMAP.md) | Cosa resta da fare (Fasi 2-7 rev04, decisioni cliente aperte, stime) |
|
||||||
| [docs/USER_GUIDE.md](docs/USER_GUIDE.md) | Manuale utente per ruolo (Maker, MeasurementTec, Metrologist) |
|
|
||||||
|
### Riferimenti operativi
|
||||||
|
|
||||||
|
| Documento | Contenuto |
|
||||||
|
|---|---|
|
||||||
|
| [`docs/API.md`](docs/API.md) | Riferimento completo API REST (endpoint, parametri, schemi) |
|
||||||
|
| [`docs/DEPLOYMENT.md`](docs/DEPLOYMENT.md) | Guida deployment VPS: Docker, Traefik, SSL, DNS, firewall |
|
||||||
|
| [`docs/USER_GUIDE.md`](docs/USER_GUIDE.md) | Manuale utente per ruolo (Maker, MeasurementTec, Metrologist) |
|
||||||
|
| [`docs/I18N_SETUP.md`](docs/I18N_SETUP.md) | Setup e workflow traduzioni (Flask-Babel + Alpine.js) |
|
||||||
|
|
||||||
|
### Piani dettagliati
|
||||||
|
|
||||||
|
| Documento | Contenuto |
|
||||||
|
|---|---|
|
||||||
|
| [`docs/superpowers/plans/2026-04-17-rev04-master-roadmap.md`](docs/superpowers/plans/2026-04-17-rev04-master-roadmap.md) | Master plan rev04 (M1 + M2, decisioni aperte, stime) |
|
||||||
|
| [`docs/superpowers/plans/2026-04-17-rev04-phase1-stations.md`](docs/superpowers/plans/2026-04-17-rev04-phase1-stations.md) | Piano TDD Fase 1 stazioni (completato) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Licenza
|
## Licenza
|
||||||
|
|
||||||
Proprietary - Tielogic. All rights reserved.
|
Proprietary — Tielogic. All rights reserved.
|
||||||
|
|
||||||
Questo software è di proprietà esclusiva di Tielogic ed è protetto dalle leggi sul copyright. Non è consentita la distribuzione, modifica o utilizzo senza autorizzazione scritta.
|
Questo software è di proprietà esclusiva di Tielogic ed è protetto dalle leggi sul copyright. Non è consentita la distribuzione, modifica o utilizzo senza autorizzazione scritta.
|
||||||
|
|||||||
@@ -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:
|
server:
|
||||||
build:
|
build:
|
||||||
context: ./server
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: tmflow-server
|
container_name: tmflow-server
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -43,8 +43,8 @@ services:
|
|||||||
|
|
||||||
client:
|
client:
|
||||||
build:
|
build:
|
||||||
context: ./client
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile.frontend
|
||||||
container_name: tmflow-client
|
container_name: tmflow-client
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file:
|
env_file:
|
||||||
|
|||||||
+3
-3
@@ -24,7 +24,7 @@ services:
|
|||||||
|
|
||||||
server:
|
server:
|
||||||
build:
|
build:
|
||||||
context: ./server
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: tmflow-server
|
container_name: tmflow-server
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -52,8 +52,8 @@ services:
|
|||||||
|
|
||||||
client:
|
client:
|
||||||
build:
|
build:
|
||||||
context: ./client
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile.frontend
|
||||||
container_name: tmflow-client
|
container_name: tmflow-client
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file:
|
env_file:
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ TieMeasureFlow supports complete internationalization (i18n) with:
|
|||||||
## Directory Structure
|
## Directory Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
client/
|
src/frontend/flask_app/
|
||||||
├── translations/ # Flask-Babel translations
|
├── translations/ # Flask-Babel translations
|
||||||
│ ├── babel.cfg # Extraction config
|
│ ├── babel.cfg # Extraction config
|
||||||
│ ├── it/LC_MESSAGES/
|
│ ├── it/LC_MESSAGES/
|
||||||
@@ -99,7 +99,7 @@ flash(_("Profilo aggiornato con successo"))
|
|||||||
After editing .po files:
|
After editing .po files:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd client
|
cd src/frontend/flask_app
|
||||||
python compile_translations.py
|
python compile_translations.py
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# 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. |
|
||||||
|
| `.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`. |
|
||||||
|
|
||||||
|
## 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,122 @@
|
|||||||
|
# Stato Progetto TieMeasureFlow — V2.0.0
|
||||||
|
|
||||||
|
> Snapshot al 2026-04-25. 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.
|
||||||
|
|
||||||
|
## 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/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test status
|
||||||
|
|
||||||
|
| Suite | Pass | Fail | Note |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 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/`) | 44 | 1 | Fail pre-esistente: `test_save_measurement_proxy`. |
|
||||||
|
| **Totale** | **171** | **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."""
|
"""FastAPI middleware for TieMeasureFlow."""
|
||||||
from middleware.api_key import (
|
from src.backend.api.middleware.api_key import (
|
||||||
get_current_user,
|
get_current_user,
|
||||||
require_role,
|
require_role,
|
||||||
require_admin,
|
require_admin,
|
||||||
@@ -8,9 +8,9 @@ from middleware.api_key import (
|
|||||||
require_metrologist,
|
require_metrologist,
|
||||||
require_admin_user,
|
require_admin_user,
|
||||||
)
|
)
|
||||||
from middleware.logging import AccessLogMiddleware
|
from src.backend.api.middleware.logging import AccessLogMiddleware
|
||||||
from middleware.rate_limit import RateLimitMiddleware
|
from src.backend.api.middleware.rate_limit import RateLimitMiddleware
|
||||||
from middleware.security_headers import SecurityHeadersMiddleware
|
from src.backend.api.middleware.security_headers import SecurityHeadersMiddleware
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"get_current_user",
|
"get_current_user",
|
||||||
@@ -3,8 +3,8 @@ from fastapi import Depends, HTTPException, Request, status
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from database import get_db
|
from src.backend.database import get_db
|
||||||
from models.user import User
|
from src.backend.models.orm.user import User
|
||||||
|
|
||||||
|
|
||||||
async def get_current_user(
|
async def get_current_user(
|
||||||
@@ -6,8 +6,8 @@ from fastapi import Request, Response
|
|||||||
from starlette.middleware.base import BaseHTTPMiddleware
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
from sqlalchemy import insert
|
from sqlalchemy import insert
|
||||||
|
|
||||||
from database import async_session_factory
|
from src.backend.database import async_session_factory
|
||||||
from models.access_log import AccessLog
|
from src.backend.models.orm.access_log import AccessLog
|
||||||
|
|
||||||
|
|
||||||
class AccessLogMiddleware(BaseHTTPMiddleware):
|
class AccessLogMiddleware(BaseHTTPMiddleware):
|
||||||
@@ -11,7 +11,7 @@ from fastapi import Request, Response
|
|||||||
from starlette.middleware.base import BaseHTTPMiddleware
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
from starlette.responses import JSONResponse
|
from starlette.responses import JSONResponse
|
||||||
|
|
||||||
from config import settings
|
from src.backend.config import settings
|
||||||
|
|
||||||
|
|
||||||
class RateLimitMiddleware(BaseHTTPMiddleware):
|
class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||||
@@ -32,6 +32,25 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
|||||||
self._general_requests: dict[str, list[float]] = defaultdict(list)
|
self._general_requests: dict[str, list[float]] = defaultdict(list)
|
||||||
self._request_count = 0 # Counter for triggering eviction
|
self._request_count = 0 # Counter for triggering eviction
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _client_ip(request: Request) -> str:
|
||||||
|
"""Resolve the originating client IP, honoring proxy headers.
|
||||||
|
|
||||||
|
Order of precedence: ``X-Forwarded-For`` (first hop), ``X-Real-IP``,
|
||||||
|
``request.client.host``. Required because Nginx and the Flask client
|
||||||
|
sit between the tablet and the API; without parsing these headers
|
||||||
|
every tablet shares one bucket.
|
||||||
|
"""
|
||||||
|
xff = request.headers.get("x-forwarded-for")
|
||||||
|
if xff:
|
||||||
|
first = xff.split(",")[0].strip()
|
||||||
|
if first:
|
||||||
|
return first
|
||||||
|
real = request.headers.get("x-real-ip")
|
||||||
|
if real:
|
||||||
|
return real.strip()
|
||||||
|
return request.client.host if request.client else "unknown"
|
||||||
|
|
||||||
def _clean_window(self, timestamps: list[float], now: float) -> list[float]:
|
def _clean_window(self, timestamps: list[float], now: float) -> list[float]:
|
||||||
"""Remove timestamps outside the current sliding window."""
|
"""Remove timestamps outside the current sliding window."""
|
||||||
cutoff = now - self.WINDOW_SECONDS
|
cutoff = now - self.WINDOW_SECONDS
|
||||||
@@ -68,7 +87,7 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
|||||||
return True, 0
|
return True, 0
|
||||||
|
|
||||||
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
||||||
client_ip = request.client.host if request.client else "unknown"
|
client_ip = self._client_ip(request)
|
||||||
now = time.time()
|
now = time.time()
|
||||||
path = request.url.path
|
path = request.url.path
|
||||||
|
|
||||||
+1
-1
@@ -8,7 +8,7 @@ from typing import Callable
|
|||||||
from fastapi import Request, Response
|
from fastapi import Request, Response
|
||||||
from starlette.middleware.base import BaseHTTPMiddleware
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
|
||||||
from config import settings
|
from src.backend.config import settings
|
||||||
|
|
||||||
# Content Security Policy - allows CDN resources used by the client
|
# Content Security Policy - allows CDN resources used by the client
|
||||||
# Note: 'unsafe-eval' required for Plotly.js runtime evaluation in SPC charts
|
# Note: 'unsafe-eval' required for Plotly.js runtime evaluation in SPC charts
|
||||||
@@ -2,16 +2,16 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from database import get_db
|
from src.backend.database import get_db
|
||||||
from middleware.api_key import get_current_user
|
from src.backend.api.middleware.api_key import get_current_user
|
||||||
from models.user import User
|
from src.backend.models.orm.user import User
|
||||||
from schemas.user import (
|
from src.backend.models.api.user import (
|
||||||
LoginRequest,
|
LoginRequest,
|
||||||
LoginResponse,
|
LoginResponse,
|
||||||
UserProfileUpdate,
|
UserProfileUpdate,
|
||||||
UserResponse,
|
UserResponse,
|
||||||
)
|
)
|
||||||
from services.auth_service import authenticate_user, login_user, logout_user
|
from src.backend.services.auth_service import authenticate_user, login_user, logout_user
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||||
|
|
||||||
@@ -7,9 +7,9 @@ from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
|
|||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
from config import settings
|
from src.backend.config import settings
|
||||||
from middleware.api_key import get_current_user, require_maker
|
from src.backend.api.middleware.api_key import get_current_user, require_maker
|
||||||
from models.user import User
|
from src.backend.models.orm.user import User
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/files", tags=["files"])
|
router = APIRouter(prefix="/api/files", tags=["files"])
|
||||||
|
|
||||||
@@ -8,23 +8,23 @@ from fastapi.responses import StreamingResponse
|
|||||||
from sqlalchemy import and_, func, select
|
from sqlalchemy import and_, func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from database import get_db
|
from src.backend.database import get_db
|
||||||
from middleware.api_key import (
|
from src.backend.api.middleware.api_key import (
|
||||||
get_current_user,
|
get_current_user,
|
||||||
require_measurement_tec,
|
require_measurement_tec,
|
||||||
require_metrologist,
|
require_metrologist,
|
||||||
)
|
)
|
||||||
from models.measurement import Measurement
|
from src.backend.models.orm.measurement import Measurement
|
||||||
from models.recipe import RecipeVersion
|
from src.backend.models.orm.recipe import RecipeVersion
|
||||||
from models.setting import SystemSetting
|
from src.backend.models.orm.setting import SystemSetting
|
||||||
from models.user import User
|
from src.backend.models.orm.user import User
|
||||||
from schemas.measurement import (
|
from src.backend.models.api.measurement import (
|
||||||
MeasurementBatchCreate,
|
MeasurementBatchCreate,
|
||||||
MeasurementCreate,
|
MeasurementCreate,
|
||||||
MeasurementListResponse,
|
MeasurementListResponse,
|
||||||
MeasurementResponse,
|
MeasurementResponse,
|
||||||
)
|
)
|
||||||
from services.measurement_service import save_measurement
|
from src.backend.services.measurement_service import save_measurement
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/measurements", tags=["measurements"])
|
router = APIRouter(prefix="/api/measurements", tags=["measurements"])
|
||||||
|
|
||||||
@@ -2,17 +2,17 @@
|
|||||||
from fastapi import APIRouter, Depends, Query
|
from fastapi import APIRouter, Depends, Query
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from database import get_db
|
from src.backend.database import get_db
|
||||||
from middleware.api_key import get_current_user, require_maker, require_measurement_tec
|
from src.backend.api.middleware.api_key import get_current_user, require_maker, require_measurement_tec
|
||||||
from models.user import User
|
from src.backend.models.orm.user import User
|
||||||
from schemas.recipe import (
|
from src.backend.models.api.recipe import (
|
||||||
RecipeCreate,
|
RecipeCreate,
|
||||||
RecipeListResponse,
|
RecipeListResponse,
|
||||||
RecipeResponse,
|
RecipeResponse,
|
||||||
RecipeUpdate,
|
RecipeUpdate,
|
||||||
RecipeVersionResponse,
|
RecipeVersionResponse,
|
||||||
)
|
)
|
||||||
from services import recipe_service
|
from src.backend.services import recipe_service
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/recipes", tags=["recipes"])
|
router = APIRouter(prefix="/api/recipes", tags=["recipes"])
|
||||||
|
|
||||||
@@ -5,10 +5,10 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
|
|||||||
from fastapi.responses import Response
|
from fastapi.responses import Response
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from database import get_db
|
from src.backend.database import get_db
|
||||||
from middleware.api_key import require_metrologist
|
from src.backend.api.middleware.api_key import require_metrologist
|
||||||
from models.user import User
|
from src.backend.models.orm.user import User
|
||||||
from services.report_service import generate_measurement_report, generate_spc_report
|
from src.backend.services.report_service import generate_measurement_report, generate_spc_report
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/reports", tags=["reports"])
|
router = APIRouter(prefix="/api/reports", tags=["reports"])
|
||||||
|
|
||||||
@@ -5,11 +5,11 @@ from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from config import settings
|
from src.backend.config import settings
|
||||||
from database import get_db
|
from src.backend.database import get_db
|
||||||
from middleware.api_key import get_current_user, require_admin_user
|
from src.backend.api.middleware.api_key import get_current_user, require_admin_user
|
||||||
from models.setting import SystemSetting
|
from src.backend.models.orm.setting import SystemSetting
|
||||||
from models.user import User
|
from src.backend.models.orm.user import User
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/settings", tags=["settings"])
|
router = APIRouter(prefix="/api/settings", tags=["settings"])
|
||||||
|
|
||||||
@@ -15,14 +15,14 @@ from pydantic import BaseModel
|
|||||||
from sqlalchemy import inspect as sa_inspect, select
|
from sqlalchemy import inspect as sa_inspect, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from config import settings
|
from src.backend.config import settings
|
||||||
from database import Base, engine, async_session_factory
|
from src.backend.database import Base, engine, async_session_factory
|
||||||
from models import (
|
from src.backend.models.orm import (
|
||||||
User, Recipe, RecipeVersion, RecipeTask, RecipeSubtask, Measurement,
|
User, Recipe, RecipeVersion, RecipeTask, RecipeSubtask, Measurement,
|
||||||
)
|
)
|
||||||
from models.station import Station, StationRecipeAssignment
|
from src.backend.models.orm.station import Station, StationRecipeAssignment
|
||||||
from services.auth_service import hash_password
|
from src.backend.services.auth_service import hash_password
|
||||||
from services.measurement_service import calculate_pass_fail
|
from src.backend.services.measurement_service import calculate_pass_fail
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/setup", tags=["setup"])
|
router = APIRouter(prefix="/api/setup", tags=["setup"])
|
||||||
|
|
||||||
@@ -2,10 +2,10 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from database import get_db
|
from src.backend.database import get_db
|
||||||
from middleware.api_key import get_current_user, require_admin_user
|
from src.backend.api.middleware.api_key import get_current_user, require_admin_user
|
||||||
from models.user import User
|
from src.backend.models.orm.user import User
|
||||||
from schemas.station import (
|
from src.backend.models.api.station import (
|
||||||
StationCreate,
|
StationCreate,
|
||||||
StationUpdate,
|
StationUpdate,
|
||||||
StationResponse,
|
StationResponse,
|
||||||
@@ -13,7 +13,7 @@ from schemas.station import (
|
|||||||
StationRecipeAssignmentResponse,
|
StationRecipeAssignmentResponse,
|
||||||
RecipeSummary,
|
RecipeSummary,
|
||||||
)
|
)
|
||||||
from services import station_service
|
from src.backend.services import station_service
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/stations", tags=["stations"])
|
router = APIRouter(prefix="/api/stations", tags=["stations"])
|
||||||
|
|
||||||
@@ -5,19 +5,19 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
|
|||||||
from sqlalchemy import and_, select
|
from sqlalchemy import and_, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from database import get_db
|
from src.backend.database import get_db
|
||||||
from middleware.api_key import require_metrologist
|
from src.backend.api.middleware.api_key import require_metrologist
|
||||||
from models.measurement import Measurement
|
from src.backend.models.orm.measurement import Measurement
|
||||||
from models.recipe import RecipeVersion
|
from src.backend.models.orm.recipe import RecipeVersion
|
||||||
from models.task import RecipeSubtask, RecipeTask
|
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
||||||
from models.user import User
|
from src.backend.models.orm.user import User
|
||||||
from schemas.statistics import (
|
from src.backend.models.api.statistics import (
|
||||||
CapabilityData,
|
CapabilityData,
|
||||||
ControlChartData,
|
ControlChartData,
|
||||||
HistogramData,
|
HistogramData,
|
||||||
SummaryData,
|
SummaryData,
|
||||||
)
|
)
|
||||||
from services.spc_service import (
|
from src.backend.services.spc_service import (
|
||||||
compute_capability,
|
compute_capability,
|
||||||
compute_control_chart,
|
compute_control_chart,
|
||||||
compute_histogram,
|
compute_histogram,
|
||||||
@@ -4,12 +4,12 @@ from sqlalchemy import select
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from database import get_db
|
from src.backend.database import get_db
|
||||||
from middleware.api_key import require_maker, get_current_user
|
from src.backend.api.middleware.api_key import require_maker, get_current_user
|
||||||
from models.recipe import Recipe, RecipeVersion
|
from src.backend.models.orm.recipe import Recipe, RecipeVersion
|
||||||
from models.task import RecipeSubtask, RecipeTask
|
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
||||||
from models.user import User
|
from src.backend.models.orm.user import User
|
||||||
from schemas.task import (
|
from src.backend.models.api.task import (
|
||||||
SubtaskCreate,
|
SubtaskCreate,
|
||||||
SubtaskResponse,
|
SubtaskResponse,
|
||||||
SubtaskUpdate,
|
SubtaskUpdate,
|
||||||
@@ -18,7 +18,7 @@ from schemas.task import (
|
|||||||
TaskResponse,
|
TaskResponse,
|
||||||
TaskUpdate,
|
TaskUpdate,
|
||||||
)
|
)
|
||||||
from services import recipe_service
|
from src.backend.services import recipe_service
|
||||||
|
|
||||||
router = APIRouter(tags=["tasks"])
|
router = APIRouter(tags=["tasks"])
|
||||||
|
|
||||||
@@ -159,7 +159,7 @@ async def create_task(
|
|||||||
|
|
||||||
if has_measurements:
|
if has_measurements:
|
||||||
# Copy-on-write: create new version preserving measurement data
|
# Copy-on-write: create new version preserving measurement data
|
||||||
from schemas.recipe import RecipeUpdate
|
from src.backend.models.api.recipe import RecipeUpdate
|
||||||
new_version = await recipe_service.create_new_version(
|
new_version = await recipe_service.create_new_version(
|
||||||
db, recipe_id, RecipeUpdate(change_notes=f"Added task: {data.title}"), user
|
db, recipe_id, RecipeUpdate(change_notes=f"Added task: {data.title}"), user
|
||||||
)
|
)
|
||||||
@@ -3,11 +3,11 @@ from fastapi import APIRouter, Depends, HTTPException, status
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from database import get_db
|
from src.backend.database import get_db
|
||||||
from middleware.api_key import require_admin_user
|
from src.backend.api.middleware.api_key import require_admin_user
|
||||||
from models.user import User
|
from src.backend.models.orm.user import User
|
||||||
from schemas.user import UserCreate, UserPasswordChange, UserResponse, UserUpdate
|
from src.backend.models.api.user import UserCreate, UserPasswordChange, UserResponse, UserUpdate
|
||||||
from services.auth_service import create_user, hash_password, regenerate_api_key
|
from src.backend.services.auth_service import create_user, hash_password, regenerate_api_key
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/users", tags=["users"])
|
router = APIRouter(prefix="/api/users", tags=["users"])
|
||||||
|
|
||||||
@@ -23,9 +23,9 @@ class Settings(BaseSettings):
|
|||||||
upload_dir: str = "uploads"
|
upload_dir: str = "uploads"
|
||||||
max_upload_size_mb: int = 50
|
max_upload_size_mb: int = 50
|
||||||
|
|
||||||
# Rate Limiting (requests per minute)
|
# Rate Limiting (requests per minute, per real client IP)
|
||||||
rate_limit_login: int = 5
|
rate_limit_login: int = 5
|
||||||
rate_limit_general: int = 100
|
rate_limit_general: int = 300
|
||||||
|
|
||||||
# SSL (Production)
|
# SSL (Production)
|
||||||
ssl_certfile: str | None = None
|
ssl_certfile: str | None = None
|
||||||
@@ -49,10 +49,16 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def upload_path(self) -> Path:
|
def upload_path(self) -> Path:
|
||||||
"""Absolute path to upload directory."""
|
"""Absolute path to upload directory.
|
||||||
return Path(__file__).parent / self.upload_dir
|
|
||||||
|
|
||||||
model_config = {"env_file": "../.env", "env_file_encoding": "utf-8", "extra": "ignore"}
|
After the V2.0.0 restructure, uploads live at the project root
|
||||||
|
(mounted as a Docker volume), not inside the backend tree.
|
||||||
|
"""
|
||||||
|
# Path(__file__) = src/backend/config.py → parents[2] = project root
|
||||||
|
return Path(__file__).resolve().parents[2] / self.upload_dir
|
||||||
|
|
||||||
|
# ../../.env reaches the project root from src/backend/.
|
||||||
|
model_config = {"env_file": "../../.env", "env_file_encoding": "utf-8", "extra": "ignore"}
|
||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
@@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import (
|
|||||||
)
|
)
|
||||||
from sqlalchemy.orm import DeclarativeBase
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
from config import settings
|
from src.backend.config import settings
|
||||||
|
|
||||||
# Create async engine
|
# Create async engine
|
||||||
engine = create_async_engine(
|
engine = create_async_engine(
|
||||||
@@ -5,22 +5,22 @@ from collections.abc import AsyncGenerator
|
|||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from config import settings
|
from src.backend.config import settings
|
||||||
from database import init_db
|
from src.backend.database import init_db
|
||||||
from middleware.logging import AccessLogMiddleware
|
from src.backend.api.middleware.logging import AccessLogMiddleware
|
||||||
from middleware.rate_limit import RateLimitMiddleware
|
from src.backend.api.middleware.rate_limit import RateLimitMiddleware
|
||||||
from middleware.security_headers import SecurityHeadersMiddleware
|
from src.backend.api.middleware.security_headers import SecurityHeadersMiddleware
|
||||||
from routers.auth import router as auth_router
|
from src.backend.api.routers.auth import router as auth_router
|
||||||
from routers.users import router as users_router
|
from src.backend.api.routers.users import router as users_router
|
||||||
from routers.recipes import router as recipes_router
|
from src.backend.api.routers.recipes import router as recipes_router
|
||||||
from routers.tasks import router as tasks_router
|
from src.backend.api.routers.tasks import router as tasks_router
|
||||||
from routers.measurements import router as measurements_router
|
from src.backend.api.routers.measurements import router as measurements_router
|
||||||
from routers.files import router as files_router
|
from src.backend.api.routers.files import router as files_router
|
||||||
from routers.settings import router as settings_router
|
from src.backend.api.routers.settings import router as settings_router
|
||||||
from routers.reports import router as reports_router
|
from src.backend.api.routers.reports import router as reports_router
|
||||||
from routers.statistics import router as statistics_router
|
from src.backend.api.routers.statistics import router as statistics_router
|
||||||
from routers.setup import router as setup_router
|
from src.backend.api.routers.setup import router as setup_router
|
||||||
from routers.stations import router as stations_router
|
from src.backend.api.routers.stations import router as stations_router
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
@@ -17,20 +17,25 @@ if config.config_file_name is not None:
|
|||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
# Add the project root (4 levels up from this file) to sys.path so that
|
||||||
from config import settings
|
# `src.backend.*` imports resolve when alembic is invoked from anywhere.
|
||||||
from database import Base
|
_PROJECT_ROOT = Path(__file__).resolve().parents[3]
|
||||||
|
if str(_PROJECT_ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(_PROJECT_ROOT))
|
||||||
|
|
||||||
|
from src.backend.config import settings
|
||||||
|
from src.backend.database import Base
|
||||||
|
|
||||||
# Override alembic.ini URL with .env settings (keep in sync)
|
# Override alembic.ini URL with .env settings (keep in sync)
|
||||||
config.set_main_option("sqlalchemy.url", settings.database_url)
|
config.set_main_option("sqlalchemy.url", settings.database_url)
|
||||||
|
|
||||||
# Import all models so they register with Base.metadata
|
# Import all models so they register with Base.metadata
|
||||||
from models.user import User # noqa: F401
|
from src.backend.models.orm.user import User # noqa: F401
|
||||||
from models.recipe import Recipe, RecipeVersion # noqa: F401
|
from src.backend.models.orm.recipe import Recipe, RecipeVersion # noqa: F401
|
||||||
from models.task import RecipeTask, RecipeSubtask # noqa: F401
|
from src.backend.models.orm.task import RecipeTask, RecipeSubtask # noqa: F401
|
||||||
from models.measurement import Measurement # noqa: F401
|
from src.backend.models.orm.measurement import Measurement # noqa: F401
|
||||||
from models.access_log import AccessLog # noqa: F401
|
from src.backend.models.orm.access_log import AccessLog # noqa: F401
|
||||||
from models.setting import SystemSetting, RecipeVersionAudit # noqa: F401
|
from src.backend.models.orm.setting import SystemSetting, RecipeVersionAudit # noqa: F401
|
||||||
|
|
||||||
target_metadata = Base.metadata
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
@@ -1,19 +1,19 @@
|
|||||||
"""Pydantic schemas for TieMeasureFlow API."""
|
"""Pydantic schemas for TieMeasureFlow API."""
|
||||||
from schemas.measurement import (
|
from src.backend.models.api.measurement import (
|
||||||
MeasurementBatchCreate,
|
MeasurementBatchCreate,
|
||||||
MeasurementCreate,
|
MeasurementCreate,
|
||||||
MeasurementListResponse,
|
MeasurementListResponse,
|
||||||
MeasurementQuery,
|
MeasurementQuery,
|
||||||
MeasurementResponse,
|
MeasurementResponse,
|
||||||
)
|
)
|
||||||
from schemas.recipe import (
|
from src.backend.models.api.recipe import (
|
||||||
RecipeCreate,
|
RecipeCreate,
|
||||||
RecipeListResponse,
|
RecipeListResponse,
|
||||||
RecipeResponse,
|
RecipeResponse,
|
||||||
RecipeUpdate,
|
RecipeUpdate,
|
||||||
RecipeVersionResponse,
|
RecipeVersionResponse,
|
||||||
)
|
)
|
||||||
from schemas.statistics import (
|
from src.backend.models.api.statistics import (
|
||||||
AlertData,
|
AlertData,
|
||||||
CapabilityData,
|
CapabilityData,
|
||||||
ControlChartData,
|
ControlChartData,
|
||||||
@@ -23,7 +23,7 @@ from schemas.statistics import (
|
|||||||
SummaryData,
|
SummaryData,
|
||||||
TrendData,
|
TrendData,
|
||||||
)
|
)
|
||||||
from schemas.task import (
|
from src.backend.models.api.task import (
|
||||||
SubtaskCreate,
|
SubtaskCreate,
|
||||||
SubtaskResponse,
|
SubtaskResponse,
|
||||||
SubtaskUpdate,
|
SubtaskUpdate,
|
||||||
@@ -32,7 +32,7 @@ from schemas.task import (
|
|||||||
TaskResponse,
|
TaskResponse,
|
||||||
TaskUpdate,
|
TaskUpdate,
|
||||||
)
|
)
|
||||||
from schemas.user import (
|
from src.backend.models.api.user import (
|
||||||
LoginRequest,
|
LoginRequest,
|
||||||
LoginResponse,
|
LoginResponse,
|
||||||
UserCreate,
|
UserCreate,
|
||||||
@@ -5,7 +5,7 @@ from typing import Optional, TYPE_CHECKING
|
|||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from schemas.task import TaskResponse
|
from src.backend.models.api.task import TaskResponse
|
||||||
|
|
||||||
|
|
||||||
class RecipeCreate(BaseModel):
|
class RecipeCreate(BaseModel):
|
||||||
@@ -71,6 +71,6 @@ class RecipeListResponse(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
# Forward reference imports for model_rebuild
|
# Forward reference imports for model_rebuild
|
||||||
from schemas.task import TaskResponse # noqa: E402
|
from src.backend.models.api.task import TaskResponse # noqa: E402
|
||||||
RecipeVersionResponse.model_rebuild()
|
RecipeVersionResponse.model_rebuild()
|
||||||
RecipeResponse.model_rebuild()
|
RecipeResponse.model_rebuild()
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
"""SQLAlchemy models for TieMeasureFlow."""
|
||||||
|
from src.backend.models.orm.user import User
|
||||||
|
from src.backend.models.orm.recipe import Recipe, RecipeVersion
|
||||||
|
from src.backend.models.orm.task import RecipeTask, RecipeSubtask
|
||||||
|
from src.backend.models.orm.measurement import Measurement
|
||||||
|
from src.backend.models.orm.access_log import AccessLog
|
||||||
|
from src.backend.models.orm.setting import SystemSetting, RecipeVersionAudit
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"User",
|
||||||
|
"Recipe",
|
||||||
|
"RecipeVersion",
|
||||||
|
"RecipeTask",
|
||||||
|
"RecipeSubtask",
|
||||||
|
"Measurement",
|
||||||
|
"AccessLog",
|
||||||
|
"SystemSetting",
|
||||||
|
"RecipeVersionAudit",
|
||||||
|
]
|
||||||
@@ -5,7 +5,7 @@ from typing import Optional
|
|||||||
from sqlalchemy import BigInteger, DateTime, ForeignKey, Index, Integer, JSON, String, func
|
from sqlalchemy import BigInteger, DateTime, ForeignKey, Index, Integer, JSON, String, func
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from database import Base
|
from src.backend.database import Base
|
||||||
|
|
||||||
|
|
||||||
class AccessLog(Base):
|
class AccessLog(Base):
|
||||||
@@ -8,7 +8,7 @@ from sqlalchemy import (
|
|||||||
)
|
)
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from database import Base
|
from src.backend.database import Base
|
||||||
|
|
||||||
|
|
||||||
class Measurement(Base):
|
class Measurement(Base):
|
||||||
@@ -5,10 +5,10 @@ from typing import TYPE_CHECKING, Optional
|
|||||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, Text, UniqueConstraint, func
|
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, Text, UniqueConstraint, func
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from database import Base
|
from src.backend.database import Base
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from models.task import RecipeTask
|
from src.backend.models.orm.task import RecipeTask
|
||||||
|
|
||||||
|
|
||||||
class Recipe(Base):
|
class Recipe(Base):
|
||||||
@@ -5,7 +5,7 @@ from typing import Optional
|
|||||||
from sqlalchemy import DateTime, Enum, ForeignKey, Index, Integer, String, Text, func
|
from sqlalchemy import DateTime, Enum, ForeignKey, Index, Integer, String, Text, func
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from database import Base
|
from src.backend.database import Base
|
||||||
|
|
||||||
|
|
||||||
class SystemSetting(Base):
|
class SystemSetting(Base):
|
||||||
@@ -10,10 +10,10 @@ from typing import TYPE_CHECKING, Optional
|
|||||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func
|
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from database import Base
|
from src.backend.database import Base
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from models.recipe import Recipe
|
from src.backend.models.orm.recipe import Recipe
|
||||||
|
|
||||||
|
|
||||||
class Station(Base):
|
class Station(Base):
|
||||||
@@ -6,10 +6,10 @@ from sqlalchemy import (
|
|||||||
)
|
)
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from database import Base
|
from src.backend.database import Base
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from models.recipe import RecipeVersion
|
from src.backend.models.orm.recipe import RecipeVersion
|
||||||
|
|
||||||
|
|
||||||
class RecipeTask(Base):
|
class RecipeTask(Base):
|
||||||
@@ -5,7 +5,7 @@ from typing import Optional
|
|||||||
from sqlalchemy import Boolean, DateTime, Enum, Integer, JSON, String, func
|
from sqlalchemy import Boolean, DateTime, Enum, Integer, JSON, String, func
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from database import Base
|
from src.backend.database import Base
|
||||||
|
|
||||||
|
|
||||||
class User(Base):
|
class User(Base):
|
||||||
@@ -6,7 +6,7 @@ import bcrypt
|
|||||||
from sqlalchemy import select, update
|
from sqlalchemy import select, update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from models.user import User
|
from src.backend.models.orm.user import User
|
||||||
|
|
||||||
|
|
||||||
def hash_password(password: str) -> str:
|
def hash_password(password: str) -> str:
|
||||||
+2
-2
@@ -4,8 +4,8 @@ from decimal import Decimal
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from models.measurement import Measurement
|
from src.backend.models.orm.measurement import Measurement
|
||||||
from models.task import RecipeSubtask
|
from src.backend.models.orm.task import RecipeSubtask
|
||||||
|
|
||||||
|
|
||||||
def calculate_pass_fail(
|
def calculate_pass_fail(
|
||||||
@@ -7,12 +7,12 @@ from sqlalchemy import func, select, update
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from models.measurement import Measurement
|
from src.backend.models.orm.measurement import Measurement
|
||||||
from models.recipe import Recipe, RecipeVersion
|
from src.backend.models.orm.recipe import Recipe, RecipeVersion
|
||||||
from models.setting import RecipeVersionAudit
|
from src.backend.models.orm.setting import RecipeVersionAudit
|
||||||
from models.task import RecipeSubtask, RecipeTask
|
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
||||||
from models.user import User
|
from src.backend.models.orm.user import User
|
||||||
from schemas.recipe import RecipeCreate, RecipeUpdate
|
from src.backend.models.api.recipe import RecipeCreate, RecipeUpdate
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -10,13 +10,13 @@ from sqlalchemy import and_, select
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from weasyprint import HTML
|
from weasyprint import HTML
|
||||||
|
|
||||||
from config import settings
|
from src.backend.config import settings
|
||||||
from models.measurement import Measurement
|
from src.backend.models.orm.measurement import Measurement
|
||||||
from models.recipe import Recipe, RecipeVersion
|
from src.backend.models.orm.recipe import Recipe, RecipeVersion
|
||||||
from models.setting import SystemSetting
|
from src.backend.models.orm.setting import SystemSetting
|
||||||
from models.task import RecipeSubtask, RecipeTask
|
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
||||||
from models.user import User
|
from src.backend.models.orm.user import User
|
||||||
from services.spc_service import (
|
from src.backend.services.spc_service import (
|
||||||
compute_capability,
|
compute_capability,
|
||||||
compute_control_chart,
|
compute_control_chart,
|
||||||
compute_histogram,
|
compute_histogram,
|
||||||
@@ -9,7 +9,7 @@ import math
|
|||||||
import statistics as stats
|
import statistics as stats
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from schemas.statistics import (
|
from src.backend.models.api.statistics import (
|
||||||
CapabilityData,
|
CapabilityData,
|
||||||
ControlChartData,
|
ControlChartData,
|
||||||
HistogramData,
|
HistogramData,
|
||||||
@@ -10,10 +10,10 @@ from fastapi import HTTPException, status
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from models.recipe import Recipe
|
from src.backend.models.orm.recipe import Recipe
|
||||||
from models.station import Station, StationRecipeAssignment
|
from src.backend.models.orm.station import Station, StationRecipeAssignment
|
||||||
from models.user import User
|
from src.backend.models.orm.user import User
|
||||||
from schemas.station import StationCreate, StationUpdate
|
from src.backend.models.api.station import StationCreate, StationUpdate
|
||||||
|
|
||||||
|
|
||||||
async def create_station(
|
async def create_station(
|
||||||
@@ -30,17 +30,17 @@ if "weasyprint" not in sys.modules:
|
|||||||
# Ensure the server package is importable
|
# Ensure the server package is importable
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
from database import Base, get_db
|
from src.backend.database import Base, get_db
|
||||||
from main import app
|
from src.backend.main import app
|
||||||
from middleware.rate_limit import RateLimitMiddleware
|
from src.backend.api.middleware.rate_limit import RateLimitMiddleware
|
||||||
from models.user import User
|
from src.backend.models.orm.user import User
|
||||||
from models.recipe import Recipe, RecipeVersion
|
from src.backend.models.orm.recipe import Recipe, RecipeVersion
|
||||||
from models.task import RecipeTask, RecipeSubtask
|
from src.backend.models.orm.task import RecipeTask, RecipeSubtask
|
||||||
from models.measurement import Measurement
|
from src.backend.models.orm.measurement import Measurement
|
||||||
from models.access_log import AccessLog
|
from src.backend.models.orm.access_log import AccessLog
|
||||||
from models.setting import SystemSetting, RecipeVersionAudit
|
from src.backend.models.orm.setting import SystemSetting, RecipeVersionAudit
|
||||||
from models.station import Station, StationRecipeAssignment
|
from src.backend.models.orm.station import Station, StationRecipeAssignment
|
||||||
from services.auth_service import hash_password, generate_api_key
|
from src.backend.services.auth_service import hash_password, generate_api_key
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# In-memory SQLite engine for tests
|
# In-memory SQLite engine for tests
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
import pytest
|
import pytest
|
||||||
from httpx import AsyncClient
|
from httpx import AsyncClient
|
||||||
|
|
||||||
from tests.conftest import _create_user, auth_headers
|
from src.backend.tests.conftest import _create_user, auth_headers
|
||||||
|
|
||||||
|
|
||||||
class TestLogin:
|
class TestLogin:
|
||||||
@@ -8,8 +8,8 @@ from unittest.mock import patch
|
|||||||
import pytest
|
import pytest
|
||||||
from httpx import AsyncClient
|
from httpx import AsyncClient
|
||||||
|
|
||||||
from models.user import User
|
from src.backend.models.orm.user import User
|
||||||
from tests.conftest import auth_headers
|
from src.backend.tests.conftest import auth_headers
|
||||||
|
|
||||||
|
|
||||||
class TestUploadFile:
|
class TestUploadFile:
|
||||||
@@ -45,7 +45,7 @@ class TestUploadFile:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Patch settings.upload_path to use tmp_path
|
# Patch settings.upload_path to use tmp_path
|
||||||
with patch("routers.files.settings") as mock_settings:
|
with patch("src.backend.api.routers.files.settings") as mock_settings:
|
||||||
mock_settings.upload_path = tmp_path
|
mock_settings.upload_path = tmp_path
|
||||||
mock_settings.max_upload_size_mb = 50
|
mock_settings.max_upload_size_mb = 50
|
||||||
|
|
||||||
@@ -82,7 +82,7 @@ class TestUploadFile:
|
|||||||
# Create a large file content
|
# Create a large file content
|
||||||
large_content = b"\x00" * (51 * 1024 * 1024) # 51MB
|
large_content = b"\x00" * (51 * 1024 * 1024) # 51MB
|
||||||
|
|
||||||
with patch("routers.files.settings") as mock_settings:
|
with patch("src.backend.api.routers.files.settings") as mock_settings:
|
||||||
mock_settings.max_upload_size_mb = 50
|
mock_settings.max_upload_size_mb = 50
|
||||||
mock_settings.upload_path = Path(tempfile.mkdtemp())
|
mock_settings.upload_path = Path(tempfile.mkdtemp())
|
||||||
|
|
||||||
@@ -109,7 +109,7 @@ class TestGetFile:
|
|||||||
test_file = tmp_path / "testfile.txt"
|
test_file = tmp_path / "testfile.txt"
|
||||||
test_file.write_text("hello test")
|
test_file.write_text("hello test")
|
||||||
|
|
||||||
with patch("routers.files.settings") as mock_settings:
|
with patch("src.backend.api.routers.files.settings") as mock_settings:
|
||||||
mock_settings.upload_path = tmp_path
|
mock_settings.upload_path = tmp_path
|
||||||
|
|
||||||
resp = await client.get(
|
resp = await client.get(
|
||||||
@@ -123,7 +123,7 @@ class TestGetFile:
|
|||||||
self, client: AsyncClient, maker_user: User, tmp_path: Path
|
self, client: AsyncClient, maker_user: User, tmp_path: Path
|
||||||
):
|
):
|
||||||
"""Requesting a non-existent file returns 404."""
|
"""Requesting a non-existent file returns 404."""
|
||||||
with patch("routers.files.settings") as mock_settings:
|
with patch("src.backend.api.routers.files.settings") as mock_settings:
|
||||||
mock_settings.upload_path = tmp_path
|
mock_settings.upload_path = tmp_path
|
||||||
|
|
||||||
resp = await client.get(
|
resp = await client.get(
|
||||||
@@ -144,7 +144,7 @@ class TestDeleteFile:
|
|||||||
test_file = tmp_path / "deleteme.txt"
|
test_file = tmp_path / "deleteme.txt"
|
||||||
test_file.write_text("delete me")
|
test_file.write_text("delete me")
|
||||||
|
|
||||||
with patch("routers.files.settings") as mock_settings:
|
with patch("src.backend.api.routers.files.settings") as mock_settings:
|
||||||
mock_settings.upload_path = tmp_path
|
mock_settings.upload_path = tmp_path
|
||||||
|
|
||||||
resp = await client.delete(
|
resp = await client.delete(
|
||||||
@@ -163,7 +163,7 @@ class TestFilenameSanitization:
|
|||||||
self, client: AsyncClient, maker_user: User, tmp_path: Path
|
self, client: AsyncClient, maker_user: User, tmp_path: Path
|
||||||
):
|
):
|
||||||
"""Path traversal attempt returns 403 or 404."""
|
"""Path traversal attempt returns 403 or 404."""
|
||||||
with patch("routers.files.settings") as mock_settings:
|
with patch("src.backend.api.routers.files.settings") as mock_settings:
|
||||||
mock_settings.upload_path = tmp_path
|
mock_settings.upload_path = tmp_path
|
||||||
|
|
||||||
resp = await client.get(
|
resp = await client.get(
|
||||||
@@ -3,10 +3,10 @@ import pytest
|
|||||||
from httpx import AsyncClient
|
from httpx import AsyncClient
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from models.user import User
|
from src.backend.models.orm.user import User
|
||||||
from models.recipe import RecipeVersion
|
from src.backend.models.orm.recipe import RecipeVersion
|
||||||
from models.task import RecipeTask, RecipeSubtask
|
from src.backend.models.orm.task import RecipeTask, RecipeSubtask
|
||||||
from tests.conftest import auth_headers, create_test_recipe
|
from src.backend.tests.conftest import auth_headers, create_test_recipe
|
||||||
|
|
||||||
|
|
||||||
async def _get_subtask_and_version(
|
async def _get_subtask_and_version(
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""Tests for RateLimitMiddleware honoring proxy headers (X-Forwarded-For)."""
|
||||||
|
from src.backend.api.middleware.rate_limit import RateLimitMiddleware
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeRequest:
|
||||||
|
"""Minimal stand-in for starlette.requests.Request."""
|
||||||
|
|
||||||
|
def __init__(self, headers: dict[str, str], host: str | None = "10.0.0.1"):
|
||||||
|
self.headers = headers
|
||||||
|
|
||||||
|
class _Client:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if host is None:
|
||||||
|
self.client = None
|
||||||
|
else:
|
||||||
|
self.client = _Client()
|
||||||
|
self.client.host = host
|
||||||
|
|
||||||
|
|
||||||
|
def test_client_ip_uses_x_forwarded_for_first_hop():
|
||||||
|
req = _FakeRequest(
|
||||||
|
headers={"x-forwarded-for": "203.0.113.5, 10.0.0.2"},
|
||||||
|
host="10.0.0.1",
|
||||||
|
)
|
||||||
|
assert RateLimitMiddleware._client_ip(req) == "203.0.113.5"
|
||||||
|
|
||||||
|
|
||||||
|
def test_client_ip_strips_whitespace():
|
||||||
|
req = _FakeRequest(headers={"x-forwarded-for": " 198.51.100.7 "})
|
||||||
|
assert RateLimitMiddleware._client_ip(req) == "198.51.100.7"
|
||||||
|
|
||||||
|
|
||||||
|
def test_client_ip_falls_back_to_x_real_ip():
|
||||||
|
req = _FakeRequest(headers={"x-real-ip": "203.0.113.99"}, host="10.0.0.1")
|
||||||
|
assert RateLimitMiddleware._client_ip(req) == "203.0.113.99"
|
||||||
|
|
||||||
|
|
||||||
|
def test_client_ip_falls_back_to_request_client_host():
|
||||||
|
req = _FakeRequest(headers={}, host="172.18.0.5")
|
||||||
|
assert RateLimitMiddleware._client_ip(req) == "172.18.0.5"
|
||||||
|
|
||||||
|
|
||||||
|
def test_client_ip_returns_unknown_without_client():
|
||||||
|
req = _FakeRequest(headers={}, host=None)
|
||||||
|
assert RateLimitMiddleware._client_ip(req) == "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def test_x_forwarded_for_overrides_x_real_ip():
|
||||||
|
req = _FakeRequest(
|
||||||
|
headers={
|
||||||
|
"x-forwarded-for": "203.0.113.5",
|
||||||
|
"x-real-ip": "10.0.0.2",
|
||||||
|
},
|
||||||
|
host="10.0.0.1",
|
||||||
|
)
|
||||||
|
assert RateLimitMiddleware._client_ip(req) == "203.0.113.5"
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_x_forwarded_for_falls_through():
|
||||||
|
req = _FakeRequest(
|
||||||
|
headers={"x-forwarded-for": "", "x-real-ip": "203.0.113.42"},
|
||||||
|
host="10.0.0.1",
|
||||||
|
)
|
||||||
|
assert RateLimitMiddleware._client_ip(req) == "203.0.113.42"
|
||||||
@@ -3,8 +3,8 @@ import pytest
|
|||||||
from httpx import AsyncClient
|
from httpx import AsyncClient
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from models.user import User
|
from src.backend.models.orm.user import User
|
||||||
from tests.conftest import auth_headers, create_test_recipe
|
from src.backend.tests.conftest import auth_headers, create_test_recipe
|
||||||
|
|
||||||
|
|
||||||
class TestListRecipes:
|
class TestListRecipes:
|
||||||
@@ -9,8 +9,8 @@ import pytest
|
|||||||
from httpx import AsyncClient
|
from httpx import AsyncClient
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from models.user import User
|
from src.backend.models.orm.user import User
|
||||||
from tests.conftest import auth_headers, create_test_recipe
|
from src.backend.tests.conftest import auth_headers, create_test_recipe
|
||||||
|
|
||||||
|
|
||||||
class TestMeasurementReport:
|
class TestMeasurementReport:
|
||||||
@@ -29,7 +29,7 @@ class TestMeasurementReport:
|
|||||||
|
|
||||||
fake_pdf = b"%PDF-1.4 fake content"
|
fake_pdf = b"%PDF-1.4 fake content"
|
||||||
with patch(
|
with patch(
|
||||||
"routers.reports.generate_measurement_report",
|
"src.backend.api.routers.reports.generate_measurement_report",
|
||||||
new_callable=AsyncMock,
|
new_callable=AsyncMock,
|
||||||
return_value=fake_pdf,
|
return_value=fake_pdf,
|
||||||
):
|
):
|
||||||
@@ -75,7 +75,7 @@ class TestSPCReport:
|
|||||||
|
|
||||||
fake_pdf = b"%PDF-1.4 spc report content"
|
fake_pdf = b"%PDF-1.4 spc report content"
|
||||||
with patch(
|
with patch(
|
||||||
"routers.reports.generate_spc_report",
|
"src.backend.api.routers.reports.generate_spc_report",
|
||||||
new_callable=AsyncMock,
|
new_callable=AsyncMock,
|
||||||
return_value=fake_pdf,
|
return_value=fake_pdf,
|
||||||
):
|
):
|
||||||
@@ -9,7 +9,7 @@ from unittest.mock import patch
|
|||||||
import pytest
|
import pytest
|
||||||
from httpx import AsyncClient
|
from httpx import AsyncClient
|
||||||
|
|
||||||
from tests.conftest import _create_user, auth_headers
|
from src.backend.tests.conftest import _create_user, auth_headers
|
||||||
|
|
||||||
|
|
||||||
class TestCORSHeaders:
|
class TestCORSHeaders:
|
||||||
@@ -126,7 +126,7 @@ class TestFilenameSanitization:
|
|||||||
|
|
||||||
async def test_dotfile_rejected(self, client: AsyncClient, db_session):
|
async def test_dotfile_rejected(self, client: AsyncClient, db_session):
|
||||||
"""Filenames starting with '.' are rejected."""
|
"""Filenames starting with '.' are rejected."""
|
||||||
from routers.files import sanitize_filename
|
from src.backend.api.routers.files import sanitize_filename
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
||||||
with pytest.raises(HTTPException) as exc_info:
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
@@ -136,7 +136,7 @@ class TestFilenameSanitization:
|
|||||||
|
|
||||||
async def test_empty_filename_rejected(self, client: AsyncClient, db_session):
|
async def test_empty_filename_rejected(self, client: AsyncClient, db_session):
|
||||||
"""Empty filenames are rejected."""
|
"""Empty filenames are rejected."""
|
||||||
from routers.files import sanitize_filename
|
from src.backend.api.routers.files import sanitize_filename
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
||||||
with pytest.raises(HTTPException) as exc_info:
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
@@ -3,9 +3,9 @@ import pytest
|
|||||||
from httpx import AsyncClient
|
from httpx import AsyncClient
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from models.setting import SystemSetting
|
from src.backend.models.orm.setting import SystemSetting
|
||||||
from models.user import User
|
from src.backend.models.orm.user import User
|
||||||
from tests.conftest import auth_headers
|
from src.backend.tests.conftest import auth_headers
|
||||||
|
|
||||||
|
|
||||||
class TestGetSettings:
|
class TestGetSettings:
|
||||||
@@ -2,8 +2,8 @@
|
|||||||
import pytest
|
import pytest
|
||||||
from httpx import AsyncClient
|
from httpx import AsyncClient
|
||||||
|
|
||||||
from config import settings
|
from src.backend.config import settings
|
||||||
from tests.conftest import test_engine, TestSessionFactory
|
from src.backend.tests.conftest import test_engine, TestSessionFactory
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -20,7 +20,7 @@ def enable_setup(monkeypatch):
|
|||||||
# setup.py imports engine and async_session_factory directly from database
|
# setup.py imports engine and async_session_factory directly from database
|
||||||
# and uses them instead of get_db dependency injection. Patch them to use
|
# and uses them instead of get_db dependency injection. Patch them to use
|
||||||
# the test in-memory SQLite engine/session instead of real MySQL.
|
# the test in-memory SQLite engine/session instead of real MySQL.
|
||||||
import routers.setup as setup_mod
|
import src.backend.api.routers.setup as setup_mod
|
||||||
monkeypatch.setattr(setup_mod, "engine", test_engine)
|
monkeypatch.setattr(setup_mod, "engine", test_engine)
|
||||||
monkeypatch.setattr(setup_mod, "async_session_factory", TestSessionFactory)
|
monkeypatch.setattr(setup_mod, "async_session_factory", TestSessionFactory)
|
||||||
|
|
||||||
@@ -52,7 +52,7 @@ async def test_seed_measurements_have_correct_distribution(client: AsyncClient,
|
|||||||
await _seed(client)
|
await _seed(client)
|
||||||
|
|
||||||
from sqlalchemy import select, func
|
from sqlalchemy import select, func
|
||||||
from models.measurement import Measurement
|
from src.backend.models.orm.measurement import Measurement
|
||||||
|
|
||||||
result = await db_session.execute(
|
result = await db_session.execute(
|
||||||
select(Measurement.pass_fail, func.count()).group_by(Measurement.pass_fail)
|
select(Measurement.pass_fail, func.count()).group_by(Measurement.pass_fail)
|
||||||
@@ -74,7 +74,7 @@ async def test_seed_measurements_have_lot_and_serial(client: AsyncClient, db_ses
|
|||||||
await _seed(client)
|
await _seed(client)
|
||||||
|
|
||||||
from sqlalchemy import select, func
|
from sqlalchemy import select, func
|
||||||
from models.measurement import Measurement
|
from src.backend.models.orm.measurement import Measurement
|
||||||
|
|
||||||
result = await db_session.execute(
|
result = await db_session.execute(
|
||||||
select(func.count()).where(Measurement.lot_number.is_(None))
|
select(func.count()).where(Measurement.lot_number.is_(None))
|
||||||
@@ -89,7 +89,7 @@ async def test_seed_measurements_input_methods(client: AsyncClient, db_session):
|
|||||||
await _seed(client)
|
await _seed(client)
|
||||||
|
|
||||||
from sqlalchemy import select, func
|
from sqlalchemy import select, func
|
||||||
from models.measurement import Measurement
|
from src.backend.models.orm.measurement import Measurement
|
||||||
|
|
||||||
result = await db_session.execute(
|
result = await db_session.execute(
|
||||||
select(Measurement.input_method, func.count()).group_by(Measurement.input_method)
|
select(Measurement.input_method, func.count()).group_by(Measurement.input_method)
|
||||||
@@ -4,8 +4,8 @@ from sqlalchemy import select
|
|||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from models.station import Station, StationRecipeAssignment
|
from src.backend.models.orm.station import Station, StationRecipeAssignment
|
||||||
from tests.conftest import _create_user, create_test_recipe
|
from src.backend.tests.conftest import _create_user, create_test_recipe
|
||||||
|
|
||||||
|
|
||||||
async def test_create_station(db_session: AsyncSession):
|
async def test_create_station(db_session: AsyncSession):
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
import pytest
|
import pytest
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
|
|
||||||
from schemas.station import (
|
from src.backend.models.api.station import (
|
||||||
StationCreate, StationUpdate, StationResponse,
|
StationCreate, StationUpdate, StationResponse,
|
||||||
StationRecipeAssignmentCreate, StationRecipeAssignmentResponse,
|
StationRecipeAssignmentCreate, StationRecipeAssignmentResponse,
|
||||||
StationWithRecipesResponse,
|
StationWithRecipesResponse,
|
||||||
@@ -18,10 +18,10 @@ from httpx import AsyncClient
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from config import settings
|
from src.backend.config import settings
|
||||||
from models.station import Station, StationRecipeAssignment
|
from src.backend.models.orm.station import Station, StationRecipeAssignment
|
||||||
from models.recipe import Recipe
|
from src.backend.models.orm.recipe import Recipe
|
||||||
from tests.conftest import test_engine, TestSessionFactory
|
from src.backend.tests.conftest import test_engine, TestSessionFactory
|
||||||
|
|
||||||
|
|
||||||
SETUP_PWD = "test-setup-pwd"
|
SETUP_PWD = "test-setup-pwd"
|
||||||
@@ -31,7 +31,7 @@ SETUP_PWD = "test-setup-pwd"
|
|||||||
def enable_setup(monkeypatch):
|
def enable_setup(monkeypatch):
|
||||||
"""Enable setup endpoints and redirect engine/session to test SQLite DB."""
|
"""Enable setup endpoints and redirect engine/session to test SQLite DB."""
|
||||||
monkeypatch.setattr(settings, "setup_password", SETUP_PWD)
|
monkeypatch.setattr(settings, "setup_password", SETUP_PWD)
|
||||||
import routers.setup as setup_mod
|
import src.backend.api.routers.setup as setup_mod
|
||||||
monkeypatch.setattr(setup_mod, "engine", test_engine)
|
monkeypatch.setattr(setup_mod, "engine", test_engine)
|
||||||
monkeypatch.setattr(setup_mod, "async_session_factory", TestSessionFactory)
|
monkeypatch.setattr(setup_mod, "async_session_factory", TestSessionFactory)
|
||||||
|
|
||||||
@@ -3,14 +3,14 @@ import pytest
|
|||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from models.station import Station
|
from src.backend.models.orm.station import Station
|
||||||
from schemas.station import StationCreate, StationUpdate
|
from src.backend.models.api.station import StationCreate, StationUpdate
|
||||||
from services.station_service import (
|
from src.backend.services.station_service import (
|
||||||
create_station, update_station, delete_station,
|
create_station, update_station, delete_station,
|
||||||
assign_recipe, unassign_recipe, list_station_recipes,
|
assign_recipe, unassign_recipe, list_station_recipes,
|
||||||
get_station_by_code,
|
get_station_by_code,
|
||||||
)
|
)
|
||||||
from tests.conftest import _create_user, create_test_recipe
|
from src.backend.tests.conftest import _create_user, create_test_recipe
|
||||||
|
|
||||||
|
|
||||||
async def test_create_station_ok(db_session: AsyncSession):
|
async def test_create_station_ok(db_session: AsyncSession):
|
||||||
@@ -102,7 +102,7 @@ async def test_list_recipes_only_returns_active(db_session: AsyncSession):
|
|||||||
|
|
||||||
async def test_delete_station_cascades_assignments(db_session: AsyncSession):
|
async def test_delete_station_cascades_assignments(db_session: AsyncSession):
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from models.station import StationRecipeAssignment
|
from src.backend.models.orm.station import StationRecipeAssignment
|
||||||
admin = await _create_user(db_session, username="a9", is_admin=True)
|
admin = await _create_user(db_session, username="a9", is_admin=True)
|
||||||
station = await create_station(db_session, StationCreate(code="ST-DEL", name="D"), admin)
|
station = await create_station(db_session, StationCreate(code="ST-DEL", name="D"), admin)
|
||||||
r = await create_test_recipe(db_session, user_id=admin.id, code="REC-DEL")
|
r = await create_test_recipe(db_session, user_id=admin.id, code="REC-DEL")
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
import pytest
|
import pytest
|
||||||
from httpx import AsyncClient
|
from httpx import AsyncClient
|
||||||
|
|
||||||
from tests.conftest import auth_headers, create_test_recipe
|
from src.backend.tests.conftest import auth_headers, create_test_recipe
|
||||||
|
|
||||||
|
|
||||||
async def test_list_stations_requires_auth(client: AsyncClient):
|
async def test_list_stations_requires_auth(client: AsyncClient):
|
||||||
@@ -3,9 +3,9 @@ import pytest
|
|||||||
from httpx import AsyncClient
|
from httpx import AsyncClient
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from models.user import User
|
from src.backend.models.orm.user import User
|
||||||
from models.measurement import Measurement
|
from src.backend.models.orm.measurement import Measurement
|
||||||
from tests.conftest import auth_headers, create_test_recipe
|
from src.backend.tests.conftest import auth_headers, create_test_recipe
|
||||||
|
|
||||||
|
|
||||||
async def _seed_measurements(
|
async def _seed_measurements(
|
||||||
@@ -3,8 +3,8 @@ import pytest
|
|||||||
from httpx import AsyncClient
|
from httpx import AsyncClient
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from models.user import User
|
from src.backend.models.orm.user import User
|
||||||
from tests.conftest import auth_headers, create_test_recipe
|
from src.backend.tests.conftest import auth_headers, create_test_recipe
|
||||||
|
|
||||||
|
|
||||||
class TestListTasks:
|
class TestListTasks:
|
||||||
@@ -3,8 +3,8 @@ import pytest
|
|||||||
from httpx import AsyncClient
|
from httpx import AsyncClient
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from models.user import User
|
from src.backend.models.orm.user import User
|
||||||
from tests.conftest import _create_user, auth_headers
|
from src.backend.tests.conftest import _create_user, auth_headers
|
||||||
|
|
||||||
|
|
||||||
class TestListUsers:
|
class TestListUsers:
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user