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 493 additions and 624 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,
-13
View File
@@ -9,7 +9,6 @@ from src.backend.api.middleware.api_key import get_current_user, require_maker,
from src.backend.models.orm.user import User
from src.backend.models.api.recipe import (
RecipeCreate,
RecipeDuplicate,
RecipeListResponse,
RecipeResponse,
RecipeUpdate,
@@ -53,18 +52,6 @@ async def create_recipe(
return _recipe_to_response(recipe)
@router.post("/{recipe_id}/duplicate", response_model=RecipeResponse, status_code=201)
async def duplicate_recipe(
recipe_id: int,
data: RecipeDuplicate,
user: User = Depends(require_maker),
db: AsyncSession = Depends(get_db),
):
"""Duplicate a recipe with its current tasks under a new code (#52). Requires Maker role."""
recipe = await recipe_service.duplicate_recipe(db, recipe_id, data.code, data.name, user)
return _recipe_to_response(recipe)
@router.get("", response_model=RecipeListResponse)
async def list_recipes(
page: int = Query(1, ge=1),
+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
-6
View File
@@ -26,12 +26,6 @@ class RecipeCreate(BaseModel):
annotations_json: Optional[dict] = None
class RecipeDuplicate(BaseModel):
"""Schema for duplicating a recipe (#52): only the new code and name."""
code: str = Field(..., min_length=1, max_length=100)
name: str = Field(..., min_length=1, max_length=255)
class RecipeUpdate(BaseModel):
"""Schema for updating a recipe (creates new version)."""
name: Optional[str] = Field(None, min_length=1, max_length=255)
+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
-72
View File
@@ -253,78 +253,6 @@ async def create_recipe(
return result.scalar_one()
async def duplicate_recipe(
db: AsyncSession,
recipe_id: int,
new_code: str,
new_name: str,
user: User,
) -> Recipe:
"""Duplicate a recipe (#52): new code/name, v1 with the current version's tasks.
The source recipe is left untouched.
"""
source = await _get_recipe_or_404(db, recipe_id)
existing = await db.execute(select(Recipe).where(Recipe.code == new_code))
if existing.scalar_one_or_none() is not None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Recipe code '{new_code}' already exists",
)
# Same header fields create_recipe persists, taken from the source recipe
recipe = Recipe(
code=new_code,
name=new_name,
description=source.description,
image_path=source.image_path,
measurement_interval_minutes=source.measurement_interval_minutes,
requires_lot=source.requires_lot,
requires_serial=source.requires_serial,
allow_manual_input=source.allow_manual_input,
created_by=user.id,
)
db.add(recipe)
await db.flush()
version = RecipeVersion(
recipe_id=recipe.id,
version_number=1,
is_current=True,
created_by=user.id,
change_notes=f"Duplicated from {source.code}",
)
db.add(version)
await db.flush()
# Reuse the existing deep copy of tasks/subtasks from the current version
source_version = await _get_current_version(db, source.id)
if source_version is not None:
await _copy_tasks_to_version(db, source_version, version)
await _write_audit(
db,
recipe_id=recipe.id,
old_version_id=None,
new_version_id=version.id,
changed_by=user.id,
change_type="CREATE",
change_reason=f"Duplicated from recipe {source.code}",
)
result = await db.execute(
select(Recipe)
.where(Recipe.id == recipe.id)
.options(
selectinload(Recipe.versions)
.selectinload(RecipeVersion.tasks)
.selectinload(RecipeTask.subtasks)
)
)
return result.scalar_one()
async def create_new_version(
db: AsyncSession,
recipe_id: int,
+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() == []
@@ -1,74 +0,0 @@
"""Tests for POST /api/recipes/{id}/duplicate (#52)."""
from httpx import AsyncClient
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.models.orm.recipe import Recipe, RecipeVersion
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
from src.backend.models.orm.user import User
from src.backend.tests.conftest import auth_headers, create_test_recipe
class TestDuplicateRecipe:
async def test_duplicate_copies_tasks_and_keeps_original(
self, client: AsyncClient, maker_user: User, db_session: AsyncSession
):
src = await create_test_recipe(db_session, maker_user.id)
src_id = src.id
before_tasks = (await db_session.execute(select(func.count(RecipeTask.id)))).scalar_one()
before_subs = (await db_session.execute(select(func.count(RecipeSubtask.id)))).scalar_one()
resp = await client.post(
f"/api/recipes/{src_id}/duplicate",
json={"code": "REC-COPIA", "name": "Copia"},
headers=auth_headers(maker_user),
)
assert resp.status_code == 201
data = resp.json()
assert data["code"] == "REC-COPIA" and data["name"] == "Copia"
assert data["id"] != src_id
assert data["description"] == "A recipe for testing"
assert data["current_version"]["version_number"] == 1
# Tasks and subtasks doubled: copied, not moved
after_tasks = (await db_session.execute(select(func.count(RecipeTask.id)))).scalar_one()
after_subs = (await db_session.execute(select(func.count(RecipeSubtask.id)))).scalar_one()
assert after_tasks == before_tasks * 2
assert after_subs == before_subs * 2
new_version = (await db_session.execute(
select(RecipeVersion).where(RecipeVersion.recipe_id == data["id"])
)).scalar_one()
new_tasks = (await db_session.execute(
select(RecipeTask).where(RecipeTask.version_id == new_version.id)
)).scalars().all()
assert len(new_tasks) == before_tasks
# Original unchanged
orig = (await db_session.execute(select(Recipe).where(Recipe.id == src_id))).scalar_one()
assert orig.code == "REC-001" and orig.name == "Test Recipe"
orig_versions = (await db_session.execute(
select(func.count(RecipeVersion.id)).where(RecipeVersion.recipe_id == src_id)
)).scalar_one()
assert orig_versions == 1
async def test_duplicate_code_conflict(
self, client: AsyncClient, maker_user: User, db_session: AsyncSession
):
src = await create_test_recipe(db_session, maker_user.id)
resp = await client.post(
f"/api/recipes/{src.id}/duplicate",
json={"code": "REC-001", "name": "X"},
headers=auth_headers(maker_user),
)
assert resp.status_code == 409
async def test_duplicate_missing_recipe(
self, client: AsyncClient, maker_user: User
):
resp = await client.post(
"/api/recipes/99999/duplicate",
json={"code": "NEW", "name": "X"},
headers=auth_headers(maker_user),
)
assert resp.status_code == 404
+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 -70
View File
@@ -57,20 +57,7 @@ def recipe_list():
@role_required("Maker")
def recipe_new():
"""Create new recipe."""
# #49: loop duration pre-filled from the system default (new recipes only);
# missing/empty/invalid key -> None -> behaviour unchanged
default_interval = None
settings = api_client.get("/api/settings")
if isinstance(settings, dict) and not settings.get("error"):
try:
value = int(str(settings.get("default_measurement_interval_minutes", "")).strip())
if 1 <= value <= 1440:
default_interval = value
except ValueError:
pass
return render_template(
"maker/recipe_editor.html", recipe=None, default_interval=default_interval
)
return render_template("maker/recipe_editor.html", recipe=None)
@maker_bp.route("/recipes/<int:recipe_id>/edit")
@@ -214,21 +201,6 @@ def api_create_recipe():
return jsonify(resp), 201
@maker_bp.route("/api/recipes/<int:recipe_id>/duplicate", methods=["POST"])
@login_required
@role_required("Maker")
def api_duplicate_recipe(recipe_id: int):
"""Proxy: Duplicate a recipe under a new code/name (#52)."""
data = request.get_json(silent=True) or {}
resp = api_client.post(f"/api/recipes/{recipe_id}/duplicate", data=data)
if resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 201
@maker_bp.route("/api/recipes/<int:recipe_id>", methods=["PUT"])
@login_required
@role_required("Maker")
@@ -405,47 +377,6 @@ def api_upload_file():
return jsonify(resp), 201
# ============================================================================
# VISION RECIPE PROXY (#46)
# ============================================================================
@maker_bp.route("/api/vision/tasks/<int:task_id>/recipe", methods=["GET"])
@login_required
@role_required("Maker")
def api_get_vision_recipe(task_id: int):
"""Proxy: ricetta visione corrente del task (cartella, file, uscite)."""
resp = api_client.get(f"/api/vision/tasks/{task_id}/recipe")
if resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 200
@maker_bp.route("/api/vision/tasks/<int:task_id>/recipe", methods=["POST"])
@login_required
@role_required("Maker")
def api_upload_vision_recipe(task_id: int):
"""Proxy: carica ricetta .json + file allegati (multipart forward)."""
recipe = request.files.get("recipe")
if recipe is None or recipe.filename == "":
return jsonify({"error": True, "detail": _("Nessun file ricetta caricato")}), 400
# Ricetta + 0..n allegati (DXF, modelli .npz, calibrazione), stesso nome campo `files`
files = [("recipe", (recipe.filename, recipe.stream, recipe.content_type))]
for f in request.files.getlist("files"):
if f.filename:
files.append(("files", (f.filename, f.stream, f.content_type)))
resp = api_client.post(f"/api/vision/tasks/{task_id}/recipe", data={}, files=files)
# Il 422 (errori di validazione) arriva al browser com'è, con `errors`
if resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 200
@maker_bp.route("/api/files/<path:file_path>", methods=["GET"])
@login_required
def api_get_file(file_path: str):
@@ -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,80 +0,0 @@
// Ricetta visione del Maker (#46): componente Alpine per i task camera_measure / dxf_compare.
// Carica la ricetta VisionSuite v2 + i file allegati verso /maker/api/vision/tasks/<id>/recipe,
// mostra cartella, file e uscite numeriche; notifica le uscite al task editor con un evento.
// Uso: x-data="visionRecipe(task.id, csrfToken, msgs)". Dipende da Alpine.js.
function visionRecipe(taskId, csrf, msgs) {
return {
taskId: taskId,
recipeDir: null,
files: [],
outputs: [],
errors: [], // errori 422 elencati in chiaro
loading: false,
busy: false,
recipeFile: null,
attachments: [],
msgs: msgs || {},
async init() {
// All'apertura: ricetta corrente (GET); il 404/errore non blocca l'editor
this.loading = true;
try {
const resp = await fetch(`/maker/api/vision/tasks/${this.taskId}/recipe`);
if (resp.ok) this._apply(await resp.json());
} catch (e) { console.error('visionRecipe init', e); }
this.loading = false;
},
// Aggiorna lo stato e avvisa il task editor (select vision_output delle quote)
_apply(data) {
this.recipeDir = data.recipe_dir || null;
this.files = data.files || [];
this.outputs = data.outputs || [];
window.dispatchEvent(new CustomEvent('vision-outputs', {
detail: { taskId: this.taskId, outputs: this.outputs }
}));
},
// Il 422 porta `errors` (lista) oppure `detail` (stringa o lista di voci)
_errorsFrom(data) {
if (Array.isArray(data.errors) && data.errors.length) return data.errors.map(String);
const d = data.detail;
if (Array.isArray(d)) return d.map(x => (typeof x === 'string' ? x : (x.msg || JSON.stringify(x))));
if (d && typeof d === 'object') {
if (Array.isArray(d.errors)) return d.errors.map(String);
return [JSON.stringify(d)];
}
return [d || this.msgs.genericError || 'Error'];
},
async upload() {
if (!this.recipeFile) return;
this.busy = true;
this.errors = [];
try {
const fd = new FormData();
fd.append('recipe', this.recipeFile);
for (const f of this.attachments) fd.append('files', f);
const resp = await fetch(`/maker/api/vision/tasks/${this.taskId}/recipe`, {
method: 'POST',
headers: { 'X-CSRFToken': typeof csrf === 'function' ? csrf() : csrf },
body: fd
});
const data = await resp.json();
if (!resp.ok || data.error) {
this.errors = this._errorsFrom(data);
} else {
this._apply(data);
this.recipeFile = null;
this.attachments = [];
}
} catch (e) {
this.errors = [this.msgs.genericError || String(e)];
}
this.busy = false;
},
onRecipe(ev) { this.recipeFile = ev.target.files[0] || null; },
onAttachments(ev) { this.attachments = Array.from(ev.target.files || []); }
};
}
@@ -526,8 +526,7 @@ function recipeEditor() {
currentVersion: {{ (recipe.current_version.version_number if recipe and recipe.current_version else 0)|tojson }},
// ---- Measurement interval ----
// #49: a new recipe starts from the system default duration (default_interval), an existing one never does
measurementIntervalMinutes: {{ (recipe.measurement_interval_minutes if recipe and recipe.measurement_interval_minutes else (default_interval if not recipe and default_interval else 'null'))|tojson }},
measurementIntervalMinutes: {{ (recipe.measurement_interval_minutes if recipe and recipe.measurement_interval_minutes else 'null')|tojson }},
// ---- Rules of measurement (points 8 and 9) ----
// A new recipe forbids typing: the caliper is the instrument, and permission
@@ -10,51 +10,6 @@
filter: 'all',
deleteModal: false,
deleteTarget: null,
// #52: duplicate modal state
dupModal: false,
dupSource: null,
dupCode: '',
dupName: '',
dupError: '',
dupBusy: false,
openDupModal(recipe) {
this.dupSource = recipe;
this.dupCode = recipe.code + '-COPIA';
this.dupName = recipe.name + ' ' + {{ _('(copia)')|tojson_attr }};
this.dupError = '';
this.dupModal = true;
},
async duplicateRecipe() {
const csrfToken = document.querySelector('meta[name=csrf-token]')?.content;
this.dupBusy = true;
this.dupError = '';
try {
const resp = await fetch(`/maker/api/recipes/${this.dupSource.id}/duplicate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken || '' },
body: JSON.stringify({ code: this.dupCode.trim(), name: this.dupName.trim() })
});
const data = await resp.json().catch(() => ({}));
if (resp.ok) {
// open the new recipe in the editor
window.location.href = '/maker/recipes/' + data.id + '/edit';
return;
}
if (resp.status === 409) {
this.dupError = {{ _('Esiste già una ricetta con questo codice')|tojson_attr }};
} else if (resp.status === 422) {
this.dupError = {{ _('Codice o nome non validi')|tojson_attr }};
} else {
this.dupError = data.detail || data.error || {{ _('Errore durante la duplicazione')|tojson_attr }};
}
} catch (err) {
this.dupError = {{ _('Errore di connessione')|tojson_attr }};
} finally {
this.dupBusy = false;
}
},
get filteredRecipes() {
let filtered = this.recipes;
@@ -310,15 +265,6 @@
{{ _('Versioni') }}
</a>
<!-- Duplica (#52) -->
<button @click="openDupModal(recipe)"
class="btn btn-secondary gap-1.5 text-xs">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
{{ _('Duplica') }}
</button>
<!-- Spacer -->
<div class="flex-1"></div>
@@ -372,42 +318,6 @@
</a>
</div>
<!-- Duplicate Modal (#52) -->
<div x-show="dupModal"
x-cloak
@keydown.escape.window="dupModal = false"
class="fixed inset-0 z-50 overflow-y-auto"
role="dialog"
aria-modal="true">
<div @click="dupModal = false" class="fixed inset-0 bg-black/50 backdrop-blur-sm"></div>
<div class="flex min-h-full items-center justify-center p-4">
<div class="tmf-card max-w-lg w-full relative p-6">
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-4">
{{ _('Duplica ricetta') }}
</h3>
<div class="mb-3">
<label class="tmf-label" for="dup-code">{{ _('Codice') }}</label>
<input id="dup-code" type="text" x-model="dupCode" maxlength="100" class="tmf-input">
</div>
<div class="mb-3">
<label class="tmf-label" for="dup-name">{{ _('Nome') }}</label>
<input id="dup-name" type="text" x-model="dupName" maxlength="255" class="tmf-input">
</div>
<p x-show="dupError" x-text="dupError" class="text-sm text-red-600 mb-3"></p>
<div class="flex flex-col sm:flex-row gap-3">
<button @click="dupModal = false" class="btn btn-secondary flex-1 justify-center">
{{ _('Annulla') }}
</button>
<button @click="duplicateRecipe()"
:disabled="dupBusy || !dupCode.trim() || !dupName.trim()"
class="btn btn-primary flex-1 justify-center">
{{ _('Duplica') }}
</button>
</div>
</div>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div x-show="deleteModal"
x-cloak
@@ -277,8 +277,6 @@
<option value="note">{{ _('Nota') }}</option>
<option value="measure">{{ _('Misura') }}</option>
<option value="drawing">{{ _('Disegno') }}</option>
<option value="camera_measure">{{ _('Misura camera') }}</option>
<option value="dxf_compare">{{ _('Confronto profilo') }}</option>
</select>
<p class="mt-1 text-xs text-[var(--text-secondary)]">
{{ _('Un task di misura resta tale anche prima di inserire le quote') }}
@@ -462,55 +460,6 @@
<div class="tmf-card-body space-y-4">
<!-- ========== Ricetta visione (#46): solo camera_measure / dxf_compare ========== -->
<template x-if="task.task_type === 'camera_measure' || task.task_type === 'dxf_compare'">
<div class="p-3 rounded-lg bg-[var(--bg-secondary)] border border-[var(--border-color)]"
x-data="visionRecipe(task.id, () => csrfToken(), {{ {'genericError': _('Errore durante il caricamento della ricetta')}|tojson_attr }})">
<p class="text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wide mb-2">
{{ _('Ricetta visione') }}
</p>
<!-- Stato corrente: cartella, file, uscite -->
<template x-if="recipeDir">
<div class="text-xs space-y-1 mb-3">
<p><span class="text-[var(--text-secondary)]">{{ _('Cartella') }}:</span>
<span class="font-mono" x-text="recipeDir"></span></p>
<p x-show="files.length"><span class="text-[var(--text-secondary)]">{{ _('File') }}:</span>
<span class="font-mono" x-text="files.join(', ')"></span></p>
<p x-show="outputs.length"><span class="text-[var(--text-secondary)]">{{ _('Uscite numeriche') }}:</span></p>
<ul class="list-disc pl-5" x-show="outputs.length">
<template x-for="o in outputs" :key="o.key">
<li><span class="font-mono" x-text="o.key"></span>
<span class="text-[var(--text-muted)]" x-text="(o.quantity || o.unit) ? '(' + [o.quantity, o.unit].filter(Boolean).join(', ') + ')' : ''"></span></li>
</template>
</ul>
</div>
</template>
<p x-show="!recipeDir && !loading" class="text-xs text-[var(--text-muted)] mb-3">
{{ _('Nessuna ricetta caricata') }}
</p>
<!-- Caricamento: ricetta .json + allegati -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label class="tmf-label text-xs">{{ _('Ricetta (.json)') }}</label>
<input type="file" accept=".json,application/json" class="tmf-input text-xs"
@change="onRecipe($event)">
</div>
<div>
<label class="tmf-label text-xs">{{ _('File allegati (DXF, modelli, calibrazione)') }}</label>
<input type="file" multiple class="tmf-input text-xs" @change="onAttachments($event)">
</div>
</div>
<div class="flex justify-end mt-3">
<button type="button" class="btn btn-primary text-xs" @click="upload()"
:disabled="!recipeFile || busy">{{ _('Carica ricetta') }}</button>
</div>
<!-- Errori 422 in chiaro -->
<ul x-show="errors.length" class="mt-3 text-xs text-red-500 list-disc pl-5">
<template x-for="(e, i) in errors" :key="i"><li x-text="e"></li></template>
</ul>
</div>
</template>
<!-- ========== Task File Upload / Preview ========== -->
<div class="p-3 rounded-lg bg-[var(--bg-secondary)] border border-[var(--border-color)]">
<p class="text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wide mb-2">
@@ -594,8 +543,6 @@
<option value="note">{{ _('Nota') }}</option>
<option value="measure">{{ _('Misura') }}</option>
<option value="drawing">{{ _('Disegno') }}</option>
<option value="camera_measure">{{ _('Misura camera') }}</option>
<option value="dxf_compare">{{ _('Confronto profilo') }}</option>
</select>
</div>
<div>
@@ -752,18 +699,6 @@
class="tmf-input"
placeholder="{{ _('Es. Diametro foro principale') }}">
</div>
<!-- Uscita visione (#46): solo task camera_measure / dxf_compare -->
<div class="col-span-2 sm:col-span-3 lg:col-span-2"
x-show="task.task_type === 'camera_measure' || task.task_type === 'dxf_compare'">
<label class="tmf-label text-xs">{{ _('Uscita visione') }}</label>
<select x-model="newSubtask.vision_output" class="tmf-input">
<option value="">{{ _('Nessuna') }}</option>
<template x-for="o in (visionOutputs[task.id] || [])" :key="o.key">
<option :value="o.key" :selected="o.key === newSubtask.vision_output"
x-text="o.key + ((o.quantity || o.unit) ? ' (' + [o.quantity, o.unit].filter(Boolean).join(', ') + ')' : '')"></option>
</template>
</select>
</div>
<!-- Measurement Type -->
<div>
<label class="tmf-label text-xs">{{ _('Tipo') }}</label>
@@ -1158,7 +1093,6 @@
{% block extra_js %}
{# Bold and line breaks in descriptions: the same rules the server renders with. #}
<script src="{{ url_for('static', filename='js/rich-text.js') }}"></script>
<script src="{{ url_for('static', filename='js/vision_recipe.js') }}"></script>
<script src="{{ url_for('static', filename='vendor/pdf-3.11.174.min.js') }}"></script>
<script>if(typeof pdfjsLib!=='undefined')pdfjsLib.GlobalWorkerOptions.workerSrc='{{ url_for('static', filename='vendor/pdf.worker-3.11.174.min.js') }}';</script>
<script src="{{ url_for('static', filename='js/annotation-viewer.js') }}?v=6"></script>
@@ -1195,11 +1129,8 @@ function taskEditor() {
addingSubtaskForTask: null,
newSubtask: {
marker_number: 1, description: '', measurement_type: '',
nominal: null, utl: null, uwl: null, lwl: null, ltl: null, unit: 'mm',
vision_output: ''
nominal: null, utl: null, uwl: null, lwl: null, ltl: null, unit: 'mm'
},
// Uscite numeriche della ricetta visione per task (#46), riempite da vision_recipe.js
visionOutputs: {},
// ---- Delete modals ----
deleteTaskModal: false,
@@ -1222,10 +1153,6 @@ function taskEditor() {
// Init
// ============================================================
init() {
// Uscite della ricetta visione: evento dal componente visionRecipe (#46)
window.addEventListener('vision-outputs', (ev) => {
this.visionOutputs = { ...this.visionOutputs, [ev.detail.taskId]: ev.detail.outputs || [] };
});
// Sort tasks by order_index on load
this.tasks.sort((a, b) => (a.order_index || 0) - (b.order_index || 0));
@@ -1683,8 +1610,7 @@ function taskEditor() {
uwl: null,
lwl: null,
ltl: null,
unit: 'mm',
vision_output: ''
unit: 'mm'
};
},
@@ -1708,8 +1634,7 @@ function taskEditor() {
uwl: this.newSubtask.uwl,
lwl: this.newSubtask.lwl,
ltl: this.newSubtask.ltl,
unit: this.newSubtask.unit || 'mm',
vision_output: this.newSubtask.vision_output || null // chiave uscita ricetta visione (#46)
unit: this.newSubtask.unit || 'mm'
};
try {
@@ -1762,8 +1687,7 @@ function taskEditor() {
uwl: subtask.uwl,
lwl: subtask.lwl,
ltl: subtask.ltl,
unit: subtask.unit || 'mm',
vision_output: subtask.vision_output || ''
unit: subtask.unit || 'mm'
};
},
@@ -1787,8 +1711,7 @@ function taskEditor() {
uwl: this.newSubtask.uwl,
lwl: this.newSubtask.lwl,
ltl: this.newSubtask.ltl,
unit: this.newSubtask.unit || 'mm',
vision_output: this.newSubtask.vision_output || null // chiave uscita ricetta visione (#46)
unit: this.newSubtask.unit || 'mm'
};
try {
@@ -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 %}
-134
View File
@@ -71,61 +71,6 @@ class TestRecipeNew:
assert resp.status_code == 200
def test_recipe_new_default_interval_present(self, logged_in_client, mock_api_client):
"""#49: default_measurement_interval_minutes pre-fills the loop duration."""
mock_api_client.get.return_value = {"default_measurement_interval_minutes": "45"}
html = logged_in_client.get("/maker/recipes/new").get_data(as_text=True)
assert "measurementIntervalMinutes: 45," in html
def test_recipe_new_default_interval_absent(self, logged_in_client, mock_api_client):
"""#49: key missing, empty or invalid -> unchanged behaviour (null)."""
for payload in ({}, {"default_measurement_interval_minutes": ""},
{"default_measurement_interval_minutes": "abc"},
{"default_measurement_interval_minutes": "5000"}):
mock_api_client.get.return_value = payload
html = logged_in_client.get("/maker/recipes/new").get_data(as_text=True)
assert 'measurementIntervalMinutes: "null",' in html
def test_recipe_edit_ignores_default_interval(self, logged_in_client, mock_api_client):
"""#49: an existing recipe never receives the default."""
def fake_get(path, *a, **k):
if path == "/api/recipes/7":
return {"id": 7, "code": "R", "name": "R"}
if path.endswith("/versions"):
return []
return {"default_measurement_interval_minutes": "45"}
mock_api_client.get.side_effect = fake_get
html = logged_in_client.get("/maker/recipes/7/edit").get_data(as_text=True)
assert 'measurementIntervalMinutes: "null",' in html
class TestRecipeDuplicateProxy:
"""POST /maker/api/recipes/<id>/duplicate (#52)."""
def test_duplicate_proxy_ok(self, logged_in_client, mock_api_client):
mock_api_client.post.return_value = {"id": 9, "code": "A-COPIA"}
resp = logged_in_client.post(
"/maker/api/recipes/3/duplicate", json={"code": "A-COPIA", "name": "A (copia)"}
)
assert resp.status_code == 201
assert resp.get_json()["id"] == 9
mock_api_client.post.assert_called_with(
"/api/recipes/3/duplicate", data={"code": "A-COPIA", "name": "A (copia)"}
)
def test_duplicate_proxy_conflict(self, logged_in_client, mock_api_client):
mock_api_client.post.return_value = {"error": True, "status_code": 409, "detail": "exists"}
resp = logged_in_client.post(
"/maker/api/recipes/3/duplicate", json={"code": "A", "name": "A"}
)
assert resp.status_code == 409
def test_recipe_list_has_duplicate_button(self, logged_in_client, mock_api_client):
mock_api_client.get.return_value = {"items": [], "total": 0, "pages": 1}
html = logged_in_client.get("/maker/recipes").get_data(as_text=True)
assert "openDupModal" in html
class TestRoleRequired:
"""Maker role enforcement tests."""
@@ -216,82 +161,3 @@ class TestVersionConflictProxy:
assert resp.status_code == 409
assert resp.get_json()["detail"] == self._CONFLICT["detail"]
class TestVisionRecipeProxy:
"""Proxy ricetta visione (#46): /maker/api/vision/tasks/<id>/recipe."""
def test_upload_forwards_recipe_and_files(self, logged_in_client, mock_api_client):
import io
mock_api_client.post.return_value = {
"task_id": 7, "recipe_dir": "vision/recipes/7/abc", "files": ["a.dxf"],
"outputs": [{"key": "n1.points_in_pct", "quantity": None, "unit": None}],
}
resp = logged_in_client.post(
"/maker/api/vision/tasks/7/recipe",
data={
"recipe": (io.BytesIO(b"{}"), "r.json"),
"files": [(io.BytesIO(b"dxf"), "a.dxf"), (io.BytesIO(b"npz"), "m.npz")],
},
content_type="multipart/form-data",
)
assert resp.status_code == 200
assert resp.get_json()["outputs"][0]["key"] == "n1.points_in_pct"
endpoint = mock_api_client.post.call_args.args[0]
sent = mock_api_client.post.call_args.kwargs["files"]
assert endpoint == "/api/vision/tasks/7/recipe"
# un campo `recipe` e un campo `files` per ogni allegato
assert [name for name, _f in sent] == ["recipe", "files", "files"]
def test_upload_without_recipe_is_400(self, logged_in_client, mock_api_client):
resp = logged_in_client.post(
"/maker/api/vision/tasks/7/recipe", data={}, content_type="multipart/form-data"
)
assert resp.status_code == 400
mock_api_client.post.assert_not_called()
def test_upload_proxies_422(self, logged_in_client, mock_api_client):
import io
mock_api_client.post.return_value = {
"error": True, "status_code": 422, "detail": "file mancante: a.dxf",
}
resp = logged_in_client.post(
"/maker/api/vision/tasks/7/recipe",
data={"recipe": (io.BytesIO(b"{}"), "r.json")},
content_type="multipart/form-data",
)
assert resp.status_code == 422
assert "a.dxf" in resp.get_json()["detail"]
def test_get_recipe_proxies(self, logged_in_client, mock_api_client):
mock_api_client.get.return_value = {
"task_id": 7, "recipe_dir": None, "recipe": None, "files": [], "outputs": [],
}
resp = logged_in_client.get("/maker/api/vision/tasks/7/recipe")
assert resp.status_code == 200
mock_api_client.get.assert_called_once_with("/api/vision/tasks/7/recipe")
class TestTaskEditorVisionUi:
"""task_editor.html espone i tipi visione e la select vision_output (#46)."""
def test_editor_has_vision_types_and_section(self, logged_in_client, mock_api_client):
from tests.conftest import api_get_router
mock_api_client.get.side_effect = api_get_router({
"/api/recipes/3": {"id": 3, "code": "R", "name": "R"},
"/api/recipes/3/tasks": [{
"id": 11, "title": "T", "task_type": "dxf_compare", "subtasks": [],
"file_path": None, "order_index": 1,
}],
})
html = logged_in_client.get("/maker/recipes/3/tasks").get_data(as_text=True)
assert 'value="camera_measure"' in html
assert 'value="dxf_compare"' in html
assert "visionRecipe(" in html
assert "vision_output" in html
assert "js/vision_recipe.js" in html
@@ -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