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