diff --git a/.gitignore b/.gitignore index 9ca9484..35a7cd6 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,9 @@ uploads/pdfs/* uploads/logos/* uploads/reports/* uploads/general/ +# Statistics files emitted when a production is closed: shop-floor evidence, not +# source. They live on the mounted volume in production. +uploads/statistics/ !uploads/images/.gitkeep !uploads/pdfs/.gitkeep !uploads/logos/.gitkeep diff --git a/src/backend/api/routers/measurements.py b/src/backend/api/routers/measurements.py index 66534a1..8e1225a 100644 --- a/src/backend/api/routers/measurements.py +++ b/src/backend/api/routers/measurements.py @@ -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, diff --git a/src/backend/api/routers/production.py b/src/backend/api/routers/production.py index 9cb229c..89f4c34 100644 --- a/src/backend/api/routers/production.py +++ b/src/backend/api/routers/production.py @@ -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) diff --git a/src/backend/migrations/versions/010_measurement_authorisation.py b/src/backend/migrations/versions/010_measurement_authorisation.py new file mode 100644 index 0000000..e1add16 --- /dev/null +++ b/src/backend/migrations/versions/010_measurement_authorisation.py @@ -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') diff --git a/src/backend/models/api/measurement.py b/src/backend/models/api/measurement.py index 240e4e0..ed8a26d 100644 --- a/src/backend/models/api/measurement.py +++ b/src/backend/models/api/measurement.py @@ -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 diff --git a/src/backend/models/orm/measurement.py b/src/backend/models/orm/measurement.py index 2bf1187..6fba791 100644 --- a/src/backend/models/orm/measurement.py +++ b/src/backend/models/orm/measurement.py @@ -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 diff --git a/src/backend/services/auth_service.py b/src/backend/services/auth_service.py index 9e86fb7..0128bb1 100644 --- a/src/backend/services/auth_service.py +++ b/src/backend/services/auth_service.py @@ -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() diff --git a/src/backend/services/measurement_service.py b/src/backend/services/measurement_service.py index 2ff7084..337717a 100644 --- a/src/backend/services/measurement_service.py +++ b/src/backend/services/measurement_service.py @@ -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( diff --git a/src/backend/services/production_export_service.py b/src/backend/services/production_export_service.py index 9c37c9c..0ea2b96 100644 --- a/src/backend/services/production_export_service.py +++ b/src/backend/services/production_export_service.py @@ -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() diff --git a/src/backend/services/production_service.py b/src/backend/services/production_service.py index 8588475..2df5667 100644 --- a/src/backend/services/production_service.py +++ b/src/backend/services/production_service.py @@ -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] diff --git a/src/backend/tests/test_out_of_tolerance.py b/src/backend/tests/test_out_of_tolerance.py new file mode 100644 index 0000000..b921f2c --- /dev/null +++ b/src/backend/tests/test_out_of_tolerance.py @@ -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 diff --git a/src/frontend/flask_app/blueprints/measure.py b/src/frontend/flask_app/blueprints/measure.py index 72581c3..4d94ddf 100644 --- a/src/frontend/flask_app/blueprints/measure.py +++ b/src/frontend/flask_app/blueprints/measure.py @@ -397,31 +397,53 @@ def save_measurement(): # --------------------------------------------------------------------------- -# Route: Validate supervisor credentials (AJAX) +# Routes: Out-of-tolerance authorisation (point 5) # --------------------------------------------------------------------------- -@measure_bp.route("/validate-supervisor", methods=["POST"]) +@measure_bp.route("/api/measurements/pending-authorisation", methods=["GET"]) @login_required @role_required("MeasurementTec") -def validate_supervisor(): - """Validate supervisor (capoturno) credentials for out-of-tolerance authorization.""" +def api_pending_authorisation(): + """Proxy: the out-of-tolerance measurement holding this operator up, or null. + + Asked when the measurement screen loads, so reloading the page finds the same + block the server enforces rather than a clean slate. + """ + params = {"version_id": request.args.get("version_id")} + run_id = request.args.get("production_run_id") + if run_id: + params["production_run_id"] = run_id + + resp = api_client.get("/api/measurements/pending-authorisation", params=params) + if isinstance(resp, dict) and resp.get("error"): + return jsonify(resp), resp.get("status_code", 500) + return jsonify(resp), 200 + + +@measure_bp.route("/api/measurements//authorise", methods=["POST"]) +@login_required +@role_required("MeasurementTec") +def api_authorise_measurement(measurement_id: int): + """Proxy: the capoturno lets an out-of-tolerance value stand. + + The credentials go to the endpoint that records the approval on the + measurement. Checking them separately, as this used to, left the approval + written down nowhere and nothing depending on it. + """ data = request.get_json(silent=True) or {} - username = data.get("username", "").strip() - password = data.get("password", "") - + username = (data.get("supervisor_username") or "").strip() + password = data.get("supervisor_password") or "" if not username or not password: - return jsonify({"error": True, "detail": _("Username e password richiesti")}), 400 + return jsonify({ + "error": True, "detail": _("Username e password richiesti"), + }), 400 - resp = api_client.post("/api/auth/login", data={"username": username, "password": password}) - - if resp.get("error"): - return jsonify({"error": True, "detail": _("Credenziali non valide")}), 401 - - user = resp.get("user", {}) - is_supervisor = "Supervisor" in (user.get("roles") or []) - if not (is_supervisor or user.get("is_admin")): - return jsonify({"error": True, "detail": _("Utente non autorizzato (richiesto capoturno)")}), 403 - - return jsonify({"authorized": True, "supervisor": user.get("display_name", username)}), 200 + resp = api_client.post( + f"/api/measurements/{measurement_id}/authorise", + data={"supervisor_username": username, "supervisor_password": password}, + ) + if isinstance(resp, dict) and resp.get("error"): + return jsonify(resp), resp.get("status_code", 500) + return jsonify(resp), 200 # --------------------------------------------------------------------------- diff --git a/src/frontend/flask_app/templates/measure/task_execute.html b/src/frontend/flask_app/templates/measure/task_execute.html index 3d77e71..647703b 100644 --- a/src/frontend/flask_app/templates/measure/task_execute.html +++ b/src/frontend/flask_app/templates/measure/task_execute.html @@ -493,6 +493,32 @@ + {# ================================================================ + QUOTA FUORI TOLLERANZA — nothing moves from here without the capoturno or a + new reading of this same quote. Kept on screen after the modal is dismissed, + because the block outlives the modal: the server refuses the next value. + ================================================================ #} +
+
+ + + + + {{ _('Quota fuori tolleranza') }} + + + {{ _('serve il capoturno, oppure una nuova misura della stessa quota') }} + + +
+
+ {# ================================================================ LINEA FERMA — the countdown is frozen, not merely hidden. Stated plainly because a stopped line that looks like a running one is how a measurement @@ -826,9 +852,13 @@
+ {# Out of tolerance, the way out of this modal is measuring the quote + again - not cancelling. Closing it changes nothing: the quote stays + blocked, and the banner behind says so. #}