Compare commits
52 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7bc3c1f938 | |||
| 55e5e0153f | |||
| e7889f80a3 | |||
| a159eb2b8b | |||
| 28ee44bcf1 | |||
| 2a566321f3 | |||
| d0e07b5e5f | |||
| 3f03ec8ab1 | |||
| edb7eb382c | |||
| 8c61a557f8 | |||
| 821c7c39d5 | |||
| 83f8e2d332 | |||
| f3e593610b | |||
| fd571c479e | |||
| 25a788f430 | |||
| 61c265b38b | |||
| 74b348988e | |||
| a36a37aca0 | |||
| b8d04c54f7 | |||
| 34a72e6f1a | |||
| 4c75a32186 | |||
| fdfe1072d8 | |||
| 6ff78b2150 | |||
| bff577b461 | |||
| 5cc5123a0c | |||
| 2eb5c51353 | |||
| b325eb3512 | |||
| a7254d8932 | |||
| d2bf0b5828 | |||
| 9bd605c958 | |||
| e05eb66a1c | |||
| 6bfe0a98e8 | |||
| 1da7e5c7af | |||
| 0748ce9b1e | |||
| b9c767fb0c | |||
| 6772e3166a | |||
| 182aa9fa9a | |||
| f1c7a28296 | |||
| 52d78ea5c1 | |||
| fb96bad000 | |||
| 6a8931e108 | |||
| 85a00dea1b | |||
| e4eb4cd932 | |||
| 4de7d78b66 | |||
| 2a2d40bec9 | |||
| 6e284b0c0c | |||
| 742cc1fb58 | |||
| 563b7789f4 | |||
| e4b29c0b2d | |||
| 1a0431366f | |||
| 86df67f2e5 | |||
| ea8e4687b5 |
+38
-10
@@ -1,15 +1,43 @@
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.pyo
|
||||
.pytest_cache
|
||||
# Build context exclusions: keep image small and rebuilds fast.
|
||||
|
||||
# VCS
|
||||
.git
|
||||
.gitignore
|
||||
.env
|
||||
*.md
|
||||
node_modules
|
||||
.gitattributes
|
||||
|
||||
# Local envs and caches
|
||||
.venv
|
||||
venv
|
||||
*.egg-info
|
||||
.env
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov
|
||||
.mypy_cache
|
||||
htmlcov/
|
||||
.mypy_cache/
|
||||
*.egg-info
|
||||
|
||||
# IDE / editor
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*~
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
|
||||
# Local-only notes
|
||||
docs/
|
||||
*.docx
|
||||
|
||||
# Uploads are a runtime volume, never baked in.
|
||||
uploads/
|
||||
|
||||
# Claude Code
|
||||
.claude/
|
||||
.omc/
|
||||
|
||||
# Competitor analysis (local only)
|
||||
Concorrente/
|
||||
|
||||
+13
-1
@@ -24,14 +24,26 @@ API_SERVER_URL=http://localhost:8000
|
||||
# Each physical tablet/PC deployment must set this unique per-station value.
|
||||
# Leave empty only for a single-station all-in-one demo using ST-DEFAULT.
|
||||
STATION_CODE=ST-DEFAULT
|
||||
# Allow switching station from the URL (?station=CODE) during commissioning, so a
|
||||
# single PC can exercise several stations. Leave at 0 in production: on the shop
|
||||
# floor the station identity comes from the local install, and measuring against
|
||||
# another station's recipes would silently break traceability.
|
||||
STATION_SWITCH_ENABLED=0
|
||||
|
||||
# --- File Storage ---
|
||||
UPLOAD_DIR=server/uploads
|
||||
# Resolved against the project root in src/backend/config.py.
|
||||
# Default "uploads" maps to <project_root>/uploads, mounted as a Docker
|
||||
# volume in production.
|
||||
UPLOAD_DIR=uploads
|
||||
MAX_UPLOAD_SIZE_MB=50
|
||||
|
||||
# --- Setup Page ---
|
||||
SETUP_PASSWORD= # Password per /api/setup, vuoto = disabilitato
|
||||
|
||||
# --- AI (OpenRouter) ---
|
||||
OPENROUTER_API_KEY= # API key per parsing schede tecniche, vuoto = disabilitato
|
||||
OPENROUTER_MODEL=anthropic/claude-sonnet-4 # Modello AI da utilizzare
|
||||
|
||||
# --- Docker ---
|
||||
DB_ROOT_PASSWORD=root_password_change_me
|
||||
NGINX_PORT=80
|
||||
|
||||
+15
-11
@@ -33,21 +33,24 @@ env/
|
||||
Thumbs.db
|
||||
desktop.ini
|
||||
|
||||
# Uploads (server-side files)
|
||||
server/uploads/images/*
|
||||
server/uploads/pdfs/*
|
||||
server/uploads/logos/*
|
||||
server/uploads/reports/*
|
||||
!server/uploads/images/.gitkeep
|
||||
!server/uploads/pdfs/.gitkeep
|
||||
!server/uploads/logos/.gitkeep
|
||||
!server/uploads/reports/.gitkeep
|
||||
# Uploads (server-side files, now at project root)
|
||||
uploads/images/*
|
||||
uploads/pdfs/*
|
||||
uploads/logos/*
|
||||
uploads/reports/*
|
||||
uploads/general/
|
||||
!uploads/images/.gitkeep
|
||||
!uploads/pdfs/.gitkeep
|
||||
!uploads/logos/.gitkeep
|
||||
!uploads/reports/.gitkeep
|
||||
|
||||
# TailwindCSS output
|
||||
client/static/css/tailwind.css
|
||||
src/frontend/flask_app/static/css/tailwind.css
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
src/frontend/flask_app/package.json
|
||||
src/frontend/flask_app/package-lock.json
|
||||
|
||||
# Flask-Babel compiled
|
||||
*.mo
|
||||
@@ -63,10 +66,11 @@ node_modules/
|
||||
htmlcov/
|
||||
|
||||
# Debug files
|
||||
client/static/js/fabric-debug.js
|
||||
src/frontend/flask_app/static/js/fabric-debug.js
|
||||
|
||||
# Misc
|
||||
nul
|
||||
|
||||
# Competitor analysis (local only)
|
||||
Concorrente/
|
||||
docker-compose.override.yml
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
3.11
|
||||
@@ -5,10 +5,65 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
## Panoramica
|
||||
|
||||
TieMeasureFlow by Tielogic - Sistema di gestione task per misurazioni con calibro manuale.
|
||||
Monorepo con **server FastAPI** (backend API, porta 8000) e **client Flask** (frontend tablet, porta 5000), orchestrati con Docker Compose + Nginx reverse proxy + MySQL 8.0.
|
||||
Monorepo con **backend FastAPI** (porta 8000) e **frontend Flask** (porta 5000), orchestrati con Docker Compose + Nginx reverse proxy + MySQL 8.0.
|
||||
|
||||
## Layout del repository (V2.0.0)
|
||||
|
||||
A partire dalla migrazione V2.0.0 (struttura conforme alla spec `python-project-spec-design.md`):
|
||||
|
||||
```
|
||||
TieMeasureFlow/
|
||||
├── pyproject.toml # Dipendenze monorepo (uv)
|
||||
├── uv.lock # Lock file riproducibile
|
||||
├── .python-version # 3.11
|
||||
├── Dockerfile # Backend (uv + uvicorn)
|
||||
├── Dockerfile.frontend # Frontend (uv + gunicorn + Tailwind + Babel)
|
||||
├── docker-compose.dev.yml # Dev (Nginx)
|
||||
├── docker-compose.yml # Prod (Traefik + SSL)
|
||||
├── nginx/
|
||||
├── uploads/ # Volume montato in /app/uploads
|
||||
├── docs/
|
||||
└── src/
|
||||
├── backend/
|
||||
│ ├── main.py # Entry FastAPI
|
||||
│ ├── config.py
|
||||
│ ├── database.py
|
||||
│ ├── api/
|
||||
│ │ ├── routers/ # 11 router REST
|
||||
│ │ └── middleware/ # api_key, rate_limit, security_headers, logging
|
||||
│ ├── models/
|
||||
│ │ ├── orm/ # SQLAlchemy
|
||||
│ │ └── api/ # Pydantic schemas
|
||||
│ ├── services/ # Logica business
|
||||
│ ├── migrations/ # Alembic
|
||||
│ ├── templates/ # Setup page
|
||||
│ └── tests/ # pytest
|
||||
└── frontend/
|
||||
└── flask_app/ # Flask + Jinja2 + Alpine.js (deroga vs spec React,
|
||||
├── app.py # giustificata: tablet UX server-side, USB calipers,
|
||||
├── config.py # workflow operatore con Fabric.js)
|
||||
├── blueprints/
|
||||
├── services/
|
||||
├── templates/
|
||||
├── static/
|
||||
├── translations/
|
||||
└── tests/
|
||||
```
|
||||
|
||||
Dipendenze gestite con **uv** (no `requirements.txt`):
|
||||
- `[project] dependencies` = core condivisi (pydantic, dotenv)
|
||||
- `[project.optional-dependencies] server` = backend FastAPI
|
||||
- `[project.optional-dependencies] client` = frontend Flask
|
||||
- `[project.optional-dependencies] dev` = pytest, httpx, aiosqlite
|
||||
|
||||
## Comandi di Sviluppo
|
||||
|
||||
### Setup iniziale
|
||||
```bash
|
||||
cp .env.example .env
|
||||
uv sync --extra server --extra client --extra dev # installa tutto
|
||||
```
|
||||
|
||||
### Avvio servizi (Docker)
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml up -d # Sviluppo (Nginx, porta 80)
|
||||
@@ -20,51 +75,63 @@ docker compose ps # Stato servizi
|
||||
|
||||
### Avvio manuale (senza Docker)
|
||||
```bash
|
||||
# Server (terminale 1)
|
||||
cd server && uvicorn main:app --reload --host 0.0.0.0 --port 8000
|
||||
# Backend (terminale 1)
|
||||
uv run uvicorn src.backend.main:app --reload --host 0.0.0.0 --port 8000
|
||||
|
||||
# Client (terminale 2)
|
||||
cd client && flask run --host 0.0.0.0 --port 5000
|
||||
# Frontend (terminale 2) — gunicorn richiede cwd interna per app:create_app()
|
||||
cd src/frontend/flask_app && uv run --project ../../.. gunicorn --bind 0.0.0.0:5000 app:create_app()
|
||||
|
||||
# TailwindCSS watch (terminale 3)
|
||||
cd client && npx tailwindcss -i static/css/input.css -o static/css/tailwind.css --watch
|
||||
cd src/frontend/flask_app && npx tailwindcss -i static/css/input.css -o static/css/tailwind.css --watch
|
||||
```
|
||||
|
||||
### Database & Migrations
|
||||
```bash
|
||||
# alembic.ini è in server/migrations/, serve il flag -c
|
||||
cd server && alembic -c migrations/alembic.ini upgrade head # Applica migrazioni
|
||||
cd server && alembic -c migrations/alembic.ini revision --autogenerate -m "descrizione" # Genera migrazione
|
||||
cd server && alembic -c migrations/alembic.ini downgrade -1 # Rollback ultima
|
||||
docker compose exec server alembic -c migrations/alembic.ini upgrade head # Via Docker
|
||||
# alembic.ini in src/backend/migrations/, serve il flag -c
|
||||
uv run alembic -c src/backend/migrations/alembic.ini upgrade head # Applica migrazioni
|
||||
uv run alembic -c src/backend/migrations/alembic.ini revision --autogenerate -m "descrizione" # Genera
|
||||
uv run alembic -c src/backend/migrations/alembic.ini downgrade -1 # Rollback
|
||||
docker compose exec server uv run alembic -c src/backend/migrations/alembic.ini upgrade head # Via Docker
|
||||
```
|
||||
Nota: `env.py` sovrascrive la URL di alembic.ini con quella da `.env` (`settings.database_url`). `script_location = %(here)s` usa path relativo.
|
||||
Nota: `env.py` aggiunge la project root a `sys.path`, sovrascrive la URL di alembic.ini con quella da `.env` (`settings.database_url`). `script_location = %(here)s` usa path relativo.
|
||||
|
||||
### Test
|
||||
```bash
|
||||
# Server (usa SQLite in-memory via aiosqlite, no MySQL richiesto)
|
||||
cd server && pytest # Tutti i test
|
||||
cd server && pytest tests/test_auth.py # Singolo modulo
|
||||
cd server && pytest tests/test_auth.py::test_login_success # Singolo test
|
||||
cd server && pytest --cov # Con copertura
|
||||
# Tutti i test (backend + frontend)
|
||||
uv run pytest
|
||||
|
||||
# Client
|
||||
cd client && pytest
|
||||
cd client && pytest tests/test_auth.py
|
||||
# Solo backend (SQLite in-memory via aiosqlite, no MySQL richiesto)
|
||||
uv run pytest src/backend/tests/
|
||||
uv run pytest src/backend/tests/test_auth.py
|
||||
uv run pytest src/backend/tests/test_auth.py::test_login_success
|
||||
uv run pytest --cov src/backend
|
||||
|
||||
# Solo frontend
|
||||
uv run pytest src/frontend/flask_app/tests/
|
||||
```
|
||||
|
||||
### Gestione dipendenze (uv)
|
||||
```bash
|
||||
uv add <pacchetto> # core (entrambi)
|
||||
uv add --optional server <pacchetto> # solo backend
|
||||
uv add --optional client <pacchetto> # solo frontend
|
||||
uv add --optional dev <pacchetto> # solo dev/test
|
||||
uv sync --extra server --extra client --extra dev # reinstalla
|
||||
uv lock # rigenera uv.lock
|
||||
```
|
||||
|
||||
### i18n (Traduzioni)
|
||||
```bash
|
||||
# Estrai stringhe
|
||||
cd client && pybabel extract -F babel.cfg -k _ -o translations/messages.pot .
|
||||
cd src/frontend/flask_app && uv run pybabel extract -F translations/babel.cfg -k _ -o translations/messages.pot .
|
||||
|
||||
# Aggiorna catalogo
|
||||
cd client && pybabel update -i translations/messages.pot -d translations
|
||||
cd src/frontend/flask_app && uv run pybabel update -i translations/messages.pot -d translations
|
||||
|
||||
# Compila .po → .mo
|
||||
cd client && pybabel compile -d translations
|
||||
cd src/frontend/flask_app && uv run pybabel compile -d translations
|
||||
# oppure
|
||||
cd client && python compile_translations.py
|
||||
cd src/frontend/flask_app && uv run python compile_translations.py
|
||||
```
|
||||
|
||||
### Setup iniziale
|
||||
@@ -78,7 +145,7 @@ Browser/Tablet → Nginx (:80/443) → Flask Client (:5000) → APIClient → Fa
|
||||
```
|
||||
Il client Flask è un frontend server-side che comunica col backend via REST API. Ogni richiesta dal client al server include l'header `X-API-Key` per autenticazione.
|
||||
|
||||
### Server (FastAPI) — `server/`
|
||||
### Server (FastAPI) — `src/backend/`
|
||||
- **main.py**: entry point, lifespan async (`@asynccontextmanager`), registra middleware e 10 router. Health: `GET /api/health`
|
||||
- **config.py**: `Settings` (pydantic_settings.BaseSettings), legge da `../.env`. Rate limits: login 5/min, general 100/min
|
||||
- **database.py**: SQLAlchemy 2.0 async engine con `AsyncSession`, pool 10+20 overflow, `pool_recycle=3600`, `expire_on_commit=False`
|
||||
@@ -87,10 +154,10 @@ Il client Flask è un frontend server-side che comunica col backend via REST API
|
||||
- **middleware/**: Stack order (outermost→innermost): AccessLogMiddleware → CORSMiddleware → SecurityHeadersMiddleware → RateLimitMiddleware. Nota: `add_middleware()` in Starlette wrappa l'app, quindi l'ultimo aggiunto (AccessLog) è il più esterno. Il commento in `main.py` dice "outermost" per RateLimit ma è fuorviante. api_key.py (auth dependency `get_current_user()`), rate_limit.py (sliding window 60s per-IP, in-memory dicts), security_headers.py (CSP con `unsafe-eval` per Plotly.js, HSTS solo con SSL), logging.py (audit trail async su DB, esclude /api/health, /docs, /openapi.json, /redoc)
|
||||
- **models/**: User (include `email`, `language_pref`, `theme_pref`), Recipe (`image_path` per preview), RecipeVersion, RecipeTask, RecipeSubtask (`image_path` per immagine specifica), Measurement (`synced_to_csv`, `input_method`), AccessLog, SystemSetting, RecipeVersionAudit
|
||||
- **schemas/**: Pydantic v2 per validazione I/O API
|
||||
- **migrations/**: Alembic con `alembic.ini` e `env.py` nella directory `server/migrations/`
|
||||
- **migrations/**: Alembic con `alembic.ini` e `env.py` nella directory `src/backend/migrations/`
|
||||
- **tests/**: pytest + pytest-asyncio, SQLite in-memory (`sqlite+aiosqlite://`, StaticPool), WeasyPrint mockato via `sys.modules`, rate limit reset tra test
|
||||
|
||||
### Client (Flask) — `client/`
|
||||
### Client (Flask) — `src/frontend/flask_app/`
|
||||
- **app.py**: factory pattern `create_app()`, CSRF (`WTF_CSRF_TIME_LIMIT=3600`), Babel i18n (`default_locale="it"`)
|
||||
- **blueprints/**: auth (login/logout/session), maker (editor ricette con Fabric.js), measure (esecuzione misurazioni), statistics (dashboard SPC con Plotly.js), admin (gestione utenti CRUD, cambio password, toggle attivo — solo `is_admin`)
|
||||
- **services/api_client.py**: singleton `APIClient` — wrapper HTTP (get/post/put/delete) con gestione errori normalizzata, timeout 30s, header X-API-Key da session
|
||||
@@ -99,7 +166,7 @@ Il client Flask è un frontend server-side che comunica col backend via REST API
|
||||
- **translations/**: Flask-Babel, cataloghi .po/.mo per IT/EN. Locale selector: `session["language"]` → Accept-Language → `"it"`
|
||||
- **config.py**: `PERMANENT_SESSION_LIFETIME=28800` (8h), cookie secure in produzione, `BABEL_DEFAULT_TIMEZONE="Europe/Rome"`
|
||||
|
||||
## Template Structure (`client/templates/base.html`)
|
||||
## Template Structure (`src/frontend/flask_app/templates/base.html`)
|
||||
|
||||
Ordine blocchi in `base.html`:
|
||||
```
|
||||
@@ -127,7 +194,7 @@ In alternativa, usare il filtro custom `|tojson_attr` (registrato in `app.py`) c
|
||||
|
||||
Per selettori CSS in `x-data`: usare `meta[name=csrf-token]` senza virgolette interne.
|
||||
|
||||
## Fabric.js Annotation Editor (`client/static/js/annotation-editor.js`)
|
||||
## Fabric.js Annotation Editor (`src/frontend/flask_app/static/js/annotation-editor.js`)
|
||||
|
||||
Editor annotazioni su disegni tecnici (Fabric.js 5.3.1, ~1200 righe). Pattern critici:
|
||||
|
||||
@@ -152,16 +219,16 @@ Le ricette usano un versioning condizionale. L'endpoint `PUT /api/recipes/{id}`
|
||||
|
||||
La stessa logica si applica nel task router: aggiungere un task a una ricetta con measurements crea una nuova versione.
|
||||
|
||||
La versione corrente ha `is_current=True`, le precedenti `False`. Audit trail in `recipe_version_audit` (CREATE, UPDATE, ACTIVATE, RETIRE). Logica in `server/services/recipe_service.py`.
|
||||
La versione corrente ha `is_current=True`, le precedenti `False`. Audit trail in `recipe_version_audit` (CREATE, UPDATE, ACTIVATE, RETIRE). Logica in `src/backend/services/recipe_service.py`.
|
||||
|
||||
### Calcolo Pass/Fail
|
||||
Ogni subtask ha 4 limiti di tolleranza: UTL (upper tolerance), UWL (upper warning), LWL (lower warning), LTL (lower tolerance) più un valore nominale. Il calcolo in `server/services/measurement_service.py`:
|
||||
Ogni subtask ha 4 limiti di tolleranza: UTL (upper tolerance), UWL (upper warning), LWL (lower warning), LTL (lower tolerance) più un valore nominale. Il calcolo in `src/backend/services/measurement_service.py`:
|
||||
- Fuori UTL/LTL → **fail**
|
||||
- Fuori UWL/LWL ma dentro UTL/LTL → **warning**
|
||||
- Dentro UWL/LWL → **pass**
|
||||
|
||||
### SPC (Statistical Process Control)
|
||||
Calcoli in `server/services/spc_service.py` usando solo `math` e `statistics` stdlib (no numpy/scipy): summary (conteggi pass/warning/fail), capability (Cp, Cpk, Pp, Ppk), control chart (UCL/LCL = mean ± 3σ), histogram (20 bin + curva normale).
|
||||
Calcoli in `src/backend/services/spc_service.py` usando solo `math` e `statistics` stdlib (no numpy/scipy): summary (conteggi pass/warning/fail), capability (Cp, Cpk, Pp, Ppk), control chart (UCL/LCL = mean ± 3σ), histogram (20 bin + curva normale).
|
||||
|
||||
### Autenticazione
|
||||
1. Login con username/password → server ritorna `api_key` (64 char random)
|
||||
@@ -187,7 +254,7 @@ Upload in `uploads/{recipe_id}/{version_id}/`. Tipi ammessi: JPEG, PNG, GIF, Web
|
||||
|
||||
## Test Infrastructure
|
||||
|
||||
### Server (`server/tests/conftest.py`)
|
||||
### Server (`src/backend/tests/conftest.py`)
|
||||
- SQLite in-memory `sqlite+aiosqlite://` con `StaticPool` (singola connessione condivisa tra fixture, app e test)
|
||||
- WeasyPrint mockato prima di qualsiasi import server: `sys.modules["weasyprint"] = MagicMock()`
|
||||
- Rate limit buckets resettati tra test (walk middleware stack → clear dicts)
|
||||
@@ -196,7 +263,7 @@ Upload in `uploads/{recipe_id}/{version_id}/`. Tipi ammessi: JPEG, PNG, GIF, Web
|
||||
- Helper: `auth_headers(user)` → `{"X-API-Key": user.api_key}`
|
||||
- Client httpx: `AsyncClient` con `ASGITransport(app=app)`, override di `get_db` dependency
|
||||
|
||||
### Client (`client/tests/conftest.py`)
|
||||
### Client (`src/frontend/flask_app/tests/conftest.py`)
|
||||
- `api_client` patchato in 4 blueprint (auth, maker, measure, statistics). **admin NON è patchato** — i test admin devono gestire il mock manualmente
|
||||
- `logged_in_client` fixture pre-popola session con `api_key`, `user_id`, `language`, `theme` + user dict
|
||||
- CSRF disabilitato nei test: `WTF_CSRF_ENABLED=False`
|
||||
@@ -232,7 +299,7 @@ Variabili d'ambiente in `.env` (copiare da `.env.example`):
|
||||
- DB: `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD`, `DB_ROOT_PASSWORD` (Docker)
|
||||
- Server: `SERVER_HOST`, `SERVER_PORT`, `SERVER_SECRET_KEY`, `SERVER_CORS_ORIGINS`
|
||||
- Client: `CLIENT_HOST`, `CLIENT_PORT`, `CLIENT_SECRET_KEY`, `API_SERVER_URL`
|
||||
- Upload: `UPLOAD_DIR` (default `"uploads"`, relativo a `server/`), `MAX_UPLOAD_SIZE_MB`
|
||||
- Upload: `UPLOAD_DIR` (default `"uploads"`, relativo a `src/backend/`), `MAX_UPLOAD_SIZE_MB`
|
||||
- Docker: `NGINX_PORT`, `NGINX_SSL_PORT`
|
||||
- Setup: `SETUP_PASSWORD` (vuota = endpoint disabilitato)
|
||||
- SSL: `SSL_CERTFILE`, `SSL_KEYFILE`
|
||||
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
FROM python:3.11-slim AS base
|
||||
|
||||
# Install uv (fast Python package manager) from official slim image.
|
||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/
|
||||
|
||||
# System libs required by WeasyPrint at runtime.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libpango-1.0-0 \
|
||||
libpangocairo-1.0-0 \
|
||||
libcairo2 \
|
||||
libgdk-pixbuf-2.0-0 \
|
||||
libffi-dev \
|
||||
shared-mime-info \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy lockfile + project metadata first to maximize Docker layer cache.
|
||||
COPY pyproject.toml uv.lock ./
|
||||
COPY .python-version ./
|
||||
|
||||
# Install ONLY backend deps from the locked dependencies. --frozen ensures
|
||||
# we never resolve at build time; --no-dev keeps the image lean.
|
||||
RUN uv sync --frozen --no-dev --extra server
|
||||
|
||||
# Now copy the actual sources.
|
||||
COPY src/ ./src/
|
||||
|
||||
# Uploads directory (mounted as a volume in production).
|
||||
RUN mkdir -p /app/uploads/images /app/uploads/pdfs /app/uploads/logos /app/uploads/reports
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
# Entry point: run Alembic migrations then start Uvicorn through uv so
|
||||
# it uses the pinned interpreter and venv from `uv sync`.
|
||||
CMD ["sh", "-c", \
|
||||
"uv run alembic -c src/backend/migrations/alembic.ini upgrade head && \
|
||||
uv run uvicorn src.backend.main:app \
|
||||
--host 0.0.0.0 --port 8000 --workers 4 \
|
||||
--proxy-headers --forwarded-allow-ips='*'"]
|
||||
@@ -0,0 +1,42 @@
|
||||
FROM python:3.11-slim AS base
|
||||
|
||||
# uv from the official slim image (fast Python package manager).
|
||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/
|
||||
|
||||
# Node.js 20 is needed at build time to compile TailwindCSS.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \
|
||||
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
||||
&& apt-get install -y --no-install-recommends nodejs \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Resolve Python deps from the project lockfile (only the `client` extra).
|
||||
COPY pyproject.toml uv.lock ./
|
||||
COPY .python-version ./
|
||||
RUN uv sync --frozen --no-dev --extra client
|
||||
|
||||
# Copy the Flask app sources.
|
||||
COPY src/frontend/flask_app/ ./flask_app/
|
||||
|
||||
# Build TailwindCSS (one-shot; no watcher in production image).
|
||||
WORKDIR /app/flask_app
|
||||
RUN npm install tailwindcss@3 && \
|
||||
npx tailwindcss -i static/css/input.css -o static/css/tailwind.css --minify
|
||||
|
||||
# Compile Flask-Babel translation catalogs.
|
||||
RUN uv run --project /app pybabel compile -d translations
|
||||
|
||||
EXPOSE 5000
|
||||
|
||||
# Gunicorn behind Nginx/Traefik. Worker count and proxy trust kept in sync
|
||||
# with the per-tablet rate-limiting fix (see V2.0.0 perf commit).
|
||||
CMD ["uv", "run", "--project", "/app", "gunicorn", \
|
||||
"--workers", "5", \
|
||||
"--threads", "4", \
|
||||
"--worker-class", "gthread", \
|
||||
"--timeout", "60", \
|
||||
"--bind", "0.0.0.0:5000", \
|
||||
"--access-logfile", "-", \
|
||||
"--forwarded-allow-ips", "*", \
|
||||
"app:create_app()"]
|
||||
@@ -1,6 +1,9 @@
|
||||
# TieMeasureFlow by Tielogic
|
||||
|
||||
Sistema di gestione task per misurazioni con calibro manuale. Soluzione tablet-first, multi-ruolo, con statistiche SPC (Statistical Process Control) integrate.
|
||||
Sistema di gestione task per misurazioni con calibro manuale. Soluzione tablet-first, multi-ruolo, con statistiche SPC (Statistical Process Control) integrate, identità per-stazione e rate limiting per-tablet.
|
||||
|
||||
> **Versione corrente:** V2.0.0 (in sviluppo) — branch default `V2.0.0`.
|
||||
> Per stato dettagliato e prossimi passi vedi [`docs/architecture/STATO_PROGETTO.md`](docs/architecture/STATO_PROGETTO.md) e [`docs/architecture/ROADMAP.md`](docs/architecture/ROADMAP.md).
|
||||
|
||||
---
|
||||
|
||||
@@ -12,10 +15,13 @@ Caratteristiche principali:
|
||||
|
||||
- Recipe versioning condizionale (copy-on-write se esistono misurazioni, update in-place altrimenti)
|
||||
- Calcolo pass/fail/warning su quattro limiti di tolleranza (UTL, UWL, LWL, LTL)
|
||||
- Tracciamento del tempo di inserimento per ogni misura (`input_duration_ms`): il client misura quanto tempo l'operatore impiega su ciascun subtask, dall'attivazione al salvataggio, utile per analisi di tempo ciclo e produttività
|
||||
- SPC: Cp, Cpk, Pp, Ppk, control chart UCL/LCL, istogramma con curva normale — puro stdlib (no numpy)
|
||||
- Annotazioni grafiche su disegni tecnici (Fabric.js) con viewer sincronizzato all'esecuzione
|
||||
- Identità per-stazione (`STATION_CODE`): ogni tablet vede solo le ricette assegnate alla propria stazione, gestione assegnazioni via GUI admin
|
||||
- Interfaccia completamente localizzata IT/EN, dark mode, ottimizzata per tablet touch
|
||||
- Autenticazione API Key, rate limiting sliding window, audit log persistente
|
||||
- Autenticazione API Key, rate limiting sliding window per-IP reale (X-Forwarded-For-aware), audit log persistente
|
||||
- Capacità testata per ~20 tablet contemporanei (gunicorn 5 workers × 4 thread + uvicorn 4 workers async)
|
||||
|
||||
---
|
||||
|
||||
@@ -26,25 +32,25 @@ Browser/Tablet
|
||||
|
|
||||
Reverse Proxy (Nginx — sviluppo | Traefik+SSL — produzione)
|
||||
|
|
||||
Flask Client :5000 (rendering server-side, Jinja2 + Alpine.js)
|
||||
| X-API-Key
|
||||
FastAPI Server :8000 (API REST asincrona)
|
||||
Flask Frontend :5000 (rendering server-side, Jinja2 + Alpine.js)
|
||||
| X-API-Key + X-Forwarded-For
|
||||
FastAPI Backend :8000 (API REST asincrona)
|
||||
|
|
||||
MySQL 8.0
|
||||
```
|
||||
|
||||
Il client Flask non espone mai le credenziali al browser: ogni chiamata al backend avviene server-side con l'header `X-API-Key` estratto dalla sessione Flask.
|
||||
Il frontend Flask non espone mai le credenziali al browser: ogni chiamata al backend avviene server-side con l'header `X-API-Key` estratto dalla sessione Flask. L'IP reale del tablet è propagato in `X-Forwarded-For` per il rate limiter.
|
||||
|
||||
---
|
||||
|
||||
## Stack Tecnologico
|
||||
|
||||
### Backend (server/)
|
||||
### Backend (`src/backend/`)
|
||||
|
||||
| Componente | Versione | Ruolo |
|
||||
|---|---|---|
|
||||
| FastAPI | ultima stabile | Framework API REST asincrono |
|
||||
| SQLAlchemy 2.0 | async | ORM con pool connessioni |
|
||||
| SQLAlchemy 2.0 | async | ORM con pool connessioni 10+20 |
|
||||
| asyncmy | ultima stabile | Driver MySQL asincrono |
|
||||
| MySQL | 8.0 | Database relazionale |
|
||||
| Alembic | ultima stabile | Migrazioni schema |
|
||||
@@ -54,35 +60,47 @@ Il client Flask non espone mai le credenziali al browser: ogni chiamata al backe
|
||||
| bcrypt | ultima stabile | Hashing password |
|
||||
| Pillow | ultima stabile | Thumbnail automatici upload |
|
||||
|
||||
### Frontend (client/)
|
||||
### Frontend (`src/frontend/flask_app/`)
|
||||
|
||||
| Componente | Versione | Ruolo |
|
||||
|---|---|---|
|
||||
| Flask | 3.x | Framework web server-side |
|
||||
| gunicorn | 21+ | WSGI server (5 workers × 4 thread gthread) |
|
||||
| Jinja2 | incluso in Flask | Template engine |
|
||||
| Alpine.js | 3.x (CDN) | Reattivita leggera lato client |
|
||||
| Alpine.js | 3.x (CDN) | Reattività leggera lato client |
|
||||
| TailwindCSS | 3.x | CSS utility-first |
|
||||
| Plotly.js | CDN | Grafici SPC interattivi |
|
||||
| Fabric.js | 5.3.1 (CDN) | Editor annotazioni disegni tecnici |
|
||||
| html5-qrcode | CDN | Scanner barcode/QR camera |
|
||||
| Flask-Babel | ultima stabile | i18n IT/EN |
|
||||
|
||||
### Tooling
|
||||
|
||||
| Componente | Ruolo |
|
||||
|---|---|
|
||||
| **uv** | Package manager Python (no `requirements.txt`) |
|
||||
| `pyproject.toml` | Dipendenze monorepo con extra `server`/`client`/`dev` |
|
||||
| `uv.lock` | Lockfile per build riproducibili |
|
||||
| `.python-version` | Pin Python 3.11 |
|
||||
| pytest + pytest-asyncio + httpx + aiosqlite | Test stack |
|
||||
|
||||
---
|
||||
|
||||
## Quick Start con Docker
|
||||
|
||||
Docker Compose è il metodo raccomandato. Gestisce database, migrazioni e configurazione Nginx in un solo comando.
|
||||
Docker Compose è il metodo raccomandato. Gestisce database, migrazioni, build delle immagini con `uv` e configurazione Nginx in un solo comando.
|
||||
|
||||
```bash
|
||||
# 1. Clona il repository
|
||||
git clone <repository-url>
|
||||
git clone ssh://git@git.tielogic.xyz:222/Adriano/TieMeasureFlow.git
|
||||
cd TieMeasureFlow
|
||||
|
||||
# 2. Configura le variabili d'ambiente
|
||||
cp .env.example .env
|
||||
# Modifica .env: credenziali DB, chiavi segrete, SETUP_PASSWORD
|
||||
# Modifica .env: credenziali DB, chiavi segrete, SETUP_PASSWORD, STATION_CODE per ogni tablet
|
||||
|
||||
# 3. Avvia i servizi (ambiente di sviluppo)
|
||||
docker compose -f docker-compose.dev.yml up -d
|
||||
docker compose -f docker-compose.dev.yml up -d --build
|
||||
|
||||
# 4. Verifica lo stato dei container
|
||||
docker compose -f docker-compose.dev.yml ps
|
||||
@@ -90,6 +108,7 @@ docker compose -f docker-compose.dev.yml ps
|
||||
# 5. Setup iniziale (solo al primo avvio)
|
||||
# Apri http://localhost/api/setup nel browser
|
||||
# Usa SETUP_PASSWORD configurata in .env
|
||||
# Lo script seed crea anche la stazione ST-DEFAULT con tutte le ricette assegnate
|
||||
```
|
||||
|
||||
L'applicazione sarà disponibile su:
|
||||
@@ -97,8 +116,9 @@ L'applicazione sarà disponibile su:
|
||||
- Frontend: http://localhost
|
||||
- API: http://localhost/api
|
||||
- Pagina setup: http://localhost/api/setup
|
||||
- Admin stazioni: http://localhost/admin/stations (solo `is_admin`)
|
||||
|
||||
Per il deployment in produzione (Traefik + SSL) consulta [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md).
|
||||
Per il deployment in produzione (Traefik + SSL) consulta [`docs/DEPLOYMENT.md`](docs/DEPLOYMENT.md).
|
||||
|
||||
---
|
||||
|
||||
@@ -108,12 +128,14 @@ Dopo il primo avvio, la pagina `/api/setup` (protetta da `SETUP_PASSWORD`) perme
|
||||
|
||||
- **Initialize Database** — crea tutte le tabelle
|
||||
- **Create Admin User** — crea l'utente amministratore con credenziali da `.env`
|
||||
- **Seed Demo Data** — carica ricette, misurazioni e utenti di esempio
|
||||
- **Seed Demo Data** — carica ricette, misurazioni e utenti di esempio + crea stazione `ST-DEFAULT` con tutte le ricette assegnate
|
||||
- **Reset Database** — elimina e ricrea tutte le tabelle (attenzione: cancella tutti i dati)
|
||||
- **Gestione utenti** — crea, modifica, attiva/disattiva account dalla stessa pagina
|
||||
|
||||
Se `SETUP_PASSWORD` è vuota o assente nel `.env`, l'endpoint è disabilitato.
|
||||
|
||||
Per gestire stazioni e assegnazioni ricette dopo il setup: `/admin/stations` (richiede login admin).
|
||||
|
||||
---
|
||||
|
||||
## Setup Manuale (Senza Docker)
|
||||
@@ -121,8 +143,9 @@ Se `SETUP_PASSWORD` è vuota o assente nel `.env`, l'endpoint è disabilitato.
|
||||
### Requisiti
|
||||
|
||||
- Python 3.11 o superiore
|
||||
- Node.js 18 o superiore
|
||||
- Node.js 18 o superiore (per TailwindCSS)
|
||||
- MySQL 8.0
|
||||
- [uv](https://docs.astral.sh/uv/) installato
|
||||
|
||||
### 1. Database MySQL
|
||||
|
||||
@@ -139,35 +162,37 @@ SQL
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Imposta DB_HOST, DB_USER, DB_PASSWORD, DB_NAME, SERVER_SECRET_KEY, CLIENT_SECRET_KEY, SETUP_PASSWORD
|
||||
# Imposta DB_HOST, DB_USER, DB_PASSWORD, DB_NAME, SERVER_SECRET_KEY,
|
||||
# CLIENT_SECRET_KEY, SETUP_PASSWORD, STATION_CODE
|
||||
```
|
||||
|
||||
### 3. Server FastAPI
|
||||
### 3. Installa dipendenze (uv)
|
||||
|
||||
```bash
|
||||
cd server
|
||||
python -m venv venv
|
||||
source venv/bin/activate # Windows: venv\Scripts\activate
|
||||
pip install -r requirements.txt
|
||||
alembic -c migrations/alembic.ini upgrade head
|
||||
uvicorn main:app --reload --host 0.0.0.0 --port 8000
|
||||
uv sync --extra server --extra client --extra dev
|
||||
```
|
||||
|
||||
### 4. Client Flask
|
||||
### 4. Backend FastAPI
|
||||
|
||||
```bash
|
||||
cd client
|
||||
python -m venv venv
|
||||
source venv/bin/activate # Windows: venv\Scripts\activate
|
||||
pip install -r requirements.txt
|
||||
pybabel compile -d translations
|
||||
flask run --host 0.0.0.0 --port 5000
|
||||
uv run alembic -c src/backend/migrations/alembic.ini upgrade head
|
||||
uv run uvicorn src.backend.main:app --reload --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
### 5. TailwindCSS (watch per sviluppo)
|
||||
### 5. Frontend Flask
|
||||
|
||||
```bash
|
||||
cd client
|
||||
# Compila i cataloghi i18n una volta
|
||||
cd src/frontend/flask_app && uv run --project ../../.. pybabel compile -d translations && cd -
|
||||
|
||||
# Avvia (development)
|
||||
cd src/frontend/flask_app && uv run --project ../../.. flask run --host 0.0.0.0 --port 5000
|
||||
```
|
||||
|
||||
### 6. TailwindCSS (watch per sviluppo)
|
||||
|
||||
```bash
|
||||
cd src/frontend/flask_app
|
||||
npx tailwindcss -i static/css/input.css -o static/css/tailwind.css --watch
|
||||
```
|
||||
|
||||
@@ -185,9 +210,9 @@ I ruoli sono combinabili (array JSON per utente). Il flag `is_admin` è separato
|
||||
| Ruolo | Descrizione |
|
||||
|---|---|
|
||||
| **Maker** | Crea e gestisce ricette di misurazione: caricamento disegni (PDF/immagini), annotazioni Fabric.js, definizione task/subtask, configurazione tolleranze, versioning copy-on-write |
|
||||
| **MeasurementTec** | Esegue misurazioni: scansione barcode per selezione ricetta, interfaccia task-driven, input da calibro USB HID o numpad touch, validazione real-time pass/warning/fail |
|
||||
| **MeasurementTec** | Esegue misurazioni: scansione barcode per selezione ricetta, interfaccia task-driven, input da calibro USB HID o numpad touch, validazione real-time pass/warning/fail. Vede solo le ricette assegnate alla propria stazione (`STATION_CODE`) |
|
||||
| **Metrologist** | Analisi qualità: dashboard SPC (X-bar, R, Cp, Cpk, Pp, Ppk), filtri multi-dimensionali, export report PDF, analisi capability e control chart |
|
||||
| **Admin** (flag) | Gestione sistema: CRUD utenti, cambio password, attivazione/disattivazione account |
|
||||
| **Admin** (flag) | Gestione sistema: CRUD utenti, cambio password, attivazione/disattivazione account, **CRUD stazioni e assegnazioni ricette** |
|
||||
|
||||
---
|
||||
|
||||
@@ -195,39 +220,53 @@ I ruoli sono combinabili (array JSON per utente). Il flag `is_admin` è separato
|
||||
|
||||
```
|
||||
TieMeasureFlow/
|
||||
├── server/ # FastAPI Backend
|
||||
│ ├── main.py # Entry point, lifespan, middleware, 10 router
|
||||
│ ├── config.py # Settings (pydantic_settings.BaseSettings)
|
||||
│ ├── database.py # SQLAlchemy 2.0 async engine
|
||||
│ ├── models/ # ORM: User, Recipe, RecipeVersion, RecipeTask,
|
||||
│ │ # RecipeSubtask, Measurement, AccessLog,
|
||||
│ │ # SystemSetting, RecipeVersionAudit
|
||||
│ ├── schemas/ # Pydantic v2 per validazione I/O API
|
||||
│ ├── routers/ # auth, users, recipes, tasks, measurements,
|
||||
│ │ # files, settings, statistics, reports, setup
|
||||
│ ├── services/ # recipe_service, measurement_service,
|
||||
│ │ # spc_service, report_service, auth_service
|
||||
│ ├── middleware/ # api_key, rate_limit, security_headers, logging
|
||||
│ ├── migrations/ # Alembic (alembic.ini + env.py)
|
||||
│ └── templates/ # Template HTML pagina setup
|
||||
├── client/ # Flask Frontend
|
||||
│ ├── app.py # Factory pattern, CSRF, Babel
|
||||
│ ├── blueprints/ # auth, maker, measure, statistics, admin
|
||||
│ ├── services/ # APIClient singleton (proxy verso FastAPI)
|
||||
│ ├── templates/ # Jinja2 + Alpine.js
|
||||
│ ├── static/
|
||||
│ │ ├── css/ # TailwindCSS compilato
|
||||
│ │ └── js/ # numpad, caliper, barcode, csv-export,
|
||||
│ │ # spc-charts, annotation-editor/viewer
|
||||
│ └── translations/ # Flask-Babel .po/.mo IT/EN
|
||||
├── nginx/ # Configurazione Nginx (dev)
|
||||
├── docs/ # Documentazione tecnica
|
||||
│ ├── API.md # Riferimento API REST
|
||||
│ ├── DEPLOYMENT.md # Guida deployment VPS (Traefik, SSL, DNS)
|
||||
│ └── USER_GUIDE.md # Manuale utente per ruolo
|
||||
├── docker-compose.yml # Produzione (Traefik, SSL)
|
||||
├── docker-compose.dev.yml # Sviluppo (Nginx, porta 80)
|
||||
└── .env.example # Template variabili d'ambiente
|
||||
├── pyproject.toml # Dipendenze monorepo (uv)
|
||||
├── uv.lock # Lockfile riproducibile
|
||||
├── .python-version # 3.11
|
||||
├── Dockerfile # Backend (uv + uvicorn)
|
||||
├── Dockerfile.frontend # Frontend (uv + gunicorn + Tailwind + Babel)
|
||||
├── docker-compose.dev.yml # Sviluppo (Nginx, porta 80)
|
||||
├── docker-compose.yml # Produzione (Traefik, SSL)
|
||||
├── nginx/ # Config Nginx (dev)
|
||||
├── uploads/ # Volume Docker file caricati
|
||||
├── docs/ # Documentazione (vedi indice docs/README.md)
|
||||
└── src/
|
||||
├── backend/ # FastAPI Backend
|
||||
│ ├── main.py # Entry point, lifespan, middleware, 11 router
|
||||
│ ├── config.py # Settings (pydantic_settings.BaseSettings)
|
||||
│ ├── database.py # SQLAlchemy 2.0 async engine
|
||||
│ ├── api/
|
||||
│ │ ├── routers/ # auth, users, recipes, tasks, measurements,
|
||||
│ │ │ # files, settings, statistics, reports,
|
||||
│ │ │ # setup, stations
|
||||
│ │ └── middleware/ # api_key, rate_limit, security_headers, logging
|
||||
│ ├── models/
|
||||
│ │ ├── orm/ # SQLAlchemy: User, Recipe, RecipeVersion,
|
||||
│ │ │ # RecipeTask, RecipeSubtask, Measurement,
|
||||
│ │ │ # AccessLog, SystemSetting,
|
||||
│ │ │ # RecipeVersionAudit, Station,
|
||||
│ │ │ # StationRecipeAssignment
|
||||
│ │ └── api/ # Pydantic v2 schemas request/response
|
||||
│ ├── services/ # recipe_service, measurement_service,
|
||||
│ │ # spc_service, report_service,
|
||||
│ │ # auth_service, station_service
|
||||
│ ├── migrations/ # Alembic (alembic.ini + env.py)
|
||||
│ ├── templates/ # Pagina setup (Jinja2)
|
||||
│ └── tests/ # pytest + httpx + aiosqlite
|
||||
└── frontend/
|
||||
└── flask_app/ # Flask Frontend
|
||||
├── app.py # Factory + ProxyFix + CSRF + Babel
|
||||
├── config.py # STATION_CODE, API_SERVER_URL, ecc.
|
||||
├── compile_translations.py
|
||||
├── blueprints/ # auth, maker, measure, statistics, admin
|
||||
├── services/ # APIClient (proxy verso FastAPI con XFF)
|
||||
├── templates/ # Jinja2 + Alpine.js
|
||||
├── static/
|
||||
│ ├── css/ # TailwindCSS compilato
|
||||
│ └── js/ # numpad, caliper, barcode, csv-export,
|
||||
│ # spc-charts, annotation-editor/viewer
|
||||
├── translations/ # Flask-Babel .po/.mo IT/EN
|
||||
└── tests/
|
||||
```
|
||||
|
||||
---
|
||||
@@ -238,7 +277,7 @@ TieMeasureFlow/
|
||||
|
||||
| Comando | Descrizione |
|
||||
|---|---|
|
||||
| `docker compose -f docker-compose.dev.yml up -d` | Avvia servizi in sviluppo |
|
||||
| `docker compose -f docker-compose.dev.yml up -d --build` | Avvia servizi in sviluppo (build incluso) |
|
||||
| `docker compose -f docker-compose.dev.yml down` | Ferma e rimuove i container |
|
||||
| `docker compose logs -f server` | Segui log server in tempo reale |
|
||||
| `docker compose logs -f client` | Segui log client in tempo reale |
|
||||
@@ -248,47 +287,62 @@ TieMeasureFlow/
|
||||
|
||||
### Alembic (migrations)
|
||||
|
||||
Nota: `alembic.ini` si trova dentro `server/migrations/`, è richiesto il flag `-c`.
|
||||
`alembic.ini` si trova in `src/backend/migrations/`, è richiesto il flag `-c`. `env.py` aggiunge la project root a `sys.path` per risolvere `src.backend.*`.
|
||||
|
||||
```bash
|
||||
cd server
|
||||
alembic -c migrations/alembic.ini upgrade head # Applica migrazioni
|
||||
alembic -c migrations/alembic.ini revision --autogenerate -m "descrizione" # Genera migrazione
|
||||
alembic -c migrations/alembic.ini downgrade -1 # Rollback ultima
|
||||
uv run alembic -c src/backend/migrations/alembic.ini upgrade head # Applica migrazioni
|
||||
uv run alembic -c src/backend/migrations/alembic.ini revision --autogenerate -m "descrizione" # Genera
|
||||
uv run alembic -c src/backend/migrations/alembic.ini downgrade -1 # Rollback ultima
|
||||
```
|
||||
|
||||
Via Docker:
|
||||
|
||||
```bash
|
||||
docker compose exec server alembic -c migrations/alembic.ini upgrade head
|
||||
docker compose exec server uv run alembic -c src/backend/migrations/alembic.ini upgrade head
|
||||
```
|
||||
|
||||
### i18n (Traduzioni)
|
||||
|
||||
```bash
|
||||
cd client
|
||||
pybabel extract -F babel.cfg -k _ -o translations/messages.pot . # Estrai stringhe
|
||||
pybabel update -i translations/messages.pot -d translations # Aggiorna catalogo
|
||||
pybabel compile -d translations # Compila .po → .mo
|
||||
cd src/frontend/flask_app
|
||||
uv run --project ../../.. pybabel extract -F babel.cfg -k _ -o translations/messages.pot . # Estrai
|
||||
uv run --project ../../.. pybabel update -i translations/messages.pot -d translations # Aggiorna
|
||||
uv run --project ../../.. pybabel compile -d translations # Compila .po → .mo
|
||||
```
|
||||
|
||||
### Gestione dipendenze (uv)
|
||||
|
||||
```bash
|
||||
uv add <pacchetto> # core (entrambi)
|
||||
uv add --optional server <pacchetto> # solo backend
|
||||
uv add --optional client <pacchetto> # solo frontend
|
||||
uv add --optional dev <pacchetto> # solo dev/test
|
||||
uv sync --extra server --extra client --extra dev # reinstalla
|
||||
uv lock # rigenera uv.lock
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
Il server usa SQLite in-memory tramite `aiosqlite`: i test girano senza MySQL installato.
|
||||
Il backend usa SQLite in-memory tramite `aiosqlite`: i test girano senza MySQL installato.
|
||||
|
||||
```bash
|
||||
# Server
|
||||
cd server && pytest # Tutti i test
|
||||
cd server && pytest tests/test_auth.py # Singolo modulo
|
||||
cd server && pytest tests/test_auth.py::test_login_success # Singolo test
|
||||
cd server && pytest --cov # Con copertura
|
||||
# Tutti i test (backend + frontend)
|
||||
uv run pytest
|
||||
|
||||
# Client
|
||||
cd client && pytest
|
||||
# Solo backend
|
||||
uv run pytest src/backend/tests/
|
||||
uv run pytest src/backend/tests/test_auth.py
|
||||
uv run pytest src/backend/tests/test_auth.py::test_login_success
|
||||
uv run pytest --cov src/backend
|
||||
|
||||
# Solo frontend
|
||||
uv run pytest src/frontend/flask_app/tests/
|
||||
```
|
||||
|
||||
Stato corrente: **183 pass, 0 fail** (vedi `docs/architecture/STATO_PROGETTO.md`).
|
||||
|
||||
---
|
||||
|
||||
## Variabili d'Ambiente
|
||||
@@ -304,8 +358,11 @@ Copia `.env.example` in `.env` e configura:
|
||||
| `SERVER_CORS_ORIGINS` | Origini CORS ammesse |
|
||||
| `CLIENT_SECRET_KEY` | Chiave segreta Flask (sessioni, CSRF) |
|
||||
| `API_SERVER_URL` | URL del backend visto dal client (es. `http://server:8000`) |
|
||||
| `UPLOAD_DIR` | Percorso upload file (default: `uploads`, relativo a `server/`) |
|
||||
| `MAX_UPLOAD_SIZE_MB` | Limite dimensione upload |
|
||||
| `STATION_CODE` | **Per-tablet** — codice stazione (es. `ST-001`). Senza, il client mostra errore configurazione. |
|
||||
| `UPLOAD_DIR` | Percorso upload file (default: `uploads`, project root) |
|
||||
| `MAX_UPLOAD_SIZE_MB` | Limite dimensione upload (default 50) |
|
||||
| `RATE_LIMIT_LOGIN` | Login req/min/IP (default 5) |
|
||||
| `RATE_LIMIT_GENERAL` | Richieste req/min/IP (default 300, per-tablet) |
|
||||
| `NGINX_PORT`, `NGINX_SSL_PORT` | Porte Nginx (solo compose dev) |
|
||||
| `SETUP_PASSWORD` | Password pagina setup (vuota = endpoint disabilitato) |
|
||||
| `SSL_CERTFILE`, `SSL_KEYFILE` | Certificato SSL (solo setup manuale) |
|
||||
@@ -314,16 +371,35 @@ Copia `.env.example` in `.env` e configura:
|
||||
|
||||
## Documentazione
|
||||
|
||||
Indice completo: [`docs/README.md`](docs/README.md).
|
||||
|
||||
### Stato e direzione
|
||||
|
||||
| Documento | Contenuto |
|
||||
|---|---|
|
||||
| [docs/API.md](docs/API.md) | Riferimento completo API REST (endpoint, parametri, schemi) |
|
||||
| [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md) | Guida deployment VPS: Docker, Traefik, SSL, DNS, firewall |
|
||||
| [docs/USER_GUIDE.md](docs/USER_GUIDE.md) | Manuale utente per ruolo (Maker, MeasurementTec, Metrologist) |
|
||||
| [`docs/architecture/STATO_PROGETTO.md`](docs/architecture/STATO_PROGETTO.md) | Snapshot V2.0.0: cosa funziona oggi, test status, decisioni architetturali |
|
||||
| [`docs/architecture/ROADMAP.md`](docs/architecture/ROADMAP.md) | Cosa resta da fare (Fasi 2-7 rev04, decisioni cliente aperte, stime) |
|
||||
|
||||
### Riferimenti operativi
|
||||
|
||||
| Documento | Contenuto |
|
||||
|---|---|
|
||||
| [`docs/API.md`](docs/API.md) | Riferimento completo API REST (endpoint, parametri, schemi) |
|
||||
| [`docs/DEPLOYMENT.md`](docs/DEPLOYMENT.md) | Guida deployment VPS: Docker, Traefik, SSL, DNS, firewall |
|
||||
| [`docs/USER_GUIDE.md`](docs/USER_GUIDE.md) | Manuale utente per ruolo (Maker, MeasurementTec, Metrologist) |
|
||||
| [`docs/I18N_SETUP.md`](docs/I18N_SETUP.md) | Setup e workflow traduzioni (Flask-Babel + Alpine.js) |
|
||||
|
||||
### Piani dettagliati
|
||||
|
||||
| Documento | Contenuto |
|
||||
|---|---|
|
||||
| [`docs/superpowers/plans/2026-04-17-rev04-master-roadmap.md`](docs/superpowers/plans/2026-04-17-rev04-master-roadmap.md) | Master plan rev04 (M1 + M2, decisioni aperte, stime) |
|
||||
| [`docs/superpowers/plans/2026-04-17-rev04-phase1-stations.md`](docs/superpowers/plans/2026-04-17-rev04-phase1-stations.md) | Piano TDD Fase 1 stazioni (completato) |
|
||||
|
||||
---
|
||||
|
||||
## Licenza
|
||||
|
||||
Proprietary - Tielogic. All rights reserved.
|
||||
Proprietary — Tielogic. All rights reserved.
|
||||
|
||||
Questo software è di proprietà esclusiva di Tielogic ed è protetto dalle leggi sul copyright. Non è consentita la distribuzione, modifica o utilizzo senza autorizzazione scritta.
|
||||
|
||||
@@ -0,0 +1,614 @@
|
||||
# TieMeasureFlow — modifiche da apportare
|
||||
|
||||
**Data:** 28 luglio 2026 (agg. serale: architettura d'installazione, punti 7-8 precisati, punti 14-15 nuovi, timer bidirezionale nel punto 3)
|
||||
**Riferimento codice:** branch `V2.0.0`, commit `2a56632`
|
||||
**Destinatari:** Parte 1 leggibile da cliente e rivenditore · Parte 2 per chi mette mano al codice
|
||||
|
||||
---
|
||||
|
||||
## Come leggere questo documento
|
||||
|
||||
La **Parte 1** dice *cosa deve fare* il sistema e *perché*: si può mandare a Ricerca e
|
||||
Misure e a Tràfilo. La **Parte 2** dice *dove si interviene*, con file e criteri di
|
||||
completamento. La **Parte 3** elenca le domande la cui risposta non dipende da noi.
|
||||
|
||||
Ogni punto porta lo **stato verificato sul codice**, non riferito: `già fatto`,
|
||||
`parziale`, `da fare`. Serve a evitare l'equivoco emerso in questi giorni — diverse
|
||||
funzioni risultano "già implementate" perché **l'interfaccia c'è**, ma dietro non
|
||||
succede nulla.
|
||||
|
||||
**Fonti:** specifica *Schema sviluppo SW TieFlow rev. 04-2026*; *MODIFICHE TIEMEASURE
|
||||
v260519*; call del 28/07/2026; rettifica di Marco Menoncin del 28/07; lettura diretta
|
||||
del codice.
|
||||
|
||||
---
|
||||
|
||||
## Il punto che regge tutti gli altri
|
||||
|
||||
**Lo stato della produzione oggi vive dentro una pagina del browser.**
|
||||
|
||||
Il timer, il conteggio dei cicli, il flag "produzione avviata" e le misure in corso
|
||||
sono variabili del componente Alpine di `task_execute.html`. La navigazione fra un
|
||||
task e l'altro è un **ricaricamento completo di pagina** (`window.location.href`).
|
||||
|
||||
Conseguenza diretta: **cambiando task si perde tutto.** Il timer smette di esistere,
|
||||
il conteggio dei cicli riparte, "produzione avviata" si dimentica.
|
||||
|
||||
Questo spiega, senza bisogno di altre ipotesi:
|
||||
|
||||
- perché il loop di misura non regge quando la ricetta ha altri task dopo la misura;
|
||||
- perché con più task di misura non si sa a quale tornare allo scadere del timer;
|
||||
- perché *fermo linea* e *fine produzione* non hanno nulla da fermare o da chiudere;
|
||||
- perché non esiste storico di cosa è successo durante una produzione.
|
||||
|
||||
**Va risolto per primo**: i punti 2, 3, 6 e 8 dipendono da questa scelta, e affrontarli
|
||||
prima significa rifarli dopo.
|
||||
|
||||
---
|
||||
|
||||
## Architettura d'installazione (decisa il 28/07)
|
||||
|
||||
Chiude la domanda D-3 lato Tielogic. L'installazione a Tràfilo è così composta:
|
||||
|
||||
```
|
||||
SERVER (uno, del cliente) STAZIONE (una per PC, installata in locale)
|
||||
┌─────────────────────────┐ ┌──────────────────────────────────────┐
|
||||
│ MySQL │◄──API──│ App di stazione: │
|
||||
│ Backend dati (API) │ │ · interfaccia operatore (frontend) │
|
||||
│ File dei disegni │ │ · agente hardware: calibro USB, │
|
||||
│ Migrazioni dello schema │ │ driver camera, colonnina/cicalino │
|
||||
│ Adattatore GAIA │ │ · elaborazione visione (futura: │
|
||||
└─────────────────────────┘ │ XF compare, pattern matching) │
|
||||
└──────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **Sul server vive tutto ciò che è stato**: il database, i file dei disegni, le
|
||||
migrazioni dello schema e l'unico punto di contatto col gestionale GAIA.
|
||||
Aggiornare la logica dati = un deploy, in un posto solo.
|
||||
- **Sulla stazione vive tutto ciò che tocca l'hardware**: calibro, telecamera,
|
||||
segnalazione luminosa/acustica, e in prospettiva l'elaborazione visione — che
|
||||
così non carica il server (rilevante per il dimensionamento, D-4).
|
||||
L'app di stazione è **senza stato**: non tocca il database direttamente, parla
|
||||
col backend dati via API e gli manda risultati e immagini.
|
||||
- **L'identità della stazione è data dall'installazione** (configurata sul PC),
|
||||
non più dal container sul server: il vincolo "una stazione = un container"
|
||||
decade.
|
||||
- La **licenza per postazione** ha l'aggancio naturale nell'app di stazione
|
||||
installata.
|
||||
|
||||
**Correzione a quanto detto in call:** i disegni **non stanno nel database** —
|
||||
stanno su filesystem (`uploads/`), serviti dal backend. Con questa architettura
|
||||
restano sul server, in un posto solo: nessuna cartella da sincronizzare fra i PC.
|
||||
|
||||
Conseguenza per lo sviluppo: lo stato della produzione (punto 1) va nel database
|
||||
**per obbligo architetturale**, non per scelta — con 20 app di stazione non esiste
|
||||
altro posto dove possa vivere.
|
||||
|
||||
---
|
||||
|
||||
# Parte 1 — Cosa deve fare il sistema
|
||||
|
||||
## Quadro: cosa già funziona
|
||||
|
||||
Da mettere a verbale, perché in parte era dato per mancante:
|
||||
|
||||
| Funzione | Stato |
|
||||
|---|---|
|
||||
| Stazioni e assegnazione ricette alle stazioni | ✅ presente |
|
||||
| Ruolo **capoturno** con autorizzazione a login | ✅ presente |
|
||||
| Timer d'intervallo misura con **conto alla rovescia a video** e cicalino | ✅ presente |
|
||||
| Logout automatico per inattività, configurabile | ✅ presente |
|
||||
| Tolleranze con soglie di attenzione (UTL/UWL/LWL/LTL) ed esito per quota | ✅ presente |
|
||||
| Versionamento delle ricette con storico delle modifiche | ✅ presente |
|
||||
| Registrazione del **tempo di inserimento** di ogni misura | ✅ presente (28/07) |
|
||||
| Pulsanti *Fermo linea*, *Fine produzione*, *Avvio produzione* | ⚠️ solo interfaccia |
|
||||
|
||||
L'ultima riga è la più importante: i tre pulsanti esistono, chiedono correttamente
|
||||
l'autorizzazione del capoturno, **e poi non fanno nulla**.
|
||||
|
||||
---
|
||||
|
||||
## 1. Memoria della produzione in corso `da fare` · prerequisito
|
||||
|
||||
**Oggi:** lo stato di una produzione esiste solo finché l'operatore resta sulla stessa
|
||||
schermata.
|
||||
|
||||
**Deve:** la produzione diventa un'entità con una vita propria — si apre quando parte,
|
||||
registra ciò che accade (avvio, cicli di misura, fermo linea, ripresa, chiusura) e si
|
||||
chiude quando il capoturno la chiude. Se l'operatore cambia task, esce e rientra, o il
|
||||
tablet si riavvia, la produzione è ancora lì con il suo timer.
|
||||
|
||||
**Perché conta:** senza questo, *fermo linea* e *fine produzione* non possono
|
||||
funzionare, il loop di misura non è realizzabile e non esiste il dato storico su cui
|
||||
poggiano sia la statistica sia l'integrazione col gestionale.
|
||||
|
||||
---
|
||||
|
||||
## 2. Tipo di task esplicito `da fare`
|
||||
|
||||
**Oggi:** il sistema distingue un task di misura da un task documentale **deducendolo**:
|
||||
se ha delle quote è una misura, altrimenti è una nota. Non esiste un tipo dichiarato.
|
||||
|
||||
**Deve:** ogni task nasce con un tipo scelto da chi crea la ricetta — **nota**,
|
||||
**misura**, **disegno** — predisposto per i tipi futuri (confronto profilo/DXF, misura
|
||||
con camera).
|
||||
|
||||
**Perché conta:** è la richiesta n. 1 di Menoncin, ed è ciò a cui si agganciano timer e
|
||||
loop. Con la deduzione attuale un task di misura a cui non sono ancora state inserite le
|
||||
quote viene trattato come una nota: il sistema si comporta in modo diverso a seconda di
|
||||
quanto è completa la ricetta.
|
||||
|
||||
---
|
||||
|
||||
## 3. Loop di misura e ripetizione `parziale`
|
||||
|
||||
**Oggi:** allo scadere del timer suona il cicalino e il ciclo si riazzera — ma solo se
|
||||
l'operatore è rimasto su quella schermata.
|
||||
|
||||
**Deve:**
|
||||
- l'operatore esegue i task in sequenza fino al primo task di misura;
|
||||
- da lì **resta in loop sulla misura** finché la produzione non viene chiusa;
|
||||
- il conto alla rovescia di **quanto manca alla prossima misura** è sempre visibile;
|
||||
- **arrivato a zero, il contatore riparte nell'altro senso**: mostra da quanto tempo
|
||||
si è **oltre** l'intervallo di misura, in evidenza — così il ritardo si vede, non
|
||||
si deduce (richiesta dell'operatore, 28/07);
|
||||
- allo scadere del timer la misura **si ripropone** ovunque si trovi l'operatore;
|
||||
- se la ricetta ha **più task di misura**, il timer riparte alla fine dell'**ultimo**;
|
||||
- dev'essere possibile **girare il pezzo e rimisurare** senza chiudere il ciclo.
|
||||
|
||||
**Perché conta:** è la seconda richiesta di Menoncin e il comportamento descritto al
|
||||
punto 4.10 della specifica del 19/05. La logica c'è già in gran parte: manca che
|
||||
sopravviva al cambio di schermata (punto 1).
|
||||
|
||||
---
|
||||
|
||||
## 4. Limite di tentativi prima del capoturno `da fare`
|
||||
|
||||
**Oggi:** l'operatore può ripetere la misura quante volte vuole.
|
||||
|
||||
**Deve:** dopo un numero di tentativi definito nella ricetta, per proseguire serve
|
||||
l'autorizzazione del capoturno.
|
||||
|
||||
**Perché conta:** è nella specifica ed è la contromisura al caso in cui si ripete finché
|
||||
non "viene bene". Il meccanismo di autorizzazione esiste già: manca il contatore e la
|
||||
soglia.
|
||||
|
||||
---
|
||||
|
||||
## 5. Avanzamento solo se in tolleranza `da verificare`
|
||||
|
||||
**Deve:** si passa alla quota successiva in autonomia **solo se la quota è in
|
||||
tolleranza**; per confermare una quota fuori tolleranza serve il capoturno.
|
||||
|
||||
**Nota:** il gate del capoturno per il fuori tolleranza è implementato. Va verificato
|
||||
sul campo che **blocchi davvero l'avanzamento** e non sia solo una richiesta di conferma.
|
||||
Se blocca, il punto si chiude senza sviluppo.
|
||||
|
||||
---
|
||||
|
||||
## 6. Fermo linea e Fine produzione: dare effetto `parziale`
|
||||
|
||||
**Oggi:** entrambi chiedono l'autorizzazione del capoturno, poi non succede niente.
|
||||
|
||||
**Deve:**
|
||||
- **Fermo linea** — sospende il timer e la produzione; il capoturno può riattivarla;
|
||||
- **Fine produzione** — chiude la produzione, ferma il timer definitivamente e **invia
|
||||
i dati di misura dell'intera produzione** al file di statistica.
|
||||
|
||||
**Perché conta:** il comportamento verso il gestionale è ancora da definire (Parte 3),
|
||||
ma **tutto ciò che sta prima del gestionale si può e si deve fare adesso**: sospendere,
|
||||
riprendere, chiudere, registrare. Consegnare i pulsanti funzionanti senza il gestionale
|
||||
è possibile; il contrario no.
|
||||
|
||||
---
|
||||
|
||||
## 7. Gestione delle stazioni `parziale`
|
||||
|
||||
**Deve:**
|
||||
- la lista stazioni mostra, oltre a codice e postazione, **le ricette collegate**;
|
||||
- esiste un **reset della stazione**, con un pulsante **per riga** nella lista:
|
||||
la stazione torna senza ricette associate e si riassegna (precisazione
|
||||
dell'operatore, 28/07);
|
||||
- la **stazione corrente si può cambiare al volo**, per poter provare più stazioni da un
|
||||
solo computer senza riconfigurare l'installazione.
|
||||
|
||||
**Perché conta:** è la terza richiesta di Menoncin. Il cambio al volo non è un vezzo da
|
||||
sviluppatori: senza, la sessione di collaudo con Menoncin richiede tanti PC quante sono
|
||||
le stazioni da provare.
|
||||
|
||||
---
|
||||
|
||||
## 8. Tracciabilità obbligatoria `parziale`
|
||||
|
||||
**Oggi:** numero di lotto e numero seriale sono facoltativi e si inseriscono nella lista
|
||||
task, cioè dopo aver iniziato.
|
||||
|
||||
**Deve:** l'obbligatorietà di lotto e seriale si **decide alla creazione della
|
||||
ricetta** (obbligatori sì/no); l'operatore li inserisce **alla selezione della
|
||||
ricetta**, e finché mancano il pulsante *Avvia* **non si attiva** (precisazione
|
||||
dell'operatore, 28/07).
|
||||
|
||||
**Perché conta:** una misura senza lotto non è tracciabile a posteriori, e la
|
||||
tracciabilità è metà del valore del sistema in un audit.
|
||||
|
||||
---
|
||||
|
||||
## 9. Blocco dell'inserimento manuale `da fare`
|
||||
|
||||
**Oggi:** il sistema registra **come** è stata inserita una misura (calibro o tastiera),
|
||||
ma accetta sempre entrambi.
|
||||
|
||||
**Deve:** un'impostazione della ricetta consente o vieta l'inserimento manuale, **con
|
||||
divieto come impostazione predefinita**: si misura col calibro.
|
||||
|
||||
**Perché conta:** è il punto sollevato in call — senza questo vincolo un valore in
|
||||
tolleranza si può digitare. Il dato su *come* è stata inserita c'è già: manca la regola
|
||||
che lo impedisce.
|
||||
|
||||
---
|
||||
|
||||
## 10. Interfaccia operatore: sequenza e conferme `da fare`
|
||||
|
||||
Richieste del 19/05, tutte di interfaccia:
|
||||
|
||||
- un pulsante che **avvia i task in sequenza**, senza sceglierli a uno a uno;
|
||||
- la lista completa dei task retrocessa a **secondo livello**, per tornare a vedere i
|
||||
task precedenti;
|
||||
- «inizia misure» rinominato **«visualizza singolo TASK»**;
|
||||
- dentro il task, «Riepilogo» sostituito da **«Completato»** per passare al successivo;
|
||||
- un task lasciato a metà **resta incompiuto** e si vede;
|
||||
- «fine ciclo misura» **cliccabile solo quando tutte le quote hanno un valore**.
|
||||
|
||||
---
|
||||
|
||||
## 11. Formattazione delle descrizioni `da fare`
|
||||
|
||||
**Deve:** le descrizioni dei task accettano andate a capo e grassetto.
|
||||
|
||||
**Perché conta:** chi crea le ricette fa **copia e incolla dal PDF della scheda
|
||||
tecnica**; oggi il testo arriva appiattito e va risistemato a mano ogni volta.
|
||||
|
||||
---
|
||||
|
||||
## 12. Funzionamento senza internet `da fare` · bloccante per l'installazione
|
||||
|
||||
**Oggi:** l'applicazione **non funziona senza collegamento a internet**. Cinque librerie
|
||||
vengono scaricate al volo da servizi esterni ogni volta che si apre una pagina.
|
||||
|
||||
**In una rete di produzione isolata — la norma in fabbrica — il risultato è una pagina
|
||||
bianca.** Non un degrado: l'interfaccia non parte proprio, e senza le altre non si vedono
|
||||
i disegni tecnici né i grafici statistici.
|
||||
|
||||
**Deve:** tutte le librerie sono incluse nell'installazione e l'applicazione funziona a
|
||||
rete staccata.
|
||||
|
||||
**Perché conta ora:** l'installazione a Tràfilo è on-premise e prevista per settembre.
|
||||
È poco lavoro, ma va fatto **prima**, non in fabbrica il giorno dell'installazione.
|
||||
|
||||
**Beneficio collaterale non ovvio:** oggi una delle librerie è agganciata a una versione
|
||||
"qualunque della serie 3" — cioè **l'applicazione cambia da sola** quando gli autori
|
||||
pubblicano un aggiornamento, senza che nessuno l'abbia validata. Per un sistema che
|
||||
produce evidenze per audit ISO 9001 / IATF 16949 questo è di per sé un problema.
|
||||
Includendo le librerie le versioni si congelano: da difetto diventa argomento di vendita.
|
||||
|
||||
---
|
||||
|
||||
## 13. Generazione dei task dalla scheda tecnica con l'AI `fuori offerta`
|
||||
|
||||
**Richiesta:** leggere il PDF della scheda tecnica e **creare un task per blocco**,
|
||||
invece del copia-incolla manuale.
|
||||
|
||||
**Storia:** posta il **19/05/2026** nel documento delle modifiche, rimasta senza
|
||||
risposta; **rilanciata da Tràfilo il 28/07** come elaborazione massiva iniziale delle
|
||||
schede, «senza installare agenti nel sistema».
|
||||
|
||||
**Stato:** non è in nessuna offerta. Prima di quotare servono tre informazioni: quante
|
||||
sono le schede, se il formato è standard, e se l'elaborazione è una-tantum in fase di
|
||||
avviamento o una funzione permanente del prodotto. Sono domande da fare, non da
|
||||
supporre — vedi Parte 3.
|
||||
|
||||
---
|
||||
|
||||
## 14. Stabilità del layout `da fare` · da circoscrivere
|
||||
|
||||
**Oggi:** le dimensioni delle viste **cambiano a seconda del menu**: passando da una
|
||||
schermata all'altra la finestra non mantiene proporzioni stabili.
|
||||
|
||||
**Deve:** il layout resta stabile nel passaggio fra le viste.
|
||||
|
||||
**Nota:** segnalazione dell'operatore del 28/07, non ancora circoscritta sul codice —
|
||||
prima di intervenire va riprodotta e va stilato l'elenco delle viste interessate.
|
||||
|
||||
---
|
||||
|
||||
## 15. Statistica: si registra sempre, si consulta a parte `già fatto` · da confermare sul campo
|
||||
|
||||
**Richiesta (28/07):** a fine misura l'operatore **non va portato nella pagina della
|
||||
statistica**. I dati **entrano comunque in statistica**: cambia solo chi la consulta —
|
||||
serve l'**utente con il ruolo adeguato**, che apre la pagina dedicata.
|
||||
|
||||
**Verificato sul codice:** è già così. Tutte le pagine di statistica richiedono il
|
||||
ruolo **Metrologo** (`role_required("Metrologist")` su ogni route), e a fine ciclo
|
||||
l'operatore viene portato al **riepilogo**, non alla statistica.
|
||||
|
||||
**Resta da fare:** niente sviluppo; il requisito entra come **criterio di collaudo**
|
||||
(l'operatore non deve poter raggiungere la statistica da nessun percorso) e va tenuto
|
||||
fermo quando il punto 10 ridisegna la navigazione a fine task.
|
||||
|
||||
---
|
||||
|
||||
# Parte 2 — Dove si interviene
|
||||
|
||||
Riferimenti al branch `V2.0.0`, commit `2a56632`.
|
||||
|
||||
## Ordine consigliato
|
||||
|
||||
```
|
||||
12 (offline) ──────────────► indipendente, si può fare subito
|
||||
bloccante per l'installazione
|
||||
|
||||
1 (stato produzione) ──┬───► 3 (loop misura)
|
||||
├───► 6 (fermo linea / fine produzione)
|
||||
└───► 4 (limite tentativi)
|
||||
|
||||
2 (tipo task) ─────────────► 3, 10
|
||||
|
||||
7 (stazioni) · 8 (tracciabilità) · 9 (inserimento manuale) · 11 (formattazione)
|
||||
indipendenti fra loro
|
||||
|
||||
5 (avanzamento in tolleranza) ──► prima verificare, forse è già a posto
|
||||
|
||||
14 (layout) ────────────────► prima riprodurre e circoscrivere le viste
|
||||
|
||||
15 (statistica riservata) ──► già a posto: solo criterio di collaudo,
|
||||
da non rompere lavorando sul punto 10
|
||||
```
|
||||
|
||||
La separazione **backend dati sul server / app di stazione in locale** (vedi
|
||||
*Architettura d'installazione*) non è un punto di questa lista: è il contesto in
|
||||
cui i punti 1, 3, 6 e 7 vanno progettati. In pratica: API senza stato, stato solo
|
||||
nel database, niente dipendenze dal container per l'identità della stazione.
|
||||
|
||||
## 1 · Stato della produzione lato server
|
||||
|
||||
**Problema tecnico:** `task_execute.html` tiene in variabili Alpine
|
||||
(`timerActive`, `timerRemaining`, `_timerInterval`, `cycleCount`, `cycleConfirmed`,
|
||||
`productionStarted`, `measurements`) uno stato che deve sopravvivere alla pagina.
|
||||
`goToNextTask()` fa `window.location.href` → il componente viene distrutto.
|
||||
Lato server la sessione conserva soltanto `lot_number` e `serial_number`.
|
||||
|
||||
**Intervento:**
|
||||
- nuove tabelle `production_runs` e `production_events` (avvio, ciclo completato, fermo
|
||||
linea, ripresa, chiusura), con `station_id`, `recipe_version_id`, `operator_id`,
|
||||
`supervisor_id` dove serve, timestamp;
|
||||
- endpoint REST per aprire, interrogare e aggiornare la produzione corrente della
|
||||
stazione;
|
||||
- il frontend legge lo stato all'apertura di ogni pagina invece di tenerlo in memoria;
|
||||
il conto alla rovescia si **ricalcola dall'orario di scadenza** salvato lato server,
|
||||
non da un contatore locale;
|
||||
- gli endpoint vanno progettati **senza stato in memoria di processo**: con l'app di
|
||||
stazione installata su ogni PC (vedi *Architettura d'installazione*) il database è
|
||||
l'unico posto condiviso.
|
||||
|
||||
**File:** `src/backend/models/orm/` (nuovo modulo), `src/backend/migrations/versions/`
|
||||
(migrazione 005), `src/backend/api/routers/`, `src/frontend/flask_app/blueprints/measure.py`,
|
||||
`src/frontend/flask_app/templates/measure/task_execute.html`.
|
||||
|
||||
**Fatto quando:** avviata una produzione, si naviga fra i task, si esce e si rientra, e
|
||||
il timer prosegue coerente; il riavvio del browser non azzera nulla.
|
||||
|
||||
---
|
||||
|
||||
## 2 · Tipo di task
|
||||
|
||||
**Problema tecnico:** `RecipeTask` non ha campo tipo; il frontend decide con
|
||||
`subtasks.length > 0` (`task_execute.html`, `task_list.html`).
|
||||
|
||||
**Intervento:** campo `type` su `recipe_tasks` con valori `note | measure | drawing`
|
||||
(predisposto per `xf_compare`, `camera_measure`), migrazione con valorizzazione dei dati
|
||||
esistenti secondo la regola attuale, selezione del tipo nell'editor ricetta,
|
||||
sostituzione dei controlli su `subtasks.length` con il tipo.
|
||||
|
||||
**File:** `src/backend/models/orm/task.py`, nuova migrazione,
|
||||
`src/backend/models/api/`, `src/frontend/flask_app/templates/maker/task_editor.html`,
|
||||
`templates/measure/task_execute.html`, `templates/measure/task_list.html`.
|
||||
|
||||
**Fatto quando:** una ricetta con un task di misura ancora privo di quote si comporta da
|
||||
task di misura.
|
||||
|
||||
---
|
||||
|
||||
## 3 · Loop di misura
|
||||
|
||||
**Base già presente:** `confirmCycle()`, `startMeasurementTimer()`, `onTimerExpired()`,
|
||||
`timerDisplay`, `playBuzzer()` in `task_execute.html` (righe ~955-1050). La logica è
|
||||
corretta; il problema è la persistenza (punto 1) e il fatto che allo scadere non si può
|
||||
riportare l'operatore sul task giusto.
|
||||
|
||||
**Intervento:** spostare la scadenza sul server; alla scadenza, **redirezione al task di
|
||||
misura** della produzione corrente; con più task di misura far ripartire il timer al
|
||||
completamento dell'**ultimo**; aggiungere «rimisura» che riapre il ciclo senza chiuderlo.
|
||||
|
||||
**Timer bidirezionale:** oggi il contatore **solo decrementa e si ferma a zero**
|
||||
(`timerRemaining--`, poi `onTimerExpired()`); `timerDisplay` formatta minuti:secondi
|
||||
dal residuo. Va esteso: sotto zero il valore continua **in negativo** e la
|
||||
visualizzazione passa a "oltre da m:s", con stile in evidenza. Calcolando dal
|
||||
timestamp di scadenza lato server (come sopra), il ritardo è coerente su qualunque
|
||||
schermata e sopravvive al ricaricamento.
|
||||
|
||||
**Fatto quando:** con una ricetta a due task di misura e task documentali in coda, allo
|
||||
scadere del timer l'operatore viene riportato alla misura da qualunque schermata.
|
||||
|
||||
---
|
||||
|
||||
## 4 · Limite di tentativi
|
||||
|
||||
**Problema tecnico:** nessun `max_retries` nel codice.
|
||||
|
||||
**Intervento:** campo sulla ricetta (accanto a `measurement_interval_minutes`, che segue
|
||||
lo stesso schema), contatore per quota nella produzione corrente, superata la soglia
|
||||
riuso del modale capoturno esistente con motivo `max_retries`.
|
||||
|
||||
**File:** `src/backend/models/orm/recipe.py`, migrazione,
|
||||
`templates/maker/recipe_editor.html` (accanto al timer), `task_execute.html`.
|
||||
|
||||
---
|
||||
|
||||
## 5 · Avanzamento in tolleranza — prima verificare
|
||||
|
||||
Il modale capoturno gestisce già `out_of_tolerance` (`task_execute.html`,
|
||||
`openSupervisorModal`, `validateSupervisor`, endpoint
|
||||
`measure.validate_supervisor`). **Prima di sviluppare, provare**: se il rifiuto blocca
|
||||
l'avanzamento, il punto è chiuso. Se è solo una conferma, va reso vincolante.
|
||||
|
||||
---
|
||||
|
||||
## 6 · Fermo linea e Fine produzione
|
||||
|
||||
**Problema tecnico:** in `task_execute.html` i due pulsanti aprono il modale e poi
|
||||
ricadono su un commento: `fermo_linea and fine_produzione are handled by GAIA
|
||||
integration (future)`. `startProduction()` è un `TODO` con la chiamata commentata.
|
||||
|
||||
**Intervento (senza gestionale):** scrivere gli eventi su `production_events`,
|
||||
sospendere e riprendere il timer, chiudere la produzione, ed **emettere il file di
|
||||
statistica** con tutte le misure della produzione. L'invio al gestionale resta un
|
||||
adattatore separato da riempire quando il protocollo sarà definito (Parte 3): va
|
||||
previsto il punto d'innesto, non l'implementazione.
|
||||
|
||||
**Fatto quando:** *fermo linea* congela il timer e solo il capoturno lo riattiva; *fine
|
||||
produzione* chiude e produce il file.
|
||||
|
||||
---
|
||||
|
||||
## 7 · Stazioni
|
||||
|
||||
**Base presente:** `Station` e `StationRecipeAssignment` (`models/orm/station.py`,
|
||||
migrazione 002).
|
||||
|
||||
**Intervento:** ricette collegate nella lista stazioni; pulsante di reset **per riga**
|
||||
che rimuove le assegnazioni; selezione della stazione corrente da parametro URL con
|
||||
ricaduta sulla variabile d'ambiente, per il collaudo da una sola macchina.
|
||||
|
||||
**Nota (28/07, chiude il dubbio che era in D-3):** in produzione l'identità della
|
||||
stazione è data dall'**installazione locale** dell'app di stazione, non dal container.
|
||||
Il cambio al volo via URL resta come strumento di **collaudo**.
|
||||
|
||||
---
|
||||
|
||||
## 8 · Tracciabilità
|
||||
|
||||
**Base presente:** `lot_number` e `serial_number` su `Measurement`, salvataggio in
|
||||
sessione (`measure.save_traceability`), inserimento in `task_list.html`.
|
||||
|
||||
**Intervento:** flag sulla ricetta (`richiede lotto`, `richiede seriale`); spostare
|
||||
l'inserimento sull'avvio produzione; *Avvia* disabilitato finché mancano.
|
||||
|
||||
---
|
||||
|
||||
## 9 · Inserimento manuale
|
||||
|
||||
**Base presente:** `Measurement.input_method` (`manual | usb_caliper`), valorizzato dal
|
||||
frontend.
|
||||
|
||||
**Intervento:** flag sulla ricetta `consente inserimento manuale`, **predefinito falso**;
|
||||
validazione **lato server** — un flag solo nel frontend non protegge da nulla; tastierino
|
||||
nascosto quando vietato.
|
||||
|
||||
---
|
||||
|
||||
## 10 · Interfaccia operatore
|
||||
|
||||
Interventi su `templates/measure/task_list.html` e `task_execute.html`: pulsante di
|
||||
avvio in sequenza, retrocessione della lista a secondo livello, rinomina dei due
|
||||
pulsanti, stato «incompiuto» sui task abbandonati, «fine ciclo misura» abilitato solo a
|
||||
quote complete.
|
||||
|
||||
---
|
||||
|
||||
## 11 · Formattazione descrizioni
|
||||
|
||||
`RecipeTask.description` è già `Text`. Serve un editor minimale (grassetto e a capo) e
|
||||
la resa corrispondente in esecuzione, con **sanificazione dell'HTML** in ingresso.
|
||||
|
||||
**File:** `templates/maker/task_editor.html`, `templates/measure/task_execute.html`.
|
||||
|
||||
---
|
||||
|
||||
## 12 · Funzionamento senza internet
|
||||
|
||||
**Verificato sul codice.** Cinque librerie esterne in sei template:
|
||||
|
||||
| Libreria | Dove | Senza rete si perde |
|
||||
|---|---|---|
|
||||
| Alpine.js `3.x.x` | `base.html` | **tutta l'interfaccia** |
|
||||
| Plotly `2.32.0` | `statistics/dashboard.html` | carte di controllo e istogrammi |
|
||||
| PDF.js `3.11.174` | `task_execute`, `recipe_preview`, `task_drawing`, `task_editor` | visualizzazione dei disegni |
|
||||
| Fabric.js `5.3.1` | `maker/task_drawing.html` | editor delle annotazioni |
|
||||
| Google Fonts | `base.html` | estetica e attese al caricamento |
|
||||
|
||||
**Intervento:** scaricare le librerie in `src/frontend/flask_app/static/vendor/` (la
|
||||
cartella **esiste già ed è vuota**), ripuntare i tag, chiudere la policy di sicurezza in
|
||||
`security_headers.py` da elenco-di-CDN a solo-origine-locale.
|
||||
|
||||
⚠️ **Trappola da non mancare:** PDF.js ha una **seconda** referenza al CDN,
|
||||
`pdfjsLib.GlobalWorkerOptions.workerSrc`, presente in **quattro file**. Ripuntando solo
|
||||
lo script principale la libreria si carica in locale **e il worker continua a cercare
|
||||
internet**: sembra funzionare finché non si apre un disegno.
|
||||
|
||||
**Fatto quando:** con la rete staccata si percorre login → scelta ricetta → esecuzione
|
||||
task → annotazione disegno → statistiche → report, senza errori in console.
|
||||
|
||||
---
|
||||
|
||||
# Parte 3 — Decisioni che non dipendono da noi
|
||||
|
||||
Da chiudere **prima** che i punti collegati entrino in sviluppo. Vanno girate a Tràfilo
|
||||
tramite Menoncin.
|
||||
|
||||
| # | Domanda | Blocca | Chi risponde |
|
||||
|---|---|---|---|
|
||||
| **D-1** | **Protocollo del gestionale GAIA**: come si scambiano i dati — servizi web, database condiviso, file? | avvio produzione, fermo linea, fine produzione verso il gestionale; lettura dei codici articolo per stazione | IT Tràfilo + fornitore GAIA |
|
||||
| **D-2** | **Rete e credenziali** per raggiungere GAIA dal server dove sarà installato | come sopra | IT Tràfilo |
|
||||
| ~~**D-3**~~ | ~~Una applicazione per stazione o una sola per tutte?~~ **Decisa il 28/07** lato Tielogic: backend dati sul server, app di stazione installata su ogni PC — vedi *Architettura d'installazione*. Resta la validazione con l'IT di Tràfilo (macchina e rete) | — | chiusa (noi); validazione in D-4 |
|
||||
| **D-4** | **Server**: quale macchina, quanto spazio disco. Sul server stanno database **e file dei disegni** (`uploads/`); l'elaborazione visione **non** è sul server (sta sull'app di stazione), quindi pesa lo spazio disco, non la potenza di calcolo | installazione | IT Tràfilo |
|
||||
| **D-5** | **Cicalino**: basta il suono del browser o serve una segnalazione luminosa? La specifica chiede luce **e** suono accesi per tutta la misura, visibili da lontano. Con l'architettura del 28/07 l'app di stazione **può pilotare una colonnina**: la domanda diventa *quale hardware* | punto 3 | Tràfilo |
|
||||
| **D-6** | **Autorizzazione capoturno**: username e password come oggi, o PIN rapido / badge? Venti volte al giorno la password è un attrito | punti 4, 5, 6 | Tràfilo |
|
||||
| **D-7** | **Numero di tentativi** consentiti prima del capoturno: quanti, e uguali per tutte le ricette? | punto 4 | Tràfilo |
|
||||
| **D-8** | **Schede tecniche**: quante sono, il formato è standard, e serve una conversione una-tantum o una funzione permanente? | punto 13 e la sua quotazione | Tràfilo |
|
||||
| **D-9** | Modificare i parametri di una ricetta (timer, tentativi) **crea una nuova versione** o no? Sono parametri di esercizio, non di prodotto | punti 4, 3 | noi, con conferma cliente |
|
||||
|
||||
**Nota su D-1 e D-2:** finché non hanno risposta, del gestionale si può solo predisporre
|
||||
il punto d'innesto. Tutto il resto del punto 6 — sospendere, riprendere, chiudere,
|
||||
registrare, produrre il file — **si fa comunque e va fatto adesso**.
|
||||
|
||||
---
|
||||
|
||||
## Appendice — Come è stato verificato
|
||||
|
||||
Ogni «già fatto» e ogni «da fare» viene dalla lettura del codice al commit `2a56632`,
|
||||
non dai documenti. In particolare:
|
||||
|
||||
- il **tipo di task** è dedotto da `subtasks.length > 0` in `task_execute.html:117` e
|
||||
`task_list.html:169`;
|
||||
- il **ruolo capoturno** esiste come `Supervisor`
|
||||
(`api/middleware/api_key.py:71`, `blueprints/measure.py:350`);
|
||||
- **fermo linea / fine produzione / avvio produzione** hanno interfaccia e gate ma
|
||||
nessun effetto (`task_execute.html:1017-1021`, `1118`);
|
||||
- il **timer** è un `setInterval` locale alla pagina (`task_execute.html:968-995`) e la
|
||||
navigazione fra task è un ricaricamento (`task_execute.html:1054`);
|
||||
- **`max_retries`** e **`production_events`** non compaiono in nessun file;
|
||||
- le **cinque librerie da CDN** sono ai riferimenti citati al punto 12, e
|
||||
`static/vendor/` contiene solo `.gitkeep`;
|
||||
- i **disegni stanno su filesystem**, non nel database: cartella `uploads/` servita
|
||||
dal backend (`api/routers/files.py:201`, `FileResponse`) — verifica del 28/07 sera,
|
||||
corregge quanto detto in call;
|
||||
- la **statistica è già riservata**: ogni route di `blueprints/statistics.py` porta
|
||||
`@role_required("Metrologist")`; a fine ciclo il frontend va al riepilogo
|
||||
(`task_execute.html:1126`), non alla statistica;
|
||||
- il **timer si ferma a zero**: decremento in `task_execute.html:974-975` con uscita
|
||||
su `onTimerExpired()` — il conteggio del ritardo (punto 3) oggi non esiste;
|
||||
- lo stack attuale è a 4 container (`docker-compose.yml`): MySQL 8, backend FastAPI,
|
||||
frontend Flask, nginx — base della sezione *Architettura d'installazione*.
|
||||
|
||||
Il punto 14 (layout) è l'unico **non verificato sul codice**: è una segnalazione
|
||||
dell'operatore del 28/07, da riprodurre.
|
||||
@@ -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,27 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
||||
<!-- Stylized Caliper Icon for Favicon -->
|
||||
|
||||
<!-- Caliper outer frame -->
|
||||
<rect x="2" y="3" width="5" height="26" rx="1.5" fill="#2563EB"/>
|
||||
<rect x="2" y="3" width="19" height="5" rx="1.5" fill="#2563EB"/>
|
||||
<rect x="2" y="24" width="19" height="5" rx="1.5" fill="#2563EB"/>
|
||||
|
||||
<!-- Sliding jaw -->
|
||||
<rect x="12" y="8" width="4.5" height="6.5" rx="1" fill="#1E40AF"/>
|
||||
<rect x="12" y="17.5" width="4.5" height="6.5" rx="1" fill="#1E40AF"/>
|
||||
|
||||
<!-- Depth rod -->
|
||||
<rect x="4" y="14" width="12" height="3.5" rx="1" fill="#3B82F6" opacity="0.55"/>
|
||||
|
||||
<!-- Scale markings -->
|
||||
<rect x="8" y="5" width="1" height="2.5" rx="0.5" fill="#FFFFFF" opacity="0.65"/>
|
||||
<rect x="11" y="5" width="1" height="2.5" rx="0.5" fill="#FFFFFF" opacity="0.65"/>
|
||||
<rect x="14" y="5" width="1" height="2.5" rx="0.5" fill="#FFFFFF" opacity="0.65"/>
|
||||
<rect x="17" y="5" width="1" height="2.5" rx="0.5" fill="#FFFFFF" opacity="0.65"/>
|
||||
|
||||
<!-- Flow arrow -->
|
||||
<path d="M22 16 C25 16, 26.5 11, 29 11"
|
||||
stroke="#3B82F6" stroke-width="2.2" stroke-linecap="round" fill="none"/>
|
||||
<path d="M27 8.5 L29.5 11 L27 13.5"
|
||||
stroke="#3B82F6" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB |
@@ -1,41 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 220 44" fill="none">
|
||||
<!-- Stylized Caliper Icon -->
|
||||
<g transform="translate(2, 2)">
|
||||
<!-- Caliper outer frame -->
|
||||
<rect x="0" y="4" width="6" height="32" rx="1.5" fill="#2563EB"/>
|
||||
<rect x="0" y="4" width="24" height="6" rx="1.5" fill="#2563EB"/>
|
||||
<rect x="0" y="30" width="24" height="6" rx="1.5" fill="#2563EB"/>
|
||||
|
||||
<!-- Caliper sliding jaw -->
|
||||
<rect x="14" y="10" width="5" height="8" rx="1" fill="#1E40AF"/>
|
||||
<rect x="14" y="22" width="5" height="8" rx="1" fill="#1E40AF"/>
|
||||
|
||||
<!-- Caliper depth rod -->
|
||||
<rect x="2" y="18" width="16" height="4" rx="1" fill="#3B82F6" opacity="0.6"/>
|
||||
|
||||
<!-- Scale markings -->
|
||||
<rect x="7" y="7" width="1" height="3" rx="0.5" fill="#FFFFFF" opacity="0.7"/>
|
||||
<rect x="10" y="7" width="1" height="3" rx="0.5" fill="#FFFFFF" opacity="0.7"/>
|
||||
<rect x="13" y="7" width="1" height="3" rx="0.5" fill="#FFFFFF" opacity="0.7"/>
|
||||
<rect x="16" y="7" width="1" height="3" rx="0.5" fill="#FFFFFF" opacity="0.7"/>
|
||||
<rect x="19" y="7" width="1" height="3" rx="0.5" fill="#FFFFFF" opacity="0.7"/>
|
||||
|
||||
<!-- Flow arrow (smooth curve) -->
|
||||
<path d="M26 20 C30 20, 32 14, 36 14 C40 14, 40 20, 36 20"
|
||||
stroke="#2563EB" stroke-width="2.5" stroke-linecap="round" fill="none"/>
|
||||
<path d="M34 17 L37 20 L34 23" stroke="#2563EB" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||
</g>
|
||||
|
||||
<!-- Text: TieMeasureFlow -->
|
||||
<g transform="translate(48, 0)">
|
||||
<!-- "Tie" in bold primary -->
|
||||
<text x="0" y="28" font-family="Inter, system-ui, sans-serif" font-size="20" font-weight="700" fill="#2563EB"
|
||||
letter-spacing="-0.5">Tie</text>
|
||||
<!-- "Measure" in medium dark -->
|
||||
<text x="32" y="28" font-family="Inter, system-ui, sans-serif" font-size="20" font-weight="500" fill="#1E40AF"
|
||||
letter-spacing="-0.5">Measure</text>
|
||||
<!-- "Flow" in bold primary -->
|
||||
<text x="120" y="28" font-family="Inter, system-ui, sans-serif" font-size="20" font-weight="700" fill="#2563EB"
|
||||
letter-spacing="-0.5">Flow</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.1 KiB |
@@ -1,95 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Login — TieMeasureFlow{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100 dark:from-slate-900 dark:to-slate-800 px-4 sm:px-6 lg:px-8">
|
||||
<div class="max-w-md w-full space-y-8">
|
||||
<!-- Card -->
|
||||
<div class="bg-white dark:bg-slate-800 shadow-lg rounded-xl p-8">
|
||||
<!-- Logo -->
|
||||
<div class="text-center mb-8">
|
||||
<img src="{{ url_for('static', filename='img/tmflow-logo.svg') }}"
|
||||
alt="TieMeasureFlow Logo"
|
||||
class="mx-auto h-16 w-auto mb-4"
|
||||
onerror="this.style.display='none'">
|
||||
<h2 class="text-3xl font-bold text-slate-900 dark:text-white mb-2">
|
||||
TieMeasureFlow
|
||||
</h2>
|
||||
<p class="text-sm text-slate-600 dark:text-slate-400">
|
||||
{{ _('Accedi al sistema') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Login Form -->
|
||||
<form method="POST" action="{{ url_for('auth.login') }}" class="space-y-6">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<!-- Username -->
|
||||
<div>
|
||||
<label for="username" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||
{{ _('Username') }}
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<svg class="h-5 w-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input type="text"
|
||||
id="username"
|
||||
name="username"
|
||||
required
|
||||
autofocus
|
||||
placeholder="{{ _('Username') }}"
|
||||
class="block w-full pl-10 pr-3 py-2.5 border border-slate-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:focus:ring-primary-400 dark:focus:border-primary-400 bg-white dark:bg-slate-700 text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-slate-500 transition-colors">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Password -->
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||
{{ _('Password') }}
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<svg class="h-5 w-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
required
|
||||
placeholder="{{ _('Password') }}"
|
||||
class="block w-full pl-10 pr-3 py-2.5 border border-slate-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:focus:ring-primary-400 dark:focus:border-primary-400 bg-white dark:bg-slate-700 text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-slate-500 transition-colors">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<div>
|
||||
<button type="submit"
|
||||
class="w-full flex justify-center py-3 px-4 border border-transparent rounded-lg shadow-sm text-sm font-semibold text-white bg-primary-600 hover:bg-primary-700 dark:bg-primary-500 dark:hover:bg-primary-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 transition-all duration-200">
|
||||
<svg class="h-5 w-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
{{ _('Accedi') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Help Text -->
|
||||
<div class="mt-6 text-center">
|
||||
<p class="text-xs text-slate-500 dark:text-slate-400">
|
||||
{{ _('Hai dimenticato la password?') }}
|
||||
<br>
|
||||
{{ _('Contatta l\'amministratore') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="text-center text-xs text-slate-500 dark:text-slate-400">
|
||||
<p>TieMeasureFlow © 2025 - {{ _('Sistema di misurazione industriale') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,307 +0,0 @@
|
||||
<!-- TieMeasureFlow Navbar -->
|
||||
<nav class="sticky top-0 z-40 bg-[var(--bg-card)] border-b border-[var(--border-color)] shadow-sm transition-colors duration-300"
|
||||
x-data="{ mobileOpen: false, userDropdown: false }">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex items-center justify-between h-14">
|
||||
|
||||
<!-- Left: Logo -->
|
||||
<div class="flex items-center gap-3">
|
||||
<a href="{{ url_for('index') }}" class="flex items-center gap-2.5 group">
|
||||
{% if company_logo %}
|
||||
<img src="{{ url_for('static', filename='img/' ~ company_logo) }}" alt="Logo" class="h-8 w-auto"
|
||||
onerror="this.src='{{ url_for('static', filename='img/tmflow-logo.svg') }}'">
|
||||
{% else %}
|
||||
<img src="{{ url_for('static', filename='img/tmflow-logo.svg') }}"
|
||||
alt="TieMeasureFlow"
|
||||
class="h-8 w-auto">
|
||||
{% endif %}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Center: Navigation Links (Desktop) -->
|
||||
{% if current_user %}
|
||||
<div class="hidden md:flex items-center gap-1">
|
||||
|
||||
{# MeasurementTec: Misure #}
|
||||
{% if current_user.get('roles') and 'MeasurementTec' in current_user.roles %}
|
||||
<a href="{{ url_for('measure.select_recipe') }}"
|
||||
class="nav-link group flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium
|
||||
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
|
||||
transition-colors duration-200
|
||||
{% if request.endpoint and request.endpoint.startswith('measure.') %}
|
||||
text-primary bg-primary-50 dark:bg-primary-900/20
|
||||
{% endif %}">
|
||||
<!-- Clipboard Icon -->
|
||||
<svg class="w-4.5 h-4.5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"/>
|
||||
</svg>
|
||||
<span>{{ _('Misure') }}</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{# Maker: Ricette #}
|
||||
{% if current_user.get('roles') and 'Maker' in current_user.roles %}
|
||||
<a href="{{ url_for('maker.recipe_list') }}"
|
||||
class="nav-link group flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium
|
||||
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
|
||||
transition-colors duration-200
|
||||
{% if request.endpoint and request.endpoint.startswith('maker.') %}
|
||||
text-primary bg-primary-50 dark:bg-primary-900/20
|
||||
{% endif %}">
|
||||
<!-- Edit/Pencil Icon -->
|
||||
<svg class="w-4.5 h-4.5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
|
||||
</svg>
|
||||
<span>{{ _('Ricette') }}</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{# Metrologist: Statistiche #}
|
||||
{% if current_user.get('roles') and 'Metrologist' in current_user.roles %}
|
||||
<a href="{{ url_for('statistics.dashboard') }}"
|
||||
class="nav-link group flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium
|
||||
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
|
||||
transition-colors duration-200
|
||||
{% if request.endpoint and request.endpoint.startswith('statistics.') %}
|
||||
text-primary bg-primary-50 dark:bg-primary-900/20
|
||||
{% endif %}">
|
||||
<!-- Chart Icon -->
|
||||
<svg class="w-4.5 h-4.5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/>
|
||||
</svg>
|
||||
<span>{{ _('Statistiche') }}</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{# Admin: Utenti #}
|
||||
{% if current_user.get('is_admin') %}
|
||||
<a href="{{ url_for('admin.user_list') }}"
|
||||
class="nav-link group flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium
|
||||
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
|
||||
transition-colors duration-200
|
||||
{% if request.endpoint == 'admin.user_list' %}
|
||||
text-primary bg-primary-50 dark:bg-primary-900/20
|
||||
{% endif %}">
|
||||
<!-- Users Icon -->
|
||||
<svg class="w-4.5 h-4.5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"/>
|
||||
</svg>
|
||||
<span>{{ _('Utenti') }}</span>
|
||||
</a>
|
||||
|
||||
<a href="{{ url_for('admin.station_list') }}"
|
||||
class="nav-link group flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium
|
||||
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
|
||||
transition-colors duration-200
|
||||
{% if request.endpoint == 'admin.station_list' %}
|
||||
text-primary bg-primary-50 dark:bg-primary-900/20
|
||||
{% endif %}">
|
||||
<!-- Station / Workstation Icon -->
|
||||
<svg class="w-4.5 h-4.5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25m18 0A2.25 2.25 0 0018.75 3H5.25A2.25 2.25 0 003 5.25m18 0V12a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 12V5.25"/>
|
||||
</svg>
|
||||
<span>{{ _('Stazioni') }}</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Right: Controls -->
|
||||
<div class="flex items-center gap-2">
|
||||
|
||||
<!-- Language Toggle -->
|
||||
<div class="flex items-center border border-[var(--border-color)] rounded-lg overflow-hidden">
|
||||
<a href="{{ url_for('set_language', lang='it') }}"
|
||||
class="px-2 py-1.5 text-xs font-semibold transition-colors duration-200
|
||||
{% if current_language == 'it' %}
|
||||
bg-primary text-white
|
||||
{% else %}
|
||||
text-[var(--text-secondary)] hover:bg-[var(--bg-secondary)]
|
||||
{% endif %}">
|
||||
IT
|
||||
</a>
|
||||
<a href="{{ url_for('set_language', lang='en') }}"
|
||||
class="px-2 py-1.5 text-xs font-semibold transition-colors duration-200
|
||||
{% if current_language == 'en' %}
|
||||
bg-primary text-white
|
||||
{% else %}
|
||||
text-[var(--text-secondary)] hover:bg-[var(--bg-secondary)]
|
||||
{% endif %}">
|
||||
EN
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Theme Toggle -->
|
||||
<button @click="$store.theme.toggle()"
|
||||
class="p-2 rounded-lg text-[var(--text-secondary)] hover:text-primary
|
||||
hover:bg-[var(--bg-secondary)] transition-colors duration-200"
|
||||
:title="$store.theme.dark ? 'Tema chiaro' : 'Tema scuro'">
|
||||
<!-- Sun (shown in dark mode) -->
|
||||
<svg x-show="$store.theme.dark" x-cloak class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"/>
|
||||
</svg>
|
||||
<!-- Moon (shown in light mode) -->
|
||||
<svg x-show="!$store.theme.dark" class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- User Menu (when logged in) -->
|
||||
{% if current_user %}
|
||||
<div class="relative" x-data="{ open: false }" @click.outside="open = false">
|
||||
<button @click="open = !open"
|
||||
class="flex items-center gap-2 pl-2 pr-3 py-1.5 rounded-lg
|
||||
hover:bg-[var(--bg-secondary)] transition-colors duration-200">
|
||||
<!-- Avatar -->
|
||||
<div class="w-7 h-7 rounded-full bg-primary/10 border border-primary/20
|
||||
flex items-center justify-center text-primary font-semibold text-xs">
|
||||
{{ current_user.get('display_name', current_user.get('username', '?'))[0]|upper }}
|
||||
</div>
|
||||
<span class="hidden sm:block text-sm font-medium text-[var(--text-primary)] max-w-[120px] truncate">
|
||||
{{ current_user.get('display_name', current_user.get('username', '')) }}
|
||||
</span>
|
||||
<svg class="w-3.5 h-3.5 text-[var(--text-secondary)] transition-transform duration-200"
|
||||
:class="{ 'rotate-180': open }"
|
||||
fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- User Dropdown -->
|
||||
<div x-show="open"
|
||||
x-transition:enter="transition ease-out duration-150"
|
||||
x-transition:enter-start="opacity-0 scale-95 -translate-y-1"
|
||||
x-transition:enter-end="opacity-100 scale-100 translate-y-0"
|
||||
x-transition:leave="transition ease-in duration-100"
|
||||
x-transition:leave-start="opacity-100 scale-100"
|
||||
x-transition:leave-end="opacity-0 scale-95"
|
||||
class="absolute right-0 mt-1 w-52 rounded-lg bg-[var(--bg-card)] border border-[var(--border-color)]
|
||||
shadow-lg py-1 z-50">
|
||||
<!-- User Info Header -->
|
||||
<div class="px-4 py-2.5 border-b border-[var(--border-color)]">
|
||||
<p class="text-sm font-semibold text-[var(--text-primary)] truncate">
|
||||
{{ current_user.get('display_name', current_user.get('username', '')) }}
|
||||
</p>
|
||||
<p class="text-xs text-[var(--text-secondary)] mt-0.5">
|
||||
{{ current_user.get('roles', [])|join(', ') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<a href="{{ url_for('auth.profile') }}"
|
||||
class="flex items-center gap-2.5 px-4 py-2.5 text-sm text-[var(--text-secondary)]
|
||||
hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20 transition-colors">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
|
||||
</svg>
|
||||
{{ _('Profilo') }}
|
||||
</a>
|
||||
|
||||
<div class="border-t border-[var(--border-color)] my-1"></div>
|
||||
|
||||
<a href="{{ url_for('auth.logout') }}"
|
||||
class="flex items-center gap-2.5 px-4 py-2.5 text-sm text-measure-fail
|
||||
hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/>
|
||||
</svg>
|
||||
{{ _('Logout') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Mobile Hamburger (when logged in) -->
|
||||
{% if current_user %}
|
||||
<button @click="mobileOpen = !mobileOpen"
|
||||
class="md:hidden p-2 rounded-lg text-[var(--text-secondary)]
|
||||
hover:bg-[var(--bg-secondary)] transition-colors duration-200">
|
||||
<svg x-show="!mobileOpen" class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16"/>
|
||||
</svg>
|
||||
<svg x-show="mobileOpen" x-cloak class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Menu -->
|
||||
{% if current_user %}
|
||||
<div x-show="mobileOpen"
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0 -translate-y-2"
|
||||
x-transition:enter-end="opacity-100 translate-y-0"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100 translate-y-0"
|
||||
x-transition:leave-end="opacity-0 -translate-y-2"
|
||||
x-cloak
|
||||
class="md:hidden border-t border-[var(--border-color)] bg-[var(--bg-card)]">
|
||||
<div class="px-4 py-3 space-y-1">
|
||||
|
||||
{% if current_user.get('roles') and 'MeasurementTec' in current_user.roles %}
|
||||
<a href="{{ url_for('measure.select_recipe') }}"
|
||||
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium
|
||||
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
|
||||
transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"/>
|
||||
</svg>
|
||||
{{ _('Misure') }}
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% if current_user.get('roles') and 'Maker' in current_user.roles %}
|
||||
<a href="{{ url_for('maker.recipe_list') }}"
|
||||
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium
|
||||
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
|
||||
transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
|
||||
</svg>
|
||||
{{ _('Ricette') }}
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% if current_user.get('roles') and 'Metrologist' in current_user.roles %}
|
||||
<a href="{{ url_for('statistics.dashboard') }}"
|
||||
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium
|
||||
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
|
||||
transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/>
|
||||
</svg>
|
||||
{{ _('Statistiche') }}
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{# Admin: Utenti + Stazioni #}
|
||||
{% if current_user.get('is_admin') %}
|
||||
<a href="{{ url_for('admin.user_list') }}"
|
||||
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium
|
||||
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
|
||||
transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"/>
|
||||
</svg>
|
||||
{{ _('Utenti') }}
|
||||
</a>
|
||||
|
||||
<a href="{{ url_for('admin.station_list') }}"
|
||||
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium
|
||||
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
|
||||
transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25m18 0A2.25 2.25 0 0018.75 3H5.25A2.25 2.25 0 003 5.25m18 0V12a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 12V5.25"/>
|
||||
</svg>
|
||||
{{ _('Stazioni') }}
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</nav>
|
||||
@@ -1,708 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ task.title or 'Task' }} — {{ _('Misure') }} — TieMeasureFlow{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<style>
|
||||
/* Annotation viewer container */
|
||||
.annotation-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--bg-secondary);
|
||||
overflow: hidden;
|
||||
}
|
||||
.annotation-container canvas {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Tolerance bar gradient background */
|
||||
.tolerance-bar-bg {
|
||||
background: linear-gradient(to right,
|
||||
rgba(220, 38, 38, 0.15) 0%,
|
||||
rgba(217, 119, 6, 0.15) 15%,
|
||||
rgba(5, 150, 105, 0.15) 30%,
|
||||
rgba(5, 150, 105, 0.15) 70%,
|
||||
rgba(217, 119, 6, 0.15) 85%,
|
||||
rgba(220, 38, 38, 0.15) 100%
|
||||
);
|
||||
}
|
||||
|
||||
/* Pulse animation for active marker */
|
||||
@keyframes markerPulse {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(37, 99, 235, 0.4); }
|
||||
50% { box-shadow: 0 0 0 6px rgba(37, 99, 235, 0); }
|
||||
}
|
||||
.marker-active-pulse {
|
||||
animation: markerPulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Left sidebar scrollbar */
|
||||
.sidebar-markers::-webkit-scrollbar { width: 3px; }
|
||||
.sidebar-markers::-webkit-scrollbar-thumb { background: var(--border-color); border-radius: 3px; }
|
||||
|
||||
/* Right panel scrollbar */
|
||||
.right-panel::-webkit-scrollbar { width: 4px; }
|
||||
.right-panel::-webkit-scrollbar-thumb { background: var(--border-color); border-radius: 4px; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<script>
|
||||
window.__taskData = {{ task|tojson }};
|
||||
window.__taskSubtasks = {{ task.subtasks|tojson if task.subtasks else '[]' }};
|
||||
window.__taskAnnotations = {{ task.annotations_json|default('null')|tojson }};
|
||||
window.__allTaskIds = {{ all_task_ids|tojson }};
|
||||
</script>
|
||||
|
||||
<div class="h-screen flex flex-col overflow-hidden"
|
||||
x-data="taskExecute()"
|
||||
x-init="init()"
|
||||
@numpad-confirm.window="handleMeasurement($event.detail.value, $event.detail.inputMethod)"
|
||||
@marker-click.window="goToSubtaskByMarker($event.detail.marker_number)">
|
||||
|
||||
{# ================================================================
|
||||
HEADER — Compact task info bar
|
||||
================================================================ #}
|
||||
<div class="shrink-0 bg-[var(--bg-card)] border-b border-[var(--border-color)] shadow-sm z-20">
|
||||
<div class="px-3 py-2 flex items-center gap-3">
|
||||
|
||||
{# Back button #}
|
||||
<a href="{{ url_for('measure.task_list', recipe_id=task.recipe_id or 0) }}"
|
||||
class="shrink-0 p-1.5 rounded-lg hover:bg-[var(--bg-secondary)] transition-colors text-[var(--text-muted)] hover:text-[var(--text-primary)]">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
{# Task badge + title #}
|
||||
<div class="flex items-center gap-2 min-w-0 flex-1">
|
||||
<span class="shrink-0 inline-flex items-center px-2 py-0.5 rounded text-xs font-bold
|
||||
bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300
|
||||
border border-primary-200 dark:border-primary-800">
|
||||
Task {{ (task.order_index or 0) + 1 }}
|
||||
</span>
|
||||
<h1 class="text-sm font-bold text-[var(--text-primary)] truncate">
|
||||
{{ task.title or _('Task di misurazione') }}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{# Lot + Serial badges #}
|
||||
<div class="shrink-0 flex items-center gap-2">
|
||||
{% if lot_number %}
|
||||
<div class="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs
|
||||
bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800
|
||||
text-amber-800 dark:text-amber-200">
|
||||
<svg class="w-3 h-3 shrink-0" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
|
||||
</svg>
|
||||
<span class="font-mono font-semibold">{{ lot_number }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if serial_number %}
|
||||
<div class="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs
|
||||
bg-indigo-50 dark:bg-indigo-900/20 border border-indigo-200 dark:border-indigo-800
|
||||
text-indigo-800 dark:text-indigo-200">
|
||||
<svg class="w-3 h-3 shrink-0" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14"/>
|
||||
</svg>
|
||||
<span class="font-mono font-semibold">{{ serial_number }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Caliper status #}
|
||||
{% include "components/caliper_status.html" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ================================================================
|
||||
MAIN — 3-column layout
|
||||
================================================================ #}
|
||||
<div class="flex-1 flex overflow-hidden">
|
||||
|
||||
{# ──────────────────────────────────────────────
|
||||
LEFT SIDEBAR — Marker list (vertical)
|
||||
────────────────────────────────────────────── #}
|
||||
<div class="shrink-0 w-14 md:w-16 bg-[var(--bg-card)] border-r border-[var(--border-color)] flex flex-col sidebar-markers overflow-y-auto">
|
||||
<template x-for="(st, idx) in subtasks" :key="st.id">
|
||||
<button @click="goToSubtask(idx)"
|
||||
class="relative flex flex-col items-center justify-center py-2.5 px-1 border-b border-[var(--border-color)] transition-all duration-200"
|
||||
:class="idx === currentIndex
|
||||
? 'bg-primary-50 dark:bg-primary-900/20 border-l-[3px] border-l-primary'
|
||||
: 'hover:bg-[var(--bg-secondary)] border-l-[3px] border-l-transparent'">
|
||||
|
||||
{# Marker circle #}
|
||||
<span class="inline-flex items-center justify-center w-8 h-8 rounded-full text-xs font-bold transition-all"
|
||||
:class="idx === currentIndex
|
||||
? 'bg-primary text-white shadow-md marker-active-pulse'
|
||||
: getMeasurementStatus(st.id) === 'pass'
|
||||
? 'bg-measure-pass/20 text-measure-pass border border-measure-pass/40'
|
||||
: getMeasurementStatus(st.id) === 'fail'
|
||||
? 'bg-red-100 dark:bg-red-900/30 text-measure-fail border border-measure-fail/40'
|
||||
: getMeasurementStatus(st.id) === 'warning'
|
||||
? 'bg-amber-100 dark:bg-amber-900/30 text-measure-warning border border-measure-warning/40'
|
||||
: 'bg-[var(--bg-secondary)] text-[var(--text-muted)] border border-[var(--border-color)]'"
|
||||
x-text="st.marker_number"></span>
|
||||
|
||||
{# Status icon below circle #}
|
||||
<template x-if="getMeasurementStatus(st.id) === 'pass'">
|
||||
<svg class="w-3 h-3 mt-0.5 text-measure-pass" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
|
||||
</svg>
|
||||
</template>
|
||||
<template x-if="getMeasurementStatus(st.id) === 'fail'">
|
||||
<svg class="w-3 h-3 mt-0.5 text-measure-fail" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</template>
|
||||
<template x-if="getMeasurementStatus(st.id) === 'warning'">
|
||||
<svg class="w-3 h-3 mt-0.5 text-measure-warning" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"/>
|
||||
</svg>
|
||||
</template>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
{# ──────────────────────────────────────────────
|
||||
CENTER — Image area
|
||||
────────────────────────────────────────────── #}
|
||||
<div class="flex-1 flex flex-col overflow-hidden bg-[var(--bg-secondary)]">
|
||||
|
||||
{# Main image area #}
|
||||
<div class="flex-1 overflow-hidden relative">
|
||||
|
||||
{# Option A: Subtask has its own image → plain <img> #}
|
||||
<div x-show="showSubtaskImage" class="absolute inset-0 flex items-center justify-center p-2">
|
||||
<img :src="'/measure/api/files/' + currentSubtaskImage"
|
||||
alt="{{ _('Immagine dettaglio misura') }}"
|
||||
class="max-w-full max-h-full object-contain rounded-lg shadow-sm">
|
||||
</div>
|
||||
|
||||
{# Option B: Task image with annotations → annotation-viewer (kept in DOM via x-show) #}
|
||||
<div x-show="showTaskImage" class="absolute inset-0">
|
||||
{% if task.file_path %}
|
||||
<div class="annotation-container"
|
||||
x-data="annotationViewer()"
|
||||
x-init="
|
||||
imageUrl = '/measure/api/files/{{ task.file_path }}';
|
||||
annotations = window.__taskAnnotations;
|
||||
$nextTick(() => init());
|
||||
"
|
||||
x-effect="setActiveMarker(currentSubtask?.marker_number || 0)">
|
||||
<canvas x-ref="annotationCanvas"
|
||||
@click="handleClick($event)"
|
||||
class="cursor-pointer"></canvas>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Option C: No image → placeholder #}
|
||||
<div x-show="!showSubtaskImage && !showTaskImage" class="absolute inset-0 flex items-center justify-center">
|
||||
<div class="text-center">
|
||||
<svg class="w-16 h-16 mx-auto text-[var(--text-muted)] mb-3 opacity-30" fill="none" stroke="currentColor" stroke-width="1" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
<p class="text-sm font-medium text-[var(--text-muted)]">{{ _('Nessuna immagine allegata') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ──────────────────────────────────────────────
|
||||
RIGHT PANEL — Info + tolerances + numpad
|
||||
────────────────────────────────────────────── #}
|
||||
<div class="shrink-0 w-72 lg:w-80 bg-[var(--bg-card)] border-l border-[var(--border-color)] flex flex-col right-panel overflow-y-auto">
|
||||
|
||||
{# ---- Subtask header ---- #}
|
||||
<div class="p-3 border-b border-[var(--border-color)]" x-show="currentSubtask">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="inline-flex items-center justify-center w-8 h-8 rounded-full
|
||||
bg-primary text-white font-bold text-sm shadow marker-active-pulse"
|
||||
x-text="currentSubtask?.marker_number"></span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h2 class="font-semibold text-[var(--text-primary)] text-sm leading-tight truncate"
|
||||
x-text="currentSubtask?.description || '{{ _('Misurazione') }}'"></h2>
|
||||
<span class="text-[11px] text-[var(--text-muted)]"
|
||||
x-text="(currentSubtask?.measurement_type || '{{ _('Misura') }}') + ' (' + (currentSubtask?.unit || 'mm') + ')'"></span>
|
||||
</div>
|
||||
<span class="shrink-0 text-[11px] font-mono text-[var(--text-muted)] bg-[var(--bg-secondary)] px-1.5 py-0.5 rounded">
|
||||
<span x-text="currentIndex + 1"></span>/<span x-text="totalSubtasks"></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ---- Tolerance parameters (compact grid) ---- #}
|
||||
<div class="px-3 py-2 border-b border-[var(--border-color)]" x-show="currentSubtask">
|
||||
{# Nominal row #}
|
||||
<div class="flex items-center justify-between py-1 px-2 rounded bg-primary-50 dark:bg-primary-900/20 border border-primary-100 dark:border-primary-800 mb-1.5">
|
||||
<span class="text-[10px] font-medium text-primary-700 dark:text-primary-300 uppercase tracking-wider">{{ _('Nominale') }}</span>
|
||||
<span class="font-mono font-bold text-primary text-sm"
|
||||
x-text="currentSubtask?.nominal?.toFixed(3) || '—'"></span>
|
||||
</div>
|
||||
|
||||
{# 2x2 tolerance grid #}
|
||||
<div class="grid grid-cols-2 gap-1 text-[11px]">
|
||||
<div class="flex items-center justify-between py-0.5 px-2 rounded bg-red-50/50 dark:bg-red-900/10">
|
||||
<span class="text-measure-fail font-medium">UTL</span>
|
||||
<span class="font-mono font-semibold text-measure-fail" x-text="currentSubtask?.utl?.toFixed(3) || '—'"></span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between py-0.5 px-2 rounded bg-red-50/50 dark:bg-red-900/10">
|
||||
<span class="text-measure-fail font-medium">LTL</span>
|
||||
<span class="font-mono font-semibold text-measure-fail" x-text="currentSubtask?.ltl?.toFixed(3) || '—'"></span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between py-0.5 px-2 rounded bg-amber-50/50 dark:bg-amber-900/10">
|
||||
<span class="text-measure-warning font-medium">UWL</span>
|
||||
<span class="font-mono font-semibold text-measure-warning" x-text="currentSubtask?.uwl?.toFixed(3) || '—'"></span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between py-0.5 px-2 rounded bg-amber-50/50 dark:bg-amber-900/10">
|
||||
<span class="text-measure-warning font-medium">LWL</span>
|
||||
<span class="font-mono font-semibold text-measure-warning" x-text="currentSubtask?.lwl?.toFixed(3) || '—'"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ---- Tolerance bar (compact) ---- #}
|
||||
<div class="px-3 py-2 border-b border-[var(--border-color)]" x-show="currentSubtask">
|
||||
<div class="tolerance-bar-bg h-5 rounded-full relative overflow-hidden border border-[var(--border-color)]">
|
||||
{# Zone labels #}
|
||||
<div class="absolute inset-0 flex items-center justify-between px-1.5 text-[8px] font-mono text-[var(--text-muted)]">
|
||||
<span x-text="currentSubtask?.ltl?.toFixed(2)"></span>
|
||||
<span x-text="currentSubtask?.nominal?.toFixed(2)"></span>
|
||||
<span x-text="currentSubtask?.utl?.toFixed(2)"></span>
|
||||
</div>
|
||||
{# Center line #}
|
||||
<div class="absolute top-0 bottom-0 w-px bg-[var(--text-muted)]/30" style="left: 50%;"></div>
|
||||
{# Value needle #}
|
||||
<div x-show="currentValue !== null"
|
||||
x-transition
|
||||
class="absolute top-0 bottom-0 w-1 rounded-full transition-all duration-300"
|
||||
:class="{
|
||||
'bg-measure-pass': passFailStatus === 'pass',
|
||||
'bg-measure-warning': passFailStatus === 'warning',
|
||||
'bg-measure-fail': passFailStatus === 'fail'
|
||||
}"
|
||||
:style="'left: calc(' + progressWidth + '% - 2px)'">
|
||||
<div class="absolute -top-0.5 left-1/2 -translate-x-1/2 w-2.5 h-2.5 rounded-full border-2 border-white dark:border-slate-800 shadow"
|
||||
:class="{
|
||||
'bg-measure-pass': passFailStatus === 'pass',
|
||||
'bg-measure-warning': passFailStatus === 'warning',
|
||||
'bg-measure-fail': passFailStatus === 'fail'
|
||||
}"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ---- Already measured indicator ---- #}
|
||||
<div x-show="isMeasured(currentSubtask?.id)"
|
||||
class="mx-3 mt-2 p-2 rounded-lg bg-measure-pass/10 border border-measure-pass/20">
|
||||
<div class="flex items-center gap-1.5 text-xs text-measure-pass font-medium">
|
||||
<svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
|
||||
</svg>
|
||||
<span>{{ _('Registrata') }}:</span>
|
||||
<span class="font-mono font-bold" x-text="getMeasuredValue(currentSubtask?.id)?.toFixed(3) || '—'"></span>
|
||||
<span x-text="currentSubtask?.unit || 'mm'"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ---- Measurement feedback ---- #}
|
||||
<div class="px-3 pt-2" x-data="{ get nominal() { return currentSubtask?.nominal || 0; },
|
||||
get utl() { return currentSubtask?.utl || 0; },
|
||||
get uwl() { return currentSubtask?.uwl || 0; },
|
||||
get lwl() { return currentSubtask?.lwl || 0; },
|
||||
get ltl() { return currentSubtask?.ltl || 0; },
|
||||
get unit() { return currentSubtask?.unit || 'mm'; } }">
|
||||
{% include "components/measurement_feedback.html" %}
|
||||
</div>
|
||||
|
||||
{# ---- Numpad ---- #}
|
||||
<div class="px-3 py-2 relative flex-1">
|
||||
{# Saving overlay #}
|
||||
<div x-show="saving"
|
||||
x-transition
|
||||
class="absolute inset-0 z-10 bg-white/70 dark:bg-slate-900/70 flex items-center justify-center backdrop-blur-sm rounded">
|
||||
<div class="flex items-center gap-2 text-primary font-medium text-sm">
|
||||
<svg class="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
{{ _('Salvataggio...') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% include "components/numpad.html" %}
|
||||
</div>
|
||||
|
||||
{# ---- Next measurement indicator ---- #}
|
||||
<div class="px-3 pb-2">
|
||||
{% include "components/next_measurement.html" %}
|
||||
</div>
|
||||
|
||||
{# ---- Error message ---- #}
|
||||
<div x-show="errorMessage"
|
||||
x-transition
|
||||
x-cloak
|
||||
class="mx-3 mb-2 p-2 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-xs text-measure-fail">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
<span x-text="errorMessage"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{# ================================================================
|
||||
FOOTER — Progress bar + navigation
|
||||
================================================================ #}
|
||||
<div class="shrink-0 bg-[var(--bg-card)] border-t border-[var(--border-color)] shadow-[0_-2px_10px_rgba(0,0,0,0.05)] z-20">
|
||||
<div class="px-3 py-2 flex items-center gap-3">
|
||||
|
||||
{# Left: Back #}
|
||||
<a href="{{ url_for('measure.task_list', recipe_id=task.recipe_id or 0) }}"
|
||||
class="btn btn-secondary text-xs shrink-0 gap-1 py-1.5 px-2.5">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
|
||||
</svg>
|
||||
<span class="hidden sm:inline">{{ _('Task') }}</span>
|
||||
</a>
|
||||
|
||||
{# Center: Progress bar #}
|
||||
<div class="flex-1 flex items-center gap-2">
|
||||
<span class="text-xs font-medium text-[var(--text-secondary)] shrink-0 font-mono">
|
||||
<span x-text="completedCount"></span>/<span x-text="totalSubtasks"></span>
|
||||
</span>
|
||||
<div class="flex-1 h-2 rounded-full bg-[var(--bg-secondary)] border border-[var(--border-color)] overflow-hidden">
|
||||
<div class="h-full rounded-full transition-all duration-500 ease-out"
|
||||
:class="isComplete ? 'bg-measure-pass' : 'bg-primary'"
|
||||
:style="'width: ' + progressPercent + '%'"></div>
|
||||
</div>
|
||||
<span class="text-xs font-bold shrink-0"
|
||||
:class="isComplete ? 'text-measure-pass' : 'text-primary'"
|
||||
x-text="Math.round(progressPercent) + '%'"></span>
|
||||
</div>
|
||||
|
||||
{# Right: Summary button #}
|
||||
<button x-show="isComplete"
|
||||
x-transition
|
||||
@click="goToSummary()"
|
||||
class="btn btn-primary text-xs shrink-0 gap-1 py-1.5 px-2.5 shadow-md">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
{{ _('Riepilogo') }}
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M14 5l7 7m0 0l-7 7m7-7H3"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ================================================================
|
||||
COMPLETION OVERLAY
|
||||
================================================================ #}
|
||||
<div x-show="showCompletionOverlay"
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
x-cloak
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<div class="bg-[var(--bg-card)] rounded-2xl shadow-2xl p-8 max-w-sm mx-4 text-center"
|
||||
x-transition:enter="transition ease-out duration-300 delay-100"
|
||||
x-transition:enter-start="opacity-0 scale-90"
|
||||
x-transition:enter-end="opacity-100 scale-100">
|
||||
|
||||
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-measure-pass/10 mb-4">
|
||||
<svg class="w-8 h-8 text-measure-pass" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h3 class="text-lg font-bold text-[var(--text-primary)] mb-1">{{ _('Misurazioni Complete') }}</h3>
|
||||
<p class="text-sm text-[var(--text-secondary)] mb-2">
|
||||
{{ _('Tutte le') }} <span class="font-bold font-mono" x-text="totalSubtasks"></span> {{ _('misurazioni sono state registrate.') }}
|
||||
</p>
|
||||
|
||||
<div class="flex justify-center gap-4 mb-5">
|
||||
<div class="text-center">
|
||||
<div class="text-xl font-bold font-mono text-measure-pass" x-text="passCount"></div>
|
||||
<div class="text-[10px] uppercase tracking-wider text-[var(--text-muted)]">{{ _('Conformi') }}</div>
|
||||
</div>
|
||||
<div class="text-center" x-show="warningCount > 0">
|
||||
<div class="text-xl font-bold font-mono text-measure-warning" x-text="warningCount"></div>
|
||||
<div class="text-[10px] uppercase tracking-wider text-[var(--text-muted)]">{{ _('Attenzione') }}</div>
|
||||
</div>
|
||||
<div class="text-center" x-show="failCount > 0">
|
||||
<div class="text-xl font-bold font-mono text-measure-fail" x-text="failCount"></div>
|
||||
<div class="text-[10px] uppercase tracking-wider text-[var(--text-muted)]">{{ _('Non Conf.') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button @click="goToSummary()"
|
||||
class="btn btn-primary w-full justify-center gap-2">
|
||||
{{ _('Vai al Riepilogo') }}
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M14 5l7 7m0 0l-7 7m7-7H3"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
|
||||
<script>
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
|
||||
</script>
|
||||
<script src="{{ url_for('static', filename='js/numpad.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/annotation-viewer.js') }}?v=6"></script>
|
||||
<script src="{{ url_for('static', filename='js/caliper.js') }}"></script>
|
||||
|
||||
<script>
|
||||
/**
|
||||
* Task Execute - Main Alpine.js component
|
||||
* Manages measurement workflow state, save logic, and navigation.
|
||||
* Layout: 3-column (marker sidebar | image center | info+numpad right)
|
||||
*/
|
||||
function taskExecute() {
|
||||
return {
|
||||
// ---- Data from server ----
|
||||
task: window.__taskData,
|
||||
subtasks: window.__taskSubtasks,
|
||||
lotNumber: '{{ lot_number or '' }}',
|
||||
serialNumber: '{{ serial_number or '' }}',
|
||||
// ---- Measurement state ----
|
||||
currentIndex: 0,
|
||||
measurements: [], // [{subtask_id, value, pass_fail, deviation}, ...]
|
||||
saving: false,
|
||||
errorMessage: '',
|
||||
showCompletionOverlay: false,
|
||||
|
||||
// ---- Value from numpad / caliper ----
|
||||
currentValue: null,
|
||||
|
||||
// ---- Image switching logic ----
|
||||
get currentSubtaskImage() {
|
||||
return this.currentSubtask?.image_path || null;
|
||||
},
|
||||
get showSubtaskImage() {
|
||||
return !!this.currentSubtaskImage;
|
||||
},
|
||||
get showTaskImage() {
|
||||
return !this.currentSubtaskImage && !!this.task.file_path;
|
||||
},
|
||||
|
||||
// ---- Computed properties ----
|
||||
get currentSubtask() {
|
||||
return this.subtasks[this.currentIndex] || null;
|
||||
},
|
||||
|
||||
get nextSubtask() {
|
||||
return this.subtasks[this.currentIndex + 1] || null;
|
||||
},
|
||||
|
||||
get totalSubtasks() {
|
||||
return this.subtasks.length;
|
||||
},
|
||||
|
||||
get completedCount() {
|
||||
return this.measurements.length;
|
||||
},
|
||||
|
||||
get isComplete() {
|
||||
return this.completedCount >= this.totalSubtasks;
|
||||
},
|
||||
|
||||
get progressPercent() {
|
||||
return this.totalSubtasks > 0
|
||||
? (this.completedCount / this.totalSubtasks * 100)
|
||||
: 0;
|
||||
},
|
||||
|
||||
// ---- Pass/fail logic ----
|
||||
get passFailStatus() {
|
||||
if (this.currentValue === null || !this.currentSubtask) return null;
|
||||
const v = this.currentValue;
|
||||
const s = this.currentSubtask;
|
||||
if (s.utl != null && v > s.utl) return 'fail';
|
||||
if (s.ltl != null && v < s.ltl) return 'fail';
|
||||
if (s.uwl != null && v > s.uwl) return 'warning';
|
||||
if (s.lwl != null && v < s.lwl) return 'warning';
|
||||
return 'pass';
|
||||
},
|
||||
|
||||
get deviation() {
|
||||
if (this.currentValue === null || !this.currentSubtask) return null;
|
||||
return this.currentValue - (this.currentSubtask.nominal || 0);
|
||||
},
|
||||
|
||||
get progressWidth() {
|
||||
if (!this.currentSubtask || this.currentValue === null) return 50;
|
||||
const s = this.currentSubtask;
|
||||
if (s.utl == null || s.ltl == null) return 50;
|
||||
const range = s.utl - s.ltl;
|
||||
if (range <= 0) return 50;
|
||||
const pos = (this.currentValue - s.ltl) / range * 100;
|
||||
return Math.max(0, Math.min(100, pos));
|
||||
},
|
||||
|
||||
// ---- Summary counts ----
|
||||
get passCount() {
|
||||
return this.measurements.filter(m => m.pass_fail === 'pass').length;
|
||||
},
|
||||
get warningCount() {
|
||||
return this.measurements.filter(m => m.pass_fail === 'warning').length;
|
||||
},
|
||||
get failCount() {
|
||||
return this.measurements.filter(m => m.pass_fail === 'fail').length;
|
||||
},
|
||||
|
||||
// ---- Init ----
|
||||
init() {
|
||||
this.subtasks.sort((a, b) => (a.order_index || 0) - (b.order_index || 0));
|
||||
},
|
||||
|
||||
// ---- Check if a subtask has been measured ----
|
||||
isMeasured(subtaskId) {
|
||||
return this.measurements.some(m => m.subtask_id === subtaskId);
|
||||
},
|
||||
|
||||
getMeasuredValue(subtaskId) {
|
||||
const m = this.measurements.find(m => m.subtask_id === subtaskId);
|
||||
return m ? m.value : null;
|
||||
},
|
||||
|
||||
getMeasurementStatus(subtaskId) {
|
||||
const m = this.measurements.find(m => m.subtask_id === subtaskId);
|
||||
return m ? m.pass_fail : null;
|
||||
},
|
||||
|
||||
// ---- Handle numpad confirm ----
|
||||
async handleMeasurement(value, inputMethod) {
|
||||
if (!this.currentSubtask || this.saving) return;
|
||||
|
||||
this.currentValue = value;
|
||||
this.errorMessage = '';
|
||||
|
||||
const pf = this.passFailStatus;
|
||||
const dev = this.deviation;
|
||||
|
||||
this.saving = true;
|
||||
|
||||
try {
|
||||
const csrfToken = document.querySelector('meta[name=csrf-token]')?.content || '';
|
||||
|
||||
const response = await fetch('{{ url_for("measure.save_measurement") }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': csrfToken,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
subtask_id: this.currentSubtask.id,
|
||||
version_id: this.task.version_id,
|
||||
value: value,
|
||||
lot_number: this.lotNumber,
|
||||
serial_number: this.serialNumber,
|
||||
input_method: inputMethod || 'manual',
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok || result.error) {
|
||||
this.errorMessage = result.detail || '{{ _("Errore nel salvataggio della misurazione") }}';
|
||||
this.saving = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Record measurement locally
|
||||
const existingIdx = this.measurements.findIndex(m => m.subtask_id === this.currentSubtask.id);
|
||||
const mEntry = { subtask_id: this.currentSubtask.id, value, pass_fail: pf, deviation: dev };
|
||||
if (existingIdx !== -1) {
|
||||
this.measurements.splice(existingIdx, 1, mEntry);
|
||||
} else {
|
||||
this.measurements.push(mEntry);
|
||||
}
|
||||
|
||||
this.saving = false;
|
||||
|
||||
// Pause to show result feedback
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
|
||||
// Check if all done
|
||||
if (this.completedCount >= this.totalSubtasks) {
|
||||
const taskIds = window.__allTaskIds || [];
|
||||
const currentIdx = taskIds.indexOf(this.task.id);
|
||||
if (currentIdx >= 0 && currentIdx < taskIds.length - 1) {
|
||||
window.location.href = '{{ url_for("measure.task_execute", task_id=0) }}'.replace('/0', '/' + taskIds[currentIdx + 1]);
|
||||
} else {
|
||||
this.showCompletionOverlay = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.advanceToNext();
|
||||
|
||||
} catch (err) {
|
||||
console.error('Save measurement error:', err);
|
||||
this.errorMessage = '{{ _("Errore di rete. Riprovare.") }}';
|
||||
this.saving = false;
|
||||
}
|
||||
},
|
||||
|
||||
// ---- Advance to next unmeasured subtask ----
|
||||
advanceToNext() {
|
||||
this.currentValue = null;
|
||||
|
||||
for (let i = this.currentIndex + 1; i < this.totalSubtasks; i++) {
|
||||
if (!this.isMeasured(this.subtasks[i].id)) {
|
||||
this.currentIndex = i;
|
||||
return;
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < this.currentIndex; i++) {
|
||||
if (!this.isMeasured(this.subtasks[i].id)) {
|
||||
this.currentIndex = i;
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// ---- Navigation ----
|
||||
goToSubtask(index) {
|
||||
if (index >= 0 && index < this.totalSubtasks) {
|
||||
this.currentIndex = index;
|
||||
this.currentValue = null;
|
||||
this.errorMessage = '';
|
||||
}
|
||||
},
|
||||
|
||||
goToSubtaskByMarker(markerNumber) {
|
||||
const idx = this.subtasks.findIndex(s => s.marker_number === markerNumber);
|
||||
if (idx !== -1) {
|
||||
this.goToSubtask(idx);
|
||||
}
|
||||
},
|
||||
|
||||
// ---- Go to summary ----
|
||||
goToSummary() {
|
||||
const recipeId = this.task.recipe_id || 0;
|
||||
window.location.href = '{{ url_for("measure.task_complete", recipe_id=0) }}'.replace('/0', '/' + recipeId) +
|
||||
'?version_id=' + encodeURIComponent(this.task.version_id || '');
|
||||
},
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,37 +0,0 @@
|
||||
"""Verify that /measure/select reads STATION_CODE and filters recipes via the server."""
|
||||
import importlib
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
|
||||
def _reload_measure(monkeypatch, station_code=None):
|
||||
"""Reload config and measure module under the given STATION_CODE env."""
|
||||
if station_code is None:
|
||||
monkeypatch.delenv("STATION_CODE", raising=False)
|
||||
else:
|
||||
monkeypatch.setenv("STATION_CODE", station_code)
|
||||
import config
|
||||
importlib.reload(config)
|
||||
import blueprints.measure
|
||||
importlib.reload(blueprints.measure)
|
||||
|
||||
|
||||
def test_select_recipe_calls_station_endpoint(logged_in_client, monkeypatch):
|
||||
_reload_measure(monkeypatch, station_code="ST-TEST")
|
||||
from blueprints import measure as measure_bp_mod
|
||||
with patch.object(measure_bp_mod, "api_client") as mock_api:
|
||||
mock_api.get_station_recipes.return_value = [
|
||||
{"id": 1, "code": "R1", "name": "Recipe 1", "active": True},
|
||||
]
|
||||
resp = logged_in_client.get("/measure/select")
|
||||
assert resp.status_code == 200
|
||||
mock_api.get_station_recipes.assert_called_once()
|
||||
args, kwargs = mock_api.get_station_recipes.call_args
|
||||
assert args[0] == "ST-TEST" or kwargs.get("station_code") == "ST-TEST"
|
||||
|
||||
|
||||
def test_select_recipe_without_station_code_shows_error(logged_in_client, monkeypatch):
|
||||
_reload_measure(monkeypatch, station_code=None)
|
||||
resp = logged_in_client.get("/measure/select")
|
||||
assert resp.status_code == 503
|
||||
body = resp.data.lower()
|
||||
assert b"station_code" in body or b"stazione" in body
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,118 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
"""Verify i18n setup for TieMeasureFlow."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
def check_file(path: Path, description: str) -> bool:
|
||||
"""Check if a file exists and report."""
|
||||
exists = path.exists()
|
||||
status = "[OK]" if exists else "[FAIL]"
|
||||
print(f"{status} {description}: {path}")
|
||||
return exists
|
||||
|
||||
def check_json_valid(path: Path) -> bool:
|
||||
"""Check if JSON file is valid."""
|
||||
try:
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
json.load(f)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f" ERROR: {e}")
|
||||
return False
|
||||
|
||||
def main():
|
||||
"""Verify i18n setup."""
|
||||
print("=== TieMeasureFlow i18n Verification ===\n")
|
||||
|
||||
root = Path(__file__).parent
|
||||
all_good = True
|
||||
|
||||
# Check Flask-Babel files
|
||||
print("Flask-Babel (Server-side):")
|
||||
files = [
|
||||
(root / "translations/babel.cfg", "Babel config"),
|
||||
(root / "translations/it/LC_MESSAGES/messages.po", "Italian .po"),
|
||||
(root / "translations/it/LC_MESSAGES/messages.mo", "Italian .mo"),
|
||||
(root / "translations/en/LC_MESSAGES/messages.po", "English .po"),
|
||||
(root / "translations/en/LC_MESSAGES/messages.mo", "English .mo"),
|
||||
]
|
||||
|
||||
for path, desc in files:
|
||||
if not check_file(path, desc):
|
||||
all_good = False
|
||||
|
||||
# Count messages in .po files
|
||||
if (root / "translations/it/LC_MESSAGES/messages.po").exists():
|
||||
with open(root / "translations/it/LC_MESSAGES/messages.po", 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
msgid_count = content.count('msgid "') - 1 # Exclude header
|
||||
print(f" Italian: {msgid_count} messages")
|
||||
|
||||
if (root / "translations/en/LC_MESSAGES/messages.po").exists():
|
||||
with open(root / "translations/en/LC_MESSAGES/messages.po", 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
msgid_count = content.count('msgid "') - 1
|
||||
print(f" English: {msgid_count} messages")
|
||||
|
||||
# Check Alpine.js i18n files
|
||||
print("\nAlpine.js i18n (Client-side):")
|
||||
json_files = [
|
||||
(root / "static/js/locales/it.json", "Italian locale"),
|
||||
(root / "static/js/locales/en.json", "English locale"),
|
||||
]
|
||||
|
||||
for path, desc in json_files:
|
||||
if check_file(path, desc):
|
||||
if not check_json_valid(path):
|
||||
all_good = False
|
||||
else:
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
keys = count_keys(data)
|
||||
print(f" {keys} total keys")
|
||||
|
||||
# Check app.py integration
|
||||
print("\nApp Integration:")
|
||||
app_py = root / "app.py"
|
||||
if check_file(app_py, "app.py"):
|
||||
with open(app_py, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
checks = [
|
||||
("from flask_babel import Babel", "Flask-Babel imported"),
|
||||
("def get_locale()", "get_locale() function"),
|
||||
("Babel(app, locale_selector=get_locale)", "Babel initialized"),
|
||||
("def set_language(lang)", "set_language endpoint"),
|
||||
]
|
||||
for check_str, check_desc in checks:
|
||||
found = check_str in content
|
||||
status = "[OK]" if found else "[FAIL]"
|
||||
print(f" {status} {check_desc}")
|
||||
if not found:
|
||||
all_good = False
|
||||
|
||||
# Summary
|
||||
print("\n" + "="*40)
|
||||
if all_good:
|
||||
print("[OK] i18n setup verified successfully!")
|
||||
print("\nNext steps:")
|
||||
print("1. Use _() in Python code and templates")
|
||||
print("2. Use $t() in Alpine.js components")
|
||||
print("3. Test language switching: /set-language/en or /set-language/it")
|
||||
else:
|
||||
print("[FAIL] Some checks failed - review errors above")
|
||||
return 1
|
||||
|
||||
return 0
|
||||
|
||||
def count_keys(obj, depth=0):
|
||||
"""Recursively count keys in nested dict."""
|
||||
if not isinstance(obj, dict):
|
||||
return 0
|
||||
count = len(obj)
|
||||
for value in obj.values():
|
||||
if isinstance(value, dict):
|
||||
count += count_keys(value, depth + 1)
|
||||
return count
|
||||
|
||||
if __name__ == '__main__':
|
||||
exit(main())
|
||||
@@ -24,7 +24,7 @@ services:
|
||||
|
||||
server:
|
||||
build:
|
||||
context: ./server
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: tmflow-server
|
||||
restart: unless-stopped
|
||||
@@ -43,8 +43,8 @@ services:
|
||||
|
||||
client:
|
||||
build:
|
||||
context: ./client
|
||||
dockerfile: Dockerfile
|
||||
context: .
|
||||
dockerfile: Dockerfile.frontend
|
||||
container_name: tmflow-client
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
|
||||
+4
-4
@@ -24,7 +24,7 @@ services:
|
||||
|
||||
server:
|
||||
build:
|
||||
context: ./server
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: tmflow-server
|
||||
restart: unless-stopped
|
||||
@@ -52,8 +52,8 @@ services:
|
||||
|
||||
client:
|
||||
build:
|
||||
context: ./client
|
||||
dockerfile: Dockerfile
|
||||
context: .
|
||||
dockerfile: Dockerfile.frontend
|
||||
container_name: tmflow-client
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
@@ -93,4 +93,4 @@ networks:
|
||||
driver: bridge
|
||||
traefik-net:
|
||||
external: true
|
||||
name: root_default
|
||||
name: traefik
|
||||
|
||||
+184
@@ -23,6 +23,7 @@ TieMeasureFlow provides a REST API built with FastAPI for managing measurement t
|
||||
- [Measurements](#measurements)
|
||||
- [Files](#files)
|
||||
- [Settings](#settings)
|
||||
- [Stations](#stations)
|
||||
- [Statistics](#statistics)
|
||||
- [Reports](#reports)
|
||||
7. [Pagination](#pagination)
|
||||
@@ -1101,6 +1102,189 @@ Content-Type: multipart/form-data
|
||||
|
||||
---
|
||||
|
||||
### Stations
|
||||
|
||||
Stations model the physical measurement posts on the shop floor. Each Flask client identifies itself through `STATION_CODE`, and the operator only sees recipes assigned to that station. See the user guide section "Admin Workflow → Station Management" for the operational model.
|
||||
|
||||
#### GET `/stations`
|
||||
|
||||
List stations. **Admin only.**
|
||||
|
||||
Query parameters:
|
||||
- `active_only` (bool, default `false`): if `true`, return only stations where `active = true`.
|
||||
|
||||
Response `200`:
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"code": "ST-DEFAULT",
|
||||
"name": "Default Station",
|
||||
"location": "Initial seed - change me",
|
||||
"notes": null,
|
||||
"active": true,
|
||||
"created_by": 5,
|
||||
"created_at": "2026-04-26T10:12:06"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Errors:
|
||||
- 401: Missing or invalid API key
|
||||
- 403: Admin role required
|
||||
|
||||
---
|
||||
|
||||
#### POST `/stations`
|
||||
|
||||
Create a station. **Admin only.**
|
||||
|
||||
Request body:
|
||||
```json
|
||||
{
|
||||
"code": "ST-LINEA-A",
|
||||
"name": "Linea A — Tornitura alberi",
|
||||
"location": "Reparto 2 — Cella 3",
|
||||
"notes": "Provisioned 2026-04-26 by Adriano",
|
||||
"active": true
|
||||
}
|
||||
```
|
||||
|
||||
`code` (1-100 chars) and `name` (1-255 chars) are required. `location` is optional (≤ 255 chars). `active` defaults to `true`.
|
||||
|
||||
Response `201`: same shape as `GET /stations` items.
|
||||
|
||||
Errors:
|
||||
- 400: Invalid payload (missing `code`/`name`, exceeded length)
|
||||
- 403: Admin role required
|
||||
- 409: Station with that code already exists
|
||||
|
||||
---
|
||||
|
||||
#### GET `/stations/{station_id}`
|
||||
|
||||
Get a single station by id. **Admin only.**
|
||||
|
||||
Response `200`: same as the list item shape.
|
||||
|
||||
Errors:
|
||||
- 403: Admin role required
|
||||
- 404: Station not found
|
||||
|
||||
---
|
||||
|
||||
#### PUT `/stations/{station_id}`
|
||||
|
||||
Update a station's editable fields. **Admin only.**
|
||||
|
||||
Request body (all fields optional):
|
||||
```json
|
||||
{
|
||||
"name": "Linea A — riconfigurata",
|
||||
"location": "Reparto 2 — Cella 4",
|
||||
"notes": "Moved cell on 2026-05-12",
|
||||
"active": false
|
||||
}
|
||||
```
|
||||
|
||||
Note: the `code` field is not in the schema. Codes are immutable on purpose (changing the code would orphan every tablet pointing at it).
|
||||
|
||||
Response `200`: the updated station.
|
||||
|
||||
Errors:
|
||||
- 400: Invalid payload
|
||||
- 403: Admin role required
|
||||
- 404: Station not found
|
||||
|
||||
---
|
||||
|
||||
#### DELETE `/stations/{station_id}`
|
||||
|
||||
Delete a station. **Admin only.**
|
||||
|
||||
The deletion cascades to every row in `station_recipe_assignments` for that station. Existing measurements are NOT affected (measurements link to recipe versions, not stations).
|
||||
|
||||
Response `204`: no body.
|
||||
|
||||
Errors:
|
||||
- 403: Admin role required
|
||||
- 404: Station not found
|
||||
|
||||
---
|
||||
|
||||
#### GET `/stations/{station_id}/recipes`
|
||||
|
||||
Admin view of the recipes currently assigned to a station. Returns the same projection used by the assignment modal in `/admin/stations`. **Admin only.**
|
||||
|
||||
Response `200`:
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": 2,
|
||||
"code": "DEMO-001",
|
||||
"name": "Demo Measurement Recipe",
|
||||
"active": true
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Errors:
|
||||
- 403: Admin role required
|
||||
- 404: Station not found
|
||||
|
||||
---
|
||||
|
||||
#### GET `/stations/by-code/{code}/recipes`
|
||||
|
||||
**Operator view** (any authenticated user, no admin requirement). Returns the active recipes assigned to the station whose code matches `{code}`. The Flask client calls this endpoint at every page load of `/measure/select`, passing its own `STATION_CODE`.
|
||||
|
||||
Response `200`: same shape as `GET /stations/{station_id}/recipes`.
|
||||
|
||||
Errors:
|
||||
- 401: Missing or invalid API key
|
||||
- 404: Station not found OR station is not active (operator-facing endpoint deliberately treats both cases as 404 to avoid leaking the existence of disabled stations)
|
||||
|
||||
---
|
||||
|
||||
#### POST `/stations/{station_id}/recipes`
|
||||
|
||||
Assign an existing recipe to a station. **Admin only.**
|
||||
|
||||
Request body:
|
||||
```json
|
||||
{ "recipe_id": 2 }
|
||||
```
|
||||
|
||||
Response `201`:
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"station_id": 1,
|
||||
"recipe_id": 2,
|
||||
"assigned_by": 5,
|
||||
"assigned_at": "2026-04-26T10:12:06"
|
||||
}
|
||||
```
|
||||
|
||||
Errors:
|
||||
- 403: Admin role required
|
||||
- 404: Station or recipe not found
|
||||
- 409: Recipe already assigned to this station
|
||||
|
||||
---
|
||||
|
||||
#### DELETE `/stations/{station_id}/recipes/{recipe_id}`
|
||||
|
||||
Remove an existing assignment. **Admin only.**
|
||||
|
||||
Response `204`: no body.
|
||||
|
||||
Errors:
|
||||
- 403: Admin role required
|
||||
- 404: Station/recipe not found, or no assignment between them
|
||||
|
||||
---
|
||||
|
||||
### Statistics
|
||||
|
||||
All statistics endpoints **require Metrologist role**.
|
||||
|
||||
+4
-3
@@ -134,10 +134,11 @@ SSL_KEYFILE=
|
||||
| `CLIENT_HOST` | string | 0.0.0.0 | Flask client bind address |
|
||||
| `CLIENT_PORT` | int | 5000 | Flask client port |
|
||||
| `SERVER_CORS_ORIGINS` | string | http://localhost:5000 | Comma-separated CORS origins |
|
||||
| `UPLOAD_DIR` | string | uploads | Directory for file uploads |
|
||||
| `UPLOAD_DIR` | string | uploads | Directory for file uploads (resolved against the project root) |
|
||||
| `MAX_UPLOAD_SIZE_MB` | int | 50 | Maximum upload file size in MB |
|
||||
| `RATE_LIMIT_LOGIN` | int | 5 | Login requests per minute |
|
||||
| `RATE_LIMIT_GENERAL` | int | 100 | General requests per minute |
|
||||
| `RATE_LIMIT_LOGIN` | int | 5 | Login requests per minute, per real client IP |
|
||||
| `RATE_LIMIT_GENERAL` | int | 300 | General requests per minute, per real client IP (post-V2.0.0; was 100 in V1.0.x) |
|
||||
| `STATION_CODE` | string | (empty) | **Per-tablet** code identifying the station this Flask client serves. Must match a station created in the admin UI. Empty = the client refuses `/measure/select` with HTTP 503 "Stazione non configurata". |
|
||||
| `SSL_CERTFILE` | string | (empty) | Path to SSL certificate (production) |
|
||||
| `SSL_KEYFILE` | string | (empty) | Path to SSL private key (production) |
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ TieMeasureFlow supports complete internationalization (i18n) with:
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
client/
|
||||
src/frontend/flask_app/
|
||||
├── translations/ # Flask-Babel translations
|
||||
│ ├── babel.cfg # Extraction config
|
||||
│ ├── it/LC_MESSAGES/
|
||||
@@ -99,7 +99,7 @@ flash(_("Profilo aggiornato con successo"))
|
||||
After editing .po files:
|
||||
|
||||
```bash
|
||||
cd client
|
||||
cd src/frontend/flask_app
|
||||
python compile_translations.py
|
||||
```
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
# Documentazione TieMeasureFlow
|
||||
|
||||
Indice della documentazione del progetto.
|
||||
|
||||
## Stato e direzione
|
||||
|
||||
| Documento | Scopo |
|
||||
|---|---|
|
||||
| [`architecture/STATO_PROGETTO.md`](architecture/STATO_PROGETTO.md) | Cosa è fatto oggi (V2.0.0). Snapshot del sistema, componenti e capacità. |
|
||||
| [`architecture/ROADMAP.md`](architecture/ROADMAP.md) | Cosa resta da fare. Fasi 2-7 della migrazione rev04 verso V1.1.0/M1 demo cliente. |
|
||||
|
||||
## Riferimenti operativi
|
||||
|
||||
| Documento | Scopo |
|
||||
|---|---|
|
||||
| [`API.md`](API.md) | Riferimento endpoint REST esposti dal backend FastAPI. |
|
||||
| [`DEPLOYMENT.md`](DEPLOYMENT.md) | Guida deploy su VPS (Hostinger/Tielogic con Traefik + Let's Encrypt). |
|
||||
| [`USER_GUIDE.md`](USER_GUIDE.md) | Manuale utente (operatore, maker, metrologist, admin). |
|
||||
| [`I18N_SETUP.md`](I18N_SETUP.md) | Setup e workflow traduzioni (Flask-Babel + Alpine.js). |
|
||||
|
||||
## Piani dettagliati TDD (rev04)
|
||||
|
||||
| Documento | Scopo |
|
||||
|---|---|
|
||||
| [`superpowers/plans/2026-04-17-rev04-master-roadmap.md`](superpowers/plans/2026-04-17-rev04-master-roadmap.md) | Master plan rev04 V1.0.7 → V1.1.0, le 7 fasi e le decisioni aperte. |
|
||||
| [`superpowers/plans/2026-04-17-rev04-phase1-stations.md`](superpowers/plans/2026-04-17-rev04-phase1-stations.md) | Piano TDD dettagliato Fase 1 (stazioni e identità per-tablet). **COMPLETATO.** |
|
||||
|
||||
## Specifiche esterne (binari)
|
||||
|
||||
| Documento | Scopo |
|
||||
|---|---|
|
||||
| [`specs/2026-04-16-schema-sviluppo-rev04.docx`](specs/2026-04-16-schema-sviluppo-rev04.docx) | Schema sviluppo SW TieFlow rev04-2026 fornito dal committente. |
|
||||
|
||||
## Storico
|
||||
|
||||
| Documento | Scopo |
|
||||
|---|---|
|
||||
| [`archive/2026-02-06-piano-implementazione-v1.md`](archive/2026-02-06-piano-implementazione-v1.md) | Piano implementazione V1.0.0 originale (riferimento storico). |
|
||||
|
||||
## Ulteriori riferimenti nel repo
|
||||
|
||||
- [`/CLAUDE.md`](../CLAUDE.md) — guidance per Claude Code (architettura, comandi, pattern critici).
|
||||
- [`/README.md`](../README.md) — entry point del progetto.
|
||||
Binary file not shown.
+89
-3
@@ -37,6 +37,8 @@ TieMeasureFlow is a web-based measurement management system that enables teams t
|
||||
| **Subtask** | Individual measurement point with tolerance limits (UTL/UWL/LWL/LTL) |
|
||||
| **Measurement** | Individual recorded value with automatic pass/fail/warning status |
|
||||
| **Lot/Serial** | Traceability fields linking measurements to physical parts |
|
||||
| **Station** | A physical measurement post (typically one tablet on a production line). Each station has a unique code (`STATION_CODE`) and a list of assigned recipes |
|
||||
| **Station assignment** | Many-to-many link between a station and the recipes available to the operators using that station's tablet |
|
||||
|
||||
### Architecture
|
||||
|
||||
@@ -175,23 +177,25 @@ TieMeasureFlow has four primary roles. Users can have multiple roles simultaneou
|
||||
|
||||
### Admin
|
||||
|
||||
**Purpose:** System administration and user management
|
||||
**Purpose:** System administration, user management and station deployment
|
||||
|
||||
**Permissions:**
|
||||
- Create/edit/delete users
|
||||
- Assign roles to users
|
||||
- Regenerate user API keys
|
||||
- Create/edit/delete stations and assign recipes to them
|
||||
- Configure system settings
|
||||
- Upload company logo
|
||||
- Manage CSV export settings
|
||||
|
||||
**Access:**
|
||||
- Menu: **Admin** → User Management
|
||||
- Can see: All users and system settings
|
||||
- Menu: **Admin** → Utenti / Stazioni
|
||||
- Can see: All users, all stations and system settings
|
||||
|
||||
**Typical Tasks:**
|
||||
- Onboard new users
|
||||
- Reset lost API keys
|
||||
- Roll out a new tablet: create the matching station, assign recipes, hand the `STATION_CODE` to devops
|
||||
- Configure locale/format settings
|
||||
- Upload company branding
|
||||
- Manage system access
|
||||
@@ -276,6 +280,12 @@ When you edit a recipe (add/remove tasks, change tolerances), a **new version**
|
||||
|
||||
## MeasurementTec Workflow
|
||||
|
||||
### Recipes you see are filtered by station
|
||||
|
||||
Each tablet/PC running the Flask client is configured at deployment time with a `STATION_CODE` environment variable (for example `ST-LINEA-A`). Whenever you open **Select Recipe**, the page only lists recipes that the admin has assigned to that station. If your tablet shows fewer recipes than you expect, ask the admin to assign the missing recipes to your station — see **Admin Workflow → Station Management**.
|
||||
|
||||
If the page shows "Stazione non configurata" (HTTP 503), the deployment is missing the `STATION_CODE` setting; this is a deploy-time configuration issue, not something the operator can fix from the UI.
|
||||
|
||||
### Select a Recipe
|
||||
|
||||
**Method 1: Search**
|
||||
@@ -503,6 +513,82 @@ If user loses their API key:
|
||||
4. New key is generated and displayed
|
||||
5. Provide new key to user (display once only)
|
||||
|
||||
### Station Management
|
||||
|
||||
Stations are how TieMeasureFlow enforces "this tablet is responsible for these measurements". Each physical measurement post in the shop floor is modelled as a station; each tablet's Flask client identifies itself through a `STATION_CODE` env var that must match a station's code in the database.
|
||||
|
||||
The page is reachable from the navbar entry **"Stazioni"** (workstation icon) for any user with the admin flag, or directly at `/admin/stations`.
|
||||
|
||||
#### Mental model
|
||||
|
||||
- **One station = one tablet/PC** in the shop floor. The station's identity (`STATION_CODE`) is configured **once at deploy time** in the tablet's `.env`, never changed at runtime.
|
||||
- A station has a list of **assigned recipes**. The operator using that tablet sees exactly that list — no more, no less.
|
||||
- A recipe can be assigned to several stations (e.g. a calibration recipe everyone needs).
|
||||
- A station can be temporarily **disabled** (`active = false`) to take a line offline without losing its history; tablets pointing at a disabled station get HTTP 404 from the recipe endpoint.
|
||||
|
||||
#### Create a Station
|
||||
|
||||
1. Navigate to **Admin** → **Stazioni**
|
||||
2. Click **Nuova Stazione**
|
||||
3. Fill in the modal:
|
||||
- **Codice** (required, unique): `ST-LINEA-A`. This is the value the tablet will set as `STATION_CODE` in its `.env`. Use ASCII letters, digits and hyphens only — no spaces, no accented characters. Once created the code cannot be changed (changing it would break every tablet pointing at it).
|
||||
- **Nome** (required): human-readable description, e.g. `Linea A — Tornitura alberi`.
|
||||
- **Postazione** (optional): physical location, e.g. `Reparto 2 — Cella 3`.
|
||||
- **Note** (optional): free text, useful for tracking who set up the station.
|
||||
- **Attiva** (default checked): uncheck only when retiring the station.
|
||||
4. Click **Crea Stazione**
|
||||
|
||||
Naming convention: prefix every station code with `ST-` and use a stable identifier that survives shop-floor reorganisations.
|
||||
|
||||
#### Assign Recipes to a Station
|
||||
|
||||
1. From the stations table, click the **checklist icon** on the row of the target station
|
||||
2. The "Ricette Assegnate" modal opens with two columns:
|
||||
- **Ricette disponibili** (left): every recipe in the system not yet assigned to this station. Each row has an inline **+ Assegna** button that immediately moves the recipe to the right column.
|
||||
- **Assegnate alla stazione** (right): the list the operator at this station's tablet will see. The **X** button removes the assignment.
|
||||
3. Use the search field at the top to narrow either column by recipe code or name
|
||||
4. Empty-state hints tell you why a column is empty:
|
||||
- "Tutte le ricette sono già assegnate" — nothing more to assign
|
||||
- "Nessuna ricetta nel sistema" — create at least one recipe first (Maker workflow)
|
||||
- "Nessun risultato per il filtro" — clear the search
|
||||
|
||||
The assignment audit trail (`assigned_by`, `assigned_at`) is stored on the server but not currently shown in the UI.
|
||||
|
||||
#### Edit a Station
|
||||
|
||||
1. Click the **pencil icon** (or the row itself) for the target station
|
||||
2. Update **Nome**, **Postazione**, **Note** or the **Attiva** flag
|
||||
3. Click **Salva Modifiche**
|
||||
|
||||
The **Codice** field is read-only on edit because the value is contractually bound to the `STATION_CODE` set on already-deployed tablets. To rename a station you must delete it and create a new one with the new code, then update every affected tablet's `.env`.
|
||||
|
||||
#### Delete a Station
|
||||
|
||||
1. Click the **trash icon** for the station
|
||||
2. Confirm in the modal
|
||||
|
||||
Deletion cascades to **all recipe assignments** for that station. Existing measurements collected by tablets at that station are not affected (measurements are linked to recipe versions, not stations).
|
||||
|
||||
#### The `ST-DEFAULT` station
|
||||
|
||||
The first time `/api/setup/seed` runs, it creates a station called `ST-DEFAULT` and assigns every demo recipe to it. This is intended for single-tablet demos and dev setups where no per-station segmentation is needed.
|
||||
|
||||
In multi-station production deployments you should either:
|
||||
|
||||
- Delete `ST-DEFAULT` once your real stations are configured, or
|
||||
- Disable it (`Attiva` off), to prevent a misconfigured tablet from accidentally inheriting the default assignment set.
|
||||
|
||||
#### Tablet deployment cheat sheet
|
||||
|
||||
For each new physical tablet:
|
||||
|
||||
1. Admin creates the station in the UI (e.g. `ST-LINEA-A`)
|
||||
2. Admin assigns the relevant recipes
|
||||
3. Devops sets `STATION_CODE=ST-LINEA-A` in the tablet's `.env`
|
||||
4. Tablet container starts; the operator opens **Measure → Select Recipe** and sees the curated list
|
||||
|
||||
If step 3 is missed, **Select Recipe** shows the page "Stazione non configurata" — this is the intentional fail-fast behaviour to prevent a tablet from silently falling back to the wrong recipe set.
|
||||
|
||||
### System Settings
|
||||
|
||||
#### Configure CSV Export
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
# Roadmap TieMeasureFlow — V2.0.0 → V1.1.0 (rev04 / M1 demo cliente)
|
||||
|
||||
> Aggiornare ad ogni Fase chiusa.
|
||||
|
||||
## Riferimenti
|
||||
|
||||
- Master plan dettagliato: [`../superpowers/plans/2026-04-17-rev04-master-roadmap.md`](../superpowers/plans/2026-04-17-rev04-master-roadmap.md)
|
||||
- Spec sorgente: [`../specs/2026-04-16-schema-sviluppo-rev04.docx`](../specs/2026-04-16-schema-sviluppo-rev04.docx)
|
||||
- Stato corrente: [`STATO_PROGETTO.md`](STATO_PROGETTO.md)
|
||||
|
||||
## Strategia: due milestone
|
||||
|
||||
| Milestone | Scope | Obiettivo |
|
||||
|---|---|---|
|
||||
| **M1 — Demo cliente** | Fasi 1-5 + deploy "demo" | Sistema testabile end-to-end col cliente per raccogliere feedback |
|
||||
| **M2 — Produzione** | Fasi 6-7 + correzioni post-feedback + GAIA live | Rollout su tablet/PC reali |
|
||||
|
||||
## Stato Fasi (M1)
|
||||
|
||||
| Fase | Scope | Stato | Branch / Commit |
|
||||
|---|---|---|---|
|
||||
| **1** | Stazioni + identità per-tablet | ✅ **COMPLETATA** | `V2.0.0` (merge `ea8e468` da `feature/rev04-phase1-stations`) |
|
||||
| 2 | Ruolo Capoturno (Supervisor) + override token breve | ⏳ Da iniziare | — |
|
||||
| 3 | Editor ricetta a blocchi (preparation + measurement) | ⏳ Da iniziare | — |
|
||||
| 4 | Workflow operatore (retry/timer/autologout/avvio produzione) | ⏳ Da iniziare | — |
|
||||
| 5 (M1) | `ImportOnlyGaiaClient` + UI import dati cliente reali | ⏳ Da iniziare | — |
|
||||
| Deploy M1 | VPS demo (compose + Traefik + LE, no registry) | ⏳ Da iniziare | — |
|
||||
|
||||
## Stato Fasi (M2)
|
||||
|
||||
| Fase | Scope | Stato |
|
||||
|---|---|---|
|
||||
| 5 (M2) | GAIA reale (protocollo TBD, polling, comandi produzione) | ⏳ Bloccata da decisioni cliente (D-0.1, D-0.2) |
|
||||
| 6 | Deploy B industriale (registry privato + Watchtower + STATION_ID per-tablet + CI release) | ⏳ Pianificata |
|
||||
| 7 | Hardening, security review, E2E sito pilota, docs aggiornati, i18n delta | ⏳ Pianificata |
|
||||
|
||||
## Decisioni aperte (bloccanti per M2 / future fasi)
|
||||
|
||||
Da: master plan §0 "Precondizioni e Decisioni Aperte". Da risolvere col cliente prima della Fase 5/6.
|
||||
|
||||
| ID | Decisione | Stato | Bloccante per |
|
||||
|---|---|---|---|
|
||||
| D-0.1 | Protocollo integrazione GAIA (REST / DB shared / OPC-UA / file) | **Aperta** | Fase 5 reale (M2) |
|
||||
| D-0.2 | Credenziali e rete GAIA (VPN / firewall / whitelist IP) | **Aperta** | Fase 5 reale (M2) |
|
||||
| D-0.3 | Target hardware "tablet" (Windows / Linux industriale / Android) | **Aperta** | Fase 6 (deploy B) |
|
||||
| D-0.4 | Cicalino/luce avviso (audio HTML5 / hardware USB / entrambi) | **Rimandata a M2** | Fase 4 finale |
|
||||
| D-0.5 | Parametri runtime modificabili vs versione immutabile | **Aperta** (raccomandato B: separare volatili) | Fase 3 |
|
||||
| D-0.6 | Auth capoturno durante override (modale / PIN / RFID) | **Aperta** | Fase 2 |
|
||||
| D-0.7 | Timeout auto-logout | **Risolta** | — |
|
||||
| D-0.8 | Naming ruolo capoturno | **Proposta:** `Supervisor` | Fase 2 |
|
||||
| D-0.9 | Tag versione immagine docker | **Proposta:** SemVer + `latest` | Fase 6 |
|
||||
| D-0.10 | Registry esposto su Internet o solo VPN | **Proposta:** solo VPN cliente | Fase 6 |
|
||||
|
||||
## Tech debt da chiudere
|
||||
|
||||
| Item | Priorità | Note |
|
||||
|---|---|---|
|
||||
| 3 test backend pre-esistenti rotti (`test_recipes`, `test_tasks`) | Media | Investigare prima di Fase 3 (toccano recipe + task router). |
|
||||
| 1 test client pre-esistente rotto (`test_save_measurement_proxy`) | Bassa | Probabilmente CSRF/payload. Risolvere con Fase 4. |
|
||||
| Pagina `task_complete` riepilogo: utente segnala riga vuota in alcuni scenari | Media | Da debuggare (rendering corretto via curl ma utente vede vuoto in browser, possibile interazione con sessione lot/serial). |
|
||||
| `.env` rename a convenzione spec (SERVICE_NAME, SERVICE_DOMAIN, API_KEY) | Bassa | Rinviato (impatto deploy). |
|
||||
| Header `X-API-Key` rename a `X-Api-Key` | Bassa | Vedere se M2 lo richiede. |
|
||||
| Envelope risposta `{success,data,error}` | Bassa | Eventuale API v2 in M2. |
|
||||
| `Dockerfile.frontend`: `pybabel compile` via `uv run` non testato in build reale | Alta | Verificare al primo `docker compose build`. |
|
||||
| Smoke test in container Docker (non solo locale uvicorn+gunicorn) | Alta | Validare che i Dockerfile riscritti con `uv` buildino e girino correttamente prima di chiudere V2.0.0. |
|
||||
|
||||
## Open per scelta utente prima della prossima sessione
|
||||
|
||||
1. **Quale fase iniziare adesso?** Opzioni: Fase 2 (Supervisor — sblocca workflow), Fase 3 (block editor — independente), Fase 5 (import GAIA dati reali — sblocca demo).
|
||||
2. **Revisione decisioni aperte col cliente** — D-0.1 / D-0.2 / D-0.3 / D-0.6 prima di pianificare Fase 5 e 6.
|
||||
3. **Smoke test Docker** della nuova struttura V2.0.0 (`docker compose -f docker-compose.dev.yml up --build`) per validare i Dockerfile riscritti.
|
||||
4. **Test di carico** (k6/locust) a 20 VU su `/measure/save-measurement` per validare la scalatura worker (capacità annunciata: 20-30 tablet contemporanei).
|
||||
|
||||
## Stima tempi residui M1 (post-Fase 1)
|
||||
|
||||
| Task | Stima full-time |
|
||||
|---|---|
|
||||
| Fase 2 — Supervisor + override | 1 settimana |
|
||||
| Fase 3 — Block editor | 1.5 settimane |
|
||||
| Fase 4 — Workflow operatore | 2 settimane |
|
||||
| Fase 5 (M1) — Import-only GAIA | 1 settimana |
|
||||
| Deploy M1 demo | 0.5 settimane |
|
||||
| **Totale M1 residuo** | **~6 settimane** |
|
||||
|
||||
## Stima tempi M2 (dopo feedback)
|
||||
|
||||
| Task | Stima |
|
||||
|---|---|
|
||||
| Aggiustamenti post-feedback | variabile (1-2 sett.) |
|
||||
| Fase 5 reale GAIA | 1-2 settimane |
|
||||
| Fase 6 deploy B | 1 settimana |
|
||||
| Fase 7 hardening | 1-2 settimane |
|
||||
| **Totale M2** | **~4-7 settimane** |
|
||||
|
||||
**Totale fino a produzione:** ~10-13 settimane full-time da oggi (2026-04-25), assumendo decisioni aperte risolte in tempo utile.
|
||||
@@ -0,0 +1,145 @@
|
||||
# Stato Progetto TieMeasureFlow — V2.0.0
|
||||
|
||||
> Snapshot al 2026-04-27. Aggiornare ad ogni milestone.
|
||||
|
||||
## Versione corrente
|
||||
|
||||
**V2.0.0** (in sviluppo, branch `V2.0.0` come default su `git.tielogic.xyz`).
|
||||
|
||||
Versione precedente di produzione: `V1.0.7`.
|
||||
|
||||
## Sintesi esecutiva
|
||||
|
||||
Il sistema base (V1.0.7) è completo e collaudato: ricette, task, misurazioni, SPC, report PDF, gestione utenti, dashboard metrologist. La V2.0.0 in corso aggiunge il primo blocco della migrazione **rev04** (stazioni per-tablet) e ristruttura l'intero monorepo secondo lo standard `python-project-spec-design.md` (uv + `src/backend/` + `src/frontend/flask_app/`).
|
||||
|
||||
## Cosa funziona oggi (V2.0.0 — branch corrente)
|
||||
|
||||
### Funzionalità ereditate da V1.0.7
|
||||
- Autenticazione username/password + API key per-utente, ruoli combinabili (Maker, MeasurementTec, Metrologist) + flag `is_admin`.
|
||||
- Recipe versioning copy-on-write: una nuova versione si crea solo se la corrente ha già measurements; altrimenti update in-place.
|
||||
- Editor ricette (Maker) con annotation editor Fabric.js (~1200 LOC, collaudato su tablet).
|
||||
- Workflow operatore tablet: select_recipe → task_list → task_execute → task_complete, con barcode scanner e numpad touch (input USB calibro con burst detection).
|
||||
- Calcolo pass/fail con limiti UTL/UWL/LWL/LTL.
|
||||
- Dashboard SPC: capability (Cp/Cpk/Pp/Ppk), control chart (UCL/LCL = mean ± 3σ), istogramma con curva normale, calcoli puro stdlib (no numpy).
|
||||
- Report PDF (WeasyPrint + Kaleido SVG).
|
||||
- Setup page protetta da `SETUP_PASSWORD` per inizializzazione DB e seed.
|
||||
- i18n IT/EN (Flask-Babel + Alpine.js JSON).
|
||||
- Tema light/dark via `Alpine.store('theme')` + localStorage.
|
||||
|
||||
### Aggiunte V2.0.0 (rev04 Fase 1 — Stazioni per-tablet)
|
||||
- Tabelle `stations` + `station_recipe_assignments` (Alembic migration `002_add_stations.py`).
|
||||
- Modelli ORM: `Station`, `StationRecipeAssignment` con vincolo unique `(station_id, recipe_id)`.
|
||||
- Schemas Pydantic: `StationCreate/Update/Response`, `StationRecipeAssignmentCreate/Response`, `RecipeSummary`.
|
||||
- Service `station_service` (CRUD + assegnazioni + cascade delete).
|
||||
- Router `/api/stations` con CRUD admin + endpoint operatore `GET /api/stations/by-code/{code}/recipes`.
|
||||
- Seed automatico `ST-DEFAULT` con tutte le ricette esistenti (idempotente).
|
||||
- Variabile env client `STATION_CODE` letta da `Config`, helper `APIClient.get_station_recipes()`.
|
||||
- Filtro `select_recipe`: il client mostra solo le ricette assegnate alla propria stazione, errore se `STATION_CODE` non configurato.
|
||||
- **GUI admin completa** in `/admin/stations`: tabella con search, modal create/edit, modal gestione assegnazioni ricette, conferma eliminazione, link in navbar (desktop + mobile).
|
||||
- 47 nuovi test (32 server + 15 client) tutti pass.
|
||||
|
||||
### Aggiunte V2.0.0 (performance + multi-utente)
|
||||
- Gunicorn 5 workers × 4 thread (gthread) — capacità ~20 richieste concorrenti Flask, regge 20+ tablet.
|
||||
- Uvicorn 4 workers + `--proxy-headers --forwarded-allow-ips='*'`.
|
||||
- Rate limit middleware: identificazione IP reale via `X-Forwarded-For` → `X-Real-IP` → `request.client.host`.
|
||||
- Rate limit general 100 → 300 req/min/IP (per-tablet ora, non più condiviso).
|
||||
- Flask `ProxyFix(x_for=1, x_proto=1, x_host=1)` per IP reale dietro Nginx.
|
||||
- `APIClient` propaga `X-Forwarded-For` + `X-Real-IP` (sia JSON che multipart).
|
||||
- 12 test aggiuntivi (7 server + 5 client).
|
||||
|
||||
### Aggiunte V2.0.0 (struttura monorepo)
|
||||
- `pyproject.toml` unico con extra `server`/`client`/`dev`. Niente più `requirements.txt`.
|
||||
- `uv.lock` (77 pacchetti) + `.python-version` (3.11) committati per build riproducibili.
|
||||
- Layout `src/backend/` + `src/frontend/flask_app/` (vedi sotto).
|
||||
- `Dockerfile` (root) + `Dockerfile.frontend` riscritti con `uv sync --frozen --no-dev --extra server|client`.
|
||||
- `docker-compose.{dev,}.yml` con build context `.`.
|
||||
- Alembic env.py aggiunge project root a `sys.path`; `script_location = %(here)s` resta valido.
|
||||
- `.dockerignore` aggiornato.
|
||||
|
||||
### Hardening post-restructure (smoke test 2026-04-26)
|
||||
|
||||
Sequenza di smoke test in locale (uvicorn + gunicorn + MySQL Docker) ha fatto emergere quattro regressioni che sarebbero rimaste invisibili al test suite:
|
||||
|
||||
- **`src/backend/config.py`**: `env_file` era cwd-relative (`../../.env`). Rotto fuori da `src/backend/`. Risolto con percorso assoluto `Path(__file__).resolve().parents[2] / ".env"`.
|
||||
- **`src/backend/models/orm/__init__.py`**: `Station` e `StationRecipeAssignment` non erano esportati, quindi `Base.metadata.create_all` non creava le tabelle stations. Aggiunti agli import.
|
||||
- **`.env.example`**: `UPLOAD_DIR=server/uploads` era residuo della vecchia struttura → file landavano fuori dall'albero di progetto. Aggiornato a `UPLOAD_DIR=uploads`.
|
||||
- **Apostrofi italiani in template Alpine** (`l'utente`, `nell'eliminazione`, `nell'assegnazione`): chiudevano prematuramente JS string literals dentro `x-text` e blocchi `<script>`. Riscritti con delimitatori `"..."` o riformulazione testuale.
|
||||
|
||||
Inoltre **UX rework** della modale assegnazione ricette su `/admin/stations`: dropdown sostituita da layout a 2 colonne (disponibili / assegnate) con bottone inline `+ Assegna`, search filter, empty state esplicativo (mostrava silenziosamente lista vuota se tutte le ricette erano già assegnate).
|
||||
|
||||
Test guard aggiunto: `test_template_js_syntax.py` valida ogni inline `<script>` E ogni espressione Alpine (`x-*`, `@*`, `:*`) della pagina con `node --check`. Cattura automaticamente il bug-class apostrofo. Skip se Node non è installato.
|
||||
|
||||
## Layout repository (V2.0.0)
|
||||
|
||||
```
|
||||
TieMeasureFlow/
|
||||
├── pyproject.toml + uv.lock + .python-version
|
||||
├── Dockerfile (backend) + Dockerfile.frontend
|
||||
├── docker-compose.dev.yml + docker-compose.yml
|
||||
├── nginx/
|
||||
├── uploads/ # volume Docker
|
||||
├── docs/ # raggruppata e indicizzata
|
||||
│ ├── README.md (indice)
|
||||
│ ├── API.md / DEPLOYMENT.md / USER_GUIDE.md / I18N_SETUP.md
|
||||
│ ├── architecture/ # questo file + ROADMAP.md
|
||||
│ ├── archive/ # piani storici
|
||||
│ ├── specs/ # spec esterne (.docx)
|
||||
│ └── superpowers/plans/ # piani TDD dettagliati
|
||||
└── src/
|
||||
├── backend/
|
||||
│ ├── main.py / config.py / database.py
|
||||
│ ├── api/{routers,middleware}/
|
||||
│ ├── models/{orm,api}/
|
||||
│ ├── services/
|
||||
│ ├── migrations/
|
||||
│ ├── templates/
|
||||
│ └── tests/
|
||||
└── frontend/
|
||||
└── flask_app/
|
||||
├── app.py / config.py / compile_translations.py
|
||||
├── blueprints/ (auth, maker, measure, statistics, admin)
|
||||
├── services/ (api_client.py)
|
||||
├── templates/ + static/ + translations/
|
||||
└── tests/
|
||||
```
|
||||
|
||||
## Smoke test status
|
||||
|
||||
Validazione end-to-end in locale (2026-04-26):
|
||||
|
||||
- ✅ MySQL container Docker up, schema creato, alembic stamp head OK
|
||||
- ✅ uvicorn `--reload` su :8000, `/api/health` risponde
|
||||
- ✅ Seed `/api/setup/seed` con `SETUP_PASSWORD=adriano77` → admin + 4 utenti demo + DEMO-001 + ST-DEFAULT con assegnazione automatica
|
||||
- ✅ Login `admin/admin123` via web, sessione persistente
|
||||
- ✅ `/admin/stations`: tabella, modal create/edit, modal gestione assegnazioni a 2 colonne con search, eliminazione con cascade
|
||||
- ✅ `/admin/users`, `/maker/recipes`, `/measure/select` (filtrato per stazione), `/statistics/dashboard`
|
||||
- ✅ Workflow MeasurementTec end-to-end: select_recipe → task_list → task_execute → task_complete (riepilogo con misure)
|
||||
- ✅ Hot reload Flask + uvicorn `--reload` + Tailwind watch attivi durante lo sviluppo
|
||||
|
||||
## Test status
|
||||
| Backend (`src/backend/tests/`) | 127 | 3 | Fail pre-esistenti: `test_recipes` (2) + `test_tasks` (1). Nessuno introdotto dalla V2.0.0. |
|
||||
| Frontend (`src/frontend/flask_app/tests/`) | 46 | 1 | +2 test post-restructure (`test_template_js_syntax.py`). Fail pre-esistente: `test_save_measurement_proxy`. |
|
||||
| **Totale** | **173** | **4** | Tutti i fallimenti tracciati come tech debt da risolvere. |
|
||||
|
||||
## Stack confermato
|
||||
|
||||
- **Backend:** FastAPI + SQLAlchemy 2.0 async + MySQL 8 + Alembic + Pydantic v2 + WeasyPrint + Plotly/Kaleido.
|
||||
- **Frontend:** Flask + Jinja2 + Alpine.js + TailwindCSS + Fabric.js 5.3.1 + html5-qrcode + Plotly.js + Flask-Babel.
|
||||
- **Deploy:** Docker Compose. Dev = Nginx; Prod = Traefik + Let's Encrypt SSL.
|
||||
- **Tooling:** uv (package mgmt), pytest + pytest-asyncio + httpx + aiosqlite (test).
|
||||
|
||||
## Decisioni architetturali rilevanti
|
||||
|
||||
| Decisione | Stato | Note |
|
||||
|---|---|---|
|
||||
| Frontend Flask invece di React (deroga vs spec §8) | **Confermata** | Tablet UX server-side, USB calipers/barcode, Fabric.js editor, i18n Babel collaudato. Vedi conversazione 2026-04-25. |
|
||||
| NATS messaging (spec §7) | **Skippato** | Monorepo single-host, no microservizi. Nessuno stub `nats_client/` creato. |
|
||||
| Envelope risposta `{success,data,error}` (spec §6) | **Rimandato** | Costo 4-5gg refactor + rotture client. Eventuale v2 API in M2. |
|
||||
| Header `X-API-Key` vs spec `X-Api-Key` | **Mantenuto attuale** | Rinominare costa 50+ punti di codice + breaking per deploy. Rivedere in M2. |
|
||||
| Variabili `.env` (DB_HOST, SERVER_PORT, ...) | **Mantenute attuali** | Rename a SERVICE_NAME/SERVICE_DOMAIN/API_KEY rinviato (impatta deploy esistenti). |
|
||||
|
||||
## Branch git
|
||||
|
||||
- **Default:** `V2.0.0` (lavoro corrente)
|
||||
- **Mantenuti:** `V1.0.0` … `V1.0.7` (release branches storiche)
|
||||
- **Mergiato e chiuso:** `feature/rev04-phase1-stations` (in `V2.0.0` con commit `ea8e468`)
|
||||
Binary file not shown.
@@ -0,0 +1,75 @@
|
||||
[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",
|
||||
"pdfplumber>=0.10.0",
|
||||
"httpx>=0.27.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,16 +1,17 @@
|
||||
"""FastAPI middleware for TieMeasureFlow."""
|
||||
from middleware.api_key import (
|
||||
from src.backend.api.middleware.api_key import (
|
||||
get_current_user,
|
||||
require_role,
|
||||
require_admin,
|
||||
require_maker,
|
||||
require_measurement_tec,
|
||||
require_metrologist,
|
||||
require_supervisor,
|
||||
require_admin_user,
|
||||
)
|
||||
from middleware.logging import AccessLogMiddleware
|
||||
from middleware.rate_limit import RateLimitMiddleware
|
||||
from middleware.security_headers import SecurityHeadersMiddleware
|
||||
from src.backend.api.middleware.logging import AccessLogMiddleware
|
||||
from src.backend.api.middleware.rate_limit import RateLimitMiddleware
|
||||
from src.backend.api.middleware.security_headers import SecurityHeadersMiddleware
|
||||
|
||||
__all__ = [
|
||||
"get_current_user",
|
||||
@@ -19,6 +20,7 @@ __all__ = [
|
||||
"require_maker",
|
||||
"require_measurement_tec",
|
||||
"require_metrologist",
|
||||
"require_supervisor",
|
||||
"require_admin_user",
|
||||
"AccessLogMiddleware",
|
||||
"RateLimitMiddleware",
|
||||
@@ -3,8 +3,8 @@ from fastapi import Depends, HTTPException, Request, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database import get_db
|
||||
from models.user import User
|
||||
from src.backend.database import get_db
|
||||
from src.backend.models.orm.user import User
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
@@ -68,4 +68,5 @@ def require_admin():
|
||||
require_maker = require_role("Maker")
|
||||
require_measurement_tec = require_role("MeasurementTec")
|
||||
require_metrologist = require_role("Metrologist")
|
||||
require_supervisor = require_role("Supervisor")
|
||||
require_admin_user = require_admin()
|
||||
@@ -6,8 +6,8 @@ from fastapi import Request, Response
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from sqlalchemy import insert
|
||||
|
||||
from database import async_session_factory
|
||||
from models.access_log import AccessLog
|
||||
from src.backend.database import async_session_factory
|
||||
from src.backend.models.orm.access_log import AccessLog
|
||||
|
||||
|
||||
class AccessLogMiddleware(BaseHTTPMiddleware):
|
||||
@@ -11,7 +11,7 @@ from fastapi import Request, Response
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
from config import settings
|
||||
from src.backend.config import settings
|
||||
|
||||
|
||||
class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||
@@ -32,6 +32,25 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||
self._general_requests: dict[str, list[float]] = defaultdict(list)
|
||||
self._request_count = 0 # Counter for triggering eviction
|
||||
|
||||
@staticmethod
|
||||
def _client_ip(request: Request) -> str:
|
||||
"""Resolve the originating client IP, honoring proxy headers.
|
||||
|
||||
Order of precedence: ``X-Forwarded-For`` (first hop), ``X-Real-IP``,
|
||||
``request.client.host``. Required because Nginx and the Flask client
|
||||
sit between the tablet and the API; without parsing these headers
|
||||
every tablet shares one bucket.
|
||||
"""
|
||||
xff = request.headers.get("x-forwarded-for")
|
||||
if xff:
|
||||
first = xff.split(",")[0].strip()
|
||||
if first:
|
||||
return first
|
||||
real = request.headers.get("x-real-ip")
|
||||
if real:
|
||||
return real.strip()
|
||||
return request.client.host if request.client else "unknown"
|
||||
|
||||
def _clean_window(self, timestamps: list[float], now: float) -> list[float]:
|
||||
"""Remove timestamps outside the current sliding window."""
|
||||
cutoff = now - self.WINDOW_SECONDS
|
||||
@@ -68,7 +87,7 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||
return True, 0
|
||||
|
||||
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
client_ip = self._client_ip(request)
|
||||
now = time.time()
|
||||
path = request.url.path
|
||||
|
||||
+9
-7
@@ -8,18 +8,20 @@ from typing import Callable
|
||||
from fastapi import Request, Response
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
from config import settings
|
||||
from src.backend.config import settings
|
||||
|
||||
# Content Security Policy - allows CDN resources used by the client
|
||||
# Content Security Policy - same-origin only: every third-party library now ships with
|
||||
# the app (see frontend static/vendor/), so no CDN host needs allowing. The install runs
|
||||
# on an isolated shop-floor network where an outbound fetch would simply fail.
|
||||
# Note: 'unsafe-eval' required for Plotly.js runtime evaluation in SPC charts
|
||||
CSP = (
|
||||
"default-src 'self'; "
|
||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval' "
|
||||
"https://cdn.tailwindcss.com https://cdn.jsdelivr.net https://cdn.plot.ly; "
|
||||
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; "
|
||||
"font-src 'self' https://fonts.gstatic.com; "
|
||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; "
|
||||
"style-src 'self' 'unsafe-inline'; "
|
||||
"font-src 'self'; "
|
||||
"img-src 'self' data: blob:; "
|
||||
"connect-src 'self'"
|
||||
"connect-src 'self'; "
|
||||
"worker-src 'self' blob:"
|
||||
)
|
||||
|
||||
|
||||
@@ -2,16 +2,16 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database import get_db
|
||||
from middleware.api_key import get_current_user
|
||||
from models.user import User
|
||||
from schemas.user import (
|
||||
from src.backend.database import get_db
|
||||
from src.backend.api.middleware.api_key import get_current_user
|
||||
from src.backend.models.orm.user import User
|
||||
from src.backend.models.api.user import (
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
UserProfileUpdate,
|
||||
UserResponse,
|
||||
)
|
||||
from services.auth_service import authenticate_user, login_user, logout_user
|
||||
from src.backend.services.auth_service import authenticate_user, login_user, logout_user
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""File upload/download/delete router for images and PDFs."""
|
||||
import os
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
@@ -7,9 +7,11 @@ from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
|
||||
from fastapi.responses import FileResponse
|
||||
from PIL import Image
|
||||
|
||||
from config import settings
|
||||
from middleware.api_key import get_current_user, require_maker
|
||||
from models.user import User
|
||||
from src.backend.config import settings
|
||||
from src.backend.api.middleware.api_key import get_current_user, require_maker
|
||||
from src.backend.models.orm.user import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/files", tags=["files"])
|
||||
|
||||
@@ -65,6 +67,37 @@ def sanitize_filename(filename: str) -> str:
|
||||
return filename
|
||||
|
||||
|
||||
def resolve_upload_path(file_path: str) -> Path:
|
||||
"""Resolve a user-supplied relative path inside the uploads directory.
|
||||
|
||||
Raises:
|
||||
HTTPException: 404 if the path escapes the uploads directory,
|
||||
cannot be resolved, or does not point to an existing file.
|
||||
"""
|
||||
try:
|
||||
full_path = (settings.upload_path / file_path).resolve()
|
||||
if not full_path.is_relative_to(settings.upload_path.resolve()):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied",
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except (OSError, RuntimeError, ValueError):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="File not found",
|
||||
)
|
||||
|
||||
if not full_path.exists() or not full_path.is_file():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="File not found",
|
||||
)
|
||||
|
||||
return full_path
|
||||
|
||||
|
||||
def generate_thumbnail(image_path: Path, thumbnail_path: Path, max_size: tuple[int, int] = (200, 200)):
|
||||
"""Generate a thumbnail for an image."""
|
||||
try:
|
||||
@@ -73,7 +106,7 @@ def generate_thumbnail(image_path: Path, thumbnail_path: Path, max_size: tuple[i
|
||||
img.save(thumbnail_path, quality=85)
|
||||
except Exception as e:
|
||||
# If thumbnail generation fails, we continue without it
|
||||
print(f"Thumbnail generation failed: {e}")
|
||||
logger.warning("Thumbnail generation failed for %s: %s", image_path, e)
|
||||
|
||||
|
||||
@router.post("/upload")
|
||||
@@ -164,30 +197,7 @@ async def get_file(
|
||||
|
||||
Requires authentication but no specific role.
|
||||
"""
|
||||
# Construct full path
|
||||
full_path = settings.upload_path / file_path
|
||||
|
||||
# Security: ensure path is within uploads directory
|
||||
try:
|
||||
full_path = full_path.resolve()
|
||||
if not str(full_path).startswith(str(settings.upload_path.resolve())):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied",
|
||||
)
|
||||
except Exception:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="File not found",
|
||||
)
|
||||
|
||||
# Check if file exists
|
||||
if not full_path.exists() or not full_path.is_file():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="File not found",
|
||||
)
|
||||
|
||||
full_path = resolve_upload_path(file_path)
|
||||
return FileResponse(full_path)
|
||||
|
||||
|
||||
@@ -200,29 +210,7 @@ async def delete_file(
|
||||
|
||||
Also deletes associated thumbnail if it exists.
|
||||
"""
|
||||
# Construct full path
|
||||
full_path = settings.upload_path / file_path
|
||||
|
||||
# Security: ensure path is within uploads directory
|
||||
try:
|
||||
full_path = full_path.resolve()
|
||||
if not str(full_path).startswith(str(settings.upload_path.resolve())):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied",
|
||||
)
|
||||
except Exception:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="File not found",
|
||||
)
|
||||
|
||||
# Check if file exists
|
||||
if not full_path.exists() or not full_path.is_file():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="File not found",
|
||||
)
|
||||
full_path = resolve_upload_path(file_path)
|
||||
|
||||
# Delete thumbnail if it exists
|
||||
if full_path.parent.name != "thumbnails":
|
||||
@@ -8,23 +8,23 @@ from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy import and_, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database import get_db
|
||||
from middleware.api_key import (
|
||||
from src.backend.database import get_db
|
||||
from src.backend.api.middleware.api_key import (
|
||||
get_current_user,
|
||||
require_measurement_tec,
|
||||
require_metrologist,
|
||||
)
|
||||
from models.measurement import Measurement
|
||||
from models.recipe import RecipeVersion
|
||||
from models.setting import SystemSetting
|
||||
from models.user import User
|
||||
from schemas.measurement import (
|
||||
from src.backend.models.orm.measurement import Measurement
|
||||
from src.backend.models.orm.recipe import RecipeVersion
|
||||
from src.backend.models.orm.setting import SystemSetting
|
||||
from src.backend.models.orm.user import User
|
||||
from src.backend.models.api.measurement import (
|
||||
MeasurementBatchCreate,
|
||||
MeasurementCreate,
|
||||
MeasurementListResponse,
|
||||
MeasurementResponse,
|
||||
)
|
||||
from services.measurement_service import save_measurement
|
||||
from src.backend.services.measurement_service import save_measurement
|
||||
|
||||
router = APIRouter(prefix="/api/measurements", tags=["measurements"])
|
||||
|
||||
@@ -46,6 +46,8 @@ async def create_measurement(
|
||||
lot_number=data.lot_number,
|
||||
serial_number=data.serial_number,
|
||||
input_method=data.input_method,
|
||||
input_duration_ms=data.input_duration_ms,
|
||||
production_run_id=data.production_run_id,
|
||||
)
|
||||
return MeasurementResponse.model_validate(measurement)
|
||||
except ValueError as e:
|
||||
@@ -74,6 +76,8 @@ async def create_measurement_batch(
|
||||
lot_number=measurement_data.lot_number,
|
||||
serial_number=measurement_data.serial_number,
|
||||
input_method=measurement_data.input_method,
|
||||
input_duration_ms=measurement_data.input_duration_ms,
|
||||
production_run_id=measurement_data.production_run_id,
|
||||
)
|
||||
measurements.append(measurement)
|
||||
return [MeasurementResponse.model_validate(m) for m in measurements]
|
||||
@@ -84,24 +88,18 @@ async def create_measurement_batch(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/", response_model=MeasurementListResponse)
|
||||
async def get_measurements(
|
||||
recipe_id: int | None = Query(None),
|
||||
version_id: int | None = Query(None),
|
||||
subtask_id: int | None = Query(None),
|
||||
measured_by: int | None = Query(None),
|
||||
lot_number: str | None = Query(None),
|
||||
serial_number: str | None = Query(None),
|
||||
date_from: datetime | None = Query(None),
|
||||
date_to: datetime | None = Query(None),
|
||||
pass_fail: str | None = Query(None, pattern="^(pass|warning|fail)$"),
|
||||
page: int = Query(1, ge=1),
|
||||
per_page: int = Query(50, ge=1, le=500),
|
||||
user: User = Depends(require_metrologist),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Query measurements with filters and pagination."""
|
||||
# Build filter conditions
|
||||
def _build_measurement_filters(
|
||||
recipe_id: int | None,
|
||||
version_id: int | None,
|
||||
subtask_id: int | None,
|
||||
measured_by: int | None,
|
||||
lot_number: str | None,
|
||||
serial_number: str | None,
|
||||
date_from: datetime | None,
|
||||
date_to: datetime | None,
|
||||
pass_fail: str | None,
|
||||
) -> list:
|
||||
"""Build SQLAlchemy filter conditions shared by list and CSV export."""
|
||||
filters = []
|
||||
if recipe_id is not None:
|
||||
# Filter by recipe via subquery on RecipeVersion
|
||||
@@ -125,6 +123,37 @@ async def get_measurements(
|
||||
filters.append(Measurement.measured_at <= date_to)
|
||||
if pass_fail is not None:
|
||||
filters.append(Measurement.pass_fail == pass_fail)
|
||||
return filters
|
||||
|
||||
|
||||
@router.get("/", response_model=MeasurementListResponse)
|
||||
async def get_measurements(
|
||||
recipe_id: int | None = Query(None),
|
||||
version_id: int | None = Query(None),
|
||||
subtask_id: int | None = Query(None),
|
||||
measured_by: int | None = Query(None),
|
||||
lot_number: str | None = Query(None),
|
||||
serial_number: str | None = Query(None),
|
||||
date_from: datetime | None = Query(None),
|
||||
date_to: datetime | None = Query(None),
|
||||
pass_fail: str | None = Query(None, pattern="^(pass|warning|fail)$"),
|
||||
page: int = Query(1, ge=1),
|
||||
per_page: int = Query(50, ge=1, le=500),
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Query measurements with filters and pagination.
|
||||
|
||||
Available to any authenticated user. The MeasurementTec workflow needs
|
||||
this endpoint to render `task_complete.html` after running through a
|
||||
recipe; the Metrologist dashboard uses the same endpoint with broader
|
||||
filters. Measurements carry no PII beyond numeric values + a recipe
|
||||
reference, so role-gating beyond authentication isn't justified.
|
||||
"""
|
||||
filters = _build_measurement_filters(
|
||||
recipe_id, version_id, subtask_id, measured_by, lot_number,
|
||||
serial_number, date_from, date_to, pass_fail,
|
||||
)
|
||||
|
||||
# Build query
|
||||
query = select(Measurement).where(and_(*filters) if filters else True)
|
||||
@@ -189,29 +218,10 @@ async def export_measurements_csv(
|
||||
decimal_setting = decimal_result.scalar_one_or_none()
|
||||
decimal_separator = decimal_setting.setting_value if decimal_setting else "."
|
||||
|
||||
# Build filter conditions (same as get_measurements)
|
||||
filters = []
|
||||
if recipe_id is not None:
|
||||
version_ids = select(RecipeVersion.id).where(
|
||||
RecipeVersion.recipe_id == recipe_id
|
||||
)
|
||||
filters.append(Measurement.version_id.in_(version_ids))
|
||||
if version_id is not None:
|
||||
filters.append(Measurement.version_id == version_id)
|
||||
if subtask_id is not None:
|
||||
filters.append(Measurement.subtask_id == subtask_id)
|
||||
if measured_by is not None:
|
||||
filters.append(Measurement.measured_by == measured_by)
|
||||
if lot_number is not None:
|
||||
filters.append(Measurement.lot_number == lot_number)
|
||||
if serial_number is not None:
|
||||
filters.append(Measurement.serial_number == serial_number)
|
||||
if date_from is not None:
|
||||
filters.append(Measurement.measured_at >= date_from)
|
||||
if date_to is not None:
|
||||
filters.append(Measurement.measured_at <= date_to)
|
||||
if pass_fail is not None:
|
||||
filters.append(Measurement.pass_fail == pass_fail)
|
||||
filters = _build_measurement_filters(
|
||||
recipe_id, version_id, subtask_id, measured_by, lot_number,
|
||||
serial_number, date_from, date_to, pass_fail,
|
||||
)
|
||||
|
||||
# Query all matching measurements
|
||||
query = select(Measurement).where(and_(*filters) if filters else True)
|
||||
@@ -0,0 +1,137 @@
|
||||
"""Production runs router - open, read, and drive the life of a production.
|
||||
|
||||
Deliberately stateless: nothing lives in process memory. With one station app
|
||||
installed per PC the database is the only shared place, and any station app must be
|
||||
able to ask "what is going on here?" and get the same answer.
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.backend.api.middleware.api_key import get_current_user
|
||||
from src.backend.database import get_db
|
||||
from src.backend.models.api.production import (
|
||||
CycleCompletePayload,
|
||||
ProductionEventResponse,
|
||||
ProductionRunCreate,
|
||||
ProductionRunResponse,
|
||||
ProductionRunWithEventsResponse,
|
||||
SupervisorAction,
|
||||
)
|
||||
from src.backend.models.orm.production import ProductionRun
|
||||
from src.backend.models.orm.user import User
|
||||
from src.backend.services import production_service
|
||||
|
||||
router = APIRouter(prefix="/api/production-runs", tags=["production"])
|
||||
|
||||
|
||||
def _as_response(run: ProductionRun) -> ProductionRunResponse:
|
||||
return ProductionRunResponse(
|
||||
**ProductionRunResponse.model_validate(run).model_dump(
|
||||
exclude={"seconds_to_next_measurement", "overdue", "server_time"}
|
||||
),
|
||||
**production_service.describe(run),
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=ProductionRunResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def open_production_run(
|
||||
data: ProductionRunCreate,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Start a production at a station."""
|
||||
run = await production_service.open_run(db, data, user)
|
||||
return _as_response(run)
|
||||
|
||||
|
||||
@router.get("/current", response_model=ProductionRunResponse | None)
|
||||
async def get_current_production_run(
|
||||
station_code: str = Query(..., min_length=1),
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""The run open at this station, or null.
|
||||
|
||||
This is what every page asks on load instead of keeping the timer in memory.
|
||||
"""
|
||||
station = await production_service.get_station_by_code(db, station_code)
|
||||
run = await production_service.get_open_run_for_station(db, station.id)
|
||||
return _as_response(run) if run is not None else None
|
||||
|
||||
|
||||
@router.get("/{run_id}", response_model=ProductionRunWithEventsResponse)
|
||||
async def get_production_run(
|
||||
run_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""A run with its full trace - the history a production did not have before."""
|
||||
run = await production_service.get_run(db, run_id)
|
||||
events = await production_service.list_run_events(db, run_id)
|
||||
return ProductionRunWithEventsResponse(
|
||||
**_as_response(run).model_dump(),
|
||||
events=[ProductionEventResponse.model_validate(e) for e in events],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{run_id}/cycle", response_model=ProductionRunResponse)
|
||||
async def complete_measurement_cycle(
|
||||
run_id: int,
|
||||
payload: CycleCompletePayload | None = None,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Record a finished measurement cycle and restart the interval."""
|
||||
run = await production_service.get_run(db, run_id)
|
||||
run = await production_service.complete_cycle(
|
||||
db, run, user, note=payload.note if payload else None,
|
||||
)
|
||||
return _as_response(run)
|
||||
|
||||
|
||||
@router.post("/{run_id}/pause", response_model=ProductionRunResponse)
|
||||
async def pause_production_run(
|
||||
run_id: int,
|
||||
action: SupervisorAction,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Fermo linea - requires a supervisor."""
|
||||
run = await production_service.get_run(db, run_id)
|
||||
supervisor = await production_service.authorise_supervisor(
|
||||
db, action.supervisor_username, action.supervisor_password,
|
||||
)
|
||||
run = await production_service.pause_run(db, run, user, supervisor, note=action.note)
|
||||
return _as_response(run)
|
||||
|
||||
|
||||
@router.post("/{run_id}/resume", response_model=ProductionRunResponse)
|
||||
async def resume_production_run(
|
||||
run_id: int,
|
||||
action: SupervisorAction,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Restart a stopped line - requires a supervisor."""
|
||||
run = await production_service.get_run(db, run_id)
|
||||
supervisor = await production_service.authorise_supervisor(
|
||||
db, action.supervisor_username, action.supervisor_password,
|
||||
)
|
||||
run = await production_service.resume_run(db, run, user, supervisor, note=action.note)
|
||||
return _as_response(run)
|
||||
|
||||
|
||||
@router.post("/{run_id}/close", response_model=ProductionRunResponse)
|
||||
async def close_production_run(
|
||||
run_id: int,
|
||||
action: SupervisorAction,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Fine produzione - requires a supervisor. Stops the timer for good."""
|
||||
run = await production_service.get_run(db, run_id)
|
||||
supervisor = await production_service.authorise_supervisor(
|
||||
db, action.supervisor_username, action.supervisor_password,
|
||||
)
|
||||
run = await production_service.close_run(db, run, user, supervisor, note=action.note)
|
||||
return _as_response(run)
|
||||
@@ -1,18 +1,20 @@
|
||||
"""Recipe router - CRUD, versioning, barcode lookup."""
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
"""Recipe router - CRUD, versioning, barcode lookup, AI parsing."""
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database import get_db
|
||||
from middleware.api_key import get_current_user, require_maker, require_measurement_tec
|
||||
from models.user import User
|
||||
from schemas.recipe import (
|
||||
from src.backend.database import get_db
|
||||
from src.backend.api.middleware.api_key import get_current_user, require_maker, require_measurement_tec
|
||||
from src.backend.models.orm.user import User
|
||||
from src.backend.models.api.recipe import (
|
||||
RecipeCreate,
|
||||
RecipeListResponse,
|
||||
RecipeResponse,
|
||||
RecipeUpdate,
|
||||
RecipeVersionResponse,
|
||||
)
|
||||
from services import recipe_service
|
||||
from src.backend.services import recipe_service
|
||||
|
||||
router = APIRouter(prefix="/api/recipes", tags=["recipes"])
|
||||
|
||||
@@ -164,3 +166,31 @@ async def get_measurement_count(
|
||||
"""
|
||||
count = await recipe_service.get_measurement_count(db, recipe_id, version_number)
|
||||
return {"recipe_id": recipe_id, "version_number": version_number, "measurement_count": count}
|
||||
|
||||
|
||||
@router.post("/{recipe_id}/parse-technical-sheet")
|
||||
async def parse_technical_sheet(
|
||||
recipe_id: int,
|
||||
file: UploadFile = File(...),
|
||||
_user: User = Depends(require_maker),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Parse a PDF technical sheet using AI and return suggested tasks."""
|
||||
from src.backend.services import ai_service
|
||||
|
||||
if not file.content_type or "pdf" not in file.content_type:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Il file deve essere un PDF")
|
||||
|
||||
pdf_bytes = await file.read()
|
||||
if len(pdf_bytes) > 20 * 1024 * 1024:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="File troppo grande (max 20MB)")
|
||||
|
||||
try:
|
||||
tasks = await ai_service.parse_technical_sheet(pdf_bytes)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
except Exception as e:
|
||||
logging.getLogger(__name__).error("AI parsing error: %s", e)
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="Errore nel servizio AI")
|
||||
|
||||
return {"recipe_id": recipe_id, "suggested_tasks": tasks}
|
||||
@@ -5,10 +5,10 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from fastapi.responses import Response
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database import get_db
|
||||
from middleware.api_key import require_metrologist
|
||||
from models.user import User
|
||||
from services.report_service import generate_measurement_report, generate_spc_report
|
||||
from src.backend.database import get_db
|
||||
from src.backend.api.middleware.api_key import require_metrologist
|
||||
from src.backend.models.orm.user import User
|
||||
from src.backend.services.report_service import generate_measurement_report, generate_spc_report
|
||||
|
||||
router = APIRouter(prefix="/api/reports", tags=["reports"])
|
||||
|
||||
@@ -5,11 +5,11 @@ from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config import settings
|
||||
from database import get_db
|
||||
from middleware.api_key import get_current_user, require_admin_user
|
||||
from models.setting import SystemSetting
|
||||
from models.user import User
|
||||
from src.backend.config import settings
|
||||
from src.backend.database import get_db
|
||||
from src.backend.api.middleware.api_key import get_current_user, require_admin_user
|
||||
from src.backend.models.orm.setting import SystemSetting
|
||||
from src.backend.models.orm.user import User
|
||||
|
||||
router = APIRouter(prefix="/api/settings", tags=["settings"])
|
||||
|
||||
@@ -15,18 +15,19 @@ from pydantic import BaseModel
|
||||
from sqlalchemy import inspect as sa_inspect, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config import settings
|
||||
from database import Base, engine, async_session_factory
|
||||
from models import (
|
||||
from src.backend.api.routers.users import VALID_ROLES
|
||||
from src.backend.config import settings
|
||||
from src.backend.database import Base, engine, async_session_factory
|
||||
from src.backend.models.orm import (
|
||||
User, Recipe, RecipeVersion, RecipeTask, RecipeSubtask, Measurement,
|
||||
)
|
||||
from models.station import Station, StationRecipeAssignment
|
||||
from services.auth_service import hash_password
|
||||
from services.measurement_service import calculate_pass_fail
|
||||
from src.backend.models.orm.station import Station, StationRecipeAssignment
|
||||
from src.backend.services.auth_service import hash_password
|
||||
from src.backend.services.measurement_service import calculate_pass_fail
|
||||
|
||||
router = APIRouter(prefix="/api/setup", tags=["setup"])
|
||||
|
||||
_templates_dir = Path(__file__).resolve().parent.parent / "templates"
|
||||
_templates_dir = Path(__file__).resolve().parent.parent.parent / "templates"
|
||||
templates = Jinja2Templates(directory=str(_templates_dir))
|
||||
|
||||
|
||||
@@ -215,7 +216,7 @@ async def _seed_default_station(session: AsyncSession, admin_user: User) -> None
|
||||
async def setup_page(request: Request):
|
||||
"""Serve the setup page HTML."""
|
||||
_check_setup_enabled()
|
||||
return templates.TemplateResponse("setup/setup.html", {"request": request})
|
||||
return templates.TemplateResponse(request, "setup/setup.html")
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
@@ -544,7 +545,6 @@ async def manage_user(body: ManageUserBody):
|
||||
"""Create or update a user. user_id=null creates new, user_id=int updates."""
|
||||
_check_password(body.password)
|
||||
|
||||
VALID_ROLES = {"Maker", "MeasurementTec", "Metrologist"}
|
||||
invalid = set(body.roles) - VALID_ROLES
|
||||
if invalid:
|
||||
raise HTTPException(
|
||||
@@ -2,31 +2,54 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database import get_db
|
||||
from middleware.api_key import get_current_user, require_admin_user
|
||||
from models.user import User
|
||||
from schemas.station import (
|
||||
from src.backend.database import get_db
|
||||
from src.backend.api.middleware.api_key import get_current_user, require_admin_user
|
||||
from src.backend.models.orm.user import User
|
||||
from src.backend.models.api.station import (
|
||||
StationCreate,
|
||||
StationUpdate,
|
||||
StationResponse,
|
||||
StationRecipeAssignmentCreate,
|
||||
StationRecipeAssignmentResponse,
|
||||
StationResetResponse,
|
||||
StationWithRecipesResponse,
|
||||
RecipeSummary,
|
||||
)
|
||||
from services import station_service
|
||||
from src.backend.services import station_service
|
||||
|
||||
router = APIRouter(prefix="/api/stations", tags=["stations"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[StationResponse])
|
||||
@router.get("", response_model=list[StationWithRecipesResponse])
|
||||
async def list_stations(
|
||||
active_only: bool = False,
|
||||
admin: User = Depends(require_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List all stations (admin only)."""
|
||||
"""List all stations with the recipes assigned to each (admin only).
|
||||
|
||||
The admin station list shows which products a station handles, so the
|
||||
assignments travel with the station instead of needing a call per row.
|
||||
Station.assignments is selectin-loaded, so this costs no extra query.
|
||||
|
||||
Only active recipes are listed, matching what an operator actually sees
|
||||
at that station via /by-code/{code}/recipes.
|
||||
"""
|
||||
stations = await station_service.list_stations(db, active_only=active_only)
|
||||
return [StationResponse.model_validate(s) for s in stations]
|
||||
return [
|
||||
StationWithRecipesResponse(
|
||||
**StationResponse.model_validate(s).model_dump(),
|
||||
recipes=sorted(
|
||||
(
|
||||
RecipeSummary.model_validate(a.recipe)
|
||||
for a in s.assignments
|
||||
if a.recipe is not None and a.recipe.active
|
||||
),
|
||||
key=lambda r: r.code,
|
||||
),
|
||||
)
|
||||
for s in stations
|
||||
]
|
||||
|
||||
|
||||
@router.post("", response_model=StationResponse, status_code=status.HTTP_201_CREATED)
|
||||
@@ -128,6 +151,22 @@ async def assign_recipe_to_station(
|
||||
return StationRecipeAssignmentResponse.model_validate(assignment)
|
||||
|
||||
|
||||
@router.delete("/{station_id}/recipes", response_model=StationResetResponse)
|
||||
async def reset_station_recipes(
|
||||
station_id: int,
|
||||
admin: User = Depends(require_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Clear every recipe assignment of a station (admin only).
|
||||
|
||||
Backs the per-row reset in the admin list: the station is left with no
|
||||
recipes and can be reassigned from scratch, without deleting the station
|
||||
itself. Idempotent - resetting an already empty station reports removed=0.
|
||||
"""
|
||||
removed = await station_service.unassign_all_recipes(db, station_id)
|
||||
return StationResetResponse(station_id=station_id, removed=removed)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{station_id}/recipes/{recipe_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
@@ -5,19 +5,19 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import and_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database import get_db
|
||||
from middleware.api_key import require_metrologist
|
||||
from models.measurement import Measurement
|
||||
from models.recipe import RecipeVersion
|
||||
from models.task import RecipeSubtask, RecipeTask
|
||||
from models.user import User
|
||||
from schemas.statistics import (
|
||||
from src.backend.database import get_db
|
||||
from src.backend.api.middleware.api_key import require_metrologist
|
||||
from src.backend.models.orm.measurement import Measurement
|
||||
from src.backend.models.orm.recipe import RecipeVersion
|
||||
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
||||
from src.backend.models.orm.user import User
|
||||
from src.backend.models.api.statistics import (
|
||||
CapabilityData,
|
||||
ControlChartData,
|
||||
HistogramData,
|
||||
SummaryData,
|
||||
)
|
||||
from services.spc_service import (
|
||||
from src.backend.services.spc_service import (
|
||||
compute_capability,
|
||||
compute_control_chart,
|
||||
compute_histogram,
|
||||
@@ -4,12 +4,12 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from database import get_db
|
||||
from middleware.api_key import require_maker, get_current_user
|
||||
from models.recipe import Recipe, RecipeVersion
|
||||
from models.task import RecipeSubtask, RecipeTask
|
||||
from models.user import User
|
||||
from schemas.task import (
|
||||
from src.backend.database import get_db
|
||||
from src.backend.api.middleware.api_key import require_maker, get_current_user
|
||||
from src.backend.models.orm.recipe import Recipe, RecipeVersion
|
||||
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
||||
from src.backend.models.orm.user import User
|
||||
from src.backend.models.api.task import (
|
||||
SubtaskCreate,
|
||||
SubtaskResponse,
|
||||
SubtaskUpdate,
|
||||
@@ -18,7 +18,7 @@ from schemas.task import (
|
||||
TaskResponse,
|
||||
TaskUpdate,
|
||||
)
|
||||
from services import recipe_service
|
||||
from src.backend.services import recipe_service
|
||||
|
||||
router = APIRouter(tags=["tasks"])
|
||||
|
||||
@@ -159,7 +159,7 @@ async def create_task(
|
||||
|
||||
if has_measurements:
|
||||
# Copy-on-write: create new version preserving measurement data
|
||||
from schemas.recipe import RecipeUpdate
|
||||
from src.backend.models.api.recipe import RecipeUpdate
|
||||
new_version = await recipe_service.create_new_version(
|
||||
db, recipe_id, RecipeUpdate(change_notes=f"Added task: {data.title}"), user
|
||||
)
|
||||
@@ -223,7 +223,10 @@ async def reorder_tasks(
|
||||
result = await db.execute(
|
||||
select(RecipeTask)
|
||||
.where(RecipeTask.id.in_(data.task_ids))
|
||||
.options(selectinload(RecipeTask.subtasks))
|
||||
.options(
|
||||
selectinload(RecipeTask.subtasks),
|
||||
selectinload(RecipeTask.version),
|
||||
)
|
||||
)
|
||||
tasks_map = {t.id: t for t in result.scalars().all()}
|
||||
|
||||
@@ -3,14 +3,18 @@ from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database import get_db
|
||||
from middleware.api_key import require_admin_user
|
||||
from models.user import User
|
||||
from schemas.user import UserCreate, UserPasswordChange, UserResponse, UserUpdate
|
||||
from services.auth_service import create_user, hash_password, regenerate_api_key
|
||||
from src.backend.database import get_db
|
||||
from src.backend.api.middleware.api_key import require_admin_user
|
||||
from src.backend.models.orm.user import User
|
||||
from src.backend.models.api.user import UserCreate, UserPasswordChange, UserResponse, UserUpdate
|
||||
from src.backend.services.auth_service import create_user, hash_password, regenerate_api_key
|
||||
|
||||
router = APIRouter(prefix="/api/users", tags=["users"])
|
||||
|
||||
# Combinable user roles. Supervisor (capoturno) authorizes out-of-tolerance
|
||||
# overrides during measurement without needing full admin privileges.
|
||||
VALID_ROLES = {"Maker", "MeasurementTec", "Metrologist", "Supervisor"}
|
||||
|
||||
|
||||
@router.get("", response_model=list[UserResponse])
|
||||
async def list_users(
|
||||
@@ -40,12 +44,11 @@ async def create_new_user(
|
||||
detail=f"Username '{data.username}' already exists",
|
||||
)
|
||||
# Validate roles
|
||||
valid_roles = {"Maker", "MeasurementTec", "Metrologist"}
|
||||
for role in data.roles:
|
||||
if role not in valid_roles:
|
||||
if role not in VALID_ROLES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"Invalid role '{role}'. Valid roles: {valid_roles}",
|
||||
detail=f"Invalid role '{role}'. Valid roles: {VALID_ROLES}",
|
||||
)
|
||||
user = await create_user(
|
||||
db,
|
||||
@@ -77,12 +80,11 @@ async def update_user(
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
# Validate roles if provided
|
||||
if "roles" in update_data:
|
||||
valid_roles = {"Maker", "MeasurementTec", "Metrologist"}
|
||||
for role in update_data["roles"]:
|
||||
if role not in valid_roles:
|
||||
if role not in VALID_ROLES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"Invalid role '{role}'. Valid roles: {valid_roles}",
|
||||
detail=f"Invalid role '{role}'. Valid roles: {VALID_ROLES}",
|
||||
)
|
||||
for field, value in update_data.items():
|
||||
setattr(user, field, value)
|
||||
@@ -23,9 +23,9 @@ class Settings(BaseSettings):
|
||||
upload_dir: str = "uploads"
|
||||
max_upload_size_mb: int = 50
|
||||
|
||||
# Rate Limiting (requests per minute)
|
||||
# Rate Limiting (requests per minute, per real client IP)
|
||||
rate_limit_login: int = 5
|
||||
rate_limit_general: int = 100
|
||||
rate_limit_general: int = 300
|
||||
|
||||
# SSL (Production)
|
||||
ssl_certfile: str | None = None
|
||||
@@ -34,6 +34,10 @@ class Settings(BaseSettings):
|
||||
# Setup page (empty = disabled)
|
||||
setup_password: str | None = None
|
||||
|
||||
# AI / OpenRouter (for technical sheet parsing)
|
||||
openrouter_api_key: str | None = None
|
||||
openrouter_model: str = "anthropic/claude-sonnet-4"
|
||||
|
||||
@property
|
||||
def database_url(self) -> str:
|
||||
"""Async MySQL connection string."""
|
||||
@@ -49,10 +53,21 @@ class Settings(BaseSettings):
|
||||
|
||||
@property
|
||||
def upload_path(self) -> Path:
|
||||
"""Absolute path to upload directory."""
|
||||
return Path(__file__).parent / self.upload_dir
|
||||
"""Absolute path to upload directory.
|
||||
|
||||
model_config = {"env_file": "../.env", "env_file_encoding": "utf-8", "extra": "ignore"}
|
||||
After the V2.0.0 restructure, uploads live at the project root
|
||||
(mounted as a Docker volume), not inside the backend tree.
|
||||
"""
|
||||
# Path(__file__) = src/backend/config.py → parents[2] = project root
|
||||
return Path(__file__).resolve().parents[2] / self.upload_dir
|
||||
|
||||
# Always resolve .env against the project root regardless of cwd
|
||||
# (pydantic-settings would otherwise treat the path as cwd-relative).
|
||||
model_config = {
|
||||
"env_file": str(Path(__file__).resolve().parents[2] / ".env"),
|
||||
"env_file_encoding": "utf-8",
|
||||
"extra": "ignore",
|
||||
}
|
||||
|
||||
|
||||
settings = Settings()
|
||||
@@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import (
|
||||
)
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from config import settings
|
||||
from src.backend.config import settings
|
||||
|
||||
# Create async engine
|
||||
engine = create_async_engine(
|
||||
@@ -5,22 +5,23 @@ from collections.abc import AsyncGenerator
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from config import settings
|
||||
from database import init_db
|
||||
from middleware.logging import AccessLogMiddleware
|
||||
from middleware.rate_limit import RateLimitMiddleware
|
||||
from middleware.security_headers import SecurityHeadersMiddleware
|
||||
from routers.auth import router as auth_router
|
||||
from routers.users import router as users_router
|
||||
from routers.recipes import router as recipes_router
|
||||
from routers.tasks import router as tasks_router
|
||||
from routers.measurements import router as measurements_router
|
||||
from routers.files import router as files_router
|
||||
from routers.settings import router as settings_router
|
||||
from routers.reports import router as reports_router
|
||||
from routers.statistics import router as statistics_router
|
||||
from routers.setup import router as setup_router
|
||||
from routers.stations import router as stations_router
|
||||
from src.backend.config import settings
|
||||
from src.backend.database import init_db
|
||||
from src.backend.api.middleware.logging import AccessLogMiddleware
|
||||
from src.backend.api.middleware.rate_limit import RateLimitMiddleware
|
||||
from src.backend.api.middleware.security_headers import SecurityHeadersMiddleware
|
||||
from src.backend.api.routers.auth import router as auth_router
|
||||
from src.backend.api.routers.users import router as users_router
|
||||
from src.backend.api.routers.recipes import router as recipes_router
|
||||
from src.backend.api.routers.tasks import router as tasks_router
|
||||
from src.backend.api.routers.measurements import router as measurements_router
|
||||
from src.backend.api.routers.files import router as files_router
|
||||
from src.backend.api.routers.settings import router as settings_router
|
||||
from src.backend.api.routers.reports import router as reports_router
|
||||
from src.backend.api.routers.statistics import router as statistics_router
|
||||
from src.backend.api.routers.setup import router as setup_router
|
||||
from src.backend.api.routers.stations import router as stations_router
|
||||
from src.backend.api.routers.production import router as production_router
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -73,6 +74,7 @@ app.include_router(statistics_router)
|
||||
app.include_router(reports_router)
|
||||
app.include_router(setup_router)
|
||||
app.include_router(stations_router)
|
||||
app.include_router(production_router)
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
@@ -17,20 +17,27 @@ if config.config_file_name is not None:
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
from config import settings
|
||||
from database import Base
|
||||
# Add the project root (4 levels up from this file) to sys.path so that
|
||||
# `src.backend.*` imports resolve when alembic is invoked from anywhere.
|
||||
_PROJECT_ROOT = Path(__file__).resolve().parents[3]
|
||||
if str(_PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_PROJECT_ROOT))
|
||||
|
||||
from src.backend.config import settings
|
||||
from src.backend.database import Base
|
||||
|
||||
# Override alembic.ini URL with .env settings (keep in sync)
|
||||
config.set_main_option("sqlalchemy.url", settings.database_url)
|
||||
|
||||
# Import all models so they register with Base.metadata
|
||||
from models.user import User # noqa: F401
|
||||
from models.recipe import Recipe, RecipeVersion # noqa: F401
|
||||
from models.task import RecipeTask, RecipeSubtask # noqa: F401
|
||||
from models.measurement import Measurement # noqa: F401
|
||||
from models.access_log import AccessLog # noqa: F401
|
||||
from models.setting import SystemSetting, RecipeVersionAudit # noqa: F401
|
||||
from src.backend.models.orm.user import User # noqa: F401
|
||||
from src.backend.models.orm.recipe import Recipe, RecipeVersion # noqa: F401
|
||||
from src.backend.models.orm.task import RecipeTask, RecipeSubtask # noqa: F401
|
||||
from src.backend.models.orm.measurement import Measurement # noqa: F401
|
||||
from src.backend.models.orm.access_log import AccessLog # noqa: F401
|
||||
from src.backend.models.orm.setting import SystemSetting, RecipeVersionAudit # noqa: F401
|
||||
from src.backend.models.orm.station import Station, StationRecipeAssignment # noqa: F401
|
||||
from src.backend.models.orm.production import ProductionRun, ProductionEvent # noqa: F401
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
"""add measurement_interval_minutes to recipes
|
||||
|
||||
Revision ID: 003_measurement_interval
|
||||
Revises: 002_add_stations
|
||||
Create Date: 2026-05-23
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = '003_measurement_interval'
|
||||
down_revision: Union[str, None] = '002_add_stations'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column('recipes', sa.Column('measurement_interval_minutes', sa.SmallInteger(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('recipes', 'measurement_interval_minutes')
|
||||
@@ -0,0 +1,27 @@
|
||||
"""add input_duration_ms to measurements
|
||||
|
||||
Revision ID: 004_input_duration
|
||||
Revises: 003_measurement_interval
|
||||
Create Date: 2026-07-28
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = '004_input_duration'
|
||||
down_revision: Union[str, None] = '003_measurement_interval'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
'measurements',
|
||||
sa.Column('input_duration_ms', sa.Integer, nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('measurements', 'input_duration_ms')
|
||||
@@ -0,0 +1,100 @@
|
||||
"""add production_runs and production_events
|
||||
|
||||
Gives a production a life of its own: before this its state lived in the Alpine
|
||||
component of task_execute.html, so changing task - a full page load - lost the timer,
|
||||
the cycle count and the "production started" flag.
|
||||
|
||||
Revision ID: 005_production_runs
|
||||
Revises: 004_input_duration
|
||||
Create Date: 2026-07-28
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = '005_production_runs'
|
||||
down_revision: Union[str, None] = '004_input_duration'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'production_runs',
|
||||
sa.Column('id', sa.Integer, primary_key=True, autoincrement=True),
|
||||
sa.Column('station_id', sa.Integer, sa.ForeignKey('stations.id'), nullable=False),
|
||||
sa.Column('recipe_id', sa.Integer, sa.ForeignKey('recipes.id'), nullable=False),
|
||||
sa.Column('version_id', sa.Integer, sa.ForeignKey('recipe_versions.id'), nullable=False),
|
||||
sa.Column('operator_id', sa.Integer, sa.ForeignKey('users.id'), nullable=False),
|
||||
sa.Column('lot_number', sa.String(100), nullable=True),
|
||||
sa.Column('serial_number', sa.String(100), nullable=True),
|
||||
sa.Column(
|
||||
'status',
|
||||
sa.Enum('running', 'paused', 'closed', name='production_run_status_enum'),
|
||||
nullable=False,
|
||||
server_default='running',
|
||||
),
|
||||
sa.Column('measurement_interval_minutes', sa.SmallInteger, nullable=True),
|
||||
sa.Column('next_measurement_at', sa.DateTime, nullable=True),
|
||||
sa.Column('cycle_count', sa.Integer, nullable=False, server_default='0'),
|
||||
sa.Column('started_at', sa.DateTime, nullable=False, server_default=sa.func.now()),
|
||||
sa.Column('paused_at', sa.DateTime, nullable=True),
|
||||
sa.Column('closed_at', sa.DateTime, nullable=True),
|
||||
sa.Column('closed_by', sa.Integer, sa.ForeignKey('users.id'), nullable=True),
|
||||
# Mirrors station_id while open, NULL once closed. The unique constraint makes
|
||||
# "one open run per station" a database guarantee rather than a race; repeated
|
||||
# NULLs do not collide, so closed runs are free to pile up.
|
||||
sa.Column('active_station_id', sa.Integer, nullable=True),
|
||||
# Declared inline rather than added afterwards: adding a constraint is an ALTER,
|
||||
# which SQLite cannot do, and the test databases are SQLite.
|
||||
sa.UniqueConstraint('active_station_id', name='uq_production_runs_active_station'),
|
||||
mysql_engine='InnoDB',
|
||||
mysql_charset='utf8mb4',
|
||||
)
|
||||
op.create_index('ix_production_runs_station_id', 'production_runs', ['station_id'])
|
||||
op.create_index('ix_production_runs_recipe_id', 'production_runs', ['recipe_id'])
|
||||
op.create_index('ix_production_runs_version_id', 'production_runs', ['version_id'])
|
||||
op.create_index('ix_production_runs_operator_id', 'production_runs', ['operator_id'])
|
||||
op.create_index('ix_production_runs_lot_number', 'production_runs', ['lot_number'])
|
||||
op.create_index('ix_production_runs_serial_number', 'production_runs', ['serial_number'])
|
||||
op.create_index('ix_production_runs_status', 'production_runs', ['status'])
|
||||
op.create_index(
|
||||
'ix_production_runs_next_measurement_at', 'production_runs', ['next_measurement_at'],
|
||||
)
|
||||
op.create_index(
|
||||
'ix_production_runs_station_status', 'production_runs', ['station_id', 'status'],
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
'production_events',
|
||||
sa.Column('id', sa.Integer, primary_key=True, autoincrement=True),
|
||||
sa.Column(
|
||||
'run_id', sa.Integer,
|
||||
sa.ForeignKey('production_runs.id', ondelete='CASCADE'), nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
'event_type',
|
||||
sa.Enum(
|
||||
'start', 'cycle_completed', 'line_stop', 'resume', 'close',
|
||||
name='production_event_type_enum',
|
||||
),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column('user_id', sa.Integer, sa.ForeignKey('users.id'), nullable=False),
|
||||
sa.Column('supervisor_id', sa.Integer, sa.ForeignKey('users.id'), nullable=True),
|
||||
sa.Column('note', sa.Text, nullable=True),
|
||||
sa.Column('created_at', sa.DateTime, nullable=False, server_default=sa.func.now()),
|
||||
mysql_engine='InnoDB',
|
||||
mysql_charset='utf8mb4',
|
||||
)
|
||||
op.create_index('ix_production_events_run_id', 'production_events', ['run_id'])
|
||||
op.create_index('ix_production_events_event_type', 'production_events', ['event_type'])
|
||||
op.create_index('ix_production_events_user_id', 'production_events', ['user_id'])
|
||||
op.create_index('ix_production_events_created_at', 'production_events', ['created_at'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table('production_events')
|
||||
op.drop_table('production_runs')
|
||||
@@ -0,0 +1,63 @@
|
||||
"""link measurements to their production run
|
||||
|
||||
Fine produzione has to hand the measurements of the whole production to the
|
||||
statistics file. Without this column "the measurements of this production" is not a
|
||||
query: measurements only knew their recipe version, lot and serial, none of which
|
||||
delimits one run from the next on the same recipe and lot.
|
||||
|
||||
Nullable on purpose: measurements taken before this - and any taken outside a
|
||||
production - simply have no run.
|
||||
|
||||
Revision ID: 006_measurement_run
|
||||
Revises: 005_production_runs
|
||||
Create Date: 2026-07-28
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = '006_measurement_run'
|
||||
down_revision: Union[str, None] = '005_production_runs'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Batch mode, because the column carries a foreign key: adding a constraint is
|
||||
# an ALTER that SQLite cannot do, and alembic raises rather than silently
|
||||
# dropping it. On MySQL this is a plain ALTER; on SQLite it rebuilds the table.
|
||||
# Keeping the key is worth the ceremony - ON DELETE SET NULL means a measurement,
|
||||
# the record that matters in an audit, survives its run being deleted.
|
||||
with op.batch_alter_table('measurements') as batch_op:
|
||||
batch_op.add_column(
|
||||
sa.Column(
|
||||
'production_run_id', sa.Integer,
|
||||
sa.ForeignKey(
|
||||
'production_runs.id',
|
||||
ondelete='SET NULL',
|
||||
# Named because batch mode requires it, and because an anonymous
|
||||
# constraint cannot be referred to later.
|
||||
name='fk_measurements_production_run',
|
||||
),
|
||||
nullable=True,
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
'ix_measurements_production_run_id', 'measurements', ['production_run_id'],
|
||||
)
|
||||
|
||||
# Where the statistics file for a closed run was written. No constraint, so a
|
||||
# plain ALTER is enough.
|
||||
op.add_column(
|
||||
'production_runs',
|
||||
sa.Column('statistics_path', sa.String(500), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('production_runs', 'statistics_path')
|
||||
op.drop_index('ix_measurements_production_run_id', table_name='measurements')
|
||||
with op.batch_alter_table('measurements') as batch_op:
|
||||
batch_op.drop_column('production_run_id')
|
||||
@@ -1,19 +1,19 @@
|
||||
"""Pydantic schemas for TieMeasureFlow API."""
|
||||
from schemas.measurement import (
|
||||
from src.backend.models.api.measurement import (
|
||||
MeasurementBatchCreate,
|
||||
MeasurementCreate,
|
||||
MeasurementListResponse,
|
||||
MeasurementQuery,
|
||||
MeasurementResponse,
|
||||
)
|
||||
from schemas.recipe import (
|
||||
from src.backend.models.api.recipe import (
|
||||
RecipeCreate,
|
||||
RecipeListResponse,
|
||||
RecipeResponse,
|
||||
RecipeUpdate,
|
||||
RecipeVersionResponse,
|
||||
)
|
||||
from schemas.statistics import (
|
||||
from src.backend.models.api.statistics import (
|
||||
AlertData,
|
||||
CapabilityData,
|
||||
ControlChartData,
|
||||
@@ -23,7 +23,7 @@ from schemas.statistics import (
|
||||
SummaryData,
|
||||
TrendData,
|
||||
)
|
||||
from schemas.task import (
|
||||
from src.backend.models.api.task import (
|
||||
SubtaskCreate,
|
||||
SubtaskResponse,
|
||||
SubtaskUpdate,
|
||||
@@ -32,7 +32,7 @@ from schemas.task import (
|
||||
TaskResponse,
|
||||
TaskUpdate,
|
||||
)
|
||||
from schemas.user import (
|
||||
from src.backend.models.api.user import (
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
UserCreate,
|
||||
@@ -13,6 +13,9 @@ class MeasurementCreate(BaseModel):
|
||||
lot_number: Optional[str] = Field(None, max_length=100)
|
||||
serial_number: Optional[str] = Field(None, max_length=100)
|
||||
input_method: str = Field("manual", pattern="^(usb_caliper|manual)$")
|
||||
input_duration_ms: Optional[int] = Field(None, ge=0)
|
||||
# The production this belongs to, when one is open at the station.
|
||||
production_run_id: Optional[int] = Field(None, gt=0)
|
||||
|
||||
|
||||
class MeasurementBatchCreate(BaseModel):
|
||||
@@ -34,6 +37,8 @@ class MeasurementResponse(BaseModel):
|
||||
lot_number: Optional[str] = None
|
||||
serial_number: Optional[str] = None
|
||||
input_method: str
|
||||
input_duration_ms: Optional[int] = None
|
||||
production_run_id: Optional[int] = None
|
||||
measured_at: datetime
|
||||
synced_to_csv: bool
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Pydantic schemas for production runs and their events."""
|
||||
from datetime import datetime
|
||||
from typing import Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class ProductionRunCreate(BaseModel):
|
||||
station_code: str = Field(..., min_length=1, max_length=100)
|
||||
recipe_id: int = Field(..., gt=0)
|
||||
version_id: Optional[int] = Field(default=None, gt=0)
|
||||
lot_number: Optional[str] = Field(default=None, max_length=100)
|
||||
serial_number: Optional[str] = Field(default=None, max_length=100)
|
||||
|
||||
|
||||
class ProductionEventResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
event_type: str
|
||||
user_id: int
|
||||
supervisor_id: Optional[int]
|
||||
note: Optional[str]
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class ProductionRunResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
station_id: int
|
||||
recipe_id: int
|
||||
version_id: int
|
||||
operator_id: int
|
||||
lot_number: Optional[str]
|
||||
serial_number: Optional[str]
|
||||
status: str
|
||||
measurement_interval_minutes: Optional[int]
|
||||
next_measurement_at: Optional[datetime]
|
||||
cycle_count: int
|
||||
started_at: datetime
|
||||
paused_at: Optional[datetime]
|
||||
closed_at: Optional[datetime]
|
||||
closed_by: Optional[int]
|
||||
statistics_path: Optional[str] = None
|
||||
|
||||
# Derived server-side so every client agrees on the countdown regardless of
|
||||
# clock skew. Negative once the interval has elapsed: how long the run has been
|
||||
# overdue is a fact the operator must see, not deduce.
|
||||
seconds_to_next_measurement: Optional[int] = None
|
||||
overdue: bool = False
|
||||
server_time: Optional[datetime] = None
|
||||
|
||||
|
||||
class ProductionRunWithEventsResponse(ProductionRunResponse):
|
||||
events: list[ProductionEventResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SupervisorAction(BaseModel):
|
||||
"""Credentials of the supervisor authorising a line stop or a close."""
|
||||
|
||||
supervisor_username: str = Field(..., min_length=1)
|
||||
supervisor_password: str = Field(..., min_length=1)
|
||||
note: Optional[str] = None
|
||||
|
||||
|
||||
class CycleCompletePayload(BaseModel):
|
||||
note: Optional[str] = None
|
||||
|
||||
|
||||
ProductionEventType = Literal[
|
||||
"start", "cycle_completed", "line_stop", "resume", "close",
|
||||
]
|
||||
@@ -5,7 +5,7 @@ from typing import Optional, TYPE_CHECKING
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from schemas.task import TaskResponse
|
||||
from src.backend.models.api.task import TaskResponse
|
||||
|
||||
|
||||
class RecipeCreate(BaseModel):
|
||||
@@ -14,6 +14,7 @@ class RecipeCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
description: Optional[str] = None
|
||||
image_path: Optional[str] = Field(None, max_length=500)
|
||||
measurement_interval_minutes: Optional[int] = Field(None, ge=1, le=1440)
|
||||
# Optional task-level fields for the initial technical drawing
|
||||
file_path: Optional[str] = Field(None, max_length=500)
|
||||
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
|
||||
@@ -25,6 +26,7 @@ class RecipeUpdate(BaseModel):
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||
description: Optional[str] = None
|
||||
image_path: Optional[str] = Field(None, max_length=500)
|
||||
measurement_interval_minutes: Optional[int] = Field(None, ge=1, le=1440)
|
||||
change_notes: Optional[str] = None
|
||||
# Task-level fields: saved to the first task of the new version
|
||||
file_path: Optional[str] = Field(None, max_length=500)
|
||||
@@ -55,6 +57,7 @@ class RecipeResponse(BaseModel):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
image_path: Optional[str] = None
|
||||
measurement_interval_minutes: Optional[int] = None
|
||||
created_by: int
|
||||
created_at: datetime
|
||||
active: bool
|
||||
@@ -71,6 +74,6 @@ class RecipeListResponse(BaseModel):
|
||||
|
||||
|
||||
# Forward reference imports for model_rebuild
|
||||
from schemas.task import TaskResponse # noqa: E402
|
||||
from src.backend.models.api.task import TaskResponse # noqa: E402
|
||||
RecipeVersionResponse.model_rebuild()
|
||||
RecipeResponse.model_rebuild()
|
||||
@@ -51,7 +51,16 @@ class RecipeSummary(BaseModel):
|
||||
code: str
|
||||
name: str
|
||||
active: bool
|
||||
image_path: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class StationWithRecipesResponse(StationResponse):
|
||||
recipes: list[RecipeSummary] = Field(default_factory=list)
|
||||
|
||||
|
||||
class StationResetResponse(BaseModel):
|
||||
"""Outcome of clearing every recipe assignment of a station."""
|
||||
|
||||
station_id: int
|
||||
removed: int
|
||||
@@ -0,0 +1,25 @@
|
||||
"""SQLAlchemy models for TieMeasureFlow."""
|
||||
from src.backend.models.orm.user import User
|
||||
from src.backend.models.orm.recipe import Recipe, RecipeVersion
|
||||
from src.backend.models.orm.task import RecipeTask, RecipeSubtask
|
||||
from src.backend.models.orm.measurement import Measurement
|
||||
from src.backend.models.orm.access_log import AccessLog
|
||||
from src.backend.models.orm.setting import SystemSetting, RecipeVersionAudit
|
||||
from src.backend.models.orm.station import Station, StationRecipeAssignment
|
||||
from src.backend.models.orm.production import ProductionRun, ProductionEvent
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
"Recipe",
|
||||
"RecipeVersion",
|
||||
"RecipeTask",
|
||||
"RecipeSubtask",
|
||||
"Measurement",
|
||||
"AccessLog",
|
||||
"SystemSetting",
|
||||
"RecipeVersionAudit",
|
||||
"Station",
|
||||
"StationRecipeAssignment",
|
||||
"ProductionRun",
|
||||
"ProductionEvent",
|
||||
]
|
||||
@@ -5,7 +5,7 @@ from typing import Optional
|
||||
from sqlalchemy import BigInteger, DateTime, ForeignKey, Index, Integer, JSON, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from database import Base
|
||||
from src.backend.database import Base
|
||||
|
||||
|
||||
class AccessLog(Base):
|
||||
@@ -8,7 +8,7 @@ from sqlalchemy import (
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from database import Base
|
||||
from src.backend.database import Base
|
||||
|
||||
|
||||
class Measurement(Base):
|
||||
@@ -43,6 +43,22 @@ class Measurement(Base):
|
||||
default="manual",
|
||||
)
|
||||
|
||||
# Time spent by the operator entering this value (client-reported)
|
||||
input_duration_ms: Mapped[Optional[int]] = mapped_column(
|
||||
Integer, nullable=True
|
||||
)
|
||||
|
||||
# The production this was taken during, when there was one. Nullable: rows
|
||||
# predating production runs, and measurements taken outside a production, have
|
||||
# none. This is what makes "the measurements of this production" a query, which
|
||||
# fine produzione needs to emit the statistics file.
|
||||
production_run_id: Mapped[Optional[int]] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("production_runs.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# Timestamp
|
||||
measured_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, nullable=False, server_default=func.now(), index=True
|
||||
@@ -0,0 +1,154 @@
|
||||
"""ProductionRun and ProductionEvent models.
|
||||
|
||||
A production run is what an operator opens when production starts and a supervisor
|
||||
closes when it ends. It is the thing that outlives a page: before this, the timer,
|
||||
the cycle count and the "production started" flag were Alpine variables in
|
||||
task_execute.html, and navigating between tasks - a full page load - destroyed them.
|
||||
|
||||
The measurement deadline is stored as an absolute timestamp (next_measurement_at)
|
||||
rather than a remaining count. Any client can then derive the countdown from it, get
|
||||
the same answer, survive a reload, and tell how far *past* the interval it is by
|
||||
letting the difference go negative.
|
||||
|
||||
With one station app installed per PC (see the install architecture of 28/07) the
|
||||
database is the only place this state can live: the API keeps nothing in process
|
||||
memory.
|
||||
"""
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from sqlalchemy import (
|
||||
DateTime, Enum, ForeignKey, Index, Integer, SmallInteger, String, Text,
|
||||
UniqueConstraint, func,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from src.backend.database import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from src.backend.models.orm.recipe import Recipe, RecipeVersion
|
||||
from src.backend.models.orm.station import Station
|
||||
|
||||
|
||||
RUN_STATUSES = ("running", "paused", "closed")
|
||||
EVENT_TYPES = ("start", "cycle_completed", "line_stop", "resume", "close")
|
||||
|
||||
|
||||
class ProductionRun(Base):
|
||||
__tablename__ = "production_runs"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
|
||||
station_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("stations.id"), nullable=False, index=True
|
||||
)
|
||||
recipe_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("recipes.id"), nullable=False, index=True
|
||||
)
|
||||
version_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("recipe_versions.id"), nullable=False, index=True
|
||||
)
|
||||
operator_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("users.id"), nullable=False, index=True
|
||||
)
|
||||
|
||||
# Traceability travels with the run instead of the Flask session, so it stays
|
||||
# attached to every measurement taken during it.
|
||||
lot_number: Mapped[Optional[str]] = mapped_column(String(100), nullable=True, index=True)
|
||||
serial_number: Mapped[Optional[str]] = mapped_column(String(100), nullable=True, index=True)
|
||||
|
||||
status: Mapped[str] = mapped_column(
|
||||
Enum(*RUN_STATUSES, name="production_run_status_enum"),
|
||||
nullable=False,
|
||||
default="running",
|
||||
index=True,
|
||||
)
|
||||
|
||||
# Snapshot of the recipe interval at start: editing the recipe mid-run must not
|
||||
# move the deadline of a production already under way.
|
||||
measurement_interval_minutes: Mapped[Optional[int]] = mapped_column(
|
||||
SmallInteger, nullable=True
|
||||
)
|
||||
# When the next measurement falls due. Null when the recipe has no interval.
|
||||
# Past this instant the run is overdue, and by how much is simply now - this.
|
||||
next_measurement_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime, nullable=True, index=True
|
||||
)
|
||||
cycle_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
|
||||
started_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, nullable=False, server_default=func.now()
|
||||
)
|
||||
paused_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||
closed_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||
closed_by: Mapped[Optional[int]] = mapped_column(
|
||||
Integer, ForeignKey("users.id"), nullable=True
|
||||
)
|
||||
# Statistics file emitted when the run was closed, relative to the upload dir.
|
||||
statistics_path: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||
|
||||
# Mirrors station_id while the run is open and goes NULL when it closes. A unique
|
||||
# index on it lets the database - not a check-then-insert race - guarantee that a
|
||||
# station never has two open runs. Repeated NULLs do not collide in a unique index.
|
||||
active_station_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
|
||||
station: Mapped["Station"] = relationship(lazy="selectin")
|
||||
recipe: Mapped["Recipe"] = relationship(lazy="selectin")
|
||||
version: Mapped["RecipeVersion"] = relationship(lazy="selectin")
|
||||
events: Mapped[list["ProductionEvent"]] = relationship(
|
||||
back_populates="run",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="ProductionEvent.created_at",
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("active_station_id", name="uq_production_runs_active_station"),
|
||||
Index("ix_production_runs_station_status", "station_id", "status"),
|
||||
{"mysql_engine": "InnoDB", "mysql_charset": "utf8mb4"},
|
||||
)
|
||||
|
||||
@property
|
||||
def is_open(self) -> bool:
|
||||
return self.status in ("running", "paused")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<ProductionRun {self.id} station={self.station_id} {self.status}>"
|
||||
|
||||
|
||||
class ProductionEvent(Base):
|
||||
"""Append-only trace of what happened during a run.
|
||||
|
||||
This is the record that did not exist before: without it there is no history of
|
||||
a production, and nothing for the statistics file or the ERP hand-off to stand on.
|
||||
"""
|
||||
|
||||
__tablename__ = "production_events"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
run_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("production_runs.id", ondelete="CASCADE"),
|
||||
nullable=False, index=True,
|
||||
)
|
||||
event_type: Mapped[str] = mapped_column(
|
||||
Enum(*EVENT_TYPES, name="production_event_type_enum"), nullable=False, index=True
|
||||
)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("users.id"), nullable=False, index=True
|
||||
)
|
||||
# Who authorised it, when the action needed a supervisor (fermo linea, chiusura).
|
||||
supervisor_id: Mapped[Optional[int]] = mapped_column(
|
||||
Integer, ForeignKey("users.id"), nullable=True
|
||||
)
|
||||
note: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, nullable=False, server_default=func.now(), index=True
|
||||
)
|
||||
|
||||
run: Mapped["ProductionRun"] = relationship(back_populates="events")
|
||||
|
||||
__table_args__ = (
|
||||
{"mysql_engine": "InnoDB", "mysql_charset": "utf8mb4"},
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<ProductionEvent run={self.run_id} {self.event_type}>"
|
||||
@@ -2,13 +2,13 @@
|
||||
from datetime import datetime
|
||||
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, SmallInteger, String, Text, UniqueConstraint, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from database import Base
|
||||
from src.backend.database import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from models.task import RecipeTask
|
||||
from src.backend.models.orm.task import RecipeTask
|
||||
|
||||
|
||||
class Recipe(Base):
|
||||
@@ -24,6 +24,7 @@ class Recipe(Base):
|
||||
DateTime, nullable=False, server_default=func.now()
|
||||
)
|
||||
active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
|
||||
measurement_interval_minutes: Mapped[Optional[int]] = mapped_column(SmallInteger, nullable=True)
|
||||
|
||||
# Relationships
|
||||
versions: Mapped[list["RecipeVersion"]] = relationship(
|
||||
@@ -5,7 +5,7 @@ from typing import Optional
|
||||
from sqlalchemy import DateTime, Enum, ForeignKey, Index, Integer, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from database import Base
|
||||
from src.backend.database import Base
|
||||
|
||||
|
||||
class SystemSetting(Base):
|
||||
@@ -10,10 +10,10 @@ from typing import TYPE_CHECKING, Optional
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from database import Base
|
||||
from src.backend.database import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from models.recipe import Recipe
|
||||
from src.backend.models.orm.recipe import Recipe
|
||||
|
||||
|
||||
class Station(Base):
|
||||
@@ -6,10 +6,10 @@ from sqlalchemy import (
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from database import Base
|
||||
from src.backend.database import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from models.recipe import RecipeVersion
|
||||
from src.backend.models.orm.recipe import RecipeVersion
|
||||
|
||||
|
||||
class RecipeTask(Base):
|
||||
@@ -5,7 +5,7 @@ from typing import Optional
|
||||
from sqlalchemy import Boolean, DateTime, Enum, Integer, JSON, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from database import Base
|
||||
from src.backend.database import Base
|
||||
|
||||
|
||||
class User(Base):
|
||||
@@ -0,0 +1,74 @@
|
||||
"""AI service for parsing technical sheets via OpenRouter."""
|
||||
import json
|
||||
import logging
|
||||
from io import BytesIO
|
||||
|
||||
import httpx
|
||||
import pdfplumber
|
||||
|
||||
from src.backend.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SYSTEM_PROMPT = """Sei un assistente specializzato nell'analisi di schede tecniche industriali.
|
||||
Analizza il testo estratto da una scheda tecnica e identifica i blocchi operativi distinti.
|
||||
Per ogni blocco, estrai:
|
||||
- title: titolo breve del blocco (es. "MATERIALI", "TEMPERATURA", "MISURA", "IMBALLO")
|
||||
- directive: istruzione operativa principale (es. "VERIFICARE ATTREZZATURE")
|
||||
- description: dettagli completi del blocco, preservando gli "a capo" originali
|
||||
|
||||
Rispondi SOLO con un array JSON valido, senza markdown o testo aggiuntivo.
|
||||
Esempio:
|
||||
[
|
||||
{"title": "MATERIALI", "directive": "Verificare materiali", "description": "RIGIDO (36): AFT9/UV CRI 7\\nMORBIDO (E38): M70"},
|
||||
{"title": "TEMPERATURA", "directive": "Verificare impostazioni", "description": "RIGIDO: TEMP: 170/170/170/170/170\\nMORBIDO: TEMP: 130-135-140-145"}
|
||||
]"""
|
||||
|
||||
|
||||
def extract_text_from_pdf(pdf_bytes: bytes) -> str:
|
||||
with pdfplumber.open(BytesIO(pdf_bytes)) as pdf:
|
||||
pages = []
|
||||
for page in pdf.pages:
|
||||
text = page.extract_text()
|
||||
if text:
|
||||
pages.append(text)
|
||||
return "\n\n---\n\n".join(pages)
|
||||
|
||||
|
||||
async def parse_technical_sheet(pdf_bytes: bytes) -> list[dict]:
|
||||
text = extract_text_from_pdf(pdf_bytes)
|
||||
if not text.strip():
|
||||
return []
|
||||
|
||||
if not settings.openrouter_api_key:
|
||||
raise ValueError("OPENROUTER_API_KEY non configurata")
|
||||
|
||||
async with httpx.AsyncClient(timeout=60) as client:
|
||||
resp = await client.post(
|
||||
"https://openrouter.ai/api/v1/chat/completions",
|
||||
headers={
|
||||
"Authorization": f"Bearer {settings.openrouter_api_key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={
|
||||
"model": settings.openrouter_model,
|
||||
"messages": [
|
||||
{"role": "system", "content": SYSTEM_PROMPT},
|
||||
{"role": "user", "content": f"Analizza questa scheda tecnica ed estrai i task:\n\n{text}"},
|
||||
],
|
||||
"temperature": 0.1,
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
||||
data = resp.json()
|
||||
content = data["choices"][0]["message"]["content"].strip()
|
||||
|
||||
# Strip markdown fences if present
|
||||
if content.startswith("```"):
|
||||
content = content.split("\n", 1)[1]
|
||||
if content.endswith("```"):
|
||||
content = content[:-3]
|
||||
content = content.strip()
|
||||
|
||||
return json.loads(content)
|
||||
@@ -6,7 +6,7 @@ import bcrypt
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from models.user import User
|
||||
from src.backend.models.orm.user import User
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
+6
-2
@@ -4,8 +4,8 @@ from decimal import Decimal
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from models.measurement import Measurement
|
||||
from models.task import RecipeSubtask
|
||||
from src.backend.models.orm.measurement import Measurement
|
||||
from src.backend.models.orm.task import RecipeSubtask
|
||||
|
||||
|
||||
def calculate_pass_fail(
|
||||
@@ -48,6 +48,8 @@ async def save_measurement(
|
||||
lot_number: str | None = None,
|
||||
serial_number: str | None = None,
|
||||
input_method: str = "manual",
|
||||
input_duration_ms: int | None = None,
|
||||
production_run_id: int | None = None,
|
||||
) -> Measurement:
|
||||
"""Save a single measurement with auto-calculated pass/fail."""
|
||||
# Get subtask for tolerance values
|
||||
@@ -70,6 +72,8 @@ async def save_measurement(
|
||||
lot_number=lot_number,
|
||||
serial_number=serial_number,
|
||||
input_method=input_method,
|
||||
input_duration_ms=input_duration_ms,
|
||||
production_run_id=production_run_id,
|
||||
)
|
||||
db.add(measurement)
|
||||
await db.flush()
|
||||
@@ -0,0 +1,161 @@
|
||||
"""Statistics file emitted when a production is closed.
|
||||
|
||||
Fine produzione has to hand the measurements of the whole production over. That part
|
||||
does not depend on the ERP: the file is produced now, and the hand-off to GAIA plugs
|
||||
in beside it once the protocol is agreed (questions D-1 and D-2). Keeping it in its
|
||||
own module makes that seam obvious rather than buried in the close path.
|
||||
|
||||
The CSV uses the delimiters configured in system_settings, the same ones the manual
|
||||
export honours, so a shop floor set to the Italian convention (';' and ',') gets
|
||||
files that open correctly in their Excel.
|
||||
"""
|
||||
import csv
|
||||
import io
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.backend.config import settings
|
||||
from src.backend.models.orm.measurement import Measurement
|
||||
from src.backend.models.orm.production import ProductionRun
|
||||
from src.backend.models.orm.recipe import Recipe
|
||||
from src.backend.models.orm.setting import SystemSetting
|
||||
from src.backend.models.orm.station import Station
|
||||
from src.backend.models.orm.task import RecipeSubtask
|
||||
|
||||
# Written under the upload directory: it is the volume that is already mounted and
|
||||
# backed up, so the files survive a container being replaced.
|
||||
EXPORT_SUBDIR = "statistics"
|
||||
|
||||
HEADER = [
|
||||
"production_run_id",
|
||||
"station_code",
|
||||
"recipe_code",
|
||||
"version_id",
|
||||
"lot_number",
|
||||
"serial_number",
|
||||
"measurement_id",
|
||||
"subtask_id",
|
||||
"marker_number",
|
||||
"subtask_description",
|
||||
"nominal",
|
||||
"value",
|
||||
"deviation",
|
||||
"pass_fail",
|
||||
# The limits travel with the row: without them a pass/fail cannot be re-derived
|
||||
# from the file years later, which is the whole point of an audit record.
|
||||
"ltl",
|
||||
"lwl",
|
||||
"uwl",
|
||||
"utl",
|
||||
"unit",
|
||||
"input_method",
|
||||
"input_duration_ms",
|
||||
"measured_by",
|
||||
"measured_at",
|
||||
]
|
||||
|
||||
|
||||
async def _csv_format(db: AsyncSession) -> tuple[str, str]:
|
||||
"""Delimiter and decimal separator as configured for this installation."""
|
||||
delimiter_row = await db.execute(
|
||||
select(SystemSetting).where(SystemSetting.setting_key == "csv_delimiter")
|
||||
)
|
||||
delimiter = delimiter_row.scalar_one_or_none()
|
||||
decimal_row = await db.execute(
|
||||
select(SystemSetting).where(
|
||||
SystemSetting.setting_key == "csv_decimal_separator"
|
||||
)
|
||||
)
|
||||
decimal = decimal_row.scalar_one_or_none()
|
||||
return (
|
||||
delimiter.setting_value if delimiter else ",",
|
||||
decimal.setting_value if decimal else ".",
|
||||
)
|
||||
|
||||
|
||||
async def build_statistics_csv(db: AsyncSession, run: ProductionRun) -> str:
|
||||
"""Render every measurement of a run as CSV text."""
|
||||
delimiter, decimal_separator = await _csv_format(db)
|
||||
|
||||
station = (await db.execute(
|
||||
select(Station).where(Station.id == run.station_id)
|
||||
)).scalar_one_or_none()
|
||||
recipe = (await db.execute(
|
||||
select(Recipe).where(Recipe.id == run.recipe_id)
|
||||
)).scalar_one_or_none()
|
||||
|
||||
rows = (await db.execute(
|
||||
select(Measurement, RecipeSubtask)
|
||||
.join(RecipeSubtask, RecipeSubtask.id == Measurement.subtask_id, isouter=True)
|
||||
.where(Measurement.production_run_id == run.id)
|
||||
.order_by(Measurement.measured_at, Measurement.id)
|
||||
)).all()
|
||||
|
||||
def number(value) -> str:
|
||||
return "" if value is None else str(value).replace(".", decimal_separator)
|
||||
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output, delimiter=delimiter, lineterminator="\n")
|
||||
writer.writerow(HEADER)
|
||||
|
||||
for measurement, subtask in rows:
|
||||
writer.writerow([
|
||||
run.id,
|
||||
station.code if station else "",
|
||||
recipe.code if recipe else "",
|
||||
measurement.version_id,
|
||||
measurement.lot_number or "",
|
||||
measurement.serial_number or "",
|
||||
measurement.id,
|
||||
measurement.subtask_id,
|
||||
subtask.marker_number if subtask else "",
|
||||
subtask.description if subtask else "",
|
||||
number(subtask.nominal) if subtask else "",
|
||||
number(measurement.value),
|
||||
number(measurement.deviation),
|
||||
measurement.pass_fail,
|
||||
number(subtask.ltl) if subtask else "",
|
||||
number(subtask.lwl) if subtask else "",
|
||||
number(subtask.uwl) if subtask else "",
|
||||
number(subtask.utl) if subtask else "",
|
||||
(subtask.unit or "") if subtask else "",
|
||||
measurement.input_method,
|
||||
"" if measurement.input_duration_ms is None else measurement.input_duration_ms,
|
||||
measurement.measured_by,
|
||||
measurement.measured_at.isoformat() if measurement.measured_at else "",
|
||||
])
|
||||
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
async def export_run_statistics(db: AsyncSession, run: ProductionRun) -> str | None:
|
||||
"""Write the run's statistics file and mark its measurements as exported.
|
||||
|
||||
Returns the path relative to the upload directory, or None when the run has no
|
||||
measurements - an empty file would only be noise in the folder.
|
||||
"""
|
||||
content = await build_statistics_csv(db, run)
|
||||
# Header only: nothing was measured during this production.
|
||||
if len(content.strip().splitlines()) <= 1:
|
||||
return None
|
||||
|
||||
directory = Path(settings.upload_path) / EXPORT_SUBDIR
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
stamp = (run.closed_at or datetime.now()).strftime("%Y%m%d_%H%M%S")
|
||||
filename = f"production_{run.id}_{stamp}.csv"
|
||||
(directory / filename).write_text(content, encoding="utf-8-sig")
|
||||
|
||||
# utf-8-sig: the BOM is what makes Excel read accented characters correctly,
|
||||
# and these files are opened in Excel on the shop floor.
|
||||
|
||||
await db.execute(
|
||||
update(Measurement)
|
||||
.where(Measurement.production_run_id == run.id)
|
||||
.values(synced_to_csv=True)
|
||||
)
|
||||
|
||||
return f"{EXPORT_SUBDIR}/{filename}"
|
||||
@@ -0,0 +1,364 @@
|
||||
"""Business logic for production runs.
|
||||
|
||||
Routers must call into these functions rather than manipulating models directly.
|
||||
All functions are async and accept an AsyncSession; they flush but do NOT commit
|
||||
(commit is handled by the FastAPI get_db dependency).
|
||||
|
||||
Clock convention
|
||||
----------------
|
||||
Every timestamp this module writes comes from ``_now()``, so the deadline and the
|
||||
comparison against it are always on the same clock. Clients are handed
|
||||
``seconds_to_next_measurement`` already computed rather than the raw deadline: a
|
||||
naive datetime crossing the wire would be parsed in the browser's own timezone, and
|
||||
the countdown would be off by the UTC offset. The number is the contract; the
|
||||
timestamp is only there for the audit trail.
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.backend.models.api.production import ProductionRunCreate
|
||||
from src.backend.models.orm.production import ProductionEvent, ProductionRun
|
||||
from src.backend.models.orm.recipe import Recipe, RecipeVersion
|
||||
from src.backend.models.orm.station import Station
|
||||
from src.backend.models.orm.user import User
|
||||
from src.backend.services import auth_service, production_export_service
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
"""Single source of time for runs.
|
||||
|
||||
Naive local time, matching what the database writes for ``func.now()`` defaults,
|
||||
so a run's started_at and its deadline are comparable.
|
||||
"""
|
||||
return datetime.now()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Derived view
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def seconds_to_next_measurement(run: ProductionRun, now: Optional[datetime] = None) -> Optional[int]:
|
||||
"""Seconds until the next measurement is due; negative once it is overdue.
|
||||
|
||||
Letting the value go negative is deliberate: the operator has to see how long
|
||||
the line has been past the interval, not merely that it elapsed.
|
||||
While the run is paused the countdown is frozen at what was left when the line
|
||||
stopped - a fermo linea must not eat into the measurement interval.
|
||||
"""
|
||||
if run.next_measurement_at is None:
|
||||
return None
|
||||
reference = run.paused_at if (run.status == "paused" and run.paused_at) else (now or _now())
|
||||
return int(round((run.next_measurement_at - reference).total_seconds()))
|
||||
|
||||
|
||||
def describe(run: ProductionRun) -> dict:
|
||||
"""Fields the API adds on top of the stored columns."""
|
||||
now = _now()
|
||||
remaining = seconds_to_next_measurement(run, now)
|
||||
return {
|
||||
"seconds_to_next_measurement": remaining,
|
||||
"overdue": remaining is not None and remaining < 0,
|
||||
"server_time": now,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lookups
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def get_run(db: AsyncSession, run_id: int) -> ProductionRun:
|
||||
result = await db.execute(select(ProductionRun).where(ProductionRun.id == run_id))
|
||||
run = result.scalar_one_or_none()
|
||||
if run is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Production run not found",
|
||||
)
|
||||
return run
|
||||
|
||||
|
||||
async def get_open_run_for_station(
|
||||
db: AsyncSession, station_id: int,
|
||||
) -> Optional[ProductionRun]:
|
||||
"""The run currently open at a station, running or paused."""
|
||||
result = await db.execute(
|
||||
select(ProductionRun).where(ProductionRun.active_station_id == station_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_station_by_code(db: AsyncSession, code: str) -> Station:
|
||||
result = await db.execute(select(Station).where(Station.code == code))
|
||||
station = result.scalar_one_or_none()
|
||||
if station is None or not station.active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Station '{code}' not found or inactive",
|
||||
)
|
||||
return station
|
||||
|
||||
|
||||
async def list_run_events(db: AsyncSession, run_id: int) -> list[ProductionEvent]:
|
||||
result = await db.execute(
|
||||
select(ProductionEvent)
|
||||
.where(ProductionEvent.run_id == run_id)
|
||||
.order_by(ProductionEvent.created_at, ProductionEvent.id)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Supervisor authorisation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def authorise_supervisor(db: AsyncSession, username: str, password: str) -> User:
|
||||
"""Check the credentials of the supervisor authorising a stop or a close.
|
||||
|
||||
Uses authenticate_user rather than a full login: logging in would rotate the
|
||||
supervisor's API key and knock out whatever session they have open elsewhere.
|
||||
"""
|
||||
supervisor = await auth_service.authenticate_user(db, username, password)
|
||||
if supervisor is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid supervisor credentials",
|
||||
)
|
||||
if not supervisor.has_role("Supervisor") and not supervisor.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="User is not authorised as supervisor",
|
||||
)
|
||||
return supervisor
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _add_event(
|
||||
db: AsyncSession,
|
||||
run: ProductionRun,
|
||||
event_type: str,
|
||||
user: User,
|
||||
supervisor: Optional[User] = None,
|
||||
note: Optional[str] = None,
|
||||
) -> ProductionEvent:
|
||||
"""Append to the run's trace.
|
||||
|
||||
Added through the session rather than run.events: touching the collection would
|
||||
trigger a lazy load, which raises under async SQLAlchemy.
|
||||
"""
|
||||
event = ProductionEvent(
|
||||
run_id=run.id,
|
||||
event_type=event_type,
|
||||
user_id=user.id,
|
||||
supervisor_id=supervisor.id if supervisor else None,
|
||||
note=note,
|
||||
created_at=_now(),
|
||||
)
|
||||
db.add(event)
|
||||
return event
|
||||
|
||||
|
||||
async def open_run(
|
||||
db: AsyncSession, data: ProductionRunCreate, operator: User,
|
||||
) -> ProductionRun:
|
||||
"""Start a production at a station.
|
||||
|
||||
Refuses if the station already has one open: two runs on the same station would
|
||||
mean two timers and two histories for one physical line.
|
||||
"""
|
||||
station = await get_station_by_code(db, data.station_code)
|
||||
|
||||
existing = await get_open_run_for_station(db, station.id)
|
||||
if existing is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Station '{station.code}' already has an open production run",
|
||||
)
|
||||
|
||||
recipe_row = await db.execute(select(Recipe).where(Recipe.id == data.recipe_id))
|
||||
recipe = recipe_row.scalar_one_or_none()
|
||||
if recipe is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Recipe not found",
|
||||
)
|
||||
|
||||
if data.version_id is not None:
|
||||
version_row = await db.execute(
|
||||
select(RecipeVersion).where(
|
||||
RecipeVersion.id == data.version_id,
|
||||
RecipeVersion.recipe_id == recipe.id,
|
||||
)
|
||||
)
|
||||
version = version_row.scalar_one_or_none()
|
||||
if version is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Recipe version not found for this recipe",
|
||||
)
|
||||
else:
|
||||
version_row = await db.execute(
|
||||
select(RecipeVersion).where(
|
||||
RecipeVersion.recipe_id == recipe.id,
|
||||
RecipeVersion.is_current == True, # noqa: E712
|
||||
)
|
||||
)
|
||||
version = version_row.scalar_one_or_none()
|
||||
if version is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Recipe has no current version",
|
||||
)
|
||||
|
||||
now = _now()
|
||||
# The interval is copied, not referenced: editing the recipe mid-production must
|
||||
# not move the deadline of a run already under way.
|
||||
interval = recipe.measurement_interval_minutes
|
||||
run = ProductionRun(
|
||||
station_id=station.id,
|
||||
recipe_id=recipe.id,
|
||||
version_id=version.id,
|
||||
operator_id=operator.id,
|
||||
lot_number=data.lot_number,
|
||||
serial_number=data.serial_number,
|
||||
status="running",
|
||||
measurement_interval_minutes=interval,
|
||||
next_measurement_at=(
|
||||
now + timedelta(minutes=interval) if interval and interval > 0 else None
|
||||
),
|
||||
cycle_count=0,
|
||||
started_at=now,
|
||||
active_station_id=station.id,
|
||||
)
|
||||
db.add(run)
|
||||
await db.flush()
|
||||
_add_event(db, run, "start", operator)
|
||||
await db.flush()
|
||||
await db.refresh(run)
|
||||
return run
|
||||
|
||||
|
||||
def _require_open(run: ProductionRun) -> None:
|
||||
if run.status == "closed":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Production run is already closed",
|
||||
)
|
||||
|
||||
|
||||
async def complete_cycle(
|
||||
db: AsyncSession, run: ProductionRun, user: User, note: Optional[str] = None,
|
||||
) -> ProductionRun:
|
||||
"""Record a finished measurement cycle and restart the interval."""
|
||||
_require_open(run)
|
||||
if run.status == "paused":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Production run is paused: resume it before measuring",
|
||||
)
|
||||
|
||||
now = _now()
|
||||
run.cycle_count += 1
|
||||
if run.measurement_interval_minutes and run.measurement_interval_minutes > 0:
|
||||
# Measured from now, not from the previous deadline: a late measurement must
|
||||
# not compress the following interval.
|
||||
run.next_measurement_at = now + timedelta(
|
||||
minutes=run.measurement_interval_minutes
|
||||
)
|
||||
_add_event(db, run, "cycle_completed", user, note=note)
|
||||
await db.flush()
|
||||
await db.refresh(run)
|
||||
return run
|
||||
|
||||
|
||||
async def pause_run(
|
||||
db: AsyncSession,
|
||||
run: ProductionRun,
|
||||
user: User,
|
||||
supervisor: User,
|
||||
note: Optional[str] = None,
|
||||
) -> ProductionRun:
|
||||
"""Fermo linea: suspend the run and freeze the countdown."""
|
||||
_require_open(run)
|
||||
if run.status == "paused":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT, detail="Production run is already paused",
|
||||
)
|
||||
run.status = "paused"
|
||||
run.paused_at = _now()
|
||||
_add_event(db, run, "line_stop", user, supervisor=supervisor, note=note)
|
||||
await db.flush()
|
||||
await db.refresh(run)
|
||||
return run
|
||||
|
||||
|
||||
async def resume_run(
|
||||
db: AsyncSession,
|
||||
run: ProductionRun,
|
||||
user: User,
|
||||
supervisor: User,
|
||||
note: Optional[str] = None,
|
||||
) -> ProductionRun:
|
||||
"""Restart a paused run, pushing the deadline out by the length of the stop."""
|
||||
_require_open(run)
|
||||
if run.status != "paused":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT, detail="Production run is not paused",
|
||||
)
|
||||
now = _now()
|
||||
if run.next_measurement_at is not None and run.paused_at is not None:
|
||||
# Shift rather than recompute: whatever was left when the line stopped is
|
||||
# what the operator gets back, so a stop neither grants nor costs time.
|
||||
run.next_measurement_at = run.next_measurement_at + (now - run.paused_at)
|
||||
run.status = "running"
|
||||
run.paused_at = None
|
||||
_add_event(db, run, "resume", user, supervisor=supervisor, note=note)
|
||||
await db.flush()
|
||||
await db.refresh(run)
|
||||
return run
|
||||
|
||||
|
||||
async def close_run(
|
||||
db: AsyncSession,
|
||||
run: ProductionRun,
|
||||
user: User,
|
||||
supervisor: User,
|
||||
note: Optional[str] = None,
|
||||
) -> ProductionRun:
|
||||
"""Fine produzione: close the run for good, stop the timer, emit the statistics.
|
||||
|
||||
Everything here happens without the ERP. Handing the production over to GAIA
|
||||
goes at the marked seam below, once D-1 and D-2 are answered; until then the
|
||||
file is produced and the run is properly closed, which is what the shop floor
|
||||
needs either way.
|
||||
"""
|
||||
_require_open(run)
|
||||
run.status = "closed"
|
||||
run.closed_at = _now()
|
||||
run.closed_by = supervisor.id
|
||||
run.next_measurement_at = None
|
||||
run.paused_at = None
|
||||
# Releasing the slot lets the station open a new run; the unique index on this
|
||||
# column is what keeps exactly one open at a time.
|
||||
run.active_station_id = None
|
||||
_add_event(db, run, "close", user, supervisor=supervisor, note=note)
|
||||
await db.flush()
|
||||
|
||||
# Statistics file for the whole production, and the measurements marked as sent.
|
||||
run.statistics_path = await production_export_service.export_run_statistics(db, run)
|
||||
|
||||
# --- ERP hand-off (GAIA) plugs in here once the protocol is defined (D-1, D-2).
|
||||
# Deliberately absent rather than stubbed: an empty call that looks wired is
|
||||
# worse than none at all.
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(run)
|
||||
return run
|
||||
@@ -7,12 +7,12 @@ from sqlalchemy import func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from models.measurement import Measurement
|
||||
from models.recipe import Recipe, RecipeVersion
|
||||
from models.setting import RecipeVersionAudit
|
||||
from models.task import RecipeSubtask, RecipeTask
|
||||
from models.user import User
|
||||
from schemas.recipe import RecipeCreate, RecipeUpdate
|
||||
from src.backend.models.orm.measurement import Measurement
|
||||
from src.backend.models.orm.recipe import Recipe, RecipeVersion
|
||||
from src.backend.models.orm.setting import RecipeVersionAudit
|
||||
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
||||
from src.backend.models.orm.user import User
|
||||
from src.backend.models.api.recipe import RecipeCreate, RecipeUpdate
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -134,6 +134,7 @@ async def create_recipe(
|
||||
name=data.name,
|
||||
description=data.description,
|
||||
image_path=data.image_path,
|
||||
measurement_interval_minutes=data.measurement_interval_minutes,
|
||||
created_by=user.id,
|
||||
)
|
||||
db.add(recipe)
|
||||
@@ -273,6 +274,8 @@ async def create_new_version(
|
||||
update_fields["description"] = data.description
|
||||
if data.image_path is not None:
|
||||
update_fields["image_path"] = data.image_path
|
||||
if data.measurement_interval_minutes is not None:
|
||||
update_fields["measurement_interval_minutes"] = data.measurement_interval_minutes
|
||||
if update_fields:
|
||||
await db.execute(
|
||||
update(Recipe).where(Recipe.id == recipe_id).values(**update_fields)
|
||||
@@ -369,6 +372,8 @@ async def update_current_version(
|
||||
update_fields["description"] = data.description
|
||||
if data.image_path is not None:
|
||||
update_fields["image_path"] = data.image_path
|
||||
if data.measurement_interval_minutes is not None:
|
||||
update_fields["measurement_interval_minutes"] = data.measurement_interval_minutes
|
||||
if update_fields:
|
||||
await db.execute(
|
||||
update(Recipe).where(Recipe.id == recipe_id).values(**update_fields)
|
||||
@@ -10,13 +10,13 @@ from sqlalchemy import and_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from weasyprint import HTML
|
||||
|
||||
from config import settings
|
||||
from models.measurement import Measurement
|
||||
from models.recipe import Recipe, RecipeVersion
|
||||
from models.setting import SystemSetting
|
||||
from models.task import RecipeSubtask, RecipeTask
|
||||
from models.user import User
|
||||
from services.spc_service import (
|
||||
from src.backend.config import settings
|
||||
from src.backend.models.orm.measurement import Measurement
|
||||
from src.backend.models.orm.recipe import Recipe, RecipeVersion
|
||||
from src.backend.models.orm.setting import SystemSetting
|
||||
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
||||
from src.backend.models.orm.user import User
|
||||
from src.backend.services.spc_service import (
|
||||
compute_capability,
|
||||
compute_control_chart,
|
||||
compute_histogram,
|
||||
@@ -9,7 +9,7 @@ import math
|
||||
import statistics as stats
|
||||
from datetime import datetime
|
||||
|
||||
from schemas.statistics import (
|
||||
from src.backend.models.api.statistics import (
|
||||
CapabilityData,
|
||||
ControlChartData,
|
||||
HistogramData,
|
||||
@@ -10,10 +10,10 @@ from fastapi import HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from models.recipe import Recipe
|
||||
from models.station import Station, StationRecipeAssignment
|
||||
from models.user import User
|
||||
from schemas.station import StationCreate, StationUpdate
|
||||
from src.backend.models.orm.recipe import Recipe
|
||||
from src.backend.models.orm.station import Station, StationRecipeAssignment
|
||||
from src.backend.models.orm.user import User
|
||||
from src.backend.models.api.station import StationCreate, StationUpdate
|
||||
|
||||
|
||||
async def create_station(
|
||||
@@ -138,6 +138,26 @@ async def unassign_recipe(
|
||||
await db.flush()
|
||||
|
||||
|
||||
async def unassign_all_recipes(db: AsyncSession, station_id: int) -> int:
|
||||
"""Drop every recipe assignment of a station and return how many were removed.
|
||||
|
||||
Used by the per-row reset in the admin station list: the station goes back to
|
||||
having no recipes so it can be reassigned from scratch. Unlike unassign_recipe
|
||||
this is idempotent - resetting an already empty station returns 0 rather than 404.
|
||||
"""
|
||||
await get_station(db, station_id)
|
||||
result = await db.execute(
|
||||
select(StationRecipeAssignment).where(
|
||||
StationRecipeAssignment.station_id == station_id
|
||||
)
|
||||
)
|
||||
assignments = list(result.scalars().all())
|
||||
for assignment in assignments:
|
||||
await db.delete(assignment)
|
||||
await db.flush()
|
||||
return len(assignments)
|
||||
|
||||
|
||||
async def list_station_recipes(
|
||||
db: AsyncSession, station_id: int,
|
||||
) -> list[Recipe]:
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user