Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1a5e98f072 | |||
| fdfcf05951 | |||
| 26884ebf4f | |||
| 8b3d3a8424 | |||
| 153fafd1b4 | |||
| cf8fab4c08 | |||
| b7cb061fee | |||
| 6a8ad4dae4 | |||
| d750cf9e0e | |||
| 2564498915 | |||
| 21f8bae5ac | |||
| c1f8a5e2ff | |||
| dee27ac8e1 | |||
| df1727be73 |
@@ -35,6 +35,8 @@ 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
|
||||
@@ -97,7 +99,7 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||
return True
|
||||
if method != "POST":
|
||||
return False
|
||||
if path == cls.LOGIN_PATH:
|
||||
if path in (cls.LOGIN_PATH, cls.LOGIN_PIN_PATH):
|
||||
return True
|
||||
return any(p.match(path) for p in cls._SUPERVISOR_CREDENTIAL_PATTERNS)
|
||||
|
||||
|
||||
@@ -61,7 +61,8 @@ 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.
|
||||
_UNDELETABLE_PREFIXES = ("statistics/", "reports/", "logos/")
|
||||
# task_results/: allegati degli esiti dei task (#55), record di audit.
|
||||
_UNDELETABLE_PREFIXES = ("statistics/", "reports/", "logos/", "task_results/")
|
||||
|
||||
# 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
|
||||
|
||||
@@ -27,6 +27,7 @@ router = APIRouter(prefix="/api/production-runs", tags=["production"])
|
||||
|
||||
_DERIVED = {
|
||||
"seconds_to_next_measurement", "overdue", "server_time", "measurement_task_ids",
|
||||
"close_task_ids",
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +54,8 @@ 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),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
"""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]
|
||||
@@ -17,6 +17,9 @@ 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
|
||||
@@ -202,6 +205,13 @@ 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()
|
||||
@@ -240,6 +250,10 @@ 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()
|
||||
@@ -341,6 +355,26 @@ 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():
|
||||
|
||||
@@ -23,6 +23,7 @@ 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
|
||||
@@ -77,6 +78,7 @@ 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")
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
"""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')
|
||||
@@ -55,6 +55,8 @@ 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,9 +1,10 @@
|
||||
"""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 TASK_TYPES
|
||||
from src.backend.models.orm.task import DEFAULT_LOOP_TASK_TYPES, TASK_TYPES
|
||||
|
||||
# Kept in step with the ORM enum so the two cannot drift apart.
|
||||
TaskType = Literal[TASK_TYPES]
|
||||
@@ -34,6 +35,76 @@ 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)
|
||||
@@ -98,6 +169,10 @@ 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] = []
|
||||
|
||||
|
||||
@@ -111,6 +186,9 @@ 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):
|
||||
@@ -129,6 +207,9 @@ 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] = []
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"""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
|
||||
@@ -3,6 +3,7 @@ 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
|
||||
@@ -17,6 +18,7 @@ __all__ = [
|
||||
"RecipeVersion",
|
||||
"RecipeTask",
|
||||
"RecipeSubtask",
|
||||
"TaskResult",
|
||||
"Measurement",
|
||||
"AccessLog",
|
||||
"SystemSetting",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from sqlalchemy import (
|
||||
DECIMAL, Enum, ForeignKey, Index, Integer, JSON, String, Text, UniqueConstraint,
|
||||
Boolean, DECIMAL, Enum, ForeignKey, Index, Integer, JSON, String, Text, UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
@@ -15,12 +15,22 @@ 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.
|
||||
TASK_TYPES = ("note", "measure", "drawing", "dxf_compare", "camera_measure")
|
||||
# 'check' = Controllo OK/NOK, senza quote (#54).
|
||||
TASK_TYPES = ("note", "measure", "drawing", "dxf_compare", "camera_measure", "check")
|
||||
|
||||
# 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"
|
||||
@@ -55,6 +65,17 @@ 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")
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"""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}>"
|
||||
@@ -145,7 +145,21 @@ async def measurement_task_ids(db: AsyncSession, version_id: int) -> list[int]:
|
||||
select(RecipeTask.id)
|
||||
.where(
|
||||
RecipeTask.version_id == version_id,
|
||||
RecipeTask.task_type.in_(MEASURING_TASK_TYPES),
|
||||
# #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),
|
||||
)
|
||||
.order_by(RecipeTask.order_index, RecipeTask.id)
|
||||
)
|
||||
|
||||
@@ -117,6 +117,10 @@ 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
|
||||
|
||||
@@ -168,3 +168,68 @@ 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")
|
||||
|
||||
@@ -6,6 +6,7 @@ from fastapi import HTTPException
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from src.backend.config import settings
|
||||
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
|
||||
@@ -15,6 +16,13 @@ from src.backend.tests.test_production_runs import (
|
||||
from src.backend.utils.tempo import utcnow
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _alza_limite_login(monkeypatch):
|
||||
# #65: login-pin conta nel bucket del login (limite al minuto); i test del blocco PIN
|
||||
# fanno piu tentativi di fila, quindi qui il limite si alza. Il middleware resta com'e.
|
||||
monkeypatch.setattr(settings, "rate_limit_login", 1000)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
"""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")
|
||||
@@ -0,0 +1,240 @@
|
||||
"""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
|
||||
@@ -935,7 +935,7 @@ def api_upload_attachment():
|
||||
if upload is None or not upload.filename:
|
||||
return jsonify({"error": True, "detail": _("Nessun file caricato")}), 400
|
||||
resp = api_client.post(
|
||||
"/api/files/upload",
|
||||
"/api/task-results/attachments", # #55: upload per l operatore (files/upload e solo Maker)
|
||||
files={"file": (
|
||||
upload.filename,
|
||||
upload.read(),
|
||||
|
||||
@@ -25,7 +25,8 @@
|
||||
{% include "admin/_users_panel.html" %}
|
||||
</div>
|
||||
|
||||
<div x-show="tab === 'generale'" class="max-w-4xl mx-auto" x-data="systemSettings()">
|
||||
{# #65: niente larghezza propria, la scheda sta nella cornice .tmf-page #}
|
||||
<div x-show="tab === 'generale'" x-data="systemSettings()">
|
||||
<!-- Settings Card -->
|
||||
<div class="tmf-card mb-6">
|
||||
<div class="tmf-card-header flex items-center gap-2">
|
||||
|
||||
@@ -32,6 +32,10 @@ def _views():
|
||||
rel = path.relative_to(TEMPLATES).as_posix()
|
||||
if rel.startswith("components/") or rel == "base.html":
|
||||
continue
|
||||
# #65: i parziali (nome che comincia per "_") sono inclusi da un'altra vista
|
||||
# e stanno dentro la sua cornice .tmf-page: da soli non ne hanno una.
|
||||
if path.name.startswith("_"):
|
||||
continue
|
||||
yield rel, path
|
||||
|
||||
|
||||
|
||||
@@ -160,7 +160,7 @@ def test_attachment_upload_forwards_multipart(logged_in_client, mock_api_client)
|
||||
assert resp.status_code == 201
|
||||
assert resp.get_json()["file_path"] == "general/foto.png"
|
||||
args, kwargs = mock_api_client.post.call_args
|
||||
assert args[0] == "/api/files/upload"
|
||||
assert args[0] == "/api/task-results/attachments" # #55
|
||||
name, content, mime = kwargs["files"]["file"]
|
||||
assert name == "foto.png" and mime == "image/png" and content.startswith(b"\x89PNG")
|
||||
|
||||
|
||||
@@ -128,9 +128,11 @@ def test_the_pdf_worker_is_local_everywhere_it_is_set():
|
||||
assert "vendor/pdf.worker" in line, (
|
||||
f"{path.relative_to(APP_ROOT)}:{n} punta il worker fuori casa"
|
||||
)
|
||||
# Four templates render PDFs; if that number changes the new one needs the
|
||||
# Five templates render PDFs; if that number changes the new one needs the
|
||||
# worker set too, and this is where you find out.
|
||||
assert len(setters) == 4, f"template che impostano il worker: {setters}"
|
||||
# #65: da quattro a cinque, task_execute.html mostra PDF nelle note e negli
|
||||
# allegati dell'esecuzione (#55).
|
||||
assert len(setters) == 5,f"template che impostano il worker: {setters}"
|
||||
|
||||
|
||||
def test_the_shipped_libraries_are_the_ones_that_were_checked():
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Vendored
+1
-1
Submodule vendor/visionsuite updated: bc0df1667c...adf7a2640d
Reference in New Issue
Block a user