[split-required] Split final 43 files (500-668 LOC) to complete refactoring
klausur-service (11 files): - cv_gutter_repair, ocr_pipeline_regression, upload_api - ocr_pipeline_sessions, smart_spell, nru_worksheet_generator - ocr_pipeline_overlays, mail/aggregator, zeugnis_api - cv_syllable_detect, self_rag backend-lehrer (17 files): - classroom_engine/suggestions, generators/quiz_generator - worksheets_api, llm_gateway/comparison, state_engine_api - classroom/models (→ 4 submodules), services/file_processor - alerts_agent/api/wizard+digests+routes, content_generators/pdf - classroom/routes/sessions, llm_gateway/inference - classroom_engine/analytics, auth/keycloak_auth - alerts_agent/processing/rule_engine, ai_processor/print_versions agent-core (5 files): - brain/memory_store, brain/knowledge_graph, brain/context_manager - orchestrator/supervisor, sessions/session_manager admin-lehrer (5 components): - GridOverlay, StepGridReview, DevOpsPipelineSidebar - DataFlowDiagram, sbom/wizard/page website (2 files): - DependencyMap, lehrer/abitur-archiv Other: nibis_ingestion, grid_detection_service, export-doclayout-onnx Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -9,13 +9,10 @@ Endpoints:
|
||||
- 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 import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from ..db.database import get_db
|
||||
@@ -23,126 +20,27 @@ from ..db.models import (
|
||||
AlertDigestDB, UserAlertSubscriptionDB, DigestStatusEnum
|
||||
)
|
||||
from ..processing.digest_generator import DigestGenerator
|
||||
from .digests_models import (
|
||||
DigestDetail,
|
||||
DigestListResponse,
|
||||
GenerateDigestRequest,
|
||||
GenerateDigestResponse,
|
||||
SendEmailRequest,
|
||||
SendEmailResponse,
|
||||
digest_to_list_item,
|
||||
digest_to_detail,
|
||||
)
|
||||
from .digests_email import generate_pdf_from_html, send_digest_by_email
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
"""Extrahiert User-ID aus Request. TODO: JWT-Token auswerten."""
|
||||
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
|
||||
# ============================================================================
|
||||
@@ -153,11 +51,7 @@ async def list_digests(
|
||||
offset: int = Query(0, ge=0),
|
||||
db: DBSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Liste alle Digests des aktuellen Users.
|
||||
|
||||
Sortiert nach Erstellungsdatum (neueste zuerst).
|
||||
"""
|
||||
"""Liste alle Digests des aktuellen Users."""
|
||||
user_id = get_user_id_from_request()
|
||||
|
||||
query = db.query(AlertDigestDB).filter(
|
||||
@@ -168,18 +62,14 @@ async def list_digests(
|
||||
digests = query.offset(offset).limit(limit).all()
|
||||
|
||||
return DigestListResponse(
|
||||
digests=[_digest_to_list_item(d) for d in digests],
|
||||
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.
|
||||
"""
|
||||
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(
|
||||
@@ -189,17 +79,12 @@ async def get_latest_digest(
|
||||
if not digest:
|
||||
raise HTTPException(status_code=404, detail="Kein Digest vorhanden")
|
||||
|
||||
return _digest_to_detail(digest)
|
||||
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.
|
||||
"""
|
||||
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(
|
||||
@@ -210,17 +95,12 @@ async def get_digest(
|
||||
if not digest:
|
||||
raise HTTPException(status_code=404, detail="Digest nicht gefunden")
|
||||
|
||||
return _digest_to_detail(digest)
|
||||
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.
|
||||
"""
|
||||
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(
|
||||
@@ -230,35 +110,26 @@ async def get_digest_pdf(
|
||||
|
||||
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}"
|
||||
}
|
||||
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.
|
||||
"""
|
||||
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(
|
||||
@@ -267,11 +138,9 @@ async def get_latest_digest_pdf(
|
||||
|
||||
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:
|
||||
@@ -282,9 +151,7 @@ async def get_latest_digest_pdf(
|
||||
return StreamingResponse(
|
||||
io.BytesIO(pdf_bytes),
|
||||
media_type="application/pdf",
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename={filename}"
|
||||
}
|
||||
headers={"Content-Disposition": f"attachment; filename={filename}"}
|
||||
)
|
||||
|
||||
|
||||
@@ -293,16 +160,10 @@ 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.
|
||||
"""
|
||||
"""Generiere einen neuen Digest manuell."""
|
||||
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)
|
||||
@@ -315,12 +176,10 @@ async def generate_digest(
|
||||
|
||||
if existing and not (request and request.force_regenerate):
|
||||
return GenerateDigestResponse(
|
||||
status="exists",
|
||||
digest_id=existing.id,
|
||||
status="exists", digest_id=existing.id,
|
||||
message="Digest fuer diesen Zeitraum existiert bereits"
|
||||
)
|
||||
|
||||
# Generiere neuen Digest
|
||||
generator = DigestGenerator(db)
|
||||
|
||||
try:
|
||||
@@ -328,14 +187,12 @@ async def generate_digest(
|
||||
|
||||
if digest:
|
||||
return GenerateDigestResponse(
|
||||
status="success",
|
||||
digest_id=digest.id,
|
||||
status="success", digest_id=digest.id,
|
||||
message="Digest erfolgreich generiert"
|
||||
)
|
||||
else:
|
||||
return GenerateDigestResponse(
|
||||
status="empty",
|
||||
digest_id=None,
|
||||
status="empty", digest_id=None,
|
||||
message="Keine Alerts fuer diesen Zeitraum vorhanden"
|
||||
)
|
||||
except Exception as e:
|
||||
@@ -348,9 +205,7 @@ async def send_digest_email(
|
||||
request: SendEmailRequest = None,
|
||||
db: DBSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Versende Digest per E-Mail.
|
||||
"""
|
||||
"""Versende Digest per E-Mail."""
|
||||
user_id = get_user_id_from_request()
|
||||
|
||||
digest = db.query(AlertDigestDB).filter(
|
||||
@@ -361,12 +216,10 @@ async def send_digest_email(
|
||||
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()
|
||||
@@ -376,176 +229,18 @@ async def send_digest_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,
|
||||
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}")
|
||||
|
||||
146
backend-lehrer/alerts_agent/api/digests_email.py
Normal file
146
backend-lehrer/alerts_agent/api/digests_email.py
Normal file
@@ -0,0 +1,146 @@
|
||||
"""
|
||||
Alert Digests - PDF-Generierung und E-Mail-Versand.
|
||||
"""
|
||||
|
||||
import io
|
||||
import logging
|
||||
|
||||
from ..db.models import AlertDigestDB
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def generate_pdf_from_html(html_content: str) -> bytes:
|
||||
"""
|
||||
Generiere PDF aus HTML.
|
||||
|
||||
Verwendet WeasyPrint oder wkhtmltopdf als Fallback.
|
||||
"""
|
||||
try:
|
||||
from weasyprint import HTML
|
||||
pdf_bytes = HTML(string=html_content).write_pdf()
|
||||
return pdf_bytes
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
import pdfkit
|
||||
pdf_bytes = pdfkit.from_string(html_content, False)
|
||||
return pdf_bytes
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
from xhtml2pdf import pisa
|
||||
result = io.BytesIO()
|
||||
pisa.CreatePDF(io.StringIO(html_content), dest=result)
|
||||
return result.getvalue()
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
raise ImportError(
|
||||
"Keine PDF-Bibliothek verfuegbar. "
|
||||
"Installieren Sie: pip install weasyprint oder pip install pdfkit oder pip install xhtml2pdf"
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
|
||||
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_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'))
|
||||
|
||||
if digest.summary_html:
|
||||
msg.attach(MIMEText(digest.summary_html, 'html', 'utf-8'))
|
||||
|
||||
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
|
||||
|
||||
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:
|
||||
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:
|
||||
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}")
|
||||
116
backend-lehrer/alerts_agent/api/digests_models.py
Normal file
116
backend-lehrer/alerts_agent/api/digests_models.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""
|
||||
Alert Digests - Request/Response Models und Konverter.
|
||||
"""
|
||||
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ..db.models import AlertDigestDB
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 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)")
|
||||
|
||||
|
||||
class SendEmailResponse(BaseModel):
|
||||
"""Response fuer E-Mail-Versand."""
|
||||
status: str
|
||||
sent_to: str
|
||||
message: str
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Converter Functions
|
||||
# ============================================================================
|
||||
|
||||
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
|
||||
)
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
API Routes für Alerts Agent.
|
||||
API Routes fuer Alerts Agent.
|
||||
|
||||
Endpoints:
|
||||
- POST /alerts/ingest - Manuell Alerts importieren
|
||||
@@ -13,12 +13,18 @@ Endpoints:
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel, Field
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
|
||||
from ..models.alert_item import AlertItem, AlertStatus
|
||||
from ..models.relevance_profile import RelevanceProfile, PriorityItem
|
||||
from ..processing.relevance_scorer import RelevanceDecision, RelevanceScorer
|
||||
from .schemas import (
|
||||
AlertIngestRequest, AlertIngestResponse,
|
||||
AlertRunRequest, AlertRunResponse,
|
||||
InboxItem, InboxResponse,
|
||||
FeedbackRequest, FeedbackResponse,
|
||||
ProfilePriorityRequest, ProfileUpdateRequest, ProfileResponse,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter(prefix="/alerts", tags=["alerts"])
|
||||
@@ -30,113 +36,13 @@ ALERTS_USE_LLM = os.getenv("ALERTS_USE_LLM", "false").lower() == "true"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# In-Memory Storage (später durch DB ersetzen)
|
||||
# In-Memory Storage (spaeter 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
|
||||
# ============================================================================
|
||||
@@ -146,7 +52,7 @@ async def ingest_alert(request: AlertIngestRequest):
|
||||
"""
|
||||
Manuell einen Alert importieren.
|
||||
|
||||
Nützlich für Tests oder manuelles Hinzufügen von Artikeln.
|
||||
Nuetzlich fuer Tests oder manuelles Hinzufuegen von Artikeln.
|
||||
"""
|
||||
alert = AlertItem(
|
||||
title=request.title,
|
||||
@@ -168,13 +74,13 @@ async def ingest_alert(request: AlertIngestRequest):
|
||||
@router.post("/run", response_model=AlertRunResponse)
|
||||
async def run_scoring_pipeline(request: AlertRunRequest):
|
||||
"""
|
||||
Scoring-Pipeline für neue Alerts starten.
|
||||
Scoring-Pipeline fuer 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.
|
||||
Wenn ALERTS_USE_LLM=true, wird das LLM Gateway fuer Scoring verwendet.
|
||||
Sonst wird ein schnelles Keyword-basiertes Scoring durchgefuehrt.
|
||||
"""
|
||||
import time
|
||||
start = time.time()
|
||||
@@ -193,7 +99,7 @@ async def run_scoring_pipeline(request: AlertRunRequest):
|
||||
|
||||
keep = drop = review = errors = 0
|
||||
|
||||
# Profil für Scoring laden
|
||||
# Profil fuer Scoring laden
|
||||
profile = _profile_store.get("default")
|
||||
if not profile:
|
||||
profile = RelevanceProfile.create_default_education_profile()
|
||||
@@ -201,7 +107,7 @@ async def run_scoring_pipeline(request: AlertRunRequest):
|
||||
_profile_store["default"] = profile
|
||||
|
||||
if ALERTS_USE_LLM and LLM_API_KEY:
|
||||
# LLM-basiertes Scoring über Gateway
|
||||
# LLM-basiertes Scoring ueber Gateway
|
||||
scorer = RelevanceScorer(
|
||||
gateway_url=LLM_GATEWAY_URL,
|
||||
api_key=LLM_API_KEY,
|
||||
@@ -227,12 +133,12 @@ async def run_scoring_pipeline(request: AlertRunRequest):
|
||||
snippet_lower = (alert.snippet or "").lower()
|
||||
combined = title_lower + " " + snippet_lower
|
||||
|
||||
# Ausschlüsse aus Profil prüfen
|
||||
# Ausschluesse aus Profil pruefen
|
||||
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
|
||||
# Prioritaeten aus Profil pruefen
|
||||
elif any(
|
||||
p.label.lower() in combined or
|
||||
any(kw.lower() in combined for kw in (p.keywords if hasattr(p, 'keywords') else []))
|
||||
@@ -285,9 +191,9 @@ async def get_inbox(
|
||||
|
||||
# Pagination
|
||||
total = len(alerts)
|
||||
start = (page - 1) * page_size
|
||||
end = start + page_size
|
||||
page_alerts = alerts[start:end]
|
||||
start_idx = (page - 1) * page_size
|
||||
end_idx = start_idx + page_size
|
||||
page_alerts = alerts[start_idx:end_idx]
|
||||
|
||||
items = [
|
||||
InboxItem(
|
||||
@@ -327,7 +233,7 @@ async def submit_feedback(request: FeedbackRequest):
|
||||
# Alert Status aktualisieren
|
||||
alert.status = AlertStatus.REVIEWED
|
||||
|
||||
# Profile aktualisieren (Default-Profile für Demo)
|
||||
# Profile aktualisieren (Default-Profile fuer Demo)
|
||||
profile = _profile_store.get("default")
|
||||
if not profile:
|
||||
profile = RelevanceProfile.create_default_education_profile()
|
||||
@@ -353,7 +259,7 @@ async def get_profile(user_id: Optional[str] = Query(default=None)):
|
||||
"""
|
||||
Relevanz-Profil abrufen.
|
||||
|
||||
Ohne user_id wird das Default-Profil zurückgegeben.
|
||||
Ohne user_id wird das Default-Profil zurueckgegeben.
|
||||
"""
|
||||
profile_id = user_id or "default"
|
||||
profile = _profile_store.get(profile_id)
|
||||
@@ -385,7 +291,7 @@ async def update_profile(
|
||||
"""
|
||||
Relevanz-Profil aktualisieren.
|
||||
|
||||
Erlaubt Anpassung von Prioritäten, Ausschlüssen und Policies.
|
||||
Erlaubt Anpassung von Prioritaeten, Ausschluessen und Policies.
|
||||
"""
|
||||
profile_id = user_id or "default"
|
||||
profile = _profile_store.get(profile_id)
|
||||
@@ -431,34 +337,24 @@ async def update_profile(
|
||||
@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
|
||||
Statistiken ueber Alerts und Scoring.
|
||||
"""
|
||||
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:
|
||||
@@ -478,15 +374,12 @@ async def get_stats():
|
||||
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,
|
||||
@@ -496,7 +389,6 @@ async def get_stats():
|
||||
"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),
|
||||
|
||||
111
backend-lehrer/alerts_agent/api/schemas.py
Normal file
111
backend-lehrer/alerts_agent/api/schemas.py
Normal file
@@ -0,0 +1,111 @@
|
||||
"""
|
||||
Request/Response Schemas fuer Alerts Agent API.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Request Models
|
||||
# ============================================================================
|
||||
|
||||
class AlertIngestRequest(BaseModel):
|
||||
"""Request fuer 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 AlertRunRequest(BaseModel):
|
||||
"""Request fuer Scoring-Pipeline."""
|
||||
limit: int = Field(default=50, ge=1, le=200)
|
||||
skip_scored: bool = Field(default=True)
|
||||
|
||||
|
||||
class FeedbackRequest(BaseModel):
|
||||
"""Request fuer Relevanz-Feedback."""
|
||||
alert_id: str
|
||||
is_relevant: bool
|
||||
reason: Optional[str] = None
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ProfilePriorityRequest(BaseModel):
|
||||
"""Priority fuer 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 fuer Profile-Update."""
|
||||
priorities: Optional[list[ProfilePriorityRequest]] = None
|
||||
exclusions: Optional[list[str]] = None
|
||||
policies: Optional[dict] = None
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Response Models
|
||||
# ============================================================================
|
||||
|
||||
class AlertIngestResponse(BaseModel):
|
||||
"""Response fuer Alert-Import."""
|
||||
id: str
|
||||
status: str
|
||||
message: str
|
||||
|
||||
|
||||
class AlertRunResponse(BaseModel):
|
||||
"""Response fuer 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 fuer Inbox-Abfrage."""
|
||||
items: list[InboxItem]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
|
||||
|
||||
class FeedbackResponse(BaseModel):
|
||||
"""Response fuer Feedback."""
|
||||
success: bool
|
||||
message: str
|
||||
profile_updated: bool
|
||||
|
||||
|
||||
class ProfileResponse(BaseModel):
|
||||
"""Response fuer Profile."""
|
||||
id: str
|
||||
priorities: list[dict]
|
||||
exclusions: list[str]
|
||||
policies: dict
|
||||
total_scored: int
|
||||
total_kept: int
|
||||
total_dropped: int
|
||||
accuracy_estimate: Optional[float]
|
||||
@@ -7,21 +7,12 @@ Verwaltet den 3-Schritt Setup-Wizard:
|
||||
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 typing import List, Dict, Any
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel, Field
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from ..db.database import get_db
|
||||
@@ -29,77 +20,22 @@ from ..db.models import (
|
||||
UserAlertSubscriptionDB, AlertTemplateDB, AlertSourceDB,
|
||||
AlertModeEnum, UserRoleEnum, MigrationModeEnum, FeedTypeEnum
|
||||
)
|
||||
from .wizard_models import (
|
||||
WizardState,
|
||||
Step1Data,
|
||||
Step2Data,
|
||||
Step3Data,
|
||||
StepResponse,
|
||||
MigrateEmailRequest,
|
||||
MigrateEmailResponse,
|
||||
MigrateRssRequest,
|
||||
MigrateRssResponse,
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
# ============================================================================
|
||||
@@ -144,13 +80,9 @@ def _get_recommended_templates(db: DBSession, role: str) -> List[Dict[str, Any]]
|
||||
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,
|
||||
"id": t.id, "slug": t.slug, "name": t.name,
|
||||
"description": t.description, "icon": t.icon,
|
||||
"category": t.category, "recommended": True,
|
||||
})
|
||||
|
||||
return result
|
||||
@@ -167,14 +99,8 @@ def _generate_inbound_address(user_id: str, source_id: str) -> str:
|
||||
# ============================================================================
|
||||
|
||||
@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.
|
||||
"""
|
||||
async def get_wizard_state(db: DBSession = Depends(get_db)):
|
||||
"""Hole aktuellen Wizard-Status."""
|
||||
user_id = get_user_id_from_request()
|
||||
|
||||
subscription = db.query(UserAlertSubscriptionDB).filter(
|
||||
@@ -182,15 +108,8 @@ async def get_wizard_state(
|
||||
).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=[],
|
||||
)
|
||||
return WizardState()
|
||||
|
||||
# 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 []
|
||||
|
||||
@@ -204,61 +123,37 @@ async def get_wizard_state(
|
||||
|
||||
|
||||
@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.
|
||||
"""
|
||||
async def save_step_1(data: Step1Data, db: DBSession = Depends(get_db)):
|
||||
"""Schritt 1: Rolle speichern."""
|
||||
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'"
|
||||
)
|
||||
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,
|
||||
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).
|
||||
"""
|
||||
async def save_step_2(data: Step2Data, db: DBSession = Depends(get_db)):
|
||||
"""Schritt 2: Templates auswählen."""
|
||||
user_id = get_user_id_from_request()
|
||||
|
||||
subscription = db.query(UserAlertSubscriptionDB).filter(
|
||||
@@ -269,46 +164,28 @@ async def save_step_2(
|
||||
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()
|
||||
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],
|
||||
}
|
||||
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,
|
||||
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.
|
||||
"""
|
||||
async def save_step_3(data: Step3Data, db: DBSession = Depends(get_db)):
|
||||
"""Schritt 3: Digest-Einstellungen und Bestätigung."""
|
||||
user_id = get_user_id_from_request()
|
||||
|
||||
subscription = db.query(UserAlertSubscriptionDB).filter(
|
||||
@@ -318,16 +195,13 @@ async def save_step_3(
|
||||
|
||||
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,
|
||||
@@ -336,27 +210,17 @@ async def save_step_3(
|
||||
}
|
||||
subscription.wizard_state = wizard_state
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
|
||||
db.commit()
|
||||
|
||||
return StepResponse(
|
||||
status="success",
|
||||
current_step=3,
|
||||
next_step=4,
|
||||
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.
|
||||
"""
|
||||
async def complete_wizard(db: DBSession = Depends(get_db)):
|
||||
"""Wizard abschließen und Templates aktivieren."""
|
||||
user_id = get_user_id_from_request()
|
||||
|
||||
subscription = db.query(UserAlertSubscriptionDB).filter(
|
||||
@@ -366,18 +230,14 @@ async def complete_wizard(
|
||||
|
||||
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 {
|
||||
@@ -390,9 +250,7 @@ async def complete_wizard(
|
||||
|
||||
|
||||
@router.post("/reset")
|
||||
async def reset_wizard(
|
||||
db: DBSession = Depends(get_db)
|
||||
):
|
||||
async def reset_wizard(db: DBSession = Depends(get_db)):
|
||||
"""Wizard zurücksetzen (für Neustart)."""
|
||||
user_id = get_user_id_from_request()
|
||||
|
||||
@@ -405,10 +263,7 @@ async def reset_wizard(
|
||||
db.delete(subscription)
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Wizard zurückgesetzt. Sie können neu beginnen.",
|
||||
}
|
||||
return {"status": "success", "message": "Wizard zurückgesetzt. Sie können neu beginnen."}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
@@ -416,29 +271,16 @@ async def reset_wizard(
|
||||
# ============================================================================
|
||||
|
||||
@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.
|
||||
"""
|
||||
async def start_email_migration(request: MigrateEmailRequest = None, db: DBSession = Depends(get_db)):
|
||||
"""Starte E-Mail-Migration für bestehende Google Alerts."""
|
||||
user_id = get_user_id_from_request()
|
||||
|
||||
# Erstelle AlertSource
|
||||
source = AlertSourceDB(
|
||||
id=str(uuid.uuid4()),
|
||||
user_id=user_id,
|
||||
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,
|
||||
migration_mode=MigrationModeEnum.FORWARD, is_active=True,
|
||||
)
|
||||
|
||||
# Generiere Inbound-Adresse
|
||||
source.inbound_address = _generate_inbound_address(user_id, source.id)
|
||||
|
||||
db.add(source)
|
||||
@@ -446,9 +288,7 @@ async def start_email_migration(
|
||||
db.refresh(source)
|
||||
|
||||
return MigrateEmailResponse(
|
||||
status="success",
|
||||
inbound_address=source.inbound_address,
|
||||
source_id=source.id,
|
||||
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",
|
||||
@@ -460,74 +300,49 @@ async def start_email_migration(
|
||||
|
||||
|
||||
@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.
|
||||
"""
|
||||
async def import_rss_feeds(request: MigrateRssRequest, db: DBSession = Depends(get_db)):
|
||||
"""Importiere bestehende Google Alert RSS-Feeds."""
|
||||
user_id = get_user_id_from_request()
|
||||
|
||||
from ..db.models import AlertTopicDB
|
||||
|
||||
sources_created = 0
|
||||
topics_created = 0
|
||||
sources_created, topics_created = 0, 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,
|
||||
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,
|
||||
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,
|
||||
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)
|
||||
):
|
||||
async def list_migration_sources(db: DBSession = Depends(get_db)):
|
||||
"""Liste alle Migration-Quellen des Users."""
|
||||
user_id = get_user_id_from_request()
|
||||
|
||||
|
||||
68
backend-lehrer/alerts_agent/api/wizard_models.py
Normal file
68
backend-lehrer/alerts_agent/api/wizard_models.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""
|
||||
Wizard API - Request/Response Models.
|
||||
"""
|
||||
|
||||
from typing import Optional, List, Dict, Any
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user