Files
stiftung-management-system/app/stiftung/tasks.py
SysAdmin Agent aed540fe4b
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
Add Vorlagen editor, upload portal, onboarding, and participant import command
- Dokument-Vorlagen-Editor: create/edit/reset document templates (admin)
- Upload-Portal: public portal for Nachweis uploads via token
- Onboarding: invite Destinatäre via email with multi-step wizard
- Bestätigungsschreiben: preview and send confirmation letters
- Email settings: SMTP configuration UI
- Management command: import_veranstaltung_teilnehmer for bulk participant import

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 09:25:18 +00:00

845 lines
32 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Celery-Tasks fuer die automatische Verarbeitung eingehender E-Mails.
Workflow:
1. `poll_emails` laeuft alle 15 Minuten (Celery Beat)
2. Er liest ungelesene E-Mails aus dem IMAP-Postfach
3. Fuer jede E-Mail:
a) Absender wird mit Destinataer-Datenbank abgeglichen (E-Mail-Feld)
b) Betreff/Body wird auf Rechnungs-Keywords geprueft
c) Ein EmailEingang-Datensatz wird angelegt (mit Kategorie)
d) Alle Anhaenge werden als DokumentDatei im Django-DMS gespeichert
4. Nicht erkannte Absender werden als "unbekannt" markiert (manuelle Nachbearbeitung)
Konfiguration (Umgebungsvariablen in .env / compose.yml):
IMAP_HOST — IMAP-Server-Adresse (z. B. mail.vhtv-stiftung.de)
IMAP_PORT — Port (Standard: 993 fuer SSL)
IMAP_USER — Benutzername
IMAP_PASSWORD — Passwort
IMAP_FOLDER — Ordner (Standard: INBOX)
"""
import email
import email.utils
import imaplib
import logging
import mimetypes
import re
from datetime import datetime, timezone as dt_timezone
from email.header import decode_header, make_header
from celery import shared_task
from django.utils import timezone
logger = logging.getLogger(__name__)
# Patterns fuer Rechnungserkennung im Betreff/Body
RECHNUNG_PATTERNS = [
re.compile(r"\brechnung\b", re.IGNORECASE),
re.compile(r"\binvoice\b", re.IGNORECASE),
re.compile(r"\brechnungs[-\s]?nr\.?\s*[:\s]?\s*\d+", re.IGNORECASE),
re.compile(r"\bRE[-/]\d{4,}", re.IGNORECASE), # RE-2024001, RE/20240315
]
GESCHICHTE_PATTERNS = [
re.compile(r"\bstiftungsgeschichte\b", re.IGNORECASE),
re.compile(r"\bahnenforschung\b", re.IGNORECASE),
re.compile(r"\bgenealogie\b", re.IGNORECASE),
re.compile(r"\bstammbaum\b", re.IGNORECASE),
re.compile(r"\bhistorisch", re.IGNORECASE),
re.compile(r"\bchronik\b", re.IGNORECASE),
re.compile(r"\barchiv\b", re.IGNORECASE),
re.compile(r"\bfamiliengeschichte\b", re.IGNORECASE),
re.compile(r"\burkunde\b", re.IGNORECASE),
]
# ---------------------------------------------------------------------------
# Hilfsfunktionen
# ---------------------------------------------------------------------------
def _decode_header_value(raw_value: str) -> str:
"""Dekodiert kodierte E-Mail-Header (z. B. UTF-8 oder Latin-1)."""
if not raw_value:
return ""
try:
return str(make_header(decode_header(raw_value)))
except Exception:
return raw_value
def _parse_email_date(date_str: str) -> datetime:
"""Parst das E-Mail-Datum und gibt ein timezone-aware datetime zurueck."""
try:
parsed = email.utils.parsedate_to_datetime(date_str)
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=dt_timezone.utc)
return parsed
except Exception:
return timezone.now()
def _get_email_body(msg) -> str:
"""Extrahiert den Text-Body aus einer E-Mail (bevorzugt plain text)."""
body_parts = []
if msg.is_multipart():
for part in msg.walk():
ctype = part.get_content_type()
disposition = str(part.get_content_disposition() or "")
if ctype == "text/plain" and "attachment" not in disposition:
charset = part.get_content_charset() or "utf-8"
try:
body_parts.append(part.get_payload(decode=True).decode(charset, errors="replace"))
except Exception:
pass
else:
charset = msg.get_content_charset() or "utf-8"
try:
body_parts.append(msg.get_payload(decode=True).decode(charset, errors="replace"))
except Exception:
pass
return "\n".join(body_parts).strip()
def _detect_kategorie(betreff: str, email_text: str, has_destinataer: bool) -> str:
"""
Erkennt die Kategorie einer Email anhand von Betreff und Body.
Gibt 'destinataer', 'rechnung', 'stiftungsgeschichte', oder 'allgemein' zurueck.
"""
if has_destinataer:
return "destinataer"
text_to_check = f"{betreff}\n{email_text[:2000]}"
# Rechnungserkennung via Patterns
for pattern in RECHNUNG_PATTERNS:
if pattern.search(text_to_check):
return "rechnung"
# Stiftungsgeschichte-Erkennung
for pattern in GESCHICHTE_PATTERNS:
if pattern.search(text_to_check):
return "stiftungsgeschichte"
return "allgemein"
def _save_to_dms(content: bytes, filename: str, destinataer=None, betreff: str = "", kontext: str = "korrespondenz"):
"""
Speichert einen E-Mail-Anhang direkt als DokumentDatei im Django-DMS.
Gibt das DokumentDatei-Objekt zurueck, oder None bei Fehler.
"""
from stiftung.models import DokumentDatei
from django.core.files.base import ContentFile
safe_filename = filename or "anhang.bin"
mime_type, _ = mimetypes.guess_type(safe_filename)
mime_type = mime_type or "application/octet-stream"
titel = f"{betreff[:100]} {safe_filename}" if betreff else safe_filename
beschreibung = ""
if destinataer:
beschreibung = (
f"Automatisch importiert aus E-Mail-Eingang.\n"
f"Absender: {destinataer.vorname} {destinataer.nachname} <{destinataer.email}>"
)
try:
doc = DokumentDatei(
titel=titel[:255],
beschreibung=beschreibung,
kontext=kontext,
dateiname_original=safe_filename,
dateityp=mime_type,
dateigroesse=len(content),
destinataer=destinataer,
)
doc.datei.save(safe_filename, ContentFile(content), save=False)
doc.save()
logger.info("Anhang '%s' als DokumentDatei gespeichert (ID: %s).", safe_filename, doc.pk)
return doc
except Exception as exc:
logger.error("Fehler beim Speichern von '%s' im DMS: %s", safe_filename, exc)
return None
# ---------------------------------------------------------------------------
# Haupttask
# ---------------------------------------------------------------------------
@shared_task(bind=True, max_retries=3, default_retry_delay=300, name="stiftung.tasks.poll_emails")
def poll_emails(self, search_all_recent_days=0):
"""
Liest E-Mails aus dem IMAP-Postfach und verarbeitet sie.
Wird durch Celery Beat alle 15 Minuten ausgefuehrt.
Erkennt automatisch Destinataer-Emails, Rechnungen und allgemeine Post.
Args:
search_all_recent_days: Wenn > 0, werden alle E-Mails der letzten N Tage
durchsucht (nicht nur ungelesene). Nuetzlich fuer manuellen Abruf.
"""
from stiftung.models import Destinataer, EmailEingang
# IMAP-Konfiguration: DB (AppConfiguration) mit Fallback auf env/settings
from stiftung.utils.config import get_config
imap_host = get_config("imap_host")
imap_port = int(get_config("imap_port", 993))
imap_user = get_config("imap_user")
imap_password = get_config("imap_password")
imap_folder = get_config("imap_folder", "INBOX")
imap_use_ssl = get_config("imap_use_ssl", True)
if not all([imap_host, imap_user, imap_password]):
logger.warning(
"IMAP nicht konfiguriert (IMAP_HOST, IMAP_USER, IMAP_PASSWORD fehlen). "
"Task wird uebersprungen."
)
return {"status": "skipped", "reason": "IMAP not configured"}
# Vorab: Destinataer-E-Mail-Index fuer schnelle Zuordnung
# Nur aktive Destinataere mit gesetzter E-Mail-Adresse
destinataer_by_email = {
d.email.lower(): d
for d in Destinataer.objects.filter(aktiv=True, email__isnull=False).exclude(email="")
}
processed = 0
errors = 0
try:
# IMAP-Verbindung aufbauen (mit Socket-Timeout fuer grosse E-Mails)
imap_timeout = 120 # Sekunden genug fuer grosse Anhaenge
if imap_use_ssl:
mail = imaplib.IMAP4_SSL(imap_host, imap_port, timeout=imap_timeout)
else:
mail = imaplib.IMAP4(imap_host, imap_port, timeout=imap_timeout)
mail.login(imap_user, imap_password)
mail.select(imap_folder)
# Nachrichten suchen
if search_all_recent_days and search_all_recent_days > 0:
from datetime import timedelta
since_date = (datetime.now(dt_timezone.utc) - timedelta(days=search_all_recent_days)).strftime("%d-%b-%Y")
_, message_ids_raw = mail.search(None, "SINCE", since_date)
search_mode = f"ALL seit {since_date}"
else:
_, message_ids_raw = mail.search(None, "UNSEEN")
search_mode = "UNSEEN"
message_ids = message_ids_raw[0].split()
logger.info("Postfach '%s' (%s): %d Nachricht(en) gefunden.", imap_folder, search_mode, len(message_ids))
for msg_id in message_ids:
try:
_, msg_data = mail.fetch(msg_id, "(RFC822)")
raw_email = msg_data[0][1]
msg = email.message_from_bytes(raw_email)
# Absender ermitteln
from_raw = msg.get("From", "")
absender_name_raw, absender_email_raw = email.utils.parseaddr(from_raw)
absender_email_addr = absender_email_raw.lower().strip()
absender_name = _decode_header_value(absender_name_raw)
# Betreff
betreff = _decode_header_value(msg.get("Subject", ""))
# Eingangsdatum
eingangsdatum = _parse_email_date(msg.get("Date", ""))
# E-Mail-Text
email_text = _get_email_body(msg)
# Destinataer zuordnen
destinataer = destinataer_by_email.get(absender_email_addr)
# Kategorie erkennen
kategorie = _detect_kategorie(betreff, email_text, has_destinataer=bool(destinataer))
# Status basierend auf Kategorie
if destinataer:
status = "zugewiesen"
elif kategorie == "rechnung":
status = "neu" # Muss manuell als Rechnung erfasst werden
else:
status = "unbekannt"
# DMS-Kontext fuer Anhaenge basierend auf Kategorie
dms_kontext_map = {
"rechnung": "rechnung",
"stiftungsgeschichte": "stiftungsgeschichte",
}
dms_kontext = dms_kontext_map.get(kategorie, "korrespondenz")
# Pruefen ob diese E-Mail bereits verarbeitet wurde (Duplikat-Check via
# Datum + Absender + Betreff)
already_exists = EmailEingang.objects.filter(
absender_email=absender_email_addr,
eingangsdatum=eingangsdatum,
betreff=betreff[:500],
).exists()
if already_exists:
logger.debug(
"E-Mail von %s am %s bereits vorhanden wird uebersprungen.",
absender_email_addr, eingangsdatum,
)
# Als gelesen markieren
mail.store(msg_id, "+FLAGS", "\\Seen")
continue
# Datensatz anlegen
eingang = EmailEingang(
kategorie=kategorie,
destinataer=destinataer,
absender_email=absender_email_addr,
absender_name=absender_name,
betreff=betreff[:500],
eingangsdatum=eingangsdatum,
email_text=email_text,
status=status,
)
# Anhaenge verarbeiten und als DokumentDatei im DMS speichern
dms_dokumente = []
if msg.is_multipart():
for part in msg.walk():
disposition = str(part.get_content_disposition() or "")
if "attachment" in disposition:
filename = _decode_header_value(part.get_filename() or "")
content = part.get_payload(decode=True)
if not content:
logger.warning(
"Anhang '%s' hat keinen Inhalt wird uebersprungen.",
filename,
)
continue
doc = _save_to_dms(
content=content,
filename=filename,
destinataer=destinataer,
betreff=betreff,
kontext=dms_kontext,
)
if doc:
dms_dokumente.append(doc)
# Cover-Email als eigenes DMS-Dokument speichern
email_body_doc = None
if email_text.strip():
email_filename = f"Email_{eingangsdatum.strftime('%Y%m%d_%H%M')}_{betreff[:50]}.txt"
# Bereinige Dateinamen
email_filename = re.sub(r'[^\w\s\-._]', '', email_filename)
anhang_count = len(dms_dokumente)
anhang_hinweis = (
f"\n\n--- Anhänge: {anhang_count} ---\n"
+ "\n".join(f"{d.dateiname_original or d.titel}" for d in dms_dokumente)
if dms_dokumente else ""
)
email_body_content = (
f"Von: {absender_name} <{absender_email_addr}>\n"
f"Datum: {eingangsdatum.strftime('%d.%m.%Y %H:%M')}\n"
f"Betreff: {betreff}\n"
f"{'=' * 60}\n\n"
f"{email_text}"
f"{anhang_hinweis}"
)
email_body_doc = _save_to_dms(
content=email_body_content.encode("utf-8"),
filename=email_filename,
destinataer=destinataer,
betreff=betreff,
kontext="email",
)
if email_body_doc:
# Beschreibung mit Anhang-Verweis ergaenzen
if dms_dokumente:
email_body_doc.beschreibung = (
f"E-Mail-Nachricht mit {anhang_count} Anhang/Anhängen.\n"
f"Absender: {absender_name} <{absender_email_addr}>"
)
else:
email_body_doc.beschreibung = (
f"E-Mail-Nachricht (ohne Anhänge).\n"
f"Absender: {absender_name} <{absender_email_addr}>"
)
email_body_doc.save(update_fields=["beschreibung"])
# Alle DMS-Dokumente (Email-Body + Anhaenge) verknuepfen
alle_dms_dokumente = []
if email_body_doc:
alle_dms_dokumente.append(email_body_doc)
alle_dms_dokumente.extend(dms_dokumente)
if dms_dokumente:
eingang.status = "verarbeitet" if destinataer else status
eingang.save()
if alle_dms_dokumente:
eingang.dokument_dateien.set(alle_dms_dokumente)
# Als gelesen markieren
mail.store(msg_id, "+FLAGS", "\\Seen")
processed += 1
logger.info(
"E-Mail verarbeitet: von=%s, Kategorie=%s, Destinataer=%s, Anhaenge=%d",
absender_email_addr,
kategorie,
str(destinataer) if destinataer else "",
len(dms_dokumente),
)
except Exception as exc:
errors += 1
logger.exception("Fehler bei Verarbeitung von Nachricht %s: %s", msg_id, exc)
# Nicht als gelesen markieren wird beim naechsten Lauf erneut versucht
mail.close()
mail.logout()
except imaplib.IMAP4.error as exc:
logger.error("IMAP-Fehler: %s", exc)
raise self.retry(exc=exc)
except Exception as exc:
logger.exception("Unerwarteter Fehler im poll_emails-Task: %s", exc)
raise self.retry(exc=exc)
result = {"status": "done", "processed": processed, "errors": errors}
logger.info("poll_emails abgeschlossen: %s", result)
return result
# Backward-compatible alias for existing Celery Beat schedules
poll_destinataer_emails = poll_emails
# =============================================================================
# SMTP-Ausgangs-Tasks: Nachweis-Aufforderungen und Token-Erinnerungen
# =============================================================================
import secrets # noqa: E402 (wird hier benötigt)
from datetime import timedelta # noqa: E402
def _get_smtp_connection():
"""
Erstellt eine Django-E-Mail-Verbindung mit SMTP-Einstellungen aus der DB.
"""
from django.core.mail import get_connection
from stiftung.utils.config import get_config
return get_connection(
backend="django.core.mail.backends.smtp.EmailBackend",
host=get_config("smtp_host", "smtp.ionos.de"),
port=int(get_config("smtp_port", 465)),
username=get_config("smtp_user", ""),
password=get_config("smtp_password", ""),
use_ssl=bool(get_config("smtp_use_ssl", True)),
use_tls=False,
fail_silently=False,
)
def _get_smtp_from_email():
"""Gibt die konfigurierte Absenderadresse zurück."""
from stiftung.utils.config import get_config
return get_config("smtp_from_email", "buero@vhtv-stiftung.de")
@shared_task(bind=True, max_retries=3, default_retry_delay=300)
def send_nachweis_aufforderung(self, destinataer_id, nachweis_id, base_url=None):
"""
Erstellt einen UploadToken und sendet eine Nachweis-Aufforderungs-E-Mail
mit Einmallink und QR-Code an den Destinatär.
Args:
destinataer_id: UUID des Destinatärs
nachweis_id: UUID des VierteljahresNachweises
base_url: Basis-URL der Anwendung (z.B. 'https://vhtv-stiftung.de')
"""
from django.conf import settings
from django.core.mail import EmailMultiAlternatives
from django.template.loader import render_to_string
from django.utils import timezone
import io
try:
import qrcode
from PIL import Image
import base64
qr_available = True
except ImportError:
qr_available = False
from stiftung.models import Destinataer, VierteljahresNachweis, UploadToken
try:
destinataer = Destinataer.objects.get(id=destinataer_id)
nachweis = VierteljahresNachweis.objects.get(id=nachweis_id)
except (Destinataer.DoesNotExist, VierteljahresNachweis.DoesNotExist) as exc:
logger.error("send_nachweis_aufforderung: Objekt nicht gefunden: %s", exc)
return {"status": "error", "message": str(exc)}
if not destinataer.email:
logger.warning(
"send_nachweis_aufforderung: Destinatär %s hat keine E-Mail-Adresse",
destinataer_id,
)
return {"status": "skipped", "reason": "no_email"}
# Bestehende aktive Tokens für diesen Nachweis deaktivieren
UploadToken.objects.filter(
destinataer=destinataer,
nachweis=nachweis,
ist_aktiv=True,
).update(ist_aktiv=False)
# Neuen Token erstellen
token_str = secrets.token_urlsafe(48)
gueltig_bis = timezone.now() + timedelta(days=30)
upload_token = UploadToken.objects.create(
token=token_str,
destinataer=destinataer,
nachweis=nachweis,
gueltig_bis=gueltig_bis,
)
if base_url is None:
base_url = getattr(settings, "SITE_URL", "https://vhtv-stiftung.de")
upload_url = f"{base_url}/portal/upload/{token_str}/"
# QR-Code generieren
qr_code_base64 = None
if qr_available:
try:
qr = qrcode.QRCode(
version=1,
error_correction=qrcode.constants.ERROR_CORRECT_M,
box_size=6,
border=4,
)
qr.add_data(upload_url)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
buffer = io.BytesIO()
img.save(buffer, format="PNG")
qr_code_base64 = base64.b64encode(buffer.getvalue()).decode()
except Exception as qr_exc:
logger.warning("QR-Code-Generierung fehlgeschlagen: %s", qr_exc)
# Halbjahr bestimmen (Q1+Q2 = 1. Halbjahr, Q3+Q4 = 2. Halbjahr)
halbjahr = 1 if nachweis.quartal in [1, 2] else 2
halbjahr_label = f"{halbjahr}. Halbjahr {nachweis.jahr}"
quartal_label = f"Q{nachweis.quartal} {nachweis.jahr}"
context = {
"destinataer": destinataer,
"nachweis": nachweis,
"upload_url": upload_url,
"qr_code_base64": qr_code_base64,
"gueltig_bis": gueltig_bis,
"halbjahr_label": halbjahr_label,
"quartal_label": quartal_label,
}
subject = f"Nachweis-Aufforderung: {quartal_label} ({halbjahr_label}) vHTV-Stiftung"
from_email = _get_smtp_from_email()
to_email = destinataer.email
from stiftung.utils.vorlagen import render_vorlage
text_body = render_vorlage("email/nachweis_aufforderung.txt", context)
html_body = render_vorlage("email/nachweis_aufforderung.html", context)
try:
connection = _get_smtp_connection()
msg = EmailMultiAlternatives(subject, text_body, from_email, [to_email], connection=connection)
msg.attach_alternative(html_body, "text/html")
msg.send()
logger.info(
"Nachweis-Aufforderung gesendet an %s (Token %s)",
to_email,
upload_token.id,
)
return {
"status": "sent",
"destinataer_id": str(destinataer_id),
"nachweis_id": str(nachweis_id),
"token_id": str(upload_token.id),
}
except Exception as exc:
logger.exception("E-Mail-Versand fehlgeschlagen für %s: %s", to_email, exc)
raise self.retry(exc=exc)
@shared_task(bind=True, max_retries=3, default_retry_delay=300)
def send_nachweis_erinnerung(self, token_id, base_url=None):
"""
Sendet eine Erinnerungs-E-Mail für einen bald ablaufenden Upload-Token.
Wird durch Celery Beat ausgelöst (7 Tage vor Ablauf).
"""
from django.conf import settings
from django.core.mail import EmailMultiAlternatives
from django.template.loader import render_to_string
from stiftung.models import UploadToken
try:
upload_token = UploadToken.objects.select_related(
"destinataer", "nachweis"
).get(id=token_id, ist_aktiv=True)
except UploadToken.DoesNotExist:
return {"status": "skipped", "reason": "token_not_found_or_inactive"}
if not upload_token.ist_gueltig():
return {"status": "skipped", "reason": "token_invalid"}
if not upload_token.destinataer.email:
return {"status": "skipped", "reason": "no_email"}
if base_url is None:
base_url = getattr(settings, "SITE_URL", "https://vhtv-stiftung.de")
upload_url = f"{base_url}/portal/upload/{upload_token.token}/"
nachweis = upload_token.nachweis
halbjahr = 1 if nachweis.quartal in [1, 2] else 2
halbjahr_label = f"{halbjahr}. Halbjahr {nachweis.jahr}"
context = {
"destinataer": upload_token.destinataer,
"nachweis": nachweis,
"upload_url": upload_url,
"gueltig_bis": upload_token.gueltig_bis,
"halbjahr_label": halbjahr_label,
"ist_erinnerung": True,
}
subject = f"Erinnerung: Nachweis-Upload noch ausstehend {halbjahr_label}"
from_email = _get_smtp_from_email()
to_email = upload_token.destinataer.email
from stiftung.utils.vorlagen import render_vorlage
text_body = render_vorlage("email/nachweis_aufforderung.txt", context)
html_body = render_vorlage("email/nachweis_aufforderung.html", context)
try:
connection = _get_smtp_connection()
msg = EmailMultiAlternatives(subject, text_body, from_email, [to_email], connection=connection)
msg.attach_alternative(html_body, "text/html")
msg.send()
upload_token.erinnerung_gesendet = True
upload_token.save(update_fields=["erinnerung_gesendet"])
logger.info("Erinnerung gesendet an %s (Token %s)", to_email, token_id)
return {"status": "sent", "token_id": str(token_id)}
except Exception as exc:
logger.exception("Erinnerungs-E-Mail fehlgeschlagen für %s: %s", to_email, exc)
raise self.retry(exc=exc)
@shared_task(bind=True, max_retries=3, default_retry_delay=300)
def send_onboarding_einladung(self, einladung_id, base_url=None):
"""
Sendet eine Onboarding-Einladungs-E-Mail an eine neue potenzielle Destinatärin/
einen neuen potenziellen Destinatär.
Args:
einladung_id: UUID der OnboardingEinladung
base_url: Basis-URL der Anwendung
"""
from django.conf import settings
from django.core.mail import EmailMultiAlternatives
from django.template.loader import render_to_string
from stiftung.models import OnboardingEinladung
try:
einladung = OnboardingEinladung.objects.get(id=einladung_id)
except OnboardingEinladung.DoesNotExist as exc:
logger.error("send_onboarding_einladung: Einladung %s nicht gefunden", einladung_id)
return {"status": "error", "message": str(exc)}
if not einladung.ist_gueltig():
return {"status": "skipped", "reason": "einladung_ungueltig"}
if base_url is None:
base_url = getattr(settings, "SITE_URL", "https://vhtv-stiftung.de")
onboarding_url = f"{base_url}/portal/onboarding/{einladung.token}/"
context = {
"einladung": einladung,
"onboarding_url": onboarding_url,
"gueltig_bis": einladung.gueltig_bis,
}
subject = "Einladung zum Onboarding van Hees-Theyssen-Vogel'sche Stiftung"
from_email = _get_smtp_from_email()
to_email = einladung.email
from stiftung.utils.vorlagen import render_vorlage
text_body = render_vorlage("email/onboarding_einladung.txt", context)
html_body = render_vorlage("email/onboarding_einladung.html", context)
try:
connection = _get_smtp_connection()
msg = EmailMultiAlternatives(subject, text_body, from_email, [to_email], connection=connection)
msg.attach_alternative(html_body, "text/html")
msg.send()
logger.info(
"Onboarding-Einladung gesendet an %s (Einladung %s)",
to_email,
einladung_id,
)
return {"status": "sent", "einladung_id": str(einladung_id), "email": to_email}
except Exception as exc:
logger.exception("Onboarding-E-Mail-Versand fehlgeschlagen für %s: %s", to_email, exc)
raise self.retry(exc=exc)
@shared_task(bind=True, max_retries=3, default_retry_delay=300)
def send_bestaetigung(self, destinataer_id, base_url=None):
"""
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.
Args:
destinataer_id: UUID des Destinatärs
base_url: Basis-URL der Anwendung (für Konsistenz mit anderen Tasks)
"""
from decimal import Decimal
from django.core.files.base import ContentFile
from django.template.loader import render_to_string
from django.core.mail import EmailMultiAlternatives
from django.utils import timezone
from stiftung.models import Destinataer, DestinataerUnterstuetzung, DokumentDatei
try:
destinataer = Destinataer.objects.get(id=destinataer_id)
except Destinataer.DoesNotExist as exc:
logger.error("send_bestaetigung: Destinatär %s nicht gefunden", destinataer_id)
return {"status": "error", "message": str(exc)}
if not destinataer.email:
logger.warning("send_bestaetigung: Destinatär %s hat keine E-Mail-Adresse", destinataer_id)
return {"status": "skipped", "reason": "no_email"}
# Alle abgeschlossenen Unterstützungen laden
unterstuetzungen = list(DestinataerUnterstuetzung.objects.filter(
destinataer=destinataer,
status__in=["ausgezahlt", "abgeschlossen"],
).order_by("faellig_am"))
gesamtbetrag = sum(u.betrag for u in unterstuetzungen) if unterstuetzungen else Decimal("0")
zeitraum = None
if unterstuetzungen:
erste = unterstuetzungen[0].faellig_am
letzte = unterstuetzungen[-1].faellig_am
if erste == letzte:
zeitraum = erste.strftime("%d.%m.%Y")
else:
zeitraum = f"{erste.strftime('%d.%m.%Y')} {letzte.strftime('%d.%m.%Y')}"
betrag_quartal = destinataer.vierteljaehrlicher_betrag
betrag_jaehrlich = (betrag_quartal * 4) if betrag_quartal else None
zweck = destinataer.ausbildungsstand or (destinataer.get_berufsgruppe_display() if destinataer.berufsgruppe else None)
datum = timezone.now().date()
context = {
"destinataer": destinataer,
"unterstuetzungen": unterstuetzungen,
"gesamtbetrag": gesamtbetrag,
"datum": datum,
"zeitraum": zeitraum,
"betrag_quartal": betrag_quartal,
"betrag_jaehrlich": betrag_jaehrlich,
"zweck": zweck,
}
# PDF generieren via WeasyPrint
pdf_bytes = None
try:
from weasyprint import HTML
from stiftung.utils.vorlagen import render_vorlage
html_content = render_vorlage("pdf/bestaetigung.html", context)
pdf_bytes = HTML(string=html_content).write_pdf()
except Exception as exc:
logger.error("send_bestaetigung: PDF-Generierung fehlgeschlagen: %s", exc)
raise self.retry(exc=exc)
# PDF im DMS ablegen
filename = (
f"bestaetigung_{destinataer.nachname}_{destinataer.vorname}"
f"_{datum.strftime('%Y%m%d')}.pdf"
)
try:
doc = DokumentDatei(
titel=f"Bestätigungsschreiben {datum.strftime('%d.%m.%Y')} {destinataer.get_full_name()}",
beschreibung="Automatisch generiertes Bestätigungsschreiben über Förderleistungen.",
kontext="korrespondenz",
dateiname_original=filename,
dateityp="application/pdf",
dateigroesse=len(pdf_bytes),
destinataer=destinataer,
)
doc.datei.save(filename, ContentFile(pdf_bytes), save=False)
doc.save()
logger.info("Bestätigung im DMS gespeichert (ID: %s).", doc.pk)
except Exception as exc:
logger.error("send_bestaetigung: DMS-Speicherung fehlgeschlagen: %s", exc)
# Weiter mit E-Mail-Versand auch wenn DMS-Speicherung schlägt fehl
# E-Mail senden
from stiftung.utils.vorlagen import render_vorlage
html_body = render_vorlage("email/bestaetigung.html", context)
subject = "Bestätigung Ihrer Stiftungsförderung van Hees-Theyssen-Vogel'sche Stiftung"
from_email = _get_smtp_from_email()
to_email = destinataer.email
try:
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}
except Exception as exc:
logger.exception("send_bestaetigung: E-Mail-Versand fehlgeschlagen für %s: %s", to_email, exc)
raise self.retry(exc=exc)
@shared_task
def check_ablaufende_tokens():
"""
Prüft täglich Upload-Tokens, die in 7 Tagen ablaufen,
und sendet Erinnerungs-E-Mails (falls noch nicht gesendet).
Wird durch Celery Beat aufgerufen.
"""
from django.utils import timezone
from stiftung.models import UploadToken
grenze = timezone.now() + timedelta(days=7)
tokens = UploadToken.objects.filter(
ist_aktiv=True,
eingeloest_am__isnull=True,
erinnerung_gesendet=False,
gueltig_bis__lte=grenze,
gueltig_bis__gt=timezone.now(),
)
count = 0
for token in tokens:
send_nachweis_erinnerung.delay(str(token.id))
count += 1
logger.info("check_ablaufende_tokens: %d Erinnerungen angestoßen", count)
return {"triggered": count}