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
+97
View File
@@ -0,0 +1,97 @@
"""Authentication service - password hashing, API key management."""
import secrets
from datetime import datetime
import bcrypt
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.models.orm.user import User
def hash_password(password: str) -> str:
"""Hash a password using bcrypt."""
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Verify a password against its hash."""
return bcrypt.checkpw(
plain_password.encode("utf-8"), hashed_password.encode("utf-8")
)
def generate_api_key() -> str:
"""Generate a secure 64-character API key."""
return secrets.token_urlsafe(48) # 64 chars base64
async def authenticate_user(
db: AsyncSession, username: str, password: str
) -> User | None:
"""Authenticate user by username and password."""
result = await db.execute(
select(User).where(User.username == username, User.active == True)
)
user = result.scalar_one_or_none()
if user is None or not verify_password(password, user.password_hash):
return None
return user
async def login_user(db: AsyncSession, user: User) -> str:
"""Generate API key and update last_login for user."""
api_key = generate_api_key()
await db.execute(
update(User)
.where(User.id == user.id)
.values(api_key=api_key, last_login=datetime.utcnow())
)
await db.flush()
return api_key
async def logout_user(db: AsyncSession, user: User) -> None:
"""Invalidate user's API key."""
await db.execute(
update(User).where(User.id == user.id).values(api_key=None)
)
await db.flush()
async def create_user(
db: AsyncSession,
username: str,
password: str,
display_name: str,
email: str | None = None,
roles: list[str] | None = None,
is_admin: bool = False,
language_pref: str = "it",
theme_pref: str = "light",
) -> User:
"""Create a new user with hashed password."""
user = User(
username=username,
password_hash=hash_password(password),
display_name=display_name,
email=email,
roles=roles or [],
is_admin=is_admin,
language_pref=language_pref,
theme_pref=theme_pref,
)
db.add(user)
await db.flush()
await db.refresh(user)
return user
async def regenerate_api_key(db: AsyncSession, user_id: int) -> str:
"""Regenerate API key for a user."""
new_key = generate_api_key()
await db.execute(
update(User).where(User.id == user_id).values(api_key=new_key)
)
await db.flush()
return new_key