Task: in_loop, at_close, fields, tipo check, task_results, close_task_ids, rate limit login-pin (#53 #54 #55 #59)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 12:42:13 +02:00
parent e07c4d4eee
commit df1727be73
14 changed files with 493 additions and 5 deletions
+82 -1
View File
@@ -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] = []