Compare commits

...

2 Commits

Author SHA1 Message Date
Adriano 1d93eded27 Ripristino delle quote già misurate nel ciclo in corso dopo un reload (#40)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 11:31:57 +02:00
Adriano 70e2072b79 File di riferimento rimossi con il task, immagini animate rifiutate, filtro production_run_id (#43)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 11:27:23 +02:00
10 changed files with 603 additions and 15 deletions
+24 -1
View File
@@ -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 -2
View File
@@ -19,7 +19,8 @@ from src.backend.models.api.task import (
TaskUpdate,
validate_tolerance_order,
)
from src.backend.services import recipe_service
from src.backend.models.orm.vision import VisionReferenceImage
from src.backend.services import recipe_service, vision_service
router = APIRouter(tags=["tasks"])
@@ -366,8 +367,24 @@ async def delete_task(
task = await _task_for_write(db, task, user)
# The rows go with the task (migration 014 cascades them), the files do not:
# collect them now, remove the files only once the commit is durable - same
# order as vision.py::delete_reference_image. Rows are deleted explicitly so
# file_still_referenced sees the post-delete state even where the database
# does not enforce the cascade.
references = (await db.execute(
select(VisionReferenceImage).where(VisionReferenceImage.task_id == task.id)
)).scalars().all()
for reference in references:
await db.delete(reference)
await db.delete(task)
await db.flush()
await db.commit()
# A copy-on-write sibling may still point at the same file (#38.6).
for reference in references:
if not await vision_service.file_still_referenced(db, reference.path):
vision_service.remove_reference_file(reference)
# ---------------------------------------------------------------------------
+21
View File
@@ -4,7 +4,10 @@ It does not know, and must not know, whether the server or the station computed
the numbers: that is what lets a station's configuration change without touching
the frontend or the recipes.
"""
import io
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
from PIL import Image
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -90,9 +93,27 @@ async def _read_image(image: UploadFile) -> bytes:
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="the file's content does not match a supported image format",
)
_reject_animated(content)
return content
def _reject_animated(content: bytes) -> None:
"""Animated GIF/WebP/APNG have no single frame to measure (decision of
06/10/2026, #43): refuse them instead of silently reading frame 0."""
try:
with Image.open(io.BytesIO(content)) as img:
animated = getattr(img, "is_animated", False) or getattr(img, "n_frames", 1) > 1
except Exception:
# Unreadable here is not this check's business: the magic bytes passed
# and the worker decides what it can decode.
return
if animated:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="animated images are not supported",
)
@router.post("/execute", response_model=VisionExecuteResponse)
async def execute(
task_id: int = Form(...),
+15
View File
@@ -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":
+172
View File
@@ -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}
+39 -7
View File
@@ -386,14 +386,12 @@ def task_complete(recipe_id: int):
# Load measurements if version_id provided
measurements = []
if version_id:
# Scoped to this measurement session, not the whole version (#30): a
# second production run on the same recipe version used to pull in
# every other station's/day's readings too. GET /api/measurements has
# no production_run_id filter yet (that's lotto A3), so lot/serial +
# the current operator is the closest available identity - MANCA:
# once the backend accepts production_run_id, prefer it when the
# active run is known (see _current_run_id()).
# Scoped to this measurement session, not the whole version (#30): the
# active production run when known, else lot/serial + current operator.
params = {"version_id": version_id, "per_page": 500}
run_id = _current_run_id()
if run_id:
params["production_run_id"] = run_id
if lot_number:
params["lot_number"] = lot_number
if serial_number:
@@ -597,6 +595,40 @@ def api_task_progress(task_id: int):
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)
# ---------------------------------------------------------------------------
@@ -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
+88 -5
View File
@@ -248,6 +248,67 @@ class TestApiTaskProgress:
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."""
@@ -338,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
]
@@ -357,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"
@@ -371,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,
):
@@ -379,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
@@ -404,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