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
@@ -0,0 +1,70 @@
"""Tests that APIClient forwards the real tablet IP via X-Forwarded-For."""
from unittest.mock import patch
from services.api_client import api_client
def _last_call_headers(mock):
"""Return the headers kwarg from the most recent requests.* call."""
return mock.call_args.kwargs["headers"]
def test_get_forwards_real_client_ip(flask_app):
with flask_app.test_request_context(
"/", environ_base={"REMOTE_ADDR": "203.0.113.10"}
):
with patch("services.api_client.requests.get") as mock_get:
mock_get.return_value.ok = True
mock_get.return_value.status_code = 200
mock_get.return_value.json.return_value = {}
api_client.get("/api/health")
headers = _last_call_headers(mock_get)
assert headers["X-Forwarded-For"] == "203.0.113.10"
assert headers["X-Real-IP"] == "203.0.113.10"
def test_post_json_forwards_real_client_ip(flask_app):
with flask_app.test_request_context(
"/", environ_base={"REMOTE_ADDR": "198.51.100.42"}
):
with patch("services.api_client.requests.post") as mock_post:
mock_post.return_value.ok = True
mock_post.return_value.status_code = 200
mock_post.return_value.json.return_value = {}
api_client.post("/api/foo", data={"x": 1})
headers = _last_call_headers(mock_post)
assert headers["X-Forwarded-For"] == "198.51.100.42"
def test_post_with_files_forwards_real_client_ip(flask_app):
with flask_app.test_request_context(
"/", environ_base={"REMOTE_ADDR": "198.51.100.77"}
):
with patch("services.api_client.requests.post") as mock_post:
mock_post.return_value.ok = True
mock_post.return_value.status_code = 200
mock_post.return_value.json.return_value = {}
api_client.post(
"/api/files/upload", data={}, files={"file": ("x.txt", b"data")}
)
headers = _last_call_headers(mock_post)
assert headers["X-Forwarded-For"] == "198.51.100.77"
assert headers["X-Real-IP"] == "198.51.100.77"
def test_real_client_ip_returns_none_outside_request_context(flask_app):
"""Background workers without a Flask request must NOT inject a fake IP."""
from services.api_client import APIClient
with flask_app.app_context():
assert APIClient._real_client_ip() is None
def test_proxy_fix_is_installed(flask_app):
"""ProxyFix must wrap wsgi_app so REMOTE_ADDR is rewritten in production."""
from werkzeug.middleware.proxy_fix import ProxyFix
assert isinstance(flask_app.wsgi_app, ProxyFix)