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
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|
||||||
# Local-only notes (Markdown docs are still copied if a Dockerfile asks)
|
# Local-only notes
|
||||||
PIANO_IMPLEMENTAZIONE.md
|
docs/
|
||||||
*.docx
|
*.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 are a runtime volume, never baked in.
|
||||||
uploads/
|
uploads/
|
||||||
|
|
||||||
|
|||||||
+8
-1
@@ -26,12 +26,19 @@ API_SERVER_URL=http://localhost:8000
|
|||||||
STATION_CODE=ST-DEFAULT
|
STATION_CODE=ST-DEFAULT
|
||||||
|
|
||||||
# --- File Storage ---
|
# --- File Storage ---
|
||||||
UPLOAD_DIR=server/uploads
|
# Resolved against the project root in src/backend/config.py.
|
||||||
|
# Default "uploads" maps to <project_root>/uploads, mounted as a Docker
|
||||||
|
# volume in production.
|
||||||
|
UPLOAD_DIR=uploads
|
||||||
MAX_UPLOAD_SIZE_MB=50
|
MAX_UPLOAD_SIZE_MB=50
|
||||||
|
|
||||||
# --- Setup Page ---
|
# --- Setup Page ---
|
||||||
SETUP_PASSWORD= # Password per /api/setup, vuoto = disabilitato
|
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 ---
|
# --- Docker ---
|
||||||
DB_ROOT_PASSWORD=root_password_change_me
|
DB_ROOT_PASSWORD=root_password_change_me
|
||||||
NGINX_PORT=80
|
NGINX_PORT=80
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ uploads/images/*
|
|||||||
uploads/pdfs/*
|
uploads/pdfs/*
|
||||||
uploads/logos/*
|
uploads/logos/*
|
||||||
uploads/reports/*
|
uploads/reports/*
|
||||||
|
uploads/general/
|
||||||
!uploads/images/.gitkeep
|
!uploads/images/.gitkeep
|
||||||
!uploads/pdfs/.gitkeep
|
!uploads/pdfs/.gitkeep
|
||||||
!uploads/logos/.gitkeep
|
!uploads/logos/.gitkeep
|
||||||
@@ -48,6 +49,8 @@ src/frontend/flask_app/static/css/tailwind.css
|
|||||||
|
|
||||||
# Node
|
# Node
|
||||||
node_modules/
|
node_modules/
|
||||||
|
src/frontend/flask_app/package.json
|
||||||
|
src/frontend/flask_app/package-lock.json
|
||||||
|
|
||||||
# Flask-Babel compiled
|
# Flask-Babel compiled
|
||||||
*.mo
|
*.mo
|
||||||
@@ -70,3 +73,4 @@ nul
|
|||||||
|
|
||||||
# Competitor analysis (local only)
|
# Competitor analysis (local only)
|
||||||
Concorrente/
|
Concorrente/
|
||||||
|
docker-compose.override.yml
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ uv lock # rigenera uv.lock
|
|||||||
### i18n (Traduzioni)
|
### i18n (Traduzioni)
|
||||||
```bash
|
```bash
|
||||||
# Estrai stringhe
|
# 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
|
# Aggiorna catalogo
|
||||||
cd src/frontend/flask_app && uv run pybabel update -i translations/messages.pot -d translations
|
cd src/frontend/flask_app && uv run pybabel update -i translations/messages.pot -d translations
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
# TieMeasureFlow by Tielogic
|
# TieMeasureFlow by Tielogic
|
||||||
|
|
||||||
Sistema di gestione task per misurazioni con calibro manuale. Soluzione tablet-first, multi-ruolo, con statistiche SPC (Statistical Process Control) integrate.
|
Sistema di gestione task per misurazioni con calibro manuale. Soluzione tablet-first, multi-ruolo, con statistiche SPC (Statistical Process Control) integrate, identità per-stazione e rate limiting per-tablet.
|
||||||
|
|
||||||
|
> **Versione corrente:** V2.0.0 (in sviluppo) — branch default `V2.0.0`.
|
||||||
|
> Per stato dettagliato e prossimi passi vedi [`docs/architecture/STATO_PROGETTO.md`](docs/architecture/STATO_PROGETTO.md) e [`docs/architecture/ROADMAP.md`](docs/architecture/ROADMAP.md).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -14,8 +17,10 @@ Caratteristiche principali:
|
|||||||
- Calcolo pass/fail/warning su quattro limiti di tolleranza (UTL, UWL, LWL, LTL)
|
- Calcolo pass/fail/warning su quattro limiti di tolleranza (UTL, UWL, LWL, LTL)
|
||||||
- SPC: Cp, Cpk, Pp, Ppk, control chart UCL/LCL, istogramma con curva normale — puro stdlib (no numpy)
|
- SPC: Cp, Cpk, Pp, Ppk, control chart UCL/LCL, istogramma con curva normale — puro stdlib (no numpy)
|
||||||
- Annotazioni grafiche su disegni tecnici (Fabric.js) con viewer sincronizzato all'esecuzione
|
- Annotazioni grafiche su disegni tecnici (Fabric.js) con viewer sincronizzato all'esecuzione
|
||||||
|
- Identità per-stazione (`STATION_CODE`): ogni tablet vede solo le ricette assegnate alla propria stazione, gestione assegnazioni via GUI admin
|
||||||
- Interfaccia completamente localizzata IT/EN, dark mode, ottimizzata per tablet touch
|
- Interfaccia completamente localizzata IT/EN, dark mode, ottimizzata per tablet touch
|
||||||
- Autenticazione API Key, rate limiting sliding window, audit log persistente
|
- Autenticazione API Key, rate limiting sliding window per-IP reale (X-Forwarded-For-aware), audit log persistente
|
||||||
|
- Capacità testata per ~20 tablet contemporanei (gunicorn 5 workers × 4 thread + uvicorn 4 workers async)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -26,25 +31,25 @@ Browser/Tablet
|
|||||||
|
|
|
|
||||||
Reverse Proxy (Nginx — sviluppo | Traefik+SSL — produzione)
|
Reverse Proxy (Nginx — sviluppo | Traefik+SSL — produzione)
|
||||||
|
|
|
|
||||||
Flask Client :5000 (rendering server-side, Jinja2 + Alpine.js)
|
Flask Frontend :5000 (rendering server-side, Jinja2 + Alpine.js)
|
||||||
| X-API-Key
|
| X-API-Key + X-Forwarded-For
|
||||||
FastAPI Server :8000 (API REST asincrona)
|
FastAPI Backend :8000 (API REST asincrona)
|
||||||
|
|
|
|
||||||
MySQL 8.0
|
MySQL 8.0
|
||||||
```
|
```
|
||||||
|
|
||||||
Il client Flask non espone mai le credenziali al browser: ogni chiamata al backend avviene server-side con l'header `X-API-Key` estratto dalla sessione Flask.
|
Il frontend Flask non espone mai le credenziali al browser: ogni chiamata al backend avviene server-side con l'header `X-API-Key` estratto dalla sessione Flask. L'IP reale del tablet è propagato in `X-Forwarded-For` per il rate limiter.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Stack Tecnologico
|
## Stack Tecnologico
|
||||||
|
|
||||||
### Backend (server/)
|
### Backend (`src/backend/`)
|
||||||
|
|
||||||
| Componente | Versione | Ruolo |
|
| Componente | Versione | Ruolo |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| FastAPI | ultima stabile | Framework API REST asincrono |
|
| FastAPI | ultima stabile | Framework API REST asincrono |
|
||||||
| SQLAlchemy 2.0 | async | ORM con pool connessioni |
|
| SQLAlchemy 2.0 | async | ORM con pool connessioni 10+20 |
|
||||||
| asyncmy | ultima stabile | Driver MySQL asincrono |
|
| asyncmy | ultima stabile | Driver MySQL asincrono |
|
||||||
| MySQL | 8.0 | Database relazionale |
|
| MySQL | 8.0 | Database relazionale |
|
||||||
| Alembic | ultima stabile | Migrazioni schema |
|
| Alembic | ultima stabile | Migrazioni schema |
|
||||||
@@ -54,35 +59,47 @@ Il client Flask non espone mai le credenziali al browser: ogni chiamata al backe
|
|||||||
| bcrypt | ultima stabile | Hashing password |
|
| bcrypt | ultima stabile | Hashing password |
|
||||||
| Pillow | ultima stabile | Thumbnail automatici upload |
|
| Pillow | ultima stabile | Thumbnail automatici upload |
|
||||||
|
|
||||||
### Frontend (client/)
|
### Frontend (`src/frontend/flask_app/`)
|
||||||
|
|
||||||
| Componente | Versione | Ruolo |
|
| Componente | Versione | Ruolo |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Flask | 3.x | Framework web server-side |
|
| Flask | 3.x | Framework web server-side |
|
||||||
|
| gunicorn | 21+ | WSGI server (5 workers × 4 thread gthread) |
|
||||||
| Jinja2 | incluso in Flask | Template engine |
|
| Jinja2 | incluso in Flask | Template engine |
|
||||||
| Alpine.js | 3.x (CDN) | Reattivita leggera lato client |
|
| Alpine.js | 3.x (CDN) | Reattività leggera lato client |
|
||||||
| TailwindCSS | 3.x | CSS utility-first |
|
| TailwindCSS | 3.x | CSS utility-first |
|
||||||
| Plotly.js | CDN | Grafici SPC interattivi |
|
| Plotly.js | CDN | Grafici SPC interattivi |
|
||||||
| Fabric.js | 5.3.1 (CDN) | Editor annotazioni disegni tecnici |
|
| Fabric.js | 5.3.1 (CDN) | Editor annotazioni disegni tecnici |
|
||||||
|
| html5-qrcode | CDN | Scanner barcode/QR camera |
|
||||||
| Flask-Babel | ultima stabile | i18n IT/EN |
|
| Flask-Babel | ultima stabile | i18n IT/EN |
|
||||||
|
|
||||||
|
### Tooling
|
||||||
|
|
||||||
|
| Componente | Ruolo |
|
||||||
|
|---|---|
|
||||||
|
| **uv** | Package manager Python (no `requirements.txt`) |
|
||||||
|
| `pyproject.toml` | Dipendenze monorepo con extra `server`/`client`/`dev` |
|
||||||
|
| `uv.lock` | Lockfile per build riproducibili |
|
||||||
|
| `.python-version` | Pin Python 3.11 |
|
||||||
|
| pytest + pytest-asyncio + httpx + aiosqlite | Test stack |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Quick Start con Docker
|
## Quick Start con Docker
|
||||||
|
|
||||||
Docker Compose è il metodo raccomandato. Gestisce database, migrazioni e configurazione Nginx in un solo comando.
|
Docker Compose è il metodo raccomandato. Gestisce database, migrazioni, build delle immagini con `uv` e configurazione Nginx in un solo comando.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Clona il repository
|
# 1. Clona il repository
|
||||||
git clone <repository-url>
|
git clone ssh://git@git.tielogic.xyz:222/Adriano/TieMeasureFlow.git
|
||||||
cd TieMeasureFlow
|
cd TieMeasureFlow
|
||||||
|
|
||||||
# 2. Configura le variabili d'ambiente
|
# 2. Configura le variabili d'ambiente
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
# Modifica .env: credenziali DB, chiavi segrete, SETUP_PASSWORD
|
# Modifica .env: credenziali DB, chiavi segrete, SETUP_PASSWORD, STATION_CODE per ogni tablet
|
||||||
|
|
||||||
# 3. Avvia i servizi (ambiente di sviluppo)
|
# 3. Avvia i servizi (ambiente di sviluppo)
|
||||||
docker compose -f docker-compose.dev.yml up -d
|
docker compose -f docker-compose.dev.yml up -d --build
|
||||||
|
|
||||||
# 4. Verifica lo stato dei container
|
# 4. Verifica lo stato dei container
|
||||||
docker compose -f docker-compose.dev.yml ps
|
docker compose -f docker-compose.dev.yml ps
|
||||||
@@ -90,6 +107,7 @@ docker compose -f docker-compose.dev.yml ps
|
|||||||
# 5. Setup iniziale (solo al primo avvio)
|
# 5. Setup iniziale (solo al primo avvio)
|
||||||
# Apri http://localhost/api/setup nel browser
|
# Apri http://localhost/api/setup nel browser
|
||||||
# Usa SETUP_PASSWORD configurata in .env
|
# Usa SETUP_PASSWORD configurata in .env
|
||||||
|
# Lo script seed crea anche la stazione ST-DEFAULT con tutte le ricette assegnate
|
||||||
```
|
```
|
||||||
|
|
||||||
L'applicazione sarà disponibile su:
|
L'applicazione sarà disponibile su:
|
||||||
@@ -97,8 +115,9 @@ L'applicazione sarà disponibile su:
|
|||||||
- Frontend: http://localhost
|
- Frontend: http://localhost
|
||||||
- API: http://localhost/api
|
- API: http://localhost/api
|
||||||
- Pagina setup: http://localhost/api/setup
|
- Pagina setup: http://localhost/api/setup
|
||||||
|
- Admin stazioni: http://localhost/admin/stations (solo `is_admin`)
|
||||||
|
|
||||||
Per il deployment in produzione (Traefik + SSL) consulta [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md).
|
Per il deployment in produzione (Traefik + SSL) consulta [`docs/DEPLOYMENT.md`](docs/DEPLOYMENT.md).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -108,12 +127,14 @@ Dopo il primo avvio, la pagina `/api/setup` (protetta da `SETUP_PASSWORD`) perme
|
|||||||
|
|
||||||
- **Initialize Database** — crea tutte le tabelle
|
- **Initialize Database** — crea tutte le tabelle
|
||||||
- **Create Admin User** — crea l'utente amministratore con credenziali da `.env`
|
- **Create Admin User** — crea l'utente amministratore con credenziali da `.env`
|
||||||
- **Seed Demo Data** — carica ricette, misurazioni e utenti di esempio
|
- **Seed Demo Data** — carica ricette, misurazioni e utenti di esempio + crea stazione `ST-DEFAULT` con tutte le ricette assegnate
|
||||||
- **Reset Database** — elimina e ricrea tutte le tabelle (attenzione: cancella tutti i dati)
|
- **Reset Database** — elimina e ricrea tutte le tabelle (attenzione: cancella tutti i dati)
|
||||||
- **Gestione utenti** — crea, modifica, attiva/disattiva account dalla stessa pagina
|
- **Gestione utenti** — crea, modifica, attiva/disattiva account dalla stessa pagina
|
||||||
|
|
||||||
Se `SETUP_PASSWORD` è vuota o assente nel `.env`, l'endpoint è disabilitato.
|
Se `SETUP_PASSWORD` è vuota o assente nel `.env`, l'endpoint è disabilitato.
|
||||||
|
|
||||||
|
Per gestire stazioni e assegnazioni ricette dopo il setup: `/admin/stations` (richiede login admin).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Setup Manuale (Senza Docker)
|
## Setup Manuale (Senza Docker)
|
||||||
@@ -121,8 +142,9 @@ Se `SETUP_PASSWORD` è vuota o assente nel `.env`, l'endpoint è disabilitato.
|
|||||||
### Requisiti
|
### Requisiti
|
||||||
|
|
||||||
- Python 3.11 o superiore
|
- Python 3.11 o superiore
|
||||||
- Node.js 18 o superiore
|
- Node.js 18 o superiore (per TailwindCSS)
|
||||||
- MySQL 8.0
|
- MySQL 8.0
|
||||||
|
- [uv](https://docs.astral.sh/uv/) installato
|
||||||
|
|
||||||
### 1. Database MySQL
|
### 1. Database MySQL
|
||||||
|
|
||||||
@@ -139,35 +161,37 @@ SQL
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
# Imposta DB_HOST, DB_USER, DB_PASSWORD, DB_NAME, SERVER_SECRET_KEY, CLIENT_SECRET_KEY, SETUP_PASSWORD
|
# Imposta DB_HOST, DB_USER, DB_PASSWORD, DB_NAME, SERVER_SECRET_KEY,
|
||||||
|
# CLIENT_SECRET_KEY, SETUP_PASSWORD, STATION_CODE
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. Server FastAPI
|
### 3. Installa dipendenze (uv)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd server
|
uv sync --extra server --extra client --extra dev
|
||||||
python -m venv venv
|
|
||||||
source venv/bin/activate # Windows: venv\Scripts\activate
|
|
||||||
pip install -r requirements.txt
|
|
||||||
alembic -c migrations/alembic.ini upgrade head
|
|
||||||
uvicorn main:app --reload --host 0.0.0.0 --port 8000
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4. Client Flask
|
### 4. Backend FastAPI
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd client
|
uv run alembic -c src/backend/migrations/alembic.ini upgrade head
|
||||||
python -m venv venv
|
uv run uvicorn src.backend.main:app --reload --host 0.0.0.0 --port 8000
|
||||||
source venv/bin/activate # Windows: venv\Scripts\activate
|
|
||||||
pip install -r requirements.txt
|
|
||||||
pybabel compile -d translations
|
|
||||||
flask run --host 0.0.0.0 --port 5000
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 5. TailwindCSS (watch per sviluppo)
|
### 5. Frontend Flask
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd client
|
# Compila i cataloghi i18n una volta
|
||||||
|
cd src/frontend/flask_app && uv run --project ../../.. pybabel compile -d translations && cd -
|
||||||
|
|
||||||
|
# Avvia (development)
|
||||||
|
cd src/frontend/flask_app && uv run --project ../../.. flask run --host 0.0.0.0 --port 5000
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. TailwindCSS (watch per sviluppo)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd src/frontend/flask_app
|
||||||
npx tailwindcss -i static/css/input.css -o static/css/tailwind.css --watch
|
npx tailwindcss -i static/css/input.css -o static/css/tailwind.css --watch
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -185,9 +209,9 @@ I ruoli sono combinabili (array JSON per utente). Il flag `is_admin` è separato
|
|||||||
| Ruolo | Descrizione |
|
| Ruolo | Descrizione |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **Maker** | Crea e gestisce ricette di misurazione: caricamento disegni (PDF/immagini), annotazioni Fabric.js, definizione task/subtask, configurazione tolleranze, versioning copy-on-write |
|
| **Maker** | Crea e gestisce ricette di misurazione: caricamento disegni (PDF/immagini), annotazioni Fabric.js, definizione task/subtask, configurazione tolleranze, versioning copy-on-write |
|
||||||
| **MeasurementTec** | Esegue misurazioni: scansione barcode per selezione ricetta, interfaccia task-driven, input da calibro USB HID o numpad touch, validazione real-time pass/warning/fail |
|
| **MeasurementTec** | Esegue misurazioni: scansione barcode per selezione ricetta, interfaccia task-driven, input da calibro USB HID o numpad touch, validazione real-time pass/warning/fail. Vede solo le ricette assegnate alla propria stazione (`STATION_CODE`) |
|
||||||
| **Metrologist** | Analisi qualità: dashboard SPC (X-bar, R, Cp, Cpk, Pp, Ppk), filtri multi-dimensionali, export report PDF, analisi capability e control chart |
|
| **Metrologist** | Analisi qualità: dashboard SPC (X-bar, R, Cp, Cpk, Pp, Ppk), filtri multi-dimensionali, export report PDF, analisi capability e control chart |
|
||||||
| **Admin** (flag) | Gestione sistema: CRUD utenti, cambio password, attivazione/disattivazione account |
|
| **Admin** (flag) | Gestione sistema: CRUD utenti, cambio password, attivazione/disattivazione account, **CRUD stazioni e assegnazioni ricette** |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -195,39 +219,53 @@ I ruoli sono combinabili (array JSON per utente). Il flag `is_admin` è separato
|
|||||||
|
|
||||||
```
|
```
|
||||||
TieMeasureFlow/
|
TieMeasureFlow/
|
||||||
├── server/ # FastAPI Backend
|
├── pyproject.toml # Dipendenze monorepo (uv)
|
||||||
│ ├── main.py # Entry point, lifespan, middleware, 10 router
|
├── uv.lock # Lockfile riproducibile
|
||||||
|
├── .python-version # 3.11
|
||||||
|
├── Dockerfile # Backend (uv + uvicorn)
|
||||||
|
├── Dockerfile.frontend # Frontend (uv + gunicorn + Tailwind + Babel)
|
||||||
|
├── docker-compose.dev.yml # Sviluppo (Nginx, porta 80)
|
||||||
|
├── docker-compose.yml # Produzione (Traefik, SSL)
|
||||||
|
├── nginx/ # Config Nginx (dev)
|
||||||
|
├── uploads/ # Volume Docker file caricati
|
||||||
|
├── docs/ # Documentazione (vedi indice docs/README.md)
|
||||||
|
└── src/
|
||||||
|
├── backend/ # FastAPI Backend
|
||||||
|
│ ├── main.py # Entry point, lifespan, middleware, 11 router
|
||||||
│ ├── config.py # Settings (pydantic_settings.BaseSettings)
|
│ ├── config.py # Settings (pydantic_settings.BaseSettings)
|
||||||
│ ├── database.py # SQLAlchemy 2.0 async engine
|
│ ├── database.py # SQLAlchemy 2.0 async engine
|
||||||
│ ├── models/ # ORM: User, Recipe, RecipeVersion, RecipeTask,
|
│ ├── api/
|
||||||
│ │ # RecipeSubtask, Measurement, AccessLog,
|
│ │ ├── routers/ # auth, users, recipes, tasks, measurements,
|
||||||
│ │ # SystemSetting, RecipeVersionAudit
|
│ │ │ # files, settings, statistics, reports,
|
||||||
│ ├── schemas/ # Pydantic v2 per validazione I/O API
|
│ │ │ # setup, stations
|
||||||
│ ├── routers/ # auth, users, recipes, tasks, measurements,
|
│ │ └── middleware/ # api_key, rate_limit, security_headers, logging
|
||||||
│ │ # files, settings, statistics, reports, setup
|
│ ├── models/
|
||||||
|
│ │ ├── orm/ # SQLAlchemy: User, Recipe, RecipeVersion,
|
||||||
|
│ │ │ # RecipeTask, RecipeSubtask, Measurement,
|
||||||
|
│ │ │ # AccessLog, SystemSetting,
|
||||||
|
│ │ │ # RecipeVersionAudit, Station,
|
||||||
|
│ │ │ # StationRecipeAssignment
|
||||||
|
│ │ └── api/ # Pydantic v2 schemas request/response
|
||||||
│ ├── services/ # recipe_service, measurement_service,
|
│ ├── services/ # recipe_service, measurement_service,
|
||||||
│ │ # spc_service, report_service, auth_service
|
│ │ # spc_service, report_service,
|
||||||
│ ├── middleware/ # api_key, rate_limit, security_headers, logging
|
│ │ # auth_service, station_service
|
||||||
│ ├── migrations/ # Alembic (alembic.ini + env.py)
|
│ ├── migrations/ # Alembic (alembic.ini + env.py)
|
||||||
│ └── templates/ # Template HTML pagina setup
|
│ ├── templates/ # Pagina setup (Jinja2)
|
||||||
├── client/ # Flask Frontend
|
│ └── tests/ # pytest + httpx + aiosqlite
|
||||||
│ ├── app.py # Factory pattern, CSRF, Babel
|
└── frontend/
|
||||||
│ ├── blueprints/ # auth, maker, measure, statistics, admin
|
└── flask_app/ # Flask Frontend
|
||||||
│ ├── services/ # APIClient singleton (proxy verso FastAPI)
|
├── app.py # Factory + ProxyFix + CSRF + Babel
|
||||||
│ ├── templates/ # Jinja2 + Alpine.js
|
├── config.py # STATION_CODE, API_SERVER_URL, ecc.
|
||||||
│ ├── static/
|
├── compile_translations.py
|
||||||
│ │ ├── css/ # TailwindCSS compilato
|
├── blueprints/ # auth, maker, measure, statistics, admin
|
||||||
│ │ └── js/ # numpad, caliper, barcode, csv-export,
|
├── services/ # APIClient (proxy verso FastAPI con XFF)
|
||||||
│ │ # spc-charts, annotation-editor/viewer
|
├── templates/ # Jinja2 + Alpine.js
|
||||||
│ └── translations/ # Flask-Babel .po/.mo IT/EN
|
├── static/
|
||||||
├── nginx/ # Configurazione Nginx (dev)
|
│ ├── css/ # TailwindCSS compilato
|
||||||
├── docs/ # Documentazione tecnica
|
│ └── js/ # numpad, caliper, barcode, csv-export,
|
||||||
│ ├── API.md # Riferimento API REST
|
│ # spc-charts, annotation-editor/viewer
|
||||||
│ ├── DEPLOYMENT.md # Guida deployment VPS (Traefik, SSL, DNS)
|
├── translations/ # Flask-Babel .po/.mo IT/EN
|
||||||
│ └── USER_GUIDE.md # Manuale utente per ruolo
|
└── tests/
|
||||||
├── docker-compose.yml # Produzione (Traefik, SSL)
|
|
||||||
├── docker-compose.dev.yml # Sviluppo (Nginx, porta 80)
|
|
||||||
└── .env.example # Template variabili d'ambiente
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -238,7 +276,7 @@ TieMeasureFlow/
|
|||||||
|
|
||||||
| Comando | Descrizione |
|
| Comando | Descrizione |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `docker compose -f docker-compose.dev.yml up -d` | Avvia servizi in sviluppo |
|
| `docker compose -f docker-compose.dev.yml up -d --build` | Avvia servizi in sviluppo (build incluso) |
|
||||||
| `docker compose -f docker-compose.dev.yml down` | Ferma e rimuove i container |
|
| `docker compose -f docker-compose.dev.yml down` | Ferma e rimuove i container |
|
||||||
| `docker compose logs -f server` | Segui log server in tempo reale |
|
| `docker compose logs -f server` | Segui log server in tempo reale |
|
||||||
| `docker compose logs -f client` | Segui log client in tempo reale |
|
| `docker compose logs -f client` | Segui log client in tempo reale |
|
||||||
@@ -248,47 +286,62 @@ TieMeasureFlow/
|
|||||||
|
|
||||||
### Alembic (migrations)
|
### Alembic (migrations)
|
||||||
|
|
||||||
Nota: `alembic.ini` si trova dentro `server/migrations/`, è richiesto il flag `-c`.
|
`alembic.ini` si trova in `src/backend/migrations/`, è richiesto il flag `-c`. `env.py` aggiunge la project root a `sys.path` per risolvere `src.backend.*`.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd server
|
uv run alembic -c src/backend/migrations/alembic.ini upgrade head # Applica migrazioni
|
||||||
alembic -c migrations/alembic.ini upgrade head # Applica migrazioni
|
uv run alembic -c src/backend/migrations/alembic.ini revision --autogenerate -m "descrizione" # Genera
|
||||||
alembic -c migrations/alembic.ini revision --autogenerate -m "descrizione" # Genera migrazione
|
uv run alembic -c src/backend/migrations/alembic.ini downgrade -1 # Rollback ultima
|
||||||
alembic -c migrations/alembic.ini downgrade -1 # Rollback ultima
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Via Docker:
|
Via Docker:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose exec server alembic -c migrations/alembic.ini upgrade head
|
docker compose exec server uv run alembic -c src/backend/migrations/alembic.ini upgrade head
|
||||||
```
|
```
|
||||||
|
|
||||||
### i18n (Traduzioni)
|
### i18n (Traduzioni)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd client
|
cd src/frontend/flask_app
|
||||||
pybabel extract -F babel.cfg -k _ -o translations/messages.pot . # Estrai stringhe
|
uv run --project ../../.. pybabel extract -F babel.cfg -k _ -o translations/messages.pot . # Estrai
|
||||||
pybabel update -i translations/messages.pot -d translations # Aggiorna catalogo
|
uv run --project ../../.. pybabel update -i translations/messages.pot -d translations # Aggiorna
|
||||||
pybabel compile -d translations # Compila .po → .mo
|
uv run --project ../../.. pybabel compile -d translations # Compila .po → .mo
|
||||||
|
```
|
||||||
|
|
||||||
|
### Gestione dipendenze (uv)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv add <pacchetto> # core (entrambi)
|
||||||
|
uv add --optional server <pacchetto> # solo backend
|
||||||
|
uv add --optional client <pacchetto> # solo frontend
|
||||||
|
uv add --optional dev <pacchetto> # solo dev/test
|
||||||
|
uv sync --extra server --extra client --extra dev # reinstalla
|
||||||
|
uv lock # rigenera uv.lock
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
Il server usa SQLite in-memory tramite `aiosqlite`: i test girano senza MySQL installato.
|
Il backend usa SQLite in-memory tramite `aiosqlite`: i test girano senza MySQL installato.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Server
|
# Tutti i test (backend + frontend)
|
||||||
cd server && pytest # Tutti i test
|
uv run pytest
|
||||||
cd server && pytest tests/test_auth.py # Singolo modulo
|
|
||||||
cd server && pytest tests/test_auth.py::test_login_success # Singolo test
|
|
||||||
cd server && pytest --cov # Con copertura
|
|
||||||
|
|
||||||
# Client
|
# Solo backend
|
||||||
cd client && pytest
|
uv run pytest src/backend/tests/
|
||||||
|
uv run pytest src/backend/tests/test_auth.py
|
||||||
|
uv run pytest src/backend/tests/test_auth.py::test_login_success
|
||||||
|
uv run pytest --cov src/backend
|
||||||
|
|
||||||
|
# Solo frontend
|
||||||
|
uv run pytest src/frontend/flask_app/tests/
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Stato corrente: **171 pass, 4 fail pre-esistenti** (vedi `docs/architecture/STATO_PROGETTO.md`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Variabili d'Ambiente
|
## Variabili d'Ambiente
|
||||||
@@ -304,8 +357,11 @@ Copia `.env.example` in `.env` e configura:
|
|||||||
| `SERVER_CORS_ORIGINS` | Origini CORS ammesse |
|
| `SERVER_CORS_ORIGINS` | Origini CORS ammesse |
|
||||||
| `CLIENT_SECRET_KEY` | Chiave segreta Flask (sessioni, CSRF) |
|
| `CLIENT_SECRET_KEY` | Chiave segreta Flask (sessioni, CSRF) |
|
||||||
| `API_SERVER_URL` | URL del backend visto dal client (es. `http://server:8000`) |
|
| `API_SERVER_URL` | URL del backend visto dal client (es. `http://server:8000`) |
|
||||||
| `UPLOAD_DIR` | Percorso upload file (default: `uploads`, relativo a `server/`) |
|
| `STATION_CODE` | **Per-tablet** — codice stazione (es. `ST-001`). Senza, il client mostra errore configurazione. |
|
||||||
| `MAX_UPLOAD_SIZE_MB` | Limite dimensione upload |
|
| `UPLOAD_DIR` | Percorso upload file (default: `uploads`, project root) |
|
||||||
|
| `MAX_UPLOAD_SIZE_MB` | Limite dimensione upload (default 50) |
|
||||||
|
| `RATE_LIMIT_LOGIN` | Login req/min/IP (default 5) |
|
||||||
|
| `RATE_LIMIT_GENERAL` | Richieste req/min/IP (default 300, per-tablet) |
|
||||||
| `NGINX_PORT`, `NGINX_SSL_PORT` | Porte Nginx (solo compose dev) |
|
| `NGINX_PORT`, `NGINX_SSL_PORT` | Porte Nginx (solo compose dev) |
|
||||||
| `SETUP_PASSWORD` | Password pagina setup (vuota = endpoint disabilitato) |
|
| `SETUP_PASSWORD` | Password pagina setup (vuota = endpoint disabilitato) |
|
||||||
| `SSL_CERTFILE`, `SSL_KEYFILE` | Certificato SSL (solo setup manuale) |
|
| `SSL_CERTFILE`, `SSL_KEYFILE` | Certificato SSL (solo setup manuale) |
|
||||||
@@ -314,16 +370,35 @@ Copia `.env.example` in `.env` e configura:
|
|||||||
|
|
||||||
## Documentazione
|
## Documentazione
|
||||||
|
|
||||||
|
Indice completo: [`docs/README.md`](docs/README.md).
|
||||||
|
|
||||||
|
### Stato e direzione
|
||||||
|
|
||||||
| Documento | Contenuto |
|
| Documento | Contenuto |
|
||||||
|---|---|
|
|---|---|
|
||||||
| [docs/API.md](docs/API.md) | Riferimento completo API REST (endpoint, parametri, schemi) |
|
| [`docs/architecture/STATO_PROGETTO.md`](docs/architecture/STATO_PROGETTO.md) | Snapshot V2.0.0: cosa funziona oggi, test status, decisioni architetturali |
|
||||||
| [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md) | Guida deployment VPS: Docker, Traefik, SSL, DNS, firewall |
|
| [`docs/architecture/ROADMAP.md`](docs/architecture/ROADMAP.md) | Cosa resta da fare (Fasi 2-7 rev04, decisioni cliente aperte, stime) |
|
||||||
| [docs/USER_GUIDE.md](docs/USER_GUIDE.md) | Manuale utente per ruolo (Maker, MeasurementTec, Metrologist) |
|
|
||||||
|
### Riferimenti operativi
|
||||||
|
|
||||||
|
| Documento | Contenuto |
|
||||||
|
|---|---|
|
||||||
|
| [`docs/API.md`](docs/API.md) | Riferimento completo API REST (endpoint, parametri, schemi) |
|
||||||
|
| [`docs/DEPLOYMENT.md`](docs/DEPLOYMENT.md) | Guida deployment VPS: Docker, Traefik, SSL, DNS, firewall |
|
||||||
|
| [`docs/USER_GUIDE.md`](docs/USER_GUIDE.md) | Manuale utente per ruolo (Maker, MeasurementTec, Metrologist) |
|
||||||
|
| [`docs/I18N_SETUP.md`](docs/I18N_SETUP.md) | Setup e workflow traduzioni (Flask-Babel + Alpine.js) |
|
||||||
|
|
||||||
|
### Piani dettagliati
|
||||||
|
|
||||||
|
| Documento | Contenuto |
|
||||||
|
|---|---|
|
||||||
|
| [`docs/superpowers/plans/2026-04-17-rev04-master-roadmap.md`](docs/superpowers/plans/2026-04-17-rev04-master-roadmap.md) | Master plan rev04 (M1 + M2, decisioni aperte, stime) |
|
||||||
|
| [`docs/superpowers/plans/2026-04-17-rev04-phase1-stations.md`](docs/superpowers/plans/2026-04-17-rev04-phase1-stations.md) | Piano TDD Fase 1 stazioni (completato) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Licenza
|
## Licenza
|
||||||
|
|
||||||
Proprietary - Tielogic. All rights reserved.
|
Proprietary — Tielogic. All rights reserved.
|
||||||
|
|
||||||
Questo software è di proprietà esclusiva di Tielogic ed è protetto dalle leggi sul copyright. Non è consentita la distribuzione, modifica o utilizzo senza autorizzazione scritta.
|
Questo software è di proprietà esclusiva di Tielogic ed è protetto dalle leggi sul copyright. Non è consentita la distribuzione, modifica o utilizzo senza autorizzazione scritta.
|
||||||
|
|||||||
+1
-1
@@ -93,4 +93,4 @@ networks:
|
|||||||
driver: bridge
|
driver: bridge
|
||||||
traefik-net:
|
traefik-net:
|
||||||
external: true
|
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)
|
- [Measurements](#measurements)
|
||||||
- [Files](#files)
|
- [Files](#files)
|
||||||
- [Settings](#settings)
|
- [Settings](#settings)
|
||||||
|
- [Stations](#stations)
|
||||||
- [Statistics](#statistics)
|
- [Statistics](#statistics)
|
||||||
- [Reports](#reports)
|
- [Reports](#reports)
|
||||||
7. [Pagination](#pagination)
|
7. [Pagination](#pagination)
|
||||||
@@ -1101,6 +1102,189 @@ Content-Type: multipart/form-data
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### Stations
|
||||||
|
|
||||||
|
Stations model the physical measurement posts on the shop floor. Each Flask client identifies itself through `STATION_CODE`, and the operator only sees recipes assigned to that station. See the user guide section "Admin Workflow → Station Management" for the operational model.
|
||||||
|
|
||||||
|
#### GET `/stations`
|
||||||
|
|
||||||
|
List stations. **Admin only.**
|
||||||
|
|
||||||
|
Query parameters:
|
||||||
|
- `active_only` (bool, default `false`): if `true`, return only stations where `active = true`.
|
||||||
|
|
||||||
|
Response `200`:
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"code": "ST-DEFAULT",
|
||||||
|
"name": "Default Station",
|
||||||
|
"location": "Initial seed - change me",
|
||||||
|
"notes": null,
|
||||||
|
"active": true,
|
||||||
|
"created_by": 5,
|
||||||
|
"created_at": "2026-04-26T10:12:06"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
Errors:
|
||||||
|
- 401: Missing or invalid API key
|
||||||
|
- 403: Admin role required
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### POST `/stations`
|
||||||
|
|
||||||
|
Create a station. **Admin only.**
|
||||||
|
|
||||||
|
Request body:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": "ST-LINEA-A",
|
||||||
|
"name": "Linea A — Tornitura alberi",
|
||||||
|
"location": "Reparto 2 — Cella 3",
|
||||||
|
"notes": "Provisioned 2026-04-26 by Adriano",
|
||||||
|
"active": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`code` (1-100 chars) and `name` (1-255 chars) are required. `location` is optional (≤ 255 chars). `active` defaults to `true`.
|
||||||
|
|
||||||
|
Response `201`: same shape as `GET /stations` items.
|
||||||
|
|
||||||
|
Errors:
|
||||||
|
- 400: Invalid payload (missing `code`/`name`, exceeded length)
|
||||||
|
- 403: Admin role required
|
||||||
|
- 409: Station with that code already exists
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### GET `/stations/{station_id}`
|
||||||
|
|
||||||
|
Get a single station by id. **Admin only.**
|
||||||
|
|
||||||
|
Response `200`: same as the list item shape.
|
||||||
|
|
||||||
|
Errors:
|
||||||
|
- 403: Admin role required
|
||||||
|
- 404: Station not found
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### PUT `/stations/{station_id}`
|
||||||
|
|
||||||
|
Update a station's editable fields. **Admin only.**
|
||||||
|
|
||||||
|
Request body (all fields optional):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Linea A — riconfigurata",
|
||||||
|
"location": "Reparto 2 — Cella 4",
|
||||||
|
"notes": "Moved cell on 2026-05-12",
|
||||||
|
"active": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: the `code` field is not in the schema. Codes are immutable on purpose (changing the code would orphan every tablet pointing at it).
|
||||||
|
|
||||||
|
Response `200`: the updated station.
|
||||||
|
|
||||||
|
Errors:
|
||||||
|
- 400: Invalid payload
|
||||||
|
- 403: Admin role required
|
||||||
|
- 404: Station not found
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### DELETE `/stations/{station_id}`
|
||||||
|
|
||||||
|
Delete a station. **Admin only.**
|
||||||
|
|
||||||
|
The deletion cascades to every row in `station_recipe_assignments` for that station. Existing measurements are NOT affected (measurements link to recipe versions, not stations).
|
||||||
|
|
||||||
|
Response `204`: no body.
|
||||||
|
|
||||||
|
Errors:
|
||||||
|
- 403: Admin role required
|
||||||
|
- 404: Station not found
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### GET `/stations/{station_id}/recipes`
|
||||||
|
|
||||||
|
Admin view of the recipes currently assigned to a station. Returns the same projection used by the assignment modal in `/admin/stations`. **Admin only.**
|
||||||
|
|
||||||
|
Response `200`:
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"code": "DEMO-001",
|
||||||
|
"name": "Demo Measurement Recipe",
|
||||||
|
"active": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
Errors:
|
||||||
|
- 403: Admin role required
|
||||||
|
- 404: Station not found
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### GET `/stations/by-code/{code}/recipes`
|
||||||
|
|
||||||
|
**Operator view** (any authenticated user, no admin requirement). Returns the active recipes assigned to the station whose code matches `{code}`. The Flask client calls this endpoint at every page load of `/measure/select`, passing its own `STATION_CODE`.
|
||||||
|
|
||||||
|
Response `200`: same shape as `GET /stations/{station_id}/recipes`.
|
||||||
|
|
||||||
|
Errors:
|
||||||
|
- 401: Missing or invalid API key
|
||||||
|
- 404: Station not found OR station is not active (operator-facing endpoint deliberately treats both cases as 404 to avoid leaking the existence of disabled stations)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### POST `/stations/{station_id}/recipes`
|
||||||
|
|
||||||
|
Assign an existing recipe to a station. **Admin only.**
|
||||||
|
|
||||||
|
Request body:
|
||||||
|
```json
|
||||||
|
{ "recipe_id": 2 }
|
||||||
|
```
|
||||||
|
|
||||||
|
Response `201`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"station_id": 1,
|
||||||
|
"recipe_id": 2,
|
||||||
|
"assigned_by": 5,
|
||||||
|
"assigned_at": "2026-04-26T10:12:06"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Errors:
|
||||||
|
- 403: Admin role required
|
||||||
|
- 404: Station or recipe not found
|
||||||
|
- 409: Recipe already assigned to this station
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### DELETE `/stations/{station_id}/recipes/{recipe_id}`
|
||||||
|
|
||||||
|
Remove an existing assignment. **Admin only.**
|
||||||
|
|
||||||
|
Response `204`: no body.
|
||||||
|
|
||||||
|
Errors:
|
||||||
|
- 403: Admin role required
|
||||||
|
- 404: Station/recipe not found, or no assignment between them
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### Statistics
|
### Statistics
|
||||||
|
|
||||||
All statistics endpoints **require Metrologist role**.
|
All statistics endpoints **require Metrologist role**.
|
||||||
|
|||||||
+4
-3
@@ -134,10 +134,11 @@ SSL_KEYFILE=
|
|||||||
| `CLIENT_HOST` | string | 0.0.0.0 | Flask client bind address |
|
| `CLIENT_HOST` | string | 0.0.0.0 | Flask client bind address |
|
||||||
| `CLIENT_PORT` | int | 5000 | Flask client port |
|
| `CLIENT_PORT` | int | 5000 | Flask client port |
|
||||||
| `SERVER_CORS_ORIGINS` | string | http://localhost:5000 | Comma-separated CORS origins |
|
| `SERVER_CORS_ORIGINS` | string | http://localhost:5000 | Comma-separated CORS origins |
|
||||||
| `UPLOAD_DIR` | string | uploads | Directory for file uploads |
|
| `UPLOAD_DIR` | string | uploads | Directory for file uploads (resolved against the project root) |
|
||||||
| `MAX_UPLOAD_SIZE_MB` | int | 50 | Maximum upload file size in MB |
|
| `MAX_UPLOAD_SIZE_MB` | int | 50 | Maximum upload file size in MB |
|
||||||
| `RATE_LIMIT_LOGIN` | int | 5 | Login requests per minute |
|
| `RATE_LIMIT_LOGIN` | int | 5 | Login requests per minute, per real client IP |
|
||||||
| `RATE_LIMIT_GENERAL` | int | 100 | General requests per minute |
|
| `RATE_LIMIT_GENERAL` | int | 300 | General requests per minute, per real client IP (post-V2.0.0; was 100 in V1.0.x) |
|
||||||
|
| `STATION_CODE` | string | (empty) | **Per-tablet** code identifying the station this Flask client serves. Must match a station created in the admin UI. Empty = the client refuses `/measure/select` with HTTP 503 "Stazione non configurata". |
|
||||||
| `SSL_CERTFILE` | string | (empty) | Path to SSL certificate (production) |
|
| `SSL_CERTFILE` | string | (empty) | Path to SSL certificate (production) |
|
||||||
| `SSL_KEYFILE` | string | (empty) | Path to SSL private key (production) |
|
| `SSL_KEYFILE` | string | (empty) | Path to SSL private key (production) |
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ TieMeasureFlow supports complete internationalization (i18n) with:
|
|||||||
## Directory Structure
|
## Directory Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
client/
|
src/frontend/flask_app/
|
||||||
├── translations/ # Flask-Babel translations
|
├── translations/ # Flask-Babel translations
|
||||||
│ ├── babel.cfg # Extraction config
|
│ ├── babel.cfg # Extraction config
|
||||||
│ ├── it/LC_MESSAGES/
|
│ ├── it/LC_MESSAGES/
|
||||||
@@ -99,7 +99,7 @@ flash(_("Profilo aggiornato con successo"))
|
|||||||
After editing .po files:
|
After editing .po files:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd client
|
cd src/frontend/flask_app
|
||||||
python compile_translations.py
|
python compile_translations.py
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Documentazione TieMeasureFlow
|
||||||
|
|
||||||
|
Indice della documentazione del progetto.
|
||||||
|
|
||||||
|
## Stato e direzione
|
||||||
|
|
||||||
|
| Documento | Scopo |
|
||||||
|
|---|---|
|
||||||
|
| [`architecture/STATO_PROGETTO.md`](architecture/STATO_PROGETTO.md) | Cosa è fatto oggi (V2.0.0). Snapshot del sistema, componenti e capacità. |
|
||||||
|
| [`architecture/ROADMAP.md`](architecture/ROADMAP.md) | Cosa resta da fare. Fasi 2-7 della migrazione rev04 verso V1.1.0/M1 demo cliente. |
|
||||||
|
|
||||||
|
## Riferimenti operativi
|
||||||
|
|
||||||
|
| Documento | Scopo |
|
||||||
|
|---|---|
|
||||||
|
| [`API.md`](API.md) | Riferimento endpoint REST esposti dal backend FastAPI. |
|
||||||
|
| [`DEPLOYMENT.md`](DEPLOYMENT.md) | Guida deploy su VPS (Hostinger/Tielogic con Traefik + Let's Encrypt). |
|
||||||
|
| [`USER_GUIDE.md`](USER_GUIDE.md) | Manuale utente (operatore, maker, metrologist, admin). |
|
||||||
|
| [`I18N_SETUP.md`](I18N_SETUP.md) | Setup e workflow traduzioni (Flask-Babel + Alpine.js). |
|
||||||
|
|
||||||
|
## Piani dettagliati TDD (rev04)
|
||||||
|
|
||||||
|
| Documento | Scopo |
|
||||||
|
|---|---|
|
||||||
|
| [`superpowers/plans/2026-04-17-rev04-master-roadmap.md`](superpowers/plans/2026-04-17-rev04-master-roadmap.md) | Master plan rev04 V1.0.7 → V1.1.0, le 7 fasi e le decisioni aperte. |
|
||||||
|
| [`superpowers/plans/2026-04-17-rev04-phase1-stations.md`](superpowers/plans/2026-04-17-rev04-phase1-stations.md) | Piano TDD dettagliato Fase 1 (stazioni e identità per-tablet). **COMPLETATO.** |
|
||||||
|
|
||||||
|
## Specifiche esterne (binari)
|
||||||
|
|
||||||
|
| Documento | Scopo |
|
||||||
|
|---|---|
|
||||||
|
| [`specs/2026-04-16-schema-sviluppo-rev04.docx`](specs/2026-04-16-schema-sviluppo-rev04.docx) | Schema sviluppo SW TieFlow rev04-2026 fornito dal committente. |
|
||||||
|
|
||||||
|
## Storico
|
||||||
|
|
||||||
|
| Documento | Scopo |
|
||||||
|
|---|---|
|
||||||
|
| [`archive/2026-02-06-piano-implementazione-v1.md`](archive/2026-02-06-piano-implementazione-v1.md) | Piano implementazione V1.0.0 originale (riferimento storico). |
|
||||||
|
|
||||||
|
## Ulteriori riferimenti nel repo
|
||||||
|
|
||||||
|
- [`/CLAUDE.md`](../CLAUDE.md) — guidance per Claude Code (architettura, comandi, pattern critici).
|
||||||
|
- [`/README.md`](../README.md) — entry point del progetto.
|
||||||
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) |
|
| **Subtask** | Individual measurement point with tolerance limits (UTL/UWL/LWL/LTL) |
|
||||||
| **Measurement** | Individual recorded value with automatic pass/fail/warning status |
|
| **Measurement** | Individual recorded value with automatic pass/fail/warning status |
|
||||||
| **Lot/Serial** | Traceability fields linking measurements to physical parts |
|
| **Lot/Serial** | Traceability fields linking measurements to physical parts |
|
||||||
|
| **Station** | A physical measurement post (typically one tablet on a production line). Each station has a unique code (`STATION_CODE`) and a list of assigned recipes |
|
||||||
|
| **Station assignment** | Many-to-many link between a station and the recipes available to the operators using that station's tablet |
|
||||||
|
|
||||||
### Architecture
|
### Architecture
|
||||||
|
|
||||||
@@ -175,23 +177,25 @@ TieMeasureFlow has four primary roles. Users can have multiple roles simultaneou
|
|||||||
|
|
||||||
### Admin
|
### Admin
|
||||||
|
|
||||||
**Purpose:** System administration and user management
|
**Purpose:** System administration, user management and station deployment
|
||||||
|
|
||||||
**Permissions:**
|
**Permissions:**
|
||||||
- Create/edit/delete users
|
- Create/edit/delete users
|
||||||
- Assign roles to users
|
- Assign roles to users
|
||||||
- Regenerate user API keys
|
- Regenerate user API keys
|
||||||
|
- Create/edit/delete stations and assign recipes to them
|
||||||
- Configure system settings
|
- Configure system settings
|
||||||
- Upload company logo
|
- Upload company logo
|
||||||
- Manage CSV export settings
|
- Manage CSV export settings
|
||||||
|
|
||||||
**Access:**
|
**Access:**
|
||||||
- Menu: **Admin** → User Management
|
- Menu: **Admin** → Utenti / Stazioni
|
||||||
- Can see: All users and system settings
|
- Can see: All users, all stations and system settings
|
||||||
|
|
||||||
**Typical Tasks:**
|
**Typical Tasks:**
|
||||||
- Onboard new users
|
- Onboard new users
|
||||||
- Reset lost API keys
|
- Reset lost API keys
|
||||||
|
- Roll out a new tablet: create the matching station, assign recipes, hand the `STATION_CODE` to devops
|
||||||
- Configure locale/format settings
|
- Configure locale/format settings
|
||||||
- Upload company branding
|
- Upload company branding
|
||||||
- Manage system access
|
- Manage system access
|
||||||
@@ -276,6 +280,12 @@ When you edit a recipe (add/remove tasks, change tolerances), a **new version**
|
|||||||
|
|
||||||
## MeasurementTec Workflow
|
## MeasurementTec Workflow
|
||||||
|
|
||||||
|
### Recipes you see are filtered by station
|
||||||
|
|
||||||
|
Each tablet/PC running the Flask client is configured at deployment time with a `STATION_CODE` environment variable (for example `ST-LINEA-A`). Whenever you open **Select Recipe**, the page only lists recipes that the admin has assigned to that station. If your tablet shows fewer recipes than you expect, ask the admin to assign the missing recipes to your station — see **Admin Workflow → Station Management**.
|
||||||
|
|
||||||
|
If the page shows "Stazione non configurata" (HTTP 503), the deployment is missing the `STATION_CODE` setting; this is a deploy-time configuration issue, not something the operator can fix from the UI.
|
||||||
|
|
||||||
### Select a Recipe
|
### Select a Recipe
|
||||||
|
|
||||||
**Method 1: Search**
|
**Method 1: Search**
|
||||||
@@ -503,6 +513,82 @@ If user loses their API key:
|
|||||||
4. New key is generated and displayed
|
4. New key is generated and displayed
|
||||||
5. Provide new key to user (display once only)
|
5. Provide new key to user (display once only)
|
||||||
|
|
||||||
|
### Station Management
|
||||||
|
|
||||||
|
Stations are how TieMeasureFlow enforces "this tablet is responsible for these measurements". Each physical measurement post in the shop floor is modelled as a station; each tablet's Flask client identifies itself through a `STATION_CODE` env var that must match a station's code in the database.
|
||||||
|
|
||||||
|
The page is reachable from the navbar entry **"Stazioni"** (workstation icon) for any user with the admin flag, or directly at `/admin/stations`.
|
||||||
|
|
||||||
|
#### Mental model
|
||||||
|
|
||||||
|
- **One station = one tablet/PC** in the shop floor. The station's identity (`STATION_CODE`) is configured **once at deploy time** in the tablet's `.env`, never changed at runtime.
|
||||||
|
- A station has a list of **assigned recipes**. The operator using that tablet sees exactly that list — no more, no less.
|
||||||
|
- A recipe can be assigned to several stations (e.g. a calibration recipe everyone needs).
|
||||||
|
- A station can be temporarily **disabled** (`active = false`) to take a line offline without losing its history; tablets pointing at a disabled station get HTTP 404 from the recipe endpoint.
|
||||||
|
|
||||||
|
#### Create a Station
|
||||||
|
|
||||||
|
1. Navigate to **Admin** → **Stazioni**
|
||||||
|
2. Click **Nuova Stazione**
|
||||||
|
3. Fill in the modal:
|
||||||
|
- **Codice** (required, unique): `ST-LINEA-A`. This is the value the tablet will set as `STATION_CODE` in its `.env`. Use ASCII letters, digits and hyphens only — no spaces, no accented characters. Once created the code cannot be changed (changing it would break every tablet pointing at it).
|
||||||
|
- **Nome** (required): human-readable description, e.g. `Linea A — Tornitura alberi`.
|
||||||
|
- **Postazione** (optional): physical location, e.g. `Reparto 2 — Cella 3`.
|
||||||
|
- **Note** (optional): free text, useful for tracking who set up the station.
|
||||||
|
- **Attiva** (default checked): uncheck only when retiring the station.
|
||||||
|
4. Click **Crea Stazione**
|
||||||
|
|
||||||
|
Naming convention: prefix every station code with `ST-` and use a stable identifier that survives shop-floor reorganisations.
|
||||||
|
|
||||||
|
#### Assign Recipes to a Station
|
||||||
|
|
||||||
|
1. From the stations table, click the **checklist icon** on the row of the target station
|
||||||
|
2. The "Ricette Assegnate" modal opens with two columns:
|
||||||
|
- **Ricette disponibili** (left): every recipe in the system not yet assigned to this station. Each row has an inline **+ Assegna** button that immediately moves the recipe to the right column.
|
||||||
|
- **Assegnate alla stazione** (right): the list the operator at this station's tablet will see. The **X** button removes the assignment.
|
||||||
|
3. Use the search field at the top to narrow either column by recipe code or name
|
||||||
|
4. Empty-state hints tell you why a column is empty:
|
||||||
|
- "Tutte le ricette sono già assegnate" — nothing more to assign
|
||||||
|
- "Nessuna ricetta nel sistema" — create at least one recipe first (Maker workflow)
|
||||||
|
- "Nessun risultato per il filtro" — clear the search
|
||||||
|
|
||||||
|
The assignment audit trail (`assigned_by`, `assigned_at`) is stored on the server but not currently shown in the UI.
|
||||||
|
|
||||||
|
#### Edit a Station
|
||||||
|
|
||||||
|
1. Click the **pencil icon** (or the row itself) for the target station
|
||||||
|
2. Update **Nome**, **Postazione**, **Note** or the **Attiva** flag
|
||||||
|
3. Click **Salva Modifiche**
|
||||||
|
|
||||||
|
The **Codice** field is read-only on edit because the value is contractually bound to the `STATION_CODE` set on already-deployed tablets. To rename a station you must delete it and create a new one with the new code, then update every affected tablet's `.env`.
|
||||||
|
|
||||||
|
#### Delete a Station
|
||||||
|
|
||||||
|
1. Click the **trash icon** for the station
|
||||||
|
2. Confirm in the modal
|
||||||
|
|
||||||
|
Deletion cascades to **all recipe assignments** for that station. Existing measurements collected by tablets at that station are not affected (measurements are linked to recipe versions, not stations).
|
||||||
|
|
||||||
|
#### The `ST-DEFAULT` station
|
||||||
|
|
||||||
|
The first time `/api/setup/seed` runs, it creates a station called `ST-DEFAULT` and assigns every demo recipe to it. This is intended for single-tablet demos and dev setups where no per-station segmentation is needed.
|
||||||
|
|
||||||
|
In multi-station production deployments you should either:
|
||||||
|
|
||||||
|
- Delete `ST-DEFAULT` once your real stations are configured, or
|
||||||
|
- Disable it (`Attiva` off), to prevent a misconfigured tablet from accidentally inheriting the default assignment set.
|
||||||
|
|
||||||
|
#### Tablet deployment cheat sheet
|
||||||
|
|
||||||
|
For each new physical tablet:
|
||||||
|
|
||||||
|
1. Admin creates the station in the UI (e.g. `ST-LINEA-A`)
|
||||||
|
2. Admin assigns the relevant recipes
|
||||||
|
3. Devops sets `STATION_CODE=ST-LINEA-A` in the tablet's `.env`
|
||||||
|
4. Tablet container starts; the operator opens **Measure → Select Recipe** and sees the curated list
|
||||||
|
|
||||||
|
If step 3 is missed, **Select Recipe** shows the page "Stazione non configurata" — this is the intentional fail-fast behaviour to prevent a tablet from silently falling back to the wrong recipe set.
|
||||||
|
|
||||||
### System Settings
|
### System Settings
|
||||||
|
|
||||||
#### Configure CSV Export
|
#### Configure CSV Export
|
||||||
|
|||||||
@@ -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",
|
"plotly>=5.0.0",
|
||||||
"kaleido>=0.2.0",
|
"kaleido>=0.2.0",
|
||||||
"weasyprint>=62.0",
|
"weasyprint>=62.0",
|
||||||
|
"pdfplumber>=0.10.0",
|
||||||
|
"httpx>=0.27.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Frontend (Flask tablet UI).
|
# 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_maker,
|
||||||
require_measurement_tec,
|
require_measurement_tec,
|
||||||
require_metrologist,
|
require_metrologist,
|
||||||
|
require_supervisor,
|
||||||
require_admin_user,
|
require_admin_user,
|
||||||
)
|
)
|
||||||
from src.backend.api.middleware.logging import AccessLogMiddleware
|
from src.backend.api.middleware.logging import AccessLogMiddleware
|
||||||
@@ -19,6 +20,7 @@ __all__ = [
|
|||||||
"require_maker",
|
"require_maker",
|
||||||
"require_measurement_tec",
|
"require_measurement_tec",
|
||||||
"require_metrologist",
|
"require_metrologist",
|
||||||
|
"require_supervisor",
|
||||||
"require_admin_user",
|
"require_admin_user",
|
||||||
"AccessLogMiddleware",
|
"AccessLogMiddleware",
|
||||||
"RateLimitMiddleware",
|
"RateLimitMiddleware",
|
||||||
|
|||||||
@@ -68,4 +68,5 @@ def require_admin():
|
|||||||
require_maker = require_role("Maker")
|
require_maker = require_role("Maker")
|
||||||
require_measurement_tec = require_role("MeasurementTec")
|
require_measurement_tec = require_role("MeasurementTec")
|
||||||
require_metrologist = require_role("Metrologist")
|
require_metrologist = require_role("Metrologist")
|
||||||
|
require_supervisor = require_role("Supervisor")
|
||||||
require_admin_user = require_admin()
|
require_admin_user = require_admin()
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""File upload/download/delete router for images and PDFs."""
|
"""File upload/download/delete router for images and PDFs."""
|
||||||
import os
|
import logging
|
||||||
import re
|
import re
|
||||||
from pathlib import Path
|
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.api.middleware.api_key import get_current_user, require_maker
|
||||||
from src.backend.models.orm.user import User
|
from src.backend.models.orm.user import User
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/files", tags=["files"])
|
router = APIRouter(prefix="/api/files", tags=["files"])
|
||||||
|
|
||||||
# Allowed MIME types
|
# Allowed MIME types
|
||||||
@@ -65,6 +67,37 @@ def sanitize_filename(filename: str) -> str:
|
|||||||
return filename
|
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)):
|
def generate_thumbnail(image_path: Path, thumbnail_path: Path, max_size: tuple[int, int] = (200, 200)):
|
||||||
"""Generate a thumbnail for an image."""
|
"""Generate a thumbnail for an image."""
|
||||||
try:
|
try:
|
||||||
@@ -73,7 +106,7 @@ def generate_thumbnail(image_path: Path, thumbnail_path: Path, max_size: tuple[i
|
|||||||
img.save(thumbnail_path, quality=85)
|
img.save(thumbnail_path, quality=85)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# If thumbnail generation fails, we continue without it
|
# 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")
|
@router.post("/upload")
|
||||||
@@ -164,30 +197,7 @@ async def get_file(
|
|||||||
|
|
||||||
Requires authentication but no specific role.
|
Requires authentication but no specific role.
|
||||||
"""
|
"""
|
||||||
# Construct full path
|
full_path = resolve_upload_path(file_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",
|
|
||||||
)
|
|
||||||
|
|
||||||
return FileResponse(full_path)
|
return FileResponse(full_path)
|
||||||
|
|
||||||
|
|
||||||
@@ -200,29 +210,7 @@ async def delete_file(
|
|||||||
|
|
||||||
Also deletes associated thumbnail if it exists.
|
Also deletes associated thumbnail if it exists.
|
||||||
"""
|
"""
|
||||||
# Construct full path
|
full_path = resolve_upload_path(file_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",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Delete thumbnail if it exists
|
# Delete thumbnail if it exists
|
||||||
if full_path.parent.name != "thumbnails":
|
if full_path.parent.name != "thumbnails":
|
||||||
|
|||||||
@@ -84,24 +84,18 @@ async def create_measurement_batch(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", response_model=MeasurementListResponse)
|
def _build_measurement_filters(
|
||||||
async def get_measurements(
|
recipe_id: int | None,
|
||||||
recipe_id: int | None = Query(None),
|
version_id: int | None,
|
||||||
version_id: int | None = Query(None),
|
subtask_id: int | None,
|
||||||
subtask_id: int | None = Query(None),
|
measured_by: int | None,
|
||||||
measured_by: int | None = Query(None),
|
lot_number: str | None,
|
||||||
lot_number: str | None = Query(None),
|
serial_number: str | None,
|
||||||
serial_number: str | None = Query(None),
|
date_from: datetime | None,
|
||||||
date_from: datetime | None = Query(None),
|
date_to: datetime | None,
|
||||||
date_to: datetime | None = Query(None),
|
pass_fail: str | None,
|
||||||
pass_fail: str | None = Query(None, pattern="^(pass|warning|fail)$"),
|
) -> list:
|
||||||
page: int = Query(1, ge=1),
|
"""Build SQLAlchemy filter conditions shared by list and CSV export."""
|
||||||
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
|
|
||||||
filters = []
|
filters = []
|
||||||
if recipe_id is not None:
|
if recipe_id is not None:
|
||||||
# Filter by recipe via subquery on RecipeVersion
|
# Filter by recipe via subquery on RecipeVersion
|
||||||
@@ -125,6 +119,37 @@ async def get_measurements(
|
|||||||
filters.append(Measurement.measured_at <= date_to)
|
filters.append(Measurement.measured_at <= date_to)
|
||||||
if pass_fail is not None:
|
if pass_fail is not None:
|
||||||
filters.append(Measurement.pass_fail == pass_fail)
|
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
|
# Build query
|
||||||
query = select(Measurement).where(and_(*filters) if filters else True)
|
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_setting = decimal_result.scalar_one_or_none()
|
||||||
decimal_separator = decimal_setting.setting_value if decimal_setting else "."
|
decimal_separator = decimal_setting.setting_value if decimal_setting else "."
|
||||||
|
|
||||||
# Build filter conditions (same as get_measurements)
|
filters = _build_measurement_filters(
|
||||||
filters = []
|
recipe_id, version_id, subtask_id, measured_by, lot_number,
|
||||||
if recipe_id is not None:
|
serial_number, date_from, date_to, pass_fail,
|
||||||
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)
|
|
||||||
|
|
||||||
# Query all matching measurements
|
# Query all matching measurements
|
||||||
query = select(Measurement).where(and_(*filters) if filters else True)
|
query = select(Measurement).where(and_(*filters) if filters else True)
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
"""Recipe router - CRUD, versioning, barcode lookup."""
|
"""Recipe router - CRUD, versioning, barcode lookup, AI parsing."""
|
||||||
from fastapi import APIRouter, Depends, Query
|
import logging
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.backend.database import get_db
|
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)
|
count = await recipe_service.get_measurement_count(db, recipe_id, version_number)
|
||||||
return {"recipe_id": recipe_id, "version_number": version_number, "measurement_count": count}
|
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 import inspect as sa_inspect, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.backend.api.routers.users import VALID_ROLES
|
||||||
from src.backend.config import settings
|
from src.backend.config import settings
|
||||||
from src.backend.database import Base, engine, async_session_factory
|
from src.backend.database import Base, engine, async_session_factory
|
||||||
from src.backend.models.orm import (
|
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"])
|
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))
|
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):
|
async def setup_page(request: Request):
|
||||||
"""Serve the setup page HTML."""
|
"""Serve the setup page HTML."""
|
||||||
_check_setup_enabled()
|
_check_setup_enabled()
|
||||||
return templates.TemplateResponse("setup/setup.html", {"request": request})
|
return templates.TemplateResponse(request, "setup/setup.html")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/status")
|
@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."""
|
"""Create or update a user. user_id=null creates new, user_id=int updates."""
|
||||||
_check_password(body.password)
|
_check_password(body.password)
|
||||||
|
|
||||||
VALID_ROLES = {"Maker", "MeasurementTec", "Metrologist"}
|
|
||||||
invalid = set(body.roles) - VALID_ROLES
|
invalid = set(body.roles) - VALID_ROLES
|
||||||
if invalid:
|
if invalid:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|||||||
@@ -223,7 +223,10 @@ async def reorder_tasks(
|
|||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(RecipeTask)
|
select(RecipeTask)
|
||||||
.where(RecipeTask.id.in_(data.task_ids))
|
.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()}
|
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"])
|
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])
|
@router.get("", response_model=list[UserResponse])
|
||||||
async def list_users(
|
async def list_users(
|
||||||
@@ -40,12 +44,11 @@ async def create_new_user(
|
|||||||
detail=f"Username '{data.username}' already exists",
|
detail=f"Username '{data.username}' already exists",
|
||||||
)
|
)
|
||||||
# Validate roles
|
# Validate roles
|
||||||
valid_roles = {"Maker", "MeasurementTec", "Metrologist"}
|
|
||||||
for role in data.roles:
|
for role in data.roles:
|
||||||
if role not in valid_roles:
|
if role not in VALID_ROLES:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
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(
|
user = await create_user(
|
||||||
db,
|
db,
|
||||||
@@ -77,12 +80,11 @@ async def update_user(
|
|||||||
update_data = data.model_dump(exclude_unset=True)
|
update_data = data.model_dump(exclude_unset=True)
|
||||||
# Validate roles if provided
|
# Validate roles if provided
|
||||||
if "roles" in update_data:
|
if "roles" in update_data:
|
||||||
valid_roles = {"Maker", "MeasurementTec", "Metrologist"}
|
|
||||||
for role in update_data["roles"]:
|
for role in update_data["roles"]:
|
||||||
if role not in valid_roles:
|
if role not in VALID_ROLES:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
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():
|
for field, value in update_data.items():
|
||||||
setattr(user, field, value)
|
setattr(user, field, value)
|
||||||
|
|||||||
+11
-2
@@ -34,6 +34,10 @@ class Settings(BaseSettings):
|
|||||||
# Setup page (empty = disabled)
|
# Setup page (empty = disabled)
|
||||||
setup_password: str | None = None
|
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
|
@property
|
||||||
def database_url(self) -> str:
|
def database_url(self) -> str:
|
||||||
"""Async MySQL connection string."""
|
"""Async MySQL connection string."""
|
||||||
@@ -57,8 +61,13 @@ class Settings(BaseSettings):
|
|||||||
# Path(__file__) = src/backend/config.py → parents[2] = project root
|
# Path(__file__) = src/backend/config.py → parents[2] = project root
|
||||||
return Path(__file__).resolve().parents[2] / self.upload_dir
|
return Path(__file__).resolve().parents[2] / self.upload_dir
|
||||||
|
|
||||||
# ../../.env reaches the project root from src/backend/.
|
# Always resolve .env against the project root regardless of cwd
|
||||||
model_config = {"env_file": "../../.env", "env_file_encoding": "utf-8", "extra": "ignore"}
|
# (pydantic-settings would otherwise treat the path as cwd-relative).
|
||||||
|
model_config = {
|
||||||
|
"env_file": str(Path(__file__).resolve().parents[2] / ".env"),
|
||||||
|
"env_file_encoding": "utf-8",
|
||||||
|
"extra": "ignore",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|||||||
@@ -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)
|
name: str = Field(..., min_length=1, max_length=255)
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
image_path: Optional[str] = Field(None, max_length=500)
|
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
|
# Optional task-level fields for the initial technical drawing
|
||||||
file_path: Optional[str] = Field(None, max_length=500)
|
file_path: Optional[str] = Field(None, max_length=500)
|
||||||
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
|
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)
|
name: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
image_path: Optional[str] = Field(None, max_length=500)
|
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
|
change_notes: Optional[str] = None
|
||||||
# Task-level fields: saved to the first task of the new version
|
# Task-level fields: saved to the first task of the new version
|
||||||
file_path: Optional[str] = Field(None, max_length=500)
|
file_path: Optional[str] = Field(None, max_length=500)
|
||||||
@@ -55,6 +57,7 @@ class RecipeResponse(BaseModel):
|
|||||||
name: str
|
name: str
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
image_path: Optional[str] = None
|
image_path: Optional[str] = None
|
||||||
|
measurement_interval_minutes: Optional[int] = None
|
||||||
created_by: int
|
created_by: int
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
active: bool
|
active: bool
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ class RecipeSummary(BaseModel):
|
|||||||
code: str
|
code: str
|
||||||
name: str
|
name: str
|
||||||
active: bool
|
active: bool
|
||||||
|
image_path: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class StationWithRecipesResponse(StationResponse):
|
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.measurement import Measurement
|
||||||
from src.backend.models.orm.access_log import AccessLog
|
from src.backend.models.orm.access_log import AccessLog
|
||||||
from src.backend.models.orm.setting import SystemSetting, RecipeVersionAudit
|
from src.backend.models.orm.setting import SystemSetting, RecipeVersionAudit
|
||||||
|
from src.backend.models.orm.station import Station, StationRecipeAssignment
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"User",
|
"User",
|
||||||
@@ -16,4 +17,6 @@ __all__ = [
|
|||||||
"AccessLog",
|
"AccessLog",
|
||||||
"SystemSetting",
|
"SystemSetting",
|
||||||
"RecipeVersionAudit",
|
"RecipeVersionAudit",
|
||||||
|
"Station",
|
||||||
|
"StationRecipeAssignment",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import TYPE_CHECKING, Optional
|
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 sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from src.backend.database import Base
|
from src.backend.database import Base
|
||||||
@@ -24,6 +24,7 @@ class Recipe(Base):
|
|||||||
DateTime, nullable=False, server_default=func.now()
|
DateTime, nullable=False, server_default=func.now()
|
||||||
)
|
)
|
||||||
active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
|
active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
|
||||||
|
measurement_interval_minutes: Mapped[Optional[int]] = mapped_column(SmallInteger, nullable=True)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
versions: Mapped[list["RecipeVersion"]] = relationship(
|
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,
|
name=data.name,
|
||||||
description=data.description,
|
description=data.description,
|
||||||
image_path=data.image_path,
|
image_path=data.image_path,
|
||||||
|
measurement_interval_minutes=data.measurement_interval_minutes,
|
||||||
created_by=user.id,
|
created_by=user.id,
|
||||||
)
|
)
|
||||||
db.add(recipe)
|
db.add(recipe)
|
||||||
@@ -273,6 +274,8 @@ async def create_new_version(
|
|||||||
update_fields["description"] = data.description
|
update_fields["description"] = data.description
|
||||||
if data.image_path is not None:
|
if data.image_path is not None:
|
||||||
update_fields["image_path"] = data.image_path
|
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:
|
if update_fields:
|
||||||
await db.execute(
|
await db.execute(
|
||||||
update(Recipe).where(Recipe.id == recipe_id).values(**update_fields)
|
update(Recipe).where(Recipe.id == recipe_id).values(**update_fields)
|
||||||
@@ -369,6 +372,8 @@ async def update_current_version(
|
|||||||
update_fields["description"] = data.description
|
update_fields["description"] = data.description
|
||||||
if data.image_path is not None:
|
if data.image_path is not None:
|
||||||
update_fields["image_path"] = data.image_path
|
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:
|
if update_fields:
|
||||||
await db.execute(
|
await db.execute(
|
||||||
update(Recipe).where(Recipe.id == recipe_id).values(**update_fields)
|
update(Recipe).where(Recipe.id == recipe_id).values(**update_fields)
|
||||||
|
|||||||
@@ -559,6 +559,19 @@
|
|||||||
return data;
|
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 */
|
/* Check DB status */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
@@ -635,6 +648,7 @@
|
|||||||
try {
|
try {
|
||||||
const data = await apiPost('/init-db', { password: setupPassword });
|
const data = await apiPost('/init-db', { password: setupPassword });
|
||||||
showToast(data.message, 'success');
|
showToast(data.message, 'success');
|
||||||
|
await invalidateClientSession();
|
||||||
await checkStatus();
|
await checkStatus();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showToast(e.message, 'error');
|
showToast(e.message, 'error');
|
||||||
@@ -667,6 +681,7 @@
|
|||||||
admin_display_name: display,
|
admin_display_name: display,
|
||||||
});
|
});
|
||||||
showToast(data.message, 'success');
|
showToast(data.message, 'success');
|
||||||
|
await invalidateClientSession();
|
||||||
// Clear form
|
// Clear form
|
||||||
document.getElementById('admin-username').value = '';
|
document.getElementById('admin-username').value = '';
|
||||||
document.getElementById('admin-password').value = '';
|
document.getElementById('admin-password').value = '';
|
||||||
@@ -690,6 +705,7 @@
|
|||||||
if (data.measurements_created) msg += ', measurements: ' + data.measurements_created;
|
if (data.measurements_created) msg += ', measurements: ' + data.measurements_created;
|
||||||
msg += ')';
|
msg += ')';
|
||||||
showToast(msg, 'success');
|
showToast(msg, 'success');
|
||||||
|
await invalidateClientSession();
|
||||||
await loadUsers();
|
await loadUsers();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showToast(e.message, 'error');
|
showToast(e.message, 'error');
|
||||||
@@ -876,6 +892,7 @@
|
|||||||
if (pwd) body.user_password = pwd;
|
if (pwd) body.user_password = pwd;
|
||||||
var data = await apiPost('/manage-user', body);
|
var data = await apiPost('/manage-user', body);
|
||||||
showToast(data.message, 'success');
|
showToast(data.message, 'success');
|
||||||
|
await invalidateClientSession();
|
||||||
closeModal('modal-user');
|
closeModal('modal-user');
|
||||||
await loadUsers();
|
await loadUsers();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -908,6 +925,7 @@
|
|||||||
new_password: newPwd,
|
new_password: newPwd,
|
||||||
});
|
});
|
||||||
showToast(data.message, 'success');
|
showToast(data.message, 'success');
|
||||||
|
await invalidateClientSession();
|
||||||
closeModal('modal-password');
|
closeModal('modal-password');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showToast(e.message, 'error');
|
showToast(e.message, 'error');
|
||||||
@@ -924,6 +942,7 @@
|
|||||||
user_id: userId,
|
user_id: userId,
|
||||||
});
|
});
|
||||||
showToast(data.message, 'success');
|
showToast(data.message, 'success');
|
||||||
|
await invalidateClientSession();
|
||||||
await loadUsers();
|
await loadUsers();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showToast(e.message, 'error');
|
showToast(e.message, 'error');
|
||||||
|
|||||||
@@ -211,6 +211,43 @@ class TestListMeasurements:
|
|||||||
assert "total" in data
|
assert "total" in data
|
||||||
assert data["total"] >= 3
|
assert data["total"] >= 3
|
||||||
|
|
||||||
|
async def test_measurement_tec_can_list_measurements(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
measurement_tec_user: User,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
):
|
||||||
|
"""Regression: MeasurementTec must be able to list measurements.
|
||||||
|
|
||||||
|
The Flask client renders task_complete.html by calling GET
|
||||||
|
/api/measurements?version_id=X right after the operator finishes a
|
||||||
|
recipe. The endpoint used to require Metrologist, which silently
|
||||||
|
produced an empty riepilogo for every operator without that role.
|
||||||
|
"""
|
||||||
|
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
||||||
|
subtask_id, version_id = await _get_subtask_and_version(
|
||||||
|
client, measurement_tec_user, recipe.id
|
||||||
|
)
|
||||||
|
|
||||||
|
for val in [9.8, 10.0, 10.2]:
|
||||||
|
await client.post(
|
||||||
|
"/api/measurements/",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
json={
|
||||||
|
"subtask_id": subtask_id,
|
||||||
|
"version_id": version_id,
|
||||||
|
"value": val,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = await client.get(
|
||||||
|
f"/api/measurements/?version_id={version_id}",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
data = resp.json()
|
||||||
|
assert data["total"] >= 3
|
||||||
|
|
||||||
async def test_measurement_by_recipe_filter(
|
async def test_measurement_by_recipe_filter(
|
||||||
self,
|
self,
|
||||||
client: AsyncClient,
|
client: AsyncClient,
|
||||||
|
|||||||
@@ -1,12 +1,40 @@
|
|||||||
"""Tests for recipes router (/api/recipes)."""
|
"""Tests for recipes router (/api/recipes)."""
|
||||||
import pytest
|
import pytest
|
||||||
from httpx import AsyncClient
|
from httpx import AsyncClient
|
||||||
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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.models.orm.user import User
|
||||||
from src.backend.tests.conftest import auth_headers, create_test_recipe
|
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:
|
class TestListRecipes:
|
||||||
"""GET /api/recipes tests."""
|
"""GET /api/recipes tests."""
|
||||||
|
|
||||||
@@ -160,18 +188,13 @@ class TestCreateRecipe:
|
|||||||
class TestUpdateRecipe:
|
class TestUpdateRecipe:
|
||||||
"""PUT /api/recipes/{recipe_id} tests."""
|
"""PUT /api/recipes/{recipe_id} tests."""
|
||||||
|
|
||||||
async def test_update_recipe(
|
async def test_update_recipe_in_place(
|
||||||
self,
|
self,
|
||||||
client: AsyncClient,
|
client: AsyncClient,
|
||||||
maker_user: User,
|
maker_user: User,
|
||||||
db_session: AsyncSession,
|
db_session: AsyncSession,
|
||||||
):
|
):
|
||||||
"""Updating a recipe creates a new version (copy-on-write).
|
"""Updating a recipe without measurements updates current version in-place."""
|
||||||
|
|
||||||
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.
|
|
||||||
"""
|
|
||||||
recipe = await create_test_recipe(db_session, maker_user.id)
|
recipe = await create_test_recipe(db_session, maker_user.id)
|
||||||
resp = await client.put(
|
resp = await client.put(
|
||||||
f"/api/recipes/{recipe.id}",
|
f"/api/recipes/{recipe.id}",
|
||||||
@@ -183,7 +206,9 @@ class TestUpdateRecipe:
|
|||||||
)
|
)
|
||||||
assert resp.status_code == 200
|
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(
|
versions_resp = await client.get(
|
||||||
f"/api/recipes/{recipe.id}/versions",
|
f"/api/recipes/{recipe.id}/versions",
|
||||||
headers=auth_headers(maker_user),
|
headers=auth_headers(maker_user),
|
||||||
@@ -191,8 +216,38 @@ class TestUpdateRecipe:
|
|||||||
assert versions_resp.status_code == 200
|
assert versions_resp.status_code == 200
|
||||||
versions = versions_resp.json()
|
versions = versions_resp.json()
|
||||||
version_numbers = [v["version_number"] for v in versions]
|
version_numbers = [v["version_number"] for v in versions]
|
||||||
assert 1 in version_numbers
|
assert version_numbers == [1]
|
||||||
assert 2 in version_numbers
|
|
||||||
|
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:
|
class TestDeleteRecipe:
|
||||||
@@ -222,31 +277,34 @@ class TestRecipeVersioning:
|
|||||||
maker_user: User,
|
maker_user: User,
|
||||||
db_session: AsyncSession,
|
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)
|
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(
|
await client.put(
|
||||||
f"/api/recipes/{recipe.id}",
|
f"/api/recipes/{recipe.id}",
|
||||||
headers=auth_headers(maker_user),
|
headers=auth_headers(maker_user),
|
||||||
json={"change_notes": "Version 2"},
|
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(
|
await client.put(
|
||||||
f"/api/recipes/{recipe.id}",
|
f"/api/recipes/{recipe.id}",
|
||||||
headers=auth_headers(maker_user),
|
headers=auth_headers(maker_user),
|
||||||
json={"change_notes": "Version 3"},
|
json={"change_notes": "Version 3"},
|
||||||
)
|
)
|
||||||
|
db_session.expunge_all()
|
||||||
|
|
||||||
# List versions
|
|
||||||
resp = await client.get(
|
resp = await client.get(
|
||||||
f"/api/recipes/{recipe.id}/versions",
|
f"/api/recipes/{recipe.id}/versions",
|
||||||
headers=auth_headers(maker_user),
|
headers=auth_headers(maker_user),
|
||||||
)
|
)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
versions = resp.json()
|
versions = resp.json()
|
||||||
version_numbers = [v["version_number"] for v in versions]
|
version_numbers = sorted(v["version_number"] for v in versions)
|
||||||
assert 1 in version_numbers
|
assert version_numbers == [1, 2, 3]
|
||||||
assert 2 in version_numbers
|
|
||||||
assert 3 in version_numbers
|
|
||||||
|
|||||||
@@ -189,7 +189,7 @@ class TestReorderTasks:
|
|||||||
"""Maker can reorder tasks by providing task IDs in desired order."""
|
"""Maker can reorder tasks by providing task IDs in desired order."""
|
||||||
recipe = await create_test_recipe(db_session, maker_user.id)
|
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(
|
add_resp = await client.post(
|
||||||
f"/api/recipes/{recipe.id}/tasks",
|
f"/api/recipes/{recipe.id}/tasks",
|
||||||
headers=auth_headers(maker_user),
|
headers=auth_headers(maker_user),
|
||||||
@@ -197,7 +197,11 @@ class TestReorderTasks:
|
|||||||
)
|
)
|
||||||
assert add_resp.status_code == 201
|
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(
|
tasks_resp = await client.get(
|
||||||
f"/api/recipes/{recipe.id}/tasks",
|
f"/api/recipes/{recipe.id}/tasks",
|
||||||
headers=auth_headers(maker_user),
|
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."""
|
"""TieMeasureFlow Client - Flask Entry Point."""
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
from datetime import date
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from flask import Flask, redirect, url_for, session, request
|
from flask import Flask, redirect, url_for, session, request
|
||||||
from flask_babel import Babel
|
from flask_babel import Babel
|
||||||
@@ -63,7 +65,14 @@ def create_app() -> Flask:
|
|||||||
"""Set user's preferred language and store in session."""
|
"""Set user's preferred language and store in session."""
|
||||||
if lang in Config.LANGUAGES:
|
if lang in Config.LANGUAGES:
|
||||||
session["language"] = lang
|
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")
|
@app.template_filter("tojson_attr")
|
||||||
def tojson_attr_filter(value):
|
def tojson_attr_filter(value):
|
||||||
@@ -92,6 +101,8 @@ def create_app() -> Flask:
|
|||||||
"current_language": get_locale(),
|
"current_language": get_locale(),
|
||||||
"languages": Config.LANGUAGES,
|
"languages": Config.LANGUAGES,
|
||||||
"company_logo": session.get("company_logo"),
|
"company_logo": session.get("company_logo"),
|
||||||
|
"auto_logout_minutes": session.get("auto_logout_minutes"),
|
||||||
|
"current_year": date.today().year,
|
||||||
}
|
}
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|||||||
@@ -43,6 +43,28 @@ def user_list():
|
|||||||
return render_template("admin/users.html", users=users)
|
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")
|
@admin_bp.route("/stations")
|
||||||
@login_required
|
@login_required
|
||||||
@admin_required
|
@admin_required
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Authentication blueprint - login, logout, profile."""
|
"""Authentication blueprint - login, logout, profile."""
|
||||||
from functools import wraps
|
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 flask_babel import gettext as _
|
||||||
|
|
||||||
from services.api_client import api_client
|
from services.api_client import api_client
|
||||||
@@ -39,15 +39,32 @@ def role_required(*roles):
|
|||||||
return decorator
|
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"])
|
@auth_bp.route("/login", methods=["GET", "POST"])
|
||||||
def login():
|
def login():
|
||||||
"""Login page and form handler."""
|
"""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"):
|
if session.get("api_key"):
|
||||||
try:
|
return redirect(_post_login_landing())
|
||||||
return redirect(url_for("measure.select_recipe"))
|
|
||||||
except Exception:
|
|
||||||
return redirect(url_for("auth.profile"))
|
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
username = request.form.get("username", "").strip()
|
username = request.form.get("username", "").strip()
|
||||||
@@ -76,20 +93,23 @@ def login():
|
|||||||
if user.get("theme_pref"):
|
if user.get("theme_pref"):
|
||||||
session["theme"] = user["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")
|
settings_resp = api_client.get("/api/settings")
|
||||||
if not settings_resp.get("error"):
|
if not settings_resp.get("error"):
|
||||||
logo_path = settings_resp.get("company_logo_path")
|
logo_path = settings_resp.get("company_logo_path")
|
||||||
if logo_path:
|
if logo_path:
|
||||||
session["company_logo"] = 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")
|
flash(_("Benvenuto, %(name)s!", name=user.get("display_name", username)), "success")
|
||||||
|
|
||||||
# Redirect dopo login
|
# Redirect dopo login (in base al ruolo)
|
||||||
try:
|
return redirect(_post_login_landing())
|
||||||
return redirect(url_for("measure.select_recipe"))
|
|
||||||
except Exception:
|
|
||||||
return redirect(url_for("auth.profile"))
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
flash(_("Errore di connessione al server: %(error)s", error=str(e)), "error")
|
flash(_("Errore di connessione al server: %(error)s", error=str(e)), "error")
|
||||||
@@ -113,6 +133,27 @@ def logout():
|
|||||||
return redirect(url_for("auth.login"))
|
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"])
|
@auth_bp.route("/profile", methods=["GET", "POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def profile():
|
def profile():
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
"""Maker blueprint - recipe creation and editing."""
|
"""Maker blueprint - recipe creation and editing."""
|
||||||
import requests as http_requests
|
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 flask_babel import gettext as _
|
||||||
|
|
||||||
from blueprints.auth import login_required, role_required
|
from blueprints.auth import login_required, role_required
|
||||||
from config import Config
|
from config import Config
|
||||||
from services.api_client import api_client
|
from services.api_client import api_client
|
||||||
|
from services.file_proxy import proxy_file
|
||||||
|
|
||||||
maker_bp = Blueprint("maker", __name__, url_prefix="/maker")
|
maker_bp = Blueprint("maker", __name__, url_prefix="/maker")
|
||||||
|
|
||||||
@@ -380,19 +381,7 @@ def api_upload_file():
|
|||||||
@login_required
|
@login_required
|
||||||
def api_get_file(file_path: str):
|
def api_get_file(file_path: str):
|
||||||
"""Proxy: Serve file from API server (browser can't send X-API-Key)."""
|
"""Proxy: Serve file from API server (browser can't send X-API-Key)."""
|
||||||
api_key = session.get("api_key", "")
|
return proxy_file(file_path)
|
||||||
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"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@maker_bp.route("/api/files/<path:file_path>", methods=["DELETE"])
|
@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), resp.get("status_code", 500)
|
||||||
|
|
||||||
return jsonify(resp), 200
|
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."""
|
"""MeasurementTec blueprint - recipe selection and measurement execution."""
|
||||||
import requests as http_requests
|
|
||||||
|
|
||||||
from flask import (
|
from flask import (
|
||||||
Blueprint, Response, flash, jsonify, redirect, render_template,
|
Blueprint, flash, jsonify, redirect, render_template,
|
||||||
request, session, url_for,
|
request, session, url_for,
|
||||||
)
|
)
|
||||||
from flask_babel import gettext as _
|
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 blueprints.auth import login_required, role_required
|
||||||
from config import Config
|
from config import Config
|
||||||
from services.api_client import api_client
|
from services.api_client import api_client
|
||||||
|
from services.file_proxy import proxy_file
|
||||||
|
|
||||||
measure_bp = Blueprint("measure", __name__)
|
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
|
# Load all task IDs for this recipe (ordered) for auto-advance
|
||||||
recipe_id = task_resp.get("recipe_id")
|
recipe_id = task_resp.get("recipe_id")
|
||||||
all_task_ids = []
|
all_task_ids = []
|
||||||
|
measurement_interval_minutes = None
|
||||||
if recipe_id:
|
if recipe_id:
|
||||||
tasks_resp = api_client.get(f"/api/recipes/{recipe_id}/tasks")
|
tasks_resp = api_client.get(f"/api/recipes/{recipe_id}/tasks")
|
||||||
if isinstance(tasks_resp, list):
|
if isinstance(tasks_resp, list):
|
||||||
sorted_tasks = sorted(tasks_resp, key=lambda t: t.get("order_index", 0))
|
sorted_tasks = sorted(tasks_resp, key=lambda t: t.get("order_index", 0))
|
||||||
all_task_ids = [t["id"] for t in sorted_tasks]
|
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(
|
return render_template(
|
||||||
"measure/task_execute.html",
|
"measure/task_execute.html",
|
||||||
@@ -147,6 +150,7 @@ def task_execute(task_id: int):
|
|||||||
lot_number=lot_number,
|
lot_number=lot_number,
|
||||||
serial_number=serial_number,
|
serial_number=serial_number,
|
||||||
all_task_ids=all_task_ids,
|
all_task_ids=all_task_ids,
|
||||||
|
measurement_interval_minutes=measurement_interval_minutes,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -321,6 +325,34 @@ def save_measurement():
|
|||||||
return jsonify(resp), 201
|
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)
|
# Route: File proxy (browser can't send X-API-Key directly)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -328,16 +360,4 @@ def save_measurement():
|
|||||||
@login_required
|
@login_required
|
||||||
def api_get_file(file_path: str):
|
def api_get_file(file_path: str):
|
||||||
"""Proxy: Serve file from API server (browser can't send X-API-Key)."""
|
"""Proxy: Serve file from API server (browser can't send X-API-Key)."""
|
||||||
api_key = session.get("api_key", "")
|
return proxy_file(file_path)
|
||||||
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"),
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -56,11 +56,21 @@ class APIClient:
|
|||||||
try:
|
try:
|
||||||
error_body = response.json()
|
error_body = response.json()
|
||||||
detail = error_body.get("detail", str(error_body))
|
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):
|
if isinstance(detail, list):
|
||||||
detail = "; ".join(
|
parts = []
|
||||||
e.get("msg", str(e)) for e in detail if isinstance(e, dict)
|
for e in detail:
|
||||||
) or str(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:
|
except Exception:
|
||||||
detail = response.text or f"HTTP {response.status_code}"
|
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
|
Global Transitions
|
||||||
============================================================ */
|
============================================================ */
|
||||||
@@ -151,6 +168,7 @@ textarea:focus-visible {
|
|||||||
|
|
||||||
.tmf-card:hover {
|
.tmf-card:hover {
|
||||||
box-shadow: var(--shadow-md);
|
box-shadow: var(--shadow-md);
|
||||||
|
border-color: var(--scrollbar-thumb);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tmf-card-header {
|
.tmf-card-header {
|
||||||
@@ -250,11 +268,21 @@ textarea:focus-visible {
|
|||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn:active:not(:disabled) {
|
||||||
|
transform: scale(0.97);
|
||||||
|
}
|
||||||
|
|
||||||
.btn:disabled {
|
.btn:disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-lg {
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
border-radius: 0.625rem;
|
||||||
|
}
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
background: var(--color-primary);
|
background: var(--color-primary);
|
||||||
color: #FFFFFF;
|
color: #FFFFFF;
|
||||||
|
|||||||
@@ -1,27 +1,21 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
||||||
<!-- Stylized Caliper Icon for Favicon -->
|
<!-- 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 -->
|
<!-- Badge -->
|
||||||
<rect x="2" y="3" width="5" height="26" rx="1.5" fill="#2563EB"/>
|
<rect x="2" y="2" width="60" height="60" rx="15" fill="url(#tmfBadge)"/>
|
||||||
<rect x="2" y="3" width="19" height="5" rx="1.5" fill="#2563EB"/>
|
|
||||||
<rect x="2" y="24" width="19" height="5" rx="1.5" fill="#2563EB"/>
|
|
||||||
|
|
||||||
<!-- Sliding jaw -->
|
<!-- Measurement end bars -->
|
||||||
<rect x="12" y="8" width="4.5" height="6.5" rx="1" fill="#1E40AF"/>
|
<rect x="14" y="16" width="5" height="32" rx="2.5" fill="#FFFFFF"/>
|
||||||
<rect x="12" y="17.5" width="4.5" height="6.5" rx="1" fill="#1E40AF"/>
|
<rect x="45" y="16" width="5" height="32" rx="2.5" fill="#FFFFFF"/>
|
||||||
|
|
||||||
<!-- Depth rod -->
|
<!-- Double-headed arrow (measured span) -->
|
||||||
<rect x="4" y="14" width="12" height="3.5" rx="1" fill="#3B82F6" opacity="0.55"/>
|
<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"/>
|
||||||
<!-- Scale markings -->
|
<path d="M36 26.5 L41.5 32 L36 37.5" stroke="#FFFFFF" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
<rect x="8" y="5" width="1" height="2.5" rx="0.5" fill="#FFFFFF" opacity="0.65"/>
|
|
||||||
<rect x="11" y="5" width="1" height="2.5" rx="0.5" fill="#FFFFFF" opacity="0.65"/>
|
|
||||||
<rect x="14" y="5" width="1" height="2.5" rx="0.5" fill="#FFFFFF" opacity="0.65"/>
|
|
||||||
<rect x="17" y="5" width="1" height="2.5" rx="0.5" fill="#FFFFFF" opacity="0.65"/>
|
|
||||||
|
|
||||||
<!-- Flow arrow -->
|
|
||||||
<path d="M22 16 C25 16, 26.5 11, 29 11"
|
|
||||||
stroke="#3B82F6" stroke-width="2.2" stroke-linecap="round" fill="none"/>
|
|
||||||
<path d="M27 8.5 L29.5 11 L27 13.5"
|
|
||||||
stroke="#3B82F6" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
|
||||||
</svg>
|
</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">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 340 64" fill="none">
|
||||||
<!-- Stylized Caliper Icon -->
|
<!-- TieMeasureFlow full logo: brand badge + wordmark (standalone asset) -->
|
||||||
<g transform="translate(2, 2)">
|
<defs>
|
||||||
<!-- Caliper outer frame -->
|
<linearGradient id="tmfBadgeFull" x1="0" y1="0" x2="1" y2="1">
|
||||||
<rect x="0" y="4" width="6" height="32" rx="1.5" fill="#2563EB"/>
|
<stop offset="0" stop-color="#3B82F6"/>
|
||||||
<rect x="0" y="4" width="24" height="6" rx="1.5" fill="#2563EB"/>
|
<stop offset="1" stop-color="#1D4ED8"/>
|
||||||
<rect x="0" y="30" width="24" height="6" rx="1.5" fill="#2563EB"/>
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
<!-- Caliper sliding jaw -->
|
<!-- Badge -->
|
||||||
<rect x="14" y="10" width="5" height="8" rx="1" fill="#1E40AF"/>
|
<rect x="2" y="2" width="60" height="60" rx="15" fill="url(#tmfBadgeFull)"/>
|
||||||
<rect x="14" y="22" width="5" height="8" rx="1" fill="#1E40AF"/>
|
<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 -->
|
<!-- Wordmark: single text element with flowing tspans (cannot be clipped/overlap) -->
|
||||||
<rect x="2" y="18" width="16" height="4" rx="1" fill="#3B82F6" opacity="0.6"/>
|
<text x="76" y="42" font-family="Inter, system-ui, -apple-system, sans-serif"
|
||||||
|
font-size="30" letter-spacing="-0.5" fill="#0F172A">
|
||||||
<!-- Scale markings -->
|
<tspan font-weight="700">Tie</tspan><tspan font-weight="600" fill="#2563EB">Measure</tspan><tspan font-weight="700">Flow</tspan>
|
||||||
<rect x="7" y="7" width="1" height="3" rx="0.5" fill="#FFFFFF" opacity="0.7"/>
|
</text>
|
||||||
<rect x="10" y="7" width="1" height="3" rx="0.5" fill="#FFFFFF" opacity="0.7"/>
|
|
||||||
<rect x="13" y="7" width="1" height="3" rx="0.5" fill="#FFFFFF" opacity="0.7"/>
|
|
||||||
<rect x="16" y="7" width="1" height="3" rx="0.5" fill="#FFFFFF" opacity="0.7"/>
|
|
||||||
<rect x="19" y="7" width="1" height="3" rx="0.5" fill="#FFFFFF" opacity="0.7"/>
|
|
||||||
|
|
||||||
<!-- Flow arrow (smooth curve) -->
|
|
||||||
<path d="M26 20 C30 20, 32 14, 36 14 C40 14, 40 20, 36 20"
|
|
||||||
stroke="#2563EB" stroke-width="2.5" stroke-linecap="round" fill="none"/>
|
|
||||||
<path d="M34 17 L37 20 L34 23" stroke="#2563EB" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<!-- Text: TieMeasureFlow -->
|
|
||||||
<g transform="translate(48, 0)">
|
|
||||||
<!-- "Tie" in bold primary -->
|
|
||||||
<text x="0" y="28" font-family="Inter, system-ui, sans-serif" font-size="20" font-weight="700" fill="#2563EB"
|
|
||||||
letter-spacing="-0.5">Tie</text>
|
|
||||||
<!-- "Measure" in medium dark -->
|
|
||||||
<text x="32" y="28" font-family="Inter, system-ui, sans-serif" font-size="20" font-weight="500" fill="#1E40AF"
|
|
||||||
letter-spacing="-0.5">Measure</text>
|
|
||||||
<!-- "Flow" in bold primary -->
|
|
||||||
<text x="120" y="28" font-family="Inter, system-ui, sans-serif" font-size="20" font-weight="700" fill="#2563EB"
|
|
||||||
letter-spacing="-0.5">Flow</text>
|
|
||||||
</g>
|
|
||||||
</svg>
|
</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.
|
* 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() {
|
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;
|
var stored = null;
|
||||||
try {
|
try {
|
||||||
stored = localStorage.getItem(STORAGE_KEY);
|
stored = localStorage.getItem(STORAGE_KEY);
|
||||||
@@ -70,6 +82,7 @@
|
|||||||
toggle: function () {
|
toggle: function () {
|
||||||
this.dark = !this.dark;
|
this.dark = !this.dark;
|
||||||
this._apply();
|
this._apply();
|
||||||
|
this._persist();
|
||||||
},
|
},
|
||||||
|
|
||||||
set: function (dark) {
|
set: function (dark) {
|
||||||
@@ -77,6 +90,24 @@
|
|||||||
this._apply();
|
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 () {
|
_apply: function () {
|
||||||
if (this.dark) {
|
if (this.dark) {
|
||||||
document.documentElement.classList.add('dark');
|
document.documentElement.classList.add('dark');
|
||||||
|
|||||||
@@ -671,6 +671,53 @@ function annotationEditor() {
|
|||||||
return rect;
|
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
|
// Toolbar / mode switching
|
||||||
// ----------------------------------------------------------------
|
// ----------------------------------------------------------------
|
||||||
@@ -678,7 +725,7 @@ function annotationEditor() {
|
|||||||
/**
|
/**
|
||||||
* Set the current interaction mode.
|
* Set the current interaction mode.
|
||||||
*
|
*
|
||||||
* @param {'select'|'marker'|'arrow'|'rect'} mode
|
* @param {'select'|'marker'|'arrow'|'rect'|'text'} mode
|
||||||
*/
|
*/
|
||||||
setMode(mode) {
|
setMode(mode) {
|
||||||
this.activeMode = mode;
|
this.activeMode = mode;
|
||||||
@@ -731,6 +778,13 @@ function annotationEditor() {
|
|||||||
self.addMarker(pointer.x, pointer.y);
|
self.addMarker(pointer.x, pointer.y);
|
||||||
self.setMode('select');
|
self.setMode('select');
|
||||||
window.dispatchEvent(new CustomEvent('anno-mode-changed', { detail: { mode: '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') {
|
} else if (self.activeMode === 'arrow') {
|
||||||
isDrawing = true;
|
isDrawing = true;
|
||||||
drawStartX = pointer.x;
|
drawStartX = pointer.x;
|
||||||
@@ -902,7 +956,7 @@ function annotationEditor() {
|
|||||||
setupKeyboard() {
|
setupKeyboard() {
|
||||||
var self = this;
|
var self = this;
|
||||||
|
|
||||||
document.addEventListener('keydown', function (e) {
|
this._onKeyDown = function (e) {
|
||||||
// Skip when typing in form fields
|
// Skip when typing in form fields
|
||||||
var tag = e.target.tagName;
|
var tag = e.target.tagName;
|
||||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
|
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
|
||||||
@@ -916,7 +970,8 @@ function annotationEditor() {
|
|||||||
self.canvas.discardActiveObject();
|
self.canvas.discardActiveObject();
|
||||||
self.canvas.renderAll();
|
self.canvas.renderAll();
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
|
document.addEventListener('keydown', this._onKeyDown);
|
||||||
},
|
},
|
||||||
|
|
||||||
// ----------------------------------------------------------------
|
// ----------------------------------------------------------------
|
||||||
@@ -1023,6 +1078,13 @@ function annotationEditor() {
|
|||||||
if (obj.arrowLineDash) data.lineDash = obj.arrowLineDash;
|
if (obj.arrowLineDash) data.lineDash = obj.arrowLineDash;
|
||||||
if (obj.areaLineDash) data.lineDash = obj.areaLineDash;
|
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
|
// Arrow-specific: store original endpoints
|
||||||
if (obj.objectType === 'arrow') {
|
if (obj.objectType === 'arrow') {
|
||||||
data.x1 = obj.arrowX1 != null ? Math.round(obj.arrowX1 * 100) / 100 : null;
|
data.x1 = obj.arrowX1 != null ? Math.round(obj.arrowX1 * 100) / 100 : null;
|
||||||
@@ -1120,6 +1182,11 @@ function annotationEditor() {
|
|||||||
this.currentColor = savedColor3;
|
this.currentColor = savedColor3;
|
||||||
this.currentStrokeWidth = savedWidth2;
|
this.currentStrokeWidth = savedWidth2;
|
||||||
this.currentLineDash = savedDash2;
|
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) {
|
if (this._onImageLoaded) {
|
||||||
window.removeEventListener('image-loaded', this._onImageLoaded);
|
window.removeEventListener('image-loaded', this._onImageLoaded);
|
||||||
}
|
}
|
||||||
|
if (this._onKeyDown) {
|
||||||
|
document.removeEventListener('keydown', this._onKeyDown);
|
||||||
|
}
|
||||||
if (this.canvas) {
|
if (this.canvas) {
|
||||||
this.canvas.dispose();
|
this.canvas.dispose();
|
||||||
this.canvas = null;
|
this.canvas = null;
|
||||||
|
|||||||
@@ -436,6 +436,8 @@ function _drawEditorAnnotations(ctx, data, scale) {
|
|||||||
_drawPreviewArrow(ctx, obj, annoScale);
|
_drawPreviewArrow(ctx, obj, annoScale);
|
||||||
} else if (obj.type === 'area') {
|
} else if (obj.type === 'area') {
|
||||||
_drawPreviewArea(ctx, obj, annoScale);
|
_drawPreviewArea(ctx, obj, annoScale);
|
||||||
|
} else if (obj.type === 'text') {
|
||||||
|
_drawPreviewText(ctx, obj, annoScale);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -514,6 +516,24 @@ function _drawPreviewArea(ctx, obj, scale) {
|
|||||||
ctx.setLineDash([]);
|
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).
|
* Draw annotations in the legacy format (markers: point/rectangle).
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -163,6 +163,17 @@ function numpad() {
|
|||||||
* @param {KeyboardEvent} e - The keyboard event
|
* @param {KeyboardEvent} e - The keyboard event
|
||||||
*/
|
*/
|
||||||
handleKeydown(e) {
|
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
|
// Number keys
|
||||||
if (e.key >= '0' && e.key <= '9') {
|
if (e.key >= '0' && e.key <= '9') {
|
||||||
e.preventDefault();
|
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 class="absolute inset-0 bg-black/50" @click="closeAssignmentsModal()"></div>
|
||||||
<div x-show="showAssignments"
|
<div x-show="showAssignments"
|
||||||
x-transition
|
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 class="flex items-center justify-between px-6 py-4 border-b border-[var(--border-color)]">
|
||||||
<div>
|
<div>
|
||||||
@@ -236,57 +236,88 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="px-6 py-4 space-y-4">
|
<div class="px-6 py-4 space-y-4">
|
||||||
<!-- Add recipe -->
|
|
||||||
<div class="flex items-end gap-2">
|
<!-- Search filter -->
|
||||||
<div class="flex-1">
|
<div class="relative">
|
||||||
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Aggiungi ricetta') }}</label>
|
<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">
|
||||||
<select x-model="recipeToAdd"
|
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||||
class="w-full px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)]
|
</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">
|
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>
|
</div>
|
||||||
<button @click="assignRecipe()"
|
|
||||||
:disabled="!recipeToAdd || saving"
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
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">
|
<!-- 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') }}
|
{{ _('Assegna') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</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 list -->
|
<!-- Assigned recipes (right column) -->
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-sm font-semibold text-[var(--text-primary)] mb-2">
|
<h3 class="text-sm font-semibold text-[var(--text-primary)] mb-2 flex items-center gap-2">
|
||||||
{{ _('Ricette correntemente assegnate') }} (<span x-text="assignedRecipes.length"></span>)
|
<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>
|
</h3>
|
||||||
<div class="border border-[var(--border-color)] rounded-lg divide-y divide-[var(--border-color)] max-h-96 overflow-y-auto">
|
<div class="border border-[var(--border-color)] rounded-lg p-2 space-y-2 h-96 overflow-y-auto">
|
||||||
<template x-for="r in assignedRecipes" :key="r.id">
|
<template x-for="r in filteredAssignedRecipes" :key="r.id">
|
||||||
<div class="flex items-center justify-between px-3 py-2 hover:bg-[var(--bg-secondary)] transition-colors">
|
<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>
|
<div class="min-w-0 flex-1">
|
||||||
<span class="font-mono text-xs font-semibold text-[var(--text-primary)]" x-text="r.code"></span>
|
<div class="font-mono text-xs font-semibold text-[var(--text-primary)] truncate" x-text="r.code"></div>
|
||||||
<span class="ml-2 text-sm text-[var(--text-secondary)]" x-text="r.name"></span>
|
<div class="text-xs text-[var(--text-secondary)] truncate" x-text="r.name"></div>
|
||||||
</div>
|
</div>
|
||||||
<button @click="unassignRecipe(r.id)"
|
<button @click="unassignRecipe(r.id)"
|
||||||
:disabled="saving"
|
: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"
|
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') }}'">
|
:title="'{{ _('Rimuovi assegnazione') }}'">
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
<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"/>
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template x-if="assignedRecipes.length === 0">
|
<template x-if="filteredAssignedRecipes.length === 0">
|
||||||
<div class="px-3 py-6 text-center text-sm text-[var(--text-secondary)]">
|
<div class="px-3 py-6 text-center text-sm text-[var(--text-secondary)]"
|
||||||
{{ _('Nessuna ricetta assegnata') }}
|
x-text="assignedEmptyMessage"></div>
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
<template x-if="errorMsg">
|
<template x-if="errorMsg">
|
||||||
<div class="px-3 py-2 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800">
|
<div class="px-3 py-2 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800">
|
||||||
<p class="text-sm text-red-700 dark:text-red-300" x-text="errorMsg"></p>
|
<p class="text-sm text-red-700 dark:text-red-300" x-text="errorMsg"></p>
|
||||||
@@ -351,7 +382,7 @@ function stationManagement(initialStations, initialRecipes) {
|
|||||||
deleteTarget: null,
|
deleteTarget: null,
|
||||||
assignmentStation: null,
|
assignmentStation: null,
|
||||||
assignedRecipes: [],
|
assignedRecipes: [],
|
||||||
recipeToAdd: '',
|
recipeSearch: '',
|
||||||
form: {
|
form: {
|
||||||
code: '',
|
code: '',
|
||||||
name: '',
|
name: '',
|
||||||
@@ -380,6 +411,32 @@ function stationManagement(initialStations, initialRecipes) {
|
|||||||
return this.allRecipes.filter(r => !assignedIds.has(r.id) && r.active !== false);
|
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() {
|
openCreateModal() {
|
||||||
this.isEditing = false;
|
this.isEditing = false;
|
||||||
this.editingId = null;
|
this.editingId = null;
|
||||||
@@ -477,7 +534,7 @@ function stationManagement(initialStations, initialRecipes) {
|
|||||||
});
|
});
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
const result = await resp.json().catch(() => ({}));
|
const result = await resp.json().catch(() => ({}));
|
||||||
alert(result.detail || '{{ _("Errore nell\'eliminazione") }}');
|
alert(result.detail || "{{ _('Errore nella eliminazione') }}");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.stations = this.stations.filter(s => s.id !== this.deleteTarget.id);
|
this.stations = this.stations.filter(s => s.id !== this.deleteTarget.id);
|
||||||
@@ -493,7 +550,7 @@ function stationManagement(initialStations, initialRecipes) {
|
|||||||
async openAssignmentsModal(station) {
|
async openAssignmentsModal(station) {
|
||||||
this.assignmentStation = station;
|
this.assignmentStation = station;
|
||||||
this.assignedRecipes = [];
|
this.assignedRecipes = [];
|
||||||
this.recipeToAdd = '';
|
this.recipeSearch = '';
|
||||||
this.errorMsg = '';
|
this.errorMsg = '';
|
||||||
this.showAssignments = true;
|
this.showAssignments = true;
|
||||||
try {
|
try {
|
||||||
@@ -513,27 +570,28 @@ function stationManagement(initialStations, initialRecipes) {
|
|||||||
this.showAssignments = false;
|
this.showAssignments = false;
|
||||||
this.assignmentStation = null;
|
this.assignmentStation = null;
|
||||||
this.assignedRecipes = [];
|
this.assignedRecipes = [];
|
||||||
|
this.recipeSearch = '';
|
||||||
this.errorMsg = '';
|
this.errorMsg = '';
|
||||||
},
|
},
|
||||||
|
|
||||||
async assignRecipe() {
|
async assignRecipe(recipeId) {
|
||||||
if (!this.recipeToAdd || !this.assignmentStation) return;
|
if (!recipeId || !this.assignmentStation) return;
|
||||||
|
const id = parseInt(recipeId, 10);
|
||||||
this.saving = true;
|
this.saving = true;
|
||||||
this.errorMsg = '';
|
this.errorMsg = '';
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`/admin/api/stations/${this.assignmentStation.id}/recipes`, {
|
const resp = await fetch(`/admin/api/stations/${this.assignmentStation.id}/recipes`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
|
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();
|
const result = await resp.json();
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
this.errorMsg = result.detail || '{{ _("Errore nell\'assegnazione") }}';
|
this.errorMsg = result.detail || "{{ _('Errore nella assegnazione') }}";
|
||||||
return;
|
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 });
|
if (recipe) this.assignedRecipes.push({ id: recipe.id, code: recipe.code, name: recipe.name, active: recipe.active });
|
||||||
this.recipeToAdd = '';
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.errorMsg = '{{ _("Errore di connessione al server") }}';
|
this.errorMsg = '{{ _("Errore di connessione al server") }}';
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -78,7 +78,8 @@
|
|||||||
:class="{
|
:class="{
|
||||||
'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300': role === 'Maker',
|
'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-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>
|
x-text="role"></span>
|
||||||
</template>
|
</template>
|
||||||
@@ -233,6 +234,11 @@
|
|||||||
class="rounded border-[var(--border-color)] text-primary focus:ring-primary/30">
|
class="rounded border-[var(--border-color)] text-primary focus:ring-primary/30">
|
||||||
<span class="text-sm text-[var(--text-primary)]">Metrologist</span>
|
<span class="text-sm text-[var(--text-primary)]">Metrologist</span>
|
||||||
</label>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -307,8 +313,8 @@
|
|||||||
x-text="toggleUser?.active ? '{{ _('Conferma Disattivazione') }}' : '{{ _('Conferma Riattivazione') }}'"></h3>
|
x-text="toggleUser?.active ? '{{ _('Conferma Disattivazione') }}' : '{{ _('Conferma Riattivazione') }}'"></h3>
|
||||||
<p class="text-sm text-[var(--text-secondary)] mb-4">
|
<p class="text-sm text-[var(--text-secondary)] mb-4">
|
||||||
<span x-text="toggleUser?.active
|
<span x-text="toggleUser?.active
|
||||||
? '{{ _('Sei sicuro di voler disattivare l\'utente') }}'
|
? "{{ _('Sei sicuro di voler disattivare l\'utente') }}"
|
||||||
: '{{ _('Sei sicuro di voler riattivare l\'utente') }}'"></span>
|
: "{{ _('Sei sicuro di voler riattivare l\'utente') }}""></span>
|
||||||
<strong x-text="toggleUser?.username"></strong>?
|
<strong x-text="toggleUser?.username"></strong>?
|
||||||
</p>
|
</p>
|
||||||
<div class="flex justify-end gap-3">
|
<div class="flex justify-end gap-3">
|
||||||
|
|||||||
@@ -2,20 +2,26 @@
|
|||||||
{% block title %}Login — TieMeasureFlow{% endblock %}
|
{% block title %}Login — TieMeasureFlow{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% 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="relative min-h-screen flex items-center justify-center overflow-hidden bg-[var(--bg-primary)] px-4 sm:px-6 lg:px-8">
|
||||||
<div class="max-w-md w-full space-y-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 -->
|
<!-- 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 -->
|
<!-- Logo -->
|
||||||
<div class="text-center mb-8">
|
<div class="text-center mb-8">
|
||||||
<img src="{{ url_for('static', filename='img/tmflow-logo.svg') }}"
|
<div class="mx-auto mb-4 inline-flex justify-center text-slate-900 dark:text-white">
|
||||||
alt="TieMeasureFlow Logo"
|
{% set logo_class = 'h-14 w-14' %}
|
||||||
class="mx-auto h-16 w-auto mb-4"
|
{% set wordmark_class = 'text-3xl' %}
|
||||||
onerror="this.style.display='none'">
|
{% set logo_id = 'login' %}
|
||||||
<h2 class="text-3xl font-bold text-slate-900 dark:text-white mb-2">
|
{% include 'components/_app_logo.html' %}
|
||||||
TieMeasureFlow
|
</div>
|
||||||
</h2>
|
<p class="text-sm text-[var(--text-secondary)]">
|
||||||
<p class="text-sm text-slate-600 dark:text-slate-400">
|
|
||||||
{{ _('Accedi al sistema') }}
|
{{ _('Accedi al sistema') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -25,12 +31,12 @@
|
|||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
<!-- Username -->
|
<!-- Username -->
|
||||||
<div>
|
<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') }}
|
{{ _('Username') }}
|
||||||
</label>
|
</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<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" />
|
<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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
@@ -39,36 +45,52 @@
|
|||||||
name="username"
|
name="username"
|
||||||
required
|
required
|
||||||
autofocus
|
autofocus
|
||||||
|
autocomplete="username"
|
||||||
placeholder="{{ _('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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Password -->
|
<!-- Password -->
|
||||||
<div>
|
<div x-data="{ show: false }">
|
||||||
<label for="password" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
<label for="password" class="tmf-label">
|
||||||
{{ _('Password') }}
|
{{ _('Password') }}
|
||||||
</label>
|
</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<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" />
|
<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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<input type="password"
|
<input :type="show ? 'text' : 'password'"
|
||||||
|
type="password"
|
||||||
id="password"
|
id="password"
|
||||||
name="password"
|
name="password"
|
||||||
required
|
required
|
||||||
|
autocomplete="current-password"
|
||||||
placeholder="{{ _('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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Submit Button -->
|
<!-- Submit Button -->
|
||||||
<div>
|
<div>
|
||||||
<button type="submit"
|
<button type="submit" class="btn btn-primary btn-lg w-full shadow-sm">
|
||||||
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" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<svg class="h-5 w-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1" />
|
<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>
|
</svg>
|
||||||
{{ _('Accedi') }}
|
{{ _('Accedi') }}
|
||||||
@@ -78,7 +100,7 @@
|
|||||||
|
|
||||||
<!-- Help Text -->
|
<!-- Help Text -->
|
||||||
<div class="mt-6 text-center">
|
<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?') }}
|
{{ _('Hai dimenticato la password?') }}
|
||||||
<br>
|
<br>
|
||||||
{{ _('Contatta l\'amministratore') }}
|
{{ _('Contatta l\'amministratore') }}
|
||||||
@@ -87,8 +109,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<div class="text-center text-xs text-slate-500 dark:text-slate-400">
|
<div class="text-center text-xs text-[var(--text-muted)]">
|
||||||
<p>TieMeasureFlow © 2025 - {{ _('Sistema di misurazione industriale') }}</p>
|
<p>TieMeasureFlow © {{ current_year }} - {{ _('Sistema di misurazione industriale') }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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
|
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' %}
|
{% 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
|
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 %}
|
{% else %}
|
||||||
bg-slate-100 dark:bg-slate-700 text-slate-800 dark:text-slate-200 border border-slate-200 dark:border-slate-600
|
bg-slate-100 dark:bg-slate-700 text-slate-800 dark:text-slate-200 border border-slate-200 dark:border-slate-600
|
||||||
{% endif %}">
|
{% endif %}">
|
||||||
|
|||||||
@@ -24,16 +24,19 @@
|
|||||||
<!-- Theme CSS Variables -->
|
<!-- Theme CSS Variables -->
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/themes.css') }}">
|
<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) -->
|
<!-- Alpine.js Theme Init (before Alpine loads) -->
|
||||||
<script src="{{ url_for('static', filename='js/alpine-init.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/alpine-init.js') }}"></script>
|
||||||
|
|
||||||
{% block extra_head %}{% endblock %}
|
{% block extra_head %}{% endblock %}
|
||||||
</head>
|
</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 -->
|
<!-- 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 -->
|
<!-- Navbar -->
|
||||||
{% include "components/navbar.html" %}
|
{% include "components/navbar.html" %}
|
||||||
@@ -98,21 +101,91 @@
|
|||||||
{% endwith %}
|
{% endwith %}
|
||||||
|
|
||||||
<!-- Main Content -->
|
<!-- Main Content -->
|
||||||
<main class="flex-1">
|
<main class="{% block main_class %}flex-1{% endblock %}">
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
{% block footer %}
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<footer class="py-4 text-center border-t border-[var(--border-color)] transition-colors duration-300">
|
<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">
|
<p class="text-xs text-steel dark:text-steel-light tracking-wide">
|
||||||
Powered by <span class="font-semibold">TieMeasureFlow</span> — Tielogic
|
Powered by <span class="font-semibold">TieMeasureFlow</span> — Tielogic
|
||||||
</p>
|
</p>
|
||||||
</footer>
|
</footer>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% block extra_js %}{% endblock %}
|
{% 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 -->
|
<!-- 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>
|
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||||
</body>
|
</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 -->
|
<!-- 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"
|
<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="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div class="flex items-center justify-between h-14">
|
<div class="flex items-center justify-between h-14">
|
||||||
|
|
||||||
<!-- Left: Logo -->
|
<!-- Left: Logo -->
|
||||||
<div class="flex items-center gap-3">
|
<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 %}
|
{% if company_logo %}
|
||||||
<img src="{{ url_for('static', filename='img/' ~ company_logo) }}" alt="Logo" class="h-8 w-auto"
|
<img src="{{ url_for('static', filename='img/' ~ company_logo) }}" alt="Logo" class="h-10 w-auto"
|
||||||
onerror="this.src='{{ url_for('static', filename='img/tmflow-logo.svg') }}'">
|
onerror="this.style.display='none'">
|
||||||
{% else %}
|
{% else %}
|
||||||
<img src="{{ url_for('static', filename='img/tmflow-logo.svg') }}"
|
{% set logo_class = 'h-9 w-9' %}
|
||||||
alt="TieMeasureFlow"
|
{% set wordmark_class = 'text-xl' %}
|
||||||
class="h-8 w-auto">
|
{% set logo_id = 'nav' %}
|
||||||
|
{% include 'components/_app_logo.html' %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -21,89 +62,7 @@
|
|||||||
<!-- Center: Navigation Links (Desktop) -->
|
<!-- Center: Navigation Links (Desktop) -->
|
||||||
{% if current_user %}
|
{% if current_user %}
|
||||||
<div class="hidden md:flex items-center gap-1">
|
<div class="hidden md:flex items-center gap-1">
|
||||||
|
{{ nav_links() }}
|
||||||
{# MeasurementTec: Misure #}
|
|
||||||
{% if current_user.get('roles') and 'MeasurementTec' in current_user.roles %}
|
|
||||||
<a href="{{ url_for('measure.select_recipe') }}"
|
|
||||||
class="nav-link group flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium
|
|
||||||
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
|
|
||||||
transition-colors duration-200
|
|
||||||
{% if request.endpoint and request.endpoint.startswith('measure.') %}
|
|
||||||
text-primary bg-primary-50 dark:bg-primary-900/20
|
|
||||||
{% endif %}">
|
|
||||||
<!-- Clipboard Icon -->
|
|
||||||
<svg class="w-4.5 h-4.5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"/>
|
|
||||||
</svg>
|
|
||||||
<span>{{ _('Misure') }}</span>
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{# Maker: Ricette #}
|
|
||||||
{% if current_user.get('roles') and 'Maker' in current_user.roles %}
|
|
||||||
<a href="{{ url_for('maker.recipe_list') }}"
|
|
||||||
class="nav-link group flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium
|
|
||||||
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
|
|
||||||
transition-colors duration-200
|
|
||||||
{% if request.endpoint and request.endpoint.startswith('maker.') %}
|
|
||||||
text-primary bg-primary-50 dark:bg-primary-900/20
|
|
||||||
{% endif %}">
|
|
||||||
<!-- Edit/Pencil Icon -->
|
|
||||||
<svg class="w-4.5 h-4.5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
|
|
||||||
</svg>
|
|
||||||
<span>{{ _('Ricette') }}</span>
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{# Metrologist: Statistiche #}
|
|
||||||
{% if current_user.get('roles') and 'Metrologist' in current_user.roles %}
|
|
||||||
<a href="{{ url_for('statistics.dashboard') }}"
|
|
||||||
class="nav-link group flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium
|
|
||||||
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
|
|
||||||
transition-colors duration-200
|
|
||||||
{% if request.endpoint and request.endpoint.startswith('statistics.') %}
|
|
||||||
text-primary bg-primary-50 dark:bg-primary-900/20
|
|
||||||
{% endif %}">
|
|
||||||
<!-- Chart Icon -->
|
|
||||||
<svg class="w-4.5 h-4.5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/>
|
|
||||||
</svg>
|
|
||||||
<span>{{ _('Statistiche') }}</span>
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{# Admin: Utenti #}
|
|
||||||
{% if current_user.get('is_admin') %}
|
|
||||||
<a href="{{ url_for('admin.user_list') }}"
|
|
||||||
class="nav-link group flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium
|
|
||||||
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
|
|
||||||
transition-colors duration-200
|
|
||||||
{% if request.endpoint == 'admin.user_list' %}
|
|
||||||
text-primary bg-primary-50 dark:bg-primary-900/20
|
|
||||||
{% endif %}">
|
|
||||||
<!-- Users Icon -->
|
|
||||||
<svg class="w-4.5 h-4.5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"/>
|
|
||||||
</svg>
|
|
||||||
<span>{{ _('Utenti') }}</span>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a href="{{ url_for('admin.station_list') }}"
|
|
||||||
class="nav-link group flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium
|
|
||||||
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
|
|
||||||
transition-colors duration-200
|
|
||||||
{% if request.endpoint == 'admin.station_list' %}
|
|
||||||
text-primary bg-primary-50 dark:bg-primary-900/20
|
|
||||||
{% endif %}">
|
|
||||||
<!-- Station / Workstation Icon -->
|
|
||||||
<svg class="w-4.5 h-4.5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25m18 0A2.25 2.25 0 0018.75 3H5.25A2.25 2.25 0 003 5.25m18 0V12a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 12V5.25"/>
|
|
||||||
</svg>
|
|
||||||
<span>{{ _('Stazioni') }}</span>
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
@@ -112,24 +71,17 @@
|
|||||||
|
|
||||||
<!-- Language Toggle -->
|
<!-- Language Toggle -->
|
||||||
<div class="flex items-center border border-[var(--border-color)] rounded-lg overflow-hidden">
|
<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
|
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
|
bg-primary text-white
|
||||||
{% else %}
|
{% else %}
|
||||||
text-[var(--text-secondary)] hover:bg-[var(--bg-secondary)]
|
text-[var(--text-secondary)] hover:bg-[var(--bg-secondary)]
|
||||||
{% endif %}">
|
{% endif %}">
|
||||||
IT
|
{{ lang_code|upper }}
|
||||||
</a>
|
|
||||||
<a href="{{ url_for('set_language', lang='en') }}"
|
|
||||||
class="px-2 py-1.5 text-xs font-semibold transition-colors duration-200
|
|
||||||
{% if current_language == 'en' %}
|
|
||||||
bg-primary text-white
|
|
||||||
{% else %}
|
|
||||||
text-[var(--text-secondary)] hover:bg-[var(--bg-secondary)]
|
|
||||||
{% endif %}">
|
|
||||||
EN
|
|
||||||
</a>
|
</a>
|
||||||
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Theme Toggle -->
|
<!-- Theme Toggle -->
|
||||||
@@ -199,6 +151,15 @@
|
|||||||
|
|
||||||
<div class="border-t border-[var(--border-color)] my-1"></div>
|
<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') }}"
|
<a href="{{ url_for('auth.logout') }}"
|
||||||
class="flex items-center gap-2.5 px-4 py-2.5 text-sm text-measure-fail
|
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">
|
hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors">
|
||||||
@@ -207,6 +168,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
{{ _('Logout') }}
|
{{ _('Logout') }}
|
||||||
</a>
|
</a>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -241,66 +203,7 @@
|
|||||||
x-cloak
|
x-cloak
|
||||||
class="md:hidden border-t border-[var(--border-color)] bg-[var(--bg-card)]">
|
class="md:hidden border-t border-[var(--border-color)] bg-[var(--bg-card)]">
|
||||||
<div class="px-4 py-3 space-y-1">
|
<div class="px-4 py-3 space-y-1">
|
||||||
|
{{ nav_links(mobile=true) }}
|
||||||
{% if current_user.get('roles') and 'MeasurementTec' in current_user.roles %}
|
|
||||||
<a href="{{ url_for('measure.select_recipe') }}"
|
|
||||||
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium
|
|
||||||
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
|
|
||||||
transition-colors">
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"/>
|
|
||||||
</svg>
|
|
||||||
{{ _('Misure') }}
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if current_user.get('roles') and 'Maker' in current_user.roles %}
|
|
||||||
<a href="{{ url_for('maker.recipe_list') }}"
|
|
||||||
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium
|
|
||||||
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
|
|
||||||
transition-colors">
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
|
|
||||||
</svg>
|
|
||||||
{{ _('Ricette') }}
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if current_user.get('roles') and 'Metrologist' in current_user.roles %}
|
|
||||||
<a href="{{ url_for('statistics.dashboard') }}"
|
|
||||||
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium
|
|
||||||
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
|
|
||||||
transition-colors">
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/>
|
|
||||||
</svg>
|
|
||||||
{{ _('Statistiche') }}
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{# Admin: Utenti + Stazioni #}
|
|
||||||
{% if current_user.get('is_admin') %}
|
|
||||||
<a href="{{ url_for('admin.user_list') }}"
|
|
||||||
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium
|
|
||||||
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
|
|
||||||
transition-colors">
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"/>
|
|
||||||
</svg>
|
|
||||||
{{ _('Utenti') }}
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a href="{{ url_for('admin.station_list') }}"
|
|
||||||
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium
|
|
||||||
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
|
|
||||||
transition-colors">
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25m18 0A2.25 2.25 0 0018.75 3H5.25A2.25 2.25 0 003 5.25m18 0V12a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 12V5.25"/>
|
|
||||||
</svg>
|
|
||||||
{{ _('Stazioni') }}
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -231,6 +231,23 @@
|
|||||||
rows="3"
|
rows="3"
|
||||||
placeholder="{{ _('Descrizione opzionale della ricetta...') }}"></textarea>
|
placeholder="{{ _('Descrizione opzionale della ricetta...') }}"></textarea>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -466,6 +483,9 @@ function recipeEditor() {
|
|||||||
versions: {{ (recipe.versions if recipe and recipe.versions else [])|tojson }},
|
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 }},
|
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) ----
|
// ---- File upload (preview image) ----
|
||||||
currentFilePath: {{ (recipe.image_path if recipe and recipe.image_path else '')|tojson }},
|
currentFilePath: {{ (recipe.image_path if recipe and recipe.image_path else '')|tojson }},
|
||||||
uploadingFile: false,
|
uploadingFile: false,
|
||||||
@@ -487,6 +507,11 @@ function recipeEditor() {
|
|||||||
description: this.description.trim(),
|
description: this.description.trim(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Measurement interval
|
||||||
|
if (this.measurementIntervalMinutes) {
|
||||||
|
payload.measurement_interval_minutes = parseInt(this.measurementIntervalMinutes, 10);
|
||||||
|
}
|
||||||
|
|
||||||
// Include image_path for preview thumbnail
|
// Include image_path for preview thumbnail
|
||||||
if (this.currentFilePath) {
|
if (this.currentFilePath) {
|
||||||
payload.image_path = this.currentFilePath;
|
payload.image_path = this.currentFilePath;
|
||||||
|
|||||||
@@ -120,7 +120,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<input type="text"
|
<input type="text"
|
||||||
x-model="search"
|
x-model="search"
|
||||||
class="tmf-input pl-10"
|
class="tmf-input !pl-10"
|
||||||
:placeholder="'{{ _('Cerca per nome, codice o descrizione...') }}'">
|
:placeholder="'{{ _('Cerca per nome, codice o descrizione...') }}'">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -152,11 +152,11 @@
|
|||||||
<div class="p-5 sm:p-6">
|
<div class="p-5 sm:p-6">
|
||||||
<!-- Card Header -->
|
<!-- Card Header -->
|
||||||
<div class="flex flex-col sm:flex-row sm:items-start gap-4 mb-4">
|
<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">
|
<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"
|
<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"
|
loading="lazy"
|
||||||
onerror="this.parentElement.style.display='none'">
|
onerror="this.parentElement.style.display='none'">
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -247,6 +247,18 @@
|
|||||||
<span class="hidden sm:inline">{{ _('Rettangolo') }}</span>
|
<span class="hidden sm:inline">{{ _('Rettangolo') }}</span>
|
||||||
</button>
|
</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 -->
|
<!-- Separator -->
|
||||||
<div class="w-px h-6 bg-[var(--border-color)] mx-1"></div>
|
<div class="w-px h-6 bg-[var(--border-color)] mx-1"></div>
|
||||||
|
|
||||||
|
|||||||
@@ -221,6 +221,7 @@
|
|||||||
<span x-text="tasks.length"></span>
|
<span x-text="tasks.length"></span>
|
||||||
<span x-text="tasks.length === 1 ? '{{ _('task') }}' : '{{ _('task') }}'"></span>
|
<span x-text="tasks.length === 1 ? '{{ _('task') }}' : '{{ _('task') }}'"></span>
|
||||||
</p>
|
</p>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
<button @click="showAddTask = true; $nextTick(() => $refs.newTaskTitle && $refs.newTaskTitle.focus())"
|
<button @click="showAddTask = true; $nextTick(() => $refs.newTaskTitle && $refs.newTaskTitle.focus())"
|
||||||
x-show="!showAddTask"
|
x-show="!showAddTask"
|
||||||
class="btn btn-primary gap-1.5">
|
class="btn btn-primary gap-1.5">
|
||||||
@@ -229,6 +230,15 @@
|
|||||||
</svg>
|
</svg>
|
||||||
{{ _('Aggiungi Task') }}
|
{{ _('Aggiungi Task') }}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- ============================================================
|
<!-- ============================================================
|
||||||
@@ -269,12 +279,12 @@
|
|||||||
placeholder="{{ _('Es. Seguire procedura ISO 2768') }}">
|
placeholder="{{ _('Es. Seguire procedura ISO 2768') }}">
|
||||||
</div>
|
</div>
|
||||||
<!-- Descrizione -->
|
<!-- Descrizione -->
|
||||||
<div>
|
<div class="sm:col-span-2">
|
||||||
<label class="tmf-label">{{ _('Descrizione') }}</label>
|
<label class="tmf-label">{{ _('Descrizione') }}</label>
|
||||||
<input type="text"
|
<textarea x-model="newTask.description"
|
||||||
x-model="newTask.description"
|
class="tmf-input text-sm"
|
||||||
class="tmf-input"
|
rows="3"
|
||||||
placeholder="{{ _('Descrizione opzionale...') }}">
|
placeholder="{{ _('Descrizione opzionale...') }}"></textarea>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -497,7 +507,7 @@
|
|||||||
<template x-if="task.description">
|
<template x-if="task.description">
|
||||||
<div class="flex items-start gap-2">
|
<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-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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
@@ -513,12 +523,12 @@
|
|||||||
class="tmf-input text-sm"
|
class="tmf-input text-sm"
|
||||||
placeholder="{{ _('Direttiva opzionale...') }}">
|
placeholder="{{ _('Direttiva opzionale...') }}">
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="sm:col-span-2">
|
||||||
<label class="tmf-label">{{ _('Descrizione') }}</label>
|
<label class="tmf-label">{{ _('Descrizione') }}</label>
|
||||||
<input type="text"
|
<textarea x-model="editTaskData.description"
|
||||||
x-model="editTaskData.description"
|
|
||||||
class="tmf-input text-sm"
|
class="tmf-input text-sm"
|
||||||
placeholder="{{ _('Descrizione opzionale...') }}">
|
rows="3"
|
||||||
|
placeholder="{{ _('Descrizione opzionale...') }}"></textarea>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -930,6 +940,118 @@
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@@ -983,6 +1105,13 @@ function taskEditor() {
|
|||||||
// ---- Drag & Drop state ----
|
// ---- Drag & Drop state ----
|
||||||
dragState: { dragging: null, over: null, position: null },
|
dragState: { dragging: null, over: null, position: null },
|
||||||
|
|
||||||
|
// ---- AI Import ----
|
||||||
|
showAiImport: false,
|
||||||
|
aiParsing: false,
|
||||||
|
aiCreating: false,
|
||||||
|
aiSuggestions: [],
|
||||||
|
aiError: '',
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Init
|
// Init
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -1081,6 +1210,80 @@ function taskEditor() {
|
|||||||
this.newTask = { title: '', directive: '', description: '' };
|
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() {
|
async addTask() {
|
||||||
if (!this.newTask.title.trim()) return;
|
if (!this.newTask.title.trim()) return;
|
||||||
this.saving = true;
|
this.saving = true;
|
||||||
|
|||||||
@@ -92,12 +92,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Search + Traceability Bar -->
|
<!-- Search Bar (filter) -->
|
||||||
<div class="tmf-card mb-6">
|
<div class="tmf-card mb-4">
|
||||||
<div class="p-4 sm:p-5">
|
<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">
|
<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">
|
<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"/>
|
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||||
@@ -109,23 +106,47 @@
|
|||||||
placeholder="{{ _('Nome, codice o descrizione...') }}"
|
placeholder="{{ _('Nome, codice o descrizione...') }}"
|
||||||
class="tmf-input">
|
class="tmf-input">
|
||||||
</div>
|
</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 -->
|
<!-- Lot Number -->
|
||||||
<div>
|
<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"
|
<input type="text"
|
||||||
x-model="lot_number"
|
x-model="lot_number"
|
||||||
placeholder="{{ _('Opzionale') }}"
|
placeholder="{{ _('Es. LOT-2026-001 (opzionale)') }}"
|
||||||
class="tmf-input">
|
class="tmf-input font-mono">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Serial Number -->
|
<!-- Serial Number -->
|
||||||
<div>
|
<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"
|
<input type="text"
|
||||||
x-model="serial_number"
|
x-model="serial_number"
|
||||||
placeholder="{{ _('Opzionale') }}"
|
placeholder="{{ _('Es. SN-000123 (opzionale)') }}"
|
||||||
class="tmf-input">
|
class="tmf-input font-mono">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -157,15 +178,30 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 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>
|
||||||
|
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
<!-- Recipe Name -->
|
<!-- Recipe Name -->
|
||||||
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-2 leading-snug"
|
<h3 class="text-lg font-semibold text-[var(--text-primary)] leading-snug"
|
||||||
x-text="recipe.name">
|
x-text="recipe.name">
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<!-- Description -->
|
<!-- Description -->
|
||||||
<p class="text-sm text-[var(--text-secondary)] line-clamp-2 leading-relaxed"
|
<p class="mt-1 text-sm text-[var(--text-secondary)] line-clamp-2 leading-relaxed"
|
||||||
x-text="recipe.description || '{{ _('Nessuna descrizione disponibile') }}'">
|
x-text="recipe.description || '{{ _('Nessuna descrizione disponibile') }}'">
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Meta Info -->
|
<!-- Meta Info -->
|
||||||
<div class="mt-4 flex items-center gap-3 text-xs text-[var(--text-muted)]">
|
<div class="mt-4 flex items-center gap-3 text-xs text-[var(--text-muted)]">
|
||||||
|
|||||||
@@ -175,7 +175,9 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<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>
|
<tr>
|
||||||
<td class="text-center font-mono text-xs" style="color: var(--text-secondary);">
|
<td class="text-center font-mono text-xs" style="color: var(--text-secondary);">
|
||||||
{{ m.measured_at[:16]|replace('T', ' ') if m.measured_at else '-' }}
|
{{ m.measured_at[:16]|replace('T', ' ') if m.measured_at else '-' }}
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}{{ task.title or 'Task' }} — {{ _('Misure') }} — TieMeasureFlow{% endblock %}
|
{% 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 %}
|
{% block extra_head %}
|
||||||
<style>
|
<style>
|
||||||
/* Annotation viewer container */
|
/* Annotation viewer container */
|
||||||
@@ -57,7 +63,7 @@
|
|||||||
window.__allTaskIds = {{ all_task_ids|tojson }};
|
window.__allTaskIds = {{ all_task_ids|tojson }};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="h-screen flex flex-col overflow-hidden"
|
<div class="h-full flex flex-col overflow-hidden"
|
||||||
x-data="taskExecute()"
|
x-data="taskExecute()"
|
||||||
x-init="init()"
|
x-init="init()"
|
||||||
@numpad-confirm.window="handleMeasurement($event.detail.value, $event.detail.inputMethod)"
|
@numpad-confirm.window="handleMeasurement($event.detail.value, $event.detail.inputMethod)"
|
||||||
@@ -78,7 +84,7 @@
|
|||||||
</a>
|
</a>
|
||||||
|
|
||||||
{# Task badge + title #}
|
{# 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
|
<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
|
bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300
|
||||||
border border-primary-200 dark:border-primary-800">
|
border border-primary-200 dark:border-primary-800">
|
||||||
@@ -89,6 +95,45 @@
|
|||||||
</h1>
|
</h1>
|
||||||
</div>
|
</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 #}
|
{# Lot + Serial badges #}
|
||||||
<div class="shrink-0 flex items-center gap-2">
|
<div class="shrink-0 flex items-center gap-2">
|
||||||
{% if lot_number %}
|
{% if lot_number %}
|
||||||
@@ -124,9 +169,10 @@
|
|||||||
<div class="flex-1 flex overflow-hidden">
|
<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">
|
<template x-for="(st, idx) in subtasks" :key="st.id">
|
||||||
<button @click="goToSubtask(idx)"
|
<button @click="goToSubtask(idx)"
|
||||||
class="relative flex flex-col items-center justify-center py-2.5 px-1 border-b border-[var(--border-color)] transition-all duration-200"
|
class="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>
|
</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 ---- #}
|
{# ---- Subtask header ---- #}
|
||||||
<div class="p-3 border-b border-[var(--border-color)]" x-show="currentSubtask">
|
<div class="p-3 border-b border-[var(--border-color)]" x-show="currentSubtask">
|
||||||
@@ -310,7 +357,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# ---- Measurement feedback ---- #}
|
{# ---- 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 utl() { return currentSubtask?.utl || 0; },
|
||||||
get uwl() { return currentSubtask?.uwl || 0; },
|
get uwl() { return currentSubtask?.uwl || 0; },
|
||||||
get lwl() { return currentSubtask?.lwl || 0; },
|
get lwl() { return currentSubtask?.lwl || 0; },
|
||||||
@@ -319,8 +366,8 @@
|
|||||||
{% include "components/measurement_feedback.html" %}
|
{% include "components/measurement_feedback.html" %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# ---- Numpad ---- #}
|
{# ---- Numpad (only when a subtask is active) ---- #}
|
||||||
<div class="px-3 py-2 relative flex-1">
|
<div class="px-3 py-2 relative flex-1" x-show="currentSubtask">
|
||||||
{# Saving overlay #}
|
{# Saving overlay #}
|
||||||
<div x-show="saving"
|
<div x-show="saving"
|
||||||
x-transition
|
x-transition
|
||||||
@@ -338,10 +385,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# ---- Next measurement indicator ---- #}
|
{# ---- Next measurement indicator ---- #}
|
||||||
<div class="px-3 pb-2">
|
<div class="px-3 pb-2" x-show="currentSubtask">
|
||||||
{% include "components/next_measurement.html" %}
|
{% include "components/next_measurement.html" %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
{# ---- Error message ---- #}
|
{# ---- Error message ---- #}
|
||||||
<div x-show="errorMessage"
|
<div x-show="errorMessage"
|
||||||
x-transition
|
x-transition
|
||||||
@@ -358,6 +406,62 @@
|
|||||||
|
|
||||||
</div>
|
</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
|
FOOTER — Progress bar + navigation
|
||||||
================================================================ #}
|
================================================================ #}
|
||||||
@@ -388,20 +492,48 @@
|
|||||||
x-text="Math.round(progressPercent) + '%'"></span>
|
x-text="Math.round(progressPercent) + '%'"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# Right: Summary button #}
|
{# Right: Fine ciclo misura (measurement tasks) / Completato (non-measurement or after cycle) #}
|
||||||
<button x-show="isComplete"
|
<template x-if="subtasks.length > 0">
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<button x-show="isComplete && !cycleConfirmed"
|
||||||
x-transition
|
x-transition
|
||||||
@click="goToSummary()"
|
@click="confirmCycle()"
|
||||||
class="btn btn-primary text-xs shrink-0 gap-1 py-1.5 px-2.5 shadow-md">
|
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">
|
<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"/>
|
<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>
|
</svg>
|
||||||
{{ _('Riepilogo') }}
|
{{ _('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">
|
<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"/>
|
<path stroke-linecap="round" stroke-linejoin="round" d="M14 5l7 7m0 0l-7 7m7-7H3"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{# ================================================================
|
{# ================================================================
|
||||||
@@ -447,13 +579,100 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-2">
|
||||||
<button @click="goToSummary()"
|
<button @click="goToSummary()"
|
||||||
class="btn btn-primary w-full justify-center gap-2">
|
class="btn btn-secondary flex-1 justify-center gap-2">
|
||||||
{{ _('Vai al Riepilogo') }}
|
{{ _('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">
|
<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"/>
|
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -489,6 +708,24 @@ function taskExecute() {
|
|||||||
errorMessage: '',
|
errorMessage: '',
|
||||||
showCompletionOverlay: false,
|
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 ----
|
// ---- Value from numpad / caliper ----
|
||||||
currentValue: null,
|
currentValue: null,
|
||||||
|
|
||||||
@@ -641,15 +878,24 @@ function taskExecute() {
|
|||||||
// Pause to show result feedback
|
// Pause to show result feedback
|
||||||
await new Promise(r => setTimeout(r, 1000));
|
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
|
// Check if all done
|
||||||
if (this.completedCount >= this.totalSubtasks) {
|
if (this.completedCount >= this.totalSubtasks) {
|
||||||
const taskIds = window.__allTaskIds || [];
|
// START run (production not yet started): don't block with the
|
||||||
const currentIdx = taskIds.indexOf(this.task.id);
|
// "Misurazioni Complete" overlay — just advance to the next task.
|
||||||
if (currentIdx >= 0 && currentIdx < taskIds.length - 1) {
|
// The overlay is shown only on the last task (to offer "Avvio
|
||||||
window.location.href = '{{ url_for("measure.task_execute", task_id=0) }}'.replace('/0', '/' + taskIds[currentIdx + 1]);
|
// Produzione") or once production is running ("Conferma ciclo").
|
||||||
} else {
|
if (!this.productionStarted && !this.isLastTask) {
|
||||||
this.showCompletionOverlay = true;
|
this.goToNextTask();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
this.showCompletionOverlay = true;
|
||||||
return;
|
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 ----
|
// ---- Go to summary ----
|
||||||
goToSummary() {
|
goToSummary() {
|
||||||
const recipeId = this.task.recipe_id || 0;
|
const recipeId = this.task.recipe_id || 0;
|
||||||
|
|||||||
@@ -27,6 +27,17 @@
|
|||||||
</ol>
|
</ol>
|
||||||
</nav>
|
</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 -->
|
<!-- Recipe Info Card -->
|
||||||
<div class="tmf-card mb-8">
|
<div class="tmf-card mb-8">
|
||||||
<div class="p-5 sm:p-6">
|
<div class="p-5 sm:p-6">
|
||||||
@@ -60,8 +71,19 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Right: Traceability Badges -->
|
<!-- Right: AVVIA + Traceability -->
|
||||||
<div class="flex flex-col gap-2 sm:items-end shrink-0">
|
<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 %}
|
{% if lot_number %}
|
||||||
<div class="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm
|
<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
|
bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800
|
||||||
@@ -100,67 +122,62 @@
|
|||||||
</h2>
|
</h2>
|
||||||
<span class="badge badge-neutral">
|
<span class="badge badge-neutral">
|
||||||
{{ tasks|length }} task
|
{{ 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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Task Cards -->
|
<!-- Task Cards -->
|
||||||
{% if tasks %}
|
{% if tasks %}
|
||||||
<div class="space-y-4">
|
<div class="space-y-2.5">
|
||||||
{% for task in tasks %}
|
{% for task in tasks %}
|
||||||
<div class="tmf-card hover:border-primary/30 transition-all duration-200 group">
|
<div class="tmf-card hover:border-primary/30 transition-all duration-200 group">
|
||||||
<div class="p-5 sm:p-6">
|
<div class="p-3 sm:p-4">
|
||||||
<div class="flex flex-col sm:flex-row sm:items-center gap-4">
|
<div class="flex flex-row items-center gap-3">
|
||||||
|
|
||||||
<!-- Task Number Circle -->
|
<!-- 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
|
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 }}
|
{{ loop.index }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Task Info -->
|
<!-- Task Info -->
|
||||||
<div class="flex-1 min-w-0">
|
<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 -->
|
<!-- 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) }}
|
{{ task.title or task.name or (_('Task') ~ ' ' ~ loop.index) }}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<!-- Directive -->
|
<!-- Directive -->
|
||||||
{% if task.directive or task.description %}
|
{% 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 }}
|
{{ task.directive or task.description }}
|
||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Meta Row -->
|
<!-- Indicators (top-right): measurements + attachment -->
|
||||||
<div class="flex items-center gap-4 flex-wrap">
|
<div class="shrink-0 self-start flex items-center gap-2.5">
|
||||||
<!-- Subtask Count -->
|
|
||||||
{% if task.subtask_count is defined or task.subtasks %}
|
{% if task.subtask_count is defined or task.subtasks %}
|
||||||
<span class="inline-flex items-center gap-1.5 text-xs text-[var(--text-secondary)]">
|
<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">
|
<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"/>
|
<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>
|
</svg>
|
||||||
<span class="font-medium">
|
<span class="font-medium font-mono">{% if task.subtask_count is defined %}{{ task.subtask_count }}{% elif task.subtasks %}{{ task.subtasks|length }}{% endif %}</span>
|
||||||
{% if task.subtask_count is defined %}
|
|
||||||
{{ task.subtask_count }}
|
|
||||||
{% elif task.subtasks %}
|
|
||||||
{{ task.subtasks|length }}
|
|
||||||
{% endif %}
|
|
||||||
{{ _('misurazioni') }}
|
|
||||||
</span>
|
|
||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- File Attachment -->
|
|
||||||
{% if task.file_path %}
|
{% if task.file_path %}
|
||||||
<span class="inline-flex items-center gap-1.5 text-xs text-[var(--text-secondary)]">
|
<span class="inline-flex items-center text-xs" title="{{ _('Allegato') }}">
|
||||||
{% if task.file_path.endswith('.pdf') %}
|
{% 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">
|
<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"/>
|
<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"/>
|
||||||
@@ -170,13 +187,12 @@
|
|||||||
<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"/>
|
<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>
|
</svg>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<span class="font-medium">{{ _('Allegato') }}</span>
|
|
||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Action Button -->
|
<!-- 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">
|
<div class="shrink-0 sm:ml-4">
|
||||||
<a href="{{ url_for('measure.task_execute', task_id=task.id) }}"
|
<a href="{{ url_for('measure.task_execute', task_id=task.id) }}"
|
||||||
class="btn btn-primary gap-2 w-full sm:w-auto justify-center
|
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="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"/>
|
<path stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||||
</svg>
|
</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">
|
<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"/>
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/>
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -215,33 +232,5 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% 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>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ class TestSaveMeasurement:
|
|||||||
mock_api_client.post.return_value = {
|
mock_api_client.post.return_value = {
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"subtask_id": 10,
|
"subtask_id": 10,
|
||||||
"task_id": 5,
|
"version_id": 3,
|
||||||
"value": 9.95,
|
"value": 9.95,
|
||||||
"pass_fail": "pass",
|
"pass_fail": "pass",
|
||||||
}
|
}
|
||||||
@@ -70,10 +70,8 @@ class TestSaveMeasurement:
|
|||||||
"/measure/save-measurement",
|
"/measure/save-measurement",
|
||||||
json={
|
json={
|
||||||
"subtask_id": 10,
|
"subtask_id": 10,
|
||||||
"task_id": 5,
|
"version_id": 3,
|
||||||
"value": 9.95,
|
"value": 9.95,
|
||||||
"pass_fail": "pass",
|
|
||||||
"deviation": 0.05,
|
|
||||||
},
|
},
|
||||||
content_type="application/json",
|
content_type="application/json",
|
||||||
)
|
)
|
||||||
@@ -86,7 +84,7 @@ class TestSaveMeasurement:
|
|||||||
"""Missing required fields return 400."""
|
"""Missing required fields return 400."""
|
||||||
resp = logged_in_client.post(
|
resp = logged_in_client.post(
|
||||||
"/measure/save-measurement",
|
"/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",
|
content_type="application/json",
|
||||||
)
|
)
|
||||||
assert resp.status_code == 400
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "cssselect2"
|
name = "cssselect2"
|
||||||
version = "0.9.0"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "pillow"
|
name = "pillow"
|
||||||
version = "12.2.0"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "pyphen"
|
name = "pyphen"
|
||||||
version = "0.17.2"
|
version = "0.17.2"
|
||||||
@@ -1643,8 +1758,10 @@ server = [
|
|||||||
{ name = "asyncmy" },
|
{ name = "asyncmy" },
|
||||||
{ name = "bcrypt" },
|
{ name = "bcrypt" },
|
||||||
{ name = "fastapi" },
|
{ name = "fastapi" },
|
||||||
|
{ name = "httpx" },
|
||||||
{ name = "jinja2" },
|
{ name = "jinja2" },
|
||||||
{ name = "kaleido" },
|
{ name = "kaleido" },
|
||||||
|
{ name = "pdfplumber" },
|
||||||
{ name = "pillow" },
|
{ name = "pillow" },
|
||||||
{ name = "plotly" },
|
{ name = "plotly" },
|
||||||
{ name = "python-multipart" },
|
{ name = "python-multipart" },
|
||||||
@@ -1666,8 +1783,10 @@ requires-dist = [
|
|||||||
{ name = "flask-wtf", marker = "extra == 'client'", specifier = ">=1.2.0" },
|
{ name = "flask-wtf", marker = "extra == 'client'", specifier = ">=1.2.0" },
|
||||||
{ name = "gunicorn", marker = "extra == 'client'", specifier = ">=21.0.0" },
|
{ name = "gunicorn", marker = "extra == 'client'", specifier = ">=21.0.0" },
|
||||||
{ name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27.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 = "jinja2", marker = "extra == 'server'", specifier = ">=3.1.0" },
|
||||||
{ name = "kaleido", marker = "extra == 'server'", specifier = ">=0.2.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 = "pillow", marker = "extra == 'server'", specifier = ">=10.0.0" },
|
||||||
{ name = "plotly", marker = "extra == 'server'", specifier = ">=5.0.0" },
|
{ name = "plotly", marker = "extra == 'server'", specifier = ">=5.0.0" },
|
||||||
{ name = "pydantic", specifier = ">=2.0.0" },
|
{ name = "pydantic", specifier = ">=2.0.0" },
|
||||||
|
|||||||
Reference in New Issue
Block a user