Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
+2
-6
@@ -28,14 +28,10 @@ htmlcov/
|
||||
# Node
|
||||
node_modules/
|
||||
|
||||
# Local-only notes (Markdown docs are still copied if a Dockerfile asks)
|
||||
PIANO_IMPLEMENTAZIONE.md
|
||||
# Local-only notes
|
||||
docs/
|
||||
*.docx
|
||||
|
||||
# Legacy Dockerfiles preserved in tree but not needed in builds
|
||||
src/backend/Dockerfile.legacy
|
||||
src/frontend/flask_app/Dockerfile.legacy
|
||||
|
||||
# Uploads are a runtime volume, never baked in.
|
||||
uploads/
|
||||
|
||||
|
||||
+8
-1
@@ -26,12 +26,19 @@ API_SERVER_URL=http://localhost:8000
|
||||
STATION_CODE=ST-DEFAULT
|
||||
|
||||
# --- File Storage ---
|
||||
UPLOAD_DIR=server/uploads
|
||||
# Resolved against the project root in src/backend/config.py.
|
||||
# Default "uploads" maps to <project_root>/uploads, mounted as a Docker
|
||||
# volume in production.
|
||||
UPLOAD_DIR=uploads
|
||||
MAX_UPLOAD_SIZE_MB=50
|
||||
|
||||
# --- Setup Page ---
|
||||
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
|
||||
|
||||
@@ -38,6 +38,7 @@ uploads/images/*
|
||||
uploads/pdfs/*
|
||||
uploads/logos/*
|
||||
uploads/reports/*
|
||||
uploads/general/
|
||||
!uploads/images/.gitkeep
|
||||
!uploads/pdfs/.gitkeep
|
||||
!uploads/logos/.gitkeep
|
||||
@@ -48,6 +49,8 @@ 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
|
||||
@@ -70,3 +73,4 @@ nul
|
||||
|
||||
# Competitor analysis (local only)
|
||||
Concorrente/
|
||||
docker-compose.override.yml
|
||||
|
||||
@@ -123,7 +123,7 @@ uv lock # rigenera uv.lock
|
||||
### i18n (Traduzioni)
|
||||
```bash
|
||||
# Estrai stringhe
|
||||
cd src/frontend/flask_app && uv run 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 src/frontend/flask_app && uv run pybabel update -i translations/messages.pot -d translations
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
# TieMeasureFlow by Tielogic
|
||||
|
||||
Sistema di gestione task per misurazioni con calibro manuale. Soluzione tablet-first, multi-ruolo, con statistiche SPC (Statistical Process Control) integrate.
|
||||
Sistema di gestione task per misurazioni con calibro manuale. Soluzione tablet-first, multi-ruolo, con statistiche SPC (Statistical Process Control) integrate, identità per-stazione e rate limiting per-tablet.
|
||||
|
||||
> **Versione corrente:** V2.0.0 (in sviluppo) — branch default `V2.0.0`.
|
||||
> Per stato dettagliato e prossimi passi vedi [`docs/architecture/STATO_PROGETTO.md`](docs/architecture/STATO_PROGETTO.md) e [`docs/architecture/ROADMAP.md`](docs/architecture/ROADMAP.md).
|
||||
|
||||
---
|
||||
|
||||
@@ -14,8 +17,10 @@ Caratteristiche principali:
|
||||
- Calcolo pass/fail/warning su quattro limiti di tolleranza (UTL, UWL, LWL, LTL)
|
||||
- SPC: Cp, Cpk, Pp, Ppk, control chart UCL/LCL, istogramma con curva normale — puro stdlib (no numpy)
|
||||
- Annotazioni grafiche su disegni tecnici (Fabric.js) con viewer sincronizzato all'esecuzione
|
||||
- Identità per-stazione (`STATION_CODE`): ogni tablet vede solo le ricette assegnate alla propria stazione, gestione assegnazioni via GUI admin
|
||||
- Interfaccia completamente localizzata IT/EN, dark mode, ottimizzata per tablet touch
|
||||
- Autenticazione API Key, rate limiting sliding window, audit log persistente
|
||||
- Autenticazione API Key, rate limiting sliding window per-IP reale (X-Forwarded-For-aware), audit log persistente
|
||||
- Capacità testata per ~20 tablet contemporanei (gunicorn 5 workers × 4 thread + uvicorn 4 workers async)
|
||||
|
||||
---
|
||||
|
||||
@@ -26,25 +31,25 @@ Browser/Tablet
|
||||
|
|
||||
Reverse Proxy (Nginx — sviluppo | Traefik+SSL — produzione)
|
||||
|
|
||||
Flask Client :5000 (rendering server-side, Jinja2 + Alpine.js)
|
||||
| X-API-Key
|
||||
FastAPI Server :8000 (API REST asincrona)
|
||||
Flask Frontend :5000 (rendering server-side, Jinja2 + Alpine.js)
|
||||
| X-API-Key + X-Forwarded-For
|
||||
FastAPI Backend :8000 (API REST asincrona)
|
||||
|
|
||||
MySQL 8.0
|
||||
```
|
||||
|
||||
Il client Flask non espone mai le credenziali al browser: ogni chiamata al backend avviene server-side con l'header `X-API-Key` estratto dalla sessione Flask.
|
||||
Il frontend Flask non espone mai le credenziali al browser: ogni chiamata al backend avviene server-side con l'header `X-API-Key` estratto dalla sessione Flask. L'IP reale del tablet è propagato in `X-Forwarded-For` per il rate limiter.
|
||||
|
||||
---
|
||||
|
||||
## Stack Tecnologico
|
||||
|
||||
### Backend (server/)
|
||||
### Backend (`src/backend/`)
|
||||
|
||||
| Componente | Versione | Ruolo |
|
||||
|---|---|---|
|
||||
| FastAPI | ultima stabile | Framework API REST asincrono |
|
||||
| SQLAlchemy 2.0 | async | ORM con pool connessioni |
|
||||
| SQLAlchemy 2.0 | async | ORM con pool connessioni 10+20 |
|
||||
| asyncmy | ultima stabile | Driver MySQL asincrono |
|
||||
| MySQL | 8.0 | Database relazionale |
|
||||
| Alembic | ultima stabile | Migrazioni schema |
|
||||
@@ -54,35 +59,47 @@ Il client Flask non espone mai le credenziali al browser: ogni chiamata al backe
|
||||
| bcrypt | ultima stabile | Hashing password |
|
||||
| Pillow | ultima stabile | Thumbnail automatici upload |
|
||||
|
||||
### Frontend (client/)
|
||||
### Frontend (`src/frontend/flask_app/`)
|
||||
|
||||
| Componente | Versione | Ruolo |
|
||||
|---|---|---|
|
||||
| Flask | 3.x | Framework web server-side |
|
||||
| gunicorn | 21+ | WSGI server (5 workers × 4 thread gthread) |
|
||||
| Jinja2 | incluso in Flask | Template engine |
|
||||
| Alpine.js | 3.x (CDN) | Reattivita leggera lato client |
|
||||
| Alpine.js | 3.x (CDN) | Reattività leggera lato client |
|
||||
| TailwindCSS | 3.x | CSS utility-first |
|
||||
| Plotly.js | CDN | Grafici SPC interattivi |
|
||||
| Fabric.js | 5.3.1 (CDN) | Editor annotazioni disegni tecnici |
|
||||
| html5-qrcode | CDN | Scanner barcode/QR camera |
|
||||
| Flask-Babel | ultima stabile | i18n IT/EN |
|
||||
|
||||
### Tooling
|
||||
|
||||
| Componente | Ruolo |
|
||||
|---|---|
|
||||
| **uv** | Package manager Python (no `requirements.txt`) |
|
||||
| `pyproject.toml` | Dipendenze monorepo con extra `server`/`client`/`dev` |
|
||||
| `uv.lock` | Lockfile per build riproducibili |
|
||||
| `.python-version` | Pin Python 3.11 |
|
||||
| pytest + pytest-asyncio + httpx + aiosqlite | Test stack |
|
||||
|
||||
---
|
||||
|
||||
## Quick Start con Docker
|
||||
|
||||
Docker Compose è il metodo raccomandato. Gestisce database, migrazioni e configurazione Nginx in un solo comando.
|
||||
Docker Compose è il metodo raccomandato. Gestisce database, migrazioni, build delle immagini con `uv` e configurazione Nginx in un solo comando.
|
||||
|
||||
```bash
|
||||
# 1. Clona il repository
|
||||
git clone <repository-url>
|
||||
git clone ssh://git@git.tielogic.xyz:222/Adriano/TieMeasureFlow.git
|
||||
cd TieMeasureFlow
|
||||
|
||||
# 2. Configura le variabili d'ambiente
|
||||
cp .env.example .env
|
||||
# Modifica .env: credenziali DB, chiavi segrete, SETUP_PASSWORD
|
||||
# Modifica .env: credenziali DB, chiavi segrete, SETUP_PASSWORD, STATION_CODE per ogni tablet
|
||||
|
||||
# 3. Avvia i servizi (ambiente di sviluppo)
|
||||
docker compose -f docker-compose.dev.yml up -d
|
||||
docker compose -f docker-compose.dev.yml up -d --build
|
||||
|
||||
# 4. Verifica lo stato dei container
|
||||
docker compose -f docker-compose.dev.yml ps
|
||||
@@ -90,6 +107,7 @@ docker compose -f docker-compose.dev.yml ps
|
||||
# 5. Setup iniziale (solo al primo avvio)
|
||||
# Apri http://localhost/api/setup nel browser
|
||||
# Usa SETUP_PASSWORD configurata in .env
|
||||
# Lo script seed crea anche la stazione ST-DEFAULT con tutte le ricette assegnate
|
||||
```
|
||||
|
||||
L'applicazione sarà disponibile su:
|
||||
@@ -97,8 +115,9 @@ L'applicazione sarà disponibile su:
|
||||
- Frontend: http://localhost
|
||||
- API: http://localhost/api
|
||||
- Pagina setup: http://localhost/api/setup
|
||||
- Admin stazioni: http://localhost/admin/stations (solo `is_admin`)
|
||||
|
||||
Per il deployment in produzione (Traefik + SSL) consulta [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md).
|
||||
Per il deployment in produzione (Traefik + SSL) consulta [`docs/DEPLOYMENT.md`](docs/DEPLOYMENT.md).
|
||||
|
||||
---
|
||||
|
||||
@@ -108,12 +127,14 @@ Dopo il primo avvio, la pagina `/api/setup` (protetta da `SETUP_PASSWORD`) perme
|
||||
|
||||
- **Initialize Database** — crea tutte le tabelle
|
||||
- **Create Admin User** — crea l'utente amministratore con credenziali da `.env`
|
||||
- **Seed Demo Data** — carica ricette, misurazioni e utenti di esempio
|
||||
- **Seed Demo Data** — carica ricette, misurazioni e utenti di esempio + crea stazione `ST-DEFAULT` con tutte le ricette assegnate
|
||||
- **Reset Database** — elimina e ricrea tutte le tabelle (attenzione: cancella tutti i dati)
|
||||
- **Gestione utenti** — crea, modifica, attiva/disattiva account dalla stessa pagina
|
||||
|
||||
Se `SETUP_PASSWORD` è vuota o assente nel `.env`, l'endpoint è disabilitato.
|
||||
|
||||
Per gestire stazioni e assegnazioni ricette dopo il setup: `/admin/stations` (richiede login admin).
|
||||
|
||||
---
|
||||
|
||||
## Setup Manuale (Senza Docker)
|
||||
@@ -121,8 +142,9 @@ Se `SETUP_PASSWORD` è vuota o assente nel `.env`, l'endpoint è disabilitato.
|
||||
### Requisiti
|
||||
|
||||
- Python 3.11 o superiore
|
||||
- Node.js 18 o superiore
|
||||
- Node.js 18 o superiore (per TailwindCSS)
|
||||
- MySQL 8.0
|
||||
- [uv](https://docs.astral.sh/uv/) installato
|
||||
|
||||
### 1. Database MySQL
|
||||
|
||||
@@ -139,35 +161,37 @@ SQL
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Imposta DB_HOST, DB_USER, DB_PASSWORD, DB_NAME, SERVER_SECRET_KEY, CLIENT_SECRET_KEY, SETUP_PASSWORD
|
||||
# Imposta DB_HOST, DB_USER, DB_PASSWORD, DB_NAME, SERVER_SECRET_KEY,
|
||||
# CLIENT_SECRET_KEY, SETUP_PASSWORD, STATION_CODE
|
||||
```
|
||||
|
||||
### 3. Server FastAPI
|
||||
### 3. Installa dipendenze (uv)
|
||||
|
||||
```bash
|
||||
cd server
|
||||
python -m venv venv
|
||||
source venv/bin/activate # Windows: venv\Scripts\activate
|
||||
pip install -r requirements.txt
|
||||
alembic -c migrations/alembic.ini upgrade head
|
||||
uvicorn main:app --reload --host 0.0.0.0 --port 8000
|
||||
uv sync --extra server --extra client --extra dev
|
||||
```
|
||||
|
||||
### 4. Client Flask
|
||||
### 4. Backend FastAPI
|
||||
|
||||
```bash
|
||||
cd client
|
||||
python -m venv venv
|
||||
source venv/bin/activate # Windows: venv\Scripts\activate
|
||||
pip install -r requirements.txt
|
||||
pybabel compile -d translations
|
||||
flask run --host 0.0.0.0 --port 5000
|
||||
uv run alembic -c src/backend/migrations/alembic.ini upgrade head
|
||||
uv run uvicorn src.backend.main:app --reload --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
### 5. TailwindCSS (watch per sviluppo)
|
||||
### 5. Frontend Flask
|
||||
|
||||
```bash
|
||||
cd client
|
||||
# Compila i cataloghi i18n una volta
|
||||
cd src/frontend/flask_app && uv run --project ../../.. pybabel compile -d translations && cd -
|
||||
|
||||
# Avvia (development)
|
||||
cd src/frontend/flask_app && uv run --project ../../.. flask run --host 0.0.0.0 --port 5000
|
||||
```
|
||||
|
||||
### 6. TailwindCSS (watch per sviluppo)
|
||||
|
||||
```bash
|
||||
cd src/frontend/flask_app
|
||||
npx tailwindcss -i static/css/input.css -o static/css/tailwind.css --watch
|
||||
```
|
||||
|
||||
@@ -185,9 +209,9 @@ I ruoli sono combinabili (array JSON per utente). Il flag `is_admin` è separato
|
||||
| Ruolo | Descrizione |
|
||||
|---|---|
|
||||
| **Maker** | Crea e gestisce ricette di misurazione: caricamento disegni (PDF/immagini), annotazioni Fabric.js, definizione task/subtask, configurazione tolleranze, versioning copy-on-write |
|
||||
| **MeasurementTec** | Esegue misurazioni: scansione barcode per selezione ricetta, interfaccia task-driven, input da calibro USB HID o numpad touch, validazione real-time pass/warning/fail |
|
||||
| **MeasurementTec** | Esegue misurazioni: scansione barcode per selezione ricetta, interfaccia task-driven, input da calibro USB HID o numpad touch, validazione real-time pass/warning/fail. Vede solo le ricette assegnate alla propria stazione (`STATION_CODE`) |
|
||||
| **Metrologist** | Analisi qualità: dashboard SPC (X-bar, R, Cp, Cpk, Pp, Ppk), filtri multi-dimensionali, export report PDF, analisi capability e control chart |
|
||||
| **Admin** (flag) | Gestione sistema: CRUD utenti, cambio password, attivazione/disattivazione account |
|
||||
| **Admin** (flag) | Gestione sistema: CRUD utenti, cambio password, attivazione/disattivazione account, **CRUD stazioni e assegnazioni ricette** |
|
||||
|
||||
---
|
||||
|
||||
@@ -195,39 +219,53 @@ I ruoli sono combinabili (array JSON per utente). Il flag `is_admin` è separato
|
||||
|
||||
```
|
||||
TieMeasureFlow/
|
||||
├── server/ # FastAPI Backend
|
||||
│ ├── main.py # Entry point, lifespan, middleware, 10 router
|
||||
│ ├── config.py # Settings (pydantic_settings.BaseSettings)
|
||||
│ ├── database.py # SQLAlchemy 2.0 async engine
|
||||
│ ├── models/ # ORM: User, Recipe, RecipeVersion, RecipeTask,
|
||||
│ │ # RecipeSubtask, Measurement, AccessLog,
|
||||
│ │ # SystemSetting, RecipeVersionAudit
|
||||
│ ├── schemas/ # Pydantic v2 per validazione I/O API
|
||||
│ ├── routers/ # auth, users, recipes, tasks, measurements,
|
||||
│ │ # files, settings, statistics, reports, setup
|
||||
│ ├── services/ # recipe_service, measurement_service,
|
||||
│ │ # spc_service, report_service, auth_service
|
||||
│ ├── middleware/ # api_key, rate_limit, security_headers, logging
|
||||
│ ├── migrations/ # Alembic (alembic.ini + env.py)
|
||||
│ └── templates/ # Template HTML pagina setup
|
||||
├── client/ # Flask Frontend
|
||||
│ ├── app.py # Factory pattern, CSRF, Babel
|
||||
│ ├── blueprints/ # auth, maker, measure, statistics, admin
|
||||
│ ├── services/ # APIClient singleton (proxy verso FastAPI)
|
||||
│ ├── templates/ # Jinja2 + Alpine.js
|
||||
│ ├── static/
|
||||
│ │ ├── css/ # TailwindCSS compilato
|
||||
│ │ └── js/ # numpad, caliper, barcode, csv-export,
|
||||
│ │ # spc-charts, annotation-editor/viewer
|
||||
│ └── translations/ # Flask-Babel .po/.mo IT/EN
|
||||
├── nginx/ # Configurazione Nginx (dev)
|
||||
├── docs/ # Documentazione tecnica
|
||||
│ ├── API.md # Riferimento API REST
|
||||
│ ├── DEPLOYMENT.md # Guida deployment VPS (Traefik, SSL, DNS)
|
||||
│ └── USER_GUIDE.md # Manuale utente per ruolo
|
||||
├── docker-compose.yml # Produzione (Traefik, SSL)
|
||||
├── docker-compose.dev.yml # Sviluppo (Nginx, porta 80)
|
||||
└── .env.example # Template variabili d'ambiente
|
||||
├── pyproject.toml # Dipendenze monorepo (uv)
|
||||
├── uv.lock # Lockfile riproducibile
|
||||
├── .python-version # 3.11
|
||||
├── Dockerfile # Backend (uv + uvicorn)
|
||||
├── Dockerfile.frontend # Frontend (uv + gunicorn + Tailwind + Babel)
|
||||
├── docker-compose.dev.yml # Sviluppo (Nginx, porta 80)
|
||||
├── docker-compose.yml # Produzione (Traefik, SSL)
|
||||
├── nginx/ # Config Nginx (dev)
|
||||
├── uploads/ # Volume Docker file caricati
|
||||
├── docs/ # Documentazione (vedi indice docs/README.md)
|
||||
└── src/
|
||||
├── backend/ # FastAPI Backend
|
||||
│ ├── main.py # Entry point, lifespan, middleware, 11 router
|
||||
│ ├── config.py # Settings (pydantic_settings.BaseSettings)
|
||||
│ ├── database.py # SQLAlchemy 2.0 async engine
|
||||
│ ├── api/
|
||||
│ │ ├── routers/ # auth, users, recipes, tasks, measurements,
|
||||
│ │ │ # files, settings, statistics, reports,
|
||||
│ │ │ # setup, stations
|
||||
│ │ └── middleware/ # api_key, rate_limit, security_headers, logging
|
||||
│ ├── models/
|
||||
│ │ ├── orm/ # SQLAlchemy: User, Recipe, RecipeVersion,
|
||||
│ │ │ # RecipeTask, RecipeSubtask, Measurement,
|
||||
│ │ │ # AccessLog, SystemSetting,
|
||||
│ │ │ # RecipeVersionAudit, Station,
|
||||
│ │ │ # StationRecipeAssignment
|
||||
│ │ └── api/ # Pydantic v2 schemas request/response
|
||||
│ ├── services/ # recipe_service, measurement_service,
|
||||
│ │ # spc_service, report_service,
|
||||
│ │ # auth_service, station_service
|
||||
│ ├── migrations/ # Alembic (alembic.ini + env.py)
|
||||
│ ├── templates/ # Pagina setup (Jinja2)
|
||||
│ └── tests/ # pytest + httpx + aiosqlite
|
||||
└── frontend/
|
||||
└── flask_app/ # Flask Frontend
|
||||
├── app.py # Factory + ProxyFix + CSRF + Babel
|
||||
├── config.py # STATION_CODE, API_SERVER_URL, ecc.
|
||||
├── compile_translations.py
|
||||
├── blueprints/ # auth, maker, measure, statistics, admin
|
||||
├── services/ # APIClient (proxy verso FastAPI con XFF)
|
||||
├── templates/ # Jinja2 + Alpine.js
|
||||
├── static/
|
||||
│ ├── css/ # TailwindCSS compilato
|
||||
│ └── js/ # numpad, caliper, barcode, csv-export,
|
||||
│ # spc-charts, annotation-editor/viewer
|
||||
├── translations/ # Flask-Babel .po/.mo IT/EN
|
||||
└── tests/
|
||||
```
|
||||
|
||||
---
|
||||
@@ -238,7 +276,7 @@ TieMeasureFlow/
|
||||
|
||||
| Comando | Descrizione |
|
||||
|---|---|
|
||||
| `docker compose -f docker-compose.dev.yml up -d` | Avvia servizi in sviluppo |
|
||||
| `docker compose -f docker-compose.dev.yml up -d --build` | Avvia servizi in sviluppo (build incluso) |
|
||||
| `docker compose -f docker-compose.dev.yml down` | Ferma e rimuove i container |
|
||||
| `docker compose logs -f server` | Segui log server in tempo reale |
|
||||
| `docker compose logs -f client` | Segui log client in tempo reale |
|
||||
@@ -248,47 +286,62 @@ TieMeasureFlow/
|
||||
|
||||
### Alembic (migrations)
|
||||
|
||||
Nota: `alembic.ini` si trova dentro `server/migrations/`, è richiesto il flag `-c`.
|
||||
`alembic.ini` si trova in `src/backend/migrations/`, è richiesto il flag `-c`. `env.py` aggiunge la project root a `sys.path` per risolvere `src.backend.*`.
|
||||
|
||||
```bash
|
||||
cd server
|
||||
alembic -c migrations/alembic.ini upgrade head # Applica migrazioni
|
||||
alembic -c migrations/alembic.ini revision --autogenerate -m "descrizione" # Genera migrazione
|
||||
alembic -c migrations/alembic.ini downgrade -1 # Rollback ultima
|
||||
uv run alembic -c src/backend/migrations/alembic.ini upgrade head # Applica migrazioni
|
||||
uv run alembic -c src/backend/migrations/alembic.ini revision --autogenerate -m "descrizione" # Genera
|
||||
uv run alembic -c src/backend/migrations/alembic.ini downgrade -1 # Rollback ultima
|
||||
```
|
||||
|
||||
Via Docker:
|
||||
|
||||
```bash
|
||||
docker compose exec server alembic -c migrations/alembic.ini upgrade head
|
||||
docker compose exec server uv run alembic -c src/backend/migrations/alembic.ini upgrade head
|
||||
```
|
||||
|
||||
### i18n (Traduzioni)
|
||||
|
||||
```bash
|
||||
cd client
|
||||
pybabel extract -F babel.cfg -k _ -o translations/messages.pot . # Estrai stringhe
|
||||
pybabel update -i translations/messages.pot -d translations # Aggiorna catalogo
|
||||
pybabel compile -d translations # Compila .po → .mo
|
||||
cd src/frontend/flask_app
|
||||
uv run --project ../../.. pybabel extract -F babel.cfg -k _ -o translations/messages.pot . # Estrai
|
||||
uv run --project ../../.. pybabel update -i translations/messages.pot -d translations # Aggiorna
|
||||
uv run --project ../../.. pybabel compile -d translations # Compila .po → .mo
|
||||
```
|
||||
|
||||
### Gestione dipendenze (uv)
|
||||
|
||||
```bash
|
||||
uv add <pacchetto> # core (entrambi)
|
||||
uv add --optional server <pacchetto> # solo backend
|
||||
uv add --optional client <pacchetto> # solo frontend
|
||||
uv add --optional dev <pacchetto> # solo dev/test
|
||||
uv sync --extra server --extra client --extra dev # reinstalla
|
||||
uv lock # rigenera uv.lock
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
Il server usa SQLite in-memory tramite `aiosqlite`: i test girano senza MySQL installato.
|
||||
Il backend usa SQLite in-memory tramite `aiosqlite`: i test girano senza MySQL installato.
|
||||
|
||||
```bash
|
||||
# Server
|
||||
cd server && pytest # Tutti i test
|
||||
cd server && pytest tests/test_auth.py # Singolo modulo
|
||||
cd server && pytest tests/test_auth.py::test_login_success # Singolo test
|
||||
cd server && pytest --cov # Con copertura
|
||||
# Tutti i test (backend + frontend)
|
||||
uv run pytest
|
||||
|
||||
# Client
|
||||
cd client && pytest
|
||||
# Solo backend
|
||||
uv run pytest src/backend/tests/
|
||||
uv run pytest src/backend/tests/test_auth.py
|
||||
uv run pytest src/backend/tests/test_auth.py::test_login_success
|
||||
uv run pytest --cov src/backend
|
||||
|
||||
# Solo frontend
|
||||
uv run pytest src/frontend/flask_app/tests/
|
||||
```
|
||||
|
||||
Stato corrente: **171 pass, 4 fail pre-esistenti** (vedi `docs/architecture/STATO_PROGETTO.md`).
|
||||
|
||||
---
|
||||
|
||||
## Variabili d'Ambiente
|
||||
@@ -304,8 +357,11 @@ Copia `.env.example` in `.env` e configura:
|
||||
| `SERVER_CORS_ORIGINS` | Origini CORS ammesse |
|
||||
| `CLIENT_SECRET_KEY` | Chiave segreta Flask (sessioni, CSRF) |
|
||||
| `API_SERVER_URL` | URL del backend visto dal client (es. `http://server:8000`) |
|
||||
| `UPLOAD_DIR` | Percorso upload file (default: `uploads`, relativo a `server/`) |
|
||||
| `MAX_UPLOAD_SIZE_MB` | Limite dimensione upload |
|
||||
| `STATION_CODE` | **Per-tablet** — codice stazione (es. `ST-001`). Senza, il client mostra errore configurazione. |
|
||||
| `UPLOAD_DIR` | Percorso upload file (default: `uploads`, project root) |
|
||||
| `MAX_UPLOAD_SIZE_MB` | Limite dimensione upload (default 50) |
|
||||
| `RATE_LIMIT_LOGIN` | Login req/min/IP (default 5) |
|
||||
| `RATE_LIMIT_GENERAL` | Richieste req/min/IP (default 300, per-tablet) |
|
||||
| `NGINX_PORT`, `NGINX_SSL_PORT` | Porte Nginx (solo compose dev) |
|
||||
| `SETUP_PASSWORD` | Password pagina setup (vuota = endpoint disabilitato) |
|
||||
| `SSL_CERTFILE`, `SSL_KEYFILE` | Certificato SSL (solo setup manuale) |
|
||||
@@ -314,16 +370,35 @@ Copia `.env.example` in `.env` e configura:
|
||||
|
||||
## Documentazione
|
||||
|
||||
Indice completo: [`docs/README.md`](docs/README.md).
|
||||
|
||||
### Stato e direzione
|
||||
|
||||
| Documento | Contenuto |
|
||||
|---|---|
|
||||
| [docs/API.md](docs/API.md) | Riferimento completo API REST (endpoint, parametri, schemi) |
|
||||
| [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md) | Guida deployment VPS: Docker, Traefik, SSL, DNS, firewall |
|
||||
| [docs/USER_GUIDE.md](docs/USER_GUIDE.md) | Manuale utente per ruolo (Maker, MeasurementTec, Metrologist) |
|
||||
| [`docs/architecture/STATO_PROGETTO.md`](docs/architecture/STATO_PROGETTO.md) | Snapshot V2.0.0: cosa funziona oggi, test status, decisioni architetturali |
|
||||
| [`docs/architecture/ROADMAP.md`](docs/architecture/ROADMAP.md) | Cosa resta da fare (Fasi 2-7 rev04, decisioni cliente aperte, stime) |
|
||||
|
||||
### Riferimenti operativi
|
||||
|
||||
| Documento | Contenuto |
|
||||
|---|---|
|
||||
| [`docs/API.md`](docs/API.md) | Riferimento completo API REST (endpoint, parametri, schemi) |
|
||||
| [`docs/DEPLOYMENT.md`](docs/DEPLOYMENT.md) | Guida deployment VPS: Docker, Traefik, SSL, DNS, firewall |
|
||||
| [`docs/USER_GUIDE.md`](docs/USER_GUIDE.md) | Manuale utente per ruolo (Maker, MeasurementTec, Metrologist) |
|
||||
| [`docs/I18N_SETUP.md`](docs/I18N_SETUP.md) | Setup e workflow traduzioni (Flask-Babel + Alpine.js) |
|
||||
|
||||
### Piani dettagliati
|
||||
|
||||
| Documento | Contenuto |
|
||||
|---|---|
|
||||
| [`docs/superpowers/plans/2026-04-17-rev04-master-roadmap.md`](docs/superpowers/plans/2026-04-17-rev04-master-roadmap.md) | Master plan rev04 (M1 + M2, decisioni aperte, stime) |
|
||||
| [`docs/superpowers/plans/2026-04-17-rev04-phase1-stations.md`](docs/superpowers/plans/2026-04-17-rev04-phase1-stations.md) | Piano TDD Fase 1 stazioni (completato) |
|
||||
|
||||
---
|
||||
|
||||
## Licenza
|
||||
|
||||
Proprietary - Tielogic. All rights reserved.
|
||||
Proprietary — Tielogic. All rights reserved.
|
||||
|
||||
Questo software è di proprietà esclusiva di Tielogic ed è protetto dalle leggi sul copyright. Non è consentita la distribuzione, modifica o utilizzo senza autorizzazione scritta.
|
||||
|
||||
+1
-1
@@ -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`)
|
||||
@@ -30,6 +30,8 @@ server = [
|
||||
"plotly>=5.0.0",
|
||||
"kaleido>=0.2.0",
|
||||
"weasyprint>=62.0",
|
||||
"pdfplumber>=0.10.0",
|
||||
"httpx>=0.27.0",
|
||||
]
|
||||
|
||||
# Frontend (Flask tablet UI).
|
||||
|
||||
@@ -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 4 --proxy-headers --forwarded-allow-ips='*'"]
|
||||
@@ -6,6 +6,7 @@ from src.backend.api.middleware.api_key import (
|
||||
require_maker,
|
||||
require_measurement_tec,
|
||||
require_metrologist,
|
||||
require_supervisor,
|
||||
require_admin_user,
|
||||
)
|
||||
from src.backend.api.middleware.logging import AccessLogMiddleware
|
||||
@@ -19,6 +20,7 @@ __all__ = [
|
||||
"require_maker",
|
||||
"require_measurement_tec",
|
||||
"require_metrologist",
|
||||
"require_supervisor",
|
||||
"require_admin_user",
|
||||
"AccessLogMiddleware",
|
||||
"RateLimitMiddleware",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""File upload/download/delete router for images and PDFs."""
|
||||
import os
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
@@ -11,6 +11,8 @@ 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"])
|
||||
|
||||
# Allowed MIME types
|
||||
@@ -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":
|
||||
|
||||
@@ -84,24 +84,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 +119,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 +214,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)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""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 src.backend.database import get_db
|
||||
@@ -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}
|
||||
|
||||
@@ -15,6 +15,7 @@ from pydantic import BaseModel
|
||||
from sqlalchemy import inspect as sa_inspect, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
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 (
|
||||
@@ -26,7 +27,7 @@ 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(
|
||||
|
||||
@@ -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()}
|
||||
|
||||
|
||||
@@ -11,6 +11,10 @@ from src.backend.services.auth_service import create_user, hash_password, regene
|
||||
|
||||
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)
|
||||
|
||||
+11
-2
@@ -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."""
|
||||
@@ -57,8 +61,13 @@ class Settings(BaseSettings):
|
||||
# Path(__file__) = src/backend/config.py → parents[2] = project root
|
||||
return Path(__file__).resolve().parents[2] / self.upload_dir
|
||||
|
||||
# ../../.env reaches the project root from src/backend/.
|
||||
model_config = {"env_file": "../../.env", "env_file_encoding": "utf-8", "extra": "ignore"}
|
||||
# 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()
|
||||
|
||||
@@ -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')
|
||||
@@ -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
|
||||
|
||||
@@ -51,6 +51,8 @@ class RecipeSummary(BaseModel):
|
||||
code: str
|
||||
name: str
|
||||
active: bool
|
||||
image_path: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class StationWithRecipesResponse(StationResponse):
|
||||
|
||||
@@ -5,6 +5,7 @@ from src.backend.models.orm.task import RecipeTask, RecipeSubtask
|
||||
from src.backend.models.orm.measurement import Measurement
|
||||
from src.backend.models.orm.access_log import AccessLog
|
||||
from src.backend.models.orm.setting import SystemSetting, RecipeVersionAudit
|
||||
from src.backend.models.orm.station import Station, StationRecipeAssignment
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
@@ -16,4 +17,6 @@ __all__ = [
|
||||
"AccessLog",
|
||||
"SystemSetting",
|
||||
"RecipeVersionAudit",
|
||||
"Station",
|
||||
"StationRecipeAssignment",
|
||||
]
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
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 src.backend.database import 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(
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -559,6 +559,19 @@
|
||||
return data;
|
||||
}
|
||||
|
||||
/* Setup operations rotate api_keys (DB init, admin creation, password
|
||||
* change, user (de)activation). Any Flask session held in this browser
|
||||
* still carries the old key, so we clear it to force a fresh /auth/login. */
|
||||
async function invalidateClientSession() {
|
||||
try {
|
||||
await fetch('/auth/logout', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
redirect: 'manual',
|
||||
});
|
||||
} catch (_) { /* best-effort */ }
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Check DB status */
|
||||
/* ------------------------------------------------------------------ */
|
||||
@@ -635,6 +648,7 @@
|
||||
try {
|
||||
const data = await apiPost('/init-db', { password: setupPassword });
|
||||
showToast(data.message, 'success');
|
||||
await invalidateClientSession();
|
||||
await checkStatus();
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
@@ -667,6 +681,7 @@
|
||||
admin_display_name: display,
|
||||
});
|
||||
showToast(data.message, 'success');
|
||||
await invalidateClientSession();
|
||||
// Clear form
|
||||
document.getElementById('admin-username').value = '';
|
||||
document.getElementById('admin-password').value = '';
|
||||
@@ -690,6 +705,7 @@
|
||||
if (data.measurements_created) msg += ', measurements: ' + data.measurements_created;
|
||||
msg += ')';
|
||||
showToast(msg, 'success');
|
||||
await invalidateClientSession();
|
||||
await loadUsers();
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
@@ -876,6 +892,7 @@
|
||||
if (pwd) body.user_password = pwd;
|
||||
var data = await apiPost('/manage-user', body);
|
||||
showToast(data.message, 'success');
|
||||
await invalidateClientSession();
|
||||
closeModal('modal-user');
|
||||
await loadUsers();
|
||||
} catch (e) {
|
||||
@@ -908,6 +925,7 @@
|
||||
new_password: newPwd,
|
||||
});
|
||||
showToast(data.message, 'success');
|
||||
await invalidateClientSession();
|
||||
closeModal('modal-password');
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
@@ -924,6 +942,7 @@
|
||||
user_id: userId,
|
||||
});
|
||||
showToast(data.message, 'success');
|
||||
await invalidateClientSession();
|
||||
await loadUsers();
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
|
||||
@@ -211,6 +211,43 @@ class TestListMeasurements:
|
||||
assert "total" in data
|
||||
assert data["total"] >= 3
|
||||
|
||||
async def test_measurement_tec_can_list_measurements(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
measurement_tec_user: User,
|
||||
db_session: AsyncSession,
|
||||
):
|
||||
"""Regression: MeasurementTec must be able to list measurements.
|
||||
|
||||
The Flask client renders task_complete.html by calling GET
|
||||
/api/measurements?version_id=X right after the operator finishes a
|
||||
recipe. The endpoint used to require Metrologist, which silently
|
||||
produced an empty riepilogo for every operator without that role.
|
||||
"""
|
||||
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
||||
subtask_id, version_id = await _get_subtask_and_version(
|
||||
client, measurement_tec_user, recipe.id
|
||||
)
|
||||
|
||||
for val in [9.8, 10.0, 10.2]:
|
||||
await client.post(
|
||||
"/api/measurements/",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
json={
|
||||
"subtask_id": subtask_id,
|
||||
"version_id": version_id,
|
||||
"value": val,
|
||||
},
|
||||
)
|
||||
|
||||
resp = await client.get(
|
||||
f"/api/measurements/?version_id={version_id}",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
data = resp.json()
|
||||
assert data["total"] >= 3
|
||||
|
||||
async def test_measurement_by_recipe_filter(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
|
||||
@@ -1,12 +1,40 @@
|
||||
"""Tests for recipes router (/api/recipes)."""
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.backend.models.orm.measurement import Measurement
|
||||
from src.backend.models.orm.recipe import RecipeVersion
|
||||
from src.backend.models.orm.task import RecipeSubtask
|
||||
from src.backend.models.orm.user import User
|
||||
from src.backend.tests.conftest import auth_headers, create_test_recipe
|
||||
|
||||
|
||||
async def _seed_measurement(db: AsyncSession, recipe_id: int, user_id: int) -> None:
|
||||
version_id = (await db.execute(
|
||||
select(RecipeVersion.id).where(
|
||||
RecipeVersion.recipe_id == recipe_id,
|
||||
RecipeVersion.is_current == True, # noqa: E712
|
||||
)
|
||||
)).scalar_one()
|
||||
subtask_id = (await db.execute(
|
||||
select(RecipeSubtask.id)
|
||||
.join(RecipeSubtask.task)
|
||||
.where(RecipeSubtask.task.has(version_id=version_id))
|
||||
)).scalar_one()
|
||||
db.add(Measurement(
|
||||
subtask_id=subtask_id,
|
||||
version_id=version_id,
|
||||
measured_by=user_id,
|
||||
value=10.0,
|
||||
pass_fail="pass",
|
||||
deviation=0.0,
|
||||
input_method="manual",
|
||||
))
|
||||
await db.flush()
|
||||
|
||||
|
||||
class TestListRecipes:
|
||||
"""GET /api/recipes tests."""
|
||||
|
||||
@@ -160,18 +188,13 @@ class TestCreateRecipe:
|
||||
class TestUpdateRecipe:
|
||||
"""PUT /api/recipes/{recipe_id} tests."""
|
||||
|
||||
async def test_update_recipe(
|
||||
async def test_update_recipe_in_place(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
maker_user: User,
|
||||
db_session: AsyncSession,
|
||||
):
|
||||
"""Updating a recipe creates a new version (copy-on-write).
|
||||
|
||||
We verify the new version exists by listing versions, since
|
||||
SQLAlchemy identity-map staleness with StaticPool can cause
|
||||
``is_current`` to appear stale in some responses.
|
||||
"""
|
||||
"""Updating a recipe without measurements updates current version in-place."""
|
||||
recipe = await create_test_recipe(db_session, maker_user.id)
|
||||
resp = await client.put(
|
||||
f"/api/recipes/{recipe.id}",
|
||||
@@ -183,7 +206,9 @@ class TestUpdateRecipe:
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
# Verify via versions list which always does a clean query
|
||||
# expire identity-map cache so subsequent GETs see fresh data
|
||||
db_session.expunge_all()
|
||||
|
||||
versions_resp = await client.get(
|
||||
f"/api/recipes/{recipe.id}/versions",
|
||||
headers=auth_headers(maker_user),
|
||||
@@ -191,8 +216,38 @@ class TestUpdateRecipe:
|
||||
assert versions_resp.status_code == 200
|
||||
versions = versions_resp.json()
|
||||
version_numbers = [v["version_number"] for v in versions]
|
||||
assert 1 in version_numbers
|
||||
assert 2 in version_numbers
|
||||
assert version_numbers == [1]
|
||||
|
||||
async def test_update_recipe_copy_on_write(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
maker_user: User,
|
||||
db_session: AsyncSession,
|
||||
):
|
||||
"""Updating a recipe with measurements creates a new version (copy-on-write)."""
|
||||
recipe = await create_test_recipe(db_session, maker_user.id)
|
||||
await _seed_measurement(db_session, recipe.id, maker_user.id)
|
||||
db_session.expunge_all()
|
||||
|
||||
resp = await client.put(
|
||||
f"/api/recipes/{recipe.id}",
|
||||
headers=auth_headers(maker_user),
|
||||
json={
|
||||
"name": "Updated Recipe",
|
||||
"change_notes": "Triggers copy-on-write",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
db_session.expunge_all()
|
||||
|
||||
versions_resp = await client.get(
|
||||
f"/api/recipes/{recipe.id}/versions",
|
||||
headers=auth_headers(maker_user),
|
||||
)
|
||||
assert versions_resp.status_code == 200
|
||||
versions = versions_resp.json()
|
||||
version_numbers = sorted(v["version_number"] for v in versions)
|
||||
assert version_numbers == [1, 2]
|
||||
|
||||
|
||||
class TestDeleteRecipe:
|
||||
@@ -222,31 +277,34 @@ class TestRecipeVersioning:
|
||||
maker_user: User,
|
||||
db_session: AsyncSession,
|
||||
):
|
||||
"""Multiple updates create consecutive versions."""
|
||||
"""Each update creates a new version when measurements exist (copy-on-write)."""
|
||||
recipe = await create_test_recipe(db_session, maker_user.id)
|
||||
await _seed_measurement(db_session, recipe.id, maker_user.id)
|
||||
db_session.expunge_all()
|
||||
|
||||
# Update once -> v2
|
||||
# Update once -> v2 (v1 had measurements)
|
||||
await client.put(
|
||||
f"/api/recipes/{recipe.id}",
|
||||
headers=auth_headers(maker_user),
|
||||
json={"change_notes": "Version 2"},
|
||||
)
|
||||
db_session.expunge_all()
|
||||
await _seed_measurement(db_session, recipe.id, maker_user.id)
|
||||
db_session.expunge_all()
|
||||
|
||||
# Update again -> v3
|
||||
# Update again -> v3 (v2 now has measurements too)
|
||||
await client.put(
|
||||
f"/api/recipes/{recipe.id}",
|
||||
headers=auth_headers(maker_user),
|
||||
json={"change_notes": "Version 3"},
|
||||
)
|
||||
db_session.expunge_all()
|
||||
|
||||
# List versions
|
||||
resp = await client.get(
|
||||
f"/api/recipes/{recipe.id}/versions",
|
||||
headers=auth_headers(maker_user),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
versions = resp.json()
|
||||
version_numbers = [v["version_number"] for v in versions]
|
||||
assert 1 in version_numbers
|
||||
assert 2 in version_numbers
|
||||
assert 3 in version_numbers
|
||||
version_numbers = sorted(v["version_number"] for v in versions)
|
||||
assert version_numbers == [1, 2, 3]
|
||||
|
||||
@@ -189,7 +189,7 @@ class TestReorderTasks:
|
||||
"""Maker can reorder tasks by providing task IDs in desired order."""
|
||||
recipe = await create_test_recipe(db_session, maker_user.id)
|
||||
|
||||
# Add a second task via copy-on-write
|
||||
# Add a second task (no measurements → appended to current version)
|
||||
add_resp = await client.post(
|
||||
f"/api/recipes/{recipe.id}/tasks",
|
||||
headers=auth_headers(maker_user),
|
||||
@@ -197,7 +197,11 @@ class TestReorderTasks:
|
||||
)
|
||||
assert add_resp.status_code == 201
|
||||
|
||||
# Get tasks from new version
|
||||
# Force SQLA to re-fetch RecipeVersion.tasks: the shared test session
|
||||
# caches the collection from create_test_recipe, so the GET below would
|
||||
# otherwise return a stale list. In prod each request gets its own session.
|
||||
db_session.expunge_all()
|
||||
|
||||
tasks_resp = await client.get(
|
||||
f"/api/recipes/{recipe.id}/tasks",
|
||||
headers=auth_headers(maker_user),
|
||||
|
||||
@@ -1,33 +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", "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,8 @@
|
||||
"""TieMeasureFlow Client - Flask Entry Point."""
|
||||
import json
|
||||
import os
|
||||
from datetime import date
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from flask import Flask, redirect, url_for, session, request
|
||||
from flask_babel import Babel
|
||||
@@ -63,7 +65,14 @@ def create_app() -> Flask:
|
||||
"""Set user's preferred language and store in session."""
|
||||
if lang in Config.LANGUAGES:
|
||||
session["language"] = lang
|
||||
return redirect(request.referrer or url_for("auth.login"))
|
||||
# Only follow the referrer if it points back to this host
|
||||
# (prevents open redirect via a forged Referer header).
|
||||
referrer = request.referrer
|
||||
if referrer:
|
||||
parsed = urlparse(referrer)
|
||||
if parsed.netloc and parsed.netloc != request.host:
|
||||
referrer = None
|
||||
return redirect(referrer or url_for("auth.login"))
|
||||
|
||||
@app.template_filter("tojson_attr")
|
||||
def tojson_attr_filter(value):
|
||||
@@ -92,6 +101,8 @@ def create_app() -> Flask:
|
||||
"current_language": get_locale(),
|
||||
"languages": Config.LANGUAGES,
|
||||
"company_logo": session.get("company_logo"),
|
||||
"auto_logout_minutes": session.get("auto_logout_minutes"),
|
||||
"current_year": date.today().year,
|
||||
}
|
||||
|
||||
return app
|
||||
|
||||
@@ -43,6 +43,28 @@ def user_list():
|
||||
return render_template("admin/users.html", users=users)
|
||||
|
||||
|
||||
@admin_bp.route("/settings")
|
||||
@login_required
|
||||
@admin_required
|
||||
def settings_page():
|
||||
"""System settings page."""
|
||||
resp = api_client.get("/api/settings")
|
||||
settings = resp if not isinstance(resp, dict) or not resp.get("error") else {}
|
||||
return render_template("admin/settings.html", settings=settings)
|
||||
|
||||
|
||||
@admin_bp.route("/api/settings", methods=["PUT"])
|
||||
@login_required
|
||||
@admin_required
|
||||
def api_update_settings():
|
||||
"""Proxy: Update system settings."""
|
||||
data = request.get_json(silent=True) or {}
|
||||
resp = api_client.put("/api/settings", data=data)
|
||||
if isinstance(resp, dict) and resp.get("error"):
|
||||
return jsonify(resp), resp.get("status_code", 500)
|
||||
return jsonify(resp), 200
|
||||
|
||||
|
||||
@admin_bp.route("/stations")
|
||||
@login_required
|
||||
@admin_required
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Authentication blueprint - login, logout, profile."""
|
||||
from functools import wraps
|
||||
|
||||
from flask import Blueprint, abort, flash, redirect, render_template, request, session, url_for
|
||||
from flask import Blueprint, abort, flash, jsonify, redirect, render_template, request, session, url_for
|
||||
from flask_babel import gettext as _
|
||||
|
||||
from services.api_client import api_client
|
||||
@@ -39,15 +39,32 @@ def role_required(*roles):
|
||||
return decorator
|
||||
|
||||
|
||||
def _post_login_landing():
|
||||
"""Pick the landing endpoint based on the logged-in user's roles.
|
||||
|
||||
A Maker-only user must not be bounced to /measure (MeasurementTec-only),
|
||||
which would raise a 403 right after login. Order of preference follows the
|
||||
primary operator flow first, then editing, analysis, admin.
|
||||
"""
|
||||
user = session.get("user", {})
|
||||
roles = user.get("roles", [])
|
||||
if "MeasurementTec" in roles:
|
||||
return url_for("measure.select_recipe")
|
||||
if "Maker" in roles:
|
||||
return url_for("maker.recipe_list")
|
||||
if "Metrologist" in roles:
|
||||
return url_for("statistics.dashboard")
|
||||
if user.get("is_admin"):
|
||||
return url_for("admin.user_list")
|
||||
return url_for("auth.profile")
|
||||
|
||||
|
||||
@auth_bp.route("/login", methods=["GET", "POST"])
|
||||
def login():
|
||||
"""Login page and form handler."""
|
||||
# Se già loggato, redirect a home
|
||||
# Se già loggato, redirect alla home in base al ruolo
|
||||
if session.get("api_key"):
|
||||
try:
|
||||
return redirect(url_for("measure.select_recipe"))
|
||||
except Exception:
|
||||
return redirect(url_for("auth.profile"))
|
||||
return redirect(_post_login_landing())
|
||||
|
||||
if request.method == "POST":
|
||||
username = request.form.get("username", "").strip()
|
||||
@@ -76,20 +93,23 @@ def login():
|
||||
if user.get("theme_pref"):
|
||||
session["theme"] = user["theme_pref"]
|
||||
|
||||
# Carica logo aziendale dalle impostazioni di sistema
|
||||
# Carica impostazioni di sistema
|
||||
settings_resp = api_client.get("/api/settings")
|
||||
if not settings_resp.get("error"):
|
||||
logo_path = settings_resp.get("company_logo_path")
|
||||
if logo_path:
|
||||
session["company_logo"] = logo_path
|
||||
auto_logout = settings_resp.get("auto_logout_minutes")
|
||||
if auto_logout:
|
||||
try:
|
||||
session["auto_logout_minutes"] = int(auto_logout)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
flash(_("Benvenuto, %(name)s!", name=user.get("display_name", username)), "success")
|
||||
|
||||
# Redirect dopo login
|
||||
try:
|
||||
return redirect(url_for("measure.select_recipe"))
|
||||
except Exception:
|
||||
return redirect(url_for("auth.profile"))
|
||||
# Redirect dopo login (in base al ruolo)
|
||||
return redirect(_post_login_landing())
|
||||
|
||||
except Exception as e:
|
||||
flash(_("Errore di connessione al server: %(error)s", error=str(e)), "error")
|
||||
@@ -113,6 +133,27 @@ def logout():
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
|
||||
@auth_bp.route("/set-theme", methods=["POST"])
|
||||
@login_required
|
||||
def set_theme():
|
||||
"""Persist the user's theme toggle (light/dark) to session and profile.
|
||||
|
||||
Called via AJAX from the navbar theme switch so the choice survives
|
||||
navigation and matches on the next login. Best-effort on the API side:
|
||||
the session is updated regardless so the current browsing stays correct.
|
||||
"""
|
||||
theme = (request.get_json(silent=True) or {}).get("theme", "")
|
||||
if theme not in ("light", "dark"):
|
||||
return jsonify({"error": True, "detail": "invalid theme"}), 400
|
||||
|
||||
session["theme"] = theme
|
||||
try:
|
||||
api_client.put("/api/auth/me", data={"theme_pref": theme})
|
||||
except Exception:
|
||||
pass # Non-fatal: session theme is already updated for this session.
|
||||
return jsonify({"ok": True, "theme": theme})
|
||||
|
||||
|
||||
@auth_bp.route("/profile", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def profile():
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
"""Maker blueprint - recipe creation and editing."""
|
||||
import requests as http_requests
|
||||
|
||||
from flask import Blueprint, Response, flash, jsonify, redirect, render_template, request, session, url_for
|
||||
from flask import Blueprint, flash, jsonify, redirect, render_template, request, session, url_for
|
||||
from flask_babel import gettext as _
|
||||
|
||||
from blueprints.auth import login_required, role_required
|
||||
from config import Config
|
||||
from services.api_client import api_client
|
||||
from services.file_proxy import proxy_file
|
||||
|
||||
maker_bp = Blueprint("maker", __name__, url_prefix="/maker")
|
||||
|
||||
@@ -380,19 +381,7 @@ def api_upload_file():
|
||||
@login_required
|
||||
def api_get_file(file_path: str):
|
||||
"""Proxy: Serve file from API server (browser can't send X-API-Key)."""
|
||||
api_key = session.get("api_key", "")
|
||||
base_url = Config.API_SERVER_URL.rstrip("/")
|
||||
resp = http_requests.get(
|
||||
f"{base_url}/api/files/{file_path}",
|
||||
headers={"X-API-Key": api_key},
|
||||
timeout=30,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
return Response(resp.text, status=resp.status_code)
|
||||
return Response(
|
||||
resp.content,
|
||||
content_type=resp.headers.get("content-type", "application/octet-stream"),
|
||||
)
|
||||
return proxy_file(file_path)
|
||||
|
||||
|
||||
@maker_bp.route("/api/files/<path:file_path>", methods=["DELETE"])
|
||||
@@ -424,3 +413,31 @@ def api_get_measurement_count(recipe_id: int, version_number: int):
|
||||
return jsonify(resp), resp.get("status_code", 500)
|
||||
|
||||
return jsonify(resp), 200
|
||||
|
||||
|
||||
@maker_bp.route("/api/recipes/<int:recipe_id>/parse-technical-sheet", methods=["POST"])
|
||||
@login_required
|
||||
@role_required("Maker")
|
||||
def api_parse_technical_sheet(recipe_id: int):
|
||||
"""Proxy: Parse PDF technical sheet with AI."""
|
||||
file = request.files.get("file")
|
||||
if not file:
|
||||
return jsonify({"error": True, "detail": "Nessun file caricato"}), 400
|
||||
|
||||
api_key = session.get("api_key", "")
|
||||
base_url = Config.API_SERVER_URL.rstrip("/")
|
||||
resp = http_requests.post(
|
||||
f"{base_url}/api/recipes/{recipe_id}/parse-technical-sheet",
|
||||
headers={"X-API-Key": api_key},
|
||||
files={"file": (file.filename, file.stream, file.content_type)},
|
||||
timeout=90,
|
||||
)
|
||||
|
||||
try:
|
||||
payload = resp.json()
|
||||
except ValueError:
|
||||
payload = {
|
||||
"error": True,
|
||||
"detail": resp.text or f"HTTP {resp.status_code}",
|
||||
}
|
||||
return jsonify(payload), resp.status_code
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
"""MeasurementTec blueprint - recipe selection and measurement execution."""
|
||||
import requests as http_requests
|
||||
|
||||
from flask import (
|
||||
Blueprint, Response, flash, jsonify, redirect, render_template,
|
||||
Blueprint, flash, jsonify, redirect, render_template,
|
||||
request, session, url_for,
|
||||
)
|
||||
from flask_babel import gettext as _
|
||||
@@ -10,6 +8,7 @@ from flask_babel import gettext as _
|
||||
from blueprints.auth import login_required, role_required
|
||||
from config import Config
|
||||
from services.api_client import api_client
|
||||
from services.file_proxy import proxy_file
|
||||
|
||||
measure_bp = Blueprint("measure", __name__)
|
||||
|
||||
@@ -135,11 +134,15 @@ def task_execute(task_id: int):
|
||||
# Load all task IDs for this recipe (ordered) for auto-advance
|
||||
recipe_id = task_resp.get("recipe_id")
|
||||
all_task_ids = []
|
||||
measurement_interval_minutes = None
|
||||
if recipe_id:
|
||||
tasks_resp = api_client.get(f"/api/recipes/{recipe_id}/tasks")
|
||||
if isinstance(tasks_resp, list):
|
||||
sorted_tasks = sorted(tasks_resp, key=lambda t: t.get("order_index", 0))
|
||||
all_task_ids = [t["id"] for t in sorted_tasks]
|
||||
recipe_resp = api_client.get(f"/api/recipes/{recipe_id}")
|
||||
if not recipe_resp.get("error"):
|
||||
measurement_interval_minutes = recipe_resp.get("measurement_interval_minutes")
|
||||
|
||||
return render_template(
|
||||
"measure/task_execute.html",
|
||||
@@ -147,6 +150,7 @@ def task_execute(task_id: int):
|
||||
lot_number=lot_number,
|
||||
serial_number=serial_number,
|
||||
all_task_ids=all_task_ids,
|
||||
measurement_interval_minutes=measurement_interval_minutes,
|
||||
)
|
||||
|
||||
|
||||
@@ -321,6 +325,34 @@ def save_measurement():
|
||||
return jsonify(resp), 201
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Route: Validate supervisor credentials (AJAX)
|
||||
# ---------------------------------------------------------------------------
|
||||
@measure_bp.route("/validate-supervisor", methods=["POST"])
|
||||
@login_required
|
||||
@role_required("MeasurementTec")
|
||||
def validate_supervisor():
|
||||
"""Validate supervisor (capoturno) credentials for out-of-tolerance authorization."""
|
||||
data = request.get_json(silent=True) or {}
|
||||
username = data.get("username", "").strip()
|
||||
password = data.get("password", "")
|
||||
|
||||
if not username or not password:
|
||||
return jsonify({"error": True, "detail": _("Username e password richiesti")}), 400
|
||||
|
||||
resp = api_client.post("/api/auth/login", data={"username": username, "password": password})
|
||||
|
||||
if resp.get("error"):
|
||||
return jsonify({"error": True, "detail": _("Credenziali non valide")}), 401
|
||||
|
||||
user = resp.get("user", {})
|
||||
is_supervisor = "Supervisor" in (user.get("roles") or [])
|
||||
if not (is_supervisor or user.get("is_admin")):
|
||||
return jsonify({"error": True, "detail": _("Utente non autorizzato (richiesto capoturno)")}), 403
|
||||
|
||||
return jsonify({"authorized": True, "supervisor": user.get("display_name", username)}), 200
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Route: File proxy (browser can't send X-API-Key directly)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -328,16 +360,4 @@ def save_measurement():
|
||||
@login_required
|
||||
def api_get_file(file_path: str):
|
||||
"""Proxy: Serve file from API server (browser can't send X-API-Key)."""
|
||||
api_key = session.get("api_key", "")
|
||||
base_url = Config.API_SERVER_URL.rstrip("/")
|
||||
resp = http_requests.get(
|
||||
f"{base_url}/api/files/{file_path}",
|
||||
headers={"X-API-Key": api_key},
|
||||
timeout=30,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
return Response(resp.text, status=resp.status_code)
|
||||
return Response(
|
||||
resp.content,
|
||||
content_type=resp.headers.get("content-type", "application/octet-stream"),
|
||||
)
|
||||
return proxy_file(file_path)
|
||||
|
||||
@@ -56,11 +56,21 @@ class APIClient:
|
||||
try:
|
||||
error_body = response.json()
|
||||
detail = error_body.get("detail", str(error_body))
|
||||
# FastAPI 422 returns detail as a list of validation errors
|
||||
# FastAPI 422 returns detail as a list of validation errors:
|
||||
# [{"type": ..., "loc": ["body", "password"], "msg": "...", ...}, ...]
|
||||
if isinstance(detail, list):
|
||||
detail = "; ".join(
|
||||
e.get("msg", str(e)) for e in detail if isinstance(e, dict)
|
||||
) or str(detail)
|
||||
parts = []
|
||||
for e in detail:
|
||||
if not isinstance(e, dict):
|
||||
parts.append(str(e))
|
||||
continue
|
||||
msg = e.get("msg", str(e))
|
||||
loc = e.get("loc") or []
|
||||
# Strip leading "body"/"query"/"path" segments; keep field path.
|
||||
field_path = [str(p) for p in loc if p not in ("body", "query", "path")]
|
||||
field = ".".join(field_path) if field_path else None
|
||||
parts.append(f"{field}: {msg}" if field else msg)
|
||||
detail = "; ".join(parts) or str(detail)
|
||||
except Exception:
|
||||
detail = response.text or f"HTTP {response.status_code}"
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Shared file proxy: relay uploads from the FastAPI server to the browser.
|
||||
|
||||
The browser can't send the X-API-Key header directly, so blueprints expose
|
||||
a proxy route and delegate here.
|
||||
"""
|
||||
import requests as http_requests
|
||||
from flask import Response, session
|
||||
|
||||
from config import Config
|
||||
|
||||
|
||||
def proxy_file(file_path: str) -> Response:
|
||||
"""Fetch a file from the API server and relay it with its content type."""
|
||||
base_url = Config.API_SERVER_URL.rstrip("/")
|
||||
resp = http_requests.get(
|
||||
f"{base_url}/api/files/{file_path}",
|
||||
headers={"X-API-Key": session.get("api_key", "")},
|
||||
timeout=30,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
return Response(resp.text, status=resp.status_code)
|
||||
return Response(
|
||||
resp.content,
|
||||
content_type=resp.headers.get("content-type", "application/octet-stream"),
|
||||
)
|
||||
@@ -69,6 +69,23 @@
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
Brand details
|
||||
============================================================ */
|
||||
|
||||
:root {
|
||||
accent-color: var(--color-primary);
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: rgba(37, 99, 235, 0.2);
|
||||
}
|
||||
|
||||
.dark ::selection {
|
||||
background: rgba(96, 165, 250, 0.3);
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
Global Transitions
|
||||
============================================================ */
|
||||
@@ -151,6 +168,7 @@ textarea:focus-visible {
|
||||
|
||||
.tmf-card:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
border-color: var(--scrollbar-thumb);
|
||||
}
|
||||
|
||||
.tmf-card-header {
|
||||
@@ -250,11 +268,21 @@ textarea:focus-visible {
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.btn:active:not(:disabled) {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-lg {
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-size: 1rem;
|
||||
border-radius: 0.625rem;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--color-primary);
|
||||
color: #FFFFFF;
|
||||
|
||||
@@ -1,27 +1,21 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
||||
<!-- Stylized Caliper Icon for Favicon -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
||||
<!-- TieMeasureFlow brand mark: measurement span glyph on brand-blue badge -->
|
||||
<defs>
|
||||
<linearGradient id="tmfBadge" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#3B82F6"/>
|
||||
<stop offset="1" stop-color="#1D4ED8"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- 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"/>
|
||||
<!-- Badge -->
|
||||
<rect x="2" y="2" width="60" height="60" rx="15" fill="url(#tmfBadge)"/>
|
||||
|
||||
<!-- 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"/>
|
||||
<!-- Measurement end bars -->
|
||||
<rect x="14" y="16" width="5" height="32" rx="2.5" fill="#FFFFFF"/>
|
||||
<rect x="45" y="16" width="5" height="32" rx="2.5" fill="#FFFFFF"/>
|
||||
|
||||
<!-- 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"/>
|
||||
<!-- Double-headed arrow (measured span) -->
|
||||
<path d="M24 32 H40" stroke="#FFFFFF" stroke-width="4" stroke-linecap="round"/>
|
||||
<path d="M28 26.5 L22.5 32 L28 37.5" stroke="#FFFFFF" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M36 26.5 L41.5 32 L36 37.5" stroke="#FFFFFF" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 997 B |
@@ -1,41 +1,23 @@
|
||||
<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"/>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 340 64" fill="none">
|
||||
<!-- TieMeasureFlow full logo: brand badge + wordmark (standalone asset) -->
|
||||
<defs>
|
||||
<linearGradient id="tmfBadgeFull" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#3B82F6"/>
|
||||
<stop offset="1" stop-color="#1D4ED8"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- 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"/>
|
||||
<!-- Badge -->
|
||||
<rect x="2" y="2" width="60" height="60" rx="15" fill="url(#tmfBadgeFull)"/>
|
||||
<rect x="14" y="16" width="5" height="32" rx="2.5" fill="#FFFFFF"/>
|
||||
<rect x="45" y="16" width="5" height="32" rx="2.5" fill="#FFFFFF"/>
|
||||
<path d="M24 32 H40" stroke="#FFFFFF" stroke-width="4" stroke-linecap="round"/>
|
||||
<path d="M28 26.5 L22.5 32 L28 37.5" stroke="#FFFFFF" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M36 26.5 L41.5 32 L36 37.5" stroke="#FFFFFF" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
|
||||
<!-- 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>
|
||||
<!-- Wordmark: single text element with flowing tspans (cannot be clipped/overlap) -->
|
||||
<text x="76" y="42" font-family="Inter, system-ui, -apple-system, sans-serif"
|
||||
font-size="30" letter-spacing="-0.5" fill="#0F172A">
|
||||
<tspan font-weight="700">Tie</tspan><tspan font-weight="600" fill="#2563EB">Measure</tspan><tspan font-weight="700">Flow</tspan>
|
||||
</text>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 1.3 KiB |
@@ -16,9 +16,21 @@
|
||||
|
||||
/**
|
||||
* Resolve the initial dark-mode state.
|
||||
* Priority: localStorage > system preference > light (default).
|
||||
* Priority: server (per-user theme_pref) > localStorage > system preference > light.
|
||||
*
|
||||
* The server value (window.__SERVER_THEME) is authoritative for logged-in
|
||||
* users: it reflects the theme_pref of whoever is currently authenticated,
|
||||
* so switching user actually switches theme instead of inheriting the
|
||||
* previous user's localStorage value. It is empty for anonymous pages.
|
||||
*/
|
||||
function getInitialDark() {
|
||||
var server = (typeof window !== 'undefined' && window.__SERVER_THEME) || '';
|
||||
if (server === 'dark' || server === 'light') {
|
||||
// Keep localStorage in sync so the rest of the app stays consistent.
|
||||
try { localStorage.setItem(STORAGE_KEY, server); } catch (_) {}
|
||||
return server === 'dark';
|
||||
}
|
||||
|
||||
var stored = null;
|
||||
try {
|
||||
stored = localStorage.getItem(STORAGE_KEY);
|
||||
@@ -70,6 +82,7 @@
|
||||
toggle: function () {
|
||||
this.dark = !this.dark;
|
||||
this._apply();
|
||||
this._persist();
|
||||
},
|
||||
|
||||
set: function (dark) {
|
||||
@@ -77,6 +90,24 @@
|
||||
this._apply();
|
||||
},
|
||||
|
||||
// Persist an explicit user choice to the server (session + theme_pref),
|
||||
// so it survives navigation and matches on the next login. Best-effort.
|
||||
_persist: function () {
|
||||
try {
|
||||
var csrf = document.querySelector('meta[name=csrf-token]');
|
||||
fetch('/auth/set-theme', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': (csrf && csrf.content) || ''
|
||||
},
|
||||
body: JSON.stringify({ theme: this.dark ? 'dark' : 'light' })
|
||||
}).catch(function () {});
|
||||
} catch (_) {
|
||||
// Non-fatal: localStorage already holds the choice for this browser.
|
||||
}
|
||||
},
|
||||
|
||||
_apply: function () {
|
||||
if (this.dark) {
|
||||
document.documentElement.classList.add('dark');
|
||||
|
||||
@@ -671,6 +671,53 @@ function annotationEditor() {
|
||||
return rect;
|
||||
},
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Object creation - Text
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
addText(x, y, textContent, fontSize) {
|
||||
var size = fontSize || 18;
|
||||
var itext = new fabric.IText(textContent || 'Testo', {
|
||||
left: x,
|
||||
top: y,
|
||||
fontSize: size,
|
||||
fill: this.currentColor,
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
fontWeight: 'bold',
|
||||
selectable: true,
|
||||
evented: true,
|
||||
hasControls: false,
|
||||
hasBorders: true,
|
||||
lockRotation: true,
|
||||
lockScalingX: true,
|
||||
lockScalingY: true,
|
||||
objectType: 'text',
|
||||
textColor: this.currentColor,
|
||||
textFontSize: size,
|
||||
});
|
||||
|
||||
var self = this;
|
||||
itext.on('editing:exited', function () {
|
||||
if (!itext.text || itext.text.trim() === '') {
|
||||
self.canvas.remove(itext);
|
||||
}
|
||||
itext.textColor = itext.fill;
|
||||
self.isDirty = true;
|
||||
window.dispatchEvent(new CustomEvent('annotations-changed', { detail: { json: self.getAnnotationsJson() } }));
|
||||
});
|
||||
|
||||
itext.on('changed', function () {
|
||||
self.isDirty = true;
|
||||
});
|
||||
|
||||
this.canvas.add(itext);
|
||||
itext.setCoords();
|
||||
this.canvas.setActiveObject(itext);
|
||||
this.isDirty = true;
|
||||
window.dispatchEvent(new CustomEvent('annotations-changed', { detail: { json: this.getAnnotationsJson() } }));
|
||||
return itext;
|
||||
},
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Toolbar / mode switching
|
||||
// ----------------------------------------------------------------
|
||||
@@ -678,7 +725,7 @@ function annotationEditor() {
|
||||
/**
|
||||
* Set the current interaction mode.
|
||||
*
|
||||
* @param {'select'|'marker'|'arrow'|'rect'} mode
|
||||
* @param {'select'|'marker'|'arrow'|'rect'|'text'} mode
|
||||
*/
|
||||
setMode(mode) {
|
||||
this.activeMode = mode;
|
||||
@@ -731,6 +778,13 @@ function annotationEditor() {
|
||||
self.addMarker(pointer.x, pointer.y);
|
||||
self.setMode('select');
|
||||
window.dispatchEvent(new CustomEvent('anno-mode-changed', { detail: { mode: 'select' } }));
|
||||
} else if (self.activeMode === 'text') {
|
||||
var textObj = self.addText(pointer.x, pointer.y);
|
||||
self.setMode('select');
|
||||
window.dispatchEvent(new CustomEvent('anno-mode-changed', { detail: { mode: 'select' } }));
|
||||
self.canvas.setActiveObject(textObj);
|
||||
textObj.enterEditing();
|
||||
textObj.selectAll();
|
||||
} else if (self.activeMode === 'arrow') {
|
||||
isDrawing = true;
|
||||
drawStartX = pointer.x;
|
||||
@@ -902,7 +956,7 @@ function annotationEditor() {
|
||||
setupKeyboard() {
|
||||
var self = this;
|
||||
|
||||
document.addEventListener('keydown', function (e) {
|
||||
this._onKeyDown = function (e) {
|
||||
// Skip when typing in form fields
|
||||
var tag = e.target.tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
|
||||
@@ -916,7 +970,8 @@ function annotationEditor() {
|
||||
self.canvas.discardActiveObject();
|
||||
self.canvas.renderAll();
|
||||
}
|
||||
});
|
||||
};
|
||||
document.addEventListener('keydown', this._onKeyDown);
|
||||
},
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
@@ -1023,6 +1078,13 @@ function annotationEditor() {
|
||||
if (obj.arrowLineDash) data.lineDash = obj.arrowLineDash;
|
||||
if (obj.areaLineDash) data.lineDash = obj.areaLineDash;
|
||||
|
||||
// Text-specific
|
||||
if (obj.objectType === 'text') {
|
||||
data.textContent = obj.text;
|
||||
data.textColor = obj.textColor || obj.fill;
|
||||
data.fontSize = obj.textFontSize || obj.fontSize;
|
||||
}
|
||||
|
||||
// Arrow-specific: store original endpoints
|
||||
if (obj.objectType === 'arrow') {
|
||||
data.x1 = obj.arrowX1 != null ? Math.round(obj.arrowX1 * 100) / 100 : null;
|
||||
@@ -1120,6 +1182,11 @@ function annotationEditor() {
|
||||
this.currentColor = savedColor3;
|
||||
this.currentStrokeWidth = savedWidth2;
|
||||
this.currentLineDash = savedDash2;
|
||||
} else if (obj.type === 'text') {
|
||||
var savedColor4 = this.currentColor;
|
||||
if (obj.textColor) this.currentColor = obj.textColor;
|
||||
this.addText(obj.left * cs, obj.top * cs, obj.textContent, (obj.fontSize || 18) * cs);
|
||||
this.currentColor = savedColor4;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1223,6 +1290,9 @@ function annotationEditor() {
|
||||
if (this._onImageLoaded) {
|
||||
window.removeEventListener('image-loaded', this._onImageLoaded);
|
||||
}
|
||||
if (this._onKeyDown) {
|
||||
document.removeEventListener('keydown', this._onKeyDown);
|
||||
}
|
||||
if (this.canvas) {
|
||||
this.canvas.dispose();
|
||||
this.canvas = null;
|
||||
|
||||
@@ -436,6 +436,8 @@ function _drawEditorAnnotations(ctx, data, scale) {
|
||||
_drawPreviewArrow(ctx, obj, annoScale);
|
||||
} else if (obj.type === 'area') {
|
||||
_drawPreviewArea(ctx, obj, annoScale);
|
||||
} else if (obj.type === 'text') {
|
||||
_drawPreviewText(ctx, obj, annoScale);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -514,6 +516,24 @@ function _drawPreviewArea(ctx, obj, scale) {
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
|
||||
function _drawPreviewText(ctx, obj, scale) {
|
||||
var x = obj.left * scale;
|
||||
var y = obj.top * scale;
|
||||
var color = obj.textColor || '#000000';
|
||||
var fontSize = Math.max(10, Math.round((obj.fontSize || 18) * scale));
|
||||
|
||||
ctx.fillStyle = color;
|
||||
ctx.font = 'bold ' + fontSize + 'px Inter, sans-serif';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'top';
|
||||
|
||||
var lines = (obj.textContent || '').split('\n');
|
||||
var lineHeight = fontSize * 1.2;
|
||||
for (var j = 0; j < lines.length; j++) {
|
||||
ctx.fillText(lines[j], x, y + j * lineHeight);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw annotations in the legacy format (markers: point/rectangle).
|
||||
*/
|
||||
|
||||
@@ -163,6 +163,17 @@ function numpad() {
|
||||
* @param {KeyboardEvent} e - The keyboard event
|
||||
*/
|
||||
handleKeydown(e) {
|
||||
// Ignore keystrokes aimed at an editable field (e.g. the supervisor
|
||||
// login modal). The numpad has no text inputs of its own, so this
|
||||
// window-level capture is only meant for the USB caliper / keyboard
|
||||
// wedge when no field is focused. Without this guard the numpad would
|
||||
// swallow digits and Backspace inside those inputs.
|
||||
const t = e.target;
|
||||
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' ||
|
||||
t.tagName === 'SELECT' || t.isContentEditable)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Number keys
|
||||
if (e.key >= '0' && e.key <= '9') {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ _('Impostazioni') }} - TieMeasureFlow{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-6"
|
||||
x-data="systemSettings()">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-[var(--text-primary)]">{{ _('Impostazioni di Sistema') }}</h1>
|
||||
<p class="mt-1 text-sm text-[var(--text-secondary)]">{{ _('Configura i parametri generali del sistema') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Settings Card -->
|
||||
<div class="tmf-card">
|
||||
<div class="tmf-card-header flex items-center gap-2">
|
||||
<svg class="w-4 h-4 text-[var(--text-muted)]" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
{{ _('Sessione e Sicurezza') }}
|
||||
</div>
|
||||
|
||||
<div class="tmf-card-body space-y-5">
|
||||
<!-- Auto-logout -->
|
||||
<div>
|
||||
<label class="tmf-label" for="auto-logout">{{ _('Auto-logout per inattività (minuti)') }}</label>
|
||||
<div class="flex items-center gap-3">
|
||||
<input id="auto-logout"
|
||||
type="number"
|
||||
x-model.number="autoLogoutMinutes"
|
||||
class="tmf-input w-32"
|
||||
min="1"
|
||||
max="480"
|
||||
placeholder="—">
|
||||
<button @click="autoLogoutMinutes = null"
|
||||
class="text-xs text-[var(--text-muted)] hover:text-red-500 transition-colors"
|
||||
x-show="autoLogoutMinutes">
|
||||
{{ _('Disabilita') }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-[var(--text-muted)] mt-1">
|
||||
{{ _('L\'operatore verrà disconnesso automaticamente dopo il periodo di inattività indicato. Lasciare vuoto per disabilitare.') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tmf-card-footer flex items-center justify-between">
|
||||
<span x-show="saved" x-transition class="text-sm text-emerald-600 flex items-center gap-1">
|
||||
<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="M5 13l4 4L19 7"/>
|
||||
</svg>
|
||||
{{ _('Salvato') }}
|
||||
</span>
|
||||
<span x-show="errorMessage" x-transition class="text-sm text-red-600" x-text="errorMessage"></span>
|
||||
<div class="flex-1"></div>
|
||||
<button @click="save()"
|
||||
:disabled="saving"
|
||||
class="btn btn-primary gap-1.5">
|
||||
<svg x-show="!saving" 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="M5 13l4 4L19 7"/>
|
||||
</svg>
|
||||
<svg x-show="saving" class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"/>
|
||||
</svg>
|
||||
{{ _('Salva impostazioni') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
function systemSettings() {
|
||||
return {
|
||||
autoLogoutMinutes: {{ (settings.auto_logout_minutes or '')|tojson }},
|
||||
saving: false,
|
||||
saved: false,
|
||||
errorMessage: '',
|
||||
|
||||
async save() {
|
||||
this.saving = true;
|
||||
this.saved = false;
|
||||
this.errorMessage = '';
|
||||
|
||||
var csrfToken = document.querySelector('meta[name=csrf-token]')?.content;
|
||||
var payload = {
|
||||
auto_logout_minutes: this.autoLogoutMinutes ? String(this.autoLogoutMinutes) : ''
|
||||
};
|
||||
|
||||
try {
|
||||
var resp = await fetch('/admin/api/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken || '' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
var data = await resp.json();
|
||||
if (data.error) {
|
||||
this.errorMessage = data.detail || data.error;
|
||||
} else {
|
||||
this.saved = true;
|
||||
setTimeout(() => { this.saved = false; }, 3000);
|
||||
}
|
||||
} catch (err) {
|
||||
this.errorMessage = {{ _('Errore di connessione al server')|tojson }};
|
||||
}
|
||||
this.saving = false;
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -221,7 +221,7 @@
|
||||
<div class="absolute inset-0 bg-black/50" @click="closeAssignmentsModal()"></div>
|
||||
<div x-show="showAssignments"
|
||||
x-transition
|
||||
class="relative bg-[var(--bg-card)] rounded-xl border border-[var(--border-color)] shadow-xl w-full max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
class="relative bg-[var(--bg-card)] rounded-xl border border-[var(--border-color)] shadow-xl w-full max-w-3xl max-h-[90vh] overflow-y-auto">
|
||||
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-[var(--border-color)]">
|
||||
<div>
|
||||
@@ -236,55 +236,86 @@
|
||||
</div>
|
||||
|
||||
<div class="px-6 py-4 space-y-4">
|
||||
<!-- Add recipe -->
|
||||
<div class="flex items-end gap-2">
|
||||
<div class="flex-1">
|
||||
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Aggiungi ricetta') }}</label>
|
||||
<select x-model="recipeToAdd"
|
||||
class="w-full px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)]
|
||||
text-sm text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors">
|
||||
<option value="">{{ _('-- Seleziona ricetta --') }}</option>
|
||||
<template x-for="r in unassignedRecipes" :key="r.id">
|
||||
<option :value="r.id" x-text="r.code + ' — ' + r.name"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
<button @click="assignRecipe()"
|
||||
:disabled="!recipeToAdd || saving"
|
||||
class="px-4 py-2 bg-primary text-white text-sm font-medium rounded-lg
|
||||
hover:bg-primary-700 transition-colors shadow-sm disabled:opacity-50">
|
||||
{{ _('Assegna') }}
|
||||
</button>
|
||||
|
||||
<!-- Search filter -->
|
||||
<div class="relative">
|
||||
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--text-secondary)]" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||
</svg>
|
||||
<input type="text" x-model="recipeSearch"
|
||||
placeholder="{{ _('Filtra per codice o nome ricetta...') }}"
|
||||
class="w-full pl-10 pr-4 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)]
|
||||
text-sm text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors">
|
||||
</div>
|
||||
|
||||
<!-- Assigned list -->
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-[var(--text-primary)] mb-2">
|
||||
{{ _('Ricette correntemente assegnate') }} (<span x-text="assignedRecipes.length"></span>)
|
||||
</h3>
|
||||
<div class="border border-[var(--border-color)] rounded-lg divide-y divide-[var(--border-color)] max-h-96 overflow-y-auto">
|
||||
<template x-for="r in assignedRecipes" :key="r.id">
|
||||
<div class="flex items-center justify-between px-3 py-2 hover:bg-[var(--bg-secondary)] transition-colors">
|
||||
<div>
|
||||
<span class="font-mono text-xs font-semibold text-[var(--text-primary)]" x-text="r.code"></span>
|
||||
<span class="ml-2 text-sm text-[var(--text-secondary)]" x-text="r.name"></span>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
|
||||
<!-- Available recipes (left column) -->
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-[var(--text-primary)] mb-2 flex items-center gap-2">
|
||||
<svg class="w-4 h-4 text-[var(--text-secondary)]" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||||
</svg>
|
||||
{{ _('Ricette disponibili') }} (<span x-text="filteredUnassignedRecipes.length"></span>)
|
||||
</h3>
|
||||
<div class="border border-[var(--border-color)] rounded-lg p-2 space-y-2 h-96 overflow-y-auto">
|
||||
<template x-for="r in filteredUnassignedRecipes" :key="r.id">
|
||||
<div class="flex items-center justify-between px-3 py-2 border border-[var(--border-color)] rounded-md hover:bg-[var(--bg-secondary)] transition-colors">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="font-mono text-xs font-semibold text-[var(--text-primary)] truncate" x-text="r.code"></div>
|
||||
<div class="text-xs text-[var(--text-secondary)] truncate" x-text="r.name"></div>
|
||||
</div>
|
||||
<button @click="assignRecipe(r.id)"
|
||||
:disabled="saving"
|
||||
class="ml-2 inline-flex items-center gap-1 px-2.5 py-1 bg-primary text-white text-xs font-medium rounded-md
|
||||
hover:bg-primary-700 transition-colors shadow-sm disabled:opacity-50"
|
||||
:title="'{{ _('Assegna a questa stazione') }}'">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4"/>
|
||||
</svg>
|
||||
{{ _('Assegna') }}
|
||||
</button>
|
||||
</div>
|
||||
<button @click="unassignRecipe(r.id)"
|
||||
:disabled="saving"
|
||||
class="p-1.5 rounded-lg text-[var(--text-secondary)] hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors disabled:opacity-50"
|
||||
:title="'{{ _('Rimuovi') }}'">
|
||||
<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="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="assignedRecipes.length === 0">
|
||||
<div class="px-3 py-6 text-center text-sm text-[var(--text-secondary)]">
|
||||
{{ _('Nessuna ricetta assegnata') }}
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
<template x-if="filteredUnassignedRecipes.length === 0">
|
||||
<div class="px-3 py-6 text-center text-sm text-[var(--text-secondary)]"
|
||||
x-text="unassignedEmptyMessage"></div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Assigned recipes (right column) -->
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-[var(--text-primary)] mb-2 flex items-center gap-2">
|
||||
<svg class="w-4 h-4 text-emerald-600" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/>
|
||||
</svg>
|
||||
{{ _('Assegnate alla stazione') }} (<span x-text="filteredAssignedRecipes.length"></span>)
|
||||
</h3>
|
||||
<div class="border border-[var(--border-color)] rounded-lg p-2 space-y-2 h-96 overflow-y-auto">
|
||||
<template x-for="r in filteredAssignedRecipes" :key="r.id">
|
||||
<div class="flex items-center justify-between px-3 py-2 border border-[var(--border-color)] rounded-md hover:bg-[var(--bg-secondary)] transition-colors">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="font-mono text-xs font-semibold text-[var(--text-primary)] truncate" x-text="r.code"></div>
|
||||
<div class="text-xs text-[var(--text-secondary)] truncate" x-text="r.name"></div>
|
||||
</div>
|
||||
<button @click="unassignRecipe(r.id)"
|
||||
:disabled="saving"
|
||||
class="ml-2 p-1.5 rounded-md text-[var(--text-secondary)] hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors disabled:opacity-50"
|
||||
:title="'{{ _('Rimuovi assegnazione') }}'">
|
||||
<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="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="filteredAssignedRecipes.length === 0">
|
||||
<div class="px-3 py-6 text-center text-sm text-[var(--text-secondary)]"
|
||||
x-text="assignedEmptyMessage"></div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<template x-if="errorMsg">
|
||||
@@ -351,7 +382,7 @@ function stationManagement(initialStations, initialRecipes) {
|
||||
deleteTarget: null,
|
||||
assignmentStation: null,
|
||||
assignedRecipes: [],
|
||||
recipeToAdd: '',
|
||||
recipeSearch: '',
|
||||
form: {
|
||||
code: '',
|
||||
name: '',
|
||||
@@ -380,6 +411,32 @@ function stationManagement(initialStations, initialRecipes) {
|
||||
return this.allRecipes.filter(r => !assignedIds.has(r.id) && r.active !== false);
|
||||
},
|
||||
|
||||
_matchesRecipeSearch(r) {
|
||||
if (!this.recipeSearch.trim()) return true;
|
||||
const q = this.recipeSearch.toLowerCase();
|
||||
return (r.code && r.code.toLowerCase().includes(q)) ||
|
||||
(r.name && r.name.toLowerCase().includes(q));
|
||||
},
|
||||
|
||||
get filteredUnassignedRecipes() {
|
||||
return this.unassignedRecipes.filter(r => this._matchesRecipeSearch(r));
|
||||
},
|
||||
|
||||
get filteredAssignedRecipes() {
|
||||
return this.assignedRecipes.filter(r => this._matchesRecipeSearch(r));
|
||||
},
|
||||
|
||||
get unassignedEmptyMessage() {
|
||||
if (this.recipeSearch) return '{{ _("Nessun risultato per il filtro") }}';
|
||||
if (this.allRecipes.length === 0) return '{{ _("Nessuna ricetta nel sistema") }}';
|
||||
return '{{ _("Tutte le ricette sono già assegnate") }}';
|
||||
},
|
||||
|
||||
get assignedEmptyMessage() {
|
||||
if (this.recipeSearch) return '{{ _("Nessun risultato per il filtro") }}';
|
||||
return '{{ _("Nessuna ricetta assegnata") }}';
|
||||
},
|
||||
|
||||
openCreateModal() {
|
||||
this.isEditing = false;
|
||||
this.editingId = null;
|
||||
@@ -477,7 +534,7 @@ function stationManagement(initialStations, initialRecipes) {
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const result = await resp.json().catch(() => ({}));
|
||||
alert(result.detail || '{{ _("Errore nell\'eliminazione") }}');
|
||||
alert(result.detail || "{{ _('Errore nella eliminazione') }}");
|
||||
return;
|
||||
}
|
||||
this.stations = this.stations.filter(s => s.id !== this.deleteTarget.id);
|
||||
@@ -493,7 +550,7 @@ function stationManagement(initialStations, initialRecipes) {
|
||||
async openAssignmentsModal(station) {
|
||||
this.assignmentStation = station;
|
||||
this.assignedRecipes = [];
|
||||
this.recipeToAdd = '';
|
||||
this.recipeSearch = '';
|
||||
this.errorMsg = '';
|
||||
this.showAssignments = true;
|
||||
try {
|
||||
@@ -513,27 +570,28 @@ function stationManagement(initialStations, initialRecipes) {
|
||||
this.showAssignments = false;
|
||||
this.assignmentStation = null;
|
||||
this.assignedRecipes = [];
|
||||
this.recipeSearch = '';
|
||||
this.errorMsg = '';
|
||||
},
|
||||
|
||||
async assignRecipe() {
|
||||
if (!this.recipeToAdd || !this.assignmentStation) return;
|
||||
async assignRecipe(recipeId) {
|
||||
if (!recipeId || !this.assignmentStation) return;
|
||||
const id = parseInt(recipeId, 10);
|
||||
this.saving = true;
|
||||
this.errorMsg = '';
|
||||
try {
|
||||
const resp = await fetch(`/admin/api/stations/${this.assignmentStation.id}/recipes`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
|
||||
body: JSON.stringify({ recipe_id: parseInt(this.recipeToAdd, 10) }),
|
||||
body: JSON.stringify({ recipe_id: id }),
|
||||
});
|
||||
const result = await resp.json();
|
||||
if (!resp.ok) {
|
||||
this.errorMsg = result.detail || '{{ _("Errore nell\'assegnazione") }}';
|
||||
this.errorMsg = result.detail || "{{ _('Errore nella assegnazione') }}";
|
||||
return;
|
||||
}
|
||||
const recipe = this.allRecipes.find(r => r.id === parseInt(this.recipeToAdd, 10));
|
||||
const recipe = this.allRecipes.find(r => r.id === id);
|
||||
if (recipe) this.assignedRecipes.push({ id: recipe.id, code: recipe.code, name: recipe.name, active: recipe.active });
|
||||
this.recipeToAdd = '';
|
||||
} catch (e) {
|
||||
this.errorMsg = '{{ _("Errore di connessione al server") }}';
|
||||
} finally {
|
||||
|
||||
@@ -78,7 +78,8 @@
|
||||
:class="{
|
||||
'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300': role === 'Maker',
|
||||
'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300': role === 'MeasurementTec',
|
||||
'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300': role === 'Metrologist'
|
||||
'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300': role === 'Metrologist',
|
||||
'bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300': role === 'Supervisor'
|
||||
}"
|
||||
x-text="role"></span>
|
||||
</template>
|
||||
@@ -233,6 +234,11 @@
|
||||
class="rounded border-[var(--border-color)] text-primary focus:ring-primary/30">
|
||||
<span class="text-sm text-[var(--text-primary)]">Metrologist</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" x-model="form.roles" value="Supervisor"
|
||||
class="rounded border-[var(--border-color)] text-primary focus:ring-primary/30">
|
||||
<span class="text-sm text-[var(--text-primary)]">{{ _('Supervisor (capoturno)') }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -307,8 +313,8 @@
|
||||
x-text="toggleUser?.active ? '{{ _('Conferma Disattivazione') }}' : '{{ _('Conferma Riattivazione') }}'"></h3>
|
||||
<p class="text-sm text-[var(--text-secondary)] mb-4">
|
||||
<span x-text="toggleUser?.active
|
||||
? '{{ _('Sei sicuro di voler disattivare l\'utente') }}'
|
||||
: '{{ _('Sei sicuro di voler riattivare l\'utente') }}'"></span>
|
||||
? "{{ _('Sei sicuro di voler disattivare l\'utente') }}"
|
||||
: "{{ _('Sei sicuro di voler riattivare l\'utente') }}""></span>
|
||||
<strong x-text="toggleUser?.username"></strong>?
|
||||
</p>
|
||||
<div class="flex justify-end gap-3">
|
||||
|
||||
@@ -2,20 +2,26 @@
|
||||
{% 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">
|
||||
<div class="relative min-h-screen flex items-center justify-center overflow-hidden bg-[var(--bg-primary)] px-4 sm:px-6 lg:px-8">
|
||||
|
||||
<!-- Decorative brand background -->
|
||||
<div class="pointer-events-none absolute inset-0" aria-hidden="true">
|
||||
<div class="absolute -top-32 -left-32 w-96 h-96 rounded-full bg-primary-500/10 dark:bg-primary-400/10 blur-3xl"></div>
|
||||
<div class="absolute -bottom-40 -right-24 w-[28rem] h-[28rem] rounded-full bg-primary-600/10 dark:bg-primary-500/10 blur-3xl"></div>
|
||||
</div>
|
||||
|
||||
<div class="relative max-w-md w-full space-y-6">
|
||||
<!-- Card -->
|
||||
<div class="bg-white dark:bg-slate-800 shadow-lg rounded-xl p-8">
|
||||
<div class="bg-[var(--bg-card)] border border-[var(--border-color)] shadow-lg rounded-2xl 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">
|
||||
<div class="mx-auto mb-4 inline-flex justify-center text-slate-900 dark:text-white">
|
||||
{% set logo_class = 'h-14 w-14' %}
|
||||
{% set wordmark_class = 'text-3xl' %}
|
||||
{% set logo_id = 'login' %}
|
||||
{% include 'components/_app_logo.html' %}
|
||||
</div>
|
||||
<p class="text-sm text-[var(--text-secondary)]">
|
||||
{{ _('Accedi al sistema') }}
|
||||
</p>
|
||||
</div>
|
||||
@@ -25,12 +31,12 @@
|
||||
<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">
|
||||
<label for="username" class="tmf-label">
|
||||
{{ _('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">
|
||||
<svg class="h-5 w-5 text-[var(--text-muted)]" 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>
|
||||
@@ -39,36 +45,52 @@
|
||||
name="username"
|
||||
required
|
||||
autofocus
|
||||
autocomplete="username"
|
||||
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">
|
||||
class="tmf-input !pl-10 !py-2.5">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Password -->
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||
<div x-data="{ show: false }">
|
||||
<label for="password" class="tmf-label">
|
||||
{{ _('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">
|
||||
<svg class="h-5 w-5 text-[var(--text-muted)]" 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"
|
||||
<input :type="show ? 'text' : 'password'"
|
||||
type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
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">
|
||||
class="tmf-input !pl-10 !pr-12 !py-2.5">
|
||||
<button type="button"
|
||||
@click="show = !show"
|
||||
:aria-label="show ? {{ _('Nascondi password')|tojson_attr }} : {{ _('Mostra password')|tojson_attr }}"
|
||||
class="absolute inset-y-0 right-0 px-3 flex items-center text-[var(--text-muted)] hover:text-[var(--text-secondary)] transition-colors">
|
||||
<!-- Eye (password hidden, click to show) -->
|
||||
<svg x-show="!show" class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
</svg>
|
||||
<!-- Eye slash (password visible, click to hide) -->
|
||||
<svg x-show="show" x-cloak class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3.98 8.223A10.477 10.477 0 001.934 12c1.292 4.338 5.31 7.5 10.066 7.5.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88"/>
|
||||
</svg>
|
||||
</button>
|
||||
</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">
|
||||
<button type="submit" class="btn btn-primary btn-lg w-full shadow-sm">
|
||||
<svg class="h-5 w-5" 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') }}
|
||||
@@ -78,7 +100,7 @@
|
||||
|
||||
<!-- Help Text -->
|
||||
<div class="mt-6 text-center">
|
||||
<p class="text-xs text-slate-500 dark:text-slate-400">
|
||||
<p class="text-xs text-[var(--text-muted)]">
|
||||
{{ _('Hai dimenticato la password?') }}
|
||||
<br>
|
||||
{{ _('Contatta l\'amministratore') }}
|
||||
@@ -87,8 +109,8 @@
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="text-center text-xs text-slate-500 dark:text-slate-400">
|
||||
<p>TieMeasureFlow © 2025 - {{ _('Sistema di misurazione industriale') }}</p>
|
||||
<div class="text-center text-xs text-[var(--text-muted)]">
|
||||
<p>TieMeasureFlow © {{ current_year }} - {{ _('Sistema di misurazione industriale') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -87,6 +87,8 @@
|
||||
bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200 border border-blue-200 dark:border-blue-800
|
||||
{% elif role == 'Metrologist' %}
|
||||
bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-200 border border-purple-200 dark:border-purple-800
|
||||
{% elif role == 'Supervisor' %}
|
||||
bg-orange-100 dark:bg-orange-900/30 text-orange-800 dark:text-orange-200 border border-orange-200 dark:border-orange-800
|
||||
{% else %}
|
||||
bg-slate-100 dark:bg-slate-700 text-slate-800 dark:text-slate-200 border border-slate-200 dark:border-slate-600
|
||||
{% endif %}">
|
||||
|
||||
@@ -24,16 +24,19 @@
|
||||
<!-- Theme CSS Variables -->
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/themes.css') }}">
|
||||
|
||||
<!-- Authoritative per-user theme from the server session (empty when anonymous) -->
|
||||
<script>window.__SERVER_THEME = "{{ current_theme if current_user else '' }}";</script>
|
||||
|
||||
<!-- Alpine.js Theme Init (before Alpine loads) -->
|
||||
<script src="{{ url_for('static', filename='js/alpine-init.js') }}"></script>
|
||||
|
||||
{% block extra_head %}{% endblock %}
|
||||
</head>
|
||||
|
||||
<body class="h-full bg-[var(--bg-primary)] text-[var(--text-primary)] font-sans antialiased transition-colors duration-300">
|
||||
<body class="{% block body_class %}h-full{% endblock %} bg-[var(--bg-primary)] text-[var(--text-primary)] font-sans antialiased transition-colors duration-300">
|
||||
|
||||
<!-- Alpine.js App Wrapper -->
|
||||
<div x-data class="min-h-full flex flex-col">
|
||||
<div x-data class="{% block wrapper_class %}min-h-full{% endblock %} flex flex-col">
|
||||
|
||||
<!-- Navbar -->
|
||||
{% include "components/navbar.html" %}
|
||||
@@ -98,21 +101,91 @@
|
||||
{% endwith %}
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="flex-1">
|
||||
<main class="{% block main_class %}flex-1{% endblock %}">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
{% block footer %}
|
||||
<!-- Footer -->
|
||||
<footer class="py-4 text-center border-t border-[var(--border-color)] transition-colors duration-300">
|
||||
<p class="text-xs text-steel dark:text-steel-light tracking-wide">
|
||||
Powered by <span class="font-semibold">TieMeasureFlow</span> — Tielogic
|
||||
</p>
|
||||
</footer>
|
||||
{% endblock %}
|
||||
|
||||
</div>
|
||||
|
||||
{% block extra_js %}{% endblock %}
|
||||
|
||||
{% if current_user and auto_logout_minutes %}
|
||||
<script>
|
||||
function inactivityTimer() {
|
||||
var timeoutMs = {{ auto_logout_minutes }} * 60 * 1000;
|
||||
var warningMs = 60 * 1000;
|
||||
return {
|
||||
lastActivity: Date.now(),
|
||||
showWarning: false,
|
||||
remainingSeconds: 60,
|
||||
_interval: null,
|
||||
init() {
|
||||
var self = this;
|
||||
this._interval = setInterval(function() { self._check(); }, 5000);
|
||||
var lastUpdate = 0;
|
||||
var handler = function() {
|
||||
var now = Date.now();
|
||||
if (now - lastUpdate > 3000) {
|
||||
lastUpdate = now;
|
||||
self.lastActivity = now;
|
||||
self.showWarning = false;
|
||||
}
|
||||
};
|
||||
['mousemove','mousedown','keypress','touchstart','scroll'].forEach(function(evt) {
|
||||
window.addEventListener(evt, handler, { passive: true });
|
||||
});
|
||||
},
|
||||
resetTimer() {
|
||||
this.lastActivity = Date.now();
|
||||
this.showWarning = false;
|
||||
},
|
||||
_check() {
|
||||
var elapsed = Date.now() - this.lastActivity;
|
||||
var remaining = timeoutMs - elapsed;
|
||||
if (remaining <= 0) {
|
||||
clearInterval(this._interval);
|
||||
window.location.href = '{{ url_for("auth.logout") }}';
|
||||
} else if (remaining <= warningMs) {
|
||||
this.showWarning = true;
|
||||
this.remainingSeconds = Math.ceil(remaining / 1000);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
<div x-data="inactivityTimer()" class="contents">
|
||||
<template x-if="showWarning">
|
||||
<div class="fixed inset-0 z-[100] flex items-center justify-center bg-black/50" @click.self="resetTimer()">
|
||||
<div class="bg-[var(--bg-primary)] rounded-xl p-6 shadow-2xl max-w-sm mx-4 border border-[var(--border-color)]">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="w-10 h-10 rounded-full bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-amber-600" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-[var(--text-primary)]">{{ _('Sessione in scadenza') }}</h3>
|
||||
</div>
|
||||
<p class="text-sm text-[var(--text-secondary)] mb-5">
|
||||
{{ _('Sarai disconnesso tra') }} <span class="font-mono font-bold text-amber-600" x-text="remainingSeconds"></span> {{ _('secondi per inattività.') }}
|
||||
</p>
|
||||
<button @click="resetTimer()" class="btn btn-primary w-full">
|
||||
{{ _('Continua a lavorare') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Alpine.js CDN (defer) - must load AFTER extra_js so component functions are defined -->
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||
</body>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<!--
|
||||
TieMeasureFlow logo: brand badge (SVG) + HTML wordmark.
|
||||
The wordmark inherits `color` from the parent (set e.g. `text-slate-900
|
||||
dark:text-white`); "Measure" keeps the brand blue.
|
||||
|
||||
Parameters (set before include):
|
||||
logo_class — badge size, default 'h-10 w-10'
|
||||
wordmark_class — wordmark text size, default 'text-xl'; '' hides the text
|
||||
logo_id — unique suffix for the SVG gradient id when the logo
|
||||
appears more than once per page (default 'tmf')
|
||||
-->
|
||||
{% set _logo_id = logo_id|default('tmf') %}
|
||||
<span class="inline-flex items-center gap-2.5 select-none whitespace-nowrap">
|
||||
<svg class="{{ logo_class|default('h-10 w-10') }} shrink-0" viewBox="0 0 64 64" fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<defs>
|
||||
<linearGradient id="tmfBadge-{{ _logo_id }}" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#3B82F6"/>
|
||||
<stop offset="1" stop-color="#1D4ED8"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="2" y="2" width="60" height="60" rx="15" fill="url(#tmfBadge-{{ _logo_id }})"/>
|
||||
<rect x="14" y="16" width="5" height="32" rx="2.5" fill="#FFFFFF"/>
|
||||
<rect x="45" y="16" width="5" height="32" rx="2.5" fill="#FFFFFF"/>
|
||||
<path d="M24 32 H40" stroke="#FFFFFF" stroke-width="4" stroke-linecap="round"/>
|
||||
<path d="M28 26.5 L22.5 32 L28 37.5" stroke="#FFFFFF" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M36 26.5 L41.5 32 L36 37.5" stroke="#FFFFFF" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
{% if wordmark_class is not defined or wordmark_class %}
|
||||
<span class="{{ wordmark_class|default('text-xl') }} font-bold tracking-tight leading-none"
|
||||
aria-label="TieMeasureFlow">Tie<span class="text-primary-600 dark:text-primary-400">Measure</span>Flow</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
@@ -1,19 +1,60 @@
|
||||
<!-- TieMeasureFlow Navbar -->
|
||||
|
||||
{# SVG path data for nav icons, shared between desktop and mobile menus #}
|
||||
{% set icon_measure %}<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"/>{% endset %}
|
||||
{% set icon_recipes %}<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"/>{% endset %}
|
||||
{% set icon_stats %}<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"/>{% endset %}
|
||||
{% set icon_users %}<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"/>{% endset %}
|
||||
{% set icon_stations %}<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"/>{% endset %}
|
||||
{% set icon_settings %}<path stroke-linecap="round" stroke-linejoin="round" d="M10.343 3.94c.09-.542.56-.94 1.11-.94h1.093c.55 0 1.02.398 1.11.94l.149.894c.07.424.384.764.78.93.398.164.855.142 1.205-.108l.737-.527a1.125 1.125 0 011.45.12l.773.774c.39.389.44 1.002.12 1.45l-.527.737c-.25.35-.272.806-.107 1.204.165.397.505.71.93.78l.893.15c.543.09.94.56.94 1.109v1.094c0 .55-.397 1.02-.94 1.11l-.893.149c-.425.07-.765.383-.93.78-.165.398-.143.854.107 1.204l.527.738c.32.447.269 1.06-.12 1.45l-.774.773a1.125 1.125 0 01-1.449.12l-.738-.527c-.35-.25-.806-.272-1.204-.107-.397.165-.71.505-.78.929l-.15.894c-.09.542-.56.94-1.11.94h-1.094c-.55 0-1.019-.398-1.11-.94l-.148-.894c-.071-.424-.384-.764-.781-.93-.398-.164-.854-.142-1.204.108l-.738.527c-.447.32-1.06.269-1.45-.12l-.773-.774a1.125 1.125 0 01-.12-1.45l.527-.737c.25-.35.273-.806.108-1.204-.165-.397-.506-.71-.93-.78l-.894-.15c-.542-.09-.94-.56-.94-1.109v-1.094c0-.55.398-1.02.94-1.11l.894-.149c.424-.07.765-.383.93-.78.165-.398.143-.854-.107-1.204l-.527-.738a1.125 1.125 0 01.12-1.45l.773-.773a1.125 1.125 0 011.45-.12l.737.527c.35.25.807.272 1.204.107.397-.165.71-.505.78-.929l.15-.894z"/><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>{% endset %}
|
||||
|
||||
{# Single nav link, desktop or mobile variant. `icon` is SVG path markup. #}
|
||||
{% macro nav_link(href, label, icon, active=false, mobile=false) -%}
|
||||
<a href="{{ href }}"
|
||||
{% if active %}aria-current="page"{% endif %}
|
||||
class="flex items-center {{ 'gap-3 px-3 py-2.5' if mobile else 'gap-2 px-3 py-2' }} rounded-lg text-sm font-medium transition-colors duration-200
|
||||
{% if active %}text-primary bg-primary-50 dark:bg-primary-900/20{% else %}text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20{% endif %}">
|
||||
<svg class="{{ 'w-5 h-5' if mobile else 'w-[18px] h-[18px]' }} shrink-0" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">{{ icon }}</svg>
|
||||
<span>{{ label }}</span>
|
||||
</a>
|
||||
{%- endmacro %}
|
||||
|
||||
{# Render all role-gated nav links for the current user #}
|
||||
{% macro nav_links(mobile=false) -%}
|
||||
{% set ep = request.endpoint or '' %}
|
||||
{% if current_user.get('roles') and 'MeasurementTec' in current_user.roles %}
|
||||
{{ nav_link(url_for('measure.select_recipe'), _('Misure'), icon_measure, ep.startswith('measure.'), mobile) }}
|
||||
{% endif %}
|
||||
{% if current_user.get('roles') and 'Maker' in current_user.roles %}
|
||||
{{ nav_link(url_for('maker.recipe_list'), _('Ricette'), icon_recipes, ep.startswith('maker.'), mobile) }}
|
||||
{% endif %}
|
||||
{% if current_user.get('roles') and 'Metrologist' in current_user.roles %}
|
||||
{{ nav_link(url_for('statistics.dashboard'), _('Statistiche'), icon_stats, ep.startswith('statistics.'), mobile) }}
|
||||
{% endif %}
|
||||
{% if current_user.get('is_admin') %}
|
||||
{{ nav_link(url_for('admin.user_list'), _('Utenti'), icon_users, ep == 'admin.user_list', mobile) }}
|
||||
{{ nav_link(url_for('admin.station_list'), _('Stazioni'), icon_stations, ep == 'admin.station_list', mobile) }}
|
||||
{{ nav_link(url_for('admin.settings_page'), _('Impostazioni'), icon_settings, ep == 'admin.settings_page', mobile) }}
|
||||
{% endif %}
|
||||
{%- endmacro %}
|
||||
|
||||
<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 }">
|
||||
x-data="{ mobileOpen: 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">
|
||||
<a href="{{ url_for('index') }}"
|
||||
class="flex items-center gap-2.5 group text-slate-900 dark:text-white">
|
||||
{% 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') }}'">
|
||||
<img src="{{ url_for('static', filename='img/' ~ company_logo) }}" alt="Logo" class="h-10 w-auto"
|
||||
onerror="this.style.display='none'">
|
||||
{% else %}
|
||||
<img src="{{ url_for('static', filename='img/tmflow-logo.svg') }}"
|
||||
alt="TieMeasureFlow"
|
||||
class="h-8 w-auto">
|
||||
{% set logo_class = 'h-9 w-9' %}
|
||||
{% set wordmark_class = 'text-xl' %}
|
||||
{% set logo_id = 'nav' %}
|
||||
{% include 'components/_app_logo.html' %}
|
||||
{% endif %}
|
||||
</a>
|
||||
</div>
|
||||
@@ -21,89 +62,7 @@
|
||||
<!-- 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 %}
|
||||
|
||||
{{ nav_links() }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -112,24 +71,17 @@
|
||||
|
||||
<!-- Language Toggle -->
|
||||
<div class="flex items-center border border-[var(--border-color)] rounded-lg overflow-hidden">
|
||||
<a href="{{ url_for('set_language', lang='it') }}"
|
||||
{% for lang_code in ['it', 'en'] %}
|
||||
<a href="{{ url_for('set_language', lang=lang_code) }}"
|
||||
class="px-2 py-1.5 text-xs font-semibold transition-colors duration-200
|
||||
{% if current_language == 'it' %}
|
||||
{% if current_language == lang_code %}
|
||||
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
|
||||
{{ lang_code|upper }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Theme Toggle -->
|
||||
@@ -199,6 +151,15 @@
|
||||
|
||||
<div class="border-t border-[var(--border-color)] my-1"></div>
|
||||
|
||||
{% if request.endpoint == 'measure.task_execute' %}
|
||||
<span class="flex items-center gap-2.5 px-4 py-2.5 text-sm text-[var(--text-muted)] opacity-50 cursor-not-allowed"
|
||||
title="{{ _('Logout bloccato durante le misurazioni') }}">
|
||||
<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="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>
|
||||
{{ _('Logout') }}
|
||||
</span>
|
||||
{% else %}
|
||||
<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">
|
||||
@@ -207,6 +168,7 @@
|
||||
</svg>
|
||||
{{ _('Logout') }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -241,66 +203,7 @@
|
||||
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 %}
|
||||
|
||||
{{ nav_links(mobile=true) }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -231,6 +231,23 @@
|
||||
rows="3"
|
||||
placeholder="{{ _('Descrizione opzionale della ricetta...') }}"></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Intervallo di misura -->
|
||||
<div>
|
||||
<label class="tmf-label" for="recipe-interval">
|
||||
{{ _('Intervallo misura (minuti)') }}
|
||||
</label>
|
||||
<input id="recipe-interval"
|
||||
type="number"
|
||||
x-model.number="measurementIntervalMinutes"
|
||||
class="tmf-input"
|
||||
min="1"
|
||||
max="1440"
|
||||
placeholder="{{ _('Es. 30') }}">
|
||||
<p class="text-xs text-[var(--text-muted)] mt-1">
|
||||
{{ _('Timer cicalino per ricordare la misurazione periodica') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -466,6 +483,9 @@ function recipeEditor() {
|
||||
versions: {{ (recipe.versions if recipe and recipe.versions else [])|tojson }},
|
||||
currentVersion: {{ (recipe.current_version.version_number if recipe and recipe.current_version else 0)|tojson }},
|
||||
|
||||
// ---- Measurement interval ----
|
||||
measurementIntervalMinutes: {{ (recipe.measurement_interval_minutes if recipe and recipe.measurement_interval_minutes else 'null')|tojson }},
|
||||
|
||||
// ---- File upload (preview image) ----
|
||||
currentFilePath: {{ (recipe.image_path if recipe and recipe.image_path else '')|tojson }},
|
||||
uploadingFile: false,
|
||||
@@ -487,6 +507,11 @@ function recipeEditor() {
|
||||
description: this.description.trim(),
|
||||
};
|
||||
|
||||
// Measurement interval
|
||||
if (this.measurementIntervalMinutes) {
|
||||
payload.measurement_interval_minutes = parseInt(this.measurementIntervalMinutes, 10);
|
||||
}
|
||||
|
||||
// Include image_path for preview thumbnail
|
||||
if (this.currentFilePath) {
|
||||
payload.image_path = this.currentFilePath;
|
||||
|
||||
@@ -120,7 +120,7 @@
|
||||
</div>
|
||||
<input type="text"
|
||||
x-model="search"
|
||||
class="tmf-input pl-10"
|
||||
class="tmf-input !pl-10"
|
||||
:placeholder="'{{ _('Cerca per nome, codice o descrizione...') }}'">
|
||||
</div>
|
||||
</div>
|
||||
@@ -152,11 +152,11 @@
|
||||
<div class="p-5 sm:p-6">
|
||||
<!-- Card Header -->
|
||||
<div class="flex flex-col sm:flex-row sm:items-start gap-4 mb-4">
|
||||
<!-- Thumbnail -->
|
||||
<!-- Thumbnail (fit, no crop) -->
|
||||
<template x-if="recipe.image_path">
|
||||
<div class="w-20 h-20 rounded-lg overflow-hidden bg-[var(--bg-secondary)] border border-[var(--border-color)] shrink-0">
|
||||
<div class="w-20 h-20 rounded-lg overflow-hidden bg-[var(--bg-secondary)] border border-[var(--border-color)] shrink-0 flex items-center justify-center">
|
||||
<img :src="'/maker/api/files/' + recipe.image_path"
|
||||
class="w-full h-full object-cover"
|
||||
class="max-w-full max-h-full object-contain"
|
||||
loading="lazy"
|
||||
onerror="this.parentElement.style.display='none'">
|
||||
</div>
|
||||
|
||||
@@ -247,6 +247,18 @@
|
||||
<span class="hidden sm:inline">{{ _('Rettangolo') }}</span>
|
||||
</button>
|
||||
|
||||
<!-- Text Tool -->
|
||||
<button type="button"
|
||||
class="anno-btn"
|
||||
:class="{ 'active': annoTool === 'text' }"
|
||||
@click="setAnnoTool('text')"
|
||||
title="{{ _('Testo') }}">
|
||||
<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="M4 6h16M4 6v2m16-2v2M7 6v12m0 0h2m-2 0H5m12-12v12m0 0h2m-2 0h-2"/>
|
||||
</svg>
|
||||
<span class="hidden sm:inline">{{ _('Testo') }}</span>
|
||||
</button>
|
||||
|
||||
<!-- Separator -->
|
||||
<div class="w-px h-6 bg-[var(--border-color)] mx-1"></div>
|
||||
|
||||
|
||||
@@ -221,14 +221,24 @@
|
||||
<span x-text="tasks.length"></span>
|
||||
<span x-text="tasks.length === 1 ? '{{ _('task') }}' : '{{ _('task') }}'"></span>
|
||||
</p>
|
||||
<button @click="showAddTask = true; $nextTick(() => $refs.newTaskTitle && $refs.newTaskTitle.focus())"
|
||||
x-show="!showAddTask"
|
||||
class="btn btn-primary gap-1.5">
|
||||
<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="M12 4v16m8-8H4"/>
|
||||
</svg>
|
||||
{{ _('Aggiungi Task') }}
|
||||
</button>
|
||||
<div class="flex items-center gap-2">
|
||||
<button @click="showAddTask = true; $nextTick(() => $refs.newTaskTitle && $refs.newTaskTitle.focus())"
|
||||
x-show="!showAddTask"
|
||||
class="btn btn-primary gap-1.5">
|
||||
<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="M12 4v16m8-8H4"/>
|
||||
</svg>
|
||||
{{ _('Aggiungi Task') }}
|
||||
</button>
|
||||
<button @click="showAiImport = true"
|
||||
x-show="!showAddTask"
|
||||
class="btn btn-secondary gap-1.5">
|
||||
<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="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||||
</svg>
|
||||
{{ _('Importa da PDF') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============================================================
|
||||
@@ -269,12 +279,12 @@
|
||||
placeholder="{{ _('Es. Seguire procedura ISO 2768') }}">
|
||||
</div>
|
||||
<!-- Descrizione -->
|
||||
<div>
|
||||
<div class="sm:col-span-2">
|
||||
<label class="tmf-label">{{ _('Descrizione') }}</label>
|
||||
<input type="text"
|
||||
x-model="newTask.description"
|
||||
class="tmf-input"
|
||||
placeholder="{{ _('Descrizione opzionale...') }}">
|
||||
<textarea x-model="newTask.description"
|
||||
class="tmf-input text-sm"
|
||||
rows="3"
|
||||
placeholder="{{ _('Descrizione opzionale...') }}"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -497,7 +507,7 @@
|
||||
<template x-if="task.description">
|
||||
<div class="flex items-start gap-2">
|
||||
<span class="text-xs font-semibold text-[var(--text-muted)] uppercase tracking-wide shrink-0 mt-0.5">{{ _('Descrizione') }}:</span>
|
||||
<span class="text-sm text-[var(--text-secondary)]" x-text="task.description"></span>
|
||||
<span class="text-sm text-[var(--text-secondary)] whitespace-pre-wrap" x-text="task.description"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -513,12 +523,12 @@
|
||||
class="tmf-input text-sm"
|
||||
placeholder="{{ _('Direttiva opzionale...') }}">
|
||||
</div>
|
||||
<div>
|
||||
<div class="sm:col-span-2">
|
||||
<label class="tmf-label">{{ _('Descrizione') }}</label>
|
||||
<input type="text"
|
||||
x-model="editTaskData.description"
|
||||
class="tmf-input text-sm"
|
||||
placeholder="{{ _('Descrizione opzionale...') }}">
|
||||
<textarea x-model="editTaskData.description"
|
||||
class="tmf-input text-sm"
|
||||
rows="3"
|
||||
placeholder="{{ _('Descrizione opzionale...') }}"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -930,6 +940,118 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ================================================================
|
||||
AI IMPORT MODAL — Upload PDF + preview suggested tasks
|
||||
================================================================ #}
|
||||
<div x-show="showAiImport"
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
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"
|
||||
@click.self="showAiImport = false">
|
||||
<div class="bg-[var(--bg-card)] rounded-2xl shadow-2xl max-w-2xl w-full mx-4 max-h-[85vh] flex flex-col border border-[var(--border-color)]">
|
||||
|
||||
{# Header #}
|
||||
<div class="p-5 border-b border-[var(--border-color)] shrink-0">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-full bg-primary-50 dark:bg-primary-900/30 flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-primary" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-bold text-[var(--text-primary)]">{{ _('Importa da Scheda Tecnica') }}</h3>
|
||||
<p class="text-xs text-[var(--text-secondary)]">{{ _('Carica un PDF e l\'AI estrarrà i task automaticamente') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Body #}
|
||||
<div class="p-5 overflow-y-auto flex-1">
|
||||
{# Upload area (before analysis) #}
|
||||
<div x-show="!aiSuggestions.length && !aiParsing">
|
||||
<label class="flex flex-col items-center justify-center w-full h-40 border-2 border-dashed border-[var(--border-color)] rounded-xl cursor-pointer
|
||||
hover:border-primary hover:bg-primary-50/50 dark:hover:bg-primary-900/10 transition-colors">
|
||||
<svg class="w-10 h-10 text-[var(--text-muted)] mb-2" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5"/>
|
||||
</svg>
|
||||
<span class="text-sm font-medium text-[var(--text-secondary)]">{{ _('Clicca per caricare un PDF') }}</span>
|
||||
<span class="text-xs text-[var(--text-muted)] mt-1">{{ _('Max 20MB') }}</span>
|
||||
<input type="file" accept=".pdf,application/pdf" class="hidden" @change="uploadAndParse($event)">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{# Loading #}
|
||||
<div x-show="aiParsing" class="flex flex-col items-center justify-center py-12">
|
||||
<svg class="w-10 h-10 animate-spin text-primary mb-3" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"/>
|
||||
</svg>
|
||||
<p class="text-sm font-medium text-[var(--text-secondary)]">{{ _('Analisi in corso con AI...') }}</p>
|
||||
<p class="text-xs text-[var(--text-muted)] mt-1">{{ _('Potrebbe richiedere fino a 30 secondi') }}</p>
|
||||
</div>
|
||||
|
||||
{# Error #}
|
||||
<div x-show="aiError" class="p-3 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 mb-4">
|
||||
<p class="text-sm text-red-700 dark:text-red-300" x-text="aiError"></p>
|
||||
<button @click="aiError = ''; aiSuggestions = []" class="text-xs text-red-600 underline mt-1">{{ _('Riprova') }}</button>
|
||||
</div>
|
||||
|
||||
{# Suggested tasks (editable preview) #}
|
||||
<div x-show="aiSuggestions.length > 0" class="space-y-3">
|
||||
<p class="text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
<span x-text="aiSuggestions.length"></span> {{ _('task suggeriti — modifica o rimuovi prima di confermare') }}
|
||||
</p>
|
||||
<template x-for="(suggestion, idx) in aiSuggestions" :key="idx">
|
||||
<div class="tmf-card">
|
||||
<div class="p-4 space-y-2">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-xs font-bold text-primary">Task <span x-text="idx + 1"></span></span>
|
||||
<button @click="aiSuggestions.splice(idx, 1)" class="text-xs text-red-500 hover:text-red-700">
|
||||
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<input type="text" x-model="suggestion.title" class="tmf-input text-sm font-semibold"
|
||||
placeholder="{{ _('Titolo') }}">
|
||||
<input type="text" x-model="suggestion.directive" class="tmf-input text-sm"
|
||||
placeholder="{{ _('Direttiva') }}">
|
||||
<textarea x-model="suggestion.description" class="tmf-input text-sm" rows="3"
|
||||
placeholder="{{ _('Descrizione') }}"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Footer #}
|
||||
<div class="p-4 border-t border-[var(--border-color)] shrink-0 flex items-center justify-between gap-2">
|
||||
<button @click="showAiImport = false; aiSuggestions = []; aiError = ''"
|
||||
class="btn btn-secondary text-sm">
|
||||
{{ _('Annulla') }}
|
||||
</button>
|
||||
<button x-show="aiSuggestions.length > 0"
|
||||
@click="createTasksFromSuggestions()"
|
||||
:disabled="aiCreating"
|
||||
class="btn btn-primary text-sm gap-1.5">
|
||||
<svg x-show="!aiCreating" 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="M5 13l4 4L19 7"/>
|
||||
</svg>
|
||||
<svg x-show="aiCreating" class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"/>
|
||||
</svg>
|
||||
{{ _('Crea') }} <span x-text="aiSuggestions.length"></span> {{ _('task') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -983,6 +1105,13 @@ function taskEditor() {
|
||||
// ---- Drag & Drop state ----
|
||||
dragState: { dragging: null, over: null, position: null },
|
||||
|
||||
// ---- AI Import ----
|
||||
showAiImport: false,
|
||||
aiParsing: false,
|
||||
aiCreating: false,
|
||||
aiSuggestions: [],
|
||||
aiError: '',
|
||||
|
||||
// ============================================================
|
||||
// Init
|
||||
// ============================================================
|
||||
@@ -1081,6 +1210,80 @@ function taskEditor() {
|
||||
this.newTask = { title: '', directive: '', description: '' };
|
||||
},
|
||||
|
||||
// ============================================================
|
||||
// AI Import
|
||||
// ============================================================
|
||||
async uploadAndParse(event) {
|
||||
var file = event.target.files[0];
|
||||
if (!file) return;
|
||||
this.aiParsing = true;
|
||||
this.aiError = '';
|
||||
this.aiSuggestions = [];
|
||||
|
||||
try {
|
||||
var formData = new FormData();
|
||||
formData.append('file', file);
|
||||
var resp = await fetch('/maker/api/recipes/' + this.recipeId + '/parse-technical-sheet', {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRFToken': this.csrfToken() },
|
||||
body: formData
|
||||
});
|
||||
var data = await resp.json();
|
||||
|
||||
if (!resp.ok || data.error) {
|
||||
this.aiError = data.detail || {{ _("Errore nell'analisi del PDF")|tojson }};
|
||||
} else {
|
||||
this.aiSuggestions = data.suggested_tasks || [];
|
||||
if (!this.aiSuggestions.length) {
|
||||
this.aiError = {{ _("Nessun task identificato nel PDF")|tojson }};
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('AI parse error:', err);
|
||||
this.aiError = {{ _("Errore di connessione al server")|tojson }};
|
||||
}
|
||||
|
||||
this.aiParsing = false;
|
||||
event.target.value = '';
|
||||
},
|
||||
|
||||
async createTasksFromSuggestions() {
|
||||
this.aiCreating = true;
|
||||
var created = 0;
|
||||
|
||||
for (var i = 0; i < this.aiSuggestions.length; i++) {
|
||||
var s = this.aiSuggestions[i];
|
||||
if (!s.title || !s.title.trim()) continue;
|
||||
|
||||
try {
|
||||
var resp = await fetch('/maker/api/recipes/' + this.recipeId + '/tasks', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken() },
|
||||
body: JSON.stringify({
|
||||
title: s.title.trim(),
|
||||
directive: (s.directive || '').trim() || null,
|
||||
description: (s.description || '').trim() || null
|
||||
})
|
||||
});
|
||||
var data = await resp.json();
|
||||
if (!data.error) {
|
||||
if (!data.subtasks) data.subtasks = [];
|
||||
this.tasks.push(data);
|
||||
created++;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Create task error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
this.aiCreating = false;
|
||||
this.showAiImport = false;
|
||||
this.aiSuggestions = [];
|
||||
if (created > 0) {
|
||||
this.successMessage = created + ' ' + {{ _("task creati dalla scheda tecnica")|tojson }};
|
||||
}
|
||||
},
|
||||
|
||||
async addTask() {
|
||||
if (!this.newTask.title.trim()) return;
|
||||
this.saving = true;
|
||||
|
||||
@@ -92,40 +92,61 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search + Traceability Bar -->
|
||||
<div class="tmf-card mb-6">
|
||||
<!-- Search Bar (filter) -->
|
||||
<div class="tmf-card mb-4">
|
||||
<div class="p-4 sm:p-5">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<!-- Search Input -->
|
||||
<div class="sm:col-span-2">
|
||||
<label class="tmf-label">
|
||||
<svg class="w-3.5 h-3.5 inline-block mr-1 -mt-0.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||
</svg>
|
||||
{{ _('Cerca ricetta') }}
|
||||
</label>
|
||||
<input type="text"
|
||||
x-model="search"
|
||||
placeholder="{{ _('Nome, codice o descrizione...') }}"
|
||||
class="tmf-input">
|
||||
</div>
|
||||
<label class="tmf-label">
|
||||
<svg class="w-3.5 h-3.5 inline-block mr-1 -mt-0.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||
</svg>
|
||||
{{ _('Cerca ricetta') }}
|
||||
</label>
|
||||
<input type="text"
|
||||
x-model="search"
|
||||
placeholder="{{ _('Nome, codice o descrizione...') }}"
|
||||
class="tmf-input">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Traceability (data entry, NOT a search filter) -->
|
||||
<div class="tmf-card mb-6 border-l-4 border-l-primary">
|
||||
<div class="p-4 sm:p-5">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<svg class="w-5 h-5 text-primary 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>
|
||||
<div>
|
||||
<h2 class="text-sm font-semibold text-[var(--text-primary)] leading-tight">{{ _('Tracciabilità') }}</h2>
|
||||
<p class="text-xs text-[var(--text-muted)] leading-tight">{{ _('Dati del pezzo da misurare — compila prima di selezionare la ricetta') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<!-- Lot Number -->
|
||||
<div>
|
||||
<label class="tmf-label">{{ _('Numero Lotto') }}</label>
|
||||
<label class="tmf-label flex items-center gap-1.5">
|
||||
<svg class="w-3.5 h-3.5 text-amber-500" fill="none" stroke="currentColor" stroke-width="2" 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>
|
||||
{{ _('Numero Lotto') }}
|
||||
</label>
|
||||
<input type="text"
|
||||
x-model="lot_number"
|
||||
placeholder="{{ _('Opzionale') }}"
|
||||
class="tmf-input">
|
||||
placeholder="{{ _('Es. LOT-2026-001 (opzionale)') }}"
|
||||
class="tmf-input font-mono">
|
||||
</div>
|
||||
|
||||
<!-- Serial Number -->
|
||||
<div>
|
||||
<label class="tmf-label">{{ _('Numero Seriale') }}</label>
|
||||
<label class="tmf-label flex items-center gap-1.5">
|
||||
<svg class="w-3.5 h-3.5 text-indigo-500" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14"/>
|
||||
</svg>
|
||||
{{ _('Numero Seriale') }}
|
||||
</label>
|
||||
<input type="text"
|
||||
x-model="serial_number"
|
||||
placeholder="{{ _('Opzionale') }}"
|
||||
class="tmf-input">
|
||||
placeholder="{{ _('Es. SN-000123 (opzionale)') }}"
|
||||
class="tmf-input font-mono">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -157,15 +178,30 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Recipe Name -->
|
||||
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-2 leading-snug"
|
||||
x-text="recipe.name">
|
||||
</h3>
|
||||
<!-- Title + Thumbnail row -->
|
||||
<div class="flex items-start gap-3 mb-2">
|
||||
<!-- Thumbnail (fit, no crop) -->
|
||||
<template x-if="recipe.image_path">
|
||||
<div class="w-16 h-16 rounded-md overflow-hidden bg-[var(--bg-secondary)] border border-[var(--border-color)] shrink-0 flex items-center justify-center">
|
||||
<img :src="'/measure/api/files/' + recipe.image_path"
|
||||
class="max-w-full max-h-full object-contain"
|
||||
loading="lazy"
|
||||
onerror="this.parentElement.style.display='none'">
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Description -->
|
||||
<p class="text-sm text-[var(--text-secondary)] line-clamp-2 leading-relaxed"
|
||||
x-text="recipe.description || '{{ _('Nessuna descrizione disponibile') }}'">
|
||||
</p>
|
||||
<div class="min-w-0 flex-1">
|
||||
<!-- Recipe Name -->
|
||||
<h3 class="text-lg font-semibold text-[var(--text-primary)] leading-snug"
|
||||
x-text="recipe.name">
|
||||
</h3>
|
||||
|
||||
<!-- Description -->
|
||||
<p class="mt-1 text-sm text-[var(--text-secondary)] line-clamp-2 leading-relaxed"
|
||||
x-text="recipe.description || '{{ _('Nessuna descrizione disponibile') }}'">
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Meta Info -->
|
||||
<div class="mt-4 flex items-center gap-3 text-xs text-[var(--text-muted)]">
|
||||
|
||||
@@ -175,7 +175,9 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for m in measurements|sort(attribute='task_info.order_index,subtask.marker_number') %}
|
||||
{# Jinja's sort takes a single attribute. Chain stable sorts
|
||||
to get task.order_index → subtask.marker_number ordering. #}
|
||||
{% for m in measurements|sort(attribute='subtask.marker_number')|sort(attribute='task_info.order_index') %}
|
||||
<tr>
|
||||
<td class="text-center font-mono text-xs" style="color: var(--text-secondary);">
|
||||
{{ m.measured_at[:16]|replace('T', ' ') if m.measured_at else '-' }}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ task.title or 'Task' }} — {{ _('Misure') }} — TieMeasureFlow{% endblock %}
|
||||
|
||||
{# Full-screen layout: no body scroll, no footer, main fills available space #}
|
||||
{% block body_class %}h-screen overflow-hidden{% endblock %}
|
||||
{% block wrapper_class %}h-full{% endblock %}
|
||||
{% block main_class %}flex-1 min-h-0{% endblock %}
|
||||
{% block footer %}{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<style>
|
||||
/* Annotation viewer container */
|
||||
@@ -57,7 +63,7 @@
|
||||
window.__allTaskIds = {{ all_task_ids|tojson }};
|
||||
</script>
|
||||
|
||||
<div class="h-screen flex flex-col overflow-hidden"
|
||||
<div class="h-full flex flex-col overflow-hidden"
|
||||
x-data="taskExecute()"
|
||||
x-init="init()"
|
||||
@numpad-confirm.window="handleMeasurement($event.detail.value, $event.detail.inputMethod)"
|
||||
@@ -78,7 +84,7 @@
|
||||
</a>
|
||||
|
||||
{# Task badge + title #}
|
||||
<div class="flex items-center gap-2 min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<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">
|
||||
@@ -89,6 +95,45 @@
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{# Lista task + Riepilogo buttons #}
|
||||
<div class="shrink-0 flex items-center gap-1.5">
|
||||
<a href="{{ url_for('measure.task_list', recipe_id=task.recipe_id or 0) }}"
|
||||
class="btn btn-secondary text-xs py-1 px-2.5 gap-1">
|
||||
<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="M4 6h16M4 10h16M4 14h16M4 18h16"/>
|
||||
</svg>
|
||||
{{ _('Lista task') }}
|
||||
</a>
|
||||
<button @click="goToSummary()"
|
||||
class="btn btn-secondary text-xs py-1 px-2.5 gap-1">
|
||||
<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 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||||
</svg>
|
||||
{{ _('Riepilogo') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{# Fermo linea + Fine produzione (measurement tasks only) #}
|
||||
<template x-if="subtasks.length > 0">
|
||||
<div class="shrink-0 flex items-center gap-1.5">
|
||||
<button @click="openSupervisorModal('fermo_linea')"
|
||||
class="btn text-xs py-1 px-2.5 gap-1 border-2 border-amber-500 text-amber-700 dark:text-amber-300 bg-amber-50 dark:bg-amber-900/20 hover:bg-amber-100 dark:hover:bg-amber-900/40">
|
||||
<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 9v6m4-6v6m7-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
{{ _('Fermo linea') }}
|
||||
</button>
|
||||
<button @click="openSupervisorModal('fine_produzione')"
|
||||
class="btn text-xs py-1 px-2.5 gap-1 border-2 border-red-500 text-red-700 dark:text-red-300 bg-red-50 dark:bg-red-900/20 hover:bg-red-100 dark:hover:bg-red-900/40">
|
||||
<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="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 10a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z"/>
|
||||
</svg>
|
||||
{{ _('Fine Produzione') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
{# Lot + Serial badges #}
|
||||
<div class="shrink-0 flex items-center gap-2">
|
||||
{% if lot_number %}
|
||||
@@ -124,9 +169,10 @@
|
||||
<div class="flex-1 flex overflow-hidden">
|
||||
|
||||
{# ──────────────────────────────────────────────
|
||||
LEFT SIDEBAR — Marker list (vertical)
|
||||
LEFT SIDEBAR — Marker list (vertical, hidden when no subtasks)
|
||||
────────────────────────────────────────────── #}
|
||||
<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">
|
||||
<div x-show="subtasks.length > 0"
|
||||
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"
|
||||
@@ -213,9 +259,10 @@
|
||||
</div>
|
||||
|
||||
{# ──────────────────────────────────────────────
|
||||
RIGHT PANEL — Info + tolerances + numpad
|
||||
RIGHT PANEL — Info + tolerances + numpad (hidden when no subtasks)
|
||||
────────────────────────────────────────────── #}
|
||||
<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">
|
||||
<div x-show="subtasks.length > 0"
|
||||
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">
|
||||
@@ -310,7 +357,7 @@
|
||||
</div>
|
||||
|
||||
{# ---- Measurement feedback ---- #}
|
||||
<div class="px-3 pt-2" x-data="{ get nominal() { return currentSubtask?.nominal || 0; },
|
||||
<div class="px-3 pt-2" x-show="currentSubtask" 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; },
|
||||
@@ -319,8 +366,8 @@
|
||||
{% include "components/measurement_feedback.html" %}
|
||||
</div>
|
||||
|
||||
{# ---- Numpad ---- #}
|
||||
<div class="px-3 py-2 relative flex-1">
|
||||
{# ---- Numpad (only when a subtask is active) ---- #}
|
||||
<div class="px-3 py-2 relative flex-1" x-show="currentSubtask">
|
||||
{# Saving overlay #}
|
||||
<div x-show="saving"
|
||||
x-transition
|
||||
@@ -338,10 +385,11 @@
|
||||
</div>
|
||||
|
||||
{# ---- Next measurement indicator ---- #}
|
||||
<div class="px-3 pb-2">
|
||||
<div class="px-3 pb-2" x-show="currentSubtask">
|
||||
{% include "components/next_measurement.html" %}
|
||||
</div>
|
||||
|
||||
|
||||
{# ---- Error message ---- #}
|
||||
<div x-show="errorMessage"
|
||||
x-transition
|
||||
@@ -358,6 +406,62 @@
|
||||
|
||||
</div>
|
||||
|
||||
{# ================================================================
|
||||
MEASUREMENT TIMER BANNER
|
||||
================================================================ #}
|
||||
<div x-show="timerActive"
|
||||
x-transition
|
||||
x-cloak
|
||||
class="shrink-0 bg-amber-50 dark:bg-amber-900/20 border-t border-amber-300 dark:border-amber-700 px-4 py-2">
|
||||
<div class="flex items-center justify-center gap-3">
|
||||
<svg class="w-5 h-5 text-amber-600 animate-pulse" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
<span class="text-sm font-medium text-amber-800 dark:text-amber-200">
|
||||
{{ _('Prossima misurazione tra') }}
|
||||
</span>
|
||||
<span class="text-lg font-bold font-mono text-amber-900 dark:text-amber-100 bg-amber-100 dark:bg-amber-900/40 px-2 py-0.5 rounded"
|
||||
x-text="timerDisplay"></span>
|
||||
<span class="text-xs text-amber-600 dark:text-amber-400">
|
||||
({{ _('Ciclo') }} #<span x-text="cycleCount"></span>)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ================================================================
|
||||
AVVIO PRODUZIONE — visible after first cycle, before production started
|
||||
================================================================ #}
|
||||
<div x-show="cycleConfirmed && cycleCount === 1 && !productionStarted && subtasks.length > 0"
|
||||
x-transition
|
||||
x-cloak
|
||||
class="shrink-0 border-t border-[var(--border-color)] bg-[var(--bg-card)] px-4 py-3">
|
||||
<div class="flex items-center justify-center">
|
||||
<button @click="startProduction()"
|
||||
class="btn gap-2 px-6 py-2.5 text-sm font-bold
|
||||
bg-emerald-600 hover:bg-emerald-700 text-white shadow-lg hover:shadow-xl
|
||||
rounded-xl transition-all duration-200">
|
||||
<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="M5 3l14 9-14 9V3z"/>
|
||||
</svg>
|
||||
{{ _('Avvio Produzione') }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-center text-[var(--text-muted)] mt-1.5">
|
||||
{{ _('Invia segnale al gestionale per avviare il timer della linea') }}
|
||||
</p>
|
||||
</div>
|
||||
<div x-show="productionStarted && subtasks.length > 0"
|
||||
x-transition
|
||||
x-cloak
|
||||
class="shrink-0 border-t border-emerald-200 dark:border-emerald-800 bg-emerald-50 dark:bg-emerald-900/20 px-4 py-1.5">
|
||||
<div class="flex items-center justify-center gap-2 text-xs text-emerald-700 dark:text-emerald-300">
|
||||
<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="M5 13l4 4L19 7"/>
|
||||
</svg>
|
||||
<span class="font-medium">{{ _('Produzione avviata') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ================================================================
|
||||
FOOTER — Progress bar + navigation
|
||||
================================================================ #}
|
||||
@@ -388,19 +492,47 @@
|
||||
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>
|
||||
{# Right: Fine ciclo misura (measurement tasks) / Completato (non-measurement or after cycle) #}
|
||||
<template x-if="subtasks.length > 0">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<button x-show="isComplete && !cycleConfirmed"
|
||||
x-transition
|
||||
@click="confirmCycle()"
|
||||
class="btn text-xs shrink-0 gap-1 py-1.5 px-3 shadow-md
|
||||
bg-primary text-white hover:bg-primary-700">
|
||||
<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>
|
||||
{{ _('Fine ciclo misura') }}
|
||||
</button>
|
||||
<button x-show="cycleConfirmed"
|
||||
x-transition
|
||||
@click="goToNextTask()"
|
||||
class="btn text-xs shrink-0 gap-1 py-1.5 px-3 shadow-md
|
||||
bg-measure-pass text-white hover:opacity-90">
|
||||
<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="M5 13l4 4L19 7"/>
|
||||
</svg>
|
||||
{{ _('Completato') }}
|
||||
<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>
|
||||
</template>
|
||||
<template x-if="subtasks.length === 0">
|
||||
<button @click="goToNextTask()"
|
||||
class="btn text-xs shrink-0 gap-1 py-1.5 px-3 shadow-md
|
||||
bg-measure-pass text-white hover:opacity-90">
|
||||
<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="M5 13l4 4L19 7"/>
|
||||
</svg>
|
||||
{{ _('Completato') }}
|
||||
<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>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -447,13 +579,100 @@
|
||||
</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 class="flex gap-2">
|
||||
<button @click="goToSummary()"
|
||||
class="btn btn-secondary flex-1 justify-center gap-2">
|
||||
{{ _('Riepilogo') }}
|
||||
</button>
|
||||
|
||||
{# Production phase: confirm the cycle (starts the interval timer) #}
|
||||
<button x-show="productionStarted"
|
||||
@click="showCompletionOverlay = false; cycleConfirmed = true"
|
||||
class="btn btn-primary flex-1 justify-center gap-2">
|
||||
{{ _('Conferma ciclo') }}
|
||||
<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="M5 13l4 4L19 7"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{# START phase, more tasks to go: advance without blocking or exiting #}
|
||||
<button x-show="!productionStarted && !isLastTask"
|
||||
@click="goToNextTask()"
|
||||
class="btn btn-primary flex-1 justify-center gap-2">
|
||||
{{ _('Task successivo') }}
|
||||
<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="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{# START phase, last task: start production #}
|
||||
<button x-show="!productionStarted && isLastTask"
|
||||
@click="startProductionFromOverlay()"
|
||||
class="btn flex-1 justify-center gap-2 bg-emerald-600 hover:bg-emerald-700 text-white">
|
||||
{{ _('Avvio Produzione') }}
|
||||
<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="M5 3l14 9-14 9V3z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ================================================================
|
||||
SUPERVISOR LOGIN MODAL
|
||||
================================================================ #}
|
||||
<div x-show="showSupervisorModal"
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
x-cloak
|
||||
class="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 backdrop-blur-sm"
|
||||
@click.self="closeSupervisorModal()">
|
||||
<div class="bg-[var(--bg-card)] rounded-2xl shadow-2xl p-6 max-w-sm mx-4 w-full border border-[var(--border-color)]">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="w-10 h-10 rounded-full bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-amber-600" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" 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>
|
||||
<div>
|
||||
<h3 class="text-lg font-bold text-[var(--text-primary)]">{{ _('Autorizzazione capoturno') }}</h3>
|
||||
<p class="text-xs text-[var(--text-secondary)]" x-text="supervisorReason"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3 mb-5">
|
||||
<div>
|
||||
<label class="tmf-label text-xs">{{ _('Username') }}</label>
|
||||
<input type="text" x-model="supervisorUsername" class="tmf-input text-sm"
|
||||
placeholder="{{ _('Username capoturno') }}" @keydown.enter="validateSupervisor()">
|
||||
</div>
|
||||
<div>
|
||||
<label class="tmf-label text-xs">{{ _('Password') }}</label>
|
||||
<input type="password" x-model="supervisorPassword" class="tmf-input text-sm"
|
||||
placeholder="••••••••" @keydown.enter="validateSupervisor()">
|
||||
</div>
|
||||
<p x-show="supervisorError" class="text-xs text-red-600" x-text="supervisorError"></p>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button @click="closeSupervisorModal()"
|
||||
class="btn btn-secondary flex-1 text-sm">
|
||||
{{ _('Annulla') }}
|
||||
</button>
|
||||
<button @click="validateSupervisor()"
|
||||
:disabled="!supervisorUsername || !supervisorPassword || supervisorValidating"
|
||||
class="btn btn-primary flex-1 text-sm gap-1">
|
||||
<svg x-show="supervisorValidating" class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"/>
|
||||
</svg>
|
||||
{{ _('Autorizza') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -489,6 +708,24 @@ function taskExecute() {
|
||||
errorMessage: '',
|
||||
showCompletionOverlay: false,
|
||||
|
||||
// ---- Measurement timer ----
|
||||
measurementIntervalMinutes: {{ measurement_interval_minutes|tojson if measurement_interval_minutes else 'null' }},
|
||||
timerActive: false,
|
||||
timerRemaining: 0,
|
||||
_timerInterval: null,
|
||||
cycleCount: 0,
|
||||
productionStarted: false,
|
||||
|
||||
// ---- Cycle & workflow state ----
|
||||
cycleConfirmed: false,
|
||||
showSupervisorModal: false,
|
||||
supervisorAction: '', // 'out_of_tolerance', 'fermo_linea', 'fine_produzione'
|
||||
supervisorUsername: '',
|
||||
supervisorPassword: '',
|
||||
supervisorError: '',
|
||||
supervisorValidating: false,
|
||||
pendingAdvance: false,
|
||||
|
||||
// ---- Value from numpad / caliper ----
|
||||
currentValue: null,
|
||||
|
||||
@@ -641,15 +878,24 @@ function taskExecute() {
|
||||
// Pause to show result feedback
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
|
||||
// Out-of-tolerance: block advancement, require supervisor
|
||||
if (pf === 'fail') {
|
||||
this.pendingAdvance = true;
|
||||
this.openSupervisorModal('out_of_tolerance');
|
||||
return;
|
||||
}
|
||||
|
||||
// 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;
|
||||
// START run (production not yet started): don't block with the
|
||||
// "Misurazioni Complete" overlay — just advance to the next task.
|
||||
// The overlay is shown only on the last task (to offer "Avvio
|
||||
// Produzione") or once production is running ("Conferma ciclo").
|
||||
if (!this.productionStarted && !this.isLastTask) {
|
||||
this.goToNextTask();
|
||||
return;
|
||||
}
|
||||
this.showCompletionOverlay = true;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -696,6 +942,177 @@ function taskExecute() {
|
||||
}
|
||||
},
|
||||
|
||||
// ---- Confirm measurement cycle (Fine ciclo misura) ----
|
||||
confirmCycle() {
|
||||
this.cycleConfirmed = true;
|
||||
this.showCompletionOverlay = false;
|
||||
this.cycleCount++;
|
||||
|
||||
// Start measurement timer if recipe has an interval
|
||||
if (this.measurementIntervalMinutes && this.measurementIntervalMinutes > 0) {
|
||||
this.startMeasurementTimer();
|
||||
}
|
||||
},
|
||||
|
||||
// ---- Measurement timer ----
|
||||
startMeasurementTimer() {
|
||||
this.stopMeasurementTimer();
|
||||
this.timerRemaining = this.measurementIntervalMinutes * 60;
|
||||
this.timerActive = true;
|
||||
var self = this;
|
||||
this._timerInterval = setInterval(function () {
|
||||
self.timerRemaining--;
|
||||
if (self.timerRemaining <= 0) {
|
||||
self.onTimerExpired();
|
||||
}
|
||||
}, 1000);
|
||||
},
|
||||
|
||||
stopMeasurementTimer() {
|
||||
if (this._timerInterval) {
|
||||
clearInterval(this._timerInterval);
|
||||
this._timerInterval = null;
|
||||
}
|
||||
this.timerActive = false;
|
||||
},
|
||||
|
||||
onTimerExpired() {
|
||||
this.stopMeasurementTimer();
|
||||
this.playBuzzer();
|
||||
// Reset for new measurement cycle
|
||||
this.cycleConfirmed = false;
|
||||
this.measurements = [];
|
||||
this.currentIndex = 0;
|
||||
this.currentValue = null;
|
||||
},
|
||||
|
||||
playBuzzer() {
|
||||
try {
|
||||
var ctx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
// 3 short beeps
|
||||
[0, 0.25, 0.5].forEach(function (delay) {
|
||||
var osc = ctx.createOscillator();
|
||||
var gain = ctx.createGain();
|
||||
osc.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
osc.frequency.value = 880;
|
||||
osc.type = 'square';
|
||||
gain.gain.value = 0.3;
|
||||
osc.start(ctx.currentTime + delay);
|
||||
osc.stop(ctx.currentTime + delay + 0.15);
|
||||
});
|
||||
} catch (_) {}
|
||||
},
|
||||
|
||||
// ---- Avvio Produzione (GAIA placeholder) ----
|
||||
startProduction() {
|
||||
this.productionStarted = true;
|
||||
// TODO: integrazione GAIA — inviare segnale per avviare timer linea
|
||||
// await fetch('/measure/api/gaia/start-production', { method: 'POST', ... });
|
||||
},
|
||||
|
||||
// Is the current task the last one in the recipe sequence?
|
||||
get isLastTask() {
|
||||
const taskIds = window.__allTaskIds || [];
|
||||
const idx = taskIds.indexOf(this.task.id);
|
||||
return idx === -1 || idx >= taskIds.length - 1;
|
||||
},
|
||||
|
||||
// Start production from the completion overlay (last START task) and begin
|
||||
// the measurement-interval cycle if the recipe defines one.
|
||||
startProductionFromOverlay() {
|
||||
this.showCompletionOverlay = false;
|
||||
this.startProduction();
|
||||
this.cycleConfirmed = true;
|
||||
this.cycleCount++;
|
||||
if (this.measurementIntervalMinutes && this.measurementIntervalMinutes > 0) {
|
||||
this.startMeasurementTimer();
|
||||
}
|
||||
},
|
||||
|
||||
get timerDisplay() {
|
||||
var m = Math.floor(this.timerRemaining / 60);
|
||||
var s = this.timerRemaining % 60;
|
||||
return (m < 10 ? '0' : '') + m + ':' + (s < 10 ? '0' : '') + s;
|
||||
},
|
||||
|
||||
// ---- Navigate to next task (Completato) ----
|
||||
goToNextTask() {
|
||||
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.goToSummary();
|
||||
}
|
||||
},
|
||||
|
||||
// ---- Open/close supervisor modal (always reset fields) ----
|
||||
openSupervisorModal(action) {
|
||||
this.supervisorAction = action;
|
||||
this.supervisorUsername = '';
|
||||
this.supervisorPassword = '';
|
||||
this.supervisorError = '';
|
||||
this.showSupervisorModal = true;
|
||||
},
|
||||
|
||||
closeSupervisorModal() {
|
||||
this.showSupervisorModal = false;
|
||||
this.supervisorUsername = '';
|
||||
this.supervisorPassword = '';
|
||||
this.supervisorError = '';
|
||||
},
|
||||
|
||||
// ---- Supervisor reason text ----
|
||||
get supervisorReason() {
|
||||
if (this.supervisorAction === 'out_of_tolerance') return '{{ _("Misurazione fuori tolleranza") }}';
|
||||
if (this.supervisorAction === 'fermo_linea') return '{{ _("Fermo linea richiesto") }}';
|
||||
if (this.supervisorAction === 'fine_produzione') return '{{ _("Fine produzione richiesta") }}';
|
||||
return '';
|
||||
},
|
||||
|
||||
// ---- Validate supervisor credentials ----
|
||||
async validateSupervisor() {
|
||||
this.supervisorError = '';
|
||||
this.supervisorValidating = true;
|
||||
|
||||
try {
|
||||
const csrfToken = document.querySelector('meta[name=csrf-token]')?.content || '';
|
||||
const resp = await fetch('{{ url_for("measure.validate_supervisor") }}', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken },
|
||||
body: JSON.stringify({ username: this.supervisorUsername, password: this.supervisorPassword })
|
||||
});
|
||||
const data = await resp.json();
|
||||
|
||||
if (!resp.ok || data.error) {
|
||||
this.supervisorError = data.detail || '{{ _("Credenziali non valide o utente non autorizzato") }}';
|
||||
this.supervisorValidating = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Authorized — close modal and proceed
|
||||
this.showSupervisorModal = false;
|
||||
this.supervisorUsername = '';
|
||||
this.supervisorPassword = '';
|
||||
this.supervisorValidating = false;
|
||||
|
||||
if (this.supervisorAction === 'out_of_tolerance') {
|
||||
this.pendingAdvance = false;
|
||||
if (this.completedCount >= this.totalSubtasks) {
|
||||
this.showCompletionOverlay = true;
|
||||
} else {
|
||||
this.advanceToNext();
|
||||
}
|
||||
}
|
||||
// fermo_linea and fine_produzione are handled by GAIA integration (future)
|
||||
|
||||
} catch (err) {
|
||||
this.supervisorError = '{{ _("Errore di connessione") }}';
|
||||
this.supervisorValidating = false;
|
||||
}
|
||||
},
|
||||
|
||||
// ---- Go to summary ----
|
||||
goToSummary() {
|
||||
const recipeId = this.task.recipe_id || 0;
|
||||
|
||||
@@ -27,6 +27,17 @@
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<!-- Top action: change recipe (easy to find) -->
|
||||
<div class="mb-4">
|
||||
<a href="{{ url_for('measure.select_recipe') }}"
|
||||
class="btn btn-secondary gap-2">
|
||||
<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="M10 19l-7-7m0 0l7-7m-7 7h18"/>
|
||||
</svg>
|
||||
{{ _('Seleziona altra ricetta') }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Recipe Info Card -->
|
||||
<div class="tmf-card mb-8">
|
||||
<div class="p-5 sm:p-6">
|
||||
@@ -60,8 +71,19 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Right: Traceability Badges -->
|
||||
<div class="flex flex-col gap-2 sm:items-end shrink-0">
|
||||
<!-- Right: AVVIA + Traceability -->
|
||||
<div class="flex flex-col gap-3 sm:items-end shrink-0">
|
||||
{% if tasks %}
|
||||
<a href="{{ url_for('measure.task_execute', task_id=tasks[0].id) }}"
|
||||
class="btn gap-3 w-full sm:w-auto justify-center text-lg font-bold
|
||||
bg-red-600 hover:bg-red-700 text-white shadow-lg hover:shadow-xl
|
||||
px-8 py-3 rounded-xl transition-all duration-200">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 3l14 9-14 9V3z"/>
|
||||
</svg>
|
||||
{{ _('AVVIA') }}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if lot_number %}
|
||||
<div class="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm
|
||||
bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800
|
||||
@@ -100,83 +122,77 @@
|
||||
</h2>
|
||||
<span class="badge badge-neutral">
|
||||
{{ tasks|length }} task
|
||||
{%- set total_subs = namespace(count=0) -%}
|
||||
{%- for t in tasks -%}
|
||||
{%- if t.subtask_count is defined -%}
|
||||
{%- set total_subs.count = total_subs.count + t.subtask_count -%}
|
||||
{%- elif t.subtasks -%}
|
||||
{%- set total_subs.count = total_subs.count + t.subtasks|length -%}
|
||||
{%- endif -%}
|
||||
{%- endfor -%}
|
||||
{%- if total_subs.count > 0 %} · {{ total_subs.count }} {{ _('misurazioni totali') }}{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Task Cards -->
|
||||
{% if tasks %}
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2.5">
|
||||
{% for task in tasks %}
|
||||
<div class="tmf-card hover:border-primary/30 transition-all duration-200 group">
|
||||
<div class="p-5 sm:p-6">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center gap-4">
|
||||
<div class="p-3 sm:p-4">
|
||||
<div class="flex flex-row items-center gap-3">
|
||||
|
||||
<!-- Task Number Circle -->
|
||||
<div class="flex items-center justify-center w-12 h-12 rounded-full shrink-0
|
||||
<div class="flex items-center justify-center w-9 h-9 rounded-full shrink-0
|
||||
bg-primary-50 dark:bg-primary-900/30 border-2 border-primary-200 dark:border-primary-700
|
||||
text-primary-700 dark:text-primary-300 font-bold text-lg">
|
||||
text-primary-700 dark:text-primary-300 font-bold text-base">
|
||||
{{ loop.index }}
|
||||
</div>
|
||||
|
||||
<!-- Task Info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<!-- Task Header -->
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="text-xs font-medium text-[var(--text-muted)] uppercase tracking-wider">
|
||||
Task {{ loop.index }} {{ _('di') }} {{ tasks|length }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Task Title -->
|
||||
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-1">
|
||||
<h3 class="text-base font-semibold text-[var(--text-primary)] leading-tight truncate">
|
||||
{{ task.title or task.name or (_('Task') ~ ' ' ~ loop.index) }}
|
||||
</h3>
|
||||
|
||||
<!-- Directive -->
|
||||
{% if task.directive or task.description %}
|
||||
<p class="text-sm text-[var(--text-secondary)] leading-relaxed mb-3 line-clamp-2">
|
||||
<p class="text-xs text-[var(--text-secondary)] leading-snug mt-0.5 line-clamp-2">
|
||||
{{ task.directive or task.description }}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<!-- Meta Row -->
|
||||
<div class="flex items-center gap-4 flex-wrap">
|
||||
<!-- Subtask Count -->
|
||||
{% if task.subtask_count is defined or task.subtasks %}
|
||||
<span class="inline-flex items-center gap-1.5 text-xs text-[var(--text-secondary)]">
|
||||
<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="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
<span class="font-medium">
|
||||
{% if task.subtask_count is defined %}
|
||||
{{ task.subtask_count }}
|
||||
{% elif task.subtasks %}
|
||||
{{ task.subtasks|length }}
|
||||
{% endif %}
|
||||
{{ _('misurazioni') }}
|
||||
</span>
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
<!-- File Attachment -->
|
||||
{% if task.file_path %}
|
||||
<span class="inline-flex items-center gap-1.5 text-xs text-[var(--text-secondary)]">
|
||||
{% if task.file_path.endswith('.pdf') %}
|
||||
<svg class="w-4 h-4 text-red-500" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
{% else %}
|
||||
<svg class="w-4 h-4 text-green-500" fill="none" stroke="currentColor" stroke-width="1.75" 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>
|
||||
{% endif %}
|
||||
<span class="font-medium">{{ _('Allegato') }}</span>
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Button -->
|
||||
<!-- Indicators (top-right): measurements + attachment -->
|
||||
<div class="shrink-0 self-start flex items-center gap-2.5">
|
||||
{% if task.subtask_count is defined or task.subtasks %}
|
||||
<span class="inline-flex items-center gap-1 text-xs text-[var(--text-secondary)]"
|
||||
title="{{ _('misurazioni') }}">
|
||||
<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="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
<span class="font-medium font-mono">{% if task.subtask_count is defined %}{{ task.subtask_count }}{% elif task.subtasks %}{{ task.subtasks|length }}{% endif %}</span>
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
{% if task.file_path %}
|
||||
<span class="inline-flex items-center text-xs" title="{{ _('Allegato') }}">
|
||||
{% if task.file_path.endswith('.pdf') %}
|
||||
<svg class="w-4 h-4 text-red-500" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
{% else %}
|
||||
<svg class="w-4 h-4 text-green-500" fill="none" stroke="currentColor" stroke-width="1.75" 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>
|
||||
{% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Action Button (Maker only — operators start the guided flow with AVVIA) -->
|
||||
{% if current_user and 'Maker' in current_user.get('roles', []) %}
|
||||
<div class="shrink-0 sm:ml-4">
|
||||
<a href="{{ url_for('measure.task_execute', task_id=task.id) }}"
|
||||
class="btn btn-primary gap-2 w-full sm:w-auto justify-center
|
||||
@@ -185,12 +201,13 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
{{ _('Inizia Misure') }}
|
||||
{{ _('Visualizza Task') }}
|
||||
<svg class="w-4 h-4 transition-transform group-hover:translate-x-0.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -215,33 +232,5 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Bottom Navigation -->
|
||||
<div class="mt-8 flex items-center justify-between">
|
||||
<a href="{{ url_for('measure.select_recipe') }}"
|
||||
class="btn btn-secondary gap-2">
|
||||
<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="M10 19l-7-7m0 0l7-7m-7 7h18"/>
|
||||
</svg>
|
||||
{{ _('Seleziona altra ricetta') }}
|
||||
</a>
|
||||
|
||||
{% if tasks %}
|
||||
<div class="text-sm text-[var(--text-secondary)]">
|
||||
{{ tasks|length }} task ·
|
||||
{% set total_subs = namespace(count=0) %}
|
||||
{% for t in tasks %}
|
||||
{% if t.subtask_count is defined %}
|
||||
{% set total_subs.count = total_subs.count + t.subtask_count %}
|
||||
{% elif t.subtasks %}
|
||||
{% set total_subs.count = total_subs.count + t.subtasks|length %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if total_subs.count > 0 %}
|
||||
{{ total_subs.count }} {{ _('misurazioni totali') }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -61,7 +61,7 @@ class TestSaveMeasurement:
|
||||
mock_api_client.post.return_value = {
|
||||
"id": 1,
|
||||
"subtask_id": 10,
|
||||
"task_id": 5,
|
||||
"version_id": 3,
|
||||
"value": 9.95,
|
||||
"pass_fail": "pass",
|
||||
}
|
||||
@@ -70,10 +70,8 @@ class TestSaveMeasurement:
|
||||
"/measure/save-measurement",
|
||||
json={
|
||||
"subtask_id": 10,
|
||||
"task_id": 5,
|
||||
"version_id": 3,
|
||||
"value": 9.95,
|
||||
"pass_fail": "pass",
|
||||
"deviation": 0.05,
|
||||
},
|
||||
content_type="application/json",
|
||||
)
|
||||
@@ -86,7 +84,7 @@ class TestSaveMeasurement:
|
||||
"""Missing required fields return 400."""
|
||||
resp = logged_in_client.post(
|
||||
"/measure/save-measurement",
|
||||
json={"subtask_id": 10}, # missing task_id and value
|
||||
json={"subtask_id": 10}, # missing version_id and value
|
||||
content_type="application/json",
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
"""Verify that inline <script> blocks in admin templates parse as valid JS.
|
||||
|
||||
We had a regression where an Italian translation containing an apostrophe
|
||||
(e.g. "Errore nell'eliminazione") broke a single-quoted JS string literal,
|
||||
killing every Alpine binding on the page (the "Nuova Stazione" button no
|
||||
longer triggered openCreateModal).
|
||||
|
||||
Strategy: render each admin page through the Flask test client, extract every
|
||||
inline <script> body and feed it to `node --check` for a syntax verdict. We
|
||||
force the IT locale so the worst-case translations (which contain apostrophes)
|
||||
are the ones evaluated.
|
||||
|
||||
The test is skipped if `node` is not on PATH; CI images that exclude Node
|
||||
get a soft skip rather than a false failure.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# Match every inline <script>…</script> (i.e. without `src=`) so we don't try
|
||||
# to syntax-check Alpine.js / Plotly bundles served from a CDN.
|
||||
_INLINE_SCRIPT_RX = re.compile(
|
||||
r"<script(?![^>]*\bsrc=)[^>]*>(.*?)</script>",
|
||||
re.DOTALL | re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Match every Alpine.js / event-binding attribute on a tag — the bug class we
|
||||
# care about (apostrophe in IT translation closing a JS literal) hides equally
|
||||
# well inside x-text="…" as inside <script>…</script>.
|
||||
#
|
||||
# Covers: x-data, x-init, x-text, x-html, x-show, x-if, x-for, x-bind:foo,
|
||||
# x-on:foo, x-model, x-effect, x-transition:*, plus the @evt and :attr
|
||||
# shortcuts.
|
||||
_ALPINE_ATTR_RX = re.compile(
|
||||
r"""(?P<name>(?:x-[a-zA-Z][a-zA-Z0-9:-]*|@[a-zA-Z][a-zA-Z0-9.:-]*|:[a-zA-Z][a-zA-Z0-9.:-]*))="(?P<value>[^"]*)\"""",
|
||||
re.DOTALL,
|
||||
)
|
||||
|
||||
# Skip these — they're plain identifiers / object shapes, not JS expressions
|
||||
# that node --check should reject for things like a stray apostrophe.
|
||||
_ALPINE_NON_EXPR_NAMES = {
|
||||
# transition modifiers like x-transition:enter-start get bare class names
|
||||
"x-transition:enter",
|
||||
"x-transition:enter-start",
|
||||
"x-transition:enter-end",
|
||||
"x-transition:leave",
|
||||
"x-transition:leave-start",
|
||||
"x-transition:leave-end",
|
||||
# x-cloak has no value, x-data is parsed below as an expression but Alpine
|
||||
# also accepts an object literal.
|
||||
}
|
||||
|
||||
|
||||
def _alpine_attribute_expressions(html: str):
|
||||
"""Yield (attr_name, value) tuples for every Alpine expression attribute."""
|
||||
for m in _ALPINE_ATTR_RX.finditer(html):
|
||||
name = m.group("name")
|
||||
if name in _ALPINE_NON_EXPR_NAMES:
|
||||
continue
|
||||
value = m.group("value")
|
||||
if not value.strip():
|
||||
continue
|
||||
# HTML entities used to embed quotes inside the attribute value
|
||||
# (most common: " for ").
|
||||
value = (
|
||||
value.replace(""", '"')
|
||||
.replace(""", '"')
|
||||
.replace("'", "'")
|
||||
.replace("&", "&")
|
||||
)
|
||||
yield name, value
|
||||
|
||||
|
||||
def _node_check(source: str, label: str) -> None:
|
||||
"""Fail the test if `node --check` rejects `source`."""
|
||||
if not source.strip():
|
||||
return
|
||||
node = shutil.which("node")
|
||||
if node is None:
|
||||
pytest.skip("node binary not found on PATH; cannot validate JS syntax")
|
||||
|
||||
fd, path = tempfile.mkstemp(suffix=".js")
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
f.write(source)
|
||||
result = subprocess.run(
|
||||
[node, "--check", path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
if result.returncode != 0:
|
||||
snippet = source.strip()
|
||||
if len(snippet) > 600:
|
||||
snippet = snippet[:600] + "\n…(truncated)…"
|
||||
pytest.fail(
|
||||
f"{label}: node rejected this code as invalid JS.\n"
|
||||
f"--- node stderr ---\n{result.stderr.strip()}\n"
|
||||
f"--- source (first 600 chars) ---\n{snippet}"
|
||||
)
|
||||
|
||||
|
||||
def _check_alpine_attributes(html: str, page_label: str) -> None:
|
||||
"""Validate every Alpine expression attribute on the page.
|
||||
|
||||
Wraps each value in `void (…)` so node parses it as an expression rather
|
||||
than a statement. Function-body forms like `async () => { … }` parse fine
|
||||
inside that wrapper too.
|
||||
"""
|
||||
for name, value in _alpine_attribute_expressions(html):
|
||||
# Some Alpine attrs accept a function call shorthand (e.g.
|
||||
# x-data="myComponent(window.__x)"); those parse fine as expressions.
|
||||
wrapper = f"void ({value});\n"
|
||||
_node_check(
|
||||
wrapper,
|
||||
f"{page_label} attribute {name}=\"…\" did not parse as JS",
|
||||
)
|
||||
|
||||
|
||||
def _force_italian(client) -> None:
|
||||
"""Make the next request render with the IT locale (worst case for JS)."""
|
||||
with client.session_transaction() as sess:
|
||||
sess["language"] = "it"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_admin_api():
|
||||
"""Patch api_client used inside the admin blueprint."""
|
||||
mock = MagicMock()
|
||||
with patch("blueprints.admin.api_client", mock):
|
||||
yield mock
|
||||
|
||||
|
||||
def test_admin_stations_inline_js_is_valid(logged_in_client, mock_admin_api):
|
||||
"""The /admin/stations page must emit syntactically valid inline JS in IT.
|
||||
|
||||
Regression guard for the apostrophe-in-single-quote bug.
|
||||
"""
|
||||
mock_admin_api.get.side_effect = [
|
||||
# GET /api/stations
|
||||
[{
|
||||
"id": 1, "code": "ST-DEFAULT", "name": "Default Station",
|
||||
"location": None, "notes": None, "active": True,
|
||||
"created_by": 1, "created_at": "2026-04-25T10:00:00",
|
||||
}],
|
||||
# GET /api/recipes
|
||||
[{"id": 1, "code": "DEMO-001", "name": "Demo Recipe", "active": True}],
|
||||
]
|
||||
|
||||
_force_italian(logged_in_client)
|
||||
resp = logged_in_client.get("/admin/stations")
|
||||
assert resp.status_code == 200, resp.data[:300]
|
||||
|
||||
html = resp.data.decode("utf-8")
|
||||
scripts = _INLINE_SCRIPT_RX.findall(html)
|
||||
assert scripts, "expected at least one inline <script> on /admin/stations"
|
||||
|
||||
for i, body in enumerate(scripts):
|
||||
_node_check(body, f"/admin/stations script[{i}]")
|
||||
|
||||
_check_alpine_attributes(html, "/admin/stations")
|
||||
|
||||
|
||||
def test_admin_users_inline_js_is_valid(logged_in_client, mock_admin_api):
|
||||
"""Same guard for /admin/users (reuses the userManagement Alpine pattern)."""
|
||||
mock_admin_api.get.return_value = [{
|
||||
"id": 1, "username": "admin", "display_name": "Admin",
|
||||
"email": None, "roles": ["Maker"], "is_admin": True,
|
||||
"language_pref": "it", "theme_pref": "light", "active": True,
|
||||
}]
|
||||
|
||||
_force_italian(logged_in_client)
|
||||
resp = logged_in_client.get("/admin/users")
|
||||
assert resp.status_code == 200
|
||||
|
||||
html = resp.data.decode("utf-8")
|
||||
scripts = _INLINE_SCRIPT_RX.findall(html)
|
||||
assert scripts
|
||||
|
||||
for i, body in enumerate(scripts):
|
||||
_node_check(body, f"/admin/users script[{i}]")
|
||||
|
||||
_check_alpine_attributes(html, "/admin/users")
|
||||
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())
|
||||
@@ -554,6 +554,65 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cryptography"
|
||||
version = "48.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9f/a9/db8f313fdcd85d767d4973515e1db101f9c71f95fced83233de224673757/cryptography-48.0.0.tar.gz", hash = "sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920", size = 832984, upload-time = "2026-05-04T22:59:38.133Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/df/3d/01f6dd9190170a5a241e0e98c2d04be3664a9e6f5b9b872cde63aff1c3dd/cryptography-48.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6", size = 8001587, upload-time = "2026-05-04T22:57:36.803Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/6e/e90527eef33f309beb811cf7c982c3aeffcce8e3edb178baa4ca3ae4a6fa/cryptography-48.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c", size = 4690433, upload-time = "2026-05-04T22:57:40.373Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/04/673510ed51ddff56575f306cf1617d80411ee76831ccd3097599140efdfe/cryptography-48.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3", size = 4710620, upload-time = "2026-05-04T22:57:42.935Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/d5/e9c4ef932c8d800490c34d8bd589d64a31d5890e27ec9e9ad532be893294/cryptography-48.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5", size = 4696283, upload-time = "2026-05-04T22:57:45.294Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/29/174b9dfb60b12d59ecfc6cfa04bc88c21b42a54f01b8aae09bb6e51e4c7f/cryptography-48.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c", size = 5296573, upload-time = "2026-05-04T22:57:47.933Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/38/0d29a6fd7d0d1373f0c0c88a04ba20e359b257753ac497564cd660fc1d55/cryptography-48.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f", size = 4743677, upload-time = "2026-05-04T22:57:50.067Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/be/eef653013d5c63b6a490529e0316f9ac14a37602965d4903efed1399f32b/cryptography-48.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25", size = 4330808, upload-time = "2026-05-04T22:57:52.301Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/9e/500463e87abb7a0a0f9f256ec21123ecde0a7b5541a15e840ea54551fd81/cryptography-48.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602", size = 4695941, upload-time = "2026-05-04T22:57:54.603Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/dc/7303087450c2ec9e7fbb750e17c2abfbc658f23cbd0e54009509b7cc4091/cryptography-48.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c", size = 5252579, upload-time = "2026-05-04T22:57:57.207Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/c0/7101d3b7215edcdc90c45da544961fd8ed2d6448f77577460fa75a8443f7/cryptography-48.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5", size = 4743326, upload-time = "2026-05-04T22:57:59.535Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/d8/5b833bad13016f562ab9d063d68199a4bd121d18458e439515601d3357ec/cryptography-48.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321", size = 4826672, upload-time = "2026-05-04T22:58:01.996Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/e1/7074eb8bf3c135558c73fc2bcf0f5633f912e6fb87e868a55c454080ef09/cryptography-48.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74", size = 4972574, upload-time = "2026-05-04T22:58:03.968Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/70/e5a1b41d325f797f39427aa44ef8baf0be500065ab6d8e10369d850d4a4f/cryptography-48.0.0-cp311-abi3-win32.whl", hash = "sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4", size = 3294868, upload-time = "2026-05-04T22:58:06.467Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/ac/8ac51b4a5fc5932eb7ee5c517ba7dc8cd834f0048962b6b352f00f41ebf9/cryptography-48.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7", size = 3817107, upload-time = "2026-05-04T22:58:08.845Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/84/70e3feea9feea87fd7cbe77efb2712ae1e3e6edf10749dc6e95f4e60e455/cryptography-48.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:3cb07a3ed6431663cd321ea8a000a1314c74211f823e4177fefa2255e057d1ec", size = 7986556, upload-time = "2026-05-04T22:58:11.172Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/6e/18e07a618bb5442ba10cf4df16e99c071365528aa570dfcb8c02e25a303b/cryptography-48.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c7378637d7d88016fa6791c159f698b3d3eed28ebf844ac36b9dc04a14dae18", size = 4684776, upload-time = "2026-05-04T22:58:13.712Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/6a/4ea3b4c6c6759794d5ee2103c304a5076dc4b19ae1f9fe47dba439e159e9/cryptography-48.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc90c0b39b2e3c65ef52c804b72e3c58f8a04ab2a1871272798e5f9572c17d20", size = 4698121, upload-time = "2026-05-04T22:58:16.448Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/59/6ff6ad6cae03bb887da2a5860b2c9805f8dac969ef01ce563336c49bd1d1/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:76341972e1eff8b4bea859f09c0d3e64b96ce931b084f9b9b7db8ef364c30eff", size = 4690042, upload-time = "2026-05-04T22:58:18.544Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/b4/fc334ed8cfd705aca282fe4d8f5ae64a8e0f74932e9feecb344610cf6e4d/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:55b7718303bf06a5753dcdccf2f3945cf18ad7bffde41b61226e4db31ab89a9c", size = 5282526, upload-time = "2026-05-04T22:58:20.75Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/08/9f8c5386cc4cd90d8255c7cdd0f5baf459a08502a09de30dc51f553d38dc/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:a64697c641c7b1b2178e573cbc31c7c6684cd56883a478d75143dbb7118036db", size = 4733116, upload-time = "2026-05-04T22:58:23.627Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/77/99307d7574045699f8805aa500fa0fb83422d115b5400a064ddd306d7750/cryptography-48.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:561215ea3879cb1cbbf272867e2efda62476f240fb58c64de6b393ae19246741", size = 4316030, upload-time = "2026-05-04T22:58:25.581Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/36/a608b98337af3cb2aff4818e406649d30572b7031918b04c87d979495348/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ad64688338ed4bc1a6618076ba75fd7194a5f1797ac60b47afe926285adb3166", size = 4689640, upload-time = "2026-05-04T22:58:27.747Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/a6/825010a291b4438aecc1f568bc428189fc1175515223632477c07dc0a6df/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:906cbf0670286c6e0044156bc7d4af9cbb0ef6db9f73e52c3ec56ba6bdde5336", size = 5237657, upload-time = "2026-05-04T22:58:29.848Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/09/4e76a09b4caa29aad535ddc806f5d4c5d01885bd978bd984fbc6ca032cae/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:ea8990436d914540a40ab24b6a77c0969695ed52f4a4874c5137ccf7045a7057", size = 4732362, upload-time = "2026-05-04T22:58:32.009Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/78/444fa04a77d0cb95f417dda20d450e13c56ba8e5220fc892a1658f44f882/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c18684a7f0cc9a3cb60328f496b8e3372def7c5d2df39ac267878b05565aaaae", size = 4819580, upload-time = "2026-05-04T22:58:34.254Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/85/ea67067c70a1fd4be2c63d35eeed82658023021affccc7b17705f8527dd2/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9be5aafa5736574f8f15f262adc81b2a9869e2cfe9014d52a44633905b40d52c", size = 4963283, upload-time = "2026-05-04T22:58:36.376Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/54/cc6d0f3deac3e81c7f847e8a189a12b6cdd65059b43dad25d4316abd849a/cryptography-48.0.0-cp314-cp314t-win32.whl", hash = "sha256:c17dfe85494deaeddc5ce251aebd1d60bbe6afc8b62071bb0b469431a000124f", size = 3270954, upload-time = "2026-05-04T22:58:38.791Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/67/cc947e288c0758a4e5473d1dcb743037ab7785541265a969240b8885441a/cryptography-48.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27241b1dc9962e056062a8eef1991d02c3a24569c95975bd2322a8a52c6e5e12", size = 3797313, upload-time = "2026-05-04T22:58:40.746Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/63/61d4a4e1c6b6bab6ce1e213cd36a24c415d90e76d78c5eb8577c5541d2e8/cryptography-48.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86", size = 7983482, upload-time = "2026-05-04T22:58:43.769Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/ac/f5b5995b87770c693e2596559ffafe195b4033a57f14a82268a2842953f3/cryptography-48.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e", size = 4683266, upload-time = "2026-05-04T22:58:46.064Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/c6/8b14f67e18338fbc4adb76f66c001f5c3610b3e2d1837f268f47a347dbbb/cryptography-48.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f", size = 4696228, upload-time = "2026-05-04T22:58:48.22Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/73/f808fbae9514bd91b47875b003f13e284c8c6bdfd904b7944e803937eec1/cryptography-48.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7", size = 4689097, upload-time = "2026-05-04T22:58:50.9Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/01/d86632d7d28db8ae83221995752eeb6639ffb374c2d22955648cf8d52797/cryptography-48.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832", size = 5283582, upload-time = "2026-05-04T22:58:53.017Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/e1/50edc7a50334807cc4791fc4a0ce7468b4a1416d9138eab358bfc9a3d70b/cryptography-48.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c", size = 4730479, upload-time = "2026-05-04T22:58:55.611Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/af/99a582b1b1641ff5911ac559beb45097cf79efd4ead4657f578ef1af2d47/cryptography-48.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a", size = 4326481, upload-time = "2026-05-04T22:58:57.607Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/ee/89aa26a06ef0a7d7611788ffd571a7c50e368cc6a4d5eef8b4884e866edb/cryptography-48.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a", size = 4688713, upload-time = "2026-05-04T22:59:00.077Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/ba/bcb1b0bb7a33d4c7c0c4d4c7874b4a62ae4f56113a5f4baefa362dfb1f0f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a", size = 5238165, upload-time = "2026-05-04T22:59:02.317Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/70/ca4003b1ce5ca3dc3186ada51908c8a9b9ff7d5cab83cc0d43ee14ec144f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239", size = 4729947, upload-time = "2026-05-04T22:59:05.255Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/a0/4ec7cf774207905aef1a8d11c3750d5a1db805eb380ee4e16df317870128/cryptography-48.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c", size = 4822059, upload-time = "2026-05-04T22:59:07.802Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/75/a2e55f99c16fcac7b5d6c1eb19ad8e00799854d6be5ca845f9259eae1681/cryptography-48.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4", size = 4960575, upload-time = "2026-05-04T22:59:09.851Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/23/6e6f32143ab5d8b36ca848a502c4bcd477ae75b9e1677e3530d669062578/cryptography-48.0.0-cp39-abi3-win32.whl", hash = "sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd", size = 3279117, upload-time = "2026-05-04T22:59:12.019Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/9a/0fea98a70cf1749d41d738836f6349d97945f7c89433a259a6c2642eefeb/cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8", size = 3792100, upload-time = "2026-05-04T22:59:14.884Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/d2/024b5e06be9d44cb021fb0e1a03d34d63989cf56a0fe62f3dfbab695b9b4/cryptography-48.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:84cf79f0dc8b36ac5da873481716e87aef31fcfa0444f9e1d8b4b2cece142855", size = 3950391, upload-time = "2026-05-04T22:59:17.415Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/17/3861e17c56fa0fd37491a14a8673fdb77c57fc5693cafe745ea8b06dba75/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:fdfef35d751d510fcef5252703621574364fec16418c4a1e5e1055248401054b", size = 4637126, upload-time = "2026-05-04T22:59:20.197Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/0a/7e226dbff530f21480727eb764973a7bff2b912f8e15cd4f129e71b56d1d/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:0890f502ddf7d9c6426129c3f49f5c0a39278ed7cd6322c8755ffca6ee675a13", size = 4667270, upload-time = "2026-05-04T22:59:22.647Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/f2/5a72274ca9f1b2a8b44a662ee0bf1b435909deb473d6f97bcd035bcdbc71/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:ecde28a596bead48b0cfd2a1b4416c3d43074c2d785e3a398d7ec1fc4d0f7fbb", size = 4636797, upload-time = "2026-05-04T22:59:24.912Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/e1/48cedb2fe63626e91ded1edad159e2a4fb8b6906c4425eb7749673077ce7/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:4defde8685ae324a9eb9d818717e93b4638ef67070ac9bc15b8ca85f63048355", size = 4666800, upload-time = "2026-05-04T22:59:27.474Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/ca/7e8365deec19afb2b2c7be7c1c0aa8f99633b54e90c570999acda93260fc/cryptography-48.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:db63bf618e5dea46c07de12e900fe1cdd2541e6dc9dbae772a70b7d4d4765f6a", size = 3739536, upload-time = "2026-05-04T22:59:29.61Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cssselect2"
|
||||
version = "0.9.0"
|
||||
@@ -1063,6 +1122,33 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfminer-six"
|
||||
version = "20251230"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "charset-normalizer" },
|
||||
{ name = "cryptography" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/46/9a/d79d8fa6d47a0338846bb558b39b9963b8eb2dfedec61867c138c1b17eeb/pdfminer_six-20251230.tar.gz", hash = "sha256:e8f68a14c57e00c2d7276d26519ea64be1b48f91db1cdc776faa80528ca06c1e", size = 8511285, upload-time = "2025-12-30T15:49:13.104Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/65/d7/b288ea32deb752a09aab73c75e1e7572ab2a2b56c3124a5d1eb24c62ceb3/pdfminer_six-20251230-py3-none-any.whl", hash = "sha256:9ff2e3466a7dfc6de6fd779478850b6b7c2d9e9405aa2a5869376a822771f485", size = 6591909, upload-time = "2025-12-30T15:49:10.76Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfplumber"
|
||||
version = "0.11.9"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pdfminer-six" },
|
||||
{ name = "pillow" },
|
||||
{ name = "pypdfium2" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/38/37/9ca3519e92a8434eb93be570b131476cc0a4e840bb39c62ddb7813a39d53/pdfplumber-0.11.9.tar.gz", hash = "sha256:481224b678b2bbdbf376e2c39bf914144eef7c3d301b4a28eebf0f7f6109d6dc", size = 102768, upload-time = "2026-01-05T08:10:29.072Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/c8/cdbc975f5b634e249cfa6597e37c50f3078412474f21c015e508bfbfe3c3/pdfplumber-0.11.9-py3-none-any.whl", hash = "sha256:33ec5580959ba524e9100138746e090879504c42955df1b8a997604dd326c443", size = 60045, upload-time = "2026-01-05T08:10:27.512Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pillow"
|
||||
version = "12.2.0"
|
||||
@@ -1330,6 +1416,35 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pypdfium2"
|
||||
version = "5.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6d/3d/dc934d3b606c51c3ecc95b6731d84b7dd7ab8e513a50b0e98a4da6c8a719/pypdfium2-5.8.0.tar.gz", hash = "sha256:049397c647e50f83115ee951c49394dab9e9ba52ebdd5a11ab1109390eb3d34e", size = 271934, upload-time = "2026-05-04T17:39:43.794Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/8c/6b75b923cb81368fa3ea7c48a0616b839620a3aeff899885bd930449b89e/pypdfium2-5.8.0-py3-none-android_23_arm64_v8a.whl", hash = "sha256:f67b6c74b716d9ac725ad1af49ae786ad813ac20823d45606d59f1fc06caa8af", size = 3374554, upload-time = "2026-05-04T17:39:05.552Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/61/a885c7f36efba89ec98e3d1fe95c83b48c2d6dea321e9194ac6460e7a834/pypdfium2-5.8.0-py3-none-android_23_armeabi_v7a.whl", hash = "sha256:53e82bf3e6a2da170b1bda83f93b7eec57cb6efe3cacd05cba78823879a85203", size = 2831667, upload-time = "2026-05-04T17:39:08.028Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/1f/04b5627f6dba312d3e707e5b019c9f24d8b03b5aa366866a9e02ec00f8d4/pypdfium2-5.8.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:085e633dcc89b65ff4035a4787e98ce7ae636836eb39c83dd0db26113d9774bc", size = 3450815, upload-time = "2026-05-04T17:39:09.551Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/77/8e3a2aba2bc4aef5abe1b1306d05b00588dc0bf7f5c850d1adf6164c786b/pypdfium2-5.8.0-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:bc84b7c6efede88fcfb9467f81daf416f26b973a54fc1cf4d3410d622fda6d7a", size = 3634395, upload-time = "2026-05-04T17:39:11.225Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/11/6f2b1847d9fa457b3b7251afc2bba2706d104a0c6f01431dfae5d679a839/pypdfium2-5.8.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a63bf09b2e13ba8545c930d243f0650c664a1b51314daa3b5f38df6d1a17b4bc", size = 3617413, upload-time = "2026-05-04T17:39:13.139Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/fd/99ce639de5ca06d21743c740dd988cd209dda623bc763ae10b8a162022e1/pypdfium2-5.8.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:937881c1698456749ed203a58db1895baa5eb7178cdb837ef84867790638da28", size = 3347639, upload-time = "2026-05-04T17:39:15.086Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/47/82864cc6e26dd8969d5594c168635acb16458d35cf5fed65d6b2e32abb42/pypdfium2-5.8.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6be9dc2b84a8694ad7e626bab133244e8241014d5ed1930d865a9bdf90df1e24", size = 3746404, upload-time = "2026-05-04T17:39:17.094Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/58/e41e49bba951f61921bac7289e67fe02af5ac57192d0bbfb5f459dc3691d/pypdfium2-5.8.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7f27bd82891ae302dd02d736b14809661f6d1220ee1e96dbed9b23e2811922a3", size = 4177893, upload-time = "2026-05-04T17:39:18.729Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/15/fa7031010d5cf6853dadb4864680a0bfb7782c5bb6a1a401e0c25c4fca87/pypdfium2-5.8.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26c1089cdbbdc7fe1248f6d17fe3f30214be4f287dd0196b31aaee18a1564240", size = 3665152, upload-time = "2026-05-04T17:39:20.207Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/6a/5a3520a8b0cfa8d7fdc3f03a07ad9d6146c28ffd519330706f64fd8939a8/pypdfium2-5.8.0-py3-none-manylinux_2_27_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1c038a9290864aaa4862dd32e591993d82551ca4d152b4e8ce6d43ba37dc04a8", size = 3095365, upload-time = "2026-05-04T17:39:22.054Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/d3/845bae4de3cfa36865959046156edb5bf9baea400ccdecdd84fdd911b0f5/pypdfium2-5.8.0-py3-none-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f104bc1a6d8bfc1ff088aa50db13b9729cfdb3722b44975c3c457e9a7b9c7318", size = 2961801, upload-time = "2026-05-04T17:39:23.817Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/76/cf54eabee4a172241dfcfe63533bd1e11e2162114a983453a5a40bfec114/pypdfium2-5.8.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:04ca7c57a553facf8d46c6ea8ba6fa557e698670cfa4a58e0e01fdae2f6be87d", size = 4133067, upload-time = "2026-05-04T17:39:25.619Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/66/dcf871d19187ca04ea184a99801a6e7e556d8347aa49540fee33cda6dfc5/pypdfium2-5.8.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:ad42b9c22477b32dbedcbc8232833f385d92fd0cf92822547b02383cf9a476d7", size = 3749100, upload-time = "2026-05-04T17:39:27.203Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/67/0d456c79660959ca45ad307b4d67161d29f9ed4083ee1e8fe8c6925b7c82/pypdfium2-5.8.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:388e3119cf5ca0979b7d5f6d40b7fcd5ab49e17ed4e6de6af89ba116061acfda", size = 4339212, upload-time = "2026-05-04T17:39:29.277Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/89/e5b0e0f7936be341c91c0f45cd70d693878894ed62aed93a6ee32e9c43c4/pypdfium2-5.8.0-py3-none-musllinux_1_2_ppc64le.whl", hash = "sha256:aa05bbfa485ce7916217aa78d856c9f9cd86b08b20846c650392a67975ee72e9", size = 4383943, upload-time = "2026-05-04T17:39:31.287Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/21/4502ed255f082f579cd3537c2971cf1a57778d43703a08bcd1a92253189f/pypdfium2-5.8.0-py3-none-musllinux_1_2_riscv64.whl", hash = "sha256:f0813a16bb39d5ebd173ea5484430bb67a89b4b181db0a636c73b64ad063c3ea", size = 3925680, upload-time = "2026-05-04T17:39:33.241Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/4f/2e59723e7a07779439bd885c1b4960079c9710603308888d29ac926ae69a/pypdfium2-5.8.0-py3-none-musllinux_1_2_s390x.whl", hash = "sha256:a3c78f7d20dd821bec6c072efdb21a1370b9efe10fdeeb68c969e67608e25385", size = 4269560, upload-time = "2026-05-04T17:39:34.926Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/4e/7b6b1bde3788c8b880d4b8131d95d9d339cebafb3ad9102d82e234bb65be/pypdfium2-5.8.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:86d302e207c138c827b885a72784f7b306d840646ebeae07e8efdbc39321c629", size = 4182434, upload-time = "2026-05-04T17:39:36.624Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/7b/6ed4782e0d7a5278330598ce8c4b2df7255f4585a0b3d04520fa580d6507/pypdfium2-5.8.0-py3-none-win32.whl", hash = "sha256:3f25fd436920a907291462b41bdc0ab9f8235c3944b4c9c15398da595ffd1fed", size = 3636680, upload-time = "2026-05-04T17:39:38.49Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/55/da7223d4202b2461f4f889b0baf10dddec3db7f88e6fd8c52db4a516eecd/pypdfium2-5.8.0-py3-none-win_amd64.whl", hash = "sha256:55592af0bddd2d62bed18e0053c546c9b72041430c5115e54870f7f6163125b0", size = 3754962, upload-time = "2026-05-04T17:39:40.13Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/7a/f3dcefe6ee7389aad3ca1488c177e8fbf978206de21c7a99ccf487ea38ab/pypdfium2-5.8.0-py3-none-win_arm64.whl", hash = "sha256:3f17ed97ae8a5a1705301ca93af256a5b02f9009dee4e99c5e175831d46ebd7c", size = 3548362, upload-time = "2026-05-04T17:39:42.304Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyphen"
|
||||
version = "0.17.2"
|
||||
@@ -1643,8 +1758,10 @@ server = [
|
||||
{ name = "asyncmy" },
|
||||
{ name = "bcrypt" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "httpx" },
|
||||
{ name = "jinja2" },
|
||||
{ name = "kaleido" },
|
||||
{ name = "pdfplumber" },
|
||||
{ name = "pillow" },
|
||||
{ name = "plotly" },
|
||||
{ name = "python-multipart" },
|
||||
@@ -1666,8 +1783,10 @@ requires-dist = [
|
||||
{ name = "flask-wtf", marker = "extra == 'client'", specifier = ">=1.2.0" },
|
||||
{ name = "gunicorn", marker = "extra == 'client'", specifier = ">=21.0.0" },
|
||||
{ name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27.0" },
|
||||
{ name = "httpx", marker = "extra == 'server'", specifier = ">=0.27.0" },
|
||||
{ name = "jinja2", marker = "extra == 'server'", specifier = ">=3.1.0" },
|
||||
{ name = "kaleido", marker = "extra == 'server'", specifier = ">=0.2.0" },
|
||||
{ name = "pdfplumber", marker = "extra == 'server'", specifier = ">=0.10.0" },
|
||||
{ name = "pillow", marker = "extra == 'server'", specifier = ">=10.0.0" },
|
||||
{ name = "plotly", marker = "extra == 'server'", specifier = ">=5.0.0" },
|
||||
{ name = "pydantic", specifier = ">=2.0.0" },
|
||||
|
||||
Reference in New Issue
Block a user