This repository has been archived on 2026-02-15. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
breakpilot-pwa/backend/alerts_agent/api/digests.py
Benjamin Admin 21a844cb8a 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>
2026-02-09 09:51:32 +01:00

552 lines
16 KiB
Python

"""
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}")