chore(v2): restructure monorepo to src/ layout with uv

Aligns the repo with the python-project-spec-design.md template chosen
for V2.0.0. Big move, no logic changes. The 3 pre-existing test
failures (test_recipes::test_update_recipe, test_recipes::
test_recipe_versioning, test_tasks::test_reorder_tasks, plus the
client test_save_measurement_proxy) survive unchanged.

Layout changes
- server/        -> src/backend/
- server/middleware/ -> src/backend/api/middleware/
- server/routers/    -> src/backend/api/routers/
- server/models/     -> src/backend/models/orm/
- server/schemas/    -> src/backend/models/api/
- server/uploads/    -> uploads/ (project root, mounted volume)
- server/tests/      -> src/backend/tests/
- client/            -> src/frontend/flask_app/ (Flask kept; React
  deroga is documented in CLAUDE.md, justified by tablet UX, USB
  caliper/barcode workflow and Fabric.js integration)

Tooling
- pyproject.toml: monorepo with [project] core deps and
  optional-dependencies server / client / dev. Replaces both
  server/requirements.txt and client/requirements.txt.
- uv.lock + .python-version (3.11) committed for reproducible builds.
- Dockerfile (root, backend) and Dockerfile.frontend rewritten to use
  uv sync --frozen --no-dev --extra server|client; legacy Dockerfiles
  preserved as Dockerfile.legacy for reference but excluded from build
  context via .dockerignore.
- docker-compose.dev.yml + docker-compose.yml: build context now ".",
  dockerfile pointing to the root files.

Code adjustments forced by the move
- Every "from config|database|models|schemas|services|routers|middleware
  import ..." rewritten to its src.backend.* equivalent (50+ files
  including indented inline imports inside test bodies).
- src/backend/migrations/env.py: insert project root into sys.path so
  alembic can resolve src.backend.* imports regardless of cwd.
- src/backend/config.py: env_file ../../.env (was ../.env), upload_path
  resolves project root via parents[2].
- src/backend/tests/conftest.py + tests: import ... from src.backend.*
  instead of bare names; old per-directory pytest.ini files removed in
  favor of root pyproject.toml [tool.pytest.ini_options].
- .gitignore: uploads/ at root, src/frontend/flask_app/static/css/
  tailwind.css path; .dockerignore tightened.
- CLAUDE.md: rewrote sections "Layout del repository", "Comandi di
  Sviluppo", "Database & Migrations", "Test", "i18n", and all path
  references throughout the architecture sections.

Verified
- uv lock resolves 77 packages; uv sync --extra server --extra client
  --extra dev installs cleanly.
- uv run pytest: 171 passed, 4 pre-existing failures.
- uv run alembic -c src/backend/migrations/alembic.ini check loads
  config and metadata (errors only on the absent local MySQL).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-25 12:26:47 +02:00
parent 86df67f2e5
commit 1a0431366f
174 changed files with 2568 additions and 308 deletions
View File
+81
View File
@@ -0,0 +1,81 @@
"""Pydantic schemas for TieMeasureFlow API."""
from src.backend.models.api.measurement import (
MeasurementBatchCreate,
MeasurementCreate,
MeasurementListResponse,
MeasurementQuery,
MeasurementResponse,
)
from src.backend.models.api.recipe import (
RecipeCreate,
RecipeListResponse,
RecipeResponse,
RecipeUpdate,
RecipeVersionResponse,
)
from src.backend.models.api.statistics import (
AlertData,
CapabilityData,
ControlChartData,
HistogramData,
StatisticsQuery,
StatisticsResponse,
SummaryData,
TrendData,
)
from src.backend.models.api.task import (
SubtaskCreate,
SubtaskResponse,
SubtaskUpdate,
TaskCreate,
TaskReorderRequest,
TaskResponse,
TaskUpdate,
)
from src.backend.models.api.user import (
LoginRequest,
LoginResponse,
UserCreate,
UserProfileUpdate,
UserResponse,
UserUpdate,
)
__all__ = [
# User schemas
"UserCreate",
"UserUpdate",
"UserProfileUpdate",
"UserResponse",
"LoginRequest",
"LoginResponse",
# Recipe schemas
"RecipeCreate",
"RecipeUpdate",
"RecipeResponse",
"RecipeVersionResponse",
"RecipeListResponse",
# Task schemas
"TaskCreate",
"TaskUpdate",
"TaskResponse",
"TaskReorderRequest",
"SubtaskCreate",
"SubtaskUpdate",
"SubtaskResponse",
# Measurement schemas
"MeasurementCreate",
"MeasurementBatchCreate",
"MeasurementResponse",
"MeasurementListResponse",
"MeasurementQuery",
# Statistics schemas
"StatisticsQuery",
"ControlChartData",
"HistogramData",
"CapabilityData",
"TrendData",
"SummaryData",
"AlertData",
"StatisticsResponse",
]
+62
View File
@@ -0,0 +1,62 @@
"""Pydantic schemas for Measurement operations."""
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, ConfigDict, Field
class MeasurementCreate(BaseModel):
"""Schema for creating a single measurement."""
subtask_id: int
version_id: int
value: float
lot_number: Optional[str] = Field(None, max_length=100)
serial_number: Optional[str] = Field(None, max_length=100)
input_method: str = Field("manual", pattern="^(usb_caliper|manual)$")
class MeasurementBatchCreate(BaseModel):
"""Schema for creating multiple measurements at once."""
measurements: list[MeasurementCreate]
class MeasurementResponse(BaseModel):
"""Schema for measurement response."""
model_config = ConfigDict(from_attributes=True)
id: int
subtask_id: int
version_id: int
measured_by: int
value: float
pass_fail: str
deviation: Optional[float] = None
lot_number: Optional[str] = None
serial_number: Optional[str] = None
input_method: str
measured_at: datetime
synced_to_csv: bool
class MeasurementListResponse(BaseModel):
"""Schema for paginated measurement list."""
items: list[MeasurementResponse]
total: int
page: int
per_page: int
pages: int
class MeasurementQuery(BaseModel):
"""Schema for measurement query filters."""
recipe_id: Optional[int] = None
version_id: Optional[int] = None
subtask_id: Optional[int] = None
measured_by: Optional[int] = None
lot_number: Optional[str] = None
serial_number: Optional[str] = None
date_from: Optional[datetime] = None
date_to: Optional[datetime] = None
pass_fail: Optional[str] = Field(None, pattern="^(pass|warning|fail)$")
page: int = Field(1, ge=1)
per_page: int = Field(50, ge=1, le=500)
+76
View File
@@ -0,0 +1,76 @@
"""Pydantic schemas for Recipe and RecipeVersion operations."""
from datetime import datetime
from typing import Optional, TYPE_CHECKING
from pydantic import BaseModel, ConfigDict, Field
if TYPE_CHECKING:
from src.backend.models.api.task import TaskResponse
class RecipeCreate(BaseModel):
"""Schema for creating a new recipe."""
code: str = Field(..., min_length=1, max_length=100)
name: str = Field(..., min_length=1, max_length=255)
description: Optional[str] = None
image_path: Optional[str] = Field(None, max_length=500)
# Optional task-level fields for the initial technical drawing
file_path: Optional[str] = Field(None, max_length=500)
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
annotations_json: Optional[dict] = None
class RecipeUpdate(BaseModel):
"""Schema for updating a recipe (creates new version)."""
name: Optional[str] = Field(None, min_length=1, max_length=255)
description: Optional[str] = None
image_path: Optional[str] = Field(None, max_length=500)
change_notes: Optional[str] = None
# Task-level fields: saved to the first task of the new version
file_path: Optional[str] = Field(None, max_length=500)
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
annotations_json: Optional[dict] = None
class RecipeVersionResponse(BaseModel):
"""Schema for recipe version response."""
model_config = ConfigDict(from_attributes=True)
id: int
recipe_id: int
version_number: int
is_current: bool
created_by: int
created_at: datetime
change_notes: Optional[str] = None
tasks: list["TaskResponse"] = []
class RecipeResponse(BaseModel):
"""Schema for recipe response."""
model_config = ConfigDict(from_attributes=True)
id: int
code: str
name: str
description: Optional[str] = None
image_path: Optional[str] = None
created_by: int
created_at: datetime
active: bool
current_version: Optional[RecipeVersionResponse] = None
class RecipeListResponse(BaseModel):
"""Schema for paginated recipe list."""
items: list[RecipeResponse]
total: int
page: int
per_page: int
pages: int
# Forward reference imports for model_rebuild
from src.backend.models.api.task import TaskResponse # noqa: E402
RecipeVersionResponse.model_rebuild()
RecipeResponse.model_rebuild()
+57
View File
@@ -0,0 +1,57 @@
"""Pydantic schemas for Station and StationRecipeAssignment."""
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, ConfigDict, Field
class StationCreate(BaseModel):
code: str = Field(..., min_length=1, max_length=100)
name: str = Field(..., min_length=1, max_length=255)
location: Optional[str] = Field(default=None, max_length=255)
notes: Optional[str] = None
active: bool = True
class StationUpdate(BaseModel):
name: Optional[str] = Field(default=None, min_length=1, max_length=255)
location: Optional[str] = Field(default=None, max_length=255)
notes: Optional[str] = None
active: Optional[bool] = None
class StationResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
code: str
name: str
location: Optional[str]
notes: Optional[str]
active: bool
created_by: int
created_at: datetime
class StationRecipeAssignmentCreate(BaseModel):
recipe_id: int = Field(..., gt=0)
class StationRecipeAssignmentResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
station_id: int
recipe_id: int
assigned_by: int
assigned_at: datetime
class RecipeSummary(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
code: str
name: str
active: bool
class StationWithRecipesResponse(StationResponse):
recipes: list[RecipeSummary] = Field(default_factory=list)
+94
View File
@@ -0,0 +1,94 @@
"""Pydantic schemas for SPC statistics responses."""
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, Field
class StatisticsQuery(BaseModel):
"""Common query parameters for statistics endpoints."""
recipe_id: int
version_id: Optional[int] = None
subtask_id: Optional[int] = None
date_from: Optional[datetime] = None
date_to: Optional[datetime] = None
operator_id: Optional[int] = None
lot_number: Optional[str] = None
serial_number: Optional[str] = None
class ControlChartData(BaseModel):
"""Data for X-bar/R control chart."""
values: list[float]
timestamps: list[datetime]
mean: float
ucl: float # Upper Control Limit
lcl: float # Lower Control Limit
utl: Optional[float] = None
uwl: Optional[float] = None
lwl: Optional[float] = None
ltl: Optional[float] = None
nominal: Optional[float] = None
out_of_control: list[int] = [] # Indices of OOC points
class HistogramData(BaseModel):
"""Data for histogram with normal curve."""
bins: list[float]
counts: list[int]
normal_x: list[float]
normal_y: list[float]
mean: float
std_dev: float
n: int
class CapabilityData(BaseModel):
"""Capability indices."""
cp: Optional[float] = None
cpk: Optional[float] = None
pp: Optional[float] = None
ppk: Optional[float] = None
mean: float
std_dev: float
n: int
utl: Optional[float] = None
ltl: Optional[float] = None
nominal: Optional[float] = None
class TrendData(BaseModel):
"""Temporal trend data for capability indices."""
dates: list[datetime]
cpk_values: list[Optional[float]]
ppk_values: list[Optional[float]]
n_per_period: list[int]
class SummaryData(BaseModel):
"""Pass/fail/warning summary counts."""
total: int
pass_count: int
warning_count: int
fail_count: int
pass_rate: float
warning_rate: float
fail_rate: float
class AlertData(BaseModel):
"""Western Electric rules violations."""
rule: str
description: str
points: list[int] # Indices of violating points
severity: str = Field(..., pattern="^(info|warning|critical)$")
class StatisticsResponse(BaseModel):
"""Combined statistics response."""
summary: SummaryData
capability: Optional[CapabilityData] = None
control_chart: Optional[ControlChartData] = None
histogram: Optional[HistogramData] = None
trend: Optional[TrendData] = None
alerts: list[AlertData] = []
+93
View File
@@ -0,0 +1,93 @@
"""Pydantic schemas for RecipeTask and RecipeSubtask operations."""
from typing import Any, Optional
from pydantic import BaseModel, ConfigDict, Field
class SubtaskCreate(BaseModel):
"""Schema for creating a subtask."""
marker_number: int = Field(..., ge=1)
description: str = Field(..., min_length=1, max_length=500)
measurement_type: Optional[str] = Field(None, max_length=100)
nominal: Optional[float] = None
utl: Optional[float] = None
uwl: Optional[float] = None
lwl: Optional[float] = None
ltl: Optional[float] = None
unit: str = Field("mm", max_length=20)
image_path: Optional[str] = Field(None, max_length=500)
class SubtaskUpdate(BaseModel):
"""Schema for updating a subtask."""
marker_number: Optional[int] = Field(None, ge=1)
description: Optional[str] = Field(None, min_length=1, max_length=500)
measurement_type: Optional[str] = Field(None, max_length=100)
nominal: Optional[float] = None
utl: Optional[float] = None
uwl: Optional[float] = None
lwl: Optional[float] = None
ltl: Optional[float] = None
unit: Optional[str] = Field(None, max_length=20)
image_path: Optional[str] = Field(None, max_length=500)
class SubtaskResponse(BaseModel):
"""Schema for subtask response."""
model_config = ConfigDict(from_attributes=True)
id: int
task_id: int
marker_number: int
description: str
measurement_type: Optional[str] = None
nominal: Optional[float] = None
utl: Optional[float] = None
uwl: Optional[float] = None
lwl: Optional[float] = None
ltl: Optional[float] = None
unit: str
image_path: Optional[str] = None
class TaskCreate(BaseModel):
"""Schema for creating a task."""
title: str = Field(..., min_length=1, max_length=255)
directive: Optional[str] = None
description: Optional[str] = None
file_path: Optional[str] = Field(None, max_length=500)
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
annotations_json: Optional[dict[str, Any]] = None
subtasks: list[SubtaskCreate] = []
class TaskUpdate(BaseModel):
"""Schema for updating a task."""
title: Optional[str] = Field(None, min_length=1, max_length=255)
directive: Optional[str] = None
description: Optional[str] = None
file_path: Optional[str] = Field(None, max_length=500)
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
annotations_json: Optional[dict[str, Any]] = None
class TaskResponse(BaseModel):
"""Schema for task response."""
model_config = ConfigDict(from_attributes=True)
id: int
version_id: int
recipe_id: Optional[int] = None
order_index: int
title: str
directive: Optional[str] = None
description: Optional[str] = None
file_path: Optional[str] = None
file_type: Optional[str] = None
annotations_json: Optional[dict[str, Any]] = None
subtasks: list[SubtaskResponse] = []
class TaskReorderRequest(BaseModel):
"""Schema for reordering tasks."""
task_ids: list[int] = Field(..., min_length=1)
+70
View File
@@ -0,0 +1,70 @@
"""Pydantic schemas for User operations."""
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, ConfigDict, Field
class UserCreate(BaseModel):
"""Schema for creating a new user."""
username: str = Field(..., min_length=3, max_length=100)
password: str = Field(..., min_length=6, max_length=128)
email: Optional[str] = Field(None, max_length=255)
display_name: str = Field(..., min_length=1, max_length=255)
roles: list[str] = Field(default_factory=list)
is_admin: bool = False
language_pref: str = Field("it", pattern="^(it|en)$")
theme_pref: str = Field("light", pattern="^(light|dark)$")
class UserUpdate(BaseModel):
"""Schema for updating a user (admin)."""
email: Optional[str] = Field(None, max_length=255)
display_name: Optional[str] = Field(None, min_length=1, max_length=255)
roles: Optional[list[str]] = None
is_admin: Optional[bool] = None
active: Optional[bool] = None
language_pref: Optional[str] = Field(None, pattern="^(it|en)$")
theme_pref: Optional[str] = Field(None, pattern="^(light|dark)$")
class UserProfileUpdate(BaseModel):
"""Schema for user self-update (profile)."""
display_name: Optional[str] = Field(None, min_length=1, max_length=255)
language_pref: Optional[str] = Field(None, pattern="^(it|en)$")
theme_pref: Optional[str] = Field(None, pattern="^(light|dark)$")
class UserResponse(BaseModel):
"""Schema for user response."""
model_config = ConfigDict(from_attributes=True)
id: int
username: str
email: Optional[str] = None
display_name: str
roles: list[str]
is_admin: bool
language_pref: str
theme_pref: str
active: bool
created_at: datetime
last_login: Optional[datetime] = None
class UserPasswordChange(BaseModel):
"""Schema for changing user password (admin only)."""
password: str = Field(..., min_length=6, max_length=128)
class LoginRequest(BaseModel):
"""Schema for login request."""
username: str
password: str
class LoginResponse(BaseModel):
"""Schema for login response."""
user: UserResponse
api_key: str
message: str = "Login successful"
+19
View File
@@ -0,0 +1,19 @@
"""SQLAlchemy models for TieMeasureFlow."""
from src.backend.models.orm.user import User
from src.backend.models.orm.recipe import Recipe, RecipeVersion
from src.backend.models.orm.task import RecipeTask, RecipeSubtask
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
__all__ = [
"User",
"Recipe",
"RecipeVersion",
"RecipeTask",
"RecipeSubtask",
"Measurement",
"AccessLog",
"SystemSetting",
"RecipeVersionAudit",
]
+32
View File
@@ -0,0 +1,32 @@
"""Access log model for tracking user actions."""
from datetime import datetime
from typing import Optional
from sqlalchemy import BigInteger, DateTime, ForeignKey, Index, Integer, JSON, String, func
from sqlalchemy.orm import Mapped, mapped_column
from src.backend.database import Base
class AccessLog(Base):
__tablename__ = "access_logs"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
user_id: Mapped[Optional[int]] = mapped_column(
Integer, ForeignKey("users.id"), nullable=True
)
action: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
details: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
ip_address: Mapped[Optional[str]] = mapped_column(String(45), nullable=True)
user_agent: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, server_default=func.now()
)
__table_args__ = (
Index("ix_access_log_user_time", "user_id", "created_at"),
{"mysql_engine": "InnoDB", "mysql_charset": "utf8mb4"},
)
def __repr__(self) -> str:
return f"<AccessLog user={self.user_id} action={self.action}>"
+55
View File
@@ -0,0 +1,55 @@
"""Measurement model for storing individual measurements."""
from datetime import datetime
from typing import Optional
from sqlalchemy import (
BigInteger, Boolean, DECIMAL, DateTime, Enum, ForeignKey,
Integer, String, func,
)
from sqlalchemy.orm import Mapped, mapped_column
from src.backend.database import Base
class Measurement(Base):
__tablename__ = "measurements"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
subtask_id: Mapped[int] = mapped_column(
Integer, ForeignKey("recipe_subtasks.id"), nullable=False, index=True
)
version_id: Mapped[int] = mapped_column(
Integer, ForeignKey("recipe_versions.id"), nullable=False, index=True
)
measured_by: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id"), nullable=False, index=True
)
# Measurement data
value: Mapped[float] = mapped_column(DECIMAL(12, 6), nullable=False)
pass_fail: Mapped[str] = mapped_column(
Enum("pass", "warning", "fail", name="pass_fail_enum"), nullable=False
)
deviation: Mapped[Optional[float]] = mapped_column(DECIMAL(12, 6), nullable=True)
# Traceability
lot_number: Mapped[Optional[str]] = mapped_column(String(100), nullable=True, index=True)
serial_number: Mapped[Optional[str]] = mapped_column(String(100), nullable=True, index=True)
# Input method
input_method: Mapped[str] = mapped_column(
Enum("usb_caliper", "manual", name="input_method_enum"),
nullable=False,
default="manual",
)
# Timestamp
measured_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, server_default=func.now(), index=True
)
# CSV sync flag
synced_to_csv: Mapped[bool] = mapped_column(Boolean, default=False)
def __repr__(self) -> str:
return f"<Measurement subtask={self.subtask_id} value={self.value} {self.pass_fail}>"
+65
View File
@@ -0,0 +1,65 @@
"""Recipe and RecipeVersion models with immutable versioning."""
from datetime import datetime
from typing import TYPE_CHECKING, Optional
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, Text, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.backend.database import Base
if TYPE_CHECKING:
from src.backend.models.orm.task import RecipeTask
class Recipe(Base):
__tablename__ = "recipes"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
code: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
image_path: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
created_by: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, server_default=func.now()
)
active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
# Relationships
versions: Mapped[list["RecipeVersion"]] = relationship(
back_populates="recipe", cascade="all, delete-orphan", lazy="selectin"
)
def __repr__(self) -> str:
return f"<Recipe {self.code} '{self.name}'>"
class RecipeVersion(Base):
__tablename__ = "recipe_versions"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
recipe_id: Mapped[int] = mapped_column(Integer, ForeignKey("recipes.id"), nullable=False)
version_number: Mapped[int] = mapped_column(Integer, nullable=False)
is_current: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
# Audit
created_by: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, server_default=func.now()
)
change_notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
# Relationships
recipe: Mapped["Recipe"] = relationship(back_populates="versions")
tasks: Mapped[list["RecipeTask"]] = relationship(
back_populates="version", cascade="all, delete-orphan", lazy="selectin"
)
__table_args__ = (
UniqueConstraint("recipe_id", "version_number", name="uq_recipe_version"),
Index("ix_recipe_version_current", "recipe_id", "is_current"),
{"mysql_engine": "InnoDB", "mysql_charset": "utf8mb4"},
)
def __repr__(self) -> str:
return f"<RecipeVersion recipe={self.recipe_id} v{self.version_number}>"
+60
View File
@@ -0,0 +1,60 @@
"""SystemSetting and RecipeVersionAudit models."""
from datetime import datetime
from typing import Optional
from sqlalchemy import DateTime, Enum, ForeignKey, Index, Integer, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column
from src.backend.database import Base
class SystemSetting(Base):
__tablename__ = "system_settings"
setting_key: Mapped[str] = mapped_column(String(100), primary_key=True)
setting_value: Mapped[str] = mapped_column(Text, nullable=False)
setting_type: Mapped[str] = mapped_column(
Enum("string", "number", "boolean", "json", name="setting_type_enum"),
nullable=False,
default="string",
)
description: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
updated_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, server_default=func.now(), onupdate=func.now()
)
updated_by: Mapped[Optional[int]] = mapped_column(
Integer, ForeignKey("users.id"), nullable=True
)
def __repr__(self) -> str:
return f"<SystemSetting {self.setting_key}={self.setting_value}>"
class RecipeVersionAudit(Base):
__tablename__ = "recipe_version_audit"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
recipe_id: Mapped[int] = mapped_column(
Integer, ForeignKey("recipes.id"), nullable=False
)
old_version_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
new_version_id: Mapped[int] = mapped_column(Integer, nullable=False)
changed_by: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id"), nullable=False
)
change_type: Mapped[str] = mapped_column(
Enum("CREATE", "UPDATE", "ACTIVATE", "RETIRE", name="change_type_enum"),
nullable=False,
)
change_reason: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, server_default=func.now()
)
__table_args__ = (
Index("ix_recipe_audit_recipe_time", "recipe_id", "created_at"),
{"mysql_engine": "InnoDB", "mysql_charset": "utf8mb4"},
)
def __repr__(self) -> str:
return f"<RecipeVersionAudit recipe={self.recipe_id} {self.change_type}>"
+70
View File
@@ -0,0 +1,70 @@
"""Station and StationRecipeAssignment models.
A Station represents a physical control point (typically one per tablet/PC).
Recipes are assigned to stations so that each station only sees the products
it is supposed to inspect.
"""
from datetime import datetime
from typing import TYPE_CHECKING, Optional
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.backend.database import Base
if TYPE_CHECKING:
from src.backend.models.orm.recipe import Recipe
class Station(Base):
__tablename__ = "stations"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
code: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
location: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
created_by: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, server_default=func.now()
)
assignments: Mapped[list["StationRecipeAssignment"]] = relationship(
back_populates="station", cascade="all, delete-orphan", lazy="selectin"
)
__table_args__ = (
UniqueConstraint("code", name="uq_stations_code"),
{"mysql_engine": "InnoDB", "mysql_charset": "utf8mb4"},
)
def __repr__(self) -> str:
return f"<Station {self.code} '{self.name}'>"
class StationRecipeAssignment(Base):
__tablename__ = "station_recipe_assignments"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
station_id: Mapped[int] = mapped_column(
Integer, ForeignKey("stations.id", ondelete="CASCADE"), nullable=False, index=True
)
recipe_id: Mapped[int] = mapped_column(
Integer, ForeignKey("recipes.id", ondelete="CASCADE"), nullable=False, index=True
)
assigned_by: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False)
assigned_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, server_default=func.now()
)
station: Mapped["Station"] = relationship(back_populates="assignments")
recipe: Mapped["Recipe"] = relationship(lazy="selectin")
__table_args__ = (
UniqueConstraint("station_id", "recipe_id", name="uq_station_recipe"),
{"mysql_engine": "InnoDB", "mysql_charset": "utf8mb4"},
)
def __repr__(self) -> str:
return f"<StationRecipeAssignment station={self.station_id} recipe={self.recipe_id}>"
+84
View File
@@ -0,0 +1,84 @@
"""RecipeTask and RecipeSubtask models."""
from typing import TYPE_CHECKING, Optional
from sqlalchemy import (
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
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)
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)
# 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)
# 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}'>"
+48
View File
@@ -0,0 +1,48 @@
"""User model with combinable roles and admin flag."""
from datetime import datetime
from typing import Optional
from sqlalchemy import Boolean, DateTime, Enum, Integer, JSON, String, func
from sqlalchemy.orm import Mapped, mapped_column
from src.backend.database import Base
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
username: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
email: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
display_name: Mapped[str] = mapped_column(String(255), nullable=False)
# Roles: combinable JSON array of "Maker", "MeasurementTec", "Metrologist"
roles: Mapped[list] = mapped_column(JSON, nullable=False, default=list)
# Admin flag separate from roles
is_admin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
# User preferences
language_pref: Mapped[str] = mapped_column(
Enum("it", "en", name="language_enum"), nullable=False, default="it"
)
theme_pref: Mapped[str] = mapped_column(
Enum("light", "dark", name="theme_enum"), nullable=False, default="light"
)
# Status
active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
api_key: Mapped[Optional[str]] = mapped_column(String(64), unique=True, nullable=True, index=True)
# Timestamps
created_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, server_default=func.now()
)
last_login: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
def has_role(self, role: str) -> bool:
"""Check if user has a specific role."""
return role in (self.roles or [])
def __repr__(self) -> str:
return f"<User {self.username} roles={self.roles}>"