feat: Email-Eingangsverarbeitung für Destinatäre implementieren
Neues System zur automatischen Verarbeitung eingehender E-Mails von Destinatären. IMAP-Polling alle 15 Minuten via Celery Beat, automatische Zuordnung zu Destinatären anhand der E-Mail-Adresse, Upload von Anhängen zu Paperless-NGX. Umfasst: - DestinataerEmailEingang Model mit Status-Tracking - Celery Task für IMAP-Polling und Paperless-Integration - Web-UI (Liste + Detail) mit Such- und Filterfunktion - Admin-Interface mit Bulk-Actions - Agent-Dokumentation (SysAdmin, RentmeisterAI) - Dev-Environment Modernisierung (docker compose v2) Reviewed by: SysAdmin (STI-15), RentmeisterAI (STI-16) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -106,6 +106,26 @@ MEDIA_ROOT = BASE_DIR / "media"
|
||||
CELERY_BROKER_URL = os.getenv("REDIS_URL", "redis://redis:6379/0")
|
||||
CELERY_RESULT_BACKEND = os.getenv("REDIS_URL", "redis://redis:6379/0")
|
||||
|
||||
# Celery Beat – periodische Tasks
|
||||
from celery.schedules import crontab # noqa: E402
|
||||
|
||||
CELERY_BEAT_SCHEDULE = {
|
||||
# E-Mail-Postfach alle 15 Minuten auf neue Destinatär-Nachrichten prüfen
|
||||
"poll-destinataer-emails": {
|
||||
"task": "stiftung.tasks.poll_destinataer_emails",
|
||||
"schedule": crontab(minute="*/15"),
|
||||
},
|
||||
}
|
||||
|
||||
# IMAP-Konfiguration für E-Mail-Eingang (Destinatäre)
|
||||
# Pflichtfelder: IMAP_HOST, IMAP_USER, IMAP_PASSWORD
|
||||
IMAP_HOST = os.getenv("IMAP_HOST", "")
|
||||
IMAP_PORT = int(os.getenv("IMAP_PORT", "993"))
|
||||
IMAP_USER = os.getenv("IMAP_USER", "paperless@vhtv-stiftung.de")
|
||||
IMAP_PASSWORD = os.getenv("IMAP_PASSWORD", "")
|
||||
IMAP_FOLDER = os.getenv("IMAP_FOLDER", "INBOX")
|
||||
IMAP_USE_SSL = os.getenv("IMAP_USE_SSL", "true").lower() == "true"
|
||||
|
||||
# Paperless
|
||||
PAPERLESS_API_URL = os.getenv("PAPERLESS_API_URL", "https://vhtv-stiftung.de/paperless")
|
||||
PAPERLESS_API_TOKEN = os.getenv("PAPERLESS_API_TOKEN")
|
||||
|
||||
@@ -7,7 +7,8 @@ from django.utils.safestring import mark_safe
|
||||
|
||||
from . import models
|
||||
from .models import (AppConfiguration, AuditLog, BackupJob, BankTransaction,
|
||||
CSVImport, Destinataer, DestinataerUnterstuetzung,
|
||||
CSVImport, Destinataer, DestinataerEmailEingang,
|
||||
DestinataerUnterstuetzung,
|
||||
DokumentLink, Foerderung, Land, LandVerpachtung, Paechter, Person,
|
||||
Rentmeister, StiftungsKonto, UnterstuetzungWiederkehrend,
|
||||
Verwaltungskosten, VierteljahresNachweis)
|
||||
@@ -1157,6 +1158,76 @@ class VierteljahresNachweisAdmin(admin.ModelAdmin):
|
||||
mark_as_needs_revision.short_description = "Nachbesserung erforderlich markieren"
|
||||
|
||||
|
||||
@admin.register(DestinataerEmailEingang)
|
||||
class DestinataerEmailEingangAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
"eingangsdatum",
|
||||
"absender_email",
|
||||
"absender_name",
|
||||
"destinataer_link",
|
||||
"betreff_kurz",
|
||||
"anzahl_anhaenge",
|
||||
"status",
|
||||
"created_at",
|
||||
]
|
||||
list_filter = ["status", "eingangsdatum"]
|
||||
search_fields = [
|
||||
"absender_email",
|
||||
"absender_name",
|
||||
"betreff",
|
||||
"destinataer__vorname",
|
||||
"destinataer__nachname",
|
||||
]
|
||||
readonly_fields = ["created_at", "absender_email", "absender_name", "eingangsdatum",
|
||||
"email_text", "paperless_dokument_ids", "fehler_details"]
|
||||
raw_id_fields = ["destinataer", "quartalsnachweis"]
|
||||
date_hierarchy = "eingangsdatum"
|
||||
ordering = ["-eingangsdatum"]
|
||||
|
||||
fieldsets = [
|
||||
("E-Mail-Metadaten", {
|
||||
"fields": ["eingangsdatum", "absender_name", "absender_email", "betreff"],
|
||||
}),
|
||||
("Zuordnung", {
|
||||
"fields": ["destinataer", "status", "quartalsnachweis"],
|
||||
}),
|
||||
("Inhalt & Anhänge", {
|
||||
"fields": ["email_text", "paperless_dokument_ids"],
|
||||
}),
|
||||
("Notizen & Fehler", {
|
||||
"fields": ["notizen", "fehler_details"],
|
||||
"classes": ["collapse"],
|
||||
}),
|
||||
("System", {
|
||||
"fields": ["created_at"],
|
||||
"classes": ["collapse"],
|
||||
}),
|
||||
]
|
||||
|
||||
def destinataer_link(self, obj):
|
||||
if obj.destinataer:
|
||||
url = reverse("admin:stiftung_destinataer_change", args=[obj.destinataer.pk])
|
||||
return format_html('<a href="{}">{}</a>', url, obj.destinataer)
|
||||
return format_html('<span style="color:red;">–</span>')
|
||||
destinataer_link.short_description = "Destinatär"
|
||||
|
||||
def betreff_kurz(self, obj):
|
||||
return (obj.betreff or "")[:60]
|
||||
betreff_kurz.short_description = "Betreff"
|
||||
|
||||
def anzahl_anhaenge(self, obj):
|
||||
n = len(obj.paperless_dokument_ids or [])
|
||||
return n if n else "–"
|
||||
anzahl_anhaenge.short_description = "Anhänge"
|
||||
|
||||
actions = ["mark_verarbeitet"]
|
||||
|
||||
def mark_verarbeitet(self, request, queryset):
|
||||
updated = queryset.filter(status__in=["neu", "zugewiesen"]).update(status="verarbeitet")
|
||||
self.message_user(request, f"{updated} E-Mail(s) als verarbeitet markiert.")
|
||||
mark_verarbeitet.short_description = "Als verarbeitet markieren"
|
||||
|
||||
|
||||
# Customize admin site
|
||||
admin.site.site_header = "Stiftungsverwaltung Administration"
|
||||
admin.site.site_title = "Stiftungsverwaltung Admin"
|
||||
|
||||
124
app/stiftung/migrations/0043_destinataer_email_eingang.py
Normal file
124
app/stiftung/migrations/0043_destinataer_email_eingang.py
Normal file
@@ -0,0 +1,124 @@
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("stiftung", "0042_add_separate_deadlines"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="DestinataerEmailEingang",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
),
|
||||
),
|
||||
(
|
||||
"destinataer",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="email_eingaenge",
|
||||
to="stiftung.destinataer",
|
||||
verbose_name="Destinatär",
|
||||
),
|
||||
),
|
||||
(
|
||||
"absender_email",
|
||||
models.EmailField(max_length=254, verbose_name="Absender-E-Mail"),
|
||||
),
|
||||
(
|
||||
"absender_name",
|
||||
models.CharField(
|
||||
blank=True, max_length=255, verbose_name="Absender-Name"
|
||||
),
|
||||
),
|
||||
(
|
||||
"betreff",
|
||||
models.CharField(
|
||||
blank=True, max_length=500, verbose_name="Betreff"
|
||||
),
|
||||
),
|
||||
(
|
||||
"eingangsdatum",
|
||||
models.DateTimeField(verbose_name="Eingangsdatum"),
|
||||
),
|
||||
(
|
||||
"email_text",
|
||||
models.TextField(blank=True, verbose_name="E-Mail-Text"),
|
||||
),
|
||||
(
|
||||
"paperless_dokument_ids",
|
||||
models.JSONField(
|
||||
blank=True,
|
||||
default=list,
|
||||
help_text="Automatisch befüllte Liste der hochgeladenen Anhänge in Paperless-NGX",
|
||||
verbose_name="Paperless Dokument-IDs (Anhänge)",
|
||||
),
|
||||
),
|
||||
(
|
||||
"status",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("neu", "Neu / Unbearbeitet"),
|
||||
("zugewiesen", "Destinatär zugewiesen"),
|
||||
("verarbeitet", "Verarbeitet"),
|
||||
("unbekannt", "Unbekannter Absender"),
|
||||
("fehler", "Fehler bei Verarbeitung"),
|
||||
],
|
||||
default="neu",
|
||||
max_length=20,
|
||||
verbose_name="Status",
|
||||
),
|
||||
),
|
||||
(
|
||||
"fehler_details",
|
||||
models.TextField(
|
||||
blank=True,
|
||||
help_text="Technische Fehlermeldung bei Verarbeitungsfehlern",
|
||||
verbose_name="Fehlerdetails",
|
||||
),
|
||||
),
|
||||
(
|
||||
"notizen",
|
||||
models.TextField(
|
||||
blank=True,
|
||||
help_text="Manuelle Notizen der Verwaltung zur E-Mail",
|
||||
verbose_name="Interne Notizen",
|
||||
),
|
||||
),
|
||||
(
|
||||
"quartalsnachweis",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="email_eingaenge",
|
||||
to="stiftung.vierteljahresnachweis",
|
||||
verbose_name="Quartalsnachweis (zugeordnet)",
|
||||
),
|
||||
),
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(
|
||||
auto_now_add=True, verbose_name="Erfasst am"
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "E-Mail-Eingang (Destinatär)",
|
||||
"verbose_name_plural": "E-Mail-Eingänge (Destinatäre)",
|
||||
"ordering": ["-eingangsdatum"],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -3183,3 +3183,99 @@ class StiftungsKalenderEintrag(models.Model):
|
||||
return False
|
||||
today = timezone.now().date()
|
||||
return today <= self.datum <= (today + timezone.timedelta(days=days))
|
||||
|
||||
|
||||
class DestinataerEmailEingang(models.Model):
|
||||
"""
|
||||
Erfasst eingehende E-Mails von Destinatären.
|
||||
|
||||
Wird automatisch durch den Celery-Task `poll_destinataer_emails` befüllt,
|
||||
der das IMAP-Postfach der Stiftung (paperless@vhtv-stiftung.de) überwacht.
|
||||
Anhänge werden automatisch in Paperless-NGX hochgeladen und als DokumentLink
|
||||
mit dem jeweiligen Destinatär verknüpft.
|
||||
"""
|
||||
|
||||
STATUS_CHOICES = [
|
||||
("neu", "Neu / Unbearbeitet"),
|
||||
("zugewiesen", "Destinatär zugewiesen"),
|
||||
("verarbeitet", "Verarbeitet"),
|
||||
("unbekannt", "Unbekannter Absender"),
|
||||
("fehler", "Fehler bei Verarbeitung"),
|
||||
]
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
|
||||
# Verknüpfung zum Destinatär (None = unbekannter Absender)
|
||||
destinataer = models.ForeignKey(
|
||||
Destinataer,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="email_eingaenge",
|
||||
verbose_name="Destinatär",
|
||||
)
|
||||
|
||||
# E-Mail-Metadaten
|
||||
absender_email = models.EmailField(verbose_name="Absender-E-Mail")
|
||||
absender_name = models.CharField(
|
||||
max_length=255, blank=True, verbose_name="Absender-Name"
|
||||
)
|
||||
betreff = models.CharField(max_length=500, blank=True, verbose_name="Betreff")
|
||||
eingangsdatum = models.DateTimeField(verbose_name="Eingangsdatum")
|
||||
email_text = models.TextField(blank=True, verbose_name="E-Mail-Text")
|
||||
|
||||
# Anhänge: Liste der Paperless-Dokument-IDs (JSON-Format)
|
||||
paperless_dokument_ids = models.JSONField(
|
||||
default=list,
|
||||
blank=True,
|
||||
verbose_name="Paperless Dokument-IDs (Anhänge)",
|
||||
help_text="Automatisch befüllte Liste der hochgeladenen Anhänge in Paperless-NGX",
|
||||
)
|
||||
|
||||
# Verarbeitungsstatus
|
||||
status = models.CharField(
|
||||
max_length=20,
|
||||
choices=STATUS_CHOICES,
|
||||
default="neu",
|
||||
verbose_name="Status",
|
||||
)
|
||||
fehler_details = models.TextField(
|
||||
blank=True,
|
||||
verbose_name="Fehlerdetails",
|
||||
help_text="Technische Fehlermeldung bei Verarbeitungsfehlern",
|
||||
)
|
||||
notizen = models.TextField(
|
||||
blank=True,
|
||||
verbose_name="Interne Notizen",
|
||||
help_text="Manuelle Notizen der Verwaltung zur E-Mail",
|
||||
)
|
||||
|
||||
# Verweis auf VierteljahresNachweis, falls E-Mail einem Quartal zugeordnet
|
||||
quartalsnachweis = models.ForeignKey(
|
||||
"VierteljahresNachweis",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="email_eingaenge",
|
||||
verbose_name="Quartalsnachweis (zugeordnet)",
|
||||
)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Erfasst am")
|
||||
|
||||
class Meta:
|
||||
verbose_name = "E-Mail-Eingang (Destinatär)"
|
||||
verbose_name_plural = "E-Mail-Eingänge (Destinatäre)"
|
||||
ordering = ["-eingangsdatum"]
|
||||
|
||||
def __str__(self):
|
||||
dest = str(self.destinataer) if self.destinataer else self.absender_email
|
||||
return f"[{self.eingangsdatum.strftime('%d.%m.%Y')}] {dest}: {self.betreff[:60]}"
|
||||
|
||||
def get_paperless_links(self):
|
||||
"""Gibt Liste der Paperless-Dokument-URLs zurück."""
|
||||
from django.conf import settings
|
||||
base = settings.PAPERLESS_API_URL or ""
|
||||
return [
|
||||
f"{base}/documents/{doc_id}/"
|
||||
for doc_id in (self.paperless_dokument_ids or [])
|
||||
]
|
||||
|
||||
324
app/stiftung/tasks.py
Normal file
324
app/stiftung/tasks.py
Normal file
@@ -0,0 +1,324 @@
|
||||
"""
|
||||
Celery-Tasks für die automatische Verarbeitung von Destinatär-E-Mails.
|
||||
|
||||
Workflow:
|
||||
1. `poll_destinataer_emails` läuft alle 15 Minuten (Celery Beat)
|
||||
2. Er liest ungelesene E-Mails aus dem IMAP-Postfach (paperless@vhtv-stiftung.de)
|
||||
3. Für jede E-Mail:
|
||||
a) Absender wird mit Destinatär-Datenbank abgeglichen (E-Mail-Feld)
|
||||
b) Ein DestinataerEmailEingang-Datensatz wird angelegt
|
||||
c) Alle Anhänge werden per Paperless-API hochgeladen
|
||||
d) Für jeden Anhang wird ein DokumentLink erstellt
|
||||
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 für SSL)
|
||||
IMAP_USER — Benutzername (z. B. paperless@vhtv-stiftung.de)
|
||||
IMAP_PASSWORD — Passwort
|
||||
IMAP_FOLDER — Ordner (Standard: INBOX)
|
||||
"""
|
||||
|
||||
import email
|
||||
import email.utils
|
||||
import imaplib
|
||||
import io
|
||||
import logging
|
||||
import mimetypes
|
||||
from datetime import datetime, timezone as dt_timezone
|
||||
from email.header import decode_header, make_header
|
||||
|
||||
import requests
|
||||
from celery import shared_task
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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 zurück."""
|
||||
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 _upload_to_paperless(content: bytes, filename: str, destinataer=None, betreff: str = "") -> int | None:
|
||||
"""
|
||||
Lädt einen Anhang in Paperless-NGX hoch.
|
||||
|
||||
Gibt die neue Paperless-Dokument-ID zurück, oder None bei Fehler.
|
||||
"""
|
||||
api_url = getattr(settings, "PAPERLESS_API_URL", None)
|
||||
api_token = getattr(settings, "PAPERLESS_API_TOKEN", None)
|
||||
|
||||
if not api_url or not api_token:
|
||||
logger.warning("Paperless nicht konfiguriert – Anhang '%s' wird nicht hochgeladen.", filename)
|
||||
return None
|
||||
|
||||
# Tag-ID für Destinatäre ermitteln
|
||||
tag_ids = []
|
||||
dest_tag_id = getattr(settings, "PAPERLESS_DESTINATAERE_TAG_ID", None)
|
||||
if dest_tag_id:
|
||||
try:
|
||||
tag_ids.append(int(dest_tag_id))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Correspondent: Name des Destinatärs (optional, Paperless sucht/erstellt ihn)
|
||||
correspondent_name = None
|
||||
if destinataer:
|
||||
correspondent_name = f"{destinataer.vorname} {destinataer.nachname}".strip()
|
||||
|
||||
# Dateiname bereinigen
|
||||
safe_filename = filename or "anhang.pdf"
|
||||
|
||||
# Mime-Type bestimmen
|
||||
mime_type, _ = mimetypes.guess_type(safe_filename)
|
||||
mime_type = mime_type or "application/octet-stream"
|
||||
|
||||
upload_url = f"{api_url.rstrip('/')}/api/documents/post_document/"
|
||||
headers = {"Authorization": f"Token {api_token}"}
|
||||
|
||||
form_data = {}
|
||||
if tag_ids:
|
||||
form_data["tags"] = tag_ids
|
||||
if correspondent_name:
|
||||
form_data["correspondent_name"] = correspondent_name
|
||||
if betreff:
|
||||
form_data["title"] = betreff[:128]
|
||||
|
||||
files = {"document": (safe_filename, io.BytesIO(content), mime_type)}
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
upload_url,
|
||||
headers=headers,
|
||||
data=form_data,
|
||||
files=files,
|
||||
timeout=60,
|
||||
)
|
||||
response.raise_for_status()
|
||||
# Paperless gibt die neue Dokument-ID zurück (als Integer oder UUID-String)
|
||||
result = response.json()
|
||||
doc_id = result if isinstance(result, int) else result.get("id")
|
||||
logger.info("Anhang '%s' erfolgreich in Paperless hochgeladen (ID: %s).", safe_filename, doc_id)
|
||||
return doc_id
|
||||
except requests.RequestException as exc:
|
||||
logger.error("Fehler beim Hochladen von '%s' in Paperless: %s", safe_filename, exc)
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Haupttask
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@shared_task(bind=True, max_retries=3, default_retry_delay=300, name="stiftung.tasks.poll_destinataer_emails")
|
||||
def poll_destinataer_emails(self):
|
||||
"""
|
||||
Liest ungelesene E-Mails aus dem IMAP-Postfach und verarbeitet sie.
|
||||
|
||||
Wird durch Celery Beat alle 15 Minuten ausgeführt.
|
||||
"""
|
||||
from stiftung.models import Destinataer, DestinataerEmailEingang, DokumentLink
|
||||
|
||||
# IMAP-Konfiguration aus Settings
|
||||
imap_host = getattr(settings, "IMAP_HOST", None)
|
||||
imap_port = int(getattr(settings, "IMAP_PORT", 993))
|
||||
imap_user = getattr(settings, "IMAP_USER", None)
|
||||
imap_password = getattr(settings, "IMAP_PASSWORD", None)
|
||||
imap_folder = getattr(settings, "IMAP_FOLDER", "INBOX")
|
||||
imap_use_ssl = getattr(settings, "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 übersprungen."
|
||||
)
|
||||
return {"status": "skipped", "reason": "IMAP not configured"}
|
||||
|
||||
# Vorab: Destinatär-E-Mail-Index für schnelle Zuordnung
|
||||
# Nur aktive Destinatäre 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
|
||||
if imap_use_ssl:
|
||||
mail = imaplib.IMAP4_SSL(imap_host, imap_port)
|
||||
else:
|
||||
mail = imaplib.IMAP4(imap_host, imap_port)
|
||||
|
||||
mail.login(imap_user, imap_password)
|
||||
mail.select(imap_folder)
|
||||
|
||||
# Ungelesene Nachrichten suchen
|
||||
_, message_ids_raw = mail.search(None, "UNSEEN")
|
||||
message_ids = message_ids_raw[0].split()
|
||||
|
||||
logger.info("Postfach '%s': %d ungelesene Nachricht(en) gefunden.", imap_folder, 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 = 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)
|
||||
|
||||
# Destinatär zuordnen
|
||||
destinataer = destinataer_by_email.get(absender_email)
|
||||
status = "zugewiesen" if destinataer else "unbekannt"
|
||||
|
||||
# Prüfen ob diese E-Mail bereits verarbeitet wurde (Duplikat-Check via
|
||||
# Datum + Absender + Betreff)
|
||||
already_exists = DestinataerEmailEingang.objects.filter(
|
||||
absender_email=absender_email,
|
||||
eingangsdatum=eingangsdatum,
|
||||
betreff=betreff[:500],
|
||||
).exists()
|
||||
if already_exists:
|
||||
logger.debug(
|
||||
"E-Mail von %s am %s bereits vorhanden – wird übersprungen.",
|
||||
absender_email, eingangsdatum,
|
||||
)
|
||||
# Als gelesen markieren
|
||||
mail.store(msg_id, "+FLAGS", "\\Seen")
|
||||
continue
|
||||
|
||||
# Datensatz anlegen
|
||||
eingang = DestinataerEmailEingang(
|
||||
destinataer=destinataer,
|
||||
absender_email=absender_email,
|
||||
absender_name=absender_name,
|
||||
betreff=betreff[:500],
|
||||
eingangsdatum=eingangsdatum,
|
||||
email_text=email_text,
|
||||
status=status,
|
||||
)
|
||||
|
||||
# Anhänge verarbeiten
|
||||
paperless_ids = []
|
||||
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:
|
||||
continue
|
||||
|
||||
doc_id = _upload_to_paperless(
|
||||
content=content,
|
||||
filename=filename,
|
||||
destinataer=destinataer,
|
||||
betreff=betreff,
|
||||
)
|
||||
if doc_id:
|
||||
paperless_ids.append(doc_id)
|
||||
# DokumentLink anlegen
|
||||
DokumentLink.objects.create(
|
||||
paperless_document_id=doc_id,
|
||||
kontext="verwendungsnachweis",
|
||||
titel=f"{betreff[:100]} – {filename}" if filename else betreff[:200],
|
||||
beschreibung=(
|
||||
f"Automatisch importiert aus E-Mail-Eingang.\n"
|
||||
f"Absender: {absender_name} <{absender_email}>\n"
|
||||
f"Datum: {eingangsdatum.strftime('%d.%m.%Y %H:%M')}"
|
||||
),
|
||||
destinataer_id=destinataer.pk if destinataer else None,
|
||||
)
|
||||
|
||||
eingang.paperless_dokument_ids = paperless_ids
|
||||
if paperless_ids:
|
||||
eingang.status = "verarbeitet" if destinataer else "unbekannt"
|
||||
eingang.save()
|
||||
|
||||
# Als gelesen markieren
|
||||
mail.store(msg_id, "+FLAGS", "\\Seen")
|
||||
processed += 1
|
||||
logger.info(
|
||||
"E-Mail verarbeitet: von=%s, Destinatär=%s, Anhänge=%d",
|
||||
absender_email,
|
||||
str(destinataer) if destinataer else "unbekannt",
|
||||
len(paperless_ids),
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
errors += 1
|
||||
logger.exception("Fehler bei Verarbeitung von Nachricht %s: %s", msg_id, exc)
|
||||
# Nicht als gelesen markieren – wird beim nächsten 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_destinataer_emails-Task: %s", exc)
|
||||
raise self.retry(exc=exc)
|
||||
|
||||
result = {"status": "done", "processed": processed, "errors": errors}
|
||||
logger.info("poll_destinataer_emails abgeschlossen: %s", result)
|
||||
return result
|
||||
@@ -396,6 +396,10 @@ urlpatterns = [
|
||||
path("geschichte/<slug:slug>/bild-upload/", views.geschichte_bild_upload, name="geschichte_bild_upload"),
|
||||
path("geschichte/<slug:slug>/bild/<uuid:bild_id>/loeschen/", views.geschichte_bild_delete, name="geschichte_bild_delete"),
|
||||
|
||||
# E-Mail-Eingang Destinatäre
|
||||
path("email-eingang/", views.email_eingang_list, name="email_eingang_list"),
|
||||
path("email-eingang/<uuid:pk>/", views.email_eingang_detail, name="email_eingang_detail"),
|
||||
path("email-eingang/poll/", views.email_eingang_poll_trigger, name="email_eingang_poll_trigger"),
|
||||
# Kalender URLs
|
||||
path("kalender/", views.kalender_view, name="kalender"),
|
||||
path("kalender/admin/", views.kalender_admin, name="kalender_admin"),
|
||||
|
||||
@@ -28,7 +28,8 @@ from rest_framework.decorators import api_view
|
||||
from rest_framework.response import Response
|
||||
|
||||
from .models import (AppConfiguration, CSVImport, Destinataer,
|
||||
DestinataerUnterstuetzung, DokumentLink, Foerderung, Land,
|
||||
DestinataerEmailEingang, DestinataerUnterstuetzung,
|
||||
DokumentLink, Foerderung, Land,
|
||||
LandAbrechnung, LandVerpachtung, Paechter, Person,
|
||||
StiftungsKonto, UnterstuetzungWiederkehrend, VierteljahresNachweis)
|
||||
|
||||
@@ -8513,3 +8514,131 @@ def kalender_view(request):
|
||||
'title': 'Kalendereintrag löschen'
|
||||
}
|
||||
return render(request, 'stiftung/kalender/delete.html', context)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# E-Mail-Eingang – Destinatäre
|
||||
# =============================================================================
|
||||
|
||||
@login_required
|
||||
def email_eingang_list(request):
|
||||
"""
|
||||
Übersicht aller eingegangenen E-Mails von Destinatären.
|
||||
Zeigt ungeklärte Absender zuerst, dann chronologisch absteigend.
|
||||
"""
|
||||
status_filter = request.GET.get("status", "")
|
||||
search = request.GET.get("q", "").strip()
|
||||
|
||||
qs = DestinataerEmailEingang.objects.select_related("destinataer", "quartalsnachweis")
|
||||
|
||||
if status_filter:
|
||||
qs = qs.filter(status=status_filter)
|
||||
if search:
|
||||
qs = qs.filter(
|
||||
Q(absender_email__icontains=search)
|
||||
| Q(absender_name__icontains=search)
|
||||
| Q(betreff__icontains=search)
|
||||
| Q(destinataer__vorname__icontains=search)
|
||||
| Q(destinataer__nachname__icontains=search)
|
||||
)
|
||||
|
||||
# Unbekannte Absender zuerst, dann nach Datum absteigend
|
||||
qs = qs.order_by(
|
||||
"status", # "unbekannt" kommt alphabetisch vor "verarbeitet" / "zugewiesen"
|
||||
"-eingangsdatum",
|
||||
)
|
||||
|
||||
paginator = Paginator(qs, 30)
|
||||
page_obj = paginator.get_page(request.GET.get("page"))
|
||||
|
||||
context = {
|
||||
"title": "E-Mail-Eingang (Destinatäre)",
|
||||
"page_obj": page_obj,
|
||||
"status_filter": status_filter,
|
||||
"search": search,
|
||||
"status_choices": DestinataerEmailEingang.STATUS_CHOICES,
|
||||
"counts": {
|
||||
"gesamt": DestinataerEmailEingang.objects.count(),
|
||||
"neu": DestinataerEmailEingang.objects.filter(status="neu").count(),
|
||||
"unbekannt": DestinataerEmailEingang.objects.filter(status="unbekannt").count(),
|
||||
"fehler": DestinataerEmailEingang.objects.filter(status="fehler").count(),
|
||||
},
|
||||
}
|
||||
return render(request, "stiftung/email_eingang/list.html", context)
|
||||
|
||||
|
||||
@login_required
|
||||
def email_eingang_detail(request, pk):
|
||||
"""Detailansicht einer eingegangenen E-Mail mit Möglichkeit zur manuellen Zuordnung."""
|
||||
eingang = get_object_or_404(DestinataerEmailEingang, pk=pk)
|
||||
|
||||
if request.method == "POST":
|
||||
action = request.POST.get("action")
|
||||
|
||||
if action == "assign_destinataer":
|
||||
dest_id = request.POST.get("destinataer_id")
|
||||
if dest_id:
|
||||
try:
|
||||
destinataer = Destinataer.objects.get(pk=dest_id)
|
||||
eingang.destinataer = destinataer
|
||||
eingang.status = "zugewiesen"
|
||||
eingang.save()
|
||||
messages.success(
|
||||
request,
|
||||
f"E-Mail wurde {destinataer} zugeordnet.",
|
||||
)
|
||||
except Destinataer.DoesNotExist:
|
||||
messages.error(request, "Destinatär nicht gefunden.")
|
||||
return redirect("email_eingang_detail", pk=pk)
|
||||
|
||||
elif action == "mark_verarbeitet":
|
||||
eingang.status = "verarbeitet"
|
||||
eingang.notizen = request.POST.get("notizen", eingang.notizen)
|
||||
eingang.save()
|
||||
messages.success(request, "E-Mail als verarbeitet markiert.")
|
||||
return redirect("email_eingang_list")
|
||||
|
||||
elif action == "save_notizen":
|
||||
eingang.notizen = request.POST.get("notizen", "")
|
||||
eingang.save()
|
||||
messages.success(request, "Notizen gespeichert.")
|
||||
return redirect("email_eingang_detail", pk=pk)
|
||||
|
||||
# Paperless-Links zusammenstellen
|
||||
paperless_links = eingang.get_paperless_links()
|
||||
|
||||
# DokumentLinks für diese E-Mail (über paperless_dokument_ids)
|
||||
dokument_links = []
|
||||
if eingang.paperless_dokument_ids:
|
||||
dokument_links = DokumentLink.objects.filter(
|
||||
paperless_document_id__in=eingang.paperless_dokument_ids
|
||||
)
|
||||
|
||||
# Alle aktiven Destinatäre für manuelle Zuordnung
|
||||
alle_destinataere = Destinataer.objects.filter(aktiv=True).order_by("nachname", "vorname")
|
||||
|
||||
context = {
|
||||
"title": f"E-Mail-Eingang: {eingang}",
|
||||
"eingang": eingang,
|
||||
"paperless_links": paperless_links,
|
||||
"dokument_links": dokument_links,
|
||||
"alle_destinataere": alle_destinataere,
|
||||
}
|
||||
return render(request, "stiftung/email_eingang/detail.html", context)
|
||||
|
||||
|
||||
@login_required
|
||||
def email_eingang_poll_trigger(request):
|
||||
"""Löst den IMAP-Poll-Task manuell aus (für Tests und manuelle Verarbeitung)."""
|
||||
if request.method == "POST":
|
||||
from stiftung.tasks import poll_destinataer_emails
|
||||
try:
|
||||
task = poll_destinataer_emails.delay()
|
||||
messages.success(
|
||||
request,
|
||||
f"E-Mail-Abruf wurde gestartet (Task-ID: {task.id}). "
|
||||
"Bitte Seite in ca. 30 Sekunden neu laden.",
|
||||
)
|
||||
except Exception as exc:
|
||||
messages.error(request, f"Fehler beim Starten des Tasks: {exc}")
|
||||
return redirect("email_eingang_list")
|
||||
|
||||
227
app/templates/stiftung/email_eingang/detail.html
Normal file
227
app/templates/stiftung/email_eingang/detail.html
Normal file
@@ -0,0 +1,227 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load humanize %}
|
||||
|
||||
{% block title %}E-Mail-Eingang Detail - van Hees-Theyssen-Vogel'sche Stiftung{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="d-sm-flex align-items-center justify-content-between mb-4">
|
||||
<h1 class="h3 mb-0 text-gray-800">
|
||||
<i class="fas fa-envelope me-2"></i>E-Mail-Eingang
|
||||
</h1>
|
||||
<a href="{% url 'stiftung:email_eingang_list' %}" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="fas fa-arrow-left me-1"></i>Zurück zur Übersicht
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<!-- Linke Spalte: E-Mail-Details -->
|
||||
<div class="col-lg-8">
|
||||
<div class="card mb-4">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span><i class="fas fa-envelope-open me-2"></i>E-Mail-Details</span>
|
||||
<span>
|
||||
{% if eingang.status == "neu" %}<span class="badge bg-warning text-dark">Neu</span>
|
||||
{% elif eingang.status == "zugewiesen" %}<span class="badge bg-primary">Zugewiesen</span>
|
||||
{% elif eingang.status == "verarbeitet" %}<span class="badge bg-success">Verarbeitet</span>
|
||||
{% elif eingang.status == "unbekannt" %}<span class="badge bg-danger">Unbekannter Absender</span>
|
||||
{% elif eingang.status == "fehler" %}<span class="badge bg-secondary">Fehler</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<dl class="row">
|
||||
<dt class="col-sm-3">Eingangsdatum</dt>
|
||||
<dd class="col-sm-9">{{ eingang.eingangsdatum|date:"d.m.Y H:i" }} Uhr</dd>
|
||||
|
||||
<dt class="col-sm-3">Absender</dt>
|
||||
<dd class="col-sm-9">
|
||||
{% if eingang.absender_name %}{{ eingang.absender_name }} <{% endif %}
|
||||
<a href="mailto:{{ eingang.absender_email }}">{{ eingang.absender_email }}</a>
|
||||
{% if eingang.absender_name %}>{% endif %}
|
||||
</dd>
|
||||
|
||||
<dt class="col-sm-3">Betreff</dt>
|
||||
<dd class="col-sm-9">{{ eingang.betreff|default:"(kein Betreff)" }}</dd>
|
||||
|
||||
<dt class="col-sm-3">Destinatär</dt>
|
||||
<dd class="col-sm-9">
|
||||
{% if eingang.destinataer %}
|
||||
<a href="{% url 'stiftung:destinataer_detail' eingang.destinataer.pk %}">
|
||||
{{ eingang.destinataer }}
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="text-danger"><i class="fas fa-exclamation-circle me-1"></i>Nicht zugeordnet</span>
|
||||
{% endif %}
|
||||
</dd>
|
||||
|
||||
{% if eingang.quartalsnachweis %}
|
||||
<dt class="col-sm-3">Quartalsnachweis</dt>
|
||||
<dd class="col-sm-9">
|
||||
Q{{ eingang.quartalsnachweis.quartal }} / {{ eingang.quartalsnachweis.jahr }}
|
||||
</dd>
|
||||
{% endif %}
|
||||
</dl>
|
||||
|
||||
{% if eingang.email_text %}
|
||||
<hr>
|
||||
<h6 class="text-muted"><i class="fas fa-align-left me-1"></i>E-Mail-Text</h6>
|
||||
<div class="bg-light rounded p-3" style="white-space: pre-wrap; font-family: monospace; font-size: 0.85rem; max-height: 400px; overflow-y: auto;">{{ eingang.email_text }}</div>
|
||||
{% endif %}
|
||||
|
||||
{% if eingang.fehler_details %}
|
||||
<hr>
|
||||
<div class="alert alert-danger">
|
||||
<strong><i class="fas fa-exclamation-triangle me-1"></i>Fehlerdetails:</strong>
|
||||
<pre class="mb-0 mt-1" style="font-size: 0.8rem;">{{ eingang.fehler_details }}</pre>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Anhänge / Paperless-Dokumente -->
|
||||
{% if dokument_links %}
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<i class="fas fa-paperclip me-2"></i>Anhänge in Paperless-NGX
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Titel</th>
|
||||
<th>Kontext</th>
|
||||
<th>Paperless-ID</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for link in dokument_links %}
|
||||
<tr>
|
||||
<td>{{ link.titel }}</td>
|
||||
<td>{{ link.get_kontext_display }}</td>
|
||||
<td><code>{{ link.paperless_document_id }}</code></td>
|
||||
<td>
|
||||
<a href="{{ link.get_paperless_url }}" target="_blank" class="btn btn-sm btn-outline-info">
|
||||
<i class="fas fa-external-link-alt me-1"></i>Öffnen
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% elif eingang.paperless_dokument_ids %}
|
||||
<div class="alert alert-info">
|
||||
<i class="fas fa-info-circle me-1"></i>
|
||||
{{ eingang.paperless_dokument_ids|length }} Anhang/-hänge in Paperless hochgeladen
|
||||
(IDs: {{ eingang.paperless_dokument_ids|join:", " }}), aber noch kein DokumentLink erstellt.
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card mb-4">
|
||||
<div class="card-body text-muted text-center py-3">
|
||||
<i class="fas fa-paperclip me-1"></i>Keine Anhänge in dieser E-Mail.
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Rechte Spalte: Aktionen -->
|
||||
<div class="col-lg-4">
|
||||
|
||||
<!-- Manuelle Destinatär-Zuordnung -->
|
||||
{% if not eingang.destinataer or eingang.status == "unbekannt" %}
|
||||
<div class="card mb-4 border-warning">
|
||||
<div class="card-header bg-warning text-dark">
|
||||
<i class="fas fa-user-plus me-2"></i>Destinatär manuell zuordnen
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="small text-muted">
|
||||
Die E-Mail-Adresse <strong>{{ eingang.absender_email }}</strong>
|
||||
konnte keinem Destinatär automatisch zugeordnet werden.
|
||||
Bitte wählen Sie den passenden Destinatär aus.
|
||||
</p>
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="assign_destinataer">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Destinatär</label>
|
||||
<select class="form-select" name="destinataer_id" required>
|
||||
<option value="">– Bitte wählen –</option>
|
||||
{% for d in alle_destinataere %}
|
||||
<option value="{{ d.pk }}">{{ d.nachname }}, {{ d.vorname }}
|
||||
{% if d.email %} ({{ d.email }}){% endif %}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-warning w-100">
|
||||
<i class="fas fa-link me-1"></i>Zuordnen & Speichern
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Als verarbeitet markieren -->
|
||||
{% if eingang.status != "verarbeitet" %}
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<i class="fas fa-check-circle me-2"></i>Als verarbeitet markieren
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="mark_verarbeitet">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Interne Notiz (optional)</label>
|
||||
<textarea class="form-control" name="notizen" rows="3"
|
||||
placeholder="Z. B. 'Studiennachweis für WS 2025/26 eingegangen und geprüft.'">{{ eingang.notizen }}</textarea>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-success w-100">
|
||||
<i class="fas fa-check me-1"></i>Verarbeitet
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Notizen bearbeiten -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<i class="fas fa-sticky-note me-2"></i>Interne Notizen
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="save_notizen">
|
||||
<div class="mb-3">
|
||||
<textarea class="form-control" name="notizen" rows="5"
|
||||
placeholder="Interne Notizen zur E-Mail...">{{ eingang.notizen }}</textarea>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-outline-secondary w-100">
|
||||
<i class="fas fa-save me-1"></i>Notizen speichern
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Metadaten -->
|
||||
<div class="card">
|
||||
<div class="card-header"><i class="fas fa-info-circle me-2"></i>Metadaten</div>
|
||||
<div class="card-body">
|
||||
<dl class="row mb-0 small">
|
||||
<dt class="col-6">Erfasst am</dt>
|
||||
<dd class="col-6">{{ eingang.created_at|date:"d.m.Y H:i" }}</dd>
|
||||
<dt class="col-6">Datensatz-ID</dt>
|
||||
<dd class="col-6 text-muted"><code>{{ eingang.pk|stringformat:"s"|slice:":8" }}…</code></dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
229
app/templates/stiftung/email_eingang/list.html
Normal file
229
app/templates/stiftung/email_eingang/list.html
Normal file
@@ -0,0 +1,229 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load humanize %}
|
||||
|
||||
{% block title %}E-Mail-Eingang (Destinatäre) - van Hees-Theyssen-Vogel'sche Stiftung{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="d-sm-flex align-items-center justify-content-between mb-4">
|
||||
<h1 class="h3 mb-0 text-gray-800">
|
||||
<i class="fas fa-envelope-open-text me-2"></i>E-Mail-Eingang (Destinatäre)
|
||||
</h1>
|
||||
<div class="d-flex gap-2">
|
||||
<form method="post" action="{% url 'stiftung:email_eingang_poll_trigger' %}" class="d-inline">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-outline-primary btn-sm">
|
||||
<i class="fas fa-sync-alt me-1"></i>Jetzt abrufen
|
||||
</button>
|
||||
</form>
|
||||
<a href="{% url 'stiftung:destinataer_list' %}" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="fas fa-arrow-left me-1"></i>Destinatäre
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Statuskarten -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-3">
|
||||
<div class="card border-left-primary h-100 py-2">
|
||||
<div class="card-body">
|
||||
<div class="row no-gutters align-items-center">
|
||||
<div class="col mr-2">
|
||||
<div class="text-xs font-weight-bold text-primary text-uppercase mb-1">Gesamt</div>
|
||||
<div class="h5 mb-0 font-weight-bold text-gray-800">{{ counts.gesamt }}</div>
|
||||
</div>
|
||||
<div class="col-auto"><i class="fas fa-envelope fa-2x text-gray-300"></i></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card border-left-warning h-100 py-2">
|
||||
<div class="card-body">
|
||||
<div class="row no-gutters align-items-center">
|
||||
<div class="col mr-2">
|
||||
<div class="text-xs font-weight-bold text-warning text-uppercase mb-1">Neu / Unbearbeitet</div>
|
||||
<div class="h5 mb-0 font-weight-bold text-gray-800">{{ counts.neu }}</div>
|
||||
</div>
|
||||
<div class="col-auto"><i class="fas fa-exclamation-circle fa-2x text-gray-300"></i></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card border-left-danger h-100 py-2">
|
||||
<div class="card-body">
|
||||
<div class="row no-gutters align-items-center">
|
||||
<div class="col mr-2">
|
||||
<div class="text-xs font-weight-bold text-danger text-uppercase mb-1">Unbekannter Absender</div>
|
||||
<div class="h5 mb-0 font-weight-bold text-gray-800">{{ counts.unbekannt }}</div>
|
||||
</div>
|
||||
<div class="col-auto"><i class="fas fa-user-times fa-2x text-gray-300"></i></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card border-left-secondary h-100 py-2">
|
||||
<div class="card-body">
|
||||
<div class="row no-gutters align-items-center">
|
||||
<div class="col mr-2">
|
||||
<div class="text-xs font-weight-bold text-secondary text-uppercase mb-1">Fehler</div>
|
||||
<div class="h5 mb-0 font-weight-bold text-gray-800">{{ counts.fehler }}</div>
|
||||
</div>
|
||||
<div class="col-auto"><i class="fas fa-exclamation-triangle fa-2x text-gray-300"></i></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header"><i class="fas fa-filter me-2"></i>Filter</div>
|
||||
<div class="card-body">
|
||||
<form method="get" class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Suche</label>
|
||||
<input type="text" class="form-control" name="q" value="{{ search }}"
|
||||
placeholder="Absender, Betreff, Destinatär...">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Status</label>
|
||||
<select class="form-select" name="status">
|
||||
<option value="">Alle</option>
|
||||
{% for value, label in status_choices %}
|
||||
<option value="{{ value }}" {% if status_filter == value %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2 d-flex align-items-end">
|
||||
<button type="submit" class="btn btn-primary w-100">
|
||||
<i class="fas fa-search me-1"></i>Filtern
|
||||
</button>
|
||||
</div>
|
||||
{% if search or status_filter %}
|
||||
<div class="col-md-2 d-flex align-items-end">
|
||||
<a href="{% url 'stiftung:email_eingang_list' %}" class="btn btn-outline-secondary w-100">
|
||||
<i class="fas fa-times me-1"></i>Zurücksetzen
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabelle -->
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span><i class="fas fa-inbox me-2"></i>Eingegangene E-Mails</span>
|
||||
<span class="text-muted small">{{ page_obj.paginator.count }} Einträge</span>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
{% if page_obj %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Datum</th>
|
||||
<th>Absender</th>
|
||||
<th>Destinatär</th>
|
||||
<th>Betreff</th>
|
||||
<th>Anhänge</th>
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for e in page_obj %}
|
||||
<tr>
|
||||
<td class="text-nowrap">
|
||||
<small>{{ e.eingangsdatum|date:"d.m.Y H:i" }}</small>
|
||||
</td>
|
||||
<td>
|
||||
<div>{{ e.absender_name|default:e.absender_email }}</div>
|
||||
{% if e.absender_name %}
|
||||
<small class="text-muted">{{ e.absender_email }}</small>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if e.destinataer %}
|
||||
<a href="{% url 'stiftung:destinataer_detail' e.destinataer.pk %}">
|
||||
{{ e.destinataer }}
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="text-danger"><i class="fas fa-question-circle me-1"></i>Unbekannt</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ e.betreff|truncatechars:60 }}</td>
|
||||
<td class="text-center">
|
||||
{% if e.paperless_dokument_ids %}
|
||||
<span class="badge bg-info">{{ e.paperless_dokument_ids|length }}</span>
|
||||
{% else %}
|
||||
<span class="text-muted">–</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if e.status == "neu" %}
|
||||
<span class="badge bg-warning text-dark">Neu</span>
|
||||
{% elif e.status == "zugewiesen" %}
|
||||
<span class="badge bg-primary">Zugewiesen</span>
|
||||
{% elif e.status == "verarbeitet" %}
|
||||
<span class="badge bg-success">Verarbeitet</span>
|
||||
{% elif e.status == "unbekannt" %}
|
||||
<span class="badge bg-danger">Unbekannt</span>
|
||||
{% elif e.status == "fehler" %}
|
||||
<span class="badge bg-secondary">Fehler</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<a href="{% url 'stiftung:email_eingang_detail' e.pk %}" class="btn btn-sm btn-outline-primary">
|
||||
<i class="fas fa-eye"></i>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if page_obj.has_other_pages %}
|
||||
<div class="d-flex justify-content-center py-3">
|
||||
<nav>
|
||||
<ul class="pagination mb-0">
|
||||
{% if page_obj.has_previous %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ page_obj.previous_page_number }}&q={{ search }}&status={{ status_filter }}">
|
||||
«
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li class="page-item disabled">
|
||||
<span class="page-link">Seite {{ page_obj.number }} von {{ page_obj.paginator.num_pages }}</span>
|
||||
</li>
|
||||
{% if page_obj.has_next %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ page_obj.next_page_number }}&q={{ search }}&status={{ status_filter }}">
|
||||
»
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<div class="text-center py-5 text-muted">
|
||||
<i class="fas fa-inbox fa-3x mb-3"></i>
|
||||
<p>Keine E-Mails gefunden.</p>
|
||||
<small>Der automatische Abruf erfolgt alle 15 Minuten. Über den Button "Jetzt abrufen" kann der Vorgang manuell ausgelöst werden.</small>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user