Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1a0431366f | |||
| 86df67f2e5 |
+42
-10
@@ -1,15 +1,47 @@
|
|||||||
__pycache__
|
# Build context exclusions: keep image small and rebuilds fast.
|
||||||
*.pyc
|
|
||||||
*.pyo
|
# VCS
|
||||||
.pytest_cache
|
|
||||||
.git
|
.git
|
||||||
.gitignore
|
.gitignore
|
||||||
.env
|
.gitattributes
|
||||||
*.md
|
|
||||||
node_modules
|
# Local envs and caches
|
||||||
.venv
|
.venv
|
||||||
venv
|
venv
|
||||||
*.egg-info
|
.env
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
.pytest_cache/
|
||||||
.coverage
|
.coverage
|
||||||
htmlcov
|
htmlcov/
|
||||||
.mypy_cache
|
.mypy_cache/
|
||||||
|
*.egg-info
|
||||||
|
|
||||||
|
# IDE / editor
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*~
|
||||||
|
|
||||||
|
# Node
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Local-only notes (Markdown docs are still copied if a Dockerfile asks)
|
||||||
|
PIANO_IMPLEMENTAZIONE.md
|
||||||
|
*.docx
|
||||||
|
|
||||||
|
# Legacy Dockerfiles preserved in tree but not needed in builds
|
||||||
|
src/backend/Dockerfile.legacy
|
||||||
|
src/frontend/flask_app/Dockerfile.legacy
|
||||||
|
|
||||||
|
# Uploads are a runtime volume, never baked in.
|
||||||
|
uploads/
|
||||||
|
|
||||||
|
# Claude Code
|
||||||
|
.claude/
|
||||||
|
.omc/
|
||||||
|
|
||||||
|
# Competitor analysis (local only)
|
||||||
|
Concorrente/
|
||||||
|
|||||||
+11
-11
@@ -33,18 +33,18 @@ env/
|
|||||||
Thumbs.db
|
Thumbs.db
|
||||||
desktop.ini
|
desktop.ini
|
||||||
|
|
||||||
# Uploads (server-side files)
|
# Uploads (server-side files, now at project root)
|
||||||
server/uploads/images/*
|
uploads/images/*
|
||||||
server/uploads/pdfs/*
|
uploads/pdfs/*
|
||||||
server/uploads/logos/*
|
uploads/logos/*
|
||||||
server/uploads/reports/*
|
uploads/reports/*
|
||||||
!server/uploads/images/.gitkeep
|
!uploads/images/.gitkeep
|
||||||
!server/uploads/pdfs/.gitkeep
|
!uploads/pdfs/.gitkeep
|
||||||
!server/uploads/logos/.gitkeep
|
!uploads/logos/.gitkeep
|
||||||
!server/uploads/reports/.gitkeep
|
!uploads/reports/.gitkeep
|
||||||
|
|
||||||
# TailwindCSS output
|
# TailwindCSS output
|
||||||
client/static/css/tailwind.css
|
src/frontend/flask_app/static/css/tailwind.css
|
||||||
|
|
||||||
# Node
|
# Node
|
||||||
node_modules/
|
node_modules/
|
||||||
@@ -63,7 +63,7 @@ node_modules/
|
|||||||
htmlcov/
|
htmlcov/
|
||||||
|
|
||||||
# Debug files
|
# Debug files
|
||||||
client/static/js/fabric-debug.js
|
src/frontend/flask_app/static/js/fabric-debug.js
|
||||||
|
|
||||||
# Misc
|
# Misc
|
||||||
nul
|
nul
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
3.11
|
||||||
@@ -5,10 +5,65 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
## Panoramica
|
## Panoramica
|
||||||
|
|
||||||
TieMeasureFlow by Tielogic - Sistema di gestione task per misurazioni con calibro manuale.
|
TieMeasureFlow by Tielogic - Sistema di gestione task per misurazioni con calibro manuale.
|
||||||
Monorepo con **server FastAPI** (backend API, porta 8000) e **client Flask** (frontend tablet, porta 5000), orchestrati con Docker Compose + Nginx reverse proxy + MySQL 8.0.
|
Monorepo con **backend FastAPI** (porta 8000) e **frontend Flask** (porta 5000), orchestrati con Docker Compose + Nginx reverse proxy + MySQL 8.0.
|
||||||
|
|
||||||
|
## Layout del repository (V2.0.0)
|
||||||
|
|
||||||
|
A partire dalla migrazione V2.0.0 (struttura conforme alla spec `python-project-spec-design.md`):
|
||||||
|
|
||||||
|
```
|
||||||
|
TieMeasureFlow/
|
||||||
|
├── pyproject.toml # Dipendenze monorepo (uv)
|
||||||
|
├── uv.lock # Lock file riproducibile
|
||||||
|
├── .python-version # 3.11
|
||||||
|
├── Dockerfile # Backend (uv + uvicorn)
|
||||||
|
├── Dockerfile.frontend # Frontend (uv + gunicorn + Tailwind + Babel)
|
||||||
|
├── docker-compose.dev.yml # Dev (Nginx)
|
||||||
|
├── docker-compose.yml # Prod (Traefik + SSL)
|
||||||
|
├── nginx/
|
||||||
|
├── uploads/ # Volume montato in /app/uploads
|
||||||
|
├── docs/
|
||||||
|
└── src/
|
||||||
|
├── backend/
|
||||||
|
│ ├── main.py # Entry FastAPI
|
||||||
|
│ ├── config.py
|
||||||
|
│ ├── database.py
|
||||||
|
│ ├── api/
|
||||||
|
│ │ ├── routers/ # 11 router REST
|
||||||
|
│ │ └── middleware/ # api_key, rate_limit, security_headers, logging
|
||||||
|
│ ├── models/
|
||||||
|
│ │ ├── orm/ # SQLAlchemy
|
||||||
|
│ │ └── api/ # Pydantic schemas
|
||||||
|
│ ├── services/ # Logica business
|
||||||
|
│ ├── migrations/ # Alembic
|
||||||
|
│ ├── templates/ # Setup page
|
||||||
|
│ └── tests/ # pytest
|
||||||
|
└── frontend/
|
||||||
|
└── flask_app/ # Flask + Jinja2 + Alpine.js (deroga vs spec React,
|
||||||
|
├── app.py # giustificata: tablet UX server-side, USB calipers,
|
||||||
|
├── config.py # workflow operatore con Fabric.js)
|
||||||
|
├── blueprints/
|
||||||
|
├── services/
|
||||||
|
├── templates/
|
||||||
|
├── static/
|
||||||
|
├── translations/
|
||||||
|
└── tests/
|
||||||
|
```
|
||||||
|
|
||||||
|
Dipendenze gestite con **uv** (no `requirements.txt`):
|
||||||
|
- `[project] dependencies` = core condivisi (pydantic, dotenv)
|
||||||
|
- `[project.optional-dependencies] server` = backend FastAPI
|
||||||
|
- `[project.optional-dependencies] client` = frontend Flask
|
||||||
|
- `[project.optional-dependencies] dev` = pytest, httpx, aiosqlite
|
||||||
|
|
||||||
## Comandi di Sviluppo
|
## Comandi di Sviluppo
|
||||||
|
|
||||||
|
### Setup iniziale
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
uv sync --extra server --extra client --extra dev # installa tutto
|
||||||
|
```
|
||||||
|
|
||||||
### Avvio servizi (Docker)
|
### Avvio servizi (Docker)
|
||||||
```bash
|
```bash
|
||||||
docker compose -f docker-compose.dev.yml up -d # Sviluppo (Nginx, porta 80)
|
docker compose -f docker-compose.dev.yml up -d # Sviluppo (Nginx, porta 80)
|
||||||
@@ -20,51 +75,63 @@ docker compose ps # Stato servizi
|
|||||||
|
|
||||||
### Avvio manuale (senza Docker)
|
### Avvio manuale (senza Docker)
|
||||||
```bash
|
```bash
|
||||||
# Server (terminale 1)
|
# Backend (terminale 1)
|
||||||
cd server && uvicorn main:app --reload --host 0.0.0.0 --port 8000
|
uv run uvicorn src.backend.main:app --reload --host 0.0.0.0 --port 8000
|
||||||
|
|
||||||
# Client (terminale 2)
|
# Frontend (terminale 2) — gunicorn richiede cwd interna per app:create_app()
|
||||||
cd client && flask run --host 0.0.0.0 --port 5000
|
cd src/frontend/flask_app && uv run --project ../../.. gunicorn --bind 0.0.0.0:5000 app:create_app()
|
||||||
|
|
||||||
# TailwindCSS watch (terminale 3)
|
# TailwindCSS watch (terminale 3)
|
||||||
cd client && npx tailwindcss -i static/css/input.css -o static/css/tailwind.css --watch
|
cd src/frontend/flask_app && npx tailwindcss -i static/css/input.css -o static/css/tailwind.css --watch
|
||||||
```
|
```
|
||||||
|
|
||||||
### Database & Migrations
|
### Database & Migrations
|
||||||
```bash
|
```bash
|
||||||
# alembic.ini è in server/migrations/, serve il flag -c
|
# alembic.ini in src/backend/migrations/, serve il flag -c
|
||||||
cd server && alembic -c migrations/alembic.ini upgrade head # Applica migrazioni
|
uv run alembic -c src/backend/migrations/alembic.ini upgrade head # Applica migrazioni
|
||||||
cd server && alembic -c migrations/alembic.ini revision --autogenerate -m "descrizione" # Genera migrazione
|
uv run alembic -c src/backend/migrations/alembic.ini revision --autogenerate -m "descrizione" # Genera
|
||||||
cd server && alembic -c migrations/alembic.ini downgrade -1 # Rollback ultima
|
uv run alembic -c src/backend/migrations/alembic.ini downgrade -1 # Rollback
|
||||||
docker compose exec server alembic -c migrations/alembic.ini upgrade head # Via Docker
|
docker compose exec server uv run alembic -c src/backend/migrations/alembic.ini upgrade head # Via Docker
|
||||||
```
|
```
|
||||||
Nota: `env.py` sovrascrive la URL di alembic.ini con quella da `.env` (`settings.database_url`). `script_location = %(here)s` usa path relativo.
|
Nota: `env.py` aggiunge la project root a `sys.path`, sovrascrive la URL di alembic.ini con quella da `.env` (`settings.database_url`). `script_location = %(here)s` usa path relativo.
|
||||||
|
|
||||||
### Test
|
### Test
|
||||||
```bash
|
```bash
|
||||||
# Server (usa SQLite in-memory via aiosqlite, no MySQL richiesto)
|
# Tutti i test (backend + frontend)
|
||||||
cd server && pytest # Tutti i test
|
uv run pytest
|
||||||
cd server && pytest tests/test_auth.py # Singolo modulo
|
|
||||||
cd server && pytest tests/test_auth.py::test_login_success # Singolo test
|
|
||||||
cd server && pytest --cov # Con copertura
|
|
||||||
|
|
||||||
# Client
|
# Solo backend (SQLite in-memory via aiosqlite, no MySQL richiesto)
|
||||||
cd client && pytest
|
uv run pytest src/backend/tests/
|
||||||
cd client && pytest tests/test_auth.py
|
uv run pytest src/backend/tests/test_auth.py
|
||||||
|
uv run pytest src/backend/tests/test_auth.py::test_login_success
|
||||||
|
uv run pytest --cov src/backend
|
||||||
|
|
||||||
|
# Solo frontend
|
||||||
|
uv run pytest src/frontend/flask_app/tests/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Gestione dipendenze (uv)
|
||||||
|
```bash
|
||||||
|
uv add <pacchetto> # core (entrambi)
|
||||||
|
uv add --optional server <pacchetto> # solo backend
|
||||||
|
uv add --optional client <pacchetto> # solo frontend
|
||||||
|
uv add --optional dev <pacchetto> # solo dev/test
|
||||||
|
uv sync --extra server --extra client --extra dev # reinstalla
|
||||||
|
uv lock # rigenera uv.lock
|
||||||
```
|
```
|
||||||
|
|
||||||
### i18n (Traduzioni)
|
### i18n (Traduzioni)
|
||||||
```bash
|
```bash
|
||||||
# Estrai stringhe
|
# Estrai stringhe
|
||||||
cd client && pybabel extract -F babel.cfg -k _ -o translations/messages.pot .
|
cd src/frontend/flask_app && uv run pybabel extract -F babel.cfg -k _ -o translations/messages.pot .
|
||||||
|
|
||||||
# Aggiorna catalogo
|
# Aggiorna catalogo
|
||||||
cd client && pybabel update -i translations/messages.pot -d translations
|
cd src/frontend/flask_app && uv run pybabel update -i translations/messages.pot -d translations
|
||||||
|
|
||||||
# Compila .po → .mo
|
# Compila .po → .mo
|
||||||
cd client && pybabel compile -d translations
|
cd src/frontend/flask_app && uv run pybabel compile -d translations
|
||||||
# oppure
|
# oppure
|
||||||
cd client && python compile_translations.py
|
cd src/frontend/flask_app && uv run python compile_translations.py
|
||||||
```
|
```
|
||||||
|
|
||||||
### Setup iniziale
|
### Setup iniziale
|
||||||
@@ -78,7 +145,7 @@ Browser/Tablet → Nginx (:80/443) → Flask Client (:5000) → APIClient → Fa
|
|||||||
```
|
```
|
||||||
Il client Flask è un frontend server-side che comunica col backend via REST API. Ogni richiesta dal client al server include l'header `X-API-Key` per autenticazione.
|
Il client Flask è un frontend server-side che comunica col backend via REST API. Ogni richiesta dal client al server include l'header `X-API-Key` per autenticazione.
|
||||||
|
|
||||||
### Server (FastAPI) — `server/`
|
### Server (FastAPI) — `src/backend/`
|
||||||
- **main.py**: entry point, lifespan async (`@asynccontextmanager`), registra middleware e 10 router. Health: `GET /api/health`
|
- **main.py**: entry point, lifespan async (`@asynccontextmanager`), registra middleware e 10 router. Health: `GET /api/health`
|
||||||
- **config.py**: `Settings` (pydantic_settings.BaseSettings), legge da `../.env`. Rate limits: login 5/min, general 100/min
|
- **config.py**: `Settings` (pydantic_settings.BaseSettings), legge da `../.env`. Rate limits: login 5/min, general 100/min
|
||||||
- **database.py**: SQLAlchemy 2.0 async engine con `AsyncSession`, pool 10+20 overflow, `pool_recycle=3600`, `expire_on_commit=False`
|
- **database.py**: SQLAlchemy 2.0 async engine con `AsyncSession`, pool 10+20 overflow, `pool_recycle=3600`, `expire_on_commit=False`
|
||||||
@@ -87,10 +154,10 @@ Il client Flask è un frontend server-side che comunica col backend via REST API
|
|||||||
- **middleware/**: Stack order (outermost→innermost): AccessLogMiddleware → CORSMiddleware → SecurityHeadersMiddleware → RateLimitMiddleware. Nota: `add_middleware()` in Starlette wrappa l'app, quindi l'ultimo aggiunto (AccessLog) è il più esterno. Il commento in `main.py` dice "outermost" per RateLimit ma è fuorviante. api_key.py (auth dependency `get_current_user()`), rate_limit.py (sliding window 60s per-IP, in-memory dicts), security_headers.py (CSP con `unsafe-eval` per Plotly.js, HSTS solo con SSL), logging.py (audit trail async su DB, esclude /api/health, /docs, /openapi.json, /redoc)
|
- **middleware/**: Stack order (outermost→innermost): AccessLogMiddleware → CORSMiddleware → SecurityHeadersMiddleware → RateLimitMiddleware. Nota: `add_middleware()` in Starlette wrappa l'app, quindi l'ultimo aggiunto (AccessLog) è il più esterno. Il commento in `main.py` dice "outermost" per RateLimit ma è fuorviante. api_key.py (auth dependency `get_current_user()`), rate_limit.py (sliding window 60s per-IP, in-memory dicts), security_headers.py (CSP con `unsafe-eval` per Plotly.js, HSTS solo con SSL), logging.py (audit trail async su DB, esclude /api/health, /docs, /openapi.json, /redoc)
|
||||||
- **models/**: User (include `email`, `language_pref`, `theme_pref`), Recipe (`image_path` per preview), RecipeVersion, RecipeTask, RecipeSubtask (`image_path` per immagine specifica), Measurement (`synced_to_csv`, `input_method`), AccessLog, SystemSetting, RecipeVersionAudit
|
- **models/**: User (include `email`, `language_pref`, `theme_pref`), Recipe (`image_path` per preview), RecipeVersion, RecipeTask, RecipeSubtask (`image_path` per immagine specifica), Measurement (`synced_to_csv`, `input_method`), AccessLog, SystemSetting, RecipeVersionAudit
|
||||||
- **schemas/**: Pydantic v2 per validazione I/O API
|
- **schemas/**: Pydantic v2 per validazione I/O API
|
||||||
- **migrations/**: Alembic con `alembic.ini` e `env.py` nella directory `server/migrations/`
|
- **migrations/**: Alembic con `alembic.ini` e `env.py` nella directory `src/backend/migrations/`
|
||||||
- **tests/**: pytest + pytest-asyncio, SQLite in-memory (`sqlite+aiosqlite://`, StaticPool), WeasyPrint mockato via `sys.modules`, rate limit reset tra test
|
- **tests/**: pytest + pytest-asyncio, SQLite in-memory (`sqlite+aiosqlite://`, StaticPool), WeasyPrint mockato via `sys.modules`, rate limit reset tra test
|
||||||
|
|
||||||
### Client (Flask) — `client/`
|
### Client (Flask) — `src/frontend/flask_app/`
|
||||||
- **app.py**: factory pattern `create_app()`, CSRF (`WTF_CSRF_TIME_LIMIT=3600`), Babel i18n (`default_locale="it"`)
|
- **app.py**: factory pattern `create_app()`, CSRF (`WTF_CSRF_TIME_LIMIT=3600`), Babel i18n (`default_locale="it"`)
|
||||||
- **blueprints/**: auth (login/logout/session), maker (editor ricette con Fabric.js), measure (esecuzione misurazioni), statistics (dashboard SPC con Plotly.js), admin (gestione utenti CRUD, cambio password, toggle attivo — solo `is_admin`)
|
- **blueprints/**: auth (login/logout/session), maker (editor ricette con Fabric.js), measure (esecuzione misurazioni), statistics (dashboard SPC con Plotly.js), admin (gestione utenti CRUD, cambio password, toggle attivo — solo `is_admin`)
|
||||||
- **services/api_client.py**: singleton `APIClient` — wrapper HTTP (get/post/put/delete) con gestione errori normalizzata, timeout 30s, header X-API-Key da session
|
- **services/api_client.py**: singleton `APIClient` — wrapper HTTP (get/post/put/delete) con gestione errori normalizzata, timeout 30s, header X-API-Key da session
|
||||||
@@ -99,7 +166,7 @@ Il client Flask è un frontend server-side che comunica col backend via REST API
|
|||||||
- **translations/**: Flask-Babel, cataloghi .po/.mo per IT/EN. Locale selector: `session["language"]` → Accept-Language → `"it"`
|
- **translations/**: Flask-Babel, cataloghi .po/.mo per IT/EN. Locale selector: `session["language"]` → Accept-Language → `"it"`
|
||||||
- **config.py**: `PERMANENT_SESSION_LIFETIME=28800` (8h), cookie secure in produzione, `BABEL_DEFAULT_TIMEZONE="Europe/Rome"`
|
- **config.py**: `PERMANENT_SESSION_LIFETIME=28800` (8h), cookie secure in produzione, `BABEL_DEFAULT_TIMEZONE="Europe/Rome"`
|
||||||
|
|
||||||
## Template Structure (`client/templates/base.html`)
|
## Template Structure (`src/frontend/flask_app/templates/base.html`)
|
||||||
|
|
||||||
Ordine blocchi in `base.html`:
|
Ordine blocchi in `base.html`:
|
||||||
```
|
```
|
||||||
@@ -127,7 +194,7 @@ In alternativa, usare il filtro custom `|tojson_attr` (registrato in `app.py`) c
|
|||||||
|
|
||||||
Per selettori CSS in `x-data`: usare `meta[name=csrf-token]` senza virgolette interne.
|
Per selettori CSS in `x-data`: usare `meta[name=csrf-token]` senza virgolette interne.
|
||||||
|
|
||||||
## Fabric.js Annotation Editor (`client/static/js/annotation-editor.js`)
|
## Fabric.js Annotation Editor (`src/frontend/flask_app/static/js/annotation-editor.js`)
|
||||||
|
|
||||||
Editor annotazioni su disegni tecnici (Fabric.js 5.3.1, ~1200 righe). Pattern critici:
|
Editor annotazioni su disegni tecnici (Fabric.js 5.3.1, ~1200 righe). Pattern critici:
|
||||||
|
|
||||||
@@ -152,16 +219,16 @@ Le ricette usano un versioning condizionale. L'endpoint `PUT /api/recipes/{id}`
|
|||||||
|
|
||||||
La stessa logica si applica nel task router: aggiungere un task a una ricetta con measurements crea una nuova versione.
|
La stessa logica si applica nel task router: aggiungere un task a una ricetta con measurements crea una nuova versione.
|
||||||
|
|
||||||
La versione corrente ha `is_current=True`, le precedenti `False`. Audit trail in `recipe_version_audit` (CREATE, UPDATE, ACTIVATE, RETIRE). Logica in `server/services/recipe_service.py`.
|
La versione corrente ha `is_current=True`, le precedenti `False`. Audit trail in `recipe_version_audit` (CREATE, UPDATE, ACTIVATE, RETIRE). Logica in `src/backend/services/recipe_service.py`.
|
||||||
|
|
||||||
### Calcolo Pass/Fail
|
### Calcolo Pass/Fail
|
||||||
Ogni subtask ha 4 limiti di tolleranza: UTL (upper tolerance), UWL (upper warning), LWL (lower warning), LTL (lower tolerance) più un valore nominale. Il calcolo in `server/services/measurement_service.py`:
|
Ogni subtask ha 4 limiti di tolleranza: UTL (upper tolerance), UWL (upper warning), LWL (lower warning), LTL (lower tolerance) più un valore nominale. Il calcolo in `src/backend/services/measurement_service.py`:
|
||||||
- Fuori UTL/LTL → **fail**
|
- Fuori UTL/LTL → **fail**
|
||||||
- Fuori UWL/LWL ma dentro UTL/LTL → **warning**
|
- Fuori UWL/LWL ma dentro UTL/LTL → **warning**
|
||||||
- Dentro UWL/LWL → **pass**
|
- Dentro UWL/LWL → **pass**
|
||||||
|
|
||||||
### SPC (Statistical Process Control)
|
### SPC (Statistical Process Control)
|
||||||
Calcoli in `server/services/spc_service.py` usando solo `math` e `statistics` stdlib (no numpy/scipy): summary (conteggi pass/warning/fail), capability (Cp, Cpk, Pp, Ppk), control chart (UCL/LCL = mean ± 3σ), histogram (20 bin + curva normale).
|
Calcoli in `src/backend/services/spc_service.py` usando solo `math` e `statistics` stdlib (no numpy/scipy): summary (conteggi pass/warning/fail), capability (Cp, Cpk, Pp, Ppk), control chart (UCL/LCL = mean ± 3σ), histogram (20 bin + curva normale).
|
||||||
|
|
||||||
### Autenticazione
|
### Autenticazione
|
||||||
1. Login con username/password → server ritorna `api_key` (64 char random)
|
1. Login con username/password → server ritorna `api_key` (64 char random)
|
||||||
@@ -187,7 +254,7 @@ Upload in `uploads/{recipe_id}/{version_id}/`. Tipi ammessi: JPEG, PNG, GIF, Web
|
|||||||
|
|
||||||
## Test Infrastructure
|
## Test Infrastructure
|
||||||
|
|
||||||
### Server (`server/tests/conftest.py`)
|
### Server (`src/backend/tests/conftest.py`)
|
||||||
- SQLite in-memory `sqlite+aiosqlite://` con `StaticPool` (singola connessione condivisa tra fixture, app e test)
|
- SQLite in-memory `sqlite+aiosqlite://` con `StaticPool` (singola connessione condivisa tra fixture, app e test)
|
||||||
- WeasyPrint mockato prima di qualsiasi import server: `sys.modules["weasyprint"] = MagicMock()`
|
- WeasyPrint mockato prima di qualsiasi import server: `sys.modules["weasyprint"] = MagicMock()`
|
||||||
- Rate limit buckets resettati tra test (walk middleware stack → clear dicts)
|
- Rate limit buckets resettati tra test (walk middleware stack → clear dicts)
|
||||||
@@ -196,7 +263,7 @@ Upload in `uploads/{recipe_id}/{version_id}/`. Tipi ammessi: JPEG, PNG, GIF, Web
|
|||||||
- Helper: `auth_headers(user)` → `{"X-API-Key": user.api_key}`
|
- Helper: `auth_headers(user)` → `{"X-API-Key": user.api_key}`
|
||||||
- Client httpx: `AsyncClient` con `ASGITransport(app=app)`, override di `get_db` dependency
|
- Client httpx: `AsyncClient` con `ASGITransport(app=app)`, override di `get_db` dependency
|
||||||
|
|
||||||
### Client (`client/tests/conftest.py`)
|
### Client (`src/frontend/flask_app/tests/conftest.py`)
|
||||||
- `api_client` patchato in 4 blueprint (auth, maker, measure, statistics). **admin NON è patchato** — i test admin devono gestire il mock manualmente
|
- `api_client` patchato in 4 blueprint (auth, maker, measure, statistics). **admin NON è patchato** — i test admin devono gestire il mock manualmente
|
||||||
- `logged_in_client` fixture pre-popola session con `api_key`, `user_id`, `language`, `theme` + user dict
|
- `logged_in_client` fixture pre-popola session con `api_key`, `user_id`, `language`, `theme` + user dict
|
||||||
- CSRF disabilitato nei test: `WTF_CSRF_ENABLED=False`
|
- CSRF disabilitato nei test: `WTF_CSRF_ENABLED=False`
|
||||||
@@ -232,7 +299,7 @@ Variabili d'ambiente in `.env` (copiare da `.env.example`):
|
|||||||
- DB: `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD`, `DB_ROOT_PASSWORD` (Docker)
|
- DB: `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD`, `DB_ROOT_PASSWORD` (Docker)
|
||||||
- Server: `SERVER_HOST`, `SERVER_PORT`, `SERVER_SECRET_KEY`, `SERVER_CORS_ORIGINS`
|
- Server: `SERVER_HOST`, `SERVER_PORT`, `SERVER_SECRET_KEY`, `SERVER_CORS_ORIGINS`
|
||||||
- Client: `CLIENT_HOST`, `CLIENT_PORT`, `CLIENT_SECRET_KEY`, `API_SERVER_URL`
|
- Client: `CLIENT_HOST`, `CLIENT_PORT`, `CLIENT_SECRET_KEY`, `API_SERVER_URL`
|
||||||
- Upload: `UPLOAD_DIR` (default `"uploads"`, relativo a `server/`), `MAX_UPLOAD_SIZE_MB`
|
- Upload: `UPLOAD_DIR` (default `"uploads"`, relativo a `src/backend/`), `MAX_UPLOAD_SIZE_MB`
|
||||||
- Docker: `NGINX_PORT`, `NGINX_SSL_PORT`
|
- Docker: `NGINX_PORT`, `NGINX_SSL_PORT`
|
||||||
- Setup: `SETUP_PASSWORD` (vuota = endpoint disabilitato)
|
- Setup: `SETUP_PASSWORD` (vuota = endpoint disabilitato)
|
||||||
- SSL: `SSL_CERTFILE`, `SSL_KEYFILE`
|
- SSL: `SSL_CERTFILE`, `SSL_KEYFILE`
|
||||||
|
|||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
FROM python:3.11-slim AS base
|
||||||
|
|
||||||
|
# Install uv (fast Python package manager) from official slim image.
|
||||||
|
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/
|
||||||
|
|
||||||
|
# System libs required by WeasyPrint at runtime.
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
libpango-1.0-0 \
|
||||||
|
libpangocairo-1.0-0 \
|
||||||
|
libcairo2 \
|
||||||
|
libgdk-pixbuf-2.0-0 \
|
||||||
|
libffi-dev \
|
||||||
|
shared-mime-info \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy lockfile + project metadata first to maximize Docker layer cache.
|
||||||
|
COPY pyproject.toml uv.lock ./
|
||||||
|
COPY .python-version ./
|
||||||
|
|
||||||
|
# Install ONLY backend deps from the locked dependencies. --frozen ensures
|
||||||
|
# we never resolve at build time; --no-dev keeps the image lean.
|
||||||
|
RUN uv sync --frozen --no-dev --extra server
|
||||||
|
|
||||||
|
# Now copy the actual sources.
|
||||||
|
COPY src/ ./src/
|
||||||
|
|
||||||
|
# Uploads directory (mounted as a volume in production).
|
||||||
|
RUN mkdir -p /app/uploads/images /app/uploads/pdfs /app/uploads/logos /app/uploads/reports
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# Entry point: run Alembic migrations then start Uvicorn through uv so
|
||||||
|
# it uses the pinned interpreter and venv from `uv sync`.
|
||||||
|
CMD ["sh", "-c", \
|
||||||
|
"uv run alembic -c src/backend/migrations/alembic.ini upgrade head && \
|
||||||
|
uv run uvicorn src.backend.main:app \
|
||||||
|
--host 0.0.0.0 --port 8000 --workers 4 \
|
||||||
|
--proxy-headers --forwarded-allow-ips='*'"]
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
FROM python:3.11-slim AS base
|
||||||
|
|
||||||
|
# uv from the official slim image (fast Python package manager).
|
||||||
|
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/
|
||||||
|
|
||||||
|
# Node.js 20 is needed at build time to compile TailwindCSS.
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \
|
||||||
|
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
||||||
|
&& apt-get install -y --no-install-recommends nodejs \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Resolve Python deps from the project lockfile (only the `client` extra).
|
||||||
|
COPY pyproject.toml uv.lock ./
|
||||||
|
COPY .python-version ./
|
||||||
|
RUN uv sync --frozen --no-dev --extra client
|
||||||
|
|
||||||
|
# Copy the Flask app sources.
|
||||||
|
COPY src/frontend/flask_app/ ./flask_app/
|
||||||
|
|
||||||
|
# Build TailwindCSS (one-shot; no watcher in production image).
|
||||||
|
WORKDIR /app/flask_app
|
||||||
|
RUN npm install tailwindcss@3 && \
|
||||||
|
npx tailwindcss -i static/css/input.css -o static/css/tailwind.css --minify
|
||||||
|
|
||||||
|
# Compile Flask-Babel translation catalogs.
|
||||||
|
RUN uv run --project /app pybabel compile -d translations
|
||||||
|
|
||||||
|
EXPOSE 5000
|
||||||
|
|
||||||
|
# Gunicorn behind Nginx/Traefik. Worker count and proxy trust kept in sync
|
||||||
|
# with the per-tablet rate-limiting fix (see V2.0.0 perf commit).
|
||||||
|
CMD ["uv", "run", "--project", "/app", "gunicorn", \
|
||||||
|
"--workers", "5", \
|
||||||
|
"--threads", "4", \
|
||||||
|
"--worker-class", "gthread", \
|
||||||
|
"--timeout", "60", \
|
||||||
|
"--bind", "0.0.0.0:5000", \
|
||||||
|
"--access-logfile", "-", \
|
||||||
|
"--forwarded-allow-ips", "*", \
|
||||||
|
"app:create_app()"]
|
||||||
Binary file not shown.
@@ -1,4 +0,0 @@
|
|||||||
[pytest]
|
|
||||||
testpaths = tests
|
|
||||||
python_files = test_*.py
|
|
||||||
python_functions = test_*
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
# Flask
|
|
||||||
flask>=3.0.0
|
|
||||||
flask-babel>=4.0.0
|
|
||||||
flask-wtf>=1.2.0
|
|
||||||
|
|
||||||
# HTTP Client (to call FastAPI server)
|
|
||||||
requests>=2.31.0
|
|
||||||
urllib3>=2.0.0
|
|
||||||
|
|
||||||
# Utilities
|
|
||||||
python-dotenv>=1.0.0
|
|
||||||
|
|
||||||
# Testing
|
|
||||||
pytest>=8.0.0
|
|
||||||
coverage>=7.0.0
|
|
||||||
@@ -24,7 +24,7 @@ services:
|
|||||||
|
|
||||||
server:
|
server:
|
||||||
build:
|
build:
|
||||||
context: ./server
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: tmflow-server
|
container_name: tmflow-server
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -43,8 +43,8 @@ services:
|
|||||||
|
|
||||||
client:
|
client:
|
||||||
build:
|
build:
|
||||||
context: ./client
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile.frontend
|
||||||
container_name: tmflow-client
|
container_name: tmflow-client
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file:
|
env_file:
|
||||||
|
|||||||
+3
-3
@@ -24,7 +24,7 @@ services:
|
|||||||
|
|
||||||
server:
|
server:
|
||||||
build:
|
build:
|
||||||
context: ./server
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: tmflow-server
|
container_name: tmflow-server
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -52,8 +52,8 @@ services:
|
|||||||
|
|
||||||
client:
|
client:
|
||||||
build:
|
build:
|
||||||
context: ./client
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile.frontend
|
||||||
container_name: tmflow-client
|
container_name: tmflow-client
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file:
|
env_file:
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
[project]
|
||||||
|
name = "tiemeasureflow"
|
||||||
|
version = "2.0.0"
|
||||||
|
description = "TieMeasureFlow by Tielogic — manual caliper measurement task management for industrial QA stations."
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
authors = [
|
||||||
|
{ name = "Adriano Dal Pastro", email = "adrianodalpastro@tielogic.com" },
|
||||||
|
]
|
||||||
|
|
||||||
|
# Shared core deps used by both backend and the Flask frontend.
|
||||||
|
dependencies = [
|
||||||
|
"pydantic>=2.0.0",
|
||||||
|
"pydantic-settings>=2.0.0",
|
||||||
|
"python-dotenv>=1.0.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
|
||||||
|
# Backend (FastAPI + DB + reports).
|
||||||
|
server = [
|
||||||
|
"fastapi>=0.110.0",
|
||||||
|
"uvicorn[standard]>=0.30.0",
|
||||||
|
"sqlalchemy[asyncio]>=2.0.0",
|
||||||
|
"asyncmy>=0.2.0",
|
||||||
|
"alembic>=1.13.0",
|
||||||
|
"bcrypt>=4.0.0",
|
||||||
|
"pillow>=10.0.0",
|
||||||
|
"python-multipart>=0.0.6",
|
||||||
|
"jinja2>=3.1.0",
|
||||||
|
"plotly>=5.0.0",
|
||||||
|
"kaleido>=0.2.0",
|
||||||
|
"weasyprint>=62.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Frontend (Flask tablet UI).
|
||||||
|
client = [
|
||||||
|
"flask>=3.0.0",
|
||||||
|
"flask-babel>=4.0.0",
|
||||||
|
"flask-wtf>=1.2.0",
|
||||||
|
"requests>=2.31.0",
|
||||||
|
"urllib3>=2.0.0",
|
||||||
|
"gunicorn>=21.0.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Dev / test (covers both server and client tests).
|
||||||
|
dev = [
|
||||||
|
"pytest>=8.0.0",
|
||||||
|
"pytest-asyncio>=0.23.0",
|
||||||
|
"httpx>=0.27.0",
|
||||||
|
"aiosqlite>=0.20.0",
|
||||||
|
"coverage>=7.0.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
# Backend
|
||||||
|
server = "uvicorn:run" # placeholder, real CMD lives in Dockerfile
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
# Source layout will be src/backend/ and src/frontend/flask_app/ after the
|
||||||
|
# folder restructure (Phase 2 of the V2.0.0 plan).
|
||||||
|
packages = ["src/backend", "src/frontend"]
|
||||||
|
|
||||||
|
[tool.uv]
|
||||||
|
# Pin the resolver to the deps we declared; reproducible builds.
|
||||||
|
package = false
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
asyncio_mode = "auto"
|
||||||
|
testpaths = ["src/backend/tests", "src/frontend/flask_app/tests"]
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
"""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",
|
|
||||||
]
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
[pytest]
|
|
||||||
asyncio_mode = auto
|
|
||||||
testpaths = tests
|
|
||||||
python_files = test_*.py
|
|
||||||
python_functions = test_*
|
|
||||||
@@ -23,4 +23,4 @@ RUN mkdir -p uploads/images uploads/pdfs uploads/logos uploads/reports
|
|||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
# Entry point: Alembic upgrade + Uvicorn
|
# Entry point: Alembic upgrade + Uvicorn
|
||||||
CMD ["sh", "-c", "alembic -c migrations/alembic.ini upgrade head && uvicorn main:app --host 0.0.0.0 --port 8000 --workers 2"]
|
CMD ["sh", "-c", "alembic -c migrations/alembic.ini upgrade head && uvicorn main:app --host 0.0.0.0 --port 8000 --workers 4 --proxy-headers --forwarded-allow-ips='*'"]
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
"""FastAPI middleware for TieMeasureFlow."""
|
"""FastAPI middleware for TieMeasureFlow."""
|
||||||
from middleware.api_key import (
|
from src.backend.api.middleware.api_key import (
|
||||||
get_current_user,
|
get_current_user,
|
||||||
require_role,
|
require_role,
|
||||||
require_admin,
|
require_admin,
|
||||||
@@ -8,9 +8,9 @@ from middleware.api_key import (
|
|||||||
require_metrologist,
|
require_metrologist,
|
||||||
require_admin_user,
|
require_admin_user,
|
||||||
)
|
)
|
||||||
from middleware.logging import AccessLogMiddleware
|
from src.backend.api.middleware.logging import AccessLogMiddleware
|
||||||
from middleware.rate_limit import RateLimitMiddleware
|
from src.backend.api.middleware.rate_limit import RateLimitMiddleware
|
||||||
from middleware.security_headers import SecurityHeadersMiddleware
|
from src.backend.api.middleware.security_headers import SecurityHeadersMiddleware
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"get_current_user",
|
"get_current_user",
|
||||||
@@ -3,8 +3,8 @@ from fastapi import Depends, HTTPException, Request, status
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from database import get_db
|
from src.backend.database import get_db
|
||||||
from models.user import User
|
from src.backend.models.orm.user import User
|
||||||
|
|
||||||
|
|
||||||
async def get_current_user(
|
async def get_current_user(
|
||||||
@@ -6,8 +6,8 @@ from fastapi import Request, Response
|
|||||||
from starlette.middleware.base import BaseHTTPMiddleware
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
from sqlalchemy import insert
|
from sqlalchemy import insert
|
||||||
|
|
||||||
from database import async_session_factory
|
from src.backend.database import async_session_factory
|
||||||
from models.access_log import AccessLog
|
from src.backend.models.orm.access_log import AccessLog
|
||||||
|
|
||||||
|
|
||||||
class AccessLogMiddleware(BaseHTTPMiddleware):
|
class AccessLogMiddleware(BaseHTTPMiddleware):
|
||||||
@@ -11,7 +11,7 @@ from fastapi import Request, Response
|
|||||||
from starlette.middleware.base import BaseHTTPMiddleware
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
from starlette.responses import JSONResponse
|
from starlette.responses import JSONResponse
|
||||||
|
|
||||||
from config import settings
|
from src.backend.config import settings
|
||||||
|
|
||||||
|
|
||||||
class RateLimitMiddleware(BaseHTTPMiddleware):
|
class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||||
@@ -32,6 +32,25 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
|||||||
self._general_requests: dict[str, list[float]] = defaultdict(list)
|
self._general_requests: dict[str, list[float]] = defaultdict(list)
|
||||||
self._request_count = 0 # Counter for triggering eviction
|
self._request_count = 0 # Counter for triggering eviction
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _client_ip(request: Request) -> str:
|
||||||
|
"""Resolve the originating client IP, honoring proxy headers.
|
||||||
|
|
||||||
|
Order of precedence: ``X-Forwarded-For`` (first hop), ``X-Real-IP``,
|
||||||
|
``request.client.host``. Required because Nginx and the Flask client
|
||||||
|
sit between the tablet and the API; without parsing these headers
|
||||||
|
every tablet shares one bucket.
|
||||||
|
"""
|
||||||
|
xff = request.headers.get("x-forwarded-for")
|
||||||
|
if xff:
|
||||||
|
first = xff.split(",")[0].strip()
|
||||||
|
if first:
|
||||||
|
return first
|
||||||
|
real = request.headers.get("x-real-ip")
|
||||||
|
if real:
|
||||||
|
return real.strip()
|
||||||
|
return request.client.host if request.client else "unknown"
|
||||||
|
|
||||||
def _clean_window(self, timestamps: list[float], now: float) -> list[float]:
|
def _clean_window(self, timestamps: list[float], now: float) -> list[float]:
|
||||||
"""Remove timestamps outside the current sliding window."""
|
"""Remove timestamps outside the current sliding window."""
|
||||||
cutoff = now - self.WINDOW_SECONDS
|
cutoff = now - self.WINDOW_SECONDS
|
||||||
@@ -68,7 +87,7 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
|||||||
return True, 0
|
return True, 0
|
||||||
|
|
||||||
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
||||||
client_ip = request.client.host if request.client else "unknown"
|
client_ip = self._client_ip(request)
|
||||||
now = time.time()
|
now = time.time()
|
||||||
path = request.url.path
|
path = request.url.path
|
||||||
|
|
||||||
+1
-1
@@ -8,7 +8,7 @@ from typing import Callable
|
|||||||
from fastapi import Request, Response
|
from fastapi import Request, Response
|
||||||
from starlette.middleware.base import BaseHTTPMiddleware
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
|
||||||
from config import settings
|
from src.backend.config import settings
|
||||||
|
|
||||||
# Content Security Policy - allows CDN resources used by the client
|
# Content Security Policy - allows CDN resources used by the client
|
||||||
# Note: 'unsafe-eval' required for Plotly.js runtime evaluation in SPC charts
|
# Note: 'unsafe-eval' required for Plotly.js runtime evaluation in SPC charts
|
||||||
@@ -2,16 +2,16 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from database import get_db
|
from src.backend.database import get_db
|
||||||
from middleware.api_key import get_current_user
|
from src.backend.api.middleware.api_key import get_current_user
|
||||||
from models.user import User
|
from src.backend.models.orm.user import User
|
||||||
from schemas.user import (
|
from src.backend.models.api.user import (
|
||||||
LoginRequest,
|
LoginRequest,
|
||||||
LoginResponse,
|
LoginResponse,
|
||||||
UserProfileUpdate,
|
UserProfileUpdate,
|
||||||
UserResponse,
|
UserResponse,
|
||||||
)
|
)
|
||||||
from services.auth_service import authenticate_user, login_user, logout_user
|
from src.backend.services.auth_service import authenticate_user, login_user, logout_user
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||||
|
|
||||||
@@ -7,9 +7,9 @@ from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
|
|||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
from config import settings
|
from src.backend.config import settings
|
||||||
from middleware.api_key import get_current_user, require_maker
|
from src.backend.api.middleware.api_key import get_current_user, require_maker
|
||||||
from models.user import User
|
from src.backend.models.orm.user import User
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/files", tags=["files"])
|
router = APIRouter(prefix="/api/files", tags=["files"])
|
||||||
|
|
||||||
@@ -8,23 +8,23 @@ from fastapi.responses import StreamingResponse
|
|||||||
from sqlalchemy import and_, func, select
|
from sqlalchemy import and_, func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from database import get_db
|
from src.backend.database import get_db
|
||||||
from middleware.api_key import (
|
from src.backend.api.middleware.api_key import (
|
||||||
get_current_user,
|
get_current_user,
|
||||||
require_measurement_tec,
|
require_measurement_tec,
|
||||||
require_metrologist,
|
require_metrologist,
|
||||||
)
|
)
|
||||||
from models.measurement import Measurement
|
from src.backend.models.orm.measurement import Measurement
|
||||||
from models.recipe import RecipeVersion
|
from src.backend.models.orm.recipe import RecipeVersion
|
||||||
from models.setting import SystemSetting
|
from src.backend.models.orm.setting import SystemSetting
|
||||||
from models.user import User
|
from src.backend.models.orm.user import User
|
||||||
from schemas.measurement import (
|
from src.backend.models.api.measurement import (
|
||||||
MeasurementBatchCreate,
|
MeasurementBatchCreate,
|
||||||
MeasurementCreate,
|
MeasurementCreate,
|
||||||
MeasurementListResponse,
|
MeasurementListResponse,
|
||||||
MeasurementResponse,
|
MeasurementResponse,
|
||||||
)
|
)
|
||||||
from services.measurement_service import save_measurement
|
from src.backend.services.measurement_service import save_measurement
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/measurements", tags=["measurements"])
|
router = APIRouter(prefix="/api/measurements", tags=["measurements"])
|
||||||
|
|
||||||
@@ -2,17 +2,17 @@
|
|||||||
from fastapi import APIRouter, Depends, Query
|
from fastapi import APIRouter, Depends, Query
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from database import get_db
|
from src.backend.database import get_db
|
||||||
from middleware.api_key import get_current_user, require_maker, require_measurement_tec
|
from src.backend.api.middleware.api_key import get_current_user, require_maker, require_measurement_tec
|
||||||
from models.user import User
|
from src.backend.models.orm.user import User
|
||||||
from schemas.recipe import (
|
from src.backend.models.api.recipe import (
|
||||||
RecipeCreate,
|
RecipeCreate,
|
||||||
RecipeListResponse,
|
RecipeListResponse,
|
||||||
RecipeResponse,
|
RecipeResponse,
|
||||||
RecipeUpdate,
|
RecipeUpdate,
|
||||||
RecipeVersionResponse,
|
RecipeVersionResponse,
|
||||||
)
|
)
|
||||||
from services import recipe_service
|
from src.backend.services import recipe_service
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/recipes", tags=["recipes"])
|
router = APIRouter(prefix="/api/recipes", tags=["recipes"])
|
||||||
|
|
||||||
@@ -5,10 +5,10 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
|
|||||||
from fastapi.responses import Response
|
from fastapi.responses import Response
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from database import get_db
|
from src.backend.database import get_db
|
||||||
from middleware.api_key import require_metrologist
|
from src.backend.api.middleware.api_key import require_metrologist
|
||||||
from models.user import User
|
from src.backend.models.orm.user import User
|
||||||
from services.report_service import generate_measurement_report, generate_spc_report
|
from src.backend.services.report_service import generate_measurement_report, generate_spc_report
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/reports", tags=["reports"])
|
router = APIRouter(prefix="/api/reports", tags=["reports"])
|
||||||
|
|
||||||
@@ -5,11 +5,11 @@ from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from config import settings
|
from src.backend.config import settings
|
||||||
from database import get_db
|
from src.backend.database import get_db
|
||||||
from middleware.api_key import get_current_user, require_admin_user
|
from src.backend.api.middleware.api_key import get_current_user, require_admin_user
|
||||||
from models.setting import SystemSetting
|
from src.backend.models.orm.setting import SystemSetting
|
||||||
from models.user import User
|
from src.backend.models.orm.user import User
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/settings", tags=["settings"])
|
router = APIRouter(prefix="/api/settings", tags=["settings"])
|
||||||
|
|
||||||
@@ -15,14 +15,14 @@ from pydantic import BaseModel
|
|||||||
from sqlalchemy import inspect as sa_inspect, select
|
from sqlalchemy import inspect as sa_inspect, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from config import settings
|
from src.backend.config import settings
|
||||||
from database import Base, engine, async_session_factory
|
from src.backend.database import Base, engine, async_session_factory
|
||||||
from models import (
|
from src.backend.models.orm import (
|
||||||
User, Recipe, RecipeVersion, RecipeTask, RecipeSubtask, Measurement,
|
User, Recipe, RecipeVersion, RecipeTask, RecipeSubtask, Measurement,
|
||||||
)
|
)
|
||||||
from models.station import Station, StationRecipeAssignment
|
from src.backend.models.orm.station import Station, StationRecipeAssignment
|
||||||
from services.auth_service import hash_password
|
from src.backend.services.auth_service import hash_password
|
||||||
from services.measurement_service import calculate_pass_fail
|
from src.backend.services.measurement_service import calculate_pass_fail
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/setup", tags=["setup"])
|
router = APIRouter(prefix="/api/setup", tags=["setup"])
|
||||||
|
|
||||||
@@ -2,10 +2,10 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from database import get_db
|
from src.backend.database import get_db
|
||||||
from middleware.api_key import get_current_user, require_admin_user
|
from src.backend.api.middleware.api_key import get_current_user, require_admin_user
|
||||||
from models.user import User
|
from src.backend.models.orm.user import User
|
||||||
from schemas.station import (
|
from src.backend.models.api.station import (
|
||||||
StationCreate,
|
StationCreate,
|
||||||
StationUpdate,
|
StationUpdate,
|
||||||
StationResponse,
|
StationResponse,
|
||||||
@@ -13,7 +13,7 @@ from schemas.station import (
|
|||||||
StationRecipeAssignmentResponse,
|
StationRecipeAssignmentResponse,
|
||||||
RecipeSummary,
|
RecipeSummary,
|
||||||
)
|
)
|
||||||
from services import station_service
|
from src.backend.services import station_service
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/stations", tags=["stations"])
|
router = APIRouter(prefix="/api/stations", tags=["stations"])
|
||||||
|
|
||||||
@@ -5,19 +5,19 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
|
|||||||
from sqlalchemy import and_, select
|
from sqlalchemy import and_, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from database import get_db
|
from src.backend.database import get_db
|
||||||
from middleware.api_key import require_metrologist
|
from src.backend.api.middleware.api_key import require_metrologist
|
||||||
from models.measurement import Measurement
|
from src.backend.models.orm.measurement import Measurement
|
||||||
from models.recipe import RecipeVersion
|
from src.backend.models.orm.recipe import RecipeVersion
|
||||||
from models.task import RecipeSubtask, RecipeTask
|
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
||||||
from models.user import User
|
from src.backend.models.orm.user import User
|
||||||
from schemas.statistics import (
|
from src.backend.models.api.statistics import (
|
||||||
CapabilityData,
|
CapabilityData,
|
||||||
ControlChartData,
|
ControlChartData,
|
||||||
HistogramData,
|
HistogramData,
|
||||||
SummaryData,
|
SummaryData,
|
||||||
)
|
)
|
||||||
from services.spc_service import (
|
from src.backend.services.spc_service import (
|
||||||
compute_capability,
|
compute_capability,
|
||||||
compute_control_chart,
|
compute_control_chart,
|
||||||
compute_histogram,
|
compute_histogram,
|
||||||
@@ -4,12 +4,12 @@ from sqlalchemy import select
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from database import get_db
|
from src.backend.database import get_db
|
||||||
from middleware.api_key import require_maker, get_current_user
|
from src.backend.api.middleware.api_key import require_maker, get_current_user
|
||||||
from models.recipe import Recipe, RecipeVersion
|
from src.backend.models.orm.recipe import Recipe, RecipeVersion
|
||||||
from models.task import RecipeSubtask, RecipeTask
|
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
||||||
from models.user import User
|
from src.backend.models.orm.user import User
|
||||||
from schemas.task import (
|
from src.backend.models.api.task import (
|
||||||
SubtaskCreate,
|
SubtaskCreate,
|
||||||
SubtaskResponse,
|
SubtaskResponse,
|
||||||
SubtaskUpdate,
|
SubtaskUpdate,
|
||||||
@@ -18,7 +18,7 @@ from schemas.task import (
|
|||||||
TaskResponse,
|
TaskResponse,
|
||||||
TaskUpdate,
|
TaskUpdate,
|
||||||
)
|
)
|
||||||
from services import recipe_service
|
from src.backend.services import recipe_service
|
||||||
|
|
||||||
router = APIRouter(tags=["tasks"])
|
router = APIRouter(tags=["tasks"])
|
||||||
|
|
||||||
@@ -159,7 +159,7 @@ async def create_task(
|
|||||||
|
|
||||||
if has_measurements:
|
if has_measurements:
|
||||||
# Copy-on-write: create new version preserving measurement data
|
# Copy-on-write: create new version preserving measurement data
|
||||||
from schemas.recipe import RecipeUpdate
|
from src.backend.models.api.recipe import RecipeUpdate
|
||||||
new_version = await recipe_service.create_new_version(
|
new_version = await recipe_service.create_new_version(
|
||||||
db, recipe_id, RecipeUpdate(change_notes=f"Added task: {data.title}"), user
|
db, recipe_id, RecipeUpdate(change_notes=f"Added task: {data.title}"), user
|
||||||
)
|
)
|
||||||
@@ -3,11 +3,11 @@ from fastapi import APIRouter, Depends, HTTPException, status
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from database import get_db
|
from src.backend.database import get_db
|
||||||
from middleware.api_key import require_admin_user
|
from src.backend.api.middleware.api_key import require_admin_user
|
||||||
from models.user import User
|
from src.backend.models.orm.user import User
|
||||||
from schemas.user import UserCreate, UserPasswordChange, UserResponse, UserUpdate
|
from src.backend.models.api.user import UserCreate, UserPasswordChange, UserResponse, UserUpdate
|
||||||
from services.auth_service import create_user, hash_password, regenerate_api_key
|
from src.backend.services.auth_service import create_user, hash_password, regenerate_api_key
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/users", tags=["users"])
|
router = APIRouter(prefix="/api/users", tags=["users"])
|
||||||
|
|
||||||
@@ -23,9 +23,9 @@ class Settings(BaseSettings):
|
|||||||
upload_dir: str = "uploads"
|
upload_dir: str = "uploads"
|
||||||
max_upload_size_mb: int = 50
|
max_upload_size_mb: int = 50
|
||||||
|
|
||||||
# Rate Limiting (requests per minute)
|
# Rate Limiting (requests per minute, per real client IP)
|
||||||
rate_limit_login: int = 5
|
rate_limit_login: int = 5
|
||||||
rate_limit_general: int = 100
|
rate_limit_general: int = 300
|
||||||
|
|
||||||
# SSL (Production)
|
# SSL (Production)
|
||||||
ssl_certfile: str | None = None
|
ssl_certfile: str | None = None
|
||||||
@@ -49,10 +49,16 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def upload_path(self) -> Path:
|
def upload_path(self) -> Path:
|
||||||
"""Absolute path to upload directory."""
|
"""Absolute path to upload directory.
|
||||||
return Path(__file__).parent / self.upload_dir
|
|
||||||
|
|
||||||
model_config = {"env_file": "../.env", "env_file_encoding": "utf-8", "extra": "ignore"}
|
After the V2.0.0 restructure, uploads live at the project root
|
||||||
|
(mounted as a Docker volume), not inside the backend tree.
|
||||||
|
"""
|
||||||
|
# Path(__file__) = src/backend/config.py → parents[2] = project root
|
||||||
|
return Path(__file__).resolve().parents[2] / self.upload_dir
|
||||||
|
|
||||||
|
# ../../.env reaches the project root from src/backend/.
|
||||||
|
model_config = {"env_file": "../../.env", "env_file_encoding": "utf-8", "extra": "ignore"}
|
||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
@@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import (
|
|||||||
)
|
)
|
||||||
from sqlalchemy.orm import DeclarativeBase
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
from config import settings
|
from src.backend.config import settings
|
||||||
|
|
||||||
# Create async engine
|
# Create async engine
|
||||||
engine = create_async_engine(
|
engine = create_async_engine(
|
||||||
@@ -5,22 +5,22 @@ from collections.abc import AsyncGenerator
|
|||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from config import settings
|
from src.backend.config import settings
|
||||||
from database import init_db
|
from src.backend.database import init_db
|
||||||
from middleware.logging import AccessLogMiddleware
|
from src.backend.api.middleware.logging import AccessLogMiddleware
|
||||||
from middleware.rate_limit import RateLimitMiddleware
|
from src.backend.api.middleware.rate_limit import RateLimitMiddleware
|
||||||
from middleware.security_headers import SecurityHeadersMiddleware
|
from src.backend.api.middleware.security_headers import SecurityHeadersMiddleware
|
||||||
from routers.auth import router as auth_router
|
from src.backend.api.routers.auth import router as auth_router
|
||||||
from routers.users import router as users_router
|
from src.backend.api.routers.users import router as users_router
|
||||||
from routers.recipes import router as recipes_router
|
from src.backend.api.routers.recipes import router as recipes_router
|
||||||
from routers.tasks import router as tasks_router
|
from src.backend.api.routers.tasks import router as tasks_router
|
||||||
from routers.measurements import router as measurements_router
|
from src.backend.api.routers.measurements import router as measurements_router
|
||||||
from routers.files import router as files_router
|
from src.backend.api.routers.files import router as files_router
|
||||||
from routers.settings import router as settings_router
|
from src.backend.api.routers.settings import router as settings_router
|
||||||
from routers.reports import router as reports_router
|
from src.backend.api.routers.reports import router as reports_router
|
||||||
from routers.statistics import router as statistics_router
|
from src.backend.api.routers.statistics import router as statistics_router
|
||||||
from routers.setup import router as setup_router
|
from src.backend.api.routers.setup import router as setup_router
|
||||||
from routers.stations import router as stations_router
|
from src.backend.api.routers.stations import router as stations_router
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
@@ -17,20 +17,25 @@ if config.config_file_name is not None:
|
|||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
# Add the project root (4 levels up from this file) to sys.path so that
|
||||||
from config import settings
|
# `src.backend.*` imports resolve when alembic is invoked from anywhere.
|
||||||
from database import Base
|
_PROJECT_ROOT = Path(__file__).resolve().parents[3]
|
||||||
|
if str(_PROJECT_ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(_PROJECT_ROOT))
|
||||||
|
|
||||||
|
from src.backend.config import settings
|
||||||
|
from src.backend.database import Base
|
||||||
|
|
||||||
# Override alembic.ini URL with .env settings (keep in sync)
|
# Override alembic.ini URL with .env settings (keep in sync)
|
||||||
config.set_main_option("sqlalchemy.url", settings.database_url)
|
config.set_main_option("sqlalchemy.url", settings.database_url)
|
||||||
|
|
||||||
# Import all models so they register with Base.metadata
|
# Import all models so they register with Base.metadata
|
||||||
from models.user import User # noqa: F401
|
from src.backend.models.orm.user import User # noqa: F401
|
||||||
from models.recipe import Recipe, RecipeVersion # noqa: F401
|
from src.backend.models.orm.recipe import Recipe, RecipeVersion # noqa: F401
|
||||||
from models.task import RecipeTask, RecipeSubtask # noqa: F401
|
from src.backend.models.orm.task import RecipeTask, RecipeSubtask # noqa: F401
|
||||||
from models.measurement import Measurement # noqa: F401
|
from src.backend.models.orm.measurement import Measurement # noqa: F401
|
||||||
from models.access_log import AccessLog # noqa: F401
|
from src.backend.models.orm.access_log import AccessLog # noqa: F401
|
||||||
from models.setting import SystemSetting, RecipeVersionAudit # noqa: F401
|
from src.backend.models.orm.setting import SystemSetting, RecipeVersionAudit # noqa: F401
|
||||||
|
|
||||||
target_metadata = Base.metadata
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
@@ -1,19 +1,19 @@
|
|||||||
"""Pydantic schemas for TieMeasureFlow API."""
|
"""Pydantic schemas for TieMeasureFlow API."""
|
||||||
from schemas.measurement import (
|
from src.backend.models.api.measurement import (
|
||||||
MeasurementBatchCreate,
|
MeasurementBatchCreate,
|
||||||
MeasurementCreate,
|
MeasurementCreate,
|
||||||
MeasurementListResponse,
|
MeasurementListResponse,
|
||||||
MeasurementQuery,
|
MeasurementQuery,
|
||||||
MeasurementResponse,
|
MeasurementResponse,
|
||||||
)
|
)
|
||||||
from schemas.recipe import (
|
from src.backend.models.api.recipe import (
|
||||||
RecipeCreate,
|
RecipeCreate,
|
||||||
RecipeListResponse,
|
RecipeListResponse,
|
||||||
RecipeResponse,
|
RecipeResponse,
|
||||||
RecipeUpdate,
|
RecipeUpdate,
|
||||||
RecipeVersionResponse,
|
RecipeVersionResponse,
|
||||||
)
|
)
|
||||||
from schemas.statistics import (
|
from src.backend.models.api.statistics import (
|
||||||
AlertData,
|
AlertData,
|
||||||
CapabilityData,
|
CapabilityData,
|
||||||
ControlChartData,
|
ControlChartData,
|
||||||
@@ -23,7 +23,7 @@ from schemas.statistics import (
|
|||||||
SummaryData,
|
SummaryData,
|
||||||
TrendData,
|
TrendData,
|
||||||
)
|
)
|
||||||
from schemas.task import (
|
from src.backend.models.api.task import (
|
||||||
SubtaskCreate,
|
SubtaskCreate,
|
||||||
SubtaskResponse,
|
SubtaskResponse,
|
||||||
SubtaskUpdate,
|
SubtaskUpdate,
|
||||||
@@ -32,7 +32,7 @@ from schemas.task import (
|
|||||||
TaskResponse,
|
TaskResponse,
|
||||||
TaskUpdate,
|
TaskUpdate,
|
||||||
)
|
)
|
||||||
from schemas.user import (
|
from src.backend.models.api.user import (
|
||||||
LoginRequest,
|
LoginRequest,
|
||||||
LoginResponse,
|
LoginResponse,
|
||||||
UserCreate,
|
UserCreate,
|
||||||
@@ -5,7 +5,7 @@ from typing import Optional, TYPE_CHECKING
|
|||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from schemas.task import TaskResponse
|
from src.backend.models.api.task import TaskResponse
|
||||||
|
|
||||||
|
|
||||||
class RecipeCreate(BaseModel):
|
class RecipeCreate(BaseModel):
|
||||||
@@ -71,6 +71,6 @@ class RecipeListResponse(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
# Forward reference imports for model_rebuild
|
# Forward reference imports for model_rebuild
|
||||||
from schemas.task import TaskResponse # noqa: E402
|
from src.backend.models.api.task import TaskResponse # noqa: E402
|
||||||
RecipeVersionResponse.model_rebuild()
|
RecipeVersionResponse.model_rebuild()
|
||||||
RecipeResponse.model_rebuild()
|
RecipeResponse.model_rebuild()
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -5,7 +5,7 @@ from typing import Optional
|
|||||||
from sqlalchemy import BigInteger, DateTime, ForeignKey, Index, Integer, JSON, String, func
|
from sqlalchemy import BigInteger, DateTime, ForeignKey, Index, Integer, JSON, String, func
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from database import Base
|
from src.backend.database import Base
|
||||||
|
|
||||||
|
|
||||||
class AccessLog(Base):
|
class AccessLog(Base):
|
||||||
@@ -8,7 +8,7 @@ from sqlalchemy import (
|
|||||||
)
|
)
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from database import Base
|
from src.backend.database import Base
|
||||||
|
|
||||||
|
|
||||||
class Measurement(Base):
|
class Measurement(Base):
|
||||||
@@ -5,10 +5,10 @@ from typing import TYPE_CHECKING, Optional
|
|||||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, Text, UniqueConstraint, func
|
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, Text, UniqueConstraint, func
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from database import Base
|
from src.backend.database import Base
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from models.task import RecipeTask
|
from src.backend.models.orm.task import RecipeTask
|
||||||
|
|
||||||
|
|
||||||
class Recipe(Base):
|
class Recipe(Base):
|
||||||
@@ -5,7 +5,7 @@ from typing import Optional
|
|||||||
from sqlalchemy import DateTime, Enum, ForeignKey, Index, Integer, String, Text, func
|
from sqlalchemy import DateTime, Enum, ForeignKey, Index, Integer, String, Text, func
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from database import Base
|
from src.backend.database import Base
|
||||||
|
|
||||||
|
|
||||||
class SystemSetting(Base):
|
class SystemSetting(Base):
|
||||||
@@ -10,10 +10,10 @@ from typing import TYPE_CHECKING, Optional
|
|||||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func
|
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from database import Base
|
from src.backend.database import Base
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from models.recipe import Recipe
|
from src.backend.models.orm.recipe import Recipe
|
||||||
|
|
||||||
|
|
||||||
class Station(Base):
|
class Station(Base):
|
||||||
@@ -6,10 +6,10 @@ from sqlalchemy import (
|
|||||||
)
|
)
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from database import Base
|
from src.backend.database import Base
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from models.recipe import RecipeVersion
|
from src.backend.models.orm.recipe import RecipeVersion
|
||||||
|
|
||||||
|
|
||||||
class RecipeTask(Base):
|
class RecipeTask(Base):
|
||||||
@@ -5,7 +5,7 @@ from typing import Optional
|
|||||||
from sqlalchemy import Boolean, DateTime, Enum, Integer, JSON, String, func
|
from sqlalchemy import Boolean, DateTime, Enum, Integer, JSON, String, func
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from database import Base
|
from src.backend.database import Base
|
||||||
|
|
||||||
|
|
||||||
class User(Base):
|
class User(Base):
|
||||||
@@ -6,7 +6,7 @@ import bcrypt
|
|||||||
from sqlalchemy import select, update
|
from sqlalchemy import select, update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from models.user import User
|
from src.backend.models.orm.user import User
|
||||||
|
|
||||||
|
|
||||||
def hash_password(password: str) -> str:
|
def hash_password(password: str) -> str:
|
||||||
+2
-2
@@ -4,8 +4,8 @@ from decimal import Decimal
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from models.measurement import Measurement
|
from src.backend.models.orm.measurement import Measurement
|
||||||
from models.task import RecipeSubtask
|
from src.backend.models.orm.task import RecipeSubtask
|
||||||
|
|
||||||
|
|
||||||
def calculate_pass_fail(
|
def calculate_pass_fail(
|
||||||
@@ -7,12 +7,12 @@ from sqlalchemy import func, select, update
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from models.measurement import Measurement
|
from src.backend.models.orm.measurement import Measurement
|
||||||
from models.recipe import Recipe, RecipeVersion
|
from src.backend.models.orm.recipe import Recipe, RecipeVersion
|
||||||
from models.setting import RecipeVersionAudit
|
from src.backend.models.orm.setting import RecipeVersionAudit
|
||||||
from models.task import RecipeSubtask, RecipeTask
|
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
||||||
from models.user import User
|
from src.backend.models.orm.user import User
|
||||||
from schemas.recipe import RecipeCreate, RecipeUpdate
|
from src.backend.models.api.recipe import RecipeCreate, RecipeUpdate
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -10,13 +10,13 @@ from sqlalchemy import and_, select
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from weasyprint import HTML
|
from weasyprint import HTML
|
||||||
|
|
||||||
from config import settings
|
from src.backend.config import settings
|
||||||
from models.measurement import Measurement
|
from src.backend.models.orm.measurement import Measurement
|
||||||
from models.recipe import Recipe, RecipeVersion
|
from src.backend.models.orm.recipe import Recipe, RecipeVersion
|
||||||
from models.setting import SystemSetting
|
from src.backend.models.orm.setting import SystemSetting
|
||||||
from models.task import RecipeSubtask, RecipeTask
|
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
||||||
from models.user import User
|
from src.backend.models.orm.user import User
|
||||||
from services.spc_service import (
|
from src.backend.services.spc_service import (
|
||||||
compute_capability,
|
compute_capability,
|
||||||
compute_control_chart,
|
compute_control_chart,
|
||||||
compute_histogram,
|
compute_histogram,
|
||||||
@@ -9,7 +9,7 @@ import math
|
|||||||
import statistics as stats
|
import statistics as stats
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from schemas.statistics import (
|
from src.backend.models.api.statistics import (
|
||||||
CapabilityData,
|
CapabilityData,
|
||||||
ControlChartData,
|
ControlChartData,
|
||||||
HistogramData,
|
HistogramData,
|
||||||
@@ -10,10 +10,10 @@ from fastapi import HTTPException, status
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from models.recipe import Recipe
|
from src.backend.models.orm.recipe import Recipe
|
||||||
from models.station import Station, StationRecipeAssignment
|
from src.backend.models.orm.station import Station, StationRecipeAssignment
|
||||||
from models.user import User
|
from src.backend.models.orm.user import User
|
||||||
from schemas.station import StationCreate, StationUpdate
|
from src.backend.models.api.station import StationCreate, StationUpdate
|
||||||
|
|
||||||
|
|
||||||
async def create_station(
|
async def create_station(
|
||||||
@@ -30,17 +30,17 @@ if "weasyprint" not in sys.modules:
|
|||||||
# Ensure the server package is importable
|
# Ensure the server package is importable
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
from database import Base, get_db
|
from src.backend.database import Base, get_db
|
||||||
from main import app
|
from src.backend.main import app
|
||||||
from middleware.rate_limit import RateLimitMiddleware
|
from src.backend.api.middleware.rate_limit import RateLimitMiddleware
|
||||||
from models.user import User
|
from src.backend.models.orm.user import User
|
||||||
from models.recipe import Recipe, RecipeVersion
|
from src.backend.models.orm.recipe import Recipe, RecipeVersion
|
||||||
from models.task import RecipeTask, RecipeSubtask
|
from src.backend.models.orm.task import RecipeTask, RecipeSubtask
|
||||||
from models.measurement import Measurement
|
from src.backend.models.orm.measurement import Measurement
|
||||||
from models.access_log import AccessLog
|
from src.backend.models.orm.access_log import AccessLog
|
||||||
from models.setting import SystemSetting, RecipeVersionAudit
|
from src.backend.models.orm.setting import SystemSetting, RecipeVersionAudit
|
||||||
from models.station import Station, StationRecipeAssignment
|
from src.backend.models.orm.station import Station, StationRecipeAssignment
|
||||||
from services.auth_service import hash_password, generate_api_key
|
from src.backend.services.auth_service import hash_password, generate_api_key
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# In-memory SQLite engine for tests
|
# In-memory SQLite engine for tests
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
import pytest
|
import pytest
|
||||||
from httpx import AsyncClient
|
from httpx import AsyncClient
|
||||||
|
|
||||||
from tests.conftest import _create_user, auth_headers
|
from src.backend.tests.conftest import _create_user, auth_headers
|
||||||
|
|
||||||
|
|
||||||
class TestLogin:
|
class TestLogin:
|
||||||
@@ -8,8 +8,8 @@ from unittest.mock import patch
|
|||||||
import pytest
|
import pytest
|
||||||
from httpx import AsyncClient
|
from httpx import AsyncClient
|
||||||
|
|
||||||
from models.user import User
|
from src.backend.models.orm.user import User
|
||||||
from tests.conftest import auth_headers
|
from src.backend.tests.conftest import auth_headers
|
||||||
|
|
||||||
|
|
||||||
class TestUploadFile:
|
class TestUploadFile:
|
||||||
@@ -45,7 +45,7 @@ class TestUploadFile:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Patch settings.upload_path to use tmp_path
|
# Patch settings.upload_path to use tmp_path
|
||||||
with patch("routers.files.settings") as mock_settings:
|
with patch("src.backend.api.routers.files.settings") as mock_settings:
|
||||||
mock_settings.upload_path = tmp_path
|
mock_settings.upload_path = tmp_path
|
||||||
mock_settings.max_upload_size_mb = 50
|
mock_settings.max_upload_size_mb = 50
|
||||||
|
|
||||||
@@ -82,7 +82,7 @@ class TestUploadFile:
|
|||||||
# Create a large file content
|
# Create a large file content
|
||||||
large_content = b"\x00" * (51 * 1024 * 1024) # 51MB
|
large_content = b"\x00" * (51 * 1024 * 1024) # 51MB
|
||||||
|
|
||||||
with patch("routers.files.settings") as mock_settings:
|
with patch("src.backend.api.routers.files.settings") as mock_settings:
|
||||||
mock_settings.max_upload_size_mb = 50
|
mock_settings.max_upload_size_mb = 50
|
||||||
mock_settings.upload_path = Path(tempfile.mkdtemp())
|
mock_settings.upload_path = Path(tempfile.mkdtemp())
|
||||||
|
|
||||||
@@ -109,7 +109,7 @@ class TestGetFile:
|
|||||||
test_file = tmp_path / "testfile.txt"
|
test_file = tmp_path / "testfile.txt"
|
||||||
test_file.write_text("hello test")
|
test_file.write_text("hello test")
|
||||||
|
|
||||||
with patch("routers.files.settings") as mock_settings:
|
with patch("src.backend.api.routers.files.settings") as mock_settings:
|
||||||
mock_settings.upload_path = tmp_path
|
mock_settings.upload_path = tmp_path
|
||||||
|
|
||||||
resp = await client.get(
|
resp = await client.get(
|
||||||
@@ -123,7 +123,7 @@ class TestGetFile:
|
|||||||
self, client: AsyncClient, maker_user: User, tmp_path: Path
|
self, client: AsyncClient, maker_user: User, tmp_path: Path
|
||||||
):
|
):
|
||||||
"""Requesting a non-existent file returns 404."""
|
"""Requesting a non-existent file returns 404."""
|
||||||
with patch("routers.files.settings") as mock_settings:
|
with patch("src.backend.api.routers.files.settings") as mock_settings:
|
||||||
mock_settings.upload_path = tmp_path
|
mock_settings.upload_path = tmp_path
|
||||||
|
|
||||||
resp = await client.get(
|
resp = await client.get(
|
||||||
@@ -144,7 +144,7 @@ class TestDeleteFile:
|
|||||||
test_file = tmp_path / "deleteme.txt"
|
test_file = tmp_path / "deleteme.txt"
|
||||||
test_file.write_text("delete me")
|
test_file.write_text("delete me")
|
||||||
|
|
||||||
with patch("routers.files.settings") as mock_settings:
|
with patch("src.backend.api.routers.files.settings") as mock_settings:
|
||||||
mock_settings.upload_path = tmp_path
|
mock_settings.upload_path = tmp_path
|
||||||
|
|
||||||
resp = await client.delete(
|
resp = await client.delete(
|
||||||
@@ -163,7 +163,7 @@ class TestFilenameSanitization:
|
|||||||
self, client: AsyncClient, maker_user: User, tmp_path: Path
|
self, client: AsyncClient, maker_user: User, tmp_path: Path
|
||||||
):
|
):
|
||||||
"""Path traversal attempt returns 403 or 404."""
|
"""Path traversal attempt returns 403 or 404."""
|
||||||
with patch("routers.files.settings") as mock_settings:
|
with patch("src.backend.api.routers.files.settings") as mock_settings:
|
||||||
mock_settings.upload_path = tmp_path
|
mock_settings.upload_path = tmp_path
|
||||||
|
|
||||||
resp = await client.get(
|
resp = await client.get(
|
||||||
@@ -3,10 +3,10 @@ import pytest
|
|||||||
from httpx import AsyncClient
|
from httpx import AsyncClient
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from models.user import User
|
from src.backend.models.orm.user import User
|
||||||
from models.recipe import RecipeVersion
|
from src.backend.models.orm.recipe import RecipeVersion
|
||||||
from models.task import RecipeTask, RecipeSubtask
|
from src.backend.models.orm.task import RecipeTask, RecipeSubtask
|
||||||
from tests.conftest import auth_headers, create_test_recipe
|
from src.backend.tests.conftest import auth_headers, create_test_recipe
|
||||||
|
|
||||||
|
|
||||||
async def _get_subtask_and_version(
|
async def _get_subtask_and_version(
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""Tests for RateLimitMiddleware honoring proxy headers (X-Forwarded-For)."""
|
||||||
|
from src.backend.api.middleware.rate_limit import RateLimitMiddleware
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeRequest:
|
||||||
|
"""Minimal stand-in for starlette.requests.Request."""
|
||||||
|
|
||||||
|
def __init__(self, headers: dict[str, str], host: str | None = "10.0.0.1"):
|
||||||
|
self.headers = headers
|
||||||
|
|
||||||
|
class _Client:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if host is None:
|
||||||
|
self.client = None
|
||||||
|
else:
|
||||||
|
self.client = _Client()
|
||||||
|
self.client.host = host
|
||||||
|
|
||||||
|
|
||||||
|
def test_client_ip_uses_x_forwarded_for_first_hop():
|
||||||
|
req = _FakeRequest(
|
||||||
|
headers={"x-forwarded-for": "203.0.113.5, 10.0.0.2"},
|
||||||
|
host="10.0.0.1",
|
||||||
|
)
|
||||||
|
assert RateLimitMiddleware._client_ip(req) == "203.0.113.5"
|
||||||
|
|
||||||
|
|
||||||
|
def test_client_ip_strips_whitespace():
|
||||||
|
req = _FakeRequest(headers={"x-forwarded-for": " 198.51.100.7 "})
|
||||||
|
assert RateLimitMiddleware._client_ip(req) == "198.51.100.7"
|
||||||
|
|
||||||
|
|
||||||
|
def test_client_ip_falls_back_to_x_real_ip():
|
||||||
|
req = _FakeRequest(headers={"x-real-ip": "203.0.113.99"}, host="10.0.0.1")
|
||||||
|
assert RateLimitMiddleware._client_ip(req) == "203.0.113.99"
|
||||||
|
|
||||||
|
|
||||||
|
def test_client_ip_falls_back_to_request_client_host():
|
||||||
|
req = _FakeRequest(headers={}, host="172.18.0.5")
|
||||||
|
assert RateLimitMiddleware._client_ip(req) == "172.18.0.5"
|
||||||
|
|
||||||
|
|
||||||
|
def test_client_ip_returns_unknown_without_client():
|
||||||
|
req = _FakeRequest(headers={}, host=None)
|
||||||
|
assert RateLimitMiddleware._client_ip(req) == "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def test_x_forwarded_for_overrides_x_real_ip():
|
||||||
|
req = _FakeRequest(
|
||||||
|
headers={
|
||||||
|
"x-forwarded-for": "203.0.113.5",
|
||||||
|
"x-real-ip": "10.0.0.2",
|
||||||
|
},
|
||||||
|
host="10.0.0.1",
|
||||||
|
)
|
||||||
|
assert RateLimitMiddleware._client_ip(req) == "203.0.113.5"
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_x_forwarded_for_falls_through():
|
||||||
|
req = _FakeRequest(
|
||||||
|
headers={"x-forwarded-for": "", "x-real-ip": "203.0.113.42"},
|
||||||
|
host="10.0.0.1",
|
||||||
|
)
|
||||||
|
assert RateLimitMiddleware._client_ip(req) == "203.0.113.42"
|
||||||
@@ -3,8 +3,8 @@ import pytest
|
|||||||
from httpx import AsyncClient
|
from httpx import AsyncClient
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from models.user import User
|
from src.backend.models.orm.user import User
|
||||||
from tests.conftest import auth_headers, create_test_recipe
|
from src.backend.tests.conftest import auth_headers, create_test_recipe
|
||||||
|
|
||||||
|
|
||||||
class TestListRecipes:
|
class TestListRecipes:
|
||||||
@@ -9,8 +9,8 @@ import pytest
|
|||||||
from httpx import AsyncClient
|
from httpx import AsyncClient
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from models.user import User
|
from src.backend.models.orm.user import User
|
||||||
from tests.conftest import auth_headers, create_test_recipe
|
from src.backend.tests.conftest import auth_headers, create_test_recipe
|
||||||
|
|
||||||
|
|
||||||
class TestMeasurementReport:
|
class TestMeasurementReport:
|
||||||
@@ -29,7 +29,7 @@ class TestMeasurementReport:
|
|||||||
|
|
||||||
fake_pdf = b"%PDF-1.4 fake content"
|
fake_pdf = b"%PDF-1.4 fake content"
|
||||||
with patch(
|
with patch(
|
||||||
"routers.reports.generate_measurement_report",
|
"src.backend.api.routers.reports.generate_measurement_report",
|
||||||
new_callable=AsyncMock,
|
new_callable=AsyncMock,
|
||||||
return_value=fake_pdf,
|
return_value=fake_pdf,
|
||||||
):
|
):
|
||||||
@@ -75,7 +75,7 @@ class TestSPCReport:
|
|||||||
|
|
||||||
fake_pdf = b"%PDF-1.4 spc report content"
|
fake_pdf = b"%PDF-1.4 spc report content"
|
||||||
with patch(
|
with patch(
|
||||||
"routers.reports.generate_spc_report",
|
"src.backend.api.routers.reports.generate_spc_report",
|
||||||
new_callable=AsyncMock,
|
new_callable=AsyncMock,
|
||||||
return_value=fake_pdf,
|
return_value=fake_pdf,
|
||||||
):
|
):
|
||||||
@@ -9,7 +9,7 @@ from unittest.mock import patch
|
|||||||
import pytest
|
import pytest
|
||||||
from httpx import AsyncClient
|
from httpx import AsyncClient
|
||||||
|
|
||||||
from tests.conftest import _create_user, auth_headers
|
from src.backend.tests.conftest import _create_user, auth_headers
|
||||||
|
|
||||||
|
|
||||||
class TestCORSHeaders:
|
class TestCORSHeaders:
|
||||||
@@ -126,7 +126,7 @@ class TestFilenameSanitization:
|
|||||||
|
|
||||||
async def test_dotfile_rejected(self, client: AsyncClient, db_session):
|
async def test_dotfile_rejected(self, client: AsyncClient, db_session):
|
||||||
"""Filenames starting with '.' are rejected."""
|
"""Filenames starting with '.' are rejected."""
|
||||||
from routers.files import sanitize_filename
|
from src.backend.api.routers.files import sanitize_filename
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
||||||
with pytest.raises(HTTPException) as exc_info:
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
@@ -136,7 +136,7 @@ class TestFilenameSanitization:
|
|||||||
|
|
||||||
async def test_empty_filename_rejected(self, client: AsyncClient, db_session):
|
async def test_empty_filename_rejected(self, client: AsyncClient, db_session):
|
||||||
"""Empty filenames are rejected."""
|
"""Empty filenames are rejected."""
|
||||||
from routers.files import sanitize_filename
|
from src.backend.api.routers.files import sanitize_filename
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
||||||
with pytest.raises(HTTPException) as exc_info:
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
@@ -3,9 +3,9 @@ import pytest
|
|||||||
from httpx import AsyncClient
|
from httpx import AsyncClient
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from models.setting import SystemSetting
|
from src.backend.models.orm.setting import SystemSetting
|
||||||
from models.user import User
|
from src.backend.models.orm.user import User
|
||||||
from tests.conftest import auth_headers
|
from src.backend.tests.conftest import auth_headers
|
||||||
|
|
||||||
|
|
||||||
class TestGetSettings:
|
class TestGetSettings:
|
||||||
@@ -2,8 +2,8 @@
|
|||||||
import pytest
|
import pytest
|
||||||
from httpx import AsyncClient
|
from httpx import AsyncClient
|
||||||
|
|
||||||
from config import settings
|
from src.backend.config import settings
|
||||||
from tests.conftest import test_engine, TestSessionFactory
|
from src.backend.tests.conftest import test_engine, TestSessionFactory
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -20,7 +20,7 @@ def enable_setup(monkeypatch):
|
|||||||
# setup.py imports engine and async_session_factory directly from database
|
# setup.py imports engine and async_session_factory directly from database
|
||||||
# and uses them instead of get_db dependency injection. Patch them to use
|
# and uses them instead of get_db dependency injection. Patch them to use
|
||||||
# the test in-memory SQLite engine/session instead of real MySQL.
|
# the test in-memory SQLite engine/session instead of real MySQL.
|
||||||
import routers.setup as setup_mod
|
import src.backend.api.routers.setup as setup_mod
|
||||||
monkeypatch.setattr(setup_mod, "engine", test_engine)
|
monkeypatch.setattr(setup_mod, "engine", test_engine)
|
||||||
monkeypatch.setattr(setup_mod, "async_session_factory", TestSessionFactory)
|
monkeypatch.setattr(setup_mod, "async_session_factory", TestSessionFactory)
|
||||||
|
|
||||||
@@ -52,7 +52,7 @@ async def test_seed_measurements_have_correct_distribution(client: AsyncClient,
|
|||||||
await _seed(client)
|
await _seed(client)
|
||||||
|
|
||||||
from sqlalchemy import select, func
|
from sqlalchemy import select, func
|
||||||
from models.measurement import Measurement
|
from src.backend.models.orm.measurement import Measurement
|
||||||
|
|
||||||
result = await db_session.execute(
|
result = await db_session.execute(
|
||||||
select(Measurement.pass_fail, func.count()).group_by(Measurement.pass_fail)
|
select(Measurement.pass_fail, func.count()).group_by(Measurement.pass_fail)
|
||||||
@@ -74,7 +74,7 @@ async def test_seed_measurements_have_lot_and_serial(client: AsyncClient, db_ses
|
|||||||
await _seed(client)
|
await _seed(client)
|
||||||
|
|
||||||
from sqlalchemy import select, func
|
from sqlalchemy import select, func
|
||||||
from models.measurement import Measurement
|
from src.backend.models.orm.measurement import Measurement
|
||||||
|
|
||||||
result = await db_session.execute(
|
result = await db_session.execute(
|
||||||
select(func.count()).where(Measurement.lot_number.is_(None))
|
select(func.count()).where(Measurement.lot_number.is_(None))
|
||||||
@@ -89,7 +89,7 @@ async def test_seed_measurements_input_methods(client: AsyncClient, db_session):
|
|||||||
await _seed(client)
|
await _seed(client)
|
||||||
|
|
||||||
from sqlalchemy import select, func
|
from sqlalchemy import select, func
|
||||||
from models.measurement import Measurement
|
from src.backend.models.orm.measurement import Measurement
|
||||||
|
|
||||||
result = await db_session.execute(
|
result = await db_session.execute(
|
||||||
select(Measurement.input_method, func.count()).group_by(Measurement.input_method)
|
select(Measurement.input_method, func.count()).group_by(Measurement.input_method)
|
||||||
@@ -4,8 +4,8 @@ from sqlalchemy import select
|
|||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from models.station import Station, StationRecipeAssignment
|
from src.backend.models.orm.station import Station, StationRecipeAssignment
|
||||||
from tests.conftest import _create_user, create_test_recipe
|
from src.backend.tests.conftest import _create_user, create_test_recipe
|
||||||
|
|
||||||
|
|
||||||
async def test_create_station(db_session: AsyncSession):
|
async def test_create_station(db_session: AsyncSession):
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
import pytest
|
import pytest
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
|
|
||||||
from schemas.station import (
|
from src.backend.models.api.station import (
|
||||||
StationCreate, StationUpdate, StationResponse,
|
StationCreate, StationUpdate, StationResponse,
|
||||||
StationRecipeAssignmentCreate, StationRecipeAssignmentResponse,
|
StationRecipeAssignmentCreate, StationRecipeAssignmentResponse,
|
||||||
StationWithRecipesResponse,
|
StationWithRecipesResponse,
|
||||||
@@ -18,10 +18,10 @@ from httpx import AsyncClient
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from config import settings
|
from src.backend.config import settings
|
||||||
from models.station import Station, StationRecipeAssignment
|
from src.backend.models.orm.station import Station, StationRecipeAssignment
|
||||||
from models.recipe import Recipe
|
from src.backend.models.orm.recipe import Recipe
|
||||||
from tests.conftest import test_engine, TestSessionFactory
|
from src.backend.tests.conftest import test_engine, TestSessionFactory
|
||||||
|
|
||||||
|
|
||||||
SETUP_PWD = "test-setup-pwd"
|
SETUP_PWD = "test-setup-pwd"
|
||||||
@@ -31,7 +31,7 @@ SETUP_PWD = "test-setup-pwd"
|
|||||||
def enable_setup(monkeypatch):
|
def enable_setup(monkeypatch):
|
||||||
"""Enable setup endpoints and redirect engine/session to test SQLite DB."""
|
"""Enable setup endpoints and redirect engine/session to test SQLite DB."""
|
||||||
monkeypatch.setattr(settings, "setup_password", SETUP_PWD)
|
monkeypatch.setattr(settings, "setup_password", SETUP_PWD)
|
||||||
import routers.setup as setup_mod
|
import src.backend.api.routers.setup as setup_mod
|
||||||
monkeypatch.setattr(setup_mod, "engine", test_engine)
|
monkeypatch.setattr(setup_mod, "engine", test_engine)
|
||||||
monkeypatch.setattr(setup_mod, "async_session_factory", TestSessionFactory)
|
monkeypatch.setattr(setup_mod, "async_session_factory", TestSessionFactory)
|
||||||
|
|
||||||
@@ -3,14 +3,14 @@ import pytest
|
|||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from models.station import Station
|
from src.backend.models.orm.station import Station
|
||||||
from schemas.station import StationCreate, StationUpdate
|
from src.backend.models.api.station import StationCreate, StationUpdate
|
||||||
from services.station_service import (
|
from src.backend.services.station_service import (
|
||||||
create_station, update_station, delete_station,
|
create_station, update_station, delete_station,
|
||||||
assign_recipe, unassign_recipe, list_station_recipes,
|
assign_recipe, unassign_recipe, list_station_recipes,
|
||||||
get_station_by_code,
|
get_station_by_code,
|
||||||
)
|
)
|
||||||
from tests.conftest import _create_user, create_test_recipe
|
from src.backend.tests.conftest import _create_user, create_test_recipe
|
||||||
|
|
||||||
|
|
||||||
async def test_create_station_ok(db_session: AsyncSession):
|
async def test_create_station_ok(db_session: AsyncSession):
|
||||||
@@ -102,7 +102,7 @@ async def test_list_recipes_only_returns_active(db_session: AsyncSession):
|
|||||||
|
|
||||||
async def test_delete_station_cascades_assignments(db_session: AsyncSession):
|
async def test_delete_station_cascades_assignments(db_session: AsyncSession):
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from models.station import StationRecipeAssignment
|
from src.backend.models.orm.station import StationRecipeAssignment
|
||||||
admin = await _create_user(db_session, username="a9", is_admin=True)
|
admin = await _create_user(db_session, username="a9", is_admin=True)
|
||||||
station = await create_station(db_session, StationCreate(code="ST-DEL", name="D"), admin)
|
station = await create_station(db_session, StationCreate(code="ST-DEL", name="D"), admin)
|
||||||
r = await create_test_recipe(db_session, user_id=admin.id, code="REC-DEL")
|
r = await create_test_recipe(db_session, user_id=admin.id, code="REC-DEL")
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
import pytest
|
import pytest
|
||||||
from httpx import AsyncClient
|
from httpx import AsyncClient
|
||||||
|
|
||||||
from tests.conftest import auth_headers, create_test_recipe
|
from src.backend.tests.conftest import auth_headers, create_test_recipe
|
||||||
|
|
||||||
|
|
||||||
async def test_list_stations_requires_auth(client: AsyncClient):
|
async def test_list_stations_requires_auth(client: AsyncClient):
|
||||||
@@ -3,9 +3,9 @@ import pytest
|
|||||||
from httpx import AsyncClient
|
from httpx import AsyncClient
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from models.user import User
|
from src.backend.models.orm.user import User
|
||||||
from models.measurement import Measurement
|
from src.backend.models.orm.measurement import Measurement
|
||||||
from tests.conftest import auth_headers, create_test_recipe
|
from src.backend.tests.conftest import auth_headers, create_test_recipe
|
||||||
|
|
||||||
|
|
||||||
async def _seed_measurements(
|
async def _seed_measurements(
|
||||||
@@ -3,8 +3,8 @@ import pytest
|
|||||||
from httpx import AsyncClient
|
from httpx import AsyncClient
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from models.user import User
|
from src.backend.models.orm.user import User
|
||||||
from tests.conftest import auth_headers, create_test_recipe
|
from src.backend.tests.conftest import auth_headers, create_test_recipe
|
||||||
|
|
||||||
|
|
||||||
class TestListTasks:
|
class TestListTasks:
|
||||||
@@ -3,8 +3,8 @@ import pytest
|
|||||||
from httpx import AsyncClient
|
from httpx import AsyncClient
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from models.user import User
|
from src.backend.models.orm.user import User
|
||||||
from tests.conftest import _create_user, auth_headers
|
from src.backend.tests.conftest import _create_user, auth_headers
|
||||||
|
|
||||||
|
|
||||||
class TestListUsers:
|
class TestListUsers:
|
||||||
@@ -22,4 +22,12 @@ RUN pybabel compile -d translations
|
|||||||
|
|
||||||
EXPOSE 5000
|
EXPOSE 5000
|
||||||
|
|
||||||
CMD ["gunicorn", "--workers", "2", "--bind", "0.0.0.0:5000", "app:create_app()"]
|
CMD ["gunicorn", \
|
||||||
|
"--workers", "5", \
|
||||||
|
"--threads", "4", \
|
||||||
|
"--worker-class", "gthread", \
|
||||||
|
"--timeout", "60", \
|
||||||
|
"--bind", "0.0.0.0:5000", \
|
||||||
|
"--access-logfile", "-", \
|
||||||
|
"--forwarded-allow-ips", "*", \
|
||||||
|
"app:create_app()"]
|
||||||
@@ -6,6 +6,7 @@ from flask import Flask, redirect, url_for, session, request
|
|||||||
from flask_babel import Babel
|
from flask_babel import Babel
|
||||||
from flask_wtf.csrf import CSRFProtect
|
from flask_wtf.csrf import CSRFProtect
|
||||||
from markupsafe import Markup
|
from markupsafe import Markup
|
||||||
|
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||||
|
|
||||||
from config import Config
|
from config import Config
|
||||||
|
|
||||||
@@ -26,6 +27,11 @@ def create_app() -> Flask:
|
|||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
app.config.from_object(Config)
|
app.config.from_object(Config)
|
||||||
|
|
||||||
|
# Trust one reverse-proxy hop (Nginx in dev, Traefik in prod) so that
|
||||||
|
# request.remote_addr returns the real tablet IP rather than the proxy IP.
|
||||||
|
# The APIClient forwards that IP to FastAPI for accurate rate limiting.
|
||||||
|
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)
|
||||||
|
|
||||||
# Initialize CSRF protection
|
# Initialize CSRF protection
|
||||||
csrf = CSRFProtect(app)
|
csrf = CSRFProtect(app)
|
||||||
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user