Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6648882ec1 | |||
| 3c1d5f1323 | |||
| e640c4a36e |
@@ -20,8 +20,7 @@ from src.backend.models.api.production import (
|
|||||||
)
|
)
|
||||||
from src.backend.models.orm.production import ProductionRun
|
from src.backend.models.orm.production import ProductionRun
|
||||||
from src.backend.models.orm.user import User
|
from src.backend.models.orm.user import User
|
||||||
from src.backend.models.api.monitor import MonitorRun
|
from src.backend.services import auth_service, production_service
|
||||||
from src.backend.services import auth_service, monitor_service, production_service
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/production-runs", tags=["production"])
|
router = APIRouter(prefix="/api/production-runs", tags=["production"])
|
||||||
|
|
||||||
@@ -82,27 +81,6 @@ async def get_current_production_run(
|
|||||||
return await _as_response(db, run) if run is not None else None
|
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)
|
@router.get("/{run_id}", response_model=ProductionRunWithEventsResponse)
|
||||||
async def get_production_run(
|
async def get_production_run(
|
||||||
run_id: int,
|
run_id: int,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ 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.orm.user import User
|
||||||
from src.backend.models.api.recipe import (
|
from src.backend.models.api.recipe import (
|
||||||
RecipeCreate,
|
RecipeCreate,
|
||||||
|
RecipeDuplicate,
|
||||||
RecipeListResponse,
|
RecipeListResponse,
|
||||||
RecipeResponse,
|
RecipeResponse,
|
||||||
RecipeUpdate,
|
RecipeUpdate,
|
||||||
@@ -52,6 +53,18 @@ async def create_recipe(
|
|||||||
return _recipe_to_response(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)
|
@router.get("", response_model=RecipeListResponse)
|
||||||
async def list_recipes(
|
async def list_recipes(
|
||||||
page: int = Query(1, ge=1),
|
page: int = Query(1, ge=1),
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
"""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
|
|
||||||
@@ -26,6 +26,12 @@ class RecipeCreate(BaseModel):
|
|||||||
annotations_json: Optional[dict] = None
|
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):
|
class RecipeUpdate(BaseModel):
|
||||||
"""Schema for updating a recipe (creates new version)."""
|
"""Schema for updating a recipe (creates new version)."""
|
||||||
name: Optional[str] = Field(None, min_length=1, max_length=255)
|
name: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||||
|
|||||||
@@ -1,133 +0,0 @@
|
|||||||
"""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
|
|
||||||
@@ -253,6 +253,78 @@ async def create_recipe(
|
|||||||
return result.scalar_one()
|
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(
|
async def create_new_version(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
recipe_id: int,
|
recipe_id: int,
|
||||||
|
|||||||
@@ -1,83 +0,0 @@
|
|||||||
"""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() == []
|
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"""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
|
||||||
@@ -74,14 +74,12 @@ def create_app() -> Flask:
|
|||||||
from blueprints.maker import maker_bp
|
from blueprints.maker import maker_bp
|
||||||
from blueprints.statistics import statistics_bp
|
from blueprints.statistics import statistics_bp
|
||||||
from blueprints.admin import admin_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(auth_bp)
|
||||||
app.register_blueprint(measure_bp, url_prefix="/measure")
|
app.register_blueprint(measure_bp, url_prefix="/measure")
|
||||||
app.register_blueprint(maker_bp, url_prefix="/maker")
|
app.register_blueprint(maker_bp, url_prefix="/maker")
|
||||||
app.register_blueprint(statistics_bp, url_prefix="/statistics")
|
app.register_blueprint(statistics_bp, url_prefix="/statistics")
|
||||||
app.register_blueprint(admin_bp, url_prefix="/admin")
|
app.register_blueprint(admin_bp, url_prefix="/admin")
|
||||||
app.register_blueprint(monitor_bp, url_prefix="/monitor") # #57
|
|
||||||
|
|
||||||
@app.before_request
|
@app.before_request
|
||||||
def _check_secret_key():
|
def _check_secret_key():
|
||||||
|
|||||||
@@ -57,7 +57,20 @@ def recipe_list():
|
|||||||
@role_required("Maker")
|
@role_required("Maker")
|
||||||
def recipe_new():
|
def recipe_new():
|
||||||
"""Create new recipe."""
|
"""Create new recipe."""
|
||||||
return render_template("maker/recipe_editor.html", recipe=None)
|
# #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
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@maker_bp.route("/recipes/<int:recipe_id>/edit")
|
@maker_bp.route("/recipes/<int:recipe_id>/edit")
|
||||||
@@ -201,6 +214,21 @@ def api_create_recipe():
|
|||||||
return jsonify(resp), 201
|
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"])
|
@maker_bp.route("/api/recipes/<int:recipe_id>", methods=["PUT"])
|
||||||
@login_required
|
@login_required
|
||||||
@role_required("Maker")
|
@role_required("Maker")
|
||||||
@@ -377,6 +405,47 @@ def api_upload_file():
|
|||||||
return jsonify(resp), 201
|
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"])
|
@maker_bp.route("/api/files/<path:file_path>", methods=["GET"])
|
||||||
@login_required
|
@login_required
|
||||||
def api_get_file(file_path: str):
|
def api_get_file(file_path: str):
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
"""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)
|
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
// 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,7 +526,8 @@ function recipeEditor() {
|
|||||||
currentVersion: {{ (recipe.current_version.version_number if recipe and recipe.current_version else 0)|tojson }},
|
currentVersion: {{ (recipe.current_version.version_number if recipe and recipe.current_version else 0)|tojson }},
|
||||||
|
|
||||||
// ---- Measurement interval ----
|
// ---- Measurement interval ----
|
||||||
measurementIntervalMinutes: {{ (recipe.measurement_interval_minutes if recipe and recipe.measurement_interval_minutes else 'null')|tojson }},
|
// #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 }},
|
||||||
|
|
||||||
// ---- Rules of measurement (points 8 and 9) ----
|
// ---- Rules of measurement (points 8 and 9) ----
|
||||||
// A new recipe forbids typing: the caliper is the instrument, and permission
|
// A new recipe forbids typing: the caliper is the instrument, and permission
|
||||||
|
|||||||
@@ -10,6 +10,51 @@
|
|||||||
filter: 'all',
|
filter: 'all',
|
||||||
deleteModal: false,
|
deleteModal: false,
|
||||||
deleteTarget: null,
|
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() {
|
get filteredRecipes() {
|
||||||
let filtered = this.recipes;
|
let filtered = this.recipes;
|
||||||
@@ -265,6 +310,15 @@
|
|||||||
{{ _('Versioni') }}
|
{{ _('Versioni') }}
|
||||||
</a>
|
</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 -->
|
<!-- Spacer -->
|
||||||
<div class="flex-1"></div>
|
<div class="flex-1"></div>
|
||||||
|
|
||||||
@@ -318,6 +372,42 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</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 -->
|
<!-- Delete Confirmation Modal -->
|
||||||
<div x-show="deleteModal"
|
<div x-show="deleteModal"
|
||||||
x-cloak
|
x-cloak
|
||||||
|
|||||||
@@ -277,6 +277,8 @@
|
|||||||
<option value="note">{{ _('Nota') }}</option>
|
<option value="note">{{ _('Nota') }}</option>
|
||||||
<option value="measure">{{ _('Misura') }}</option>
|
<option value="measure">{{ _('Misura') }}</option>
|
||||||
<option value="drawing">{{ _('Disegno') }}</option>
|
<option value="drawing">{{ _('Disegno') }}</option>
|
||||||
|
<option value="camera_measure">{{ _('Misura camera') }}</option>
|
||||||
|
<option value="dxf_compare">{{ _('Confronto profilo') }}</option>
|
||||||
</select>
|
</select>
|
||||||
<p class="mt-1 text-xs text-[var(--text-secondary)]">
|
<p class="mt-1 text-xs text-[var(--text-secondary)]">
|
||||||
{{ _('Un task di misura resta tale anche prima di inserire le quote') }}
|
{{ _('Un task di misura resta tale anche prima di inserire le quote') }}
|
||||||
@@ -460,6 +462,55 @@
|
|||||||
|
|
||||||
<div class="tmf-card-body space-y-4">
|
<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 ========== -->
|
<!-- ========== Task File Upload / Preview ========== -->
|
||||||
<div class="p-3 rounded-lg bg-[var(--bg-secondary)] border border-[var(--border-color)]">
|
<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">
|
<p class="text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wide mb-2">
|
||||||
@@ -543,6 +594,8 @@
|
|||||||
<option value="note">{{ _('Nota') }}</option>
|
<option value="note">{{ _('Nota') }}</option>
|
||||||
<option value="measure">{{ _('Misura') }}</option>
|
<option value="measure">{{ _('Misura') }}</option>
|
||||||
<option value="drawing">{{ _('Disegno') }}</option>
|
<option value="drawing">{{ _('Disegno') }}</option>
|
||||||
|
<option value="camera_measure">{{ _('Misura camera') }}</option>
|
||||||
|
<option value="dxf_compare">{{ _('Confronto profilo') }}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -699,6 +752,18 @@
|
|||||||
class="tmf-input"
|
class="tmf-input"
|
||||||
placeholder="{{ _('Es. Diametro foro principale') }}">
|
placeholder="{{ _('Es. Diametro foro principale') }}">
|
||||||
</div>
|
</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 -->
|
<!-- Measurement Type -->
|
||||||
<div>
|
<div>
|
||||||
<label class="tmf-label text-xs">{{ _('Tipo') }}</label>
|
<label class="tmf-label text-xs">{{ _('Tipo') }}</label>
|
||||||
@@ -1093,6 +1158,7 @@
|
|||||||
{% block extra_js %}
|
{% block extra_js %}
|
||||||
{# Bold and line breaks in descriptions: the same rules the server renders with. #}
|
{# 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/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 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>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>
|
<script src="{{ url_for('static', filename='js/annotation-viewer.js') }}?v=6"></script>
|
||||||
@@ -1129,8 +1195,11 @@ function taskEditor() {
|
|||||||
addingSubtaskForTask: null,
|
addingSubtaskForTask: null,
|
||||||
newSubtask: {
|
newSubtask: {
|
||||||
marker_number: 1, description: '', measurement_type: '',
|
marker_number: 1, description: '', measurement_type: '',
|
||||||
nominal: null, utl: null, uwl: null, lwl: null, ltl: null, unit: 'mm'
|
nominal: null, utl: null, uwl: null, lwl: null, ltl: null, unit: 'mm',
|
||||||
|
vision_output: ''
|
||||||
},
|
},
|
||||||
|
// Uscite numeriche della ricetta visione per task (#46), riempite da vision_recipe.js
|
||||||
|
visionOutputs: {},
|
||||||
|
|
||||||
// ---- Delete modals ----
|
// ---- Delete modals ----
|
||||||
deleteTaskModal: false,
|
deleteTaskModal: false,
|
||||||
@@ -1153,6 +1222,10 @@ function taskEditor() {
|
|||||||
// Init
|
// Init
|
||||||
// ============================================================
|
// ============================================================
|
||||||
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
|
// Sort tasks by order_index on load
|
||||||
this.tasks.sort((a, b) => (a.order_index || 0) - (b.order_index || 0));
|
this.tasks.sort((a, b) => (a.order_index || 0) - (b.order_index || 0));
|
||||||
|
|
||||||
@@ -1610,7 +1683,8 @@ function taskEditor() {
|
|||||||
uwl: null,
|
uwl: null,
|
||||||
lwl: null,
|
lwl: null,
|
||||||
ltl: null,
|
ltl: null,
|
||||||
unit: 'mm'
|
unit: 'mm',
|
||||||
|
vision_output: ''
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -1634,7 +1708,8 @@ function taskEditor() {
|
|||||||
uwl: this.newSubtask.uwl,
|
uwl: this.newSubtask.uwl,
|
||||||
lwl: this.newSubtask.lwl,
|
lwl: this.newSubtask.lwl,
|
||||||
ltl: this.newSubtask.ltl,
|
ltl: this.newSubtask.ltl,
|
||||||
unit: this.newSubtask.unit || 'mm'
|
unit: this.newSubtask.unit || 'mm',
|
||||||
|
vision_output: this.newSubtask.vision_output || null // chiave uscita ricetta visione (#46)
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -1687,7 +1762,8 @@ function taskEditor() {
|
|||||||
uwl: subtask.uwl,
|
uwl: subtask.uwl,
|
||||||
lwl: subtask.lwl,
|
lwl: subtask.lwl,
|
||||||
ltl: subtask.ltl,
|
ltl: subtask.ltl,
|
||||||
unit: subtask.unit || 'mm'
|
unit: subtask.unit || 'mm',
|
||||||
|
vision_output: subtask.vision_output || ''
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -1711,7 +1787,8 @@ function taskEditor() {
|
|||||||
uwl: this.newSubtask.uwl,
|
uwl: this.newSubtask.uwl,
|
||||||
lwl: this.newSubtask.lwl,
|
lwl: this.newSubtask.lwl,
|
||||||
ltl: this.newSubtask.ltl,
|
ltl: this.newSubtask.ltl,
|
||||||
unit: this.newSubtask.unit || 'mm'
|
unit: this.newSubtask.unit || 'mm',
|
||||||
|
vision_output: this.newSubtask.vision_output || null // chiave uscita ricetta visione (#46)
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,105 +0,0 @@
|
|||||||
{% 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 %}
|
|
||||||
@@ -71,6 +71,61 @@ class TestRecipeNew:
|
|||||||
assert resp.status_code == 200
|
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:
|
class TestRoleRequired:
|
||||||
"""Maker role enforcement tests."""
|
"""Maker role enforcement tests."""
|
||||||
|
|
||||||
@@ -161,3 +216,82 @@ class TestVersionConflictProxy:
|
|||||||
|
|
||||||
assert resp.status_code == 409
|
assert resp.status_code == 409
|
||||||
assert resp.get_json()["detail"] == self._CONFLICT["detail"]
|
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
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
"""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
|
|
||||||
Reference in New Issue
Block a user