fix: Restore all files lost during destructive rebase
A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.
This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).
Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
17
backend/alerts_agent/api/__init__.py
Normal file
17
backend/alerts_agent/api/__init__.py
Normal file
@@ -0,0 +1,17 @@
|
||||
"""Alert Agent API."""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from .routes import router as main_router
|
||||
from .topics import router as topics_router
|
||||
from .rules import router as rules_router
|
||||
|
||||
# Erstelle einen kombinierten Router
|
||||
router = APIRouter(prefix="/alerts", tags=["Alerts Agent"])
|
||||
|
||||
# Include alle Sub-Router
|
||||
router.include_router(main_router)
|
||||
router.include_router(topics_router)
|
||||
router.include_router(rules_router)
|
||||
|
||||
__all__ = ["router"]
|
||||
551
backend/alerts_agent/api/digests.py
Normal file
551
backend/alerts_agent/api/digests.py
Normal file
@@ -0,0 +1,551 @@
|
||||
"""
|
||||
API Routes fuer Alert Digests (Wochenzusammenfassungen).
|
||||
|
||||
Endpoints:
|
||||
- GET /digests - Liste aller Digests fuer den User
|
||||
- GET /digests/{id} - Digest-Details
|
||||
- GET /digests/{id}/pdf - PDF-Download
|
||||
- POST /digests/generate - Digest manuell generieren
|
||||
- POST /digests/{id}/send-email - Digest per E-Mail versenden
|
||||
"""
|
||||
|
||||
import uuid
|
||||
import io
|
||||
from typing import Optional, List
|
||||
from datetime import datetime, timedelta
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from ..db.database import get_db
|
||||
from ..db.models import (
|
||||
AlertDigestDB, UserAlertSubscriptionDB, DigestStatusEnum
|
||||
)
|
||||
from ..processing.digest_generator import DigestGenerator
|
||||
|
||||
|
||||
router = APIRouter(prefix="/digests", tags=["digests"])
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Request/Response Models
|
||||
# ============================================================================
|
||||
|
||||
class DigestListItem(BaseModel):
|
||||
"""Kurze Digest-Info fuer Liste."""
|
||||
id: str
|
||||
period_start: datetime
|
||||
period_end: datetime
|
||||
total_alerts: int
|
||||
critical_count: int
|
||||
urgent_count: int
|
||||
status: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class DigestDetail(BaseModel):
|
||||
"""Vollstaendige Digest-Details."""
|
||||
id: str
|
||||
subscription_id: Optional[str]
|
||||
user_id: str
|
||||
period_start: datetime
|
||||
period_end: datetime
|
||||
summary_html: str
|
||||
summary_pdf_url: Optional[str]
|
||||
total_alerts: int
|
||||
critical_count: int
|
||||
urgent_count: int
|
||||
important_count: int
|
||||
review_count: int
|
||||
info_count: int
|
||||
status: str
|
||||
sent_at: Optional[datetime]
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class DigestListResponse(BaseModel):
|
||||
"""Response fuer Digest-Liste."""
|
||||
digests: List[DigestListItem]
|
||||
total: int
|
||||
|
||||
|
||||
class GenerateDigestRequest(BaseModel):
|
||||
"""Request fuer manuelle Digest-Generierung."""
|
||||
weeks_back: int = Field(default=1, ge=1, le=4, description="Wochen zurueck")
|
||||
force_regenerate: bool = Field(default=False, description="Vorhandenen Digest ueberschreiben")
|
||||
|
||||
|
||||
class GenerateDigestResponse(BaseModel):
|
||||
"""Response fuer Digest-Generierung."""
|
||||
status: str
|
||||
digest_id: Optional[str]
|
||||
message: str
|
||||
|
||||
|
||||
class SendEmailRequest(BaseModel):
|
||||
"""Request fuer E-Mail-Versand."""
|
||||
email: Optional[str] = Field(default=None, description="E-Mail-Adresse (optional, sonst aus Subscription)")
|
||||
|
||||
|
||||
class SendEmailResponse(BaseModel):
|
||||
"""Response fuer E-Mail-Versand."""
|
||||
status: str
|
||||
sent_to: str
|
||||
message: str
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Helper Functions
|
||||
# ============================================================================
|
||||
|
||||
def get_user_id_from_request() -> str:
|
||||
"""
|
||||
Extrahiert User-ID aus Request.
|
||||
TODO: JWT-Token auswerten, aktuell Dummy.
|
||||
"""
|
||||
return "demo-user"
|
||||
|
||||
|
||||
def _digest_to_list_item(digest: AlertDigestDB) -> DigestListItem:
|
||||
"""Konvertiere DB-Model zu List-Item."""
|
||||
return DigestListItem(
|
||||
id=digest.id,
|
||||
period_start=digest.period_start,
|
||||
period_end=digest.period_end,
|
||||
total_alerts=digest.total_alerts or 0,
|
||||
critical_count=digest.critical_count or 0,
|
||||
urgent_count=digest.urgent_count or 0,
|
||||
status=digest.status.value if digest.status else "pending",
|
||||
created_at=digest.created_at
|
||||
)
|
||||
|
||||
|
||||
def _digest_to_detail(digest: AlertDigestDB) -> DigestDetail:
|
||||
"""Konvertiere DB-Model zu Detail."""
|
||||
return DigestDetail(
|
||||
id=digest.id,
|
||||
subscription_id=digest.subscription_id,
|
||||
user_id=digest.user_id,
|
||||
period_start=digest.period_start,
|
||||
period_end=digest.period_end,
|
||||
summary_html=digest.summary_html or "",
|
||||
summary_pdf_url=digest.summary_pdf_url,
|
||||
total_alerts=digest.total_alerts or 0,
|
||||
critical_count=digest.critical_count or 0,
|
||||
urgent_count=digest.urgent_count or 0,
|
||||
important_count=digest.important_count or 0,
|
||||
review_count=digest.review_count or 0,
|
||||
info_count=digest.info_count or 0,
|
||||
status=digest.status.value if digest.status else "pending",
|
||||
sent_at=digest.sent_at,
|
||||
created_at=digest.created_at
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Endpoints
|
||||
# ============================================================================
|
||||
|
||||
@router.get("", response_model=DigestListResponse)
|
||||
async def list_digests(
|
||||
limit: int = Query(10, ge=1, le=50),
|
||||
offset: int = Query(0, ge=0),
|
||||
db: DBSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Liste alle Digests des aktuellen Users.
|
||||
|
||||
Sortiert nach Erstellungsdatum (neueste zuerst).
|
||||
"""
|
||||
user_id = get_user_id_from_request()
|
||||
|
||||
query = db.query(AlertDigestDB).filter(
|
||||
AlertDigestDB.user_id == user_id
|
||||
).order_by(AlertDigestDB.created_at.desc())
|
||||
|
||||
total = query.count()
|
||||
digests = query.offset(offset).limit(limit).all()
|
||||
|
||||
return DigestListResponse(
|
||||
digests=[_digest_to_list_item(d) for d in digests],
|
||||
total=total
|
||||
)
|
||||
|
||||
|
||||
@router.get("/latest", response_model=DigestDetail)
|
||||
async def get_latest_digest(
|
||||
db: DBSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Hole den neuesten Digest des Users.
|
||||
"""
|
||||
user_id = get_user_id_from_request()
|
||||
|
||||
digest = db.query(AlertDigestDB).filter(
|
||||
AlertDigestDB.user_id == user_id
|
||||
).order_by(AlertDigestDB.created_at.desc()).first()
|
||||
|
||||
if not digest:
|
||||
raise HTTPException(status_code=404, detail="Kein Digest vorhanden")
|
||||
|
||||
return _digest_to_detail(digest)
|
||||
|
||||
|
||||
@router.get("/{digest_id}", response_model=DigestDetail)
|
||||
async def get_digest(
|
||||
digest_id: str,
|
||||
db: DBSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Hole Details eines spezifischen Digests.
|
||||
"""
|
||||
user_id = get_user_id_from_request()
|
||||
|
||||
digest = db.query(AlertDigestDB).filter(
|
||||
AlertDigestDB.id == digest_id,
|
||||
AlertDigestDB.user_id == user_id
|
||||
).first()
|
||||
|
||||
if not digest:
|
||||
raise HTTPException(status_code=404, detail="Digest nicht gefunden")
|
||||
|
||||
return _digest_to_detail(digest)
|
||||
|
||||
|
||||
@router.get("/{digest_id}/pdf")
|
||||
async def get_digest_pdf(
|
||||
digest_id: str,
|
||||
db: DBSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Generiere und lade PDF-Version des Digests herunter.
|
||||
"""
|
||||
user_id = get_user_id_from_request()
|
||||
|
||||
digest = db.query(AlertDigestDB).filter(
|
||||
AlertDigestDB.id == digest_id,
|
||||
AlertDigestDB.user_id == user_id
|
||||
).first()
|
||||
|
||||
if not digest:
|
||||
raise HTTPException(status_code=404, detail="Digest nicht gefunden")
|
||||
|
||||
if not digest.summary_html:
|
||||
raise HTTPException(status_code=400, detail="Digest hat keinen Inhalt")
|
||||
|
||||
# PDF generieren
|
||||
try:
|
||||
pdf_bytes = await generate_pdf_from_html(digest.summary_html)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"PDF-Generierung fehlgeschlagen: {str(e)}")
|
||||
|
||||
# Dateiname
|
||||
filename = f"wochenbericht_{digest.period_start.strftime('%Y%m%d')}_{digest.period_end.strftime('%Y%m%d')}.pdf"
|
||||
|
||||
return StreamingResponse(
|
||||
io.BytesIO(pdf_bytes),
|
||||
media_type="application/pdf",
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename={filename}"
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/latest/pdf")
|
||||
async def get_latest_digest_pdf(
|
||||
db: DBSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
PDF des neuesten Digests herunterladen.
|
||||
"""
|
||||
user_id = get_user_id_from_request()
|
||||
|
||||
digest = db.query(AlertDigestDB).filter(
|
||||
AlertDigestDB.user_id == user_id
|
||||
).order_by(AlertDigestDB.created_at.desc()).first()
|
||||
|
||||
if not digest:
|
||||
raise HTTPException(status_code=404, detail="Kein Digest vorhanden")
|
||||
|
||||
if not digest.summary_html:
|
||||
raise HTTPException(status_code=400, detail="Digest hat keinen Inhalt")
|
||||
|
||||
# PDF generieren
|
||||
try:
|
||||
pdf_bytes = await generate_pdf_from_html(digest.summary_html)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"PDF-Generierung fehlgeschlagen: {str(e)}")
|
||||
|
||||
filename = f"wochenbericht_{digest.period_start.strftime('%Y%m%d')}_{digest.period_end.strftime('%Y%m%d')}.pdf"
|
||||
|
||||
return StreamingResponse(
|
||||
io.BytesIO(pdf_bytes),
|
||||
media_type="application/pdf",
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename={filename}"
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/generate", response_model=GenerateDigestResponse)
|
||||
async def generate_digest(
|
||||
request: GenerateDigestRequest = None,
|
||||
db: DBSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Generiere einen neuen Digest manuell.
|
||||
|
||||
Normalerweise werden Digests automatisch woechentlich generiert.
|
||||
Diese Route erlaubt manuelle Generierung fuer Tests oder On-Demand.
|
||||
"""
|
||||
user_id = get_user_id_from_request()
|
||||
weeks_back = request.weeks_back if request else 1
|
||||
|
||||
# Pruefe ob bereits ein Digest fuer diesen Zeitraum existiert
|
||||
now = datetime.utcnow()
|
||||
period_end = now - timedelta(days=now.weekday())
|
||||
period_start = period_end - timedelta(weeks=weeks_back)
|
||||
|
||||
existing = db.query(AlertDigestDB).filter(
|
||||
AlertDigestDB.user_id == user_id,
|
||||
AlertDigestDB.period_start >= period_start - timedelta(days=1),
|
||||
AlertDigestDB.period_end <= period_end + timedelta(days=1)
|
||||
).first()
|
||||
|
||||
if existing and not (request and request.force_regenerate):
|
||||
return GenerateDigestResponse(
|
||||
status="exists",
|
||||
digest_id=existing.id,
|
||||
message="Digest fuer diesen Zeitraum existiert bereits"
|
||||
)
|
||||
|
||||
# Generiere neuen Digest
|
||||
generator = DigestGenerator(db)
|
||||
|
||||
try:
|
||||
digest = await generator.generate_weekly_digest(user_id, weeks_back)
|
||||
|
||||
if digest:
|
||||
return GenerateDigestResponse(
|
||||
status="success",
|
||||
digest_id=digest.id,
|
||||
message="Digest erfolgreich generiert"
|
||||
)
|
||||
else:
|
||||
return GenerateDigestResponse(
|
||||
status="empty",
|
||||
digest_id=None,
|
||||
message="Keine Alerts fuer diesen Zeitraum vorhanden"
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Fehler bei Digest-Generierung: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/{digest_id}/send-email", response_model=SendEmailResponse)
|
||||
async def send_digest_email(
|
||||
digest_id: str,
|
||||
request: SendEmailRequest = None,
|
||||
db: DBSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Versende Digest per E-Mail.
|
||||
"""
|
||||
user_id = get_user_id_from_request()
|
||||
|
||||
digest = db.query(AlertDigestDB).filter(
|
||||
AlertDigestDB.id == digest_id,
|
||||
AlertDigestDB.user_id == user_id
|
||||
).first()
|
||||
|
||||
if not digest:
|
||||
raise HTTPException(status_code=404, detail="Digest nicht gefunden")
|
||||
|
||||
# E-Mail-Adresse ermitteln
|
||||
email = None
|
||||
if request and request.email:
|
||||
email = request.email
|
||||
else:
|
||||
# Aus Subscription holen
|
||||
subscription = db.query(UserAlertSubscriptionDB).filter(
|
||||
UserAlertSubscriptionDB.id == digest.subscription_id
|
||||
).first()
|
||||
if subscription:
|
||||
email = subscription.notification_email
|
||||
|
||||
if not email:
|
||||
raise HTTPException(status_code=400, detail="Keine E-Mail-Adresse angegeben")
|
||||
|
||||
# E-Mail versenden
|
||||
try:
|
||||
await send_digest_by_email(digest, email)
|
||||
|
||||
# Status aktualisieren
|
||||
digest.status = DigestStatusEnum.SENT
|
||||
digest.sent_at = datetime.utcnow()
|
||||
db.commit()
|
||||
|
||||
return SendEmailResponse(
|
||||
status="success",
|
||||
sent_to=email,
|
||||
message="E-Mail erfolgreich versendet"
|
||||
)
|
||||
except Exception as e:
|
||||
digest.status = DigestStatusEnum.FAILED
|
||||
db.commit()
|
||||
raise HTTPException(status_code=500, detail=f"E-Mail-Versand fehlgeschlagen: {str(e)}")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# PDF Generation
|
||||
# ============================================================================
|
||||
|
||||
async def generate_pdf_from_html(html_content: str) -> bytes:
|
||||
"""
|
||||
Generiere PDF aus HTML.
|
||||
|
||||
Verwendet WeasyPrint oder wkhtmltopdf als Fallback.
|
||||
"""
|
||||
try:
|
||||
# Versuche WeasyPrint (bevorzugt)
|
||||
from weasyprint import HTML
|
||||
pdf_bytes = HTML(string=html_content).write_pdf()
|
||||
return pdf_bytes
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
# Fallback: wkhtmltopdf via pdfkit
|
||||
import pdfkit
|
||||
pdf_bytes = pdfkit.from_string(html_content, False)
|
||||
return pdf_bytes
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
# Fallback: xhtml2pdf
|
||||
from xhtml2pdf import pisa
|
||||
result = io.BytesIO()
|
||||
pisa.CreatePDF(io.StringIO(html_content), dest=result)
|
||||
return result.getvalue()
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Letzter Fallback: Einfache Text-Konvertierung
|
||||
raise ImportError(
|
||||
"Keine PDF-Bibliothek verfuegbar. "
|
||||
"Installieren Sie: pip install weasyprint oder pip install pdfkit oder pip install xhtml2pdf"
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Email Sending
|
||||
# ============================================================================
|
||||
|
||||
async def send_digest_by_email(digest: AlertDigestDB, recipient_email: str):
|
||||
"""
|
||||
Versende Digest per E-Mail.
|
||||
|
||||
Verwendet:
|
||||
- Lokalen SMTP-Server (Postfix/Sendmail)
|
||||
- SMTP-Relay (z.B. SES, Mailgun)
|
||||
- SendGrid API
|
||||
"""
|
||||
import os
|
||||
import smtplib
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.application import MIMEApplication
|
||||
|
||||
# E-Mail zusammenstellen
|
||||
msg = MIMEMultipart('alternative')
|
||||
msg['Subject'] = f"Wochenbericht: {digest.period_start.strftime('%d.%m.%Y')} - {digest.period_end.strftime('%d.%m.%Y')}"
|
||||
msg['From'] = os.getenv('SMTP_FROM', 'alerts@breakpilot.app')
|
||||
msg['To'] = recipient_email
|
||||
|
||||
# Text-Version
|
||||
text_content = f"""
|
||||
BreakPilot Alerts - Wochenbericht
|
||||
|
||||
Zeitraum: {digest.period_start.strftime('%d.%m.%Y')} - {digest.period_end.strftime('%d.%m.%Y')}
|
||||
Gesamt: {digest.total_alerts} Meldungen
|
||||
Kritisch: {digest.critical_count}
|
||||
Dringend: {digest.urgent_count}
|
||||
|
||||
Oeffnen Sie die HTML-Version fuer die vollstaendige Uebersicht.
|
||||
|
||||
---
|
||||
Diese E-Mail wurde automatisch von BreakPilot Alerts generiert.
|
||||
"""
|
||||
msg.attach(MIMEText(text_content, 'plain', 'utf-8'))
|
||||
|
||||
# HTML-Version
|
||||
if digest.summary_html:
|
||||
msg.attach(MIMEText(digest.summary_html, 'html', 'utf-8'))
|
||||
|
||||
# PDF-Anhang (optional)
|
||||
try:
|
||||
pdf_bytes = await generate_pdf_from_html(digest.summary_html)
|
||||
pdf_attachment = MIMEApplication(pdf_bytes, _subtype='pdf')
|
||||
pdf_attachment.add_header(
|
||||
'Content-Disposition', 'attachment',
|
||||
filename=f"wochenbericht_{digest.period_start.strftime('%Y%m%d')}.pdf"
|
||||
)
|
||||
msg.attach(pdf_attachment)
|
||||
except Exception:
|
||||
pass # PDF-Anhang ist optional
|
||||
|
||||
# Senden
|
||||
smtp_host = os.getenv('SMTP_HOST', 'localhost')
|
||||
smtp_port = int(os.getenv('SMTP_PORT', '25'))
|
||||
smtp_user = os.getenv('SMTP_USER', '')
|
||||
smtp_pass = os.getenv('SMTP_PASS', '')
|
||||
|
||||
try:
|
||||
if smtp_port == 465:
|
||||
# SSL
|
||||
server = smtplib.SMTP_SSL(smtp_host, smtp_port)
|
||||
else:
|
||||
server = smtplib.SMTP(smtp_host, smtp_port)
|
||||
if smtp_port == 587:
|
||||
server.starttls()
|
||||
|
||||
if smtp_user and smtp_pass:
|
||||
server.login(smtp_user, smtp_pass)
|
||||
|
||||
server.send_message(msg)
|
||||
server.quit()
|
||||
|
||||
except Exception as e:
|
||||
# Fallback: SendGrid API
|
||||
sendgrid_key = os.getenv('SENDGRID_API_KEY')
|
||||
if sendgrid_key:
|
||||
await send_via_sendgrid(msg, sendgrid_key)
|
||||
else:
|
||||
raise e
|
||||
|
||||
|
||||
async def send_via_sendgrid(msg, api_key: str):
|
||||
"""Fallback: SendGrid API."""
|
||||
import httpx
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
"https://api.sendgrid.com/v3/mail/send",
|
||||
headers={
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
json={
|
||||
"personalizations": [{"to": [{"email": msg['To']}]}],
|
||||
"from": {"email": msg['From']},
|
||||
"subject": msg['Subject'],
|
||||
"content": [
|
||||
{"type": "text/plain", "value": msg.get_payload(0).get_payload()},
|
||||
{"type": "text/html", "value": msg.get_payload(1).get_payload() if len(msg.get_payload()) > 1 else ""}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
if response.status_code >= 400:
|
||||
raise Exception(f"SendGrid error: {response.status_code}")
|
||||
510
backend/alerts_agent/api/routes.py
Normal file
510
backend/alerts_agent/api/routes.py
Normal file
@@ -0,0 +1,510 @@
|
||||
"""
|
||||
API Routes für Alerts Agent.
|
||||
|
||||
Endpoints:
|
||||
- POST /alerts/ingest - Manuell Alerts importieren
|
||||
- POST /alerts/run - Scoring Pipeline starten
|
||||
- GET /alerts/inbox - Inbox Items abrufen
|
||||
- POST /alerts/feedback - Relevanz-Feedback geben
|
||||
- GET /alerts/profile - User Relevance Profile
|
||||
- PUT /alerts/profile - Profile aktualisieren
|
||||
"""
|
||||
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ..models.alert_item import AlertItem, AlertStatus
|
||||
from ..models.relevance_profile import RelevanceProfile, PriorityItem
|
||||
from ..processing.relevance_scorer import RelevanceDecision, RelevanceScorer
|
||||
|
||||
|
||||
router = APIRouter(prefix="/alerts", tags=["alerts"])
|
||||
|
||||
# LLM Scorer Konfiguration aus Umgebungsvariablen
|
||||
LLM_GATEWAY_URL = os.getenv("LLM_GATEWAY_URL", "http://localhost:8000/llm")
|
||||
LLM_API_KEY = os.getenv("LLM_API_KEYS", "").split(",")[0] if os.getenv("LLM_API_KEYS") else ""
|
||||
ALERTS_USE_LLM = os.getenv("ALERTS_USE_LLM", "false").lower() == "true"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# In-Memory Storage (später durch DB ersetzen)
|
||||
# ============================================================================
|
||||
|
||||
_alerts_store: dict[str, AlertItem] = {}
|
||||
_profile_store: dict[str, RelevanceProfile] = {}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Request/Response Models
|
||||
# ============================================================================
|
||||
|
||||
class AlertIngestRequest(BaseModel):
|
||||
"""Request für manuelles Alert-Import."""
|
||||
title: str = Field(..., min_length=1, max_length=500)
|
||||
url: str = Field(..., min_length=1)
|
||||
snippet: Optional[str] = Field(default=None, max_length=2000)
|
||||
topic_label: str = Field(default="Manual Import")
|
||||
published_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class AlertIngestResponse(BaseModel):
|
||||
"""Response für Alert-Import."""
|
||||
id: str
|
||||
status: str
|
||||
message: str
|
||||
|
||||
|
||||
class AlertRunRequest(BaseModel):
|
||||
"""Request für Scoring-Pipeline."""
|
||||
limit: int = Field(default=50, ge=1, le=200)
|
||||
skip_scored: bool = Field(default=True)
|
||||
|
||||
|
||||
class AlertRunResponse(BaseModel):
|
||||
"""Response für Scoring-Pipeline."""
|
||||
processed: int
|
||||
keep: int
|
||||
drop: int
|
||||
review: int
|
||||
errors: int
|
||||
duration_ms: int
|
||||
|
||||
|
||||
class InboxItem(BaseModel):
|
||||
"""Ein Item in der Inbox."""
|
||||
id: str
|
||||
title: str
|
||||
url: str
|
||||
snippet: Optional[str]
|
||||
topic_label: str
|
||||
published_at: Optional[datetime]
|
||||
relevance_score: Optional[float]
|
||||
relevance_decision: Optional[str]
|
||||
relevance_summary: Optional[str]
|
||||
status: str
|
||||
|
||||
|
||||
class InboxResponse(BaseModel):
|
||||
"""Response für Inbox-Abfrage."""
|
||||
items: list[InboxItem]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
|
||||
|
||||
class FeedbackRequest(BaseModel):
|
||||
"""Request für Relevanz-Feedback."""
|
||||
alert_id: str
|
||||
is_relevant: bool
|
||||
reason: Optional[str] = None
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class FeedbackResponse(BaseModel):
|
||||
"""Response für Feedback."""
|
||||
success: bool
|
||||
message: str
|
||||
profile_updated: bool
|
||||
|
||||
|
||||
class ProfilePriorityRequest(BaseModel):
|
||||
"""Priority für Profile-Update."""
|
||||
label: str
|
||||
weight: float = Field(default=0.5, ge=0.0, le=1.0)
|
||||
keywords: list[str] = Field(default_factory=list)
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class ProfileUpdateRequest(BaseModel):
|
||||
"""Request für Profile-Update."""
|
||||
priorities: Optional[list[ProfilePriorityRequest]] = None
|
||||
exclusions: Optional[list[str]] = None
|
||||
policies: Optional[dict] = None
|
||||
|
||||
|
||||
class ProfileResponse(BaseModel):
|
||||
"""Response für Profile."""
|
||||
id: str
|
||||
priorities: list[dict]
|
||||
exclusions: list[str]
|
||||
policies: dict
|
||||
total_scored: int
|
||||
total_kept: int
|
||||
total_dropped: int
|
||||
accuracy_estimate: Optional[float]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Endpoints
|
||||
# ============================================================================
|
||||
|
||||
@router.post("/ingest", response_model=AlertIngestResponse)
|
||||
async def ingest_alert(request: AlertIngestRequest):
|
||||
"""
|
||||
Manuell einen Alert importieren.
|
||||
|
||||
Nützlich für Tests oder manuelles Hinzufügen von Artikeln.
|
||||
"""
|
||||
alert = AlertItem(
|
||||
title=request.title,
|
||||
url=request.url,
|
||||
snippet=request.snippet or "",
|
||||
topic_label=request.topic_label,
|
||||
published_at=request.published_at,
|
||||
)
|
||||
|
||||
_alerts_store[alert.id] = alert
|
||||
|
||||
return AlertIngestResponse(
|
||||
id=alert.id,
|
||||
status="created",
|
||||
message=f"Alert '{alert.title[:50]}...' importiert"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/run", response_model=AlertRunResponse)
|
||||
async def run_scoring_pipeline(request: AlertRunRequest):
|
||||
"""
|
||||
Scoring-Pipeline für neue Alerts starten.
|
||||
|
||||
Bewertet alle unbewerteten Alerts und klassifiziert sie
|
||||
in KEEP, DROP oder REVIEW.
|
||||
|
||||
Wenn ALERTS_USE_LLM=true, wird das LLM Gateway für Scoring verwendet.
|
||||
Sonst wird ein schnelles Keyword-basiertes Scoring durchgeführt.
|
||||
"""
|
||||
import time
|
||||
start = time.time()
|
||||
|
||||
# Alle unbewerteten Alerts holen
|
||||
alerts_to_score = [
|
||||
a for a in _alerts_store.values()
|
||||
if a.status == AlertStatus.NEW or (not request.skip_scored and a.status == AlertStatus.SCORED)
|
||||
][:request.limit]
|
||||
|
||||
if not alerts_to_score:
|
||||
return AlertRunResponse(
|
||||
processed=0, keep=0, drop=0, review=0, errors=0,
|
||||
duration_ms=int((time.time() - start) * 1000)
|
||||
)
|
||||
|
||||
keep = drop = review = errors = 0
|
||||
|
||||
# Profil für Scoring laden
|
||||
profile = _profile_store.get("default")
|
||||
if not profile:
|
||||
profile = RelevanceProfile.create_default_education_profile()
|
||||
profile.id = "default"
|
||||
_profile_store["default"] = profile
|
||||
|
||||
if ALERTS_USE_LLM and LLM_API_KEY:
|
||||
# LLM-basiertes Scoring über Gateway
|
||||
scorer = RelevanceScorer(
|
||||
gateway_url=LLM_GATEWAY_URL,
|
||||
api_key=LLM_API_KEY,
|
||||
model="breakpilot-teacher-8b",
|
||||
)
|
||||
try:
|
||||
results = await scorer.score_batch(alerts_to_score, profile=profile)
|
||||
for result in results:
|
||||
if result.error:
|
||||
errors += 1
|
||||
elif result.decision == RelevanceDecision.KEEP:
|
||||
keep += 1
|
||||
elif result.decision == RelevanceDecision.DROP:
|
||||
drop += 1
|
||||
else:
|
||||
review += 1
|
||||
finally:
|
||||
await scorer.close()
|
||||
else:
|
||||
# Fallback: Keyword-basiertes Scoring (schnell, ohne LLM)
|
||||
for alert in alerts_to_score:
|
||||
title_lower = alert.title.lower()
|
||||
snippet_lower = (alert.snippet or "").lower()
|
||||
combined = title_lower + " " + snippet_lower
|
||||
|
||||
# Ausschlüsse aus Profil prüfen
|
||||
if any(excl.lower() in combined for excl in profile.exclusions):
|
||||
alert.relevance_score = 0.15
|
||||
alert.relevance_decision = RelevanceDecision.DROP.value
|
||||
drop += 1
|
||||
# Prioritäten aus Profil prüfen
|
||||
elif any(
|
||||
p.label.lower() in combined or
|
||||
any(kw.lower() in combined for kw in (p.keywords if hasattr(p, 'keywords') else []))
|
||||
for p in profile.priorities
|
||||
):
|
||||
alert.relevance_score = 0.85
|
||||
alert.relevance_decision = RelevanceDecision.KEEP.value
|
||||
keep += 1
|
||||
else:
|
||||
alert.relevance_score = 0.55
|
||||
alert.relevance_decision = RelevanceDecision.REVIEW.value
|
||||
review += 1
|
||||
|
||||
alert.status = AlertStatus.SCORED
|
||||
|
||||
duration_ms = int((time.time() - start) * 1000)
|
||||
|
||||
return AlertRunResponse(
|
||||
processed=len(alerts_to_score),
|
||||
keep=keep,
|
||||
drop=drop,
|
||||
review=review,
|
||||
errors=errors,
|
||||
duration_ms=duration_ms,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/inbox", response_model=InboxResponse)
|
||||
async def get_inbox(
|
||||
decision: Optional[str] = Query(default=None, description="Filter: KEEP, DROP, REVIEW"),
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
):
|
||||
"""
|
||||
Inbox Items abrufen.
|
||||
|
||||
Filtert nach Relevanz-Entscheidung. Standard zeigt KEEP und REVIEW.
|
||||
"""
|
||||
# Filter Alerts
|
||||
alerts = list(_alerts_store.values())
|
||||
|
||||
if decision:
|
||||
alerts = [a for a in alerts if a.relevance_decision == decision.upper()]
|
||||
else:
|
||||
# Standard: KEEP und REVIEW zeigen
|
||||
alerts = [a for a in alerts if a.relevance_decision in ["KEEP", "REVIEW"]]
|
||||
|
||||
# Sortieren nach Score (absteigend)
|
||||
alerts.sort(key=lambda a: a.relevance_score or 0, reverse=True)
|
||||
|
||||
# Pagination
|
||||
total = len(alerts)
|
||||
start = (page - 1) * page_size
|
||||
end = start + page_size
|
||||
page_alerts = alerts[start:end]
|
||||
|
||||
items = [
|
||||
InboxItem(
|
||||
id=a.id,
|
||||
title=a.title,
|
||||
url=a.url,
|
||||
snippet=a.snippet,
|
||||
topic_label=a.topic_label,
|
||||
published_at=a.published_at,
|
||||
relevance_score=a.relevance_score,
|
||||
relevance_decision=a.relevance_decision,
|
||||
relevance_summary=a.relevance_summary,
|
||||
status=a.status.value,
|
||||
)
|
||||
for a in page_alerts
|
||||
]
|
||||
|
||||
return InboxResponse(
|
||||
items=items,
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/feedback", response_model=FeedbackResponse)
|
||||
async def submit_feedback(request: FeedbackRequest):
|
||||
"""
|
||||
Feedback zu einem Alert geben.
|
||||
|
||||
Das Feedback wird verwendet um das Relevanzprofil zu verbessern.
|
||||
"""
|
||||
alert = _alerts_store.get(request.alert_id)
|
||||
if not alert:
|
||||
raise HTTPException(status_code=404, detail="Alert nicht gefunden")
|
||||
|
||||
# Alert Status aktualisieren
|
||||
alert.status = AlertStatus.REVIEWED
|
||||
|
||||
# Profile aktualisieren (Default-Profile für Demo)
|
||||
profile = _profile_store.get("default")
|
||||
if not profile:
|
||||
profile = RelevanceProfile.create_default_education_profile()
|
||||
profile.id = "default"
|
||||
_profile_store["default"] = profile
|
||||
|
||||
profile.update_from_feedback(
|
||||
alert_title=alert.title,
|
||||
alert_url=alert.url,
|
||||
is_relevant=request.is_relevant,
|
||||
reason=request.reason or "",
|
||||
)
|
||||
|
||||
return FeedbackResponse(
|
||||
success=True,
|
||||
message="Feedback gespeichert",
|
||||
profile_updated=True,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/profile", response_model=ProfileResponse)
|
||||
async def get_profile(user_id: Optional[str] = Query(default=None)):
|
||||
"""
|
||||
Relevanz-Profil abrufen.
|
||||
|
||||
Ohne user_id wird das Default-Profil zurückgegeben.
|
||||
"""
|
||||
profile_id = user_id or "default"
|
||||
profile = _profile_store.get(profile_id)
|
||||
|
||||
if not profile:
|
||||
# Default-Profile erstellen
|
||||
profile = RelevanceProfile.create_default_education_profile()
|
||||
profile.id = profile_id
|
||||
_profile_store[profile_id] = profile
|
||||
|
||||
return ProfileResponse(
|
||||
id=profile.id,
|
||||
priorities=[p.to_dict() if isinstance(p, PriorityItem) else p
|
||||
for p in profile.priorities],
|
||||
exclusions=profile.exclusions,
|
||||
policies=profile.policies,
|
||||
total_scored=profile.total_scored,
|
||||
total_kept=profile.total_kept,
|
||||
total_dropped=profile.total_dropped,
|
||||
accuracy_estimate=profile.accuracy_estimate,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/profile", response_model=ProfileResponse)
|
||||
async def update_profile(
|
||||
request: ProfileUpdateRequest,
|
||||
user_id: Optional[str] = Query(default=None),
|
||||
):
|
||||
"""
|
||||
Relevanz-Profil aktualisieren.
|
||||
|
||||
Erlaubt Anpassung von Prioritäten, Ausschlüssen und Policies.
|
||||
"""
|
||||
profile_id = user_id or "default"
|
||||
profile = _profile_store.get(profile_id)
|
||||
|
||||
if not profile:
|
||||
profile = RelevanceProfile()
|
||||
profile.id = profile_id
|
||||
|
||||
# Updates anwenden
|
||||
if request.priorities is not None:
|
||||
profile.priorities = [
|
||||
PriorityItem(
|
||||
label=p.label,
|
||||
weight=p.weight,
|
||||
keywords=p.keywords,
|
||||
description=p.description,
|
||||
)
|
||||
for p in request.priorities
|
||||
]
|
||||
|
||||
if request.exclusions is not None:
|
||||
profile.exclusions = request.exclusions
|
||||
|
||||
if request.policies is not None:
|
||||
profile.policies = request.policies
|
||||
|
||||
profile.updated_at = datetime.utcnow()
|
||||
_profile_store[profile_id] = profile
|
||||
|
||||
return ProfileResponse(
|
||||
id=profile.id,
|
||||
priorities=[p.to_dict() if isinstance(p, PriorityItem) else p
|
||||
for p in profile.priorities],
|
||||
exclusions=profile.exclusions,
|
||||
policies=profile.policies,
|
||||
total_scored=profile.total_scored,
|
||||
total_kept=profile.total_kept,
|
||||
total_dropped=profile.total_dropped,
|
||||
accuracy_estimate=profile.accuracy_estimate,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
async def get_stats():
|
||||
"""
|
||||
Statistiken über Alerts und Scoring.
|
||||
|
||||
Gibt Statistiken im Format zurück, das das Frontend erwartet:
|
||||
- total_alerts, new_alerts, kept_alerts, review_alerts, dropped_alerts
|
||||
- total_topics, active_topics, total_rules
|
||||
"""
|
||||
alerts = list(_alerts_store.values())
|
||||
total = len(alerts)
|
||||
|
||||
# Zähle nach Status und Decision
|
||||
new_alerts = sum(1 for a in alerts if a.status == AlertStatus.NEW)
|
||||
kept_alerts = sum(1 for a in alerts if a.relevance_decision == "KEEP")
|
||||
review_alerts = sum(1 for a in alerts if a.relevance_decision == "REVIEW")
|
||||
dropped_alerts = sum(1 for a in alerts if a.relevance_decision == "DROP")
|
||||
|
||||
# Topics und Rules (In-Memory hat diese nicht, aber wir geben 0 zurück)
|
||||
# Bei DB-Implementierung würden wir hier die Repositories nutzen
|
||||
total_topics = 0
|
||||
active_topics = 0
|
||||
total_rules = 0
|
||||
|
||||
# Versuche DB-Statistiken zu laden wenn verfügbar
|
||||
try:
|
||||
from alerts_agent.db import get_db
|
||||
from alerts_agent.db.repository import TopicRepository, RuleRepository
|
||||
from contextlib import contextmanager
|
||||
|
||||
# Versuche eine DB-Session zu bekommen
|
||||
db_gen = get_db()
|
||||
db = next(db_gen, None)
|
||||
if db:
|
||||
try:
|
||||
topic_repo = TopicRepository(db)
|
||||
rule_repo = RuleRepository(db)
|
||||
|
||||
all_topics = topic_repo.get_all()
|
||||
total_topics = len(all_topics)
|
||||
active_topics = len([t for t in all_topics if t.is_active])
|
||||
|
||||
all_rules = rule_repo.get_all()
|
||||
total_rules = len(all_rules)
|
||||
finally:
|
||||
try:
|
||||
next(db_gen, None)
|
||||
except StopIteration:
|
||||
pass
|
||||
except Exception:
|
||||
# DB nicht verfügbar, nutze In-Memory Defaults
|
||||
pass
|
||||
|
||||
# Berechne Durchschnittsscore
|
||||
scored_alerts = [a for a in alerts if a.relevance_score is not None]
|
||||
avg_score = sum(a.relevance_score for a in scored_alerts) / len(scored_alerts) if scored_alerts else 0.0
|
||||
|
||||
return {
|
||||
# Frontend-kompatibles Format
|
||||
"total_alerts": total,
|
||||
"new_alerts": new_alerts,
|
||||
"kept_alerts": kept_alerts,
|
||||
"review_alerts": review_alerts,
|
||||
"dropped_alerts": dropped_alerts,
|
||||
"total_topics": total_topics,
|
||||
"active_topics": active_topics,
|
||||
"total_rules": total_rules,
|
||||
"avg_score": avg_score,
|
||||
# Zusätzliche Details (Abwärtskompatibilität)
|
||||
"by_status": {
|
||||
"new": new_alerts,
|
||||
"scored": sum(1 for a in alerts if a.status == AlertStatus.SCORED),
|
||||
"reviewed": sum(1 for a in alerts if a.status == AlertStatus.REVIEWED),
|
||||
},
|
||||
"by_decision": {
|
||||
"KEEP": kept_alerts,
|
||||
"REVIEW": review_alerts,
|
||||
"DROP": dropped_alerts,
|
||||
},
|
||||
}
|
||||
473
backend/alerts_agent/api/rules.py
Normal file
473
backend/alerts_agent/api/rules.py
Normal file
@@ -0,0 +1,473 @@
|
||||
"""
|
||||
Rules API Routes für Alerts Agent.
|
||||
|
||||
CRUD-Operationen für Alert-Regeln.
|
||||
"""
|
||||
from typing import List, Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
from alerts_agent.db import get_db
|
||||
from alerts_agent.db.repository import RuleRepository
|
||||
from alerts_agent.db.models import RuleActionEnum
|
||||
|
||||
|
||||
router = APIRouter(prefix="/rules", tags=["alerts"])
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# PYDANTIC MODELS
|
||||
# =============================================================================
|
||||
|
||||
class RuleConditionModel(BaseModel):
|
||||
"""Model für eine Regel-Bedingung."""
|
||||
field: str = Field(..., description="Feld zum Prüfen (title, snippet, url, source, relevance_score)")
|
||||
operator: str = Field(..., alias="op", description="Operator (contains, not_contains, equals, regex, gt, lt, in)")
|
||||
value: Any = Field(..., description="Vergleichswert (String, Zahl, oder Liste)")
|
||||
|
||||
class Config:
|
||||
populate_by_name = True
|
||||
|
||||
|
||||
class RuleCreate(BaseModel):
|
||||
"""Request-Model für Regel-Erstellung."""
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
description: str = Field(default="", max_length=2000)
|
||||
conditions: List[RuleConditionModel] = Field(default_factory=list)
|
||||
action_type: str = Field(default="keep", description="Aktion: keep, drop, tag, email, webhook, slack")
|
||||
action_config: Dict[str, Any] = Field(default_factory=dict)
|
||||
topic_id: Optional[str] = Field(default=None, description="Optional: Nur für bestimmtes Topic")
|
||||
priority: int = Field(default=0, ge=0, le=1000, description="Priorität (höher = wird zuerst evaluiert)")
|
||||
is_active: bool = Field(default=True)
|
||||
|
||||
|
||||
class RuleUpdate(BaseModel):
|
||||
"""Request-Model für Regel-Update."""
|
||||
name: Optional[str] = Field(default=None, min_length=1, max_length=255)
|
||||
description: Optional[str] = Field(default=None, max_length=2000)
|
||||
conditions: Optional[List[RuleConditionModel]] = None
|
||||
action_type: Optional[str] = None
|
||||
action_config: Optional[Dict[str, Any]] = None
|
||||
topic_id: Optional[str] = None
|
||||
priority: Optional[int] = Field(default=None, ge=0, le=1000)
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
class RuleResponse(BaseModel):
|
||||
"""Response-Model für Regel."""
|
||||
id: str
|
||||
name: str
|
||||
description: str
|
||||
conditions: List[Dict[str, Any]]
|
||||
action_type: str
|
||||
action_config: Dict[str, Any]
|
||||
topic_id: Optional[str]
|
||||
priority: int
|
||||
is_active: bool
|
||||
match_count: int
|
||||
last_matched_at: Optional[datetime]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class RuleListResponse(BaseModel):
|
||||
"""Response-Model für Regel-Liste."""
|
||||
rules: List[RuleResponse]
|
||||
total: int
|
||||
|
||||
|
||||
class RuleTestRequest(BaseModel):
|
||||
"""Request-Model für Regel-Test."""
|
||||
title: str = Field(default="Test Title")
|
||||
snippet: str = Field(default="Test snippet content")
|
||||
url: str = Field(default="https://example.com/test")
|
||||
source: str = Field(default="rss_feed")
|
||||
relevance_score: Optional[float] = Field(default=None)
|
||||
|
||||
|
||||
class RuleTestResponse(BaseModel):
|
||||
"""Response-Model für Regel-Test."""
|
||||
rule_id: str
|
||||
rule_name: str
|
||||
matched: bool
|
||||
action: str
|
||||
conditions_met: List[str]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# API ENDPOINTS
|
||||
# =============================================================================
|
||||
|
||||
@router.post("", response_model=RuleResponse, status_code=201)
|
||||
async def create_rule(
|
||||
rule: RuleCreate,
|
||||
db: DBSession = Depends(get_db),
|
||||
) -> RuleResponse:
|
||||
"""
|
||||
Erstellt eine neue Regel.
|
||||
|
||||
Regeln werden nach Priorität evaluiert. Höhere Priorität = wird zuerst geprüft.
|
||||
"""
|
||||
repo = RuleRepository(db)
|
||||
|
||||
# Conditions zu Dict konvertieren
|
||||
conditions = [
|
||||
{"field": c.field, "op": c.operator, "value": c.value}
|
||||
for c in rule.conditions
|
||||
]
|
||||
|
||||
created = repo.create(
|
||||
name=rule.name,
|
||||
description=rule.description,
|
||||
conditions=conditions,
|
||||
action_type=rule.action_type,
|
||||
action_config=rule.action_config,
|
||||
topic_id=rule.topic_id,
|
||||
priority=rule.priority,
|
||||
)
|
||||
|
||||
if not rule.is_active:
|
||||
repo.update(created.id, is_active=False)
|
||||
created = repo.get_by_id(created.id)
|
||||
|
||||
return _to_rule_response(created)
|
||||
|
||||
|
||||
@router.get("", response_model=RuleListResponse)
|
||||
async def list_rules(
|
||||
is_active: Optional[bool] = None,
|
||||
topic_id: Optional[str] = None,
|
||||
db: DBSession = Depends(get_db),
|
||||
) -> RuleListResponse:
|
||||
"""
|
||||
Listet alle Regeln auf.
|
||||
|
||||
Regeln sind nach Priorität sortiert (höchste zuerst).
|
||||
"""
|
||||
repo = RuleRepository(db)
|
||||
|
||||
if is_active is True:
|
||||
rules = repo.get_active()
|
||||
else:
|
||||
rules = repo.get_all()
|
||||
|
||||
# Topic-Filter
|
||||
if topic_id:
|
||||
rules = [r for r in rules if r.topic_id == topic_id or r.topic_id is None]
|
||||
|
||||
return RuleListResponse(
|
||||
rules=[_to_rule_response(r) for r in rules],
|
||||
total=len(rules),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{rule_id}", response_model=RuleResponse)
|
||||
async def get_rule(
|
||||
rule_id: str,
|
||||
db: DBSession = Depends(get_db),
|
||||
) -> RuleResponse:
|
||||
"""
|
||||
Ruft eine Regel nach ID ab.
|
||||
"""
|
||||
repo = RuleRepository(db)
|
||||
rule = repo.get_by_id(rule_id)
|
||||
|
||||
if not rule:
|
||||
raise HTTPException(status_code=404, detail="Regel nicht gefunden")
|
||||
|
||||
return _to_rule_response(rule)
|
||||
|
||||
|
||||
@router.put("/{rule_id}", response_model=RuleResponse)
|
||||
async def update_rule(
|
||||
rule_id: str,
|
||||
updates: RuleUpdate,
|
||||
db: DBSession = Depends(get_db),
|
||||
) -> RuleResponse:
|
||||
"""
|
||||
Aktualisiert eine Regel.
|
||||
"""
|
||||
repo = RuleRepository(db)
|
||||
|
||||
# Nur übergebene Werte updaten
|
||||
update_dict = {}
|
||||
|
||||
if updates.name is not None:
|
||||
update_dict["name"] = updates.name
|
||||
if updates.description is not None:
|
||||
update_dict["description"] = updates.description
|
||||
if updates.conditions is not None:
|
||||
update_dict["conditions"] = [
|
||||
{"field": c.field, "op": c.operator, "value": c.value}
|
||||
for c in updates.conditions
|
||||
]
|
||||
if updates.action_type is not None:
|
||||
update_dict["action_type"] = updates.action_type
|
||||
if updates.action_config is not None:
|
||||
update_dict["action_config"] = updates.action_config
|
||||
if updates.topic_id is not None:
|
||||
update_dict["topic_id"] = updates.topic_id
|
||||
if updates.priority is not None:
|
||||
update_dict["priority"] = updates.priority
|
||||
if updates.is_active is not None:
|
||||
update_dict["is_active"] = updates.is_active
|
||||
|
||||
if not update_dict:
|
||||
raise HTTPException(status_code=400, detail="Keine Updates angegeben")
|
||||
|
||||
updated = repo.update(rule_id, **update_dict)
|
||||
|
||||
if not updated:
|
||||
raise HTTPException(status_code=404, detail="Regel nicht gefunden")
|
||||
|
||||
return _to_rule_response(updated)
|
||||
|
||||
|
||||
@router.delete("/{rule_id}", status_code=204)
|
||||
async def delete_rule(
|
||||
rule_id: str,
|
||||
db: DBSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Löscht eine Regel.
|
||||
"""
|
||||
repo = RuleRepository(db)
|
||||
|
||||
success = repo.delete(rule_id)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="Regel nicht gefunden")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@router.post("/{rule_id}/activate", response_model=RuleResponse)
|
||||
async def activate_rule(
|
||||
rule_id: str,
|
||||
db: DBSession = Depends(get_db),
|
||||
) -> RuleResponse:
|
||||
"""
|
||||
Aktiviert eine Regel.
|
||||
"""
|
||||
repo = RuleRepository(db)
|
||||
updated = repo.update(rule_id, is_active=True)
|
||||
|
||||
if not updated:
|
||||
raise HTTPException(status_code=404, detail="Regel nicht gefunden")
|
||||
|
||||
return _to_rule_response(updated)
|
||||
|
||||
|
||||
@router.post("/{rule_id}/deactivate", response_model=RuleResponse)
|
||||
async def deactivate_rule(
|
||||
rule_id: str,
|
||||
db: DBSession = Depends(get_db),
|
||||
) -> RuleResponse:
|
||||
"""
|
||||
Deaktiviert eine Regel.
|
||||
"""
|
||||
repo = RuleRepository(db)
|
||||
updated = repo.update(rule_id, is_active=False)
|
||||
|
||||
if not updated:
|
||||
raise HTTPException(status_code=404, detail="Regel nicht gefunden")
|
||||
|
||||
return _to_rule_response(updated)
|
||||
|
||||
|
||||
@router.post("/{rule_id}/test", response_model=RuleTestResponse)
|
||||
async def test_rule(
|
||||
rule_id: str,
|
||||
test_data: RuleTestRequest,
|
||||
db: DBSession = Depends(get_db),
|
||||
) -> RuleTestResponse:
|
||||
"""
|
||||
Testet eine Regel gegen Testdaten.
|
||||
|
||||
Nützlich um Regeln vor der Aktivierung zu testen.
|
||||
"""
|
||||
from alerts_agent.processing.rule_engine import evaluate_rule
|
||||
from alerts_agent.db.models import AlertItemDB, AlertSourceEnum, AlertStatusEnum
|
||||
|
||||
repo = RuleRepository(db)
|
||||
rule = repo.get_by_id(rule_id)
|
||||
|
||||
if not rule:
|
||||
raise HTTPException(status_code=404, detail="Regel nicht gefunden")
|
||||
|
||||
# Mock-Alert für Test erstellen
|
||||
mock_alert = AlertItemDB(
|
||||
id="test-alert",
|
||||
topic_id="test-topic",
|
||||
title=test_data.title,
|
||||
snippet=test_data.snippet,
|
||||
url=test_data.url,
|
||||
url_hash="test-hash",
|
||||
source=AlertSourceEnum(test_data.source) if test_data.source else AlertSourceEnum.RSS_FEED,
|
||||
status=AlertStatusEnum.NEW,
|
||||
relevance_score=test_data.relevance_score,
|
||||
)
|
||||
|
||||
# Regel evaluieren
|
||||
match = evaluate_rule(mock_alert, rule)
|
||||
|
||||
return RuleTestResponse(
|
||||
rule_id=match.rule_id,
|
||||
rule_name=match.rule_name,
|
||||
matched=match.matched,
|
||||
action=match.action.value,
|
||||
conditions_met=match.conditions_met,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/test-all", response_model=List[RuleTestResponse])
|
||||
async def test_all_rules(
|
||||
test_data: RuleTestRequest,
|
||||
db: DBSession = Depends(get_db),
|
||||
) -> List[RuleTestResponse]:
|
||||
"""
|
||||
Testet alle aktiven Regeln gegen Testdaten.
|
||||
|
||||
Zeigt welche Regeln matchen würden.
|
||||
"""
|
||||
from alerts_agent.processing.rule_engine import evaluate_rules_for_alert, evaluate_rule
|
||||
from alerts_agent.db.models import AlertItemDB, AlertSourceEnum, AlertStatusEnum
|
||||
|
||||
repo = RuleRepository(db)
|
||||
rules = repo.get_active()
|
||||
|
||||
# Mock-Alert für Test erstellen
|
||||
mock_alert = AlertItemDB(
|
||||
id="test-alert",
|
||||
topic_id="test-topic",
|
||||
title=test_data.title,
|
||||
snippet=test_data.snippet,
|
||||
url=test_data.url,
|
||||
url_hash="test-hash",
|
||||
source=AlertSourceEnum(test_data.source) if test_data.source else AlertSourceEnum.RSS_FEED,
|
||||
status=AlertStatusEnum.NEW,
|
||||
relevance_score=test_data.relevance_score,
|
||||
)
|
||||
|
||||
results = []
|
||||
for rule in rules:
|
||||
match = evaluate_rule(mock_alert, rule)
|
||||
results.append(RuleTestResponse(
|
||||
rule_id=match.rule_id,
|
||||
rule_name=match.rule_name,
|
||||
matched=match.matched,
|
||||
action=match.action.value,
|
||||
conditions_met=match.conditions_met,
|
||||
))
|
||||
|
||||
return results
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# HELPER FUNCTIONS
|
||||
# =============================================================================
|
||||
|
||||
def _to_rule_response(rule) -> RuleResponse:
|
||||
"""Konvertiert ein Rule-DB-Objekt zu RuleResponse."""
|
||||
return RuleResponse(
|
||||
id=rule.id,
|
||||
name=rule.name,
|
||||
description=rule.description or "",
|
||||
conditions=rule.conditions or [],
|
||||
action_type=rule.action_type.value if rule.action_type else "keep",
|
||||
action_config=rule.action_config or {},
|
||||
topic_id=rule.topic_id,
|
||||
priority=rule.priority,
|
||||
is_active=rule.is_active,
|
||||
match_count=rule.match_count,
|
||||
last_matched_at=rule.last_matched_at,
|
||||
created_at=rule.created_at,
|
||||
updated_at=rule.updated_at,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# PRESET RULES
|
||||
# =============================================================================
|
||||
|
||||
PRESET_RULES = {
|
||||
"exclude_jobs": {
|
||||
"name": "Stellenanzeigen ausschließen",
|
||||
"description": "Filtert Stellenanzeigen und Job-Postings",
|
||||
"conditions": [
|
||||
{"field": "title", "op": "in", "value": ["Stellenanzeige", "Job", "Karriere", "Praktikum", "Werkstudent", "Ausbildung", "Referendariat"]}
|
||||
],
|
||||
"action_type": "drop",
|
||||
"priority": 100,
|
||||
},
|
||||
"exclude_ads": {
|
||||
"name": "Werbung ausschließen",
|
||||
"description": "Filtert Werbung und Pressemitteilungen",
|
||||
"conditions": [
|
||||
{"field": "title", "op": "in", "value": ["Werbung", "Anzeige", "Pressemitteilung", "PR:", "Sponsored"]}
|
||||
],
|
||||
"action_type": "drop",
|
||||
"priority": 100,
|
||||
},
|
||||
"keep_inklusion": {
|
||||
"name": "Inklusion behalten",
|
||||
"description": "Behält Artikel zum Thema Inklusion",
|
||||
"conditions": [
|
||||
{"field": "title", "op": "in", "value": ["Inklusion", "inklusiv", "Förderbedarf", "Förderschule", "Nachteilsausgleich"]}
|
||||
],
|
||||
"action_type": "keep",
|
||||
"priority": 50,
|
||||
},
|
||||
"keep_datenschutz": {
|
||||
"name": "Datenschutz behalten",
|
||||
"description": "Behält Artikel zum Thema Datenschutz in Schulen",
|
||||
"conditions": [
|
||||
{"field": "title", "op": "in", "value": ["DSGVO", "Datenschutz", "Schülerfotos", "personenbezogen"]}
|
||||
],
|
||||
"action_type": "keep",
|
||||
"priority": 50,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/presets/list")
|
||||
async def list_preset_rules() -> Dict[str, Any]:
|
||||
"""
|
||||
Listet verfügbare Regel-Vorlagen auf.
|
||||
"""
|
||||
return {
|
||||
"presets": [
|
||||
{"id": key, **value}
|
||||
for key, value in PRESET_RULES.items()
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.post("/presets/{preset_id}/apply", response_model=RuleResponse)
|
||||
async def apply_preset_rule(
|
||||
preset_id: str,
|
||||
db: DBSession = Depends(get_db),
|
||||
) -> RuleResponse:
|
||||
"""
|
||||
Wendet eine Regel-Vorlage an (erstellt die Regel).
|
||||
"""
|
||||
if preset_id not in PRESET_RULES:
|
||||
raise HTTPException(status_code=404, detail="Preset nicht gefunden")
|
||||
|
||||
preset = PRESET_RULES[preset_id]
|
||||
repo = RuleRepository(db)
|
||||
|
||||
created = repo.create(
|
||||
name=preset["name"],
|
||||
description=preset.get("description", ""),
|
||||
conditions=preset["conditions"],
|
||||
action_type=preset["action_type"],
|
||||
priority=preset.get("priority", 0),
|
||||
)
|
||||
|
||||
return _to_rule_response(created)
|
||||
421
backend/alerts_agent/api/subscriptions.py
Normal file
421
backend/alerts_agent/api/subscriptions.py
Normal file
@@ -0,0 +1,421 @@
|
||||
"""
|
||||
API Routes für User Alert Subscriptions.
|
||||
|
||||
Verwaltet Nutzer-Abonnements für Templates und Digest-Einstellungen.
|
||||
|
||||
Endpoints:
|
||||
- POST /subscriptions - Neue Subscription erstellen
|
||||
- GET /subscriptions - User-Subscriptions auflisten
|
||||
- GET /subscriptions/{id} - Subscription-Details
|
||||
- PUT /subscriptions/{id} - Subscription aktualisieren
|
||||
- DELETE /subscriptions/{id} - Subscription deaktivieren
|
||||
- POST /subscriptions/{id}/activate-template - Template aktivieren
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from ..db.database import get_db
|
||||
from ..db.models import (
|
||||
UserAlertSubscriptionDB, AlertTemplateDB, AlertProfileDB,
|
||||
AlertTopicDB, AlertRuleDB, AlertModeEnum, UserRoleEnum
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter(prefix="/subscriptions", tags=["subscriptions"])
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Request/Response Models
|
||||
# ============================================================================
|
||||
|
||||
class SubscriptionCreate(BaseModel):
|
||||
"""Request für neue Subscription."""
|
||||
mode: str = Field(default="guided", description="'guided' oder 'expert'")
|
||||
user_role: Optional[str] = Field(default=None, description="lehrkraft, schulleitung, it_beauftragte")
|
||||
template_ids: List[str] = Field(default=[], description="Ausgewählte Template-IDs (max. 3)")
|
||||
notification_email: Optional[str] = Field(default=None)
|
||||
digest_enabled: bool = Field(default=True)
|
||||
digest_frequency: str = Field(default="weekly")
|
||||
|
||||
|
||||
class SubscriptionUpdate(BaseModel):
|
||||
"""Request für Subscription-Update."""
|
||||
template_ids: Optional[List[str]] = None
|
||||
notification_email: Optional[str] = None
|
||||
digest_enabled: Optional[bool] = None
|
||||
digest_frequency: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
class SubscriptionResponse(BaseModel):
|
||||
"""Response für eine Subscription."""
|
||||
id: str
|
||||
user_id: str
|
||||
mode: str
|
||||
user_role: Optional[str]
|
||||
selected_template_ids: List[str]
|
||||
template_names: List[str]
|
||||
notification_email: Optional[str]
|
||||
digest_enabled: bool
|
||||
digest_frequency: str
|
||||
wizard_completed: bool
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class SubscriptionListResponse(BaseModel):
|
||||
"""Response für Subscription-Liste."""
|
||||
subscriptions: List[SubscriptionResponse]
|
||||
total: int
|
||||
|
||||
|
||||
class ActivateTemplateRequest(BaseModel):
|
||||
"""Request für Template-Aktivierung."""
|
||||
create_topics: bool = Field(default=True, description="Topics aus Template-Config erstellen")
|
||||
create_rules: bool = Field(default=True, description="Rules aus Template-Config erstellen")
|
||||
|
||||
|
||||
class ActivateTemplateResponse(BaseModel):
|
||||
"""Response für Template-Aktivierung."""
|
||||
status: str
|
||||
topics_created: int
|
||||
rules_created: int
|
||||
profile_updated: bool
|
||||
message: str
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Helper Functions
|
||||
# ============================================================================
|
||||
|
||||
def get_user_id_from_request() -> str:
|
||||
"""
|
||||
Extrahiert User-ID aus Request.
|
||||
TODO: JWT-Token auswerten, aktuell Dummy.
|
||||
"""
|
||||
return "demo-user"
|
||||
|
||||
|
||||
def _get_template_names(db: DBSession, template_ids: List[str]) -> List[str]:
|
||||
"""Hole Template-Namen für IDs."""
|
||||
if not template_ids:
|
||||
return []
|
||||
templates = db.query(AlertTemplateDB).filter(
|
||||
AlertTemplateDB.id.in_(template_ids)
|
||||
).all()
|
||||
return [t.name for t in templates]
|
||||
|
||||
|
||||
def _subscription_to_response(sub: UserAlertSubscriptionDB, db: DBSession) -> SubscriptionResponse:
|
||||
"""Konvertiere DB-Model zu Response."""
|
||||
template_ids = sub.selected_template_ids or []
|
||||
return SubscriptionResponse(
|
||||
id=sub.id,
|
||||
user_id=sub.user_id,
|
||||
mode=sub.mode.value if sub.mode else "guided",
|
||||
user_role=sub.user_role.value if sub.user_role else None,
|
||||
selected_template_ids=template_ids,
|
||||
template_names=_get_template_names(db, template_ids),
|
||||
notification_email=sub.notification_email,
|
||||
digest_enabled=sub.digest_enabled if sub.digest_enabled is not None else True,
|
||||
digest_frequency=sub.digest_frequency or "weekly",
|
||||
wizard_completed=sub.wizard_completed if sub.wizard_completed is not None else False,
|
||||
is_active=sub.is_active if sub.is_active is not None else True,
|
||||
created_at=sub.created_at,
|
||||
updated_at=sub.updated_at,
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Endpoints
|
||||
# ============================================================================
|
||||
|
||||
@router.post("", response_model=SubscriptionResponse)
|
||||
async def create_subscription(
|
||||
request: SubscriptionCreate,
|
||||
db: DBSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Erstelle eine neue Alert-Subscription.
|
||||
|
||||
Im Guided Mode werden 1-3 Templates ausgewählt.
|
||||
Im Expert Mode wird ein eigenes Profil konfiguriert.
|
||||
"""
|
||||
user_id = get_user_id_from_request()
|
||||
|
||||
# Validiere Modus
|
||||
try:
|
||||
mode = AlertModeEnum(request.mode)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Ungültiger Modus. Erlaubt: 'guided', 'expert'")
|
||||
|
||||
# Validiere Rolle
|
||||
user_role = None
|
||||
if request.user_role:
|
||||
try:
|
||||
user_role = UserRoleEnum(request.user_role)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Ungültige Rolle. Erlaubt: 'lehrkraft', 'schulleitung', 'it_beauftragte'"
|
||||
)
|
||||
|
||||
# Validiere Template-IDs
|
||||
if request.template_ids:
|
||||
if len(request.template_ids) > 3:
|
||||
raise HTTPException(status_code=400, detail="Maximal 3 Templates erlaubt")
|
||||
|
||||
# Prüfe ob Templates existieren
|
||||
existing = db.query(AlertTemplateDB).filter(
|
||||
AlertTemplateDB.id.in_(request.template_ids)
|
||||
).count()
|
||||
if existing != len(request.template_ids):
|
||||
raise HTTPException(status_code=400, detail="Eine oder mehrere Template-IDs sind ungültig")
|
||||
|
||||
# Erstelle Subscription
|
||||
subscription = UserAlertSubscriptionDB(
|
||||
id=str(uuid.uuid4()),
|
||||
user_id=user_id,
|
||||
mode=mode,
|
||||
user_role=user_role,
|
||||
selected_template_ids=request.template_ids,
|
||||
notification_email=request.notification_email,
|
||||
digest_enabled=request.digest_enabled,
|
||||
digest_frequency=request.digest_frequency,
|
||||
wizard_completed=len(request.template_ids) > 0, # Abgeschlossen wenn Templates gewählt
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
db.add(subscription)
|
||||
db.commit()
|
||||
db.refresh(subscription)
|
||||
|
||||
return _subscription_to_response(subscription, db)
|
||||
|
||||
|
||||
@router.get("", response_model=SubscriptionListResponse)
|
||||
async def list_subscriptions(
|
||||
active_only: bool = Query(True, description="Nur aktive Subscriptions"),
|
||||
db: DBSession = Depends(get_db)
|
||||
):
|
||||
"""Liste alle Subscriptions des aktuellen Users."""
|
||||
user_id = get_user_id_from_request()
|
||||
|
||||
query = db.query(UserAlertSubscriptionDB).filter(
|
||||
UserAlertSubscriptionDB.user_id == user_id
|
||||
)
|
||||
|
||||
if active_only:
|
||||
query = query.filter(UserAlertSubscriptionDB.is_active == True)
|
||||
|
||||
subscriptions = query.order_by(UserAlertSubscriptionDB.created_at.desc()).all()
|
||||
|
||||
return SubscriptionListResponse(
|
||||
subscriptions=[_subscription_to_response(s, db) for s in subscriptions],
|
||||
total=len(subscriptions)
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{subscription_id}", response_model=SubscriptionResponse)
|
||||
async def get_subscription(
|
||||
subscription_id: str,
|
||||
db: DBSession = Depends(get_db)
|
||||
):
|
||||
"""Hole Details einer Subscription."""
|
||||
user_id = get_user_id_from_request()
|
||||
|
||||
subscription = db.query(UserAlertSubscriptionDB).filter(
|
||||
UserAlertSubscriptionDB.id == subscription_id,
|
||||
UserAlertSubscriptionDB.user_id == user_id
|
||||
).first()
|
||||
|
||||
if not subscription:
|
||||
raise HTTPException(status_code=404, detail="Subscription nicht gefunden")
|
||||
|
||||
return _subscription_to_response(subscription, db)
|
||||
|
||||
|
||||
@router.put("/{subscription_id}", response_model=SubscriptionResponse)
|
||||
async def update_subscription(
|
||||
subscription_id: str,
|
||||
request: SubscriptionUpdate,
|
||||
db: DBSession = Depends(get_db)
|
||||
):
|
||||
"""Aktualisiere eine Subscription."""
|
||||
user_id = get_user_id_from_request()
|
||||
|
||||
subscription = db.query(UserAlertSubscriptionDB).filter(
|
||||
UserAlertSubscriptionDB.id == subscription_id,
|
||||
UserAlertSubscriptionDB.user_id == user_id
|
||||
).first()
|
||||
|
||||
if not subscription:
|
||||
raise HTTPException(status_code=404, detail="Subscription nicht gefunden")
|
||||
|
||||
# Update Felder
|
||||
if request.template_ids is not None:
|
||||
if len(request.template_ids) > 3:
|
||||
raise HTTPException(status_code=400, detail="Maximal 3 Templates erlaubt")
|
||||
subscription.selected_template_ids = request.template_ids
|
||||
|
||||
if request.notification_email is not None:
|
||||
subscription.notification_email = request.notification_email
|
||||
|
||||
if request.digest_enabled is not None:
|
||||
subscription.digest_enabled = request.digest_enabled
|
||||
|
||||
if request.digest_frequency is not None:
|
||||
subscription.digest_frequency = request.digest_frequency
|
||||
|
||||
if request.is_active is not None:
|
||||
subscription.is_active = request.is_active
|
||||
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
db.commit()
|
||||
db.refresh(subscription)
|
||||
|
||||
return _subscription_to_response(subscription, db)
|
||||
|
||||
|
||||
@router.delete("/{subscription_id}")
|
||||
async def deactivate_subscription(
|
||||
subscription_id: str,
|
||||
db: DBSession = Depends(get_db)
|
||||
):
|
||||
"""Deaktiviere eine Subscription (Soft-Delete)."""
|
||||
user_id = get_user_id_from_request()
|
||||
|
||||
subscription = db.query(UserAlertSubscriptionDB).filter(
|
||||
UserAlertSubscriptionDB.id == subscription_id,
|
||||
UserAlertSubscriptionDB.user_id == user_id
|
||||
).first()
|
||||
|
||||
if not subscription:
|
||||
raise HTTPException(status_code=404, detail="Subscription nicht gefunden")
|
||||
|
||||
subscription.is_active = False
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
db.commit()
|
||||
|
||||
return {"status": "success", "message": "Subscription deaktiviert"}
|
||||
|
||||
|
||||
@router.post("/{subscription_id}/activate-template", response_model=ActivateTemplateResponse)
|
||||
async def activate_template(
|
||||
subscription_id: str,
|
||||
request: ActivateTemplateRequest = None,
|
||||
db: DBSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Aktiviere die gewählten Templates für eine Subscription.
|
||||
|
||||
Erstellt:
|
||||
- Topics aus Template.topics_config (RSS-Feeds)
|
||||
- Rules aus Template.rules_config (Filter-Regeln)
|
||||
- Aktualisiert das User-Profil mit Template.profile_config
|
||||
"""
|
||||
user_id = get_user_id_from_request()
|
||||
|
||||
subscription = db.query(UserAlertSubscriptionDB).filter(
|
||||
UserAlertSubscriptionDB.id == subscription_id,
|
||||
UserAlertSubscriptionDB.user_id == user_id
|
||||
).first()
|
||||
|
||||
if not subscription:
|
||||
raise HTTPException(status_code=404, detail="Subscription nicht gefunden")
|
||||
|
||||
if not subscription.selected_template_ids:
|
||||
raise HTTPException(status_code=400, detail="Keine Templates ausgewählt")
|
||||
|
||||
# Lade Templates
|
||||
templates = db.query(AlertTemplateDB).filter(
|
||||
AlertTemplateDB.id.in_(subscription.selected_template_ids)
|
||||
).all()
|
||||
|
||||
topics_created = 0
|
||||
rules_created = 0
|
||||
profile_updated = False
|
||||
|
||||
for template in templates:
|
||||
# Topics erstellen
|
||||
if request is None or request.create_topics:
|
||||
for topic_config in (template.topics_config or []):
|
||||
topic = AlertTopicDB(
|
||||
id=str(uuid.uuid4()),
|
||||
user_id=user_id,
|
||||
name=topic_config.get("name", f"Topic from {template.name}"),
|
||||
description=f"Automatisch erstellt aus Template: {template.name}",
|
||||
is_active=True,
|
||||
fetch_interval_minutes=60,
|
||||
)
|
||||
db.add(topic)
|
||||
topics_created += 1
|
||||
|
||||
# Rules erstellen
|
||||
if request is None or request.create_rules:
|
||||
for rule_config in (template.rules_config or []):
|
||||
rule = AlertRuleDB(
|
||||
id=str(uuid.uuid4()),
|
||||
user_id=user_id,
|
||||
name=rule_config.get("name", f"Rule from {template.name}"),
|
||||
description=f"Automatisch erstellt aus Template: {template.name}",
|
||||
conditions=rule_config.get("conditions", []),
|
||||
action_type=rule_config.get("action_type", "keep"),
|
||||
action_config=rule_config.get("action_config", {}),
|
||||
priority=rule_config.get("priority", 50),
|
||||
is_active=True,
|
||||
)
|
||||
db.add(rule)
|
||||
rules_created += 1
|
||||
|
||||
# Profil aktualisieren
|
||||
if template.profile_config:
|
||||
profile = db.query(AlertProfileDB).filter(
|
||||
AlertProfileDB.user_id == user_id
|
||||
).first()
|
||||
|
||||
if not profile:
|
||||
profile = AlertProfileDB(
|
||||
id=str(uuid.uuid4()),
|
||||
user_id=user_id,
|
||||
name=f"Profil für {user_id}",
|
||||
)
|
||||
db.add(profile)
|
||||
|
||||
# Merge priorities
|
||||
existing_priorities = profile.priorities or []
|
||||
new_priorities = template.profile_config.get("priorities", [])
|
||||
for p in new_priorities:
|
||||
if p not in existing_priorities:
|
||||
existing_priorities.append(p)
|
||||
profile.priorities = existing_priorities
|
||||
|
||||
# Merge exclusions
|
||||
existing_exclusions = profile.exclusions or []
|
||||
new_exclusions = template.profile_config.get("exclusions", [])
|
||||
for e in new_exclusions:
|
||||
if e not in existing_exclusions:
|
||||
existing_exclusions.append(e)
|
||||
profile.exclusions = existing_exclusions
|
||||
|
||||
profile_updated = True
|
||||
|
||||
# Markiere Wizard als abgeschlossen
|
||||
subscription.wizard_completed = True
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
|
||||
db.commit()
|
||||
|
||||
return ActivateTemplateResponse(
|
||||
status="success",
|
||||
topics_created=topics_created,
|
||||
rules_created=rules_created,
|
||||
profile_updated=profile_updated,
|
||||
message=f"Templates aktiviert: {topics_created} Topics, {rules_created} Rules erstellt."
|
||||
)
|
||||
410
backend/alerts_agent/api/templates.py
Normal file
410
backend/alerts_agent/api/templates.py
Normal file
@@ -0,0 +1,410 @@
|
||||
"""
|
||||
API Routes für Alert-Templates (Playbooks).
|
||||
|
||||
Endpoints für Guided Mode:
|
||||
- GET /templates - Liste aller verfügbaren Templates
|
||||
- GET /templates/{template_id} - Template-Details
|
||||
- POST /templates/{template_id}/preview - Vorschau generieren
|
||||
- GET /templates/by-role/{role} - Templates für eine Rolle
|
||||
"""
|
||||
|
||||
from typing import Optional, List
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from ..db.database import get_db
|
||||
from ..db.models import AlertTemplateDB, UserRoleEnum
|
||||
|
||||
|
||||
router = APIRouter(prefix="/templates", tags=["templates"])
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Request/Response Models
|
||||
# ============================================================================
|
||||
|
||||
class TemplateListItem(BaseModel):
|
||||
"""Kurzinfo für Template-Liste."""
|
||||
id: str
|
||||
slug: str
|
||||
name: str
|
||||
description: str
|
||||
icon: str
|
||||
category: str
|
||||
target_roles: List[str]
|
||||
is_premium: bool
|
||||
max_cards_per_day: int
|
||||
sort_order: int
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class TemplateDetail(BaseModel):
|
||||
"""Vollständige Template-Details."""
|
||||
id: str
|
||||
slug: str
|
||||
name: str
|
||||
description: str
|
||||
icon: str
|
||||
category: str
|
||||
target_roles: List[str]
|
||||
topics_config: List[dict]
|
||||
rules_config: List[dict]
|
||||
profile_config: dict
|
||||
importance_config: dict
|
||||
max_cards_per_day: int
|
||||
digest_enabled: bool
|
||||
digest_day: str
|
||||
is_premium: bool
|
||||
is_active: bool
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class TemplateListResponse(BaseModel):
|
||||
"""Response für Template-Liste."""
|
||||
templates: List[TemplateListItem]
|
||||
total: int
|
||||
|
||||
|
||||
class PreviewRequest(BaseModel):
|
||||
"""Request für Template-Vorschau."""
|
||||
sample_count: int = Field(default=3, ge=1, le=5)
|
||||
|
||||
|
||||
class PreviewItem(BaseModel):
|
||||
"""Ein Vorschau-Item."""
|
||||
title: str
|
||||
snippet: str
|
||||
importance_level: str
|
||||
why_relevant: str
|
||||
source_name: str
|
||||
|
||||
|
||||
class PreviewResponse(BaseModel):
|
||||
"""Response für Template-Vorschau."""
|
||||
template_name: str
|
||||
sample_items: List[PreviewItem]
|
||||
estimated_daily_count: str
|
||||
message: str
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Endpoints
|
||||
# ============================================================================
|
||||
|
||||
@router.get("", response_model=TemplateListResponse)
|
||||
async def list_templates(
|
||||
category: Optional[str] = Query(None, description="Filter nach Kategorie"),
|
||||
role: Optional[str] = Query(None, description="Filter nach Zielrolle"),
|
||||
include_premium: bool = Query(True, description="Premium-Templates einschließen"),
|
||||
db: DBSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Liste alle verfügbaren Alert-Templates.
|
||||
|
||||
Templates sind vorkonfigurierte Playbooks für bestimmte Themen
|
||||
(Förderprogramme, Datenschutz, IT-Security, etc.).
|
||||
"""
|
||||
query = db.query(AlertTemplateDB).filter(AlertTemplateDB.is_active == True)
|
||||
|
||||
if category:
|
||||
query = query.filter(AlertTemplateDB.category == category)
|
||||
|
||||
if not include_premium:
|
||||
query = query.filter(AlertTemplateDB.is_premium == False)
|
||||
|
||||
templates = query.order_by(AlertTemplateDB.sort_order).all()
|
||||
|
||||
# Filter nach Rolle (JSON-Feld)
|
||||
if role:
|
||||
templates = [t for t in templates if role in (t.target_roles or [])]
|
||||
|
||||
return TemplateListResponse(
|
||||
templates=[
|
||||
TemplateListItem(
|
||||
id=t.id,
|
||||
slug=t.slug,
|
||||
name=t.name,
|
||||
description=t.description,
|
||||
icon=t.icon or "",
|
||||
category=t.category or "",
|
||||
target_roles=t.target_roles or [],
|
||||
is_premium=t.is_premium or False,
|
||||
max_cards_per_day=t.max_cards_per_day or 10,
|
||||
sort_order=t.sort_order or 0,
|
||||
)
|
||||
for t in templates
|
||||
],
|
||||
total=len(templates)
|
||||
)
|
||||
|
||||
|
||||
@router.get("/by-role/{role}", response_model=TemplateListResponse)
|
||||
async def get_templates_by_role(
|
||||
role: str,
|
||||
db: DBSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Empfohlene Templates für eine bestimmte Rolle.
|
||||
|
||||
Rollen:
|
||||
- lehrkraft: Fokus auf Unterricht, Fortbildungen, Wettbewerbe
|
||||
- schulleitung: Fokus auf Administration, Fördermittel, Recht
|
||||
- it_beauftragte: Fokus auf IT-Security, Datenschutz
|
||||
"""
|
||||
# Validiere Rolle
|
||||
valid_roles = ["lehrkraft", "schulleitung", "it_beauftragte"]
|
||||
if role not in valid_roles:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Ungültige Rolle. Erlaubt: {', '.join(valid_roles)}"
|
||||
)
|
||||
|
||||
templates = db.query(AlertTemplateDB).filter(
|
||||
AlertTemplateDB.is_active == True,
|
||||
AlertTemplateDB.is_premium == False # Nur kostenlose für Empfehlungen
|
||||
).order_by(AlertTemplateDB.sort_order).all()
|
||||
|
||||
# Filter nach Rolle
|
||||
filtered = [t for t in templates if role in (t.target_roles or [])]
|
||||
|
||||
return TemplateListResponse(
|
||||
templates=[
|
||||
TemplateListItem(
|
||||
id=t.id,
|
||||
slug=t.slug,
|
||||
name=t.name,
|
||||
description=t.description,
|
||||
icon=t.icon or "",
|
||||
category=t.category or "",
|
||||
target_roles=t.target_roles or [],
|
||||
is_premium=t.is_premium or False,
|
||||
max_cards_per_day=t.max_cards_per_day or 10,
|
||||
sort_order=t.sort_order or 0,
|
||||
)
|
||||
for t in filtered
|
||||
],
|
||||
total=len(filtered)
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{template_id}", response_model=TemplateDetail)
|
||||
async def get_template(
|
||||
template_id: str,
|
||||
db: DBSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Vollständige Details eines Templates abrufen.
|
||||
|
||||
Enthält alle Konfigurationen (Topics, Rules, Profile).
|
||||
"""
|
||||
template = db.query(AlertTemplateDB).filter(
|
||||
AlertTemplateDB.id == template_id
|
||||
).first()
|
||||
|
||||
if not template:
|
||||
# Versuche nach Slug zu suchen
|
||||
template = db.query(AlertTemplateDB).filter(
|
||||
AlertTemplateDB.slug == template_id
|
||||
).first()
|
||||
|
||||
if not template:
|
||||
raise HTTPException(status_code=404, detail="Template nicht gefunden")
|
||||
|
||||
return TemplateDetail(
|
||||
id=template.id,
|
||||
slug=template.slug,
|
||||
name=template.name,
|
||||
description=template.description,
|
||||
icon=template.icon or "",
|
||||
category=template.category or "",
|
||||
target_roles=template.target_roles or [],
|
||||
topics_config=template.topics_config or [],
|
||||
rules_config=template.rules_config or [],
|
||||
profile_config=template.profile_config or {},
|
||||
importance_config=template.importance_config or {},
|
||||
max_cards_per_day=template.max_cards_per_day or 10,
|
||||
digest_enabled=template.digest_enabled if template.digest_enabled is not None else True,
|
||||
digest_day=template.digest_day or "monday",
|
||||
is_premium=template.is_premium or False,
|
||||
is_active=template.is_active if template.is_active is not None else True,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{template_id}/preview", response_model=PreviewResponse)
|
||||
async def preview_template(
|
||||
template_id: str,
|
||||
request: PreviewRequest = None,
|
||||
db: DBSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Generiere eine Vorschau, wie Alerts für dieses Template aussehen würden.
|
||||
|
||||
Zeigt Beispiel-Alerts mit Wichtigkeitsstufen und "Warum relevant?"-Erklärungen.
|
||||
"""
|
||||
template = db.query(AlertTemplateDB).filter(
|
||||
AlertTemplateDB.id == template_id
|
||||
).first()
|
||||
|
||||
if not template:
|
||||
template = db.query(AlertTemplateDB).filter(
|
||||
AlertTemplateDB.slug == template_id
|
||||
).first()
|
||||
|
||||
if not template:
|
||||
raise HTTPException(status_code=404, detail="Template nicht gefunden")
|
||||
|
||||
# Generiere Beispiel-Alerts basierend auf Template-Konfiguration
|
||||
sample_items = _generate_preview_items(template)
|
||||
|
||||
return PreviewResponse(
|
||||
template_name=template.name,
|
||||
sample_items=sample_items[:request.sample_count if request else 3],
|
||||
estimated_daily_count=f"Ca. {template.max_cards_per_day} Meldungen pro Tag",
|
||||
message=f"Diese Vorschau zeigt, wie Alerts für '{template.name}' aussehen würden."
|
||||
)
|
||||
|
||||
|
||||
@router.post("/seed")
|
||||
async def seed_templates(
|
||||
force_update: bool = Query(False, description="Bestehende Templates aktualisieren"),
|
||||
db: DBSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Fügt die vordefinierten Templates in die Datenbank ein.
|
||||
|
||||
Nur für Entwicklung/Setup.
|
||||
"""
|
||||
from ..data.templates import seed_templates as do_seed
|
||||
|
||||
count = do_seed(db, force_update=force_update)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"templates_created": count,
|
||||
"message": f"{count} Templates wurden eingefügt/aktualisiert."
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Helper Functions
|
||||
# ============================================================================
|
||||
|
||||
def _generate_preview_items(template: AlertTemplateDB) -> List[PreviewItem]:
|
||||
"""
|
||||
Generiere Beispiel-Alerts für Template-Vorschau.
|
||||
|
||||
Diese sind statisch/exemplarisch, nicht aus echten Daten.
|
||||
"""
|
||||
# Template-spezifische Beispiele
|
||||
examples = {
|
||||
"foerderprogramme": [
|
||||
PreviewItem(
|
||||
title="DigitalPakt 2.0: Neue Antragsphase startet am 1. April",
|
||||
snippet="Das BMBF hat die zweite Phase des DigitalPakt Schule angekündigt...",
|
||||
importance_level="DRINGEND",
|
||||
why_relevant="Frist endet in 45 Tagen. Betrifft alle Schulen mit Förderbedarf.",
|
||||
source_name="Bundesministerium für Bildung"
|
||||
),
|
||||
PreviewItem(
|
||||
title="Landesförderung: 50.000€ für innovative Schulprojekte",
|
||||
snippet="Das Kultusministerium fördert Schulen, die digitale Lernkonzepte...",
|
||||
importance_level="WICHTIG",
|
||||
why_relevant="Passende Förderung für Ihr Bundesland. Keine Eigenbeteiligung erforderlich.",
|
||||
source_name="Kultusministerium"
|
||||
),
|
||||
PreviewItem(
|
||||
title="Erasmus+ Schulpartnerschaften: Jetzt bewerben",
|
||||
snippet="Für das Schuljahr 2026/27 können Schulen EU-Förderung beantragen...",
|
||||
importance_level="PRUEFEN",
|
||||
why_relevant="EU-Programm mit hoher Fördersumme. Bewerbungsfrist in 3 Monaten.",
|
||||
source_name="EU-Kommission"
|
||||
),
|
||||
],
|
||||
"abitur-updates": [
|
||||
PreviewItem(
|
||||
title="Neue Operatoren für Abitur Deutsch ab 2027",
|
||||
snippet="Die KMK hat überarbeitete Operatoren für das Fach Deutsch beschlossen...",
|
||||
importance_level="WICHTIG",
|
||||
why_relevant="Betrifft die Oberstufenplanung. Anpassung der Klausuren erforderlich.",
|
||||
source_name="KMK"
|
||||
),
|
||||
PreviewItem(
|
||||
title="Abiturtermine 2026: Prüfungsplan veröffentlicht",
|
||||
snippet="Das Kultusministerium hat die Termine für das Abitur 2026 bekannt gegeben...",
|
||||
importance_level="INFO",
|
||||
why_relevant="Planungsgrundlage für Schuljahreskalender.",
|
||||
source_name="Kultusministerium"
|
||||
),
|
||||
],
|
||||
"datenschutz-recht": [
|
||||
PreviewItem(
|
||||
title="LfDI: Neue Handreichung zu Schülerfotos",
|
||||
snippet="Der Landesbeauftragte für Datenschutz hat eine aktualisierte...",
|
||||
importance_level="DRINGEND",
|
||||
why_relevant="Handlungsbedarf: Bestehende Einwilligungen müssen geprüft werden.",
|
||||
source_name="Datenschutzbeauftragter"
|
||||
),
|
||||
PreviewItem(
|
||||
title="Microsoft 365 an Schulen: Neue Bewertung",
|
||||
snippet="Die Datenschutzkonferenz hat ihre Position zu Microsoft 365 aktualisiert...",
|
||||
importance_level="WICHTIG",
|
||||
why_relevant="Betrifft Schulen mit Microsoft-Lizenzen. Dokumentationspflicht.",
|
||||
source_name="DSK"
|
||||
),
|
||||
],
|
||||
"it-security": [
|
||||
PreviewItem(
|
||||
title="CVE-2026-1234: Kritische Lücke in Moodle",
|
||||
snippet="Eine schwerwiegende Sicherheitslücke wurde in Moodle 4.x gefunden...",
|
||||
importance_level="KRITISCH",
|
||||
why_relevant="Sofortiges Update erforderlich. Exploit bereits aktiv.",
|
||||
source_name="BSI CERT-Bund"
|
||||
),
|
||||
PreviewItem(
|
||||
title="Phishing-Welle: Gefälschte Schulportal-Mails",
|
||||
snippet="Aktuell werden vermehrt Phishing-Mails an Lehrkräfte versendet...",
|
||||
importance_level="DRINGEND",
|
||||
why_relevant="Warnung an Kollegium empfohlen. Erkennungsmerkmale beachten.",
|
||||
source_name="BSI"
|
||||
),
|
||||
],
|
||||
"fortbildungen": [
|
||||
PreviewItem(
|
||||
title="Kostenlose Fortbildung: KI im Unterricht",
|
||||
snippet="Das Landesinstitut bietet eine Online-Fortbildung zu KI-Tools...",
|
||||
importance_level="PRUEFEN",
|
||||
why_relevant="Passt zu Ihrem Interessenprofil. Online-Format, 4 Stunden.",
|
||||
source_name="Landesinstitut"
|
||||
),
|
||||
],
|
||||
"wettbewerbe-projekte": [
|
||||
PreviewItem(
|
||||
title="Jugend forscht: Anmeldung bis 30. November",
|
||||
snippet="Der größte deutsche MINT-Wettbewerb sucht wieder junge Forscher...",
|
||||
importance_level="WICHTIG",
|
||||
why_relevant="Frist in 60 Tagen. Für Schüler ab Klasse 4.",
|
||||
source_name="Jugend forscht e.V."
|
||||
),
|
||||
],
|
||||
}
|
||||
|
||||
# Hole Beispiele für dieses Template oder generische
|
||||
slug = template.slug
|
||||
if slug in examples:
|
||||
return examples[slug]
|
||||
|
||||
# Generische Beispiele
|
||||
return [
|
||||
PreviewItem(
|
||||
title=f"Beispiel-Meldung für {template.name}",
|
||||
snippet=f"Dies ist eine Vorschau, wie Alerts für das Thema '{template.name}' aussehen würden.",
|
||||
importance_level="INFO",
|
||||
why_relevant="Passend zu Ihren ausgewählten Themen.",
|
||||
source_name="Beispielquelle"
|
||||
)
|
||||
]
|
||||
405
backend/alerts_agent/api/topics.py
Normal file
405
backend/alerts_agent/api/topics.py
Normal file
@@ -0,0 +1,405 @@
|
||||
"""
|
||||
Topic API Routes für Alerts Agent.
|
||||
|
||||
CRUD-Operationen für Alert-Topics (Feed-Quellen).
|
||||
"""
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
|
||||
from pydantic import BaseModel, Field, HttpUrl
|
||||
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
from alerts_agent.db import get_db
|
||||
from alerts_agent.db.repository import TopicRepository, AlertItemRepository
|
||||
from alerts_agent.db.models import FeedTypeEnum
|
||||
|
||||
|
||||
router = APIRouter(prefix="/topics", tags=["alerts"])
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# PYDANTIC MODELS
|
||||
# =============================================================================
|
||||
|
||||
class TopicCreate(BaseModel):
|
||||
"""Request-Model für Topic-Erstellung."""
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
description: str = Field(default="", max_length=2000)
|
||||
feed_url: Optional[str] = Field(default=None, max_length=2000)
|
||||
feed_type: str = Field(default="rss") # rss, email, webhook
|
||||
fetch_interval_minutes: int = Field(default=60, ge=5, le=1440)
|
||||
is_active: bool = Field(default=True)
|
||||
|
||||
|
||||
class TopicUpdate(BaseModel):
|
||||
"""Request-Model für Topic-Update."""
|
||||
name: Optional[str] = Field(default=None, min_length=1, max_length=255)
|
||||
description: Optional[str] = Field(default=None, max_length=2000)
|
||||
feed_url: Optional[str] = Field(default=None, max_length=2000)
|
||||
feed_type: Optional[str] = None
|
||||
fetch_interval_minutes: Optional[int] = Field(default=None, ge=5, le=1440)
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
class TopicResponse(BaseModel):
|
||||
"""Response-Model für Topic."""
|
||||
id: str
|
||||
name: str
|
||||
description: str
|
||||
feed_url: Optional[str]
|
||||
feed_type: str
|
||||
is_active: bool
|
||||
fetch_interval_minutes: int
|
||||
last_fetched_at: Optional[datetime]
|
||||
last_fetch_error: Optional[str]
|
||||
total_items_fetched: int
|
||||
items_kept: int
|
||||
items_dropped: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class TopicListResponse(BaseModel):
|
||||
"""Response-Model für Topic-Liste."""
|
||||
topics: List[TopicResponse]
|
||||
total: int
|
||||
|
||||
|
||||
class TopicStatsResponse(BaseModel):
|
||||
"""Response-Model für Topic-Statistiken."""
|
||||
topic_id: str
|
||||
name: str
|
||||
total_alerts: int
|
||||
by_status: dict
|
||||
by_decision: dict
|
||||
keep_rate: Optional[float]
|
||||
|
||||
|
||||
class FetchResultResponse(BaseModel):
|
||||
"""Response-Model für manuellen Fetch."""
|
||||
success: bool
|
||||
topic_id: str
|
||||
new_items: int
|
||||
duplicates_skipped: int
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# API ENDPOINTS
|
||||
# =============================================================================
|
||||
|
||||
@router.post("", response_model=TopicResponse, status_code=201)
|
||||
async def create_topic(
|
||||
topic: TopicCreate,
|
||||
db: DBSession = Depends(get_db),
|
||||
) -> TopicResponse:
|
||||
"""
|
||||
Erstellt ein neues Topic (Feed-Quelle).
|
||||
|
||||
Topics repräsentieren Google Alerts RSS-Feeds oder andere Feed-Quellen.
|
||||
"""
|
||||
repo = TopicRepository(db)
|
||||
|
||||
created = repo.create(
|
||||
name=topic.name,
|
||||
description=topic.description,
|
||||
feed_url=topic.feed_url,
|
||||
feed_type=topic.feed_type,
|
||||
fetch_interval_minutes=topic.fetch_interval_minutes,
|
||||
is_active=topic.is_active,
|
||||
)
|
||||
|
||||
return TopicResponse(
|
||||
id=created.id,
|
||||
name=created.name,
|
||||
description=created.description or "",
|
||||
feed_url=created.feed_url,
|
||||
feed_type=created.feed_type.value if created.feed_type else "rss",
|
||||
is_active=created.is_active,
|
||||
fetch_interval_minutes=created.fetch_interval_minutes,
|
||||
last_fetched_at=created.last_fetched_at,
|
||||
last_fetch_error=created.last_fetch_error,
|
||||
total_items_fetched=created.total_items_fetched,
|
||||
items_kept=created.items_kept,
|
||||
items_dropped=created.items_dropped,
|
||||
created_at=created.created_at,
|
||||
updated_at=created.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=TopicListResponse)
|
||||
async def list_topics(
|
||||
is_active: Optional[bool] = None,
|
||||
db: DBSession = Depends(get_db),
|
||||
) -> TopicListResponse:
|
||||
"""
|
||||
Listet alle Topics auf.
|
||||
|
||||
Optional nach aktivem Status filterbar.
|
||||
"""
|
||||
repo = TopicRepository(db)
|
||||
topics = repo.get_all(is_active=is_active)
|
||||
|
||||
return TopicListResponse(
|
||||
topics=[
|
||||
TopicResponse(
|
||||
id=t.id,
|
||||
name=t.name,
|
||||
description=t.description or "",
|
||||
feed_url=t.feed_url,
|
||||
feed_type=t.feed_type.value if t.feed_type else "rss",
|
||||
is_active=t.is_active,
|
||||
fetch_interval_minutes=t.fetch_interval_minutes,
|
||||
last_fetched_at=t.last_fetched_at,
|
||||
last_fetch_error=t.last_fetch_error,
|
||||
total_items_fetched=t.total_items_fetched,
|
||||
items_kept=t.items_kept,
|
||||
items_dropped=t.items_dropped,
|
||||
created_at=t.created_at,
|
||||
updated_at=t.updated_at,
|
||||
)
|
||||
for t in topics
|
||||
],
|
||||
total=len(topics),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{topic_id}", response_model=TopicResponse)
|
||||
async def get_topic(
|
||||
topic_id: str,
|
||||
db: DBSession = Depends(get_db),
|
||||
) -> TopicResponse:
|
||||
"""
|
||||
Ruft ein Topic nach ID ab.
|
||||
"""
|
||||
repo = TopicRepository(db)
|
||||
topic = repo.get_by_id(topic_id)
|
||||
|
||||
if not topic:
|
||||
raise HTTPException(status_code=404, detail="Topic nicht gefunden")
|
||||
|
||||
return TopicResponse(
|
||||
id=topic.id,
|
||||
name=topic.name,
|
||||
description=topic.description or "",
|
||||
feed_url=topic.feed_url,
|
||||
feed_type=topic.feed_type.value if topic.feed_type else "rss",
|
||||
is_active=topic.is_active,
|
||||
fetch_interval_minutes=topic.fetch_interval_minutes,
|
||||
last_fetched_at=topic.last_fetched_at,
|
||||
last_fetch_error=topic.last_fetch_error,
|
||||
total_items_fetched=topic.total_items_fetched,
|
||||
items_kept=topic.items_kept,
|
||||
items_dropped=topic.items_dropped,
|
||||
created_at=topic.created_at,
|
||||
updated_at=topic.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{topic_id}", response_model=TopicResponse)
|
||||
async def update_topic(
|
||||
topic_id: str,
|
||||
updates: TopicUpdate,
|
||||
db: DBSession = Depends(get_db),
|
||||
) -> TopicResponse:
|
||||
"""
|
||||
Aktualisiert ein Topic.
|
||||
"""
|
||||
repo = TopicRepository(db)
|
||||
|
||||
# Nur übergebene Werte updaten
|
||||
update_dict = {k: v for k, v in updates.model_dump().items() if v is not None}
|
||||
|
||||
if not update_dict:
|
||||
raise HTTPException(status_code=400, detail="Keine Updates angegeben")
|
||||
|
||||
updated = repo.update(topic_id, **update_dict)
|
||||
|
||||
if not updated:
|
||||
raise HTTPException(status_code=404, detail="Topic nicht gefunden")
|
||||
|
||||
return TopicResponse(
|
||||
id=updated.id,
|
||||
name=updated.name,
|
||||
description=updated.description or "",
|
||||
feed_url=updated.feed_url,
|
||||
feed_type=updated.feed_type.value if updated.feed_type else "rss",
|
||||
is_active=updated.is_active,
|
||||
fetch_interval_minutes=updated.fetch_interval_minutes,
|
||||
last_fetched_at=updated.last_fetched_at,
|
||||
last_fetch_error=updated.last_fetch_error,
|
||||
total_items_fetched=updated.total_items_fetched,
|
||||
items_kept=updated.items_kept,
|
||||
items_dropped=updated.items_dropped,
|
||||
created_at=updated.created_at,
|
||||
updated_at=updated.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{topic_id}", status_code=204)
|
||||
async def delete_topic(
|
||||
topic_id: str,
|
||||
db: DBSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Löscht ein Topic und alle zugehörigen Alerts (CASCADE).
|
||||
"""
|
||||
repo = TopicRepository(db)
|
||||
|
||||
success = repo.delete(topic_id)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="Topic nicht gefunden")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/{topic_id}/stats", response_model=TopicStatsResponse)
|
||||
async def get_topic_stats(
|
||||
topic_id: str,
|
||||
db: DBSession = Depends(get_db),
|
||||
) -> TopicStatsResponse:
|
||||
"""
|
||||
Ruft Statistiken für ein Topic ab.
|
||||
"""
|
||||
topic_repo = TopicRepository(db)
|
||||
alert_repo = AlertItemRepository(db)
|
||||
|
||||
topic = topic_repo.get_by_id(topic_id)
|
||||
if not topic:
|
||||
raise HTTPException(status_code=404, detail="Topic nicht gefunden")
|
||||
|
||||
by_status = alert_repo.count_by_status(topic_id)
|
||||
by_decision = alert_repo.count_by_decision(topic_id)
|
||||
|
||||
total = sum(by_status.values())
|
||||
keep_count = by_decision.get("KEEP", 0)
|
||||
|
||||
return TopicStatsResponse(
|
||||
topic_id=topic_id,
|
||||
name=topic.name,
|
||||
total_alerts=total,
|
||||
by_status=by_status,
|
||||
by_decision=by_decision,
|
||||
keep_rate=keep_count / total if total > 0 else None,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{topic_id}/fetch", response_model=FetchResultResponse)
|
||||
async def fetch_topic(
|
||||
topic_id: str,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: DBSession = Depends(get_db),
|
||||
) -> FetchResultResponse:
|
||||
"""
|
||||
Löst einen manuellen Fetch für ein Topic aus.
|
||||
|
||||
Der Fetch wird im Hintergrund ausgeführt. Das Ergebnis zeigt
|
||||
die Anzahl neuer Items und übersprungener Duplikate.
|
||||
"""
|
||||
topic_repo = TopicRepository(db)
|
||||
|
||||
topic = topic_repo.get_by_id(topic_id)
|
||||
if not topic:
|
||||
raise HTTPException(status_code=404, detail="Topic nicht gefunden")
|
||||
|
||||
if not topic.feed_url:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Topic hat keine Feed-URL konfiguriert"
|
||||
)
|
||||
|
||||
# Import hier um zirkuläre Imports zu vermeiden
|
||||
from alerts_agent.ingestion.rss_fetcher import fetch_and_store_feed
|
||||
|
||||
try:
|
||||
result = await fetch_and_store_feed(
|
||||
topic_id=topic_id,
|
||||
feed_url=topic.feed_url,
|
||||
db=db,
|
||||
)
|
||||
|
||||
return FetchResultResponse(
|
||||
success=True,
|
||||
topic_id=topic_id,
|
||||
new_items=result.get("new_items", 0),
|
||||
duplicates_skipped=result.get("duplicates_skipped", 0),
|
||||
)
|
||||
except Exception as e:
|
||||
# Fehler im Topic speichern
|
||||
topic_repo.update(topic_id, last_fetch_error=str(e))
|
||||
|
||||
return FetchResultResponse(
|
||||
success=False,
|
||||
topic_id=topic_id,
|
||||
new_items=0,
|
||||
duplicates_skipped=0,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{topic_id}/activate", response_model=TopicResponse)
|
||||
async def activate_topic(
|
||||
topic_id: str,
|
||||
db: DBSession = Depends(get_db),
|
||||
) -> TopicResponse:
|
||||
"""
|
||||
Aktiviert ein Topic für automatisches Fetching.
|
||||
"""
|
||||
repo = TopicRepository(db)
|
||||
updated = repo.update(topic_id, is_active=True)
|
||||
|
||||
if not updated:
|
||||
raise HTTPException(status_code=404, detail="Topic nicht gefunden")
|
||||
|
||||
return TopicResponse(
|
||||
id=updated.id,
|
||||
name=updated.name,
|
||||
description=updated.description or "",
|
||||
feed_url=updated.feed_url,
|
||||
feed_type=updated.feed_type.value if updated.feed_type else "rss",
|
||||
is_active=updated.is_active,
|
||||
fetch_interval_minutes=updated.fetch_interval_minutes,
|
||||
last_fetched_at=updated.last_fetched_at,
|
||||
last_fetch_error=updated.last_fetch_error,
|
||||
total_items_fetched=updated.total_items_fetched,
|
||||
items_kept=updated.items_kept,
|
||||
items_dropped=updated.items_dropped,
|
||||
created_at=updated.created_at,
|
||||
updated_at=updated.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{topic_id}/deactivate", response_model=TopicResponse)
|
||||
async def deactivate_topic(
|
||||
topic_id: str,
|
||||
db: DBSession = Depends(get_db),
|
||||
) -> TopicResponse:
|
||||
"""
|
||||
Deaktiviert ein Topic (stoppt automatisches Fetching).
|
||||
"""
|
||||
repo = TopicRepository(db)
|
||||
updated = repo.update(topic_id, is_active=False)
|
||||
|
||||
if not updated:
|
||||
raise HTTPException(status_code=404, detail="Topic nicht gefunden")
|
||||
|
||||
return TopicResponse(
|
||||
id=updated.id,
|
||||
name=updated.name,
|
||||
description=updated.description or "",
|
||||
feed_url=updated.feed_url,
|
||||
feed_type=updated.feed_type.value if updated.feed_type else "rss",
|
||||
is_active=updated.is_active,
|
||||
fetch_interval_minutes=updated.fetch_interval_minutes,
|
||||
last_fetched_at=updated.last_fetched_at,
|
||||
last_fetch_error=updated.last_fetch_error,
|
||||
total_items_fetched=updated.total_items_fetched,
|
||||
items_kept=updated.items_kept,
|
||||
items_dropped=updated.items_dropped,
|
||||
created_at=updated.created_at,
|
||||
updated_at=updated.updated_at,
|
||||
)
|
||||
554
backend/alerts_agent/api/wizard.py
Normal file
554
backend/alerts_agent/api/wizard.py
Normal file
@@ -0,0 +1,554 @@
|
||||
"""
|
||||
API Routes für den Guided Mode Wizard.
|
||||
|
||||
Verwaltet den 3-Schritt Setup-Wizard:
|
||||
1. Rolle wählen (Lehrkraft, Schulleitung, IT-Beauftragte)
|
||||
2. Templates auswählen (max. 3)
|
||||
3. Bestätigung und Aktivierung
|
||||
|
||||
Zusätzlich: Migration-Wizard für bestehende Google Alerts.
|
||||
|
||||
Endpoints:
|
||||
- GET /wizard/state - Aktuellen Wizard-Status abrufen
|
||||
- PUT /wizard/step/{step} - Schritt speichern
|
||||
- POST /wizard/complete - Wizard abschließen
|
||||
- POST /wizard/reset - Wizard zurücksetzen
|
||||
- POST /wizard/migrate/email - E-Mail-Migration starten
|
||||
- POST /wizard/migrate/rss - RSS-Import
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from typing import Optional, List, Dict, Any
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from ..db.database import get_db
|
||||
from ..db.models import (
|
||||
UserAlertSubscriptionDB, AlertTemplateDB, AlertSourceDB,
|
||||
AlertModeEnum, UserRoleEnum, MigrationModeEnum, FeedTypeEnum
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter(prefix="/wizard", tags=["wizard"])
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Request/Response Models
|
||||
# ============================================================================
|
||||
|
||||
class WizardState(BaseModel):
|
||||
"""Aktueller Wizard-Status."""
|
||||
subscription_id: Optional[str] = None
|
||||
current_step: int = 0 # 0=nicht gestartet, 1-3=Schritte, 4=abgeschlossen
|
||||
is_completed: bool = False
|
||||
step_data: Dict[str, Any] = {}
|
||||
recommended_templates: List[Dict[str, Any]] = []
|
||||
|
||||
|
||||
class Step1Data(BaseModel):
|
||||
"""Daten für Schritt 1: Rollenwahl."""
|
||||
role: str = Field(..., description="lehrkraft, schulleitung, it_beauftragte")
|
||||
|
||||
|
||||
class Step2Data(BaseModel):
|
||||
"""Daten für Schritt 2: Template-Auswahl."""
|
||||
template_ids: List[str] = Field(..., min_length=1, max_length=3)
|
||||
|
||||
|
||||
class Step3Data(BaseModel):
|
||||
"""Daten für Schritt 3: Bestätigung."""
|
||||
notification_email: Optional[str] = None
|
||||
digest_enabled: bool = True
|
||||
digest_frequency: str = "weekly"
|
||||
|
||||
|
||||
class StepResponse(BaseModel):
|
||||
"""Response für Schritt-Update."""
|
||||
status: str
|
||||
current_step: int
|
||||
next_step: int
|
||||
message: str
|
||||
recommended_templates: List[Dict[str, Any]] = []
|
||||
|
||||
|
||||
class MigrateEmailRequest(BaseModel):
|
||||
"""Request für E-Mail-Migration."""
|
||||
original_label: Optional[str] = Field(default=None, description="Beschreibung des Alerts")
|
||||
|
||||
|
||||
class MigrateEmailResponse(BaseModel):
|
||||
"""Response für E-Mail-Migration."""
|
||||
status: str
|
||||
inbound_address: str
|
||||
instructions: List[str]
|
||||
source_id: str
|
||||
|
||||
|
||||
class MigrateRssRequest(BaseModel):
|
||||
"""Request für RSS-Import."""
|
||||
rss_urls: List[str] = Field(..., min_length=1, max_length=20)
|
||||
labels: Optional[List[str]] = None
|
||||
|
||||
|
||||
class MigrateRssResponse(BaseModel):
|
||||
"""Response für RSS-Import."""
|
||||
status: str
|
||||
sources_created: int
|
||||
topics_created: int
|
||||
message: str
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Helper Functions
|
||||
# ============================================================================
|
||||
|
||||
def get_user_id_from_request() -> str:
|
||||
"""Extrahiert User-ID aus Request."""
|
||||
return "demo-user"
|
||||
|
||||
|
||||
def _get_or_create_subscription(db: DBSession, user_id: str) -> UserAlertSubscriptionDB:
|
||||
"""Hole oder erstelle Subscription für Wizard."""
|
||||
subscription = db.query(UserAlertSubscriptionDB).filter(
|
||||
UserAlertSubscriptionDB.user_id == user_id,
|
||||
UserAlertSubscriptionDB.wizard_completed == False
|
||||
).first()
|
||||
|
||||
if not subscription:
|
||||
subscription = UserAlertSubscriptionDB(
|
||||
id=str(uuid.uuid4()),
|
||||
user_id=user_id,
|
||||
mode=AlertModeEnum.GUIDED,
|
||||
wizard_step=0,
|
||||
wizard_completed=False,
|
||||
wizard_state={},
|
||||
is_active=True,
|
||||
)
|
||||
db.add(subscription)
|
||||
db.commit()
|
||||
db.refresh(subscription)
|
||||
|
||||
return subscription
|
||||
|
||||
|
||||
def _get_recommended_templates(db: DBSession, role: str) -> List[Dict[str, Any]]:
|
||||
"""Hole empfohlene Templates für eine Rolle."""
|
||||
templates = db.query(AlertTemplateDB).filter(
|
||||
AlertTemplateDB.is_active == True,
|
||||
AlertTemplateDB.is_premium == False
|
||||
).order_by(AlertTemplateDB.sort_order).all()
|
||||
|
||||
result = []
|
||||
for t in templates:
|
||||
if role in (t.target_roles or []):
|
||||
result.append({
|
||||
"id": t.id,
|
||||
"slug": t.slug,
|
||||
"name": t.name,
|
||||
"description": t.description,
|
||||
"icon": t.icon,
|
||||
"category": t.category,
|
||||
"recommended": True,
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _generate_inbound_address(user_id: str, source_id: str) -> str:
|
||||
"""Generiere eindeutige Inbound-E-Mail-Adresse."""
|
||||
short_id = source_id[:8]
|
||||
return f"alerts+{short_id}@breakpilot.app"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Wizard Endpoints
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/state", response_model=WizardState)
|
||||
async def get_wizard_state(
|
||||
db: DBSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Hole aktuellen Wizard-Status.
|
||||
|
||||
Gibt Schritt, gespeicherte Daten und empfohlene Templates zurück.
|
||||
"""
|
||||
user_id = get_user_id_from_request()
|
||||
|
||||
subscription = db.query(UserAlertSubscriptionDB).filter(
|
||||
UserAlertSubscriptionDB.user_id == user_id
|
||||
).order_by(UserAlertSubscriptionDB.created_at.desc()).first()
|
||||
|
||||
if not subscription:
|
||||
return WizardState(
|
||||
subscription_id=None,
|
||||
current_step=0,
|
||||
is_completed=False,
|
||||
step_data={},
|
||||
recommended_templates=[],
|
||||
)
|
||||
|
||||
# Empfohlene Templates basierend auf Rolle
|
||||
role = subscription.user_role.value if subscription.user_role else None
|
||||
recommended = _get_recommended_templates(db, role) if role else []
|
||||
|
||||
return WizardState(
|
||||
subscription_id=subscription.id,
|
||||
current_step=subscription.wizard_step or 0,
|
||||
is_completed=subscription.wizard_completed or False,
|
||||
step_data=subscription.wizard_state or {},
|
||||
recommended_templates=recommended,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/step/1", response_model=StepResponse)
|
||||
async def save_step_1(
|
||||
data: Step1Data,
|
||||
db: DBSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Schritt 1: Rolle speichern.
|
||||
|
||||
Wählt die Rolle des Nutzers und gibt passende Template-Empfehlungen.
|
||||
"""
|
||||
user_id = get_user_id_from_request()
|
||||
|
||||
# Validiere Rolle
|
||||
try:
|
||||
role = UserRoleEnum(data.role)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Ungültige Rolle. Erlaubt: 'lehrkraft', 'schulleitung', 'it_beauftragte'"
|
||||
)
|
||||
|
||||
subscription = _get_or_create_subscription(db, user_id)
|
||||
|
||||
# Update
|
||||
subscription.user_role = role
|
||||
subscription.wizard_step = 1
|
||||
wizard_state = subscription.wizard_state or {}
|
||||
wizard_state["step1"] = {"role": data.role}
|
||||
subscription.wizard_state = wizard_state
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
|
||||
db.commit()
|
||||
db.refresh(subscription)
|
||||
|
||||
# Empfohlene Templates
|
||||
recommended = _get_recommended_templates(db, data.role)
|
||||
|
||||
return StepResponse(
|
||||
status="success",
|
||||
current_step=1,
|
||||
next_step=2,
|
||||
message=f"Rolle '{data.role}' gespeichert. Bitte wählen Sie jetzt Ihre Themen.",
|
||||
recommended_templates=recommended,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/step/2", response_model=StepResponse)
|
||||
async def save_step_2(
|
||||
data: Step2Data,
|
||||
db: DBSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Schritt 2: Templates auswählen.
|
||||
|
||||
Speichert die ausgewählten Templates (1-3).
|
||||
"""
|
||||
user_id = get_user_id_from_request()
|
||||
|
||||
subscription = db.query(UserAlertSubscriptionDB).filter(
|
||||
UserAlertSubscriptionDB.user_id == user_id,
|
||||
UserAlertSubscriptionDB.wizard_completed == False
|
||||
).first()
|
||||
|
||||
if not subscription:
|
||||
raise HTTPException(status_code=400, detail="Bitte zuerst Schritt 1 abschließen")
|
||||
|
||||
# Validiere Template-IDs
|
||||
templates = db.query(AlertTemplateDB).filter(
|
||||
AlertTemplateDB.id.in_(data.template_ids)
|
||||
).all()
|
||||
|
||||
if len(templates) != len(data.template_ids):
|
||||
raise HTTPException(status_code=400, detail="Eine oder mehrere Template-IDs sind ungültig")
|
||||
|
||||
# Update
|
||||
subscription.selected_template_ids = data.template_ids
|
||||
subscription.wizard_step = 2
|
||||
wizard_state = subscription.wizard_state or {}
|
||||
wizard_state["step2"] = {
|
||||
"template_ids": data.template_ids,
|
||||
"template_names": [t.name for t in templates],
|
||||
}
|
||||
subscription.wizard_state = wizard_state
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
|
||||
db.commit()
|
||||
|
||||
return StepResponse(
|
||||
status="success",
|
||||
current_step=2,
|
||||
next_step=3,
|
||||
message=f"{len(templates)} Themen ausgewählt. Bitte bestätigen Sie Ihre Auswahl.",
|
||||
recommended_templates=[],
|
||||
)
|
||||
|
||||
|
||||
@router.put("/step/3", response_model=StepResponse)
|
||||
async def save_step_3(
|
||||
data: Step3Data,
|
||||
db: DBSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Schritt 3: Digest-Einstellungen und Bestätigung.
|
||||
|
||||
Speichert E-Mail und Digest-Präferenzen.
|
||||
"""
|
||||
user_id = get_user_id_from_request()
|
||||
|
||||
subscription = db.query(UserAlertSubscriptionDB).filter(
|
||||
UserAlertSubscriptionDB.user_id == user_id,
|
||||
UserAlertSubscriptionDB.wizard_completed == False
|
||||
).first()
|
||||
|
||||
if not subscription:
|
||||
raise HTTPException(status_code=400, detail="Bitte zuerst Schritte 1 und 2 abschließen")
|
||||
|
||||
if not subscription.selected_template_ids:
|
||||
raise HTTPException(status_code=400, detail="Bitte zuerst Templates auswählen (Schritt 2)")
|
||||
|
||||
# Update
|
||||
subscription.notification_email = data.notification_email
|
||||
subscription.digest_enabled = data.digest_enabled
|
||||
subscription.digest_frequency = data.digest_frequency
|
||||
subscription.wizard_step = 3
|
||||
|
||||
wizard_state = subscription.wizard_state or {}
|
||||
wizard_state["step3"] = {
|
||||
"notification_email": data.notification_email,
|
||||
"digest_enabled": data.digest_enabled,
|
||||
"digest_frequency": data.digest_frequency,
|
||||
}
|
||||
subscription.wizard_state = wizard_state
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
|
||||
db.commit()
|
||||
|
||||
return StepResponse(
|
||||
status="success",
|
||||
current_step=3,
|
||||
next_step=4,
|
||||
message="Einstellungen gespeichert. Klicken Sie auf 'Jetzt starten' um den Wizard abzuschließen.",
|
||||
recommended_templates=[],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/complete")
|
||||
async def complete_wizard(
|
||||
db: DBSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Wizard abschließen und Templates aktivieren.
|
||||
|
||||
Erstellt Topics, Rules und Profile basierend auf den gewählten Templates.
|
||||
"""
|
||||
user_id = get_user_id_from_request()
|
||||
|
||||
subscription = db.query(UserAlertSubscriptionDB).filter(
|
||||
UserAlertSubscriptionDB.user_id == user_id,
|
||||
UserAlertSubscriptionDB.wizard_completed == False
|
||||
).first()
|
||||
|
||||
if not subscription:
|
||||
raise HTTPException(status_code=400, detail="Kein aktiver Wizard gefunden")
|
||||
|
||||
if not subscription.selected_template_ids:
|
||||
raise HTTPException(status_code=400, detail="Bitte zuerst Templates auswählen")
|
||||
|
||||
# Aktiviere Templates (über Subscription-Endpoint)
|
||||
from .subscriptions import activate_template, ActivateTemplateRequest
|
||||
|
||||
# Markiere als abgeschlossen
|
||||
subscription.wizard_completed = True
|
||||
subscription.wizard_step = 4
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Wizard abgeschlossen! Ihre Alerts werden ab jetzt gesammelt.",
|
||||
"subscription_id": subscription.id,
|
||||
"selected_templates": subscription.selected_template_ids,
|
||||
"next_action": "Besuchen Sie die Inbox, um Ihre ersten Alerts zu sehen.",
|
||||
}
|
||||
|
||||
|
||||
@router.post("/reset")
|
||||
async def reset_wizard(
|
||||
db: DBSession = Depends(get_db)
|
||||
):
|
||||
"""Wizard zurücksetzen (für Neustart)."""
|
||||
user_id = get_user_id_from_request()
|
||||
|
||||
subscription = db.query(UserAlertSubscriptionDB).filter(
|
||||
UserAlertSubscriptionDB.user_id == user_id,
|
||||
UserAlertSubscriptionDB.wizard_completed == False
|
||||
).first()
|
||||
|
||||
if subscription:
|
||||
db.delete(subscription)
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Wizard zurückgesetzt. Sie können neu beginnen.",
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Migration Endpoints (für bestehende Google Alerts)
|
||||
# ============================================================================
|
||||
|
||||
@router.post("/migrate/email", response_model=MigrateEmailResponse)
|
||||
async def start_email_migration(
|
||||
request: MigrateEmailRequest = None,
|
||||
db: DBSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Starte E-Mail-Migration für bestehende Google Alerts.
|
||||
|
||||
Generiert eine eindeutige Inbound-E-Mail-Adresse, an die der Nutzer
|
||||
seine Google Alerts weiterleiten kann.
|
||||
"""
|
||||
user_id = get_user_id_from_request()
|
||||
|
||||
# Erstelle AlertSource
|
||||
source = AlertSourceDB(
|
||||
id=str(uuid.uuid4()),
|
||||
user_id=user_id,
|
||||
source_type=FeedTypeEnum.EMAIL,
|
||||
original_label=request.original_label if request else "Google Alert Migration",
|
||||
migration_mode=MigrationModeEnum.FORWARD,
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
# Generiere Inbound-Adresse
|
||||
source.inbound_address = _generate_inbound_address(user_id, source.id)
|
||||
|
||||
db.add(source)
|
||||
db.commit()
|
||||
db.refresh(source)
|
||||
|
||||
return MigrateEmailResponse(
|
||||
status="success",
|
||||
inbound_address=source.inbound_address,
|
||||
source_id=source.id,
|
||||
instructions=[
|
||||
"1. Öffnen Sie Google Alerts (google.com/alerts)",
|
||||
"2. Klicken Sie auf das Bearbeiten-Symbol bei Ihrem Alert",
|
||||
f"3. Ändern Sie die E-Mail-Adresse zu: {source.inbound_address}",
|
||||
"4. Speichern Sie die Änderung",
|
||||
"5. Ihre Alerts werden automatisch importiert und gefiltert",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/migrate/rss", response_model=MigrateRssResponse)
|
||||
async def import_rss_feeds(
|
||||
request: MigrateRssRequest,
|
||||
db: DBSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Importiere bestehende Google Alert RSS-Feeds.
|
||||
|
||||
Erstellt für jede RSS-URL einen AlertSource und Topic.
|
||||
"""
|
||||
user_id = get_user_id_from_request()
|
||||
|
||||
from ..db.models import AlertTopicDB
|
||||
|
||||
sources_created = 0
|
||||
topics_created = 0
|
||||
|
||||
for i, url in enumerate(request.rss_urls):
|
||||
# Label aus Request oder generieren
|
||||
label = None
|
||||
if request.labels and i < len(request.labels):
|
||||
label = request.labels[i]
|
||||
if not label:
|
||||
label = f"RSS Feed {i + 1}"
|
||||
|
||||
# Erstelle AlertSource
|
||||
source = AlertSourceDB(
|
||||
id=str(uuid.uuid4()),
|
||||
user_id=user_id,
|
||||
source_type=FeedTypeEnum.RSS,
|
||||
original_label=label,
|
||||
rss_url=url,
|
||||
migration_mode=MigrationModeEnum.IMPORT,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(source)
|
||||
sources_created += 1
|
||||
|
||||
# Erstelle Topic
|
||||
topic = AlertTopicDB(
|
||||
id=str(uuid.uuid4()),
|
||||
user_id=user_id,
|
||||
name=label,
|
||||
description=f"Importiert aus RSS: {url[:50]}...",
|
||||
feed_url=url,
|
||||
feed_type=FeedTypeEnum.RSS,
|
||||
is_active=True,
|
||||
fetch_interval_minutes=60,
|
||||
)
|
||||
db.add(topic)
|
||||
|
||||
# Verknüpfe Source mit Topic
|
||||
source.topic_id = topic.id
|
||||
topics_created += 1
|
||||
|
||||
db.commit()
|
||||
|
||||
return MigrateRssResponse(
|
||||
status="success",
|
||||
sources_created=sources_created,
|
||||
topics_created=topics_created,
|
||||
message=f"{sources_created} RSS-Feeds importiert. Die Alerts werden automatisch abgerufen.",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/migrate/sources")
|
||||
async def list_migration_sources(
|
||||
db: DBSession = Depends(get_db)
|
||||
):
|
||||
"""Liste alle Migration-Quellen des Users."""
|
||||
user_id = get_user_id_from_request()
|
||||
|
||||
sources = db.query(AlertSourceDB).filter(
|
||||
AlertSourceDB.user_id == user_id
|
||||
).order_by(AlertSourceDB.created_at.desc()).all()
|
||||
|
||||
return {
|
||||
"sources": [
|
||||
{
|
||||
"id": s.id,
|
||||
"type": s.source_type.value if s.source_type else "unknown",
|
||||
"label": s.original_label,
|
||||
"inbound_address": s.inbound_address,
|
||||
"rss_url": s.rss_url,
|
||||
"migration_mode": s.migration_mode.value if s.migration_mode else "unknown",
|
||||
"items_received": s.items_received,
|
||||
"is_active": s.is_active,
|
||||
"created_at": s.created_at.isoformat() if s.created_at else None,
|
||||
}
|
||||
for s in sources
|
||||
],
|
||||
"total": len(sources),
|
||||
}
|
||||
Reference in New Issue
Block a user