feat: FASE 1 - Backend Core (modelli, auth, API)

Implementazione completa del backend FastAPI:
- Modelli SQLAlchemy: User, Recipe, RecipeVersion, RecipeTask,
  RecipeSubtask, Measurement, AccessLog, SystemSetting, RecipeVersionAudit
- Schemas Pydantic v2 per tutti i CRUD + statistiche SPC
- Middleware: API Key auth (X-API-Key) con role checking + access logging
- Router: auth, users, recipes, tasks, measurements, files, settings
- Services: auth (bcrypt+secrets), recipe (copy-on-write versioning),
  measurement (auto pass/fail con UTL/UWL/LWL/LTL)
- Alembic env.py con import modelli attivi
- Fix architect review: no double-commit, recipe_id subquery filter,
  user_id in access logs, type annotations corrette

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Adriano
2026-02-07 00:40:50 +01:00
parent 76be6f5ac4
commit d6508e0ae8
28 changed files with 2942 additions and 7 deletions
+19
View File
@@ -0,0 +1,19 @@
"""SQLAlchemy models for TieMeasureFlow."""
from models.user import User
from models.recipe import Recipe, RecipeVersion
from models.task import RecipeTask, RecipeSubtask
from models.measurement import Measurement
from models.access_log import AccessLog
from models.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 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 database import Base
class Measurement(Base):
__tablename__ = "measurements"
id: Mapped[int] = mapped_column(BigInteger, 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}>"
+64
View File
@@ -0,0 +1,64 @@
"""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 database import Base
if TYPE_CHECKING:
from models.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)
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 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}>"
+76
View File
@@ -0,0 +1,76 @@
"""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 database import Base
if TYPE_CHECKING:
from models.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"},
)
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")
# 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 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}>"