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 @@ ────────────────────────────────────────────── #}+ {{ task.directive|rich_text }} +
+ {% endif %} + {% if task.description %} +- {{ task.directive or task.description }} + {{ (task.directive or task.description)|rich_text }}
{% endif %}