Enhanced quarterly confirmation system with approval workflow and export improvements
Features added: - ✅ Fixed quarterly confirmation approval system with URL pattern - ✅ Added re-approval and status reset functionality for quarterly confirmations - ✅ Synchronized quarterly approval status with support payment system - ✅ Enhanced Destinataer export with missing fields (anrede, titel, mobil) - ✅ Added quarterly confirmation data and documents to export system - ✅ Fixed address field display issues in destinataer template - ✅ Added quarterly statistics dashboard to support payment lists - ✅ Implemented duplicate support payment prevention and cleanup - ✅ Added visual indicators for quarterly-linked support payments Technical improvements: - Enhanced create_quarterly_support_payment() with duplicate detection - Added get_related_support_payment() method to VierteljahresNachweis model - Improved quarterly confirmation workflow with proper status transitions - Added computed address property to Destinataer model - Fixed template field mismatches (anrede, titel, mobil vs strasse, plz, ort) - Enhanced backup system with operation tracking and cancellation Workflow enhancements: - Quarterly confirmations now properly sync with support payments - Single support payment per destinataer per quarter (no duplicates) - Approval button works for both eingereicht and geprueft status - Reset functionality allows workflow restart - Export includes complete quarterly data with uploaded documents
@@ -36,6 +36,9 @@ INSTALLED_APPS = [
|
|||||||
"rest_framework",
|
"rest_framework",
|
||||||
"stiftung",
|
"stiftung",
|
||||||
]
|
]
|
||||||
|
# Add this to app/core/settings.py
|
||||||
|
SESSION_COOKIE_NAME = 'stiftung_sessionid' # Different from default 'sessionid'
|
||||||
|
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
"django.middleware.security.SecurityMiddleware",
|
"django.middleware.security.SecurityMiddleware",
|
||||||
@@ -120,10 +123,6 @@ GRAMPS_STIFTER_IDS = os.environ.get("GRAMPS_STIFTER_IDS", "") # comma-separated
|
|||||||
GRAMPS_USERNAME = os.environ.get("GRAMPS_USERNAME", "")
|
GRAMPS_USERNAME = os.environ.get("GRAMPS_USERNAME", "")
|
||||||
GRAMPS_PASSWORD = os.environ.get("GRAMPS_PASSWORD", "")
|
GRAMPS_PASSWORD = os.environ.get("GRAMPS_PASSWORD", "")
|
||||||
|
|
||||||
# Session Configuration
|
|
||||||
SESSION_COOKIE_NAME = os.environ.get("SESSION_COOKIE_NAME") or "stiftung_sessionid"
|
|
||||||
CSRF_COOKIE_NAME = os.environ.get("CSRF_COOKIE_NAME") or "stiftung_csrftoken"
|
|
||||||
|
|
||||||
# HTTPS Security Settings (production)
|
# HTTPS Security Settings (production)
|
||||||
if not DEBUG:
|
if not DEBUG:
|
||||||
SECURE_SSL_REDIRECT = True
|
SECURE_SSL_REDIRECT = True
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
Vorname;Nachname;E-Mail;Telefon;IBAN;Straße;PLZ;Ort;Personentyp;Geburtsdatum;Pachtnummer;Pachtbeginn_Erste;Pachtende_Letzte;Pachtzins_Aktuell;Landwirtschaftliche_Ausbildung;Berufserfahrung_Jahre;Spezialisierung;Notizen;Aktiv
|
||||||
|
N/A;Groiner Milch KG;;;;Groiner Allee 18;46459;Rees;Gesellschaft;;;;;;;;;;WAHR
|
||||||
|
N/A;M u D Becker GbR;;;;Helweg 76;46348;Raesfeld;Gesellschaft;;;;;;;;;;WAHR
|
||||||
|
Walter;Buchmann;;;;Büskes Heide 11;46499;Hamminkeln-Brünen;Herrn;;;;;;;;;;WAHR
|
||||||
|
Andreas;Elbers;;;;Grietherbusch Nr. 15;46459;Rees;Herrn;;;;;;;;;;WAHR
|
||||||
|
N/A;Fischer GbR;;;;Werricher Strasse 1;46487;Wesel;Gesellschaft;;;;;;;;;;WAHR
|
||||||
|
Michael;Ingenbleek;;;;Achterhoeker Schulweg 39 a;47626;Kevelaer;Herrn;;;;;;;;;;WAHR
|
||||||
|
N/A;Bröcheler KG;;;;Kervenheimer Str. 30;47626;Kevelaer;Gesellschaft;;;;;;;;;;WAHR
|
||||||
|
Jens;Bodden;;;;Moelscher Weg 16;47574;Goch;Herrn;;;;;;;;;;WAHR
|
||||||
|
Marcel;Müller;;;;Weysche Strasse 55;47546;Kalkar-Hanselaer;Herrn;;;;;;;;;;WAHR
|
||||||
|
Ludger;Paeßens;;;;Bergerfurther Strasse 4;46499;Hamminkeln;Herrn;;;;;;;;;;WAHR
|
||||||
|
N/A;Sander GbR;;;;Hardtbergweg 21;46569;Hünxe;Gesellschaft;;;;;;;;;;WAHR
|
||||||
|
Dirk;Schmäh;;;;Schlümersweg 7 A;46499;Hamminkeln-Brünen;Herrn;;;;;;;;;;WAHR
|
||||||
|
Wolfgang;Schmitz;;;;Höst-Vornicker-Weg 1;47652;Weeze;Herrn;;;;;;;;;;WAHR
|
||||||
|
Sebastian;Scholten;;;;Underbergsheide 46;46485;Wesel-Obrighoven;Herrn;;;;;;;;;;WAHR
|
||||||
|
N/A;Ulmenhorst GbR;;;;Schlootweg 10;46499;Hamminkeln;Gesellschaft;;;;;;;;;;WAHR
|
||||||
|
Simone;Gerten;;;;Obrighovener Str. 107;46485;Wesel;Frau;;;;;;;;;;WAHR
|
||||||
|
Günther;Engelmann;;;;Loher Weg 42;46484;Wesel;Herrn;;;;;;;;;;WAHR
|
||||||
|
N/A;Lühlshof KG;;;;Auf dem Heidchen 27;46485;Wesel;Gesellschaft;;;;;;;;;;WAHR
|
||||||
|
Walter;Schmidt;;;;Ilserheider Str. 4;32469;Petershagen;Herrn;;;;;;;;;;WAHR
|
||||||
|
N/A;Inge und Tom Rose GbR;;;;Kohlbreite 22;34414;Warburg-Calenberg;Gesellschaft;;;;;;;;;;WAHR
|
||||||
|
N/A;Stiftung;;;;Schwarzensteiner Weg 75;46569;Hünxe;;;;;;;;;;;WAHR
|
||||||
|
|
After Width: | Height: | Size: 207 KiB |
|
After Width: | Height: | Size: 204 KiB |
|
After Width: | Height: | Size: 207 KiB |
|
After Width: | Height: | Size: 207 KiB |
@@ -0,0 +1,22 @@
|
|||||||
|
Vorname;Nachname;E-Mail;Telefon;IBAN;Straße;PLZ;Ort;Personentyp;Geburtsdatum;Pachtnummer;Pachtbeginn_Erste;Pachtende_Letzte;Pachtzins_Aktuell;Landwirtschaftliche_Ausbildung;Berufserfahrung_Jahre;Spezialisierung;Notizen;Aktiv
|
||||||
|
N/A;Groiner Milch KG;;;;Groiner Allee 18;46459;Rees;Gesellschaft;;;;;;;;;;WAHR
|
||||||
|
N/A;M u D Becker GbR;;;;Helweg 76;46348;Raesfeld;Gesellschaft;;;;;;;;;;WAHR
|
||||||
|
Walter;Buchmann;;;;Büskes Heide 11;46499;Hamminkeln-Brünen;Herrn;;;;;;;;;;WAHR
|
||||||
|
Andreas;Elbers;;;;Grietherbusch Nr. 15;46459;Rees;Herrn;;;;;;;;;;WAHR
|
||||||
|
N/A;Fischer GbR;;;;Werricher Strasse 1;46487;Wesel;Gesellschaft;;;;;;;;;;WAHR
|
||||||
|
Michael;Ingenbleek;;;;Achterhoeker Schulweg 39 a;47626;Kevelaer;Herrn;;;;;;;;;;WAHR
|
||||||
|
N/A;Bröcheler KG;;;;Kervenheimer Str. 30;47626;Kevelaer;Gesellschaft;;;;;;;;;;WAHR
|
||||||
|
Jens;Bodden;;;;Moelscher Weg 16;47574;Goch;Herrn;;;;;;;;;;WAHR
|
||||||
|
Marcel;Müller;;;;Weysche Strasse 55;47546;Kalkar-Hanselaer;Herrn;;;;;;;;;;WAHR
|
||||||
|
Ludger;Paeßens;;;;Bergerfurther Strasse 4;46499;Hamminkeln;Herrn;;;;;;;;;;WAHR
|
||||||
|
N/A;Sander GbR;;;;Hardtbergweg 21;46569;Hünxe;Gesellschaft;;;;;;;;;;WAHR
|
||||||
|
Dirk;Schmäh;;;;Schlümersweg 7 A;46499;Hamminkeln-Brünen;Herrn;;;;;;;;;;WAHR
|
||||||
|
Wolfgang;Schmitz;;;;Höst-Vornicker-Weg 1;47652;Weeze;Herrn;;;;;;;;;;WAHR
|
||||||
|
Sebastian;Scholten;;;;Underbergsheide 46;46485;Wesel-Obrighoven;Herrn;;;;;;;;;;WAHR
|
||||||
|
N/A;Ulmenhorst GbR;;;;Schlootweg 10;46499;Hamminkeln;Gesellschaft;;;;;;;;;;WAHR
|
||||||
|
Simone;Gerten;;;;Obrighovener Str. 107;46485;Wesel;Frau;;;;;;;;;;WAHR
|
||||||
|
Günther;Engelmann;;;;Loher Weg 42;46484;Wesel;Herrn;;;;;;;;;;WAHR
|
||||||
|
N/A;Lühlshof KG;;;;Auf dem Heidchen 27;46485;Wesel;Gesellschaft;;;;;;;;;;WAHR
|
||||||
|
Walter;Schmidt;;;;Ilserheider Str. 4;32469;Petershagen;Herrn;;;;;;;;;;WAHR
|
||||||
|
N/A;Inge und Tom Rose GbR;;;;Kohlbreite 22;34414;Warburg-Calenberg;Gesellschaft;;;;;;;;;;WAHR
|
||||||
|
N/A;Stiftung;;;;Schwarzensteiner Weg 75;46569;Hünxe;;;;;;;;;;;WAHR
|
||||||
|
|
After Width: | Height: | Size: 207 KiB |
|
After Width: | Height: | Size: 204 KiB |
|
After Width: | Height: | Size: 207 KiB |
|
After Width: | Height: | Size: 207 KiB |
@@ -0,0 +1,22 @@
|
|||||||
|
Vorname;Nachname;E-Mail;Telefon;IBAN;Straße;PLZ;Ort;Personentyp;Geburtsdatum;Pachtnummer;Pachtbeginn_Erste;Pachtende_Letzte;Pachtzins_Aktuell;Landwirtschaftliche_Ausbildung;Berufserfahrung_Jahre;Spezialisierung;Notizen;Aktiv
|
||||||
|
N/A;Groiner Milch KG;;;;Groiner Allee 18;46459;Rees;Gesellschaft;;;;;;;;;;WAHR
|
||||||
|
N/A;M u D Becker GbR;;;;Helweg 76;46348;Raesfeld;Gesellschaft;;;;;;;;;;WAHR
|
||||||
|
Walter;Buchmann;;;;Büskes Heide 11;46499;Hamminkeln-Brünen;Herrn;;;;;;;;;;WAHR
|
||||||
|
Andreas;Elbers;;;;Grietherbusch Nr. 15;46459;Rees;Herrn;;;;;;;;;;WAHR
|
||||||
|
N/A;Fischer GbR;;;;Werricher Strasse 1;46487;Wesel;Gesellschaft;;;;;;;;;;WAHR
|
||||||
|
Michael;Ingenbleek;;;;Achterhoeker Schulweg 39 a;47626;Kevelaer;Herrn;;;;;;;;;;WAHR
|
||||||
|
N/A;Bröcheler KG;;;;Kervenheimer Str. 30;47626;Kevelaer;Gesellschaft;;;;;;;;;;WAHR
|
||||||
|
Jens;Bodden;;;;Moelscher Weg 16;47574;Goch;Herrn;;;;;;;;;;WAHR
|
||||||
|
Marcel;Müller;;;;Weysche Strasse 55;47546;Kalkar-Hanselaer;Herrn;;;;;;;;;;WAHR
|
||||||
|
Ludger;Paeßens;;;;Bergerfurther Strasse 4;46499;Hamminkeln;Herrn;;;;;;;;;;WAHR
|
||||||
|
N/A;Sander GbR;;;;Hardtbergweg 21;46569;Hünxe;Gesellschaft;;;;;;;;;;WAHR
|
||||||
|
Dirk;Schmäh;;;;Schlümersweg 7 A;46499;Hamminkeln-Brünen;Herrn;;;;;;;;;;WAHR
|
||||||
|
Wolfgang;Schmitz;;;;Höst-Vornicker-Weg 1;47652;Weeze;Herrn;;;;;;;;;;WAHR
|
||||||
|
Sebastian;Scholten;;;;Underbergsheide 46;46485;Wesel-Obrighoven;Herrn;;;;;;;;;;WAHR
|
||||||
|
N/A;Ulmenhorst GbR;;;;Schlootweg 10;46499;Hamminkeln;Gesellschaft;;;;;;;;;;WAHR
|
||||||
|
Simone;Gerten;;;;Obrighovener Str. 107;46485;Wesel;Frau;;;;;;;;;;WAHR
|
||||||
|
Günther;Engelmann;;;;Loher Weg 42;46484;Wesel;Herrn;;;;;;;;;;WAHR
|
||||||
|
N/A;Lühlshof KG;;;;Auf dem Heidchen 27;46485;Wesel;Gesellschaft;;;;;;;;;;WAHR
|
||||||
|
Walter;Schmidt;;;;Ilserheider Str. 4;32469;Petershagen;Herrn;;;;;;;;;;WAHR
|
||||||
|
N/A;Inge und Tom Rose GbR;;;;Kohlbreite 22;34414;Warburg-Calenberg;Gesellschaft;;;;;;;;;;WAHR
|
||||||
|
N/A;Stiftung;;;;Schwarzensteiner Weg 75;46569;Hünxe;;;;;;;;;;;WAHR
|
||||||
|
|
After Width: | Height: | Size: 207 KiB |
|
After Width: | Height: | Size: 204 KiB |
|
After Width: | Height: | Size: 207 KiB |
|
After Width: | Height: | Size: 207 KiB |
@@ -0,0 +1,22 @@
|
|||||||
|
Vorname;Nachname;E-Mail;Telefon;IBAN;Straße;PLZ;Ort;Personentyp;Geburtsdatum;Pachtnummer;Pachtbeginn_Erste;Pachtende_Letzte;Pachtzins_Aktuell;Landwirtschaftliche_Ausbildung;Berufserfahrung_Jahre;Spezialisierung;Notizen;Aktiv
|
||||||
|
N/A;Groiner Milch KG;;;;Groiner Allee 18;46459;Rees;Gesellschaft;;;;;;;;;;WAHR
|
||||||
|
N/A;M u D Becker GbR;;;;Helweg 76;46348;Raesfeld;Gesellschaft;;;;;;;;;;WAHR
|
||||||
|
Walter;Buchmann;;;;Büskes Heide 11;46499;Hamminkeln-Brünen;Herrn;;;;;;;;;;WAHR
|
||||||
|
Andreas;Elbers;;;;Grietherbusch Nr. 15;46459;Rees;Herrn;;;;;;;;;;WAHR
|
||||||
|
N/A;Fischer GbR;;;;Werricher Strasse 1;46487;Wesel;Gesellschaft;;;;;;;;;;WAHR
|
||||||
|
Michael;Ingenbleek;;;;Achterhoeker Schulweg 39 a;47626;Kevelaer;Herrn;;;;;;;;;;WAHR
|
||||||
|
N/A;Bröcheler KG;;;;Kervenheimer Str. 30;47626;Kevelaer;Gesellschaft;;;;;;;;;;WAHR
|
||||||
|
Jens;Bodden;;;;Moelscher Weg 16;47574;Goch;Herrn;;;;;;;;;;WAHR
|
||||||
|
Marcel;Müller;;;;Weysche Strasse 55;47546;Kalkar-Hanselaer;Herrn;;;;;;;;;;WAHR
|
||||||
|
Ludger;Paeßens;;;;Bergerfurther Strasse 4;46499;Hamminkeln;Herrn;;;;;;;;;;WAHR
|
||||||
|
N/A;Sander GbR;;;;Hardtbergweg 21;46569;Hünxe;Gesellschaft;;;;;;;;;;WAHR
|
||||||
|
Dirk;Schmäh;;;;Schlümersweg 7 A;46499;Hamminkeln-Brünen;Herrn;;;;;;;;;;WAHR
|
||||||
|
Wolfgang;Schmitz;;;;Höst-Vornicker-Weg 1;47652;Weeze;Herrn;;;;;;;;;;WAHR
|
||||||
|
Sebastian;Scholten;;;;Underbergsheide 46;46485;Wesel-Obrighoven;Herrn;;;;;;;;;;WAHR
|
||||||
|
N/A;Ulmenhorst GbR;;;;Schlootweg 10;46499;Hamminkeln;Gesellschaft;;;;;;;;;;WAHR
|
||||||
|
Simone;Gerten;;;;Obrighovener Str. 107;46485;Wesel;Frau;;;;;;;;;;WAHR
|
||||||
|
Günther;Engelmann;;;;Loher Weg 42;46484;Wesel;Herrn;;;;;;;;;;WAHR
|
||||||
|
N/A;Lühlshof KG;;;;Auf dem Heidchen 27;46485;Wesel;Gesellschaft;;;;;;;;;;WAHR
|
||||||
|
Walter;Schmidt;;;;Ilserheider Str. 4;32469;Petershagen;Herrn;;;;;;;;;;WAHR
|
||||||
|
N/A;Inge und Tom Rose GbR;;;;Kohlbreite 22;34414;Warburg-Calenberg;Gesellschaft;;;;;;;;;;WAHR
|
||||||
|
N/A;Stiftung;;;;Schwarzensteiner Weg 75;46569;Hünxe;;;;;;;;;;;WAHR
|
||||||
|
@@ -0,0 +1,22 @@
|
|||||||
|
Vorname;Nachname;E-Mail;Telefon;IBAN;Straße;PLZ;Ort;Personentyp;Geburtsdatum;Pachtnummer;Pachtbeginn_Erste;Pachtende_Letzte;Pachtzins_Aktuell;Landwirtschaftliche_Ausbildung;Berufserfahrung_Jahre;Spezialisierung;Notizen;Aktiv
|
||||||
|
N/A;Groiner Milch KG;;;;Groiner Allee 18;46459;Rees;Gesellschaft;;;;;;;;;;WAHR
|
||||||
|
N/A;M u D Becker GbR;;;;Helweg 76;46348;Raesfeld;Gesellschaft;;;;;;;;;;WAHR
|
||||||
|
Walter;Buchmann;;;;Büskes Heide 11;46499;Hamminkeln-Brünen;Herrn;;;;;;;;;;WAHR
|
||||||
|
Andreas;Elbers;;;;Grietherbusch Nr. 15;46459;Rees;Herrn;;;;;;;;;;WAHR
|
||||||
|
N/A;Fischer GbR;;;;Werricher Strasse 1;46487;Wesel;Gesellschaft;;;;;;;;;;WAHR
|
||||||
|
Michael;Ingenbleek;;;;Achterhoeker Schulweg 39 a;47626;Kevelaer;Herrn;;;;;;;;;;WAHR
|
||||||
|
N/A;Bröcheler KG;;;;Kervenheimer Str. 30;47626;Kevelaer;Gesellschaft;;;;;;;;;;WAHR
|
||||||
|
Jens;Bodden;;;;Moelscher Weg 16;47574;Goch;Herrn;;;;;;;;;;WAHR
|
||||||
|
Marcel;Müller;;;;Weysche Strasse 55;47546;Kalkar-Hanselaer;Herrn;;;;;;;;;;WAHR
|
||||||
|
Ludger;Paeßens;;;;Bergerfurther Strasse 4;46499;Hamminkeln;Herrn;;;;;;;;;;WAHR
|
||||||
|
N/A;Sander GbR;;;;Hardtbergweg 21;46569;Hünxe;Gesellschaft;;;;;;;;;;WAHR
|
||||||
|
Dirk;Schmäh;;;;Schlümersweg 7 A;46499;Hamminkeln-Brünen;Herrn;;;;;;;;;;WAHR
|
||||||
|
Wolfgang;Schmitz;;;;Höst-Vornicker-Weg 1;47652;Weeze;Herrn;;;;;;;;;;WAHR
|
||||||
|
Sebastian;Scholten;;;;Underbergsheide 46;46485;Wesel-Obrighoven;Herrn;;;;;;;;;;WAHR
|
||||||
|
N/A;Ulmenhorst GbR;;;;Schlootweg 10;46499;Hamminkeln;Gesellschaft;;;;;;;;;;WAHR
|
||||||
|
Simone;Gerten;;;;Obrighovener Str. 107;46485;Wesel;Frau;;;;;;;;;;WAHR
|
||||||
|
Günther;Engelmann;;;;Loher Weg 42;46484;Wesel;Herrn;;;;;;;;;;WAHR
|
||||||
|
N/A;Lühlshof KG;;;;Auf dem Heidchen 27;46485;Wesel;Gesellschaft;;;;;;;;;;WAHR
|
||||||
|
Walter;Schmidt;;;;Ilserheider Str. 4;32469;Petershagen;Herrn;;;;;;;;;;WAHR
|
||||||
|
N/A;Inge und Tom Rose GbR;;;;Kohlbreite 22;34414;Warburg-Calenberg;Gesellschaft;;;;;;;;;;WAHR
|
||||||
|
N/A;Stiftung;;;;Schwarzensteiner Weg 75;46569;Hünxe;;;;;;;;;;;WAHR
|
||||||
|
@@ -0,0 +1,22 @@
|
|||||||
|
Vorname;Nachname;E-Mail;Telefon;IBAN;Straße;PLZ;Ort;Personentyp;Geburtsdatum;Pachtnummer;Pachtbeginn_Erste;Pachtende_Letzte;Pachtzins_Aktuell;Landwirtschaftliche_Ausbildung;Berufserfahrung_Jahre;Spezialisierung;Notizen;Aktiv
|
||||||
|
N/A;Groiner Milch KG;;;;Groiner Allee 18;46459;Rees;Gesellschaft;;;;;;;;;;WAHR
|
||||||
|
N/A;M u D Becker GbR;;;;Helweg 76;46348;Raesfeld;Gesellschaft;;;;;;;;;;WAHR
|
||||||
|
Walter;Buchmann;;;;Büskes Heide 11;46499;Hamminkeln-Brünen;Herrn;;;;;;;;;;WAHR
|
||||||
|
Andreas;Elbers;;;;Grietherbusch Nr. 15;46459;Rees;Herrn;;;;;;;;;;WAHR
|
||||||
|
N/A;Fischer GbR;;;;Werricher Strasse 1;46487;Wesel;Gesellschaft;;;;;;;;;;WAHR
|
||||||
|
Michael;Ingenbleek;;;;Achterhoeker Schulweg 39 a;47626;Kevelaer;Herrn;;;;;;;;;;WAHR
|
||||||
|
N/A;Bröcheler KG;;;;Kervenheimer Str. 30;47626;Kevelaer;Gesellschaft;;;;;;;;;;WAHR
|
||||||
|
Jens;Bodden;;;;Moelscher Weg 16;47574;Goch;Herrn;;;;;;;;;;WAHR
|
||||||
|
Marcel;Müller;;;;Weysche Strasse 55;47546;Kalkar-Hanselaer;Herrn;;;;;;;;;;WAHR
|
||||||
|
Ludger;Paeßens;;;;Bergerfurther Strasse 4;46499;Hamminkeln;Herrn;;;;;;;;;;WAHR
|
||||||
|
N/A;Sander GbR;;;;Hardtbergweg 21;46569;Hünxe;Gesellschaft;;;;;;;;;;WAHR
|
||||||
|
Dirk;Schmäh;;;;Schlümersweg 7 A;46499;Hamminkeln-Brünen;Herrn;;;;;;;;;;WAHR
|
||||||
|
Wolfgang;Schmitz;;;;Höst-Vornicker-Weg 1;47652;Weeze;Herrn;;;;;;;;;;WAHR
|
||||||
|
Sebastian;Scholten;;;;Underbergsheide 46;46485;Wesel-Obrighoven;Herrn;;;;;;;;;;WAHR
|
||||||
|
N/A;Ulmenhorst GbR;;;;Schlootweg 10;46499;Hamminkeln;Gesellschaft;;;;;;;;;;WAHR
|
||||||
|
Simone;Gerten;;;;Obrighovener Str. 107;46485;Wesel;Frau;;;;;;;;;;WAHR
|
||||||
|
Günther;Engelmann;;;;Loher Weg 42;46484;Wesel;Herrn;;;;;;;;;;WAHR
|
||||||
|
N/A;Lühlshof KG;;;;Auf dem Heidchen 27;46485;Wesel;Gesellschaft;;;;;;;;;;WAHR
|
||||||
|
Walter;Schmidt;;;;Ilserheider Str. 4;32469;Petershagen;Herrn;;;;;;;;;;WAHR
|
||||||
|
N/A;Inge und Tom Rose GbR;;;;Kohlbreite 22;34414;Warburg-Calenberg;Gesellschaft;;;;;;;;;;WAHR
|
||||||
|
N/A;Stiftung;;;;Schwarzensteiner Weg 75;46569;Hünxe;;;;;;;;;;;WAHR
|
||||||
|
@@ -0,0 +1,22 @@
|
|||||||
|
Vorname;Nachname;E-Mail;Telefon;IBAN;Straße;PLZ;Ort;Personentyp;Geburtsdatum;Pachtnummer;Pachtbeginn_Erste;Pachtende_Letzte;Pachtzins_Aktuell;Landwirtschaftliche_Ausbildung;Berufserfahrung_Jahre;Spezialisierung;Notizen;Aktiv
|
||||||
|
N/A;Groiner Milch KG;;;;Groiner Allee 18;46459;Rees;Gesellschaft;;;;;;;;;;WAHR
|
||||||
|
N/A;M u D Becker GbR;;;;Helweg 76;46348;Raesfeld;Gesellschaft;;;;;;;;;;WAHR
|
||||||
|
Walter;Buchmann;;;;Büskes Heide 11;46499;Hamminkeln-Brünen;Herrn;;;;;;;;;;WAHR
|
||||||
|
Andreas;Elbers;;;;Grietherbusch Nr. 15;46459;Rees;Herrn;;;;;;;;;;WAHR
|
||||||
|
N/A;Fischer GbR;;;;Werricher Strasse 1;46487;Wesel;Gesellschaft;;;;;;;;;;WAHR
|
||||||
|
Michael;Ingenbleek;;;;Achterhoeker Schulweg 39 a;47626;Kevelaer;Herrn;;;;;;;;;;WAHR
|
||||||
|
N/A;Bröcheler KG;;;;Kervenheimer Str. 30;47626;Kevelaer;Gesellschaft;;;;;;;;;;WAHR
|
||||||
|
Jens;Bodden;;;;Moelscher Weg 16;47574;Goch;Herrn;;;;;;;;;;WAHR
|
||||||
|
Marcel;Müller;;;;Weysche Strasse 55;47546;Kalkar-Hanselaer;Herrn;;;;;;;;;;WAHR
|
||||||
|
Ludger;Paeßens;;;;Bergerfurther Strasse 4;46499;Hamminkeln;Herrn;;;;;;;;;;WAHR
|
||||||
|
N/A;Sander GbR;;;;Hardtbergweg 21;46569;Hünxe;Gesellschaft;;;;;;;;;;WAHR
|
||||||
|
Dirk;Schmäh;;;;Schlümersweg 7 A;46499;Hamminkeln-Brünen;Herrn;;;;;;;;;;WAHR
|
||||||
|
Wolfgang;Schmitz;;;;Höst-Vornicker-Weg 1;47652;Weeze;Herrn;;;;;;;;;;WAHR
|
||||||
|
Sebastian;Scholten;;;;Underbergsheide 46;46485;Wesel-Obrighoven;Herrn;;;;;;;;;;WAHR
|
||||||
|
N/A;Ulmenhorst GbR;;;;Schlootweg 10;46499;Hamminkeln;Gesellschaft;;;;;;;;;;WAHR
|
||||||
|
Simone;Gerten;;;;Obrighovener Str. 107;46485;Wesel;Frau;;;;;;;;;;WAHR
|
||||||
|
Günther;Engelmann;;;;Loher Weg 42;46484;Wesel;Herrn;;;;;;;;;;WAHR
|
||||||
|
N/A;Lühlshof KG;;;;Auf dem Heidchen 27;46485;Wesel;Gesellschaft;;;;;;;;;;WAHR
|
||||||
|
Walter;Schmidt;;;;Ilserheider Str. 4;32469;Petershagen;Herrn;;;;;;;;;;WAHR
|
||||||
|
N/A;Inge und Tom Rose GbR;;;;Kohlbreite 22;34414;Warburg-Calenberg;Gesellschaft;;;;;;;;;;WAHR
|
||||||
|
N/A;Stiftung;;;;Schwarzensteiner Weg 75;46569;Hünxe;;;;;;;;;;;WAHR
|
||||||
|
|
After Width: | Height: | Size: 207 KiB |
|
After Width: | Height: | Size: 204 KiB |
|
After Width: | Height: | Size: 207 KiB |
|
After Width: | Height: | Size: 207 KiB |
11
app/static.backup.20250924_230831/README.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# Static Files Directory
|
||||||
|
|
||||||
|
This directory contains static files for the Django application.
|
||||||
|
|
||||||
|
## Structure:
|
||||||
|
- `css/` - Custom CSS files
|
||||||
|
- `js/` - Custom JavaScript files
|
||||||
|
- `img/` - Image assets
|
||||||
|
- `fonts/` - Font files
|
||||||
|
|
||||||
|
Static files are collected to `staticfiles/` directory during deployment via `python manage.py collectstatic`.
|
||||||
11
app/static.backup.20250924_232119/README.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# Static Files Directory
|
||||||
|
|
||||||
|
This directory contains static files for the Django application.
|
||||||
|
|
||||||
|
## Structure:
|
||||||
|
- `css/` - Custom CSS files
|
||||||
|
- `js/` - Custom JavaScript files
|
||||||
|
- `img/` - Image assets
|
||||||
|
- `fonts/` - Font files
|
||||||
|
|
||||||
|
Static files are collected to `staticfiles/` directory during deployment via `python manage.py collectstatic`.
|
||||||
11
app/static.backup.20250924_232152/README.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# Static Files Directory
|
||||||
|
|
||||||
|
This directory contains static files for the Django application.
|
||||||
|
|
||||||
|
## Structure:
|
||||||
|
- `css/` - Custom CSS files
|
||||||
|
- `js/` - Custom JavaScript files
|
||||||
|
- `img/` - Image assets
|
||||||
|
- `fonts/` - Font files
|
||||||
|
|
||||||
|
Static files are collected to `staticfiles/` directory during deployment via `python manage.py collectstatic`.
|
||||||
11
app/static.backup.20250924_232216/README.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# Static Files Directory
|
||||||
|
|
||||||
|
This directory contains static files for the Django application.
|
||||||
|
|
||||||
|
## Structure:
|
||||||
|
- `css/` - Custom CSS files
|
||||||
|
- `js/` - Custom JavaScript files
|
||||||
|
- `img/` - Image assets
|
||||||
|
- `fonts/` - Font files
|
||||||
|
|
||||||
|
Static files are collected to `staticfiles/` directory during deployment via `python manage.py collectstatic`.
|
||||||
11
app/static.backup.20250924_232511/README.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# Static Files Directory
|
||||||
|
|
||||||
|
This directory contains static files for the Django application.
|
||||||
|
|
||||||
|
## Structure:
|
||||||
|
- `css/` - Custom CSS files
|
||||||
|
- `js/` - Custom JavaScript files
|
||||||
|
- `img/` - Image assets
|
||||||
|
- `fonts/` - Font files
|
||||||
|
|
||||||
|
Static files are collected to `staticfiles/` directory during deployment via `python manage.py collectstatic`.
|
||||||
11
app/static.backup.20250924_233138/README.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# Static Files Directory
|
||||||
|
|
||||||
|
This directory contains static files for the Django application.
|
||||||
|
|
||||||
|
## Structure:
|
||||||
|
- `css/` - Custom CSS files
|
||||||
|
- `js/` - Custom JavaScript files
|
||||||
|
- `img/` - Image assets
|
||||||
|
- `fonts/` - Font files
|
||||||
|
|
||||||
|
Static files are collected to `staticfiles/` directory during deployment via `python manage.py collectstatic`.
|
||||||
11
app/static.backup.20250924_233209/README.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# Static Files Directory
|
||||||
|
|
||||||
|
This directory contains static files for the Django application.
|
||||||
|
|
||||||
|
## Structure:
|
||||||
|
- `css/` - Custom CSS files
|
||||||
|
- `js/` - Custom JavaScript files
|
||||||
|
- `img/` - Image assets
|
||||||
|
- `fonts/` - Font files
|
||||||
|
|
||||||
|
Static files are collected to `staticfiles/` directory during deployment via `python manage.py collectstatic`.
|
||||||
11
app/static.backup.20250924_233321/README.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# Static Files Directory
|
||||||
|
|
||||||
|
This directory contains static files for the Django application.
|
||||||
|
|
||||||
|
## Structure:
|
||||||
|
- `css/` - Custom CSS files
|
||||||
|
- `js/` - Custom JavaScript files
|
||||||
|
- `img/` - Image assets
|
||||||
|
- `fonts/` - Font files
|
||||||
|
|
||||||
|
Static files are collected to `staticfiles/` directory during deployment via `python manage.py collectstatic`.
|
||||||
@@ -189,42 +189,65 @@ def run_restore(restore_job_id, backup_file_path):
|
|||||||
restore_job.started_at = timezone.now()
|
restore_job.started_at = timezone.now()
|
||||||
restore_job.save()
|
restore_job.save()
|
||||||
|
|
||||||
|
# Verify backup file exists
|
||||||
|
if not os.path.exists(backup_file_path):
|
||||||
|
raise Exception(f"Backup file not found: {backup_file_path}")
|
||||||
|
|
||||||
# Extract backup
|
# Extract backup
|
||||||
with tempfile.TemporaryDirectory() as temp_dir:
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
extract_dir = os.path.join(temp_dir, "restore")
|
extract_dir = os.path.join(temp_dir, "restore")
|
||||||
os.makedirs(extract_dir)
|
os.makedirs(extract_dir)
|
||||||
|
|
||||||
# Extract tar.gz
|
# Extract tar.gz
|
||||||
with tarfile.open(backup_file_path, "r:gz") as tar:
|
try:
|
||||||
tar.extractall(extract_dir)
|
with tarfile.open(backup_file_path, "r:gz") as tar:
|
||||||
|
tar.extractall(extract_dir)
|
||||||
|
except Exception as e:
|
||||||
|
raise Exception(f"Failed to extract backup file: {e}")
|
||||||
|
|
||||||
# Validate backup
|
# Validate backup
|
||||||
metadata_file = os.path.join(extract_dir, "backup_metadata.json")
|
metadata_files = [name for name in os.listdir(extract_dir) if name.endswith('backup_metadata.json')]
|
||||||
if not os.path.exists(metadata_file):
|
if not metadata_files:
|
||||||
raise Exception("Invalid backup: missing metadata")
|
raise Exception("Invalid backup: missing metadata file")
|
||||||
|
|
||||||
# Read metadata
|
# Read metadata
|
||||||
import json
|
import json
|
||||||
|
|
||||||
with open(metadata_file, "r") as f:
|
try:
|
||||||
metadata = json.load(f)
|
metadata_file = os.path.join(extract_dir, metadata_files[0])
|
||||||
|
with open(metadata_file, "r") as f:
|
||||||
|
metadata = json.load(f)
|
||||||
|
print(f"Restoring backup created at: {metadata.get('created_at', 'unknown')}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Warning: Could not read backup metadata: {e}")
|
||||||
|
|
||||||
# Restore database
|
# Restore database
|
||||||
db_backup_file = os.path.join(extract_dir, "database.sql")
|
db_backup_file = os.path.join(extract_dir, "database.sql")
|
||||||
if os.path.exists(db_backup_file):
|
if os.path.exists(db_backup_file):
|
||||||
|
print("Restoring database...")
|
||||||
restore_database(db_backup_file)
|
restore_database(db_backup_file)
|
||||||
|
print("Database restore completed")
|
||||||
|
else:
|
||||||
|
print("No database backup found in archive")
|
||||||
|
|
||||||
# Restore files
|
# Restore files
|
||||||
files_dir = os.path.join(extract_dir, "files")
|
files_dir = os.path.join(extract_dir, "files")
|
||||||
if os.path.exists(files_dir):
|
if os.path.exists(files_dir):
|
||||||
|
print("Restoring files...")
|
||||||
restore_files(files_dir)
|
restore_files(files_dir)
|
||||||
|
print("Files restore completed")
|
||||||
|
else:
|
||||||
|
print("No files backup found in archive")
|
||||||
|
|
||||||
# Update job status
|
# Update job status
|
||||||
restore_job.status = "completed"
|
restore_job.status = "completed"
|
||||||
restore_job.completed_at = timezone.now()
|
restore_job.completed_at = timezone.now()
|
||||||
restore_job.save()
|
restore_job.save()
|
||||||
|
print(f"Restore job {restore_job_id} completed successfully")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
print(f"Restore job {restore_job_id} failed: {e}")
|
||||||
|
restore_job = BackupJob.objects.get(id=restore_job_id)
|
||||||
restore_job.status = "failed"
|
restore_job.status = "failed"
|
||||||
restore_job.error_message = str(e)
|
restore_job.error_message = str(e)
|
||||||
restore_job.completed_at = timezone.now()
|
restore_job.completed_at = timezone.now()
|
||||||
@@ -234,49 +257,151 @@ def run_restore(restore_job_id, backup_file_path):
|
|||||||
def restore_database(db_backup_file):
|
def restore_database(db_backup_file):
|
||||||
"""Restore database from backup"""
|
"""Restore database from backup"""
|
||||||
try:
|
try:
|
||||||
|
print(f"Starting database restore from: {db_backup_file}")
|
||||||
|
|
||||||
# Get database settings
|
# Get database settings
|
||||||
db_settings = settings.DATABASES["default"]
|
db_settings = settings.DATABASES["default"]
|
||||||
|
print(f"Database settings: {db_settings.get('NAME')} at {db_settings.get('HOST')}:{db_settings.get('PORT')}")
|
||||||
|
|
||||||
# Build pg_restore command
|
# First, try to determine if this is a custom format or SQL format
|
||||||
cmd = [
|
# by checking if the file starts with binary data (custom format)
|
||||||
"pg_restore",
|
is_custom_format = False
|
||||||
"--host",
|
try:
|
||||||
db_settings.get("HOST", "localhost"),
|
with open(db_backup_file, 'rb') as f:
|
||||||
"--port",
|
header = f.read(8)
|
||||||
str(db_settings.get("PORT", 5432)),
|
# Custom format files start with 'PGDMP' followed by version info
|
||||||
"--username",
|
if header.startswith(b'PGDMP'):
|
||||||
db_settings.get("USER", "postgres"),
|
is_custom_format = True
|
||||||
"--dbname",
|
print(f"Detected custom format backup (header: {header})")
|
||||||
db_settings.get("NAME", "stiftung"),
|
else:
|
||||||
"--clean", # Drop existing objects first
|
print(f"Detected SQL format backup (header: {header})")
|
||||||
"--if-exists", # Don't error if objects don't exist
|
except Exception as e:
|
||||||
"--no-owner", # don't attempt to set original owners
|
print(f"Could not determine backup format, assuming SQL: {e}")
|
||||||
"--role",
|
|
||||||
db_settings.get("USER", "postgres"), # set target owner
|
if is_custom_format:
|
||||||
"--single-transaction", # restore atomically when possible
|
print("Using pg_restore for custom format")
|
||||||
"--disable-triggers", # avoid FK issues during data load
|
# Use pg_restore for custom format
|
||||||
"--no-password",
|
cmd = [
|
||||||
"--verbose",
|
"pg_restore",
|
||||||
db_backup_file,
|
"--host",
|
||||||
]
|
db_settings.get("HOST", "localhost"),
|
||||||
|
"--port",
|
||||||
|
str(db_settings.get("PORT", 5432)),
|
||||||
|
"--username",
|
||||||
|
db_settings.get("USER", "postgres"),
|
||||||
|
"--dbname",
|
||||||
|
db_settings.get("NAME", "stiftung"),
|
||||||
|
"--clean", # Drop existing objects first
|
||||||
|
"--if-exists", # Don't error if objects don't exist
|
||||||
|
"--no-owner", # don't attempt to set original owners
|
||||||
|
"--role",
|
||||||
|
db_settings.get("USER", "postgres"), # set target owner
|
||||||
|
# Remove --single-transaction to allow partial restore even with configuration errors
|
||||||
|
"--disable-triggers", # avoid FK issues during data load
|
||||||
|
"--no-password",
|
||||||
|
"--verbose",
|
||||||
|
# Remove --exit-on-error to allow continuation after configuration warnings
|
||||||
|
db_backup_file,
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
print("Using psql for SQL format")
|
||||||
|
# Use psql for SQL format
|
||||||
|
cmd = [
|
||||||
|
"psql",
|
||||||
|
"--host",
|
||||||
|
db_settings.get("HOST", "localhost"),
|
||||||
|
"--port",
|
||||||
|
str(db_settings.get("PORT", 5432)),
|
||||||
|
"--username",
|
||||||
|
db_settings.get("USER", "postgres"),
|
||||||
|
"--dbname",
|
||||||
|
db_settings.get("NAME", "stiftung"),
|
||||||
|
"--no-password",
|
||||||
|
"--file",
|
||||||
|
db_backup_file,
|
||||||
|
]
|
||||||
|
|
||||||
|
print(f"Running command: {' '.join(cmd)}")
|
||||||
|
|
||||||
# Set environment variables for authentication
|
# Set environment variables for authentication
|
||||||
env = os.environ.copy()
|
env = os.environ.copy()
|
||||||
env["PGPASSWORD"] = db_settings.get("PASSWORD", "")
|
env["PGPASSWORD"] = db_settings.get("PASSWORD", "")
|
||||||
|
|
||||||
# Run pg_restore
|
# Run the restore command
|
||||||
result = subprocess.run(cmd, env=env, capture_output=True, text=True)
|
result = subprocess.run(cmd, env=env, capture_output=True, text=True)
|
||||||
|
|
||||||
# Fail if there are real errors
|
print(f"Command exit code: {result.returncode}")
|
||||||
|
print(f"STDOUT length: {len(result.stdout)} chars")
|
||||||
|
print(f"STDERR length: {len(result.stderr)} chars")
|
||||||
|
|
||||||
|
# Show first 500 chars of output for debugging
|
||||||
|
if result.stdout:
|
||||||
|
print(f"STDOUT (first 500 chars): {result.stdout[:500]}...")
|
||||||
|
if result.stderr:
|
||||||
|
print(f"STDERR (first 500 chars): {result.stderr[:500]}...")
|
||||||
|
|
||||||
|
# Handle different error conditions more gracefully
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
stderr = result.stderr or ""
|
stderr = result.stderr or ""
|
||||||
# escalate only if we see ERROR
|
stdout = result.stdout or ""
|
||||||
if "ERROR" in stderr.upper():
|
|
||||||
raise Exception(f"pg_restore failed: {stderr}")
|
# Check for known configuration parameter issues
|
||||||
|
if "unrecognized configuration parameter" in stderr:
|
||||||
|
print(f"Warning: Configuration parameter issues detected, but continuing: {stderr[:200]}...")
|
||||||
|
# For configuration parameter issues, we'll consider this a warning, not a fatal error
|
||||||
|
# if there are no other serious errors
|
||||||
|
serious_errors = [line for line in stderr.split('\n')
|
||||||
|
if 'ERROR' in line and 'unrecognized configuration parameter' not in line]
|
||||||
|
if serious_errors:
|
||||||
|
print(f"Serious errors found: {serious_errors}")
|
||||||
|
raise Exception(f"pg_restore failed with serious errors: {'; '.join(serious_errors)}")
|
||||||
|
else:
|
||||||
|
print("Restore completed with configuration warnings (non-fatal)")
|
||||||
|
elif "ERROR" in stderr.upper():
|
||||||
|
# Look for specific error patterns we can ignore
|
||||||
|
ignorable_patterns = [
|
||||||
|
"already exists",
|
||||||
|
"does not exist",
|
||||||
|
"unrecognized configuration parameter"
|
||||||
|
]
|
||||||
|
|
||||||
|
error_lines = [line for line in stderr.split('\n') if 'ERROR' in line]
|
||||||
|
serious_errors = []
|
||||||
|
|
||||||
|
for error_line in error_lines:
|
||||||
|
is_ignorable = any(pattern in error_line for pattern in ignorable_patterns)
|
||||||
|
if not is_ignorable:
|
||||||
|
serious_errors.append(error_line)
|
||||||
|
|
||||||
|
if serious_errors:
|
||||||
|
print(f"Serious errors found: {serious_errors}")
|
||||||
|
raise Exception(f"Database restore failed with errors: {'; '.join(serious_errors)}")
|
||||||
|
else:
|
||||||
|
print(f"Restore completed with ignorable warnings")
|
||||||
else:
|
else:
|
||||||
print(f"pg_restore completed with warnings: {stderr}")
|
print(f"Restore completed with warnings but no errors")
|
||||||
|
else:
|
||||||
|
print("Database restore completed successfully with no errors")
|
||||||
|
|
||||||
|
# Verify data was actually restored by checking table counts
|
||||||
|
try:
|
||||||
|
print("Verifying data was restored...")
|
||||||
|
from django.db import connection
|
||||||
|
with connection.cursor() as cursor:
|
||||||
|
# Check some key tables
|
||||||
|
test_tables = ['stiftung_person', 'stiftung_land', 'stiftung_destinataer']
|
||||||
|
for table in test_tables:
|
||||||
|
try:
|
||||||
|
cursor.execute(f"SELECT COUNT(*) FROM {table}")
|
||||||
|
count = cursor.fetchone()[0]
|
||||||
|
print(f"Table {table}: {count} rows")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Could not check table {table}: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Could not verify data restoration: {e}")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
print(f"Database restore failed with exception: {e}")
|
||||||
raise Exception(f"Database restore failed: {e}")
|
raise Exception(f"Database restore failed: {e}")
|
||||||
|
|
||||||
|
|
||||||
@@ -335,3 +460,51 @@ def cleanup_old_backups(keep_count=10):
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Cleanup failed: {e}")
|
print(f"Cleanup failed: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def validate_backup_file(backup_file_path):
|
||||||
|
"""Validate that a backup file is valid and can be restored"""
|
||||||
|
try:
|
||||||
|
if not os.path.exists(backup_file_path):
|
||||||
|
return False, "Backup file does not exist"
|
||||||
|
|
||||||
|
if not backup_file_path.endswith('.tar.gz'):
|
||||||
|
return False, "Invalid file format. Only .tar.gz files are supported"
|
||||||
|
|
||||||
|
# Try to open and extract metadata
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
try:
|
||||||
|
with tarfile.open(backup_file_path, "r:gz") as tar:
|
||||||
|
# Check if it contains expected files
|
||||||
|
names = tar.getnames()
|
||||||
|
|
||||||
|
# Look for metadata file (could be with or without ./ prefix)
|
||||||
|
metadata_files = [name for name in names if name.endswith('backup_metadata.json')]
|
||||||
|
if not metadata_files:
|
||||||
|
return False, "Invalid backup: missing metadata"
|
||||||
|
|
||||||
|
# Extract and validate metadata
|
||||||
|
metadata_file = metadata_files[0]
|
||||||
|
tar.extract(metadata_file, temp_dir)
|
||||||
|
extracted_metadata = os.path.join(temp_dir, metadata_file)
|
||||||
|
|
||||||
|
import json
|
||||||
|
with open(extracted_metadata, "r") as f:
|
||||||
|
metadata = json.load(f)
|
||||||
|
|
||||||
|
# Check metadata structure
|
||||||
|
if "backup_type" not in metadata:
|
||||||
|
return False, "Invalid backup metadata"
|
||||||
|
|
||||||
|
created_at = metadata.get('created_at', 'unknown date')
|
||||||
|
backup_type = metadata.get('backup_type', 'unknown type')
|
||||||
|
|
||||||
|
return True, f"Valid {backup_type} backup from {created_at}"
|
||||||
|
|
||||||
|
except tarfile.TarError as e:
|
||||||
|
return False, f"Corrupted backup file: {e}"
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return False, "Invalid backup metadata format"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return False, f"Validation failed: {e}"
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 5.0.6 on 2025-09-24 20:59
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('stiftung', '0030_vierteljahresnachweis'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='land',
|
||||||
|
name='ust_satz',
|
||||||
|
field=models.DecimalField(blank=True, decimal_places=2, default=19.0, max_digits=4, null=True, verbose_name='USt-Satz (%)'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='land',
|
||||||
|
name='zahlungsweise',
|
||||||
|
field=models.CharField(blank=True, choices=[('jaehrlich', 'Jährlich'), ('halbjaehrlich', 'Halbjährlich'), ('vierteljaehrlich', 'Vierteljährlich'), ('monatlich', 'Monatlich')], default='jaehrlich', max_length=20, null=True, verbose_name='Zahlungsweise'),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
app/stiftung/migrations/0032_backupjob_operation.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.0.6 on 2025-09-24 21:28
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('stiftung', '0031_alter_land_ust_satz_alter_land_zahlungsweise'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='backupjob',
|
||||||
|
name='operation',
|
||||||
|
field=models.CharField(choices=[('backup', 'Backup'), ('restore', 'Wiederherstellung')], default='backup', max_length=20, verbose_name='Vorgang'),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
app/stiftung/migrations/0033_alter_backupjob_status.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.0.6 on 2025-09-24 21:42
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('stiftung', '0032_backupjob_operation'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='backupjob',
|
||||||
|
name='status',
|
||||||
|
field=models.CharField(choices=[('pending', 'Wartend'), ('running', 'Läuft'), ('completed', 'Abgeschlossen'), ('failed', 'Fehlgeschlagen'), ('cancelled', 'Abgebrochen')], default='pending', max_length=20, verbose_name='Status'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -327,6 +327,21 @@ class Destinataer(models.Model):
|
|||||||
"""Get the most recent funding grant"""
|
"""Get the most recent funding grant"""
|
||||||
return self.foerderung_set.order_by("-jahr", "-betrag").first()
|
return self.foerderung_set.order_by("-jahr", "-betrag").first()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def adresse(self):
|
||||||
|
"""Construct full address from separate fields"""
|
||||||
|
parts = []
|
||||||
|
if self.strasse:
|
||||||
|
parts.append(self.strasse)
|
||||||
|
if self.plz or self.ort:
|
||||||
|
city_part = []
|
||||||
|
if self.plz:
|
||||||
|
city_part.append(self.plz)
|
||||||
|
if self.ort:
|
||||||
|
city_part.append(self.ort)
|
||||||
|
parts.append(" ".join(city_part))
|
||||||
|
return "\n".join(parts) if parts else ""
|
||||||
|
|
||||||
def erfuellt_voraussetzungen(self):
|
def erfuellt_voraussetzungen(self):
|
||||||
"""Prüft die Unterstützungsvoraussetzungen gemäß Angaben.
|
"""Prüft die Unterstützungsvoraussetzungen gemäß Angaben.
|
||||||
- Abkömmling muss True sein
|
- Abkömmling muss True sein
|
||||||
@@ -347,6 +362,20 @@ class Destinataer(models.Model):
|
|||||||
vermoegen_ok = (self.vermoegen or Decimal("0")) <= Decimal("15500")
|
vermoegen_ok = (self.vermoegen or Decimal("0")) <= Decimal("15500")
|
||||||
return bool(self.ist_abkoemmling and einkommen_ok and vermoegen_ok)
|
return bool(self.ist_abkoemmling and einkommen_ok and vermoegen_ok)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def adresse(self):
|
||||||
|
"""Computed address property combining strasse, plz, ort"""
|
||||||
|
parts = []
|
||||||
|
if self.strasse:
|
||||||
|
parts.append(self.strasse)
|
||||||
|
if self.plz and self.ort:
|
||||||
|
parts.append(f"{self.plz} {self.ort}")
|
||||||
|
elif self.plz:
|
||||||
|
parts.append(self.plz)
|
||||||
|
elif self.ort:
|
||||||
|
parts.append(self.ort)
|
||||||
|
return "\n".join(parts) if parts else None
|
||||||
|
|
||||||
def naechste_studiennachweis_termine(self):
|
def naechste_studiennachweis_termine(self):
|
||||||
"""Gibt die nächsten beiden Stichtage (15.03, 15.09) zurück."""
|
"""Gibt die nächsten beiden Stichtage (15.03, 15.09) zurück."""
|
||||||
import datetime as _dt
|
import datetime as _dt
|
||||||
@@ -522,6 +551,8 @@ class Land(models.Model):
|
|||||||
max_length=20,
|
max_length=20,
|
||||||
choices=ZAHLUNGSWEISE_CHOICES,
|
choices=ZAHLUNGSWEISE_CHOICES,
|
||||||
default="jaehrlich",
|
default="jaehrlich",
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
verbose_name="Zahlungsweise",
|
verbose_name="Zahlungsweise",
|
||||||
)
|
)
|
||||||
pachtzins_pro_ha = models.DecimalField(
|
pachtzins_pro_ha = models.DecimalField(
|
||||||
@@ -544,7 +575,12 @@ class Land(models.Model):
|
|||||||
# Umsatzsteuer
|
# Umsatzsteuer
|
||||||
ust_option = models.BooleanField(default=False, verbose_name="USt-Option")
|
ust_option = models.BooleanField(default=False, verbose_name="USt-Option")
|
||||||
ust_satz = models.DecimalField(
|
ust_satz = models.DecimalField(
|
||||||
max_digits=4, decimal_places=2, default=19.00, verbose_name="USt-Satz (%)"
|
max_digits=4,
|
||||||
|
decimal_places=2,
|
||||||
|
default=19.00,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="USt-Satz (%)"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Umlagen (Durchreichungen)
|
# Umlagen (Durchreichungen)
|
||||||
@@ -2234,6 +2270,7 @@ class BackupJob(models.Model):
|
|||||||
("running", "Läuft"),
|
("running", "Läuft"),
|
||||||
("completed", "Abgeschlossen"),
|
("completed", "Abgeschlossen"),
|
||||||
("failed", "Fehlgeschlagen"),
|
("failed", "Fehlgeschlagen"),
|
||||||
|
("cancelled", "Abgebrochen"),
|
||||||
]
|
]
|
||||||
|
|
||||||
TYPE_CHOICES = [
|
TYPE_CHOICES = [
|
||||||
@@ -2242,9 +2279,17 @@ class BackupJob(models.Model):
|
|||||||
("files", "Nur Dateien"),
|
("files", "Nur Dateien"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
OPERATION_CHOICES = [
|
||||||
|
("backup", "Backup"),
|
||||||
|
("restore", "Wiederherstellung"),
|
||||||
|
]
|
||||||
|
|
||||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||||
|
|
||||||
# Job-Details
|
# Job-Details
|
||||||
|
operation = models.CharField(
|
||||||
|
max_length=20, choices=OPERATION_CHOICES, default="backup", verbose_name="Vorgang"
|
||||||
|
)
|
||||||
backup_type = models.CharField(
|
backup_type = models.CharField(
|
||||||
max_length=20, choices=TYPE_CHOICES, verbose_name="Backup-Typ"
|
max_length=20, choices=TYPE_CHOICES, verbose_name="Backup-Typ"
|
||||||
)
|
)
|
||||||
@@ -2725,6 +2770,20 @@ class VierteljahresNachweis(models.Model):
|
|||||||
|
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
def get_related_support_payment(self):
|
||||||
|
"""Get the related support payment for this quarterly confirmation"""
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
quarter_start = datetime(self.jahr, (self.quartal - 1) * 3 + 1, 1).date()
|
||||||
|
quarter_end = datetime(self.jahr, self.quartal * 3, 1).date()
|
||||||
|
|
||||||
|
return DestinataerUnterstuetzung.objects.filter(
|
||||||
|
destinataer=self.destinataer,
|
||||||
|
faellig_am__gte=quarter_start,
|
||||||
|
faellig_am__lt=quarter_end,
|
||||||
|
beschreibung__contains=f"Q{self.quartal}/{self.jahr}"
|
||||||
|
).first()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_or_create_for_period(cls, destinataer, jahr, quartal):
|
def get_or_create_for_period(cls, destinataer, jahr, quartal):
|
||||||
"""Get or create a quarterly confirmation for a specific period"""
|
"""Get or create a quarterly confirmation for a specific period"""
|
||||||
|
|||||||
@@ -262,6 +262,11 @@ urlpatterns = [
|
|||||||
name="backup_download",
|
name="backup_download",
|
||||||
),
|
),
|
||||||
path("administration/backup/restore/", views.backup_restore, name="backup_restore"),
|
path("administration/backup/restore/", views.backup_restore, name="backup_restore"),
|
||||||
|
path(
|
||||||
|
"administration/backup/<uuid:backup_id>/cancel/",
|
||||||
|
views.backup_cancel,
|
||||||
|
name="backup_cancel",
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
"administration/unterstuetzungen/",
|
"administration/unterstuetzungen/",
|
||||||
views.unterstuetzungen_list,
|
views.unterstuetzungen_list,
|
||||||
@@ -364,4 +369,14 @@ urlpatterns = [
|
|||||||
views.quarterly_confirmation_update,
|
views.quarterly_confirmation_update,
|
||||||
name="quarterly_confirmation_update",
|
name="quarterly_confirmation_update",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"quarterly-confirmations/<uuid:pk>/approve/",
|
||||||
|
views.quarterly_confirmation_approve,
|
||||||
|
name="quarterly_confirmation_approve",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"quarterly-confirmations/<uuid:pk>/reset/",
|
||||||
|
views.quarterly_confirmation_reset,
|
||||||
|
name="quarterly_confirmation_reset",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1705,16 +1705,23 @@ def land_list(request):
|
|||||||
sum_wald_qm=Sum("wald_qm"),
|
sum_wald_qm=Sum("wald_qm"),
|
||||||
sum_sonstiges_qm=Sum("sonstiges_qm"),
|
sum_sonstiges_qm=Sum("sonstiges_qm"),
|
||||||
)
|
)
|
||||||
|
sum_groesse_qm = float(aggregates.get("sum_groesse_qm") or 0)
|
||||||
sum_gruenland_qm = float(aggregates.get("sum_gruenland_qm") or 0)
|
sum_gruenland_qm = float(aggregates.get("sum_gruenland_qm") or 0)
|
||||||
sum_acker_qm = float(aggregates.get("sum_acker_qm") or 0)
|
sum_acker_qm = float(aggregates.get("sum_acker_qm") or 0)
|
||||||
sum_wald_qm = float(aggregates.get("sum_wald_qm") or 0)
|
sum_wald_qm = float(aggregates.get("sum_wald_qm") or 0)
|
||||||
sum_sonstiges_qm = float(aggregates.get("sum_sonstiges_qm") or 0)
|
sum_sonstiges_qm = float(aggregates.get("sum_sonstiges_qm") or 0)
|
||||||
sum_total_use_qm = sum_gruenland_qm + sum_acker_qm + sum_wald_qm + sum_sonstiges_qm
|
sum_total_use_qm = sum_gruenland_qm + sum_acker_qm + sum_wald_qm + sum_sonstiges_qm
|
||||||
|
|
||||||
|
# Calculate verpachtung statistics
|
||||||
|
total_plots = lands.count()
|
||||||
|
verpachtete_plots = lands.filter(verp_flaeche_aktuell__gt=0).count()
|
||||||
|
unveerpachtete_plots = total_plots - verpachtete_plots
|
||||||
|
|
||||||
def pct(part, total):
|
def pct(part, total):
|
||||||
return round((part / total) * 100, 1) if total and part is not None else 0.0
|
return round((part / total) * 100, 1) if total and part is not None else 0.0
|
||||||
|
|
||||||
stats = {
|
stats = {
|
||||||
|
"sum_groesse_qm": sum_groesse_qm,
|
||||||
"sum_gruenland_qm": sum_gruenland_qm,
|
"sum_gruenland_qm": sum_gruenland_qm,
|
||||||
"sum_acker_qm": sum_acker_qm,
|
"sum_acker_qm": sum_acker_qm,
|
||||||
"sum_wald_qm": sum_wald_qm,
|
"sum_wald_qm": sum_wald_qm,
|
||||||
@@ -1723,6 +1730,11 @@ def land_list(request):
|
|||||||
"pct_gruenland": pct(sum_gruenland_qm, sum_total_use_qm),
|
"pct_gruenland": pct(sum_gruenland_qm, sum_total_use_qm),
|
||||||
"pct_acker": pct(sum_acker_qm, sum_total_use_qm),
|
"pct_acker": pct(sum_acker_qm, sum_total_use_qm),
|
||||||
"pct_wald": pct(sum_wald_qm, sum_total_use_qm),
|
"pct_wald": pct(sum_wald_qm, sum_total_use_qm),
|
||||||
|
"total_plots": total_plots,
|
||||||
|
"verpachtete_plots": verpachtete_plots,
|
||||||
|
"unveerpachtete_plots": unveerpachtete_plots,
|
||||||
|
"pct_verpachtet": pct(verpachtete_plots, total_plots),
|
||||||
|
"pct_unveerpachtet": pct(unveerpachtete_plots, total_plots),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Prepare size chart data (top 30 by size)
|
# Prepare size chart data (top 30 by size)
|
||||||
@@ -1783,10 +1795,31 @@ def land_detail(request, pk):
|
|||||||
def land_create(request):
|
def land_create(request):
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
form = LandForm(request.POST)
|
form = LandForm(request.POST)
|
||||||
|
|
||||||
|
# Debug: Print form data
|
||||||
|
print("=== LAND CREATE DEBUG ===")
|
||||||
|
print(f"POST data: {dict(request.POST)}")
|
||||||
|
print(f"Form is valid: {form.is_valid()}")
|
||||||
|
|
||||||
|
if not form.is_valid():
|
||||||
|
print(f"Form errors: {form.errors}")
|
||||||
|
print(f"Form non-field errors: {form.non_field_errors()}")
|
||||||
|
# Add error messages for debugging
|
||||||
|
for field, errors in form.errors.items():
|
||||||
|
for error in errors:
|
||||||
|
messages.error(request, f"{field}: {error}")
|
||||||
|
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
land = form.save()
|
try:
|
||||||
messages.success(request, f'Länderei "{land}" wurde erfolgreich erstellt.')
|
land = form.save()
|
||||||
return redirect("stiftung:land_detail", pk=land.pk)
|
messages.success(request, f'Länderei "{land}" wurde erfolgreich erstellt.')
|
||||||
|
print(f"Successfully created land: {land}")
|
||||||
|
return redirect("stiftung:land_detail", pk=land.pk)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error saving land: {e}")
|
||||||
|
messages.error(request, f"Fehler beim Speichern: {str(e)}")
|
||||||
|
else:
|
||||||
|
messages.error(request, "Bitte korrigieren Sie die Fehler im Formular.")
|
||||||
else:
|
else:
|
||||||
form = LandForm()
|
form = LandForm()
|
||||||
|
|
||||||
@@ -4456,9 +4489,22 @@ def unterstuetzungen_list(request):
|
|||||||
# Enhanced PDF export with corporate identity
|
# Enhanced PDF export with corporate identity
|
||||||
elif export_format == "pdf":
|
elif export_format == "pdf":
|
||||||
return export_unterstuetzungen_pdf(request, qs, selected_ids)
|
return export_unterstuetzungen_pdf(request, qs, selected_ids)
|
||||||
|
|
||||||
|
# Get quarterly confirmation statistics
|
||||||
|
quarterly_stats = {}
|
||||||
|
total_quarterly = VierteljahresNachweis.objects.count()
|
||||||
|
for status_code, status_name in VierteljahresNachweis.STATUS_CHOICES:
|
||||||
|
count = VierteljahresNachweis.objects.filter(status=status_code).count()
|
||||||
|
quarterly_stats[status_code] = {
|
||||||
|
'name': status_name,
|
||||||
|
'count': count
|
||||||
|
}
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
"unterstuetzungen": qs,
|
"unterstuetzungen": qs,
|
||||||
"status_filter": status,
|
"status_filter": status,
|
||||||
|
"quarterly_stats": quarterly_stats,
|
||||||
|
"total_quarterly": total_quarterly,
|
||||||
}
|
}
|
||||||
return render(request, "stiftung/unterstuetzungen_list.html", context)
|
return render(request, "stiftung/unterstuetzungen_list.html", context)
|
||||||
|
|
||||||
@@ -5193,6 +5239,8 @@ def destinataer_export(request, pk):
|
|||||||
# 1. Entity data as JSON
|
# 1. Entity data as JSON
|
||||||
entity_data = {
|
entity_data = {
|
||||||
"id": str(destinataer.id),
|
"id": str(destinataer.id),
|
||||||
|
"anrede": destinataer.get_anrede_display() if hasattr(destinataer, 'anrede') and destinataer.anrede else None,
|
||||||
|
"titel": destinataer.titel if hasattr(destinataer, 'titel') else None,
|
||||||
"vorname": destinataer.vorname,
|
"vorname": destinataer.vorname,
|
||||||
"nachname": destinataer.nachname,
|
"nachname": destinataer.nachname,
|
||||||
"geburtsdatum": (
|
"geburtsdatum": (
|
||||||
@@ -5202,6 +5250,7 @@ def destinataer_export(request, pk):
|
|||||||
),
|
),
|
||||||
"email": destinataer.email,
|
"email": destinataer.email,
|
||||||
"telefon": destinataer.telefon,
|
"telefon": destinataer.telefon,
|
||||||
|
"mobil": destinataer.mobil if hasattr(destinataer, 'mobil') else None,
|
||||||
"iban": destinataer.iban,
|
"iban": destinataer.iban,
|
||||||
"strasse": destinataer.strasse,
|
"strasse": destinataer.strasse,
|
||||||
"plz": destinataer.plz,
|
"plz": destinataer.plz,
|
||||||
@@ -5340,6 +5389,69 @@ def destinataer_export(request, pk):
|
|||||||
json.dumps(docs_data, indent=2, ensure_ascii=False),
|
json.dumps(docs_data, indent=2, ensure_ascii=False),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 4. Quarterly Confirmations with documents
|
||||||
|
quarterly_confirmations = destinataer.quartalseinreichungen.all().order_by("-jahr", "-quartal")
|
||||||
|
quarterly_data = []
|
||||||
|
|
||||||
|
for confirmation in quarterly_confirmations:
|
||||||
|
confirmation_data = {
|
||||||
|
"id": str(confirmation.id),
|
||||||
|
"jahr": confirmation.jahr,
|
||||||
|
"quartal": confirmation.quartal,
|
||||||
|
"quartal_display": confirmation.get_quartal_display(),
|
||||||
|
"status": confirmation.status,
|
||||||
|
"status_display": confirmation.get_status_display(),
|
||||||
|
"studiennachweis_erforderlich": confirmation.studiennachweis_erforderlich,
|
||||||
|
"studiennachweis_eingereicht": confirmation.studiennachweis_eingereicht,
|
||||||
|
"studiennachweis_bemerkung": confirmation.studiennachweis_bemerkung,
|
||||||
|
"einkommenssituation_bestaetigt": confirmation.einkommenssituation_bestaetigt,
|
||||||
|
"einkommenssituation_text": confirmation.einkommenssituation_text,
|
||||||
|
"vermogenssituation_bestaetigt": confirmation.vermogenssituation_bestaetigt,
|
||||||
|
"vermogenssituation_text": confirmation.vermogenssituation_text,
|
||||||
|
"weitere_dokumente_beschreibung": confirmation.weitere_dokumente_beschreibung,
|
||||||
|
"interne_notizen": confirmation.interne_notizen,
|
||||||
|
"erstellt_am": confirmation.erstellt_am.isoformat(),
|
||||||
|
"aktualisiert_am": confirmation.aktualisiert_am.isoformat(),
|
||||||
|
"eingereicht_am": confirmation.eingereicht_am.isoformat() if confirmation.eingereicht_am else None,
|
||||||
|
"geprueft_am": confirmation.geprueft_am.isoformat() if confirmation.geprueft_am else None,
|
||||||
|
"geprueft_von": confirmation.geprueft_von.username if confirmation.geprueft_von else None,
|
||||||
|
"faelligkeitsdatum": confirmation.faelligkeitsdatum.isoformat() if confirmation.faelligkeitsdatum else None,
|
||||||
|
"completion_percentage": confirmation.completion_percentage(),
|
||||||
|
"uploaded_files": []
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add uploaded files from quarterly confirmation
|
||||||
|
quarterly_files = [
|
||||||
|
("studiennachweis", confirmation.studiennachweis_datei),
|
||||||
|
("einkommenssituation", confirmation.einkommenssituation_datei),
|
||||||
|
("vermogenssituation", confirmation.vermogenssituation_datei),
|
||||||
|
("weitere_dokumente", confirmation.weitere_dokumente),
|
||||||
|
]
|
||||||
|
|
||||||
|
for file_type, file_field in quarterly_files:
|
||||||
|
if file_field and os.path.exists(file_field.path):
|
||||||
|
file_info = {
|
||||||
|
"type": file_type,
|
||||||
|
"name": os.path.basename(file_field.name),
|
||||||
|
"path": file_field.name
|
||||||
|
}
|
||||||
|
confirmation_data["uploaded_files"].append(file_info)
|
||||||
|
|
||||||
|
# Add file to ZIP
|
||||||
|
safe_filename = f"quarterly_{confirmation.jahr}_Q{confirmation.quartal}_{file_type}_{os.path.basename(file_field.name)}"
|
||||||
|
zipf.write(
|
||||||
|
file_field.path,
|
||||||
|
f"vierteljahresnachweis/{safe_filename}"
|
||||||
|
)
|
||||||
|
|
||||||
|
quarterly_data.append(confirmation_data)
|
||||||
|
|
||||||
|
if quarterly_data:
|
||||||
|
zipf.writestr(
|
||||||
|
"vierteljahresnachweis.json",
|
||||||
|
json.dumps(quarterly_data, indent=2, ensure_ascii=False),
|
||||||
|
)
|
||||||
|
|
||||||
# Prepare response
|
# Prepare response
|
||||||
with open(temp_file.name, "rb") as f:
|
with open(temp_file.name, "rb") as f:
|
||||||
response = HttpResponse(f.read(), content_type="application/zip")
|
response = HttpResponse(f.read(), content_type="application/zip")
|
||||||
@@ -5888,58 +6000,118 @@ def backup_restore(request):
|
|||||||
messages.error(request, "Bitte wählen Sie eine Backup-Datei aus.")
|
messages.error(request, "Bitte wählen Sie eine Backup-Datei aus.")
|
||||||
return redirect("stiftung:backup_management")
|
return redirect("stiftung:backup_management")
|
||||||
|
|
||||||
# Validate file
|
# Validate file format
|
||||||
if not backup_file.name.endswith(".tar.gz"):
|
if not backup_file.name.endswith(".tar.gz"):
|
||||||
messages.error(
|
messages.error(
|
||||||
request, "Ungültiges Backup-Format. Nur .tar.gz Dateien sind erlaubt."
|
request, "Ungültiges Backup-Format. Nur .tar.gz Dateien sind erlaubt."
|
||||||
)
|
)
|
||||||
return redirect("stiftung:backup_management")
|
return redirect("stiftung:backup_management")
|
||||||
|
|
||||||
# Save uploaded file
|
# Save uploaded file to temporary location
|
||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
temp_dir = tempfile.mkdtemp()
|
temp_dir = tempfile.mkdtemp()
|
||||||
backup_path = os.path.join(temp_dir, backup_file.name)
|
backup_path = os.path.join(temp_dir, backup_file.name)
|
||||||
|
|
||||||
with open(backup_path, "wb+") as destination:
|
try:
|
||||||
for chunk in backup_file.chunks():
|
with open(backup_path, "wb+") as destination:
|
||||||
destination.write(chunk)
|
for chunk in backup_file.chunks():
|
||||||
|
destination.write(chunk)
|
||||||
|
|
||||||
# Create restore job
|
# Validate the backup file
|
||||||
restore_job = BackupJob.objects.create(
|
from stiftung.backup_utils import validate_backup_file
|
||||||
backup_type="full",
|
|
||||||
created_by=request.user,
|
|
||||||
backup_filename=backup_file.name,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Log restore initiation
|
is_valid, message = validate_backup_file(backup_path)
|
||||||
|
if not is_valid:
|
||||||
|
messages.error(request, f"Ungültiges Backup: {message}")
|
||||||
|
return redirect("stiftung:backup_management")
|
||||||
|
|
||||||
|
# Show validation success
|
||||||
|
messages.info(request, f"Backup validiert: {message}")
|
||||||
|
|
||||||
|
# Create restore job
|
||||||
|
restore_job = BackupJob.objects.create(
|
||||||
|
operation="restore",
|
||||||
|
backup_type="full",
|
||||||
|
created_by=request.user,
|
||||||
|
backup_filename=backup_file.name,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Log restore initiation
|
||||||
|
from stiftung.audit import log_system_action
|
||||||
|
|
||||||
|
log_system_action(
|
||||||
|
request=request,
|
||||||
|
action="restore",
|
||||||
|
description=f"Wiederherstellung gestartet von: {backup_file.name}",
|
||||||
|
details={
|
||||||
|
"restore_job_id": str(restore_job.id),
|
||||||
|
"filename": backup_file.name,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Start restore process
|
||||||
|
import threading
|
||||||
|
|
||||||
|
from stiftung.backup_utils import run_restore
|
||||||
|
|
||||||
|
restore_thread = threading.Thread(
|
||||||
|
target=run_restore, args=(str(restore_job.id), backup_path)
|
||||||
|
)
|
||||||
|
restore_thread.start()
|
||||||
|
|
||||||
|
messages.success(
|
||||||
|
request, f'Wiederherstellung von "{backup_file.name}" wurde gestartet. '
|
||||||
|
f'Überwachen Sie den Fortschritt in der Backup-Historie.'
|
||||||
|
)
|
||||||
|
return redirect("stiftung:backup_management")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
messages.error(request, f"Fehler beim Verarbeiten der Backup-Datei: {e}")
|
||||||
|
return redirect("stiftung:backup_management")
|
||||||
|
|
||||||
|
return redirect("stiftung:backup_management")
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def backup_cancel(request, backup_id):
|
||||||
|
"""Cancel a running backup job"""
|
||||||
|
try:
|
||||||
|
backup_job = BackupJob.objects.get(id=backup_id)
|
||||||
|
|
||||||
|
# Only allow cancelling running or pending jobs
|
||||||
|
if backup_job.status not in ['running', 'pending']:
|
||||||
|
messages.error(request, "Nur laufende oder wartende Backups können abgebrochen werden.")
|
||||||
|
return redirect("stiftung:backup_management")
|
||||||
|
|
||||||
|
# Check if user has permission to cancel (either own job or admin)
|
||||||
|
if backup_job.created_by != request.user and not request.user.is_staff:
|
||||||
|
messages.error(request, "Sie können nur Ihre eigenen Backup-Jobs abbrechen.")
|
||||||
|
return redirect("stiftung:backup_management")
|
||||||
|
|
||||||
|
# Mark as cancelled
|
||||||
|
from django.utils import timezone
|
||||||
|
backup_job.status = "cancelled"
|
||||||
|
backup_job.completed_at = timezone.now()
|
||||||
|
backup_job.error_message = f"Abgebrochen von {request.user.username}"
|
||||||
|
backup_job.save()
|
||||||
|
|
||||||
|
# Log the cancellation
|
||||||
from stiftung.audit import log_system_action
|
from stiftung.audit import log_system_action
|
||||||
|
|
||||||
log_system_action(
|
log_system_action(
|
||||||
request=request,
|
request=request,
|
||||||
action="restore",
|
action="backup_cancel",
|
||||||
description=f"Wiederherstellung gestartet von: {backup_file.name}",
|
description=f"Backup-Job abgebrochen: {backup_job.get_backup_type_display()}",
|
||||||
details={
|
details={"backup_job_id": str(backup_job.id)},
|
||||||
"restore_job_id": str(restore_job.id),
|
|
||||||
"filename": backup_file.name,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Start restore process
|
messages.success(request, f"Backup-Job wurde abgebrochen.")
|
||||||
import threading
|
|
||||||
|
|
||||||
from stiftung.backup_utils import run_restore
|
except BackupJob.DoesNotExist:
|
||||||
|
messages.error(request, "Backup-Job nicht gefunden.")
|
||||||
restore_thread = threading.Thread(
|
except Exception as e:
|
||||||
target=run_restore, args=(str(restore_job.id), backup_path)
|
messages.error(request, f"Fehler beim Abbrechen des Backup-Jobs: {e}")
|
||||||
)
|
|
||||||
restore_thread.start()
|
|
||||||
|
|
||||||
messages.success(
|
|
||||||
request, f'Wiederherstellung von "{backup_file.name}" wurde gestartet.'
|
|
||||||
)
|
|
||||||
return redirect("stiftung:backup_management")
|
|
||||||
|
|
||||||
return redirect("stiftung:backup_management")
|
return redirect("stiftung:backup_management")
|
||||||
|
|
||||||
@@ -6857,7 +7029,16 @@ def unterstuetzungen_all(request):
|
|||||||
|
|
||||||
# Statistics
|
# Statistics
|
||||||
total_betrag = unterstuetzungen.aggregate(total=Sum("betrag"))["total"] or 0
|
total_betrag = unterstuetzungen.aggregate(total=Sum("betrag"))["total"] or 0
|
||||||
avg_betrag = unterstuetzungen.aggregate(avg=Avg("betrag"))["avg"] or 0
|
|
||||||
|
# Get quarterly confirmation statistics
|
||||||
|
quarterly_stats = {}
|
||||||
|
total_quarterly = VierteljahresNachweis.objects.count()
|
||||||
|
for status_code, status_name in VierteljahresNachweis.STATUS_CHOICES:
|
||||||
|
count = VierteljahresNachweis.objects.filter(status=status_code).count()
|
||||||
|
quarterly_stats[status_code] = {
|
||||||
|
'name': status_name,
|
||||||
|
'count': count
|
||||||
|
}
|
||||||
|
|
||||||
# Available destinataer for filter
|
# Available destinataer for filter
|
||||||
destinataer = Destinataer.objects.all().order_by("nachname", "vorname")
|
destinataer = Destinataer.objects.all().order_by("nachname", "vorname")
|
||||||
@@ -6868,7 +7049,8 @@ def unterstuetzungen_all(request):
|
|||||||
"title": "Alle Unterstützungen",
|
"title": "Alle Unterstützungen",
|
||||||
"status_filter": status,
|
"status_filter": status,
|
||||||
"total_betrag": total_betrag,
|
"total_betrag": total_betrag,
|
||||||
"avg_betrag": avg_betrag,
|
"quarterly_stats": quarterly_stats,
|
||||||
|
"total_quarterly": total_quarterly,
|
||||||
"status_choices": DestinataerUnterstuetzung.STATUS_CHOICES,
|
"status_choices": DestinataerUnterstuetzung.STATUS_CHOICES,
|
||||||
"destinataer": destinataer,
|
"destinataer": destinataer,
|
||||||
}
|
}
|
||||||
@@ -7325,7 +7507,8 @@ def quarterly_confirmation_update(request, pk):
|
|||||||
|
|
||||||
def create_quarterly_support_payment(nachweis):
|
def create_quarterly_support_payment(nachweis):
|
||||||
"""
|
"""
|
||||||
Create an automatic support payment when all quarterly requirements are met
|
Get or create a single support payment for this quarterly confirmation
|
||||||
|
Ensures only one payment exists per destinataer per quarter
|
||||||
"""
|
"""
|
||||||
destinataer = nachweis.destinataer
|
destinataer = nachweis.destinataer
|
||||||
|
|
||||||
@@ -7340,18 +7523,31 @@ def create_quarterly_support_payment(nachweis):
|
|||||||
if not destinataer.iban:
|
if not destinataer.iban:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Check if a payment for this quarter already exists
|
# Calculate quarter date range for more robust search
|
||||||
quarter_start = datetime(nachweis.jahr, (nachweis.quartal - 1) * 3 + 1, 1).date()
|
quarter_start = datetime(nachweis.jahr, (nachweis.quartal - 1) * 3 + 1, 1).date()
|
||||||
quarter_end = datetime(nachweis.jahr, nachweis.quartal * 3, 1).date()
|
if nachweis.quartal == 4: # Q4 special case
|
||||||
|
quarter_end = datetime(nachweis.jahr + 1, 1, 1).date()
|
||||||
|
else:
|
||||||
|
quarter_end = datetime(nachweis.jahr, nachweis.quartal * 3 + 1, 1).date()
|
||||||
|
|
||||||
|
# Search for existing payment - use broader criteria to catch all possibilities
|
||||||
existing_payment = DestinataerUnterstuetzung.objects.filter(
|
existing_payment = DestinataerUnterstuetzung.objects.filter(
|
||||||
destinataer=destinataer,
|
destinataer=destinataer,
|
||||||
faellig_am__gte=quarter_start,
|
faellig_am__gte=quarter_start,
|
||||||
faellig_am__lt=quarter_end,
|
faellig_am__lt=quarter_end
|
||||||
beschreibung__contains=f"Q{nachweis.quartal}/{nachweis.jahr}"
|
).filter(
|
||||||
|
Q(beschreibung__contains=f"Q{nachweis.quartal}/{nachweis.jahr}") |
|
||||||
|
Q(beschreibung__contains=f"Vierteljährliche Unterstützung")
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
if existing_payment:
|
if existing_payment:
|
||||||
|
# Update existing payment to ensure it matches current requirements
|
||||||
|
existing_payment.betrag = destinataer.vierteljaehrlicher_betrag
|
||||||
|
existing_payment.empfaenger_iban = destinataer.iban
|
||||||
|
existing_payment.empfaenger_name = destinataer.get_full_name()
|
||||||
|
existing_payment.verwendungszweck = f"Vierteljährliche Unterstützung Q{nachweis.quartal}/{nachweis.jahr} - {destinataer.get_full_name()}"
|
||||||
|
existing_payment.beschreibung = f"Vierteljährliche Unterstützung Q{nachweis.quartal}/{nachweis.jahr} (automatisch erstellt)"
|
||||||
|
existing_payment.save()
|
||||||
return existing_payment
|
return existing_payment
|
||||||
|
|
||||||
# Get default payment account
|
# Get default payment account
|
||||||
@@ -7363,8 +7559,6 @@ def create_quarterly_support_payment(nachweis):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
# Calculate payment due date (last day of quarter)
|
# Calculate payment due date (last day of quarter)
|
||||||
# Quarter end months and their last days:
|
|
||||||
# Q1: March (31), Q2: June (30), Q3: September (30), Q4: December (31)
|
|
||||||
quarter_end_month = nachweis.quartal * 3
|
quarter_end_month = nachweis.quartal * 3
|
||||||
|
|
||||||
if nachweis.quartal == 1: # Q1: January-March (ends March 31)
|
if nachweis.quartal == 1: # Q1: January-March (ends March 31)
|
||||||
@@ -7544,21 +7738,103 @@ def quarterly_confirmation_approve(request, pk):
|
|||||||
nachweis = get_object_or_404(VierteljahresNachweis, pk=pk)
|
nachweis = get_object_or_404(VierteljahresNachweis, pk=pk)
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
if nachweis.status == 'eingereicht':
|
if nachweis.status in ['eingereicht', 'geprueft']:
|
||||||
nachweis.status = 'geprueft'
|
# Check if we need to create or update support payment
|
||||||
nachweis.geprueft_am = timezone.now()
|
related_payment = nachweis.get_related_support_payment()
|
||||||
nachweis.geprueft_von = request.user
|
|
||||||
nachweis.save()
|
|
||||||
|
|
||||||
messages.success(
|
if nachweis.status == 'eingereicht' or (nachweis.status == 'geprueft' and not related_payment):
|
||||||
request,
|
# Approve the quarterly confirmation
|
||||||
f"Vierteljahresnachweis für {nachweis.destinataer.get_full_name()} "
|
nachweis.status = 'geprueft'
|
||||||
f"({nachweis.jahr} Q{nachweis.quartal}) wurde freigegeben."
|
nachweis.geprueft_am = timezone.now()
|
||||||
)
|
nachweis.geprueft_von = request.user
|
||||||
|
nachweis.save()
|
||||||
|
|
||||||
|
# Handle support payment - create if missing, update if exists
|
||||||
|
if not related_payment:
|
||||||
|
# Create new support payment
|
||||||
|
related_payment = create_quarterly_support_payment(nachweis)
|
||||||
|
if related_payment:
|
||||||
|
related_payment.status = 'in_bearbeitung'
|
||||||
|
related_payment.aktualisiert_am = timezone.now()
|
||||||
|
related_payment.save()
|
||||||
|
|
||||||
|
messages.success(
|
||||||
|
request,
|
||||||
|
f"Vierteljahresnachweis freigegeben und neue Unterstützung über {related_payment.betrag}€ für {nachweis.destinataer.get_full_name()} "
|
||||||
|
f"({nachweis.jahr} Q{nachweis.quartal}) wurde erstellt."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
messages.warning(
|
||||||
|
request,
|
||||||
|
f"Vierteljahresnachweis freigegeben, aber Unterstützung konnte nicht erstellt werden. "
|
||||||
|
f"Bitte prüfen Sie die Einstellungen für {nachweis.destinataer.get_full_name()}."
|
||||||
|
)
|
||||||
|
elif related_payment.status == 'geplant':
|
||||||
|
# Update existing payment
|
||||||
|
related_payment.status = 'in_bearbeitung'
|
||||||
|
related_payment.aktualisiert_am = timezone.now()
|
||||||
|
related_payment.save()
|
||||||
|
|
||||||
|
messages.success(
|
||||||
|
request,
|
||||||
|
f"Vierteljahresnachweis und zugehörige Unterstützung für {nachweis.destinataer.get_full_name()} "
|
||||||
|
f"({nachweis.jahr} Q{nachweis.quartal}) wurden freigegeben."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
messages.success(
|
||||||
|
request,
|
||||||
|
f"Vierteljahresnachweis für {nachweis.destinataer.get_full_name()} "
|
||||||
|
f"({nachweis.jahr} Q{nachweis.quartal}) wurde freigegeben."
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
messages.error(
|
messages.error(
|
||||||
request,
|
request,
|
||||||
"Nur eingereichte Nachweise können freigegeben werden."
|
"Nur eingereichte oder bereits genehmigte Nachweise können verarbeitet werden."
|
||||||
|
)
|
||||||
|
|
||||||
|
return redirect("stiftung:destinataer_detail", pk=nachweis.destinataer.pk)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def quarterly_confirmation_reset(request, pk):
|
||||||
|
"""Reset quarterly confirmation status (staff only)"""
|
||||||
|
if not request.user.is_staff:
|
||||||
|
messages.error(request, "Sie haben keine Berechtigung für diese Aktion.")
|
||||||
|
return redirect("stiftung:destinataer_list")
|
||||||
|
|
||||||
|
nachweis = get_object_or_404(VierteljahresNachweis, pk=pk)
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
if nachweis.status in ['geprueft', 'eingereicht']:
|
||||||
|
# Reset the quarterly confirmation status
|
||||||
|
nachweis.status = 'eingereicht' if nachweis.is_complete() else 'teilweise'
|
||||||
|
nachweis.geprueft_am = None
|
||||||
|
nachweis.geprueft_von = None
|
||||||
|
nachweis.aktualisiert_am = timezone.now()
|
||||||
|
nachweis.save()
|
||||||
|
|
||||||
|
# Reset related support payment status if it exists
|
||||||
|
related_payment = nachweis.get_related_support_payment()
|
||||||
|
if related_payment and related_payment.status == 'in_bearbeitung':
|
||||||
|
related_payment.status = 'geplant'
|
||||||
|
related_payment.aktualisiert_am = timezone.now()
|
||||||
|
related_payment.save()
|
||||||
|
|
||||||
|
messages.success(
|
||||||
|
request,
|
||||||
|
f"Vierteljahresnachweis und zugehörige Unterstützung für {nachweis.destinataer.get_full_name()} "
|
||||||
|
f"({nachweis.jahr} Q{nachweis.quartal}) wurden zurückgesetzt."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
messages.success(
|
||||||
|
request,
|
||||||
|
f"Vierteljahresnachweis für {nachweis.destinataer.get_full_name()} "
|
||||||
|
f"({nachweis.jahr} Q{nachweis.quartal}) wurde zurückgesetzt."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
messages.error(
|
||||||
|
request,
|
||||||
|
"Nur genehmigte oder eingereichte Nachweise können zurückgesetzt werden."
|
||||||
)
|
)
|
||||||
|
|
||||||
return redirect("stiftung:destinataer_detail", pk=nachweis.destinataer.pk)
|
return redirect("stiftung:destinataer_detail", pk=nachweis.destinataer.pk)
|
||||||
|
|||||||
@@ -101,6 +101,7 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Erstellt</th>
|
<th>Erstellt</th>
|
||||||
|
<th>Vorgang</th>
|
||||||
<th>Typ</th>
|
<th>Typ</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th>Größe</th>
|
<th>Größe</th>
|
||||||
@@ -117,10 +118,19 @@
|
|||||||
<small class="text-muted">{{ backup.created_at|date:"H:i:s" }}</small>
|
<small class="text-muted">{{ backup.created_at|date:"H:i:s" }}</small>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="badge bg-info">{{ backup.get_backup_type_display }}</span>
|
<span class="badge bg-{% if backup.operation == 'backup' %}info{% else %}warning{% endif %}">
|
||||||
|
{% if backup.operation == 'backup' %}
|
||||||
|
<i class="fas fa-save me-1"></i>Backup
|
||||||
|
{% else %}
|
||||||
|
<i class="fas fa-undo me-1"></i>Restore
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="badge bg-{% if backup.status == 'completed' %}success{% elif backup.status == 'failed' %}danger{% elif backup.status == 'running' %}primary{% else %}secondary{% endif %}">
|
<span class="badge bg-secondary">{{ backup.get_backup_type_display }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge bg-{% if backup.status == 'completed' %}success{% elif backup.status == 'failed' %}danger{% elif backup.status == 'running' %}primary{% elif backup.status == 'cancelled' %}warning{% else %}secondary{% endif %}">
|
||||||
{{ backup.get_status_display }}
|
{{ backup.get_status_display }}
|
||||||
</span>
|
</span>
|
||||||
{% if backup.status == 'running' %}
|
{% if backup.status == 'running' %}
|
||||||
@@ -153,7 +163,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% if backup.status == 'completed' %}
|
{% if backup.status == 'completed' and backup.operation == 'backup' %}
|
||||||
<a href="{% url 'stiftung:backup_download' backup.id %}" class="btn btn-outline-primary btn-sm" title="Herunterladen">
|
<a href="{% url 'stiftung:backup_download' backup.id %}" class="btn btn-outline-primary btn-sm" title="Herunterladen">
|
||||||
<i class="fas fa-download"></i>
|
<i class="fas fa-download"></i>
|
||||||
</a>
|
</a>
|
||||||
@@ -162,9 +172,27 @@
|
|||||||
<i class="fas fa-exclamation-triangle"></i>
|
<i class="fas fa-exclamation-triangle"></i>
|
||||||
</button>
|
</button>
|
||||||
{% elif backup.status == 'running' %}
|
{% elif backup.status == 'running' %}
|
||||||
<button class="btn btn-outline-primary btn-sm" disabled title="Läuft...">
|
<div class="btn-group" role="group">
|
||||||
<i class="fas fa-spinner fa-spin"></i>
|
<button class="btn btn-outline-primary btn-sm" disabled title="Läuft...">
|
||||||
</button>
|
<i class="fas fa-spinner fa-spin"></i>
|
||||||
|
</button>
|
||||||
|
{% if backup.created_by == request.user or request.user.is_staff %}
|
||||||
|
<a href="{% url 'stiftung:backup_cancel' backup.id %}"
|
||||||
|
class="btn btn-outline-danger btn-sm"
|
||||||
|
onclick="return confirm('Sind Sie sicher, dass Sie diesen Backup-Job abbrechen möchten?')"
|
||||||
|
title="Abbrechen">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% elif backup.status == 'cancelled' %}
|
||||||
|
<span class="badge bg-warning" title="Abgebrochen">
|
||||||
|
<i class="fas fa-ban"></i> Abgebrochen
|
||||||
|
</span>
|
||||||
|
{% elif backup.status == 'completed' and backup.operation == 'restore' %}
|
||||||
|
<span class="badge bg-success" title="Wiederherstellung abgeschlossen">
|
||||||
|
<i class="fas fa-check"></i> Fertig
|
||||||
|
</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-muted">-</span>
|
<span class="text-muted">-</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -198,7 +198,17 @@
|
|||||||
<em class="text-muted">Nicht angegeben</em>
|
<em class="text-muted">Nicht angegeben</em>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</span>
|
</span>
|
||||||
<textarea name="adresse" class="form-control edit-mode" style="display: none;" rows="3" placeholder="Straße Nr. PLZ Ort">{{ destinataer.adresse }}</textarea>
|
<div class="edit-mode" style="display: none;">
|
||||||
|
<input name="strasse" class="form-control mb-2" placeholder="Straße" value="{{ destinataer.strasse|default:'' }}">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-4">
|
||||||
|
<input name="plz" class="form-control" placeholder="PLZ" value="{{ destinataer.plz|default:'' }}">
|
||||||
|
</div>
|
||||||
|
<div class="col-8">
|
||||||
|
<input name="ort" class="form-control" placeholder="Ort" value="{{ destinataer.ort|default:'' }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -592,13 +602,28 @@
|
|||||||
title="Bearbeiten (Vollbild)">
|
title="Bearbeiten (Vollbild)">
|
||||||
<i class="fas fa-external-link-alt"></i>
|
<i class="fas fa-external-link-alt"></i>
|
||||||
</a>
|
</a>
|
||||||
{% if nachweis.status == 'eingereicht' and user.is_staff %}
|
{% if user.is_staff %}
|
||||||
<button type="button"
|
{% if nachweis.status == 'eingereicht' %}
|
||||||
class="btn btn-sm btn-outline-success"
|
<button type="button"
|
||||||
onclick="approveQuarterly('{{ nachweis.id }}')"
|
class="btn btn-sm btn-outline-success"
|
||||||
title="Freigeben">
|
onclick="approveQuarterly('{{ nachweis.id }}')"
|
||||||
<i class="fas fa-check"></i>
|
title="Freigeben">
|
||||||
</button>
|
<i class="fas fa-check"></i>
|
||||||
|
</button>
|
||||||
|
{% elif nachweis.status == 'geprueft' %}
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn-sm btn-outline-success"
|
||||||
|
onclick="approveQuarterly('{{ nachweis.id }}')"
|
||||||
|
title="Erneut freigeben / Unterstützung synchronisieren">
|
||||||
|
<i class="fas fa-sync"></i>
|
||||||
|
</button>
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn-sm btn-outline-warning"
|
||||||
|
onclick="resetQuarterly('{{ nachweis.id }}')"
|
||||||
|
title="Status zurücksetzen">
|
||||||
|
<i class="fas fa-undo"></i>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -1353,5 +1378,28 @@ function approveQuarterly(nachweisId) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resetQuarterly(nachweisId) {
|
||||||
|
if (confirm('Möchten Sie den Status dieses Vierteljahresnachweis wirklich zurücksetzen?')) {
|
||||||
|
fetch(`/quarterly-confirmations/${nachweisId}/reset/`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]').value,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then(response => {
|
||||||
|
if (response.ok) {
|
||||||
|
location.reload(); // Reload to show updated status
|
||||||
|
} else {
|
||||||
|
alert('Fehler beim Zurücksetzen des Nachweises.');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error:', error);
|
||||||
|
alert('Fehler beim Zurücksetzen des Nachweises.');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -65,51 +65,79 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Stats and Chart -->
|
<!-- Charts Row -->
|
||||||
<div class="row mb-4">
|
<div class="row mb-4">
|
||||||
<div class="col-lg-6 mb-3">
|
<!-- Usage Chart -->
|
||||||
<div class="card shadow h-100">
|
<div class="col-lg-4 mb-3">
|
||||||
|
<div class="card shadow">
|
||||||
<div class="card-header py-3">
|
<div class="card-header py-3">
|
||||||
<h6 class="m-0 font-weight-bold text-primary">
|
<h6 class="m-0 font-weight-bold text-primary">
|
||||||
<i class="fas fa-percentage me-2"></i>Flächennutzung (aktuelle Auswahl)
|
<i class="fas fa-percentage me-2"></i>Flächennutzung
|
||||||
</h6>
|
</h6>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="row text-center">
|
<div class="row text-center mb-3">
|
||||||
<div class="col-4">
|
<div class="col-4">
|
||||||
<div class="h4 mb-0">{{ stats.pct_wald|default:0 }}%</div>
|
<div class="h6 mb-0 text-success">{{ stats.pct_wald|default:0 }}%</div>
|
||||||
<div class="text-muted">Wald</div>
|
<div class="small text-muted">Wald</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-4">
|
<div class="col-4">
|
||||||
<div class="h4 mb-0">{{ stats.pct_acker|default:0 }}%</div>
|
<div class="h6 mb-0 text-info">{{ stats.pct_acker|default:0 }}%</div>
|
||||||
<div class="text-muted">Acker</div>
|
<div class="small text-muted">Acker</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-4">
|
<div class="col-4">
|
||||||
<div class="h4 mb-0">{{ stats.pct_gruenland|default:0 }}%</div>
|
<div class="h6 mb-0 text-warning">{{ stats.pct_gruenland|default:0 }}%</div>
|
||||||
<div class="text-muted">Grünland</div>
|
<div class="small text-muted">Grünland</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-3 small text-muted text-center">
|
<div style="height: 200px;">
|
||||||
Gesamt (Nutzungsarten): {{ stats.sum_total_use_qm|floatformat:0 }} qm
|
|
||||||
</div>
|
|
||||||
<div class="mt-3" style="height:200px">
|
|
||||||
<canvas id="usageChart"></canvas>
|
<canvas id="usageChart"></canvas>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-lg-6 mb-3">
|
|
||||||
|
<!-- Sizes Chart -->
|
||||||
|
<div class="col-lg-4 mb-3">
|
||||||
<div class="card shadow">
|
<div class="card shadow">
|
||||||
<div class="card-header py-3 d-flex justify-content-between align-items-center">
|
<div class="card-header py-3">
|
||||||
<h6 class="m-0 font-weight-bold text-primary">
|
<h6 class="m-0 font-weight-bold text-primary">
|
||||||
<i class="fas fa-chart-bar me-2"></i>Größen der Grundstücke (Top 30)
|
<i class="fas fa-chart-bar me-2"></i>Größen (Top 30)
|
||||||
</h6>
|
</h6>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div style="height: 140px; overflow: hidden;">
|
<div class="text-center mb-3">
|
||||||
<div id="sizesChart" style="display: flex; align-items: end; height: 100%; gap: 2px; padding: 5px;">
|
<div class="h6 mb-0">{{ stats.total_plots }}</div>
|
||||||
<!-- Simple CSS bar chart will be populated by JavaScript -->
|
<div class="small text-muted">Grundstücke gesamt</div>
|
||||||
|
</div>
|
||||||
|
<div style="height: 200px;">
|
||||||
|
<canvas id="sizesChart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Verpachtung Chart -->
|
||||||
|
<div class="col-lg-4 mb-3">
|
||||||
|
<div class="card shadow">
|
||||||
|
<div class="card-header py-3">
|
||||||
|
<h6 class="m-0 font-weight-bold text-primary">
|
||||||
|
<i class="fas fa-handshake me-2"></i>Verpachtungsstatus
|
||||||
|
</h6>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row text-center mb-3">
|
||||||
|
<div class="col-6">
|
||||||
|
<div class="h6 mb-0 text-success">{{ stats.pct_verpachtet|default:0 }}%</div>
|
||||||
|
<div class="small text-muted">Verpachtet</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<div class="h6 mb-0 text-secondary">{{ stats.pct_unveerpachtet|default:0 }}%</div>
|
||||||
|
<div class="small text-muted">Verfügbar</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="height: 200px;">
|
||||||
|
<canvas id="verpachtungChart"></canvas>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -379,8 +407,13 @@
|
|||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
responsive: true,
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
plugins: {
|
plugins: {
|
||||||
legend: { position: 'bottom' },
|
legend: {
|
||||||
|
display: true,
|
||||||
|
position: 'bottom',
|
||||||
|
labels: { padding: 10, fontSize: 12 }
|
||||||
|
},
|
||||||
tooltip: {
|
tooltip: {
|
||||||
callbacks: {
|
callbacks: {
|
||||||
label: function(context) {
|
label: function(context) {
|
||||||
@@ -399,50 +432,107 @@
|
|||||||
console.log('usageChart canvas not found');
|
console.log('usageChart canvas not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Simple CSS bar chart for sizes (no Chart.js)
|
// Bar chart for sizes
|
||||||
const chartContainer = document.getElementById('sizesChart');
|
const ctx = document.getElementById('sizesChart');
|
||||||
if (chartContainer) {
|
if (ctx) {
|
||||||
console.log('Found sizesChart container');
|
console.log('Found sizesChart canvas');
|
||||||
const labels = JSON.parse('{{ size_chart_labels_json|escapejs }}');
|
const labels = JSON.parse('{{ size_chart_labels_json|escapejs }}');
|
||||||
const dataVals = JSON.parse('{{ size_chart_values_json|escapejs }}');
|
const dataVals = JSON.parse('{{ size_chart_values_json|escapejs }}');
|
||||||
console.log('Bar chart data:', {labels: labels.length, values: dataVals.length});
|
console.log('Bar chart data:', {labels: labels.length, values: dataVals.length});
|
||||||
|
|
||||||
if (dataVals.length > 0) {
|
new Chart(ctx, {
|
||||||
const maxValue = Math.max(...dataVals);
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels: labels,
|
||||||
|
datasets: [{
|
||||||
|
label: 'Größe (qm)',
|
||||||
|
data: dataVals,
|
||||||
|
backgroundColor: 'rgba(0, 104, 55, 0.6)',
|
||||||
|
borderColor: '#006837',
|
||||||
|
borderWidth: 2
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
scales: {
|
||||||
|
x: { ticks: { autoSkip: true, maxTicksLimit: 8 } },
|
||||||
|
y: { beginAtZero: true }
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
legend: { display: false },
|
||||||
|
tooltip: { callbacks: { label: function(ctx) { return ctx.parsed.y.toLocaleString() + ' qm'; } } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
console.log('Bar chart created successfully');
|
||||||
|
} else {
|
||||||
|
console.log('sizesChart canvas not found');
|
||||||
|
}
|
||||||
|
|
||||||
chartContainer.innerHTML = ''; // Clear container
|
// Doughnut chart for verpachtung
|
||||||
|
const vctx = document.getElementById('verpachtungChart');
|
||||||
|
if (vctx) {
|
||||||
|
console.log('Found verpachtungChart canvas');
|
||||||
|
|
||||||
// Create bars
|
const verpachtet = {{ stats.verpachtete_plots|default:0 }};
|
||||||
dataVals.forEach((value, index) => {
|
const verfuegbar = {{ stats.unveerpachtete_plots|default:0 }};
|
||||||
const barHeight = (value / maxValue) * 120; // Max height 120px
|
|
||||||
const bar = document.createElement('div');
|
|
||||||
bar.style.cssText = `
|
|
||||||
background-color: rgba(0, 104, 55, 0.8);
|
|
||||||
border: 1px solid #006837;
|
|
||||||
width: ${Math.max(100 / dataVals.length - 1, 8)}%;
|
|
||||||
height: ${barHeight}px;
|
|
||||||
min-height: 2px;
|
|
||||||
display: flex;
|
|
||||||
align-items: end;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 10px;
|
|
||||||
color: white;
|
|
||||||
text-shadow: 1px 1px 1px rgba(0,0,0,0.5);
|
|
||||||
cursor: pointer;
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Add tooltip on hover
|
console.log('Verpachtung data:', {verpachtet: verpachtet, verfuegbar: verfuegbar});
|
||||||
bar.title = `${labels[index] || 'N/A'}: ${value.toLocaleString()} qm`;
|
|
||||||
|
|
||||||
chartContainer.appendChild(bar);
|
if (verpachtet > 0 || verfuegbar > 0) {
|
||||||
|
const verpachtungData = [];
|
||||||
|
const verpachtungLabels = [];
|
||||||
|
const verpachtungColors = [];
|
||||||
|
|
||||||
|
if (verpachtet > 0) {
|
||||||
|
verpachtungData.push(verpachtet);
|
||||||
|
verpachtungLabels.push('Verpachtet');
|
||||||
|
verpachtungColors.push('rgba(40, 167, 69, 0.8)');
|
||||||
|
}
|
||||||
|
if (verfuegbar > 0) {
|
||||||
|
verpachtungData.push(verfuegbar);
|
||||||
|
verpachtungLabels.push('Verfügbar');
|
||||||
|
verpachtungColors.push('rgba(108, 117, 125, 0.8)');
|
||||||
|
}
|
||||||
|
|
||||||
|
new Chart(vctx, {
|
||||||
|
type: 'doughnut',
|
||||||
|
data: {
|
||||||
|
labels: verpachtungLabels,
|
||||||
|
datasets: [{
|
||||||
|
data: verpachtungData,
|
||||||
|
backgroundColor: verpachtungColors,
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: '#fff'
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: true,
|
||||||
|
position: 'bottom',
|
||||||
|
labels: { padding: 10, fontSize: 12 }
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
label: function(ctx) {
|
||||||
|
const percentage = ((ctx.parsed / (verpachtet + verfuegbar)) * 100).toFixed(1);
|
||||||
|
return ctx.label + ': ' + ctx.parsed + ' (' + percentage + '%)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
console.log('Verpachtung chart created successfully');
|
||||||
console.log('CSS bar chart created successfully');
|
|
||||||
} else {
|
} else {
|
||||||
chartContainer.innerHTML = '<div style="text-align: center; color: #666; padding: 20px;">Keine Daten verfügbar</div>';
|
console.log('No data for verpachtung chart');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.log('sizesChart container not found');
|
console.log('verpachtungChart canvas not found');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Chart initialization error:', e);
|
console.error('Chart initialization error:', e);
|
||||||
|
|||||||
@@ -189,7 +189,7 @@
|
|||||||
|
|
||||||
<!-- Statistics Cards -->
|
<!-- Statistics Cards -->
|
||||||
<div class="row mb-4">
|
<div class="row mb-4">
|
||||||
<div class="col-md-3">
|
<div class="col-md-4">
|
||||||
<div class="card bg-primary text-white">
|
<div class="card bg-primary text-white">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h5 class="card-title">Gesamtbetrag</h5>
|
<h5 class="card-title">Gesamtbetrag</h5>
|
||||||
@@ -197,15 +197,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
<div class="col-md-4">
|
||||||
<div class="card bg-success text-white">
|
|
||||||
<div class="card-body">
|
|
||||||
<h5 class="card-title">Durchschnitt</h5>
|
|
||||||
<h3 class="card-text">€{{ avg_betrag|floatformat:2 }}</h3>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<div class="card bg-info text-white">
|
<div class="card bg-info text-white">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h5 class="card-title">Anzahl</h5>
|
<h5 class="card-title">Anzahl</h5>
|
||||||
@@ -213,6 +205,38 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card bg-light">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<h6 class="card-title text-muted mb-2">
|
||||||
|
<i class="fas fa-chart-pie me-2"></i>Vierteljahresnachweis Status
|
||||||
|
</h6>
|
||||||
|
<div class="row g-1">
|
||||||
|
<div class="col-6">
|
||||||
|
<div class="text-primary fw-bold">{{ total_quarterly }}</div>
|
||||||
|
<small class="text-muted">Gesamt</small>
|
||||||
|
</div>
|
||||||
|
{% for status_code, status_data in quarterly_stats.items %}
|
||||||
|
{% if status_data.count > 0 %}
|
||||||
|
<div class="col-6">
|
||||||
|
<div class="fw-bold
|
||||||
|
{% if status_code == 'offen' %}text-secondary
|
||||||
|
{% elif status_code == 'teilweise' %}text-warning
|
||||||
|
{% elif status_code == 'eingereicht' %}text-info
|
||||||
|
{% elif status_code == 'geprueft' %}text-success
|
||||||
|
{% elif status_code == 'nachbesserung' %}text-danger
|
||||||
|
{% elif status_code == 'abgelehnt' %}text-dark
|
||||||
|
{% endif %}">
|
||||||
|
{{ status_data.count }}
|
||||||
|
</div>
|
||||||
|
<small class="text-muted">{{ status_data.name }}</small>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Filters -->
|
<!-- Filters -->
|
||||||
|
|||||||
@@ -212,6 +212,47 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Quarterly Confirmation Statistics -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header bg-light">
|
||||||
|
<h6 class="card-title mb-0">
|
||||||
|
<i class="fas fa-chart-pie me-2"></i>
|
||||||
|
Vierteljahresnachweis Status Übersicht
|
||||||
|
</h6>
|
||||||
|
</div>
|
||||||
|
<div class="card-body py-2">
|
||||||
|
<div class="row text-center g-0">
|
||||||
|
<div class="col-lg-2 col-md-3 col-4 mb-2">
|
||||||
|
<div class="px-2">
|
||||||
|
<div class="h5 fw-bold text-primary mb-0">{{ total_quarterly }}</div>
|
||||||
|
<small class="text-muted">Gesamt</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% for status_code, status_data in quarterly_stats.items %}
|
||||||
|
<div class="col-lg-2 col-md-3 col-4 mb-2">
|
||||||
|
<div class="px-2">
|
||||||
|
<div class="h6 fw-bold mb-0
|
||||||
|
{% if status_code == 'offen' %}text-secondary
|
||||||
|
{% elif status_code == 'teilweise' %}text-warning
|
||||||
|
{% elif status_code == 'eingereicht' %}text-info
|
||||||
|
{% elif status_code == 'geprueft' %}text-success
|
||||||
|
{% elif status_code == 'nachbesserung' %}text-danger
|
||||||
|
{% elif status_code == 'abgelehnt' %}text-dark
|
||||||
|
{% endif %}">
|
||||||
|
{{ status_data.count }}
|
||||||
|
</div>
|
||||||
|
<small class="text-muted">{{ status_data.name }}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card shadow">
|
<div class="card shadow">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
@@ -258,7 +299,12 @@
|
|||||||
{{ u.get_status_display }}
|
{{ u.get_status_display }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ u.beschreibung }}</td>
|
<td>
|
||||||
|
{{ u.beschreibung }}
|
||||||
|
{% if "Q" in u.beschreibung and "/" in u.beschreibung %}
|
||||||
|
<i class="fas fa-file-contract text-primary ms-2" title="Verknüpft mit Vierteljahresnachweis" data-bs-toggle="tooltip"></i>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="btn-group btn-group-sm" role="group">
|
<div class="btn-group btn-group-sm" role="group">
|
||||||
<a href="{% url 'stiftung:unterstuetzung_edit' pk=u.pk %}" class="btn btn-outline-warning"><i class="fas fa-edit"></i></a>
|
<a href="{% url 'stiftung:unterstuetzung_edit' pk=u.pk %}" class="btn btn-outline-warning"><i class="fas fa-edit"></i></a>
|
||||||
@@ -424,6 +470,12 @@ document.getElementById('exportForm').addEventListener('submit', function(e) {
|
|||||||
// Initialize counts on page load
|
// Initialize counts on page load
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
updateSelectedCount();
|
updateSelectedCount();
|
||||||
|
|
||||||
|
// Initialize tooltips for quarterly confirmation indicators
|
||||||
|
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
|
||||||
|
var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
|
||||||
|
return new bootstrap.Tooltip(tooltipTriggerEl);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -39,8 +39,6 @@ services:
|
|||||||
- LANGUAGE_CODE=de
|
- LANGUAGE_CODE=de
|
||||||
- TIME_ZONE=Europe/Berlin
|
- TIME_ZONE=Europe/Berlin
|
||||||
- REDIS_URL=redis://redis:6379/0
|
- REDIS_URL=redis://redis:6379/0
|
||||||
- SESSION_COOKIE_NAME=stiftung_sessionid
|
|
||||||
- CSRF_COOKIE_NAME=stiftung_csrftoken
|
|
||||||
- PAPERLESS_API_URL=http://paperless:8000
|
- PAPERLESS_API_URL=http://paperless:8000
|
||||||
- PAPERLESS_API_TOKEN=d477152aca264ea00620910ac09a06f0a4faaecc
|
- PAPERLESS_API_TOKEN=d477152aca264ea00620910ac09a06f0a4faaecc
|
||||||
- PAPERLESS_REQUIRED_TAG=Stiftung_Destinatäre
|
- PAPERLESS_REQUIRED_TAG=Stiftung_Destinatäre
|
||||||
@@ -56,9 +54,7 @@ services:
|
|||||||
command: ["python", "manage.py", "runserver", "0.0.0.0:8000"]
|
command: ["python", "manage.py", "runserver", "0.0.0.0:8000"]
|
||||||
|
|
||||||
paperless:
|
paperless:
|
||||||
build:
|
image: ghcr.io/paperless-ngx/paperless-ngx:latest
|
||||||
context: ./paperless
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
ports:
|
ports:
|
||||||
- "8082:8000"
|
- "8082:8000"
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
0
debug-502-detailed.sh
Normal file
0
debug-502.sh
Normal file
0
emergency-fix.sh
Normal file
@@ -1,22 +0,0 @@
|
|||||||
# Custom Paperless-ngx with German OCR support
|
|
||||||
FROM ghcr.io/paperless-ngx/paperless-ngx:latest
|
|
||||||
|
|
||||||
# Switch to root to install packages
|
|
||||||
USER root
|
|
||||||
|
|
||||||
# Update package list and install German OCR language data
|
|
||||||
# The correct package name is tesseract-ocr-deu (not tesseract-data-deu)
|
|
||||||
RUN apt-get update && \
|
|
||||||
apt-get install -y \
|
|
||||||
tesseract-ocr-deu \
|
|
||||||
&& apt-get clean && \
|
|
||||||
rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Verify German language pack is installed
|
|
||||||
RUN tesseract --list-langs | grep deu || echo "German language pack not found"
|
|
||||||
|
|
||||||
# Switch back to paperless user
|
|
||||||
USER paperless
|
|
||||||
|
|
||||||
# Set the OCR language to include German and English
|
|
||||||
ENV PAPERLESS_OCR_LANGUAGE=deu+eng
|
|
||||||