Generalize email system with invoice workflow and Stiftungsgeschichte category

- Rename DestinataerEmailEingang → EmailEingang with category support
  (destinataer, rechnung, land_pacht, stiftungsgeschichte, allgemein)
- Add invoice capture workflow: create Verwaltungskosten from email,
  link DMS documents as invoice attachments, track payment status
- Add Stiftungsgeschichte email category with auto-detection patterns
  (Ahnenforschung, Genealogie, Chronik, etc.) and DMS integration
- Update poll_emails task with category detection and DMS context mapping
- Show available history documents in Geschichte editor sidebar
- Consolidate DMS views, remove legacy dokument templates
- Update all detail/form templates for DMS document linking
- Add deploy.sh script and streamline compose.yml

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
SysAdmin Agent
2026-03-12 10:17:14 +00:00
parent f4fc512ad3
commit e6f4c5ba1b
44 changed files with 1076 additions and 3428 deletions

View File

@@ -124,9 +124,9 @@ CELERY_RESULT_BACKEND = os.getenv("REDIS_URL", "redis://redis:6379/0")
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",
# E-Mail-Postfach alle 15 Minuten auf neue Nachrichten pruefen
"poll-emails": {
"task": "stiftung.tasks.poll_emails",
"schedule": crontab(minute="*/15"),
},
}
@@ -140,15 +140,6 @@ 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")
PAPERLESS_REQUIRED_TAG = os.getenv("PAPERLESS_REQUIRED_TAG", "Stiftung_Destinatäre")
PAPERLESS_LAND_TAG = os.getenv("PAPERLESS_LAND_TAG", "Stiftung_Land_und_Pächter")
PAPERLESS_ADMIN_TAG = os.getenv("PAPERLESS_ADMIN_TAG", "Stiftung_Administration")
PAPERLESS_DESTINATAERE_TAG_ID = os.getenv("PAPERLESS_DESTINATAERE_TAG_ID")
PAPERLESS_LAND_TAG_ID = os.getenv("PAPERLESS_LAND_TAG_ID")
PAPERLESS_ADMIN_TAG_ID = os.getenv("PAPERLESS_ADMIN_TAG_ID")
# Authentication
LOGIN_URL = "/login/"

View File

@@ -7,90 +7,6 @@ class Command(BaseCommand):
help = "Initialize default app configuration settings"
def handle(self, *args, **options):
# Paperless Integration Settings
paperless_settings = [
{
"key": "paperless_api_url",
"display_name": "Paperless API URL",
"description": "The base URL for your Paperless-NGX API (e.g., http://paperless.example.com:8000)",
"value": "http://192.168.178.167:30070",
"default_value": "http://192.168.178.167:30070",
"setting_type": "url",
"category": "paperless",
"order": 1,
},
{
"key": "paperless_api_token",
"display_name": "Paperless API Token",
"description": "The authentication token for Paperless API access",
"value": "",
"default_value": "",
"setting_type": "text",
"category": "paperless",
"order": 2,
},
{
"key": "paperless_destinataere_tag",
"display_name": "Destinatäre Tag Name",
"description": "The tag name used to identify Destinatäre documents in Paperless",
"value": "Stiftung_Destinatäre",
"default_value": "Stiftung_Destinatäre",
"setting_type": "tag",
"category": "paperless",
"order": 3,
},
{
"key": "paperless_destinataere_tag_id",
"display_name": "Destinatäre Tag ID",
"description": "The numeric ID of the Destinatäre tag in Paperless",
"value": "210",
"default_value": "210",
"setting_type": "tag_id",
"category": "paperless",
"order": 4,
},
{
"key": "paperless_land_tag",
"display_name": "Land & Pächter Tag Name",
"description": "The tag name used to identify Land and Pächter documents in Paperless",
"value": "Stiftung_Land_und_Pächter",
"default_value": "Stiftung_Land_und_Pächter",
"setting_type": "tag",
"category": "paperless",
"order": 5,
},
{
"key": "paperless_land_tag_id",
"display_name": "Land & Pächter Tag ID",
"description": "The numeric ID of the Land & Pächter tag in Paperless",
"value": "204",
"default_value": "204",
"setting_type": "tag_id",
"category": "paperless",
"order": 6,
},
{
"key": "paperless_admin_tag",
"display_name": "Administration Tag Name",
"description": "The tag name used to identify Administration documents in Paperless",
"value": "Stiftung_Administration",
"default_value": "Stiftung_Administration",
"setting_type": "tag",
"category": "paperless",
"order": 7,
},
{
"key": "paperless_admin_tag_id",
"display_name": "Administration Tag ID",
"description": "The numeric ID of the Administration tag in Paperless",
"value": "216",
"default_value": "216",
"setting_type": "tag_id",
"category": "paperless",
"order": 8,
},
]
# E-Mail / IMAP Settings
email_settings = [
{
@@ -155,7 +71,7 @@ class Command(BaseCommand):
},
]
all_settings = paperless_settings + email_settings
all_settings = email_settings
created_count = 0
updated_count = 0

View File

@@ -0,0 +1,33 @@
# Generated by Django 5.0.6 on 2026-03-12 08:11
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('stiftung', '0048_phase3_dms_dokument_datei'),
]
operations = [
migrations.AddField(
model_name='destinataeremaileingang',
name='dokument_dateien',
field=models.ManyToManyField(blank=True, help_text='Automatisch befüllte Anhänge als Django-DMS-Dateien.', related_name='email_eingaenge', to='stiftung.dokumentdatei', verbose_name='DMS-Dokumente (Anhänge)'),
),
migrations.AlterField(
model_name='appconfiguration',
name='category',
field=models.CharField(choices=[('paperless', 'Paperless Integration'), ('email', 'E-Mail / IMAP'), ('general', 'General Settings'), ('corporate', 'Corporate Identity'), ('notifications', 'Notifications'), ('system', 'System Settings')], default='general', max_length=50, verbose_name='Category'),
),
migrations.AlterField(
model_name='appconfiguration',
name='setting_type',
field=models.CharField(choices=[('text', 'Text'), ('password', 'Password'), ('number', 'Number'), ('boolean', 'Boolean'), ('url', 'URL'), ('tag', 'Tag Name'), ('tag_id', 'Tag ID')], default='text', max_length=20, verbose_name='Type'),
),
migrations.AlterField(
model_name='destinataeremaileingang',
name='paperless_dokument_ids',
field=models.JSONField(blank=True, default=list, help_text='Veraltet wird nach vollständiger Migration entfernt. Neue Anhänge in dokument_dateien.', verbose_name='Paperless Dokument-IDs (Anhänge, veraltet)'),
),
]

View File

@@ -0,0 +1,132 @@
# Phase 4: Generalize EmailEingang + Rechnungsworkflow
# - Rename DestinataerEmailEingang → EmailEingang
# - Add kategorie, verwaltungskosten FK, land FK, verpachtung FK
# - Expand status choices (rechnung_erfasst, zahlung_gebucht)
# - Add verwaltungskosten FK to DokumentDatei
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('stiftung', '0049_phase3_email_dms_m2m'),
]
operations = [
# 1. Rename model (preserves DB table, updates Django state)
migrations.RenameModel(
old_name='DestinataerEmailEingang',
new_name='EmailEingang',
),
# 2. Add kategorie field to EmailEingang
migrations.AddField(
model_name='emaileingang',
name='kategorie',
field=models.CharField(
choices=[
('destinataer', 'Destinataer'),
('rechnung', 'Rechnung'),
('land_pacht', 'Grundstueck / Pacht'),
('allgemein', 'Allgemein'),
],
default='allgemein',
max_length=20,
verbose_name='Kategorie',
),
),
# 3. Add verwaltungskosten FK to EmailEingang
migrations.AddField(
model_name='emaileingang',
name='verwaltungskosten',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='email_eingaenge',
to='stiftung.verwaltungskosten',
verbose_name='Verwaltungskosten / Rechnung',
),
),
# 4. Add land FK to EmailEingang
migrations.AddField(
model_name='emaileingang',
name='land',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='email_eingaenge',
to='stiftung.land',
verbose_name='Laenderei',
),
),
# 5. Add verpachtung FK to EmailEingang
migrations.AddField(
model_name='emaileingang',
name='verpachtung',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='email_eingaenge',
to='stiftung.landverpachtung',
verbose_name='Verpachtung',
),
),
# 6. Update status choices on EmailEingang
migrations.AlterField(
model_name='emaileingang',
name='status',
field=models.CharField(
choices=[
('neu', 'Neu / Unbearbeitet'),
('zugewiesen', 'Destinataer zugewiesen'),
('verarbeitet', 'Verarbeitet'),
('rechnung_erfasst', 'Rechnung erfasst'),
('zahlung_gebucht', 'Zahlung gebucht'),
('unbekannt', 'Unbekannter Absender'),
('fehler', 'Fehler bei Verarbeitung'),
],
default='neu',
max_length=20,
verbose_name='Status',
),
),
# 7. Update Meta on EmailEingang
migrations.AlterModelOptions(
name='emaileingang',
options={
'ordering': ['-eingangsdatum'],
'verbose_name': 'E-Mail-Eingang',
'verbose_name_plural': 'E-Mail-Eingaenge',
},
),
# 8. Set kategorie='destinataer' for existing emails that have a destinataer FK
migrations.RunSQL(
sql="UPDATE stiftung_emaileingang SET kategorie = 'destinataer' WHERE destinataer_id IS NOT NULL;",
reverse_sql=migrations.RunSQL.noop,
),
# 9. Add verwaltungskosten FK to DokumentDatei
migrations.AddField(
model_name='dokumentdatei',
name='verwaltungskosten',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='dms_dokumente',
to='stiftung.verwaltungskosten',
verbose_name='Verwaltungskosten / Rechnung',
),
),
]

View File

@@ -0,0 +1,29 @@
# Generated by Django 5.0.6 on 2026-03-12 09:45
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('stiftung', '0050_generalize_email_rechnungsworkflow'),
]
operations = [
migrations.AlterField(
model_name='emaileingang',
name='destinataer',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='email_eingaenge', to='stiftung.destinataer', verbose_name='Destinataer'),
),
migrations.AlterField(
model_name='emaileingang',
name='dokument_dateien',
field=models.ManyToManyField(blank=True, help_text='Automatisch befuellte Anhaenge als Django-DMS-Dateien.', related_name='email_eingaenge', to='stiftung.dokumentdatei', verbose_name='DMS-Dokumente (Anhaenge)'),
),
migrations.AlterField(
model_name='emaileingang',
name='paperless_dokument_ids',
field=models.JSONField(blank=True, default=list, help_text='Veraltet wird nach vollstaendiger Migration entfernt. Neue Anhaenge in dokument_dateien.', verbose_name='Paperless Dokument-IDs (Anhaenge, veraltet)'),
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 5.0.6 on 2026-03-12 10:07
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('stiftung', '0051_alter_emaileingang_destinataer_and_more'),
]
operations = [
migrations.AlterField(
model_name='dokumentdatei',
name='kontext',
field=models.CharField(choices=[('pachtvertrag', 'Pachtvertrag'), ('antrag', 'Antrag / Förderantrag'), ('verwendungsnachweis', 'Verwendungsnachweis'), ('studiennachweis', 'Studiennachweis'), ('rechnung', 'Rechnung'), ('vertrag', 'Vertrag'), ('bericht', 'Bericht'), ('landkarte', 'Landkarte / Kataster'), ('korrespondenz', 'Korrespondenz / Brief'), ('bescheid', 'Bescheid / Behörde'), ('stiftungsgeschichte', 'Stiftungsgeschichte / Archiv'), ('anderes', 'Sonstiges')], default='anderes', max_length=30, verbose_name='Dokumententyp'),
),
migrations.AlterField(
model_name='emaileingang',
name='kategorie',
field=models.CharField(choices=[('destinataer', 'Destinataer'), ('rechnung', 'Rechnung'), ('land_pacht', 'Grundstueck / Pacht'), ('stiftungsgeschichte', 'Stiftungsgeschichte'), ('allgemein', 'Allgemein')], default='allgemein', max_length=20, verbose_name='Kategorie'),
),
]

View File

@@ -32,6 +32,7 @@ from .finanzen import ( # noqa: F401
from .destinataere import ( # noqa: F401
Destinataer,
DestinataerEmailEingang,
EmailEingang,
DestinataerNotiz,
DestinataerUnterstuetzung,
Foerderung,

View File

@@ -1104,34 +1104,79 @@ class VierteljahresNachweis(models.Model):
return None
class DestinataerEmailEingang(models.Model):
class EmailEingang(models.Model):
"""
Erfasst eingehende E-Mails von Destinatären.
Erfasst eingehende E-Mails (Destinataere, Rechnungen, Grundstuecke, Allgemein).
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.
Wird automatisch durch den Celery-Task `poll_emails` befuellt,
der das IMAP-Postfach der Stiftung (paperless@vhtv-stiftung.de) ueberwacht.
Anhaenge werden direkt als DokumentDatei im Django-DMS gespeichert.
"""
KATEGORIE_CHOICES = [
("destinataer", "Destinataer"),
("rechnung", "Rechnung"),
("land_pacht", "Grundstueck / Pacht"),
("stiftungsgeschichte", "Stiftungsgeschichte"),
("allgemein", "Allgemein"),
]
STATUS_CHOICES = [
("neu", "Neu / Unbearbeitet"),
("zugewiesen", "Destinatär zugewiesen"),
("zugewiesen", "Destinataer zugewiesen"),
("verarbeitet", "Verarbeitet"),
("rechnung_erfasst", "Rechnung erfasst"),
("zahlung_gebucht", "Zahlung gebucht"),
("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)
# Klassifizierung
kategorie = models.CharField(
max_length=20,
choices=KATEGORIE_CHOICES,
default="allgemein",
verbose_name="Kategorie",
)
# Verknuepfung zum Destinataer (None = kein Destinataer-Bezug)
destinataer = models.ForeignKey(
Destinataer,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="email_eingaenge",
verbose_name="Destinatär",
verbose_name="Destinataer",
)
# Verknuepfung zu Verwaltungskosten (Rechnungsworkflow)
verwaltungskosten = models.ForeignKey(
"Verwaltungskosten",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="email_eingaenge",
verbose_name="Verwaltungskosten / Rechnung",
)
# Verknuepfung zu Land / Verpachtung
land = models.ForeignKey(
"Land",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="email_eingaenge",
verbose_name="Laenderei",
)
verpachtung = models.ForeignKey(
"LandVerpachtung",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="email_eingaenge",
verbose_name="Verpachtung",
)
# E-Mail-Metadaten
@@ -1143,12 +1188,21 @@ class DestinataerEmailEingang(models.Model):
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)
# Anhaenge: DMS-Dokumente (Phase 3 DokumentDatei)
dokument_dateien = models.ManyToManyField(
"DokumentDatei",
blank=True,
related_name="email_eingaenge",
verbose_name="DMS-Dokumente (Anhaenge)",
help_text="Automatisch befuellte Anhaenge als Django-DMS-Dateien.",
)
# Anhaenge: Liste der Paperless-Dokument-IDs (JSON-Format, deprecated)
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",
verbose_name="Paperless Dokument-IDs (Anhaenge, veraltet)",
help_text="Veraltet wird nach vollstaendiger Migration entfernt. Neue Anhaenge in dokument_dateien.",
)
# Verarbeitungsstatus
@@ -1182,8 +1236,8 @@ class DestinataerEmailEingang(models.Model):
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)"
verbose_name = "E-Mail-Eingang"
verbose_name_plural = "E-Mail-Eingaenge"
ordering = ["-eingangsdatum"]
def __str__(self):
@@ -1191,10 +1245,18 @@ class DestinataerEmailEingang(models.Model):
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."""
"""Gibt Liste der Paperless-Dokument-URLs zurueck (deprecated)."""
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 [])
]
def get_dms_dokumente(self):
"""Gibt alle verknuepften DokumentDatei-Objekte zurueck."""
return self.dokument_dateien.all()
# Backward-compatible alias
DestinataerEmailEingang = EmailEingang

View File

@@ -30,6 +30,7 @@ class DokumentDatei(models.Model):
("landkarte", "Landkarte / Kataster"),
("korrespondenz", "Korrespondenz / Brief"),
("bescheid", "Bescheid / Behörde"),
("stiftungsgeschichte", "Stiftungsgeschichte / Archiv"),
("anderes", "Sonstiges"),
]
@@ -109,6 +110,13 @@ class DokumentDatei(models.Model):
related_name="dms_dokumente",
verbose_name="Rentmeister",
)
verwaltungskosten = models.ForeignKey(
"Verwaltungskosten",
on_delete=models.SET_NULL,
null=True, blank=True,
related_name="dms_dokumente",
verbose_name="Verwaltungskosten / Rechnung",
)
# Herkunft (optional: Verweis auf altes Paperless-Dokument zur Rückverfolgung)
paperless_dokument_id = models.IntegerField(

View File

@@ -972,6 +972,11 @@ class LandAbrechnung(models.Model):
class DokumentLink(models.Model):
"""
DEPRECATED: Paperless-basierte Dokumentverknüpfung.
Wird durch DokumentDatei (Django-natives DMS) ersetzt.
Dieses Modell bleibt für historische Daten erhalten, wird aber nicht mehr aktiv genutzt.
"""
KONTEXT_CHOICES = [
("pachtvertrag", "Pachtvertrag"),
("antrag", "Antrag"),
@@ -1020,18 +1025,6 @@ class DokumentLink(models.Model):
def __str__(self):
return f"{self.titel} ({self.get_kontext_display()})"
def get_paperless_url(self):
"""Gibt die URL zum Dokument in Paperless zurück (über Django Redirect)"""
return f"/api/paperless/documents/{self.paperless_document_id}/"
def get_paperless_thumbnail_url(self):
"""Gibt die URL zum Thumbnail in Paperless zurück"""
from django.conf import settings
if settings.PAPERLESS_API_URL:
return f"{settings.PAPERLESS_API_URL}/api/documents/{self.paperless_document_id}/thumb/"
return None
def get_verpachtung(self):
"""Gibt die verknüpfte Verpachtung zurück"""
if self.verpachtung_id:

View File

@@ -1,20 +1,20 @@
"""
Celery-Tasks für die automatische Verarbeitung von Destinatär-E-Mails.
Celery-Tasks fuer die automatische Verarbeitung eingehender 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
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 für SSL)
IMAP_USER — Benutzername (z. B. paperless@vhtv-stiftung.de)
IMAP_PORT — Port (Standard: 993 fuer SSL)
IMAP_USER — Benutzername
IMAP_PASSWORD — Passwort
IMAP_FOLDER — Ordner (Standard: INBOX)
"""
@@ -22,22 +22,39 @@ Konfiguration (Umgebungsvariablen in .env / compose.yml):
import email
import email.utils
import imaplib
import io
import logging
import mimetypes
import re
from datetime import datetime, timezone as dt_timezone
from email.header import decode_header, make_header
import time
import requests
from celery import shared_task
from django.conf import settings
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
# ---------------------------------------------------------------------------
@@ -54,7 +71,7 @@ def _decode_header_value(raw_value: str) -> str:
def _parse_email_date(date_str: str) -> datetime:
"""Parst das E-Mail-Datum und gibt ein timezone-aware datetime zurück."""
"""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:
@@ -86,148 +103,88 @@ def _get_email_body(msg) -> str:
return "\n".join(body_parts).strip()
def _poll_paperless_task(api_url: str, headers: dict, task_id: str, filename: str, max_wait: int = 120) -> int | None:
def _detect_kategorie(betreff: str, email_text: str, has_destinataer: bool) -> str:
"""
Pollt den Paperless-ngx Task-Status bis das Dokument verarbeitet wurde.
Gibt die Dokument-ID zurück, oder None bei Fehler/Timeout.
Erkennt die Kategorie einer Email anhand von Betreff und Body.
Gibt 'destinataer', 'rechnung', 'stiftungsgeschichte', oder 'allgemein' zurueck.
"""
task_url = f"{api_url.rstrip('/')}/api/tasks/?task_id={task_id}"
waited = 0
interval = 2
while waited < max_wait:
try:
resp = requests.get(task_url, headers=headers, timeout=30)
resp.raise_for_status()
tasks = resp.json()
if tasks:
task = tasks[0] if isinstance(tasks, list) else tasks
status = task.get("status", "")
if status == "SUCCESS":
related = task.get("related_document")
if related:
# related_document can be a URL like "/api/documents/42/"
# or just an ID
if isinstance(related, str):
parts = related.rstrip("/").split("/")
try:
return int(parts[-1])
except (ValueError, IndexError):
logger.warning("Konnte Dokument-ID nicht aus '%s' extrahieren.", related)
return None
return int(related)
# Some versions use result_id or document_id
for key in ("result_id", "document_id", "id"):
val = task.get(key)
if val is not None:
try:
return int(val)
except (ValueError, TypeError):
pass
logger.warning("Task %s erfolgreich aber keine Dokument-ID gefunden: %s", task_id, task)
return None
elif status == "FAILURE":
logger.error("Paperless-Task %s fehlgeschlagen für '%s': %s", task_id, filename, task.get("result"))
return None
except requests.RequestException as exc:
logger.warning("Fehler beim Abfragen von Paperless-Task %s: %s", task_id, exc)
time.sleep(interval)
waited += interval
logger.error("Timeout beim Warten auf Paperless-Task %s für '%s' (%ds).", task_id, filename, max_wait)
return None
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 _upload_to_paperless(content: bytes, filename: str, destinataer=None, betreff: str = "") -> int | None:
def _save_to_dms(content: bytes, filename: str, destinataer=None, betreff: str = "", kontext: str = "korrespondenz"):
"""
Lädt einen Anhang in Paperless-NGX hoch.
Speichert einen E-Mail-Anhang direkt als DokumentDatei im Django-DMS.
Gibt die neue Paperless-Dokument-ID zurück, oder None bei Fehler.
Gibt das DokumentDatei-Objekt zurueck, oder None bei Fehler.
"""
api_url = getattr(settings, "PAPERLESS_API_URL", None)
api_token = getattr(settings, "PAPERLESS_API_TOKEN", None)
from stiftung.models import DokumentDatei
from django.core.files.base import ContentFile
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
safe_filename = filename or "anhang.bin"
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)}
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:
response = requests.post(
upload_url,
headers=headers,
data=form_data,
files=files,
timeout=300, # 5 Minuten für große Anhänge
doc = DokumentDatei(
titel=titel[:255],
beschreibung=beschreibung,
kontext=kontext,
dateiname_original=safe_filename,
dateityp=mime_type,
dateigroesse=len(content),
destinataer=destinataer,
)
response.raise_for_status()
result = response.json()
# Paperless-ngx post_document returns a task UUID string.
# We need to poll the task status to get the actual document ID.
if isinstance(result, str):
task_id = result
doc_id = _poll_paperless_task(api_url, headers, task_id, safe_filename)
elif isinstance(result, int):
doc_id = result
else:
doc_id = 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)
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_destinataer_emails")
def poll_destinataer_emails(self, search_all_recent_days=0):
@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 ausgeführt.
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). Nützlich für manuellen Abruf.
durchsucht (nicht nur ungelesene). Nuetzlich fuer manuellen Abruf.
"""
from stiftung.models import Destinataer, DestinataerEmailEingang, DokumentLink
from stiftung.models import Destinataer, EmailEingang
# IMAP-Konfiguration: DB (AppConfiguration) mit Fallback auf env/settings
from stiftung.utils.config import get_config
@@ -242,12 +199,12 @@ def poll_destinataer_emails(self, search_all_recent_days=0):
if not all([imap_host, imap_user, imap_password]):
logger.warning(
"IMAP nicht konfiguriert (IMAP_HOST, IMAP_USER, IMAP_PASSWORD fehlen). "
"Task wird übersprungen."
"Task wird uebersprungen."
)
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
# 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="")
@@ -257,8 +214,8 @@ def poll_destinataer_emails(self, search_all_recent_days=0):
errors = 0
try:
# IMAP-Verbindung aufbauen (mit Socket-Timeout für große E-Mails)
imap_timeout = 120 # Sekunden genug für große Anhänge
# 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:
@@ -289,7 +246,7 @@ def poll_destinataer_emails(self, search_all_recent_days=0):
# 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_email_addr = absender_email_raw.lower().strip()
absender_name = _decode_header_value(absender_name_raw)
# Betreff
@@ -301,30 +258,48 @@ def poll_destinataer_emails(self, search_all_recent_days=0):
# 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"
# Destinataer zuordnen
destinataer = destinataer_by_email.get(absender_email_addr)
# Prüfen ob diese E-Mail bereits verarbeitet wurde (Duplikat-Check via
# 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 = DestinataerEmailEingang.objects.filter(
absender_email=absender_email,
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 übersprungen.",
absender_email, eingangsdatum,
"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 = DestinataerEmailEingang(
eingang = EmailEingang(
kategorie=kategorie,
destinataer=destinataer,
absender_email=absender_email,
absender_email=absender_email_addr,
absender_name=absender_name,
betreff=betreff[:500],
eingangsdatum=eingangsdatum,
@@ -332,8 +307,8 @@ def poll_destinataer_emails(self, search_all_recent_days=0):
status=status,
)
# Anhänge verarbeiten
paperless_ids = []
# 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 "")
@@ -342,51 +317,42 @@ def poll_destinataer_emails(self, search_all_recent_days=0):
content = part.get_payload(decode=True)
if not content:
logger.warning(
"Anhang '%s' hat keinen Inhalt (möglicherweise zu groß oder beschädigt) wird übersprungen.",
"Anhang '%s' hat keinen Inhalt wird uebersprungen.",
filename,
)
continue
doc_id = _upload_to_paperless(
doc = _save_to_dms(
content=content,
filename=filename,
destinataer=destinataer,
betreff=betreff,
kontext=dms_kontext,
)
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,
)
if doc:
dms_dokumente.append(doc)
eingang.paperless_dokument_ids = paperless_ids
if paperless_ids:
eingang.status = "verarbeitet" if destinataer else "unbekannt"
if dms_dokumente:
eingang.status = "verarbeitet" if destinataer else status
eingang.save()
if dms_dokumente:
eingang.dokument_dateien.set(dms_dokumente)
# 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),
"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 nächsten Lauf erneut versucht
# Nicht als gelesen markieren wird beim naechsten Lauf erneut versucht
mail.close()
mail.logout()
@@ -395,9 +361,13 @@ def poll_destinataer_emails(self, search_all_recent_days=0):
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)
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_destinataer_emails abgeschlossen: %s", result)
logger.info("poll_emails abgeschlossen: %s", result)
return result
# Backward-compatible alias for existing Celery Beat schedules
poll_destinataer_emails = poll_emails

View File

@@ -140,46 +140,7 @@ urlpatterns = [
views.foerderung_delete,
name="foerderung_delete",
),
# Dokumente URLs
path("dokumente/", views.dokument_list, name="dokument_list"),
path("dokumente/<uuid:pk>/", views.dokument_detail, name="dokument_detail"),
path("dokumente/neu/", views.dokument_create, name="dokument_create"),
path(
"dokumente/<uuid:pk>/bearbeiten/", views.dokument_update, name="dokument_update"
),
path(
"dokumente/<uuid:pk>/loeschen/", views.dokument_delete, name="dokument_delete"
),
# Dokumentenverwaltung (Paperless-Integration, Verwaltung & Verknüpfung)
path(
"dokumente/verwaltung/", views.dokument_management, name="dokument_management"
),
# Legacy document URLs removed - use dokument_management instead
# Dokument-Verknüpfung
path(
"api/link-document/search/",
views.link_document_search,
name="link_document_search",
),
path(
"api/link-document/create/",
views.link_document_create,
name="link_document_create",
),
path(
"api/link-document/list/", views.link_document_list, name="link_document_list"
),
path(
"api/link-document/update/",
views.link_document_update,
name="link_document_update",
),
path(
"api/link-document/delete/<uuid:link_id>/",
views.link_document_delete,
name="link_document_delete",
),
# Legacy dokument_verknuepfung URL removed - use dokument_management instead
# Dokumente-URLs (DMS) Legacy-Paperless-URLs entfernt (Phase 3)
# Jahresbericht URLs
path("berichte/", views.bericht_list, name="bericht_list"),
path(
@@ -355,19 +316,6 @@ urlpatterns = [
# API URLs
path("api/land-stats/", views.land_stats_api, name="land_stats_api"),
path("api/health/", views.health_check, name="health_check"),
path("api/paperless/ping/", views.paperless_ping, name="paperless_ping"),
path(
"api/paperless/documents/",
views.paperless_documents,
name="paperless_documents",
),
path("api/paperless/tags/", views.paperless_tags_only, name="paperless_tags_only"),
path("api/paperless/debug/", views.paperless_debug, name="paperless_debug"),
path(
"api/paperless/documents/<int:doc_id>/",
views.paperless_document_redirect,
name="paperless_document_redirect",
),
# Veranstaltungsmodul
path("veranstaltungen/", views.veranstaltung_list, name="veranstaltung_list"),
path("veranstaltungen/neu/", views.veranstaltung_create, name="veranstaltung_create"),

View File

@@ -30,25 +30,6 @@ def get_config(key, default=None, fallback_to_settings=True):
return value if value is not None else default
def get_paperless_config():
"""
Get all Paperless-related configuration values
Returns:
dict: Dictionary containing all Paperless configuration
"""
return {
"api_url": get_config("paperless_api_url"),
"api_token": get_config("paperless_api_token"),
"destinataere_tag": get_config("paperless_destinataere_tag"),
"destinataere_tag_id": get_config("paperless_destinataere_tag_id"),
"land_tag": get_config("paperless_land_tag"),
"land_tag_id": get_config("paperless_land_tag_id"),
"admin_tag": get_config("paperless_admin_tag"),
"admin_tag_id": get_config("paperless_admin_tag_id"),
}
def set_config(key, value, **kwargs):
"""
Set a configuration value
@@ -63,13 +44,3 @@ def set_config(key, value, **kwargs):
"""
return AppConfiguration.set_setting(key, value, **kwargs)
def is_paperless_configured():
"""
Check if Paperless is properly configured
Returns:
bool: True if API URL and token are configured
"""
config = get_paperless_config()
return bool(config["api_url"] and config["api_token"])

View File

@@ -23,25 +23,6 @@ from .destinataere import ( # noqa: F401
destinataer_export,
)
from .dokumente import ( # noqa: F401
dokument_management,
paperless_document_redirect,
dokument_list,
dokument_detail,
dokument_create,
dokument_update,
dokument_delete,
paperless_ping,
paperless_documents,
paperless_debug,
paperless_tags_only,
link_document_search,
create_paechter_link_for_verpachtung,
link_document_create,
link_document_list,
link_document_update,
link_document_delete,
)
from .finanzen import ( # noqa: F401
bericht_list,

View File

@@ -35,7 +35,7 @@ from stiftung.models import (AppConfiguration, AuditLog, BackupJob, BankTransact
BriefVorlage, CSVImport, Destinataer,
DestinataerEmailEingang, DestinataerNotiz,
DestinataerUnterstuetzung,
DokumentLink, Foerderung, GeschichteBild, GeschichteSeite,
DokumentDatei, DokumentLink, Foerderung, GeschichteBild, GeschichteSeite,
Land, LandAbrechnung, LandVerpachtung, Paechter, Person,
Rentmeister, StiftungsKalenderEintrag, StiftungsKonto,
UnterstuetzungWiederkehrend, Veranstaltung,
@@ -268,8 +268,8 @@ def destinataer_detail(request, pk):
destinataer = get_object_or_404(Destinataer, pk=pk)
# Alle mit diesem Destinatär verknüpften Dokumente laden
verknuepfte_dokumente = DokumentLink.objects.filter(
destinataer_id=destinataer.pk
verknuepfte_dokumente = DokumentDatei.objects.filter(
destinataer=destinataer
).order_by("kontext", "titel")
# Förderungen für diesen Destinatär laden

View File

@@ -115,6 +115,7 @@ def dms_upload(request):
"land_id": request.GET.get("land", ""),
"paechter_id": request.GET.get("paechter", ""),
"verpachtung_id": request.GET.get("verpachtung", ""),
"foerderung_id": request.GET.get("foerderung", ""),
"kontext": request.GET.get("kontext", "anderes"),
}
@@ -144,6 +145,7 @@ def dms_upload(request):
land_id = request.POST.get("land_id", "").strip()
paechter_id = request.POST.get("paechter_id", "").strip()
verp_id = request.POST.get("verpachtung_id", "").strip()
foerd_id = request.POST.get("foerderung_id", "").strip()
if dest_id:
try:
@@ -165,6 +167,11 @@ def dms_upload(request):
dok.verpachtung_id = verp_id
except Exception:
pass
if foerd_id:
try:
dok.foerderung_id = foerd_id
except Exception:
pass
_save_upload(request, dok)

File diff suppressed because it is too large Load Diff

View File

@@ -35,7 +35,7 @@ from stiftung.models import (AppConfiguration, AuditLog, BackupJob, BankTransact
BriefVorlage, CSVImport, Destinataer,
DestinataerEmailEingang, DestinataerNotiz,
DestinataerUnterstuetzung,
DokumentLink, Foerderung, GeschichteBild, GeschichteSeite,
DokumentDatei, DokumentLink, Foerderung, GeschichteBild, GeschichteSeite,
Land, LandAbrechnung, LandVerpachtung, Paechter, Person,
Rentmeister, StiftungsKalenderEintrag, StiftungsKonto,
UnterstuetzungWiederkehrend, Veranstaltung,
@@ -138,8 +138,8 @@ def foerderung_detail(request, pk):
)
# Alle mit dieser Förderung verknüpften Dokumente laden
verknuepfte_dokumente = DokumentLink.objects.filter(
foerderung_id=foerderung.pk
verknuepfte_dokumente = DokumentDatei.objects.filter(
foerderung=foerderung
).order_by("kontext", "titel")
context = {

View File

@@ -33,7 +33,7 @@ from rest_framework.response import Response
from stiftung.models import (AppConfiguration, AuditLog, BackupJob, BankTransaction,
BriefVorlage, CSVImport, Destinataer,
DestinataerEmailEingang, DestinataerNotiz,
DestinataerEmailEingang, EmailEingang, DestinataerNotiz,
DestinataerUnterstuetzung,
DokumentLink, Foerderung, GeschichteBild, GeschichteSeite,
Land, LandAbrechnung, LandVerpachtung, Paechter, Person,
@@ -105,9 +105,16 @@ def geschichte_create(request):
else:
form = GeschichteSeiteForm()
# Verfuegbare Stiftungsgeschichte-Dokumente aus DMS
from stiftung.models import DokumentDatei
geschichte_dokumente = DokumentDatei.objects.filter(
kontext="stiftungsgeschichte"
).order_by("-erstellt_am")[:20]
context = {
'form': form,
'title': 'Neue Geschichtsseite'
'title': 'Neue Geschichtsseite',
'geschichte_dokumente': geschichte_dokumente,
}
return render(request, 'stiftung/geschichte/form.html', context)
@@ -134,10 +141,17 @@ def geschichte_edit(request, slug):
else:
form = GeschichteSeiteForm(instance=seite)
# Verfuegbare Stiftungsgeschichte-Dokumente aus DMS
from stiftung.models import DokumentDatei
geschichte_dokumente = DokumentDatei.objects.filter(
kontext="stiftungsgeschichte"
).order_by("-erstellt_am")[:20]
context = {
'form': form,
'seite': seite,
'title': f'Bearbeiten: {seite.titel}'
'title': f'Bearbeiten: {seite.titel}',
'geschichte_dokumente': geschichte_dokumente,
}
return render(request, 'stiftung/geschichte/form.html', context)
@@ -583,16 +597,19 @@ def kalender_view(request):
@login_required
def email_eingang_list(request):
"""
Übersicht aller eingegangenen E-Mails von Destinatären.
Zeigt ungeklärte Absender zuerst, dann chronologisch absteigend.
Uebersicht aller eingegangenen E-Mails.
Filtert nach Status und Kategorie, zeigt ungeklaerte Absender zuerst.
"""
status_filter = request.GET.get("status", "")
kategorie_filter = request.GET.get("kategorie", "")
search = request.GET.get("q", "").strip()
qs = DestinataerEmailEingang.objects.select_related("destinataer", "quartalsnachweis")
qs = EmailEingang.objects.select_related("destinataer", "quartalsnachweis", "verwaltungskosten")
if status_filter:
qs = qs.filter(status=status_filter)
if kategorie_filter:
qs = qs.filter(kategorie=kategorie_filter)
if search:
qs = qs.filter(
Q(absender_email__icontains=search)
@@ -604,7 +621,7 @@ def email_eingang_list(request):
# Unbekannte Absender zuerst, dann nach Datum absteigend
qs = qs.order_by(
"status", # "unbekannt" kommt alphabetisch vor "verarbeitet" / "zugewiesen"
"status",
"-eingangsdatum",
)
@@ -612,16 +629,19 @@ def email_eingang_list(request):
page_obj = paginator.get_page(request.GET.get("page"))
context = {
"title": "E-Mail-Eingang (Destinatäre)",
"title": "E-Mail-Eingang",
"page_obj": page_obj,
"status_filter": status_filter,
"kategorie_filter": kategorie_filter,
"search": search,
"status_choices": DestinataerEmailEingang.STATUS_CHOICES,
"status_choices": EmailEingang.STATUS_CHOICES,
"kategorie_choices": EmailEingang.KATEGORIE_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(),
"gesamt": EmailEingang.objects.count(),
"neu": EmailEingang.objects.filter(status="neu").count(),
"unbekannt": EmailEingang.objects.filter(status="unbekannt").count(),
"rechnung": EmailEingang.objects.filter(kategorie="rechnung").count(),
"fehler": EmailEingang.objects.filter(status="fehler").count(),
},
}
return render(request, "stiftung/email_eingang/list.html", context)
@@ -629,8 +649,8 @@ def email_eingang_list(request):
@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)
"""Detailansicht einer eingegangenen E-Mail mit Zuordnung und Rechnungserfassung."""
eingang = get_object_or_404(EmailEingang, pk=pk)
if request.method == "POST":
action = request.POST.get("action")
@@ -641,19 +661,62 @@ def email_eingang_detail(request, pk):
try:
destinataer = Destinataer.objects.get(pk=dest_id)
eingang.destinataer = destinataer
eingang.kategorie = "destinataer"
eingang.status = "zugewiesen"
eingang.save()
# Verknüpfte DokumentLinks ebenfalls dem Destinatär zuordnen
if eingang.paperless_dokument_ids:
DokumentLink.objects.filter(
paperless_document_id__in=eingang.paperless_dokument_ids
).update(destinataer_id=destinataer.pk)
messages.success(
request,
f"E-Mail wurde {destinataer} zugeordnet.",
eingang.dokument_dateien.filter(destinataer__isnull=True).update(
destinataer=destinataer
)
messages.success(request, f"E-Mail wurde {destinataer} zugeordnet.")
except Destinataer.DoesNotExist:
messages.error(request, "Destinatär nicht gefunden.")
messages.error(request, "Destinataer nicht gefunden.")
return redirect("stiftung:email_eingang_detail", pk=pk)
elif action == "erfasse_rechnung":
# Erstelle Verwaltungskosten-Eintrag aus Email
bezeichnung = request.POST.get("bezeichnung", eingang.betreff[:200]).strip()
betrag = request.POST.get("betrag", "0").strip().replace(",", ".")
kategorie = request.POST.get("vk_kategorie", "rechnung_intern")
lieferant = request.POST.get("lieferant", eingang.absender_name or eingang.absender_email).strip()
rechnungsnummer = request.POST.get("rechnungsnummer", "").strip()
try:
from decimal import Decimal
vk = Verwaltungskosten(
bezeichnung=bezeichnung[:200],
kategorie=kategorie,
betrag=Decimal(betrag) if betrag else Decimal("0"),
datum=eingang.eingangsdatum.date(),
lieferant_firma=lieferant[:200],
rechnungsnummer=rechnungsnummer[:100],
status="erhalten",
beschreibung=f"Automatisch erfasst aus E-Mail-Eingang.\nBetreff: {eingang.betreff}\nAbsender: {eingang.absender_email}",
)
vk.save()
# Verknuepfe Email mit Verwaltungskosten
eingang.verwaltungskosten = vk
eingang.kategorie = "rechnung"
eingang.status = "rechnung_erfasst"
eingang.save()
# Verknuepfe angehaengte Dokumente mit Verwaltungskosten
for dok in eingang.dokument_dateien.all():
dok.verwaltungskosten = vk
dok.kontext = "rechnung"
dok.save()
messages.success(request, f'Rechnung "{bezeichnung}" erfasst (€{betrag}).')
except Exception as exc:
messages.error(request, f"Fehler beim Erfassen der Rechnung: {exc}")
return redirect("stiftung:email_eingang_detail", pk=pk)
elif action == "set_kategorie":
new_kategorie = request.POST.get("kategorie", "")
if new_kategorie in dict(EmailEingang.KATEGORIE_CHOICES):
eingang.kategorie = new_kategorie
eingang.save()
messages.success(request, f"Kategorie auf '{dict(EmailEingang.KATEGORIE_CHOICES)[new_kategorie]}' gesetzt.")
return redirect("stiftung:email_eingang_detail", pk=pk)
elif action == "mark_verarbeitet":
@@ -669,38 +732,29 @@ def email_eingang_detail(request, pk):
messages.success(request, "Notizen gespeichert.")
return redirect("stiftung:email_eingang_detail", pk=pk)
# Paperless-Links zusammenstellen
paperless_links = eingang.get_paperless_links()
# DMS-Dokumente
dms_dokumente = eingang.dokument_dateien.all().order_by("erstellt_am")
# 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 aktiven Destinataere fuer 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,
"dms_dokumente": dms_dokumente,
"alle_destinataere": alle_destinataere,
"vk_kategorie_choices": Verwaltungskosten.KATEGORIE_CHOICES,
}
return render(request, "stiftung/email_eingang/detail.html", context)
@login_required
def email_eingang_poll_trigger(request):
"""Löst den IMAP-Poll manuell aus sucht alle E-Mails der letzten 30 Tage."""
"""Loest den IMAP-Poll manuell aus sucht alle E-Mails der letzten 30 Tage."""
if request.method == "POST":
from stiftung.tasks import poll_destinataer_emails
from stiftung.tasks import poll_emails
try:
# Synchron ausführen für sofortiges Feedback; sucht auch bereits
# gelesene E-Mails der letzten 30 Tage (Duplikate werden übersprungen).
result = poll_destinataer_emails.apply(kwargs={"search_all_recent_days": 30}).get(timeout=300)
result = poll_emails.apply(kwargs={"search_all_recent_days": 30}).get(timeout=300)
processed = result.get("processed", 0) if isinstance(result, dict) else 0
if result and result.get("status") == "skipped":
messages.warning(request, "IMAP ist nicht konfiguriert. Bitte Einstellungen unter Administration → E-Mail / IMAP prüfen.")
@@ -723,8 +777,8 @@ def email_eingang_poll_trigger(request):
@login_required
def email_eingang_delete(request, pk):
"""Löscht eine eingegangene E-Mail."""
eingang = get_object_or_404(DestinataerEmailEingang, pk=pk)
"""Loescht eine eingegangene E-Mail."""
eingang = get_object_or_404(EmailEingang, pk=pk)
if request.method == "POST":
betreff = eingang.betreff or "(kein Betreff)"
eingang.delete()

View File

@@ -11,8 +11,6 @@ from decimal import Decimal
import qrcode
import qrcode.image.svg
import requests
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
@@ -35,7 +33,7 @@ from stiftung.models import (AppConfiguration, AuditLog, BackupJob, BankTransact
BriefVorlage, CSVImport, Destinataer,
DestinataerEmailEingang, DestinataerNotiz,
DestinataerUnterstuetzung,
DokumentLink, Foerderung, GeschichteBild, GeschichteSeite,
DokumentDatei, DokumentLink, Foerderung, GeschichteBild, GeschichteSeite,
Land, LandAbrechnung, LandVerpachtung, Paechter, Person,
Rentmeister, StiftungsKalenderEintrag, StiftungsKonto,
UnterstuetzungWiederkehrend, Veranstaltung,
@@ -143,8 +141,8 @@ def paechter_detail(request, pk):
paechter = get_object_or_404(Paechter, pk=pk)
# Alle mit diesem Pächter verknüpften Dokumente laden
verknuepfte_dokumente = DokumentLink.objects.filter(
paechter_id=paechter.pk
verknuepfte_dokumente = DokumentDatei.objects.filter(
paechter=paechter
).order_by("kontext", "titel")
# Neue LandVerpachtungen für diesen Pächter laden
@@ -392,7 +390,7 @@ def land_detail(request, pk):
land = get_object_or_404(Land, pk=pk)
# Alle mit dieser Länderei verknüpften Dokumente laden
verknuepfte_dokumente = DokumentLink.objects.filter(land_id=land.pk).order_by(
verknuepfte_dokumente = DokumentDatei.objects.filter(land=land).order_by(
"kontext", "titel"
)
@@ -584,8 +582,8 @@ def land_verpachtung_detail(request, pk):
verpachtung = get_object_or_404(LandVerpachtung, pk=pk)
# Alle mit dieser Verpachtung verknüpften Dokumente laden
verknuepfte_dokumente = DokumentLink.objects.filter(
land_verpachtung_id=verpachtung.pk
verknuepfte_dokumente = DokumentDatei.objects.filter(
verpachtung=verpachtung
).order_by("kontext", "titel")
context = {
@@ -747,55 +745,26 @@ def paechter_export(request, pk):
json.dumps(entity_data, indent=2, ensure_ascii=False),
)
# 2. Linked documents from Paperless
dokumente = DokumentLink.objects.filter(paechter_id=paechter.pk)
# 2. DMS-Dokumente (Django-natives DMS)
dokumente = DokumentDatei.objects.filter(paechter=paechter)
docs_data = []
for doc in dokumente:
doc_data = {
"paperless_id": doc.paperless_document_id,
"dms_id": str(doc.id),
"titel": doc.titel,
"kontext": doc.get_kontext_display(),
"beschreibung": doc.beschreibung,
"dateiname": doc.dateiname_original,
}
docs_data.append(doc_data)
# Try to download document from Paperless
try:
if (
hasattr(settings, "PAPERLESS_API_URL")
and settings.PAPERLESS_API_URL
):
doc_url = f"{settings.PAPERLESS_API_URL}/api/documents/{doc.paperless_document_id}/download/"
headers = {}
if (
hasattr(settings, "PAPERLESS_API_TOKEN")
and settings.PAPERLESS_API_TOKEN
):
headers["Authorization"] = (
f"Token {settings.PAPERLESS_API_TOKEN}"
)
response = requests.get(doc_url, headers=headers, timeout=30)
if response.status_code == 200:
content_type = response.headers.get("content-type", "")
if "pdf" in content_type:
ext = ".pdf"
elif "jpeg" in content_type or "jpg" in content_type:
ext = ".jpg"
elif "png" in content_type:
ext = ".png"
else:
ext = ".pdf"
safe_filename = f"dokument_{doc.paperless_document_id}_{doc.titel.replace('/', '_')[:50]}{ext}"
zipf.writestr(
f"dokumente/{safe_filename}", response.content
)
doc_data["downloaded"] = True
else:
doc_data["download_error"] = f"HTTP {response.status_code}"
if doc.datei:
safe_filename = doc.dateiname_original or str(doc.id)
zipf.writestr(f"dokumente/{safe_filename}", doc.datei.read())
doc_data["included"] = True
except Exception as e:
doc_data["download_error"] = str(e)
doc_data["error"] = str(e)
if docs_data:
zipf.writestr(
@@ -871,55 +840,26 @@ def land_export(request, pk):
"land_data.json", json.dumps(entity_data, indent=2, ensure_ascii=False)
)
# 2. Linked documents from Paperless
dokumente = DokumentLink.objects.filter(land_id=land.pk)
# 2. DMS-Dokumente (Django-natives DMS)
dokumente = DokumentDatei.objects.filter(land=land)
docs_data = []
for doc in dokumente:
doc_data = {
"paperless_id": doc.paperless_document_id,
"dms_id": str(doc.id),
"titel": doc.titel,
"kontext": doc.get_kontext_display(),
"beschreibung": doc.beschreibung,
"dateiname": doc.dateiname_original,
}
docs_data.append(doc_data)
# Try to download document from Paperless
try:
if (
hasattr(settings, "PAPERLESS_API_URL")
and settings.PAPERLESS_API_URL
):
doc_url = f"{settings.PAPERLESS_API_URL}/api/documents/{doc.paperless_document_id}/download/"
headers = {}
if (
hasattr(settings, "PAPERLESS_API_TOKEN")
and settings.PAPERLESS_API_TOKEN
):
headers["Authorization"] = (
f"Token {settings.PAPERLESS_API_TOKEN}"
)
response = requests.get(doc_url, headers=headers, timeout=30)
if response.status_code == 200:
content_type = response.headers.get("content-type", "")
if "pdf" in content_type:
ext = ".pdf"
elif "jpeg" in content_type or "jpg" in content_type:
ext = ".jpg"
elif "png" in content_type:
ext = ".png"
else:
ext = ".pdf"
safe_filename = f"dokument_{doc.paperless_document_id}_{doc.titel.replace('/', '_')[:50]}{ext}"
zipf.writestr(
f"dokumente/{safe_filename}", response.content
)
doc_data["downloaded"] = True
else:
doc_data["download_error"] = f"HTTP {response.status_code}"
if doc.datei:
safe_filename = doc.dateiname_original or str(doc.id)
zipf.writestr(f"dokumente/{safe_filename}", doc.datei.read())
doc_data["included"] = True
except Exception as e:
doc_data["download_error"] = str(e)
doc_data["error"] = str(e)
if docs_data:
zipf.writestr(
@@ -996,55 +936,26 @@ def verpachtung_export(request, pk):
json.dumps(entity_data, indent=2, ensure_ascii=False),
)
# 2. Linked documents from Paperless
dokumente = DokumentLink.objects.filter(verpachtung_id=verpachtung.pk)
# 2. DMS-Dokumente (Django-natives DMS)
dokumente = DokumentDatei.objects.filter(verpachtung=verpachtung)
docs_data = []
for doc in dokumente:
doc_data = {
"paperless_id": doc.paperless_document_id,
"dms_id": str(doc.id),
"titel": doc.titel,
"kontext": doc.get_kontext_display(),
"beschreibung": doc.beschreibung,
"dateiname": doc.dateiname_original,
}
docs_data.append(doc_data)
# Try to download document from Paperless
try:
if (
hasattr(settings, "PAPERLESS_API_URL")
and settings.PAPERLESS_API_URL
):
doc_url = f"{settings.PAPERLESS_API_URL}/api/documents/{doc.paperless_document_id}/download/"
headers = {}
if (
hasattr(settings, "PAPERLESS_API_TOKEN")
and settings.PAPERLESS_API_TOKEN
):
headers["Authorization"] = (
f"Token {settings.PAPERLESS_API_TOKEN}"
)
response = requests.get(doc_url, headers=headers, timeout=30)
if response.status_code == 200:
content_type = response.headers.get("content-type", "")
if "pdf" in content_type:
ext = ".pdf"
elif "jpeg" in content_type or "jpg" in content_type:
ext = ".jpg"
elif "png" in content_type:
ext = ".png"
else:
ext = ".pdf"
safe_filename = f"dokument_{doc.paperless_document_id}_{doc.titel.replace('/', '_')[:50]}{ext}"
zipf.writestr(
f"dokumente/{safe_filename}", response.content
)
doc_data["downloaded"] = True
else:
doc_data["download_error"] = f"HTTP {response.status_code}"
if doc.datei:
safe_filename = doc.dateiname_original or str(doc.id)
zipf.writestr(f"dokumente/{safe_filename}", doc.datei.read())
doc_data["included"] = True
except Exception as e:
doc_data["download_error"] = str(e)
doc_data["error"] = str(e)
if docs_data:
zipf.writestr(
@@ -1438,8 +1349,8 @@ def verpachtung_detail(request, pk):
verpachtung = get_object_or_404(LandVerpachtung, pk=pk)
# Alle mit dieser Verpachtung verknüpften Dokumente laden
verknuepfte_dokumente = DokumentLink.objects.filter(
land_verpachtung_id=verpachtung.pk
verknuepfte_dokumente = DokumentDatei.objects.filter(
verpachtung=verpachtung
).order_by("kontext", "titel")
context = {

View File

@@ -657,11 +657,7 @@
<div class="sidebar-heading">Dokumente</div>
<a class="sidebar-link" href="{% url 'stiftung:dms_list' %}">
<i class="fas fa-folder-open"></i>
<span>DMS</span>
</a>
<a class="sidebar-link" href="{% url 'stiftung:dokument_management' %}">
<i class="fas fa-archive"></i>
<span>Paperless (Legacy)</span>
<span>Dokumente</span>
</a>
</div>

View File

@@ -260,7 +260,7 @@
</a>
</div>
<div class="col-md-3 mb-3">
<a href="{% url 'stiftung:dokument_management' %}" class="btn btn-outline-secondary w-100">
<a href="{% url 'stiftung:dms_list' %}" class="btn btn-outline-secondary w-100">
<i class="fas fa-folder-open d-block mb-2 fa-2x"></i>
<span>Dokumentenverwaltung</span>
</a>

View File

@@ -622,8 +622,8 @@
{# ════════ TAB: Dokumente ════════ #}
<div class="tab-pane fade" id="pane-dokumente" role="tabpanel">
<div class="d-flex justify-content-end mb-3">
<a href="{% url 'stiftung:dokument_management' %}" class="btn btn-success btn-sm">
<i class="fas fa-plus me-1"></i>Dokument verknuepfen
<a href="{% url 'stiftung:dms_upload' %}?destinataer={{ destinataer.pk }}" class="btn btn-success btn-sm">
<i class="fas fa-upload me-1"></i>Dokument hochladen
</a>
</div>
{% if verknuepfte_dokumente %}
@@ -635,14 +635,17 @@
<tbody>
{% for d in verknuepfte_dokumente %}
<tr>
<td><strong>{{ d.titel }}</strong><br><small class="text-muted">ID: {{ d.paperless_document_id }}</small></td>
<td>
<strong>{{ d.titel }}</strong>
{% if d.dateiname_original %}<br><small class="text-muted">{{ d.dateiname_original }} ({{ d.get_human_size }})</small>{% endif %}
</td>
<td><span class="badge bg-secondary">{{ d.get_kontext_display }}</span></td>
<td>{{ d.beschreibung|default:"-"|truncatewords:10 }}</td>
<td>
<div class="btn-group btn-group-sm">
<a href="{{ d.get_paperless_url }}" target="_blank" class="btn btn-outline-primary" title="In Paperless oeffnen"><i class="fas fa-external-link-alt"></i></a>
<a href="{% url 'stiftung:dokument_update' d.pk %}" class="btn btn-outline-warning" title="Bearbeiten"><i class="fas fa-edit"></i></a>
<a href="{% url 'stiftung:dokument_delete' d.pk %}" class="btn btn-outline-danger" title="Loeschen"><i class="fas fa-unlink"></i></a>
<a href="{% url 'stiftung:dms_download' d.pk %}" class="btn btn-outline-primary" title="Herunterladen"><i class="fas fa-download"></i></a>
<a href="{% url 'stiftung:dms_edit' d.pk %}" class="btn btn-outline-warning" title="Bearbeiten"><i class="fas fa-edit"></i></a>
<a href="{% url 'stiftung:dms_delete' d.pk %}" class="btn btn-outline-danger" title="Loeschen"><i class="fas fa-trash"></i></a>
</div>
</td>
</tr>
@@ -653,7 +656,7 @@
{% else %}
<div class="text-center py-4 text-muted">
<i class="fas fa-file-alt fa-2x mb-2"></i>
<p>Keine Dokumente verknuepft.</p>
<p>Keine Dokumente vorhanden.</p>
</div>
{% endif %}
</div>

View File

@@ -18,6 +18,8 @@
<form method="post" enctype="multipart/form-data" id="upload-form">
{% csrf_token %}
{% if initial.foerderung_id %}<input type="hidden" name="foerderung_id" value="{{ initial.foerderung_id }}">{% endif %}
{% if initial.verpachtung_id %}<input type="hidden" name="verpachtung_id" value="{{ initial.verpachtung_id }}">{% endif %}
<!-- Drag & Drop Zone -->
<div class="card shadow mb-4">

View File

@@ -1,65 +0,0 @@
{% extends 'base.html' %}
{% load static %}
{% block title %}Dokument löschen - Stiftungsverwaltung{% endblock %}
{% block content %}
<div class="row">
<div class="col-lg-6 mx-auto">
<div class="card shadow">
<div class="card-header bg-danger text-white">
<h4 class="mb-0">
<i class="fas fa-exclamation-triangle me-2"></i>Dokument löschen
</h4>
</div>
<div class="card-body">
<div class="alert alert-warning">
<h5 class="alert-heading">
<i class="fas fa-exclamation-triangle me-2"></i>Warnung!
</h5>
<p class="mb-0">
Sind Sie sicher, dass Sie das Dokument <strong>{{ dokument.titel }}</strong> löschen möchten?
</p>
</div>
<div class="card mb-3">
<div class="card-body">
<h6 class="card-title">Dokumentdetails:</h6>
<p class="card-text">
<strong>Titel:</strong> {{ dokument.titel }}<br>
<strong>Kontext:</strong> {{ dokument.get_kontext_display }}<br>
<strong>Paperless ID:</strong> {{ dokument.paperless_document_id }}<br>
{% if dokument.beschreibung %}
<strong>Beschreibung:</strong> {{ dokument.beschreibung }}
{% endif %}
</p>
</div>
</div>
<div class="alert alert-info">
<h6 class="alert-heading">
<i class="fas fa-info-circle me-2"></i>Wichtiger Hinweis
</h6>
<p class="mb-0">
Diese Aktion kann nicht rückgängig gemacht werden. Alle zugehörigen Verknüpfungen werden ebenfalls gelöscht.
</p>
</div>
<form method="post">
{% csrf_token %}
<div class="d-flex justify-content-between">
<a href="{% url 'stiftung:dokument_detail' dokument.pk %}" class="btn btn-secondary">
<i class="fas fa-arrow-left me-2"></i>Abbrechen
</a>
<button type="submit" class="btn btn-danger">
<i class="fas fa-trash me-2"></i>Endgültig löschen
</button>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -1,168 +0,0 @@
{% extends 'base.html' %}
{% load static %}
{% block title %}{{ title }} - Stiftungsverwaltung{% endblock %}
{% block content %}
<div class="row">
<div class="col-12">
<!-- Header -->
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3">
<i class="fas fa-file-alt text-primary me-2"></i>{{ title }}
</h1>
<div class="btn-group" role="group">
<a href="{% url 'stiftung:dokument_update' dokument.pk %}" class="btn btn-warning">
<i class="fas fa-edit me-2"></i>Bearbeiten
</a>
<a href="{% url 'stiftung:dokument_delete' dokument.pk %}" class="btn btn-danger">
<i class="fas fa-trash me-2"></i>Löschen
</a>
</div>
</div>
<div class="row">
<!-- Main Information -->
<div class="col-lg-8">
<!-- Dokument Details -->
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-info-circle me-2"></i>Dokumentdetails
</h6>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<h6 class="text-primary">Titel</h6>
<p class="mb-3">{{ dokument.titel }}</p>
<h6 class="text-primary">Kontext</h6>
<p class="mb-3">
<span class="badge bg-secondary">{{ dokument.get_kontext_display }}</span>
</p>
</div>
<div class="col-md-6">
<h6 class="text-primary">Paperless ID</h6>
<p class="mb-3">
<code>{{ dokument.paperless_document_id }}</code>
</p>
<h6 class="text-primary">Erstellt</h6>
<p class="mb-3">{{ dokument.id }}</p>
</div>
</div>
{% if dokument.beschreibung %}
<hr class="my-3">
<h6 class="text-primary">Beschreibung</h6>
<p class="mb-0">{{ dokument.beschreibung }}</p>
{% endif %}
</div>
</div>
<!-- Verknüpfungen -->
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-link me-2"></i>Verknüpfungen
</h6>
</div>
<div class="card-body">
{% if dokument.foerderung_set.exists or dokument.verpachtung_set.exists %}
{% if dokument.foerderung_set.exists %}
<h6 class="text-primary">Förderungen</h6>
<div class="list-group list-group-flush mb-3">
{% for foerderung in dokument.foerderung_set.all %}
<div class="list-group-item d-flex justify-content-between align-items-center">
<div>
<strong>{{ foerderung.person.get_full_name }}</strong> - {{ foerderung.jahr }}
<br>
<small class="text-muted">€{{ foerderung.betrag|floatformat:2 }}</small>
</div>
<a href="{% url 'stiftung:foerderung_detail' foerderung.pk %}" class="btn btn-sm btn-outline-primary">
<i class="fas fa-eye"></i>
</a>
</div>
{% endfor %}
</div>
{% endif %}
{% if dokument.verpachtung_set.exists %}
<h6 class="text-primary">Verpachtungen</h6>
<div class="list-group list-group-flush">
{% for verpachtung in dokument.verpachtung_set.all %}
<div class="list-group-item d-flex justify-content-between align-items-center">
<div>
<strong>{{ verpachtung.vertragsnummer }}</strong> - {{ verpachtung.land.gemeinde }}
<br>
<small class="text-muted">{{ verpachtung.paechter.get_full_name }}</small>
</div>
<a href="{% url 'stiftung:verpachtung_detail' verpachtung.pk %}" class="btn btn-sm btn-outline-primary">
<i class="fas fa-eye"></i>
</a>
</div>
{% endfor %}
</div>
{% endif %}
{% else %}
<div class="text-center py-4">
<i class="fas fa-link fa-3x text-muted mb-3"></i>
<h5 class="text-muted">Keine Verknüpfungen</h5>
<p class="text-muted">Dieses Dokument ist noch nicht mit Förderungen oder Verpachtungen verknüpft.</p>
</div>
{% endif %}
</div>
</div>
</div>
<!-- Sidebar -->
<div class="col-lg-4">
<!-- Quick Stats -->
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-chart-pie me-2"></i>Übersicht
</h6>
</div>
<div class="card-body">
<div class="row text-center">
<div class="col-6">
<div class="border-end">
<h4 class="text-primary">{{ dokument.foerderung_set.count }}</h4>
<small class="text-muted">Förderungen</small>
</div>
</div>
<div class="col-6">
<h4 class="text-success">{{ dokument.verpachtung_set.count }}</h4>
<small class="text-muted">Verpachtungen</small>
</div>
</div>
</div>
</div>
<!-- Actions -->
<div class="card shadow">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-bolt me-2"></i>Schnellzugriff
</h6>
</div>
<div class="card-body">
<div class="d-grid gap-2">
<a href="{% url 'stiftung:dokument_update' dokument.pk %}" class="btn btn-warning">
<i class="fas fa-edit me-2"></i>Dokument bearbeiten
</a>
<a href="{% url 'stiftung:dokument_list' %}" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left me-2"></i>Zurück zur Liste
</a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -1,139 +0,0 @@
{% extends 'base.html' %}
{% load static %}
{% block title %}{{ title }} - Stiftung Management{% endblock %}
{% block content %}
<div class="row">
<div class="col-lg-8 mx-auto">
<div class="card shadow">
<div class="card-header">
<h4 class="mb-0">
<i class="fas fa-file-alt text-primary me-2"></i>{{ title }}
</h4>
</div>
<div class="card-body">
<form method="post">
{% csrf_token %}
<!-- Verknüpfungsanzeigen -->
{% if form.land_verpachtung_id.value %}
<div class="alert alert-info">
<i class="fas fa-info-circle me-2"></i>
<strong>Verknüpfung:</strong> Dieses Dokument wird mit einer Verpachtung verknüpft.
</div>
{% elif form.verpachtung_id.value %}
<div class="alert alert-info">
<i class="fas fa-info-circle me-2"></i>
<strong>Verknüpfung:</strong> Dieses Dokument wird mit einer Verpachtung (Legacy) verknüpft.
</div>
{% elif form.land_id.value %}
<div class="alert alert-info">
<i class="fas fa-info-circle me-2"></i>
<strong>Verknüpfung:</strong> Dieses Dokument wird mit einer Länderei verknüpft.
</div>
{% elif form.paechter_id.value %}
<div class="alert alert-info">
<i class="fas fa-info-circle me-2"></i>
<strong>Verknüpfung:</strong> Dieses Dokument wird mit einem Pächter verknüpft.
</div>
{% elif form.destinataer_id.value %}
<div class="alert alert-info">
<i class="fas fa-info-circle me-2"></i>
<strong>Verknüpfung:</strong> Dieses Dokument wird mit einem Destinatär verknüpft.
</div>
{% elif form.foerderung_id.value %}
<div class="alert alert-info">
<i class="fas fa-info-circle me-2"></i>
<strong>Verknüpfung:</strong> Dieses Dokument wird mit einer Förderung verknüpft.
</div>
{% endif %}
<div class="row">
<div class="col-md-6 mb-3">
<label for="{{ form.paperless_document_id.id_for_label }}" class="form-label">
{{ form.paperless_document_id.label }} *
</label>
{{ form.paperless_document_id }}
{% if form.paperless_document_id.errors %}
<div class="invalid-feedback d-block">
{{ form.paperless_document_id.errors.0 }}
</div>
{% endif %}
<small class="form-text text-muted">
Die Dokument-ID aus Paperless (z.B. aus der URL: /documents/12345/)
</small>
</div>
<div class="col-md-6 mb-3">
<label for="{{ form.kontext.id_for_label }}" class="form-label">
{{ form.kontext.label }} *
</label>
{{ form.kontext }}
{% if form.kontext.errors %}
<div class="invalid-feedback d-block">
{{ form.kontext.errors.0 }}
</div>
{% endif %}
</div>
</div>
<div class="mb-3">
<label for="{{ form.titel.id_for_label }}" class="form-label">
{{ form.titel.label }} *
</label>
{{ form.titel }}
{% if form.titel.errors %}
<div class="invalid-feedback d-block">
{{ form.titel.errors.0 }}
</div>
{% endif %}
</div>
<div class="mb-3">
<label for="{{ form.beschreibung.id_for_label }}" class="form-label">
{{ form.beschreibung.label }}
</label>
{{ form.beschreibung }}
{% if form.beschreibung.errors %}
<div class="invalid-feedback d-block">
{{ form.beschreibung.errors.0 }}
</div>
{% endif %}
</div>
<!-- Versteckte Verknüpfungsfelder -->
{{ form.land_verpachtung_id }}
{{ form.verpachtung_id }}
{{ form.land_id }}
{{ form.paechter_id }}
{{ form.destinataer_id }}
{{ form.foerderung_id }}
<div class="d-flex justify-content-between">
<a href="{% url 'stiftung:dokument_list' %}" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left me-1"></i>Zurück zur Liste
</a>
<button type="submit" class="btn btn-primary">
<i class="fas fa-save me-1"></i>
{% if dokument %}Aktualisieren{% else %}Verknüpfen{% endif %}
</button>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_css %}
<style>
.form-control, .form-select {
border-radius: 0.375rem;
}
.form-control:focus, .form-select:focus {
border-color: #86b7fe;
box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25);
}
</style>
{% endblock %}

View File

@@ -1,146 +0,0 @@
{% extends 'base.html' %}
{% load static %}
{% block title %}Alle Dokumente - Stiftungsverwaltung{% endblock %}
{% block content %}
<div class="row">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 mb-0">
<i class="fas fa-file-alt text-primary me-2"></i>
Alle Dokumente
</h1>
<div>
<a href="{% url 'stiftung:dokument_management' %}" class="btn btn-info me-2">
<i class="fas fa-external-link-alt me-1"></i>Dokumentenverwaltung
</a>
</div>
</div>
</div>
</div>
<!-- Verknüpfte Dokumente -->
<div class="row mb-4">
<div class="col-12">
<div class="card shadow">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-success">
<i class="fas fa-link me-2"></i>Verknüpfte Dokumente ({{ dokumente|length }})
</h6>
</div>
<div class="card-body">
{% if dokumente %}
<div class="table-responsive">
<table class="table table-hover">
<thead class="table-light">
<tr>
<th>Dokument</th>
<th>Kontext</th>
<th>Verknüpft mit</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{% for dokument in dokumente %}
<tr>
<td>
<strong>{{ dokument.titel }}</strong>
<br>
<small class="text-muted">ID: {{ dokument.paperless_document_id }}</small>
</td>
<td>
<span class="badge bg-secondary">{{ dokument.get_kontext_display }}</span>
</td>
<td>
{% if dokument.verpachtung_id %}
<span class="badge bg-info">Verpachtung</span>
{% elif dokument.land_id %}
<span class="badge bg-success">Länderei</span>
{% elif dokument.paechter_id %}
<span class="badge bg-primary">Pächter</span>
{% elif dokument.destinataer_id %}
<span class="badge bg-warning">Destinatär</span>
{% elif dokument.foerderung_id %}
<span class="badge bg-secondary">Förderung</span>
{% else %}
<span class="text-muted">Keine Verknüpfung</span>
{% endif %}
</td>
<td>
<div class="btn-group" role="group">
<a href="{{ dokument.get_paperless_url }}" target="_blank" class="btn btn-sm btn-outline-primary" title="In Paperless öffnen">
<i class="fas fa-external-link-alt"></i>
</a>
<a href="{% url 'stiftung:dokument_detail' dokument.pk %}" class="btn btn-sm btn-outline-info" title="Details">
<i class="fas fa-eye"></i>
</a>
<a href="{% url 'stiftung:dokument_update' dokument.pk %}" class="btn btn-sm btn-outline-warning" title="Bearbeiten">
<i class="fas fa-edit"></i>
</a>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-4">
<i class="fas fa-link fa-3x text-muted mb-3"></i>
<h5 class="text-muted">Keine Dokumente verknüpft</h5>
<p class="text-muted">Verknüpfen Sie Dokumente aus Paperless mit Ihren Entitäten.</p>
</div>
{% endif %}
</div>
</div>
</div>
</div>
<!-- Verfügbare Paperless-Dokumente -->
{% if available_dokumente %}
<div class="row mb-4">
<div class="col-12">
<div class="card shadow">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-info">
<i class="fas fa-plus-circle me-2"></i>Verfügbare Paperless-Dokumente ({{ available_dokumente|length }})
</h6>
</div>
<div class="card-body">
<div class="row">
{% for doc in available_dokumente %}
<div class="col-md-6 col-lg-4 mb-3">
<div class="card h-100 border-info">
<div class="card-body">
<h6 class="card-title">{{ doc.title }}</h6>
<div class="mb-2">
{% for tag in doc.tags %}
{% if tag == 'Stiftung_Destinatäre' or tag == 'Stiftung_Land_und_Pächter' or tag == 'Stiftung_Administration' %}
<span class="badge bg-primary me-1">{{ tag }}</span>
{% else %}
<span class="badge bg-light text-dark me-1">{{ tag }}</span>
{% endif %}
{% endfor %}
</div>
<div class="d-flex justify-content-between align-items-center">
<a href="{{ doc.document_url }}" target="_blank" class="btn btn-sm btn-outline-info">
<i class="fas fa-external-link-alt me-1"></i>In Paperless öffnen
</a>
<a href="{% url 'stiftung:dokument_management' %}" class="btn btn-sm btn-success">
<i class="fas fa-link me-1"></i>Verknüpfen
</a>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
</div>
</div>
{% endif %}
{% endblock %}

View File

@@ -1,557 +0,0 @@
{% extends 'base.html' %}
{% load static %}
{% block title %}Dokumentenverwaltung - Stiftung{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1><i class="fas fa-folder-open me-2"></i>Dokumentenverwaltung</h1>
<div>
<a href="{% url 'stiftung:dokument_list' %}" class="btn btn-outline-primary">
<i class="fas fa-list me-1"></i>Alle Dokumente anzeigen
</a>
</div>
</div>
<div id="statusMessages"></div>
<!-- Filter -->
<div class="card mb-3">
<div class="card-header">
<i class="fas fa-filter me-2"></i>Filter
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-3">
<label class="form-label">Kategorie</label>
<select id="filterCategory" class="form-select">
<option value="all">Alle</option>
<option value="destinaere">Destinatäre</option>
<option value="land">Ländereien</option>
<option value="admin">Administration</option>
</select>
</div>
<div class="col-md-6">
<label class="form-label">Suche im Titel</label>
<input id="filterQuery" class="form-control" placeholder="Titel enthält..." />
</div>
<div class="col-md-3 d-flex align-items-end">
<button id="refreshDocuments" class="btn btn-primary w-100">
<i class="fas fa-sync me-1"></i>Aktualisieren
</button>
</div>
</div>
</div>
</div>
<!-- Dokumente-Liste -->
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<div><i class="fas fa-file-alt me-2"></i>Dokumente</div>
<small class="text-muted" id="counts"></small>
</div>
<div class="card-body" id="documentsContainer">
<div class="text-center py-5 text-muted" id="loadingState">
<div class="spinner-border" role="status"></div>
<p class="mt-2">Lade Dokumente...</p>
</div>
</div>
</div>
<!-- Re-Link Modal -->
<div class="modal fade" id="relinkModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Dokument neu verknüpfen</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3"><strong id="relinkDocTitle"></strong></div>
<div id="currentLinks" class="mb-3"></div>
<div class="row g-3">
<div class="col-md-4">
<label class="form-label">Kategorie</label>
<select id="relinkCategory" class="form-select">
<option value="destinataer">Destinatäre</option>
<option value="land">Ländereien</option>
<option value="paechter">Pächter</option>
<option value="verpachtung">Verpachtungen</option>
<option value="foerderung">Förderungen</option>
<option value="abrechnung">Abrechnungen</option>
<option value="rentmeister">Rentmeister</option>
</select>
</div>
<div class="col-md-8">
<label class="form-label">Suche</label>
<div class="input-group">
<input id="relinkQuery" class="form-control" placeholder="Name, Ort, E-Mail, Telefon, Adresse..." />
<button id="relinkSearch" class="btn btn-outline-secondary"><i class="fas fa-search"></i></button>
</div>
<small class="form-text text-muted">Durchsucht Name, Adresse, E-Mail, Telefon und weitere Felder</small>
</div>
</div>
<div class="mt-3" id="relinkResults" style="max-height: 400px; overflow-y: auto; border: 1px solid #dee2e6; border-radius: 0.375rem; padding: 0.5rem;"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Schließen</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_css %}
<style>
.search-result-item:hover {
background-color: #f8f9fa !important;
border-color: #0d6efd !important;
}
#relinkResults::-webkit-scrollbar {
width: 8px;
}
#relinkResults::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 4px;
}
#relinkResults::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 4px;
}
#relinkResults::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
</style>
{% endblock %}
{% block javascript %}
<script>
let allDocuments = [];
let linksByPaperlessId = new Map();
let currentRelink = { linkId: null, paperlessId: null };
function showMessage(message, type) {
const statusDiv = document.getElementById('statusMessages');
const alert = document.createElement('div');
alert.className = `alert alert-${type} alert-dismissible fade show`;
alert.innerHTML = `${message}<button type="button" class="btn-close" data-bs-dismiss="alert"></button>`;
statusDiv.appendChild(alert);
setTimeout(() => alert.remove(), 5000);
}
function fetchData() {
console.log('Fetching updated document data...'); // Debug log
const loadingState = document.getElementById('loadingState');
if (loadingState) {
loadingState.style.display = 'block';
}
console.log('Making API calls to:', [
'/api/paperless/documents/?poll=1',
'/api/link-document/list/'
]);
Promise.all([
fetch('/api/paperless/documents/?poll=1').then(r => {
console.log('Paperless API response status:', r.status, r.ok);
if (!r.ok) {
throw new Error(`Paperless API failed: ${r.status} ${r.statusText}`);
}
return r.json();
}),
fetch('/api/link-document/list/').then(r => {
console.log('Link API response status:', r.status, r.ok);
if (!r.ok) {
throw new Error(`Link API failed: ${r.status} ${r.statusText}`);
}
return r.json();
})
]).then(([docs, linksResp]) => {
console.log('Data fetched successfully:', { docs: docs.documents?.length, links: linksResp.links?.length }); // Debug log
console.log('Full docs response:', docs); // Debug the full response
console.log('Full links response:', linksResp); // Debug the full response
allDocuments = docs.documents || [];
linksByPaperlessId = new Map();
// Handle new grouped links format
(linksResp.links || []).forEach(docLinks => {
console.log(`Setting linksByPaperlessId for document ${docLinks.paperless_id}:`, docLinks);
linksByPaperlessId.set(docLinks.paperless_id, docLinks);
});
console.log('Final linksByPaperlessId Map:', linksByPaperlessId);
renderDocuments();
const countsElement = document.getElementById('counts');
if (countsElement) {
countsElement.textContent = `Gesamt: ${docs.total_all} | Destinatäre: ${docs.total_destinaere} | Land: ${docs.total_land} | Admin: ${docs.total_admin}`;
}
}).catch(err => {
console.error('Error fetching data:', err);
console.error('Error details:', {
message: err.message,
stack: err.stack,
name: err.name
});
showMessage('Fehler beim Laden der Daten: ' + err.message, 'danger');
// Show error in the container
const container = document.getElementById('documentsContainer');
if (container) {
container.innerHTML = `
<div class="alert alert-danger">
<h6><i class="fas fa-exclamation-triangle me-2"></i>Fehler beim Laden der Dokumente</h6>
<p class="mb-0">${err.message}</p>
<button class="btn btn-primary mt-2" onclick="fetchData()">
<i class="fas fa-sync me-1"></i>Erneut versuchen
</button>
</div>
`;
}
}).finally(() => {
const loadingState = document.getElementById('loadingState');
if (loadingState) {
loadingState.style.display = 'none';
}
});
}
function renderDocuments() {
const container = document.getElementById('documentsContainer');
const category = document.getElementById('filterCategory').value;
const query = document.getElementById('filterQuery').value.toLowerCase();
let filtered = allDocuments.slice();
if (category !== 'all') {
filtered = filtered.filter(d => d.tag_category === category);
}
if (query) {
filtered = filtered.filter(d => (d.title || '').toLowerCase().includes(query));
}
if (!filtered.length) {
container.innerHTML = '<p class="text-muted">Keine Dokumente gefunden.</p>';
return;
}
let html = '<div class="table-responsive"><table class="table table-striped align-middle"><thead><tr><th>Titel</th><th>Kategorie</th><th>Verknüpft mit</th><th>Aktionen</th></tr></thead><tbody>';
filtered.forEach(doc => {
const linkData = linksByPaperlessId.get(doc.id);
let linkedTo = '<span class="text-muted">nicht verknüpft</span>';
let hasLinks = false;
if (linkData && linkData.links && linkData.links.length > 0) {
console.log(`Document ${doc.id} (${doc.title}) has ${linkData.links.length} links:`, linkData.links);
hasLinks = true;
const linkItems = linkData.links.map(link => {
const obj = link.linked_object;
if (!obj) {
console.warn('Link missing linked_object:', link);
return `<div class="mb-1"><span class="badge bg-warning me-1">Fehler</span> Fehlerhafter Link</div>`;
}
// Generate the appropriate detail URL based on link type
let detailUrl = '#';
if (link.link_type === 'destinataer') {
detailUrl = `/destinataere/${obj.id}/`;
} else if (link.link_type === 'land') {
detailUrl = `/laendereien/${obj.id}/`;
} else if (link.link_type === 'paechter') {
detailUrl = `/paechter/${obj.id}/`;
} else if (link.link_type === 'verpachtung') {
detailUrl = `/laendereien/verpachtungen/${obj.id}/`;
} else if (link.link_type === 'foerderung') {
detailUrl = `/foerderungen/${obj.id}/`;
} else if (link.link_type === 'abrechnung') {
detailUrl = `/laendereien/abrechnungen/${obj.id}/`;
} else if (link.link_type === 'rentmeister') {
detailUrl = `/geschaeftsfuehrung/rentmeister/${obj.id}/`;
}
return `<div class="mb-1 d-flex align-items-center justify-content-between">
<div class="flex-grow-1">
<span class="badge bg-info me-1">${obj?.type || 'Unbekannt'}</span>
<a href="${detailUrl}" class="text-decoration-none small text-primary" title="Zu ${obj?.type || 'Entität'} navigieren">
${obj?.name || 'Unbekannt'}
<i class="fas fa-external-link-alt ms-1" style="font-size: 0.7em;"></i>
</a>
</div>
<button class="btn btn-sm btn-outline-danger ms-2" onclick="onDeleteLink('${link.id}')" title="Diese Verknüpfung löschen">
<i class="fas fa-times" style="font-size: 0.7em;"></i>
</button>
</div>`;
}).join('');
linkedTo = `<div class="small">${linkItems}</div>`;
console.log(`Final linkedTo HTML for doc ${doc.id}:`, linkedTo);
}
const openUrl = `/api/paperless/documents/${doc.id}/`;
html += `
<tr>
<td><strong>${doc.title || 'Ohne Titel'}</strong><br><small class="text-muted">Paperless-ID: ${doc.id}</small></td>
<td><span class="badge bg-secondary">${doc.tag_category}</span></td>
<td>${linkedTo}</td>
<td>
<a class="btn btn-sm btn-outline-primary" href="${openUrl}" target="_blank" title="In Paperless öffnen"><i class="fas fa-external-link-alt"></i></a>
${hasLinks ? `<button class="btn btn-sm btn-outline-danger" onclick="onDeleteAllLinks(${doc.id})" title="Alle Verknüpfungen löschen"><i class="fas fa-trash"></i></button>` : ''}
<button class="btn btn-sm btn-outline-success" onclick="onRelink('', ${doc.id}, '${(doc.title||'').replace(/'/g, "&#39;")}')" title="Neu verknüpfen"><i class="fas fa-link"></i></button>
</td>
</tr>`;
});
html += '</tbody></table></div>';
container.innerHTML = html;
}
function onRelink(linkId, paperlessId, title) {
currentRelink = { linkId, paperlessId };
document.getElementById('relinkDocTitle').textContent = title;
// Show current links in modal
const linkData = linksByPaperlessId.get(paperlessId);
const currentLinksDiv = document.getElementById('currentLinks');
if (linkData && linkData.links && linkData.links.length > 0) {
let currentLinksHtml = '<div class="alert alert-info"><strong>Aktuell verknüpft mit:</strong><ul class="mb-0 mt-2">';
linkData.links.forEach(link => {
const obj = link.linked_object;
currentLinksHtml += `<li><span class="badge bg-secondary me-1">${obj?.type || 'Unbekannt'}</span> ${obj?.name || 'Unbekannt'}</li>`;
});
currentLinksHtml += '</ul></div>';
currentLinksDiv.innerHTML = currentLinksHtml;
} else {
currentLinksDiv.innerHTML = '<div class="alert alert-warning">Dieses Dokument ist noch nicht verknüpft.</div>';
}
document.getElementById('relinkResults').innerHTML = '';
const modal = new bootstrap.Modal(document.getElementById('relinkModal'));
modal.show();
}
document.getElementById('relinkSearch').addEventListener('click', function() {
const category = document.getElementById('relinkCategory').value;
const q = document.getElementById('relinkQuery').value || 'all';
const target = document.getElementById('relinkResults');
target.innerHTML = '<div class="d-flex align-items-center"><div class="spinner-border spinner-border-sm me-2" role="status"></div>Suche...</div>';
fetch(`/api/link-document/search/?q=${encodeURIComponent(q)}&category=${category}`)
.then(r => r.json())
.then(data => {
let items = data[category] || [];
if (!items.length) {
target.innerHTML = '<div class="text-center py-3 text-muted"><i class="fas fa-search me-2"></i>Keine Treffer gefunden.</div>';
return;
}
let html = `<div class="mb-2"><small class="text-muted">${items.length} Treffer gefunden</small></div>`;
// Get current links for this document to mark already linked entities
const linkData = linksByPaperlessId.get(currentRelink.paperlessId);
const currentlyLinkedIds = new Set();
if (linkData && linkData.links) {
linkData.links.forEach(link => {
if (link.linked_object && link.linked_object.id) {
currentlyLinkedIds.add(link.linked_object.id);
}
});
}
items.forEach(it => {
const isLinked = currentlyLinkedIds.has(it.id);
const linkClass = isLinked ? 'border-success bg-light' : 'border';
const buttonClass = isLinked ? 'btn-success' : 'btn-outline-primary';
const buttonIcon = isLinked ? 'fas fa-check-circle' : 'fas fa-plus';
const buttonText = isLinked ? 'Bereits verknüpft' : 'Verknüpfen';
html += `<div class="d-flex justify-content-between align-items-start ${linkClass} rounded p-3 mb-2 search-result-item" style="transition: all 0.2s;">
<div class="flex-grow-1">
<div class="fw-bold mb-1">${it.name}</div>
<div class="text-muted small mb-1">${it.details || ''}</div>
${isLinked ? '<span class="badge bg-success mt-1"><i class="fas fa-check-circle me-1"></i>Aktuell verknüpft</span>' : ''}
</div>
<button class="btn btn-sm ${buttonClass} ms-3" onclick="confirmRelink('${it.id}', '${category}')" title="${buttonText}" ${isLinked ? 'disabled' : ''}>
<i class="${buttonIcon} me-1"></i>${isLinked ? 'Verknüpft' : 'Auswählen'}
</button>
</div>`;
});
target.innerHTML = html;
})
.catch(() => { target.innerHTML = '<div class="text-center py-3 text-danger"><i class="fas fa-exclamation-triangle me-2"></i>Fehler bei der Suche</div>'; });
});
function onDeleteAllLinks(paperlessId) {
const linkData = linksByPaperlessId.get(paperlessId);
if (!linkData || !linkData.links || linkData.links.length === 0) {
showMessage('Keine Verknüpfungen zum Löschen gefunden', 'warning');
return;
}
if (!confirm(`Möchten Sie wirklich alle ${linkData.links.length} Verknüpfung(en) für dieses Dokument löschen?`)) {
return;
}
console.log(`Deleting all ${linkData.links.length} links for document ${paperlessId}:`, linkData.links);
// Delete all links for this document with proper CSRF token
const deletePromises = linkData.links.map(link => {
console.log(`Deleting link ${link.id}`);
return fetch(`/api/link-document/delete/${link.id}/`, {
method: 'DELETE',
headers: { 'X-CSRFToken': getCookie('csrftoken') }
});
});
Promise.all(deletePromises).then(responses => {
console.log('All delete responses:', responses.map(r => ({ status: r.status, ok: r.ok })));
const allSuccessful = responses.every(r => r.ok);
const failedCount = responses.filter(r => !r.ok).length;
if (allSuccessful) {
showMessage('Alle Verknüpfungen erfolgreich gelöscht', 'success');
setTimeout(() => {
fetchData();
}, 300);
} else {
console.error(`${failedCount} of ${responses.length} delete requests failed`);
showMessage(`Fehler beim Löschen von ${failedCount} Verknüpfung(en)`, 'danger');
}
}).catch(err => {
console.error('Error during bulk delete:', err);
showMessage('Fehler beim Löschen der Verknüpfungen', 'danger');
});
}
// Add Enter key support for search
document.getElementById('relinkQuery').addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
document.getElementById('relinkSearch').click();
}
});
function confirmRelink(targetId, category) {
console.log('confirmRelink called:', { targetId, category, currentRelink });
const isUpdate = !!currentRelink.linkId;
const url = isUpdate ? '/api/link-document/update/' : '/api/link-document/create/';
const payload = isUpdate ? {
link_id: currentRelink.linkId,
link_type: category,
link_id_target: targetId
} : {
paperless_id: currentRelink.paperlessId,
paperless_title: document.getElementById('relinkDocTitle').textContent,
paperless_url: `/api/paperless/documents/${currentRelink.paperlessId}/`,
link_type: category,
link_id: targetId
};
console.log('Sending request:', { url, payload });
fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': getCookie('csrftoken') },
body: JSON.stringify(payload)
}).then(async r => {
console.log('Response status:', r.status, r.ok);
let resp = {};
try {
resp = await r.json();
console.log('Response data:', resp);
} catch (e) {
console.log('No JSON response, treating as success if status OK');
if (r.ok) {
resp = { success: true };
} else {
throw new Error('Network response was not ok');
}
}
if (r.ok && (resp.success || resp.message)) {
console.log('Success! Showing message and refreshing...');
showMessage(resp.message || 'Verknüpfung gespeichert', 'success');
// Close modal first, then refresh data
const modal = bootstrap.Modal.getInstance(document.getElementById('relinkModal'));
if (modal) {
console.log('Closing modal...');
modal.hide();
}
// Clear search results to prevent confusion
document.getElementById('relinkResults').innerHTML = '';
document.getElementById('relinkQuery').value = '';
// Try immediate refresh first
console.log('Calling fetchData() immediately...');
fetchData();
// Also schedule a backup refresh to be sure
console.log('Scheduling backup refresh in 500ms...');
setTimeout(() => {
console.log('Backup fetchData() call...');
fetchData();
}, 500);
} else {
console.log('Error response:', resp);
showMessage(resp.error || 'Fehler beim Speichern', 'danger');
}
}).catch(err => {
console.error('Relink error:', err);
showMessage('Fehler beim Speichern', 'danger');
});
}
function onDeleteLink(linkId) {
if (!confirm('Diese Verknüpfung wirklich löschen?')) return;
console.log('Deleting individual link:', linkId);
fetch(`/api/link-document/delete/${linkId}/`, {
method: 'DELETE',
headers: { 'X-CSRFToken': getCookie('csrftoken') }
})
.then(async r => {
console.log('Delete response status:', r.status, r.ok);
let data = {};
try {
data = await r.json();
console.log('Delete response data:', data);
} catch (_) {
console.log('No JSON response, treating as success if status OK');
}
if (r.ok && (data.success === undefined || data.success === true)) {
showMessage('Verknüpfung gelöscht', 'success');
setTimeout(() => {
fetchData();
}, 300);
} else {
showMessage((data && data.error) || 'Fehler beim Löschen', 'danger');
}
})
.catch(err => {
console.error('Delete error:', err);
showMessage('Fehler beim Löschen', 'danger');
});
}
function getCookie(name) {
let cookieValue = null;
if (document.cookie && document.cookie !== '') {
const cookies = document.cookie.split(';');
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
if (cookie.substring(0, name.length + 1) === (name + '=')) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
}
document.getElementById('refreshDocuments').addEventListener('click', fetchData);
document.getElementById('filterCategory').addEventListener('change', renderDocuments);
document.getElementById('filterQuery').addEventListener('input', () => { renderDocuments(); });
document.addEventListener('DOMContentLoaded', function() {
console.log('Dokumentenverwaltung page loaded, calling fetchData()...');
fetchData();
});
</script>
{% endblock %}

View File

@@ -1,7 +1,7 @@
{% extends 'base.html' %}
{% load humanize %}
{% block title %}E-Mail-Eingang Detail - van Hees-Theyssen-Vogel'sche Stiftung{% endblock %}
{% block title %}E-Mail-Eingang Detail - Stiftungsverwaltung{% endblock %}
{% block content %}
<div class="row">
@@ -11,22 +11,31 @@
<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
<i class="fas fa-arrow-left me-1"></i>Zurueck zur Uebersicht
</a>
</div>
</div>
</div>
<div class="row">
<!-- Linke Spalte: E-Mail-Details -->
{# 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>
{# Kategorie-Badge #}
{% if eingang.kategorie == "rechnung" %}<span class="badge bg-warning text-dark me-1"><i class="fas fa-file-invoice me-1"></i>Rechnung</span>
{% elif eingang.kategorie == "destinataer" %}<span class="badge bg-info me-1"><i class="fas fa-user me-1"></i>Destinataer</span>
{% elif eingang.kategorie == "land_pacht" %}<span class="badge bg-success me-1"><i class="fas fa-map me-1"></i>Land/Pacht</span>
{% elif eingang.kategorie == "stiftungsgeschichte" %}<span class="badge bg-dark me-1"><i class="fas fa-landmark me-1"></i>Geschichte</span>
{% endif %}
{# Status-Badge #}
{% 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 == "rechnung_erfasst" %}<span class="badge bg-info">Rechnung erfasst</span>
{% elif eingang.status == "zahlung_gebucht" %}<span class="badge bg-success">Zahlung gebucht</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 %}
@@ -47,17 +56,27 @@
<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>
<dt class="col-sm-3">Destinataer</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>
<span class="text-muted">Nicht zugeordnet</span>
{% endif %}
</dd>
{% if eingang.verwaltungskosten %}
<dt class="col-sm-3">Rechnung</dt>
<dd class="col-sm-9">
<a href="{% url 'stiftung:verwaltungskosten_edit' eingang.verwaltungskosten.pk %}">
{{ eingang.verwaltungskosten.bezeichnung }} ({{ eingang.verwaltungskosten.betrag }} EUR)
</a>
<span class="badge bg-{{ eingang.verwaltungskosten.get_status_color }}">{{ eingang.verwaltungskosten.get_status_display }}</span>
</dd>
{% endif %}
{% if eingang.quartalsnachweis %}
<dt class="col-sm-3">Quartalsnachweis</dt>
<dd class="col-sm-9">
@@ -82,32 +101,34 @@
</div>
</div>
<!-- Anhänge / Paperless-Dokumente -->
{% if dokument_links %}
{# Anhaenge / DMS-Dokumente #}
{% if dms_dokumente %}
<div class="card mb-4">
<div class="card-header">
<i class="fas fa-paperclip me-2"></i>Anhänge in Paperless-NGX
<i class="fas fa-paperclip me-2"></i>Anhaenge ({{ dms_dokumente|length }})
</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>Dateiname</th>
<th>Typ</th>
<th>Groesse</th>
<th></th>
</tr>
</thead>
<tbody>
{% for link in dokument_links %}
{% for dok in dms_dokumente %}
<tr>
<td>{{ link.titel }}</td>
<td>{{ link.get_kontext_display }}</td>
<td><code>{{ link.paperless_document_id }}</code></td>
<td>{{ dok.dateiname_original|default:dok.titel }}</td>
<td><span class="text-muted small">{{ dok.dateityp|default:"" }}</span></td>
<td><span class="text-muted small">{{ dok.get_human_size }}</span></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
{% if dok.datei %}
<a href="{% url 'stiftung:dms_download' dok.pk %}" class="btn btn-sm btn-outline-primary">
<i class="fas fa-download me-1"></i>Herunterladen
</a>
{% endif %}
</td>
</tr>
{% endfor %}
@@ -115,43 +136,109 @@
</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.
<i class="fas fa-paperclip me-1"></i>Keine Anhaenge in dieser E-Mail.
</div>
</div>
{% endif %}
</div>
<!-- Rechte Spalte: Aktionen -->
{# Rechte Spalte: Aktionen #}
<div class="col-lg-4">
<!-- Manuelle Destinatär-Zuordnung -->
{% if not eingang.destinataer or eingang.status == "unbekannt" %}
{# Kategorie aendern #}
<div class="card mb-4">
<div class="card-header">
<i class="fas fa-tag me-2"></i>Kategorie
</div>
<div class="card-body">
<form method="post">
{% csrf_token %}
<input type="hidden" name="action" value="set_kategorie">
<div class="mb-2">
<select class="form-select form-select-sm" name="kategorie">
<option value="allgemein" {% if eingang.kategorie == "allgemein" %}selected{% endif %}>Allgemein</option>
<option value="destinataer" {% if eingang.kategorie == "destinataer" %}selected{% endif %}>Destinataer</option>
<option value="rechnung" {% if eingang.kategorie == "rechnung" %}selected{% endif %}>Rechnung</option>
<option value="land_pacht" {% if eingang.kategorie == "land_pacht" %}selected{% endif %}>Grundstueck / Pacht</option>
<option value="stiftungsgeschichte" {% if eingang.kategorie == "stiftungsgeschichte" %}selected{% endif %}>Stiftungsgeschichte</option>
</select>
</div>
<button type="submit" class="btn btn-outline-primary btn-sm w-100">
<i class="fas fa-save me-1"></i>Kategorie setzen
</button>
</form>
</div>
</div>
{# Rechnung erfassen (nur wenn noch keine zugeordnet) #}
{% if not eingang.verwaltungskosten and eingang.status != "zahlung_gebucht" %}
<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
<i class="fas fa-file-invoice-dollar me-2"></i>Als Rechnung erfassen
</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.
Erstellt einen Verwaltungskosten-Eintrag und verknuepft die Anhaenge als Rechnungsdokumente.
</p>
<form method="post">
{% csrf_token %}
<input type="hidden" name="action" value="erfasse_rechnung">
<div class="mb-2">
<label class="form-label small">Bezeichnung</label>
<input type="text" class="form-control form-control-sm" name="bezeichnung"
value="{{ eingang.betreff }}" required>
</div>
<div class="mb-2">
<label class="form-label small">Betrag (EUR)</label>
<input type="number" step="0.01" class="form-control form-control-sm" name="betrag"
placeholder="0.00" required>
</div>
<div class="mb-2">
<label class="form-label small">Lieferant / Firma</label>
<input type="text" class="form-control form-control-sm" name="lieferant"
value="{{ eingang.absender_name|default:eingang.absender_email }}">
</div>
<div class="mb-2">
<label class="form-label small">Rechnungsnummer</label>
<input type="text" class="form-control form-control-sm" name="rechnungsnummer"
placeholder="z.B. RE-2026001">
</div>
<div class="mb-2">
<label class="form-label small">Kategorie</label>
<select class="form-select form-select-sm" name="vk_kategorie">
{% for key, label in vk_kategorie_choices %}
<option value="{{ key }}" {% if key == "rechnung_intern" %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<button type="submit" class="btn btn-warning w-100">
<i class="fas fa-file-invoice me-1"></i>Rechnung erfassen
</button>
</form>
</div>
</div>
{% endif %}
{# Manuelle Destinataer-Zuordnung #}
{% if not eingang.destinataer or eingang.status == "unbekannt" %}
<div class="card mb-4 border-info">
<div class="card-header bg-info text-white">
<i class="fas fa-user-plus me-2"></i>Destinataer zuordnen
</div>
<div class="card-body">
<p class="small text-muted">
Absender <strong>{{ eingang.absender_email }}</strong>
konnte nicht automatisch zugeordnet werden.
</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>
<select class="form-select form-select-sm" name="destinataer_id" required>
<option value=""> Bitte waehlen </option>
{% for d in alle_destinataere %}
<option value="{{ d.pk }}">{{ d.nachname }}, {{ d.vorname }}
{% if d.email %} ({{ d.email }}){% endif %}
@@ -159,16 +246,16 @@
{% endfor %}
</select>
</div>
<button type="submit" class="btn btn-warning w-100">
<i class="fas fa-link me-1"></i>Zuordnen & Speichern
<button type="submit" class="btn btn-info w-100">
<i class="fas fa-link me-1"></i>Zuordnen
</button>
</form>
</div>
</div>
{% endif %}
<!-- Als verarbeitet markieren -->
{% if eingang.status != "verarbeitet" %}
{# Als verarbeitet markieren #}
{% if eingang.status != "verarbeitet" and eingang.status != "zahlung_gebucht" %}
<div class="card mb-4">
<div class="card-header">
<i class="fas fa-check-circle me-2"></i>Als verarbeitet markieren
@@ -178,9 +265,8 @@
{% 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>
<textarea class="form-control form-control-sm" name="notizen" rows="3"
placeholder="Optionale Notiz...">{{ eingang.notizen }}</textarea>
</div>
<button type="submit" class="btn btn-success w-100">
<i class="fas fa-check me-1"></i>Verarbeitet
@@ -190,7 +276,7 @@
</div>
{% endif %}
<!-- Notizen bearbeiten -->
{# Notizen #}
<div class="card mb-4">
<div class="card-header">
<i class="fas fa-sticky-note me-2"></i>Interne Notizen
@@ -200,41 +286,42 @@
{% 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>
<textarea class="form-control form-control-sm" name="notizen" rows="4"
placeholder="Interne Notizen...">{{ eingang.notizen }}</textarea>
</div>
<button type="submit" class="btn btn-outline-secondary w-100">
<button type="submit" class="btn btn-outline-secondary btn-sm w-100">
<i class="fas fa-save me-1"></i>Notizen speichern
</button>
</form>
</div>
</div>
<!-- Metadaten -->
{# Metadaten #}
<div class="card mb-4">
<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">Kategorie</dt>
<dd class="col-6">{{ eingang.get_kategorie_display }}</dd>
<dt class="col-6">Datensatz-ID</dt>
<dd class="col-6 text-muted"><code>{{ eingang.pk|stringformat:"s"|slice:":8" }}</code></dd>
<dd class="col-6 text-muted"><code>{{ eingang.pk|stringformat:"s"|slice:":8" }}...</code></dd>
</dl>
</div>
</div>
<!-- Löschen -->
{# Loeschen #}
<div class="card border-danger">
<div class="card-header text-danger">
<i class="fas fa-trash-alt me-2"></i>E-Mail löschen
<i class="fas fa-trash-alt me-2"></i>E-Mail loeschen
</div>
<div class="card-body">
<p class="small text-muted mb-2">Diese E-Mail unwiderruflich aus dem System entfernen.</p>
<form method="post" action="{% url 'stiftung:email_eingang_delete' eingang.pk %}"
onsubmit="return confirm('E-Mail wirklich löschen?');">
onsubmit="return confirm('E-Mail wirklich loeschen?');">
{% csrf_token %}
<button type="submit" class="btn btn-outline-danger w-100">
<i class="fas fa-trash-alt me-1"></i>Löschen
<button type="submit" class="btn btn-outline-danger btn-sm w-100">
<i class="fas fa-trash-alt me-1"></i>Loeschen
</button>
</form>
</div>

View File

@@ -1,14 +1,14 @@
{% extends 'base.html' %}
{% load humanize %}
{% block title %}E-Mail-Eingang (Destinatäre) - van Hees-Theyssen-Vogel'sche Stiftung{% endblock %}
{% block title %}E-Mail-Eingang - Stiftungsverwaltung{% 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)
<i class="fas fa-envelope-open-text me-2"></i>E-Mail-Eingang
</h1>
<div class="d-flex gap-2">
<form method="post" action="{% url 'stiftung:email_eingang_poll_trigger' %}" class="d-inline">
@@ -17,79 +17,65 @@
<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 -->
{# Statuskarten #}
<div class="row mb-4">
<div class="col-md-3">
<div class="col-md-3 col-6 mb-2">
<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 class="text-xs font-weight-bold text-primary text-uppercase mb-1">Gesamt</div>
<div class="h5 mb-0 font-weight-bold">{{ counts.gesamt }}</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="col-md-3 col-6 mb-2">
<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 class="text-xs font-weight-bold text-warning text-uppercase mb-1">Neu</div>
<div class="h5 mb-0 font-weight-bold">{{ counts.neu }}</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="col-md-3 col-6 mb-2">
<div class="card border-left-info h-100 py-2">
<div class="card-body">
<div class="text-xs font-weight-bold text-info text-uppercase mb-1">Rechnungen</div>
<div class="h5 mb-0 font-weight-bold">{{ counts.rechnung }}</div>
</div>
</div>
</div>
<div class="col-md-3 col-6 mb-2">
<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 class="text-xs font-weight-bold text-danger text-uppercase mb-1">Unbekannt</div>
<div class="h5 mb-0 font-weight-bold">{{ counts.unbekannt }}</div>
</div>
</div>
</div>
</div>
<!-- Filter -->
{# 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">
<div class="col-md-3">
<label class="form-label">Suche</label>
<input type="text" class="form-control" name="q" value="{{ search }}"
placeholder="Absender, Betreff, Destinatär...">
placeholder="Absender, Betreff...">
</div>
<div class="col-md-3">
<label class="form-label">Kategorie</label>
<select class="form-select" name="kategorie">
<option value="">Alle</option>
{% for value, label in kategorie_choices %}
<option value="{{ value }}" {% if kategorie_filter == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-3">
<label class="form-label">Status</label>
@@ -100,15 +86,15 @@
{% endfor %}
</select>
</div>
<div class="col-md-2 d-flex align-items-end">
<div class="col-md-1 d-flex align-items-end">
<button type="submit" class="btn btn-primary w-100">
<i class="fas fa-search me-1"></i>Filtern
<i class="fas fa-search"></i>
</button>
</div>
{% if search or status_filter %}
{% if search or status_filter or kategorie_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
<i class="fas fa-times me-1"></i>Reset
</a>
</div>
{% endif %}
@@ -116,11 +102,11 @@
</div>
</div>
<!-- Tabelle -->
{# 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>
<span class="text-muted small">{{ page_obj.paginator.count }} Eintraege</span>
</div>
<div class="card-body p-0">
{% if page_obj %}
@@ -130,9 +116,9 @@
<tr>
<th>Datum</th>
<th>Absender</th>
<th>Destinatär</th>
<th>Betreff</th>
<th>Anhänge</th>
<th>Kategorie</th>
<th>Zuordnung</th>
<th>Status</th>
<th></th>
</tr>
@@ -149,19 +135,27 @@
<small class="text-muted">{{ e.absender_email }}</small>
{% endif %}
</td>
<td>{{ e.betreff|truncatechars:50 }}</td>
<td>
{% if e.destinataer %}
<a href="{% url 'stiftung:destinataer_detail' e.destinataer.pk %}">
{{ e.destinataer }}
</a>
{% if e.kategorie == "rechnung" %}
<span class="badge bg-warning text-dark"><i class="fas fa-file-invoice me-1"></i>Rechnung</span>
{% elif e.kategorie == "destinataer" %}
<span class="badge bg-info"><i class="fas fa-user me-1"></i>Destinataer</span>
{% elif e.kategorie == "land_pacht" %}
<span class="badge bg-success"><i class="fas fa-map me-1"></i>Land/Pacht</span>
{% elif e.kategorie == "stiftungsgeschichte" %}
<span class="badge bg-dark"><i class="fas fa-landmark me-1"></i>Geschichte</span>
{% else %}
<span class="text-danger"><i class="fas fa-question-circle me-1"></i>Unbekannt</span>
<span class="badge bg-secondary">Allgemein</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>
<td>
{% if e.destinataer %}
<a href="{% url 'stiftung:destinataer_detail' e.destinataer.pk %}" class="text-decoration-none">
{{ e.destinataer }}
</a>
{% elif e.verwaltungskosten %}
<span class="text-info"><i class="fas fa-file-invoice-dollar me-1"></i>{{ e.verwaltungskosten.bezeichnung|truncatechars:30 }}</span>
{% else %}
<span class="text-muted"></span>
{% endif %}
@@ -173,6 +167,10 @@
<span class="badge bg-primary">Zugewiesen</span>
{% elif e.status == "verarbeitet" %}
<span class="badge bg-success">Verarbeitet</span>
{% elif e.status == "rechnung_erfasst" %}
<span class="badge bg-info">Rechnung erfasst</span>
{% elif e.status == "zahlung_gebucht" %}
<span class="badge bg-success">Bezahlt</span>
{% elif e.status == "unbekannt" %}
<span class="badge bg-danger">Unbekannt</span>
{% elif e.status == "fehler" %}
@@ -180,18 +178,9 @@
{% endif %}
</td>
<td>
<div class="btn-group btn-group-sm">
<a href="{% url 'stiftung:email_eingang_detail' e.pk %}" class="btn btn-outline-primary" title="Details">
<i class="fas fa-eye"></i>
</a>
<form method="post" action="{% url 'stiftung:email_eingang_delete' e.pk %}" class="d-inline"
onsubmit="return confirm('E-Mail wirklich löschen?');">
{% csrf_token %}
<button type="submit" class="btn btn-outline-danger" title="Löschen">
<i class="fas fa-trash-alt"></i>
</button>
</form>
</div>
<a href="{% url 'stiftung:email_eingang_detail' e.pk %}" class="btn btn-sm btn-outline-primary" title="Details">
<i class="fas fa-eye"></i>
</a>
</td>
</tr>
{% endfor %}
@@ -199,14 +188,14 @@
</table>
</div>
<!-- Pagination -->
{# 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 class="page-link" href="?page={{ page_obj.previous_page_number }}&q={{ search }}&status={{ status_filter }}&kategorie={{ kategorie_filter }}">
&laquo;
</a>
</li>
@@ -216,7 +205,7 @@
</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 class="page-link" href="?page={{ page_obj.next_page_number }}&q={{ search }}&status={{ status_filter }}&kategorie={{ kategorie_filter }}">
&raquo;
</a>
</li>
@@ -230,7 +219,7 @@
<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>
<small>Der automatische Abruf erfolgt alle 15 Minuten.</small>
</div>
{% endif %}
</div>

View File

@@ -72,18 +72,21 @@
<div class="col-12">
<h6 class="text-primary">Verwendungsnachweis</h6>
<p class="mb-3">
<a href="{% url 'stiftung:dokument_detail' foerderung.verwendungsnachweis.pk %}">
{{ foerderung.verwendungsnachweis.titel }}
</a>
{{ foerderung.verwendungsnachweis.titel }}
</p>
</div>
</div>
{% endif %}
<!-- Verknüpfte Dokumente -->
<!-- Dokumente (DMS) -->
<div class="row">
<div class="col-12">
<h6 class="text-primary">Verknüpfte Dokumente</h6>
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="text-primary mb-0">Dokumente</h6>
<a href="{% url 'stiftung:dms_upload' %}?foerderung={{ foerderung.pk }}" class="btn btn-sm btn-success">
<i class="fas fa-upload me-1"></i>Dokument hochladen
</a>
</div>
{% if verknuepfte_dokumente %}
<div class="table-responsive">
<table class="table table-sm table-hover">
@@ -100,8 +103,7 @@
<tr>
<td>
<strong>{{ dokument.titel }}</strong>
<br>
<small class="text-muted">ID: {{ dokument.paperless_document_id }}</small>
{% if dokument.dateiname_original %}<br><small class="text-muted">{{ dokument.dateiname_original }} ({{ dokument.get_human_size }})</small>{% endif %}
</td>
<td>
<span class="badge bg-secondary">{{ dokument.get_kontext_display }}</span>
@@ -115,12 +117,15 @@
</td>
<td>
<div class="btn-group" role="group">
<a href="{{ dokument.get_paperless_url }}" target="_blank" class="btn btn-sm btn-outline-primary" title="In Paperless öffnen">
<i class="fas fa-external-link-alt"></i>
<a href="{% url 'stiftung:dms_download' dokument.pk %}" class="btn btn-sm btn-outline-primary" title="Herunterladen">
<i class="fas fa-download"></i>
</a>
<a href="{% url 'stiftung:dokument_update' dokument.pk %}" class="btn btn-sm btn-outline-warning" title="Bearbeiten">
<a href="{% url 'stiftung:dms_edit' dokument.pk %}" class="btn btn-sm btn-outline-warning" title="Bearbeiten">
<i class="fas fa-edit"></i>
</a>
<a href="{% url 'stiftung:dms_delete' dokument.pk %}" class="btn btn-sm btn-outline-danger" title="Löschen">
<i class="fas fa-trash"></i>
</a>
</div>
</td>
</tr>
@@ -128,17 +133,12 @@
</tbody>
</table>
</div>
<div class="mt-2">
<a href="{% url 'stiftung:dokument_management' %}" class="btn btn-sm btn-success">
<i class="fas fa-plus me-1"></i>Weiteres Dokument verknüpfen
</a>
</div>
{% else %}
<div class="text-center py-3">
<i class="fas fa-file-alt fa-2x text-muted mb-2"></i>
<p class="text-muted mb-2">Keine Dokumente verknüpft</p>
<a href="{% url 'stiftung:dokument_management' %}" class="btn btn-success btn-sm">
<i class="fas fa-plus me-1"></i>Erstes Dokument verknüpfen
<p class="text-muted mb-2">Keine Dokumente vorhanden</p>
<a href="{% url 'stiftung:dms_upload' %}?foerderung={{ foerderung.pk }}" class="btn btn-success btn-sm">
<i class="fas fa-upload me-1"></i>Erstes Dokument hochladen
</a>
</div>
{% endif %}

View File

@@ -123,7 +123,7 @@
</div>
{% endif %}
<div class="form-text">
Optionale Verknüpfung zu einem Dokument aus dem Paperless-System
Optionale Verknüpfung zu einem Verwendungsnachweis (Legacy)
</div>
</div>
</div>

View File

@@ -177,6 +177,35 @@
</p>
</div>
</div>
{% if geschichte_dokumente %}
<div class="card mt-3">
<div class="card-header bg-dark text-white">
<h6 class="mb-0"><i class="fas fa-landmark me-2"></i>Verfuegbare Geschichtsdokumente</h6>
</div>
<div class="card-body p-0">
<div class="list-group list-group-flush">
{% for dok in geschichte_dokumente %}
<div class="list-group-item">
<div class="d-flex justify-content-between align-items-start">
<div>
<div class="small fw-bold">{{ dok.titel|truncatechars:40 }}</div>
<small class="text-muted">{{ dok.dateiname_original }} ({{ dok.get_human_size }})</small>
<br><small class="text-muted">{{ dok.erstellt_am|date:"d.m.Y" }}</small>
</div>
<a href="{% url 'stiftung:dms_download' dok.pk %}" class="btn btn-sm btn-outline-primary" title="Herunterladen">
<i class="fas fa-download"></i>
</a>
</div>
</div>
{% endfor %}
</div>
</div>
<div class="card-footer small text-muted">
Dokumente aus dem DMS mit Kontext "Stiftungsgeschichte". Eingegangen per E-Mail oder manuell hochgeladen.
</div>
</div>
{% endif %}
</div>
</div>
{% endblock %}

View File

@@ -303,11 +303,8 @@
{% endif %}
<div class="d-grid gap-2">
<a href="{% url 'stiftung:dokument_management' %}" class="btn btn-outline-primary btn-sm">
<i class="fas fa-link me-2"></i>Dokumente verknüpfen
</a>
<a href="mailto:paperless@vhtv-stiftung.de?subject=Dokumente für {{ land }}" class="btn btn-outline-info btn-sm">
<i class="fas fa-envelope me-2"></i>E-Mail an Paperless
<a href="{% url 'stiftung:dms_list' %}" class="btn btn-outline-primary btn-sm">
<i class="fas fa-folder-open me-2"></i>Zum DMS
</a>
</div>
</div>

View File

@@ -234,14 +234,11 @@
</div>
<div class="card-body">
<p class="text-muted mb-3">
Dokumente werden über Paperless verwaltet und verknüpft.
Dokumente werden im DMS verwaltet.
</p>
<div class="d-grid gap-2">
<a href="{% url 'stiftung:dokument_management' %}" class="btn btn-outline-primary btn-sm">
<i class="fas fa-link me-2"></i>Dokumente verknüpfen
</a>
<a href="mailto:paperless@vhtv-stiftung.de" class="btn btn-outline-info btn-sm">
<i class="fas fa-envelope me-2"></i>E-Mail an Paperless
<a href="{% url 'stiftung:dms_list' %}" class="btn btn-outline-primary btn-sm">
<i class="fas fa-folder-open me-2"></i>Zum DMS
</a>
</div>

View File

@@ -479,14 +479,14 @@
</div>
{% endif %}
<!-- Verknüpfte Dokumente -->
<!-- Dokumente (DMS) -->
<div class="card shadow mb-4">
<div class="card-header py-3 d-flex justify-content-between align-items-center">
<h6 class="m-0 font-weight-bold text-success">
<i class="fas fa-file-alt me-2"></i>Verknüpfte Dokumente
<i class="fas fa-file-alt me-2"></i>Dokumente
</h6>
<a href="{% url 'stiftung:dokument_management' %}" class="btn btn-sm btn-success">
<i class="fas fa-plus me-2"></i>Dokument verknüpfen
<a href="{% url 'stiftung:dms_upload' %}?land={{ land.pk }}" class="btn btn-sm btn-success">
<i class="fas fa-upload me-2"></i>Dokument hochladen
</a>
</div>
<div class="card-body">
@@ -506,8 +506,7 @@
<tr>
<td>
<strong>{{ dokument.titel }}</strong>
<br>
<small class="text-muted">ID: {{ dokument.paperless_document_id }}</small>
{% if dokument.dateiname_original %}<br><small class="text-muted">{{ dokument.dateiname_original }} ({{ dokument.get_human_size }})</small>{% endif %}
</td>
<td>
<span class="badge bg-secondary">{{ dokument.get_kontext_display }}</span>
@@ -521,17 +520,14 @@
</td>
<td>
<div class="btn-group" role="group">
<a href="{{ dokument.get_paperless_url }}" target="_blank" class="btn btn-sm btn-outline-primary" title="In Paperless öffnen">
<i class="fas fa-external-link-alt"></i>
<a href="{% url 'stiftung:dms_download' dokument.pk %}" class="btn btn-sm btn-outline-primary" title="Herunterladen">
<i class="fas fa-download"></i>
</a>
<a href="{{ dokument.get_paperless_thumbnail_url }}" target="_blank" class="btn btn-sm btn-outline-info" title="Thumbnail anzeigen">
<i class="fas fa-image"></i>
</a>
<a href="{% url 'stiftung:dokument_update' dokument.pk %}" class="btn btn-sm btn-outline-warning" title="Bearbeiten">
<a href="{% url 'stiftung:dms_edit' dokument.pk %}" class="btn btn-sm btn-outline-warning" title="Bearbeiten">
<i class="fas fa-edit"></i>
</a>
<a href="{% url 'stiftung:dokument_delete' dokument.pk %}" class="btn btn-sm btn-outline-danger" title="Verknüpfung löschen">
<i class="fas fa-unlink"></i>
<a href="{% url 'stiftung:dms_delete' dokument.pk %}" class="btn btn-sm btn-outline-danger" title="Löschen">
<i class="fas fa-trash"></i>
</a>
</div>
</td>
@@ -543,10 +539,10 @@
{% else %}
<div class="text-center py-4">
<i class="fas fa-file-alt fa-3x text-muted mb-3"></i>
<h5 class="text-muted">Keine Dokumente verknüpft</h5>
<p class="text-muted">Verknüpfen Sie Dokumente aus Paperless mit dieser Länderei.</p>
<a href="{% url 'stiftung:dokument_management' %}" class="btn btn-success">
<i class="fas fa-plus me-2"></i>Erstes Dokument verknüpfen
<h5 class="text-muted">Keine Dokumente vorhanden</h5>
<p class="text-muted">Laden Sie Dokumente direkt hoch und verknüpfen Sie sie mit dieser Länderei.</p>
<a href="{% url 'stiftung:dms_upload' %}?land={{ land.pk }}" class="btn btn-success">
<i class="fas fa-upload me-2"></i>Erstes Dokument hochladen
</a>
</div>
{% endif %}

View File

@@ -188,12 +188,12 @@
</div>
</div>
<!-- Verknüpfte Dokumente -->
<!-- Dokumente (DMS) -->
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0"><i class="fas fa-folder-open me-2"></i>Verknüpfte Dokumente</h5>
<a href="/dokumente/verwaltung/" class="btn btn-sm btn-outline-primary">
<i class="fas fa-link me-1"></i>Dokument verknüpfen
<h5 class="mb-0"><i class="fas fa-folder-open me-2"></i>Dokumente</h5>
<a href="{% url 'stiftung:dms_upload' %}?verpachtung={{ landverpachtung.pk }}" class="btn btn-sm btn-outline-primary">
<i class="fas fa-upload me-1"></i>Dokument hochladen
</a>
</div>
<div class="card-body">
@@ -212,14 +212,21 @@
<tr>
<td>
<strong>{{ doc.titel|default:"Ohne Titel" }}</strong>
<br>
<small class="text-muted">Paperless-ID: {{ doc.paperless_document_id }}</small>
{% if doc.dateiname_original %}<br><small class="text-muted">{{ doc.dateiname_original }} ({{ doc.get_human_size }})</small>{% endif %}
</td>
<td>{{ doc.get_kontext_display }}</td>
<td>
<a href="{{ doc.get_paperless_url }}" target="_blank" class="btn btn-sm btn-outline-primary" title="In Paperless öffnen">
<i class="fas fa-external-link-alt"></i>
</a>
<div class="btn-group" role="group">
<a href="{% url 'stiftung:dms_download' doc.pk %}" class="btn btn-sm btn-outline-primary" title="Herunterladen">
<i class="fas fa-download"></i>
</a>
<a href="{% url 'stiftung:dms_edit' doc.pk %}" class="btn btn-sm btn-outline-warning" title="Bearbeiten">
<i class="fas fa-edit"></i>
</a>
<a href="{% url 'stiftung:dms_delete' doc.pk %}" class="btn btn-sm btn-outline-danger" title="Löschen">
<i class="fas fa-trash"></i>
</a>
</div>
</td>
</tr>
{% endfor %}
@@ -227,7 +234,7 @@
</table>
</div>
{% else %}
<p class="text-muted">Keine Dokumente verknüpft.</p>
<p class="text-muted">Keine Dokumente vorhanden.</p>
{% endif %}
</div>
</div>

View File

@@ -279,14 +279,14 @@
<!-- Legacy Verpachtungen entfernt für saubere UI -->
<!-- Verknüpfte Dokumente -->
<!-- Dokumente (DMS) -->
<div class="card shadow mb-4">
<div class="card-header bg-success text-white d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0">
<i class="fas fa-file-alt me-2"></i>Verknüpfte Dokumente
<i class="fas fa-file-alt me-2"></i>Dokumente
</h5>
<a href="{% url 'stiftung:dokument_management' %}" class="btn btn-light btn-sm">
<i class="fas fa-plus me-1"></i>Dokument verknüpfen
<a href="{% url 'stiftung:dms_upload' %}?paechter={{ paechter.pk }}" class="btn btn-light btn-sm">
<i class="fas fa-upload me-1"></i>Dokument hochladen
</a>
</div>
<div class="card-body">
@@ -306,8 +306,7 @@
<tr>
<td>
<strong>{{ dokument.titel }}</strong>
<br>
<small class="text-muted">ID: {{ dokument.paperless_document_id }}</small>
{% if dokument.dateiname_original %}<br><small class="text-muted">{{ dokument.dateiname_original }} ({{ dokument.get_human_size }})</small>{% endif %}
</td>
<td>
<span class="badge bg-secondary">{{ dokument.get_kontext_display }}</span>
@@ -321,17 +320,14 @@
</td>
<td>
<div class="btn-group" role="group">
<a href="{{ dokument.get_paperless_url }}" target="_blank" class="btn btn-sm btn-outline-primary" title="In Paperless öffnen">
<i class="fas fa-external-link-alt"></i>
<a href="{% url 'stiftung:dms_download' dokument.pk %}" class="btn btn-sm btn-outline-primary" title="Herunterladen">
<i class="fas fa-download"></i>
</a>
<a href="{{ dokument.get_paperless_thumbnail_url }}" target="_blank" class="btn btn-sm btn-outline-info" title="Thumbnail anzeigen">
<i class="fas fa-image"></i>
</a>
<a href="{% url 'stiftung:dokument_update' dokument.pk %}" class="btn btn-sm btn-outline-warning" title="Bearbeiten">
<a href="{% url 'stiftung:dms_edit' dokument.pk %}" class="btn btn-sm btn-outline-warning" title="Bearbeiten">
<i class="fas fa-edit"></i>
</a>
<a href="{% url 'stiftung:dokument_delete' dokument.pk %}" class="btn btn-sm btn-outline-danger" title="Verknüpfung löschen">
<i class="fas fa-unlink"></i>
<a href="{% url 'stiftung:dms_delete' dokument.pk %}" class="btn btn-sm btn-outline-danger" title="Löschen">
<i class="fas fa-trash"></i>
</a>
</div>
</td>
@@ -343,10 +339,10 @@
{% else %}
<div class="text-center py-4">
<i class="fas fa-file-alt fa-3x text-muted mb-3"></i>
<h5 class="text-muted">Keine Dokumente verknüpft</h5>
<p class="text-muted">Verknüpfen Sie Dokumente aus Paperless mit diesem Pächter.</p>
<a href="{% url 'stiftung:dokument_management' %}" class="btn btn-success">
<i class="fas fa-plus me-2"></i>Erstes Dokument verknüpfen
<h5 class="text-muted">Keine Dokumente vorhanden</h5>
<p class="text-muted">Laden Sie Dokumente direkt hoch und verknüpfen Sie sie mit diesem Pächter.</p>
<a href="{% url 'stiftung:dms_upload' %}?paechter={{ paechter.pk }}" class="btn btn-success">
<i class="fas fa-upload me-2"></i>Erstes Dokument hochladen
</a>
</div>
{% endif %}

View File

@@ -296,8 +296,8 @@
<span class="badge bg-primary ms-2">{{ verknuepfte_dokumente.count }}</span>
{% endif %}
</h6>
<a href="/dokumente/verwaltung/" class="btn btn-outline-primary btn-sm">
<i class="fas fa-link me-1"></i>Dokumentenverwaltung
<a href="{% url 'stiftung:dms_list' %}" class="btn btn-outline-primary btn-sm">
<i class="fas fa-folder-open me-1"></i>Dokumentenverwaltung
</a>
</div>
<div class="card-body">
@@ -318,10 +318,7 @@
</small>
</div>
<div class="d-flex gap-2">
<a href="/api/paperless/documents/{{ dokument.paperless_document_id }}/"
class="btn btn-outline-primary btn-sm" target="_blank" title="In Paperless öffnen">
<i class="fas fa-external-link-alt"></i>
</a>
<span class="badge bg-secondary">Legacy</span>
</div>
</div>
{% endfor %}
@@ -332,7 +329,7 @@
<small class="text-muted">
<i class="fas fa-info-circle me-1"></i>
Es werden nur die neuesten 10 Dokumente angezeigt.
<a href="/dokumente/verwaltung/" class="text-decoration-none">Alle Dokumente anzeigen</a>
<a href="{% url 'stiftung:dms_list' %}" class="text-decoration-none">Alle Dokumente anzeigen</a>
</small>
</div>
{% endif %}
@@ -342,7 +339,7 @@
<h5 class="text-muted">Keine Dokumente verknüpft</h5>
<p class="text-muted">
Verknüpfen Sie Dokumente über die
<a href="/dokumente/verwaltung/" class="text-decoration-none">Dokumentenverwaltung</a>.
<a href="{% url 'stiftung:dms_list' %}" class="text-decoration-none">Dokumentenverwaltung</a>.
</p>
</div>
{% endif %}

View File

@@ -292,66 +292,60 @@
</div>
{% endif %}
<!-- Verknüpfte Dokumente -->
{% if verknuepfte_dokumente %}
<div class="row">
<div class="col-12">
<div class="card shadow mb-4">
<div class="card-header bg-success text-white">
<h5 class="card-title mb-0">
<i class="fas fa-file-alt me-2"></i>Verknüpfte Dokumente ({{ verknuepfte_dokumente.count }})
</h5>
</div>
<div class="card-body">
<div class="row">
{% for dokument in verknuepfte_dokumente %}
<div class="col-md-6 mb-3">
<div class="card border-left-success">
<div class="card-body py-2">
<div class="d-flex justify-content-between align-items-center">
<div>
<h6 class="mb-1">{{ dokument.titel }}</h6>
<small class="text-muted">{{ dokument.kontext }}</small>
</div>
<div>
<a href="{{ dokument.paperless_url }}" target="_blank"
class="btn btn-sm btn-outline-success">
<i class="fas fa-external-link-alt"></i>
</a>
</div>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
</div>
</div>
{% endif %}
<!-- Dokument Management Section -->
<!-- Dokumente (DMS) -->
<div class="row mb-4">
<div class="col-12">
<div class="card shadow">
<div class="card-header py-3 d-flex justify-content-between align-items-center">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-paperclip me-2"></i>Dokumente verwalten
<i class="fas fa-file-alt me-2"></i>Dokumente{% if verknuepfte_dokumente %} ({{ verknuepfte_dokumente.count }}){% endif %}
</h6>
<a href="{% url 'stiftung:dokument_create' %}?land_verpachtung_id={{ verpachtung.pk }}"
<a href="{% url 'stiftung:dms_upload' %}?verpachtung={{ verpachtung.pk }}"
class="btn btn-sm btn-success">
<i class="fas fa-plus me-1"></i>Dokument verknüpfen
<i class="fas fa-upload me-1"></i>Dokument hochladen
</a>
</div>
<div class="card-body">
{% if verknuepfte_dokumente %}
<p class="text-muted mb-3">{{ verknuepfte_dokumente.count }} Dokument{{ verknuepfte_dokumente.count|pluralize:"e" }} verknüpft</p>
<div class="table-responsive">
<table class="table table-hover table-sm align-middle">
<thead class="table-light">
<tr>
<th>Titel</th>
<th>Kontext</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{% for dokument in verknuepfte_dokumente %}
<tr>
<td>
<strong>{{ dokument.titel }}</strong>
{% if dokument.dateiname_original %}<br><small class="text-muted">{{ dokument.dateiname_original }} ({{ dokument.get_human_size }})</small>{% endif %}
</td>
<td><span class="badge bg-secondary">{{ dokument.get_kontext_display }}</span></td>
<td>
<div class="btn-group" role="group">
<a href="{% url 'stiftung:dms_download' dokument.pk %}" class="btn btn-sm btn-outline-primary" title="Herunterladen">
<i class="fas fa-download"></i>
</a>
<a href="{% url 'stiftung:dms_edit' dokument.pk %}" class="btn btn-sm btn-outline-warning" title="Bearbeiten">
<i class="fas fa-edit"></i>
</a>
<a href="{% url 'stiftung:dms_delete' dokument.pk %}" class="btn btn-sm btn-outline-danger" title="Löschen">
<i class="fas fa-trash"></i>
</a>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted mb-0">
<i class="fas fa-info-circle me-2"></i>
Noch keine Dokumente mit dieser Verpachtung verknüpft.
Klicken Sie auf "Dokument verknüpfen", um Dokumente aus dem Paperless-System zu verknüpfen.
Noch keine Dokumente vorhanden.
</p>
{% endif %}
</div>

View File

@@ -341,7 +341,7 @@
</div>
<div class="card-body">
<p class="text-muted small">Dokumente können nach dem Speichern der Verpachtung verknüpft werden.</p>
<a href="{% url 'stiftung:dokument_create' %}?land_verpachtung_id={{ form.instance.pk }}"
<a href="{% url 'stiftung:dms_upload' %}?land_verpachtung_id={{ form.instance.pk }}"
class="btn btn-outline-primary btn-sm">
<i class="fas fa-plus me-1"></i>Neues Dokument
</a>

View File

@@ -15,7 +15,6 @@ services:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- dbdata:/var/lib/postgresql/data
- ./scripts/init-paperless-db.sh:/docker-entrypoint-initdb.d/init-paperless-db.sh
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 10s
@@ -46,14 +45,6 @@ services:
- REDIS_URL=${REDIS_URL}
- SESSION_COOKIE_NAME=${SESSION_COOKIE_NAME}
- CSRF_COOKIE_NAME=${CSRF_COOKIE_NAME}
- PAPERLESS_API_URL=${PAPERLESS_API_URL}
- PAPERLESS_API_TOKEN=${PAPERLESS_API_TOKEN}
- PAPERLESS_REQUIRED_TAG=${PAPERLESS_REQUIRED_TAG}
- PAPERLESS_LAND_TAG=${PAPERLESS_LAND_TAG}
- PAPERLESS_ADMIN_TAG=${PAPERLESS_ADMIN_TAG}
- PAPERLESS_DESTINATAERE_TAG_ID=${PAPERLESS_DESTINATAERE_TAG_ID}
- PAPERLESS_LAND_TAG_ID=${PAPERLESS_LAND_TAG_ID}
- PAPERLESS_ADMIN_TAG_ID=${PAPERLESS_ADMIN_TAG_ID}
- GRAMPS_URL=${GRAMPS_URL}
- GRAMPS_USERNAME=${GRAMPS_USERNAME}
- GRAMPS_PASSWORD=${GRAMPS_PASSWORD}
@@ -91,9 +82,6 @@ services:
- IMAP_PASSWORD=${IMAP_PASSWORD}
- IMAP_FOLDER=${IMAP_FOLDER}
- IMAP_USE_SSL=${IMAP_USE_SSL}
- PAPERLESS_API_URL=${PAPERLESS_API_URL}
- PAPERLESS_API_TOKEN=${PAPERLESS_API_TOKEN}
- PAPERLESS_DESTINATAERE_TAG_ID=${PAPERLESS_DESTINATAERE_TAG_ID}
depends_on:
- redis
- db
@@ -120,9 +108,6 @@ services:
- IMAP_PASSWORD=${IMAP_PASSWORD}
- IMAP_FOLDER=${IMAP_FOLDER}
- IMAP_USE_SSL=${IMAP_USE_SSL}
- PAPERLESS_API_URL=${PAPERLESS_API_URL}
- PAPERLESS_API_TOKEN=${PAPERLESS_API_TOKEN}
- PAPERLESS_DESTINATAERE_TAG_ID=${PAPERLESS_DESTINATAERE_TAG_ID}
depends_on:
- redis
- db
@@ -150,43 +135,6 @@ services:
- db
- redis
# Phase 3 (Vision 2026): Paperless-NGX durch Django-natives DMS ersetzt.
# Dienst deaktiviert. Bestehende Dokumente via: python manage.py migrate_paperless_dokumente
# paperless:
# image: ghcr.io/remmerinio/stiftung-management-system-paperless:latest
# ports:
# - "8080:8000"
# environment:
# - PAPERLESS_REDIS=redis://redis:6379
# - PAPERLESS_DBHOST=db
# - PAPERLESS_DBPORT=5432
# - PAPERLESS_DBNAME=${PAPERLESS_DBNAME:-paperless}
# - PAPERLESS_DBUSER=${POSTGRES_USER}
# - PAPERLESS_DBPASS=${POSTGRES_PASSWORD}
# - PAPERLESS_SECRET_KEY=${PAPERLESS_SECRET_KEY}
# - PAPERLESS_URL=https://vhtv-stiftung.de
# - PAPERLESS_ALLOWED_HOSTS=vhtv-stiftung.de,localhost,paperless
# - PAPERLESS_CORS_ALLOWED_HOSTS=https://vhtv-stiftung.de
# - PAPERLESS_FORCE_SCRIPT_NAME=/paperless
# - PAPERLESS_STATIC_URL=/paperless/static/
# - PAPERLESS_LOGIN_REDIRECT_URL=/paperless/
# - PAPERLESS_LOGOUT_REDIRECT_URL=/paperless/
# - PAPERLESS_ADMIN_USER=${PAPERLESS_ADMIN_USER}
# - PAPERLESS_ADMIN_PASSWORD=${PAPERLESS_ADMIN_PASSWORD}
# - PAPERLESS_ADMIN_MAIL=${PAPERLESS_ADMIN_MAIL}
# volumes:
# - paperless_data:/usr/src/paperless/data
# - paperless_media:/usr/src/paperless/media
# - paperless_export:/usr/src/paperless/export
# - paperless_consume:/usr/src/paperless/consume
# depends_on:
# - db
# - redis
volumes:
dbdata:
gramps_data:
paperless_data:
paperless_media:
paperless_export:
paperless_consume:

103
deploy.sh Executable file
View File

@@ -0,0 +1,103 @@
#!/bin/bash
# deploy.sh — Deploy main branch to production (vhtv-stiftung.de)
#
# Usage:
# ./deploy.sh # Deploy current main to production
# ./deploy.sh --dry-run # Show what would happen without deploying
#
# Prerequisites:
# - SSH access to the production server (key-based auth)
# - Production .env file at /opt/stiftung/.env on the server
# - Git remote 'origin' configured on the server pointing to Gitea
set -euo pipefail
SERVER="${DEPLOY_SERVER:-remmer@vhtv-stiftung.de}"
PROD_DIR="${DEPLOY_DIR:-/opt/stiftung}"
COMPOSE_FILE="compose.yml"
DRY_RUN=false
if [[ "${1:-}" == "--dry-run" ]]; then
DRY_RUN=true
echo "=== DRY RUN — no changes will be made ==="
fi
echo "=== Stiftung Production Deployment ==="
echo "Server: $SERVER"
echo "Path: $PROD_DIR"
echo "Compose: $COMPOSE_FILE"
echo ""
# Verify local main is up to date with remote
LOCAL_MAIN=$(git rev-parse main 2>/dev/null || echo "unknown")
echo "Local main: $LOCAL_MAIN"
if [[ "$DRY_RUN" == true ]]; then
echo ""
echo "Would SSH to $SERVER and:"
echo " 1. git fetch origin main && git reset --hard origin/main"
echo " 2. docker compose down"
echo " 3. docker compose up -d --build"
echo " 4. Run migrations and collectstatic"
echo ""
echo "=== Dry run complete ==="
exit 0
fi
echo ""
read -p "Deploy main ($LOCAL_MAIN) to production? [y/N] " -n 1 -r
echo ""
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo "Aborted."
exit 0
fi
echo ""
echo "=== Deploying to $SERVER:$PROD_DIR ==="
ssh "$SERVER" bash -s "$PROD_DIR" "$COMPOSE_FILE" << 'DEPLOY_SCRIPT'
set -euo pipefail
PROD_DIR="$1"
COMPOSE_FILE="$2"
cd "$PROD_DIR"
echo "--- Fetching latest main ---"
git fetch origin main
git checkout main
git reset --hard origin/main
echo ""
echo "--- Pulling standard images ---"
docker compose -f "$COMPOSE_FILE" pull db redis grampsweb || echo "Some pulls failed, using cached"
echo ""
echo "--- Stopping containers ---"
docker compose -f "$COMPOSE_FILE" down
echo ""
echo "--- Building and starting ---"
docker compose -f "$COMPOSE_FILE" up -d --build
echo ""
echo "--- Waiting for services to start ---"
sleep 20
echo ""
echo "--- Running migrations ---"
docker compose -f "$COMPOSE_FILE" exec -T web python manage.py migrate
echo ""
echo "--- Collecting static files ---"
docker compose -f "$COMPOSE_FILE" exec -T web python manage.py collectstatic --noinput
echo ""
echo "--- Service status ---"
docker compose -f "$COMPOSE_FILE" ps
echo ""
echo "=== Deployment complete ==="
DEPLOY_SCRIPT
echo ""
echo "=== Done! Production updated to main ($LOCAL_MAIN) ==="
echo "Site: https://vhtv-stiftung.de"