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