- Datenschutzerklärung unter /portal/datenschutz/ öffentlich erreichbar - Link zur Datenschutzerklärung in Nachweis-Aufforderungs-E-Mails (HTML + TXT) - Einwilligungs-Checkbox vor Upload mit Server-Side-Validierung - Consent-Logging: einwilligung_erteilt_am auf UploadToken (Art. 7 Abs. 1 DSGVO) - Regelsatz-Korrektur: 449€→563€ in Onboarding-Template (Stand 01/2024) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
850 lines
32 KiB
Python
850 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,
|
||
"datenschutz_url": f"{base_url}/portal/datenschutz/",
|
||
}
|
||
|
||
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,
|
||
"datenschutz_url": f"{base_url}/portal/datenschutz/",
|
||
}
|
||
|
||
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}
|