From 5aa3d595ad63022bfc936743e778f0fd950a1e41 Mon Sep 17 00:00:00 2001 From: Adriano Dal Pastro Date: Tue, 28 Jul 2026 20:27:19 +0000 Subject: [PATCH] feat(recipes): la ricetta decide cosa pretendere e come si misura MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../versions/009_recipe_measurement_rules.py | 57 ++ src/backend/models/api/recipe.py | 13 + src/backend/models/api/station.py | 4 + src/backend/models/orm/recipe.py | 16 + src/backend/services/measurement_service.py | 47 ++ src/backend/services/production_service.py | 14 + src/backend/services/recipe_service.py | 48 +- src/backend/tests/conftest.py | 4 + src/backend/tests/test_recipe_rules.py | 280 +++++++++ src/frontend/flask_app/app.py | 25 +- src/frontend/flask_app/blueprints/measure.py | 5 + src/frontend/flask_app/static/js/numpad.js | 44 +- src/frontend/flask_app/static/js/rich-text.js | 54 ++ .../templates/components/numpad.html | 39 +- .../components/rich_text_toolbar.html | 22 + .../templates/maker/recipe_editor.html | 55 ++ .../templates/maker/recipe_preview.html | 2 +- .../templates/maker/task_editor.html | 23 +- .../templates/measure/select_recipe.html | 42 +- .../templates/measure/task_execute.html | 30 + .../templates/measure/task_list.html | 23 +- .../flask_app/tests/test_recipe_rules_ui.py | 213 +++++++ .../tests/test_template_js_syntax.py | 33 ++ .../translations/en/LC_MESSAGES/messages.po | 534 ++++++++++-------- .../translations/it/LC_MESSAGES/messages.po | 534 ++++++++++-------- .../flask_app/translations/messages.pot | 529 +++++++++-------- 26 files changed, 1945 insertions(+), 745 deletions(-) create mode 100644 src/backend/migrations/versions/009_recipe_measurement_rules.py create mode 100644 src/backend/tests/test_recipe_rules.py create mode 100644 src/frontend/flask_app/static/js/rich-text.js create mode 100644 src/frontend/flask_app/templates/components/rich_text_toolbar.html create mode 100644 src/frontend/flask_app/tests/test_recipe_rules_ui.py diff --git a/src/backend/migrations/versions/009_recipe_measurement_rules.py b/src/backend/migrations/versions/009_recipe_measurement_rules.py new file mode 100644 index 0000000..1c39061 --- /dev/null +++ b/src/backend/migrations/versions/009_recipe_measurement_rules.py @@ -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') diff --git a/src/backend/models/api/recipe.py b/src/backend/models/api/recipe.py index 2f31e47..f46d504 100644 --- a/src/backend/models/api/recipe.py +++ b/src/backend/models/api/recipe.py @@ -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 diff --git a/src/backend/models/api/station.py b/src/backend/models/api/station.py index 24e8fd1..23afe9d 100644 --- a/src/backend/models/api/station.py +++ b/src/backend/models/api/station.py @@ -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): diff --git a/src/backend/models/orm/recipe.py b/src/backend/models/orm/recipe.py index 95faa12..a329947 100644 --- a/src/backend/models/orm/recipe.py +++ b/src/backend/models/orm/recipe.py @@ -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" diff --git a/src/backend/services/measurement_service.py b/src/backend/services/measurement_service.py index e63f968..2ff7084 100644 --- a/src/backend/services/measurement_service.py +++ b/src/backend/services/measurement_service.py @@ -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( diff --git a/src/backend/services/production_service.py b/src/backend/services/production_service.py index 6ffa36d..8588475 100644 --- a/src/backend/services/production_service.py +++ b/src/backend/services/production_service.py @@ -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. diff --git a/src/backend/services/recipe_service.py b/src/backend/services/recipe_service.py index 8d40812..eac16ce 100644 --- a/src/backend/services/recipe_service.py +++ b/src/backend/services/recipe_service.py @@ -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) diff --git a/src/backend/tests/conftest.py b/src/backend/tests/conftest.py index ba269a8..d1a36bb 100644 --- a/src/backend/tests/conftest.py +++ b/src/backend/tests/conftest.py @@ -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) diff --git a/src/backend/tests/test_recipe_rules.py b/src/backend/tests/test_recipe_rules.py new file mode 100644 index 0000000..6bd10d8 --- /dev/null +++ b/src/backend/tests/test_recipe_rules.py @@ -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 diff --git a/src/frontend/flask_app/app.py b/src/frontend/flask_app/app.py index bf6d030..0a5d5de 100644 --- a/src/frontend/flask_app/app.py +++ b/src/frontend/flask_app/app.py @@ -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"\1", escaped) + return Markup(bolded.replace("\n", "
")) + @app.template_filter("tojson_attr") def tojson_attr_filter(value): """JSON encode safe for HTML attributes (x-data, etc.). diff --git a/src/frontend/flask_app/blueprints/measure.py b/src/frontend/flask_app/blueprints/measure.py index 917ea64..72581c3 100644 --- a/src/frontend/flask_app/blueprints/measure.py +++ b/src/frontend/flask_app/blueprints/measure.py @@ -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, ) diff --git a/src/frontend/flask_app/static/js/numpad.js b/src/frontend/flask_app/static/js/numpad.js index 70154ec..fefd37d 100644 --- a/src/frontend/flask_app/static/js/numpad.js +++ b/src/frontend/flask_app/static/js/numpad.js @@ -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 diff --git a/src/frontend/flask_app/static/js/rich-text.js b/src/frontend/flask_app/static/js/rich-text.js new file mode 100644 index 0000000..0fa8870 --- /dev/null +++ b/src/frontend/flask_app/static/js/rich-text.js @@ -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 diff --git a/src/frontend/flask_app/templates/measure/select_recipe.html b/src/frontend/flask_app/templates/measure/select_recipe.html index 9192e48..ddcca04 100644 --- a/src/frontend/flask_app/templates/measure/select_recipe.html +++ b/src/frontend/flask_app/templates/measure/select_recipe.html @@ -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 @@ @@ -166,7 +183,7 @@ @@ -248,9 +265,26 @@
- +

+ + + + +

+ + +
+ transition-shadow duration-200"> diff --git a/src/frontend/flask_app/templates/measure/task_execute.html b/src/frontend/flask_app/templates/measure/task_execute.html index 7d70a82..3d77e71 100644 --- a/src/frontend/flask_app/templates/measure/task_execute.html +++ b/src/frontend/flask_app/templates/measure/task_execute.html @@ -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 @@ ────────────────────────────────────────────── #}
+ {# 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 %} +
+ {% if task.directive %} +

+ {{ task.directive|rich_text }} +

+ {% endif %} + {% if task.description %} +
+ {{ task.description|rich_text }} +
+ {% endif %} +
+ {% endif %} + {# Main image area #}
@@ -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; diff --git a/src/frontend/flask_app/templates/measure/task_list.html b/src/frontend/flask_app/templates/measure/task_list.html index 1a33081..e1d8838 100644 --- a/src/frontend/flask_app/templates/measure/task_list.html +++ b/src/frontend/flask_app/templates/measure/task_list.html @@ -75,8 +75,28 @@
+ {# + 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 []) %}
diff --git a/src/frontend/flask_app/tests/test_recipe_rules_ui.py b/src/frontend/flask_app/tests/test_recipe_rules_ui.py new file mode 100644 index 0000000..945be43 --- /dev/null +++ b/src/frontend/flask_app/tests/test_recipe_rules_ui.py @@ -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
seconda" + + +def test_rich_text_makes_bold(flask_app): + out = _render(flask_app, "{{ v|rich_text }}", v="quota **critica** qui") + assert out == "quota critica 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="") + assert "