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:
@@ -35,6 +35,8 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
LOGIN_PATH = "/api/auth/login"
|
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
|
WINDOW_SECONDS = 60
|
||||||
|
|
||||||
# Paths that carry a supervisor's (capoturno) credentials in the request
|
# Paths that carry a supervisor's (capoturno) credentials in the request
|
||||||
@@ -97,7 +99,7 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
|||||||
return True
|
return True
|
||||||
if method != "POST":
|
if method != "POST":
|
||||||
return False
|
return False
|
||||||
if path == cls.LOGIN_PATH:
|
if path in (cls.LOGIN_PATH, cls.LOGIN_PIN_PATH):
|
||||||
return True
|
return True
|
||||||
return any(p.match(path) for p in cls._SUPERVISOR_CREDENTIAL_PATTERNS)
|
return any(p.match(path) for p in cls._SUPERVISOR_CREDENTIAL_PATTERNS)
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ router = APIRouter(prefix="/api/production-runs", tags=["production"])
|
|||||||
|
|
||||||
_DERIVED = {
|
_DERIVED = {
|
||||||
"seconds_to_next_measurement", "overdue", "server_time", "measurement_task_ids",
|
"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(
|
measurement_task_ids=await production_service.measurement_task_ids(
|
||||||
db, run.version_id,
|
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,132 @@
|
|||||||
|
"""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.
|
||||||
|
"""
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, 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.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)
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_relative_path(path: str) -> bool:
|
||||||
|
"""Percorso di upload: relativo, senza '..', senza unita' o backslash."""
|
||||||
|
if path.startswith("/") or "\\" in path or ":" in path:
|
||||||
|
return False
|
||||||
|
return ".." not in path.split("/")
|
||||||
|
|
||||||
|
|
||||||
|
@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: {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,
|
TaskReorderRequest,
|
||||||
TaskResponse,
|
TaskResponse,
|
||||||
TaskUpdate,
|
TaskUpdate,
|
||||||
|
check_task_loop_options,
|
||||||
|
default_in_loop,
|
||||||
|
normalize_note_fields,
|
||||||
validate_tolerance_order,
|
validate_tolerance_order,
|
||||||
)
|
)
|
||||||
from src.backend.models.orm.vision import VisionReferenceImage
|
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
|
This creates a NEW version via copy-on-write, then appends the task to
|
||||||
the new version. Requires Maker role.
|
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
|
# Ensure recipe exists and is active
|
||||||
result = await db.execute(select(Recipe).where(Recipe.id == recipe_id))
|
result = await db.execute(select(Recipe).where(Recipe.id == recipe_id))
|
||||||
recipe = result.scalar_one_or_none()
|
recipe = result.scalar_one_or_none()
|
||||||
@@ -240,6 +250,10 @@ async def create_task(
|
|||||||
file_type=data.file_type,
|
file_type=data.file_type,
|
||||||
annotations_json=data.annotations_json,
|
annotations_json=data.annotations_json,
|
||||||
vision_json=data.vision_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)
|
db.add(new_task)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
@@ -341,6 +355,26 @@ async def update_task(
|
|||||||
if not update_data:
|
if not update_data:
|
||||||
return TaskResponse.model_validate(task)
|
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)
|
task = await _task_for_write(db, task, user)
|
||||||
|
|
||||||
for field, value in update_data.items():
|
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.stations import router as stations_router
|
||||||
from src.backend.api.routers.production import router as production_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.vision import router as vision_router
|
||||||
|
from src.backend.api.routers.task_results import router as task_results_router
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
@@ -77,6 +78,7 @@ app.include_router(setup_router)
|
|||||||
app.include_router(stations_router)
|
app.include_router(stations_router)
|
||||||
app.include_router(production_router)
|
app.include_router(production_router)
|
||||||
app.include_router(vision_router)
|
app.include_router(vision_router)
|
||||||
|
app.include_router(task_results_router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
@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
|
# 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.
|
# expires, whichever screen they are on, and to know which task closes a cycle.
|
||||||
measurement_task_ids: list[int] = Field(default_factory=list)
|
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):
|
class ProductionRunWithEventsResponse(ProductionRunResponse):
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
"""Pydantic schemas for RecipeTask and RecipeSubtask operations."""
|
"""Pydantic schemas for RecipeTask and RecipeSubtask operations."""
|
||||||
|
import re
|
||||||
from typing import Any, Literal, Optional
|
from typing import Any, Literal, Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
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.
|
# Kept in step with the ORM enum so the two cannot drift apart.
|
||||||
TaskType = Literal[TASK_TYPES]
|
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):
|
class SubtaskCreate(BaseModel):
|
||||||
"""Schema for creating a subtask."""
|
"""Schema for creating a subtask."""
|
||||||
marker_number: int = Field(..., ge=1)
|
marker_number: int = Field(..., ge=1)
|
||||||
@@ -98,6 +169,10 @@ class TaskCreate(BaseModel):
|
|||||||
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
|
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
|
||||||
annotations_json: Optional[dict[str, Any]] = None
|
annotations_json: Optional[dict[str, Any]] = None
|
||||||
vision_json: Optional[dict] = 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] = []
|
subtasks: list[SubtaskCreate] = []
|
||||||
|
|
||||||
|
|
||||||
@@ -111,6 +186,9 @@ class TaskUpdate(BaseModel):
|
|||||||
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
|
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
|
||||||
annotations_json: Optional[dict[str, Any]] = None
|
annotations_json: Optional[dict[str, Any]] = None
|
||||||
vision_json: Optional[dict] = 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):
|
class TaskResponse(BaseModel):
|
||||||
@@ -129,6 +207,9 @@ class TaskResponse(BaseModel):
|
|||||||
file_type: Optional[str] = None
|
file_type: Optional[str] = None
|
||||||
annotations_json: Optional[dict[str, Any]] = None
|
annotations_json: Optional[dict[str, Any]] = None
|
||||||
vision_json: Optional[dict] = None
|
vision_json: Optional[dict] = None
|
||||||
|
in_loop: bool = False
|
||||||
|
at_close: bool = False
|
||||||
|
fields: Optional[list[dict[str, Any]]] = None
|
||||||
subtasks: list[SubtaskResponse] = []
|
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.api_key import ApiKey
|
||||||
from src.backend.models.orm.recipe import Recipe, RecipeVersion
|
from src.backend.models.orm.recipe import Recipe, RecipeVersion
|
||||||
from src.backend.models.orm.task import RecipeTask, RecipeSubtask
|
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.measurement import Measurement
|
||||||
from src.backend.models.orm.access_log import AccessLog
|
from src.backend.models.orm.access_log import AccessLog
|
||||||
from src.backend.models.orm.setting import SystemSetting, RecipeVersionAudit
|
from src.backend.models.orm.setting import SystemSetting, RecipeVersionAudit
|
||||||
@@ -17,6 +18,7 @@ __all__ = [
|
|||||||
"RecipeVersion",
|
"RecipeVersion",
|
||||||
"RecipeTask",
|
"RecipeTask",
|
||||||
"RecipeSubtask",
|
"RecipeSubtask",
|
||||||
|
"TaskResult",
|
||||||
"Measurement",
|
"Measurement",
|
||||||
"AccessLog",
|
"AccessLog",
|
||||||
"SystemSetting",
|
"SystemSetting",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
from typing import TYPE_CHECKING, Optional
|
from typing import TYPE_CHECKING, Optional
|
||||||
|
|
||||||
from sqlalchemy import (
|
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
|
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
|
# 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
|
# 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.
|
# 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".
|
# Types the operator screen treats as "there are quotes to take here".
|
||||||
# dxf_compare produce quote come camera_measure (#46)
|
# dxf_compare produce quote come camera_measure (#46)
|
||||||
MEASURING_TASK_TYPES = ("measure", "camera_measure", "dxf_compare")
|
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):
|
class RecipeTask(Base):
|
||||||
__tablename__ = "recipe_tasks"
|
__tablename__ = "recipe_tasks"
|
||||||
@@ -55,6 +65,17 @@ class RecipeTask(Base):
|
|||||||
# Cartella immutabile (relativa a upload_path) con recipe.json e i file di
|
# Cartella immutabile (relativa a upload_path) con recipe.json e i file di
|
||||||
# meta della ricetta VisionSuite v2 (#46).
|
# meta della ricetta VisionSuite v2 (#46).
|
||||||
vision_recipe_dir: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
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
|
# Relationships
|
||||||
version: Mapped["RecipeVersion"] = relationship(back_populates="tasks")
|
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)
|
select(RecipeTask.id)
|
||||||
.where(
|
.where(
|
||||||
RecipeTask.version_id == version_id,
|
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)
|
.order_by(RecipeTask.order_index, RecipeTask.id)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -117,6 +117,10 @@ async def _copy_tasks_to_version(
|
|||||||
vision_json=task.vision_json,
|
vision_json=task.vision_json,
|
||||||
# Cartella immutabile: basta il riferimento (#46).
|
# Cartella immutabile: basta il riferimento (#46).
|
||||||
vision_recipe_dir=task.vision_recipe_dir,
|
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)
|
db.add(new_task)
|
||||||
await db.flush() # get new_task.id
|
await db.flush() # get new_task.id
|
||||||
|
|||||||
Reference in New Issue
Block a user