docs: Phase 2 — Aufgaben 11-15 (Intake, Game-Service, Admin, Structlog, Integration)
This commit is contained in:
@@ -0,0 +1,336 @@
|
||||
# Alicia — Aufgaben 11–15: BSN-Livesystem Phase 2
|
||||
|
||||
> **Repo:** `git@git.datenhimmel.work:danielkrause/BSN-Livesystem.git`
|
||||
> **Branch:** `main`
|
||||
> **Stand:** Aufgaben 1–10 sind fertig. Alle 10 erledigt.
|
||||
> **Arbeitsverzeichnis:** `/tmp/BSN-Livesystem`
|
||||
> **Qualitäts-Check vor JEDEM Commit:** `ruff check . && ruff format . && mypy --config-file pyproject.toml . && pytest -v`
|
||||
> **Fehler?** Korrigieren, Checks wiederholen. Nicht aufgeben bis ALLE grün.
|
||||
|
||||
---
|
||||
|
||||
## Aufgabe 11 — Message-Intake-Service
|
||||
|
||||
Parst eingehende Nachrichten und extrahiert Kommandos.
|
||||
|
||||
### Datei: `src/services/__init__.py`
|
||||
Leer lassen.
|
||||
|
||||
### Datei: `src/services/intake.py`
|
||||
|
||||
```python
|
||||
from __future__ import annotations
|
||||
from enum import StrEnum
|
||||
|
||||
from src.models.submission import Submission
|
||||
|
||||
|
||||
class KommandoTyp(StrEnum):
|
||||
"""Bekannte Benutzer-Kommandos."""
|
||||
SPIEL_SUCHEN = "spiel_suchen"
|
||||
SPIEL_BEWERTEN = "spiel_bewerten"
|
||||
HILFE = "hilfe"
|
||||
UNBEKANNT = "unbekannt"
|
||||
|
||||
|
||||
def erkenne_kommando(text: str) -> KommandoTyp:
|
||||
"""Erkennt das Kommando aus dem Nachrichtentext.
|
||||
|
||||
Args:
|
||||
text: Der Nachrichtentext.
|
||||
|
||||
Returns:
|
||||
Das erkannte KommandoTyp-Enum.
|
||||
"""
|
||||
text_lower = text.lower().strip()
|
||||
|
||||
if text_lower.startswith("suche") or text_lower.startswith("finde"):
|
||||
return KommandoTyp.SPIEL_SUCHEN
|
||||
if text_lower.startswith("bewerte") or text_lower.startswith("bewertung"):
|
||||
return KommandoTyp.SPIEL_BEWERTEN
|
||||
if text_lower in ("hilfe", "help", "?", "commands"):
|
||||
return KommandoTyp.HILFE
|
||||
|
||||
return KommandoTyp.UNBEKANNT
|
||||
|
||||
|
||||
def extrahiere_suchbegriff(text: str) -> str | None:
|
||||
"""Extrahiert den Suchbegriff aus einer Such-Nachricht.
|
||||
|
||||
Args:
|
||||
text: Der Nachrichtentext (z.B. "suche Catan").
|
||||
|
||||
Returns:
|
||||
Der Suchbegriff ohne Kommando-Präfix, oder None.
|
||||
"""
|
||||
for praefix in ("suche ", "finde "):
|
||||
if text.lower().startswith(praefix):
|
||||
begriff = text[len(praefix):].strip()
|
||||
return begriff if begriff else None
|
||||
return None
|
||||
|
||||
|
||||
def verarbeite_submission(submission: Submission) -> dict[str, object]:
|
||||
"""Verarbeitet eine Submission und gibt eine Antwort-Struktur zurück.
|
||||
|
||||
Args:
|
||||
submission: Die eingegangene Submission.
|
||||
|
||||
Returns:
|
||||
Ein Dict mit 'antwort' und 'kommando'-Keys.
|
||||
"""
|
||||
kommando = erkenne_kommando(submission.text)
|
||||
|
||||
if kommando == KommandoTyp.HILFE:
|
||||
return {
|
||||
"kommando": kommando.value,
|
||||
"antwort": (
|
||||
"📋 Verfügbare Kommandos:\n"
|
||||
"• suche <spielname> — Spiel suchen\n"
|
||||
"• bewerte <spielname> — Spiel bewerten\n"
|
||||
"• hilfe — Diese Hilfe anzeigen"
|
||||
),
|
||||
}
|
||||
|
||||
if kommando == KommandoTyp.SPIEL_SUCHEN:
|
||||
suchbegriff = extrahiere_suchbegriff(submission.text)
|
||||
if suchbegriff:
|
||||
return {
|
||||
"kommando": kommando.value,
|
||||
"antwort": f"🔍 Suche nach '{suchbegriff}'...",
|
||||
"suchbegriff": suchbegriff,
|
||||
}
|
||||
return {
|
||||
"kommando": kommando.value,
|
||||
"antwort": "❌ Bitte gib einen Suchbegriff an: suche <spielname>",
|
||||
}
|
||||
|
||||
if kommando == KommandoTyp.SPIEL_BEWERTEN:
|
||||
return {
|
||||
"kommando": kommando.value,
|
||||
"antwort": "⭐ Bewertungsmodus aktiviert. Welches Spiel möchtest du bewerten?",
|
||||
}
|
||||
|
||||
return {
|
||||
"kommando": KommandoTyp.UNBEKANNT.value,
|
||||
"antwort": (
|
||||
"🤔 Das habe ich nicht verstanden. Schreib 'hilfe' "
|
||||
"für eine Liste der Kommandos."
|
||||
),
|
||||
}
|
||||
```
|
||||
|
||||
### Test-Datei: `tests/test_intake.py`
|
||||
|
||||
7 Tests:
|
||||
1. `"suche Catan"` → KommandoTyp.SPIEL_SUCHEN
|
||||
2. `"suche"` ohne Begriff → Suchbegriff None
|
||||
3. `"suche Catan"` → extrahiert "Catan"
|
||||
4. `"bewerte Carcassonne"` → KommandoTyp.SPIEL_BEWERTEN
|
||||
5. `"hilfe"` → KommandoTyp.HILFE
|
||||
6. `"blabla"` → KommandoTyp.UNBEKANNT
|
||||
7. `verarbeite_submission` mit "hilfe" → Antwort enthält "Verfügbare Kommandos"
|
||||
|
||||
**Commit:** `feat: Message-Intake-Service mit Kommando-Erkennung`
|
||||
|
||||
---
|
||||
|
||||
## Aufgabe 12 — Game-Service (Spiele-Datenbank & CRUD)
|
||||
|
||||
### Datei: `src/services/game_service.py`
|
||||
|
||||
```python
|
||||
from __future__ import annotations
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class Spiel(BaseModel):
|
||||
"""Ein Brettspiel-Datenmodell."""
|
||||
id: int | None = None
|
||||
name: str = Field(..., min_length=1, max_length=200)
|
||||
min_spieler: int = Field(default=1, ge=1, le=20)
|
||||
max_spieler: int = Field(default=10, ge=1, le=20)
|
||||
dauer_min: int = Field(default=30, ge=1)
|
||||
komplexitaet: float = Field(default=2.5, ge=1.0, le=5.0)
|
||||
kategorie: str = Field(default="Familienspiel")
|
||||
|
||||
|
||||
class GameService:
|
||||
"""Verwaltet die Spiele-Datenbank (In-Memory für jetzt, später DB)."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._spiele: dict[int, Spiel] = {}
|
||||
self._next_id = 1
|
||||
|
||||
def hinzufuegen(self, spiel: Spiel) -> Spiel:
|
||||
"""Fügt ein Spiel hinzu und weist eine ID zu."""
|
||||
spiel.id = self._next_id
|
||||
self._spiele[self._next_id] = spiel
|
||||
self._next_id += 1
|
||||
return spiel
|
||||
|
||||
def alle(self) -> list[Spiel]:
|
||||
"""Gibt alle Spiele zurück."""
|
||||
return list(self._spiele.values())
|
||||
|
||||
def suchen(self, name: str) -> list[Spiel]:
|
||||
"""Sucht Spiele nach Name (case-insensitive Teilstring)."""
|
||||
name_lower = name.lower()
|
||||
return [
|
||||
spiel
|
||||
for spiel in self._spiele.values()
|
||||
if name_lower in spiel.name.lower()
|
||||
]
|
||||
|
||||
def nach_id(self, spiel_id: int) -> Spiel | None:
|
||||
"""Holt ein Spiel anhand seiner ID."""
|
||||
return self._spiele.get(spiel_id)
|
||||
```
|
||||
|
||||
### Test-Datei: `tests/test_game_service.py`
|
||||
|
||||
6 Tests:
|
||||
1. Hinzufügen eines Spiels → ID wird vergeben
|
||||
2. Alle Spiele → leere Liste am Anfang
|
||||
3. Alle Spiele → 2 Spiele nach Hinzufügen
|
||||
4. Suchen nach "cat" → findet "Catan"
|
||||
5. Suchen nach "xyz" → leere Liste
|
||||
6. Nach ID holen → korrektes/nicht existentes Spiel
|
||||
|
||||
**Commit:** `feat: Game-Service mit In-Memory-CRUD und Suche`
|
||||
|
||||
---
|
||||
|
||||
## Aufgabe 13 — Admin-Route (Queue-Status)
|
||||
|
||||
### Datei: `src/routes/admin.py`
|
||||
|
||||
Blueprint `"admin"` mit `/admin/queue` (GET) und `/admin/stats` (GET):
|
||||
|
||||
```python
|
||||
from flask import Blueprint, current_app, jsonify
|
||||
|
||||
admin_bp = Blueprint("admin", __name__)
|
||||
|
||||
|
||||
@admin_bp.route("/admin/queue", methods=["GET"])
|
||||
def queue_status() -> tuple:
|
||||
"""Gibt Queue-Status zurück."""
|
||||
qh = current_app.queue_handler
|
||||
return jsonify({
|
||||
"queue_size": qh.queue_size(),
|
||||
"status": "ok",
|
||||
}), 200
|
||||
|
||||
|
||||
@admin_bp.route("/admin/stats", methods=["GET"])
|
||||
def stats() -> tuple:
|
||||
"""Gibt System-Statistiken zurück."""
|
||||
qh = current_app.queue_handler
|
||||
return jsonify({
|
||||
"queue_size": qh.queue_size(),
|
||||
"uptime": "placeholder",
|
||||
"version": "0.1.0",
|
||||
}), 200
|
||||
```
|
||||
|
||||
### Test-Datei: `tests/test_admin.py`
|
||||
|
||||
4 Tests mit `app.test_client()`:
|
||||
1. GET `/admin/queue` → 200 + JSON mit queue_size
|
||||
2. GET `/admin/queue` → queue_size ist Integer
|
||||
3. GET `/admin/stats` → 200 + version "0.1.0"
|
||||
4. GET `/admin/stats` → JSON enthält alle Keys
|
||||
|
||||
**Commit:** `feat: Admin-Route mit Queue-Status und Statistiken`
|
||||
|
||||
---
|
||||
|
||||
## Aufgabe 14 — Structlog-Integration
|
||||
|
||||
### Datei: `src/logging_config.py`
|
||||
|
||||
```python
|
||||
from __future__ import annotations
|
||||
import structlog
|
||||
|
||||
|
||||
def setup_logging() -> None:
|
||||
"""Konfiguriert strukturiertes Logging mit structlog."""
|
||||
structlog.configure(
|
||||
processors=[
|
||||
structlog.stdlib.filter_by_level,
|
||||
structlog.stdlib.add_logger_name,
|
||||
structlog.stdlib.add_log_level,
|
||||
structlog.stdlib.PositionalArgumentsFormatter(),
|
||||
structlog.processors.TimeStamper(fmt="iso"),
|
||||
structlog.processors.StackInfoRenderer(),
|
||||
structlog.processors.format_exc_info,
|
||||
structlog.processors.UnicodeDecoder(),
|
||||
structlog.dev.ConsoleRenderer(),
|
||||
],
|
||||
context_class=dict,
|
||||
logger_factory=structlog.stdlib.LoggerFactory(),
|
||||
wrapper_class=structlog.stdlib.BoundLogger,
|
||||
cache_logger_on_first_use=True,
|
||||
)
|
||||
|
||||
|
||||
def get_logger(name: str) -> structlog.stdlib.BoundLogger:
|
||||
"""Erstellt einen benannten Logger.
|
||||
|
||||
Args:
|
||||
name: Logger-Name (üblicherweise __name__).
|
||||
|
||||
Returns:
|
||||
Konfigurierter structlog-Logger.
|
||||
"""
|
||||
return structlog.get_logger(name)
|
||||
```
|
||||
|
||||
### Integration in `src/app.py`
|
||||
|
||||
Ergänze in `create_app()` direkt nach `app = Flask(...)`:
|
||||
```python
|
||||
from src.logging_config import setup_logging
|
||||
setup_logging()
|
||||
```
|
||||
|
||||
### Test-Datei: `tests/test_logging.py`
|
||||
|
||||
3 Tests:
|
||||
1. `setup_logging()` läuft ohne Fehler durch
|
||||
2. `get_logger(__name__)` gibt BoundLogger zurück
|
||||
3. Logger.info() produziert keinen Fehler
|
||||
|
||||
**Commit:** `feat: Structlog-Integration mit Console-Renderer`
|
||||
|
||||
---
|
||||
|
||||
## Aufgabe 15 — Integration-Tests (End-to-End)
|
||||
|
||||
### Datei: `tests/test_integration.py`
|
||||
|
||||
Testet den kompletten Flow: Webhook → Queue → Worker → Submission.
|
||||
|
||||
5 Tests mit `app.test_client()` und fakeredis:
|
||||
1. WhatsApp POST mit gültiger Signatur → 200, Submission in Queue
|
||||
2. WhatsApp POST mit ungültiger Signatur → 401
|
||||
3. Telegram POST mit gültigem Payload → 200
|
||||
4. Vollständiger Flow: WhatsApp POST → Worker verarbeitet → Status "erledigt"
|
||||
5. Vollständiger Flow: Telegram POST → Worker verarbeitet → Status "erledigt"
|
||||
|
||||
Für Test 4+5: Enqueue via POST, dann `worker_loop` mit gemocktem `time.sleep` aufrufen, danach prüfen ob Queue leer.
|
||||
|
||||
**Commit:** `test: Integration-Tests für WhatsApp/Telegram-Webhook-Flows`
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ WICHTIG
|
||||
|
||||
- **Bluesprints registrieren:** Bei Aufgaben 13 und 15 musst du in `create_app()` temporär die neuen Blueprints registrieren (admin_bp, whatsapp_bp, telegram_bp), damit die Tests mit `app.test_client()` funktionieren.
|
||||
- **worker_loop-Timeout:** Mocke `time.sleep` in Integration-Tests, sonst hängt die Endlos-Schleife.
|
||||
- **Qualität:** `ruff check . && ruff format . && mypy --config-file pyproject.toml . && pytest -v` vor JEDEM Commit.
|
||||
- **Bei Fehlern:** Code korrigieren, nicht aufgeben.
|
||||
- **Nach jeder Aufgabe:** Commit + Push.
|
||||
- **Täglich vor Start:** `git pull origin main`
|
||||
Reference in New Issue
Block a user