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