Some checks failed
Tests / Go Tests (push) Has been cancelled
Tests / Python Tests (push) Has been cancelled
Tests / Integration Tests (push) Has been cancelled
Tests / Go Lint (push) Has been cancelled
Tests / Python Lint (push) Has been cancelled
Tests / Security Scan (push) Has been cancelled
Tests / All Checks Passed (push) Has been cancelled
Security Scanning / Secret Scanning (push) Has been cancelled
Security Scanning / Dependency Vulnerability Scan (push) Has been cancelled
Security Scanning / Go Security Scan (push) Has been cancelled
Security Scanning / Python Security Scan (push) Has been cancelled
Security Scanning / Node.js Security Scan (push) Has been cancelled
Security Scanning / Docker Image Security (push) Has been cancelled
Security Scanning / Security Summary (push) Has been cancelled
CI/CD Pipeline / Go Tests (push) Has been cancelled
CI/CD Pipeline / Python Tests (push) Has been cancelled
CI/CD Pipeline / Website Tests (push) Has been cancelled
CI/CD Pipeline / Linting (push) Has been cancelled
CI/CD Pipeline / Security Scan (push) Has been cancelled
CI/CD Pipeline / Docker Build & Push (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Deploy to Staging (push) Has been cancelled
CI/CD Pipeline / Deploy to Production (push) Has been cancelled
CI/CD Pipeline / CI Summary (push) Has been cancelled
ci/woodpecker/manual/build-ci-image Pipeline was successful
ci/woodpecker/manual/main Pipeline failed
All services: admin-v2, studio-v2, website, ai-compliance-sdk, consent-service, klausur-service, voice-service, and infrastructure. Large PDFs and compiled binaries excluded via .gitignore.
552 lines
16 KiB
Python
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}")
|