Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1d93eded27 | |||
| 70e2072b79 | |||
| 97c49f2b5b | |||
| d483fa1b6a | |||
| 48c47e871e | |||
| cc686129c0 | |||
| b27d56d4f7 | |||
| 96dd8d6ea3 | |||
| 2b00f83a2e | |||
| e10585a071 | |||
| 8b43e7a601 | |||
| 2de226f1fd | |||
| ddb764d639 |
@@ -0,0 +1 @@
|
||||
*
|
||||
+12
-3
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -19,6 +19,7 @@ from src.backend.models.orm.recipe import RecipeVersion
|
||||
from src.backend.models.orm.setting import SystemSetting
|
||||
from src.backend.models.orm.user import User
|
||||
from src.backend.models.api.measurement import (
|
||||
CurrentCycleResponse,
|
||||
MeasurementAuthorisation,
|
||||
MeasurementBatchCreate,
|
||||
MeasurementCreate,
|
||||
@@ -97,6 +98,24 @@ async def get_task_progress(
|
||||
return TaskProgressListResponse(tasks=tasks)
|
||||
|
||||
|
||||
@router.get("/current-cycle", response_model=CurrentCycleResponse)
|
||||
async def get_current_cycle(
|
||||
production_run_id: int = Query(..., gt=0),
|
||||
task_id: int = Query(..., gt=0),
|
||||
user: User = Depends(require_measurement_tec),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Quotes of this task already measured in the cycle under way.
|
||||
|
||||
Asked on load, so that a reload mid-cycle does not present quotes again that
|
||||
were taken a moment ago.
|
||||
"""
|
||||
rows = await measurement_service.current_cycle_measurements(
|
||||
db, production_run_id, task_id,
|
||||
)
|
||||
return CurrentCycleResponse(measurements=rows)
|
||||
|
||||
|
||||
@router.post("/{measurement_id}/authorise", response_model=MeasurementResponse)
|
||||
async def authorise_measurement(
|
||||
measurement_id: int,
|
||||
@@ -174,6 +193,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 +219,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 +235,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 +251,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,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)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -91,6 +91,21 @@ class TaskProgressListResponse(BaseModel):
|
||||
tasks: list[TaskProgressResponse]
|
||||
|
||||
|
||||
class CycleMeasurementResponse(BaseModel):
|
||||
"""One quote already measured in the cycle under way."""
|
||||
|
||||
subtask_id: int
|
||||
value: float
|
||||
pass_fail: str
|
||||
measured_at: datetime
|
||||
|
||||
|
||||
class CurrentCycleResponse(BaseModel):
|
||||
"""The quotes to restore on the screen after a reload; empty if the task is closed."""
|
||||
|
||||
measurements: list[CycleMeasurementResponse]
|
||||
|
||||
|
||||
class MeasurementQuery(BaseModel):
|
||||
"""Schema for measurement query filters."""
|
||||
recipe_id: Optional[int] = None
|
||||
|
||||
@@ -9,6 +9,7 @@ from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.backend.models.orm.measurement import Measurement
|
||||
from src.backend.models.orm.production import ProductionEvent
|
||||
from src.backend.models.orm.recipe import Recipe, RecipeVersion
|
||||
from src.backend.models.orm.task import (
|
||||
MEASURING_TASK_TYPES, RecipeSubtask, RecipeTask,
|
||||
@@ -241,6 +242,73 @@ async def task_progress(
|
||||
return progress
|
||||
|
||||
|
||||
async def current_cycle_measurements(
|
||||
db: AsyncSession, run_id: int, task_id: int,
|
||||
) -> list[dict]:
|
||||
"""The quotes of a task already measured in the cycle that is under way.
|
||||
|
||||
A reload, a timer redirect or an auto-logout throws away what the page held in
|
||||
memory; the operator would measure the same quotes again and the duplicates
|
||||
would reach the SPC statistics. The cycle needs no column of its own: it starts
|
||||
at the latest event of the run that opens one for this task - the run start, a
|
||||
cycle_completed, or a remeasure of this task.
|
||||
|
||||
If a task_measured of this task follows that boundary the task is already
|
||||
closed in this cycle and there is nothing to restore. Where a quote was measured
|
||||
more than once, the last reading stands.
|
||||
"""
|
||||
boundary = (await db.execute(
|
||||
select(ProductionEvent.id, ProductionEvent.created_at)
|
||||
.where(
|
||||
ProductionEvent.run_id == run_id,
|
||||
(ProductionEvent.event_type.in_(("start", "cycle_completed")))
|
||||
| (
|
||||
(ProductionEvent.event_type == "remeasure")
|
||||
& (ProductionEvent.task_id == task_id)
|
||||
),
|
||||
)
|
||||
.order_by(ProductionEvent.id.desc())
|
||||
.limit(1)
|
||||
)).first()
|
||||
|
||||
event_filter = [ProductionEvent.run_id == run_id]
|
||||
measurement_filter = [Measurement.production_run_id == run_id]
|
||||
if boundary is not None:
|
||||
boundary_id, boundary_at = boundary
|
||||
event_filter.append(ProductionEvent.id > boundary_id)
|
||||
measurement_filter.append(Measurement.measured_at > boundary_at)
|
||||
|
||||
closed = (await db.execute(
|
||||
select(ProductionEvent.id).where(
|
||||
*event_filter,
|
||||
ProductionEvent.event_type == "task_measured",
|
||||
ProductionEvent.task_id == task_id,
|
||||
).limit(1)
|
||||
)).first()
|
||||
if closed is not None:
|
||||
return []
|
||||
|
||||
rows = (await db.execute(
|
||||
select(Measurement)
|
||||
.join(RecipeSubtask, RecipeSubtask.id == Measurement.subtask_id)
|
||||
.where(*measurement_filter, RecipeSubtask.task_id == task_id)
|
||||
.order_by(Measurement.measured_at, Measurement.id)
|
||||
)).scalars().all()
|
||||
|
||||
latest: dict[int, Measurement] = {}
|
||||
for m in rows:
|
||||
latest[m.subtask_id] = m
|
||||
return [
|
||||
{
|
||||
"subtask_id": m.subtask_id,
|
||||
"value": float(m.value),
|
||||
"pass_fail": m.pass_fail,
|
||||
"measured_at": m.measured_at,
|
||||
}
|
||||
for m in sorted(latest.values(), key=lambda m: (m.measured_at, m.id))
|
||||
]
|
||||
|
||||
|
||||
async def validate_run_for_version(
|
||||
db: AsyncSession, run_id: int, version_id: int,
|
||||
) -> "ProductionRun":
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,172 @@
|
||||
"""#40: after a reload, the quotes already measured in the cycle come back.
|
||||
|
||||
The cycle has no column of its own: it opens at the latest start, cycle_completed
|
||||
or remeasure-of-this-task event of the run, and a task_measured of the task after
|
||||
that boundary means the task is closed in this cycle.
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from src.backend.models.orm.measurement import Measurement
|
||||
from src.backend.models.orm.production import ProductionEvent, ProductionRun
|
||||
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
|
||||
|
||||
T0 = datetime(2026, 10, 1, 8, 0, 0)
|
||||
|
||||
|
||||
async def _setup(db, user):
|
||||
recipe = await create_test_recipe(db, user.id)
|
||||
version = (await db.execute(
|
||||
select(RecipeVersion).where(
|
||||
RecipeVersion.recipe_id == recipe.id, RecipeVersion.is_current == True, # noqa: E712
|
||||
)
|
||||
)).scalar_one()
|
||||
task = (await db.execute(
|
||||
select(RecipeTask).where(RecipeTask.version_id == version.id)
|
||||
)).scalars().first()
|
||||
first = (await db.execute(
|
||||
select(RecipeSubtask).where(RecipeSubtask.task_id == task.id)
|
||||
)).scalars().first()
|
||||
second = RecipeSubtask(
|
||||
task_id=task.id, marker_number=2, description="Quota 2",
|
||||
nominal=10.0, utl=10.5, uwl=10.3, lwl=9.7, ltl=9.5, unit="mm",
|
||||
)
|
||||
db.add(second)
|
||||
station = Station(code="ST-CYC", name="St", active=True, created_by=user.id)
|
||||
db.add(station)
|
||||
await db.commit()
|
||||
run = ProductionRun(
|
||||
station_id=station.id, recipe_id=recipe.id, version_id=version.id,
|
||||
operator_id=user.id, status="running", active_station_id=station.id,
|
||||
)
|
||||
db.add(run)
|
||||
await db.commit()
|
||||
return run, version, task, first, second
|
||||
|
||||
|
||||
async def _event(db, run, user, kind, minutes, task_id=None):
|
||||
db.add(ProductionEvent(
|
||||
run_id=run.id, event_type=kind, user_id=user.id, task_id=task_id,
|
||||
created_at=T0 + timedelta(minutes=minutes),
|
||||
))
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def _meas(db, run, version, subtask, user, minutes, value=10.0):
|
||||
db.add(Measurement(
|
||||
subtask_id=subtask.id, version_id=version.id, measured_by=user.id,
|
||||
value=value, pass_fail="pass", production_run_id=run.id,
|
||||
measured_at=T0 + timedelta(minutes=minutes),
|
||||
))
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def _get(client, user, run, task):
|
||||
resp = await client.get(
|
||||
"/api/measurements/current-cycle", headers=auth_headers(user),
|
||||
params={"production_run_id": run.id, "task_id": task.id},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
return resp.json()["measurements"]
|
||||
|
||||
|
||||
async def test_only_second_cycle_after_cycle_completed(
|
||||
client: AsyncClient, db_session, measurement_tec_user,
|
||||
):
|
||||
u = measurement_tec_user
|
||||
run, version, task, q1, q2 = await _setup(db_session, u)
|
||||
await _event(db_session, run, u, "start", 0)
|
||||
await _meas(db_session, run, version, q1, u, 1, 10.1)
|
||||
await _meas(db_session, run, version, q2, u, 2, 10.2)
|
||||
await _event(db_session, run, u, "cycle_completed", 3)
|
||||
await _meas(db_session, run, version, q1, u, 4, 10.3)
|
||||
|
||||
rows = await _get(client, u, run, task)
|
||||
assert [(r["subtask_id"], r["value"]) for r in rows] == [(q1.id, 10.3)]
|
||||
assert rows[0]["pass_fail"] == "pass"
|
||||
|
||||
|
||||
async def test_last_reading_of_a_quote_wins(
|
||||
client: AsyncClient, db_session, measurement_tec_user,
|
||||
):
|
||||
u = measurement_tec_user
|
||||
run, version, task, q1, q2 = await _setup(db_session, u)
|
||||
await _event(db_session, run, u, "start", 0)
|
||||
await _meas(db_session, run, version, q1, u, 1, 10.1)
|
||||
await _meas(db_session, run, version, q1, u, 2, 10.4)
|
||||
|
||||
rows = await _get(client, u, run, task)
|
||||
assert [(r["subtask_id"], r["value"]) for r in rows] == [(q1.id, 10.4)]
|
||||
|
||||
|
||||
async def test_remeasure_of_the_task_empties_the_list(
|
||||
client: AsyncClient, db_session, measurement_tec_user,
|
||||
):
|
||||
u = measurement_tec_user
|
||||
run, version, task, q1, q2 = await _setup(db_session, u)
|
||||
await _event(db_session, run, u, "start", 0)
|
||||
await _meas(db_session, run, version, q1, u, 1)
|
||||
await _event(db_session, run, u, "remeasure", 2, task_id=task.id)
|
||||
|
||||
assert await _get(client, u, run, task) == []
|
||||
|
||||
|
||||
async def test_remeasure_of_another_task_does_not_move_the_boundary(
|
||||
client: AsyncClient, db_session, measurement_tec_user,
|
||||
):
|
||||
u = measurement_tec_user
|
||||
run, version, task, q1, q2 = await _setup(db_session, u)
|
||||
other = RecipeTask(
|
||||
version_id=version.id, title="Altro", task_type=task.task_type, order_index=9,
|
||||
)
|
||||
db_session.add(other)
|
||||
await db_session.commit()
|
||||
await _event(db_session, run, u, "start", 0)
|
||||
await _meas(db_session, run, version, q1, u, 1)
|
||||
await _event(db_session, run, u, "remeasure", 2, task_id=other.id)
|
||||
|
||||
assert len(await _get(client, u, run, task)) == 1
|
||||
|
||||
|
||||
async def test_task_measured_after_boundary_means_closed(
|
||||
client: AsyncClient, db_session, measurement_tec_user,
|
||||
):
|
||||
u = measurement_tec_user
|
||||
run, version, task, q1, q2 = await _setup(db_session, u)
|
||||
await _event(db_session, run, u, "start", 0)
|
||||
await _meas(db_session, run, version, q1, u, 1)
|
||||
await _meas(db_session, run, version, q2, u, 2)
|
||||
await _event(db_session, run, u, "task_measured", 3, task_id=task.id)
|
||||
|
||||
assert await _get(client, u, run, task) == []
|
||||
|
||||
|
||||
async def test_task_measured_before_boundary_does_not_close_next_cycle(
|
||||
client: AsyncClient, db_session, measurement_tec_user,
|
||||
):
|
||||
u = measurement_tec_user
|
||||
run, version, task, q1, q2 = await _setup(db_session, u)
|
||||
await _event(db_session, run, u, "start", 0)
|
||||
await _event(db_session, run, u, "task_measured", 1, task_id=task.id)
|
||||
await _event(db_session, run, u, "cycle_completed", 2)
|
||||
await _meas(db_session, run, version, q1, u, 3)
|
||||
|
||||
assert len(await _get(client, u, run, task)) == 1
|
||||
|
||||
|
||||
async def test_permissions(client: AsyncClient, db_session, measurement_tec_user, maker_user):
|
||||
run, _v, task, _q1, _q2 = await _setup(db_session, measurement_tec_user)
|
||||
params = {"production_run_id": run.id, "task_id": task.id}
|
||||
|
||||
no_key = await client.get("/api/measurements/current-cycle", params=params)
|
||||
assert no_key.status_code in (401, 403)
|
||||
|
||||
forbidden = await client.get(
|
||||
"/api/measurements/current-cycle", headers=auth_headers(maker_user), params=params,
|
||||
)
|
||||
assert forbidden.status_code == 403
|
||||
@@ -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}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,72 @@ 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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Route: quotes already measured in the current cycle (#40)
|
||||
# ---------------------------------------------------------------------------
|
||||
@measure_bp.route("/api/current-cycle/<int:task_id>", methods=["GET"])
|
||||
@login_required
|
||||
@role_required("MeasurementTec")
|
||||
def api_current_cycle(task_id: int):
|
||||
"""Proxy: quotes of this task already measured in the cycle under way.
|
||||
|
||||
The run comes from _current_run_id(), like the rest of the page. Outside a
|
||||
production there is no cycle to restore and the answer is an empty 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"))
|
||||
|
||||
run_id = _current_run_id()
|
||||
if not run_id:
|
||||
return jsonify({"measurements": []}), 200
|
||||
|
||||
resp = api_client.get(
|
||||
"/api/measurements/current-cycle",
|
||||
params={"production_run_id": run_id, "task_id": task_id},
|
||||
)
|
||||
if not isinstance(resp, dict) or resp.get("error"):
|
||||
# Never fatal: without the restore the screen behaves as it always did.
|
||||
return jsonify({"measurements": []}), 200
|
||||
return jsonify(resp), 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 }};
|
||||
|
||||
@@ -1100,6 +1100,7 @@ function taskExecute() {
|
||||
this.inputStartedAt = Date.now();
|
||||
// Rejoin whatever production is already open at this station.
|
||||
await this.loadProductionRun();
|
||||
await this.loadCurrentCycle();
|
||||
await this.loadPendingAuthorisation();
|
||||
// A tablet left to sleep drifts (#37.7): catch the countdown back up to
|
||||
// the server's own clock, the same source adoptProductionRun already
|
||||
@@ -1110,6 +1111,42 @@ function taskExecute() {
|
||||
});
|
||||
},
|
||||
|
||||
/* Quotes already measured in the cycle under way (#40).
|
||||
|
||||
A reload, a timer redirect or an auto-logout used to bring every quote back
|
||||
and measuring them again put duplicates in the SPC statistics. Only inside
|
||||
a production: outside one there is no cycle, and the screen starts empty as
|
||||
it always did. */
|
||||
async loadCurrentCycle() {
|
||||
if (!this.isMeasureTask || !this.subtasks.length || !this.productionRun) return;
|
||||
try {
|
||||
const resp = await fetch(
|
||||
'{{ url_for("measure.api_current_cycle", task_id=task.id) }}',
|
||||
);
|
||||
if (!resp.ok) return;
|
||||
const data = await resp.json();
|
||||
const known = new Set(this.subtasks.map(s => s.id));
|
||||
const restored = (data.measurements || []).filter(m => known.has(m.subtask_id));
|
||||
if (!restored.length) return;
|
||||
this.measurements = restored.map(m => {
|
||||
const st = this.subtasks.find(s => s.id === m.subtask_id);
|
||||
const nominal = st ? (st.nominal || 0) : 0;
|
||||
return {
|
||||
subtask_id: m.subtask_id,
|
||||
value: m.value,
|
||||
pass_fail: m.pass_fail,
|
||||
deviation: m.value - nominal,
|
||||
};
|
||||
});
|
||||
const next = this.subtasks.findIndex(
|
||||
s => !this.measurements.some(m => m.subtask_id === s.id),
|
||||
);
|
||||
this.currentIndex = next !== -1 ? next : this.subtasks.length - 1;
|
||||
} catch (e) {
|
||||
// Offline: the screen starts empty, as before.
|
||||
}
|
||||
},
|
||||
|
||||
/* Is a quote already waiting for the capoturno?
|
||||
|
||||
Reloading the page was the simplest way past the old gate. It is not one
|
||||
|
||||
@@ -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,148 @@ 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 TestApiCurrentCycle:
|
||||
"""GET /measure/api/current-cycle/<task_id> (gitea#40)."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _station_configured(self, monkeypatch):
|
||||
monkeypatch.setattr("blueprints.measure.Config.STATION_CODE", "ST-TEST")
|
||||
|
||||
def test_forwards_run_and_task(self, logged_in_client, mock_api_client):
|
||||
mock_api_client.get_station_recipes.return_value = [{"id": 1}]
|
||||
payload = {"measurements": [
|
||||
{"subtask_id": 3, "value": 10.1, "pass_fail": "pass",
|
||||
"measured_at": "2026-10-01T08:01:00"},
|
||||
]}
|
||||
mock_api_client.get.side_effect = api_get_router({
|
||||
"/api/tasks/7": {"id": 7, "recipe_id": 1, "version_id": 42},
|
||||
"/api/production-runs/current": {"id": 5},
|
||||
"/api/measurements/current-cycle": payload,
|
||||
})
|
||||
|
||||
resp = logged_in_client.get("/measure/api/current-cycle/7")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json() == payload
|
||||
call = next(
|
||||
c for c in mock_api_client.get.call_args_list
|
||||
if c.args[0] == "/api/measurements/current-cycle"
|
||||
)
|
||||
assert call.kwargs["params"] == {"production_run_id": 5, "task_id": 7}
|
||||
|
||||
def test_no_run_means_empty_and_no_backend_call(
|
||||
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,
|
||||
})
|
||||
|
||||
resp = logged_in_client.get("/measure/api/current-cycle/7")
|
||||
|
||||
assert resp.get_json() == {"measurements": []}
|
||||
assert not any(
|
||||
c.args[0] == "/api/measurements/current-cycle"
|
||||
for c in mock_api_client.get.call_args_list
|
||||
)
|
||||
|
||||
def test_refuses_a_recipe_not_assigned_to_this_station(
|
||||
self, logged_in_client, mock_api_client,
|
||||
):
|
||||
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},
|
||||
})
|
||||
|
||||
assert logged_in_client.get("/measure/api/current-cycle/7").status_code == 403
|
||||
|
||||
def test_requires_login(self, client):
|
||||
resp = client.get("/measure/api/current-cycle/7")
|
||||
assert resp.status_code in (302, 401)
|
||||
|
||||
|
||||
class TestStartSequence:
|
||||
"""GET /measure/start/<recipe_id> — the operator's way in."""
|
||||
|
||||
@@ -257,6 +399,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 +419,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 +434,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 +460,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 +486,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'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"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user