Generalize email system with invoice workflow and Stiftungsgeschichte category
- Rename DestinataerEmailEingang → EmailEingang with category support (destinataer, rechnung, land_pacht, stiftungsgeschichte, allgemein) - Add invoice capture workflow: create Verwaltungskosten from email, link DMS documents as invoice attachments, track payment status - Add Stiftungsgeschichte email category with auto-detection patterns (Ahnenforschung, Genealogie, Chronik, etc.) and DMS integration - Update poll_emails task with category detection and DMS context mapping - Show available history documents in Geschichte editor sidebar - Consolidate DMS views, remove legacy dokument templates - Update all detail/form templates for DMS document linking - Add deploy.sh script and streamline compose.yml Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
33
app/stiftung/migrations/0049_phase3_email_dms_m2m.py
Normal file
33
app/stiftung/migrations/0049_phase3_email_dms_m2m.py
Normal file
@@ -0,0 +1,33 @@
|
||||
# Generated by Django 5.0.6 on 2026-03-12 08:11
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('stiftung', '0048_phase3_dms_dokument_datei'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='destinataeremaileingang',
|
||||
name='dokument_dateien',
|
||||
field=models.ManyToManyField(blank=True, help_text='Automatisch befüllte Anhänge als Django-DMS-Dateien.', related_name='email_eingaenge', to='stiftung.dokumentdatei', verbose_name='DMS-Dokumente (Anhänge)'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='appconfiguration',
|
||||
name='category',
|
||||
field=models.CharField(choices=[('paperless', 'Paperless Integration'), ('email', 'E-Mail / IMAP'), ('general', 'General Settings'), ('corporate', 'Corporate Identity'), ('notifications', 'Notifications'), ('system', 'System Settings')], default='general', max_length=50, verbose_name='Category'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='appconfiguration',
|
||||
name='setting_type',
|
||||
field=models.CharField(choices=[('text', 'Text'), ('password', 'Password'), ('number', 'Number'), ('boolean', 'Boolean'), ('url', 'URL'), ('tag', 'Tag Name'), ('tag_id', 'Tag ID')], default='text', max_length=20, verbose_name='Type'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='destinataeremaileingang',
|
||||
name='paperless_dokument_ids',
|
||||
field=models.JSONField(blank=True, default=list, help_text='Veraltet – wird nach vollständiger Migration entfernt. Neue Anhänge in dokument_dateien.', verbose_name='Paperless Dokument-IDs (Anhänge, veraltet)'),
|
||||
),
|
||||
]
|
||||
@@ -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',
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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)'),
|
||||
),
|
||||
]
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -32,6 +32,7 @@ from .finanzen import ( # noqa: F401
|
||||
from .destinataere import ( # noqa: F401
|
||||
Destinataer,
|
||||
DestinataerEmailEingang,
|
||||
EmailEingang,
|
||||
DestinataerNotiz,
|
||||
DestinataerUnterstuetzung,
|
||||
Foerderung,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -140,46 +140,7 @@ urlpatterns = [
|
||||
views.foerderung_delete,
|
||||
name="foerderung_delete",
|
||||
),
|
||||
# Dokumente URLs
|
||||
path("dokumente/", views.dokument_list, name="dokument_list"),
|
||||
path("dokumente/<uuid:pk>/", views.dokument_detail, name="dokument_detail"),
|
||||
path("dokumente/neu/", views.dokument_create, name="dokument_create"),
|
||||
path(
|
||||
"dokumente/<uuid:pk>/bearbeiten/", views.dokument_update, name="dokument_update"
|
||||
),
|
||||
path(
|
||||
"dokumente/<uuid:pk>/loeschen/", views.dokument_delete, name="dokument_delete"
|
||||
),
|
||||
# Dokumentenverwaltung (Paperless-Integration, Verwaltung & Verknüpfung)
|
||||
path(
|
||||
"dokumente/verwaltung/", views.dokument_management, name="dokument_management"
|
||||
),
|
||||
# Legacy document URLs removed - use dokument_management instead
|
||||
# Dokument-Verknüpfung
|
||||
path(
|
||||
"api/link-document/search/",
|
||||
views.link_document_search,
|
||||
name="link_document_search",
|
||||
),
|
||||
path(
|
||||
"api/link-document/create/",
|
||||
views.link_document_create,
|
||||
name="link_document_create",
|
||||
),
|
||||
path(
|
||||
"api/link-document/list/", views.link_document_list, name="link_document_list"
|
||||
),
|
||||
path(
|
||||
"api/link-document/update/",
|
||||
views.link_document_update,
|
||||
name="link_document_update",
|
||||
),
|
||||
path(
|
||||
"api/link-document/delete/<uuid:link_id>/",
|
||||
views.link_document_delete,
|
||||
name="link_document_delete",
|
||||
),
|
||||
# Legacy dokument_verknuepfung URL removed - use dokument_management instead
|
||||
# Dokumente-URLs (DMS) – Legacy-Paperless-URLs entfernt (Phase 3)
|
||||
# Jahresbericht URLs
|
||||
path("berichte/", views.bericht_list, name="bericht_list"),
|
||||
path(
|
||||
@@ -355,19 +316,6 @@ urlpatterns = [
|
||||
# API URLs
|
||||
path("api/land-stats/", views.land_stats_api, name="land_stats_api"),
|
||||
path("api/health/", views.health_check, name="health_check"),
|
||||
path("api/paperless/ping/", views.paperless_ping, name="paperless_ping"),
|
||||
path(
|
||||
"api/paperless/documents/",
|
||||
views.paperless_documents,
|
||||
name="paperless_documents",
|
||||
),
|
||||
path("api/paperless/tags/", views.paperless_tags_only, name="paperless_tags_only"),
|
||||
path("api/paperless/debug/", views.paperless_debug, name="paperless_debug"),
|
||||
path(
|
||||
"api/paperless/documents/<int:doc_id>/",
|
||||
views.paperless_document_redirect,
|
||||
name="paperless_document_redirect",
|
||||
),
|
||||
# Veranstaltungsmodul
|
||||
path("veranstaltungen/", views.veranstaltung_list, name="veranstaltung_list"),
|
||||
path("veranstaltungen/neu/", views.veranstaltung_create, name="veranstaltung_create"),
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -115,6 +115,7 @@ def dms_upload(request):
|
||||
"land_id": request.GET.get("land", ""),
|
||||
"paechter_id": request.GET.get("paechter", ""),
|
||||
"verpachtung_id": request.GET.get("verpachtung", ""),
|
||||
"foerderung_id": request.GET.get("foerderung", ""),
|
||||
"kontext": request.GET.get("kontext", "anderes"),
|
||||
}
|
||||
|
||||
@@ -144,6 +145,7 @@ def dms_upload(request):
|
||||
land_id = request.POST.get("land_id", "").strip()
|
||||
paechter_id = request.POST.get("paechter_id", "").strip()
|
||||
verp_id = request.POST.get("verpachtung_id", "").strip()
|
||||
foerd_id = request.POST.get("foerderung_id", "").strip()
|
||||
|
||||
if dest_id:
|
||||
try:
|
||||
@@ -165,6 +167,11 @@ def dms_upload(request):
|
||||
dok.verpachtung_id = verp_id
|
||||
except Exception:
|
||||
pass
|
||||
if foerd_id:
|
||||
try:
|
||||
dok.foerderung_id = foerd_id
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_save_upload(request, dok)
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 = {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user