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
+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