Compare commits

..

2 Commits

Author SHA1 Message Date
Adriano 8e3316957c Monitor stazioni: pagina /monitor e proxy JSON (#57)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 12:25:51 +02:00
Adriano db5d686938 Monitor stazioni: GET /api/production-runs/open (#57)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 12:25:16 +02:00
18 changed files with 586 additions and 509 deletions
+23 -1
View File
@@ -20,7 +20,8 @@ from src.backend.models.api.production import (
)
from src.backend.models.orm.production import ProductionRun
from src.backend.models.orm.user import User
from src.backend.services import auth_service, production_service
from src.backend.models.api.monitor import MonitorRun
from src.backend.services import auth_service, monitor_service, production_service
router = APIRouter(prefix="/api/production-runs", tags=["production"])
@@ -81,6 +82,27 @@ async def get_current_production_run(
return await _as_response(db, run) if run is not None else None
async def _require_high_authority(user: User = Depends(get_current_user)) -> User:
"""Monitor of all stations (#57): admin or Supervisor (capoturno) only."""
if user.is_admin or user.has_role("Supervisor"):
return user
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Supervisor role required",
)
@router.get("/open", response_model=list[MonitorRun])
async def list_open_production_runs(
user: User = Depends(_require_high_authority),
db: AsyncSession = Depends(get_db),
):
"""Every open run with timer, last event and recent measurements (#57).
Declared before /{run_id} so "open" is not parsed as a run id.
"""
return await monitor_service.list_open_runs(db)
@router.get("/{run_id}", response_model=ProductionRunWithEventsResponse)
async def get_production_run(
run_id: int,
+49
View File
@@ -0,0 +1,49 @@
"""API models for the live station monitor (#57)."""
from datetime import datetime
from typing import Optional
from pydantic import BaseModel
class MonitorSubtask(BaseModel):
"""Mean of the recent measurements of one quote, against its tolerance."""
subtask_id: int
description: str
unit: str
count: int
mean: float
ltl: Optional[float] = None
utl: Optional[float] = None
# True when ltl <= mean <= utl (a missing limit does not constrain).
in_tolerance: bool
class MonitorEvent(BaseModel):
event_type: str
created_at: datetime
note: Optional[str] = None
class MonitorRun(BaseModel):
id: int
station_code: str
station_name: str
recipe_code: str
recipe_name: str
version_number: int
operator: str
status: str
started_at: datetime
duration_seconds: int
cycle_count: int
seconds_to_next_measurement: Optional[int] = None
overdue: bool
last_event: Optional[MonitorEvent] = None
# Counts over the last MONITOR_WINDOW measurements of the run.
measured: int
pass_count: int
warning_count: int
fail_count: int
subtasks: list[MonitorSubtask]
server_time: datetime
+133
View File
@@ -0,0 +1,133 @@
"""Live monitor of the open production runs (#57).
One query per table, whatever the number of runs: measurements and last events are
ranked per run with a window function and cut in the database, not in a loop.
"""
from collections import defaultdict
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.models.api.monitor import MonitorEvent, MonitorRun, MonitorSubtask
from src.backend.models.orm.measurement import Measurement
from src.backend.models.orm.production import ProductionEvent, ProductionRun
from src.backend.models.orm.task import RecipeSubtask
from src.backend.models.orm.user import User
from src.backend.services import production_service
# Recent measurements per run that feed counts and means (#57: last N).
MONITOR_WINDOW = 20
def _in_tolerance(mean: float, ltl, utl) -> bool:
# A missing limit does not constrain.
if ltl is not None and mean < float(ltl):
return False
if utl is not None and mean > float(utl):
return False
return True
async def list_open_runs(db: AsyncSession) -> list[MonitorRun]:
runs = (await db.execute(
select(ProductionRun)
.where(ProductionRun.status.in_(("running", "paused")))
.order_by(ProductionRun.started_at)
)).scalars().all()
if not runs:
return []
run_ids = [r.id for r in runs]
# Operators: one query.
users = {
u.id: u for u in (await db.execute(
select(User).where(User.id.in_({r.operator_id for r in runs}))
)).scalars().all()
}
# Last event per run: rank desc inside the run, keep rank 1.
ev_rank = func.row_number().over(
partition_by=ProductionEvent.run_id,
order_by=(ProductionEvent.created_at.desc(), ProductionEvent.id.desc()),
).label("rn")
ev_sub = select(
ProductionEvent.run_id.label("run_id"),
ProductionEvent.event_type.label("event_type"),
ProductionEvent.created_at.label("created_at"),
ProductionEvent.note.label("note"),
ev_rank,
).where(ProductionEvent.run_id.in_(run_ids)).subquery()
ev_rows = (await db.execute(
select(ev_sub.c.run_id, ev_sub.c.event_type, ev_sub.c.created_at, ev_sub.c.note)
.where(ev_sub.c.rn == 1)
)).all()
last_event = {r.run_id: r for r in ev_rows}
# Last N measurements per run, joined to their subtask for the limits.
m_rank = func.row_number().over(
partition_by=Measurement.production_run_id,
order_by=(Measurement.measured_at.desc(), Measurement.id.desc()),
).label("rn")
m_sub = select(
Measurement.production_run_id.label("run_id"),
Measurement.subtask_id.label("subtask_id"),
Measurement.value.label("value"),
Measurement.pass_fail.label("pass_fail"),
m_rank,
).where(Measurement.production_run_id.in_(run_ids)).subquery()
m_rows = (await db.execute(
select(
m_sub.c.run_id, m_sub.c.subtask_id, m_sub.c.value, m_sub.c.pass_fail,
RecipeSubtask.description, RecipeSubtask.unit,
RecipeSubtask.ltl, RecipeSubtask.utl,
)
.join(RecipeSubtask, RecipeSubtask.id == m_sub.c.subtask_id)
.where(m_sub.c.rn <= MONITOR_WINDOW)
)).all()
by_run = defaultdict(list)
for row in m_rows:
by_run[row.run_id].append(row)
result = []
for run in runs:
rows = by_run.get(run.id, [])
per_sub: dict[int, list] = defaultdict(list)
for row in rows:
per_sub[row.subtask_id].append(row)
subtasks = []
for sid, group in sorted(per_sub.items()):
mean = sum(float(g.value) for g in group) / len(group)
first = group[0]
subtasks.append(MonitorSubtask(
subtask_id=sid, description=first.description, unit=first.unit,
count=len(group), mean=round(mean, 6),
ltl=float(first.ltl) if first.ltl is not None else None,
utl=float(first.utl) if first.utl is not None else None,
in_tolerance=_in_tolerance(mean, first.ltl, first.utl),
))
timer = production_service.describe(run)
ev = last_event.get(run.id)
operator = users.get(run.operator_id)
result.append(MonitorRun(
id=run.id,
station_code=run.station.code, station_name=run.station.name,
recipe_code=run.recipe.code, recipe_name=run.recipe.name,
version_number=run.version.version_number,
operator=(operator.display_name or operator.username) if operator else "",
status=run.status,
started_at=run.started_at,
duration_seconds=max(0, int((timer["server_time"] - run.started_at).total_seconds())),
cycle_count=run.cycle_count,
seconds_to_next_measurement=timer["seconds_to_next_measurement"],
overdue=timer["overdue"],
last_event=MonitorEvent(
event_type=ev.event_type, created_at=ev.created_at, note=ev.note,
) if ev else None,
measured=len(rows),
pass_count=sum(1 for g in rows if g.pass_fail == "pass"),
warning_count=sum(1 for g in rows if g.pass_fail == "warning"),
fail_count=sum(1 for g in rows if g.pass_fail == "fail"),
subtasks=subtasks,
server_time=timer["server_time"],
))
return result
+83
View File
@@ -0,0 +1,83 @@
"""GET /api/production-runs/open - live monitor of the open runs (#57)."""
from datetime import datetime, timedelta
from sqlalchemy import select
from src.backend.models.orm.measurement import Measurement
from src.backend.models.orm.production import ProductionEvent, ProductionRun
from src.backend.models.orm.station import Station
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
from src.backend.tests.conftest import _create_user, auth_headers, create_test_recipe
async def _run(db, user, code, status="running", overdue=False, open_=True):
"""A station plus a run on a fresh recipe (limits ltl 9.5 / utl 10.5)."""
station = Station(code=f"ST-{code}", name=f"Linea {code}", active=True, created_by=user.id)
db.add(station)
recipe = await create_test_recipe(db, user_id=user.id, code=f"REC-{code}")
await db.flush()
version = recipe.versions[0]
now = datetime.utcnow()
run = ProductionRun(
station_id=station.id, recipe_id=recipe.id, version_id=version.id,
operator_id=user.id, status=status if open_ else "closed",
started_at=now - timedelta(hours=1),
measurement_interval_minutes=30,
next_measurement_at=now + (timedelta(minutes=-5) if overdue else timedelta(minutes=20)),
cycle_count=3,
active_station_id=station.id if open_ else None,
)
db.add(run)
await db.flush()
db.add(ProductionEvent(run_id=run.id, event_type="start", user_id=user.id))
sub = (await db.execute(
select(RecipeSubtask).join(RecipeTask, RecipeTask.id == RecipeSubtask.task_id)
.where(RecipeTask.version_id == version.id)
)).scalars().first()
return run, sub, version
def _m(run, sub, version, user, value, pf, minutes=0):
return Measurement(
subtask_id=sub.id, version_id=version.id, measured_by=user.id, value=value,
pass_fail=pf, production_run_id=run.id,
measured_at=datetime.utcnow() - timedelta(minutes=minutes),
)
async def test_open_requires_high_authority(client, db_session, measurement_tec_user):
resp = await client.get("/api/production-runs/open", headers=auth_headers(measurement_tec_user))
assert resp.status_code == 403
async def test_open_lists_only_open_runs_with_means(client, db_session, admin_user):
sup = await _create_user(db_session, "capo", roles=["Supervisor"])
r1, s1, v1 = await _run(db_session, admin_user, "A", overdue=True)
r2, s2, v2 = await _run(db_session, admin_user, "B", status="paused")
await _run(db_session, admin_user, "C", open_=False)
# Run A: mean 10.0 (inside), run B: mean 10.8 (outside, one fail).
for i, (val, pf) in enumerate([(9.9, "pass"), (10.1, "pass")]):
db_session.add(_m(r1, s1, v1, admin_user, val, pf, i))
for i, (val, pf) in enumerate([(10.6, "fail"), (11.0, "fail"), (10.8, "warning")]):
db_session.add(_m(r2, s2, v2, admin_user, val, pf, i))
await db_session.commit()
resp = await client.get("/api/production-runs/open", headers=auth_headers(sup))
assert resp.status_code == 200
body = {r["station_code"]: r for r in resp.json()}
assert set(body) == {"ST-A", "ST-B"} # closed run C is absent
a, b = body["ST-A"], body["ST-B"]
assert a["overdue"] is True and a["seconds_to_next_measurement"] < 0
assert b["overdue"] is False and b["status"] == "paused"
assert a["duration_seconds"] >= 3600 and a["cycle_count"] == 3
assert a["last_event"]["event_type"] == "start"
assert a["pass_count"] == 2 and a["fail_count"] == 0
assert a["subtasks"][0]["mean"] == 10.0 and a["subtasks"][0]["in_tolerance"] is True
assert b["fail_count"] == 2 and b["warning_count"] == 1
assert round(b["subtasks"][0]["mean"], 3) == 10.8
assert b["subtasks"][0]["in_tolerance"] is False
async def test_open_empty_for_admin(client, admin_user):
resp = await client.get("/api/production-runs/open", headers=auth_headers(admin_user))
assert resp.status_code == 200 and resp.json() == []
+2
View File
@@ -74,12 +74,14 @@ def create_app() -> Flask:
from blueprints.maker import maker_bp
from blueprints.statistics import statistics_bp
from blueprints.admin import admin_bp
from blueprints.monitor import monitor_bp # #57 live station monitor
app.register_blueprint(auth_bp)
app.register_blueprint(measure_bp, url_prefix="/measure")
app.register_blueprint(maker_bp, url_prefix="/maker")
app.register_blueprint(statistics_bp, url_prefix="/statistics")
app.register_blueprint(admin_bp, url_prefix="/admin")
app.register_blueprint(monitor_bp, url_prefix="/monitor") # #57
@app.before_request
def _check_secret_key():
+1 -67
View File
@@ -1,10 +1,8 @@
"""MeasurementTec blueprint - recipe selection and measurement execution."""
import urllib.parse
import requests as http_requests
from flask import (
Blueprint, Response, abort, flash, jsonify, redirect, render_template,
Blueprint, abort, flash, jsonify, redirect, render_template,
request, session, url_for,
)
from flask_babel import gettext as _
@@ -822,67 +820,3 @@ def api_close_production(run_id: int):
def api_get_file(file_path: str):
"""Proxy: Serve file from API server (browser can't send X-API-Key)."""
return proxy_file(file_path)
# ---------------------------------------------------------------------------
# Route: vision execute (#46) - the browser sends an image, we relay it to
# POST /api/vision/execute (multipart) because it cannot send X-API-Key itself.
# ---------------------------------------------------------------------------
@measure_bp.route("/api/vision/execute", methods=["POST"])
@login_required
@role_required("MeasurementTec")
def api_vision_execute():
"""Proxy: run the task's VisionSuite recipe on the uploaded image."""
image = request.files.get("image")
task_id = request.form.get("task_id")
if image is None or not task_id:
return jsonify({
"error": True,
"detail": _("Dati mancanti: task_id e image sono obbligatori"),
}), 400
# Same context fields as the manual flow; empty ones are left out so the
# backend sees them as absent rather than as "".
form = {"task_id": task_id}
for key in ("task_type", "lot_number", "serial_number", "production_run_id"):
val = request.form.get(key)
if val:
form[key] = val
form.setdefault("lot_number", session.get("lot_number", ""))
form.setdefault("serial_number", session.get("serial_number", ""))
resp = api_client.post(
"/api/vision/execute",
data=form,
files={"image": (
image.filename or "capture.jpg",
image.read(),
image.mimetype or "application/octet-stream",
)},
)
if resp.get("error"):
status_code = resp.get("status_code", 500)
body = {"error": True, "detail": resp.get("detail", _("Errore nell'esecuzione"))}
if resp.get("session_expired"):
body["session_expired"] = True
return jsonify(body), status_code if status_code >= 400 else 500
return jsonify(resp), 200
@measure_bp.route("/api/vision/results/<int:result_id>/image", methods=["GET"])
@login_required
@role_required("MeasurementTec")
def api_vision_result_image(result_id: int):
"""Proxy: the image saved with a vision result (needs X-API-Key)."""
base_url = Config.API_SERVER_URL.rstrip("/")
resp = http_requests.get(
f"{base_url}/api/vision/results/{result_id}/image",
headers={"X-API-Key": session.get("api_key", "")},
timeout=30,
)
if resp.status_code != 200:
return Response(resp.text, status=resp.status_code)
return Response(
resp.content,
content_type=resp.headers.get("content-type", "application/octet-stream"),
)
@@ -0,0 +1,45 @@
"""Live monitor of the open production runs (#57).
Page at /monitor for admin and Supervisor (capoturno); the page polls a JSON proxy
of GET /api/production-runs/open every 10 seconds.
"""
from functools import wraps
from flask import Blueprint, abort, jsonify, render_template, session
from blueprints.auth import login_required
from services.api_client import api_client
monitor_bp = Blueprint("monitor", __name__)
def monitor_required(f):
"""Admin or Supervisor only: the same people the backend lets read /open."""
@wraps(f)
def decorated(*args, **kwargs):
user = session.get("user", {})
if not (user.get("is_admin") or "Supervisor" in user.get("roles", [])):
abort(403)
return f(*args, **kwargs)
return decorated
@monitor_bp.route("", strict_slashes=False)
@login_required
@monitor_required
def index():
return render_template("monitor/index.html")
@monitor_bp.route("/api/runs")
@login_required
@monitor_required
def api_runs():
"""JSON proxy polled by the page."""
resp = api_client.get("/api/production-runs/open")
if isinstance(resp, dict) and resp.get("error"):
body = {"error": True, "detail": resp.get("detail", "")}
if resp.get("session_expired"):
body["session_expired"] = True
return jsonify(body), resp.get("status_code", 500)
return jsonify(resp)
@@ -1,125 +0,0 @@
/**
* Vision execute - Alpine component for camera_measure / dxf_compare tasks (#46).
*
* The operator takes (or picks) an image, `acquire(blob)` sends it to the Flask
* proxy, and the result - quote table, outcome, failures, overlay - is shown on
* the image. `acquire(blob)` is the single entry point on purpose: the agent
* camera (#3) will call it with its own blob and reuse everything below.
*
* Config comes from window.__visionCfg (set by the template):
* executeUrl, imageUrlTpl (with a 0 placeholder for the result id), taskId,
* taskType, subtasks, labels.
* Run context (lot, serial, production run) is read lazily through `ctx()`,
* which the template binds to the parent task component.
*/
function visionExecute() {
// Overlay colour per role (VisionSuite roles: reference, roi, found).
const ROLE_COLORS = { reference: '#2563eb', found: '#16a34a', roi: '#f59e0b' };
return {
busy: false,
error: '',
result: null, // last execute response
imageSrc: '', // object URL of the acquired image (shown under the overlay)
ctx: () => ({}), // replaced by the template: {lot, serial, runId}
// Entry point shared with the future agent camera.
async acquire(blob) {
if (!blob || this.busy) return;
const cfg = window.__visionCfg || {};
this.busy = true;
this.error = '';
this.result = null;
// Show the picked image at once: the overlay is drawn on top when the result arrives.
if (this.imageSrc) URL.revokeObjectURL(this.imageSrc);
this.imageSrc = URL.createObjectURL(blob);
try {
const c = this.ctx() || {};
const fd = new FormData();
fd.append('task_id', cfg.taskId);
fd.append('task_type', cfg.taskType || '');
fd.append('image', blob, blob.name || 'capture.jpg');
fd.append('lot_number', c.lot || '');
fd.append('serial_number', c.serial || '');
if (c.runId) fd.append('production_run_id', c.runId);
const csrf = document.querySelector('meta[name=csrf-token]')?.content || '';
const resp = await fetch(cfg.executeUrl, {
method: 'POST', headers: { 'X-CSRFToken': csrf }, body: fd,
});
const body = await resp.json().catch(() => ({}));
if (!resp.ok || body.error) {
this.error = body.detail || (cfg.labels && cfg.labels.requestFailed) || 'Error';
return;
}
this.result = body;
// Draw once Alpine has rendered the canvas.
this.$nextTick(() => this.drawOverlay());
} catch (e) {
this.error = (cfg.labels && cfg.labels.requestFailed) || 'Error';
} finally {
this.busy = false;
}
},
// Input change: take the file and allow picking the same one again.
onPick(ev) {
const f = ev.target.files && ev.target.files[0];
ev.target.value = '';
if (f) this.acquire(f);
},
// Image shown: the saved one from the server when available, else the local one.
get shownImage() {
const cfg = window.__visionCfg || {};
if (this.result && this.result.vision_result_id && cfg.imageUrlTpl) {
return cfg.imageUrlTpl.replace('/0/', '/' + this.result.vision_result_id + '/');
}
return this.imageSrc;
},
// Quote rows: server measurements joined with the subtask names.
get rows() {
const cfg = window.__visionCfg || {};
const subs = cfg.subtasks || [];
return ((this.result && this.result.measurements) || []).map((m) => {
const st = subs.find((s) => s.id === m.subtask_id) || {};
return {
label: (st.marker_number ? st.marker_number + '. ' : '') + (st.measurement_type || st.name || '#' + m.subtask_id),
value: m.value,
unit: st.unit || 'mm',
status: m.pass_fail,
};
});
},
statusClass(s) {
// Same palette as the measurement markers of the manual flow.
if (s === 'pass') return 'bg-measure-pass/20 text-measure-pass border border-measure-pass/40';
if (s === 'fail') return 'bg-red-100 dark:bg-red-900/30 text-measure-fail border border-measure-fail/40';
if (s === 'warning') return 'bg-amber-100 dark:bg-amber-900/30 text-measure-warning border border-measure-warning/40';
return 'bg-[var(--bg-secondary)] text-[var(--text-muted)] border border-[var(--border-color)]';
},
// Canvas is sized to the natural image, so overlay coordinates (image px)
// are used as they are and CSS scales the whole thing to the displayed size.
drawOverlay() {
const img = this.$refs.img, cv = this.$refs.overlay;
if (!img || !cv || !img.naturalWidth) return;
cv.width = img.naturalWidth;
cv.height = img.naturalHeight;
const g = cv.getContext('2d');
g.clearRect(0, 0, cv.width, cv.height);
g.lineWidth = Math.max(2, cv.width / 400);
g.lineJoin = 'round';
for (const item of ((this.result && this.result.overlay) || [])) {
const rows = item.rows || [], cols = item.cols || [];
if (!rows.length || rows.length !== cols.length) continue;
g.strokeStyle = ROLE_COLORS[item.role] || '#ef4444';
g.beginPath();
g.moveTo(cols[0], rows[0]);
for (let i = 1; i < rows.length; i++) g.lineTo(cols[i], rows[i]);
g.stroke();
}
},
};
}
@@ -13,15 +13,6 @@
barcodeInput: '',
barcodeLoading: false,
barcodeError: '',
/* #63/#64: ricetta scelta che richiede lotto/seriale; il pannello compare solo allora. */
selected: null,
needsTrace(recipe) {
return !!(recipe && (recipe.requires_lot || recipe.requires_serial));
},
choose(recipe) {
this.selected = recipe;
this.$nextTick(() => { const el = document.getElementById('trace-panel'); if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' }); });
},
get filteredRecipes() {
if (!this.search) return this.recipes;
const q = this.search.toLowerCase();
@@ -71,12 +62,9 @@
// 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.
// #63/#64: se la ricetta chiede lotto/seriale mancanti, la si sceglie e
// compare il pannello con i soli campi richiesti.
if (this.missingTraceability(data).length) {
this.barcodeModal = false;
this.barcodeInput = '';
this.choose(data);
const missing = this.missingTraceability(data);
if (missing.length) {
this.barcodeError = {{ _("Compila prima:")|tojson_attr }} + ' ' + missing.join(', ');
return;
}
this.barcodeModal = false;
@@ -161,6 +149,50 @@
</div>
</div>
<!-- Traceability (data entry, NOT a search filter) -->
<div class="tmf-card mb-6 border-l-4 border-l-primary">
<div class="p-4 sm:p-5">
<div class="flex items-center gap-2 mb-3">
<svg class="w-5 h-5 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="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
</svg>
<div>
<h2 class="text-sm font-semibold text-[var(--text-primary)] leading-tight">{{ _('Tracciabilità') }}</h2>
<p class="text-xs text-[var(--text-muted)] leading-tight">{{ _('Dati del pezzo da misurare — compila prima di selezionare la ricetta') }}</p>
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<!-- Lot Number -->
<div>
<label class="tmf-label flex items-center gap-1.5">
<svg class="w-3.5 h-3.5 text-amber-500" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
</svg>
{{ _('Numero Lotto') }}
</label>
<input type="text"
x-model="lot_number"
placeholder="{{ _('Es. LOT-2026-001') }}"
class="tmf-input font-mono">
</div>
<!-- Serial Number -->
<div>
<label class="tmf-label flex items-center gap-1.5">
<svg class="w-3.5 h-3.5 text-indigo-500" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14"/>
</svg>
{{ _('Numero Seriale') }}
</label>
<input type="text"
x-model="serial_number"
placeholder="{{ _('Es. SN-000123') }}"
class="tmf-input font-mono">
</div>
</div>
</div>
</div>
<!-- Results Count -->
<div class="flex items-center justify-between mb-4">
<p class="text-sm text-[var(--text-secondary)]">
@@ -237,26 +269,31 @@
<div class="px-5 py-3.5 border-t border-[var(--border-color)] bg-[var(--bg-secondary)]
rounded-b-xl">
{# #63/#64: una ricetta con lotto/seriale obbligatori si sceglie prima
(il pannello compare sotto la griglia); le altre partono subito. #}
<template x-if="needsTrace(recipe)">
<button type="button" @click="choose(recipe)"
:class="selected && selected.id === recipe.id ? 'ring-2 ring-primary' : ''"
class="btn btn-primary w-full justify-center text-sm font-semibold">
{{ _('Seleziona') }}
</button>
</template>
<template x-if="!needsTrace(recipe)">
<a :href="buildTaskUrl(recipe.id)"
class="btn btn-primary w-full justify-center text-sm font-semibold
transition-shadow duration-200 group-hover:shadow-md">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 3l14 9-14 9V3z"/>
{# 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>
{{ _('AVVIA ATTIVITÀ') }}
</a>
<span x-text="{{ _('Compila prima:')|tojson_attr }} + ' ' + 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
transition-shadow duration-200">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 3l14 9-14 9V3z"/>
</svg>
{{ _('AVVIA IN SEQUENZA') }}
</a>
{# The whole list, for when the operator wants to see it or go back to a
task left unfinished. Deliberately the quiet one of the two. #}
<a :href="'/measure/tasks/' + recipe.id"
@@ -269,41 +306,6 @@
</template>
</div>
<!-- Traceability (#63/#64): compare solo dopo aver scelto una ricetta con
requires_lot / requires_serial, con i soli campi richiesti. -->
<div id="trace-panel" x-show="selected && needsTrace(selected)" x-cloak
class="tmf-card mt-6 border-l-4 border-l-primary">
<div class="p-4 sm:p-5">
<div class="flex items-center gap-2 mb-3">
<svg class="w-5 h-5 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="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
</svg>
<div>
<h2 class="text-sm font-semibold text-[var(--text-primary)] leading-tight">{{ _('Tracciabilità') }}: <span x-text="selected ? selected.name : ''"></span></h2>
<p class="text-xs text-[var(--text-muted)] leading-tight">{{ _('Dati del pezzo da misurare — compila prima di selezionare la ricetta') }}</p>
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div x-show="selected && selected.requires_lot">
<label class="tmf-label">{{ _('Numero Lotto') }}</label>
<input type="text" x-model="lot_number"
placeholder="{{ _('Es. LOT-2026-001') }}" class="tmf-input font-mono">
</div>
<div x-show="selected && selected.requires_serial">
<label class="tmf-label">{{ _('Numero Seriale') }}</label>
<input type="text" x-model="serial_number"
placeholder="{{ _('Es. SN-000123') }}" class="tmf-input font-mono">
</div>
</div>
<a :href="selected && !missingTraceability(selected).length ? buildTaskUrl(selected.id) : null"
:aria-disabled="!selected || missingTraceability(selected).length > 0"
:class="(!selected || missingTraceability(selected).length) ? 'opacity-40 cursor-not-allowed pointer-events-none' : ''"
class="btn btn-primary w-full justify-center text-sm font-semibold mt-4">
{{ _('AVVIA ATTIVITÀ') }}
</a>
</div>
</div>
<!-- Empty State -->
<div x-show="filteredRecipes.length === 0" x-cloak
class="text-center py-16">
@@ -115,12 +115,20 @@
</svg>
{{ _('Lista task') }}
</a>
{# #60: un solo «Completato», quello grande in fondo (touch); qui resta solo la lista. #}
<button @click="goToNextTask()"
:disabled="!canLeaveTask"
:title="canLeaveTask ? '' : blockedReason"
:class="canLeaveTask ? '' : 'opacity-40 cursor-not-allowed'"
class="btn btn-secondary text-xs py-1 px-2.5 gap-1">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
</svg>
{{ _('Completato') }}
</button>
</div>
{# Fermo linea + Fine produzione (measurement tasks only) #}
<template x-if="isMeasureTask && productionRun">
{# #61: Fermo linea / Fine produzione solo con una produzione aperta #}
<template x-if="isMeasureTask">
<div class="shrink-0 flex items-center gap-1.5">
{# While the line is stopped this becomes the way to restart it, so the
capoturno never has to hunt for a second control. #}
@@ -183,8 +191,7 @@
{# ================================================================
MAIN — 3-column layout
================================================================ #}
{# #46: camera_measure / dxf_compare get the acquisition panel below instead of the manual layout #}
<div class="flex-1 flex overflow-hidden" x-show="!isVisionTask">
<div class="flex-1 flex overflow-hidden">
{# ──────────────────────────────────────────────
LEFT SIDEBAR — Marker list (vertical, hidden when no subtasks)
@@ -444,63 +451,6 @@
</div>
{# ================================================================
VISION PANEL (#46) - acquire an image, run the recipe server-side, show the
quotes and the overlay. acquire(blob) is the entry point the agent camera
(#3) will reuse.
================================================================ #}
<div x-show="isVisionTask" x-cloak
x-data="visionExecute()"
x-init="ctx = () => ({ lot: lotNumber, serial: serialNumber, runId: productionRun ? productionRun.id : null })"
class="flex-1 overflow-auto p-3 flex flex-col lg:flex-row gap-3">
<div class="flex-1 min-w-0 flex flex-col gap-2">
<div class="flex items-center gap-2 flex-wrap">
{# On a tablet this opens the camera; on a PC it picks a file. «Riprova» reuses the same input. #}
<label class="btn btn-primary text-sm gap-1.5 cursor-pointer" :class="busy ? 'opacity-50 pointer-events-none' : ''">
<span x-text="result ? '{{ _('Riprova') }}' : '{{ _('Acquisisci immagine') }}'"></span>
<input type="file" accept="image/*" capture="environment" class="hidden"
@change="onPick($event)" :disabled="busy">
</label>
<span x-show="busy" class="text-sm text-[var(--text-muted)]">{{ _('Elaborazione in corso...') }}</span>
</div>
<div x-show="error" class="p-2 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-sm text-measure-fail" x-text="error"></div>
<div x-show="shownImage" class="relative border border-[var(--border-color)] rounded-lg overflow-hidden bg-black/5">
<img x-ref="img" :src="shownImage" @load="drawOverlay()" alt="" class="block w-full h-auto">
<canvas x-ref="overlay" class="absolute inset-0 w-full h-full pointer-events-none"></canvas>
</div>
<div x-show="!shownImage" class="text-sm text-[var(--text-muted)]">
{{ _('Nessuna immagine acquisita: premi «Acquisisci immagine».') }}
</div>
</div>
<div class="lg:w-80 shrink-0 flex flex-col gap-2" x-show="result">
<div class="text-sm font-bold text-[var(--text-primary)]">
{{ _('Esito') }}: <span x-text="(result && result.outcome) || '-'"></span>
</div>
<table class="w-full text-sm" x-show="rows.length">
<template x-for="r in rows" :key="r.label">
<tr>
<td class="py-1 pr-2 text-[var(--text-secondary)]" x-text="r.label"></td>
<td class="py-1 pr-2 font-mono text-right" x-text="Number(r.value).toFixed(3) + ' ' + r.unit"></td>
<td class="py-1"><span class="inline-block px-2 py-0.5 rounded text-xs font-bold" :class="statusClass(r.status)" x-text="r.status"></span></td>
</tr>
</template>
</table>
<div x-show="result && result.failures && result.failures.length"
class="p-2 rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-300 dark:border-amber-700 text-xs">
<div class="font-bold mb-1">{{ _('Strumenti falliti') }}</div>
<template x-for="f in ((result && result.failures) || [])" :key="f.tool_id">
<div><span class="font-mono" x-text="f.tool_name || f.tool_id"></span>: <span x-text="f.error"></span></div>
</template>
</div>
</div>
</div>
{# ================================================================
MEASUREMENT TIMER BANNER
================================================================ #}
@@ -748,12 +698,10 @@
</div>
</template>
<template x-if="!isMeasureTask || subtasks.length === 0">
{# #60: unico «Completato», largo e alto almeno 64 px per il touch. #}
<button @click="goToNextTask()"
:disabled="!canLeaveTask"
class="btn text-lg font-bold flex-1 w-full min-h-[64px] justify-center gap-2 shadow-md
class="btn text-xs shrink-0 gap-1 py-1.5 px-3 shadow-md
bg-measure-pass text-white hover:opacity-90">
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
</svg>
{{ _('Completato') }}
@@ -839,10 +787,6 @@
</div>
</div>
{# #61: se il server rifiuta l'avvio produzione, l'errore si vede qui, nell'overlay #}
<p x-show="productionError" x-cloak
class="mb-2 text-sm text-red-700 dark:text-red-300 text-center" x-text="productionError"></p>
<div class="flex gap-2">
<button @click="goToSummary()"
class="btn btn-secondary flex-1 justify-center gap-2">
@@ -968,18 +912,6 @@
<script src="{{ url_for('static', filename='js/numpad.js') }}"></script>
<script src="{{ url_for('static', filename='js/annotation-viewer.js') }}?v=6"></script>
<script src="{{ url_for('static', filename='js/caliper.js') }}"></script>
{# #46: acquisition + overlay for camera_measure / dxf_compare #}
<script>
window.__visionCfg = {
executeUrl: '{{ url_for("measure.api_vision_execute") }}',
imageUrlTpl: '{{ url_for("measure.api_vision_result_image", result_id=0) }}',
taskId: {{ task.id|tojson }},
taskType: {{ (task.task_type or '')|tojson }},
subtasks: window.__taskSubtasks,
labels: { requestFailed: {{ _('Richiesta non riuscita')|tojson }} },
};
</script>
<script src="{{ url_for('static', filename='js/vision_execute.js') }}"></script>
{# 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. #}
<script src="{{ url_for('static', filename='js/production-clock.js') }}"></script>
@@ -1248,18 +1180,11 @@ function taskExecute() {
async loadProductionRun() {
try {
const resp = await fetch('{{ url_for("measure.api_current_production") }}');
if (!resp.ok) {
// #61: si mostra il detail del server (es. 503 "Stazione non configurata"),
// che e' la causa vera; fallback generico se il corpo non e' JSON.
const errBody = await resp.json().catch(() => null);
this.productionError = (errBody && errBody.detail) || '{{ _("Errore di comunicazione con il server") }}';
return;
}
if (!resp.ok) return;
const run = await resp.json();
if (run && run.id) this.adoptProductionRun(run);
} catch (e) {
// #61: offline o server giu: pagina usabile, ma l'errore e' visibile.
this.productionError = '{{ _("Errore di connessione") }}';
// Offline or server down: leave the page usable, just without the timer.
}
},
@@ -1772,9 +1697,13 @@ function taskExecute() {
this.showCompletionOverlay = false;
const opened = await this.startProduction();
if (!opened) {
// #61: server rifiuta -> niente timer locale finto; si riapre l'overlay
// con l'errore (productionError) cosi' l'operatore puo' riprovare.
this.showCompletionOverlay = true;
// Server refused: keep the operator working rather than stranding them
// mid-shift, but productionError is on screen so it is not silent.
this.cycleConfirmed = true;
this.cycleCount++;
if (this.measurementIntervalMinutes && this.measurementIntervalMinutes > 0) {
this.startCountdownFrom(this.measurementIntervalMinutes * 60);
}
return;
}
// The START-phase measurements have just been taken: record them as the first
@@ -1822,12 +1751,6 @@ function taskExecute() {
// ---- Open/close supervisor modal (always reset fields) ----
openSupervisorModal(action) {
// #61: fermo/ripresa/fine produzione agiscono su un run: senza run aperto
// si dice subito, prima di chiedere le credenziali.
if (['fermo_linea', 'ripresa', 'fine_produzione'].includes(action) && !this.productionRun) {
this.productionError = '{{ _("Nessuna produzione aperta su questa stazione") }}';
return;
}
this.supervisorAction = action;
this.supervisorUsername = '';
this.supervisorPassword = '';
@@ -1853,12 +1776,6 @@ function taskExecute() {
return declared === 'measure' || declared === 'camera_measure';
},
/* #46: image-based tasks run a VisionSuite recipe on the server. */
get isVisionTask() {
const t = this.task.task_type;
return t === 'camera_measure' || t === 'dxf_compare';
},
/* A measurement task with no quotes yet: the recipe is unfinished. Said out
loud rather than silently rendering an empty screen. */
get isMeasureTaskWithoutQuotes() {
@@ -104,7 +104,7 @@
<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="M5 3l14 9-14 9V3z"/>
</svg>
{{ _('AVVIA ATTIVITÀ') }}
{{ _('AVVIA IN SEQUENZA') }}
</a>
{% endif %}
{% endif %}
@@ -0,0 +1,105 @@
{% extends "base.html" %}
{% block title %}{{ _("Monitor stazioni") }} — TieMeasureFlow{% endblock %}
{% block content %}
<div class="tmf-page" x-data="stationMonitor()" x-init="init()">
<div class="mb-6 flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-[var(--text-primary)]">{{ _("Monitor stazioni") }}</h1>
<p class="text-sm text-steel mt-1">{{ _("Produzioni aperte, aggiornate ogni 10 secondi") }}</p>
</div>
<div class="text-xs text-steel" x-text="updatedLabel"></div>
</div>
<div x-show="error" class="mb-4 p-3 rounded-lg bg-red-50 text-red-700 text-sm" x-text="error"></div>
<div x-show="loaded && runs.length === 0 && !error"
class="p-8 text-center text-steel bg-[var(--bg-secondary)] rounded-xl border border-[var(--border-color)]">
{{ _("Nessuna stazione in produzione") }}
</div>
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
<template x-for="r in runs" :key="r.id">
<div class="bg-[var(--bg-secondary)] rounded-xl border border-[var(--border-color)] border-l-8 shadow-sm p-4"
:class="borderClass(r)">
<div class="flex items-start justify-between gap-2">
<div>
<div class="text-lg font-bold text-[var(--text-primary)]" x-text="r.station_code + ' — ' + r.station_name"></div>
<div class="text-sm text-steel" x-text="r.recipe_code + ' ' + r.recipe_name + ' (v' + r.version_number + ')'"></div>
</div>
<span class="text-xs font-semibold px-2 py-1 rounded"
:class="r.overdue ? 'bg-red-600 text-white animate-pulse' : 'bg-gray-200 text-gray-700'"
x-text="statusLabel(r)"></span>
</div>
<dl class="mt-3 grid grid-cols-2 gap-x-3 gap-y-1 text-sm">
<dt class="text-steel">{{ _("Operatore") }}</dt><dd x-text="r.operator"></dd>
<dt class="text-steel">{{ _("Avviata alle") }}</dt><dd x-text="startedAt(r)"></dd>
<dt class="text-steel">{{ _("Durata") }}</dt><dd x-text="fmt(r.duration_seconds)"></dd>
<dt class="text-steel">{{ _("Cicli") }}</dt><dd x-text="r.cycle_count"></dd>
<dt class="text-steel">{{ _("Prossima misura") }}</dt>
<dd :class="r.overdue ? 'text-red-600 font-bold' : ''" x-text="timerLabel(r)"></dd>
<dt class="text-steel">{{ _("Ultimo evento") }}</dt>
<dd x-text="r.last_event ? r.last_event.event_type : '-'"></dd>
</dl>
<div class="mt-3 flex gap-2 text-xs font-semibold">
<span class="px-2 py-1 rounded bg-green-100 text-green-800" x-text="'OK ' + r.pass_count"></span>
<span class="px-2 py-1 rounded bg-yellow-100 text-yellow-800" x-text="'{{ _("Avviso") }} ' + r.warning_count"></span>
<span class="px-2 py-1 rounded bg-red-100 text-red-800" x-text="'NOK ' + r.fail_count"></span>
</div>
<ul class="mt-3 space-y-1 text-sm">
<template x-for="s in r.subtasks" :key="s.subtask_id">
<li class="flex justify-between gap-2">
<span class="truncate" x-text="s.description"></span>
<span class="font-mono whitespace-nowrap"
:class="s.in_tolerance ? 'text-green-700' : 'text-red-700 font-bold'"
x-text="s.mean.toFixed(3) + ' ' + s.unit"></span>
</li>
</template>
</ul>
</div>
</template>
</div>
</div>
<script>
function stationMonitor() {
return {
runs: [], loaded: false, error: '', updatedLabel: '',
init() { this.load(); setInterval(() => this.load(), 10000); },
async load() {
try {
const resp = await fetch('/monitor/api/runs');
const data = await resp.json();
if (!resp.ok || data.error) { this.error = data.detail || {{ _("Errore di caricamento")|tojson }}; return; }
this.runs = data; this.error = ''; this.loaded = true;
this.updatedLabel = new Date().toLocaleTimeString();
} catch (e) { this.error = {{ _("Errore di rete")|tojson }}; }
},
// Green in tolerance, yellow with warnings, red with fails or a mean out of tolerance.
borderClass(r) {
if (r.fail_count > 0 || r.subtasks.some(s => !s.in_tolerance)) return 'border-l-red-600';
if (r.warning_count > 0) return 'border-l-yellow-500';
return 'border-l-green-600';
},
statusLabel(r) {
if (r.overdue) return {{ _("IN RITARDO")|tojson }};
return r.status === 'paused' ? {{ _("In pausa")|tojson }} : {{ _("In corso")|tojson }};
},
startedAt(r) { return new Date(r.started_at + 'Z').toLocaleTimeString(); },
timerLabel(r) {
if (r.seconds_to_next_measurement === null) return '-';
const s = r.seconds_to_next_measurement;
return (s < 0 ? '-' : '') + this.fmt(Math.abs(s));
},
fmt(sec) {
const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60;
return (h ? h + 'h ' : '') + m + 'm ' + String(s).padStart(2, '0') + 's';
},
};
}
</script>
{% endblock %}
@@ -1,86 +0,0 @@
"""gitea:Adriano/TieMeasureFlow#46 - operator side of the vision tasks.
The backend of contract B is not merged yet: api_client is mocked.
"""
import io
from unittest.mock import MagicMock, patch
from tests.conftest import api_get_router
TASK = {
"id": 12, "recipe_id": 3, "version_id": 5, "task_type": "camera_measure",
"title": "Foto", "directive": None, "description": None, "file_path": None,
"file_type": None, "annotations_json": None, "subtasks": [],
}
RECIPE = {
"id": 3, "code": "REC-1", "name": "Ricetta",
"measurement_interval_minutes": 30, "allow_manual_input": True,
"current_version": {"id": 5, "version_number": 1},
}
def test_execute_page_has_acquire_input(logged_in_client, mock_api_client, monkeypatch):
"""camera_measure screen offers the file/camera input and the acquire entry point."""
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({
"/api/tasks/12": TASK,
"/api/recipes/3/tasks": [{"id": 12, "order_index": 0}],
"/api/recipes/3": RECIPE,
})
html = logged_in_client.get("/measure/execute/12").get_data(as_text=True)
assert 'capture="environment"' in html
assert 'accept="image/*"' in html
assert "visionExecute()" in html
assert "/measure/api/vision/execute" in html
def test_vision_execute_proxy_forwards_multipart(logged_in_client, mock_api_client):
mock_api_client.post.return_value = {
"vision_result_id": 7, "outcome": "ok", "overlay": [], "failures": [],
"measurements": [{"subtask_id": 1, "value": 9.9, "pass_fail": "pass"}],
}
resp = logged_in_client.post(
"/measure/api/vision/execute",
data={
"task_id": "12", "lot_number": "L1", "serial_number": "S1",
"production_run_id": "4",
"image": (io.BytesIO(b"\xff\xd8\xff fake"), "shot.jpg", "image/jpeg"),
},
content_type="multipart/form-data",
)
assert resp.status_code == 200
assert resp.get_json()["vision_result_id"] == 7
args, kwargs = mock_api_client.post.call_args
assert args[0] == "/api/vision/execute"
assert kwargs["data"]["task_id"] == "12"
assert kwargs["data"]["lot_number"] == "L1"
assert kwargs["data"]["production_run_id"] == "4"
name, content, mime = kwargs["files"]["image"]
assert name == "shot.jpg" and mime == "image/jpeg" and content.startswith(b"\xff\xd8")
def test_vision_execute_proxy_requires_image(logged_in_client, mock_api_client):
resp = logged_in_client.post("/measure/api/vision/execute", data={"task_id": "12"})
assert resp.status_code == 400
mock_api_client.post.assert_not_called()
def test_vision_execute_proxy_relays_backend_error(logged_in_client, mock_api_client):
mock_api_client.post.return_value = {"error": True, "status_code": 422, "detail": "bad"}
resp = logged_in_client.post(
"/measure/api/vision/execute",
data={"task_id": "12", "image": (io.BytesIO(b"x"), "a.jpg", "image/jpeg")},
content_type="multipart/form-data",
)
assert resp.status_code == 422
assert resp.get_json()["detail"] == "bad"
def test_vision_result_image_proxy(logged_in_client):
fake = MagicMock(status_code=200, content=b"IMG", headers={"content-type": "image/jpeg"})
with patch("blueprints.measure.http_requests.get", return_value=fake) as g:
resp = logged_in_client.get("/measure/api/vision/results/7/image")
assert resp.status_code == 200 and resp.data == b"IMG"
assert g.call_args[0][0].endswith("/api/vision/results/7/image")
assert g.call_args[1]["headers"]["X-API-Key"] == "test-api-key-12345"
@@ -0,0 +1,46 @@
"""Live station monitor page and JSON proxy (#57)."""
from unittest.mock import patch
import pytest
@pytest.fixture
def mock_api():
with patch("blueprints.monitor.api_client") as m:
yield m
def _login(client, roles, is_admin=False):
with client.session_transaction() as sess:
sess["api_key"] = "k"
sess["user"] = {"id": 2, "username": "u", "display_name": "U", "roles": roles,
"is_admin": is_admin, "language_pref": "en", "theme_pref": "light",
"active": True}
sess["language"] = "en"
def test_page_ok_for_supervisor(client, mock_api):
_login(client, ["Supervisor"])
resp = client.get("/monitor")
assert resp.status_code == 200 and b"stationMonitor" in resp.data
def test_page_forbidden_for_operator(client, mock_api):
_login(client, ["MeasurementTec"])
assert client.get("/monitor").status_code == 403
assert client.get("/monitor/api/runs").status_code == 403
def test_proxy_returns_backend_json(client, mock_api):
_login(client, ["Supervisor"])
mock_api.get.return_value = [{"id": 1, "station_code": "ST-1"}]
resp = client.get("/monitor/api/runs")
assert resp.status_code == 200 and resp.get_json()[0]["station_code"] == "ST-1"
mock_api.get.assert_called_once_with("/api/production-runs/open")
def test_proxy_passes_backend_error(client, mock_api):
_login(client, [], is_admin=True)
mock_api.get.return_value = {"error": True, "status_code": 403, "detail": "no"}
resp = client.get("/monitor/api/runs")
assert resp.status_code == 403 and resp.get_json()["error"] is True
@@ -104,7 +104,7 @@ def test_the_selection_screen_starts_the_sequence(
def test_avvia_on_the_list_starts_the_sequence(logged_in_client, mock_api_client):
html = _list_page(logged_in_client, mock_api_client)
assert "/measure/start/3" in html
assert "AVVIA ATTIVITÀ" in html
assert "AVVIA IN SEQUENZA" in html
# ---------------------------------------------------------------------------
@@ -163,32 +163,6 @@ def test_the_header_offers_completato_not_the_summary(
assert '@click="goToNextTask()"' in html
def test_only_one_completato_and_it_is_big(
logged_in_client, mock_api_client, monkeypatch,
):
"""#60: «Completato» una sola volta (in basso), largo e alto per il touch."""
html = _execute_page(logged_in_client, mock_api_client, monkeypatch)
# the label as a button text on its own line (comments in the JS also name it)
assert [l.strip() for l in html.splitlines()].count("Completato") == 1
assert "min-h-[64px]" in html
def test_no_open_run_means_no_fermo_linea_and_no_fake_timer(
logged_in_client, mock_api_client, monkeypatch,
):
"""#61: senza produzione aperta i pulsanti non si vedono, la modale non si
apre e un rifiuto del server non avvia un timer locale finto."""
html = _execute_page(logged_in_client, mock_api_client, monkeypatch)
assert 'x-if="isMeasureTask && productionRun"' in html
assert "!this.productionRun" in html
# #61: loadProductionRun mostra il detail del server, non solo il messaggio generico
assert "errBody.detail" in html
assert "Nessuna produzione aperta su questa stazione" in html
# il vecchio ripiego locale non c'e' piu'
assert "this.startCountdownFrom(this.measurementIntervalMinutes * 60)" not in html.split(
"async startProductionFromOverlay()")[1].split("async confirmCycle")[0]
def test_fine_ciclo_is_shown_before_it_can_be_used(
logged_in_client, mock_api_client, monkeypatch,
):
@@ -222,27 +196,3 @@ def test_the_operator_flow_never_links_to_the_statistics(
]
for html in pages:
assert "/statistics" not in html
def test_trace_panel_only_for_recipes_that_require_it(
logged_in_client, mock_api_client, monkeypatch,
):
"""#63/#64: ricerca+griglia in alto, pannello lotto/seriale solo dopo la
scelta di una ricetta che lo richiede, con i soli campi richiesti."""
_italian(logged_in_client)
monkeypatch.setattr("blueprints.measure.Config.STATION_CODE", "ST-TEST")
mock_api_client.get_station_recipes.return_value = [
{"id": 1, "code": "A", "name": "Con lotto", "requires_lot": True,
"requires_serial": False},
{"id": 2, "code": "B", "name": "Libera"},
]
html = logged_in_client.get("/measure/select").get_data(as_text=True)
assert 'id="trace-panel"' in html
assert 'x-show="selected && needsTrace(selected)"' in html
assert 'x-show="selected && selected.requires_lot"' in html
assert 'x-show="selected && selected.requires_serial"' in html
# la ricerca e la griglia precedono il pannello
assert html.index("Cerca ricetta") < html.index('id="trace-panel"')
assert html.index("Recipe Grid") < html.index('id="trace-panel"')
# le ricette senza obblighi partono direttamente
assert 'x-if="!needsTrace(recipe)"' in html
@@ -1862,8 +1862,8 @@ msgstr "No description available"
#: templates/measure/select_recipe.html:294
#: templates/measure/task_list.html:107
msgid "AVVIA ATTIVITÀ"
msgstr "START ACTIVITY"
msgid "AVVIA IN SEQUENZA"
msgstr "START THE SEQUENCE"
# Maker - Task Editor
#: templates/measure/select_recipe.html:302
@@ -1864,8 +1864,8 @@ msgstr "Nessuna descrizione disponibile"
#: templates/measure/select_recipe.html:294
#: templates/measure/task_list.html:107
msgid "AVVIA ATTIVITÀ"
msgstr "AVVIA ATTIVITÀ"
msgid "AVVIA IN SEQUENZA"
msgstr "AVVIA IN SEQUENZA"
# Maker - Task Editor
#: templates/measure/select_recipe.html:302
@@ -1825,7 +1825,7 @@ msgstr ""
#: templates/measure/select_recipe.html:294
#: templates/measure/task_list.html:107
msgid "AVVIA ATTIVITÀ"
msgid "AVVIA IN SEQUENZA"
msgstr ""
#: templates/measure/select_recipe.html:302