Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -814,6 +814,91 @@ def api_close_production(run_id: int):
|
|||||||
return _supervised_action(run_id, "close")
|
return _supervised_action(run_id, "close")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Routes: task results (#53 #54 #55) - outcome of a "check" task and the values /
|
||||||
|
# attachments of a "note" task with fields. The browser cannot send X-API-Key,
|
||||||
|
# so each call goes through here.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def _relay(resp, ok_status: int = 200):
|
||||||
|
"""JSON back to the browser: backend errors keep their status and detail.
|
||||||
|
|
||||||
|
The detail is what the operator reads («Per un NOK serve la nota»), so it is
|
||||||
|
passed through as it came, never replaced by a generic message.
|
||||||
|
"""
|
||||||
|
if isinstance(resp, dict) and resp.get("error"):
|
||||||
|
body = {"error": True, "detail": resp.get("detail", _("Errore nel salvataggio"))}
|
||||||
|
if resp.get("session_expired"):
|
||||||
|
body["session_expired"] = True
|
||||||
|
status_code = resp.get("status_code", 500)
|
||||||
|
return jsonify(body), status_code if status_code >= 400 else 500
|
||||||
|
return jsonify(resp), ok_status
|
||||||
|
|
||||||
|
|
||||||
|
@measure_bp.route("/api/tasks/<int:task_id>", methods=["GET"])
|
||||||
|
@login_required
|
||||||
|
@role_required("MeasurementTec")
|
||||||
|
def api_get_task(task_id: int):
|
||||||
|
"""Proxy: a task with its fields (#55: the tasks to fill at the end of production)."""
|
||||||
|
return _relay(api_client.get(f"/api/tasks/{task_id}"))
|
||||||
|
|
||||||
|
|
||||||
|
@measure_bp.route("/api/task-results", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
@role_required("MeasurementTec")
|
||||||
|
def api_save_task_result():
|
||||||
|
"""Proxy: record the result of a check / note task (#54, #55)."""
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
if data.get("task_id") is None:
|
||||||
|
return jsonify({
|
||||||
|
"error": True, "detail": _("Dati mancanti: task_id è obbligatorio"),
|
||||||
|
}), 400
|
||||||
|
payload = {
|
||||||
|
"task_id": data.get("task_id"),
|
||||||
|
"production_run_id": data.get("production_run_id"),
|
||||||
|
"outcome": data.get("outcome"),
|
||||||
|
"note": data.get("note"),
|
||||||
|
"values": data.get("values"),
|
||||||
|
"attachments": data.get("attachments"),
|
||||||
|
}
|
||||||
|
return _relay(api_client.post("/api/task-results", data=payload), 201)
|
||||||
|
|
||||||
|
|
||||||
|
@measure_bp.route("/api/task-results", methods=["GET"])
|
||||||
|
@login_required
|
||||||
|
@role_required("MeasurementTec")
|
||||||
|
def api_list_task_results():
|
||||||
|
"""Proxy: results already recorded, filtered by production run and/or task."""
|
||||||
|
params = {
|
||||||
|
key: request.args[key]
|
||||||
|
for key in ("production_run_id", "task_id")
|
||||||
|
if request.args.get(key)
|
||||||
|
}
|
||||||
|
return _relay(api_client.get("/api/task-results", params=params))
|
||||||
|
|
||||||
|
|
||||||
|
@measure_bp.route("/api/attachments", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
@role_required("MeasurementTec")
|
||||||
|
def api_upload_attachment():
|
||||||
|
"""Proxy: upload an attachment of a note task (image or PDF), multipart.
|
||||||
|
|
||||||
|
Answers with the backend's reply: the `file_path` in it is what goes into the
|
||||||
|
`attachments` of the result.
|
||||||
|
"""
|
||||||
|
upload = request.files.get("file")
|
||||||
|
if upload is None or not upload.filename:
|
||||||
|
return jsonify({"error": True, "detail": _("Nessun file caricato")}), 400
|
||||||
|
resp = api_client.post(
|
||||||
|
"/api/files/upload",
|
||||||
|
files={"file": (
|
||||||
|
upload.filename,
|
||||||
|
upload.read(),
|
||||||
|
upload.mimetype or "application/octet-stream",
|
||||||
|
)},
|
||||||
|
)
|
||||||
|
return _relay(resp, 201)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Route: File proxy (browser can't send X-API-Key directly)
|
# Route: File proxy (browser can't send X-API-Key directly)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,272 @@
|
|||||||
|
/**
|
||||||
|
* Task results (#53 #54 #55) - Alpine component for the tasks that record a result.
|
||||||
|
*
|
||||||
|
* - «check» task (#54): the operator answers OK or NOK, with a note (mandatory for NOK).
|
||||||
|
* - «note» task with `fields` (#55): one input per field, an optional attachment
|
||||||
|
* (image / PDF) for the fields that ask for it, outcome 'done'.
|
||||||
|
*
|
||||||
|
* The same component serves the task being executed (one form) and the panel shown
|
||||||
|
* at the end of production (one form per at_close task), so the field rules live here
|
||||||
|
* once. The page sets `window.__taskResultsCfg` (urls + labels) and the component's
|
||||||
|
* `ctx` (production run id + callbacks) from its x-init, the way visionExecute() does.
|
||||||
|
*
|
||||||
|
* Plain functions only (no framework imports) so tests/js/test_task_results.js can
|
||||||
|
* run them under node with a stubbed fetch.
|
||||||
|
*/
|
||||||
|
function taskResultForm() {
|
||||||
|
return {
|
||||||
|
// Set by the page's x-init: () => ({ runId: int|null, done: () => void })
|
||||||
|
ctx: null,
|
||||||
|
forms: [],
|
||||||
|
working: false,
|
||||||
|
error: '',
|
||||||
|
// true once a result was recorded during this page view (shows «Prosegui»)
|
||||||
|
justRecorded: false,
|
||||||
|
// Panel open state (used by the end-of-production panel only)
|
||||||
|
open: false,
|
||||||
|
|
||||||
|
cfg() { return window.__taskResultsCfg || { labels: {} }; },
|
||||||
|
label(key, fallback) {
|
||||||
|
return (this.cfg().labels && this.cfg().labels[key]) || fallback;
|
||||||
|
},
|
||||||
|
runId() {
|
||||||
|
const c = this.ctx ? this.ctx() : null;
|
||||||
|
return c && c.runId ? c.runId : null;
|
||||||
|
},
|
||||||
|
|
||||||
|
/* One form per task: values start from the field's default, the way the maker set it. */
|
||||||
|
makeForm(task) {
|
||||||
|
const fields = Array.isArray(task.fields) ? task.fields : [];
|
||||||
|
const values = {};
|
||||||
|
fields.forEach((f) => { values[f.key] = (f.default === null || f.default === undefined) ? '' : String(f.default); });
|
||||||
|
return {
|
||||||
|
task_id: task.id,
|
||||||
|
title: task.title || '',
|
||||||
|
directive: task.directive || '',
|
||||||
|
type: task.task_type,
|
||||||
|
fields: fields,
|
||||||
|
values: values,
|
||||||
|
files: {},
|
||||||
|
fileNames: {},
|
||||||
|
note: '',
|
||||||
|
outcome: '',
|
||||||
|
error: '',
|
||||||
|
result: null,
|
||||||
|
recorded: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
/* The single form of the task on screen. */
|
||||||
|
initForTask(task) {
|
||||||
|
if (!task) return;
|
||||||
|
const isCheck = task.task_type === 'check';
|
||||||
|
const isNoteWithFields = task.task_type === 'note' && Array.isArray(task.fields) && task.fields.length > 0;
|
||||||
|
if (!isCheck && !isNoteWithFields) return;
|
||||||
|
this.forms = [this.makeForm(task)];
|
||||||
|
this.loadLast();
|
||||||
|
},
|
||||||
|
|
||||||
|
pickFile(form, key, ev) {
|
||||||
|
const f = ev && ev.target && ev.target.files ? ev.target.files[0] : null;
|
||||||
|
if (f) { form.files[key] = f; form.fileNames[key] = f.name; }
|
||||||
|
else { delete form.files[key]; form.fileNames[key] = ''; }
|
||||||
|
},
|
||||||
|
|
||||||
|
csrf() {
|
||||||
|
const m = typeof document !== 'undefined' ? document.querySelector('meta[name=csrf-token]') : null;
|
||||||
|
return (m && m.content) || '';
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Fetch + JSON with the page's usual error reading: the detail of the server is
|
||||||
|
what the operator sees; an HTML answer (expired CSRF / session) means «reload». */
|
||||||
|
async call(url, opts) {
|
||||||
|
let resp;
|
||||||
|
try {
|
||||||
|
resp = await fetch(url, opts);
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, detail: this.label('connection', 'Errore di connessione') };
|
||||||
|
}
|
||||||
|
const data = await resp.json().catch(() => null);
|
||||||
|
if (!data || data.csrf_expired || data.session_expired) {
|
||||||
|
return { ok: false, detail: this.label('sessionExpired', 'Sessione scaduta: ricaricare la pagina') };
|
||||||
|
}
|
||||||
|
if (!resp.ok) {
|
||||||
|
return { ok: false, detail: data.detail || this.label('serverError', 'Errore di comunicazione con il server') };
|
||||||
|
}
|
||||||
|
return { ok: true, data: data };
|
||||||
|
},
|
||||||
|
|
||||||
|
/* The last result of this task: in the run under way if there is one, otherwise
|
||||||
|
the one given in this browser session (no run = nothing on the server to ask).
|
||||||
|
Only shown: the buttons stay active, a loop gives a new result every cycle. */
|
||||||
|
async loadLast() {
|
||||||
|
const form = this.forms[0];
|
||||||
|
if (!form) return;
|
||||||
|
const rid = this.runId();
|
||||||
|
if (rid && this.cfg().listUrl) {
|
||||||
|
const r = await this.call(
|
||||||
|
this.cfg().listUrl + '?production_run_id=' + encodeURIComponent(rid) +
|
||||||
|
'&task_id=' + encodeURIComponent(form.task_id));
|
||||||
|
if (r.ok && Array.isArray(r.data) && r.data.length) {
|
||||||
|
form.result = r.data[r.data.length - 1];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const raw = window.sessionStorage.getItem('tmf-task-result-' + form.task_id);
|
||||||
|
if (raw && !rid) form.result = JSON.parse(raw);
|
||||||
|
} catch (e) { /* storage may be unavailable: the result is simply not shown */ }
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Validation before anything goes on the wire. Returns the message or ''. */
|
||||||
|
validate(form, outcome) {
|
||||||
|
if (form.type === 'check') {
|
||||||
|
if (outcome === 'nok' && !(form.note || '').trim()) {
|
||||||
|
return this.label('nokNeedsNote', 'Per un NOK serve la nota');
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
for (const f of form.fields) {
|
||||||
|
const v = form.values[f.key];
|
||||||
|
if (f.type === 'number' && v !== '' && v !== null && v !== undefined &&
|
||||||
|
!isFinite(Number(String(v).replace(',', '.')))) {
|
||||||
|
return this.label('notANumber', 'Valore non numerico') + ': ' + f.label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Upload the attachments of a form, then POST the result.
|
||||||
|
Returns true when the result is recorded (forms already recorded are skipped). */
|
||||||
|
async record(form, outcome) {
|
||||||
|
if (form.recorded) return true;
|
||||||
|
form.error = this.validate(form, outcome);
|
||||||
|
if (form.error) return false;
|
||||||
|
|
||||||
|
const attachments = [];
|
||||||
|
for (const f of form.fields) {
|
||||||
|
const file = form.files[f.key];
|
||||||
|
if (!f.attachment || !file) continue;
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('file', file, file.name);
|
||||||
|
const up = await this.call(this.cfg().uploadUrl, {
|
||||||
|
method: 'POST', headers: { 'X-CSRFToken': this.csrf() }, body: fd,
|
||||||
|
});
|
||||||
|
if (!up.ok) { form.error = up.detail; return false; }
|
||||||
|
// The backend answers {file_path, ...}: that path is what the result keeps.
|
||||||
|
attachments.push({ key: f.key, path: up.data.file_path || up.data.path, name: file.name });
|
||||||
|
}
|
||||||
|
|
||||||
|
let values = null;
|
||||||
|
if (form.type === 'note' && form.fields.length) {
|
||||||
|
values = {};
|
||||||
|
form.fields.forEach((f) => {
|
||||||
|
const v = form.values[f.key];
|
||||||
|
if (v === '' || v === null || v === undefined) return;
|
||||||
|
values[f.key] = f.type === 'number' ? String(v).replace(',', '.') : String(v);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
task_id: form.task_id,
|
||||||
|
production_run_id: this.runId(),
|
||||||
|
outcome: form.type === 'check' ? outcome : 'done',
|
||||||
|
note: (form.note || '').trim() || null,
|
||||||
|
values: values,
|
||||||
|
attachments: attachments.length ? attachments : null,
|
||||||
|
};
|
||||||
|
const r = await this.call(this.cfg().saveUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrf() },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!r.ok) { form.error = r.detail; return false; }
|
||||||
|
form.result = r.data;
|
||||||
|
form.recorded = true;
|
||||||
|
try {
|
||||||
|
window.sessionStorage.setItem('tmf-task-result-' + form.task_id, JSON.stringify(r.data));
|
||||||
|
} catch (e) { /* storage may be unavailable */ }
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Task on screen: OK / NOK button, or «Completato» of a note with fields. */
|
||||||
|
async submit(outcome) {
|
||||||
|
const form = this.forms[0];
|
||||||
|
if (!form || this.working) return;
|
||||||
|
this.working = true;
|
||||||
|
this.error = '';
|
||||||
|
const ok = await this.record(form, outcome);
|
||||||
|
this.working = false;
|
||||||
|
if (!ok) return;
|
||||||
|
this.justRecorded = true;
|
||||||
|
this.proceed();
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Hand over to the page: close the cycle / go on. Also bound to «Prosegui», for
|
||||||
|
when that step failed after the result was already recorded. */
|
||||||
|
proceed() {
|
||||||
|
const c = this.ctx ? this.ctx() : null;
|
||||||
|
if (c && c.done) c.done();
|
||||||
|
},
|
||||||
|
|
||||||
|
/* The result as the operator reads it. */
|
||||||
|
resultLabel(result) {
|
||||||
|
if (!result) return '';
|
||||||
|
if (result.outcome === 'ok') return this.label('outcomeOk', 'OK');
|
||||||
|
if (result.outcome === 'nok') return this.label('outcomeNok', 'NOK');
|
||||||
|
return this.label('outcomeDone', 'Registrato');
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---- End of production panel (#55) ----
|
||||||
|
|
||||||
|
/* Open the panel for the at_close tasks of the run. A task that already has a
|
||||||
|
result in this run (page reloaded in between) is not asked again. */
|
||||||
|
async openClose(ids) {
|
||||||
|
this.error = '';
|
||||||
|
this.working = true;
|
||||||
|
this.open = true;
|
||||||
|
this.forms = [];
|
||||||
|
const rid = this.runId();
|
||||||
|
let done = [];
|
||||||
|
if (rid && this.cfg().listUrl) {
|
||||||
|
const r = await this.call(this.cfg().listUrl + '?production_run_id=' + encodeURIComponent(rid));
|
||||||
|
if (r.ok && Array.isArray(r.data)) done = r.data.map((x) => x.task_id);
|
||||||
|
}
|
||||||
|
const forms = [];
|
||||||
|
for (const id of ids) {
|
||||||
|
const r = await this.call(this.cfg().taskUrlTpl.replace('/0', '/' + id));
|
||||||
|
if (!r.ok) { this.error = r.detail; this.working = false; return; }
|
||||||
|
const form = this.makeForm(r.data);
|
||||||
|
if (done.indexOf(id) !== -1) form.recorded = true;
|
||||||
|
forms.push(form);
|
||||||
|
}
|
||||||
|
this.forms = forms;
|
||||||
|
this.working = false;
|
||||||
|
// Nothing left to ask: straight on to the capoturno.
|
||||||
|
if (forms.every((f) => f.recorded)) this.finishClose();
|
||||||
|
},
|
||||||
|
|
||||||
|
cancelClose() { this.open = false; this.forms = []; this.error = ''; },
|
||||||
|
|
||||||
|
async submitClose() {
|
||||||
|
if (this.working) return;
|
||||||
|
this.working = true;
|
||||||
|
this.error = '';
|
||||||
|
let allOk = true;
|
||||||
|
for (const form of this.forms) {
|
||||||
|
// Keep going after a failure: each form shows its own error, and the ones
|
||||||
|
// already recorded are not sent again on the next try.
|
||||||
|
if (!(await this.record(form, 'done'))) allOk = false;
|
||||||
|
}
|
||||||
|
this.working = false;
|
||||||
|
if (allOk) this.finishClose();
|
||||||
|
},
|
||||||
|
|
||||||
|
finishClose() {
|
||||||
|
this.open = false;
|
||||||
|
this.proceed();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof module !== 'undefined') module.exports = { taskResultForm };
|
||||||
@@ -256,6 +256,88 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{# #55: the inputs of the fields of a «note» task. One block for the task on screen
|
||||||
|
and for the panel at the end of production, so the rules are written once. #}
|
||||||
|
{% macro result_fields() %}
|
||||||
|
<template x-for="fld in f.fields" :key="fld.key">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="block text-sm font-medium text-[var(--text-secondary)] mb-1" x-text="fld.label"></label>
|
||||||
|
<input :type="fld.type === 'number' ? 'number' : 'text'"
|
||||||
|
:step="fld.type === 'number' ? 'any' : null"
|
||||||
|
:inputmode="fld.type === 'number' ? 'decimal' : null"
|
||||||
|
x-model="f.values[fld.key]"
|
||||||
|
class="input w-full text-base py-2">
|
||||||
|
<template x-if="fld.attachment">
|
||||||
|
<div class="mt-1.5">
|
||||||
|
<label class="btn btn-secondary text-xs gap-1.5 cursor-pointer">
|
||||||
|
{{ _('Allega immagine o PDF') }}
|
||||||
|
<input type="file" accept="image/*,application/pdf" class="hidden"
|
||||||
|
@change="pickFile(f, fld.key, $event)">
|
||||||
|
</label>
|
||||||
|
<span class="ml-2 text-xs text-[var(--text-muted)]" x-text="f.fileNames[fld.key]"></span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{# #54 #55: result panel of the task on screen - OK / NOK for a «check», the
|
||||||
|
fields and «Completato» for a «note» with fields. #}
|
||||||
|
<div x-show="hasResultPanel" x-cloak
|
||||||
|
x-data="taskResultForm()"
|
||||||
|
x-init="ctx = () => ({ runId: productionRun ? productionRun.id : null, done: () => advanceAfterResult() });
|
||||||
|
initForTask(task);
|
||||||
|
$watch('productionRun && productionRun.id', () => loadLast())"
|
||||||
|
@task-result-reset.window="justRecorded = false; initForTask(task)"
|
||||||
|
class="shrink-0 max-h-[65%] overflow-y-auto px-4 py-3 bg-[var(--bg-card)] border-b border-[var(--border-color)]">
|
||||||
|
<template x-for="f in forms" :key="f.task_id">
|
||||||
|
<div class="max-w-2xl mx-auto">
|
||||||
|
<div x-show="f.result" class="mb-3 px-3 py-2 rounded-lg text-sm border border-[var(--border-color)] bg-[var(--bg-secondary)]">
|
||||||
|
<span class="text-[var(--text-secondary)]">{{ _('Ultimo esito') }}:</span>
|
||||||
|
<span class="font-bold"
|
||||||
|
:class="f.result && f.result.outcome === 'nok' ? 'text-measure-fail' : 'text-measure-pass'"
|
||||||
|
x-text="resultLabel(f.result)"></span>
|
||||||
|
<span class="text-[var(--text-muted)]" x-text="f.result && f.result.note ? '— ' + f.result.note : ''"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template x-if="f.type === 'check'">
|
||||||
|
<div>
|
||||||
|
<textarea x-model="f.note" rows="2"
|
||||||
|
placeholder="{{ _('Nota (obbligatoria per un NOK)') }}"
|
||||||
|
class="input w-full text-base mb-3"></textarea>
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<button @click="submit('ok')" :disabled="working"
|
||||||
|
class="btn min-h-[72px] text-2xl font-bold justify-center shadow-md bg-measure-pass text-white hover:opacity-90 disabled:opacity-50">
|
||||||
|
{{ _('OK') }}
|
||||||
|
</button>
|
||||||
|
<button @click="submit('nok')" :disabled="working"
|
||||||
|
class="btn min-h-[72px] text-2xl font-bold justify-center shadow-md bg-red-600 text-white hover:bg-red-700 disabled:opacity-50">
|
||||||
|
{{ _('NOK') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="f.type === 'note'">
|
||||||
|
<div>
|
||||||
|
{{ result_fields() }}
|
||||||
|
<button @click="submit('done')" :disabled="working"
|
||||||
|
class="btn text-lg font-bold w-full min-h-[64px] justify-center gap-2 shadow-md bg-measure-pass text-white hover:opacity-90 disabled:opacity-50">
|
||||||
|
<span>{{ _('Completato') }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<p x-show="f.error" class="mt-2 text-sm text-measure-fail" x-text="f.error"></p>
|
||||||
|
{# The result is in, but going on failed (cycle refused, network): another try, without recording twice. #}
|
||||||
|
<button x-show="justRecorded" @click="proceed()"
|
||||||
|
class="btn btn-secondary mt-3 w-full justify-center">
|
||||||
|
{{ _('Prosegui') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
{# Main image area #}
|
{# Main image area #}
|
||||||
<div class="flex-1 overflow-hidden relative">
|
<div class="flex-1 overflow-hidden relative">
|
||||||
|
|
||||||
@@ -747,7 +829,8 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template x-if="!isMeasureTask || subtasks.length === 0">
|
{# #54 #55: a check / note-with-fields task has its own buttons in the panel #}
|
||||||
|
<template x-if="(!isMeasureTask || subtasks.length === 0) && !hasResultPanel">
|
||||||
{# #60: unico «Completato», largo e alto almeno 64 px per il touch. #}
|
{# #60: unico «Completato», largo e alto almeno 64 px per il touch. #}
|
||||||
<button @click="goToNextTask()"
|
<button @click="goToNextTask()"
|
||||||
:disabled="!canLeaveTask"
|
:disabled="!canLeaveTask"
|
||||||
@@ -895,6 +978,39 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{# ================================================================
|
||||||
|
#55 TASK DI CHIUSURA — before the capoturno's modal, the notes marked «alla fine
|
||||||
|
della produzione» are filled in and recorded on the run. The modal below is
|
||||||
|
untouched: it opens when this panel is done.
|
||||||
|
================================================================ #}
|
||||||
|
<div x-show="open" x-cloak
|
||||||
|
x-data="taskResultForm()"
|
||||||
|
x-init="ctx = () => ({ runId: productionRun ? productionRun.id : null, done: () => closeTasksRecorded() })"
|
||||||
|
@task-close-open.window="openClose($event.detail.ids)"
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||||
|
<div class="bg-[var(--bg-card)] rounded-2xl shadow-2xl p-6 w-full max-w-xl mx-4 max-h-[90vh] overflow-y-auto border border-[var(--border-color)]">
|
||||||
|
<h3 class="text-lg font-bold text-[var(--text-primary)] mb-3">{{ _('Prima di chiudere la produzione') }}</h3>
|
||||||
|
<p x-show="working && !forms.length" class="text-sm text-[var(--text-muted)]">{{ _('Caricamento...') }}</p>
|
||||||
|
<template x-for="f in forms" :key="f.task_id">
|
||||||
|
<div class="mb-4" x-show="!f.recorded">
|
||||||
|
<div class="font-semibold text-[var(--text-primary)] mb-1" x-text="f.title"></div>
|
||||||
|
<div class="text-sm text-[var(--text-secondary)] mb-2" x-show="f.directive" x-text="f.directive"></div>
|
||||||
|
{{ result_fields() }}
|
||||||
|
<textarea x-model="f.note" rows="2" placeholder="{{ _('Nota') }}" class="input w-full text-base"></textarea>
|
||||||
|
<p x-show="f.error" class="mt-1 text-sm text-measure-fail" x-text="f.error"></p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<p x-show="error" class="mb-2 text-sm text-measure-fail" x-text="error"></p>
|
||||||
|
<div class="flex gap-3 mt-2">
|
||||||
|
<button @click="cancelClose()" class="btn btn-secondary flex-1 justify-center">{{ _('Annulla') }}</button>
|
||||||
|
<button @click="submitClose()" :disabled="working || !forms.length"
|
||||||
|
class="btn flex-1 justify-center font-bold bg-measure-pass text-white hover:opacity-90 disabled:opacity-50">
|
||||||
|
{{ _('Registra e prosegui') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{# ================================================================
|
{# ================================================================
|
||||||
SUPERVISOR LOGIN MODAL
|
SUPERVISOR LOGIN MODAL
|
||||||
================================================================ #}
|
================================================================ #}
|
||||||
@@ -980,6 +1096,26 @@
|
|||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
<script src="{{ url_for('static', filename='js/vision_execute.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/vision_execute.js') }}"></script>
|
||||||
|
{# #53 #54 #55: result of check / note tasks (urls and words for the component) #}
|
||||||
|
<script>
|
||||||
|
window.__taskResultsCfg = {
|
||||||
|
saveUrl: '{{ url_for("measure.api_save_task_result") }}',
|
||||||
|
listUrl: '{{ url_for("measure.api_list_task_results") }}',
|
||||||
|
uploadUrl: '{{ url_for("measure.api_upload_attachment") }}',
|
||||||
|
taskUrlTpl: '{{ url_for("measure.api_get_task", task_id=0) }}',
|
||||||
|
labels: {
|
||||||
|
connection: {{ _('Errore di connessione')|tojson }},
|
||||||
|
sessionExpired: {{ _('Sessione scaduta: ricaricare la pagina')|tojson }},
|
||||||
|
serverError: {{ _('Errore di comunicazione con il server')|tojson }},
|
||||||
|
nokNeedsNote: {{ _('Per un NOK serve la nota')|tojson }},
|
||||||
|
notANumber: {{ _('Valore non numerico')|tojson }},
|
||||||
|
outcomeOk: {{ _('OK')|tojson }},
|
||||||
|
outcomeNok: {{ _('NOK')|tojson }},
|
||||||
|
outcomeDone: {{ _('Registrato')|tojson }},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<script src="{{ url_for('static', filename='js/task-results.js') }}"></script>
|
||||||
{# The clock's rules - how a delay reads, when the buzzer goes, where the
|
{# The clock's rules - how a delay reads, when the buzzer goes, where the
|
||||||
measurement lives - are shared with the other screens rather than restated. #}
|
measurement lives - are shared with the other screens rather than restated. #}
|
||||||
<script src="{{ url_for('static', filename='js/production-clock.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/production-clock.js') }}"></script>
|
||||||
@@ -1028,6 +1164,10 @@ function taskExecute() {
|
|||||||
// The measurement tasks of this recipe, in order: the loop the operator stays
|
// The measurement tasks of this recipe, in order: the loop the operator stays
|
||||||
// in once production starts, and what says which task closes a cycle.
|
// in once production starts, and what says which task closes a cycle.
|
||||||
measurementTaskIds: [],
|
measurementTaskIds: [],
|
||||||
|
// #55: notes to fill in before the capoturno closes the production, and whether
|
||||||
|
// they were already recorded (so cancelling the modal does not ask them again).
|
||||||
|
closeTaskIds: [],
|
||||||
|
closeTasksDone: false,
|
||||||
// The interval has elapsed and it has been said out loud - buzzer, and the
|
// The interval has elapsed and it has been said out loud - buzzer, and the
|
||||||
// operator on their way back to the measurement. Kept so it is said once.
|
// operator on their way back to the measurement. Kept so it is said once.
|
||||||
dueAnnounced: false,
|
dueAnnounced: false,
|
||||||
@@ -1270,6 +1410,7 @@ function taskExecute() {
|
|||||||
this.productionStarted = run.status !== 'closed';
|
this.productionStarted = run.status !== 'closed';
|
||||||
this.cycleCount = run.cycle_count;
|
this.cycleCount = run.cycle_count;
|
||||||
this.measurementTaskIds = run.measurement_task_ids || [];
|
this.measurementTaskIds = run.measurement_task_ids || [];
|
||||||
|
this.closeTaskIds = run.close_task_ids || []; // #55
|
||||||
|
|
||||||
const seconds = run.seconds_to_next_measurement;
|
const seconds = run.seconds_to_next_measurement;
|
||||||
if (seconds === null || seconds === undefined) {
|
if (seconds === null || seconds === undefined) {
|
||||||
@@ -1696,6 +1837,7 @@ function taskExecute() {
|
|||||||
this.currentValue = null;
|
this.currentValue = null;
|
||||||
this.errorMessage = '';
|
this.errorMessage = '';
|
||||||
this.inputStartedAt = Date.now();
|
this.inputStartedAt = Date.now();
|
||||||
|
window.dispatchEvent(new CustomEvent('task-result-reset')); // #54: a check / note loops on the spot
|
||||||
},
|
},
|
||||||
|
|
||||||
// ---- Where the measurement lives ----
|
// ---- Where the measurement lives ----
|
||||||
@@ -1788,6 +1930,33 @@ function taskExecute() {
|
|||||||
return window.ProductionClock.format(this.timerRemaining);
|
return window.ProductionClock.format(this.timerRemaining);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/* #53: result recorded on a check / note task. In the loop of a production the
|
||||||
|
task counts as a step of the cycle exactly like a measurement task: the last
|
||||||
|
one closes the cycle (the server decides, as in confirmCycle), the others go
|
||||||
|
to the next one. If the server refuses, the panel stays with «Prosegui». */
|
||||||
|
async advanceAfterResult() {
|
||||||
|
if (this.productionRun && this.isMeasurementTaskOfRun) {
|
||||||
|
const countBefore = this.cycleCount;
|
||||||
|
const run = await this.postProduction(
|
||||||
|
'{{ url_for("measure.api_complete_cycle", run_id=0) }}'.replace('/0/', '/' + this.productionRun.id + '/'),
|
||||||
|
{ task_id: this.task.id },
|
||||||
|
);
|
||||||
|
if (!run) return;
|
||||||
|
this.adoptProductionRun(run);
|
||||||
|
if (run.cycle_count === countBefore && this.nextMeasurementTaskId) {
|
||||||
|
this.goToTask(this.nextMeasurementTaskId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.goToNextTask();
|
||||||
|
},
|
||||||
|
|
||||||
|
/* #55: the end-of-production notes are recorded: now the capoturno's modal. */
|
||||||
|
closeTasksRecorded() {
|
||||||
|
this.closeTasksDone = true;
|
||||||
|
this.openSupervisorModal('fine_produzione');
|
||||||
|
},
|
||||||
|
|
||||||
// ---- Navigate to next task (Completato) ----
|
// ---- Navigate to next task (Completato) ----
|
||||||
/* With a production open the operator stays in the measurement loop until the
|
/* With a production open the operator stays in the measurement loop until the
|
||||||
capoturno closes it: after the last measurement task they go back to the
|
capoturno closes it: after the last measurement task they go back to the
|
||||||
@@ -1828,6 +1997,12 @@ function taskExecute() {
|
|||||||
this.productionError = '{{ _("Nessuna produzione aperta su questa stazione") }}';
|
this.productionError = '{{ _("Nessuna produzione aperta su questa stazione") }}';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// #55: first the notes marked «alla fine della produzione»; the panel calls back
|
||||||
|
// here (closeTasksRecorded) when they are recorded.
|
||||||
|
if (action === 'fine_produzione' && this.closeTaskIds.length && !this.closeTasksDone) {
|
||||||
|
window.dispatchEvent(new CustomEvent('task-close-open', { detail: { ids: this.closeTaskIds } }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.supervisorAction = action;
|
this.supervisorAction = action;
|
||||||
this.supervisorUsername = '';
|
this.supervisorUsername = '';
|
||||||
this.supervisorPassword = '';
|
this.supervisorPassword = '';
|
||||||
@@ -1859,6 +2034,19 @@ function taskExecute() {
|
|||||||
return t === 'camera_measure' || t === 'dxf_compare';
|
return t === 'camera_measure' || t === 'dxf_compare';
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/* #54: «Controllo OK/NOK» - no quotes, the result is the outcome. */
|
||||||
|
get isCheckTask() {
|
||||||
|
return this.task.task_type === 'check';
|
||||||
|
},
|
||||||
|
|
||||||
|
/* #54 #55: tasks that bring their own result panel (a check, or a note with
|
||||||
|
fields). A note without fields stays the plain «Completato» task. */
|
||||||
|
get hasResultPanel() {
|
||||||
|
if (this.isCheckTask) return true;
|
||||||
|
return this.task.task_type === 'note' && Array.isArray(this.task.fields)
|
||||||
|
&& this.task.fields.length > 0;
|
||||||
|
},
|
||||||
|
|
||||||
/* A measurement task with no quotes yet: the recipe is unfinished. Said out
|
/* A measurement task with no quotes yet: the recipe is unfinished. Said out
|
||||||
loud rather than silently rendering an empty screen. */
|
loud rather than silently rendering an empty screen. */
|
||||||
get isMeasureTaskWithoutQuotes() {
|
get isMeasureTaskWithoutQuotes() {
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
// Behaviour test for static/js/task-results.js (#53 #54 #55), plain node, no framework.
|
||||||
|
// Run: node tests/js/test_task_results.js (exit code != 0 on failure)
|
||||||
|
const assert = require('assert');
|
||||||
|
const { taskResultForm } = require('../../static/js/task-results.js');
|
||||||
|
|
||||||
|
const calls = [];
|
||||||
|
let responses = [];
|
||||||
|
global.window = {
|
||||||
|
__taskResultsCfg: {
|
||||||
|
saveUrl: '/s', listUrl: '/l', uploadUrl: '/u', taskUrlTpl: '/t/0',
|
||||||
|
labels: { nokNeedsNote: 'Per un NOK serve la nota' },
|
||||||
|
},
|
||||||
|
sessionStorage: { _d: {}, getItem(k) { return this._d[k] || null; }, setItem(k, v) { this._d[k] = v; } },
|
||||||
|
};
|
||||||
|
global.FormData = class { constructor() { this.d = []; } append(k, v, n) { this.d.push([k, v, n]); } };
|
||||||
|
global.fetch = async (url, opts) => {
|
||||||
|
calls.push({ url, opts });
|
||||||
|
const r = responses.shift() || { status: 200, body: {} };
|
||||||
|
return { ok: r.status < 400, status: r.status, json: async () => r.body };
|
||||||
|
};
|
||||||
|
|
||||||
|
function comp(runId) {
|
||||||
|
const c = taskResultForm();
|
||||||
|
c.ctx = () => ({ runId, done() { c._done = (c._done || 0) + 1; } });
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
// check: NOK without note is refused before any request
|
||||||
|
let c = comp(4);
|
||||||
|
c.initForTask({ id: 30, task_type: 'check' });
|
||||||
|
responses = [{ status: 200, body: [] }]; // loadLast
|
||||||
|
await c.submit('nok');
|
||||||
|
assert.strictEqual(c.forms[0].error, 'Per un NOK serve la nota');
|
||||||
|
assert.ok(!calls.some((x) => x.url === '/s'), 'no POST for a NOK without note');
|
||||||
|
|
||||||
|
// check: OK goes to the server with the run id, then hands over
|
||||||
|
calls.length = 0;
|
||||||
|
responses = [{ status: 201, body: { id: 1, outcome: 'ok' } }];
|
||||||
|
await c.submit('ok');
|
||||||
|
const post = calls.find((x) => x.url === '/s');
|
||||||
|
const body = JSON.parse(post.opts.body);
|
||||||
|
assert.deepStrictEqual([body.task_id, body.production_run_id, body.outcome, body.values], [30, 4, 'ok', null]);
|
||||||
|
assert.strictEqual(c._done, 1);
|
||||||
|
assert.strictEqual(c.forms[0].result.outcome, 'ok');
|
||||||
|
|
||||||
|
// server 422: the detail is shown, nothing is handed over
|
||||||
|
c = comp(4);
|
||||||
|
responses = [{ status: 200, body: [] }, { status: 422, body: { detail: 'Per un NOK serve la nota' } }];
|
||||||
|
c.initForTask({ id: 30, task_type: 'check' }); // consumes the first one (loadLast)
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
c.forms[0].note = 'x';
|
||||||
|
await c.submit('nok');
|
||||||
|
assert.strictEqual(c.forms[0].error, 'Per un NOK serve la nota');
|
||||||
|
assert.ok(!c._done);
|
||||||
|
|
||||||
|
// note with fields: defaults, upload of the attachment, values + attachments in the result
|
||||||
|
calls.length = 0;
|
||||||
|
c = comp(null);
|
||||||
|
const task = { id: 31, task_type: 'note', fields: [
|
||||||
|
{ key: 'peso', label: 'Peso', type: 'number', default: '1,5', attachment: false },
|
||||||
|
{ key: 'foto', label: 'Foto', type: 'text', default: null, attachment: true },
|
||||||
|
] };
|
||||||
|
c.initForTask(task);
|
||||||
|
assert.strictEqual(c.forms[0].values.peso, '1,5');
|
||||||
|
c.forms[0].files.foto = { name: 'a.png' };
|
||||||
|
responses = [
|
||||||
|
{ status: 201, body: { file_path: 'general/a.png' } },
|
||||||
|
{ status: 201, body: { id: 2, outcome: 'done' } },
|
||||||
|
];
|
||||||
|
await c.submit('done');
|
||||||
|
const saved = JSON.parse(calls.find((x) => x.url === '/s').opts.body);
|
||||||
|
assert.deepStrictEqual(saved.values, { peso: '1.5' });
|
||||||
|
assert.deepStrictEqual(saved.attachments, [{ key: 'foto', path: 'general/a.png', name: 'a.png' }]);
|
||||||
|
assert.strictEqual(saved.outcome, 'done');
|
||||||
|
assert.strictEqual(saved.production_run_id, null);
|
||||||
|
|
||||||
|
// non numeric value in a number field is refused locally
|
||||||
|
c = comp(null);
|
||||||
|
c.initForTask(task);
|
||||||
|
c.forms[0].values.peso = 'abc';
|
||||||
|
await c.submit('done');
|
||||||
|
assert.ok(c.forms[0].error.indexOf('Peso') !== -1);
|
||||||
|
|
||||||
|
// a note without fields has no panel
|
||||||
|
c = comp(null);
|
||||||
|
c.initForTask({ id: 33, task_type: 'note', fields: [] });
|
||||||
|
assert.strictEqual(c.forms.length, 0);
|
||||||
|
|
||||||
|
// end of production: tasks already recorded in the run are skipped; the rest recorded then handed over
|
||||||
|
calls.length = 0;
|
||||||
|
c = comp(4);
|
||||||
|
responses = [
|
||||||
|
{ status: 200, body: [{ task_id: 40 }] }, // results of the run
|
||||||
|
{ status: 200, body: { id: 40, title: 'A', task_type: 'note', fields: [] } },
|
||||||
|
{ status: 200, body: { id: 41, title: 'B', task_type: 'note', fields: [{ key: 'k', label: 'K', type: 'text', default: null, attachment: false }] } },
|
||||||
|
];
|
||||||
|
await c.openClose([40, 41]);
|
||||||
|
assert.strictEqual(c.forms[0].recorded, true);
|
||||||
|
assert.strictEqual(c.forms[1].recorded, false);
|
||||||
|
assert.ok(c.open && !c._done);
|
||||||
|
c.forms[1].values.k = 'ok';
|
||||||
|
responses = [{ status: 201, body: { id: 5, outcome: 'done' } }];
|
||||||
|
await c.submitClose();
|
||||||
|
assert.strictEqual(c._done, 1);
|
||||||
|
assert.strictEqual(c.open, false);
|
||||||
|
assert.strictEqual(calls.filter((x) => x.url === '/s').length, 1, 'only the pending task is posted');
|
||||||
|
|
||||||
|
console.log('ok');
|
||||||
|
})().catch((e) => { console.error(e); process.exit(1); });
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
"""gitea:Adriano/TieMeasureFlow#53 #54 #55 - operator side of the check / note results.
|
||||||
|
|
||||||
|
The backend (task-results, in_loop, close_task_ids) lives in another unit: api_client
|
||||||
|
is mocked and the tests work against the contract written in the issues.
|
||||||
|
"""
|
||||||
|
import io
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tests.conftest import api_get_router
|
||||||
|
from tests.test_template_js_syntax import (
|
||||||
|
_INLINE_SCRIPT_RX, _check_alpine_attributes, _force_italian, _node_check,
|
||||||
|
)
|
||||||
|
|
||||||
|
FLASK_APP_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
RECIPE = {
|
||||||
|
"id": 3, "code": "REC-1", "name": "Ricetta",
|
||||||
|
"measurement_interval_minutes": 30, "allow_manual_input": True,
|
||||||
|
"current_version": {"id": 5, "version_number": 1},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _task(task_id, task_type, **extra):
|
||||||
|
task = {
|
||||||
|
"id": task_id, "recipe_id": 3, "version_id": 5, "task_type": task_type,
|
||||||
|
"title": "Task d'esempio", "directive": None, "description": None,
|
||||||
|
"file_path": None, "file_type": None, "annotations_json": None,
|
||||||
|
"subtasks": [], "fields": None,
|
||||||
|
}
|
||||||
|
task.update(extra)
|
||||||
|
return task
|
||||||
|
|
||||||
|
|
||||||
|
def _render(client, mock_api_client, monkeypatch, task):
|
||||||
|
monkeypatch.setattr("blueprints.measure.Config.STATION_CODE", "ST-TEST")
|
||||||
|
mock_api_client.get_station_recipes.return_value = {"items": [{"id": 3}]}
|
||||||
|
mock_api_client.get.side_effect = api_get_router({
|
||||||
|
f"/api/tasks/{task['id']}": task,
|
||||||
|
"/api/recipes/3/tasks": [{"id": task["id"], "order_index": 0}],
|
||||||
|
"/api/recipes/3": RECIPE,
|
||||||
|
})
|
||||||
|
return client.get(f"/measure/execute/{task['id']}")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Page
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def test_check_task_has_ok_nok_buttons(logged_in_client, mock_api_client, monkeypatch):
|
||||||
|
resp = _render(logged_in_client, mock_api_client, monkeypatch, _task(30, "check"))
|
||||||
|
html = resp.get_data(as_text=True)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "submit('ok')" in html and "submit('nok')" in html
|
||||||
|
assert "taskResultForm()" in html
|
||||||
|
assert "/measure/api/task-results" in html
|
||||||
|
assert "js/task-results.js" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_note_with_fields_shows_field_inputs(logged_in_client, mock_api_client, monkeypatch):
|
||||||
|
fields = [
|
||||||
|
{"key": "peso", "label": "Peso", "type": "number", "default": "1.5", "attachment": False},
|
||||||
|
{"key": "foto", "label": "Foto", "type": "text", "default": None, "attachment": True},
|
||||||
|
]
|
||||||
|
task = _task(31, "note", fields=fields)
|
||||||
|
html = _render(logged_in_client, mock_api_client, monkeypatch, task).get_data(as_text=True)
|
||||||
|
# the fields travel to the page in the task data, the inputs are drawn from them
|
||||||
|
assert '"key": "peso"' in html or '"key":"peso"' in html
|
||||||
|
assert 'x-model="f.values[fld.key]"' in html
|
||||||
|
assert "pickFile(f, fld.key, $event)" in html
|
||||||
|
assert "submit('done')" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_close_panel_and_modal_hook_present(logged_in_client, mock_api_client, monkeypatch):
|
||||||
|
html = _render(logged_in_client, mock_api_client, monkeypatch, _task(32, "check")).get_data(as_text=True)
|
||||||
|
assert "task-close-open" in html
|
||||||
|
assert "closeTasksRecorded()" in html
|
||||||
|
assert "close_task_ids" in html
|
||||||
|
assert "/measure/api/tasks/0" in html
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("task", [
|
||||||
|
_task(30, "check"),
|
||||||
|
_task(31, "note", fields=[{"key": "n", "label": "L'etichetta", "type": "text",
|
||||||
|
"default": None, "attachment": True}]),
|
||||||
|
])
|
||||||
|
def test_inline_js_and_alpine_attributes_valid(logged_in_client, mock_api_client, monkeypatch, task):
|
||||||
|
_force_italian(logged_in_client)
|
||||||
|
html = _render(logged_in_client, mock_api_client, monkeypatch, task).get_data(as_text=True)
|
||||||
|
scripts = _INLINE_SCRIPT_RX.findall(html)
|
||||||
|
assert scripts
|
||||||
|
for i, body in enumerate(scripts):
|
||||||
|
_node_check(body, f"/measure/execute check-note script[{i}]")
|
||||||
|
_check_alpine_attributes(html, "/measure/execute check-note")
|
||||||
|
|
||||||
|
|
||||||
|
def test_plain_note_keeps_old_footer(logged_in_client, mock_api_client, monkeypatch):
|
||||||
|
"""A note without fields has no result panel: it is still the plain «Completato»."""
|
||||||
|
html = _render(logged_in_client, mock_api_client, monkeypatch, _task(33, "note")).get_data(as_text=True)
|
||||||
|
assert "goToNextTask()" in html
|
||||||
|
# the panel markup is in the page either way; hasResultPanel (false here) decides
|
||||||
|
assert "hasResultPanel" in html
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Proxies
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def test_task_result_post_is_forwarded(logged_in_client, mock_api_client):
|
||||||
|
mock_api_client.post.return_value = {"id": 9, "outcome": "nok"}
|
||||||
|
body = {
|
||||||
|
"task_id": 30, "production_run_id": 4, "outcome": "nok", "note": "graffio",
|
||||||
|
"values": None, "attachments": None, "ignored": "x",
|
||||||
|
}
|
||||||
|
resp = logged_in_client.post("/measure/api/task-results", json=body)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
assert resp.get_json()["id"] == 9
|
||||||
|
args, kwargs = mock_api_client.post.call_args
|
||||||
|
assert args[0] == "/api/task-results"
|
||||||
|
assert kwargs["data"] == {
|
||||||
|
"task_id": 30, "production_run_id": 4, "outcome": "nok", "note": "graffio",
|
||||||
|
"values": None, "attachments": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_task_result_422_detail_is_passed_through(logged_in_client, mock_api_client):
|
||||||
|
mock_api_client.post.return_value = {
|
||||||
|
"error": True, "status_code": 422, "detail": "Per un NOK serve la nota",
|
||||||
|
}
|
||||||
|
resp = logged_in_client.post(
|
||||||
|
"/measure/api/task-results", json={"task_id": 30, "outcome": "nok"})
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert resp.get_json()["detail"] == "Per un NOK serve la nota"
|
||||||
|
|
||||||
|
|
||||||
|
def test_task_result_requires_task_id(logged_in_client, mock_api_client):
|
||||||
|
resp = logged_in_client.post("/measure/api/task-results", json={"outcome": "ok"})
|
||||||
|
assert resp.status_code == 400
|
||||||
|
mock_api_client.post.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_task_results_list_forwards_filters(logged_in_client, mock_api_client):
|
||||||
|
mock_api_client.get.return_value = [{"id": 1, "task_id": 30}]
|
||||||
|
resp = logged_in_client.get("/measure/api/task-results?production_run_id=4&task_id=30")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json() == [{"id": 1, "task_id": 30}]
|
||||||
|
args, kwargs = mock_api_client.get.call_args
|
||||||
|
assert args[0] == "/api/task-results"
|
||||||
|
assert kwargs["params"] == {"production_run_id": "4", "task_id": "30"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_attachment_upload_forwards_multipart(logged_in_client, mock_api_client):
|
||||||
|
mock_api_client.post.return_value = {"file_path": "general/foto.png", "file_type": "image/png"}
|
||||||
|
resp = logged_in_client.post(
|
||||||
|
"/measure/api/attachments",
|
||||||
|
data={"file": (io.BytesIO(b"\x89PNG fake"), "foto.png", "image/png")},
|
||||||
|
content_type="multipart/form-data",
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
assert resp.get_json()["file_path"] == "general/foto.png"
|
||||||
|
args, kwargs = mock_api_client.post.call_args
|
||||||
|
assert args[0] == "/api/files/upload"
|
||||||
|
name, content, mime = kwargs["files"]["file"]
|
||||||
|
assert name == "foto.png" and mime == "image/png" and content.startswith(b"\x89PNG")
|
||||||
|
|
||||||
|
|
||||||
|
def test_attachment_upload_requires_file(logged_in_client, mock_api_client):
|
||||||
|
resp = logged_in_client.post("/measure/api/attachments", data={})
|
||||||
|
assert resp.status_code == 400
|
||||||
|
mock_api_client.post.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_attachment_upload_error_detail_is_passed_through(logged_in_client, mock_api_client):
|
||||||
|
mock_api_client.post.return_value = {"error": True, "status_code": 400, "detail": "File type not allowed"}
|
||||||
|
resp = logged_in_client.post(
|
||||||
|
"/measure/api/attachments",
|
||||||
|
data={"file": (io.BytesIO(b"x"), "a.exe", "application/octet-stream")},
|
||||||
|
content_type="multipart/form-data",
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert resp.get_json()["detail"] == "File type not allowed"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_task_proxy(logged_in_client, mock_api_client):
|
||||||
|
mock_api_client.get.return_value = {"id": 40, "task_type": "note", "fields": []}
|
||||||
|
resp = logged_in_client.get("/measure/api/tasks/40")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json()["id"] == 40
|
||||||
|
assert mock_api_client.get.call_args[0][0] == "/api/tasks/40"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Component logic (plain node, stubbed fetch)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def test_task_results_component_js():
|
||||||
|
node = shutil.which("node")
|
||||||
|
if not node:
|
||||||
|
pytest.skip("node non installato: test JS di task-results.js saltato")
|
||||||
|
script = Path(__file__).resolve().parent / "js" / "test_task_results.js"
|
||||||
|
result = subprocess.run(
|
||||||
|
[node, str(script)], cwd=str(FLASK_APP_DIR),
|
||||||
|
capture_output=True, text=True, timeout=30,
|
||||||
|
)
|
||||||
|
assert result.returncode == 0, f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
|
||||||
Reference in New Issue
Block a user