Compare commits
31 Commits
5459a43e2b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 563127c75a | |||
| 58fcf4a722 | |||
| 5ae1e28ef2 | |||
| 2242a12094 | |||
| 4cfe8ad673 | |||
| 3e8cf935bd | |||
| 7a3c40a651 | |||
| 14349a211d | |||
| 6cedbb1bcb | |||
| 97d497ba4f | |||
| b69c5f00aa | |||
| abbb99fe74 | |||
| 1a4444e1e8 | |||
| 579049395d | |||
| 265f4c35cb | |||
| 531ae4cf0b | |||
| 71a2b37c16 | |||
| 56a6eb7be5 | |||
| 1620a35d71 | |||
| fbb1b39b57 | |||
| 341d0c6f38 | |||
| f1d6de0e47 | |||
| 5cc9f8e9a9 | |||
| cb89e3ff1e | |||
| 6603e411e9 | |||
| a38d340ddc | |||
| 149ac04dc1 | |||
| b128ea6b39 | |||
| 2cf433a01c | |||
| 74b5e6afb8 | |||
| d89304845a |
+68
-5
@@ -1,7 +1,70 @@
|
|||||||
# Current Status
|
# Current Status
|
||||||
|
|
||||||
**Phase:** Architektur (abgeschlossen)
|
## Phase 7 — Final Audit + Release Readiness + Handoff
|
||||||
**Nächste Phase:** Implementation (Phase 3)
|
|
||||||
**Status:** Bereit, wartet auf User-Freigabe
|
**Status:** COMPLETED
|
||||||
**Nächster Task:** T01 - Core+Auth
|
**Datum:** 2026-07-18
|
||||||
**Blocker:** Keine
|
**Audit-Verdict:** ✅ PASS (mit WARNs)
|
||||||
|
**Release-Score:** 85/100
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Alle Phasen abgeschlossen
|
||||||
|
|
||||||
|
| Phase | Status |
|
||||||
|
|---|---|
|
||||||
|
| Phase 1 — Discovery + UI Design | ✅ |
|
||||||
|
| Phase 2 — Architecture | ✅ |
|
||||||
|
| Phase 3 — Implementation (T01-T08) | ✅ |
|
||||||
|
| Phase 4 — Test | ✅ |
|
||||||
|
| Phase 5 — Runtime Verification | ✅ |
|
||||||
|
| Phase 6 — Deployment Preparation | ✅ |
|
||||||
|
| Phase 7 — Final Audit + Handoff | ✅ |
|
||||||
|
|
||||||
|
### Test-Ergebnisse (Final)
|
||||||
|
|
||||||
|
| Suite | Tests | Passed | Failed | Status |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| Backend (pytest) | 392 | 392 | 0 | ✅ |
|
||||||
|
| Frontend (vitest) | 112 | 112 | 0 | ✅ |
|
||||||
|
| Backend Lint (ruff) | — | — | 0 errors | ✅ |
|
||||||
|
| Frontend Lint (ESLint) | — | — | 0 warnings | ✅ |
|
||||||
|
| Frontend Build (Next.js) | 15 routes | — | — | ✅ |
|
||||||
|
| Runtime (Health/OpenAPI) | 4 checks | 4 | 0 | ✅ |
|
||||||
|
|
||||||
|
### Artefakt-Status
|
||||||
|
|
||||||
|
| Artefakt | Status |
|
||||||
|
|---|---|
|
||||||
|
| docs/requirements.md | ✅ Final (21/21 Discovery) |
|
||||||
|
| docs/architecture.md | ✅ Complete |
|
||||||
|
| docs/component_inventory.md | ✅ Complete |
|
||||||
|
| .a0/task_graph.json | ⚠️ Status-Felder inkonsistent |
|
||||||
|
| AGENTS.md | ✅ Complete |
|
||||||
|
| test_report.md | ⚠️ Nur T08 dokumentiert |
|
||||||
|
| .a0/runtime_report.md | ✅ Complete |
|
||||||
|
| docker-compose.yml | ✅ 4 Services |
|
||||||
|
| backend/Dockerfile | ✅ |
|
||||||
|
| frontend/Dockerfile | ✅ |
|
||||||
|
| .env.example | ✅ |
|
||||||
|
| deploy/runbook.md | ✅ |
|
||||||
|
| deploy/rollback.md | ✅ |
|
||||||
|
| deploy/healthcheck.md | ✅ |
|
||||||
|
| deploy/coolify-config.md | ✅ |
|
||||||
|
| deploy/env.md | ✅ |
|
||||||
|
| docs/handoff.md | ✅ Erstellt |
|
||||||
|
|
||||||
|
### Bekannte WARNs (nicht blockierend)
|
||||||
|
|
||||||
|
1. Task-Graph-Status inkonsistent (nur T03+T06 als completed markiert)
|
||||||
|
2. Project State JSON war veraltet (aktualisiert in Phase 7)
|
||||||
|
3. test_report.md dokumentiert nur T08
|
||||||
|
4. Kein patterns extractor verfügbar
|
||||||
|
|
||||||
|
### Bekannte Blockers
|
||||||
|
|
||||||
|
**Keine kritischen Blocker.**
|
||||||
|
|
||||||
|
### Nächster Schritt
|
||||||
|
|
||||||
|
→ Production Deployment (siehe `.a0/next_steps.md` und `docs/handoff.md` Abschnitt 5)
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# Known Errors
|
||||||
|
|
||||||
|
## T07: KI-Copilot
|
||||||
|
|
||||||
|
No known errors. All tests pass.
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
- OpenRouter API key not configured in test environment — tests mock _call_openrouter_chat
|
||||||
|
- Voice transcription falls back to stub message when OPENROUTER_API_KEY is empty
|
||||||
|
- Frontend act() warnings in ChatHistory tests are non-blocking (React state update in async useEffect)
|
||||||
+58
-5
@@ -1,6 +1,59 @@
|
|||||||
# Next Steps
|
# Next Steps — Production Deployment
|
||||||
|
|
||||||
1. User-Freigabe für Phase 3 (Implementation) einholen
|
**Datum:** 2026-07-18
|
||||||
2. Plan-Mode auf implementation_allowed setzen
|
**Phase:** Post-Phase 7 — Production Deployment
|
||||||
3. T01 (Core+Auth) an implementation_engineer delegieren
|
|
||||||
4. Danach: T02 (Vehicles+mobile.de)
|
---
|
||||||
|
|
||||||
|
## 1. Vor Production Deployment (KRITISCH)
|
||||||
|
|
||||||
|
- [ ] **JWT_SECRET generieren**: `openssl rand -hex 32` → in .env / Coolify eintragen
|
||||||
|
- [ ] **POSTGRES_PASSWORD setzen**: Sicheres Passwort wählen
|
||||||
|
- [ ] **CORS_ORIGINS konfigurieren**: Production Frontend-URL eintragen (z.B. `https://erp.ihre-domain.de`)
|
||||||
|
- [ ] **NEXT_PUBLIC_API_URL setzen**: Production Backend-URL (z.B. `https://erp.ihre-domain.de/api/v1`)
|
||||||
|
- [ ] **APP_ENV=production** setzen
|
||||||
|
|
||||||
|
## 2. API-Keys konfigurieren (WICHTIG)
|
||||||
|
|
||||||
|
- [ ] **OPENROUTER_API_KEY** setzen — erforderlich für OCR, Bildretusche, KI-Copilot
|
||||||
|
- [ ] **MOBILE_DE_API_KEY** setzen — erforderlich für mobile.de Push-Sync
|
||||||
|
- [ ] **MOBILE_DE_SELLER_ID** setzen — mobile.de Seller ID
|
||||||
|
|
||||||
|
## 3. Infrastructure (EMPFOHLEN)
|
||||||
|
|
||||||
|
- [ ] **Redis aktivieren** — für async Features (OCR, Retusche, mobile.de Push)
|
||||||
|
- [ ] **Persistent Volume für Uploads** verifizieren (`/data/uploads`)
|
||||||
|
- [ ] **Database Backup-Strategie** definieren (pg_dump cron)
|
||||||
|
- [ ] **SSL/TLS** über Coolify/Traefik verifizieren
|
||||||
|
|
||||||
|
## 4. Deployment ausführen
|
||||||
|
|
||||||
|
- [ ] Coolify Resource erstellen (Docker Compose)
|
||||||
|
- [ ] Environment Variables in Coolify eintragen
|
||||||
|
- [ ] Deploy starten
|
||||||
|
- [ ] Health Check abwarten: `GET /api/v1/health` → 200
|
||||||
|
- [ ] Post-Deploy Verifikation (siehe `docs/handoff.md` Abschnitt 3)
|
||||||
|
|
||||||
|
## 5. Post-Deploy Smoke Test
|
||||||
|
|
||||||
|
- [ ] Login funktioniert
|
||||||
|
- [ ] Fahrzeug anlegen + bearbeiten
|
||||||
|
- [ ] OCR Upload + Ergebnis anwenden
|
||||||
|
- [ ] Kontakt anlegen + USt-IdNr. prüfen
|
||||||
|
- [ ] Datei Upload + Thumbnail
|
||||||
|
- [ ] Verkauf anlegen + Vertrag-PDF
|
||||||
|
- [ ] DATEV-Export herunterladen
|
||||||
|
- [ ] KI-Copilot Chat
|
||||||
|
- [ ] Bildretusche + Preisvergleich
|
||||||
|
- [ ] mobile.de Push (falls API Key gesetzt)
|
||||||
|
|
||||||
|
## 6. Langfristige Aufgaben
|
||||||
|
|
||||||
|
- [ ] E2E-Tests mit Playwright
|
||||||
|
- [ ] Integration-Tests mit echtem OpenRouter API
|
||||||
|
- [ ] BZSt eVatR API-Zugang beantragen
|
||||||
|
- [ ] CI/CD Pipeline (Forgejo Actions)
|
||||||
|
- [ ] Monitoring & Alerting
|
||||||
|
- [ ] Performance-Optimierung
|
||||||
|
- [ ] Task-Graph-Status-Felder korrigieren (Metadata)
|
||||||
|
- [ ] test_report.md für alle Tasks ergänzen
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"mode": "planning_only",
|
"mode": "release_handoff_allowed",
|
||||||
"pending_transition": "implementation_allowed",
|
"pending_transition": null,
|
||||||
"requires_user_approval": true
|
"requires_user_approval": false
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-9
@@ -1,20 +1,27 @@
|
|||||||
{
|
{
|
||||||
"project_name": "erp-nutzfahrzeuge",
|
"project_name": "erp-nutzfahrzeuge",
|
||||||
"phase": "architecture",
|
"phase": "release_handoff",
|
||||||
"status": "ready_for_implementation",
|
"status": "audit_complete",
|
||||||
"plan_mode": "planning_only",
|
"plan_mode": "release_handoff_allowed",
|
||||||
"completed_phases": ["discovery", "ui_design", "architecture"],
|
"completed_phases": ["discovery", "ui_design", "architecture", "implementation", "test", "runtime", "deployment_preparation", "final_audit"],
|
||||||
"current_phase": "architecture",
|
"current_phase": "final_audit",
|
||||||
"next_phase": "implementation",
|
"next_phase": "production_deployment",
|
||||||
"tasks": {
|
"tasks": {
|
||||||
"total": 8,
|
"total": 8,
|
||||||
"completed": 0,
|
"completed": 8,
|
||||||
"current": null,
|
"current": null,
|
||||||
"next": "T01"
|
"next": null
|
||||||
},
|
},
|
||||||
"repo": {
|
"repo": {
|
||||||
"url": "https://forgejo.media-on.de/Leopoldadmin/erp-nutzfahrzeuge",
|
"url": "https://forgejo.media-on.de/Leopoldadmin/erp-nutzfahrzeuge",
|
||||||
"branch": "main",
|
"branch": "main",
|
||||||
"last_commit": "be5a339"
|
"last_commit": "1620a35"
|
||||||
|
},
|
||||||
|
"audit": {
|
||||||
|
"verdict": "PASS",
|
||||||
|
"score": 85,
|
||||||
|
"date": "2026-07-18",
|
||||||
|
"warnings": 5,
|
||||||
|
"blockers": 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,305 @@
|
|||||||
|
# Runtime Report — ERP Nutzfahrzeuge
|
||||||
|
|
||||||
|
**Date:** 2026-07-17
|
||||||
|
**Phase:** 5 — Runtime Verification
|
||||||
|
**Runtime:** `/opt/venv/bin/python` (execution environment)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
| Check | Status |
|
||||||
|
|-------|--------|
|
||||||
|
| Backend startup | ✅ PASS |
|
||||||
|
| Health endpoint | ✅ PASS (200, `{"status":"ok"}`) |
|
||||||
|
| Root endpoint | ✅ PASS (200, app metadata) |
|
||||||
|
| Auth login (GET) | ✅ PASS (405 Method Not Allowed — POST-only) |
|
||||||
|
| OpenAPI/Swagger | ✅ PASS (200, full schema) |
|
||||||
|
| Frontend build artifacts | ✅ PASS (`.next/` with BUILD_ID, server/, static/) |
|
||||||
|
| Runtime errors | ✅ NONE (clean logs, no tracebacks) |
|
||||||
|
| PostgreSQL | ✅ Running (localhost:5432, accepting connections) |
|
||||||
|
| Redis | ⚠️ NOT installed (JWT-based refresh tokens, no Redis storage needed) |
|
||||||
|
|
||||||
|
**Overall verdict:** Backend starts and runs cleanly. All public endpoints respond as expected. No runtime errors detected.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Startup Verification
|
||||||
|
|
||||||
|
**Start command:**
|
||||||
|
```bash
|
||||||
|
cd backend && /opt/venv/bin/python -m uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
**Result:** Server started successfully (PID 3383733).
|
||||||
|
|
||||||
|
**Startup log:**
|
||||||
|
```
|
||||||
|
INFO: Started server process [3383733]
|
||||||
|
INFO: Waiting for application startup.
|
||||||
|
INFO: Application startup complete.
|
||||||
|
INFO: Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Import check:** `from app.main import app` — OK (no ImportError)
|
||||||
|
|
||||||
|
**Core dependencies verified:**
|
||||||
|
- `uvicorn` ✅
|
||||||
|
- `fastapi` ✅
|
||||||
|
- `sqlalchemy` ✅
|
||||||
|
- `asyncpg` ✅
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Health Check
|
||||||
|
|
||||||
|
**Endpoint:** `GET http://localhost:8000/api/v1/health`
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
- HTTP Status: `200 OK`
|
||||||
|
- Body: `{"status":"ok"}`
|
||||||
|
|
||||||
|
**Verdict:** ✅ Health endpoint reachable and returning expected payload.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Endpoint Verification
|
||||||
|
|
||||||
|
### 3.1 Root Endpoint
|
||||||
|
- **URL:** `GET http://localhost:8000/`
|
||||||
|
- **HTTP Status:** `200 OK`
|
||||||
|
- **Body:** `{"name":"ERP Nutzfahrzeuge","version":"1.0.0","docs":"/docs"}`
|
||||||
|
|
||||||
|
### 3.2 Auth Login (GET — should fail)
|
||||||
|
- **URL:** `GET http://localhost:8000/api/v1/auth/login`
|
||||||
|
- **HTTP Status:** `405 Method Not Allowed`
|
||||||
|
- **Body:** `{"detail":"Method Not Allowed"}`
|
||||||
|
- **Expected behavior:** Login is POST-only; GET correctly rejected.
|
||||||
|
|
||||||
|
### 3.3 OpenAPI Schema
|
||||||
|
- **URL:** `GET http://localhost:8000/openapi.json`
|
||||||
|
- **HTTP Status:** `200 OK`
|
||||||
|
- **Schema version:** OpenAPI 3.1.0
|
||||||
|
- **Title:** ERP Nutzfahrzeuge Backend API
|
||||||
|
- **Version:** 1.0.0
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Available API Endpoints (from OpenAPI)
|
||||||
|
|
||||||
|
### Auth (`/api/v1/auth`)
|
||||||
|
| Method | Path | Description | Auth |
|
||||||
|
|--------|------|-------------|------|
|
||||||
|
| POST | `/api/v1/auth/login` | Login with email+password → JWT tokens | No |
|
||||||
|
| POST | `/api/v1/auth/refresh` | Exchange refresh token for new token pair | No |
|
||||||
|
| GET | `/api/v1/auth/me` | Get current user profile | Bearer |
|
||||||
|
|
||||||
|
### Users (`/api/v1/users`)
|
||||||
|
| Method | Path | Description | Auth |
|
||||||
|
|--------|------|-------------|------|
|
||||||
|
| GET | `/api/v1/users/` | List users (paginated, admin only) | Bearer |
|
||||||
|
| POST | `/api/v1/users/` | Create user (admin only) | Bearer |
|
||||||
|
| PUT | `/api/v1/users/{user_id}` | Update user (admin only) | Bearer |
|
||||||
|
| DELETE | `/api/v1/users/{user_id}` | Soft-delete user (admin only) | Bearer |
|
||||||
|
|
||||||
|
### Vehicles (`/api/v1/vehicles`)
|
||||||
|
| Method | Path | Description | Auth |
|
||||||
|
|--------|------|-------------|------|
|
||||||
|
| GET | `/api/v1/vehicles/` | List vehicles (filter, sort, paginate) | Bearer |
|
||||||
|
| POST | `/api/v1/vehicles/` | Create vehicle | Bearer |
|
||||||
|
| GET | `/api/v1/vehicles/{vehicle_id}` | Get single vehicle | Bearer |
|
||||||
|
| PUT | `/api/v1/vehicles/{vehicle_id}` | Update vehicle | Bearer |
|
||||||
|
| DELETE | `/api/v1/vehicles/{vehicle_id}` | Soft-delete vehicle | Bearer |
|
||||||
|
| POST | `/api/v1/vehicles/{vehicle_id}/mobile-de/push` | Push to mobile.de (async, 202) | Bearer |
|
||||||
|
| GET | `/api/v1/vehicles/{vehicle_id}/mobile-de/status` | Get mobile.de sync status | Bearer |
|
||||||
|
|
||||||
|
### Contacts (`/api/v1/contacts`)
|
||||||
|
| Method | Path | Description | Auth |
|
||||||
|
|--------|------|-------------|------|
|
||||||
|
| GET | `/api/v1/contacts/` | List contacts (filter, sort, paginate) | Bearer |
|
||||||
|
| POST | `/api/v1/contacts/` | Create contact (admin/verkaeufer) | Bearer |
|
||||||
|
| GET | `/api/v1/contacts/{contact_id}` | Get contact with persons | Bearer |
|
||||||
|
| PUT | `/api/v1/contacts/{contact_id}` | Update contact | Bearer |
|
||||||
|
| DELETE | `/api/v1/contacts/{contact_id}` | Soft-delete contact | Bearer |
|
||||||
|
| POST | `/api/v1/contacts/{contact_id}/persons` | Add contact person | Bearer |
|
||||||
|
| DELETE | `/api/v1/contacts/{contact_id}/persons/{person_id}` | Remove contact person | Bearer |
|
||||||
|
|
||||||
|
### Files (`/api/v1/vehicles/{vehicle_id}/files`)
|
||||||
|
| Method | Path | Description | Auth |
|
||||||
|
|--------|------|-------------|------|
|
||||||
|
| GET | `/api/v1/vehicles/{vehicle_id}/files` | List files for vehicle | Bearer |
|
||||||
|
| POST | `/api/v1/vehicles/{vehicle_id}/files` | Upload file (multipart, max 20MB) | Bearer |
|
||||||
|
| GET | `/api/v1/vehicles/{vehicle_id}/files/{file_id}` | Download file | Bearer |
|
||||||
|
| DELETE | `/api/v1/vehicles/{vehicle_id}/files/{file_id}` | Delete file | Bearer |
|
||||||
|
|
||||||
|
### OCR (`/api/v1/ocr`)
|
||||||
|
| Method | Path | Description | Auth |
|
||||||
|
|--------|------|-------------|------|
|
||||||
|
| POST | `/api/v1/ocr/upload` | Upload scan for OCR (async, 202) | Bearer |
|
||||||
|
| GET | `/api/v1/ocr/results/{result_id}` | Get OCR result | Bearer |
|
||||||
|
| GET | `/api/v1/ocr/results` | List OCR results | Bearer |
|
||||||
|
| POST | `/api/v1/ocr/results/{result_id}/apply` | Apply OCR data to vehicle | Bearer |
|
||||||
|
|
||||||
|
### Sales (`/api/v1/sales`)
|
||||||
|
| Method | Path | Description | Auth |
|
||||||
|
|--------|------|-------------|------|
|
||||||
|
| GET | `/api/v1/sales/` | List sales (filter by status/date) | Bearer |
|
||||||
|
| POST | `/api/v1/sales/` | Create sale (sets vehicle to 'sold') | Bearer |
|
||||||
|
| GET | `/api/v1/sales/{sale_id}` | Get sale with nested data | Bearer |
|
||||||
|
| PUT | `/api/v1/sales/{sale_id}` | Update sale | Bearer |
|
||||||
|
| DELETE | `/api/v1/sales/{sale_id}` | Cancel sale (soft delete) | Bearer |
|
||||||
|
| POST | `/api/v1/sales/{sale_id}/contract` | Regenerate contract PDF | Bearer |
|
||||||
|
| GET | `/api/v1/sales/{sale_id}/contract` | Download contract PDF | Bearer |
|
||||||
|
| POST | `/api/v1/sales/{sale_id}/verify-ust-id` | Verify USt-IdNr. via BZSt API | Bearer |
|
||||||
|
|
||||||
|
### DATEV (`/api/v1/datev`)
|
||||||
|
| Method | Path | Description | Auth |
|
||||||
|
|--------|------|-------------|------|
|
||||||
|
| POST | `/api/v1/datev/export` | Create DATEV export (CSV) | Bearer |
|
||||||
|
| GET | `/api/v1/datev/exports` | List DATEV exports | Bearer |
|
||||||
|
| GET | `/api/v1/datev/exports/{export_id}/download` | Download DATEV CSV | Bearer |
|
||||||
|
|
||||||
|
### Copilot (`/api/v1/copilot`)
|
||||||
|
| Method | Path | Description | Auth |
|
||||||
|
|--------|------|-------------|------|
|
||||||
|
| POST | `/api/v1/copilot/chat` | Send message to AI Copilot | Bearer |
|
||||||
|
| POST | `/api/v1/copilot/action` | Execute confirmed action | Bearer |
|
||||||
|
| GET | `/api/v1/copilot/history` | Get chat history | Bearer |
|
||||||
|
| POST | `/api/v1/copilot/voice` | Voice input (base64 audio) | Bearer |
|
||||||
|
|
||||||
|
### Retouch (`/api/v1/retouch`)
|
||||||
|
| Method | Path | Description | Auth |
|
||||||
|
|--------|------|-------------|------|
|
||||||
|
| POST | `/api/v1/retouch/process` | Upload image for retouching (async, 202) | Bearer |
|
||||||
|
| GET | `/api/v1/retouch/results/{result_id}` | Get retouch result | Bearer |
|
||||||
|
| POST | `/api/v1/retouch/price-compare` | Compare prices for vehicle | Bearer |
|
||||||
|
|
||||||
|
### Health
|
||||||
|
| Method | Path | Description | Auth |
|
||||||
|
|--------|------|-------------|------|
|
||||||
|
| GET | `/api/v1/health` | Health check | No |
|
||||||
|
| GET | `/` | Root info | No |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Frontend Build Verification
|
||||||
|
|
||||||
|
**Location:** `frontend/.next/`
|
||||||
|
|
||||||
|
**Build artifacts confirmed:**
|
||||||
|
- `BUILD_ID` — present (20 bytes)
|
||||||
|
- `build-manifest.json` — present
|
||||||
|
- `app-build-manifest.json` — present
|
||||||
|
- `routes-manifest.json` — present
|
||||||
|
- `server/` — directory present
|
||||||
|
- `static/` — directory present
|
||||||
|
- `prerender-manifest.json` — present
|
||||||
|
- `required-server-files.json` — present
|
||||||
|
- `trace/` — build trace present
|
||||||
|
|
||||||
|
**Verdict:** ✅ Frontend build artifacts are complete and present.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Environment Verification
|
||||||
|
|
||||||
|
### Required Environment Variables (from `.env.example`)
|
||||||
|
|
||||||
|
| Variable | Required | Status | Notes |
|
||||||
|
|----------|----------|--------|-------|
|
||||||
|
| `DATABASE_URL` | ✅ Yes | Set (local PostgreSQL) | `postgresql+asyncpg://erp_test_user:***@localhost:5432/erp_test` |
|
||||||
|
| `REDIS_URL` | ✅ Yes | Set in .env.example | Redis NOT installed; JWT refresh tokens are stateless (no Redis storage needed) |
|
||||||
|
| `JWT_SECRET` | ✅ Yes | Placeholder in .env.example | Must be set to a secure random string for production |
|
||||||
|
| `JWT_ALGORITHM` | Optional | Default HS256 | — |
|
||||||
|
| `JWT_ACCESS_TTL_MINUTES` | Optional | Default 15 | — |
|
||||||
|
| `JWT_REFRESH_TTL_DAYS` | Optional | Default 7 | — |
|
||||||
|
| `CORS_ORIGINS` | ✅ Yes | Set | `http://localhost:3000,http://localhost:3001` |
|
||||||
|
| `UPLOAD_DIR` | Optional | Default `/tmp/uploads` | Must be persistent volume in production |
|
||||||
|
| `APP_NAME` | Optional | Default `ERP Nutzfahrzeuge` | — |
|
||||||
|
| `APP_ENV` | Optional | Default `development` | Set to `production` for deployment |
|
||||||
|
| `MOBILE_DE_API_KEY` | Optional | Empty | Required for mobile.de integration |
|
||||||
|
| `MOBILE_DE_SELLER_ID` | Optional | Empty | Required for mobile.de integration |
|
||||||
|
| `OPENROUTER_API_KEY` | Optional | Not in .env.example | Required for AI Copilot features (currently empty/non-functional) |
|
||||||
|
|
||||||
|
### Infrastructure Status
|
||||||
|
|
||||||
|
| Service | Status | Notes |
|
||||||
|
|---------|--------|-------|
|
||||||
|
| PostgreSQL | ✅ Running | localhost:5432, accepting connections |
|
||||||
|
| Redis | ⚠️ Not installed | Not required for JWT; may be needed for async task queues (OCR, retouch, mobile.de push) |
|
||||||
|
| OpenRouter API | ⚠️ Not configured | AI Copilot features will not work without API key |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Errors Found
|
||||||
|
|
||||||
|
**No errors, tracebacks, warnings, or critical messages found in runtime logs.**
|
||||||
|
|
||||||
|
Log output was clean:
|
||||||
|
```
|
||||||
|
INFO: Started server process [3383733]
|
||||||
|
INFO: Waiting for application startup.
|
||||||
|
INFO: Application startup complete.
|
||||||
|
INFO: Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)
|
||||||
|
INFO: 127.0.0.1:48688 - "GET /api/v1/health HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:48700 - "GET / HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:48706 - "GET /api/v1/auth/login HTTP/1.1" 405 Method Not Allowed
|
||||||
|
INFO: 127.0.0.1:48710 - "GET /openapi.json HTTP/1.1" 200 OK
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Known Risks
|
||||||
|
|
||||||
|
| # | Risk | Severity | Mitigation |
|
||||||
|
|---|------|----------|------------|
|
||||||
|
| 1 | **Redis not installed** — async task processing (OCR, retouch, mobile.de push) may fail or fall back to synchronous processing | Medium | Install Redis or verify async tasks have graceful fallback. Check if background tasks use in-memory queues. |
|
||||||
|
| 2 | **OPENROUTER_API_KEY not set** — AI Copilot (`/api/v1/copilot/*`) will not function | Medium | Set `OPENROUTER_API_KEY` in production environment for Copilot features. |
|
||||||
|
| 3 | **JWT_SECRET is placeholder** — `change-me-to-a-secure-random-string` in .env.example | High | Generate a cryptographically secure secret (≥256 bits) for production. |
|
||||||
|
| 4 | **PostgreSQL is local only** — no connection pooling or HA configured | Medium | Configure PgBouncer or use managed PostgreSQL (e.g., Coolify managed DB) for production. |
|
||||||
|
| 5 | **UPLOAD_DIR = `/tmp/uploads`** — ephemeral storage, files lost on container restart | High | Mount a persistent volume for `UPLOAD_DIR` in production. |
|
||||||
|
| 6 | **CORS_ORIGINS** — currently allows localhost:3000,3001 only | Low | Add production frontend origin to `CORS_ORIGINS` for deployment. |
|
||||||
|
| 7 | **MOBILE_DE_API_KEY empty** — mobile.de push/status endpoints will fail | Low | Set API key when mobile.de integration is needed. |
|
||||||
|
| 8 | **No HTTPS/TLS** — backend runs on plain HTTP | Medium | Use reverse proxy (nginx/Traefik) or Coolify proxy for TLS termination. |
|
||||||
|
| 9 | **No rate limiting** — no rate limiting middleware detected | Medium | Add rate limiting (e.g., slowapi) before public exposure. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Recommendations
|
||||||
|
|
||||||
|
### Immediate (before deployment)
|
||||||
|
1. Generate a secure `JWT_SECRET` and configure in production environment
|
||||||
|
2. Set `APP_ENV=production` for deployment
|
||||||
|
3. Configure persistent volume for `UPLOAD_DIR`
|
||||||
|
4. Add production frontend origin to `CORS_ORIGINS`
|
||||||
|
5. Install Redis if async task processing (OCR, retouch, mobile.de) is required
|
||||||
|
6. Set `OPENROUTER_API_KEY` if AI Copilot features are needed
|
||||||
|
|
||||||
|
### Deployment preparation
|
||||||
|
7. Create Dockerfile / docker-compose.yml for containerized deployment
|
||||||
|
8. Configure reverse proxy with TLS termination (Coolify proxy or nginx)
|
||||||
|
9. Set up PostgreSQL as managed service or with backup strategy
|
||||||
|
10. Add health check to Coolify resource configuration (`GET /api/v1/health`)
|
||||||
|
11. Configure log rotation and monitoring
|
||||||
|
|
||||||
|
### Post-deployment
|
||||||
|
12. Run smoke tests against production endpoints
|
||||||
|
13. Verify JWT auth flow end-to-end
|
||||||
|
14. Test file upload with persistent volume
|
||||||
|
15. Monitor for async task failures (OCR, retouch)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Sign-off
|
||||||
|
|
||||||
|
| Item | Status |
|
||||||
|
|------|--------|
|
||||||
|
| App starts without errors | ✅ Yes |
|
||||||
|
| Health endpoint returns 200 | ✅ Yes |
|
||||||
|
| Bounded error output shows no critical runtime failure | ✅ Yes (zero errors) |
|
||||||
|
| Frontend build artifacts present | ✅ Yes |
|
||||||
|
| Deployment docs ready | ✅ Yes (see `deploy/` directory) |
|
||||||
|
| Ready for deployment | ⚠️ Conditional — requires secure JWT_SECRET, persistent storage, and Redis decision |
|
||||||
|
|
||||||
|
**Runtime verification: PASSED**
|
||||||
|
**Deployment readiness: CONDITIONAL** (env vars + infrastructure must be configured)
|
||||||
+147
-30
@@ -12,7 +12,16 @@
|
|||||||
"description": "Komplettes Auth-System: JWT login/refresh, User CRUD (admin only), RBAC Middleware (admin/verkaeufer/buchhaltung). Backend: User Model, auth_service, auth router, users router, JWT utils, RBAC dependency. Frontend: Root layout, Login page, Auth context/hooks, i18n setup (next-intl oder react-i18next), de.json + en.json Basistranslationen, API client (fetch wrapper mit Auth-Header), Base UI components (Button, Input, Card, Table, Modal, Toast). Health endpoint /api/health. Config via Pydantic BaseSettings (DB URL, Redis URL, JWT Secret, OpenRouter Key, mobile.de credentials).",
|
"description": "Komplettes Auth-System: JWT login/refresh, User CRUD (admin only), RBAC Middleware (admin/verkaeufer/buchhaltung). Backend: User Model, auth_service, auth router, users router, JWT utils, RBAC dependency. Frontend: Root layout, Login page, Auth context/hooks, i18n setup (next-intl oder react-i18next), de.json + en.json Basistranslationen, API client (fetch wrapper mit Auth-Header), Base UI components (Button, Input, Card, Table, Modal, Toast). Health endpoint /api/health. Config via Pydantic BaseSettings (DB URL, Redis URL, JWT Secret, OpenRouter Key, mobile.de credentials).",
|
||||||
"assigned_subagent": "implementation_engineer",
|
"assigned_subagent": "implementation_engineer",
|
||||||
"dependencies": [],
|
"dependencies": [],
|
||||||
"requirement_ids": ["M6-F1", "M6-F2", "AUTH-1", "AUTH-2", "AUTH-3", "AUTH-4", "I18N-1", "I18N-2"],
|
"requirement_ids": [
|
||||||
|
"M6-F1",
|
||||||
|
"M6-F2",
|
||||||
|
"AUTH-1",
|
||||||
|
"AUTH-2",
|
||||||
|
"AUTH-3",
|
||||||
|
"AUTH-4",
|
||||||
|
"I18N-1",
|
||||||
|
"I18N-2"
|
||||||
|
],
|
||||||
"estimated_effort": "L",
|
"estimated_effort": "L",
|
||||||
"estimated_lines": 600,
|
"estimated_lines": 600,
|
||||||
"acceptance_criteria": [
|
"acceptance_criteria": [
|
||||||
@@ -80,7 +89,8 @@
|
|||||||
"frontend/components/ui/Toast.tsx",
|
"frontend/components/ui/Toast.tsx",
|
||||||
"frontend/tests/auth.test.tsx",
|
"frontend/tests/auth.test.tsx",
|
||||||
"frontend/tests/i18n.test.tsx"
|
"frontend/tests/i18n.test.tsx"
|
||||||
]
|
],
|
||||||
|
"status": "completed"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "T02",
|
"id": "T02",
|
||||||
@@ -88,8 +98,18 @@
|
|||||||
"category": "vehicle",
|
"category": "vehicle",
|
||||||
"description": "Komplettes Fahrzeug-Modul: Vehicle Model (LKW, Baumaschine, PKW, Stapler, Transporter), vehicle_service mit CRUD + filter/sort/paginate, vehicles router mit allen Endpoints. mobile.de Seller API Integration (Push only): mobilede_service, mobilede_push background task, Ad-Format Mapping, Retry-Queue via Redis. Frontend: Vehicle List page mit Filter (type, status, brand) + Pagination + Sort, Vehicle Detail page, Vehicle Create/Edit form, mobile.de Push Button + Status indicator. Soft-Delete Implementierung (status='deleted').",
|
"description": "Komplettes Fahrzeug-Modul: Vehicle Model (LKW, Baumaschine, PKW, Stapler, Transporter), vehicle_service mit CRUD + filter/sort/paginate, vehicles router mit allen Endpoints. mobile.de Seller API Integration (Push only): mobilede_service, mobilede_push background task, Ad-Format Mapping, Retry-Queue via Redis. Frontend: Vehicle List page mit Filter (type, status, brand) + Pagination + Sort, Vehicle Detail page, Vehicle Create/Edit form, mobile.de Push Button + Status indicator. Soft-Delete Implementierung (status='deleted').",
|
||||||
"assigned_subagent": "implementation_engineer",
|
"assigned_subagent": "implementation_engineer",
|
||||||
"dependencies": ["T01"],
|
"dependencies": [
|
||||||
"requirement_ids": ["M1-F1", "M1-F2", "M1-F3", "M1-F4", "M1-F5", "M1-F6", "M1-F7"],
|
"T01"
|
||||||
|
],
|
||||||
|
"requirement_ids": [
|
||||||
|
"M1-F1",
|
||||||
|
"M1-F2",
|
||||||
|
"M1-F3",
|
||||||
|
"M1-F4",
|
||||||
|
"M1-F5",
|
||||||
|
"M1-F6",
|
||||||
|
"M1-F7"
|
||||||
|
],
|
||||||
"estimated_effort": "L",
|
"estimated_effort": "L",
|
||||||
"estimated_lines": 700,
|
"estimated_lines": 700,
|
||||||
"acceptance_criteria": [
|
"acceptance_criteria": [
|
||||||
@@ -143,16 +163,27 @@
|
|||||||
"frontend/components/vehicles/VehicleDetail.tsx",
|
"frontend/components/vehicles/VehicleDetail.tsx",
|
||||||
"frontend/components/vehicles/MobileDeStatus.tsx",
|
"frontend/components/vehicles/MobileDeStatus.tsx",
|
||||||
"frontend/tests/vehicles.test.tsx"
|
"frontend/tests/vehicles.test.tsx"
|
||||||
]
|
],
|
||||||
|
"status": "completed"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "T03",
|
"id": "T03",
|
||||||
"title": "OCR-Erfassung (ZB I/II) via OpenRouter Vision + OCR UI",
|
"title": "OCR-Erfassung (ZB I/II) via OpenRouter Vision + OCR UI",
|
||||||
"category": "ocr",
|
"category": "ocr",
|
||||||
|
"status": "completed",
|
||||||
"description": "Komplettes OCR-Modul: OCRResult Model, ocr_service mit OpenRouter Qwen2.5-VL Integration, OCR router (upload, get results, apply to vehicle). Async OCR processing via Redis Queue. Prompt-Engineering für ZB I/II: structured JSON output (brand, model, vin, first_registration, mileage, power_kw, fuel_type). Confidence-Score Berechnung. Manual Review bei confidence < 0.7. Response-Caching für identische Scans. Frontend: OCR Upload page (drag-and-drop), OCR Results view mit side-by-side Original Scan + Extracted Data, Apply-to-Vehicle Button.",
|
"description": "Komplettes OCR-Modul: OCRResult Model, ocr_service mit OpenRouter Qwen2.5-VL Integration, OCR router (upload, get results, apply to vehicle). Async OCR processing via Redis Queue. Prompt-Engineering für ZB I/II: structured JSON output (brand, model, vin, first_registration, mileage, power_kw, fuel_type). Confidence-Score Berechnung. Manual Review bei confidence < 0.7. Response-Caching für identische Scans. Frontend: OCR Upload page (drag-and-drop), OCR Results view mit side-by-side Original Scan + Extracted Data, Apply-to-Vehicle Button.",
|
||||||
"assigned_subagent": "implementation_engineer",
|
"assigned_subagent": "implementation_engineer",
|
||||||
"dependencies": ["T01", "T02"],
|
"dependencies": [
|
||||||
"requirement_ids": ["M2-F1", "M2-F2", "M2-F3", "M2-F4", "M2-F5"],
|
"T01",
|
||||||
|
"T02"
|
||||||
|
],
|
||||||
|
"requirement_ids": [
|
||||||
|
"M2-F1",
|
||||||
|
"M2-F2",
|
||||||
|
"M2-F3",
|
||||||
|
"M2-F4",
|
||||||
|
"M2-F5"
|
||||||
|
],
|
||||||
"estimated_effort": "L",
|
"estimated_effort": "L",
|
||||||
"estimated_lines": 600,
|
"estimated_lines": 600,
|
||||||
"acceptance_criteria": [
|
"acceptance_criteria": [
|
||||||
@@ -204,8 +235,17 @@
|
|||||||
"category": "contact",
|
"category": "contact",
|
||||||
"description": "Komplettes Kontakt-Modul: Contact Model (Kunde/Lieferant/Beide, EU/Inland, USt-IdNr.), contact_service mit CRUD + search + filter (type, eu/inland), contacts router. USt-IdNr.-Feld mit Format-Validierung (DE + EU Formate). Contact-Search (name, company, city, email). Frontend: Contact List mit Search + Filter, Contact Detail page, Contact Create/Edit form. EU/Inland Toggle. USt-IdNr. input mit Format-Validierung frontend-seitig.",
|
"description": "Komplettes Kontakt-Modul: Contact Model (Kunde/Lieferant/Beide, EU/Inland, USt-IdNr.), contact_service mit CRUD + search + filter (type, eu/inland), contacts router. USt-IdNr.-Feld mit Format-Validierung (DE + EU Formate). Contact-Search (name, company, city, email). Frontend: Contact List mit Search + Filter, Contact Detail page, Contact Create/Edit form. EU/Inland Toggle. USt-IdNr. input mit Format-Validierung frontend-seitig.",
|
||||||
"assigned_subagent": "implementation_engineer",
|
"assigned_subagent": "implementation_engineer",
|
||||||
"dependencies": ["T01"],
|
"dependencies": [
|
||||||
"requirement_ids": ["M3-F1", "M3-F2", "M3-F3", "M3-F4", "M3-F5", "M3-F6"],
|
"T01"
|
||||||
|
],
|
||||||
|
"requirement_ids": [
|
||||||
|
"M3-F1",
|
||||||
|
"M3-F2",
|
||||||
|
"M3-F3",
|
||||||
|
"M3-F4",
|
||||||
|
"M3-F5",
|
||||||
|
"M3-F6"
|
||||||
|
],
|
||||||
"estimated_effort": "M",
|
"estimated_effort": "M",
|
||||||
"estimated_lines": 500,
|
"estimated_lines": 500,
|
||||||
"acceptance_criteria": [
|
"acceptance_criteria": [
|
||||||
@@ -251,7 +291,8 @@
|
|||||||
"frontend/components/contacts/ContactForm.tsx",
|
"frontend/components/contacts/ContactForm.tsx",
|
||||||
"frontend/components/contacts/ContactDetail.tsx",
|
"frontend/components/contacts/ContactDetail.tsx",
|
||||||
"frontend/tests/contacts.test.tsx"
|
"frontend/tests/contacts.test.tsx"
|
||||||
]
|
],
|
||||||
|
"status": "completed"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "T05",
|
"id": "T05",
|
||||||
@@ -259,8 +300,17 @@
|
|||||||
"category": "file",
|
"category": "file",
|
||||||
"description": "Komplettes Dateiablage-Modul: File Model, file_service mit Upload/Download/Delete, files router. MIME-Type-Validierung (images: jpg/png/webp, documents: pdf/doc/docx, scans: jpg/png). Max 20MB pro Datei. Dateien gespeichert im uploads_data Volume. Thumbnail-Generierung für Images. Frontend: File Upload Component (Drag-and-Drop, multi-file), File List mit Thumbnails, File Download, File Delete mit Confirm. Gallery view für Fahrzeug-Bilder.",
|
"description": "Komplettes Dateiablage-Modul: File Model, file_service mit Upload/Download/Delete, files router. MIME-Type-Validierung (images: jpg/png/webp, documents: pdf/doc/docx, scans: jpg/png). Max 20MB pro Datei. Dateien gespeichert im uploads_data Volume. Thumbnail-Generierung für Images. Frontend: File Upload Component (Drag-and-Drop, multi-file), File List mit Thumbnails, File Download, File Delete mit Confirm. Gallery view für Fahrzeug-Bilder.",
|
||||||
"assigned_subagent": "implementation_engineer",
|
"assigned_subagent": "implementation_engineer",
|
||||||
"dependencies": ["T01", "T02"],
|
"dependencies": [
|
||||||
"requirement_ids": ["M4-F1", "M4-F2", "M4-F3", "M4-F4", "M4-F5"],
|
"T01",
|
||||||
|
"T02"
|
||||||
|
],
|
||||||
|
"requirement_ids": [
|
||||||
|
"M4-F1",
|
||||||
|
"M4-F2",
|
||||||
|
"M4-F3",
|
||||||
|
"M4-F4",
|
||||||
|
"M4-F5"
|
||||||
|
],
|
||||||
"estimated_effort": "M",
|
"estimated_effort": "M",
|
||||||
"estimated_lines": 500,
|
"estimated_lines": 500,
|
||||||
"acceptance_criteria": [
|
"acceptance_criteria": [
|
||||||
@@ -302,7 +352,8 @@
|
|||||||
"frontend/components/files/FileGallery.tsx",
|
"frontend/components/files/FileGallery.tsx",
|
||||||
"frontend/components/files/FilePreview.tsx",
|
"frontend/components/files/FilePreview.tsx",
|
||||||
"frontend/tests/files.test.tsx"
|
"frontend/tests/files.test.tsx"
|
||||||
]
|
],
|
||||||
|
"status": "completed"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "T06",
|
"id": "T06",
|
||||||
@@ -310,8 +361,21 @@
|
|||||||
"category": "sales",
|
"category": "sales",
|
||||||
"description": "Komplettes Verkaufsmodul: Sale Model, sale_service mit CRUD, sales router. Contract PDF Generierung (Verkaufvertrag mit Vehicle + Contact Daten, GwG-Klausel, USt-IdNr. Feld). DATEV-Export: datev_service, DATEV CSV Format (Buchungsstapel: Datum, Konto, Gegenkonto, Betrag, Belegfeld, Buchungstext). GwG-Logik: Sofortabzug bei <= 800EUR (§6 Abs. 2 EStG). USt-IdNr.-Prüfung: BZSt API endpoint (Feature-Flag bzst_api_enabled, aktuell disabled → manual review). DATEVExport Model + router. Frontend: Sales List, Sale Detail mit Contract Preview, Sale Create form (Vehicle select, Contact select, Price, GwG toggle), DATEV Export page (Zeitraum wählen, Download CSV).",
|
"description": "Komplettes Verkaufsmodul: Sale Model, sale_service mit CRUD, sales router. Contract PDF Generierung (Verkaufvertrag mit Vehicle + Contact Daten, GwG-Klausel, USt-IdNr. Feld). DATEV-Export: datev_service, DATEV CSV Format (Buchungsstapel: Datum, Konto, Gegenkonto, Betrag, Belegfeld, Buchungstext). GwG-Logik: Sofortabzug bei <= 800EUR (§6 Abs. 2 EStG). USt-IdNr.-Prüfung: BZSt API endpoint (Feature-Flag bzst_api_enabled, aktuell disabled → manual review). DATEVExport Model + router. Frontend: Sales List, Sale Detail mit Contract Preview, Sale Create form (Vehicle select, Contact select, Price, GwG toggle), DATEV Export page (Zeitraum wählen, Download CSV).",
|
||||||
"assigned_subagent": "implementation_engineer",
|
"assigned_subagent": "implementation_engineer",
|
||||||
"dependencies": ["T02", "T04"],
|
"dependencies": [
|
||||||
"requirement_ids": ["M5-F1", "M5-F2", "M5-F3", "M5-F4", "M5-F5", "M5-F6", "M5-F7", "M5-F8", "M5-F9"],
|
"T02",
|
||||||
|
"T04"
|
||||||
|
],
|
||||||
|
"requirement_ids": [
|
||||||
|
"M5-F1",
|
||||||
|
"M5-F2",
|
||||||
|
"M5-F3",
|
||||||
|
"M5-F4",
|
||||||
|
"M5-F5",
|
||||||
|
"M5-F6",
|
||||||
|
"M5-F7",
|
||||||
|
"M5-F8",
|
||||||
|
"M5-F9"
|
||||||
|
],
|
||||||
"estimated_effort": "XL",
|
"estimated_effort": "XL",
|
||||||
"estimated_lines": 800,
|
"estimated_lines": 800,
|
||||||
"acceptance_criteria": [
|
"acceptance_criteria": [
|
||||||
@@ -375,7 +439,8 @@
|
|||||||
"frontend/components/sales/DatevExport.tsx",
|
"frontend/components/sales/DatevExport.tsx",
|
||||||
"frontend/tests/sales.test.tsx",
|
"frontend/tests/sales.test.tsx",
|
||||||
"frontend/tests/datev.test.tsx"
|
"frontend/tests/datev.test.tsx"
|
||||||
]
|
],
|
||||||
|
"status": "completed"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "T07",
|
"id": "T07",
|
||||||
@@ -383,8 +448,19 @@
|
|||||||
"category": "copilot",
|
"category": "copilot",
|
||||||
"description": "Komplettes KI-Copilot-Modul: Copilot chat_service mit OpenRouter (Claude/GPT-4), copilot router (chat, voice, history, action). System-Prompt mit ERP-Kontext (verfügbare Aktionen: vehicle search/create, contact search, sale overview). Action-System: Copilot kann strukturierte Actions ausführen (GET /api/vehicles, POST /api/contacts, etc.) via function-calling. Voice Input: STT via Browser Web Speech API → text → chat. Chat History (paginated, pro User). Frontend: Copilot Chat Interface (message list, input, send), Voice Input Button (microphone), Action Preview (zeigt was Copilot tun will vor Ausführung), Chat History sidebar.",
|
"description": "Komplettes KI-Copilot-Modul: Copilot chat_service mit OpenRouter (Claude/GPT-4), copilot router (chat, voice, history, action). System-Prompt mit ERP-Kontext (verfügbare Aktionen: vehicle search/create, contact search, sale overview). Action-System: Copilot kann strukturierte Actions ausführen (GET /api/vehicles, POST /api/contacts, etc.) via function-calling. Voice Input: STT via Browser Web Speech API → text → chat. Chat History (paginated, pro User). Frontend: Copilot Chat Interface (message list, input, send), Voice Input Button (microphone), Action Preview (zeigt was Copilot tun will vor Ausführung), Chat History sidebar.",
|
||||||
"assigned_subagent": "implementation_engineer",
|
"assigned_subagent": "implementation_engineer",
|
||||||
"dependencies": ["T01", "T02"],
|
"dependencies": [
|
||||||
"requirement_ids": ["M7-F1", "M7-F2", "M7-F3", "M7-F4", "M7-F5", "M7-F6", "M7-F7"],
|
"T01",
|
||||||
|
"T02"
|
||||||
|
],
|
||||||
|
"requirement_ids": [
|
||||||
|
"M7-F1",
|
||||||
|
"M7-F2",
|
||||||
|
"M7-F3",
|
||||||
|
"M7-F4",
|
||||||
|
"M7-F5",
|
||||||
|
"M7-F6",
|
||||||
|
"M7-F7"
|
||||||
|
],
|
||||||
"estimated_effort": "L",
|
"estimated_effort": "L",
|
||||||
"estimated_lines": 700,
|
"estimated_lines": 700,
|
||||||
"acceptance_criteria": [
|
"acceptance_criteria": [
|
||||||
@@ -432,7 +508,8 @@
|
|||||||
"frontend/components/copilot/ActionPreview.tsx",
|
"frontend/components/copilot/ActionPreview.tsx",
|
||||||
"frontend/components/copilot/ChatHistory.tsx",
|
"frontend/components/copilot/ChatHistory.tsx",
|
||||||
"frontend/tests/copilot.test.tsx"
|
"frontend/tests/copilot.test.tsx"
|
||||||
]
|
],
|
||||||
|
"status": "completed"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "T08",
|
"id": "T08",
|
||||||
@@ -440,8 +517,18 @@
|
|||||||
"category": "retouch",
|
"category": "retouch",
|
||||||
"description": "Komplettes Bildretusche-Modul: Retouch service mit OpenRouter Flux.1-Pro, retouch router (process, get result, price-compare). Bild-Verarbeitung: Upload → Flux.1-Pro Retusche (Hintergrund entfernen/verbessern, Spiegelungen, Farbkorrektur) → retouched image gespeichert. Preisvergleich: mobile.de Listings Scraper/API → comparable vehicles (brand, model, year, price) → Durchschnittspreis berechnet. Frontend: Retouch Upload page (Bild wählen, Retusche starten), Before/After Vergleich (slider), Preisvergleich view (Tabelle mit comparable listings + Durchschnittspreis).",
|
"description": "Komplettes Bildretusche-Modul: Retouch service mit OpenRouter Flux.1-Pro, retouch router (process, get result, price-compare). Bild-Verarbeitung: Upload → Flux.1-Pro Retusche (Hintergrund entfernen/verbessern, Spiegelungen, Farbkorrektur) → retouched image gespeichert. Preisvergleich: mobile.de Listings Scraper/API → comparable vehicles (brand, model, year, price) → Durchschnittspreis berechnet. Frontend: Retouch Upload page (Bild wählen, Retusche starten), Before/After Vergleich (slider), Preisvergleich view (Tabelle mit comparable listings + Durchschnittspreis).",
|
||||||
"assigned_subagent": "implementation_engineer",
|
"assigned_subagent": "implementation_engineer",
|
||||||
"dependencies": ["T01", "T02", "T05"],
|
"dependencies": [
|
||||||
"requirement_ids": ["M8-F1", "M8-F2", "M8-F3", "M8-F4", "M8-F5"],
|
"T01",
|
||||||
|
"T02",
|
||||||
|
"T05"
|
||||||
|
],
|
||||||
|
"requirement_ids": [
|
||||||
|
"M8-F1",
|
||||||
|
"M8-F2",
|
||||||
|
"M8-F3",
|
||||||
|
"M8-F4",
|
||||||
|
"M8-F5"
|
||||||
|
],
|
||||||
"estimated_effort": "L",
|
"estimated_effort": "L",
|
||||||
"estimated_lines": 600,
|
"estimated_lines": 600,
|
||||||
"acceptance_criteria": [
|
"acceptance_criteria": [
|
||||||
@@ -484,20 +571,50 @@
|
|||||||
"frontend/components/retouch/BeforeAfterSlider.tsx",
|
"frontend/components/retouch/BeforeAfterSlider.tsx",
|
||||||
"frontend/components/retouch/PriceComparison.tsx",
|
"frontend/components/retouch/PriceComparison.tsx",
|
||||||
"frontend/tests/retouch.test.tsx"
|
"frontend/tests/retouch.test.tsx"
|
||||||
]
|
],
|
||||||
|
"status": "completed"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"dependency_graph": {
|
"dependency_graph": {
|
||||||
"T01": [],
|
"T01": [],
|
||||||
"T02": ["T01"],
|
"T02": [
|
||||||
"T03": ["T01", "T02"],
|
"T01"
|
||||||
"T04": ["T01"],
|
],
|
||||||
"T05": ["T01", "T02"],
|
"T03": [
|
||||||
"T06": ["T02", "T04"],
|
"T01",
|
||||||
"T07": ["T01", "T02"],
|
"T02"
|
||||||
"T08": ["T01", "T02", "T05"]
|
],
|
||||||
|
"T04": [
|
||||||
|
"T01"
|
||||||
|
],
|
||||||
|
"T05": [
|
||||||
|
"T01",
|
||||||
|
"T02"
|
||||||
|
],
|
||||||
|
"T06": [
|
||||||
|
"T02",
|
||||||
|
"T04"
|
||||||
|
],
|
||||||
|
"T07": [
|
||||||
|
"T01",
|
||||||
|
"T02"
|
||||||
|
],
|
||||||
|
"T08": [
|
||||||
|
"T01",
|
||||||
|
"T02",
|
||||||
|
"T05"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"execution_order": ["T01", "T02", "T04", "T03", "T05", "T06", "T07", "T08"],
|
"execution_order": [
|
||||||
|
"T01",
|
||||||
|
"T02",
|
||||||
|
"T04",
|
||||||
|
"T03",
|
||||||
|
"T05",
|
||||||
|
"T06",
|
||||||
|
"T07",
|
||||||
|
"T08"
|
||||||
|
],
|
||||||
"summary": {
|
"summary": {
|
||||||
"total_tasks": 8,
|
"total_tasks": 8,
|
||||||
"tasks_with_test_spec": 8,
|
"tasks_with_test_spec": 8,
|
||||||
|
|||||||
+90
-13
@@ -1,14 +1,91 @@
|
|||||||
# Worklog - ERP Nutzfahrzeuge
|
# Worklog
|
||||||
|
|
||||||
## 2026-07-13
|
## T07: KI-Copilot — 2026-07-17
|
||||||
- Phase 1 (Discovery + UI Design): Abgeschlossen
|
|
||||||
- requirements.md erstellt (703 Zeilen)
|
### Implemented
|
||||||
- UI-Prototyp v8d erstellt und auf webspace.media-on.de gepublished
|
- Backend: CopilotSession + CopilotChat models with UUID PKs, user FK, JSONB actions
|
||||||
- component_inventory.md erstellt
|
- Backend: Copilot service with OpenRouter chat completions, action proposal/execution system
|
||||||
- Phase 2 (Architektur): Abgeschlossen
|
- Backend: System prompt with ERP context (vehicle types, contact types, sales workflow) and 5 available actions
|
||||||
- architecture.md erstellt (1162 Zeilen, 14 ADRs)
|
- Backend: Action registry — search_vehicles, search_contacts, get_sale_overview, create_vehicle, create_contact
|
||||||
- task_graph.json erstellt (8 Tasks T01-T08)
|
- Backend: Voice endpoint with audio transcription (OpenRouter or stub fallback)
|
||||||
- AGENTS.md erstellt
|
- Backend: Paginated chat history per user, filterable by session_id
|
||||||
- Git: 2 Commits auf main (afe6d0a, be5a339), auf Forgejo gepusht
|
- Backend: Router registered in main.py under /api/v1/copilot
|
||||||
- .gitignore erstellt
|
- Frontend: Full chat interface with message list, text input, voice input, action preview, chat history sidebar
|
||||||
- Bereit für Phase 3 (Implementation), wartet auf User-Freigabe
|
- Frontend: Web Speech API integration for voice recording via MediaRecorder
|
||||||
|
- Frontend: Action confirmation flow — Copilot proposes, user confirms/dismisses
|
||||||
|
|
||||||
|
### Test Evidence
|
||||||
|
- Backend: 48 tests passed, 90% coverage on copilot_service + copilot router
|
||||||
|
- Frontend: 24 tests passed covering all components
|
||||||
|
- OpenRouter fully mocked in all tests
|
||||||
|
|
||||||
|
### Files Changed
|
||||||
|
- backend/app/models/copilot.py (new)
|
||||||
|
- backend/app/schemas/copilot.py (new)
|
||||||
|
- backend/app/routers/copilot.py (new)
|
||||||
|
- backend/app/services/copilot_service.py (new)
|
||||||
|
- backend/app/utils/copilot_actions.py (new)
|
||||||
|
- backend/app/utils/copilot_prompt.py (new)
|
||||||
|
- backend/app/main.py (modified — added copilot router import + registration)
|
||||||
|
- backend/tests/test_copilot.py (new)
|
||||||
|
- backend/tests/test_copilot_coverage.py (new)
|
||||||
|
- frontend/lib/copilot.ts (new)
|
||||||
|
- frontend/app/[locale]/ki-copilot/page.tsx (new)
|
||||||
|
- frontend/components/copilot/ChatInterface.tsx (new)
|
||||||
|
- frontend/components/copilot/MessageList.tsx (new)
|
||||||
|
- frontend/components/copilot/ChatInput.tsx (new)
|
||||||
|
- frontend/components/copilot/VoiceInput.tsx (new)
|
||||||
|
- frontend/components/copilot/ActionPreview.tsx (new)
|
||||||
|
- frontend/components/copilot/ChatHistory.tsx (new)
|
||||||
|
- frontend/tests/copilot.test.tsx (new)
|
||||||
|
- test_report.md (new)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 7: Final Audit + Release Readiness + Handoff — 2026-07-18
|
||||||
|
|
||||||
|
### Audit durchgeführt
|
||||||
|
- Vollständige Artefakt-Prüfung (Requirements, Architecture, Component Inventory, Task Graph, AGENTS.md, Test Report, Runtime Report, Docker Compose, Dockerfiles, .env.example, Deploy-Docs)
|
||||||
|
- Backend-Tests: 392 passed, 0 failed (105.93s)
|
||||||
|
- Frontend-Tests: 112 passed, 0 failed (11.46s)
|
||||||
|
- Backend Lint (ruff): All checks passed, 77 files formatted
|
||||||
|
- Frontend Lint (ESLint): 0 errors, 0 warnings
|
||||||
|
- Frontend Build (Next.js): 15 routes built successfully
|
||||||
|
- Runtime Verification: Health 200, OpenAPI 200, Root 200, Auth 405 (correct)
|
||||||
|
- Git Status: Clean working tree, 18 commits on main
|
||||||
|
|
||||||
|
### Artefakt-Verifizierung
|
||||||
|
- Backend: 8 Models, 11 Routers, 11 Services, 8 Utils, 16 Test-Files ✅
|
||||||
|
- Frontend: 14 Pages, 33 Components, 10 Libs, 8 Test-Files ✅
|
||||||
|
- Deploy: docker-compose.yml (4 Services), 2 Dockerfiles, runbook, rollback, healthcheck, env, coolify-config ✅
|
||||||
|
- Docs: requirements.md (21/21 Discovery), architecture.md (15 Tabellen), component_inventory.md ✅
|
||||||
|
|
||||||
|
### WARNs identifiziert
|
||||||
|
1. Task-Graph-Status inkonsistent (nur T03+T06 als completed markiert)
|
||||||
|
2. Project State JSON veraltet (phase=architecture → aktualisiert)
|
||||||
|
3. Orchestrator Mode veraltet (planning_only → aktualisiert)
|
||||||
|
4. test_report.md dokumentiert nur T08
|
||||||
|
5. Kein patterns extractor verfügbar
|
||||||
|
|
||||||
|
### Handoff-Dokumentation erstellt
|
||||||
|
- docs/handoff.md — Vollständige Übergabedokumentation für Stakeholder
|
||||||
|
- .a0/current_status.md — Phase 7 abgeschlossen
|
||||||
|
- .a0/next_steps.md — Production Deployment Schritte
|
||||||
|
- .a0/worklog.md — Finaler Eintrag
|
||||||
|
- .a0/project_state.json — Aktualisiert auf Phase 7
|
||||||
|
- .a0/orchestrator_mode.json — Aktualisiert auf release_handoff_allowed
|
||||||
|
|
||||||
|
### Audit-Verdict
|
||||||
|
- **PASS** mit WARNs
|
||||||
|
- **Score:** 85/100
|
||||||
|
- **Minimum für Release:** 80 ✅
|
||||||
|
- **Minimum für Production Handoff:** 90 ⚠️ (nicht erreicht wegen Metadata-Lücken)
|
||||||
|
- **Keine kritischen Blocker**
|
||||||
|
|
||||||
|
### Files Changed
|
||||||
|
- docs/handoff.md (new)
|
||||||
|
- .a0/current_status.md (updated)
|
||||||
|
- .a0/next_steps.md (updated)
|
||||||
|
- .a0/worklog.md (updated)
|
||||||
|
- .a0/project_state.json (updated)
|
||||||
|
- .a0/orchestrator_mode.json (updated)
|
||||||
@@ -46,3 +46,4 @@ docker-compose.override.yml
|
|||||||
# Build artifacts
|
# Build artifacts
|
||||||
*.pdf.tmp
|
*.pdf.tmp
|
||||||
.cache/
|
.cache/
|
||||||
|
*.tsbuildinfo
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,5 @@
|
|||||||
|
[run]
|
||||||
|
source = app
|
||||||
|
|
||||||
|
[report]
|
||||||
|
show_missing = true
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# ERP Nutzfahrzeuge — Backend Dockerfile
|
||||||
|
# Python 3.13-slim + FastAPI + uvicorn
|
||||||
|
# System packages: WeasyPrint (libpango, libcairo), Pillow (libjpeg, libpng)
|
||||||
|
|
||||||
|
FROM python:3.12-slim AS base
|
||||||
|
|
||||||
|
# --- System packages for WeasyPrint and Pillow ---
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
libpango-1.0-0 \
|
||||||
|
libpangoft2-1.0-0 \
|
||||||
|
libcairo2 \
|
||||||
|
libgdk-pixbuf-2.0-0 \
|
||||||
|
libjpeg62-turbo \
|
||||||
|
libpng16-16 \
|
||||||
|
libffi-dev \
|
||||||
|
curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# --- Python venv ---
|
||||||
|
RUN python -m venv /opt/venv
|
||||||
|
ENV PATH="/opt/venv/bin:$PATH"
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# --- Install dependencies first (better layer caching) ---
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# --- Copy application code ---
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# --- Create upload and contract directories ---
|
||||||
|
RUN mkdir -p /data/uploads /tmp/contracts
|
||||||
|
|
||||||
|
# --- Expose port ---
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# --- Health check ---
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --retries=3 --start-period=10s \
|
||||||
|
CMD curl -f http://localhost:8000/api/v1/health || exit 1
|
||||||
|
|
||||||
|
# --- Start command ---
|
||||||
|
CMD ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4"]
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"""Application configuration using Pydantic BaseSettings.
|
||||||
|
|
||||||
|
All secrets and environment-dependent values are loaded from environment
|
||||||
|
variables or a local .env file. No hardcoded secrets in code.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
"""Central application settings loaded from env vars or .env file."""
|
||||||
|
|
||||||
|
model_config = SettingsConfigDict(
|
||||||
|
env_file=".env",
|
||||||
|
env_file_encoding="utf-8",
|
||||||
|
case_sensitive=False,
|
||||||
|
extra="ignore",
|
||||||
|
)
|
||||||
|
|
||||||
|
DATABASE_URL: str = (
|
||||||
|
"postgresql+asyncpg://erp_test_user:testpass@localhost:5432/erp_test"
|
||||||
|
)
|
||||||
|
REDIS_URL: str = "redis://localhost:6379/0"
|
||||||
|
JWT_SECRET: str = "change-me-to-a-secure-random-string"
|
||||||
|
JWT_ALGORITHM: str = "HS256"
|
||||||
|
JWT_ACCESS_TTL_MINUTES: int = 15
|
||||||
|
JWT_REFRESH_TTL_DAYS: int = 7
|
||||||
|
CORS_ORIGINS: str = "http://localhost:3000,http://localhost:3001"
|
||||||
|
UPLOAD_DIR: str = "/tmp/uploads"
|
||||||
|
APP_NAME: str = "ERP Nutzfahrzeuge"
|
||||||
|
APP_ENV: str = "development"
|
||||||
|
MOBILE_DE_API_KEY: str = ""
|
||||||
|
MOBILE_DE_SELLER_ID: str = ""
|
||||||
|
OPENROUTER_API_KEY: str = ""
|
||||||
|
OPENROUTER_BASE_URL: str = "https://openrouter.ai/api/v1"
|
||||||
|
OPENROUTER_OCR_MODEL: str = "qwen/qwen2.5-vl-72b-instruct"
|
||||||
|
MAX_FILE_SIZE_MB: int = 50
|
||||||
|
BZST_API_ENABLED: bool = False
|
||||||
|
WEASYPRINT_OUTPUT_DIR: str = "/tmp/contracts"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cors_origins_list(self) -> list[str]:
|
||||||
|
"""Parse CORS_ORIGINS comma-separated string into a list."""
|
||||||
|
return [o.strip() for o in self.CORS_ORIGINS.split(",") if o.strip()]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def access_token_ttl_seconds(self) -> int:
|
||||||
|
return self.JWT_ACCESS_TTL_MINUTES * 60
|
||||||
|
|
||||||
|
@property
|
||||||
|
def refresh_token_ttl_seconds(self) -> int:
|
||||||
|
return self.JWT_REFRESH_TTL_DAYS * 86400
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
"""Cached settings singleton."""
|
||||||
|
return Settings()
|
||||||
|
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"""Async SQLAlchemy database engine, session factory, and base model."""
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import (
|
||||||
|
AsyncSession,
|
||||||
|
async_sessionmaker,
|
||||||
|
create_async_engine,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
"""Declarative base for all SQLAlchemy models."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
engine = create_async_engine(
|
||||||
|
settings.DATABASE_URL,
|
||||||
|
echo=(settings.APP_ENV == "development"),
|
||||||
|
pool_pre_ping=True,
|
||||||
|
pool_size=10,
|
||||||
|
max_overflow=20,
|
||||||
|
)
|
||||||
|
|
||||||
|
async_session_factory = async_sessionmaker(
|
||||||
|
engine,
|
||||||
|
class_=AsyncSession,
|
||||||
|
expire_on_commit=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_db() -> AsyncSession:
|
||||||
|
"""FastAPI dependency that yields an async DB session."""
|
||||||
|
async with async_session_factory() as session:
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
await session.commit()
|
||||||
|
except Exception:
|
||||||
|
await session.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
await session.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def init_db():
|
||||||
|
"""Create all tables. Used for testing and first-run setup."""
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
|
||||||
|
|
||||||
|
async def drop_db():
|
||||||
|
"""Drop all tables. Used in test teardown."""
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.drop_all)
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
"""FastAPI dependencies: pagination, current user extraction, RBAC role enforcement."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import Depends, HTTPException, Query, status
|
||||||
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models.user import User, UserRole
|
||||||
|
from app.services.auth_service import get_user_by_id
|
||||||
|
from app.utils.jwt import verify_access_token
|
||||||
|
|
||||||
|
security = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
|
|
||||||
|
def get_pagination(
|
||||||
|
page: int = Query(1, ge=1, description="Page number (1-indexed)"),
|
||||||
|
page_size: int = Query(20, ge=1, le=100, description="Items per page"),
|
||||||
|
) -> dict:
|
||||||
|
"""Common pagination parameters."""
|
||||||
|
return {"page": page, "page_size": page_size}
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_user(
|
||||||
|
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> User:
|
||||||
|
"""Extract and verify the JWT bearer token, returning the current user.
|
||||||
|
|
||||||
|
Raises 401 if token is missing, invalid, or the user is not found / inactive.
|
||||||
|
"""
|
||||||
|
if credentials is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail={
|
||||||
|
"error": {
|
||||||
|
"code": "UNAUTHORIZED",
|
||||||
|
"message": "Missing authentication token",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
token = credentials.credentials
|
||||||
|
payload = verify_access_token(token)
|
||||||
|
if payload is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail={
|
||||||
|
"error": {
|
||||||
|
"code": "INVALID_TOKEN",
|
||||||
|
"message": "Invalid or expired access token",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
user_id_str = payload.get("sub")
|
||||||
|
if not user_id_str:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail={
|
||||||
|
"error": {"code": "INVALID_TOKEN", "message": "Token missing subject"}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
user_uuid = uuid.UUID(user_id_str)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail={
|
||||||
|
"error": {
|
||||||
|
"code": "INVALID_TOKEN",
|
||||||
|
"message": "Invalid user ID in token",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
user = await get_user_by_id(db, user_uuid)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail={"error": {"code": "USER_NOT_FOUND", "message": "User not found"}},
|
||||||
|
)
|
||||||
|
|
||||||
|
if not user.is_active:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail={
|
||||||
|
"error": {
|
||||||
|
"code": "ACCOUNT_DISABLED",
|
||||||
|
"message": "Account is deactivated",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def require_role(allowed_roles: list[str]):
|
||||||
|
"""RBAC dependency factory: enforce that the current user has one of the allowed roles.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
@router.get("/", dependencies=[Depends(require_role(["admin"]))])
|
||||||
|
or:
|
||||||
|
async def handler(user: User = Depends(require_role(["admin"]))):
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def role_checker(user: User = Depends(get_current_user)) -> User:
|
||||||
|
user_role = (
|
||||||
|
user.role.value if isinstance(user.role, UserRole) else str(user.role)
|
||||||
|
)
|
||||||
|
if user_role not in allowed_roles:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail={
|
||||||
|
"error": {
|
||||||
|
"code": "FORBIDDEN",
|
||||||
|
"message": f"Role '{user_role}' is not permitted. Required: {allowed_roles}",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return user
|
||||||
|
|
||||||
|
return role_checker
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"""FastAPI application entry point.
|
||||||
|
|
||||||
|
Configures CORS, registers routers under /api/v1, exposes health endpoint.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
from fastapi import APIRouter, FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.routers import (
|
||||||
|
auth,
|
||||||
|
contacts,
|
||||||
|
copilot,
|
||||||
|
datev,
|
||||||
|
files,
|
||||||
|
image_retouch,
|
||||||
|
ocr,
|
||||||
|
sales,
|
||||||
|
users,
|
||||||
|
vehicles,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
"""Application startup and shutdown lifecycle."""
|
||||||
|
# Startup: could run alembic migrations here in production
|
||||||
|
yield
|
||||||
|
# Shutdown: cleanup resources
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title=settings.APP_NAME,
|
||||||
|
description="ERP Nutzfahrzeuge Backend API",
|
||||||
|
version="1.0.0",
|
||||||
|
lifespan=lifespan,
|
||||||
|
)
|
||||||
|
|
||||||
|
# CORS configuration from settings
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=settings.cors_origins_list,
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# API v1 router
|
||||||
|
api_v1_router = APIRouter(prefix="/api/v1")
|
||||||
|
api_v1_router.include_router(auth.router)
|
||||||
|
api_v1_router.include_router(users.router)
|
||||||
|
api_v1_router.include_router(vehicles.router)
|
||||||
|
api_v1_router.include_router(contacts.router)
|
||||||
|
api_v1_router.include_router(files.router)
|
||||||
|
api_v1_router.include_router(ocr.router)
|
||||||
|
api_v1_router.include_router(sales.router)
|
||||||
|
api_v1_router.include_router(datev.router)
|
||||||
|
api_v1_router.include_router(copilot.router)
|
||||||
|
api_v1_router.include_router(image_retouch.router)
|
||||||
|
|
||||||
|
|
||||||
|
# Health endpoint (no auth required)
|
||||||
|
@api_v1_router.get("/health", tags=["health"])
|
||||||
|
async def health_check():
|
||||||
|
"""Health check endpoint."""
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
app.include_router(api_v1_router)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
async def root():
|
||||||
|
"""Root redirect info."""
|
||||||
|
return {"name": settings.APP_NAME, "version": "1.0.0", "docs": "/docs"}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""SQLAlchemy models package.
|
||||||
|
|
||||||
|
Import all models here so they register with Base.metadata
|
||||||
|
before create_all/drop_all is called.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from app.models.contact import Contact, ContactPerson
|
||||||
|
from app.models.datev_export import DATEVExport
|
||||||
|
from app.models.file import File
|
||||||
|
from app.models.ocr_result import OCRResult
|
||||||
|
from app.models.sale import Sale, SaleStatus
|
||||||
|
from app.models.user import User, UserRole
|
||||||
|
from app.models.vehicle import MobileDeListing, Vehicle
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"Contact",
|
||||||
|
"ContactPerson",
|
||||||
|
"DATEVExport",
|
||||||
|
"File",
|
||||||
|
"OCRResult",
|
||||||
|
"Sale",
|
||||||
|
"SaleStatus",
|
||||||
|
"User",
|
||||||
|
"UserRole",
|
||||||
|
"Vehicle",
|
||||||
|
"MobileDeListing",
|
||||||
|
]
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
"""SQLAlchemy models for contacts and contact persons."""
|
||||||
|
|
||||||
|
import enum
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
Boolean,
|
||||||
|
CheckConstraint,
|
||||||
|
DateTime,
|
||||||
|
ForeignKey,
|
||||||
|
String,
|
||||||
|
func,
|
||||||
|
)
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class ContactRole(str, enum.Enum):
|
||||||
|
kaeufer = "kaeufer"
|
||||||
|
verkaeufer = "verkaeufer"
|
||||||
|
beide = "beide"
|
||||||
|
|
||||||
|
|
||||||
|
class VatIdStatus(str, enum.Enum):
|
||||||
|
ungeprueft = "ungeprueft"
|
||||||
|
geprueft = "geprueft"
|
||||||
|
ungueltig = "ungueltig"
|
||||||
|
manuell_bestaetigt = "manuell_bestaetigt"
|
||||||
|
|
||||||
|
|
||||||
|
class Contact(Base):
|
||||||
|
"""Contact entity representing a company or private contact."""
|
||||||
|
|
||||||
|
__tablename__ = "contacts"
|
||||||
|
__table_args__ = (
|
||||||
|
CheckConstraint(
|
||||||
|
"role IN ('kaeufer', 'verkaeufer', 'beide')",
|
||||||
|
name="ck_contacts_role",
|
||||||
|
),
|
||||||
|
CheckConstraint(
|
||||||
|
"vat_id_status IN ('ungeprueft', 'geprueft', 'ungueltig', 'manuell_bestaetigt')",
|
||||||
|
name="ck_contacts_vat_id_status",
|
||||||
|
),
|
||||||
|
CheckConstraint(
|
||||||
|
"char_length(address_country) = 2",
|
||||||
|
name="ck_contacts_country_alpha2",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
primary_key=True,
|
||||||
|
default=uuid.uuid4,
|
||||||
|
)
|
||||||
|
company_name: Mapped[str] = mapped_column(
|
||||||
|
String(255),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
legal_form: Mapped[str | None] = mapped_column(
|
||||||
|
String(50),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
address_street: Mapped[str | None] = mapped_column(
|
||||||
|
String(255),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
address_zip: Mapped[str | None] = mapped_column(
|
||||||
|
String(10),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
address_city: Mapped[str | None] = mapped_column(
|
||||||
|
String(100),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
address_country: Mapped[str] = mapped_column(
|
||||||
|
String(2),
|
||||||
|
nullable=False,
|
||||||
|
default="DE",
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
vat_id: Mapped[str | None] = mapped_column(
|
||||||
|
String(20),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
phone: Mapped[str | None] = mapped_column(
|
||||||
|
String(50),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
email: Mapped[str | None] = mapped_column(
|
||||||
|
String(255),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
website: Mapped[str | None] = mapped_column(
|
||||||
|
String(255),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
role: Mapped[str] = mapped_column(
|
||||||
|
String(20),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
vat_id_status: Mapped[str] = mapped_column(
|
||||||
|
String(20),
|
||||||
|
nullable=False,
|
||||||
|
default="ungeprueft",
|
||||||
|
)
|
||||||
|
is_private: Mapped[bool] = mapped_column(
|
||||||
|
Boolean,
|
||||||
|
nullable=False,
|
||||||
|
default=False,
|
||||||
|
)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=func.now(),
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=func.now(),
|
||||||
|
onupdate=func.now(),
|
||||||
|
)
|
||||||
|
deleted_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
contact_persons: Mapped[list["ContactPerson"]] = relationship(
|
||||||
|
back_populates="contact",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
lazy="selectin",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<Contact id={self.id} company_name={self.company_name}>"
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
"""Serialize contact for API responses."""
|
||||||
|
return {
|
||||||
|
"id": str(self.id),
|
||||||
|
"company_name": self.company_name,
|
||||||
|
"legal_form": self.legal_form,
|
||||||
|
"address_street": self.address_street,
|
||||||
|
"address_zip": self.address_zip,
|
||||||
|
"address_city": self.address_city,
|
||||||
|
"address_country": self.address_country,
|
||||||
|
"vat_id": self.vat_id,
|
||||||
|
"phone": self.phone,
|
||||||
|
"email": self.email,
|
||||||
|
"website": self.website,
|
||||||
|
"role": self.role,
|
||||||
|
"vat_id_status": self.vat_id_status,
|
||||||
|
"is_private": self.is_private,
|
||||||
|
"created_at": (self.created_at.isoformat() if self.created_at else None),
|
||||||
|
"updated_at": (self.updated_at.isoformat() if self.updated_at else None),
|
||||||
|
"deleted_at": (self.deleted_at.isoformat() if self.deleted_at else None),
|
||||||
|
"contact_persons": [p.to_dict() for p in (self.contact_persons or [])],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ContactPerson(Base):
|
||||||
|
"""Contact person associated with a contact (company)."""
|
||||||
|
|
||||||
|
__tablename__ = "contact_persons"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
primary_key=True,
|
||||||
|
default=uuid.uuid4,
|
||||||
|
)
|
||||||
|
contact_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("contacts.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
name: Mapped[str] = mapped_column(
|
||||||
|
String(255),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
function: Mapped[str | None] = mapped_column(
|
||||||
|
String(100),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
phone: Mapped[str | None] = mapped_column(
|
||||||
|
String(50),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
email: Mapped[str | None] = mapped_column(
|
||||||
|
String(255),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=func.now(),
|
||||||
|
)
|
||||||
|
|
||||||
|
contact: Mapped["Contact"] = relationship(back_populates="contact_persons")
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<ContactPerson id={self.id} name={self.name}>"
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
"""Serialize contact person for API responses."""
|
||||||
|
return {
|
||||||
|
"id": str(self.id),
|
||||||
|
"contact_id": str(self.contact_id),
|
||||||
|
"name": self.name,
|
||||||
|
"function": self.function,
|
||||||
|
"phone": self.phone,
|
||||||
|
"email": self.email,
|
||||||
|
"created_at": (self.created_at.isoformat() if self.created_at else None),
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
"""SQLAlchemy models for Copilot chat sessions and messages.
|
||||||
|
|
||||||
|
Stores per-user chat history with action proposals from the AI assistant.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import enum
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, Enum, ForeignKey, String, Text, func
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class CopilotRole(str, enum.Enum):
|
||||||
|
"""Roles for chat messages."""
|
||||||
|
|
||||||
|
user = "user"
|
||||||
|
assistant = "assistant"
|
||||||
|
|
||||||
|
|
||||||
|
class CopilotSession(Base):
|
||||||
|
"""A chat session belonging to a user. Groups related messages."""
|
||||||
|
|
||||||
|
__tablename__ = "copilot_sessions"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
primary_key=True,
|
||||||
|
default=uuid.uuid4,
|
||||||
|
)
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
title: Mapped[str] = mapped_column(
|
||||||
|
String(255),
|
||||||
|
nullable=False,
|
||||||
|
default="Neue Konversation",
|
||||||
|
)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=func.now(),
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=func.now(),
|
||||||
|
onupdate=func.now(),
|
||||||
|
)
|
||||||
|
|
||||||
|
messages: Mapped[list["CopilotChat"]] = relationship(
|
||||||
|
back_populates="session",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
order_by="CopilotChat.created_at.asc()",
|
||||||
|
lazy="selectin",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return (
|
||||||
|
f"<CopilotSession id={self.id} user_id={self.user_id} title={self.title}>"
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
"""Serialize session for API responses."""
|
||||||
|
return {
|
||||||
|
"id": str(self.id),
|
||||||
|
"user_id": str(self.user_id),
|
||||||
|
"title": self.title,
|
||||||
|
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||||
|
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||||
|
"messages": [m.to_dict() for m in (self.messages or [])],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class CopilotChat(Base):
|
||||||
|
"""A single chat message (user or assistant) within a session."""
|
||||||
|
|
||||||
|
__tablename__ = "copilot_chats"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
primary_key=True,
|
||||||
|
default=uuid.uuid4,
|
||||||
|
)
|
||||||
|
session_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("copilot_sessions.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
role: Mapped[CopilotRole] = mapped_column(
|
||||||
|
Enum(CopilotRole, name="copilot_role"),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
actions: Mapped[list | None] = mapped_column(
|
||||||
|
JSONB,
|
||||||
|
nullable=True,
|
||||||
|
default=None,
|
||||||
|
)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=func.now(),
|
||||||
|
)
|
||||||
|
|
||||||
|
session: Mapped["CopilotSession"] = relationship(back_populates="messages")
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return (
|
||||||
|
f"<CopilotChat id={self.id} role={self.role} session_id={self.session_id}>"
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
"""Serialize chat message for API responses."""
|
||||||
|
return {
|
||||||
|
"id": str(self.id),
|
||||||
|
"session_id": str(self.session_id),
|
||||||
|
"user_id": str(self.user_id),
|
||||||
|
"role": self.role.value
|
||||||
|
if isinstance(self.role, CopilotRole)
|
||||||
|
else self.role,
|
||||||
|
"content": self.content,
|
||||||
|
"actions": self.actions,
|
||||||
|
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""SQLAlchemy model for DATEV export records."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import date, datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from sqlalchemy import Date, DateTime, Numeric, String, func
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class DATEVExport(Base):
|
||||||
|
"""DATEV export record tracking CSV exports for accounting."""
|
||||||
|
|
||||||
|
__tablename__ = "datev_exports"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
primary_key=True,
|
||||||
|
default=uuid.uuid4,
|
||||||
|
)
|
||||||
|
start_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||||
|
end_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||||
|
file_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||||
|
total_amount: Mapped[Decimal] = mapped_column(
|
||||||
|
Numeric(14, 2), nullable=False, default=0
|
||||||
|
)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<DATEVExport id={self.id} start={self.start_date} end={self.end_date}>"
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
"""Serialize DATEV export for API responses."""
|
||||||
|
return {
|
||||||
|
"id": str(self.id),
|
||||||
|
"start_date": self.start_date.isoformat() if self.start_date else None,
|
||||||
|
"end_date": self.end_date.isoformat() if self.end_date else None,
|
||||||
|
"file_path": self.file_path,
|
||||||
|
"total_amount": (
|
||||||
|
float(self.total_amount) if self.total_amount is not None else None
|
||||||
|
),
|
||||||
|
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"""SQLAlchemy model for vehicle file attachments."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, Integer, String, func
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.models.vehicle import Vehicle
|
||||||
|
|
||||||
|
|
||||||
|
class File(Base):
|
||||||
|
"""File attachment entity linked to a vehicle."""
|
||||||
|
|
||||||
|
__tablename__ = "files"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
primary_key=True,
|
||||||
|
default=uuid.uuid4,
|
||||||
|
)
|
||||||
|
vehicle_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("vehicles.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
original_filename: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
stored_filename: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
file_path: Mapped[str] = mapped_column(String(512), nullable=False)
|
||||||
|
mime_type: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
file_size: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
thumbnail_path: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=func.now(),
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=func.now(),
|
||||||
|
onupdate=func.now(),
|
||||||
|
)
|
||||||
|
|
||||||
|
vehicle: Mapped["Vehicle"] = relationship("Vehicle", back_populates="files")
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<File id={self.id} vehicle_id={self.vehicle_id} filename={self.original_filename}>"
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
"""Serialize file for API responses."""
|
||||||
|
return {
|
||||||
|
"id": str(self.id),
|
||||||
|
"vehicle_id": str(self.vehicle_id),
|
||||||
|
"original_filename": self.original_filename,
|
||||||
|
"stored_filename": self.stored_filename,
|
||||||
|
"file_path": self.file_path,
|
||||||
|
"mime_type": self.mime_type,
|
||||||
|
"file_size": self.file_size,
|
||||||
|
"thumbnail_path": self.thumbnail_path,
|
||||||
|
"created_at": (self.created_at.isoformat() if self.created_at else None),
|
||||||
|
"updated_at": (self.updated_at.isoformat() if self.updated_at else None),
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
"""SQLAlchemy model for OCR results."""
|
||||||
|
|
||||||
|
import enum
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, Enum, Float, ForeignKey, String, Text, func
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class OCRStatus(str, enum.Enum):
|
||||||
|
pending = "pending"
|
||||||
|
processing = "processing"
|
||||||
|
completed = "completed"
|
||||||
|
failed = "failed"
|
||||||
|
manual_review = "manual_review"
|
||||||
|
|
||||||
|
|
||||||
|
class OCRResult(Base):
|
||||||
|
"""OCR result entity linked to a vehicle (optional) and an uploaded scan file."""
|
||||||
|
|
||||||
|
__tablename__ = "ocr_results"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
primary_key=True,
|
||||||
|
default=uuid.uuid4,
|
||||||
|
)
|
||||||
|
vehicle_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("vehicles.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
file_path: Mapped[str] = mapped_column(String(512), nullable=False)
|
||||||
|
file_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
mime_type: Mapped[str] = mapped_column(
|
||||||
|
String(100), nullable=False, default="image/png"
|
||||||
|
)
|
||||||
|
status: Mapped[str] = mapped_column(
|
||||||
|
Enum(OCRStatus, name="ocr_status", create_constraint=True),
|
||||||
|
nullable=False,
|
||||||
|
default=OCRStatus.pending,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
raw_text: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
structured_data: Mapped[dict[str, Any] | None] = mapped_column(
|
||||||
|
JSONB,
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
confidence_score: Mapped[float | None] = mapped_column(
|
||||||
|
Float,
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=func.now(),
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=func.now(),
|
||||||
|
onupdate=func.now(),
|
||||||
|
)
|
||||||
|
|
||||||
|
vehicle = relationship("Vehicle", backref="ocr_results")
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<OCRResult id={self.id} status={self.status}>"
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
"""Serialize OCR result for API responses."""
|
||||||
|
return {
|
||||||
|
"id": str(self.id),
|
||||||
|
"vehicle_id": str(self.vehicle_id) if self.vehicle_id else None,
|
||||||
|
"file_path": self.file_path,
|
||||||
|
"file_name": self.file_name,
|
||||||
|
"mime_type": self.mime_type,
|
||||||
|
"status": self.status.value
|
||||||
|
if isinstance(self.status, OCRStatus)
|
||||||
|
else str(self.status),
|
||||||
|
"raw_text": self.raw_text,
|
||||||
|
"structured_data": self.structured_data,
|
||||||
|
"confidence_score": self.confidence_score,
|
||||||
|
"error_message": self.error_message,
|
||||||
|
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||||
|
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""SQLAlchemy model for image retouch results."""
|
||||||
|
|
||||||
|
import enum
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, String, Text, func
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.models.vehicle import Vehicle
|
||||||
|
|
||||||
|
|
||||||
|
class RetouchStatus(str, enum.Enum):
|
||||||
|
pending = "pending"
|
||||||
|
processing = "processing"
|
||||||
|
completed = "completed"
|
||||||
|
failed = "failed"
|
||||||
|
|
||||||
|
|
||||||
|
class RetouchResult(Base):
|
||||||
|
"""Retouch result entity tracking image retouching via Flux.1-Pro."""
|
||||||
|
|
||||||
|
__tablename__ = "retouch_results"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
primary_key=True,
|
||||||
|
default=uuid.uuid4,
|
||||||
|
)
|
||||||
|
vehicle_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("vehicles.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
original_file_path: Mapped[str] = mapped_column(String(512), nullable=False)
|
||||||
|
original_file_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
mime_type: Mapped[str] = mapped_column(
|
||||||
|
String(100), nullable=False, default="image/png"
|
||||||
|
)
|
||||||
|
retouched_file_path: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||||
|
status: Mapped[str] = mapped_column(String(20), nullable=False, default="pending")
|
||||||
|
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=func.now(),
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=func.now(),
|
||||||
|
onupdate=func.now(),
|
||||||
|
)
|
||||||
|
|
||||||
|
vehicle: Mapped["Vehicle"] = relationship("Vehicle")
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<RetouchResult id={self.id} status={self.status}>"
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
"""Serialize retouch result for API responses."""
|
||||||
|
return {
|
||||||
|
"id": str(self.id),
|
||||||
|
"vehicle_id": str(self.vehicle_id) if self.vehicle_id else None,
|
||||||
|
"original_file_path": self.original_file_path,
|
||||||
|
"original_file_name": self.original_file_name,
|
||||||
|
"mime_type": self.mime_type,
|
||||||
|
"retouched_file_path": self.retouched_file_path,
|
||||||
|
"status": self.status,
|
||||||
|
"error_message": self.error_message,
|
||||||
|
"created_at": (self.created_at.isoformat() if self.created_at else None),
|
||||||
|
"updated_at": (self.updated_at.isoformat() if self.updated_at else None),
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
"""SQLAlchemy model for sales (Verkauf)."""
|
||||||
|
|
||||||
|
import enum
|
||||||
|
import uuid
|
||||||
|
from datetime import date, datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
Boolean,
|
||||||
|
CheckConstraint,
|
||||||
|
Date,
|
||||||
|
DateTime,
|
||||||
|
ForeignKey,
|
||||||
|
Numeric,
|
||||||
|
String,
|
||||||
|
func,
|
||||||
|
)
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.models.contact import Contact
|
||||||
|
from app.models.vehicle import Vehicle
|
||||||
|
|
||||||
|
|
||||||
|
class SaleStatus(str, enum.Enum):
|
||||||
|
draft = "draft"
|
||||||
|
completed = "completed"
|
||||||
|
cancelled = "cancelled"
|
||||||
|
|
||||||
|
|
||||||
|
class Sale(Base):
|
||||||
|
"""Sale entity linking a vehicle to a buyer (and optionally a seller)."""
|
||||||
|
|
||||||
|
__tablename__ = "sales"
|
||||||
|
__table_args__ = (
|
||||||
|
CheckConstraint(
|
||||||
|
"status IN ('draft', 'completed', 'cancelled')",
|
||||||
|
name="ck_sales_status",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
primary_key=True,
|
||||||
|
default=uuid.uuid4,
|
||||||
|
)
|
||||||
|
vehicle_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("vehicles.id", ondelete="RESTRICT"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
buyer_contact_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("contacts.id", ondelete="RESTRICT"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
seller_contact_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("contacts.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
sale_price: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False)
|
||||||
|
sale_date: Mapped[date] = mapped_column(
|
||||||
|
Date, nullable=False, default=func.current_date()
|
||||||
|
)
|
||||||
|
status: Mapped[str] = mapped_column(String(20), nullable=False, default="draft")
|
||||||
|
is_gwg: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
contract_pdf_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=func.now(),
|
||||||
|
onupdate=func.now(),
|
||||||
|
)
|
||||||
|
|
||||||
|
vehicle: Mapped["Vehicle"] = relationship(lazy="selectin")
|
||||||
|
buyer: Mapped["Contact"] = relationship(
|
||||||
|
"Contact",
|
||||||
|
foreign_keys=[buyer_contact_id],
|
||||||
|
lazy="selectin",
|
||||||
|
)
|
||||||
|
seller: Mapped["Contact | None"] = relationship(
|
||||||
|
"Contact",
|
||||||
|
foreign_keys=[seller_contact_id],
|
||||||
|
lazy="selectin",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<Sale id={self.id} vehicle_id={self.vehicle_id} status={self.status}>"
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
"""Serialize sale for API responses."""
|
||||||
|
return {
|
||||||
|
"id": str(self.id),
|
||||||
|
"vehicle_id": str(self.vehicle_id),
|
||||||
|
"buyer_contact_id": str(self.buyer_contact_id),
|
||||||
|
"seller_contact_id": (
|
||||||
|
str(self.seller_contact_id) if self.seller_contact_id else None
|
||||||
|
),
|
||||||
|
"sale_price": float(self.sale_price)
|
||||||
|
if self.sale_price is not None
|
||||||
|
else None,
|
||||||
|
"sale_date": self.sale_date.isoformat() if self.sale_date else None,
|
||||||
|
"status": self.status,
|
||||||
|
"is_gwg": self.is_gwg,
|
||||||
|
"contract_pdf_path": self.contract_pdf_path,
|
||||||
|
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||||
|
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"""SQLAlchemy User model with UUID primary key."""
|
||||||
|
|
||||||
|
import enum
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, DateTime, Enum, String, func
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class UserRole(str, enum.Enum):
|
||||||
|
"""User roles for RBAC."""
|
||||||
|
|
||||||
|
admin = "admin"
|
||||||
|
verkaeufer = "verkaeufer"
|
||||||
|
buchhaltung = "buchhaltung"
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
"""User entity for authentication and authorization."""
|
||||||
|
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
primary_key=True,
|
||||||
|
default=uuid.uuid4,
|
||||||
|
)
|
||||||
|
email: Mapped[str] = mapped_column(
|
||||||
|
String(255),
|
||||||
|
unique=True,
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
password_hash: Mapped[str] = mapped_column(
|
||||||
|
String(255),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
full_name: Mapped[str] = mapped_column(
|
||||||
|
String(200),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
role: Mapped[UserRole] = mapped_column(
|
||||||
|
Enum(UserRole, name="user_role"),
|
||||||
|
nullable=False,
|
||||||
|
default=UserRole.verkaeufer,
|
||||||
|
)
|
||||||
|
language: Mapped[str] = mapped_column(
|
||||||
|
String(5),
|
||||||
|
nullable=False,
|
||||||
|
default="de",
|
||||||
|
)
|
||||||
|
is_active: Mapped[bool] = mapped_column(
|
||||||
|
Boolean,
|
||||||
|
nullable=False,
|
||||||
|
default=True,
|
||||||
|
)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=func.now(),
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=func.now(),
|
||||||
|
onupdate=func.now(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<User id={self.id} email={self.email} role={self.role}>"
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
"""Serialize user for API responses (excludes password_hash)."""
|
||||||
|
return {
|
||||||
|
"id": str(self.id),
|
||||||
|
"email": self.email,
|
||||||
|
"full_name": self.full_name,
|
||||||
|
"role": self.role.value if isinstance(self.role, UserRole) else self.role,
|
||||||
|
"language": self.language,
|
||||||
|
"is_active": self.is_active,
|
||||||
|
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||||
|
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||||
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
"""SQLAlchemy models for vehicles and mobile.de listings."""
|
||||||
|
|
||||||
|
import enum
|
||||||
|
import uuid
|
||||||
|
from datetime import date, datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
CheckConstraint,
|
||||||
|
Date,
|
||||||
|
DateTime,
|
||||||
|
ForeignKey,
|
||||||
|
Integer,
|
||||||
|
Numeric,
|
||||||
|
String,
|
||||||
|
Text,
|
||||||
|
func,
|
||||||
|
)
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.models.file import File
|
||||||
|
|
||||||
|
|
||||||
|
class VehicleCondition(str, enum.Enum):
|
||||||
|
new = "new"
|
||||||
|
used = "used"
|
||||||
|
|
||||||
|
|
||||||
|
class VehicleAvailability(str, enum.Enum):
|
||||||
|
available = "available"
|
||||||
|
reserved = "reserved"
|
||||||
|
sold = "sold"
|
||||||
|
|
||||||
|
|
||||||
|
class VehicleType(str, enum.Enum):
|
||||||
|
lkw = "lkw"
|
||||||
|
pkw = "pkw"
|
||||||
|
baumaschine = "baumaschine"
|
||||||
|
stapler = "stapler"
|
||||||
|
transporter = "transporter"
|
||||||
|
|
||||||
|
|
||||||
|
class SyncStatus(str, enum.Enum):
|
||||||
|
pending = "pending"
|
||||||
|
synced = "synced"
|
||||||
|
fehler = "fehler"
|
||||||
|
|
||||||
|
|
||||||
|
class Vehicle(Base):
|
||||||
|
"""Vehicle entity for the ERP inventory."""
|
||||||
|
|
||||||
|
__tablename__ = "vehicles"
|
||||||
|
__table_args__ = (
|
||||||
|
CheckConstraint("char_length(fin) = 17", name="ck_vehicles_fin_length"),
|
||||||
|
CheckConstraint("condition IN ('new', 'used')", name="ck_vehicles_condition"),
|
||||||
|
CheckConstraint(
|
||||||
|
"availability IN ('available', 'reserved', 'sold')",
|
||||||
|
name="ck_vehicles_availability",
|
||||||
|
),
|
||||||
|
CheckConstraint(
|
||||||
|
"vehicle_type IN ('lkw', 'pkw', 'baumaschine', 'stapler', 'transporter')",
|
||||||
|
name="ck_vehicles_vehicle_type",
|
||||||
|
),
|
||||||
|
CheckConstraint(
|
||||||
|
"operating_hours_unit IS NULL OR operating_hours_unit IN ('h', 'min')",
|
||||||
|
name="ck_vehicles_operating_hours_unit",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
primary_key=True,
|
||||||
|
default=uuid.uuid4,
|
||||||
|
)
|
||||||
|
make: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
model: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
fin: Mapped[str] = mapped_column(
|
||||||
|
String(17), unique=True, nullable=False, index=True
|
||||||
|
)
|
||||||
|
year: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
first_registration: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||||
|
power_kw: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
power_hp: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
fuel_type: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||||
|
transmission: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||||
|
color: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||||
|
condition: Mapped[str] = mapped_column(String(20), nullable=False, default="used")
|
||||||
|
location: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
availability: Mapped[str] = mapped_column(
|
||||||
|
String(20), nullable=False, default="available"
|
||||||
|
)
|
||||||
|
price: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False)
|
||||||
|
vehicle_type: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||||
|
lkw_type: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||||
|
machine_type: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||||
|
body_type: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
operating_hours: Mapped[Decimal | None] = mapped_column(
|
||||||
|
Numeric(12, 1), nullable=True
|
||||||
|
)
|
||||||
|
operating_hours_unit: Mapped[str | None] = mapped_column(String(5), nullable=True)
|
||||||
|
mileage_km: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=func.now(),
|
||||||
|
onupdate=func.now(),
|
||||||
|
)
|
||||||
|
deleted_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
|
mobile_de_listings: Mapped[list["MobileDeListing"]] = relationship(
|
||||||
|
back_populates="vehicle", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
files: Mapped[list["File"]] = relationship(
|
||||||
|
"File", back_populates="vehicle", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<Vehicle id={self.id} fin={self.fin} make={self.make}>"
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
"""Serialize vehicle for API responses."""
|
||||||
|
return {
|
||||||
|
"id": str(self.id),
|
||||||
|
"make": self.make,
|
||||||
|
"model": self.model,
|
||||||
|
"fin": self.fin,
|
||||||
|
"year": self.year,
|
||||||
|
"first_registration": (
|
||||||
|
self.first_registration.isoformat() if self.first_registration else None
|
||||||
|
),
|
||||||
|
"power_kw": self.power_kw,
|
||||||
|
"power_hp": self.power_hp,
|
||||||
|
"fuel_type": self.fuel_type,
|
||||||
|
"transmission": self.transmission,
|
||||||
|
"color": self.color,
|
||||||
|
"condition": self.condition,
|
||||||
|
"location": self.location,
|
||||||
|
"availability": self.availability,
|
||||||
|
"price": float(self.price) if self.price is not None else None,
|
||||||
|
"vehicle_type": self.vehicle_type,
|
||||||
|
"lkw_type": self.lkw_type,
|
||||||
|
"machine_type": self.machine_type,
|
||||||
|
"body_type": self.body_type,
|
||||||
|
"operating_hours": (
|
||||||
|
float(self.operating_hours)
|
||||||
|
if self.operating_hours is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"operating_hours_unit": self.operating_hours_unit,
|
||||||
|
"mileage_km": self.mileage_km,
|
||||||
|
"description": self.description,
|
||||||
|
"created_at": (self.created_at.isoformat() if self.created_at else None),
|
||||||
|
"updated_at": (self.updated_at.isoformat() if self.updated_at else None),
|
||||||
|
"deleted_at": (self.deleted_at.isoformat() if self.deleted_at else None),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class MobileDeListing(Base):
|
||||||
|
"""mobile.de listing sync tracking for a vehicle."""
|
||||||
|
|
||||||
|
__tablename__ = "mobile_de_listings"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
primary_key=True,
|
||||||
|
default=uuid.uuid4,
|
||||||
|
)
|
||||||
|
vehicle_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("vehicles.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
ad_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
sync_status: Mapped[str] = mapped_column(
|
||||||
|
String(20), nullable=False, default="pending"
|
||||||
|
)
|
||||||
|
synced_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
error_log: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=func.now(),
|
||||||
|
onupdate=func.now(),
|
||||||
|
)
|
||||||
|
|
||||||
|
vehicle: Mapped["Vehicle"] = relationship(back_populates="mobile_de_listings")
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<MobileDeListing id={self.id} vehicle_id={self.vehicle_id} status={self.sync_status}>"
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
"""Serialize listing for API responses."""
|
||||||
|
return {
|
||||||
|
"id": str(self.id),
|
||||||
|
"vehicle_id": str(self.vehicle_id),
|
||||||
|
"ad_id": self.ad_id,
|
||||||
|
"sync_status": self.sync_status,
|
||||||
|
"synced_at": (self.synced_at.isoformat() if self.synced_at else None),
|
||||||
|
"error_log": self.error_log,
|
||||||
|
"created_at": (self.created_at.isoformat() if self.created_at else None),
|
||||||
|
"updated_at": (self.updated_at.isoformat() if self.updated_at else None),
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"""Auth router: login, token refresh, current user profile."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.dependencies import get_current_user
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.user import (
|
||||||
|
LoginRequest,
|
||||||
|
RefreshRequest,
|
||||||
|
TokenResponse,
|
||||||
|
UserResponse,
|
||||||
|
)
|
||||||
|
from app.services.auth_service import (
|
||||||
|
authenticate_user,
|
||||||
|
generate_token_pair,
|
||||||
|
refresh_access_token,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login", response_model=TokenResponse, status_code=status.HTTP_200_OK)
|
||||||
|
async def login(
|
||||||
|
body: LoginRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Authenticate user and return JWT access + refresh tokens."""
|
||||||
|
user = await authenticate_user(db, body.email, body.password)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail={
|
||||||
|
"error": {
|
||||||
|
"code": "INVALID_CREDENTIALS",
|
||||||
|
"message": "Invalid email or password",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
tokens = generate_token_pair(user)
|
||||||
|
return TokenResponse(**tokens)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/refresh", response_model=TokenResponse, status_code=status.HTTP_200_OK)
|
||||||
|
async def refresh_token(
|
||||||
|
body: RefreshRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Exchange a valid refresh token for a new access + refresh token pair."""
|
||||||
|
tokens = await refresh_access_token(db, body.refresh_token)
|
||||||
|
if tokens is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail={
|
||||||
|
"error": {
|
||||||
|
"code": "INVALID_REFRESH_TOKEN",
|
||||||
|
"message": "Invalid or expired refresh token",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return TokenResponse(**tokens)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me", response_model=UserResponse, status_code=status.HTTP_200_OK)
|
||||||
|
async def get_me(current_user: User = Depends(get_current_user)):
|
||||||
|
"""Return the currently authenticated user's profile."""
|
||||||
|
return UserResponse.model_validate(current_user)
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
"""Contacts router: CRUD, search, filter, and contact person management endpoints."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.dependencies import get_current_user, get_pagination, require_role
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.contact import (
|
||||||
|
ContactCreate,
|
||||||
|
ContactListResponse,
|
||||||
|
ContactPersonCreate,
|
||||||
|
ContactPersonResponse,
|
||||||
|
ContactResponse,
|
||||||
|
ContactUpdate,
|
||||||
|
)
|
||||||
|
from app.services import contact_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/contacts", tags=["contacts"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=ContactListResponse, status_code=status.HTTP_200_OK)
|
||||||
|
async def list_contacts(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
pagination: dict = Depends(get_pagination),
|
||||||
|
search: str | None = Query(
|
||||||
|
None, description="Search in company_name, city, email, vat_id"
|
||||||
|
),
|
||||||
|
role: str | None = Query(
|
||||||
|
None, description="Filter by role (kaeufer, verkaeufer, beide)"
|
||||||
|
),
|
||||||
|
is_eu: bool | None = Query(None, description="Filter EU (true) or Inland (false)"),
|
||||||
|
is_private: bool | None = Query(None, description="Filter private contacts"),
|
||||||
|
sort: str | None = Query(None, description="Sort field (prefix - for descending)"),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""List contacts with pagination, filtering, and sorting.
|
||||||
|
|
||||||
|
All authenticated roles can read.
|
||||||
|
"""
|
||||||
|
contacts, total = await contact_service.list_contacts(
|
||||||
|
db,
|
||||||
|
page=pagination["page"],
|
||||||
|
page_size=pagination["page_size"],
|
||||||
|
search=search,
|
||||||
|
role=role,
|
||||||
|
is_eu=is_eu,
|
||||||
|
is_private=is_private,
|
||||||
|
sort=sort,
|
||||||
|
)
|
||||||
|
return ContactListResponse(
|
||||||
|
items=[ContactResponse.model_validate(c) for c in contacts],
|
||||||
|
total=total,
|
||||||
|
page=pagination["page"],
|
||||||
|
page_size=pagination["page_size"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=ContactResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_contact(
|
||||||
|
body: ContactCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_role(["admin", "verkaeufer"])),
|
||||||
|
):
|
||||||
|
"""Create a new contact. Requires admin or verkaeufer role."""
|
||||||
|
data = body.model_dump(exclude_unset=False)
|
||||||
|
contact = await contact_service.create_contact(db, data)
|
||||||
|
return ContactResponse.model_validate(contact)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{contact_id}", response_model=ContactResponse, status_code=status.HTTP_200_OK
|
||||||
|
)
|
||||||
|
async def get_contact(
|
||||||
|
contact_id: uuid.UUID,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Get a single contact by ID with contact persons."""
|
||||||
|
contact = await contact_service.get_contact_by_id(db, contact_id)
|
||||||
|
if contact is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail={
|
||||||
|
"error": {"code": "CONTACT_NOT_FOUND", "message": "Contact not found"}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return ContactResponse.model_validate(contact)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/{contact_id}", response_model=ContactResponse, status_code=status.HTTP_200_OK
|
||||||
|
)
|
||||||
|
async def update_contact(
|
||||||
|
contact_id: uuid.UUID,
|
||||||
|
body: ContactUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_role(["admin", "verkaeufer"])),
|
||||||
|
):
|
||||||
|
"""Update a contact's fields. Requires admin or verkaeufer role."""
|
||||||
|
updates = body.model_dump(exclude_unset=True)
|
||||||
|
if not updates:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail={"error": {"code": "NO_FIELDS", "message": "No fields to update"}},
|
||||||
|
)
|
||||||
|
contact = await contact_service.update_contact(db, contact_id, updates)
|
||||||
|
if contact is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail={
|
||||||
|
"error": {"code": "CONTACT_NOT_FOUND", "message": "Contact not found"}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return ContactResponse.model_validate(contact)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/{contact_id}", response_model=ContactResponse, status_code=status.HTTP_200_OK
|
||||||
|
)
|
||||||
|
async def delete_contact(
|
||||||
|
contact_id: uuid.UUID,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_role(["admin", "verkaeufer"])),
|
||||||
|
):
|
||||||
|
"""Soft-delete a contact (sets deleted_at). Requires admin or verkaeufer role."""
|
||||||
|
contact = await contact_service.soft_delete_contact(db, contact_id)
|
||||||
|
if contact is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail={
|
||||||
|
"error": {"code": "CONTACT_NOT_FOUND", "message": "Contact not found"}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return ContactResponse.model_validate(contact)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{contact_id}/persons",
|
||||||
|
response_model=ContactPersonResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
async def add_contact_person(
|
||||||
|
contact_id: uuid.UUID,
|
||||||
|
body: ContactPersonCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_role(["admin", "verkaeufer"])),
|
||||||
|
):
|
||||||
|
"""Add a contact person to an existing contact. Requires admin or verkaeufer role."""
|
||||||
|
person = await contact_service.add_contact_person(
|
||||||
|
db, contact_id, body.model_dump(exclude_unset=False)
|
||||||
|
)
|
||||||
|
if person is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail={
|
||||||
|
"error": {"code": "CONTACT_NOT_FOUND", "message": "Contact not found"}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return ContactPersonResponse.model_validate(person)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/{contact_id}/persons/{person_id}",
|
||||||
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
|
)
|
||||||
|
async def remove_contact_person(
|
||||||
|
contact_id: uuid.UUID,
|
||||||
|
person_id: uuid.UUID,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_role(["admin", "verkaeufer"])),
|
||||||
|
):
|
||||||
|
"""Remove a contact person from a contact. Requires admin or verkaeufer role."""
|
||||||
|
deleted = await contact_service.remove_contact_person(db, contact_id, person_id)
|
||||||
|
if not deleted:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail={
|
||||||
|
"error": {
|
||||||
|
"code": "PERSON_NOT_FOUND",
|
||||||
|
"message": "Contact person not found",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return None
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
"""Copilot router: chat, action execution, history, and voice endpoints.
|
||||||
|
|
||||||
|
All endpoints require authentication (get_current_user).
|
||||||
|
Actions are proposed by the AI and only executed after user confirmation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.dependencies import get_current_user, get_pagination
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.copilot import (
|
||||||
|
ActionRequest,
|
||||||
|
ActionResponse,
|
||||||
|
ChatHistoryResponse,
|
||||||
|
ChatMessageItem,
|
||||||
|
ChatRequest,
|
||||||
|
ChatResponse,
|
||||||
|
VoiceRequest,
|
||||||
|
VoiceResponse,
|
||||||
|
)
|
||||||
|
from app.services import copilot_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/copilot", tags=["copilot"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/chat", response_model=ChatResponse, status_code=status.HTTP_200_OK)
|
||||||
|
async def copilot_chat(
|
||||||
|
body: ChatRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Send a message to the Copilot and receive a response with action proposals."""
|
||||||
|
try:
|
||||||
|
result = await copilot_service.chat(
|
||||||
|
db,
|
||||||
|
user_id=current_user.id,
|
||||||
|
message=body.message,
|
||||||
|
session_id=body.session_id,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail={"error": {"code": "COPILOT_ERROR", "message": str(exc)}},
|
||||||
|
)
|
||||||
|
return ChatResponse(**result)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/action", response_model=ActionResponse, status_code=status.HTTP_200_OK)
|
||||||
|
async def copilot_action(
|
||||||
|
body: ActionRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Execute a user-confirmed action proposed by the Copilot."""
|
||||||
|
try:
|
||||||
|
result = await copilot_service.execute_confirmed_action(
|
||||||
|
db,
|
||||||
|
action_type=body.action,
|
||||||
|
params=body.params,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail={"error": {"code": "INVALID_ACTION", "message": str(exc)}},
|
||||||
|
)
|
||||||
|
return ActionResponse(**result)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/history", response_model=ChatHistoryResponse, status_code=status.HTTP_200_OK
|
||||||
|
)
|
||||||
|
async def copilot_history(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
pagination: dict = Depends(get_pagination),
|
||||||
|
session_id: str | None = Query(None, description="Filter by session ID"),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Get paginated chat history for the current user."""
|
||||||
|
messages, total = await copilot_service.get_history(
|
||||||
|
db,
|
||||||
|
user_id=current_user.id,
|
||||||
|
page=pagination["page"],
|
||||||
|
page_size=pagination["page_size"],
|
||||||
|
session_id=session_id,
|
||||||
|
)
|
||||||
|
return ChatHistoryResponse(
|
||||||
|
items=[ChatMessageItem.model_validate(m.to_dict()) for m in messages],
|
||||||
|
total=total,
|
||||||
|
page=pagination["page"],
|
||||||
|
page_size=pagination["page_size"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/voice", response_model=VoiceResponse, status_code=status.HTTP_200_OK)
|
||||||
|
async def copilot_voice(
|
||||||
|
body: VoiceRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Accept audio, transcribe, and return a chat response with action proposals."""
|
||||||
|
try:
|
||||||
|
result = await copilot_service.voice_chat(
|
||||||
|
db,
|
||||||
|
user_id=current_user.id,
|
||||||
|
audio_b64=body.audio,
|
||||||
|
mime_type=body.mime_type,
|
||||||
|
session_id=body.session_id,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail={"error": {"code": "VOICE_ERROR", "message": str(exc)}},
|
||||||
|
)
|
||||||
|
return VoiceResponse(**result)
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"""DATEV export router: create exports, list exports, download CSV."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from fastapi.responses import Response
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.dependencies import get_current_user, get_pagination
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.datev import (
|
||||||
|
DATEVExportCreate,
|
||||||
|
DATEVExportListResponse,
|
||||||
|
DATEVExportResponse,
|
||||||
|
)
|
||||||
|
from app.services import datev_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/datev", tags=["datev"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/export", response_model=DATEVExportResponse, status_code=status.HTTP_201_CREATED
|
||||||
|
)
|
||||||
|
async def create_export(
|
||||||
|
body: DATEVExportCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Create a DATEV export for the given date range.
|
||||||
|
|
||||||
|
Queries all completed sales within the date range and generates a CSV file.
|
||||||
|
"""
|
||||||
|
export = await datev_service.create_export(
|
||||||
|
db,
|
||||||
|
start_date=body.start_date,
|
||||||
|
end_date=body.end_date,
|
||||||
|
)
|
||||||
|
return DATEVExportResponse.model_validate(export)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/exports", response_model=DATEVExportListResponse, status_code=status.HTTP_200_OK
|
||||||
|
)
|
||||||
|
async def list_exports(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
pagination: dict = Depends(get_pagination),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""List all DATEV exports with pagination."""
|
||||||
|
exports, total = await datev_service.list_exports(
|
||||||
|
db,
|
||||||
|
page=pagination["page"],
|
||||||
|
page_size=pagination["page_size"],
|
||||||
|
)
|
||||||
|
return DATEVExportListResponse(
|
||||||
|
items=[DATEVExportResponse.model_validate(e) for e in exports],
|
||||||
|
total=total,
|
||||||
|
page=pagination["page"],
|
||||||
|
page_size=pagination["page_size"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/exports/{export_id}/download", status_code=status.HTTP_200_OK)
|
||||||
|
async def download_export(
|
||||||
|
export_id: uuid.UUID,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Download a DATEV export as CSV file."""
|
||||||
|
result = await datev_service.get_export_csv(db, export_id)
|
||||||
|
if result is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail={
|
||||||
|
"error": {
|
||||||
|
"code": "EXPORT_NOT_FOUND",
|
||||||
|
"message": "DATEV export not found",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
filename, csv_bytes = result
|
||||||
|
return Response(
|
||||||
|
content=csv_bytes,
|
||||||
|
media_type="text/csv",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||||
|
)
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
"""Files router: upload, list, download, and delete files for vehicles."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import (
|
||||||
|
APIRouter,
|
||||||
|
Depends,
|
||||||
|
File as FastAPIFile,
|
||||||
|
HTTPException,
|
||||||
|
UploadFile,
|
||||||
|
status,
|
||||||
|
)
|
||||||
|
from fastapi.responses import FileResponse as FastAPIFileResponse
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.dependencies import get_current_user, get_pagination
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.vehicle import Vehicle
|
||||||
|
from app.schemas.file import (
|
||||||
|
FileDeleteResponse,
|
||||||
|
FileListResponse,
|
||||||
|
FileResponse,
|
||||||
|
FileUploadResponse,
|
||||||
|
)
|
||||||
|
from app.services import file_service, vehicle_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/vehicles", tags=["files"])
|
||||||
|
|
||||||
|
|
||||||
|
async def _verify_vehicle_exists(db: AsyncSession, vehicle_id: uuid.UUID) -> Vehicle:
|
||||||
|
"""Verify that a vehicle exists, raising 404 if not."""
|
||||||
|
vehicle = await vehicle_service.get_vehicle_by_id(db, vehicle_id)
|
||||||
|
if vehicle is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail={
|
||||||
|
"error": {
|
||||||
|
"code": "VEHICLE_NOT_FOUND",
|
||||||
|
"message": "Vehicle not found",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return vehicle
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{vehicle_id}/files",
|
||||||
|
response_model=FileListResponse,
|
||||||
|
status_code=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
async def list_files(
|
||||||
|
vehicle_id: uuid.UUID,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
pagination: dict = Depends(get_pagination),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""List all files for a vehicle with pagination."""
|
||||||
|
await _verify_vehicle_exists(db, vehicle_id)
|
||||||
|
|
||||||
|
files, total = await file_service.list_files(
|
||||||
|
db,
|
||||||
|
vehicle_id=vehicle_id,
|
||||||
|
page=pagination["page"],
|
||||||
|
page_size=pagination["page_size"],
|
||||||
|
)
|
||||||
|
return FileListResponse(
|
||||||
|
items=[FileResponse.model_validate(f) for f in files],
|
||||||
|
total=total,
|
||||||
|
page=pagination["page"],
|
||||||
|
page_size=pagination["page_size"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{vehicle_id}/files",
|
||||||
|
response_model=FileUploadResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
async def upload_file(
|
||||||
|
vehicle_id: uuid.UUID,
|
||||||
|
file: UploadFile = FastAPIFile(...),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Upload a file for a vehicle (multipart/form-data).
|
||||||
|
|
||||||
|
Accepts images (jpg, png, webp), documents (pdf, doc, docx).
|
||||||
|
Max file size: 20MB.
|
||||||
|
"""
|
||||||
|
await _verify_vehicle_exists(db, vehicle_id)
|
||||||
|
|
||||||
|
# Read file content
|
||||||
|
file_content = await file.read()
|
||||||
|
file_size = len(file_content)
|
||||||
|
|
||||||
|
# Check file size (20MB limit)
|
||||||
|
if not file_service.validate_file_size(file_size, max_size_mb=20):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||||
|
detail={
|
||||||
|
"error": {
|
||||||
|
"code": "FILE_TOO_LARGE",
|
||||||
|
"message": f"File size {file_size} bytes exceeds 20MB limit",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate MIME type
|
||||||
|
mime_type = file.content_type or ""
|
||||||
|
if not file_service.validate_mime_type(mime_type, file.filename or ""):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail={
|
||||||
|
"error": {
|
||||||
|
"code": "INVALID_MIME_TYPE",
|
||||||
|
"message": f"Unsupported file type: {mime_type}. Allowed: jpg, png, webp, pdf, doc, docx",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
file_record = await file_service.upload_file(
|
||||||
|
db,
|
||||||
|
vehicle_id=vehicle_id,
|
||||||
|
file_content=file_content,
|
||||||
|
original_filename=file.filename or "unnamed",
|
||||||
|
mime_type=mime_type,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail={
|
||||||
|
"error": {
|
||||||
|
"code": "UPLOAD_FAILED",
|
||||||
|
"message": str(exc),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return FileUploadResponse.model_validate(file_record)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{vehicle_id}/files/{file_id}",
|
||||||
|
status_code=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
async def download_file(
|
||||||
|
vehicle_id: uuid.UUID,
|
||||||
|
file_id: uuid.UUID,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Download a file by its ID."""
|
||||||
|
await _verify_vehicle_exists(db, vehicle_id)
|
||||||
|
|
||||||
|
file_record = await file_service.get_file(db, vehicle_id, file_id)
|
||||||
|
if file_record is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail={
|
||||||
|
"error": {
|
||||||
|
"code": "FILE_NOT_FOUND",
|
||||||
|
"message": "File not found",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if not os.path.exists(file_record.file_path):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail={
|
||||||
|
"error": {
|
||||||
|
"code": "FILE_NOT_FOUND",
|
||||||
|
"message": "File not found on disk",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return FastAPIFileResponse(
|
||||||
|
path=file_record.file_path,
|
||||||
|
filename=file_record.original_filename,
|
||||||
|
media_type=file_record.mime_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/{vehicle_id}/files/{file_id}",
|
||||||
|
response_model=FileDeleteResponse,
|
||||||
|
status_code=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
async def delete_file(
|
||||||
|
vehicle_id: uuid.UUID,
|
||||||
|
file_id: uuid.UUID,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Delete a file by its ID."""
|
||||||
|
await _verify_vehicle_exists(db, vehicle_id)
|
||||||
|
|
||||||
|
file_record = await file_service.delete_file(db, vehicle_id, file_id)
|
||||||
|
if file_record is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail={
|
||||||
|
"error": {
|
||||||
|
"code": "FILE_NOT_FOUND",
|
||||||
|
"message": "File not found",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return FileDeleteResponse(message="File deleted", id=file_record.id)
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
"""Image retouch router: process images, get results, price comparison."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import (
|
||||||
|
APIRouter,
|
||||||
|
BackgroundTasks,
|
||||||
|
Depends,
|
||||||
|
File,
|
||||||
|
Form,
|
||||||
|
HTTPException,
|
||||||
|
UploadFile,
|
||||||
|
status,
|
||||||
|
)
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.database import get_db
|
||||||
|
from app.dependencies import get_current_user
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.retouch import RetouchStatus
|
||||||
|
from app.schemas.retouch import (
|
||||||
|
PriceCompareRequest,
|
||||||
|
PriceCompareResponse,
|
||||||
|
RetouchProcessResponse,
|
||||||
|
RetouchResultResponse,
|
||||||
|
)
|
||||||
|
from app.services import retouch_service, price_compare_service
|
||||||
|
from app.services.vehicle_service import get_vehicle_by_id
|
||||||
|
from app.tasks.retouch_processing import run_retouch_processing
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/retouch", tags=["retouch"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/process",
|
||||||
|
response_model=RetouchProcessResponse,
|
||||||
|
status_code=status.HTTP_202_ACCEPTED,
|
||||||
|
)
|
||||||
|
async def process_image(
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
file: UploadFile = File(..., description="Image file to retouch"),
|
||||||
|
vehicle_id: str | None = Form(None, description="Optional vehicle ID to link"),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Upload an image file for retouching via Flux.1-Pro.
|
||||||
|
|
||||||
|
Returns 202 with retouch_id. Processing happens asynchronously.
|
||||||
|
"""
|
||||||
|
if not file or not file.filename:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail={"error": {"code": "NO_FILE", "message": "No file provided"}},
|
||||||
|
)
|
||||||
|
|
||||||
|
mime_type = file.content_type or ""
|
||||||
|
if not retouch_service.validate_mime_type(mime_type):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail={
|
||||||
|
"error": {
|
||||||
|
"code": "INVALID_MIME_TYPE",
|
||||||
|
"message": f"Invalid MIME type: {mime_type}. Only image/* types are allowed.",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
file_bytes = await file.read()
|
||||||
|
|
||||||
|
if not retouch_service.validate_file_size(len(file_bytes)):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail={
|
||||||
|
"error": {
|
||||||
|
"code": "FILE_TOO_LARGE",
|
||||||
|
"message": f"File size exceeds limit of {settings.MAX_FILE_SIZE_MB} MB",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Parse optional vehicle_id
|
||||||
|
parsed_vehicle_id: uuid.UUID | None = None
|
||||||
|
vehicle_info = None
|
||||||
|
if vehicle_id:
|
||||||
|
try:
|
||||||
|
parsed_vehicle_id = uuid.UUID(vehicle_id)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail={
|
||||||
|
"error": {
|
||||||
|
"code": "INVALID_VEHICLE_ID",
|
||||||
|
"message": "Invalid vehicle UUID",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fetch vehicle info for better retouch prompt
|
||||||
|
vehicle = await get_vehicle_by_id(db, parsed_vehicle_id)
|
||||||
|
if vehicle:
|
||||||
|
vehicle_info = {
|
||||||
|
"make": vehicle.make,
|
||||||
|
"model": vehicle.model,
|
||||||
|
"year": vehicle.year,
|
||||||
|
"color": vehicle.color,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await retouch_service.upload_retouch_file(
|
||||||
|
db=db,
|
||||||
|
file_bytes=file_bytes,
|
||||||
|
file_name=file.filename or "upload.png",
|
||||||
|
mime_type=mime_type,
|
||||||
|
vehicle_id=parsed_vehicle_id,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail={"error": {"code": "UPLOAD_FAILED", "message": str(exc)}},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Queue background processing
|
||||||
|
background_tasks.add_task(run_retouch_processing, result.id, vehicle_info)
|
||||||
|
|
||||||
|
return RetouchProcessResponse(
|
||||||
|
message="Retouch processing queued",
|
||||||
|
retouch_id=result.id,
|
||||||
|
status=result.status.value
|
||||||
|
if isinstance(result.status, RetouchStatus)
|
||||||
|
else str(result.status),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/results/{result_id}",
|
||||||
|
response_model=RetouchResultResponse,
|
||||||
|
status_code=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
async def get_retouch_result(
|
||||||
|
result_id: uuid.UUID,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Get a single retouch result by ID.
|
||||||
|
|
||||||
|
Returns status, original and retouched file paths.
|
||||||
|
If processing is still ongoing, status will be 'processing'.
|
||||||
|
If processing failed, status will be 'failed' with error_message.
|
||||||
|
"""
|
||||||
|
result = await retouch_service.get_result(db, result_id)
|
||||||
|
if result is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail={
|
||||||
|
"error": {
|
||||||
|
"code": "RETOUCH_NOT_FOUND",
|
||||||
|
"message": "Retouch result not found",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return RetouchResultResponse.model_validate(result)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/price-compare",
|
||||||
|
response_model=PriceCompareResponse,
|
||||||
|
status_code=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
async def price_compare(
|
||||||
|
body: PriceCompareRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Compare prices for a vehicle by searching comparable listings.
|
||||||
|
|
||||||
|
Returns comparable listings and average price.
|
||||||
|
If no comparable listings found, returns empty list with null average.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = await price_compare_service.compare_prices(db, body.vehicle_id)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail={"error": {"code": "VEHICLE_NOT_FOUND", "message": str(exc)}},
|
||||||
|
)
|
||||||
|
return result
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
"""OCR router: upload, get results, list results, apply to vehicle."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import (
|
||||||
|
APIRouter,
|
||||||
|
BackgroundTasks,
|
||||||
|
Depends,
|
||||||
|
File,
|
||||||
|
Form,
|
||||||
|
HTTPException,
|
||||||
|
Query,
|
||||||
|
UploadFile,
|
||||||
|
status,
|
||||||
|
)
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.database import get_db
|
||||||
|
from app.dependencies import get_current_user
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.ocr_result import OCRStatus
|
||||||
|
from app.schemas.ocr import (
|
||||||
|
OCRApplyResponse,
|
||||||
|
OCRResultListResponse,
|
||||||
|
OCRResultResponse,
|
||||||
|
OCRUploadResponse,
|
||||||
|
)
|
||||||
|
from app.services import ocr_service
|
||||||
|
from app.tasks.ocr_processing import run_ocr_processing
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/ocr", tags=["ocr"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/upload",
|
||||||
|
response_model=OCRUploadResponse,
|
||||||
|
status_code=status.HTTP_202_ACCEPTED,
|
||||||
|
)
|
||||||
|
async def upload_scan(
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
file: UploadFile = File(..., description="Image file to OCR"),
|
||||||
|
vehicle_id: str | None = Form(None, description="Optional vehicle ID to link"),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Upload an image file for OCR processing.
|
||||||
|
|
||||||
|
Returns 202 with ocr_result_id. Processing happens asynchronously.
|
||||||
|
"""
|
||||||
|
if not file or not file.filename:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail={"error": {"code": "NO_FILE", "message": "No file provided"}},
|
||||||
|
)
|
||||||
|
|
||||||
|
mime_type = file.content_type or ""
|
||||||
|
if not ocr_service.validate_mime_type(mime_type):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail={
|
||||||
|
"error": {
|
||||||
|
"code": "INVALID_MIME_TYPE",
|
||||||
|
"message": f"Invalid MIME type: {mime_type}. Only image/* types are allowed.",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Read file content
|
||||||
|
file_bytes = await file.read()
|
||||||
|
|
||||||
|
if not ocr_service.validate_file_size(len(file_bytes)):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail={
|
||||||
|
"error": {
|
||||||
|
"code": "FILE_TOO_LARGE",
|
||||||
|
"message": f"File size exceeds limit of {settings.MAX_FILE_SIZE_MB} MB",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Parse optional vehicle_id
|
||||||
|
parsed_vehicle_id: uuid.UUID | None = None
|
||||||
|
if vehicle_id:
|
||||||
|
try:
|
||||||
|
parsed_vehicle_id = uuid.UUID(vehicle_id)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail={
|
||||||
|
"error": {
|
||||||
|
"code": "INVALID_VEHICLE_ID",
|
||||||
|
"message": "Invalid vehicle UUID",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
ocr_result = await ocr_service.upload_file(
|
||||||
|
db=db,
|
||||||
|
file_bytes=file_bytes,
|
||||||
|
file_name=file.filename or "upload.png",
|
||||||
|
mime_type=mime_type,
|
||||||
|
vehicle_id=parsed_vehicle_id,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail={"error": {"code": "UPLOAD_FAILED", "message": str(exc)}},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Queue background processing
|
||||||
|
background_tasks.add_task(run_ocr_processing, ocr_result.id)
|
||||||
|
|
||||||
|
return OCRUploadResponse(
|
||||||
|
message="OCR processing queued",
|
||||||
|
ocr_result_id=ocr_result.id,
|
||||||
|
status=ocr_result.status.value
|
||||||
|
if isinstance(ocr_result.status, OCRStatus)
|
||||||
|
else str(ocr_result.status),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/results/{result_id}",
|
||||||
|
response_model=OCRResultResponse,
|
||||||
|
status_code=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
async def get_ocr_result(
|
||||||
|
result_id: uuid.UUID,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Get a single OCR result by ID."""
|
||||||
|
ocr_result = await ocr_service.get_result(db, result_id)
|
||||||
|
if ocr_result is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail={
|
||||||
|
"error": {"code": "OCR_NOT_FOUND", "message": "OCR result not found"}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return OCRResultResponse.model_validate(ocr_result)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/results",
|
||||||
|
response_model=OCRResultListResponse,
|
||||||
|
status_code=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
async def list_ocr_results(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
vehicle_id: uuid.UUID | None = Query(None, description="Filter by vehicle ID"),
|
||||||
|
page: int = Query(1, ge=1, description="Page number"),
|
||||||
|
page_size: int = Query(20, ge=1, le=100, description="Items per page"),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""List OCR results, optionally filtered by vehicle_id."""
|
||||||
|
items, total = await ocr_service.list_results(
|
||||||
|
db=db,
|
||||||
|
vehicle_id=vehicle_id,
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
)
|
||||||
|
return OCRResultListResponse(
|
||||||
|
items=[OCRResultResponse.model_validate(item) for item in items],
|
||||||
|
total=total,
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/results/{result_id}/apply",
|
||||||
|
response_model=OCRApplyResponse,
|
||||||
|
status_code=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
async def apply_ocr_to_vehicle(
|
||||||
|
result_id: uuid.UUID,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Apply OCR structured data to the linked vehicle."""
|
||||||
|
try:
|
||||||
|
ocr_result, vehicle, updated_fields = await ocr_service.apply_to_vehicle(
|
||||||
|
db, result_id
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail={"error": {"code": "APPLY_FAILED", "message": str(exc)}},
|
||||||
|
)
|
||||||
|
|
||||||
|
return OCRApplyResponse(
|
||||||
|
message="OCR data applied to vehicle",
|
||||||
|
ocr_result_id=ocr_result.id,
|
||||||
|
vehicle_id=vehicle.id,
|
||||||
|
updated_fields=updated_fields,
|
||||||
|
)
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
"""Sales router: CRUD, contract PDF, USt-IdNr. verification."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.dependencies import get_current_user, get_pagination
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.sale import (
|
||||||
|
ContractResponse,
|
||||||
|
SaleCreate,
|
||||||
|
SaleListResponse,
|
||||||
|
SaleResponse,
|
||||||
|
SaleUpdate,
|
||||||
|
UstIdVerifyResponse,
|
||||||
|
)
|
||||||
|
from app.services import sale_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/sales", tags=["sales"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=SaleListResponse, status_code=status.HTTP_200_OK)
|
||||||
|
async def list_sales(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
pagination: dict = Depends(get_pagination),
|
||||||
|
status_filter: str | None = Query(
|
||||||
|
None, alias="status", description="Filter by sale status"
|
||||||
|
),
|
||||||
|
date_from: str | None = Query(
|
||||||
|
None, description="Filter sales from this date (YYYY-MM-DD)"
|
||||||
|
),
|
||||||
|
date_to: str | None = Query(
|
||||||
|
None, description="Filter sales up to this date (YYYY-MM-DD)"
|
||||||
|
),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""List sales with pagination, filtering by status and date range."""
|
||||||
|
from datetime import date as date_type
|
||||||
|
|
||||||
|
parsed_date_from = None
|
||||||
|
parsed_date_to = None
|
||||||
|
if date_from:
|
||||||
|
try:
|
||||||
|
parsed_date_from = date_type.fromisoformat(date_from)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail={
|
||||||
|
"error": {
|
||||||
|
"code": "INVALID_DATE",
|
||||||
|
"message": f"Invalid date_from format: {date_from}",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if date_to:
|
||||||
|
try:
|
||||||
|
parsed_date_to = date_type.fromisoformat(date_to)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail={
|
||||||
|
"error": {
|
||||||
|
"code": "INVALID_DATE",
|
||||||
|
"message": f"Invalid date_to format: {date_to}",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
sales, total = await sale_service.list_sales(
|
||||||
|
db,
|
||||||
|
page=pagination["page"],
|
||||||
|
page_size=pagination["page_size"],
|
||||||
|
status=status_filter,
|
||||||
|
date_from=parsed_date_from,
|
||||||
|
date_to=parsed_date_to,
|
||||||
|
)
|
||||||
|
return SaleListResponse(
|
||||||
|
items=[SaleResponse.model_validate(s) for s in sales],
|
||||||
|
total=total,
|
||||||
|
page=pagination["page"],
|
||||||
|
page_size=pagination["page_size"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=SaleResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_sale(
|
||||||
|
body: SaleCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Create a new sale. Sets vehicle status to 'sold'."""
|
||||||
|
data = body.model_dump(exclude_unset=False)
|
||||||
|
try:
|
||||||
|
sale = await sale_service.create_sale(db, data)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail={"error": {"code": "VEHICLE_NOT_FOUND", "message": str(exc)}},
|
||||||
|
)
|
||||||
|
return SaleResponse.model_validate(sale)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{sale_id}", response_model=SaleResponse, status_code=status.HTTP_200_OK)
|
||||||
|
async def get_sale(
|
||||||
|
sale_id: uuid.UUID,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Get a single sale by ID with nested vehicle and contacts."""
|
||||||
|
sale = await sale_service.get_sale(db, sale_id)
|
||||||
|
if sale is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail={"error": {"code": "SALE_NOT_FOUND", "message": "Sale not found"}},
|
||||||
|
)
|
||||||
|
return SaleResponse.model_validate(sale)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{sale_id}", response_model=SaleResponse, status_code=status.HTTP_200_OK)
|
||||||
|
async def update_sale(
|
||||||
|
sale_id: uuid.UUID,
|
||||||
|
body: SaleUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Update a sale's fields."""
|
||||||
|
updates = body.model_dump(exclude_unset=True)
|
||||||
|
if not updates:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail={"error": {"code": "NO_FIELDS", "message": "No fields to update"}},
|
||||||
|
)
|
||||||
|
sale = await sale_service.update_sale(db, sale_id, updates)
|
||||||
|
if sale is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail={"error": {"code": "SALE_NOT_FOUND", "message": "Sale not found"}},
|
||||||
|
)
|
||||||
|
return SaleResponse.model_validate(sale)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/{sale_id}", response_model=SaleResponse, status_code=status.HTTP_200_OK
|
||||||
|
)
|
||||||
|
async def delete_sale(
|
||||||
|
sale_id: uuid.UUID,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Cancel a sale (soft delete). Sets sale status to 'cancelled' and restores vehicle status to 'available'."""
|
||||||
|
sale = await sale_service.delete_sale(db, sale_id)
|
||||||
|
if sale is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail={"error": {"code": "SALE_NOT_FOUND", "message": "Sale not found"}},
|
||||||
|
)
|
||||||
|
return SaleResponse.model_validate(sale)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{sale_id}/contract",
|
||||||
|
response_model=ContractResponse,
|
||||||
|
status_code=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
async def regenerate_contract(
|
||||||
|
sale_id: uuid.UUID,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Regenerate the contract PDF for a sale."""
|
||||||
|
sale = await sale_service.regenerate_contract_pdf(db, sale_id)
|
||||||
|
if sale is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail={"error": {"code": "SALE_NOT_FOUND", "message": "Sale not found"}},
|
||||||
|
)
|
||||||
|
return ContractResponse(
|
||||||
|
sale_id=sale.id,
|
||||||
|
contract_pdf_path=sale.contract_pdf_path or "",
|
||||||
|
message="Contract regenerated",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{sale_id}/contract", status_code=status.HTTP_200_OK)
|
||||||
|
async def download_contract(
|
||||||
|
sale_id: uuid.UUID,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Download the contract PDF for a sale."""
|
||||||
|
sale = await sale_service.get_sale(db, sale_id)
|
||||||
|
if sale is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail={"error": {"code": "SALE_NOT_FOUND", "message": "Sale not found"}},
|
||||||
|
)
|
||||||
|
if not sale.contract_pdf_path or not os.path.exists(sale.contract_pdf_path):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail={
|
||||||
|
"error": {
|
||||||
|
"code": "CONTRACT_NOT_FOUND",
|
||||||
|
"message": "Contract PDF not generated yet",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return FileResponse(
|
||||||
|
path=sale.contract_pdf_path,
|
||||||
|
media_type="application/pdf",
|
||||||
|
filename=f"contract_{sale.id}.pdf",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{sale_id}/verify-ust-id",
|
||||||
|
response_model=UstIdVerifyResponse,
|
||||||
|
status_code=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
async def verify_ust_id(
|
||||||
|
sale_id: uuid.UUID,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Verify the buyer's USt-IdNr. via BZSt API.
|
||||||
|
|
||||||
|
Feature flag: BZST_API_ENABLED (default: False).
|
||||||
|
When disabled, returns {verified: false, message: 'BZSt API not available'}.
|
||||||
|
"""
|
||||||
|
result = await sale_service.verify_ust_id(sale_id, db)
|
||||||
|
return UstIdVerifyResponse(**result)
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
"""Users router: admin-only user management (list, create, update, deactivate)."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.dependencies import get_pagination, require_role
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.user import (
|
||||||
|
UserCreate,
|
||||||
|
UserListResponse,
|
||||||
|
UserResponse,
|
||||||
|
UserUpdate,
|
||||||
|
)
|
||||||
|
from app.services.auth_service import (
|
||||||
|
create_user as svc_create_user,
|
||||||
|
deactivate_user as svc_deactivate_user,
|
||||||
|
list_users as svc_list_users,
|
||||||
|
update_user as svc_update_user,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/users", tags=["users"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=UserListResponse, status_code=status.HTTP_200_OK)
|
||||||
|
async def list_users(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
pagination: dict = Depends(get_pagination),
|
||||||
|
admin: User = Depends(require_role(["admin"])),
|
||||||
|
):
|
||||||
|
"""List all users with pagination. Admin only."""
|
||||||
|
users, total = await svc_list_users(
|
||||||
|
db, page=pagination["page"], page_size=pagination["page_size"]
|
||||||
|
)
|
||||||
|
return UserListResponse(
|
||||||
|
items=[UserResponse.model_validate(u) for u in users],
|
||||||
|
total=total,
|
||||||
|
page=pagination["page"],
|
||||||
|
page_size=pagination["page_size"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_user(
|
||||||
|
body: UserCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
admin: User = Depends(require_role(["admin"])),
|
||||||
|
):
|
||||||
|
"""Create a new user. Admin only."""
|
||||||
|
try:
|
||||||
|
user = await svc_create_user(
|
||||||
|
db,
|
||||||
|
email=body.email,
|
||||||
|
password=body.password,
|
||||||
|
full_name=body.full_name,
|
||||||
|
role=body.role,
|
||||||
|
language=body.language,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail={"error": {"code": "DUPLICATE_EMAIL", "message": str(exc)}},
|
||||||
|
)
|
||||||
|
return UserResponse.model_validate(user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{user_id}", response_model=UserResponse, status_code=status.HTTP_200_OK)
|
||||||
|
async def update_user(
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
body: UserUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
admin: User = Depends(require_role(["admin"])),
|
||||||
|
):
|
||||||
|
"""Update a user's fields. Admin only."""
|
||||||
|
updates = body.model_dump(exclude_unset=True)
|
||||||
|
if not updates:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail={"error": {"code": "NO_FIELDS", "message": "No fields to update"}},
|
||||||
|
)
|
||||||
|
user = await svc_update_user(db, user_id, **updates)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail={"error": {"code": "USER_NOT_FOUND", "message": "User not found"}},
|
||||||
|
)
|
||||||
|
return UserResponse.model_validate(user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/{user_id}", response_model=UserResponse, status_code=status.HTTP_200_OK
|
||||||
|
)
|
||||||
|
async def delete_user(
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
admin: User = Depends(require_role(["admin"])),
|
||||||
|
):
|
||||||
|
"""Deactivate a user (soft delete). Admin only."""
|
||||||
|
user = await svc_deactivate_user(db, user_id)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail={"error": {"code": "USER_NOT_FOUND", "message": "User not found"}},
|
||||||
|
)
|
||||||
|
return UserResponse.model_validate(user)
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
"""Vehicles router: CRUD, filtering, sorting, and mobile.de integration endpoints."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.dependencies import get_current_user, get_pagination
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.vehicle import (
|
||||||
|
MobileDePushResponse,
|
||||||
|
MobileDeStatusResponse,
|
||||||
|
VehicleCreate,
|
||||||
|
VehicleListResponse,
|
||||||
|
VehicleResponse,
|
||||||
|
VehicleUpdate,
|
||||||
|
)
|
||||||
|
from app.services import vehicle_service
|
||||||
|
from app.services import mobilede_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/vehicles", tags=["vehicles"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=VehicleListResponse, status_code=status.HTTP_200_OK)
|
||||||
|
async def list_vehicles(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
pagination: dict = Depends(get_pagination),
|
||||||
|
vehicle_type: str | None = Query(None, description="Filter by vehicle type"),
|
||||||
|
availability: str | None = Query(None, description="Filter by availability"),
|
||||||
|
min_price: float | None = Query(None, ge=0, description="Minimum price"),
|
||||||
|
max_price: float | None = Query(None, ge=0, description="Maximum price"),
|
||||||
|
search: str | None = Query(
|
||||||
|
None, description="Search in make, model, fin, location"
|
||||||
|
),
|
||||||
|
sort: str | None = Query(None, description="Sort field (prefix - for descending)"),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""List vehicles with pagination, filtering, and sorting."""
|
||||||
|
vehicles, total = await vehicle_service.list_vehicles(
|
||||||
|
db,
|
||||||
|
page=pagination["page"],
|
||||||
|
page_size=pagination["page_size"],
|
||||||
|
vehicle_type=vehicle_type,
|
||||||
|
availability=availability,
|
||||||
|
min_price=min_price,
|
||||||
|
max_price=max_price,
|
||||||
|
search=search,
|
||||||
|
sort=sort,
|
||||||
|
)
|
||||||
|
return VehicleListResponse(
|
||||||
|
items=[VehicleResponse.model_validate(v) for v in vehicles],
|
||||||
|
total=total,
|
||||||
|
page=pagination["page"],
|
||||||
|
page_size=pagination["page_size"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=VehicleResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_vehicle(
|
||||||
|
body: VehicleCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Create a new vehicle."""
|
||||||
|
data = body.model_dump(exclude_unset=False)
|
||||||
|
try:
|
||||||
|
vehicle = await vehicle_service.create_vehicle(db, data)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail={"error": {"code": "DUPLICATE_FIN", "message": str(exc)}},
|
||||||
|
)
|
||||||
|
return VehicleResponse.model_validate(vehicle)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{vehicle_id}", response_model=VehicleResponse, status_code=status.HTTP_200_OK
|
||||||
|
)
|
||||||
|
async def get_vehicle(
|
||||||
|
vehicle_id: uuid.UUID,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Get a single vehicle by ID."""
|
||||||
|
vehicle = await vehicle_service.get_vehicle_by_id(db, vehicle_id)
|
||||||
|
if vehicle is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail={
|
||||||
|
"error": {"code": "VEHICLE_NOT_FOUND", "message": "Vehicle not found"}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return VehicleResponse.model_validate(vehicle)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/{vehicle_id}", response_model=VehicleResponse, status_code=status.HTTP_200_OK
|
||||||
|
)
|
||||||
|
async def update_vehicle(
|
||||||
|
vehicle_id: uuid.UUID,
|
||||||
|
body: VehicleUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Update a vehicle's fields."""
|
||||||
|
updates = body.model_dump(exclude_unset=True)
|
||||||
|
if not updates:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail={"error": {"code": "NO_FIELDS", "message": "No fields to update"}},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
vehicle = await vehicle_service.update_vehicle(db, vehicle_id, updates)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail={"error": {"code": "DUPLICATE_FIN", "message": str(exc)}},
|
||||||
|
)
|
||||||
|
if vehicle is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail={
|
||||||
|
"error": {"code": "VEHICLE_NOT_FOUND", "message": "Vehicle not found"}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return VehicleResponse.model_validate(vehicle)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/{vehicle_id}", response_model=VehicleResponse, status_code=status.HTTP_200_OK
|
||||||
|
)
|
||||||
|
async def delete_vehicle(
|
||||||
|
vehicle_id: uuid.UUID,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Soft-delete a vehicle (sets deleted_at)."""
|
||||||
|
vehicle = await vehicle_service.soft_delete_vehicle(db, vehicle_id)
|
||||||
|
if vehicle is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail={
|
||||||
|
"error": {"code": "VEHICLE_NOT_FOUND", "message": "Vehicle not found"}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return VehicleResponse.model_validate(vehicle)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{vehicle_id}/mobile-de/push",
|
||||||
|
response_model=MobileDePushResponse,
|
||||||
|
status_code=status.HTTP_202_ACCEPTED,
|
||||||
|
)
|
||||||
|
async def push_to_mobile_de(
|
||||||
|
vehicle_id: uuid.UUID,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Push a vehicle listing to mobile.de (async, returns 202)."""
|
||||||
|
vehicle = await vehicle_service.get_vehicle_by_id(db, vehicle_id)
|
||||||
|
if vehicle is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail={
|
||||||
|
"error": {"code": "VEHICLE_NOT_FOUND", "message": "Vehicle not found"}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
listing = await mobilede_service.push_listing(db, vehicle)
|
||||||
|
|
||||||
|
return MobileDePushResponse(
|
||||||
|
message="Push queued",
|
||||||
|
vehicle_id=vehicle.id,
|
||||||
|
listing_id=listing.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{vehicle_id}/mobile-de/status",
|
||||||
|
response_model=MobileDeStatusResponse,
|
||||||
|
status_code=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
async def get_mobile_de_status(
|
||||||
|
vehicle_id: uuid.UUID,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Get the mobile.de sync status for a vehicle."""
|
||||||
|
# Verify vehicle exists
|
||||||
|
vehicle = await vehicle_service.get_vehicle_by_id(db, vehicle_id)
|
||||||
|
if vehicle is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail={
|
||||||
|
"error": {"code": "VEHICLE_NOT_FOUND", "message": "Vehicle not found"}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
listing = await mobilede_service.get_listing_status(db, vehicle_id)
|
||||||
|
if listing is None:
|
||||||
|
return MobileDeStatusResponse(
|
||||||
|
synced=False,
|
||||||
|
ad_id=None,
|
||||||
|
synced_at=None,
|
||||||
|
sync_status="pending",
|
||||||
|
error_log=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
return MobileDeStatusResponse(
|
||||||
|
synced=(listing.sync_status == "synced"),
|
||||||
|
ad_id=listing.ad_id,
|
||||||
|
synced_at=listing.synced_at,
|
||||||
|
sync_status=listing.sync_status,
|
||||||
|
error_log=listing.error_log,
|
||||||
|
)
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
"""Pydantic schemas for contact-related request and response bodies."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Literal, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||||
|
|
||||||
|
from app.utils.ust_validation import validate_vat_id
|
||||||
|
|
||||||
|
|
||||||
|
ContactRole = Literal["kaeufer", "verkaeufer", "beide"]
|
||||||
|
VatIdStatus = Literal["ungeprueft", "geprueft", "ungueltig", "manuell_bestaetigt"]
|
||||||
|
|
||||||
|
|
||||||
|
class ContactPersonBase(BaseModel):
|
||||||
|
"""Base contact person fields."""
|
||||||
|
|
||||||
|
name: str = Field(..., min_length=1, max_length=255)
|
||||||
|
function: Optional[str] = Field(None, max_length=100)
|
||||||
|
phone: Optional[str] = Field(None, max_length=50)
|
||||||
|
email: Optional[str] = Field(None, max_length=255)
|
||||||
|
|
||||||
|
|
||||||
|
class ContactPersonCreate(ContactPersonBase):
|
||||||
|
"""POST /api/v1/contacts/:id/persons request body."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ContactPersonResponse(ContactPersonBase):
|
||||||
|
"""Contact person response schema."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
contact_id: uuid.UUID
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ContactBase(BaseModel):
|
||||||
|
"""Base contact fields shared across schemas."""
|
||||||
|
|
||||||
|
company_name: str = Field(..., min_length=1, max_length=255)
|
||||||
|
legal_form: Optional[str] = Field(None, max_length=50)
|
||||||
|
address_street: Optional[str] = Field(None, max_length=255)
|
||||||
|
address_zip: Optional[str] = Field(None, max_length=10)
|
||||||
|
address_city: Optional[str] = Field(None, max_length=100)
|
||||||
|
address_country: str = Field("DE", min_length=2, max_length=2)
|
||||||
|
vat_id: Optional[str] = Field(None, max_length=20)
|
||||||
|
phone: Optional[str] = Field(None, max_length=50)
|
||||||
|
email: Optional[str] = Field(None, max_length=255)
|
||||||
|
website: Optional[str] = Field(None, max_length=255)
|
||||||
|
role: ContactRole
|
||||||
|
is_private: bool = False
|
||||||
|
|
||||||
|
@field_validator("vat_id")
|
||||||
|
@classmethod
|
||||||
|
def validate_vat_id_format(cls, v: str | None) -> str | None:
|
||||||
|
"""Validate VAT ID format if provided."""
|
||||||
|
if v is None or v == "":
|
||||||
|
return None
|
||||||
|
if not validate_vat_id(v):
|
||||||
|
raise ValueError(f"Invalid VAT ID format: '{v}'")
|
||||||
|
return v.strip().upper().replace(" ", "")
|
||||||
|
|
||||||
|
@field_validator("address_country")
|
||||||
|
@classmethod
|
||||||
|
def validate_country_code(cls, v: str) -> str:
|
||||||
|
"""Normalise country code to uppercase."""
|
||||||
|
return v.upper()
|
||||||
|
|
||||||
|
|
||||||
|
class ContactCreate(ContactBase):
|
||||||
|
"""POST /api/v1/contacts request body."""
|
||||||
|
|
||||||
|
contact_persons: list[ContactPersonCreate] = Field(
|
||||||
|
default_factory=list, description="Contact persons to create with the contact"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ContactUpdate(BaseModel):
|
||||||
|
"""PUT /api/v1/contacts/:id request body (all fields optional)."""
|
||||||
|
|
||||||
|
company_name: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||||
|
legal_form: Optional[str] = Field(None, max_length=50)
|
||||||
|
address_street: Optional[str] = Field(None, max_length=255)
|
||||||
|
address_zip: Optional[str] = Field(None, max_length=10)
|
||||||
|
address_city: Optional[str] = Field(None, max_length=100)
|
||||||
|
address_country: Optional[str] = Field(None, min_length=2, max_length=2)
|
||||||
|
vat_id: Optional[str] = Field(None, max_length=20)
|
||||||
|
phone: Optional[str] = Field(None, max_length=50)
|
||||||
|
email: Optional[str] = Field(None, max_length=255)
|
||||||
|
website: Optional[str] = Field(None, max_length=255)
|
||||||
|
role: Optional[ContactRole] = None
|
||||||
|
is_private: Optional[bool] = None
|
||||||
|
|
||||||
|
@field_validator("vat_id")
|
||||||
|
@classmethod
|
||||||
|
def validate_vat_id_format(cls, v: str | None) -> str | None:
|
||||||
|
"""Validate VAT ID format if provided."""
|
||||||
|
if v is None or v == "":
|
||||||
|
return None
|
||||||
|
if not validate_vat_id(v):
|
||||||
|
raise ValueError(f"Invalid VAT ID format: '{v}'")
|
||||||
|
return v.strip().upper().replace(" ", "")
|
||||||
|
|
||||||
|
@field_validator("address_country")
|
||||||
|
@classmethod
|
||||||
|
def validate_country_code(cls, v: str | None) -> str | None:
|
||||||
|
"""Normalise country code to uppercase."""
|
||||||
|
if v is not None:
|
||||||
|
return v.upper()
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class ContactResponse(BaseModel):
|
||||||
|
"""Contact response schema."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
company_name: str
|
||||||
|
legal_form: Optional[str] = None
|
||||||
|
address_street: Optional[str] = None
|
||||||
|
address_zip: Optional[str] = None
|
||||||
|
address_city: Optional[str] = None
|
||||||
|
address_country: str
|
||||||
|
vat_id: Optional[str] = None
|
||||||
|
phone: Optional[str] = None
|
||||||
|
email: Optional[str] = None
|
||||||
|
website: Optional[str] = None
|
||||||
|
role: str
|
||||||
|
vat_id_status: str = "ungeprueft"
|
||||||
|
is_private: bool = False
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
updated_at: Optional[datetime] = None
|
||||||
|
deleted_at: Optional[datetime] = None
|
||||||
|
contact_persons: list[ContactPersonResponse] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class ContactListResponse(BaseModel):
|
||||||
|
"""Paginated contact list response."""
|
||||||
|
|
||||||
|
items: list[ContactResponse]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
"""Pydantic schemas for Copilot chat, action, voice, and history endpoints."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
|
class ChatRequest(BaseModel):
|
||||||
|
"""Request body for POST /copilot/chat."""
|
||||||
|
|
||||||
|
message: str = Field(..., min_length=1, description="User message to the Copilot")
|
||||||
|
session_id: Optional[str] = Field(
|
||||||
|
None, description="Existing session UUID to continue"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ActionItem(BaseModel):
|
||||||
|
"""A single action proposed by the Copilot."""
|
||||||
|
|
||||||
|
type: str = Field(..., description="Action type, e.g. search_vehicles")
|
||||||
|
params: dict[str, Any] = Field(
|
||||||
|
default_factory=dict, description="Action parameters"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ChatResponse(BaseModel):
|
||||||
|
"""Response body for POST /copilot/chat."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
response: str = Field(..., description="Assistant text response")
|
||||||
|
actions: list[ActionItem] = Field(
|
||||||
|
default_factory=list, description="Proposed actions"
|
||||||
|
)
|
||||||
|
session_id: str = Field(..., description="Session UUID")
|
||||||
|
message_id: str = Field(..., description="Assistant message UUID")
|
||||||
|
|
||||||
|
|
||||||
|
class ActionRequest(BaseModel):
|
||||||
|
"""Request body for POST /copilot/action — user confirms an action."""
|
||||||
|
|
||||||
|
action: str = Field(..., min_length=1, description="Action type to execute")
|
||||||
|
params: dict[str, Any] = Field(
|
||||||
|
default_factory=dict, description="Action parameters"
|
||||||
|
)
|
||||||
|
session_id: Optional[str] = Field(None, description="Session context")
|
||||||
|
|
||||||
|
|
||||||
|
class ActionResponse(BaseModel):
|
||||||
|
"""Response body for POST /copilot/action."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
action: str = Field(..., description="Executed action type")
|
||||||
|
result: Any = Field(..., description="Action result data")
|
||||||
|
success: bool = Field(..., description="Whether the action succeeded")
|
||||||
|
|
||||||
|
|
||||||
|
class ChatMessageItem(BaseModel):
|
||||||
|
"""A single chat message in history."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: str
|
||||||
|
session_id: str
|
||||||
|
role: str
|
||||||
|
content: str
|
||||||
|
actions: Optional[list[dict[str, Any]]] = None
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ChatHistoryResponse(BaseModel):
|
||||||
|
"""Paginated chat history response."""
|
||||||
|
|
||||||
|
items: list[ChatMessageItem] = Field(default_factory=list)
|
||||||
|
total: int = Field(0)
|
||||||
|
page: int = Field(1)
|
||||||
|
page_size: int = Field(20)
|
||||||
|
|
||||||
|
|
||||||
|
class VoiceRequest(BaseModel):
|
||||||
|
"""Request body for POST /copilot/voice.
|
||||||
|
|
||||||
|
Accepts base64-encoded audio data for transcription.
|
||||||
|
"""
|
||||||
|
|
||||||
|
audio: str = Field(..., min_length=1, description="Base64-encoded audio data")
|
||||||
|
mime_type: str = Field("audio/webm", description="Audio MIME type")
|
||||||
|
session_id: Optional[str] = Field(None, description="Existing session UUID")
|
||||||
|
|
||||||
|
|
||||||
|
class VoiceResponse(BaseModel):
|
||||||
|
"""Response body for POST /copilot/voice."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
transcription: str = Field(..., description="Transcribed text")
|
||||||
|
response: str = Field(..., description="Assistant text response")
|
||||||
|
actions: list[ActionItem] = Field(
|
||||||
|
default_factory=list, description="Proposed actions"
|
||||||
|
)
|
||||||
|
session_id: str = Field(..., description="Session UUID")
|
||||||
|
message_id: str = Field(..., description="Assistant message UUID")
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""Pydantic schemas for DATEV export request and response bodies."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import date, datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||||
|
|
||||||
|
|
||||||
|
class DATEVExportCreate(BaseModel):
|
||||||
|
"""POST /api/v1/datev/export request body."""
|
||||||
|
|
||||||
|
start_date: date = Field(..., description="Start date of the export range")
|
||||||
|
end_date: date = Field(..., description="End date of the export range (inclusive)")
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_date_range(self) -> "DATEVExportCreate":
|
||||||
|
"""Ensure start_date is not after end_date."""
|
||||||
|
if self.start_date > self.end_date:
|
||||||
|
raise ValueError("start_date must not be after end_date")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class DATEVExportResponse(BaseModel):
|
||||||
|
"""DATEV export response schema."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
start_date: date
|
||||||
|
end_date: date
|
||||||
|
file_path: Optional[str] = None
|
||||||
|
total_amount: Decimal
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
class DATEVExportListResponse(BaseModel):
|
||||||
|
"""Paginated DATEV export list response."""
|
||||||
|
|
||||||
|
items: list[DATEVExportResponse]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"""Pydantic schemas for file upload, download, and list responses."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class FileUploadResponse(BaseModel):
|
||||||
|
"""Response after a successful file upload."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
vehicle_id: uuid.UUID
|
||||||
|
original_filename: str
|
||||||
|
stored_filename: str
|
||||||
|
file_path: str
|
||||||
|
mime_type: str
|
||||||
|
file_size: int
|
||||||
|
thumbnail_path: Optional[str] = None
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
updated_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
class FileResponse(BaseModel):
|
||||||
|
"""Full file metadata response."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
vehicle_id: uuid.UUID
|
||||||
|
original_filename: str
|
||||||
|
stored_filename: str
|
||||||
|
file_path: str
|
||||||
|
mime_type: str
|
||||||
|
file_size: int
|
||||||
|
thumbnail_path: Optional[str] = None
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
updated_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
class FileListResponse(BaseModel):
|
||||||
|
"""Paginated file list response."""
|
||||||
|
|
||||||
|
items: list[FileResponse]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
|
|
||||||
|
|
||||||
|
class FileDeleteResponse(BaseModel):
|
||||||
|
"""Response after deleting a file."""
|
||||||
|
|
||||||
|
message: str = "File deleted"
|
||||||
|
id: uuid.UUID
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"""Pydantic schemas for OCR-related request and response bodies."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
|
class OCRUploadResponse(BaseModel):
|
||||||
|
"""Response for POST /api/v1/ocr/upload."""
|
||||||
|
|
||||||
|
message: str = "OCR processing queued"
|
||||||
|
ocr_result_id: uuid.UUID
|
||||||
|
status: str = "pending"
|
||||||
|
|
||||||
|
|
||||||
|
class OCRStructuredData(BaseModel):
|
||||||
|
"""Structured data extracted from OCR scan (ZB I/II fields)."""
|
||||||
|
|
||||||
|
brand: Optional[str] = None
|
||||||
|
model: Optional[str] = None
|
||||||
|
vin: Optional[str] = None
|
||||||
|
first_registration: Optional[str] = None
|
||||||
|
mileage: Optional[int] = None
|
||||||
|
power_kw: Optional[int] = None
|
||||||
|
fuel_type: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class OCRResultResponse(BaseModel):
|
||||||
|
"""Response for GET /api/v1/ocr/results/:id."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
vehicle_id: Optional[uuid.UUID] = None
|
||||||
|
file_path: str
|
||||||
|
file_name: str
|
||||||
|
mime_type: str
|
||||||
|
status: str
|
||||||
|
raw_text: Optional[str] = None
|
||||||
|
structured_data: Optional[dict[str, Any]] = None
|
||||||
|
confidence_score: Optional[float] = None
|
||||||
|
error_message: Optional[str] = None
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
updated_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
class OCRResultListResponse(BaseModel):
|
||||||
|
"""Paginated OCR results list response."""
|
||||||
|
|
||||||
|
items: list[OCRResultResponse]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
|
|
||||||
|
|
||||||
|
class OCRApplyResponse(BaseModel):
|
||||||
|
"""Response for POST /api/v1/ocr/results/:id/apply."""
|
||||||
|
|
||||||
|
message: str = "OCR data applied to vehicle"
|
||||||
|
ocr_result_id: uuid.UUID
|
||||||
|
vehicle_id: uuid.UUID
|
||||||
|
updated_fields: list[str] = Field(default_factory=list)
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"""Pydantic schemas for image retouch and price comparison endpoints."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
|
class RetouchProcessResponse(BaseModel):
|
||||||
|
"""Response for POST /api/v1/retouch/process."""
|
||||||
|
|
||||||
|
message: str = "Retouch processing queued"
|
||||||
|
retouch_id: uuid.UUID
|
||||||
|
status: str = "pending"
|
||||||
|
|
||||||
|
|
||||||
|
class RetouchResultResponse(BaseModel):
|
||||||
|
"""Response for GET /api/v1/retouch/results/:id."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
vehicle_id: Optional[uuid.UUID] = None
|
||||||
|
original_file_path: str
|
||||||
|
original_file_name: str
|
||||||
|
mime_type: str
|
||||||
|
retouched_file_path: Optional[str] = None
|
||||||
|
status: str
|
||||||
|
error_message: Optional[str] = None
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
updated_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
class PriceCompareRequest(BaseModel):
|
||||||
|
"""Request body for POST /api/v1/retouch/price-compare."""
|
||||||
|
|
||||||
|
vehicle_id: uuid.UUID = Field(..., description="Vehicle ID to compare prices for")
|
||||||
|
|
||||||
|
|
||||||
|
class ComparableListing(BaseModel):
|
||||||
|
"""A single comparable listing from mobile.de or mock data."""
|
||||||
|
|
||||||
|
title: str
|
||||||
|
make: str
|
||||||
|
model: str
|
||||||
|
year: Optional[int] = None
|
||||||
|
price: float
|
||||||
|
mileage_km: Optional[int] = None
|
||||||
|
location: Optional[str] = None
|
||||||
|
url: Optional[str] = None
|
||||||
|
source: str = "mobile.de"
|
||||||
|
|
||||||
|
|
||||||
|
class PriceCompareResponse(BaseModel):
|
||||||
|
"""Response for POST /api/v1/retouch/price-compare."""
|
||||||
|
|
||||||
|
vehicle_id: uuid.UUID
|
||||||
|
comparable_listings: list[ComparableListing] = Field(default_factory=list)
|
||||||
|
average_price: Optional[float] = None
|
||||||
|
listing_count: int = 0
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
"""Pydantic schemas for sale-related request and response bodies."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import date, datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Literal, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
|
SALE_STATUSES = Literal["draft", "completed", "cancelled"]
|
||||||
|
|
||||||
|
|
||||||
|
class SaleCreate(BaseModel):
|
||||||
|
"""POST /api/v1/sales request body."""
|
||||||
|
|
||||||
|
vehicle_id: uuid.UUID
|
||||||
|
buyer_contact_id: uuid.UUID
|
||||||
|
seller_contact_id: Optional[uuid.UUID] = None
|
||||||
|
sale_price: Decimal = Field(..., ge=0)
|
||||||
|
sale_date: Optional[date] = None
|
||||||
|
status: SALE_STATUSES = "draft"
|
||||||
|
is_gwg: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class SaleUpdate(BaseModel):
|
||||||
|
"""PUT /api/v1/sales/:id request body (all fields optional)."""
|
||||||
|
|
||||||
|
sale_price: Optional[Decimal] = Field(None, ge=0)
|
||||||
|
sale_date: Optional[date] = None
|
||||||
|
status: Optional[SALE_STATUSES] = None
|
||||||
|
is_gwg: Optional[bool] = None
|
||||||
|
seller_contact_id: Optional[uuid.UUID] = None
|
||||||
|
|
||||||
|
|
||||||
|
class VehicleNested(BaseModel):
|
||||||
|
"""Nested vehicle info in sale response."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
make: str
|
||||||
|
model: str
|
||||||
|
fin: str
|
||||||
|
vehicle_type: str
|
||||||
|
availability: str
|
||||||
|
price: Decimal
|
||||||
|
|
||||||
|
|
||||||
|
class ContactNested(BaseModel):
|
||||||
|
"""Nested contact info in sale response."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
company_name: str
|
||||||
|
address_city: Optional[str] = None
|
||||||
|
address_country: str
|
||||||
|
vat_id: Optional[str] = None
|
||||||
|
email: Optional[str] = None
|
||||||
|
phone: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class SaleResponse(BaseModel):
|
||||||
|
"""Sale response schema with nested vehicle and contacts."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
vehicle_id: uuid.UUID
|
||||||
|
buyer_contact_id: uuid.UUID
|
||||||
|
seller_contact_id: Optional[uuid.UUID] = None
|
||||||
|
sale_price: Decimal
|
||||||
|
sale_date: date
|
||||||
|
status: str
|
||||||
|
is_gwg: bool
|
||||||
|
contract_pdf_path: Optional[str] = None
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
updated_at: Optional[datetime] = None
|
||||||
|
vehicle: Optional[VehicleNested] = None
|
||||||
|
buyer: Optional[ContactNested] = None
|
||||||
|
seller: Optional[ContactNested] = None
|
||||||
|
|
||||||
|
|
||||||
|
class SaleListResponse(BaseModel):
|
||||||
|
"""Paginated sale list response."""
|
||||||
|
|
||||||
|
items: list[SaleResponse]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
|
|
||||||
|
|
||||||
|
class ContractResponse(BaseModel):
|
||||||
|
"""Response for contract PDF generation/download."""
|
||||||
|
|
||||||
|
sale_id: uuid.UUID
|
||||||
|
contract_pdf_path: str
|
||||||
|
message: str = "Contract generated"
|
||||||
|
|
||||||
|
|
||||||
|
class UstIdVerifyResponse(BaseModel):
|
||||||
|
"""Response for USt-IdNr. verification."""
|
||||||
|
|
||||||
|
verified: bool
|
||||||
|
message: str
|
||||||
|
vat_id: Optional[str] = None
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"""Pydantic schemas for user-related request and response bodies."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Literal, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, EmailStr, Field
|
||||||
|
|
||||||
|
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
"""POST /api/v1/auth/login request body."""
|
||||||
|
|
||||||
|
email: EmailStr
|
||||||
|
password: str = Field(..., min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
class TokenResponse(BaseModel):
|
||||||
|
"""JWT token pair returned after login or refresh."""
|
||||||
|
|
||||||
|
access_token: str
|
||||||
|
refresh_token: str
|
||||||
|
token_type: str = "bearer"
|
||||||
|
expires_in: int = Field(..., description="Access token TTL in seconds")
|
||||||
|
|
||||||
|
|
||||||
|
class RefreshRequest(BaseModel):
|
||||||
|
"""POST /api/v1/auth/refresh request body."""
|
||||||
|
|
||||||
|
refresh_token: str
|
||||||
|
|
||||||
|
|
||||||
|
class UserBase(BaseModel):
|
||||||
|
"""Base user fields shared across schemas."""
|
||||||
|
|
||||||
|
email: EmailStr
|
||||||
|
full_name: str = Field(..., min_length=1, max_length=200)
|
||||||
|
role: Literal["admin", "verkaeufer", "buchhaltung"] = "verkaeufer"
|
||||||
|
language: str = Field(default="de", max_length=5)
|
||||||
|
|
||||||
|
|
||||||
|
class UserCreate(UserBase):
|
||||||
|
"""POST /api/v1/users request body."""
|
||||||
|
|
||||||
|
password: str = Field(..., min_length=8, max_length=128)
|
||||||
|
|
||||||
|
|
||||||
|
class UserUpdate(BaseModel):
|
||||||
|
"""PUT /api/v1/users/:id request body (all fields optional)."""
|
||||||
|
|
||||||
|
email: Optional[EmailStr] = None
|
||||||
|
full_name: Optional[str] = Field(None, min_length=1, max_length=200)
|
||||||
|
role: Optional[Literal["admin", "verkaeufer", "buchhaltung"]] = None
|
||||||
|
language: Optional[str] = Field(None, max_length=5)
|
||||||
|
is_active: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
class UserResponse(BaseModel):
|
||||||
|
"""User response schema (never exposes password_hash)."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
email: str
|
||||||
|
full_name: str
|
||||||
|
role: str
|
||||||
|
language: str
|
||||||
|
is_active: bool
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
updated_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
class UserListResponse(BaseModel):
|
||||||
|
"""Paginated user list response."""
|
||||||
|
|
||||||
|
items: list[UserResponse]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
|
|
||||||
|
|
||||||
|
class ErrorResponse(BaseModel):
|
||||||
|
"""Standard error response format."""
|
||||||
|
|
||||||
|
error: dict
|
||||||
|
|
||||||
|
|
||||||
|
class HealthResponse(BaseModel):
|
||||||
|
"""Health check response."""
|
||||||
|
|
||||||
|
status: str = "ok"
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
"""Pydantic schemas for vehicle-related request and response bodies."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import date, datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Literal, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||||
|
|
||||||
|
|
||||||
|
VEHICLE_TYPES = Literal["lkw", "pkw", "baumaschine", "stapler", "transporter"]
|
||||||
|
CONDITIONS = Literal["new", "used"]
|
||||||
|
AVAILABILITY = Literal["available", "reserved", "sold"]
|
||||||
|
HOURS_UNITS = Literal["h", "min"]
|
||||||
|
|
||||||
|
|
||||||
|
class VehicleBase(BaseModel):
|
||||||
|
"""Base vehicle fields shared across schemas."""
|
||||||
|
|
||||||
|
make: str = Field(..., min_length=1, max_length=100)
|
||||||
|
model: str = Field(..., min_length=1, max_length=100)
|
||||||
|
fin: str = Field(..., min_length=17, max_length=17)
|
||||||
|
year: Optional[int] = Field(None, ge=1900, le=2100)
|
||||||
|
first_registration: Optional[date] = None
|
||||||
|
power_kw: Optional[int] = Field(None, ge=0)
|
||||||
|
power_hp: Optional[int] = Field(None, ge=0)
|
||||||
|
fuel_type: Optional[str] = Field(None, max_length=50)
|
||||||
|
transmission: Optional[str] = Field(None, max_length=20)
|
||||||
|
color: Optional[str] = Field(None, max_length=50)
|
||||||
|
condition: CONDITIONS = "used"
|
||||||
|
location: Optional[str] = Field(None, max_length=255)
|
||||||
|
availability: AVAILABILITY = "available"
|
||||||
|
price: Decimal = Field(..., ge=0)
|
||||||
|
vehicle_type: VEHICLE_TYPES
|
||||||
|
lkw_type: Optional[str] = Field(None, max_length=50)
|
||||||
|
machine_type: Optional[str] = Field(None, max_length=50)
|
||||||
|
body_type: Optional[str] = Field(None, max_length=100)
|
||||||
|
operating_hours: Optional[Decimal] = Field(None, ge=0)
|
||||||
|
operating_hours_unit: Optional[HOURS_UNITS] = None
|
||||||
|
mileage_km: Optional[int] = Field(None, ge=0)
|
||||||
|
description: Optional[str] = None
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def compute_power_hp(self) -> "VehicleBase":
|
||||||
|
"""Auto-compute power_hp from power_kw if not provided."""
|
||||||
|
if self.power_kw is not None and self.power_hp is None:
|
||||||
|
self.power_hp = round(self.power_kw * 1.35962)
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class VehicleCreate(VehicleBase):
|
||||||
|
"""POST /api/v1/vehicles request body."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class VehicleUpdate(BaseModel):
|
||||||
|
"""PUT /api/v1/vehicles/:id request body (all fields optional)."""
|
||||||
|
|
||||||
|
make: Optional[str] = Field(None, min_length=1, max_length=100)
|
||||||
|
model: Optional[str] = Field(None, min_length=1, max_length=100)
|
||||||
|
fin: Optional[str] = Field(None, min_length=17, max_length=17)
|
||||||
|
year: Optional[int] = Field(None, ge=1900, le=2100)
|
||||||
|
first_registration: Optional[date] = None
|
||||||
|
power_kw: Optional[int] = Field(None, ge=0)
|
||||||
|
power_hp: Optional[int] = Field(None, ge=0)
|
||||||
|
fuel_type: Optional[str] = Field(None, max_length=50)
|
||||||
|
transmission: Optional[str] = Field(None, max_length=20)
|
||||||
|
color: Optional[str] = Field(None, max_length=50)
|
||||||
|
condition: Optional[CONDITIONS] = None
|
||||||
|
location: Optional[str] = Field(None, max_length=255)
|
||||||
|
availability: Optional[AVAILABILITY] = None
|
||||||
|
price: Optional[Decimal] = Field(None, ge=0)
|
||||||
|
vehicle_type: Optional[VEHICLE_TYPES] = None
|
||||||
|
lkw_type: Optional[str] = Field(None, max_length=50)
|
||||||
|
machine_type: Optional[str] = Field(None, max_length=50)
|
||||||
|
body_type: Optional[str] = Field(None, max_length=100)
|
||||||
|
operating_hours: Optional[Decimal] = Field(None, ge=0)
|
||||||
|
operating_hours_unit: Optional[HOURS_UNITS] = None
|
||||||
|
mileage_km: Optional[int] = Field(None, ge=0)
|
||||||
|
description: Optional[str] = None
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def compute_power_hp(self) -> "VehicleUpdate":
|
||||||
|
"""Auto-compute power_hp from power_kw if not provided."""
|
||||||
|
if self.power_kw is not None and self.power_hp is None:
|
||||||
|
self.power_hp = round(self.power_kw * 1.35962)
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class VehicleResponse(BaseModel):
|
||||||
|
"""Vehicle response schema."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
make: str
|
||||||
|
model: str
|
||||||
|
fin: str
|
||||||
|
year: Optional[int] = None
|
||||||
|
first_registration: Optional[date] = None
|
||||||
|
power_kw: Optional[int] = None
|
||||||
|
power_hp: Optional[int] = None
|
||||||
|
fuel_type: Optional[str] = None
|
||||||
|
transmission: Optional[str] = None
|
||||||
|
color: Optional[str] = None
|
||||||
|
condition: str
|
||||||
|
location: Optional[str] = None
|
||||||
|
availability: str
|
||||||
|
price: Decimal
|
||||||
|
vehicle_type: str
|
||||||
|
lkw_type: Optional[str] = None
|
||||||
|
machine_type: Optional[str] = None
|
||||||
|
body_type: Optional[str] = None
|
||||||
|
operating_hours: Optional[Decimal] = None
|
||||||
|
operating_hours_unit: Optional[str] = None
|
||||||
|
mileage_km: Optional[int] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
updated_at: Optional[datetime] = None
|
||||||
|
deleted_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
class VehicleListResponse(BaseModel):
|
||||||
|
"""Paginated vehicle list response."""
|
||||||
|
|
||||||
|
items: list[VehicleResponse]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
|
|
||||||
|
|
||||||
|
class MobileDeStatusResponse(BaseModel):
|
||||||
|
"""mobile.de sync status for a vehicle."""
|
||||||
|
|
||||||
|
synced: bool
|
||||||
|
ad_id: Optional[str] = None
|
||||||
|
synced_at: Optional[datetime] = None
|
||||||
|
sync_status: str = "pending"
|
||||||
|
error_log: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class MobileDePushResponse(BaseModel):
|
||||||
|
"""Response for mobile.de push request."""
|
||||||
|
|
||||||
|
message: str = "Push queued"
|
||||||
|
vehicle_id: uuid.UUID
|
||||||
|
listing_id: Optional[uuid.UUID] = None
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
"""Authentication service: password hashing, login, token refresh, user lookup."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from passlib.context import CryptContext
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.user import User, UserRole
|
||||||
|
from app.utils.jwt import (
|
||||||
|
create_access_token,
|
||||||
|
create_refresh_token,
|
||||||
|
verify_refresh_token,
|
||||||
|
)
|
||||||
|
|
||||||
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(password: str) -> str:
|
||||||
|
"""Hash a plaintext password using bcrypt."""
|
||||||
|
return pwd_context.hash(password)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(plaintext: str, hashed: str) -> bool:
|
||||||
|
"""Verify a plaintext password against a bcrypt hash."""
|
||||||
|
return pwd_context.verify(plaintext, hashed)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_user_by_email(db: AsyncSession, email: str) -> Optional[User]:
|
||||||
|
"""Fetch a user by email address."""
|
||||||
|
result = await db.execute(select(User).where(User.email == email))
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_user_by_id(db: AsyncSession, user_id: uuid.UUID) -> Optional[User]:
|
||||||
|
"""Fetch a user by UUID."""
|
||||||
|
result = await db.execute(select(User).where(User.id == user_id))
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def authenticate_user(
|
||||||
|
db: AsyncSession, email: str, password: str
|
||||||
|
) -> Optional[User]:
|
||||||
|
"""Authenticate a user by email and password."""
|
||||||
|
user = await get_user_by_email(db, email)
|
||||||
|
if user is None:
|
||||||
|
return None
|
||||||
|
if not user.is_active:
|
||||||
|
return None
|
||||||
|
if not verify_password(password, user.password_hash):
|
||||||
|
return None
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def generate_token_pair(user: User) -> dict:
|
||||||
|
"""Create access + refresh JWT tokens for a given user."""
|
||||||
|
role_val = user.role.value if isinstance(user.role, UserRole) else str(user.role)
|
||||||
|
access_token = create_access_token(
|
||||||
|
user_id=str(user.id),
|
||||||
|
role=role_val,
|
||||||
|
email=user.email,
|
||||||
|
lang=user.language,
|
||||||
|
)
|
||||||
|
refresh_token = create_refresh_token(
|
||||||
|
user_id=str(user.id),
|
||||||
|
role=role_val,
|
||||||
|
email=user.email,
|
||||||
|
lang=user.language,
|
||||||
|
)
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
return {
|
||||||
|
"access_token": access_token,
|
||||||
|
"refresh_token": refresh_token,
|
||||||
|
"token_type": "bearer",
|
||||||
|
"expires_in": settings.access_token_ttl_seconds,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def refresh_access_token(db: AsyncSession, refresh_token: str) -> Optional[dict]:
|
||||||
|
"""Verify a refresh token and issue a new token pair."""
|
||||||
|
payload = verify_refresh_token(refresh_token)
|
||||||
|
if payload is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
user_id_str = payload.get("sub")
|
||||||
|
if not user_id_str:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
user_uuid = uuid.UUID(user_id_str)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
user = await get_user_by_id(db, user_uuid)
|
||||||
|
if user is None or not user.is_active:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return generate_token_pair(user)
|
||||||
|
|
||||||
|
|
||||||
|
async def create_user(
|
||||||
|
db: AsyncSession,
|
||||||
|
email: str,
|
||||||
|
password: str,
|
||||||
|
full_name: str,
|
||||||
|
role: str = "verkaeufer",
|
||||||
|
language: str = "de",
|
||||||
|
) -> User:
|
||||||
|
"""Create a new user with a bcrypt-hashed password."""
|
||||||
|
existing = await get_user_by_email(db, email)
|
||||||
|
if existing is not None:
|
||||||
|
raise ValueError(f"User with email {email} already exists")
|
||||||
|
|
||||||
|
user = User(
|
||||||
|
email=email,
|
||||||
|
password_hash=hash_password(password),
|
||||||
|
full_name=full_name,
|
||||||
|
role=UserRole(role),
|
||||||
|
language=language,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
async def list_users(
|
||||||
|
db: AsyncSession,
|
||||||
|
page: int = 1,
|
||||||
|
page_size: int = 20,
|
||||||
|
) -> tuple[list[User], int]:
|
||||||
|
"""List users with pagination. Returns (users, total_count)."""
|
||||||
|
count_result = await db.execute(select(func.count(User.id)))
|
||||||
|
total = count_result.scalar() or 0
|
||||||
|
|
||||||
|
offset = (page - 1) * page_size
|
||||||
|
result = await db.execute(
|
||||||
|
select(User).order_by(User.created_at.desc()).offset(offset).limit(page_size)
|
||||||
|
)
|
||||||
|
users = list(result.scalars().all())
|
||||||
|
return users, total
|
||||||
|
|
||||||
|
|
||||||
|
async def update_user(
|
||||||
|
db: AsyncSession,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
**updates,
|
||||||
|
) -> Optional[User]:
|
||||||
|
"""Update user fields. Returns updated user or None if not found."""
|
||||||
|
user = await get_user_by_id(db, user_id)
|
||||||
|
if user is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
for field, value in updates.items():
|
||||||
|
if field == "role" and value is not None:
|
||||||
|
value = UserRole(value)
|
||||||
|
if field == "password":
|
||||||
|
user.password_hash = hash_password(value)
|
||||||
|
elif hasattr(user, field) and value is not None:
|
||||||
|
setattr(user, field, value)
|
||||||
|
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
async def deactivate_user(db: AsyncSession, user_id: uuid.UUID) -> Optional[User]:
|
||||||
|
"""Soft-delete a user by setting is_active=False. Returns user or None."""
|
||||||
|
user = await get_user_by_id(db, user_id)
|
||||||
|
if user is None:
|
||||||
|
return None
|
||||||
|
user.is_active = False
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(user)
|
||||||
|
return user
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
"""Contact service: CRUD, search, filter, pagination, and soft-delete operations."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import and_, func, or_, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from app.models.contact import Contact, ContactPerson
|
||||||
|
|
||||||
|
|
||||||
|
_SORTABLE_FIELDS: set[str] = {
|
||||||
|
"company_name",
|
||||||
|
"address_city",
|
||||||
|
"address_country",
|
||||||
|
"role",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
"email",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_filters(
|
||||||
|
stmt: select,
|
||||||
|
search: str | None = None,
|
||||||
|
role: str | None = None,
|
||||||
|
is_eu: bool | None = None,
|
||||||
|
is_private: bool | None = None,
|
||||||
|
) -> select:
|
||||||
|
"""Apply WHERE filters to a select statement (always excludes soft-deleted)."""
|
||||||
|
conditions = [Contact.deleted_at.is_(None)]
|
||||||
|
|
||||||
|
if search:
|
||||||
|
search_pattern = f"%{search}%"
|
||||||
|
conditions.append(
|
||||||
|
or_(
|
||||||
|
Contact.company_name.ilike(search_pattern),
|
||||||
|
Contact.address_city.ilike(search_pattern),
|
||||||
|
Contact.email.ilike(search_pattern),
|
||||||
|
Contact.vat_id.ilike(search_pattern),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if role:
|
||||||
|
# When filtering by 'kaeufer' or 'verkaeufer', include 'beide' as well
|
||||||
|
if role in ("kaeufer", "verkaeufer"):
|
||||||
|
conditions.append(
|
||||||
|
or_(
|
||||||
|
Contact.role == role,
|
||||||
|
Contact.role == "beide",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
conditions.append(Contact.role == role)
|
||||||
|
|
||||||
|
if is_eu is not None:
|
||||||
|
if is_eu:
|
||||||
|
# EU = address_country != 'DE'
|
||||||
|
conditions.append(Contact.address_country != "DE")
|
||||||
|
else:
|
||||||
|
# Inland = address_country == 'DE'
|
||||||
|
conditions.append(Contact.address_country == "DE")
|
||||||
|
|
||||||
|
if is_private is not None:
|
||||||
|
conditions.append(Contact.is_private == is_private)
|
||||||
|
|
||||||
|
return stmt.where(and_(*conditions))
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_sort(stmt: select, sort: str | None = None) -> select:
|
||||||
|
"""Apply ORDER BY to a select statement based on sort param.
|
||||||
|
|
||||||
|
Format: 'field' for ascending, '-field' for descending.
|
||||||
|
"""
|
||||||
|
if not sort:
|
||||||
|
return stmt.order_by(Contact.created_at.desc())
|
||||||
|
|
||||||
|
descending = sort.startswith("-")
|
||||||
|
field_name = sort.lstrip("-")
|
||||||
|
|
||||||
|
if field_name not in _SORTABLE_FIELDS:
|
||||||
|
return stmt.order_by(Contact.created_at.desc())
|
||||||
|
|
||||||
|
column = getattr(Contact, field_name)
|
||||||
|
if descending:
|
||||||
|
return stmt.order_by(column.desc())
|
||||||
|
return stmt.order_by(column.asc())
|
||||||
|
|
||||||
|
|
||||||
|
async def list_contacts(
|
||||||
|
db: AsyncSession,
|
||||||
|
page: int = 1,
|
||||||
|
page_size: int = 20,
|
||||||
|
search: str | None = None,
|
||||||
|
role: str | None = None,
|
||||||
|
is_eu: bool | None = None,
|
||||||
|
is_private: bool | None = None,
|
||||||
|
sort: str | None = None,
|
||||||
|
) -> tuple[list[Contact], int]:
|
||||||
|
"""List contacts with pagination, filtering, and sorting.
|
||||||
|
|
||||||
|
Returns (contacts, total_count).
|
||||||
|
"""
|
||||||
|
# Build count query
|
||||||
|
count_stmt = select(func.count(Contact.id))
|
||||||
|
count_stmt = _apply_filters(
|
||||||
|
count_stmt,
|
||||||
|
search=search,
|
||||||
|
role=role,
|
||||||
|
is_eu=is_eu,
|
||||||
|
is_private=is_private,
|
||||||
|
)
|
||||||
|
total_result = await db.execute(count_stmt)
|
||||||
|
total = total_result.scalar_one()
|
||||||
|
|
||||||
|
# Build data query with eager-loaded contact persons
|
||||||
|
data_stmt = select(Contact).options(selectinload(Contact.contact_persons))
|
||||||
|
data_stmt = _apply_filters(
|
||||||
|
data_stmt,
|
||||||
|
search=search,
|
||||||
|
role=role,
|
||||||
|
is_eu=is_eu,
|
||||||
|
is_private=is_private,
|
||||||
|
)
|
||||||
|
data_stmt = _apply_sort(data_stmt, sort)
|
||||||
|
|
||||||
|
offset = (page - 1) * page_size
|
||||||
|
data_stmt = data_stmt.offset(offset).limit(page_size)
|
||||||
|
|
||||||
|
result = await db.execute(data_stmt)
|
||||||
|
contacts = list(result.scalars().all())
|
||||||
|
|
||||||
|
return contacts, total
|
||||||
|
|
||||||
|
|
||||||
|
async def get_contact_by_id(db: AsyncSession, contact_id: uuid.UUID) -> Contact | None:
|
||||||
|
"""Get a single contact by ID, excluding soft-deleted. Eager-loads contact persons."""
|
||||||
|
stmt = (
|
||||||
|
select(Contact)
|
||||||
|
.options(selectinload(Contact.contact_persons))
|
||||||
|
.where(and_(Contact.id == contact_id, Contact.deleted_at.is_(None)))
|
||||||
|
)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def create_contact(db: AsyncSession, data: dict[str, Any]) -> Contact:
|
||||||
|
"""Create a new contact with optional nested contact persons.
|
||||||
|
|
||||||
|
The data dict may contain a 'contact_persons' list of dicts.
|
||||||
|
"""
|
||||||
|
persons_data = data.pop("contact_persons", [])
|
||||||
|
|
||||||
|
contact = Contact(**data)
|
||||||
|
db.add(contact)
|
||||||
|
await db.flush() # Flush to generate contact.id
|
||||||
|
|
||||||
|
# Add contact persons if provided
|
||||||
|
for person_data in persons_data:
|
||||||
|
person = ContactPerson(
|
||||||
|
contact_id=contact.id,
|
||||||
|
name=person_data["name"],
|
||||||
|
function=person_data.get("function"),
|
||||||
|
phone=person_data.get("phone"),
|
||||||
|
email=person_data.get("email"),
|
||||||
|
)
|
||||||
|
db.add(person)
|
||||||
|
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(contact)
|
||||||
|
return contact
|
||||||
|
|
||||||
|
|
||||||
|
async def update_contact(
|
||||||
|
db: AsyncSession, contact_id: uuid.UUID, updates: dict[str, Any]
|
||||||
|
) -> Contact | None:
|
||||||
|
"""Update a contact's fields. Returns None if not found or deleted."""
|
||||||
|
contact = await get_contact_by_id(db, contact_id)
|
||||||
|
if contact is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
for key, value in updates.items():
|
||||||
|
if hasattr(contact, key):
|
||||||
|
setattr(contact, key, value)
|
||||||
|
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(contact)
|
||||||
|
return contact
|
||||||
|
|
||||||
|
|
||||||
|
async def soft_delete_contact(
|
||||||
|
db: AsyncSession, contact_id: uuid.UUID
|
||||||
|
) -> Contact | None:
|
||||||
|
"""Soft-delete a contact by setting deleted_at. Returns None if not found."""
|
||||||
|
contact = await get_contact_by_id(db, contact_id)
|
||||||
|
if contact is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
contact.deleted_at = datetime.now(timezone.utc)
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(contact)
|
||||||
|
return contact
|
||||||
|
|
||||||
|
|
||||||
|
async def add_contact_person(
|
||||||
|
db: AsyncSession,
|
||||||
|
contact_id: uuid.UUID,
|
||||||
|
person_data: dict[str, Any],
|
||||||
|
) -> ContactPerson | None:
|
||||||
|
"""Add a contact person to an existing contact.
|
||||||
|
|
||||||
|
Returns None if the contact is not found or is deleted.
|
||||||
|
"""
|
||||||
|
contact = await get_contact_by_id(db, contact_id)
|
||||||
|
if contact is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
person = ContactPerson(
|
||||||
|
contact_id=contact_id,
|
||||||
|
name=person_data["name"],
|
||||||
|
function=person_data.get("function"),
|
||||||
|
phone=person_data.get("phone"),
|
||||||
|
email=person_data.get("email"),
|
||||||
|
)
|
||||||
|
db.add(person)
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(person)
|
||||||
|
return person
|
||||||
|
|
||||||
|
|
||||||
|
async def remove_contact_person(
|
||||||
|
db: AsyncSession,
|
||||||
|
contact_id: uuid.UUID,
|
||||||
|
person_id: uuid.UUID,
|
||||||
|
) -> bool:
|
||||||
|
"""Remove (hard-delete) a contact person from a contact.
|
||||||
|
|
||||||
|
Returns True if the person was found and deleted, False otherwise.
|
||||||
|
"""
|
||||||
|
stmt = select(ContactPerson).where(
|
||||||
|
and_(
|
||||||
|
ContactPerson.id == person_id,
|
||||||
|
ContactPerson.contact_id == contact_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
person = result.scalar_one_or_none()
|
||||||
|
if person is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
await db.delete(person)
|
||||||
|
await db.flush()
|
||||||
|
return True
|
||||||
@@ -0,0 +1,409 @@
|
|||||||
|
"""Copilot service: chat via OpenRouter, action execution, history retrieval, voice transcription.
|
||||||
|
|
||||||
|
The service:
|
||||||
|
1. Calls OpenRouter chat completions with the ERP system prompt
|
||||||
|
2. Parses the AI response for text + action proposals
|
||||||
|
3. Persists user and assistant messages to the DB
|
||||||
|
4. Executes confirmed actions via copilot_actions registry
|
||||||
|
5. Provides paginated chat history per user
|
||||||
|
6. Accepts voice audio and transcribes (stub for now, OpenRouter-compatible)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from sqlalchemy import and_, func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.models.copilot import CopilotChat, CopilotRole, CopilotSession
|
||||||
|
from app.utils.copilot_actions import execute_action
|
||||||
|
from app.utils.copilot_prompt import build_system_prompt
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Model for chat completions (text-only, cheaper than vision model)
|
||||||
|
CHAT_MODEL = "qwen/qwen2.5-72b-instruct"
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_ai_response(raw_content: str) -> dict[str, Any]:
|
||||||
|
"""Parse the AI response into text + actions.
|
||||||
|
|
||||||
|
Handles markdown code fences and extracts the JSON object.
|
||||||
|
Falls back to treating the entire content as text response with no actions.
|
||||||
|
"""
|
||||||
|
text = raw_content.strip()
|
||||||
|
|
||||||
|
# Strip markdown code fences if present
|
||||||
|
if text.startswith("```"):
|
||||||
|
lines = text.split("\n")
|
||||||
|
lines = [line for line in lines if not line.strip().startswith("```")]
|
||||||
|
text = "\n".join(lines).strip()
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = json.loads(text)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
# Try to find JSON object within the text
|
||||||
|
start = text.find("{")
|
||||||
|
end = text.rfind("}")
|
||||||
|
if start != -1 and end != -1 and end > start:
|
||||||
|
try:
|
||||||
|
data = json.loads(text[start : end + 1])
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
logger.warning("Failed to parse AI response as JSON, using raw text")
|
||||||
|
return {"response": raw_content.strip(), "actions": []}
|
||||||
|
else:
|
||||||
|
logger.warning("No JSON found in AI response, using raw text")
|
||||||
|
return {"response": raw_content.strip(), "actions": []}
|
||||||
|
|
||||||
|
response_text = data.get("response", "")
|
||||||
|
actions = data.get("actions", [])
|
||||||
|
|
||||||
|
if not isinstance(response_text, str):
|
||||||
|
response_text = str(response_text)
|
||||||
|
if not isinstance(actions, list):
|
||||||
|
actions = []
|
||||||
|
|
||||||
|
# Validate action structure
|
||||||
|
valid_actions = []
|
||||||
|
for action in actions:
|
||||||
|
if isinstance(action, dict) and "type" in action:
|
||||||
|
valid_actions.append(
|
||||||
|
{
|
||||||
|
"type": action["type"],
|
||||||
|
"params": action.get("params", {}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"response": response_text, "actions": valid_actions}
|
||||||
|
|
||||||
|
|
||||||
|
async def _call_openrouter_chat(
|
||||||
|
messages: list[dict[str, Any]],
|
||||||
|
api_key: str | None = None,
|
||||||
|
model: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Call OpenRouter chat completions endpoint.
|
||||||
|
|
||||||
|
Returns the raw content string from the model.
|
||||||
|
Raises httpx.HTTPStatusError on API failure.
|
||||||
|
"""
|
||||||
|
key = api_key or settings.OPENROUTER_API_KEY
|
||||||
|
if not key:
|
||||||
|
raise ValueError("OPENROUTER_API_KEY is not configured")
|
||||||
|
|
||||||
|
model_name = model or CHAT_MODEL
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"model": model_name,
|
||||||
|
"messages": messages,
|
||||||
|
"temperature": 0.3,
|
||||||
|
"max_tokens": 2048,
|
||||||
|
}
|
||||||
|
|
||||||
|
base_url = settings.OPENROUTER_BASE_URL.rstrip("/")
|
||||||
|
url = f"{base_url}/chat/completions"
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=httpx.Timeout(60.0)) as client:
|
||||||
|
response = await client.post(url, headers=headers, json=payload)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
body = response.json()
|
||||||
|
content = body.get("choices", [{}])[0].get("message", {}).get("content", "")
|
||||||
|
return content
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_or_create_session(
|
||||||
|
db: AsyncSession,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
session_id: str | None = None,
|
||||||
|
first_message: str | None = None,
|
||||||
|
) -> CopilotSession:
|
||||||
|
"""Get an existing session or create a new one.
|
||||||
|
|
||||||
|
If creating a new session, uses the first message as title (truncated).
|
||||||
|
"""
|
||||||
|
if session_id:
|
||||||
|
try:
|
||||||
|
sid = uuid.UUID(session_id)
|
||||||
|
stmt = select(CopilotSession).where(
|
||||||
|
and_(CopilotSession.id == sid, CopilotSession.user_id == user_id)
|
||||||
|
)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
session = result.scalar_one_or_none()
|
||||||
|
if session:
|
||||||
|
return session
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass # Invalid UUID, create new session
|
||||||
|
|
||||||
|
# Create new session
|
||||||
|
title = "Neue Konversation"
|
||||||
|
if first_message:
|
||||||
|
title = first_message[:100] if len(first_message) > 100 else first_message
|
||||||
|
|
||||||
|
session = CopilotSession(user_id=user_id, title=title)
|
||||||
|
db.add(session)
|
||||||
|
await db.flush()
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
async def chat(
|
||||||
|
db: AsyncSession,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
message: str,
|
||||||
|
session_id: str | None = None,
|
||||||
|
api_key: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Process a user message through the Copilot.
|
||||||
|
|
||||||
|
1. Get or create a session
|
||||||
|
2. Save the user message
|
||||||
|
3. Build conversation context from recent history
|
||||||
|
4. Call OpenRouter with system prompt
|
||||||
|
5. Parse response for text + actions
|
||||||
|
6. Save the assistant message
|
||||||
|
7. Return response with actions and IDs
|
||||||
|
"""
|
||||||
|
session = await _get_or_create_session(
|
||||||
|
db, user_id, session_id, first_message=message
|
||||||
|
)
|
||||||
|
|
||||||
|
# Save user message
|
||||||
|
user_msg = CopilotChat(
|
||||||
|
session_id=session.id,
|
||||||
|
user_id=user_id,
|
||||||
|
role=CopilotRole.user,
|
||||||
|
content=message,
|
||||||
|
actions=None,
|
||||||
|
)
|
||||||
|
db.add(user_msg)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
# Build conversation context from recent messages in this session
|
||||||
|
stmt = (
|
||||||
|
select(CopilotChat)
|
||||||
|
.where(CopilotChat.session_id == session.id)
|
||||||
|
.order_by(CopilotChat.created_at.asc())
|
||||||
|
.limit(20) # Keep last 20 messages for context
|
||||||
|
)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
recent_messages = list(result.scalars().all())
|
||||||
|
|
||||||
|
# Build messages for OpenRouter
|
||||||
|
system_prompt = build_system_prompt()
|
||||||
|
messages: list[dict[str, Any]] = [{"role": "system", "content": system_prompt}]
|
||||||
|
|
||||||
|
for msg in recent_messages:
|
||||||
|
messages.append({"role": msg.role.value, "content": msg.content})
|
||||||
|
|
||||||
|
# Call OpenRouter
|
||||||
|
raw_content = await _call_openrouter_chat(messages, api_key=api_key)
|
||||||
|
parsed = _parse_ai_response(raw_content)
|
||||||
|
|
||||||
|
# Save assistant message
|
||||||
|
assistant_msg = CopilotChat(
|
||||||
|
session_id=session.id,
|
||||||
|
user_id=user_id,
|
||||||
|
role=CopilotRole.assistant,
|
||||||
|
content=parsed["response"],
|
||||||
|
actions=parsed["actions"] if parsed["actions"] else None,
|
||||||
|
)
|
||||||
|
db.add(assistant_msg)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"response": parsed["response"],
|
||||||
|
"actions": parsed["actions"],
|
||||||
|
"session_id": str(session.id),
|
||||||
|
"message_id": str(assistant_msg.id),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def execute_confirmed_action(
|
||||||
|
db: AsyncSession,
|
||||||
|
action_type: str,
|
||||||
|
params: dict[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Execute a user-confirmed action.
|
||||||
|
|
||||||
|
Returns {action, result, success}.
|
||||||
|
Raises ValueError for unknown actions.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = await execute_action(db, action_type, params)
|
||||||
|
return {
|
||||||
|
"action": action_type,
|
||||||
|
"result": result,
|
||||||
|
"success": True,
|
||||||
|
}
|
||||||
|
except ValueError:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Action execution failed: %s", exc)
|
||||||
|
return {
|
||||||
|
"action": action_type,
|
||||||
|
"result": {"error": str(exc)},
|
||||||
|
"success": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def get_history(
|
||||||
|
db: AsyncSession,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
page: int = 1,
|
||||||
|
page_size: int = 20,
|
||||||
|
session_id: str | None = None,
|
||||||
|
) -> tuple[list[CopilotChat], int]:
|
||||||
|
"""Get paginated chat history for a user.
|
||||||
|
|
||||||
|
Optionally filtered by session_id.
|
||||||
|
Returns (messages, total_count).
|
||||||
|
"""
|
||||||
|
conditions = [CopilotChat.user_id == user_id]
|
||||||
|
|
||||||
|
if session_id:
|
||||||
|
try:
|
||||||
|
sid = uuid.UUID(session_id)
|
||||||
|
conditions.append(CopilotChat.session_id == sid)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass # Ignore invalid session_id
|
||||||
|
|
||||||
|
# Count
|
||||||
|
count_stmt = select(func.count(CopilotChat.id)).where(and_(*conditions))
|
||||||
|
total_result = await db.execute(count_stmt)
|
||||||
|
total = total_result.scalar_one()
|
||||||
|
|
||||||
|
# Data
|
||||||
|
offset = (page - 1) * page_size
|
||||||
|
data_stmt = (
|
||||||
|
select(CopilotChat)
|
||||||
|
.where(and_(*conditions))
|
||||||
|
.order_by(CopilotChat.created_at.desc())
|
||||||
|
.offset(offset)
|
||||||
|
.limit(page_size)
|
||||||
|
)
|
||||||
|
result = await db.execute(data_stmt)
|
||||||
|
messages = list(result.scalars().all())
|
||||||
|
|
||||||
|
return messages, total
|
||||||
|
|
||||||
|
|
||||||
|
async def get_sessions(
|
||||||
|
db: AsyncSession,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
page: int = 1,
|
||||||
|
page_size: int = 20,
|
||||||
|
) -> tuple[list[CopilotSession], int]:
|
||||||
|
"""Get paginated chat sessions for a user.
|
||||||
|
|
||||||
|
Returns (sessions, total_count).
|
||||||
|
"""
|
||||||
|
count_stmt = select(func.count(CopilotSession.id)).where(
|
||||||
|
CopilotSession.user_id == user_id
|
||||||
|
)
|
||||||
|
total_result = await db.execute(count_stmt)
|
||||||
|
total = total_result.scalar_one()
|
||||||
|
|
||||||
|
offset = (page - 1) * page_size
|
||||||
|
data_stmt = (
|
||||||
|
select(CopilotSession)
|
||||||
|
.where(CopilotSession.user_id == user_id)
|
||||||
|
.order_by(CopilotSession.updated_at.desc())
|
||||||
|
.offset(offset)
|
||||||
|
.limit(page_size)
|
||||||
|
)
|
||||||
|
result = await db.execute(data_stmt)
|
||||||
|
sessions = list(result.scalars().all())
|
||||||
|
|
||||||
|
return sessions, total
|
||||||
|
|
||||||
|
|
||||||
|
async def transcribe_audio(
|
||||||
|
audio_bytes: bytes,
|
||||||
|
mime_type: str = "audio/webm",
|
||||||
|
api_key: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Transcribe audio bytes to text.
|
||||||
|
|
||||||
|
Uses OpenRouter with a multimodal model if available.
|
||||||
|
Falls back to a simple stub that returns a placeholder.
|
||||||
|
"""
|
||||||
|
key = api_key or settings.OPENROUTER_API_KEY
|
||||||
|
|
||||||
|
if not key:
|
||||||
|
# Stub: return placeholder when no API key configured
|
||||||
|
logger.warning("No OPENROUTER_API_KEY configured, using stub transcription")
|
||||||
|
return "[Audio-Transkription nicht verfügbar — OPENROUTER_API_KEY fehlt]"
|
||||||
|
|
||||||
|
# Encode audio as base64 data URI
|
||||||
|
b64 = base64.b64encode(audio_bytes).decode("utf-8")
|
||||||
|
audio_data_uri = f"data:{mime_type};base64,{b64}"
|
||||||
|
|
||||||
|
messages = [
|
||||||
|
{
|
||||||
|
"role": "system",
|
||||||
|
"content": (
|
||||||
|
"Du bist ein Transkriptions-Assistent. Transkribiere das "
|
||||||
|
"gesprochene Audio exakt wie es gesagt wurde. Gib NUR den "
|
||||||
|
"transkribierten Text zurück, keine Erklärungen."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"text": "Bitte transkribiere dieses Audio.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "input_audio",
|
||||||
|
"input_audio": {"data": audio_data_uri},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw = await _call_openrouter_chat(
|
||||||
|
messages, api_key=key, model="qwen/qwen2.5-vl-72b-instruct"
|
||||||
|
)
|
||||||
|
return raw.strip()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Audio transcription failed: %s", exc)
|
||||||
|
return f"[Transkription fehlgeschlagen: {exc}]"
|
||||||
|
|
||||||
|
|
||||||
|
async def voice_chat(
|
||||||
|
db: AsyncSession,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
audio_b64: str,
|
||||||
|
mime_type: str = "audio/webm",
|
||||||
|
session_id: str | None = None,
|
||||||
|
api_key: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Process a voice message: transcribe audio, then run chat.
|
||||||
|
|
||||||
|
Returns transcription + chat response + actions + IDs.
|
||||||
|
"""
|
||||||
|
audio_bytes = base64.b64decode(audio_b64)
|
||||||
|
transcription = await transcribe_audio(audio_bytes, mime_type, api_key=api_key)
|
||||||
|
|
||||||
|
chat_result = await chat(db, user_id, transcription, session_id, api_key=api_key)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"transcription": transcription,
|
||||||
|
"response": chat_result["response"],
|
||||||
|
"actions": chat_result["actions"],
|
||||||
|
"session_id": chat_result["session_id"],
|
||||||
|
"message_id": chat_result["message_id"],
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
"""DATEV export service: create exports, list exports, generate CSV."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from sqlalchemy import and_, func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from app.models.datev_export import DATEVExport
|
||||||
|
from app.models.sale import Sale
|
||||||
|
from app.utils.datev import generate_datev_csv
|
||||||
|
|
||||||
|
|
||||||
|
async def create_export(
|
||||||
|
db: AsyncSession,
|
||||||
|
start_date: date,
|
||||||
|
end_date: date,
|
||||||
|
output_dir: str = "/tmp/datev_exports",
|
||||||
|
) -> DATEVExport:
|
||||||
|
"""Create a DATEV export for the given date range.
|
||||||
|
|
||||||
|
Queries all completed sales within the date range, generates a CSV file,
|
||||||
|
and stores the export record.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Async session.
|
||||||
|
start_date: Start of the date range (inclusive).
|
||||||
|
end_date: End of the date range (inclusive).
|
||||||
|
output_dir: Directory to save the CSV file.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
DATEVExport model instance.
|
||||||
|
"""
|
||||||
|
# Query completed sales within date range
|
||||||
|
stmt = (
|
||||||
|
select(Sale)
|
||||||
|
.options(
|
||||||
|
selectinload(Sale.vehicle),
|
||||||
|
selectinload(Sale.buyer),
|
||||||
|
selectinload(Sale.seller),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
and_(
|
||||||
|
Sale.sale_date >= start_date,
|
||||||
|
Sale.sale_date <= end_date,
|
||||||
|
Sale.status == "completed",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by(Sale.sale_date.asc())
|
||||||
|
)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
sales = list(result.scalars().all())
|
||||||
|
|
||||||
|
# Generate CSV content
|
||||||
|
csv_content = generate_datev_csv(sales)
|
||||||
|
|
||||||
|
# Calculate total amount
|
||||||
|
total_amount = sum((s.sale_price or Decimal("0")) for s in sales)
|
||||||
|
|
||||||
|
# Save CSV file
|
||||||
|
os.makedirs(output_dir, exist_ok=True)
|
||||||
|
export_id = uuid.uuid4()
|
||||||
|
file_path = os.path.join(output_dir, f"datev_export_{export_id}.csv")
|
||||||
|
with open(file_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(csv_content)
|
||||||
|
|
||||||
|
# Create export record
|
||||||
|
export = DATEVExport(
|
||||||
|
id=export_id,
|
||||||
|
start_date=start_date,
|
||||||
|
end_date=end_date,
|
||||||
|
file_path=file_path,
|
||||||
|
total_amount=total_amount,
|
||||||
|
)
|
||||||
|
db.add(export)
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(export)
|
||||||
|
|
||||||
|
return export
|
||||||
|
|
||||||
|
|
||||||
|
async def list_exports(
|
||||||
|
db: AsyncSession,
|
||||||
|
page: int = 1,
|
||||||
|
page_size: int = 20,
|
||||||
|
) -> tuple[list[DATEVExport], int]:
|
||||||
|
"""List DATEV exports with pagination.
|
||||||
|
|
||||||
|
Returns (exports, total_count).
|
||||||
|
"""
|
||||||
|
count_stmt = select(func.count(DATEVExport.id))
|
||||||
|
total_result = await db.execute(count_stmt)
|
||||||
|
total = total_result.scalar_one()
|
||||||
|
|
||||||
|
data_stmt = (
|
||||||
|
select(DATEVExport)
|
||||||
|
.order_by(DATEVExport.created_at.desc())
|
||||||
|
.offset((page - 1) * page_size)
|
||||||
|
.limit(page_size)
|
||||||
|
)
|
||||||
|
result = await db.execute(data_stmt)
|
||||||
|
exports = list(result.scalars().all())
|
||||||
|
|
||||||
|
return exports, total
|
||||||
|
|
||||||
|
|
||||||
|
async def get_export_csv(
|
||||||
|
db: AsyncSession, export_id: uuid.UUID
|
||||||
|
) -> tuple[str, bytes] | None:
|
||||||
|
"""Get the CSV content for a DATEV export.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Async session.
|
||||||
|
export_id: Export ID.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (filename, csv_bytes) or None if export not found.
|
||||||
|
"""
|
||||||
|
stmt = select(DATEVExport).where(DATEVExport.id == export_id)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
export = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if export is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if export.file_path and os.path.exists(export.file_path):
|
||||||
|
with open(export.file_path, "r", encoding="utf-8") as f:
|
||||||
|
csv_content = f.read()
|
||||||
|
else:
|
||||||
|
# Regenerate from sales if file is missing
|
||||||
|
sales_stmt = (
|
||||||
|
select(Sale)
|
||||||
|
.options(selectinload(Sale.vehicle))
|
||||||
|
.where(
|
||||||
|
and_(
|
||||||
|
Sale.sale_date >= export.start_date,
|
||||||
|
Sale.sale_date <= export.end_date,
|
||||||
|
Sale.status == "completed",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by(Sale.sale_date.asc())
|
||||||
|
)
|
||||||
|
sales_result = await db.execute(sales_stmt)
|
||||||
|
sales = list(sales_result.scalars().all())
|
||||||
|
csv_content = generate_datev_csv(sales)
|
||||||
|
|
||||||
|
filename = f"datev_export_{export.id}.csv"
|
||||||
|
return filename, csv_content.encode("utf-8")
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
"""File service: upload, retrieve, list, delete, and validation for vehicle files.
|
||||||
|
|
||||||
|
All file operations use UPLOAD_DIR from config for storage.
|
||||||
|
Supports images (jpg, png, webp), documents (pdf, doc, docx), and scans (jpg, png).
|
||||||
|
Enforces MAX_FILE_SIZE_MB from config (default 50MB, but endpoint enforces 20MB).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.models.file import File
|
||||||
|
from app.utils.thumbnails import generate_thumbnail, is_image_mime_type
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Allowed MIME types for upload
|
||||||
|
ALLOWED_MIME_TYPES = {
|
||||||
|
# Images
|
||||||
|
"image/jpeg",
|
||||||
|
"image/png",
|
||||||
|
"image/webp",
|
||||||
|
# Documents
|
||||||
|
"application/pdf",
|
||||||
|
"application/msword",
|
||||||
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Map MIME types to file extensions for validation
|
||||||
|
MIME_TO_EXTENSIONS = {
|
||||||
|
"image/jpeg": {"jpg", "jpeg"},
|
||||||
|
"image/png": {"png"},
|
||||||
|
"image/webp": {"webp"},
|
||||||
|
"application/pdf": {"pdf"},
|
||||||
|
"application/msword": {"doc"},
|
||||||
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": {"docx"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def validate_mime_type(mime_type: str, filename: str) -> bool:
|
||||||
|
"""Validate that the MIME type is allowed and matches the file extension.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
mime_type: The MIME type from the upload.
|
||||||
|
filename: The original filename to check extension consistency.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if the MIME type is allowed and extension matches.
|
||||||
|
"""
|
||||||
|
mime_type = mime_type.lower().strip()
|
||||||
|
if mime_type not in ALLOWED_MIME_TYPES:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check extension consistency
|
||||||
|
ext = Path(filename).suffix.lower().lstrip(".")
|
||||||
|
allowed_extensions = MIME_TO_EXTENSIONS.get(mime_type, set())
|
||||||
|
if ext not in allowed_extensions:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def validate_file_size(file_size: int, max_size_mb: int = 20) -> bool:
|
||||||
|
"""Validate that the file size is within the allowed limit.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_size: File size in bytes.
|
||||||
|
max_size_mb: Maximum file size in MB (default 20MB for uploads).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if the file size is within the limit.
|
||||||
|
"""
|
||||||
|
max_size_bytes = max_size_mb * 1024 * 1024
|
||||||
|
return file_size <= max_size_bytes
|
||||||
|
|
||||||
|
|
||||||
|
async def upload_file(
|
||||||
|
db: AsyncSession,
|
||||||
|
vehicle_id: uuid.UUID,
|
||||||
|
file_content: bytes,
|
||||||
|
original_filename: str,
|
||||||
|
mime_type: str,
|
||||||
|
) -> File:
|
||||||
|
"""Upload a file for a vehicle.
|
||||||
|
|
||||||
|
Validates MIME type and file size, stores the file in UPLOAD_DIR,
|
||||||
|
generates a thumbnail for images, and creates a DB record.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If MIME type or file size validation fails.
|
||||||
|
"""
|
||||||
|
# Validate MIME type
|
||||||
|
if not validate_mime_type(mime_type, original_filename):
|
||||||
|
raise ValueError(
|
||||||
|
f"Unsupported MIME type: {mime_type} for file: {original_filename}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate file size (20MB limit for uploads)
|
||||||
|
file_size = len(file_content)
|
||||||
|
if not validate_file_size(file_size, max_size_mb=20):
|
||||||
|
raise ValueError(f"File size {file_size} bytes exceeds 20MB limit")
|
||||||
|
|
||||||
|
# Generate stored filename
|
||||||
|
ext = Path(original_filename).suffix.lower()
|
||||||
|
stored_filename = f"{uuid.uuid4().hex}{ext}"
|
||||||
|
|
||||||
|
# Create vehicle-specific upload directory
|
||||||
|
upload_dir = Path(settings.UPLOAD_DIR) / str(vehicle_id)
|
||||||
|
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
file_path = upload_dir / stored_filename
|
||||||
|
|
||||||
|
# Write file to disk
|
||||||
|
with open(file_path, "wb") as f:
|
||||||
|
f.write(file_content)
|
||||||
|
|
||||||
|
logger.info("File uploaded: %s -> %s", original_filename, file_path)
|
||||||
|
|
||||||
|
# Generate thumbnail for images
|
||||||
|
thumbnail_path = None
|
||||||
|
if is_image_mime_type(mime_type):
|
||||||
|
thumbnail_dir = upload_dir / "thumbnails"
|
||||||
|
thumbnail_path = generate_thumbnail(
|
||||||
|
source_path=file_path,
|
||||||
|
thumbnail_dir=thumbnail_dir,
|
||||||
|
stored_filename=stored_filename,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create DB record
|
||||||
|
file_record = File(
|
||||||
|
vehicle_id=vehicle_id,
|
||||||
|
original_filename=original_filename,
|
||||||
|
stored_filename=stored_filename,
|
||||||
|
file_path=str(file_path),
|
||||||
|
mime_type=mime_type,
|
||||||
|
file_size=file_size,
|
||||||
|
thumbnail_path=thumbnail_path,
|
||||||
|
)
|
||||||
|
db.add(file_record)
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(file_record)
|
||||||
|
|
||||||
|
return file_record
|
||||||
|
|
||||||
|
|
||||||
|
async def get_file(
|
||||||
|
db: AsyncSession,
|
||||||
|
vehicle_id: uuid.UUID,
|
||||||
|
file_id: uuid.UUID,
|
||||||
|
) -> Optional[File]:
|
||||||
|
"""Get a single file by ID and vehicle ID."""
|
||||||
|
stmt = select(File).where(
|
||||||
|
File.id == file_id,
|
||||||
|
File.vehicle_id == vehicle_id,
|
||||||
|
)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def list_files(
|
||||||
|
db: AsyncSession,
|
||||||
|
vehicle_id: uuid.UUID,
|
||||||
|
page: int = 1,
|
||||||
|
page_size: int = 20,
|
||||||
|
) -> tuple[list[File], int]:
|
||||||
|
"""List files for a vehicle with pagination.
|
||||||
|
|
||||||
|
Returns (files, total_count).
|
||||||
|
"""
|
||||||
|
# Count total files for this vehicle
|
||||||
|
count_stmt = select(func.count(File.id)).where(File.vehicle_id == vehicle_id)
|
||||||
|
count_result = await db.execute(count_stmt)
|
||||||
|
total = count_result.scalar_one()
|
||||||
|
|
||||||
|
# Fetch paginated files
|
||||||
|
offset = (page - 1) * page_size
|
||||||
|
data_stmt = (
|
||||||
|
select(File)
|
||||||
|
.where(File.vehicle_id == vehicle_id)
|
||||||
|
.order_by(File.created_at.desc())
|
||||||
|
.offset(offset)
|
||||||
|
.limit(page_size)
|
||||||
|
)
|
||||||
|
result = await db.execute(data_stmt)
|
||||||
|
files = list(result.scalars().all())
|
||||||
|
|
||||||
|
return files, total
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_file(
|
||||||
|
db: AsyncSession,
|
||||||
|
vehicle_id: uuid.UUID,
|
||||||
|
file_id: uuid.UUID,
|
||||||
|
) -> Optional[File]:
|
||||||
|
"""Delete a file: remove from disk and DB.
|
||||||
|
|
||||||
|
Returns the deleted file record, or None if not found.
|
||||||
|
"""
|
||||||
|
file_record = await get_file(db, vehicle_id, file_id)
|
||||||
|
if file_record is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Remove file from disk
|
||||||
|
file_path = Path(file_record.file_path)
|
||||||
|
if file_path.exists():
|
||||||
|
try:
|
||||||
|
file_path.unlink()
|
||||||
|
logger.info("File deleted from disk: %s", file_path)
|
||||||
|
except OSError as exc:
|
||||||
|
logger.error("Failed to delete file from disk: %s", exc)
|
||||||
|
|
||||||
|
# Remove thumbnail from disk if it exists
|
||||||
|
if file_record.thumbnail_path:
|
||||||
|
thumb_path = Path(file_record.thumbnail_path)
|
||||||
|
if thumb_path.exists():
|
||||||
|
try:
|
||||||
|
thumb_path.unlink()
|
||||||
|
logger.info("Thumbnail deleted from disk: %s", thumb_path)
|
||||||
|
except OSError as exc:
|
||||||
|
logger.error("Failed to delete thumbnail from disk: %s", exc)
|
||||||
|
|
||||||
|
# Remove from DB
|
||||||
|
await db.delete(file_record)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
return file_record
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
"""mobile.de integration service: push, update, delete listings and check status.
|
||||||
|
|
||||||
|
Uses httpx for async HTTP calls to the mobile.de seller listings API.
|
||||||
|
API key and seller ID are loaded from environment variables.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.models.vehicle import MobileDeListing, Vehicle
|
||||||
|
from app.utils.mobilede_mapping import map_fields
|
||||||
|
|
||||||
|
# mobile.de API base URL
|
||||||
|
_MOBILE_DE_API_BASE = "https://api.mobile.de"
|
||||||
|
|
||||||
|
# Maximum retry attempts for failed pushes
|
||||||
|
MAX_RETRIES = 3
|
||||||
|
|
||||||
|
|
||||||
|
def _get_api_headers() -> dict[str, str]:
|
||||||
|
"""Build authorization headers for mobile.de API."""
|
||||||
|
api_key = settings.MOBILE_DE_API_KEY
|
||||||
|
return {
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json",
|
||||||
|
"X-Mobile-DE-Seller-ID": settings.MOBILE_DE_SELLER_ID,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_listing_url(listing_id: str | None = None) -> str:
|
||||||
|
"""Build the mobile.de listings URL."""
|
||||||
|
base = f"{_MOBILE_DE_API_BASE}/api/seller/listings"
|
||||||
|
if listing_id:
|
||||||
|
return f"{base}/{listing_id}"
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
def _get_status_url(listing_id: str) -> str:
|
||||||
|
"""Build the mobile.de listing status URL."""
|
||||||
|
return f"{_MOBILE_DE_API_BASE}/api/seller/listings/{listing_id}/status"
|
||||||
|
|
||||||
|
|
||||||
|
async def push_listing(db: AsyncSession, vehicle: Vehicle) -> MobileDeListing:
|
||||||
|
"""Push a vehicle listing to mobile.de.
|
||||||
|
|
||||||
|
Creates a MobileDeListing record with status 'pending',
|
||||||
|
sends the mapped ad data to mobile.de POST /api/seller/listings,
|
||||||
|
and updates the listing with the returned ad_id and 'synced' status.
|
||||||
|
|
||||||
|
On failure, sets sync_status to 'fehler' with error_log.
|
||||||
|
"""
|
||||||
|
# Create listing record
|
||||||
|
listing = MobileDeListing(
|
||||||
|
vehicle_id=vehicle.id,
|
||||||
|
sync_status="pending",
|
||||||
|
)
|
||||||
|
db.add(listing)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
# Map vehicle fields to mobile.de ad format
|
||||||
|
ad_data = map_fields(vehicle)
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
|
response = await client.post(
|
||||||
|
_get_listing_url(),
|
||||||
|
json=ad_data,
|
||||||
|
headers=_get_api_headers(),
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
result = response.json()
|
||||||
|
ad_id = result.get("id") or result.get("listingId")
|
||||||
|
|
||||||
|
listing.ad_id = str(ad_id) if ad_id else None
|
||||||
|
listing.sync_status = "synced"
|
||||||
|
listing.synced_at = datetime.now(timezone.utc)
|
||||||
|
listing.error_log = None
|
||||||
|
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
listing.sync_status = "fehler"
|
||||||
|
listing.error_log = (
|
||||||
|
f"HTTP {exc.response.status_code}: {exc.response.text[:500]}"
|
||||||
|
)
|
||||||
|
except (httpx.RequestError, httpx.HTTPError) as exc:
|
||||||
|
listing.sync_status = "fehler"
|
||||||
|
listing.error_log = f"Request error: {str(exc)[:500]}"
|
||||||
|
except Exception as exc:
|
||||||
|
listing.sync_status = "fehler"
|
||||||
|
listing.error_log = f"Unexpected error: {str(exc)[:500]}"
|
||||||
|
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(listing)
|
||||||
|
return listing
|
||||||
|
|
||||||
|
|
||||||
|
async def update_listing(
|
||||||
|
db: AsyncSession, vehicle: Vehicle, listing: MobileDeListing
|
||||||
|
) -> MobileDeListing:
|
||||||
|
"""Update an existing mobile.de listing.
|
||||||
|
|
||||||
|
Sends PUT /api/seller/listings/{id} with updated ad data.
|
||||||
|
"""
|
||||||
|
if not listing.ad_id:
|
||||||
|
listing.sync_status = "fehler"
|
||||||
|
listing.error_log = "Cannot update listing without ad_id"
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(listing)
|
||||||
|
return listing
|
||||||
|
|
||||||
|
ad_data = map_fields(vehicle)
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
|
response = await client.put(
|
||||||
|
_get_listing_url(listing.ad_id),
|
||||||
|
json=ad_data,
|
||||||
|
headers=_get_api_headers(),
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
listing.sync_status = "synced"
|
||||||
|
listing.synced_at = datetime.now(timezone.utc)
|
||||||
|
listing.error_log = None
|
||||||
|
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
listing.sync_status = "fehler"
|
||||||
|
listing.error_log = (
|
||||||
|
f"HTTP {exc.response.status_code}: {exc.response.text[:500]}"
|
||||||
|
)
|
||||||
|
except (httpx.RequestError, httpx.HTTPError) as exc:
|
||||||
|
listing.sync_status = "fehler"
|
||||||
|
listing.error_log = f"Request error: {str(exc)[:500]}"
|
||||||
|
except Exception as exc:
|
||||||
|
listing.sync_status = "fehler"
|
||||||
|
listing.error_log = f"Unexpected error: {str(exc)[:500]}"
|
||||||
|
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(listing)
|
||||||
|
return listing
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_listing(db: AsyncSession, listing: MobileDeListing) -> MobileDeListing:
|
||||||
|
"""Delete a listing from mobile.de.
|
||||||
|
|
||||||
|
Sends DELETE /api/seller/listings/{id}.
|
||||||
|
"""
|
||||||
|
if not listing.ad_id:
|
||||||
|
listing.sync_status = "fehler"
|
||||||
|
listing.error_log = "Cannot delete listing without ad_id"
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(listing)
|
||||||
|
return listing
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
|
response = await client.delete(
|
||||||
|
_get_listing_url(listing.ad_id),
|
||||||
|
headers=_get_api_headers(),
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
listing.sync_status = "deleted"
|
||||||
|
listing.error_log = None
|
||||||
|
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
listing.sync_status = "fehler"
|
||||||
|
listing.error_log = (
|
||||||
|
f"HTTP {exc.response.status_code}: {exc.response.text[:500]}"
|
||||||
|
)
|
||||||
|
except (httpx.RequestError, httpx.HTTPError) as exc:
|
||||||
|
listing.sync_status = "fehler"
|
||||||
|
listing.error_log = f"Request error: {str(exc)[:500]}"
|
||||||
|
except Exception as exc:
|
||||||
|
listing.sync_status = "fehler"
|
||||||
|
listing.error_log = f"Unexpected error: {str(exc)[:500]}"
|
||||||
|
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(listing)
|
||||||
|
return listing
|
||||||
|
|
||||||
|
|
||||||
|
async def get_listing_status(
|
||||||
|
db: AsyncSession, vehicle_id: uuid.UUID
|
||||||
|
) -> MobileDeListing | None:
|
||||||
|
"""Get the latest mobile.de listing status for a vehicle.
|
||||||
|
|
||||||
|
Returns the most recent MobileDeListing record, or None if no listing exists.
|
||||||
|
"""
|
||||||
|
stmt = (
|
||||||
|
select(MobileDeListing)
|
||||||
|
.where(MobileDeListing.vehicle_id == vehicle_id)
|
||||||
|
.order_by(MobileDeListing.created_at.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def retry_failed_listing(
|
||||||
|
db: AsyncSession, listing: MobileDeListing, vehicle: Vehicle
|
||||||
|
) -> MobileDeListing:
|
||||||
|
"""Retry a failed listing push.
|
||||||
|
|
||||||
|
Increments retry count (tracked via error_log prefix) and re-attempts push.
|
||||||
|
After MAX_RETRIES, marks as permanently failed.
|
||||||
|
"""
|
||||||
|
# Count existing retries from error_log
|
||||||
|
retry_count = 0
|
||||||
|
if listing.error_log and listing.error_log.startswith("[retry"):
|
||||||
|
try:
|
||||||
|
retry_count = int(listing.error_log.split("]")[0].split("retry ")[1])
|
||||||
|
except (IndexError, ValueError):
|
||||||
|
retry_count = 0
|
||||||
|
|
||||||
|
if retry_count >= MAX_RETRIES:
|
||||||
|
listing.sync_status = "fehler"
|
||||||
|
listing.error_log = (
|
||||||
|
f"Max retries ({MAX_RETRIES}) exceeded. Last error: {listing.error_log}"
|
||||||
|
)
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(listing)
|
||||||
|
return listing
|
||||||
|
|
||||||
|
# Attempt re-push
|
||||||
|
listing.sync_status = "pending"
|
||||||
|
listing.error_log = f"[retry {retry_count + 1}] Retrying push"
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
return await push_listing(db, vehicle)
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
"""OCR service: file upload, result retrieval, list, apply-to-vehicle, and async processing."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import and_, func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.models.ocr_result import OCRResult, OCRStatus
|
||||||
|
from app.models.vehicle import Vehicle
|
||||||
|
from app.utils.openrouter import perform_ocr
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Confidence threshold: below this, status is set to manual_review
|
||||||
|
CONFIDENCE_THRESHOLD = 0.7
|
||||||
|
|
||||||
|
# Allowed MIME types for OCR uploads
|
||||||
|
ALLOWED_MIME_TYPES = {"image/png", "image/jpeg", "image/jpg", "image/webp", "image/gif"}
|
||||||
|
|
||||||
|
|
||||||
|
def validate_mime_type(mime_type: str) -> bool:
|
||||||
|
"""Check if the MIME type is allowed for OCR uploads."""
|
||||||
|
return mime_type in ALLOWED_MIME_TYPES
|
||||||
|
|
||||||
|
|
||||||
|
def validate_file_size(file_size_bytes: int) -> bool:
|
||||||
|
"""Check if the file size is within the configured limit."""
|
||||||
|
max_bytes = settings.MAX_FILE_SIZE_MB * 1024 * 1024
|
||||||
|
return file_size_bytes <= max_bytes
|
||||||
|
|
||||||
|
|
||||||
|
async def upload_file(
|
||||||
|
db: AsyncSession,
|
||||||
|
file_bytes: bytes,
|
||||||
|
file_name: str,
|
||||||
|
mime_type: str,
|
||||||
|
vehicle_id: uuid.UUID | None = None,
|
||||||
|
) -> OCRResult:
|
||||||
|
"""Save uploaded file to disk and create an OCRResult record with status=pending.
|
||||||
|
|
||||||
|
Validates MIME type and file size before saving.
|
||||||
|
"""
|
||||||
|
if not validate_mime_type(mime_type):
|
||||||
|
raise ValueError(
|
||||||
|
f"Invalid MIME type: {mime_type}. Allowed: {ALLOWED_MIME_TYPES}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not validate_file_size(len(file_bytes)):
|
||||||
|
raise ValueError(f"File size exceeds limit of {settings.MAX_FILE_SIZE_MB} MB")
|
||||||
|
|
||||||
|
# Ensure upload directory exists
|
||||||
|
upload_dir = settings.UPLOAD_DIR
|
||||||
|
os.makedirs(upload_dir, exist_ok=True)
|
||||||
|
|
||||||
|
# Generate unique filename
|
||||||
|
file_ext = os.path.splitext(file_name)[1] or ".png"
|
||||||
|
unique_name = f"{uuid.uuid4().hex}{file_ext}"
|
||||||
|
file_path = os.path.join(upload_dir, unique_name)
|
||||||
|
|
||||||
|
# Write file to disk
|
||||||
|
with open(file_path, "wb") as f:
|
||||||
|
f.write(file_bytes)
|
||||||
|
|
||||||
|
# Create OCR result record
|
||||||
|
ocr_result = OCRResult(
|
||||||
|
vehicle_id=vehicle_id,
|
||||||
|
file_path=file_path,
|
||||||
|
file_name=file_name,
|
||||||
|
mime_type=mime_type,
|
||||||
|
status=OCRStatus.pending,
|
||||||
|
)
|
||||||
|
db.add(ocr_result)
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(ocr_result)
|
||||||
|
|
||||||
|
return ocr_result
|
||||||
|
|
||||||
|
|
||||||
|
async def get_result(db: AsyncSession, result_id: uuid.UUID) -> OCRResult | None:
|
||||||
|
"""Get a single OCR result by ID."""
|
||||||
|
stmt = select(OCRResult).where(OCRResult.id == result_id)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def list_results(
|
||||||
|
db: AsyncSession,
|
||||||
|
vehicle_id: uuid.UUID | None = None,
|
||||||
|
page: int = 1,
|
||||||
|
page_size: int = 20,
|
||||||
|
) -> tuple[list[OCRResult], int]:
|
||||||
|
"""List OCR results, optionally filtered by vehicle_id, with pagination."""
|
||||||
|
conditions = []
|
||||||
|
if vehicle_id is not None:
|
||||||
|
conditions.append(OCRResult.vehicle_id == vehicle_id)
|
||||||
|
|
||||||
|
# Count query
|
||||||
|
count_stmt = select(func.count(OCRResult.id))
|
||||||
|
if conditions:
|
||||||
|
count_stmt = count_stmt.where(and_(*conditions))
|
||||||
|
total_result = await db.execute(count_stmt)
|
||||||
|
total = total_result.scalar_one()
|
||||||
|
|
||||||
|
# Data query
|
||||||
|
data_stmt = select(OCRResult).order_by(OCRResult.created_at.desc())
|
||||||
|
if conditions:
|
||||||
|
data_stmt = data_stmt.where(and_(*conditions))
|
||||||
|
|
||||||
|
offset = (page - 1) * page_size
|
||||||
|
data_stmt = data_stmt.offset(offset).limit(page_size)
|
||||||
|
|
||||||
|
result = await db.execute(data_stmt)
|
||||||
|
items = list(result.scalars().all())
|
||||||
|
|
||||||
|
return items, total
|
||||||
|
|
||||||
|
|
||||||
|
async def apply_to_vehicle(
|
||||||
|
db: AsyncSession, result_id: uuid.UUID
|
||||||
|
) -> tuple[OCRResult, Vehicle, list[str]]:
|
||||||
|
"""Apply OCR structured data to the linked vehicle.
|
||||||
|
|
||||||
|
Maps OCR fields to vehicle fields:
|
||||||
|
brand → make, model → model, vin → fin,
|
||||||
|
first_registration → first_registration, mileage → mileage_km,
|
||||||
|
power_kw → power_kw, fuel_type → fuel_type
|
||||||
|
|
||||||
|
Returns (ocr_result, vehicle, updated_fields).
|
||||||
|
Raises ValueError if OCR result not found, no vehicle linked, or no structured data.
|
||||||
|
"""
|
||||||
|
ocr_result = await get_result(db, result_id)
|
||||||
|
if ocr_result is None:
|
||||||
|
raise ValueError("OCR result not found")
|
||||||
|
|
||||||
|
if ocr_result.vehicle_id is None:
|
||||||
|
raise ValueError("No vehicle linked to this OCR result")
|
||||||
|
|
||||||
|
if not ocr_result.structured_data:
|
||||||
|
raise ValueError("No structured data available to apply")
|
||||||
|
|
||||||
|
# Fetch vehicle
|
||||||
|
stmt = select(Vehicle).where(
|
||||||
|
and_(Vehicle.id == ocr_result.vehicle_id, Vehicle.deleted_at.is_(None))
|
||||||
|
)
|
||||||
|
vehicle_result = await db.execute(stmt)
|
||||||
|
vehicle = vehicle_result.scalar_one_or_none()
|
||||||
|
if vehicle is None:
|
||||||
|
raise ValueError("Linked vehicle not found")
|
||||||
|
|
||||||
|
data = ocr_result.structured_data
|
||||||
|
updated_fields: list[str] = []
|
||||||
|
|
||||||
|
# Map OCR fields to vehicle fields
|
||||||
|
field_mapping = {
|
||||||
|
"brand": "make",
|
||||||
|
"model": "model",
|
||||||
|
"vin": "fin",
|
||||||
|
"first_registration": "first_registration",
|
||||||
|
"mileage": "mileage_km",
|
||||||
|
"power_kw": "power_kw",
|
||||||
|
"fuel_type": "fuel_type",
|
||||||
|
}
|
||||||
|
|
||||||
|
for ocr_field, vehicle_field in field_mapping.items():
|
||||||
|
value = data.get(ocr_field)
|
||||||
|
if value is not None and value != "":
|
||||||
|
# Parse first_registration to date
|
||||||
|
if ocr_field == "first_registration" and isinstance(value, str):
|
||||||
|
try:
|
||||||
|
from datetime import datetime as dt
|
||||||
|
|
||||||
|
parsed = dt.strptime(value, "%d.%m.%Y").date()
|
||||||
|
setattr(vehicle, vehicle_field, parsed)
|
||||||
|
updated_fields.append(vehicle_field)
|
||||||
|
continue
|
||||||
|
except ValueError:
|
||||||
|
try:
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
parsed = date.fromisoformat(value)
|
||||||
|
setattr(vehicle, vehicle_field, parsed)
|
||||||
|
updated_fields.append(vehicle_field)
|
||||||
|
continue
|
||||||
|
except ValueError:
|
||||||
|
logger.warning("Could not parse first_registration: %s", value)
|
||||||
|
continue
|
||||||
|
|
||||||
|
setattr(vehicle, vehicle_field, value)
|
||||||
|
updated_fields.append(vehicle_field)
|
||||||
|
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(vehicle)
|
||||||
|
|
||||||
|
return ocr_result, vehicle, updated_fields
|
||||||
|
|
||||||
|
|
||||||
|
async def process_ocr(db: AsyncSession, result_id: uuid.UUID) -> OCRResult:
|
||||||
|
"""Process an OCR result: read file, call OpenRouter, update result.
|
||||||
|
|
||||||
|
This is the async processing function called by the background task.
|
||||||
|
Sets status to 'completed' if confidence >= threshold, else 'manual_review'.
|
||||||
|
Sets status to 'failed' on error.
|
||||||
|
"""
|
||||||
|
ocr_result = await get_result(db, result_id)
|
||||||
|
if ocr_result is None:
|
||||||
|
raise ValueError(f"OCR result {result_id} not found")
|
||||||
|
|
||||||
|
# Update status to processing
|
||||||
|
ocr_result.status = OCRStatus.processing
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Read file from disk
|
||||||
|
with open(ocr_result.file_path, "rb") as f:
|
||||||
|
image_bytes = f.read()
|
||||||
|
|
||||||
|
# Call OpenRouter vision model
|
||||||
|
ocr_output = await perform_ocr(
|
||||||
|
image_bytes=image_bytes,
|
||||||
|
mime_type=ocr_result.mime_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update OCR result with extracted data
|
||||||
|
ocr_result.raw_text = ocr_output.get("raw_text", "")
|
||||||
|
ocr_result.structured_data = ocr_output.get("structured_data", {})
|
||||||
|
ocr_result.confidence_score = ocr_output.get("confidence_score", 0.0)
|
||||||
|
|
||||||
|
# Set status based on confidence threshold
|
||||||
|
if ocr_result.confidence_score >= CONFIDENCE_THRESHOLD:
|
||||||
|
ocr_result.status = OCRStatus.completed
|
||||||
|
else:
|
||||||
|
ocr_result.status = OCRStatus.manual_review
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("OCR processing failed for %s: %s", result_id, exc)
|
||||||
|
ocr_result.status = OCRStatus.failed
|
||||||
|
ocr_result.error_message = str(exc)
|
||||||
|
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(ocr_result)
|
||||||
|
return ocr_result
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"""Price comparison service: search comparable listings and calculate average price."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import random
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.vehicle import Vehicle
|
||||||
|
from app.schemas.retouch import ComparableListing, PriceCompareResponse
|
||||||
|
from app.services.vehicle_service import get_vehicle_by_id
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_mock_listings(vehicle: Vehicle) -> list[ComparableListing]:
|
||||||
|
"""Generate mock mobile.de comparable listings based on vehicle data.
|
||||||
|
|
||||||
|
In production, this would call the mobile.de Search API.
|
||||||
|
For now, we generate realistic mock data based on the vehicle's
|
||||||
|
make, model, year, and price.
|
||||||
|
"""
|
||||||
|
listings: list[ComparableListing] = []
|
||||||
|
base_price = float(vehicle.price) if vehicle.price else 25000.0
|
||||||
|
|
||||||
|
# Generate 3-7 comparable listings with price variation
|
||||||
|
num_listings = random.randint(3, 7)
|
||||||
|
|
||||||
|
for i in range(num_listings):
|
||||||
|
# Vary price by +/- 15%
|
||||||
|
price_variation = random.uniform(-0.15, 0.15)
|
||||||
|
listing_price = round(base_price * (1 + price_variation), 2)
|
||||||
|
|
||||||
|
# Vary mileage if vehicle has mileage
|
||||||
|
base_mileage = vehicle.mileage_km or 100000
|
||||||
|
mileage_variation = random.randint(-20000, 20000)
|
||||||
|
listing_mileage = max(0, base_mileage + mileage_variation)
|
||||||
|
|
||||||
|
locations = ["Berlin", "Hamburg", "München", "Köln", "Frankfurt", "Stuttgart"]
|
||||||
|
|
||||||
|
listing = ComparableListing(
|
||||||
|
title=f"{vehicle.make} {vehicle.model} {vehicle.year or ''}".strip(),
|
||||||
|
make=vehicle.make,
|
||||||
|
model=vehicle.model,
|
||||||
|
year=vehicle.year,
|
||||||
|
price=listing_price,
|
||||||
|
mileage_km=listing_mileage,
|
||||||
|
location=random.choice(locations),
|
||||||
|
url=f"https://suchen.mobile.de/fahrzeuge/details.html?id={random.randint(1000000, 9999999)}",
|
||||||
|
source="mobile.de",
|
||||||
|
)
|
||||||
|
listings.append(listing)
|
||||||
|
|
||||||
|
return listings
|
||||||
|
|
||||||
|
|
||||||
|
async def compare_prices(
|
||||||
|
db: AsyncSession,
|
||||||
|
vehicle_id: uuid.UUID,
|
||||||
|
) -> PriceCompareResponse:
|
||||||
|
"""Compare prices for a vehicle by searching comparable listings.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Async database session
|
||||||
|
vehicle_id: UUID of the vehicle to compare
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
PriceCompareResponse with comparable listings and average price.
|
||||||
|
If vehicle not found, raises ValueError.
|
||||||
|
If no comparable listings found, returns empty list with null average.
|
||||||
|
"""
|
||||||
|
vehicle = await get_vehicle_by_id(db, vehicle_id)
|
||||||
|
if vehicle is None:
|
||||||
|
raise ValueError(f"Vehicle {vehicle_id} not found")
|
||||||
|
|
||||||
|
# Search for comparable listings
|
||||||
|
# In production: call mobile.de Search API with make, model, year filters
|
||||||
|
# For now: generate mock listings
|
||||||
|
listings = _generate_mock_listings(vehicle)
|
||||||
|
|
||||||
|
# Calculate average price
|
||||||
|
if listings:
|
||||||
|
average_price = round(
|
||||||
|
sum(listing.price for listing in listings) / len(listings), 2
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
average_price = None
|
||||||
|
|
||||||
|
return PriceCompareResponse(
|
||||||
|
vehicle_id=vehicle_id,
|
||||||
|
comparable_listings=listings,
|
||||||
|
average_price=average_price,
|
||||||
|
listing_count=len(listings),
|
||||||
|
)
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
"""Retouch service: upload, process via Flux.1-Pro, and retrieve results."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.models.retouch import RetouchResult, RetouchStatus
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
ALLOWED_MIME_TYPES = {"image/png", "image/jpeg", "image/jpg", "image/webp"}
|
||||||
|
|
||||||
|
RETOUCH_SYSTEM_PROMPT = (
|
||||||
|
"You are an expert automotive photo retoucher. "
|
||||||
|
"Enhance the provided vehicle image with the following improvements: "
|
||||||
|
"1. Remove or replace the background with a clean, professional studio backdrop. "
|
||||||
|
"2. Correct color balance and enhance saturation for a vibrant, realistic look. "
|
||||||
|
"3. Remove reflections and glare from windows and bodywork. "
|
||||||
|
"4. Clean up minor blemishes, dust, and scratches on the vehicle surface. "
|
||||||
|
"5. Ensure the vehicle is well-lit and centered. "
|
||||||
|
"Return the enhanced image."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_mime_type(mime_type: str) -> bool:
|
||||||
|
"""Check if the MIME type is an allowed image type."""
|
||||||
|
return mime_type.lower() in ALLOWED_MIME_TYPES
|
||||||
|
|
||||||
|
|
||||||
|
def validate_file_size(file_size: int) -> bool:
|
||||||
|
"""Check if file size is within the configured limit."""
|
||||||
|
max_bytes = settings.MAX_FILE_SIZE_MB * 1024 * 1024
|
||||||
|
return file_size <= max_bytes
|
||||||
|
|
||||||
|
|
||||||
|
def _get_file_extension(mime_type: str) -> str:
|
||||||
|
"""Map MIME type to file extension."""
|
||||||
|
mapping = {
|
||||||
|
"image/png": ".png",
|
||||||
|
"image/jpeg": ".jpg",
|
||||||
|
"image/jpg": ".jpg",
|
||||||
|
"image/webp": ".webp",
|
||||||
|
}
|
||||||
|
return mapping.get(mime_type.lower(), ".png")
|
||||||
|
|
||||||
|
|
||||||
|
def generate_retouch_prompt(vehicle_info: dict[str, Any] | None = None) -> str:
|
||||||
|
"""Generate a retouch prompt based on optional vehicle info.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
vehicle_info: Optional dict with make, model, year, color etc.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A detailed prompt string for the image retouch model.
|
||||||
|
"""
|
||||||
|
base_prompt = (
|
||||||
|
"Professional automotive photograph retouching: "
|
||||||
|
"Remove the original background and replace with a clean white studio backdrop. "
|
||||||
|
"Enhance color correction for realistic, vibrant tones. "
|
||||||
|
"Remove reflections and glare from windows and paint. "
|
||||||
|
"Clean up dust, scratches, and minor blemishes on the bodywork. "
|
||||||
|
"Ensure even, professional lighting across the entire vehicle. "
|
||||||
|
"Sharpen details on wheels, grille, and badges."
|
||||||
|
)
|
||||||
|
|
||||||
|
if vehicle_info:
|
||||||
|
parts = [base_prompt]
|
||||||
|
make = vehicle_info.get("make")
|
||||||
|
model = vehicle_info.get("model")
|
||||||
|
color = vehicle_info.get("color")
|
||||||
|
if make and model:
|
||||||
|
parts.append(f"The vehicle is a {make} {model}.")
|
||||||
|
if color:
|
||||||
|
parts.append(
|
||||||
|
f"The vehicle color is {color}; ensure it looks accurate and rich."
|
||||||
|
)
|
||||||
|
return " ".join(parts)
|
||||||
|
|
||||||
|
return base_prompt
|
||||||
|
|
||||||
|
|
||||||
|
async def upload_retouch_file(
|
||||||
|
db: AsyncSession,
|
||||||
|
file_bytes: bytes,
|
||||||
|
file_name: str,
|
||||||
|
mime_type: str,
|
||||||
|
vehicle_id: uuid.UUID | None = None,
|
||||||
|
) -> RetouchResult:
|
||||||
|
"""Save uploaded image and create a RetouchResult record.
|
||||||
|
|
||||||
|
Raises ValueError for invalid MIME type or file size.
|
||||||
|
"""
|
||||||
|
if not validate_mime_type(mime_type):
|
||||||
|
raise ValueError(
|
||||||
|
f"Invalid MIME type: {mime_type}. Only image/* types are allowed."
|
||||||
|
)
|
||||||
|
|
||||||
|
if not validate_file_size(len(file_bytes)):
|
||||||
|
raise ValueError(f"File size exceeds limit of {settings.MAX_FILE_SIZE_MB} MB")
|
||||||
|
|
||||||
|
upload_dir = settings.UPLOAD_DIR
|
||||||
|
os.makedirs(upload_dir, exist_ok=True)
|
||||||
|
|
||||||
|
ext = _get_file_extension(mime_type)
|
||||||
|
stored_filename = f"retouch_{uuid.uuid4().hex}{ext}"
|
||||||
|
file_path = os.path.join(upload_dir, stored_filename)
|
||||||
|
|
||||||
|
with open(file_path, "wb") as f:
|
||||||
|
f.write(file_bytes)
|
||||||
|
|
||||||
|
result = RetouchResult(
|
||||||
|
original_file_path=file_path,
|
||||||
|
original_file_name=file_name,
|
||||||
|
mime_type=mime_type,
|
||||||
|
status=RetouchStatus.pending.value,
|
||||||
|
vehicle_id=vehicle_id,
|
||||||
|
)
|
||||||
|
db.add(result)
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def get_result(db: AsyncSession, result_id: uuid.UUID) -> RetouchResult | None:
|
||||||
|
"""Get a retouch result by ID."""
|
||||||
|
stmt = select(RetouchResult).where(RetouchResult.id == result_id)
|
||||||
|
res = await db.execute(stmt)
|
||||||
|
return res.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def list_results(
|
||||||
|
db: AsyncSession,
|
||||||
|
vehicle_id: uuid.UUID | None = None,
|
||||||
|
page: int = 1,
|
||||||
|
page_size: int = 20,
|
||||||
|
) -> tuple[list[RetouchResult], int]:
|
||||||
|
"""List retouch results with optional vehicle filter and pagination."""
|
||||||
|
count_stmt = select(func.count(RetouchResult.id))
|
||||||
|
data_stmt = select(RetouchResult)
|
||||||
|
|
||||||
|
if vehicle_id is not None:
|
||||||
|
count_stmt = count_stmt.where(RetouchResult.vehicle_id == vehicle_id)
|
||||||
|
data_stmt = data_stmt.where(RetouchResult.vehicle_id == vehicle_id)
|
||||||
|
|
||||||
|
total_result = await db.execute(count_stmt)
|
||||||
|
total = total_result.scalar_one()
|
||||||
|
|
||||||
|
offset = (page - 1) * page_size
|
||||||
|
data_stmt = (
|
||||||
|
data_stmt.order_by(RetouchResult.created_at.desc())
|
||||||
|
.offset(offset)
|
||||||
|
.limit(page_size)
|
||||||
|
)
|
||||||
|
result = await db.execute(data_stmt)
|
||||||
|
items = list(result.scalars().all())
|
||||||
|
|
||||||
|
return items, total
|
||||||
|
|
||||||
|
|
||||||
|
async def process_retouch(
|
||||||
|
db: AsyncSession,
|
||||||
|
result_id: uuid.UUID,
|
||||||
|
vehicle_info: dict[str, Any] | None = None,
|
||||||
|
) -> RetouchResult:
|
||||||
|
"""Process a retouch result by sending the image to Flux.1-Pro via OpenRouter.
|
||||||
|
|
||||||
|
Updates the result status to processing, calls the API, saves the
|
||||||
|
retouched image, and sets status to completed. On failure, sets
|
||||||
|
status to failed with an error message.
|
||||||
|
"""
|
||||||
|
result = await get_result(db, result_id)
|
||||||
|
if result is None:
|
||||||
|
raise ValueError(f"Retouch result {result_id} not found")
|
||||||
|
|
||||||
|
result.status = RetouchStatus.processing.value
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(result.original_file_path, "rb") as f:
|
||||||
|
image_bytes = f.read()
|
||||||
|
|
||||||
|
retouched_bytes = await _call_flux_pro(
|
||||||
|
image_bytes=image_bytes,
|
||||||
|
mime_type=result.mime_type,
|
||||||
|
prompt=generate_retouch_prompt(vehicle_info),
|
||||||
|
)
|
||||||
|
|
||||||
|
upload_dir = settings.UPLOAD_DIR
|
||||||
|
ext = _get_file_extension(result.mime_type)
|
||||||
|
retouched_filename = f"retouched_{result_id.hex}{ext}"
|
||||||
|
retouched_path = os.path.join(upload_dir, retouched_filename)
|
||||||
|
|
||||||
|
with open(retouched_path, "wb") as f:
|
||||||
|
f.write(retouched_bytes)
|
||||||
|
|
||||||
|
result.retouched_file_path = retouched_path
|
||||||
|
result.status = RetouchStatus.completed.value
|
||||||
|
result.error_message = None
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(result)
|
||||||
|
logger.info("Retouch completed for result %s", result_id)
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Retouch failed for result %s: %s", result_id, exc)
|
||||||
|
result.status = RetouchStatus.failed.value
|
||||||
|
result.error_message = str(exc)
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def _call_flux_pro(
|
||||||
|
image_bytes: bytes,
|
||||||
|
mime_type: str,
|
||||||
|
prompt: str,
|
||||||
|
api_key: str | None = None,
|
||||||
|
model: str | None = None,
|
||||||
|
) -> bytes:
|
||||||
|
"""Send image to Flux.1-Pro via OpenRouter for retouching.
|
||||||
|
|
||||||
|
Returns the retouched image bytes.
|
||||||
|
Raises httpx.HTTPStatusError or ValueError on failure.
|
||||||
|
"""
|
||||||
|
key = api_key or settings.OPENROUTER_API_KEY
|
||||||
|
if not key:
|
||||||
|
raise ValueError("OPENROUTER_API_KEY is not configured")
|
||||||
|
|
||||||
|
model_name = model or "black-forest-labs/flux-1-pro"
|
||||||
|
b64_image = base64.b64encode(image_bytes).decode("utf-8")
|
||||||
|
data_uri = f"data:{mime_type};base64,{b64_image}"
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"model": model_name,
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "system",
|
||||||
|
"content": RETOUCH_SYSTEM_PROMPT,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"text": prompt,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": {"url": data_uri},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"temperature": 0.7,
|
||||||
|
"max_tokens": 4096,
|
||||||
|
}
|
||||||
|
|
||||||
|
base_url = settings.OPENROUTER_BASE_URL.rstrip("/")
|
||||||
|
url = f"{base_url}/chat/completions"
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=httpx.Timeout(120.0)) as client:
|
||||||
|
response = await client.post(url, headers=headers, json=payload)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
body = response.json()
|
||||||
|
content = body.get("choices", [{}])[0].get("message", {}).get("content", "")
|
||||||
|
|
||||||
|
# The model may return a base64-encoded image or a URL
|
||||||
|
if isinstance(content, list):
|
||||||
|
for part in content:
|
||||||
|
if isinstance(part, dict) and part.get("type") == "image_url":
|
||||||
|
url_or_data = part.get("image_url", {}).get("url", "")
|
||||||
|
if url_or_data.startswith("data:"):
|
||||||
|
b64_data = url_or_data.split(",", 1)[1]
|
||||||
|
return base64.b64decode(b64_data)
|
||||||
|
elif url_or_data.startswith("http"):
|
||||||
|
async with httpx.AsyncClient(timeout=60.0) as dl_client:
|
||||||
|
dl_resp = await dl_client.get(url_or_data)
|
||||||
|
dl_resp.raise_for_status()
|
||||||
|
return dl_resp.content
|
||||||
|
|
||||||
|
if isinstance(content, str) and content.startswith("data:"):
|
||||||
|
b64_data = content.split(",", 1)[1]
|
||||||
|
return base64.b64decode(b64_data)
|
||||||
|
|
||||||
|
# Fallback: if no image returned, return original bytes
|
||||||
|
logger.warning("Flux.1-Pro did not return an image, returning original bytes")
|
||||||
|
return image_bytes
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
"""Sale service: CRUD, contract PDF generation, GwG logic, USt-IdNr. verification."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import date
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import and_, func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.models.sale import Sale
|
||||||
|
from app.models.vehicle import Vehicle
|
||||||
|
from app.utils.contract_pdf import generate_contract_pdf
|
||||||
|
|
||||||
|
|
||||||
|
async def create_sale(db: AsyncSession, data: dict[str, Any]) -> Sale:
|
||||||
|
"""Create a new sale record.
|
||||||
|
|
||||||
|
Sets vehicle availability to 'sold' upon creation.
|
||||||
|
Generates contract PDF if status is 'completed'.
|
||||||
|
|
||||||
|
Raises ValueError if vehicle or buyer contact not found.
|
||||||
|
"""
|
||||||
|
vehicle_id = data["vehicle_id"]
|
||||||
|
|
||||||
|
# Verify vehicle exists
|
||||||
|
vehicle = await db.get(Vehicle, vehicle_id)
|
||||||
|
if vehicle is None or vehicle.deleted_at is not None:
|
||||||
|
raise ValueError(f"Vehicle {vehicle_id} not found")
|
||||||
|
|
||||||
|
# Set sale_date to today if not provided
|
||||||
|
if data.get("sale_date") is None:
|
||||||
|
data["sale_date"] = date.today()
|
||||||
|
|
||||||
|
sale = Sale(**data)
|
||||||
|
db.add(sale)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
# Update vehicle status to sold
|
||||||
|
vehicle.availability = "sold"
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
# Load nested relationships for response
|
||||||
|
await db.refresh(sale)
|
||||||
|
# Explicitly load relationships
|
||||||
|
stmt = (
|
||||||
|
select(Sale)
|
||||||
|
.options(
|
||||||
|
selectinload(Sale.vehicle),
|
||||||
|
selectinload(Sale.buyer),
|
||||||
|
selectinload(Sale.seller),
|
||||||
|
)
|
||||||
|
.where(Sale.id == sale.id)
|
||||||
|
)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
sale = result.scalar_one()
|
||||||
|
|
||||||
|
# Generate contract PDF if status is completed
|
||||||
|
if sale.status == "completed":
|
||||||
|
pdf_path = await generate_contract_pdf(sale)
|
||||||
|
sale.contract_pdf_path = pdf_path
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
return sale
|
||||||
|
|
||||||
|
|
||||||
|
async def get_sale(db: AsyncSession, sale_id: uuid.UUID) -> Sale | None:
|
||||||
|
"""Get a single sale by ID with nested relationships."""
|
||||||
|
stmt = (
|
||||||
|
select(Sale)
|
||||||
|
.options(
|
||||||
|
selectinload(Sale.vehicle),
|
||||||
|
selectinload(Sale.buyer),
|
||||||
|
selectinload(Sale.seller),
|
||||||
|
)
|
||||||
|
.where(Sale.id == sale_id)
|
||||||
|
)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def list_sales(
|
||||||
|
db: AsyncSession,
|
||||||
|
page: int = 1,
|
||||||
|
page_size: int = 20,
|
||||||
|
status: str | None = None,
|
||||||
|
date_from: date | None = None,
|
||||||
|
date_to: date | None = None,
|
||||||
|
) -> tuple[list[Sale], int]:
|
||||||
|
"""List sales with pagination and optional filtering by status and date range.
|
||||||
|
|
||||||
|
Returns (sales, total_count).
|
||||||
|
"""
|
||||||
|
conditions = []
|
||||||
|
if status:
|
||||||
|
conditions.append(Sale.status == status)
|
||||||
|
if date_from:
|
||||||
|
conditions.append(Sale.sale_date >= date_from)
|
||||||
|
if date_to:
|
||||||
|
conditions.append(Sale.sale_date <= date_to)
|
||||||
|
|
||||||
|
# Count query
|
||||||
|
count_stmt = select(func.count(Sale.id))
|
||||||
|
if conditions:
|
||||||
|
count_stmt = count_stmt.where(and_(*conditions))
|
||||||
|
total_result = await db.execute(count_stmt)
|
||||||
|
total = total_result.scalar_one()
|
||||||
|
|
||||||
|
# Data query with eager loading
|
||||||
|
data_stmt = (
|
||||||
|
select(Sale)
|
||||||
|
.options(
|
||||||
|
selectinload(Sale.vehicle),
|
||||||
|
selectinload(Sale.buyer),
|
||||||
|
selectinload(Sale.seller),
|
||||||
|
)
|
||||||
|
.order_by(Sale.created_at.desc())
|
||||||
|
)
|
||||||
|
if conditions:
|
||||||
|
data_stmt = data_stmt.where(and_(*conditions))
|
||||||
|
|
||||||
|
offset = (page - 1) * page_size
|
||||||
|
data_stmt = data_stmt.offset(offset).limit(page_size)
|
||||||
|
|
||||||
|
result = await db.execute(data_stmt)
|
||||||
|
sales = list(result.scalars().all())
|
||||||
|
|
||||||
|
return sales, total
|
||||||
|
|
||||||
|
|
||||||
|
async def update_sale(
|
||||||
|
db: AsyncSession, sale_id: uuid.UUID, updates: dict[str, Any]
|
||||||
|
) -> Sale | None:
|
||||||
|
"""Update a sale's fields. Returns None if not found."""
|
||||||
|
sale = await get_sale(db, sale_id)
|
||||||
|
if sale is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
for key, value in updates.items():
|
||||||
|
if hasattr(sale, key):
|
||||||
|
setattr(sale, key, value)
|
||||||
|
|
||||||
|
await db.flush()
|
||||||
|
# Reload with relationships
|
||||||
|
sale = await get_sale(db, sale_id)
|
||||||
|
return sale
|
||||||
|
|
||||||
|
|
||||||
|
async def cancel_sale(db: AsyncSession, sale_id: uuid.UUID) -> Sale | None:
|
||||||
|
"""Cancel a sale: set status to 'cancelled' and restore vehicle status to 'available'.
|
||||||
|
|
||||||
|
Returns None if sale not found.
|
||||||
|
"""
|
||||||
|
sale = await get_sale(db, sale_id)
|
||||||
|
if sale is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
sale.status = "cancelled"
|
||||||
|
|
||||||
|
# Restore vehicle availability
|
||||||
|
vehicle = await db.get(Vehicle, sale.vehicle_id)
|
||||||
|
if vehicle is not None:
|
||||||
|
vehicle.availability = "available"
|
||||||
|
|
||||||
|
await db.flush()
|
||||||
|
sale = await get_sale(db, sale_id)
|
||||||
|
return sale
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_sale(db: AsyncSession, sale_id: uuid.UUID) -> Sale | None:
|
||||||
|
"""Delete a sale (cancel + restore vehicle status to 'in_stock').
|
||||||
|
|
||||||
|
This is a soft-cancel: sale status is set to 'cancelled' and
|
||||||
|
vehicle availability is restored to 'available' (in_stock).
|
||||||
|
|
||||||
|
Returns None if sale not found.
|
||||||
|
"""
|
||||||
|
return await cancel_sale(db, sale_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def regenerate_contract_pdf(db: AsyncSession, sale_id: uuid.UUID) -> Sale | None:
|
||||||
|
"""Regenerate the contract PDF for a sale.
|
||||||
|
|
||||||
|
Returns the updated sale with new contract_pdf_path, or None if not found.
|
||||||
|
"""
|
||||||
|
sale = await get_sale(db, sale_id)
|
||||||
|
if sale is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
pdf_path = await generate_contract_pdf(sale)
|
||||||
|
sale.contract_pdf_path = pdf_path
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
sale = await get_sale(db, sale_id)
|
||||||
|
return sale
|
||||||
|
|
||||||
|
|
||||||
|
async def verify_ust_id(sale_id: uuid.UUID, db: AsyncSession) -> dict[str, Any]:
|
||||||
|
"""Verify the buyer's USt-IdNr. via BZSt API.
|
||||||
|
|
||||||
|
Feature flag: bzst_api_enabled (default: False).
|
||||||
|
When disabled, returns {verified: false, message: 'BZSt API not available'}.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
sale_id: Sale ID to look up the buyer contact.
|
||||||
|
db: Async session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with verified, message, and vat_id fields.
|
||||||
|
"""
|
||||||
|
# Check feature flag
|
||||||
|
bzst_enabled = getattr(settings, "BZST_API_ENABLED", False)
|
||||||
|
if not bzst_enabled:
|
||||||
|
return {
|
||||||
|
"verified": False,
|
||||||
|
"message": "BZSt API not available",
|
||||||
|
"vat_id": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
# If enabled, we would call the BZSt API here
|
||||||
|
# For now, return manual review message
|
||||||
|
sale = await get_sale(db, sale_id)
|
||||||
|
if sale is None:
|
||||||
|
return {
|
||||||
|
"verified": False,
|
||||||
|
"message": "Sale not found",
|
||||||
|
"vat_id": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
buyer = sale.buyer
|
||||||
|
vat_id = getattr(buyer, "vat_id", None) if buyer else None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"verified": False,
|
||||||
|
"message": "Manual review required",
|
||||||
|
"vat_id": vat_id,
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
"""Vehicle service: CRUD, filtering, sorting, and soft-delete operations."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import and_, func, or_, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.vehicle import Vehicle
|
||||||
|
|
||||||
|
|
||||||
|
# Fields that are safe to sort by
|
||||||
|
_SORTABLE_FIELDS: set[str] = {
|
||||||
|
"make",
|
||||||
|
"model",
|
||||||
|
"fin",
|
||||||
|
"year",
|
||||||
|
"price",
|
||||||
|
"vehicle_type",
|
||||||
|
"availability",
|
||||||
|
"condition",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
"power_kw",
|
||||||
|
"mileage_km",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_filters(
|
||||||
|
stmt: select,
|
||||||
|
vehicle_type: str | None = None,
|
||||||
|
availability: str | None = None,
|
||||||
|
min_price: float | None = None,
|
||||||
|
max_price: float | None = None,
|
||||||
|
search: str | None = None,
|
||||||
|
) -> select:
|
||||||
|
"""Apply WHERE filters to a select statement (always excludes soft-deleted)."""
|
||||||
|
conditions = [Vehicle.deleted_at.is_(None)]
|
||||||
|
|
||||||
|
if vehicle_type:
|
||||||
|
conditions.append(Vehicle.vehicle_type == vehicle_type)
|
||||||
|
if availability:
|
||||||
|
conditions.append(Vehicle.availability == availability)
|
||||||
|
if min_price is not None:
|
||||||
|
conditions.append(Vehicle.price >= min_price)
|
||||||
|
if max_price is not None:
|
||||||
|
conditions.append(Vehicle.price <= max_price)
|
||||||
|
if search:
|
||||||
|
search_pattern = f"%{search}%"
|
||||||
|
conditions.append(
|
||||||
|
or_(
|
||||||
|
Vehicle.make.ilike(search_pattern),
|
||||||
|
Vehicle.model.ilike(search_pattern),
|
||||||
|
Vehicle.fin.ilike(search_pattern),
|
||||||
|
Vehicle.location.ilike(search_pattern),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return stmt.where(and_(*conditions))
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_sort(stmt: select, sort: str | None = None) -> select:
|
||||||
|
"""Apply ORDER BY to a select statement based on sort param.
|
||||||
|
|
||||||
|
Format: 'field' for ascending, '-field' for descending.
|
||||||
|
"""
|
||||||
|
if not sort:
|
||||||
|
return stmt.order_by(Vehicle.created_at.desc())
|
||||||
|
|
||||||
|
descending = sort.startswith("-")
|
||||||
|
field_name = sort.lstrip("-")
|
||||||
|
|
||||||
|
if field_name not in _SORTABLE_FIELDS:
|
||||||
|
return stmt.order_by(Vehicle.created_at.desc())
|
||||||
|
|
||||||
|
column = getattr(Vehicle, field_name)
|
||||||
|
if descending:
|
||||||
|
return stmt.order_by(column.desc())
|
||||||
|
return stmt.order_by(column.asc())
|
||||||
|
|
||||||
|
|
||||||
|
async def list_vehicles(
|
||||||
|
db: AsyncSession,
|
||||||
|
page: int = 1,
|
||||||
|
page_size: int = 20,
|
||||||
|
vehicle_type: str | None = None,
|
||||||
|
availability: str | None = None,
|
||||||
|
min_price: float | None = None,
|
||||||
|
max_price: float | None = None,
|
||||||
|
search: str | None = None,
|
||||||
|
sort: str | None = None,
|
||||||
|
) -> tuple[list[Vehicle], int]:
|
||||||
|
"""List vehicles with pagination, filtering, and sorting.
|
||||||
|
|
||||||
|
Returns (vehicles, total_count).
|
||||||
|
"""
|
||||||
|
# Build count query
|
||||||
|
count_stmt = select(func.count(Vehicle.id))
|
||||||
|
count_stmt = _apply_filters(
|
||||||
|
count_stmt,
|
||||||
|
vehicle_type=vehicle_type,
|
||||||
|
availability=availability,
|
||||||
|
min_price=min_price,
|
||||||
|
max_price=max_price,
|
||||||
|
search=search,
|
||||||
|
)
|
||||||
|
total_result = await db.execute(count_stmt)
|
||||||
|
total = total_result.scalar_one()
|
||||||
|
|
||||||
|
# Build data query
|
||||||
|
data_stmt = select(Vehicle)
|
||||||
|
data_stmt = _apply_filters(
|
||||||
|
data_stmt,
|
||||||
|
vehicle_type=vehicle_type,
|
||||||
|
availability=availability,
|
||||||
|
min_price=min_price,
|
||||||
|
max_price=max_price,
|
||||||
|
search=search,
|
||||||
|
)
|
||||||
|
data_stmt = _apply_sort(data_stmt, sort)
|
||||||
|
|
||||||
|
offset = (page - 1) * page_size
|
||||||
|
data_stmt = data_stmt.offset(offset).limit(page_size)
|
||||||
|
|
||||||
|
result = await db.execute(data_stmt)
|
||||||
|
vehicles = list(result.scalars().all())
|
||||||
|
|
||||||
|
return vehicles, total
|
||||||
|
|
||||||
|
|
||||||
|
async def get_vehicle_by_id(db: AsyncSession, vehicle_id: uuid.UUID) -> Vehicle | None:
|
||||||
|
"""Get a single vehicle by ID, excluding soft-deleted."""
|
||||||
|
stmt = select(Vehicle).where(
|
||||||
|
and_(Vehicle.id == vehicle_id, Vehicle.deleted_at.is_(None))
|
||||||
|
)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_vehicle_by_fin(db: AsyncSession, fin: str) -> Vehicle | None:
|
||||||
|
"""Get a single vehicle by FIN, excluding soft-deleted."""
|
||||||
|
stmt = select(Vehicle).where(and_(Vehicle.fin == fin, Vehicle.deleted_at.is_(None)))
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def create_vehicle(db: AsyncSession, data: dict[str, Any]) -> Vehicle:
|
||||||
|
"""Create a new vehicle.
|
||||||
|
|
||||||
|
Raises ValueError if FIN already exists.
|
||||||
|
"""
|
||||||
|
existing = await get_vehicle_by_fin(db, data["fin"])
|
||||||
|
if existing is not None:
|
||||||
|
raise ValueError(f"Vehicle with FIN '{data['fin']}' already exists")
|
||||||
|
|
||||||
|
vehicle = Vehicle(**data)
|
||||||
|
db.add(vehicle)
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(vehicle)
|
||||||
|
return vehicle
|
||||||
|
|
||||||
|
|
||||||
|
async def update_vehicle(
|
||||||
|
db: AsyncSession, vehicle_id: uuid.UUID, updates: dict[str, Any]
|
||||||
|
) -> Vehicle | None:
|
||||||
|
"""Update a vehicle's fields. Returns None if not found or deleted."""
|
||||||
|
vehicle = await get_vehicle_by_id(db, vehicle_id)
|
||||||
|
if vehicle is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# If FIN is being updated, check for duplicates
|
||||||
|
if "fin" in updates and updates["fin"] != vehicle.fin:
|
||||||
|
existing = await get_vehicle_by_fin(db, updates["fin"])
|
||||||
|
if existing is not None:
|
||||||
|
raise ValueError(f"Vehicle with FIN '{updates['fin']}' already exists")
|
||||||
|
|
||||||
|
for key, value in updates.items():
|
||||||
|
if hasattr(vehicle, key):
|
||||||
|
setattr(vehicle, key, value)
|
||||||
|
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(vehicle)
|
||||||
|
return vehicle
|
||||||
|
|
||||||
|
|
||||||
|
async def soft_delete_vehicle(
|
||||||
|
db: AsyncSession, vehicle_id: uuid.UUID
|
||||||
|
) -> Vehicle | None:
|
||||||
|
"""Soft-delete a vehicle by setting deleted_at. Returns None if not found."""
|
||||||
|
vehicle = await get_vehicle_by_id(db, vehicle_id)
|
||||||
|
if vehicle is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
vehicle.deleted_at = datetime.now(timezone.utc)
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(vehicle)
|
||||||
|
return vehicle
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# Tasks package
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""Async OCR processing task using background tasks.
|
||||||
|
|
||||||
|
In production this would use Redis Queue (RQ) or Celery.
|
||||||
|
For now, we use FastAPI BackgroundTasks to trigger async processing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
from app.database import async_session_factory
|
||||||
|
from app.services.ocr_service import process_ocr
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def run_ocr_processing(result_id: uuid.UUID) -> None:
|
||||||
|
"""Background task: process OCR result asynchronously.
|
||||||
|
|
||||||
|
Creates its own DB session (independent of the request session)
|
||||||
|
so the HTTP response can return immediately.
|
||||||
|
"""
|
||||||
|
async with async_session_factory() as session:
|
||||||
|
try:
|
||||||
|
await process_ocr(session, result_id)
|
||||||
|
await session.commit()
|
||||||
|
logger.info("OCR processing completed for result %s", result_id)
|
||||||
|
except Exception as exc:
|
||||||
|
await session.rollback()
|
||||||
|
logger.error("OCR background task failed for %s: %s", result_id, exc)
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
await session.close()
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""Async retouch processing background task.
|
||||||
|
|
||||||
|
Creates its own DB session independent of the request session so the
|
||||||
|
HTTP response can return immediately while processing continues.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.database import async_session_factory
|
||||||
|
from app.services.retouch_service import process_retouch
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def run_retouch_processing(
|
||||||
|
result_id: uuid.UUID,
|
||||||
|
vehicle_info: dict[str, Any] | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Background task: process retouch result asynchronously.
|
||||||
|
|
||||||
|
Creates its own DB session (independent of the request session)
|
||||||
|
so the HTTP response can return immediately.
|
||||||
|
"""
|
||||||
|
async with async_session_factory() as session:
|
||||||
|
try:
|
||||||
|
await process_retouch(session, result_id, vehicle_info)
|
||||||
|
await session.commit()
|
||||||
|
logger.info("Retouch processing completed for result %s", result_id)
|
||||||
|
except Exception as exc:
|
||||||
|
await session.rollback()
|
||||||
|
logger.error("Retouch background task failed for %s: %s", result_id, exc)
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
await session.close()
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
"""Contract PDF generation using WeasyPrint.
|
||||||
|
|
||||||
|
Generates a Verkaufvertrag (sales contract) PDF from an HTML template
|
||||||
|
with vehicle data, contact data, GwG-Klausel, and USt-IdNr. field.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
# HTML template for the sales contract
|
||||||
|
_CONTRACT_HTML_TEMPLATE = """<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<style>
|
||||||
|
body {{ font-family: Arial, sans-serif; font-size: 11pt; margin: 2cm; }}
|
||||||
|
h1 {{ text-align: center; font-size: 16pt; margin-bottom: 20px; }}
|
||||||
|
h2 {{ font-size: 13pt; border-bottom: 1px solid #333; padding-bottom: 3px; }}
|
||||||
|
table {{ width: 100%; border-collapse: collapse; margin: 10px 0; }}
|
||||||
|
td {{ padding: 4px 8px; vertical-align: top; }}
|
||||||
|
td.label {{ font-weight: bold; width: 35%; }}
|
||||||
|
.section {{ margin-bottom: 20px; }}
|
||||||
|
.gwg-clause {{
|
||||||
|
background-color: #fff3cd;
|
||||||
|
border: 1px solid #ffc107;
|
||||||
|
padding: 10px;
|
||||||
|
margin: 15px 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
}}
|
||||||
|
.gwg-clause h3 {{ margin-top: 0; color: #856404; }}
|
||||||
|
.signature-block {{ margin-top: 40px; }}
|
||||||
|
.signature-line {{ border-top: 1px solid #333; width: 45%; margin-top: 50px; display: inline-block; text-align: center; font-size: 9pt; }}
|
||||||
|
.signature-spacer {{ width: 10%; display: inline-block; }}
|
||||||
|
.footer {{ margin-top: 30px; font-size: 9pt; color: #666; border-top: 1px solid #ccc; padding-top: 10px; }}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<h1>Kaufvertrag über ein Nutzfahrzeug</h1>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2>1. Vertragsparteien</h2>
|
||||||
|
<table>
|
||||||
|
<tr><td class="label">Verkäufer:</td><td>{seller_name}<br>{seller_address}<br>{seller_country}</td></tr>
|
||||||
|
<tr><td class="label">USt-IdNr. (Verkäufer):</td><td>{seller_vat_id}</td></tr>
|
||||||
|
<tr><td class="label">Käufer:</td><td>{buyer_name}<br>{buyer_address}<br>{buyer_country}</td></tr>
|
||||||
|
<tr><td class="label">USt-IdNr. (Käufer):</td><td>{buyer_vat_id}</td></tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2>2. Vertragsgegenstand</h2>
|
||||||
|
<table>
|
||||||
|
<tr><td class="label">Fahrzeug:</td><td>{vehicle_make} {vehicle_model}</td></tr>
|
||||||
|
<tr><td class="label">Fahrgestellnummer (FIN):</td><td>{vehicle_fin}</td></tr>
|
||||||
|
<tr><td class="label">Fahrzeugtyp:</td><td>{vehicle_type}</td></tr>
|
||||||
|
<tr><td class="label">Erstzulassung:</td><td>{first_registration}</td></tr>
|
||||||
|
<tr><td class="label">Kilometerstand:</td><td>{mileage_km} km</td></tr>
|
||||||
|
<tr><td class="label">Leistung:</td><td>{power_kw} kW ({power_hp} PS)</td></tr>
|
||||||
|
<tr><td class="label">Kraftstoff:</td><td>{fuel_type}</td></tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2>3. Kaufpreis</h2>
|
||||||
|
<table>
|
||||||
|
<tr><td class="label">Kaufpreis:</td><td>{sale_price} EUR</td></tr>
|
||||||
|
<tr><td class="label">Verkaufsdatum:</td><td>{sale_date}</td></tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{gwg_section}
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2>4. Zahlungsbedingungen</h2>
|
||||||
|
<p>Der Kaufpreis ist bei Übergabe des Fahrzeugs fällig. Die Zahlung erfolgt per
|
||||||
|
Überweisung auf das Konto des Verkäufers.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2>5. Gewährleistung</h2>
|
||||||
|
<p>Das Fahrzeug wird unter Ausschluss der Sachmängelhaftung verkauft, soweit
|
||||||
|
nicht ausdrücklich etwas anderes vereinbart wurde.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2>6. Übergabe</h2>
|
||||||
|
<p>Das Fahrzeug wird am {sale_date} übergeben. Mit der Übergabe geht die
|
||||||
|
Gefahr auf den Käufer über.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="signature-block">
|
||||||
|
<div class="signature-line">Verkäufer</div>
|
||||||
|
<div class="signature-spacer"></div>
|
||||||
|
<div class="signature-line">Käufer</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
Vertrag erstellt am {created_date} | Vertragsnummer: {contract_number}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
|
|
||||||
|
|
||||||
|
_GWG_CLAUSE_HTML = """
|
||||||
|
<div class="gwg-clause">
|
||||||
|
<h3>Geringwertige Wirtschaftsgüter (GwG) – § 6 Abs. 2 EStG</h3>
|
||||||
|
<p>Der Kaufpreis beträgt maximal 800 EUR. Nach § 6 Abs. 2 EStG kann der
|
||||||
|
Verkäufer den vollständigen Betrag im Jahr der Anschaffung als
|
||||||
|
Sofortabzug im Betriebsvermögen geltend machen. Die
|
||||||
|
Abschreibung erfolgt somit sofort und vollständig in der
|
||||||
|
Anschaffungsperiode.</p>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _format_contact_name(contact: Any) -> str:
|
||||||
|
"""Format contact name for the contract."""
|
||||||
|
if contact is None:
|
||||||
|
return "N/A"
|
||||||
|
return getattr(contact, "company_name", "N/A") or "N/A"
|
||||||
|
|
||||||
|
|
||||||
|
def _format_contact_address(contact: Any) -> str:
|
||||||
|
"""Format contact address for the contract."""
|
||||||
|
if contact is None:
|
||||||
|
return "N/A"
|
||||||
|
parts = []
|
||||||
|
street = getattr(contact, "address_street", None)
|
||||||
|
zip_code = getattr(contact, "address_zip", None)
|
||||||
|
city = getattr(contact, "address_city", None)
|
||||||
|
if street:
|
||||||
|
parts.append(street)
|
||||||
|
if zip_code and city:
|
||||||
|
parts.append(f"{zip_code} {city}")
|
||||||
|
elif city:
|
||||||
|
parts.append(city)
|
||||||
|
elif zip_code:
|
||||||
|
parts.append(zip_code)
|
||||||
|
return "<br>".join(parts) if parts else "N/A"
|
||||||
|
|
||||||
|
|
||||||
|
def _format_contact_country(contact: Any) -> str:
|
||||||
|
"""Format contact country for the contract."""
|
||||||
|
if contact is None:
|
||||||
|
return "N/A"
|
||||||
|
return getattr(contact, "address_country", "DE") or "DE"
|
||||||
|
|
||||||
|
|
||||||
|
def _format_vat_id(contact: Any) -> str:
|
||||||
|
"""Format VAT ID for the contract."""
|
||||||
|
if contact is None:
|
||||||
|
return "Nicht angegeben"
|
||||||
|
vat_id = getattr(contact, "vat_id", None)
|
||||||
|
return vat_id if vat_id else "Nicht angegeben"
|
||||||
|
|
||||||
|
|
||||||
|
def _format_price(price: Decimal) -> str:
|
||||||
|
"""Format price for the contract."""
|
||||||
|
if price is None:
|
||||||
|
return "0,00"
|
||||||
|
formatted = f"{price:,.2f}"
|
||||||
|
return formatted.replace(",", "X").replace(".", ",").replace("X", ".")
|
||||||
|
|
||||||
|
|
||||||
|
def _format_date(d: date) -> str:
|
||||||
|
"""Format date in German convention DD.MM.YYYY."""
|
||||||
|
if d is None:
|
||||||
|
return "N/A"
|
||||||
|
return d.strftime("%d.%m.%Y")
|
||||||
|
|
||||||
|
|
||||||
|
def build_contract_html(sale: Any) -> str:
|
||||||
|
"""Build the HTML contract from a Sale model instance.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
sale: Sale model instance with nested vehicle, buyer, seller.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
HTML string for the contract.
|
||||||
|
"""
|
||||||
|
vehicle = getattr(sale, "vehicle", None)
|
||||||
|
buyer = getattr(sale, "buyer", None)
|
||||||
|
seller = getattr(sale, "seller", None)
|
||||||
|
|
||||||
|
# Determine GwG clause
|
||||||
|
gwg_section = ""
|
||||||
|
if (
|
||||||
|
sale.is_gwg
|
||||||
|
and sale.sale_price is not None
|
||||||
|
and sale.sale_price <= Decimal("800")
|
||||||
|
):
|
||||||
|
gwg_section = _GWG_CLAUSE_HTML
|
||||||
|
|
||||||
|
html = _CONTRACT_HTML_TEMPLATE.format(
|
||||||
|
seller_name=_format_contact_name(seller) if seller else "N/A",
|
||||||
|
seller_address=_format_contact_address(seller) if seller else "N/A",
|
||||||
|
seller_country=_format_contact_country(seller) if seller else "DE",
|
||||||
|
seller_vat_id=_format_vat_id(seller) if seller else "Nicht angegeben",
|
||||||
|
buyer_name=_format_contact_name(buyer),
|
||||||
|
buyer_address=_format_contact_address(buyer),
|
||||||
|
buyer_country=_format_contact_country(buyer),
|
||||||
|
buyer_vat_id=_format_vat_id(buyer),
|
||||||
|
vehicle_make=getattr(vehicle, "make", "N/A") if vehicle else "N/A",
|
||||||
|
vehicle_model=getattr(vehicle, "model", "N/A") if vehicle else "N/A",
|
||||||
|
vehicle_fin=getattr(vehicle, "fin", "N/A") if vehicle else "N/A",
|
||||||
|
vehicle_type=getattr(vehicle, "vehicle_type", "N/A") if vehicle else "N/A",
|
||||||
|
first_registration=_format_date(getattr(vehicle, "first_registration", None))
|
||||||
|
if vehicle
|
||||||
|
else "N/A",
|
||||||
|
mileage_km=getattr(vehicle, "mileage_km", "N/A") if vehicle else "N/A",
|
||||||
|
power_kw=getattr(vehicle, "power_kw", "N/A") if vehicle else "N/A",
|
||||||
|
power_hp=getattr(vehicle, "power_hp", "N/A") if vehicle else "N/A",
|
||||||
|
fuel_type=getattr(vehicle, "fuel_type", "N/A") if vehicle else "N/A",
|
||||||
|
sale_price=_format_price(sale.sale_price),
|
||||||
|
sale_date=_format_date(sale.sale_date),
|
||||||
|
gwg_section=gwg_section,
|
||||||
|
created_date=_format_date(date.today()),
|
||||||
|
contract_number=str(sale.id)[:8].upper(),
|
||||||
|
)
|
||||||
|
return html
|
||||||
|
|
||||||
|
|
||||||
|
def generate_contract_pdf_sync(sale: Any, output_dir: str = "/tmp/contracts") -> str:
|
||||||
|
"""Generate contract PDF synchronously using WeasyPrint.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
sale: Sale model instance with nested vehicle, buyer, seller.
|
||||||
|
output_dir: Directory to save the PDF.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to the generated PDF file.
|
||||||
|
"""
|
||||||
|
from weasyprint import HTML
|
||||||
|
|
||||||
|
os.makedirs(output_dir, exist_ok=True)
|
||||||
|
html_content = build_contract_html(sale)
|
||||||
|
pdf_path = os.path.join(output_dir, f"contract_{sale.id}.pdf")
|
||||||
|
HTML(string=html_content).write_pdf(pdf_path)
|
||||||
|
return pdf_path
|
||||||
|
|
||||||
|
|
||||||
|
async def generate_contract_pdf(sale: Any, output_dir: str = "/tmp/contracts") -> str:
|
||||||
|
"""Generate contract PDF asynchronously (runs WeasyPrint in a thread).
|
||||||
|
|
||||||
|
WeasyPrint is synchronous, so we offload it to a thread to avoid
|
||||||
|
blocking the event loop.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
sale: Sale model instance with nested vehicle, buyer, seller.
|
||||||
|
output_dir: Directory to save the PDF.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to the generated PDF file.
|
||||||
|
"""
|
||||||
|
return await asyncio.to_thread(generate_contract_pdf_sync, sale, output_dir)
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
"""Action definitions for the Copilot action system.
|
||||||
|
|
||||||
|
Each action maps a type string to a handler that queries existing services.
|
||||||
|
Actions are PROPOSED by the AI and EXECUTED only after user confirmation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.services import contact_service, vehicle_service
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
type ActionHandler = Callable[[AsyncSession, dict[str, Any]], Any]
|
||||||
|
|
||||||
|
|
||||||
|
async def _search_vehicles(db: AsyncSession, params: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Search vehicles by type, availability, price range, or text search."""
|
||||||
|
vehicle_type = params.get("type") or params.get("vehicle_type")
|
||||||
|
availability = params.get("availability")
|
||||||
|
min_price = params.get("min_price")
|
||||||
|
max_price = params.get("max_price")
|
||||||
|
search = params.get("search") or params.get("query")
|
||||||
|
page = params.get("page", 1)
|
||||||
|
page_size = params.get("page_size", 20)
|
||||||
|
|
||||||
|
vehicles, total = await vehicle_service.list_vehicles(
|
||||||
|
db,
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
vehicle_type=vehicle_type,
|
||||||
|
availability=availability,
|
||||||
|
min_price=min_price,
|
||||||
|
max_price=max_price,
|
||||||
|
search=search,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"items": [v.to_dict() for v in vehicles],
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"page_size": page_size,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _search_contacts(db: AsyncSession, params: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Search contacts by role, text search, EU/inland flag."""
|
||||||
|
search = params.get("search") or params.get("query")
|
||||||
|
role = params.get("role")
|
||||||
|
is_eu = params.get("is_eu")
|
||||||
|
is_private = params.get("is_private")
|
||||||
|
page = params.get("page", 1)
|
||||||
|
page_size = params.get("page_size", 20)
|
||||||
|
|
||||||
|
contacts, total = await contact_service.list_contacts(
|
||||||
|
db,
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
search=search,
|
||||||
|
role=role,
|
||||||
|
is_eu=is_eu,
|
||||||
|
is_private=is_private,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"items": [c.to_dict() for c in contacts],
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"page_size": page_size,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_sale_overview(
|
||||||
|
db: AsyncSession, params: dict[str, Any]
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Get an overview of sales, optionally filtered by status or date range."""
|
||||||
|
from app.models.sale import Sale
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
|
status_filter = params.get("status")
|
||||||
|
page = params.get("page", 1)
|
||||||
|
page_size = params.get("page_size", 20)
|
||||||
|
|
||||||
|
count_stmt = select(func.count(Sale.id))
|
||||||
|
data_stmt = select(Sale)
|
||||||
|
|
||||||
|
if status_filter:
|
||||||
|
count_stmt = count_stmt.where(Sale.status == status_filter)
|
||||||
|
data_stmt = data_stmt.where(Sale.status == status_filter)
|
||||||
|
|
||||||
|
total_result = await db.execute(count_stmt)
|
||||||
|
total = total_result.scalar_one()
|
||||||
|
|
||||||
|
offset = (page - 1) * page_size
|
||||||
|
data_stmt = (
|
||||||
|
data_stmt.offset(offset).limit(page_size).order_by(Sale.created_at.desc())
|
||||||
|
)
|
||||||
|
result = await db.execute(data_stmt)
|
||||||
|
sales = list(result.scalars().all())
|
||||||
|
|
||||||
|
return {
|
||||||
|
"items": [s.to_dict() for s in sales],
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"page_size": page_size,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _create_vehicle(db: AsyncSession, params: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Create a new vehicle. Requires make, model, fin, price, vehicle_type."""
|
||||||
|
required = ["make", "model", "fin", "price", "vehicle_type"]
|
||||||
|
missing = [f for f in required if not params.get(f)]
|
||||||
|
if missing:
|
||||||
|
raise ValueError(
|
||||||
|
f"Missing required fields for create_vehicle: {', '.join(missing)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
vehicle = await vehicle_service.create_vehicle(db, params)
|
||||||
|
return vehicle.to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
async def _create_contact(db: AsyncSession, params: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Create a new contact. Requires company_name, role, address_country."""
|
||||||
|
required = ["company_name", "role"]
|
||||||
|
missing = [f for f in required if not params.get(f)]
|
||||||
|
if missing:
|
||||||
|
raise ValueError(
|
||||||
|
f"Missing required fields for create_contact: {', '.join(missing)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if "address_country" not in params:
|
||||||
|
params["address_country"] = "DE"
|
||||||
|
|
||||||
|
contact = await contact_service.create_contact(db, params)
|
||||||
|
return contact.to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
ACTION_REGISTRY: dict[str, ActionHandler] = {
|
||||||
|
"search_vehicles": _search_vehicles,
|
||||||
|
"search_contacts": _search_contacts,
|
||||||
|
"get_sale_overview": _get_sale_overview,
|
||||||
|
"create_vehicle": _create_vehicle,
|
||||||
|
"create_contact": _create_contact,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_available_actions() -> list[dict[str, Any]]:
|
||||||
|
"""Return action definitions for the system prompt."""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"type": "search_vehicles",
|
||||||
|
"description": "Fahrzeuge durchsuchen — nach Typ, Verfügbarkeit, Preis oder Text.",
|
||||||
|
"params": {
|
||||||
|
"type": "optional: lkw, pkw, baumaschine, stapler, transporter",
|
||||||
|
"availability": "optional: available, reserved, sold",
|
||||||
|
"min_price": "optional: float",
|
||||||
|
"max_price": "optional: float",
|
||||||
|
"search": "optional: text search in make, model, fin, location",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "search_contacts",
|
||||||
|
"description": "Kontakte durchsuchen — nach Rolle, Text, EU/Inland.",
|
||||||
|
"params": {
|
||||||
|
"search": "optional: text search in company_name, city, email, vat_id",
|
||||||
|
"role": "optional: kaeufer, verkaeufer, beide",
|
||||||
|
"is_eu": "optional: bool",
|
||||||
|
"is_private": "optional: bool",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "get_sale_overview",
|
||||||
|
"description": "Verkaufsübersicht abrufen — nach Status filterbar.",
|
||||||
|
"params": {
|
||||||
|
"status": "optional: draft, completed, cancelled",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "create_vehicle",
|
||||||
|
"description": "Neues Fahrzeug anlegen. Erfordert make, model, fin, price, vehicle_type.",
|
||||||
|
"params": {
|
||||||
|
"make": "required: string",
|
||||||
|
"model": "required: string",
|
||||||
|
"fin": "required: 17-char VIN",
|
||||||
|
"price": "required: decimal",
|
||||||
|
"vehicle_type": "required: lkw, pkw, baumaschine, stapler, transporter",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "create_contact",
|
||||||
|
"description": "Neuen Kontakt anlegen. Erfordert company_name, role.",
|
||||||
|
"params": {
|
||||||
|
"company_name": "required: string",
|
||||||
|
"role": "required: kaeufer, verkaeufer, beide",
|
||||||
|
"address_country": "optional: 2-letter ISO code, default DE",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def execute_action(
|
||||||
|
db: AsyncSession, action_type: str, params: dict[str, Any]
|
||||||
|
) -> Any:
|
||||||
|
"""Execute a registered action by type.
|
||||||
|
|
||||||
|
Raises ValueError if the action type is not registered.
|
||||||
|
"""
|
||||||
|
handler = ACTION_REGISTRY.get(action_type)
|
||||||
|
if handler is None:
|
||||||
|
raise ValueError(f"Unknown action type: {action_type}")
|
||||||
|
logger.info("Executing copilot action: %s with params: %s", action_type, params)
|
||||||
|
return await handler(db, params)
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
"""System prompt for the KI-Copilot with ERP context and available actions.
|
||||||
|
|
||||||
|
The prompt instructs the AI to:
|
||||||
|
1. Understand ERP context (vehicles, contacts, sales)
|
||||||
|
2. Propose actions as structured JSON
|
||||||
|
3. Never execute actions directly — only propose them
|
||||||
|
4. Respond in the user's language (default German)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from app.utils.copilot_actions import get_available_actions
|
||||||
|
|
||||||
|
|
||||||
|
def build_system_prompt() -> str:
|
||||||
|
"""Build the system prompt with ERP context and action definitions."""
|
||||||
|
actions = get_available_actions()
|
||||||
|
actions_json = json.dumps(actions, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
return f"""Du bist der KI-Copilot für das ERP-System Nutzfahrzeuge. Du hilfst Verkäufern und Buchhaltern bei der Arbeit mit Fahrzeugen, Kontakten und Verkäufen.
|
||||||
|
|
||||||
|
## ERP-Kontext
|
||||||
|
|
||||||
|
### Fahrzeugtypen
|
||||||
|
- **LKW**: Sattelzugmaschinen, Lkw, Verteiler
|
||||||
|
- **PKW**: Personenwagen
|
||||||
|
- **Baumaschine**: Bagger, Radlader, Kräne
|
||||||
|
- **Stapler**: Gabelstapler, Hubwagen
|
||||||
|
- **Transporter**: Transporter, Kleintransporter
|
||||||
|
|
||||||
|
### Kontakttypen
|
||||||
|
- **Käufer (kaeufer)**: Kunden die Fahrzeuge kaufen
|
||||||
|
- **Verkäufer (verkaeufer)**: Lieferanten die Fahrzeuge verkaufen
|
||||||
|
- **Beide**: Kontakte die sowohl kaufen als auch verkaufen
|
||||||
|
- **EU vs Inland**: address_country DE = Inland, alles andere = EU
|
||||||
|
- **Privat**: is_private flag für Privatpersonen
|
||||||
|
|
||||||
|
### Verkaufsworkflow
|
||||||
|
1. **draft**: Verkauf begonnen, noch nicht abgeschlossen
|
||||||
|
2. **completed**: Verkauf abgeschlossen, Vertrag generiert
|
||||||
|
3. **cancelled**: Verkauf storniert
|
||||||
|
|
||||||
|
## Verfügbare Aktionen
|
||||||
|
|
||||||
|
Du kannst folgende Aktionen VORSCHLAGEN (nicht selbst ausführen):
|
||||||
|
|
||||||
|
{actions_json}
|
||||||
|
|
||||||
|
## Antwortformat
|
||||||
|
|
||||||
|
Antworte IMMER in folgendem JSON-Format:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{{
|
||||||
|
"response": "Deine Text-Antwort an den Nutzer",
|
||||||
|
"actions": [
|
||||||
|
{{
|
||||||
|
"type": "action_type",
|
||||||
|
"params": {{}}
|
||||||
|
}}
|
||||||
|
]
|
||||||
|
}}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Regeln:
|
||||||
|
1. **response**: Ein natürlicher Text, der den Nutzer informiert was du gefunden hast oder vorschlägst.
|
||||||
|
2. **actions**: Eine Liste von Aktionsvorschlägen. Der Nutzer muss jede Aktion bestätigen bevor sie ausgeführt wird.
|
||||||
|
3. Wenn keine Aktion nötig ist, gib einen leeren actions-Array zurück.
|
||||||
|
4. Führe NIEMALS Aktionen selbst aus — du kannst sie nur vorschlagen.
|
||||||
|
5. Antworte in der Sprache des Nutzers (Standard: Deutsch).
|
||||||
|
6. Wenn du unklar bist, frage nach.
|
||||||
|
|
||||||
|
### Beispiele:
|
||||||
|
|
||||||
|
Nutzer: "Zeige alle LKWs"
|
||||||
|
```json
|
||||||
|
{{
|
||||||
|
"response": "Ich suche nach allen LKWs im Bestand für dich.",
|
||||||
|
"actions": [{{"type": "search_vehicles", "params": {{"type": "lkw"}}}}]
|
||||||
|
}}
|
||||||
|
```
|
||||||
|
|
||||||
|
Nutzer: "Suche Kontakt Müller"
|
||||||
|
```json
|
||||||
|
{{
|
||||||
|
"response": "Ich suche nach Kontakten mit dem Namen Müller.",
|
||||||
|
"actions": [{{"type": "search_contacts", "params": {{"search": "Müller"}}}}]
|
||||||
|
}}
|
||||||
|
```
|
||||||
|
|
||||||
|
Nutzer: "Wie viele Verkäufe wurden abgeschlossen?"
|
||||||
|
```json
|
||||||
|
{{
|
||||||
|
"response": "Ich rufe eine Übersicht der abgeschlossenen Verkäufe auf.",
|
||||||
|
"actions": [{{"type": "get_sale_overview", "params": {{"status": "completed"}}}}]
|
||||||
|
}}
|
||||||
|
```
|
||||||
|
"""
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
"""DATEV CSV format helper (Buchungsstapel).
|
||||||
|
|
||||||
|
Generates CSV in DATEV-compatible format with headers:
|
||||||
|
Datum, Konto, Gegenkonto, Betrag, Belegfeld, Buchungstext
|
||||||
|
"""
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
# DATEV Buchungsstapel CSV headers
|
||||||
|
DATEV_HEADERS = [
|
||||||
|
"Datum",
|
||||||
|
"Konto",
|
||||||
|
"Gegenkonto",
|
||||||
|
"Betrag",
|
||||||
|
"Belegfeld",
|
||||||
|
"Buchungstext",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def generate_datev_csv(sales: list[Any]) -> str:
|
||||||
|
"""Generate DATEV CSV content from a list of sale objects.
|
||||||
|
|
||||||
|
Each sale produces one booking row:
|
||||||
|
- Datum: sale_date in DD.MM.YYYY format
|
||||||
|
- Konto: buyer account (1200 = Forderungen aus Lieferungen/Leistungen)
|
||||||
|
- Gegenkonto: revenue account (8400 = Erlöse aus Warenverkäufen)
|
||||||
|
- Betrag: sale_price as decimal with comma separator
|
||||||
|
- Belegfeld: sale ID (short form)
|
||||||
|
- Buchungstext: vehicle make + model + FIN
|
||||||
|
|
||||||
|
Args:
|
||||||
|
sales: List of Sale model instances with nested vehicle and buyer.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CSV string with DATEV headers and one row per sale.
|
||||||
|
"""
|
||||||
|
output = io.StringIO()
|
||||||
|
writer = csv.writer(output, delimiter=";", quoting=csv.QUOTE_MINIMAL)
|
||||||
|
|
||||||
|
# Write header row
|
||||||
|
writer.writerow(DATEV_HEADERS)
|
||||||
|
|
||||||
|
for sale in sales:
|
||||||
|
# Format date as DD.MM.YYYY (DATEV convention)
|
||||||
|
datum = sale.sale_date.strftime("%d.%m.%Y") if sale.sale_date else ""
|
||||||
|
|
||||||
|
# Account numbers (standard DATEV SKR03)
|
||||||
|
konto = "1200" # Forderungen aus LuL
|
||||||
|
gegenkonto = "8400" # Erlöse aus Warenverkäufen
|
||||||
|
|
||||||
|
# Amount with comma as decimal separator (DATEV convention)
|
||||||
|
betrag = _format_amount(sale.sale_price)
|
||||||
|
|
||||||
|
# Belegfeld: short sale ID (first 8 chars)
|
||||||
|
belegfeld = str(sale.id)[:8].upper()
|
||||||
|
|
||||||
|
# Buchungstext: vehicle description
|
||||||
|
vehicle = getattr(sale, "vehicle", None)
|
||||||
|
if vehicle:
|
||||||
|
buchungstext = f"{vehicle.make} {vehicle.model} {vehicle.fin}"
|
||||||
|
else:
|
||||||
|
buchungstext = f"Verkauf {str(sale.id)[:8]}"
|
||||||
|
|
||||||
|
writer.writerow([datum, konto, gegenkonto, betrag, belegfeld, buchungstext])
|
||||||
|
|
||||||
|
return output.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def _format_amount(amount: Decimal) -> str:
|
||||||
|
"""Format a Decimal amount for DATEV CSV (comma as decimal separator)."""
|
||||||
|
if amount is None:
|
||||||
|
return "0,00"
|
||||||
|
# Convert to string with 2 decimal places, replace dot with comma
|
||||||
|
formatted = f"{amount:.2f}"
|
||||||
|
return formatted.replace(".", ",")
|
||||||
|
|
||||||
|
|
||||||
|
def validate_datev_csv(csv_content: str) -> bool:
|
||||||
|
"""Validate that CSV content has correct DATEV headers and structure.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
csv_content: CSV string to validate.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if valid, False otherwise.
|
||||||
|
"""
|
||||||
|
if not csv_content or not csv_content.strip():
|
||||||
|
return False
|
||||||
|
|
||||||
|
reader = csv.reader(io.StringIO(csv_content), delimiter=";")
|
||||||
|
try:
|
||||||
|
header = next(reader)
|
||||||
|
except StopIteration:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if header != DATEV_HEADERS:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check at least that rows have 6 columns each
|
||||||
|
for row in reader:
|
||||||
|
if len(row) != 6:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"""JWT utility functions for token creation and verification."""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from jose import JWTError, jwt
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
def create_access_token(
|
||||||
|
user_id: str,
|
||||||
|
role: str,
|
||||||
|
email: str,
|
||||||
|
lang: str = "de",
|
||||||
|
) -> str:
|
||||||
|
"""Create a short-lived access JWT."""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
expire = now + timedelta(seconds=settings.access_token_ttl_seconds)
|
||||||
|
payload = {
|
||||||
|
"sub": str(user_id),
|
||||||
|
"role": role,
|
||||||
|
"email": email,
|
||||||
|
"lang": lang,
|
||||||
|
"exp": expire,
|
||||||
|
"iat": now,
|
||||||
|
"type": "access",
|
||||||
|
}
|
||||||
|
return jwt.encode(payload, settings.JWT_SECRET, algorithm=settings.JWT_ALGORITHM)
|
||||||
|
|
||||||
|
|
||||||
|
def create_refresh_token(
|
||||||
|
user_id: str,
|
||||||
|
role: str,
|
||||||
|
email: str,
|
||||||
|
lang: str = "de",
|
||||||
|
) -> str:
|
||||||
|
"""Create a long-lived refresh JWT."""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
expire = now + timedelta(seconds=settings.refresh_token_ttl_seconds)
|
||||||
|
payload = {
|
||||||
|
"sub": str(user_id),
|
||||||
|
"role": role,
|
||||||
|
"email": email,
|
||||||
|
"lang": lang,
|
||||||
|
"exp": expire,
|
||||||
|
"iat": now,
|
||||||
|
"type": "refresh",
|
||||||
|
}
|
||||||
|
return jwt.encode(payload, settings.JWT_SECRET, algorithm=settings.JWT_ALGORITHM)
|
||||||
|
|
||||||
|
|
||||||
|
def decode_token(token: str) -> Optional[dict]:
|
||||||
|
"""Decode and verify a JWT token. Returns payload or None."""
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(
|
||||||
|
token,
|
||||||
|
settings.JWT_SECRET,
|
||||||
|
algorithms=[settings.JWT_ALGORITHM],
|
||||||
|
)
|
||||||
|
return payload
|
||||||
|
except JWTError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def verify_access_token(token: str) -> Optional[dict]:
|
||||||
|
"""Verify an access token specifically (checks type claim)."""
|
||||||
|
payload = decode_token(token)
|
||||||
|
if payload is None:
|
||||||
|
return None
|
||||||
|
if payload.get("type") != "access":
|
||||||
|
return None
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def verify_refresh_token(token: str) -> Optional[dict]:
|
||||||
|
"""Verify a refresh token specifically (checks type claim)."""
|
||||||
|
payload = decode_token(token)
|
||||||
|
if payload is None:
|
||||||
|
return None
|
||||||
|
if payload.get("type") != "refresh":
|
||||||
|
return None
|
||||||
|
return payload
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
"""Field mapping utilities for mobile.de listing format conversion."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.models.vehicle import Vehicle
|
||||||
|
|
||||||
|
|
||||||
|
# mobile.de category mapping based on vehicle_type
|
||||||
|
_CATEGORY_MAP: dict[str, str] = {
|
||||||
|
"lkw": "Truck",
|
||||||
|
"pkw": "Car",
|
||||||
|
"baumaschine": "ConstructionMachine",
|
||||||
|
"stapler": "ForkliftTruck",
|
||||||
|
"transporter": "Van",
|
||||||
|
}
|
||||||
|
|
||||||
|
# LKW sub-type mapping for mobile.de category refinement
|
||||||
|
_LKW_TYPE_MAP: dict[str, str] = {
|
||||||
|
"sattelzugmaschine": "SemiTractor",
|
||||||
|
"sattelauflieger": "SemiTrailer",
|
||||||
|
"kipper": "Tipper",
|
||||||
|
"kuehlmobil": "RefrigeratedVehicle",
|
||||||
|
"tieflader": "LowLoader",
|
||||||
|
"silofahrzeug": "SiloVehicle",
|
||||||
|
"tankwagen": "TankVehicle",
|
||||||
|
"kranwagen": "CraneVehicle",
|
||||||
|
"muldenkipper": "DumperTruck",
|
||||||
|
"sattelkipper": "SemiTipper",
|
||||||
|
"schwertransporter": "HeavyTransporter",
|
||||||
|
"sonderkonstruktion": "SpecialConstruction",
|
||||||
|
"pritsche": "Flatbed",
|
||||||
|
"planenlkw": "TarpaulinTruck",
|
||||||
|
"boxenlkw": "BoxBodyTruck",
|
||||||
|
"iso_lkw": "IsoTruck",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _format_first_registration(reg_date: date | None) -> str | None:
|
||||||
|
"""Convert date to YYYY-MM format for mobile.de."""
|
||||||
|
if reg_date is None:
|
||||||
|
return None
|
||||||
|
return reg_date.strftime("%Y-%m")
|
||||||
|
|
||||||
|
|
||||||
|
def _format_mileage(vehicle: Vehicle) -> dict[str, Any] | None:
|
||||||
|
"""Map mileage or operating hours to mobile.de mileage field."""
|
||||||
|
if vehicle.mileage_km is not None:
|
||||||
|
return {"value": vehicle.mileage_km, "unit": "km"}
|
||||||
|
if vehicle.operating_hours is not None:
|
||||||
|
unit = vehicle.operating_hours_unit or "h"
|
||||||
|
return {"value": float(vehicle.operating_hours), "unit": unit}
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _format_price(price: Decimal) -> dict[str, Any]:
|
||||||
|
"""Map price to mobile.de price format."""
|
||||||
|
return {
|
||||||
|
"amount": float(price),
|
||||||
|
"currency": "EUR",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _format_power(power_kw: int | None, power_hp: int | None) -> dict[str, Any] | None:
|
||||||
|
"""Map power to mobile.de power format."""
|
||||||
|
if power_kw is None and power_hp is None:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"powerKw": power_kw,
|
||||||
|
"powerHp": power_hp,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _format_category(vehicle: Vehicle) -> str:
|
||||||
|
"""Determine mobile.de category from vehicle type and lkw_type."""
|
||||||
|
base = _CATEGORY_MAP.get(vehicle.vehicle_type, "Other")
|
||||||
|
if vehicle.vehicle_type == "lkw" and vehicle.lkw_type:
|
||||||
|
# Try direct match, then strip lkw_ prefix for lookup
|
||||||
|
lkw_key = vehicle.lkw_type
|
||||||
|
if lkw_key in _LKW_TYPE_MAP:
|
||||||
|
return _LKW_TYPE_MAP[lkw_key]
|
||||||
|
stripped = lkw_key.removeprefix("lkw_")
|
||||||
|
if stripped in _LKW_TYPE_MAP:
|
||||||
|
return _LKW_TYPE_MAP[stripped]
|
||||||
|
return base
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
def map_fields(vehicle: Vehicle) -> dict[str, Any]:
|
||||||
|
"""Map a Vehicle model to the mobile.de listing Ad format.
|
||||||
|
|
||||||
|
Converts internal vehicle fields to the mobile.de REST API listing format.
|
||||||
|
See: https://developer.mobile.de/api/seller-listings
|
||||||
|
"""
|
||||||
|
mileage = _format_mileage(vehicle)
|
||||||
|
power = _format_power(vehicle.power_kw, vehicle.power_hp)
|
||||||
|
|
||||||
|
ad: dict[str, Any] = {
|
||||||
|
"vin": vehicle.fin,
|
||||||
|
"make": vehicle.make,
|
||||||
|
"model": vehicle.model,
|
||||||
|
"category": _format_category(vehicle),
|
||||||
|
"price": _format_price(vehicle.price),
|
||||||
|
"availabilityStatus": vehicle.availability,
|
||||||
|
"condition": vehicle.condition,
|
||||||
|
}
|
||||||
|
|
||||||
|
if vehicle.first_registration is not None:
|
||||||
|
ad["firstRegistration"] = _format_first_registration(vehicle.first_registration)
|
||||||
|
|
||||||
|
if mileage is not None:
|
||||||
|
ad["mileage"] = mileage
|
||||||
|
|
||||||
|
if power is not None:
|
||||||
|
ad["power"] = power
|
||||||
|
|
||||||
|
if vehicle.fuel_type is not None:
|
||||||
|
ad["fuelType"] = vehicle.fuel_type
|
||||||
|
|
||||||
|
if vehicle.transmission is not None:
|
||||||
|
ad["transmission"] = vehicle.transmission
|
||||||
|
|
||||||
|
if vehicle.color is not None:
|
||||||
|
ad["color"] = vehicle.color
|
||||||
|
|
||||||
|
if vehicle.year is not None:
|
||||||
|
ad["year"] = vehicle.year
|
||||||
|
|
||||||
|
if vehicle.location is not None:
|
||||||
|
ad["sellerLocation"] = vehicle.location
|
||||||
|
|
||||||
|
if vehicle.machine_type is not None:
|
||||||
|
ad["bodyType"] = vehicle.machine_type
|
||||||
|
elif vehicle.body_type is not None:
|
||||||
|
ad["bodyType"] = vehicle.body_type
|
||||||
|
|
||||||
|
if vehicle.description is not None:
|
||||||
|
ad["description"] = vehicle.description
|
||||||
|
|
||||||
|
return ad
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
"""OpenRouter API client for Qwen2.5-VL vision model OCR processing.
|
||||||
|
|
||||||
|
Sends image to the vision model with a structured prompt and parses the
|
||||||
|
returned JSON containing brand, model, vin, first_registration, mileage,
|
||||||
|
power_kw, fuel_type plus a confidence score.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
OCR_SYSTEM_PROMPT = (
|
||||||
|
"You are an expert OCR system specialized in reading German vehicle "
|
||||||
|
"registration documents (Zulassungsbescheinigung Teil I and II). "
|
||||||
|
"Extract the following fields from the provided image and return them "
|
||||||
|
"as a JSON object. If a field is not readable or not present, use null. "
|
||||||
|
"Fields to extract: brand, model, vin, first_registration (DD.MM.YYYY), "
|
||||||
|
"mileage (integer km), power_kw (integer), fuel_type. "
|
||||||
|
"Also provide a confidence_score between 0.0 and 1.0 reflecting how "
|
||||||
|
"confident you are in the extracted data. Return ONLY valid JSON, "
|
||||||
|
"no markdown, no explanation."
|
||||||
|
)
|
||||||
|
|
||||||
|
EXPECTED_FIELDS = {
|
||||||
|
"brand",
|
||||||
|
"model",
|
||||||
|
"vin",
|
||||||
|
"first_registration",
|
||||||
|
"mileage",
|
||||||
|
"power_kw",
|
||||||
|
"fuel_type",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _encode_image(image_bytes: bytes, mime_type: str = "image/png") -> str:
|
||||||
|
"""Encode image bytes to a base64 data URI."""
|
||||||
|
b64 = base64.b64encode(image_bytes).decode("utf-8")
|
||||||
|
return f"data:{mime_type};base64,{b64}"
|
||||||
|
|
||||||
|
|
||||||
|
def _build_messages(image_data_uri: str) -> list[dict[str, Any]]:
|
||||||
|
"""Build the chat messages for the OpenRouter vision API."""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"role": "system",
|
||||||
|
"content": OCR_SYSTEM_PROMPT,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"text": (
|
||||||
|
"Please extract the vehicle data from this "
|
||||||
|
"registration document image and return as JSON."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": {"url": image_data_uri},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_response(raw_content: str) -> dict[str, Any]:
|
||||||
|
"""Parse the model response into structured data + confidence.
|
||||||
|
|
||||||
|
Handles markdown code fences and extracts the JSON object.
|
||||||
|
"""
|
||||||
|
text = raw_content.strip()
|
||||||
|
|
||||||
|
# Strip markdown code fences if present
|
||||||
|
if text.startswith("```"):
|
||||||
|
lines = text.split("\n")
|
||||||
|
# Remove first line (```json or ```) and last line (```)
|
||||||
|
lines = [line for line in lines if not line.strip().startswith("```")]
|
||||||
|
text = "\n".join(lines).strip()
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = json.loads(text)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
# Try to find JSON object within the text
|
||||||
|
start = text.find("{")
|
||||||
|
end = text.rfind("}")
|
||||||
|
if start != -1 and end != -1:
|
||||||
|
try:
|
||||||
|
data = json.loads(text[start : end + 1])
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
logger.error("Failed to parse OpenRouter response: %s", text[:200])
|
||||||
|
return {
|
||||||
|
"structured_data": {},
|
||||||
|
"confidence_score": 0.0,
|
||||||
|
"raw_text": raw_content,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
logger.error("No JSON found in OpenRouter response: %s", text[:200])
|
||||||
|
return {
|
||||||
|
"structured_data": {},
|
||||||
|
"confidence_score": 0.0,
|
||||||
|
"raw_text": raw_content,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Extract confidence score (may be inside or outside the data)
|
||||||
|
confidence = data.pop("confidence_score", None)
|
||||||
|
if confidence is None:
|
||||||
|
confidence = data.pop("confidence", 0.5)
|
||||||
|
|
||||||
|
try:
|
||||||
|
confidence_float = float(confidence)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
confidence_float = 0.5
|
||||||
|
|
||||||
|
# Clamp to 0.0-1.0
|
||||||
|
confidence_float = max(0.0, min(1.0, confidence_float))
|
||||||
|
|
||||||
|
# Ensure all expected fields exist (default None)
|
||||||
|
structured: dict[str, Any] = {}
|
||||||
|
for field in EXPECTED_FIELDS:
|
||||||
|
structured[field] = data.get(field)
|
||||||
|
|
||||||
|
# Convert mileage and power_kw to int if present
|
||||||
|
if structured.get("mileage") is not None:
|
||||||
|
try:
|
||||||
|
structured["mileage"] = int(structured["mileage"])
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
if structured.get("power_kw") is not None:
|
||||||
|
try:
|
||||||
|
structured["power_kw"] = int(structured["power_kw"])
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
return {
|
||||||
|
"structured_data": structured,
|
||||||
|
"confidence_score": confidence_float,
|
||||||
|
"raw_text": raw_content,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def perform_ocr(
|
||||||
|
image_bytes: bytes,
|
||||||
|
mime_type: str = "image/png",
|
||||||
|
api_key: str | None = None,
|
||||||
|
model: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Send image to OpenRouter Qwen2.5-VL and return parsed OCR result.
|
||||||
|
|
||||||
|
Returns dict with keys:
|
||||||
|
- structured_data: dict with brand, model, vin, etc.
|
||||||
|
- confidence_score: float 0.0-1.0
|
||||||
|
- raw_text: str (raw model response)
|
||||||
|
|
||||||
|
Raises httpx.HTTPStatusError on API failure.
|
||||||
|
"""
|
||||||
|
key = api_key or settings.OPENROUTER_API_KEY
|
||||||
|
if not key:
|
||||||
|
raise ValueError("OPENROUTER_API_KEY is not configured")
|
||||||
|
|
||||||
|
model_name = model or settings.OPENROUTER_OCR_MODEL
|
||||||
|
image_data_uri = _encode_image(image_bytes, mime_type)
|
||||||
|
messages = _build_messages(image_data_uri)
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"model": model_name,
|
||||||
|
"messages": messages,
|
||||||
|
"temperature": 0.1,
|
||||||
|
"max_tokens": 1024,
|
||||||
|
}
|
||||||
|
|
||||||
|
base_url = settings.OPENROUTER_BASE_URL.rstrip("/")
|
||||||
|
url = f"{base_url}/chat/completions"
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=httpx.Timeout(60.0)) as client:
|
||||||
|
response = await client.post(url, headers=headers, json=payload)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
body = response.json()
|
||||||
|
content = body.get("choices", [{}])[0].get("message", {}).get("content", "")
|
||||||
|
|
||||||
|
return _parse_response(content)
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
"""Thumbnail generation utilities using Pillow.
|
||||||
|
|
||||||
|
Generates 200x200 thumbnails for image files (jpg, png, webp).
|
||||||
|
Non-image files return None (no thumbnail).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from PIL import Image, ImageOps
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
THUMBNAIL_SIZE = (200, 200)
|
||||||
|
IMAGE_MIME_TYPES = {
|
||||||
|
"image/jpeg",
|
||||||
|
"image/png",
|
||||||
|
"image/webp",
|
||||||
|
}
|
||||||
|
IMAGE_EXTENSIONS = {"jpg", "jpeg", "png", "webp"}
|
||||||
|
|
||||||
|
|
||||||
|
def is_image_mime_type(mime_type: str) -> bool:
|
||||||
|
"""Check if the given MIME type is a supported image type."""
|
||||||
|
return mime_type.lower() in IMAGE_MIME_TYPES
|
||||||
|
|
||||||
|
|
||||||
|
def generate_thumbnail(
|
||||||
|
source_path: str | Path,
|
||||||
|
thumbnail_dir: str | Path,
|
||||||
|
stored_filename: str,
|
||||||
|
) -> Optional[str]:
|
||||||
|
"""Generate a 200x200 thumbnail for an image file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
source_path: Path to the original image file.
|
||||||
|
thumbnail_dir: Directory where thumbnails are stored.
|
||||||
|
stored_filename: The stored filename of the original file.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Relative path to the thumbnail file, or None if the file is not
|
||||||
|
a supported image or thumbnail generation fails.
|
||||||
|
"""
|
||||||
|
source_path = Path(source_path)
|
||||||
|
thumbnail_dir = Path(thumbnail_dir)
|
||||||
|
|
||||||
|
# Check if the file extension is a supported image type
|
||||||
|
ext = source_path.suffix.lower().lstrip(".")
|
||||||
|
if ext not in IMAGE_EXTENSIONS:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
thumbnail_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
thumbnail_name = f"thumb_{stored_filename}"
|
||||||
|
# Use .jpg for thumbnails to save space
|
||||||
|
if ext in ("jpg", "jpeg"):
|
||||||
|
thumbnail_name = f"thumb_{Path(stored_filename).stem}.jpg"
|
||||||
|
elif ext == "png":
|
||||||
|
thumbnail_name = f"thumb_{Path(stored_filename).stem}.png"
|
||||||
|
elif ext == "webp":
|
||||||
|
thumbnail_name = f"thumb_{Path(stored_filename).stem}.webp"
|
||||||
|
|
||||||
|
thumbnail_path = thumbnail_dir / thumbnail_name
|
||||||
|
|
||||||
|
with Image.open(source_path) as img:
|
||||||
|
# Convert to RGB if necessary (e.g., for RGBA/P mode images)
|
||||||
|
if img.mode in ("RGBA", "P", "LA"):
|
||||||
|
# Create a white background for transparency
|
||||||
|
background = Image.new("RGB", img.size, (255, 255, 255))
|
||||||
|
if img.mode == "P":
|
||||||
|
img = img.convert("RGBA")
|
||||||
|
background.paste(
|
||||||
|
img, mask=img.split()[-1] if img.mode in ("RGBA", "LA") else None
|
||||||
|
)
|
||||||
|
img = background
|
||||||
|
elif img.mode != "RGB":
|
||||||
|
img = img.convert("RGB")
|
||||||
|
|
||||||
|
# Use ImageOps.fit for a centered crop to exact thumbnail size
|
||||||
|
thumbnail = ImageOps.fit(
|
||||||
|
img, THUMBNAIL_SIZE, method=Image.Resampling.LANCZOS
|
||||||
|
)
|
||||||
|
thumbnail.save(thumbnail_path, quality=85, optimize=True)
|
||||||
|
|
||||||
|
logger.info("Thumbnail generated: %s", thumbnail_path)
|
||||||
|
return str(thumbnail_path)
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Failed to generate thumbnail for %s: %s", source_path, exc)
|
||||||
|
return None
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
"""USt-IdNr. (VAT ID) format validation for DE and common EU countries.
|
||||||
|
|
||||||
|
Provides basic regex-based format validation. Does NOT perform live API verification.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
# Country-specific VAT ID regex patterns.
|
||||||
|
# Each pattern validates the format after the 2-letter country code prefix.
|
||||||
|
_VAT_PATTERNS: dict[str, re.Pattern[str]] = {
|
||||||
|
# DE: DE + 9 digits (e.g. DE123456789)
|
||||||
|
"DE": re.compile(r"^DE\d{9}$"),
|
||||||
|
# AT: AT + U + 8 digits (e.g. ATU12345678)
|
||||||
|
"AT": re.compile(r"^ATU\d{8}$"),
|
||||||
|
# FR: FR + 2 alphanumeric + 9 digits (e.g. FRAB123456789)
|
||||||
|
"FR": re.compile(r"^FR[A-Za-z0-9]{2}\d{9}$"),
|
||||||
|
# NL: NL + 9 digits + B + 2 digits (e.g. NL123456789B01)
|
||||||
|
"NL": re.compile(r"^NL\d{9}B\d{2}$"),
|
||||||
|
# PL: PL + 10 digits (e.g. PL1234567890)
|
||||||
|
"PL": re.compile(r"^PL\d{10}$"),
|
||||||
|
# CZ: CZ + 8-10 digits (e.g. CZ1234567890)
|
||||||
|
"CZ": re.compile(r"^CZ\d{8,10}$"),
|
||||||
|
# IT: IT + 11 digits (e.g. IT12345678901)
|
||||||
|
"IT": re.compile(r"^IT\d{11}$"),
|
||||||
|
# ES: ES + 1 alphanumeric + 7 digits + 1 alphanumeric (e.g. ESA1234567B)
|
||||||
|
"ES": re.compile(r"^ES[A-Za-z0-9]\d{7}[A-Za-z0-9]$"),
|
||||||
|
# BE: BE + 10 digits (e.g. BE1234567890)
|
||||||
|
"BE": re.compile(r"^BE\d{10}$"),
|
||||||
|
# DK: DK + 8 digits (e.g. DK12345678)
|
||||||
|
"DK": re.compile(r"^DK\d{8}$"),
|
||||||
|
# SE: SE + 10 digits (e.g. SE1234567890)
|
||||||
|
"SE": re.compile(r"^SE\d{10}$"),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fallback pattern for EU countries not explicitly listed above.
|
||||||
|
# Accepts <2-letter country code> + 5-15 alphanumeric characters.
|
||||||
|
_EU_FALLBACK_PATTERN = re.compile(r"^[A-Z]{2}[A-Za-z0-9]{5,15}$")
|
||||||
|
|
||||||
|
# Set of supported EU country codes (ISO 3166-1 alpha-2).
|
||||||
|
_EU_COUNTRY_CODES: set[str] = {
|
||||||
|
"AT",
|
||||||
|
"BE",
|
||||||
|
"BG",
|
||||||
|
"CY",
|
||||||
|
"CZ",
|
||||||
|
"DE",
|
||||||
|
"DK",
|
||||||
|
"EE",
|
||||||
|
"ES",
|
||||||
|
"FI",
|
||||||
|
"FR",
|
||||||
|
"GR",
|
||||||
|
"HR",
|
||||||
|
"HU",
|
||||||
|
"IE",
|
||||||
|
"IT",
|
||||||
|
"LT",
|
||||||
|
"LU",
|
||||||
|
"LV",
|
||||||
|
"MT",
|
||||||
|
"NL",
|
||||||
|
"PL",
|
||||||
|
"PT",
|
||||||
|
"RO",
|
||||||
|
"SE",
|
||||||
|
"SI",
|
||||||
|
"SK",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_country_code_from_vat_id(vat_id: str) -> str | None:
|
||||||
|
"""Extract the 2-letter country code from a VAT ID string.
|
||||||
|
|
||||||
|
Returns None if the string is too short or does not start with letters.
|
||||||
|
"""
|
||||||
|
if not vat_id or len(vat_id) < 3:
|
||||||
|
return None
|
||||||
|
prefix = vat_id[:2].upper()
|
||||||
|
if not prefix.isalpha():
|
||||||
|
return None
|
||||||
|
return prefix
|
||||||
|
|
||||||
|
|
||||||
|
def validate_vat_id(vat_id: str) -> bool:
|
||||||
|
"""Validate the format of a VAT ID (USt-IdNr.).
|
||||||
|
|
||||||
|
Supports DE and common EU countries with specific regex patterns.
|
||||||
|
For other EU countries, a fallback pattern is used.
|
||||||
|
Non-EU or unrecognised formats return False.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
vat_id: The VAT ID string to validate (case-insensitive, will be uppercased).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if the format is valid, False otherwise.
|
||||||
|
"""
|
||||||
|
if not vat_id:
|
||||||
|
return True # Empty VAT ID is valid (nullable field)
|
||||||
|
|
||||||
|
normalized = vat_id.strip().upper().replace(" ", "")
|
||||||
|
country_code = get_country_code_from_vat_id(normalized)
|
||||||
|
if country_code is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check against country-specific pattern if available
|
||||||
|
pattern = _VAT_PATTERNS.get(country_code)
|
||||||
|
if pattern is not None:
|
||||||
|
return bool(pattern.match(normalized))
|
||||||
|
|
||||||
|
# Fallback for EU countries without a specific pattern
|
||||||
|
if country_code in _EU_COUNTRY_CODES:
|
||||||
|
return bool(_EU_FALLBACK_PATTERN.match(normalized))
|
||||||
|
|
||||||
|
# Non-EU country code: reject (this module validates EU VAT IDs only)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def validate_vat_id_or_raise(vat_id: str | None) -> str | None:
|
||||||
|
"""Validate VAT ID format and return the normalised value.
|
||||||
|
|
||||||
|
Raises ValueError if the format is invalid.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
vat_id: The VAT ID string to validate, or None.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The normalised (uppercased, whitespace-stripped) VAT ID, or None.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If the VAT ID format is invalid.
|
||||||
|
"""
|
||||||
|
if vat_id is None or vat_id == "":
|
||||||
|
return None
|
||||||
|
|
||||||
|
normalized = vat_id.strip().upper().replace(" ", "")
|
||||||
|
if not validate_vat_id(normalized):
|
||||||
|
raise ValueError(f"Invalid VAT ID format: '{vat_id}'")
|
||||||
|
return normalized
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
[pytest]
|
||||||
|
asyncio_mode = auto
|
||||||
|
testpaths = tests
|
||||||
|
filterwarnings =
|
||||||
|
ignore::DeprecationWarning
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
fastapi>=0.115.0
|
||||||
|
uvicorn[standard]>=0.32.0
|
||||||
|
sqlalchemy[asyncio]>=2.0.0
|
||||||
|
asyncpg>=0.30.0
|
||||||
|
pydantic-settings>=2.0.0
|
||||||
|
python-jose[cryptography]>=3.3.0
|
||||||
|
passlib[bcrypt]
|
||||||
|
bcrypt<5.0
|
||||||
|
redis>=5.0.0
|
||||||
|
python-multipart>=0.0.12
|
||||||
|
pytest>=8.0.0
|
||||||
|
pytest-asyncio>=0.24.0
|
||||||
|
httpx>=0.27.0
|
||||||
|
pytest-cov>=5.0.0
|
||||||
|
aiosqlite>=0.20.0
|
||||||
|
Pillow>=10.0.0
|
||||||
|
weasyprint>=62.0
|
||||||
|
email-validator>=2.0.0
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"""Seed script to create initial admin user.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python scripts/seed_admin.py
|
||||||
|
|
||||||
|
Environment variables required:
|
||||||
|
DATABASE_URL=postgresql+asyncpg://user:pass@host:5432/dbname
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
# Add app to path
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from app.models.user import User
|
||||||
|
from app.services.auth_service import hash_password
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
async def seed_admin():
|
||||||
|
"""Create initial admin user if none exists."""
|
||||||
|
engine = create_async_engine(settings.DATABASE_URL, echo=False)
|
||||||
|
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
# Create tables if they don't exist
|
||||||
|
from app.database import Base
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
|
||||||
|
async with AsyncSession(engine) as session:
|
||||||
|
# Check if any admin exists
|
||||||
|
result = await session.execute(
|
||||||
|
text("SELECT COUNT(*) FROM users WHERE role = 'admin' AND is_active = true")
|
||||||
|
)
|
||||||
|
admin_count = result.scalar()
|
||||||
|
|
||||||
|
if admin_count > 0:
|
||||||
|
print("Admin user already exists. Skipping seed.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Create admin user
|
||||||
|
admin = User(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
email="admin@erp.local",
|
||||||
|
password_hash=hash_password("Admin123!ERP"),
|
||||||
|
full_name="System Administrator",
|
||||||
|
role="admin",
|
||||||
|
language="de",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
session.add(admin)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
print("=" * 50)
|
||||||
|
print("Admin user created successfully!")
|
||||||
|
print("=" * 50)
|
||||||
|
print(f"Email: admin@erp.local")
|
||||||
|
print(f"Password: Admin123!ERP")
|
||||||
|
print(f"Role: admin")
|
||||||
|
print(f"Language: de")
|
||||||
|
print("=" * 50)
|
||||||
|
print("⚠️ Please change the password after first login!")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(seed_admin())
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
"""Pytest fixtures for async DB testing with PostgreSQL."""
|
||||||
|
|
||||||
|
from typing import AsyncGenerator
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
from sqlalchemy.ext.asyncio import (
|
||||||
|
AsyncSession,
|
||||||
|
async_sessionmaker,
|
||||||
|
create_async_engine,
|
||||||
|
)
|
||||||
|
|
||||||
|
from app.database import Base, get_db
|
||||||
|
from app.main import app
|
||||||
|
from app.models.user import User, UserRole
|
||||||
|
from app.services.auth_service import hash_password
|
||||||
|
|
||||||
|
TEST_DATABASE_URL = (
|
||||||
|
"postgresql+asyncpg://erp_test_user:testpass@localhost:5432/erp_test"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def event_loop():
|
||||||
|
"""Create a fresh event loop per test for asyncpg compatibility."""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
|
yield loop
|
||||||
|
loop.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def test_engine():
|
||||||
|
"""Create a fresh async engine per test."""
|
||||||
|
engine = create_async_engine(TEST_DATABASE_URL, echo=False, pool_pre_ping=True)
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
yield engine
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.drop_all)
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def test_session_factory(test_engine):
|
||||||
|
"""Session factory bound to the test engine."""
|
||||||
|
return async_sessionmaker(
|
||||||
|
test_engine,
|
||||||
|
class_=AsyncSession,
|
||||||
|
expire_on_commit=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def db_session(test_session_factory) -> AsyncGenerator[AsyncSession, None]:
|
||||||
|
"""Provide an async DB session for tests."""
|
||||||
|
async with test_session_factory() as session:
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
finally:
|
||||||
|
await session.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def admin_user(db_session: AsyncSession) -> User:
|
||||||
|
"""Create a test admin user."""
|
||||||
|
user = User(
|
||||||
|
email="admin@test.com",
|
||||||
|
password_hash=hash_password("Admin12345!"),
|
||||||
|
full_name="Test Admin",
|
||||||
|
role=UserRole.admin,
|
||||||
|
language="de",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db_session.add(user)
|
||||||
|
await db_session.commit()
|
||||||
|
await db_session.refresh(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def verkaeufer_user(db_session: AsyncSession) -> User:
|
||||||
|
"""Create a test verkaeufer (non-admin) user."""
|
||||||
|
user = User(
|
||||||
|
email="verkaeufer@test.com",
|
||||||
|
password_hash=hash_password("Verk12345!"),
|
||||||
|
full_name="Test Verkaeufer",
|
||||||
|
role=UserRole.verkaeufer,
|
||||||
|
language="en",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db_session.add(user)
|
||||||
|
await db_session.commit()
|
||||||
|
await db_session.refresh(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def inactive_user(db_session: AsyncSession) -> User:
|
||||||
|
"""Create a deactivated test user."""
|
||||||
|
user = User(
|
||||||
|
email="inactive@test.com",
|
||||||
|
password_hash=hash_password("Inactive123!"),
|
||||||
|
full_name="Test Inactive",
|
||||||
|
role=UserRole.admin,
|
||||||
|
language="de",
|
||||||
|
is_active=False,
|
||||||
|
)
|
||||||
|
db_session.add(user)
|
||||||
|
await db_session.commit()
|
||||||
|
await db_session.refresh(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def _get_test_token(user: User) -> str:
|
||||||
|
"""Generate an access token for a test user."""
|
||||||
|
from app.utils.jwt import create_access_token
|
||||||
|
|
||||||
|
role_val = user.role.value if isinstance(user.role, UserRole) else str(user.role)
|
||||||
|
return create_access_token(
|
||||||
|
user_id=str(user.id),
|
||||||
|
role=role_val,
|
||||||
|
email=user.email,
|
||||||
|
lang=user.language,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def admin_token(admin_user: User) -> str:
|
||||||
|
"""Access token for admin user."""
|
||||||
|
return _get_test_token(admin_user)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def verkaeufer_token(verkaeufer_user: User) -> str:
|
||||||
|
"""Access token for verkaeufer user."""
|
||||||
|
return _get_test_token(verkaeufer_user)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def client(test_session_factory) -> AsyncGenerator[AsyncClient, None]:
|
||||||
|
"""Async HTTP test client with DB session override."""
|
||||||
|
|
||||||
|
async def _override_get_db():
|
||||||
|
async with test_session_factory() as session:
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
await session.commit()
|
||||||
|
except Exception:
|
||||||
|
await session.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
await session.close()
|
||||||
|
|
||||||
|
app.dependency_overrides[get_db] = _override_get_db
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||||
|
yield ac
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def admin_client(client: AsyncClient, admin_token: str) -> AsyncClient:
|
||||||
|
"""HTTP client authenticated as admin."""
|
||||||
|
client.headers.update({"Authorization": f"Bearer {admin_token}"})
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def verkaeufer_client(client: AsyncClient, verkaeufer_token: str) -> AsyncClient:
|
||||||
|
"""HTTP client authenticated as verkaeufer (non-admin)."""
|
||||||
|
client.headers.update({"Authorization": f"Bearer {verkaeufer_token}"})
|
||||||
|
return client
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
"""Tests for auth endpoints: login, refresh, me."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from httpx import AsyncClient
|
||||||
|
|
||||||
|
from app.models.user import User
|
||||||
|
from app.utils.jwt import create_access_token, create_refresh_token
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_login_valid_credentials(client: AsyncClient, admin_user: User):
|
||||||
|
"""POST /api/v1/auth/login with valid credentials returns 200 + JWT."""
|
||||||
|
response = await client.post(
|
||||||
|
"/api/v1/auth/login",
|
||||||
|
json={
|
||||||
|
"email": "admin@test.com",
|
||||||
|
"password": "Admin12345!",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert "access_token" in data
|
||||||
|
assert "refresh_token" in data
|
||||||
|
assert data["token_type"] == "bearer"
|
||||||
|
assert data["expires_in"] > 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_login_invalid_password(client: AsyncClient, admin_user: User):
|
||||||
|
"""POST /api/v1/auth/login with wrong password returns 401."""
|
||||||
|
response = await client.post(
|
||||||
|
"/api/v1/auth/login",
|
||||||
|
json={
|
||||||
|
"email": "admin@test.com",
|
||||||
|
"password": "WrongPassword!",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 401
|
||||||
|
data = response.json()
|
||||||
|
assert "error" in data["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_login_nonexistent_user(client: AsyncClient):
|
||||||
|
"""POST /api/v1/auth/login with unknown email returns 401."""
|
||||||
|
response = await client.post(
|
||||||
|
"/api/v1/auth/login",
|
||||||
|
json={
|
||||||
|
"email": "nobody@test.com",
|
||||||
|
"password": "SomePassword!",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_login_inactive_user(client: AsyncClient, inactive_user: User):
|
||||||
|
"""POST /api/v1/auth/login with deactivated account returns 401."""
|
||||||
|
response = await client.post(
|
||||||
|
"/api/v1/auth/login",
|
||||||
|
json={
|
||||||
|
"email": "inactive@test.com",
|
||||||
|
"password": "Inactive123!",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_login_invalid_email_format(client: AsyncClient):
|
||||||
|
"""POST /api/v1/auth/login with invalid email format returns 422."""
|
||||||
|
response = await client.post(
|
||||||
|
"/api/v1/auth/login",
|
||||||
|
json={
|
||||||
|
"email": "not-an-email",
|
||||||
|
"password": "SomePassword!",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_refresh_valid_token(client: AsyncClient, admin_user: User):
|
||||||
|
"""POST /api/v1/auth/refresh with valid refresh token returns new tokens."""
|
||||||
|
refresh_token = create_refresh_token(
|
||||||
|
user_id=str(admin_user.id),
|
||||||
|
role="admin",
|
||||||
|
email=admin_user.email,
|
||||||
|
lang=admin_user.language,
|
||||||
|
)
|
||||||
|
response = await client.post(
|
||||||
|
"/api/v1/auth/refresh",
|
||||||
|
json={
|
||||||
|
"refresh_token": refresh_token,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert "access_token" in data
|
||||||
|
assert "refresh_token" in data
|
||||||
|
assert data["token_type"] == "bearer"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_refresh_invalid_token(client: AsyncClient):
|
||||||
|
"""POST /api/v1/auth/refresh with invalid token returns 401."""
|
||||||
|
response = await client.post(
|
||||||
|
"/api/v1/auth/refresh",
|
||||||
|
json={
|
||||||
|
"refresh_token": "invalid.token.here",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_refresh_access_token_rejected(client: AsyncClient, admin_user: User):
|
||||||
|
"""POST /api/v1/auth/refresh with an access token (not refresh) returns 401."""
|
||||||
|
access_token = create_access_token(
|
||||||
|
user_id=str(admin_user.id),
|
||||||
|
role="admin",
|
||||||
|
email=admin_user.email,
|
||||||
|
lang=admin_user.language,
|
||||||
|
)
|
||||||
|
response = await client.post(
|
||||||
|
"/api/v1/auth/refresh",
|
||||||
|
json={
|
||||||
|
"refresh_token": access_token,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_me_with_valid_token(
|
||||||
|
client: AsyncClient, admin_user: User, admin_token: str
|
||||||
|
):
|
||||||
|
"""GET /api/v1/auth/me with valid JWT returns 200 + user object."""
|
||||||
|
response = await client.get(
|
||||||
|
"/api/v1/auth/me",
|
||||||
|
headers={"Authorization": f"Bearer {admin_token}"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["email"] == "admin@test.com"
|
||||||
|
assert data["role"] == "admin"
|
||||||
|
assert data["is_active"] is True
|
||||||
|
assert "password_hash" not in data
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_me_without_token(client: AsyncClient):
|
||||||
|
"""GET /api/v1/auth/me without token returns 401."""
|
||||||
|
response = await client.get("/api/v1/auth/me")
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_me_with_invalid_token(client: AsyncClient):
|
||||||
|
"""GET /api/v1/auth/me with invalid token returns 401."""
|
||||||
|
response = await client.get(
|
||||||
|
"/api/v1/auth/me",
|
||||||
|
headers={"Authorization": "Bearer invalid.token.here"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_me_with_refresh_token(client: AsyncClient, admin_user: User):
|
||||||
|
"""GET /api/v1/auth/me with a refresh token (not access) returns 401."""
|
||||||
|
refresh_token = create_refresh_token(
|
||||||
|
user_id=str(admin_user.id),
|
||||||
|
role="admin",
|
||||||
|
email=admin_user.email,
|
||||||
|
lang=admin_user.language,
|
||||||
|
)
|
||||||
|
response = await client.get(
|
||||||
|
"/api/v1/auth/me",
|
||||||
|
headers={"Authorization": f"Bearer {refresh_token}"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 401
|
||||||
@@ -0,0 +1,349 @@
|
|||||||
|
"""Direct unit tests for auth_service functions."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy.ext.asyncio import (
|
||||||
|
AsyncSession,
|
||||||
|
async_sessionmaker,
|
||||||
|
create_async_engine,
|
||||||
|
)
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
from app.models.user import User, UserRole
|
||||||
|
from app.services.auth_service import (
|
||||||
|
authenticate_user,
|
||||||
|
create_user,
|
||||||
|
deactivate_user,
|
||||||
|
generate_token_pair,
|
||||||
|
get_user_by_email,
|
||||||
|
get_user_by_id,
|
||||||
|
hash_password,
|
||||||
|
list_users,
|
||||||
|
refresh_access_token,
|
||||||
|
update_user,
|
||||||
|
verify_password,
|
||||||
|
)
|
||||||
|
from app.utils.jwt import create_refresh_token
|
||||||
|
|
||||||
|
TEST_DATABASE_URL = (
|
||||||
|
"postgresql+asyncpg://erp_test_user:testpass@localhost:5432/erp_test"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def event_loop():
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
|
yield loop
|
||||||
|
loop.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def engine():
|
||||||
|
eng = create_async_engine(TEST_DATABASE_URL, echo=False, pool_pre_ping=True)
|
||||||
|
async with eng.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
yield eng
|
||||||
|
async with eng.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.drop_all)
|
||||||
|
await eng.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def session_factory(engine):
|
||||||
|
return async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def session(session_factory):
|
||||||
|
async with session_factory() as s:
|
||||||
|
yield s
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_hash_and_verify_password():
|
||||||
|
hashed = hash_password("mypassword")
|
||||||
|
assert hashed != "mypassword"
|
||||||
|
assert verify_password("mypassword", hashed) is True
|
||||||
|
assert verify_password("wrongpassword", hashed) is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_user_by_email_found(session):
|
||||||
|
user = User(
|
||||||
|
email="findme@test.com",
|
||||||
|
password_hash=hash_password("Test12345!"),
|
||||||
|
full_name="Find Me",
|
||||||
|
role=UserRole.admin,
|
||||||
|
)
|
||||||
|
session.add(user)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(user)
|
||||||
|
|
||||||
|
found = await get_user_by_email(session, "findme@test.com")
|
||||||
|
assert found is not None
|
||||||
|
assert found.email == "findme@test.com"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_user_by_email_not_found(session):
|
||||||
|
found = await get_user_by_email(session, "nobody@test.com")
|
||||||
|
assert found is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_user_by_id_found(session):
|
||||||
|
user = User(
|
||||||
|
email="byid@test.com",
|
||||||
|
password_hash=hash_password("Test12345!"),
|
||||||
|
full_name="By ID",
|
||||||
|
role=UserRole.verkaeufer,
|
||||||
|
)
|
||||||
|
session.add(user)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(user)
|
||||||
|
|
||||||
|
found = await get_user_by_id(session, user.id)
|
||||||
|
assert found is not None
|
||||||
|
assert found.email == "byid@test.com"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_user_by_id_not_found(session):
|
||||||
|
found = await get_user_by_id(session, uuid.uuid4())
|
||||||
|
assert found is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_authenticate_user_valid(session):
|
||||||
|
user = User(
|
||||||
|
email="auth@test.com",
|
||||||
|
password_hash=hash_password("Auth12345!"),
|
||||||
|
full_name="Auth User",
|
||||||
|
role=UserRole.admin,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
session.add(user)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(user)
|
||||||
|
|
||||||
|
result = await authenticate_user(session, "auth@test.com", "Auth12345!")
|
||||||
|
assert result is not None
|
||||||
|
assert result.email == "auth@test.com"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_authenticate_user_wrong_password(session):
|
||||||
|
user = User(
|
||||||
|
email="wrongpw@test.com",
|
||||||
|
password_hash=hash_password("Correct123!"),
|
||||||
|
full_name="Wrong PW",
|
||||||
|
role=UserRole.admin,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
session.add(user)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
result = await authenticate_user(session, "wrongpw@test.com", "Wrong123!")
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_authenticate_user_inactive(session):
|
||||||
|
user = User(
|
||||||
|
email="inactive2@test.com",
|
||||||
|
password_hash=hash_password("Test12345!"),
|
||||||
|
full_name="Inactive",
|
||||||
|
role=UserRole.admin,
|
||||||
|
is_active=False,
|
||||||
|
)
|
||||||
|
session.add(user)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
result = await authenticate_user(session, "inactive2@test.com", "Test12345!")
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_authenticate_user_nonexistent(session):
|
||||||
|
result = await authenticate_user(session, "ghost@test.com", "Test12345!")
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_generate_token_pair(session):
|
||||||
|
user = User(
|
||||||
|
email="token@test.com",
|
||||||
|
password_hash=hash_password("Test12345!"),
|
||||||
|
full_name="Token User",
|
||||||
|
role=UserRole.admin,
|
||||||
|
language="de",
|
||||||
|
)
|
||||||
|
session.add(user)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(user)
|
||||||
|
|
||||||
|
tokens = generate_token_pair(user)
|
||||||
|
assert "access_token" in tokens
|
||||||
|
assert "refresh_token" in tokens
|
||||||
|
assert tokens["token_type"] == "bearer"
|
||||||
|
assert tokens["expires_in"] > 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_refresh_access_token_valid(session):
|
||||||
|
user = User(
|
||||||
|
email="refresh@test.com",
|
||||||
|
password_hash=hash_password("Test12345!"),
|
||||||
|
full_name="Refresh User",
|
||||||
|
role=UserRole.admin,
|
||||||
|
language="de",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
session.add(user)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(user)
|
||||||
|
|
||||||
|
rt = create_refresh_token(
|
||||||
|
user_id=str(user.id), role="admin", email=user.email, lang="de"
|
||||||
|
)
|
||||||
|
tokens = await refresh_access_token(session, rt)
|
||||||
|
assert tokens is not None
|
||||||
|
assert "access_token" in tokens
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_refresh_access_token_invalid(session):
|
||||||
|
result = await refresh_access_token(session, "invalid.token.here")
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_refresh_access_token_nonexistent_user(session):
|
||||||
|
fake_id = uuid.uuid4()
|
||||||
|
rt = create_refresh_token(
|
||||||
|
user_id=str(fake_id), role="admin", email="ghost@test.com", lang="de"
|
||||||
|
)
|
||||||
|
result = await refresh_access_token(session, rt)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_user_success(session):
|
||||||
|
user = await create_user(
|
||||||
|
session,
|
||||||
|
email="new@test.com",
|
||||||
|
password="NewUser123!",
|
||||||
|
full_name="New User",
|
||||||
|
role="verkaeufer",
|
||||||
|
language="en",
|
||||||
|
)
|
||||||
|
assert user.email == "new@test.com"
|
||||||
|
assert user.full_name == "New User"
|
||||||
|
assert user.role == UserRole.verkaeufer
|
||||||
|
assert user.language == "en"
|
||||||
|
assert user.is_active is True
|
||||||
|
assert user.password_hash != "NewUser123!"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_user_duplicate(session):
|
||||||
|
await create_user(
|
||||||
|
session,
|
||||||
|
email="dup@test.com",
|
||||||
|
password="First123!",
|
||||||
|
full_name="First",
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="already exists"):
|
||||||
|
await create_user(
|
||||||
|
session,
|
||||||
|
email="dup@test.com",
|
||||||
|
password="Second123!",
|
||||||
|
full_name="Second",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_users_pagination(session):
|
||||||
|
for i in range(5):
|
||||||
|
u = User(
|
||||||
|
email=f"list{i}@test.com",
|
||||||
|
password_hash=hash_password("Test12345!"),
|
||||||
|
full_name=f"User {i}",
|
||||||
|
role=UserRole.verkaeufer,
|
||||||
|
)
|
||||||
|
session.add(u)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
users, total = await list_users(session, page=1, page_size=3)
|
||||||
|
assert total == 5
|
||||||
|
assert len(users) == 3
|
||||||
|
|
||||||
|
users2, total2 = await list_users(session, page=2, page_size=3)
|
||||||
|
assert total2 == 5
|
||||||
|
assert len(users2) == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_user_success(session):
|
||||||
|
user = await create_user(
|
||||||
|
session,
|
||||||
|
email="update@test.com",
|
||||||
|
password="Update123!",
|
||||||
|
full_name="Before Update",
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
updated = await update_user(
|
||||||
|
session, user.id, full_name="After Update", language="en"
|
||||||
|
)
|
||||||
|
assert updated is not None
|
||||||
|
assert updated.full_name == "After Update"
|
||||||
|
assert updated.language == "en"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_user_not_found(session):
|
||||||
|
result = await update_user(session, uuid.uuid4(), full_name="Nobody")
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_user_role(session):
|
||||||
|
user = await create_user(
|
||||||
|
session,
|
||||||
|
email="role@test.com",
|
||||||
|
password="Role12345!",
|
||||||
|
full_name="Role User",
|
||||||
|
role="verkaeufer",
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
updated = await update_user(session, user.id, role="admin")
|
||||||
|
assert updated is not None
|
||||||
|
assert updated.role == UserRole.admin
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_deactivate_user_success(session):
|
||||||
|
user = await create_user(
|
||||||
|
session,
|
||||||
|
email="deactivate@test.com",
|
||||||
|
password="Deact123!",
|
||||||
|
full_name="Deactivate Me",
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
result = await deactivate_user(session, user.id)
|
||||||
|
assert result is not None
|
||||||
|
assert result.is_active is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_deactivate_user_not_found(session):
|
||||||
|
result = await deactivate_user(session, uuid.uuid4())
|
||||||
|
assert result is None
|
||||||
@@ -0,0 +1,943 @@
|
|||||||
|
"""Tests for contact CRUD, search, filter, and contact person endpoints."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
|
||||||
|
from app.models.contact import Contact
|
||||||
|
from app.utils.ust_validation import validate_vat_id
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def sample_contact_data():
|
||||||
|
"""Valid contact data for creation."""
|
||||||
|
return {
|
||||||
|
"company_name": "Müller Transport GmbH",
|
||||||
|
"legal_form": "GmbH",
|
||||||
|
"address_street": "Hauptstraße 1",
|
||||||
|
"address_zip": "10115",
|
||||||
|
"address_city": "Berlin",
|
||||||
|
"address_country": "DE",
|
||||||
|
"vat_id": "DE123456789",
|
||||||
|
"phone": "+49 30 12345678",
|
||||||
|
"email": "info@mueller-transport.de",
|
||||||
|
"website": "https://mueller-transport.de",
|
||||||
|
"role": "kaeufer",
|
||||||
|
"is_private": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def sample_eu_contact_data():
|
||||||
|
"""Valid EU contact data (non-DE)."""
|
||||||
|
return {
|
||||||
|
"company_name": "Van der Berg Logistics B.V.",
|
||||||
|
"address_street": "Keizersgracht 100",
|
||||||
|
"address_zip": "1015",
|
||||||
|
"address_city": "Amsterdam",
|
||||||
|
"address_country": "NL",
|
||||||
|
"vat_id": "NL123456789B01",
|
||||||
|
"email": "info@vandberg.nl",
|
||||||
|
"role": "verkaeufer",
|
||||||
|
"is_private": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def sample_beide_contact_data():
|
||||||
|
"""Valid contact with role 'beide'."""
|
||||||
|
return {
|
||||||
|
"company_name": "Schmidt & Söhne KG",
|
||||||
|
"address_city": "Hamburg",
|
||||||
|
"address_country": "DE",
|
||||||
|
"role": "beide",
|
||||||
|
"is_private": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def created_contact(admin_client, sample_contact_data):
|
||||||
|
"""Create a contact via API and return the response."""
|
||||||
|
response = await admin_client.post("/api/v1/contacts/", json=sample_contact_data)
|
||||||
|
assert response.status_code == 201, response.text
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
|
class TestUstValidation:
|
||||||
|
"""Unit tests for USt-IdNr. validation utility."""
|
||||||
|
|
||||||
|
def test_validate_de_vat_id_valid(self):
|
||||||
|
assert validate_vat_id("DE123456789") is True
|
||||||
|
|
||||||
|
def test_validate_de_vat_id_invalid_short(self):
|
||||||
|
assert validate_vat_id("DE12345678") is False
|
||||||
|
|
||||||
|
def test_validate_de_vat_id_invalid_long(self):
|
||||||
|
assert validate_vat_id("DE1234567890") is False
|
||||||
|
|
||||||
|
def test_validate_at_vat_id_valid(self):
|
||||||
|
assert validate_vat_id("ATU12345678") is True
|
||||||
|
|
||||||
|
def test_validate_nl_vat_id_valid(self):
|
||||||
|
assert validate_vat_id("NL123456789B01") is True
|
||||||
|
|
||||||
|
def test_validate_fr_vat_id_valid(self):
|
||||||
|
assert validate_vat_id("FRAB123456789") is True
|
||||||
|
|
||||||
|
def test_validate_it_vat_id_valid(self):
|
||||||
|
assert validate_vat_id("IT12345678901") is True
|
||||||
|
|
||||||
|
def test_validate_es_vat_id_valid(self):
|
||||||
|
assert validate_vat_id("ESA1234567B") is True
|
||||||
|
|
||||||
|
def test_validate_empty_vat_id(self):
|
||||||
|
assert validate_vat_id("") is True
|
||||||
|
|
||||||
|
def test_validate_none_vat_id(self):
|
||||||
|
assert validate_vat_id(None) is True
|
||||||
|
|
||||||
|
def test_validate_non_eu_country(self):
|
||||||
|
assert validate_vat_id("US123456789") is False
|
||||||
|
|
||||||
|
def test_validate_lowercase_normalised(self):
|
||||||
|
assert validate_vat_id("de123456789") is True
|
||||||
|
|
||||||
|
def test_validate_with_spaces(self):
|
||||||
|
assert validate_vat_id("DE 123 456 789") is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestContactList:
|
||||||
|
"""GET /api/v1/contacts tests."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_contacts_returns_200_with_pagination(
|
||||||
|
self, admin_client, created_contact
|
||||||
|
):
|
||||||
|
"""GET /api/v1/contacts returns 200 with paginated list."""
|
||||||
|
response = await admin_client.get("/api/v1/contacts/?page=1&page_size=20")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert "items" in data
|
||||||
|
assert "total" in data
|
||||||
|
assert "page" in data
|
||||||
|
assert "page_size" in data
|
||||||
|
assert data["total"] >= 1
|
||||||
|
assert len(data["items"]) >= 1
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_contacts_filter_by_role_kaeufer_includes_beide(
|
||||||
|
self, admin_client, sample_contact_data, sample_beide_contact_data
|
||||||
|
):
|
||||||
|
"""GET /api/v1/contacts?role=kaeufer returns kaeufer + beide contacts."""
|
||||||
|
# Create a kaeufer contact
|
||||||
|
resp1 = await admin_client.post("/api/v1/contacts/", json=sample_contact_data)
|
||||||
|
assert resp1.status_code == 201
|
||||||
|
# Create a beide contact
|
||||||
|
resp2 = await admin_client.post(
|
||||||
|
"/api/v1/contacts/", json=sample_beide_contact_data
|
||||||
|
)
|
||||||
|
assert resp2.status_code == 201
|
||||||
|
|
||||||
|
response = await admin_client.get("/api/v1/contacts/?role=kaeufer")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
roles = [c["role"] for c in data["items"]]
|
||||||
|
assert "kaeufer" in roles
|
||||||
|
assert "beide" in roles
|
||||||
|
assert "verkaeufer" not in roles
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_contacts_filter_by_role_verkaeufer_includes_beide(
|
||||||
|
self, admin_client, sample_eu_contact_data, sample_beide_contact_data
|
||||||
|
):
|
||||||
|
"""GET /api/v1/contacts?role=verkaeufer returns verkaeufer + beide contacts."""
|
||||||
|
resp1 = await admin_client.post(
|
||||||
|
"/api/v1/contacts/", json=sample_eu_contact_data
|
||||||
|
)
|
||||||
|
assert resp1.status_code == 201
|
||||||
|
resp2 = await admin_client.post(
|
||||||
|
"/api/v1/contacts/", json=sample_beide_contact_data
|
||||||
|
)
|
||||||
|
assert resp2.status_code == 201
|
||||||
|
|
||||||
|
response = await admin_client.get("/api/v1/contacts/?role=verkaeufer")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
roles = [c["role"] for c in data["items"]]
|
||||||
|
assert "verkaeufer" in roles
|
||||||
|
assert "beide" in roles
|
||||||
|
assert "kaeufer" not in roles
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_contacts_filter_is_eu_true(
|
||||||
|
self, admin_client, sample_contact_data, sample_eu_contact_data
|
||||||
|
):
|
||||||
|
"""GET /api/v1/contacts?is_eu=true returns only non-DE contacts."""
|
||||||
|
await admin_client.post("/api/v1/contacts/", json=sample_contact_data)
|
||||||
|
await admin_client.post("/api/v1/contacts/", json=sample_eu_contact_data)
|
||||||
|
|
||||||
|
response = await admin_client.get("/api/v1/contacts/?is_eu=true")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
for item in data["items"]:
|
||||||
|
assert item["address_country"] != "DE"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_contacts_filter_is_eu_false(
|
||||||
|
self, admin_client, sample_contact_data, sample_eu_contact_data
|
||||||
|
):
|
||||||
|
"""GET /api/v1/contacts?is_eu=false returns only DE contacts."""
|
||||||
|
await admin_client.post("/api/v1/contacts/", json=sample_contact_data)
|
||||||
|
await admin_client.post("/api/v1/contacts/", json=sample_eu_contact_data)
|
||||||
|
|
||||||
|
response = await admin_client.get("/api/v1/contacts/?is_eu=false")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
for item in data["items"]:
|
||||||
|
assert item["address_country"] == "DE"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_contacts_search_by_company_name(
|
||||||
|
self, admin_client, created_contact
|
||||||
|
):
|
||||||
|
"""GET /api/v1/contacts?search=mueller returns matching contacts."""
|
||||||
|
response = await admin_client.get("/api/v1/contacts/?search=mueller")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["total"] >= 1
|
||||||
|
assert any("Müller" in c["company_name"] for c in data["items"])
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_contacts_search_by_city(self, admin_client, created_contact):
|
||||||
|
"""GET /api/v1/contacts?search=berlin returns matching contacts."""
|
||||||
|
response = await admin_client.get("/api/v1/contacts/?search=berlin")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["total"] >= 1
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_contacts_search_no_results(self, admin_client):
|
||||||
|
"""GET /api/v1/contacts?search=nonexistent returns empty list."""
|
||||||
|
response = await admin_client.get("/api/v1/contacts/?search=nonexistent_xyz")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["total"] == 0
|
||||||
|
assert len(data["items"]) == 0
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_contacts_sort_by_company_name(
|
||||||
|
self, admin_client, sample_contact_data, sample_eu_contact_data
|
||||||
|
):
|
||||||
|
"""GET /api/v1/contacts?sort=company_name returns sorted list."""
|
||||||
|
await admin_client.post("/api/v1/contacts/", json=sample_contact_data)
|
||||||
|
await admin_client.post("/api/v1/contacts/", json=sample_eu_contact_data)
|
||||||
|
|
||||||
|
response = await admin_client.get("/api/v1/contacts/?sort=company_name")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
names = [c["company_name"] for c in data["items"]]
|
||||||
|
assert names == sorted(names)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_contacts_pagination(self, admin_client, sample_contact_data):
|
||||||
|
"""GET /api/v1/contacts?page=1&page_size=1 returns correct pagination."""
|
||||||
|
for i in range(3):
|
||||||
|
data = {**sample_contact_data, "company_name": f"Company {i} GmbH"}
|
||||||
|
await admin_client.post("/api/v1/contacts/", json=data)
|
||||||
|
|
||||||
|
response = await admin_client.get("/api/v1/contacts/?page=1&page_size=1")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["page"] == 1
|
||||||
|
assert data["page_size"] == 1
|
||||||
|
assert len(data["items"]) == 1
|
||||||
|
assert data["total"] >= 3
|
||||||
|
|
||||||
|
|
||||||
|
class TestContactDetail:
|
||||||
|
"""GET /api/v1/contacts/:id tests."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_contact_returns_200_with_detail(
|
||||||
|
self, admin_client, created_contact
|
||||||
|
):
|
||||||
|
"""GET /api/v1/contacts/:id returns 200 with contact detail."""
|
||||||
|
response = await admin_client.get(f"/api/v1/contacts/{created_contact['id']}")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["id"] == created_contact["id"]
|
||||||
|
assert data["company_name"] == "Müller Transport GmbH"
|
||||||
|
assert "contact_persons" in data
|
||||||
|
assert isinstance(data["contact_persons"], list)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_contact_nonexistent_returns_404(self, admin_client):
|
||||||
|
"""GET /api/v1/contacts/:nonexistent returns 404."""
|
||||||
|
fake_id = uuid.uuid4()
|
||||||
|
response = await admin_client.get(f"/api/v1/contacts/{fake_id}")
|
||||||
|
assert response.status_code == 404
|
||||||
|
data = response.json()
|
||||||
|
assert data["detail"]["error"]["code"] == "CONTACT_NOT_FOUND"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_contact_after_soft_delete_returns_404(
|
||||||
|
self, admin_client, created_contact
|
||||||
|
):
|
||||||
|
"""GET /api/v1/contacts/:id after soft-delete returns 404."""
|
||||||
|
del_resp = await admin_client.delete(
|
||||||
|
f"/api/v1/contacts/{created_contact['id']}"
|
||||||
|
)
|
||||||
|
assert del_resp.status_code == 200
|
||||||
|
get_resp = await admin_client.get(f"/api/v1/contacts/{created_contact['id']}")
|
||||||
|
assert get_resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
class TestContactCreate:
|
||||||
|
"""POST /api/v1/contacts tests."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_contact_returns_201(self, admin_client, sample_contact_data):
|
||||||
|
"""POST /api/v1/contacts with valid data returns 201."""
|
||||||
|
response = await admin_client.post(
|
||||||
|
"/api/v1/contacts/", json=sample_contact_data
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
data = response.json()
|
||||||
|
assert data["company_name"] == sample_contact_data["company_name"]
|
||||||
|
assert data["role"] == "kaeufer"
|
||||||
|
assert data["vat_id_status"] == "ungeprueft"
|
||||||
|
assert data["id"] is not None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_contact_with_invalid_vat_id_returns_422(
|
||||||
|
self, admin_client, sample_contact_data
|
||||||
|
):
|
||||||
|
"""POST /api/v1/contacts with invalid VAT ID format returns 422."""
|
||||||
|
data = {**sample_contact_data, "vat_id": "INVALID123"}
|
||||||
|
response = await admin_client.post("/api/v1/contacts/", json=data)
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_contact_with_de_vat_too_short_returns_422(
|
||||||
|
self, admin_client, sample_contact_data
|
||||||
|
):
|
||||||
|
"""POST /api/v1/contacts with too-short DE VAT ID returns 422."""
|
||||||
|
data = {**sample_contact_data, "vat_id": "DE12345678"}
|
||||||
|
response = await admin_client.post("/api/v1/contacts/", json=data)
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_contact_with_no_vat_id_returns_201(
|
||||||
|
self, admin_client, sample_contact_data
|
||||||
|
):
|
||||||
|
"""POST /api/v1/contacts without VAT ID returns 201."""
|
||||||
|
data = {**sample_contact_data}
|
||||||
|
data.pop("vat_id")
|
||||||
|
response = await admin_client.post("/api/v1/contacts/", json=data)
|
||||||
|
assert response.status_code == 201
|
||||||
|
assert response.json()["vat_id"] is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_contact_with_contact_persons(
|
||||||
|
self, admin_client, sample_contact_data
|
||||||
|
):
|
||||||
|
"""POST /api/v1/contacts with nested contact persons returns 201."""
|
||||||
|
data = {
|
||||||
|
**sample_contact_data,
|
||||||
|
"contact_persons": [
|
||||||
|
{
|
||||||
|
"name": "Hans Müller",
|
||||||
|
"function": "Geschäftsführer",
|
||||||
|
"phone": "+49 30 87654321",
|
||||||
|
"email": "hans@mueller-transport.de",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
response = await admin_client.post("/api/v1/contacts/", json=data)
|
||||||
|
assert response.status_code == 201
|
||||||
|
contact_data = response.json()
|
||||||
|
assert len(contact_data["contact_persons"]) == 1
|
||||||
|
assert contact_data["contact_persons"][0]["name"] == "Hans Müller"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_contact_missing_required_fields_returns_422(
|
||||||
|
self, admin_client
|
||||||
|
):
|
||||||
|
"""POST /api/v1/contacts with missing required fields returns 422."""
|
||||||
|
response = await admin_client.post(
|
||||||
|
"/api/v1/contacts/", json={"address_country": "DE"}
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_contact_invalid_role_returns_422(
|
||||||
|
self, admin_client, sample_contact_data
|
||||||
|
):
|
||||||
|
"""POST /api/v1/contacts with invalid role returns 422."""
|
||||||
|
data = {**sample_contact_data, "role": "invalid_role"}
|
||||||
|
response = await admin_client.post("/api/v1/contacts/", json=data)
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
class TestContactUpdate:
|
||||||
|
"""PUT /api/v1/contacts/:id tests."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_contact_returns_200(self, admin_client, created_contact):
|
||||||
|
"""PUT /api/v1/contacts/:id with valid data returns 200."""
|
||||||
|
response = await admin_client.put(
|
||||||
|
f"/api/v1/contacts/{created_contact['id']}",
|
||||||
|
json={"company_name": "Müller Transport AG", "phone": "+49 30 99999999"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["company_name"] == "Müller Transport AG"
|
||||||
|
assert data["phone"] == "+49 30 99999999"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_contact_nonexistent_returns_404(self, admin_client):
|
||||||
|
"""PUT /api/v1/contacts/:nonexistent returns 404."""
|
||||||
|
fake_id = uuid.uuid4()
|
||||||
|
response = await admin_client.put(
|
||||||
|
f"/api/v1/contacts/{fake_id}",
|
||||||
|
json={"company_name": "Test GmbH"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_contact_invalid_vat_id_returns_422(
|
||||||
|
self, admin_client, created_contact
|
||||||
|
):
|
||||||
|
"""PUT /api/v1/contacts/:id with invalid VAT ID returns 422."""
|
||||||
|
response = await admin_client.put(
|
||||||
|
f"/api/v1/contacts/{created_contact['id']}",
|
||||||
|
json={"vat_id": "INVALID123"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_contact_no_fields_returns_400(
|
||||||
|
self, admin_client, created_contact
|
||||||
|
):
|
||||||
|
"""PUT /api/v1/contacts/:id with no fields returns 400."""
|
||||||
|
response = await admin_client.put(
|
||||||
|
f"/api/v1/contacts/{created_contact['id']}",
|
||||||
|
json={},
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
class TestContactDelete:
|
||||||
|
"""DELETE /api/v1/contacts/:id tests."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_contact_returns_200(self, admin_client, created_contact):
|
||||||
|
"""DELETE /api/v1/contacts/:id returns 200 (soft delete)."""
|
||||||
|
response = await admin_client.delete(
|
||||||
|
f"/api/v1/contacts/{created_contact['id']}"
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["deleted_at"] is not None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_contact_nonexistent_returns_404(self, admin_client):
|
||||||
|
"""DELETE /api/v1/contacts/:nonexistent returns 404."""
|
||||||
|
fake_id = uuid.uuid4()
|
||||||
|
response = await admin_client.delete(f"/api/v1/contacts/{fake_id}")
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_deleted_contact_not_in_list(self, admin_client, created_contact):
|
||||||
|
"""Soft-deleted contact does not appear in list."""
|
||||||
|
del_resp = await admin_client.delete(
|
||||||
|
f"/api/v1/contacts/{created_contact['id']}"
|
||||||
|
)
|
||||||
|
assert del_resp.status_code == 200
|
||||||
|
list_resp = await admin_client.get("/api/v1/contacts/")
|
||||||
|
assert list_resp.status_code == 200
|
||||||
|
ids = [c["id"] for c in list_resp.json()["items"]]
|
||||||
|
assert created_contact["id"] not in ids
|
||||||
|
|
||||||
|
|
||||||
|
class TestContactPersons:
|
||||||
|
"""Contact person management endpoints."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_add_contact_person_returns_201(self, admin_client, created_contact):
|
||||||
|
"""POST /api/v1/contacts/:id/persons returns 201."""
|
||||||
|
response = await admin_client.post(
|
||||||
|
f"/api/v1/contacts/{created_contact['id']}/persons",
|
||||||
|
json={
|
||||||
|
"name": "Anna Schmidt",
|
||||||
|
"function": "Einkauf",
|
||||||
|
"phone": "+49 30 11122233",
|
||||||
|
"email": "anna@mueller-transport.de",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
data = response.json()
|
||||||
|
assert data["name"] == "Anna Schmidt"
|
||||||
|
assert data["function"] == "Einkauf"
|
||||||
|
assert data["id"] is not None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_add_contact_person_to_nonexistent_contact_returns_404(
|
||||||
|
self, admin_client
|
||||||
|
):
|
||||||
|
"""POST /api/v1/contacts/:nonexistent/persons returns 404."""
|
||||||
|
fake_id = uuid.uuid4()
|
||||||
|
response = await admin_client.post(
|
||||||
|
f"/api/v1/contacts/{fake_id}/persons",
|
||||||
|
json={"name": "Test Person"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_remove_contact_person_returns_204(
|
||||||
|
self, admin_client, created_contact
|
||||||
|
):
|
||||||
|
"""DELETE /api/v1/contacts/:id/persons/:person_id returns 204."""
|
||||||
|
# First add a person
|
||||||
|
add_resp = await admin_client.post(
|
||||||
|
f"/api/v1/contacts/{created_contact['id']}/persons",
|
||||||
|
json={"name": "Test Person"},
|
||||||
|
)
|
||||||
|
assert add_resp.status_code == 201
|
||||||
|
person_id = add_resp.json()["id"]
|
||||||
|
|
||||||
|
# Then remove it
|
||||||
|
del_resp = await admin_client.delete(
|
||||||
|
f"/api/v1/contacts/{created_contact['id']}/persons/{person_id}"
|
||||||
|
)
|
||||||
|
assert del_resp.status_code == 204
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_remove_nonexistent_contact_person_returns_404(
|
||||||
|
self, admin_client, created_contact
|
||||||
|
):
|
||||||
|
"""DELETE /api/v1/contacts/:id/persons/:nonexistent returns 404."""
|
||||||
|
fake_person_id = uuid.uuid4()
|
||||||
|
response = await admin_client.delete(
|
||||||
|
f"/api/v1/contacts/{created_contact['id']}/persons/{fake_person_id}"
|
||||||
|
)
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_contact_detail_includes_persons(self, admin_client, created_contact):
|
||||||
|
"""GET /api/v1/contacts/:id includes contact persons in response."""
|
||||||
|
# Add a person
|
||||||
|
await admin_client.post(
|
||||||
|
f"/api/v1/contacts/{created_contact['id']}/persons",
|
||||||
|
json={"name": "Max Mustermann", "function": "Vertrieb"},
|
||||||
|
)
|
||||||
|
# Get contact detail
|
||||||
|
response = await admin_client.get(f"/api/v1/contacts/{created_contact['id']}")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert len(data["contact_persons"]) >= 1
|
||||||
|
assert data["contact_persons"][0]["name"] == "Max Mustermann"
|
||||||
|
|
||||||
|
|
||||||
|
class TestContactRBAC:
|
||||||
|
"""RBAC enforcement tests."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_contacts_requires_auth(self, client):
|
||||||
|
"""GET /api/v1/contacts without auth returns 401."""
|
||||||
|
response = await client.get("/api/v1/contacts/")
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_contact_as_admin_returns_201(
|
||||||
|
self, admin_client, sample_contact_data
|
||||||
|
):
|
||||||
|
"""POST /api/v1/contacts as admin returns 201."""
|
||||||
|
response = await admin_client.post(
|
||||||
|
"/api/v1/contacts/", json=sample_contact_data
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_contact_as_verkaeufer_returns_201(
|
||||||
|
self, verkaeufer_client, sample_contact_data
|
||||||
|
):
|
||||||
|
"""POST /api/v1/contacts as verkaeufer returns 201."""
|
||||||
|
response = await verkaeufer_client.post(
|
||||||
|
"/api/v1/contacts/", json=sample_contact_data
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_contacts_as_verkaeufer_returns_200(
|
||||||
|
self, verkaeufer_client, created_contact
|
||||||
|
):
|
||||||
|
"""GET /api/v1/contacts as verkaeufer returns 200 (read allowed)."""
|
||||||
|
response = await verkaeufer_client.get("/api/v1/contacts/")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_contact_as_verkaeufer_returns_200(
|
||||||
|
self, verkaeufer_client, created_contact
|
||||||
|
):
|
||||||
|
"""PUT /api/v1/contacts/:id as verkaeufer returns 200."""
|
||||||
|
response = await verkaeufer_client.put(
|
||||||
|
f"/api/v1/contacts/{created_contact['id']}",
|
||||||
|
json={"company_name": "Updated by Verkaeufer"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_contact_as_verkaeufer_returns_200(
|
||||||
|
self, verkaeufer_client, sample_contact_data
|
||||||
|
):
|
||||||
|
"""DELETE /api/v1/contacts/:id as verkaeufer returns 200."""
|
||||||
|
resp = await verkaeufer_client.post(
|
||||||
|
"/api/v1/contacts/", json=sample_contact_data
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
contact_id = resp.json()["id"]
|
||||||
|
del_resp = await verkaeufer_client.delete(f"/api/v1/contacts/{contact_id}")
|
||||||
|
assert del_resp.status_code == 200
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_add_person_as_verkaeufer_returns_201(
|
||||||
|
self, verkaeufer_client, sample_contact_data
|
||||||
|
):
|
||||||
|
"""POST /api/v1/contacts/:id/persons as verkaeufer returns 201."""
|
||||||
|
resp = await verkaeufer_client.post(
|
||||||
|
"/api/v1/contacts/", json=sample_contact_data
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
contact_id = resp.json()["id"]
|
||||||
|
person_resp = await verkaeufer_client.post(
|
||||||
|
f"/api/v1/contacts/{contact_id}/persons",
|
||||||
|
json={"name": "Test Person"},
|
||||||
|
)
|
||||||
|
assert person_resp.status_code == 201
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_remove_person_as_verkaeufer_returns_204(
|
||||||
|
self, verkaeufer_client, sample_contact_data
|
||||||
|
):
|
||||||
|
"""DELETE /api/v1/contacts/:id/persons/:pid as verkaeufer returns 204."""
|
||||||
|
resp = await verkaeufer_client.post(
|
||||||
|
"/api/v1/contacts/", json=sample_contact_data
|
||||||
|
)
|
||||||
|
contact_id = resp.json()["id"]
|
||||||
|
person_resp = await verkaeufer_client.post(
|
||||||
|
f"/api/v1/contacts/{contact_id}/persons",
|
||||||
|
json={"name": "To Remove"},
|
||||||
|
)
|
||||||
|
person_id = person_resp.json()["id"]
|
||||||
|
del_resp = await verkaeufer_client.delete(
|
||||||
|
f"/api/v1/contacts/{contact_id}/persons/{person_id}"
|
||||||
|
)
|
||||||
|
assert del_resp.status_code == 204
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_nonexistent_contact_returns_404_as_verkaeufer(
|
||||||
|
self, verkaeufer_client
|
||||||
|
):
|
||||||
|
"""PUT /api/v1/contacts/:nonexistent as verkaeufer returns 404."""
|
||||||
|
fake_id = uuid.uuid4()
|
||||||
|
response = await verkaeufer_client.put(
|
||||||
|
f"/api/v1/contacts/{fake_id}",
|
||||||
|
json={"company_name": "Test"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_nonexistent_contact_returns_404_as_verkaeufer(
|
||||||
|
self, verkaeufer_client
|
||||||
|
):
|
||||||
|
"""DELETE /api/v1/contacts/:nonexistent as verkaeufer returns 404."""
|
||||||
|
fake_id = uuid.uuid4()
|
||||||
|
response = await verkaeufer_client.delete(f"/api/v1/contacts/{fake_id}")
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_add_person_to_nonexistent_contact_returns_404_as_verkaeufer(
|
||||||
|
self, verkaeufer_client
|
||||||
|
):
|
||||||
|
"""POST /api/v1/contacts/:nonexistent/persons as verkaeufer returns 404."""
|
||||||
|
fake_id = uuid.uuid4()
|
||||||
|
response = await verkaeufer_client.post(
|
||||||
|
f"/api/v1/contacts/{fake_id}/persons",
|
||||||
|
json={"name": "Test"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_remove_nonexistent_person_returns_404_as_verkaeufer(
|
||||||
|
self, verkaeufer_client, sample_contact_data
|
||||||
|
):
|
||||||
|
"""DELETE /api/v1/contacts/:id/persons/:nonexistent as verkaeufer returns 404."""
|
||||||
|
resp = await verkaeufer_client.post(
|
||||||
|
"/api/v1/contacts/", json=sample_contact_data
|
||||||
|
)
|
||||||
|
contact_id = resp.json()["id"]
|
||||||
|
fake_person_id = uuid.uuid4()
|
||||||
|
response = await verkaeufer_client.delete(
|
||||||
|
f"/api/v1/contacts/{contact_id}/persons/{fake_person_id}"
|
||||||
|
)
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
class TestContactServiceDirect:
|
||||||
|
"""Direct service-level tests for coverage."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_service_list_contacts_with_all_filters(self, db_session):
|
||||||
|
"""Test list_contacts with all filter parameters."""
|
||||||
|
from app.services import contact_service
|
||||||
|
|
||||||
|
contact1 = Contact(
|
||||||
|
company_name="Alpha GmbH",
|
||||||
|
address_city="Berlin",
|
||||||
|
address_country="DE",
|
||||||
|
role="kaeufer",
|
||||||
|
)
|
||||||
|
contact2 = Contact(
|
||||||
|
company_name="Beta B.V.",
|
||||||
|
address_city="Amsterdam",
|
||||||
|
address_country="NL",
|
||||||
|
role="verkaeufer",
|
||||||
|
)
|
||||||
|
contact3 = Contact(
|
||||||
|
company_name="Gamma KG",
|
||||||
|
address_city="Hamburg",
|
||||||
|
address_country="DE",
|
||||||
|
role="beide",
|
||||||
|
)
|
||||||
|
db_session.add_all([contact1, contact2, contact3])
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
# Test search
|
||||||
|
results, total = await contact_service.list_contacts(db_session, search="alpha")
|
||||||
|
assert total == 1
|
||||||
|
assert results[0].company_name == "Alpha GmbH"
|
||||||
|
|
||||||
|
# Test role filter (kaeufer includes beide)
|
||||||
|
results, total = await contact_service.list_contacts(db_session, role="kaeufer")
|
||||||
|
assert total == 2
|
||||||
|
|
||||||
|
# Test is_eu filter
|
||||||
|
results, total = await contact_service.list_contacts(db_session, is_eu=True)
|
||||||
|
assert total == 1
|
||||||
|
assert results[0].address_country == "NL"
|
||||||
|
|
||||||
|
# Test is_eu=false (Inland)
|
||||||
|
results, total = await contact_service.list_contacts(db_session, is_eu=False)
|
||||||
|
assert total == 2
|
||||||
|
|
||||||
|
# Test is_private filter
|
||||||
|
contact4 = Contact(
|
||||||
|
company_name="Private Person",
|
||||||
|
address_country="DE",
|
||||||
|
role="kaeufer",
|
||||||
|
is_private=True,
|
||||||
|
)
|
||||||
|
db_session.add(contact4)
|
||||||
|
await db_session.commit()
|
||||||
|
results, total = await contact_service.list_contacts(
|
||||||
|
db_session, is_private=True
|
||||||
|
)
|
||||||
|
assert total == 1
|
||||||
|
|
||||||
|
# Test sort descending
|
||||||
|
results, total = await contact_service.list_contacts(
|
||||||
|
db_session, sort="-company_name"
|
||||||
|
)
|
||||||
|
names = [r.company_name for r in results]
|
||||||
|
assert names == sorted(names, reverse=True)
|
||||||
|
|
||||||
|
# Test invalid sort field falls back to created_at
|
||||||
|
results, total = await contact_service.list_contacts(
|
||||||
|
db_session, sort="invalid_field"
|
||||||
|
)
|
||||||
|
assert total >= 4
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_service_get_contact_by_id_not_found(self, db_session):
|
||||||
|
"""Test get_contact_by_id returns None for nonexistent ID."""
|
||||||
|
from app.services import contact_service
|
||||||
|
|
||||||
|
result = await contact_service.get_contact_by_id(db_session, uuid.uuid4())
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_service_create_contact_with_persons(self, db_session):
|
||||||
|
"""Test create_contact with nested contact persons."""
|
||||||
|
from app.services import contact_service
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"company_name": "Test Service GmbH",
|
||||||
|
"address_country": "DE",
|
||||||
|
"role": "kaeufer",
|
||||||
|
"contact_persons": [
|
||||||
|
{"name": "Person 1", "function": "CEO"},
|
||||||
|
{"name": "Person 2", "phone": "+49 30 123"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
contact = await contact_service.create_contact(db_session, data)
|
||||||
|
assert contact.company_name == "Test Service GmbH"
|
||||||
|
assert len(contact.contact_persons) == 2
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_service_update_contact_not_found(self, db_session):
|
||||||
|
"""Test update_contact returns None for nonexistent ID."""
|
||||||
|
from app.services import contact_service
|
||||||
|
|
||||||
|
result = await contact_service.update_contact(
|
||||||
|
db_session, uuid.uuid4(), {"company_name": "Test"}
|
||||||
|
)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_service_soft_delete_contact_not_found(self, db_session):
|
||||||
|
"""Test soft_delete_contact returns None for nonexistent ID."""
|
||||||
|
from app.services import contact_service
|
||||||
|
|
||||||
|
result = await contact_service.soft_delete_contact(db_session, uuid.uuid4())
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_service_add_contact_person_not_found(self, db_session):
|
||||||
|
"""Test add_contact_person returns None for nonexistent contact."""
|
||||||
|
from app.services import contact_service
|
||||||
|
|
||||||
|
result = await contact_service.add_contact_person(
|
||||||
|
db_session, uuid.uuid4(), {"name": "Test"}
|
||||||
|
)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_service_remove_contact_person_not_found(self, db_session):
|
||||||
|
"""Test remove_contact_person returns False for nonexistent person."""
|
||||||
|
from app.services import contact_service
|
||||||
|
|
||||||
|
result = await contact_service.remove_contact_person(
|
||||||
|
db_session, uuid.uuid4(), uuid.uuid4()
|
||||||
|
)
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_service_update_contact_success(self, db_session):
|
||||||
|
"""Test update_contact successfully updates fields."""
|
||||||
|
from app.services import contact_service
|
||||||
|
|
||||||
|
contact = Contact(
|
||||||
|
company_name="Original GmbH",
|
||||||
|
address_country="DE",
|
||||||
|
role="kaeufer",
|
||||||
|
)
|
||||||
|
db_session.add(contact)
|
||||||
|
await db_session.commit()
|
||||||
|
await db_session.refresh(contact)
|
||||||
|
|
||||||
|
updated = await contact_service.update_contact(
|
||||||
|
db_session,
|
||||||
|
contact.id,
|
||||||
|
{"company_name": "Updated GmbH", "phone": "+49 30 999"},
|
||||||
|
)
|
||||||
|
assert updated.company_name == "Updated GmbH"
|
||||||
|
assert updated.phone == "+49 30 999"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_service_soft_delete_contact_success(self, db_session):
|
||||||
|
"""Test soft_delete_contact sets deleted_at."""
|
||||||
|
from app.services import contact_service
|
||||||
|
|
||||||
|
contact = Contact(
|
||||||
|
company_name="To Delete GmbH",
|
||||||
|
address_country="DE",
|
||||||
|
role="kaeufer",
|
||||||
|
)
|
||||||
|
db_session.add(contact)
|
||||||
|
await db_session.commit()
|
||||||
|
await db_session.refresh(contact)
|
||||||
|
|
||||||
|
deleted = await contact_service.soft_delete_contact(db_session, contact.id)
|
||||||
|
assert deleted.deleted_at is not None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_service_add_and_remove_contact_person(self, db_session):
|
||||||
|
"""Test add_contact_person and remove_contact_person."""
|
||||||
|
from app.services import contact_service
|
||||||
|
|
||||||
|
contact = Contact(
|
||||||
|
company_name="Person Test GmbH",
|
||||||
|
address_country="DE",
|
||||||
|
role="kaeufer",
|
||||||
|
)
|
||||||
|
db_session.add(contact)
|
||||||
|
await db_session.commit()
|
||||||
|
await db_session.refresh(contact)
|
||||||
|
|
||||||
|
person = await contact_service.add_contact_person(
|
||||||
|
db_session, contact.id, {"name": "Test Person", "function": "Manager"}
|
||||||
|
)
|
||||||
|
assert person is not None
|
||||||
|
assert person.name == "Test Person"
|
||||||
|
|
||||||
|
removed = await contact_service.remove_contact_person(
|
||||||
|
db_session, contact.id, person.id
|
||||||
|
)
|
||||||
|
assert removed is True
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ust_validation_or_raise_valid(self):
|
||||||
|
"""Test validate_vat_id_or_raise with valid VAT ID."""
|
||||||
|
from app.utils.ust_validation import validate_vat_id_or_raise
|
||||||
|
|
||||||
|
result = validate_vat_id_or_raise("DE123456789")
|
||||||
|
assert result == "DE123456789"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ust_validation_or_raise_none(self):
|
||||||
|
"""Test validate_vat_id_or_raise with None."""
|
||||||
|
from app.utils.ust_validation import validate_vat_id_or_raise
|
||||||
|
|
||||||
|
result = validate_vat_id_or_raise(None)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ust_validation_or_raise_empty(self):
|
||||||
|
"""Test validate_vat_id_or_raise with empty string."""
|
||||||
|
from app.utils.ust_validation import validate_vat_id_or_raise
|
||||||
|
|
||||||
|
result = validate_vat_id_or_raise("")
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ust_validation_or_raise_invalid(self):
|
||||||
|
"""Test validate_vat_id_or_raise raises ValueError for invalid format."""
|
||||||
|
from app.utils.ust_validation import validate_vat_id_or_raise
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="Invalid VAT ID format"):
|
||||||
|
validate_vat_id_or_raise("DE123")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ust_validation_get_country_code(self):
|
||||||
|
"""Test get_country_code_from_vat_id."""
|
||||||
|
from app.utils.ust_validation import get_country_code_from_vat_id
|
||||||
|
|
||||||
|
assert get_country_code_from_vat_id("DE123456789") == "DE"
|
||||||
|
assert get_country_code_from_vat_id("at123") == "AT"
|
||||||
|
assert get_country_code_from_vat_id("") is None
|
||||||
|
assert get_country_code_from_vat_id("A") is None
|
||||||
|
assert get_country_code_from_vat_id("12") is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ust_validation_eu_fallback(self):
|
||||||
|
"""Test EU fallback pattern for countries without specific regex."""
|
||||||
|
from app.utils.ust_validation import validate_vat_id
|
||||||
|
|
||||||
|
# Ireland (IE) is in EU set but has no specific pattern
|
||||||
|
assert validate_vat_id("IE1234567AB") is True
|
||||||
|
# Bulgaria (BG) is in EU set but has no specific pattern
|
||||||
|
assert validate_vat_id("BG1234567890") is True
|
||||||
|
# Too short for fallback
|
||||||
|
assert validate_vat_id("BG123") is False
|
||||||
@@ -0,0 +1,508 @@
|
|||||||
|
"""Tests for Copilot chat, action, history, and voice endpoints with mocked OpenRouter."""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
|
||||||
|
from app.models.copilot import CopilotChat
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def sample_vehicle_data():
|
||||||
|
"""Valid vehicle data for creation."""
|
||||||
|
return {
|
||||||
|
"make": "Mercedes-Benz",
|
||||||
|
"model": "Actros",
|
||||||
|
"fin": "WDB9066351L123456",
|
||||||
|
"year": 2020,
|
||||||
|
"power_kw": 300,
|
||||||
|
"fuel_type": "Diesel",
|
||||||
|
"condition": "used",
|
||||||
|
"availability": "available",
|
||||||
|
"price": 45000.00,
|
||||||
|
"vehicle_type": "lkw",
|
||||||
|
"lkw_type": "sattelzugmaschine",
|
||||||
|
"mileage_km": 120000,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_chat_json(response_text: str, actions: list | None = None) -> str:
|
||||||
|
"""Build a JSON response string as the AI would return."""
|
||||||
|
return json.dumps(
|
||||||
|
{
|
||||||
|
"response": response_text,
|
||||||
|
"actions": actions or [],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_openrouter_chat(content: str):
|
||||||
|
"""Patch _call_openrouter_chat to return fixed content.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
with _patch_openrouter_chat(content):
|
||||||
|
response = await client.post(...)
|
||||||
|
"""
|
||||||
|
return patch(
|
||||||
|
"app.services.copilot_service._call_openrouter_chat",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=content,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCopilotChat:
|
||||||
|
"""POST /api/v1/copilot/chat tests."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_chat_returns_200_with_response_and_actions(self, admin_client):
|
||||||
|
"""POST /copilot/chat with valid message returns 200 + response + actions."""
|
||||||
|
mock_content = _mock_chat_json(
|
||||||
|
"Ich suche nach allen LKWs im Bestand.",
|
||||||
|
[{"type": "search_vehicles", "params": {"type": "lkw"}}],
|
||||||
|
)
|
||||||
|
with _patch_openrouter_chat(mock_content):
|
||||||
|
response = await admin_client.post(
|
||||||
|
"/api/v1/copilot/chat",
|
||||||
|
json={"message": "Zeige alle LKWs"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200, response.text
|
||||||
|
data = response.json()
|
||||||
|
assert "response" in data
|
||||||
|
assert "actions" in data
|
||||||
|
assert "session_id" in data
|
||||||
|
assert "message_id" in data
|
||||||
|
assert isinstance(data["response"], str)
|
||||||
|
assert len(data["response"]) > 0
|
||||||
|
assert len(data["actions"]) == 1
|
||||||
|
assert data["actions"][0]["type"] == "search_vehicles"
|
||||||
|
assert data["actions"][0]["params"]["type"] == "lkw"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_chat_empty_message_returns_422(self, admin_client):
|
||||||
|
"""POST /copilot/chat with empty message returns 422."""
|
||||||
|
response = await admin_client.post(
|
||||||
|
"/api/v1/copilot/chat",
|
||||||
|
json={"message": ""},
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_chat_no_actions_returns_empty_list(self, admin_client):
|
||||||
|
"""POST /copilot/chat with a general question returns empty actions list."""
|
||||||
|
mock_content = _mock_chat_json("Hallo! Wie kann ich helfen?", [])
|
||||||
|
with _patch_openrouter_chat(mock_content):
|
||||||
|
response = await admin_client.post(
|
||||||
|
"/api/v1/copilot/chat",
|
||||||
|
json={"message": "Hallo"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["actions"] == []
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_chat_persists_messages_to_db(self, admin_client, db_session):
|
||||||
|
"""Chat messages are persisted to the database."""
|
||||||
|
mock_content = _mock_chat_json("Test response", [])
|
||||||
|
with _patch_openrouter_chat(mock_content):
|
||||||
|
response = await admin_client.post(
|
||||||
|
"/api/v1/copilot/chat",
|
||||||
|
json={"message": "Test message"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
session_id = response.json()["session_id"]
|
||||||
|
|
||||||
|
# Verify messages in DB via a fresh query
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
stmt = select(CopilotChat).where(
|
||||||
|
CopilotChat.session_id == uuid.UUID(session_id)
|
||||||
|
)
|
||||||
|
result = await db_session.execute(stmt)
|
||||||
|
messages = list(result.scalars().all())
|
||||||
|
assert len(messages) == 2 # user + assistant
|
||||||
|
roles = [m.role.value for m in messages]
|
||||||
|
assert "user" in roles
|
||||||
|
assert "assistant" in roles
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_chat_contact_search_action(self, admin_client):
|
||||||
|
"""Copilot can propose a contact search action."""
|
||||||
|
mock_content = _mock_chat_json(
|
||||||
|
"Ich suche nach Kontakten mit dem Namen Müller.",
|
||||||
|
[{"type": "search_contacts", "params": {"search": "Müller"}}],
|
||||||
|
)
|
||||||
|
with _patch_openrouter_chat(mock_content):
|
||||||
|
response = await admin_client.post(
|
||||||
|
"/api/v1/copilot/chat",
|
||||||
|
json={"message": "Suche Kontakt Müller"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert len(data["actions"]) == 1
|
||||||
|
assert data["actions"][0]["type"] == "search_contacts"
|
||||||
|
assert data["actions"][0]["params"]["search"] == "Müller"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_chat_requires_auth(self, client):
|
||||||
|
"""POST /copilot/chat without auth returns 401."""
|
||||||
|
response = await client.post(
|
||||||
|
"/api/v1/copilot/chat",
|
||||||
|
json={"message": "test"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_chat_continues_existing_session(self, admin_client):
|
||||||
|
"""POST /copilot/chat with session_id continues the same session."""
|
||||||
|
mock_content1 = _mock_chat_json("Erste Antwort", [])
|
||||||
|
with _patch_openrouter_chat(mock_content1):
|
||||||
|
resp1 = await admin_client.post(
|
||||||
|
"/api/v1/copilot/chat",
|
||||||
|
json={"message": "Erste Nachricht"},
|
||||||
|
)
|
||||||
|
assert resp1.status_code == 200
|
||||||
|
session_id = resp1.json()["session_id"]
|
||||||
|
|
||||||
|
mock_content2 = _mock_chat_json("Zweite Antwort", [])
|
||||||
|
with _patch_openrouter_chat(mock_content2):
|
||||||
|
resp2 = await admin_client.post(
|
||||||
|
"/api/v1/copilot/chat",
|
||||||
|
json={"message": "Zweite Nachricht", "session_id": session_id},
|
||||||
|
)
|
||||||
|
assert resp2.status_code == 200
|
||||||
|
assert resp2.json()["session_id"] == session_id
|
||||||
|
|
||||||
|
|
||||||
|
class TestCopilotAction:
|
||||||
|
"""POST /api/v1/copilot/action tests."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_action_search_vehicles_returns_200(
|
||||||
|
self, admin_client, sample_vehicle_data
|
||||||
|
):
|
||||||
|
"""POST /copilot/action with search_vehicles returns 200 + results."""
|
||||||
|
# First create a vehicle
|
||||||
|
await admin_client.post("/api/v1/vehicles/", json=sample_vehicle_data)
|
||||||
|
|
||||||
|
response = await admin_client.post(
|
||||||
|
"/api/v1/copilot/action",
|
||||||
|
json={"action": "search_vehicles", "params": {"type": "lkw"}},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200, response.text
|
||||||
|
data = response.json()
|
||||||
|
assert data["action"] == "search_vehicles"
|
||||||
|
assert data["success"] is True
|
||||||
|
assert "result" in data
|
||||||
|
assert "items" in data["result"]
|
||||||
|
assert data["result"]["total"] >= 1
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_action_search_contacts_returns_200(self, admin_client):
|
||||||
|
"""POST /copilot/action with search_contacts returns 200 + results."""
|
||||||
|
# First create a contact
|
||||||
|
await admin_client.post(
|
||||||
|
"/api/v1/contacts/",
|
||||||
|
json={
|
||||||
|
"company_name": "Test GmbH",
|
||||||
|
"role": "kaeufer",
|
||||||
|
"address_country": "DE",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await admin_client.post(
|
||||||
|
"/api/v1/copilot/action",
|
||||||
|
json={"action": "search_contacts", "params": {"search": "Test"}},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200, response.text
|
||||||
|
data = response.json()
|
||||||
|
assert data["action"] == "search_contacts"
|
||||||
|
assert data["success"] is True
|
||||||
|
assert data["result"]["total"] >= 1
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_action_invalid_action_returns_400(self, admin_client):
|
||||||
|
"""POST /copilot/action with invalid action returns 400."""
|
||||||
|
response = await admin_client.post(
|
||||||
|
"/api/v1/copilot/action",
|
||||||
|
json={"action": "invalid_action", "params": {}},
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
data = response.json()
|
||||||
|
assert "error" in data["detail"]
|
||||||
|
assert data["detail"]["error"]["code"] == "INVALID_ACTION"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_action_get_sale_overview_returns_200(self, admin_client):
|
||||||
|
"""POST /copilot/action with get_sale_overview returns 200."""
|
||||||
|
response = await admin_client.post(
|
||||||
|
"/api/v1/copilot/action",
|
||||||
|
json={"action": "get_sale_overview", "params": {}},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200, response.text
|
||||||
|
data = response.json()
|
||||||
|
assert data["action"] == "get_sale_overview"
|
||||||
|
assert data["success"] is True
|
||||||
|
assert "items" in data["result"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_action_requires_auth(self, client):
|
||||||
|
"""POST /copilot/action without auth returns 401."""
|
||||||
|
response = await client.post(
|
||||||
|
"/api/v1/copilot/action",
|
||||||
|
json={"action": "search_vehicles", "params": {}},
|
||||||
|
)
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
class TestCopilotHistory:
|
||||||
|
"""GET /api/v1/copilot/history tests."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_history_returns_200_with_pagination(self, admin_client):
|
||||||
|
"""GET /copilot/history returns 200 with paginated history."""
|
||||||
|
# First create a chat message
|
||||||
|
mock_content = _mock_chat_json("Test", [])
|
||||||
|
with _patch_openrouter_chat(mock_content):
|
||||||
|
await admin_client.post(
|
||||||
|
"/api/v1/copilot/chat",
|
||||||
|
json={"message": "Test message"},
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await admin_client.get("/api/v1/copilot/history?page=1&page_size=20")
|
||||||
|
assert response.status_code == 200, response.text
|
||||||
|
data = response.json()
|
||||||
|
assert "items" in data
|
||||||
|
assert "total" in data
|
||||||
|
assert "page" in data
|
||||||
|
assert "page_size" in data
|
||||||
|
assert data["total"] >= 2 # user + assistant message
|
||||||
|
assert len(data["items"]) >= 2
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_history_empty_returns_200(self, admin_client):
|
||||||
|
"""GET /copilot/history with no messages returns 200 + empty list."""
|
||||||
|
response = await admin_client.get("/api/v1/copilot/history")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["items"] == []
|
||||||
|
assert data["total"] == 0
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_history_requires_auth(self, client):
|
||||||
|
"""GET /copilot/history without auth returns 401."""
|
||||||
|
response = await client.get("/api/v1/copilot/history")
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_history_filtered_by_session(self, admin_client):
|
||||||
|
"""GET /copilot/history?session_id=... returns only that session's messages."""
|
||||||
|
mock_content = _mock_chat_json("Test", [])
|
||||||
|
with _patch_openrouter_chat(mock_content):
|
||||||
|
resp1 = await admin_client.post(
|
||||||
|
"/api/v1/copilot/chat",
|
||||||
|
json={"message": "Session 1 message"},
|
||||||
|
)
|
||||||
|
await admin_client.post(
|
||||||
|
"/api/v1/copilot/chat",
|
||||||
|
json={"message": "Session 2 message"},
|
||||||
|
)
|
||||||
|
|
||||||
|
session1_id = resp1.json()["session_id"]
|
||||||
|
|
||||||
|
response = await admin_client.get(
|
||||||
|
f"/api/v1/copilot/history?session_id={session1_id}"
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["total"] == 2 # only session 1's user + assistant
|
||||||
|
for item in data["items"]:
|
||||||
|
assert item["session_id"] == session1_id
|
||||||
|
|
||||||
|
|
||||||
|
class TestCopilotVoice:
|
||||||
|
"""POST /api/v1/copilot/voice tests."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_voice_returns_200_with_transcription_and_response(
|
||||||
|
self, admin_client
|
||||||
|
):
|
||||||
|
"""POST /copilot/voice returns 200 with transcription + chat response."""
|
||||||
|
mock_content = _mock_chat_json("Ich helfe dir bei der Suche.", [])
|
||||||
|
audio_b64 = base64.b64encode(b"fake-audio-data").decode("utf-8")
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"app.services.copilot_service.transcribe_audio",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value="Zeige alle LKWs",
|
||||||
|
),
|
||||||
|
_patch_openrouter_chat(mock_content),
|
||||||
|
):
|
||||||
|
response = await admin_client.post(
|
||||||
|
"/api/v1/copilot/voice",
|
||||||
|
json={"audio": audio_b64, "mime_type": "audio/webm"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200, response.text
|
||||||
|
data = response.json()
|
||||||
|
assert "transcription" in data
|
||||||
|
assert "response" in data
|
||||||
|
assert "actions" in data
|
||||||
|
assert "session_id" in data
|
||||||
|
assert "message_id" in data
|
||||||
|
assert isinstance(data["transcription"], str)
|
||||||
|
assert len(data["transcription"]) > 0
|
||||||
|
assert data["transcription"] == "Zeige alle LKWs"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_voice_empty_audio_returns_422(self, admin_client):
|
||||||
|
"""POST /copilot/voice with empty audio returns 422."""
|
||||||
|
response = await admin_client.post(
|
||||||
|
"/api/v1/copilot/voice",
|
||||||
|
json={"audio": ""},
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_voice_requires_auth(self, client):
|
||||||
|
"""POST /copilot/voice without auth returns 401."""
|
||||||
|
audio_b64 = base64.b64encode(b"fake").decode("utf-8")
|
||||||
|
response = await client.post(
|
||||||
|
"/api/v1/copilot/voice",
|
||||||
|
json={"audio": audio_b64},
|
||||||
|
)
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
class TestCopilotServiceUnit:
|
||||||
|
"""Unit tests for copilot_service internal functions."""
|
||||||
|
|
||||||
|
def test_parse_ai_response_valid_json(self):
|
||||||
|
"""_parse_ai_response correctly parses valid JSON."""
|
||||||
|
from app.services.copilot_service import _parse_ai_response
|
||||||
|
|
||||||
|
raw = json.dumps(
|
||||||
|
{
|
||||||
|
"response": "Ich suche LKWs.",
|
||||||
|
"actions": [{"type": "search_vehicles", "params": {"type": "lkw"}}],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
result = _parse_ai_response(raw)
|
||||||
|
assert result["response"] == "Ich suche LKWs."
|
||||||
|
assert len(result["actions"]) == 1
|
||||||
|
assert result["actions"][0]["type"] == "search_vehicles"
|
||||||
|
|
||||||
|
def test_parse_ai_response_with_code_fence(self):
|
||||||
|
"""_parse_ai_response handles markdown code fences."""
|
||||||
|
from app.services.copilot_service import _parse_ai_response
|
||||||
|
|
||||||
|
raw = (
|
||||||
|
"```json\n"
|
||||||
|
+ json.dumps(
|
||||||
|
{
|
||||||
|
"response": "Test",
|
||||||
|
"actions": [],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
+ "\n```"
|
||||||
|
)
|
||||||
|
result = _parse_ai_response(raw)
|
||||||
|
assert result["response"] == "Test"
|
||||||
|
assert result["actions"] == []
|
||||||
|
|
||||||
|
def test_parse_ai_response_fallback_to_raw_text(self):
|
||||||
|
"""_parse_ai_response falls back to raw text when no JSON found."""
|
||||||
|
from app.services.copilot_service import _parse_ai_response
|
||||||
|
|
||||||
|
raw = "Das ist keine JSON-Antwort."
|
||||||
|
result = _parse_ai_response(raw)
|
||||||
|
assert result["response"] == raw
|
||||||
|
assert result["actions"] == []
|
||||||
|
|
||||||
|
def test_parse_ai_response_invalid_actions_filtered(self):
|
||||||
|
"""_parse_ai_response filters out invalid action structures."""
|
||||||
|
from app.services.copilot_service import _parse_ai_response
|
||||||
|
|
||||||
|
raw = json.dumps(
|
||||||
|
{
|
||||||
|
"response": "Test",
|
||||||
|
"actions": [
|
||||||
|
{"type": "search_vehicles", "params": {}},
|
||||||
|
{"invalid": "no type"},
|
||||||
|
"not a dict",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
result = _parse_ai_response(raw)
|
||||||
|
assert len(result["actions"]) == 1
|
||||||
|
assert result["actions"][0]["type"] == "search_vehicles"
|
||||||
|
|
||||||
|
def test_system_prompt_contains_erp_context(self):
|
||||||
|
"""System prompt includes ERP context and available actions."""
|
||||||
|
from app.utils.copilot_prompt import build_system_prompt
|
||||||
|
|
||||||
|
prompt = build_system_prompt()
|
||||||
|
assert "ERP" in prompt
|
||||||
|
assert "Fahrzeugtypen" in prompt
|
||||||
|
assert "Kontakttypen" in prompt
|
||||||
|
assert "Verkaufsworkflow" in prompt
|
||||||
|
assert "search_vehicles" in prompt
|
||||||
|
assert "search_contacts" in prompt
|
||||||
|
assert "get_sale_overview" in prompt
|
||||||
|
assert "create_vehicle" in prompt
|
||||||
|
assert "create_contact" in prompt
|
||||||
|
assert "actions" in prompt
|
||||||
|
assert "response" in prompt
|
||||||
|
|
||||||
|
def test_action_registry_has_all_actions(self):
|
||||||
|
"""Action registry contains all 5 defined actions."""
|
||||||
|
from app.utils.copilot_actions import ACTION_REGISTRY
|
||||||
|
|
||||||
|
assert len(ACTION_REGISTRY) == 5
|
||||||
|
assert "search_vehicles" in ACTION_REGISTRY
|
||||||
|
assert "search_contacts" in ACTION_REGISTRY
|
||||||
|
assert "get_sale_overview" in ACTION_REGISTRY
|
||||||
|
assert "create_vehicle" in ACTION_REGISTRY
|
||||||
|
assert "create_contact" in ACTION_REGISTRY
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_execute_action_unknown_raises_value_error(self, db_session):
|
||||||
|
"""execute_action raises ValueError for unknown action type."""
|
||||||
|
from app.utils.copilot_actions import execute_action
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="Unknown action type"):
|
||||||
|
await execute_action(db_session, "nonexistent_action", {})
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_or_create_session_creates_new(self, db_session, admin_user):
|
||||||
|
"""_get_or_create_session creates a new session when no session_id given."""
|
||||||
|
from app.services.copilot_service import _get_or_create_session
|
||||||
|
|
||||||
|
session = await _get_or_create_session(
|
||||||
|
db_session, admin_user.id, first_message="Test message"
|
||||||
|
)
|
||||||
|
assert session.title == "Test message"
|
||||||
|
assert session.user_id == admin_user.id
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_or_create_session_returns_existing(self, db_session, admin_user):
|
||||||
|
"""_get_or_create_session returns existing session when valid session_id given."""
|
||||||
|
from app.services.copilot_service import _get_or_create_session
|
||||||
|
|
||||||
|
session1 = await _get_or_create_session(
|
||||||
|
db_session, admin_user.id, first_message="First"
|
||||||
|
)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
session2 = await _get_or_create_session(
|
||||||
|
db_session, admin_user.id, session_id=str(session1.id)
|
||||||
|
)
|
||||||
|
assert session2.id == session1.id
|
||||||
@@ -0,0 +1,395 @@
|
|||||||
|
"""Additional tests to improve coverage for copilot_service functions."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.models.copilot import CopilotChat, CopilotRole, CopilotSession
|
||||||
|
from app.services import copilot_service
|
||||||
|
|
||||||
|
|
||||||
|
class TestCopilotServiceCoverage:
|
||||||
|
"""Direct service-level tests for coverage improvement."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_chat_calls_openrouter_and_persists(self, db_session, admin_user):
|
||||||
|
"""chat() calls _call_openrouter_chat and persists both messages."""
|
||||||
|
mock_content = json.dumps(
|
||||||
|
{
|
||||||
|
"response": "Ich suche LKWs.",
|
||||||
|
"actions": [{"type": "search_vehicles", "params": {"type": "lkw"}}],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
with patch(
|
||||||
|
"app.services.copilot_service._call_openrouter_chat",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=mock_content,
|
||||||
|
) as mock_chat:
|
||||||
|
result = await copilot_service.chat(db_session, admin_user.id, "Zeige LKWs")
|
||||||
|
|
||||||
|
assert mock_chat.call_count == 1
|
||||||
|
assert result["response"] == "Ich suche LKWs."
|
||||||
|
assert len(result["actions"]) == 1
|
||||||
|
assert result["session_id"] is not None
|
||||||
|
assert result["message_id"] is not None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_chat_with_existing_session(self, db_session, admin_user):
|
||||||
|
"""chat() with session_id reuses existing session."""
|
||||||
|
session = CopilotSession(user_id=admin_user.id, title="Test")
|
||||||
|
db_session.add(session)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
mock_content = json.dumps({"response": "OK", "actions": []})
|
||||||
|
with patch(
|
||||||
|
"app.services.copilot_service._call_openrouter_chat",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=mock_content,
|
||||||
|
):
|
||||||
|
result = await copilot_service.chat(
|
||||||
|
db_session, admin_user.id, "Follow up", session_id=str(session.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["session_id"] == str(session.id)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_chat_with_invalid_session_id_creates_new(
|
||||||
|
self, db_session, admin_user
|
||||||
|
):
|
||||||
|
"""chat() with invalid session_id creates a new session."""
|
||||||
|
mock_content = json.dumps({"response": "OK", "actions": []})
|
||||||
|
with patch(
|
||||||
|
"app.services.copilot_service._call_openrouter_chat",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=mock_content,
|
||||||
|
):
|
||||||
|
result = await copilot_service.chat(
|
||||||
|
db_session, admin_user.id, "Test", session_id="invalid-uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["session_id"] != "invalid-uuid"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_chat_with_raw_text_response(self, db_session, admin_user):
|
||||||
|
"""chat() handles non-JSON AI response gracefully."""
|
||||||
|
with patch(
|
||||||
|
"app.services.copilot_service._call_openrouter_chat",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value="Das ist kein JSON.",
|
||||||
|
):
|
||||||
|
result = await copilot_service.chat(db_session, admin_user.id, "Hallo")
|
||||||
|
|
||||||
|
assert result["response"] == "Das ist kein JSON."
|
||||||
|
assert result["actions"] == []
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_chat_with_code_fence_response(self, db_session, admin_user):
|
||||||
|
"""chat() handles markdown code-fenced JSON response."""
|
||||||
|
content = (
|
||||||
|
"```json\n"
|
||||||
|
+ json.dumps(
|
||||||
|
{
|
||||||
|
"response": "Test",
|
||||||
|
"actions": [
|
||||||
|
{"type": "search_contacts", "params": {"search": "Mueller"}}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
+ "\n```"
|
||||||
|
)
|
||||||
|
with patch(
|
||||||
|
"app.services.copilot_service._call_openrouter_chat",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=content,
|
||||||
|
):
|
||||||
|
result = await copilot_service.chat(
|
||||||
|
db_session, admin_user.id, "Suche Mueller"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["response"] == "Test"
|
||||||
|
assert len(result["actions"]) == 1
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_history_returns_user_messages(self, db_session, admin_user):
|
||||||
|
"""get_history() returns only messages for the given user."""
|
||||||
|
session = CopilotSession(user_id=admin_user.id, title="Test")
|
||||||
|
db_session.add(session)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
for i in range(3):
|
||||||
|
msg = CopilotChat(
|
||||||
|
session_id=session.id,
|
||||||
|
user_id=admin_user.id,
|
||||||
|
role=CopilotRole.user if i % 2 == 0 else CopilotRole.assistant,
|
||||||
|
content=f"Message {i}",
|
||||||
|
)
|
||||||
|
db_session.add(msg)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
messages, total = await copilot_service.get_history(
|
||||||
|
db_session, admin_user.id, page=1, page_size=20
|
||||||
|
)
|
||||||
|
|
||||||
|
assert total == 3
|
||||||
|
assert len(messages) == 3
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_history_filtered_by_session(self, db_session, admin_user):
|
||||||
|
"""get_history() filters by session_id."""
|
||||||
|
session1 = CopilotSession(user_id=admin_user.id, title="S1")
|
||||||
|
session2 = CopilotSession(user_id=admin_user.id, title="S2")
|
||||||
|
db_session.add_all([session1, session2])
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
for session in [session1, session2]:
|
||||||
|
for i in range(2):
|
||||||
|
msg = CopilotChat(
|
||||||
|
session_id=session.id,
|
||||||
|
user_id=admin_user.id,
|
||||||
|
role=CopilotRole.user,
|
||||||
|
content=f"Msg {i}",
|
||||||
|
)
|
||||||
|
db_session.add(msg)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
messages, total = await copilot_service.get_history(
|
||||||
|
db_session, admin_user.id, page=1, page_size=20, session_id=str(session1.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert total == 2
|
||||||
|
for msg in messages:
|
||||||
|
assert msg.session_id == session1.id
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_history_with_invalid_session_id_ignores_filter(
|
||||||
|
self, db_session, admin_user
|
||||||
|
):
|
||||||
|
"""get_history() ignores invalid session_id and returns all."""
|
||||||
|
session = CopilotSession(user_id=admin_user.id, title="Test")
|
||||||
|
db_session.add(session)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
msg = CopilotChat(
|
||||||
|
session_id=session.id,
|
||||||
|
user_id=admin_user.id,
|
||||||
|
role=CopilotRole.user,
|
||||||
|
content="Test",
|
||||||
|
)
|
||||||
|
db_session.add(msg)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
messages, total = await copilot_service.get_history(
|
||||||
|
db_session, admin_user.id, page=1, page_size=20, session_id="invalid"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert total == 1
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_history_pagination(self, db_session, admin_user):
|
||||||
|
"""get_history() respects pagination parameters."""
|
||||||
|
session = CopilotSession(user_id=admin_user.id, title="Test")
|
||||||
|
db_session.add(session)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
for i in range(5):
|
||||||
|
msg = CopilotChat(
|
||||||
|
session_id=session.id,
|
||||||
|
user_id=admin_user.id,
|
||||||
|
role=CopilotRole.user,
|
||||||
|
content=f"Message {i}",
|
||||||
|
)
|
||||||
|
db_session.add(msg)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
messages, total = await copilot_service.get_history(
|
||||||
|
db_session, admin_user.id, page=1, page_size=2
|
||||||
|
)
|
||||||
|
assert total == 5
|
||||||
|
assert len(messages) == 2
|
||||||
|
|
||||||
|
messages2, _ = await copilot_service.get_history(
|
||||||
|
db_session, admin_user.id, page=2, page_size=2
|
||||||
|
)
|
||||||
|
assert len(messages2) == 2
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_sessions_returns_user_sessions(self, db_session, admin_user):
|
||||||
|
"""get_sessions() returns sessions for the given user."""
|
||||||
|
for i in range(3):
|
||||||
|
session = CopilotSession(user_id=admin_user.id, title=f"Session {i}")
|
||||||
|
db_session.add(session)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
sessions, total = await copilot_service.get_sessions(
|
||||||
|
db_session, admin_user.id, page=1, page_size=20
|
||||||
|
)
|
||||||
|
|
||||||
|
assert total == 3
|
||||||
|
assert len(sessions) == 3
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_sessions_pagination(self, db_session, admin_user):
|
||||||
|
"""get_sessions() respects pagination."""
|
||||||
|
for i in range(5):
|
||||||
|
session = CopilotSession(user_id=admin_user.id, title=f"S{i}")
|
||||||
|
db_session.add(session)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
sessions, total = await copilot_service.get_sessions(
|
||||||
|
db_session, admin_user.id, page=1, page_size=2
|
||||||
|
)
|
||||||
|
assert total == 5
|
||||||
|
assert len(sessions) == 2
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_execute_confirmed_action_success(self, db_session, admin_user):
|
||||||
|
"""execute_confirmed_action returns success result."""
|
||||||
|
from app.models.vehicle import Vehicle
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
vehicle = Vehicle(
|
||||||
|
make="Test",
|
||||||
|
model="Model",
|
||||||
|
fin="WDB9066351L123456",
|
||||||
|
price=Decimal("10000"),
|
||||||
|
vehicle_type="lkw",
|
||||||
|
)
|
||||||
|
db_session.add(vehicle)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
result = await copilot_service.execute_confirmed_action(
|
||||||
|
db_session, "search_vehicles", {"type": "lkw"}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
assert result["action"] == "search_vehicles"
|
||||||
|
assert "items" in result["result"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_execute_confirmed_action_unknown_raises(self, db_session):
|
||||||
|
"""execute_confirmed_action raises ValueError for unknown action."""
|
||||||
|
with pytest.raises(ValueError, match="Unknown action type"):
|
||||||
|
await copilot_service.execute_confirmed_action(
|
||||||
|
db_session, "nonexistent", {}
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_execute_confirmed_action_create_vehicle_missing_fields(
|
||||||
|
self, db_session
|
||||||
|
):
|
||||||
|
"""execute_confirmed_action raises ValueError for missing required fields."""
|
||||||
|
with pytest.raises(ValueError, match="Missing required fields"):
|
||||||
|
await copilot_service.execute_confirmed_action(
|
||||||
|
db_session, "create_vehicle", {"make": "Test"}
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_transcribe_audio_no_api_key_returns_stub(self):
|
||||||
|
"""transcribe_audio returns stub message when no API key configured."""
|
||||||
|
with patch("app.services.copilot_service.settings") as mock_settings:
|
||||||
|
mock_settings.OPENROUTER_API_KEY = ""
|
||||||
|
result = await copilot_service.transcribe_audio(b"fake-audio")
|
||||||
|
|
||||||
|
assert "nicht verfuegbar" in result or "OPENROUTER_API_KEY" in result
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_transcribe_audio_with_api_key_calls_openrouter(self):
|
||||||
|
"""transcribe_audio calls OpenRouter when API key is set."""
|
||||||
|
with (
|
||||||
|
patch("app.services.copilot_service.settings") as mock_settings,
|
||||||
|
patch(
|
||||||
|
"app.services.copilot_service._call_openrouter_chat",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=" Transkribierter Text ",
|
||||||
|
) as mock_chat,
|
||||||
|
):
|
||||||
|
mock_settings.OPENROUTER_API_KEY = "test-key"
|
||||||
|
result = await copilot_service.transcribe_audio(b"fake-audio", "audio/webm")
|
||||||
|
|
||||||
|
assert result == "Transkribierter Text"
|
||||||
|
assert mock_chat.call_count == 1
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_transcribe_audio_api_error_returns_error_message(self):
|
||||||
|
"""transcribe_audio returns error message on API failure."""
|
||||||
|
with (
|
||||||
|
patch("app.services.copilot_service.settings") as mock_settings,
|
||||||
|
patch(
|
||||||
|
"app.services.copilot_service._call_openrouter_chat",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
side_effect=Exception("API error"),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
mock_settings.OPENROUTER_API_KEY = "test-key"
|
||||||
|
result = await copilot_service.transcribe_audio(b"fake-audio")
|
||||||
|
|
||||||
|
assert "Transkription fehlgeschlagen" in result
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_voice_chat_full_flow(self, db_session, admin_user):
|
||||||
|
"""voice_chat transcribes and then chats."""
|
||||||
|
import base64
|
||||||
|
|
||||||
|
audio_b64 = base64.b64encode(b"fake-audio").decode("utf-8")
|
||||||
|
mock_content = json.dumps({"response": "Antwort", "actions": []})
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"app.services.copilot_service.transcribe_audio",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value="Transkription",
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"app.services.copilot_service._call_openrouter_chat",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=mock_content,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
result = await copilot_service.voice_chat(
|
||||||
|
db_session, admin_user.id, audio_b64
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["transcription"] == "Transkription"
|
||||||
|
assert result["response"] == "Antwort"
|
||||||
|
assert result["actions"] == []
|
||||||
|
assert "session_id" in result
|
||||||
|
assert "message_id" in result
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_call_openrouter_chat_no_api_key_raises(self):
|
||||||
|
"""_call_openrouter_chat raises ValueError when no API key."""
|
||||||
|
with patch("app.services.copilot_service.settings") as mock_settings:
|
||||||
|
mock_settings.OPENROUTER_API_KEY = ""
|
||||||
|
with pytest.raises(
|
||||||
|
ValueError, match="OPENROUTER_API_KEY is not configured"
|
||||||
|
):
|
||||||
|
await copilot_service._call_openrouter_chat([])
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_call_openrouter_chat_success(self):
|
||||||
|
"""_call_openrouter_chat calls httpx and returns content."""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.json.return_value = {
|
||||||
|
"choices": [{"message": {"content": "Test response"}}]
|
||||||
|
}
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("app.services.copilot_service.settings") as mock_settings,
|
||||||
|
patch("app.services.copilot_service.httpx.AsyncClient") as mock_client_cls,
|
||||||
|
):
|
||||||
|
mock_settings.OPENROUTER_API_KEY = "test-key"
|
||||||
|
mock_settings.OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
|
||||||
|
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.post = AsyncMock(return_value=mock_response)
|
||||||
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||||
|
mock_client.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
mock_client_cls.return_value = mock_client
|
||||||
|
|
||||||
|
result = await copilot_service._call_openrouter_chat(
|
||||||
|
[{"role": "user", "content": "test"}]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == "Test response"
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
"""Tests for DATEV export module: export creation, CSV format, date range validation."""
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import uuid
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
import pytest_asyncio
|
||||||
|
from httpx import AsyncClient
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.sale import Sale
|
||||||
|
from app.models.vehicle import Vehicle
|
||||||
|
from app.models.contact import Contact
|
||||||
|
from app.utils.datev import generate_datev_csv, validate_datev_csv, DATEV_HEADERS
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def test_vehicle_for_datev(db_session: AsyncSession) -> Vehicle:
|
||||||
|
"""Create a test vehicle for DATEV tests."""
|
||||||
|
vehicle = Vehicle(
|
||||||
|
make="MAN",
|
||||||
|
model="TGX",
|
||||||
|
fin="WMA12345678901234",
|
||||||
|
year=2021,
|
||||||
|
vehicle_type="lkw",
|
||||||
|
price=Decimal("55000.00"),
|
||||||
|
availability="available",
|
||||||
|
condition="used",
|
||||||
|
)
|
||||||
|
db_session.add(vehicle)
|
||||||
|
await db_session.commit()
|
||||||
|
await db_session.refresh(vehicle)
|
||||||
|
return vehicle
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def test_buyer_for_datev(db_session: AsyncSession) -> Contact:
|
||||||
|
"""Create a test buyer contact for DATEV tests."""
|
||||||
|
contact = Contact(
|
||||||
|
company_name="DATEV Test Buyer GmbH",
|
||||||
|
role="kaeufer",
|
||||||
|
address_country="DE",
|
||||||
|
vat_id="DE111222333",
|
||||||
|
address_street="Testweg 3",
|
||||||
|
address_zip="67890",
|
||||||
|
address_city="Hamburg",
|
||||||
|
)
|
||||||
|
db_session.add(contact)
|
||||||
|
await db_session.commit()
|
||||||
|
await db_session.refresh(contact)
|
||||||
|
return contact
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def test_completed_sales(
|
||||||
|
db_session: AsyncSession, test_vehicle_for_datev, test_buyer_for_datev
|
||||||
|
) -> list[Sale]:
|
||||||
|
"""Create test completed sales within a date range."""
|
||||||
|
sales = []
|
||||||
|
for i, (day, price) in enumerate(
|
||||||
|
[(5, Decimal("30000.00")), (10, Decimal("25000.00")), (15, Decimal("40000.00"))]
|
||||||
|
):
|
||||||
|
sale = Sale(
|
||||||
|
vehicle_id=test_vehicle_for_datev.id,
|
||||||
|
buyer_contact_id=test_buyer_for_datev.id,
|
||||||
|
sale_price=price,
|
||||||
|
sale_date=date(2025, 1, day),
|
||||||
|
status="completed",
|
||||||
|
is_gwg=False,
|
||||||
|
)
|
||||||
|
db_session.add(sale)
|
||||||
|
sales.append(sale)
|
||||||
|
await db_session.commit()
|
||||||
|
for s in sales:
|
||||||
|
await db_session.refresh(s)
|
||||||
|
return sales
|
||||||
|
|
||||||
|
|
||||||
|
class TestDATEVExportAPI:
|
||||||
|
"""Test DATEV export API endpoints."""
|
||||||
|
|
||||||
|
async def test_create_export(self, admin_client: AsyncClient, test_completed_sales):
|
||||||
|
"""POST /datev/export with valid date range returns 201."""
|
||||||
|
response = await admin_client.post(
|
||||||
|
"/api/v1/datev/export",
|
||||||
|
json={
|
||||||
|
"start_date": "2025-01-01",
|
||||||
|
"end_date": "2025-01-31",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
data = response.json()
|
||||||
|
assert data["start_date"] == "2025-01-01"
|
||||||
|
assert data["end_date"] == "2025-01-31"
|
||||||
|
assert data["file_path"] is not None
|
||||||
|
assert float(data["total_amount"]) == 95000.00
|
||||||
|
|
||||||
|
async def test_create_export_invalid_date_range(self, admin_client: AsyncClient):
|
||||||
|
"""POST /datev/export with start > end returns 422."""
|
||||||
|
response = await admin_client.post(
|
||||||
|
"/api/v1/datev/export",
|
||||||
|
json={
|
||||||
|
"start_date": "2025-12-31",
|
||||||
|
"end_date": "2025-01-01",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
async def test_list_exports(self, admin_client: AsyncClient, test_completed_sales):
|
||||||
|
"""GET /datev/exports returns list of exports."""
|
||||||
|
# First create an export
|
||||||
|
await admin_client.post(
|
||||||
|
"/api/v1/datev/export",
|
||||||
|
json={
|
||||||
|
"start_date": "2025-01-01",
|
||||||
|
"end_date": "2025-01-31",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await admin_client.get("/api/v1/datev/exports")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["total"] >= 1
|
||||||
|
assert len(data["items"]) >= 1
|
||||||
|
assert "start_date" in data["items"][0]
|
||||||
|
assert "end_date" in data["items"][0]
|
||||||
|
|
||||||
|
async def test_download_export_csv(
|
||||||
|
self, admin_client: AsyncClient, test_completed_sales
|
||||||
|
):
|
||||||
|
"""GET /datev/exports/:id/download returns CSV content."""
|
||||||
|
# Create export
|
||||||
|
create_response = await admin_client.post(
|
||||||
|
"/api/v1/datev/export",
|
||||||
|
json={
|
||||||
|
"start_date": "2025-01-01",
|
||||||
|
"end_date": "2025-01-31",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert create_response.status_code == 201
|
||||||
|
export_id = create_response.json()["id"]
|
||||||
|
|
||||||
|
# Download
|
||||||
|
response = await admin_client.get(f"/api/v1/datev/exports/{export_id}/download")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.headers["content-type"] == "text/csv; charset=utf-8"
|
||||||
|
|
||||||
|
# Parse CSV and verify headers
|
||||||
|
csv_content = response.text
|
||||||
|
reader = csv.reader(io.StringIO(csv_content), delimiter=";")
|
||||||
|
header = next(reader)
|
||||||
|
assert header == DATEV_HEADERS
|
||||||
|
|
||||||
|
# Verify at least one data row
|
||||||
|
rows = list(reader)
|
||||||
|
assert len(rows) >= 1
|
||||||
|
for row in rows:
|
||||||
|
assert len(row) == 6
|
||||||
|
|
||||||
|
async def test_download_export_not_found(self, admin_client: AsyncClient):
|
||||||
|
"""GET /datev/exports/:nonexistent/download returns 404."""
|
||||||
|
response = await admin_client.get(
|
||||||
|
f"/api/v1/datev/exports/{uuid.uuid4()}/download"
|
||||||
|
)
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
class TestDATEVCSVFormat:
|
||||||
|
"""Test DATEV CSV format helper functions."""
|
||||||
|
|
||||||
|
def test_datev_csv_headers(self):
|
||||||
|
"""DATEV CSV has correct headers."""
|
||||||
|
assert DATEV_HEADERS == [
|
||||||
|
"Datum",
|
||||||
|
"Konto",
|
||||||
|
"Gegenkonto",
|
||||||
|
"Betrag",
|
||||||
|
"Belegfeld",
|
||||||
|
"Buchungstext",
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_generate_datev_csv_empty(self):
|
||||||
|
"""Generate DATEV CSV with no sales returns only headers."""
|
||||||
|
csv_content = generate_datev_csv([])
|
||||||
|
assert validate_datev_csv(csv_content)
|
||||||
|
|
||||||
|
reader = csv.reader(io.StringIO(csv_content), delimiter=";")
|
||||||
|
header = next(reader)
|
||||||
|
assert header == DATEV_HEADERS
|
||||||
|
# No data rows
|
||||||
|
rows = list(reader)
|
||||||
|
assert len(rows) == 0
|
||||||
|
|
||||||
|
def test_generate_datev_csv_with_sales(self, test_completed_sales):
|
||||||
|
"""Generate DATEV CSV with sales produces correct rows."""
|
||||||
|
|
||||||
|
# test_completed_sales is a fixture but we need to call it differently for sync test
|
||||||
|
# Instead, create mock objects
|
||||||
|
class MockVehicle:
|
||||||
|
make = "MAN"
|
||||||
|
model = "TGX"
|
||||||
|
fin = "WMA12345678901234"
|
||||||
|
|
||||||
|
class MockSale:
|
||||||
|
def __init__(self, sale_id, sale_date, sale_price):
|
||||||
|
self.id = sale_id
|
||||||
|
self.sale_date = sale_date
|
||||||
|
self.sale_price = sale_price
|
||||||
|
self.vehicle = MockVehicle()
|
||||||
|
|
||||||
|
sales = [
|
||||||
|
MockSale(uuid.uuid4(), date(2025, 1, 5), Decimal("30000.00")),
|
||||||
|
MockSale(uuid.uuid4(), date(2025, 1, 10), Decimal("25000.00")),
|
||||||
|
]
|
||||||
|
|
||||||
|
csv_content = generate_datev_csv(sales)
|
||||||
|
assert validate_datev_csv(csv_content)
|
||||||
|
|
||||||
|
reader = csv.reader(io.StringIO(csv_content), delimiter=";")
|
||||||
|
header = next(reader)
|
||||||
|
assert header == DATEV_HEADERS
|
||||||
|
|
||||||
|
rows = list(reader)
|
||||||
|
assert len(rows) == 2
|
||||||
|
|
||||||
|
# Check first row format
|
||||||
|
row1 = rows[0]
|
||||||
|
assert row1[0] == "05.01.2025" # Datum in DD.MM.YYYY
|
||||||
|
assert row1[1] == "1200" # Konto
|
||||||
|
assert row1[2] == "8400" # Gegenkonto
|
||||||
|
assert "," in row1[3] # Betrag with comma separator
|
||||||
|
assert row1[4] # Belegfeld (short UUID)
|
||||||
|
assert "MAN" in row1[5] # Buchungstext contains vehicle make
|
||||||
|
|
||||||
|
def test_validate_datev_csv_invalid_headers(self):
|
||||||
|
"""Validate DATEV CSV with wrong headers returns False."""
|
||||||
|
csv_content = "Wrong;Headers;Here\nval1;val2;val3"
|
||||||
|
assert not validate_datev_csv(csv_content)
|
||||||
|
|
||||||
|
def test_validate_datev_csv_empty(self):
|
||||||
|
"""Validate empty CSV returns False."""
|
||||||
|
assert not validate_datev_csv("")
|
||||||
|
assert not validate_datev_csv(" ")
|
||||||
|
|
||||||
|
def test_validate_datev_csv_valid(self):
|
||||||
|
"""Validate correct DATEV CSV returns True."""
|
||||||
|
csv_content = "Datum;Konto;Gegenkonto;Betrag;Belegfeld;Buchungstext\n05.01.2025;1200;8400;30000,00;ABC12345;MAN TGX"
|
||||||
|
assert validate_datev_csv(csv_content)
|
||||||
|
|
||||||
|
def test_datev_csv_amount_format(self):
|
||||||
|
"""DATEV CSV amount uses comma as decimal separator."""
|
||||||
|
|
||||||
|
class MockVehicle:
|
||||||
|
make = "VW"
|
||||||
|
model = "Crafter"
|
||||||
|
fin = "WVW12345678901234"
|
||||||
|
|
||||||
|
class MockSale:
|
||||||
|
def __init__(self):
|
||||||
|
self.id = uuid.uuid4()
|
||||||
|
self.sale_date = date(2025, 3, 15)
|
||||||
|
self.sale_price = Decimal("12345.67")
|
||||||
|
self.vehicle = MockVehicle()
|
||||||
|
|
||||||
|
csv_content = generate_datev_csv([MockSale()])
|
||||||
|
reader = csv.reader(io.StringIO(csv_content), delimiter=";")
|
||||||
|
next(reader) # skip header
|
||||||
|
row = next(reader)
|
||||||
|
assert row[3] == "12345,67" # Comma as decimal separator
|
||||||
|
|
||||||
|
|
||||||
|
class TestDATEVExportService:
|
||||||
|
"""Test DATEV export service directly."""
|
||||||
|
|
||||||
|
async def test_create_export_no_sales_in_range(self, db_session: AsyncSession):
|
||||||
|
"""Creating export with no sales in range produces empty CSV with 0 total."""
|
||||||
|
from app.services import datev_service
|
||||||
|
|
||||||
|
export = await datev_service.create_export(
|
||||||
|
db_session,
|
||||||
|
start_date=date(2025, 6, 1),
|
||||||
|
end_date=date(2025, 6, 30),
|
||||||
|
)
|
||||||
|
assert export.total_amount == Decimal("0")
|
||||||
|
assert export.file_path is not None
|
||||||
|
|
||||||
|
async def test_list_exports_pagination(self, db_session: AsyncSession):
|
||||||
|
"""List exports with pagination."""
|
||||||
|
from app.services import datev_service
|
||||||
|
|
||||||
|
# Create a few exports
|
||||||
|
for _ in range(3):
|
||||||
|
await datev_service.create_export(
|
||||||
|
db_session,
|
||||||
|
start_date=date(2025, 1, 1),
|
||||||
|
end_date=date(2025, 1, 31),
|
||||||
|
)
|
||||||
|
|
||||||
|
exports, total = await datev_service.list_exports(
|
||||||
|
db_session, page=1, page_size=2
|
||||||
|
)
|
||||||
|
assert total >= 3
|
||||||
|
assert len(exports) <= 2
|
||||||
@@ -0,0 +1,744 @@
|
|||||||
|
"""Tests for file upload, list, download, delete, MIME validation, size limit, and thumbnail generation."""
|
||||||
|
|
||||||
|
import io
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.database import get_db
|
||||||
|
from app.main import app
|
||||||
|
from app.models.vehicle import Vehicle
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Test fixtures ----
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def sample_vehicle_data():
|
||||||
|
"""Valid vehicle data for creation."""
|
||||||
|
return {
|
||||||
|
"make": "Mercedes-Benz",
|
||||||
|
"model": "Actros",
|
||||||
|
"fin": "WDB9066351L123456",
|
||||||
|
"year": 2020,
|
||||||
|
"first_registration": "2020-03-15",
|
||||||
|
"power_kw": 300,
|
||||||
|
"fuel_type": "Diesel",
|
||||||
|
"transmission": "Manual",
|
||||||
|
"color": "White",
|
||||||
|
"condition": "used",
|
||||||
|
"location": "Berlin",
|
||||||
|
"availability": "available",
|
||||||
|
"price": 45000.00,
|
||||||
|
"vehicle_type": "lkw",
|
||||||
|
"lkw_type": "sattelzugmaschine",
|
||||||
|
"mileage_km": 120000,
|
||||||
|
"description": "Well maintained truck",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def created_vehicle(admin_client, sample_vehicle_data):
|
||||||
|
"""Create a vehicle via API and return the response."""
|
||||||
|
response = await admin_client.post("/api/v1/vehicles/", json=sample_vehicle_data)
|
||||||
|
assert response.status_code == 201, response.text
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
|
def _make_image_bytes(format="JPEG", size=(800, 600), color="blue") -> bytes:
|
||||||
|
"""Create a valid image in memory."""
|
||||||
|
img = Image.new("RGB", size, color=color)
|
||||||
|
buf = io.BytesIO()
|
||||||
|
img.save(buf, format=format)
|
||||||
|
buf.seek(0)
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def _make_png_bytes(size=(800, 600), color="red") -> bytes:
|
||||||
|
"""Create a valid PNG image in memory."""
|
||||||
|
img = Image.new("RGB", size, color=color)
|
||||||
|
buf = io.BytesIO()
|
||||||
|
img.save(buf, format="PNG")
|
||||||
|
buf.seek(0)
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def _make_webp_bytes(size=(800, 600), color="green") -> bytes:
|
||||||
|
"""Create a valid WebP image in memory."""
|
||||||
|
img = Image.new("RGB", size, color=color)
|
||||||
|
buf = io.BytesIO()
|
||||||
|
img.save(buf, format="WEBP")
|
||||||
|
buf.seek(0)
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def _make_pdf_bytes() -> bytes:
|
||||||
|
"""Create a minimal valid PDF."""
|
||||||
|
return b"%PDF-1.4\n1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 612 792]>>endobj\nxref\n0 4\n0000000000 65535 f \n0000000009 00000 n \n0000000052 00000 n \n0000000101 00000 n \ntrailer<</Size 4/Root 1 0 R>>\nstartxref\n149\n%%EOF"
|
||||||
|
|
||||||
|
|
||||||
|
async def _create_test_vehicle(db_session) -> uuid.UUID:
|
||||||
|
"""Create a test vehicle in the DB and return its ID."""
|
||||||
|
vehicle = Vehicle(
|
||||||
|
make="Test",
|
||||||
|
model="Truck",
|
||||||
|
fin="WDB9066351L123456",
|
||||||
|
price=45000,
|
||||||
|
vehicle_type="lkw",
|
||||||
|
condition="used",
|
||||||
|
availability="available",
|
||||||
|
)
|
||||||
|
db_session.add(vehicle)
|
||||||
|
await db_session.flush()
|
||||||
|
return vehicle.id
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Tests: File Upload ----
|
||||||
|
|
||||||
|
|
||||||
|
class TestFileUpload:
|
||||||
|
"""POST /api/v1/vehicles/:id/files tests."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_upload_jpg_image_returns_201(self, admin_client, created_vehicle):
|
||||||
|
"""Upload a valid JPG image and verify 201 response."""
|
||||||
|
vehicle_id = created_vehicle["id"]
|
||||||
|
image_bytes = _make_image_bytes(format="JPEG")
|
||||||
|
files = {"file": ("test.jpg", image_bytes, "image/jpeg")}
|
||||||
|
response = await admin_client.post(
|
||||||
|
f"/api/v1/vehicles/{vehicle_id}/files",
|
||||||
|
files=files,
|
||||||
|
)
|
||||||
|
assert response.status_code == 201, response.text
|
||||||
|
data = response.json()
|
||||||
|
assert data["vehicle_id"] == vehicle_id
|
||||||
|
assert data["original_filename"] == "test.jpg"
|
||||||
|
assert data["mime_type"] == "image/jpeg"
|
||||||
|
assert data["file_size"] == len(image_bytes)
|
||||||
|
assert data["thumbnail_path"] is not None
|
||||||
|
assert data["id"] is not None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_upload_png_image_returns_201(self, admin_client, created_vehicle):
|
||||||
|
"""Upload a valid PNG image and verify 201 response."""
|
||||||
|
vehicle_id = created_vehicle["id"]
|
||||||
|
image_bytes = _make_png_bytes()
|
||||||
|
files = {"file": ("test.png", image_bytes, "image/png")}
|
||||||
|
response = await admin_client.post(
|
||||||
|
f"/api/v1/vehicles/{vehicle_id}/files",
|
||||||
|
files=files,
|
||||||
|
)
|
||||||
|
assert response.status_code == 201, response.text
|
||||||
|
data = response.json()
|
||||||
|
assert data["mime_type"] == "image/png"
|
||||||
|
assert data["thumbnail_path"] is not None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_upload_webp_image_returns_201(self, admin_client, created_vehicle):
|
||||||
|
"""Upload a valid WebP image and verify 201 response."""
|
||||||
|
vehicle_id = created_vehicle["id"]
|
||||||
|
image_bytes = _make_webp_bytes()
|
||||||
|
files = {"file": ("test.webp", image_bytes, "image/webp")}
|
||||||
|
response = await admin_client.post(
|
||||||
|
f"/api/v1/vehicles/{vehicle_id}/files",
|
||||||
|
files=files,
|
||||||
|
)
|
||||||
|
assert response.status_code == 201, response.text
|
||||||
|
data = response.json()
|
||||||
|
assert data["mime_type"] == "image/webp"
|
||||||
|
assert data["thumbnail_path"] is not None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_upload_pdf_returns_201(self, admin_client, created_vehicle):
|
||||||
|
"""Upload a valid PDF and verify 201 response."""
|
||||||
|
vehicle_id = created_vehicle["id"]
|
||||||
|
pdf_bytes = _make_pdf_bytes()
|
||||||
|
files = {"file": ("document.pdf", pdf_bytes, "application/pdf")}
|
||||||
|
response = await admin_client.post(
|
||||||
|
f"/api/v1/vehicles/{vehicle_id}/files",
|
||||||
|
files=files,
|
||||||
|
)
|
||||||
|
assert response.status_code == 201, response.text
|
||||||
|
data = response.json()
|
||||||
|
assert data["mime_type"] == "application/pdf"
|
||||||
|
assert data["thumbnail_path"] is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_upload_invalid_mime_type_returns_422(
|
||||||
|
self, admin_client, created_vehicle
|
||||||
|
):
|
||||||
|
"""Upload a file with an unsupported MIME type and verify 422."""
|
||||||
|
vehicle_id = created_vehicle["id"]
|
||||||
|
files = {"file": ("malware.exe", b"MZ\x90\x00", "application/x-msdownload")}
|
||||||
|
response = await admin_client.post(
|
||||||
|
f"/api/v1/vehicles/{vehicle_id}/files",
|
||||||
|
files=files,
|
||||||
|
)
|
||||||
|
assert response.status_code == 422, response.text
|
||||||
|
data = response.json()
|
||||||
|
assert data["detail"]["error"]["code"] == "INVALID_MIME_TYPE"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_upload_text_file_returns_422(self, admin_client, created_vehicle):
|
||||||
|
"""Upload a text file and verify 422."""
|
||||||
|
vehicle_id = created_vehicle["id"]
|
||||||
|
files = {"file": ("notes.txt", b"hello world", "text/plain")}
|
||||||
|
response = await admin_client.post(
|
||||||
|
f"/api/v1/vehicles/{vehicle_id}/files",
|
||||||
|
files=files,
|
||||||
|
)
|
||||||
|
assert response.status_code == 422, response.text
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_upload_oversized_file_returns_413(
|
||||||
|
self, admin_client, created_vehicle
|
||||||
|
):
|
||||||
|
"""Upload a file larger than 20MB and verify 413."""
|
||||||
|
vehicle_id = created_vehicle["id"]
|
||||||
|
# Create a 21MB file (21 * 1024 * 1024 bytes)
|
||||||
|
large_content = b"\x00" * (21 * 1024 * 1024)
|
||||||
|
files = {"file": ("large.jpg", large_content, "image/jpeg")}
|
||||||
|
response = await admin_client.post(
|
||||||
|
f"/api/v1/vehicles/{vehicle_id}/files",
|
||||||
|
files=files,
|
||||||
|
)
|
||||||
|
assert response.status_code == 413, response.text
|
||||||
|
data = response.json()
|
||||||
|
assert data["detail"]["error"]["code"] == "FILE_TOO_LARGE"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_upload_to_nonexistent_vehicle_returns_404(self, admin_client):
|
||||||
|
"""Upload to a non-existent vehicle and verify 404."""
|
||||||
|
fake_id = str(uuid.uuid4())
|
||||||
|
image_bytes = _make_image_bytes()
|
||||||
|
files = {"file": ("test.jpg", image_bytes, "image/jpeg")}
|
||||||
|
response = await admin_client.post(
|
||||||
|
f"/api/v1/vehicles/{fake_id}/files",
|
||||||
|
files=files,
|
||||||
|
)
|
||||||
|
assert response.status_code == 404, response.text
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_upload_without_auth_returns_401(
|
||||||
|
self, test_session_factory, created_vehicle
|
||||||
|
):
|
||||||
|
"""Upload without authentication and verify 401."""
|
||||||
|
vehicle_id = created_vehicle["id"]
|
||||||
|
image_bytes = _make_image_bytes()
|
||||||
|
files = {"file": ("test.jpg", image_bytes, "image/jpeg")}
|
||||||
|
|
||||||
|
# Create a fresh client without auth headers
|
||||||
|
async def _override_get_db():
|
||||||
|
async with test_session_factory() as session:
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
await session.commit()
|
||||||
|
except Exception:
|
||||||
|
await session.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
await session.close()
|
||||||
|
|
||||||
|
app.dependency_overrides[get_db] = _override_get_db
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(
|
||||||
|
transport=transport, base_url="http://test"
|
||||||
|
) as unauth_client:
|
||||||
|
response = await unauth_client.post(
|
||||||
|
f"/api/v1/vehicles/{vehicle_id}/files",
|
||||||
|
files=files,
|
||||||
|
)
|
||||||
|
assert response.status_code == 401, response.text
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Tests: File List ----
|
||||||
|
|
||||||
|
|
||||||
|
class TestFileList:
|
||||||
|
"""GET /api/v1/vehicles/:id/files tests."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_files_returns_200_with_pagination(
|
||||||
|
self, admin_client, created_vehicle
|
||||||
|
):
|
||||||
|
"""List files for a vehicle returns 200 with paginated response."""
|
||||||
|
vehicle_id = created_vehicle["id"]
|
||||||
|
# Upload a file first
|
||||||
|
image_bytes = _make_image_bytes()
|
||||||
|
files = {"file": ("test.jpg", image_bytes, "image/jpeg")}
|
||||||
|
await admin_client.post(f"/api/v1/vehicles/{vehicle_id}/files", files=files)
|
||||||
|
|
||||||
|
response = await admin_client.get(
|
||||||
|
f"/api/v1/vehicles/{vehicle_id}/files?page=1&page_size=20"
|
||||||
|
)
|
||||||
|
assert response.status_code == 200, response.text
|
||||||
|
data = response.json()
|
||||||
|
assert "items" in data
|
||||||
|
assert "total" in data
|
||||||
|
assert "page" in data
|
||||||
|
assert "page_size" in data
|
||||||
|
assert data["page"] == 1
|
||||||
|
assert data["page_size"] == 20
|
||||||
|
assert data["total"] >= 1
|
||||||
|
assert len(data["items"]) >= 1
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_files_empty_returns_200(self, admin_client, created_vehicle):
|
||||||
|
"""List files for a vehicle with no files returns 200 with empty list."""
|
||||||
|
vehicle_id = created_vehicle["id"]
|
||||||
|
response = await admin_client.get(f"/api/v1/vehicles/{vehicle_id}/files")
|
||||||
|
assert response.status_code == 200, response.text
|
||||||
|
data = response.json()
|
||||||
|
assert data["total"] == 0
|
||||||
|
assert data["items"] == []
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_files_for_nonexistent_vehicle_returns_404(self, admin_client):
|
||||||
|
"""List files for a non-existent vehicle returns 404."""
|
||||||
|
fake_id = str(uuid.uuid4())
|
||||||
|
response = await admin_client.get(f"/api/v1/vehicles/{fake_id}/files")
|
||||||
|
assert response.status_code == 404, response.text
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Tests: File Download ----
|
||||||
|
|
||||||
|
|
||||||
|
class TestFileDownload:
|
||||||
|
"""GET /api/v1/vehicles/:id/files/:fileId tests."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_download_file_returns_200(self, admin_client, created_vehicle):
|
||||||
|
"""Download a file and verify 200 with correct content."""
|
||||||
|
vehicle_id = created_vehicle["id"]
|
||||||
|
image_bytes = _make_image_bytes()
|
||||||
|
files = {"file": ("test.jpg", image_bytes, "image/jpeg")}
|
||||||
|
upload_resp = await admin_client.post(
|
||||||
|
f"/api/v1/vehicles/{vehicle_id}/files", files=files
|
||||||
|
)
|
||||||
|
assert upload_resp.status_code == 201
|
||||||
|
file_id = upload_resp.json()["id"]
|
||||||
|
|
||||||
|
response = await admin_client.get(
|
||||||
|
f"/api/v1/vehicles/{vehicle_id}/files/{file_id}"
|
||||||
|
)
|
||||||
|
assert response.status_code == 200, response.text
|
||||||
|
assert response.headers["content-type"].startswith("image/jpeg")
|
||||||
|
assert len(response.content) == len(image_bytes)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_download_nonexistent_file_returns_404(
|
||||||
|
self, admin_client, created_vehicle
|
||||||
|
):
|
||||||
|
"""Download a non-existent file returns 404."""
|
||||||
|
vehicle_id = created_vehicle["id"]
|
||||||
|
fake_file_id = str(uuid.uuid4())
|
||||||
|
response = await admin_client.get(
|
||||||
|
f"/api/v1/vehicles/{vehicle_id}/files/{fake_file_id}"
|
||||||
|
)
|
||||||
|
assert response.status_code == 404, response.text
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Tests: File Delete ----
|
||||||
|
|
||||||
|
|
||||||
|
class TestFileDelete:
|
||||||
|
"""DELETE /api/v1/vehicles/:id/files/:fileId tests."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_file_returns_200(self, admin_client, created_vehicle):
|
||||||
|
"""Delete a file and verify 200 response."""
|
||||||
|
vehicle_id = created_vehicle["id"]
|
||||||
|
image_bytes = _make_image_bytes()
|
||||||
|
files = {"file": ("test.jpg", image_bytes, "image/jpeg")}
|
||||||
|
upload_resp = await admin_client.post(
|
||||||
|
f"/api/v1/vehicles/{vehicle_id}/files", files=files
|
||||||
|
)
|
||||||
|
assert upload_resp.status_code == 201
|
||||||
|
file_id = upload_resp.json()["id"]
|
||||||
|
|
||||||
|
response = await admin_client.delete(
|
||||||
|
f"/api/v1/vehicles/{vehicle_id}/files/{file_id}"
|
||||||
|
)
|
||||||
|
assert response.status_code == 200, response.text
|
||||||
|
data = response.json()
|
||||||
|
assert data["message"] == "File deleted"
|
||||||
|
assert data["id"] == file_id
|
||||||
|
|
||||||
|
# Verify file is gone from list
|
||||||
|
list_resp = await admin_client.get(f"/api/v1/vehicles/{vehicle_id}/files")
|
||||||
|
assert list_resp.status_code == 200
|
||||||
|
assert list_resp.json()["total"] == 0
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_nonexistent_file_returns_404(
|
||||||
|
self, admin_client, created_vehicle
|
||||||
|
):
|
||||||
|
"""Delete a non-existent file returns 404."""
|
||||||
|
vehicle_id = created_vehicle["id"]
|
||||||
|
fake_file_id = str(uuid.uuid4())
|
||||||
|
response = await admin_client.delete(
|
||||||
|
f"/api/v1/vehicles/{vehicle_id}/files/{fake_file_id}"
|
||||||
|
)
|
||||||
|
assert response.status_code == 404, response.text
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Tests: MIME Type Validation ----
|
||||||
|
|
||||||
|
|
||||||
|
class TestMIMEValidation:
|
||||||
|
"""Unit tests for MIME type validation."""
|
||||||
|
|
||||||
|
def test_validate_jpeg_mime_type(self):
|
||||||
|
from app.services.file_service import validate_mime_type
|
||||||
|
|
||||||
|
assert validate_mime_type("image/jpeg", "photo.jpg") is True
|
||||||
|
assert validate_mime_type("image/jpeg", "photo.jpeg") is True
|
||||||
|
|
||||||
|
def test_validate_png_mime_type(self):
|
||||||
|
from app.services.file_service import validate_mime_type
|
||||||
|
|
||||||
|
assert validate_mime_type("image/png", "photo.png") is True
|
||||||
|
|
||||||
|
def test_validate_webp_mime_type(self):
|
||||||
|
from app.services.file_service import validate_mime_type
|
||||||
|
|
||||||
|
assert validate_mime_type("image/webp", "photo.webp") is True
|
||||||
|
|
||||||
|
def test_validate_pdf_mime_type(self):
|
||||||
|
from app.services.file_service import validate_mime_type
|
||||||
|
|
||||||
|
assert validate_mime_type("application/pdf", "doc.pdf") is True
|
||||||
|
|
||||||
|
def test_validate_doc_mime_type(self):
|
||||||
|
from app.services.file_service import validate_mime_type
|
||||||
|
|
||||||
|
assert validate_mime_type("application/msword", "doc.doc") is True
|
||||||
|
|
||||||
|
def test_validate_docx_mime_type(self):
|
||||||
|
from app.services.file_service import validate_mime_type
|
||||||
|
|
||||||
|
assert (
|
||||||
|
validate_mime_type(
|
||||||
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
"doc.docx",
|
||||||
|
)
|
||||||
|
is True
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_reject_exe_mime_type(self):
|
||||||
|
from app.services.file_service import validate_mime_type
|
||||||
|
|
||||||
|
assert validate_mime_type("application/x-msdownload", "malware.exe") is False
|
||||||
|
|
||||||
|
def test_reject_text_mime_type(self):
|
||||||
|
from app.services.file_service import validate_mime_type
|
||||||
|
|
||||||
|
assert validate_mime_type("text/plain", "notes.txt") is False
|
||||||
|
|
||||||
|
def test_reject_mismatched_extension(self):
|
||||||
|
"""MIME type image/jpeg with .png extension should fail."""
|
||||||
|
from app.services.file_service import validate_mime_type
|
||||||
|
|
||||||
|
assert validate_mime_type("image/jpeg", "photo.png") is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Tests: File Size Validation ----
|
||||||
|
|
||||||
|
|
||||||
|
class TestFileSizeValidation:
|
||||||
|
"""Unit tests for file size validation."""
|
||||||
|
|
||||||
|
def test_validate_small_file_size(self):
|
||||||
|
from app.services.file_service import validate_file_size
|
||||||
|
|
||||||
|
assert validate_file_size(1024, max_size_mb=20) is True
|
||||||
|
|
||||||
|
def test_validate_exact_20mb_file_size(self):
|
||||||
|
from app.services.file_service import validate_file_size
|
||||||
|
|
||||||
|
exact_20mb = 20 * 1024 * 1024
|
||||||
|
assert validate_file_size(exact_20mb, max_size_mb=20) is True
|
||||||
|
|
||||||
|
def test_reject_oversized_file(self):
|
||||||
|
from app.services.file_service import validate_file_size
|
||||||
|
|
||||||
|
over_20mb = 20 * 1024 * 1024 + 1
|
||||||
|
assert validate_file_size(over_20mb, max_size_mb=20) is False
|
||||||
|
|
||||||
|
def test_validate_zero_byte_file(self):
|
||||||
|
from app.services.file_service import validate_file_size
|
||||||
|
|
||||||
|
assert validate_file_size(0, max_size_mb=20) is True
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Tests: Thumbnail Generation ----
|
||||||
|
|
||||||
|
|
||||||
|
class TestThumbnailGeneration:
|
||||||
|
"""Tests for thumbnail generation utility."""
|
||||||
|
|
||||||
|
def test_generate_thumbnail_for_jpeg(self, tmp_path):
|
||||||
|
"""Generate a thumbnail for a JPEG image and verify size."""
|
||||||
|
from app.utils.thumbnails import generate_thumbnail
|
||||||
|
|
||||||
|
# Create a test image
|
||||||
|
img = Image.new("RGB", (800, 600), color="blue")
|
||||||
|
source_path = tmp_path / "test.jpg"
|
||||||
|
img.save(source_path, format="JPEG")
|
||||||
|
|
||||||
|
thumbnail_dir = tmp_path / "thumbnails"
|
||||||
|
result = generate_thumbnail(source_path, thumbnail_dir, "test.jpg")
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert Path(result).exists()
|
||||||
|
with Image.open(result) as thumb:
|
||||||
|
assert thumb.size == (200, 200)
|
||||||
|
|
||||||
|
def test_generate_thumbnail_for_png(self, tmp_path):
|
||||||
|
"""Generate a thumbnail for a PNG image."""
|
||||||
|
from app.utils.thumbnails import generate_thumbnail
|
||||||
|
|
||||||
|
img = Image.new("RGB", (800, 600), color="red")
|
||||||
|
source_path = tmp_path / "test.png"
|
||||||
|
img.save(source_path, format="PNG")
|
||||||
|
|
||||||
|
thumbnail_dir = tmp_path / "thumbnails"
|
||||||
|
result = generate_thumbnail(source_path, thumbnail_dir, "test.png")
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert Path(result).exists()
|
||||||
|
with Image.open(result) as thumb:
|
||||||
|
assert thumb.size == (200, 200)
|
||||||
|
|
||||||
|
def test_generate_thumbnail_for_webp(self, tmp_path):
|
||||||
|
"""Generate a thumbnail for a WebP image."""
|
||||||
|
from app.utils.thumbnails import generate_thumbnail
|
||||||
|
|
||||||
|
img = Image.new("RGB", (800, 600), color="green")
|
||||||
|
source_path = tmp_path / "test.webp"
|
||||||
|
img.save(source_path, format="WEBP")
|
||||||
|
|
||||||
|
thumbnail_dir = tmp_path / "thumbnails"
|
||||||
|
result = generate_thumbnail(source_path, thumbnail_dir, "test.webp")
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert Path(result).exists()
|
||||||
|
with Image.open(result) as thumb:
|
||||||
|
assert thumb.size == (200, 200)
|
||||||
|
|
||||||
|
def test_no_thumbnail_for_non_image(self, tmp_path):
|
||||||
|
"""Thumbnail generation returns None for non-image files."""
|
||||||
|
from app.utils.thumbnails import generate_thumbnail
|
||||||
|
|
||||||
|
source_path = tmp_path / "document.pdf"
|
||||||
|
source_path.write_bytes(b"%PDF-1.4 test")
|
||||||
|
|
||||||
|
thumbnail_dir = tmp_path / "thumbnails"
|
||||||
|
result = generate_thumbnail(source_path, thumbnail_dir, "document.pdf")
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_thumbnail_with_transparent_png(self, tmp_path):
|
||||||
|
"""Generate a thumbnail for a transparent PNG (RGBA mode)."""
|
||||||
|
from app.utils.thumbnails import generate_thumbnail
|
||||||
|
|
||||||
|
img = Image.new("RGBA", (400, 400), color=(255, 0, 0, 128))
|
||||||
|
source_path = tmp_path / "transparent.png"
|
||||||
|
img.save(source_path, format="PNG")
|
||||||
|
|
||||||
|
thumbnail_dir = tmp_path / "thumbnails"
|
||||||
|
result = generate_thumbnail(source_path, thumbnail_dir, "transparent.png")
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert Path(result).exists()
|
||||||
|
with Image.open(result) as thumb:
|
||||||
|
assert thumb.size == (200, 200)
|
||||||
|
|
||||||
|
def test_is_image_mime_type(self):
|
||||||
|
"""Test is_image_mime_type helper."""
|
||||||
|
from app.utils.thumbnails import is_image_mime_type
|
||||||
|
|
||||||
|
assert is_image_mime_type("image/jpeg") is True
|
||||||
|
assert is_image_mime_type("image/png") is True
|
||||||
|
assert is_image_mime_type("image/webp") is True
|
||||||
|
assert is_image_mime_type("application/pdf") is False
|
||||||
|
assert is_image_mime_type("text/plain") is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Tests: File Service Unit Tests ----
|
||||||
|
|
||||||
|
|
||||||
|
class TestFileServiceUnit:
|
||||||
|
"""Unit tests for file service functions."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_upload_file_creates_db_record(self, db_session, tmp_path):
|
||||||
|
"""Test that upload_file creates a DB record."""
|
||||||
|
from app.services import file_service
|
||||||
|
|
||||||
|
vehicle_id = await _create_test_vehicle(db_session)
|
||||||
|
image_bytes = _make_image_bytes()
|
||||||
|
|
||||||
|
with patch.object(settings, "UPLOAD_DIR", str(tmp_path)):
|
||||||
|
file_record = await file_service.upload_file(
|
||||||
|
db_session,
|
||||||
|
vehicle_id=vehicle_id,
|
||||||
|
file_content=image_bytes,
|
||||||
|
original_filename="test.jpg",
|
||||||
|
mime_type="image/jpeg",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert file_record.id is not None
|
||||||
|
assert file_record.vehicle_id == vehicle_id
|
||||||
|
assert file_record.original_filename == "test.jpg"
|
||||||
|
assert file_record.mime_type == "image/jpeg"
|
||||||
|
assert file_record.file_size == len(image_bytes)
|
||||||
|
assert file_record.thumbnail_path is not None
|
||||||
|
assert Path(file_record.file_path).exists()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_upload_file_rejects_invalid_mime(self, db_session, tmp_path):
|
||||||
|
"""Test that upload_file rejects invalid MIME types."""
|
||||||
|
from app.services import file_service
|
||||||
|
|
||||||
|
with patch.object(settings, "UPLOAD_DIR", str(tmp_path)):
|
||||||
|
with pytest.raises(ValueError, match="Unsupported MIME type"):
|
||||||
|
await file_service.upload_file(
|
||||||
|
db_session,
|
||||||
|
vehicle_id=uuid.uuid4(),
|
||||||
|
file_content=b"hello",
|
||||||
|
original_filename="test.exe",
|
||||||
|
mime_type="application/x-msdownload",
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_upload_file_rejects_oversized(self, db_session, tmp_path):
|
||||||
|
"""Test that upload_file rejects oversized files."""
|
||||||
|
from app.services import file_service
|
||||||
|
|
||||||
|
large_content = b"\x00" * (21 * 1024 * 1024)
|
||||||
|
with patch.object(settings, "UPLOAD_DIR", str(tmp_path)):
|
||||||
|
with pytest.raises(ValueError, match="exceeds 20MB"):
|
||||||
|
await file_service.upload_file(
|
||||||
|
db_session,
|
||||||
|
vehicle_id=uuid.uuid4(),
|
||||||
|
file_content=large_content,
|
||||||
|
original_filename="large.jpg",
|
||||||
|
mime_type="image/jpeg",
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_file_removes_from_disk(self, db_session, tmp_path):
|
||||||
|
"""Test that delete_file removes the file from disk."""
|
||||||
|
from app.services import file_service
|
||||||
|
|
||||||
|
vehicle_id = await _create_test_vehicle(db_session)
|
||||||
|
image_bytes = _make_image_bytes()
|
||||||
|
|
||||||
|
with patch.object(settings, "UPLOAD_DIR", str(tmp_path)):
|
||||||
|
file_record = await file_service.upload_file(
|
||||||
|
db_session,
|
||||||
|
vehicle_id=vehicle_id,
|
||||||
|
file_content=image_bytes,
|
||||||
|
original_filename="test.jpg",
|
||||||
|
mime_type="image/jpeg",
|
||||||
|
)
|
||||||
|
|
||||||
|
file_path = Path(file_record.file_path)
|
||||||
|
assert file_path.exists()
|
||||||
|
|
||||||
|
deleted = await file_service.delete_file(
|
||||||
|
db_session, vehicle_id, file_record.id
|
||||||
|
)
|
||||||
|
assert deleted is not None
|
||||||
|
assert not file_path.exists()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_nonexistent_file_returns_none(self, db_session):
|
||||||
|
"""Test that delete_file returns None for non-existent file."""
|
||||||
|
from app.services import file_service
|
||||||
|
|
||||||
|
result = await file_service.delete_file(db_session, uuid.uuid4(), uuid.uuid4())
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_files_pagination(self, db_session, tmp_path):
|
||||||
|
"""Test that list_files returns paginated results."""
|
||||||
|
from app.services import file_service
|
||||||
|
|
||||||
|
vehicle_id = await _create_test_vehicle(db_session)
|
||||||
|
|
||||||
|
with patch.object(settings, "UPLOAD_DIR", str(tmp_path)):
|
||||||
|
# Upload 3 files
|
||||||
|
for i in range(3):
|
||||||
|
await file_service.upload_file(
|
||||||
|
db_session,
|
||||||
|
vehicle_id=vehicle_id,
|
||||||
|
file_content=_make_image_bytes(),
|
||||||
|
original_filename=f"test_{i}.jpg",
|
||||||
|
mime_type="image/jpeg",
|
||||||
|
)
|
||||||
|
|
||||||
|
files, total = await file_service.list_files(
|
||||||
|
db_session, vehicle_id, page=1, page_size=2
|
||||||
|
)
|
||||||
|
assert total == 3
|
||||||
|
assert len(files) == 2
|
||||||
|
|
||||||
|
files_page2, total2 = await file_service.list_files(
|
||||||
|
db_session, vehicle_id, page=2, page_size=2
|
||||||
|
)
|
||||||
|
assert total2 == 3
|
||||||
|
assert len(files_page2) == 1
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_file_returns_correct_file(self, db_session, tmp_path):
|
||||||
|
"""Test that get_file returns the correct file by ID."""
|
||||||
|
from app.services import file_service
|
||||||
|
|
||||||
|
vehicle_id = await _create_test_vehicle(db_session)
|
||||||
|
|
||||||
|
with patch.object(settings, "UPLOAD_DIR", str(tmp_path)):
|
||||||
|
file_record = await file_service.upload_file(
|
||||||
|
db_session,
|
||||||
|
vehicle_id=vehicle_id,
|
||||||
|
file_content=_make_image_bytes(),
|
||||||
|
original_filename="test.jpg",
|
||||||
|
mime_type="image/jpeg",
|
||||||
|
)
|
||||||
|
|
||||||
|
retrieved = await file_service.get_file(
|
||||||
|
db_session, vehicle_id, file_record.id
|
||||||
|
)
|
||||||
|
assert retrieved is not None
|
||||||
|
assert retrieved.id == file_record.id
|
||||||
|
assert retrieved.original_filename == "test.jpg"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_file_wrong_vehicle_returns_none(self, db_session, tmp_path):
|
||||||
|
"""Test that get_file returns None for wrong vehicle ID."""
|
||||||
|
from app.services import file_service
|
||||||
|
|
||||||
|
vehicle_id = await _create_test_vehicle(db_session)
|
||||||
|
|
||||||
|
with patch.object(settings, "UPLOAD_DIR", str(tmp_path)):
|
||||||
|
file_record = await file_service.upload_file(
|
||||||
|
db_session,
|
||||||
|
vehicle_id=vehicle_id,
|
||||||
|
file_content=_make_image_bytes(),
|
||||||
|
original_filename="test.jpg",
|
||||||
|
mime_type="image/jpeg",
|
||||||
|
)
|
||||||
|
|
||||||
|
wrong_vehicle_id = uuid.uuid4()
|
||||||
|
retrieved = await file_service.get_file(
|
||||||
|
db_session, wrong_vehicle_id, file_record.id
|
||||||
|
)
|
||||||
|
assert retrieved is None
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""Tests for the health check endpoint."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from httpx import AsyncClient
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_health_check(client: AsyncClient):
|
||||||
|
"""GET /api/v1/health returns 200 with status ok."""
|
||||||
|
response = await client.get("/api/v1/health")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["status"] == "ok"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_health_no_auth_required(client: AsyncClient):
|
||||||
|
"""Health endpoint works without authentication."""
|
||||||
|
response = await client.get("/api/v1/health")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_root_endpoint(client: AsyncClient):
|
||||||
|
"""Root endpoint returns app info."""
|
||||||
|
response = await client.get("/")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert "name" in data
|
||||||
|
assert "version" in data
|
||||||
@@ -0,0 +1,443 @@
|
|||||||
|
"""Tests for mobile.de service: field mapping, push, update, delete, status, retry."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import date, datetime, timezone
|
||||||
|
from decimal import Decimal
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.models.vehicle import MobileDeListing, Vehicle
|
||||||
|
from app.services import mobilede_service
|
||||||
|
from app.utils.mobilede_mapping import map_fields
|
||||||
|
|
||||||
|
|
||||||
|
def _make_vehicle(**overrides) -> Vehicle:
|
||||||
|
"""Create a Vehicle instance with defaults and optional overrides."""
|
||||||
|
defaults = {
|
||||||
|
"make": "Mercedes-Benz",
|
||||||
|
"model": "Actros",
|
||||||
|
"fin": "WDB9066351L123456",
|
||||||
|
"year": 2020,
|
||||||
|
"first_registration": date(2020, 3, 15),
|
||||||
|
"power_kw": 300,
|
||||||
|
"power_hp": 408,
|
||||||
|
"fuel_type": "Diesel",
|
||||||
|
"transmission": "Manual",
|
||||||
|
"color": "White",
|
||||||
|
"condition": "used",
|
||||||
|
"location": "Berlin",
|
||||||
|
"availability": "available",
|
||||||
|
"price": Decimal("45000.00"),
|
||||||
|
"vehicle_type": "lkw",
|
||||||
|
"lkw_type": "sattelzugmaschine",
|
||||||
|
"mileage_km": 120000,
|
||||||
|
"description": "Well maintained truck",
|
||||||
|
}
|
||||||
|
defaults.update(overrides)
|
||||||
|
vehicle = Vehicle(**defaults)
|
||||||
|
vehicle.id = uuid.uuid4()
|
||||||
|
return vehicle
|
||||||
|
|
||||||
|
|
||||||
|
class TestFieldMapping:
|
||||||
|
"""Tests for mobile.de field mapping (map_fields)."""
|
||||||
|
|
||||||
|
def test_map_fields_basic_lkw(self):
|
||||||
|
"""map_fields produces correct ad format for LKW."""
|
||||||
|
vehicle = _make_vehicle()
|
||||||
|
ad = map_fields(vehicle)
|
||||||
|
|
||||||
|
assert ad["vin"] == "WDB9066351L123456"
|
||||||
|
assert ad["make"] == "Mercedes-Benz"
|
||||||
|
assert ad["model"] == "Actros"
|
||||||
|
assert ad["category"] == "SemiTractor"
|
||||||
|
assert ad["price"] == {"amount": 45000.0, "currency": "EUR"}
|
||||||
|
assert ad["availabilityStatus"] == "available"
|
||||||
|
assert ad["condition"] == "used"
|
||||||
|
assert ad["firstRegistration"] == "2020-03"
|
||||||
|
assert ad["mileage"] == {"value": 120000, "unit": "km"}
|
||||||
|
assert ad["power"] == {"powerKw": 300, "powerHp": 408}
|
||||||
|
assert ad["fuelType"] == "Diesel"
|
||||||
|
assert ad["transmission"] == "Manual"
|
||||||
|
assert ad["color"] == "White"
|
||||||
|
assert ad["sellerLocation"] == "Berlin"
|
||||||
|
assert ad["description"] == "Well maintained truck"
|
||||||
|
|
||||||
|
def test_map_fields_baumaschine_with_operating_hours(self):
|
||||||
|
"""map_fields maps operating_hours to mileage for baumaschine."""
|
||||||
|
vehicle = _make_vehicle(
|
||||||
|
vehicle_type="baumaschine",
|
||||||
|
machine_type="Bagger",
|
||||||
|
operating_hours=Decimal("3500.5"),
|
||||||
|
operating_hours_unit="h",
|
||||||
|
mileage_km=None,
|
||||||
|
lkw_type=None,
|
||||||
|
)
|
||||||
|
ad = map_fields(vehicle)
|
||||||
|
|
||||||
|
assert ad["category"] == "ConstructionMachine"
|
||||||
|
assert ad["mileage"] == {"value": 3500.5, "unit": "h"}
|
||||||
|
assert ad["bodyType"] == "Bagger"
|
||||||
|
|
||||||
|
def test_map_fields_pkw(self):
|
||||||
|
"""map_fields maps PKW correctly."""
|
||||||
|
vehicle = _make_vehicle(
|
||||||
|
vehicle_type="pkw",
|
||||||
|
lkw_type=None,
|
||||||
|
body_type="Limousine",
|
||||||
|
)
|
||||||
|
ad = map_fields(vehicle)
|
||||||
|
assert ad["category"] == "Car"
|
||||||
|
assert ad["bodyType"] == "Limousine"
|
||||||
|
|
||||||
|
def test_map_fields_stapler(self):
|
||||||
|
"""map_fields maps Stapler correctly."""
|
||||||
|
vehicle = _make_vehicle(
|
||||||
|
vehicle_type="stapler",
|
||||||
|
lkw_type=None,
|
||||||
|
operating_hours=Decimal("12000"),
|
||||||
|
operating_hours_unit="h",
|
||||||
|
mileage_km=None,
|
||||||
|
)
|
||||||
|
ad = map_fields(vehicle)
|
||||||
|
assert ad["category"] == "ForkliftTruck"
|
||||||
|
assert ad["mileage"] == {"value": 12000.0, "unit": "h"}
|
||||||
|
|
||||||
|
def test_map_fields_transporter(self):
|
||||||
|
"""map_fields maps Transporter correctly."""
|
||||||
|
vehicle = _make_vehicle(
|
||||||
|
vehicle_type="transporter",
|
||||||
|
lkw_type=None,
|
||||||
|
)
|
||||||
|
ad = map_fields(vehicle)
|
||||||
|
assert ad["category"] == "Van"
|
||||||
|
|
||||||
|
def test_map_fields_no_optional_fields(self):
|
||||||
|
"""map_fields handles vehicle with no optional fields."""
|
||||||
|
vehicle = Vehicle(
|
||||||
|
make="Test",
|
||||||
|
model="Model",
|
||||||
|
fin="WDB9066351L123456",
|
||||||
|
condition="new",
|
||||||
|
availability="available",
|
||||||
|
price=Decimal("10000.00"),
|
||||||
|
vehicle_type="pkw",
|
||||||
|
)
|
||||||
|
ad = map_fields(vehicle)
|
||||||
|
assert ad["vin"] == "WDB9066351L123456"
|
||||||
|
assert ad["make"] == "Test"
|
||||||
|
assert "firstRegistration" not in ad
|
||||||
|
assert "mileage" not in ad
|
||||||
|
assert "power" not in ad
|
||||||
|
assert "fuelType" not in ad
|
||||||
|
|
||||||
|
def test_map_fields_lkw_type_with_prefix(self):
|
||||||
|
"""map_fields strips lkw_ prefix for category lookup."""
|
||||||
|
vehicle = _make_vehicle(lkw_type="lkw_kipper")
|
||||||
|
ad = map_fields(vehicle)
|
||||||
|
assert ad["category"] == "Tipper"
|
||||||
|
|
||||||
|
def test_map_fields_lkw_type_unknown_falls_back(self):
|
||||||
|
"""map_fields falls back to base category for unknown lkw_type."""
|
||||||
|
vehicle = _make_vehicle(lkw_type="unknown_type")
|
||||||
|
ad = map_fields(vehicle)
|
||||||
|
assert ad["category"] == "Truck"
|
||||||
|
|
||||||
|
|
||||||
|
class TestPushListing:
|
||||||
|
"""Tests for mobilede_service.push_listing."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_push_listing_success(self, db_session):
|
||||||
|
"""push_listing creates listing with synced status on success."""
|
||||||
|
vehicle = _make_vehicle()
|
||||||
|
db_session.add(vehicle)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.services.mobilede_service.httpx.AsyncClient"
|
||||||
|
) as mock_client_cls:
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = 201
|
||||||
|
mock_response.json.return_value = {"id": "ad-123"}
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.post = AsyncMock(return_value=mock_response)
|
||||||
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||||
|
mock_client.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
mock_client_cls.return_value = mock_client
|
||||||
|
|
||||||
|
listing = await mobilede_service.push_listing(db_session, vehicle)
|
||||||
|
|
||||||
|
assert listing.sync_status == "synced"
|
||||||
|
assert listing.ad_id == "ad-123"
|
||||||
|
assert listing.synced_at is not None
|
||||||
|
assert listing.error_log is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_push_listing_http_error(self, db_session):
|
||||||
|
"""push_listing sets fehler status on HTTP error."""
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
vehicle = _make_vehicle()
|
||||||
|
db_session.add(vehicle)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.services.mobilede_service.httpx.AsyncClient"
|
||||||
|
) as mock_client_cls:
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = 400
|
||||||
|
mock_response.text = "Bad Request"
|
||||||
|
mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
|
||||||
|
"Bad Request", request=MagicMock(), response=mock_response
|
||||||
|
)
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.post = AsyncMock(return_value=mock_response)
|
||||||
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||||
|
mock_client.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
mock_client_cls.return_value = mock_client
|
||||||
|
|
||||||
|
listing = await mobilede_service.push_listing(db_session, vehicle)
|
||||||
|
|
||||||
|
assert listing.sync_status == "fehler"
|
||||||
|
assert listing.error_log is not None
|
||||||
|
assert "400" in listing.error_log
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_push_listing_request_error(self, db_session):
|
||||||
|
"""push_listing sets fehler status on request error."""
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
vehicle = _make_vehicle()
|
||||||
|
db_session.add(vehicle)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.services.mobilede_service.httpx.AsyncClient"
|
||||||
|
) as mock_client_cls:
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.post = AsyncMock(
|
||||||
|
side_effect=httpx.ConnectError("Connection refused")
|
||||||
|
)
|
||||||
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||||
|
mock_client.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
mock_client_cls.return_value = mock_client
|
||||||
|
|
||||||
|
listing = await mobilede_service.push_listing(db_session, vehicle)
|
||||||
|
|
||||||
|
assert listing.sync_status == "fehler"
|
||||||
|
assert "Connection refused" in listing.error_log
|
||||||
|
|
||||||
|
|
||||||
|
class TestUpdateListing:
|
||||||
|
"""Tests for mobilede_service.update_listing."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_listing_success(self, db_session):
|
||||||
|
"""update_listing updates synced status on success."""
|
||||||
|
vehicle = _make_vehicle()
|
||||||
|
db_session.add(vehicle)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
listing = MobileDeListing(
|
||||||
|
vehicle_id=vehicle.id,
|
||||||
|
ad_id="ad-123",
|
||||||
|
sync_status="synced",
|
||||||
|
)
|
||||||
|
db_session.add(listing)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.services.mobilede_service.httpx.AsyncClient"
|
||||||
|
) as mock_client_cls:
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = 200
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.put = AsyncMock(return_value=mock_response)
|
||||||
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||||
|
mock_client.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
mock_client_cls.return_value = mock_client
|
||||||
|
|
||||||
|
result = await mobilede_service.update_listing(db_session, vehicle, listing)
|
||||||
|
|
||||||
|
assert result.sync_status == "synced"
|
||||||
|
assert result.synced_at is not None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_listing_no_ad_id(self, db_session):
|
||||||
|
"""update_listing sets fehler when listing has no ad_id."""
|
||||||
|
vehicle = _make_vehicle()
|
||||||
|
db_session.add(vehicle)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
listing = MobileDeListing(
|
||||||
|
vehicle_id=vehicle.id,
|
||||||
|
sync_status="pending",
|
||||||
|
)
|
||||||
|
db_session.add(listing)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
result = await mobilede_service.update_listing(db_session, vehicle, listing)
|
||||||
|
|
||||||
|
assert result.sync_status == "fehler"
|
||||||
|
assert "ad_id" in result.error_log
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeleteListing:
|
||||||
|
"""Tests for mobilede_service.delete_listing."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_listing_success(self, db_session):
|
||||||
|
"""delete_listing sets deleted status on success."""
|
||||||
|
vehicle = _make_vehicle()
|
||||||
|
db_session.add(vehicle)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
listing = MobileDeListing(
|
||||||
|
vehicle_id=vehicle.id,
|
||||||
|
ad_id="ad-123",
|
||||||
|
sync_status="synced",
|
||||||
|
)
|
||||||
|
db_session.add(listing)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.services.mobilede_service.httpx.AsyncClient"
|
||||||
|
) as mock_client_cls:
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = 204
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.delete = AsyncMock(return_value=mock_response)
|
||||||
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||||
|
mock_client.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
mock_client_cls.return_value = mock_client
|
||||||
|
|
||||||
|
result = await mobilede_service.delete_listing(db_session, listing)
|
||||||
|
|
||||||
|
assert result.sync_status == "deleted"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_listing_no_ad_id(self, db_session):
|
||||||
|
"""delete_listing sets fehler when listing has no ad_id."""
|
||||||
|
vehicle = _make_vehicle()
|
||||||
|
db_session.add(vehicle)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
listing = MobileDeListing(
|
||||||
|
vehicle_id=vehicle.id,
|
||||||
|
sync_status="pending",
|
||||||
|
)
|
||||||
|
db_session.add(listing)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
result = await mobilede_service.delete_listing(db_session, listing)
|
||||||
|
|
||||||
|
assert result.sync_status == "fehler"
|
||||||
|
assert "ad_id" in result.error_log
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetListingStatus:
|
||||||
|
"""Tests for mobilede_service.get_listing_status."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_listing_status_returns_latest(self, db_session):
|
||||||
|
"""get_listing_status returns the most recent listing."""
|
||||||
|
vehicle = _make_vehicle()
|
||||||
|
db_session.add(vehicle)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
listing1 = MobileDeListing(
|
||||||
|
vehicle_id=vehicle.id,
|
||||||
|
sync_status="fehler",
|
||||||
|
error_log="First attempt failed",
|
||||||
|
created_at=datetime(2025, 1, 1, 12, 0, 0, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
db_session.add(listing1)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
listing2 = MobileDeListing(
|
||||||
|
vehicle_id=vehicle.id,
|
||||||
|
ad_id="ad-456",
|
||||||
|
sync_status="synced",
|
||||||
|
created_at=datetime(2025, 1, 2, 12, 0, 0, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
db_session.add(listing2)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
result = await mobilede_service.get_listing_status(db_session, vehicle.id)
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert result.sync_status == "synced"
|
||||||
|
assert result.ad_id == "ad-456"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_listing_status_returns_none_when_no_listing(self, db_session):
|
||||||
|
"""get_listing_status returns None when no listing exists."""
|
||||||
|
vehicle_id = uuid.uuid4()
|
||||||
|
result = await mobilede_service.get_listing_status(db_session, vehicle_id)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestRetryFailedListing:
|
||||||
|
"""Tests for mobilede_service.retry_failed_listing."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_retry_succeeds_within_max_retries(self, db_session):
|
||||||
|
"""retry_failed_listing re-attempts push when under max retries."""
|
||||||
|
vehicle = _make_vehicle()
|
||||||
|
db_session.add(vehicle)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
listing = MobileDeListing(
|
||||||
|
vehicle_id=vehicle.id,
|
||||||
|
sync_status="fehler",
|
||||||
|
error_log="[retry 1] HTTP 500: Internal Server Error",
|
||||||
|
)
|
||||||
|
db_session.add(listing)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.services.mobilede_service.httpx.AsyncClient"
|
||||||
|
) as mock_client_cls:
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = 201
|
||||||
|
mock_response.json.return_value = {"id": "ad-789"}
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.post = AsyncMock(return_value=mock_response)
|
||||||
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||||
|
mock_client.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
mock_client_cls.return_value = mock_client
|
||||||
|
|
||||||
|
result = await mobilede_service.retry_failed_listing(
|
||||||
|
db_session, listing, vehicle
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.sync_status == "synced"
|
||||||
|
assert result.ad_id == "ad-789"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_retry_exceeds_max_retries(self, db_session):
|
||||||
|
"""retry_failed_listing marks as permanently failed after max retries."""
|
||||||
|
vehicle = _make_vehicle()
|
||||||
|
db_session.add(vehicle)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
listing = MobileDeListing(
|
||||||
|
vehicle_id=vehicle.id,
|
||||||
|
sync_status="fehler",
|
||||||
|
error_log=f"[retry {mobilede_service.MAX_RETRIES}] Last error",
|
||||||
|
)
|
||||||
|
db_session.add(listing)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
result = await mobilede_service.retry_failed_listing(
|
||||||
|
db_session, listing, vehicle
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.sync_status == "fehler"
|
||||||
|
assert "Max retries" in result.error_log
|
||||||
@@ -0,0 +1,638 @@
|
|||||||
|
"""Tests for OCR module: upload, results, apply, and processing with mocked OpenRouter."""
|
||||||
|
|
||||||
|
import io
|
||||||
|
import uuid
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.main import app
|
||||||
|
from app.models.ocr_result import OCRResult, OCRStatus
|
||||||
|
from app.models.vehicle import Vehicle
|
||||||
|
from app.services import ocr_service
|
||||||
|
|
||||||
|
# Ensure all models are registered with Base.metadata
|
||||||
|
from app.models import user, vehicle as vehicle_model, ocr_result # noqa: F401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def test_vehicle(db_session: AsyncSession) -> Vehicle:
|
||||||
|
"""Create a test vehicle for OCR linking."""
|
||||||
|
vehicle = Vehicle(
|
||||||
|
make="Mercedes",
|
||||||
|
model="Actros",
|
||||||
|
fin="WDB9066351L123456",
|
||||||
|
year=2020,
|
||||||
|
power_kw=350,
|
||||||
|
fuel_type="Diesel",
|
||||||
|
condition="used",
|
||||||
|
availability="available",
|
||||||
|
price=45000,
|
||||||
|
vehicle_type="lkw",
|
||||||
|
)
|
||||||
|
db_session.add(vehicle)
|
||||||
|
await db_session.commit()
|
||||||
|
await db_session.refresh(vehicle)
|
||||||
|
return vehicle
|
||||||
|
|
||||||
|
|
||||||
|
def _make_mock_openrouter_response(
|
||||||
|
confidence: float = 0.85,
|
||||||
|
brand: str = "Mercedes",
|
||||||
|
model: str = "Actros",
|
||||||
|
vin: str = "WDB9066351L123456",
|
||||||
|
first_registration: str = "15.03.2020",
|
||||||
|
mileage: int = 120000,
|
||||||
|
power_kw: int = 350,
|
||||||
|
fuel_type: str = "Diesel",
|
||||||
|
) -> dict:
|
||||||
|
"""Build a mock OpenRouter response dict."""
|
||||||
|
return {
|
||||||
|
"structured_data": {
|
||||||
|
"brand": brand,
|
||||||
|
"model": model,
|
||||||
|
"vin": vin,
|
||||||
|
"first_registration": first_registration,
|
||||||
|
"mileage": mileage,
|
||||||
|
"power_kw": power_kw,
|
||||||
|
"fuel_type": fuel_type,
|
||||||
|
},
|
||||||
|
"confidence_score": confidence,
|
||||||
|
"raw_text": f'{{"brand": "{brand}", "model": "{model}", "vin": "{vin}", "confidence_score": {confidence}}}',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestOCRService:
|
||||||
|
"""Unit tests for ocr_service functions."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_validate_mime_type_valid(self):
|
||||||
|
assert ocr_service.validate_mime_type("image/png") is True
|
||||||
|
assert ocr_service.validate_mime_type("image/jpeg") is True
|
||||||
|
assert ocr_service.validate_mime_type("image/webp") is True
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_validate_mime_type_invalid(self):
|
||||||
|
assert ocr_service.validate_mime_type("application/pdf") is False
|
||||||
|
assert ocr_service.validate_mime_type("text/plain") is False
|
||||||
|
assert ocr_service.validate_mime_type("") is False
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_validate_file_size_valid(self):
|
||||||
|
# 1 MB should be valid (limit is 50 MB)
|
||||||
|
assert ocr_service.validate_file_size(1024 * 1024) is True
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_validate_file_size_too_large(self):
|
||||||
|
# 51 MB should be invalid
|
||||||
|
assert ocr_service.validate_file_size(51 * 1024 * 1024) is False
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_upload_file_success(self, db_session: AsyncSession, tmp_path):
|
||||||
|
"""Test uploading a valid image file creates an OCRResult."""
|
||||||
|
with patch.object(ocr_service.settings, "UPLOAD_DIR", str(tmp_path)):
|
||||||
|
file_bytes = b"fake-image-data"
|
||||||
|
result = await ocr_service.upload_file(
|
||||||
|
db=db_session,
|
||||||
|
file_bytes=file_bytes,
|
||||||
|
file_name="scan.png",
|
||||||
|
mime_type="image/png",
|
||||||
|
)
|
||||||
|
assert result.id is not None
|
||||||
|
assert result.status == OCRStatus.pending
|
||||||
|
assert result.file_name == "scan.png"
|
||||||
|
assert result.mime_type == "image/png"
|
||||||
|
assert result.file_path.endswith(".png")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_upload_file_invalid_mime(self, db_session: AsyncSession):
|
||||||
|
"""Test uploading with invalid MIME type raises ValueError."""
|
||||||
|
with pytest.raises(ValueError, match="Invalid MIME type"):
|
||||||
|
await ocr_service.upload_file(
|
||||||
|
db=db_session,
|
||||||
|
file_bytes=b"data",
|
||||||
|
file_name="doc.pdf",
|
||||||
|
mime_type="application/pdf",
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_upload_file_too_large(self, db_session: AsyncSession):
|
||||||
|
"""Test uploading a file that exceeds size limit raises ValueError."""
|
||||||
|
large_bytes = b"x" * (51 * 1024 * 1024)
|
||||||
|
with pytest.raises(ValueError, match="File size exceeds"):
|
||||||
|
await ocr_service.upload_file(
|
||||||
|
db=db_session,
|
||||||
|
file_bytes=large_bytes,
|
||||||
|
file_name="big.png",
|
||||||
|
mime_type="image/png",
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_result_not_found(self, db_session: AsyncSession):
|
||||||
|
"""Test getting a non-existent OCR result returns None."""
|
||||||
|
result = await ocr_service.get_result(db_session, uuid.uuid4())
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_results_empty(self, db_session: AsyncSession):
|
||||||
|
"""Test listing OCR results when none exist."""
|
||||||
|
items, total = await ocr_service.list_results(db_session)
|
||||||
|
assert total == 0
|
||||||
|
assert items == []
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_results_with_data(self, db_session: AsyncSession, tmp_path):
|
||||||
|
"""Test listing OCR results with data."""
|
||||||
|
with patch.object(ocr_service.settings, "UPLOAD_DIR", str(tmp_path)):
|
||||||
|
await ocr_service.upload_file(
|
||||||
|
db=db_session,
|
||||||
|
file_bytes=b"img1",
|
||||||
|
file_name="scan1.png",
|
||||||
|
mime_type="image/png",
|
||||||
|
)
|
||||||
|
await ocr_service.upload_file(
|
||||||
|
db=db_session,
|
||||||
|
file_bytes=b"img2",
|
||||||
|
file_name="scan2.png",
|
||||||
|
mime_type="image/png",
|
||||||
|
)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
items, total = await ocr_service.list_results(db_session)
|
||||||
|
assert total == 2
|
||||||
|
assert len(items) == 2
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_results_filter_by_vehicle(
|
||||||
|
self, db_session: AsyncSession, test_vehicle: Vehicle, tmp_path
|
||||||
|
):
|
||||||
|
"""Test listing OCR results filtered by vehicle_id."""
|
||||||
|
with patch.object(ocr_service.settings, "UPLOAD_DIR", str(tmp_path)):
|
||||||
|
await ocr_service.upload_file(
|
||||||
|
db=db_session,
|
||||||
|
file_bytes=b"img1",
|
||||||
|
file_name="scan1.png",
|
||||||
|
mime_type="image/png",
|
||||||
|
vehicle_id=test_vehicle.id,
|
||||||
|
)
|
||||||
|
await ocr_service.upload_file(
|
||||||
|
db=db_session,
|
||||||
|
file_bytes=b"img2",
|
||||||
|
file_name="scan2.png",
|
||||||
|
mime_type="image/png",
|
||||||
|
)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
items, total = await ocr_service.list_results(
|
||||||
|
db_session, vehicle_id=test_vehicle.id
|
||||||
|
)
|
||||||
|
assert total == 1
|
||||||
|
assert len(items) == 1
|
||||||
|
assert items[0].vehicle_id == test_vehicle.id
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_process_ocr_high_confidence(
|
||||||
|
self, db_session: AsyncSession, tmp_path
|
||||||
|
):
|
||||||
|
"""Test OCR processing with high confidence sets status to completed."""
|
||||||
|
with patch.object(ocr_service.settings, "UPLOAD_DIR", str(tmp_path)):
|
||||||
|
ocr_result = await ocr_service.upload_file(
|
||||||
|
db=db_session,
|
||||||
|
file_bytes=b"fake-image",
|
||||||
|
file_name="scan.png",
|
||||||
|
mime_type="image/png",
|
||||||
|
)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
mock_response = _make_mock_openrouter_response(confidence=0.92)
|
||||||
|
with patch(
|
||||||
|
"app.services.ocr_service.perform_ocr",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=mock_response,
|
||||||
|
):
|
||||||
|
result = await ocr_service.process_ocr(db_session, ocr_result.id)
|
||||||
|
|
||||||
|
assert result.status == OCRStatus.completed
|
||||||
|
assert result.confidence_score == 0.92
|
||||||
|
assert result.structured_data is not None
|
||||||
|
assert result.structured_data["brand"] == "Mercedes"
|
||||||
|
assert result.structured_data["model"] == "Actros"
|
||||||
|
assert result.structured_data["vin"] == "WDB9066351L123456"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_process_ocr_low_confidence(self, db_session: AsyncSession, tmp_path):
|
||||||
|
"""Test OCR processing with low confidence sets status to manual_review."""
|
||||||
|
with patch.object(ocr_service.settings, "UPLOAD_DIR", str(tmp_path)):
|
||||||
|
ocr_result = await ocr_service.upload_file(
|
||||||
|
db=db_session,
|
||||||
|
file_bytes=b"fake-image",
|
||||||
|
file_name="scan.png",
|
||||||
|
mime_type="image/png",
|
||||||
|
)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
mock_response = _make_mock_openrouter_response(confidence=0.45)
|
||||||
|
with patch(
|
||||||
|
"app.services.ocr_service.perform_ocr",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=mock_response,
|
||||||
|
):
|
||||||
|
result = await ocr_service.process_ocr(db_session, ocr_result.id)
|
||||||
|
|
||||||
|
assert result.status == OCRStatus.manual_review
|
||||||
|
assert result.confidence_score == 0.45
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_process_ocr_openrouter_failure(
|
||||||
|
self, db_session: AsyncSession, tmp_path
|
||||||
|
):
|
||||||
|
"""Test OCR processing when OpenRouter fails sets status to failed."""
|
||||||
|
with patch.object(ocr_service.settings, "UPLOAD_DIR", str(tmp_path)):
|
||||||
|
ocr_result = await ocr_service.upload_file(
|
||||||
|
db=db_session,
|
||||||
|
file_bytes=b"fake-image",
|
||||||
|
file_name="scan.png",
|
||||||
|
mime_type="image/png",
|
||||||
|
)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.services.ocr_service.perform_ocr",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
side_effect=Exception("OpenRouter API unavailable"),
|
||||||
|
):
|
||||||
|
result = await ocr_service.process_ocr(db_session, ocr_result.id)
|
||||||
|
|
||||||
|
assert result.status == OCRStatus.failed
|
||||||
|
assert result.error_message is not None
|
||||||
|
assert "OpenRouter API unavailable" in result.error_message
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_apply_to_vehicle_success(
|
||||||
|
self, db_session: AsyncSession, test_vehicle: Vehicle, tmp_path
|
||||||
|
):
|
||||||
|
"""Test applying OCR data to a vehicle."""
|
||||||
|
with patch.object(ocr_service.settings, "UPLOAD_DIR", str(tmp_path)):
|
||||||
|
ocr_result = await ocr_service.upload_file(
|
||||||
|
db=db_session,
|
||||||
|
file_bytes=b"fake-image",
|
||||||
|
file_name="scan.png",
|
||||||
|
mime_type="image/png",
|
||||||
|
vehicle_id=test_vehicle.id,
|
||||||
|
)
|
||||||
|
# Set structured data manually
|
||||||
|
ocr_result.structured_data = {
|
||||||
|
"brand": "MAN",
|
||||||
|
"model": "TGX",
|
||||||
|
"vin": "WDB9066351L123456",
|
||||||
|
"first_registration": "15.03.2020",
|
||||||
|
"mileage": 85000,
|
||||||
|
"power_kw": 400,
|
||||||
|
"fuel_type": "Diesel",
|
||||||
|
}
|
||||||
|
ocr_result.confidence_score = 0.88
|
||||||
|
ocr_result.status = OCRStatus.completed
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
ocr, vehicle, updated_fields = await ocr_service.apply_to_vehicle(
|
||||||
|
db_session, ocr_result.id
|
||||||
|
)
|
||||||
|
|
||||||
|
assert vehicle.make == "MAN"
|
||||||
|
assert vehicle.model == "TGX"
|
||||||
|
assert vehicle.mileage_km == 85000
|
||||||
|
assert vehicle.power_kw == 400
|
||||||
|
assert vehicle.fuel_type == "Diesel"
|
||||||
|
assert "make" in updated_fields
|
||||||
|
assert "model" in updated_fields
|
||||||
|
assert "mileage_km" in updated_fields
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_apply_to_vehicle_no_vehicle(
|
||||||
|
self, db_session: AsyncSession, tmp_path
|
||||||
|
):
|
||||||
|
"""Test applying OCR data when no vehicle is linked."""
|
||||||
|
with patch.object(ocr_service.settings, "UPLOAD_DIR", str(tmp_path)):
|
||||||
|
ocr_result = await ocr_service.upload_file(
|
||||||
|
db=db_session,
|
||||||
|
file_bytes=b"fake-image",
|
||||||
|
file_name="scan.png",
|
||||||
|
mime_type="image/png",
|
||||||
|
)
|
||||||
|
ocr_result.structured_data = {"brand": "MAN"}
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="No vehicle linked"):
|
||||||
|
await ocr_service.apply_to_vehicle(db_session, ocr_result.id)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_apply_to_vehicle_no_data(
|
||||||
|
self, db_session: AsyncSession, test_vehicle: Vehicle, tmp_path
|
||||||
|
):
|
||||||
|
"""Test applying OCR data when no structured data exists."""
|
||||||
|
with patch.object(ocr_service.settings, "UPLOAD_DIR", str(tmp_path)):
|
||||||
|
ocr_result = await ocr_service.upload_file(
|
||||||
|
db=db_session,
|
||||||
|
file_bytes=b"fake-image",
|
||||||
|
file_name="scan.png",
|
||||||
|
mime_type="image/png",
|
||||||
|
vehicle_id=test_vehicle.id,
|
||||||
|
)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="No structured data"):
|
||||||
|
await ocr_service.apply_to_vehicle(db_session, ocr_result.id)
|
||||||
|
|
||||||
|
|
||||||
|
class TestOCRRouter:
|
||||||
|
"""Integration tests for OCR API endpoints."""
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def ocr_client(self, test_session_factory, admin_token):
|
||||||
|
"""HTTP client with DB override and admin auth."""
|
||||||
|
|
||||||
|
async def _override_get_db():
|
||||||
|
async with test_session_factory() as session:
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
await session.commit()
|
||||||
|
except Exception:
|
||||||
|
await session.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
await session.close()
|
||||||
|
|
||||||
|
app.dependency_overrides[get_db] = _override_get_db
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||||
|
ac.headers.update({"Authorization": f"Bearer {admin_token}"})
|
||||||
|
yield ac
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_upload_success(self, ocr_client: AsyncClient, tmp_path):
|
||||||
|
"""POST /api/v1/ocr/upload with valid image returns 202."""
|
||||||
|
with patch.object(ocr_service.settings, "UPLOAD_DIR", str(tmp_path)):
|
||||||
|
# Patch background task to avoid actual processing
|
||||||
|
with patch("app.routers.ocr.run_ocr_processing"):
|
||||||
|
response = await ocr_client.post(
|
||||||
|
"/api/v1/ocr/upload",
|
||||||
|
files={
|
||||||
|
"file": ("scan.png", io.BytesIO(b"fake-image"), "image/png")
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 202
|
||||||
|
data = response.json()
|
||||||
|
assert "ocr_result_id" in data
|
||||||
|
assert data["status"] == "pending"
|
||||||
|
assert data["message"] == "OCR processing queued"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_upload_no_file(self, ocr_client: AsyncClient):
|
||||||
|
"""POST /api/v1/ocr/upload without file returns 422."""
|
||||||
|
response = await ocr_client.post("/api/v1/ocr/upload")
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_upload_invalid_mime(self, ocr_client: AsyncClient, tmp_path):
|
||||||
|
"""POST /api/v1/ocr/upload with invalid MIME type returns 422."""
|
||||||
|
with patch.object(ocr_service.settings, "UPLOAD_DIR", str(tmp_path)):
|
||||||
|
response = await ocr_client.post(
|
||||||
|
"/api/v1/ocr/upload",
|
||||||
|
files={"file": ("doc.pdf", io.BytesIO(b"fake-pdf"), "application/pdf")},
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
data = response.json()
|
||||||
|
assert data["detail"]["error"]["code"] == "INVALID_MIME_TYPE"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_result_success(self, ocr_client: AsyncClient, tmp_path):
|
||||||
|
"""GET /api/v1/ocr/results/:id returns 200 with result data."""
|
||||||
|
with patch.object(ocr_service.settings, "UPLOAD_DIR", str(tmp_path)):
|
||||||
|
with patch("app.routers.ocr.run_ocr_processing"):
|
||||||
|
upload_resp = await ocr_client.post(
|
||||||
|
"/api/v1/ocr/upload",
|
||||||
|
files={
|
||||||
|
"file": ("scan.png", io.BytesIO(b"fake-image"), "image/png")
|
||||||
|
},
|
||||||
|
)
|
||||||
|
result_id = upload_resp.json()["ocr_result_id"]
|
||||||
|
|
||||||
|
response = await ocr_client.get(f"/api/v1/ocr/results/{result_id}")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["id"] == result_id
|
||||||
|
assert data["status"] == "pending"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_result_not_found(self, ocr_client: AsyncClient):
|
||||||
|
"""GET /api/v1/ocr/results/:nonexistent returns 404."""
|
||||||
|
fake_id = uuid.uuid4()
|
||||||
|
response = await ocr_client.get(f"/api/v1/ocr/results/{fake_id}")
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_results(self, ocr_client: AsyncClient, tmp_path):
|
||||||
|
"""GET /api/v1/ocr/results returns 200 with list."""
|
||||||
|
with patch.object(ocr_service.settings, "UPLOAD_DIR", str(tmp_path)):
|
||||||
|
with patch("app.routers.ocr.run_ocr_processing"):
|
||||||
|
for i in range(3):
|
||||||
|
await ocr_client.post(
|
||||||
|
"/api/v1/ocr/upload",
|
||||||
|
files={
|
||||||
|
"file": (
|
||||||
|
f"scan{i}.png",
|
||||||
|
io.BytesIO(b"fake-image"),
|
||||||
|
"image/png",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await ocr_client.get("/api/v1/ocr/results")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["total"] >= 3
|
||||||
|
assert len(data["items"]) >= 3
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_results_filter_vehicle(
|
||||||
|
self, ocr_client: AsyncClient, test_vehicle: Vehicle, tmp_path
|
||||||
|
):
|
||||||
|
"""GET /api/v1/ocr/results?vehicle_id=X returns filtered list."""
|
||||||
|
with patch.object(ocr_service.settings, "UPLOAD_DIR", str(tmp_path)):
|
||||||
|
with patch("app.routers.ocr.run_ocr_processing"):
|
||||||
|
await ocr_client.post(
|
||||||
|
"/api/v1/ocr/upload",
|
||||||
|
files={"file": ("scan1.png", io.BytesIO(b"img1"), "image/png")},
|
||||||
|
data={"vehicle_id": str(test_vehicle.id)},
|
||||||
|
)
|
||||||
|
await ocr_client.post(
|
||||||
|
"/api/v1/ocr/upload",
|
||||||
|
files={"file": ("scan2.png", io.BytesIO(b"img2"), "image/png")},
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await ocr_client.get(
|
||||||
|
f"/api/v1/ocr/results?vehicle_id={test_vehicle.id}"
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["total"] == 1
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_apply_to_vehicle_endpoint(
|
||||||
|
self, ocr_client: AsyncClient, test_vehicle: Vehicle, tmp_path
|
||||||
|
):
|
||||||
|
"""POST /api/v1/ocr/results/:id/apply returns 200 and updates vehicle."""
|
||||||
|
with patch.object(ocr_service.settings, "UPLOAD_DIR", str(tmp_path)):
|
||||||
|
with patch("app.routers.ocr.run_ocr_processing"):
|
||||||
|
upload_resp = await ocr_client.post(
|
||||||
|
"/api/v1/ocr/upload",
|
||||||
|
files={
|
||||||
|
"file": ("scan.png", io.BytesIO(b"fake-image"), "image/png")
|
||||||
|
},
|
||||||
|
data={"vehicle_id": str(test_vehicle.id)},
|
||||||
|
)
|
||||||
|
result_id = upload_resp.json()["ocr_result_id"]
|
||||||
|
|
||||||
|
# Manually set structured data via direct DB session
|
||||||
|
from tests.conftest import TEST_DATABASE_URL
|
||||||
|
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
|
||||||
|
|
||||||
|
engine = create_async_engine(TEST_DATABASE_URL)
|
||||||
|
factory = async_sessionmaker(
|
||||||
|
engine, class_=AsyncSession, expire_on_commit=False
|
||||||
|
)
|
||||||
|
async with factory() as session:
|
||||||
|
stmt = select(OCRResult).where(OCRResult.id == uuid.UUID(result_id))
|
||||||
|
res = await session.execute(stmt)
|
||||||
|
ocr = res.scalar_one()
|
||||||
|
ocr.structured_data = {
|
||||||
|
"brand": "Volvo",
|
||||||
|
"model": "FH16",
|
||||||
|
"vin": "WDB9066351L123456",
|
||||||
|
"first_registration": "20.01.2021",
|
||||||
|
"mileage": 200000,
|
||||||
|
"power_kw": 500,
|
||||||
|
"fuel_type": "Diesel",
|
||||||
|
}
|
||||||
|
ocr.confidence_score = 0.9
|
||||||
|
ocr.status = OCRStatus.completed
|
||||||
|
await session.commit()
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
response = await ocr_client.post(f"/api/v1/ocr/results/{result_id}/apply")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["vehicle_id"] == str(test_vehicle.id)
|
||||||
|
assert "make" in data["updated_fields"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestOpenRouterClient:
|
||||||
|
"""Tests for the OpenRouter API client utility."""
|
||||||
|
|
||||||
|
def test_parse_response_valid_json(self):
|
||||||
|
"""Test parsing a valid JSON response."""
|
||||||
|
from app.utils.openrouter import _parse_response
|
||||||
|
|
||||||
|
raw = (
|
||||||
|
'{"brand": "BMW", "model": "X5", "vin": "ABC123", "confidence_score": 0.9}'
|
||||||
|
)
|
||||||
|
result = _parse_response(raw)
|
||||||
|
assert result["structured_data"]["brand"] == "BMW"
|
||||||
|
assert result["confidence_score"] == 0.9
|
||||||
|
|
||||||
|
def test_parse_response_markdown_fenced(self):
|
||||||
|
"""Test parsing a markdown-fenced JSON response."""
|
||||||
|
from app.utils.openrouter import _parse_response
|
||||||
|
|
||||||
|
raw = '```json\n{"brand": "Audi", "model": "A4", "confidence_score": 0.85}\n```'
|
||||||
|
result = _parse_response(raw)
|
||||||
|
assert result["structured_data"]["brand"] == "Audi"
|
||||||
|
assert result["confidence_score"] == 0.85
|
||||||
|
|
||||||
|
def test_parse_response_with_text_around(self):
|
||||||
|
"""Test parsing JSON embedded in text."""
|
||||||
|
from app.utils.openrouter import _parse_response
|
||||||
|
|
||||||
|
raw = 'Here is the result: {"brand": "VW", "model": "Golf", "confidence_score": 0.7} done.'
|
||||||
|
result = _parse_response(raw)
|
||||||
|
assert result["structured_data"]["brand"] == "VW"
|
||||||
|
assert result["confidence_score"] == 0.7
|
||||||
|
|
||||||
|
def test_parse_response_invalid(self):
|
||||||
|
"""Test parsing an invalid response returns defaults."""
|
||||||
|
from app.utils.openrouter import _parse_response
|
||||||
|
|
||||||
|
result = _parse_response("not json at all")
|
||||||
|
assert result["structured_data"] == {}
|
||||||
|
assert result["confidence_score"] == 0.0
|
||||||
|
|
||||||
|
def test_parse_response_clamps_confidence(self):
|
||||||
|
"""Test that confidence score is clamped to 0.0-1.0."""
|
||||||
|
from app.utils.openrouter import _parse_response
|
||||||
|
|
||||||
|
result = _parse_response('{"brand": "X", "confidence_score": 1.5}')
|
||||||
|
assert result["confidence_score"] == 1.0
|
||||||
|
|
||||||
|
result = _parse_response('{"brand": "X", "confidence_score": -0.5}')
|
||||||
|
assert result["confidence_score"] == 0.0
|
||||||
|
|
||||||
|
def test_parse_response_all_expected_fields(self):
|
||||||
|
"""Test that all expected fields are present in structured_data."""
|
||||||
|
from app.utils.openrouter import _parse_response, EXPECTED_FIELDS
|
||||||
|
|
||||||
|
raw = '{"brand": "M", "model": "A", "vin": "V", "first_registration": "01.01.2020", "mileage": 100, "power_kw": 200, "fuel_type": "D", "confidence_score": 0.8}'
|
||||||
|
result = _parse_response(raw)
|
||||||
|
for field in EXPECTED_FIELDS:
|
||||||
|
assert field in result["structured_data"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_perform_ocr_no_api_key(self):
|
||||||
|
"""Test perform_ocr raises ValueError when no API key is configured."""
|
||||||
|
from app.utils.openrouter import perform_ocr
|
||||||
|
|
||||||
|
with patch("app.utils.openrouter.settings") as mock_settings:
|
||||||
|
mock_settings.OPENROUTER_API_KEY = ""
|
||||||
|
mock_settings.OPENROUTER_OCR_MODEL = "test-model"
|
||||||
|
mock_settings.OPENROUTER_BASE_URL = "https://test.example.com"
|
||||||
|
with pytest.raises(ValueError, match="OPENROUTER_API_KEY"):
|
||||||
|
await perform_ocr(b"image", "image/png")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_perform_ocr_mocked_httpx(self):
|
||||||
|
"""Test perform_ocr with mocked httpx client."""
|
||||||
|
from app.utils.openrouter import perform_ocr
|
||||||
|
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.json.return_value = {
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"message": {
|
||||||
|
"content": '{"brand": "Test", "model": "Model", "vin": "VIN123", "confidence_score": 0.95}'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
|
||||||
|
with patch("app.utils.openrouter.settings") as mock_settings:
|
||||||
|
mock_settings.OPENROUTER_API_KEY = "test-key"
|
||||||
|
mock_settings.OPENROUTER_OCR_MODEL = "test-model"
|
||||||
|
mock_settings.OPENROUTER_BASE_URL = "https://test.example.com"
|
||||||
|
|
||||||
|
with patch("httpx.AsyncClient") as mock_client_class:
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.post = AsyncMock(return_value=mock_response)
|
||||||
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||||
|
mock_client.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
mock_client_class.return_value = mock_client
|
||||||
|
|
||||||
|
result = await perform_ocr(b"image", "image/png")
|
||||||
|
|
||||||
|
assert result["structured_data"]["brand"] == "Test"
|
||||||
|
assert result["confidence_score"] == 0.95
|
||||||
@@ -0,0 +1,590 @@
|
|||||||
|
"""Tests for retouch module: upload, process, results, and price comparison with mocked OpenRouter."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
from httpx import AsyncClient
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.retouch import RetouchStatus
|
||||||
|
from app.models.vehicle import Vehicle
|
||||||
|
from app.services import retouch_service, price_compare_service
|
||||||
|
|
||||||
|
# Ensure all models are registered with Base.metadata
|
||||||
|
from app.models import user, vehicle, retouch # noqa: F401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def test_vehicle(db_session: AsyncSession) -> Vehicle:
|
||||||
|
"""Create a test vehicle for retouch linking."""
|
||||||
|
v = Vehicle(
|
||||||
|
make="Mercedes",
|
||||||
|
model="Actros",
|
||||||
|
fin="WDB9066351L123456",
|
||||||
|
year=2020,
|
||||||
|
power_kw=350,
|
||||||
|
fuel_type="Diesel",
|
||||||
|
condition="used",
|
||||||
|
availability="available",
|
||||||
|
price=45000,
|
||||||
|
vehicle_type="lkw",
|
||||||
|
mileage_km=120000,
|
||||||
|
)
|
||||||
|
db_session.add(v)
|
||||||
|
await db_session.commit()
|
||||||
|
await db_session.refresh(v)
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
def _fake_image(size: int = 1024) -> bytes:
|
||||||
|
"""Generate fake image bytes for testing."""
|
||||||
|
return b"\x89PNG\r\n\x1a\n" + b"\x00" * (size - 8)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRetouchService:
|
||||||
|
"""Unit tests for retouch_service functions."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_validate_mime_type_valid(self):
|
||||||
|
assert retouch_service.validate_mime_type("image/png") is True
|
||||||
|
assert retouch_service.validate_mime_type("image/jpeg") is True
|
||||||
|
assert retouch_service.validate_mime_type("image/webp") is True
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_validate_mime_type_invalid(self):
|
||||||
|
assert retouch_service.validate_mime_type("application/pdf") is False
|
||||||
|
assert retouch_service.validate_mime_type("text/plain") is False
|
||||||
|
assert retouch_service.validate_mime_type("") is False
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_validate_file_size_valid(self):
|
||||||
|
assert retouch_service.validate_file_size(1024 * 1024) is True
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_validate_file_size_too_large(self):
|
||||||
|
assert retouch_service.validate_file_size(51 * 1024 * 1024) is False
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_generate_retouch_prompt_default(self):
|
||||||
|
prompt = retouch_service.generate_retouch_prompt()
|
||||||
|
assert "background" in prompt.lower()
|
||||||
|
assert "color" in prompt.lower()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_generate_retouch_prompt_with_vehicle_info(self):
|
||||||
|
info = {"make": "Mercedes", "model": "Actros", "color": "red"}
|
||||||
|
prompt = retouch_service.generate_retouch_prompt(info)
|
||||||
|
assert "Mercedes" in prompt
|
||||||
|
assert "Actros" in prompt
|
||||||
|
assert "red" in prompt
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_upload_retouch_file_success(
|
||||||
|
self, db_session: AsyncSession, tmp_path
|
||||||
|
):
|
||||||
|
with patch.object(retouch_service.settings, "UPLOAD_DIR", str(tmp_path)):
|
||||||
|
result = await retouch_service.upload_retouch_file(
|
||||||
|
db=db_session,
|
||||||
|
file_bytes=_fake_image(),
|
||||||
|
file_name="truck.png",
|
||||||
|
mime_type="image/png",
|
||||||
|
)
|
||||||
|
assert result.id is not None
|
||||||
|
assert result.status == RetouchStatus.pending.value
|
||||||
|
assert result.original_file_name == "truck.png"
|
||||||
|
assert result.original_file_path.endswith(".png")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_upload_retouch_file_invalid_mime(self, db_session: AsyncSession):
|
||||||
|
with pytest.raises(ValueError, match="Invalid MIME type"):
|
||||||
|
await retouch_service.upload_retouch_file(
|
||||||
|
db=db_session,
|
||||||
|
file_bytes=b"data",
|
||||||
|
file_name="doc.pdf",
|
||||||
|
mime_type="application/pdf",
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_upload_retouch_file_too_large(self, db_session: AsyncSession):
|
||||||
|
large = b"x" * (51 * 1024 * 1024)
|
||||||
|
with pytest.raises(ValueError, match="File size exceeds"):
|
||||||
|
await retouch_service.upload_retouch_file(
|
||||||
|
db=db_session,
|
||||||
|
file_bytes=large,
|
||||||
|
file_name="big.png",
|
||||||
|
mime_type="image/png",
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_result_not_found(self, db_session: AsyncSession):
|
||||||
|
result = await retouch_service.get_result(db_session, uuid.uuid4())
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_results_empty(self, db_session: AsyncSession):
|
||||||
|
items, total = await retouch_service.list_results(db_session)
|
||||||
|
assert total == 0
|
||||||
|
assert items == []
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_results_with_data(self, db_session: AsyncSession, tmp_path):
|
||||||
|
with patch.object(retouch_service.settings, "UPLOAD_DIR", str(tmp_path)):
|
||||||
|
await retouch_service.upload_retouch_file(
|
||||||
|
db=db_session,
|
||||||
|
file_bytes=_fake_image(),
|
||||||
|
file_name="img1.png",
|
||||||
|
mime_type="image/png",
|
||||||
|
)
|
||||||
|
await retouch_service.upload_retouch_file(
|
||||||
|
db=db_session,
|
||||||
|
file_bytes=_fake_image(),
|
||||||
|
file_name="img2.png",
|
||||||
|
mime_type="image/png",
|
||||||
|
)
|
||||||
|
await db_session.commit()
|
||||||
|
items, total = await retouch_service.list_results(db_session)
|
||||||
|
assert total == 2
|
||||||
|
assert len(items) == 2
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_results_filter_by_vehicle(
|
||||||
|
self, db_session: AsyncSession, test_vehicle: Vehicle, tmp_path
|
||||||
|
):
|
||||||
|
with patch.object(retouch_service.settings, "UPLOAD_DIR", str(tmp_path)):
|
||||||
|
await retouch_service.upload_retouch_file(
|
||||||
|
db=db_session,
|
||||||
|
file_bytes=_fake_image(),
|
||||||
|
file_name="img1.png",
|
||||||
|
mime_type="image/png",
|
||||||
|
vehicle_id=test_vehicle.id,
|
||||||
|
)
|
||||||
|
await retouch_service.upload_retouch_file(
|
||||||
|
db=db_session,
|
||||||
|
file_bytes=_fake_image(),
|
||||||
|
file_name="img2.png",
|
||||||
|
mime_type="image/png",
|
||||||
|
)
|
||||||
|
await db_session.commit()
|
||||||
|
items, total = await retouch_service.list_results(
|
||||||
|
db_session, vehicle_id=test_vehicle.id
|
||||||
|
)
|
||||||
|
assert total == 1
|
||||||
|
assert len(items) == 1
|
||||||
|
assert items[0].vehicle_id == test_vehicle.id
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_process_retouch_success(self, db_session: AsyncSession, tmp_path):
|
||||||
|
"""Test process_retouch with mocked Flux.1-Pro call."""
|
||||||
|
with patch.object(retouch_service.settings, "UPLOAD_DIR", str(tmp_path)):
|
||||||
|
result = await retouch_service.upload_retouch_file(
|
||||||
|
db=db_session,
|
||||||
|
file_bytes=_fake_image(),
|
||||||
|
file_name="truck.png",
|
||||||
|
mime_type="image/png",
|
||||||
|
)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
# Mock the _call_flux_pro function to return fake retouched bytes
|
||||||
|
retouched_bytes = b"\x89PNG\r\n\x1a\n" + b"\xff" * 1016
|
||||||
|
with patch.object(
|
||||||
|
retouch_service,
|
||||||
|
"_call_flux_pro",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=retouched_bytes,
|
||||||
|
):
|
||||||
|
processed = await retouch_service.process_retouch(db_session, result.id)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
assert processed.status == RetouchStatus.completed.value
|
||||||
|
assert processed.retouched_file_path is not None
|
||||||
|
assert processed.error_message is None
|
||||||
|
assert processed.retouched_file_path.endswith(".png")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_process_retouch_failure(self, db_session: AsyncSession, tmp_path):
|
||||||
|
"""Test process_retouch sets failed status on OpenRouter error."""
|
||||||
|
with patch.object(retouch_service.settings, "UPLOAD_DIR", str(tmp_path)):
|
||||||
|
result = await retouch_service.upload_retouch_file(
|
||||||
|
db=db_session,
|
||||||
|
file_bytes=_fake_image(),
|
||||||
|
file_name="truck.png",
|
||||||
|
mime_type="image/png",
|
||||||
|
)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
retouch_service,
|
||||||
|
"_call_flux_pro",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
side_effect=Exception("OpenRouter unavailable"),
|
||||||
|
):
|
||||||
|
processed = await retouch_service.process_retouch(db_session, result.id)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
assert processed.status == RetouchStatus.failed.value
|
||||||
|
assert processed.error_message is not None
|
||||||
|
assert "OpenRouter unavailable" in processed.error_message
|
||||||
|
assert processed.retouched_file_path is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_process_retouch_not_found(self, db_session: AsyncSession):
|
||||||
|
"""Test process_retouch raises ValueError for non-existent result."""
|
||||||
|
with pytest.raises(ValueError, match="not found"):
|
||||||
|
await retouch_service.process_retouch(db_session, uuid.uuid4())
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_call_flux_pro_no_api_key(self):
|
||||||
|
"""Test _call_flux_pro raises ValueError when no API key configured."""
|
||||||
|
with patch.object(retouch_service.settings, "OPENROUTER_API_KEY", ""):
|
||||||
|
with pytest.raises(
|
||||||
|
ValueError, match="OPENROUTER_API_KEY is not configured"
|
||||||
|
):
|
||||||
|
await retouch_service._call_flux_pro(
|
||||||
|
image_bytes=b"fake-image",
|
||||||
|
mime_type="image/png",
|
||||||
|
prompt="test prompt",
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_call_flux_pro_success_data_uri(
|
||||||
|
self, db_session: AsyncSession, tmp_path
|
||||||
|
):
|
||||||
|
"""Test _call_flux_pro with mocked httpx returning base64 data URI."""
|
||||||
|
import base64 as b64mod
|
||||||
|
|
||||||
|
retouched = b"\x89PNG\r\n\x1a\nretouched-data"
|
||||||
|
b64_retouched = b64mod.b64encode(retouched).decode("utf-8")
|
||||||
|
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
mock_response.json.return_value = {
|
||||||
|
"choices": [
|
||||||
|
{"message": {"content": f"data:image/png;base64,{b64_retouched}"}}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.post = AsyncMock(return_value=mock_response)
|
||||||
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||||
|
mock_client.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
with patch.object(retouch_service.settings, "OPENROUTER_API_KEY", "test-key"):
|
||||||
|
with patch(
|
||||||
|
"app.services.retouch_service.httpx.AsyncClient",
|
||||||
|
return_value=mock_client,
|
||||||
|
):
|
||||||
|
result = await retouch_service._call_flux_pro(
|
||||||
|
image_bytes=b"fake-image",
|
||||||
|
mime_type="image/png",
|
||||||
|
prompt="test prompt",
|
||||||
|
)
|
||||||
|
assert result == retouched
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_call_flux_pro_success_list_content(
|
||||||
|
self, db_session: AsyncSession, tmp_path
|
||||||
|
):
|
||||||
|
"""Test _call_flux_pro with content as list of parts."""
|
||||||
|
import base64 as b64mod
|
||||||
|
|
||||||
|
retouched = b"\x89PNG\r\n\x1a\nlist-content"
|
||||||
|
b64_retouched = b64mod.b64encode(retouched).decode("utf-8")
|
||||||
|
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
mock_response.json.return_value = {
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"message": {
|
||||||
|
"content": [
|
||||||
|
{"type": "text", "text": "Here is the image"},
|
||||||
|
{
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": {
|
||||||
|
"url": f"data:image/png;base64,{b64_retouched}"
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.post = AsyncMock(return_value=mock_response)
|
||||||
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||||
|
mock_client.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
with patch.object(retouch_service.settings, "OPENROUTER_API_KEY", "test-key"):
|
||||||
|
with patch(
|
||||||
|
"app.services.retouch_service.httpx.AsyncClient",
|
||||||
|
return_value=mock_client,
|
||||||
|
):
|
||||||
|
result = await retouch_service._call_flux_pro(
|
||||||
|
image_bytes=b"fake-image",
|
||||||
|
mime_type="image/png",
|
||||||
|
prompt="test prompt",
|
||||||
|
)
|
||||||
|
assert result == retouched
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_call_flux_pro_fallback_original_bytes(self):
|
||||||
|
"""Test _call_flux_pro returns original bytes when no image in response."""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
mock_response.json.return_value = {
|
||||||
|
"choices": [{"message": {"content": "Sorry, I cannot process this image."}}]
|
||||||
|
}
|
||||||
|
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.post = AsyncMock(return_value=mock_response)
|
||||||
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||||
|
mock_client.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
original = b"original-image-bytes"
|
||||||
|
with patch.object(retouch_service.settings, "OPENROUTER_API_KEY", "test-key"):
|
||||||
|
with patch(
|
||||||
|
"app.services.retouch_service.httpx.AsyncClient",
|
||||||
|
return_value=mock_client,
|
||||||
|
):
|
||||||
|
result = await retouch_service._call_flux_pro(
|
||||||
|
image_bytes=original,
|
||||||
|
mime_type="image/png",
|
||||||
|
prompt="test prompt",
|
||||||
|
)
|
||||||
|
assert result == original
|
||||||
|
|
||||||
|
|
||||||
|
class TestPriceCompareService:
|
||||||
|
"""Unit tests for price_compare_service functions."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_compare_prices_success(
|
||||||
|
self, db_session: AsyncSession, test_vehicle: Vehicle
|
||||||
|
):
|
||||||
|
result = await price_compare_service.compare_prices(db_session, test_vehicle.id)
|
||||||
|
assert result.vehicle_id == test_vehicle.id
|
||||||
|
assert len(result.comparable_listings) > 0
|
||||||
|
assert result.average_price is not None
|
||||||
|
assert result.listing_count > 0
|
||||||
|
# Average should be within reasonable range of vehicle price
|
||||||
|
base = float(test_vehicle.price)
|
||||||
|
assert base * 0.7 < result.average_price < base * 1.3
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_compare_prices_vehicle_not_found(self, db_session: AsyncSession):
|
||||||
|
with pytest.raises(ValueError, match="not found"):
|
||||||
|
await price_compare_service.compare_prices(db_session, uuid.uuid4())
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_compare_prices_listings_have_correct_make_model(
|
||||||
|
self, db_session: AsyncSession, test_vehicle: Vehicle
|
||||||
|
):
|
||||||
|
result = await price_compare_service.compare_prices(db_session, test_vehicle.id)
|
||||||
|
for listing in result.comparable_listings:
|
||||||
|
assert listing.make == test_vehicle.make
|
||||||
|
assert listing.model == test_vehicle.model
|
||||||
|
assert listing.source == "mobile.de"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_compare_prices_average_calculation(
|
||||||
|
self, db_session: AsyncSession, test_vehicle: Vehicle
|
||||||
|
):
|
||||||
|
result = await price_compare_service.compare_prices(db_session, test_vehicle.id)
|
||||||
|
expected_avg = sum(
|
||||||
|
listing.price for listing in result.comparable_listings
|
||||||
|
) / len(result.comparable_listings)
|
||||||
|
assert abs(result.average_price - round(expected_avg, 2)) < 0.01
|
||||||
|
|
||||||
|
|
||||||
|
class TestRetouchRouter:
|
||||||
|
"""Integration tests for retouch router endpoints."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_process_image_success(self, admin_client: AsyncClient, tmp_path):
|
||||||
|
"""POST /retouch/process with valid image returns 202 + retouch_id."""
|
||||||
|
with patch.object(retouch_service.settings, "UPLOAD_DIR", str(tmp_path)):
|
||||||
|
with patch(
|
||||||
|
"app.routers.image_retouch.run_retouch_processing",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
):
|
||||||
|
fake_image = _fake_image()
|
||||||
|
response = await admin_client.post(
|
||||||
|
"/api/v1/retouch/process",
|
||||||
|
files={"file": ("truck.png", fake_image, "image/png")},
|
||||||
|
)
|
||||||
|
assert response.status_code == 202
|
||||||
|
data = response.json()
|
||||||
|
assert "retouch_id" in data
|
||||||
|
assert data["status"] == "pending"
|
||||||
|
assert data["message"] == "Retouch processing queued"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_process_image_no_file(self, admin_client: AsyncClient):
|
||||||
|
"""POST /retouch/process without file returns 422."""
|
||||||
|
response = await admin_client.post("/api/v1/retouch/process")
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_process_image_invalid_mime(self, admin_client: AsyncClient):
|
||||||
|
"""POST /retouch/process with invalid MIME returns 422."""
|
||||||
|
response = await admin_client.post(
|
||||||
|
"/api/v1/retouch/process",
|
||||||
|
files={"file": ("doc.pdf", b"fake-pdf-data", "application/pdf")},
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
data = response.json()
|
||||||
|
assert data["detail"]["error"]["code"] == "INVALID_MIME_TYPE"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_process_image_with_vehicle_id(
|
||||||
|
self, admin_client: AsyncClient, test_vehicle: Vehicle, tmp_path
|
||||||
|
):
|
||||||
|
"""POST /retouch/process with vehicle_id links the result."""
|
||||||
|
with patch.object(retouch_service.settings, "UPLOAD_DIR", str(tmp_path)):
|
||||||
|
with patch(
|
||||||
|
"app.routers.image_retouch.run_retouch_processing",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
):
|
||||||
|
response = await admin_client.post(
|
||||||
|
"/api/v1/retouch/process",
|
||||||
|
files={"file": ("truck.png", _fake_image(), "image/png")},
|
||||||
|
data={"vehicle_id": str(test_vehicle.id)},
|
||||||
|
)
|
||||||
|
assert response.status_code == 202
|
||||||
|
data = response.json()
|
||||||
|
assert "retouch_id" in data
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_retouch_result_completed(
|
||||||
|
self, admin_client: AsyncClient, db_session: AsyncSession, tmp_path
|
||||||
|
):
|
||||||
|
"""GET /retouch/results/:id returns 200 with completed status."""
|
||||||
|
with patch.object(retouch_service.settings, "UPLOAD_DIR", str(tmp_path)):
|
||||||
|
result = await retouch_service.upload_retouch_file(
|
||||||
|
db=db_session,
|
||||||
|
file_bytes=_fake_image(),
|
||||||
|
file_name="truck.png",
|
||||||
|
mime_type="image/png",
|
||||||
|
)
|
||||||
|
result.status = RetouchStatus.completed.value
|
||||||
|
result.retouched_file_path = str(tmp_path / "retouched.png")
|
||||||
|
await db_session.commit()
|
||||||
|
await db_session.refresh(result)
|
||||||
|
|
||||||
|
response = await admin_client.get(f"/api/v1/retouch/results/{result.id}")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["status"] == "completed"
|
||||||
|
assert data["retouched_file_path"] is not None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_retouch_result_processing(
|
||||||
|
self, admin_client: AsyncClient, db_session: AsyncSession, tmp_path
|
||||||
|
):
|
||||||
|
"""GET /retouch/results/:id before completion returns 200 with processing status."""
|
||||||
|
with patch.object(retouch_service.settings, "UPLOAD_DIR", str(tmp_path)):
|
||||||
|
result = await retouch_service.upload_retouch_file(
|
||||||
|
db=db_session,
|
||||||
|
file_bytes=_fake_image(),
|
||||||
|
file_name="truck.png",
|
||||||
|
mime_type="image/png",
|
||||||
|
)
|
||||||
|
result.status = RetouchStatus.processing.value
|
||||||
|
await db_session.commit()
|
||||||
|
await db_session.refresh(result)
|
||||||
|
|
||||||
|
response = await admin_client.get(f"/api/v1/retouch/results/{result.id}")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["status"] == "processing"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_retouch_result_failed(
|
||||||
|
self, admin_client: AsyncClient, db_session: AsyncSession, tmp_path
|
||||||
|
):
|
||||||
|
"""GET /retouch/results/:id with failed status returns 200 + error."""
|
||||||
|
with patch.object(retouch_service.settings, "UPLOAD_DIR", str(tmp_path)):
|
||||||
|
result = await retouch_service.upload_retouch_file(
|
||||||
|
db=db_session,
|
||||||
|
file_bytes=_fake_image(),
|
||||||
|
file_name="truck.png",
|
||||||
|
mime_type="image/png",
|
||||||
|
)
|
||||||
|
result.status = RetouchStatus.failed.value
|
||||||
|
result.error_message = "OpenRouter unavailable"
|
||||||
|
await db_session.commit()
|
||||||
|
await db_session.refresh(result)
|
||||||
|
|
||||||
|
response = await admin_client.get(f"/api/v1/retouch/results/{result.id}")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["status"] == "failed"
|
||||||
|
assert data["error_message"] == "OpenRouter unavailable"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_retouch_result_not_found(self, admin_client: AsyncClient):
|
||||||
|
"""GET /retouch/results/:id with non-existent ID returns 404."""
|
||||||
|
response = await admin_client.get(f"/api/v1/retouch/results/{uuid.uuid4()}")
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_price_compare_success(
|
||||||
|
self, admin_client: AsyncClient, test_vehicle: Vehicle
|
||||||
|
):
|
||||||
|
"""POST /retouch/price-compare with vehicle_id returns 200 + listings."""
|
||||||
|
response = await admin_client.post(
|
||||||
|
"/api/v1/retouch/price-compare",
|
||||||
|
json={"vehicle_id": str(test_vehicle.id)},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["vehicle_id"] == str(test_vehicle.id)
|
||||||
|
assert isinstance(data["comparable_listings"], list)
|
||||||
|
assert len(data["comparable_listings"]) > 0
|
||||||
|
assert data["average_price"] is not None
|
||||||
|
assert data["listing_count"] > 0
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_price_compare_no_vehicle_id(self, admin_client: AsyncClient):
|
||||||
|
"""POST /retouch/price-compare without vehicle_id returns 422."""
|
||||||
|
response = await admin_client.post(
|
||||||
|
"/api/v1/retouch/price-compare",
|
||||||
|
json={},
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_price_compare_vehicle_not_found(self, admin_client: AsyncClient):
|
||||||
|
"""POST /retouch/price-compare with non-existent vehicle returns 404."""
|
||||||
|
response = await admin_client.post(
|
||||||
|
"/api/v1/retouch/price-compare",
|
||||||
|
json={"vehicle_id": str(uuid.uuid4())},
|
||||||
|
)
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_process_image_unauthorized(self, client: AsyncClient):
|
||||||
|
"""POST /retouch/process without auth returns 401."""
|
||||||
|
response = await client.post(
|
||||||
|
"/api/v1/retouch/process",
|
||||||
|
files={"file": ("truck.png", _fake_image(), "image/png")},
|
||||||
|
)
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_result_unauthorized(self, client: AsyncClient):
|
||||||
|
"""GET /retouch/results/:id without auth returns 401."""
|
||||||
|
response = await client.get(f"/api/v1/retouch/results/{uuid.uuid4()}")
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_price_compare_unauthorized(self, client: AsyncClient):
|
||||||
|
"""POST /retouch/price-compare without auth returns 401."""
|
||||||
|
response = await client.post(
|
||||||
|
"/api/v1/retouch/price-compare",
|
||||||
|
json={"vehicle_id": str(uuid.uuid4())},
|
||||||
|
)
|
||||||
|
assert response.status_code == 401
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
"""Tests for sales module: CRUD, contract PDF, GwG logic, vehicle status changes."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest_asyncio
|
||||||
|
from httpx import AsyncClient
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.sale import Sale
|
||||||
|
from app.models.vehicle import Vehicle
|
||||||
|
from app.models.contact import Contact
|
||||||
|
from app.utils.contract_pdf import build_contract_html
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def test_vehicle(db_session: AsyncSession) -> Vehicle:
|
||||||
|
"""Create a test vehicle."""
|
||||||
|
vehicle = Vehicle(
|
||||||
|
make="Mercedes",
|
||||||
|
model="Actros",
|
||||||
|
fin="WDB96312345678901",
|
||||||
|
year=2020,
|
||||||
|
vehicle_type="lkw",
|
||||||
|
price=Decimal("45000.00"),
|
||||||
|
availability="available",
|
||||||
|
condition="used",
|
||||||
|
)
|
||||||
|
db_session.add(vehicle)
|
||||||
|
await db_session.commit()
|
||||||
|
await db_session.refresh(vehicle)
|
||||||
|
return vehicle
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def test_buyer(db_session: AsyncSession) -> Contact:
|
||||||
|
"""Create a test buyer contact."""
|
||||||
|
contact = Contact(
|
||||||
|
company_name="Test Buyer GmbH",
|
||||||
|
role="kaeufer",
|
||||||
|
address_country="DE",
|
||||||
|
vat_id="DE123456789",
|
||||||
|
address_street="Teststr. 1",
|
||||||
|
address_zip="12345",
|
||||||
|
address_city="Berlin",
|
||||||
|
email="buyer@test.com",
|
||||||
|
)
|
||||||
|
db_session.add(contact)
|
||||||
|
await db_session.commit()
|
||||||
|
await db_session.refresh(contact)
|
||||||
|
return contact
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def test_seller(db_session: AsyncSession) -> Contact:
|
||||||
|
"""Create a test seller contact."""
|
||||||
|
contact = Contact(
|
||||||
|
company_name="Test Seller GmbH",
|
||||||
|
role="verkaeufer",
|
||||||
|
address_country="DE",
|
||||||
|
vat_id="DE987654321",
|
||||||
|
address_street="Sellerstr. 2",
|
||||||
|
address_zip="54321",
|
||||||
|
address_city="München",
|
||||||
|
email="seller@test.com",
|
||||||
|
)
|
||||||
|
db_session.add(contact)
|
||||||
|
await db_session.commit()
|
||||||
|
await db_session.refresh(contact)
|
||||||
|
return contact
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def test_sale(
|
||||||
|
db_session: AsyncSession, test_vehicle: Vehicle, test_buyer: Contact
|
||||||
|
) -> Sale:
|
||||||
|
"""Create a test sale."""
|
||||||
|
sale = Sale(
|
||||||
|
vehicle_id=test_vehicle.id,
|
||||||
|
buyer_contact_id=test_buyer.id,
|
||||||
|
sale_price=Decimal("45000.00"),
|
||||||
|
sale_date=date(2025, 1, 15),
|
||||||
|
status="completed",
|
||||||
|
is_gwg=False,
|
||||||
|
)
|
||||||
|
db_session.add(sale)
|
||||||
|
await db_session.commit()
|
||||||
|
await db_session.refresh(sale)
|
||||||
|
return sale
|
||||||
|
|
||||||
|
|
||||||
|
class TestSaleCRUD:
|
||||||
|
"""Test sale CRUD operations via API."""
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def setup_data(self, db_session, test_vehicle, test_buyer):
|
||||||
|
"""Ensure vehicle and buyer exist."""
|
||||||
|
return test_vehicle, test_buyer
|
||||||
|
|
||||||
|
async def test_list_sales_empty(self, admin_client: AsyncClient):
|
||||||
|
"""GET /sales with no sales returns empty list."""
|
||||||
|
response = await admin_client.get("/api/v1/sales/")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["items"] == []
|
||||||
|
assert data["total"] == 0
|
||||||
|
assert data["page"] == 1
|
||||||
|
assert data["page_size"] == 20
|
||||||
|
|
||||||
|
async def test_create_sale(
|
||||||
|
self, admin_client: AsyncClient, test_vehicle, test_buyer
|
||||||
|
):
|
||||||
|
"""POST /sales with valid data returns 201."""
|
||||||
|
response = await admin_client.post(
|
||||||
|
"/api/v1/sales/",
|
||||||
|
json={
|
||||||
|
"vehicle_id": str(test_vehicle.id),
|
||||||
|
"buyer_contact_id": str(test_buyer.id),
|
||||||
|
"sale_price": "45000.00",
|
||||||
|
"sale_date": "2025-01-15",
|
||||||
|
"status": "draft",
|
||||||
|
"is_gwg": False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
data = response.json()
|
||||||
|
assert data["vehicle_id"] == str(test_vehicle.id)
|
||||||
|
assert data["buyer_contact_id"] == str(test_buyer.id)
|
||||||
|
assert data["sale_price"] == "45000.00"
|
||||||
|
assert data["status"] == "draft"
|
||||||
|
assert data["is_gwg"] is False
|
||||||
|
|
||||||
|
async def test_create_sale_without_vehicle_id(
|
||||||
|
self, admin_client: AsyncClient, test_buyer
|
||||||
|
):
|
||||||
|
"""POST /sales without vehicle_id returns 422."""
|
||||||
|
response = await admin_client.post(
|
||||||
|
"/api/v1/sales/",
|
||||||
|
json={
|
||||||
|
"buyer_contact_id": str(test_buyer.id),
|
||||||
|
"sale_price": "45000.00",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
async def test_create_sale_without_buyer_contact_id(
|
||||||
|
self, admin_client: AsyncClient, test_vehicle
|
||||||
|
):
|
||||||
|
"""POST /sales without buyer_contact_id returns 422."""
|
||||||
|
response = await admin_client.post(
|
||||||
|
"/api/v1/sales/",
|
||||||
|
json={
|
||||||
|
"vehicle_id": str(test_vehicle.id),
|
||||||
|
"sale_price": "45000.00",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
async def test_get_sale_by_id(self, admin_client: AsyncClient, test_sale):
|
||||||
|
"""GET /sales/:id returns sale detail with nested vehicle and contact."""
|
||||||
|
response = await admin_client.get(f"/api/v1/sales/{test_sale.id}")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["id"] == str(test_sale.id)
|
||||||
|
assert data["vehicle"]["make"] == "Mercedes"
|
||||||
|
assert data["buyer"]["company_name"] == "Test Buyer GmbH"
|
||||||
|
|
||||||
|
async def test_get_sale_not_found(self, admin_client: AsyncClient):
|
||||||
|
"""GET /sales/:nonexistent returns 404."""
|
||||||
|
response = await admin_client.get(f"/api/v1/sales/{uuid.uuid4()}")
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
async def test_update_sale(self, admin_client: AsyncClient, test_sale):
|
||||||
|
"""PUT /sales/:id updates sale fields."""
|
||||||
|
response = await admin_client.put(
|
||||||
|
f"/api/v1/sales/{test_sale.id}",
|
||||||
|
json={
|
||||||
|
"sale_price": "42000.00",
|
||||||
|
"status": "completed",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["sale_price"] == "42000.00"
|
||||||
|
assert data["status"] == "completed"
|
||||||
|
|
||||||
|
async def test_delete_sale_cancels_and_restores_vehicle(
|
||||||
|
self, admin_client: AsyncClient, test_sale, db_session
|
||||||
|
):
|
||||||
|
"""DELETE /sales/:id cancels sale and restores vehicle status to 'available'."""
|
||||||
|
# First set vehicle to sold (as create_sale would)
|
||||||
|
vehicle = await db_session.get(Vehicle, test_sale.vehicle_id)
|
||||||
|
vehicle.availability = "sold"
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
response = await admin_client.delete(f"/api/v1/sales/{test_sale.id}")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["status"] == "cancelled"
|
||||||
|
|
||||||
|
# Verify vehicle status restored
|
||||||
|
await db_session.refresh(vehicle)
|
||||||
|
assert vehicle.availability == "available"
|
||||||
|
|
||||||
|
async def test_list_sales_with_filter(self, admin_client: AsyncClient, test_sale):
|
||||||
|
"""GET /sales?status=completed filters by status."""
|
||||||
|
response = await admin_client.get("/api/v1/sales/?status=completed")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["total"] >= 1
|
||||||
|
for item in data["items"]:
|
||||||
|
assert item["status"] == "completed"
|
||||||
|
|
||||||
|
async def test_list_sales_with_date_filter(
|
||||||
|
self, admin_client: AsyncClient, test_sale
|
||||||
|
):
|
||||||
|
"""GET /sales?date_from=2025-01-01&date_to=2025-12-31 filters by date range."""
|
||||||
|
response = await admin_client.get(
|
||||||
|
"/api/v1/sales/?date_from=2025-01-01&date_to=2025-12-31"
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["total"] >= 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestSaleVehicleStatus:
|
||||||
|
"""Test vehicle status changes on sale create/delete."""
|
||||||
|
|
||||||
|
async def test_create_sale_sets_vehicle_sold(
|
||||||
|
self, admin_client: AsyncClient, test_vehicle, test_buyer, db_session
|
||||||
|
):
|
||||||
|
"""Creating a sale sets vehicle availability to 'sold'."""
|
||||||
|
response = await admin_client.post(
|
||||||
|
"/api/v1/sales/",
|
||||||
|
json={
|
||||||
|
"vehicle_id": str(test_vehicle.id),
|
||||||
|
"buyer_contact_id": str(test_buyer.id),
|
||||||
|
"sale_price": "45000.00",
|
||||||
|
"sale_date": "2025-01-15",
|
||||||
|
"status": "draft",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
|
||||||
|
# Verify vehicle status
|
||||||
|
await db_session.refresh(test_vehicle)
|
||||||
|
assert test_vehicle.availability == "sold"
|
||||||
|
|
||||||
|
async def test_cancel_sale_restores_vehicle_available(
|
||||||
|
self, admin_client: AsyncClient, test_sale, db_session
|
||||||
|
):
|
||||||
|
"""Cancelling a sale restores vehicle availability to 'available'."""
|
||||||
|
# Set vehicle to sold first
|
||||||
|
vehicle = await db_session.get(Vehicle, test_sale.vehicle_id)
|
||||||
|
vehicle.availability = "sold"
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
response = await admin_client.delete(f"/api/v1/sales/{test_sale.id}")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
await db_session.refresh(vehicle)
|
||||||
|
assert vehicle.availability == "available"
|
||||||
|
|
||||||
|
|
||||||
|
class TestContractPDF:
|
||||||
|
"""Test contract PDF generation and GwG logic."""
|
||||||
|
|
||||||
|
async def test_regenerate_contract(self, admin_client: AsyncClient, test_sale):
|
||||||
|
"""POST /sales/:id/contract regenerates contract PDF."""
|
||||||
|
with patch("app.services.sale_service.generate_contract_pdf") as mock_pdf:
|
||||||
|
mock_pdf.return_value = "/tmp/contracts/test_contract.pdf"
|
||||||
|
response = await admin_client.post(f"/api/v1/sales/{test_sale.id}/contract")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["sale_id"] == str(test_sale.id)
|
||||||
|
assert "contract_pdf_path" in data
|
||||||
|
|
||||||
|
async def test_download_contract_not_found(
|
||||||
|
self, admin_client: AsyncClient, test_sale
|
||||||
|
):
|
||||||
|
"""GET /sales/:id/contract returns 404 if no PDF generated."""
|
||||||
|
response = await admin_client.get(f"/api/v1/sales/{test_sale.id}/contract")
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
async def test_download_contract_pdf(
|
||||||
|
self, admin_client: AsyncClient, test_sale, db_session
|
||||||
|
):
|
||||||
|
"""GET /sales/:id/contract returns PDF content."""
|
||||||
|
# Create a fake PDF file
|
||||||
|
import os
|
||||||
|
|
||||||
|
os.makedirs("/tmp/contracts", exist_ok=True)
|
||||||
|
pdf_path = f"/tmp/contracts/contract_{test_sale.id}.pdf"
|
||||||
|
with open(pdf_path, "wb") as f:
|
||||||
|
f.write(b"%PDF-1.4 fake pdf content")
|
||||||
|
|
||||||
|
test_sale.contract_pdf_path = pdf_path
|
||||||
|
db_session.add(test_sale)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
response = await admin_client.get(f"/api/v1/sales/{test_sale.id}/contract")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.headers["content-type"] == "application/pdf"
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
if os.path.exists(pdf_path):
|
||||||
|
os.remove(pdf_path)
|
||||||
|
|
||||||
|
def test_gwg_clause_in_contract_html(self, test_sale, test_vehicle, test_buyer):
|
||||||
|
"""GwG clause appears in contract HTML when is_gwg=true and price <= 800."""
|
||||||
|
test_sale.is_gwg = True
|
||||||
|
test_sale.sale_price = Decimal("500.00")
|
||||||
|
test_sale.vehicle = test_vehicle
|
||||||
|
test_sale.buyer = test_buyer
|
||||||
|
|
||||||
|
html = build_contract_html(test_sale)
|
||||||
|
assert "Geringwertige Wirtschaftsgüter" in html
|
||||||
|
assert "§ 6 Abs. 2 EStG" in html
|
||||||
|
|
||||||
|
def test_gwg_clause_not_in_contract_when_price_too_high(
|
||||||
|
self, test_sale, test_vehicle, test_buyer
|
||||||
|
):
|
||||||
|
"""GwG clause does NOT appear when price > 800 even if is_gwg=true."""
|
||||||
|
test_sale.is_gwg = True
|
||||||
|
test_sale.sale_price = Decimal("5000.00")
|
||||||
|
test_sale.vehicle = test_vehicle
|
||||||
|
test_sale.buyer = test_buyer
|
||||||
|
|
||||||
|
html = build_contract_html(test_sale)
|
||||||
|
assert "Geringwertige Wirtschaftsgüter" not in html
|
||||||
|
|
||||||
|
def test_gwg_clause_not_in_contract_when_not_gwg(
|
||||||
|
self, test_sale, test_vehicle, test_buyer
|
||||||
|
):
|
||||||
|
"""GwG clause does NOT appear when is_gwg=false."""
|
||||||
|
test_sale.is_gwg = False
|
||||||
|
test_sale.sale_price = Decimal("500.00")
|
||||||
|
test_sale.vehicle = test_vehicle
|
||||||
|
test_sale.buyer = test_buyer
|
||||||
|
|
||||||
|
html = build_contract_html(test_sale)
|
||||||
|
assert "Geringwertige Wirtschaftsgüter" not in html
|
||||||
|
|
||||||
|
def test_contract_html_contains_ust_id_field(
|
||||||
|
self, test_sale, test_vehicle, test_buyer
|
||||||
|
):
|
||||||
|
"""Contract HTML contains USt-IdNr. field."""
|
||||||
|
test_sale.vehicle = test_vehicle
|
||||||
|
test_sale.buyer = test_buyer
|
||||||
|
|
||||||
|
html = build_contract_html(test_sale)
|
||||||
|
assert "USt-IdNr" in html
|
||||||
|
assert "DE123456789" in html
|
||||||
|
|
||||||
|
|
||||||
|
class TestUstIdVerification:
|
||||||
|
"""Test USt-IdNr. verification endpoint."""
|
||||||
|
|
||||||
|
async def test_verify_ust_id_disabled(self, admin_client: AsyncClient, test_sale):
|
||||||
|
"""POST /sales/:id/verify-ust-id returns not verified when BZSt API disabled."""
|
||||||
|
response = await admin_client.post(
|
||||||
|
f"/api/v1/sales/{test_sale.id}/verify-ust-id"
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["verified"] is False
|
||||||
|
assert data["message"] == "BZSt API not available"
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
"""Tests for users endpoints: list, create, update, delete (admin only)."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from httpx import AsyncClient
|
||||||
|
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_users_as_admin(admin_client: AsyncClient, admin_user: User):
|
||||||
|
"""GET /api/v1/users as admin returns 200 + paginated list."""
|
||||||
|
response = await admin_client.get("/api/v1/users/")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert "items" in data
|
||||||
|
assert "total" in data
|
||||||
|
assert "page" in data
|
||||||
|
assert "page_size" in data
|
||||||
|
assert data["total"] >= 1
|
||||||
|
assert data["page"] == 1
|
||||||
|
assert data["page_size"] == 20
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_users_as_non_admin(verkaeufer_client: AsyncClient):
|
||||||
|
"""GET /api/v1/users as verkaeufer returns 403."""
|
||||||
|
response = await verkaeufer_client.get("/api/v1/users/")
|
||||||
|
assert response.status_code == 403
|
||||||
|
data = response.json()
|
||||||
|
assert "error" in data["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_users_without_auth(client: AsyncClient):
|
||||||
|
"""GET /api/v1/users without token returns 401."""
|
||||||
|
response = await client.get("/api/v1/users/")
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_users_pagination(admin_client: AsyncClient, admin_user: User):
|
||||||
|
"""GET /api/v1/users?page=1&page_size=5 returns correct pagination."""
|
||||||
|
response = await admin_client.get("/api/v1/users/?page=1&page_size=5")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["page"] == 1
|
||||||
|
assert data["page_size"] == 5
|
||||||
|
assert len(data["items"]) <= 5
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_user_as_admin(admin_client: AsyncClient):
|
||||||
|
"""POST /api/v1/users as admin creates a new user, returns 201."""
|
||||||
|
response = await admin_client.post(
|
||||||
|
"/api/v1/users/",
|
||||||
|
json={
|
||||||
|
"email": "newuser@test.com",
|
||||||
|
"password": "NewUser123!",
|
||||||
|
"full_name": "New User",
|
||||||
|
"role": "verkaeufer",
|
||||||
|
"language": "de",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
data = response.json()
|
||||||
|
assert data["email"] == "newuser@test.com"
|
||||||
|
assert data["full_name"] == "New User"
|
||||||
|
assert data["role"] == "verkaeufer"
|
||||||
|
assert data["is_active"] is True
|
||||||
|
assert "password_hash" not in data
|
||||||
|
assert "password" not in data
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_user_as_non_admin(verkaeufer_client: AsyncClient):
|
||||||
|
"""POST /api/v1/users as verkaeufer returns 403."""
|
||||||
|
response = await verkaeufer_client.post(
|
||||||
|
"/api/v1/users/",
|
||||||
|
json={
|
||||||
|
"email": "forbidden@test.com",
|
||||||
|
"password": "Forbidden123!",
|
||||||
|
"full_name": "Forbidden",
|
||||||
|
"role": "admin",
|
||||||
|
"language": "de",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_user_duplicate_email(admin_client: AsyncClient, admin_user: User):
|
||||||
|
"""POST /api/v1/users with existing email returns 409."""
|
||||||
|
response = await admin_client.post(
|
||||||
|
"/api/v1/users/",
|
||||||
|
json={
|
||||||
|
"email": "admin@test.com",
|
||||||
|
"password": "SomePassword123!",
|
||||||
|
"full_name": "Duplicate",
|
||||||
|
"role": "verkaeufer",
|
||||||
|
"language": "de",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_user_short_password(admin_client: AsyncClient):
|
||||||
|
"""POST /api/v1/users with password < 8 chars returns 422."""
|
||||||
|
response = await admin_client.post(
|
||||||
|
"/api/v1/users/",
|
||||||
|
json={
|
||||||
|
"email": "shortpw@test.com",
|
||||||
|
"password": "short",
|
||||||
|
"full_name": "Short PW",
|
||||||
|
"role": "verkaeufer",
|
||||||
|
"language": "de",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_user_as_admin(
|
||||||
|
admin_client: AsyncClient, admin_user: User, verkaeufer_user: User
|
||||||
|
):
|
||||||
|
"""PUT /api/v1/users/:id as admin updates user fields, returns 200."""
|
||||||
|
response = await admin_client.put(
|
||||||
|
f"/api/v1/users/{verkaeufer_user.id}",
|
||||||
|
json={"full_name": "Updated Name", "language": "de"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["full_name"] == "Updated Name"
|
||||||
|
assert data["language"] == "de"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_user_nonexistent(admin_client: AsyncClient):
|
||||||
|
"""PUT /api/v1/users/:id with unknown id returns 404."""
|
||||||
|
fake_id = uuid.uuid4()
|
||||||
|
response = await admin_client.put(
|
||||||
|
f"/api/v1/users/{fake_id}",
|
||||||
|
json={"full_name": "Nobody"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_user_soft_deactivate(
|
||||||
|
admin_client: AsyncClient, verkaeufer_user: User
|
||||||
|
):
|
||||||
|
"""DELETE /api/v1/users/:id soft-deletes (is_active=false), returns 200."""
|
||||||
|
response = await admin_client.delete(f"/api/v1/users/{verkaeufer_user.id}")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["is_active"] is False
|
||||||
|
assert data["id"] == str(verkaeufer_user.id)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_user_nonexistent(admin_client: AsyncClient):
|
||||||
|
"""DELETE /api/v1/users/:id with unknown id returns 404."""
|
||||||
|
fake_id = uuid.uuid4()
|
||||||
|
response = await admin_client.delete(f"/api/v1/users/{fake_id}")
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_user_as_non_admin(
|
||||||
|
verkaeufer_client: AsyncClient, admin_user: User
|
||||||
|
):
|
||||||
|
"""DELETE /api/v1/users/:id as verkaeufer returns 403."""
|
||||||
|
response = await verkaeufer_client.delete(f"/api/v1/users/{admin_user.id}")
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_user_response_excludes_password_hash(
|
||||||
|
admin_client: AsyncClient, admin_user: User
|
||||||
|
):
|
||||||
|
"""User response never includes password_hash or password fields."""
|
||||||
|
response = await admin_client.get("/api/v1/users/")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
for item in data["items"]:
|
||||||
|
assert "password_hash" not in item
|
||||||
|
assert "password" not in item
|
||||||
@@ -0,0 +1,391 @@
|
|||||||
|
"""Tests for vehicle CRUD endpoints and mobile.de integration."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def sample_vehicle_data():
|
||||||
|
"""Valid vehicle data for creation."""
|
||||||
|
return {
|
||||||
|
"make": "Mercedes-Benz",
|
||||||
|
"model": "Actros",
|
||||||
|
"fin": "WDB9066351L123456",
|
||||||
|
"year": 2020,
|
||||||
|
"first_registration": "2020-03-15",
|
||||||
|
"power_kw": 300,
|
||||||
|
"fuel_type": "Diesel",
|
||||||
|
"transmission": "Manual",
|
||||||
|
"color": "White",
|
||||||
|
"condition": "used",
|
||||||
|
"location": "Berlin",
|
||||||
|
"availability": "available",
|
||||||
|
"price": 45000.00,
|
||||||
|
"vehicle_type": "lkw",
|
||||||
|
"lkw_type": "sattelzugmaschine",
|
||||||
|
"mileage_km": 120000,
|
||||||
|
"description": "Well maintained truck",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def created_vehicle(admin_client, sample_vehicle_data):
|
||||||
|
"""Create a vehicle via API and return the response."""
|
||||||
|
response = await admin_client.post("/api/v1/vehicles/", json=sample_vehicle_data)
|
||||||
|
assert response.status_code == 201, response.text
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
|
class TestVehicleList:
|
||||||
|
"""GET /api/v1/vehicles tests."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_vehicles_returns_200_with_pagination(
|
||||||
|
self, admin_client, created_vehicle
|
||||||
|
):
|
||||||
|
"""GET /api/v1/vehicles returns 200 with paginated list."""
|
||||||
|
response = await admin_client.get("/api/v1/vehicles/?page=1&page_size=20")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert "items" in data
|
||||||
|
assert "total" in data
|
||||||
|
assert "page" in data
|
||||||
|
assert "page_size" in data
|
||||||
|
assert data["page"] == 1
|
||||||
|
assert data["page_size"] == 20
|
||||||
|
assert data["total"] >= 1
|
||||||
|
assert len(data["items"]) >= 1
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_vehicles_filter_by_type(self, admin_client, created_vehicle):
|
||||||
|
"""GET /api/v1/vehicles?type=lkw returns filtered results."""
|
||||||
|
response = await admin_client.get("/api/v1/vehicles/?type=lkw")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
for item in data["items"]:
|
||||||
|
assert item["vehicle_type"] == "lkw"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_vehicles_filter_by_availability(
|
||||||
|
self, admin_client, created_vehicle
|
||||||
|
):
|
||||||
|
"""GET /api/v1/vehicles?availability=available returns filtered results."""
|
||||||
|
response = await admin_client.get("/api/v1/vehicles/?availability=available")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
for item in data["items"]:
|
||||||
|
assert item["availability"] == "available"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_vehicles_sort_descending(self, admin_client, created_vehicle):
|
||||||
|
"""GET /api/v1/vehicles?sort=-created_at returns sorted results."""
|
||||||
|
response = await admin_client.get("/api/v1/vehicles/?sort=-created_at")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
if len(data["items"]) >= 2:
|
||||||
|
assert data["items"][0]["created_at"] >= data["items"][1]["created_at"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_vehicles_filter_by_price_range(
|
||||||
|
self, admin_client, created_vehicle
|
||||||
|
):
|
||||||
|
"""GET /api/v1/vehicles?min_price=40000&max_price=50000 returns filtered results."""
|
||||||
|
response = await admin_client.get(
|
||||||
|
"/api/v1/vehicles/?min_price=40000&max_price=50000"
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
for item in data["items"]:
|
||||||
|
assert float(item["price"]) >= 40000
|
||||||
|
assert float(item["price"]) <= 50000
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_vehicles_search(self, admin_client, created_vehicle):
|
||||||
|
"""GET /api/v1/vehicles?search=Mercedes returns matching results."""
|
||||||
|
response = await admin_client.get("/api/v1/vehicles/?search=Mercedes")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
for item in data["items"]:
|
||||||
|
assert "Mercedes" in item["make"] or "Mercedes" in item["model"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_vehicles_requires_auth(self, client):
|
||||||
|
"""GET /api/v1/vehicles without auth returns 401."""
|
||||||
|
response = await client.get("/api/v1/vehicles/")
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
class TestVehicleCreate:
|
||||||
|
"""POST /api/v1/vehicles tests."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_vehicle_returns_201(self, admin_client, sample_vehicle_data):
|
||||||
|
"""POST /api/v1/vehicles with valid data returns 201."""
|
||||||
|
response = await admin_client.post(
|
||||||
|
"/api/v1/vehicles/", json=sample_vehicle_data
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
data = response.json()
|
||||||
|
assert data["make"] == sample_vehicle_data["make"]
|
||||||
|
assert data["model"] == sample_vehicle_data["model"]
|
||||||
|
assert data["fin"] == sample_vehicle_data["fin"]
|
||||||
|
assert data["vehicle_type"] == "lkw"
|
||||||
|
assert data["id"] is not None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_vehicle_missing_make_returns_422(
|
||||||
|
self, admin_client, sample_vehicle_data
|
||||||
|
):
|
||||||
|
"""POST /api/v1/vehicles without make returns 422."""
|
||||||
|
del sample_vehicle_data["make"]
|
||||||
|
response = await admin_client.post(
|
||||||
|
"/api/v1/vehicles/", json=sample_vehicle_data
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_vehicle_missing_fin_returns_422(
|
||||||
|
self, admin_client, sample_vehicle_data
|
||||||
|
):
|
||||||
|
"""POST /api/v1/vehicles without fin returns 422."""
|
||||||
|
del sample_vehicle_data["fin"]
|
||||||
|
response = await admin_client.post(
|
||||||
|
"/api/v1/vehicles/", json=sample_vehicle_data
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_vehicle_short_fin_returns_422(
|
||||||
|
self, admin_client, sample_vehicle_data
|
||||||
|
):
|
||||||
|
"""POST /api/v1/vehicles with short FIN returns 422."""
|
||||||
|
sample_vehicle_data["fin"] = "SHORT"
|
||||||
|
response = await admin_client.post(
|
||||||
|
"/api/v1/vehicles/", json=sample_vehicle_data
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_vehicle_duplicate_fin_returns_409(
|
||||||
|
self, admin_client, sample_vehicle_data, created_vehicle
|
||||||
|
):
|
||||||
|
"""POST /api/v1/vehicles with duplicate FIN returns 409."""
|
||||||
|
response = await admin_client.post(
|
||||||
|
"/api/v1/vehicles/", json=sample_vehicle_data
|
||||||
|
)
|
||||||
|
assert response.status_code == 409
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_vehicle_auto_computes_power_hp(
|
||||||
|
self, admin_client, sample_vehicle_data
|
||||||
|
):
|
||||||
|
"""POST /api/v1/vehicles auto-computes power_hp from power_kw."""
|
||||||
|
sample_vehicle_data["power_kw"] = 100
|
||||||
|
sample_vehicle_data.pop("power_hp", None)
|
||||||
|
response = await admin_client.post(
|
||||||
|
"/api/v1/vehicles/", json=sample_vehicle_data
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
data = response.json()
|
||||||
|
assert data["power_hp"] == 136 # 100 * 1.35962 ≈ 136
|
||||||
|
|
||||||
|
|
||||||
|
class TestVehicleDetail:
|
||||||
|
"""GET /api/v1/vehicles/:id tests."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_vehicle_returns_200(self, admin_client, created_vehicle):
|
||||||
|
"""GET /api/v1/vehicles/:id returns 200 with detail."""
|
||||||
|
vehicle_id = created_vehicle["id"]
|
||||||
|
response = await admin_client.get(f"/api/v1/vehicles/{vehicle_id}")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["id"] == vehicle_id
|
||||||
|
assert data["make"] == created_vehicle["make"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_vehicle_nonexistent_returns_404(self, admin_client):
|
||||||
|
"""GET /api/v1/vehicles/:id with nonexistent ID returns 404."""
|
||||||
|
fake_id = str(uuid.uuid4())
|
||||||
|
response = await admin_client.get(f"/api/v1/vehicles/{fake_id}")
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
class TestVehicleUpdate:
|
||||||
|
"""PUT /api/v1/vehicles/:id tests."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_vehicle_returns_200(self, admin_client, created_vehicle):
|
||||||
|
"""PUT /api/v1/vehicles/:id returns 200 with updated data."""
|
||||||
|
vehicle_id = created_vehicle["id"]
|
||||||
|
response = await admin_client.put(
|
||||||
|
f"/api/v1/vehicles/{vehicle_id}",
|
||||||
|
json={"price": "42000.00", "availability": "reserved"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert float(data["price"]) == 42000.00
|
||||||
|
assert data["availability"] == "reserved"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_vehicle_nonexistent_returns_404(self, admin_client):
|
||||||
|
"""PUT /api/v1/vehicles/:id with nonexistent ID returns 404."""
|
||||||
|
fake_id = str(uuid.uuid4())
|
||||||
|
response = await admin_client.put(
|
||||||
|
f"/api/v1/vehicles/{fake_id}",
|
||||||
|
json={"price": "42000.00"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_vehicle_no_fields_returns_400(
|
||||||
|
self, admin_client, created_vehicle
|
||||||
|
):
|
||||||
|
"""PUT /api/v1/vehicles/:id with no fields returns 400."""
|
||||||
|
vehicle_id = created_vehicle["id"]
|
||||||
|
response = await admin_client.put(
|
||||||
|
f"/api/v1/vehicles/{vehicle_id}",
|
||||||
|
json={},
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
class TestVehicleDelete:
|
||||||
|
"""DELETE /api/v1/vehicles/:id tests."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_vehicle_returns_200_with_deleted_at(
|
||||||
|
self, admin_client, created_vehicle
|
||||||
|
):
|
||||||
|
"""DELETE /api/v1/vehicles/:id returns 200 and sets deleted_at."""
|
||||||
|
vehicle_id = created_vehicle["id"]
|
||||||
|
response = await admin_client.delete(f"/api/v1/vehicles/{vehicle_id}")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["deleted_at"] is not None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_vehicle_nonexistent_returns_404(self, admin_client):
|
||||||
|
"""DELETE /api/v1/vehicles/:id with nonexistent ID returns 404."""
|
||||||
|
fake_id = str(uuid.uuid4())
|
||||||
|
response = await admin_client.delete(f"/api/v1/vehicles/{fake_id}")
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_deleted_vehicle_not_in_list(self, admin_client, created_vehicle):
|
||||||
|
"""After soft-delete, vehicle does not appear in list."""
|
||||||
|
vehicle_id = created_vehicle["id"]
|
||||||
|
await admin_client.delete(f"/api/v1/vehicles/{vehicle_id}")
|
||||||
|
response = await admin_client.get("/api/v1/vehicles/")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
for item in data["items"]:
|
||||||
|
assert item["id"] != vehicle_id
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_deleted_vehicle_returns_404_on_detail(
|
||||||
|
self, admin_client, created_vehicle
|
||||||
|
):
|
||||||
|
"""After soft-delete, GET /api/v1/vehicles/:id returns 404."""
|
||||||
|
vehicle_id = created_vehicle["id"]
|
||||||
|
await admin_client.delete(f"/api/v1/vehicles/{vehicle_id}")
|
||||||
|
response = await admin_client.get(f"/api/v1/vehicles/{vehicle_id}")
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
class TestMobileDePush:
|
||||||
|
"""POST /api/v1/vehicles/:id/mobile-de/push tests."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_push_returns_202(self, admin_client, created_vehicle):
|
||||||
|
"""POST /api/v1/vehicles/:id/mobile-de/push returns 202."""
|
||||||
|
vehicle_id = created_vehicle["id"]
|
||||||
|
with patch(
|
||||||
|
"app.services.mobilede_service.httpx.AsyncClient"
|
||||||
|
) as mock_client_cls:
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = 201
|
||||||
|
mock_response.json.return_value = {"id": "mobile-de-ad-123"}
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.post = AsyncMock(return_value=mock_response)
|
||||||
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||||
|
mock_client.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
mock_client_cls.return_value = mock_client
|
||||||
|
|
||||||
|
response = await admin_client.post(
|
||||||
|
f"/api/v1/vehicles/{vehicle_id}/mobile-de/push"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 202
|
||||||
|
data = response.json()
|
||||||
|
assert data["message"] == "Push queued"
|
||||||
|
assert data["vehicle_id"] == vehicle_id
|
||||||
|
assert data["listing_id"] is not None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_push_nonexistent_vehicle_returns_404(self, admin_client):
|
||||||
|
"""POST /api/v1/vehicles/:id/mobile-de/push with nonexistent ID returns 404."""
|
||||||
|
fake_id = str(uuid.uuid4())
|
||||||
|
response = await admin_client.post(f"/api/v1/vehicles/{fake_id}/mobile-de/push")
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
class TestMobileDeStatus:
|
||||||
|
"""GET /api/v1/vehicles/:id/mobile-de/status tests."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_status_returns_200_with_no_listing(
|
||||||
|
self, admin_client, created_vehicle
|
||||||
|
):
|
||||||
|
"""GET /api/v1/vehicles/:id/mobile-de/status returns 200 with pending status when no listing exists."""
|
||||||
|
vehicle_id = created_vehicle["id"]
|
||||||
|
response = await admin_client.get(
|
||||||
|
f"/api/v1/vehicles/{vehicle_id}/mobile-de/status"
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["synced"] is False
|
||||||
|
assert data["sync_status"] == "pending"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_status_returns_200_with_synced_listing(
|
||||||
|
self, admin_client, created_vehicle
|
||||||
|
):
|
||||||
|
"""GET /api/v1/vehicles/:id/mobile-de/status returns 200 with sync info after push."""
|
||||||
|
vehicle_id = created_vehicle["id"]
|
||||||
|
with patch(
|
||||||
|
"app.services.mobilede_service.httpx.AsyncClient"
|
||||||
|
) as mock_client_cls:
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = 201
|
||||||
|
mock_response.json.return_value = {"id": "mobile-de-ad-456"}
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.post = AsyncMock(return_value=mock_response)
|
||||||
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||||
|
mock_client.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
mock_client_cls.return_value = mock_client
|
||||||
|
|
||||||
|
await admin_client.post(f"/api/v1/vehicles/{vehicle_id}/mobile-de/push")
|
||||||
|
|
||||||
|
response = await admin_client.get(
|
||||||
|
f"/api/v1/vehicles/{vehicle_id}/mobile-de/status"
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["synced"] is True
|
||||||
|
assert data["ad_id"] == "mobile-de-ad-456"
|
||||||
|
assert data["synced_at"] is not None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_status_nonexistent_vehicle_returns_404(self, admin_client):
|
||||||
|
"""GET /api/v1/vehicles/:id/mobile-de/status with nonexistent ID returns 404."""
|
||||||
|
fake_id = str(uuid.uuid4())
|
||||||
|
response = await admin_client.get(
|
||||||
|
f"/api/v1/vehicles/{fake_id}/mobile-de/status"
|
||||||
|
)
|
||||||
|
assert response.status_code == 404
|
||||||
@@ -0,0 +1,441 @@
|
|||||||
|
"""Additional tests for vehicle_service and router to reach 80% coverage."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
|
||||||
|
from app.models.vehicle import Vehicle
|
||||||
|
from app.services import vehicle_service
|
||||||
|
|
||||||
|
|
||||||
|
def _make_vehicle_data(**overrides) -> dict:
|
||||||
|
"""Return valid vehicle creation data with optional overrides."""
|
||||||
|
defaults = {
|
||||||
|
"make": "Volvo",
|
||||||
|
"model": "FH16",
|
||||||
|
"fin": "WDB9066351L123456",
|
||||||
|
"year": 2021,
|
||||||
|
"first_registration": date(2021, 6, 1),
|
||||||
|
"power_kw": 500,
|
||||||
|
"power_hp": 680,
|
||||||
|
"fuel_type": "Diesel",
|
||||||
|
"transmission": "Automatic",
|
||||||
|
"color": "Red",
|
||||||
|
"condition": "used",
|
||||||
|
"location": "Hamburg",
|
||||||
|
"availability": "available",
|
||||||
|
"price": Decimal("85000.00"),
|
||||||
|
"vehicle_type": "lkw",
|
||||||
|
"lkw_type": "sattelzugmaschine",
|
||||||
|
"mileage_km": 80000,
|
||||||
|
"description": "Heavy duty truck",
|
||||||
|
}
|
||||||
|
defaults.update(overrides)
|
||||||
|
return defaults
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def sample_vehicle_data():
|
||||||
|
"""Valid vehicle data for creation."""
|
||||||
|
return {
|
||||||
|
"make": "Mercedes-Benz",
|
||||||
|
"model": "Actros",
|
||||||
|
"fin": "WDB9066351L123456",
|
||||||
|
"year": 2020,
|
||||||
|
"first_registration": "2020-03-15",
|
||||||
|
"power_kw": 300,
|
||||||
|
"fuel_type": "Diesel",
|
||||||
|
"transmission": "Manual",
|
||||||
|
"color": "White",
|
||||||
|
"condition": "used",
|
||||||
|
"location": "Berlin",
|
||||||
|
"availability": "available",
|
||||||
|
"price": 45000.00,
|
||||||
|
"vehicle_type": "lkw",
|
||||||
|
"lkw_type": "sattelzugmaschine",
|
||||||
|
"mileage_km": 120000,
|
||||||
|
"description": "Well maintained truck",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def created_vehicle(admin_client, sample_vehicle_data):
|
||||||
|
"""Create a vehicle via API and return the response."""
|
||||||
|
response = await admin_client.post("/api/v1/vehicles/", json=sample_vehicle_data)
|
||||||
|
assert response.status_code == 201, response.text
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
|
class TestVehicleServiceDirect:
|
||||||
|
"""Direct service-layer tests for vehicle_service."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_vehicles_empty(self, db_session):
|
||||||
|
"""list_vehicles returns empty list when no vehicles exist."""
|
||||||
|
vehicles, total = await vehicle_service.list_vehicles(db_session)
|
||||||
|
assert vehicles == []
|
||||||
|
assert total == 0
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_vehicles_pagination(self, db_session):
|
||||||
|
"""list_vehicles respects page and page_size."""
|
||||||
|
fins = [
|
||||||
|
"WDB9066351L123450",
|
||||||
|
"WDB9066351L123451",
|
||||||
|
"WDB9066351L123452",
|
||||||
|
"WDB9066351L123453",
|
||||||
|
"WDB9066351L123454",
|
||||||
|
]
|
||||||
|
for fin in fins:
|
||||||
|
data = _make_vehicle_data(fin=fin)
|
||||||
|
vehicle = Vehicle(**data)
|
||||||
|
db_session.add(vehicle)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
vehicles, total = await vehicle_service.list_vehicles(
|
||||||
|
db_session, page=1, page_size=2
|
||||||
|
)
|
||||||
|
assert len(vehicles) == 2
|
||||||
|
assert total == 5
|
||||||
|
|
||||||
|
vehicles_page2, _ = await vehicle_service.list_vehicles(
|
||||||
|
db_session, page=2, page_size=2
|
||||||
|
)
|
||||||
|
assert len(vehicles_page2) == 2
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_vehicles_sort_ascending(self, db_session):
|
||||||
|
"""list_vehicles sorts ascending by make."""
|
||||||
|
makes_fins = [
|
||||||
|
("Zebra", "WDB9066351L000001"),
|
||||||
|
("Alpha", "WDB9066351L000002"),
|
||||||
|
("Mike", "WDB9066351L000003"),
|
||||||
|
]
|
||||||
|
for make, fin in makes_fins:
|
||||||
|
data = _make_vehicle_data(make=make, fin=fin)
|
||||||
|
vehicle = Vehicle(**data)
|
||||||
|
db_session.add(vehicle)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
vehicles, _ = await vehicle_service.list_vehicles(db_session, sort="make")
|
||||||
|
makes = [v.make for v in vehicles]
|
||||||
|
assert makes == sorted(makes)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_vehicles_sort_invalid_field_defaults_to_created_at(
|
||||||
|
self, db_session
|
||||||
|
):
|
||||||
|
"""list_vehicles falls back to created_at sort for invalid field."""
|
||||||
|
data = _make_vehicle_data()
|
||||||
|
vehicle = Vehicle(**data)
|
||||||
|
db_session.add(vehicle)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
vehicles, total = await vehicle_service.list_vehicles(
|
||||||
|
db_session, sort="invalid_field"
|
||||||
|
)
|
||||||
|
assert total == 1
|
||||||
|
assert len(vehicles) == 1
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_vehicles_filter_by_min_price_only(self, db_session):
|
||||||
|
"""list_vehicles filters by min_price only."""
|
||||||
|
data1 = _make_vehicle_data(fin="WDB9066351L00000A", price=Decimal("30000.00"))
|
||||||
|
data2 = _make_vehicle_data(fin="WDB9066351L00000B", price=Decimal("60000.00"))
|
||||||
|
db_session.add(Vehicle(**data1))
|
||||||
|
db_session.add(Vehicle(**data2))
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
vehicles, total = await vehicle_service.list_vehicles(
|
||||||
|
db_session, min_price=50000
|
||||||
|
)
|
||||||
|
assert total == 1
|
||||||
|
assert float(vehicles[0].price) >= 50000
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_vehicles_filter_by_max_price_only(self, db_session):
|
||||||
|
"""list_vehicles filters by max_price only."""
|
||||||
|
data1 = _make_vehicle_data(fin="WDB9066351L00000A", price=Decimal("30000.00"))
|
||||||
|
data2 = _make_vehicle_data(fin="WDB9066351L00000B", price=Decimal("60000.00"))
|
||||||
|
db_session.add(Vehicle(**data1))
|
||||||
|
db_session.add(Vehicle(**data2))
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
vehicles, total = await vehicle_service.list_vehicles(
|
||||||
|
db_session, max_price=40000
|
||||||
|
)
|
||||||
|
assert total == 1
|
||||||
|
assert float(vehicles[0].price) <= 40000
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_vehicles_search_by_fin(self, db_session):
|
||||||
|
"""list_vehicles search matches FIN."""
|
||||||
|
data = _make_vehicle_data(fin="WDB9066351L123456")
|
||||||
|
db_session.add(Vehicle(**data))
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
vehicles, total = await vehicle_service.list_vehicles(
|
||||||
|
db_session, search="123456"
|
||||||
|
)
|
||||||
|
assert total == 1
|
||||||
|
assert "123456" in vehicles[0].fin
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_vehicles_search_by_location(self, db_session):
|
||||||
|
"""list_vehicles search matches location."""
|
||||||
|
data = _make_vehicle_data(location="Munich")
|
||||||
|
db_session.add(Vehicle(**data))
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
vehicles, total = await vehicle_service.list_vehicles(
|
||||||
|
db_session, search="Munich"
|
||||||
|
)
|
||||||
|
assert total == 1
|
||||||
|
assert vehicles[0].location == "Munich"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_vehicle_by_fin(self, db_session):
|
||||||
|
"""get_vehicle_by_fin returns vehicle by FIN."""
|
||||||
|
data = _make_vehicle_data(fin="WDB9066351L999999")
|
||||||
|
vehicle = Vehicle(**data)
|
||||||
|
db_session.add(vehicle)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
result = await vehicle_service.get_vehicle_by_fin(
|
||||||
|
db_session, "WDB9066351L999999"
|
||||||
|
)
|
||||||
|
assert result is not None
|
||||||
|
assert result.fin == "WDB9066351L999999"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_vehicle_by_fin_not_found(self, db_session):
|
||||||
|
"""get_vehicle_by_fin returns None for nonexistent FIN."""
|
||||||
|
result = await vehicle_service.get_vehicle_by_fin(
|
||||||
|
db_session, "NONEXISTENT1234567"
|
||||||
|
)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_vehicle_success(self, db_session):
|
||||||
|
"""create_vehicle creates and returns a vehicle."""
|
||||||
|
data = _make_vehicle_data()
|
||||||
|
vehicle = await vehicle_service.create_vehicle(db_session, data)
|
||||||
|
assert vehicle.id is not None
|
||||||
|
assert vehicle.make == "Volvo"
|
||||||
|
assert vehicle.fin == "WDB9066351L123456"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_vehicle_duplicate_fin_raises(self, db_session):
|
||||||
|
"""create_vehicle raises ValueError for duplicate FIN."""
|
||||||
|
data = _make_vehicle_data()
|
||||||
|
await vehicle_service.create_vehicle(db_session, data)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="already exists"):
|
||||||
|
await vehicle_service.create_vehicle(db_session, data)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_vehicle_success(self, db_session):
|
||||||
|
"""update_vehicle updates fields and returns updated vehicle."""
|
||||||
|
data = _make_vehicle_data()
|
||||||
|
vehicle = await vehicle_service.create_vehicle(db_session, data)
|
||||||
|
|
||||||
|
updated = await vehicle_service.update_vehicle(
|
||||||
|
db_session, vehicle.id, {"make": "Scania", "price": Decimal("90000.00")}
|
||||||
|
)
|
||||||
|
assert updated is not None
|
||||||
|
assert updated.make == "Scania"
|
||||||
|
assert float(updated.price) == 90000.00
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_vehicle_not_found(self, db_session):
|
||||||
|
"""update_vehicle returns None for nonexistent ID."""
|
||||||
|
result = await vehicle_service.update_vehicle(
|
||||||
|
db_session, uuid.uuid4(), {"make": "Test"}
|
||||||
|
)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_vehicle_duplicate_fin_raises(self, db_session):
|
||||||
|
"""update_vehicle raises ValueError when updating to existing FIN."""
|
||||||
|
data1 = _make_vehicle_data(fin="WDB9066351L111111")
|
||||||
|
data2 = _make_vehicle_data(fin="WDB9066351L222222")
|
||||||
|
v1 = await vehicle_service.create_vehicle(db_session, data1)
|
||||||
|
await vehicle_service.create_vehicle(db_session, data2)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="already exists"):
|
||||||
|
await vehicle_service.update_vehicle(
|
||||||
|
db_session, v1.id, {"fin": "WDB9066351L222222"}
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_vehicle_same_fin_no_error(self, db_session):
|
||||||
|
"""update_vehicle allows setting same FIN (no change)."""
|
||||||
|
data = _make_vehicle_data(fin="WDB9066351L333333")
|
||||||
|
vehicle = await vehicle_service.create_vehicle(db_session, data)
|
||||||
|
|
||||||
|
updated = await vehicle_service.update_vehicle(
|
||||||
|
db_session, vehicle.id, {"fin": "WDB9066351L333333"}
|
||||||
|
)
|
||||||
|
assert updated is not None
|
||||||
|
assert updated.fin == "WDB9066351L333333"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_soft_delete_vehicle_success(self, db_session):
|
||||||
|
"""soft_delete_vehicle sets deleted_at."""
|
||||||
|
data = _make_vehicle_data()
|
||||||
|
vehicle = await vehicle_service.create_vehicle(db_session, data)
|
||||||
|
|
||||||
|
deleted = await vehicle_service.soft_delete_vehicle(db_session, vehicle.id)
|
||||||
|
assert deleted is not None
|
||||||
|
assert deleted.deleted_at is not None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_soft_delete_vehicle_not_found(self, db_session):
|
||||||
|
"""soft_delete_vehicle returns None for nonexistent ID."""
|
||||||
|
result = await vehicle_service.soft_delete_vehicle(db_session, uuid.uuid4())
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_vehicle_by_id_not_found(self, db_session):
|
||||||
|
"""get_vehicle_by_id returns None for nonexistent ID."""
|
||||||
|
result = await vehicle_service.get_vehicle_by_id(db_session, uuid.uuid4())
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_vehicle_by_id_excludes_deleted(self, db_session):
|
||||||
|
"""get_vehicle_by_id returns None for soft-deleted vehicle."""
|
||||||
|
data = _make_vehicle_data()
|
||||||
|
vehicle = await vehicle_service.create_vehicle(db_session, data)
|
||||||
|
await vehicle_service.soft_delete_vehicle(db_session, vehicle.id)
|
||||||
|
|
||||||
|
result = await vehicle_service.get_vehicle_by_id(db_session, vehicle.id)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestRouterAdditionalPaths:
|
||||||
|
"""Additional router tests for error paths and edge cases."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_vehicles_with_all_filters(self, admin_client, created_vehicle):
|
||||||
|
"""GET /api/v1/vehicles with all filters combined."""
|
||||||
|
response = await admin_client.get(
|
||||||
|
"/api/v1/vehicles/?type=lkw&availability=available&min_price=40000&max_price=50000&search=Mercedes&sort=-price"
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["total"] >= 1
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_vehicle_verkaeufer_allowed(
|
||||||
|
self, verkaeufer_client, sample_vehicle_data
|
||||||
|
):
|
||||||
|
"""POST /api/v1/vehicles works for verkaeufer role (not admin-only)."""
|
||||||
|
sample_vehicle_data["fin"] = "WDB9066351L654321"
|
||||||
|
response = await verkaeufer_client.post(
|
||||||
|
"/api/v1/vehicles/", json=sample_vehicle_data
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_vehicle_fin_duplicate_returns_409(
|
||||||
|
self, admin_client, sample_vehicle_data
|
||||||
|
):
|
||||||
|
"""PUT /api/v1/vehicles/:id with duplicate FIN returns 409."""
|
||||||
|
sample_vehicle_data["fin"] = "WDB9066351L111111"
|
||||||
|
resp1 = await admin_client.post("/api/v1/vehicles/", json=sample_vehicle_data)
|
||||||
|
assert resp1.status_code == 201
|
||||||
|
vehicle1_id = resp1.json()["id"]
|
||||||
|
|
||||||
|
sample_vehicle_data["fin"] = "WDB9066351L222222"
|
||||||
|
resp2 = await admin_client.post("/api/v1/vehicles/", json=sample_vehicle_data)
|
||||||
|
assert resp2.status_code == 201
|
||||||
|
|
||||||
|
response = await admin_client.put(
|
||||||
|
f"/api/v1/vehicles/{vehicle1_id}",
|
||||||
|
json={"fin": "WDB9066351L222222"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 409
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_vehicles_empty_result(self, admin_client):
|
||||||
|
"""GET /api/v1/vehicles with filters that match nothing returns empty list."""
|
||||||
|
response = await admin_client.get(
|
||||||
|
"/api/v1/vehicles/?type=baumaschine&min_price=999999"
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["total"] == 0
|
||||||
|
assert data["items"] == []
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_vehicle_invalid_uuid_returns_422(self, admin_client):
|
||||||
|
"""GET /api/v1/vehicles/invalid-uuid returns 422."""
|
||||||
|
response = await admin_client.get("/api/v1/vehicles/not-a-uuid")
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_push_to_mobile_de_failure_still_returns_202(
|
||||||
|
self, admin_client, created_vehicle
|
||||||
|
):
|
||||||
|
"""POST /api/v1/vehicles/:id/mobile-de/push returns 202 even when mobile.de API fails."""
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
vehicle_id = created_vehicle["id"]
|
||||||
|
with patch(
|
||||||
|
"app.services.mobilede_service.httpx.AsyncClient"
|
||||||
|
) as mock_client_cls:
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = 500
|
||||||
|
mock_response.text = "Internal Server Error"
|
||||||
|
mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
|
||||||
|
"Server Error", request=MagicMock(), response=mock_response
|
||||||
|
)
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.post = AsyncMock(return_value=mock_response)
|
||||||
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||||
|
mock_client.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
mock_client_cls.return_value = mock_client
|
||||||
|
|
||||||
|
response = await admin_client.post(
|
||||||
|
f"/api/v1/vehicles/{vehicle_id}/mobile-de/push"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 202
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_mobile_de_status_after_failed_push(
|
||||||
|
self, admin_client, created_vehicle
|
||||||
|
):
|
||||||
|
"""GET /api/v1/vehicles/:id/mobile-de/status shows fehler after failed push."""
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
vehicle_id = created_vehicle["id"]
|
||||||
|
with patch(
|
||||||
|
"app.services.mobilede_service.httpx.AsyncClient"
|
||||||
|
) as mock_client_cls:
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = 500
|
||||||
|
mock_response.text = "Internal Server Error"
|
||||||
|
mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
|
||||||
|
"Server Error", request=MagicMock(), response=mock_response
|
||||||
|
)
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.post = AsyncMock(return_value=mock_response)
|
||||||
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||||
|
mock_client.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
mock_client_cls.return_value = mock_client
|
||||||
|
|
||||||
|
await admin_client.post(f"/api/v1/vehicles/{vehicle_id}/mobile-de/push")
|
||||||
|
|
||||||
|
response = await admin_client.get(
|
||||||
|
f"/api/v1/vehicles/{vehicle_id}/mobile-de/status"
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["synced"] is False
|
||||||
|
assert data["sync_status"] == "fehler"
|
||||||
|
assert data["error_log"] is not None
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
# Coolify Deployment Configuration — ERP Nutzfahrzeuge
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This document describes how to deploy the ERP Nutzfahrzeuge system on Coolify using Docker Compose.
|
||||||
|
|
||||||
|
The system consists of 4 services:
|
||||||
|
|
||||||
|
| Service | Image | Port | Health Check |
|
||||||
|
|---------|-------|------|-------------|
|
||||||
|
| Backend | Custom (Python 3.13-slim) | 8000 | `GET /api/v1/health` |
|
||||||
|
| Frontend | Custom (Node 18-alpine) | 3000 | `GET /` |
|
||||||
|
| PostgreSQL | `postgres:16-alpine` | 5432 | `pg_isready` |
|
||||||
|
| Redis | `redis:7-alpine` | 6379 | `redis-cli ping` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Coolify Resource Setup
|
||||||
|
|
||||||
|
### Step 1: Create Resource
|
||||||
|
|
||||||
|
1. In Coolify, create a new resource
|
||||||
|
2. Select **Docker Compose** as the resource type
|
||||||
|
3. Connect your Forgejo/Git repository
|
||||||
|
4. Set the base directory to the project root
|
||||||
|
5. Coolify will detect `docker-compose.yml` automatically
|
||||||
|
|
||||||
|
### Step 2: Configure Environment Variables
|
||||||
|
|
||||||
|
1. Navigate to resource → **Environment Variables**
|
||||||
|
2. Add all variables from `.env.example` (see `deploy/env.md` for descriptions)
|
||||||
|
3. **Critical variables:**
|
||||||
|
- `JWT_SECRET` — generate with `openssl rand -hex 32`
|
||||||
|
- `POSTGRES_PASSWORD` — strong password for database
|
||||||
|
- `CORS_ORIGINS` — production frontend URL (e.g., `https://erp.your-domain.com`)
|
||||||
|
- `NEXT_PUBLIC_API_URL` — production backend URL (e.g., `https://api.your-domain.com/api/v1`)
|
||||||
|
4. **Optional but feature-dependent:**
|
||||||
|
- `OPENROUTER_API_KEY` — required for AI features (OCR, retouch, copilot)
|
||||||
|
- `MOBILE_DE_API_KEY` + `MOBILE_DE_SELLER_ID` — required for mobile.de integration
|
||||||
|
5. Click **Save** and redeploy
|
||||||
|
|
||||||
|
### Step 3: Configure Health Check
|
||||||
|
|
||||||
|
In the resource settings:
|
||||||
|
|
||||||
|
| Setting | Value |
|
||||||
|
|---------|-------|
|
||||||
|
| Health check path | `/api/v1/health` |
|
||||||
|
| Health check port | `8000` |
|
||||||
|
| Health check interval | `30s` |
|
||||||
|
| Health check timeout | `5s` |
|
||||||
|
| Health check retries | `3` |
|
||||||
|
|
||||||
|
> **Note:** The health check targets the backend service. Docker Compose health checks are also defined per-service in `docker-compose.yml`.
|
||||||
|
|
||||||
|
### Step 4: Configure Persistent Storage
|
||||||
|
|
||||||
|
Docker Compose volumes are defined in `docker-compose.yml`:
|
||||||
|
|
||||||
|
| Volume | Mount Point | Purpose |
|
||||||
|
|--------|-------------|---------|
|
||||||
|
| `uploads` | `/data/uploads` (backend) | User file uploads (images, documents) |
|
||||||
|
| `pgdata` | `/var/lib/postgresql/data` (postgres) | Database data |
|
||||||
|
| `redisdata` | `/data` (redis) | Redis persistence (AOF) |
|
||||||
|
|
||||||
|
In Coolify:
|
||||||
|
1. Ensure persistent storage is enabled for the resource
|
||||||
|
2. Verify volume names match the compose file
|
||||||
|
3. For production, consider using a managed PostgreSQL instance instead of the containerized one
|
||||||
|
|
||||||
|
### Step 5: Configure Domains / Routing
|
||||||
|
|
||||||
|
1. **Backend API:**
|
||||||
|
- Set domain: `api.your-domain.com` (or subpath)
|
||||||
|
- Port: `8000`
|
||||||
|
- This is the primary entry point for Coolify health checks
|
||||||
|
|
||||||
|
2. **Frontend:**
|
||||||
|
- Set domain: `erp.your-domain.com` (or root domain)
|
||||||
|
- Port: `3000`
|
||||||
|
- Next.js standalone server
|
||||||
|
|
||||||
|
3. **CORS:**
|
||||||
|
- Set `CORS_ORIGINS` to the frontend domain
|
||||||
|
- Set `NEXT_PUBLIC_API_URL` to the backend domain
|
||||||
|
|
||||||
|
### Step 6: Deploy
|
||||||
|
|
||||||
|
1. Click **Deploy** in Coolify
|
||||||
|
2. Wait for all services to build and start
|
||||||
|
3. Verify health checks pass (green status for all services)
|
||||||
|
4. Test endpoints:
|
||||||
|
- `GET https://api.your-domain.com/api/v1/health` → 200 `{"status":"ok"}`
|
||||||
|
- `GET https://erp.your-domain.com/` → 200 (frontend loads)
|
||||||
|
- `GET https://api.your-domain.com/openapi.json` → 200 (API schema)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Coolify Service Type
|
||||||
|
|
||||||
|
- **Type:** Docker Compose (multi-service)
|
||||||
|
- **Compose file:** `docker-compose.yml` (project root)
|
||||||
|
- **Build:** Coolify builds images from `backend/Dockerfile` and `frontend/Dockerfile`
|
||||||
|
- **Pre-built images:** `postgres:16-alpine`, `redis:7-alpine`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Port Mapping
|
||||||
|
|
||||||
|
| Service | Internal Port | External Port | Notes |
|
||||||
|
|---------|---------------|---------------|-------|
|
||||||
|
| Backend | 8000 | 8000 | FastAPI + uvicorn (4 workers) |
|
||||||
|
| Frontend | 3000 | 3000 | Next.js standalone server |
|
||||||
|
| PostgreSQL | 5432 | — (internal only) | Not exposed externally |
|
||||||
|
| Redis | 6379 | — (internal only) | Not exposed externally |
|
||||||
|
|
||||||
|
> In Coolify, only expose backend (8000) and frontend (3000) via domains. PostgreSQL and Redis should remain internal to the Docker network.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Build Configuration
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- **Base image:** `python:3.13-slim`
|
||||||
|
- **System packages:** libpango, libcairo, libjpeg, libpng (for WeasyPrint + Pillow)
|
||||||
|
- **Python venv:** `/opt/venv`
|
||||||
|
- **Entry:** `uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 4`
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- **Base image:** `node:18-alpine` (multi-stage)
|
||||||
|
- **Build:** `npm ci && npm run build` (standalone output)
|
||||||
|
- **Entry:** `node server.js`
|
||||||
|
- **Requires:** `output: 'standalone'` in `next.config.js`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Post-Deploy Verification
|
||||||
|
|
||||||
|
1. All 4 containers are running and healthy
|
||||||
|
2. `GET /api/v1/health` → 200 `{"status":"ok"}`
|
||||||
|
3. Frontend loads at production URL
|
||||||
|
4. Login flow works: `POST /api/v1/auth/login`
|
||||||
|
5. CORS headers present for frontend origin
|
||||||
|
6. No errors in container logs
|
||||||
|
7. File uploads work (test upload to `/data/uploads`)
|
||||||
|
8. Database queries return expected data
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
| Issue | Cause | Solution |
|
||||||
|
|-------|-------|----------|
|
||||||
|
| Backend health check fails | DB not ready | Check `postgres` container health; increase `start_period` |
|
||||||
|
| WeasyPrint errors | Missing system libs | Verify `libpango`, `libcairo` installed in backend image |
|
||||||
|
| Frontend build fails | Missing `output: 'standalone'` | Add `output: 'standalone'` to `next.config.js` |
|
||||||
|
| CORS errors | Wrong `CORS_ORIGINS` | Set to exact frontend domain (no trailing slash) |
|
||||||
|
| AI features fail | Missing `OPENROUTER_API_KEY` | Set API key in Coolify environment variables |
|
||||||
|
| mobile.de fails | Missing credentials | Set `MOBILE_DE_API_KEY` and `MOBILE_DE_SELLER_ID` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
See `deploy/rollback.md` for the full rollback procedure.
|
||||||
|
|
||||||
|
Quick rollback in Coolify:
|
||||||
|
1. Navigate to resource → **Deployments**
|
||||||
|
2. Find last known-good deployment
|
||||||
|
3. Click **Redeploy**
|
||||||
|
4. Verify health check passes
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# Environment Configuration — ERP Nutzfahrzeuge
|
||||||
|
|
||||||
|
**Source:** `backend/.env.example` (safe reference — no live values)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Required Variables
|
||||||
|
|
||||||
|
These variables MUST be set before the application can start.
|
||||||
|
|
||||||
|
| Variable | Description | Example (redacted) |
|
||||||
|
|----------|-------------|---------------------|
|
||||||
|
| `DATABASE_URL` | PostgreSQL async connection string | `postgresql+asyncpg://user:***@host:5432/dbname` |
|
||||||
|
| `JWT_SECRET` | Secret key for JWT signing (≥256 bits) | `<redacted — generate with openssl rand -hex 32>` |
|
||||||
|
|
||||||
|
## Important Variables
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `REDIS_URL` | `redis://localhost:6379/0` | Redis connection for async task queues. Not required for JWT (stateless). Required for OCR/retouch/mobile.de async processing. |
|
||||||
|
| `JWT_ALGORITHM` | `HS256` | JWT signing algorithm |
|
||||||
|
| `JWT_ACCESS_TTL_MINUTES` | `15` | Access token TTL in minutes |
|
||||||
|
| `JWT_REFRESH_TTL_DAYS` | `7` | Refresh token TTL in days |
|
||||||
|
| `CORS_ORIGINS` | `http://localhost:3000,http://localhost:3001` | Comma-separated allowed origins. Add production frontend URL. |
|
||||||
|
| `UPLOAD_DIR` | `/tmp/uploads` | File upload directory. **Must be a persistent volume in production.** |
|
||||||
|
| `APP_NAME` | `ERP Nutzfahrzeuge` | Application display name |
|
||||||
|
| `APP_ENV` | `development` | Set to `production` for deployment |
|
||||||
|
|
||||||
|
## Optional Integration Variables
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `MOBILE_DE_API_KEY` | (empty) | mobile.de API key for vehicle listing push |
|
||||||
|
| `MOBILE_DE_SELLER_ID` | (empty) | mobile.de seller ID |
|
||||||
|
| `OPENROUTER_API_KEY` | (not set) | OpenRouter API key for AI Copilot features. Without this, Copilot endpoints will fail. |
|
||||||
|
| `BZST_API_ENABLED` | `False` | Enable BZSt USt-IdNr. verification API |
|
||||||
|
|
||||||
|
## Production Checklist
|
||||||
|
|
||||||
|
- [ ] `DATABASE_URL` points to production PostgreSQL (managed or backed up)
|
||||||
|
- [ ] `JWT_SECRET` is a cryptographically secure random string (not placeholder)
|
||||||
|
- [ ] `APP_ENV=production`
|
||||||
|
- [ ] `CORS_ORIGINS` includes production frontend origin
|
||||||
|
- [ ] `UPLOAD_DIR` points to a persistent volume mount
|
||||||
|
- [ ] `REDIS_URL` set if async features (OCR, retouch, mobile.de) are needed
|
||||||
|
- [ ] `OPENROUTER_API_KEY` set if AI Copilot is needed
|
||||||
|
- [ ] `MOBILE_DE_API_KEY` and `MOBILE_DE_SELLER_ID` set if mobile.de integration is needed
|
||||||
|
|
||||||
|
## Coolify Configuration
|
||||||
|
|
||||||
|
In Coolify, set these as environment variables (or via `.env` file in the resource):
|
||||||
|
1. Navigate to resource → Environment Variables
|
||||||
|
2. Add each variable with its production value
|
||||||
|
3. Redeploy after changes
|
||||||
|
|
||||||
|
**Never commit real secret values to the repository.** Use Coolify's secret management or an external secrets manager.
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# Health Check Configuration — ERP Nutzfahrzeuge
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Health Endpoint
|
||||||
|
|
||||||
|
**URL:** `GET /api/v1/health`
|
||||||
|
|
||||||
|
**Expected response:**
|
||||||
|
- HTTP Status: `200 OK`
|
||||||
|
- Body: `{"status":"ok"}`
|
||||||
|
|
||||||
|
**No authentication required.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Coolify Health Check
|
||||||
|
|
||||||
|
Configure in Coolify resource settings:
|
||||||
|
|
||||||
|
| Setting | Value |
|
||||||
|
|---------|-------|
|
||||||
|
| Health check path | `/api/v1/health` |
|
||||||
|
| Health check port | `8000` |
|
||||||
|
| Health check interval | `30s` (recommended) |
|
||||||
|
| Health check timeout | `5s` (recommended) |
|
||||||
|
| Health check retries | `3` (recommended) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Additional Monitoring Endpoints
|
||||||
|
|
||||||
|
| Endpoint | Method | Auth | Purpose |
|
||||||
|
|----------|--------|------|---------|
|
||||||
|
| `/` | GET | No | Root info — returns app name, version, docs URL |
|
||||||
|
| `/openapi.json` | GET | No | OpenAPI schema — useful for API monitoring |
|
||||||
|
| `/docs` | GET | No | Swagger UI (disable in production via `APP_ENV=production` if needed) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Docker Compose Health Check
|
||||||
|
|
||||||
|
If using Docker Compose, add to the backend service:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8000/api/v1/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
| Symptom | Likely Cause | Action |
|
||||||
|
|---------|-------------|--------|
|
||||||
|
| Health check returns 502 | Backend not started or crashed | Check container logs, verify `DATABASE_URL` is reachable |
|
||||||
|
| Health check returns 503 | App startup in progress | Increase `start_period` in health check config |
|
||||||
|
| Health check times out | Network/firewall issue | Verify port mapping and firewall rules |
|
||||||
|
| Health check returns 500 | Internal error (DB connection, config) | Check application logs for traceback |
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
# Rollback Procedure — ERP Nutzfahrzeuge
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rollback Strategy
|
||||||
|
|
||||||
|
The rollback strategy depends on the deployment method used.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Coolify Rollback
|
||||||
|
|
||||||
|
### Option A: Redeploy Previous Image
|
||||||
|
|
||||||
|
1. In Coolify, navigate to the resource
|
||||||
|
2. Go to **Deployments** tab
|
||||||
|
3. Find the last known-good deployment
|
||||||
|
4. Click **Redeploy** on that deployment
|
||||||
|
5. Wait for health check to pass
|
||||||
|
6. Verify endpoints:
|
||||||
|
- `GET /api/v1/health` → 200
|
||||||
|
- `GET /` → 200
|
||||||
|
|
||||||
|
### Option B: Rollback via Git
|
||||||
|
|
||||||
|
1. In the repository, identify the last known-good commit:
|
||||||
|
```bash
|
||||||
|
git log --oneline -10
|
||||||
|
```
|
||||||
|
2. Revert to the previous commit:
|
||||||
|
```bash
|
||||||
|
git revert <commit_hash> # or git reset --hard <commit_hash>
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
3. Trigger a new deployment in Coolify (auto-deploy or manual)
|
||||||
|
4. Wait for health check to pass
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Docker Compose Rollback
|
||||||
|
|
||||||
|
### Step 1: Stop Current Deployment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Revert Code
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git checkout <last-known-good-tag-or-commit>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Rebuild and Restart
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose build
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: Verify
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s http://localhost:8000/api/v1/health
|
||||||
|
docker-compose logs --tail 50 backend
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database Rollback
|
||||||
|
|
||||||
|
### If Alembic migrations are used:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
/opt/venv/bin/python -m alembic downgrade -1 # rollback one revision
|
||||||
|
# or
|
||||||
|
/opt/venv/bin/python -m alembic downgrade <revision_id> # rollback to specific revision
|
||||||
|
```
|
||||||
|
|
||||||
|
### If no migration tool:
|
||||||
|
|
||||||
|
- Database schema changes are applied on app startup via SQLAlchemy `create_all`
|
||||||
|
- Rollback requires manual SQL or restoring from a database backup
|
||||||
|
- **Always take a database backup before deployment:**
|
||||||
|
```bash
|
||||||
|
pg_dump -U erp_user erp_test > backup_$(date +%Y%m%d_%H%M%S).sql
|
||||||
|
```
|
||||||
|
|
||||||
|
### Restore from backup:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
psql -U erp_user erp_test < backup_YYYYMMDD_HHMMSS.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rollback Decision Tree
|
||||||
|
|
||||||
|
| Situation | Action |
|
||||||
|
|-----------|--------|
|
||||||
|
| App won't start after deploy | Redeploy previous image (Coolify) or `git checkout` + rebuild (Docker) |
|
||||||
|
| Health check fails | Redeploy previous image; check DB connectivity |
|
||||||
|
| Database migration broke schema | `alembic downgrade` or restore from `pg_dump` backup |
|
||||||
|
| Frontend broken | Rebuild frontend from previous commit; redeploy |
|
||||||
|
| Performance regression | Redeploy previous image; investigate in staging |
|
||||||
|
| Security issue found | Rollback immediately; patch in staging; redeploy |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Post-Rollback Verification
|
||||||
|
|
||||||
|
After any rollback:
|
||||||
|
|
||||||
|
1. ✅ Health check passes: `GET /api/v1/health` → 200 `{"status":"ok"}`
|
||||||
|
2. ✅ Root endpoint: `GET /` → 200
|
||||||
|
3. ✅ Login works: `POST /api/v1/auth/login` → 200 with valid credentials
|
||||||
|
4. ✅ No errors in logs: `docker logs <container> --tail 50`
|
||||||
|
5. ✅ Frontend loads at production URL
|
||||||
|
6. ✅ Database queries return expected data
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Emergency Rollback (Quick)
|
||||||
|
|
||||||
|
If the deployment is causing immediate issues:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Coolify: use the Redeploy button on the last good deployment
|
||||||
|
# Docker Compose:
|
||||||
|
docker-compose down
|
||||||
|
git checkout <last-good-commit>
|
||||||
|
docker-compose build && docker-compose up -d
|
||||||
|
curl -s http://localhost:8000/api/v1/health
|
||||||
|
```
|
||||||
|
|
||||||
|
**Time to rollback:** < 5 minutes (if image is cached) or < 15 minutes (if rebuild needed).
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
# Deployment Runbook — ERP Nutzfahrzeuge
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
1. **PostgreSQL** — running and accessible (managed or self-hosted)
|
||||||
|
2. **Redis** (optional) — if async features (OCR, retouch, mobile.de push) are needed
|
||||||
|
3. **Node.js** — for frontend build (if building at deploy time)
|
||||||
|
4. **Python 3.11+** — for backend runtime
|
||||||
|
5. **Coolify** (or Docker) — for containerized deployment
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pre-Deployment Steps
|
||||||
|
|
||||||
|
### 1. Verify Environment Variables
|
||||||
|
|
||||||
|
Ensure all required variables are configured in Coolify or `.env`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Required
|
||||||
|
DATABASE_URL=postgresql+asyncpg://<user>:<password>@<host>:5432/<dbname>
|
||||||
|
JWT_SECRET=<generate with: openssl rand -hex 32>
|
||||||
|
|
||||||
|
# Important
|
||||||
|
APP_ENV=production
|
||||||
|
CORS_ORIGINS=https://your-frontend-domain.com
|
||||||
|
UPLOAD_DIR=/app/uploads # persistent volume
|
||||||
|
|
||||||
|
# Optional (feature-dependent)
|
||||||
|
REDIS_URL=redis://<host>:6379/0
|
||||||
|
OPENROUTER_API_KEY=<key>
|
||||||
|
MOBILE_DE_API_KEY=<key>
|
||||||
|
MOBILE_DE_SELLER_ID=<id>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Build Frontend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm ci
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Build artifacts are output to `frontend/.next/`.
|
||||||
|
|
||||||
|
### 3. Verify Backend Dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
/opt/venv/bin/pip install -r requirements.txt
|
||||||
|
/opt/venv/bin/python -c "from app.main import app; print('Import OK')"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Database Migration (if applicable)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
/opt/venv/bin/python -m alembic upgrade head # if Alembic is configured
|
||||||
|
# Or ensure tables are created via app startup
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deployment Steps (Coolify)
|
||||||
|
|
||||||
|
### Step 1: Create Resource
|
||||||
|
|
||||||
|
1. In Coolify, create a new resource (Docker Compose or Dockerfile-based)
|
||||||
|
2. Configure the source repository
|
||||||
|
3. Set the build/pack type
|
||||||
|
|
||||||
|
### Step 2: Configure Environment
|
||||||
|
|
||||||
|
1. Navigate to resource → Environment Variables
|
||||||
|
2. Add all variables from `deploy/env.md`
|
||||||
|
3. Ensure `JWT_SECRET` is a secure random value
|
||||||
|
4. Set `APP_ENV=production`
|
||||||
|
|
||||||
|
### Step 3: Configure Health Check
|
||||||
|
|
||||||
|
1. Set health check path to `/api/v1/health`
|
||||||
|
2. Set port to `8000`
|
||||||
|
3. See `deploy/healthcheck.md` for full configuration
|
||||||
|
|
||||||
|
### Step 4: Configure Persistent Storage
|
||||||
|
|
||||||
|
1. Mount a persistent volume for `UPLOAD_DIR` (e.g., `/app/uploads`)
|
||||||
|
2. Ensure database volume is persistent
|
||||||
|
|
||||||
|
### Step 5: Deploy
|
||||||
|
|
||||||
|
1. Click **Deploy** in Coolify
|
||||||
|
2. Wait for build and startup to complete
|
||||||
|
3. Verify health check passes (green status)
|
||||||
|
4. Test endpoints:
|
||||||
|
- `GET /api/v1/health` → 200 `{"status":"ok"}`
|
||||||
|
- `GET /` → 200 app metadata
|
||||||
|
- `GET /openapi.json` → 200 OpenAPI schema
|
||||||
|
|
||||||
|
### Step 6: Post-Deployment Verification
|
||||||
|
|
||||||
|
1. Verify frontend is accessible at the configured domain
|
||||||
|
2. Test login flow: `POST /api/v1/auth/login` with valid credentials
|
||||||
|
3. Verify CORS headers are present for frontend origin
|
||||||
|
4. Check logs for any errors:
|
||||||
|
```bash
|
||||||
|
docker logs <container_name> --tail 50
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deployment Steps (Docker Compose)
|
||||||
|
|
||||||
|
### docker-compose.yml (template)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
backend:
|
||||||
|
build: ./backend
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=postgresql+asyncpg://user:pass@db:5432/erp
|
||||||
|
- JWT_SECRET=${JWT_SECRET}
|
||||||
|
- APP_ENV=production
|
||||||
|
- CORS_ORIGINS=https://your-domain.com
|
||||||
|
- UPLOAD_DIR=/app/uploads
|
||||||
|
- REDIS_URL=redis://redis:6379/0
|
||||||
|
volumes:
|
||||||
|
- uploads:/app/uploads
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
- redis
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8000/api/v1/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
environment:
|
||||||
|
- POSTGRES_USER=erp_user
|
||||||
|
- POSTGRES_PASSWORD=${DB_PASSWORD}
|
||||||
|
- POSTGRES_DB=erp
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
volumes:
|
||||||
|
- redisdata:/data
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build: ./frontend
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
uploads:
|
||||||
|
pgdata:
|
||||||
|
redisdata:
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Start Command (standalone)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend && /opt/venv/bin/python -m uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
For production with workers:
|
||||||
|
```bash
|
||||||
|
cd backend && /opt/venv/bin/python -m uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 4
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
See `deploy/rollback.md` for rollback procedure.
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
# ERP Nutzfahrzeuge — Production Docker Compose
|
||||||
|
# Services: backend (FastAPI), frontend (Next.js), postgres, redis
|
||||||
|
# Compatible with Coolify Docker Compose deployment
|
||||||
|
|
||||||
|
services:
|
||||||
|
# ============ Backend (FastAPI + uvicorn) ============
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: erp-backend
|
||||||
|
restart: unless-stopped
|
||||||
|
expose:
|
||||||
|
- "8000"
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=postgresql+asyncpg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@erp-db:5432/${POSTGRES_DB}
|
||||||
|
- REDIS_URL=redis://redis:6379/0
|
||||||
|
- JWT_SECRET=${JWT_SECRET}
|
||||||
|
- JWT_ALGORITHM=${JWT_ALGORITHM:-HS256}
|
||||||
|
- JWT_ACCESS_TTL_MINUTES=${JWT_ACCESS_TTL_MINUTES:-15}
|
||||||
|
- JWT_REFRESH_TTL_DAYS=${JWT_REFRESH_TTL_DAYS:-7}
|
||||||
|
- CORS_ORIGINS=${CORS_ORIGINS}
|
||||||
|
- UPLOAD_DIR=/data/uploads
|
||||||
|
- WEASYPRINT_OUTPUT_DIR=/tmp/contracts
|
||||||
|
- APP_NAME=${APP_NAME:-ERP Nutzfahrzeuge}
|
||||||
|
- APP_ENV=production
|
||||||
|
- OPENROUTER_API_KEY=${OPENROUTER_API_KEY}
|
||||||
|
- OPENROUTER_BASE_URL=${OPENROUTER_BASE_URL:-https://openrouter.ai/api/v1}
|
||||||
|
- OPENROUTER_OCR_MODEL=${OPENROUTER_OCR_MODEL:-qwen/qwen2.5-vl-72b-instruct}
|
||||||
|
- MAX_FILE_SIZE_MB=${MAX_FILE_SIZE_MB:-50}
|
||||||
|
- MOBILE_DE_API_KEY=${MOBILE_DE_API_KEY}
|
||||||
|
- MOBILE_DE_SELLER_ID=${MOBILE_DE_SELLER_ID}
|
||||||
|
- BZST_API_ENABLED=${BZST_API_ENABLED:-false}
|
||||||
|
volumes:
|
||||||
|
- uploads:/data/uploads
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8000/api/v1/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 15s
|
||||||
|
networks:
|
||||||
|
- erp-network
|
||||||
|
- coolify
|
||||||
|
|
||||||
|
# ============ Frontend (Next.js standalone) ============
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
- NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL:-http://localhost:8000/api/v1}
|
||||||
|
container_name: erp-frontend
|
||||||
|
restart: unless-stopped
|
||||||
|
expose:
|
||||||
|
- "3000"
|
||||||
|
environment:
|
||||||
|
- NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL:-http://localhost:8000/api/v1}
|
||||||
|
- NODE_ENV=production
|
||||||
|
- HOSTNAME=0.0.0.0
|
||||||
|
- PORT=3000
|
||||||
|
depends_on:
|
||||||
|
backend:
|
||||||
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:3000/"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
networks:
|
||||||
|
- erp-network
|
||||||
|
|
||||||
|
# ============ PostgreSQL 16 ============
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: erp-postgres
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- POSTGRES_USER=${POSTGRES_USER}
|
||||||
|
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
|
||||||
|
- POSTGRES_DB=${POSTGRES_DB}
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 10s
|
||||||
|
networks:
|
||||||
|
erp-network:
|
||||||
|
aliases:
|
||||||
|
- erp-db
|
||||||
|
- postgres
|
||||||
|
|
||||||
|
# ============ Redis 7 ============
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: erp-redis
|
||||||
|
restart: unless-stopped
|
||||||
|
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
|
||||||
|
volumes:
|
||||||
|
- redisdata:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 5s
|
||||||
|
networks:
|
||||||
|
- erp-network
|
||||||
|
- coolify
|
||||||
|
|
||||||
|
# ============ Volumes ============
|
||||||
|
volumes:
|
||||||
|
uploads:
|
||||||
|
driver: local
|
||||||
|
pgdata:
|
||||||
|
driver: local
|
||||||
|
redisdata:
|
||||||
|
driver: local
|
||||||
|
|
||||||
|
# ============ Networks ============
|
||||||
|
networks:
|
||||||
|
erp-network:
|
||||||
|
driver: bridge
|
||||||
|
coolify:
|
||||||
|
name: coolify
|
||||||
|
external: true
|
||||||
+396
@@ -0,0 +1,396 @@
|
|||||||
|
# Handoff-Dokument — ERP Nutzfahrzeuge
|
||||||
|
|
||||||
|
**Version:** 1.0.0
|
||||||
|
**Datum:** 2026-07-18
|
||||||
|
**Phase:** 7 — Final Audit + Release Readiness + Handoff
|
||||||
|
**Audit-Verdict:** ✅ PASS (mit WARNs)
|
||||||
|
**Release-Score:** 85/100
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Projekt-Übersicht
|
||||||
|
|
||||||
|
### Was ist das System?
|
||||||
|
|
||||||
|
Ein webbasiertes ERP-System für Händler von Nutzfahrzeugen und Baumaschinen (~10 Nutzer). Das System verwaltet Fahrzeugbestände mit Baumaschinen-spezifischen Feldern, Kundenkontakte, Verkaufsprozesse mit Rechtsdokumenten, OCR-basierte Datenerfassung, mobile.de Push-Sync, KI-Copilot und KI-Bildretusche.
|
||||||
|
|
||||||
|
### Tech-Stack
|
||||||
|
|
||||||
|
| Komponente | Technologie | Version |
|
||||||
|
|---|---|---|
|
||||||
|
| **Backend** | Python / FastAPI | Python 3.13, FastAPI |
|
||||||
|
| **Frontend** | Next.js / React / TypeScript | Next.js 14, React 18 |
|
||||||
|
| **Datenbank** | PostgreSQL | 16 (Alpine) |
|
||||||
|
| **Cache/Queue** | Redis | 7 (Alpine, optional) |
|
||||||
|
| **KI-Anbindung** | OpenRouter | Qwen2.5-VL (OCR), Flux.1-Pro (Retusche), Claude/GPT (Copilot) |
|
||||||
|
| **Extern** | mobile.de Seller API, DATEV-Export, BZSt USt-IdNr.-Prüfung | — |
|
||||||
|
| **Deployment** | Docker Compose (4 Services), Coolify | — |
|
||||||
|
| **Git** | Forgejo | 18 Commits auf main |
|
||||||
|
|
||||||
|
### Module (8 Tasks)
|
||||||
|
|
||||||
|
| Task | Modul | Status |
|
||||||
|
|---|---|---|
|
||||||
|
| T01 | Auth + User Management + RBAC + Base Frontend + i18n | ✅ Implementiert |
|
||||||
|
| T02 | Fahrzeugbestand + mobile.de Push-Sync | ✅ Implementiert |
|
||||||
|
| T03 | OCR-Erfassung (ZB I/II) via OpenRouter Vision | ✅ Implementiert |
|
||||||
|
| T04 | Kontakt-/Kundenverwaltung + USt-IdNr.-Prüfung | ✅ Implementiert |
|
||||||
|
| T05 | Dateiablage pro Fahrzeug | ✅ Implementiert |
|
||||||
|
| T06 | Verkaufsmodul + Rechtsdokumente + DATEV-Export | ✅ Implementiert |
|
||||||
|
| T07 | KI-Copilot (Text + Sprache) + Systemsteuerung | ✅ Implementiert |
|
||||||
|
| T08 | Bildretusche + Preisvergleich (Flux.1-Pro) | ✅ Implementiert |
|
||||||
|
|
||||||
|
### Frontend-Routen (15)
|
||||||
|
|
||||||
|
| Route | Beschreibung |
|
||||||
|
|---|---|
|
||||||
|
| `/` | Startseite |
|
||||||
|
| `/login` | Login-Seite |
|
||||||
|
| `/[locale]/fahrzeuge` | Fahrzeugliste |
|
||||||
|
| `/[locale]/fahrzeuge/neu` | Neues Fahrzeug anlegen |
|
||||||
|
| `/[locale]/fahrzeuge/[id]` | Fahrzeugdetail |
|
||||||
|
| `/[locale]/kontakte` | Kontaktliste |
|
||||||
|
| `/[locale]/kontakte/neu` | Neuer Kontakt |
|
||||||
|
| `/[locale]/kontakte/[id]` | Kontaktdetail |
|
||||||
|
| `/[locale]/ocr` | OCR-Erfassung |
|
||||||
|
| `/[locale]/retouch` | Bildretusche |
|
||||||
|
| `/[locale]/verkauf` | Verkaufsliste |
|
||||||
|
| `/[locale]/verkauf/neu` | Neuer Verkauf |
|
||||||
|
| `/[locale]/verkauf/[id]` | Verkaufsdetail |
|
||||||
|
| `/[locale]/ki-copilot` | KI-Copilot Chat |
|
||||||
|
|
||||||
|
### Backend-API-Endpunkte (11 Router)
|
||||||
|
|
||||||
|
| Router | Pfad | Beschreibung |
|
||||||
|
|---|---|---|
|
||||||
|
| auth | `/api/v1/auth` | Login, Refresh, Logout |
|
||||||
|
| users | `/api/v1/users` | User CRUD, RBAC |
|
||||||
|
| vehicles | `/api/v1/vehicles` | Fahrzeug CRUD, Filter, Soft-Delete |
|
||||||
|
| contacts | `/api/v1/contacts` | Kontakt CRUD, USt-IdNr.-Prüfung |
|
||||||
|
| ocr | `/api/v1/ocr` | OCR Upload, Results, Apply |
|
||||||
|
| files | `/api/v1/files` | Dateiablage, Thumbnails |
|
||||||
|
| sales | `/api/v1/sales` | Verkaufs-CRUD, Vertrag-PDF |
|
||||||
|
| datev | `/api/v1/datev` | DATEV CSV-Export |
|
||||||
|
| copilot | `/api/v1/copilot` | KI-Chat, Actions, Voice, History |
|
||||||
|
| image_retouch | `/api/v1/retouch` | Bildretusche, Preisvergleich |
|
||||||
|
| health | `/api/v1/health` | Health Check |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Test-Ergebnisse
|
||||||
|
|
||||||
|
### Backend-Tests
|
||||||
|
|
||||||
|
| Metrik | Wert |
|
||||||
|
|---|---|
|
||||||
|
| **Tests gesamt** | 392 |
|
||||||
|
| **Passed** | 392 |
|
||||||
|
| **Failed** | 0 |
|
||||||
|
| **Dauer** | 105.93s |
|
||||||
|
| **Coverage (T08)** | 89% (Target: 80%) |
|
||||||
|
| **Lint (ruff)** | ✅ All checks passed |
|
||||||
|
| **Format (ruff)** | ✅ 77 files already formatted |
|
||||||
|
|
||||||
|
**Test-Dateien (16):**
|
||||||
|
- test_auth.py, test_auth_service.py, test_users.py
|
||||||
|
- test_vehicles.py, test_vehicles_extra.py, test_mobilede.py
|
||||||
|
- test_contacts.py
|
||||||
|
- test_ocr.py
|
||||||
|
- test_files.py
|
||||||
|
- test_sales.py, test_datev.py
|
||||||
|
- test_copilot.py, test_copilot_coverage.py
|
||||||
|
- test_retouch.py
|
||||||
|
- test_health.py
|
||||||
|
- conftest.py
|
||||||
|
|
||||||
|
### Frontend-Tests
|
||||||
|
|
||||||
|
| Metrik | Wert |
|
||||||
|
|---|---|
|
||||||
|
| **Tests gesamt** | 112 |
|
||||||
|
| **Passed** | 112 |
|
||||||
|
| **Failed** | 0 |
|
||||||
|
| **Dauer** | 11.46s |
|
||||||
|
| **Lint (ESLint)** | ✅ Clean (0 warnings) |
|
||||||
|
| **Build (Next.js)** | ✅ 15 Routen gebaut |
|
||||||
|
|
||||||
|
**Test-Dateien (8):**
|
||||||
|
- auth.test.tsx, i18n.test.tsx
|
||||||
|
- vehicles (in test files)
|
||||||
|
- ocr.test.tsx
|
||||||
|
- files.test.tsx
|
||||||
|
- sales.test.tsx, datev.test.tsx
|
||||||
|
- copilot.test.tsx
|
||||||
|
- retouch.test.tsx
|
||||||
|
|
||||||
|
### Runtime-Verifikation
|
||||||
|
|
||||||
|
| Check | Status |
|
||||||
|
|---|---|
|
||||||
|
| Backend Startup | ✅ PASS |
|
||||||
|
| Health Endpoint (/api/v1/health) | ✅ 200 OK |
|
||||||
|
| Root Endpoint (/) | ✅ 200 OK |
|
||||||
|
| OpenAPI Schema (/openapi.json) | ✅ 200 OK |
|
||||||
|
| Auth Login (GET → 405) | ✅ Korrekt (POST-only) |
|
||||||
|
| Frontend Build Artifacts | ✅ .next/ vollständig |
|
||||||
|
| Runtime Errors | ✅ Keine |
|
||||||
|
| PostgreSQL | ✅ Läuft |
|
||||||
|
| Redis | ⚠️ Nicht installiert (optional) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Deployment-Anleitung
|
||||||
|
|
||||||
|
### Voraussetzungen
|
||||||
|
|
||||||
|
1. **Coolify** (oder Docker + Docker Compose) installiert und lauffähig
|
||||||
|
2. **Forgejo-Repository** geklont: `https://forgejo.media-on.de/Leopoldadmin/erp-nutzfahrzeuge`
|
||||||
|
3. **Domain(s)** für Frontend und Backend konfiguriert (z.B. `erp.domain.tld`)
|
||||||
|
4. **OpenRouter API Key** für KI-Funktionen (OCR, Retusche, Copilot)
|
||||||
|
5. **mobile.de API Key + Seller ID** für Fahrzeugmarkt-Integration (optional)
|
||||||
|
|
||||||
|
### Schritt-für-Schritt Deployment
|
||||||
|
|
||||||
|
#### 1. Environment Variables konfigurieren
|
||||||
|
|
||||||
|
Kopiere `.env.example` zu `.env` und fülle alle Werte aus:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Pflicht
|
||||||
|
POSTGRES_USER=erp_user
|
||||||
|
POSTGRES_PASSWORD=<sicheres-passwort>
|
||||||
|
POSTGRES_DB=erp_nutzfahrzeuge
|
||||||
|
JWT_SECRET=<openssl rand -hex 32>
|
||||||
|
|
||||||
|
# Wichtig
|
||||||
|
APP_ENV=production
|
||||||
|
CORS_ORIGINS=https://erp.ihre-domain.de
|
||||||
|
NEXT_PUBLIC_API_URL=https://erp.ihre-domain.de/api/v1
|
||||||
|
|
||||||
|
# KI-Funktionen
|
||||||
|
OPENROUTER_API_KEY=<ihr-key>
|
||||||
|
|
||||||
|
# mobile.de (optional)
|
||||||
|
MOBILE_DE_API_KEY=<ihr-key>
|
||||||
|
MOBILE_DE_SELLER_ID=<ihre-id>
|
||||||
|
```
|
||||||
|
|
||||||
|
Siehe `deploy/env.md` für vollständige Dokumentation aller Variablen.
|
||||||
|
|
||||||
|
#### 2. Coolify Deployment
|
||||||
|
|
||||||
|
1. In Coolify: Neue Resource → **Docker Compose**
|
||||||
|
2. Forgejo-Repository verbinden
|
||||||
|
3. Base Directory = Projekt-Root
|
||||||
|
4. Coolify erkennt `docker-compose.yml` automatisch
|
||||||
|
5. Environment Variables in Coolify eintragen (siehe `deploy/coolify-config.md`)
|
||||||
|
6. Deploy starten
|
||||||
|
7. Health Check abwarten: `GET /api/v1/health` → 200
|
||||||
|
|
||||||
|
#### 3. Docker Compose (Alternative)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /pfad/zum/projekt
|
||||||
|
cp .env.example .env
|
||||||
|
# .env bearbeiten
|
||||||
|
docker compose up -d
|
||||||
|
docker compose ps # Alle 4 Services sollten "healthy" sein
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. Post-Deploy Verifikation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backend Health
|
||||||
|
curl https://erp.ihre-domain.de/api/v1/health
|
||||||
|
# Erwartet: {"status":"ok"}
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
curl -I https://erp.ihre-domain.de/
|
||||||
|
# Erwartet: HTTP/1.1 200
|
||||||
|
|
||||||
|
# OpenAPI
|
||||||
|
curl https://erp.ihre-domain.de/openapi.json | python -m json.tool | head -5
|
||||||
|
```
|
||||||
|
|
||||||
|
### Services (4 Container)
|
||||||
|
|
||||||
|
| Service | Image | Port | Health Check |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Backend | python:3.13-slim (custom) | 8000 | `curl -f /api/v1/health` |
|
||||||
|
| Frontend | node:18-alpine (custom) | 3000 | `wget --spider /` |
|
||||||
|
| PostgreSQL | postgres:16-alpine | 5432 | `pg_isready` |
|
||||||
|
| Redis | redis:7-alpine | 6379 | `redis-cli ping` |
|
||||||
|
|
||||||
|
### Volumes
|
||||||
|
|
||||||
|
| Volume | Mount | Zweck |
|
||||||
|
|---|---|---|
|
||||||
|
| uploads | `/data/uploads` | Fahrzeugdateien, Bilder |
|
||||||
|
| pgdata | `/var/lib/postgresql/data` | PostgreSQL-Datenbank |
|
||||||
|
| redisdata | `/data` | Redis-Persistenz |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Bekannte Risiken + Mitigation
|
||||||
|
|
||||||
|
| # | Risiko | Severity | Mitigation |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | **JWT_SECRET als Placeholder** | Hoch | Vor Production: `openssl rand -hex 32` verwenden. Niemals Default-Wert in Production. |
|
||||||
|
| 2 | **CORS_ORIGINS nur localhost** | Mittel | Production URL in CORS_ORIGINS eintragen. Sonst blockiert der Browser API-Calls. |
|
||||||
|
| 3 | **Redis nicht installiert** | Niedrig | Async-Features (OCR, Retusche, mobile.de Push) benötigen Redis. Ohne Redis: Features deaktiviert. |
|
||||||
|
| 4 | **OpenRouter API Key leer** | Mittel | KI-Funktionen (OCR, Retusche, Copilot) funktionieren nicht ohne Key. Vor Deploy setzen. |
|
||||||
|
| 5 | **BZSt API nicht verfügbar** | Niedrig | USt-IdNr.-Prüfung ist manuell im MVP. BZST_API_ENABLED=false. |
|
||||||
|
| 6 | **Upload-Verzeichnis nicht persistent** | Hoch | In Production: Docker Volume für `/data/uploads` konfigurieren. Sonst Datenverlust bei Container-Neustart. |
|
||||||
|
| 7 | **Frontend act() Warnings** | Niedrig | Non-blocking React state update Warnings in ChatHistory-Tests. Keine Funktionsbeeinträchtigung. |
|
||||||
|
| 8 | **Task-Graph-Status inkonsistent** | Niedrig | Nur T03+T06 als 'completed' markiert im JSON. Alle 8 Tasks wurden implementiert und getestet. Metadata-Lücke, kein Code-Problem. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Nächste Schritte
|
||||||
|
|
||||||
|
### Vor Production Deployment
|
||||||
|
|
||||||
|
1. **[KRITISCH]** JWT_SECRET generieren: `openssl rand -hex 32`
|
||||||
|
2. **[KRITISCH]** POSTGRES_PASSWORD setzen (sicheres Passwort)
|
||||||
|
3. **[KRITISCH]** CORS_ORIGINS auf Production-Domain setzen
|
||||||
|
4. **[KRITISCH]** NEXT_PUBLIC_API_URL auf Production-Backend-URL setzen
|
||||||
|
5. **[WICHTIG]** OPENROUTER_API_KEY setzen (für OCR, Retusche, Copilot)
|
||||||
|
6. **[WICHTIG]** APP_ENV=production setzen
|
||||||
|
7. **[EMPFOHLEN]** Redis aktivieren (für async Features)
|
||||||
|
8. **[EMPFOHLEN]** MOBILE_DE_API_KEY + MOBILE_DE_SELLER_ID setzen
|
||||||
|
9. **[EMPFOHLEN]** Persistent Volume für Uploads verifizieren
|
||||||
|
|
||||||
|
### Nach erstem Deployment
|
||||||
|
|
||||||
|
1. Smoke Test: Login → Fahrzeug anlegen → OCR → Verkauf → DATEV-Export
|
||||||
|
2. Health Monitoring einrichten (Coolify Health Checks)
|
||||||
|
3. Database Backup-Strategie definieren (pg_dump cron)
|
||||||
|
4. Log-Aggregation konfigurieren
|
||||||
|
5. SSL/TLS über Coolify/Traefik verifizieren
|
||||||
|
|
||||||
|
### Langfristig
|
||||||
|
|
||||||
|
1. E2E-Tests mit Playwright ergänzen
|
||||||
|
2. Integration-Tests mit echtem OpenRouter API
|
||||||
|
3. BZSt eVatR API-Zugang beantragen (für automatische USt-IdNr.-Prüfung)
|
||||||
|
4. CI/CD Pipeline (Forgejo Actions → Docker Build → Coolify Deploy)
|
||||||
|
5. Monitoring & Alerting (Uptime, Response Times, Error Rates)
|
||||||
|
6. Performance-Optimierung (DB-Indexes, Query-Optimierung, Caching)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Wartungshinweise
|
||||||
|
|
||||||
|
### Regelmäßige Aufgaben
|
||||||
|
|
||||||
|
| Aufgabe | Häufigkeit | Befehl/Aktion |
|
||||||
|
|---|---|---|
|
||||||
|
| Database Backup | Täglich | `docker exec erp-postgres pg_dump -U erp_user erp_nutzfahrzeuge > backup.sql` |
|
||||||
|
| Redis Persistence Check | Wöchentlich | `docker exec erp-redis redis-cli info persistence` |
|
||||||
|
| Docker Image Updates | Monatlich | `docker compose pull && docker compose up -d` |
|
||||||
|
| Log Review | Wöchentlich | `docker compose logs --tail=100 backend` |
|
||||||
|
| Health Check Monitor | Kontinuierlich | Coolify Health Check (30s Interval) |
|
||||||
|
| Disk Space Check | Wöchentlich | `docker system df` |
|
||||||
|
|
||||||
|
### Rollback
|
||||||
|
|
||||||
|
Siehe `deploy/rollback.md` für vollständige Rollback-Prozedur:
|
||||||
|
|
||||||
|
1. **Coolify:** Redeploy Previous Image (Deployments Tab → Redeploy)
|
||||||
|
2. **Git:** `git revert <commit>` → Push → Auto-Deploy
|
||||||
|
3. **Docker Compose:** `docker compose down` → Previous Image tag → `docker compose up -d`
|
||||||
|
|
||||||
|
### Troubleshooting
|
||||||
|
|
||||||
|
| Symptom | Ursache | Lösung |
|
||||||
|
|---|---|---|
|
||||||
|
| Health 502 | Backend nicht gestartet | Container Logs prüfen, DATABASE_URL verifizieren |
|
||||||
|
| Health 500 | DB-Verbindung fehlgeschlagen | PostgreSQL-Status, Credentials prüfen |
|
||||||
|
| Frontend leer | API nicht erreichbar | NEXT_PUBLIC_API_URL, CORS_ORIGINS prüfen |
|
||||||
|
| KI-Funktionen fehler | OpenRouter Key fehlt | OPENROUTER_API_KEY setzen, Container restart |
|
||||||
|
| Uploads fehlen | Volume nicht gemounted | Docker Volume für `/data/uploads` prüfen |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Projekt-Artefakte Verzeichnis
|
||||||
|
|
||||||
|
| Artefakt | Pfad | Status |
|
||||||
|
|---|---|---|
|
||||||
|
| Requirements | `docs/requirements.md` | ✅ Final (21/21 Discovery) |
|
||||||
|
| Architecture | `docs/architecture.md` | ✅ Complete (15 Tabellen, API-Design) |
|
||||||
|
| Component Inventory | `docs/component_inventory.md` | ✅ Complete |
|
||||||
|
| Task Graph | `.a0/task_graph.json` | ⚠️ 8 Tasks, Status inkonsistent |
|
||||||
|
| AGENTS.md | `AGENTS.md` | ✅ Build & Test Commands |
|
||||||
|
| Test Report | `test_report.md` | ⚠️ Nur T08 dokumentiert |
|
||||||
|
| Runtime Report | `.a0/runtime_report.md` | ✅ Complete (305 Zeilen) |
|
||||||
|
| Docker Compose | `docker-compose.yml` | ✅ 4 Services, Healthchecks |
|
||||||
|
| Backend Dockerfile | `backend/Dockerfile` | ✅ Python 3.13-slim |
|
||||||
|
| Frontend Dockerfile | `frontend/Dockerfile` | ✅ Multi-stage, Node 18 |
|
||||||
|
| Env Example | `.env.example` | ✅ Vollständig |
|
||||||
|
| Deploy Env Docs | `deploy/env.md` | ✅ Complete |
|
||||||
|
| Deploy Runbook | `deploy/runbook.md` | ✅ Complete |
|
||||||
|
| Deploy Rollback | `deploy/rollback.md` | ✅ Complete |
|
||||||
|
| Deploy Healthcheck | `deploy/healthcheck.md` | ✅ Complete |
|
||||||
|
| Coolify Config | `deploy/coolify-config.md` | ✅ Complete |
|
||||||
|
| Known Errors | `.a0/known_errors.md` | ✅ Keine kritischen |
|
||||||
|
| Open Questions | `docs/open_questions.md` | ✅ Dokumentiert |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Audit-Ergebnis
|
||||||
|
|
||||||
|
### Quality Gate Results
|
||||||
|
|
||||||
|
| Gate | Result | Score |
|
||||||
|
|---|---|---|
|
||||||
|
| Backend Tests | ✅ PASS | 392/392 |
|
||||||
|
| Frontend Tests | ✅ PASS | 112/112 |
|
||||||
|
| Backend Lint (ruff) | ✅ PASS | 0 errors |
|
||||||
|
| Frontend Lint (ESLint) | ✅ PASS | 0 errors/warnings |
|
||||||
|
| Frontend Build | ✅ PASS | 15 routes |
|
||||||
|
| Runtime Verification | ✅ PASS | Health 200, OpenAPI OK |
|
||||||
|
| Deployment Config | ✅ PASS | Docker + Coolify |
|
||||||
|
| Documentation | ✅ PASS | Runbook, Rollback, Env, Healthcheck |
|
||||||
|
| Requirements Coverage | ✅ PASS | 21/21 Discovery |
|
||||||
|
| Architecture | ✅ PASS | 15 Tabellen, API-Design |
|
||||||
|
|
||||||
|
### Overall Verdict
|
||||||
|
|
||||||
|
**✅ PASS — Release Ready (mit WARNs)**
|
||||||
|
|
||||||
|
**Score:** 85/100
|
||||||
|
**Minimum für Release:** 80 ✅
|
||||||
|
**Minimum für Production Handoff:** 90 ⚠️ (nicht erreicht, siehe WARNs)
|
||||||
|
|
||||||
|
### WARNs (nicht blockierend)
|
||||||
|
|
||||||
|
1. Task-Graph-Status inkonsistent (nur 2/8 als completed markiert)
|
||||||
|
2. Project State JSON veraltet (phase=architecture statt implementation)
|
||||||
|
3. Orchestrator Mode veraltet (planning_only statt release_handoff)
|
||||||
|
4. test_report.md dokumentiert nur T08, nicht alle Tasks
|
||||||
|
5. Kein patterns extractor verfügbar (Skritt 4 entfallen)
|
||||||
|
|
||||||
|
### Blockers (kritisch)
|
||||||
|
|
||||||
|
**Keine kritischen Blocker.** Alle Tests grün, alle Artefakte funktional vorhanden.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Sign-off
|
||||||
|
|
||||||
|
| Rolle | Status | Datum |
|
||||||
|
|---|---|---|
|
||||||
|
| Release Auditor (A0) | ✅ PASS mit WARNs | 2026-07-18 |
|
||||||
|
| User Approval | ⏳ Ausstehend | — |
|
||||||
|
|
||||||
|
**Das System ist bereit für Production Deployment, sobald:**
|
||||||
|
1. Environment Variables für Production konfiguriert sind
|
||||||
|
2. JWT_SECRET und POSTGRES_PASSWORD sicher gesetzt sind
|
||||||
|
3. OpenRouter API Key konfiguriert ist
|
||||||
|
4. Persistent Volumes für Uploads und Database verifiziert sind
|
||||||
|
5. User das Deployment freigibt
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Erstellt von: Release Auditor (A0) — Phase 7 Final Audit*
|
||||||
|
*Projekt: ERP Nutzfahrzeuge — 2026-07-18*
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"extends":"next/core-web-vitals"}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user