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
+155
View File
@@ -0,0 +1,155 @@
"""Business logic for stations and recipe assignments.
Routers must call into these functions rather than manipulating models directly.
All functions are async and accept an AsyncSession; they flush but do NOT commit
(commit is handled by the FastAPI get_db dependency).
"""
from typing import Optional
from fastapi import HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.models.orm.recipe import Recipe
from src.backend.models.orm.station import Station, StationRecipeAssignment
from src.backend.models.orm.user import User
from src.backend.models.api.station import StationCreate, StationUpdate
async def create_station(
db: AsyncSession, data: StationCreate, creator: User,
) -> Station:
existing = await db.execute(select(Station).where(Station.code == data.code))
if existing.scalar_one_or_none() is not None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Station code '{data.code}' already exists",
)
station = Station(
code=data.code,
name=data.name,
location=data.location,
notes=data.notes,
active=data.active,
created_by=creator.id,
)
db.add(station)
await db.flush()
await db.refresh(station)
return station
async def get_station(db: AsyncSession, station_id: int) -> Station:
result = await db.execute(select(Station).where(Station.id == station_id))
station = result.scalar_one_or_none()
if station is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Station not found",
)
return station
async def get_station_by_code(db: AsyncSession, code: str) -> Optional[Station]:
result = await db.execute(select(Station).where(Station.code == code))
return result.scalar_one_or_none()
async def list_stations(db: AsyncSession, active_only: bool = False) -> list[Station]:
query = select(Station).order_by(Station.code)
if active_only:
query = query.where(Station.active == True) # noqa: E712
result = await db.execute(query)
return list(result.scalars().all())
async def update_station(
db: AsyncSession, station_id: int, data: StationUpdate,
) -> Station:
station = await get_station(db, station_id)
for field, value in data.model_dump(exclude_unset=True).items():
setattr(station, field, value)
await db.flush()
await db.refresh(station)
return station
async def delete_station(db: AsyncSession, station_id: int) -> None:
station = await get_station(db, station_id)
# Explicitly delete assignments so the ORM cascade fires within the
# current session (SQLite test DB does not enforce FK CASCADE without
# PRAGMA foreign_keys = ON; production MySQL handles it at DB level too,
# but explicit ORM deletion is engine-agnostic and safer).
assignments = await db.execute(
select(StationRecipeAssignment).where(
StationRecipeAssignment.station_id == station_id
)
)
for assignment in assignments.scalars().all():
await db.delete(assignment)
await db.delete(station)
await db.flush()
async def assign_recipe(
db: AsyncSession, station_id: int, recipe_id: int, assigner: User,
) -> StationRecipeAssignment:
await get_station(db, station_id)
recipe_row = await db.execute(select(Recipe).where(Recipe.id == recipe_id))
if recipe_row.scalar_one_or_none() is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Recipe not found",
)
existing = await db.execute(
select(StationRecipeAssignment).where(
StationRecipeAssignment.station_id == station_id,
StationRecipeAssignment.recipe_id == recipe_id,
)
)
if existing.scalar_one_or_none() is not None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Recipe already assigned to this station",
)
assignment = StationRecipeAssignment(
station_id=station_id, recipe_id=recipe_id, assigned_by=assigner.id,
)
db.add(assignment)
await db.flush()
await db.refresh(assignment)
return assignment
async def unassign_recipe(
db: AsyncSession, station_id: int, recipe_id: int,
) -> None:
result = await db.execute(
select(StationRecipeAssignment).where(
StationRecipeAssignment.station_id == station_id,
StationRecipeAssignment.recipe_id == recipe_id,
)
)
assignment = result.scalar_one_or_none()
if assignment is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Assignment not found",
)
await db.delete(assignment)
await db.flush()
async def list_station_recipes(
db: AsyncSession, station_id: int,
) -> list[Recipe]:
"""Return active recipes assigned to this station, ordered by code."""
await get_station(db, station_id)
result = await db.execute(
select(Recipe)
.join(StationRecipeAssignment, StationRecipeAssignment.recipe_id == Recipe.id)
.where(
StationRecipeAssignment.station_id == station_id,
Recipe.active == True, # noqa: E712
)
.order_by(Recipe.code)
)
return list(result.scalars().all())