Compare commits

..

4 Commits

26 changed files with 681 additions and 1113 deletions
+1 -3
View File
@@ -35,8 +35,6 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
"""
LOGIN_PATH = "/api/auth/login"
# #59: il login con PIN e' lo stesso attacco (4 cifre: ancora piu' facile da indovinare)
LOGIN_PIN_PATH = "/api/auth/login-pin"
WINDOW_SECONDS = 60
# Paths that carry a supervisor's (capoturno) credentials in the request
@@ -99,7 +97,7 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
return True
if method != "POST":
return False
if path in (cls.LOGIN_PATH, cls.LOGIN_PIN_PATH):
if path == cls.LOGIN_PATH:
return True
return any(p.match(path) for p in cls._SUPERVISOR_CREDENTIAL_PATTERNS)
+1 -2
View File
@@ -61,8 +61,7 @@ def detect_file_type(content: bytes) -> tuple[str, str] | None:
# Top-level folders nothing may DELETE through this router: statistics/reports are
# audit records, the logo is only ever replaced via POST /api/settings/logo.
# task_results/: allegati degli esiti dei task (#55), record di audit.
_UNDELETABLE_PREFIXES = ("statistics/", "reports/", "logos/", "task_results/")
_UNDELETABLE_PREFIXES = ("statistics/", "reports/", "logos/")
# GET under these needs a role that is meant to see production data, not just
# any authenticated user (a Maker or MeasurementTec has no business reading
-3
View File
@@ -27,7 +27,6 @@ router = APIRouter(prefix="/api/production-runs", tags=["production"])
_DERIVED = {
"seconds_to_next_measurement", "overdue", "server_time", "measurement_task_ids",
"close_task_ids",
}
@@ -54,8 +53,6 @@ async def _as_response(db: AsyncSession, run: ProductionRun) -> ProductionRunRes
measurement_task_ids=await production_service.measurement_task_ids(
db, run.version_id,
),
# #53: task da compilare alla fine produzione
close_task_ids=await production_service.close_task_ids(db, run.version_id),
)
-192
View File
@@ -1,192 +0,0 @@
"""Task results router: l'esito di un task 'check' (OK/NOK) o 'note' (#54 #55).
Un 'check' registra ok o nok (nok vuole la nota); un 'note' registra 'done' con i
valori dei campi extra e gli allegati. I task di misura non passano di qui: le
loro letture sono Measurement.
"""
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.api.middleware.api_key import require_measurement_tec
from src.backend.api.routers.files import (
_UPLOAD_CHUNK_SIZE, detect_file_type, sanitize_filename,
)
from src.backend.config import settings
from src.backend.database import get_db
from src.backend.models.api.task_result import TaskResultCreate, TaskResultResponse
from src.backend.models.orm.production import ProductionRun
from src.backend.models.orm.task import RecipeTask
from src.backend.models.orm.task_result import TaskResult
from src.backend.models.orm.user import User
router = APIRouter(prefix="/api/task-results", tags=["task-results"])
def _unprocessable(detail: str) -> HTTPException:
# 422 con detail testuale: l'api_client del frontend legge solo `detail`
return HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=detail)
# Radice degli allegati degli esiti: sotto upload_path, mai cancellabile (audit).
ATTACHMENT_DIR = "task_results"
def _safe_relative_path(path: str) -> bool:
"""Allegato: relativo, dentro task_results/, senza '..', unita' o backslash."""
if path.startswith("/") or "\\" in path or ":" in path:
return False
parts = path.split("/")
return ".." not in parts and parts[0] == ATTACHMENT_DIR and len(parts) > 1
@router.post("/attachments", status_code=status.HTTP_201_CREATED)
async def upload_attachment(
file: UploadFile = File(...),
_user: User = Depends(require_measurement_tec),
):
"""Carica l'allegato di un task 'note' (l'operatore non ha accesso a /api/files/upload).
Stessi controlli di upload_file: tipo dai magic bytes, limite di dimensione
con lettura a blocchi, nome ripulito. Salva sotto task_results/<AAAA>/<MM>/.
"""
max_bytes = settings.max_upload_size_mb * 1024 * 1024
buffer = bytearray()
while True:
chunk = await file.read(_UPLOAD_CHUNK_SIZE)
if not chunk:
break
buffer.extend(chunk)
if len(buffer) > max_bytes:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"File size exceeds maximum {settings.max_upload_size_mb}MB",
)
content = bytes(buffer)
detected = detect_file_type(content)
if detected is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="File type not allowed. Must be a recognised image or PDF.",
)
_mime, ext = detected
# nome originale ripulito (per mostrarlo); sul disco solo uuid + estensione rilevata
name = sanitize_filename(file.filename or "")
now = datetime.now(timezone.utc)
storage_dir = settings.upload_path / ATTACHMENT_DIR / f"{now:%Y}" / f"{now:%m}"
storage_dir.mkdir(parents=True, exist_ok=True)
target = storage_dir / f"{uuid.uuid4().hex}{ext}"
target.write_bytes(content)
return {
"file_path": str(target.relative_to(settings.upload_path)).replace("\\", "/"),
"name": name,
}
@router.post("", response_model=TaskResultResponse, status_code=status.HTTP_201_CREATED)
async def create_task_result(
data: TaskResultCreate,
user: User = Depends(require_measurement_tec),
db: AsyncSession = Depends(get_db),
):
"""Registra l'esito di un task 'check' o 'note'."""
task = (await db.execute(
select(RecipeTask).where(RecipeTask.id == data.task_id)
)).scalar_one_or_none()
if task is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Task not found")
note = data.note.strip() if data.note else None
note = note or None
values: Optional[dict] = None
attachments: Optional[list] = None
if task.task_type == "check":
if data.outcome not in ("ok", "nok"):
raise _unprocessable("Un controllo si registra come ok o nok")
if data.outcome == "nok" and not note:
raise _unprocessable("Per un NOK serve la nota")
elif task.task_type == "note":
if data.outcome != "done":
raise _unprocessable("Una nota si registra come done")
fields = {f["key"]: f for f in (task.fields or [])}
values = {}
for key, value in (data.values or {}).items():
if key not in fields:
raise _unprocessable(f"Campo '{key}' non previsto dal task")
if fields[key]["type"] == "number" and value.strip() != "":
try:
float(value.replace(",", "."))
except ValueError:
raise _unprocessable(f"Il campo '{fields[key]['label']}' deve essere un numero")
values[key] = value
attachments = []
for att in data.attachments or []:
if not _safe_relative_path(att.path):
raise _unprocessable(
f"Percorso allegato non valido (deve stare in {ATTACHMENT_DIR}/): {att.path}"
)
if att.key is not None and att.key not in fields:
raise _unprocessable(f"Campo allegato '{att.key}' non previsto dal task")
attachments.append(att.model_dump())
values = values or None
attachments = attachments or None
else:
raise _unprocessable(
f"I task di tipo '{task.task_type}' non registrano un esito"
)
if data.production_run_id is not None:
run = (await db.execute(
select(ProductionRun).where(ProductionRun.id == data.production_run_id)
)).scalar_one_or_none()
if run is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Production run not found"
)
if run.status == "closed":
raise _unprocessable("La produzione e' gia' chiusa")
if run.version_id != task.version_id:
raise _unprocessable("Il task non appartiene alla versione di questa produzione")
result = TaskResult(
task_id=task.id,
version_id=task.version_id, # presa dal task, non dal client
production_run_id=data.production_run_id,
user_id=user.id,
outcome=data.outcome,
note=note,
values=values,
attachments=attachments,
)
db.add(result)
await db.flush()
await db.refresh(result) # created_at e' un server_default
return TaskResultResponse.model_validate(result)
@router.get("", response_model=list[TaskResultResponse])
async def list_task_results(
production_run_id: Optional[int] = Query(None, gt=0),
task_id: Optional[int] = Query(None, gt=0),
_user: User = Depends(require_measurement_tec),
db: AsyncSession = Depends(get_db),
):
"""Esiti registrati, filtrabili per produzione e/o task; il piu' recente prima."""
stmt = select(TaskResult)
if production_run_id is not None:
stmt = stmt.where(TaskResult.production_run_id == production_run_id)
if task_id is not None:
stmt = stmt.where(TaskResult.task_id == task_id)
rows = (await db.execute(
stmt.order_by(TaskResult.created_at.desc(), TaskResult.id.desc())
)).scalars().all()
return [TaskResultResponse.model_validate(r) for r in rows]
-34
View File
@@ -17,9 +17,6 @@ from src.backend.models.api.task import (
TaskReorderRequest,
TaskResponse,
TaskUpdate,
check_task_loop_options,
default_in_loop,
normalize_note_fields,
validate_tolerance_order,
)
from src.backend.models.orm.vision import VisionReferenceImage
@@ -205,13 +202,6 @@ async def create_task(
This creates a NEW version via copy-on-write, then appends the task to
the new version. Requires Maker role.
"""
# #53 #55: validazione di in_loop/at_close/fields prima di toccare il db
try:
fields = normalize_note_fields(data.fields)
check_task_loop_options(data.task_type, data.at_close, fields)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc))
# Ensure recipe exists and is active
result = await db.execute(select(Recipe).where(Recipe.id == recipe_id))
recipe = result.scalar_one_or_none()
@@ -250,10 +240,6 @@ async def create_task(
file_type=data.file_type,
annotations_json=data.annotations_json,
vision_json=data.vision_json,
# #53 #55: ciclo (default dal tipo), chiusura, campi extra
in_loop=data.in_loop if data.in_loop is not None else default_in_loop(data.task_type),
at_close=data.at_close,
fields=fields,
)
db.add(new_task)
await db.flush()
@@ -355,26 +341,6 @@ async def update_task(
if not update_data:
return TaskResponse.model_validate(task)
# #53 #55: i tre campi nuovi si validano sulla combinazione finale (valori
# nuovi, o quelli gia' sul task per cio' che il client non manda)
if "fields" in update_data:
try:
update_data["fields"] = normalize_note_fields(update_data["fields"])
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc))
for flag in ("in_loop", "at_close"):
if flag in update_data and update_data[flag] is None:
del update_data[flag] # i booleani non sono nullable: null = non toccare
final_type = update_data.get("task_type") or task.task_type
try:
check_task_loop_options(
final_type,
update_data.get("at_close", task.at_close),
update_data.get("fields", task.fields),
)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc))
task = await _task_for_write(db, task, user)
for field, value in update_data.items():
-2
View File
@@ -23,7 +23,6 @@ from src.backend.api.routers.setup import router as setup_router
from src.backend.api.routers.stations import router as stations_router
from src.backend.api.routers.production import router as production_router
from src.backend.api.routers.vision import router as vision_router
from src.backend.api.routers.task_results import router as task_results_router
@asynccontextmanager
@@ -78,7 +77,6 @@ app.include_router(setup_router)
app.include_router(stations_router)
app.include_router(production_router)
app.include_router(vision_router)
app.include_router(task_results_router)
@app.get("/api/health")
@@ -1,108 +0,0 @@
"""task: in_loop / at_close / fields, tipo 'check', tabella task_results (#53 #54 #55)
in_loop: il task fa parte del ciclo di produzione (prima: implicito dal tipo).
at_close: il task 'note' si compila alla fine produzione.
fields: campi extra del task 'note' (JSON).
task_results: esito registrato da un task 'check' (OK/NOK) o 'note' (done).
Revision ID: 018_task_loop_results
Revises: 017_user_pin
Create Date: 2026-10-07
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '018_task_loop_results'
down_revision: Union[str, None] = '017_user_pin'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
OLD_TYPES = ('note', 'measure', 'drawing', 'dxf_compare', 'camera_measure')
NEW_TYPES = OLD_TYPES + ('check',)
# stessi tipi di MEASURING_TASK_TYPES: fino a oggi erano l'unico ciclo possibile
LOOP_TYPES = ('measure', 'camera_measure', 'dxf_compare')
def upgrade() -> None:
with op.batch_alter_table('recipe_tasks') as batch:
batch.add_column(sa.Column(
'in_loop', sa.Boolean(), nullable=False, server_default=sa.false()))
batch.add_column(sa.Column(
'at_close', sa.Boolean(), nullable=False, server_default=sa.false()))
batch.add_column(sa.Column('fields', sa.JSON(), nullable=True))
# enum allargato come in 011: 'check' = Controllo OK/NOK
batch.alter_column(
'task_type',
existing_type=sa.Enum(*OLD_TYPES, name='task_type_enum'),
type_=sa.Enum(*NEW_TYPES, name='task_type_enum'),
existing_nullable=False,
existing_server_default='note',
)
# i task di misura erano gia' nel ciclo: lo restano
recipe_tasks = sa.table(
'recipe_tasks', sa.column('task_type', sa.String), sa.column('in_loop', sa.Boolean))
op.execute(
recipe_tasks.update()
.where(recipe_tasks.c.task_type.in_(LOOP_TYPES))
.values(in_loop=True)
)
op.create_table(
'task_results',
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
sa.Column('task_id', sa.Integer(),
sa.ForeignKey('recipe_tasks.id', ondelete='CASCADE'), nullable=False),
sa.Column('version_id', sa.Integer(),
sa.ForeignKey('recipe_versions.id'), nullable=False),
sa.Column('production_run_id', sa.Integer(),
sa.ForeignKey('production_runs.id', ondelete='SET NULL'), nullable=True),
sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=False),
sa.Column('outcome', sa.Enum('ok', 'nok', 'done', name='task_outcome_enum'),
nullable=False),
sa.Column('note', sa.Text(), nullable=True),
sa.Column('values', sa.JSON(), nullable=True),
sa.Column('attachments', sa.JSON(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False,
server_default=sa.func.now()),
mysql_engine='InnoDB',
mysql_charset='utf8mb4',
)
op.create_index('ix_task_results_task_id', 'task_results', ['task_id'])
op.create_index('ix_task_results_version_id', 'task_results', ['version_id'])
op.create_index(
'ix_task_results_production_run_id', 'task_results', ['production_run_id'])
def downgrade() -> None:
# il tipo 'check' non esiste prima di 018: se un task lo usa, tornare indietro
# lo lascerebbe con un valore fuori enum
bind = op.get_bind()
used = bind.execute(
sa.text("SELECT COUNT(*) FROM recipe_tasks WHERE task_type = 'check'")
).scalar()
if used:
raise RuntimeError(
f"Downgrade 018 impossibile: {used} task usano il tipo 'check'. "
"Cambiare il tipo o eliminare quei task prima di tornare indietro."
)
op.drop_index('ix_task_results_production_run_id', table_name='task_results')
op.drop_index('ix_task_results_version_id', table_name='task_results')
op.drop_index('ix_task_results_task_id', table_name='task_results')
op.drop_table('task_results')
with op.batch_alter_table('recipe_tasks') as batch:
batch.alter_column(
'task_type',
existing_type=sa.Enum(*NEW_TYPES, name='task_type_enum'),
type_=sa.Enum(*OLD_TYPES, name='task_type_enum'),
existing_nullable=False,
existing_server_default='note',
)
batch.drop_column('fields')
batch.drop_column('at_close')
batch.drop_column('in_loop')
-2
View File
@@ -55,8 +55,6 @@ class ProductionRunResponse(BaseModel):
# needs them to bring the operator back to the measurement when the interval
# expires, whichever screen they are on, and to know which task closes a cycle.
measurement_task_ids: list[int] = Field(default_factory=list)
# #53: task 'note' con at_close, da compilare prima della modale del capoturno.
close_task_ids: list[int] = Field(default_factory=list)
class ProductionRunWithEventsResponse(ProductionRunResponse):
+1 -82
View File
@@ -1,10 +1,9 @@
"""Pydantic schemas for RecipeTask and RecipeSubtask operations."""
import re
from typing import Any, Literal, Optional
from pydantic import BaseModel, ConfigDict, Field, model_validator
from src.backend.models.orm.task import DEFAULT_LOOP_TASK_TYPES, TASK_TYPES
from src.backend.models.orm.task import TASK_TYPES
# Kept in step with the ORM enum so the two cannot drift apart.
TaskType = Literal[TASK_TYPES]
@@ -35,76 +34,6 @@ def validate_tolerance_order(
)
# #55: campi extra di un task 'note'
MAX_NOTE_FIELDS = 20
_FIELD_KEY_RE = re.compile(r"^[a-z0-9_]{1,40}$")
NOTE_FIELD_TYPES = ("text", "number")
def normalize_note_fields(fields: Optional[list]) -> Optional[list[dict]]:
"""Valida e normalizza `fields` di un task 'note' (#55); lista vuota -> None.
Solleva ValueError con un messaggio leggibile (il router lo trasforma in 422).
"""
if not fields:
return None
if len(fields) > MAX_NOTE_FIELDS:
raise ValueError(f"Al massimo {MAX_NOTE_FIELDS} campi per task")
out: list[dict] = []
seen: set[str] = set()
for i, raw in enumerate(fields, start=1):
if not isinstance(raw, dict):
raise ValueError(f"Campo {i}: formato non valido")
key = raw.get("key")
if not isinstance(key, str) or not _FIELD_KEY_RE.match(key):
raise ValueError(
f"Campo {i}: chiave non valida (solo a-z, 0-9, _, da 1 a 40 caratteri)"
)
if key in seen:
raise ValueError(f"Campo {i}: chiave '{key}' duplicata")
seen.add(key)
label = raw.get("label")
label = label.strip() if isinstance(label, str) else ""
if not 1 <= len(label) <= 100:
raise ValueError(f"Campo '{key}': etichetta da 1 a 100 caratteri")
ftype = raw.get("type")
if ftype not in NOTE_FIELD_TYPES:
raise ValueError(f"Campo '{key}': tipo deve essere text o number")
default = raw.get("default")
if default is not None:
if not isinstance(default, str):
raise ValueError(f"Campo '{key}': il valore predefinito deve essere testo")
if default == "":
default = None
if default is not None and ftype == "number":
try:
float(default.replace(",", "."))
except ValueError:
raise ValueError(f"Campo '{key}': il valore predefinito non e' un numero")
attachment = raw.get("attachment", False)
if not isinstance(attachment, bool):
raise ValueError(f"Campo '{key}': attachment deve essere true o false")
out.append({
"key": key, "label": label, "type": ftype,
"default": default, "attachment": attachment,
})
return out
def check_task_loop_options(task_type: str, at_close: bool, fields: Optional[list]) -> None:
"""at_close e fields sono ammessi solo per i task 'note' (#53 #55)."""
if task_type != "note":
if at_close:
raise ValueError("'Alla chiusura' e' ammesso solo per i task di tipo note")
if fields:
raise ValueError("I campi extra sono ammessi solo per i task di tipo note")
def default_in_loop(task_type: str) -> bool:
"""in_loop quando non dichiarato: misure e check si', gli altri no (#53)."""
return task_type in DEFAULT_LOOP_TASK_TYPES
class SubtaskCreate(BaseModel):
"""Schema for creating a subtask."""
marker_number: int = Field(..., ge=1)
@@ -169,10 +98,6 @@ class TaskCreate(BaseModel):
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
annotations_json: Optional[dict[str, Any]] = None
vision_json: Optional[dict] = None
# #53: assente = default dal tipo (misure e check nel ciclo)
in_loop: Optional[bool] = None
at_close: bool = False
fields: Optional[list[dict[str, Any]]] = None
subtasks: list[SubtaskCreate] = []
@@ -186,9 +111,6 @@ class TaskUpdate(BaseModel):
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
annotations_json: Optional[dict[str, Any]] = None
vision_json: Optional[dict] = None
in_loop: Optional[bool] = None
at_close: Optional[bool] = None
fields: Optional[list[dict[str, Any]]] = None
class TaskResponse(BaseModel):
@@ -207,9 +129,6 @@ class TaskResponse(BaseModel):
file_type: Optional[str] = None
annotations_json: Optional[dict[str, Any]] = None
vision_json: Optional[dict] = None
in_loop: bool = False
at_close: bool = False
fields: Optional[list[dict[str, Any]]] = None
subtasks: list[SubtaskResponse] = []
-37
View File
@@ -1,37 +0,0 @@
"""Pydantic schemas for task results (#54 check OK/NOK, #55 note con campi)."""
from typing import Literal, Optional
from pydantic import BaseModel, ConfigDict, Field
from src.backend.models.api.tipi import UtcDateTime
class TaskResultAttachment(BaseModel):
"""Un allegato: `path` e' il percorso restituito da /api/files/upload."""
key: Optional[str] = Field(None, max_length=40)
path: str = Field(..., min_length=1, max_length=500)
name: str = Field(..., min_length=1, max_length=255)
class TaskResultCreate(BaseModel):
task_id: int = Field(..., gt=0)
production_run_id: Optional[int] = Field(None, gt=0)
outcome: Literal["ok", "nok", "done"]
note: Optional[str] = None
values: Optional[dict[str, str]] = None
attachments: Optional[list[TaskResultAttachment]] = Field(None, max_length=50)
class TaskResultResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
task_id: int
version_id: int
production_run_id: Optional[int] = None
user_id: int
outcome: str
note: Optional[str] = None
values: Optional[dict[str, str]] = None
attachments: Optional[list[TaskResultAttachment]] = None
created_at: UtcDateTime
-2
View File
@@ -3,7 +3,6 @@ from src.backend.models.orm.user import User
from src.backend.models.orm.api_key import ApiKey
from src.backend.models.orm.recipe import Recipe, RecipeVersion
from src.backend.models.orm.task import RecipeTask, RecipeSubtask
from src.backend.models.orm.task_result import TaskResult
from src.backend.models.orm.measurement import Measurement
from src.backend.models.orm.access_log import AccessLog
from src.backend.models.orm.setting import SystemSetting, RecipeVersionAudit
@@ -18,7 +17,6 @@ __all__ = [
"RecipeVersion",
"RecipeTask",
"RecipeSubtask",
"TaskResult",
"Measurement",
"AccessLog",
"SystemSetting",
+2 -23
View File
@@ -2,7 +2,7 @@
from typing import TYPE_CHECKING, Optional
from sqlalchemy import (
Boolean, DECIMAL, Enum, ForeignKey, Index, Integer, JSON, String, Text, UniqueConstraint,
DECIMAL, Enum, ForeignKey, Index, Integer, JSON, String, Text, UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -15,22 +15,12 @@ if TYPE_CHECKING:
# What a task is, declared by whoever writes the recipe instead of guessed from its
# contents. The last two are not built yet; they are in the enum from the start
# because widening a MySQL enum later is an ALTER on a live table.
# 'check' = Controllo OK/NOK, senza quote (#54).
TASK_TYPES = ("note", "measure", "drawing", "dxf_compare", "camera_measure", "check")
TASK_TYPES = ("note", "measure", "drawing", "dxf_compare", "camera_measure")
# Types the operator screen treats as "there are quotes to take here".
# dxf_compare produce quote come camera_measure (#46)
MEASURING_TASK_TYPES = ("measure", "camera_measure", "dxf_compare")
# Tipi che entrano nel ciclo di produzione se in_loop non e' dichiarato (#53):
# le misure di sempre piu' il controllo OK/NOK.
DEFAULT_LOOP_TASK_TYPES = MEASURING_TASK_TYPES + ("check",)
def _default_in_loop(context) -> bool:
"""Default alla creazione di in_loop: dipende dal tipo del task (#53)."""
return context.get_current_parameters().get("task_type", "note") in DEFAULT_LOOP_TASK_TYPES
class RecipeTask(Base):
__tablename__ = "recipe_tasks"
@@ -65,17 +55,6 @@ class RecipeTask(Base):
# Cartella immutabile (relativa a upload_path) con recipe.json e i file di
# meta della ricetta VisionSuite v2 (#46).
vision_recipe_dir: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
# #53: il task fa parte del ciclo di produzione (default dal tipo: misure e
# check si', gli altri no; un task 'note' si puo' mettere nel ciclo a mano).
in_loop: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=_default_in_loop, server_default="0"
)
# #53: task 'note' compilato alla fine produzione, prima della modale del capoturno.
at_close: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False, server_default="0"
)
# #55: campi extra del task 'note' ([{key,label,type,default,attachment}]).
fields: Mapped[Optional[list]] = mapped_column(JSON, nullable=True)
# Relationships
version: Mapped["RecipeVersion"] = relationship(back_populates="tasks")
-46
View File
@@ -1,46 +0,0 @@
"""TaskResult: l'esito registrato da un task 'check' (OK/NOK) o 'note' (#54 #55)."""
from datetime import datetime
from typing import Optional
from sqlalchemy import DateTime, Enum, ForeignKey, Integer, JSON, Text, func
from sqlalchemy.orm import Mapped, mapped_column
from src.backend.database import Base
TASK_OUTCOMES = ("ok", "nok", "done")
class TaskResult(Base):
__tablename__ = "task_results"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
task_id: Mapped[int] = mapped_column(
Integer, ForeignKey("recipe_tasks.id", ondelete="CASCADE"),
nullable=False, index=True,
)
# Copiata dal task: la versione in cui l'esito e' stato dato non cambia piu'.
version_id: Mapped[int] = mapped_column(
Integer, ForeignKey("recipe_versions.id"), nullable=False, index=True
)
production_run_id: Mapped[Optional[int]] = mapped_column(
Integer, ForeignKey("production_runs.id", ondelete="SET NULL"),
nullable=True, index=True,
)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id"), nullable=False
)
outcome: Mapped[str] = mapped_column(
Enum(*TASK_OUTCOMES, name="task_outcome_enum"), nullable=False
)
note: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
# {chiave del campo: valore stringa}; allegati: [{key, path, name}]
values: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
attachments: Mapped[Optional[list]] = mapped_column(JSON, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, server_default=func.now()
)
__table_args__ = ({"mysql_engine": "InnoDB", "mysql_charset": "utf8mb4"},)
def __repr__(self) -> str:
return f"<TaskResult task={self.task_id} outcome={self.outcome}>"
+1 -15
View File
@@ -145,21 +145,7 @@ async def measurement_task_ids(db: AsyncSession, version_id: int) -> list[int]:
select(RecipeTask.id)
.where(
RecipeTask.version_id == version_id,
# #53: il ciclo lo dichiara il task (in_loop), non piu' il solo tipo.
RecipeTask.in_loop.is_(True),
)
.order_by(RecipeTask.order_index, RecipeTask.id)
)
return list(result.scalars().all())
async def close_task_ids(db: AsyncSession, version_id: int) -> list[int]:
"""Ids dei task da compilare alla fine produzione (at_close), in ordine (#53)."""
result = await db.execute(
select(RecipeTask.id)
.where(
RecipeTask.version_id == version_id,
RecipeTask.at_close.is_(True),
RecipeTask.task_type.in_(MEASURING_TASK_TYPES),
)
.order_by(RecipeTask.order_index, RecipeTask.id)
)
-4
View File
@@ -117,10 +117,6 @@ async def _copy_tasks_to_version(
vision_json=task.vision_json,
# Cartella immutabile: basta il riferimento (#46).
vision_recipe_dir=task.vision_recipe_dir,
# #53 #55: ciclo, chiusura e campi extra seguono il task nella nuova versione.
in_loop=task.in_loop,
at_close=task.at_close,
fields=task.fields,
)
db.add(new_task)
await db.flush() # get new_task.id
-65
View File
@@ -168,68 +168,3 @@ class TestUserPinMigration:
assert not (new & cols())
command.upgrade(cfg, "head") # e di nuovo su
assert new <= cols()
_SEED_USER_RECIPE_VERSION = [
"INSERT INTO users (username, password_hash, display_name, roles, is_admin, active,"
" language_pref, theme_pref) VALUES ('u', 'x', 'U', '[]', 1, 1, 'en', 'light')",
"INSERT INTO recipes (code, name, active, created_by) VALUES ('R', 'R', 1, 1)",
"INSERT INTO recipe_versions (recipe_id, version_number, is_current, created_by)"
" VALUES (1, 1, 1, 1)",
]
class TestTaskLoopResultsMigration:
"""018_task_loop_results: in_loop/at_close/fields, tipo check, task_results (#53 #54 #55)."""
def test_018_upgrade_and_downgrade(self, sqlite_database_url):
db_path, _ = sqlite_database_url
cfg = _alembic_config()
def cols():
return {c["name"] for c in _inspect(db_path).get_columns("recipe_tasks")}
command.upgrade(cfg, "head")
assert {"in_loop", "at_close", "fields"} <= cols()
assert "task_results" in _inspect(db_path).get_table_names()
command.downgrade(cfg, "017_user_pin")
assert not ({"in_loop", "at_close", "fields"} & cols())
assert "task_results" not in _inspect(db_path).get_table_names()
command.upgrade(cfg, "head") # e di nuovo su
def test_018_marks_existing_measure_tasks_in_loop(self, sqlite_database_url):
db_path, _ = sqlite_database_url
cfg = _alembic_config()
command.upgrade(cfg, "017_user_pin")
engine = create_engine(f"sqlite:///{db_path}")
with engine.begin() as conn:
for stmt in _SEED_USER_RECIPE_VERSION:
conn.execute(text(stmt))
for i, t in enumerate(("measure", "camera_measure", "dxf_compare", "note", "drawing")):
conn.execute(text(
"INSERT INTO recipe_tasks (version_id, order_index, task_type, title)"
f" VALUES (1, {i}, '{t}', '{t}')"))
engine.dispose()
command.upgrade(cfg, "head")
engine = create_engine(f"sqlite:///{db_path}")
with engine.connect() as conn:
rows = dict(conn.execute(text("SELECT task_type, in_loop FROM recipe_tasks")).all())
engine.dispose()
assert rows == {"measure": 1, "camera_measure": 1, "dxf_compare": 1, "note": 0, "drawing": 0}
def test_018_downgrade_refused_when_a_check_task_exists(self, sqlite_database_url):
db_path, _ = sqlite_database_url
cfg = _alembic_config()
command.upgrade(cfg, "head")
engine = create_engine(f"sqlite:///{db_path}")
with engine.begin() as conn:
for stmt in _SEED_USER_RECIPE_VERSION:
conn.execute(text(stmt))
conn.execute(text(
"INSERT INTO recipe_tasks (version_id, order_index, task_type, title)"
" VALUES (1, 0, 'check', 'c')"))
engine.dispose()
with pytest.raises(RuntimeError, match="check"):
command.downgrade(cfg, "017_user_pin")
-210
View File
@@ -1,210 +0,0 @@
"""Test di in_loop / at_close / fields e del tipo 'check' sui task (#53 #54 #55),
di close_task_ids nella risposta del run e del bucket di rate limit del login PIN."""
import pytest
from httpx import AsyncClient
from sqlalchemy import select
from src.backend.api.middleware.rate_limit import RateLimitMiddleware
from src.backend.models.orm.measurement import Measurement
from src.backend.models.orm.recipe import RecipeVersion
from src.backend.models.orm.station import Station
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
from src.backend.tests.conftest import auth_headers, create_test_recipe
FIELDS = [
{"key": "peso", "label": "Peso", "type": "number", "default": "1.5", "attachment": False},
{"key": "foto", "label": "Foto", "type": "text", "default": None, "attachment": True},
]
async def _post_task(client, user, recipe_id, **body):
return await client.post(
f"/api/recipes/{recipe_id}/tasks", headers=auth_headers(user), json=body,
)
async def test_create_defaults_in_loop_from_type(client: AsyncClient, maker_user, db_session):
recipe = await create_test_recipe(db_session, user_id=maker_user.id, code="REC-L1")
await db_session.commit()
expected = {"note": False, "drawing": False, "measure": True,
"camera_measure": True, "dxf_compare": True, "check": True}
for task_type, in_loop in expected.items():
resp = await _post_task(client, maker_user, recipe.id, title=task_type, task_type=task_type)
assert resp.status_code == 201, resp.text
body = resp.json()
assert body["in_loop"] is in_loop, task_type
assert body["at_close"] is False
assert body["fields"] is None
async def test_create_explicit_in_loop_and_note_fields(client: AsyncClient, maker_user, db_session):
recipe = await create_test_recipe(db_session, user_id=maker_user.id, code="REC-L2")
await db_session.commit()
resp = await _post_task(
client, maker_user, recipe.id, title="Nota", task_type="note",
in_loop=True, at_close=True, fields=FIELDS,
)
assert resp.status_code == 201, resp.text
body = resp.json()
assert body["in_loop"] is True and body["at_close"] is True
assert body["fields"] == FIELDS
@pytest.mark.parametrize("task_type", ["measure", "check", "drawing"])
async def test_create_at_close_or_fields_rejected_for_other_types(
client: AsyncClient, maker_user, db_session, task_type,
):
recipe = await create_test_recipe(db_session, user_id=maker_user.id, code=f"REC-L3{task_type}")
await db_session.commit()
resp = await _post_task(client, maker_user, recipe.id, title="x", task_type=task_type, at_close=True)
assert resp.status_code == 422
assert isinstance(resp.json()["detail"], str)
resp = await _post_task(client, maker_user, recipe.id, title="x", task_type=task_type, fields=FIELDS)
assert resp.status_code == 422
@pytest.mark.parametrize("bad", [
[{"key": "A b", "label": "x", "type": "text"}], # chiave non slug
[{"key": "k", "label": "", "type": "text"}], # etichetta vuota
[{"key": "k", "label": "x", "type": "date"}], # tipo ignoto
[{"key": "k", "label": "x", "type": "text"}, {"key": "k", "label": "y", "type": "text"}], # duplicata
[{"key": "k", "label": "x", "type": "number", "default": "abc"}], # default non numerico
[{"key": f"k{i}", "label": "x", "type": "text"} for i in range(21)], # troppi
])
async def test_create_invalid_fields_rejected(client: AsyncClient, maker_user, db_session, bad):
recipe = await create_test_recipe(db_session, user_id=maker_user.id, code="REC-L4")
await db_session.commit()
resp = await _post_task(client, maker_user, recipe.id, title="n", task_type="note", fields=bad)
assert resp.status_code == 422
assert isinstance(resp.json()["detail"], str)
async def test_update_fields_null_clears_and_type_rules(client: AsyncClient, maker_user, db_session):
recipe = await create_test_recipe(db_session, user_id=maker_user.id, code="REC-L5")
await db_session.commit()
created = (await _post_task(
client, maker_user, recipe.id, title="Nota", task_type="note",
at_close=True, fields=FIELDS,
)).json()
headers = auth_headers(maker_user)
# il frontend manda sempre i tre campi: fields null = nessun campo
resp = await client.put(
f"/api/tasks/{created['id']}", headers=headers,
json={"in_loop": True, "at_close": False, "fields": None},
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["in_loop"] is True and body["at_close"] is False and body["fields"] is None
# tipo diverso da note con at_close o fields: 422
resp = await client.put(
f"/api/tasks/{created['id']}", headers=headers,
json={"task_type": "measure", "at_close": True, "fields": None},
)
assert resp.status_code == 422
resp = await client.put(
f"/api/tasks/{created['id']}", headers=headers,
json={"task_type": "check", "at_close": False, "fields": FIELDS},
)
assert resp.status_code == 422
# un task che ha gia' at_close non puo' passare a measure senza toglierlo
resp = await client.put(
f"/api/tasks/{created['id']}", headers=headers,
json={"at_close": True, "fields": FIELDS},
)
assert resp.status_code == 200
resp = await client.put(
f"/api/tasks/{created['id']}", headers=headers, json={"task_type": "measure"},
)
assert resp.status_code == 422
async def test_copy_on_write_carries_the_three_fields(
client: AsyncClient, maker_user, db_session,
):
recipe = await create_test_recipe(db_session, user_id=maker_user.id, code="REC-L6")
await db_session.commit()
created = (await _post_task(
client, maker_user, recipe.id, title="Nota", task_type="note",
in_loop=True, at_close=True, fields=FIELDS,
)).json()
# una misura sulla versione corrente forza il copy-on-write alla prossima modifica
version = (await db_session.execute(
select(RecipeVersion).where(
RecipeVersion.recipe_id == recipe.id, RecipeVersion.is_current == True, # noqa: E712
)
)).scalar_one()
subtask_id = (await db_session.execute(
select(RecipeSubtask.id).join(RecipeSubtask.task)
.where(RecipeSubtask.task.has(version_id=version.id))
)).scalars().first()
db_session.add(Measurement(
subtask_id=subtask_id, version_id=version.id, measured_by=maker_user.id,
value=10.0, pass_fail="pass", deviation=0.0, input_method="manual",
))
await db_session.commit()
# sessione condivisa col client: la collezione version.tasks va riletta
db_session.expire_all()
resp = await client.put(
f"/api/tasks/{created['id']}", headers=auth_headers(maker_user),
json={"title": "Nota rinominata"},
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["version_id"] != version.id # e' la copia nella nuova versione
assert body["in_loop"] is True and body["at_close"] is True
assert body["fields"] == FIELDS
async def test_run_response_loop_and_close_task_ids(
client: AsyncClient, maker_user, measurement_tec_user, admin_user, db_session,
):
recipe = await create_test_recipe(db_session, user_id=maker_user.id, code="REC-L7")
db_session.add(Station(code="ST-L7", name="S", active=True, created_by=admin_user.id))
await db_session.commit()
async def mk(**body):
resp = await _post_task(client, maker_user, recipe.id, **body)
assert resp.status_code == 201, resp.text
return resp.json()
check = await mk(title="Check", task_type="check")
note_loop = await mk(title="Nota ciclo", task_type="note", in_loop=True)
note_out = await mk(title="Nota fuori", task_type="note")
close_a = await mk(title="Chiusura A", task_type="note", at_close=True)
measure_off = await mk(title="Misura fuori", task_type="measure", in_loop=False)
close_b = await mk(title="Chiusura B", task_type="note", at_close=True)
first_id = (await db_session.execute(
select(RecipeTask.id).where(RecipeTask.version_id == check["version_id"])
.order_by(RecipeTask.order_index)
)).scalars().first()
resp = await client.post(
"/api/production-runs", headers=auth_headers(measurement_tec_user),
json={"station_code": "ST-L7", "recipe_id": recipe.id},
)
assert resp.status_code == 201, resp.text
body = resp.json()
# ciclo: solo in_loop, in ordine (misura della fixture, check, nota in ciclo)
assert body["measurement_task_ids"] == [first_id, check["id"], note_loop["id"]]
assert note_out["id"] not in body["measurement_task_ids"]
assert measure_off["id"] not in body["measurement_task_ids"]
assert body["close_task_ids"] == [close_a["id"], close_b["id"]]
class TestLoginPinRateLimitBucket:
"""#59: il login con PIN sta nel bucket 'login' come il login con password."""
def test_login_pin_is_login_bucket(self):
assert RateLimitMiddleware._is_login_bucket_request("POST", "/api/auth/login-pin")
def test_login_pin_get_is_not_login_bucket(self):
assert not RateLimitMiddleware._is_login_bucket_request("GET", "/api/auth/login-pin")
def test_pin_users_list_is_not_login_bucket(self):
assert not RateLimitMiddleware._is_login_bucket_request("GET", "/api/auth/pin-users")
-240
View File
@@ -1,240 +0,0 @@
"""Test di POST/GET /api/task-results (#54 check OK/NOK, #55 note con campi)."""
from unittest.mock import patch
from httpx import AsyncClient
from sqlalchemy import select
from src.backend.models.orm.production import ProductionRun
from src.backend.models.orm.station import Station
from src.backend.tests.conftest import auth_headers, create_test_recipe
FIELDS = [
{"key": "peso", "label": "Peso", "type": "number", "default": None, "attachment": False},
{"key": "foto", "label": "Foto", "type": "text", "default": None, "attachment": True},
]
async def _setup(client, db_session, maker_user, admin_user, code):
recipe = await create_test_recipe(db_session, user_id=maker_user.id, code=code)
db_session.add(Station(code=f"ST-{code}", name="S", active=True, created_by=admin_user.id))
await db_session.commit()
h = auth_headers(maker_user)
check = (await client.post(
f"/api/recipes/{recipe.id}/tasks", headers=h, json={"title": "C", "task_type": "check"},
)).json()
note = (await client.post(
f"/api/recipes/{recipe.id}/tasks", headers=h,
json={"title": "N", "task_type": "note", "fields": FIELDS},
)).json()
return recipe, check, note
async def _open_run(client, user, recipe, code):
resp = await client.post(
"/api/production-runs", headers=auth_headers(user),
json={"station_code": f"ST-{code}", "recipe_id": recipe.id},
)
assert resp.status_code == 201, resp.text
return resp.json()
async def _post(client, user, **body):
return await client.post("/api/task-results", headers=auth_headers(user), json=body)
async def test_requires_auth_and_role(client: AsyncClient, maker_user, admin_user, db_session):
_, check, _ = await _setup(client, db_session, maker_user, admin_user, "TR0")
resp = await client.post("/api/task-results", json={"task_id": check["id"], "outcome": "ok"})
assert resp.status_code == 401
# un Maker senza ruolo di reparto non registra esiti
resp = await _post(client, maker_user, task_id=check["id"], outcome="ok")
assert resp.status_code == 403
async def test_check_ok_and_nok_rules(
client: AsyncClient, maker_user, admin_user, measurement_tec_user, db_session,
):
recipe, check, _ = await _setup(client, db_session, maker_user, admin_user, "TR1")
run = await _open_run(client, measurement_tec_user, recipe, "TR1")
resp = await _post(client, measurement_tec_user, task_id=check["id"],
production_run_id=run["id"], outcome="ok")
assert resp.status_code == 201, resp.text
body = resp.json()
assert body["outcome"] == "ok"
assert body["version_id"] == check["version_id"]
assert body["user_id"] == measurement_tec_user.id
assert body["production_run_id"] == run["id"]
assert body["created_at"]
for note in (None, "", " "):
resp = await _post(client, measurement_tec_user, task_id=check["id"],
outcome="nok", note=note)
assert resp.status_code == 422
assert resp.json()["detail"] == "Per un NOK serve la nota"
resp = await _post(client, measurement_tec_user, task_id=check["id"],
outcome="nok", note=" graffio ")
assert resp.status_code == 201
assert resp.json()["note"] == "graffio"
# un check non si chiude con 'done'
resp = await _post(client, measurement_tec_user, task_id=check["id"], outcome="done")
assert resp.status_code == 422
async def test_note_values_and_attachments(
client: AsyncClient, maker_user, admin_user, measurement_tec_user, db_session,
):
recipe, _, note = await _setup(client, db_session, maker_user, admin_user, "TR2")
ok = await _post(
client, measurement_tec_user, task_id=note["id"], outcome="done",
values={"peso": "12,5"},
attachments=[{"key": "foto", "path": "task_results/2026/10/abc.jpg", "name": "foto.jpg"}],
)
assert ok.status_code == 201, ok.text
assert ok.json()["values"] == {"peso": "12,5"}
assert ok.json()["attachments"] == [{"key": "foto", "path": "task_results/2026/10/abc.jpg", "name": "foto.jpg"}]
# solo done
assert (await _post(client, measurement_tec_user, task_id=note["id"], outcome="ok")).status_code == 422
# chiave fuori dai campi
assert (await _post(client, measurement_tec_user, task_id=note["id"], outcome="done",
values={"altro": "1"})).status_code == 422
# numero non convertibile
assert (await _post(client, measurement_tec_user, task_id=note["id"], outcome="done",
values={"peso": "abc"})).status_code == 422
# allegati con percorso non sicuro
for bad in ("../x.jpg", "/etc/passwd", "a/../b.jpg", "C:/x.jpg", "a\\b.jpg"):
resp = await _post(client, measurement_tec_user, task_id=note["id"], outcome="done",
attachments=[{"key": None, "path": bad, "name": "x"}])
assert resp.status_code == 422, bad
# chiave allegato fuori dai campi
resp = await _post(client, measurement_tec_user, task_id=note["id"], outcome="done",
attachments=[{"key": "boh", "path": "task_results/a.jpg", "name": "x"}])
assert resp.status_code == 422
async def test_other_task_types_rejected_and_unknown_task(
client: AsyncClient, maker_user, admin_user, measurement_tec_user, db_session,
):
recipe, _, _ = await _setup(client, db_session, maker_user, admin_user, "TR3")
measure = (await client.post(
f"/api/recipes/{recipe.id}/tasks", headers=auth_headers(maker_user),
json={"title": "M", "task_type": "measure"},
)).json()
resp = await _post(client, measurement_tec_user, task_id=measure["id"], outcome="ok")
assert resp.status_code == 422
resp = await _post(client, measurement_tec_user, task_id=999999, outcome="ok")
assert resp.status_code == 404
async def test_run_must_be_open_and_same_version(
client: AsyncClient, maker_user, admin_user, measurement_tec_user, db_session,
):
recipe, check, _ = await _setup(client, db_session, maker_user, admin_user, "TR4")
_, other_check, _ = await _setup(client, db_session, maker_user, admin_user, "TR4b")
run = await _open_run(client, measurement_tec_user, recipe, "TR4")
# task di un'altra versione
resp = await _post(client, measurement_tec_user, task_id=other_check["id"],
production_run_id=run["id"], outcome="ok")
assert resp.status_code == 422
# run inesistente
resp = await _post(client, measurement_tec_user, task_id=check["id"],
production_run_id=999999, outcome="ok")
assert resp.status_code == 404
# run chiuso: non registra piu'
row = (await db_session.execute(
select(ProductionRun).where(ProductionRun.id == run["id"])
)).scalar_one()
row.status = "closed"
await db_session.commit()
resp = await _post(client, measurement_tec_user, task_id=check["id"],
production_run_id=run["id"], outcome="ok")
assert resp.status_code == 422
async def test_list_filters_and_order(
client: AsyncClient, maker_user, admin_user, measurement_tec_user, db_session,
):
recipe, check, note = await _setup(client, db_session, maker_user, admin_user, "TR5")
run = await _open_run(client, measurement_tec_user, recipe, "TR5")
a = (await _post(client, measurement_tec_user, task_id=check["id"],
production_run_id=run["id"], outcome="ok")).json()
b = (await _post(client, measurement_tec_user, task_id=note["id"],
production_run_id=run["id"], outcome="done")).json()
c = (await _post(client, measurement_tec_user, task_id=check["id"], outcome="ok")).json()
h = auth_headers(measurement_tec_user)
resp = await client.get(f"/api/task-results?production_run_id={run['id']}", headers=h)
assert [r["id"] for r in resp.json()] == [b["id"], a["id"]] # piu' recente prima
resp = await client.get(f"/api/task-results?task_id={check['id']}", headers=h)
assert [r["id"] for r in resp.json()] == [c["id"], a["id"]]
resp = await client.get(
f"/api/task-results?task_id={check['id']}&production_run_id={run['id']}", headers=h)
assert [r["id"] for r in resp.json()] == [a["id"]]
assert (await client.get("/api/task-results")).status_code == 401
# ---------------------------------------------------------------------------
# POST /api/task-results/attachments: l'operatore carica gli allegati (#55)
# ---------------------------------------------------------------------------
# firma PNG valida (magic bytes) piu' un po' di zeri
PNG = bytes([0x89]) + b"PNG" + bytes([13, 10, 26, 10]) + bytes(32)
async def test_attachment_upload_ok_for_measurement_tec(
client: AsyncClient, measurement_tec_user, tmp_path,
):
with patch("src.backend.api.routers.task_results.settings") as mock_settings:
mock_settings.upload_path = tmp_path
mock_settings.max_upload_size_mb = 50
resp = await client.post(
"/api/task-results/attachments", headers=auth_headers(measurement_tec_user),
files={"file": ("foto bella (1).png", PNG, "image/png")},
)
assert resp.status_code == 201, resp.text
body = resp.json()
assert body["name"] == "fotobella1.png"
assert body["file_path"].startswith("task_results/")
assert body["file_path"].endswith(".png")
assert (tmp_path / body["file_path"]).read_bytes() == PNG
async def test_attachment_upload_rejects_wrong_type_size_and_role(
client: AsyncClient, measurement_tec_user, maker_user, tmp_path,
):
h = auth_headers(measurement_tec_user)
with patch("src.backend.api.routers.task_results.settings") as mock_settings:
mock_settings.upload_path = tmp_path
mock_settings.max_upload_size_mb = 1
# tipo non ammesso (HTML dichiarato come png): come upload_file, 400
resp = await client.post(
"/api/task-results/attachments", headers=h,
files={"file": ("x.png", b"<html></html>", "image/png")},
)
assert resp.status_code == 400
# oltre il limite
resp = await client.post(
"/api/task-results/attachments", headers=h,
files={"file": ("x.png", PNG + bytes(1024 * 1024 + 1), "image/png")},
)
assert resp.status_code == 400
# un Maker senza ruolo di reparto non carica
resp = await client.post(
"/api/task-results/attachments", headers=auth_headers(maker_user),
files={"file": ("x.png", PNG, "image/png")},
)
assert resp.status_code == 403
assert not any(tmp_path.rglob("*.png"))
async def test_task_results_dir_is_undeletable(client: AsyncClient, admin_user):
resp = await client.delete(
"/api/files/task_results/2026/10/abc.png", headers=auth_headers(admin_user),
)
assert resp.status_code in (403, 404)
assert resp.status_code == 403
@@ -138,6 +138,21 @@ def api_change_password(user_id: int):
return jsonify(resp), 200
@admin_bp.route("/api/users/<int:user_id>/pin", methods=["PUT"])
@login_required
@admin_required
def api_set_pin(user_id: int):
"""Proxy: set (4 digits) or remove ({"pin": null}) a user's PIN (#59)."""
data = request.get_json(silent=True) or {}
# Forward only the pin field; backend answers 422 for admins / malformed PINs.
resp = api_client.put(f"/api/users/{user_id}/pin", data={"pin": data.get("pin")})
if isinstance(resp, dict) and resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 200
@admin_bp.route("/api/users/<int:user_id>/toggle-active", methods=["POST"])
@login_required
@admin_required
+78 -32
View File
@@ -5,7 +5,7 @@ import requests as http_requests
from flask import (
Blueprint, Response, abort, flash, jsonify, redirect, render_template,
request, session, url_for,
g, request, session, url_for,
)
from flask_babel import gettext as _
@@ -654,6 +654,74 @@ def api_pending_authorisation():
return jsonify(resp), 200
def _supervisor_credentials(data: dict):
"""The capoturno's credentials from the request body (#59).
Two ways in: ``supervisor_user_id`` + ``supervisor_pin`` (name and 4-digit PIN
from the touch pad) or, as before, ``supervisor_username`` +
``supervisor_password``. Returns ``(fields, None)`` with the pair to forward,
or ``(None, error_response)`` when neither pair is complete.
"""
pin = str(data.get("supervisor_pin") or "").strip()
user_id = data.get("supervisor_user_id")
if pin and user_id not in (None, ""):
try:
return {"supervisor_user_id": int(user_id), "supervisor_pin": pin}, None
except (TypeError, ValueError):
pass # user_id not a number: falls through to the missing-credentials answer
username = (data.get("supervisor_username") or "").strip()
password = data.get("supervisor_password") or ""
if username and password:
return {"supervisor_username": username, "supervisor_password": password}, None
return None, (jsonify({
"error": True, "detail": _("Username e password richiesti"),
}), 400)
def _post_supervised(endpoint: str, creds: dict, extra: dict | None = None):
"""POST an action carrying the capoturno's credentials, forwarding the answer.
api_client reads every 401 as "your API key is dead" and empties the Flask
session. For these endpoints a 401 can also be the capoturno's wrong PIN or
password, and that must not throw the operator out of their session (#59):
when the detail names the supervisor or the PIN, the session is put back and
the ``session_expired`` flag is dropped so the modal can say "PIN errato".
"""
saved = dict(session)
resp = api_client.post(endpoint, data={**creds, **(extra or {})})
if isinstance(resp, dict) and resp.get("error"):
detail = str(resp.get("detail") or "").lower()
if resp.get("status_code") == 401 and ("supervisor" in detail or "pin" in detail):
session.update(saved) # the 401 was about the capoturno, not about this session
g.session_expired = False
resp = {k: v for k, v in resp.items() if k != "session_expired"}
if "supervisor_pin" in creds:
resp["detail"] = _("PIN errato") # the backend's English text is not for the operator
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 200
@measure_bp.route("/api/pin-users", methods=["GET"])
@login_required
@role_required("MeasurementTec")
def api_pin_users():
"""Proxy: names for the capoturno's PIN pad (#59), id and display name only.
Empty list when the backend does not answer, so the modal falls back to the
password form instead of showing a pad nobody can use.
"""
try:
resp = api_client.get("/api/auth/pin-users")
except Exception:
return jsonify([])
if not isinstance(resp, list):
return jsonify([])
return jsonify([
{"id": u.get("id"), "display_name": u.get("display_name", "")}
for u in resp if isinstance(u, dict)
])
@measure_bp.route("/api/measurements/<int:measurement_id>/authorise", methods=["POST"])
@login_required
@role_required("MeasurementTec")
@@ -665,20 +733,10 @@ def api_authorise_measurement(measurement_id: int):
written down nowhere and nothing depending on it.
"""
data = request.get_json(silent=True) or {}
username = (data.get("supervisor_username") or "").strip()
password = data.get("supervisor_password") or ""
if not username or not password:
return jsonify({
"error": True, "detail": _("Username e password richiesti"),
}), 400
resp = api_client.post(
f"/api/measurements/{measurement_id}/authorise",
data={"supervisor_username": username, "supervisor_password": password},
)
if isinstance(resp, dict) and resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 200
creds, error = _supervisor_credentials(data) # #59: PIN or password
if error:
return error
return _post_supervised(f"/api/measurements/{measurement_id}/authorise", creds)
# ---------------------------------------------------------------------------
@@ -770,24 +828,12 @@ def _supervised_action(run_id: int, action: str):
leave no such record, and is an extra round trip besides.
"""
data = request.get_json(silent=True) or {}
username = (data.get("supervisor_username") or "").strip()
password = data.get("supervisor_password") or ""
if not username or not password:
return jsonify({
"error": True, "detail": _("Username e password richiesti"),
}), 400
resp = api_client.post(
f"/api/production-runs/{run_id}/{action}",
data={
"supervisor_username": username,
"supervisor_password": password,
"note": data.get("note"),
},
creds, error = _supervisor_credentials(data) # #59: PIN or password
if error:
return error
return _post_supervised(
f"/api/production-runs/{run_id}/{action}", creds, {"note": data.get("note")},
)
if isinstance(resp, dict) and resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 200
@measure_bp.route("/api/production/<int:run_id>/pause", methods=["POST"])
@@ -55,6 +55,10 @@
<template x-if="user.is_admin">
<span class="ml-1.5 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300">Admin</span>
</template>
<!-- #59: indicatore PIN impostato (solo se il backend manda has_pin) -->
<template x-if="!user.is_admin && user.has_pin === true">
<span class="ml-1.5 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold bg-sky-100 dark:bg-sky-900/30 text-sky-700 dark:text-sky-300">{{ _('PIN impostato') }}</span>
</template>
</div>
</div>
</td>
@@ -97,6 +101,10 @@
<path stroke-linecap="round" stroke-linejoin="round" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"/>
</svg>
</button>
<!-- #59: imposta/reset PIN (non per gli admin: il backend risponde 422) -->
<template x-if="!user.is_admin">
<button @click="openPin(user)" class="px-1.5 py-1 rounded-lg text-[11px] font-semibold text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20 transition-colors" :title="user.has_pin ? {{ _('Reset PIN')|tojson_attr }} : {{ _('Imposta PIN')|tojson_attr }}">PIN</button>
</template>
<button @click="toggleActive(user)" class="p-1.5 rounded-lg transition-colors"
:class="user.active
? 'text-[var(--text-secondary)] hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20'
@@ -210,6 +218,20 @@
minlength="6">
</div>
<!-- #59: PIN facoltativo, solo in creazione di un non admin -->
<template x-if="!isEditing && !form.is_admin">
<div>
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">
{{ _('PIN') }}
<span class="font-normal text-[var(--text-secondary)]"> ({{ _('facoltativo, 4 cifre') }})</span>
</label>
<input type="text" x-model="form.pin" inputmode="numeric" maxlength="4" autocomplete="off"
class="w-full px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)]
text-sm font-mono text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors"
placeholder="1234">
</div>
</template>
<!-- Roles -->
<div>
<label class="block text-sm font-medium text-[var(--text-primary)] mb-2">{{ _('Ruoli') }}</label>
@@ -374,4 +396,59 @@
</div>
</div>
<!-- #59: Modal imposta / reset / togli PIN (il PIN attuale non si mostra mai) -->
<div x-show="showPin" x-cloak
class="fixed inset-0 z-50 flex items-center justify-center p-4"
@keydown.escape.window="closePin()">
<div class="absolute inset-0 bg-black/50" @click="closePin()"></div>
<div class="relative bg-[var(--bg-card)] rounded-xl border border-[var(--border-color)] shadow-xl w-full max-w-md p-6">
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-2">
<span x-text="pinUser?.has_pin ? {{ _('Reset PIN')|tojson_attr }} : {{ _('Imposta PIN')|tojson_attr }}"></span>: <span x-text="pinUser?.username"></span>
</h3>
<p class="text-sm text-[var(--text-secondary)] mb-4">
{{ _('Il PIN serve per accedere alla postazione e, per il capoturno, per autorizzare le azioni.') }}
</p>
<template x-if="!pinDone">
<div>
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Nuovo PIN (4 cifre)') }}</label>
<div class="flex gap-2">
<input type="text" x-model="pinValue" inputmode="numeric" maxlength="4" autocomplete="off"
class="flex-1 px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)] text-sm font-mono text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary">
<button @click="generatePin()" type="button"
class="px-3 py-2 text-sm font-medium rounded-lg border border-[var(--border-color)] text-[var(--text-primary)] hover:bg-[var(--bg-secondary)]">
{{ _('Genera') }}
</button>
</div>
<template x-if="pinError">
<p class="mt-2 text-sm text-red-600" x-text="pinError"></p>
</template>
</div>
</template>
<template x-if="pinDone">
<div class="px-3 py-2 rounded-lg bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-200 dark:border-emerald-800">
<p class="text-sm text-emerald-700 dark:text-emerald-300" x-text="pinRemoved ? {{ _('PIN tolto.')|tojson_attr }} : {{ _('PIN impostato. Annotalo ora: non verrà mostrato di nuovo.')|tojson_attr }}"></p>
<p x-show="!pinRemoved" class="mt-1 text-base font-mono font-semibold text-[var(--text-primary)] select-all" x-text="pinValue"></p>
</div>
</template>
<div class="flex items-center justify-between gap-3 mt-5">
<div>
<button x-show="!pinDone && pinUser?.has_pin" @click="removePin()" :disabled="saving"
class="px-4 py-2 text-sm font-medium text-red-600 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 disabled:opacity-50">
{{ _('Togli PIN') }}
</button>
</div>
<div class="flex gap-3">
<button @click="closePin()"
class="px-4 py-2 text-sm font-medium text-[var(--text-secondary)] rounded-lg hover:bg-[var(--bg-secondary)]">
<span x-text="pinDone ? {{ _('Chiudi')|tojson_attr }} : {{ _('Annulla')|tojson_attr }}"></span>
</button>
<button x-show="!pinDone" @click="confirmPin()" :disabled="saving"
class="px-4 py-2 bg-primary text-white text-sm font-medium rounded-lg hover:bg-primary-700 shadow-sm disabled:opacity-50">
{{ _('Imposta PIN') }}
</button>
</div>
</div>
</div>
</div>
</div>
@@ -11,6 +11,13 @@ function userManagement(initialUsers) {
resetPw: '',
resetDone: false,
resetError: '',
// #59: stato del modal PIN
showPin: false,
pinUser: null,
pinValue: '',
pinDone: false,
pinRemoved: false,
pinError: '',
isEditing: false,
editingUserId: null,
toggleUser: null,
@@ -21,6 +28,7 @@ function userManagement(initialUsers) {
display_name: '',
email: '',
password: '',
pin: '', // #59: PIN facoltativo alla creazione
roles: [],
is_admin: false,
language_pref: 'it',
@@ -52,6 +60,7 @@ function userManagement(initialUsers) {
display_name: '',
email: '',
password: '',
pin: '', // #59
roles: [],
is_admin: false,
language_pref: 'it',
@@ -134,6 +143,12 @@ function userManagement(initialUsers) {
return;
}
// #59: PIN facoltativo, 4 cifre, solo per i non admin
const pin = this.form.is_admin ? '' : (this.form.pin || '').trim();
if (pin && !/^\d{4}$/.test(pin)) {
this.errorMsg = {{ _('Il PIN deve essere di 4 cifre')|tojson }};
return;
}
const data = {
username: this.form.username,
password: this.form.password,
@@ -158,6 +173,24 @@ function userManagement(initialUsers) {
}
this.users.push(result);
// #59: l'utente esiste gia', il PIN si imposta con la chiamata dedicata
if (pin) {
const pinResp = await fetch(`/admin/api/users/${result.id}/pin`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
body: JSON.stringify({ pin: pin })
});
if (!pinResp.ok) {
const pinResult = await pinResp.json().catch(() => ({}));
// resta aperto in modifica sull'utente appena creato, cosi' non si ricrea
this.isEditing = true;
this.editingUserId = result.id;
this.errorMsg = {{ _('Utente creato, ma PIN non impostato')|tojson }} + ': ' + (pinResult.detail || '');
return;
}
result.has_pin = true;
}
}
this.closeModal();
@@ -218,6 +251,70 @@ function userManagement(initialUsers) {
}
},
// #59: imposta / reset / togli PIN, usa PUT /admin/api/users/<id>/pin
openPin(user) {
this.pinUser = user;
this.pinValue = '';
this.pinDone = false;
this.pinRemoved = false;
this.pinError = '';
this.showPin = true;
},
closePin() {
this.showPin = false;
this.pinValue = ''; // il PIN generato non resta in memoria
this.pinDone = false;
},
generatePin() {
// 4 cifre da crypto random
const buf = new Uint32Array(4);
crypto.getRandomValues(buf);
this.pinValue = Array.from(buf, n => n % 10).join('');
this.pinError = '';
},
async savePin(pin) {
// pin = stringa di 4 cifre oppure null (toglie il PIN)
this.saving = true;
this.pinError = '';
try {
const resp = await fetch(`/admin/api/users/${this.pinUser.id}/pin`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
body: JSON.stringify({ pin: pin })
});
const result = await resp.json().catch(() => ({}));
if (!resp.ok) {
this.pinError = result.detail || {{ _('Errore nel salvataggio del PIN')|tojson }};
return;
}
// aggiorna l'indicatore locale
const idx = this.users.findIndex(u => u.id === this.pinUser.id);
if (idx >= 0) this.users[idx] = Object.assign({}, this.users[idx], { has_pin: pin !== null });
this.pinUser = idx >= 0 ? this.users[idx] : this.pinUser;
this.pinRemoved = pin === null;
this.pinDone = true;
} catch (e) {
this.pinError = {{ _('Errore di connessione al server')|tojson }};
} finally {
this.saving = false;
}
},
async confirmPin() {
if (!/^\d{4}$/.test(this.pinValue)) {
this.pinError = {{ _('Il PIN deve essere di 4 cifre')|tojson }};
return;
}
await this.savePin(this.pinValue);
},
async removePin() {
await this.savePin(null);
},
toggleActive(user) {
this.toggleUser = user;
this.showToggleConfirm = true;
@@ -8,6 +8,8 @@
{% set icon_stations %}<path stroke-linecap="round" stroke-linejoin="round" d="M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25m18 0A2.25 2.25 0 0018.75 3H5.25A2.25 2.25 0 003 5.25m18 0V12a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 12V5.25"/>{% endset %}
{% set icon_settings %}<path stroke-linecap="round" stroke-linejoin="round" d="M10.343 3.94c.09-.542.56-.94 1.11-.94h1.093c.55 0 1.02.398 1.11.94l.149.894c.07.424.384.764.78.93.398.164.855.142 1.205-.108l.737-.527a1.125 1.125 0 011.45.12l.773.774c.39.389.44 1.002.12 1.45l-.527.737c-.25.35-.272.806-.107 1.204.165.397.505.71.93.78l.893.15c.543.09.94.56.94 1.109v1.094c0 .55-.397 1.02-.94 1.11l-.893.149c-.425.07-.765.383-.93.78-.165.398-.143.854.107 1.204l.527.738c.32.447.269 1.06-.12 1.45l-.774.773a1.125 1.125 0 01-1.449.12l-.738-.527c-.35-.25-.806-.272-1.204-.107-.397.165-.71.505-.78.929l-.15.894c-.09.542-.56.94-1.11.94h-1.094c-.55 0-1.019-.398-1.11-.94l-.148-.894c-.071-.424-.384-.764-.781-.93-.398-.164-.854-.142-1.204.108l-.738.527c-.447.32-1.06.269-1.45-.12l-.773-.774a1.125 1.125 0 01-.12-1.45l.527-.737c.25-.35.273-.806.108-1.204-.165-.397-.506-.71-.93-.78l-.894-.15c-.542-.09-.94-.56-.94-1.109v-1.094c0-.55.398-1.02.94-1.11l.894-.149c.424-.07.765-.383.93-.78.165-.398.143-.854-.107-1.204l-.527-.738a1.125 1.125 0 01.12-1.45l.773-.773a1.125 1.125 0 011.45-.12l.737.527c.35.25.807.272 1.204.107.397-.165.71-.505.78-.929l.15-.894z"/><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>{% endset %}
{% set icon_monitor %}<path stroke-linecap="round" stroke-linejoin="round" d="M9.348 14.652a3.75 3.75 0 010-5.304m5.304 0a3.75 3.75 0 010 5.304m-7.425 2.121a6.75 6.75 0 010-9.546m9.546 0a6.75 6.75 0 010 9.546M5.106 18.894c-3.808-3.807-3.808-9.98 0-13.788m13.788 0c3.808 3.807 3.808 9.98 0 13.788M12 12h.008v.008H12V12zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z"/>{% endset %}
{# Single nav link, desktop or mobile variant. `icon` is SVG path markup. #}
{% macro nav_link(href, label, icon, active=false, mobile=false) -%}
<a href="{{ href }}"
@@ -31,6 +33,10 @@
{% if current_user.get('roles') and 'Metrologist' in current_user.roles %}
{{ nav_link(url_for('statistics.dashboard'), _('Statistiche'), icon_stats, ep.startswith('statistics.'), mobile) }}
{% endif %}
{# #57: Monitor produzione, visibile ad admin e capoturno (stessa regola di monitor_required) #}
{% if current_user.get('is_admin') or (current_user.get('roles') and 'Supervisor' in current_user.roles) %}
{{ nav_link(url_for('monitor.index'), _('Monitor'), icon_monitor, ep.startswith('monitor.'), mobile) }}
{% endif %}
{% if current_user.get('is_admin') %}
{{ nav_link(url_for('admin.station_list'), _('Stazioni'), icon_stations, ep == 'admin.station_list', mobile) }}
{{ nav_link(url_for('admin.settings_page'), _('Impostazioni'), icon_settings, ep == 'admin.settings_page', mobile) }}
@@ -921,7 +921,55 @@
</div>
</div>
<div class="space-y-3 mb-5">
{# #59: two ways to authorise - PIN (default, touch pad) or password. #}
<div class="flex gap-2 mb-4" role="tablist">
<button type="button" role="tab" @click="setSupervisorMode('pin')"
:disabled="!supervisorPinUsers.length"
class="btn flex-1 text-sm"
:class="supervisorMode === 'pin' ? 'btn-primary' : 'btn-secondary'">{{ _('PIN') }}</button>
<button type="button" role="tab" @click="setSupervisorMode('password')"
class="btn flex-1 text-sm"
:class="supervisorMode === 'password' ? 'btn-primary' : 'btn-secondary'">{{ _('Password') }}</button>
</div>
<div x-show="supervisorMode === 'pin'" class="mb-5">
{# name grid #}
<div x-show="!supervisorPinUser">
<p class="text-xs text-center text-[var(--text-secondary)] mb-3">{{ _('Chi sei?') }}</p>
<div class="grid grid-cols-2 gap-3 max-h-64 overflow-y-auto">
<template x-for="u in supervisorPinUsers" :key="u.id">
<button type="button" @click="selectSupervisorUser(u)"
class="btn btn-secondary min-h-[64px] text-base w-full" x-text="u.display_name"></button>
</template>
</div>
</div>
{# pad #}
<div x-show="supervisorPinUser">
<p class="text-center font-semibold mb-1" x-text="supervisorPinUser && supervisorPinUser.display_name"></p>
<p class="text-xs text-center text-[var(--text-secondary)] mb-3">{{ _('Inserisci il PIN') }}</p>
<div class="flex justify-center gap-3 mb-3" aria-hidden="true">
<template x-for="i in 4" :key="i">
<span class="h-4 w-4 rounded-full border-2 border-[var(--border-color)]"
:class="supervisorPin.length >= i ? 'bg-primary-600 border-primary-600' : ''"></span>
</template>
</div>
<div class="grid grid-cols-3 gap-3 max-w-xs mx-auto">
<template x-for="d in ['1','2','3','4','5','6','7','8','9']" :key="d">
<button type="button" @click="pressSupervisorPin(d)" :disabled="supervisorValidating"
class="btn btn-secondary h-16 min-w-[64px] text-2xl" x-text="d"></button>
</template>
<button type="button" @click="backSupervisorUser()" :disabled="supervisorValidating"
class="btn btn-secondary h-16 min-w-[64px] text-sm">{{ _('Indietro') }}</button>
<button type="button" @click="pressSupervisorPin('0')" :disabled="supervisorValidating"
class="btn btn-secondary h-16 min-w-[64px] text-2xl">0</button>
<button type="button" @click="supervisorPin = supervisorPin.slice(0, -1)" :disabled="supervisorValidating"
class="btn btn-secondary h-16 min-w-[64px] text-sm">{{ _('Cancella') }}</button>
</div>
</div>
<p x-show="supervisorError" class="text-sm text-center text-red-600 mt-3" role="alert" x-text="supervisorError"></p>
</div>
<div x-show="supervisorMode === 'password'" class="space-y-3 mb-5">
<div>
<label class="tmf-label text-xs">{{ _('Username') }}</label>
<input type="text" x-model="supervisorUsername" class="tmf-input text-sm"
@@ -945,13 +993,13 @@
<span x-show="supervisorAction === 'out_of_tolerance'">{{ _('Rimisura la quota') }}</span>
</button>
<button @click="submitSupervisor()"
:disabled="!supervisorUsername || !supervisorPassword || supervisorValidating"
:disabled="!supervisorCanSubmit || supervisorValidating"
class="btn btn-primary flex-1 text-sm gap-1">
<svg x-show="supervisorValidating" class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"/>
</svg>
{{ _('Autorizza') }}
<span x-text="supervisorMode === 'pin' ? '{{ _('Conferma') }}' : '{{ _('Autorizza') }}'"></span>
</button>
</div>
</div>
@@ -1039,6 +1087,11 @@ function taskExecute() {
supervisorAction: '', // 'out_of_tolerance', 'fermo_linea', 'fine_produzione'
supervisorUsername: '',
supervisorPassword: '',
// #59: the capoturno can authorise with name + 4-digit PIN instead of the password.
supervisorMode: 'pin', // 'pin' | 'password'
supervisorPinUsers: [], // [{id, display_name}] from /measure/api/pin-users
supervisorPinUser: null,
supervisorPin: '', // never kept past the modal: cleared on open and close
supervisorError: '',
supervisorValidating: false,
/* The out-of-tolerance measurement waiting for the capoturno:
@@ -1832,6 +1885,7 @@ function taskExecute() {
this.supervisorUsername = '';
this.supervisorPassword = '';
this.supervisorError = '';
this.resetSupervisorPin(); // #59: PIN fields cleared, names reloaded
this.showSupervisorModal = true;
},
@@ -1840,6 +1894,68 @@ function taskExecute() {
this.supervisorUsername = '';
this.supervisorPassword = '';
this.supervisorError = '';
this.supervisorPin = ''; // #59: the PIN does not outlive the modal
this.supervisorPinUser = null;
},
/* #59: PIN mode of the capoturno modal. */
resetSupervisorPin() {
this.supervisorPin = '';
this.supervisorPinUser = null;
this.supervisorMode = 'pin';
this.loadSupervisorPinUsers();
},
// Names with a PIN; an empty list or a failure opens the Password tab instead.
async loadSupervisorPinUsers() {
try {
const resp = await fetch('{{ url_for("measure.api_pin_users") }}', { headers: { 'Accept': 'application/json' } });
const list = resp.ok ? await resp.json() : [];
this.supervisorPinUsers = Array.isArray(list) ? list : [];
} catch (e) {
this.supervisorPinUsers = [];
}
if (!this.supervisorPinUsers.length) this.supervisorMode = 'password';
},
setSupervisorMode(mode) {
this.supervisorMode = mode;
this.supervisorPin = '';
this.supervisorPinUser = null;
this.supervisorError = '';
},
selectSupervisorUser(u) {
this.supervisorPinUser = u;
this.supervisorPin = '';
this.supervisorError = '';
},
backSupervisorUser() {
this.supervisorPinUser = null;
this.supervisorPin = '';
this.supervisorError = '';
},
// Submits by itself on the fourth digit.
pressSupervisorPin(d) {
if (this.supervisorValidating || this.supervisorPin.length >= 4) return;
this.supervisorPin += d;
this.supervisorError = '';
if (this.supervisorPin.length === 4) this.submitSupervisor();
},
get supervisorCanSubmit() {
if (this.supervisorMode === 'pin') return !!this.supervisorPinUser && this.supervisorPin.length === 4;
return !!this.supervisorUsername && !!this.supervisorPassword;
},
// The pair the proxies forward: id + PIN in PIN mode, username + password otherwise.
supervisorCredentials() {
if (this.supervisorMode === 'pin') {
return { supervisor_user_id: this.supervisorPinUser && this.supervisorPinUser.id, supervisor_pin: this.supervisorPin };
}
return { supervisor_username: this.supervisorUsername, supervisor_password: this.supervisorPassword };
},
/* What kind of task this is, as declared in the recipe.
@@ -1910,16 +2026,15 @@ function taskExecute() {
const resp = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken },
body: JSON.stringify({
supervisor_username: this.supervisorUsername,
supervisor_password: this.supervisorPassword,
}),
body: JSON.stringify(this.supervisorCredentials()), // #59: PIN or password
});
const data = await resp.json().catch(() => null);
if (!resp.ok || (data && data.error)) {
this.supervisorError = (data && data.detail)
|| (this.supervisorMode === 'pin' && resp.status === 401 ? '{{ _("PIN errato") }}' : '')
|| '{{ _("Credenziali non valide o utente non autorizzato") }}';
if (this.supervisorMode === 'pin') this.supervisorPin = ''; // #59: wrong PIN stays in the modal, pad cleared
this.supervisorValidating = false;
return;
}
@@ -1954,10 +2069,7 @@ function taskExecute() {
const base = '{{ url_for("measure.api_pause_production", run_id=0) }}'
.replace('/0/pause', '/' + this.productionRun.id + '/' + action);
const run = await this.postProduction(base, {
supervisor_username: this.supervisorUsername,
supervisor_password: this.supervisorPassword,
});
const run = await this.postProduction(base, this.supervisorCredentials()); // #59: PIN or password
this.supervisorValidating = false;
if (!run) {
@@ -1965,6 +2077,7 @@ function taskExecute() {
// capoturno sees why the action was refused, rather than a silent no-op.
this.supervisorError = this.productionError || '{{ _("Azione non riuscita") }}';
this.productionError = '';
if (this.supervisorMode === 'pin') this.supervisorPin = ''; // #59: wrong PIN / lockout: pad cleared, modal stays
return;
}
@@ -0,0 +1,110 @@
"""#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,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"