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
+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}>"