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:
Adriano Dal Pastro
2026-07-28 20:27:19 +00:00
parent bde8fafd77
commit 5aa3d595ad
26 changed files with 1945 additions and 745 deletions
@@ -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')
+13
View File
@@ -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
+4
View File
@@ -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):
+16
View File
@@ -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.
+29 -19
View File
@@ -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)
+4
View File
@@ -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)
+280
View File
@@ -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
+24 -1
View File
@@ -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,
)
+41 -3
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/* 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 "&lt;script&gt;" 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 "&#39;" in out
assert "<b " not in out
assert out.startswith("<strong>&lt;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