617 lines
27 KiB
Markdown
617 lines
27 KiB
Markdown
|
|
# CRM-Build – Pattern-Summary aus wochenplaner-Repo
|
|||
|
|
|
|||
|
|
> **Phase 3 – Codebase Exploration & Pattern Reuse**
|
|||
|
|
> **Sub-Agent:** codebase_explorer (READ-ONLY)
|
|||
|
|
> **Erstellt:** 2026-06-03 22:24 UTC
|
|||
|
|
> **Quellen:** `https://forgejo.media-on.de/Leopoldadmin/wochenplaner` (Branch `master`)
|
|||
|
|
> **Status:** Wochenplaner-Patterns analysiert; rentman-clone NICHT verfügbar
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Section A: Wochenplaner-Patterns (PRIMAER)
|
|||
|
|
|
|||
|
|
Wochenplaner-Architektur ist **Multi-Container** (`backend` + `frontend` mit nginx) im Gegensatz zur ursprünglichen Annahme "Single-Container mit content: mount". Tatsächliche Stack-Bestätigung:
|
|||
|
|
|
|||
|
|
| Layer | Stack |
|
|||
|
|
|---|---|
|
|||
|
|
| Webserver | nginx (Reverse-Proxy + Static) |
|
|||
|
|
| Backend | FastAPI 0.111.0 + uvicorn 0.29.0, sync (nicht async!) |
|
|||
|
|
| DB | SQLite via SQLAlchemy 2.0.35 (sync!) |
|
|||
|
|
| Auth | python-jose 3.3.0 + passlib[bcrypt] 1.7.4 + bcrypt 4.0.1 |
|
|||
|
|
| Validation | pydantic 2.7.0 |
|
|||
|
|
| HTTP-Fileupload | python-multipart 0.0.9 |
|
|||
|
|
|
|||
|
|
**Wichtige Abweichung zur CRM-Architektur (02-architecture.md):**
|
|||
|
|
- Wochenplaner nutzt **sync** SQLAlchemy, CRM braucht **async** (aiosqlite/asyncpg)
|
|||
|
|
- Wochenplaner hat **kein** Alembic (nutzt `Base.metadata.create_all`), CRM braucht Alembic
|
|||
|
|
- Wochenplaner hat **kein** pytest/Coverage-Setup, CRM braucht es (NFR-4: ≥70%)
|
|||
|
|
- Wochenplaner hat **kein** Service-Layer-Pattern (Monolith in main.py, 12.9 KB), CRM braucht Service-Layer
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### Pattern A-1: FastAPI App-Init + CORS + Static-Mount
|
|||
|
|
|
|||
|
|
**Was:** Single-File-Setup mit CORS-Middleware, Static-Files-Mount, Mount-Check via `os.path.isdir`.
|
|||
|
|
**Wo:** `backend/main.py` Zeilen 13-30
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
import os
|
|||
|
|
from fastapi import FastAPI
|
|||
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|||
|
|
from fastapi.staticfiles import StaticFiles
|
|||
|
|
|
|||
|
|
app = FastAPI(title="Wochenplaner API", version="1.0.0")
|
|||
|
|
|
|||
|
|
app.add_middleware(
|
|||
|
|
CORSMiddleware,
|
|||
|
|
allow_origins=["*"],
|
|||
|
|
allow_credentials=True,
|
|||
|
|
allow_methods=["*"],
|
|||
|
|
allow_headers=["*"],
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
FRONTEND_DIR = os.path.join(os.path.dirname(__file__), "frontend")
|
|||
|
|
if os.path.isdir(FRONTEND_DIR):
|
|||
|
|
app.mount("/", StaticFiles(directory=FRONTEND_DIR, html=True), name="frontend")
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**CRM-Anwendung:**
|
|||
|
|
- `allow_origins=["*"]` MUSS ersetzt werden durch Whitelist (NFR-2: CORS-Whitelist, kein `*`)
|
|||
|
|
- Static-Mount in `app/main.py` mounten unter `/static` (nicht `/` – CRM hat eigene Routes)
|
|||
|
|
- `os.path.isdir`-Check übernehmen für optionalen Mount bei Dev-Skips
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### Pattern A-2: JWT-Setup + Token-Encode/Decode
|
|||
|
|
|
|||
|
|
**Was:** `python-jose` JWT mit HS256, 7-Tage-Expiry, Secret aus ENV mit Fallback.
|
|||
|
|
**Wo:** `backend/main.py` Zeilen 32-70
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
import os
|
|||
|
|
from datetime import datetime, timedelta
|
|||
|
|
from jose import JWTError, jwt
|
|||
|
|
|
|||
|
|
SECRET_KEY = os.environ.get("JWT_SECRET", "wochenplaner-jwt-secret-2024")
|
|||
|
|
ALGORITHM = "HS256"
|
|||
|
|
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7 Tage
|
|||
|
|
|
|||
|
|
def create_access_token(data: dict) -> str:
|
|||
|
|
to_encode = data.copy()
|
|||
|
|
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
|||
|
|
to_encode.update({"exp": expire})
|
|||
|
|
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
|||
|
|
|
|||
|
|
def verify_token(token: str) -> dict:
|
|||
|
|
try:
|
|||
|
|
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
|||
|
|
return payload
|
|||
|
|
except JWTError:
|
|||
|
|
raise HTTPException(status_code=401, detail="Ungültiger Token")
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**CRM-Anwendung:**
|
|||
|
|
- JWT-Lib beibehalten (`python-jose[cryptography]` 3.3.0) für 1:1-Übernahme
|
|||
|
|
- Expiry anpassen: 02-architecture/01-requirements spezifiziert 24h für CRM (FR-1.2)
|
|||
|
|
- `SECRET_KEY` MUSS mindestens 32 Zeichen haben (NFR-2: ≥32 Zeichen). Fallback in Prod ist VERBOTEN.
|
|||
|
|
- `verify_token` erweitern um expired-vs-invalid Unterscheidung für `token_expired` Hint (FR-1.7)
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### Pattern A-3: Passwort-Hashing mit passlib + bcrypt
|
|||
|
|
|
|||
|
|
**Was:** CryptContext mit bcrypt-Schema, deprecated="auto" (rüstet Hashes automatisch up).
|
|||
|
|
**Wo:** `backend/main.py` Zeile 35
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
from passlib.context import CryptContext
|
|||
|
|
|
|||
|
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
|||
|
|
|
|||
|
|
# Hashing
|
|||
|
|
password_hash = pwd_context.hash("admin123")
|
|||
|
|
|
|||
|
|
# Verify
|
|||
|
|
if not user or not pwd_context.verify(req.password, user.password_hash):
|
|||
|
|
raise HTTPException(status_code=401, detail="Ungültige Zugangsdaten")
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**CRM-Anwendung:**
|
|||
|
|
- 1:1 übernehmen, `passlib[bcrypt]==1.7.4` und `bcrypt==4.0.1` exakt pinnen (Inkompatibilität 5.x)
|
|||
|
|
- NFR-2 fordert argon2id (Primary) oder bcrypt (Fallback) – bcrypt ist OK
|
|||
|
|
- Default-Rounds sind 12 (passlib-Default), für CRM reicht das, ggf. auf 14 erhöhen wenn Hardware es erlaubt
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### Pattern A-4: get_current_user + require_role Factory
|
|||
|
|
|
|||
|
|
**Was:** Dual-Source-Auth (Header Bearer + Query-Param `?token=`), Role-Factory-Pattern.
|
|||
|
|
**Wo:** `backend/main.py` Zeilen 60-100
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
from fastapi import Depends, Request, HTTPException
|
|||
|
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
|||
|
|
from typing import Optional
|
|||
|
|
|
|||
|
|
security = HTTPBearer(auto_error=False)
|
|||
|
|
|
|||
|
|
def get_current_user(
|
|||
|
|
request: Request,
|
|||
|
|
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security)
|
|||
|
|
) -> dict:
|
|||
|
|
token = None
|
|||
|
|
if credentials:
|
|||
|
|
token = credentials.credentials
|
|||
|
|
if not token:
|
|||
|
|
token = request.query_params.get("token") # WebSocket-Fallback
|
|||
|
|
if not token:
|
|||
|
|
raise HTTPException(status_code=401, detail="Nicht authentifiziert")
|
|||
|
|
return verify_token(token)
|
|||
|
|
|
|||
|
|
def require_role(role: str):
|
|||
|
|
def checker(payload: dict = Depends(get_current_user)):
|
|||
|
|
user_role = payload.get("role", "user")
|
|||
|
|
if user_role == "admin":
|
|||
|
|
return payload # Admin bypass
|
|||
|
|
if user_role != role:
|
|||
|
|
raise HTTPException(status_code=403, detail=f"Rolle '{role}' erforderlich")
|
|||
|
|
return payload
|
|||
|
|
return checker
|
|||
|
|
|
|||
|
|
# Nutzung:
|
|||
|
|
@app.get("/api/admin/users")
|
|||
|
|
def list_users(payload: dict = Depends(require_role("admin"))):
|
|||
|
|
...
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**CRM-Anwendung:**
|
|||
|
|
- `get_current_user` zurückgibt nicht `dict`, sondern ein **User-Model** (async DB-Load). Wochenplaner nutzt nur JWT-Payload (kein DB-Reload) – CRM sollte User neu laden, damit `is_active`/Soft-Delete greift.
|
|||
|
|
- `require_role` für 3 CRM-Rollen: `admin`, `manager`, `rep` (statt admin/user/viewer)
|
|||
|
|
- Query-Param-Token-Fallback für WebSocket-Support ggf. beibehalten, aber NFR-2: Auth ausschließlich via Bearer-Header ist sicherer
|
|||
|
|
- `auto_error=False` beibehalten, damit 401 mit Custom-Detail möglich ist
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### Pattern A-5: get_db() Dependency (sync)
|
|||
|
|
|
|||
|
|
**Was:** Generator-Dependency mit try/finally, SessionLocal-Instanz.
|
|||
|
|
**Wo:** `backend/main.py` Zeilen 102-108
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
def get_db():
|
|||
|
|
db = SessionLocal()
|
|||
|
|
try:
|
|||
|
|
yield db
|
|||
|
|
finally:
|
|||
|
|
db.close()
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**CRM-Anwendung:**
|
|||
|
|
- **Komplett NEU** schreiben als `AsyncSession`-Variante:
|
|||
|
|
```python
|
|||
|
|
# app/core/db.py (CRM)
|
|||
|
|
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
|||
|
|
async with AsyncSessionLocal() as session:
|
|||
|
|
try:
|
|||
|
|
yield session
|
|||
|
|
except Exception:
|
|||
|
|
await session.rollback()
|
|||
|
|
raise
|
|||
|
|
```
|
|||
|
|
- Wochenplaner-Sync-Pattern NICHT übernehmen, weil 02-architecture.md explizit async vorschreibt
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### Pattern A-6: SQLAlchemy-Engine + Session-Factory
|
|||
|
|
|
|||
|
|
**Was:** Engine mit `check_same_thread=False` (SQLite-Workaround), SessionLocal-Standardkonfig.
|
|||
|
|
**Wo:** `backend/models.py` Zeilen 10-15
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
from sqlalchemy import create_engine, Column, String, Text, Integer, Boolean, DateTime
|
|||
|
|
from sqlalchemy.orm import declarative_base, sessionmaker, Session
|
|||
|
|
import os
|
|||
|
|
|
|||
|
|
DATA_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data")
|
|||
|
|
os.makedirs(DATA_DIR, exist_ok=True)
|
|||
|
|
DATABASE_URL = f"sqlite:///{DATA_DIR}/wochenplaner.db"
|
|||
|
|
|
|||
|
|
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
|
|||
|
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
|||
|
|
Base = declarative_base()
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**CRM-Anwendung:**
|
|||
|
|
- URL-Generierung adaptieren: `if os.environ.get("DATABASE_URL"): postgres_url else: sqlite+aiosqlite:///./dev.db`
|
|||
|
|
- `check_same_thread` entfällt komplett (async-Engine nutzt asyncpg/aiosqlite, kein Multi-Thread-Issue)
|
|||
|
|
- `Base = declarative_base()` ersetzen durch `Base = DeclarativeBase` + Type-Annotations (SQLAlchemy 2.0 Style)
|
|||
|
|
- Models: Spalten-Definitionen ohne `Mapped[]` (sync) → CRM mit `Mapped[...] = mapped_column(...)` (async 2.0)
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### Pattern A-7: Docker-Multi-Stage-Setup
|
|||
|
|
|
|||
|
|
**Was:** python:3.12-slim, requirements-copy-then-install (Cache), mkdir data-dir, uvicorn-CMD.
|
|||
|
|
**Wo:** `backend/Dockerfile.backend` (244 Bytes)
|
|||
|
|
|
|||
|
|
```dockerfile
|
|||
|
|
FROM python:3.12-slim
|
|||
|
|
WORKDIR /app
|
|||
|
|
COPY requirements.txt .
|
|||
|
|
RUN pip install --no-cache-dir -r requirements.txt
|
|||
|
|
COPY . .
|
|||
|
|
RUN mkdir -p /app/data && chmod 777 /app/data
|
|||
|
|
EXPOSE 8000
|
|||
|
|
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**CRM-Anwendung:**
|
|||
|
|
- 1:1 übernehmen, **aber**:
|
|||
|
|
- `main:app` ersetzen durch `app.main:app` (CRM nutzt `app/`-Package)
|
|||
|
|
- `/app/data` durch persistentes Volume oder PostgreSQL-Service ersetzen
|
|||
|
|
- **Multi-Stage-Build empfohlen** für CRM: Builder-Stage mit gcc, Final-Stage nur Runtime-Deps (kleinere Images)
|
|||
|
|
- Non-Root-User `USER appuser` für Production-Sicherheit
|
|||
|
|
- Alembic-Startup-Hook: `CMD ["sh", "-c", "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000"]`
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### Pattern A-8: docker-compose mit Coolify-Env-Pattern
|
|||
|
|
|
|||
|
|
**Was:** Multi-Service (backend + frontend mit nginx), Volume-Mount für SQLite, SERVICE_BASE64_64-Pattern für JWT-Secret.
|
|||
|
|
**Wo:** `docker-compose.yml` (429 Bytes) im Repo-Root
|
|||
|
|
|
|||
|
|
```yaml
|
|||
|
|
services:
|
|||
|
|
backend:
|
|||
|
|
build:
|
|||
|
|
context: ./backend
|
|||
|
|
dockerfile: Dockerfile.backend
|
|||
|
|
restart: unless-stopped
|
|||
|
|
environment:
|
|||
|
|
- PORT=80
|
|||
|
|
- JWT_SECRET=${SERVICE_BASE64_64_JWT:-wochenplaner-jwt-secret}
|
|||
|
|
volumes:
|
|||
|
|
- wochenplaner_data:/app/data
|
|||
|
|
|
|||
|
|
frontend:
|
|||
|
|
build:
|
|||
|
|
context: .
|
|||
|
|
dockerfile: frontend/Dockerfile
|
|||
|
|
restart: unless-stopped
|
|||
|
|
|
|||
|
|
volumes:
|
|||
|
|
wochenplaner_data:
|
|||
|
|
driver: local
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**CRM-Anwendung:**
|
|||
|
|
- Pattern 1:1 übernehmen, **aber**:
|
|||
|
|
- Service-Name `crm-api` statt `backend`
|
|||
|
|
- Service `crm-web` (nginx mit statischem Frontend) zusätzlich
|
|||
|
|
- **3. Service `crm-db`** (PostgreSQL) hinzufügen gemäß 02-architecture §1 (Production: PostgreSQL statt SQLite)
|
|||
|
|
- ENV-Keys: `JWT_SECRET`, `DATABASE_URL`, `AUTH_SECRET` (≥32 Zeichen), `CORS_ORIGINS`
|
|||
|
|
- `SERVICE_BASE64_64_*`-Pattern ist Coolify-Standard für Auto-generierte Secrets (64 Zeichen base64) – für CRM:
|
|||
|
|
- `JWT_SECRET=${SERVICE_BASE64_64_JWT}`
|
|||
|
|
- `DATABASE_PASSWORD=${SERVICE_BASE64_32_DB}`
|
|||
|
|
- Volume: Für SQLite-Dev `crm_data:/app/data`, für Production **kein** lokales Volume (PostgreSQL ist externer Service)
|
|||
|
|
- Domain-Eintrag: `https://crm.media-on.de:80` (Port MUSS in URL – Memory-regel)
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### Pattern A-9: nginx-Reverse-Proxy + SPA-Fallback
|
|||
|
|
|
|||
|
|
**Was:** nginx-Config mit `/api/` Proxy zum Backend, `/health` direkter Proxy, alle anderen Routen → `index.html` (SPA-Fallback).
|
|||
|
|
**Wo:** `nginx.conf` im Repo-Root (714 Bytes)
|
|||
|
|
|
|||
|
|
```nginx
|
|||
|
|
server {
|
|||
|
|
listen 80;
|
|||
|
|
server_name _;
|
|||
|
|
|
|||
|
|
root /var/www/html;
|
|||
|
|
index index.html;
|
|||
|
|
|
|||
|
|
location /api/ {
|
|||
|
|
proxy_pass http://backend:8000/api/;
|
|||
|
|
proxy_set_header Host $host;
|
|||
|
|
proxy_set_header X-Real-IP $remote_addr;
|
|||
|
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|||
|
|
proxy_set_header X-Forwarded-Proto $scheme;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
location /health {
|
|||
|
|
proxy_pass http://backend:8000/health;
|
|||
|
|
proxy_set_header Host $host;
|
|||
|
|
proxy_set_header X-Real-IP $remote_addr;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
location / {
|
|||
|
|
try_files $uri $uri/ /index.html; # SPA-Fallback
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**CRM-Anwendung:**
|
|||
|
|
- 1:1 übernehmen für nginx-Service in docker-compose
|
|||
|
|
- **Aber**: CRM hat KEIN SPA mit client-side Routing in v1 (Alpine.js ist seitenbasiert, nicht SPA). `try_files` ist trotzdem OK, weil Alpine-Komponenten auf eigenen HTML-Pages liegen
|
|||
|
|
- `proxy_pass http://crm-api:8000` (Service-Name an CRM anpassen)
|
|||
|
|
- `/docs` (Swagger) und `/redoc` zusätzlich via `location` exposen
|
|||
|
|
- `/metrics` für Prometheus (NFR-5, optional v1.1)
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### Pattern A-10: DB-Init mit Default-Usern (Bootstrap-Pattern)
|
|||
|
|
|
|||
|
|
**Was:** Beim Startup prüfen, ob Default-User existieren, ggf. anlegen.
|
|||
|
|
**Wo:** `backend/main.py` Zeilen 110-140
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
from passlib.context import CryptContext
|
|||
|
|
import uuid
|
|||
|
|
|
|||
|
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
|||
|
|
|
|||
|
|
# Init DB
|
|||
|
|
init_db()
|
|||
|
|
|
|||
|
|
# Create default users if not exist
|
|||
|
|
db = SessionLocal()
|
|||
|
|
try:
|
|||
|
|
if not db.query(User).filter(User.username == "admin").first():
|
|||
|
|
db.add(User(id=str(uuid.uuid4()), username="admin",
|
|||
|
|
password_hash=pwd_context.hash("admin"), role="admin"))
|
|||
|
|
db.add(User(id=str(uuid.uuid4()), username="user",
|
|||
|
|
password_hash=pwd_context.hash("user"), role="user"))
|
|||
|
|
db.add(User(id=str(uuid.uuid4()), username="viewer",
|
|||
|
|
password_hash=pwd_context.hash("viewer"), role="viewer"))
|
|||
|
|
db.commit()
|
|||
|
|
finally:
|
|||
|
|
db.close()
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**CRM-Anwendung:**
|
|||
|
|
- **NICHT 1:1 übernehmen** – Wochenplaner hat hartcodierte Default-User (admin/admin, user/user, viewer/viewer) – das ist ein SECURITY-ISSUE
|
|||
|
|
- CRM-Requirements (FR-1.1) verlangen **Bootstrap-Registrierung**: erste Registrierung nur möglich wenn `users`-Tabelle leer, danach 403
|
|||
|
|
- Pattern adaptieren: Statt Default-User anlegen, **leere DB** lassen und Bootstrap-Endpoint `/api/auth/register` exponieren mit Check `if db.query(User).count() == 0: allow else: 403`
|
|||
|
|
- `uuid.uuid4()` als String-ID übernehmen für v1 (einfacher als Auto-Increment für Replikation)
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### Pattern A-11: Rate-Limiting via DB-Tracking
|
|||
|
|
|
|||
|
|
**Was:** `LoginAttempt`-Tabelle trackt jeden Login-Versuch; 5 Failures in 10 Min → 429.
|
|||
|
|
**Wo:** `backend/main.py` Zeilen 175-200
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
# Rate-Limit-Check
|
|||
|
|
recent_fails = db.query(LoginAttempt).filter(
|
|||
|
|
LoginAttempt.username == req.username,
|
|||
|
|
LoginAttempt.attempted_at > datetime.utcnow() - timedelta(minutes=10),
|
|||
|
|
LoginAttempt.success == False
|
|||
|
|
).count()
|
|||
|
|
|
|||
|
|
if recent_fails >= 5:
|
|||
|
|
raise HTTPException(status_code=429, detail="Zu viele Fehlversuche. Bitte 10 Minuten warten.")
|
|||
|
|
|
|||
|
|
# Nach Verify:
|
|||
|
|
if not user or not pwd_context.verify(req.password, user.password_hash):
|
|||
|
|
db.add(LoginAttempt(username=req.username, success=False))
|
|||
|
|
db.commit()
|
|||
|
|
raise HTTPException(status_code=401, detail="Ungültige Zugangsdaten")
|
|||
|
|
|
|||
|
|
db.add(LoginAttempt(username=req.username, success=True))
|
|||
|
|
db.commit()
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**CRM-Anwendung:**
|
|||
|
|
- Pattern 1:1 übernehmen, **aber**:
|
|||
|
|
- NFR-2 fordert 60 req/min/IP (SlowAPI oder ähnliches) – Wochenplaner-Lösung ist Username-basiert, CRM sollte IP-basiert (oder beides)
|
|||
|
|
- Tabelle `login_attempts` mit Cleanup-Job (alle 24h alte Einträge löschen)
|
|||
|
|
- IP-Tracking: `request.client.host` zusätzlich speichern
|
|||
|
|
- Alternative: `slowapi`-Lib nutzen statt Custom-Tracking (moderneres Python-Idiom)
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### Pattern A-12: Health-Endpoint
|
|||
|
|
|
|||
|
|
**Was:** Minimaler `/health` mit Timestamp.
|
|||
|
|
**Wo:** `backend/main.py` Zeile 290
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
@app.get("/health")
|
|||
|
|
def health():
|
|||
|
|
return {"status": "ok", "time": datetime.utcnow().isoformat()}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**CRM-Anwendung:**
|
|||
|
|
- 1:1 übernehmen, **erweitern** um DB-Connection-Check (NFR-5: 503 wenn DB down):
|
|||
|
|
```python
|
|||
|
|
# CRM app/api/v1/health.py
|
|||
|
|
@router.get("/health")
|
|||
|
|
async def health(db: AsyncSession = Depends(get_db)):
|
|||
|
|
try:
|
|||
|
|
await db.execute(text("SELECT 1"))
|
|||
|
|
return {"status": "ok", "db": "ok", "time": datetime.utcnow().isoformat()}
|
|||
|
|
except Exception as e:
|
|||
|
|
raise HTTPException(status_code=503, detail=f"DB down: {e}")
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Section B: Rentman-Clone-Repo
|
|||
|
|
|
|||
|
|
**Status:** NICHT GEFUNDEN via forgejo-Suche (Such-Queries: `rentman`, `fastapi-crm`)
|
|||
|
|
**Suchergebnis:** Beide Queries lieferten `data: []` (0 Repositories)
|
|||
|
|
**Konsequenz:** Kein zusätzlicher Pattern-Reuse möglich. CRM-Implementation basiert nur auf wochenplaner-Patterns + 02-architecture.md Specs + 01-requirements.md Akzeptanzkriterien.
|
|||
|
|
|
|||
|
|
**Alternative Pattern-Quellen (NICHT genutzt, da außerhalb des Auftrags):**
|
|||
|
|
- Eigene FastAPI-Projekte (z.B. persönliche Templates) – nicht in scope
|
|||
|
|
- Public Open-Source-CRMs (Twenty, EspoCRM) – haben komplett anderen Stack (TypeORM/Node)
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Section C: Pattern-Mapping auf CRM-Tasks
|
|||
|
|
|
|||
|
|
| Pattern (aus wochenplaner) | CRM-Phase | CRM-Task-ID | CRM-Datei-Pfad | Änderungsbedarf |
|
|||
|
|
|---|---|---|---|---|
|
|||
|
|
| A-1 App-Init + CORS + Static-Mount | 4a | `main-app` | `app/main.py` | `allow_origins` Whitelist; Static-Mount unter `/static`; Frontend-Pages separat |
|
|||
|
|
| A-2 JWT-Setup (python-jose, HS256) | 4a | `core-modules` | `app/core/security.py` | Expiry 24h statt 7 Tage; ≥32 Zeichen Secret; `token_expired` Hint |
|
|||
|
|
| A-3 bcrypt + passlib | 4a | `core-modules` | `app/core/security.py` | 1:1 übernehmen, Rounds 12 OK |
|
|||
|
|
| A-4 get_current_user + require_role | 4a | `core-modules`, `auth-routes` | `app/core/deps.py` | User-Model-Return statt dict; Rollen admin/manager/rep; Query-Param-Fallback prüfen |
|
|||
|
|
| A-5 get_db() (sync) | 4a | `core-modules` | `app/core/db.py` | **Komplett async** umschreiben mit AsyncSession |
|
|||
|
|
| A-6 SQLAlchemy-Engine + Session | 4a | `core-modules` | `app/core/db.py` | async-Engine; URL-Switch SQLite/Postgres; `Mapped[]`-Types 2.0 |
|
|||
|
|
| A-7 Dockerfile python:3.12-slim | 4d | `dockerfile` | `Dockerfile` | 1:1 + Multi-Stage + Non-Root + alembic-migrate-Start |
|
|||
|
|
| A-8 docker-compose Multi-Container | 4d | `docker-compose` | `docker-compose.yml` | Service-Namen crm-api/crm-web/crm-db; Postgres-Service; SERVICE_BASE64_64-ENV |
|
|||
|
|
| A-9 nginx Reverse-Proxy + SPA | 4d | `docker-compose` (frontend) | `frontend/nginx.conf` | Service-Name crm-api; /docs + /metrics exposen |
|
|||
|
|
| A-10 DB-Init Default-User | 4a | `auth-routes` | `app/api/v1/auth.py` | **NICHT übernehmen** (Security); Bootstrap-Register-Endpoint mit User-count-Check |
|
|||
|
|
| A-11 Rate-Limiting DB-Tracking | 4a | `auth-routes` | `app/services/auth_service.py` | IP+Username-basiert; 60 req/min NFR-2; Cleanup-Job |
|
|||
|
|
| A-12 Health-Endpoint | 4a | `health-route` | `app/api/v1/health.py` | + DB-Connection-Check (NFR-5) |
|
|||
|
|
| Static-Files-Mount (Frontend) | 4c | `frontend-foundation` | `app/main.py` (mount) + `webui/` | Mount unter `/static`; HTML-Pages pro Feature |
|
|||
|
|
| nginx-SPA-Fallback | 4c | `frontend-foundation` | `frontend/nginx.conf` | 1:1 OK, aber CRM nicht zwingend SPA |
|
|||
|
|
| CORS-Wildcard | 4a | `main-app` | `app/main.py` | **MUSS ersetzt werden** durch Whitelist (NFR-2) |
|
|||
|
|
| bcrypt 4.0.1 pinning | 4a | `core-modules` | `backend/requirements.txt` | Versionen exakt pinnen (sqlalchemy 2.0.35, bcrypt 4.0.1, passlib 1.7.4) |
|
|||
|
|
| LoginAttempt-Table | 4a | `models-base` (optional in 4a) | `app/models/login_attempt.py` | Optional v1, ggf. erst v1.1 |
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Section D: Risiken und Lessons Learned
|
|||
|
|
|
|||
|
|
### Risiko 1: Sync-SQLAlchemy → Async-Migration
|
|||
|
|
**Wochenplaner-Pattern:** Sync-Engine + sync-Session
|
|||
|
|
**CRM-Anforderung:** Async SQLAlchemy 2.0 + aiosqlite (Dev) / asyncpg (Prod)
|
|||
|
|
**Lesson:** Sync-Code aus wochenplaner (alle Endpoint-Handler, alle DB-Queries) MUSS in CRM async umgeschrieben werden. Pattern `db.query()` → `select()` + `await session.execute()`. Risiko: sehr viele Stellen, Fehleranfälligkeit bei vergessenen `await`.
|
|||
|
|
**Mitigation:** Implementation-Engineer muss async-Tests (pytest-asyncio) früh schreiben.
|
|||
|
|
|
|||
|
|
### Risiko 2: Kein Alembic im wochenplaner
|
|||
|
|
**Wochenplaner-Pattern:** `init_db()` ruft `Base.metadata.create_all(engine)` auf – keine Migrations.
|
|||
|
|
**CRM-Anforderung:** Alembic mit async-env.py, Migrationen 0001_init, 0002_business_entities.
|
|||
|
|
**Lesson:** CRM kann wochenplaner-Pattern NICHT für Schema-Migration nutzen. Neuer Setup nötig.
|
|||
|
|
**Mitigation:** `alembic init -t async` mit asyncpg/aiosqlite-URL.
|
|||
|
|
|
|||
|
|
### Risiko 3: Hardcoded Default-User (admin/admin)
|
|||
|
|
**Wochenplaner-Pattern:** Beim Start werden admin/admin123, user/user, viewer/viewer auto-erzeugt.
|
|||
|
|
**CRM-Anforderung:** FR-1.1: Bootstrap-Registrierung NUR wenn User-Tabelle leer.
|
|||
|
|
**Lesson:** 1:1-Übernahme wäre SECURITY-INCIDENT. Wochenplaner hat Default-User in Production weil `app.run()` immer beim Start ausgeführt wird.
|
|||
|
|
**Mitigation:** CRM-Implementation MUSS Pattern A-10 verwerfen und Bootstrap-Endpoint mit `if count == 0` implementieren.
|
|||
|
|
|
|||
|
|
### Risiko 4: CORS-Wildcard erlaubt alles
|
|||
|
|
**Wochenplaner-Pattern:** `allow_origins=["*"]`
|
|||
|
|
**CRM-Anforderung:** NFR-2: Explizite CORS-Whitelist.
|
|||
|
|
**Lesson:** 1:1-Übernahme verletzt NFR-2.
|
|||
|
|
**Mitigation:** CRM `main.py` nutzt `allow_origins=[os.environ.get("CORS_ORIGINS", "https://crm.media-on.de")].split(",")`.
|
|||
|
|
|
|||
|
|
### Risiko 5: JWT-Secret-Fallback in Production
|
|||
|
|
**Wochenplaner-Pattern:** `os.environ.get("JWT_SECRET", "wochenplaner-jwt-secret-2024")` – Fallback hardcoded.
|
|||
|
|
**CRM-Anforderung:** NFR-2: `AUTH_SECRET` ≥32 Zeichen aus Coolify Env-Vars.
|
|||
|
|
**Lesson:** Fallback in Production ist SECURITY-RISK. Bei vergessenem ENV-Var läuft App mit known Secret.
|
|||
|
|
**Mitigation:** CRM `config.py` (Pydantic-Settings) MUSS `AUTH_SECRET: str = Field(..., min_length=32)` – kein Default.
|
|||
|
|
|
|||
|
|
### Risiko 6: bcrypt-Version-Inkompatibilität
|
|||
|
|
**Wochenplaner-Pattern:** `bcrypt==4.0.1` exakt gepinnt.
|
|||
|
|
**CRM-Anforderung:** bcrypt für Passwort-Hashing (NFR-2 erlaubt bcrypt als Fallback zu argon2id).
|
|||
|
|
**Lesson:** bcrypt 4.1+ hat `__about__`-Attribute-Änderung, die passlib bricht. 4.0.1 ist letzte stable Version für passlib.
|
|||
|
|
**Mitigation:** CRM-requirements.txt MUSS `bcrypt==4.0.1` exakt pinnen + `passlib[bcrypt]==1.7.4`.
|
|||
|
|
|
|||
|
|
### Risiko 7: SQLAlchemy 2.0 Mixed-Style
|
|||
|
|
**Wochenplaner-Pattern:** `Column(String)` ohne `Mapped[]`-Type-Annotations.
|
|||
|
|
**CRM-Anforderung:** SQLAlchemy 2.0 mit modernem `Mapped[T]` + `mapped_column()` Style.
|
|||
|
|
**Lesson:** Wochenplaner nutzt 1.x-Style. CRM muss komplett 2.0-Style schreiben für mypy-strict-Compliance (NFR-4).
|
|||
|
|
**Mitigation:** Codebase-Explorer-Output: CRM-Models komplett neu schreiben, kein Copy-Paste aus wochenplaner/models.py.
|
|||
|
|
|
|||
|
|
### Risiko 8: Statisches Volume statt PostgreSQL
|
|||
|
|
**Wochenplaner-Pattern:** `wochenplaner_data:/app/data` mit SQLite.
|
|||
|
|
**CRM-Anforderung:** PostgreSQL als Production-DB (NFR-3, horizontale Skalierung).
|
|||
|
|
**Lesson:** SQLite-Limit (single-writer) macht horizontale Skalierung unmöglich. Volume-Pattern funktioniert nur für Single-Instance.
|
|||
|
|
**Mitigation:** CRM docker-compose MUSS PostgreSQL-Service + Healthcheck + Backup-Volume enthalten. SQLite nur für Dev (`aiosqlite:///./dev.db`).
|
|||
|
|
|
|||
|
|
### Risiko 9: Domain-URL ohne Port
|
|||
|
|
**Wochenplaner-Realität:** Domain `https://reinigung.media-on.de:80` mit Port (Memory-regel)
|
|||
|
|
**CRM-Anforderung:** Domain `crm.media-on.de:80` mit Port (selbe Regel)
|
|||
|
|
**Lesson:** User-Definitive-Rule: Port MUSS in Coolify-Domain-URL.
|
|||
|
|
**Mitigation:** Implementation-Engineer MUSS bei Coolify-Service-Setup `urls: ["https://crm.media-on.de:80"]` setzen (PATCH /api/v1/services/{uuid}).
|
|||
|
|
|
|||
|
|
### Risiko 10: LoginAttempt-Persistenz vs. Cleanup
|
|||
|
|
**Wochenplaner-Pattern:** LoginAttempt-Tabelle wächst unbegrenzt.
|
|||
|
|
**CRM-Anforderung:** NFR-4 Maintainability.
|
|||
|
|
**Lesson:** Kein Cleanup-Job definiert → Tabelle wächst → Performance-Degradation.
|
|||
|
|
**Mitigation:** CRM-Implementation mit Cleanup-Task (v1.1) oder TTL-Index.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Section E: Empfehlung für Phase 4
|
|||
|
|
|
|||
|
|
### E-1: 1:1 übernehmbare Files (mit kleinen Anpassungen)
|
|||
|
|
|
|||
|
|
1. **`backend/Dockerfile.backend` → CRM `Dockerfile`** (Pattern A-7)
|
|||
|
|
- Anpassungen: `main:app` → `app.main:app`, Multi-Stage-Build, Non-Root-User, alembic-Migration vor uvicorn
|
|||
|
|
2. **`docker-compose.yml` → CRM `docker-compose.yml`** (Pattern A-8)
|
|||
|
|
- Anpassungen: Service-Namen `crm-api`/`crm-web`/`crm-db`, Postgres-Service hinzu, `DATABASE_URL` und `JWT_SECRET` als SERVICE_BASE64_64
|
|||
|
|
3. **`nginx.conf` → CRM `frontend/nginx.conf`** (Pattern A-9)
|
|||
|
|
- Anpassungen: `proxy_pass http://crm-api:8000`, /docs + /metrics exposen
|
|||
|
|
4. **`requirements.txt` → CRM `backend/requirements.txt`** (Pinning-Liste)
|
|||
|
|
- Anpassungen: `pydantic` 2.7.0 → 2.9+ (aktuelle Sicherheitspatches), `alembic` + `asyncpg`/`aiosqlite` + `pytest` + `pytest-asyncio` + `httpx` + `slowapi` ergänzen
|
|||
|
|
|
|||
|
|
### E-2: 1:1 übernehmbare Code-Snippets (in andere Files integrieren)
|
|||
|
|
|
|||
|
|
1. **JWT-Encode/Decode-Funktionen** (Pattern A-2) → `app/core/security.py`
|
|||
|
|
2. **CryptContext-Setup** (Pattern A-3) → `app/core/security.py`
|
|||
|
|
3. **require_role Factory-Pattern** (Pattern A-4) → `app/core/deps.py`
|
|||
|
|
4. **Rate-Limiting-Logik** (Pattern A-11) → `app/services/auth_service.py` (erweitert um IP-Tracking)
|
|||
|
|
5. **Health-Endpoint-Logik** (Pattern A-12) → `app/api/v1/health.py` (erweitert um DB-Check)
|
|||
|
|
|
|||
|
|
### E-3: Komplett neu zu schreibende Files
|
|||
|
|
|
|||
|
|
1. **`app/core/db.py`** (Async SQLAlchemy + Alembic-kompatibel) – Pattern A-5+6 nur als Referenz
|
|||
|
|
2. **`app/models/base.py`** (Base + TimestampMixin + SoftDeleteMixin + OrgScopedMixin) – wochenplaner hat nur 1.x-Style
|
|||
|
|
3. **`app/models/org.py` + `app/models/user.py`** (mit org_id-FK) – wochenplaner hat keine Org-Trennung
|
|||
|
|
4. **Alle Service-Layer-Files** (`app/services/*_service.py`) – wochenplaner hat keinen Service-Layer
|
|||
|
|
5. **Alle Schema-Files** (`app/schemas/*`) – wochenplaner hat nur inline Pydantic-Models in main.py
|
|||
|
|
6. **`app/api/v1/*.py` Router** – wochenplaner hat alles in einem 12.9 KB main.py
|
|||
|
|
7. **`alembic/env.py` + `alembic/versions/0001_init.py`** – wochenplaner hat keine Alembic
|
|||
|
|
8. **`app/core/config.py`** (Pydantic-Settings) – wochenplaner nutzt rohe `os.environ.get`
|
|||
|
|
9. **Frontend-Files** (`webui/index.html`, `webui/app.html`, `webui/js/*`, `webui/css/*`) – wochenplaner-Frontend nutzt Vanilla-JS, CRM braucht Alpine.js + Tailwind CDN
|
|||
|
|
|
|||
|
|
### E-4: ENV-Vars, die schon im wochenplaner-Coolify funktionieren
|
|||
|
|
|
|||
|
|
Aus `docker-compose.yml` und Memory-Analyse:
|
|||
|
|
|
|||
|
|
| ENV-Var | wochenplaner-Wert | CRM-Anwendung |
|
|||
|
|
|---|---|---|
|
|||
|
|
| `PORT` | `80` (intern) | `8000` (intern, dann via nginx exposed) |
|
|||
|
|
| `JWT_SECRET` | `${SERVICE_BASE64_64_JWT:-fallback}` | `${SERVICE_BASE64_64_JWT}` (KEIN Fallback) |
|
|||
|
|
| `JWT_SECRET_MIN_LENGTH` | – | `32` (Pydantic-Validation) |
|
|||
|
|
| `DATABASE_URL` | – (SQLite intern) | `${SERVICE_BASE64_32_DB_URL}` (PostgreSQL) |
|
|||
|
|
| `CORS_ORIGINS` | `*` (hardcoded) | `https://crm.media-on.de:80` (Whitelist) |
|
|||
|
|
| `ENV` | – | `production` / `development` |
|
|||
|
|
| `LOG_LEVEL` | – | `INFO` / `DEBUG` |
|
|||
|
|
|
|||
|
|
**Coolify-Setup-Empfehlung für CRM:**
|
|||
|
|
- 3 Services in Coolify: `crm-api` (FastAPI), `crm-web` (nginx), `crm-db` (PostgreSQL)
|
|||
|
|
- Service-UUIDs: Implementation-Engineer MUSS nach Coolify-Create `PATCH /api/v1/services/{uuid}` mit `urls: ["https://crm.media-on.de:80"]` aufrufen
|
|||
|
|
- Domain: `crm.media-on.de:80` (Port zwingend)
|
|||
|
|
- SERVICE_BASE64_64_JWT, SERVICE_BASE64_32_DB_URL werden auto-generiert von Coolify
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Zusammenfassung
|
|||
|
|
|
|||
|
|
| Metrik | Wert |
|
|||
|
|
|---|---|
|
|||
|
|
| **Sektionen** | 5 (A, B, C, D, E) |
|
|||
|
|
| **Dokumentierte Patterns (Section A)** | 12 (A-1 bis A-12) |
|
|||
|
|
| **CRM-Tasks in Mapping-Tabelle (Section C)** | 17 Zeilen |
|
|||
|
|
| **Risiken/Lessons Learned (Section D)** | 10 |
|
|||
|
|
| **Empfehlungen (Section E)** | 4 Sub-Sektionen |
|
|||
|
|
| **Patterns 1:1 übernehmbar** | 4 Files + 5 Code-Snippets |
|
|||
|
|
| **Patterns neu zu schreiben** | 9 File-Kategorien |
|
|||
|
|
| **Wochenplaner als Quelle** | ✅ analysiert (forgejo, 6 Files gelesen) |
|
|||
|
|
| **Rentman-clone als Quelle** | ❌ NICHT GEFUNDEN |
|
|||
|
|
| **Empfehlung** | **GO für Phase 4a** (mit expliziten Warnungen zu Risiken 3, 4, 5, 8) |
|
|||
|
|
|
|||
|
|
**Offene Punkte für Implementation-Engineer (Phase 4a):**
|
|||
|
|
1. Soll `python-jose` (wochenplaner-konsistent) ODER `PyJWT` (moderneres Python-Idiom) genutzt werden? – Memory sagt python-jose, also beibehalten
|
|||
|
|
2. SQLite-only für Dev ODER parallel PostgreSQL-Dev? – 02-architecture sagt SQLite-Dev, also beibehalten
|
|||
|
|
3. CSP-Header (R-5 Mitigation) im nginx.conf oder im FastAPI-Middleware? – 02-architecture nennt main.py (Middleware), also dort
|
|||
|
|
4. Soll `LoginAttempt`-Tabelle in v1 oder erst v1.1 kommen? – Phase 4a nicht zwingend
|
|||
|
|
|