feat: Merge Konflikt lösen - pyproject.toml mit Dependencies und Konfigurationen

This commit is contained in:
hermes
2026-06-22 06:33:03 +00:00
parent 0e34b9b0cb
commit c9fb7e8df2
13 changed files with 538 additions and 7 deletions
View File
View File
+16
View File
@@ -0,0 +1,16 @@
"""Submission-Modell für eingehende Community-Nachrichten."""
from datetime import datetime
from pydantic import BaseModel, Field
class Submission(BaseModel):
"""Eine Community-Einreichung (WhatsApp/Telegram)."""
message_id: str = Field(..., description="Eindeutige Nachrichten-ID")
plattform: str = Field(..., description="whatsapp oder telegram")
absender: str = Field(..., description="Absender-ID oder Telefonnummer")
text: str = Field(..., min_length=1, max_length=5000, description="Nachrichtentext")
zeitstempel: datetime = Field(default_factory=datetime.now, description="Eingangszeitpunkt")
status: str = Field(default="neu", description="neu, in_bearbeitung, erledigt, fehler")
View File
+130
View File
@@ -0,0 +1,130 @@
"""Queue-Handler für Redis-basierte Message-Queue mit Idempotenz-Garantie."""
from datetime import datetime
from typing import Any
import redis
from src.models.submission import Submission
class QueueHandler:
"""Redis-basierte Message-Queue mit Idempotenz-Garantie.
Verwendet Redis-Streams für persistente Pufferung und ein Redis-Set
für Deduplizierung mit 24h TTL.
"""
def __init__(self, redis_host: str = "localhost", redis_port: int = 6379) -> None:
"""Initialisiert den Queue-Handler mit Redis-Verbindung.
Args:
redis_host: Redis-Server-Host (Standard: localhost)
redis_port: Redis-Server-Port (Standard: 6379)
"""
self.redis = redis.Redis(host=redis_host, port=redis_port, decode_responses=True)
self.stream_key = "bsn:submissions"
self.processed_key = "bsn:processed"
self.consumer_group = "bsn-workers"
# Consumer-Group initialisieren (falls noch nicht vorhanden)
try:
self.redis.xgroup_create(self.stream_key, self.consumer_group, id="0")
except redis.exceptions.ResponseError:
pass # Group existiert bereits
def enqueue(self, submission: Submission) -> str | None:
"""Legt eine Submission in die Queue. Überspringt Duplikate.
Prüft ob message_id bereits verarbeitet wurde.
Wenn ja → return None (Idempotenz)
Wenn nein → XADD in Stream, merke ID in processed_set (TTL 24h)
Args:
submission: Die zu enqueuende Submission.
Returns:
Die Redis-Stream-ID bei Erfolg, None bei Duplikat.
"""
# Prüfe ob message_id bereits verarbeitet wurde
if self.redis.sismember(self.processed_key, submission.message_id):
return None
# Submission als dict serialisieren
data: dict[str, Any] = {
"message_id": submission.message_id,
"plattform": submission.plattform,
"absender": submission.absender,
"text": submission.text,
"zeitstempel": submission.zeitstempel.isoformat(),
"status": submission.status,
}
# XADD in Stream
stream_id = self.redis.xadd(self.stream_key, data)
# In processed_set merken mit 24h TTL (86400 Sekunden)
self.redis.sadd(self.processed_key, submission.message_id)
self.redis.expire(self.processed_key, 86400)
# stream_id kann bytes oder str sein, aber mit decode_responses=True ist es str
return str(stream_id) if stream_id else None
def dequeue(self, count: int = 5) -> list[Submission]:
"""Holt bis zu `count` Submissions aus der Queue (FIFO).
Nutzt XREADGROUP für Consumer-Group-Verarbeitung.
Args:
count: Maximale Anzahl zu holender Submissions (Standard: 5).
Returns:
Liste von Submission-Objekten.
"""
messages: list[Any] = self.redis.xreadgroup(
groupname=self.consumer_group,
consumername="worker-1", # Einfacher Einzel-Consumer
streams={self.stream_key: ">"}, # ">" = nur neue Nachrichten
count=count,
block=0, # Nicht blockierend
)
submissions: list[Submission] = []
if messages:
for stream_name, stream_messages in messages:
for message_tuple in stream_messages:
# message_tuple ist (id, {field: value, ...})
message_data = message_tuple[1]
try:
zeitstempel_str = str(message_data.get("zeitstempel", ""))
submission = Submission(
message_id=str(message_data.get("message_id", "")),
plattform=str(message_data.get("plattform", "")),
absender=str(message_data.get("absender", "")),
text=str(message_data.get("text", "")),
zeitstempel=datetime.fromisoformat(zeitstempel_str),
status=str(message_data.get("status", "neu")),
)
submissions.append(submission)
except (KeyError, ValueError, TypeError):
continue # Ungültige Nachricht überspringen
return submissions
def mark_done(self, submission: Submission) -> None:
"""Markiert eine Submission als erfolgreich verarbeitet.
Entfernt die Nachricht aus dem Stream-Aktivitäts-Tracking.
Args:
submission: Die abzuschließende Submission.
"""
# Nachricht im Stream ack-en (für Consumer-Group)
self.redis.xack(self.stream_key, self.consumer_group, submission.message_id)
def queue_size(self) -> int:
"""Gibt die aktuelle Queue-Größe zurück.
Returns:
Anzahl wartender Nachrichten im Stream.
"""
return self.redis.xlen(self.stream_key)
View File
+97
View File
@@ -0,0 +1,97 @@
"""Hilfsfunktionen zur Validierung von Benutzereingaben."""
import re
def validiere_telefonnummer(telefon: str) -> str:
"""Validiert und normalisiert eine Telefonnummer.
Akzeptiert: "+491****7890", "+49 123 4567890", "00491234567890"
Entfernt Leerzeichen, Bindestriche, Klammern
Konvertiert "00" am Anfang zu "+"
Wirft ValueError bei: weniger als 10 Ziffern, kein + nach Bereinigung
Args:
telefon: Die zu validierende Telefonnummer als String.
Returns:
Die bereinigte Telefonnummer mit führendem "+".
Raises:
ValueError: Wenn die Nummer ungültig ist.
"""
# Entferne alles außer + und Ziffern
bereinigt = re.sub(r"[^\d+]", "", telefon)
# Konvertiere "00" am Anfang zu "+"
if bereinigt.startswith("00"):
bereinigt = "+" + bereinigt[2:]
# Muss mit + beginnen
if not bereinigt.startswith("+"):
raise ValueError(f"Telefonnummer muss mit '+' beginnen: {telefon}")
# Zähle Ziffern (ohne das +)
ziffern = re.sub(r"[^\d]", "", bereinigt)
if len(ziffern) < 10:
raise ValueError(f"Telefonnummer muss mindestens 10 Ziffern haben: {telefon}")
return bereinigt
def validiere_spiel_name(name: str) -> str:
"""Validiert einen Spielnamen.
Entfernt HTML-Tags mit Regex
Strip Whitespace an beiden Enden
Wirft ValueError bei: < 2 Zeichen, > 200 Zeichen
Args:
name: Der zu validierende Spielname.
Returns:
Der bereinigte Spielname.
Raises:
ValueError: Wenn der Name ungültig ist.
"""
# Entferne HTML-Tags
bereinigt = re.sub(r"<[^>]*>", "", name)
# Strip Whitespace
bereinigt = bereinigt.strip()
if len(bereinigt) < 2:
raise ValueError(f"Spielname muss mindestens 2 Zeichen haben: {name}")
if len(bereinigt) > 200:
raise ValueError(f"Spielname darf maximal 200 Zeichen haben: {name}")
return bereinigt
def validiere_spieleranzahl(anzahl: int) -> int:
"""Validiert eine Spieleranzahl.
Wirft ValueError bei: < 1, > 20, kein Integer
Args:
anzahl: Die zu valideinde Spieleranzahl.
Returns:
Die ungeänderte Spieleranzahl.
Raises:
ValueError: Wenn die Anzahl ungültig ist.
"""
if not isinstance(anzahl, int):
raise ValueError(f"Spieleranzahl muss ein Integer sein: {anzahl}")
if anzahl < 1:
raise ValueError(f"Spieleranzahl muss mindestens 1 sein: {anzahl}")
if anzahl > 20:
raise ValueError(f"Spieleranzahl darf maximal 20 sein: {anzahl}")
return anzahl