feat(measure): il fuori tolleranza si autorizza, non si chiude

Il punto 5 c'era a meta': la schermata apriva il modale del capoturno e bloccava
l'avanzamento automatico, ma il modale si chiudeva con Annulla o con un click
sullo sfondo, la misura era gia' salvata, e `pendingAdvance` veniva impostato e
non letto da nessuno. Era una conferma, non uno sbarramento.

Ora l'autorizzazione finisce sulla misura: due colonne, chi ha autorizzato e
quando. Finche' una quota e' fuori tolleranza e nessuno l'ha autorizzata, il
server rifiuta la misura della quota successiva e la chiusura del ciclo. Non e'
la schermata a impedirlo: la schermata risparmia solo il viaggio.

Rimisurare la stessa quota resta possibile — il calibro scivola, il pezzo si
riposiziona — e una seconda lettura in tolleranza libera il blocco, perche' conta
l'ultima lettura di ogni quota. Quante volte si possa riprovare e' il punto 4, e
tutte le letture restano comunque a registro. Un warning non blocca: fuori dai
limiti di attenzione ma dentro la tolleranza e' dentro la tolleranza.

Ricaricare la pagina era il modo piu' semplice per scavalcare il vecchio gate.
Non lo e' piu': la schermata chiede al server, all'apertura, se una quota sta
aspettando, e si ritrova davanti lo stesso blocco.

Sparisce /validate-supervisor, che verificava le credenziali e buttava via la
risposta. Al suo posto un endpoint che le credenziali le usa per scrivere
l'approvazione dove serve. Il controllo del capoturno si sposta in auth_service,
accanto al resto delle credenziali: fermo linea, chiusura e fuori tolleranza
fanno la stessa domanda, e solo una delle tre riguarda la produzione.

Il file di statistica guadagna authorised_by e authorised_at: mostrava il
fallimento e non la decisione, che e' la meta' che un auditor chiede.

Migrazione 010: due colonne nullable sulle misure. Le righe esistenti restano
nulle — retrodatare un'autorizzazione mai avvenuta sarebbe inventarsi un record
di audit, e quelle produzioni sono chiuse da un pezzo.

Test: +18 (309). Coprono il rifiuto della quota successiva, la rimisura ammessa,
il rilascio del blocco con una lettura buona, il ciclo che non si chiude, il
capoturno registrato sulla misura, le credenziali sbagliate e chi capoturno non
e', il file di statistica, e il fatto che una produzione chiusa non blocchi la
successiva. Aggiunto uploads/statistics/ al gitignore: i test che chiudono una
produzione scrivevano nel repository.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-07-28 20:54:24 +00:00
parent 5aa3d595ad
commit e2aae88858
17 changed files with 1341 additions and 441 deletions
+56
View File
@@ -19,11 +19,13 @@ from src.backend.models.orm.recipe import RecipeVersion
from src.backend.models.orm.setting import SystemSetting
from src.backend.models.orm.user import User
from src.backend.models.api.measurement import (
MeasurementAuthorisation,
MeasurementBatchCreate,
MeasurementCreate,
MeasurementListResponse,
MeasurementResponse,
)
from src.backend.services import auth_service, measurement_service
from src.backend.services.measurement_service import save_measurement
router = APIRouter(prefix="/api/measurements", tags=["measurements"])
@@ -57,6 +59,60 @@ async def create_measurement(
)
@router.get("/pending-authorisation", response_model=MeasurementResponse | None)
async def get_pending_authorisation(
version_id: int = Query(..., gt=0),
production_run_id: int | None = Query(None, gt=0),
user: User = Depends(require_measurement_tec),
db: AsyncSession = Depends(get_db),
):
"""The out-of-tolerance measurement holding this operator up, or null.
Asked on load, so that reloading the page is not a way past the gate: the
screen finds the same block waiting for it that the server enforces.
"""
blocking = await measurement_service.pending_authorisation(
db, version_id, user.id, production_run_id,
)
return MeasurementResponse.model_validate(blocking) if blocking else None
@router.post("/{measurement_id}/authorise", response_model=MeasurementResponse)
async def authorise_measurement(
measurement_id: int,
action: MeasurementAuthorisation,
user: User = Depends(require_measurement_tec),
db: AsyncSession = Depends(get_db),
):
"""Let an out-of-tolerance value stand, on the capoturno's name.
Credentials go to this endpoint rather than to a separate check because the
approval has to end up attached to the measurement. A check whose answer is
thrown away is what this point had before: a modal that closed.
"""
result = await db.execute(
select(Measurement).where(Measurement.id == measurement_id)
)
measurement = result.scalar_one_or_none()
if measurement is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Measurement not found",
)
if measurement.pass_fail != "fail":
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="This measurement is within tolerance: there is nothing to authorise",
)
supervisor = await auth_service.authorise_supervisor(
db, action.supervisor_username, action.supervisor_password,
)
measurement = await measurement_service.authorise_measurement(
db, measurement, supervisor,
)
return MeasurementResponse.model_validate(measurement)
@router.post("/batch", response_model=list[MeasurementResponse])
async def create_measurement_batch(
data: MeasurementBatchCreate,
+4 -4
View File
@@ -20,7 +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.services import production_service
from src.backend.services import auth_service, production_service
router = APIRouter(prefix="/api/production-runs", tags=["production"])
@@ -124,7 +124,7 @@ async def pause_production_run(
):
"""Fermo linea - requires a supervisor."""
run = await production_service.get_run(db, run_id)
supervisor = await production_service.authorise_supervisor(
supervisor = await auth_service.authorise_supervisor(
db, action.supervisor_username, action.supervisor_password,
)
run = await production_service.pause_run(db, run, user, supervisor, note=action.note)
@@ -140,7 +140,7 @@ async def resume_production_run(
):
"""Restart a stopped line - requires a supervisor."""
run = await production_service.get_run(db, run_id)
supervisor = await production_service.authorise_supervisor(
supervisor = await auth_service.authorise_supervisor(
db, action.supervisor_username, action.supervisor_password,
)
run = await production_service.resume_run(db, run, user, supervisor, note=action.note)
@@ -156,7 +156,7 @@ async def close_production_run(
):
"""Fine produzione - requires a supervisor. Stops the timer for good."""
run = await production_service.get_run(db, run_id)
supervisor = await production_service.authorise_supervisor(
supervisor = await auth_service.authorise_supervisor(
db, action.supervisor_username, action.supervisor_password,
)
run = await production_service.close_run(db, run, user, supervisor, note=action.note)
@@ -0,0 +1,49 @@
"""record who authorised an out-of-tolerance measurement
The supervisor gate existed on screen and nowhere else: the modal opened, the
credentials were checked, the modal closed, and nothing was written down. Nothing
depended on the answer either, so a value outside tolerance could be left behind
by dismissing the modal.
These two columns are what the rule now stands on. A failed measurement with no
supervisor on it is a measurement waiting for one, and while one is waiting the
operator cannot move to the next quote.
Existing rows are left null. Backfilling an authorisation that never happened
would be inventing an audit record; the fails already in the database belong to
production runs that are over, and nothing is waiting on them.
Revision ID: 010_meas_authorisation
Revises: 009_recipe_rules
Create Date: 2026-07-28
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '010_meas_authorisation'
down_revision: Union[str, None] = '009_recipe_rules'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
with op.batch_alter_table('measurements') as batch:
batch.add_column(sa.Column('supervisor_id', sa.Integer(), nullable=True))
batch.add_column(sa.Column('authorised_at', sa.DateTime(), nullable=True))
batch.create_foreign_key(
'fk_measurements_supervisor_id', 'users', ['supervisor_id'], ['id'],
)
batch.create_index(
'ix_measurements_supervisor_id', ['supervisor_id'],
)
def downgrade() -> None:
with op.batch_alter_table('measurements') as batch:
batch.drop_index('ix_measurements_supervisor_id')
batch.drop_constraint('fk_measurements_supervisor_id', type_='foreignkey')
batch.drop_column('authorised_at')
batch.drop_column('supervisor_id')
+11
View File
@@ -39,6 +39,10 @@ class MeasurementResponse(BaseModel):
input_method: str
input_duration_ms: Optional[int] = None
production_run_id: Optional[int] = None
# Who let this value stand, when it was out of tolerance. Null on a value in
# tolerance - and on one that is not and is still waiting for an answer.
supervisor_id: Optional[int] = None
authorised_at: Optional[datetime] = None
measured_at: datetime
synced_to_csv: bool
@@ -52,6 +56,13 @@ class MeasurementListResponse(BaseModel):
pages: int
class MeasurementAuthorisation(BaseModel):
"""Credentials of the supervisor allowing an out-of-tolerance value to stand."""
supervisor_username: str = Field(..., min_length=1)
supervisor_password: str = Field(..., min_length=1)
class MeasurementQuery(BaseModel):
"""Schema for measurement query filters."""
recipe_id: Optional[int] = None
+10
View File
@@ -59,6 +59,16 @@ class Measurement(Base):
index=True,
)
# Who let an out-of-tolerance value stand, and when. Null on a value that is
# within tolerance, and null on one that is not and has not been authorised -
# which is exactly the state that stops the operator moving on to the next
# quote. Before this the supervisor's approval was a modal that closed: nothing
# recorded it, and nothing depended on it.
supervisor_id: Mapped[Optional[int]] = mapped_column(
Integer, ForeignKey("users.id"), nullable=True, index=True
)
authorised_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
# Timestamp
measured_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, server_default=func.now(), index=True
+26
View File
@@ -3,6 +3,7 @@ import secrets
from datetime import datetime
import bcrypt
from fastapi import HTTPException, status
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
@@ -39,6 +40,31 @@ async def authenticate_user(
return user
async def authorise_supervisor(db: AsyncSession, username: str, password: str) -> User:
"""Check the credentials of the supervisor authorising something.
Used wherever the shop floor needs a capoturno: a line stop, a closure, an
out-of-tolerance value allowed to stand. Lives here rather than beside any one
of them because it is the same question every time - who is this, and are they
allowed to say yes.
Uses authenticate_user rather than a full login: logging in would rotate the
supervisor's API key and knock out whatever session they have open elsewhere.
"""
supervisor = await authenticate_user(db, username, password)
if supervisor is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid supervisor credentials",
)
if not supervisor.has_role("Supervisor") and not supervisor.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User is not authorised as supervisor",
)
return supervisor
async def login_user(db: AsyncSession, user: User) -> str:
"""Generate API key and update last_login for user."""
api_key = generate_api_key()
+79 -1
View File
@@ -1,13 +1,15 @@
"""Measurement service - pass/fail calculation, data storage."""
from datetime import datetime
from decimal import Decimal
from fastapi import HTTPException, status
from sqlalchemy import select
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.models.orm.measurement import Measurement
from src.backend.models.orm.recipe import Recipe, RecipeVersion
from src.backend.models.orm.task import RecipeSubtask
from src.backend.models.orm.user import User
def calculate_pass_fail(
@@ -83,6 +85,67 @@ def _enforce_recipe_rules(
)
def _scope(query, version_id: int, measured_by: int, production_run_id: int | None):
"""Narrow a measurement query to the run in progress, or to this operator's work.
Inside a production the run is the boundary. Outside one - a recipe executed on
its own - the boundary is the version and the person doing it, so two operators
on the same recipe do not block each other.
"""
if production_run_id is not None:
return query.where(Measurement.production_run_id == production_run_id)
return query.where(
Measurement.version_id == version_id,
Measurement.measured_by == measured_by,
Measurement.production_run_id.is_(None),
)
async def pending_authorisation(
db: AsyncSession,
version_id: int,
measured_by: int,
production_run_id: int | None = None,
) -> Measurement | None:
"""The out-of-tolerance measurement that is holding everything up, if any.
Only the *latest* reading of each quote counts. Measuring the same quote again
is allowed - the caliper slips, the piece is reseated - and a second reading
within tolerance releases the block. What is not allowed is moving on to the
next quote while the one in hand is out of tolerance and nobody has said so.
How many attempts that permits is point 4's business, not this one's. Every
reading stays in the record either way.
"""
latest_per_subtask = _scope(
select(func.max(Measurement.id)),
version_id, measured_by, production_run_id,
).group_by(Measurement.subtask_id)
result = await db.execute(
select(Measurement)
.where(
Measurement.id.in_(latest_per_subtask),
Measurement.pass_fail == "fail",
Measurement.supervisor_id.is_(None),
)
.order_by(Measurement.id)
.limit(1)
)
return result.scalar_one_or_none()
async def authorise_measurement(
db: AsyncSession, measurement: Measurement, supervisor: User,
) -> Measurement:
"""Let an out-of-tolerance value stand, on the supervisor's name."""
measurement.supervisor_id = supervisor.id
measurement.authorised_at = datetime.now()
await db.flush()
await db.refresh(measurement)
return measurement
async def save_measurement(
db: AsyncSession,
subtask_id: int,
@@ -107,6 +170,21 @@ async def save_measurement(
recipe = await recipe_of_version(db, version_id)
_enforce_recipe_rules(recipe, lot_number, serial_number, input_method)
# No moving on while a quote is out of tolerance and unauthorised. Measuring
# that same quote again is the way out that does not need the capoturno; the
# next quote is not.
blocking = await pending_authorisation(
db, version_id, measured_by, production_run_id,
)
if blocking is not None and blocking.subtask_id != subtask_id:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=(
"A measurement is out of tolerance and awaiting the supervisor: "
"authorise it or measure that quote again before going on"
),
)
pass_fail, deviation = calculate_pass_fail(value, subtask)
measurement = Measurement(
@@ -55,6 +55,11 @@ HEADER = [
"input_duration_ms",
"measured_by",
"measured_at",
# A value outside tolerance that was allowed to stand carries the name of who
# allowed it. Without these two columns the file would show the failure and
# not the decision, which is the half an auditor asks about.
"authorised_by",
"authorised_at",
]
@@ -126,6 +131,8 @@ async def build_statistics_csv(db: AsyncSession, run: ProductionRun) -> str:
"" if measurement.input_duration_ms is None else measurement.input_duration_ms,
measurement.measured_by,
measurement.measured_at.isoformat() if measurement.measured_at else "",
measurement.supervisor_id or "",
measurement.authorised_at.isoformat() if measurement.authorised_at else "",
])
return output.getvalue()
+21 -22
View File
@@ -26,7 +26,9 @@ from src.backend.models.orm.recipe import Recipe, RecipeVersion
from src.backend.models.orm.station import Station
from src.backend.models.orm.task import MEASURING_TASK_TYPES, RecipeTask
from src.backend.models.orm.user import User
from src.backend.services import auth_service, production_export_service
from src.backend.services import (
auth_service, measurement_service, production_export_service,
)
def _now() -> datetime:
@@ -137,27 +139,9 @@ async def list_run_events(db: AsyncSession, run_id: int) -> list[ProductionEvent
# ---------------------------------------------------------------------------
# Supervisor authorisation
# ---------------------------------------------------------------------------
async def authorise_supervisor(db: AsyncSession, username: str, password: str) -> User:
"""Check the credentials of the supervisor authorising a stop or a close.
Uses authenticate_user rather than a full login: logging in would rotate the
supervisor's API key and knock out whatever session they have open elsewhere.
"""
supervisor = await auth_service.authenticate_user(db, username, password)
if supervisor is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid supervisor credentials",
)
if not supervisor.has_role("Supervisor") and not supervisor.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User is not authorised as supervisor",
)
return supervisor
# The capoturno check lives in auth_service, next to the rest of the credential
# handling: a line stop, a closure and an out-of-tolerance value all ask the same
# question, and only one of the three is about production.
# ---------------------------------------------------------------------------
@@ -320,6 +304,21 @@ async def complete_cycle(
"""
_require_measurable(run)
# A cycle that contains a quote out of tolerance is not a finished cycle. This
# is the same gate as on the next measurement: closing the cycle would be
# another way of walking past it.
blocking = await measurement_service.pending_authorisation(
db, run.version_id, user.id, run.id,
)
if blocking is not None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=(
"A measurement is out of tolerance and awaiting the supervisor: "
"the cycle cannot be closed until it is authorised"
),
)
measure_ids = await measurement_task_ids(db, run.version_id)
closes_cycle = (
task_id is None or task_id not in measure_ids or task_id == measure_ids[-1]
+411
View File
@@ -0,0 +1,411 @@
"""Point 5: a quote out of tolerance stops the line until someone says otherwise.
The gate existed on screen and nowhere else - a modal that opened, checked a
password and closed, while the measurement had already been saved. Dismissing it
was enough to carry on. These tests are about the part that cannot be dismissed.
"""
from httpx import AsyncClient
from sqlalchemy import select
from src.backend.models.orm.measurement import Measurement
from src.backend.models.orm.recipe import RecipeVersion
from src.backend.models.orm.station import Station
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
from src.backend.services import auth_service
from src.backend.tests.conftest import auth_headers, create_test_recipe
GOOD = 10.0 # inside every limit of the fixture quote
OUT = 99.0 # far outside
async def _supervisor(db_session, username="capo-tol", password="TurnoPwd1"):
user = await auth_service.create_user(
db_session, username=username, password=password,
display_name="Capo Turno", roles=["Supervisor"],
)
await db_session.commit()
return user, password
async def _two_quotes(db_session, recipe_id: int):
"""The fixture recipe plus a second quote, so there is a 'next' one."""
version = (await db_session.execute(
select(RecipeVersion).where(
RecipeVersion.recipe_id == recipe_id,
RecipeVersion.is_current == True, # noqa: E712
)
)).scalar_one()
task = (await db_session.execute(
select(RecipeTask).where(RecipeTask.version_id == version.id)
)).scalars().first()
first = (await db_session.execute(
select(RecipeSubtask).where(RecipeSubtask.task_id == task.id)
)).scalars().first()
second = RecipeSubtask(
task_id=task.id, marker_number=2, description="Seconda quota",
nominal=10.0, utl=10.5, uwl=10.3, lwl=9.7, ltl=9.5, unit="mm",
)
db_session.add(second)
await db_session.commit()
await db_session.refresh(second)
return version.id, first, second
async def _measure(client, user, subtask_id, version_id, value, run_id=None):
body = {"subtask_id": subtask_id, "version_id": version_id, "value": value}
if run_id is not None:
body["production_run_id"] = run_id
return await client.post(
"/api/measurements/", headers=auth_headers(user), json=body,
)
async def _pending(client, user, version_id, run_id=None):
params = {"version_id": version_id}
if run_id is not None:
params["production_run_id"] = run_id
return await client.get(
"/api/measurements/pending-authorisation",
headers=auth_headers(user), params=params,
)
# ---------------------------------------------------------------------------
# The block
# ---------------------------------------------------------------------------
async def test_next_quote_is_refused_while_one_is_out_of_tolerance(
client: AsyncClient, maker_user, measurement_tec_user, db_session,
):
recipe = await create_test_recipe(db_session, maker_user.id, code="TOL-NEXT")
version_id, first, second = await _two_quotes(db_session, recipe.id)
bad = await _measure(client, measurement_tec_user, first.id, version_id, OUT)
assert bad.status_code == 200
assert bad.json()["pass_fail"] == "fail"
refused = await _measure(client, measurement_tec_user, second.id, version_id, GOOD)
assert refused.status_code == 409
assert "supervisor" in refused.json()["detail"].lower()
async def test_the_same_quote_can_be_measured_again(
client: AsyncClient, maker_user, measurement_tec_user, db_session,
):
"""The caliper slips, the piece is reseated. Trying again is not going on."""
recipe = await create_test_recipe(db_session, maker_user.id, code="TOL-AGAIN")
version_id, first, _second = await _two_quotes(db_session, recipe.id)
await _measure(client, measurement_tec_user, first.id, version_id, OUT)
again = await _measure(client, measurement_tec_user, first.id, version_id, OUT)
assert again.status_code == 200, "rimisurare la stessa quota deve restare possibile"
async def test_a_good_reading_of_the_same_quote_releases_the_block(
client: AsyncClient, maker_user, measurement_tec_user, db_session,
):
"""Only the latest reading of a quote counts: it is in tolerance now."""
recipe = await create_test_recipe(db_session, maker_user.id, code="TOL-FIXED")
version_id, first, second = await _two_quotes(db_session, recipe.id)
await _measure(client, measurement_tec_user, first.id, version_id, OUT)
await _measure(client, measurement_tec_user, first.id, version_id, GOOD)
ahead = await _measure(client, measurement_tec_user, second.id, version_id, GOOD)
assert ahead.status_code == 200
# Both readings stay on record - how many are allowed is point 4's business.
rows = (await db_session.execute(
select(Measurement).where(Measurement.subtask_id == first.id)
)).scalars().all()
assert len(rows) == 2
async def test_a_warning_does_not_block(
client: AsyncClient, maker_user, measurement_tec_user, db_session,
):
"""Outside the warning limits but inside tolerance is still inside tolerance."""
recipe = await create_test_recipe(db_session, maker_user.id, code="TOL-WARN")
version_id, first, second = await _two_quotes(db_session, recipe.id)
warned = await _measure(client, measurement_tec_user, first.id, version_id, 10.4)
assert warned.json()["pass_fail"] == "warning"
ahead = await _measure(client, measurement_tec_user, second.id, version_id, GOOD)
assert ahead.status_code == 200
async def test_pending_authorisation_names_the_blocking_quote(
client: AsyncClient, maker_user, measurement_tec_user, db_session,
):
"""What the screen asks on load, so a reload is not a way past the gate."""
recipe = await create_test_recipe(db_session, maker_user.id, code="TOL-PEND")
version_id, first, _second = await _two_quotes(db_session, recipe.id)
assert (await _pending(client, measurement_tec_user, version_id)).json() is None
bad = (await _measure(
client, measurement_tec_user, first.id, version_id, OUT,
)).json()
pending = (await _pending(client, measurement_tec_user, version_id)).json()
assert pending["id"] == bad["id"]
assert pending["subtask_id"] == first.id
# ---------------------------------------------------------------------------
# The authorisation
# ---------------------------------------------------------------------------
async def test_supervisor_authorisation_is_recorded_and_releases_the_block(
client: AsyncClient, maker_user, measurement_tec_user, db_session,
):
recipe = await create_test_recipe(db_session, maker_user.id, code="TOL-AUTH")
version_id, first, second = await _two_quotes(db_session, recipe.id)
supervisor, password = await _supervisor(db_session)
bad = (await _measure(
client, measurement_tec_user, first.id, version_id, OUT,
)).json()
authorised = await client.post(
f"/api/measurements/{bad['id']}/authorise",
headers=auth_headers(measurement_tec_user),
json={
"supervisor_username": supervisor.username,
"supervisor_password": password,
},
)
assert authorised.status_code == 200, authorised.text
body = authorised.json()
# Who said yes is on the measurement itself, which is the whole point.
assert body["supervisor_id"] == supervisor.id
assert body["authorised_at"] is not None
ahead = await _measure(client, measurement_tec_user, second.id, version_id, GOOD)
assert ahead.status_code == 200
async def test_authorisation_refuses_someone_who_is_not_a_supervisor(
client: AsyncClient, maker_user, measurement_tec_user, db_session,
):
recipe = await create_test_recipe(db_session, maker_user.id, code="TOL-NOSUP")
version_id, first, second = await _two_quotes(db_session, recipe.id)
bad = (await _measure(
client, measurement_tec_user, first.id, version_id, OUT,
)).json()
resp = await client.post(
f"/api/measurements/{bad['id']}/authorise",
headers=auth_headers(measurement_tec_user),
json={
"supervisor_username": maker_user.username,
"supervisor_password": "testpassword123",
},
)
assert resp.status_code in (401, 403)
# And the block is still there.
refused = await _measure(client, measurement_tec_user, second.id, version_id, GOOD)
assert refused.status_code == 409
async def test_authorisation_refuses_wrong_credentials(
client: AsyncClient, maker_user, measurement_tec_user, db_session,
):
recipe = await create_test_recipe(db_session, maker_user.id, code="TOL-BADPW")
version_id, first, _second = await _two_quotes(db_session, recipe.id)
supervisor, _password = await _supervisor(db_session, username="capo-badpw")
bad = (await _measure(
client, measurement_tec_user, first.id, version_id, OUT,
)).json()
resp = await client.post(
f"/api/measurements/{bad['id']}/authorise",
headers=auth_headers(measurement_tec_user),
json={
"supervisor_username": supervisor.username,
"supervisor_password": "sbagliata",
},
)
assert resp.status_code == 401
async def test_there_is_nothing_to_authorise_on_a_good_measurement(
client: AsyncClient, maker_user, measurement_tec_user, db_session,
):
recipe = await create_test_recipe(db_session, maker_user.id, code="TOL-GOOD")
version_id, first, _second = await _two_quotes(db_session, recipe.id)
supervisor, password = await _supervisor(db_session, username="capo-good")
good = (await _measure(
client, measurement_tec_user, first.id, version_id, GOOD,
)).json()
resp = await client.post(
f"/api/measurements/{good['id']}/authorise",
headers=auth_headers(measurement_tec_user),
json={
"supervisor_username": supervisor.username,
"supervisor_password": password,
},
)
assert resp.status_code == 409
# ---------------------------------------------------------------------------
# The production run
# ---------------------------------------------------------------------------
async def test_cycle_cannot_close_over_an_unauthorised_fail(
client: AsyncClient, maker_user, measurement_tec_user, admin_user, db_session,
):
"""Closing the cycle would be another way of walking past the quote."""
recipe = await create_test_recipe(db_session, maker_user.id, code="TOL-CYC")
recipe.measurement_interval_minutes = 10
station = Station(
code="ST-TOL", name="Stazione", active=True, created_by=admin_user.id,
)
db_session.add(station)
await db_session.commit()
version_id, first, _second = await _two_quotes(db_session, recipe.id)
run = (await client.post(
"/api/production-runs",
headers=auth_headers(measurement_tec_user),
json={"station_code": station.code, "recipe_id": recipe.id},
)).json()
await _measure(
client, measurement_tec_user, first.id, version_id, OUT, run_id=run["id"],
)
refused = await client.post(
f"/api/production-runs/{run['id']}/cycle",
headers=auth_headers(measurement_tec_user),
)
assert refused.status_code == 409
supervisor, password = await _supervisor(db_session, username="capo-cyc")
blocking = (await _pending(
client, measurement_tec_user, version_id, run_id=run["id"],
)).json()
await client.post(
f"/api/measurements/{blocking['id']}/authorise",
headers=auth_headers(measurement_tec_user),
json={
"supervisor_username": supervisor.username,
"supervisor_password": password,
},
)
allowed = await client.post(
f"/api/production-runs/{run['id']}/cycle",
headers=auth_headers(measurement_tec_user),
)
assert allowed.status_code == 200
assert allowed.json()["cycle_count"] == 1
async def test_the_statistics_file_carries_the_decision(
client: AsyncClient, maker_user, measurement_tec_user, admin_user, db_session,
tmp_path, monkeypatch,
):
"""The file has to show the failure and who let it stand, not just the failure."""
from src.backend.config import settings
monkeypatch.setattr(
type(settings), "upload_path", property(lambda self: tmp_path), raising=False,
)
recipe = await create_test_recipe(db_session, maker_user.id, code="TOL-CSV")
station = Station(
code="ST-CSV", name="Stazione", active=True, created_by=admin_user.id,
)
db_session.add(station)
await db_session.commit()
version_id, first, _second = await _two_quotes(db_session, recipe.id)
supervisor, password = await _supervisor(db_session, username="capo-csv")
creds = {
"supervisor_username": supervisor.username, "supervisor_password": password,
}
run = (await client.post(
"/api/production-runs",
headers=auth_headers(measurement_tec_user),
json={"station_code": station.code, "recipe_id": recipe.id},
)).json()
bad = (await _measure(
client, measurement_tec_user, first.id, version_id, OUT, run_id=run["id"],
)).json()
await client.post(
f"/api/measurements/{bad['id']}/authorise",
headers=auth_headers(measurement_tec_user), json=creds,
)
closed = (await client.post(
f"/api/production-runs/{run['id']}/close",
headers=auth_headers(measurement_tec_user), json=creds,
)).json()
text = (tmp_path / closed["statistics_path"]).read_text(encoding="utf-8-sig")
header, row = [line for line in text.splitlines() if line.strip()]
assert "authorised_by" in header and "authorised_at" in header
assert str(supervisor.id) in row
async def test_a_fail_from_another_production_does_not_block_this_one(
client: AsyncClient, maker_user, measurement_tec_user, admin_user, db_session,
tmp_path, monkeypatch,
):
"""The run is the boundary: an old production must not stop a new one."""
# Closing a run writes the statistics file; pointed at tmp_path so the test
# does not leave one in the repository's uploads directory.
from src.backend.config import settings
monkeypatch.setattr(
type(settings), "upload_path", property(lambda self: tmp_path), raising=False,
)
recipe = await create_test_recipe(db_session, maker_user.id, code="TOL-SCOPE")
station = Station(
code="ST-SCOPE", name="Stazione", active=True, created_by=admin_user.id,
)
db_session.add(station)
await db_session.commit()
version_id, first, second = await _two_quotes(db_session, recipe.id)
first_run = (await client.post(
"/api/production-runs",
headers=auth_headers(measurement_tec_user),
json={"station_code": station.code, "recipe_id": recipe.id},
)).json()
await _measure(
client, measurement_tec_user, first.id, version_id, OUT,
run_id=first_run["id"],
)
supervisor, password = await _supervisor(db_session, username="capo-scope")
await client.post(
f"/api/production-runs/{first_run['id']}/close",
headers=auth_headers(measurement_tec_user),
json={
"supervisor_username": supervisor.username,
"supervisor_password": password,
},
)
second_run = (await client.post(
"/api/production-runs",
headers=auth_headers(measurement_tec_user),
json={"station_code": station.code, "recipe_id": recipe.id},
)).json()
resp = await _measure(
client, measurement_tec_user, second.id, version_id, GOOD,
run_id=second_run["id"],
)
assert resp.status_code == 200