9.9 KiB
Alicia — Aufgaben 11–15: BSN-Livesystem Phase 2
Repo:
git@git.datenhimmel.work:danielkrause/BSN-Livesystem.gitBranch:mainStand: Aufgaben 1–10 sind fertig. Alle 10 erledigt. Arbeitsverzeichnis:/tmp/BSN-LivesystemQualitäts-Check vor JEDEM Commit:ruff check . && ruff format . && mypy --config-file pyproject.toml . && pytest -vFehler? 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
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:
"suche Catan"→ KommandoTyp.SPIEL_SUCHEN"suche"ohne Begriff → Suchbegriff None"suche Catan"→ extrahiert "Catan""bewerte Carcassonne"→ KommandoTyp.SPIEL_BEWERTEN"hilfe"→ KommandoTyp.HILFE"blabla"→ KommandoTyp.UNBEKANNTverarbeite_submissionmit "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
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:
- Hinzufügen eines Spiels → ID wird vergeben
- Alle Spiele → leere Liste am Anfang
- Alle Spiele → 2 Spiele nach Hinzufügen
- Suchen nach "cat" → findet "Catan"
- Suchen nach "xyz" → leere Liste
- 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):
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():
- GET
/admin/queue→ 200 + JSON mit queue_size - GET
/admin/queue→ queue_size ist Integer - GET
/admin/stats→ 200 + version "0.1.0" - 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
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(...):
from src.logging_config import setup_logging
setup_logging()
Test-Datei: tests/test_logging.py
3 Tests:
setup_logging()läuft ohne Fehler durchget_logger(__name__)gibt BoundLogger zurück- 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:
- WhatsApp POST mit gültiger Signatur → 200, Submission in Queue
- WhatsApp POST mit ungültiger Signatur → 401
- Telegram POST mit gültigem Payload → 200
- Vollständiger Flow: WhatsApp POST → Worker verarbeitet → Status "erledigt"
- 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 mitapp.test_client()funktionieren. - worker_loop-Timeout: Mocke
time.sleepin Integration-Tests, sonst hängt die Endlos-Schleife. - Qualität:
ruff check . && ruff format . && mypy --config-file pyproject.toml . && pytest -vvor JEDEM Commit. - Bei Fehlern: Code korrigieren, nicht aufgeben.
- Nach jeder Aufgabe: Commit + Push.
- Täglich vor Start:
git pull origin main