Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 736b853e22 | |||
| 627ae15591 | |||
| c6d4260ede | |||
| 459d1c71ea | |||
| 05e3faa9d4 | |||
| dc94b2e778 | |||
| 3c55550d93 | |||
| e07c4d4eee | |||
| 0b45f1c35a | |||
| 743f246cbf | |||
| f541f4a810 | |||
| 07e23b957d |
@@ -1,5 +1,6 @@
|
|||||||
"""Authentication router - login, logout, profile."""
|
"""Authentication router - login, logout, profile."""
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||||
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.backend.database import get_db
|
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 (
|
from src.backend.models.api.user import (
|
||||||
LoginRequest,
|
LoginRequest,
|
||||||
LoginResponse,
|
LoginResponse,
|
||||||
|
PinLoginRequest,
|
||||||
|
PinUserItem,
|
||||||
UserProfileUpdate,
|
UserProfileUpdate,
|
||||||
UserResponse,
|
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"])
|
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)
|
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
async def logout(
|
async def logout(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|||||||
@@ -147,6 +147,7 @@ async def authorise_measurement(
|
|||||||
|
|
||||||
supervisor = await auth_service.authorise_supervisor(
|
supervisor = await auth_service.authorise_supervisor(
|
||||||
db, action.supervisor_username, action.supervisor_password,
|
db, action.supervisor_username, action.supervisor_password,
|
||||||
|
user_id=action.supervisor_user_id, pin=action.supervisor_pin, # #59
|
||||||
)
|
)
|
||||||
measurement = await measurement_service.authorise_measurement(
|
measurement = await measurement_service.authorise_measurement(
|
||||||
db, measurement, supervisor,
|
db, measurement, supervisor,
|
||||||
|
|||||||
@@ -163,6 +163,7 @@ async def pause_production_run(
|
|||||||
run = await production_service.get_run(db, run_id)
|
run = await production_service.get_run(db, run_id)
|
||||||
supervisor = await auth_service.authorise_supervisor(
|
supervisor = await auth_service.authorise_supervisor(
|
||||||
db, action.supervisor_username, action.supervisor_password,
|
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)
|
run = await production_service.pause_run(db, run, user, supervisor, note=action.note)
|
||||||
return await _as_response(db, run)
|
return await _as_response(db, run)
|
||||||
@@ -179,6 +180,7 @@ async def resume_production_run(
|
|||||||
run = await production_service.get_run(db, run_id)
|
run = await production_service.get_run(db, run_id)
|
||||||
supervisor = await auth_service.authorise_supervisor(
|
supervisor = await auth_service.authorise_supervisor(
|
||||||
db, action.supervisor_username, action.supervisor_password,
|
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)
|
run = await production_service.resume_run(db, run, user, supervisor, note=action.note)
|
||||||
return await _as_response(db, run)
|
return await _as_response(db, run)
|
||||||
@@ -195,6 +197,7 @@ async def close_production_run(
|
|||||||
run = await production_service.get_run(db, run_id)
|
run = await production_service.get_run(db, run_id)
|
||||||
supervisor = await auth_service.authorise_supervisor(
|
supervisor = await auth_service.authorise_supervisor(
|
||||||
db, action.supervisor_username, action.supervisor_password,
|
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)
|
run = await production_service.close_run(db, run, user, supervisor, note=action.note)
|
||||||
return await _as_response(db, run)
|
return await _as_response(db, run)
|
||||||
|
|||||||
@@ -6,8 +6,14 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from src.backend.database import get_db
|
from src.backend.database import get_db
|
||||||
from src.backend.api.middleware.api_key import require_admin_user
|
from src.backend.api.middleware.api_key import require_admin_user
|
||||||
from src.backend.models.orm.user import User
|
from src.backend.models.orm.user import User
|
||||||
from src.backend.models.api.user import UserCreate, UserPasswordChange, UserResponse, UserUpdate
|
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
|
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"])
|
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}"}
|
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)
|
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
async def deactivate_user(
|
async def deactivate_user(
|
||||||
user_id: int,
|
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')
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
from src.backend.models.api.tipi import UtcDateTime, UtcNaiveInput
|
from src.backend.models.api.tipi import UtcDateTime, UtcNaiveInput
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||||
|
|
||||||
|
|
||||||
class MeasurementCreate(BaseModel):
|
class MeasurementCreate(BaseModel):
|
||||||
@@ -71,8 +71,21 @@ class MeasurementListResponse(BaseModel):
|
|||||||
class MeasurementAuthorisation(BaseModel):
|
class MeasurementAuthorisation(BaseModel):
|
||||||
"""Credentials of the supervisor allowing an out-of-tolerance value to stand."""
|
"""Credentials of the supervisor allowing an out-of-tolerance value to stand."""
|
||||||
|
|
||||||
supervisor_username: str = Field(..., min_length=1)
|
# #59: o username+password, oppure supervisor_user_id+supervisor_pin
|
||||||
supervisor_password: str = Field(..., min_length=1)
|
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):
|
class TaskProgressResponse(BaseModel):
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
from src.backend.models.api.tipi import UtcDateTime
|
from src.backend.models.api.tipi import UtcDateTime
|
||||||
from typing import Literal, Optional
|
from typing import Literal, Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||||
|
|
||||||
|
|
||||||
class ProductionRunCreate(BaseModel):
|
class ProductionRunCreate(BaseModel):
|
||||||
@@ -64,8 +64,21 @@ class ProductionRunWithEventsResponse(ProductionRunResponse):
|
|||||||
class SupervisorAction(BaseModel):
|
class SupervisorAction(BaseModel):
|
||||||
"""Credentials of the supervisor authorising a line stop or a close."""
|
"""Credentials of the supervisor authorising a line stop or a close."""
|
||||||
|
|
||||||
supervisor_username: str = Field(..., min_length=1)
|
# #59: o username+password, oppure supervisor_user_id+supervisor_pin
|
||||||
supervisor_password: str = Field(..., min_length=1)
|
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
|
note: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ class UserResponse(BaseModel):
|
|||||||
active: bool
|
active: bool
|
||||||
created_at: UtcDateTime
|
created_at: UtcDateTime
|
||||||
last_login: Optional[UtcDateTime] = None
|
last_login: Optional[UtcDateTime] = None
|
||||||
|
# #59: solo il fatto che il PIN esista, mai l'hash
|
||||||
|
has_pin: bool = False
|
||||||
|
|
||||||
|
|
||||||
class UserPasswordChange(BaseModel):
|
class UserPasswordChange(BaseModel):
|
||||||
@@ -68,3 +70,21 @@ class LoginResponse(BaseModel):
|
|||||||
user: UserResponse
|
user: UserResponse
|
||||||
api_key: str
|
api_key: str
|
||||||
message: str = "Login successful"
|
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}$")
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,13 @@ class User(Base):
|
|||||||
# Admin flag separate from roles
|
# Admin flag separate from roles
|
||||||
is_admin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
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
|
# User preferences
|
||||||
language_pref: Mapped[str] = mapped_column(
|
language_pref: Mapped[str] = mapped_column(
|
||||||
Enum("it", "en", name="language_enum"), nullable=False, default="it"
|
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)
|
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:
|
def has_role(self, role: str) -> bool:
|
||||||
"""Check if user has a specific role."""
|
"""Check if user has a specific role."""
|
||||||
return role in (self.roles or [])
|
return role in (self.roles or [])
|
||||||
|
|||||||
@@ -54,7 +54,79 @@ async def authenticate_user(
|
|||||||
return 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.
|
"""Check the credentials of the supervisor authorising something.
|
||||||
|
|
||||||
Used wherever the shop floor needs a capoturno: a line stop, a closure, an
|
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
|
Uses authenticate_user rather than a full login: logging in would issue a
|
||||||
needless API key for the supervisor.
|
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:
|
if supervisor is None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
|||||||
@@ -149,3 +149,22 @@ class TestVisionRecipeDirMigration:
|
|||||||
assert "vision_recipe_dir" not in cols()
|
assert "vision_recipe_dir" not in cols()
|
||||||
command.upgrade(cfg, "head") # e di nuovo su
|
command.upgrade(cfg, "head") # e di nuovo su
|
||||||
assert "vision_recipe_dir" in cols()
|
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()
|
||||||
|
|||||||
@@ -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
|
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"])
|
@admin_bp.route("/api/users/<int:user_id>/toggle-active", methods=["POST"])
|
||||||
@login_required
|
@login_required
|
||||||
@admin_required
|
@admin_required
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import requests as http_requests
|
|||||||
|
|
||||||
from flask import (
|
from flask import (
|
||||||
Blueprint, Response, abort, flash, jsonify, redirect, render_template,
|
Blueprint, Response, abort, flash, jsonify, redirect, render_template,
|
||||||
request, session, url_for,
|
g, request, session, url_for,
|
||||||
)
|
)
|
||||||
from flask_babel import gettext as _
|
from flask_babel import gettext as _
|
||||||
|
|
||||||
@@ -654,6 +654,74 @@ def api_pending_authorisation():
|
|||||||
return jsonify(resp), 200
|
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"])
|
@measure_bp.route("/api/measurements/<int:measurement_id>/authorise", methods=["POST"])
|
||||||
@login_required
|
@login_required
|
||||||
@role_required("MeasurementTec")
|
@role_required("MeasurementTec")
|
||||||
@@ -665,20 +733,10 @@ def api_authorise_measurement(measurement_id: int):
|
|||||||
written down nowhere and nothing depending on it.
|
written down nowhere and nothing depending on it.
|
||||||
"""
|
"""
|
||||||
data = request.get_json(silent=True) or {}
|
data = request.get_json(silent=True) or {}
|
||||||
username = (data.get("supervisor_username") or "").strip()
|
creds, error = _supervisor_credentials(data) # #59: PIN or password
|
||||||
password = data.get("supervisor_password") or ""
|
if error:
|
||||||
if not username or not password:
|
return error
|
||||||
return jsonify({
|
return _post_supervised(f"/api/measurements/{measurement_id}/authorise", creds)
|
||||||
"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
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -770,24 +828,12 @@ def _supervised_action(run_id: int, action: str):
|
|||||||
leave no such record, and is an extra round trip besides.
|
leave no such record, and is an extra round trip besides.
|
||||||
"""
|
"""
|
||||||
data = request.get_json(silent=True) or {}
|
data = request.get_json(silent=True) or {}
|
||||||
username = (data.get("supervisor_username") or "").strip()
|
creds, error = _supervisor_credentials(data) # #59: PIN or password
|
||||||
password = data.get("supervisor_password") or ""
|
if error:
|
||||||
if not username or not password:
|
return error
|
||||||
return jsonify({
|
return _post_supervised(
|
||||||
"error": True, "detail": _("Username e password richiesti"),
|
f"/api/production-runs/{run_id}/{action}", creds, {"note": data.get("note")},
|
||||||
}), 400
|
|
||||||
|
|
||||||
resp = api_client.post(
|
|
||||||
f"/api/production-runs/{run_id}/{action}",
|
|
||||||
data={
|
|
||||||
"supervisor_username": username,
|
|
||||||
"supervisor_password": password,
|
|
||||||
"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"])
|
@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")
|
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)
|
# 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">
|
<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>
|
<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>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</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"/>
|
<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>
|
</svg>
|
||||||
</button>
|
</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"
|
<button @click="toggleActive(user)" class="p-1.5 rounded-lg transition-colors"
|
||||||
:class="user.active
|
:class="user.active
|
||||||
? 'text-[var(--text-secondary)] hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20'
|
? 'text-[var(--text-secondary)] hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20'
|
||||||
@@ -210,6 +218,20 @@
|
|||||||
minlength="6">
|
minlength="6">
|
||||||
</div>
|
</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 -->
|
<!-- Roles -->
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-[var(--text-primary)] mb-2">{{ _('Ruoli') }}</label>
|
<label class="block text-sm font-medium text-[var(--text-primary)] mb-2">{{ _('Ruoli') }}</label>
|
||||||
@@ -374,4 +396,59 @@
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|||||||
@@ -11,6 +11,13 @@ function userManagement(initialUsers) {
|
|||||||
resetPw: '',
|
resetPw: '',
|
||||||
resetDone: false,
|
resetDone: false,
|
||||||
resetError: '',
|
resetError: '',
|
||||||
|
// #59: stato del modal PIN
|
||||||
|
showPin: false,
|
||||||
|
pinUser: null,
|
||||||
|
pinValue: '',
|
||||||
|
pinDone: false,
|
||||||
|
pinRemoved: false,
|
||||||
|
pinError: '',
|
||||||
isEditing: false,
|
isEditing: false,
|
||||||
editingUserId: null,
|
editingUserId: null,
|
||||||
toggleUser: null,
|
toggleUser: null,
|
||||||
@@ -21,6 +28,7 @@ function userManagement(initialUsers) {
|
|||||||
display_name: '',
|
display_name: '',
|
||||||
email: '',
|
email: '',
|
||||||
password: '',
|
password: '',
|
||||||
|
pin: '', // #59: PIN facoltativo alla creazione
|
||||||
roles: [],
|
roles: [],
|
||||||
is_admin: false,
|
is_admin: false,
|
||||||
language_pref: 'it',
|
language_pref: 'it',
|
||||||
@@ -52,6 +60,7 @@ function userManagement(initialUsers) {
|
|||||||
display_name: '',
|
display_name: '',
|
||||||
email: '',
|
email: '',
|
||||||
password: '',
|
password: '',
|
||||||
|
pin: '', // #59
|
||||||
roles: [],
|
roles: [],
|
||||||
is_admin: false,
|
is_admin: false,
|
||||||
language_pref: 'it',
|
language_pref: 'it',
|
||||||
@@ -134,6 +143,12 @@ function userManagement(initialUsers) {
|
|||||||
return;
|
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 = {
|
const data = {
|
||||||
username: this.form.username,
|
username: this.form.username,
|
||||||
password: this.form.password,
|
password: this.form.password,
|
||||||
@@ -158,6 +173,24 @@ function userManagement(initialUsers) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.users.push(result);
|
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();
|
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) {
|
toggleActive(user) {
|
||||||
this.toggleUser = user;
|
this.toggleUser = user;
|
||||||
this.showToggleConfirm = true;
|
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_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_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. #}
|
{# Single nav link, desktop or mobile variant. `icon` is SVG path markup. #}
|
||||||
{% macro nav_link(href, label, icon, active=false, mobile=false) -%}
|
{% macro nav_link(href, label, icon, active=false, mobile=false) -%}
|
||||||
<a href="{{ href }}"
|
<a href="{{ href }}"
|
||||||
@@ -31,6 +33,10 @@
|
|||||||
{% if current_user.get('roles') and 'Metrologist' in current_user.roles %}
|
{% if current_user.get('roles') and 'Metrologist' in current_user.roles %}
|
||||||
{{ nav_link(url_for('statistics.dashboard'), _('Statistiche'), icon_stats, ep.startswith('statistics.'), mobile) }}
|
{{ nav_link(url_for('statistics.dashboard'), _('Statistiche'), icon_stats, ep.startswith('statistics.'), mobile) }}
|
||||||
{% endif %}
|
{% 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') %}
|
{% 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.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) }}
|
{{ nav_link(url_for('admin.settings_page'), _('Impostazioni'), icon_settings, ep == 'admin.settings_page', mobile) }}
|
||||||
|
|||||||
@@ -91,6 +91,74 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% 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') }}">↑</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') }}">↓</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') }}">×</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
{% endmacro %}
|
||||||
<div class="tmf-page"
|
<div class="tmf-page"
|
||||||
x-data="taskEditor()"
|
x-data="taskEditor()"
|
||||||
x-cloak>
|
x-cloak>
|
||||||
@@ -273,12 +341,15 @@
|
|||||||
<!-- Tipo: dichiarato qui, non dedotto dal contenuto -->
|
<!-- Tipo: dichiarato qui, non dedotto dal contenuto -->
|
||||||
<div>
|
<div>
|
||||||
<label class="tmf-label">{{ _('Tipo') }} <span class="text-red-500">*</span></label>
|
<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="note">{{ _('Nota') }}</option>
|
||||||
<option value="measure">{{ _('Misura') }}</option>
|
<option value="measure">{{ _('Misura') }}</option>
|
||||||
<option value="drawing">{{ _('Disegno') }}</option>
|
<option value="drawing">{{ _('Disegno') }}</option>
|
||||||
<option value="camera_measure">{{ _('Misura camera') }}</option>
|
<option value="camera_measure">{{ _('Misura camera') }}</option>
|
||||||
<option value="dxf_compare">{{ _('Confronto profilo') }}</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>
|
</select>
|
||||||
<p class="mt-1 text-xs text-[var(--text-secondary)]">
|
<p class="mt-1 text-xs text-[var(--text-secondary)]">
|
||||||
{{ _('Un task di misura resta tale anche prima di inserire le quote') }}
|
{{ _('Un task di misura resta tale anche prima di inserire le quote') }}
|
||||||
@@ -306,6 +377,8 @@
|
|||||||
rows="3"
|
rows="3"
|
||||||
placeholder="{{ _('Descrizione opzionale...') }}"></textarea>
|
placeholder="{{ _('Descrizione opzionale...') }}"></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
{# #53 #55: ciclo di misura, compilazione a fine produzione e campi della nota #}
|
||||||
|
{{ task_flags_editor('newTask') }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="tmf-card-footer flex items-center gap-2 justify-end">
|
<div class="tmf-card-footer flex items-center gap-2 justify-end">
|
||||||
@@ -387,8 +460,20 @@
|
|||||||
x-ref="editTaskTitleInput">
|
x-ref="editTaskTitleInput">
|
||||||
</template>
|
</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"
|
<span class="badge badge-neutral text-xs shrink-0"
|
||||||
|
x-show="task.task_type !== 'check'"
|
||||||
x-text="(task.subtasks ? task.subtasks.length : 0) + ' {{ _('misurazioni') }}'">
|
x-text="(task.subtasks ? task.subtasks.length : 0) + ' {{ _('misurazioni') }}'">
|
||||||
</span>
|
</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 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>
|
<div>
|
||||||
<label class="tmf-label">{{ _('Tipo') }}</label>
|
<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="note">{{ _('Nota') }}</option>
|
||||||
<option value="measure">{{ _('Misura') }}</option>
|
<option value="measure">{{ _('Misura') }}</option>
|
||||||
<option value="drawing">{{ _('Disegno') }}</option>
|
<option value="drawing">{{ _('Disegno') }}</option>
|
||||||
<option value="camera_measure">{{ _('Misura camera') }}</option>
|
<option value="camera_measure">{{ _('Misura camera') }}</option>
|
||||||
<option value="dxf_compare">{{ _('Confronto profilo') }}</option>
|
<option value="dxf_compare">{{ _('Confronto profilo') }}</option>
|
||||||
|
<option value="check">{{ _('Controllo OK/NOK') }}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -618,12 +705,14 @@
|
|||||||
rows="3"
|
rows="3"
|
||||||
placeholder="{{ _('Descrizione opzionale...') }}"></textarea>
|
placeholder="{{ _('Descrizione opzionale...') }}"></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
{# #53 #55: ciclo di misura, compilazione a fine produzione e campi della nota #}
|
||||||
|
{{ task_flags_editor('editTaskData') }}
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- ========== Subtask Table ========== -->
|
<!-- ========== Subtask Table ========== -->
|
||||||
<div class="subtask-table-wrap">
|
<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>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="w-16 text-center">{{ _('#') }}</th>
|
<th class="w-16 text-center">{{ _('#') }}</th>
|
||||||
@@ -710,7 +799,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ========== Empty Subtask State ========== -->
|
<!-- ========== 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">
|
class="text-center py-8">
|
||||||
<div class="inline-flex items-center justify-center w-14 h-14 rounded-full
|
<div class="inline-flex items-center justify-center w-14 h-14 rounded-full
|
||||||
bg-[var(--bg-secondary)] mb-3">
|
bg-[var(--bg-secondary)] mb-3">
|
||||||
@@ -862,7 +951,7 @@
|
|||||||
<!-- ========== Task Card Footer ========== -->
|
<!-- ========== Task Card Footer ========== -->
|
||||||
<div class="tmf-card-footer flex items-center justify-end">
|
<div class="tmf-card-footer flex items-center justify-end">
|
||||||
<button @click="openAddSubtask(task)"
|
<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">
|
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">
|
<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"/>
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4"/>
|
||||||
@@ -1210,8 +1299,10 @@ function taskEditor() {
|
|||||||
|
|
||||||
// ---- Task CRUD state ----
|
// ---- Task CRUD state ----
|
||||||
editingTask: null,
|
editingTask: null,
|
||||||
editTaskData: { 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' },
|
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 ----
|
// ---- Subtask CRUD state ----
|
||||||
editingSubtaskId: null,
|
editingSubtaskId: null,
|
||||||
@@ -1391,7 +1482,84 @@ function taskEditor() {
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
resetNewTask() {
|
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() {
|
async addTask() {
|
||||||
if (!this.newTask.title.trim()) return;
|
if (!this.newTask.title.trim()) return;
|
||||||
this.saving = true;
|
|
||||||
this.errorMessage = '';
|
this.errorMessage = '';
|
||||||
|
const fp = this._fieldsPayload(this.newTask); // #55
|
||||||
|
if (!fp.ok) return;
|
||||||
|
this.saving = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`/maker/api/recipes/${this.recipeId}/tasks`, {
|
const resp = await fetch(`/maker/api/recipes/${this.recipeId}/tasks`, {
|
||||||
@@ -1484,7 +1654,11 @@ function taskEditor() {
|
|||||||
title: this.newTask.title.trim(),
|
title: this.newTask.title.trim(),
|
||||||
task_type: this.newTask.task_type || 'note',
|
task_type: this.newTask.task_type || 'note',
|
||||||
directive: this.newTask.directive.trim() || null,
|
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,
|
title: task.title,
|
||||||
task_type: task.task_type || 'note',
|
task_type: task.task_type || 'note',
|
||||||
directive: task.directive || '',
|
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
|
// Ensure expanded
|
||||||
this.expandedTask = task.id;
|
this.expandedTask = task.id;
|
||||||
@@ -1524,13 +1703,15 @@ function taskEditor() {
|
|||||||
|
|
||||||
cancelEditTask() {
|
cancelEditTask() {
|
||||||
this.editingTask = null;
|
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) {
|
async updateTask(taskId) {
|
||||||
if (!this.editTaskData.title.trim()) return;
|
if (!this.editTaskData.title.trim()) return;
|
||||||
this.saving = true;
|
|
||||||
this.errorMessage = '';
|
this.errorMessage = '';
|
||||||
|
const fp = this._fieldsPayload(this.editTaskData); // #55
|
||||||
|
if (!fp.ok) return;
|
||||||
|
this.saving = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`/maker/api/tasks/${taskId}`, {
|
const resp = await fetch(`/maker/api/tasks/${taskId}`, {
|
||||||
@@ -1543,7 +1724,11 @@ function taskEditor() {
|
|||||||
title: this.editTaskData.title.trim(),
|
title: this.editTaskData.title.trim(),
|
||||||
task_type: this.editTaskData.task_type || 'note',
|
task_type: this.editTaskData.task_type || 'note',
|
||||||
directive: this.editTaskData.directive.trim() || null,
|
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. #}
|
{# #60: un solo «Completato», quello grande in fondo (touch); qui resta solo la lista. #}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# Fermo linea + Fine produzione (measurement tasks only) #}
|
{# Fermo linea + Fine produzione: task di misura o del ciclo (#53: anche un check nel loop) #}
|
||||||
<template x-if="isMeasureTask && productionRun">
|
<template x-if="(isMeasureTask || isMeasurementTaskOfRun) && productionRun">
|
||||||
{# #61: Fermo linea / Fine produzione solo con una produzione aperta #}
|
{# #61: Fermo linea / Fine produzione solo con una produzione aperta #}
|
||||||
<div class="shrink-0 flex items-center gap-1.5">
|
<div class="shrink-0 flex items-center gap-1.5">
|
||||||
{# While the line is stopped this becomes the way to restart it, so the
|
{# While the line is stopped this becomes the way to restart it, so the
|
||||||
@@ -256,6 +256,88 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% 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 #}
|
{# Main image area #}
|
||||||
<div class="flex-1 overflow-hidden relative">
|
<div class="flex-1 overflow-hidden relative">
|
||||||
|
|
||||||
@@ -747,7 +829,8 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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. #}
|
{# #60: unico «Completato», largo e alto almeno 64 px per il touch. #}
|
||||||
<button @click="goToNextTask()"
|
<button @click="goToNextTask()"
|
||||||
:disabled="!canLeaveTask"
|
:disabled="!canLeaveTask"
|
||||||
@@ -895,6 +978,39 @@
|
|||||||
</div>
|
</div>
|
||||||
</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
|
SUPERVISOR LOGIN MODAL
|
||||||
================================================================ #}
|
================================================================ #}
|
||||||
@@ -921,7 +1037,55 @@
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
<div>
|
||||||
<label class="tmf-label text-xs">{{ _('Username') }}</label>
|
<label class="tmf-label text-xs">{{ _('Username') }}</label>
|
||||||
<input type="text" x-model="supervisorUsername" class="tmf-input text-sm"
|
<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>
|
<span x-show="supervisorAction === 'out_of_tolerance'">{{ _('Rimisura la quota') }}</span>
|
||||||
</button>
|
</button>
|
||||||
<button @click="submitSupervisor()"
|
<button @click="submitSupervisor()"
|
||||||
:disabled="!supervisorUsername || !supervisorPassword || supervisorValidating"
|
:disabled="!supervisorCanSubmit || supervisorValidating"
|
||||||
class="btn btn-primary flex-1 text-sm gap-1">
|
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">
|
<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"/>
|
<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"/>
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"/>
|
||||||
</svg>
|
</svg>
|
||||||
{{ _('Autorizza') }}
|
<span x-text="supervisorMode === 'pin' ? '{{ _('Conferma') }}' : '{{ _('Autorizza') }}'"></span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -980,6 +1144,26 @@
|
|||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
<script src="{{ url_for('static', filename='js/vision_execute.js') }}"></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
|
{# 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. #}
|
measurement lives - are shared with the other screens rather than restated. #}
|
||||||
<script src="{{ url_for('static', filename='js/production-clock.js') }}"></script>
|
<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
|
// 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.
|
// in once production starts, and what says which task closes a cycle.
|
||||||
measurementTaskIds: [],
|
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
|
// 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.
|
// operator on their way back to the measurement. Kept so it is said once.
|
||||||
dueAnnounced: false,
|
dueAnnounced: false,
|
||||||
@@ -1039,6 +1227,11 @@ function taskExecute() {
|
|||||||
supervisorAction: '', // 'out_of_tolerance', 'fermo_linea', 'fine_produzione'
|
supervisorAction: '', // 'out_of_tolerance', 'fermo_linea', 'fine_produzione'
|
||||||
supervisorUsername: '',
|
supervisorUsername: '',
|
||||||
supervisorPassword: '',
|
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: '',
|
supervisorError: '',
|
||||||
supervisorValidating: false,
|
supervisorValidating: false,
|
||||||
/* The out-of-tolerance measurement waiting for the capoturno:
|
/* The out-of-tolerance measurement waiting for the capoturno:
|
||||||
@@ -1270,6 +1463,7 @@ function taskExecute() {
|
|||||||
this.productionStarted = run.status !== 'closed';
|
this.productionStarted = run.status !== 'closed';
|
||||||
this.cycleCount = run.cycle_count;
|
this.cycleCount = run.cycle_count;
|
||||||
this.measurementTaskIds = run.measurement_task_ids || [];
|
this.measurementTaskIds = run.measurement_task_ids || [];
|
||||||
|
this.closeTaskIds = run.close_task_ids || []; // #55
|
||||||
|
|
||||||
const seconds = run.seconds_to_next_measurement;
|
const seconds = run.seconds_to_next_measurement;
|
||||||
if (seconds === null || seconds === undefined) {
|
if (seconds === null || seconds === undefined) {
|
||||||
@@ -1696,6 +1890,7 @@ function taskExecute() {
|
|||||||
this.currentValue = null;
|
this.currentValue = null;
|
||||||
this.errorMessage = '';
|
this.errorMessage = '';
|
||||||
this.inputStartedAt = Date.now();
|
this.inputStartedAt = Date.now();
|
||||||
|
window.dispatchEvent(new CustomEvent('task-result-reset')); // #54: a check / note loops on the spot
|
||||||
},
|
},
|
||||||
|
|
||||||
// ---- Where the measurement lives ----
|
// ---- Where the measurement lives ----
|
||||||
@@ -1788,6 +1983,33 @@ function taskExecute() {
|
|||||||
return window.ProductionClock.format(this.timerRemaining);
|
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) ----
|
// ---- Navigate to next task (Completato) ----
|
||||||
/* With a production open the operator stays in the measurement loop until the
|
/* 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
|
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") }}';
|
this.productionError = '{{ _("Nessuna produzione aperta su questa stazione") }}';
|
||||||
return;
|
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.supervisorAction = action;
|
||||||
this.supervisorUsername = '';
|
this.supervisorUsername = '';
|
||||||
this.supervisorPassword = '';
|
this.supervisorPassword = '';
|
||||||
this.supervisorError = '';
|
this.supervisorError = '';
|
||||||
|
this.resetSupervisorPin(); // #59: PIN fields cleared, names reloaded
|
||||||
this.showSupervisorModal = true;
|
this.showSupervisorModal = true;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -1840,6 +2069,68 @@ function taskExecute() {
|
|||||||
this.supervisorUsername = '';
|
this.supervisorUsername = '';
|
||||||
this.supervisorPassword = '';
|
this.supervisorPassword = '';
|
||||||
this.supervisorError = '';
|
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.
|
/* What kind of task this is, as declared in the recipe.
|
||||||
@@ -1859,6 +2150,19 @@ function taskExecute() {
|
|||||||
return t === 'camera_measure' || t === 'dxf_compare';
|
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
|
/* A measurement task with no quotes yet: the recipe is unfinished. Said out
|
||||||
loud rather than silently rendering an empty screen. */
|
loud rather than silently rendering an empty screen. */
|
||||||
get isMeasureTaskWithoutQuotes() {
|
get isMeasureTaskWithoutQuotes() {
|
||||||
@@ -1910,16 +2214,15 @@ function taskExecute() {
|
|||||||
const resp = await fetch(url, {
|
const resp = await fetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken },
|
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(this.supervisorCredentials()), // #59: PIN or password
|
||||||
supervisor_username: this.supervisorUsername,
|
|
||||||
supervisor_password: this.supervisorPassword,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
const data = await resp.json().catch(() => null);
|
const data = await resp.json().catch(() => null);
|
||||||
|
|
||||||
if (!resp.ok || (data && data.error)) {
|
if (!resp.ok || (data && data.error)) {
|
||||||
this.supervisorError = (data && data.detail)
|
this.supervisorError = (data && data.detail)
|
||||||
|
|| (this.supervisorMode === 'pin' && resp.status === 401 ? '{{ _("PIN errato") }}' : '')
|
||||||
|| '{{ _("Credenziali non valide o utente non autorizzato") }}';
|
|| '{{ _("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;
|
this.supervisorValidating = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1954,10 +2257,7 @@ function taskExecute() {
|
|||||||
|
|
||||||
const base = '{{ url_for("measure.api_pause_production", run_id=0) }}'
|
const base = '{{ url_for("measure.api_pause_production", run_id=0) }}'
|
||||||
.replace('/0/pause', '/' + this.productionRun.id + '/' + action);
|
.replace('/0/pause', '/' + this.productionRun.id + '/' + action);
|
||||||
const run = await this.postProduction(base, {
|
const run = await this.postProduction(base, this.supervisorCredentials()); // #59: PIN or password
|
||||||
supervisor_username: this.supervisorUsername,
|
|
||||||
supervisor_password: this.supervisorPassword,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.supervisorValidating = false;
|
this.supervisorValidating = false;
|
||||||
if (!run) {
|
if (!run) {
|
||||||
@@ -1965,6 +2265,7 @@ function taskExecute() {
|
|||||||
// capoturno sees why the action was refused, rather than a silent no-op.
|
// capoturno sees why the action was refused, rather than a silent no-op.
|
||||||
this.supervisorError = this.productionError || '{{ _("Azione non riuscita") }}';
|
this.supervisorError = this.productionError || '{{ _("Azione non riuscita") }}';
|
||||||
this.productionError = '';
|
this.productionError = '';
|
||||||
|
if (this.supervisorMode === 'pin') this.supervisorPin = ''; // #59: wrong PIN / lockout: pad cleared, modal stays
|
||||||
return;
|
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
|
"""#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."""
|
apre e un rifiuto del server non avvia un timer locale finto."""
|
||||||
html = _execute_page(logged_in_client, mock_api_client, monkeypatch)
|
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
|
assert "!this.productionRun" in html
|
||||||
# #61: loadProductionRun mostra il detail del server, non solo il messaggio generico
|
# #61: loadProductionRun mostra il detail del server, non solo il messaggio generico
|
||||||
assert "errBody.detail" in html
|
assert "errBody.detail" in html
|
||||||
|
|||||||
Reference in New Issue
Block a user