Fix Bestätigung email: send synchronously for immediate error feedback (STI-77)
Some checks failed
CI/CD Pipeline / test (push) Has been cancelled
CI/CD Pipeline / deploy (push) Has been cancelled
Code Quality / quality (push) Has been cancelled

The Bestätigung email was sent via Celery task (fire-and-forget), so the UI
always showed "wird gesendet" even when the task failed silently in the worker.
Now sends synchronously from the web process (matching the working test email
pattern) with proper error display to the user.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
SysAdmin Agent
2026-03-21 21:48:30 +00:00
parent d7992558ee
commit b8fb35db7a
2 changed files with 40 additions and 21 deletions

View File

@@ -702,19 +702,16 @@ def send_onboarding_einladung(self, einladung_id, base_url=None):
raise self.retry(exc=exc) raise self.retry(exc=exc)
@shared_task(bind=True, max_retries=3, default_retry_delay=300) def _send_bestaetigung_sync(destinataer_id):
def send_bestaetigung(self, destinataer_id, base_url=None):
""" """
Generiert ein Bestätigungsschreiben (PDF) für einen Destinatär und sendet es Generiert ein Bestätigungsschreiben (PDF) für einen Destinatär und sendet es
per E-Mail. Das PDF wird zusätzlich im DMS unter Kontext "korrespondenz" abgelegt. per E-Mail. Das PDF wird zusätzlich im DMS unter Kontext "korrespondenz" abgelegt.
Args: Kann direkt (synchron) oder via Celery-Task aufgerufen werden.
destinataer_id: UUID des Destinatärs Bei Fehlern wird eine Exception geworfen (kein stilles Verschlucken).
base_url: Basis-URL der Anwendung (für Konsistenz mit anderen Tasks)
""" """
from decimal import Decimal from decimal import Decimal
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.template.loader import render_to_string
from django.core.mail import EmailMultiAlternatives from django.core.mail import EmailMultiAlternatives
from django.utils import timezone from django.utils import timezone
@@ -772,7 +769,7 @@ def send_bestaetigung(self, destinataer_id, base_url=None):
pdf_bytes = HTML(string=html_content).write_pdf() pdf_bytes = HTML(string=html_content).write_pdf()
except Exception as exc: except Exception as exc:
logger.error("send_bestaetigung: PDF-Generierung fehlgeschlagen: %s", exc) logger.error("send_bestaetigung: PDF-Generierung fehlgeschlagen: %s", exc)
raise self.retry(exc=exc) raise
# PDF im DMS ablegen # PDF im DMS ablegen
filename = ( filename = (
@@ -803,17 +800,23 @@ def send_bestaetigung(self, destinataer_id, base_url=None):
from_email = _get_smtp_from_email() from_email = _get_smtp_from_email()
to_email = destinataer.email to_email = destinataer.email
connection = _get_smtp_connection()
msg = EmailMultiAlternatives(subject, "", from_email, [to_email], connection=connection)
msg.attach_alternative(html_body, "text/html")
if pdf_bytes:
msg.attach(filename, pdf_bytes, "application/pdf")
msg.send()
logger.info("Bestätigung gesendet an %s (Destinatär %s)", to_email, destinataer_id)
return {"status": "sent", "destinataer_id": str(destinataer_id), "email": to_email}
@shared_task(bind=True, max_retries=3, default_retry_delay=300)
def send_bestaetigung(self, destinataer_id, base_url=None):
"""Celery-Wrapper für _send_bestaetigung_sync (für asynchronen Aufruf)."""
try: try:
connection = _get_smtp_connection() return _send_bestaetigung_sync(destinataer_id)
msg = EmailMultiAlternatives(subject, "", from_email, [to_email], connection=connection)
msg.attach_alternative(html_body, "text/html")
if pdf_bytes:
msg.attach(filename, pdf_bytes, "application/pdf")
msg.send()
logger.info("Bestätigung gesendet an %s (Destinatär %s)", to_email, destinataer_id)
return {"status": "sent", "destinataer_id": str(destinataer_id), "email": to_email}
except Exception as exc: except Exception as exc:
logger.exception("send_bestaetigung: E-Mail-Versand fehlgeschlagen für %s: %s", to_email, exc) logger.exception("send_bestaetigung task fehlgeschlagen: %s", exc)
raise self.retry(exc=exc) raise self.retry(exc=exc)

View File

@@ -827,9 +827,9 @@ def bestaetigung_vorschau(request, pk):
def bestaetigung_versenden(request, pk): def bestaetigung_versenden(request, pk):
""" """
Sendet das Bestätigungsschreiben per E-Mail an den Destinatär. Sendet das Bestätigungsschreiben per E-Mail an den Destinatär.
POST-only (CSRF-geschützt). Startet asynchronen Celery-Task. POST-only (CSRF-geschützt). Sendet synchron für direktes Feedback.
""" """
from stiftung.tasks import send_bestaetigung from stiftung.tasks import _send_bestaetigung_sync
if request.method != "POST": if request.method != "POST":
return redirect("stiftung:destinataer_detail", pk=pk) return redirect("stiftung:destinataer_detail", pk=pk)
@@ -843,8 +843,24 @@ def bestaetigung_versenden(request, pk):
) )
return redirect("stiftung:destinataer_detail", pk=pk) return redirect("stiftung:destinataer_detail", pk=pk)
base_url = request.build_absolute_uri("/").rstrip("/") try:
send_bestaetigung.delay(str(destinataer.id), base_url=base_url) result = _send_bestaetigung_sync(str(destinataer.id))
except Exception as exc:
import logging
logging.getLogger(__name__).exception("Bestätigung versenden fehlgeschlagen: %s", exc)
messages.error(
request,
f"Bestätigungsschreiben konnte nicht gesendet werden: {exc}",
)
return redirect("stiftung:destinataer_detail", pk=pk)
if result and result.get("status") == "skipped":
messages.warning(request, "Versand übersprungen: Keine E-Mail-Adresse hinterlegt.")
return redirect("stiftung:destinataer_detail", pk=pk)
if result and result.get("status") == "error":
messages.error(request, f"Fehler: {result.get('message', 'Unbekannter Fehler')}")
return redirect("stiftung:destinataer_detail", pk=pk)
log_action( log_action(
request, request,
@@ -857,7 +873,7 @@ def bestaetigung_versenden(request, pk):
messages.success( messages.success(
request, request,
f"Bestätigungsschreiben wird per E-Mail an {destinataer.email} gesendet.", f"Bestätigungsschreiben wurde erfolgreich an {destinataer.email} gesendet.",
) )
return redirect("stiftung:destinataer_detail", pk=pk) return redirect("stiftung:destinataer_detail", pk=pk)