Compare commits
6 Commits
c8dd5eecfd
...
e07c4d4eee
| Author | SHA1 | Date | |
|---|---|---|---|
| e07c4d4eee | |||
| 0b45f1c35a | |||
| 743f246cbf | |||
| f541f4a810 | |||
| dffb11f9f2 | |||
| 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.
|
||||||
"""
|
"""
|
||||||
|
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)
|
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]
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
"""Maker blueprint - recipe creation and editing."""
|
"""Maker blueprint - recipe creation and editing."""
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
|
||||||
import requests as http_requests
|
import requests as http_requests
|
||||||
|
|
||||||
from flask import Blueprint, flash, jsonify, redirect, render_template, request, session, url_for
|
from flask import Blueprint, flash, jsonify, redirect, render_template, request, session, url_for
|
||||||
@@ -118,7 +121,41 @@ def task_editor(recipe_id: int):
|
|||||||
else:
|
else:
|
||||||
recipe["tasks"] = []
|
recipe["tasks"] = []
|
||||||
|
|
||||||
return render_template("maker/task_editor.html", recipe=recipe)
|
# #47/#48: liste e tolleranze generali dalle Impostazioni; chiave assente,
|
||||||
|
# vuota o non valida -> None -> il template usa le voci scritte a mano
|
||||||
|
settings = api_client.get("/api/settings")
|
||||||
|
if not isinstance(settings, dict) or settings.get("error"):
|
||||||
|
settings = {}
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"maker/task_editor.html",
|
||||||
|
recipe=recipe,
|
||||||
|
list_measurement_types=_settings_string_list(settings.get("list_measurement_types")),
|
||||||
|
list_units=_settings_string_list(settings.get("list_units")),
|
||||||
|
general_tolerance_plus=_settings_tolerance(settings.get("general_tolerance_plus")),
|
||||||
|
general_tolerance_minus=_settings_tolerance(settings.get("general_tolerance_minus")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _settings_string_list(value):
|
||||||
|
"""#47: array JSON di stringhe non vuote dalle Impostazioni, altrimenti None."""
|
||||||
|
try:
|
||||||
|
items = json.loads(value) if isinstance(value, str) else None
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
if not isinstance(items, list):
|
||||||
|
return None
|
||||||
|
items = [i.strip() for i in items if isinstance(i, str) and i.strip()]
|
||||||
|
return items or None
|
||||||
|
|
||||||
|
|
||||||
|
def _settings_tolerance(value):
|
||||||
|
"""#48: tolleranza generale (decimale >= 0) dalle Impostazioni, altrimenti None."""
|
||||||
|
try:
|
||||||
|
number = float(str(value).strip())
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return number if math.isfinite(number) and number >= 0 else None
|
||||||
|
|
||||||
|
|
||||||
@maker_bp.route("/recipes/<int:recipe_id>/tasks/<int:task_id>/drawing")
|
@maker_bp.route("/recipes/<int:recipe_id>/tasks/<int:task_id>/drawing")
|
||||||
|
|||||||
@@ -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">
|
||||||
@@ -767,18 +856,14 @@
|
|||||||
<!-- Measurement Type -->
|
<!-- Measurement Type -->
|
||||||
<div>
|
<div>
|
||||||
<label class="tmf-label text-xs">{{ _('Tipo') }}</label>
|
<label class="tmf-label text-xs">{{ _('Tipo') }}</label>
|
||||||
|
<!-- #47: lista da Impostazioni (list_measurement_types) o voci di default; un valore salvato fuori lista resta selezionabile -->
|
||||||
<select x-model="newSubtask.measurement_type"
|
<select x-model="newSubtask.measurement_type"
|
||||||
class="tmf-input">
|
class="tmf-input">
|
||||||
<option value="">-</option>
|
<option value="">-</option>
|
||||||
<option value="linear">{{ _('Lineare') }}</option>
|
<template x-for="o in measurementTypeOptions()" :key="o.value">
|
||||||
<option value="diameter">{{ _('Diametro') }}</option>
|
<option :value="o.value" :selected="o.value === newSubtask.measurement_type"
|
||||||
<option value="radius">{{ _('Raggio') }}</option>
|
x-text="o.label"></option>
|
||||||
<option value="angle">{{ _('Angolo') }}</option>
|
</template>
|
||||||
<option value="roughness">{{ _('Rugosita') }}</option>
|
|
||||||
<option value="torque">{{ _('Coppia') }}</option>
|
|
||||||
<option value="force">{{ _('Forza') }}</option>
|
|
||||||
<option value="weight">{{ _('Peso') }}</option>
|
|
||||||
<option value="other">{{ _('Altro') }}</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<!-- Nominal -->
|
<!-- Nominal -->
|
||||||
@@ -786,24 +871,20 @@
|
|||||||
<label class="tmf-label text-xs">{{ _('Nominale') }}</label>
|
<label class="tmf-label text-xs">{{ _('Nominale') }}</label>
|
||||||
<input type="number" step="any"
|
<input type="number" step="any"
|
||||||
x-model.number="newSubtask.nominal"
|
x-model.number="newSubtask.nominal"
|
||||||
|
@input="onNominalInput($event)"
|
||||||
class="tmf-input measure-value text-right"
|
class="tmf-input measure-value text-right"
|
||||||
placeholder="0.000">
|
placeholder="0.000">
|
||||||
</div>
|
</div>
|
||||||
<!-- Unit -->
|
<!-- Unit -->
|
||||||
<div>
|
<div>
|
||||||
<label class="tmf-label text-xs">{{ _('Unita') }}</label>
|
<label class="tmf-label text-xs">{{ _('Unita') }}</label>
|
||||||
|
<!-- #47: lista da Impostazioni (list_units) o voci di default; un valore salvato fuori lista resta selezionabile -->
|
||||||
<select x-model="newSubtask.unit"
|
<select x-model="newSubtask.unit"
|
||||||
class="tmf-input">
|
class="tmf-input">
|
||||||
<option value="mm">mm</option>
|
<template x-for="o in unitOptions()" :key="o.value">
|
||||||
<option value="cm">cm</option>
|
<option :value="o.value" :selected="o.value === newSubtask.unit"
|
||||||
<option value="m">m</option>
|
x-text="o.label"></option>
|
||||||
<option value="μm">μm</option>
|
</template>
|
||||||
<option value="in">in</option>
|
|
||||||
<option value="°">°</option>
|
|
||||||
<option value="g">g</option>
|
|
||||||
<option value="kg">kg</option>
|
|
||||||
<option value="N">N</option>
|
|
||||||
<option value="Nm">Nm</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -814,6 +895,7 @@
|
|||||||
<label class="tmf-label text-xs text-red-500">{{ _('LTL') }}</label>
|
<label class="tmf-label text-xs text-red-500">{{ _('LTL') }}</label>
|
||||||
<input type="number" step="any"
|
<input type="number" step="any"
|
||||||
x-model.number="newSubtask.ltl"
|
x-model.number="newSubtask.ltl"
|
||||||
|
@input="tolAuto = false"
|
||||||
class="tmf-input measure-value text-right"
|
class="tmf-input measure-value text-right"
|
||||||
placeholder="{{ _('Lim. Tol. Inf.') }}">
|
placeholder="{{ _('Lim. Tol. Inf.') }}">
|
||||||
</div>
|
</div>
|
||||||
@@ -835,10 +917,14 @@
|
|||||||
<label class="tmf-label text-xs text-red-500">{{ _('UTL') }}</label>
|
<label class="tmf-label text-xs text-red-500">{{ _('UTL') }}</label>
|
||||||
<input type="number" step="any"
|
<input type="number" step="any"
|
||||||
x-model.number="newSubtask.utl"
|
x-model.number="newSubtask.utl"
|
||||||
|
@input="tolAuto = false"
|
||||||
class="tmf-input measure-value text-right"
|
class="tmf-input measure-value text-right"
|
||||||
placeholder="{{ _('Lim. Tol. Sup.') }}">
|
placeholder="{{ _('Lim. Tol. Sup.') }}">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- #48: promemoria delle tolleranze generali (Impostazioni) -->
|
||||||
|
<p class="text-xs text-[var(--text-muted)] mt-2"
|
||||||
|
x-show="generalTolText()" x-text="generalTolText()"></p>
|
||||||
|
|
||||||
<!-- Form Actions -->
|
<!-- Form Actions -->
|
||||||
<div class="flex items-center gap-2 justify-end mt-4">
|
<div class="flex items-center gap-2 justify-end mt-4">
|
||||||
@@ -865,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"/>
|
||||||
@@ -1213,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,
|
||||||
@@ -1227,6 +1315,27 @@ function taskEditor() {
|
|||||||
// Uscite numeriche della ricetta visione per task (#46), riempite da vision_recipe.js
|
// Uscite numeriche della ricetta visione per task (#46), riempite da vision_recipe.js
|
||||||
visionOutputs: {},
|
visionOutputs: {},
|
||||||
|
|
||||||
|
// ---- Liste e tolleranze generali dalle Impostazioni (#47, #48); null = non configurato ----
|
||||||
|
configuredTypes: {{ list_measurement_types|default(none)|tojson }},
|
||||||
|
configuredUnits: {{ list_units|default(none)|tojson }},
|
||||||
|
generalTolPlus: {{ general_tolerance_plus|default(none)|tojson }},
|
||||||
|
generalTolMinus: {{ general_tolerance_minus|default(none)|tojson }},
|
||||||
|
// #48: true = utl/ltl si riempiono da soli dal nominale (solo quota nuova, finche' non li tocca l'utente)
|
||||||
|
tolAuto: false,
|
||||||
|
// Voci scritte a mano usate quando la lista non e' configurata (#47)
|
||||||
|
defaultTypeOptions: [
|
||||||
|
{value: 'linear', label: {{ _('Lineare')|tojson }}},
|
||||||
|
{value: 'diameter', label: {{ _('Diametro')|tojson }}},
|
||||||
|
{value: 'radius', label: {{ _('Raggio')|tojson }}},
|
||||||
|
{value: 'angle', label: {{ _('Angolo')|tojson }}},
|
||||||
|
{value: 'roughness', label: {{ _('Rugosita')|tojson }}},
|
||||||
|
{value: 'torque', label: {{ _('Coppia')|tojson }}},
|
||||||
|
{value: 'force', label: {{ _('Forza')|tojson }}},
|
||||||
|
{value: 'weight', label: {{ _('Peso')|tojson }}},
|
||||||
|
{value: 'other', label: {{ _('Altro')|tojson }}}
|
||||||
|
],
|
||||||
|
defaultUnits: ['mm', 'cm', 'm', '\u03bcm', 'in', '\u00b0', 'g', 'kg', 'N', 'Nm'],
|
||||||
|
|
||||||
// ---- Delete modals ----
|
// ---- Delete modals ----
|
||||||
deleteTaskModal: false,
|
deleteTaskModal: false,
|
||||||
deleteTargetTask: null,
|
deleteTargetTask: null,
|
||||||
@@ -1373,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 };
|
||||||
},
|
},
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -1452,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`, {
|
||||||
@@ -1466,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
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1498,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;
|
||||||
@@ -1506,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}`, {
|
||||||
@@ -1525,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
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1698,8 +1901,61 @@ function taskEditor() {
|
|||||||
return Math.max(...task.subtasks.map(s => s.marker_number || 0)) + 1;
|
return Math.max(...task.subtasks.map(s => s.marker_number || 0)) + 1;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// #47: opzioni della select Tipo; il valore corrente fuori lista viene aggiunto per non perderlo
|
||||||
|
measurementTypeOptions() {
|
||||||
|
const known = v => (this.defaultTypeOptions.find(d => d.value === v) || {label: v}).label;
|
||||||
|
const opts = this.configuredTypes
|
||||||
|
? this.configuredTypes.map(v => ({value: v, label: known(v)})) // un tipo di default noto mantiene l'etichetta tradotta
|
||||||
|
: this.defaultTypeOptions.slice();
|
||||||
|
const cur = this.newSubtask.measurement_type;
|
||||||
|
if (cur && !opts.some(o => o.value === cur)) opts.push({value: cur, label: known(cur)});
|
||||||
|
return opts;
|
||||||
|
},
|
||||||
|
|
||||||
|
// #47: opzioni della select Unita, stessa logica del Tipo
|
||||||
|
unitOptions() {
|
||||||
|
const opts = (this.configuredUnits || this.defaultUnits).map(v => ({value: v, label: v}));
|
||||||
|
const cur = this.newSubtask.unit;
|
||||||
|
if (cur && !opts.some(o => o.value === cur)) opts.push({value: cur, label: cur});
|
||||||
|
return opts;
|
||||||
|
},
|
||||||
|
|
||||||
|
// #47: unita di partenza di una quota nuova: la prima della lista configurata, altrimenti mm
|
||||||
|
defaultUnit() {
|
||||||
|
return this.configuredUnits ? this.configuredUnits[0] : 'mm';
|
||||||
|
},
|
||||||
|
|
||||||
|
// #48: testo "Tolleranze generali: +x / -y" sotto i campi; vuoto se non configurate
|
||||||
|
generalTolText() {
|
||||||
|
const parts = [];
|
||||||
|
if (this.generalTolPlus !== null) parts.push('+' + this.generalTolPlus);
|
||||||
|
if (this.generalTolMinus !== null) parts.push('\u2212' + this.generalTolMinus);
|
||||||
|
return parts.length ? {{ _('Tolleranze generali')|tojson }} + ': ' + parts.join(' / ') : '';
|
||||||
|
},
|
||||||
|
|
||||||
|
// #48: su quota nuova, scrivendo il nominale riempie utl = nominale + plus e ltl = nominale - minus
|
||||||
|
onNominalInput(ev) {
|
||||||
|
if (!this.tolAuto || this.editingSubtaskId) return;
|
||||||
|
const raw = String(ev.target.value).trim();
|
||||||
|
const nominal = parseFloat(raw);
|
||||||
|
if (raw === '' || !isFinite(nominal)) {
|
||||||
|
// nominale svuotato: i limiti riempiti da noi tornano vuoti
|
||||||
|
this.newSubtask.utl = null;
|
||||||
|
this.newSubtask.ltl = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// decimali: quelli del nominale o delle tolleranze generali, al massimo 4
|
||||||
|
const dec = s => (String(s).split('.')[1] || '').length;
|
||||||
|
const decimals = Math.min(4, Math.max(dec(raw), dec(this.generalTolPlus), dec(this.generalTolMinus)));
|
||||||
|
const round = v => Number(v.toFixed(decimals));
|
||||||
|
if (this.generalTolPlus !== null) this.newSubtask.utl = round(nominal + this.generalTolPlus);
|
||||||
|
if (this.generalTolMinus !== null) this.newSubtask.ltl = round(nominal - this.generalTolMinus);
|
||||||
|
},
|
||||||
|
|
||||||
resetNewSubtask(taskId) {
|
resetNewSubtask(taskId) {
|
||||||
const task = this.tasks.find(t => t.id === taskId);
|
const task = this.tasks.find(t => t.id === taskId);
|
||||||
|
// #48: automatismo attivo solo se c'e' almeno una tolleranza generale
|
||||||
|
this.tolAuto = this.generalTolPlus !== null || this.generalTolMinus !== null;
|
||||||
this.newSubtask = {
|
this.newSubtask = {
|
||||||
marker_number: task ? this.nextMarkerNumber(task) : 1,
|
marker_number: task ? this.nextMarkerNumber(task) : 1,
|
||||||
description: '',
|
description: '',
|
||||||
@@ -1709,7 +1965,7 @@ function taskEditor() {
|
|||||||
uwl: null,
|
uwl: null,
|
||||||
lwl: null,
|
lwl: null,
|
||||||
ltl: null,
|
ltl: null,
|
||||||
unit: 'mm',
|
unit: this.defaultUnit(), // #47
|
||||||
vision_output: ''
|
vision_output: ''
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@@ -1779,6 +2035,7 @@ function taskEditor() {
|
|||||||
// Open the same form used for adding, but pre-filled with existing data
|
// Open the same form used for adding, but pre-filled with existing data
|
||||||
this.editingSubtaskId = subtask.id;
|
this.editingSubtaskId = subtask.id;
|
||||||
this.addingSubtaskForTask = taskId;
|
this.addingSubtaskForTask = taskId;
|
||||||
|
this.tolAuto = false; // #48: una quota esistente non viene mai ricalcolata
|
||||||
this.newSubtask = {
|
this.newSubtask = {
|
||||||
marker_number: subtask.marker_number,
|
marker_number: subtask.marker_number,
|
||||||
description: subtask.description || '',
|
description: subtask.description || '',
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
"""#47/#48: liste Tipo/Unita e tolleranze generali dalle Impostazioni nel task editor."""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tests.conftest import api_get_router
|
||||||
|
|
||||||
|
_RECIPE_ROUTES = {
|
||||||
|
"/api/recipes/3": {"id": 3, "code": "R", "name": "R"},
|
||||||
|
"/api/recipes/3/tasks": [{
|
||||||
|
"id": 11, "title": "T", "task_type": "measure", "subtasks": [],
|
||||||
|
"file_path": None, "order_index": 1,
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _render(logged_in_client, mock_api_client, settings):
|
||||||
|
mock_api_client.get.side_effect = api_get_router({**_RECIPE_ROUTES, "/api/settings": settings})
|
||||||
|
resp = logged_in_client.get("/maker/recipes/3/tasks")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
return resp.get_data(as_text=True)
|
||||||
|
|
||||||
|
|
||||||
|
class TestTaskEditorSettings:
|
||||||
|
def test_configured_lists_reach_template(self, logged_in_client, mock_api_client):
|
||||||
|
html = _render(logged_in_client, mock_api_client, {
|
||||||
|
"list_measurement_types": json.dumps(["Planarita", "Concentricita"]),
|
||||||
|
"list_units": json.dumps(["mm", "thou"]),
|
||||||
|
})
|
||||||
|
assert 'configuredTypes: ["Planarita", "Concentricita"]' in html
|
||||||
|
assert 'configuredUnits: ["mm", "thou"]' in html
|
||||||
|
# le select leggono le liste, non hanno piu' le option scritte a mano
|
||||||
|
assert 'x-for="o in measurementTypeOptions()"' in html
|
||||||
|
assert 'x-for="o in unitOptions()"' in html
|
||||||
|
assert '<option value="diameter">' not in html
|
||||||
|
|
||||||
|
def test_missing_keys_keep_hand_written_values(self, logged_in_client, mock_api_client):
|
||||||
|
for settings in ({}, {"list_measurement_types": "", "list_units": "non json"},
|
||||||
|
{"list_units": json.dumps({"a": 1})}, {"list_units": "[]"}):
|
||||||
|
html = _render(logged_in_client, mock_api_client, settings)
|
||||||
|
assert "configuredTypes: null," in html
|
||||||
|
assert "configuredUnits: null," in html
|
||||||
|
# le voci di oggi restano come default
|
||||||
|
assert "value: 'diameter'" in html
|
||||||
|
assert "defaultUnits: ['mm', 'cm'" in html
|
||||||
|
|
||||||
|
def test_settings_error_keeps_defaults(self, logged_in_client, mock_api_client):
|
||||||
|
html = _render(logged_in_client, mock_api_client, {"error": True, "detail": "x"})
|
||||||
|
assert "configuredTypes: null," in html
|
||||||
|
assert "generalTolPlus: null," in html
|
||||||
|
|
||||||
|
def test_general_tolerances_reach_template(self, logged_in_client, mock_api_client):
|
||||||
|
html = _render(logged_in_client, mock_api_client, {
|
||||||
|
"general_tolerance_plus": "0.1", "general_tolerance_minus": "0.25",
|
||||||
|
})
|
||||||
|
assert "generalTolPlus: 0.1," in html
|
||||||
|
assert "generalTolMinus: 0.25," in html
|
||||||
|
|
||||||
|
def test_invalid_tolerances_are_none(self, logged_in_client, mock_api_client):
|
||||||
|
for plus, minus in (("", "abc"), ("-1", "nan"), ("inf", None)):
|
||||||
|
settings = {"general_tolerance_plus": plus}
|
||||||
|
if minus is not None:
|
||||||
|
settings["general_tolerance_minus"] = minus
|
||||||
|
html = _render(logged_in_client, mock_api_client, settings)
|
||||||
|
assert "generalTolPlus: null," in html
|
||||||
|
assert "generalTolMinus: null," in html
|
||||||
|
|
||||||
|
|
||||||
|
# ---- comportamento JS (node): opzioni fuori lista e automatismo delle tolleranze ----
|
||||||
|
|
||||||
|
_NODE = shutil.which("node")
|
||||||
|
|
||||||
|
|
||||||
|
def _run_js(html, body):
|
||||||
|
"""Esegue taskEditor() estratto dalla pagina e poi `body` (che usa `ed`); stampa JSON."""
|
||||||
|
scripts = re.findall(r"<script(?![^>]*\bsrc=)[^>]*>(.*?)</script>", html, re.S | re.I)
|
||||||
|
src = next(s for s in scripts if "function taskEditor()" in s)
|
||||||
|
program = src + "\nconst ed = taskEditor();\nconst out = (function(){" + body + "})();\nconsole.log(JSON.stringify(out));"
|
||||||
|
# su file: la riga di comando di Windows non regge lo script intero
|
||||||
|
with tempfile.NamedTemporaryFile("w", suffix=".js", delete=False, encoding="utf-8") as fh:
|
||||||
|
fh.write(program)
|
||||||
|
try:
|
||||||
|
res = subprocess.run([_NODE, fh.name], capture_output=True, text=True,
|
||||||
|
encoding="utf-8", timeout=30)
|
||||||
|
finally:
|
||||||
|
os.unlink(fh.name)
|
||||||
|
assert res.returncode == 0, res.stderr
|
||||||
|
return json.loads(res.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(_NODE is None, reason="node non disponibile")
|
||||||
|
class TestTaskEditorJs:
|
||||||
|
def test_out_of_list_value_stays_selectable(self, logged_in_client, mock_api_client):
|
||||||
|
html = _render(logged_in_client, mock_api_client, {
|
||||||
|
"list_measurement_types": json.dumps(["Planarita"]),
|
||||||
|
"list_units": json.dumps(["thou"]),
|
||||||
|
})
|
||||||
|
out = _run_js(html, """
|
||||||
|
ed.newSubtask.measurement_type = 'Vecchio'; ed.newSubtask.unit = 'mm';
|
||||||
|
return {t: ed.measurementTypeOptions().map(o => o.value),
|
||||||
|
u: ed.unitOptions().map(o => o.value), d: ed.defaultUnit()};
|
||||||
|
""")
|
||||||
|
assert out["t"] == ["Planarita", "Vecchio"]
|
||||||
|
assert out["u"] == ["thou", "mm"]
|
||||||
|
assert out["d"] == "thou"
|
||||||
|
|
||||||
|
def test_default_lists_when_not_configured(self, logged_in_client, mock_api_client):
|
||||||
|
html = _render(logged_in_client, mock_api_client, {})
|
||||||
|
out = _run_js(html, """
|
||||||
|
return {t: ed.measurementTypeOptions().map(o => o.value),
|
||||||
|
u: ed.unitOptions().map(o => o.value), d: ed.defaultUnit()};
|
||||||
|
""")
|
||||||
|
assert out["t"][0] == "linear" and out["t"][-1] == "other"
|
||||||
|
assert out["u"][0] == "mm" and "Nm" in out["u"]
|
||||||
|
assert out["d"] == "mm"
|
||||||
|
|
||||||
|
def test_tolerance_autofill(self, logged_in_client, mock_api_client):
|
||||||
|
html = _render(logged_in_client, mock_api_client, {
|
||||||
|
"general_tolerance_plus": "0.1", "general_tolerance_minus": "0.2",
|
||||||
|
})
|
||||||
|
out = _run_js(html, """
|
||||||
|
ed.tasks = [{id: 1, subtasks: []}];
|
||||||
|
ed.resetNewSubtask(1);
|
||||||
|
const r = {};
|
||||||
|
ed.newSubtask.nominal = 10; ed.onNominalInput({target: {value: '10'}});
|
||||||
|
r.first = [ed.newSubtask.utl, ed.newSubtask.ltl];
|
||||||
|
// cambia ancora il nominale prima di toccare i limiti: ricalcola
|
||||||
|
ed.onNominalInput({target: {value: '12.5'}});
|
||||||
|
r.second = [ed.newSubtask.utl, ed.newSubtask.ltl];
|
||||||
|
// modifica a mano utl: smette di riempire
|
||||||
|
ed.newSubtask.utl = 99; ed.tolAuto = false;
|
||||||
|
ed.onNominalInput({target: {value: '20'}});
|
||||||
|
r.third = [ed.newSubtask.utl, ed.newSubtask.ltl];
|
||||||
|
r.text = ed.generalTolText();
|
||||||
|
return r;
|
||||||
|
""")
|
||||||
|
assert out["first"] == [10.1, 9.8]
|
||||||
|
assert out["second"] == [12.6, 12.3]
|
||||||
|
assert out["third"] == [99, 12.3]
|
||||||
|
assert "+0.1 / −0.2" in out["text"]
|
||||||
|
|
||||||
|
def test_no_autofill_without_tolerances_or_when_editing(self, logged_in_client, mock_api_client):
|
||||||
|
html = _render(logged_in_client, mock_api_client, {})
|
||||||
|
out = _run_js(html, """
|
||||||
|
ed.tasks = [{id: 1, subtasks: []}];
|
||||||
|
ed.resetNewSubtask(1);
|
||||||
|
ed.onNominalInput({target: {value: '10'}});
|
||||||
|
return {utl: ed.newSubtask.utl, ltl: ed.newSubtask.ltl, auto: ed.tolAuto,
|
||||||
|
text: ed.generalTolText()};
|
||||||
|
""")
|
||||||
|
assert out == {"utl": None, "ltl": None, "auto": False, "text": ""}
|
||||||
|
|
||||||
|
html = _render(logged_in_client, mock_api_client, {"general_tolerance_plus": "0.1"})
|
||||||
|
out = _run_js(html, """
|
||||||
|
ed.startEditSubtask({id: 5, marker_number: 1, nominal: 10, utl: null, ltl: null}, 1);
|
||||||
|
ed.onNominalInput({target: {value: '10'}});
|
||||||
|
return {utl: ed.newSubtask.utl, auto: ed.tolAuto};
|
||||||
|
""")
|
||||||
|
assert out == {"utl": None, "auto": False}
|
||||||
@@ -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"
|
||||||
Reference in New Issue
Block a user