Compare commits

...

12 Commits

Author SHA1 Message Date
Adriano 70e2072b79 File di riferimento rimossi con il task, immagini animate rifiutate, filtro production_run_id (#43)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 11:27:23 +02:00
Adriano 97c49f2b5b registri dei piani in git (AI-OS#309)
Refs gitea:Adriano/AI-OS#310

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 10:37:50 +02:00
Adriano d483fa1b6a fix(docker): uv run senza sync nelle immagini server e client, cache uv dell'utente app (#39, #38)
Le immagini server e client non portano vendor/visionsuite: ogni uv run
rivalidava il progetto e falliva. UV_NO_SYNC=1 usa il venv di uv sync.
La cache uv creata da root in build va ceduta all'utente app.
Verificato con build e run delle due immagini.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 23:53:12 +02:00
Adriano 48c47e871e docs: conteggi test aggiornati nel README (#38.12)
I numeri erano fermi a 390 pass/1 fail/4 skip, con un fallimento
descritto come preesistente (test_offline.py::test_no_first_party_script_calls_out)
che oggi non c'e' piu'. Aggiornati ai risultati effettivi dei comandi
di verifica:
uv run --extra server --extra client --extra dev pytest -q -> 622 passed, 4 skipped
UV_PROJECT_ENVIRONMENT=... uv run --python 3.13 --extra vision-worker --extra dev pytest src/vision src/vision_worker -q -> 16 passed

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 23:42:47 +02:00
Adriano cc686129c0 feat(measure): proxy JSON per il progresso di un task (#37.8, parziale)
GET /measure/api/task-progress/<task_id>: il browser non puo' chiamare
/api/measurements/task-progress del backend (niente X-API-Key). Login +
stesso controllo di stazione di task_execute() (_recipe_allowed_at_station),
poi inoltra a _task_progress() con lo stesso version_id/production_run_id
gia' usato da task_list.html.

Non tocca task_execute.html: /api/measurements/task-progress risponde
con conteggi aggregati per task (quotes/measured/state), non con le
quote-per-quota misurate (subtask_id, value, pass_fail, deviation) che
`this.measurements` richiede - ripristinarlo avrebbe richiesto inventare
valori o uno stato non offerto dal backend. MANCA: mostrato in dettaglio
nel rapporto finale.

Test Flask della route: riga per il task richiesto, default a "empty"
se assente, 403 fuori stazione, propagazione 404, login richiesto.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 23:36:42 +02:00
Adriano b27d56d4f7 fix(maker): l'editor task si riallinea al copy-on-write invece di 409 (#4)
Dopo il copy-on-write del backend (modifica su versione con misure ->
nuovi id per task e quote), task_editor.html teneva in memoria gli id
vecchi: il salvataggio successivo riceveva 409 "Cannot modify a
non-current version". Ogni handler di salvataggio (create/update/delete
di task e quote, reorder, upload immagine) ora ricarica la pagina su
409, mostrando un avviso dopo il reload. updateTask/updateSubtask/
addSubtask ricaricano anche su successo se l'id restituito (recipe_id
task, task_id quota) non corrisponde a quello locale - il copy-on-write
avvenuto silenziosamente.

Stringa aggiunta ai due cataloghi .po. Test: proxy Flask che verifica
il passaggio del 409 (test_maker.py), sintassi JS dello script inline
di task_editor.html con node --check (test_template_js_syntax.py).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 23:33:01 +02:00
Adriano 96dd8d6ea3 fix(vision): reference image sopravvive al copy-on-write (#38.6)
_copy_tasks_to_version non copiava le righe VisionReferenceImage del
task sorgente: il task copiato perdeva le immagini di riferimento del
grafo di visione. Ora copia le righe sul task nuovo, stesso path e
stessi metadati (stesso file su disco, non duplicato).
Di conseguenza il file va cancellato solo se nessun'altra riga
VisionReferenceImage referenzia lo stesso path: nuova
vision_service.file_still_referenced(), usata dalla route DELETE
prima di remove_reference_file.
Test: copy-on-write elenca l'immagine sulla nuova versione; DELETE di
una delle due righe lascia il file; DELETE dell'ultima lo rimuove.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 23:27:29 +02:00
Adriano 2b00f83a2e test(tasks): copre recipe_id nel copy-on-write di create_task (#4)
Sospetto MissingGreenlet su TaskResponse.recipe_id (via RecipeTask.version)
quando create_task passa per il copy-on-write - il task appena creato non
ha .version precaricato. Test aggiunto: verde, non riproducibile. La
lazy load risolve via identity map (new_version è già nella sessione,
FK semplice → use_get), senza toccare il DB. Test tenuto come guardia
di regressione.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 23:25:17 +02:00
Adriano e10585a071 fix(docker): home dir e cache uv per l'utente non privilegiato (#38.1)
useradd senza -m lascia l'utente app senza $HOME: uv run nel CMD non
trova dove scrivere la sua cache. Aggiunto -m a useradd in Dockerfile,
Dockerfile.frontend e Dockerfile.vision, e UV_CACHE_DIR=/tmp/uv-cache
esplicito (indipendente da $HOME). docs/DEPLOYMENT.md: nuova sezione
sull'aggiornamento di un'installazione esistente, con il chown del
volume upload_data ereditato da versioni precedenti.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 23:23:31 +02:00
Adriano 8b43e7a601 fix(deploy): X-Frame-Options coerente fra Traefik e nginx (#38.11)
traefik.http.middlewares.tmflow-headers.headers.frameDeny=true dava
DENY in produzione, mentre nginx.conf (dev) usa SAMEORIGIN. Sostituito
con customFrameOptionsValue=SAMEORIGIN per allineare le due vie di deploy.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 23:22:50 +02:00
Adriano 2de226f1fd fix(deploy): APP_ENV esplicito nei compose (#33)
docker-compose.yml (produzione) non impostava APP_ENV: la guardia
_refuse_default_secrets_outside_dev in src/backend/config.py non
scattava mai perché il default è "dev". Aggiunto APP_ENV: production
al servizio server; docker-compose.dev.yml imposta APP_ENV: dev in
modo esplicito. Tabella env in docs/DEPLOYMENT.md aggiornata.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 23:22:39 +02:00
Adriano ddb764d639 i18n: voci .po per le 3 nuove stringhe del lotto B2, test EN reso significativo
Aggiunte a mano in coda a it/en messages.po (nessuna rigenerazione):
"Sessione scaduta: effettua nuovamente l'accesso." (#12/#25),
"Ricetta non assegnata a questa stazione" (#24), "Codice non valido" (#37.4).

test_admin_stations_inline_js_is_valid_in_english (#37.5): i .mo sono
gitignored e compilati solo in Docker, non in questo run - impostare
session["language"]="en" da solo non aveva alcun effetto e il test passava
senza esercitare il bug EN reale. Ora patcha jinja_env.globals["_"] con la
traduzione nota per quella stringa, verificato che fallisca davvero sul
codice pre-fix (ripristinato subito dopo) prima di lasciarlo verde.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 23:16:43 +02:00
23 changed files with 727 additions and 37 deletions
+1
View File
@@ -0,0 +1 @@
*
+12 -3
View File
@@ -13,8 +13,17 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
shared-mime-info \
&& rm -rf /var/lib/apt/lists/*
# Non-root user the app runs as at runtime.
RUN useradd -u 10001 app
# Non-root user the app runs as at runtime. -m creates a home dir: `uv run`
# in the CMD below writes its cache under $HOME, and without one it has
# nowhere writable to put it.
RUN useradd -m -u 10001 app
# Independent of $HOME regardless: keeps the cache path explicit and writable
# by the app user even if the home directory assumption ever changes.
ENV UV_CACHE_DIR=/tmp/uv-cache
# `uv run` must use the venv built by `uv sync` below as it is: re-validating
# the project would look for vendor/visionsuite (the vision-worker extra), which
# this image does not carry (issue 39). `uv sync` itself is not affected by it.
ENV UV_NO_SYNC=1
WORKDIR /app
@@ -31,7 +40,7 @@ COPY src/ ./src/
# Uploads directory (mounted as a volume in production).
RUN mkdir -p /app/uploads/images /app/uploads/pdfs /app/uploads/logos /app/uploads/reports \
&& chown -R app:app /app
&& chown -R app:app /app /tmp/uv-cache
USER app
+12 -3
View File
@@ -18,8 +18,17 @@ FROM python:3.11.16-slim AS base
# uv from the official slim image (fast Python package manager).
COPY --from=ghcr.io/astral-sh/uv:0.10.9 /uv /uvx /usr/local/bin/
# Non-root user the app runs as at runtime.
RUN useradd -u 10001 app
# Non-root user the app runs as at runtime. -m creates a home dir: `uv run`
# in the CMD below writes its cache under $HOME, and without one it has
# nowhere writable to put it.
RUN useradd -m -u 10001 app
# Independent of $HOME regardless: keeps the cache path explicit and writable
# by the app user even if the home directory assumption ever changes.
ENV UV_CACHE_DIR=/tmp/uv-cache
# `uv run` must use the venv built by `uv sync` below as it is: re-validating
# the project would look for vendor/visionsuite (the vision-worker extra), which
# this image does not carry (issue 39). `uv sync` itself is not affected by it.
ENV UV_NO_SYNC=1
WORKDIR /app
@@ -36,7 +45,7 @@ COPY --from=tailwind-build /app/flask_app/static/css/tailwind.css ./flask_app/st
# Compile Flask-Babel translation catalogs.
WORKDIR /app/flask_app
RUN uv run --project /app pybabel compile -d translations && \
chown -R app:app /app
chown -R app:app /app /tmp/uv-cache
USER app
+7 -2
View File
@@ -10,7 +10,12 @@ COPY --from=ghcr.io/astral-sh/uv:0.10.9 /uv /usr/local/bin/uv
# Non-root user the app runs as at runtime. Same UID as the server's Dockerfile
# so both containers agree on ownership of the shared uploads volume (point 13).
RUN useradd -u 10001 app
# -m creates a home dir: `uv run` in the CMD below writes its cache under
# $HOME, and without one it has nowhere writable to put it.
RUN useradd -m -u 10001 app
# Independent of $HOME regardless: keeps the cache path explicit and writable
# by the app user even if the home directory assumption ever changes.
ENV UV_CACHE_DIR=/tmp/uv-cache
WORKDIR /app
COPY pyproject.toml uv.lock ./
@@ -38,7 +43,7 @@ RUN test -n "$VISION_ENGINE_VERSION" || { \
}
ENV VISION_ENGINE_VERSION=${VISION_ENGINE_VERSION}
RUN chown -R app:app /app
RUN chown -R app:app /app /tmp/uv-cache
USER app
EXPOSE 8100
+8 -8
View File
@@ -469,15 +469,15 @@ variabile impostata, sia in sviluppo sia in produzione, con lo stesso comando
mostrato sopra (sostituendo `docker-compose.dev.yml` con `docker-compose.yml`
in produzione).
Stato corrente su `V3.0.0`, con `uv run pytest` su Python 3.11 e senza
l'extra `vision` (un conteggio senza il suo ambiente non dice nulla, regola
che vale anche qui): **390 pass, 1 fail, 4 skip** — 243 in `src/backend/tests/`,
147 pass + 2 skip in `src/frontend/flask_app/tests/`, più 2 skip in
Stato corrente su `V3.0.0`, con `uv run --extra server --extra client --extra
dev pytest` su Python 3.11 e senza l'extra `vision` (un conteggio senza il
suo ambiente non dice nulla, regola che vale anche qui): **622 pass, 4
skip, nessun fallimento** — 410 in `src/backend/tests/`, 212 pass + 2 skip
in `src/frontend/flask_app/tests/` (geometria propria di
`test_layout_shell.py`, non legati alla visione), più 2 skip in
`src/vision/tests/` e `src/vision_worker/tests/` per il vincolo su Python
3.13 descritto sopra. Il fallimento è preesistente e indipendente da questo
lavoro: `test_offline.py::test_no_first_party_script_calls_out`, dovuto a una
copia locale di Fabric.js non tracciata da git (`static/js/fabric-debug.js`)
che nei propri commenti cita pagine di documentazione esterne.
3.13 descritto sopra. Eseguendo questi ultimi nel loro ambiente dedicato
(comando sotto) i test collegati alla visione passano tutti: **16 pass**.
Alcuni test frontend non renderizzano niente e leggono i sorgenti, perché guardano
proprietà che sopravvivono solo se qualcuno le controlla:
+1
View File
@@ -31,6 +31,7 @@ services:
env_file:
- .env
environment:
APP_ENV: dev
DB_HOST: mysql
UPLOAD_DIR: uploads
VISION_WORKER_URL: http://vision:8100
+2 -1
View File
@@ -31,6 +31,7 @@ services:
env_file:
- .env
environment:
APP_ENV: production
DB_HOST: mysql
UPLOAD_DIR: uploads
VISION_WORKER_URL: http://vision:8100
@@ -115,7 +116,7 @@ services:
- "traefik.http.middlewares.tmflow-headers.headers.forceSTSHeader=true"
- "traefik.http.middlewares.tmflow-headers.headers.STSIncludeSubdomains=true"
- "traefik.http.middlewares.tmflow-headers.headers.STSPreload=true"
- "traefik.http.middlewares.tmflow-headers.headers.frameDeny=true"
- "traefik.http.middlewares.tmflow-headers.headers.customFrameOptionsValue=SAMEORIGIN"
- "traefik.http.routers.tmflow-web.middlewares=tmflow-headers"
networks:
- tmflow-net
+25
View File
@@ -146,6 +146,7 @@ Introdotte dalla revisione del 21/09/2026:
| Variable | Type | Default | Description |
|----------|------|---------|-------------|
| `APP_ENV` | string | dev | Ambiente di esecuzione. `dev`/`test` saltano il controllo sui segreti di default; qualsiasi altro valore lo attiva. `docker-compose.yml` lo imposta a `production`, `docker-compose.dev.yml` a `dev` |
| `APP_DOMAIN` | string | *(nessuno, obbligatoria)* | Dominio pubblico dell'installazione, usato nelle rule Traefik di `docker-compose.yml` (solo produzione) |
| `TRUSTED_PROXY_COUNT` | int | 1 | Quanti hop di reverse proxy fidati stanno davanti al server, per calcolare l'IP reale del tablet |
| `WEB_CONCURRENCY` | int | 1 | Numero di worker Uvicorn del server; il `Dockerfile` ne avvia 4 di default indipendentemente da questa variabile |
@@ -434,6 +435,30 @@ docker compose -f docker-compose.yml logs -f server
docker compose -f docker-compose.yml down
```
### Aggiornare un'installazione esistente
Le immagini precedenti creavano l'utente non privilegiato senza una home
directory (`useradd -u 10001 app`), e il volume `upload_data` che ne è
risultato appartiene a `root`. Le immagini attuali creano l'utente con
`-m` e impostano `UV_CACHE_DIR=/tmp/uv-cache`, ma questo non cambia da solo
la proprietà di un volume già esistente: se si riparte con le nuove immagini
su un volume creato da una versione precedente, il container `server` non
riesce a scrivere sotto `/app/uploads`.
Prima del primo avvio con le nuove immagini, va quindi corretta la proprietà
del volume:
```bash
docker compose -f docker-compose.yml run --rm --user root --entrypoint chown server -R 10001:10001 /app/uploads
```
(sostituire `docker-compose.yml` con `docker-compose.dev.yml` per
un'installazione di sviluppo/on-premise). Il comando usa il servizio
`server` perché è quello che monta il volume `upload_data` in lettura e
scrittura; il worker di visione lo monta in sola lettura e non ne ha
bisogno. Un'installazione nuova, che parte da un volume vuoto, non richiede
questo passo.
---
## SSL/TLS
+5 -1
View File
@@ -174,6 +174,7 @@ def _build_measurement_filters(
date_from: datetime | None,
date_to: datetime | None,
pass_fail: str | None,
production_run_id: int | None = None,
) -> list:
"""Build SQLAlchemy filter conditions shared by list and CSV export."""
filters = []
@@ -199,6 +200,8 @@ def _build_measurement_filters(
filters.append(Measurement.measured_at <= date_to)
if pass_fail is not None:
filters.append(Measurement.pass_fail == pass_fail)
if production_run_id is not None:
filters.append(Measurement.production_run_id == production_run_id)
return filters
@@ -213,6 +216,7 @@ async def get_measurements(
date_from: datetime | None = Query(None),
date_to: datetime | None = Query(None),
pass_fail: str | None = Query(None, pattern="^(pass|warning|fail)$"),
production_run_id: int | None = Query(None, gt=0),
page: int = Query(1, ge=1),
per_page: int = Query(50, ge=1, le=500),
user: User = Depends(get_current_user),
@@ -228,7 +232,7 @@ async def get_measurements(
"""
filters = _build_measurement_filters(
recipe_id, version_id, subtask_id, measured_by, lot_number,
serial_number, date_from, date_to, pass_fail,
serial_number, date_from, date_to, pass_fail, production_run_id,
)
# Build query
+19 -2
View File
@@ -19,7 +19,8 @@ from src.backend.models.api.task import (
TaskUpdate,
validate_tolerance_order,
)
from src.backend.services import recipe_service
from src.backend.models.orm.vision import VisionReferenceImage
from src.backend.services import recipe_service, vision_service
router = APIRouter(tags=["tasks"])
@@ -366,8 +367,24 @@ async def delete_task(
task = await _task_for_write(db, task, user)
# The rows go with the task (migration 014 cascades them), the files do not:
# collect them now, remove the files only once the commit is durable - same
# order as vision.py::delete_reference_image. Rows are deleted explicitly so
# file_still_referenced sees the post-delete state even where the database
# does not enforce the cascade.
references = (await db.execute(
select(VisionReferenceImage).where(VisionReferenceImage.task_id == task.id)
)).scalars().all()
for reference in references:
await db.delete(reference)
await db.delete(task)
await db.flush()
await db.commit()
# A copy-on-write sibling may still point at the same file (#38.6).
for reference in references:
if not await vision_service.file_still_referenced(db, reference.path):
vision_service.remove_reference_file(reference)
# ---------------------------------------------------------------------------
+25 -2
View File
@@ -4,7 +4,10 @@ It does not know, and must not know, whether the server or the station computed
the numbers: that is what lets a station's configuration change without touching
the frontend or the recipes.
"""
import io
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
from PIL import Image
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -90,9 +93,27 @@ async def _read_image(image: UploadFile) -> bytes:
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="the file's content does not match a supported image format",
)
_reject_animated(content)
return content
def _reject_animated(content: bytes) -> None:
"""Animated GIF/WebP/APNG have no single frame to measure (decision of
06/10/2026, #43): refuse them instead of silently reading frame 0."""
try:
with Image.open(io.BytesIO(content)) as img:
animated = getattr(img, "is_animated", False) or getattr(img, "n_frames", 1) > 1
except Exception:
# Unreadable here is not this check's business: the magic bytes passed
# and the worker decides what it can decode.
return
if animated:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="animated images are not supported",
)
@router.post("/execute", response_model=VisionExecuteResponse)
async def execute(
task_id: int = Form(...),
@@ -177,8 +198,10 @@ async def delete_reference_image(
reference = await vision_service.delete_reference_image(db, reference_id)
await db.commit()
# Only now: if the commit above had failed, the row would still exist and
# the file must still be there for it.
vision_service.remove_reference_file(reference)
# the file must still be there for it. And only if no other row (a
# copy-on-write sibling, #38.6) still references the same path.
if not await vision_service.file_still_referenced(db, reference.path):
vision_service.remove_reference_file(reference)
@router.post("/preview")
+23
View File
@@ -12,6 +12,7 @@ from src.backend.models.orm.recipe import Recipe, RecipeVersion
from src.backend.models.orm.setting import RecipeVersionAudit
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
from src.backend.models.orm.user import User
from src.backend.models.orm.vision import VisionReferenceImage
from src.backend.models.api.recipe import RecipeCreate, RecipeUpdate
@@ -118,6 +119,28 @@ async def _copy_tasks_to_version(
db.add(new_task)
await db.flush() # get new_task.id
# Reference images (#38.6): the copied task points at the same files
# on disk as the source - same `path`, same acquisition metadata. A
# graph composed against a reference must keep working after the
# recipe is next edited, exactly like task_type/vision_json above.
# The file itself is only ever removed once no row references its
# path any more (see vision_service.file_still_referenced).
ref_images = (await db.execute(
select(VisionReferenceImage).where(VisionReferenceImage.task_id == task.id)
)).scalars().all()
for ref in ref_images:
db.add(VisionReferenceImage(
task_id=new_task.id,
path=ref.path,
station_id=ref.station_id,
device_code=ref.device_code,
calibration_snapshot=ref.calibration_snapshot,
engine_version=ref.engine_version,
note=ref.note,
expected_json=ref.expected_json,
acquired_at=ref.acquired_at,
))
for sub in task.subtasks:
new_sub = RecipeSubtask(
task_id=new_task.id,
+16
View File
@@ -275,6 +275,22 @@ def remove_reference_file(reference: VisionReferenceImage) -> None:
_reference_file_path(reference).unlink(missing_ok=True)
async def file_still_referenced(db: AsyncSession, path: str) -> bool:
"""True if some VisionReferenceImage row still points at *path*.
Copy-on-write (#38.6) can leave several rows sharing the same file on
disk (the copy carries over the source's `path` rather than duplicating
the file). The router must call this, after the row being deleted has
been committed, before it calls `remove_reference_file` - otherwise
deleting one task's reference would take the file out from under every
other task that still references it.
"""
result = await db.execute(
select(VisionReferenceImage.id).where(VisionReferenceImage.path == path).limit(1)
)
return result.scalar_one_or_none() is not None
async def save_reference_image(
db: AsyncSession,
task_id: int,
@@ -0,0 +1,120 @@
"""#43: reference files removed with their task, animated images refused,
GET /api/measurements filtered by production run."""
import io
import pytest
from PIL import Image
from sqlalchemy import select
from src.backend.config import settings
from src.backend.models.orm.measurement import Measurement
from src.backend.models.orm.vision import VisionReferenceImage
from src.backend.tests.conftest import auth_headers, create_test_recipe
from src.backend.tests.test_vision_upload_limits import a_png, recipe_parts
@pytest.fixture(autouse=True)
def redirect_uploads(monkeypatch, tmp_path):
monkeypatch.setattr(settings, "upload_dir", str(tmp_path))
async def _upload_reference(client, user, task_id):
resp = await client.post(
"/api/vision/reference-images", headers=auth_headers(user),
files={"image": ("p.png", a_png(), "image/png")},
data={"task_id": str(task_id)},
)
assert resp.status_code in (200, 201), resp.text
@pytest.mark.asyncio
async def test_delete_task_removes_the_reference_file(client, db_session, maker_user):
recipe = await create_test_recipe(db_session, maker_user.id)
_, task, _ = await recipe_parts(db_session, recipe.id)
await db_session.commit()
await _upload_reference(client, maker_user, task.id)
ref = (await db_session.execute(select(VisionReferenceImage))).scalars().one()
path = settings.upload_path / ref.path
assert path.exists()
resp = await client.delete(f"/api/tasks/{task.id}", headers=auth_headers(maker_user))
assert resp.status_code == 204
assert not path.exists()
@pytest.mark.asyncio
async def test_delete_task_keeps_a_file_another_task_still_references(
client, db_session, maker_user,
):
r1 = await create_test_recipe(db_session, maker_user.id)
r2 = await create_test_recipe(db_session, maker_user.id, code="REC-002")
_, t1, _ = await recipe_parts(db_session, r1.id)
_, t2, _ = await recipe_parts(db_session, r2.id)
await db_session.commit()
await _upload_reference(client, maker_user, t1.id)
ref = (await db_session.execute(select(VisionReferenceImage))).scalars().one()
db_session.add(VisionReferenceImage(task_id=t2.id, path=ref.path))
await db_session.commit()
path = settings.upload_path / ref.path
resp = await client.delete(f"/api/tasks/{t1.id}", headers=auth_headers(maker_user))
assert resp.status_code == 204
assert path.exists()
def _animated(fmt: str, frames: int) -> bytes:
imgs = [Image.new("L", (16, 16), color=i * 40) for i in range(frames)]
buf = io.BytesIO()
imgs[0].save(buf, format=fmt, save_all=True, append_images=imgs[1:], duration=50)
return buf.getvalue()
@pytest.mark.asyncio
@pytest.mark.parametrize("fmt,mime,frames,expected", [
("GIF", "image/gif", 2, 422),
("WEBP", "image/webp", 2, 422),
("GIF", "image/gif", 1, 201),
])
async def test_animated_images_are_refused(
client, db_session, maker_user, fmt, mime, frames, expected,
):
recipe = await create_test_recipe(db_session, maker_user.id)
_, task, _ = await recipe_parts(db_session, recipe.id)
await db_session.commit()
resp = await client.post(
"/api/vision/reference-images", headers=auth_headers(maker_user),
files={"image": ("a", _animated(fmt, frames), mime)},
data={"task_id": str(task.id)},
)
if expected == 422:
assert resp.status_code == 422
assert "animated" in resp.json()["detail"]
else:
assert resp.status_code in (200, 201), resp.text
@pytest.mark.asyncio
async def test_measurements_filter_by_production_run(client, db_session, maker_user):
recipe = await create_test_recipe(db_session, maker_user.id)
version, _, subtask = await recipe_parts(db_session, recipe.id)
for run in (7, 7, 8, None):
db_session.add(Measurement(
subtask_id=subtask.id, version_id=version.id, measured_by=maker_user.id,
value=1.0, pass_fail="pass", deviation=0.0, input_method="manual",
production_run_id=run,
))
await db_session.commit()
resp = await client.get(
"/api/measurements/", headers=auth_headers(maker_user),
params={"production_run_id": 7},
)
assert resp.status_code == 200
body = resp.json()
assert body["total"] == 2
assert {m["production_run_id"] for m in body["items"]} == {7}
+39
View File
@@ -73,6 +73,45 @@ class TestCreateTask:
assert len(data["subtasks"]) == 1
assert data["subtasks"][0]["description"] == "Surface roughness"
async def test_create_task_on_version_with_measurements_has_recipe_id(
self,
client: AsyncClient,
maker_user: User,
measurement_tec_user: User,
db_session: AsyncSession,
):
"""Copy-on-write branch (gitea#4): TaskResponse.recipe_id must not crash."""
recipe = await create_test_recipe(db_session, maker_user.id, code="COW-CT")
task = (await client.get(
f"/api/recipes/{recipe.id}/tasks", headers=auth_headers(maker_user),
)).json()[0]
subtask_id = task["subtasks"][0]["id"]
assert (await _measure(
client, measurement_tec_user, subtask_id, task["version_id"]
)).status_code == 200
resp = await client.post(
f"/api/recipes/{recipe.id}/tasks",
headers=auth_headers(maker_user),
json={
"title": "New Task After Measurement",
"subtasks": [
{
"marker_number": 1,
"description": "Surface roughness",
"nominal": 5.0,
"utl": 5.5,
"ltl": 4.5,
"unit": "um",
}
],
},
)
assert resp.status_code == 201, resp.text
data = resp.json()
assert data["recipe_id"] == recipe.id
assert data["version_id"] != task["version_id"]
class TestUpdateTask:
"""PUT /api/tasks/{task_id} tests."""
@@ -192,6 +192,78 @@ async def test_deleting_an_unknown_reference_image_is_404(
assert response.status_code == 404
@pytest.mark.asyncio
async def test_copy_on_write_carries_over_reference_images(
client, db_session, maker_user, measurement_tec_user,
):
"""#38.6: a copy-on-write version must list the source task's reference
images (same path, same metadata), and the file is removed only once no
row references it any more."""
recipe = await create_test_recipe(db_session, maker_user.id)
version, task, subtask = await recipe_parts(db_session, recipe.id)
await db_session.commit()
created = await client.post(
"/api/vision/reference-images",
headers=auth_headers(maker_user),
files={"image": ("ref.png", an_image(), "image/png")},
data={"task_id": str(task.id), "note": "orig"},
)
assert created.status_code == 200
old_reference_id = created.json()["id"]
path = created.json()["path"]
file_path = settings.upload_path / path
assert file_path.exists()
# A measurement on v1 forces the next PUT onto the copy-on-write path.
measured = await client.post(
"/api/measurements/",
headers=auth_headers(measurement_tec_user),
json={"subtask_id": subtask.id, "version_id": version.id, "value": 10.0},
)
assert measured.status_code == 200
resp = await client.put(
f"/api/recipes/{recipe.id}",
headers=auth_headers(maker_user),
json={"change_notes": "Edit after measurement"},
)
assert resp.status_code == 200, resp.text
db_session.expunge_all()
new_version, new_task, _ = await recipe_parts(db_session, recipe.id)
assert new_version.version_number == 2
listing = await client.get(
"/api/vision/reference-images",
headers=auth_headers(maker_user),
params={"task_id": new_task.id},
)
assert listing.status_code == 200
body = listing.json()
assert len(body) == 1
assert body[0]["path"] == path
assert body[0]["note"] == "orig"
new_reference_id = body[0]["id"]
assert new_reference_id != old_reference_id
# Two rows share the same file: deleting one must leave it in place.
del1 = await client.delete(
f"/api/vision/reference-images/{old_reference_id}",
headers=auth_headers(maker_user),
)
assert del1.status_code == 204
assert file_path.exists()
# Deleting the last row referencing the path removes the file too.
del2 = await client.delete(
f"/api/vision/reference-images/{new_reference_id}",
headers=auth_headers(maker_user),
)
assert del2.status_code == 204
assert not file_path.exists()
@pytest.mark.asyncio
async def test_preview_runs_the_graph_and_saves_no_measurement(
client, db_session, maker_user, worker_says,
+37 -7
View File
@@ -386,14 +386,12 @@ def task_complete(recipe_id: int):
# Load measurements if version_id provided
measurements = []
if version_id:
# Scoped to this measurement session, not the whole version (#30): a
# second production run on the same recipe version used to pull in
# every other station's/day's readings too. GET /api/measurements has
# no production_run_id filter yet (that's lotto A3), so lot/serial +
# the current operator is the closest available identity - MANCA:
# once the backend accepts production_run_id, prefer it when the
# active run is known (see _current_run_id()).
# Scoped to this measurement session, not the whole version (#30): the
# active production run when known, else lot/serial + current operator.
params = {"version_id": version_id, "per_page": 500}
run_id = _current_run_id()
if run_id:
params["production_run_id"] = run_id
if lot_number:
params["lot_number"] = lot_number
if serial_number:
@@ -565,6 +563,38 @@ def save_measurement():
return jsonify(resp), 201
# ---------------------------------------------------------------------------
# Route: task progress (#37.8) - browser cannot call the backend directly
# (X-API-Key), same as every other /api/* proxy in this file.
# ---------------------------------------------------------------------------
@measure_bp.route("/api/task-progress/<int:task_id>", methods=["GET"])
@login_required
@role_required("MeasurementTec")
def api_task_progress(task_id: int):
"""Proxy: how many quotes of this task are already measured.
Reuses _task_progress() - same version_id/production_run_id scoping as
task_list.html's badges - so this answers with exactly the counts
(quotes/measured/state) the backend can give without conflating one
cycle with another; it does not, and cannot, say which quotes or with
what values (task-progress is an aggregate, not a per-subtask list).
"""
task_resp = api_client.get(f"/api/tasks/{task_id}")
if isinstance(task_resp, dict) and task_resp.get("error"):
return jsonify(task_resp), task_resp.get("status_code", 500)
recipe_id = task_resp.get("recipe_id")
if recipe_id and not _recipe_allowed_at_station(recipe_id):
abort(403, description=_("Ricetta non assegnata a questa stazione"))
version_id = task_resp.get("version_id")
progress = _task_progress(version_id, _current_run_id())
row = progress.get(task_id, {
"task_id": task_id, "quotes": 0, "measured": 0, "state": "empty",
})
return jsonify(row), 200
# ---------------------------------------------------------------------------
# Routes: Out-of-tolerance authorisation (point 5)
# ---------------------------------------------------------------------------
@@ -1156,6 +1156,15 @@ function taskEditor() {
// Sort tasks by order_index on load
this.tasks.sort((a, b) => (a.order_index || 0) - (b.order_index || 0));
// Show the notice left by _reloadForNewVersion() before its reload (gitea#4).
try {
var versionNotice = sessionStorage.getItem('taskEditorVersionNotice');
if (versionNotice) {
sessionStorage.removeItem('taskEditorVersionNotice');
this.successMessage = versionNotice;
}
} catch (_e) { /* sessionStorage unavailable - notice just does not show */ }
// Check if returning from task_drawing with updated annotations
try {
var updatedRaw = sessionStorage.getItem('taskDrawingUpdated');
@@ -1193,6 +1202,27 @@ function taskEditor() {
return document.querySelector('meta[name="csrf-token"]')?.content || '';
},
// ============================================================
// Version conflict (gitea#4)
//
// Copy-on-write on a version with measurements creates NEW ids for its
// task/subtasks. The first save after that happens silently (200/201,
// new ids in the response); a save that still targets an id from before
// gets 409 "Cannot modify a non-current version". Both cases are fixed
// the same way: reload, so the editor starts fresh from the current
// version instead of patching a local state that copy-on-write already
// moved out from under it.
// ============================================================
_reloadForNewVersion() {
try {
sessionStorage.setItem(
'taskEditorVersionNotice',
{{ _("La ricetta ha una nuova versione: pagina ricaricata")|tojson }}
);
} catch (_e) { /* sessionStorage unavailable - reload still happens */ }
window.location.reload();
},
// ============================================================
// Render Task Preview (deferred)
// ============================================================
@@ -1341,6 +1371,7 @@ function taskEditor() {
})
});
if (resp.status === 409) { this._reloadForNewVersion(); return; }
const data = await resp.json();
if (data.error) {
@@ -1399,10 +1430,16 @@ function taskEditor() {
})
});
if (resp.status === 409) { this._reloadForNewVersion(); return; }
const data = await resp.json();
if (data.error) {
this.errorMessage = data.detail || {{ _("Errore nel salvataggio del task")|tojson }};
} else if (data.id !== taskId) {
// Copy-on-write happened silently: this task, and every other one
// in the recipe, now has a different id than what is in local state.
this._reloadForNewVersion();
return;
} else {
// Update local state
const idx = this.tasks.findIndex(t => t.id === taskId);
@@ -1440,6 +1477,7 @@ function taskEditor() {
headers: { 'X-CSRFToken': this.csrfToken() }
});
if (resp.status === 409) { this._reloadForNewVersion(); return; }
if (resp.ok || resp.status === 204) {
this.tasks = this.tasks.filter(t => t.id !== taskId);
this.deleteTaskModal = false;
@@ -1540,6 +1578,7 @@ function taskEditor() {
body: JSON.stringify({ task_ids: taskIds })
});
if (resp.status === 409) { this._reloadForNewVersion(); return; }
const data = await resp.json();
if (data.error) {
@@ -1608,10 +1647,16 @@ function taskEditor() {
body: JSON.stringify(payload)
});
if (resp.status === 409) { this._reloadForNewVersion(); return; }
const data = await resp.json();
if (data.error) {
this.errorMessage = data.detail || {{ _("Errore nella creazione della misurazione")|tojson }};
} else if (data.task_id !== taskId) {
// Copy-on-write happened silently: this task, and every other one
// in the recipe, now has a different id than what is in local state.
this._reloadForNewVersion();
return;
} else {
const task = this.tasks.find(t => t.id === taskId);
if (task) {
@@ -1679,10 +1724,16 @@ function taskEditor() {
body: JSON.stringify(payload)
});
if (resp.status === 409) { this._reloadForNewVersion(); return; }
const data = await resp.json();
if (data.error) {
this.errorMessage = data.detail || {{ _("Errore nel salvataggio della misurazione")|tojson }};
} else if (data.task_id !== taskId) {
// Copy-on-write happened silently: this task, and every other one
// in the recipe, now has a different id than what is in local state.
this._reloadForNewVersion();
return;
} else {
// Update local state
const task = this.tasks.find(t => t.id === taskId);
@@ -1719,6 +1770,7 @@ function taskEditor() {
headers: { 'X-CSRFToken': this.csrfToken() }
});
if (resp.status === 409) { this._reloadForNewVersion(); return; }
if (resp.ok || resp.status === 204) {
const task = this.tasks.find(t => t.id === taskId);
if (task && task.subtasks) {
@@ -1796,6 +1848,7 @@ function taskEditor() {
})
});
if (updateResp.status === 409) { this._reloadForNewVersion(); return; }
const updateData = await updateResp.json();
if (updateData.error) {
this.errorMessage = updateData.detail || {{ _("Errore nel salvataggio del task")|tojson }};
@@ -121,3 +121,43 @@ class TestRecipeEdit:
resp = logged_in_client.get("/maker/recipes/999/edit", follow_redirects=False)
assert resp.status_code == 302
assert "/maker/recipes" in resp.headers["Location"]
class TestVersionConflictProxy:
"""gitea#4: a 409 from the backend (edit targeting a non-current version,
left behind by an earlier copy-on-write) must reach the browser as a 409 -
task_editor.html's _reloadForNewVersion() only triggers on that status.
"""
_CONFLICT = {
"error": True,
"status_code": 409,
"detail": "Cannot modify a non-current version. Create a new version first.",
}
def test_update_task_proxies_409(self, logged_in_client, mock_api_client):
mock_api_client.put.return_value = self._CONFLICT
resp = logged_in_client.put("/maker/api/tasks/11", json={"title": "x"})
assert resp.status_code == 409
assert resp.get_json()["detail"] == self._CONFLICT["detail"]
def test_update_subtask_proxies_409(self, logged_in_client, mock_api_client):
mock_api_client.put.return_value = self._CONFLICT
resp = logged_in_client.put("/maker/api/subtasks/21", json={"nominal": 1.0})
assert resp.status_code == 409
assert resp.get_json()["detail"] == self._CONFLICT["detail"]
def test_create_subtask_proxies_409(self, logged_in_client, mock_api_client):
mock_api_client.post.return_value = self._CONFLICT
resp = logged_in_client.post(
"/maker/api/tasks/11/subtasks",
json={"marker_number": 1, "description": "x"},
)
assert resp.status_code == 409
assert resp.get_json()["detail"] == self._CONFLICT["detail"]
+108 -5
View File
@@ -167,6 +167,87 @@ class TestTaskList:
assert "Misura" in resp.get_data(as_text=True)
class TestApiTaskProgress:
"""GET /measure/api/task-progress/<task_id> tests (gitea#37.8).
The browser cannot call /api/measurements/task-progress directly (no
X-API-Key); this proxy forwards it with the same version_id/
production_run_id scoping _task_progress() already uses for task_list.
"""
@pytest.fixture(autouse=True)
def _station_configured(self, monkeypatch):
monkeypatch.setattr("blueprints.measure.Config.STATION_CODE", "ST-TEST")
def test_returns_this_task_progress_row(self, logged_in_client, mock_api_client):
mock_api_client.get_station_recipes.return_value = [{"id": 1}]
mock_api_client.get.side_effect = api_get_router({
"/api/tasks/7": {"id": 7, "recipe_id": 1, "version_id": 42},
"/api/production-runs/current": None,
"/api/measurements/task-progress": {
"tasks": [{"task_id": 7, "quotes": 3, "measured": 1, "state": "partial"}],
},
})
resp = logged_in_client.get("/measure/api/task-progress/7")
assert resp.status_code == 200
assert resp.get_json() == {
"task_id": 7, "quotes": 3, "measured": 1, "state": "partial",
}
# No production run open: the backend call must not carry a
# production_run_id it would then use to scope by the wrong cycle.
progress_call = next(
call for call in mock_api_client.get.call_args_list
if call.args[0] == "/api/measurements/task-progress"
)
assert progress_call.kwargs["params"] == {"version_id": 42}
def test_task_absent_from_progress_defaults_to_empty(
self, logged_in_client, mock_api_client,
):
"""A task with no quotes yet has no row in the backend's answer."""
mock_api_client.get_station_recipes.return_value = [{"id": 1}]
mock_api_client.get.side_effect = api_get_router({
"/api/tasks/7": {"id": 7, "recipe_id": 1, "version_id": 42},
"/api/production-runs/current": None,
"/api/measurements/task-progress": {"tasks": []},
})
resp = logged_in_client.get("/measure/api/task-progress/7")
assert resp.status_code == 200
assert resp.get_json() == {
"task_id": 7, "quotes": 0, "measured": 0, "state": "empty",
}
def test_refuses_a_recipe_not_assigned_to_this_station(
self, logged_in_client, mock_api_client,
):
"""Same #24 guard task_execute() uses: no bypass through this proxy."""
mock_api_client.get_station_recipes.return_value = [{"id": 99}]
mock_api_client.get.side_effect = api_get_router({
"/api/tasks/7": {"id": 7, "recipe_id": 1, "version_id": 42},
})
resp = logged_in_client.get("/measure/api/task-progress/7")
assert resp.status_code == 403
def test_propagates_a_task_not_found(self, logged_in_client, mock_api_client):
mock_api_client.get.side_effect = api_get_router({
"/api/tasks/999": {"error": True, "status_code": 404, "detail": "not found"},
})
resp = logged_in_client.get("/measure/api/task-progress/999")
assert resp.status_code == 404
def test_requires_login(self, client):
resp = client.get("/measure/api/task-progress/7")
assert resp.status_code in (302, 401)
class TestStartSequence:
"""GET /measure/start/<recipe_id> — the operator's way in."""
@@ -257,6 +338,7 @@ class TestTaskComplete:
# The summary serialises the version number, so the recipe carries one.
{"id": 1, "code": "REC-001", "name": "Test Recipe", "version": 1},
[{"id": 1, "title": "Task 1", "order_index": 0, "subtasks": []}],
None, # current run
{"items": []}, # measurements
]
@@ -276,13 +358,14 @@ class TestTaskComplete:
mock_api_client.get.side_effect = [
{"id": 1, "code": "REC-001", "name": "Test Recipe", "version": 1},
[{"id": 1, "title": "Task 1", "order_index": 0, "subtasks": []}],
None, # current run
{"items": [], "pages": 1},
]
resp = logged_in_client.get("/measure/complete/1?version_id=5")
assert resp.status_code == 200
endpoint, kwargs = mock_api_client.get.call_args_list[2]
endpoint, kwargs = mock_api_client.get.call_args_list[3]
assert endpoint[0] == "/api/measurements"
params = kwargs["params"]
assert params["version_id"] == "5"
@@ -290,6 +373,24 @@ class TestTaskComplete:
assert params["serial_number"] == "S1"
assert params["measured_by"] == 1 # session["user_id"] from the fixture
def test_measurements_filtered_by_the_active_production_run(
self, logged_in_client, mock_api_client,
):
"""#43: when the station has a run open, the summary asks for that run."""
mock_api_client.get.side_effect = [
{"id": 1, "code": "REC-001", "name": "Test Recipe", "version": 1},
[{"id": 1, "title": "Task 1", "order_index": 0, "subtasks": []}],
{"id": 77}, # current run
{"items": [], "pages": 1},
]
resp = logged_in_client.get("/measure/complete/1?version_id=5")
assert resp.status_code == 200
endpoint, kwargs = mock_api_client.get.call_args_list[3]
assert endpoint[0] == "/api/measurements"
assert kwargs["params"]["production_run_id"] == 77
def test_measurements_without_lot_or_serial_still_scope_to_the_operator(
self, logged_in_client, mock_api_client,
):
@@ -298,13 +399,14 @@ class TestTaskComplete:
mock_api_client.get.side_effect = [
{"id": 1, "code": "REC-001", "name": "Test Recipe", "version": 1},
[{"id": 1, "title": "Task 1", "order_index": 0, "subtasks": []}],
None, # current run
{"items": [], "pages": 1},
]
resp = logged_in_client.get("/measure/complete/1?version_id=5")
assert resp.status_code == 200
endpoint, kwargs = mock_api_client.get.call_args_list[2]
endpoint, kwargs = mock_api_client.get.call_args_list[3]
params = kwargs["params"]
assert "lot_number" not in params
assert "serial_number" not in params
@@ -323,15 +425,16 @@ class TestTaskComplete:
mock_api_client.get.side_effect = [
{"id": 1, "code": "REC-001", "name": "Test Recipe", "version": 1},
[{"id": 1, "title": "Task 1", "order_index": 0, "subtasks": [subtask]}],
None, # current run
page1,
page2,
]
resp = logged_in_client.get("/measure/complete/1?version_id=5")
assert resp.status_code == 200
assert mock_api_client.get.call_count == 4
first_page_call = mock_api_client.get.call_args_list[2]
second_page_call = mock_api_client.get.call_args_list[3]
assert mock_api_client.get.call_count == 5
first_page_call = mock_api_client.get.call_args_list[3]
second_page_call = mock_api_client.get.call_args_list[4]
assert first_page_call.kwargs["params"]["page"] == 1
assert second_page_call.kwargs["params"]["page"] == 2
@@ -197,7 +197,9 @@ def test_admin_stations_inline_js_is_valid(logged_in_client, mock_admin_api):
_check_alpine_attributes(html, "/admin/stations")
def test_admin_stations_inline_js_is_valid_in_english(logged_in_client, mock_admin_api):
def test_admin_stations_inline_js_is_valid_in_english(
logged_in_client, mock_admin_api, flask_app,
):
"""gitea#37.5: the EN catalog is where this page's apostrophe actually is.
translations/en/LC_MESSAGES/messages.po: msgid "Azzera le ricette della
@@ -205,7 +207,25 @@ def test_admin_stations_inline_js_is_valid_in_english(logged_in_client, mock_adm
The IT source string has no apostrophe, so test_admin_stations_inline_js_is_valid
(IT-only) would never have caught this - #37.5's own TEST note calls out
the EN render specifically.
The .mo catalogs are gitignored and compiled only at Docker build time
(compile_translations.py) - not part of this test run - so setting
session["language"] = "en" alone has no effect here: flask_babel falls
back to the (Italian) msgid with no compiled catalog to translate from.
The Jinja "_" global is patched directly instead, standing in for what a
compiled EN catalog would return for this one string; everything else
round-trips unchanged, same as gettext's own fallback.
"""
EN_TRANSLATIONS = {
"Azzera le ricette della stazione": "Clear the station's recipes",
}
def fake_gettext(msgid, **kwargs):
text = EN_TRANSLATIONS.get(msgid, msgid)
return text % kwargs if kwargs else text
flask_app.jinja_env.globals["_"] = fake_gettext
mock_admin_api.get.side_effect = [
[{
"id": 1, "code": "ST-DEFAULT", "name": "Default Station",
@@ -215,13 +235,18 @@ def test_admin_stations_inline_js_is_valid_in_english(logged_in_client, mock_adm
[{"id": 1, "code": "DEMO-001", "name": "Demo Recipe", "active": True}],
]
with logged_in_client.session_transaction() as sess:
sess["language"] = "en"
resp = logged_in_client.get("/admin/stations")
assert resp.status_code == 200, resp.data[:300]
html = resp.data.decode("utf-8")
# The fix (#37.5) is exactly why the apostrophe shows up escaped
# (tojson_attr) rather than literal - that is what keeps it from
# breaking the surrounding JS string.
assert "station" in html.lower(), "sanity check: the EN string did render"
assert "station\\u0027s recipes" in html or "station&#39;s recipes" in html, (
"expected the EN string's apostrophe to reach the page only as an "
"escaped token (tojson_attr), not literally"
)
scripts = _INLINE_SCRIPT_RX.findall(html)
for i, body in enumerate(scripts):
@@ -370,3 +395,45 @@ def test_task_list_inline_js_is_valid(logged_in_client, mock_measure_api):
_node_check(body, f"/measure/tasks script[{i}]")
_check_alpine_attributes(html, "/measure/tasks")
@pytest.fixture
def mock_maker_api():
"""Patch api_client used inside the maker blueprint."""
mock = MagicMock()
with patch("blueprints.maker.api_client", mock):
yield mock
def test_task_editor_inline_js_is_valid(logged_in_client, mock_maker_api):
"""gitea#4: the version-conflict reload added _reloadForNewVersion() and
new `if (…status === 409)` / `data.task_id !== taskId` checks in every
save handler. Regression guard that the inline script still parses."""
_force_italian(logged_in_client)
mock_maker_api.get.side_effect = api_get_router({
"/api/recipes/3/tasks": [{
"id": 11, "order_index": 0, "title": "Quota d'ingresso",
"task_type": "measure", "file_path": None, "file_type": None,
"annotations_json": None,
"subtasks": [{
"id": 21, "marker_number": 1, "description": "Diametro",
"nominal": 10.0, "utl": 10.5, "uwl": 10.2, "lwl": 9.8, "ltl": 9.5,
"unit": "mm",
}],
}],
"/api/recipes/3": {
"id": 3, "code": "REC-3", "name": "Ricetta con l'apostrofo",
"description": "Con l'apostrofo anche qui",
},
})
resp = logged_in_client.get("/maker/recipes/3/tasks")
assert resp.status_code == 200
html = resp.get_data(as_text=True)
scripts = _INLINE_SCRIPT_RX.findall(html)
assert scripts, "expected at least one inline <script> on /maker/recipes/3/tasks"
for i, body in enumerate(scripts):
_node_check(body, f"/maker/recipes/3/tasks script[{i}]")
_check_alpine_attributes(html, "/maker/recipes/3/tasks")
@@ -2760,3 +2760,19 @@ msgstr "Reading ignored: please wait"
msgid "Lettura del calibro rifiutata: valore troppo grande"
msgstr "Caliper reading refused: value too large"
#: app.py:131 app.py:207
msgid "Sessione scaduta: effettua nuovamente l'accesso."
msgstr "Session expired: please log in again."
#: blueprints/measure.py:239 blueprints/measure.py:317 blueprints/measure.py:478
msgid "Ricetta non assegnata a questa stazione"
msgstr "Recipe not assigned to this station"
#: blueprints/measure.py:462
msgid "Codice non valido"
msgstr "Invalid code"
#: templates/maker/task_editor.html:1220
msgid "La ricetta ha una nuova versione: pagina ricaricata"
msgstr "The recipe has a new version: page reloaded"
@@ -2748,3 +2748,19 @@ msgstr "Lettura ignorata: attendere"
msgid "Lettura del calibro rifiutata: valore troppo grande"
msgstr "Lettura del calibro rifiutata: valore troppo grande"
#: app.py:131 app.py:207
msgid "Sessione scaduta: effettua nuovamente l'accesso."
msgstr "Sessione scaduta: effettua nuovamente l'accesso."
#: blueprints/measure.py:239 blueprints/measure.py:317 blueprints/measure.py:478
msgid "Ricetta non assegnata a questa stazione"
msgstr "Ricetta non assegnata a questa stazione"
#: blueprints/measure.py:462
msgid "Codice non valido"
msgstr "Codice non valido"
#: templates/maker/task_editor.html:1220
msgid "La ricetta ha una nuova versione: pagina ricaricata"
msgstr "La ricetta ha una nuova versione: pagina ricaricata"