Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e44ac3129f | |||
| 4e52095423 |
@@ -20,8 +20,7 @@ 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.models.api.monitor import MonitorRun
|
||||
from src.backend.services import auth_service, monitor_service, production_service
|
||||
from src.backend.services import auth_service, production_service
|
||||
|
||||
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
|
||||
|
||||
|
||||
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,
|
||||
|
||||
@@ -23,6 +23,7 @@ async def download_spc_report(
|
||||
operator_id: int | None = Query(None),
|
||||
lot_number: str | None = Query(None),
|
||||
serial_number: str | None = Query(None),
|
||||
production_run_id: int | None = Query(None), # #58
|
||||
user: User = Depends(require_metrologist),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Response:
|
||||
@@ -32,6 +33,7 @@ async def download_spc_report(
|
||||
db, recipe_id, subtask_id,
|
||||
version_id, date_from, date_to,
|
||||
operator_id, lot_number, serial_number,
|
||||
production_run_id=production_run_id, # #58
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
@@ -57,6 +59,7 @@ async def download_measurement_report(
|
||||
operator_id: int | None = Query(None),
|
||||
lot_number: str | None = Query(None),
|
||||
serial_number: str | None = Query(None),
|
||||
production_run_id: int | None = Query(None), # #58
|
||||
user: User = Depends(require_metrologist),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Response:
|
||||
@@ -66,6 +69,7 @@ async def download_measurement_report(
|
||||
db, recipe_id, subtask_id,
|
||||
version_id, date_from, date_to,
|
||||
operator_id, lot_number, serial_number,
|
||||
production_run_id=production_run_id, # #58
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
|
||||
@@ -3,12 +3,13 @@ from datetime import datetime
|
||||
from src.backend.models.api.tipi import UtcNaiveInput
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import and_, select
|
||||
from sqlalchemy import and_, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.backend.database import get_db
|
||||
from src.backend.api.middleware.api_key import require_metrologist
|
||||
from src.backend.models.orm.measurement import Measurement
|
||||
from src.backend.models.orm.production import ProductionRun
|
||||
from src.backend.models.orm.recipe import RecipeVersion
|
||||
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
||||
from src.backend.models.orm.user import User
|
||||
@@ -38,6 +39,7 @@ async def _query_measurements(
|
||||
operator_id: int | None = None,
|
||||
lot_number: str | None = None,
|
||||
serial_number: str | None = None,
|
||||
production_run_id: int | None = None, # #58: solo le misure di una sessione
|
||||
) -> list[Measurement]:
|
||||
"""Query measurements with common filters.
|
||||
|
||||
@@ -67,6 +69,9 @@ async def _query_measurements(
|
||||
if serial_number is not None:
|
||||
filters.append(Measurement.serial_number == serial_number)
|
||||
|
||||
if production_run_id is not None:
|
||||
filters.append(Measurement.production_run_id == production_run_id)
|
||||
|
||||
query = (
|
||||
select(Measurement)
|
||||
.where(and_(*filters) if filters else True)
|
||||
@@ -106,6 +111,7 @@ async def get_summary(
|
||||
operator_id: int | None = Query(None),
|
||||
lot_number: str | None = Query(None),
|
||||
serial_number: str | None = Query(None),
|
||||
production_run_id: int | None = Query(None), # #58
|
||||
user: User = Depends(require_metrologist),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> SummaryData:
|
||||
@@ -113,6 +119,7 @@ async def get_summary(
|
||||
measurements = await _query_measurements(
|
||||
db, recipe_id, version_id, subtask_id,
|
||||
date_from, date_to, operator_id, lot_number, serial_number,
|
||||
production_run_id=production_run_id, # #58
|
||||
)
|
||||
pass_fail_values = [m.pass_fail for m in measurements]
|
||||
return compute_summary(pass_fail_values)
|
||||
@@ -128,6 +135,7 @@ async def get_capability(
|
||||
operator_id: int | None = Query(None),
|
||||
lot_number: str | None = Query(None),
|
||||
serial_number: str | None = Query(None),
|
||||
production_run_id: int | None = Query(None), # #58
|
||||
user: User = Depends(require_metrologist),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> CapabilityData:
|
||||
@@ -135,6 +143,7 @@ async def get_capability(
|
||||
measurements = await _query_measurements(
|
||||
db, recipe_id, version_id, subtask_id,
|
||||
date_from, date_to, operator_id, lot_number, serial_number,
|
||||
production_run_id=production_run_id, # #58
|
||||
)
|
||||
values = [float(m.value) for m in measurements]
|
||||
tol = await _get_subtask_tolerances(db, subtask_id)
|
||||
@@ -151,6 +160,7 @@ async def get_control_chart(
|
||||
operator_id: int | None = Query(None),
|
||||
lot_number: str | None = Query(None),
|
||||
serial_number: str | None = Query(None),
|
||||
production_run_id: int | None = Query(None), # #58
|
||||
user: User = Depends(require_metrologist),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ControlChartData:
|
||||
@@ -158,6 +168,7 @@ async def get_control_chart(
|
||||
measurements = await _query_measurements(
|
||||
db, recipe_id, version_id, subtask_id,
|
||||
date_from, date_to, operator_id, lot_number, serial_number,
|
||||
production_run_id=production_run_id, # #58
|
||||
)
|
||||
values = [float(m.value) for m in measurements]
|
||||
timestamps = [m.measured_at for m in measurements]
|
||||
@@ -178,6 +189,7 @@ async def get_histogram(
|
||||
operator_id: int | None = Query(None),
|
||||
lot_number: str | None = Query(None),
|
||||
serial_number: str | None = Query(None),
|
||||
production_run_id: int | None = Query(None), # #58
|
||||
n_bins: int = Query(20, ge=5, le=100),
|
||||
user: User = Depends(require_metrologist),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -186,6 +198,7 @@ async def get_histogram(
|
||||
measurements = await _query_measurements(
|
||||
db, recipe_id, version_id, subtask_id,
|
||||
date_from, date_to, operator_id, lot_number, serial_number,
|
||||
production_run_id=production_run_id, # #58
|
||||
)
|
||||
values = [float(m.value) for m in measurements]
|
||||
return compute_histogram(values, n_bins)
|
||||
@@ -235,3 +248,49 @@ async def get_recipe_subtasks(
|
||||
})
|
||||
|
||||
return subtasks_list
|
||||
|
||||
|
||||
@router.get("/runs")
|
||||
async def get_recipe_runs(
|
||||
recipe_id: int = Query(...),
|
||||
user: User = Depends(require_metrologist),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> list[dict]:
|
||||
"""#58: production runs of a recipe (for the "Sessione" filter), newest first."""
|
||||
runs = (await db.execute(
|
||||
select(ProductionRun)
|
||||
.where(ProductionRun.recipe_id == recipe_id)
|
||||
.order_by(ProductionRun.started_at.desc(), ProductionRun.id.desc())
|
||||
)).scalars().all()
|
||||
# #58: conta solo le misure dei run della ricetta, non tutta la tabella.
|
||||
run_ids = [r.id for r in runs]
|
||||
counts = {}
|
||||
if run_ids:
|
||||
counts = dict((await db.execute(
|
||||
select(Measurement.production_run_id, func.count(Measurement.id))
|
||||
.where(Measurement.production_run_id.in_(run_ids))
|
||||
.group_by(Measurement.production_run_id)
|
||||
)).all())
|
||||
operator_ids = {r.operator_id for r in runs}
|
||||
names = {}
|
||||
if operator_ids:
|
||||
names = dict((await db.execute(
|
||||
select(User.id, User.display_name).where(User.id.in_(operator_ids))
|
||||
)).all())
|
||||
return [
|
||||
{
|
||||
"id": r.id,
|
||||
"station_id": r.station_id,
|
||||
"station_name": r.station.name if r.station else None,
|
||||
"operator_id": r.operator_id,
|
||||
"operator_name": names.get(r.operator_id),
|
||||
"started_at": r.started_at.isoformat() if r.started_at else None,
|
||||
"closed_at": r.closed_at.isoformat() if r.closed_at else None,
|
||||
"status": r.status,
|
||||
"lot_number": r.lot_number,
|
||||
"serial_number": r.serial_number,
|
||||
"cycle_count": r.cycle_count,
|
||||
"measurement_count": counts.get(r.id, 0),
|
||||
}
|
||||
for r in runs
|
||||
]
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -48,6 +48,7 @@ async def _query_measurements(
|
||||
operator_id: int | None = None,
|
||||
lot_number: str | None = None,
|
||||
serial_number: str | None = None,
|
||||
production_run_id: int | None = None, # #58
|
||||
) -> list[Measurement]:
|
||||
"""Query measurements with common filters (duplicated from statistics router)."""
|
||||
filters = []
|
||||
@@ -73,6 +74,9 @@ async def _query_measurements(
|
||||
if serial_number is not None:
|
||||
filters.append(Measurement.serial_number == serial_number)
|
||||
|
||||
if production_run_id is not None:
|
||||
filters.append(Measurement.production_run_id == production_run_id)
|
||||
|
||||
query = (
|
||||
select(Measurement)
|
||||
.where(and_(*filters) if filters else True)
|
||||
@@ -281,6 +285,7 @@ async def generate_spc_report(
|
||||
operator_id: int | None = None,
|
||||
lot_number: str | None = None,
|
||||
serial_number: str | None = None,
|
||||
production_run_id: int | None = None, # #58
|
||||
) -> bytes:
|
||||
"""Generate SPC PDF report and return bytes.
|
||||
|
||||
@@ -290,6 +295,7 @@ async def generate_spc_report(
|
||||
measurements = await _query_measurements(
|
||||
db, recipe_id, version_id, subtask_id,
|
||||
date_from, date_to, operator_id, lot_number, serial_number,
|
||||
production_run_id=production_run_id, # #58
|
||||
)
|
||||
|
||||
# 2. Compute SPC data
|
||||
@@ -336,6 +342,8 @@ async def generate_spc_report(
|
||||
filters_desc.append(f"Lot: {lot_number}")
|
||||
if serial_number:
|
||||
filters_desc.append(f"Serial: {serial_number}")
|
||||
if production_run_id is not None:
|
||||
filters_desc.append(f"Run: {production_run_id}") # #58
|
||||
|
||||
# 6. Render HTML template
|
||||
template = _jinja_env.get_template("spc_report.html")
|
||||
@@ -367,12 +375,14 @@ async def generate_measurement_report(
|
||||
operator_id: int | None = None,
|
||||
lot_number: str | None = None,
|
||||
serial_number: str | None = None,
|
||||
production_run_id: int | None = None, # #58
|
||||
) -> bytes:
|
||||
"""Generate measurement table PDF report and return bytes."""
|
||||
# 1. Query measurements
|
||||
measurements = await _query_measurements(
|
||||
db, recipe_id, version_id, subtask_id,
|
||||
date_from, date_to, operator_id, lot_number, serial_number,
|
||||
production_run_id=production_run_id, # #58
|
||||
)
|
||||
|
||||
# 2. Get recipe info
|
||||
@@ -427,6 +437,8 @@ async def generate_measurement_report(
|
||||
filters_desc.append(f"Lot: {lot_number}")
|
||||
if serial_number:
|
||||
filters_desc.append(f"Serial: {serial_number}")
|
||||
if production_run_id is not None:
|
||||
filters_desc.append(f"Run: {production_run_id}") # #58
|
||||
|
||||
# 7. Render template
|
||||
template = _jinja_env.get_template("measurement_report.html")
|
||||
|
||||
@@ -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() == []
|
||||
@@ -211,3 +211,79 @@ class TestDateFilter:
|
||||
assert resp.status_code == 200
|
||||
# All measurements should be within this wide range
|
||||
assert resp.json()["total"] >= 2
|
||||
|
||||
|
||||
class TestRunFilter:
|
||||
"""#58: statistiche per sessione di produzione."""
|
||||
|
||||
async def _setup(self, db_session, user, metro):
|
||||
from datetime import datetime
|
||||
from src.backend.models.orm.production import ProductionRun
|
||||
from src.backend.models.orm.station import Station
|
||||
from src.backend.models.orm.recipe import RecipeVersion
|
||||
from sqlalchemy import select
|
||||
recipe = await create_test_recipe(db_session, user.id)
|
||||
version = (await db_session.execute(
|
||||
select(RecipeVersion).where(RecipeVersion.recipe_id == recipe.id)
|
||||
)).scalars().first()
|
||||
station = Station(code="ST-58", name="Stazione 58", active=True, created_by=user.id)
|
||||
db_session.add(station)
|
||||
await db_session.commit()
|
||||
runs = []
|
||||
for i, lot in enumerate(["L1", "L2"]):
|
||||
run = ProductionRun(
|
||||
station_id=station.id, recipe_id=recipe.id, version_id=version.id,
|
||||
operator_id=user.id, status="closed", lot_number=lot,
|
||||
started_at=datetime(2026, 1, 1 + i),
|
||||
)
|
||||
db_session.add(run)
|
||||
runs.append(run)
|
||||
await db_session.commit()
|
||||
return recipe, runs
|
||||
|
||||
async def test_filter_by_run_and_unfiltered(
|
||||
self, client, measurement_tec_user, metrologist_user, db_session,
|
||||
):
|
||||
from src.backend.models.orm.measurement import Measurement
|
||||
recipe, runs = await self._setup(db_session, measurement_tec_user, metrologist_user)
|
||||
subtask_id, version_id = await _seed_measurements(
|
||||
client, measurement_tec_user, recipe.id, [10.0, 10.1, 10.2],
|
||||
)
|
||||
# Assegna le prime due misure al run 1, la terza al run 2.
|
||||
from sqlalchemy import select
|
||||
ms = (await db_session.execute(
|
||||
select(Measurement).order_by(Measurement.id)
|
||||
)).scalars().all()
|
||||
ms[0].production_run_id = runs[0].id
|
||||
ms[1].production_run_id = runs[0].id
|
||||
ms[2].production_run_id = runs[1].id
|
||||
await db_session.commit()
|
||||
|
||||
h = auth_headers(metrologist_user)
|
||||
r1 = await client.get("/api/statistics/summary", headers=h,
|
||||
params={"recipe_id": recipe.id, "production_run_id": runs[0].id})
|
||||
assert r1.json()["total"] == 2
|
||||
r2 = await client.get("/api/statistics/summary", headers=h,
|
||||
params={"recipe_id": recipe.id, "production_run_id": runs[1].id})
|
||||
assert r2.json()["total"] == 1
|
||||
r_all = await client.get("/api/statistics/summary", headers=h,
|
||||
params={"recipe_id": recipe.id})
|
||||
assert r_all.json()["total"] == 3
|
||||
|
||||
runs_resp = await client.get("/api/statistics/runs", headers=h,
|
||||
params={"recipe_id": recipe.id})
|
||||
assert runs_resp.status_code == 200
|
||||
data = runs_resp.json()
|
||||
assert [r["id"] for r in data] == [runs[1].id, runs[0].id] # più recente prima
|
||||
assert [r["measurement_count"] for r in data] == [1, 2]
|
||||
assert data[0]["lot_number"] == "L2"
|
||||
assert data[0]["station_name"] == "Stazione 58"
|
||||
assert data[0]["closed_at"] is None
|
||||
|
||||
async def test_runs_require_metrologist(
|
||||
self, client, measurement_tec_user, db_session,
|
||||
):
|
||||
resp = await client.get("/api/statistics/runs",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
params={"recipe_id": 1})
|
||||
assert resp.status_code == 403
|
||||
|
||||
@@ -74,14 +74,12 @@ 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,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)
|
||||
@@ -50,7 +50,7 @@ def _proxy_statistics(endpoint: str):
|
||||
for key in [
|
||||
"recipe_id", "version_id", "subtask_id",
|
||||
"date_from", "date_to", "operator_id",
|
||||
"lot_number", "serial_number", "n_bins",
|
||||
"lot_number", "serial_number", "n_bins", "production_run_id", # #58
|
||||
]:
|
||||
val = request.args.get(key)
|
||||
if val is not None and val != "":
|
||||
@@ -127,6 +127,27 @@ def api_subtasks():
|
||||
return jsonify(resp)
|
||||
|
||||
|
||||
@statistics_bp.route("/api/runs")
|
||||
@login_required
|
||||
@role_required("Metrologist")
|
||||
def api_runs():
|
||||
"""Proxy: production runs of a recipe (#58, "Sessione" filter)."""
|
||||
params = {}
|
||||
recipe_id = request.args.get("recipe_id")
|
||||
if recipe_id:
|
||||
params["recipe_id"] = recipe_id
|
||||
|
||||
resp = api_client.get("/api/statistics/runs", params=params)
|
||||
|
||||
if isinstance(resp, dict) and resp.get("error"):
|
||||
error_body = {"error": True, "detail": resp.get("detail", "")}
|
||||
if resp.get("session_expired"):
|
||||
error_body["session_expired"] = True
|
||||
return jsonify(error_body), resp.get("status_code", 500)
|
||||
|
||||
return jsonify(resp)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# PROXY REPORT PDF → FastAPI
|
||||
# ============================================================================
|
||||
@@ -137,7 +158,7 @@ def _proxy_report(endpoint: str):
|
||||
for key in [
|
||||
"recipe_id", "version_id", "subtask_id",
|
||||
"date_from", "date_to", "operator_id",
|
||||
"lot_number", "serial_number",
|
||||
"lot_number", "serial_number", "production_run_id", # #58
|
||||
]:
|
||||
val = request.args.get(key)
|
||||
if val is not None and val != "":
|
||||
|
||||
@@ -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 %}
|
||||
@@ -35,6 +35,19 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Sessione di produzione (#58) -->
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-steel mb-1">{{ _("Sessione") }}</label>
|
||||
<select x-model="selectedRunId"
|
||||
:disabled="runs.length === 0"
|
||||
class="w-full rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-primary)] px-3 py-2 text-sm focus:ring-2 focus:ring-primary focus:border-primary disabled:opacity-50">
|
||||
<option value="">{{ _("Tutte le sessioni") }}</option>
|
||||
<template x-for="r in runs" :key="r.id">
|
||||
<option :value="r.id" x-text="runLabel(r)"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Subtask -->
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-steel mb-1">{{ _("Punto di misura") }}</label>
|
||||
@@ -258,6 +271,8 @@ function spcDashboard() {
|
||||
// Filter state
|
||||
selectedRecipeId: '',
|
||||
selectedSubtaskId: '',
|
||||
selectedRunId: '', // #58
|
||||
runs: [], // #58 sessioni della ricetta
|
||||
dateFrom: '',
|
||||
dateTo: '',
|
||||
subtasks: [],
|
||||
@@ -281,6 +296,8 @@ function spcDashboard() {
|
||||
|
||||
async onRecipeChange() {
|
||||
this.selectedSubtaskId = '';
|
||||
this.selectedRunId = ''; // #58
|
||||
this.runs = [];
|
||||
this.subtasks = [];
|
||||
this.hasData = false;
|
||||
this.summary = null;
|
||||
@@ -290,6 +307,15 @@ function spcDashboard() {
|
||||
|
||||
if (!this.selectedRecipeId) return;
|
||||
|
||||
// #58: sessioni della ricetta per il filtro «Sessione»
|
||||
try {
|
||||
const rresp = await fetch('/statistics/api/runs?recipe_id=' + this.selectedRecipeId);
|
||||
const rdata = await rresp.json();
|
||||
if (Array.isArray(rdata)) this.runs = rdata;
|
||||
} catch (e) {
|
||||
console.error('Error loading runs:', e);
|
||||
}
|
||||
|
||||
// Load subtasks for dropdown
|
||||
try {
|
||||
const resp = await fetch('/statistics/api/subtasks?recipe_id=' + this.selectedRecipeId);
|
||||
@@ -302,6 +328,12 @@ function spcDashboard() {
|
||||
}
|
||||
},
|
||||
|
||||
// #58: etichetta sessione = data, stazione, operatore, lotto
|
||||
runLabel(r) {
|
||||
const d = r.started_at ? new Date(r.started_at + (r.started_at.endsWith('Z') ? '' : 'Z')).toLocaleString() : '';
|
||||
return [d, r.station_name, r.operator_name, r.lot_number].filter(Boolean).join(' · ');
|
||||
},
|
||||
|
||||
async loadData() {
|
||||
if (!this.selectedRecipeId) return;
|
||||
|
||||
@@ -312,6 +344,7 @@ function spcDashboard() {
|
||||
const params = new URLSearchParams();
|
||||
params.set('recipe_id', this.selectedRecipeId);
|
||||
if (this.selectedSubtaskId) params.set('subtask_id', this.selectedSubtaskId);
|
||||
if (this.selectedRunId) params.set('production_run_id', this.selectedRunId); // #58
|
||||
if (this.dateFrom) params.set('date_from', new Date(this.dateFrom + 'T00:00:00').toISOString());
|
||||
if (this.dateTo) params.set('date_to', new Date(this.dateTo + 'T23:59:59.999').toISOString());
|
||||
|
||||
@@ -417,6 +450,7 @@ function spcDashboard() {
|
||||
const params = new URLSearchParams();
|
||||
params.set('recipe_id', this.selectedRecipeId);
|
||||
if (this.selectedSubtaskId) params.set('subtask_id', this.selectedSubtaskId);
|
||||
if (this.selectedRunId) params.set('production_run_id', this.selectedRunId); // #58
|
||||
if (this.dateFrom) params.set('date_from', new Date(this.dateFrom + 'T00:00:00').toISOString());
|
||||
if (this.dateTo) params.set('date_to', new Date(this.dateTo + 'T23:59:59.999').toISOString());
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -112,3 +112,21 @@ class TestSPCProxy:
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert "cp" in data
|
||||
|
||||
|
||||
class TestRunsProxy:
|
||||
"""#58: filtro per sessione di produzione."""
|
||||
|
||||
def test_runs_proxy(self, logged_in_client, mock_api_client):
|
||||
mock_api_client.get.return_value = [{"id": 5, "measurement_count": 3}]
|
||||
resp = logged_in_client.get("/statistics/api/runs?recipe_id=7")
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()[0]["id"] == 5
|
||||
args, kwargs = mock_api_client.get.call_args
|
||||
assert args[0] == "/api/statistics/runs"
|
||||
assert kwargs["params"] == {"recipe_id": "7"}
|
||||
|
||||
def test_summary_forwards_production_run_id(self, logged_in_client, mock_api_client):
|
||||
mock_api_client.get.return_value = {"total": 1}
|
||||
logged_in_client.get("/statistics/api/summary?recipe_id=1&production_run_id=5")
|
||||
assert mock_api_client.get.call_args.kwargs["params"]["production_run_id"] == "5"
|
||||
|
||||
Reference in New Issue
Block a user