feat(recipes): la ricetta decide cosa pretendere e come si misura
Tre cose che prima non erano di nessuno diventano regole della ricetta, decise da chi la scrive e fatte valere dal server. Punto 8 — tracciabilita' obbligatoria. Lotto e seriale si dichiarano obbligatori sulla ricetta. L'operatore li inserisce alla selezione, dove il pulsante non si attiva finche' mancano, e la stessa regola vale sulla lista task, raggiungibile anche per link diretto, e sul barcode: uno sbarramento che dura solo finche' qualcuno prende in mano il lettore non e' uno sbarramento. La produzione non si apre e la misura non si salva senza cio' che la ricetta pretende, perche' un valore senza il suo lotto non e' riconducibile a niente, e accorgersene dopo significa accorgersene tardi. Punto 9 — inserimento manuale. Una ricetta puo' vietare i valori digitati, ed e' il valore predefinito: il calibro e' lo strumento, digitare e' cio' che va concesso. Dove e' vietato il tastierino non viene disegnato (non nascosto con i CSS: il markup nascosto e' markup che si puo' rimostrare) e restano correzione e conferma, perche' una lettura sbagliata va cancellata. Il controllo vero e' sul server: una regola che vive solo nel frontend e' un consiglio. Migliorato al passaggio il riconoscimento del calibro. Contava solo la raffica di cifre, cosi' una lettura corta come "9.5" — tre battute — finiva registrata come digitata a mano; ora conta anche l'Invio che il wedge manda dentro la stessa raffica. Senza questa correzione il divieto avrebbe respinto misure legittime. Punto 11 — formattazione delle descrizioni. A capo e grassetto sopravvivono: chi scrive le ricette incolla dal PDF della scheda tecnica e il testo arrivava appiattito, da risistemare a mano ogni volta. Nessun HTML viene accettato o salvato — il testo viene escapato e gli unici tag nel risultato sono quelli prodotti dal renderer. La sanificazione e' questa: non c'e' niente da sanificare perche' non si accetta niente. Le stesse due regole in Jinja e in JS, cosi' una descrizione si legge uguale ovunque. E la descrizione ora si vede anche in esecuzione: era scritta per l'operatore e la vedeva solo chi la scriveva. Migrazione 009: tre colonne sulla ricetta. Le due di tracciabilita' partono false, che e' il comportamento di oggi; l'inserimento manuale parte *vero* sulle ricette gia' esistenti — il default della colonna e' falso, quindi le ricette nuove sono solo-calibro, ma spegnerlo d'ufficio su quelle in uso fermerebbe una linea alla misura successiva. Chi possiede la ricetta lo decide dall'editor. Test: +26 (291). Coprono il rifiuto sul server per lotto, seriale e valore digitato, il calibro sempre ammesso, le regole che sopravvivono alla nuova versione, il tastierino assente in pagina, l'Avvia sbarrato, e il renderer delle descrizioni compreso il caso in cui si prova a farci passare un tag. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
"""traceability and manual input become rules of the recipe
|
||||
|
||||
Three settings that were nowhere: whether the lot and the serial are compulsory,
|
||||
and whether a value may be typed instead of read from the caliper. Until now lot
|
||||
and serial were optional everywhere and the keypad was always available, so a
|
||||
value that happens to be in tolerance could simply be entered by hand.
|
||||
|
||||
Backfill: the two traceability flags start false, which is exactly today's
|
||||
behaviour. Manual input starts *true* on the recipes that already exist - the
|
||||
column default is false, so recipes written from now on are caliper-only, but
|
||||
flipping the ones already in use would stop a running line at the next
|
||||
measurement. Turning them off is a decision for whoever owns the recipe, taken in
|
||||
the editor, not a side effect of an upgrade.
|
||||
|
||||
Revision ID: 009_recipe_rules
|
||||
Revises: 008_loop_events
|
||||
Create Date: 2026-07-28
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = '009_recipe_rules'
|
||||
down_revision: Union[str, None] = '008_loop_events'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
'recipes',
|
||||
sa.Column(
|
||||
'requires_lot', sa.Boolean(), nullable=False, server_default='0',
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
'recipes',
|
||||
sa.Column(
|
||||
'requires_serial', sa.Boolean(), nullable=False, server_default='0',
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
'recipes',
|
||||
sa.Column(
|
||||
'allow_manual_input', sa.Boolean(), nullable=False, server_default='0',
|
||||
),
|
||||
)
|
||||
# Recipes already in production keep the behaviour they were written under.
|
||||
op.execute("UPDATE recipes SET allow_manual_input = 1")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('recipes', 'allow_manual_input')
|
||||
op.drop_column('recipes', 'requires_serial')
|
||||
op.drop_column('recipes', 'requires_lot')
|
||||
@@ -15,6 +15,11 @@ class RecipeCreate(BaseModel):
|
||||
description: Optional[str] = None
|
||||
image_path: Optional[str] = Field(None, max_length=500)
|
||||
measurement_interval_minutes: Optional[int] = Field(None, ge=1, le=1440)
|
||||
# Rules of the recipe: what the operator must supply, and how a value may be
|
||||
# entered. Manual input defaults to forbidden - the caliper is the instrument.
|
||||
requires_lot: bool = False
|
||||
requires_serial: bool = False
|
||||
allow_manual_input: bool = False
|
||||
# Optional task-level fields for the initial technical drawing
|
||||
file_path: Optional[str] = Field(None, max_length=500)
|
||||
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
|
||||
@@ -27,6 +32,9 @@ class RecipeUpdate(BaseModel):
|
||||
description: Optional[str] = None
|
||||
image_path: Optional[str] = Field(None, max_length=500)
|
||||
measurement_interval_minutes: Optional[int] = Field(None, ge=1, le=1440)
|
||||
requires_lot: Optional[bool] = None
|
||||
requires_serial: Optional[bool] = None
|
||||
allow_manual_input: Optional[bool] = None
|
||||
change_notes: Optional[str] = None
|
||||
# Task-level fields: saved to the first task of the new version
|
||||
file_path: Optional[str] = Field(None, max_length=500)
|
||||
@@ -58,6 +66,11 @@ class RecipeResponse(BaseModel):
|
||||
description: Optional[str] = None
|
||||
image_path: Optional[str] = None
|
||||
measurement_interval_minutes: Optional[int] = None
|
||||
# The operator's screen reads these to know whether to ask for lot and serial
|
||||
# before starting, and whether to offer the keypad at all.
|
||||
requires_lot: bool = False
|
||||
requires_serial: bool = False
|
||||
allow_manual_input: bool = False
|
||||
created_by: int
|
||||
created_at: datetime
|
||||
active: bool
|
||||
|
||||
@@ -53,6 +53,10 @@ class RecipeSummary(BaseModel):
|
||||
active: bool
|
||||
image_path: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
# The selection screen has to know before the operator starts: what a recipe
|
||||
# demands is the difference between an enabled Avvia and a disabled one.
|
||||
requires_lot: bool = False
|
||||
requires_serial: bool = False
|
||||
|
||||
|
||||
class StationWithRecipesResponse(StationResponse):
|
||||
|
||||
@@ -26,6 +26,22 @@ class Recipe(Base):
|
||||
active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
|
||||
measurement_interval_minutes: Mapped[Optional[int]] = mapped_column(SmallInteger, nullable=True)
|
||||
|
||||
# Traceability, decided when the recipe is written rather than left to whoever
|
||||
# is at the machine: a measurement without the lot it belongs to cannot be
|
||||
# traced back afterwards, and tracing back is half of what the system is for.
|
||||
requires_lot: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=False, server_default="0"
|
||||
)
|
||||
requires_serial: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=False, server_default="0"
|
||||
)
|
||||
# Whether a value may be typed instead of read from the caliper. Forbidden by
|
||||
# default: without the rule, a value that happens to be in tolerance can simply
|
||||
# be entered by hand, which is the case this setting exists to prevent.
|
||||
allow_manual_input: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=False, server_default="0"
|
||||
)
|
||||
|
||||
# Relationships
|
||||
versions: Mapped[list["RecipeVersion"]] = relationship(
|
||||
back_populates="recipe", cascade="all, delete-orphan", lazy="selectin"
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"""Measurement service - pass/fail calculation, data storage."""
|
||||
from decimal import Decimal
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.backend.models.orm.measurement import Measurement
|
||||
from src.backend.models.orm.recipe import Recipe, RecipeVersion
|
||||
from src.backend.models.orm.task import RecipeSubtask
|
||||
|
||||
|
||||
@@ -39,6 +41,48 @@ def calculate_pass_fail(
|
||||
return "pass", deviation
|
||||
|
||||
|
||||
async def recipe_of_version(db: AsyncSession, version_id: int) -> Recipe | None:
|
||||
"""The recipe a version belongs to - where the rules of measurement live."""
|
||||
result = await db.execute(
|
||||
select(Recipe)
|
||||
.join(RecipeVersion, RecipeVersion.recipe_id == Recipe.id)
|
||||
.where(RecipeVersion.id == version_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
def _enforce_recipe_rules(
|
||||
recipe: Recipe | None,
|
||||
lot_number: str | None,
|
||||
serial_number: str | None,
|
||||
input_method: str,
|
||||
) -> None:
|
||||
"""Refuse a measurement the recipe does not allow.
|
||||
|
||||
Checked here rather than only on the screen because a rule that lives in the
|
||||
frontend is a suggestion: the keypad can be hidden and the same request still
|
||||
sent. This is the one place every measurement passes through.
|
||||
"""
|
||||
if recipe is None:
|
||||
return
|
||||
|
||||
if recipe.requires_lot and not (lot_number or "").strip():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail="This recipe requires a lot number",
|
||||
)
|
||||
if recipe.requires_serial and not (serial_number or "").strip():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail="This recipe requires a serial number",
|
||||
)
|
||||
if input_method == "manual" and not recipe.allow_manual_input:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail="This recipe does not allow values typed by hand: use the caliper",
|
||||
)
|
||||
|
||||
|
||||
async def save_measurement(
|
||||
db: AsyncSession,
|
||||
subtask_id: int,
|
||||
@@ -60,6 +104,9 @@ async def save_measurement(
|
||||
if subtask is None:
|
||||
raise ValueError(f"Subtask {subtask_id} not found")
|
||||
|
||||
recipe = await recipe_of_version(db, version_id)
|
||||
_enforce_recipe_rules(recipe, lot_number, serial_number, input_method)
|
||||
|
||||
pass_fail, deviation = calculate_pass_fail(value, subtask)
|
||||
|
||||
measurement = Measurement(
|
||||
|
||||
@@ -243,6 +243,20 @@ async def open_run(
|
||||
detail="Recipe has no current version",
|
||||
)
|
||||
|
||||
# Traceability the recipe declares compulsory has to be there before the first
|
||||
# measurement, not after it: a lot entered halfway through leaves the values
|
||||
# taken up to that point unattributable.
|
||||
if recipe.requires_lot and not (data.lot_number or "").strip():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail="This recipe requires a lot number to start a production",
|
||||
)
|
||||
if recipe.requires_serial and not (data.serial_number or "").strip():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail="This recipe requires a serial number to start a production",
|
||||
)
|
||||
|
||||
now = _now()
|
||||
# The interval is copied, not referenced: editing the recipe mid-production must
|
||||
# not move the deadline of a run already under way.
|
||||
|
||||
@@ -19,6 +19,30 @@ from src.backend.models.api.recipe import RecipeCreate, RecipeUpdate
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Columns of the recipe header itself, as opposed to the versioned content. Both
|
||||
# update paths - copy-on-write and in-place - write exactly these, so the list
|
||||
# lives once: a field added to one and forgotten in the other would appear to save
|
||||
# and then quietly not, depending on whether the version had measurements.
|
||||
HEADER_FIELDS = (
|
||||
"name",
|
||||
"description",
|
||||
"image_path",
|
||||
"measurement_interval_minutes",
|
||||
"requires_lot",
|
||||
"requires_serial",
|
||||
"allow_manual_input",
|
||||
)
|
||||
|
||||
|
||||
def _header_fields(data: RecipeUpdate) -> dict:
|
||||
"""The header columns the caller actually asked to change."""
|
||||
return {
|
||||
field: getattr(data, field)
|
||||
for field in HEADER_FIELDS
|
||||
if getattr(data, field) is not None
|
||||
}
|
||||
|
||||
|
||||
async def _get_recipe_or_404(db: AsyncSession, recipe_id: int) -> Recipe:
|
||||
"""Return a recipe or raise 404."""
|
||||
result = await db.execute(select(Recipe).where(Recipe.id == recipe_id))
|
||||
@@ -138,6 +162,9 @@ async def create_recipe(
|
||||
description=data.description,
|
||||
image_path=data.image_path,
|
||||
measurement_interval_minutes=data.measurement_interval_minutes,
|
||||
requires_lot=data.requires_lot,
|
||||
requires_serial=data.requires_serial,
|
||||
allow_manual_input=data.allow_manual_input,
|
||||
created_by=user.id,
|
||||
)
|
||||
db.add(recipe)
|
||||
@@ -271,15 +298,7 @@ async def create_new_version(
|
||||
await db.flush()
|
||||
|
||||
# Apply header updates
|
||||
update_fields: dict = {}
|
||||
if data.name is not None:
|
||||
update_fields["name"] = data.name
|
||||
if data.description is not None:
|
||||
update_fields["description"] = data.description
|
||||
if data.image_path is not None:
|
||||
update_fields["image_path"] = data.image_path
|
||||
if data.measurement_interval_minutes is not None:
|
||||
update_fields["measurement_interval_minutes"] = data.measurement_interval_minutes
|
||||
update_fields = _header_fields(data)
|
||||
if update_fields:
|
||||
await db.execute(
|
||||
update(Recipe).where(Recipe.id == recipe_id).values(**update_fields)
|
||||
@@ -368,16 +387,7 @@ async def update_current_version(
|
||||
data: RecipeUpdate,
|
||||
) -> RecipeVersion:
|
||||
"""Update recipe header in-place on the current version (no copy-on-write)."""
|
||||
# Apply header updates (name, description, image_path)
|
||||
update_fields: dict = {}
|
||||
if data.name is not None:
|
||||
update_fields["name"] = data.name
|
||||
if data.description is not None:
|
||||
update_fields["description"] = data.description
|
||||
if data.image_path is not None:
|
||||
update_fields["image_path"] = data.image_path
|
||||
if data.measurement_interval_minutes is not None:
|
||||
update_fields["measurement_interval_minutes"] = data.measurement_interval_minutes
|
||||
update_fields = _header_fields(data)
|
||||
if update_fields:
|
||||
await db.execute(
|
||||
update(Recipe).where(Recipe.id == recipe_id).values(**update_fields)
|
||||
|
||||
@@ -224,6 +224,10 @@ async def create_test_recipe(
|
||||
code=code,
|
||||
name=name,
|
||||
description="A recipe for testing",
|
||||
# Typing is allowed here so that tests about something else can still save
|
||||
# a measurement. The rule itself is exercised by test_recipe_rules.py, on
|
||||
# recipes that declare it explicitly.
|
||||
allow_manual_input=True,
|
||||
created_by=user_id,
|
||||
)
|
||||
session.add(recipe)
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
"""What a recipe is allowed to demand of the operator (points 8 and 9).
|
||||
|
||||
Two rules that used to be nobody's: whether the lot and the serial are compulsory,
|
||||
and whether a value may be typed rather than read from the caliper. Both are
|
||||
declared on the recipe and enforced on the server, because a rule enforced only by
|
||||
the screen is a suggestion - the keypad can be hidden and the same request still
|
||||
sent by hand.
|
||||
"""
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from src.backend.models.orm.recipe import Recipe, 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
|
||||
|
||||
|
||||
async def _rules(db_session, recipe: Recipe, **flags) -> Recipe:
|
||||
for key, value in flags.items():
|
||||
setattr(recipe, key, value)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(recipe)
|
||||
return recipe
|
||||
|
||||
|
||||
async def _first_subtask(db_session, recipe_id: int) -> RecipeSubtask:
|
||||
row = await db_session.execute(
|
||||
select(RecipeSubtask)
|
||||
.join(RecipeTask, RecipeTask.id == RecipeSubtask.task_id)
|
||||
.join(RecipeVersion, RecipeVersion.id == RecipeTask.version_id)
|
||||
.where(RecipeVersion.recipe_id == recipe_id)
|
||||
)
|
||||
return row.scalars().first()
|
||||
|
||||
|
||||
async def _current_version_id(db_session, recipe_id: int) -> int:
|
||||
row = await db_session.execute(
|
||||
select(RecipeVersion).where(
|
||||
RecipeVersion.recipe_id == recipe_id,
|
||||
RecipeVersion.is_current == True, # noqa: E712
|
||||
)
|
||||
)
|
||||
return row.scalar_one().id
|
||||
|
||||
|
||||
async def _measure(client, user, subtask_id, version_id, **extra):
|
||||
return await client.post(
|
||||
"/api/measurements/",
|
||||
headers=auth_headers(user),
|
||||
json={
|
||||
"subtask_id": subtask_id, "version_id": version_id, "value": 10.0, **extra,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The rules are part of the recipe
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_new_recipe_forbids_typing_by_default(client: AsyncClient, maker_user):
|
||||
"""The caliper is the instrument: typing is what has to be asked for."""
|
||||
resp = await client.post(
|
||||
"/api/recipes",
|
||||
headers=auth_headers(maker_user),
|
||||
json={"code": "REG-DEF", "name": "Regole di default"},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
body = resp.json()
|
||||
assert body["allow_manual_input"] is False
|
||||
assert body["requires_lot"] is False
|
||||
assert body["requires_serial"] is False
|
||||
|
||||
|
||||
async def test_recipe_carries_the_rules_it_was_created_with(
|
||||
client: AsyncClient, maker_user,
|
||||
):
|
||||
resp = await client.post(
|
||||
"/api/recipes",
|
||||
headers=auth_headers(maker_user),
|
||||
json={
|
||||
"code": "REG-SET", "name": "Con regole",
|
||||
"requires_lot": True, "requires_serial": True, "allow_manual_input": True,
|
||||
},
|
||||
)
|
||||
body = resp.json()
|
||||
assert (body["requires_lot"], body["requires_serial"], body["allow_manual_input"]) \
|
||||
== (True, True, True)
|
||||
|
||||
|
||||
async def test_rules_can_be_switched_off_again(
|
||||
client: AsyncClient, maker_user, db_session,
|
||||
):
|
||||
"""False must reach the database: a flag that only ever turns on is a trap."""
|
||||
recipe = await create_test_recipe(db_session, maker_user.id, code="REG-OFF")
|
||||
await _rules(db_session, recipe, requires_lot=True, allow_manual_input=True)
|
||||
|
||||
resp = await client.put(
|
||||
f"/api/recipes/{recipe.id}",
|
||||
headers=auth_headers(maker_user),
|
||||
json={"requires_lot": False, "allow_manual_input": False},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["requires_lot"] is False
|
||||
assert resp.json()["allow_manual_input"] is False
|
||||
|
||||
|
||||
async def test_rules_survive_a_new_version(
|
||||
client: AsyncClient, maker_user, measurement_tec_user, db_session,
|
||||
):
|
||||
"""Editing a recipe with measurements copies it; the rules must come along."""
|
||||
recipe = await create_test_recipe(db_session, maker_user.id, code="REG-VER")
|
||||
await _rules(db_session, recipe, requires_lot=True)
|
||||
subtask = await _first_subtask(db_session, recipe.id)
|
||||
version_id = await _current_version_id(db_session, recipe.id)
|
||||
|
||||
# A measurement on the current version forces copy-on-write on the next edit.
|
||||
await _measure(
|
||||
client, measurement_tec_user, subtask.id, version_id, lot_number="L-1",
|
||||
)
|
||||
resp = await client.put(
|
||||
f"/api/recipes/{recipe.id}",
|
||||
headers=auth_headers(maker_user),
|
||||
json={"name": "Rinominata"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["requires_lot"] is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Point 8 - traceability
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_measurement_without_the_required_lot_is_refused(
|
||||
client: AsyncClient, maker_user, measurement_tec_user, db_session,
|
||||
):
|
||||
recipe = await create_test_recipe(db_session, maker_user.id, code="TRC-LOT")
|
||||
await _rules(db_session, recipe, requires_lot=True)
|
||||
subtask = await _first_subtask(db_session, recipe.id)
|
||||
version_id = await _current_version_id(db_session, recipe.id)
|
||||
|
||||
refused = await _measure(client, measurement_tec_user, subtask.id, version_id)
|
||||
assert refused.status_code == 422
|
||||
|
||||
# Blank is not a lot number either.
|
||||
blank = await _measure(
|
||||
client, measurement_tec_user, subtask.id, version_id, lot_number=" ",
|
||||
)
|
||||
assert blank.status_code == 422
|
||||
|
||||
accepted = await _measure(
|
||||
client, measurement_tec_user, subtask.id, version_id, lot_number="LOT-9",
|
||||
)
|
||||
assert accepted.status_code == 200
|
||||
|
||||
|
||||
async def test_measurement_without_the_required_serial_is_refused(
|
||||
client: AsyncClient, maker_user, measurement_tec_user, db_session,
|
||||
):
|
||||
recipe = await create_test_recipe(db_session, maker_user.id, code="TRC-SER")
|
||||
await _rules(db_session, recipe, requires_serial=True)
|
||||
subtask = await _first_subtask(db_session, recipe.id)
|
||||
version_id = await _current_version_id(db_session, recipe.id)
|
||||
|
||||
assert (await _measure(
|
||||
client, measurement_tec_user, subtask.id, version_id,
|
||||
)).status_code == 422
|
||||
assert (await _measure(
|
||||
client, measurement_tec_user, subtask.id, version_id, serial_number="SN-1",
|
||||
)).status_code == 200
|
||||
|
||||
|
||||
async def test_optional_traceability_still_accepts_nothing(
|
||||
client: AsyncClient, maker_user, measurement_tec_user, db_session,
|
||||
):
|
||||
"""Recipes that do not ask for a lot must go on working as they did."""
|
||||
recipe = await create_test_recipe(db_session, maker_user.id, code="TRC-OPT")
|
||||
subtask = await _first_subtask(db_session, recipe.id)
|
||||
version_id = await _current_version_id(db_session, recipe.id)
|
||||
|
||||
assert (await _measure(
|
||||
client, measurement_tec_user, subtask.id, version_id,
|
||||
)).status_code == 200
|
||||
|
||||
|
||||
async def test_production_cannot_start_without_the_required_lot(
|
||||
client: AsyncClient, maker_user, measurement_tec_user, admin_user, db_session,
|
||||
):
|
||||
"""The lot has to be there before the first value, not after it."""
|
||||
recipe = await create_test_recipe(db_session, maker_user.id, code="TRC-RUN")
|
||||
await _rules(db_session, recipe, requires_lot=True)
|
||||
station = Station(
|
||||
code="ST-TRC", name="Stazione", active=True, created_by=admin_user.id,
|
||||
)
|
||||
db_session.add(station)
|
||||
await db_session.commit()
|
||||
|
||||
refused = await client.post(
|
||||
"/api/production-runs",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
json={"station_code": station.code, "recipe_id": recipe.id},
|
||||
)
|
||||
assert refused.status_code == 422
|
||||
|
||||
started = await client.post(
|
||||
"/api/production-runs",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
json={
|
||||
"station_code": station.code, "recipe_id": recipe.id, "lot_number": "L-7",
|
||||
},
|
||||
)
|
||||
assert started.status_code == 201
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Point 9 - typed values
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_typed_value_is_refused_when_the_recipe_forbids_it(
|
||||
client: AsyncClient, maker_user, measurement_tec_user, db_session,
|
||||
):
|
||||
recipe = await create_test_recipe(db_session, maker_user.id, code="MAN-NO")
|
||||
await _rules(db_session, recipe, allow_manual_input=False)
|
||||
subtask = await _first_subtask(db_session, recipe.id)
|
||||
version_id = await _current_version_id(db_session, recipe.id)
|
||||
|
||||
refused = await _measure(
|
||||
client, measurement_tec_user, subtask.id, version_id, input_method="manual",
|
||||
)
|
||||
assert refused.status_code == 422
|
||||
assert "caliper" in refused.json()["detail"].lower()
|
||||
|
||||
|
||||
async def test_the_caliper_is_always_welcome(
|
||||
client: AsyncClient, maker_user, measurement_tec_user, db_session,
|
||||
):
|
||||
recipe = await create_test_recipe(db_session, maker_user.id, code="MAN-USB")
|
||||
await _rules(db_session, recipe, allow_manual_input=False)
|
||||
subtask = await _first_subtask(db_session, recipe.id)
|
||||
version_id = await _current_version_id(db_session, recipe.id)
|
||||
|
||||
resp = await _measure(
|
||||
client, measurement_tec_user, subtask.id, version_id,
|
||||
input_method="usb_caliper",
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["input_method"] == "usb_caliper"
|
||||
|
||||
|
||||
async def test_typing_is_allowed_where_the_recipe_says_so(
|
||||
client: AsyncClient, maker_user, measurement_tec_user, db_session,
|
||||
):
|
||||
recipe = await create_test_recipe(db_session, maker_user.id, code="MAN-YES")
|
||||
subtask = await _first_subtask(db_session, recipe.id)
|
||||
version_id = await _current_version_id(db_session, recipe.id)
|
||||
|
||||
resp = await _measure(
|
||||
client, measurement_tec_user, subtask.id, version_id, input_method="manual",
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
async def test_the_default_input_method_is_covered_by_the_rule(
|
||||
client: AsyncClient, maker_user, measurement_tec_user, db_session,
|
||||
):
|
||||
"""A request that says nothing about how the value was entered counts as typed.
|
||||
|
||||
Otherwise omitting the field would be the way around the rule.
|
||||
"""
|
||||
recipe = await create_test_recipe(db_session, maker_user.id, code="MAN-DEF")
|
||||
await _rules(db_session, recipe, allow_manual_input=False)
|
||||
subtask = await _first_subtask(db_session, recipe.id)
|
||||
version_id = await _current_version_id(db_session, recipe.id)
|
||||
|
||||
assert (await _measure(
|
||||
client, measurement_tec_user, subtask.id, version_id,
|
||||
)).status_code == 422
|
||||
@@ -1,17 +1,22 @@
|
||||
"""TieMeasureFlow Client - Flask Entry Point."""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from datetime import date
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from flask import Flask, redirect, url_for, session, request
|
||||
from flask_babel import Babel
|
||||
from flask_wtf.csrf import CSRFProtect
|
||||
from markupsafe import Markup
|
||||
from markupsafe import Markup, escape
|
||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||
|
||||
from config import Config
|
||||
|
||||
# **bold**, the whole of the markup a task description understands besides the
|
||||
# line break. DOTALL so a phrase that wraps onto the next line still closes.
|
||||
_BOLD_RX = re.compile(r"\*\*(.+?)\*\*", re.DOTALL)
|
||||
|
||||
|
||||
def get_locale():
|
||||
"""Get user's preferred language from session or Accept-Language header."""
|
||||
@@ -74,6 +79,24 @@ def create_app() -> Flask:
|
||||
referrer = None
|
||||
return redirect(referrer or url_for("auth.login"))
|
||||
|
||||
@app.template_filter("rich_text")
|
||||
def rich_text_filter(value):
|
||||
"""Render a task description keeping its line breaks and its bold.
|
||||
|
||||
Whoever writes a recipe pastes from the PDF of the technical sheet, and the
|
||||
text used to arrive flattened. Two conventions carry it: a blank line is a
|
||||
line break, **like this** is bold.
|
||||
|
||||
No HTML is ever stored or trusted - the text is escaped first and the only
|
||||
tags in the result are the ones produced here. That is the sanitisation:
|
||||
there is nothing to sanitise, because nothing is accepted.
|
||||
"""
|
||||
if not value:
|
||||
return Markup("")
|
||||
escaped = str(escape(str(value)))
|
||||
bolded = _BOLD_RX.sub(r"<strong>\1</strong>", escaped)
|
||||
return Markup(bolded.replace("\n", "<br>"))
|
||||
|
||||
@app.template_filter("tojson_attr")
|
||||
def tojson_attr_filter(value):
|
||||
"""JSON encode safe for HTML attributes (x-data, etc.).
|
||||
|
||||
@@ -195,6 +195,9 @@ def task_execute(task_id: int):
|
||||
recipe_id = task_resp.get("recipe_id")
|
||||
all_task_ids = []
|
||||
measurement_interval_minutes = None
|
||||
# Allowed until the recipe says otherwise: a recipe that could not be read must
|
||||
# not silently take the keypad away from the operator.
|
||||
allow_manual_input = True
|
||||
if recipe_id:
|
||||
tasks_resp = api_client.get(f"/api/recipes/{recipe_id}/tasks")
|
||||
if isinstance(tasks_resp, list):
|
||||
@@ -203,6 +206,7 @@ def task_execute(task_id: int):
|
||||
recipe_resp = api_client.get(f"/api/recipes/{recipe_id}")
|
||||
if not recipe_resp.get("error"):
|
||||
measurement_interval_minutes = recipe_resp.get("measurement_interval_minutes")
|
||||
allow_manual_input = bool(recipe_resp.get("allow_manual_input", True))
|
||||
|
||||
return render_template(
|
||||
"measure/task_execute.html",
|
||||
@@ -211,6 +215,7 @@ def task_execute(task_id: int):
|
||||
serial_number=serial_number,
|
||||
all_task_ids=all_task_ids,
|
||||
measurement_interval_minutes=measurement_interval_minutes,
|
||||
allow_manual_input=allow_manual_input,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
* Used for measurement data entry in task_execute.html
|
||||
*/
|
||||
|
||||
function numpad() {
|
||||
function numpad(options) {
|
||||
var opts = options || {};
|
||||
|
||||
return {
|
||||
// State
|
||||
value: '', // String representation of the current value
|
||||
@@ -13,9 +15,16 @@ function numpad() {
|
||||
maxIntDigits: 6, // Maximum integer digits
|
||||
maxDecDigits: 6, // Maximum decimal digits
|
||||
|
||||
/* Whether a value may be typed at all, as the recipe declares it. When false
|
||||
the keypad shows no digits and a value that looks typed is refused here as
|
||||
well as by the server: the caliper is the instrument. Defaults to allowed,
|
||||
so a caller that says nothing gets the behaviour that came before. */
|
||||
allowManual: opts.allowManual !== false,
|
||||
|
||||
// HID burst detection (USB caliper vs manual typing)
|
||||
_lastKeyTime: 0, // Timestamp of last keystroke
|
||||
_burstCount: 0, // Consecutive fast keystrokes
|
||||
_enterWasFast: false, // Enter arrived in the same burst as the digits
|
||||
|
||||
/**
|
||||
* Get the display value with sign
|
||||
@@ -109,6 +118,20 @@ function numpad() {
|
||||
this.value = '';
|
||||
this.negative = false;
|
||||
this.hasDecimal = false;
|
||||
this._enterWasFast = false;
|
||||
},
|
||||
|
||||
/**
|
||||
* How the value in the display got there.
|
||||
*
|
||||
* A wedge caliper sends its digits and the Enter that follows them as one
|
||||
* burst; a person is slower on both. The Enter is the stronger of the two
|
||||
* signals: a short reading like "9.5" is only three keystrokes, too few to
|
||||
* judge by count alone, and used to be filed as typed by hand.
|
||||
*/
|
||||
_classifyInput() {
|
||||
if (this._enterWasFast) return 'usb_caliper';
|
||||
return this._burstCount >= 3 ? 'usb_caliper' : 'manual';
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -118,9 +141,18 @@ function numpad() {
|
||||
if (!this.hasValue) return;
|
||||
|
||||
const val = this.numericValue;
|
||||
const inputMethod = this._classifyInput();
|
||||
|
||||
// Determine input method: 3+ fast keystrokes = USB caliper burst
|
||||
const inputMethod = this._burstCount >= 3 ? 'usb_caliper' : 'manual';
|
||||
// The recipe forbids typing: say so and keep the value on screen rather than
|
||||
// clearing it, so the operator sees what was refused. The server refuses the
|
||||
// same request anyway - this is only the earlier, kinder of the two answers.
|
||||
if (inputMethod === 'manual' && !this.allowManual) {
|
||||
this.$dispatch('numpad-rejected', { reason: 'manual_not_allowed', value: val });
|
||||
this._enterWasFast = false;
|
||||
this._burstCount = 0;
|
||||
this._lastKeyTime = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
// Dispatch custom event for parent component to handle
|
||||
this.$dispatch('numpad-confirm', { value: val, inputMethod: inputMethod });
|
||||
@@ -199,6 +231,12 @@ function numpad() {
|
||||
// Enter - confirm
|
||||
else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
// Measured before confirming: an Enter that lands within the burst is the
|
||||
// caliper closing its own transmission, not a person reaching for a key.
|
||||
const gap = this._lastKeyTime > 0
|
||||
? performance.now() - this._lastKeyTime
|
||||
: Infinity;
|
||||
this._enterWasFast = gap < 80;
|
||||
this.confirm();
|
||||
}
|
||||
// Minus sign - toggle sign
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Task descriptions: line breaks and bold, and nothing else.
|
||||
*
|
||||
* Whoever writes a recipe pastes from the PDF of the technical sheet, and the text
|
||||
* used to arrive flattened - every line run into the next, every emphasis lost, to
|
||||
* be put back by hand each time. Two conventions carry it now: a newline is a line
|
||||
* break, **like this** is bold.
|
||||
*
|
||||
* No HTML is stored or trusted. The text is escaped first and the only tags in the
|
||||
* result are the ones produced here, so a description that contains <script> shows
|
||||
* those characters and does nothing. The server-side filter (rich_text in app.py)
|
||||
* follows exactly these rules, so a description reads the same whether the page
|
||||
* was rendered by Flask or by Alpine.
|
||||
*/
|
||||
(function (global) {
|
||||
'use strict';
|
||||
|
||||
function escapeHtml(text) {
|
||||
return String(text)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/* Escaped text, then the two tags we add ourselves. Order matters: escaping
|
||||
after the substitution would turn our own <strong> into visible text. */
|
||||
function richText(value) {
|
||||
if (!value) return '';
|
||||
return escapeHtml(value)
|
||||
.replace(/\*\*([\s\S]+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\n/g, '<br>');
|
||||
}
|
||||
|
||||
/* Wrap whatever is selected in a textarea in ** **, the way a B button does.
|
||||
Dispatching 'input' is what keeps x-model in step: assigning to value alone
|
||||
changes the DOM and leaves Alpine with the old string. */
|
||||
function wrapSelectionBold(el) {
|
||||
if (!el) return;
|
||||
var start = el.selectionStart;
|
||||
var end = el.selectionEnd;
|
||||
var text = el.value || '';
|
||||
el.value = text.slice(0, start) + '**' + text.slice(start, end) + '**' + text.slice(end);
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
el.focus();
|
||||
// Leave the cursor around the same words, now inside the markers.
|
||||
el.selectionStart = start + 2;
|
||||
el.selectionEnd = end + 2;
|
||||
}
|
||||
|
||||
global.richText = richText;
|
||||
global.wrapSelectionBold = wrapSelectionBold;
|
||||
})(window);
|
||||
@@ -1,6 +1,16 @@
|
||||
<!-- Numpad Component - Touch-friendly numeric keypad for measurement input -->
|
||||
{#
|
||||
allow_manual_input comes from the recipe. When it is false the digits are not
|
||||
drawn at all - not hidden with CSS: they are the only unambiguously typed way
|
||||
in, and markup that is merely hidden is still markup that can be shown again.
|
||||
|
||||
The keyboard listener stays either way: that is how the USB caliper arrives, and
|
||||
the component still checks at confirm time, because a wedge and a keyboard are
|
||||
the same device as far as the browser is concerned.
|
||||
#}
|
||||
{% set allow_manual = allow_manual_input if allow_manual_input is defined else true %}
|
||||
<div
|
||||
x-data="numpad()"
|
||||
x-data="numpad({ allowManual: {{ allow_manual|tojson }} })"
|
||||
@keydown.window="handleKeydown($event)"
|
||||
class="numpad-container w-full max-w-sm mx-auto"
|
||||
>
|
||||
@@ -18,6 +28,32 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if not allow_manual %}
|
||||
<!-- Caliper only: no digits, just the corrections and the confirm -->
|
||||
<div>
|
||||
<div class="mb-3 flex items-center justify-center gap-2 px-3 py-2 rounded-lg
|
||||
bg-primary-50 dark:bg-primary-900/20 border border-primary-200 dark:border-primary-800">
|
||||
<svg class="w-4 h-4 text-primary shrink-0" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
<span class="text-xs font-medium text-primary-700 dark:text-primary-300">
|
||||
{{ _('Misura solo da calibro: questa ricetta non ammette valori digitati') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<button @click="backspace()" type="button"
|
||||
class="min-h-[56px] font-mono text-xl font-semibold rounded-xl border border-slate-200 dark:border-slate-600 bg-slate-100 dark:bg-slate-600 text-steel dark:text-steel-light hover:bg-slate-50 dark:hover:bg-slate-600 active:scale-95 transition-all duration-150 select-none cursor-pointer flex items-center justify-center touch-manipulation focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
>⌫</button>
|
||||
<button @click="clearAll()" type="button"
|
||||
class="min-h-[56px] font-mono text-xl font-semibold rounded-xl border border-slate-200 dark:border-slate-600 bg-slate-100 dark:bg-slate-600 text-measure-fail hover:bg-slate-50 dark:hover:bg-slate-600 active:scale-95 transition-all duration-150 select-none cursor-pointer flex items-center justify-center touch-manipulation focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
>C</button>
|
||||
<button @click="confirm()" type="button" :disabled="!hasValue"
|
||||
class="min-h-[56px] font-mono text-xl font-semibold rounded-xl border border-primary bg-primary text-white hover:bg-primary-dark active:scale-95 transition-all duration-150 select-none cursor-pointer flex items-center justify-center touch-manipulation focus:outline-none focus:ring-2 focus:ring-primary/50 disabled:opacity-40 disabled:cursor-not-allowed disabled:active:scale-100 disabled:hover:bg-primary"
|
||||
>✓</button>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
|
||||
<!-- Keypad Grid 4x4 -->
|
||||
<div class="grid grid-cols-4 gap-2">
|
||||
<!-- Row 1: 7 8 9 ⌫ -->
|
||||
@@ -118,4 +154,5 @@
|
||||
class="min-h-[56px] min-w-[56px] font-mono text-xl font-semibold rounded-xl border border-primary bg-primary text-white hover:bg-primary-dark active:scale-95 transition-all duration-150 select-none cursor-pointer flex items-center justify-center touch-manipulation focus:outline-none focus:ring-2 focus:ring-primary/50 disabled:opacity-40 disabled:cursor-not-allowed disabled:active:scale-100 disabled:hover:bg-primary"
|
||||
>✓</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
{#
|
||||
The whole formatting toolbar a task description gets: bold, plus the reminder
|
||||
that a line break is a line break.
|
||||
|
||||
Whoever writes a recipe pastes from the PDF of the technical sheet; the text used
|
||||
to arrive flattened and had to be put back by hand every time. `target` is the
|
||||
x-ref of the textarea this toolbar acts on.
|
||||
#}
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<button type="button"
|
||||
@click="wrapSelectionBold($refs.{{ target }})"
|
||||
title="{{ _('Grassetto: racchiude il testo selezionato fra **') }}"
|
||||
class="px-2 py-0.5 rounded border border-[var(--border-color)]
|
||||
text-sm font-bold leading-none
|
||||
text-[var(--text-secondary)] hover:text-primary hover:border-primary
|
||||
transition-colors">
|
||||
B
|
||||
</button>
|
||||
<span class="text-[11px] text-[var(--text-muted)]">
|
||||
{{ _('**grassetto** · a capo = a capo') }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -248,6 +248,48 @@
|
||||
{{ _('Timer cicalino per ricordare la misurazione periodica') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Regole di misura: cosa la ricetta pretende dall'operatore -->
|
||||
<div class="sm:col-span-2 pt-2 border-t border-[var(--border-color)]">
|
||||
<p class="tmf-label mb-2">{{ _('Regole di misura') }}</p>
|
||||
<div class="grid sm:grid-cols-3 gap-3">
|
||||
|
||||
<label class="flex items-start gap-2 cursor-pointer">
|
||||
<input type="checkbox" x-model="requiresLot"
|
||||
class="mt-0.5 rounded border-[var(--border-color)] text-primary focus:ring-primary">
|
||||
<span>
|
||||
<span class="text-sm text-[var(--text-primary)]">{{ _('Lotto obbligatorio') }}</span>
|
||||
<span class="block text-xs text-[var(--text-muted)]">
|
||||
{{ _('Senza lotto la produzione non parte') }}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="flex items-start gap-2 cursor-pointer">
|
||||
<input type="checkbox" x-model="requiresSerial"
|
||||
class="mt-0.5 rounded border-[var(--border-color)] text-primary focus:ring-primary">
|
||||
<span>
|
||||
<span class="text-sm text-[var(--text-primary)]">{{ _('Seriale obbligatorio') }}</span>
|
||||
<span class="block text-xs text-[var(--text-muted)]">
|
||||
{{ _('Senza seriale la produzione non parte') }}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{# Off by default: the caliper is the instrument, and a value that
|
||||
happens to be in tolerance must not be enterable by hand. #}
|
||||
<label class="flex items-start gap-2 cursor-pointer">
|
||||
<input type="checkbox" x-model="allowManualInput"
|
||||
class="mt-0.5 rounded border-[var(--border-color)] text-primary focus:ring-primary">
|
||||
<span>
|
||||
<span class="text-sm text-[var(--text-primary)]">{{ _('Consenti inserimento manuale') }}</span>
|
||||
<span class="block text-xs text-[var(--text-muted)]">
|
||||
{{ _('Se disattivo si misura solo col calibro') }}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -486,6 +528,13 @@ function recipeEditor() {
|
||||
// ---- Measurement interval ----
|
||||
measurementIntervalMinutes: {{ (recipe.measurement_interval_minutes if recipe and recipe.measurement_interval_minutes else 'null')|tojson }},
|
||||
|
||||
// ---- Rules of measurement (points 8 and 9) ----
|
||||
// A new recipe forbids typing: the caliper is the instrument, and permission
|
||||
// to type is what has to be given, not taken away.
|
||||
requiresLot: {{ (recipe.requires_lot|default(false) if recipe else false)|tojson }},
|
||||
requiresSerial: {{ (recipe.requires_serial|default(false) if recipe else false)|tojson }},
|
||||
allowManualInput: {{ (recipe.allow_manual_input|default(false) if recipe else false)|tojson }},
|
||||
|
||||
// ---- File upload (preview image) ----
|
||||
currentFilePath: {{ (recipe.image_path if recipe and recipe.image_path else '')|tojson }},
|
||||
uploadingFile: false,
|
||||
@@ -512,6 +561,12 @@ function recipeEditor() {
|
||||
payload.measurement_interval_minutes = parseInt(this.measurementIntervalMinutes, 10);
|
||||
}
|
||||
|
||||
// Rules of measurement. Sent every time, including when false: these are the
|
||||
// settings an operator gets told about, so turning one off has to travel.
|
||||
payload.requires_lot = !!this.requiresLot;
|
||||
payload.requires_serial = !!this.requiresSerial;
|
||||
payload.allow_manual_input = !!this.allowManualInput;
|
||||
|
||||
// Include image_path for preview thumbnail
|
||||
if (this.currentFilePath) {
|
||||
payload.image_path = this.currentFilePath;
|
||||
|
||||
@@ -188,7 +188,7 @@
|
||||
<h2 class="font-semibold text-[var(--text-primary)]">{{ task.title }}</h2>
|
||||
{% if task.directive or task.description %}
|
||||
<p class="text-xs text-[var(--text-secondary)] mt-0.5">
|
||||
{{ task.directive or task.description }}
|
||||
{{ (task.directive or task.description)|rich_text }}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -292,8 +292,14 @@
|
||||
</div>
|
||||
<!-- Descrizione -->
|
||||
<div class="sm:col-span-2">
|
||||
<label class="tmf-label">{{ _('Descrizione') }}</label>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<label class="tmf-label">{{ _('Descrizione') }}</label>
|
||||
{% with target = 'newTaskDescription' %}
|
||||
{% include "components/rich_text_toolbar.html" %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
<textarea x-model="newTask.description"
|
||||
x-ref="newTaskDescription"
|
||||
class="tmf-input text-sm"
|
||||
rows="3"
|
||||
placeholder="{{ _('Descrizione opzionale...') }}"></textarea>
|
||||
@@ -519,7 +525,10 @@
|
||||
<template x-if="task.description">
|
||||
<div class="flex items-start gap-2">
|
||||
<span class="text-xs font-semibold text-[var(--text-muted)] uppercase tracking-wide shrink-0 mt-0.5">{{ _('Descrizione') }}:</span>
|
||||
<span class="text-sm text-[var(--text-secondary)] whitespace-pre-wrap" x-text="task.description"></span>
|
||||
{# Shown the way the operator will see it - the point of the
|
||||
formatting is that the maker can check it here. #}
|
||||
<span class="text-sm text-[var(--text-secondary)]"
|
||||
x-html="richText(task.description)"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -544,8 +553,14 @@
|
||||
placeholder="{{ _('Direttiva opzionale...') }}">
|
||||
</div>
|
||||
<div class="sm:col-span-2">
|
||||
<label class="tmf-label">{{ _('Descrizione') }}</label>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<label class="tmf-label">{{ _('Descrizione') }}</label>
|
||||
{% with target = 'editTaskDescription' %}
|
||||
{% include "components/rich_text_toolbar.html" %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
<textarea x-model="editTaskData.description"
|
||||
x-ref="editTaskDescription"
|
||||
class="tmf-input text-sm"
|
||||
rows="3"
|
||||
placeholder="{{ _('Descrizione opzionale...') }}"></textarea>
|
||||
@@ -1076,6 +1091,8 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
{# Bold and line breaks in descriptions: the same rules the server renders with. #}
|
||||
<script src="{{ url_for('static', filename='js/rich-text.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='vendor/pdf-3.11.174.min.js') }}"></script>
|
||||
<script>if(typeof pdfjsLib!=='undefined')pdfjsLib.GlobalWorkerOptions.workerSrc='{{ url_for('static', filename='vendor/pdf.worker-3.11.174.min.js') }}';</script>
|
||||
<script src="{{ url_for('static', filename='js/annotation-viewer.js') }}?v=6"></script>
|
||||
|
||||
@@ -22,6 +22,15 @@
|
||||
(r.description || '').toLowerCase().includes(q)
|
||||
);
|
||||
},
|
||||
/* Which compulsory traceability fields this recipe is still missing.
|
||||
Empty means the operator can start. The server refuses the same thing
|
||||
when the production opens - this is only the earlier answer. */
|
||||
missingTraceability(recipe) {
|
||||
const missing = [];
|
||||
if (recipe.requires_lot && !this.lot_number.trim()) missing.push('{{ _('lotto') }}');
|
||||
if (recipe.requires_serial && !this.serial_number.trim()) missing.push('{{ _('seriale') }}');
|
||||
return missing;
|
||||
},
|
||||
buildTaskUrl(recipeId) {
|
||||
let url = '/measure/tasks/' + recipeId + '?';
|
||||
const params = [];
|
||||
@@ -47,6 +56,14 @@
|
||||
if (data.error) {
|
||||
this.barcodeError = data.detail || '{{ _("Ricetta non trovata") }}';
|
||||
} else {
|
||||
// The scanner is a way in like any other: a recipe that demands a lot
|
||||
// demands it here too, or the rule would last exactly as long as it
|
||||
// took someone to reach for the barcode reader.
|
||||
const missing = this.missingTraceability(data);
|
||||
if (missing.length) {
|
||||
this.barcodeError = '{{ _("Compila prima:") }} ' + missing.join(', ');
|
||||
return;
|
||||
}
|
||||
this.barcodeModal = false;
|
||||
this.barcodeInput = '';
|
||||
window.location.href = this.buildTaskUrl(data.id);
|
||||
@@ -152,7 +169,7 @@
|
||||
</label>
|
||||
<input type="text"
|
||||
x-model="lot_number"
|
||||
placeholder="{{ _('Es. LOT-2026-001 (opzionale)') }}"
|
||||
placeholder="{{ _('Es. LOT-2026-001') }}"
|
||||
class="tmf-input font-mono">
|
||||
</div>
|
||||
|
||||
@@ -166,7 +183,7 @@
|
||||
</label>
|
||||
<input type="text"
|
||||
x-model="serial_number"
|
||||
placeholder="{{ _('Es. SN-000123 (opzionale)') }}"
|
||||
placeholder="{{ _('Es. SN-000123') }}"
|
||||
class="tmf-input font-mono">
|
||||
</div>
|
||||
</div>
|
||||
@@ -248,9 +265,26 @@
|
||||
<!-- Card Footer -->
|
||||
<div class="px-5 py-3.5 border-t border-[var(--border-color)] bg-[var(--bg-secondary)]
|
||||
rounded-b-xl">
|
||||
<a :href="buildTaskUrl(recipe.id)"
|
||||
|
||||
{# What this recipe insists on before anyone measures. Said on the card
|
||||
rather than after the fact: a measurement taken without its lot cannot
|
||||
be traced back, and finding that out later is finding it out too late. #}
|
||||
<template x-if="missingTraceability(recipe).length">
|
||||
<p class="mb-2 flex items-start gap-1.5 text-xs text-amber-700 dark:text-amber-300">
|
||||
<svg class="w-3.5 h-3.5 shrink-0 mt-px" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"/>
|
||||
</svg>
|
||||
<span x-text="'{{ _('Compila prima:') }} ' + missingTraceability(recipe).join(', ')"></span>
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<a :href="missingTraceability(recipe).length ? null : buildTaskUrl(recipe.id)"
|
||||
:aria-disabled="missingTraceability(recipe).length > 0"
|
||||
:class="missingTraceability(recipe).length
|
||||
? 'opacity-40 cursor-not-allowed pointer-events-none'
|
||||
: 'group-hover:shadow-md'"
|
||||
class="btn btn-primary w-full justify-center text-sm font-semibold
|
||||
group-hover:shadow-md transition-shadow duration-200">
|
||||
transition-shadow duration-200">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"/>
|
||||
</svg>
|
||||
|
||||
@@ -67,6 +67,7 @@
|
||||
x-data="taskExecute()"
|
||||
x-init="init()"
|
||||
@numpad-confirm.window="handleMeasurement($event.detail.value, $event.detail.inputMethod)"
|
||||
@numpad-rejected.window="onNumpadRejected($event.detail)"
|
||||
@marker-click.window="goToSubtaskByMarker($event.detail.marker_number)">
|
||||
|
||||
{# ================================================================
|
||||
@@ -229,6 +230,26 @@
|
||||
────────────────────────────────────────────── #}
|
||||
<div class="flex-1 flex flex-col overflow-hidden bg-[var(--bg-secondary)]">
|
||||
|
||||
{# What the task says to do. It was written for the operator and until now
|
||||
only the maker ever saw it; the line breaks and the bold of the technical
|
||||
sheet survive here as they do in the editor. #}
|
||||
{% if task.directive or task.description %}
|
||||
<div class="shrink-0 max-h-36 overflow-y-auto px-4 py-2.5
|
||||
bg-[var(--bg-card)] border-b border-[var(--border-color)]">
|
||||
{% if task.directive %}
|
||||
<p class="text-sm font-semibold text-[var(--text-primary)] leading-snug">
|
||||
{{ task.directive|rich_text }}
|
||||
</p>
|
||||
{% endif %}
|
||||
{% if task.description %}
|
||||
<div class="text-sm text-[var(--text-secondary)] leading-relaxed
|
||||
{% if task.directive %}mt-1{% endif %}">
|
||||
{{ task.description|rich_text }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Main image area #}
|
||||
<div class="flex-1 overflow-hidden relative">
|
||||
|
||||
@@ -1034,6 +1055,15 @@ function taskExecute() {
|
||||
return m ? m.pass_fail : null;
|
||||
},
|
||||
|
||||
/* The keypad refused a value because this recipe wants the caliper. Said on
|
||||
the same line as any other measurement error, so there is one place to look
|
||||
when a value does not go in. */
|
||||
onNumpadRejected(detail) {
|
||||
if (detail && detail.reason === 'manual_not_allowed') {
|
||||
this.errorMessage = '{{ _("Questa ricetta non ammette valori digitati: usare il calibro") }}';
|
||||
}
|
||||
},
|
||||
|
||||
// ---- Handle numpad confirm ----
|
||||
async handleMeasurement(value, inputMethod) {
|
||||
if (!this.currentSubtask || this.saving) return;
|
||||
|
||||
@@ -75,8 +75,28 @@
|
||||
</div>
|
||||
|
||||
<!-- Right: AVVIA + Traceability -->
|
||||
{#
|
||||
Avvia does not start while the recipe is still owed its traceability.
|
||||
The selection screen asks for it first, but this page can be reached
|
||||
directly - by a bookmark, or by going back - and the rule has to hold
|
||||
on whichever door the operator comes through.
|
||||
#}
|
||||
{% set needs_lot = recipe.requires_lot|default(false) and not lot_number %}
|
||||
{% set needs_serial = recipe.requires_serial|default(false) and not serial_number %}
|
||||
{% set missing_trace = ([_('lotto')] if needs_lot else [])
|
||||
+ ([_('seriale')] if needs_serial else []) %}
|
||||
<div class="flex flex-col gap-3 sm:items-end shrink-0">
|
||||
{% if tasks %}
|
||||
{% if missing_trace %}
|
||||
<a href="{{ url_for('measure.select_recipe', recipe=recipe.code) }}"
|
||||
class="btn gap-3 w-full sm:w-auto justify-center text-lg font-bold
|
||||
bg-steel-400 text-white px-8 py-3 rounded-xl cursor-pointer">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"/>
|
||||
</svg>
|
||||
{{ _('Compila prima:') }} {{ missing_trace|join(', ') }}
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="{{ url_for('measure.task_execute', task_id=tasks[0].id) }}"
|
||||
class="btn gap-3 w-full sm:w-auto justify-center text-lg font-bold
|
||||
bg-red-600 hover:bg-red-700 text-white shadow-lg hover:shadow-xl
|
||||
@@ -87,6 +107,7 @@
|
||||
{{ _('AVVIA') }}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if lot_number %}
|
||||
<div class="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm
|
||||
bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800
|
||||
@@ -162,7 +183,7 @@
|
||||
<!-- Directive -->
|
||||
{% if task.directive or task.description %}
|
||||
<p class="text-xs text-[var(--text-secondary)] leading-snug mt-0.5 line-clamp-2">
|
||||
{{ task.directive or task.description }}
|
||||
{{ (task.directive or task.description)|rich_text }}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
"""The screens that carry the rules of a recipe (points 8, 9 and 11).
|
||||
|
||||
The server refuses what the recipe forbids; these tests are about the operator
|
||||
being told before the refusal rather than after it - a keypad that is not there,
|
||||
a start button that will not start, a description that reads the way it was
|
||||
written.
|
||||
"""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Point 11 - line breaks and bold, and nothing else
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _render(flask_app, template_string, **context):
|
||||
with flask_app.test_request_context():
|
||||
return flask_app.jinja_env.from_string(template_string).render(**context)
|
||||
|
||||
|
||||
def test_rich_text_keeps_line_breaks(flask_app):
|
||||
out = _render(flask_app, "{{ v|rich_text }}", v="prima\nseconda")
|
||||
assert out == "prima<br>seconda"
|
||||
|
||||
|
||||
def test_rich_text_makes_bold(flask_app):
|
||||
out = _render(flask_app, "{{ v|rich_text }}", v="quota **critica** qui")
|
||||
assert out == "quota <strong>critica</strong> qui"
|
||||
|
||||
|
||||
def test_rich_text_escapes_everything_else(flask_app):
|
||||
"""No HTML is accepted, so there is nothing to sanitise away later."""
|
||||
out = _render(flask_app, "{{ v|rich_text }}", v="<script>alert(1)</script>")
|
||||
assert "<script>" not in out
|
||||
assert "<script>" in out
|
||||
|
||||
|
||||
def test_rich_text_cannot_be_tricked_into_tags(flask_app):
|
||||
"""Bold markers around a tag must not reassemble it."""
|
||||
out = _render(flask_app, "{{ v|rich_text }}", v="**<b onclick='x'>ciao</b>**")
|
||||
assert "onclick" not in out or "'" in out
|
||||
assert "<b " not in out
|
||||
assert out.startswith("<strong><b")
|
||||
|
||||
|
||||
def test_rich_text_of_nothing_is_nothing(flask_app):
|
||||
assert _render(flask_app, "{{ v|rich_text }}", v=None) == ""
|
||||
assert _render(flask_app, "{{ v|rich_text }}", v="") == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Point 9 - the keypad is not offered when the recipe forbids typing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
TASK = {
|
||||
"id": 11,
|
||||
"recipe_id": 3,
|
||||
"version_id": 5,
|
||||
"task_type": "measure",
|
||||
"title": "Quota",
|
||||
"directive": None,
|
||||
"description": "prima riga\n**grassetto**",
|
||||
"file_path": None,
|
||||
"file_type": None,
|
||||
"annotations_json": None,
|
||||
"subtasks": [{
|
||||
"id": 21, "marker_number": 1, "description": "Altezza", "order_index": 0,
|
||||
"nominal": 10.0, "utl": 10.5, "uwl": 10.2, "lwl": 9.8, "ltl": 9.5,
|
||||
"unit": "mm",
|
||||
}],
|
||||
}
|
||||
|
||||
|
||||
def _execute_page(client, mock_api_client, allow_manual):
|
||||
mock_api_client.get.side_effect = [
|
||||
TASK,
|
||||
[{"id": 11, "order_index": 0}],
|
||||
{
|
||||
"id": 3, "measurement_interval_minutes": 30,
|
||||
"allow_manual_input": allow_manual,
|
||||
},
|
||||
]
|
||||
resp = client.get("/measure/execute/11")
|
||||
assert resp.status_code == 200
|
||||
return resp.get_data(as_text=True)
|
||||
|
||||
|
||||
def test_keypad_digits_are_absent_when_typing_is_forbidden(
|
||||
logged_in_client, mock_api_client,
|
||||
):
|
||||
html = _execute_page(logged_in_client, mock_api_client, allow_manual=False)
|
||||
assert "allowManual: false" in html
|
||||
assert "addDigit('7')" not in html, "i tasti numerici non devono essere in pagina"
|
||||
# The corrections and the confirm stay: a wrong caliper reading has to be
|
||||
# clearable, and the value still has to be confirmable.
|
||||
assert "clearAll()" in html
|
||||
assert "confirm()" in html
|
||||
|
||||
|
||||
def test_keypad_is_there_when_the_recipe_allows_typing(
|
||||
logged_in_client, mock_api_client,
|
||||
):
|
||||
html = _execute_page(logged_in_client, mock_api_client, allow_manual=True)
|
||||
assert "allowManual: true" in html
|
||||
assert "addDigit('7')" in html
|
||||
|
||||
|
||||
def test_keypad_stays_when_the_recipe_cannot_be_read(
|
||||
logged_in_client, mock_api_client,
|
||||
):
|
||||
"""A recipe that failed to load must not silently take the keypad away."""
|
||||
mock_api_client.get.side_effect = [
|
||||
TASK,
|
||||
[{"id": 11, "order_index": 0}],
|
||||
{"error": True, "detail": "boom"},
|
||||
]
|
||||
resp = logged_in_client.get("/measure/execute/11")
|
||||
assert resp.status_code == 200
|
||||
assert "allowManual: true" in resp.get_data(as_text=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Point 11 on the measurement screen
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_the_operator_sees_the_description_formatted(
|
||||
logged_in_client, mock_api_client,
|
||||
):
|
||||
"""It was written for the operator, and until now only the maker saw it."""
|
||||
html = _execute_page(logged_in_client, mock_api_client, allow_manual=True)
|
||||
assert "prima riga<br><strong>grassetto</strong>" in html
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Point 8 - the start control knows what the recipe demands
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _task_list(client, mock_api_client, recipe, session_lot=None):
|
||||
# The fixture session is English; these assertions read the Italian source
|
||||
# strings, so the locale is pinned rather than assumed.
|
||||
with client.session_transaction() as sess:
|
||||
sess["language"] = "it"
|
||||
if session_lot is not None:
|
||||
sess["lot_number"] = session_lot
|
||||
mock_api_client.get.side_effect = [
|
||||
recipe,
|
||||
[{"id": 11, "title": "Task", "order_index": 0, "task_type": "measure"}],
|
||||
]
|
||||
resp = client.get("/measure/tasks/3")
|
||||
assert resp.status_code == 200
|
||||
return resp.get_data(as_text=True)
|
||||
|
||||
|
||||
def test_avvia_does_not_start_without_the_required_lot(
|
||||
logged_in_client, mock_api_client,
|
||||
):
|
||||
"""This page is reachable directly, so the rule has to hold here too."""
|
||||
html = _task_list(
|
||||
logged_in_client, mock_api_client,
|
||||
{"id": 3, "code": "REC-1", "name": "Con lotto", "requires_lot": True},
|
||||
)
|
||||
assert "Compila prima" in html
|
||||
# The way in is closed, not merely greyed: it leads back to where the lot is
|
||||
# asked for. (The per-task links below belong to the Maker's own view.)
|
||||
assert "/measure/select?recipe=REC-1" in html
|
||||
|
||||
|
||||
def test_avvia_starts_once_the_lot_is_there(logged_in_client, mock_api_client):
|
||||
html = _task_list(
|
||||
logged_in_client, mock_api_client,
|
||||
{"id": 3, "code": "REC-1", "name": "Con lotto", "requires_lot": True},
|
||||
session_lot="LOT-1",
|
||||
)
|
||||
assert "Compila prima" not in html
|
||||
assert "/measure/select?recipe=REC-1" not in html
|
||||
|
||||
|
||||
def test_avvia_is_untouched_when_nothing_is_required(
|
||||
logged_in_client, mock_api_client,
|
||||
):
|
||||
html = _task_list(
|
||||
logged_in_client, mock_api_client,
|
||||
{"id": 3, "code": "REC-1", "name": "Libera"},
|
||||
)
|
||||
assert "Compila prima" not in html
|
||||
assert "/measure/select?recipe=REC-1" not in html
|
||||
|
||||
|
||||
def test_selection_screen_carries_the_traceability_rules(
|
||||
logged_in_client, mock_api_client, monkeypatch,
|
||||
):
|
||||
monkeypatch.setenv("STATION_CODE", "ST-TEST")
|
||||
import importlib
|
||||
|
||||
import config
|
||||
importlib.reload(config)
|
||||
import blueprints.measure
|
||||
importlib.reload(blueprints.measure)
|
||||
|
||||
mock_api_client.get_station_recipes.return_value = [
|
||||
{
|
||||
"id": 1, "code": "REC-001", "name": "Con lotto",
|
||||
"requires_lot": True, "requires_serial": False,
|
||||
},
|
||||
]
|
||||
resp = logged_in_client.get("/measure/select")
|
||||
assert resp.status_code == 200
|
||||
html = resp.get_data(as_text=True)
|
||||
assert "missingTraceability" in html
|
||||
# The rule travels with the recipe data the cards are built from.
|
||||
assert "requires_lot" in html
|
||||
@@ -266,6 +266,39 @@ def test_task_execute_inline_js_is_valid(logged_in_client, mock_measure_api):
|
||||
_check_alpine_attributes(html, "/measure/execute")
|
||||
|
||||
|
||||
def test_select_recipe_inline_js_is_valid(logged_in_client, mock_measure_api, monkeypatch):
|
||||
"""The selection screen decides whether Avvia starts at all.
|
||||
|
||||
Its expressions are inline in x-data, so a broken one there takes the search,
|
||||
the barcode scanner and the traceability check down with it.
|
||||
"""
|
||||
monkeypatch.setenv("STATION_CODE", "ST-TEST")
|
||||
import importlib
|
||||
|
||||
import config
|
||||
importlib.reload(config)
|
||||
import blueprints.measure
|
||||
importlib.reload(blueprints.measure)
|
||||
|
||||
_force_italian(logged_in_client)
|
||||
mock_measure_api.get_station_recipes.return_value = [
|
||||
{
|
||||
"id": 1, "code": "REC-001", "name": "Ricetta con l'apostrofo",
|
||||
"description": "Con l'apostrofo anche qui",
|
||||
"requires_lot": True, "requires_serial": True,
|
||||
},
|
||||
]
|
||||
|
||||
resp = logged_in_client.get("/measure/select")
|
||||
assert resp.status_code == 200
|
||||
html = resp.get_data(as_text=True)
|
||||
|
||||
for i, body in enumerate(_INLINE_SCRIPT_RX.findall(html)):
|
||||
_node_check(body, f"/measure/select script[{i}]")
|
||||
|
||||
_check_alpine_attributes(html, "/measure/select")
|
||||
|
||||
|
||||
def test_task_list_inline_js_is_valid(logged_in_client, mock_measure_api):
|
||||
"""The task list carries the shared measurement clock.
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user