Compare commits

..

10 Commits

Author SHA1 Message Date
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
19 changed files with 529 additions and 22 deletions
+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
+4 -2
View File
@@ -177,8 +177,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,
+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,
@@ -565,6 +565,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"]
@@ -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."""
@@ -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"