diff --git a/app/core/settings.py b/app/core/settings.py index fc3cbd4..84f2c02 100644 --- a/app/core/settings.py +++ b/app/core/settings.py @@ -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/" diff --git a/app/stiftung/management/commands/init_config.py b/app/stiftung/management/commands/init_config.py index 1a27205..1ec6ddc 100644 --- a/app/stiftung/management/commands/init_config.py +++ b/app/stiftung/management/commands/init_config.py @@ -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 diff --git a/app/stiftung/migrations/0049_phase3_email_dms_m2m.py b/app/stiftung/migrations/0049_phase3_email_dms_m2m.py new file mode 100644 index 0000000..50fdde7 --- /dev/null +++ b/app/stiftung/migrations/0049_phase3_email_dms_m2m.py @@ -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)'), + ), + ] diff --git a/app/stiftung/migrations/0050_generalize_email_rechnungsworkflow.py b/app/stiftung/migrations/0050_generalize_email_rechnungsworkflow.py new file mode 100644 index 0000000..c627309 --- /dev/null +++ b/app/stiftung/migrations/0050_generalize_email_rechnungsworkflow.py @@ -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', + ), + ), + ] diff --git a/app/stiftung/migrations/0051_alter_emaileingang_destinataer_and_more.py b/app/stiftung/migrations/0051_alter_emaileingang_destinataer_and_more.py new file mode 100644 index 0000000..6621bc8 --- /dev/null +++ b/app/stiftung/migrations/0051_alter_emaileingang_destinataer_and_more.py @@ -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)'), + ), + ] diff --git a/app/stiftung/migrations/0052_alter_dokumentdatei_kontext_and_more.py b/app/stiftung/migrations/0052_alter_dokumentdatei_kontext_and_more.py new file mode 100644 index 0000000..b2fee84 --- /dev/null +++ b/app/stiftung/migrations/0052_alter_dokumentdatei_kontext_and_more.py @@ -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'), + ), + ] diff --git a/app/stiftung/models/__init__.py b/app/stiftung/models/__init__.py index d8b5dc0..5066b61 100644 --- a/app/stiftung/models/__init__.py +++ b/app/stiftung/models/__init__.py @@ -32,6 +32,7 @@ from .finanzen import ( # noqa: F401 from .destinataere import ( # noqa: F401 Destinataer, DestinataerEmailEingang, + EmailEingang, DestinataerNotiz, DestinataerUnterstuetzung, Foerderung, diff --git a/app/stiftung/models/destinataere.py b/app/stiftung/models/destinataere.py index 0c9ce06..fd44773 100644 --- a/app/stiftung/models/destinataere.py +++ b/app/stiftung/models/destinataere.py @@ -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 diff --git a/app/stiftung/models/dokumente.py b/app/stiftung/models/dokumente.py index 57aac35..8fd370b 100644 --- a/app/stiftung/models/dokumente.py +++ b/app/stiftung/models/dokumente.py @@ -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( diff --git a/app/stiftung/models/land.py b/app/stiftung/models/land.py index bc66502..9fa41f4 100644 --- a/app/stiftung/models/land.py +++ b/app/stiftung/models/land.py @@ -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: diff --git a/app/stiftung/tasks.py b/app/stiftung/tasks.py index fd0f9d6..3353c98 100644 --- a/app/stiftung/tasks.py +++ b/app/stiftung/tasks.py @@ -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 diff --git a/app/stiftung/urls.py b/app/stiftung/urls.py index 89d343a..467bb23 100644 --- a/app/stiftung/urls.py +++ b/app/stiftung/urls.py @@ -140,46 +140,7 @@ urlpatterns = [ views.foerderung_delete, name="foerderung_delete", ), - # Dokumente URLs - path("dokumente/", views.dokument_list, name="dokument_list"), - path("dokumente//", views.dokument_detail, name="dokument_detail"), - path("dokumente/neu/", views.dokument_create, name="dokument_create"), - path( - "dokumente//bearbeiten/", views.dokument_update, name="dokument_update" - ), - path( - "dokumente//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//", - 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//", - 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"), diff --git a/app/stiftung/utils/config.py b/app/stiftung/utils/config.py index 5f3945c..b8ca596 100644 --- a/app/stiftung/utils/config.py +++ b/app/stiftung/utils/config.py @@ -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"]) diff --git a/app/stiftung/views/__init__.py b/app/stiftung/views/__init__.py index 79d2764..bda8193 100644 --- a/app/stiftung/views/__init__.py +++ b/app/stiftung/views/__init__.py @@ -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, diff --git a/app/stiftung/views/destinataere.py b/app/stiftung/views/destinataere.py index ba393bc..8be9fe0 100644 --- a/app/stiftung/views/destinataere.py +++ b/app/stiftung/views/destinataere.py @@ -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 diff --git a/app/stiftung/views/dms.py b/app/stiftung/views/dms.py index 55fdff9..083ef21 100644 --- a/app/stiftung/views/dms.py +++ b/app/stiftung/views/dms.py @@ -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) diff --git a/app/stiftung/views/dokumente.py b/app/stiftung/views/dokumente.py index 6769dcb..9abc97f 100644 --- a/app/stiftung/views/dokumente.py +++ b/app/stiftung/views/dokumente.py @@ -1,1453 +1,5 @@ # views/dokumente.py -# Phase 0: Vision 2026 – Code-Refactoring - -import csv -import io -import json -import os -import time -from datetime import datetime, timedelta, date -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 -from django.db.models import (Avg, Count, DecimalField, F, IntegerField, Q, - Sum, Value) -from django.db.models.functions import Cast, Coalesce, NullIf, Replace -from django.http import HttpResponse, JsonResponse -from django.shortcuts import get_object_or_404, redirect, render -from django.urls import reverse -from django.utils import timezone -from django.views.decorators.csrf import csrf_exempt -from django_otp.decorators import otp_required -from django_otp.plugins.otp_totp.models import TOTPDevice -from django_otp.plugins.otp_static.models import StaticDevice, StaticToken -from django_otp.util import random_hex -from rest_framework.decorators import api_view -from rest_framework.response import Response - -from stiftung.models import (AppConfiguration, AuditLog, BackupJob, BankTransaction, - BriefVorlage, CSVImport, Destinataer, - DestinataerEmailEingang, DestinataerNotiz, - DestinataerUnterstuetzung, - DokumentLink, Foerderung, GeschichteBild, GeschichteSeite, - Land, LandAbrechnung, LandVerpachtung, Paechter, Person, - Rentmeister, StiftungsKalenderEintrag, StiftungsKonto, - UnterstuetzungWiederkehrend, Veranstaltung, - Veranstaltungsteilnehmer, Verwaltungskosten, - VierteljahresNachweis) -from stiftung.forms import ( - DestinataerForm, DestinataerUnterstuetzungForm, DestinataerNotizForm, - FoerderungForm, GeschichteBildForm, GeschichteSeiteForm, - LandForm, LandVerpachtungForm, LandAbrechnungForm, - PaechterForm, DokumentLinkForm, - RentmeisterForm, StiftungsKontoForm, VerwaltungskostenForm, - BankTransactionForm, BankImportForm, - UnterstuetzungForm, UnterstuetzungWiederkehrendForm, - UnterstuetzungMarkAsPaidForm, VierteljahresNachweisForm, - UserCreationForm, UserUpdateForm, PasswordChangeForm, UserPermissionForm, - TwoFactorSetupForm, TwoFactorVerifyForm, TwoFactorDisableForm, - BackupTokenRegenerateForm, PersonForm, - VeranstaltungForm, VeranstaltungsteilnehmerForm, -) - - -@login_required -def dokument_management(request): - """Dokumentenverwaltung: zeigt Paperless-Dokumente (mit Stiftung-Tags) und bestehende Verknüpfungen. - Bietet Filter und ermöglicht Re-Linking. - """ - return render(request, "stiftung/dokument_management.html") - - -@api_view(["GET"]) -def paperless_document_redirect(_request, doc_id: int): - """Redirects to the Paperless UI document URL and supports thumbnails if needed later.""" - from stiftung.utils.config import get_paperless_config - - config = get_paperless_config() - url = config["api_url"] - if not url: - return Response({"error": "Paperless API not configured"}, status=400) - - # Remove /api suffix if present, then construct the document URL - base_url = url[:-4] if url.endswith("/api") else url - - # For external Paperless (already includes /paperless/ in base URL) - return redirect(f"{base_url}/documents/{doc_id}/details/") - - -@login_required -def dokument_list(request): - """Zeigt alle verknüpften Dokumente an""" - # Alle verknüpften Dokumente laden - dokumente = DokumentLink.objects.all().order_by("-id") - - # Paperless-API-Konfiguration für verfügbare Dokumente - import requests - - from stiftung.utils.config import get_paperless_config - - config = get_paperless_config() - url = config["api_url"] - token = config["api_token"] - - available_dokumente = [] - if url and token: - try: - base_url = url[:-4] if url.endswith("/api") else url - headers = {"Authorization": f"Token {token}"} - - # Alle verfügbaren Dokumente abrufen (mit Paginierung) - all_dokumente = [] - page = 1 - page_size = 100 - - while True: - response = requests.get( - f"{base_url}/api/documents/?page={page}&page_size={page_size}", - headers=headers, - timeout=10, - ) - response.raise_for_status() - data = response.json() - - all_dokumente.extend(data.get("results", [])) - - if not data.get("next"): - break - page += 1 - - # Stiftung-Dokumente filtern - for doc in all_dokumente: - try: - tags = [] - doc_tags = doc.get("tags", []) - - if isinstance(doc_tags, list): - for tag in doc_tags: - if isinstance(tag, dict) and "name" in tag: - tags.append(tag["name"]) - elif isinstance(tag, str): - tags.append(tag) - elif isinstance(tag, int): - tags.append(f"Tag_{tag}") - elif isinstance(doc_tags, str): - tags = [tag.strip() for tag in doc_tags.split(",")] - - if any( - tag - in [ - config["destinataere_tag"], - config["land_tag"], - config["admin_tag"], - ] - for tag in tags - ): - bereits_verknuepft = DokumentLink.objects.filter( - paperless_document_id=doc["id"] - ).exists() - - if not bereits_verknuepft: - available_dokumente.append( - { - "id": doc["id"], - "title": doc.get("title", f'Dokument {doc["id"]}'), - "created_date": doc.get("created_date", ""), - "tags": tags, - "thumbnail_url": f"{base_url}/api/documents/{doc['id']}/thumb/", - "document_url": f"{base_url}/documents/{doc['id']}/", - } - ) - except Exception: - continue - - # Nach Erstellungsdatum sortieren - available_dokumente.sort(key=lambda x: x["created_date"], reverse=True) - - except Exception: - pass - - context = { - "dokumente": dokumente, - "available_dokumente": available_dokumente, - "title": "Alle verknüpften Dokumente", - } - return render(request, "stiftung/dokument_list.html", context) - - -@login_required -def dokument_detail(request, pk): - """Show details of a specific document link""" - dokument = get_object_or_404(DokumentLink, pk=pk) - - context = { - "dokument": dokument, - "title": f"Dokument: {dokument}", - } - return render(request, "stiftung/dokument_detail.html", context) - - -@login_required -def dokument_create(request): - """Create a new document link""" - if request.method == "POST": - form = DokumentLinkForm(request.POST) - if form.is_valid(): - dokument = form.save() - messages.success( - request, f'Dokument "{dokument.titel}" wurde erfolgreich verknüpft.' - ) - - # Zurück zur verknüpften Entität leiten - if dokument.land_verpachtung_id: - return redirect( - "stiftung:verpachtung_detail", pk=dokument.land_verpachtung_id - ) - elif dokument.verpachtung_id: - return redirect( - "stiftung:verpachtung_detail", pk=dokument.verpachtung_id - ) - elif dokument.land_id: - return redirect("stiftung:land_detail", pk=dokument.land_id) - elif dokument.paechter_id: - return redirect("stiftung:paechter_detail", pk=dokument.paechter_id) - elif dokument.destinataer_id: - return redirect( - "stiftung:destinataer_detail", pk=dokument.destinataer_id - ) - elif dokument.foerderung_id: - return redirect("stiftung:foerderung_detail", pk=dokument.foerderung_id) - else: - return redirect("stiftung:dokument_detail", pk=dokument.pk) - else: - # Initial-Werte aus GET-Parametern setzen - initial_data = {} - if request.GET.get("land_verpachtung_id"): - initial_data["land_verpachtung_id"] = request.GET.get("land_verpachtung_id") - if request.GET.get("verpachtung"): - initial_data["verpachtung_id"] = request.GET.get("verpachtung") - if request.GET.get("land"): - initial_data["land_id"] = request.GET.get("land") - if request.GET.get("paechter"): - initial_data["paechter_id"] = request.GET.get("paechter") - if request.GET.get("destinataer"): - initial_data["destinataer_id"] = request.GET.get("destinataer") - if request.GET.get("foerderung"): - initial_data["foerderung_id"] = request.GET.get("foerderung") - - form = DokumentLinkForm(initial=initial_data) - - context = { - "form": form, - "title": "Neues Dokument verknüpfen", - } - return render(request, "stiftung/dokument_form.html", context) - - -@login_required -def dokument_update(request, pk): - """Update an existing document link""" - dokument = get_object_or_404(DokumentLink, pk=pk) - - if request.method == "POST": - form = DokumentLinkForm(request.POST, instance=dokument) - if form.is_valid(): - form.save() - messages.success( - request, f'Dokument "{dokument.titel}" wurde erfolgreich aktualisiert.' - ) - return redirect("stiftung:dokument_detail", pk=dokument.pk) - else: - form = DokumentLinkForm(instance=dokument) - - context = { - "form": form, - "dokument": dokument, - "title": f"Dokument bearbeiten: {dokument}", - } - return render(request, "stiftung/dokument_form.html", context) - - -@login_required -def dokument_delete(request, pk): - """Delete a document link""" - dokument = get_object_or_404(DokumentLink, pk=pk) - - if request.method == "POST": - dokument.delete() - messages.success( - request, f'Dokument "{dokument.titel}" wurde erfolgreich gelöscht.' - ) - return redirect("stiftung:dokument_list") - - context = { - "dokument": dokument, - "title": f"Dokument löschen: {dokument}", - } - return render(request, "stiftung/dokument_confirm_delete.html", context) - - -# Legacy document views removed - use dokument_management instead - - -# Jahresbericht Views -@api_view(["GET"]) -def paperless_ping(_request): - from stiftung.utils.config import get_paperless_config - - config = get_paperless_config() - url = config["api_url"] - token = config["api_token"] - if not url or not token: - return Response( - {"ok": False, "reason": "Paperless API not configured"}, status=400 - ) - try: - # Entferne /api vom Ende der URL falls vorhanden - base_url = url[:-4] if url.endswith("/api") else url - r = requests.get( - f"{base_url}/api/tags/", - headers={"Authorization": f"Token {token}"}, - timeout=5, - ) - return Response({"ok": r.ok, "status_code": r.status_code}) - except Exception as e: - return Response({"ok": False, "error": str(e)}, status=500) - - -@api_view(["GET"]) -def paperless_documents(request): - """Holt Dokumente aus Paperless mit den erforderlichen Tags. - Optionales Polling: ?poll=1 wartet kurz und versucht erneut, damit frisch ingestete - Dokumente in Paperless erscheinen, bevor die Liste zurückgegeben wird. - """ - from stiftung.utils.config import get_paperless_config - - config = get_paperless_config() - url = config["api_url"] - token = config["api_token"] - required_tag = config["destinataere_tag"] - land_tag = config["land_tag"] - admin_tag = config["admin_tag"] - destinaere_tag_id = config["destinataere_tag_id"] - land_tag_id = config["land_tag_id"] - admin_tag_id = config["admin_tag_id"] - - if not url or not token: - return Response( - { - "error": "Paperless API not configured", - "message": "Please set PAPERLESS_API_URL and PAPERLESS_API_TOKEN in your environment variables", - "documents": [], - "total_destinaere": 0, - "total_land": 0, - "total_admin": 0, - "total_all": 0, - }, - status=400, - ) - - try: - # Entferne /api vom Ende der URL falls vorhanden - base_url = url[:-4] if url.endswith("/api") else url - headers = {"Authorization": f"Token {token}"} - - def fetch_tagged(): - # mit ordering=-created neueste zuerst - dest_resp = requests.get( - f"{base_url}/api/documents/?tags__id={destinaere_tag_id}&ordering=-created", - headers=headers, - timeout=10, - ) - dest_resp.raise_for_status() - dest_docs = dest_resp.json() - - land_resp = requests.get( - f"{base_url}/api/documents/?tags__id={land_tag_id}&ordering=-created", - headers=headers, - timeout=10, - ) - land_resp.raise_for_status() - land_docs = land_resp.json() - - admin_resp = requests.get( - f"{base_url}/api/documents/?tags__id={admin_tag_id}&ordering=-created", - headers=headers, - timeout=10, - ) - admin_resp.raise_for_status() - admin_docs = admin_resp.json() - - return dest_docs, land_docs, admin_docs - - dest_docs, land_docs, admin_docs = fetch_tagged() - - # Optionales kurzes Polling, wenn angefordert - if request.GET.get("poll") in ("1", "true", "yes"): - start_total = sum( - [ - dest_docs.get("count", 0), - land_docs.get("count", 0), - admin_docs.get("count", 0), - ] - ) - deadline = time.time() + 6.0 # bis zu 6 Sekunden warten - while time.time() < deadline: - time.sleep(1.0) - d2, l2, a2 = fetch_tagged() - new_total = sum( - [d2.get("count", 0), l2.get("count", 0), a2.get("count", 0)] - ) - if new_total > start_total: - dest_docs, land_docs, admin_docs = d2, l2, a2 - break - - # Alle Dokumente zusammenfassen - all_documents = [] - for doc in dest_docs.get("results", []): - doc["tag_category"] = "destinaere" - all_documents.append(doc) - for doc in land_docs.get("results", []): - doc["tag_category"] = "land" - all_documents.append(doc) - for doc in admin_docs.get("results", []): - doc["tag_category"] = "admin" - all_documents.append(doc) - - return Response( - { - "documents": all_documents, - "total_destinaere": dest_docs.get("count", 0), - "total_land": land_docs.get("count", 0), - "total_admin": admin_docs.get("count", 0), - "total_all": len(all_documents), - } - ) - - except requests.exceptions.RequestException as e: - import logging - logger = logging.getLogger(__name__) - logger.error(f"Paperless API request failed: {e}") - logger.error(f"Paperless API URL: {base_url}") - logger.error(f"Token configured: {'Yes' if token else 'No'}") - - return Response( - { - "error": f"API-Fehler: {e}", - "message": f"Could not connect to Paperless API at {base_url}. Please check your configuration.", - "debug_info": { - "api_url": base_url, - "has_token": bool(token), - "error_type": type(e).__name__ - }, - "documents": [], - "total_destinaere": 0, - "total_land": 0, - "total_admin": 0, - "total_all": 0, - }, - status=500, - ) - except Exception as e: - return Response( - { - "error": f"Unerwarteter Fehler: {e}", - "message": "An unexpected error occurred while fetching documents.", - "documents": [], - "total_destinaere": 0, - "total_land": 0, - "total_admin": 0, - "total_all": 0, - }, - status=500, - ) - - -# Legacy dokument_integration view removed - use dokument_management instead - - -@api_view(["GET"]) -def paperless_debug(request): - """Debug-View für Paperless-Integration""" - from stiftung.utils.config import get_paperless_config - - config = get_paperless_config() - url = config["api_url"] - token = config["api_token"] - required_tag = config["destinataere_tag"] - land_tag = config["land_tag"] - admin_tag = config["admin_tag"] - destinaere_tag_id = config["destinataere_tag_id"] - land_tag_id = config["land_tag_id"] - admin_tag_id = config["admin_tag_id"] - - if not url or not token: - return Response({"error": "Paperless API not configured"}, status=400) - - try: - # Entferne /api vom Ende der URL falls vorhanden - base_url = url[:-4] if url.endswith("/api") else url - - headers = {"Authorization": f"Token {token}"} - - # Alle Tags abrufen - tags_response = requests.get( - f"{base_url}/api/tags/?page_size=1000", headers=headers, timeout=10 - ) - tags_response.raise_for_status() - tags_data = tags_response.json() - - # Alle Tags durchsuchen - all_tags = tags_data.get("results", []) - exact_match_destinaere = None - exact_match_land = None - exact_match_admin = None - similar_tags = [] - - # Nach den neuen Tag-Namen suchen (mit Unterstrichen) - for tag in all_tags: - tag_name = tag.get("name", "") - tag_id = tag.get("id") - - # Suche nach den neuen Tag-Namen - if tag_name == "Stiftung_Destinatäre": - exact_match_destinaere = {"id": tag_id, "name": tag_name} - elif tag_name == "Stiftung_Land_und_Pächter": - exact_match_land = {"id": tag_id, "name": tag_name} - elif tag_name == "Stiftung_Administration": - exact_match_admin = {"id": tag_id, "name": tag_name} - - # Ähnliche Tags finden - if ( - "stiftung" in tag_name.lower() - or "destinat" in tag_name.lower() - or "land" in tag_name.lower() - or "admin" in tag_name.lower() - ): - similar_tags.append({"id": tag_id, "name": tag_name}) - - # Alle Tag-Namen sammeln - all_tag_names = [tag.get("name", "") for tag in all_tags] - - # Dokumente abrufen - documents_response = requests.get( - f"{base_url}/api/documents/?page_size=10", headers=headers, timeout=10 - ) - documents_response.raise_for_status() - documents_data = documents_response.json() - - # Stiftung-Dokumente finden (mit Tag 21 "Stiftung") - stiftung_documents = [] - for doc in documents_data.get("results", []): - doc_tags = doc.get("tags", []) - if 21 in doc_tags: # Tag 21 ist "Stiftung" - stiftung_documents.append(doc) - - # Sample-Dokumente mit Tag-Namen anreichern - sample_documents = documents_data.get("results", [])[:5] - enriched_documents = [] - - for doc in sample_documents: - doc_copy = doc.copy() - tag_names = [] - for tag_id in doc.get("tags", []): - # Tag-Namen aus der Tag-Liste finden - tag_name = next( - ( - tag.get("name", f"Unknown({tag_id})") - for tag in all_tags - if tag.get("id") == tag_id - ), - f"Unknown({tag_id})", - ) - tag_names.append(tag_name) - doc_copy["tag_names"] = tag_names - enriched_documents.append(doc_copy) - - return Response( - { - "paperless_url": url, - "base_url": base_url, - "required_tag": required_tag, - "land_tag": land_tag, - "admin_tag": admin_tag, - "destinaere_tag_id": destinaere_tag_id, - "land_tag_id": land_tag_id, - "admin_tag_id": admin_tag_id, - "exact_match_destinaere": exact_match_destinaere, - "exact_match_land": exact_match_land, - "exact_match_admin": exact_match_admin, - "similar_tags": similar_tags, - "all_tag_names": all_tag_names, - "total_tags": len(all_tags), - "total_documents": documents_data.get("count", 0), - "sample_documents": sample_documents, - "api_token_length": len(token) if token else 0, - "enriched_documents": enriched_documents, - "stiftung_documents": stiftung_documents, - } - ) - - except requests.exceptions.RequestException as e: - return Response({"error": f"API-Fehler: {e}"}, status=500) - except Exception as e: - return Response({"error": f"Unerwarteter Fehler: {e}"}, status=500) - - -@api_view(["GET"]) -def paperless_tags_only(request): - """Holt nur die Tag-Liste aus Paperless - ohne Dokumente""" - from stiftung.utils.config import get_paperless_config - - config = get_paperless_config() - url = config["api_url"] - token = config["api_token"] - - if not url or not token: - return Response({"error": "Paperless API not configured"}, status=400) - - try: - # Entferne /api vom Ende der URL falls vorhanden - base_url = url[:-4] if url.endswith("/api") else url - - # Alle Tags abrufen (mit großer page_size) - headers = {"Authorization": f"Token {token}"} - - # Erste Anfrage mit großer page_size - tags_response = requests.get( - f"{base_url}/api/tags/?page_size=1000", headers=headers, timeout=10 - ) - tags_response.raise_for_status() - tags_data = tags_response.json() - - all_tags = [] - - # Erste Seite verarbeiten - for tag in tags_data.get("results", []): - tag_detail = { - "id": tag.get("id"), - "name": tag.get("name", ""), - "slug": tag.get("slug", ""), - "color": tag.get("color", ""), - "text_color": tag.get("text_color", ""), - "match": tag.get("match", ""), - "matching_algorithm": tag.get("matching_algorithm"), - "is_inbox_tag": tag.get("is_inbox_tag"), - "document_count": tag.get("document_count", 0), - } - all_tags.append(tag_detail) - - # Weitere Seiten abrufen falls vorhanden - next_url = tags_data.get("next") - while next_url: - next_response = requests.get(next_url, headers=headers, timeout=10) - next_response.raise_for_status() - next_data = next_response.json() - - for tag in next_data.get("results", []): - tag_detail = { - "id": tag.get("id"), - "name": tag.get("name", ""), - "slug": tag.get("slug", ""), - "color": tag.get("color", ""), - "text_color": tag.get("text_color", ""), - "match": tag.get("match", ""), - "matching_algorithm": tag.get("matching_algorithm"), - "is_inbox_tag": tag.get("is_inbox_tag"), - "document_count": tag.get("document_count", 0), - } - all_tags.append(tag_detail) - - next_url = next_data.get("next") - - # Nach ID sortieren - all_tags.sort(key=lambda x: x["id"]) - - return Response( - { - "total_tags": len(all_tags), - "tags": all_tags, - "tag_ids": [tag["id"] for tag in all_tags], - "tag_names": [tag["name"] for tag in all_tags], - "api_info": { - "page_size_used": 1000, - "total_count_from_api": tags_data.get("count", 0), - }, - } - ) - - except requests.exceptions.RequestException as e: - return Response({"error": f"API-Fehler: {e}"}, status=500) - except Exception as e: - return Response({"error": f"Unerwarteter Fehler: {e}"}, status=500) - - -@api_view(["GET"]) -def link_document_search(request): - """Sucht nach Datensätzen für die Dokument-Verknüpfung""" - from django.db.models import Q - - query = request.GET.get("q", "") - category = request.GET.get("category", "all") - - results = {} - - if category in ["all", "destinataer"]: - # Suche nach Destinatären - destinataer_query = Q() - if query and query != "all": - destinataer_query = ( - Q(nachname__icontains=query) - | Q(vorname__icontains=query) - | Q(email__icontains=query) - | Q(telefon__icontains=query) - | Q(strasse__icontains=query) - | Q(ort__icontains=query) - | Q(plz__icontains=query) - | Q(institution__icontains=query) - | Q(familienzweig__icontains=query) - | Q(notizen__icontains=query) - ) - - destinataer_results = Destinataer.objects.filter(destinataer_query)[:25] - results["destinataer"] = [ - { - "id": d.id, - "name": ( - f"{d.vorname} {d.nachname}".strip() - if d.vorname - else (d.institution or d.nachname) - ), - "type": "Destinatär", - "details": f"{d.strasse or ''}{(', ' + d.plz + ' ' + d.ort) if d.plz and d.ort else (' ' + d.ort) if d.ort else ''} {('• ' + d.email) if d.email else ''} {('• ' + d.institution) if d.institution else ''} {('• ' + d.familienzweig) if d.familienzweig else ''}".strip(), - } - for d in destinataer_results - ] - - if category in ["all", "land"]: - # Suche nach Ländereien - land_query = Q() - if query and query != "all": - # Extract numbers from search terms like "Flur 9" or "Flurstück 11" - import re - - flur_match = re.search(r"flur\s*(\d+)", query, re.IGNORECASE) - flurstuck_match = re.search(r"flurstück\s*(\d+)", query, re.IGNORECASE) - - land_query = ( - Q(gemarkung__icontains=query) - | Q(gemeinde__icontains=query) - | Q(flur__icontains=query) - | Q(flurstueck__icontains=query) - | Q(lfd_nr__icontains=query) - | Q(ew_nummer__icontains=query) - | Q(notizen__icontains=query) - ) - - # Add specific searches for extracted numbers - if flur_match: - land_query |= Q(flur__exact=flur_match.group(1)) - if flurstuck_match: - land_query |= Q(flurstueck__exact=flurstuck_match.group(1)) - - land_results = Land.objects.filter(land_query)[:25] - results["land"] = [ - { - "id": l.id, - "name": f"{l.gemarkung} - {l.gemeinde}, Flur {l.flur}, Flurstück {l.flurstueck or 'N/A'}", - "type": "Land", - "details": f"Lfd.Nr: {l.lfd_nr or 'N/A'} • EW-Nr: {l.ew_nummer or 'N/A'} • Größe: {l.groesse_qm or 0} m²", - } - for l in land_results - ] - - if category in ["all", "verpachtung"]: - # Suche nach Verpachtungen (using new LandVerpachtung model) - verpachtung_query = Q() - if query and query != "all": - verpachtung_query = ( - Q(paechter__nachname__icontains=query) - | Q(paechter__vorname__icontains=query) - | Q(paechter__ort__icontains=query) - | Q(paechter__email__icontains=query) - | Q(paechter__pachtnummer__icontains=query) - | Q(land__gemarkung__icontains=query) - | Q(land__gemeinde__icontains=query) - | Q(land__flur__icontains=query) - | Q(land__flurstueck__icontains=query) - | Q(land__lfd_nr__icontains=query) - | Q(vertragsnummer__icontains=query) - | Q(pachtzins_pauschal__icontains=query) - | Q(bemerkungen__icontains=query) - ) - - verpachtung_results = LandVerpachtung.objects.filter( - verpachtung_query - ).select_related("paechter", "land")[:25] - results["verpachtung"] = [ - { - "id": v.id, - "name": f"{(v.paechter.vorname + ' ') if v.paechter and v.paechter.vorname else ''}{v.paechter.nachname if v.paechter else 'Unbekannt'} → {v.land.gemarkung}, Flur {v.land.flur}", - "type": "Verpachtung", - "details": f"Vertrag: {v.vertragsnummer} • Flurstück: {v.land.flurstueck or 'N/A'} • Pachtzins: {v.pachtzins_pauschal or 'N/A'} €/Jahr • Zeitraum: {v.pachtbeginn.strftime('%Y') if v.pachtbeginn else '?'}-{v.pachtende.strftime('%Y') if v.pachtende else 'laufend'}", - } - for v in verpachtung_results - ] - - if category in ["all", "paechter"]: - # Suche nach Pächtern - paechter_query = Q() - if query and query != "all": - paechter_query = ( - Q(nachname__icontains=query) - | Q(vorname__icontains=query) - | Q(ort__icontains=query) - | Q(email__icontains=query) - | Q(telefon__icontains=query) - | Q(strasse__icontains=query) - | Q(pachtnummer__icontains=query) - | Q(plz__icontains=query) - | Q(notizen__icontains=query) - ) - paechter_results = Paechter.objects.filter(paechter_query)[:25] - results["paechter"] = [ - { - "id": p.id, - "name": f"{(p.vorname + ' ') if p.vorname else ''}{p.nachname}" - + (f" (#{p.pachtnummer})" if p.pachtnummer else ""), - "type": "Pächter", - "details": f"{p.strasse or ''}{(', ' + p.plz + ' ' + p.ort) if p.plz and p.ort else (' ' + p.ort) if p.ort else ''} {('• ' + p.email) if p.email else ''} {('• Tel: ' + p.telefon) if p.telefon else ''}".strip(), - } - for p in paechter_results - ] - - if category in ["all", "rentmeister"]: - # Suche nach Rentmeistern - from stiftung.models import Rentmeister - - rentmeister_query = Q() - if query and query != "all": - rentmeister_query = ( - Q(nachname__icontains=query) - | Q(vorname__icontains=query) - | Q(ort__icontains=query) - | Q(email__icontains=query) - | Q(telefon__icontains=query) - | Q(strasse__icontains=query) - | Q(plz__icontains=query) - | Q(notizen__icontains=query) - | Q(titel__icontains=query) - | Q(mobil__icontains=query) - ) - rentmeister_results = Rentmeister.objects.filter(rentmeister_query)[:25] - results["rentmeister"] = [ - { - "id": r.id, - "name": f"{(r.anrede + ' ') if r.anrede else ''}{(r.vorname + ' ') if r.vorname else ''}{r.nachname}" - + (f" ({r.titel})" if r.titel else ""), - "type": "Rentmeister", - "details": f"{r.strasse or ''}{(', ' + r.plz + ' ' + r.ort) if r.plz and r.ort else (' ' + r.ort) if r.ort else ''} {('• ' + r.email) if r.email else ''} {('• Tel: ' + r.telefon) if r.telefon else ''} {('• Mobil: ' + r.mobil) if r.mobil else ''}".strip(), - } - for r in rentmeister_results - ] - - if category in ["all", "abrechnung"]: - # Suche nach Abrechnungen - abrechnung_query = Q() - if query and query != "all": - abrechnung_query = ( - Q(land__gemarkung__icontains=query) - | Q(land__gemeinde__icontains=query) - | Q(land__flur__icontains=query) - | Q(land__flurstueck__icontains=query) - | Q(land__lfd_nr__icontains=query) - | Q(abrechnungsjahr__icontains=query) - | Q(bemerkungen__icontains=query) - ) - - abrechnung_results = LandAbrechnung.objects.filter( - abrechnung_query - ).select_related("land")[:25] - results["abrechnung"] = [ - { - "id": a.id, - "name": f"Abrechnung {a.abrechnungsjahr} - {a.land.gemarkung}, Flur {a.land.flur}", - "type": "Abrechnung", - "details": f"Flurstück: {a.land.flurstueck or 'N/A'} • Jahr: {a.abrechnungsjahr} • Grundsteuer: {a.grundsteuer_betrag or 0} € • Versicherung: {a.versicherungen_betrag or 0} €", - } - for a in abrechnung_results - ] - - if category in ["all", "foerderung"]: - # Suche nach Förderungen - foerderung_query = Q() - if query and query != "all": - foerderung_query = ( - Q(destinataer__nachname__icontains=query) - | Q(destinataer__vorname__icontains=query) - | Q(destinataer__institution__icontains=query) - | Q(destinataer__email__icontains=query) - | Q(jahr__icontains=query) - | Q(betrag__icontains=query) - | Q(kategorie__icontains=query) - | Q(status__icontains=query) - | Q(bemerkungen__icontains=query) - ) - - foerderung_results = Foerderung.objects.filter(foerderung_query).select_related( - "destinataer" - )[:25] - results["foerderung"] = [ - { - "id": str(f.id), # Convert UUID to string for JSON serialization - "name": f"{f.destinataer.get_full_name() if f.destinataer else 'Unbekannt'} - {f.jahr}", - "type": "Förderung", - "details": f"Betrag: {f.betrag} € • Kategorie: {f.get_kategorie_display()} • Status: {f.get_status_display()} • Jahr: {f.jahr}", - } - for f in foerderung_results - ] - - return Response(results) - - -def create_paechter_link_for_verpachtung(paperless_id, paperless_title, verpachtung_id): - """Hilfsfunktion: Erstellt automatisch eine Pächter-Verknüpfung für eine Verpachtung""" - try: - # Hole die LandVerpachtung und den zugehörigen Pächter - verpachtung = LandVerpachtung.objects.select_related("paechter").get( - id=verpachtung_id - ) - if verpachtung.paechter: - # Prüfe, ob bereits eine Verknüpfung für dieses Dokument und diesen Pächter existiert - existing_link = DokumentLink.objects.filter( - paperless_document_id=paperless_id, paechter_id=verpachtung.paechter.id - ).first() - - if not existing_link: - # Erstelle automatische Pächter-Verknüpfung - DokumentLink.objects.create( - paperless_document_id=paperless_id, - titel=paperless_title, - kontext="paechter", - paechter_id=verpachtung.paechter.id, - ) - return True - except (LandVerpachtung.DoesNotExist, Exception): - pass - return False - - -@csrf_exempt -@api_view(["POST"]) -def link_document_create(request): - """Erstellt eine Verknüpfung zwischen einem Paperless-Dokument und einem Datensatz""" - from django.db import transaction - - try: - # Robust JSON parsing to tolerate Latin-1 encoded payloads (e.g., umlauts) - try: - payload = request.data - except Exception: - raw = request.body - try: - payload = json.loads(raw.decode("utf-8")) - except UnicodeDecodeError: - payload = json.loads(raw.decode("latin-1")) - - paperless_id = payload.get("paperless_id") - paperless_title = payload.get("paperless_title") - paperless_url = payload.get("paperless_url") - link_type = payload.get( - "link_type" - ) # 'destinataer', 'land', 'verpachtung', 'paechter', 'abrechnung' - link_id = payload.get("link_id") - - if not all([paperless_id, paperless_title, paperless_url, link_type, link_id]): - return Response({"error": "Alle Felder sind erforderlich"}, status=400) - - with transaction.atomic(): - # Erstelle den DokumentLink - dokument_link = DokumentLink.objects.create( - paperless_document_id=paperless_id, # Korrigiert: 'paperless_document_id' statt 'paperless_id' - titel=paperless_title, # Korrigiert: 'titel' statt 'title' - kontext="anderes", - ) - - # Setze die entsprechenden UUID-Felder basierend auf dem Link-Typ - if link_type == "destinataer": - dokument_link.destinataer_id = link_id - elif link_type == "land": - dokument_link.land_id = link_id - elif link_type == "verpachtung": - # Use new LandVerpachtung field instead of legacy - dokument_link.land_verpachtung_id = link_id - elif link_type == "paechter": - dokument_link.paechter_id = link_id - elif link_type == "foerderung": - dokument_link.foerderung_id = link_id - elif link_type == "rentmeister": - dokument_link.rentmeister_id = link_id - elif link_type == "abrechnung": - dokument_link.abrechnung_id = link_id - - dokument_link.save() - - # Log the document linking action - from stiftung.audit import log_link - - try: - # Get the linked entity name for logging - entity_name = paperless_title - if link_type == "destinataer": - from stiftung.models import Destinataer - - entity = Destinataer.objects.get(id=link_id) - target_name = entity.get_full_name() - elif link_type == "land": - from stiftung.models import Land - - entity = Land.objects.get(id=link_id) - target_name = str(entity) - elif link_type == "paechter": - from stiftung.models import Paechter - - entity = Paechter.objects.get(id=link_id) - target_name = f"{entity.vorname} {entity.nachname}".strip() - elif link_type == "foerderung": - from stiftung.models import Foerderung - - entity = Foerderung.objects.get(id=link_id) - target_name = f"{entity.destinataer.get_full_name() if entity.destinataer else 'Unbekannt'} - {entity.jahr}" - elif link_type == "verpachtung": - entity = LandVerpachtung.objects.get(id=link_id) - target_name = str(entity) - elif link_type == "rentmeister": - from stiftung.models import Rentmeister - - entity = Rentmeister.objects.get(id=link_id) - target_name = entity.get_full_name() - else: - target_name = f"ID {link_id}" - - log_link( - request=request, - entity_type="dokumentlink", - entity_id=str(dokument_link.id), - entity_name=entity_name, - target_type=link_type, - target_name=target_name, - ) - except Exception as e: - # Don't fail the main operation if logging fails - print(f"Audit logging failed: {e}") - - # Automatische Pächter-Verknüpfung NACH der Haupttransaktion - paechter_linked = False - if link_type == "verpachtung": - paechter_linked = create_paechter_link_for_verpachtung( - paperless_id, paperless_title, link_id - ) - - message = f"Dokument erfolgreich mit {link_type} verknüpft" - if paechter_linked: - message += " (automatisch auch mit Pächter verknüpft)" - - return Response( - {"success": True, "message": message, "dokument_id": dokument_link.id} - ) - - except Exception as e: - return Response( - {"error": f"Fehler beim Erstellen der Verknüpfung: {str(e)}"}, status=500 - ) - - -# Legacy dokument_verknuepfung view removed - use dokument_management instead - - -@api_view(["GET"]) -def link_document_list(request): - """Listet alle bestehenden Dokument-Verknüpfungen auf, gruppiert nach Paperless-Document-ID""" - try: - dokument_links = DokumentLink.objects.all().order_by("-id") - - # Group links by paperless_document_id to show multiple links per document - links_by_document = {} - - for link in dokument_links: - paperless_id = link.paperless_document_id - - if paperless_id not in links_by_document: - links_by_document[paperless_id] = { - "paperless_id": paperless_id, - "title": link.titel, - "paperless_url": f"/api/paperless/documents/{paperless_id}/", - "links": [], - } - - # Create link info - link_info = { - "id": str(link.id), # Ensure UUID is stringified - "kontext": link.kontext, - "link_type": None, - "linked_object": None, - } - - # Determine link type and get linked object details - if link.destinataer_id: - link_info["link_type"] = "destinataer" - try: - dest = Destinataer.objects.get(id=link.destinataer_id) - link_info["linked_object"] = { - "id": str(dest.id), - "type": "Destinatär", - "name": ( - f"{dest.vorname} {dest.nachname}".strip() - if dest.vorname - else dest.institution - ), - "details": ( - f"Institution: {dest.institution}" - if dest.institution - else f"Person: {dest.vorname} {dest.nachname}".strip() - ), - } - except Destinataer.DoesNotExist: - link_info["linked_object"] = { - "type": "Destinatär", - "name": "Gelöscht", - "details": "Datensatz nicht mehr verfügbar", - } - - elif link.land_id: - link_info["link_type"] = "land" - try: - land = Land.objects.get(id=link.land_id) - link_info["linked_object"] = { - "id": str(land.id), - "type": "Land", - "name": f"{land.gemarkung} - {land.gemeinde}", - "details": f"Flur: {land.flur}, Größe: {land.groesse_qm or 0} m²", - } - except Land.DoesNotExist: - link_info["linked_object"] = { - "type": "Land", - "name": "Gelöscht", - "details": "Datensatz nicht mehr verfügbar", - } - - elif link.paechter_id: - link_info["link_type"] = "paechter" - try: - p = Paechter.objects.get(id=link.paechter_id) - link_info["linked_object"] = { - "id": str(p.id), - "type": "Pächter", - "name": f"{(p.vorname + ' ') if p.vorname else ''}{p.nachname}", - "details": f"{p.ort or ''}", - } - except Paechter.DoesNotExist: - link_info["linked_object"] = { - "type": "Pächter", - "name": "Gelöscht", - "details": "Datensatz nicht mehr verfügbar", - } - - elif link.land_verpachtung_id: - link_info["link_type"] = "verpachtung" - try: - from stiftung.models import LandVerpachtung - - verp = LandVerpachtung.objects.select_related( - "paechter", "land" - ).get(id=link.land_verpachtung_id) - link_info["linked_object"] = { - "id": str(verp.id), - "type": "Verpachtung", - "name": f"Verpachtung {(verp.paechter.vorname + ' ') if verp.paechter and verp.paechter.vorname else ''}{verp.paechter.nachname if verp.paechter else 'Unbekannt'}", - "details": f"Land: {verp.land.gemarkung} - {verp.land.gemeinde}, Pächter: {(verp.paechter.vorname + ' ') if verp.paechter and verp.paechter.vorname else ''}{verp.paechter.nachname if verp.paechter else 'Unbekannt'}, Vertrag: {verp.vertragsnummer}", - } - except LandVerpachtung.DoesNotExist: - link_info["linked_object"] = { - "type": "Verpachtung", - "name": "Gelöscht", - "details": "Datensatz nicht mehr verfügbar", - } - - elif link.rentmeister_id: - link_info["link_type"] = "rentmeister" - try: - from stiftung.models import Rentmeister - - rentmeister = Rentmeister.objects.get(id=link.rentmeister_id) - link_info["linked_object"] = { - "id": str(rentmeister.id), - "type": "Rentmeister", - "name": f"{(rentmeister.anrede + ' ') if rentmeister.anrede else ''}{(rentmeister.vorname + ' ') if rentmeister.vorname else ''}{rentmeister.nachname}" - + (f" ({rentmeister.titel})" if rentmeister.titel else ""), - "details": f"Seit: {rentmeister.seit_datum.strftime('%d.%m.%Y') if rentmeister.seit_datum else 'Unbekannt'}" - + ( - f", Tel: {rentmeister.telefon}" - if rentmeister.telefon - else "" - ) - + (f", {rentmeister.email}" if rentmeister.email else ""), - "url": f"/geschaeftsfuehrung/rentmeister/{rentmeister.id}/", - } - except Rentmeister.DoesNotExist: - link_info["linked_object"] = { - "type": "Rentmeister", - "name": "Gelöscht", - "details": "Datensatz nicht mehr verfügbar", - } - - elif link.abrechnung_id: - link_info["link_type"] = "abrechnung" - try: - abrechnung = LandAbrechnung.objects.select_related("land").get( - id=link.abrechnung_id - ) - link_info["linked_object"] = { - "id": str(abrechnung.id), - "type": "Abrechnung", - "name": f"Abrechnung {abrechnung.abrechnungsjahr} - {abrechnung.land.gemarkung}", - "details": f"Land: {abrechnung.land.gemarkung} - {abrechnung.land.gemeinde}, Flur {abrechnung.land.flur}, Jahr {abrechnung.abrechnungsjahr}", - "url": f"/laendereien/abrechnungen/{abrechnung.id}/", - } - except LandAbrechnung.DoesNotExist: - link_info["linked_object"] = { - "type": "Abrechnung", - "name": "Gelöscht", - "details": "Datensatz nicht mehr verfügbar", - } - - links_by_document[paperless_id]["links"].append(link_info) - - # Convert to list format for frontend - results = list(links_by_document.values()) - - return Response( - { - "total_documents": len(results), - "total_links": sum(len(doc["links"]) for doc in results), - "links": results, - } - ) - - except Exception as e: - return Response( - {"error": f"Fehler beim Abrufen der Verknüpfungen: {str(e)}"}, status=500 - ) - - -@csrf_exempt -@api_view(["POST"]) -def link_document_update(request): - """Aktualisiert die Verknüpfung eines Dokuments (Re-Linking auf anderen Datensatz/Kontext).""" - from django.db import transaction - - try: - # Robust JSON parsing to tolerate Latin-1 encoded payloads (e.g., umlauts) - try: - payload = request.data - except Exception: - raw = request.body - try: - payload = json.loads(raw.decode("utf-8")) - except UnicodeDecodeError: - payload = json.loads(raw.decode("latin-1")) - - link_id = payload.get("link_id") - link_type = payload.get( - "link_type" - ) # 'destinataer' | 'land' | 'verpachtung' | 'paechter' | 'rentmeister' - link_target_id = payload.get("link_id_target") - if not all([link_id, link_type, link_target_id]): - return Response( - {"error": "link_id, link_type und link_id_target sind erforderlich"}, - status=400, - ) - - with transaction.atomic(): - link = DokumentLink.objects.get(id=link_id) - old_verpachtung_id = ( - link.verpachtung_id - ) # Merke alte Verpachtung für Cleanup - paperless_id_for_cleanup = link.paperless_document_id - titel_for_new_link = link.titel - - # Reset all associations first - link.destinataer_id = None - link.land_id = None - link.verpachtung_id = None - link.paechter_id = None - link.foerderung_id = None - link.rentmeister_id = None - link.kontext = link_type - - if link_type == "destinataer": - link.destinataer_id = link_target_id - elif link_type == "land": - link.land_id = link_target_id - elif link_type == "verpachtung": - link.verpachtung_id = link_target_id - elif link_type == "paechter": - link.paechter_id = link_target_id - elif link_type == "foerderung": - link.foerderung_id = link_target_id - elif link_type == "rentmeister": - link.rentmeister_id = link_target_id - else: - return Response({"error": "Ungültiger link_type"}, status=400) - - link.save() - - # Automatische Pächter-Verknüpfung und Cleanup NACH der Haupttransaktion - paechter_linked = False - if link_type == "verpachtung": - paechter_linked = create_paechter_link_for_verpachtung( - paperless_id_for_cleanup, titel_for_new_link, link_target_id - ) - - # Cleanup: Entferne verwaiste Pächter-Verknüpfungen wenn von Verpachtung weg geändert - if old_verpachtung_id and link_type != "verpachtung": - try: - old_verpachtung = LandVerpachtung.objects.select_related( - "paechter" - ).get(id=old_verpachtung_id) - if old_verpachtung.paechter: - # Prüfe ob noch andere Verpachtungs-Links für diesen Pächter existieren - other_verpachtung_links = DokumentLink.objects.filter( - paperless_document_id=paperless_id_for_cleanup, - verpachtung__paechter_id=old_verpachtung.paechter.id, - ).exists() - - if not other_verpachtung_links: - # Entferne automatisch erstellte Pächter-Verknüpfung - DokumentLink.objects.filter( - paperless_document_id=paperless_id_for_cleanup, - paechter_id=old_verpachtung.paechter.id, - kontext="paechter", - ).delete() - except (LandVerpachtung.DoesNotExist, Exception): - pass - - message = "Verknüpfung aktualisiert" - if paechter_linked: - message += " (automatisch auch mit Pächter verknüpft)" - - return Response({"success": True, "message": message}) - except DokumentLink.DoesNotExist: - return Response({"error": "Verknüpfung nicht gefunden"}, status=404) - except Exception as e: - return Response({"error": f"Unerwarteter Fehler: {e}"}, status=500) - - -@csrf_exempt -@api_view(["DELETE"]) -def link_document_delete(request, link_id): - """Löscht eine bestehende Verknüpfung.""" - from django.db import transaction - - try: - with transaction.atomic(): - link = DokumentLink.objects.get(id=link_id) - verpachtung_id_for_cleanup = link.verpachtung_id - paperless_id_for_cleanup = link.paperless_document_id - - # Log the unlinking action before deletion - from stiftung.audit import log_unlink - - try: - # Determine what entity this was linked to - target_type = "unknown" - target_name = "Unknown" - - if link.destinataer_id: - target_type = "destinataer" - try: - entity = Destinataer.objects.get(id=link.destinataer_id) - target_name = entity.get_full_name() - except Destinataer.DoesNotExist: - target_name = f"Destinatär ID {link.destinataer_id}" - elif link.land_id: - target_type = "land" - try: - entity = Land.objects.get(id=link.land_id) - target_name = str(entity) - except Land.DoesNotExist: - target_name = f"Land ID {link.land_id}" - elif link.paechter_id: - target_type = "paechter" - try: - entity = Paechter.objects.get(id=link.paechter_id) - target_name = f"{entity.vorname} {entity.nachname}".strip() - except Paechter.DoesNotExist: - target_name = f"Pächter ID {link.paechter_id}" - elif link.verpachtung_id: - target_type = "verpachtung" - try: - entity = LandVerpachtung.objects.get(id=link.verpachtung_id) - target_name = str(entity) - except LandVerpachtung.DoesNotExist: - target_name = f"Verpachtung ID {link.verpachtung_id}" - elif link.rentmeister_id: - target_type = "rentmeister" - try: - from stiftung.models import Rentmeister - - entity = Rentmeister.objects.get(id=link.rentmeister_id) - target_name = entity.get_full_name() - except Rentmeister.DoesNotExist: - target_name = f"Rentmeister ID {link.rentmeister_id}" - - log_unlink( - request=request, - entity_type="dokumentlink", - entity_id=str(link.id), - entity_name=link.titel, - target_type=target_type, - target_name=target_name, - ) - except Exception as e: - # Don't fail the main operation if logging fails - print(f"Audit logging failed: {e}") - - link.delete() - - # Cleanup NACH der Haupttransaktion: Wenn eine Verpachtungs-Verknüpfung gelöscht wurde, prüfe Pächter-Links - if verpachtung_id_for_cleanup: - try: - verpachtung = LandVerpachtung.objects.select_related("paechter").get( - id=verpachtung_id_for_cleanup - ) - if verpachtung.paechter: - # Prüfe ob noch andere Verpachtungs-Links für diesen Pächter existieren - other_verpachtung_links = DokumentLink.objects.filter( - paperless_document_id=paperless_id_for_cleanup, - verpachtung__paechter_id=verpachtung.paechter.id, - ).exists() - - if not other_verpachtung_links: - # Entferne automatisch erstellte Pächter-Verknüpfung - DokumentLink.objects.filter( - paperless_document_id=paperless_id_for_cleanup, - paechter_id=verpachtung.paechter.id, - kontext="paechter", - ).delete() - except (LandVerpachtung.DoesNotExist, Exception): - pass - - return Response({"success": True}) - except DokumentLink.DoesNotExist: - return Response({"error": "Verknüpfung nicht gefunden"}, status=404) - except Exception as e: - return Response({"error": f"Unerwarteter Fehler: {e}"}, status=500) - - +# Phase 3: Vision 2026 – Paperless-Code entfernt +# Alle DokumentLink/Paperless-Views wurden im Rahmen der DMS-Migration entfernt. +# Dokumente werden jetzt über das Django-DMS (DokumentDatei) verwaltet. +# DMS-Views: stiftung/views/dms.py diff --git a/app/stiftung/views/foerderung.py b/app/stiftung/views/foerderung.py index 012e09d..2fa5c9a 100644 --- a/app/stiftung/views/foerderung.py +++ b/app/stiftung/views/foerderung.py @@ -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 = { diff --git a/app/stiftung/views/geschichte.py b/app/stiftung/views/geschichte.py index 5f71712..76dc757 100644 --- a/app/stiftung/views/geschichte.py +++ b/app/stiftung/views/geschichte.py @@ -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,11 +105,18 @@ 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,12 +141,19 @@ 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() diff --git a/app/stiftung/views/land.py b/app/stiftung/views/land.py index 3fd7ac2..b04a542 100644 --- a/app/stiftung/views/land.py +++ b/app/stiftung/views/land.py @@ -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 = { diff --git a/app/templates/base.html b/app/templates/base.html index 670f92d..cb70345 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -657,11 +657,7 @@ - DMS - - - - Paperless (Legacy) + Dokumente diff --git a/app/templates/stiftung/administration.html b/app/templates/stiftung/administration.html index a081914..c93f00e 100644 --- a/app/templates/stiftung/administration.html +++ b/app/templates/stiftung/administration.html @@ -260,7 +260,7 @@
- + Dokumentenverwaltung diff --git a/app/templates/stiftung/destinataer_detail.html b/app/templates/stiftung/destinataer_detail.html index d013a44..8253c6e 100644 --- a/app/templates/stiftung/destinataer_detail.html +++ b/app/templates/stiftung/destinataer_detail.html @@ -622,8 +622,8 @@ {# ════════ TAB: Dokumente ════════ #}
{% if verknuepfte_dokumente %} @@ -635,14 +635,17 @@ {% for d in verknuepfte_dokumente %} - {{ d.titel }}
ID: {{ d.paperless_document_id }} + + {{ d.titel }} + {% if d.dateiname_original %}
{{ d.dateiname_original }} ({{ d.get_human_size }}){% endif %} + {{ d.get_kontext_display }} {{ d.beschreibung|default:"-"|truncatewords:10 }}
- - - + + +
@@ -653,7 +656,7 @@ {% else %}
-

Keine Dokumente verknuepft.

+

Keine Dokumente vorhanden.

{% endif %}
diff --git a/app/templates/stiftung/dms/upload.html b/app/templates/stiftung/dms/upload.html index 6d57567..458bdb6 100644 --- a/app/templates/stiftung/dms/upload.html +++ b/app/templates/stiftung/dms/upload.html @@ -18,6 +18,8 @@
{% csrf_token %} + {% if initial.foerderung_id %}{% endif %} + {% if initial.verpachtung_id %}{% endif %}
diff --git a/app/templates/stiftung/dokument_confirm_delete.html b/app/templates/stiftung/dokument_confirm_delete.html deleted file mode 100644 index ffc3396..0000000 --- a/app/templates/stiftung/dokument_confirm_delete.html +++ /dev/null @@ -1,65 +0,0 @@ -{% extends 'base.html' %} -{% load static %} - -{% block title %}Dokument löschen - Stiftungsverwaltung{% endblock %} - -{% block content %} - -
-
-
-
-

- Dokument löschen -

-
-
-
-
- Warnung! -
-

- Sind Sie sicher, dass Sie das Dokument {{ dokument.titel }} löschen möchten? -

-
- -
-
-
Dokumentdetails:
-

- Titel: {{ dokument.titel }}
- Kontext: {{ dokument.get_kontext_display }}
- Paperless ID: {{ dokument.paperless_document_id }}
- {% if dokument.beschreibung %} - Beschreibung: {{ dokument.beschreibung }} - {% endif %} -

-
-
- -
-
- Wichtiger Hinweis -
-

- Diese Aktion kann nicht rückgängig gemacht werden. Alle zugehörigen Verknüpfungen werden ebenfalls gelöscht. -

-
- - - {% csrf_token %} -
- - Abbrechen - - -
- -
-
-
-
- -{% endblock %} diff --git a/app/templates/stiftung/dokument_detail.html b/app/templates/stiftung/dokument_detail.html deleted file mode 100644 index 454c89c..0000000 --- a/app/templates/stiftung/dokument_detail.html +++ /dev/null @@ -1,168 +0,0 @@ -{% extends 'base.html' %} -{% load static %} - -{% block title %}{{ title }} - Stiftungsverwaltung{% endblock %} - -{% block content %} - -
-
- -
-

- {{ title }} -

- -
- -
- -
- -
-
-
- Dokumentdetails -
-
-
-
-
-
Titel
-

{{ dokument.titel }}

- -
Kontext
-

- {{ dokument.get_kontext_display }} -

-
-
-
Paperless ID
-

- {{ dokument.paperless_document_id }} -

- -
Erstellt
-

{{ dokument.id }}

-
-
- - {% if dokument.beschreibung %} -
-
Beschreibung
-

{{ dokument.beschreibung }}

- {% endif %} -
-
- - -
-
-
- Verknüpfungen -
-
-
- {% if dokument.foerderung_set.exists or dokument.verpachtung_set.exists %} - {% if dokument.foerderung_set.exists %} -
Förderungen
-
- {% for foerderung in dokument.foerderung_set.all %} -
-
- {{ foerderung.person.get_full_name }} - {{ foerderung.jahr }} -
- €{{ foerderung.betrag|floatformat:2 }} -
- - - -
- {% endfor %} -
- {% endif %} - - {% if dokument.verpachtung_set.exists %} -
Verpachtungen
-
- {% for verpachtung in dokument.verpachtung_set.all %} -
-
- {{ verpachtung.vertragsnummer }} - {{ verpachtung.land.gemeinde }} -
- {{ verpachtung.paechter.get_full_name }} -
- - - -
- {% endfor %} -
- {% endif %} - {% else %} -
- -
Keine Verknüpfungen
-

Dieses Dokument ist noch nicht mit Förderungen oder Verpachtungen verknüpft.

-
- {% endif %} -
-
-
- - -
- -
-
-
- Übersicht -
-
-
-
-
-
-

{{ dokument.foerderung_set.count }}

- Förderungen -
-
-
-

{{ dokument.verpachtung_set.count }}

- Verpachtungen -
-
-
-
- - -
-
-
- Schnellzugriff -
-
- -
-
-
-
-
- -{% endblock %} diff --git a/app/templates/stiftung/dokument_form.html b/app/templates/stiftung/dokument_form.html deleted file mode 100644 index 7c0222f..0000000 --- a/app/templates/stiftung/dokument_form.html +++ /dev/null @@ -1,139 +0,0 @@ -{% extends 'base.html' %} -{% load static %} - -{% block title %}{{ title }} - Stiftung Management{% endblock %} - -{% block content %} -
-
-
-
-

- {{ title }} -

-
-
-
- {% csrf_token %} - - - {% if form.land_verpachtung_id.value %} -
- - Verknüpfung: Dieses Dokument wird mit einer Verpachtung verknüpft. -
- {% elif form.verpachtung_id.value %} -
- - Verknüpfung: Dieses Dokument wird mit einer Verpachtung (Legacy) verknüpft. -
- {% elif form.land_id.value %} -
- - Verknüpfung: Dieses Dokument wird mit einer Länderei verknüpft. -
- {% elif form.paechter_id.value %} -
- - Verknüpfung: Dieses Dokument wird mit einem Pächter verknüpft. -
- {% elif form.destinataer_id.value %} -
- - Verknüpfung: Dieses Dokument wird mit einem Destinatär verknüpft. -
- {% elif form.foerderung_id.value %} -
- - Verknüpfung: Dieses Dokument wird mit einer Förderung verknüpft. -
- {% endif %} - -
-
- - {{ form.paperless_document_id }} - {% if form.paperless_document_id.errors %} -
- {{ form.paperless_document_id.errors.0 }} -
- {% endif %} - - Die Dokument-ID aus Paperless (z.B. aus der URL: /documents/12345/) - -
- -
- - {{ form.kontext }} - {% if form.kontext.errors %} -
- {{ form.kontext.errors.0 }} -
- {% endif %} -
-
- -
- - {{ form.titel }} - {% if form.titel.errors %} -
- {{ form.titel.errors.0 }} -
- {% endif %} -
- -
- - {{ form.beschreibung }} - {% if form.beschreibung.errors %} -
- {{ form.beschreibung.errors.0 }} -
- {% endif %} -
- - - {{ form.land_verpachtung_id }} - {{ form.verpachtung_id }} - {{ form.land_id }} - {{ form.paechter_id }} - {{ form.destinataer_id }} - {{ form.foerderung_id }} - -
- - Zurück zur Liste - - -
-
-
-
-
-
-{% endblock %} - -{% block extra_css %} - -{% endblock %} diff --git a/app/templates/stiftung/dokument_list.html b/app/templates/stiftung/dokument_list.html deleted file mode 100644 index ce56177..0000000 --- a/app/templates/stiftung/dokument_list.html +++ /dev/null @@ -1,146 +0,0 @@ -{% extends 'base.html' %} -{% load static %} - -{% block title %}Alle Dokumente - Stiftungsverwaltung{% endblock %} - -{% block content %} - -
-
-
-

- - Alle Dokumente -

- -
-
-
- - -
-
-
-
-
- Verknüpfte Dokumente ({{ dokumente|length }}) -
-
-
- {% if dokumente %} -
- - - - - - - - - - - {% for dokument in dokumente %} - - - - - - - {% endfor %} - -
DokumentKontextVerknüpft mitAktionen
- {{ dokument.titel }} -
- ID: {{ dokument.paperless_document_id }} -
- {{ dokument.get_kontext_display }} - - {% if dokument.verpachtung_id %} - Verpachtung - {% elif dokument.land_id %} - Länderei - {% elif dokument.paechter_id %} - Pächter - {% elif dokument.destinataer_id %} - Destinatär - {% elif dokument.foerderung_id %} - Förderung - {% else %} - Keine Verknüpfung - {% endif %} - - -
-
- {% else %} -
- -
Keine Dokumente verknüpft
-

Verknüpfen Sie Dokumente aus Paperless mit Ihren Entitäten.

-
- {% endif %} -
-
-
-
- - - {% if available_dokumente %} -
-
-
-
-
- Verfügbare Paperless-Dokumente ({{ available_dokumente|length }}) -
-
-
-
- {% for doc in available_dokumente %} -
-
-
-
{{ doc.title }}
-
- {% for tag in doc.tags %} - {% if tag == 'Stiftung_Destinatäre' or tag == 'Stiftung_Land_und_Pächter' or tag == 'Stiftung_Administration' %} - {{ tag }} - {% else %} - {{ tag }} - {% endif %} - {% endfor %} -
- -
-
-
- {% endfor %} -
-
-
-
-
- {% endif %} - -{% endblock %} diff --git a/app/templates/stiftung/dokument_management.html b/app/templates/stiftung/dokument_management.html deleted file mode 100644 index 8bfeeb8..0000000 --- a/app/templates/stiftung/dokument_management.html +++ /dev/null @@ -1,557 +0,0 @@ -{% extends 'base.html' %} -{% load static %} - -{% block title %}Dokumentenverwaltung - Stiftung{% endblock %} - -{% block content %} - -
-

Dokumentenverwaltung

- -
- -
- - -
-
- Filter -
-
-
-
- - -
-
- - -
-
- -
-
-
-
- - -
-
-
Dokumente
- -
-
-
-
-

Lade Dokumente...

-
-
-
- - - - -{% endblock %} - -{% block extra_css %} - -{% endblock %} - -{% block javascript %} - -{% endblock %} - - diff --git a/app/templates/stiftung/email_eingang/detail.html b/app/templates/stiftung/email_eingang/detail.html index de44e58..d96b5b5 100644 --- a/app/templates/stiftung/email_eingang/detail.html +++ b/app/templates/stiftung/email_eingang/detail.html @@ -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 %}
@@ -11,22 +11,31 @@ E-Mail-Eingang - Zurück zur Übersicht + Zurueck zur Uebersicht
- + {# Linke Spalte: E-Mail-Details #}
E-Mail-Details + {# Kategorie-Badge #} + {% if eingang.kategorie == "rechnung" %}Rechnung + {% elif eingang.kategorie == "destinataer" %}Destinataer + {% elif eingang.kategorie == "land_pacht" %}Land/Pacht + {% elif eingang.kategorie == "stiftungsgeschichte" %}Geschichte + {% endif %} + {# Status-Badge #} {% if eingang.status == "neu" %}Neu {% elif eingang.status == "zugewiesen" %}Zugewiesen {% elif eingang.status == "verarbeitet" %}Verarbeitet + {% elif eingang.status == "rechnung_erfasst" %}Rechnung erfasst + {% elif eingang.status == "zahlung_gebucht" %}Zahlung gebucht {% elif eingang.status == "unbekannt" %}Unbekannter Absender {% elif eingang.status == "fehler" %}Fehler {% endif %} @@ -47,17 +56,27 @@
Betreff
{{ eingang.betreff|default:"(kein Betreff)" }}
-
Destinatär
+
Destinataer
{% if eingang.destinataer %} {{ eingang.destinataer }} {% else %} - Nicht zugeordnet + Nicht zugeordnet {% endif %}
+ {% if eingang.verwaltungskosten %} +
Rechnung
+
+ + {{ eingang.verwaltungskosten.bezeichnung }} ({{ eingang.verwaltungskosten.betrag }} EUR) + + {{ eingang.verwaltungskosten.get_status_display }} +
+ {% endif %} + {% if eingang.quartalsnachweis %}
Quartalsnachweis
@@ -82,32 +101,34 @@
- - {% if dokument_links %} + {# Anhaenge / DMS-Dokumente #} + {% if dms_dokumente %}
- Anhänge in Paperless-NGX + Anhaenge ({{ dms_dokumente|length }})
- - - + + + - {% for link in dokument_links %} + {% for dok in dms_dokumente %} - - - + + + {% endfor %} @@ -115,43 +136,109 @@
TitelKontextPaperless-IDDateinameTypGroesse
{{ link.titel }}{{ link.get_kontext_display }}{{ link.paperless_document_id }}{{ dok.dateiname_original|default:dok.titel }}{{ dok.dateityp|default:"–" }}{{ dok.get_human_size }} - - Öffnen + {% if dok.datei %} + + Herunterladen + {% endif %}
- {% elif eingang.paperless_dokument_ids %} -
- - {{ eingang.paperless_dokument_ids|length }} Anhang/-hänge in Paperless hochgeladen - (IDs: {{ eingang.paperless_dokument_ids|join:", " }}), aber noch kein DokumentLink erstellt. -
{% else %}
- Keine Anhänge in dieser E-Mail. + Keine Anhaenge in dieser E-Mail.
{% endif %}
- + {# Rechte Spalte: Aktionen #}
- - {% if not eingang.destinataer or eingang.status == "unbekannt" %} + {# Kategorie aendern #} +
+
+ Kategorie +
+
+
+ {% csrf_token %} + +
+ +
+ +
+
+
+ + {# Rechnung erfassen (nur wenn noch keine zugeordnet) #} + {% if not eingang.verwaltungskosten and eingang.status != "zahlung_gebucht" %}
- Destinatär manuell zuordnen + Als Rechnung erfassen

- Die E-Mail-Adresse {{ eingang.absender_email }} - 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. +

+
+ {% csrf_token %} + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+ {% endif %} + + {# Manuelle Destinataer-Zuordnung #} + {% if not eingang.destinataer or eingang.status == "unbekannt" %} +
+
+ Destinataer zuordnen +
+
+

+ Absender {{ eingang.absender_email }} + konnte nicht automatisch zugeordnet werden.

{% csrf_token %}
- - + {% for d in alle_destinataere %}
-
{% endif %} - - {% if eingang.status != "verarbeitet" %} + {# Als verarbeitet markieren #} + {% if eingang.status != "verarbeitet" and eingang.status != "zahlung_gebucht" %}
Als verarbeitet markieren @@ -178,9 +265,8 @@ {% csrf_token %}
- - +
{% endif %} - + {# Notizen #}
Interne Notizen @@ -200,41 +286,42 @@ {% csrf_token %}
- +
-
- + {# Metadaten #}
Metadaten
Erfasst am
{{ eingang.created_at|date:"d.m.Y H:i" }}
+
Kategorie
+
{{ eingang.get_kategorie_display }}
Datensatz-ID
-
{{ eingang.pk|stringformat:"s"|slice:":8" }}…
+
{{ eingang.pk|stringformat:"s"|slice:":8" }}...
- + {# Loeschen #}
- E-Mail löschen + E-Mail loeschen
-

Diese E-Mail unwiderruflich aus dem System entfernen.

+ onsubmit="return confirm('E-Mail wirklich loeschen?');"> {% csrf_token %} -
diff --git a/app/templates/stiftung/email_eingang/list.html b/app/templates/stiftung/email_eingang/list.html index c327d6f..b40d9b9 100644 --- a/app/templates/stiftung/email_eingang/list.html +++ b/app/templates/stiftung/email_eingang/list.html @@ -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 %}

- E-Mail-Eingang (Destinatäre) + E-Mail-Eingang

@@ -17,79 +17,65 @@ Jetzt abrufen
- - Destinatäre -
- +{# Statuskarten #}
-
+
-
-
-
Gesamt
-
{{ counts.gesamt }}
-
-
-
+
Gesamt
+
{{ counts.gesamt }}
-
+
-
-
-
Neu / Unbearbeitet
-
{{ counts.neu }}
-
-
-
+
Neu
+
{{ counts.neu }}
-
+
+
+
+
Rechnungen
+
{{ counts.rechnung }}
+
+
+
+
-
-
-
Unbekannter Absender
-
{{ counts.unbekannt }}
-
-
-
-
-
-
-
-
-
-
-
-
Fehler
-
{{ counts.fehler }}
-
-
-
+
Unbekannt
+
{{ counts.unbekannt }}
- +{# Filter #}
Filter
-
+
+ placeholder="Absender, Betreff..."> +
+
+ +
@@ -100,15 +86,15 @@ {% endfor %}
-
+
- {% if search or status_filter %} + {% if search or status_filter or kategorie_filter %} {% endif %} @@ -116,11 +102,11 @@
- +{# Tabelle #}
Eingegangene E-Mails - {{ page_obj.paginator.count }} Einträge + {{ page_obj.paginator.count }} Eintraege
{% if page_obj %} @@ -130,9 +116,9 @@ Datum Absender - Destinatär Betreff - Anhänge + Kategorie + Zuordnung Status @@ -149,19 +135,27 @@ {{ e.absender_email }} {% endif %} + {{ e.betreff|truncatechars:50 }} - {% if e.destinataer %} - - {{ e.destinataer }} - + {% if e.kategorie == "rechnung" %} + Rechnung + {% elif e.kategorie == "destinataer" %} + Destinataer + {% elif e.kategorie == "land_pacht" %} + Land/Pacht + {% elif e.kategorie == "stiftungsgeschichte" %} + Geschichte {% else %} - Unbekannt + Allgemein {% endif %} - {{ e.betreff|truncatechars:60 }} - - {% if e.paperless_dokument_ids %} - {{ e.paperless_dokument_ids|length }} + + {% if e.destinataer %} + + {{ e.destinataer }} + + {% elif e.verwaltungskosten %} + {{ e.verwaltungskosten.bezeichnung|truncatechars:30 }} {% else %} {% endif %} @@ -173,6 +167,10 @@ Zugewiesen {% elif e.status == "verarbeitet" %} Verarbeitet + {% elif e.status == "rechnung_erfasst" %} + Rechnung erfasst + {% elif e.status == "zahlung_gebucht" %} + Bezahlt {% elif e.status == "unbekannt" %} Unbekannt {% elif e.status == "fehler" %} @@ -180,18 +178,9 @@ {% endif %} -
- - - - - {% csrf_token %} - - -
+ + + {% endfor %} @@ -199,14 +188,14 @@
- + {# Pagination #} {% if page_obj.has_other_pages %}
diff --git a/app/templates/stiftung/foerderung_detail.html b/app/templates/stiftung/foerderung_detail.html index 489f17f..4a9aba8 100644 --- a/app/templates/stiftung/foerderung_detail.html +++ b/app/templates/stiftung/foerderung_detail.html @@ -72,18 +72,21 @@
Verwendungsnachweis

- - {{ foerderung.verwendungsnachweis.titel }} - + {{ foerderung.verwendungsnachweis.titel }}

{% endif %} - +
-
Verknüpfte Dokumente
+
+
Dokumente
+ + Dokument hochladen + +
{% if verknuepfte_dokumente %}
@@ -100,8 +103,7 @@ @@ -128,17 +133,12 @@
{{ dokument.titel }} -
- ID: {{ dokument.paperless_document_id }} + {% if dokument.dateiname_original %}
{{ dokument.dateiname_original }} ({{ dokument.get_human_size }}){% endif %}
{{ dokument.get_kontext_display }} @@ -115,12 +117,15 @@
- {% else %} {% endif %} diff --git a/app/templates/stiftung/foerderung_form.html b/app/templates/stiftung/foerderung_form.html index eb4be80..57d88e2 100644 --- a/app/templates/stiftung/foerderung_form.html +++ b/app/templates/stiftung/foerderung_form.html @@ -123,7 +123,7 @@
{% endif %}
- Optionale Verknüpfung zu einem Dokument aus dem Paperless-System + Optionale Verknüpfung zu einem Verwendungsnachweis (Legacy)
diff --git a/app/templates/stiftung/geschichte/form.html b/app/templates/stiftung/geschichte/form.html index 2bbeef8..453ec60 100644 --- a/app/templates/stiftung/geschichte/form.html +++ b/app/templates/stiftung/geschichte/form.html @@ -177,6 +177,35 @@

+ + {% if geschichte_dokumente %} +
+
+
Verfuegbare Geschichtsdokumente
+
+
+
+ {% for dok in geschichte_dokumente %} +
+
+
+
{{ dok.titel|truncatechars:40 }}
+ {{ dok.dateiname_original }} ({{ dok.get_human_size }}) +
{{ dok.erstellt_am|date:"d.m.Y" }} +
+ + + +
+
+ {% endfor %} +
+
+ +
+ {% endif %}
{% endblock %} diff --git a/app/templates/stiftung/land_abrechnung_detail.html b/app/templates/stiftung/land_abrechnung_detail.html index 3068b7e..9ed33d5 100644 --- a/app/templates/stiftung/land_abrechnung_detail.html +++ b/app/templates/stiftung/land_abrechnung_detail.html @@ -303,11 +303,8 @@ {% endif %}
diff --git a/app/templates/stiftung/land_abrechnung_form.html b/app/templates/stiftung/land_abrechnung_form.html index 938ca43..b8c1616 100644 --- a/app/templates/stiftung/land_abrechnung_form.html +++ b/app/templates/stiftung/land_abrechnung_form.html @@ -234,14 +234,11 @@

- Dokumente werden über Paperless verwaltet und verknüpft. + Dokumente werden im DMS verwaltet.

diff --git a/app/templates/stiftung/land_detail.html b/app/templates/stiftung/land_detail.html index a14ddfc..e482d68 100644 --- a/app/templates/stiftung/land_detail.html +++ b/app/templates/stiftung/land_detail.html @@ -479,14 +479,14 @@
{% endif %} - +
- Verknüpfte Dokumente + Dokumente
- - Dokument verknüpfen + + Dokument hochladen
@@ -506,8 +506,7 @@ {{ dokument.titel }} -
- ID: {{ dokument.paperless_document_id }} + {% if dokument.dateiname_original %}
{{ dokument.dateiname_original }} ({{ dokument.get_human_size }}){% endif %} {{ dokument.get_kontext_display }} @@ -521,17 +520,14 @@ @@ -543,10 +539,10 @@ {% else %} {% endif %} diff --git a/app/templates/stiftung/land_verpachtung_detail.html b/app/templates/stiftung/land_verpachtung_detail.html index cd4517d..057809f 100644 --- a/app/templates/stiftung/land_verpachtung_detail.html +++ b/app/templates/stiftung/land_verpachtung_detail.html @@ -188,12 +188,12 @@
- +
@@ -212,14 +212,21 @@ {{ doc.titel|default:"Ohne Titel" }} -
- Paperless-ID: {{ doc.paperless_document_id }} + {% if doc.dateiname_original %}
{{ doc.dateiname_original }} ({{ doc.get_human_size }}){% endif %} {{ doc.get_kontext_display }} - - - + {% endfor %} @@ -227,7 +234,7 @@
{% else %} -

Keine Dokumente verknüpft.

+

Keine Dokumente vorhanden.

{% endif %}
diff --git a/app/templates/stiftung/paechter_detail.html b/app/templates/stiftung/paechter_detail.html index f155ae5..19805d9 100644 --- a/app/templates/stiftung/paechter_detail.html +++ b/app/templates/stiftung/paechter_detail.html @@ -279,14 +279,14 @@ - +
- Verknüpfte Dokumente + Dokumente
- - Dokument verknüpfen + + Dokument hochladen
@@ -306,8 +306,7 @@ {{ dokument.titel }} -
- ID: {{ dokument.paperless_document_id }} + {% if dokument.dateiname_original %}
{{ dokument.dateiname_original }} ({{ dokument.get_human_size }}){% endif %} {{ dokument.get_kontext_display }} @@ -321,17 +320,14 @@ @@ -343,10 +339,10 @@ {% else %} {% endif %} diff --git a/app/templates/stiftung/rentmeister_detail.html b/app/templates/stiftung/rentmeister_detail.html index b38bd21..d2ebe1f 100644 --- a/app/templates/stiftung/rentmeister_detail.html +++ b/app/templates/stiftung/rentmeister_detail.html @@ -296,8 +296,8 @@ {{ verknuepfte_dokumente.count }} {% endif %} - - Dokumentenverwaltung + + Dokumentenverwaltung
@@ -318,10 +318,7 @@
- - - + Legacy
{% endfor %} @@ -332,7 +329,7 @@ Es werden nur die neuesten 10 Dokumente angezeigt. - Alle Dokumente anzeigen + Alle Dokumente anzeigen
{% endif %} @@ -342,7 +339,7 @@
Keine Dokumente verknüpft

Verknüpfen Sie Dokumente über die - Dokumentenverwaltung. + Dokumentenverwaltung.

{% endif %} diff --git a/app/templates/stiftung/verpachtung_detail.html b/app/templates/stiftung/verpachtung_detail.html index b61e8ef..a6d4daf 100644 --- a/app/templates/stiftung/verpachtung_detail.html +++ b/app/templates/stiftung/verpachtung_detail.html @@ -292,66 +292,60 @@ {% endif %} - - {% if verknuepfte_dokumente %} -
-
-
-
-
- Verknüpfte Dokumente ({{ verknuepfte_dokumente.count }}) -
-
-
-
- {% for dokument in verknuepfte_dokumente %} -
-
-
-
-
-
{{ dokument.titel }}
- {{ dokument.kontext }} -
-
- - - -
-
-
-
-
- {% endfor %} -
-
-
-
-
- {% endif %} - - +
- Dokumente verwalten + Dokumente{% if verknuepfte_dokumente %} ({{ verknuepfte_dokumente.count }}){% endif %}
- - Dokument verknüpfen + Dokument hochladen
{% if verknuepfte_dokumente %} -

{{ verknuepfte_dokumente.count }} Dokument{{ verknuepfte_dokumente.count|pluralize:"e" }} verknüpft

+
+ + + + + + + + + + {% for dokument in verknuepfte_dokumente %} + + + + + + {% endfor %} + +
TitelKontextAktionen
+ {{ dokument.titel }} + {% if dokument.dateiname_original %}
{{ dokument.dateiname_original }} ({{ dokument.get_human_size }}){% endif %} +
{{ dokument.get_kontext_display }} + +
+
{% else %}

- 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.

{% endif %}
diff --git a/app/templates/stiftung/verpachtung_form.html b/app/templates/stiftung/verpachtung_form.html index d468eb2..b33515f 100644 --- a/app/templates/stiftung/verpachtung_form.html +++ b/app/templates/stiftung/verpachtung_form.html @@ -341,7 +341,7 @@

Dokumente können nach dem Speichern der Verpachtung verknüpft werden.

- Neues Dokument diff --git a/compose.yml b/compose.yml index 2fbe4f0..aa8e32e 100644 --- a/compose.yml +++ b/compose.yml @@ -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: diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..6f94381 --- /dev/null +++ b/deploy.sh @@ -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"