Compare commits

..

13 Commits

Author SHA1 Message Date
Adriano dee27ac8e1 Traduzioni it/en stringhe Tràfilo (#47–#64)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 12:48:53 +02:00
Adriano 736b853e22 Fermo linea e Fine produzione anche sui task check del ciclo (#53)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VNVrf2wZAmMAkgmwEuBB36
2026-10-07 12:47:00 +02:00
Adriano 627ae15591 Merge lavoro/daitarn-trafilo-esegui-esiti 2026-10-07 12:43:57 +02:00
Adriano c6d4260ede Esecuzione: esiti OK/NOK, campi dei task note, task di chiusura prima del capoturno (#53 #54 #55)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 12:43:04 +02:00
Adriano 459d1c71ea Merge lavoro/daitarn-trafilo-esegui-pin 2026-10-07 12:39:59 +02:00
Adriano 05e3faa9d4 Merge lavoro/daitarn-trafilo-utenti-pin 2026-10-07 12:39:58 +02:00
Adriano dc94b2e778 Capoturno: autorizzazione con PIN (nome + tastierino) o password nella modale (#59)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 12:39:07 +02:00
Adriano 3c55550d93 Utenti: PIN impostabile/togliibile dall'admin, link Monitor nel menu (#51 #59 #57)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 12:38:58 +02:00
Adriano e07c4d4eee Merge lavoro/daitarn-trafilo-editor-task 2026-10-07 12:38:57 +02:00
Adriano 0b45f1c35a Merge lavoro/daitarn-trafilo-editor-quote 2026-10-07 12:38:56 +02:00
Adriano 743f246cbf Merge lavoro/daitarn-trafilo-pin 2026-10-07 12:38:56 +02:00
Adriano f541f4a810 Editor task: tipo check, ciclo di misura, nota a fine produzione con campi (#53 #54 #55)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 12:38:05 +02:00
Adriano 07e23b957d PIN a 4 cifre: migrazione 017, login-pin, pin-users, PUT pin, capoturno via PIN (#59)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 12:27:31 +02:00
29 changed files with 5811 additions and 2081 deletions
+38 -1
View File
@@ -1,5 +1,6 @@
"""Authentication router - login, logout, profile."""
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.database import get_db
@@ -8,10 +9,17 @@ from src.backend.models.orm.user import User
from src.backend.models.api.user import (
LoginRequest,
LoginResponse,
PinLoginRequest,
PinUserItem,
UserProfileUpdate,
UserResponse,
)
from src.backend.services.auth_service import authenticate_user, login_user, logout_user
from src.backend.services.auth_service import (
authenticate_pin,
authenticate_user,
login_user,
logout_user,
)
router = APIRouter(prefix="/api/auth", tags=["auth"])
@@ -32,6 +40,35 @@ async def login(data: LoginRequest, db: AsyncSession = Depends(get_db)):
)
@router.get("/pin-users", response_model=list[PinUserItem])
async def pin_users(db: AsyncSession = Depends(get_db)):
"""Utenti che possono entrare con PIN (#59): attivi, non admin, con PIN.
Pubblico per costruzione (serve prima del login): espone solo id e nome.
"""
result = await db.execute(
select(User).where(
User.active == True, # noqa: E712
User.is_admin == False, # noqa: E712
User.pin_hash.is_not(None),
)
)
items = [
PinUserItem(id=u.id, display_name=u.display_name or u.username)
for u in result.scalars().all()
]
items.sort(key=lambda i: i.display_name.casefold())
return items
@router.post("/login-pin", response_model=LoginResponse)
async def login_pin(data: PinLoginRequest, db: AsyncSession = Depends(get_db)):
"""Login con user_id e PIN a 4 cifre (#59); stessa risposta del login."""
user = await authenticate_pin(db, data.user_id, data.pin)
api_key = await login_user(db, user)
return LoginResponse(user=UserResponse.model_validate(user), api_key=api_key)
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
async def logout(
request: Request,
+1
View File
@@ -147,6 +147,7 @@ async def authorise_measurement(
supervisor = await auth_service.authorise_supervisor(
db, action.supervisor_username, action.supervisor_password,
user_id=action.supervisor_user_id, pin=action.supervisor_pin, # #59
)
measurement = await measurement_service.authorise_measurement(
db, measurement, supervisor,
+3
View File
@@ -163,6 +163,7 @@ async def pause_production_run(
run = await production_service.get_run(db, run_id)
supervisor = await auth_service.authorise_supervisor(
db, action.supervisor_username, action.supervisor_password,
user_id=action.supervisor_user_id, pin=action.supervisor_pin, # #59
)
run = await production_service.pause_run(db, run, user, supervisor, note=action.note)
return await _as_response(db, run)
@@ -179,6 +180,7 @@ async def resume_production_run(
run = await production_service.get_run(db, run_id)
supervisor = await auth_service.authorise_supervisor(
db, action.supervisor_username, action.supervisor_password,
user_id=action.supervisor_user_id, pin=action.supervisor_pin, # #59
)
run = await production_service.resume_run(db, run, user, supervisor, note=action.note)
return await _as_response(db, run)
@@ -195,6 +197,7 @@ async def close_production_run(
run = await production_service.get_run(db, run_id)
supervisor = await auth_service.authorise_supervisor(
db, action.supervisor_username, action.supervisor_password,
user_id=action.supervisor_user_id, pin=action.supervisor_pin, # #59
)
run = await production_service.close_run(db, run, user, supervisor, note=action.note)
return await _as_response(db, run)
+32 -2
View File
@@ -6,8 +6,14 @@ from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.database import get_db
from src.backend.api.middleware.api_key import require_admin_user
from src.backend.models.orm.user import User
from src.backend.models.api.user import UserCreate, UserPasswordChange, UserResponse, UserUpdate
from src.backend.services.auth_service import create_user, hash_password, regenerate_api_key, revoke_all_keys
from src.backend.models.api.user import PinSet, UserCreate, UserPasswordChange, UserResponse, UserUpdate
from src.backend.services.auth_service import (
create_user,
hash_password,
regenerate_api_key,
revoke_all_keys,
set_user_pin,
)
router = APIRouter(prefix="/api/users", tags=["users"])
@@ -151,6 +157,30 @@ async def change_user_password(
return {"message": f"Password changed for user {user.username}"}
@router.put("/{user_id}/pin", status_code=status.HTTP_200_OK)
async def change_user_pin(
user_id: int,
data: PinSet,
admin: User = Depends(require_admin_user),
db: AsyncSession = Depends(get_db),
):
"""Imposta o toglie (pin null) il PIN di un utente (admin, #59/#51).
422 se l'utente e' admin. Il PIN non si restituisce mai.
"""
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
if user.is_admin:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Admin users cannot have a PIN",
)
await set_user_pin(db, user, data.pin)
return {"message": f"PIN {'set' if data.pin else 'removed'} for user {user.username}"}
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def deactivate_user(
user_id: int,
@@ -0,0 +1,34 @@
"""users: PIN a 4 cifre per login rapido e capoturno (#59)
pin_hash (bcrypt, mai in chiaro), contatore errori consecutivi e fine blocco.
Revision ID: 017_user_pin
Revises: 016_vision_recipe_dir
Create Date: 2026-10-07
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '017_user_pin'
down_revision: Union[str, None] = '016_vision_recipe_dir'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# batch: SQLite non sa fare DROP COLUMN nativo su tutte le versioni
with op.batch_alter_table('users') as batch:
batch.add_column(sa.Column('pin_hash', sa.String(255), nullable=True))
batch.add_column(sa.Column(
'pin_failed_count', sa.Integer(), nullable=False, server_default='0'))
batch.add_column(sa.Column('pin_locked_until', sa.DateTime(), nullable=True))
def downgrade() -> None:
with op.batch_alter_table('users') as batch:
batch.drop_column('pin_locked_until')
batch.drop_column('pin_failed_count')
batch.drop_column('pin_hash')
+16 -3
View File
@@ -2,7 +2,7 @@
from src.backend.models.api.tipi import UtcDateTime, UtcNaiveInput
from typing import Optional
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, model_validator
class MeasurementCreate(BaseModel):
@@ -71,8 +71,21 @@ class MeasurementListResponse(BaseModel):
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)
# #59: o username+password, oppure supervisor_user_id+supervisor_pin
supervisor_username: Optional[str] = Field(None, min_length=1)
supervisor_password: Optional[str] = Field(None, min_length=1)
supervisor_user_id: Optional[int] = Field(None, gt=0)
supervisor_pin: Optional[str] = Field(None, pattern=r"^[0-9]{4}$")
@model_validator(mode="after")
def _one_credential_pair(self):
by_password = self.supervisor_username and self.supervisor_password
by_pin = self.supervisor_user_id and self.supervisor_pin
if not (by_password or by_pin):
raise ValueError(
"supervisor credentials required: username+password or user_id+pin"
)
return self
class TaskProgressResponse(BaseModel):
+16 -3
View File
@@ -2,7 +2,7 @@
from src.backend.models.api.tipi import UtcDateTime
from typing import Literal, Optional
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, model_validator
class ProductionRunCreate(BaseModel):
@@ -64,8 +64,21 @@ class ProductionRunWithEventsResponse(ProductionRunResponse):
class SupervisorAction(BaseModel):
"""Credentials of the supervisor authorising a line stop or a close."""
supervisor_username: str = Field(..., min_length=1)
supervisor_password: str = Field(..., min_length=1)
# #59: o username+password, oppure supervisor_user_id+supervisor_pin
supervisor_username: Optional[str] = Field(None, min_length=1)
supervisor_password: Optional[str] = Field(None, min_length=1)
supervisor_user_id: Optional[int] = Field(None, gt=0)
supervisor_pin: Optional[str] = Field(None, pattern=r"^[0-9]{4}$")
@model_validator(mode="after")
def _one_credential_pair(self):
by_password = self.supervisor_username and self.supervisor_password
by_pin = self.supervisor_user_id and self.supervisor_pin
if not (by_password or by_pin):
raise ValueError(
"supervisor credentials required: username+password or user_id+pin"
)
return self
note: Optional[str] = None
+20
View File
@@ -50,6 +50,8 @@ class UserResponse(BaseModel):
active: bool
created_at: UtcDateTime
last_login: Optional[UtcDateTime] = None
# #59: solo il fatto che il PIN esista, mai l'hash
has_pin: bool = False
class UserPasswordChange(BaseModel):
@@ -68,3 +70,21 @@ class LoginResponse(BaseModel):
user: UserResponse
api_key: str
message: str = "Login successful"
class PinUserItem(BaseModel):
"""Voce dell'elenco pubblico per il login con PIN (#59): solo id e nome."""
id: int
display_name: str
class PinLoginRequest(BaseModel):
"""Login con PIN (#59)."""
user_id: int
pin: str = Field(..., pattern=r"^[0-9]{4}$")
class PinSet(BaseModel):
"""Imposta (4 cifre) o toglie (null) il PIN di un utente (#59)."""
pin: Optional[str] = Field(None, pattern=r"^[0-9]{4}$")
+12
View File
@@ -22,6 +22,13 @@ class User(Base):
# Admin flag separate from roles
is_admin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
# PIN a 4 cifre (#59): solo l'hash; contatore errori e fine blocco per il lockout
pin_hash: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
pin_failed_count: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default="0"
)
pin_locked_until: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
# User preferences
language_pref: Mapped[str] = mapped_column(
Enum("it", "en", name="language_enum"), nullable=False, default="it"
@@ -39,6 +46,11 @@ class User(Base):
)
last_login: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
@property
def has_pin(self) -> bool:
"""True se l'utente ha un PIN impostato (#59); serve a UserResponse.has_pin."""
return self.pin_hash is not None
def has_role(self, role: str) -> bool:
"""Check if user has a specific role."""
return role in (self.roles or [])
+83 -2
View File
@@ -54,7 +54,79 @@ async def authenticate_user(
return user
async def authorise_supervisor(db: AsyncSession, username: str, password: str) -> User:
# #59: PIN a 4 cifre. Hash identico alle password (bcrypt); 5 errori -> blocco 15 minuti.
PIN_MAX_FAILURES = 5
PIN_LOCK_MINUTES = 15
async def set_user_pin(db: AsyncSession, user: User, pin: str | None) -> None:
"""Imposta o toglie (None) il PIN; azzera contatore e blocco. Admin esclusi (#59)."""
if pin is not None and user.is_admin:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Admin users cannot have a PIN",
)
user.pin_hash = hash_password(pin) if pin is not None else None
user.pin_failed_count = 0
user.pin_locked_until = None
await db.flush()
async def authenticate_pin(db: AsyncSession, user_id: int, pin: str) -> User:
"""Verifica user_id+PIN con blocco dopo 5 errori consecutivi (#59).
401 PIN errato (o utente non idoneo: stessa risposta, niente enumerazione),
423 utente bloccato. Il contatore va committato prima di sollevare: get_db fa
rollback su eccezione e l'errore andrebbe perso.
"""
result = await db.execute(
select(User).where(User.id == user_id, User.active == True) # noqa: E712
)
user = result.scalar_one_or_none()
if user is None or user.is_admin or user.pin_hash is None:
verify_password(pin, _DUMMY_PASSWORD_HASH) # stesso costo di un PIN errato
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid PIN"
)
now = utcnow()
if user.pin_locked_until is not None:
if user.pin_locked_until > now:
raise HTTPException(
status_code=status.HTTP_423_LOCKED,
detail="PIN locked until "
+ user.pin_locked_until.strftime("%Y-%m-%d %H:%M:%S")
+ " UTC",
)
# blocco scaduto: si riparte da zero
user.pin_locked_until = None
user.pin_failed_count = 0
if verify_password(pin, user.pin_hash):
if user.pin_failed_count:
user.pin_failed_count = 0
await db.flush()
return user
user.pin_failed_count += 1
locked = user.pin_failed_count >= PIN_MAX_FAILURES
if locked:
user.pin_locked_until = now + timedelta(minutes=PIN_LOCK_MINUTES)
await db.commit()
if locked:
raise HTTPException(
status_code=status.HTTP_423_LOCKED,
detail="PIN locked until "
+ user.pin_locked_until.strftime("%Y-%m-%d %H:%M:%S")
+ " UTC",
)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid PIN")
async def authorise_supervisor(
db: AsyncSession,
username: str | None = None,
password: str | None = None,
user_id: int | None = None,
pin: str | None = None,
) -> User:
"""Check the credentials of the supervisor authorising something.
Used wherever the shop floor needs a capoturno: a line stop, a closure, an
@@ -65,7 +137,16 @@ async def authorise_supervisor(db: AsyncSession, username: str, password: str) -
Uses authenticate_user rather than a full login: logging in would issue a
needless API key for the supervisor.
"""
supervisor = await authenticate_user(db, username, password)
if user_id is not None and pin is not None:
# #59: via PIN, stesso blocco del login con PIN (401/423 sollevati da li')
supervisor = await authenticate_pin(db, user_id, pin)
elif username is not None and password is not None:
supervisor = await authenticate_user(db, username, password)
else:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Supervisor credentials required",
)
if supervisor is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
+19
View File
@@ -149,3 +149,22 @@ class TestVisionRecipeDirMigration:
assert "vision_recipe_dir" not in cols()
command.upgrade(cfg, "head") # e di nuovo su
assert "vision_recipe_dir" in cols()
class TestUserPinMigration:
"""017_user_pin: users.pin_hash / pin_failed_count / pin_locked_until, su e giu (#59)."""
def test_017_upgrade_and_downgrade(self, sqlite_database_url):
db_path, _ = sqlite_database_url
cfg = _alembic_config()
new = {"pin_hash", "pin_failed_count", "pin_locked_until"}
def cols():
return {c["name"] for c in _inspect(db_path).get_columns("users")}
command.upgrade(cfg, "head")
assert new <= cols()
command.downgrade(cfg, "016_vision_recipe_dir")
assert not (new & cols())
command.upgrade(cfg, "head") # e di nuovo su
assert new <= cols()
+167
View File
@@ -0,0 +1,167 @@
"""PIN a 4 cifre: login rapido, blocco, capoturno via PIN (#59)."""
from datetime import timedelta
import pytest
from fastapi import HTTPException
from httpx import AsyncClient
from sqlalchemy import select
from src.backend.models.orm.user import User
from src.backend.services import auth_service
from src.backend.tests.conftest import _create_user, auth_headers
from src.backend.tests.test_production_runs import (
_open, _recipe_with_interval, _station,
)
from src.backend.utils.tempo import utcnow
async def _pin_user(db_session, username="operaio", pin="1234", **kw) -> User:
user = await _create_user(db_session, username=username, **kw)
await auth_service.set_user_pin(db_session, user, pin)
await db_session.commit()
return user
async def _login_pin(client, user_id, pin):
return await client.post("/api/auth/login-pin", json={"user_id": user_id, "pin": pin})
async def test_login_pin_ok_same_shape_as_login(client: AsyncClient, db_session):
user = await _pin_user(db_session)
resp = await _login_pin(client, user.id, "1234")
assert resp.status_code == 200
data = resp.json()
assert data["user"]["username"] == "operaio"
assert data["user"]["has_pin"] is True
assert data["api_key"]
assert "pin_hash" not in data["user"]
# la api_key vale davvero
me = await client.get("/api/auth/me", headers={"X-API-Key": data["api_key"]})
assert me.status_code == 200
async def test_login_pin_wrong_is_401(client: AsyncClient, db_session):
user = await _pin_user(db_session)
assert (await _login_pin(client, user.id, "9999")).status_code == 401
async def test_login_pin_format_422(client: AsyncClient, db_session):
user = await _pin_user(db_session)
for bad in ("123", "12345", "12a4", ""):
assert (await _login_pin(client, user.id, bad)).status_code == 422
async def test_lock_after_five_errors_even_with_right_pin(client: AsyncClient, db_session):
user = await _pin_user(db_session)
for _ in range(4):
assert (await _login_pin(client, user.id, "0000")).status_code == 401
resp = await _login_pin(client, user.id, "0000") # 5 errore: scatta il blocco
assert resp.status_code == 423
assert "locked until" in resp.json()["detail"]
assert (await _login_pin(client, user.id, "1234")).status_code == 423
async def test_lock_expires_and_counter_resets(client: AsyncClient, db_session):
user = await _pin_user(db_session)
for _ in range(5):
await _login_pin(client, user.id, "0000")
row = (await db_session.execute(select(User).where(User.id == user.id))).scalar_one()
await db_session.refresh(row)
assert row.pin_locked_until > utcnow() + timedelta(minutes=14)
# tempo simulato: il blocco e scaduto un secondo fa
row.pin_locked_until = utcnow() - timedelta(seconds=1)
await db_session.commit()
assert (await _login_pin(client, user.id, "1234")).status_code == 200
await db_session.refresh(row)
assert row.pin_failed_count == 0 and row.pin_locked_until is None
async def test_success_resets_failed_counter(client: AsyncClient, db_session):
user = await _pin_user(db_session)
for _ in range(3):
await _login_pin(client, user.id, "0000")
assert (await _login_pin(client, user.id, "1234")).status_code == 200
for _ in range(4): # il contatore e ripartito da zero: 4 errori non bloccano
assert (await _login_pin(client, user.id, "0000")).status_code == 401
async def test_admin_cannot_have_pin_or_login_with_it(client: AsyncClient, admin_user, db_session):
resp = await client.put(
f"/api/users/{admin_user.id}/pin", headers=auth_headers(admin_user), json={"pin": "1234"},
)
assert resp.status_code == 422
# anche un admin con pin_hash scritto a mano non entra da login-pin
admin_user.pin_hash = auth_service.hash_password("1234")
await db_session.commit()
assert (await _login_pin(client, admin_user.id, "1234")).status_code == 401
listed = (await client.get("/api/auth/pin-users")).json()
assert all(u["id"] != admin_user.id for u in listed)
async def test_put_pin_set_and_remove(client: AsyncClient, admin_user, db_session):
user = await _create_user(db_session, username="senzapin")
await db_session.commit()
h = auth_headers(admin_user)
assert (await client.put(f"/api/users/{user.id}/pin", headers=h, json={"pin": "4321"})).status_code == 200
assert (await _login_pin(client, user.id, "4321")).status_code == 200
assert (await client.put(f"/api/users/{user.id}/pin", headers=h, json={"pin": "12"})).status_code == 422
assert (await client.put(f"/api/users/{user.id}/pin", headers=h, json={"pin": None})).status_code == 200
assert (await _login_pin(client, user.id, "4321")).status_code == 401
assert (await client.put("/api/users/99999/pin", headers=h, json={"pin": "1234"})).status_code == 404
# non admin: 403
assert (await client.put(
f"/api/users/{user.id}/pin", headers=auth_headers(user), json={"pin": "1234"},
)).status_code == 403
async def test_pin_users_list(client: AsyncClient, db_session):
a = await _pin_user(db_session, username="zeta", display_name="Zeta Rossi", pin="1111")
b = await _pin_user(db_session, username="alfa", display_name="Alfa Bianchi", pin="2222")
await _create_user(db_session, username="nopin")
await _pin_user(db_session, username="inattivo", pin="3333", active=False)
await db_session.commit()
resp = await client.get("/api/auth/pin-users") # senza autenticazione
assert resp.status_code == 200
data = resp.json()
assert [u["display_name"] for u in data] == ["Alfa Bianchi", "Zeta Rossi"]
assert all(set(u) == {"id", "display_name"} for u in data)
assert {u["id"] for u in data} == {a.id, b.id}
async def test_supervisor_via_pin(client: AsyncClient, measurement_tec_user, admin_user, db_session):
station = await _station(db_session, admin_user.id, code="ST-PIN")
recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-PIN")
run_id = (await _open(client, measurement_tec_user, station, recipe)).json()["id"]
capo = await _pin_user(db_session, username="capo-pin", pin="5555", roles=["Supervisor"])
h = auth_headers(measurement_tec_user)
bad = await client.post(
f"/api/production-runs/{run_id}/pause", headers=h,
json={"supervisor_user_id": capo.id, "supervisor_pin": "0000"},
)
assert bad.status_code == 401
ok = await client.post(
f"/api/production-runs/{run_id}/pause", headers=h,
json={"supervisor_user_id": capo.id, "supervisor_pin": "5555"},
)
assert ok.status_code == 200
# nessuna coppia di credenziali: 422
none = await client.post(f"/api/production-runs/{run_id}/resume", headers=h, json={})
assert none.status_code == 422
async def test_supervisor_pin_role_still_checked(db_session):
plain = await _pin_user(db_session, username="non-capo", pin="6666", roles=["Maker"])
with pytest.raises(HTTPException) as e:
await auth_service.authorise_supervisor(db_session, user_id=plain.id, pin="6666")
assert e.value.status_code == 403
async def test_supervisor_pin_lockout(db_session):
capo = await _pin_user(db_session, username="capo-lock", pin="7777", roles=["Supervisor"])
codes = []
for pin in ["0000"] * 5 + ["7777"]:
with pytest.raises(HTTPException) as e:
await auth_service.authorise_supervisor(db_session, user_id=capo.id, pin=pin)
codes.append(e.value.status_code)
assert codes == [401, 401, 401, 401, 423, 423]
@@ -138,6 +138,21 @@ def api_change_password(user_id: int):
return jsonify(resp), 200
@admin_bp.route("/api/users/<int:user_id>/pin", methods=["PUT"])
@login_required
@admin_required
def api_set_pin(user_id: int):
"""Proxy: set (4 digits) or remove ({"pin": null}) a user's PIN (#59)."""
data = request.get_json(silent=True) or {}
# Forward only the pin field; backend answers 422 for admins / malformed PINs.
resp = api_client.put(f"/api/users/{user_id}/pin", data={"pin": data.get("pin")})
if isinstance(resp, dict) and resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 200
@admin_bp.route("/api/users/<int:user_id>/toggle-active", methods=["POST"])
@login_required
@admin_required
+163 -32
View File
@@ -5,7 +5,7 @@ import requests as http_requests
from flask import (
Blueprint, Response, abort, flash, jsonify, redirect, render_template,
request, session, url_for,
g, request, session, url_for,
)
from flask_babel import gettext as _
@@ -654,6 +654,74 @@ def api_pending_authorisation():
return jsonify(resp), 200
def _supervisor_credentials(data: dict):
"""The capoturno's credentials from the request body (#59).
Two ways in: ``supervisor_user_id`` + ``supervisor_pin`` (name and 4-digit PIN
from the touch pad) or, as before, ``supervisor_username`` +
``supervisor_password``. Returns ``(fields, None)`` with the pair to forward,
or ``(None, error_response)`` when neither pair is complete.
"""
pin = str(data.get("supervisor_pin") or "").strip()
user_id = data.get("supervisor_user_id")
if pin and user_id not in (None, ""):
try:
return {"supervisor_user_id": int(user_id), "supervisor_pin": pin}, None
except (TypeError, ValueError):
pass # user_id not a number: falls through to the missing-credentials answer
username = (data.get("supervisor_username") or "").strip()
password = data.get("supervisor_password") or ""
if username and password:
return {"supervisor_username": username, "supervisor_password": password}, None
return None, (jsonify({
"error": True, "detail": _("Username e password richiesti"),
}), 400)
def _post_supervised(endpoint: str, creds: dict, extra: dict | None = None):
"""POST an action carrying the capoturno's credentials, forwarding the answer.
api_client reads every 401 as "your API key is dead" and empties the Flask
session. For these endpoints a 401 can also be the capoturno's wrong PIN or
password, and that must not throw the operator out of their session (#59):
when the detail names the supervisor or the PIN, the session is put back and
the ``session_expired`` flag is dropped so the modal can say "PIN errato".
"""
saved = dict(session)
resp = api_client.post(endpoint, data={**creds, **(extra or {})})
if isinstance(resp, dict) and resp.get("error"):
detail = str(resp.get("detail") or "").lower()
if resp.get("status_code") == 401 and ("supervisor" in detail or "pin" in detail):
session.update(saved) # the 401 was about the capoturno, not about this session
g.session_expired = False
resp = {k: v for k, v in resp.items() if k != "session_expired"}
if "supervisor_pin" in creds:
resp["detail"] = _("PIN errato") # the backend's English text is not for the operator
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 200
@measure_bp.route("/api/pin-users", methods=["GET"])
@login_required
@role_required("MeasurementTec")
def api_pin_users():
"""Proxy: names for the capoturno's PIN pad (#59), id and display name only.
Empty list when the backend does not answer, so the modal falls back to the
password form instead of showing a pad nobody can use.
"""
try:
resp = api_client.get("/api/auth/pin-users")
except Exception:
return jsonify([])
if not isinstance(resp, list):
return jsonify([])
return jsonify([
{"id": u.get("id"), "display_name": u.get("display_name", "")}
for u in resp if isinstance(u, dict)
])
@measure_bp.route("/api/measurements/<int:measurement_id>/authorise", methods=["POST"])
@login_required
@role_required("MeasurementTec")
@@ -665,20 +733,10 @@ def api_authorise_measurement(measurement_id: int):
written down nowhere and nothing depending on it.
"""
data = request.get_json(silent=True) or {}
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
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
creds, error = _supervisor_credentials(data) # #59: PIN or password
if error:
return error
return _post_supervised(f"/api/measurements/{measurement_id}/authorise", creds)
# ---------------------------------------------------------------------------
@@ -770,24 +828,12 @@ def _supervised_action(run_id: int, action: str):
leave no such record, and is an extra round trip besides.
"""
data = request.get_json(silent=True) or {}
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
resp = api_client.post(
f"/api/production-runs/{run_id}/{action}",
data={
"supervisor_username": username,
"supervisor_password": password,
"note": data.get("note"),
},
creds, error = _supervisor_credentials(data) # #59: PIN or password
if error:
return error
return _post_supervised(
f"/api/production-runs/{run_id}/{action}", creds, {"note": data.get("note")},
)
if isinstance(resp, dict) and resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 200
@measure_bp.route("/api/production/<int:run_id>/pause", methods=["POST"])
@@ -814,6 +860,91 @@ def api_close_production(run_id: int):
return _supervised_action(run_id, "close")
# ---------------------------------------------------------------------------
# Routes: task results (#53 #54 #55) - outcome of a "check" task and the values /
# attachments of a "note" task with fields. The browser cannot send X-API-Key,
# so each call goes through here.
# ---------------------------------------------------------------------------
def _relay(resp, ok_status: int = 200):
"""JSON back to the browser: backend errors keep their status and detail.
The detail is what the operator reads («Per un NOK serve la nota»), so it is
passed through as it came, never replaced by a generic message.
"""
if isinstance(resp, dict) and resp.get("error"):
body = {"error": True, "detail": resp.get("detail", _("Errore nel salvataggio"))}
if resp.get("session_expired"):
body["session_expired"] = True
status_code = resp.get("status_code", 500)
return jsonify(body), status_code if status_code >= 400 else 500
return jsonify(resp), ok_status
@measure_bp.route("/api/tasks/<int:task_id>", methods=["GET"])
@login_required
@role_required("MeasurementTec")
def api_get_task(task_id: int):
"""Proxy: a task with its fields (#55: the tasks to fill at the end of production)."""
return _relay(api_client.get(f"/api/tasks/{task_id}"))
@measure_bp.route("/api/task-results", methods=["POST"])
@login_required
@role_required("MeasurementTec")
def api_save_task_result():
"""Proxy: record the result of a check / note task (#54, #55)."""
data = request.get_json(silent=True) or {}
if data.get("task_id") is None:
return jsonify({
"error": True, "detail": _("Dati mancanti: task_id è obbligatorio"),
}), 400
payload = {
"task_id": data.get("task_id"),
"production_run_id": data.get("production_run_id"),
"outcome": data.get("outcome"),
"note": data.get("note"),
"values": data.get("values"),
"attachments": data.get("attachments"),
}
return _relay(api_client.post("/api/task-results", data=payload), 201)
@measure_bp.route("/api/task-results", methods=["GET"])
@login_required
@role_required("MeasurementTec")
def api_list_task_results():
"""Proxy: results already recorded, filtered by production run and/or task."""
params = {
key: request.args[key]
for key in ("production_run_id", "task_id")
if request.args.get(key)
}
return _relay(api_client.get("/api/task-results", params=params))
@measure_bp.route("/api/attachments", methods=["POST"])
@login_required
@role_required("MeasurementTec")
def api_upload_attachment():
"""Proxy: upload an attachment of a note task (image or PDF), multipart.
Answers with the backend's reply: the `file_path` in it is what goes into the
`attachments` of the result.
"""
upload = request.files.get("file")
if upload is None or not upload.filename:
return jsonify({"error": True, "detail": _("Nessun file caricato")}), 400
resp = api_client.post(
"/api/files/upload",
files={"file": (
upload.filename,
upload.read(),
upload.mimetype or "application/octet-stream",
)},
)
return _relay(resp, 201)
# ---------------------------------------------------------------------------
# Route: File proxy (browser can't send X-API-Key directly)
# ---------------------------------------------------------------------------
@@ -0,0 +1,272 @@
/**
* Task results (#53 #54 #55) - Alpine component for the tasks that record a result.
*
* - «check» task (#54): the operator answers OK or NOK, with a note (mandatory for NOK).
* - «note» task with `fields` (#55): one input per field, an optional attachment
* (image / PDF) for the fields that ask for it, outcome 'done'.
*
* The same component serves the task being executed (one form) and the panel shown
* at the end of production (one form per at_close task), so the field rules live here
* once. The page sets `window.__taskResultsCfg` (urls + labels) and the component's
* `ctx` (production run id + callbacks) from its x-init, the way visionExecute() does.
*
* Plain functions only (no framework imports) so tests/js/test_task_results.js can
* run them under node with a stubbed fetch.
*/
function taskResultForm() {
return {
// Set by the page's x-init: () => ({ runId: int|null, done: () => void })
ctx: null,
forms: [],
working: false,
error: '',
// true once a result was recorded during this page view (shows «Prosegui»)
justRecorded: false,
// Panel open state (used by the end-of-production panel only)
open: false,
cfg() { return window.__taskResultsCfg || { labels: {} }; },
label(key, fallback) {
return (this.cfg().labels && this.cfg().labels[key]) || fallback;
},
runId() {
const c = this.ctx ? this.ctx() : null;
return c && c.runId ? c.runId : null;
},
/* One form per task: values start from the field's default, the way the maker set it. */
makeForm(task) {
const fields = Array.isArray(task.fields) ? task.fields : [];
const values = {};
fields.forEach((f) => { values[f.key] = (f.default === null || f.default === undefined) ? '' : String(f.default); });
return {
task_id: task.id,
title: task.title || '',
directive: task.directive || '',
type: task.task_type,
fields: fields,
values: values,
files: {},
fileNames: {},
note: '',
outcome: '',
error: '',
result: null,
recorded: false,
};
},
/* The single form of the task on screen. */
initForTask(task) {
if (!task) return;
const isCheck = task.task_type === 'check';
const isNoteWithFields = task.task_type === 'note' && Array.isArray(task.fields) && task.fields.length > 0;
if (!isCheck && !isNoteWithFields) return;
this.forms = [this.makeForm(task)];
this.loadLast();
},
pickFile(form, key, ev) {
const f = ev && ev.target && ev.target.files ? ev.target.files[0] : null;
if (f) { form.files[key] = f; form.fileNames[key] = f.name; }
else { delete form.files[key]; form.fileNames[key] = ''; }
},
csrf() {
const m = typeof document !== 'undefined' ? document.querySelector('meta[name=csrf-token]') : null;
return (m && m.content) || '';
},
/* Fetch + JSON with the page's usual error reading: the detail of the server is
what the operator sees; an HTML answer (expired CSRF / session) means «reload». */
async call(url, opts) {
let resp;
try {
resp = await fetch(url, opts);
} catch (e) {
return { ok: false, detail: this.label('connection', 'Errore di connessione') };
}
const data = await resp.json().catch(() => null);
if (!data || data.csrf_expired || data.session_expired) {
return { ok: false, detail: this.label('sessionExpired', 'Sessione scaduta: ricaricare la pagina') };
}
if (!resp.ok) {
return { ok: false, detail: data.detail || this.label('serverError', 'Errore di comunicazione con il server') };
}
return { ok: true, data: data };
},
/* The last result of this task: in the run under way if there is one, otherwise
the one given in this browser session (no run = nothing on the server to ask).
Only shown: the buttons stay active, a loop gives a new result every cycle. */
async loadLast() {
const form = this.forms[0];
if (!form) return;
const rid = this.runId();
if (rid && this.cfg().listUrl) {
const r = await this.call(
this.cfg().listUrl + '?production_run_id=' + encodeURIComponent(rid) +
'&task_id=' + encodeURIComponent(form.task_id));
if (r.ok && Array.isArray(r.data) && r.data.length) {
form.result = r.data[r.data.length - 1];
return;
}
}
try {
const raw = window.sessionStorage.getItem('tmf-task-result-' + form.task_id);
if (raw && !rid) form.result = JSON.parse(raw);
} catch (e) { /* storage may be unavailable: the result is simply not shown */ }
},
/* Validation before anything goes on the wire. Returns the message or ''. */
validate(form, outcome) {
if (form.type === 'check') {
if (outcome === 'nok' && !(form.note || '').trim()) {
return this.label('nokNeedsNote', 'Per un NOK serve la nota');
}
return '';
}
for (const f of form.fields) {
const v = form.values[f.key];
if (f.type === 'number' && v !== '' && v !== null && v !== undefined &&
!isFinite(Number(String(v).replace(',', '.')))) {
return this.label('notANumber', 'Valore non numerico') + ': ' + f.label;
}
}
return '';
},
/* Upload the attachments of a form, then POST the result.
Returns true when the result is recorded (forms already recorded are skipped). */
async record(form, outcome) {
if (form.recorded) return true;
form.error = this.validate(form, outcome);
if (form.error) return false;
const attachments = [];
for (const f of form.fields) {
const file = form.files[f.key];
if (!f.attachment || !file) continue;
const fd = new FormData();
fd.append('file', file, file.name);
const up = await this.call(this.cfg().uploadUrl, {
method: 'POST', headers: { 'X-CSRFToken': this.csrf() }, body: fd,
});
if (!up.ok) { form.error = up.detail; return false; }
// The backend answers {file_path, ...}: that path is what the result keeps.
attachments.push({ key: f.key, path: up.data.file_path || up.data.path, name: file.name });
}
let values = null;
if (form.type === 'note' && form.fields.length) {
values = {};
form.fields.forEach((f) => {
const v = form.values[f.key];
if (v === '' || v === null || v === undefined) return;
values[f.key] = f.type === 'number' ? String(v).replace(',', '.') : String(v);
});
}
const body = {
task_id: form.task_id,
production_run_id: this.runId(),
outcome: form.type === 'check' ? outcome : 'done',
note: (form.note || '').trim() || null,
values: values,
attachments: attachments.length ? attachments : null,
};
const r = await this.call(this.cfg().saveUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrf() },
body: JSON.stringify(body),
});
if (!r.ok) { form.error = r.detail; return false; }
form.result = r.data;
form.recorded = true;
try {
window.sessionStorage.setItem('tmf-task-result-' + form.task_id, JSON.stringify(r.data));
} catch (e) { /* storage may be unavailable */ }
return true;
},
/* Task on screen: OK / NOK button, or «Completato» of a note with fields. */
async submit(outcome) {
const form = this.forms[0];
if (!form || this.working) return;
this.working = true;
this.error = '';
const ok = await this.record(form, outcome);
this.working = false;
if (!ok) return;
this.justRecorded = true;
this.proceed();
},
/* Hand over to the page: close the cycle / go on. Also bound to «Prosegui», for
when that step failed after the result was already recorded. */
proceed() {
const c = this.ctx ? this.ctx() : null;
if (c && c.done) c.done();
},
/* The result as the operator reads it. */
resultLabel(result) {
if (!result) return '';
if (result.outcome === 'ok') return this.label('outcomeOk', 'OK');
if (result.outcome === 'nok') return this.label('outcomeNok', 'NOK');
return this.label('outcomeDone', 'Registrato');
},
// ---- End of production panel (#55) ----
/* Open the panel for the at_close tasks of the run. A task that already has a
result in this run (page reloaded in between) is not asked again. */
async openClose(ids) {
this.error = '';
this.working = true;
this.open = true;
this.forms = [];
const rid = this.runId();
let done = [];
if (rid && this.cfg().listUrl) {
const r = await this.call(this.cfg().listUrl + '?production_run_id=' + encodeURIComponent(rid));
if (r.ok && Array.isArray(r.data)) done = r.data.map((x) => x.task_id);
}
const forms = [];
for (const id of ids) {
const r = await this.call(this.cfg().taskUrlTpl.replace('/0', '/' + id));
if (!r.ok) { this.error = r.detail; this.working = false; return; }
const form = this.makeForm(r.data);
if (done.indexOf(id) !== -1) form.recorded = true;
forms.push(form);
}
this.forms = forms;
this.working = false;
// Nothing left to ask: straight on to the capoturno.
if (forms.every((f) => f.recorded)) this.finishClose();
},
cancelClose() { this.open = false; this.forms = []; this.error = ''; },
async submitClose() {
if (this.working) return;
this.working = true;
this.error = '';
let allOk = true;
for (const form of this.forms) {
// Keep going after a failure: each form shows its own error, and the ones
// already recorded are not sent again on the next try.
if (!(await this.record(form, 'done'))) allOk = false;
}
this.working = false;
if (allOk) this.finishClose();
},
finishClose() {
this.open = false;
this.proceed();
},
};
}
if (typeof module !== 'undefined') module.exports = { taskResultForm };
@@ -55,6 +55,10 @@
<template x-if="user.is_admin">
<span class="ml-1.5 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300">Admin</span>
</template>
<!-- #59: indicatore PIN impostato (solo se il backend manda has_pin) -->
<template x-if="!user.is_admin && user.has_pin === true">
<span class="ml-1.5 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold bg-sky-100 dark:bg-sky-900/30 text-sky-700 dark:text-sky-300">{{ _('PIN impostato') }}</span>
</template>
</div>
</div>
</td>
@@ -97,6 +101,10 @@
<path stroke-linecap="round" stroke-linejoin="round" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"/>
</svg>
</button>
<!-- #59: imposta/reset PIN (non per gli admin: il backend risponde 422) -->
<template x-if="!user.is_admin">
<button @click="openPin(user)" class="px-1.5 py-1 rounded-lg text-[11px] font-semibold text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20 transition-colors" :title="user.has_pin ? {{ _('Reset PIN')|tojson_attr }} : {{ _('Imposta PIN')|tojson_attr }}">PIN</button>
</template>
<button @click="toggleActive(user)" class="p-1.5 rounded-lg transition-colors"
:class="user.active
? 'text-[var(--text-secondary)] hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20'
@@ -210,6 +218,20 @@
minlength="6">
</div>
<!-- #59: PIN facoltativo, solo in creazione di un non admin -->
<template x-if="!isEditing && !form.is_admin">
<div>
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">
{{ _('PIN') }}
<span class="font-normal text-[var(--text-secondary)]"> ({{ _('facoltativo, 4 cifre') }})</span>
</label>
<input type="text" x-model="form.pin" inputmode="numeric" maxlength="4" autocomplete="off"
class="w-full px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)]
text-sm font-mono text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors"
placeholder="1234">
</div>
</template>
<!-- Roles -->
<div>
<label class="block text-sm font-medium text-[var(--text-primary)] mb-2">{{ _('Ruoli') }}</label>
@@ -374,4 +396,59 @@
</div>
</div>
<!-- #59: Modal imposta / reset / togli PIN (il PIN attuale non si mostra mai) -->
<div x-show="showPin" x-cloak
class="fixed inset-0 z-50 flex items-center justify-center p-4"
@keydown.escape.window="closePin()">
<div class="absolute inset-0 bg-black/50" @click="closePin()"></div>
<div class="relative bg-[var(--bg-card)] rounded-xl border border-[var(--border-color)] shadow-xl w-full max-w-md p-6">
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-2">
<span x-text="pinUser?.has_pin ? {{ _('Reset PIN')|tojson_attr }} : {{ _('Imposta PIN')|tojson_attr }}"></span>: <span x-text="pinUser?.username"></span>
</h3>
<p class="text-sm text-[var(--text-secondary)] mb-4">
{{ _('Il PIN serve per accedere alla postazione e, per il capoturno, per autorizzare le azioni.') }}
</p>
<template x-if="!pinDone">
<div>
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Nuovo PIN (4 cifre)') }}</label>
<div class="flex gap-2">
<input type="text" x-model="pinValue" inputmode="numeric" maxlength="4" autocomplete="off"
class="flex-1 px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)] text-sm font-mono text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary">
<button @click="generatePin()" type="button"
class="px-3 py-2 text-sm font-medium rounded-lg border border-[var(--border-color)] text-[var(--text-primary)] hover:bg-[var(--bg-secondary)]">
{{ _('Genera') }}
</button>
</div>
<template x-if="pinError">
<p class="mt-2 text-sm text-red-600" x-text="pinError"></p>
</template>
</div>
</template>
<template x-if="pinDone">
<div class="px-3 py-2 rounded-lg bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-200 dark:border-emerald-800">
<p class="text-sm text-emerald-700 dark:text-emerald-300" x-text="pinRemoved ? {{ _('PIN tolto.')|tojson_attr }} : {{ _('PIN impostato. Annotalo ora: non verrà mostrato di nuovo.')|tojson_attr }}"></p>
<p x-show="!pinRemoved" class="mt-1 text-base font-mono font-semibold text-[var(--text-primary)] select-all" x-text="pinValue"></p>
</div>
</template>
<div class="flex items-center justify-between gap-3 mt-5">
<div>
<button x-show="!pinDone && pinUser?.has_pin" @click="removePin()" :disabled="saving"
class="px-4 py-2 text-sm font-medium text-red-600 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 disabled:opacity-50">
{{ _('Togli PIN') }}
</button>
</div>
<div class="flex gap-3">
<button @click="closePin()"
class="px-4 py-2 text-sm font-medium text-[var(--text-secondary)] rounded-lg hover:bg-[var(--bg-secondary)]">
<span x-text="pinDone ? {{ _('Chiudi')|tojson_attr }} : {{ _('Annulla')|tojson_attr }}"></span>
</button>
<button x-show="!pinDone" @click="confirmPin()" :disabled="saving"
class="px-4 py-2 bg-primary text-white text-sm font-medium rounded-lg hover:bg-primary-700 shadow-sm disabled:opacity-50">
{{ _('Imposta PIN') }}
</button>
</div>
</div>
</div>
</div>
</div>
@@ -11,6 +11,13 @@ function userManagement(initialUsers) {
resetPw: '',
resetDone: false,
resetError: '',
// #59: stato del modal PIN
showPin: false,
pinUser: null,
pinValue: '',
pinDone: false,
pinRemoved: false,
pinError: '',
isEditing: false,
editingUserId: null,
toggleUser: null,
@@ -21,6 +28,7 @@ function userManagement(initialUsers) {
display_name: '',
email: '',
password: '',
pin: '', // #59: PIN facoltativo alla creazione
roles: [],
is_admin: false,
language_pref: 'it',
@@ -52,6 +60,7 @@ function userManagement(initialUsers) {
display_name: '',
email: '',
password: '',
pin: '', // #59
roles: [],
is_admin: false,
language_pref: 'it',
@@ -134,6 +143,12 @@ function userManagement(initialUsers) {
return;
}
// #59: PIN facoltativo, 4 cifre, solo per i non admin
const pin = this.form.is_admin ? '' : (this.form.pin || '').trim();
if (pin && !/^\d{4}$/.test(pin)) {
this.errorMsg = {{ _('Il PIN deve essere di 4 cifre')|tojson }};
return;
}
const data = {
username: this.form.username,
password: this.form.password,
@@ -158,6 +173,24 @@ function userManagement(initialUsers) {
}
this.users.push(result);
// #59: l'utente esiste gia', il PIN si imposta con la chiamata dedicata
if (pin) {
const pinResp = await fetch(`/admin/api/users/${result.id}/pin`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
body: JSON.stringify({ pin: pin })
});
if (!pinResp.ok) {
const pinResult = await pinResp.json().catch(() => ({}));
// resta aperto in modifica sull'utente appena creato, cosi' non si ricrea
this.isEditing = true;
this.editingUserId = result.id;
this.errorMsg = {{ _('Utente creato, ma PIN non impostato')|tojson }} + ': ' + (pinResult.detail || '');
return;
}
result.has_pin = true;
}
}
this.closeModal();
@@ -218,6 +251,70 @@ function userManagement(initialUsers) {
}
},
// #59: imposta / reset / togli PIN, usa PUT /admin/api/users/<id>/pin
openPin(user) {
this.pinUser = user;
this.pinValue = '';
this.pinDone = false;
this.pinRemoved = false;
this.pinError = '';
this.showPin = true;
},
closePin() {
this.showPin = false;
this.pinValue = ''; // il PIN generato non resta in memoria
this.pinDone = false;
},
generatePin() {
// 4 cifre da crypto random
const buf = new Uint32Array(4);
crypto.getRandomValues(buf);
this.pinValue = Array.from(buf, n => n % 10).join('');
this.pinError = '';
},
async savePin(pin) {
// pin = stringa di 4 cifre oppure null (toglie il PIN)
this.saving = true;
this.pinError = '';
try {
const resp = await fetch(`/admin/api/users/${this.pinUser.id}/pin`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
body: JSON.stringify({ pin: pin })
});
const result = await resp.json().catch(() => ({}));
if (!resp.ok) {
this.pinError = result.detail || {{ _('Errore nel salvataggio del PIN')|tojson }};
return;
}
// aggiorna l'indicatore locale
const idx = this.users.findIndex(u => u.id === this.pinUser.id);
if (idx >= 0) this.users[idx] = Object.assign({}, this.users[idx], { has_pin: pin !== null });
this.pinUser = idx >= 0 ? this.users[idx] : this.pinUser;
this.pinRemoved = pin === null;
this.pinDone = true;
} catch (e) {
this.pinError = {{ _('Errore di connessione al server')|tojson }};
} finally {
this.saving = false;
}
},
async confirmPin() {
if (!/^\d{4}$/.test(this.pinValue)) {
this.pinError = {{ _('Il PIN deve essere di 4 cifre')|tojson }};
return;
}
await this.savePin(this.pinValue);
},
async removePin() {
await this.savePin(null);
},
toggleActive(user) {
this.toggleUser = user;
this.showToggleConfirm = true;
@@ -8,6 +8,8 @@
{% set icon_stations %}<path stroke-linecap="round" stroke-linejoin="round" d="M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25m18 0A2.25 2.25 0 0018.75 3H5.25A2.25 2.25 0 003 5.25m18 0V12a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 12V5.25"/>{% endset %}
{% set icon_settings %}<path stroke-linecap="round" stroke-linejoin="round" d="M10.343 3.94c.09-.542.56-.94 1.11-.94h1.093c.55 0 1.02.398 1.11.94l.149.894c.07.424.384.764.78.93.398.164.855.142 1.205-.108l.737-.527a1.125 1.125 0 011.45.12l.773.774c.39.389.44 1.002.12 1.45l-.527.737c-.25.35-.272.806-.107 1.204.165.397.505.71.93.78l.893.15c.543.09.94.56.94 1.109v1.094c0 .55-.397 1.02-.94 1.11l-.893.149c-.425.07-.765.383-.93.78-.165.398-.143.854.107 1.204l.527.738c.32.447.269 1.06-.12 1.45l-.774.773a1.125 1.125 0 01-1.449.12l-.738-.527c-.35-.25-.806-.272-1.204-.107-.397.165-.71.505-.78.929l-.15.894c-.09.542-.56.94-1.11.94h-1.094c-.55 0-1.019-.398-1.11-.94l-.148-.894c-.071-.424-.384-.764-.781-.93-.398-.164-.854-.142-1.204.108l-.738.527c-.447.32-1.06.269-1.45-.12l-.773-.774a1.125 1.125 0 01-.12-1.45l.527-.737c.25-.35.273-.806.108-1.204-.165-.397-.506-.71-.93-.78l-.894-.15c-.542-.09-.94-.56-.94-1.109v-1.094c0-.55.398-1.02.94-1.11l.894-.149c.424-.07.765-.383.93-.78.165-.398.143-.854-.107-1.204l-.527-.738a1.125 1.125 0 01.12-1.45l.773-.773a1.125 1.125 0 011.45-.12l.737.527c.35.25.807.272 1.204.107.397-.165.71-.505.78-.929l.15-.894z"/><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>{% endset %}
{% set icon_monitor %}<path stroke-linecap="round" stroke-linejoin="round" d="M9.348 14.652a3.75 3.75 0 010-5.304m5.304 0a3.75 3.75 0 010 5.304m-7.425 2.121a6.75 6.75 0 010-9.546m9.546 0a6.75 6.75 0 010 9.546M5.106 18.894c-3.808-3.807-3.808-9.98 0-13.788m13.788 0c3.808 3.807 3.808 9.98 0 13.788M12 12h.008v.008H12V12zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z"/>{% endset %}
{# Single nav link, desktop or mobile variant. `icon` is SVG path markup. #}
{% macro nav_link(href, label, icon, active=false, mobile=false) -%}
<a href="{{ href }}"
@@ -31,6 +33,10 @@
{% if current_user.get('roles') and 'Metrologist' in current_user.roles %}
{{ nav_link(url_for('statistics.dashboard'), _('Statistiche'), icon_stats, ep.startswith('statistics.'), mobile) }}
{% endif %}
{# #57: Monitor produzione, visibile ad admin e capoturno (stessa regola di monitor_required) #}
{% if current_user.get('is_admin') or (current_user.get('roles') and 'Supervisor' in current_user.roles) %}
{{ nav_link(url_for('monitor.index'), _('Monitor'), icon_monitor, ep.startswith('monitor.'), mobile) }}
{% endif %}
{% if current_user.get('is_admin') %}
{{ nav_link(url_for('admin.station_list'), _('Stazioni'), icon_stations, ep == 'admin.station_list', mobile) }}
{{ nav_link(url_for('admin.settings_page'), _('Impostazioni'), icon_settings, ep == 'admin.settings_page', mobile) }}
@@ -91,6 +91,74 @@
{% endblock %}
{% block content %}
{# #53 #55: parte del form task comune a creazione e modifica; `d` e il nome dello stato Alpine (newTask / editTaskData) #}
{% macro task_flags_editor(d) %}
<div class="sm:col-span-2 space-y-3">
<!-- #53: il task entra nel ciclo di misura (timer e cicalino) -->
<label class="flex items-start gap-2 text-sm cursor-pointer">
<input type="checkbox" class="mt-0.5"
x-model="{{ d }}.in_loop"
@change="{{ d }}.in_loop_touched = true">
<span>{{ _('Nel ciclo di misura (timer e cicalino)') }}</span>
</label>
<!-- #55: opzioni valide solo per il tipo Nota -->
<template x-if="{{ d }}.task_type === 'note'">
<div class="space-y-3">
<label class="flex items-start gap-2 text-sm cursor-pointer">
<input type="checkbox" class="mt-0.5" x-model="{{ d }}.at_close">
<span>{{ _('Compila a fine produzione') }}</span>
</label>
<div class="space-y-2">
<div class="flex items-center justify-between gap-2">
<label class="tmf-label mb-0">{{ _('Campi da compilare') }}</label>
<button type="button" class="btn btn-secondary text-xs gap-1"
:disabled="{{ d }}.fields.length >= MAX_NOTE_FIELDS"
@click="addNoteField({{ d }})">
+ {{ _('Aggiungi campo') }}
</button>
</div>
<template x-for="(f, fi) in {{ d }}.fields" :key="f._uid">
<div class="grid grid-cols-1 sm:grid-cols-12 gap-2 p-2 rounded border border-[var(--border-color)] bg-[var(--bg-secondary)]">
<div class="sm:col-span-4">
<input type="text" class="tmf-input text-sm" maxlength="100"
placeholder="{{ _('Etichetta') }}"
x-model="f.label"
@input="deriveNoteFieldKey({{ d }}, f)">
<span class="block mt-0.5 text-[10px] font-mono text-[var(--text-muted)]" x-text="f.key"></span>
</div>
<div class="sm:col-span-2">
<select class="tmf-input text-sm" x-model="f.type">
<option value="text">{{ _('Testo') }}</option>
<option value="number">{{ _('Numero') }}</option>
</select>
</div>
<div class="sm:col-span-3">
<input type="text" class="tmf-input text-sm"
placeholder="{{ _('Valore precompilato') }}"
x-model="f.default">
</div>
<label class="sm:col-span-1 flex items-center gap-1 text-xs cursor-pointer">
<input type="checkbox" x-model="f.attachment">
<span>{{ _('Allegato') }}</span>
</label>
<div class="sm:col-span-2 flex items-center justify-end gap-1">
<button type="button" class="p-1 rounded hover:bg-[var(--bg-card-hover)]"
:disabled="fi === 0" @click="moveNoteField({{ d }}, fi, -1)"
title="{{ _('Sposta su') }}">&uarr;</button>
<button type="button" class="p-1 rounded hover:bg-[var(--bg-card-hover)]"
:disabled="fi === {{ d }}.fields.length - 1" @click="moveNoteField({{ d }}, fi, 1)"
title="{{ _('Sposta giu') }}">&darr;</button>
<button type="button" class="p-1 rounded text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20"
@click="removeNoteField({{ d }}, fi)"
title="{{ _('Rimuovi campo') }}">&times;</button>
</div>
</div>
</template>
</div>
</div>
</template>
</div>
{% endmacro %}
<div class="tmf-page"
x-data="taskEditor()"
x-cloak>
@@ -273,12 +341,15 @@
<!-- Tipo: dichiarato qui, non dedotto dal contenuto -->
<div>
<label class="tmf-label">{{ _('Tipo') }} <span class="text-red-500">*</span></label>
<select x-model="newTask.task_type" class="tmf-input">
<select x-model="newTask.task_type" class="tmf-input"
@change="onTaskTypeChange(newTask, $event.target.value)">
<option value="note">{{ _('Nota') }}</option>
<option value="measure">{{ _('Misura') }}</option>
<option value="drawing">{{ _('Disegno') }}</option>
<option value="camera_measure">{{ _('Misura camera') }}</option>
<option value="dxf_compare">{{ _('Confronto profilo') }}</option>
<!-- #54: task di controllo con esito OK/NOK, senza quote -->
<option value="check">{{ _('Controllo OK/NOK') }}</option>
</select>
<p class="mt-1 text-xs text-[var(--text-secondary)]">
{{ _('Un task di misura resta tale anche prima di inserire le quote') }}
@@ -306,6 +377,8 @@
rows="3"
placeholder="{{ _('Descrizione opzionale...') }}"></textarea>
</div>
{# #53 #55: ciclo di misura, compilazione a fine produzione e campi della nota #}
{{ task_flags_editor('newTask') }}
</div>
</div>
<div class="tmf-card-footer flex items-center gap-2 justify-end">
@@ -387,8 +460,20 @@
x-ref="editTaskTitleInput">
</template>
<!-- Badge: N misurazioni -->
<!-- #53: segno sui task nel ciclo di misura -->
<span x-show="task.in_loop" class="shrink-0 text-primary"
title="{{ _('Nel ciclo di misura (timer e cicalino)') }}">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
d="M4 4v5h5M20 20v-5h-5M5.5 15A8 8 0 0018.4 17.4M18.5 9A8 8 0 005.6 6.6"/>
</svg>
</span>
<!-- #55: nota compilata a fine produzione -->
<span x-show="task.at_close" class="badge badge-neutral text-xs shrink-0">{{ _('A fine produzione') }}</span>
<!-- Badge: N misurazioni (non per i task di controllo, senza quote) -->
<span class="badge badge-neutral text-xs shrink-0"
x-show="task.task_type !== 'check'"
x-text="(task.subtasks ? task.subtasks.length : 0) + ' {{ _('misurazioni') }}'">
</span>
@@ -590,12 +675,14 @@
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 p-3 rounded-lg bg-blue-50 dark:bg-blue-900/10 border border-blue-200 dark:border-blue-800">
<div>
<label class="tmf-label">{{ _('Tipo') }}</label>
<select x-model="editTaskData.task_type" class="tmf-input text-sm">
<select x-model="editTaskData.task_type" class="tmf-input text-sm"
@change="onTaskTypeChange(editTaskData, $event.target.value)">
<option value="note">{{ _('Nota') }}</option>
<option value="measure">{{ _('Misura') }}</option>
<option value="drawing">{{ _('Disegno') }}</option>
<option value="camera_measure">{{ _('Misura camera') }}</option>
<option value="dxf_compare">{{ _('Confronto profilo') }}</option>
<option value="check">{{ _('Controllo OK/NOK') }}</option>
</select>
</div>
<div>
@@ -618,12 +705,14 @@
rows="3"
placeholder="{{ _('Descrizione opzionale...') }}"></textarea>
</div>
{# #53 #55: ciclo di misura, compilazione a fine produzione e campi della nota #}
{{ task_flags_editor('editTaskData') }}
</div>
</template>
<!-- ========== Subtask Table ========== -->
<div class="subtask-table-wrap">
<table class="tmf-table" x-show="task.subtasks && task.subtasks.length > 0">
<table class="tmf-table" x-show="task.task_type !== 'check' && task.subtasks && task.subtasks.length > 0">
<thead>
<tr>
<th class="w-16 text-center">{{ _('#') }}</th>
@@ -710,7 +799,7 @@
</div>
<!-- ========== Empty Subtask State ========== -->
<div x-show="!task.subtasks || task.subtasks.length === 0"
<div x-show="task.task_type !== 'check' && (!task.subtasks || task.subtasks.length === 0)"
class="text-center py-8">
<div class="inline-flex items-center justify-center w-14 h-14 rounded-full
bg-[var(--bg-secondary)] mb-3">
@@ -862,7 +951,7 @@
<!-- ========== Task Card Footer ========== -->
<div class="tmf-card-footer flex items-center justify-end">
<button @click="openAddSubtask(task)"
x-show="addingSubtaskForTask !== task.id"
x-show="addingSubtaskForTask !== task.id && task.task_type !== 'check'"
class="btn btn-secondary text-xs gap-1.5">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4"/>
@@ -1210,8 +1299,10 @@ function taskEditor() {
// ---- Task CRUD state ----
editingTask: null,
editTaskData: { title: '', directive: '', description: '', task_type: 'note' },
newTask: { title: '', directive: '', description: '', task_type: 'note' },
editTaskData: { title: '', directive: '', description: '', task_type: 'note', in_loop: false, in_loop_touched: false, at_close: false, fields: [] },
newTask: { title: '', directive: '', description: '', task_type: 'note', in_loop: false, in_loop_touched: false, at_close: false, fields: [] },
MAX_NOTE_FIELDS: 20, // #55: limite campi per nota (stesso del backend)
_fieldUid: 0,
// ---- Subtask CRUD state ----
editingSubtaskId: null,
@@ -1391,7 +1482,84 @@ function taskEditor() {
// ============================================================
resetNewTask() {
this.newTask = { title: '', directive: '', description: '', task_type: 'note' };
this.newTask = { title: '', directive: '', description: '', task_type: 'note', in_loop: false, in_loop_touched: false, at_close: false, fields: [] };
},
// ---- #53 #55: ciclo di misura e campi della nota ----
// #53: default di "nel ciclo" per tipo (stessa regola del backend)
loopDefault(type) {
return ['measure', 'camera_measure', 'dxf_compare', 'check'].includes(type);
},
// Cambio tipo: il default del ciclo segue il tipo finche l'utente non lo tocca;
// at_close e campi esistono solo per la nota.
onTaskTypeChange(obj, type) {
obj.task_type = type;
if (!obj.in_loop_touched) obj.in_loop = this.loopDefault(type);
if (type !== 'note') { obj.at_close = false; obj.fields = []; }
},
// #55: key dall'etichetta (minuscole, [a-z0-9_], max 40), unica nella lista
_slugKey(label) {
const base = (label || '').normalize('NFD').replace(/[̀-ͯ]/g, '')
.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 40);
return base || 'campo';
},
deriveNoteFieldKey(obj, f) {
if (f.locked) return; // la key non cambia piu dopo il primo salvataggio
const used = new Set(obj.fields.filter(x => x !== f).map(x => x.key));
const base = this._slugKey(f.label);
let key = base, n = 2;
while (used.has(key)) {
const suffix = '_' + n++;
key = base.slice(0, 40 - suffix.length) + suffix;
}
f.key = key;
},
addNoteField(obj) {
if (obj.fields.length >= this.MAX_NOTE_FIELDS) return;
const f = { _uid: ++this._fieldUid, key: '', label: '', type: 'text', default: '', attachment: false, locked: false };
obj.fields.push(f);
this.deriveNoteFieldKey(obj, f);
},
removeNoteField(obj, i) {
obj.fields.splice(i, 1);
},
moveNoteField(obj, i, dir) {
const j = i + dir;
if (j < 0 || j >= obj.fields.length) return;
const [f] = obj.fields.splice(i, 1);
obj.fields.splice(j, 0, f);
},
// Campi salvati -> stato di modifica (key bloccate)
_fieldsToState(fields) {
return (fields || []).map(f => ({
_uid: ++this._fieldUid, key: f.key, label: f.label || '', type: f.type || 'text',
default: f.default == null ? '' : f.default, attachment: !!f.attachment, locked: true
}));
},
// Stato -> corpo API (fields null se non e una nota o e vuota); ok=false con errorMessage se manca un'etichetta.
_fieldsPayload(obj) {
if (obj.task_type !== 'note') return { ok: true, fields: null };
const out = [];
for (const f of obj.fields) {
if (!f.label.trim()) {
this.errorMessage = {{ _("Ogni campo deve avere un'etichetta")|tojson }};
return { ok: false };
}
out.push({
key: f.key, label: f.label.trim(), type: f.type || 'text',
default: (f.default || '').trim() || null, attachment: !!f.attachment
});
}
return { ok: true, fields: out.length ? out : null };
},
// ============================================================
@@ -1470,8 +1638,10 @@ function taskEditor() {
async addTask() {
if (!this.newTask.title.trim()) return;
this.saving = true;
this.errorMessage = '';
const fp = this._fieldsPayload(this.newTask); // #55
if (!fp.ok) return;
this.saving = true;
try {
const resp = await fetch(`/maker/api/recipes/${this.recipeId}/tasks`, {
@@ -1484,7 +1654,11 @@ function taskEditor() {
title: this.newTask.title.trim(),
task_type: this.newTask.task_type || 'note',
directive: this.newTask.directive.trim() || null,
description: this.newTask.description.trim() || null
description: this.newTask.description.trim() || null,
// #53 #55: ciclo di misura, compilazione a fine produzione, campi della nota
in_loop: !!this.newTask.in_loop,
at_close: this.newTask.task_type === 'note' && !!this.newTask.at_close,
fields: fp.fields
})
});
@@ -1516,7 +1690,12 @@ function taskEditor() {
title: task.title,
task_type: task.task_type || 'note',
directive: task.directive || '',
description: task.description || ''
description: task.description || '',
// #53 #55: valore salvato (task vecchi senza il campo: default del tipo)
in_loop: task.in_loop != null ? !!task.in_loop : this.loopDefault(task.task_type || 'note'),
in_loop_touched: false,
at_close: !!task.at_close,
fields: this._fieldsToState(task.fields)
};
// Ensure expanded
this.expandedTask = task.id;
@@ -1524,13 +1703,15 @@ function taskEditor() {
cancelEditTask() {
this.editingTask = null;
this.editTaskData = { title: '', directive: '', description: '', task_type: 'note' };
this.editTaskData = { title: '', directive: '', description: '', task_type: 'note', in_loop: false, in_loop_touched: false, at_close: false, fields: [] };
},
async updateTask(taskId) {
if (!this.editTaskData.title.trim()) return;
this.saving = true;
this.errorMessage = '';
const fp = this._fieldsPayload(this.editTaskData); // #55
if (!fp.ok) return;
this.saving = true;
try {
const resp = await fetch(`/maker/api/tasks/${taskId}`, {
@@ -1543,7 +1724,11 @@ function taskEditor() {
title: this.editTaskData.title.trim(),
task_type: this.editTaskData.task_type || 'note',
directive: this.editTaskData.directive.trim() || null,
description: this.editTaskData.description.trim() || null
description: this.editTaskData.description.trim() || null,
// #53 #55: ciclo di misura, compilazione a fine produzione, campi della nota
in_loop: !!this.editTaskData.in_loop,
at_close: this.editTaskData.task_type === 'note' && !!this.editTaskData.at_close,
fields: fp.fields
})
});
@@ -118,8 +118,8 @@
{# #60: un solo «Completato», quello grande in fondo (touch); qui resta solo la lista. #}
</div>
{# Fermo linea + Fine produzione (measurement tasks only) #}
<template x-if="isMeasureTask && productionRun">
{# Fermo linea + Fine produzione: task di misura o del ciclo (#53: anche un check nel loop) #}
<template x-if="(isMeasureTask || isMeasurementTaskOfRun) && productionRun">
{# #61: Fermo linea / Fine produzione solo con una produzione aperta #}
<div class="shrink-0 flex items-center gap-1.5">
{# While the line is stopped this becomes the way to restart it, so the
@@ -256,6 +256,88 @@
</div>
{% endif %}
{# #55: the inputs of the fields of a «note» task. One block for the task on screen
and for the panel at the end of production, so the rules are written once. #}
{% macro result_fields() %}
<template x-for="fld in f.fields" :key="fld.key">
<div class="mb-3">
<label class="block text-sm font-medium text-[var(--text-secondary)] mb-1" x-text="fld.label"></label>
<input :type="fld.type === 'number' ? 'number' : 'text'"
:step="fld.type === 'number' ? 'any' : null"
:inputmode="fld.type === 'number' ? 'decimal' : null"
x-model="f.values[fld.key]"
class="input w-full text-base py-2">
<template x-if="fld.attachment">
<div class="mt-1.5">
<label class="btn btn-secondary text-xs gap-1.5 cursor-pointer">
{{ _('Allega immagine o PDF') }}
<input type="file" accept="image/*,application/pdf" class="hidden"
@change="pickFile(f, fld.key, $event)">
</label>
<span class="ml-2 text-xs text-[var(--text-muted)]" x-text="f.fileNames[fld.key]"></span>
</div>
</template>
</div>
</template>
{% endmacro %}
{# #54 #55: result panel of the task on screen - OK / NOK for a «check», the
fields and «Completato» for a «note» with fields. #}
<div x-show="hasResultPanel" x-cloak
x-data="taskResultForm()"
x-init="ctx = () => ({ runId: productionRun ? productionRun.id : null, done: () => advanceAfterResult() });
initForTask(task);
$watch('productionRun && productionRun.id', () => loadLast())"
@task-result-reset.window="justRecorded = false; initForTask(task)"
class="shrink-0 max-h-[65%] overflow-y-auto px-4 py-3 bg-[var(--bg-card)] border-b border-[var(--border-color)]">
<template x-for="f in forms" :key="f.task_id">
<div class="max-w-2xl mx-auto">
<div x-show="f.result" class="mb-3 px-3 py-2 rounded-lg text-sm border border-[var(--border-color)] bg-[var(--bg-secondary)]">
<span class="text-[var(--text-secondary)]">{{ _('Ultimo esito') }}:</span>
<span class="font-bold"
:class="f.result && f.result.outcome === 'nok' ? 'text-measure-fail' : 'text-measure-pass'"
x-text="resultLabel(f.result)"></span>
<span class="text-[var(--text-muted)]" x-text="f.result && f.result.note ? '— ' + f.result.note : ''"></span>
</div>
<template x-if="f.type === 'check'">
<div>
<textarea x-model="f.note" rows="2"
placeholder="{{ _('Nota (obbligatoria per un NOK)') }}"
class="input w-full text-base mb-3"></textarea>
<div class="grid grid-cols-2 gap-3">
<button @click="submit('ok')" :disabled="working"
class="btn min-h-[72px] text-2xl font-bold justify-center shadow-md bg-measure-pass text-white hover:opacity-90 disabled:opacity-50">
{{ _('OK') }}
</button>
<button @click="submit('nok')" :disabled="working"
class="btn min-h-[72px] text-2xl font-bold justify-center shadow-md bg-red-600 text-white hover:bg-red-700 disabled:opacity-50">
{{ _('NOK') }}
</button>
</div>
</div>
</template>
<template x-if="f.type === 'note'">
<div>
{{ result_fields() }}
<button @click="submit('done')" :disabled="working"
class="btn text-lg font-bold w-full min-h-[64px] justify-center gap-2 shadow-md bg-measure-pass text-white hover:opacity-90 disabled:opacity-50">
<span>{{ _('Completato') }}</span>
</button>
</div>
</template>
<p x-show="f.error" class="mt-2 text-sm text-measure-fail" x-text="f.error"></p>
{# The result is in, but going on failed (cycle refused, network): another try, without recording twice. #}
<button x-show="justRecorded" @click="proceed()"
class="btn btn-secondary mt-3 w-full justify-center">
{{ _('Prosegui') }}
</button>
</div>
</template>
</div>
{# Main image area #}
<div class="flex-1 overflow-hidden relative">
@@ -747,7 +829,8 @@
</button>
</div>
</template>
<template x-if="!isMeasureTask || subtasks.length === 0">
{# #54 #55: a check / note-with-fields task has its own buttons in the panel #}
<template x-if="(!isMeasureTask || subtasks.length === 0) && !hasResultPanel">
{# #60: unico «Completato», largo e alto almeno 64 px per il touch. #}
<button @click="goToNextTask()"
:disabled="!canLeaveTask"
@@ -895,6 +978,39 @@
</div>
</div>
{# ================================================================
#55 TASK DI CHIUSURA — before the capoturno's modal, the notes marked «alla fine
della produzione» are filled in and recorded on the run. The modal below is
untouched: it opens when this panel is done.
================================================================ #}
<div x-show="open" x-cloak
x-data="taskResultForm()"
x-init="ctx = () => ({ runId: productionRun ? productionRun.id : null, done: () => closeTasksRecorded() })"
@task-close-open.window="openClose($event.detail.ids)"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div class="bg-[var(--bg-card)] rounded-2xl shadow-2xl p-6 w-full max-w-xl mx-4 max-h-[90vh] overflow-y-auto border border-[var(--border-color)]">
<h3 class="text-lg font-bold text-[var(--text-primary)] mb-3">{{ _('Prima di chiudere la produzione') }}</h3>
<p x-show="working && !forms.length" class="text-sm text-[var(--text-muted)]">{{ _('Caricamento...') }}</p>
<template x-for="f in forms" :key="f.task_id">
<div class="mb-4" x-show="!f.recorded">
<div class="font-semibold text-[var(--text-primary)] mb-1" x-text="f.title"></div>
<div class="text-sm text-[var(--text-secondary)] mb-2" x-show="f.directive" x-text="f.directive"></div>
{{ result_fields() }}
<textarea x-model="f.note" rows="2" placeholder="{{ _('Nota') }}" class="input w-full text-base"></textarea>
<p x-show="f.error" class="mt-1 text-sm text-measure-fail" x-text="f.error"></p>
</div>
</template>
<p x-show="error" class="mb-2 text-sm text-measure-fail" x-text="error"></p>
<div class="flex gap-3 mt-2">
<button @click="cancelClose()" class="btn btn-secondary flex-1 justify-center">{{ _('Annulla') }}</button>
<button @click="submitClose()" :disabled="working || !forms.length"
class="btn flex-1 justify-center font-bold bg-measure-pass text-white hover:opacity-90 disabled:opacity-50">
{{ _('Registra e prosegui') }}
</button>
</div>
</div>
</div>
{# ================================================================
SUPERVISOR LOGIN MODAL
================================================================ #}
@@ -921,7 +1037,55 @@
</div>
</div>
<div class="space-y-3 mb-5">
{# #59: two ways to authorise - PIN (default, touch pad) or password. #}
<div class="flex gap-2 mb-4" role="tablist">
<button type="button" role="tab" @click="setSupervisorMode('pin')"
:disabled="!supervisorPinUsers.length"
class="btn flex-1 text-sm"
:class="supervisorMode === 'pin' ? 'btn-primary' : 'btn-secondary'">{{ _('PIN') }}</button>
<button type="button" role="tab" @click="setSupervisorMode('password')"
class="btn flex-1 text-sm"
:class="supervisorMode === 'password' ? 'btn-primary' : 'btn-secondary'">{{ _('Password') }}</button>
</div>
<div x-show="supervisorMode === 'pin'" class="mb-5">
{# name grid #}
<div x-show="!supervisorPinUser">
<p class="text-xs text-center text-[var(--text-secondary)] mb-3">{{ _('Chi sei?') }}</p>
<div class="grid grid-cols-2 gap-3 max-h-64 overflow-y-auto">
<template x-for="u in supervisorPinUsers" :key="u.id">
<button type="button" @click="selectSupervisorUser(u)"
class="btn btn-secondary min-h-[64px] text-base w-full" x-text="u.display_name"></button>
</template>
</div>
</div>
{# pad #}
<div x-show="supervisorPinUser">
<p class="text-center font-semibold mb-1" x-text="supervisorPinUser && supervisorPinUser.display_name"></p>
<p class="text-xs text-center text-[var(--text-secondary)] mb-3">{{ _('Inserisci il PIN') }}</p>
<div class="flex justify-center gap-3 mb-3" aria-hidden="true">
<template x-for="i in 4" :key="i">
<span class="h-4 w-4 rounded-full border-2 border-[var(--border-color)]"
:class="supervisorPin.length >= i ? 'bg-primary-600 border-primary-600' : ''"></span>
</template>
</div>
<div class="grid grid-cols-3 gap-3 max-w-xs mx-auto">
<template x-for="d in ['1','2','3','4','5','6','7','8','9']" :key="d">
<button type="button" @click="pressSupervisorPin(d)" :disabled="supervisorValidating"
class="btn btn-secondary h-16 min-w-[64px] text-2xl" x-text="d"></button>
</template>
<button type="button" @click="backSupervisorUser()" :disabled="supervisorValidating"
class="btn btn-secondary h-16 min-w-[64px] text-sm">{{ _('Indietro') }}</button>
<button type="button" @click="pressSupervisorPin('0')" :disabled="supervisorValidating"
class="btn btn-secondary h-16 min-w-[64px] text-2xl">0</button>
<button type="button" @click="supervisorPin = supervisorPin.slice(0, -1)" :disabled="supervisorValidating"
class="btn btn-secondary h-16 min-w-[64px] text-sm">{{ _('Cancella') }}</button>
</div>
</div>
<p x-show="supervisorError" class="text-sm text-center text-red-600 mt-3" role="alert" x-text="supervisorError"></p>
</div>
<div x-show="supervisorMode === 'password'" class="space-y-3 mb-5">
<div>
<label class="tmf-label text-xs">{{ _('Username') }}</label>
<input type="text" x-model="supervisorUsername" class="tmf-input text-sm"
@@ -945,13 +1109,13 @@
<span x-show="supervisorAction === 'out_of_tolerance'">{{ _('Rimisura la quota') }}</span>
</button>
<button @click="submitSupervisor()"
:disabled="!supervisorUsername || !supervisorPassword || supervisorValidating"
:disabled="!supervisorCanSubmit || supervisorValidating"
class="btn btn-primary flex-1 text-sm gap-1">
<svg x-show="supervisorValidating" class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"/>
</svg>
{{ _('Autorizza') }}
<span x-text="supervisorMode === 'pin' ? '{{ _('Conferma') }}' : '{{ _('Autorizza') }}'"></span>
</button>
</div>
</div>
@@ -980,6 +1144,26 @@
};
</script>
<script src="{{ url_for('static', filename='js/vision_execute.js') }}"></script>
{# #53 #54 #55: result of check / note tasks (urls and words for the component) #}
<script>
window.__taskResultsCfg = {
saveUrl: '{{ url_for("measure.api_save_task_result") }}',
listUrl: '{{ url_for("measure.api_list_task_results") }}',
uploadUrl: '{{ url_for("measure.api_upload_attachment") }}',
taskUrlTpl: '{{ url_for("measure.api_get_task", task_id=0) }}',
labels: {
connection: {{ _('Errore di connessione')|tojson }},
sessionExpired: {{ _('Sessione scaduta: ricaricare la pagina')|tojson }},
serverError: {{ _('Errore di comunicazione con il server')|tojson }},
nokNeedsNote: {{ _('Per un NOK serve la nota')|tojson }},
notANumber: {{ _('Valore non numerico')|tojson }},
outcomeOk: {{ _('OK')|tojson }},
outcomeNok: {{ _('NOK')|tojson }},
outcomeDone: {{ _('Registrato')|tojson }},
},
};
</script>
<script src="{{ url_for('static', filename='js/task-results.js') }}"></script>
{# The clock's rules - how a delay reads, when the buzzer goes, where the
measurement lives - are shared with the other screens rather than restated. #}
<script src="{{ url_for('static', filename='js/production-clock.js') }}"></script>
@@ -1028,6 +1212,10 @@ function taskExecute() {
// The measurement tasks of this recipe, in order: the loop the operator stays
// in once production starts, and what says which task closes a cycle.
measurementTaskIds: [],
// #55: notes to fill in before the capoturno closes the production, and whether
// they were already recorded (so cancelling the modal does not ask them again).
closeTaskIds: [],
closeTasksDone: false,
// The interval has elapsed and it has been said out loud - buzzer, and the
// operator on their way back to the measurement. Kept so it is said once.
dueAnnounced: false,
@@ -1039,6 +1227,11 @@ function taskExecute() {
supervisorAction: '', // 'out_of_tolerance', 'fermo_linea', 'fine_produzione'
supervisorUsername: '',
supervisorPassword: '',
// #59: the capoturno can authorise with name + 4-digit PIN instead of the password.
supervisorMode: 'pin', // 'pin' | 'password'
supervisorPinUsers: [], // [{id, display_name}] from /measure/api/pin-users
supervisorPinUser: null,
supervisorPin: '', // never kept past the modal: cleared on open and close
supervisorError: '',
supervisorValidating: false,
/* The out-of-tolerance measurement waiting for the capoturno:
@@ -1270,6 +1463,7 @@ function taskExecute() {
this.productionStarted = run.status !== 'closed';
this.cycleCount = run.cycle_count;
this.measurementTaskIds = run.measurement_task_ids || [];
this.closeTaskIds = run.close_task_ids || []; // #55
const seconds = run.seconds_to_next_measurement;
if (seconds === null || seconds === undefined) {
@@ -1696,6 +1890,7 @@ function taskExecute() {
this.currentValue = null;
this.errorMessage = '';
this.inputStartedAt = Date.now();
window.dispatchEvent(new CustomEvent('task-result-reset')); // #54: a check / note loops on the spot
},
// ---- Where the measurement lives ----
@@ -1788,6 +1983,33 @@ function taskExecute() {
return window.ProductionClock.format(this.timerRemaining);
},
/* #53: result recorded on a check / note task. In the loop of a production the
task counts as a step of the cycle exactly like a measurement task: the last
one closes the cycle (the server decides, as in confirmCycle), the others go
to the next one. If the server refuses, the panel stays with «Prosegui». */
async advanceAfterResult() {
if (this.productionRun && this.isMeasurementTaskOfRun) {
const countBefore = this.cycleCount;
const run = await this.postProduction(
'{{ url_for("measure.api_complete_cycle", run_id=0) }}'.replace('/0/', '/' + this.productionRun.id + '/'),
{ task_id: this.task.id },
);
if (!run) return;
this.adoptProductionRun(run);
if (run.cycle_count === countBefore && this.nextMeasurementTaskId) {
this.goToTask(this.nextMeasurementTaskId);
return;
}
}
this.goToNextTask();
},
/* #55: the end-of-production notes are recorded: now the capoturno's modal. */
closeTasksRecorded() {
this.closeTasksDone = true;
this.openSupervisorModal('fine_produzione');
},
// ---- Navigate to next task (Completato) ----
/* With a production open the operator stays in the measurement loop until the
capoturno closes it: after the last measurement task they go back to the
@@ -1828,10 +2050,17 @@ function taskExecute() {
this.productionError = '{{ _("Nessuna produzione aperta su questa stazione") }}';
return;
}
// #55: first the notes marked «alla fine della produzione»; the panel calls back
// here (closeTasksRecorded) when they are recorded.
if (action === 'fine_produzione' && this.closeTaskIds.length && !this.closeTasksDone) {
window.dispatchEvent(new CustomEvent('task-close-open', { detail: { ids: this.closeTaskIds } }));
return;
}
this.supervisorAction = action;
this.supervisorUsername = '';
this.supervisorPassword = '';
this.supervisorError = '';
this.resetSupervisorPin(); // #59: PIN fields cleared, names reloaded
this.showSupervisorModal = true;
},
@@ -1840,6 +2069,68 @@ function taskExecute() {
this.supervisorUsername = '';
this.supervisorPassword = '';
this.supervisorError = '';
this.supervisorPin = ''; // #59: the PIN does not outlive the modal
this.supervisorPinUser = null;
},
/* #59: PIN mode of the capoturno modal. */
resetSupervisorPin() {
this.supervisorPin = '';
this.supervisorPinUser = null;
this.supervisorMode = 'pin';
this.loadSupervisorPinUsers();
},
// Names with a PIN; an empty list or a failure opens the Password tab instead.
async loadSupervisorPinUsers() {
try {
const resp = await fetch('{{ url_for("measure.api_pin_users") }}', { headers: { 'Accept': 'application/json' } });
const list = resp.ok ? await resp.json() : [];
this.supervisorPinUsers = Array.isArray(list) ? list : [];
} catch (e) {
this.supervisorPinUsers = [];
}
if (!this.supervisorPinUsers.length) this.supervisorMode = 'password';
},
setSupervisorMode(mode) {
this.supervisorMode = mode;
this.supervisorPin = '';
this.supervisorPinUser = null;
this.supervisorError = '';
},
selectSupervisorUser(u) {
this.supervisorPinUser = u;
this.supervisorPin = '';
this.supervisorError = '';
},
backSupervisorUser() {
this.supervisorPinUser = null;
this.supervisorPin = '';
this.supervisorError = '';
},
// Submits by itself on the fourth digit.
pressSupervisorPin(d) {
if (this.supervisorValidating || this.supervisorPin.length >= 4) return;
this.supervisorPin += d;
this.supervisorError = '';
if (this.supervisorPin.length === 4) this.submitSupervisor();
},
get supervisorCanSubmit() {
if (this.supervisorMode === 'pin') return !!this.supervisorPinUser && this.supervisorPin.length === 4;
return !!this.supervisorUsername && !!this.supervisorPassword;
},
// The pair the proxies forward: id + PIN in PIN mode, username + password otherwise.
supervisorCredentials() {
if (this.supervisorMode === 'pin') {
return { supervisor_user_id: this.supervisorPinUser && this.supervisorPinUser.id, supervisor_pin: this.supervisorPin };
}
return { supervisor_username: this.supervisorUsername, supervisor_password: this.supervisorPassword };
},
/* What kind of task this is, as declared in the recipe.
@@ -1859,6 +2150,19 @@ function taskExecute() {
return t === 'camera_measure' || t === 'dxf_compare';
},
/* #54: «Controllo OK/NOK» - no quotes, the result is the outcome. */
get isCheckTask() {
return this.task.task_type === 'check';
},
/* #54 #55: tasks that bring their own result panel (a check, or a note with
fields). A note without fields stays the plain «Completato» task. */
get hasResultPanel() {
if (this.isCheckTask) return true;
return this.task.task_type === 'note' && Array.isArray(this.task.fields)
&& this.task.fields.length > 0;
},
/* A measurement task with no quotes yet: the recipe is unfinished. Said out
loud rather than silently rendering an empty screen. */
get isMeasureTaskWithoutQuotes() {
@@ -1910,16 +2214,15 @@ function taskExecute() {
const resp = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken },
body: JSON.stringify({
supervisor_username: this.supervisorUsername,
supervisor_password: this.supervisorPassword,
}),
body: JSON.stringify(this.supervisorCredentials()), // #59: PIN or password
});
const data = await resp.json().catch(() => null);
if (!resp.ok || (data && data.error)) {
this.supervisorError = (data && data.detail)
|| (this.supervisorMode === 'pin' && resp.status === 401 ? '{{ _("PIN errato") }}' : '')
|| '{{ _("Credenziali non valide o utente non autorizzato") }}';
if (this.supervisorMode === 'pin') this.supervisorPin = ''; // #59: wrong PIN stays in the modal, pad cleared
this.supervisorValidating = false;
return;
}
@@ -1954,10 +2257,7 @@ function taskExecute() {
const base = '{{ url_for("measure.api_pause_production", run_id=0) }}'
.replace('/0/pause', '/' + this.productionRun.id + '/' + action);
const run = await this.postProduction(base, {
supervisor_username: this.supervisorUsername,
supervisor_password: this.supervisorPassword,
});
const run = await this.postProduction(base, this.supervisorCredentials()); // #59: PIN or password
this.supervisorValidating = false;
if (!run) {
@@ -1965,6 +2265,7 @@ function taskExecute() {
// capoturno sees why the action was refused, rather than a silent no-op.
this.supervisorError = this.productionError || '{{ _("Azione non riuscita") }}';
this.productionError = '';
if (this.supervisorMode === 'pin') this.supervisorPin = ''; // #59: wrong PIN / lockout: pad cleared, modal stays
return;
}
@@ -0,0 +1,110 @@
// Behaviour test for static/js/task-results.js (#53 #54 #55), plain node, no framework.
// Run: node tests/js/test_task_results.js (exit code != 0 on failure)
const assert = require('assert');
const { taskResultForm } = require('../../static/js/task-results.js');
const calls = [];
let responses = [];
global.window = {
__taskResultsCfg: {
saveUrl: '/s', listUrl: '/l', uploadUrl: '/u', taskUrlTpl: '/t/0',
labels: { nokNeedsNote: 'Per un NOK serve la nota' },
},
sessionStorage: { _d: {}, getItem(k) { return this._d[k] || null; }, setItem(k, v) { this._d[k] = v; } },
};
global.FormData = class { constructor() { this.d = []; } append(k, v, n) { this.d.push([k, v, n]); } };
global.fetch = async (url, opts) => {
calls.push({ url, opts });
const r = responses.shift() || { status: 200, body: {} };
return { ok: r.status < 400, status: r.status, json: async () => r.body };
};
function comp(runId) {
const c = taskResultForm();
c.ctx = () => ({ runId, done() { c._done = (c._done || 0) + 1; } });
return c;
}
(async () => {
// check: NOK without note is refused before any request
let c = comp(4);
c.initForTask({ id: 30, task_type: 'check' });
responses = [{ status: 200, body: [] }]; // loadLast
await c.submit('nok');
assert.strictEqual(c.forms[0].error, 'Per un NOK serve la nota');
assert.ok(!calls.some((x) => x.url === '/s'), 'no POST for a NOK without note');
// check: OK goes to the server with the run id, then hands over
calls.length = 0;
responses = [{ status: 201, body: { id: 1, outcome: 'ok' } }];
await c.submit('ok');
const post = calls.find((x) => x.url === '/s');
const body = JSON.parse(post.opts.body);
assert.deepStrictEqual([body.task_id, body.production_run_id, body.outcome, body.values], [30, 4, 'ok', null]);
assert.strictEqual(c._done, 1);
assert.strictEqual(c.forms[0].result.outcome, 'ok');
// server 422: the detail is shown, nothing is handed over
c = comp(4);
responses = [{ status: 200, body: [] }, { status: 422, body: { detail: 'Per un NOK serve la nota' } }];
c.initForTask({ id: 30, task_type: 'check' }); // consumes the first one (loadLast)
await new Promise((r) => setTimeout(r, 0));
c.forms[0].note = 'x';
await c.submit('nok');
assert.strictEqual(c.forms[0].error, 'Per un NOK serve la nota');
assert.ok(!c._done);
// note with fields: defaults, upload of the attachment, values + attachments in the result
calls.length = 0;
c = comp(null);
const task = { id: 31, task_type: 'note', fields: [
{ key: 'peso', label: 'Peso', type: 'number', default: '1,5', attachment: false },
{ key: 'foto', label: 'Foto', type: 'text', default: null, attachment: true },
] };
c.initForTask(task);
assert.strictEqual(c.forms[0].values.peso, '1,5');
c.forms[0].files.foto = { name: 'a.png' };
responses = [
{ status: 201, body: { file_path: 'general/a.png' } },
{ status: 201, body: { id: 2, outcome: 'done' } },
];
await c.submit('done');
const saved = JSON.parse(calls.find((x) => x.url === '/s').opts.body);
assert.deepStrictEqual(saved.values, { peso: '1.5' });
assert.deepStrictEqual(saved.attachments, [{ key: 'foto', path: 'general/a.png', name: 'a.png' }]);
assert.strictEqual(saved.outcome, 'done');
assert.strictEqual(saved.production_run_id, null);
// non numeric value in a number field is refused locally
c = comp(null);
c.initForTask(task);
c.forms[0].values.peso = 'abc';
await c.submit('done');
assert.ok(c.forms[0].error.indexOf('Peso') !== -1);
// a note without fields has no panel
c = comp(null);
c.initForTask({ id: 33, task_type: 'note', fields: [] });
assert.strictEqual(c.forms.length, 0);
// end of production: tasks already recorded in the run are skipped; the rest recorded then handed over
calls.length = 0;
c = comp(4);
responses = [
{ status: 200, body: [{ task_id: 40 }] }, // results of the run
{ status: 200, body: { id: 40, title: 'A', task_type: 'note', fields: [] } },
{ status: 200, body: { id: 41, title: 'B', task_type: 'note', fields: [{ key: 'k', label: 'K', type: 'text', default: null, attachment: false }] } },
];
await c.openClose([40, 41]);
assert.strictEqual(c.forms[0].recorded, true);
assert.strictEqual(c.forms[1].recorded, false);
assert.ok(c.open && !c._done);
c.forms[1].values.k = 'ok';
responses = [{ status: 201, body: { id: 5, outcome: 'done' } }];
await c.submitClose();
assert.strictEqual(c._done, 1);
assert.strictEqual(c.open, false);
assert.strictEqual(calls.filter((x) => x.url === '/s').length, 1, 'only the pending task is posted');
console.log('ok');
})().catch((e) => { console.error(e); process.exit(1); });
@@ -0,0 +1,110 @@
"""#59/#51/#57: PIN nella gestione utenti (proxy admin, pannello) e link Monitor nel menu."""
from unittest.mock import MagicMock, patch
import pytest
from tests.conftest import api_get_router
USER = {
"id": 2, "username": "mario", "display_name": "Mario", "email": None,
"roles": ["MeasurementTec"], "is_admin": False, "language_pref": "it",
"theme_pref": "light", "active": True, "has_pin": True,
}
@pytest.fixture
def mock_admin_api():
mock = MagicMock()
mock.get.side_effect = api_get_router({
"/api/users": [USER],
"/api/settings": {"auto_logout_minutes": "30"},
})
with patch("blueprints.admin.api_client", mock):
yield mock
def test_pin_proxy_forwards_four_digits(logged_in_client, mock_admin_api):
mock_admin_api.put.return_value = {"message": "ok"}
resp = logged_in_client.put("/admin/api/users/2/pin", json={"pin": "1234"})
assert resp.status_code == 200
mock_admin_api.put.assert_called_once_with("/api/users/2/pin", data={"pin": "1234"})
def test_pin_proxy_forwards_null_to_remove(logged_in_client, mock_admin_api):
mock_admin_api.put.return_value = {"message": "ok"}
resp = logged_in_client.put("/admin/api/users/2/pin", json={"pin": None})
assert resp.status_code == 200
mock_admin_api.put.assert_called_once_with("/api/users/2/pin", data={"pin": None})
def test_pin_proxy_passes_422_detail(logged_in_client, mock_admin_api):
mock_admin_api.put.return_value = {
"error": True, "status_code": 422, "detail": "Gli admin non hanno il PIN",
}
resp = logged_in_client.put("/admin/api/users/1/pin", json={"pin": "1234"})
assert resp.status_code == 422
assert resp.get_json()["detail"] == "Gli admin non hanno il PIN"
def test_pin_proxy_requires_admin(client, mock_admin_api):
with client.session_transaction() as sess:
sess["api_key"] = "k"
sess["user"] = {"id": 3, "username": "op", "roles": ["MeasurementTec"],
"is_admin": False, "language_pref": "it", "theme_pref": "light"}
resp = client.put("/admin/api/users/2/pin", json={"pin": "1234"})
assert resp.status_code != 200
mock_admin_api.put.assert_not_called()
def test_users_panel_has_pin_controls(logged_in_client, mock_admin_api):
html = logged_in_client.get("/admin/settings?tab=utenti").data.decode("utf-8")
assert "openPin(user)" in html # pulsante Imposta/Reset PIN
assert "user.has_pin === true" in html # indicatore PIN impostato
assert "removePin()" in html # Togli PIN
assert "form.pin" in html # campo PIN alla creazione
assert "!user.is_admin" in html # niente PIN per gli admin
def _session_client(client, roles, is_admin):
with client.session_transaction() as sess:
sess["api_key"] = "k"
sess["user"] = {"id": 5, "username": "u", "display_name": "U", "roles": roles,
"is_admin": is_admin, "language_pref": "it", "theme_pref": "light"}
return client
@pytest.fixture
def mock_monitor_api():
mock = MagicMock()
mock.get.return_value = []
with patch("blueprints.monitor.api_client", mock), \
patch("blueprints.measure.api_client", mock):
yield mock
def test_monitor_link_for_admin(logged_in_client, mock_monitor_api):
html = logged_in_client.get("/monitor").data.decode("utf-8")
assert 'href="/monitor"' in html
def test_monitor_link_for_supervisor(client, mock_monitor_api):
_session_client(client, ["Supervisor"], False)
html = client.get("/monitor").data.decode("utf-8")
assert 'href="/monitor"' in html
def test_monitor_link_absent_for_operator(client, mock_api_client, monkeypatch):
# Stessa preparazione di test_measure.py: la pagina vuole STATION_CODE a config ricaricata
monkeypatch.setenv("STATION_CODE", "ST-TEST")
import importlib
import config
importlib.reload(config)
import blueprints.measure
importlib.reload(blueprints.measure)
mock_api_client.get_station_recipes.return_value = []
_session_client(client, ["MeasurementTec"], False)
resp = client.get("/measure/select")
assert resp.status_code == 200
html = resp.data.decode("utf-8")
assert "/measure/select" in html or "Misure" in html or "Measure" in html # menu reso
assert 'href="/monitor"' not in html
@@ -0,0 +1,79 @@
"""Editor task del Maker: tipo check, in_loop, at_close, campi della nota (#53 #54 #55)."""
import re
from tests.conftest import api_get_router
def _editor_html(logged_in_client, mock_api_client, tasks):
mock_api_client.get.side_effect = api_get_router({
"/api/recipes/3": {"id": 3, "code": "R", "name": "R"},
"/api/recipes/3/tasks": tasks,
})
resp = logged_in_client.get("/maker/recipes/3/tasks")
assert resp.status_code == 200
return resp.get_data(as_text=True)
class TestTaskEditorFields:
def test_check_option_in_both_selects(self, logged_in_client, mock_api_client):
"""#54: il tipo check e presente nella select di creazione e in quella di modifica."""
html = _editor_html(logged_in_client, mock_api_client, [{
"id": 11, "title": "T", "task_type": "note", "subtasks": [],
"file_path": None, "order_index": 1,
}])
assert html.count('<option value="check">') == 2
assert "Controllo OK/NOK" in html
def test_loop_and_note_controls_present(self, logged_in_client, mock_api_client):
"""#53 #55: checkbox del ciclo, a fine produzione e editor dei campi in entrambi i form."""
html = _editor_html(logged_in_client, mock_api_client, [{
"id": 11, "title": "T", "task_type": "note", "subtasks": [],
"file_path": None, "order_index": 1, "in_loop": True,
}])
assert "Nel ciclo di misura (timer e cicalino)" in html
assert "Compila a fine produzione" in html
assert 'x-model="newTask.in_loop"' in html
assert 'x-model="editTaskData.in_loop"' in html
assert "addNoteField(newTask)" in html
assert "addNoteField(editTaskData)" in html
# segno sui task nel ciclo, e niente quote per check
assert 'x-show="task.in_loop"' in html
assert "task.task_type !== 'check'" in html
def test_save_bodies_carry_new_fields(self, logged_in_client, mock_api_client):
"""Creazione e modifica inviano in_loop, at_close e fields nel corpo."""
html = _editor_html(logged_in_client, mock_api_client, [])
assert len(re.findall(r"in_loop: !!this\.(newTask|editTaskData)\.in_loop", html)) == 2
assert len(re.findall(r"fields: fp\.fields", html)) == 2
assert len(re.findall(r"at_close: this\.(newTask|editTaskData)\.task_type === 'note'", html)) == 2
class TestTaskProxyForwardsFields:
"""I proxy inoltrano il corpo cosi com'e: in_loop/at_close/fields arrivano al backend."""
BODY = {
"title": "Note finali", "task_type": "note", "in_loop": False, "at_close": True,
"fields": [{"key": "operatore", "label": "Operatore", "type": "text",
"default": None, "attachment": False}],
}
def test_create_forwards(self, logged_in_client, mock_api_client):
mock_api_client.post.return_value = {"id": 5, **self.BODY}
resp = logged_in_client.post("/maker/api/recipes/3/tasks", json=self.BODY)
assert resp.status_code == 201
mock_api_client.post.assert_called_once_with("/api/recipes/3/tasks", data=self.BODY)
def test_update_forwards(self, logged_in_client, mock_api_client):
mock_api_client.put.return_value = {"id": 5, **self.BODY}
resp = logged_in_client.put("/maker/api/tasks/5", json=self.BODY)
assert resp.status_code == 200
mock_api_client.put.assert_called_once_with("/api/tasks/5", data=self.BODY)
def test_update_422_detail_returned(self, logged_in_client, mock_api_client):
"""Un 422 del backend arriva al browser con il suo detail e lo status."""
mock_api_client.put.return_value = {
"error": True, "status_code": 422, "detail": "Massimo 20 campi",
}
resp = logged_in_client.put("/maker/api/tasks/5", json=self.BODY)
assert resp.status_code == 422
assert resp.get_json()["detail"] == "Massimo 20 campi"
@@ -0,0 +1,168 @@
"""Capoturno authorisation by PIN (#59): proxies of the measure blueprint.
The modal in task_execute.html sends either ``supervisor_user_id`` + ``supervisor_pin``
or the old ``supervisor_username`` + ``supervisor_password``; the proxies forward
whichever pair is present and let the PIN/lockout refusals through to the modal.
"""
from unittest.mock import patch
import pytest
from flask import session
RUN = {"id": 7, "status": "paused"}
@pytest.fixture
def measure_mod():
from blueprints import measure
return measure
def test_pin_users_proxy_returns_only_id_and_name(logged_in_client, measure_mod):
with patch.object(measure_mod, "api_client") as mock_api:
mock_api.get.return_value = [
{"id": 3, "display_name": "Anna Rossi", "pin_hash": "x"},
{"id": 4, "display_name": "bruno"},
]
resp = logged_in_client.get("/measure/api/pin-users")
assert resp.status_code == 200
assert resp.get_json() == [
{"id": 3, "display_name": "Anna Rossi"},
{"id": 4, "display_name": "bruno"},
]
assert mock_api.get.call_args[0][0] == "/api/auth/pin-users"
def test_pin_users_proxy_empty_on_backend_error(logged_in_client, measure_mod):
"""The modal opens the Password tab when the list is empty."""
with patch.object(measure_mod, "api_client") as mock_api:
mock_api.get.return_value = {"error": True, "status_code": 502, "detail": "down"}
resp = logged_in_client.get("/measure/api/pin-users")
assert resp.status_code == 200
assert resp.get_json() == []
@pytest.mark.parametrize("action", ["pause", "resume", "close"])
def test_supervised_action_forwards_user_id_and_pin(logged_in_client, measure_mod, action):
with patch.object(measure_mod, "api_client") as mock_api:
mock_api.post.return_value = RUN
resp = logged_in_client.post(
f"/measure/api/production/7/{action}",
json={"supervisor_user_id": 5, "supervisor_pin": "1234"},
)
assert resp.status_code == 200
endpoint, kwargs = mock_api.post.call_args
assert endpoint[0] == f"/api/production-runs/7/{action}"
assert kwargs["data"]["supervisor_user_id"] == 5
assert kwargs["data"]["supervisor_pin"] == "1234"
assert "supervisor_username" not in kwargs["data"]
assert "supervisor_password" not in kwargs["data"]
def test_supervised_action_password_pair_unchanged(logged_in_client, measure_mod):
with patch.object(measure_mod, "api_client") as mock_api:
mock_api.post.return_value = RUN
resp = logged_in_client.post(
"/measure/api/production/7/pause",
json={"supervisor_username": "capo", "supervisor_password": "segreta"},
)
assert resp.status_code == 200
kwargs = mock_api.post.call_args[1]
assert kwargs["data"]["supervisor_username"] == "capo"
assert kwargs["data"]["supervisor_password"] == "segreta"
assert "supervisor_pin" not in kwargs["data"]
def test_supervised_action_pin_without_user_is_refused(logged_in_client, measure_mod):
with patch.object(measure_mod, "api_client") as mock_api:
resp = logged_in_client.post(
"/measure/api/production/7/pause", json={"supervisor_pin": "1234"},
)
assert resp.status_code == 400
mock_api.post.assert_not_called()
def test_authorise_measurement_forwards_user_id_and_pin(logged_in_client, measure_mod):
with patch.object(measure_mod, "api_client") as mock_api:
mock_api.post.return_value = {"id": 9}
resp = logged_in_client.post(
"/measure/api/measurements/9/authorise",
json={"supervisor_user_id": 5, "supervisor_pin": "4321"},
)
assert resp.status_code == 200
endpoint, kwargs = mock_api.post.call_args
assert endpoint[0] == "/api/measurements/9/authorise"
assert kwargs["data"] == {"supervisor_user_id": 5, "supervisor_pin": "4321"}
def test_wrong_pin_401_stays_in_the_modal_and_keeps_the_session(logged_in_client, measure_mod):
"""api_client empties the session on any 401; a wrong PIN must not log the operator out."""
def refuse(*args, **kwargs):
session.clear() # what api_client._handle_session_expired does
return {
"error": True, "status_code": 401, "session_expired": True,
"detail": "Invalid supervisor credentials",
}
with patch.object(measure_mod, "api_client") as mock_api:
mock_api.post.side_effect = refuse
resp = logged_in_client.post(
"/measure/api/production/7/close",
json={"supervisor_user_id": 5, "supervisor_pin": "0000"},
)
assert resp.status_code == 401
body = resp.get_json()
assert body["detail"] == "PIN errato"
assert "session_expired" not in body
with logged_in_client.session_transaction() as sess:
assert sess.get("api_key") == "test-api-key-12345"
def test_real_expired_session_401_still_clears_the_session(logged_in_client, measure_mod):
"""A 401 that is not about the capoturno keeps its session_expired flag."""
def expired(*args, **kwargs):
session.clear()
return {
"error": True, "status_code": 401, "session_expired": True,
"detail": "Invalid API key",
}
with patch.object(measure_mod, "api_client") as mock_api:
mock_api.post.side_effect = expired
resp = logged_in_client.post(
"/measure/api/production/7/close",
json={"supervisor_user_id": 5, "supervisor_pin": "0000"},
)
assert resp.status_code == 401
assert resp.get_json()["session_expired"] is True
with logged_in_client.session_transaction() as sess:
assert "api_key" not in sess
def test_locked_user_423_passes_the_detail(logged_in_client, measure_mod):
with patch.object(measure_mod, "api_client") as mock_api:
mock_api.post.return_value = {
"error": True, "status_code": 423,
"detail": "Utente bloccato fino alle 10:15",
}
resp = logged_in_client.post(
"/measure/api/production/7/pause",
json={"supervisor_user_id": 5, "supervisor_pin": "1111"},
)
assert resp.status_code == 423
assert resp.get_json()["detail"] == "Utente bloccato fino alle 10:15"
def test_password_401_detail_is_kept(logged_in_client, measure_mod):
"""Password mode: the refusal text is the backend's, as before."""
with patch.object(measure_mod, "api_client") as mock_api:
mock_api.post.return_value = {
"error": True, "status_code": 403, "detail": "User is not authorised as supervisor",
}
resp = logged_in_client.post(
"/measure/api/production/7/pause",
json={"supervisor_username": "op", "supervisor_password": "x"},
)
assert resp.status_code == 403
assert resp.get_json()["detail"] == "User is not authorised as supervisor"
@@ -0,0 +1,205 @@
"""gitea:Adriano/TieMeasureFlow#53 #54 #55 - operator side of the check / note results.
The backend (task-results, in_loop, close_task_ids) lives in another unit: api_client
is mocked and the tests work against the contract written in the issues.
"""
import io
import shutil
import subprocess
from pathlib import Path
import pytest
from tests.conftest import api_get_router
from tests.test_template_js_syntax import (
_INLINE_SCRIPT_RX, _check_alpine_attributes, _force_italian, _node_check,
)
FLASK_APP_DIR = Path(__file__).resolve().parent.parent
RECIPE = {
"id": 3, "code": "REC-1", "name": "Ricetta",
"measurement_interval_minutes": 30, "allow_manual_input": True,
"current_version": {"id": 5, "version_number": 1},
}
def _task(task_id, task_type, **extra):
task = {
"id": task_id, "recipe_id": 3, "version_id": 5, "task_type": task_type,
"title": "Task d'esempio", "directive": None, "description": None,
"file_path": None, "file_type": None, "annotations_json": None,
"subtasks": [], "fields": None,
}
task.update(extra)
return task
def _render(client, mock_api_client, monkeypatch, task):
monkeypatch.setattr("blueprints.measure.Config.STATION_CODE", "ST-TEST")
mock_api_client.get_station_recipes.return_value = {"items": [{"id": 3}]}
mock_api_client.get.side_effect = api_get_router({
f"/api/tasks/{task['id']}": task,
"/api/recipes/3/tasks": [{"id": task["id"], "order_index": 0}],
"/api/recipes/3": RECIPE,
})
return client.get(f"/measure/execute/{task['id']}")
# ---------------------------------------------------------------------------
# Page
# ---------------------------------------------------------------------------
def test_check_task_has_ok_nok_buttons(logged_in_client, mock_api_client, monkeypatch):
resp = _render(logged_in_client, mock_api_client, monkeypatch, _task(30, "check"))
html = resp.get_data(as_text=True)
assert resp.status_code == 200
assert "submit('ok')" in html and "submit('nok')" in html
assert "taskResultForm()" in html
assert "/measure/api/task-results" in html
assert "js/task-results.js" in html
def test_note_with_fields_shows_field_inputs(logged_in_client, mock_api_client, monkeypatch):
fields = [
{"key": "peso", "label": "Peso", "type": "number", "default": "1.5", "attachment": False},
{"key": "foto", "label": "Foto", "type": "text", "default": None, "attachment": True},
]
task = _task(31, "note", fields=fields)
html = _render(logged_in_client, mock_api_client, monkeypatch, task).get_data(as_text=True)
# the fields travel to the page in the task data, the inputs are drawn from them
assert '"key": "peso"' in html or '"key":"peso"' in html
assert 'x-model="f.values[fld.key]"' in html
assert "pickFile(f, fld.key, $event)" in html
assert "submit('done')" in html
def test_close_panel_and_modal_hook_present(logged_in_client, mock_api_client, monkeypatch):
html = _render(logged_in_client, mock_api_client, monkeypatch, _task(32, "check")).get_data(as_text=True)
assert "task-close-open" in html
assert "closeTasksRecorded()" in html
assert "close_task_ids" in html
assert "/measure/api/tasks/0" in html
@pytest.mark.parametrize("task", [
_task(30, "check"),
_task(31, "note", fields=[{"key": "n", "label": "L'etichetta", "type": "text",
"default": None, "attachment": True}]),
])
def test_inline_js_and_alpine_attributes_valid(logged_in_client, mock_api_client, monkeypatch, task):
_force_italian(logged_in_client)
html = _render(logged_in_client, mock_api_client, monkeypatch, task).get_data(as_text=True)
scripts = _INLINE_SCRIPT_RX.findall(html)
assert scripts
for i, body in enumerate(scripts):
_node_check(body, f"/measure/execute check-note script[{i}]")
_check_alpine_attributes(html, "/measure/execute check-note")
def test_plain_note_keeps_old_footer(logged_in_client, mock_api_client, monkeypatch):
"""A note without fields has no result panel: it is still the plain «Completato»."""
html = _render(logged_in_client, mock_api_client, monkeypatch, _task(33, "note")).get_data(as_text=True)
assert "goToNextTask()" in html
# the panel markup is in the page either way; hasResultPanel (false here) decides
assert "hasResultPanel" in html
# ---------------------------------------------------------------------------
# Proxies
# ---------------------------------------------------------------------------
def test_task_result_post_is_forwarded(logged_in_client, mock_api_client):
mock_api_client.post.return_value = {"id": 9, "outcome": "nok"}
body = {
"task_id": 30, "production_run_id": 4, "outcome": "nok", "note": "graffio",
"values": None, "attachments": None, "ignored": "x",
}
resp = logged_in_client.post("/measure/api/task-results", json=body)
assert resp.status_code == 201
assert resp.get_json()["id"] == 9
args, kwargs = mock_api_client.post.call_args
assert args[0] == "/api/task-results"
assert kwargs["data"] == {
"task_id": 30, "production_run_id": 4, "outcome": "nok", "note": "graffio",
"values": None, "attachments": None,
}
def test_task_result_422_detail_is_passed_through(logged_in_client, mock_api_client):
mock_api_client.post.return_value = {
"error": True, "status_code": 422, "detail": "Per un NOK serve la nota",
}
resp = logged_in_client.post(
"/measure/api/task-results", json={"task_id": 30, "outcome": "nok"})
assert resp.status_code == 422
assert resp.get_json()["detail"] == "Per un NOK serve la nota"
def test_task_result_requires_task_id(logged_in_client, mock_api_client):
resp = logged_in_client.post("/measure/api/task-results", json={"outcome": "ok"})
assert resp.status_code == 400
mock_api_client.post.assert_not_called()
def test_task_results_list_forwards_filters(logged_in_client, mock_api_client):
mock_api_client.get.return_value = [{"id": 1, "task_id": 30}]
resp = logged_in_client.get("/measure/api/task-results?production_run_id=4&task_id=30")
assert resp.status_code == 200
assert resp.get_json() == [{"id": 1, "task_id": 30}]
args, kwargs = mock_api_client.get.call_args
assert args[0] == "/api/task-results"
assert kwargs["params"] == {"production_run_id": "4", "task_id": "30"}
def test_attachment_upload_forwards_multipart(logged_in_client, mock_api_client):
mock_api_client.post.return_value = {"file_path": "general/foto.png", "file_type": "image/png"}
resp = logged_in_client.post(
"/measure/api/attachments",
data={"file": (io.BytesIO(b"\x89PNG fake"), "foto.png", "image/png")},
content_type="multipart/form-data",
)
assert resp.status_code == 201
assert resp.get_json()["file_path"] == "general/foto.png"
args, kwargs = mock_api_client.post.call_args
assert args[0] == "/api/files/upload"
name, content, mime = kwargs["files"]["file"]
assert name == "foto.png" and mime == "image/png" and content.startswith(b"\x89PNG")
def test_attachment_upload_requires_file(logged_in_client, mock_api_client):
resp = logged_in_client.post("/measure/api/attachments", data={})
assert resp.status_code == 400
mock_api_client.post.assert_not_called()
def test_attachment_upload_error_detail_is_passed_through(logged_in_client, mock_api_client):
mock_api_client.post.return_value = {"error": True, "status_code": 400, "detail": "File type not allowed"}
resp = logged_in_client.post(
"/measure/api/attachments",
data={"file": (io.BytesIO(b"x"), "a.exe", "application/octet-stream")},
content_type="multipart/form-data",
)
assert resp.status_code == 400
assert resp.get_json()["detail"] == "File type not allowed"
def test_get_task_proxy(logged_in_client, mock_api_client):
mock_api_client.get.return_value = {"id": 40, "task_type": "note", "fields": []}
resp = logged_in_client.get("/measure/api/tasks/40")
assert resp.status_code == 200
assert resp.get_json()["id"] == 40
assert mock_api_client.get.call_args[0][0] == "/api/tasks/40"
# ---------------------------------------------------------------------------
# Component logic (plain node, stubbed fetch)
# ---------------------------------------------------------------------------
def test_task_results_component_js():
node = shutil.which("node")
if not node:
pytest.skip("node non installato: test JS di task-results.js saltato")
script = Path(__file__).resolve().parent / "js" / "test_task_results.js"
result = subprocess.run(
[node, str(script)], cwd=str(FLASK_APP_DIR),
capture_output=True, text=True, timeout=30,
)
assert result.returncode == 0, f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
@@ -179,7 +179,7 @@ def test_no_open_run_means_no_fermo_linea_and_no_fake_timer(
"""#61: senza produzione aperta i pulsanti non si vedono, la modale non si
apre e un rifiuto del server non avvia un timer locale finto."""
html = _execute_page(logged_in_client, mock_api_client, monkeypatch)
assert 'x-if="isMeasureTask && productionRun"' in html
assert 'x-if="(isMeasureTask || isMeasurementTaskOfRun) && productionRun"' in html # #53: anche i task del ciclo
assert "!this.productionRun" in html
# #61: loadProductionRun mostra il detail del server, non solo il messaggio generico
assert "errBody.detail" in html
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff