8f9731839a
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
142 lines
6.4 KiB
Python
142 lines
6.4 KiB
Python
"""RecipeTask and RecipeSubtask models."""
|
|
from typing import TYPE_CHECKING, Optional
|
|
|
|
from sqlalchemy import (
|
|
Boolean, DECIMAL, Enum, ForeignKey, Index, Integer, JSON, String, Text, UniqueConstraint,
|
|
)
|
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
|
|
from src.backend.database import Base
|
|
|
|
if TYPE_CHECKING:
|
|
from src.backend.models.orm.recipe import RecipeVersion
|
|
|
|
|
|
# What a task is, declared by whoever writes the recipe instead of guessed from its
|
|
# contents. The last two are not built yet; they are in the enum from the start
|
|
# because widening a MySQL enum later is an ALTER on a live table.
|
|
# 'check' = Controllo OK/NOK, senza quote (#54).
|
|
TASK_TYPES = ("note", "measure", "drawing", "dxf_compare", "camera_measure", "check")
|
|
|
|
# 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"
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
version_id: Mapped[int] = mapped_column(
|
|
Integer, ForeignKey("recipe_versions.id"), nullable=False
|
|
)
|
|
order_index: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
# Declared, not deduced. The old rule - "has subtasks, therefore a measurement" -
|
|
# meant a measurement task whose quotes had not been entered yet behaved as a
|
|
# note, so the system acted differently depending on how finished the recipe was.
|
|
task_type: Mapped[str] = mapped_column(
|
|
Enum(*TASK_TYPES, name="task_type_enum"),
|
|
nullable=False,
|
|
default="note",
|
|
server_default="note",
|
|
index=True,
|
|
)
|
|
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
directive: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
|
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
|
file_path: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
|
file_type: Mapped[Optional[str]] = mapped_column(
|
|
Enum("image", "pdf", name="file_type_enum"), nullable=True
|
|
)
|
|
annotations_json: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
|
|
# The vision graph, serialised by vs-task. Opaque here on purpose: the
|
|
# server routes it and never interprets it, which is what keeps VisionSuite
|
|
# out of this container.
|
|
vision_json: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
|
|
# 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)
|
|
# #97: camera e modo di analisi del task, {camera_url, camera_source, modo};
|
|
# NULL = camera della stazione corrente, analisi impulsiva.
|
|
acquisition_json: Mapped[Optional[dict]] = mapped_column(JSON, 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")
|
|
subtasks: Mapped[list["RecipeSubtask"]] = relationship(
|
|
back_populates="task", cascade="all, delete-orphan", lazy="selectin"
|
|
)
|
|
|
|
__table_args__ = (
|
|
{"mysql_engine": "InnoDB", "mysql_charset": "utf8mb4"},
|
|
)
|
|
|
|
@property
|
|
def recipe_id(self) -> int | None:
|
|
"""Shortcut: recipe_id via the version relationship."""
|
|
if self.version:
|
|
return self.version.recipe_id
|
|
return None
|
|
|
|
def __repr__(self) -> str:
|
|
return f"<RecipeTask #{self.order_index} '{self.title}'>"
|
|
|
|
|
|
class RecipeSubtask(Base):
|
|
__tablename__ = "recipe_subtasks"
|
|
|
|
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
|
|
)
|
|
marker_number: Mapped[int] = mapped_column(Integer, nullable=False)
|
|
description: Mapped[str] = mapped_column(String(500), nullable=False)
|
|
measurement_type: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
|
|
|
# Tolerance values
|
|
nominal: Mapped[Optional[float]] = mapped_column(DECIMAL(12, 6), nullable=True)
|
|
utl: Mapped[Optional[float]] = mapped_column(DECIMAL(12, 6), nullable=True) # Upper Tolerance Limit
|
|
uwl: Mapped[Optional[float]] = mapped_column(DECIMAL(12, 6), nullable=True) # Upper Warning Limit
|
|
lwl: Mapped[Optional[float]] = mapped_column(DECIMAL(12, 6), nullable=True) # Lower Warning Limit
|
|
ltl: Mapped[Optional[float]] = mapped_column(DECIMAL(12, 6), nullable=True) # Lower Tolerance Limit
|
|
|
|
unit: Mapped[str] = mapped_column(String(20), nullable=False, default="mm")
|
|
image_path: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
|
# Which output of the vision graph fills this quote: "<tool_id>.<output>".
|
|
# A link is an id, not a pointer - the same rule vs-task follows, and for
|
|
# the same reason: a pointer does not survive being written to disk.
|
|
vision_output: Mapped[Optional[str]] = mapped_column(
|
|
String(120), nullable=True
|
|
)
|
|
|
|
# Relationships
|
|
task: Mapped["RecipeTask"] = relationship(back_populates="subtasks")
|
|
|
|
__table_args__ = (
|
|
UniqueConstraint("task_id", "marker_number", name="uq_task_marker"),
|
|
Index("ix_subtask_task_id", "task_id"),
|
|
{"mysql_engine": "InnoDB", "mysql_charset": "utf8mb4"},
|
|
)
|
|
|
|
def __repr__(self) -> str:
|
|
return f"<RecipeSubtask #{self.marker_number} '{self.description}'>"
|