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>
848 lines
32 KiB
Python
848 lines
32 KiB
Python
"""
|
||
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)
|
||
|
||
|
||
def _send_bestaetigung_sync(destinataer_id):
|
||
"""
|
||
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.
|
||
|
||
Kann direkt (synchron) oder via Celery-Task aufgerufen werden.
|
||
Bei Fehlern wird eine Exception geworfen (kein stilles Verschlucken).
|
||
"""
|
||
from decimal import Decimal
|
||
from django.core.files.base import ContentFile
|
||
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
|
||
|
||
# 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
|
||
|
||
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:
|
||
return _send_bestaetigung_sync(destinataer_id)
|
||
except Exception as exc:
|
||
logger.exception("send_bestaetigung task fehlgeschlagen: %s", 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}
|