Compare commits
52 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 242add047b | |||
| cd40b81f1e | |||
| 6e60e0b1a9 | |||
| 6bc6c0c0d5 | |||
| e5989c9d23 | |||
| 0af6692786 | |||
| 36074ab2f7 | |||
| fd1bbce8aa | |||
| 1453b31cee | |||
| 192d4b590c | |||
| b9f43a0ede | |||
| 5bf374aadd | |||
| 74fe5665b5 | |||
| be904bac07 | |||
| 011c08c9d8 | |||
| 82ad88538d | |||
| bafc776ef7 | |||
| 8fcb4796d5 | |||
| 8d7fa9a12a | |||
| c911449b7c | |||
| ac8604eff5 | |||
| 631ba4ee05 | |||
| 40eed5c2af | |||
| 275bbd1a5f | |||
| 95d643d6d8 | |||
| 9306fbf653 | |||
| 1a5e98f072 | |||
| fdfcf05951 | |||
| 26884ebf4f | |||
| 8b3d3a8424 | |||
| 153fafd1b4 | |||
| cf8fab4c08 | |||
| b7cb061fee | |||
| 6a8ad4dae4 | |||
| d750cf9e0e | |||
| 2564498915 | |||
| 21f8bae5ac | |||
| c1f8a5e2ff | |||
| dee27ac8e1 | |||
| 736b853e22 | |||
| 627ae15591 | |||
| c6d4260ede | |||
| df1727be73 | |||
| 459d1c71ea | |||
| 05e3faa9d4 | |||
| dc94b2e778 | |||
| 3c55550d93 | |||
| e07c4d4eee | |||
| 0b45f1c35a | |||
| 743f246cbf | |||
| f541f4a810 | |||
| 07e23b957d |
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
[submodule "vendor/visionsuite"]
|
[submodule "vendor/visionsuite"]
|
||||||
path = vendor/visionsuite
|
path = vendor/visionsuite
|
||||||
url = ssh://git@git.tielogic.xyz:222/Adriano/visionsuite.git
|
url = ssh://git@git.tielogic.xyz:222/Adriano/TieVision-Core.git
|
||||||
|
|||||||
@@ -287,9 +287,9 @@ Nginx (dev): gzip abilitato, `client_max_body_size 50M`, `proxy_read_timeout 120
|
|||||||
|
|
||||||
## Visione (V3.0.0)
|
## Visione (V3.0.0)
|
||||||
|
|
||||||
Il runner di visione (`src/vision/`) e il suo worker FastAPI (`src/vision_worker/`) dipendono da `vendor/visionsuite`, un **submodule** su `ssh://git.tielogic.xyz:222`: un `git clone` senza `--recurse-submodules` lascia la cartella vuota (chi ha già clonato: `git submodule update --init`). Le librerie di VisionSuite richiedono **Python 3.13**, mentre backend e frontend restano su Python 3.11 — è un albero a parte nel monorepo, isolato con il proprio extra `uv`.
|
Il runner di visione (`src/vision/`) e il suo worker FastAPI (`src/vision_worker/`) dipendono da `vendor/visionsuite`, un **submodule** che punta al repo TieVision-Core (`ssh://git@git.tielogic.xyz:222/Adriano/TieVision-Core.git`, ex visionsuite, rinominato il 15/10/2026; `vs-gui` e `vs-assistant` sono ora in TieVision-Studio e TieVision-Assistant, che TieMeasureFlow non usa; il path resta `vendor/visionsuite`): un `git clone` senza `--recurse-submodules` lascia la cartella vuota (chi ha già clonato: `git submodule update --init`). Le librerie di VisionSuite richiedono **Python 3.13**, mentre backend e frontend restano su Python 3.11 — è un albero a parte nel monorepo, isolato con il proprio extra `uv`.
|
||||||
|
|
||||||
Regola (Adriano, 07/10/2026): il submodule `vendor/visionsuite` segue `origin/main`: a ogni integrazione si porta all'ultimo commit (`git -C vendor/visionsuite fetch origin && git -C vendor/visionsuite checkout origin/main`) e si rilanciano i test della visione. Pin attuale: bc0df16.
|
Regola (Adriano, 07/10/2026): il submodule `vendor/visionsuite` segue `origin/main`: a ogni integrazione si porta all'ultimo commit (`git -C vendor/visionsuite fetch origin && git -C vendor/visionsuite checkout origin/main`) e si rilanciano i test della visione. La regola vale ora per `origin/main` di TieVision-Core. Pin attuale: 2e73691.
|
||||||
|
|
||||||
Il worker gira in un container separato (`Dockerfile.vision`), non pubblica porte verso l'esterno ed è raggiungibile solo dal server all'indirizzo interno `http://vision:8100`. Il container non ha accesso al repository Git del progetto principale, quindi il commit di VisionSuite in uso (`engine_version`) non può essere scoperto dentro l'immagine: va stampato al momento del build leggendo il commit dalla macchina che esegue `docker compose build`, con la variabile `VISION_ENGINE_VERSION`. Una build senza questa variabile fallisce subito, invece di produrre un worker che risponde con una versione indovinata:
|
Il worker gira in un container separato (`Dockerfile.vision`), non pubblica porte verso l'esterno ed è raggiungibile solo dal server all'indirizzo interno `http://vision:8100`. Il container non ha accesso al repository Git del progetto principale, quindi il commit di VisionSuite in uso (`engine_version`) non può essere scoperto dentro l'immagine: va stampato al momento del build leggendo il commit dalla macchina che esegue `docker compose build`, con la variabile `VISION_ENGINE_VERSION`. Una build senza questa variabile fallisce subito, invece di produrre un worker che risponde con una versione indovinata:
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,13 @@ WORKDIR /app/flask_app
|
|||||||
RUN uv run --project /app pybabel compile -d translations && \
|
RUN uv run --project /app pybabel compile -d translations && \
|
||||||
chown -R app:app /app /tmp/uv-cache
|
chown -R app:app /app /tmp/uv-cache
|
||||||
|
|
||||||
|
# Commit mostrato nel piede di pagina (#70). .git e' escluso dal contesto di
|
||||||
|
# build (.dockerignore), quindi lo passa chi costruisce; facoltativo, senza il
|
||||||
|
# piede mostra solo la versione. Uso:
|
||||||
|
# APP_COMMIT=$(git rev-parse --short HEAD) docker compose build client
|
||||||
|
ARG APP_COMMIT=""
|
||||||
|
ENV APP_COMMIT=${APP_COMMIT}
|
||||||
|
|
||||||
USER app
|
USER app
|
||||||
|
|
||||||
EXPOSE 5000
|
EXPOSE 5000
|
||||||
|
|||||||
@@ -333,6 +333,7 @@ TieMeasureFlow/
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `docker compose -f docker-compose.dev.yml up -d --build` | Avvia servizi in sviluppo (build incluso) |
|
| `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 |
|
||||||
|
| `APP_COMMIT=$(git rev-parse --short HEAD) docker compose build client` | Ricostruisci il client mostrando nel piè di pagina il commit (oltre alla versione di `pyproject.toml`); senza `APP_COMMIT` compare solo la versione |
|
||||||
| `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 |
|
||||||
| `docker compose ps` | Stato di tutti i servizi |
|
| `docker compose ps` | Stato di tutti i servizi |
|
||||||
@@ -515,6 +516,22 @@ Copia `.env.example` in `.env` e configura:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Camera di stazione (demo fiera, #72)
|
||||||
|
|
||||||
|
Per acquisire il fotogramma da una camera locale (per esempio quella del TC bench) senza passare dall'upload, sul PC della stazione gira un piccolo servizio nativo, fuori da Docker, perché la camera GigE non si raggiunge dal container. Si avvia dalla radice del repository con l'ambiente di VisionSuite (`uv sync --all-packages` in `vendor/visionsuite`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vendor/visionsuite/.venv/bin/python scripts/camera_service.py [--host 127.0.0.1] [--port 8765] [--folder <cartella immagini>]
|
||||||
|
```
|
||||||
|
|
||||||
|
Con `--folder` la sorgente `camera:folder` restituisce a rotazione le immagini della cartella, utile per provare senza hardware. Su Linux, perché il container `client` lo raggiunga, si usa `--host 172.17.0.1` (gateway di docker0; il compose di sviluppo mappa `host.docker.internal` su quell'host). Su Windows con Docker Desktop si presume che `host.docker.internal` raggiunga il loopback dell'host, ma va verificato sul PC della demo.
|
||||||
|
|
||||||
|
⚠️ Il servizio non ha autenticazione: un `--host` diverso dal loopback va usato solo su una rete isolata.
|
||||||
|
|
||||||
|
Nella schermata Stazioni si compilano `camera_url` (per esempio `http://host.docker.internal:8765`) e `camera_source` (`camera:<seriale>` oppure `camera:folder`). Per avere due postazioni sullo stesso PC si abilita `STATION_SWITCH_ENABLED`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Documentazione
|
## Documentazione
|
||||||
|
|
||||||
Indice completo: [`docs/README.md`](docs/README.md).
|
Indice completo: [`docs/README.md`](docs/README.md).
|
||||||
|
|||||||
@@ -75,6 +75,9 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile.frontend
|
dockerfile: Dockerfile.frontend
|
||||||
|
args:
|
||||||
|
# Commit nel piede di pagina (#70); facoltativo, vuoto = solo versione
|
||||||
|
APP_COMMIT: ${APP_COMMIT:-}
|
||||||
container_name: tmflow-client
|
container_name: tmflow-client
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file:
|
env_file:
|
||||||
@@ -85,6 +88,9 @@ services:
|
|||||||
# on-premise su LAN in HTTP semplice ha bisogno del cookie non-secure,
|
# on-premise su LAN in HTTP semplice ha bisogno del cookie non-secure,
|
||||||
# altrimenti il browser non lo rimanda mai indietro.
|
# altrimenti il browser non lo rimanda mai indietro.
|
||||||
SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE:-false}
|
SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE:-false}
|
||||||
|
# #72: da container si raggiunge il servizio camera (vs-gui) sull'host Linux
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
depends_on:
|
depends_on:
|
||||||
server:
|
server:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
@@ -89,6 +89,9 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile.frontend
|
dockerfile: Dockerfile.frontend
|
||||||
|
args:
|
||||||
|
# Commit nel piede di pagina (#70); facoltativo, vuoto = solo versione
|
||||||
|
APP_COMMIT: ${APP_COMMIT:-}
|
||||||
container_name: tmflow-client
|
container_name: tmflow-client
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file:
|
env_file:
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
"""Servizio camera minimo di stazione (TieMeasureFlow, #72).
|
||||||
|
|
||||||
|
Cosa fa: espone via HTTP l'acquisizione di una camera (vs-camera: GigE/GenTL,
|
||||||
|
OpenCV, cartella di immagini) con lo stesso contratto di `vs-gui`, ma solo
|
||||||
|
`grab` (copiato da TieVision-Studio, src/visionsuite/gui/server.py):
|
||||||
|
GET /camera/sources -> {"sources": [{name, kind, label}]}
|
||||||
|
POST /camera/grab json {source, trigger="continuo", timeout_ms=5000} -> PNG
|
||||||
|
Errori: 400 parametri, 404 sorgente inesistente, 503 guasto camera, 504 timeout.
|
||||||
|
Serve perche' vs-gui ascolta solo su 127.0.0.1 e il container `client` non lo
|
||||||
|
raggiunge; questo servizio permette di scegliere l'host di ascolto.
|
||||||
|
|
||||||
|
Uso (dalla radice del repo):
|
||||||
|
vendor/visionsuite/.venv/bin/python scripts/camera_service.py \
|
||||||
|
[--host 127.0.0.1] [--port 8765] [--folder <cartella immagini>]
|
||||||
|
--folder abilita la sorgente finta `camera:folder` (loop sulle immagini).
|
||||||
|
Da Docker su Linux: --host 172.17.0.1 (gateway di docker0).
|
||||||
|
|
||||||
|
Dipende da: venv di VisionSuite (`uv sync --all-packages` in vendor/visionsuite):
|
||||||
|
vs-camera, fastapi, uvicorn, opencv.
|
||||||
|
|
||||||
|
AVVERTENZA: nessuna autenticazione. Un --host diverso dal loopback espone la
|
||||||
|
camera a chiunque raggiunga la porta: solo su rete isolata.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import threading
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
from fastapi import Body, FastAPI, HTTPException
|
||||||
|
from fastapi.responses import Response
|
||||||
|
|
||||||
|
from visionsuite.camera.camera_interface import CameraError, CameraGrabError
|
||||||
|
from visionsuite.camera.sources import SourceNotFound, list_sources, open_source
|
||||||
|
|
||||||
|
_TRIGGER = ("continuo",)
|
||||||
|
|
||||||
|
# errori noti di vs-camera (inglese) -> italiano; esatti, poi per prefisso
|
||||||
|
_MESSAGGI = {
|
||||||
|
"Camera is not open": "La camera non è aperta",
|
||||||
|
"Camera is not grabbing": "La camera non sta acquisendo",
|
||||||
|
"Grab cancelled (grabbing stopped)": "Acquisizione annullata (acquisizione fermata)",
|
||||||
|
"Failed to read frame from RTSP stream": "Lettura del fotogramma dallo stream RTSP non riuscita",
|
||||||
|
}
|
||||||
|
_PREFISSI = (
|
||||||
|
("Failed to open OpenCV device", "Apertura del dispositivo OpenCV non riuscita"),
|
||||||
|
("Failed to open RTSP stream", "Apertura dello stream RTSP non riuscita"),
|
||||||
|
("Failed to reconnect to RTSP stream", "Riconnessione allo stream RTSP non riuscita"),
|
||||||
|
("Failed to read frame after", "Lettura del fotogramma non riuscita dopo"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _traduci(messaggio: str) -> str:
|
||||||
|
if messaggio in _MESSAGGI:
|
||||||
|
return _MESSAGGI[messaggio]
|
||||||
|
for inglese, italiano in _PREFISSI:
|
||||||
|
if messaggio.startswith(inglese):
|
||||||
|
return italiano + messaggio[len(inglese):]
|
||||||
|
return messaggio
|
||||||
|
|
||||||
|
|
||||||
|
def create_app(folder: Path | str | None = None) -> FastAPI:
|
||||||
|
aperte: dict = {}
|
||||||
|
lock = threading.Lock() # protegge SOLO i dizionari, mai un'apertura
|
||||||
|
lock_sorgenti: dict = {} # nome sorgente -> Lock che serializza apertura e grab
|
||||||
|
|
||||||
|
def _config() -> dict:
|
||||||
|
if folder is None:
|
||||||
|
return {}
|
||||||
|
return {"folder": {"folder": str(folder), "loop": True}}
|
||||||
|
|
||||||
|
def _lock_di(nome: str) -> threading.Lock:
|
||||||
|
with lock:
|
||||||
|
return lock_sorgenti.setdefault(nome, threading.Lock())
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def _vita(_app):
|
||||||
|
yield
|
||||||
|
with lock: # alla chiusura rilascia le camere aperte
|
||||||
|
sorgenti = list(aperte.values())
|
||||||
|
aperte.clear()
|
||||||
|
for s in sorgenti:
|
||||||
|
try:
|
||||||
|
s.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
app = FastAPI(title="TMF camera service", lifespan=_vita)
|
||||||
|
|
||||||
|
@app.get("/camera/sources")
|
||||||
|
def camera_sources():
|
||||||
|
voci = list_sources(None, cameras=True, config=_config())
|
||||||
|
return {"sources": [{"name": v.name, "kind": v.kind, "label": v.label}
|
||||||
|
for v in voci if v.kind in ("camera", "virtual")]}
|
||||||
|
|
||||||
|
@app.post("/camera/grab")
|
||||||
|
def camera_grab(corpo: dict = Body(default=None)):
|
||||||
|
nome = corpo.get("source") if isinstance(corpo, dict) else None
|
||||||
|
if not isinstance(nome, str) or not nome.strip():
|
||||||
|
raise HTTPException(400, "manca «source»")
|
||||||
|
nome = nome.strip()
|
||||||
|
trigger = corpo.get("trigger", "continuo")
|
||||||
|
if trigger not in _TRIGGER:
|
||||||
|
raise HTTPException(400, "«trigger» supportato: solo continuo")
|
||||||
|
timeout = corpo.get("timeout_ms", 5000)
|
||||||
|
if isinstance(timeout, bool) or not isinstance(timeout, int) or not 1 <= timeout <= 10000:
|
||||||
|
raise HTTPException(400, "«timeout_ms» deve essere un intero fra 1 e 10000")
|
||||||
|
try:
|
||||||
|
with _lock_di(nome): # solo questa sorgente; le altre non aspettano
|
||||||
|
with lock:
|
||||||
|
sorgente = aperte.get(nome)
|
||||||
|
if sorgente is None:
|
||||||
|
sorgente = open_source(nome, None, config=_config())
|
||||||
|
with lock:
|
||||||
|
aperte[nome] = sorgente
|
||||||
|
frame = sorgente.grab(timeout)
|
||||||
|
except SourceNotFound as e:
|
||||||
|
raise HTTPException(404, _traduci(str(e)))
|
||||||
|
except TimeoutError as e:
|
||||||
|
raise HTTPException(504, _traduci(str(e)) or "timeout")
|
||||||
|
except (CameraGrabError, CameraError) as e:
|
||||||
|
raise HTTPException(503, _traduci(str(e)))
|
||||||
|
ok, buf = cv2.imencode(".png", frame)
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(500, "codifica PNG fallita")
|
||||||
|
return Response(buf.tobytes(), media_type="image/png")
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
import uvicorn
|
||||||
|
p = argparse.ArgumentParser(description="Servizio camera minimo di stazione (#72)")
|
||||||
|
p.add_argument("--host", default="127.0.0.1")
|
||||||
|
p.add_argument("--port", type=int, default=8765)
|
||||||
|
p.add_argument("--folder", default=None, help="cartella immagini per camera:folder")
|
||||||
|
a = p.parse_args()
|
||||||
|
uvicorn.run(create_app(a.folder), host=a.host, port=a.port)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
"""Test di scripts/camera_service.py (#72).
|
||||||
|
|
||||||
|
Uso: vendor/visionsuite/.venv/bin/python -m pytest scripts/tests/test_camera_service.py -q
|
||||||
|
Dipende da: venv di VisionSuite (vs-camera, fastapi, httpx, opencv, pytest).
|
||||||
|
"""
|
||||||
|
import importlib.util
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
MODULO = Path(__file__).resolve().parents[1] / "camera_service.py"
|
||||||
|
|
||||||
|
|
||||||
|
def _carica():
|
||||||
|
spec = importlib.util.spec_from_file_location("camera_service", MODULO)
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(mod)
|
||||||
|
return mod
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def cartella(tmp_path):
|
||||||
|
for nome, (w, h) in {"a.png": (64, 48), "b.png": (64, 48)}.items():
|
||||||
|
cv2.imwrite(str(tmp_path / nome), np.full((h, w, 3), 128, np.uint8))
|
||||||
|
return tmp_path
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(cartella):
|
||||||
|
mod = _carica()
|
||||||
|
with TestClient(mod.create_app(cartella)) as c:
|
||||||
|
c.mod = mod
|
||||||
|
yield c
|
||||||
|
|
||||||
|
|
||||||
|
def test_sources_contiene_folder(client):
|
||||||
|
r = client.get("/camera/sources")
|
||||||
|
assert r.status_code == 200
|
||||||
|
nomi = [s["name"] for s in r.json()["sources"]]
|
||||||
|
assert "camera:folder" in nomi
|
||||||
|
voce = next(s for s in r.json()["sources"] if s["name"] == "camera:folder")
|
||||||
|
assert {"name", "kind", "label"} <= set(voce)
|
||||||
|
|
||||||
|
|
||||||
|
def test_grab_folder_restituisce_png(client):
|
||||||
|
r = client.post("/camera/grab", json={"source": "camera:folder"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.headers["content-type"] == "image/png"
|
||||||
|
img = cv2.imdecode(np.frombuffer(r.content, np.uint8), cv2.IMREAD_COLOR)
|
||||||
|
assert img.shape[:2] == (48, 64)
|
||||||
|
|
||||||
|
|
||||||
|
def test_grab_ripetuto_in_loop(client):
|
||||||
|
# due immagini in cartella, tre grab: il loop non deve esaurirsi
|
||||||
|
for _ in range(3):
|
||||||
|
assert client.post("/camera/grab", json={"source": "camera:folder"}).status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_source_mancante_400(client):
|
||||||
|
assert client.post("/camera/grab", json={}).status_code == 400
|
||||||
|
assert client.post("/camera/grab", json={"source": " "}).status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_source_inesistente_404(client):
|
||||||
|
assert client.post("/camera/grab", json={"source": "camera:non-esiste"}).status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_timeout_non_valido_400(client):
|
||||||
|
r = client.post("/camera/grab", json={"source": "camera:folder", "timeout_ms": 0})
|
||||||
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_errore_camera_503_tradotto(client, monkeypatch):
|
||||||
|
from visionsuite.camera.camera_interface import CameraGrabError
|
||||||
|
|
||||||
|
class Rotta:
|
||||||
|
def grab(self, timeout):
|
||||||
|
raise CameraGrabError("Camera is not open")
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
monkeypatch.setattr(client.mod, "open_source", lambda *a, **k: Rotta())
|
||||||
|
r = client.post("/camera/grab", json={"source": "camera:finta"})
|
||||||
|
assert r.status_code == 503
|
||||||
|
assert r.json()["detail"] == "La camera non è aperta"
|
||||||
|
|
||||||
|
|
||||||
|
def test_timeout_504(client, monkeypatch):
|
||||||
|
class Lenta:
|
||||||
|
def grab(self, timeout):
|
||||||
|
raise TimeoutError("scaduto")
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
monkeypatch.setattr(client.mod, "open_source", lambda *a, **k: Lenta())
|
||||||
|
r = client.post("/camera/grab", json={"source": "camera:lenta"})
|
||||||
|
assert r.status_code == 504
|
||||||
|
|
||||||
|
|
||||||
|
def test_sorgente_aperta_una_volta(client, monkeypatch):
|
||||||
|
aperture = []
|
||||||
|
reale = client.mod.open_source
|
||||||
|
|
||||||
|
def conta(*a, **k):
|
||||||
|
aperture.append(a[0])
|
||||||
|
return reale(*a, **k)
|
||||||
|
|
||||||
|
monkeypatch.setattr(client.mod, "open_source", conta)
|
||||||
|
for _ in range(3):
|
||||||
|
client.post("/camera/grab", json={"source": "camera:folder"})
|
||||||
|
assert aperture == ["camera:folder"]
|
||||||
@@ -35,6 +35,8 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
LOGIN_PATH = "/api/auth/login"
|
LOGIN_PATH = "/api/auth/login"
|
||||||
|
# #59: il login con PIN e' lo stesso attacco (4 cifre: ancora piu' facile da indovinare)
|
||||||
|
LOGIN_PIN_PATH = "/api/auth/login-pin"
|
||||||
WINDOW_SECONDS = 60
|
WINDOW_SECONDS = 60
|
||||||
|
|
||||||
# Paths that carry a supervisor's (capoturno) credentials in the request
|
# Paths that carry a supervisor's (capoturno) credentials in the request
|
||||||
@@ -97,7 +99,7 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
|||||||
return True
|
return True
|
||||||
if method != "POST":
|
if method != "POST":
|
||||||
return False
|
return False
|
||||||
if path == cls.LOGIN_PATH:
|
if path in (cls.LOGIN_PATH, cls.LOGIN_PIN_PATH):
|
||||||
return True
|
return True
|
||||||
return any(p.match(path) for p in cls._SUPERVISOR_CREDENTIAL_PATTERNS)
|
return any(p.match(path) for p in cls._SUPERVISOR_CREDENTIAL_PATTERNS)
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Authentication router - login, logout, profile."""
|
"""Authentication router - login, logout, profile."""
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||||
|
from sqlalchemy import select
|
||||||
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
|
||||||
@@ -8,10 +9,17 @@ from src.backend.models.orm.user import User
|
|||||||
from src.backend.models.api.user import (
|
from src.backend.models.api.user import (
|
||||||
LoginRequest,
|
LoginRequest,
|
||||||
LoginResponse,
|
LoginResponse,
|
||||||
|
PinLoginRequest,
|
||||||
|
PinUserItem,
|
||||||
UserProfileUpdate,
|
UserProfileUpdate,
|
||||||
UserResponse,
|
UserResponse,
|
||||||
)
|
)
|
||||||
from src.backend.services.auth_service import authenticate_user, login_user, logout_user
|
from src.backend.services.auth_service import (
|
||||||
|
authenticate_pin,
|
||||||
|
authenticate_user,
|
||||||
|
login_user,
|
||||||
|
logout_user,
|
||||||
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||||
|
|
||||||
@@ -32,6 +40,35 @@ async def login(data: LoginRequest, db: AsyncSession = Depends(get_db)):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/pin-users", response_model=list[PinUserItem])
|
||||||
|
async def pin_users(db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Utenti che possono entrare con PIN (#59): attivi, non admin, con PIN.
|
||||||
|
|
||||||
|
Pubblico per costruzione (serve prima del login): espone solo id e nome.
|
||||||
|
"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(User).where(
|
||||||
|
User.active == True, # noqa: E712
|
||||||
|
User.is_admin == False, # noqa: E712
|
||||||
|
User.pin_hash.is_not(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
items = [
|
||||||
|
PinUserItem(id=u.id, display_name=u.display_name or u.username)
|
||||||
|
for u in result.scalars().all()
|
||||||
|
]
|
||||||
|
items.sort(key=lambda i: i.display_name.casefold())
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login-pin", response_model=LoginResponse)
|
||||||
|
async def login_pin(data: PinLoginRequest, db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Login con user_id e PIN a 4 cifre (#59); stessa risposta del login."""
|
||||||
|
user = await authenticate_pin(db, data.user_id, data.pin)
|
||||||
|
api_key = await login_user(db, user)
|
||||||
|
return LoginResponse(user=UserResponse.model_validate(user), api_key=api_key)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
|
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
async def logout(
|
async def logout(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|||||||
@@ -61,7 +61,8 @@ def detect_file_type(content: bytes) -> tuple[str, str] | None:
|
|||||||
|
|
||||||
# Top-level folders nothing may DELETE through this router: statistics/reports are
|
# Top-level folders nothing may DELETE through this router: statistics/reports are
|
||||||
# audit records, the logo is only ever replaced via POST /api/settings/logo.
|
# audit records, the logo is only ever replaced via POST /api/settings/logo.
|
||||||
_UNDELETABLE_PREFIXES = ("statistics/", "reports/", "logos/")
|
# task_results/: allegati degli esiti dei task (#55), record di audit.
|
||||||
|
_UNDELETABLE_PREFIXES = ("statistics/", "reports/", "logos/", "task_results/")
|
||||||
|
|
||||||
# GET under these needs a role that is meant to see production data, not just
|
# GET under these needs a role that is meant to see production data, not just
|
||||||
# any authenticated user (a Maker or MeasurementTec has no business reading
|
# any authenticated user (a Maker or MeasurementTec has no business reading
|
||||||
|
|||||||
@@ -147,6 +147,7 @@ async def authorise_measurement(
|
|||||||
|
|
||||||
supervisor = await auth_service.authorise_supervisor(
|
supervisor = await auth_service.authorise_supervisor(
|
||||||
db, action.supervisor_username, action.supervisor_password,
|
db, action.supervisor_username, action.supervisor_password,
|
||||||
|
user_id=action.supervisor_user_id, pin=action.supervisor_pin, # #59
|
||||||
)
|
)
|
||||||
measurement = await measurement_service.authorise_measurement(
|
measurement = await measurement_service.authorise_measurement(
|
||||||
db, measurement, supervisor,
|
db, measurement, supervisor,
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ router = APIRouter(prefix="/api/production-runs", tags=["production"])
|
|||||||
|
|
||||||
_DERIVED = {
|
_DERIVED = {
|
||||||
"seconds_to_next_measurement", "overdue", "server_time", "measurement_task_ids",
|
"seconds_to_next_measurement", "overdue", "server_time", "measurement_task_ids",
|
||||||
|
"close_task_ids",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -53,6 +54,8 @@ async def _as_response(db: AsyncSession, run: ProductionRun) -> ProductionRunRes
|
|||||||
measurement_task_ids=await production_service.measurement_task_ids(
|
measurement_task_ids=await production_service.measurement_task_ids(
|
||||||
db, run.version_id,
|
db, run.version_id,
|
||||||
),
|
),
|
||||||
|
# #53: task da compilare alla fine produzione
|
||||||
|
close_task_ids=await production_service.close_task_ids(db, run.version_id),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -163,6 +166,7 @@ async def pause_production_run(
|
|||||||
run = await production_service.get_run(db, run_id)
|
run = await production_service.get_run(db, run_id)
|
||||||
supervisor = await auth_service.authorise_supervisor(
|
supervisor = await auth_service.authorise_supervisor(
|
||||||
db, action.supervisor_username, action.supervisor_password,
|
db, action.supervisor_username, action.supervisor_password,
|
||||||
|
user_id=action.supervisor_user_id, pin=action.supervisor_pin, # #59
|
||||||
)
|
)
|
||||||
run = await production_service.pause_run(db, run, user, supervisor, note=action.note)
|
run = await production_service.pause_run(db, run, user, supervisor, note=action.note)
|
||||||
return await _as_response(db, run)
|
return await _as_response(db, run)
|
||||||
@@ -179,6 +183,7 @@ async def resume_production_run(
|
|||||||
run = await production_service.get_run(db, run_id)
|
run = await production_service.get_run(db, run_id)
|
||||||
supervisor = await auth_service.authorise_supervisor(
|
supervisor = await auth_service.authorise_supervisor(
|
||||||
db, action.supervisor_username, action.supervisor_password,
|
db, action.supervisor_username, action.supervisor_password,
|
||||||
|
user_id=action.supervisor_user_id, pin=action.supervisor_pin, # #59
|
||||||
)
|
)
|
||||||
run = await production_service.resume_run(db, run, user, supervisor, note=action.note)
|
run = await production_service.resume_run(db, run, user, supervisor, note=action.note)
|
||||||
return await _as_response(db, run)
|
return await _as_response(db, run)
|
||||||
@@ -195,6 +200,7 @@ async def close_production_run(
|
|||||||
run = await production_service.get_run(db, run_id)
|
run = await production_service.get_run(db, run_id)
|
||||||
supervisor = await auth_service.authorise_supervisor(
|
supervisor = await auth_service.authorise_supervisor(
|
||||||
db, action.supervisor_username, action.supervisor_password,
|
db, action.supervisor_username, action.supervisor_password,
|
||||||
|
user_id=action.supervisor_user_id, pin=action.supervisor_pin, # #59
|
||||||
)
|
)
|
||||||
run = await production_service.close_run(db, run, user, supervisor, note=action.note)
|
run = await production_service.close_run(db, run, user, supervisor, note=action.note)
|
||||||
return await _as_response(db, run)
|
return await _as_response(db, run)
|
||||||
|
|||||||
@@ -67,6 +67,21 @@ async def create_new_station(
|
|||||||
# The int-typed station_id param already guards against "by-code" being
|
# The int-typed station_id param already guards against "by-code" being
|
||||||
# matched as a station id, but keeping the explicit order avoids surprises
|
# matched as a station id, but keeping the explicit order avoids surprises
|
||||||
# during refactors (e.g. if someone regroups handlers by HTTP method).
|
# during refactors (e.g. if someone regroups handlers by HTTP method).
|
||||||
|
@router.get("/by-code/{code}", response_model=StationResponse)
|
||||||
|
async def get_station_by_code(
|
||||||
|
code: str,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Get a station by its code (any authenticated user, like /by-code/{code}/recipes) (#72)."""
|
||||||
|
station = await station_service.get_station_by_code(db, code)
|
||||||
|
if station is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Station not found",
|
||||||
|
)
|
||||||
|
return StationResponse.model_validate(station)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/by-code/{code}/recipes", response_model=list[RecipeSummary])
|
@router.get("/by-code/{code}/recipes", response_model=list[RecipeSummary])
|
||||||
async def list_recipes_by_station_code(
|
async def list_recipes_by_station_code(
|
||||||
code: str,
|
code: str,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from src.backend.models.orm.measurement import Measurement
|
|||||||
from src.backend.models.orm.production import ProductionRun
|
from src.backend.models.orm.production import ProductionRun
|
||||||
from src.backend.models.orm.recipe import RecipeVersion
|
from src.backend.models.orm.recipe import RecipeVersion
|
||||||
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
||||||
|
from src.backend.models.orm.task_result import TaskResult # #58: esiti dei task della sessione
|
||||||
from src.backend.models.orm.user import User
|
from src.backend.models.orm.user import User
|
||||||
from src.backend.models.api.statistics import (
|
from src.backend.models.api.statistics import (
|
||||||
CapabilityData,
|
CapabilityData,
|
||||||
@@ -294,3 +295,76 @@ async def get_recipe_runs(
|
|||||||
}
|
}
|
||||||
for r in runs
|
for r in runs
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/runs/{run_id}/task-results")
|
||||||
|
async def get_run_task_results(
|
||||||
|
run_id: int,
|
||||||
|
user: User = Depends(require_metrologist),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
"""#58: esiti, note, campi e allegati dei task di una sessione di produzione.
|
||||||
|
|
||||||
|
Endpoint di statistica (require_metrologist): GET /api/task-results vuole
|
||||||
|
il ruolo MeasurementTec e non si puo' riusare qui.
|
||||||
|
"""
|
||||||
|
run = (await db.execute(
|
||||||
|
select(ProductionRun.id).where(ProductionRun.id == run_id)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if run is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Sessione non trovata")
|
||||||
|
|
||||||
|
# #58: una query per i risultati della run, una per i task, una per gli utenti.
|
||||||
|
results = (await db.execute(
|
||||||
|
select(TaskResult)
|
||||||
|
.where(TaskResult.production_run_id == run_id)
|
||||||
|
.order_by(TaskResult.created_at.asc(), TaskResult.id.asc())
|
||||||
|
)).scalars().all()
|
||||||
|
|
||||||
|
task_ids = {r.task_id for r in results}
|
||||||
|
tasks = {}
|
||||||
|
if task_ids:
|
||||||
|
tasks = {t.id: t for t in (await db.execute(
|
||||||
|
select(RecipeTask).where(RecipeTask.id.in_(task_ids))
|
||||||
|
)).scalars().all()}
|
||||||
|
user_ids = {r.user_id for r in results}
|
||||||
|
names = {}
|
||||||
|
if user_ids:
|
||||||
|
names = dict((await db.execute(
|
||||||
|
select(User.id, User.display_name).where(User.id.in_(user_ids))
|
||||||
|
)).all())
|
||||||
|
|
||||||
|
counts = {"ok": 0, "nok": 0, "done": 0}
|
||||||
|
out = []
|
||||||
|
for r in results:
|
||||||
|
counts[r.outcome] = counts.get(r.outcome, 0) + 1
|
||||||
|
task = tasks.get(r.task_id)
|
||||||
|
# #58: etichette dai campi del task; se il campo e' stato tolto vale la chiave.
|
||||||
|
labels = {
|
||||||
|
f.get("key"): f.get("label") or f.get("key")
|
||||||
|
for f in (task.fields or []) if isinstance(f, dict)
|
||||||
|
} if task else {}
|
||||||
|
out.append({
|
||||||
|
"id": r.id,
|
||||||
|
"task_id": r.task_id,
|
||||||
|
"task_title": task.title if task else None,
|
||||||
|
"task_type": task.task_type if task else None,
|
||||||
|
"outcome": r.outcome,
|
||||||
|
"note": r.note,
|
||||||
|
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||||||
|
"user_name": names.get(r.user_id),
|
||||||
|
"values": [
|
||||||
|
{"key": k, "label": labels.get(k, k), "value": v}
|
||||||
|
for k, v in (r.values or {}).items()
|
||||||
|
],
|
||||||
|
"attachments": [
|
||||||
|
{
|
||||||
|
"key": a.get("key"),
|
||||||
|
"label": labels.get(a.get("key"), a.get("key")),
|
||||||
|
"name": a.get("name"),
|
||||||
|
"path": a.get("path"),
|
||||||
|
}
|
||||||
|
for a in (r.attachments or []) if isinstance(a, dict)
|
||||||
|
],
|
||||||
|
})
|
||||||
|
return {"run_id": run_id, "counts": counts, "results": out}
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
"""Task results router: l'esito di un task 'check' (OK/NOK) o 'note' (#54 #55).
|
||||||
|
|
||||||
|
Un 'check' registra ok o nok (nok vuole la nota); un 'note' registra 'done' con i
|
||||||
|
valori dei campi extra e gli allegati. I task di misura non passano di qui: le
|
||||||
|
loro letture sono Measurement.
|
||||||
|
"""
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.backend.api.middleware.api_key import require_measurement_tec
|
||||||
|
from src.backend.api.routers.files import (
|
||||||
|
_UPLOAD_CHUNK_SIZE, detect_file_type, sanitize_filename,
|
||||||
|
)
|
||||||
|
from src.backend.config import settings
|
||||||
|
from src.backend.database import get_db
|
||||||
|
from src.backend.models.api.task_result import TaskResultCreate, TaskResultResponse
|
||||||
|
from src.backend.models.orm.production import ProductionRun
|
||||||
|
from src.backend.models.orm.task import RecipeTask
|
||||||
|
from src.backend.models.orm.task_result import TaskResult
|
||||||
|
from src.backend.models.orm.user import User
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/task-results", tags=["task-results"])
|
||||||
|
|
||||||
|
|
||||||
|
def _unprocessable(detail: str) -> HTTPException:
|
||||||
|
# 422 con detail testuale: l'api_client del frontend legge solo `detail`
|
||||||
|
return HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=detail)
|
||||||
|
|
||||||
|
|
||||||
|
# Radice degli allegati degli esiti: sotto upload_path, mai cancellabile (audit).
|
||||||
|
ATTACHMENT_DIR = "task_results"
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_relative_path(path: str) -> bool:
|
||||||
|
"""Allegato: relativo, dentro task_results/, senza '..', unita' o backslash."""
|
||||||
|
if path.startswith("/") or "\\" in path or ":" in path:
|
||||||
|
return False
|
||||||
|
parts = path.split("/")
|
||||||
|
return ".." not in parts and parts[0] == ATTACHMENT_DIR and len(parts) > 1
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/attachments", status_code=status.HTTP_201_CREATED)
|
||||||
|
async def upload_attachment(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
_user: User = Depends(require_measurement_tec),
|
||||||
|
):
|
||||||
|
"""Carica l'allegato di un task 'note' (l'operatore non ha accesso a /api/files/upload).
|
||||||
|
|
||||||
|
Stessi controlli di upload_file: tipo dai magic bytes, limite di dimensione
|
||||||
|
con lettura a blocchi, nome ripulito. Salva sotto task_results/<AAAA>/<MM>/.
|
||||||
|
"""
|
||||||
|
max_bytes = settings.max_upload_size_mb * 1024 * 1024
|
||||||
|
buffer = bytearray()
|
||||||
|
while True:
|
||||||
|
chunk = await file.read(_UPLOAD_CHUNK_SIZE)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
buffer.extend(chunk)
|
||||||
|
if len(buffer) > max_bytes:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"File size exceeds maximum {settings.max_upload_size_mb}MB",
|
||||||
|
)
|
||||||
|
content = bytes(buffer)
|
||||||
|
|
||||||
|
detected = detect_file_type(content)
|
||||||
|
if detected is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="File type not allowed. Must be a recognised image or PDF.",
|
||||||
|
)
|
||||||
|
_mime, ext = detected
|
||||||
|
|
||||||
|
# nome originale ripulito (per mostrarlo); sul disco solo uuid + estensione rilevata
|
||||||
|
name = sanitize_filename(file.filename or "")
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
storage_dir = settings.upload_path / ATTACHMENT_DIR / f"{now:%Y}" / f"{now:%m}"
|
||||||
|
storage_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
target = storage_dir / f"{uuid.uuid4().hex}{ext}"
|
||||||
|
target.write_bytes(content)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"file_path": str(target.relative_to(settings.upload_path)).replace("\\", "/"),
|
||||||
|
"name": name,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=TaskResultResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_task_result(
|
||||||
|
data: TaskResultCreate,
|
||||||
|
user: User = Depends(require_measurement_tec),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Registra l'esito di un task 'check' o 'note'."""
|
||||||
|
task = (await db.execute(
|
||||||
|
select(RecipeTask).where(RecipeTask.id == data.task_id)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if task is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Task not found")
|
||||||
|
|
||||||
|
note = data.note.strip() if data.note else None
|
||||||
|
note = note or None
|
||||||
|
values: Optional[dict] = None
|
||||||
|
attachments: Optional[list] = None
|
||||||
|
|
||||||
|
if task.task_type == "check":
|
||||||
|
if data.outcome not in ("ok", "nok"):
|
||||||
|
raise _unprocessable("Un controllo si registra come ok o nok")
|
||||||
|
if data.outcome == "nok" and not note:
|
||||||
|
raise _unprocessable("Per un NOK serve la nota")
|
||||||
|
elif task.task_type == "note":
|
||||||
|
if data.outcome != "done":
|
||||||
|
raise _unprocessable("Una nota si registra come done")
|
||||||
|
fields = {f["key"]: f for f in (task.fields or [])}
|
||||||
|
values = {}
|
||||||
|
for key, value in (data.values or {}).items():
|
||||||
|
if key not in fields:
|
||||||
|
raise _unprocessable(f"Campo '{key}' non previsto dal task")
|
||||||
|
if fields[key]["type"] == "number" and value.strip() != "":
|
||||||
|
try:
|
||||||
|
float(value.replace(",", "."))
|
||||||
|
except ValueError:
|
||||||
|
raise _unprocessable(f"Il campo '{fields[key]['label']}' deve essere un numero")
|
||||||
|
values[key] = value
|
||||||
|
attachments = []
|
||||||
|
for att in data.attachments or []:
|
||||||
|
if not _safe_relative_path(att.path):
|
||||||
|
raise _unprocessable(
|
||||||
|
f"Percorso allegato non valido (deve stare in {ATTACHMENT_DIR}/): {att.path}"
|
||||||
|
)
|
||||||
|
if att.key is not None and att.key not in fields:
|
||||||
|
raise _unprocessable(f"Campo allegato '{att.key}' non previsto dal task")
|
||||||
|
attachments.append(att.model_dump())
|
||||||
|
values = values or None
|
||||||
|
attachments = attachments or None
|
||||||
|
else:
|
||||||
|
raise _unprocessable(
|
||||||
|
f"I task di tipo '{task.task_type}' non registrano un esito"
|
||||||
|
)
|
||||||
|
|
||||||
|
if data.production_run_id is not None:
|
||||||
|
run = (await db.execute(
|
||||||
|
select(ProductionRun).where(ProductionRun.id == data.production_run_id)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if run is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Production run not found"
|
||||||
|
)
|
||||||
|
if run.status == "closed":
|
||||||
|
raise _unprocessable("La produzione e' gia' chiusa")
|
||||||
|
if run.version_id != task.version_id:
|
||||||
|
raise _unprocessable("Il task non appartiene alla versione di questa produzione")
|
||||||
|
|
||||||
|
result = TaskResult(
|
||||||
|
task_id=task.id,
|
||||||
|
version_id=task.version_id, # presa dal task, non dal client
|
||||||
|
production_run_id=data.production_run_id,
|
||||||
|
user_id=user.id,
|
||||||
|
outcome=data.outcome,
|
||||||
|
note=note,
|
||||||
|
values=values,
|
||||||
|
attachments=attachments,
|
||||||
|
)
|
||||||
|
db.add(result)
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(result) # created_at e' un server_default
|
||||||
|
return TaskResultResponse.model_validate(result)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[TaskResultResponse])
|
||||||
|
async def list_task_results(
|
||||||
|
production_run_id: Optional[int] = Query(None, gt=0),
|
||||||
|
task_id: Optional[int] = Query(None, gt=0),
|
||||||
|
_user: User = Depends(require_measurement_tec),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Esiti registrati, filtrabili per produzione e/o task; il piu' recente prima."""
|
||||||
|
stmt = select(TaskResult)
|
||||||
|
if production_run_id is not None:
|
||||||
|
stmt = stmt.where(TaskResult.production_run_id == production_run_id)
|
||||||
|
if task_id is not None:
|
||||||
|
stmt = stmt.where(TaskResult.task_id == task_id)
|
||||||
|
rows = (await db.execute(
|
||||||
|
stmt.order_by(TaskResult.created_at.desc(), TaskResult.id.desc())
|
||||||
|
)).scalars().all()
|
||||||
|
return [TaskResultResponse.model_validate(r) for r in rows]
|
||||||
@@ -17,6 +17,9 @@ from src.backend.models.api.task import (
|
|||||||
TaskReorderRequest,
|
TaskReorderRequest,
|
||||||
TaskResponse,
|
TaskResponse,
|
||||||
TaskUpdate,
|
TaskUpdate,
|
||||||
|
check_task_loop_options,
|
||||||
|
default_in_loop,
|
||||||
|
normalize_note_fields,
|
||||||
validate_tolerance_order,
|
validate_tolerance_order,
|
||||||
)
|
)
|
||||||
from src.backend.models.orm.vision import VisionReferenceImage
|
from src.backend.models.orm.vision import VisionReferenceImage
|
||||||
@@ -202,6 +205,13 @@ async def create_task(
|
|||||||
This creates a NEW version via copy-on-write, then appends the task to
|
This creates a NEW version via copy-on-write, then appends the task to
|
||||||
the new version. Requires Maker role.
|
the new version. Requires Maker role.
|
||||||
"""
|
"""
|
||||||
|
# #53 #55: validazione di in_loop/at_close/fields prima di toccare il db
|
||||||
|
try:
|
||||||
|
fields = normalize_note_fields(data.fields)
|
||||||
|
check_task_loop_options(data.task_type, data.at_close, fields)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=422, detail=str(exc))
|
||||||
|
|
||||||
# Ensure recipe exists and is active
|
# Ensure recipe exists and is active
|
||||||
result = await db.execute(select(Recipe).where(Recipe.id == recipe_id))
|
result = await db.execute(select(Recipe).where(Recipe.id == recipe_id))
|
||||||
recipe = result.scalar_one_or_none()
|
recipe = result.scalar_one_or_none()
|
||||||
@@ -240,6 +250,10 @@ async def create_task(
|
|||||||
file_type=data.file_type,
|
file_type=data.file_type,
|
||||||
annotations_json=data.annotations_json,
|
annotations_json=data.annotations_json,
|
||||||
vision_json=data.vision_json,
|
vision_json=data.vision_json,
|
||||||
|
# #53 #55: ciclo (default dal tipo), chiusura, campi extra
|
||||||
|
in_loop=data.in_loop if data.in_loop is not None else default_in_loop(data.task_type),
|
||||||
|
at_close=data.at_close,
|
||||||
|
fields=fields,
|
||||||
)
|
)
|
||||||
db.add(new_task)
|
db.add(new_task)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
@@ -341,6 +355,26 @@ async def update_task(
|
|||||||
if not update_data:
|
if not update_data:
|
||||||
return TaskResponse.model_validate(task)
|
return TaskResponse.model_validate(task)
|
||||||
|
|
||||||
|
# #53 #55: i tre campi nuovi si validano sulla combinazione finale (valori
|
||||||
|
# nuovi, o quelli gia' sul task per cio' che il client non manda)
|
||||||
|
if "fields" in update_data:
|
||||||
|
try:
|
||||||
|
update_data["fields"] = normalize_note_fields(update_data["fields"])
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=422, detail=str(exc))
|
||||||
|
for flag in ("in_loop", "at_close"):
|
||||||
|
if flag in update_data and update_data[flag] is None:
|
||||||
|
del update_data[flag] # i booleani non sono nullable: null = non toccare
|
||||||
|
final_type = update_data.get("task_type") or task.task_type
|
||||||
|
try:
|
||||||
|
check_task_loop_options(
|
||||||
|
final_type,
|
||||||
|
update_data.get("at_close", task.at_close),
|
||||||
|
update_data.get("fields", task.fields),
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=422, detail=str(exc))
|
||||||
|
|
||||||
task = await _task_for_write(db, task, user)
|
task = await _task_for_write(db, task, user)
|
||||||
|
|
||||||
for field, value in update_data.items():
|
for field, value in update_data.items():
|
||||||
|
|||||||
@@ -6,8 +6,14 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from src.backend.database import get_db
|
from src.backend.database import get_db
|
||||||
from src.backend.api.middleware.api_key import require_admin_user
|
from src.backend.api.middleware.api_key import require_admin_user
|
||||||
from src.backend.models.orm.user import User
|
from src.backend.models.orm.user import User
|
||||||
from src.backend.models.api.user import UserCreate, UserPasswordChange, UserResponse, UserUpdate
|
from src.backend.models.api.user import PinSet, UserCreate, UserPasswordChange, UserResponse, UserUpdate
|
||||||
from src.backend.services.auth_service import create_user, hash_password, regenerate_api_key, revoke_all_keys
|
from src.backend.services.auth_service import (
|
||||||
|
create_user,
|
||||||
|
hash_password,
|
||||||
|
regenerate_api_key,
|
||||||
|
revoke_all_keys,
|
||||||
|
set_user_pin,
|
||||||
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/users", tags=["users"])
|
router = APIRouter(prefix="/api/users", tags=["users"])
|
||||||
|
|
||||||
@@ -151,6 +157,30 @@ async def change_user_password(
|
|||||||
return {"message": f"Password changed for user {user.username}"}
|
return {"message": f"Password changed for user {user.username}"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{user_id}/pin", status_code=status.HTTP_200_OK)
|
||||||
|
async def change_user_pin(
|
||||||
|
user_id: int,
|
||||||
|
data: PinSet,
|
||||||
|
admin: User = Depends(require_admin_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Imposta o toglie (pin null) il PIN di un utente (admin, #59/#51).
|
||||||
|
|
||||||
|
422 se l'utente e' admin. Il PIN non si restituisce mai.
|
||||||
|
"""
|
||||||
|
result = await db.execute(select(User).where(User.id == user_id))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||||
|
if user.is_admin:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail="Admin users cannot have a PIN",
|
||||||
|
)
|
||||||
|
await set_user_pin(db, user, data.pin)
|
||||||
|
return {"message": f"PIN {'set' if data.pin else 'removed'} for user {user.username}"}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
async def deactivate_user(
|
async def deactivate_user(
|
||||||
user_id: int,
|
user_id: int,
|
||||||
|
|||||||
+17
-2
@@ -27,6 +27,10 @@ class Settings(BaseSettings):
|
|||||||
db_name: str = "tiemeasureflow"
|
db_name: str = "tiemeasureflow"
|
||||||
db_user: str = "tmflow"
|
db_user: str = "tmflow"
|
||||||
db_password: str = _DEFAULT_DB_PASSWORD
|
db_password: str = _DEFAULT_DB_PASSWORD
|
||||||
|
# Full SQLAlchemy async URL (env DB_URL). When set it wins over the
|
||||||
|
# DB_HOST/DB_PORT/DB_NAME/DB_USER/DB_PASSWORD parts. Used by the Windows
|
||||||
|
# package: sqlite+aiosqlite:///C:/ProgramData/.../tmf.db (forward slashes).
|
||||||
|
db_url: str | None = None
|
||||||
|
|
||||||
# Presentation time zone (DB is UTC, #42)
|
# Presentation time zone (DB is UTC, #42)
|
||||||
display_timezone: str = "Europe/Rome"
|
display_timezone: str = "Europe/Rome"
|
||||||
@@ -78,7 +82,9 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def database_url(self) -> str:
|
def database_url(self) -> str:
|
||||||
"""Async MySQL connection string."""
|
"""Async connection string: DB_URL if set, else the MySQL one."""
|
||||||
|
if self.db_url:
|
||||||
|
return self.db_url
|
||||||
return (
|
return (
|
||||||
f"mysql+asyncmy://{self.db_user}:{self.db_password}"
|
f"mysql+asyncmy://{self.db_user}:{self.db_password}"
|
||||||
f"@{self.db_host}:{self.db_port}/{self.db_name}"
|
f"@{self.db_host}:{self.db_port}/{self.db_name}"
|
||||||
@@ -97,7 +103,12 @@ class Settings(BaseSettings):
|
|||||||
(mounted as a Docker volume), not inside the backend tree.
|
(mounted as a Docker volume), not inside the backend tree.
|
||||||
"""
|
"""
|
||||||
# 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
|
# UPLOAD_DIR may be absolute (Windows package: C:/ProgramData/...):
|
||||||
|
# in that case it is used as is instead of being joined to the root.
|
||||||
|
configured = Path(self.upload_dir)
|
||||||
|
if configured.is_absolute():
|
||||||
|
return configured
|
||||||
|
return Path(__file__).resolve().parents[2] / configured
|
||||||
|
|
||||||
# Always resolve .env against the project root regardless of cwd
|
# Always resolve .env against the project root regardless of cwd
|
||||||
# (pydantic-settings would otherwise treat the path as cwd-relative).
|
# (pydantic-settings would otherwise treat the path as cwd-relative).
|
||||||
@@ -122,6 +133,10 @@ class Settings(BaseSettings):
|
|||||||
"SERVER_SECRET_KEY is still the committed default value; "
|
"SERVER_SECRET_KEY is still the committed default value; "
|
||||||
"set it before running with APP_ENV=" + self.app_env
|
"set it before running with APP_ENV=" + self.app_env
|
||||||
)
|
)
|
||||||
|
# A SQLite file has no DB password: the default-password guard only
|
||||||
|
# makes sense for the MySQL connection built from the DB_* parts.
|
||||||
|
if self.db_url and self.db_url.startswith("sqlite"):
|
||||||
|
return self
|
||||||
if self.db_password == _DEFAULT_DB_PASSWORD:
|
if self.db_password == _DEFAULT_DB_PASSWORD:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"DB_PASSWORD is still the committed default value; "
|
"DB_PASSWORD is still the committed default value; "
|
||||||
|
|||||||
+25
-8
@@ -1,6 +1,7 @@
|
|||||||
"""Async SQLAlchemy database engine and session management."""
|
"""Async SQLAlchemy database engine and session management."""
|
||||||
from collections.abc import AsyncGenerator
|
from collections.abc import AsyncGenerator
|
||||||
|
|
||||||
|
from sqlalchemy import event
|
||||||
from sqlalchemy.ext.asyncio import (
|
from sqlalchemy.ext.asyncio import (
|
||||||
AsyncSession,
|
AsyncSession,
|
||||||
async_sessionmaker,
|
async_sessionmaker,
|
||||||
@@ -10,14 +11,30 @@ from sqlalchemy.orm import DeclarativeBase
|
|||||||
|
|
||||||
from src.backend.config import settings
|
from src.backend.config import settings
|
||||||
|
|
||||||
# Create async engine
|
_IS_SQLITE = settings.database_url.startswith("sqlite")
|
||||||
engine = create_async_engine(
|
|
||||||
settings.database_url,
|
# Create async engine. MySQL gets a connection pool; SQLite (local file of the
|
||||||
echo=False,
|
# Windows package) does not accept pool_size/max_overflow/pool_recycle.
|
||||||
pool_size=10,
|
if _IS_SQLITE:
|
||||||
max_overflow=20,
|
engine = create_async_engine(settings.database_url, echo=False)
|
||||||
pool_recycle=3600,
|
|
||||||
)
|
@event.listens_for(engine.sync_engine, "connect")
|
||||||
|
def _sqlite_pragmas(dbapi_connection, _connection_record) -> None:
|
||||||
|
"""Per-connection PRAGMAs: SQLite ignores FKs unless asked, and WAL +
|
||||||
|
busy_timeout let the web, vision and backend threads share the file."""
|
||||||
|
cursor = dbapi_connection.cursor()
|
||||||
|
cursor.execute("PRAGMA foreign_keys=ON")
|
||||||
|
cursor.execute("PRAGMA journal_mode=WAL")
|
||||||
|
cursor.execute("PRAGMA busy_timeout=5000")
|
||||||
|
cursor.close()
|
||||||
|
else:
|
||||||
|
engine = create_async_engine(
|
||||||
|
settings.database_url,
|
||||||
|
echo=False,
|
||||||
|
pool_size=10,
|
||||||
|
max_overflow=20,
|
||||||
|
pool_recycle=3600,
|
||||||
|
)
|
||||||
|
|
||||||
# Session factory
|
# Session factory
|
||||||
async_session_factory = async_sessionmaker(
|
async_session_factory = async_sessionmaker(
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ from src.backend.api.routers.setup import router as setup_router
|
|||||||
from src.backend.api.routers.stations import router as stations_router
|
from src.backend.api.routers.stations import router as stations_router
|
||||||
from src.backend.api.routers.production import router as production_router
|
from src.backend.api.routers.production import router as production_router
|
||||||
from src.backend.api.routers.vision import router as vision_router
|
from src.backend.api.routers.vision import router as vision_router
|
||||||
|
from src.backend.api.routers.task_results import router as task_results_router
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
@@ -77,6 +78,7 @@ app.include_router(setup_router)
|
|||||||
app.include_router(stations_router)
|
app.include_router(stations_router)
|
||||||
app.include_router(production_router)
|
app.include_router(production_router)
|
||||||
app.include_router(vision_router)
|
app.include_router(vision_router)
|
||||||
|
app.include_router(task_results_router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
|
|||||||
@@ -22,24 +22,44 @@ depends_on: Union[str, Sequence[str], None] = None
|
|||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
op.create_table(
|
# Idempotent on purpose (#71): a database born from create_all has no
|
||||||
'api_keys',
|
# `uq_users_api_key` (only the unique index `ix_users_api_key`), and on
|
||||||
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
# MySQL a run that dies half way (no transactional DDL) leaves `api_keys`
|
||||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
# created and `alembic_version` still at 014. Every step checks first.
|
||||||
sa.Column('key_hash', sa.String(64), nullable=False),
|
insp = sa.inspect(op.get_bind())
|
||||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
|
||||||
sa.Column('expires_at', sa.DateTime(), nullable=False),
|
|
||||||
sa.Column('last_used_at', sa.DateTime(), nullable=True),
|
|
||||||
sa.ForeignKeyConstraint(
|
|
||||||
['user_id'], ['users.id'], name='fk_api_keys_user_id', ondelete='CASCADE'
|
|
||||||
),
|
|
||||||
)
|
|
||||||
op.create_index('ix_api_keys_key_hash', 'api_keys', ['key_hash'], unique=True)
|
|
||||||
op.create_index('ix_api_keys_user_id', 'api_keys', ['user_id'])
|
|
||||||
|
|
||||||
|
if 'api_keys' not in insp.get_table_names():
|
||||||
|
op.create_table(
|
||||||
|
'api_keys',
|
||||||
|
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('key_hash', sa.String(64), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('expires_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('last_used_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
['user_id'], ['users.id'], name='fk_api_keys_user_id', ondelete='CASCADE'
|
||||||
|
),
|
||||||
|
)
|
||||||
|
existing_idx: set[str] = set()
|
||||||
|
else:
|
||||||
|
existing_idx = {i['name'] for i in insp.get_indexes('api_keys')}
|
||||||
|
|
||||||
|
if 'ix_api_keys_key_hash' not in existing_idx:
|
||||||
|
op.create_index('ix_api_keys_key_hash', 'api_keys', ['key_hash'], unique=True)
|
||||||
|
if 'ix_api_keys_user_id' not in existing_idx:
|
||||||
|
op.create_index('ix_api_keys_user_id', 'api_keys', ['user_id'])
|
||||||
|
|
||||||
|
if 'api_key' not in {c['name'] for c in insp.get_columns('users')}:
|
||||||
|
return # column already gone: nothing else to do
|
||||||
|
|
||||||
|
user_idx = {i['name'] for i in insp.get_indexes('users')}
|
||||||
|
user_uq = {u['name'] for u in insp.get_unique_constraints('users')}
|
||||||
with op.batch_alter_table('users') as batch:
|
with op.batch_alter_table('users') as batch:
|
||||||
batch.drop_index('ix_users_api_key')
|
if 'ix_users_api_key' in user_idx:
|
||||||
batch.drop_constraint('uq_users_api_key', type_='unique')
|
batch.drop_index('ix_users_api_key')
|
||||||
|
if 'uq_users_api_key' in user_uq:
|
||||||
|
batch.drop_constraint('uq_users_api_key', type_='unique')
|
||||||
batch.drop_column('api_key')
|
batch.drop_column('api_key')
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""users: PIN a 4 cifre per login rapido e capoturno (#59)
|
||||||
|
|
||||||
|
pin_hash (bcrypt, mai in chiaro), contatore errori consecutivi e fine blocco.
|
||||||
|
|
||||||
|
Revision ID: 017_user_pin
|
||||||
|
Revises: 016_vision_recipe_dir
|
||||||
|
Create Date: 2026-10-07
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = '017_user_pin'
|
||||||
|
down_revision: Union[str, None] = '016_vision_recipe_dir'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# batch: SQLite non sa fare DROP COLUMN nativo su tutte le versioni
|
||||||
|
with op.batch_alter_table('users') as batch:
|
||||||
|
batch.add_column(sa.Column('pin_hash', sa.String(255), nullable=True))
|
||||||
|
batch.add_column(sa.Column(
|
||||||
|
'pin_failed_count', sa.Integer(), nullable=False, server_default='0'))
|
||||||
|
batch.add_column(sa.Column('pin_locked_until', sa.DateTime(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table('users') as batch:
|
||||||
|
batch.drop_column('pin_locked_until')
|
||||||
|
batch.drop_column('pin_failed_count')
|
||||||
|
batch.drop_column('pin_hash')
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
"""task: in_loop / at_close / fields, tipo 'check', tabella task_results (#53 #54 #55)
|
||||||
|
|
||||||
|
in_loop: il task fa parte del ciclo di produzione (prima: implicito dal tipo).
|
||||||
|
at_close: il task 'note' si compila alla fine produzione.
|
||||||
|
fields: campi extra del task 'note' (JSON).
|
||||||
|
task_results: esito registrato da un task 'check' (OK/NOK) o 'note' (done).
|
||||||
|
|
||||||
|
Revision ID: 018_task_loop_results
|
||||||
|
Revises: 017_user_pin
|
||||||
|
Create Date: 2026-10-07
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = '018_task_loop_results'
|
||||||
|
down_revision: Union[str, None] = '017_user_pin'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
OLD_TYPES = ('note', 'measure', 'drawing', 'dxf_compare', 'camera_measure')
|
||||||
|
NEW_TYPES = OLD_TYPES + ('check',)
|
||||||
|
# stessi tipi di MEASURING_TASK_TYPES: fino a oggi erano l'unico ciclo possibile
|
||||||
|
LOOP_TYPES = ('measure', 'camera_measure', 'dxf_compare')
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with op.batch_alter_table('recipe_tasks') as batch:
|
||||||
|
batch.add_column(sa.Column(
|
||||||
|
'in_loop', sa.Boolean(), nullable=False, server_default=sa.false()))
|
||||||
|
batch.add_column(sa.Column(
|
||||||
|
'at_close', sa.Boolean(), nullable=False, server_default=sa.false()))
|
||||||
|
batch.add_column(sa.Column('fields', sa.JSON(), nullable=True))
|
||||||
|
# enum allargato come in 011: 'check' = Controllo OK/NOK
|
||||||
|
batch.alter_column(
|
||||||
|
'task_type',
|
||||||
|
existing_type=sa.Enum(*OLD_TYPES, name='task_type_enum'),
|
||||||
|
type_=sa.Enum(*NEW_TYPES, name='task_type_enum'),
|
||||||
|
existing_nullable=False,
|
||||||
|
existing_server_default='note',
|
||||||
|
)
|
||||||
|
|
||||||
|
# i task di misura erano gia' nel ciclo: lo restano
|
||||||
|
recipe_tasks = sa.table(
|
||||||
|
'recipe_tasks', sa.column('task_type', sa.String), sa.column('in_loop', sa.Boolean))
|
||||||
|
op.execute(
|
||||||
|
recipe_tasks.update()
|
||||||
|
.where(recipe_tasks.c.task_type.in_(LOOP_TYPES))
|
||||||
|
.values(in_loop=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
'task_results',
|
||||||
|
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column('task_id', sa.Integer(),
|
||||||
|
sa.ForeignKey('recipe_tasks.id', ondelete='CASCADE'), nullable=False),
|
||||||
|
sa.Column('version_id', sa.Integer(),
|
||||||
|
sa.ForeignKey('recipe_versions.id'), nullable=False),
|
||||||
|
sa.Column('production_run_id', sa.Integer(),
|
||||||
|
sa.ForeignKey('production_runs.id', ondelete='SET NULL'), nullable=True),
|
||||||
|
sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=False),
|
||||||
|
sa.Column('outcome', sa.Enum('ok', 'nok', 'done', name='task_outcome_enum'),
|
||||||
|
nullable=False),
|
||||||
|
sa.Column('note', sa.Text(), nullable=True),
|
||||||
|
sa.Column('values', sa.JSON(), nullable=True),
|
||||||
|
sa.Column('attachments', sa.JSON(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False,
|
||||||
|
server_default=sa.func.now()),
|
||||||
|
mysql_engine='InnoDB',
|
||||||
|
mysql_charset='utf8mb4',
|
||||||
|
)
|
||||||
|
op.create_index('ix_task_results_task_id', 'task_results', ['task_id'])
|
||||||
|
op.create_index('ix_task_results_version_id', 'task_results', ['version_id'])
|
||||||
|
op.create_index(
|
||||||
|
'ix_task_results_production_run_id', 'task_results', ['production_run_id'])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# il tipo 'check' non esiste prima di 018: se un task lo usa, tornare indietro
|
||||||
|
# lo lascerebbe con un valore fuori enum
|
||||||
|
bind = op.get_bind()
|
||||||
|
used = bind.execute(
|
||||||
|
sa.text("SELECT COUNT(*) FROM recipe_tasks WHERE task_type = 'check'")
|
||||||
|
).scalar()
|
||||||
|
if used:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Downgrade 018 impossibile: {used} task usano il tipo 'check'. "
|
||||||
|
"Cambiare il tipo o eliminare quei task prima di tornare indietro."
|
||||||
|
)
|
||||||
|
|
||||||
|
op.drop_index('ix_task_results_production_run_id', table_name='task_results')
|
||||||
|
op.drop_index('ix_task_results_version_id', table_name='task_results')
|
||||||
|
op.drop_index('ix_task_results_task_id', table_name='task_results')
|
||||||
|
op.drop_table('task_results')
|
||||||
|
|
||||||
|
with op.batch_alter_table('recipe_tasks') as batch:
|
||||||
|
batch.alter_column(
|
||||||
|
'task_type',
|
||||||
|
existing_type=sa.Enum(*NEW_TYPES, name='task_type_enum'),
|
||||||
|
type_=sa.Enum(*OLD_TYPES, name='task_type_enum'),
|
||||||
|
existing_nullable=False,
|
||||||
|
existing_server_default='note',
|
||||||
|
)
|
||||||
|
batch.drop_column('fields')
|
||||||
|
batch.drop_column('at_close')
|
||||||
|
batch.drop_column('in_loop')
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""stations.vision_worker_url e vision_results.worker_url (#45)
|
||||||
|
|
||||||
|
stations.vision_worker_url: worker di visione della stazione (NULL = centrale).
|
||||||
|
vision_results.worker_url: worker effettivamente chiamato per quel risultato.
|
||||||
|
|
||||||
|
Revision ID: 019_station_vision_worker
|
||||||
|
Revises: 018_task_loop_results
|
||||||
|
Create Date: 2026-10-11
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = '019_station_vision_worker'
|
||||||
|
down_revision: Union[str, None] = '018_task_loop_results'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# batch_alter_table come nelle migrazioni vicine: serve a SQLite
|
||||||
|
with op.batch_alter_table('stations') as batch:
|
||||||
|
batch.add_column(sa.Column('vision_worker_url', sa.String(500), nullable=True))
|
||||||
|
with op.batch_alter_table('vision_results') as batch:
|
||||||
|
batch.add_column(sa.Column('worker_url', sa.String(500), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table('vision_results') as batch:
|
||||||
|
batch.drop_column('worker_url')
|
||||||
|
with op.batch_alter_table('stations') as batch:
|
||||||
|
batch.drop_column('vision_worker_url')
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""stations.camera_url e stations.camera_source (#72)
|
||||||
|
|
||||||
|
stations.camera_url: servizio camera della stazione (vs-gui o equivalente).
|
||||||
|
stations.camera_source: sorgente da chiedere al servizio, es. camera:<seriale>
|
||||||
|
o camera:folder.
|
||||||
|
|
||||||
|
Revision ID: 020_station_camera
|
||||||
|
Revises: 019_station_vision_worker
|
||||||
|
Create Date: 2026-10-15
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = '020_station_camera'
|
||||||
|
down_revision: Union[str, None] = '019_station_vision_worker'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# batch_alter_table come nelle migrazioni vicine: serve a SQLite
|
||||||
|
with op.batch_alter_table('stations') as batch:
|
||||||
|
batch.add_column(sa.Column('camera_url', sa.String(500), nullable=True))
|
||||||
|
batch.add_column(sa.Column('camera_source', sa.String(200), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table('stations') as batch:
|
||||||
|
batch.drop_column('camera_source')
|
||||||
|
batch.drop_column('camera_url')
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
from src.backend.models.api.tipi import UtcDateTime, UtcNaiveInput
|
from src.backend.models.api.tipi import UtcDateTime, UtcNaiveInput
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||||
|
|
||||||
|
|
||||||
class MeasurementCreate(BaseModel):
|
class MeasurementCreate(BaseModel):
|
||||||
@@ -71,8 +71,21 @@ class MeasurementListResponse(BaseModel):
|
|||||||
class MeasurementAuthorisation(BaseModel):
|
class MeasurementAuthorisation(BaseModel):
|
||||||
"""Credentials of the supervisor allowing an out-of-tolerance value to stand."""
|
"""Credentials of the supervisor allowing an out-of-tolerance value to stand."""
|
||||||
|
|
||||||
supervisor_username: str = Field(..., min_length=1)
|
# #59: o username+password, oppure supervisor_user_id+supervisor_pin
|
||||||
supervisor_password: str = Field(..., min_length=1)
|
supervisor_username: Optional[str] = Field(None, min_length=1)
|
||||||
|
supervisor_password: Optional[str] = Field(None, min_length=1)
|
||||||
|
supervisor_user_id: Optional[int] = Field(None, gt=0)
|
||||||
|
supervisor_pin: Optional[str] = Field(None, pattern=r"^[0-9]{4}$")
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _one_credential_pair(self):
|
||||||
|
by_password = self.supervisor_username and self.supervisor_password
|
||||||
|
by_pin = self.supervisor_user_id and self.supervisor_pin
|
||||||
|
if not (by_password or by_pin):
|
||||||
|
raise ValueError(
|
||||||
|
"supervisor credentials required: username+password or user_id+pin"
|
||||||
|
)
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
class TaskProgressResponse(BaseModel):
|
class TaskProgressResponse(BaseModel):
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
from src.backend.models.api.tipi import UtcDateTime
|
from src.backend.models.api.tipi import UtcDateTime
|
||||||
from typing import Literal, Optional
|
from typing import Literal, Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||||
|
|
||||||
|
|
||||||
class ProductionRunCreate(BaseModel):
|
class ProductionRunCreate(BaseModel):
|
||||||
@@ -55,6 +55,8 @@ class ProductionRunResponse(BaseModel):
|
|||||||
# needs them to bring the operator back to the measurement when the interval
|
# needs them to bring the operator back to the measurement when the interval
|
||||||
# expires, whichever screen they are on, and to know which task closes a cycle.
|
# expires, whichever screen they are on, and to know which task closes a cycle.
|
||||||
measurement_task_ids: list[int] = Field(default_factory=list)
|
measurement_task_ids: list[int] = Field(default_factory=list)
|
||||||
|
# #53: task 'note' con at_close, da compilare prima della modale del capoturno.
|
||||||
|
close_task_ids: list[int] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class ProductionRunWithEventsResponse(ProductionRunResponse):
|
class ProductionRunWithEventsResponse(ProductionRunResponse):
|
||||||
@@ -64,8 +66,21 @@ class ProductionRunWithEventsResponse(ProductionRunResponse):
|
|||||||
class SupervisorAction(BaseModel):
|
class SupervisorAction(BaseModel):
|
||||||
"""Credentials of the supervisor authorising a line stop or a close."""
|
"""Credentials of the supervisor authorising a line stop or a close."""
|
||||||
|
|
||||||
supervisor_username: str = Field(..., min_length=1)
|
# #59: o username+password, oppure supervisor_user_id+supervisor_pin
|
||||||
supervisor_password: str = Field(..., min_length=1)
|
supervisor_username: Optional[str] = Field(None, min_length=1)
|
||||||
|
supervisor_password: Optional[str] = Field(None, min_length=1)
|
||||||
|
supervisor_user_id: Optional[int] = Field(None, gt=0)
|
||||||
|
supervisor_pin: Optional[str] = Field(None, pattern=r"^[0-9]{4}$")
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _one_credential_pair(self):
|
||||||
|
by_password = self.supervisor_username and self.supervisor_password
|
||||||
|
by_pin = self.supervisor_user_id and self.supervisor_pin
|
||||||
|
if not (by_password or by_pin):
|
||||||
|
raise ValueError(
|
||||||
|
"supervisor credentials required: username+password or user_id+pin"
|
||||||
|
)
|
||||||
|
return self
|
||||||
note: Optional[str] = None
|
note: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,36 @@
|
|||||||
from src.backend.models.api.tipi import UtcDateTime
|
from src.backend.models.api.tipi import UtcDateTime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_http_url(value: Optional[str], field: str) -> Optional[str]:
|
||||||
|
"""Vuoto/spazi -> None; altrimenti http(s) senza '/' finale."""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
value = value.strip()
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
if not value.lower().startswith(("http://", "https://")):
|
||||||
|
raise ValueError(f"{field} must start with http:// or https://")
|
||||||
|
return value.rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_worker_url(value: Optional[str]) -> Optional[str]:
|
||||||
|
"""Vuoto/spazi -> None (worker centrale); altrimenti http(s) senza '/' finale (#45)."""
|
||||||
|
return _normalize_http_url(value, "vision_worker_url")
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_camera_url(value: Optional[str]) -> Optional[str]:
|
||||||
|
"""Vuoto/spazi -> None (nessuna camera); altrimenti http(s) senza '/' finale (#72)."""
|
||||||
|
return _normalize_http_url(value, "camera_url")
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_camera_source(value: Optional[str]) -> Optional[str]:
|
||||||
|
"""Strip; vuoto -> None (#72)."""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
return value.strip() or None
|
||||||
|
|
||||||
|
|
||||||
class StationCreate(BaseModel):
|
class StationCreate(BaseModel):
|
||||||
@@ -11,6 +40,15 @@ class StationCreate(BaseModel):
|
|||||||
location: Optional[str] = Field(default=None, max_length=255)
|
location: Optional[str] = Field(default=None, max_length=255)
|
||||||
notes: Optional[str] = None
|
notes: Optional[str] = None
|
||||||
active: bool = True
|
active: bool = True
|
||||||
|
# worker di visione della stazione (#45); vuoto = centrale
|
||||||
|
vision_worker_url: Optional[str] = Field(default=None, max_length=500)
|
||||||
|
# camera della stazione (#72); vuoto = nessuna camera
|
||||||
|
camera_url: Optional[str] = Field(default=None, max_length=500)
|
||||||
|
camera_source: Optional[str] = Field(default=None, max_length=200)
|
||||||
|
|
||||||
|
_check_worker_url = field_validator("vision_worker_url")(_normalize_worker_url)
|
||||||
|
_check_camera_url = field_validator("camera_url")(_normalize_camera_url)
|
||||||
|
_check_camera_source = field_validator("camera_source")(_normalize_camera_source)
|
||||||
|
|
||||||
|
|
||||||
class StationUpdate(BaseModel):
|
class StationUpdate(BaseModel):
|
||||||
@@ -18,6 +56,15 @@ class StationUpdate(BaseModel):
|
|||||||
location: Optional[str] = Field(default=None, max_length=255)
|
location: Optional[str] = Field(default=None, max_length=255)
|
||||||
notes: Optional[str] = None
|
notes: Optional[str] = None
|
||||||
active: Optional[bool] = None
|
active: Optional[bool] = None
|
||||||
|
# worker di visione della stazione (#45); "" -> None riporta al centrale
|
||||||
|
vision_worker_url: Optional[str] = Field(default=None, max_length=500)
|
||||||
|
# camera della stazione (#72); vuoto = nessuna camera
|
||||||
|
camera_url: Optional[str] = Field(default=None, max_length=500)
|
||||||
|
camera_source: Optional[str] = Field(default=None, max_length=200)
|
||||||
|
|
||||||
|
_check_worker_url = field_validator("vision_worker_url")(_normalize_worker_url)
|
||||||
|
_check_camera_url = field_validator("camera_url")(_normalize_camera_url)
|
||||||
|
_check_camera_source = field_validator("camera_source")(_normalize_camera_source)
|
||||||
|
|
||||||
|
|
||||||
class StationResponse(BaseModel):
|
class StationResponse(BaseModel):
|
||||||
@@ -28,6 +75,9 @@ class StationResponse(BaseModel):
|
|||||||
location: Optional[str]
|
location: Optional[str]
|
||||||
notes: Optional[str]
|
notes: Optional[str]
|
||||||
active: bool
|
active: bool
|
||||||
|
vision_worker_url: Optional[str] = None # #45
|
||||||
|
camera_url: Optional[str] = None # #72
|
||||||
|
camera_source: Optional[str] = None # #72
|
||||||
created_by: int
|
created_by: int
|
||||||
created_at: UtcDateTime
|
created_at: UtcDateTime
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
"""Pydantic schemas for RecipeTask and RecipeSubtask operations."""
|
"""Pydantic schemas for RecipeTask and RecipeSubtask operations."""
|
||||||
|
import re
|
||||||
from typing import Any, Literal, Optional
|
from typing import Any, Literal, Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||||
|
|
||||||
from src.backend.models.orm.task import TASK_TYPES
|
from src.backend.models.orm.task import DEFAULT_LOOP_TASK_TYPES, TASK_TYPES
|
||||||
|
|
||||||
# Kept in step with the ORM enum so the two cannot drift apart.
|
# Kept in step with the ORM enum so the two cannot drift apart.
|
||||||
TaskType = Literal[TASK_TYPES]
|
TaskType = Literal[TASK_TYPES]
|
||||||
@@ -34,6 +35,76 @@ def validate_tolerance_order(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# #55: campi extra di un task 'note'
|
||||||
|
MAX_NOTE_FIELDS = 20
|
||||||
|
_FIELD_KEY_RE = re.compile(r"^[a-z0-9_]{1,40}$")
|
||||||
|
NOTE_FIELD_TYPES = ("text", "number")
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_note_fields(fields: Optional[list]) -> Optional[list[dict]]:
|
||||||
|
"""Valida e normalizza `fields` di un task 'note' (#55); lista vuota -> None.
|
||||||
|
|
||||||
|
Solleva ValueError con un messaggio leggibile (il router lo trasforma in 422).
|
||||||
|
"""
|
||||||
|
if not fields:
|
||||||
|
return None
|
||||||
|
if len(fields) > MAX_NOTE_FIELDS:
|
||||||
|
raise ValueError(f"Al massimo {MAX_NOTE_FIELDS} campi per task")
|
||||||
|
out: list[dict] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for i, raw in enumerate(fields, start=1):
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
raise ValueError(f"Campo {i}: formato non valido")
|
||||||
|
key = raw.get("key")
|
||||||
|
if not isinstance(key, str) or not _FIELD_KEY_RE.match(key):
|
||||||
|
raise ValueError(
|
||||||
|
f"Campo {i}: chiave non valida (solo a-z, 0-9, _, da 1 a 40 caratteri)"
|
||||||
|
)
|
||||||
|
if key in seen:
|
||||||
|
raise ValueError(f"Campo {i}: chiave '{key}' duplicata")
|
||||||
|
seen.add(key)
|
||||||
|
label = raw.get("label")
|
||||||
|
label = label.strip() if isinstance(label, str) else ""
|
||||||
|
if not 1 <= len(label) <= 100:
|
||||||
|
raise ValueError(f"Campo '{key}': etichetta da 1 a 100 caratteri")
|
||||||
|
ftype = raw.get("type")
|
||||||
|
if ftype not in NOTE_FIELD_TYPES:
|
||||||
|
raise ValueError(f"Campo '{key}': tipo deve essere text o number")
|
||||||
|
default = raw.get("default")
|
||||||
|
if default is not None:
|
||||||
|
if not isinstance(default, str):
|
||||||
|
raise ValueError(f"Campo '{key}': il valore predefinito deve essere testo")
|
||||||
|
if default == "":
|
||||||
|
default = None
|
||||||
|
if default is not None and ftype == "number":
|
||||||
|
try:
|
||||||
|
float(default.replace(",", "."))
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(f"Campo '{key}': il valore predefinito non e' un numero")
|
||||||
|
attachment = raw.get("attachment", False)
|
||||||
|
if not isinstance(attachment, bool):
|
||||||
|
raise ValueError(f"Campo '{key}': attachment deve essere true o false")
|
||||||
|
out.append({
|
||||||
|
"key": key, "label": label, "type": ftype,
|
||||||
|
"default": default, "attachment": attachment,
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def check_task_loop_options(task_type: str, at_close: bool, fields: Optional[list]) -> None:
|
||||||
|
"""at_close e fields sono ammessi solo per i task 'note' (#53 #55)."""
|
||||||
|
if task_type != "note":
|
||||||
|
if at_close:
|
||||||
|
raise ValueError("'Alla chiusura' e' ammesso solo per i task di tipo note")
|
||||||
|
if fields:
|
||||||
|
raise ValueError("I campi extra sono ammessi solo per i task di tipo note")
|
||||||
|
|
||||||
|
|
||||||
|
def default_in_loop(task_type: str) -> bool:
|
||||||
|
"""in_loop quando non dichiarato: misure e check si', gli altri no (#53)."""
|
||||||
|
return task_type in DEFAULT_LOOP_TASK_TYPES
|
||||||
|
|
||||||
|
|
||||||
class SubtaskCreate(BaseModel):
|
class SubtaskCreate(BaseModel):
|
||||||
"""Schema for creating a subtask."""
|
"""Schema for creating a subtask."""
|
||||||
marker_number: int = Field(..., ge=1)
|
marker_number: int = Field(..., ge=1)
|
||||||
@@ -98,6 +169,10 @@ class TaskCreate(BaseModel):
|
|||||||
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
|
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
|
||||||
annotations_json: Optional[dict[str, Any]] = None
|
annotations_json: Optional[dict[str, Any]] = None
|
||||||
vision_json: Optional[dict] = None
|
vision_json: Optional[dict] = None
|
||||||
|
# #53: assente = default dal tipo (misure e check nel ciclo)
|
||||||
|
in_loop: Optional[bool] = None
|
||||||
|
at_close: bool = False
|
||||||
|
fields: Optional[list[dict[str, Any]]] = None
|
||||||
subtasks: list[SubtaskCreate] = []
|
subtasks: list[SubtaskCreate] = []
|
||||||
|
|
||||||
|
|
||||||
@@ -111,6 +186,9 @@ class TaskUpdate(BaseModel):
|
|||||||
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
|
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
|
||||||
annotations_json: Optional[dict[str, Any]] = None
|
annotations_json: Optional[dict[str, Any]] = None
|
||||||
vision_json: Optional[dict] = None
|
vision_json: Optional[dict] = None
|
||||||
|
in_loop: Optional[bool] = None
|
||||||
|
at_close: Optional[bool] = None
|
||||||
|
fields: Optional[list[dict[str, Any]]] = None
|
||||||
|
|
||||||
|
|
||||||
class TaskResponse(BaseModel):
|
class TaskResponse(BaseModel):
|
||||||
@@ -129,6 +207,9 @@ class TaskResponse(BaseModel):
|
|||||||
file_type: Optional[str] = None
|
file_type: Optional[str] = None
|
||||||
annotations_json: Optional[dict[str, Any]] = None
|
annotations_json: Optional[dict[str, Any]] = None
|
||||||
vision_json: Optional[dict] = None
|
vision_json: Optional[dict] = None
|
||||||
|
in_loop: bool = False
|
||||||
|
at_close: bool = False
|
||||||
|
fields: Optional[list[dict[str, Any]]] = None
|
||||||
subtasks: list[SubtaskResponse] = []
|
subtasks: list[SubtaskResponse] = []
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""Pydantic schemas for task results (#54 check OK/NOK, #55 note con campi)."""
|
||||||
|
from typing import Literal, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
from src.backend.models.api.tipi import UtcDateTime
|
||||||
|
|
||||||
|
|
||||||
|
class TaskResultAttachment(BaseModel):
|
||||||
|
"""Un allegato: `path` e' il percorso restituito da /api/files/upload."""
|
||||||
|
key: Optional[str] = Field(None, max_length=40)
|
||||||
|
path: str = Field(..., min_length=1, max_length=500)
|
||||||
|
name: str = Field(..., min_length=1, max_length=255)
|
||||||
|
|
||||||
|
|
||||||
|
class TaskResultCreate(BaseModel):
|
||||||
|
task_id: int = Field(..., gt=0)
|
||||||
|
production_run_id: Optional[int] = Field(None, gt=0)
|
||||||
|
outcome: Literal["ok", "nok", "done"]
|
||||||
|
note: Optional[str] = None
|
||||||
|
values: Optional[dict[str, str]] = None
|
||||||
|
attachments: Optional[list[TaskResultAttachment]] = Field(None, max_length=50)
|
||||||
|
|
||||||
|
|
||||||
|
class TaskResultResponse(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
task_id: int
|
||||||
|
version_id: int
|
||||||
|
production_run_id: Optional[int] = None
|
||||||
|
user_id: int
|
||||||
|
outcome: str
|
||||||
|
note: Optional[str] = None
|
||||||
|
values: Optional[dict[str, str]] = None
|
||||||
|
attachments: Optional[list[TaskResultAttachment]] = None
|
||||||
|
created_at: UtcDateTime
|
||||||
@@ -50,6 +50,8 @@ class UserResponse(BaseModel):
|
|||||||
active: bool
|
active: bool
|
||||||
created_at: UtcDateTime
|
created_at: UtcDateTime
|
||||||
last_login: Optional[UtcDateTime] = None
|
last_login: Optional[UtcDateTime] = None
|
||||||
|
# #59: solo il fatto che il PIN esista, mai l'hash
|
||||||
|
has_pin: bool = False
|
||||||
|
|
||||||
|
|
||||||
class UserPasswordChange(BaseModel):
|
class UserPasswordChange(BaseModel):
|
||||||
@@ -68,3 +70,21 @@ class LoginResponse(BaseModel):
|
|||||||
user: UserResponse
|
user: UserResponse
|
||||||
api_key: str
|
api_key: str
|
||||||
message: str = "Login successful"
|
message: str = "Login successful"
|
||||||
|
|
||||||
|
|
||||||
|
class PinUserItem(BaseModel):
|
||||||
|
"""Voce dell'elenco pubblico per il login con PIN (#59): solo id e nome."""
|
||||||
|
id: int
|
||||||
|
display_name: str
|
||||||
|
|
||||||
|
|
||||||
|
class PinLoginRequest(BaseModel):
|
||||||
|
"""Login con PIN (#59)."""
|
||||||
|
user_id: int
|
||||||
|
pin: str = Field(..., pattern=r"^[0-9]{4}$")
|
||||||
|
|
||||||
|
|
||||||
|
class PinSet(BaseModel):
|
||||||
|
"""Imposta (4 cifre) o toglie (null) il PIN di un utente (#59)."""
|
||||||
|
pin: Optional[str] = Field(None, pattern=r"^[0-9]{4}$")
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from src.backend.models.orm.user import User
|
|||||||
from src.backend.models.orm.api_key import ApiKey
|
from src.backend.models.orm.api_key import ApiKey
|
||||||
from src.backend.models.orm.recipe import Recipe, RecipeVersion
|
from src.backend.models.orm.recipe import Recipe, RecipeVersion
|
||||||
from src.backend.models.orm.task import RecipeTask, RecipeSubtask
|
from src.backend.models.orm.task import RecipeTask, RecipeSubtask
|
||||||
|
from src.backend.models.orm.task_result import TaskResult
|
||||||
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
|
||||||
@@ -17,6 +18,7 @@ __all__ = [
|
|||||||
"RecipeVersion",
|
"RecipeVersion",
|
||||||
"RecipeTask",
|
"RecipeTask",
|
||||||
"RecipeSubtask",
|
"RecipeSubtask",
|
||||||
|
"TaskResult",
|
||||||
"Measurement",
|
"Measurement",
|
||||||
"AccessLog",
|
"AccessLog",
|
||||||
"SystemSetting",
|
"SystemSetting",
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ class Station(Base):
|
|||||||
location: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
location: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||||
notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
|
active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
|
||||||
|
# URL del worker di visione della stazione (#45); NULL = worker centrale
|
||||||
|
vision_worker_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||||
|
# camera della stazione (#72): URL del servizio camera e sorgente da chiedergli
|
||||||
|
camera_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||||
|
camera_source: Mapped[Optional[str]] = mapped_column(String(200), nullable=True)
|
||||||
created_by: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False)
|
created_by: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime, nullable=False, server_default=func.now()
|
DateTime, nullable=False, server_default=func.now()
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
from typing import TYPE_CHECKING, Optional
|
from typing import TYPE_CHECKING, Optional
|
||||||
|
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
DECIMAL, Enum, ForeignKey, Index, Integer, JSON, String, Text, UniqueConstraint,
|
Boolean, DECIMAL, Enum, ForeignKey, Index, Integer, JSON, String, Text, UniqueConstraint,
|
||||||
)
|
)
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
@@ -15,12 +15,22 @@ if TYPE_CHECKING:
|
|||||||
# What a task is, declared by whoever writes the recipe instead of guessed from its
|
# What a task is, declared by whoever writes the recipe instead of guessed from its
|
||||||
# contents. The last two are not built yet; they are in the enum from the start
|
# contents. The last two are not built yet; they are in the enum from the start
|
||||||
# because widening a MySQL enum later is an ALTER on a live table.
|
# because widening a MySQL enum later is an ALTER on a live table.
|
||||||
TASK_TYPES = ("note", "measure", "drawing", "dxf_compare", "camera_measure")
|
# 'check' = Controllo OK/NOK, senza quote (#54).
|
||||||
|
TASK_TYPES = ("note", "measure", "drawing", "dxf_compare", "camera_measure", "check")
|
||||||
|
|
||||||
# Types the operator screen treats as "there are quotes to take here".
|
# Types the operator screen treats as "there are quotes to take here".
|
||||||
# dxf_compare produce quote come camera_measure (#46)
|
# dxf_compare produce quote come camera_measure (#46)
|
||||||
MEASURING_TASK_TYPES = ("measure", "camera_measure", "dxf_compare")
|
MEASURING_TASK_TYPES = ("measure", "camera_measure", "dxf_compare")
|
||||||
|
|
||||||
|
# Tipi che entrano nel ciclo di produzione se in_loop non e' dichiarato (#53):
|
||||||
|
# le misure di sempre piu' il controllo OK/NOK.
|
||||||
|
DEFAULT_LOOP_TASK_TYPES = MEASURING_TASK_TYPES + ("check",)
|
||||||
|
|
||||||
|
|
||||||
|
def _default_in_loop(context) -> bool:
|
||||||
|
"""Default alla creazione di in_loop: dipende dal tipo del task (#53)."""
|
||||||
|
return context.get_current_parameters().get("task_type", "note") in DEFAULT_LOOP_TASK_TYPES
|
||||||
|
|
||||||
|
|
||||||
class RecipeTask(Base):
|
class RecipeTask(Base):
|
||||||
__tablename__ = "recipe_tasks"
|
__tablename__ = "recipe_tasks"
|
||||||
@@ -55,6 +65,17 @@ class RecipeTask(Base):
|
|||||||
# Cartella immutabile (relativa a upload_path) con recipe.json e i file di
|
# Cartella immutabile (relativa a upload_path) con recipe.json e i file di
|
||||||
# meta della ricetta VisionSuite v2 (#46).
|
# meta della ricetta VisionSuite v2 (#46).
|
||||||
vision_recipe_dir: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
vision_recipe_dir: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||||
|
# #53: il task fa parte del ciclo di produzione (default dal tipo: misure e
|
||||||
|
# check si', gli altri no; un task 'note' si puo' mettere nel ciclo a mano).
|
||||||
|
in_loop: Mapped[bool] = mapped_column(
|
||||||
|
Boolean, nullable=False, default=_default_in_loop, server_default="0"
|
||||||
|
)
|
||||||
|
# #53: task 'note' compilato alla fine produzione, prima della modale del capoturno.
|
||||||
|
at_close: Mapped[bool] = mapped_column(
|
||||||
|
Boolean, nullable=False, default=False, server_default="0"
|
||||||
|
)
|
||||||
|
# #55: campi extra del task 'note' ([{key,label,type,default,attachment}]).
|
||||||
|
fields: Mapped[Optional[list]] = mapped_column(JSON, nullable=True)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
version: Mapped["RecipeVersion"] = relationship(back_populates="tasks")
|
version: Mapped["RecipeVersion"] = relationship(back_populates="tasks")
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""TaskResult: l'esito registrato da un task 'check' (OK/NOK) o 'note' (#54 #55)."""
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, Enum, ForeignKey, Integer, JSON, Text, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from src.backend.database import Base
|
||||||
|
|
||||||
|
TASK_OUTCOMES = ("ok", "nok", "done")
|
||||||
|
|
||||||
|
|
||||||
|
class TaskResult(Base):
|
||||||
|
__tablename__ = "task_results"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
task_id: Mapped[int] = mapped_column(
|
||||||
|
Integer, ForeignKey("recipe_tasks.id", ondelete="CASCADE"),
|
||||||
|
nullable=False, index=True,
|
||||||
|
)
|
||||||
|
# Copiata dal task: la versione in cui l'esito e' stato dato non cambia piu'.
|
||||||
|
version_id: Mapped[int] = mapped_column(
|
||||||
|
Integer, ForeignKey("recipe_versions.id"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
production_run_id: Mapped[Optional[int]] = mapped_column(
|
||||||
|
Integer, ForeignKey("production_runs.id", ondelete="SET NULL"),
|
||||||
|
nullable=True, index=True,
|
||||||
|
)
|
||||||
|
user_id: Mapped[int] = mapped_column(
|
||||||
|
Integer, ForeignKey("users.id"), nullable=False
|
||||||
|
)
|
||||||
|
outcome: Mapped[str] = mapped_column(
|
||||||
|
Enum(*TASK_OUTCOMES, name="task_outcome_enum"), nullable=False
|
||||||
|
)
|
||||||
|
note: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
|
# {chiave del campo: valore stringa}; allegati: [{key, path, name}]
|
||||||
|
values: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
|
||||||
|
attachments: Mapped[Optional[list]] = mapped_column(JSON, nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime, nullable=False, server_default=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
__table_args__ = ({"mysql_engine": "InnoDB", "mysql_charset": "utf8mb4"},)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<TaskResult task={self.task_id} outcome={self.outcome}>"
|
||||||
@@ -22,6 +22,13 @@ class User(Base):
|
|||||||
# Admin flag separate from roles
|
# Admin flag separate from roles
|
||||||
is_admin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
is_admin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
|
||||||
|
# PIN a 4 cifre (#59): solo l'hash; contatore errori e fine blocco per il lockout
|
||||||
|
pin_hash: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||||
|
pin_failed_count: Mapped[int] = mapped_column(
|
||||||
|
Integer, nullable=False, default=0, server_default="0"
|
||||||
|
)
|
||||||
|
pin_locked_until: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||||
|
|
||||||
# User preferences
|
# User preferences
|
||||||
language_pref: Mapped[str] = mapped_column(
|
language_pref: Mapped[str] = mapped_column(
|
||||||
Enum("it", "en", name="language_enum"), nullable=False, default="it"
|
Enum("it", "en", name="language_enum"), nullable=False, default="it"
|
||||||
@@ -39,6 +46,11 @@ class User(Base):
|
|||||||
)
|
)
|
||||||
last_login: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
last_login: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_pin(self) -> bool:
|
||||||
|
"""True se l'utente ha un PIN impostato (#59); serve a UserResponse.has_pin."""
|
||||||
|
return self.pin_hash is not None
|
||||||
|
|
||||||
def has_role(self, role: str) -> bool:
|
def has_role(self, role: str) -> bool:
|
||||||
"""Check if user has a specific role."""
|
"""Check if user has a specific role."""
|
||||||
return role in (self.roles or [])
|
return role in (self.roles or [])
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ class VisionResult(Base):
|
|||||||
Integer, ForeignKey("stations.id"), nullable=True
|
Integer, ForeignKey("stations.id"), nullable=True
|
||||||
)
|
)
|
||||||
device_code: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
device_code: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||||
|
# URL del worker effettivamente chiamato (#45), copiato: la stazione puo' cambiarlo dopo
|
||||||
|
worker_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||||
# Copied, not referenced: a calibration redone six months from now must not
|
# Copied, not referenced: a calibration redone six months from now must not
|
||||||
# rewrite what a measurement already taken means. Same for the graph.
|
# rewrite what a measurement already taken means. Same for the graph.
|
||||||
calibration_snapshot: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
|
calibration_snapshot: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
|
||||||
|
|||||||
@@ -54,7 +54,79 @@ async def authenticate_user(
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
async def authorise_supervisor(db: AsyncSession, username: str, password: str) -> User:
|
# #59: PIN a 4 cifre. Hash identico alle password (bcrypt); 5 errori -> blocco 15 minuti.
|
||||||
|
PIN_MAX_FAILURES = 5
|
||||||
|
PIN_LOCK_MINUTES = 15
|
||||||
|
|
||||||
|
|
||||||
|
async def set_user_pin(db: AsyncSession, user: User, pin: str | None) -> None:
|
||||||
|
"""Imposta o toglie (None) il PIN; azzera contatore e blocco. Admin esclusi (#59)."""
|
||||||
|
if pin is not None and user.is_admin:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail="Admin users cannot have a PIN",
|
||||||
|
)
|
||||||
|
user.pin_hash = hash_password(pin) if pin is not None else None
|
||||||
|
user.pin_failed_count = 0
|
||||||
|
user.pin_locked_until = None
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
|
||||||
|
async def authenticate_pin(db: AsyncSession, user_id: int, pin: str) -> User:
|
||||||
|
"""Verifica user_id+PIN con blocco dopo 5 errori consecutivi (#59).
|
||||||
|
|
||||||
|
401 PIN errato (o utente non idoneo: stessa risposta, niente enumerazione),
|
||||||
|
423 utente bloccato. Il contatore va committato prima di sollevare: get_db fa
|
||||||
|
rollback su eccezione e l'errore andrebbe perso.
|
||||||
|
"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(User).where(User.id == user_id, User.active == True) # noqa: E712
|
||||||
|
)
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if user is None or user.is_admin or user.pin_hash is None:
|
||||||
|
verify_password(pin, _DUMMY_PASSWORD_HASH) # stesso costo di un PIN errato
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid PIN"
|
||||||
|
)
|
||||||
|
now = utcnow()
|
||||||
|
if user.pin_locked_until is not None:
|
||||||
|
if user.pin_locked_until > now:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_423_LOCKED,
|
||||||
|
detail="PIN locked until "
|
||||||
|
+ user.pin_locked_until.strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
+ " UTC",
|
||||||
|
)
|
||||||
|
# blocco scaduto: si riparte da zero
|
||||||
|
user.pin_locked_until = None
|
||||||
|
user.pin_failed_count = 0
|
||||||
|
if verify_password(pin, user.pin_hash):
|
||||||
|
if user.pin_failed_count:
|
||||||
|
user.pin_failed_count = 0
|
||||||
|
await db.flush()
|
||||||
|
return user
|
||||||
|
user.pin_failed_count += 1
|
||||||
|
locked = user.pin_failed_count >= PIN_MAX_FAILURES
|
||||||
|
if locked:
|
||||||
|
user.pin_locked_until = now + timedelta(minutes=PIN_LOCK_MINUTES)
|
||||||
|
await db.commit()
|
||||||
|
if locked:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_423_LOCKED,
|
||||||
|
detail="PIN locked until "
|
||||||
|
+ user.pin_locked_until.strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
+ " UTC",
|
||||||
|
)
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid PIN")
|
||||||
|
|
||||||
|
|
||||||
|
async def authorise_supervisor(
|
||||||
|
db: AsyncSession,
|
||||||
|
username: str | None = None,
|
||||||
|
password: str | None = None,
|
||||||
|
user_id: int | None = None,
|
||||||
|
pin: str | None = None,
|
||||||
|
) -> User:
|
||||||
"""Check the credentials of the supervisor authorising something.
|
"""Check the credentials of the supervisor authorising something.
|
||||||
|
|
||||||
Used wherever the shop floor needs a capoturno: a line stop, a closure, an
|
Used wherever the shop floor needs a capoturno: a line stop, a closure, an
|
||||||
@@ -65,7 +137,16 @@ async def authorise_supervisor(db: AsyncSession, username: str, password: str) -
|
|||||||
Uses authenticate_user rather than a full login: logging in would issue a
|
Uses authenticate_user rather than a full login: logging in would issue a
|
||||||
needless API key for the supervisor.
|
needless API key for the supervisor.
|
||||||
"""
|
"""
|
||||||
supervisor = await authenticate_user(db, username, password)
|
if user_id is not None and pin is not None:
|
||||||
|
# #59: via PIN, stesso blocco del login con PIN (401/423 sollevati da li')
|
||||||
|
supervisor = await authenticate_pin(db, user_id, pin)
|
||||||
|
elif username is not None and password is not None:
|
||||||
|
supervisor = await authenticate_user(db, username, password)
|
||||||
|
else:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail="Supervisor credentials required",
|
||||||
|
)
|
||||||
if supervisor is None:
|
if supervisor is None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
|||||||
@@ -145,7 +145,21 @@ async def measurement_task_ids(db: AsyncSession, version_id: int) -> list[int]:
|
|||||||
select(RecipeTask.id)
|
select(RecipeTask.id)
|
||||||
.where(
|
.where(
|
||||||
RecipeTask.version_id == version_id,
|
RecipeTask.version_id == version_id,
|
||||||
RecipeTask.task_type.in_(MEASURING_TASK_TYPES),
|
# #53: il ciclo lo dichiara il task (in_loop), non piu' il solo tipo.
|
||||||
|
RecipeTask.in_loop.is_(True),
|
||||||
|
)
|
||||||
|
.order_by(RecipeTask.order_index, RecipeTask.id)
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
async def close_task_ids(db: AsyncSession, version_id: int) -> list[int]:
|
||||||
|
"""Ids dei task da compilare alla fine produzione (at_close), in ordine (#53)."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(RecipeTask.id)
|
||||||
|
.where(
|
||||||
|
RecipeTask.version_id == version_id,
|
||||||
|
RecipeTask.at_close.is_(True),
|
||||||
)
|
)
|
||||||
.order_by(RecipeTask.order_index, RecipeTask.id)
|
.order_by(RecipeTask.order_index, RecipeTask.id)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -117,6 +117,10 @@ async def _copy_tasks_to_version(
|
|||||||
vision_json=task.vision_json,
|
vision_json=task.vision_json,
|
||||||
# Cartella immutabile: basta il riferimento (#46).
|
# Cartella immutabile: basta il riferimento (#46).
|
||||||
vision_recipe_dir=task.vision_recipe_dir,
|
vision_recipe_dir=task.vision_recipe_dir,
|
||||||
|
# #53 #55: ciclo, chiusura e campi extra seguono il task nella nuova versione.
|
||||||
|
in_loop=task.in_loop,
|
||||||
|
at_close=task.at_close,
|
||||||
|
fields=task.fields,
|
||||||
)
|
)
|
||||||
db.add(new_task)
|
db.add(new_task)
|
||||||
await db.flush() # get new_task.id
|
await db.flush() # get new_task.id
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ async def create_station(
|
|||||||
location=data.location,
|
location=data.location,
|
||||||
notes=data.notes,
|
notes=data.notes,
|
||||||
active=data.active,
|
active=data.active,
|
||||||
|
vision_worker_url=data.vision_worker_url, # #45
|
||||||
|
camera_url=data.camera_url, # #72
|
||||||
|
camera_source=data.camera_source, # #72
|
||||||
created_by=creator.id,
|
created_by=creator.id,
|
||||||
)
|
)
|
||||||
db.add(station)
|
db.add(station)
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from src.backend.config import settings
|
from src.backend.config import settings
|
||||||
from src.backend.models.orm.measurement import Measurement
|
from src.backend.models.orm.measurement import Measurement
|
||||||
from src.backend.models.orm.recipe import RecipeVersion
|
from src.backend.models.orm.recipe import RecipeVersion
|
||||||
|
from src.backend.models.orm.station import Station
|
||||||
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
||||||
from src.backend.models.orm.vision import VisionReferenceImage, VisionResult
|
from src.backend.models.orm.vision import VisionReferenceImage, VisionResult
|
||||||
from src.backend.services import measurement_service
|
from src.backend.services import measurement_service
|
||||||
@@ -53,6 +54,7 @@ async def _call_worker(
|
|||||||
*,
|
*,
|
||||||
recipe_dir: str | None = None,
|
recipe_dir: str | None = None,
|
||||||
calibration: dict | None = None,
|
calibration: dict | None = None,
|
||||||
|
worker_url: str | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""The one place that talks to the worker; the tests replace it.
|
"""The one place that talks to the worker; the tests replace it.
|
||||||
|
|
||||||
@@ -70,6 +72,8 @@ async def _call_worker(
|
|||||||
# recipe_dir (relativo, #46) e calibration sono opzionali: se assenti non
|
# recipe_dir (relativo, #46) e calibration sono opzionali: se assenti non
|
||||||
# si mandano, il worker usa i suoi default.
|
# si mandano, il worker usa i suoi default.
|
||||||
data = {"graph": json.dumps(graph)}
|
data = {"graph": json.dumps(graph)}
|
||||||
|
# worker della stazione (#45) o, se manca, il centrale; mai un ripiego dall'uno all'altro
|
||||||
|
base = (worker_url or settings.vision_worker_url).rstrip("/")
|
||||||
if recipe_dir:
|
if recipe_dir:
|
||||||
data["recipe_dir"] = recipe_dir
|
data["recipe_dir"] = recipe_dir
|
||||||
if calibration:
|
if calibration:
|
||||||
@@ -77,19 +81,19 @@ async def _call_worker(
|
|||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=120.0) as client:
|
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
f"{settings.vision_worker_url}/run",
|
f"{base}/run",
|
||||||
files={"image": ("frame.png", image_bytes, "image/png")},
|
files={"image": ("frame.png", image_bytes, "image/png")},
|
||||||
data=data,
|
data=data,
|
||||||
)
|
)
|
||||||
except httpx.TimeoutException as exc:
|
except httpx.TimeoutException as exc:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_504_GATEWAY_TIMEOUT,
|
status_code=status.HTTP_504_GATEWAY_TIMEOUT,
|
||||||
detail=f"the vision worker did not answer in time: {exc}",
|
detail=f"the vision worker did not answer in time: {exc} ({base})", # #45
|
||||||
) from exc
|
) from exc
|
||||||
except (httpx.TransportError, httpx.HTTPError) as exc:
|
except (httpx.TransportError, httpx.HTTPError) as exc:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
detail=f"the vision worker is unreachable: {exc}",
|
detail=f"the vision worker is unreachable: {exc} ({base})", # #45
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
# The worker already gave this fault a name - forward its status and
|
# The worker already gave this fault a name - forward its status and
|
||||||
@@ -352,12 +356,20 @@ async def execute_task(
|
|||||||
detail="this recipe is not active",
|
detail="this recipe is not active",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
run = None
|
||||||
if production_run_id is not None:
|
if production_run_id is not None:
|
||||||
# 404 if the run does not exist, 422 if it belongs to another recipe,
|
# 404 if the run does not exist, 422 if it belongs to another recipe,
|
||||||
# 409 if it is closed or paused - same rule a manual reading follows.
|
# 409 if it is closed or paused - same rule a manual reading follows.
|
||||||
await measurement_service.validate_run_for_version(
|
run = await measurement_service.validate_run_for_version(
|
||||||
db, production_run_id, version_id,
|
db, production_run_id, version_id,
|
||||||
)
|
)
|
||||||
|
# Worker della stazione della sessione di produzione (#45); senza run o senza URL: centrale
|
||||||
|
station_url = None
|
||||||
|
if run is not None:
|
||||||
|
station = (await db.execute(
|
||||||
|
select(Station).where(Station.id == run.station_id)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
station_url = (station.vision_worker_url or None) if station is not None else None
|
||||||
|
|
||||||
if not task.vision_json:
|
if not task.vision_json:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -411,6 +423,7 @@ async def execute_task(
|
|||||||
# passa `calibration`, la ricetta porta la sua (#46).
|
# passa `calibration`, la ricetta porta la sua (#46).
|
||||||
report = await _call_worker(
|
report = await _call_worker(
|
||||||
image_bytes, task.vision_json, recipe_dir=task.vision_recipe_dir,
|
image_bytes, task.vision_json, recipe_dir=task.vision_recipe_dir,
|
||||||
|
worker_url=station_url, # #45
|
||||||
)
|
)
|
||||||
outputs = report["outputs"]
|
outputs = report["outputs"]
|
||||||
|
|
||||||
@@ -439,7 +452,10 @@ async def execute_task(
|
|||||||
image_path=image_path,
|
image_path=image_path,
|
||||||
overlay_path=overlay_path,
|
overlay_path=overlay_path,
|
||||||
engine_version=report["engine_version"],
|
engine_version=report["engine_version"],
|
||||||
executed_on="server",
|
# #45: dove e' girato davvero, e quale worker (copiato come lo snapshot)
|
||||||
|
executed_on="station" if station_url else "server",
|
||||||
|
station_id=run.station_id if run is not None else None,
|
||||||
|
worker_url=(station_url or settings.vision_worker_url).rstrip("/"),
|
||||||
graph_snapshot=task.vision_json,
|
graph_snapshot=task.vision_json,
|
||||||
duration_ms=report.get("duration_ms"),
|
duration_ms=report.get("duration_ms"),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -13,7 +13,10 @@ from pathlib import Path
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from alembic import command
|
from alembic import command
|
||||||
|
from alembic.operations import Operations
|
||||||
|
from alembic.runtime.migration import MigrationContext
|
||||||
from alembic.config import Config
|
from alembic.config import Config
|
||||||
|
import sqlalchemy as sa
|
||||||
from sqlalchemy import create_engine, inspect as sa_inspect, text
|
from sqlalchemy import create_engine, inspect as sa_inspect, text
|
||||||
|
|
||||||
from src.backend.config import Settings
|
from src.backend.config import Settings
|
||||||
@@ -132,6 +135,70 @@ class TestApiKeyHashMigration:
|
|||||||
command.upgrade(cfg, "head") # and up again
|
command.upgrade(cfg, "head") # and up again
|
||||||
assert "api_keys" in _inspect(db_path).get_table_names()
|
assert "api_keys" in _inspect(db_path).get_table_names()
|
||||||
|
|
||||||
|
def test_015_on_create_all_schema(self, sqlite_database_url):
|
||||||
|
"""DB nato da create_all: users.api_key ha solo l'indice unique
|
||||||
|
ix_users_api_key, nessun vincolo uq_users_api_key (#71)."""
|
||||||
|
db_path, _ = sqlite_database_url
|
||||||
|
cfg = _alembic_config()
|
||||||
|
command.upgrade(cfg, "014_vision_fk_ondelete")
|
||||||
|
|
||||||
|
engine = create_engine(f"sqlite:///{db_path}")
|
||||||
|
with engine.begin() as conn:
|
||||||
|
ctx = MigrationContext.configure(conn)
|
||||||
|
ops = Operations(ctx)
|
||||||
|
with ops.batch_alter_table("users") as batch:
|
||||||
|
batch.drop_index("ix_users_api_key")
|
||||||
|
batch.drop_constraint("uq_users_api_key", type_="unique")
|
||||||
|
ops.create_index("ix_users_api_key", "users", ["api_key"], unique=True)
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
insp = _inspect(db_path)
|
||||||
|
assert not insp.get_unique_constraints("users") or "uq_users_api_key" not in {
|
||||||
|
u["name"] for u in insp.get_unique_constraints("users")
|
||||||
|
}
|
||||||
|
|
||||||
|
command.upgrade(cfg, "head") # non deve sollevare
|
||||||
|
|
||||||
|
insp = _inspect(db_path)
|
||||||
|
assert "api_keys" in insp.get_table_names()
|
||||||
|
assert "api_key" not in {c["name"] for c in insp.get_columns("users")}
|
||||||
|
|
||||||
|
def test_015_resumes_after_partial_run(self, sqlite_database_url):
|
||||||
|
"""015 a meta' (MySQL, DDL non transazionale): api_keys creata e
|
||||||
|
ix_users_api_key tolto, alembic_version ancora 014 (#71)."""
|
||||||
|
db_path, _ = sqlite_database_url
|
||||||
|
cfg = _alembic_config()
|
||||||
|
command.upgrade(cfg, "014_vision_fk_ondelete")
|
||||||
|
|
||||||
|
engine = create_engine(f"sqlite:///{db_path}")
|
||||||
|
with engine.begin() as conn:
|
||||||
|
ops = Operations(MigrationContext.configure(conn))
|
||||||
|
ops.create_table(
|
||||||
|
"api_keys",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("user_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("key_hash", sa.String(64), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||||
|
sa.Column("expires_at", sa.DateTime(), nullable=False),
|
||||||
|
sa.Column("last_used_at", sa.DateTime(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["user_id"], ["users.id"],
|
||||||
|
name="fk_api_keys_user_id", ondelete="CASCADE",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
ops.create_index("ix_api_keys_key_hash", "api_keys", ["key_hash"], unique=True)
|
||||||
|
ops.drop_index("ix_users_api_key", table_name="users")
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
command.upgrade(cfg, "head") # non deve sollevare
|
||||||
|
|
||||||
|
insp = _inspect(db_path)
|
||||||
|
assert "api_keys" in insp.get_table_names()
|
||||||
|
assert {i["name"] for i in insp.get_indexes("api_keys")} >= {
|
||||||
|
"ix_api_keys_key_hash", "ix_api_keys_user_id",
|
||||||
|
}
|
||||||
|
assert "api_key" not in {c["name"] for c in insp.get_columns("users")}
|
||||||
|
|
||||||
|
|
||||||
class TestVisionRecipeDirMigration:
|
class TestVisionRecipeDirMigration:
|
||||||
"""016_vision_recipe_dir: recipe_tasks.vision_recipe_dir, su e giu (#46)."""
|
"""016_vision_recipe_dir: recipe_tasks.vision_recipe_dir, su e giu (#46)."""
|
||||||
@@ -149,3 +216,128 @@ class TestVisionRecipeDirMigration:
|
|||||||
assert "vision_recipe_dir" not in cols()
|
assert "vision_recipe_dir" not in cols()
|
||||||
command.upgrade(cfg, "head") # e di nuovo su
|
command.upgrade(cfg, "head") # e di nuovo su
|
||||||
assert "vision_recipe_dir" in cols()
|
assert "vision_recipe_dir" in cols()
|
||||||
|
|
||||||
|
|
||||||
|
class TestUserPinMigration:
|
||||||
|
"""017_user_pin: users.pin_hash / pin_failed_count / pin_locked_until, su e giu (#59)."""
|
||||||
|
|
||||||
|
def test_017_upgrade_and_downgrade(self, sqlite_database_url):
|
||||||
|
db_path, _ = sqlite_database_url
|
||||||
|
cfg = _alembic_config()
|
||||||
|
new = {"pin_hash", "pin_failed_count", "pin_locked_until"}
|
||||||
|
|
||||||
|
def cols():
|
||||||
|
return {c["name"] for c in _inspect(db_path).get_columns("users")}
|
||||||
|
|
||||||
|
command.upgrade(cfg, "head")
|
||||||
|
assert new <= cols()
|
||||||
|
command.downgrade(cfg, "016_vision_recipe_dir")
|
||||||
|
assert not (new & cols())
|
||||||
|
command.upgrade(cfg, "head") # e di nuovo su
|
||||||
|
assert new <= cols()
|
||||||
|
|
||||||
|
|
||||||
|
_SEED_USER_RECIPE_VERSION = [
|
||||||
|
"INSERT INTO users (username, password_hash, display_name, roles, is_admin, active,"
|
||||||
|
" language_pref, theme_pref) VALUES ('u', 'x', 'U', '[]', 1, 1, 'en', 'light')",
|
||||||
|
"INSERT INTO recipes (code, name, active, created_by) VALUES ('R', 'R', 1, 1)",
|
||||||
|
"INSERT INTO recipe_versions (recipe_id, version_number, is_current, created_by)"
|
||||||
|
" VALUES (1, 1, 1, 1)",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class TestTaskLoopResultsMigration:
|
||||||
|
"""018_task_loop_results: in_loop/at_close/fields, tipo check, task_results (#53 #54 #55)."""
|
||||||
|
|
||||||
|
def test_018_upgrade_and_downgrade(self, sqlite_database_url):
|
||||||
|
db_path, _ = sqlite_database_url
|
||||||
|
cfg = _alembic_config()
|
||||||
|
|
||||||
|
def cols():
|
||||||
|
return {c["name"] for c in _inspect(db_path).get_columns("recipe_tasks")}
|
||||||
|
|
||||||
|
command.upgrade(cfg, "head")
|
||||||
|
assert {"in_loop", "at_close", "fields"} <= cols()
|
||||||
|
assert "task_results" in _inspect(db_path).get_table_names()
|
||||||
|
command.downgrade(cfg, "017_user_pin")
|
||||||
|
assert not ({"in_loop", "at_close", "fields"} & cols())
|
||||||
|
assert "task_results" not in _inspect(db_path).get_table_names()
|
||||||
|
command.upgrade(cfg, "head") # e di nuovo su
|
||||||
|
|
||||||
|
def test_018_marks_existing_measure_tasks_in_loop(self, sqlite_database_url):
|
||||||
|
db_path, _ = sqlite_database_url
|
||||||
|
cfg = _alembic_config()
|
||||||
|
command.upgrade(cfg, "017_user_pin")
|
||||||
|
engine = create_engine(f"sqlite:///{db_path}")
|
||||||
|
with engine.begin() as conn:
|
||||||
|
for stmt in _SEED_USER_RECIPE_VERSION:
|
||||||
|
conn.execute(text(stmt))
|
||||||
|
for i, t in enumerate(("measure", "camera_measure", "dxf_compare", "note", "drawing")):
|
||||||
|
conn.execute(text(
|
||||||
|
"INSERT INTO recipe_tasks (version_id, order_index, task_type, title)"
|
||||||
|
f" VALUES (1, {i}, '{t}', '{t}')"))
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
command.upgrade(cfg, "head")
|
||||||
|
engine = create_engine(f"sqlite:///{db_path}")
|
||||||
|
with engine.connect() as conn:
|
||||||
|
rows = dict(conn.execute(text("SELECT task_type, in_loop FROM recipe_tasks")).all())
|
||||||
|
engine.dispose()
|
||||||
|
assert rows == {"measure": 1, "camera_measure": 1, "dxf_compare": 1, "note": 0, "drawing": 0}
|
||||||
|
|
||||||
|
def test_018_downgrade_refused_when_a_check_task_exists(self, sqlite_database_url):
|
||||||
|
db_path, _ = sqlite_database_url
|
||||||
|
cfg = _alembic_config()
|
||||||
|
command.upgrade(cfg, "head")
|
||||||
|
engine = create_engine(f"sqlite:///{db_path}")
|
||||||
|
with engine.begin() as conn:
|
||||||
|
for stmt in _SEED_USER_RECIPE_VERSION:
|
||||||
|
conn.execute(text(stmt))
|
||||||
|
conn.execute(text(
|
||||||
|
"INSERT INTO recipe_tasks (version_id, order_index, task_type, title)"
|
||||||
|
" VALUES (1, 0, 'check', 'c')"))
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="check"):
|
||||||
|
command.downgrade(cfg, "017_user_pin")
|
||||||
|
|
||||||
|
|
||||||
|
class TestStationVisionWorkerMigration:
|
||||||
|
"""019_station_vision_worker: stations.vision_worker_url e vision_results.worker_url, su e giu (#45)."""
|
||||||
|
|
||||||
|
def test_019_upgrade_and_downgrade(self, sqlite_database_url):
|
||||||
|
db_path, _ = sqlite_database_url
|
||||||
|
cfg = _alembic_config()
|
||||||
|
|
||||||
|
def cols(table):
|
||||||
|
return {c["name"] for c in _inspect(db_path).get_columns(table)}
|
||||||
|
|
||||||
|
command.upgrade(cfg, "head")
|
||||||
|
assert "vision_worker_url" in cols("stations")
|
||||||
|
assert "worker_url" in cols("vision_results")
|
||||||
|
command.downgrade(cfg, "018_task_loop_results")
|
||||||
|
assert "vision_worker_url" not in cols("stations")
|
||||||
|
assert "worker_url" not in cols("vision_results")
|
||||||
|
command.upgrade(cfg, "head") # e di nuovo su
|
||||||
|
assert "vision_worker_url" in cols("stations")
|
||||||
|
assert "worker_url" in cols("vision_results")
|
||||||
|
|
||||||
|
|
||||||
|
class TestStationCameraMigration:
|
||||||
|
"""020_station_camera: stations.camera_url e stations.camera_source, su e giu (#72)."""
|
||||||
|
|
||||||
|
def test_020_upgrade_and_downgrade(self, sqlite_database_url):
|
||||||
|
db_path, _ = sqlite_database_url
|
||||||
|
cfg = _alembic_config()
|
||||||
|
|
||||||
|
def cols(table):
|
||||||
|
return {c["name"] for c in _inspect(db_path).get_columns(table)}
|
||||||
|
|
||||||
|
command.upgrade(cfg, "head")
|
||||||
|
assert {"camera_url", "camera_source"} <= cols("stations")
|
||||||
|
command.downgrade(cfg, "019_station_vision_worker")
|
||||||
|
assert "camera_url" not in cols("stations")
|
||||||
|
assert "camera_source" not in cols("stations")
|
||||||
|
assert "vision_worker_url" in cols("stations") # la 019 resta
|
||||||
|
command.upgrade(cfg, "head") # e di nuovo su
|
||||||
|
assert {"camera_url", "camera_source"} <= cols("stations")
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
"""PIN a 4 cifre: login rapido, blocco, capoturno via PIN (#59)."""
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from httpx import AsyncClient
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from src.backend.config import settings
|
||||||
|
from src.backend.models.orm.user import User
|
||||||
|
from src.backend.services import auth_service
|
||||||
|
from src.backend.tests.conftest import _create_user, auth_headers
|
||||||
|
from src.backend.tests.test_production_runs import (
|
||||||
|
_open, _recipe_with_interval, _station,
|
||||||
|
)
|
||||||
|
from src.backend.utils.tempo import utcnow
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _alza_limite_login(monkeypatch):
|
||||||
|
# #65: login-pin conta nel bucket del login (limite al minuto); i test del blocco PIN
|
||||||
|
# fanno piu tentativi di fila, quindi qui il limite si alza. Il middleware resta com'e.
|
||||||
|
monkeypatch.setattr(settings, "rate_limit_login", 1000)
|
||||||
|
|
||||||
|
|
||||||
|
async def _pin_user(db_session, username="operaio", pin="1234", **kw) -> User:
|
||||||
|
user = await _create_user(db_session, username=username, **kw)
|
||||||
|
await auth_service.set_user_pin(db_session, user, pin)
|
||||||
|
await db_session.commit()
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
async def _login_pin(client, user_id, pin):
|
||||||
|
return await client.post("/api/auth/login-pin", json={"user_id": user_id, "pin": pin})
|
||||||
|
|
||||||
|
|
||||||
|
async def test_login_pin_ok_same_shape_as_login(client: AsyncClient, db_session):
|
||||||
|
user = await _pin_user(db_session)
|
||||||
|
resp = await _login_pin(client, user.id, "1234")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["user"]["username"] == "operaio"
|
||||||
|
assert data["user"]["has_pin"] is True
|
||||||
|
assert data["api_key"]
|
||||||
|
assert "pin_hash" not in data["user"]
|
||||||
|
# la api_key vale davvero
|
||||||
|
me = await client.get("/api/auth/me", headers={"X-API-Key": data["api_key"]})
|
||||||
|
assert me.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
async def test_login_pin_wrong_is_401(client: AsyncClient, db_session):
|
||||||
|
user = await _pin_user(db_session)
|
||||||
|
assert (await _login_pin(client, user.id, "9999")).status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
async def test_login_pin_format_422(client: AsyncClient, db_session):
|
||||||
|
user = await _pin_user(db_session)
|
||||||
|
for bad in ("123", "12345", "12a4", ""):
|
||||||
|
assert (await _login_pin(client, user.id, bad)).status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
async def test_lock_after_five_errors_even_with_right_pin(client: AsyncClient, db_session):
|
||||||
|
user = await _pin_user(db_session)
|
||||||
|
for _ in range(4):
|
||||||
|
assert (await _login_pin(client, user.id, "0000")).status_code == 401
|
||||||
|
resp = await _login_pin(client, user.id, "0000") # 5 errore: scatta il blocco
|
||||||
|
assert resp.status_code == 423
|
||||||
|
assert "locked until" in resp.json()["detail"]
|
||||||
|
assert (await _login_pin(client, user.id, "1234")).status_code == 423
|
||||||
|
|
||||||
|
|
||||||
|
async def test_lock_expires_and_counter_resets(client: AsyncClient, db_session):
|
||||||
|
user = await _pin_user(db_session)
|
||||||
|
for _ in range(5):
|
||||||
|
await _login_pin(client, user.id, "0000")
|
||||||
|
row = (await db_session.execute(select(User).where(User.id == user.id))).scalar_one()
|
||||||
|
await db_session.refresh(row)
|
||||||
|
assert row.pin_locked_until > utcnow() + timedelta(minutes=14)
|
||||||
|
# tempo simulato: il blocco e scaduto un secondo fa
|
||||||
|
row.pin_locked_until = utcnow() - timedelta(seconds=1)
|
||||||
|
await db_session.commit()
|
||||||
|
assert (await _login_pin(client, user.id, "1234")).status_code == 200
|
||||||
|
await db_session.refresh(row)
|
||||||
|
assert row.pin_failed_count == 0 and row.pin_locked_until is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_success_resets_failed_counter(client: AsyncClient, db_session):
|
||||||
|
user = await _pin_user(db_session)
|
||||||
|
for _ in range(3):
|
||||||
|
await _login_pin(client, user.id, "0000")
|
||||||
|
assert (await _login_pin(client, user.id, "1234")).status_code == 200
|
||||||
|
for _ in range(4): # il contatore e ripartito da zero: 4 errori non bloccano
|
||||||
|
assert (await _login_pin(client, user.id, "0000")).status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
async def test_admin_cannot_have_pin_or_login_with_it(client: AsyncClient, admin_user, db_session):
|
||||||
|
resp = await client.put(
|
||||||
|
f"/api/users/{admin_user.id}/pin", headers=auth_headers(admin_user), json={"pin": "1234"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
# anche un admin con pin_hash scritto a mano non entra da login-pin
|
||||||
|
admin_user.pin_hash = auth_service.hash_password("1234")
|
||||||
|
await db_session.commit()
|
||||||
|
assert (await _login_pin(client, admin_user.id, "1234")).status_code == 401
|
||||||
|
listed = (await client.get("/api/auth/pin-users")).json()
|
||||||
|
assert all(u["id"] != admin_user.id for u in listed)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_put_pin_set_and_remove(client: AsyncClient, admin_user, db_session):
|
||||||
|
user = await _create_user(db_session, username="senzapin")
|
||||||
|
await db_session.commit()
|
||||||
|
h = auth_headers(admin_user)
|
||||||
|
assert (await client.put(f"/api/users/{user.id}/pin", headers=h, json={"pin": "4321"})).status_code == 200
|
||||||
|
assert (await _login_pin(client, user.id, "4321")).status_code == 200
|
||||||
|
assert (await client.put(f"/api/users/{user.id}/pin", headers=h, json={"pin": "12"})).status_code == 422
|
||||||
|
assert (await client.put(f"/api/users/{user.id}/pin", headers=h, json={"pin": None})).status_code == 200
|
||||||
|
assert (await _login_pin(client, user.id, "4321")).status_code == 401
|
||||||
|
assert (await client.put("/api/users/99999/pin", headers=h, json={"pin": "1234"})).status_code == 404
|
||||||
|
# non admin: 403
|
||||||
|
assert (await client.put(
|
||||||
|
f"/api/users/{user.id}/pin", headers=auth_headers(user), json={"pin": "1234"},
|
||||||
|
)).status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
async def test_pin_users_list(client: AsyncClient, db_session):
|
||||||
|
a = await _pin_user(db_session, username="zeta", display_name="Zeta Rossi", pin="1111")
|
||||||
|
b = await _pin_user(db_session, username="alfa", display_name="Alfa Bianchi", pin="2222")
|
||||||
|
await _create_user(db_session, username="nopin")
|
||||||
|
await _pin_user(db_session, username="inattivo", pin="3333", active=False)
|
||||||
|
await db_session.commit()
|
||||||
|
resp = await client.get("/api/auth/pin-users") # senza autenticazione
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert [u["display_name"] for u in data] == ["Alfa Bianchi", "Zeta Rossi"]
|
||||||
|
assert all(set(u) == {"id", "display_name"} for u in data)
|
||||||
|
assert {u["id"] for u in data} == {a.id, b.id}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_supervisor_via_pin(client: AsyncClient, measurement_tec_user, admin_user, db_session):
|
||||||
|
station = await _station(db_session, admin_user.id, code="ST-PIN")
|
||||||
|
recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-PIN")
|
||||||
|
run_id = (await _open(client, measurement_tec_user, station, recipe)).json()["id"]
|
||||||
|
capo = await _pin_user(db_session, username="capo-pin", pin="5555", roles=["Supervisor"])
|
||||||
|
h = auth_headers(measurement_tec_user)
|
||||||
|
|
||||||
|
bad = await client.post(
|
||||||
|
f"/api/production-runs/{run_id}/pause", headers=h,
|
||||||
|
json={"supervisor_user_id": capo.id, "supervisor_pin": "0000"},
|
||||||
|
)
|
||||||
|
assert bad.status_code == 401
|
||||||
|
ok = await client.post(
|
||||||
|
f"/api/production-runs/{run_id}/pause", headers=h,
|
||||||
|
json={"supervisor_user_id": capo.id, "supervisor_pin": "5555"},
|
||||||
|
)
|
||||||
|
assert ok.status_code == 200
|
||||||
|
# nessuna coppia di credenziali: 422
|
||||||
|
none = await client.post(f"/api/production-runs/{run_id}/resume", headers=h, json={})
|
||||||
|
assert none.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
async def test_supervisor_pin_role_still_checked(db_session):
|
||||||
|
plain = await _pin_user(db_session, username="non-capo", pin="6666", roles=["Maker"])
|
||||||
|
with pytest.raises(HTTPException) as e:
|
||||||
|
await auth_service.authorise_supervisor(db_session, user_id=plain.id, pin="6666")
|
||||||
|
assert e.value.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
async def test_supervisor_pin_lockout(db_session):
|
||||||
|
capo = await _pin_user(db_session, username="capo-lock", pin="7777", roles=["Supervisor"])
|
||||||
|
codes = []
|
||||||
|
for pin in ["0000"] * 5 + ["7777"]:
|
||||||
|
with pytest.raises(HTTPException) as e:
|
||||||
|
await auth_service.authorise_supervisor(db_session, user_id=capo.id, pin=pin)
|
||||||
|
codes.append(e.value.status_code)
|
||||||
|
assert codes == [401, 401, 401, 401, 423, 423]
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
"""Backend su un file SQLite locale (pacchetto Windows, TieMeasureFlow#77).
|
||||||
|
|
||||||
|
Il modulo `database` costruisce l'engine all'import leggendo l'ambiente, quindi
|
||||||
|
i test che riguardano DB_URL girano in un sottoprocesso con DB_URL/UPLOAD_DIR
|
||||||
|
impostati: e' lo stesso percorso di avvio.py nel pacchetto, senza toccare
|
||||||
|
l'engine in memoria usato dal resto della suite.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import textwrap
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from src.backend.config import Settings
|
||||||
|
|
||||||
|
_ROOT = Path(__file__).resolve().parents[3]
|
||||||
|
|
||||||
|
# Script eseguito nel sottoprocesso: upgrade head da DB vuoto, poi app reale
|
||||||
|
# (health, login con utente creato via ORM), PRAGMA e vincolo FK.
|
||||||
|
_SCRIPT = textwrap.dedent(
|
||||||
|
"""
|
||||||
|
import asyncio, json, sys
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
# WeasyPrint senza GTK: stesso mock di conftest.py (le DLL sono affare del piano B)
|
||||||
|
sys.modules.setdefault("weasyprint", MagicMock())
|
||||||
|
from alembic import command
|
||||||
|
from alembic.config import Config
|
||||||
|
|
||||||
|
from src.backend.config import settings
|
||||||
|
ini = Path(r"{ini}")
|
||||||
|
command.upgrade(Config(str(ini)), "head")
|
||||||
|
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
from sqlalchemy import select, text
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from src.backend.database import async_session_factory, engine
|
||||||
|
from src.backend.main import app
|
||||||
|
from src.backend.models.orm.user import User
|
||||||
|
from src.backend.models.orm.recipe import RecipeVersion
|
||||||
|
from src.backend.services.auth_service import hash_password
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
out = {{}}
|
||||||
|
async with async_session_factory() as s:
|
||||||
|
s.add(User(username="fiera", password_hash=hash_password("Pass12345"),
|
||||||
|
display_name="Fiera", roles=["Maker"], is_admin=True))
|
||||||
|
await s.commit()
|
||||||
|
async with async_session_factory() as s:
|
||||||
|
out["utente"] = (await s.execute(select(User.username))).scalars().all()
|
||||||
|
out["fk"] = (await s.execute(text("PRAGMA foreign_keys"))).scalar()
|
||||||
|
out["journal"] = (await s.execute(text("PRAGMA journal_mode"))).scalar()
|
||||||
|
out["busy"] = (await s.execute(text("PRAGMA busy_timeout"))).scalar()
|
||||||
|
# FK attive: una versione con recipe_id inesistente deve essere rifiutata
|
||||||
|
try:
|
||||||
|
async with async_session_factory() as s:
|
||||||
|
s.add(RecipeVersion(recipe_id=99999, version_number=1, created_by=1))
|
||||||
|
await s.commit()
|
||||||
|
out["fk_violation"] = False
|
||||||
|
except IntegrityError:
|
||||||
|
out["fk_violation"] = True
|
||||||
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t") as c:
|
||||||
|
out["health"] = (await c.get("/api/health")).status_code
|
||||||
|
r = await c.post("/api/auth/login", json={{"username": "fiera", "password": "Pass12345"}})
|
||||||
|
out["login"] = r.status_code
|
||||||
|
out["upload_path"] = str(settings.upload_path)
|
||||||
|
out["database_url"] = settings.database_url
|
||||||
|
await engine.dispose()
|
||||||
|
print("RISULTATO=" + json.dumps(out))
|
||||||
|
|
||||||
|
asyncio.run(main())
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _run(tmp_path: Path, extra_env: dict[str, str]) -> dict:
|
||||||
|
ini = _ROOT / "src" / "backend" / "migrations" / "alembic.ini"
|
||||||
|
env = {k: v for k, v in os.environ.items() if k not in ("DB_URL", "UPLOAD_DIR")}
|
||||||
|
env.update(extra_env)
|
||||||
|
env["PYTHONPATH"] = str(_ROOT)
|
||||||
|
env["PYTHONIOENCODING"] = "utf-8"
|
||||||
|
proc = subprocess.run(
|
||||||
|
[sys.executable, "-c", _SCRIPT.format(ini=str(ini))],
|
||||||
|
cwd=str(_ROOT), env=env, capture_output=True, text=True, timeout=180,
|
||||||
|
)
|
||||||
|
assert proc.returncode == 0, proc.stderr[-3000:]
|
||||||
|
line = [l for l in proc.stdout.splitlines() if l.startswith("RISULTATO=")][-1]
|
||||||
|
return json.loads(line[len("RISULTATO="):])
|
||||||
|
|
||||||
|
|
||||||
|
def test_file_sqlite_upgrade_app_pragma_e_upload_assoluto(tmp_path):
|
||||||
|
db = tmp_path / "tmf.db"
|
||||||
|
uploads = tmp_path / "uploads"
|
||||||
|
# barre "/" nell'URL come nel pacchetto Windows (D:/... non va in escape)
|
||||||
|
url = f"sqlite+aiosqlite:///{db.as_posix()}"
|
||||||
|
out = _run(tmp_path, {"DB_URL": url, "UPLOAD_DIR": str(uploads)})
|
||||||
|
|
||||||
|
assert out["database_url"] == url
|
||||||
|
assert db.exists() # il file e' stato creato da upgrade head
|
||||||
|
assert out["utente"] == ["fiera"]
|
||||||
|
assert out["health"] == 200
|
||||||
|
assert out["login"] == 200
|
||||||
|
assert out["fk"] == 1 # PRAGMA foreign_keys attivo
|
||||||
|
assert out["fk_violation"] is True
|
||||||
|
assert out["journal"] == "wal"
|
||||||
|
assert out["busy"] == 5000
|
||||||
|
# UPLOAD_DIR assoluto rispettato (non accodato alla radice del progetto)
|
||||||
|
assert Path(out["upload_path"]) == uploads
|
||||||
|
|
||||||
|
|
||||||
|
def test_database_url_senza_db_url_resta_mysql(monkeypatch):
|
||||||
|
monkeypatch.delenv("DB_URL", raising=False)
|
||||||
|
s = Settings(_env_file=None, db_url=None)
|
||||||
|
assert s.database_url.startswith("mysql+asyncmy://")
|
||||||
|
|
||||||
|
|
||||||
|
def test_db_url_vince_sulle_parti_db(monkeypatch):
|
||||||
|
s = Settings(_env_file=None, db_url="sqlite+aiosqlite:///C:/x/tmf.db", db_host="altro")
|
||||||
|
assert s.database_url == "sqlite+aiosqlite:///C:/x/tmf.db"
|
||||||
|
|
||||||
|
|
||||||
|
def test_produzione_con_sqlite_non_richiede_db_password():
|
||||||
|
# niente RuntimeError: con SQLite la password DB di default non conta
|
||||||
|
Settings(
|
||||||
|
_env_file=None, app_env="production", server_secret_key="x" * 40,
|
||||||
|
db_url="sqlite+aiosqlite:///C:/x/tmf.db",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_produzione_mysql_rifiuta_ancora_password_di_default():
|
||||||
|
import pytest
|
||||||
|
with pytest.raises(Exception, match="DB_PASSWORD"):
|
||||||
|
Settings(_env_file=None, app_env="production", server_secret_key="x" * 40)
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_dir_relativo_resta_sotto_la_radice():
|
||||||
|
s = Settings(_env_file=None, upload_dir="uploads")
|
||||||
|
assert s.upload_path == _ROOT / "uploads"
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_dir_assoluto(tmp_path):
|
||||||
|
s = Settings(_env_file=None, upload_dir=str(tmp_path / "u"))
|
||||||
|
assert s.upload_path == tmp_path / "u"
|
||||||
@@ -374,3 +374,159 @@ async def test_reset_unknown_station_is_404(client: AsyncClient, admin_user):
|
|||||||
"/api/stations/999999/recipes", headers=auth_headers(admin_user),
|
"/api/stations/999999/recipes", headers=auth_headers(admin_user),
|
||||||
)
|
)
|
||||||
assert resp.status_code == 404
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# --- vision_worker_url (#45) ---
|
||||||
|
|
||||||
|
async def test_station_vision_worker_url_roundtrip(client: AsyncClient, admin_user):
|
||||||
|
created = await client.post(
|
||||||
|
"/api/stations",
|
||||||
|
headers=auth_headers(admin_user),
|
||||||
|
json={"code": "ST-VW", "name": "VW", "vision_worker_url": " http://10.0.0.7:8100/ "},
|
||||||
|
)
|
||||||
|
assert created.status_code == 201, created.text
|
||||||
|
# spazi tolti e "/" finale tolto
|
||||||
|
assert created.json()["vision_worker_url"] == "http://10.0.0.7:8100"
|
||||||
|
|
||||||
|
sid = created.json()["id"]
|
||||||
|
updated = await client.put(
|
||||||
|
f"/api/stations/{sid}", headers=auth_headers(admin_user),
|
||||||
|
json={"vision_worker_url": "https://w.example:9000"},
|
||||||
|
)
|
||||||
|
assert updated.status_code == 200
|
||||||
|
assert updated.json()["vision_worker_url"] == "https://w.example:9000"
|
||||||
|
|
||||||
|
# stringa vuota = torna al worker centrale
|
||||||
|
cleared = await client.put(
|
||||||
|
f"/api/stations/{sid}", headers=auth_headers(admin_user),
|
||||||
|
json={"vision_worker_url": ""},
|
||||||
|
)
|
||||||
|
assert cleared.status_code == 200
|
||||||
|
assert cleared.json()["vision_worker_url"] is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_station_vision_worker_url_defaults_to_null(client: AsyncClient, admin_user):
|
||||||
|
created = await client.post(
|
||||||
|
"/api/stations", headers=auth_headers(admin_user),
|
||||||
|
json={"code": "ST-VN", "name": "VN"},
|
||||||
|
)
|
||||||
|
assert created.status_code == 201
|
||||||
|
assert created.json()["vision_worker_url"] is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_station_vision_worker_url_must_be_http(client: AsyncClient, admin_user):
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/stations", headers=auth_headers(admin_user),
|
||||||
|
json={"code": "ST-VF", "name": "VF", "vision_worker_url": "ftp://x"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
created = await client.post(
|
||||||
|
"/api/stations", headers=auth_headers(admin_user),
|
||||||
|
json={"code": "ST-VG", "name": "VG"},
|
||||||
|
)
|
||||||
|
resp = await client.put(
|
||||||
|
f"/api/stations/{created.json()['id']}", headers=auth_headers(admin_user),
|
||||||
|
json={"vision_worker_url": "ftp://x"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
# --- camera_url / camera_source (#72) ---
|
||||||
|
|
||||||
|
async def test_station_camera_fields_roundtrip(client: AsyncClient, admin_user):
|
||||||
|
created = await client.post(
|
||||||
|
"/api/stations", headers=auth_headers(admin_user),
|
||||||
|
json={
|
||||||
|
"code": "ST-CAM", "name": "CAM",
|
||||||
|
"camera_url": " http://127.0.0.1:8765/ ",
|
||||||
|
"camera_source": " camera:folder ",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert created.status_code == 201, created.text
|
||||||
|
assert created.json()["camera_url"] == "http://127.0.0.1:8765"
|
||||||
|
assert created.json()["camera_source"] == "camera:folder"
|
||||||
|
|
||||||
|
sid = created.json()["id"]
|
||||||
|
read = await client.get(f"/api/stations/{sid}", headers=auth_headers(admin_user))
|
||||||
|
assert read.status_code == 200
|
||||||
|
assert read.json()["camera_url"] == "http://127.0.0.1:8765"
|
||||||
|
assert read.json()["camera_source"] == "camera:folder"
|
||||||
|
|
||||||
|
updated = await client.put(
|
||||||
|
f"/api/stations/{sid}", headers=auth_headers(admin_user),
|
||||||
|
json={"camera_url": "https://cam.example:9000", "camera_source": "camera:ABC123"},
|
||||||
|
)
|
||||||
|
assert updated.status_code == 200
|
||||||
|
assert updated.json()["camera_url"] == "https://cam.example:9000"
|
||||||
|
assert updated.json()["camera_source"] == "camera:ABC123"
|
||||||
|
|
||||||
|
cleared = await client.put(
|
||||||
|
f"/api/stations/{sid}", headers=auth_headers(admin_user),
|
||||||
|
json={"camera_url": "", "camera_source": " "},
|
||||||
|
)
|
||||||
|
assert cleared.status_code == 200
|
||||||
|
assert cleared.json()["camera_url"] is None
|
||||||
|
assert cleared.json()["camera_source"] is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_station_camera_fields_default_to_null(client: AsyncClient, admin_user):
|
||||||
|
created = await client.post(
|
||||||
|
"/api/stations", headers=auth_headers(admin_user),
|
||||||
|
json={"code": "ST-CN", "name": "CN"},
|
||||||
|
)
|
||||||
|
assert created.status_code == 201
|
||||||
|
assert created.json()["camera_url"] is None
|
||||||
|
assert created.json()["camera_source"] is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_station_camera_url_must_be_http(client: AsyncClient, admin_user):
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/stations", headers=auth_headers(admin_user),
|
||||||
|
json={"code": "ST-CF", "name": "CF", "camera_url": "ftp://x"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
created = await client.post(
|
||||||
|
"/api/stations", headers=auth_headers(admin_user),
|
||||||
|
json={"code": "ST-CG", "name": "CG"},
|
||||||
|
)
|
||||||
|
resp = await client.put(
|
||||||
|
f"/api/stations/{created.json()['id']}", headers=auth_headers(admin_user),
|
||||||
|
json={"camera_url": "ftp://x"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
async def test_station_by_code_returns_station(client: AsyncClient, admin_user):
|
||||||
|
await client.post(
|
||||||
|
"/api/stations", headers=auth_headers(admin_user),
|
||||||
|
json={"code": "ST-BC", "name": "BC", "camera_url": "http://h:1", "camera_source": "camera:folder"},
|
||||||
|
)
|
||||||
|
resp = await client.get("/api/stations/by-code/ST-BC", headers=auth_headers(admin_user))
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert resp.json()["code"] == "ST-BC"
|
||||||
|
assert resp.json()["camera_url"] == "http://h:1"
|
||||||
|
assert resp.json()["camera_source"] == "camera:folder"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_station_by_code_unknown_is_404(client: AsyncClient, admin_user):
|
||||||
|
resp = await client.get("/api/stations/by-code/NOPE", headers=auth_headers(admin_user))
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
async def test_station_by_code_any_authenticated_user(
|
||||||
|
client: AsyncClient, measurement_tec_user, admin_user,
|
||||||
|
):
|
||||||
|
await client.post(
|
||||||
|
"/api/stations", headers=auth_headers(admin_user),
|
||||||
|
json={"code": "ST-BA", "name": "BA"},
|
||||||
|
)
|
||||||
|
resp = await client.get("/api/stations/by-code/ST-BA", headers=auth_headers(measurement_tec_user))
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert resp.json()["code"] == "ST-BA"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_station_by_code_requires_auth(client: AsyncClient):
|
||||||
|
resp = await client.get("/api/stations/by-code/ST-BA")
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|||||||
@@ -287,3 +287,116 @@ class TestRunFilter:
|
|||||||
headers=auth_headers(measurement_tec_user),
|
headers=auth_headers(measurement_tec_user),
|
||||||
params={"recipe_id": 1})
|
params={"recipe_id": 1})
|
||||||
assert resp.status_code == 403
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
class TestRunTaskResults:
|
||||||
|
"""#58: GET /api/statistics/runs/{id}/task-results."""
|
||||||
|
|
||||||
|
FIELDS = [
|
||||||
|
{"key": "peso", "label": "Peso", "type": "number", "default": None, "attachment": False},
|
||||||
|
{"key": "foto", "label": "Foto", "type": "text", "default": None, "attachment": True},
|
||||||
|
]
|
||||||
|
|
||||||
|
async def _setup(self, client, db_session, maker_user, tec_user):
|
||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy import select
|
||||||
|
from src.backend.models.orm.production import ProductionRun
|
||||||
|
from src.backend.models.orm.recipe import RecipeVersion
|
||||||
|
from src.backend.models.orm.station import Station
|
||||||
|
from src.backend.models.orm.task_result import TaskResult
|
||||||
|
recipe = await create_test_recipe(db_session, maker_user.id, code="TR58")
|
||||||
|
version = (await db_session.execute(
|
||||||
|
select(RecipeVersion).where(RecipeVersion.recipe_id == recipe.id)
|
||||||
|
)).scalars().first()
|
||||||
|
db_session.add(Station(code="ST-TR58", name="S", active=True, created_by=maker_user.id))
|
||||||
|
await db_session.commit()
|
||||||
|
station = (await db_session.execute(select(Station))).scalars().first()
|
||||||
|
run = ProductionRun(
|
||||||
|
station_id=station.id, recipe_id=recipe.id, version_id=version.id,
|
||||||
|
operator_id=tec_user.id, status="open", started_at=datetime(2026, 1, 1),
|
||||||
|
)
|
||||||
|
db_session.add(run)
|
||||||
|
await db_session.commit()
|
||||||
|
h = auth_headers(maker_user)
|
||||||
|
check = (await client.post(
|
||||||
|
f"/api/recipes/{recipe.id}/tasks", headers=h,
|
||||||
|
json={"title": "Controllo", "task_type": "check"},
|
||||||
|
)).json()
|
||||||
|
note = (await client.post(
|
||||||
|
f"/api/recipes/{recipe.id}/tasks", headers=h,
|
||||||
|
json={"title": "Nota", "task_type": "note", "fields": self.FIELDS},
|
||||||
|
)).json()
|
||||||
|
# Inseriti fuori ordine: l'API deve ordinare per created_at.
|
||||||
|
rows = [
|
||||||
|
TaskResult(task_id=note["id"], version_id=version.id, production_run_id=run.id,
|
||||||
|
user_id=tec_user.id, outcome="done", note="ok finito",
|
||||||
|
values={"peso": "12", "foto": "x.jpg"},
|
||||||
|
attachments=[{"key": "foto", "path": "task_results/a.jpg", "name": "a.jpg"}],
|
||||||
|
created_at=datetime(2026, 1, 1, 10, 3)),
|
||||||
|
TaskResult(task_id=check["id"], version_id=version.id, production_run_id=run.id,
|
||||||
|
user_id=tec_user.id, outcome="nok", note="graffio",
|
||||||
|
created_at=datetime(2026, 1, 1, 10, 2)),
|
||||||
|
TaskResult(task_id=check["id"], version_id=version.id, production_run_id=run.id,
|
||||||
|
user_id=tec_user.id, outcome="ok",
|
||||||
|
created_at=datetime(2026, 1, 1, 10, 1)),
|
||||||
|
# Un esito di un'altra sessione (nessuna) non deve comparire.
|
||||||
|
TaskResult(task_id=check["id"], version_id=version.id, production_run_id=None,
|
||||||
|
user_id=tec_user.id, outcome="ok",
|
||||||
|
created_at=datetime(2026, 1, 1, 9, 0)),
|
||||||
|
]
|
||||||
|
db_session.add_all(rows)
|
||||||
|
await db_session.commit()
|
||||||
|
return recipe, run, check, note
|
||||||
|
|
||||||
|
async def test_counts_order_labels_attachments(
|
||||||
|
self, client, maker_user, measurement_tec_user, metrologist_user, db_session,
|
||||||
|
):
|
||||||
|
_, run, check, note = await self._setup(
|
||||||
|
client, db_session, maker_user, measurement_tec_user)
|
||||||
|
resp = await client.get(f"/api/statistics/runs/{run.id}/task-results",
|
||||||
|
headers=auth_headers(metrologist_user))
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
data = resp.json()
|
||||||
|
assert data["run_id"] == run.id
|
||||||
|
assert data["counts"] == {"ok": 1, "nok": 1, "done": 1}
|
||||||
|
res = data["results"]
|
||||||
|
assert [r["outcome"] for r in res] == ["ok", "nok", "done"] # per ora crescente
|
||||||
|
assert res[1]["note"] == "graffio"
|
||||||
|
assert res[1]["task_title"] == "Controllo"
|
||||||
|
assert res[1]["task_type"] == "check"
|
||||||
|
assert res[1]["user_name"] == measurement_tec_user.display_name
|
||||||
|
assert res[1]["values"] == [] and res[1]["attachments"] == []
|
||||||
|
n = res[2]
|
||||||
|
assert n["task_title"] == "Nota" and n["note"] == "ok finito"
|
||||||
|
assert n["values"] == [
|
||||||
|
{"key": "peso", "label": "Peso", "value": "12"},
|
||||||
|
{"key": "foto", "label": "Foto", "value": "x.jpg"},
|
||||||
|
]
|
||||||
|
assert n["attachments"] == [
|
||||||
|
{"key": "foto", "label": "Foto", "name": "a.jpg", "path": "task_results/a.jpg"},
|
||||||
|
]
|
||||||
|
|
||||||
|
async def test_label_falls_back_to_key_when_field_removed(
|
||||||
|
self, client, maker_user, measurement_tec_user, metrologist_user, db_session,
|
||||||
|
):
|
||||||
|
from src.backend.models.orm.task import RecipeTask
|
||||||
|
_, run, _, note = await self._setup(
|
||||||
|
client, db_session, maker_user, measurement_tec_user)
|
||||||
|
task = await db_session.get(RecipeTask, note["id"])
|
||||||
|
task.fields = [self.FIELDS[0]] # il campo «foto» viene tolto dal task
|
||||||
|
await db_session.commit()
|
||||||
|
resp = await client.get(f"/api/statistics/runs/{run.id}/task-results",
|
||||||
|
headers=auth_headers(metrologist_user))
|
||||||
|
n = resp.json()["results"][2]
|
||||||
|
assert {v["key"]: v["label"] for v in n["values"]} == {"peso": "Peso", "foto": "foto"}
|
||||||
|
assert n["attachments"][0]["label"] == "foto"
|
||||||
|
|
||||||
|
async def test_unknown_run_is_404(self, client, metrologist_user):
|
||||||
|
resp = await client.get("/api/statistics/runs/99999/task-results",
|
||||||
|
headers=auth_headers(metrologist_user))
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
async def test_measurement_tec_is_403(self, client, measurement_tec_user):
|
||||||
|
resp = await client.get("/api/statistics/runs/1/task-results",
|
||||||
|
headers=auth_headers(measurement_tec_user))
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|||||||
@@ -0,0 +1,210 @@
|
|||||||
|
"""Test di in_loop / at_close / fields e del tipo 'check' sui task (#53 #54 #55),
|
||||||
|
di close_task_ids nella risposta del run e del bucket di rate limit del login PIN."""
|
||||||
|
import pytest
|
||||||
|
from httpx import AsyncClient
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from src.backend.api.middleware.rate_limit import RateLimitMiddleware
|
||||||
|
from src.backend.models.orm.measurement import Measurement
|
||||||
|
from src.backend.models.orm.recipe import RecipeVersion
|
||||||
|
from src.backend.models.orm.station import Station
|
||||||
|
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
||||||
|
from src.backend.tests.conftest import auth_headers, create_test_recipe
|
||||||
|
|
||||||
|
FIELDS = [
|
||||||
|
{"key": "peso", "label": "Peso", "type": "number", "default": "1.5", "attachment": False},
|
||||||
|
{"key": "foto", "label": "Foto", "type": "text", "default": None, "attachment": True},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def _post_task(client, user, recipe_id, **body):
|
||||||
|
return await client.post(
|
||||||
|
f"/api/recipes/{recipe_id}/tasks", headers=auth_headers(user), json=body,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_create_defaults_in_loop_from_type(client: AsyncClient, maker_user, db_session):
|
||||||
|
recipe = await create_test_recipe(db_session, user_id=maker_user.id, code="REC-L1")
|
||||||
|
await db_session.commit()
|
||||||
|
expected = {"note": False, "drawing": False, "measure": True,
|
||||||
|
"camera_measure": True, "dxf_compare": True, "check": True}
|
||||||
|
for task_type, in_loop in expected.items():
|
||||||
|
resp = await _post_task(client, maker_user, recipe.id, title=task_type, task_type=task_type)
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
body = resp.json()
|
||||||
|
assert body["in_loop"] is in_loop, task_type
|
||||||
|
assert body["at_close"] is False
|
||||||
|
assert body["fields"] is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_create_explicit_in_loop_and_note_fields(client: AsyncClient, maker_user, db_session):
|
||||||
|
recipe = await create_test_recipe(db_session, user_id=maker_user.id, code="REC-L2")
|
||||||
|
await db_session.commit()
|
||||||
|
resp = await _post_task(
|
||||||
|
client, maker_user, recipe.id, title="Nota", task_type="note",
|
||||||
|
in_loop=True, at_close=True, fields=FIELDS,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
body = resp.json()
|
||||||
|
assert body["in_loop"] is True and body["at_close"] is True
|
||||||
|
assert body["fields"] == FIELDS
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("task_type", ["measure", "check", "drawing"])
|
||||||
|
async def test_create_at_close_or_fields_rejected_for_other_types(
|
||||||
|
client: AsyncClient, maker_user, db_session, task_type,
|
||||||
|
):
|
||||||
|
recipe = await create_test_recipe(db_session, user_id=maker_user.id, code=f"REC-L3{task_type}")
|
||||||
|
await db_session.commit()
|
||||||
|
resp = await _post_task(client, maker_user, recipe.id, title="x", task_type=task_type, at_close=True)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert isinstance(resp.json()["detail"], str)
|
||||||
|
resp = await _post_task(client, maker_user, recipe.id, title="x", task_type=task_type, fields=FIELDS)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("bad", [
|
||||||
|
[{"key": "A b", "label": "x", "type": "text"}], # chiave non slug
|
||||||
|
[{"key": "k", "label": "", "type": "text"}], # etichetta vuota
|
||||||
|
[{"key": "k", "label": "x", "type": "date"}], # tipo ignoto
|
||||||
|
[{"key": "k", "label": "x", "type": "text"}, {"key": "k", "label": "y", "type": "text"}], # duplicata
|
||||||
|
[{"key": "k", "label": "x", "type": "number", "default": "abc"}], # default non numerico
|
||||||
|
[{"key": f"k{i}", "label": "x", "type": "text"} for i in range(21)], # troppi
|
||||||
|
])
|
||||||
|
async def test_create_invalid_fields_rejected(client: AsyncClient, maker_user, db_session, bad):
|
||||||
|
recipe = await create_test_recipe(db_session, user_id=maker_user.id, code="REC-L4")
|
||||||
|
await db_session.commit()
|
||||||
|
resp = await _post_task(client, maker_user, recipe.id, title="n", task_type="note", fields=bad)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert isinstance(resp.json()["detail"], str)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_update_fields_null_clears_and_type_rules(client: AsyncClient, maker_user, db_session):
|
||||||
|
recipe = await create_test_recipe(db_session, user_id=maker_user.id, code="REC-L5")
|
||||||
|
await db_session.commit()
|
||||||
|
created = (await _post_task(
|
||||||
|
client, maker_user, recipe.id, title="Nota", task_type="note",
|
||||||
|
at_close=True, fields=FIELDS,
|
||||||
|
)).json()
|
||||||
|
headers = auth_headers(maker_user)
|
||||||
|
|
||||||
|
# il frontend manda sempre i tre campi: fields null = nessun campo
|
||||||
|
resp = await client.put(
|
||||||
|
f"/api/tasks/{created['id']}", headers=headers,
|
||||||
|
json={"in_loop": True, "at_close": False, "fields": None},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
body = resp.json()
|
||||||
|
assert body["in_loop"] is True and body["at_close"] is False and body["fields"] is None
|
||||||
|
|
||||||
|
# tipo diverso da note con at_close o fields: 422
|
||||||
|
resp = await client.put(
|
||||||
|
f"/api/tasks/{created['id']}", headers=headers,
|
||||||
|
json={"task_type": "measure", "at_close": True, "fields": None},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
resp = await client.put(
|
||||||
|
f"/api/tasks/{created['id']}", headers=headers,
|
||||||
|
json={"task_type": "check", "at_close": False, "fields": FIELDS},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
# un task che ha gia' at_close non puo' passare a measure senza toglierlo
|
||||||
|
resp = await client.put(
|
||||||
|
f"/api/tasks/{created['id']}", headers=headers,
|
||||||
|
json={"at_close": True, "fields": FIELDS},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
resp = await client.put(
|
||||||
|
f"/api/tasks/{created['id']}", headers=headers, json={"task_type": "measure"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
async def test_copy_on_write_carries_the_three_fields(
|
||||||
|
client: AsyncClient, maker_user, db_session,
|
||||||
|
):
|
||||||
|
recipe = await create_test_recipe(db_session, user_id=maker_user.id, code="REC-L6")
|
||||||
|
await db_session.commit()
|
||||||
|
created = (await _post_task(
|
||||||
|
client, maker_user, recipe.id, title="Nota", task_type="note",
|
||||||
|
in_loop=True, at_close=True, fields=FIELDS,
|
||||||
|
)).json()
|
||||||
|
|
||||||
|
# una misura sulla versione corrente forza il copy-on-write alla prossima modifica
|
||||||
|
version = (await db_session.execute(
|
||||||
|
select(RecipeVersion).where(
|
||||||
|
RecipeVersion.recipe_id == recipe.id, RecipeVersion.is_current == True, # noqa: E712
|
||||||
|
)
|
||||||
|
)).scalar_one()
|
||||||
|
subtask_id = (await db_session.execute(
|
||||||
|
select(RecipeSubtask.id).join(RecipeSubtask.task)
|
||||||
|
.where(RecipeSubtask.task.has(version_id=version.id))
|
||||||
|
)).scalars().first()
|
||||||
|
db_session.add(Measurement(
|
||||||
|
subtask_id=subtask_id, version_id=version.id, measured_by=maker_user.id,
|
||||||
|
value=10.0, pass_fail="pass", deviation=0.0, input_method="manual",
|
||||||
|
))
|
||||||
|
await db_session.commit()
|
||||||
|
# sessione condivisa col client: la collezione version.tasks va riletta
|
||||||
|
db_session.expire_all()
|
||||||
|
|
||||||
|
resp = await client.put(
|
||||||
|
f"/api/tasks/{created['id']}", headers=auth_headers(maker_user),
|
||||||
|
json={"title": "Nota rinominata"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
body = resp.json()
|
||||||
|
assert body["version_id"] != version.id # e' la copia nella nuova versione
|
||||||
|
assert body["in_loop"] is True and body["at_close"] is True
|
||||||
|
assert body["fields"] == FIELDS
|
||||||
|
|
||||||
|
|
||||||
|
async def test_run_response_loop_and_close_task_ids(
|
||||||
|
client: AsyncClient, maker_user, measurement_tec_user, admin_user, db_session,
|
||||||
|
):
|
||||||
|
recipe = await create_test_recipe(db_session, user_id=maker_user.id, code="REC-L7")
|
||||||
|
db_session.add(Station(code="ST-L7", name="S", active=True, created_by=admin_user.id))
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
async def mk(**body):
|
||||||
|
resp = await _post_task(client, maker_user, recipe.id, **body)
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
check = await mk(title="Check", task_type="check")
|
||||||
|
note_loop = await mk(title="Nota ciclo", task_type="note", in_loop=True)
|
||||||
|
note_out = await mk(title="Nota fuori", task_type="note")
|
||||||
|
close_a = await mk(title="Chiusura A", task_type="note", at_close=True)
|
||||||
|
measure_off = await mk(title="Misura fuori", task_type="measure", in_loop=False)
|
||||||
|
close_b = await mk(title="Chiusura B", task_type="note", at_close=True)
|
||||||
|
|
||||||
|
first_id = (await db_session.execute(
|
||||||
|
select(RecipeTask.id).where(RecipeTask.version_id == check["version_id"])
|
||||||
|
.order_by(RecipeTask.order_index)
|
||||||
|
)).scalars().first()
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/production-runs", headers=auth_headers(measurement_tec_user),
|
||||||
|
json={"station_code": "ST-L7", "recipe_id": recipe.id},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
body = resp.json()
|
||||||
|
# ciclo: solo in_loop, in ordine (misura della fixture, check, nota in ciclo)
|
||||||
|
assert body["measurement_task_ids"] == [first_id, check["id"], note_loop["id"]]
|
||||||
|
assert note_out["id"] not in body["measurement_task_ids"]
|
||||||
|
assert measure_off["id"] not in body["measurement_task_ids"]
|
||||||
|
assert body["close_task_ids"] == [close_a["id"], close_b["id"]]
|
||||||
|
|
||||||
|
|
||||||
|
class TestLoginPinRateLimitBucket:
|
||||||
|
"""#59: il login con PIN sta nel bucket 'login' come il login con password."""
|
||||||
|
|
||||||
|
def test_login_pin_is_login_bucket(self):
|
||||||
|
assert RateLimitMiddleware._is_login_bucket_request("POST", "/api/auth/login-pin")
|
||||||
|
|
||||||
|
def test_login_pin_get_is_not_login_bucket(self):
|
||||||
|
assert not RateLimitMiddleware._is_login_bucket_request("GET", "/api/auth/login-pin")
|
||||||
|
|
||||||
|
def test_pin_users_list_is_not_login_bucket(self):
|
||||||
|
assert not RateLimitMiddleware._is_login_bucket_request("GET", "/api/auth/pin-users")
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
"""Test di POST/GET /api/task-results (#54 check OK/NOK, #55 note con campi)."""
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from httpx import AsyncClient
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from src.backend.models.orm.production import ProductionRun
|
||||||
|
from src.backend.models.orm.station import Station
|
||||||
|
from src.backend.tests.conftest import auth_headers, create_test_recipe
|
||||||
|
|
||||||
|
FIELDS = [
|
||||||
|
{"key": "peso", "label": "Peso", "type": "number", "default": None, "attachment": False},
|
||||||
|
{"key": "foto", "label": "Foto", "type": "text", "default": None, "attachment": True},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def _setup(client, db_session, maker_user, admin_user, code):
|
||||||
|
recipe = await create_test_recipe(db_session, user_id=maker_user.id, code=code)
|
||||||
|
db_session.add(Station(code=f"ST-{code}", name="S", active=True, created_by=admin_user.id))
|
||||||
|
await db_session.commit()
|
||||||
|
h = auth_headers(maker_user)
|
||||||
|
check = (await client.post(
|
||||||
|
f"/api/recipes/{recipe.id}/tasks", headers=h, json={"title": "C", "task_type": "check"},
|
||||||
|
)).json()
|
||||||
|
note = (await client.post(
|
||||||
|
f"/api/recipes/{recipe.id}/tasks", headers=h,
|
||||||
|
json={"title": "N", "task_type": "note", "fields": FIELDS},
|
||||||
|
)).json()
|
||||||
|
return recipe, check, note
|
||||||
|
|
||||||
|
|
||||||
|
async def _open_run(client, user, recipe, code):
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/production-runs", headers=auth_headers(user),
|
||||||
|
json={"station_code": f"ST-{code}", "recipe_id": recipe.id},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def _post(client, user, **body):
|
||||||
|
return await client.post("/api/task-results", headers=auth_headers(user), json=body)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_requires_auth_and_role(client: AsyncClient, maker_user, admin_user, db_session):
|
||||||
|
_, check, _ = await _setup(client, db_session, maker_user, admin_user, "TR0")
|
||||||
|
resp = await client.post("/api/task-results", json={"task_id": check["id"], "outcome": "ok"})
|
||||||
|
assert resp.status_code == 401
|
||||||
|
# un Maker senza ruolo di reparto non registra esiti
|
||||||
|
resp = await _post(client, maker_user, task_id=check["id"], outcome="ok")
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
async def test_check_ok_and_nok_rules(
|
||||||
|
client: AsyncClient, maker_user, admin_user, measurement_tec_user, db_session,
|
||||||
|
):
|
||||||
|
recipe, check, _ = await _setup(client, db_session, maker_user, admin_user, "TR1")
|
||||||
|
run = await _open_run(client, measurement_tec_user, recipe, "TR1")
|
||||||
|
|
||||||
|
resp = await _post(client, measurement_tec_user, task_id=check["id"],
|
||||||
|
production_run_id=run["id"], outcome="ok")
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
body = resp.json()
|
||||||
|
assert body["outcome"] == "ok"
|
||||||
|
assert body["version_id"] == check["version_id"]
|
||||||
|
assert body["user_id"] == measurement_tec_user.id
|
||||||
|
assert body["production_run_id"] == run["id"]
|
||||||
|
assert body["created_at"]
|
||||||
|
|
||||||
|
for note in (None, "", " "):
|
||||||
|
resp = await _post(client, measurement_tec_user, task_id=check["id"],
|
||||||
|
outcome="nok", note=note)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert resp.json()["detail"] == "Per un NOK serve la nota"
|
||||||
|
|
||||||
|
resp = await _post(client, measurement_tec_user, task_id=check["id"],
|
||||||
|
outcome="nok", note=" graffio ")
|
||||||
|
assert resp.status_code == 201
|
||||||
|
assert resp.json()["note"] == "graffio"
|
||||||
|
|
||||||
|
# un check non si chiude con 'done'
|
||||||
|
resp = await _post(client, measurement_tec_user, task_id=check["id"], outcome="done")
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
async def test_note_values_and_attachments(
|
||||||
|
client: AsyncClient, maker_user, admin_user, measurement_tec_user, db_session,
|
||||||
|
):
|
||||||
|
recipe, _, note = await _setup(client, db_session, maker_user, admin_user, "TR2")
|
||||||
|
ok = await _post(
|
||||||
|
client, measurement_tec_user, task_id=note["id"], outcome="done",
|
||||||
|
values={"peso": "12,5"},
|
||||||
|
attachments=[{"key": "foto", "path": "task_results/2026/10/abc.jpg", "name": "foto.jpg"}],
|
||||||
|
)
|
||||||
|
assert ok.status_code == 201, ok.text
|
||||||
|
assert ok.json()["values"] == {"peso": "12,5"}
|
||||||
|
assert ok.json()["attachments"] == [{"key": "foto", "path": "task_results/2026/10/abc.jpg", "name": "foto.jpg"}]
|
||||||
|
|
||||||
|
# solo done
|
||||||
|
assert (await _post(client, measurement_tec_user, task_id=note["id"], outcome="ok")).status_code == 422
|
||||||
|
# chiave fuori dai campi
|
||||||
|
assert (await _post(client, measurement_tec_user, task_id=note["id"], outcome="done",
|
||||||
|
values={"altro": "1"})).status_code == 422
|
||||||
|
# numero non convertibile
|
||||||
|
assert (await _post(client, measurement_tec_user, task_id=note["id"], outcome="done",
|
||||||
|
values={"peso": "abc"})).status_code == 422
|
||||||
|
# allegati con percorso non sicuro
|
||||||
|
for bad in ("../x.jpg", "/etc/passwd", "a/../b.jpg", "C:/x.jpg", "a\\b.jpg"):
|
||||||
|
resp = await _post(client, measurement_tec_user, task_id=note["id"], outcome="done",
|
||||||
|
attachments=[{"key": None, "path": bad, "name": "x"}])
|
||||||
|
assert resp.status_code == 422, bad
|
||||||
|
# chiave allegato fuori dai campi
|
||||||
|
resp = await _post(client, measurement_tec_user, task_id=note["id"], outcome="done",
|
||||||
|
attachments=[{"key": "boh", "path": "task_results/a.jpg", "name": "x"}])
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
async def test_other_task_types_rejected_and_unknown_task(
|
||||||
|
client: AsyncClient, maker_user, admin_user, measurement_tec_user, db_session,
|
||||||
|
):
|
||||||
|
recipe, _, _ = await _setup(client, db_session, maker_user, admin_user, "TR3")
|
||||||
|
measure = (await client.post(
|
||||||
|
f"/api/recipes/{recipe.id}/tasks", headers=auth_headers(maker_user),
|
||||||
|
json={"title": "M", "task_type": "measure"},
|
||||||
|
)).json()
|
||||||
|
resp = await _post(client, measurement_tec_user, task_id=measure["id"], outcome="ok")
|
||||||
|
assert resp.status_code == 422
|
||||||
|
resp = await _post(client, measurement_tec_user, task_id=999999, outcome="ok")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
async def test_run_must_be_open_and_same_version(
|
||||||
|
client: AsyncClient, maker_user, admin_user, measurement_tec_user, db_session,
|
||||||
|
):
|
||||||
|
recipe, check, _ = await _setup(client, db_session, maker_user, admin_user, "TR4")
|
||||||
|
_, other_check, _ = await _setup(client, db_session, maker_user, admin_user, "TR4b")
|
||||||
|
run = await _open_run(client, measurement_tec_user, recipe, "TR4")
|
||||||
|
|
||||||
|
# task di un'altra versione
|
||||||
|
resp = await _post(client, measurement_tec_user, task_id=other_check["id"],
|
||||||
|
production_run_id=run["id"], outcome="ok")
|
||||||
|
assert resp.status_code == 422
|
||||||
|
# run inesistente
|
||||||
|
resp = await _post(client, measurement_tec_user, task_id=check["id"],
|
||||||
|
production_run_id=999999, outcome="ok")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
# run chiuso: non registra piu'
|
||||||
|
row = (await db_session.execute(
|
||||||
|
select(ProductionRun).where(ProductionRun.id == run["id"])
|
||||||
|
)).scalar_one()
|
||||||
|
row.status = "closed"
|
||||||
|
await db_session.commit()
|
||||||
|
resp = await _post(client, measurement_tec_user, task_id=check["id"],
|
||||||
|
production_run_id=run["id"], outcome="ok")
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
async def test_list_filters_and_order(
|
||||||
|
client: AsyncClient, maker_user, admin_user, measurement_tec_user, db_session,
|
||||||
|
):
|
||||||
|
recipe, check, note = await _setup(client, db_session, maker_user, admin_user, "TR5")
|
||||||
|
run = await _open_run(client, measurement_tec_user, recipe, "TR5")
|
||||||
|
a = (await _post(client, measurement_tec_user, task_id=check["id"],
|
||||||
|
production_run_id=run["id"], outcome="ok")).json()
|
||||||
|
b = (await _post(client, measurement_tec_user, task_id=note["id"],
|
||||||
|
production_run_id=run["id"], outcome="done")).json()
|
||||||
|
c = (await _post(client, measurement_tec_user, task_id=check["id"], outcome="ok")).json()
|
||||||
|
h = auth_headers(measurement_tec_user)
|
||||||
|
|
||||||
|
resp = await client.get(f"/api/task-results?production_run_id={run['id']}", headers=h)
|
||||||
|
assert [r["id"] for r in resp.json()] == [b["id"], a["id"]] # piu' recente prima
|
||||||
|
resp = await client.get(f"/api/task-results?task_id={check['id']}", headers=h)
|
||||||
|
assert [r["id"] for r in resp.json()] == [c["id"], a["id"]]
|
||||||
|
resp = await client.get(
|
||||||
|
f"/api/task-results?task_id={check['id']}&production_run_id={run['id']}", headers=h)
|
||||||
|
assert [r["id"] for r in resp.json()] == [a["id"]]
|
||||||
|
assert (await client.get("/api/task-results")).status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# POST /api/task-results/attachments: l'operatore carica gli allegati (#55)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# firma PNG valida (magic bytes) piu' un po' di zeri
|
||||||
|
PNG = bytes([0x89]) + b"PNG" + bytes([13, 10, 26, 10]) + bytes(32)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_attachment_upload_ok_for_measurement_tec(
|
||||||
|
client: AsyncClient, measurement_tec_user, tmp_path,
|
||||||
|
):
|
||||||
|
with patch("src.backend.api.routers.task_results.settings") as mock_settings:
|
||||||
|
mock_settings.upload_path = tmp_path
|
||||||
|
mock_settings.max_upload_size_mb = 50
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/task-results/attachments", headers=auth_headers(measurement_tec_user),
|
||||||
|
files={"file": ("foto bella (1).png", PNG, "image/png")},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
body = resp.json()
|
||||||
|
assert body["name"] == "fotobella1.png"
|
||||||
|
assert body["file_path"].startswith("task_results/")
|
||||||
|
assert body["file_path"].endswith(".png")
|
||||||
|
assert (tmp_path / body["file_path"]).read_bytes() == PNG
|
||||||
|
|
||||||
|
|
||||||
|
async def test_attachment_upload_rejects_wrong_type_size_and_role(
|
||||||
|
client: AsyncClient, measurement_tec_user, maker_user, tmp_path,
|
||||||
|
):
|
||||||
|
h = auth_headers(measurement_tec_user)
|
||||||
|
with patch("src.backend.api.routers.task_results.settings") as mock_settings:
|
||||||
|
mock_settings.upload_path = tmp_path
|
||||||
|
mock_settings.max_upload_size_mb = 1
|
||||||
|
# tipo non ammesso (HTML dichiarato come png): come upload_file, 400
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/task-results/attachments", headers=h,
|
||||||
|
files={"file": ("x.png", b"<html></html>", "image/png")},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
# oltre il limite
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/task-results/attachments", headers=h,
|
||||||
|
files={"file": ("x.png", PNG + bytes(1024 * 1024 + 1), "image/png")},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
# un Maker senza ruolo di reparto non carica
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/task-results/attachments", headers=auth_headers(maker_user),
|
||||||
|
files={"file": ("x.png", PNG, "image/png")},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
assert not any(tmp_path.rglob("*.png"))
|
||||||
|
|
||||||
|
|
||||||
|
async def test_task_results_dir_is_undeletable(client: AsyncClient, admin_user):
|
||||||
|
resp = await client.delete(
|
||||||
|
"/api/files/task_results/2026/10/abc.png", headers=auth_headers(admin_user),
|
||||||
|
)
|
||||||
|
assert resp.status_code in (403, 404)
|
||||||
|
assert resp.status_code == 403
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
"""#45: il worker di visione si sceglie per stazione.
|
||||||
|
|
||||||
|
La stazione si legge dalla sessione di produzione (`production_run_id`): con un
|
||||||
|
URL sulla stazione `_call_worker` lo riceve e il VisionResult lo registra;
|
||||||
|
senza URL o senza run si resta sul worker centrale, senza ripiego fra i due.
|
||||||
|
"""
|
||||||
|
import io
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from src.backend.config import settings
|
||||||
|
from src.backend.models.api.production import ProductionRunCreate
|
||||||
|
from src.backend.models.orm.station import Station
|
||||||
|
from src.backend.models.orm.vision import VisionResult
|
||||||
|
from src.backend.services import production_service
|
||||||
|
from src.backend.services.vision_service import _call_worker
|
||||||
|
from src.backend.tests.conftest import auth_headers, create_test_recipe
|
||||||
|
from src.backend.tests.test_vision_execute import recipe_parts
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
STATION_URL = "http://10.0.0.7:8100"
|
||||||
|
|
||||||
|
|
||||||
|
def an_image() -> bytes:
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
Image.new("L", (64, 64), color=0).save(buffer, format="PNG")
|
||||||
|
return buffer.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def worker_calls(monkeypatch):
|
||||||
|
"""Fake `_call_worker` che ricorda i kwargs con cui e' stato chiamato."""
|
||||||
|
calls: list[dict] = []
|
||||||
|
|
||||||
|
async def fake_call(image_bytes: bytes, graph: dict, **kwargs) -> dict:
|
||||||
|
calls.append(kwargs)
|
||||||
|
return {"outputs": {"t1.diameter": 10.0}, "failures": [],
|
||||||
|
"engine_version": "e" * 40, "duration_ms": 3}
|
||||||
|
|
||||||
|
monkeypatch.setattr("src.backend.services.vision_service._call_worker", fake_call)
|
||||||
|
return calls
|
||||||
|
|
||||||
|
|
||||||
|
async def _camera_task(db_session, user_id: int):
|
||||||
|
recipe = await create_test_recipe(db_session, user_id)
|
||||||
|
version, task, subtask = await recipe_parts(db_session, recipe.id)
|
||||||
|
task.task_type = "camera_measure"
|
||||||
|
task.vision_json = {"schema_version": 1, "name": "p", "tools": []}
|
||||||
|
subtask.vision_output = "t1.diameter"
|
||||||
|
await db_session.commit()
|
||||||
|
return recipe, task
|
||||||
|
|
||||||
|
|
||||||
|
async def _station_with_run(db_session, user, recipe, worker_url):
|
||||||
|
station = Station(
|
||||||
|
code="ST-W", name="Stazione W", active=True, created_by=user.id,
|
||||||
|
vision_worker_url=worker_url,
|
||||||
|
)
|
||||||
|
db_session.add(station)
|
||||||
|
await db_session.commit()
|
||||||
|
await db_session.refresh(station)
|
||||||
|
run = await production_service.open_run(
|
||||||
|
db_session,
|
||||||
|
ProductionRunCreate(station_code=station.code, recipe_id=recipe.id),
|
||||||
|
user,
|
||||||
|
)
|
||||||
|
await db_session.commit()
|
||||||
|
return station, run
|
||||||
|
|
||||||
|
|
||||||
|
async def _execute(client, user, task, run=None):
|
||||||
|
data = {"task_id": str(task.id)}
|
||||||
|
if run is not None:
|
||||||
|
data["production_run_id"] = str(run.id)
|
||||||
|
response = await client.post(
|
||||||
|
"/api/vision/execute",
|
||||||
|
headers=auth_headers(user),
|
||||||
|
files={"image": ("p.png", an_image(), "image/png")},
|
||||||
|
data=data,
|
||||||
|
)
|
||||||
|
assert response.status_code == 200, response.text
|
||||||
|
|
||||||
|
|
||||||
|
async def _the_result(db_session) -> VisionResult:
|
||||||
|
return (await db_session.execute(select(VisionResult))).scalars().one()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_station_with_url_gets_the_call_and_the_record(
|
||||||
|
client, db_session, measurement_tec_user, worker_calls,
|
||||||
|
):
|
||||||
|
recipe, task = await _camera_task(db_session, measurement_tec_user.id)
|
||||||
|
station, run = await _station_with_run(
|
||||||
|
db_session, measurement_tec_user, recipe, STATION_URL)
|
||||||
|
|
||||||
|
await _execute(client, measurement_tec_user, task, run)
|
||||||
|
|
||||||
|
assert worker_calls[0]["worker_url"] == STATION_URL
|
||||||
|
result = await _the_result(db_session)
|
||||||
|
assert result.executed_on == "station"
|
||||||
|
assert result.worker_url == STATION_URL
|
||||||
|
assert result.station_id == station.id
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_station_without_url_uses_the_central_worker(
|
||||||
|
client, db_session, measurement_tec_user, worker_calls,
|
||||||
|
):
|
||||||
|
recipe, task = await _camera_task(db_session, measurement_tec_user.id)
|
||||||
|
station, run = await _station_with_run(
|
||||||
|
db_session, measurement_tec_user, recipe, None)
|
||||||
|
|
||||||
|
await _execute(client, measurement_tec_user, task, run)
|
||||||
|
|
||||||
|
assert worker_calls[0]["worker_url"] is None
|
||||||
|
result = await _the_result(db_session)
|
||||||
|
assert result.executed_on == "server"
|
||||||
|
assert result.worker_url == settings.vision_worker_url.rstrip("/")
|
||||||
|
assert result.station_id == station.id
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_without_a_run_the_central_worker_is_used(
|
||||||
|
client, db_session, measurement_tec_user, worker_calls,
|
||||||
|
):
|
||||||
|
_, task = await _camera_task(db_session, measurement_tec_user.id)
|
||||||
|
|
||||||
|
await _execute(client, measurement_tec_user, task)
|
||||||
|
|
||||||
|
assert worker_calls[0]["worker_url"] is None
|
||||||
|
result = await _the_result(db_session)
|
||||||
|
assert result.executed_on == "server"
|
||||||
|
assert result.worker_url == settings.vision_worker_url.rstrip("/")
|
||||||
|
assert result.station_id is None
|
||||||
|
|
||||||
|
|
||||||
|
class _Response:
|
||||||
|
status_code = 200
|
||||||
|
|
||||||
|
def json(self):
|
||||||
|
return {"outputs": {}, "failures": [], "engine_version": "e" * 40}
|
||||||
|
|
||||||
|
|
||||||
|
class _RecordingClient:
|
||||||
|
"""httpx.AsyncClient finto: registra gli URL postati, opzionalmente fallisce."""
|
||||||
|
|
||||||
|
def __init__(self, posts: list, raises=None):
|
||||||
|
self._posts = posts
|
||||||
|
self._raises = raises
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *exc_info):
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def post(self, url, *args, **kwargs):
|
||||||
|
self._posts.append(url)
|
||||||
|
if self._raises is not None:
|
||||||
|
raise self._raises
|
||||||
|
return _Response()
|
||||||
|
|
||||||
|
|
||||||
|
def _install(monkeypatch, raises=None) -> list:
|
||||||
|
posts: list = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"src.backend.services.vision_service.httpx.AsyncClient",
|
||||||
|
lambda *a, **k: _RecordingClient(posts, raises),
|
||||||
|
)
|
||||||
|
return posts
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_call_worker_posts_to_the_given_url(monkeypatch):
|
||||||
|
posts = _install(monkeypatch)
|
||||||
|
# lo "/" finale non deve produrre "//run"
|
||||||
|
await _call_worker(b"img", {"tools": []}, worker_url=STATION_URL + "/")
|
||||||
|
assert posts == [f"{STATION_URL}/run"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_call_worker_defaults_to_the_central_url(monkeypatch):
|
||||||
|
posts = _install(monkeypatch)
|
||||||
|
await _call_worker(b"img", {"tools": []})
|
||||||
|
assert posts == [f"{settings.vision_worker_url.rstrip('/')}/run"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_station_worker_down_is_502_with_its_url_and_no_fallback(monkeypatch):
|
||||||
|
posts = _install(monkeypatch, raises=httpx.ConnectError("refused"))
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
await _call_worker(b"img", {"tools": []}, worker_url=STATION_URL)
|
||||||
|
assert exc.value.status_code == 502
|
||||||
|
assert STATION_URL in exc.value.detail
|
||||||
|
# una sola POST: il centrale non viene tentato
|
||||||
|
assert posts == [f"{STATION_URL}/run"]
|
||||||
@@ -242,6 +242,9 @@ def create_app() -> Flask:
|
|||||||
"company_logo": session.get("company_logo"),
|
"company_logo": session.get("company_logo"),
|
||||||
"auto_logout_minutes": session.get("auto_logout_minutes"),
|
"auto_logout_minutes": session.get("auto_logout_minutes"),
|
||||||
"current_year": date.today().year,
|
"current_year": date.today().year,
|
||||||
|
# Versione/commit nel piede di pagina (#70)
|
||||||
|
"app_version": Config.APP_VERSION,
|
||||||
|
"app_commit": Config.APP_COMMIT,
|
||||||
}
|
}
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|||||||
@@ -138,6 +138,21 @@ def api_change_password(user_id: int):
|
|||||||
return jsonify(resp), 200
|
return jsonify(resp), 200
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route("/api/users/<int:user_id>/pin", methods=["PUT"])
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def api_set_pin(user_id: int):
|
||||||
|
"""Proxy: set (4 digits) or remove ({"pin": null}) a user's PIN (#59)."""
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
# Forward only the pin field; backend answers 422 for admins / malformed PINs.
|
||||||
|
resp = api_client.put(f"/api/users/{user_id}/pin", data={"pin": data.get("pin")})
|
||||||
|
|
||||||
|
if isinstance(resp, dict) and resp.get("error"):
|
||||||
|
return jsonify(resp), resp.get("status_code", 500)
|
||||||
|
|
||||||
|
return jsonify(resp), 200
|
||||||
|
|
||||||
|
|
||||||
@admin_bp.route("/api/users/<int:user_id>/toggle-active", methods=["POST"])
|
@admin_bp.route("/api/users/<int:user_id>/toggle-active", methods=["POST"])
|
||||||
@login_required
|
@login_required
|
||||||
@admin_required
|
@admin_required
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import requests as http_requests
|
|||||||
|
|
||||||
from flask import (
|
from flask import (
|
||||||
Blueprint, Response, abort, flash, jsonify, redirect, render_template,
|
Blueprint, Response, abort, flash, jsonify, redirect, render_template,
|
||||||
request, session, url_for,
|
g, request, session, url_for,
|
||||||
)
|
)
|
||||||
from flask_babel import gettext as _
|
from flask_babel import gettext as _
|
||||||
|
|
||||||
@@ -345,6 +345,7 @@ def task_execute(task_id: int):
|
|||||||
all_task_ids=all_task_ids,
|
all_task_ids=all_task_ids,
|
||||||
measurement_interval_minutes=measurement_interval_minutes,
|
measurement_interval_minutes=measurement_interval_minutes,
|
||||||
allow_manual_input=allow_manual_input,
|
allow_manual_input=allow_manual_input,
|
||||||
|
camera_enabled=_station_camera(_current_station()[0]) is not None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -654,6 +655,74 @@ def api_pending_authorisation():
|
|||||||
return jsonify(resp), 200
|
return jsonify(resp), 200
|
||||||
|
|
||||||
|
|
||||||
|
def _supervisor_credentials(data: dict):
|
||||||
|
"""The capoturno's credentials from the request body (#59).
|
||||||
|
|
||||||
|
Two ways in: ``supervisor_user_id`` + ``supervisor_pin`` (name and 4-digit PIN
|
||||||
|
from the touch pad) or, as before, ``supervisor_username`` +
|
||||||
|
``supervisor_password``. Returns ``(fields, None)`` with the pair to forward,
|
||||||
|
or ``(None, error_response)`` when neither pair is complete.
|
||||||
|
"""
|
||||||
|
pin = str(data.get("supervisor_pin") or "").strip()
|
||||||
|
user_id = data.get("supervisor_user_id")
|
||||||
|
if pin and user_id not in (None, ""):
|
||||||
|
try:
|
||||||
|
return {"supervisor_user_id": int(user_id), "supervisor_pin": pin}, None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass # user_id not a number: falls through to the missing-credentials answer
|
||||||
|
username = (data.get("supervisor_username") or "").strip()
|
||||||
|
password = data.get("supervisor_password") or ""
|
||||||
|
if username and password:
|
||||||
|
return {"supervisor_username": username, "supervisor_password": password}, None
|
||||||
|
return None, (jsonify({
|
||||||
|
"error": True, "detail": _("Username e password richiesti"),
|
||||||
|
}), 400)
|
||||||
|
|
||||||
|
|
||||||
|
def _post_supervised(endpoint: str, creds: dict, extra: dict | None = None):
|
||||||
|
"""POST an action carrying the capoturno's credentials, forwarding the answer.
|
||||||
|
|
||||||
|
api_client reads every 401 as "your API key is dead" and empties the Flask
|
||||||
|
session. For these endpoints a 401 can also be the capoturno's wrong PIN or
|
||||||
|
password, and that must not throw the operator out of their session (#59):
|
||||||
|
when the detail names the supervisor or the PIN, the session is put back and
|
||||||
|
the ``session_expired`` flag is dropped so the modal can say "PIN errato".
|
||||||
|
"""
|
||||||
|
saved = dict(session)
|
||||||
|
resp = api_client.post(endpoint, data={**creds, **(extra or {})})
|
||||||
|
if isinstance(resp, dict) and resp.get("error"):
|
||||||
|
detail = str(resp.get("detail") or "").lower()
|
||||||
|
if resp.get("status_code") == 401 and ("supervisor" in detail or "pin" in detail):
|
||||||
|
session.update(saved) # the 401 was about the capoturno, not about this session
|
||||||
|
g.session_expired = False
|
||||||
|
resp = {k: v for k, v in resp.items() if k != "session_expired"}
|
||||||
|
if "supervisor_pin" in creds:
|
||||||
|
resp["detail"] = _("PIN errato") # the backend's English text is not for the operator
|
||||||
|
return jsonify(resp), resp.get("status_code", 500)
|
||||||
|
return jsonify(resp), 200
|
||||||
|
|
||||||
|
|
||||||
|
@measure_bp.route("/api/pin-users", methods=["GET"])
|
||||||
|
@login_required
|
||||||
|
@role_required("MeasurementTec")
|
||||||
|
def api_pin_users():
|
||||||
|
"""Proxy: names for the capoturno's PIN pad (#59), id and display name only.
|
||||||
|
|
||||||
|
Empty list when the backend does not answer, so the modal falls back to the
|
||||||
|
password form instead of showing a pad nobody can use.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
resp = api_client.get("/api/auth/pin-users")
|
||||||
|
except Exception:
|
||||||
|
return jsonify([])
|
||||||
|
if not isinstance(resp, list):
|
||||||
|
return jsonify([])
|
||||||
|
return jsonify([
|
||||||
|
{"id": u.get("id"), "display_name": u.get("display_name", "")}
|
||||||
|
for u in resp if isinstance(u, dict)
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
@measure_bp.route("/api/measurements/<int:measurement_id>/authorise", methods=["POST"])
|
@measure_bp.route("/api/measurements/<int:measurement_id>/authorise", methods=["POST"])
|
||||||
@login_required
|
@login_required
|
||||||
@role_required("MeasurementTec")
|
@role_required("MeasurementTec")
|
||||||
@@ -665,20 +734,10 @@ def api_authorise_measurement(measurement_id: int):
|
|||||||
written down nowhere and nothing depending on it.
|
written down nowhere and nothing depending on it.
|
||||||
"""
|
"""
|
||||||
data = request.get_json(silent=True) or {}
|
data = request.get_json(silent=True) or {}
|
||||||
username = (data.get("supervisor_username") or "").strip()
|
creds, error = _supervisor_credentials(data) # #59: PIN or password
|
||||||
password = data.get("supervisor_password") or ""
|
if error:
|
||||||
if not username or not password:
|
return error
|
||||||
return jsonify({
|
return _post_supervised(f"/api/measurements/{measurement_id}/authorise", creds)
|
||||||
"error": True, "detail": _("Username e password richiesti"),
|
|
||||||
}), 400
|
|
||||||
|
|
||||||
resp = api_client.post(
|
|
||||||
f"/api/measurements/{measurement_id}/authorise",
|
|
||||||
data={"supervisor_username": username, "supervisor_password": password},
|
|
||||||
)
|
|
||||||
if isinstance(resp, dict) and resp.get("error"):
|
|
||||||
return jsonify(resp), resp.get("status_code", 500)
|
|
||||||
return jsonify(resp), 200
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -770,24 +829,12 @@ def _supervised_action(run_id: int, action: str):
|
|||||||
leave no such record, and is an extra round trip besides.
|
leave no such record, and is an extra round trip besides.
|
||||||
"""
|
"""
|
||||||
data = request.get_json(silent=True) or {}
|
data = request.get_json(silent=True) or {}
|
||||||
username = (data.get("supervisor_username") or "").strip()
|
creds, error = _supervisor_credentials(data) # #59: PIN or password
|
||||||
password = data.get("supervisor_password") or ""
|
if error:
|
||||||
if not username or not password:
|
return error
|
||||||
return jsonify({
|
return _post_supervised(
|
||||||
"error": True, "detail": _("Username e password richiesti"),
|
f"/api/production-runs/{run_id}/{action}", creds, {"note": data.get("note")},
|
||||||
}), 400
|
|
||||||
|
|
||||||
resp = api_client.post(
|
|
||||||
f"/api/production-runs/{run_id}/{action}",
|
|
||||||
data={
|
|
||||||
"supervisor_username": username,
|
|
||||||
"supervisor_password": password,
|
|
||||||
"note": data.get("note"),
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
if isinstance(resp, dict) and resp.get("error"):
|
|
||||||
return jsonify(resp), resp.get("status_code", 500)
|
|
||||||
return jsonify(resp), 200
|
|
||||||
|
|
||||||
|
|
||||||
@measure_bp.route("/api/production/<int:run_id>/pause", methods=["POST"])
|
@measure_bp.route("/api/production/<int:run_id>/pause", methods=["POST"])
|
||||||
@@ -814,6 +861,91 @@ def api_close_production(run_id: int):
|
|||||||
return _supervised_action(run_id, "close")
|
return _supervised_action(run_id, "close")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Routes: task results (#53 #54 #55) - outcome of a "check" task and the values /
|
||||||
|
# attachments of a "note" task with fields. The browser cannot send X-API-Key,
|
||||||
|
# so each call goes through here.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def _relay(resp, ok_status: int = 200):
|
||||||
|
"""JSON back to the browser: backend errors keep their status and detail.
|
||||||
|
|
||||||
|
The detail is what the operator reads («Per un NOK serve la nota»), so it is
|
||||||
|
passed through as it came, never replaced by a generic message.
|
||||||
|
"""
|
||||||
|
if isinstance(resp, dict) and resp.get("error"):
|
||||||
|
body = {"error": True, "detail": resp.get("detail", _("Errore nel salvataggio"))}
|
||||||
|
if resp.get("session_expired"):
|
||||||
|
body["session_expired"] = True
|
||||||
|
status_code = resp.get("status_code", 500)
|
||||||
|
return jsonify(body), status_code if status_code >= 400 else 500
|
||||||
|
return jsonify(resp), ok_status
|
||||||
|
|
||||||
|
|
||||||
|
@measure_bp.route("/api/tasks/<int:task_id>", methods=["GET"])
|
||||||
|
@login_required
|
||||||
|
@role_required("MeasurementTec")
|
||||||
|
def api_get_task(task_id: int):
|
||||||
|
"""Proxy: a task with its fields (#55: the tasks to fill at the end of production)."""
|
||||||
|
return _relay(api_client.get(f"/api/tasks/{task_id}"))
|
||||||
|
|
||||||
|
|
||||||
|
@measure_bp.route("/api/task-results", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
@role_required("MeasurementTec")
|
||||||
|
def api_save_task_result():
|
||||||
|
"""Proxy: record the result of a check / note task (#54, #55)."""
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
if data.get("task_id") is None:
|
||||||
|
return jsonify({
|
||||||
|
"error": True, "detail": _("Dati mancanti: task_id è obbligatorio"),
|
||||||
|
}), 400
|
||||||
|
payload = {
|
||||||
|
"task_id": data.get("task_id"),
|
||||||
|
"production_run_id": data.get("production_run_id"),
|
||||||
|
"outcome": data.get("outcome"),
|
||||||
|
"note": data.get("note"),
|
||||||
|
"values": data.get("values"),
|
||||||
|
"attachments": data.get("attachments"),
|
||||||
|
}
|
||||||
|
return _relay(api_client.post("/api/task-results", data=payload), 201)
|
||||||
|
|
||||||
|
|
||||||
|
@measure_bp.route("/api/task-results", methods=["GET"])
|
||||||
|
@login_required
|
||||||
|
@role_required("MeasurementTec")
|
||||||
|
def api_list_task_results():
|
||||||
|
"""Proxy: results already recorded, filtered by production run and/or task."""
|
||||||
|
params = {
|
||||||
|
key: request.args[key]
|
||||||
|
for key in ("production_run_id", "task_id")
|
||||||
|
if request.args.get(key)
|
||||||
|
}
|
||||||
|
return _relay(api_client.get("/api/task-results", params=params))
|
||||||
|
|
||||||
|
|
||||||
|
@measure_bp.route("/api/attachments", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
@role_required("MeasurementTec")
|
||||||
|
def api_upload_attachment():
|
||||||
|
"""Proxy: upload an attachment of a note task (image or PDF), multipart.
|
||||||
|
|
||||||
|
Answers with the backend's reply: the `file_path` in it is what goes into the
|
||||||
|
`attachments` of the result.
|
||||||
|
"""
|
||||||
|
upload = request.files.get("file")
|
||||||
|
if upload is None or not upload.filename:
|
||||||
|
return jsonify({"error": True, "detail": _("Nessun file caricato")}), 400
|
||||||
|
resp = api_client.post(
|
||||||
|
"/api/task-results/attachments", # #55: upload per l operatore (files/upload e solo Maker)
|
||||||
|
files={"file": (
|
||||||
|
upload.filename,
|
||||||
|
upload.read(),
|
||||||
|
upload.mimetype or "application/octet-stream",
|
||||||
|
)},
|
||||||
|
)
|
||||||
|
return _relay(resp, 201)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Route: File proxy (browser can't send X-API-Key directly)
|
# Route: File proxy (browser can't send X-API-Key directly)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -869,6 +1001,64 @@ def api_vision_execute():
|
|||||||
return jsonify(resp), 200
|
return jsonify(resp), 200
|
||||||
|
|
||||||
|
|
||||||
|
def _station_camera(station_code: str | None) -> tuple[str, str] | None:
|
||||||
|
"""(camera_url, camera_source) of the station, None when it has no camera.
|
||||||
|
|
||||||
|
A station that cannot be read counts as "no camera": the grab button simply
|
||||||
|
does not show, the file input keeps working.
|
||||||
|
"""
|
||||||
|
if not station_code:
|
||||||
|
return None
|
||||||
|
station = api_client.get_station_by_code(station_code)
|
||||||
|
if not isinstance(station, dict) or station.get("error"):
|
||||||
|
return None
|
||||||
|
url = station.get("camera_url")
|
||||||
|
source = station.get("camera_source")
|
||||||
|
if not url or not source:
|
||||||
|
return None
|
||||||
|
return str(url), str(source)
|
||||||
|
|
||||||
|
|
||||||
|
# #72: the browser asks the station's camera service (vs-gui) for a frame; the
|
||||||
|
# service address is a server-side matter, so Flask relays and the PNG goes back as is.
|
||||||
|
@measure_bp.route("/api/vision/grab", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
@role_required("MeasurementTec")
|
||||||
|
def api_vision_grab():
|
||||||
|
"""Proxy: grab one frame from the current station's camera, answer a PNG."""
|
||||||
|
camera = _station_camera(_current_station()[0])
|
||||||
|
if camera is None:
|
||||||
|
return jsonify({
|
||||||
|
"error": True,
|
||||||
|
"detail": _("Camera non configurata per questa stazione"),
|
||||||
|
}), 409
|
||||||
|
camera_url, camera_source = camera
|
||||||
|
try:
|
||||||
|
resp = http_requests.post(
|
||||||
|
f"{camera_url.rstrip('/')}/camera/grab",
|
||||||
|
json={"source": camera_source, "trigger": "continuo", "timeout_ms": 5000},
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
except http_requests.RequestException as exc:
|
||||||
|
return jsonify({
|
||||||
|
"error": True,
|
||||||
|
"detail": _("Servizio camera non raggiungibile: %(detail)s", detail=str(exc)),
|
||||||
|
}), 502
|
||||||
|
if resp.status_code != 200:
|
||||||
|
detail = ""
|
||||||
|
try:
|
||||||
|
body = resp.json()
|
||||||
|
detail = body.get("detail", "") if isinstance(body, dict) else ""
|
||||||
|
except ValueError:
|
||||||
|
detail = (resp.text or "")[:200]
|
||||||
|
return jsonify({
|
||||||
|
"error": True,
|
||||||
|
"detail": _("Acquisizione non riuscita (%(status)s): %(detail)s",
|
||||||
|
status=resp.status_code, detail=detail),
|
||||||
|
}), 502
|
||||||
|
return Response(resp.content, content_type="image/png")
|
||||||
|
|
||||||
|
|
||||||
@measure_bp.route("/api/vision/results/<int:result_id>/image", methods=["GET"])
|
@measure_bp.route("/api/vision/results/<int:result_id>/image", methods=["GET"])
|
||||||
@login_required
|
@login_required
|
||||||
@role_required("MeasurementTec")
|
@role_required("MeasurementTec")
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
"""Metrologist blueprint - SPC statistics dashboard and API proxies."""
|
"""Metrologist blueprint - SPC statistics dashboard and API proxies."""
|
||||||
import requests as http_requests
|
import requests as http_requests
|
||||||
|
|
||||||
from flask import Blueprint, jsonify, render_template, request, session
|
from flask import Blueprint, abort, jsonify, render_template, request, session
|
||||||
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 # #58: allegati dei task
|
||||||
|
|
||||||
statistics_bp = Blueprint("statistics", __name__)
|
statistics_bp = Blueprint("statistics", __name__)
|
||||||
|
|
||||||
@@ -148,6 +149,33 @@ def api_runs():
|
|||||||
return jsonify(resp)
|
return jsonify(resp)
|
||||||
|
|
||||||
|
|
||||||
|
@statistics_bp.route("/api/runs/<int:run_id>/task-results")
|
||||||
|
@login_required
|
||||||
|
@role_required("Metrologist")
|
||||||
|
def api_run_task_results(run_id: int):
|
||||||
|
"""Proxy: esiti, note, campi e allegati dei task di una sessione (#58)."""
|
||||||
|
resp = api_client.get(f"/api/statistics/runs/{run_id}/task-results")
|
||||||
|
|
||||||
|
if isinstance(resp, dict) and resp.get("error"):
|
||||||
|
error_body = {"error": True, "detail": resp.get("detail", "")}
|
||||||
|
if resp.get("session_expired"):
|
||||||
|
error_body["session_expired"] = True
|
||||||
|
return jsonify(error_body), resp.get("status_code", 500)
|
||||||
|
|
||||||
|
return jsonify(resp)
|
||||||
|
|
||||||
|
|
||||||
|
@statistics_bp.route("/api/files/<path:file_path>")
|
||||||
|
@login_required
|
||||||
|
@role_required("Metrologist")
|
||||||
|
def api_task_result_file(file_path: str):
|
||||||
|
"""Proxy: allegati dei task (#58). Solo task_results/, mai altri file."""
|
||||||
|
# #58: il Metrologist non deve poter leggere altro dell'upload dir (es. statistics/).
|
||||||
|
if not file_path.startswith("task_results/") or ".." in file_path.split("/"):
|
||||||
|
abort(404)
|
||||||
|
return proxy_file(file_path)
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# PROXY REPORT PDF → FastAPI
|
# PROXY REPORT PDF → FastAPI
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
"""TieMeasureFlow Client Configuration."""
|
"""TieMeasureFlow Client Configuration."""
|
||||||
import os
|
import os
|
||||||
|
import tomllib
|
||||||
|
from pathlib import Path
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
load_dotenv(os.path.join(os.path.dirname(__file__), '..', '.env'))
|
load_dotenv(os.path.join(os.path.dirname(__file__), '..', '.env'))
|
||||||
@@ -10,6 +12,25 @@ load_dotenv(os.path.join(os.path.dirname(__file__), '..', '.env'))
|
|||||||
DEFAULT_SECRET_KEY = "change-this-to-another-random-secret-key"
|
DEFAULT_SECRET_KEY = "change-this-to-another-random-secret-key"
|
||||||
|
|
||||||
|
|
||||||
|
def _read_app_version() -> str:
|
||||||
|
"""Versione del prodotto = `[project] version` di pyproject.toml (#70).
|
||||||
|
|
||||||
|
Si risale dalla cartella del modulo: nel container il file sta in
|
||||||
|
/app/pyproject.toml (Dockerfile.frontend), nel repo alla radice. Se non
|
||||||
|
si trova o e' illeggibile torna "dev": il piede di pagina non deve mai
|
||||||
|
rompere l'avvio.
|
||||||
|
"""
|
||||||
|
for parent in Path(__file__).resolve().parents:
|
||||||
|
candidate = parent / "pyproject.toml"
|
||||||
|
if candidate.is_file():
|
||||||
|
try:
|
||||||
|
with candidate.open("rb") as fh:
|
||||||
|
return str(tomllib.load(fh)["project"]["version"])
|
||||||
|
except (OSError, tomllib.TOMLDecodeError, KeyError):
|
||||||
|
return "dev"
|
||||||
|
return "dev"
|
||||||
|
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
"""Flask client configuration."""
|
"""Flask client configuration."""
|
||||||
|
|
||||||
@@ -17,6 +38,12 @@ class Config:
|
|||||||
SECRET_KEY = os.getenv("CLIENT_SECRET_KEY", DEFAULT_SECRET_KEY)
|
SECRET_KEY = os.getenv("CLIENT_SECRET_KEY", DEFAULT_SECRET_KEY)
|
||||||
DEBUG = os.getenv("FLASK_DEBUG", "0") == "1"
|
DEBUG = os.getenv("FLASK_DEBUG", "0") == "1"
|
||||||
|
|
||||||
|
# Versione e commit mostrati nel piede di pagina (#70). Letti una volta
|
||||||
|
# all'import. Il commit arriva dal build arg APP_COMMIT (.git non e' nel
|
||||||
|
# contesto di build, vedi .dockerignore); facoltativo: vuoto = non mostrato.
|
||||||
|
APP_VERSION: str = _read_app_version()
|
||||||
|
APP_COMMIT: str = os.environ.get("APP_COMMIT", "")[:7]
|
||||||
|
|
||||||
# API Server connection
|
# API Server connection
|
||||||
API_SERVER_URL = os.getenv("API_SERVER_URL", "http://localhost:8000")
|
API_SERVER_URL = os.getenv("API_SERVER_URL", "http://localhost:8000")
|
||||||
|
|
||||||
|
|||||||
@@ -193,5 +193,9 @@ class APIClient:
|
|||||||
"""Return the list of active recipes assigned to the given station."""
|
"""Return the list of active recipes assigned to the given station."""
|
||||||
return self.get(f"/api/stations/by-code/{station_code}/recipes")
|
return self.get(f"/api/stations/by-code/{station_code}/recipes")
|
||||||
|
|
||||||
|
def get_station_by_code(self, station_code: str) -> dict[str, Any]:
|
||||||
|
"""Return the station with the given code (camera_url / camera_source included)."""
|
||||||
|
return self.get(f"/api/stations/by-code/{station_code}")
|
||||||
|
|
||||||
|
|
||||||
api_client = APIClient()
|
api_client = APIClient()
|
||||||
|
|||||||
@@ -0,0 +1,272 @@
|
|||||||
|
/**
|
||||||
|
* Task results (#53 #54 #55) - Alpine component for the tasks that record a result.
|
||||||
|
*
|
||||||
|
* - «check» task (#54): the operator answers OK or NOK, with a note (mandatory for NOK).
|
||||||
|
* - «note» task with `fields` (#55): one input per field, an optional attachment
|
||||||
|
* (image / PDF) for the fields that ask for it, outcome 'done'.
|
||||||
|
*
|
||||||
|
* The same component serves the task being executed (one form) and the panel shown
|
||||||
|
* at the end of production (one form per at_close task), so the field rules live here
|
||||||
|
* once. The page sets `window.__taskResultsCfg` (urls + labels) and the component's
|
||||||
|
* `ctx` (production run id + callbacks) from its x-init, the way visionExecute() does.
|
||||||
|
*
|
||||||
|
* Plain functions only (no framework imports) so tests/js/test_task_results.js can
|
||||||
|
* run them under node with a stubbed fetch.
|
||||||
|
*/
|
||||||
|
function taskResultForm() {
|
||||||
|
return {
|
||||||
|
// Set by the page's x-init: () => ({ runId: int|null, done: () => void })
|
||||||
|
ctx: null,
|
||||||
|
forms: [],
|
||||||
|
working: false,
|
||||||
|
error: '',
|
||||||
|
// true once a result was recorded during this page view (shows «Prosegui»)
|
||||||
|
justRecorded: false,
|
||||||
|
// Panel open state (used by the end-of-production panel only)
|
||||||
|
open: false,
|
||||||
|
|
||||||
|
cfg() { return window.__taskResultsCfg || { labels: {} }; },
|
||||||
|
label(key, fallback) {
|
||||||
|
return (this.cfg().labels && this.cfg().labels[key]) || fallback;
|
||||||
|
},
|
||||||
|
runId() {
|
||||||
|
const c = this.ctx ? this.ctx() : null;
|
||||||
|
return c && c.runId ? c.runId : null;
|
||||||
|
},
|
||||||
|
|
||||||
|
/* One form per task: values start from the field's default, the way the maker set it. */
|
||||||
|
makeForm(task) {
|
||||||
|
const fields = Array.isArray(task.fields) ? task.fields : [];
|
||||||
|
const values = {};
|
||||||
|
fields.forEach((f) => { values[f.key] = (f.default === null || f.default === undefined) ? '' : String(f.default); });
|
||||||
|
return {
|
||||||
|
task_id: task.id,
|
||||||
|
title: task.title || '',
|
||||||
|
directive: task.directive || '',
|
||||||
|
type: task.task_type,
|
||||||
|
fields: fields,
|
||||||
|
values: values,
|
||||||
|
files: {},
|
||||||
|
fileNames: {},
|
||||||
|
note: '',
|
||||||
|
outcome: '',
|
||||||
|
error: '',
|
||||||
|
result: null,
|
||||||
|
recorded: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
/* The single form of the task on screen. */
|
||||||
|
initForTask(task) {
|
||||||
|
if (!task) return;
|
||||||
|
const isCheck = task.task_type === 'check';
|
||||||
|
const isNoteWithFields = task.task_type === 'note' && Array.isArray(task.fields) && task.fields.length > 0;
|
||||||
|
if (!isCheck && !isNoteWithFields) return;
|
||||||
|
this.forms = [this.makeForm(task)];
|
||||||
|
this.loadLast();
|
||||||
|
},
|
||||||
|
|
||||||
|
pickFile(form, key, ev) {
|
||||||
|
const f = ev && ev.target && ev.target.files ? ev.target.files[0] : null;
|
||||||
|
if (f) { form.files[key] = f; form.fileNames[key] = f.name; }
|
||||||
|
else { delete form.files[key]; form.fileNames[key] = ''; }
|
||||||
|
},
|
||||||
|
|
||||||
|
csrf() {
|
||||||
|
const m = typeof document !== 'undefined' ? document.querySelector('meta[name=csrf-token]') : null;
|
||||||
|
return (m && m.content) || '';
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Fetch + JSON with the page's usual error reading: the detail of the server is
|
||||||
|
what the operator sees; an HTML answer (expired CSRF / session) means «reload». */
|
||||||
|
async call(url, opts) {
|
||||||
|
let resp;
|
||||||
|
try {
|
||||||
|
resp = await fetch(url, opts);
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, detail: this.label('connection', 'Errore di connessione') };
|
||||||
|
}
|
||||||
|
const data = await resp.json().catch(() => null);
|
||||||
|
if (!data || data.csrf_expired || data.session_expired) {
|
||||||
|
return { ok: false, detail: this.label('sessionExpired', 'Sessione scaduta: ricaricare la pagina') };
|
||||||
|
}
|
||||||
|
if (!resp.ok) {
|
||||||
|
return { ok: false, detail: data.detail || this.label('serverError', 'Errore di comunicazione con il server') };
|
||||||
|
}
|
||||||
|
return { ok: true, data: data };
|
||||||
|
},
|
||||||
|
|
||||||
|
/* The last result of this task: in the run under way if there is one, otherwise
|
||||||
|
the one given in this browser session (no run = nothing on the server to ask).
|
||||||
|
Only shown: the buttons stay active, a loop gives a new result every cycle. */
|
||||||
|
async loadLast() {
|
||||||
|
const form = this.forms[0];
|
||||||
|
if (!form) return;
|
||||||
|
const rid = this.runId();
|
||||||
|
if (rid && this.cfg().listUrl) {
|
||||||
|
const r = await this.call(
|
||||||
|
this.cfg().listUrl + '?production_run_id=' + encodeURIComponent(rid) +
|
||||||
|
'&task_id=' + encodeURIComponent(form.task_id));
|
||||||
|
if (r.ok && Array.isArray(r.data) && r.data.length) {
|
||||||
|
form.result = r.data[r.data.length - 1];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const raw = window.sessionStorage.getItem('tmf-task-result-' + form.task_id);
|
||||||
|
if (raw && !rid) form.result = JSON.parse(raw);
|
||||||
|
} catch (e) { /* storage may be unavailable: the result is simply not shown */ }
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Validation before anything goes on the wire. Returns the message or ''. */
|
||||||
|
validate(form, outcome) {
|
||||||
|
if (form.type === 'check') {
|
||||||
|
if (outcome === 'nok' && !(form.note || '').trim()) {
|
||||||
|
return this.label('nokNeedsNote', 'Per un NOK serve la nota');
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
for (const f of form.fields) {
|
||||||
|
const v = form.values[f.key];
|
||||||
|
if (f.type === 'number' && v !== '' && v !== null && v !== undefined &&
|
||||||
|
!isFinite(Number(String(v).replace(',', '.')))) {
|
||||||
|
return this.label('notANumber', 'Valore non numerico') + ': ' + f.label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Upload the attachments of a form, then POST the result.
|
||||||
|
Returns true when the result is recorded (forms already recorded are skipped). */
|
||||||
|
async record(form, outcome) {
|
||||||
|
if (form.recorded) return true;
|
||||||
|
form.error = this.validate(form, outcome);
|
||||||
|
if (form.error) return false;
|
||||||
|
|
||||||
|
const attachments = [];
|
||||||
|
for (const f of form.fields) {
|
||||||
|
const file = form.files[f.key];
|
||||||
|
if (!f.attachment || !file) continue;
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('file', file, file.name);
|
||||||
|
const up = await this.call(this.cfg().uploadUrl, {
|
||||||
|
method: 'POST', headers: { 'X-CSRFToken': this.csrf() }, body: fd,
|
||||||
|
});
|
||||||
|
if (!up.ok) { form.error = up.detail; return false; }
|
||||||
|
// The backend answers {file_path, ...}: that path is what the result keeps.
|
||||||
|
attachments.push({ key: f.key, path: up.data.file_path || up.data.path, name: file.name });
|
||||||
|
}
|
||||||
|
|
||||||
|
let values = null;
|
||||||
|
if (form.type === 'note' && form.fields.length) {
|
||||||
|
values = {};
|
||||||
|
form.fields.forEach((f) => {
|
||||||
|
const v = form.values[f.key];
|
||||||
|
if (v === '' || v === null || v === undefined) return;
|
||||||
|
values[f.key] = f.type === 'number' ? String(v).replace(',', '.') : String(v);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
task_id: form.task_id,
|
||||||
|
production_run_id: this.runId(),
|
||||||
|
outcome: form.type === 'check' ? outcome : 'done',
|
||||||
|
note: (form.note || '').trim() || null,
|
||||||
|
values: values,
|
||||||
|
attachments: attachments.length ? attachments : null,
|
||||||
|
};
|
||||||
|
const r = await this.call(this.cfg().saveUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrf() },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!r.ok) { form.error = r.detail; return false; }
|
||||||
|
form.result = r.data;
|
||||||
|
form.recorded = true;
|
||||||
|
try {
|
||||||
|
window.sessionStorage.setItem('tmf-task-result-' + form.task_id, JSON.stringify(r.data));
|
||||||
|
} catch (e) { /* storage may be unavailable */ }
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Task on screen: OK / NOK button, or «Completato» of a note with fields. */
|
||||||
|
async submit(outcome) {
|
||||||
|
const form = this.forms[0];
|
||||||
|
if (!form || this.working) return;
|
||||||
|
this.working = true;
|
||||||
|
this.error = '';
|
||||||
|
const ok = await this.record(form, outcome);
|
||||||
|
this.working = false;
|
||||||
|
if (!ok) return;
|
||||||
|
this.justRecorded = true;
|
||||||
|
this.proceed();
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Hand over to the page: close the cycle / go on. Also bound to «Prosegui», for
|
||||||
|
when that step failed after the result was already recorded. */
|
||||||
|
proceed() {
|
||||||
|
const c = this.ctx ? this.ctx() : null;
|
||||||
|
if (c && c.done) c.done();
|
||||||
|
},
|
||||||
|
|
||||||
|
/* The result as the operator reads it. */
|
||||||
|
resultLabel(result) {
|
||||||
|
if (!result) return '';
|
||||||
|
if (result.outcome === 'ok') return this.label('outcomeOk', 'OK');
|
||||||
|
if (result.outcome === 'nok') return this.label('outcomeNok', 'NOK');
|
||||||
|
return this.label('outcomeDone', 'Registrato');
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---- End of production panel (#55) ----
|
||||||
|
|
||||||
|
/* Open the panel for the at_close tasks of the run. A task that already has a
|
||||||
|
result in this run (page reloaded in between) is not asked again. */
|
||||||
|
async openClose(ids) {
|
||||||
|
this.error = '';
|
||||||
|
this.working = true;
|
||||||
|
this.open = true;
|
||||||
|
this.forms = [];
|
||||||
|
const rid = this.runId();
|
||||||
|
let done = [];
|
||||||
|
if (rid && this.cfg().listUrl) {
|
||||||
|
const r = await this.call(this.cfg().listUrl + '?production_run_id=' + encodeURIComponent(rid));
|
||||||
|
if (r.ok && Array.isArray(r.data)) done = r.data.map((x) => x.task_id);
|
||||||
|
}
|
||||||
|
const forms = [];
|
||||||
|
for (const id of ids) {
|
||||||
|
const r = await this.call(this.cfg().taskUrlTpl.replace('/0', '/' + id));
|
||||||
|
if (!r.ok) { this.error = r.detail; this.working = false; return; }
|
||||||
|
const form = this.makeForm(r.data);
|
||||||
|
if (done.indexOf(id) !== -1) form.recorded = true;
|
||||||
|
forms.push(form);
|
||||||
|
}
|
||||||
|
this.forms = forms;
|
||||||
|
this.working = false;
|
||||||
|
// Nothing left to ask: straight on to the capoturno.
|
||||||
|
if (forms.every((f) => f.recorded)) this.finishClose();
|
||||||
|
},
|
||||||
|
|
||||||
|
cancelClose() { this.open = false; this.forms = []; this.error = ''; },
|
||||||
|
|
||||||
|
async submitClose() {
|
||||||
|
if (this.working) return;
|
||||||
|
this.working = true;
|
||||||
|
this.error = '';
|
||||||
|
let allOk = true;
|
||||||
|
for (const form of this.forms) {
|
||||||
|
// Keep going after a failure: each form shows its own error, and the ones
|
||||||
|
// already recorded are not sent again on the next try.
|
||||||
|
if (!(await this.record(form, 'done'))) allOk = false;
|
||||||
|
}
|
||||||
|
this.working = false;
|
||||||
|
if (allOk) this.finishClose();
|
||||||
|
},
|
||||||
|
|
||||||
|
finishClose() {
|
||||||
|
this.open = false;
|
||||||
|
this.proceed();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof module !== 'undefined') module.exports = { taskResultForm };
|
||||||
@@ -18,6 +18,7 @@ function visionExecute() {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
busy: false,
|
busy: false,
|
||||||
|
grabbing: false, // frame requested from the station camera (#72)
|
||||||
error: '',
|
error: '',
|
||||||
result: null, // last execute response
|
result: null, // last execute response
|
||||||
imageSrc: '', // object URL of the acquired image (shown under the overlay)
|
imageSrc: '', // object URL of the acquired image (shown under the overlay)
|
||||||
@@ -61,6 +62,34 @@ function visionExecute() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Station camera (#72): the Flask proxy answers a PNG, which goes through
|
||||||
|
// acquire() like a picked file. Errors show as the execute ones do.
|
||||||
|
async grab() {
|
||||||
|
const cfg = window.__visionCfg || {};
|
||||||
|
if (this.busy || this.grabbing || !cfg.grabUrl) return;
|
||||||
|
this.grabbing = true;
|
||||||
|
this.error = '';
|
||||||
|
let blob = null;
|
||||||
|
try {
|
||||||
|
const csrf = document.querySelector('meta[name=csrf-token]')?.content || '';
|
||||||
|
const resp = await fetch(cfg.grabUrl, {
|
||||||
|
method: 'POST', headers: { 'X-CSRFToken': csrf },
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const body = await resp.json().catch(() => ({}));
|
||||||
|
this.error = body.detail || (cfg.labels && cfg.labels.requestFailed) || 'Error';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
blob = await resp.blob();
|
||||||
|
} catch (e) {
|
||||||
|
this.error = (cfg.labels && cfg.labels.requestFailed) || 'Error';
|
||||||
|
return;
|
||||||
|
} finally {
|
||||||
|
this.grabbing = false;
|
||||||
|
}
|
||||||
|
if (blob) await this.acquire(blob);
|
||||||
|
},
|
||||||
|
|
||||||
// Input change: take the file and allow picking the same one again.
|
// Input change: take the file and allow picking the same one again.
|
||||||
onPick(ev) {
|
onPick(ev) {
|
||||||
const f = ev.target.files && ev.target.files[0];
|
const f = ev.target.files && ev.target.files[0];
|
||||||
|
|||||||
@@ -55,6 +55,10 @@
|
|||||||
<template x-if="user.is_admin">
|
<template x-if="user.is_admin">
|
||||||
<span class="ml-1.5 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300">Admin</span>
|
<span class="ml-1.5 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300">Admin</span>
|
||||||
</template>
|
</template>
|
||||||
|
<!-- #59: indicatore PIN impostato (solo se il backend manda has_pin) -->
|
||||||
|
<template x-if="!user.is_admin && user.has_pin === true">
|
||||||
|
<span class="ml-1.5 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold bg-sky-100 dark:bg-sky-900/30 text-sky-700 dark:text-sky-300">{{ _('PIN impostato') }}</span>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -97,6 +101,10 @@
|
|||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"/>
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
<!-- #59: imposta/reset PIN (non per gli admin: il backend risponde 422) -->
|
||||||
|
<template x-if="!user.is_admin">
|
||||||
|
<button @click="openPin(user)" class="px-1.5 py-1 rounded-lg text-[11px] font-semibold text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20 transition-colors" :title="user.has_pin ? {{ _('Reset PIN')|tojson_attr }} : {{ _('Imposta PIN')|tojson_attr }}">PIN</button>
|
||||||
|
</template>
|
||||||
<button @click="toggleActive(user)" class="p-1.5 rounded-lg transition-colors"
|
<button @click="toggleActive(user)" class="p-1.5 rounded-lg transition-colors"
|
||||||
:class="user.active
|
:class="user.active
|
||||||
? 'text-[var(--text-secondary)] hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20'
|
? 'text-[var(--text-secondary)] hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20'
|
||||||
@@ -210,6 +218,20 @@
|
|||||||
minlength="6">
|
minlength="6">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- #59: PIN facoltativo, solo in creazione di un non admin -->
|
||||||
|
<template x-if="!isEditing && !form.is_admin">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
||||||
|
{{ _('PIN') }}
|
||||||
|
<span class="font-normal text-[var(--text-secondary)]"> ({{ _('facoltativo, 4 cifre') }})</span>
|
||||||
|
</label>
|
||||||
|
<input type="text" x-model="form.pin" inputmode="numeric" maxlength="4" autocomplete="off"
|
||||||
|
class="w-full px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)]
|
||||||
|
text-sm font-mono text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors"
|
||||||
|
placeholder="1234">
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- Roles -->
|
<!-- Roles -->
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-[var(--text-primary)] mb-2">{{ _('Ruoli') }}</label>
|
<label class="block text-sm font-medium text-[var(--text-primary)] mb-2">{{ _('Ruoli') }}</label>
|
||||||
@@ -374,4 +396,59 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- #59: Modal imposta / reset / togli PIN (il PIN attuale non si mostra mai) -->
|
||||||
|
<div x-show="showPin" x-cloak
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||||
|
@keydown.escape.window="closePin()">
|
||||||
|
<div class="absolute inset-0 bg-black/50" @click="closePin()"></div>
|
||||||
|
<div class="relative bg-[var(--bg-card)] rounded-xl border border-[var(--border-color)] shadow-xl w-full max-w-md p-6">
|
||||||
|
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-2">
|
||||||
|
<span x-text="pinUser?.has_pin ? {{ _('Reset PIN')|tojson_attr }} : {{ _('Imposta PIN')|tojson_attr }}"></span>: <span x-text="pinUser?.username"></span>
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm text-[var(--text-secondary)] mb-4">
|
||||||
|
{{ _('Il PIN serve per accedere alla postazione e, per il capoturno, per autorizzare le azioni.') }}
|
||||||
|
</p>
|
||||||
|
<template x-if="!pinDone">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Nuovo PIN (4 cifre)') }}</label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input type="text" x-model="pinValue" inputmode="numeric" maxlength="4" autocomplete="off"
|
||||||
|
class="flex-1 px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)] text-sm font-mono text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary">
|
||||||
|
<button @click="generatePin()" type="button"
|
||||||
|
class="px-3 py-2 text-sm font-medium rounded-lg border border-[var(--border-color)] text-[var(--text-primary)] hover:bg-[var(--bg-secondary)]">
|
||||||
|
{{ _('Genera') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<template x-if="pinError">
|
||||||
|
<p class="mt-2 text-sm text-red-600" x-text="pinError"></p>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template x-if="pinDone">
|
||||||
|
<div class="px-3 py-2 rounded-lg bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-200 dark:border-emerald-800">
|
||||||
|
<p class="text-sm text-emerald-700 dark:text-emerald-300" x-text="pinRemoved ? {{ _('PIN tolto.')|tojson_attr }} : {{ _('PIN impostato. Annotalo ora: non verrà mostrato di nuovo.')|tojson_attr }}"></p>
|
||||||
|
<p x-show="!pinRemoved" class="mt-1 text-base font-mono font-semibold text-[var(--text-primary)] select-all" x-text="pinValue"></p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div class="flex items-center justify-between gap-3 mt-5">
|
||||||
|
<div>
|
||||||
|
<button x-show="!pinDone && pinUser?.has_pin" @click="removePin()" :disabled="saving"
|
||||||
|
class="px-4 py-2 text-sm font-medium text-red-600 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 disabled:opacity-50">
|
||||||
|
{{ _('Togli PIN') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<button @click="closePin()"
|
||||||
|
class="px-4 py-2 text-sm font-medium text-[var(--text-secondary)] rounded-lg hover:bg-[var(--bg-secondary)]">
|
||||||
|
<span x-text="pinDone ? {{ _('Chiudi')|tojson_attr }} : {{ _('Annulla')|tojson_attr }}"></span>
|
||||||
|
</button>
|
||||||
|
<button x-show="!pinDone" @click="confirmPin()" :disabled="saving"
|
||||||
|
class="px-4 py-2 bg-primary text-white text-sm font-medium rounded-lg hover:bg-primary-700 shadow-sm disabled:opacity-50">
|
||||||
|
{{ _('Imposta PIN') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,6 +11,13 @@ function userManagement(initialUsers) {
|
|||||||
resetPw: '',
|
resetPw: '',
|
||||||
resetDone: false,
|
resetDone: false,
|
||||||
resetError: '',
|
resetError: '',
|
||||||
|
// #59: stato del modal PIN
|
||||||
|
showPin: false,
|
||||||
|
pinUser: null,
|
||||||
|
pinValue: '',
|
||||||
|
pinDone: false,
|
||||||
|
pinRemoved: false,
|
||||||
|
pinError: '',
|
||||||
isEditing: false,
|
isEditing: false,
|
||||||
editingUserId: null,
|
editingUserId: null,
|
||||||
toggleUser: null,
|
toggleUser: null,
|
||||||
@@ -21,6 +28,7 @@ function userManagement(initialUsers) {
|
|||||||
display_name: '',
|
display_name: '',
|
||||||
email: '',
|
email: '',
|
||||||
password: '',
|
password: '',
|
||||||
|
pin: '', // #59: PIN facoltativo alla creazione
|
||||||
roles: [],
|
roles: [],
|
||||||
is_admin: false,
|
is_admin: false,
|
||||||
language_pref: 'it',
|
language_pref: 'it',
|
||||||
@@ -52,6 +60,7 @@ function userManagement(initialUsers) {
|
|||||||
display_name: '',
|
display_name: '',
|
||||||
email: '',
|
email: '',
|
||||||
password: '',
|
password: '',
|
||||||
|
pin: '', // #59
|
||||||
roles: [],
|
roles: [],
|
||||||
is_admin: false,
|
is_admin: false,
|
||||||
language_pref: 'it',
|
language_pref: 'it',
|
||||||
@@ -134,6 +143,12 @@ function userManagement(initialUsers) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #59: PIN facoltativo, 4 cifre, solo per i non admin
|
||||||
|
const pin = this.form.is_admin ? '' : (this.form.pin || '').trim();
|
||||||
|
if (pin && !/^\d{4}$/.test(pin)) {
|
||||||
|
this.errorMsg = {{ _('Il PIN deve essere di 4 cifre')|tojson }};
|
||||||
|
return;
|
||||||
|
}
|
||||||
const data = {
|
const data = {
|
||||||
username: this.form.username,
|
username: this.form.username,
|
||||||
password: this.form.password,
|
password: this.form.password,
|
||||||
@@ -158,6 +173,24 @@ function userManagement(initialUsers) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.users.push(result);
|
this.users.push(result);
|
||||||
|
|
||||||
|
// #59: l'utente esiste gia', il PIN si imposta con la chiamata dedicata
|
||||||
|
if (pin) {
|
||||||
|
const pinResp = await fetch(`/admin/api/users/${result.id}/pin`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
|
||||||
|
body: JSON.stringify({ pin: pin })
|
||||||
|
});
|
||||||
|
if (!pinResp.ok) {
|
||||||
|
const pinResult = await pinResp.json().catch(() => ({}));
|
||||||
|
// resta aperto in modifica sull'utente appena creato, cosi' non si ricrea
|
||||||
|
this.isEditing = true;
|
||||||
|
this.editingUserId = result.id;
|
||||||
|
this.errorMsg = {{ _('Utente creato, ma PIN non impostato')|tojson }} + ': ' + (pinResult.detail || '');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
result.has_pin = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.closeModal();
|
this.closeModal();
|
||||||
@@ -218,6 +251,70 @@ function userManagement(initialUsers) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// #59: imposta / reset / togli PIN, usa PUT /admin/api/users/<id>/pin
|
||||||
|
openPin(user) {
|
||||||
|
this.pinUser = user;
|
||||||
|
this.pinValue = '';
|
||||||
|
this.pinDone = false;
|
||||||
|
this.pinRemoved = false;
|
||||||
|
this.pinError = '';
|
||||||
|
this.showPin = true;
|
||||||
|
},
|
||||||
|
|
||||||
|
closePin() {
|
||||||
|
this.showPin = false;
|
||||||
|
this.pinValue = ''; // il PIN generato non resta in memoria
|
||||||
|
this.pinDone = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
generatePin() {
|
||||||
|
// 4 cifre da crypto random
|
||||||
|
const buf = new Uint32Array(4);
|
||||||
|
crypto.getRandomValues(buf);
|
||||||
|
this.pinValue = Array.from(buf, n => n % 10).join('');
|
||||||
|
this.pinError = '';
|
||||||
|
},
|
||||||
|
|
||||||
|
async savePin(pin) {
|
||||||
|
// pin = stringa di 4 cifre oppure null (toglie il PIN)
|
||||||
|
this.saving = true;
|
||||||
|
this.pinError = '';
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/admin/api/users/${this.pinUser.id}/pin`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
|
||||||
|
body: JSON.stringify({ pin: pin })
|
||||||
|
});
|
||||||
|
const result = await resp.json().catch(() => ({}));
|
||||||
|
if (!resp.ok) {
|
||||||
|
this.pinError = result.detail || {{ _('Errore nel salvataggio del PIN')|tojson }};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// aggiorna l'indicatore locale
|
||||||
|
const idx = this.users.findIndex(u => u.id === this.pinUser.id);
|
||||||
|
if (idx >= 0) this.users[idx] = Object.assign({}, this.users[idx], { has_pin: pin !== null });
|
||||||
|
this.pinUser = idx >= 0 ? this.users[idx] : this.pinUser;
|
||||||
|
this.pinRemoved = pin === null;
|
||||||
|
this.pinDone = true;
|
||||||
|
} catch (e) {
|
||||||
|
this.pinError = {{ _('Errore di connessione al server')|tojson }};
|
||||||
|
} finally {
|
||||||
|
this.saving = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async confirmPin() {
|
||||||
|
if (!/^\d{4}$/.test(this.pinValue)) {
|
||||||
|
this.pinError = {{ _('Il PIN deve essere di 4 cifre')|tojson }};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.savePin(this.pinValue);
|
||||||
|
},
|
||||||
|
|
||||||
|
async removePin() {
|
||||||
|
await this.savePin(null);
|
||||||
|
},
|
||||||
|
|
||||||
toggleActive(user) {
|
toggleActive(user) {
|
||||||
this.toggleUser = user;
|
this.toggleUser = user;
|
||||||
this.showToggleConfirm = true;
|
this.showToggleConfirm = true;
|
||||||
|
|||||||
@@ -25,7 +25,8 @@
|
|||||||
{% include "admin/_users_panel.html" %}
|
{% include "admin/_users_panel.html" %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div x-show="tab === 'generale'" class="max-w-4xl mx-auto" x-data="systemSettings()">
|
{# #65: niente larghezza propria, la scheda sta nella cornice .tmf-page #}
|
||||||
|
<div x-show="tab === 'generale'" x-data="systemSettings()">
|
||||||
<!-- Settings Card -->
|
<!-- Settings Card -->
|
||||||
<div class="tmf-card mb-6">
|
<div class="tmf-card mb-6">
|
||||||
<div class="tmf-card-header flex items-center gap-2">
|
<div class="tmf-card-header flex items-center gap-2">
|
||||||
|
|||||||
@@ -204,6 +204,37 @@
|
|||||||
maxlength="255">
|
maxlength="255">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{# Worker visione (#45): vuoto = worker centrale #}
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Worker visione (URL)') }}</label>
|
||||||
|
<input type="text" x-model="form.vision_worker_url"
|
||||||
|
class="w-full px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)]
|
||||||
|
text-sm font-mono text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors"
|
||||||
|
placeholder="host:8100"
|
||||||
|
maxlength="500">
|
||||||
|
<p class="mt-1 text-xs text-[var(--text-secondary)]">{{ _('Vuoto = worker centrale. Indirizzo completo, con http o https') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Camera della stazione (#72): servizio camera (vs-gui) e sorgente da cui prendere il frame #}
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Camera (URL)') }}</label>
|
||||||
|
<input type="text" x-model="form.camera_url"
|
||||||
|
class="w-full px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)]
|
||||||
|
text-sm font-mono text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors"
|
||||||
|
placeholder="host:8765"
|
||||||
|
maxlength="500">
|
||||||
|
<p class="mt-1 text-xs text-[var(--text-secondary)]">{{ _('Vuoto = nessuna camera. Indirizzo del servizio camera, con http o https, senza / finale') }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Sorgente camera') }}</label>
|
||||||
|
<input type="text" x-model="form.camera_source"
|
||||||
|
class="w-full px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)]
|
||||||
|
text-sm font-mono text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors"
|
||||||
|
placeholder="camera:folder"
|
||||||
|
maxlength="200">
|
||||||
|
<p class="mt-1 text-xs text-[var(--text-secondary)]">{{ _('Es. camera:<seriale> oppure camera:folder') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Note') }}</label>
|
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Note') }}</label>
|
||||||
<textarea x-model="form.notes" rows="3"
|
<textarea x-model="form.notes" rows="3"
|
||||||
@@ -452,6 +483,9 @@ function stationManagement(initialStations, initialRecipes) {
|
|||||||
name: '',
|
name: '',
|
||||||
location: '',
|
location: '',
|
||||||
notes: '',
|
notes: '',
|
||||||
|
vision_worker_url: '', // #45
|
||||||
|
camera_url: '', // #72
|
||||||
|
camera_source: '', // #72
|
||||||
active: true,
|
active: true,
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -505,7 +539,7 @@ function stationManagement(initialStations, initialRecipes) {
|
|||||||
this.isEditing = false;
|
this.isEditing = false;
|
||||||
this.editingId = null;
|
this.editingId = null;
|
||||||
this.errorMsg = '';
|
this.errorMsg = '';
|
||||||
this.form = { code: '', name: '', location: '', notes: '', active: true };
|
this.form = { code: '', name: '', location: '', notes: '', vision_worker_url: '', camera_url: '', camera_source: '', active: true };
|
||||||
this.showModal = true;
|
this.showModal = true;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -518,6 +552,9 @@ function stationManagement(initialStations, initialRecipes) {
|
|||||||
name: station.name,
|
name: station.name,
|
||||||
location: station.location || '',
|
location: station.location || '',
|
||||||
notes: station.notes || '',
|
notes: station.notes || '',
|
||||||
|
vision_worker_url: station.vision_worker_url || '', // #45
|
||||||
|
camera_url: station.camera_url || '', // #72
|
||||||
|
camera_source: station.camera_source || '', // #72
|
||||||
active: station.active,
|
active: station.active,
|
||||||
};
|
};
|
||||||
this.showModal = true;
|
this.showModal = true;
|
||||||
@@ -537,6 +574,9 @@ function stationManagement(initialStations, initialRecipes) {
|
|||||||
name: this.form.name,
|
name: this.form.name,
|
||||||
location: this.form.location || null,
|
location: this.form.location || null,
|
||||||
notes: this.form.notes || null,
|
notes: this.form.notes || null,
|
||||||
|
vision_worker_url: (this.form.vision_worker_url || '').trim() || null, // #45
|
||||||
|
camera_url: (this.form.camera_url || '').trim() || null, // #72
|
||||||
|
camera_source: (this.form.camera_source || '').trim() || null, // #72
|
||||||
active: this.form.active,
|
active: this.form.active,
|
||||||
};
|
};
|
||||||
const resp = await fetch(`/admin/api/stations/${this.editingId}`, {
|
const resp = await fetch(`/admin/api/stations/${this.editingId}`, {
|
||||||
@@ -563,6 +603,9 @@ function stationManagement(initialStations, initialRecipes) {
|
|||||||
name: this.form.name,
|
name: this.form.name,
|
||||||
location: this.form.location || null,
|
location: this.form.location || null,
|
||||||
notes: this.form.notes || null,
|
notes: this.form.notes || null,
|
||||||
|
vision_worker_url: (this.form.vision_worker_url || '').trim() || null, // #45
|
||||||
|
camera_url: (this.form.camera_url || '').trim() || null, // #72
|
||||||
|
camera_source: (this.form.camera_source || '').trim() || null, // #72
|
||||||
active: this.form.active,
|
active: this.form.active,
|
||||||
};
|
};
|
||||||
const resp = await fetch('/admin/api/stations', {
|
const resp = await fetch('/admin/api/stations', {
|
||||||
|
|||||||
@@ -157,7 +157,7 @@
|
|||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<div class="text-center text-xs text-[var(--text-muted)]">
|
<div class="text-center text-xs text-[var(--text-muted)]">
|
||||||
<p>TieMeasureFlow © {{ current_year }} - {{ _('Sistema di misurazione industriale') }}</p>
|
<p>TieMeasureFlow © {{ current_year }} - {{ _('Sistema di misurazione industriale') }}{# versione e commit (#70) #} · v{{ app_version }}{% if app_commit %} · {{ app_commit }}{% endif %}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -107,7 +107,7 @@
|
|||||||
<!-- 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{# versione e commit (#70) #} · v{{ app_version }}{% if app_commit %} · {{ app_commit }}{% endif %}
|
||||||
</p>
|
</p>
|
||||||
</footer>
|
</footer>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
{% 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_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 %}
|
{% 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 %}
|
||||||
|
|
||||||
|
{% set icon_monitor %}<path stroke-linecap="round" stroke-linejoin="round" d="M9.348 14.652a3.75 3.75 0 010-5.304m5.304 0a3.75 3.75 0 010 5.304m-7.425 2.121a6.75 6.75 0 010-9.546m9.546 0a6.75 6.75 0 010 9.546M5.106 18.894c-3.808-3.807-3.808-9.98 0-13.788m13.788 0c3.808 3.807 3.808 9.98 0 13.788M12 12h.008v.008H12V12zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z"/>{% endset %}
|
||||||
|
|
||||||
{# Single nav link, desktop or mobile variant. `icon` is SVG path markup. #}
|
{# Single nav link, desktop or mobile variant. `icon` is SVG path markup. #}
|
||||||
{% macro nav_link(href, label, icon, active=false, mobile=false) -%}
|
{% macro nav_link(href, label, icon, active=false, mobile=false) -%}
|
||||||
<a href="{{ href }}"
|
<a href="{{ href }}"
|
||||||
@@ -31,6 +33,10 @@
|
|||||||
{% if current_user.get('roles') and 'Metrologist' in current_user.roles %}
|
{% if current_user.get('roles') and 'Metrologist' in current_user.roles %}
|
||||||
{{ nav_link(url_for('statistics.dashboard'), _('Statistiche'), icon_stats, ep.startswith('statistics.'), mobile) }}
|
{{ nav_link(url_for('statistics.dashboard'), _('Statistiche'), icon_stats, ep.startswith('statistics.'), mobile) }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{# #57: Monitor produzione, visibile ad admin e capoturno (stessa regola di monitor_required) #}
|
||||||
|
{% if current_user.get('is_admin') or (current_user.get('roles') and 'Supervisor' in current_user.roles) %}
|
||||||
|
{{ nav_link(url_for('monitor.index'), _('Monitor'), icon_monitor, ep.startswith('monitor.'), mobile) }}
|
||||||
|
{% endif %}
|
||||||
{% if current_user.get('is_admin') %}
|
{% if current_user.get('is_admin') %}
|
||||||
{{ nav_link(url_for('admin.station_list'), _('Stazioni'), icon_stations, ep == 'admin.station_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) }}
|
{{ nav_link(url_for('admin.settings_page'), _('Impostazioni'), icon_settings, ep == 'admin.settings_page', mobile) }}
|
||||||
|
|||||||
@@ -91,6 +91,74 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
{# #53 #55: parte del form task comune a creazione e modifica; `d` e il nome dello stato Alpine (newTask / editTaskData) #}
|
||||||
|
{% macro task_flags_editor(d) %}
|
||||||
|
<div class="sm:col-span-2 space-y-3">
|
||||||
|
<!-- #53: il task entra nel ciclo di misura (timer e cicalino) -->
|
||||||
|
<label class="flex items-start gap-2 text-sm cursor-pointer">
|
||||||
|
<input type="checkbox" class="mt-0.5"
|
||||||
|
x-model="{{ d }}.in_loop"
|
||||||
|
@change="{{ d }}.in_loop_touched = true">
|
||||||
|
<span>{{ _('Nel ciclo di misura (timer e cicalino)') }}</span>
|
||||||
|
</label>
|
||||||
|
<!-- #55: opzioni valide solo per il tipo Nota -->
|
||||||
|
<template x-if="{{ d }}.task_type === 'note'">
|
||||||
|
<div class="space-y-3">
|
||||||
|
<label class="flex items-start gap-2 text-sm cursor-pointer">
|
||||||
|
<input type="checkbox" class="mt-0.5" x-model="{{ d }}.at_close">
|
||||||
|
<span>{{ _('Compila a fine produzione') }}</span>
|
||||||
|
</label>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<label class="tmf-label mb-0">{{ _('Campi da compilare') }}</label>
|
||||||
|
<button type="button" class="btn btn-secondary text-xs gap-1"
|
||||||
|
:disabled="{{ d }}.fields.length >= MAX_NOTE_FIELDS"
|
||||||
|
@click="addNoteField({{ d }})">
|
||||||
|
+ {{ _('Aggiungi campo') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<template x-for="(f, fi) in {{ d }}.fields" :key="f._uid">
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-12 gap-2 p-2 rounded border border-[var(--border-color)] bg-[var(--bg-secondary)]">
|
||||||
|
<div class="sm:col-span-4">
|
||||||
|
<input type="text" class="tmf-input text-sm" maxlength="100"
|
||||||
|
placeholder="{{ _('Etichetta') }}"
|
||||||
|
x-model="f.label"
|
||||||
|
@input="deriveNoteFieldKey({{ d }}, f)">
|
||||||
|
<span class="block mt-0.5 text-[10px] font-mono text-[var(--text-muted)]" x-text="f.key"></span>
|
||||||
|
</div>
|
||||||
|
<div class="sm:col-span-2">
|
||||||
|
<select class="tmf-input text-sm" x-model="f.type">
|
||||||
|
<option value="text">{{ _('Testo') }}</option>
|
||||||
|
<option value="number">{{ _('Numero') }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="sm:col-span-3">
|
||||||
|
<input type="text" class="tmf-input text-sm"
|
||||||
|
placeholder="{{ _('Valore precompilato') }}"
|
||||||
|
x-model="f.default">
|
||||||
|
</div>
|
||||||
|
<label class="sm:col-span-1 flex items-center gap-1 text-xs cursor-pointer">
|
||||||
|
<input type="checkbox" x-model="f.attachment">
|
||||||
|
<span>{{ _('Allegato') }}</span>
|
||||||
|
</label>
|
||||||
|
<div class="sm:col-span-2 flex items-center justify-end gap-1">
|
||||||
|
<button type="button" class="p-1 rounded hover:bg-[var(--bg-card-hover)]"
|
||||||
|
:disabled="fi === 0" @click="moveNoteField({{ d }}, fi, -1)"
|
||||||
|
title="{{ _('Sposta su') }}">↑</button>
|
||||||
|
<button type="button" class="p-1 rounded hover:bg-[var(--bg-card-hover)]"
|
||||||
|
:disabled="fi === {{ d }}.fields.length - 1" @click="moveNoteField({{ d }}, fi, 1)"
|
||||||
|
title="{{ _('Sposta giu') }}">↓</button>
|
||||||
|
<button type="button" class="p-1 rounded text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20"
|
||||||
|
@click="removeNoteField({{ d }}, fi)"
|
||||||
|
title="{{ _('Rimuovi campo') }}">×</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
{% endmacro %}
|
||||||
<div class="tmf-page"
|
<div class="tmf-page"
|
||||||
x-data="taskEditor()"
|
x-data="taskEditor()"
|
||||||
x-cloak>
|
x-cloak>
|
||||||
@@ -273,12 +341,15 @@
|
|||||||
<!-- Tipo: dichiarato qui, non dedotto dal contenuto -->
|
<!-- Tipo: dichiarato qui, non dedotto dal contenuto -->
|
||||||
<div>
|
<div>
|
||||||
<label class="tmf-label">{{ _('Tipo') }} <span class="text-red-500">*</span></label>
|
<label class="tmf-label">{{ _('Tipo') }} <span class="text-red-500">*</span></label>
|
||||||
<select x-model="newTask.task_type" class="tmf-input">
|
<select x-model="newTask.task_type" class="tmf-input"
|
||||||
|
@change="onTaskTypeChange(newTask, $event.target.value)">
|
||||||
<option value="note">{{ _('Nota') }}</option>
|
<option value="note">{{ _('Nota') }}</option>
|
||||||
<option value="measure">{{ _('Misura') }}</option>
|
<option value="measure">{{ _('Misura') }}</option>
|
||||||
<option value="drawing">{{ _('Disegno') }}</option>
|
<option value="drawing">{{ _('Disegno') }}</option>
|
||||||
<option value="camera_measure">{{ _('Misura camera') }}</option>
|
<option value="camera_measure">{{ _('Misura camera') }}</option>
|
||||||
<option value="dxf_compare">{{ _('Confronto profilo') }}</option>
|
<option value="dxf_compare">{{ _('Confronto profilo') }}</option>
|
||||||
|
<!-- #54: task di controllo con esito OK/NOK, senza quote -->
|
||||||
|
<option value="check">{{ _('Controllo OK/NOK') }}</option>
|
||||||
</select>
|
</select>
|
||||||
<p class="mt-1 text-xs text-[var(--text-secondary)]">
|
<p class="mt-1 text-xs text-[var(--text-secondary)]">
|
||||||
{{ _('Un task di misura resta tale anche prima di inserire le quote') }}
|
{{ _('Un task di misura resta tale anche prima di inserire le quote') }}
|
||||||
@@ -306,6 +377,8 @@
|
|||||||
rows="3"
|
rows="3"
|
||||||
placeholder="{{ _('Descrizione opzionale...') }}"></textarea>
|
placeholder="{{ _('Descrizione opzionale...') }}"></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
{# #53 #55: ciclo di misura, compilazione a fine produzione e campi della nota #}
|
||||||
|
{{ task_flags_editor('newTask') }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="tmf-card-footer flex items-center gap-2 justify-end">
|
<div class="tmf-card-footer flex items-center gap-2 justify-end">
|
||||||
@@ -387,8 +460,20 @@
|
|||||||
x-ref="editTaskTitleInput">
|
x-ref="editTaskTitleInput">
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Badge: N misurazioni -->
|
<!-- #53: segno sui task nel ciclo di misura -->
|
||||||
|
<span x-show="task.in_loop" class="shrink-0 text-primary"
|
||||||
|
title="{{ _('Nel ciclo di misura (timer e cicalino)') }}">
|
||||||
|
<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 4v5h5M20 20v-5h-5M5.5 15A8 8 0 0018.4 17.4M18.5 9A8 8 0 005.6 6.6"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<!-- #55: nota compilata a fine produzione -->
|
||||||
|
<span x-show="task.at_close" class="badge badge-neutral text-xs shrink-0">{{ _('A fine produzione') }}</span>
|
||||||
|
|
||||||
|
<!-- Badge: N misurazioni (non per i task di controllo, senza quote) -->
|
||||||
<span class="badge badge-neutral text-xs shrink-0"
|
<span class="badge badge-neutral text-xs shrink-0"
|
||||||
|
x-show="task.task_type !== 'check'"
|
||||||
x-text="(task.subtasks ? task.subtasks.length : 0) + ' {{ _('misurazioni') }}'">
|
x-text="(task.subtasks ? task.subtasks.length : 0) + ' {{ _('misurazioni') }}'">
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
@@ -590,12 +675,14 @@
|
|||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 p-3 rounded-lg bg-blue-50 dark:bg-blue-900/10 border border-blue-200 dark:border-blue-800">
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 p-3 rounded-lg bg-blue-50 dark:bg-blue-900/10 border border-blue-200 dark:border-blue-800">
|
||||||
<div>
|
<div>
|
||||||
<label class="tmf-label">{{ _('Tipo') }}</label>
|
<label class="tmf-label">{{ _('Tipo') }}</label>
|
||||||
<select x-model="editTaskData.task_type" class="tmf-input text-sm">
|
<select x-model="editTaskData.task_type" class="tmf-input text-sm"
|
||||||
|
@change="onTaskTypeChange(editTaskData, $event.target.value)">
|
||||||
<option value="note">{{ _('Nota') }}</option>
|
<option value="note">{{ _('Nota') }}</option>
|
||||||
<option value="measure">{{ _('Misura') }}</option>
|
<option value="measure">{{ _('Misura') }}</option>
|
||||||
<option value="drawing">{{ _('Disegno') }}</option>
|
<option value="drawing">{{ _('Disegno') }}</option>
|
||||||
<option value="camera_measure">{{ _('Misura camera') }}</option>
|
<option value="camera_measure">{{ _('Misura camera') }}</option>
|
||||||
<option value="dxf_compare">{{ _('Confronto profilo') }}</option>
|
<option value="dxf_compare">{{ _('Confronto profilo') }}</option>
|
||||||
|
<option value="check">{{ _('Controllo OK/NOK') }}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -618,12 +705,14 @@
|
|||||||
rows="3"
|
rows="3"
|
||||||
placeholder="{{ _('Descrizione opzionale...') }}"></textarea>
|
placeholder="{{ _('Descrizione opzionale...') }}"></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
{# #53 #55: ciclo di misura, compilazione a fine produzione e campi della nota #}
|
||||||
|
{{ task_flags_editor('editTaskData') }}
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- ========== Subtask Table ========== -->
|
<!-- ========== Subtask Table ========== -->
|
||||||
<div class="subtask-table-wrap">
|
<div class="subtask-table-wrap">
|
||||||
<table class="tmf-table" x-show="task.subtasks && task.subtasks.length > 0">
|
<table class="tmf-table" x-show="task.task_type !== 'check' && task.subtasks && task.subtasks.length > 0">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="w-16 text-center">{{ _('#') }}</th>
|
<th class="w-16 text-center">{{ _('#') }}</th>
|
||||||
@@ -710,7 +799,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ========== Empty Subtask State ========== -->
|
<!-- ========== Empty Subtask State ========== -->
|
||||||
<div x-show="!task.subtasks || task.subtasks.length === 0"
|
<div x-show="task.task_type !== 'check' && (!task.subtasks || task.subtasks.length === 0)"
|
||||||
class="text-center py-8">
|
class="text-center py-8">
|
||||||
<div class="inline-flex items-center justify-center w-14 h-14 rounded-full
|
<div class="inline-flex items-center justify-center w-14 h-14 rounded-full
|
||||||
bg-[var(--bg-secondary)] mb-3">
|
bg-[var(--bg-secondary)] mb-3">
|
||||||
@@ -862,7 +951,7 @@
|
|||||||
<!-- ========== Task Card Footer ========== -->
|
<!-- ========== Task Card Footer ========== -->
|
||||||
<div class="tmf-card-footer flex items-center justify-end">
|
<div class="tmf-card-footer flex items-center justify-end">
|
||||||
<button @click="openAddSubtask(task)"
|
<button @click="openAddSubtask(task)"
|
||||||
x-show="addingSubtaskForTask !== task.id"
|
x-show="addingSubtaskForTask !== task.id && task.task_type !== 'check'"
|
||||||
class="btn btn-secondary text-xs gap-1.5">
|
class="btn btn-secondary text-xs gap-1.5">
|
||||||
<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="M12 4v16m8-8H4"/>
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4"/>
|
||||||
@@ -1210,8 +1299,10 @@ function taskEditor() {
|
|||||||
|
|
||||||
// ---- Task CRUD state ----
|
// ---- Task CRUD state ----
|
||||||
editingTask: null,
|
editingTask: null,
|
||||||
editTaskData: { title: '', directive: '', description: '', task_type: 'note' },
|
editTaskData: { title: '', directive: '', description: '', task_type: 'note', in_loop: false, in_loop_touched: false, at_close: false, fields: [] },
|
||||||
newTask: { title: '', directive: '', description: '', task_type: 'note' },
|
newTask: { title: '', directive: '', description: '', task_type: 'note', in_loop: false, in_loop_touched: false, at_close: false, fields: [] },
|
||||||
|
MAX_NOTE_FIELDS: 20, // #55: limite campi per nota (stesso del backend)
|
||||||
|
_fieldUid: 0,
|
||||||
|
|
||||||
// ---- Subtask CRUD state ----
|
// ---- Subtask CRUD state ----
|
||||||
editingSubtaskId: null,
|
editingSubtaskId: null,
|
||||||
@@ -1391,7 +1482,84 @@ function taskEditor() {
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
resetNewTask() {
|
resetNewTask() {
|
||||||
this.newTask = { title: '', directive: '', description: '', task_type: 'note' };
|
this.newTask = { title: '', directive: '', description: '', task_type: 'note', in_loop: false, in_loop_touched: false, at_close: false, fields: [] };
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---- #53 #55: ciclo di misura e campi della nota ----
|
||||||
|
|
||||||
|
// #53: default di "nel ciclo" per tipo (stessa regola del backend)
|
||||||
|
loopDefault(type) {
|
||||||
|
return ['measure', 'camera_measure', 'dxf_compare', 'check'].includes(type);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Cambio tipo: il default del ciclo segue il tipo finche l'utente non lo tocca;
|
||||||
|
// at_close e campi esistono solo per la nota.
|
||||||
|
onTaskTypeChange(obj, type) {
|
||||||
|
obj.task_type = type;
|
||||||
|
if (!obj.in_loop_touched) obj.in_loop = this.loopDefault(type);
|
||||||
|
if (type !== 'note') { obj.at_close = false; obj.fields = []; }
|
||||||
|
},
|
||||||
|
|
||||||
|
// #55: key dall'etichetta (minuscole, [a-z0-9_], max 40), unica nella lista
|
||||||
|
_slugKey(label) {
|
||||||
|
const base = (label || '').normalize('NFD').replace(/[̀-ͯ]/g, '')
|
||||||
|
.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 40);
|
||||||
|
return base || 'campo';
|
||||||
|
},
|
||||||
|
|
||||||
|
deriveNoteFieldKey(obj, f) {
|
||||||
|
if (f.locked) return; // la key non cambia piu dopo il primo salvataggio
|
||||||
|
const used = new Set(obj.fields.filter(x => x !== f).map(x => x.key));
|
||||||
|
const base = this._slugKey(f.label);
|
||||||
|
let key = base, n = 2;
|
||||||
|
while (used.has(key)) {
|
||||||
|
const suffix = '_' + n++;
|
||||||
|
key = base.slice(0, 40 - suffix.length) + suffix;
|
||||||
|
}
|
||||||
|
f.key = key;
|
||||||
|
},
|
||||||
|
|
||||||
|
addNoteField(obj) {
|
||||||
|
if (obj.fields.length >= this.MAX_NOTE_FIELDS) return;
|
||||||
|
const f = { _uid: ++this._fieldUid, key: '', label: '', type: 'text', default: '', attachment: false, locked: false };
|
||||||
|
obj.fields.push(f);
|
||||||
|
this.deriveNoteFieldKey(obj, f);
|
||||||
|
},
|
||||||
|
|
||||||
|
removeNoteField(obj, i) {
|
||||||
|
obj.fields.splice(i, 1);
|
||||||
|
},
|
||||||
|
|
||||||
|
moveNoteField(obj, i, dir) {
|
||||||
|
const j = i + dir;
|
||||||
|
if (j < 0 || j >= obj.fields.length) return;
|
||||||
|
const [f] = obj.fields.splice(i, 1);
|
||||||
|
obj.fields.splice(j, 0, f);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Campi salvati -> stato di modifica (key bloccate)
|
||||||
|
_fieldsToState(fields) {
|
||||||
|
return (fields || []).map(f => ({
|
||||||
|
_uid: ++this._fieldUid, key: f.key, label: f.label || '', type: f.type || 'text',
|
||||||
|
default: f.default == null ? '' : f.default, attachment: !!f.attachment, locked: true
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
// Stato -> corpo API (fields null se non e una nota o e vuota); ok=false con errorMessage se manca un'etichetta.
|
||||||
|
_fieldsPayload(obj) {
|
||||||
|
if (obj.task_type !== 'note') return { ok: true, fields: null };
|
||||||
|
const out = [];
|
||||||
|
for (const f of obj.fields) {
|
||||||
|
if (!f.label.trim()) {
|
||||||
|
this.errorMessage = {{ _("Ogni campo deve avere un'etichetta")|tojson }};
|
||||||
|
return { ok: false };
|
||||||
|
}
|
||||||
|
out.push({
|
||||||
|
key: f.key, label: f.label.trim(), type: f.type || 'text',
|
||||||
|
default: (f.default || '').trim() || null, attachment: !!f.attachment
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { ok: true, fields: out.length ? out : null };
|
||||||
},
|
},
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -1470,8 +1638,10 @@ function taskEditor() {
|
|||||||
|
|
||||||
async addTask() {
|
async addTask() {
|
||||||
if (!this.newTask.title.trim()) return;
|
if (!this.newTask.title.trim()) return;
|
||||||
this.saving = true;
|
|
||||||
this.errorMessage = '';
|
this.errorMessage = '';
|
||||||
|
const fp = this._fieldsPayload(this.newTask); // #55
|
||||||
|
if (!fp.ok) return;
|
||||||
|
this.saving = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`/maker/api/recipes/${this.recipeId}/tasks`, {
|
const resp = await fetch(`/maker/api/recipes/${this.recipeId}/tasks`, {
|
||||||
@@ -1484,7 +1654,11 @@ function taskEditor() {
|
|||||||
title: this.newTask.title.trim(),
|
title: this.newTask.title.trim(),
|
||||||
task_type: this.newTask.task_type || 'note',
|
task_type: this.newTask.task_type || 'note',
|
||||||
directive: this.newTask.directive.trim() || null,
|
directive: this.newTask.directive.trim() || null,
|
||||||
description: this.newTask.description.trim() || null
|
description: this.newTask.description.trim() || null,
|
||||||
|
// #53 #55: ciclo di misura, compilazione a fine produzione, campi della nota
|
||||||
|
in_loop: !!this.newTask.in_loop,
|
||||||
|
at_close: this.newTask.task_type === 'note' && !!this.newTask.at_close,
|
||||||
|
fields: fp.fields
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1516,7 +1690,12 @@ function taskEditor() {
|
|||||||
title: task.title,
|
title: task.title,
|
||||||
task_type: task.task_type || 'note',
|
task_type: task.task_type || 'note',
|
||||||
directive: task.directive || '',
|
directive: task.directive || '',
|
||||||
description: task.description || ''
|
description: task.description || '',
|
||||||
|
// #53 #55: valore salvato (task vecchi senza il campo: default del tipo)
|
||||||
|
in_loop: task.in_loop != null ? !!task.in_loop : this.loopDefault(task.task_type || 'note'),
|
||||||
|
in_loop_touched: false,
|
||||||
|
at_close: !!task.at_close,
|
||||||
|
fields: this._fieldsToState(task.fields)
|
||||||
};
|
};
|
||||||
// Ensure expanded
|
// Ensure expanded
|
||||||
this.expandedTask = task.id;
|
this.expandedTask = task.id;
|
||||||
@@ -1524,13 +1703,15 @@ function taskEditor() {
|
|||||||
|
|
||||||
cancelEditTask() {
|
cancelEditTask() {
|
||||||
this.editingTask = null;
|
this.editingTask = null;
|
||||||
this.editTaskData = { title: '', directive: '', description: '', task_type: 'note' };
|
this.editTaskData = { title: '', directive: '', description: '', task_type: 'note', in_loop: false, in_loop_touched: false, at_close: false, fields: [] };
|
||||||
},
|
},
|
||||||
|
|
||||||
async updateTask(taskId) {
|
async updateTask(taskId) {
|
||||||
if (!this.editTaskData.title.trim()) return;
|
if (!this.editTaskData.title.trim()) return;
|
||||||
this.saving = true;
|
|
||||||
this.errorMessage = '';
|
this.errorMessage = '';
|
||||||
|
const fp = this._fieldsPayload(this.editTaskData); // #55
|
||||||
|
if (!fp.ok) return;
|
||||||
|
this.saving = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`/maker/api/tasks/${taskId}`, {
|
const resp = await fetch(`/maker/api/tasks/${taskId}`, {
|
||||||
@@ -1543,7 +1724,11 @@ function taskEditor() {
|
|||||||
title: this.editTaskData.title.trim(),
|
title: this.editTaskData.title.trim(),
|
||||||
task_type: this.editTaskData.task_type || 'note',
|
task_type: this.editTaskData.task_type || 'note',
|
||||||
directive: this.editTaskData.directive.trim() || null,
|
directive: this.editTaskData.directive.trim() || null,
|
||||||
description: this.editTaskData.description.trim() || null
|
description: this.editTaskData.description.trim() || null,
|
||||||
|
// #53 #55: ciclo di misura, compilazione a fine produzione, campi della nota
|
||||||
|
in_loop: !!this.editTaskData.in_loop,
|
||||||
|
at_close: this.editTaskData.task_type === 'note' && !!this.editTaskData.at_close,
|
||||||
|
fields: fp.fields
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -118,8 +118,8 @@
|
|||||||
{# #60: un solo «Completato», quello grande in fondo (touch); qui resta solo la lista. #}
|
{# #60: un solo «Completato», quello grande in fondo (touch); qui resta solo la lista. #}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# Fermo linea + Fine produzione (measurement tasks only) #}
|
{# Fermo linea + Fine produzione: task di misura o del ciclo (#53: anche un check nel loop) #}
|
||||||
<template x-if="isMeasureTask && productionRun">
|
<template x-if="(isMeasureTask || isMeasurementTaskOfRun) && productionRun">
|
||||||
{# #61: Fermo linea / Fine produzione solo con una produzione aperta #}
|
{# #61: Fermo linea / Fine produzione solo con una produzione aperta #}
|
||||||
<div class="shrink-0 flex items-center gap-1.5">
|
<div class="shrink-0 flex items-center gap-1.5">
|
||||||
{# While the line is stopped this becomes the way to restart it, so the
|
{# While the line is stopped this becomes the way to restart it, so the
|
||||||
@@ -256,6 +256,88 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{# #55: the inputs of the fields of a «note» task. One block for the task on screen
|
||||||
|
and for the panel at the end of production, so the rules are written once. #}
|
||||||
|
{% macro result_fields() %}
|
||||||
|
<template x-for="fld in f.fields" :key="fld.key">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="block text-sm font-medium text-[var(--text-secondary)] mb-1" x-text="fld.label"></label>
|
||||||
|
<input :type="fld.type === 'number' ? 'number' : 'text'"
|
||||||
|
:step="fld.type === 'number' ? 'any' : null"
|
||||||
|
:inputmode="fld.type === 'number' ? 'decimal' : null"
|
||||||
|
x-model="f.values[fld.key]"
|
||||||
|
class="input w-full text-base py-2">
|
||||||
|
<template x-if="fld.attachment">
|
||||||
|
<div class="mt-1.5">
|
||||||
|
<label class="btn btn-secondary text-xs gap-1.5 cursor-pointer">
|
||||||
|
{{ _('Allega immagine o PDF') }}
|
||||||
|
<input type="file" accept="image/*,application/pdf" class="hidden"
|
||||||
|
@change="pickFile(f, fld.key, $event)">
|
||||||
|
</label>
|
||||||
|
<span class="ml-2 text-xs text-[var(--text-muted)]" x-text="f.fileNames[fld.key]"></span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{# #54 #55: result panel of the task on screen - OK / NOK for a «check», the
|
||||||
|
fields and «Completato» for a «note» with fields. #}
|
||||||
|
<div x-show="hasResultPanel" x-cloak
|
||||||
|
x-data="taskResultForm()"
|
||||||
|
x-init="ctx = () => ({ runId: productionRun ? productionRun.id : null, done: () => advanceAfterResult() });
|
||||||
|
initForTask(task);
|
||||||
|
$watch('productionRun && productionRun.id', () => loadLast())"
|
||||||
|
@task-result-reset.window="justRecorded = false; initForTask(task)"
|
||||||
|
class="shrink-0 max-h-[65%] overflow-y-auto px-4 py-3 bg-[var(--bg-card)] border-b border-[var(--border-color)]">
|
||||||
|
<template x-for="f in forms" :key="f.task_id">
|
||||||
|
<div class="max-w-2xl mx-auto">
|
||||||
|
<div x-show="f.result" class="mb-3 px-3 py-2 rounded-lg text-sm border border-[var(--border-color)] bg-[var(--bg-secondary)]">
|
||||||
|
<span class="text-[var(--text-secondary)]">{{ _('Ultimo esito') }}:</span>
|
||||||
|
<span class="font-bold"
|
||||||
|
:class="f.result && f.result.outcome === 'nok' ? 'text-measure-fail' : 'text-measure-pass'"
|
||||||
|
x-text="resultLabel(f.result)"></span>
|
||||||
|
<span class="text-[var(--text-muted)]" x-text="f.result && f.result.note ? '— ' + f.result.note : ''"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template x-if="f.type === 'check'">
|
||||||
|
<div>
|
||||||
|
<textarea x-model="f.note" rows="2"
|
||||||
|
placeholder="{{ _('Nota (obbligatoria per un NOK)') }}"
|
||||||
|
class="input w-full text-base mb-3"></textarea>
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<button @click="submit('ok')" :disabled="working"
|
||||||
|
class="btn min-h-[72px] text-2xl font-bold justify-center shadow-md bg-measure-pass text-white hover:opacity-90 disabled:opacity-50">
|
||||||
|
{{ _('OK') }}
|
||||||
|
</button>
|
||||||
|
<button @click="submit('nok')" :disabled="working"
|
||||||
|
class="btn min-h-[72px] text-2xl font-bold justify-center shadow-md bg-red-600 text-white hover:bg-red-700 disabled:opacity-50">
|
||||||
|
{{ _('NOK') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="f.type === 'note'">
|
||||||
|
<div>
|
||||||
|
{{ result_fields() }}
|
||||||
|
<button @click="submit('done')" :disabled="working"
|
||||||
|
class="btn text-lg font-bold w-full min-h-[64px] justify-center gap-2 shadow-md bg-measure-pass text-white hover:opacity-90 disabled:opacity-50">
|
||||||
|
<span>{{ _('Completato') }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<p x-show="f.error" class="mt-2 text-sm text-measure-fail" x-text="f.error"></p>
|
||||||
|
{# The result is in, but going on failed (cycle refused, network): another try, without recording twice. #}
|
||||||
|
<button x-show="justRecorded" @click="proceed()"
|
||||||
|
class="btn btn-secondary mt-3 w-full justify-center">
|
||||||
|
{{ _('Prosegui') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
{# Main image area #}
|
{# Main image area #}
|
||||||
<div class="flex-1 overflow-hidden relative">
|
<div class="flex-1 overflow-hidden relative">
|
||||||
|
|
||||||
@@ -462,6 +544,15 @@
|
|||||||
<input type="file" accept="image/*" capture="environment" class="hidden"
|
<input type="file" accept="image/*" capture="environment" class="hidden"
|
||||||
@change="onPick($event)" :disabled="busy">
|
@change="onPick($event)" :disabled="busy">
|
||||||
</label>
|
</label>
|
||||||
|
{% if camera_enabled %}
|
||||||
|
{# #72: frame dalla camera della stazione, stessa strada di acquire() #}
|
||||||
|
<button type="button" class="btn btn-secondary text-sm gap-1.5" @click="grab()"
|
||||||
|
:class="(busy || grabbing) ? 'opacity-50 pointer-events-none' : ''"
|
||||||
|
:disabled="busy || grabbing">
|
||||||
|
<span>{{ _('Acquisisci dalla camera') }}</span>
|
||||||
|
</button>
|
||||||
|
<span x-show="grabbing" class="text-sm text-[var(--text-muted)]">{{ _('Acquisizione in corso...') }}</span>
|
||||||
|
{% endif %}
|
||||||
<span x-show="busy" class="text-sm text-[var(--text-muted)]">{{ _('Elaborazione in corso...') }}</span>
|
<span x-show="busy" class="text-sm text-[var(--text-muted)]">{{ _('Elaborazione in corso...') }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -747,7 +838,8 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template x-if="!isMeasureTask || subtasks.length === 0">
|
{# #54 #55: a check / note-with-fields task has its own buttons in the panel #}
|
||||||
|
<template x-if="(!isMeasureTask || subtasks.length === 0) && !hasResultPanel">
|
||||||
{# #60: unico «Completato», largo e alto almeno 64 px per il touch. #}
|
{# #60: unico «Completato», largo e alto almeno 64 px per il touch. #}
|
||||||
<button @click="goToNextTask()"
|
<button @click="goToNextTask()"
|
||||||
:disabled="!canLeaveTask"
|
:disabled="!canLeaveTask"
|
||||||
@@ -895,6 +987,39 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{# ================================================================
|
||||||
|
#55 TASK DI CHIUSURA — before the capoturno's modal, the notes marked «alla fine
|
||||||
|
della produzione» are filled in and recorded on the run. The modal below is
|
||||||
|
untouched: it opens when this panel is done.
|
||||||
|
================================================================ #}
|
||||||
|
<div x-show="open" x-cloak
|
||||||
|
x-data="taskResultForm()"
|
||||||
|
x-init="ctx = () => ({ runId: productionRun ? productionRun.id : null, done: () => closeTasksRecorded() })"
|
||||||
|
@task-close-open.window="openClose($event.detail.ids)"
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||||
|
<div class="bg-[var(--bg-card)] rounded-2xl shadow-2xl p-6 w-full max-w-xl mx-4 max-h-[90vh] overflow-y-auto border border-[var(--border-color)]">
|
||||||
|
<h3 class="text-lg font-bold text-[var(--text-primary)] mb-3">{{ _('Prima di chiudere la produzione') }}</h3>
|
||||||
|
<p x-show="working && !forms.length" class="text-sm text-[var(--text-muted)]">{{ _('Caricamento...') }}</p>
|
||||||
|
<template x-for="f in forms" :key="f.task_id">
|
||||||
|
<div class="mb-4" x-show="!f.recorded">
|
||||||
|
<div class="font-semibold text-[var(--text-primary)] mb-1" x-text="f.title"></div>
|
||||||
|
<div class="text-sm text-[var(--text-secondary)] mb-2" x-show="f.directive" x-text="f.directive"></div>
|
||||||
|
{{ result_fields() }}
|
||||||
|
<textarea x-model="f.note" rows="2" placeholder="{{ _('Nota') }}" class="input w-full text-base"></textarea>
|
||||||
|
<p x-show="f.error" class="mt-1 text-sm text-measure-fail" x-text="f.error"></p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<p x-show="error" class="mb-2 text-sm text-measure-fail" x-text="error"></p>
|
||||||
|
<div class="flex gap-3 mt-2">
|
||||||
|
<button @click="cancelClose()" class="btn btn-secondary flex-1 justify-center">{{ _('Annulla') }}</button>
|
||||||
|
<button @click="submitClose()" :disabled="working || !forms.length"
|
||||||
|
class="btn flex-1 justify-center font-bold bg-measure-pass text-white hover:opacity-90 disabled:opacity-50">
|
||||||
|
{{ _('Registra e prosegui') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{# ================================================================
|
{# ================================================================
|
||||||
SUPERVISOR LOGIN MODAL
|
SUPERVISOR LOGIN MODAL
|
||||||
================================================================ #}
|
================================================================ #}
|
||||||
@@ -921,7 +1046,55 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-3 mb-5">
|
{# #59: two ways to authorise - PIN (default, touch pad) or password. #}
|
||||||
|
<div class="flex gap-2 mb-4" role="tablist">
|
||||||
|
<button type="button" role="tab" @click="setSupervisorMode('pin')"
|
||||||
|
:disabled="!supervisorPinUsers.length"
|
||||||
|
class="btn flex-1 text-sm"
|
||||||
|
:class="supervisorMode === 'pin' ? 'btn-primary' : 'btn-secondary'">{{ _('PIN') }}</button>
|
||||||
|
<button type="button" role="tab" @click="setSupervisorMode('password')"
|
||||||
|
class="btn flex-1 text-sm"
|
||||||
|
:class="supervisorMode === 'password' ? 'btn-primary' : 'btn-secondary'">{{ _('Password') }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-show="supervisorMode === 'pin'" class="mb-5">
|
||||||
|
{# name grid #}
|
||||||
|
<div x-show="!supervisorPinUser">
|
||||||
|
<p class="text-xs text-center text-[var(--text-secondary)] mb-3">{{ _('Chi sei?') }}</p>
|
||||||
|
<div class="grid grid-cols-2 gap-3 max-h-64 overflow-y-auto">
|
||||||
|
<template x-for="u in supervisorPinUsers" :key="u.id">
|
||||||
|
<button type="button" @click="selectSupervisorUser(u)"
|
||||||
|
class="btn btn-secondary min-h-[64px] text-base w-full" x-text="u.display_name"></button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{# pad #}
|
||||||
|
<div x-show="supervisorPinUser">
|
||||||
|
<p class="text-center font-semibold mb-1" x-text="supervisorPinUser && supervisorPinUser.display_name"></p>
|
||||||
|
<p class="text-xs text-center text-[var(--text-secondary)] mb-3">{{ _('Inserisci il PIN') }}</p>
|
||||||
|
<div class="flex justify-center gap-3 mb-3" aria-hidden="true">
|
||||||
|
<template x-for="i in 4" :key="i">
|
||||||
|
<span class="h-4 w-4 rounded-full border-2 border-[var(--border-color)]"
|
||||||
|
:class="supervisorPin.length >= i ? 'bg-primary-600 border-primary-600' : ''"></span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-3 gap-3 max-w-xs mx-auto">
|
||||||
|
<template x-for="d in ['1','2','3','4','5','6','7','8','9']" :key="d">
|
||||||
|
<button type="button" @click="pressSupervisorPin(d)" :disabled="supervisorValidating"
|
||||||
|
class="btn btn-secondary h-16 min-w-[64px] text-2xl" x-text="d"></button>
|
||||||
|
</template>
|
||||||
|
<button type="button" @click="backSupervisorUser()" :disabled="supervisorValidating"
|
||||||
|
class="btn btn-secondary h-16 min-w-[64px] text-sm">{{ _('Indietro') }}</button>
|
||||||
|
<button type="button" @click="pressSupervisorPin('0')" :disabled="supervisorValidating"
|
||||||
|
class="btn btn-secondary h-16 min-w-[64px] text-2xl">0</button>
|
||||||
|
<button type="button" @click="supervisorPin = supervisorPin.slice(0, -1)" :disabled="supervisorValidating"
|
||||||
|
class="btn btn-secondary h-16 min-w-[64px] text-sm">{{ _('Cancella') }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p x-show="supervisorError" class="text-sm text-center text-red-600 mt-3" role="alert" x-text="supervisorError"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-show="supervisorMode === 'password'" class="space-y-3 mb-5">
|
||||||
<div>
|
<div>
|
||||||
<label class="tmf-label text-xs">{{ _('Username') }}</label>
|
<label class="tmf-label text-xs">{{ _('Username') }}</label>
|
||||||
<input type="text" x-model="supervisorUsername" class="tmf-input text-sm"
|
<input type="text" x-model="supervisorUsername" class="tmf-input text-sm"
|
||||||
@@ -945,13 +1118,13 @@
|
|||||||
<span x-show="supervisorAction === 'out_of_tolerance'">{{ _('Rimisura la quota') }}</span>
|
<span x-show="supervisorAction === 'out_of_tolerance'">{{ _('Rimisura la quota') }}</span>
|
||||||
</button>
|
</button>
|
||||||
<button @click="submitSupervisor()"
|
<button @click="submitSupervisor()"
|
||||||
:disabled="!supervisorUsername || !supervisorPassword || supervisorValidating"
|
:disabled="!supervisorCanSubmit || supervisorValidating"
|
||||||
class="btn btn-primary flex-1 text-sm gap-1">
|
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">
|
<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"/>
|
<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"/>
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"/>
|
||||||
</svg>
|
</svg>
|
||||||
{{ _('Autorizza') }}
|
<span x-text="supervisorMode === 'pin' ? '{{ _('Conferma') }}' : '{{ _('Autorizza') }}'"></span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -972,6 +1145,7 @@
|
|||||||
<script>
|
<script>
|
||||||
window.__visionCfg = {
|
window.__visionCfg = {
|
||||||
executeUrl: '{{ url_for("measure.api_vision_execute") }}',
|
executeUrl: '{{ url_for("measure.api_vision_execute") }}',
|
||||||
|
grabUrl: '{{ url_for("measure.api_vision_grab") }}',
|
||||||
imageUrlTpl: '{{ url_for("measure.api_vision_result_image", result_id=0) }}',
|
imageUrlTpl: '{{ url_for("measure.api_vision_result_image", result_id=0) }}',
|
||||||
taskId: {{ task.id|tojson }},
|
taskId: {{ task.id|tojson }},
|
||||||
taskType: {{ (task.task_type or '')|tojson }},
|
taskType: {{ (task.task_type or '')|tojson }},
|
||||||
@@ -980,6 +1154,26 @@
|
|||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
<script src="{{ url_for('static', filename='js/vision_execute.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/vision_execute.js') }}"></script>
|
||||||
|
{# #53 #54 #55: result of check / note tasks (urls and words for the component) #}
|
||||||
|
<script>
|
||||||
|
window.__taskResultsCfg = {
|
||||||
|
saveUrl: '{{ url_for("measure.api_save_task_result") }}',
|
||||||
|
listUrl: '{{ url_for("measure.api_list_task_results") }}',
|
||||||
|
uploadUrl: '{{ url_for("measure.api_upload_attachment") }}',
|
||||||
|
taskUrlTpl: '{{ url_for("measure.api_get_task", task_id=0) }}',
|
||||||
|
labels: {
|
||||||
|
connection: {{ _('Errore di connessione')|tojson }},
|
||||||
|
sessionExpired: {{ _('Sessione scaduta: ricaricare la pagina')|tojson }},
|
||||||
|
serverError: {{ _('Errore di comunicazione con il server')|tojson }},
|
||||||
|
nokNeedsNote: {{ _('Per un NOK serve la nota')|tojson }},
|
||||||
|
notANumber: {{ _('Valore non numerico')|tojson }},
|
||||||
|
outcomeOk: {{ _('OK')|tojson }},
|
||||||
|
outcomeNok: {{ _('NOK')|tojson }},
|
||||||
|
outcomeDone: {{ _('Registrato')|tojson }},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<script src="{{ url_for('static', filename='js/task-results.js') }}"></script>
|
||||||
{# The clock's rules - how a delay reads, when the buzzer goes, where the
|
{# The clock's rules - how a delay reads, when the buzzer goes, where the
|
||||||
measurement lives - are shared with the other screens rather than restated. #}
|
measurement lives - are shared with the other screens rather than restated. #}
|
||||||
<script src="{{ url_for('static', filename='js/production-clock.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/production-clock.js') }}"></script>
|
||||||
@@ -1028,6 +1222,10 @@ function taskExecute() {
|
|||||||
// The measurement tasks of this recipe, in order: the loop the operator stays
|
// The measurement tasks of this recipe, in order: the loop the operator stays
|
||||||
// in once production starts, and what says which task closes a cycle.
|
// in once production starts, and what says which task closes a cycle.
|
||||||
measurementTaskIds: [],
|
measurementTaskIds: [],
|
||||||
|
// #55: notes to fill in before the capoturno closes the production, and whether
|
||||||
|
// they were already recorded (so cancelling the modal does not ask them again).
|
||||||
|
closeTaskIds: [],
|
||||||
|
closeTasksDone: false,
|
||||||
// The interval has elapsed and it has been said out loud - buzzer, and the
|
// The interval has elapsed and it has been said out loud - buzzer, and the
|
||||||
// operator on their way back to the measurement. Kept so it is said once.
|
// operator on their way back to the measurement. Kept so it is said once.
|
||||||
dueAnnounced: false,
|
dueAnnounced: false,
|
||||||
@@ -1039,6 +1237,11 @@ function taskExecute() {
|
|||||||
supervisorAction: '', // 'out_of_tolerance', 'fermo_linea', 'fine_produzione'
|
supervisorAction: '', // 'out_of_tolerance', 'fermo_linea', 'fine_produzione'
|
||||||
supervisorUsername: '',
|
supervisorUsername: '',
|
||||||
supervisorPassword: '',
|
supervisorPassword: '',
|
||||||
|
// #59: the capoturno can authorise with name + 4-digit PIN instead of the password.
|
||||||
|
supervisorMode: 'pin', // 'pin' | 'password'
|
||||||
|
supervisorPinUsers: [], // [{id, display_name}] from /measure/api/pin-users
|
||||||
|
supervisorPinUser: null,
|
||||||
|
supervisorPin: '', // never kept past the modal: cleared on open and close
|
||||||
supervisorError: '',
|
supervisorError: '',
|
||||||
supervisorValidating: false,
|
supervisorValidating: false,
|
||||||
/* The out-of-tolerance measurement waiting for the capoturno:
|
/* The out-of-tolerance measurement waiting for the capoturno:
|
||||||
@@ -1270,6 +1473,7 @@ function taskExecute() {
|
|||||||
this.productionStarted = run.status !== 'closed';
|
this.productionStarted = run.status !== 'closed';
|
||||||
this.cycleCount = run.cycle_count;
|
this.cycleCount = run.cycle_count;
|
||||||
this.measurementTaskIds = run.measurement_task_ids || [];
|
this.measurementTaskIds = run.measurement_task_ids || [];
|
||||||
|
this.closeTaskIds = run.close_task_ids || []; // #55
|
||||||
|
|
||||||
const seconds = run.seconds_to_next_measurement;
|
const seconds = run.seconds_to_next_measurement;
|
||||||
if (seconds === null || seconds === undefined) {
|
if (seconds === null || seconds === undefined) {
|
||||||
@@ -1696,6 +1900,7 @@ function taskExecute() {
|
|||||||
this.currentValue = null;
|
this.currentValue = null;
|
||||||
this.errorMessage = '';
|
this.errorMessage = '';
|
||||||
this.inputStartedAt = Date.now();
|
this.inputStartedAt = Date.now();
|
||||||
|
window.dispatchEvent(new CustomEvent('task-result-reset')); // #54: a check / note loops on the spot
|
||||||
},
|
},
|
||||||
|
|
||||||
// ---- Where the measurement lives ----
|
// ---- Where the measurement lives ----
|
||||||
@@ -1788,6 +1993,33 @@ function taskExecute() {
|
|||||||
return window.ProductionClock.format(this.timerRemaining);
|
return window.ProductionClock.format(this.timerRemaining);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/* #53: result recorded on a check / note task. In the loop of a production the
|
||||||
|
task counts as a step of the cycle exactly like a measurement task: the last
|
||||||
|
one closes the cycle (the server decides, as in confirmCycle), the others go
|
||||||
|
to the next one. If the server refuses, the panel stays with «Prosegui». */
|
||||||
|
async advanceAfterResult() {
|
||||||
|
if (this.productionRun && this.isMeasurementTaskOfRun) {
|
||||||
|
const countBefore = this.cycleCount;
|
||||||
|
const run = await this.postProduction(
|
||||||
|
'{{ url_for("measure.api_complete_cycle", run_id=0) }}'.replace('/0/', '/' + this.productionRun.id + '/'),
|
||||||
|
{ task_id: this.task.id },
|
||||||
|
);
|
||||||
|
if (!run) return;
|
||||||
|
this.adoptProductionRun(run);
|
||||||
|
if (run.cycle_count === countBefore && this.nextMeasurementTaskId) {
|
||||||
|
this.goToTask(this.nextMeasurementTaskId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.goToNextTask();
|
||||||
|
},
|
||||||
|
|
||||||
|
/* #55: the end-of-production notes are recorded: now the capoturno's modal. */
|
||||||
|
closeTasksRecorded() {
|
||||||
|
this.closeTasksDone = true;
|
||||||
|
this.openSupervisorModal('fine_produzione');
|
||||||
|
},
|
||||||
|
|
||||||
// ---- Navigate to next task (Completato) ----
|
// ---- Navigate to next task (Completato) ----
|
||||||
/* With a production open the operator stays in the measurement loop until the
|
/* With a production open the operator stays in the measurement loop until the
|
||||||
capoturno closes it: after the last measurement task they go back to the
|
capoturno closes it: after the last measurement task they go back to the
|
||||||
@@ -1828,10 +2060,17 @@ function taskExecute() {
|
|||||||
this.productionError = '{{ _("Nessuna produzione aperta su questa stazione") }}';
|
this.productionError = '{{ _("Nessuna produzione aperta su questa stazione") }}';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// #55: first the notes marked «alla fine della produzione»; the panel calls back
|
||||||
|
// here (closeTasksRecorded) when they are recorded.
|
||||||
|
if (action === 'fine_produzione' && this.closeTaskIds.length && !this.closeTasksDone) {
|
||||||
|
window.dispatchEvent(new CustomEvent('task-close-open', { detail: { ids: this.closeTaskIds } }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.supervisorAction = action;
|
this.supervisorAction = action;
|
||||||
this.supervisorUsername = '';
|
this.supervisorUsername = '';
|
||||||
this.supervisorPassword = '';
|
this.supervisorPassword = '';
|
||||||
this.supervisorError = '';
|
this.supervisorError = '';
|
||||||
|
this.resetSupervisorPin(); // #59: PIN fields cleared, names reloaded
|
||||||
this.showSupervisorModal = true;
|
this.showSupervisorModal = true;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -1840,6 +2079,68 @@ function taskExecute() {
|
|||||||
this.supervisorUsername = '';
|
this.supervisorUsername = '';
|
||||||
this.supervisorPassword = '';
|
this.supervisorPassword = '';
|
||||||
this.supervisorError = '';
|
this.supervisorError = '';
|
||||||
|
this.supervisorPin = ''; // #59: the PIN does not outlive the modal
|
||||||
|
this.supervisorPinUser = null;
|
||||||
|
},
|
||||||
|
|
||||||
|
/* #59: PIN mode of the capoturno modal. */
|
||||||
|
resetSupervisorPin() {
|
||||||
|
this.supervisorPin = '';
|
||||||
|
this.supervisorPinUser = null;
|
||||||
|
this.supervisorMode = 'pin';
|
||||||
|
this.loadSupervisorPinUsers();
|
||||||
|
},
|
||||||
|
|
||||||
|
// Names with a PIN; an empty list or a failure opens the Password tab instead.
|
||||||
|
async loadSupervisorPinUsers() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('{{ url_for("measure.api_pin_users") }}', { headers: { 'Accept': 'application/json' } });
|
||||||
|
const list = resp.ok ? await resp.json() : [];
|
||||||
|
this.supervisorPinUsers = Array.isArray(list) ? list : [];
|
||||||
|
} catch (e) {
|
||||||
|
this.supervisorPinUsers = [];
|
||||||
|
}
|
||||||
|
if (!this.supervisorPinUsers.length) this.supervisorMode = 'password';
|
||||||
|
},
|
||||||
|
|
||||||
|
setSupervisorMode(mode) {
|
||||||
|
this.supervisorMode = mode;
|
||||||
|
this.supervisorPin = '';
|
||||||
|
this.supervisorPinUser = null;
|
||||||
|
this.supervisorError = '';
|
||||||
|
},
|
||||||
|
|
||||||
|
selectSupervisorUser(u) {
|
||||||
|
this.supervisorPinUser = u;
|
||||||
|
this.supervisorPin = '';
|
||||||
|
this.supervisorError = '';
|
||||||
|
},
|
||||||
|
|
||||||
|
backSupervisorUser() {
|
||||||
|
this.supervisorPinUser = null;
|
||||||
|
this.supervisorPin = '';
|
||||||
|
this.supervisorError = '';
|
||||||
|
},
|
||||||
|
|
||||||
|
// Submits by itself on the fourth digit.
|
||||||
|
pressSupervisorPin(d) {
|
||||||
|
if (this.supervisorValidating || this.supervisorPin.length >= 4) return;
|
||||||
|
this.supervisorPin += d;
|
||||||
|
this.supervisorError = '';
|
||||||
|
if (this.supervisorPin.length === 4) this.submitSupervisor();
|
||||||
|
},
|
||||||
|
|
||||||
|
get supervisorCanSubmit() {
|
||||||
|
if (this.supervisorMode === 'pin') return !!this.supervisorPinUser && this.supervisorPin.length === 4;
|
||||||
|
return !!this.supervisorUsername && !!this.supervisorPassword;
|
||||||
|
},
|
||||||
|
|
||||||
|
// The pair the proxies forward: id + PIN in PIN mode, username + password otherwise.
|
||||||
|
supervisorCredentials() {
|
||||||
|
if (this.supervisorMode === 'pin') {
|
||||||
|
return { supervisor_user_id: this.supervisorPinUser && this.supervisorPinUser.id, supervisor_pin: this.supervisorPin };
|
||||||
|
}
|
||||||
|
return { supervisor_username: this.supervisorUsername, supervisor_password: this.supervisorPassword };
|
||||||
},
|
},
|
||||||
|
|
||||||
/* What kind of task this is, as declared in the recipe.
|
/* What kind of task this is, as declared in the recipe.
|
||||||
@@ -1859,6 +2160,19 @@ function taskExecute() {
|
|||||||
return t === 'camera_measure' || t === 'dxf_compare';
|
return t === 'camera_measure' || t === 'dxf_compare';
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/* #54: «Controllo OK/NOK» - no quotes, the result is the outcome. */
|
||||||
|
get isCheckTask() {
|
||||||
|
return this.task.task_type === 'check';
|
||||||
|
},
|
||||||
|
|
||||||
|
/* #54 #55: tasks that bring their own result panel (a check, or a note with
|
||||||
|
fields). A note without fields stays the plain «Completato» task. */
|
||||||
|
get hasResultPanel() {
|
||||||
|
if (this.isCheckTask) return true;
|
||||||
|
return this.task.task_type === 'note' && Array.isArray(this.task.fields)
|
||||||
|
&& this.task.fields.length > 0;
|
||||||
|
},
|
||||||
|
|
||||||
/* A measurement task with no quotes yet: the recipe is unfinished. Said out
|
/* A measurement task with no quotes yet: the recipe is unfinished. Said out
|
||||||
loud rather than silently rendering an empty screen. */
|
loud rather than silently rendering an empty screen. */
|
||||||
get isMeasureTaskWithoutQuotes() {
|
get isMeasureTaskWithoutQuotes() {
|
||||||
@@ -1910,16 +2224,15 @@ function taskExecute() {
|
|||||||
const resp = await fetch(url, {
|
const resp = await fetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken },
|
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(this.supervisorCredentials()), // #59: PIN or password
|
||||||
supervisor_username: this.supervisorUsername,
|
|
||||||
supervisor_password: this.supervisorPassword,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
const data = await resp.json().catch(() => null);
|
const data = await resp.json().catch(() => null);
|
||||||
|
|
||||||
if (!resp.ok || (data && data.error)) {
|
if (!resp.ok || (data && data.error)) {
|
||||||
this.supervisorError = (data && data.detail)
|
this.supervisorError = (data && data.detail)
|
||||||
|
|| (this.supervisorMode === 'pin' && resp.status === 401 ? '{{ _("PIN errato") }}' : '')
|
||||||
|| '{{ _("Credenziali non valide o utente non autorizzato") }}';
|
|| '{{ _("Credenziali non valide o utente non autorizzato") }}';
|
||||||
|
if (this.supervisorMode === 'pin') this.supervisorPin = ''; // #59: wrong PIN stays in the modal, pad cleared
|
||||||
this.supervisorValidating = false;
|
this.supervisorValidating = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1954,10 +2267,7 @@ function taskExecute() {
|
|||||||
|
|
||||||
const base = '{{ url_for("measure.api_pause_production", run_id=0) }}'
|
const base = '{{ url_for("measure.api_pause_production", run_id=0) }}'
|
||||||
.replace('/0/pause', '/' + this.productionRun.id + '/' + action);
|
.replace('/0/pause', '/' + this.productionRun.id + '/' + action);
|
||||||
const run = await this.postProduction(base, {
|
const run = await this.postProduction(base, this.supervisorCredentials()); // #59: PIN or password
|
||||||
supervisor_username: this.supervisorUsername,
|
|
||||||
supervisor_password: this.supervisorPassword,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.supervisorValidating = false;
|
this.supervisorValidating = false;
|
||||||
if (!run) {
|
if (!run) {
|
||||||
@@ -1965,6 +2275,7 @@ function taskExecute() {
|
|||||||
// capoturno sees why the action was refused, rather than a silent no-op.
|
// capoturno sees why the action was refused, rather than a silent no-op.
|
||||||
this.supervisorError = this.productionError || '{{ _("Azione non riuscita") }}';
|
this.supervisorError = this.productionError || '{{ _("Azione non riuscita") }}';
|
||||||
this.productionError = '';
|
this.productionError = '';
|
||||||
|
if (this.supervisorMode === 'pin') this.supervisorPin = ''; // #59: wrong PIN / lockout: pad cleared, modal stays
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -129,6 +129,74 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- #58: esiti, note, campi e allegati dei task della sessione scelta -->
|
||||||
|
<div id="run-task-results" x-show="selectedRunId" x-cloak
|
||||||
|
class="bg-[var(--bg-secondary)] rounded-xl border border-[var(--border-color)] p-5 mb-6 shadow-sm">
|
||||||
|
<h2 class="text-sm font-semibold text-steel uppercase tracking-wider mb-4">{{ _("Esiti dei task della sessione") }}</h2>
|
||||||
|
<p x-show="taskResultsLoading" class="text-sm text-steel">{{ _("Caricamento...") }}</p>
|
||||||
|
<p x-show="taskResultsError" x-text="taskResultsError" class="text-sm text-red-600"></p>
|
||||||
|
<template x-if="taskResults && !taskResultsLoading">
|
||||||
|
<div>
|
||||||
|
<div class="grid grid-cols-3 gap-3 mb-4 max-w-md">
|
||||||
|
<div class="text-center p-3 rounded-lg bg-emerald-50 dark:bg-emerald-900/20">
|
||||||
|
<div class="text-2xl font-bold font-mono text-emerald-600" x-text="taskResults.counts.ok"></div>
|
||||||
|
<div class="text-xs text-emerald-700 dark:text-emerald-400 mt-1">{{ _("OK") }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-center p-3 rounded-lg bg-red-50 dark:bg-red-900/20">
|
||||||
|
<div class="text-2xl font-bold font-mono text-red-600" x-text="taskResults.counts.nok"></div>
|
||||||
|
<div class="text-xs text-red-700 dark:text-red-400 mt-1">{{ _("NOK") }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-center p-3 rounded-lg bg-[var(--bg-primary)]">
|
||||||
|
<div class="text-2xl font-bold font-mono text-[var(--text-primary)]" x-text="taskResults.counts.done"></div>
|
||||||
|
<div class="text-xs text-steel mt-1">{{ _("Fatto") }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p x-show="taskResults.results.length === 0" class="text-sm text-steel">{{ _("Nessun esito registrato in questa sessione") }}</p>
|
||||||
|
<div x-show="taskResults.results.length > 0" class="overflow-x-auto">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="text-left text-xs text-steel uppercase border-b border-[var(--border-color)]">
|
||||||
|
<th class="py-2 pr-3">{{ _("Ora") }}</th>
|
||||||
|
<th class="py-2 pr-3">{{ _("Task") }}</th>
|
||||||
|
<th class="py-2 pr-3">{{ _("Esito") }}</th>
|
||||||
|
<th class="py-2 pr-3">{{ _("Note") }}</th>
|
||||||
|
<th class="py-2 pr-3">{{ _("Campi") }}</th>
|
||||||
|
<th class="py-2 pr-3">{{ _("Allegati") }}</th>
|
||||||
|
<th class="py-2">{{ _("Operatore") }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<template x-for="r in taskResults.results" :key="r.id">
|
||||||
|
<tr class="border-b border-[var(--border-color)] align-top">
|
||||||
|
<td class="py-2 pr-3 font-mono whitespace-nowrap" x-text="resultTime(r.created_at)"></td>
|
||||||
|
<td class="py-2 pr-3" x-text="r.task_title"></td>
|
||||||
|
<td class="py-2 pr-3">
|
||||||
|
<span class="px-2 py-0.5 rounded text-xs font-semibold"
|
||||||
|
:class="r.outcome === 'nok' ? 'bg-red-600 text-white' : (r.outcome === 'ok' ? 'bg-emerald-100 text-emerald-700' : 'bg-gray-200 text-gray-700')"
|
||||||
|
x-text="outcomeLabel(r.outcome)"></span>
|
||||||
|
</td>
|
||||||
|
<td class="py-2 pr-3 whitespace-pre-line" x-text="r.note"></td>
|
||||||
|
<td class="py-2 pr-3">
|
||||||
|
<template x-for="v in r.values" :key="v.key">
|
||||||
|
<div><span class="text-steel" x-text="v.label + ': '"></span><span x-text="v.value"></span></div>
|
||||||
|
</template>
|
||||||
|
</td>
|
||||||
|
<td class="py-2 pr-3">
|
||||||
|
<template x-for="a in r.attachments" :key="a.path">
|
||||||
|
<div><a :href="attachmentUrl(a.path)" target="_blank" rel="noopener"
|
||||||
|
class="text-primary hover:underline" x-text="a.name || a.label"></a></div>
|
||||||
|
</template>
|
||||||
|
</td>
|
||||||
|
<td class="py-2" x-text="r.user_name"></td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Nessuna ricetta selezionata -->
|
<!-- Nessuna ricetta selezionata -->
|
||||||
<template x-if="!selectedRecipeId && !loading">
|
<template x-if="!selectedRecipeId && !loading">
|
||||||
<div class="text-center py-16 text-steel">
|
<div class="text-center py-16 text-steel">
|
||||||
@@ -290,8 +358,52 @@ function spcDashboard() {
|
|||||||
reportError: '',
|
reportError: '',
|
||||||
downloadingReport: false,
|
downloadingReport: false,
|
||||||
|
|
||||||
|
// #58: esiti dei task della sessione scelta
|
||||||
|
taskResults: null,
|
||||||
|
taskResultsLoading: false,
|
||||||
|
taskResultsError: '',
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
// Nothing to pre-load
|
// #58: il pannello esiti si ricarica a ogni cambio di sessione
|
||||||
|
this.$watch('selectedRunId', () => this.loadTaskResults());
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadTaskResults() {
|
||||||
|
this.taskResults = null;
|
||||||
|
this.taskResultsError = '';
|
||||||
|
const runId = this.selectedRunId;
|
||||||
|
if (!runId) return;
|
||||||
|
this.taskResultsLoading = true;
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/statistics/api/runs/' + runId + '/task-results');
|
||||||
|
const data = await resp.json();
|
||||||
|
// #58: se nel frattempo la sessione e' cambiata, il risultato e' vecchio
|
||||||
|
if (runId !== this.selectedRunId) return;
|
||||||
|
if (!resp.ok || data.error) {
|
||||||
|
this.taskResultsError = data.detail || {{ _("Errore nel caricamento dei dati")|tojson }};
|
||||||
|
} else {
|
||||||
|
this.taskResults = data;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error loading task results:', e);
|
||||||
|
this.taskResultsError = {{ _("Errore di connessione al server")|tojson }};
|
||||||
|
} finally {
|
||||||
|
if (runId === this.selectedRunId) this.taskResultsLoading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// #58: ora locale dell'esito (created_at e' UTC senza suffisso)
|
||||||
|
resultTime(iso) {
|
||||||
|
return iso ? new Date(iso + (iso.endsWith('Z') ? '' : 'Z')).toLocaleString() : '';
|
||||||
|
},
|
||||||
|
|
||||||
|
outcomeLabel(o) {
|
||||||
|
return o === 'ok' ? {{ _("OK")|tojson }} : (o === 'nok' ? {{ _("NOK")|tojson }} : {{ _("Fatto")|tojson }});
|
||||||
|
},
|
||||||
|
|
||||||
|
// #58: gli allegati passano dal proxy del blueprint, il browser non manda X-API-Key
|
||||||
|
attachmentUrl(path) {
|
||||||
|
return '/statistics/api/files/' + String(path).split('/').map(encodeURIComponent).join('/');
|
||||||
},
|
},
|
||||||
|
|
||||||
async onRecipeChange() {
|
async onRecipeChange() {
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
// Behaviour test for static/js/task-results.js (#53 #54 #55), plain node, no framework.
|
||||||
|
// Run: node tests/js/test_task_results.js (exit code != 0 on failure)
|
||||||
|
const assert = require('assert');
|
||||||
|
const { taskResultForm } = require('../../static/js/task-results.js');
|
||||||
|
|
||||||
|
const calls = [];
|
||||||
|
let responses = [];
|
||||||
|
global.window = {
|
||||||
|
__taskResultsCfg: {
|
||||||
|
saveUrl: '/s', listUrl: '/l', uploadUrl: '/u', taskUrlTpl: '/t/0',
|
||||||
|
labels: { nokNeedsNote: 'Per un NOK serve la nota' },
|
||||||
|
},
|
||||||
|
sessionStorage: { _d: {}, getItem(k) { return this._d[k] || null; }, setItem(k, v) { this._d[k] = v; } },
|
||||||
|
};
|
||||||
|
global.FormData = class { constructor() { this.d = []; } append(k, v, n) { this.d.push([k, v, n]); } };
|
||||||
|
global.fetch = async (url, opts) => {
|
||||||
|
calls.push({ url, opts });
|
||||||
|
const r = responses.shift() || { status: 200, body: {} };
|
||||||
|
return { ok: r.status < 400, status: r.status, json: async () => r.body };
|
||||||
|
};
|
||||||
|
|
||||||
|
function comp(runId) {
|
||||||
|
const c = taskResultForm();
|
||||||
|
c.ctx = () => ({ runId, done() { c._done = (c._done || 0) + 1; } });
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
// check: NOK without note is refused before any request
|
||||||
|
let c = comp(4);
|
||||||
|
c.initForTask({ id: 30, task_type: 'check' });
|
||||||
|
responses = [{ status: 200, body: [] }]; // loadLast
|
||||||
|
await c.submit('nok');
|
||||||
|
assert.strictEqual(c.forms[0].error, 'Per un NOK serve la nota');
|
||||||
|
assert.ok(!calls.some((x) => x.url === '/s'), 'no POST for a NOK without note');
|
||||||
|
|
||||||
|
// check: OK goes to the server with the run id, then hands over
|
||||||
|
calls.length = 0;
|
||||||
|
responses = [{ status: 201, body: { id: 1, outcome: 'ok' } }];
|
||||||
|
await c.submit('ok');
|
||||||
|
const post = calls.find((x) => x.url === '/s');
|
||||||
|
const body = JSON.parse(post.opts.body);
|
||||||
|
assert.deepStrictEqual([body.task_id, body.production_run_id, body.outcome, body.values], [30, 4, 'ok', null]);
|
||||||
|
assert.strictEqual(c._done, 1);
|
||||||
|
assert.strictEqual(c.forms[0].result.outcome, 'ok');
|
||||||
|
|
||||||
|
// server 422: the detail is shown, nothing is handed over
|
||||||
|
c = comp(4);
|
||||||
|
responses = [{ status: 200, body: [] }, { status: 422, body: { detail: 'Per un NOK serve la nota' } }];
|
||||||
|
c.initForTask({ id: 30, task_type: 'check' }); // consumes the first one (loadLast)
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
c.forms[0].note = 'x';
|
||||||
|
await c.submit('nok');
|
||||||
|
assert.strictEqual(c.forms[0].error, 'Per un NOK serve la nota');
|
||||||
|
assert.ok(!c._done);
|
||||||
|
|
||||||
|
// note with fields: defaults, upload of the attachment, values + attachments in the result
|
||||||
|
calls.length = 0;
|
||||||
|
c = comp(null);
|
||||||
|
const task = { id: 31, task_type: 'note', fields: [
|
||||||
|
{ key: 'peso', label: 'Peso', type: 'number', default: '1,5', attachment: false },
|
||||||
|
{ key: 'foto', label: 'Foto', type: 'text', default: null, attachment: true },
|
||||||
|
] };
|
||||||
|
c.initForTask(task);
|
||||||
|
assert.strictEqual(c.forms[0].values.peso, '1,5');
|
||||||
|
c.forms[0].files.foto = { name: 'a.png' };
|
||||||
|
responses = [
|
||||||
|
{ status: 201, body: { file_path: 'general/a.png' } },
|
||||||
|
{ status: 201, body: { id: 2, outcome: 'done' } },
|
||||||
|
];
|
||||||
|
await c.submit('done');
|
||||||
|
const saved = JSON.parse(calls.find((x) => x.url === '/s').opts.body);
|
||||||
|
assert.deepStrictEqual(saved.values, { peso: '1.5' });
|
||||||
|
assert.deepStrictEqual(saved.attachments, [{ key: 'foto', path: 'general/a.png', name: 'a.png' }]);
|
||||||
|
assert.strictEqual(saved.outcome, 'done');
|
||||||
|
assert.strictEqual(saved.production_run_id, null);
|
||||||
|
|
||||||
|
// non numeric value in a number field is refused locally
|
||||||
|
c = comp(null);
|
||||||
|
c.initForTask(task);
|
||||||
|
c.forms[0].values.peso = 'abc';
|
||||||
|
await c.submit('done');
|
||||||
|
assert.ok(c.forms[0].error.indexOf('Peso') !== -1);
|
||||||
|
|
||||||
|
// a note without fields has no panel
|
||||||
|
c = comp(null);
|
||||||
|
c.initForTask({ id: 33, task_type: 'note', fields: [] });
|
||||||
|
assert.strictEqual(c.forms.length, 0);
|
||||||
|
|
||||||
|
// end of production: tasks already recorded in the run are skipped; the rest recorded then handed over
|
||||||
|
calls.length = 0;
|
||||||
|
c = comp(4);
|
||||||
|
responses = [
|
||||||
|
{ status: 200, body: [{ task_id: 40 }] }, // results of the run
|
||||||
|
{ status: 200, body: { id: 40, title: 'A', task_type: 'note', fields: [] } },
|
||||||
|
{ status: 200, body: { id: 41, title: 'B', task_type: 'note', fields: [{ key: 'k', label: 'K', type: 'text', default: null, attachment: false }] } },
|
||||||
|
];
|
||||||
|
await c.openClose([40, 41]);
|
||||||
|
assert.strictEqual(c.forms[0].recorded, true);
|
||||||
|
assert.strictEqual(c.forms[1].recorded, false);
|
||||||
|
assert.ok(c.open && !c._done);
|
||||||
|
c.forms[1].values.k = 'ok';
|
||||||
|
responses = [{ status: 201, body: { id: 5, outcome: 'done' } }];
|
||||||
|
await c.submitClose();
|
||||||
|
assert.strictEqual(c._done, 1);
|
||||||
|
assert.strictEqual(c.open, false);
|
||||||
|
assert.strictEqual(calls.filter((x) => x.url === '/s').length, 1, 'only the pending task is posted');
|
||||||
|
|
||||||
|
console.log('ok');
|
||||||
|
})().catch((e) => { console.error(e); process.exit(1); });
|
||||||
@@ -176,3 +176,81 @@ def test_station_list_page_renders_assigned_recipes(logged_in_client, mock_admin
|
|||||||
resp = logged_in_client.get("/admin/stations")
|
resp = logged_in_client.get("/admin/stations")
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert b"REC-ABC" in resp.data
|
assert b"REC-ABC" in resp.data
|
||||||
|
|
||||||
|
|
||||||
|
# --- Worker visione per stazione (#45) ---
|
||||||
|
|
||||||
|
def test_station_form_has_the_vision_worker_field(logged_in_client, mock_admin_api):
|
||||||
|
mock_admin_api.get.return_value = []
|
||||||
|
resp = logged_in_client.get("/admin/stations")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert b"form.vision_worker_url" in resp.data
|
||||||
|
# etichetta in italiano o in inglese, secondo il catalogo compilato
|
||||||
|
assert b"Worker visione (URL)" in resp.data or b"Vision worker (URL)" in resp.data
|
||||||
|
|
||||||
|
|
||||||
|
def test_station_list_exposes_the_vision_worker_url(logged_in_client, mock_admin_api):
|
||||||
|
mock_admin_api.get.side_effect = [
|
||||||
|
[{"id": 1, "code": "ST-001", "name": "Linea A", "location": None,
|
||||||
|
"notes": None, "active": True, "vision_worker_url": "http://10.0.0.7:8100",
|
||||||
|
"created_by": 1, "created_at": "2026-04-25T10:00:00"}],
|
||||||
|
[],
|
||||||
|
]
|
||||||
|
resp = logged_in_client.get("/admin/stations")
|
||||||
|
assert b"http://10.0.0.7:8100" in resp.data # nel JSON iniziale della pagina
|
||||||
|
|
||||||
|
|
||||||
|
def test_station_proxies_pass_the_vision_worker_url_untouched(logged_in_client, mock_admin_api):
|
||||||
|
mock_admin_api.post.return_value = {"id": 7, "code": "ST-7", "name": "n"}
|
||||||
|
mock_admin_api.put.return_value = {"id": 7, "code": "ST-7", "name": "n"}
|
||||||
|
logged_in_client.post(
|
||||||
|
"/admin/api/stations",
|
||||||
|
json={"code": "ST-7", "name": "n", "vision_worker_url": "http://w:8100"},
|
||||||
|
)
|
||||||
|
mock_admin_api.post.assert_called_once_with(
|
||||||
|
"/api/stations",
|
||||||
|
data={"code": "ST-7", "name": "n", "vision_worker_url": "http://w:8100"},
|
||||||
|
)
|
||||||
|
logged_in_client.put("/admin/api/stations/7", json={"vision_worker_url": None})
|
||||||
|
mock_admin_api.put.assert_called_once_with(
|
||||||
|
"/api/stations/7", data={"vision_worker_url": None},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Camera per stazione (#72) ---
|
||||||
|
|
||||||
|
def test_station_form_has_the_camera_fields(logged_in_client, mock_admin_api):
|
||||||
|
mock_admin_api.get.return_value = []
|
||||||
|
html = logged_in_client.get("/admin/stations").get_data(as_text=True)
|
||||||
|
assert "form.camera_url" in html
|
||||||
|
assert "form.camera_source" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_station_list_exposes_the_camera_fields(logged_in_client, mock_admin_api):
|
||||||
|
mock_admin_api.get.side_effect = [
|
||||||
|
[{"id": 1, "code": "ST-001", "name": "Linea A", "location": None,
|
||||||
|
"notes": None, "active": True,
|
||||||
|
"camera_url": "http://10.0.0.9:8765", "camera_source": "camera:folder",
|
||||||
|
"created_by": 1, "created_at": "2026-04-25T10:00:00"}],
|
||||||
|
[],
|
||||||
|
]
|
||||||
|
html = logged_in_client.get("/admin/stations").get_data(as_text=True)
|
||||||
|
assert "http://10.0.0.9:8765" in html
|
||||||
|
assert "camera:folder" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_station_payload_script_sends_the_camera_fields(logged_in_client, mock_admin_api):
|
||||||
|
mock_admin_api.get.return_value = []
|
||||||
|
html = logged_in_client.get("/admin/stations").get_data(as_text=True)
|
||||||
|
# default, reset, carica, crea, aggiorna: ogni punto che già porta vision_worker_url
|
||||||
|
assert html.count("camera_url") >= html.count("vision_worker_url")
|
||||||
|
assert html.count("camera_source") >= html.count("vision_worker_url")
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_station_proxy_forwards_the_camera_fields(logged_in_client, mock_admin_api):
|
||||||
|
mock_admin_api.post.return_value = {"id": 5, "code": "ST-005", "name": "N"}
|
||||||
|
body = {"code": "ST-005", "name": "N",
|
||||||
|
"camera_url": "http://h:8765", "camera_source": "camera:folder"}
|
||||||
|
resp = logged_in_client.post("/admin/api/stations", json=body)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
mock_admin_api.post.assert_called_once_with("/api/stations", data=body)
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
"""#59/#51/#57: PIN nella gestione utenti (proxy admin, pannello) e link Monitor nel menu."""
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tests.conftest import api_get_router
|
||||||
|
|
||||||
|
USER = {
|
||||||
|
"id": 2, "username": "mario", "display_name": "Mario", "email": None,
|
||||||
|
"roles": ["MeasurementTec"], "is_admin": False, "language_pref": "it",
|
||||||
|
"theme_pref": "light", "active": True, "has_pin": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_admin_api():
|
||||||
|
mock = MagicMock()
|
||||||
|
mock.get.side_effect = api_get_router({
|
||||||
|
"/api/users": [USER],
|
||||||
|
"/api/settings": {"auto_logout_minutes": "30"},
|
||||||
|
})
|
||||||
|
with patch("blueprints.admin.api_client", mock):
|
||||||
|
yield mock
|
||||||
|
|
||||||
|
|
||||||
|
def test_pin_proxy_forwards_four_digits(logged_in_client, mock_admin_api):
|
||||||
|
mock_admin_api.put.return_value = {"message": "ok"}
|
||||||
|
resp = logged_in_client.put("/admin/api/users/2/pin", json={"pin": "1234"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
mock_admin_api.put.assert_called_once_with("/api/users/2/pin", data={"pin": "1234"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_pin_proxy_forwards_null_to_remove(logged_in_client, mock_admin_api):
|
||||||
|
mock_admin_api.put.return_value = {"message": "ok"}
|
||||||
|
resp = logged_in_client.put("/admin/api/users/2/pin", json={"pin": None})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
mock_admin_api.put.assert_called_once_with("/api/users/2/pin", data={"pin": None})
|
||||||
|
|
||||||
|
|
||||||
|
def test_pin_proxy_passes_422_detail(logged_in_client, mock_admin_api):
|
||||||
|
mock_admin_api.put.return_value = {
|
||||||
|
"error": True, "status_code": 422, "detail": "Gli admin non hanno il PIN",
|
||||||
|
}
|
||||||
|
resp = logged_in_client.put("/admin/api/users/1/pin", json={"pin": "1234"})
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert resp.get_json()["detail"] == "Gli admin non hanno il PIN"
|
||||||
|
|
||||||
|
|
||||||
|
def test_pin_proxy_requires_admin(client, mock_admin_api):
|
||||||
|
with client.session_transaction() as sess:
|
||||||
|
sess["api_key"] = "k"
|
||||||
|
sess["user"] = {"id": 3, "username": "op", "roles": ["MeasurementTec"],
|
||||||
|
"is_admin": False, "language_pref": "it", "theme_pref": "light"}
|
||||||
|
resp = client.put("/admin/api/users/2/pin", json={"pin": "1234"})
|
||||||
|
assert resp.status_code != 200
|
||||||
|
mock_admin_api.put.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_users_panel_has_pin_controls(logged_in_client, mock_admin_api):
|
||||||
|
html = logged_in_client.get("/admin/settings?tab=utenti").data.decode("utf-8")
|
||||||
|
assert "openPin(user)" in html # pulsante Imposta/Reset PIN
|
||||||
|
assert "user.has_pin === true" in html # indicatore PIN impostato
|
||||||
|
assert "removePin()" in html # Togli PIN
|
||||||
|
assert "form.pin" in html # campo PIN alla creazione
|
||||||
|
assert "!user.is_admin" in html # niente PIN per gli admin
|
||||||
|
|
||||||
|
|
||||||
|
def _session_client(client, roles, is_admin):
|
||||||
|
with client.session_transaction() as sess:
|
||||||
|
sess["api_key"] = "k"
|
||||||
|
sess["user"] = {"id": 5, "username": "u", "display_name": "U", "roles": roles,
|
||||||
|
"is_admin": is_admin, "language_pref": "it", "theme_pref": "light"}
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_monitor_api():
|
||||||
|
mock = MagicMock()
|
||||||
|
mock.get.return_value = []
|
||||||
|
with patch("blueprints.monitor.api_client", mock), \
|
||||||
|
patch("blueprints.measure.api_client", mock):
|
||||||
|
yield mock
|
||||||
|
|
||||||
|
|
||||||
|
def test_monitor_link_for_admin(logged_in_client, mock_monitor_api):
|
||||||
|
html = logged_in_client.get("/monitor").data.decode("utf-8")
|
||||||
|
assert 'href="/monitor"' in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_monitor_link_for_supervisor(client, mock_monitor_api):
|
||||||
|
_session_client(client, ["Supervisor"], False)
|
||||||
|
html = client.get("/monitor").data.decode("utf-8")
|
||||||
|
assert 'href="/monitor"' in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_monitor_link_absent_for_operator(client, mock_api_client, monkeypatch):
|
||||||
|
# Stessa preparazione di test_measure.py: la pagina vuole STATION_CODE a config ricaricata
|
||||||
|
monkeypatch.setenv("STATION_CODE", "ST-TEST")
|
||||||
|
import importlib
|
||||||
|
import config
|
||||||
|
importlib.reload(config)
|
||||||
|
import blueprints.measure
|
||||||
|
importlib.reload(blueprints.measure)
|
||||||
|
mock_api_client.get_station_recipes.return_value = []
|
||||||
|
_session_client(client, ["MeasurementTec"], False)
|
||||||
|
resp = client.get("/measure/select")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
html = resp.data.decode("utf-8")
|
||||||
|
assert "/measure/select" in html or "Misure" in html or "Measure" in html # menu reso
|
||||||
|
assert 'href="/monitor"' not in html
|
||||||
@@ -13,3 +13,12 @@ def test_get_station_recipes_calls_correct_endpoint():
|
|||||||
called_args, called_kwargs = mock_get.call_args
|
called_args, called_kwargs = mock_get.call_args
|
||||||
assert called_args[0] == "/api/stations/by-code/ST-001/recipes"
|
assert called_args[0] == "/api/stations/by-code/ST-001/recipes"
|
||||||
assert result == [{"id": 1, "code": "R1", "name": "R1", "active": True}]
|
assert result == [{"id": 1, "code": "R1", "name": "R1", "active": True}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_station_by_code_calls_correct_endpoint():
|
||||||
|
client = APIClient()
|
||||||
|
with patch.object(client, "get") as mock_get:
|
||||||
|
mock_get.return_value = {"id": 1, "code": "ST-001", "camera_url": None}
|
||||||
|
result = client.get_station_by_code("ST-001")
|
||||||
|
assert mock_get.call_args[0][0] == "/api/stations/by-code/ST-001"
|
||||||
|
assert result["code"] == "ST-001"
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""Versione e commit nel piede di pagina (#70)."""
|
||||||
|
import tomllib
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from config import Config
|
||||||
|
|
||||||
|
_PYPROJECT = Path(__file__).resolve().parents[4] / "pyproject.toml"
|
||||||
|
|
||||||
|
|
||||||
|
def _pyproject_version() -> str:
|
||||||
|
with _PYPROJECT.open("rb") as fh:
|
||||||
|
return tomllib.load(fh)["project"]["version"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_version_matches_pyproject():
|
||||||
|
# La versione mostrata e' quella di pyproject.toml, non un valore a mano
|
||||||
|
assert Config.APP_VERSION == _pyproject_version()
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_page_shows_version(client):
|
||||||
|
resp = client.get("/auth/login")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert f"v{_pyproject_version()}" in resp.get_data(as_text=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_page_shows_commit_when_set(client, monkeypatch):
|
||||||
|
# Commit valorizzato (gia' troncato a 7 da Config): compare nel footer
|
||||||
|
monkeypatch.setattr(Config, "APP_COMMIT", "abc1234")
|
||||||
|
assert "abc1234" in client.get("/auth/login").get_data(as_text=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_page_hides_commit_when_empty(client, monkeypatch):
|
||||||
|
# Build senza APP_COMMIT: solo versione, nessun separatore orfano
|
||||||
|
monkeypatch.setattr(Config, "APP_COMMIT", "")
|
||||||
|
html = client.get("/auth/login").get_data(as_text=True)
|
||||||
|
assert f"v{_pyproject_version()}" in html
|
||||||
|
assert f"v{_pyproject_version()} ·" not in html
|
||||||
@@ -32,6 +32,10 @@ def _views():
|
|||||||
rel = path.relative_to(TEMPLATES).as_posix()
|
rel = path.relative_to(TEMPLATES).as_posix()
|
||||||
if rel.startswith("components/") or rel == "base.html":
|
if rel.startswith("components/") or rel == "base.html":
|
||||||
continue
|
continue
|
||||||
|
# #65: i parziali (nome che comincia per "_") sono inclusi da un'altra vista
|
||||||
|
# e stanno dentro la sua cornice .tmf-page: da soli non ne hanno una.
|
||||||
|
if path.name.startswith("_"):
|
||||||
|
continue
|
||||||
yield rel, path
|
yield rel, path
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""Editor task del Maker: tipo check, in_loop, at_close, campi della nota (#53 #54 #55)."""
|
||||||
|
import re
|
||||||
|
|
||||||
|
from tests.conftest import api_get_router
|
||||||
|
|
||||||
|
|
||||||
|
def _editor_html(logged_in_client, mock_api_client, tasks):
|
||||||
|
mock_api_client.get.side_effect = api_get_router({
|
||||||
|
"/api/recipes/3": {"id": 3, "code": "R", "name": "R"},
|
||||||
|
"/api/recipes/3/tasks": tasks,
|
||||||
|
})
|
||||||
|
resp = logged_in_client.get("/maker/recipes/3/tasks")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
return resp.get_data(as_text=True)
|
||||||
|
|
||||||
|
|
||||||
|
class TestTaskEditorFields:
|
||||||
|
def test_check_option_in_both_selects(self, logged_in_client, mock_api_client):
|
||||||
|
"""#54: il tipo check e presente nella select di creazione e in quella di modifica."""
|
||||||
|
html = _editor_html(logged_in_client, mock_api_client, [{
|
||||||
|
"id": 11, "title": "T", "task_type": "note", "subtasks": [],
|
||||||
|
"file_path": None, "order_index": 1,
|
||||||
|
}])
|
||||||
|
assert html.count('<option value="check">') == 2
|
||||||
|
assert "Controllo OK/NOK" in html
|
||||||
|
|
||||||
|
def test_loop_and_note_controls_present(self, logged_in_client, mock_api_client):
|
||||||
|
"""#53 #55: checkbox del ciclo, a fine produzione e editor dei campi in entrambi i form."""
|
||||||
|
html = _editor_html(logged_in_client, mock_api_client, [{
|
||||||
|
"id": 11, "title": "T", "task_type": "note", "subtasks": [],
|
||||||
|
"file_path": None, "order_index": 1, "in_loop": True,
|
||||||
|
}])
|
||||||
|
assert "Nel ciclo di misura (timer e cicalino)" in html
|
||||||
|
assert "Compila a fine produzione" in html
|
||||||
|
assert 'x-model="newTask.in_loop"' in html
|
||||||
|
assert 'x-model="editTaskData.in_loop"' in html
|
||||||
|
assert "addNoteField(newTask)" in html
|
||||||
|
assert "addNoteField(editTaskData)" in html
|
||||||
|
# segno sui task nel ciclo, e niente quote per check
|
||||||
|
assert 'x-show="task.in_loop"' in html
|
||||||
|
assert "task.task_type !== 'check'" in html
|
||||||
|
|
||||||
|
def test_save_bodies_carry_new_fields(self, logged_in_client, mock_api_client):
|
||||||
|
"""Creazione e modifica inviano in_loop, at_close e fields nel corpo."""
|
||||||
|
html = _editor_html(logged_in_client, mock_api_client, [])
|
||||||
|
assert len(re.findall(r"in_loop: !!this\.(newTask|editTaskData)\.in_loop", html)) == 2
|
||||||
|
assert len(re.findall(r"fields: fp\.fields", html)) == 2
|
||||||
|
assert len(re.findall(r"at_close: this\.(newTask|editTaskData)\.task_type === 'note'", html)) == 2
|
||||||
|
|
||||||
|
|
||||||
|
class TestTaskProxyForwardsFields:
|
||||||
|
"""I proxy inoltrano il corpo cosi com'e: in_loop/at_close/fields arrivano al backend."""
|
||||||
|
|
||||||
|
BODY = {
|
||||||
|
"title": "Note finali", "task_type": "note", "in_loop": False, "at_close": True,
|
||||||
|
"fields": [{"key": "operatore", "label": "Operatore", "type": "text",
|
||||||
|
"default": None, "attachment": False}],
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_create_forwards(self, logged_in_client, mock_api_client):
|
||||||
|
mock_api_client.post.return_value = {"id": 5, **self.BODY}
|
||||||
|
resp = logged_in_client.post("/maker/api/recipes/3/tasks", json=self.BODY)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
mock_api_client.post.assert_called_once_with("/api/recipes/3/tasks", data=self.BODY)
|
||||||
|
|
||||||
|
def test_update_forwards(self, logged_in_client, mock_api_client):
|
||||||
|
mock_api_client.put.return_value = {"id": 5, **self.BODY}
|
||||||
|
resp = logged_in_client.put("/maker/api/tasks/5", json=self.BODY)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
mock_api_client.put.assert_called_once_with("/api/tasks/5", data=self.BODY)
|
||||||
|
|
||||||
|
def test_update_422_detail_returned(self, logged_in_client, mock_api_client):
|
||||||
|
"""Un 422 del backend arriva al browser con il suo detail e lo status."""
|
||||||
|
mock_api_client.put.return_value = {
|
||||||
|
"error": True, "status_code": 422, "detail": "Massimo 20 campi",
|
||||||
|
}
|
||||||
|
resp = logged_in_client.put("/maker/api/tasks/5", json=self.BODY)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert resp.get_json()["detail"] == "Massimo 20 campi"
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
"""gitea:Adriano/TieMeasureFlow#72 - frame grab from the station camera.
|
||||||
|
|
||||||
|
The backend (station.camera_url / camera_source, GET /api/stations/by-code/{code})
|
||||||
|
is built elsewhere: api_client and requests are mocked here.
|
||||||
|
"""
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from tests.conftest import api_get_router
|
||||||
|
|
||||||
|
STATION = {
|
||||||
|
"id": 1, "code": "ST-TEST", "name": "Linea",
|
||||||
|
"camera_url": "http://host.docker.internal:8765",
|
||||||
|
"camera_source": "camera:folder",
|
||||||
|
}
|
||||||
|
PNG = b"\x89PNG\r\n\x1a\n fake"
|
||||||
|
|
||||||
|
|
||||||
|
def _fake(status=200, content=PNG, ctype="image/png", json_body=None):
|
||||||
|
resp = MagicMock(status_code=status, content=content, text=content.decode("latin-1"))
|
||||||
|
resp.headers = {"content-type": ctype}
|
||||||
|
if json_body is None:
|
||||||
|
resp.json.side_effect = ValueError("no json")
|
||||||
|
else:
|
||||||
|
resp.json.return_value = json_body
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
def _station(mock_api_client, monkeypatch, station):
|
||||||
|
monkeypatch.setattr("blueprints.measure.Config.STATION_CODE", "ST-TEST")
|
||||||
|
mock_api_client.get_station_by_code.return_value = station
|
||||||
|
|
||||||
|
|
||||||
|
def test_grab_without_camera_is_409(logged_in_client, mock_api_client, monkeypatch):
|
||||||
|
_station(mock_api_client, monkeypatch, {**STATION, "camera_url": None})
|
||||||
|
with patch("blueprints.measure.http_requests.post") as post:
|
||||||
|
resp = logged_in_client.post("/measure/api/vision/grab")
|
||||||
|
assert resp.status_code == 409
|
||||||
|
assert resp.get_json()["error"]
|
||||||
|
post.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_grab_without_source_is_409(logged_in_client, mock_api_client, monkeypatch):
|
||||||
|
_station(mock_api_client, monkeypatch, {**STATION, "camera_source": None})
|
||||||
|
with patch("blueprints.measure.http_requests.post") as post:
|
||||||
|
resp = logged_in_client.post("/measure/api/vision/grab")
|
||||||
|
assert resp.status_code == 409
|
||||||
|
post.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_grab_station_lookup_error_is_409(logged_in_client, mock_api_client, monkeypatch):
|
||||||
|
_station(mock_api_client, monkeypatch, {"error": True, "status_code": 404, "detail": "x"})
|
||||||
|
resp = logged_in_client.post("/measure/api/vision/grab")
|
||||||
|
assert resp.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
def test_grab_passes_png_through(logged_in_client, mock_api_client, monkeypatch):
|
||||||
|
_station(mock_api_client, monkeypatch, STATION)
|
||||||
|
with patch("blueprints.measure.http_requests.post", return_value=_fake()) as post:
|
||||||
|
resp = logged_in_client.post("/measure/api/vision/grab")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.mimetype == "image/png"
|
||||||
|
assert resp.data == PNG
|
||||||
|
mock_api_client.get_station_by_code.assert_called_once_with("ST-TEST")
|
||||||
|
args, kwargs = post.call_args
|
||||||
|
assert args[0] == "http://host.docker.internal:8765/camera/grab"
|
||||||
|
assert kwargs["json"] == {
|
||||||
|
"source": "camera:folder", "trigger": "continuo", "timeout_ms": 5000,
|
||||||
|
}
|
||||||
|
assert kwargs["timeout"] == 15
|
||||||
|
|
||||||
|
|
||||||
|
def test_grab_service_503_is_502(logged_in_client, mock_api_client, monkeypatch):
|
||||||
|
_station(mock_api_client, monkeypatch, STATION)
|
||||||
|
fake = _fake(503, b"{}", "application/json", {"detail": "camera occupata"})
|
||||||
|
with patch("blueprints.measure.http_requests.post", return_value=fake):
|
||||||
|
resp = logged_in_client.post("/measure/api/vision/grab")
|
||||||
|
assert resp.status_code == 502
|
||||||
|
body = resp.get_json()
|
||||||
|
assert body["error"] is True
|
||||||
|
assert "camera occupata" in body["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_grab_service_504_and_404_are_502(logged_in_client, mock_api_client, monkeypatch):
|
||||||
|
_station(mock_api_client, monkeypatch, STATION)
|
||||||
|
for status in (404, 504):
|
||||||
|
with patch("blueprints.measure.http_requests.post",
|
||||||
|
return_value=_fake(status, b"nope", "text/plain")):
|
||||||
|
resp = logged_in_client.post("/measure/api/vision/grab")
|
||||||
|
assert resp.status_code == 502
|
||||||
|
assert resp.get_json()["error"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_grab_connection_error_is_502(logged_in_client, mock_api_client, monkeypatch):
|
||||||
|
_station(mock_api_client, monkeypatch, STATION)
|
||||||
|
with patch("blueprints.measure.http_requests.post",
|
||||||
|
side_effect=requests.ConnectionError("refused")):
|
||||||
|
resp = logged_in_client.post("/measure/api/vision/grab")
|
||||||
|
assert resp.status_code == 502
|
||||||
|
assert resp.get_json()["error"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_grab_requires_measurement_tec(flask_app, mock_api_client, monkeypatch):
|
||||||
|
_station(mock_api_client, monkeypatch, STATION)
|
||||||
|
with flask_app.test_client() as c:
|
||||||
|
with c.session_transaction() as sess:
|
||||||
|
sess["api_key"] = "k"
|
||||||
|
sess["user"] = {"id": 2, "username": "m", "roles": ["Maker"],
|
||||||
|
"is_admin": False, "active": True}
|
||||||
|
sess["user_id"] = 2
|
||||||
|
with patch("blueprints.measure.http_requests.post") as post:
|
||||||
|
resp = c.post("/measure/api/vision/grab")
|
||||||
|
assert resp.status_code == 403
|
||||||
|
post.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_grab_requires_login(client):
|
||||||
|
resp = client.post("/measure/api/vision/grab")
|
||||||
|
assert resp.status_code in (302, 401)
|
||||||
|
|
||||||
|
|
||||||
|
# --- execute page: button only when the station has a camera -----------------
|
||||||
|
TASK = {
|
||||||
|
"id": 12, "recipe_id": 3, "version_id": 5, "task_type": "camera_measure",
|
||||||
|
"title": "Foto", "directive": None, "description": None, "file_path": None,
|
||||||
|
"file_type": None, "annotations_json": None, "subtasks": [],
|
||||||
|
}
|
||||||
|
RECIPE = {
|
||||||
|
"id": 3, "code": "REC-1", "name": "Ricetta",
|
||||||
|
"measurement_interval_minutes": 30, "allow_manual_input": True,
|
||||||
|
"current_version": {"id": 5, "version_number": 1},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _page(logged_in_client, mock_api_client, monkeypatch, station):
|
||||||
|
_station(mock_api_client, monkeypatch, station)
|
||||||
|
mock_api_client.get_station_recipes.return_value = {"items": [{"id": 3}]}
|
||||||
|
mock_api_client.get.side_effect = api_get_router({
|
||||||
|
"/api/tasks/12": TASK,
|
||||||
|
"/api/recipes/3/tasks": [{"id": 12, "order_index": 0}],
|
||||||
|
"/api/recipes/3": RECIPE,
|
||||||
|
})
|
||||||
|
return logged_in_client.get("/measure/execute/12").get_data(as_text=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_execute_page_shows_grab_button_with_camera(logged_in_client, mock_api_client, monkeypatch):
|
||||||
|
html = _page(logged_in_client, mock_api_client, monkeypatch, STATION)
|
||||||
|
assert "/measure/api/vision/grab" in html
|
||||||
|
assert "grab()" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_execute_page_hides_grab_button_without_camera(logged_in_client, mock_api_client, monkeypatch):
|
||||||
|
html = _page(logged_in_client, mock_api_client, monkeypatch, {**STATION, "camera_url": None})
|
||||||
|
assert "grab()" not in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_execute_page_hides_grab_button_when_lookup_fails(logged_in_client, mock_api_client, monkeypatch):
|
||||||
|
html = _page(logged_in_client, mock_api_client, monkeypatch,
|
||||||
|
{"error": True, "status_code": 502, "detail": "down"})
|
||||||
|
assert "grab()" not in html
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
"""Capoturno authorisation by PIN (#59): proxies of the measure blueprint.
|
||||||
|
|
||||||
|
The modal in task_execute.html sends either ``supervisor_user_id`` + ``supervisor_pin``
|
||||||
|
or the old ``supervisor_username`` + ``supervisor_password``; the proxies forward
|
||||||
|
whichever pair is present and let the PIN/lockout refusals through to the modal.
|
||||||
|
"""
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from flask import session
|
||||||
|
|
||||||
|
RUN = {"id": 7, "status": "paused"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def measure_mod():
|
||||||
|
from blueprints import measure
|
||||||
|
return measure
|
||||||
|
|
||||||
|
|
||||||
|
def test_pin_users_proxy_returns_only_id_and_name(logged_in_client, measure_mod):
|
||||||
|
with patch.object(measure_mod, "api_client") as mock_api:
|
||||||
|
mock_api.get.return_value = [
|
||||||
|
{"id": 3, "display_name": "Anna Rossi", "pin_hash": "x"},
|
||||||
|
{"id": 4, "display_name": "bruno"},
|
||||||
|
]
|
||||||
|
resp = logged_in_client.get("/measure/api/pin-users")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json() == [
|
||||||
|
{"id": 3, "display_name": "Anna Rossi"},
|
||||||
|
{"id": 4, "display_name": "bruno"},
|
||||||
|
]
|
||||||
|
assert mock_api.get.call_args[0][0] == "/api/auth/pin-users"
|
||||||
|
|
||||||
|
|
||||||
|
def test_pin_users_proxy_empty_on_backend_error(logged_in_client, measure_mod):
|
||||||
|
"""The modal opens the Password tab when the list is empty."""
|
||||||
|
with patch.object(measure_mod, "api_client") as mock_api:
|
||||||
|
mock_api.get.return_value = {"error": True, "status_code": 502, "detail": "down"}
|
||||||
|
resp = logged_in_client.get("/measure/api/pin-users")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json() == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("action", ["pause", "resume", "close"])
|
||||||
|
def test_supervised_action_forwards_user_id_and_pin(logged_in_client, measure_mod, action):
|
||||||
|
with patch.object(measure_mod, "api_client") as mock_api:
|
||||||
|
mock_api.post.return_value = RUN
|
||||||
|
resp = logged_in_client.post(
|
||||||
|
f"/measure/api/production/7/{action}",
|
||||||
|
json={"supervisor_user_id": 5, "supervisor_pin": "1234"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
endpoint, kwargs = mock_api.post.call_args
|
||||||
|
assert endpoint[0] == f"/api/production-runs/7/{action}"
|
||||||
|
assert kwargs["data"]["supervisor_user_id"] == 5
|
||||||
|
assert kwargs["data"]["supervisor_pin"] == "1234"
|
||||||
|
assert "supervisor_username" not in kwargs["data"]
|
||||||
|
assert "supervisor_password" not in kwargs["data"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_supervised_action_password_pair_unchanged(logged_in_client, measure_mod):
|
||||||
|
with patch.object(measure_mod, "api_client") as mock_api:
|
||||||
|
mock_api.post.return_value = RUN
|
||||||
|
resp = logged_in_client.post(
|
||||||
|
"/measure/api/production/7/pause",
|
||||||
|
json={"supervisor_username": "capo", "supervisor_password": "segreta"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
kwargs = mock_api.post.call_args[1]
|
||||||
|
assert kwargs["data"]["supervisor_username"] == "capo"
|
||||||
|
assert kwargs["data"]["supervisor_password"] == "segreta"
|
||||||
|
assert "supervisor_pin" not in kwargs["data"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_supervised_action_pin_without_user_is_refused(logged_in_client, measure_mod):
|
||||||
|
with patch.object(measure_mod, "api_client") as mock_api:
|
||||||
|
resp = logged_in_client.post(
|
||||||
|
"/measure/api/production/7/pause", json={"supervisor_pin": "1234"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
mock_api.post.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_authorise_measurement_forwards_user_id_and_pin(logged_in_client, measure_mod):
|
||||||
|
with patch.object(measure_mod, "api_client") as mock_api:
|
||||||
|
mock_api.post.return_value = {"id": 9}
|
||||||
|
resp = logged_in_client.post(
|
||||||
|
"/measure/api/measurements/9/authorise",
|
||||||
|
json={"supervisor_user_id": 5, "supervisor_pin": "4321"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
endpoint, kwargs = mock_api.post.call_args
|
||||||
|
assert endpoint[0] == "/api/measurements/9/authorise"
|
||||||
|
assert kwargs["data"] == {"supervisor_user_id": 5, "supervisor_pin": "4321"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_wrong_pin_401_stays_in_the_modal_and_keeps_the_session(logged_in_client, measure_mod):
|
||||||
|
"""api_client empties the session on any 401; a wrong PIN must not log the operator out."""
|
||||||
|
def refuse(*args, **kwargs):
|
||||||
|
session.clear() # what api_client._handle_session_expired does
|
||||||
|
return {
|
||||||
|
"error": True, "status_code": 401, "session_expired": True,
|
||||||
|
"detail": "Invalid supervisor credentials",
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch.object(measure_mod, "api_client") as mock_api:
|
||||||
|
mock_api.post.side_effect = refuse
|
||||||
|
resp = logged_in_client.post(
|
||||||
|
"/measure/api/production/7/close",
|
||||||
|
json={"supervisor_user_id": 5, "supervisor_pin": "0000"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
body = resp.get_json()
|
||||||
|
assert body["detail"] == "PIN errato"
|
||||||
|
assert "session_expired" not in body
|
||||||
|
with logged_in_client.session_transaction() as sess:
|
||||||
|
assert sess.get("api_key") == "test-api-key-12345"
|
||||||
|
|
||||||
|
|
||||||
|
def test_real_expired_session_401_still_clears_the_session(logged_in_client, measure_mod):
|
||||||
|
"""A 401 that is not about the capoturno keeps its session_expired flag."""
|
||||||
|
def expired(*args, **kwargs):
|
||||||
|
session.clear()
|
||||||
|
return {
|
||||||
|
"error": True, "status_code": 401, "session_expired": True,
|
||||||
|
"detail": "Invalid API key",
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch.object(measure_mod, "api_client") as mock_api:
|
||||||
|
mock_api.post.side_effect = expired
|
||||||
|
resp = logged_in_client.post(
|
||||||
|
"/measure/api/production/7/close",
|
||||||
|
json={"supervisor_user_id": 5, "supervisor_pin": "0000"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
assert resp.get_json()["session_expired"] is True
|
||||||
|
with logged_in_client.session_transaction() as sess:
|
||||||
|
assert "api_key" not in sess
|
||||||
|
|
||||||
|
|
||||||
|
def test_locked_user_423_passes_the_detail(logged_in_client, measure_mod):
|
||||||
|
with patch.object(measure_mod, "api_client") as mock_api:
|
||||||
|
mock_api.post.return_value = {
|
||||||
|
"error": True, "status_code": 423,
|
||||||
|
"detail": "Utente bloccato fino alle 10:15",
|
||||||
|
}
|
||||||
|
resp = logged_in_client.post(
|
||||||
|
"/measure/api/production/7/pause",
|
||||||
|
json={"supervisor_user_id": 5, "supervisor_pin": "1111"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 423
|
||||||
|
assert resp.get_json()["detail"] == "Utente bloccato fino alle 10:15"
|
||||||
|
|
||||||
|
|
||||||
|
def test_password_401_detail_is_kept(logged_in_client, measure_mod):
|
||||||
|
"""Password mode: the refusal text is the backend's, as before."""
|
||||||
|
with patch.object(measure_mod, "api_client") as mock_api:
|
||||||
|
mock_api.post.return_value = {
|
||||||
|
"error": True, "status_code": 403, "detail": "User is not authorised as supervisor",
|
||||||
|
}
|
||||||
|
resp = logged_in_client.post(
|
||||||
|
"/measure/api/production/7/pause",
|
||||||
|
json={"supervisor_username": "op", "supervisor_password": "x"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
assert resp.get_json()["detail"] == "User is not authorised as supervisor"
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
"""gitea:Adriano/TieMeasureFlow#53 #54 #55 - operator side of the check / note results.
|
||||||
|
|
||||||
|
The backend (task-results, in_loop, close_task_ids) lives in another unit: api_client
|
||||||
|
is mocked and the tests work against the contract written in the issues.
|
||||||
|
"""
|
||||||
|
import io
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tests.conftest import api_get_router
|
||||||
|
from tests.test_template_js_syntax import (
|
||||||
|
_INLINE_SCRIPT_RX, _check_alpine_attributes, _force_italian, _node_check,
|
||||||
|
)
|
||||||
|
|
||||||
|
FLASK_APP_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
RECIPE = {
|
||||||
|
"id": 3, "code": "REC-1", "name": "Ricetta",
|
||||||
|
"measurement_interval_minutes": 30, "allow_manual_input": True,
|
||||||
|
"current_version": {"id": 5, "version_number": 1},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _task(task_id, task_type, **extra):
|
||||||
|
task = {
|
||||||
|
"id": task_id, "recipe_id": 3, "version_id": 5, "task_type": task_type,
|
||||||
|
"title": "Task d'esempio", "directive": None, "description": None,
|
||||||
|
"file_path": None, "file_type": None, "annotations_json": None,
|
||||||
|
"subtasks": [], "fields": None,
|
||||||
|
}
|
||||||
|
task.update(extra)
|
||||||
|
return task
|
||||||
|
|
||||||
|
|
||||||
|
def _render(client, mock_api_client, monkeypatch, task):
|
||||||
|
monkeypatch.setattr("blueprints.measure.Config.STATION_CODE", "ST-TEST")
|
||||||
|
mock_api_client.get_station_recipes.return_value = {"items": [{"id": 3}]}
|
||||||
|
mock_api_client.get.side_effect = api_get_router({
|
||||||
|
f"/api/tasks/{task['id']}": task,
|
||||||
|
"/api/recipes/3/tasks": [{"id": task["id"], "order_index": 0}],
|
||||||
|
"/api/recipes/3": RECIPE,
|
||||||
|
})
|
||||||
|
return client.get(f"/measure/execute/{task['id']}")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Page
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def test_check_task_has_ok_nok_buttons(logged_in_client, mock_api_client, monkeypatch):
|
||||||
|
resp = _render(logged_in_client, mock_api_client, monkeypatch, _task(30, "check"))
|
||||||
|
html = resp.get_data(as_text=True)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "submit('ok')" in html and "submit('nok')" in html
|
||||||
|
assert "taskResultForm()" in html
|
||||||
|
assert "/measure/api/task-results" in html
|
||||||
|
assert "js/task-results.js" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_note_with_fields_shows_field_inputs(logged_in_client, mock_api_client, monkeypatch):
|
||||||
|
fields = [
|
||||||
|
{"key": "peso", "label": "Peso", "type": "number", "default": "1.5", "attachment": False},
|
||||||
|
{"key": "foto", "label": "Foto", "type": "text", "default": None, "attachment": True},
|
||||||
|
]
|
||||||
|
task = _task(31, "note", fields=fields)
|
||||||
|
html = _render(logged_in_client, mock_api_client, monkeypatch, task).get_data(as_text=True)
|
||||||
|
# the fields travel to the page in the task data, the inputs are drawn from them
|
||||||
|
assert '"key": "peso"' in html or '"key":"peso"' in html
|
||||||
|
assert 'x-model="f.values[fld.key]"' in html
|
||||||
|
assert "pickFile(f, fld.key, $event)" in html
|
||||||
|
assert "submit('done')" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_close_panel_and_modal_hook_present(logged_in_client, mock_api_client, monkeypatch):
|
||||||
|
html = _render(logged_in_client, mock_api_client, monkeypatch, _task(32, "check")).get_data(as_text=True)
|
||||||
|
assert "task-close-open" in html
|
||||||
|
assert "closeTasksRecorded()" in html
|
||||||
|
assert "close_task_ids" in html
|
||||||
|
assert "/measure/api/tasks/0" in html
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("task", [
|
||||||
|
_task(30, "check"),
|
||||||
|
_task(31, "note", fields=[{"key": "n", "label": "L'etichetta", "type": "text",
|
||||||
|
"default": None, "attachment": True}]),
|
||||||
|
])
|
||||||
|
def test_inline_js_and_alpine_attributes_valid(logged_in_client, mock_api_client, monkeypatch, task):
|
||||||
|
_force_italian(logged_in_client)
|
||||||
|
html = _render(logged_in_client, mock_api_client, monkeypatch, task).get_data(as_text=True)
|
||||||
|
scripts = _INLINE_SCRIPT_RX.findall(html)
|
||||||
|
assert scripts
|
||||||
|
for i, body in enumerate(scripts):
|
||||||
|
_node_check(body, f"/measure/execute check-note script[{i}]")
|
||||||
|
_check_alpine_attributes(html, "/measure/execute check-note")
|
||||||
|
|
||||||
|
|
||||||
|
def test_plain_note_keeps_old_footer(logged_in_client, mock_api_client, monkeypatch):
|
||||||
|
"""A note without fields has no result panel: it is still the plain «Completato»."""
|
||||||
|
html = _render(logged_in_client, mock_api_client, monkeypatch, _task(33, "note")).get_data(as_text=True)
|
||||||
|
assert "goToNextTask()" in html
|
||||||
|
# the panel markup is in the page either way; hasResultPanel (false here) decides
|
||||||
|
assert "hasResultPanel" in html
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Proxies
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def test_task_result_post_is_forwarded(logged_in_client, mock_api_client):
|
||||||
|
mock_api_client.post.return_value = {"id": 9, "outcome": "nok"}
|
||||||
|
body = {
|
||||||
|
"task_id": 30, "production_run_id": 4, "outcome": "nok", "note": "graffio",
|
||||||
|
"values": None, "attachments": None, "ignored": "x",
|
||||||
|
}
|
||||||
|
resp = logged_in_client.post("/measure/api/task-results", json=body)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
assert resp.get_json()["id"] == 9
|
||||||
|
args, kwargs = mock_api_client.post.call_args
|
||||||
|
assert args[0] == "/api/task-results"
|
||||||
|
assert kwargs["data"] == {
|
||||||
|
"task_id": 30, "production_run_id": 4, "outcome": "nok", "note": "graffio",
|
||||||
|
"values": None, "attachments": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_task_result_422_detail_is_passed_through(logged_in_client, mock_api_client):
|
||||||
|
mock_api_client.post.return_value = {
|
||||||
|
"error": True, "status_code": 422, "detail": "Per un NOK serve la nota",
|
||||||
|
}
|
||||||
|
resp = logged_in_client.post(
|
||||||
|
"/measure/api/task-results", json={"task_id": 30, "outcome": "nok"})
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert resp.get_json()["detail"] == "Per un NOK serve la nota"
|
||||||
|
|
||||||
|
|
||||||
|
def test_task_result_requires_task_id(logged_in_client, mock_api_client):
|
||||||
|
resp = logged_in_client.post("/measure/api/task-results", json={"outcome": "ok"})
|
||||||
|
assert resp.status_code == 400
|
||||||
|
mock_api_client.post.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_task_results_list_forwards_filters(logged_in_client, mock_api_client):
|
||||||
|
mock_api_client.get.return_value = [{"id": 1, "task_id": 30}]
|
||||||
|
resp = logged_in_client.get("/measure/api/task-results?production_run_id=4&task_id=30")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json() == [{"id": 1, "task_id": 30}]
|
||||||
|
args, kwargs = mock_api_client.get.call_args
|
||||||
|
assert args[0] == "/api/task-results"
|
||||||
|
assert kwargs["params"] == {"production_run_id": "4", "task_id": "30"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_attachment_upload_forwards_multipart(logged_in_client, mock_api_client):
|
||||||
|
mock_api_client.post.return_value = {"file_path": "general/foto.png", "file_type": "image/png"}
|
||||||
|
resp = logged_in_client.post(
|
||||||
|
"/measure/api/attachments",
|
||||||
|
data={"file": (io.BytesIO(b"\x89PNG fake"), "foto.png", "image/png")},
|
||||||
|
content_type="multipart/form-data",
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
assert resp.get_json()["file_path"] == "general/foto.png"
|
||||||
|
args, kwargs = mock_api_client.post.call_args
|
||||||
|
assert args[0] == "/api/task-results/attachments" # #55
|
||||||
|
name, content, mime = kwargs["files"]["file"]
|
||||||
|
assert name == "foto.png" and mime == "image/png" and content.startswith(b"\x89PNG")
|
||||||
|
|
||||||
|
|
||||||
|
def test_attachment_upload_requires_file(logged_in_client, mock_api_client):
|
||||||
|
resp = logged_in_client.post("/measure/api/attachments", data={})
|
||||||
|
assert resp.status_code == 400
|
||||||
|
mock_api_client.post.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_attachment_upload_error_detail_is_passed_through(logged_in_client, mock_api_client):
|
||||||
|
mock_api_client.post.return_value = {"error": True, "status_code": 400, "detail": "File type not allowed"}
|
||||||
|
resp = logged_in_client.post(
|
||||||
|
"/measure/api/attachments",
|
||||||
|
data={"file": (io.BytesIO(b"x"), "a.exe", "application/octet-stream")},
|
||||||
|
content_type="multipart/form-data",
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert resp.get_json()["detail"] == "File type not allowed"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_task_proxy(logged_in_client, mock_api_client):
|
||||||
|
mock_api_client.get.return_value = {"id": 40, "task_type": "note", "fields": []}
|
||||||
|
resp = logged_in_client.get("/measure/api/tasks/40")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json()["id"] == 40
|
||||||
|
assert mock_api_client.get.call_args[0][0] == "/api/tasks/40"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Component logic (plain node, stubbed fetch)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def test_task_results_component_js():
|
||||||
|
node = shutil.which("node")
|
||||||
|
if not node:
|
||||||
|
pytest.skip("node non installato: test JS di task-results.js saltato")
|
||||||
|
script = Path(__file__).resolve().parent / "js" / "test_task_results.js"
|
||||||
|
result = subprocess.run(
|
||||||
|
[node, str(script)], cwd=str(FLASK_APP_DIR),
|
||||||
|
capture_output=True, text=True, timeout=30,
|
||||||
|
)
|
||||||
|
assert result.returncode == 0, f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
|
||||||
@@ -128,9 +128,11 @@ def test_the_pdf_worker_is_local_everywhere_it_is_set():
|
|||||||
assert "vendor/pdf.worker" in line, (
|
assert "vendor/pdf.worker" in line, (
|
||||||
f"{path.relative_to(APP_ROOT)}:{n} punta il worker fuori casa"
|
f"{path.relative_to(APP_ROOT)}:{n} punta il worker fuori casa"
|
||||||
)
|
)
|
||||||
# Four templates render PDFs; if that number changes the new one needs the
|
# Five templates render PDFs; if that number changes the new one needs the
|
||||||
# worker set too, and this is where you find out.
|
# worker set too, and this is where you find out.
|
||||||
assert len(setters) == 4, f"template che impostano il worker: {setters}"
|
# #65: da quattro a cinque, task_execute.html mostra PDF nelle note e negli
|
||||||
|
# allegati dell'esecuzione (#55).
|
||||||
|
assert len(setters) == 5,f"template che impostano il worker: {setters}"
|
||||||
|
|
||||||
|
|
||||||
def test_the_shipped_libraries_are_the_ones_that_were_checked():
|
def test_the_shipped_libraries_are_the_ones_that_were_checked():
|
||||||
|
|||||||
@@ -179,7 +179,7 @@ def test_no_open_run_means_no_fermo_linea_and_no_fake_timer(
|
|||||||
"""#61: senza produzione aperta i pulsanti non si vedono, la modale non si
|
"""#61: senza produzione aperta i pulsanti non si vedono, la modale non si
|
||||||
apre e un rifiuto del server non avvia un timer locale finto."""
|
apre e un rifiuto del server non avvia un timer locale finto."""
|
||||||
html = _execute_page(logged_in_client, mock_api_client, monkeypatch)
|
html = _execute_page(logged_in_client, mock_api_client, monkeypatch)
|
||||||
assert 'x-if="isMeasureTask && productionRun"' in html
|
assert 'x-if="(isMeasureTask || isMeasurementTaskOfRun) && productionRun"' in html # #53: anche i task del ciclo
|
||||||
assert "!this.productionRun" in html
|
assert "!this.productionRun" in html
|
||||||
# #61: loadProductionRun mostra il detail del server, non solo il messaggio generico
|
# #61: loadProductionRun mostra il detail del server, non solo il messaggio generico
|
||||||
assert "errBody.detail" in html
|
assert "errBody.detail" in html
|
||||||
|
|||||||
@@ -130,3 +130,40 @@ class TestRunsProxy:
|
|||||||
mock_api_client.get.return_value = {"total": 1}
|
mock_api_client.get.return_value = {"total": 1}
|
||||||
logged_in_client.get("/statistics/api/summary?recipe_id=1&production_run_id=5")
|
logged_in_client.get("/statistics/api/summary?recipe_id=1&production_run_id=5")
|
||||||
assert mock_api_client.get.call_args.kwargs["params"]["production_run_id"] == "5"
|
assert mock_api_client.get.call_args.kwargs["params"]["production_run_id"] == "5"
|
||||||
|
|
||||||
|
|
||||||
|
class TestRunTaskResultsProxy:
|
||||||
|
"""#58: esiti dei task e allegati della sessione."""
|
||||||
|
|
||||||
|
def test_task_results_proxy(self, logged_in_client, mock_api_client):
|
||||||
|
payload = {"run_id": 5, "counts": {"ok": 1, "nok": 0, "done": 0}, "results": []}
|
||||||
|
mock_api_client.get.return_value = payload
|
||||||
|
resp = logged_in_client.get("/statistics/api/runs/5/task-results")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json() == payload
|
||||||
|
assert mock_api_client.get.call_args.args[0] == "/api/statistics/runs/5/task-results"
|
||||||
|
|
||||||
|
def test_task_results_proxy_relays_error(self, logged_in_client, mock_api_client):
|
||||||
|
mock_api_client.get.return_value = {"error": True, "detail": "x", "status_code": 404}
|
||||||
|
resp = logged_in_client.get("/statistics/api/runs/9/task-results")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
assert resp.get_json()["error"] is True
|
||||||
|
|
||||||
|
def test_file_proxy_serves_task_results_only(self, logged_in_client):
|
||||||
|
from unittest.mock import patch
|
||||||
|
from flask import Response
|
||||||
|
with patch("blueprints.statistics.proxy_file",
|
||||||
|
return_value=Response("img", status=200)) as pf:
|
||||||
|
ok = logged_in_client.get("/statistics/api/files/task_results/a.jpg")
|
||||||
|
assert ok.status_code == 200
|
||||||
|
pf.assert_called_once_with("task_results/a.jpg")
|
||||||
|
for bad in ("statistics/x", "task_results/../statistics/x", "../etc/passwd"):
|
||||||
|
assert logged_in_client.get(f"/statistics/api/files/{bad}").status_code == 404
|
||||||
|
assert pf.call_count == 1 # i percorsi rifiutati non arrivano al proxy
|
||||||
|
|
||||||
|
def test_dashboard_has_task_results_panel(self, logged_in_client, mock_api_client):
|
||||||
|
mock_api_client.get.return_value = {"items": [], "total": 0, "pages": 1}
|
||||||
|
html = logged_in_client.get("/statistics/dashboard").get_data(as_text=True)
|
||||||
|
assert 'id="run-task-results"' in html
|
||||||
|
assert "loadTaskResults" in html
|
||||||
|
assert "/statistics/api/runs/" in html
|
||||||
|
|||||||
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
Vendored
+1
-1
Submodule vendor/visionsuite updated: bc0df1667c...2e73691cc3
Reference in New Issue
Block a user