From e0c7d0e351e0e205a7d5cec1fa41a47a7e013ba3 Mon Sep 17 00:00:00 2001 From: Stiftung Development Date: Sat, 6 Sep 2025 21:04:07 +0200 Subject: [PATCH] Format code with Black and isort for CI/CD compliance - Apply Black formatting to all Python files in core and stiftung modules - Fix import statement ordering with isort - Ensure all code meets automated quality standards - Resolve CI/CD pipeline formatting failures - Maintain consistent code style across the entire codebase --- app/core/__init__.py | 1 - app/core/asgi.py | 4 +- app/core/celery.py | 3 +- app/core/settings.py | 101 +- app/core/urls.py | 18 +- app/core/wsgi.py | 4 +- app/stiftung/admin.py | 1165 ++- app/stiftung/apps.py | 5 +- app/stiftung/audit.py | 172 +- app/stiftung/backup_utils.py | 241 +- app/stiftung/forms.py | 1415 ++-- .../commands/generate_recurring_payments.py | 99 +- .../management/commands/init_config.py | 146 +- .../commands/init_corporate_settings.py | 183 +- .../commands/migrate_verpachtungen.py | 71 +- .../management/commands/sync_abrechnungen.py | 228 +- .../commands/unify_verpachtungen.py | 118 +- app/stiftung/middleware.py | 132 +- app/stiftung/migrations/0001_initial.py | 82 +- ...tions_alter_foerderung_options_and_more.py | 164 +- ..._alter_dokumentlink_kontext_verpachtung.py | 315 +- app/stiftung/migrations/0004_csvimport.py | 104 +- ..._paechter_alter_person_options_and_more.py | 319 +- ..._remove_paechter_familienzweig_and_more.py | 22 +- ...dresse_remove_paechter_adresse_and_more.py | 72 +- ...08_dokumentlink_destinataer_id_and_more.py | 57 +- ...lter_dokumentlink_paperless_document_id.py | 6 +- ...eister_stiftungskonto_verwaltungskosten.py | 382 +- .../migrations/0011_banktransaction.py | 162 +- ...2_verwaltungskosten_quellkonto_and_more.py | 53 +- .../0013_alter_verwaltungskosten_status.py | 20 +- .../0014_dokumentlink_rentmeister_id.py | 10 +- .../migrations/0015_backupjob_auditlog.py | 247 +- .../migrations/0016_applicationpermission.py | 50 +- ...7_destinataer_haushaltsgroesse_and_more.py | 76 +- ...8_destinataer_vierteljaehrlicher_betrag.py | 14 +- .../0019_destinataerunterstuetzung.py | 88 +- .../migrations/0020_destinataernotiz.py | 66 +- ...dresse_land_aktueller_paechter_and_more.py | 380 +- ...kumentlink_land_verpachtung_id_and_more.py | 194 +- .../0023_remove_legacy_verpachtung.py | 4 +- .../0024_dokumentlink_abrechnung_id.py | 8 +- .../migrations/0025_appconfiguration.py | 89 +- .../0026_enhance_unterstuetzung_model.py | 208 +- ...helpbox_alter_appconfiguration_category.py | 102 +- .../migrations/0028_alter_helpbox_page_key.py | 29 +- app/stiftung/models.py | 2266 +++--- app/stiftung/serializers.py | 5 +- app/stiftung/templatetags/help_tags.py | 23 +- app/stiftung/templatetags/pdf_tags.py | 69 +- app/stiftung/urls.py | 433 +- app/stiftung/utils/config.py | 38 +- app/stiftung/utils/pdf_generator.py | 173 +- app/stiftung/views.py | 6991 ++++++++++------- 54 files changed, 11004 insertions(+), 6423 deletions(-) diff --git a/app/core/__init__.py b/app/core/__init__.py index fdbcb08..8e9c292 100644 --- a/app/core/__init__.py +++ b/app/core/__init__.py @@ -1,4 +1,3 @@ from .celery import app as celery __all__ = ("celery",) - diff --git a/app/core/asgi.py b/app/core/asgi.py index d94fb6d..07b7778 100644 --- a/app/core/asgi.py +++ b/app/core/asgi.py @@ -1,4 +1,6 @@ import os + from django.core.asgi import get_asgi_application -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings') + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "core.settings") application = get_asgi_application() diff --git a/app/core/celery.py b/app/core/celery.py index fc0c6b2..62a4916 100644 --- a/app/core/celery.py +++ b/app/core/celery.py @@ -1,4 +1,5 @@ import os + from celery import Celery os.environ.setdefault("DJANGO_SETTINGS_MODULE", "core.settings") @@ -6,5 +7,3 @@ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "core.settings") app = Celery("core") app.config_from_object("django.conf:settings", namespace="CELERY") app.autodiscover_tasks() - - diff --git a/app/core/settings.py b/app/core/settings.py index 8a7c45f..8f33930 100644 --- a/app/core/settings.py +++ b/app/core/settings.py @@ -1,5 +1,6 @@ import os from pathlib import Path + from dotenv import load_dotenv # Load environment variables from .env file @@ -13,59 +14,59 @@ ALLOWED_HOSTS = os.getenv("DJANGO_ALLOWED_HOSTS", "*").split(",") # CSRF settings for localhost development CSRF_TRUSTED_ORIGINS = [ - 'http://localhost:8081', - 'http://127.0.0.1:8081', + "http://localhost:8081", + "http://127.0.0.1:8081", ] INSTALLED_APPS = [ - 'django.contrib.admin', - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.messages', - 'django.contrib.staticfiles', - 'django.contrib.humanize', - 'rest_framework', - 'stiftung', + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "django.contrib.humanize", + "rest_framework", + "stiftung", ] MIDDLEWARE = [ - 'django.middleware.security.SecurityMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.middleware.common.CommonMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', - 'django.middleware.clickjacking.XFrameOptionsMiddleware', - 'stiftung.middleware.AuditMiddleware', # Audit logging middleware + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", + "stiftung.middleware.AuditMiddleware", # Audit logging middleware ] -ROOT_URLCONF = 'core.urls' +ROOT_URLCONF = "core.urls" TEMPLATES = [ { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [BASE_DIR / 'templates'], - 'APP_DIRS': True, - 'OPTIONS': { - 'context_processors': [ - 'django.template.context_processors.debug', - 'django.template.context_processors.request', - 'django.contrib.auth.context_processors.auth', - 'django.contrib.messages.context_processors.messages', + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [BASE_DIR / "templates"], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", ], }, }, ] -WSGI_APPLICATION = 'core.wsgi.application' +WSGI_APPLICATION = "core.wsgi.application" DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.postgresql', - 'NAME': os.getenv('POSTGRES_DB', 'stiftung'), - 'USER': os.getenv('POSTGRES_USER', 'stiftung'), - 'PASSWORD': os.getenv('POSTGRES_PASSWORD', 'stiftungpass'), - 'HOST': os.getenv('DB_HOST', 'db'), - 'PORT': os.getenv('DB_PORT', '5432'), + "default": { + "ENGINE": "django.db.backends.postgresql", + "NAME": os.getenv("POSTGRES_DB", "stiftung"), + "USER": os.getenv("POSTGRES_USER", "stiftung"), + "PASSWORD": os.getenv("POSTGRES_PASSWORD", "stiftungpass"), + "HOST": os.getenv("DB_HOST", "db"), + "PORT": os.getenv("DB_PORT", "5432"), } } @@ -74,17 +75,17 @@ TIME_ZONE = os.getenv("TIME_ZONE", "Europe/Berlin") USE_I18N = True USE_TZ = True -STATIC_URL = 'static/' -STATIC_ROOT = BASE_DIR / 'staticfiles' +STATIC_URL = "static/" +STATIC_ROOT = BASE_DIR / "staticfiles" # Additional locations of static files STATICFILES_DIRS = [ BASE_DIR / "static", ] -DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' -MEDIA_URL = '/media/' -MEDIA_ROOT = BASE_DIR / 'media' +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" +MEDIA_URL = "/media/" +MEDIA_ROOT = BASE_DIR / "media" # Celery CELERY_BROKER_URL = os.getenv("REDIS_URL", "redis://redis:6379/0") @@ -101,13 +102,13 @@ PAPERLESS_LAND_TAG_ID = os.getenv("PAPERLESS_LAND_TAG_ID", "204") PAPERLESS_ADMIN_TAG_ID = os.getenv("PAPERLESS_ADMIN_TAG_ID", "216") # Authentication -LOGIN_URL = '/login/' -LOGIN_REDIRECT_URL = '/' -LOGOUT_REDIRECT_URL = '/login/' +LOGIN_URL = "/login/" +LOGIN_REDIRECT_URL = "/" +LOGOUT_REDIRECT_URL = "/login/" # Gramps integration -GRAMPS_URL = os.environ.get('GRAMPS_URL', 'http://grampsweb:80') -GRAMPS_API_TOKEN = os.environ.get('GRAMPS_API_TOKEN', '') -GRAMPS_STIFTER_IDS = os.environ.get('GRAMPS_STIFTER_IDS', '') # comma-separated -GRAMPS_USERNAME = os.environ.get('GRAMPS_USERNAME', '') -GRAMPS_PASSWORD = os.environ.get('GRAMPS_PASSWORD', '') +GRAMPS_URL = os.environ.get("GRAMPS_URL", "http://grampsweb:80") +GRAMPS_API_TOKEN = os.environ.get("GRAMPS_API_TOKEN", "") +GRAMPS_STIFTER_IDS = os.environ.get("GRAMPS_STIFTER_IDS", "") # comma-separated +GRAMPS_USERNAME = os.environ.get("GRAMPS_USERNAME", "") +GRAMPS_PASSWORD = os.environ.get("GRAMPS_PASSWORD", "") diff --git a/app/core/urls.py b/app/core/urls.py index 263950a..9811483 100644 --- a/app/core/urls.py +++ b/app/core/urls.py @@ -1,17 +1,21 @@ -from django.contrib import admin -from django.urls import path, include from django.conf import settings from django.conf.urls.static import static +from django.contrib import admin from django.contrib.auth import views as auth_views +from django.urls import include, path + from stiftung.views import home urlpatterns = [ - path('', include('stiftung.urls')), - path('admin/', admin.site.urls), - + path("", include("stiftung.urls")), + path("admin/", admin.site.urls), # Authentication URLs - path('login/', auth_views.LoginView.as_view(template_name='registration/login.html'), name='login'), - path('logout/', auth_views.LogoutView.as_view(), name='logout'), + path( + "login/", + auth_views.LoginView.as_view(template_name="registration/login.html"), + name="login", + ), + path("logout/", auth_views.LogoutView.as_view(), name="logout"), ] if settings.DEBUG: diff --git a/app/core/wsgi.py b/app/core/wsgi.py index 4177f77..a8f1ede 100644 --- a/app/core/wsgi.py +++ b/app/core/wsgi.py @@ -1,4 +1,6 @@ import os + from django.core.wsgi import get_wsgi_application -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings') + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "core.settings") application = get_wsgi_application() diff --git a/app/stiftung/admin.py b/app/stiftung/admin.py index dff21b6..911780b 100644 --- a/app/stiftung/admin.py +++ b/app/stiftung/admin.py @@ -1,42 +1,63 @@ from django.contrib import admin -from django.utils.html import format_html +from django.db.models import Count, Sum from django.urls import reverse -from django.db.models import Sum, Count +from django.utils.html import format_html from django.utils.safestring import mark_safe + from . import models -from .models import ( - Person, Paechter, Destinataer, DokumentLink, Foerderung, Land, CSVImport, - Rentmeister, StiftungsKonto, Verwaltungskosten, BankTransaction, AuditLog, BackupJob, AppConfiguration, - DestinataerUnterstuetzung, UnterstuetzungWiederkehrend -) +from .models import (AppConfiguration, AuditLog, BackupJob, BankTransaction, + CSVImport, Destinataer, DestinataerUnterstuetzung, + DokumentLink, Foerderung, Land, Paechter, Person, + Rentmeister, StiftungsKonto, UnterstuetzungWiederkehrend, + Verwaltungskosten) + @admin.register(CSVImport) class CSVImportAdmin(admin.ModelAdmin): - list_display = ['import_type', 'filename', 'status', 'total_rows', 'imported_rows', 'failed_rows', 'created_by', 'started_at', 'duration_display'] - list_filter = ['import_type', 'status', 'started_at'] - search_fields = ['filename', 'created_by'] - readonly_fields = ['id', 'started_at', 'completed_at', 'get_success_rate'] - ordering = ['-started_at'] - + list_display = [ + "import_type", + "filename", + "status", + "total_rows", + "imported_rows", + "failed_rows", + "created_by", + "started_at", + "duration_display", + ] + list_filter = ["import_type", "status", "started_at"] + search_fields = ["filename", "created_by"] + readonly_fields = ["id", "started_at", "completed_at", "get_success_rate"] + ordering = ["-started_at"] + fieldsets = ( - ('Grundinformationen', { - 'fields': ('import_type', 'filename', 'file_size', 'status') - }), - ('Ergebnisse', { - 'fields': ('total_rows', 'imported_rows', 'failed_rows', 'get_success_rate', 'error_log') - }), - ('Metadaten', { - 'fields': ('created_by', 'started_at', 'completed_at') - }), + ( + "Grundinformationen", + {"fields": ("import_type", "filename", "file_size", "status")}, + ), + ( + "Ergebnisse", + { + "fields": ( + "total_rows", + "imported_rows", + "failed_rows", + "get_success_rate", + "error_log", + ) + }, + ), + ("Metadaten", {"fields": ("created_by", "started_at", "completed_at")}), ) - + def duration_display(self, obj): duration = obj.get_duration() if duration: return f"{duration.total_seconds():.1f}s" return "-" + duration_display.short_description = "Dauer" - + def get_success_rate(self, obj): rate = obj.get_success_rate() if rate >= 90: @@ -46,564 +67,836 @@ class CSVImportAdmin(admin.ModelAdmin): else: color = "danger" return format_html('{:.1f}%', color, rate) + get_success_rate.short_description = "Erfolgsrate" + @admin.register(Person) class PersonAdmin(admin.ModelAdmin): - list_display = ['nachname', 'vorname', 'familienzweig', 'geburtsdatum', 'email', 'iban_display'] - list_filter = ['familienzweig', 'geburtsdatum'] - search_fields = ['nachname', 'vorname', 'email', 'familienzweig'] - ordering = ['nachname', 'vorname'] - readonly_fields = ['id'] - + list_display = [ + "nachname", + "vorname", + "familienzweig", + "geburtsdatum", + "email", + "iban_display", + ] + list_filter = ["familienzweig", "geburtsdatum"] + search_fields = ["nachname", "vorname", "email", "familienzweig"] + ordering = ["nachname", "vorname"] + readonly_fields = ["id"] + fieldsets = ( - ('Persönliche Daten', { - 'fields': ('vorname', 'nachname', 'geburtsdatum', 'email', 'telefon') - }), - ('Stiftungsdaten', { - 'fields': ('familienzweig', 'iban', 'adresse') - }), - ('Zusätzlich', { - 'fields': ('notizen', 'aktiv') - }), - ('System', { - 'fields': ('id',), - 'classes': ('collapse',) - }), + ( + "Persönliche Daten", + {"fields": ("vorname", "nachname", "geburtsdatum", "email", "telefon")}, + ), + ("Stiftungsdaten", {"fields": ("familienzweig", "iban", "adresse")}), + ("Zusätzlich", {"fields": ("notizen", "aktiv")}), + ("System", {"fields": ("id",), "classes": ("collapse",)}), ) - + def iban_display(self, obj): if obj.iban: - return format_html('{}', obj.iban) - return '-' - iban_display.short_description = 'IBAN' - + return format_html( + '{}', obj.iban + ) + return "-" + + iban_display.short_description = "IBAN" + def get_queryset(self, request): - return super().get_queryset(request).annotate( - total_foerderungen=Sum('foerderung__betrag') + return ( + super() + .get_queryset(request) + .annotate(total_foerderungen=Sum("foerderung__betrag")) ) + @admin.register(Paechter) class PaechterAdmin(admin.ModelAdmin): - list_display = ['nachname', 'vorname', 'pachtnummer', 'pachtzins_aktuell', 'landwirtschaftliche_ausbildung', 'aktiv'] - list_filter = ['landwirtschaftliche_ausbildung', 'aktiv'] - search_fields = ['nachname', 'vorname', 'email', 'pachtnummer'] - ordering = ['nachname', 'vorname'] - readonly_fields = ['id'] - + list_display = [ + "nachname", + "vorname", + "pachtnummer", + "pachtzins_aktuell", + "landwirtschaftliche_ausbildung", + "aktiv", + ] + list_filter = ["landwirtschaftliche_ausbildung", "aktiv"] + search_fields = ["nachname", "vorname", "email", "pachtnummer"] + ordering = ["nachname", "vorname"] + readonly_fields = ["id"] + fieldsets = ( - ('Persönliche Daten', { - 'fields': ('vorname', 'nachname', 'geburtsdatum', 'email', 'telefon') - }), - ('Pacht-Informationen', { - 'fields': ('pachtnummer', 'pachtbeginn_erste', 'pachtende_letzte', 'pachtzins_aktuell') - }), - ('Landwirtschaftliche Qualifikation', { - 'fields': ('landwirtschaftliche_ausbildung', 'berufserfahrung_jahre', 'spezialisierung') - }), - ('Kontaktdaten', { - 'fields': ('iban', 'strasse', 'plz', 'ort') - }), - ('Pächter-Typ', { - 'fields': ('personentyp',) - }), - ('Zusätzlich', { - 'fields': ('notizen', 'aktiv') - }), - ('System', { - 'fields': ('id',), - 'classes': ('collapse',) - }), + ( + "Persönliche Daten", + {"fields": ("vorname", "nachname", "geburtsdatum", "email", "telefon")}, + ), + ( + "Pacht-Informationen", + { + "fields": ( + "pachtnummer", + "pachtbeginn_erste", + "pachtende_letzte", + "pachtzins_aktuell", + ) + }, + ), + ( + "Landwirtschaftliche Qualifikation", + { + "fields": ( + "landwirtschaftliche_ausbildung", + "berufserfahrung_jahre", + "spezialisierung", + ) + }, + ), + ("Kontaktdaten", {"fields": ("iban", "strasse", "plz", "ort")}), + ("Pächter-Typ", {"fields": ("personentyp",)}), + ("Zusätzlich", {"fields": ("notizen", "aktiv")}), + ("System", {"fields": ("id",), "classes": ("collapse",)}), ) - + def iban_display(self, obj): if obj.iban: - return format_html('{}', obj.iban) - return '-' - iban_display.short_description = 'IBAN' + return format_html( + '{}', obj.iban + ) + return "-" + + iban_display.short_description = "IBAN" + @admin.register(Destinataer) class DestinataerAdmin(admin.ModelAdmin): - list_display = ['nachname', 'vorname', 'familienzweig', 'berufsgruppe', 'institution', 'finanzielle_notlage', 'aktiv'] - list_filter = ['familienzweig', 'berufsgruppe', 'finanzielle_notlage', 'aktiv'] - search_fields = ['nachname', 'vorname', 'email', 'institution', 'familienzweig'] - ordering = ['nachname', 'vorname'] - readonly_fields = ['id'] - + list_display = [ + "nachname", + "vorname", + "familienzweig", + "berufsgruppe", + "institution", + "finanzielle_notlage", + "aktiv", + ] + list_filter = ["familienzweig", "berufsgruppe", "finanzielle_notlage", "aktiv"] + search_fields = ["nachname", "vorname", "email", "institution", "familienzweig"] + ordering = ["nachname", "vorname"] + readonly_fields = ["id"] + fieldsets = ( - ('Persönliche Daten', { - 'fields': ('vorname', 'nachname', 'geburtsdatum', 'email', 'telefon') - }), - ('Berufliche Informationen', { - 'fields': ('berufsgruppe', 'ausbildungsstand', 'institution') - }), - ('Projekt & Finanzen', { - 'fields': ('projekt_beschreibung', 'jaehrliches_einkommen', 'finanzielle_notlage') - }), - ('Stiftungsdaten', { - 'fields': ('familienzweig', 'iban', 'strasse', 'plz', 'ort') - }), - ('Zusätzlich', { - 'fields': ('notizen', 'aktiv') - }), - ('System', { - 'fields': ('id',), - 'classes': ('collapse',) - }), + ( + "Persönliche Daten", + {"fields": ("vorname", "nachname", "geburtsdatum", "email", "telefon")}, + ), + ( + "Berufliche Informationen", + {"fields": ("berufsgruppe", "ausbildungsstand", "institution")}, + ), + ( + "Projekt & Finanzen", + { + "fields": ( + "projekt_beschreibung", + "jaehrliches_einkommen", + "finanzielle_notlage", + ) + }, + ), + ( + "Stiftungsdaten", + {"fields": ("familienzweig", "iban", "strasse", "plz", "ort")}, + ), + ("Zusätzlich", {"fields": ("notizen", "aktiv")}), + ("System", {"fields": ("id",), "classes": ("collapse",)}), ) - + def iban_display(self, obj): if obj.iban: - return format_html('{}', obj.iban) - return '-' - iban_display.short_description = 'IBAN' + return format_html( + '{}', obj.iban + ) + return "-" + + iban_display.short_description = "IBAN" + @admin.register(Land) class LandAdmin(admin.ModelAdmin): list_display = [ - 'lfd_nr', 'gemeinde', 'gemarkung', 'flur', 'flurstueck', - 'groesse_qm', 'verp_flaeche_aktuell', 'verpachtungsgrad_display', 'aktiv' + "lfd_nr", + "gemeinde", + "gemarkung", + "flur", + "flurstueck", + "groesse_qm", + "verp_flaeche_aktuell", + "verpachtungsgrad_display", + "aktiv", ] - list_filter = ['gemeinde', 'gemarkung', 'aktiv'] - search_fields = ['lfd_nr', 'gemeinde', 'gemarkung', 'flur', 'flurstueck'] - ordering = ['gemeinde', 'gemarkung', 'flur', 'flurstueck'] - readonly_fields = ['id', 'gesamtflaeche_berechnet', 'verpachtungsgrad_berechnet'] - + list_filter = ["gemeinde", "gemarkung", "aktiv"] + search_fields = ["lfd_nr", "gemeinde", "gemarkung", "flur", "flurstueck"] + ordering = ["gemeinde", "gemarkung", "flur", "flurstueck"] + readonly_fields = ["id", "gesamtflaeche_berechnet", "verpachtungsgrad_berechnet"] + fieldsets = ( - ('Identifikation', { - 'fields': ('lfd_nr', 'ew_nummer') - }), - ('Gerichtliche Zuständigkeit', { - 'fields': ('amtsgericht',) - }), - ('Verwaltungsstruktur', { - 'fields': ('gemeinde', 'gemarkung', 'flur', 'flurstueck') - }), - ('Flächenangaben', { - 'fields': ('groesse_qm', 'gruenland_qm', 'acker_qm', 'wald_qm', 'sonstiges_qm') - }), - ('Verpachtung', { - 'fields': ('verpachtete_gesamtflaeche', 'flaeche_alte_liste', 'verp_flaeche_aktuell') - }), - ('Steuern und Abgaben', { - 'fields': ('anteil_grundsteuer', 'anteil_lwk') - }), - ('Status', { - 'fields': ('aktiv', 'notizen') - }), - ('System', { - 'fields': ('id', 'erstellt_am', 'aktualisiert_am'), - 'classes': ('collapse',) - }), + ("Identifikation", {"fields": ("lfd_nr", "ew_nummer")}), + ("Gerichtliche Zuständigkeit", {"fields": ("amtsgericht",)}), + ( + "Verwaltungsstruktur", + {"fields": ("gemeinde", "gemarkung", "flur", "flurstueck")}, + ), + ( + "Flächenangaben", + { + "fields": ( + "groesse_qm", + "gruenland_qm", + "acker_qm", + "wald_qm", + "sonstiges_qm", + ) + }, + ), + ( + "Verpachtung", + { + "fields": ( + "verpachtete_gesamtflaeche", + "flaeche_alte_liste", + "verp_flaeche_aktuell", + ) + }, + ), + ("Steuern und Abgaben", {"fields": ("anteil_grundsteuer", "anteil_lwk")}), + ("Status", {"fields": ("aktiv", "notizen")}), + ( + "System", + { + "fields": ("id", "erstellt_am", "aktualisiert_am"), + "classes": ("collapse",), + }, + ), ) - + def verpachtungsgrad_display(self, obj): grad = obj.get_verpachtungsgrad() if grad > 90: - color = 'green' + color = "green" elif grad > 70: - color = 'orange' + color = "orange" else: - color = 'red' + color = "red" return format_html('{:.1f}%', color, grad) - verpachtungsgrad_display.short_description = 'Verpachtungsgrad' - + + verpachtungsgrad_display.short_description = "Verpachtungsgrad" + def gesamtflaeche_berechnet(self, obj): return f"{obj.get_gesamtflaeche():.2f} qm" - gesamtflaeche_berechnet.short_description = 'Berechnete Gesamtfläche' - + + gesamtflaeche_berechnet.short_description = "Berechnete Gesamtfläche" + def verpachtungsgrad_berechnet(self, obj): return f"{obj.get_verpachtungsgrad():.1f}%" - verpachtungsgrad_berechnet.short_description = 'Verpachtungsgrad' + + verpachtungsgrad_berechnet.short_description = "Verpachtungsgrad" + @admin.register(DokumentLink) class DokumentLinkAdmin(admin.ModelAdmin): - list_display = ['titel', 'kontext', 'paperless_document_id'] - list_filter = ['kontext'] - search_fields = ['titel', 'kontext'] - ordering = ['titel'] - readonly_fields = ['id'] - + list_display = ["titel", "kontext", "paperless_document_id"] + list_filter = ["kontext"] + search_fields = ["titel", "kontext"] + ordering = ["titel"] + readonly_fields = ["id"] + fieldsets = ( - ('Dokument', { - 'fields': ('titel', 'kontext', 'paperless_document_id', 'beschreibung') - }), - ('System', { - 'fields': ('id',), - 'classes': ('collapse',) - }), + ( + "Dokument", + {"fields": ("titel", "kontext", "paperless_document_id", "beschreibung")}, + ), + ("System", {"fields": ("id",), "classes": ("collapse",)}), ) + @admin.register(Foerderung) class FoerderungAdmin(admin.ModelAdmin): - list_display = ['destinataer', 'jahr', 'betrag', 'verwendungsnachweis_link', 'total_for_destinataer'] - list_filter = ['jahr', 'destinataer__familienzweig'] - search_fields = ['destinataer__nachname', 'destinataer__vorname', 'destinataer__familienzweig'] - ordering = ['-jahr', '-betrag'] - readonly_fields = ['id'] - + list_display = [ + "destinataer", + "jahr", + "betrag", + "verwendungsnachweis_link", + "total_for_destinataer", + ] + list_filter = ["jahr", "destinataer__familienzweig"] + search_fields = [ + "destinataer__nachname", + "destinataer__vorname", + "destinataer__familienzweig", + ] + ordering = ["-jahr", "-betrag"] + readonly_fields = ["id"] + fieldsets = ( - ('Förderung', { - 'fields': ('destinataer', 'person', 'jahr', 'betrag', 'kategorie', 'status') - }), - ('Dokumentation', { - 'fields': ('verwendungsnachweis', 'bemerkungen') - }), - ('Daten', { - 'fields': ('antragsdatum', 'entscheidungsdatum') - }), - ('System', { - 'fields': ('id',), - 'classes': ('collapse',) - }), + ( + "Förderung", + { + "fields": ( + "destinataer", + "person", + "jahr", + "betrag", + "kategorie", + "status", + ) + }, + ), + ("Dokumentation", {"fields": ("verwendungsnachweis", "bemerkungen")}), + ("Daten", {"fields": ("antragsdatum", "entscheidungsdatum")}), + ("System", {"fields": ("id",), "classes": ("collapse",)}), ) - + def verwendungsnachweis_link(self, obj): if obj.verwendungsnachweis: return format_html( '{}', - reverse('admin:stiftung_dokumentlink_change', args=[obj.verwendungsnachweis.id]), - obj.verwendungsnachweis.titel + reverse( + "admin:stiftung_dokumentlink_change", + args=[obj.verwendungsnachweis.id], + ), + obj.verwendungsnachweis.titel, ) - return '-' - verwendungsnachweis_link.short_description = 'Verwendungsnachweis' - + return "-" + + verwendungsnachweis_link.short_description = "Verwendungsnachweis" + def total_for_destinataer(self, obj): - total = Foerderung.objects.filter(destinataer=obj.destinataer).aggregate(Sum('betrag'))['betrag__sum'] or 0 + total = ( + Foerderung.objects.filter(destinataer=obj.destinataer).aggregate( + Sum("betrag") + )["betrag__sum"] + or 0 + ) return f"€{total:,.2f}" - total_for_destinataer.short_description = 'Gesamt für Destinatär' + + total_for_destinataer.short_description = "Gesamt für Destinatär" @admin.register(Rentmeister) class RentmeisterAdmin(admin.ModelAdmin): - list_display = ['__str__', 'email', 'telefon', 'seit_datum', 'bis_datum', 'aktiv', 'monatliche_verguetung'] - list_filter = ['aktiv', 'seit_datum', 'anrede'] - search_fields = ['vorname', 'nachname', 'email', 'telefon', 'ort'] - ordering = ['nachname', 'vorname'] - readonly_fields = ['id', 'erstellt_am', 'aktualisiert_am'] - + list_display = [ + "__str__", + "email", + "telefon", + "seit_datum", + "bis_datum", + "aktiv", + "monatliche_verguetung", + ] + list_filter = ["aktiv", "seit_datum", "anrede"] + search_fields = ["vorname", "nachname", "email", "telefon", "ort"] + ordering = ["nachname", "vorname"] + readonly_fields = ["id", "erstellt_am", "aktualisiert_am"] + fieldsets = ( - ('Persönliche Daten', { - 'fields': ('anrede', 'vorname', 'nachname', 'titel') - }), - ('Kontaktdaten', { - 'fields': ('email', 'telefon', 'mobil', 'strasse', 'plz', 'ort') - }), - ('Bankdaten', { - 'fields': ('iban', 'bic', 'bank_name'), - 'classes': ['collapse'] - }), - ('Stiftungsdaten', { - 'fields': ('seit_datum', 'bis_datum', 'aktiv', 'monatliche_verguetung', 'km_pauschale') - }), - ('Zusätzliche Informationen', { - 'fields': ('notizen',), - 'classes': ['collapse'] - }), - ('System', { - 'fields': ('id', 'erstellt_am', 'aktualisiert_am'), - 'classes': ['collapse'] - }) + ("Persönliche Daten", {"fields": ("anrede", "vorname", "nachname", "titel")}), + ( + "Kontaktdaten", + {"fields": ("email", "telefon", "mobil", "strasse", "plz", "ort")}, + ), + ( + "Bankdaten", + {"fields": ("iban", "bic", "bank_name"), "classes": ["collapse"]}, + ), + ( + "Stiftungsdaten", + { + "fields": ( + "seit_datum", + "bis_datum", + "aktiv", + "monatliche_verguetung", + "km_pauschale", + ) + }, + ), + ( + "Zusätzliche Informationen", + {"fields": ("notizen",), "classes": ["collapse"]}, + ), + ( + "System", + { + "fields": ("id", "erstellt_am", "aktualisiert_am"), + "classes": ["collapse"], + }, + ), ) @admin.register(StiftungsKonto) class StiftungsKontoAdmin(admin.ModelAdmin): - list_display = ['kontoname', 'bank_name', 'konto_typ', 'saldo', 'saldo_datum', 'aktiv'] - list_filter = ['konto_typ', 'aktiv', 'bank_name'] - search_fields = ['kontoname', 'bank_name', 'iban'] - ordering = ['bank_name', 'kontoname'] - readonly_fields = ['id', 'erstellt_am', 'aktualisiert_am'] - + list_display = [ + "kontoname", + "bank_name", + "konto_typ", + "saldo", + "saldo_datum", + "aktiv", + ] + list_filter = ["konto_typ", "aktiv", "bank_name"] + search_fields = ["kontoname", "bank_name", "iban"] + ordering = ["bank_name", "kontoname"] + readonly_fields = ["id", "erstellt_am", "aktualisiert_am"] + fieldsets = ( - ('Kontodaten', { - 'fields': ('kontoname', 'bank_name', 'iban', 'bic', 'konto_typ') - }), - ('Finanzdaten', { - 'fields': ('saldo', 'saldo_datum', 'zinssatz', 'laufzeit_bis') - }), - ('Status', { - 'fields': ('aktiv', 'notizen') - }), - ('System', { - 'fields': ('id', 'erstellt_am', 'aktualisiert_am'), - 'classes': ['collapse'] - }) + ( + "Kontodaten", + {"fields": ("kontoname", "bank_name", "iban", "bic", "konto_typ")}, + ), + ( + "Finanzdaten", + {"fields": ("saldo", "saldo_datum", "zinssatz", "laufzeit_bis")}, + ), + ("Status", {"fields": ("aktiv", "notizen")}), + ( + "System", + { + "fields": ("id", "erstellt_am", "aktualisiert_am"), + "classes": ["collapse"], + }, + ), ) @admin.register(Verwaltungskosten) class VerwaltungskostenAdmin(admin.ModelAdmin): - list_display = ['bezeichnung', 'kategorie', 'betrag', 'datum', 'status', 'rentmeister', 'konto'] - list_filter = ['kategorie', 'status', 'datum', 'rentmeister', 'konto'] - search_fields = ['bezeichnung', 'lieferant_firma', 'rechnungsnummer', 'beschreibung'] - ordering = ['-datum', '-erstellt_am'] - readonly_fields = ['id', 'erstellt_am', 'aktualisiert_am'] - date_hierarchy = 'datum' - + list_display = [ + "bezeichnung", + "kategorie", + "betrag", + "datum", + "status", + "rentmeister", + "konto", + ] + list_filter = ["kategorie", "status", "datum", "rentmeister", "konto"] + search_fields = [ + "bezeichnung", + "lieferant_firma", + "rechnungsnummer", + "beschreibung", + ] + ordering = ["-datum", "-erstellt_am"] + readonly_fields = ["id", "erstellt_am", "aktualisiert_am"] + date_hierarchy = "datum" + fieldsets = ( - ('Grunddaten', { - 'fields': ('bezeichnung', 'kategorie', 'betrag', 'datum', 'status') - }), - ('Zuordnung', { - 'fields': ('rentmeister', 'konto') - }), - ('Lieferant/Rechnung', { - 'fields': ('lieferant_firma', 'rechnungsnummer'), - 'classes': ['collapse'] - }), - ('Fahrtkosten', { - 'fields': ('km_anzahl', 'km_satz', 'von_ort', 'nach_ort', 'zweck'), - 'classes': ['collapse'], - 'description': 'Nur für Kategorie "Fahrtkosten" relevant' - }), - ('Zusätzliche Informationen', { - 'fields': ('beschreibung', 'notizen'), - 'classes': ['collapse'] - }), - ('System', { - 'fields': ('id', 'erstellt_am', 'aktualisiert_am'), - 'classes': ['collapse'] - }) + ( + "Grunddaten", + {"fields": ("bezeichnung", "kategorie", "betrag", "datum", "status")}, + ), + ("Zuordnung", {"fields": ("rentmeister", "konto")}), + ( + "Lieferant/Rechnung", + {"fields": ("lieferant_firma", "rechnungsnummer"), "classes": ["collapse"]}, + ), + ( + "Fahrtkosten", + { + "fields": ("km_anzahl", "km_satz", "von_ort", "nach_ort", "zweck"), + "classes": ["collapse"], + "description": 'Nur für Kategorie "Fahrtkosten" relevant', + }, + ), + ( + "Zusätzliche Informationen", + {"fields": ("beschreibung", "notizen"), "classes": ["collapse"]}, + ), + ( + "System", + { + "fields": ("id", "erstellt_am", "aktualisiert_am"), + "classes": ["collapse"], + }, + ), ) @admin.register(BankTransaction) class BankTransactionAdmin(admin.ModelAdmin): list_display = [ - 'datum', 'konto', 'betrag', 'empfaenger_zahlungspflichtiger', - 'transaction_type', 'status', 'verwaltungskosten' + "datum", + "konto", + "betrag", + "empfaenger_zahlungspflichtiger", + "transaction_type", + "status", + "verwaltungskosten", ] - list_filter = [ - 'konto', 'transaction_type', 'status', 'datum', 'importiert_am' - ] - search_fields = [ - 'verwendungszweck', 'empfaenger_zahlungspflichtiger', 'referenz' - ] - readonly_fields = ['importiert_am', 'import_datei'] - ordering = ['-datum', '-importiert_am'] - + list_filter = ["konto", "transaction_type", "status", "datum", "importiert_am"] + search_fields = ["verwendungszweck", "empfaenger_zahlungspflichtiger", "referenz"] + readonly_fields = ["importiert_am", "import_datei"] + ordering = ["-datum", "-importiert_am"] + fieldsets = ( - ('Basisdaten', { - 'fields': ('konto', 'datum', 'valuta', 'betrag', 'waehrung') - }), - ('Transaktionsdetails', { - 'fields': ('verwendungszweck', 'empfaenger_zahlungspflichtiger', 'iban_gegenpartei', 'bic_gegenpartei', 'referenz', 'transaction_type') - }), - ('Verwaltung', { - 'fields': ('status', 'kommentare', 'verwaltungskosten') - }), - ('Import-Information', { - 'fields': ('import_datei', 'importiert_am', 'saldo_nach_buchung'), - 'classes': ('collapse',) - }), + ("Basisdaten", {"fields": ("konto", "datum", "valuta", "betrag", "waehrung")}), + ( + "Transaktionsdetails", + { + "fields": ( + "verwendungszweck", + "empfaenger_zahlungspflichtiger", + "iban_gegenpartei", + "bic_gegenpartei", + "referenz", + "transaction_type", + ) + }, + ), + ("Verwaltung", {"fields": ("status", "kommentare", "verwaltungskosten")}), + ( + "Import-Information", + { + "fields": ("import_datei", "importiert_am", "saldo_nach_buchung"), + "classes": ("collapse",), + }, + ), ) - + def get_queryset(self, request): - return super().get_queryset(request).select_related('konto', 'verwaltungskosten') + return ( + super().get_queryset(request).select_related("konto", "verwaltungskosten") + ) @admin.register(AuditLog) class AuditLogAdmin(admin.ModelAdmin): - list_display = ['timestamp', 'username', 'action', 'entity_type', 'entity_name', 'ip_address'] - list_filter = ['action', 'entity_type', 'timestamp', 'username'] - search_fields = ['username', 'entity_name', 'description', 'ip_address'] - readonly_fields = ['id', 'timestamp', 'user', 'username', 'action', 'entity_type', 'entity_id', 'entity_name', 'description', 'changes', 'ip_address', 'user_agent', 'session_key'] - ordering = ['-timestamp'] - date_hierarchy = 'timestamp' - + list_display = [ + "timestamp", + "username", + "action", + "entity_type", + "entity_name", + "ip_address", + ] + list_filter = ["action", "entity_type", "timestamp", "username"] + search_fields = ["username", "entity_name", "description", "ip_address"] + readonly_fields = [ + "id", + "timestamp", + "user", + "username", + "action", + "entity_type", + "entity_id", + "entity_name", + "description", + "changes", + "ip_address", + "user_agent", + "session_key", + ] + ordering = ["-timestamp"] + date_hierarchy = "timestamp" + fieldsets = ( - ('Benutzer und Zeit', { - 'fields': ('timestamp', 'user', 'username', 'session_key') - }), - ('Aktion', { - 'fields': ('action', 'entity_type', 'entity_id', 'entity_name', 'description') - }), - ('Änderungsdetails', { - 'fields': ('changes',), - 'classes': ['collapse'] - }), - ('Request-Informationen', { - 'fields': ('ip_address', 'user_agent'), - 'classes': ['collapse'] - }), - ('System', { - 'fields': ('id',), - 'classes': ['collapse'] - }) + ( + "Benutzer und Zeit", + {"fields": ("timestamp", "user", "username", "session_key")}, + ), + ( + "Aktion", + { + "fields": ( + "action", + "entity_type", + "entity_id", + "entity_name", + "description", + ) + }, + ), + ("Änderungsdetails", {"fields": ("changes",), "classes": ["collapse"]}), + ( + "Request-Informationen", + {"fields": ("ip_address", "user_agent"), "classes": ["collapse"]}, + ), + ("System", {"fields": ("id",), "classes": ["collapse"]}), ) - + def has_add_permission(self, request): return False # Don't allow manual creation - + def has_change_permission(self, request, obj=None): return False # Don't allow editing @admin.register(BackupJob) class BackupJobAdmin(admin.ModelAdmin): - list_display = ['created_at', 'backup_type', 'status', 'backup_size_display', 'duration_display', 'created_by'] - list_filter = ['backup_type', 'status', 'created_at'] - search_fields = ['backup_filename', 'created_by__username'] - readonly_fields = ['id', 'created_at', 'started_at', 'completed_at', 'backup_size', 'get_duration'] - ordering = ['-created_at'] - + list_display = [ + "created_at", + "backup_type", + "status", + "backup_size_display", + "duration_display", + "created_by", + ] + list_filter = ["backup_type", "status", "created_at"] + search_fields = ["backup_filename", "created_by__username"] + readonly_fields = [ + "id", + "created_at", + "started_at", + "completed_at", + "backup_size", + "get_duration", + ] + ordering = ["-created_at"] + fieldsets = ( - ('Job-Details', { - 'fields': ('backup_type', 'status', 'created_by') - }), - ('Zeitpunkte', { - 'fields': ('created_at', 'started_at', 'completed_at', 'get_duration') - }), - ('Ergebnis', { - 'fields': ('backup_filename', 'backup_size', 'database_size', 'files_count') - }), - ('Fehlerbehandlung', { - 'fields': ('error_message',), - 'classes': ['collapse'] - }), - ('System', { - 'fields': ('id',), - 'classes': ['collapse'] - }) + ("Job-Details", {"fields": ("backup_type", "status", "created_by")}), + ( + "Zeitpunkte", + {"fields": ("created_at", "started_at", "completed_at", "get_duration")}, + ), + ( + "Ergebnis", + { + "fields": ( + "backup_filename", + "backup_size", + "database_size", + "files_count", + ) + }, + ), + ("Fehlerbehandlung", {"fields": ("error_message",), "classes": ["collapse"]}), + ("System", {"fields": ("id",), "classes": ["collapse"]}), ) - + def backup_size_display(self, obj): return obj.get_size_display() - backup_size_display.short_description = 'Backup-Größe' - + + backup_size_display.short_description = "Backup-Größe" + def duration_display(self, obj): duration = obj.get_duration() if duration: return f"{duration.total_seconds():.1f}s" return "-" + duration_display.short_description = "Dauer" - + def has_add_permission(self, request): return False # Use the web interface for creating backups @admin.register(AppConfiguration) class AppConfigurationAdmin(admin.ModelAdmin): - list_display = ['display_name', 'key', 'value_display', 'category', 'setting_type', 'is_active', 'updated_at'] - list_filter = ['category', 'setting_type', 'is_active'] - search_fields = ['key', 'display_name', 'description'] - readonly_fields = ['id', 'created_at', 'updated_at'] - ordering = ['category', 'order', 'display_name'] - + list_display = [ + "display_name", + "key", + "value_display", + "category", + "setting_type", + "is_active", + "updated_at", + ] + list_filter = ["category", "setting_type", "is_active"] + search_fields = ["key", "display_name", "description"] + readonly_fields = ["id", "created_at", "updated_at"] + ordering = ["category", "order", "display_name"] + fieldsets = ( - ('Basic Information', { - 'fields': ('key', 'display_name', 'description', 'category', 'setting_type') - }), - ('Value Configuration', { - 'fields': ('value', 'default_value') - }), - ('Options', { - 'fields': ('is_active', 'is_system', 'order') - }), - ('Metadata', { - 'fields': ('created_at', 'updated_at'), - 'classes': ('collapse',) - }), + ( + "Basic Information", + { + "fields": ( + "key", + "display_name", + "description", + "category", + "setting_type", + ) + }, + ), + ("Value Configuration", {"fields": ("value", "default_value")}), + ("Options", {"fields": ("is_active", "is_system", "order")}), + ( + "Metadata", + {"fields": ("created_at", "updated_at"), "classes": ("collapse",)}, + ), ) - + def value_display(self, obj): """Display value with type formatting""" value = obj.value - if obj.setting_type == 'boolean': - icon = '✅' if obj.get_typed_value() else '❌' - return format_html('{} {}', icon, value) - elif obj.setting_type == 'url': - return format_html('{}', value, value[:50] + '...' if len(value) > 50 else value) + if obj.setting_type == "boolean": + icon = "✅" if obj.get_typed_value() else "❌" + return format_html("{} {}", icon, value) + elif obj.setting_type == "url": + return format_html( + '{}', + value, + value[:50] + "..." if len(value) > 50 else value, + ) elif len(value) > 100: - return value[:100] + '...' + return value[:100] + "..." return value - value_display.short_description = 'Current Value' - + + value_display.short_description = "Current Value" + def get_readonly_fields(self, request, obj=None): readonly = list(self.readonly_fields) if obj and obj.is_system: - readonly.extend(['key', 'setting_type', 'is_system']) + readonly.extend(["key", "setting_type", "is_system"]) return readonly @admin.register(DestinataerUnterstuetzung) class DestinataerUnterstuetzungAdmin(admin.ModelAdmin): - list_display = ['__str__', 'destinataer', 'betrag', 'faellig_am', 'status', 'wiederkehrend_von', 'ausgezahlt_am'] - list_filter = ['status', 'faellig_am', 'erstellt_am', 'konto'] - search_fields = ['destinataer__vorname', 'destinataer__nachname', 'beschreibung', 'empfaenger_name'] - readonly_fields = ['id', 'erstellt_am', 'aktualisiert_am'] - + list_display = [ + "__str__", + "destinataer", + "betrag", + "faellig_am", + "status", + "wiederkehrend_von", + "ausgezahlt_am", + ] + list_filter = ["status", "faellig_am", "erstellt_am", "konto"] + search_fields = [ + "destinataer__vorname", + "destinataer__nachname", + "beschreibung", + "empfaenger_name", + ] + readonly_fields = ["id", "erstellt_am", "aktualisiert_am"] + fieldsets = ( - ('Grundinformationen', { - 'fields': ('destinataer', 'konto', 'betrag', 'faellig_am', 'status', 'beschreibung') - }), - ('Überweisungsdaten', { - 'fields': ('empfaenger_iban', 'empfaenger_name', 'verwendungszweck') - }), - ('Zahlungsinformationen', { - 'fields': ('ausgezahlt_am', 'ausgezahlt_von') - }), - ('Wiederkehrend', { - 'fields': ('wiederkehrend_von',) - }), - ('Metadaten', { - 'fields': ('id', 'erstellt_am', 'aktualisiert_am'), - 'classes': ('collapse',) - }), + ( + "Grundinformationen", + { + "fields": ( + "destinataer", + "konto", + "betrag", + "faellig_am", + "status", + "beschreibung", + ) + }, + ), + ( + "Überweisungsdaten", + {"fields": ("empfaenger_iban", "empfaenger_name", "verwendungszweck")}, + ), + ("Zahlungsinformationen", {"fields": ("ausgezahlt_am", "ausgezahlt_von")}), + ("Wiederkehrend", {"fields": ("wiederkehrend_von",)}), + ( + "Metadaten", + { + "fields": ("id", "erstellt_am", "aktualisiert_am"), + "classes": ("collapse",), + }, + ), ) -@admin.register(UnterstuetzungWiederkehrend) +@admin.register(UnterstuetzungWiederkehrend) class UnterstuetzungWiederkehrendAdmin(admin.ModelAdmin): - list_display = ['__str__', 'destinataer', 'betrag', 'intervall', 'aktiv', 'naechste_generierung'] - list_filter = ['intervall', 'aktiv', 'erstellt_am'] - search_fields = ['destinataer__vorname', 'destinataer__nachname', 'beschreibung', 'empfaenger_name'] - readonly_fields = ['id', 'erstellt_am'] - + list_display = [ + "__str__", + "destinataer", + "betrag", + "intervall", + "aktiv", + "naechste_generierung", + ] + list_filter = ["intervall", "aktiv", "erstellt_am"] + search_fields = [ + "destinataer__vorname", + "destinataer__nachname", + "beschreibung", + "empfaenger_name", + ] + readonly_fields = ["id", "erstellt_am"] + fieldsets = ( - ('Grundinformationen', { - 'fields': ('destinataer', 'konto', 'betrag', 'intervall', 'beschreibung', 'aktiv') - }), - ('Überweisungsdaten', { - 'fields': ('empfaenger_iban', 'empfaenger_name', 'verwendungszweck') - }), - ('Zeitplanung', { - 'fields': ('erste_zahlung_am', 'letzte_zahlung_am', 'naechste_generierung') - }), - ('Metadaten', { - 'fields': ('id', 'erstellt_von', 'erstellt_am'), - 'classes': ('collapse',) - }), + ( + "Grundinformationen", + { + "fields": ( + "destinataer", + "konto", + "betrag", + "intervall", + "beschreibung", + "aktiv", + ) + }, + ), + ( + "Überweisungsdaten", + {"fields": ("empfaenger_iban", "empfaenger_name", "verwendungszweck")}, + ), + ( + "Zeitplanung", + { + "fields": ( + "erste_zahlung_am", + "letzte_zahlung_am", + "naechste_generierung", + ) + }, + ), + ( + "Metadaten", + {"fields": ("id", "erstellt_von", "erstellt_am"), "classes": ("collapse",)}, + ), ) @admin.register(models.HelpBox) class HelpBoxAdmin(admin.ModelAdmin): - list_display = ['get_page_display', 'title', 'is_active', 'updated_at', 'updated_by'] - list_filter = ['page_key', 'is_active', 'updated_at'] - search_fields = ['title', 'content'] - + list_display = [ + "get_page_display", + "title", + "is_active", + "updated_at", + "updated_by", + ] + list_filter = ["page_key", "is_active", "updated_at"] + search_fields = ["title", "content"] + fieldsets = ( - ('Grundinformationen', { - 'fields': ('page_key', 'title', 'is_active') - }), - ('Inhalt', { - 'fields': ('content',), - 'description': 'Markdown wird unterstützt: **fett**, *kursiv*, `code`, [Link](url), Listen mit - oder 1.' - }), - ('Metadaten', { - 'fields': ('created_at', 'updated_at', 'created_by', 'updated_by'), - 'classes': ('collapse',) - }), + ("Grundinformationen", {"fields": ("page_key", "title", "is_active")}), + ( + "Inhalt", + { + "fields": ("content",), + "description": "Markdown wird unterstützt: **fett**, *kursiv*, `code`, [Link](url), Listen mit - oder 1.", + }, + ), + ( + "Metadaten", + { + "fields": ("created_at", "updated_at", "created_by", "updated_by"), + "classes": ("collapse",), + }, + ), ) - - readonly_fields = ['created_at', 'updated_at'] - + + readonly_fields = ["created_at", "updated_at"] + def get_page_display(self, obj): return obj.get_page_key_display() + get_page_display.short_description = "Seite" - + def save_model(self, request, obj, form, change): if not change: # Neues Objekt obj.created_by = request.user.username diff --git a/app/stiftung/apps.py b/app/stiftung/apps.py index a72a263..677c8e7 100644 --- a/app/stiftung/apps.py +++ b/app/stiftung/apps.py @@ -1,5 +1,6 @@ from django.apps import AppConfig + class StiftungConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'stiftung' + default_auto_field = "django.db.models.BigAutoField" + name = "stiftung" diff --git a/app/stiftung/audit.py b/app/stiftung/audit.py index d6b5b6b..6e0a7ba 100644 --- a/app/stiftung/audit.py +++ b/app/stiftung/audit.py @@ -4,8 +4,10 @@ Provides functions to log user actions throughout the application """ import json -from django.utils import timezone + from django.contrib.auth import get_user_model +from django.utils import timezone + from stiftung.models import AuditLog User = get_user_model() @@ -13,18 +15,20 @@ User = get_user_model() def get_client_ip(request): """Extract the client IP address from the request""" - x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR') + x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR") if x_forwarded_for: - ip = x_forwarded_for.split(',')[0] + ip = x_forwarded_for.split(",")[0] else: - ip = request.META.get('REMOTE_ADDR') + ip = request.META.get("REMOTE_ADDR") return ip -def log_action(request, action, entity_type, entity_id, entity_name, description, changes=None): +def log_action( + request, action, entity_type, entity_id, entity_name, description, changes=None +): """ Log a user action to the audit log - + Args: request: Django request object action: Action type (create, update, delete, etc.) @@ -35,28 +39,28 @@ def log_action(request, action, entity_type, entity_id, entity_name, description changes: Dictionary of field changes (optional) """ user = request.user if request.user.is_authenticated else None - username = user.username if user else 'Anonymous' - + username = user.username if user else "Anonymous" + # Get request metadata ip_address = get_client_ip(request) - user_agent = request.META.get('HTTP_USER_AGENT', '') - session_key = request.session.session_key if hasattr(request, 'session') else '' - + user_agent = request.META.get("HTTP_USER_AGENT", "") + session_key = request.session.session_key if hasattr(request, "session") else "" + # Create audit log entry audit_entry = AuditLog.objects.create( user=user, username=username, action=action, entity_type=entity_type, - entity_id=str(entity_id) if entity_id else '', + entity_id=str(entity_id) if entity_id else "", entity_name=entity_name, description=description, changes=changes, ip_address=ip_address, user_agent=user_agent[:500], # Truncate to avoid very long user agents - session_key=session_key + session_key=session_key, ) - + return audit_entry @@ -64,14 +68,14 @@ def log_create(request, entity_type, entity_id, entity_name, description=None): """Log entity creation""" if not description: description = f"Neue {entity_type.replace('_', ' ').title()} '{entity_name}' wurde erstellt" - + return log_action( request=request, - action='create', + action="create", entity_type=entity_type, entity_id=entity_id, entity_name=entity_name, - description=description + description=description, ) @@ -81,60 +85,78 @@ def log_update(request, entity_type, entity_id, entity_name, changes, descriptio changed_fields = list(changes.keys()) if changes else [] fields_str = ", ".join(changed_fields) description = f"{entity_type.replace('_', ' ').title()} '{entity_name}' wurde aktualisiert: {fields_str}" - + return log_action( request=request, - action='update', + action="update", entity_type=entity_type, entity_id=entity_id, entity_name=entity_name, description=description, - changes=changes + changes=changes, ) def log_delete(request, entity_type, entity_id, entity_name, description=None): """Log entity deletion""" if not description: - description = f"{entity_type.replace('_', ' ').title()} '{entity_name}' wurde gelöscht" - + description = ( + f"{entity_type.replace('_', ' ').title()} '{entity_name}' wurde gelöscht" + ) + return log_action( request=request, - action='delete', + action="delete", entity_type=entity_type, entity_id=entity_id, entity_name=entity_name, - description=description + description=description, ) -def log_link(request, entity_type, entity_id, entity_name, target_type, target_name, description=None): +def log_link( + request, + entity_type, + entity_id, + entity_name, + target_type, + target_name, + description=None, +): """Log entity linking""" if not description: description = f"{entity_type.replace('_', ' ').title()} '{entity_name}' wurde mit {target_type.replace('_', ' ')} '{target_name}' verknüpft" - + return log_action( request=request, - action='link', + action="link", entity_type=entity_type, entity_id=entity_id, entity_name=entity_name, - description=description + description=description, ) -def log_unlink(request, entity_type, entity_id, entity_name, target_type, target_name, description=None): +def log_unlink( + request, + entity_type, + entity_id, + entity_name, + target_type, + target_name, + description=None, +): """Log entity unlinking""" if not description: description = f"Verknüpfung zwischen {entity_type.replace('_', ' ').title()} '{entity_name}' und {target_type.replace('_', ' ')} '{target_name}' wurde entfernt" - + return log_action( request=request, - action='unlink', + action="unlink", entity_type=entity_type, entity_id=entity_id, entity_name=entity_name, - description=description + description=description, ) @@ -143,51 +165,54 @@ def log_system_action(request, action, description, details=None): return log_action( request=request, action=action, - entity_type='system', - entity_id='', - entity_name='System', + entity_type="system", + entity_id="", + entity_name="System", description=description, - changes=details + changes=details, ) def track_model_changes(old_instance, new_instance, exclude_fields=None): """ Track changes between model instances - + Args: old_instance: Original model instance - new_instance: Updated model instance + new_instance: Updated model instance exclude_fields: List of fields to exclude from tracking - + Returns: Dictionary of changes in format {field: {'old': old_value, 'new': new_value}} """ if exclude_fields is None: - exclude_fields = ['id', 'erstellt_am', 'aktualisiert_am', 'created_at', 'updated_at'] - + exclude_fields = [ + "id", + "erstellt_am", + "aktualisiert_am", + "created_at", + "updated_at", + ] + changes = {} - + if old_instance and new_instance: for field in new_instance._meta.fields: field_name = field.name - + if field_name in exclude_fields: continue - + old_value = getattr(old_instance, field_name, None) new_value = getattr(new_instance, field_name, None) - + # Convert to string for comparison old_str = str(old_value) if old_value is not None else None new_str = str(new_value) if new_value is not None else None - + if old_str != new_str: - changes[field_name] = { - 'old': old_str, - 'new': new_str - } - + changes[field_name] = {"old": old_str, "new": new_str} + return changes @@ -195,38 +220,39 @@ class AuditLogMixin: """ Mixin for views that provides audit logging functionality """ + audit_entity_type = None - audit_entity_name_field = 'name' - + audit_entity_name_field = "name" + def get_audit_entity_type(self): """Get the entity type for audit logging""" if self.audit_entity_type: return self.audit_entity_type - + # Try to derive from model name - if hasattr(self, 'model') and self.model: + if hasattr(self, "model") and self.model: return self.model.__name__.lower() - - return 'unknown' - + + return "unknown" + def get_audit_entity_name(self, instance): """Get the entity name for audit logging""" if hasattr(instance, self.audit_entity_name_field): return str(getattr(instance, self.audit_entity_name_field)) - elif hasattr(instance, '__str__'): + elif hasattr(instance, "__str__"): return str(instance) else: return f"{self.get_audit_entity_type()} #{instance.pk}" - + def log_create_action(self, instance): """Log creation of an instance""" log_create( request=self.request, entity_type=self.get_audit_entity_type(), entity_id=instance.pk, - entity_name=self.get_audit_entity_name(instance) + entity_name=self.get_audit_entity_name(instance), ) - + def log_update_action(self, old_instance, new_instance): """Log update of an instance""" changes = track_model_changes(old_instance, new_instance) @@ -236,16 +262,16 @@ class AuditLogMixin: entity_type=self.get_audit_entity_type(), entity_id=new_instance.pk, entity_name=self.get_audit_entity_name(new_instance), - changes=changes + changes=changes, ) - + def log_delete_action(self, instance): """Log deletion of an instance""" log_delete( request=self.request, entity_type=self.get_audit_entity_type(), entity_id=instance.pk, - entity_name=self.get_audit_entity_name(instance) + entity_name=self.get_audit_entity_name(instance), ) @@ -255,11 +281,11 @@ def log_login(request, user): try: return log_action( request=request, - action='login', - entity_type='user', + action="login", + entity_type="user", entity_id=user.pk, entity_name=user.get_username(), - description=f"User '{user.get_username()}' logged in" + description=f"User '{user.get_username()}' logged in", ) except Exception: return None @@ -268,14 +294,14 @@ def log_login(request, user): def log_logout(request, user): """Log a successful user logout.""" try: - username = user.get_username() if user else 'Unknown' + username = user.get_username() if user else "Unknown" return log_action( request=request, - action='logout', - entity_type='user', - entity_id=getattr(user, 'pk', ''), + action="logout", + entity_type="user", + entity_id=getattr(user, "pk", ""), entity_name=username, - description=f"User '{username}' logged out" + description=f"User '{username}' logged out", ) except Exception: return None diff --git a/app/stiftung/backup_utils.py b/app/stiftung/backup_utils.py index cffcb54..910a8bf 100644 --- a/app/stiftung/backup_utils.py +++ b/app/stiftung/backup_utils.py @@ -6,17 +6,19 @@ Handles creation and restoration of complete system backups import os import shutil import subprocess -import tempfile import tarfile +import tempfile from datetime import datetime + from django.conf import settings from django.utils import timezone + from stiftung.models import BackupJob def get_backup_directory(): """Get or create the backup directory""" - backup_dir = '/app/backups' + backup_dir = "/app/backups" os.makedirs(backup_dir, exist_ok=True) return backup_dir @@ -28,48 +30,48 @@ def run_backup(backup_job_id): """ try: backup_job = BackupJob.objects.get(id=backup_job_id) - backup_job.status = 'running' + backup_job.status = "running" backup_job.started_at = timezone.now() backup_job.save() - + backup_dir = get_backup_directory() - timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") backup_filename = f"stiftung_backup_{timestamp}.tar.gz" backup_path = os.path.join(backup_dir, backup_filename) - + # Create temporary directory for backup staging with tempfile.TemporaryDirectory() as temp_dir: - staging_dir = os.path.join(temp_dir, 'backup_staging') + staging_dir = os.path.join(temp_dir, "backup_staging") os.makedirs(staging_dir) - + # 1. Database backup - if backup_job.backup_type in ['full', 'database']: + if backup_job.backup_type in ["full", "database"]: db_backup_path = create_database_backup(staging_dir) if not db_backup_path: raise Exception("Database backup failed") - + # 2. Files backup - if backup_job.backup_type in ['full', 'files']: + if backup_job.backup_type in ["full", "files"]: files_backup_path = create_files_backup(staging_dir) if not files_backup_path: raise Exception("Files backup failed") - + # 3. Create metadata file create_backup_metadata(staging_dir, backup_job) - + # 4. Create compressed archive create_compressed_backup(staging_dir, backup_path) - + # 5. Update job status backup_size = os.path.getsize(backup_path) - backup_job.status = 'completed' + backup_job.status = "completed" backup_job.completed_at = timezone.now() backup_job.backup_filename = backup_filename backup_job.backup_size = backup_size backup_job.save() - + except Exception as e: - backup_job.status = 'failed' + backup_job.status = "failed" backup_job.error_message = str(e) backup_job.completed_at = timezone.now() backup_job.save() @@ -78,37 +80,42 @@ def run_backup(backup_job_id): def create_database_backup(staging_dir): """Create a database backup using pg_dump""" try: - db_backup_file = os.path.join(staging_dir, 'database.sql') - + db_backup_file = os.path.join(staging_dir, "database.sql") + # Get database settings - db_settings = settings.DATABASES['default'] - + db_settings = settings.DATABASES["default"] + # Build pg_dump command cmd = [ - 'pg_dump', - '--host', db_settings.get('HOST', 'localhost'), - '--port', str(db_settings.get('PORT', 5432)), - '--username', db_settings.get('USER', 'postgres'), - '--format', 'custom', - '--no-owner', # portability across environments - '--no-privileges', # skip GRANT/REVOKE - '--no-password', - '--file', db_backup_file, - db_settings.get('NAME', 'stiftung') + "pg_dump", + "--host", + db_settings.get("HOST", "localhost"), + "--port", + str(db_settings.get("PORT", 5432)), + "--username", + db_settings.get("USER", "postgres"), + "--format", + "custom", + "--no-owner", # portability across environments + "--no-privileges", # skip GRANT/REVOKE + "--no-password", + "--file", + db_backup_file, + db_settings.get("NAME", "stiftung"), ] - + # Set environment variables for authentication env = os.environ.copy() - env['PGPASSWORD'] = db_settings.get('PASSWORD', '') - + env["PGPASSWORD"] = db_settings.get("PASSWORD", "") + # Run pg_dump result = subprocess.run(cmd, env=env, capture_output=True, text=True) - + if result.returncode != 0: raise Exception(f"pg_dump failed: {result.stderr}") - + return db_backup_file - + except Exception as e: print(f"Database backup failed: {e}") return None @@ -117,28 +124,28 @@ def create_database_backup(staging_dir): def create_files_backup(staging_dir): """Create backup of application files""" try: - files_dir = os.path.join(staging_dir, 'files') + files_dir = os.path.join(staging_dir, "files") os.makedirs(files_dir) - + # Files to backup backup_paths = [ - '/app/media', # User uploads - '/app/static', # Static files - '/app/.env', # Environment configuration + "/app/media", # User uploads + "/app/static", # Static files + "/app/.env", # Environment configuration ] - + for source_path in backup_paths: if os.path.exists(source_path): basename = os.path.basename(source_path) dest_path = os.path.join(files_dir, basename) - + if os.path.isdir(source_path): shutil.copytree(source_path, dest_path) else: shutil.copy2(source_path, dest_path) - + return files_dir - + except Exception as e: print(f"Files backup failed: {e}") return None @@ -147,26 +154,28 @@ def create_files_backup(staging_dir): def create_backup_metadata(staging_dir, backup_job): """Create metadata file with backup information""" import json - + metadata = { - 'backup_id': str(backup_job.id), - 'backup_type': backup_job.backup_type, - 'created_at': backup_job.created_at.isoformat(), - 'created_by': backup_job.created_by.username if backup_job.created_by else 'system', - 'django_version': '5.0.6', - 'app_version': '1.0.0', - 'python_version': '3.12', + "backup_id": str(backup_job.id), + "backup_type": backup_job.backup_type, + "created_at": backup_job.created_at.isoformat(), + "created_by": ( + backup_job.created_by.username if backup_job.created_by else "system" + ), + "django_version": "5.0.6", + "app_version": "1.0.0", + "python_version": "3.12", } - - metadata_file = os.path.join(staging_dir, 'backup_metadata.json') - with open(metadata_file, 'w') as f: + + metadata_file = os.path.join(staging_dir, "backup_metadata.json") + with open(metadata_file, "w") as f: json.dump(metadata, f, indent=2) def create_compressed_backup(staging_dir, backup_path): """Create compressed tar.gz archive""" - with tarfile.open(backup_path, 'w:gz') as tar: - tar.add(staging_dir, arcname='.') + with tarfile.open(backup_path, "w:gz") as tar: + tar.add(staging_dir, arcname=".") def run_restore(restore_job_id, backup_file_path): @@ -176,46 +185,47 @@ def run_restore(restore_job_id, backup_file_path): """ try: restore_job = BackupJob.objects.get(id=restore_job_id) - restore_job.status = 'running' + restore_job.status = "running" restore_job.started_at = timezone.now() restore_job.save() - + # Extract backup 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) - + # Extract tar.gz - with tarfile.open(backup_file_path, 'r:gz') as tar: + with tarfile.open(backup_file_path, "r:gz") as tar: tar.extractall(extract_dir) - + # Validate backup - metadata_file = os.path.join(extract_dir, 'backup_metadata.json') + metadata_file = os.path.join(extract_dir, "backup_metadata.json") if not os.path.exists(metadata_file): raise Exception("Invalid backup: missing metadata") - + # Read metadata import json - with open(metadata_file, 'r') as f: + + with open(metadata_file, "r") as f: metadata = json.load(f) - + # 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): restore_database(db_backup_file) - + # Restore files - files_dir = os.path.join(extract_dir, 'files') + files_dir = os.path.join(extract_dir, "files") if os.path.exists(files_dir): restore_files(files_dir) - + # Update job status - restore_job.status = 'completed' + restore_job.status = "completed" restore_job.completed_at = timezone.now() restore_job.save() - + except Exception as e: - restore_job.status = 'failed' + restore_job.status = "failed" restore_job.error_message = str(e) restore_job.completed_at = timezone.now() restore_job.save() @@ -225,42 +235,47 @@ def restore_database(db_backup_file): """Restore database from backup""" try: # Get database settings - db_settings = settings.DATABASES['default'] - + db_settings = settings.DATABASES["default"] + # Build pg_restore command cmd = [ - 'pg_restore', - '--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 - '--single-transaction', # restore atomically when possible - '--disable-triggers', # avoid FK issues during data load - '--no-password', - '--verbose', - db_backup_file + "pg_restore", + "--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 + "--single-transaction", # restore atomically when possible + "--disable-triggers", # avoid FK issues during data load + "--no-password", + "--verbose", + db_backup_file, ] - + # Set environment variables for authentication env = os.environ.copy() - env['PGPASSWORD'] = db_settings.get('PASSWORD', '') - + env["PGPASSWORD"] = db_settings.get("PASSWORD", "") + # Run pg_restore result = subprocess.run(cmd, env=env, capture_output=True, text=True) - + # Fail if there are real errors if result.returncode != 0: - stderr = result.stderr or '' + stderr = result.stderr or "" # escalate only if we see ERROR - if 'ERROR' in stderr.upper(): + if "ERROR" in stderr.upper(): raise Exception(f"pg_restore failed: {stderr}") else: print(f"pg_restore completed with warnings: {stderr}") - + except Exception as e: raise Exception(f"Database restore failed: {e}") @@ -270,29 +285,31 @@ def restore_files(files_dir): try: # Restore paths restore_mappings = { - 'media': '/app/media', - 'static': '/app/static', - '.env': '/app/.env', + "media": "/app/media", + "static": "/app/static", + ".env": "/app/.env", } - + for source_name, dest_path in restore_mappings.items(): source_path = os.path.join(files_dir, source_name) - + if os.path.exists(source_path): # Backup existing files first if os.path.exists(dest_path): - backup_path = f"{dest_path}.backup.{datetime.now().strftime('%Y%m%d_%H%M%S')}" + backup_path = ( + f"{dest_path}.backup.{datetime.now().strftime('%Y%m%d_%H%M%S')}" + ) if os.path.isdir(dest_path): shutil.move(dest_path, backup_path) else: shutil.copy2(dest_path, backup_path) - + # Restore files if os.path.isdir(source_path): shutil.copytree(source_path, dest_path) else: shutil.copy2(source_path, dest_path) - + except Exception as e: raise Exception(f"Files restore failed: {e}") @@ -302,19 +319,19 @@ def cleanup_old_backups(keep_count=10): try: backup_dir = get_backup_directory() backup_files = [] - + for filename in os.listdir(backup_dir): - if filename.startswith('stiftung_backup_') and filename.endswith('.tar.gz'): + if filename.startswith("stiftung_backup_") and filename.endswith(".tar.gz"): filepath = os.path.join(backup_dir, filename) backup_files.append((filepath, os.path.getmtime(filepath))) - + # Sort by modification time (newest first) backup_files.sort(key=lambda x: x[1], reverse=True) - + # Remove old backups for filepath, _ in backup_files[keep_count:]: os.remove(filepath) print(f"Removed old backup: {os.path.basename(filepath)}") - + except Exception as e: print(f"Cleanup failed: {e}") diff --git a/app/stiftung/forms.py b/app/stiftung/forms.py index 7cdff86..9be429d 100644 --- a/app/stiftung/forms.py +++ b/app/stiftung/forms.py @@ -1,117 +1,143 @@ +import re + from django import forms from django.core.exceptions import ValidationError from django.utils import timezone -from .models import ( - Rentmeister, StiftungsKonto, Verwaltungskosten, Person, - Paechter, Destinataer, Land, DokumentLink, Foerderung, BankTransaction, - DestinataerUnterstuetzung, UnterstuetzungWiederkehrend, DestinataerNotiz, LandAbrechnung, -) -import re + +from .models import (BankTransaction, Destinataer, DestinataerNotiz, + DestinataerUnterstuetzung, DokumentLink, Foerderung, Land, + LandAbrechnung, Paechter, Person, Rentmeister, + StiftungsKonto, UnterstuetzungWiederkehrend, + Verwaltungskosten) class RentmeisterForm(forms.ModelForm): """Form für das Erstellen und Bearbeiten von Rentmeistern""" - + class Meta: model = Rentmeister fields = [ - 'anrede', 'vorname', 'nachname', 'titel', - 'email', 'telefon', 'mobil', - 'strasse', 'plz', 'ort', - 'iban', 'bic', 'bank_name', - 'seit_datum', 'bis_datum', 'aktiv', - 'monatliche_verguetung', 'km_pauschale', - 'notizen' + "anrede", + "vorname", + "nachname", + "titel", + "email", + "telefon", + "mobil", + "strasse", + "plz", + "ort", + "iban", + "bic", + "bank_name", + "seit_datum", + "bis_datum", + "aktiv", + "monatliche_verguetung", + "km_pauschale", + "notizen", ] - + widgets = { - 'anrede': forms.Select(attrs={'class': 'form-select'}), - 'vorname': forms.TextInput(attrs={'class': 'form-control'}), - 'nachname': forms.TextInput(attrs={'class': 'form-control'}), - 'titel': forms.TextInput(attrs={'class': 'form-control'}), - 'email': forms.EmailInput(attrs={'class': 'form-control'}), - 'telefon': forms.TextInput(attrs={'class': 'form-control'}), - 'mobil': forms.TextInput(attrs={'class': 'form-control'}), - 'strasse': forms.TextInput(attrs={'class': 'form-control'}), - 'plz': forms.TextInput(attrs={'class': 'form-control'}), - 'ort': forms.TextInput(attrs={'class': 'form-control'}), - 'iban': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'DE89370400440532013000'}), - 'bic': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'COBADEFFXXX'}), - 'bank_name': forms.TextInput(attrs={'class': 'form-control'}), - 'seit_datum': forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}), - 'bis_datum': forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}), - 'aktiv': forms.CheckboxInput(attrs={'class': 'form-check-input'}), - 'monatliche_verguetung': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}), - 'km_pauschale': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01', 'value': '0.30'}), - 'notizen': forms.Textarea(attrs={'class': 'form-control', 'rows': 4}), + "anrede": forms.Select(attrs={"class": "form-select"}), + "vorname": forms.TextInput(attrs={"class": "form-control"}), + "nachname": forms.TextInput(attrs={"class": "form-control"}), + "titel": forms.TextInput(attrs={"class": "form-control"}), + "email": forms.EmailInput(attrs={"class": "form-control"}), + "telefon": forms.TextInput(attrs={"class": "form-control"}), + "mobil": forms.TextInput(attrs={"class": "form-control"}), + "strasse": forms.TextInput(attrs={"class": "form-control"}), + "plz": forms.TextInput(attrs={"class": "form-control"}), + "ort": forms.TextInput(attrs={"class": "form-control"}), + "iban": forms.TextInput( + attrs={"class": "form-control", "placeholder": "DE89370400440532013000"} + ), + "bic": forms.TextInput( + attrs={"class": "form-control", "placeholder": "COBADEFFXXX"} + ), + "bank_name": forms.TextInput(attrs={"class": "form-control"}), + "seit_datum": forms.DateInput( + attrs={"class": "form-control", "type": "date"} + ), + "bis_datum": forms.DateInput( + attrs={"class": "form-control", "type": "date"} + ), + "aktiv": forms.CheckboxInput(attrs={"class": "form-check-input"}), + "monatliche_verguetung": forms.NumberInput( + attrs={"class": "form-control", "step": "0.01"} + ), + "km_pauschale": forms.NumberInput( + attrs={"class": "form-control", "step": "0.01", "value": "0.30"} + ), + "notizen": forms.Textarea(attrs={"class": "form-control", "rows": 4}), } - + labels = { - 'anrede': 'Anrede', - 'vorname': 'Vorname *', - 'nachname': 'Nachname *', - 'titel': 'Titel', - 'email': 'E-Mail', - 'telefon': 'Telefon', - 'mobil': 'Mobil', - 'strasse': 'Straße', - 'plz': 'PLZ', - 'ort': 'Ort', - 'iban': 'IBAN', - 'bic': 'BIC', - 'bank_name': 'Bank', - 'seit_datum': 'Rentmeister seit *', - 'bis_datum': 'Rentmeister bis', - 'aktiv': 'Aktiv', - 'monatliche_verguetung': 'Monatliche Vergütung (€)', - 'km_pauschale': 'Kilometerpauschale (€/km)', - 'notizen': 'Notizen', + "anrede": "Anrede", + "vorname": "Vorname *", + "nachname": "Nachname *", + "titel": "Titel", + "email": "E-Mail", + "telefon": "Telefon", + "mobil": "Mobil", + "strasse": "Straße", + "plz": "PLZ", + "ort": "Ort", + "iban": "IBAN", + "bic": "BIC", + "bank_name": "Bank", + "seit_datum": "Rentmeister seit *", + "bis_datum": "Rentmeister bis", + "aktiv": "Aktiv", + "monatliche_verguetung": "Monatliche Vergütung (€)", + "km_pauschale": "Kilometerpauschale (€/km)", + "notizen": "Notizen", } - + help_texts = { - 'iban': 'Internationale Bankkontonummer für Abrechnungen', - 'km_pauschale': 'Standard: 0,30 € pro Kilometer', - 'seit_datum': 'Datum des Amtsantritts als Rentmeister', - 'bis_datum': 'Leer lassen für aktive Rentmeister', + "iban": "Internationale Bankkontonummer für Abrechnungen", + "km_pauschale": "Standard: 0,30 € pro Kilometer", + "seit_datum": "Datum des Amtsantritts als Rentmeister", + "bis_datum": "Leer lassen für aktive Rentmeister", } def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) # Markiere Pflichtfelder - self.fields['vorname'].required = True - self.fields['nachname'].required = True - self.fields['seit_datum'].required = True + self.fields["vorname"].required = True + self.fields["nachname"].required = True + self.fields["seit_datum"].required = True def clean_iban(self): """Validierung der IBAN""" - iban = self.cleaned_data.get('iban') + iban = self.cleaned_data.get("iban") if iban: # Entferne Leerzeichen und konvertiere zu Großbuchstaben - iban = re.sub(r'\s+', '', iban.upper()) - + iban = re.sub(r"\s+", "", iban.upper()) + # Einfache IBAN-Längenvalidierung für deutsche IBANs - if iban.startswith('DE') and len(iban) != 22: - raise ValidationError('Deutsche IBANs müssen 22 Zeichen lang sein.') - + if iban.startswith("DE") and len(iban) != 22: + raise ValidationError("Deutsche IBANs müssen 22 Zeichen lang sein.") + # Speichere die bereinigte IBAN return iban return iban def clean_plz(self): """Validierung der PLZ""" - plz = self.cleaned_data.get('plz') - if plz and not re.match(r'^\d{5}$', plz): - raise ValidationError('PLZ muss aus 5 Ziffern bestehen.') + plz = self.cleaned_data.get("plz") + if plz and not re.match(r"^\d{5}$", plz): + raise ValidationError("PLZ muss aus 5 Ziffern bestehen.") return plz def clean(self): """Übergreifende Validierung""" from django.utils.dateparse import parse_date - + cleaned_data = super().clean() - seit_datum = cleaned_data.get('seit_datum') - bis_datum = cleaned_data.get('bis_datum') - + seit_datum = cleaned_data.get("seit_datum") + bis_datum = cleaned_data.get("bis_datum") + # Helper function to ensure we have date objects def ensure_date(date_value): if not date_value: @@ -119,595 +145,861 @@ class RentmeisterForm(forms.ModelForm): if isinstance(date_value, str): return parse_date(date_value) return date_value - + # Convert to date objects if they're strings seit_datum = ensure_date(seit_datum) bis_datum = ensure_date(bis_datum) - + # Prüfe Datum-Logik if seit_datum and bis_datum and bis_datum <= seit_datum: - raise ValidationError('Das End-Datum muss nach dem Start-Datum liegen.') - + raise ValidationError("Das End-Datum muss nach dem Start-Datum liegen.") + return cleaned_data class StiftungsKontoForm(forms.ModelForm): """Form für das Erstellen und Bearbeiten von Stiftungskonten""" - + class Meta: model = StiftungsKonto fields = [ - 'kontoname', 'bank_name', 'iban', 'bic', 'konto_typ', - 'saldo', 'saldo_datum', 'zinssatz', 'laufzeit_bis', - 'aktiv', 'notizen' + "kontoname", + "bank_name", + "iban", + "bic", + "konto_typ", + "saldo", + "saldo_datum", + "zinssatz", + "laufzeit_bis", + "aktiv", + "notizen", ] - + widgets = { - 'kontoname': forms.TextInput(attrs={'class': 'form-control'}), - 'bank_name': forms.TextInput(attrs={'class': 'form-control'}), - 'iban': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'DE89370400440532013000'}), - 'bic': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'COBADEFFXXX'}), - 'konto_typ': forms.Select(attrs={'class': 'form-select'}), - 'saldo': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}), - 'saldo_datum': forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}), - 'zinssatz': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}), - 'laufzeit_bis': forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}), - 'aktiv': forms.CheckboxInput(attrs={'class': 'form-check-input'}), - 'notizen': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}), + "kontoname": forms.TextInput(attrs={"class": "form-control"}), + "bank_name": forms.TextInput(attrs={"class": "form-control"}), + "iban": forms.TextInput( + attrs={"class": "form-control", "placeholder": "DE89370400440532013000"} + ), + "bic": forms.TextInput( + attrs={"class": "form-control", "placeholder": "COBADEFFXXX"} + ), + "konto_typ": forms.Select(attrs={"class": "form-select"}), + "saldo": forms.NumberInput(attrs={"class": "form-control", "step": "0.01"}), + "saldo_datum": forms.DateInput( + attrs={"class": "form-control", "type": "date"} + ), + "zinssatz": forms.NumberInput( + attrs={"class": "form-control", "step": "0.01"} + ), + "laufzeit_bis": forms.DateInput( + attrs={"class": "form-control", "type": "date"} + ), + "aktiv": forms.CheckboxInput(attrs={"class": "form-check-input"}), + "notizen": forms.Textarea(attrs={"class": "form-control", "rows": 3}), } class VerwaltungskostenForm(forms.ModelForm): """Form für das Erstellen und Bearbeiten von Verwaltungskosten""" - + class Meta: model = Verwaltungskosten fields = [ - 'bezeichnung', 'kategorie', 'betrag', 'datum', 'status', - 'rentmeister', 'zahlungskonto', 'quellkonto', 'lieferant_firma', 'rechnungsnummer', - 'km_anzahl', 'km_satz', 'von_ort', 'nach_ort', 'zweck', - 'beschreibung', 'notizen' + "bezeichnung", + "kategorie", + "betrag", + "datum", + "status", + "rentmeister", + "zahlungskonto", + "quellkonto", + "lieferant_firma", + "rechnungsnummer", + "km_anzahl", + "km_satz", + "von_ort", + "nach_ort", + "zweck", + "beschreibung", + "notizen", ] - + widgets = { - 'bezeichnung': forms.TextInput(attrs={'class': 'form-control'}), - 'kategorie': forms.Select(attrs={'class': 'form-select'}), - 'betrag': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}), - 'datum': forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}), - 'status': forms.Select(attrs={'class': 'form-select'}), - 'rentmeister': forms.Select(attrs={'class': 'form-select'}), - 'zahlungskonto': forms.Select(attrs={'class': 'form-select'}), - 'quellkonto': forms.Select(attrs={'class': 'form-select'}), - 'lieferant_firma': forms.TextInput(attrs={'class': 'form-control'}), - 'rechnungsnummer': forms.TextInput(attrs={'class': 'form-control'}), - 'km_anzahl': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.1'}), - 'km_satz': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}), - 'von_ort': forms.TextInput(attrs={'class': 'form-control'}), - 'nach_ort': forms.TextInput(attrs={'class': 'form-control'}), - 'zweck': forms.TextInput(attrs={'class': 'form-control'}), - 'beschreibung': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}), - 'notizen': forms.Textarea(attrs={'class': 'form-control', 'rows': 2}), + "bezeichnung": forms.TextInput(attrs={"class": "form-control"}), + "kategorie": forms.Select(attrs={"class": "form-select"}), + "betrag": forms.NumberInput( + attrs={"class": "form-control", "step": "0.01"} + ), + "datum": forms.DateInput(attrs={"class": "form-control", "type": "date"}), + "status": forms.Select(attrs={"class": "form-select"}), + "rentmeister": forms.Select(attrs={"class": "form-select"}), + "zahlungskonto": forms.Select(attrs={"class": "form-select"}), + "quellkonto": forms.Select(attrs={"class": "form-select"}), + "lieferant_firma": forms.TextInput(attrs={"class": "form-control"}), + "rechnungsnummer": forms.TextInput(attrs={"class": "form-control"}), + "km_anzahl": forms.NumberInput( + attrs={"class": "form-control", "step": "0.1"} + ), + "km_satz": forms.NumberInput( + attrs={"class": "form-control", "step": "0.01"} + ), + "von_ort": forms.TextInput(attrs={"class": "form-control"}), + "nach_ort": forms.TextInput(attrs={"class": "form-control"}), + "zweck": forms.TextInput(attrs={"class": "form-control"}), + "beschreibung": forms.Textarea(attrs={"class": "form-control", "rows": 3}), + "notizen": forms.Textarea(attrs={"class": "form-control", "rows": 2}), } def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) # Filtere nur aktive Rentmeister und Konten - self.fields['rentmeister'].queryset = Rentmeister.objects.filter(aktiv=True) - self.fields['zahlungskonto'].queryset = StiftungsKonto.objects.filter(aktiv=True) - self.fields['quellkonto'].queryset = StiftungsKonto.objects.filter(aktiv=True) - + self.fields["rentmeister"].queryset = Rentmeister.objects.filter(aktiv=True) + self.fields["zahlungskonto"].queryset = StiftungsKonto.objects.filter( + aktiv=True + ) + self.fields["quellkonto"].queryset = StiftungsKonto.objects.filter(aktiv=True) + # Standardwerte setzen if not self.instance.pk: # Nur bei neuen Objekten # Standard km_satz auf 0.30 Euro setzen - self.fields['km_satz'].initial = 0.30 + self.fields["km_satz"].initial = 0.30 class PersonForm(forms.ModelForm): """Form für das Erstellen und Bearbeiten von Personen (Legacy)""" - + class Meta: model = Person fields = [ - 'familienzweig', 'vorname', 'nachname', 'geburtsdatum', - 'email', 'telefon', 'iban', 'adresse', 'notizen', 'aktiv' + "familienzweig", + "vorname", + "nachname", + "geburtsdatum", + "email", + "telefon", + "iban", + "adresse", + "notizen", + "aktiv", ] - + widgets = { - 'familienzweig': forms.Select(attrs={'class': 'form-select'}), - 'vorname': forms.TextInput(attrs={'class': 'form-control'}), - 'nachname': forms.TextInput(attrs={'class': 'form-control'}), - 'geburtsdatum': forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}), - 'email': forms.EmailInput(attrs={'class': 'form-control'}), - 'telefon': forms.TextInput(attrs={'class': 'form-control'}), - 'iban': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'DE89370400440532013000'}), - 'adresse': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}), - 'notizen': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}), - 'aktiv': forms.CheckboxInput(attrs={'class': 'form-check-input'}), + "familienzweig": forms.Select(attrs={"class": "form-select"}), + "vorname": forms.TextInput(attrs={"class": "form-control"}), + "nachname": forms.TextInput(attrs={"class": "form-control"}), + "geburtsdatum": forms.DateInput( + attrs={"class": "form-control", "type": "date"} + ), + "email": forms.EmailInput(attrs={"class": "form-control"}), + "telefon": forms.TextInput(attrs={"class": "form-control"}), + "iban": forms.TextInput( + attrs={"class": "form-control", "placeholder": "DE89370400440532013000"} + ), + "adresse": forms.Textarea(attrs={"class": "form-control", "rows": 3}), + "notizen": forms.Textarea(attrs={"class": "form-control", "rows": 3}), + "aktiv": forms.CheckboxInput(attrs={"class": "form-check-input"}), } - + labels = { - 'familienzweig': 'Familienzweig', - 'vorname': 'Vorname *', - 'nachname': 'Nachname *', - 'geburtsdatum': 'Geburtsdatum', - 'email': 'E-Mail', - 'telefon': 'Telefon', - 'iban': 'IBAN', - 'adresse': 'Adresse', - 'notizen': 'Notizen', - 'aktiv': 'Aktiv', + "familienzweig": "Familienzweig", + "vorname": "Vorname *", + "nachname": "Nachname *", + "geburtsdatum": "Geburtsdatum", + "email": "E-Mail", + "telefon": "Telefon", + "iban": "IBAN", + "adresse": "Adresse", + "notizen": "Notizen", + "aktiv": "Aktiv", } def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) # Markiere Pflichtfelder - self.fields['vorname'].required = True - self.fields['nachname'].required = True + self.fields["vorname"].required = True + self.fields["nachname"].required = True class PaechterForm(forms.ModelForm): """Form für das Erstellen und Bearbeiten von Pächtern""" - + class Meta: model = Paechter - fields = '__all__' + fields = "__all__" widgets = { - 'anrede': forms.Select(attrs={'class': 'form-select'}), - 'vorname': forms.TextInput(attrs={'class': 'form-control'}), - 'nachname': forms.TextInput(attrs={'class': 'form-control'}), - 'email': forms.EmailInput(attrs={'class': 'form-control'}), - 'telefon': forms.TextInput(attrs={'class': 'form-control'}), - 'mobil': forms.TextInput(attrs={'class': 'form-control'}), - 'geburtsdatum': forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}), - 'strasse': forms.TextInput(attrs={'class': 'form-control'}), - 'plz': forms.TextInput(attrs={'class': 'form-control'}), - 'ort': forms.TextInput(attrs={'class': 'form-control'}), - 'aktiv': forms.CheckboxInput(attrs={'class': 'form-check-input'}), - 'notizen': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}), + "anrede": forms.Select(attrs={"class": "form-select"}), + "vorname": forms.TextInput(attrs={"class": "form-control"}), + "nachname": forms.TextInput(attrs={"class": "form-control"}), + "email": forms.EmailInput(attrs={"class": "form-control"}), + "telefon": forms.TextInput(attrs={"class": "form-control"}), + "mobil": forms.TextInput(attrs={"class": "form-control"}), + "geburtsdatum": forms.DateInput( + attrs={"class": "form-control", "type": "date"} + ), + "strasse": forms.TextInput(attrs={"class": "form-control"}), + "plz": forms.TextInput(attrs={"class": "form-control"}), + "ort": forms.TextInput(attrs={"class": "form-control"}), + "aktiv": forms.CheckboxInput(attrs={"class": "form-check-input"}), + "notizen": forms.Textarea(attrs={"class": "form-control", "rows": 3}), } class DestinataerForm(forms.ModelForm): """Form für das Erstellen und Bearbeiten von Destinatären""" - + class Meta: model = Destinataer - fields = '__all__' + fields = "__all__" widgets = { - 'anrede': forms.Select(attrs={'class': 'form-select'}), - 'vorname': forms.TextInput(attrs={'class': 'form-control'}), - 'nachname': forms.TextInput(attrs={'class': 'form-control'}), - 'titel': forms.TextInput(attrs={'class': 'form-control'}), - 'strasse': forms.TextInput(attrs={'class': 'form-control'}), - 'plz': forms.TextInput(attrs={'class': 'form-control'}), - 'ort': forms.TextInput(attrs={'class': 'form-control'}), - 'telefon': forms.TextInput(attrs={'class': 'form-control'}), - 'mobil': forms.TextInput(attrs={'class': 'form-control'}), - 'email': forms.EmailInput(attrs={'class': 'form-control'}), - 'aktiv': forms.CheckboxInput(attrs={'class': 'form-check-input'}), - 'notizen': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}), - 'ist_abkoemmling': forms.CheckboxInput(attrs={'class': 'form-check-input'}), - 'haushaltsgroesse': forms.NumberInput(attrs={'class': 'form-control', 'min': 1}), + "anrede": forms.Select(attrs={"class": "form-select"}), + "vorname": forms.TextInput(attrs={"class": "form-control"}), + "nachname": forms.TextInput(attrs={"class": "form-control"}), + "titel": forms.TextInput(attrs={"class": "form-control"}), + "strasse": forms.TextInput(attrs={"class": "form-control"}), + "plz": forms.TextInput(attrs={"class": "form-control"}), + "ort": forms.TextInput(attrs={"class": "form-control"}), + "telefon": forms.TextInput(attrs={"class": "form-control"}), + "mobil": forms.TextInput(attrs={"class": "form-control"}), + "email": forms.EmailInput(attrs={"class": "form-control"}), + "aktiv": forms.CheckboxInput(attrs={"class": "form-check-input"}), + "notizen": forms.Textarea(attrs={"class": "form-control", "rows": 3}), + "ist_abkoemmling": forms.CheckboxInput(attrs={"class": "form-check-input"}), + "haushaltsgroesse": forms.NumberInput( + attrs={"class": "form-control", "min": 1} + ), # renamed in UI: use vierteljaehrlicher_betrag field - 'vermoegen': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}), - 'unterstuetzung_bestaetigt': forms.CheckboxInput(attrs={'class': 'form-check-input'}), - 'standard_konto': forms.Select(attrs={'class': 'form-select'}), - 'vierteljaehrlicher_betrag': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}), - 'studiennachweis_erforderlich': forms.CheckboxInput(attrs={'class': 'form-check-input'}), - 'letzter_studiennachweis': forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}), + "vermoegen": forms.NumberInput( + attrs={"class": "form-control", "step": "0.01"} + ), + "unterstuetzung_bestaetigt": forms.CheckboxInput( + attrs={"class": "form-check-input"} + ), + "standard_konto": forms.Select(attrs={"class": "form-select"}), + "vierteljaehrlicher_betrag": forms.NumberInput( + attrs={"class": "form-control", "step": "0.01"} + ), + "studiennachweis_erforderlich": forms.CheckboxInput( + attrs={"class": "form-check-input"} + ), + "letzter_studiennachweis": forms.DateInput( + attrs={"class": "form-control", "type": "date"} + ), } class LandForm(forms.ModelForm): """Form für das Erstellen und Bearbeiten von Ländern""" - + class Meta: model = Land fields = [ # Grundlegende Identifikation - 'lfd_nr', 'ew_nummer', 'grundbuchblatt', + "lfd_nr", + "ew_nummer", + "grundbuchblatt", # Gerichtliche Zuständigkeit - 'amtsgericht', - # Verwaltungsstruktur - 'gemeinde', 'gemarkung', 'flur', 'flurstueck', 'adresse', + "amtsgericht", + # Verwaltungsstruktur + "gemeinde", + "gemarkung", + "flur", + "flurstueck", + "adresse", # Flächenangaben - 'groesse_qm', 'gruenland_qm', 'acker_qm', 'wald_qm', 'sonstiges_qm', + "groesse_qm", + "gruenland_qm", + "acker_qm", + "wald_qm", + "sonstiges_qm", # Legacy Verpachtung (für Kompatibilität) - 'verpachtete_gesamtflaeche', 'flaeche_alte_liste', 'verp_flaeche_aktuell', + "verpachtete_gesamtflaeche", + "flaeche_alte_liste", + "verp_flaeche_aktuell", # Aktuelle Verpachtung - 'aktueller_paechter', 'paechter_name', 'paechter_anschrift', - 'pachtbeginn', 'pachtende', 'verlaengerung_klausel', - 'zahlungsweise', 'pachtzins_pro_ha', 'pachtzins_pauschal', + "aktueller_paechter", + "paechter_name", + "paechter_anschrift", + "pachtbeginn", + "pachtende", + "verlaengerung_klausel", + "zahlungsweise", + "pachtzins_pro_ha", + "pachtzins_pauschal", # Umsatzsteuer - 'ust_option', 'ust_satz', + "ust_option", + "ust_satz", # Umlagen - 'grundsteuer_umlage', 'versicherungen_umlage', 'verbandsbeitraege_umlage', 'jagdpacht_anteil_umlage', + "grundsteuer_umlage", + "versicherungen_umlage", + "verbandsbeitraege_umlage", + "jagdpacht_anteil_umlage", # Legacy Steuern - 'anteil_grundsteuer', 'anteil_lwk', + "anteil_grundsteuer", + "anteil_lwk", # Status - 'aktiv', 'notizen', + "aktiv", + "notizen", ] widgets = { # Grundlegende Identifikation - 'lfd_nr': forms.TextInput(attrs={'class': 'form-control'}), - 'ew_nummer': forms.TextInput(attrs={'class': 'form-control'}), - 'grundbuchblatt': forms.TextInput(attrs={'class': 'form-control'}), + "lfd_nr": forms.TextInput(attrs={"class": "form-control"}), + "ew_nummer": forms.TextInput(attrs={"class": "form-control"}), + "grundbuchblatt": forms.TextInput(attrs={"class": "form-control"}), # Gerichtliche Zuständigkeit - 'amtsgericht': forms.TextInput(attrs={'class': 'form-control'}), + "amtsgericht": forms.TextInput(attrs={"class": "form-control"}), # Verwaltungsstruktur - 'gemeinde': forms.TextInput(attrs={'class': 'form-control'}), - 'gemarkung': forms.TextInput(attrs={'class': 'form-control'}), - 'flur': forms.TextInput(attrs={'class': 'form-control'}), - 'flurstueck': forms.TextInput(attrs={'class': 'form-control'}), - 'adresse': forms.TextInput(attrs={'class': 'form-control'}), + "gemeinde": forms.TextInput(attrs={"class": "form-control"}), + "gemarkung": forms.TextInput(attrs={"class": "form-control"}), + "flur": forms.TextInput(attrs={"class": "form-control"}), + "flurstueck": forms.TextInput(attrs={"class": "form-control"}), + "adresse": forms.TextInput(attrs={"class": "form-control"}), # Flächenangaben - 'groesse_qm': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}), - 'gruenland_qm': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}), - 'acker_qm': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}), - 'wald_qm': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}), - 'sonstiges_qm': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}), + "groesse_qm": forms.NumberInput( + attrs={"class": "form-control", "step": "0.01"} + ), + "gruenland_qm": forms.NumberInput( + attrs={"class": "form-control", "step": "0.01"} + ), + "acker_qm": forms.NumberInput( + attrs={"class": "form-control", "step": "0.01"} + ), + "wald_qm": forms.NumberInput( + attrs={"class": "form-control", "step": "0.01"} + ), + "sonstiges_qm": forms.NumberInput( + attrs={"class": "form-control", "step": "0.01"} + ), # Legacy Verpachtung - 'verpachtete_gesamtflaeche': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}), - 'flaeche_alte_liste': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}), - 'verp_flaeche_aktuell': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}), + "verpachtete_gesamtflaeche": forms.NumberInput( + attrs={"class": "form-control", "step": "0.01"} + ), + "flaeche_alte_liste": forms.NumberInput( + attrs={"class": "form-control", "step": "0.01"} + ), + "verp_flaeche_aktuell": forms.NumberInput( + attrs={"class": "form-control", "step": "0.01"} + ), # Aktuelle Verpachtung - 'aktueller_paechter': forms.Select(attrs={'class': 'form-select'}), - 'paechter_name': forms.TextInput(attrs={'class': 'form-control'}), - 'paechter_anschrift': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}), - 'pachtbeginn': forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}), - 'pachtende': forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}), - 'verlaengerung_klausel': forms.CheckboxInput(attrs={'class': 'form-check-input'}), - 'zahlungsweise': forms.Select(attrs={'class': 'form-select'}), - 'pachtzins_pro_ha': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}), - 'pachtzins_pauschal': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}), + "aktueller_paechter": forms.Select(attrs={"class": "form-select"}), + "paechter_name": forms.TextInput(attrs={"class": "form-control"}), + "paechter_anschrift": forms.Textarea( + attrs={"class": "form-control", "rows": 3} + ), + "pachtbeginn": forms.DateInput( + attrs={"class": "form-control", "type": "date"} + ), + "pachtende": forms.DateInput( + attrs={"class": "form-control", "type": "date"} + ), + "verlaengerung_klausel": forms.CheckboxInput( + attrs={"class": "form-check-input"} + ), + "zahlungsweise": forms.Select(attrs={"class": "form-select"}), + "pachtzins_pro_ha": forms.NumberInput( + attrs={"class": "form-control", "step": "0.01"} + ), + "pachtzins_pauschal": forms.NumberInput( + attrs={"class": "form-control", "step": "0.01"} + ), # Umsatzsteuer - 'ust_option': forms.CheckboxInput(attrs={'class': 'form-check-input'}), - 'ust_satz': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}), + "ust_option": forms.CheckboxInput(attrs={"class": "form-check-input"}), + "ust_satz": forms.NumberInput( + attrs={"class": "form-control", "step": "0.01"} + ), # Umlagen - 'grundsteuer_umlage': forms.CheckboxInput(attrs={'class': 'form-check-input'}), - 'versicherungen_umlage': forms.CheckboxInput(attrs={'class': 'form-check-input'}), - 'verbandsbeitraege_umlage': forms.CheckboxInput(attrs={'class': 'form-check-input'}), - 'jagdpacht_anteil_umlage': forms.CheckboxInput(attrs={'class': 'form-check-input'}), + "grundsteuer_umlage": forms.CheckboxInput( + attrs={"class": "form-check-input"} + ), + "versicherungen_umlage": forms.CheckboxInput( + attrs={"class": "form-check-input"} + ), + "verbandsbeitraege_umlage": forms.CheckboxInput( + attrs={"class": "form-check-input"} + ), + "jagdpacht_anteil_umlage": forms.CheckboxInput( + attrs={"class": "form-check-input"} + ), # Legacy - 'anteil_grundsteuer': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}), - 'anteil_lwk': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}), + "anteil_grundsteuer": forms.NumberInput( + attrs={"class": "form-control", "step": "0.01"} + ), + "anteil_lwk": forms.NumberInput( + attrs={"class": "form-control", "step": "0.01"} + ), # Status - 'aktiv': forms.CheckboxInput(attrs={'class': 'form-check-input'}), - 'notizen': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}), + "aktiv": forms.CheckboxInput(attrs={"class": "form-check-input"}), + "notizen": forms.Textarea(attrs={"class": "form-control", "rows": 3}), } class LandAbrechnungForm(forms.ModelForm): """Form für das Erstellen und Bearbeiten von Landabrechnungen""" - + class Meta: model = LandAbrechnung fields = [ - 'land', 'abrechnungsjahr', + "land", + "abrechnungsjahr", # Einnahmen - 'pacht_vereinnahmt', 'umlagen_vereinnahmt', 'sonstige_einnahmen', + "pacht_vereinnahmt", + "umlagen_vereinnahmt", + "sonstige_einnahmen", # Ausgaben - 'grundsteuer_bescheid_nr', 'grundsteuer_betrag', - 'versicherungen_betrag', 'verbandsbeitraege_betrag', - 'sonstige_abgaben_betrag', 'instandhaltung_betrag', 'verwaltung_recht_betrag', + "grundsteuer_bescheid_nr", + "grundsteuer_betrag", + "versicherungen_betrag", + "verbandsbeitraege_betrag", + "sonstige_abgaben_betrag", + "instandhaltung_betrag", + "verwaltung_recht_betrag", # Umsatzsteuer - 'vorsteuer_aus_umlagen', + "vorsteuer_aus_umlagen", # Sonstiges - 'offene_posten', 'bemerkungen', + "offene_posten", + "bemerkungen", # Dokumente werden über Paperless verknüpft, nicht hochgeladen ] widgets = { - 'land': forms.Select(attrs={'class': 'form-select'}), - 'abrechnungsjahr': forms.NumberInput(attrs={'class': 'form-control', 'min': '2000', 'max': '2050'}), + "land": forms.Select(attrs={"class": "form-select"}), + "abrechnungsjahr": forms.NumberInput( + attrs={"class": "form-control", "min": "2000", "max": "2050"} + ), # Einnahmen - 'pacht_vereinnahmt': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}), - 'umlagen_vereinnahmt': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}), - 'sonstige_einnahmen': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}), + "pacht_vereinnahmt": forms.NumberInput( + attrs={"class": "form-control", "step": "0.01"} + ), + "umlagen_vereinnahmt": forms.NumberInput( + attrs={"class": "form-control", "step": "0.01"} + ), + "sonstige_einnahmen": forms.NumberInput( + attrs={"class": "form-control", "step": "0.01"} + ), # Ausgaben - 'grundsteuer_bescheid_nr': forms.TextInput(attrs={'class': 'form-control'}), - 'grundsteuer_betrag': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}), - 'versicherungen_betrag': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}), - 'verbandsbeitraege_betrag': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}), - 'sonstige_abgaben_betrag': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}), - 'instandhaltung_betrag': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}), - 'verwaltung_recht_betrag': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}), + "grundsteuer_bescheid_nr": forms.TextInput(attrs={"class": "form-control"}), + "grundsteuer_betrag": forms.NumberInput( + attrs={"class": "form-control", "step": "0.01"} + ), + "versicherungen_betrag": forms.NumberInput( + attrs={"class": "form-control", "step": "0.01"} + ), + "verbandsbeitraege_betrag": forms.NumberInput( + attrs={"class": "form-control", "step": "0.01"} + ), + "sonstige_abgaben_betrag": forms.NumberInput( + attrs={"class": "form-control", "step": "0.01"} + ), + "instandhaltung_betrag": forms.NumberInput( + attrs={"class": "form-control", "step": "0.01"} + ), + "verwaltung_recht_betrag": forms.NumberInput( + attrs={"class": "form-control", "step": "0.01"} + ), # Umsatzsteuer - 'vorsteuer_aus_umlagen': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}), + "vorsteuer_aus_umlagen": forms.NumberInput( + attrs={"class": "form-control", "step": "0.01"} + ), # Sonstiges - 'offene_posten': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}), - 'bemerkungen': forms.Textarea(attrs={'class': 'form-control', 'rows': 4}), + "offene_posten": forms.NumberInput( + attrs={"class": "form-control", "step": "0.01"} + ), + "bemerkungen": forms.Textarea(attrs={"class": "form-control", "rows": 4}), } class DokumentLinkForm(forms.ModelForm): """Form für das Erstellen und Bearbeiten von Dokumentverknüpfungen""" - + class Meta: model = DokumentLink - fields = '__all__' + fields = "__all__" widgets = { - 'paperless_id': forms.NumberInput(attrs={'class': 'form-control'}), - 'content_type': forms.Select(attrs={'class': 'form-select'}), - 'object_id': forms.TextInput(attrs={'class': 'form-control'}), - 'verknuepft_am': forms.DateTimeInput(attrs={'class': 'form-control', 'type': 'datetime-local'}), + "paperless_id": forms.NumberInput(attrs={"class": "form-control"}), + "content_type": forms.Select(attrs={"class": "form-select"}), + "object_id": forms.TextInput(attrs={"class": "form-control"}), + "verknuepft_am": forms.DateTimeInput( + attrs={"class": "form-control", "type": "datetime-local"} + ), } class FoerderungForm(forms.ModelForm): """Form für das Erstellen und Bearbeiten von Förderungen""" - + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) # Add empty option for optional fields - self.fields['verwendungsnachweis'].empty_label = "--- Kein Dokument verknüpfen ---" + self.fields["verwendungsnachweis"].empty_label = ( + "--- Kein Dokument verknüpfen ---" + ) # Ensure destinataer has proper choices - from .models import Destinataer, DokumentLink from django.utils import timezone - self.fields['destinataer'].queryset = Destinataer.objects.all().order_by('nachname', 'vorname') - self.fields['verwendungsnachweis'].queryset = DokumentLink.objects.all().order_by('titel') + + from .models import Destinataer, DokumentLink + + self.fields["destinataer"].queryset = Destinataer.objects.all().order_by( + "nachname", "vorname" + ) + self.fields["verwendungsnachweis"].queryset = ( + DokumentLink.objects.all().order_by("titel") + ) # Set current year as default for new forms if not self.instance.pk: - self.fields['jahr'].initial = timezone.now().year - + self.fields["jahr"].initial = timezone.now().year + class Meta: model = Foerderung fields = [ - 'destinataer', 'jahr', 'betrag', 'kategorie', 'status', - 'antragsdatum', 'entscheidungsdatum', 'verwendungsnachweis', 'bemerkungen' + "destinataer", + "jahr", + "betrag", + "kategorie", + "status", + "antragsdatum", + "entscheidungsdatum", + "verwendungsnachweis", + "bemerkungen", ] widgets = { - 'destinataer': forms.Select(attrs={'class': 'form-select'}), - 'jahr': forms.NumberInput(attrs={'class': 'form-control'}), - 'betrag': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}), - 'kategorie': forms.Select(attrs={'class': 'form-select'}), - 'status': forms.Select(attrs={'class': 'form-select'}), - 'antragsdatum': forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}), - 'entscheidungsdatum': forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}), - 'verwendungsnachweis': forms.Select(attrs={'class': 'form-select'}), - 'bemerkungen': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}), + "destinataer": forms.Select(attrs={"class": "form-select"}), + "jahr": forms.NumberInput(attrs={"class": "form-control"}), + "betrag": forms.NumberInput( + attrs={"class": "form-control", "step": "0.01"} + ), + "kategorie": forms.Select(attrs={"class": "form-select"}), + "status": forms.Select(attrs={"class": "form-select"}), + "antragsdatum": forms.DateInput( + attrs={"class": "form-control", "type": "date"} + ), + "entscheidungsdatum": forms.DateInput( + attrs={"class": "form-control", "type": "date"} + ), + "verwendungsnachweis": forms.Select(attrs={"class": "form-select"}), + "bemerkungen": forms.Textarea(attrs={"class": "form-control", "rows": 3}), } - + labels = { - 'destinataer': 'Destinatär', - 'verwendungsnachweis': 'Verknüpftes Dokument', - 'bemerkungen': 'Bemerkungen/Beschreibung', - 'antragsdatum': 'Antragsdatum', - 'entscheidungsdatum': 'Entscheidungsdatum', + "destinataer": "Destinatär", + "verwendungsnachweis": "Verknüpftes Dokument", + "bemerkungen": "Bemerkungen/Beschreibung", + "antragsdatum": "Antragsdatum", + "entscheidungsdatum": "Entscheidungsdatum", } - + help_texts = { - 'verwendungsnachweis': 'Optionale Verknüpfung zu einem Dokument aus dem Paperless-System', - 'entscheidungsdatum': 'Datum der Bewilligung/Ablehnung (optional)', - 'bemerkungen': 'Zusätzliche Informationen zur Förderung', + "verwendungsnachweis": "Optionale Verknüpfung zu einem Dokument aus dem Paperless-System", + "entscheidungsdatum": "Datum der Bewilligung/Ablehnung (optional)", + "bemerkungen": "Zusätzliche Informationen zur Förderung", } class UnterstuetzungForm(forms.ModelForm): """Form für das Erstellen und Bearbeiten von Unterstützungen""" - + # Special field for creating recurring payments ist_wiederkehrend = forms.BooleanField( - required=False, - label='Wiederkehrende Zahlung', - help_text='Aktivieren Sie diese Option um automatisch wiederkehrende Zahlungen zu erstellen' + required=False, + label="Wiederkehrende Zahlung", + help_text="Aktivieren Sie diese Option um automatisch wiederkehrende Zahlungen zu erstellen", ) intervall = forms.ChoiceField( - choices=[('', '--- Wählen Sie ein Intervall ---')] + UnterstuetzungWiederkehrend.INTERVALL_CHOICES, + choices=[("", "--- Wählen Sie ein Intervall ---")] + + UnterstuetzungWiederkehrend.INTERVALL_CHOICES, required=False, - widget=forms.Select(attrs={'class': 'form-select'}), - label='Zahlungsintervall' + widget=forms.Select(attrs={"class": "form-select"}), + label="Zahlungsintervall", ) letzte_zahlung_am = forms.DateField( required=False, - widget=forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}), - label='Letzte Zahlung am (optional)', - help_text='Leer lassen für unbegrenzte Wiederholung' + widget=forms.DateInput(attrs={"class": "form-control", "type": "date"}), + label="Letzte Zahlung am (optional)", + help_text="Leer lassen für unbegrenzte Wiederholung", ) - + class Meta: model = DestinataerUnterstuetzung fields = [ - 'destinataer', 'konto', 'faellig_am', 'betrag', 'status', - 'beschreibung', 'empfaenger_iban', 'empfaenger_name', 'verwendungszweck' + "destinataer", + "konto", + "faellig_am", + "betrag", + "status", + "beschreibung", + "empfaenger_iban", + "empfaenger_name", + "verwendungszweck", ] - + widgets = { - 'destinataer': forms.Select(attrs={'class': 'form-select'}), - 'konto': forms.Select(attrs={'class': 'form-select'}), - 'faellig_am': forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}), - 'betrag': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}), - 'status': forms.Select(attrs={'class': 'form-select'}), - 'beschreibung': forms.TextInput(attrs={'class': 'form-control'}), - 'empfaenger_iban': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'DE89 3704 0044 0532 0130 00'}), - 'empfaenger_name': forms.TextInput(attrs={'class': 'form-control'}), - 'verwendungszweck': forms.TextInput(attrs={'class': 'form-control', 'maxlength': '140'}), + "destinataer": forms.Select(attrs={"class": "form-select"}), + "konto": forms.Select(attrs={"class": "form-select"}), + "faellig_am": forms.DateInput( + attrs={"class": "form-control", "type": "date"} + ), + "betrag": forms.NumberInput( + attrs={"class": "form-control", "step": "0.01"} + ), + "status": forms.Select(attrs={"class": "form-select"}), + "beschreibung": forms.TextInput(attrs={"class": "form-control"}), + "empfaenger_iban": forms.TextInput( + attrs={ + "class": "form-control", + "placeholder": "DE89 3704 0044 0532 0130 00", + } + ), + "empfaenger_name": forms.TextInput(attrs={"class": "form-control"}), + "verwendungszweck": forms.TextInput( + attrs={"class": "form-control", "maxlength": "140"} + ), } - + labels = { - 'destinataer': 'Destinatär', - 'konto': 'Zahlungskonto', - 'faellig_am': 'Fällig am', - 'betrag': 'Betrag (€)', - 'status': 'Status', - 'beschreibung': 'Beschreibung', - 'empfaenger_iban': 'Empfänger IBAN', - 'empfaenger_name': 'Empfänger Name', - 'verwendungszweck': 'Verwendungszweck', + "destinataer": "Destinatär", + "konto": "Zahlungskonto", + "faellig_am": "Fällig am", + "betrag": "Betrag (€)", + "status": "Status", + "beschreibung": "Beschreibung", + "empfaenger_iban": "Empfänger IBAN", + "empfaenger_name": "Empfänger Name", + "verwendungszweck": "Verwendungszweck", } - + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) # Add onchange event to destinataer field for AJAX IBAN fetching - self.fields['destinataer'].widget.attrs['onchange'] = 'updateDestinataerInfo()' - + self.fields["destinataer"].widget.attrs["onchange"] = "updateDestinataerInfo()" + def clean(self): cleaned_data = super().clean() - ist_wiederkehrend = cleaned_data.get('ist_wiederkehrend') - intervall = cleaned_data.get('intervall') - + ist_wiederkehrend = cleaned_data.get("ist_wiederkehrend") + intervall = cleaned_data.get("intervall") + if ist_wiederkehrend and not intervall: - raise forms.ValidationError('Bitte wählen Sie ein Zahlungsintervall für wiederkehrende Zahlungen.') - + raise forms.ValidationError( + "Bitte wählen Sie ein Zahlungsintervall für wiederkehrende Zahlungen." + ) + return cleaned_data class UnterstuetzungWiederkehrendForm(forms.ModelForm): """Form für das Bearbeiten von wiederkehrenden Unterstützungsvorlagen""" - + class Meta: model = UnterstuetzungWiederkehrend fields = [ - 'destinataer', 'konto', 'betrag', 'intervall', 'beschreibung', - 'empfaenger_iban', 'empfaenger_name', 'verwendungszweck', - 'erste_zahlung_am', 'letzte_zahlung_am', 'aktiv' + "destinataer", + "konto", + "betrag", + "intervall", + "beschreibung", + "empfaenger_iban", + "empfaenger_name", + "verwendungszweck", + "erste_zahlung_am", + "letzte_zahlung_am", + "aktiv", ] - + widgets = { - 'destinataer': forms.Select(attrs={'class': 'form-select'}), - 'konto': forms.Select(attrs={'class': 'form-select'}), - 'betrag': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}), - 'intervall': forms.Select(attrs={'class': 'form-select'}), - 'beschreibung': forms.TextInput(attrs={'class': 'form-control'}), - 'empfaenger_iban': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'DE89 3704 0044 0532 0130 00'}), - 'empfaenger_name': forms.TextInput(attrs={'class': 'form-control'}), - 'verwendungszweck': forms.TextInput(attrs={'class': 'form-control', 'maxlength': '140'}), - 'erste_zahlung_am': forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}), - 'letzte_zahlung_am': forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}), - 'aktiv': forms.CheckboxInput(attrs={'class': 'form-check-input'}), + "destinataer": forms.Select(attrs={"class": "form-select"}), + "konto": forms.Select(attrs={"class": "form-select"}), + "betrag": forms.NumberInput( + attrs={"class": "form-control", "step": "0.01"} + ), + "intervall": forms.Select(attrs={"class": "form-select"}), + "beschreibung": forms.TextInput(attrs={"class": "form-control"}), + "empfaenger_iban": forms.TextInput( + attrs={ + "class": "form-control", + "placeholder": "DE89 3704 0044 0532 0130 00", + } + ), + "empfaenger_name": forms.TextInput(attrs={"class": "form-control"}), + "verwendungszweck": forms.TextInput( + attrs={"class": "form-control", "maxlength": "140"} + ), + "erste_zahlung_am": forms.DateInput( + attrs={"class": "form-control", "type": "date"} + ), + "letzte_zahlung_am": forms.DateInput( + attrs={"class": "form-control", "type": "date"} + ), + "aktiv": forms.CheckboxInput(attrs={"class": "form-check-input"}), } class UnterstuetzungMarkAsPaidForm(forms.Form): """Simple form to mark an Unterstützung as paid""" - + ausgezahlt_am = forms.DateField( - widget=forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}), - label='Ausgezahlt am', - initial=timezone.now().date() + widget=forms.DateInput(attrs={"class": "form-control", "type": "date"}), + label="Ausgezahlt am", + initial=timezone.now().date(), ) - + bemerkung = forms.CharField( - widget=forms.Textarea(attrs={'class': 'form-control', 'rows': 3}), - label='Bemerkung (optional)', + widget=forms.Textarea(attrs={"class": "form-control", "rows": 3}), + label="Bemerkung (optional)", required=False, - help_text='Optionale Notiz zur Zahlung' + help_text="Optionale Notiz zur Zahlung", ) - - class BankTransactionForm(forms.ModelForm): """Form für das Bearbeiten von Banktransaktionen""" - + class Meta: model = BankTransaction fields = [ - 'konto', 'datum', 'valuta', 'betrag', 'waehrung', - 'verwendungszweck', 'empfaenger_zahlungspflichtiger', - 'iban_gegenpartei', 'bic_gegenpartei', 'transaction_type', - 'status', 'kommentare', 'verwaltungskosten' + "konto", + "datum", + "valuta", + "betrag", + "waehrung", + "verwendungszweck", + "empfaenger_zahlungspflichtiger", + "iban_gegenpartei", + "bic_gegenpartei", + "transaction_type", + "status", + "kommentare", + "verwaltungskosten", ] - + widgets = { - 'konto': forms.Select(attrs={'class': 'form-select'}), - 'datum': forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}), - 'valuta': forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}), - 'betrag': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}), - 'waehrung': forms.TextInput(attrs={'class': 'form-control'}), - 'verwendungszweck': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}), - 'empfaenger_zahlungspflichtiger': forms.TextInput(attrs={'class': 'form-control'}), - 'iban_gegenpartei': forms.TextInput(attrs={'class': 'form-control'}), - 'bic_gegenpartei': forms.TextInput(attrs={'class': 'form-control'}), - 'transaction_type': forms.Select(attrs={'class': 'form-select'}), - 'status': forms.Select(attrs={'class': 'form-select'}), - 'kommentare': forms.Textarea(attrs={'class': 'form-control', 'rows': 4}), - 'verwaltungskosten': forms.Select(attrs={'class': 'form-select'}), + "konto": forms.Select(attrs={"class": "form-select"}), + "datum": forms.DateInput(attrs={"class": "form-control", "type": "date"}), + "valuta": forms.DateInput(attrs={"class": "form-control", "type": "date"}), + "betrag": forms.NumberInput( + attrs={"class": "form-control", "step": "0.01"} + ), + "waehrung": forms.TextInput(attrs={"class": "form-control"}), + "verwendungszweck": forms.Textarea( + attrs={"class": "form-control", "rows": 3} + ), + "empfaenger_zahlungspflichtiger": forms.TextInput( + attrs={"class": "form-control"} + ), + "iban_gegenpartei": forms.TextInput(attrs={"class": "form-control"}), + "bic_gegenpartei": forms.TextInput(attrs={"class": "form-control"}), + "transaction_type": forms.Select(attrs={"class": "form-select"}), + "status": forms.Select(attrs={"class": "form-select"}), + "kommentare": forms.Textarea(attrs={"class": "form-control", "rows": 4}), + "verwaltungskosten": forms.Select(attrs={"class": "form-select"}), } class DestinataerUnterstuetzungForm(forms.ModelForm): """Form für geplante/ausgeführte Destinatärunterstützungen""" + class Meta: model = DestinataerUnterstuetzung - fields = ['destinataer', 'konto', 'betrag', 'faellig_am', 'status', 'beschreibung'] + fields = [ + "destinataer", + "konto", + "betrag", + "faellig_am", + "status", + "beschreibung", + ] widgets = { - 'destinataer': forms.Select(attrs={'class': 'form-select'}), - 'konto': forms.Select(attrs={'class': 'form-select'}), - 'betrag': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}), - 'faellig_am': forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}), - 'status': forms.Select(attrs={'class': 'form-select'}), - 'beschreibung': forms.TextInput(attrs={'class': 'form-control'}), + "destinataer": forms.Select(attrs={"class": "form-select"}), + "konto": forms.Select(attrs={"class": "form-select"}), + "betrag": forms.NumberInput( + attrs={"class": "form-control", "step": "0.01"} + ), + "faellig_am": forms.DateInput( + attrs={"class": "form-control", "type": "date"} + ), + "status": forms.Select(attrs={"class": "form-select"}), + "beschreibung": forms.TextInput(attrs={"class": "form-control"}), } class DestinataerNotizForm(forms.ModelForm): class Meta: model = DestinataerNotiz - fields = ['titel', 'text', 'datei'] + fields = ["titel", "text", "datei"] widgets = { - 'titel': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'z.B. Telefonat vom 29.08.2025'}), - 'text': forms.Textarea(attrs={'class': 'form-control', 'rows': 5, 'placeholder': 'Notiztext...'}), - 'datei': forms.ClearableFileInput(attrs={'class': 'form-control'}), + "titel": forms.TextInput( + attrs={ + "class": "form-control", + "placeholder": "z.B. Telefonat vom 29.08.2025", + } + ), + "text": forms.Textarea( + attrs={ + "class": "form-control", + "rows": 5, + "placeholder": "Notiztext...", + } + ), + "datei": forms.ClearableFileInput(attrs={"class": "form-control"}), } def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) # Make all fields optional - self.fields['datei'].required = False - self.fields['titel'].required = False - self.fields['text'].required = False + self.fields["datei"].required = False + self.fields["titel"].required = False + self.fields["text"].required = False def clean(self): cleaned = super().clean() - titel = cleaned.get('titel', '').strip() - text = cleaned.get('text', '').strip() + titel = cleaned.get("titel", "").strip() + text = cleaned.get("text", "").strip() if not (titel or text): - raise forms.ValidationError('Bitte geben Sie einen Titel oder einen Text ein.') + raise forms.ValidationError( + "Bitte geben Sie einen Titel oder einen Text ein." + ) return cleaned class BankImportForm(forms.Form): """Form für den Import von Bankdaten""" - + konto = forms.ModelChoiceField( queryset=StiftungsKonto.objects.filter(aktiv=True), - widget=forms.Select(attrs={'class': 'form-select'}), - label="Zielkonto" + widget=forms.Select(attrs={"class": "form-select"}), + label="Zielkonto", ) - + datei = forms.FileField( - widget=forms.FileInput(attrs={'class': 'form-control', 'accept': '.csv,.txt'}), + widget=forms.FileInput(attrs={"class": "form-control", "accept": ".csv,.txt"}), label="Bankdatei", - help_text="Unterstützte Formate: CSV, TXT (Sparkasse, Volksbank, etc.)" + help_text="Unterstützte Formate: CSV, TXT (Sparkasse, Volksbank, etc.)", ) - + encoding = forms.ChoiceField( choices=[ - ('utf-8', 'UTF-8'), - ('latin1', 'Latin-1 / ISO-8859-1'), - ('cp1252', 'Windows-1252'), + ("utf-8", "UTF-8"), + ("latin1", "Latin-1 / ISO-8859-1"), + ("cp1252", "Windows-1252"), ], - initial='utf-8', - widget=forms.Select(attrs={'class': 'form-select'}), - label="Zeichenkodierung" + initial="utf-8", + widget=forms.Select(attrs={"class": "form-select"}), + label="Zeichenkodierung", ) - + delimiter = forms.ChoiceField( choices=[ - (';', 'Semikolon (;)'), - (',', 'Komma (,)'), - ('\t', 'Tab'), + (";", "Semikolon (;)"), + (",", "Komma (,)"), + ("\t", "Tab"), ], - initial=';', - widget=forms.Select(attrs={'class': 'form-select'}), - label="Trennzeichen" + initial=";", + widget=forms.Select(attrs={"class": "form-select"}), + label="Trennzeichen", ) - + skip_header = forms.BooleanField( initial=True, required=False, - widget=forms.CheckboxInput(attrs={'class': 'form-check-input'}), - label="Erste Zeile überspringen (Spaltenüberschriften)" + widget=forms.CheckboxInput(attrs={"class": "form-check-input"}), + label="Erste Zeile überspringen (Spaltenüberschriften)", ) @@ -715,229 +1007,312 @@ class BankImportForm(forms.Form): # USER MANAGEMENT FORMS # ============================================================================= + class UserCreationForm(forms.Form): """Form für die Erstellung neuer Benutzer""" + username = forms.CharField( label="Benutzername", max_length=150, help_text="Eindeutiger Benutzername für die Anmeldung", - widget=forms.TextInput(attrs={'class': 'form-control'}) + widget=forms.TextInput(attrs={"class": "form-control"}), ) - + email = forms.EmailField( label="E-Mail-Adresse", help_text="E-Mail-Adresse des Benutzers", - widget=forms.EmailInput(attrs={'class': 'form-control'}) + widget=forms.EmailInput(attrs={"class": "form-control"}), ) - + first_name = forms.CharField( label="Vorname", max_length=30, required=False, - widget=forms.TextInput(attrs={'class': 'form-control'}) + widget=forms.TextInput(attrs={"class": "form-control"}), ) - + last_name = forms.CharField( label="Nachname", max_length=150, required=False, - widget=forms.TextInput(attrs={'class': 'form-control'}) + widget=forms.TextInput(attrs={"class": "form-control"}), ) - + password1 = forms.CharField( label="Passwort", - widget=forms.PasswordInput(attrs={'class': 'form-control'}), - help_text="Mindestens 8 Zeichen" + widget=forms.PasswordInput(attrs={"class": "form-control"}), + help_text="Mindestens 8 Zeichen", ) - + password2 = forms.CharField( label="Passwort bestätigen", - widget=forms.PasswordInput(attrs={'class': 'form-control'}), - help_text="Geben Sie das Passwort zur Bestätigung erneut ein" + widget=forms.PasswordInput(attrs={"class": "form-control"}), + help_text="Geben Sie das Passwort zur Bestätigung erneut ein", ) - + is_active = forms.BooleanField( label="Aktiv", required=False, initial=True, help_text="Benutzer kann sich anmelden", - widget=forms.CheckboxInput(attrs={'class': 'form-check-input'}) + widget=forms.CheckboxInput(attrs={"class": "form-check-input"}), ) - + is_staff = forms.BooleanField( label="Staff-Status", required=False, help_text="Benutzer kann auf Django Admin zugreifen", - widget=forms.CheckboxInput(attrs={'class': 'form-check-input'}) + widget=forms.CheckboxInput(attrs={"class": "form-check-input"}), ) - + def clean_username(self): - username = self.cleaned_data['username'] + username = self.cleaned_data["username"] from django.contrib.auth.models import User + if User.objects.filter(username=username).exists(): - raise forms.ValidationError("Ein Benutzer mit diesem Namen existiert bereits.") + raise forms.ValidationError( + "Ein Benutzer mit diesem Namen existiert bereits." + ) return username - + def clean_email(self): - email = self.cleaned_data['email'] + email = self.cleaned_data["email"] from django.contrib.auth.models import User + if User.objects.filter(email=email).exists(): - raise forms.ValidationError("Ein Benutzer mit dieser E-Mail-Adresse existiert bereits.") + raise forms.ValidationError( + "Ein Benutzer mit dieser E-Mail-Adresse existiert bereits." + ) return email def clean(self): cleaned_data = super().clean() password1 = cleaned_data.get("password1") password2 = cleaned_data.get("password2") - + if password1 and password2: if password1 != password2: raise forms.ValidationError("Die Passwörter stimmen nicht überein.") if len(password1) < 8: - raise forms.ValidationError("Das Passwort muss mindestens 8 Zeichen lang sein.") - + raise forms.ValidationError( + "Das Passwort muss mindestens 8 Zeichen lang sein." + ) + return cleaned_data class UserUpdateForm(forms.ModelForm): """Form für die Bearbeitung bestehender Benutzer""" - + class Meta: from django.contrib.auth.models import User + model = User - fields = ['username', 'email', 'first_name', 'last_name', 'is_active', 'is_staff'] + fields = [ + "username", + "email", + "first_name", + "last_name", + "is_active", + "is_staff", + ] widgets = { - 'username': forms.TextInput(attrs={'class': 'form-control'}), - 'email': forms.EmailInput(attrs={'class': 'form-control'}), - 'first_name': forms.TextInput(attrs={'class': 'form-control'}), - 'last_name': forms.TextInput(attrs={'class': 'form-control'}), - 'is_active': forms.CheckboxInput(attrs={'class': 'form-check-input'}), - 'is_staff': forms.CheckboxInput(attrs={'class': 'form-check-input'}), + "username": forms.TextInput(attrs={"class": "form-control"}), + "email": forms.EmailInput(attrs={"class": "form-control"}), + "first_name": forms.TextInput(attrs={"class": "form-control"}), + "last_name": forms.TextInput(attrs={"class": "form-control"}), + "is_active": forms.CheckboxInput(attrs={"class": "form-check-input"}), + "is_staff": forms.CheckboxInput(attrs={"class": "form-check-input"}), } labels = { - 'username': 'Benutzername', - 'email': 'E-Mail-Adresse', - 'first_name': 'Vorname', - 'last_name': 'Nachname', - 'is_active': 'Aktiv', - 'is_staff': 'Staff-Status', + "username": "Benutzername", + "email": "E-Mail-Adresse", + "first_name": "Vorname", + "last_name": "Nachname", + "is_active": "Aktiv", + "is_staff": "Staff-Status", } help_texts = { - 'username': 'Eindeutiger Benutzername für die Anmeldung', - 'email': 'E-Mail-Adresse des Benutzers', - 'is_active': 'Benutzer kann sich anmelden', - 'is_staff': 'Benutzer kann auf Django Admin zugreifen', + "username": "Eindeutiger Benutzername für die Anmeldung", + "email": "E-Mail-Adresse des Benutzers", + "is_active": "Benutzer kann sich anmelden", + "is_staff": "Benutzer kann auf Django Admin zugreifen", } class PasswordChangeForm(forms.Form): """Form für Passwort-Änderungen""" + new_password1 = forms.CharField( label="Neues Passwort", - widget=forms.PasswordInput(attrs={'class': 'form-control'}), - help_text="Mindestens 8 Zeichen" + widget=forms.PasswordInput(attrs={"class": "form-control"}), + help_text="Mindestens 8 Zeichen", ) - + new_password2 = forms.CharField( label="Neues Passwort bestätigen", - widget=forms.PasswordInput(attrs={'class': 'form-control'}), - help_text="Geben Sie das neue Passwort zur Bestätigung erneut ein" + widget=forms.PasswordInput(attrs={"class": "form-control"}), + help_text="Geben Sie das neue Passwort zur Bestätigung erneut ein", ) def clean(self): cleaned_data = super().clean() password1 = cleaned_data.get("new_password1") password2 = cleaned_data.get("new_password2") - + if password1 and password2: if password1 != password2: raise forms.ValidationError("Die Passwörter stimmen nicht überein.") if len(password1) < 8: - raise forms.ValidationError("Das Passwort muss mindestens 8 Zeichen lang sein.") - + raise forms.ValidationError( + "Das Passwort muss mindestens 8 Zeichen lang sein." + ) + return cleaned_data class UserPermissionForm(forms.Form): """Form für die Zuweisung von Berechtigungen""" - + def __init__(self, *args, **kwargs): - user = kwargs.pop('user', None) + user = kwargs.pop("user", None) super().__init__(*args, **kwargs) - + from django.contrib.auth.models import Permission - + # Get all custom permissions for stiftung app - app_permissions = Permission.objects.filter(content_type__app_label='stiftung').order_by('name') - + app_permissions = Permission.objects.filter( + content_type__app_label="stiftung" + ).order_by("name") + # Create checkbox fields for each permission for perm in app_permissions: - field_name = f'perm_{perm.id}' + field_name = f"perm_{perm.id}" self.fields[field_name] = forms.BooleanField( label=perm.name, required=False, - widget=forms.CheckboxInput(attrs={'class': 'form-check-input'}) + widget=forms.CheckboxInput(attrs={"class": "form-check-input"}), ) - + # Set initial values if user is provided if user: - self.fields[field_name].initial = user.has_perm(f'stiftung.{perm.codename}') - + self.fields[field_name].initial = user.has_perm( + f"stiftung.{perm.codename}" + ) + def get_permission_groups(self): """Group permissions by functionality for template rendering""" from django.contrib.auth.models import Permission - + groups = { - 'entities': { - 'name': 'Entitäten verwalten', - 'permissions': [], - 'icon': 'fas fa-users' + "entities": { + "name": "Entitäten verwalten", + "permissions": [], + "icon": "fas fa-users", }, - 'documents': { - 'name': 'Dokumentenverwaltung', - 'permissions': [], - 'icon': 'fas fa-folder-open' + "documents": { + "name": "Dokumentenverwaltung", + "permissions": [], + "icon": "fas fa-folder-open", }, - 'financial': { - 'name': 'Finanzverwaltung', - 'permissions': [], - 'icon': 'fas fa-euro-sign' + "financial": { + "name": "Finanzverwaltung", + "permissions": [], + "icon": "fas fa-euro-sign", }, - 'administration': { - 'name': 'Administration', - 'permissions': [], - 'icon': 'fas fa-cogs' + "administration": { + "name": "Administration", + "permissions": [], + "icon": "fas fa-cogs", }, - 'system': { - 'name': 'System', - 'permissions': [], - 'icon': 'fas fa-server' - } + "system": {"name": "System", "permissions": [], "icon": "fas fa-server"}, } - + # Get all permissions to properly categorize them for field_name, field in self.fields.items(): - if field_name.startswith('perm_'): + if field_name.startswith("perm_"): # Extract permission ID from field name - perm_id = field_name.replace('perm_', '') + perm_id = field_name.replace("perm_", "") try: permission = Permission.objects.get(id=perm_id) label = permission.name.lower() codename = permission.codename.lower() - + # More precise categorization based on both name and codename - if any(word in codename for word in ['destinataer', 'land', 'paechter', 'verpachtung', 'foerderung']) and 'manage_' in codename or 'view_' in codename: - groups['entities']['permissions'].append((field_name, field, permission)) - elif any(word in codename for word in ['documents', 'link_documents']) or 'dokument' in label: - groups['documents']['permissions'].append((field_name, field, permission)) - elif any(word in codename for word in ['verwaltungskosten', 'konten', 'rentmeister', 'approve_payments']) or any(word in label for word in ['verwaltungskosten', 'konto', 'rentmeister', 'zahlung']): - groups['financial']['permissions'].append((field_name, field, permission)) - elif any(word in codename for word in ['administration', 'audit', 'backup', 'manage_users', 'manage_permissions']) or any(word in label for word in ['administration', 'audit', 'backup', 'benutzer', 'berechtigung']): - groups['administration']['permissions'].append((field_name, field, permission)) + if ( + any( + word in codename + for word in [ + "destinataer", + "land", + "paechter", + "verpachtung", + "foerderung", + ] + ) + and "manage_" in codename + or "view_" in codename + ): + groups["entities"]["permissions"].append( + (field_name, field, permission) + ) + elif ( + any( + word in codename for word in ["documents", "link_documents"] + ) + or "dokument" in label + ): + groups["documents"]["permissions"].append( + (field_name, field, permission) + ) + elif any( + word in codename + for word in [ + "verwaltungskosten", + "konten", + "rentmeister", + "approve_payments", + ] + ) or any( + word in label + for word in [ + "verwaltungskosten", + "konto", + "rentmeister", + "zahlung", + ] + ): + groups["financial"]["permissions"].append( + (field_name, field, permission) + ) + elif any( + word in codename + for word in [ + "administration", + "audit", + "backup", + "manage_users", + "manage_permissions", + ] + ) or any( + word in label + for word in [ + "administration", + "audit", + "backup", + "benutzer", + "berechtigung", + ] + ): + groups["administration"]["permissions"].append( + (field_name, field, permission) + ) else: - groups['system']['permissions'].append((field_name, field, permission)) + groups["system"]["permissions"].append( + (field_name, field, permission) + ) except Permission.DoesNotExist: # Fallback for permissions that don't exist - groups['system']['permissions'].append((field_name, field, None)) - - return groups \ No newline at end of file + groups["system"]["permissions"].append((field_name, field, None)) + + return groups diff --git a/app/stiftung/management/commands/generate_recurring_payments.py b/app/stiftung/management/commands/generate_recurring_payments.py index 0e4149c..b8c35d1 100644 --- a/app/stiftung/management/commands/generate_recurring_payments.py +++ b/app/stiftung/management/commands/generate_recurring_payments.py @@ -3,60 +3,64 @@ Management command to generate due recurring support payments. This command should be run daily via cron or similar scheduling system. """ +import logging +from datetime import timedelta + from django.core.management.base import BaseCommand from django.utils import timezone -from datetime import timedelta + from stiftung.models import UnterstuetzungWiederkehrend -import logging logger = logging.getLogger(__name__) + class Command(BaseCommand): - help = 'Generate due recurring support payments' - + help = "Generate due recurring support payments" + def add_arguments(self, parser): parser.add_argument( - '--dry-run', - action='store_true', - help='Show what would be generated without actually creating payments', + "--dry-run", + action="store_true", + help="Show what would be generated without actually creating payments", ) parser.add_argument( - '--days-ahead', + "--days-ahead", type=int, default=0, - help='Generate payments that are due within this many days (default: 0 = only today)', + help="Generate payments that are due within this many days (default: 0 = only today)", ) - + def handle(self, *args, **options): - dry_run = options['dry_run'] - days_ahead = options['days_ahead'] - + dry_run = options["dry_run"] + days_ahead = options["days_ahead"] + heute = timezone.now().date() cutoff_date = heute + timedelta(days=days_ahead) - + self.stdout.write( self.style.SUCCESS( f'Checking for recurring payments due up to {cutoff_date.strftime("%d.%m.%Y")}...' ) ) - + if dry_run: - self.stdout.write(self.style.WARNING('DRY RUN MODE - No payments will be created')) - + self.stdout.write( + self.style.WARNING("DRY RUN MODE - No payments will be created") + ) + # Get all active recurring payment templates that are due templates = UnterstuetzungWiederkehrend.objects.filter( - aktiv=True, - naechste_generierung__lte=cutoff_date - ).select_related('destinataer', 'konto') - + aktiv=True, naechste_generierung__lte=cutoff_date + ).select_related("destinataer", "konto") + generated_count = 0 error_count = 0 - + for template in templates: try: if dry_run: self.stdout.write( - f'Would generate: {template.destinataer.get_full_name()} - ' + f"Would generate: {template.destinataer.get_full_name()} - " f'€{template.betrag} due {template.naechste_generierung.strftime("%d.%m.%Y")}' ) generated_count += 1 @@ -66,68 +70,67 @@ class Command(BaseCommand): if neue_zahlung: self.stdout.write( self.style.SUCCESS( - f'Generated: {neue_zahlung.destinataer.get_full_name()} - ' + f"Generated: {neue_zahlung.destinataer.get_full_name()} - " f'€{neue_zahlung.betrag} due {neue_zahlung.faellig_am.strftime("%d.%m.%Y")}' ) ) generated_count += 1 - logger.info(f'Generated recurring payment: {neue_zahlung.pk}') + logger.info(f"Generated recurring payment: {neue_zahlung.pk}") else: self.stdout.write( self.style.WARNING( - f'No payment generated for {template.destinataer.get_full_name()} ' - f'(may have reached end date or not yet due)' + f"No payment generated for {template.destinataer.get_full_name()} " + f"(may have reached end date or not yet due)" ) ) except Exception as e: error_count += 1 self.stdout.write( self.style.ERROR( - f'Error generating payment for {template.destinataer.get_full_name()}: {str(e)}' + f"Error generating payment for {template.destinataer.get_full_name()}: {str(e)}" ) ) - logger.error(f'Error generating recurring payment for template {template.pk}: {str(e)}') - + logger.error( + f"Error generating recurring payment for template {template.pk}: {str(e)}" + ) + # Summary - self.stdout.write('\n' + '='*50) + self.stdout.write("\n" + "=" * 50) if dry_run: self.stdout.write( self.style.SUCCESS( - f'DRY RUN COMPLETE: {generated_count} payments would be generated' + f"DRY RUN COMPLETE: {generated_count} payments would be generated" ) ) else: self.stdout.write( self.style.SUCCESS( - f'GENERATION COMPLETE: {generated_count} payments generated' + f"GENERATION COMPLETE: {generated_count} payments generated" ) ) - + if error_count > 0: - self.stdout.write( - self.style.ERROR(f'{error_count} errors encountered') - ) - + self.stdout.write(self.style.ERROR(f"{error_count} errors encountered")) + # Also check for overdue payments and report them from stiftung.models import DestinataerUnterstuetzung - + overdue_payments = DestinataerUnterstuetzung.objects.filter( - faellig_am__lt=heute, - status__in=['geplant', 'faellig'] - ).select_related('destinataer') - + faellig_am__lt=heute, status__in=["geplant", "faellig"] + ).select_related("destinataer") + if overdue_payments.exists(): - self.stdout.write('\n' + '='*50) + self.stdout.write("\n" + "=" * 50) self.stdout.write( self.style.WARNING( - f'WARNING: {overdue_payments.count()} overdue payments found:' + f"WARNING: {overdue_payments.count()} overdue payments found:" ) ) for payment in overdue_payments[:10]: # Limit to first 10 days_overdue = (heute - payment.faellig_am).days self.stdout.write( - f' - {payment.destinataer.get_full_name()}: €{payment.betrag} ' - f'({days_overdue} days overdue)' + f" - {payment.destinataer.get_full_name()}: €{payment.betrag} " + f"({days_overdue} days overdue)" ) if overdue_payments.count() > 10: - self.stdout.write(f' ... and {overdue_payments.count() - 10} more') + self.stdout.write(f" ... and {overdue_payments.count() - 10} more") diff --git a/app/stiftung/management/commands/init_config.py b/app/stiftung/management/commands/init_config.py index c9b233b..5af320a 100644 --- a/app/stiftung/management/commands/init_config.py +++ b/app/stiftung/management/commands/init_config.py @@ -1,93 +1,94 @@ from django.core.management.base import BaseCommand + from stiftung.models import AppConfiguration class Command(BaseCommand): - help = 'Initialize default app configuration settings' + help = "Initialize default app configuration settings" def handle(self, *args, **options): # Paperless Integration Settings paperless_settings = [ { - 'key': 'paperless_api_url', - 'display_name': 'Paperless API URL', - 'description': 'The base URL for your Paperless-NGX API (e.g., http://paperless.example.com:8000)', - 'value': 'http://192.168.178.167:30070', - 'default_value': 'http://192.168.178.167:30070', - 'setting_type': 'url', - 'category': 'paperless', - 'order': 1 + "key": "paperless_api_url", + "display_name": "Paperless API URL", + "description": "The base URL for your Paperless-NGX API (e.g., http://paperless.example.com:8000)", + "value": "http://192.168.178.167:30070", + "default_value": "http://192.168.178.167:30070", + "setting_type": "url", + "category": "paperless", + "order": 1, }, { - 'key': 'paperless_api_token', - 'display_name': 'Paperless API Token', - 'description': 'The authentication token for Paperless API access', - 'value': '', - 'default_value': '', - 'setting_type': 'text', - 'category': 'paperless', - 'order': 2 + "key": "paperless_api_token", + "display_name": "Paperless API Token", + "description": "The authentication token for Paperless API access", + "value": "", + "default_value": "", + "setting_type": "text", + "category": "paperless", + "order": 2, }, { - 'key': 'paperless_destinataere_tag', - 'display_name': 'Destinatäre Tag Name', - 'description': 'The tag name used to identify Destinatäre documents in Paperless', - 'value': 'Stiftung_Destinatäre', - 'default_value': 'Stiftung_Destinatäre', - 'setting_type': 'tag', - 'category': 'paperless', - 'order': 3 + "key": "paperless_destinataere_tag", + "display_name": "Destinatäre Tag Name", + "description": "The tag name used to identify Destinatäre documents in Paperless", + "value": "Stiftung_Destinatäre", + "default_value": "Stiftung_Destinatäre", + "setting_type": "tag", + "category": "paperless", + "order": 3, }, { - 'key': 'paperless_destinataere_tag_id', - 'display_name': 'Destinatäre Tag ID', - 'description': 'The numeric ID of the Destinatäre tag in Paperless', - 'value': '210', - 'default_value': '210', - 'setting_type': 'tag_id', - 'category': 'paperless', - 'order': 4 + "key": "paperless_destinataere_tag_id", + "display_name": "Destinatäre Tag ID", + "description": "The numeric ID of the Destinatäre tag in Paperless", + "value": "210", + "default_value": "210", + "setting_type": "tag_id", + "category": "paperless", + "order": 4, }, { - 'key': 'paperless_land_tag', - 'display_name': 'Land & Pächter Tag Name', - 'description': 'The tag name used to identify Land and Pächter documents in Paperless', - 'value': 'Stiftung_Land_und_Pächter', - 'default_value': 'Stiftung_Land_und_Pächter', - 'setting_type': 'tag', - 'category': 'paperless', - 'order': 5 + "key": "paperless_land_tag", + "display_name": "Land & Pächter Tag Name", + "description": "The tag name used to identify Land and Pächter documents in Paperless", + "value": "Stiftung_Land_und_Pächter", + "default_value": "Stiftung_Land_und_Pächter", + "setting_type": "tag", + "category": "paperless", + "order": 5, }, { - 'key': 'paperless_land_tag_id', - 'display_name': 'Land & Pächter Tag ID', - 'description': 'The numeric ID of the Land & Pächter tag in Paperless', - 'value': '204', - 'default_value': '204', - 'setting_type': 'tag_id', - 'category': 'paperless', - 'order': 6 + "key": "paperless_land_tag_id", + "display_name": "Land & Pächter Tag ID", + "description": "The numeric ID of the Land & Pächter tag in Paperless", + "value": "204", + "default_value": "204", + "setting_type": "tag_id", + "category": "paperless", + "order": 6, }, { - 'key': 'paperless_admin_tag', - 'display_name': 'Administration Tag Name', - 'description': 'The tag name used to identify Administration documents in Paperless', - 'value': 'Stiftung_Administration', - 'default_value': 'Stiftung_Administration', - 'setting_type': 'tag', - 'category': 'paperless', - 'order': 7 + "key": "paperless_admin_tag", + "display_name": "Administration Tag Name", + "description": "The tag name used to identify Administration documents in Paperless", + "value": "Stiftung_Administration", + "default_value": "Stiftung_Administration", + "setting_type": "tag", + "category": "paperless", + "order": 7, }, { - 'key': 'paperless_admin_tag_id', - 'display_name': 'Administration Tag ID', - 'description': 'The numeric ID of the Administration tag in Paperless', - 'value': '216', - 'default_value': '216', - 'setting_type': 'tag_id', - 'category': 'paperless', - 'order': 8 - } + "key": "paperless_admin_tag_id", + "display_name": "Administration Tag ID", + "description": "The numeric ID of the Administration tag in Paperless", + "value": "216", + "default_value": "216", + "setting_type": "tag_id", + "category": "paperless", + "order": 8, + }, ] created_count = 0 @@ -95,26 +96,25 @@ class Command(BaseCommand): for setting_data in paperless_settings: setting, created = AppConfiguration.objects.get_or_create( - key=setting_data['key'], - defaults=setting_data + key=setting_data["key"], defaults=setting_data ) - + if created: created_count += 1 self.stdout.write( - self.style.SUCCESS(f'Created setting: {setting.display_name}') + self.style.SUCCESS(f"Created setting: {setting.display_name}") ) else: # Update existing setting with new defaults if needed if not setting.description: - setting.description = setting_data['description'] + setting.description = setting_data["description"] setting.save() updated_count += 1 self.stdout.write( self.style.SUCCESS( - f'Configuration initialized successfully! ' - f'Created {created_count} new settings, updated {updated_count} existing settings.' + f"Configuration initialized successfully! " + f"Created {created_count} new settings, updated {updated_count} existing settings." ) ) self.stdout.write( diff --git a/app/stiftung/management/commands/init_corporate_settings.py b/app/stiftung/management/commands/init_corporate_settings.py index e32d60c..493ab97 100644 --- a/app/stiftung/management/commands/init_corporate_settings.py +++ b/app/stiftung/management/commands/init_corporate_settings.py @@ -1,114 +1,116 @@ """ Management command to initialize corporate identity settings """ + from django.core.management.base import BaseCommand + from stiftung.models import AppConfiguration class Command(BaseCommand): - help = 'Initialize corporate identity settings for PDF generation' + help = "Initialize corporate identity settings for PDF generation" def handle(self, *args, **options): corporate_settings = [ { - 'key': 'corporate_stiftung_name', - 'display_name': 'Name der Stiftung', - 'description': 'Der offizielle Name der Stiftung für PDF-Dokumente', - 'value': 'Stiftung', - 'default_value': 'Stiftung', - 'setting_type': 'text', - 'category': 'corporate', - 'order': 1 + "key": "corporate_stiftung_name", + "display_name": "Name der Stiftung", + "description": "Der offizielle Name der Stiftung für PDF-Dokumente", + "value": "Stiftung", + "default_value": "Stiftung", + "setting_type": "text", + "category": "corporate", + "order": 1, }, { - 'key': 'corporate_logo_path', - 'display_name': 'Logo-Pfad', - 'description': 'Pfad zur Logo-Datei (relativ zu MEDIA_ROOT oder STATIC_ROOT)', - 'value': '', - 'default_value': '', - 'setting_type': 'text', - 'category': 'corporate', - 'order': 2 + "key": "corporate_logo_path", + "display_name": "Logo-Pfad", + "description": "Pfad zur Logo-Datei (relativ zu MEDIA_ROOT oder STATIC_ROOT)", + "value": "", + "default_value": "", + "setting_type": "text", + "category": "corporate", + "order": 2, }, { - 'key': 'corporate_primary_color', - 'display_name': 'Primärfarbe', - 'description': 'Hauptfarbe für Überschriften und Akzente (Hex-Code)', - 'value': '#2c3e50', - 'default_value': '#2c3e50', - 'setting_type': 'text', - 'category': 'corporate', - 'order': 3 + "key": "corporate_primary_color", + "display_name": "Primärfarbe", + "description": "Hauptfarbe für Überschriften und Akzente (Hex-Code)", + "value": "#2c3e50", + "default_value": "#2c3e50", + "setting_type": "text", + "category": "corporate", + "order": 3, }, { - 'key': 'corporate_secondary_color', - 'display_name': 'Sekundärfarbe', - 'description': 'Zweitfarbe für Akzente und Details (Hex-Code)', - 'value': '#3498db', - 'default_value': '#3498db', - 'setting_type': 'text', - 'category': 'corporate', - 'order': 4 + "key": "corporate_secondary_color", + "display_name": "Sekundärfarbe", + "description": "Zweitfarbe für Akzente und Details (Hex-Code)", + "value": "#3498db", + "default_value": "#3498db", + "setting_type": "text", + "category": "corporate", + "order": 4, }, { - 'key': 'corporate_address_line1', - 'display_name': 'Adresse Zeile 1', - 'description': 'Erste Zeile der Stiftungsadresse', - 'value': '', - 'default_value': '', - 'setting_type': 'text', - 'category': 'corporate', - 'order': 5 + "key": "corporate_address_line1", + "display_name": "Adresse Zeile 1", + "description": "Erste Zeile der Stiftungsadresse", + "value": "", + "default_value": "", + "setting_type": "text", + "category": "corporate", + "order": 5, }, { - 'key': 'corporate_address_line2', - 'display_name': 'Adresse Zeile 2', - 'description': 'Zweite Zeile der Stiftungsadresse (PLZ, Ort)', - 'value': '', - 'default_value': '', - 'setting_type': 'text', - 'category': 'corporate', - 'order': 6 + "key": "corporate_address_line2", + "display_name": "Adresse Zeile 2", + "description": "Zweite Zeile der Stiftungsadresse (PLZ, Ort)", + "value": "", + "default_value": "", + "setting_type": "text", + "category": "corporate", + "order": 6, }, { - 'key': 'corporate_phone', - 'display_name': 'Telefonnummer', - 'description': 'Telefonnummer der Stiftung', - 'value': '', - 'default_value': '', - 'setting_type': 'text', - 'category': 'corporate', - 'order': 7 + "key": "corporate_phone", + "display_name": "Telefonnummer", + "description": "Telefonnummer der Stiftung", + "value": "", + "default_value": "", + "setting_type": "text", + "category": "corporate", + "order": 7, }, { - 'key': 'corporate_email', - 'display_name': 'E-Mail-Adresse', - 'description': 'Offizielle E-Mail-Adresse der Stiftung', - 'value': '', - 'default_value': '', - 'setting_type': 'text', - 'category': 'corporate', - 'order': 8 + "key": "corporate_email", + "display_name": "E-Mail-Adresse", + "description": "Offizielle E-Mail-Adresse der Stiftung", + "value": "", + "default_value": "", + "setting_type": "text", + "category": "corporate", + "order": 8, }, { - 'key': 'corporate_website', - 'display_name': 'Website', - 'description': 'Website der Stiftung', - 'value': '', - 'default_value': '', - 'setting_type': 'url', - 'category': 'corporate', - 'order': 9 + "key": "corporate_website", + "display_name": "Website", + "description": "Website der Stiftung", + "value": "", + "default_value": "", + "setting_type": "url", + "category": "corporate", + "order": 9, }, { - 'key': 'corporate_footer_text', - 'display_name': 'Fußzeilen-Text', - 'description': 'Text für die Fußzeile in PDF-Dokumenten', - 'value': 'Dieser Bericht wurde automatisch generiert.', - 'default_value': 'Dieser Bericht wurde automatisch generiert.', - 'setting_type': 'text', - 'category': 'corporate', - 'order': 10 + "key": "corporate_footer_text", + "display_name": "Fußzeilen-Text", + "description": "Text für die Fußzeile in PDF-Dokumenten", + "value": "Dieser Bericht wurde automatisch generiert.", + "default_value": "Dieser Bericht wurde automatisch generiert.", + "setting_type": "text", + "category": "corporate", + "order": 10, }, ] @@ -117,33 +119,32 @@ class Command(BaseCommand): for setting_data in corporate_settings: setting, created = AppConfiguration.objects.get_or_create( - key=setting_data['key'], - defaults=setting_data + key=setting_data["key"], defaults=setting_data ) - + if created: created_count += 1 self.stdout.write( - self.style.SUCCESS(f'Created setting: {setting.display_name}') + self.style.SUCCESS(f"Created setting: {setting.display_name}") ) else: # Update existing setting with new defaults if needed if not setting.description: - setting.description = setting_data['description'] + setting.description = setting_data["description"] setting.save() updated_count += 1 self.stdout.write( self.style.SUCCESS( - f'Corporate identity settings initialized! ' - f'Created {created_count} new settings, updated {updated_count} existing settings.' + f"Corporate identity settings initialized! " + f"Created {created_count} new settings, updated {updated_count} existing settings." ) ) - + if created_count > 0: self.stdout.write( self.style.WARNING( - 'Please configure your corporate identity settings in ' - 'Administration -> Application Settings before generating PDFs.' + "Please configure your corporate identity settings in " + "Administration -> Application Settings before generating PDFs." ) ) diff --git a/app/stiftung/management/commands/migrate_verpachtungen.py b/app/stiftung/management/commands/migrate_verpachtungen.py index d42cfd5..b3c59cf 100644 --- a/app/stiftung/management/commands/migrate_verpachtungen.py +++ b/app/stiftung/management/commands/migrate_verpachtungen.py @@ -1,84 +1,93 @@ +import logging + from django.core.management.base import BaseCommand from django.db import transaction -from stiftung.models import Land, Verpachtung, Paechter -import logging + +from stiftung.models import Land, Paechter, Verpachtung logger = logging.getLogger(__name__) + class Command(BaseCommand): - help = 'Migriert bestehende Verpachtungen in die neue Land-Struktur' + help = "Migriert bestehende Verpachtungen in die neue Land-Struktur" def add_arguments(self, parser): parser.add_argument( - '--dry-run', - action='store_true', - help='Zeigt nur an, was gemacht würde, ohne Änderungen zu speichern', + "--dry-run", + action="store_true", + help="Zeigt nur an, was gemacht würde, ohne Änderungen zu speichern", ) def handle(self, *args, **options): - dry_run = options['dry_run'] - + dry_run = options["dry_run"] + if dry_run: - self.stdout.write(self.style.WARNING('DRY RUN - Keine Änderungen werden gespeichert!')) - + self.stdout.write( + self.style.WARNING("DRY RUN - Keine Änderungen werden gespeichert!") + ) + # Alle aktiven Verpachtungen finden - aktive_verpachtungen = Verpachtung.objects.filter(status='aktiv') - - self.stdout.write(f'Gefunden: {aktive_verpachtungen.count()} aktive Verpachtungen') - + aktive_verpachtungen = Verpachtung.objects.filter(status="aktiv") + + self.stdout.write( + f"Gefunden: {aktive_verpachtungen.count()} aktive Verpachtungen" + ) + migrated_count = 0 skipped_count = 0 - + with transaction.atomic(): for verpachtung in aktive_verpachtungen: land = verpachtung.land - + # Prüfen ob bereits migriert if land.aktueller_paechter is not None: self.stdout.write( self.style.WARNING( - f'Übersprungen: {land} hat bereits einen aktuellen Pächter' + f"Übersprungen: {land} hat bereits einen aktuellen Pächter" ) ) skipped_count += 1 continue - + # Migration durchführen - self.stdout.write(f'Migriere: {land} -> {verpachtung.paechter}') - + self.stdout.write(f"Migriere: {land} -> {verpachtung.paechter}") + if not dry_run: # Pächter-Daten ins Land übertragen land.aktueller_paechter = verpachtung.paechter land.paechter_name = verpachtung.paechter.get_full_name() - land.paechter_anschrift = self._get_paechter_anschrift(verpachtung.paechter) + land.paechter_anschrift = self._get_paechter_anschrift( + verpachtung.paechter + ) land.pachtbeginn = verpachtung.pachtbeginn land.pachtende = verpachtung.pachtende land.verlaengerung_klausel = bool(verpachtung.verlaengerung) - + # Pachtzins übertragen land.pachtzins_pauschal = verpachtung.pachtzins_jaehrlich - + # Verpachtete Fläche aktualisieren (falls nicht gesetzt) if land.verp_flaeche_aktuell == 0: land.verp_flaeche_aktuell = verpachtung.verpachtete_flaeche - + land.save() - + migrated_count += 1 - + if dry_run: self.stdout.write( self.style.SUCCESS( - f'DRY RUN abgeschlossen: {migrated_count} Verpachtungen würden migriert, {skipped_count} übersprungen' + f"DRY RUN abgeschlossen: {migrated_count} Verpachtungen würden migriert, {skipped_count} übersprungen" ) ) else: self.stdout.write( self.style.SUCCESS( - f'Migration abgeschlossen: {migrated_count} Verpachtungen migriert, {skipped_count} übersprungen' + f"Migration abgeschlossen: {migrated_count} Verpachtungen migriert, {skipped_count} übersprungen" ) ) - + def _get_paechter_anschrift(self, paechter): """Erstellt eine Anschrift aus den Pächter-Daten""" parts = [] @@ -88,5 +97,5 @@ class Command(BaseCommand): parts.append(f"{paechter.plz} {paechter.ort}") elif paechter.ort: parts.append(paechter.ort) - - return '\n'.join(parts) if parts else '' + + return "\n".join(parts) if parts else "" diff --git a/app/stiftung/management/commands/sync_abrechnungen.py b/app/stiftung/management/commands/sync_abrechnungen.py index 8af3c9f..28025f0 100644 --- a/app/stiftung/management/commands/sync_abrechnungen.py +++ b/app/stiftung/management/commands/sync_abrechnungen.py @@ -12,110 +12,116 @@ Usage: python manage.py sync_abrechnungen [--dry-run] [--year YEAR] """ +from datetime import date +from decimal import Decimal + from django.core.management.base import BaseCommand, CommandError from django.db import transaction -from decimal import Decimal -from datetime import date -from stiftung.models import Verpachtung, LandVerpachtung, LandAbrechnung + +from stiftung.models import LandAbrechnung, LandVerpachtung, Verpachtung class Command(BaseCommand): - help = 'Synchronize existing Verpachtungen with LandAbrechnungen' + help = "Synchronize existing Verpachtungen with LandAbrechnungen" def add_arguments(self, parser): parser.add_argument( - '--dry-run', - action='store_true', - help='Show what would be done without making changes', + "--dry-run", + action="store_true", + help="Show what would be done without making changes", ) parser.add_argument( - '--year', + "--year", type=int, - help='Only sync data for specific year', + help="Only sync data for specific year", ) parser.add_argument( - '--force', - action='store_true', - help='Force update even if Abrechnungen already exist', + "--force", + action="store_true", + help="Force update even if Abrechnungen already exist", ) def handle(self, *args, **options): - dry_run = options['dry_run'] - target_year = options['year'] - force = options['force'] - + dry_run = options["dry_run"] + target_year = options["year"] + force = options["force"] + self.stdout.write( - self.style.SUCCESS('🔄 Starting Abrechnung synchronization...') + self.style.SUCCESS("🔄 Starting Abrechnung synchronization...") ) - + if dry_run: - self.stdout.write(self.style.WARNING('📋 DRY RUN MODE - No changes will be made')) - + self.stdout.write( + self.style.WARNING("📋 DRY RUN MODE - No changes will be made") + ) + # Statistics stats = { - 'legacy_contracts': 0, - 'new_contracts': 0, - 'abrechnungen_created': 0, - 'abrechnungen_updated': 0, - 'total_rent_amount': Decimal('0.00'), - 'years_processed': set(), + "legacy_contracts": 0, + "new_contracts": 0, + "abrechnungen_created": 0, + "abrechnungen_updated": 0, + "total_rent_amount": Decimal("0.00"), + "years_processed": set(), } - + try: with transaction.atomic(): # Process Legacy Verpachtungen - self.stdout.write('\n📄 Processing Legacy Verpachtungen...') + self.stdout.write("\n📄 Processing Legacy Verpachtungen...") legacy_verpachtungen = Verpachtung.objects.all() - + for verpachtung in legacy_verpachtungen: - stats['legacy_contracts'] += 1 + stats["legacy_contracts"] += 1 years_affected = self._get_affected_years( verpachtung.pachtbeginn, verpachtung.verlaengerung or verpachtung.pachtende, - target_year + target_year, ) - + for year in years_affected: - stats['years_processed'].add(year) - rent_amount = self._calculate_legacy_rent_for_year(verpachtung, year) - + stats["years_processed"].add(year) + rent_amount = self._calculate_legacy_rent_for_year( + verpachtung, year + ) + if not dry_run: created, updated = self._update_abrechnung( verpachtung.land, year, rent_amount, - Decimal('0.00'), # No umlage for legacy + Decimal("0.00"), # No umlage for legacy f"Legacy-Verpachtung {verpachtung.vertragsnummer}", - force + force, ) if created: - stats['abrechnungen_created'] += 1 + stats["abrechnungen_created"] += 1 if updated: - stats['abrechnungen_updated'] += 1 - - stats['total_rent_amount'] += rent_amount - + stats["abrechnungen_updated"] += 1 + + stats["total_rent_amount"] += rent_amount + self.stdout.write( f" 📊 {verpachtung.vertragsnummer} ({year}): {rent_amount:.2f}€" ) - - # Process New LandVerpachtungen - self.stdout.write('\n🆕 Processing New LandVerpachtungen...') + + # Process New LandVerpachtungen + self.stdout.write("\n🆕 Processing New LandVerpachtungen...") land_verpachtungen = LandVerpachtung.objects.all() - + for verpachtung in land_verpachtungen: - stats['new_contracts'] += 1 + stats["new_contracts"] += 1 years_affected = self._get_affected_years( - verpachtung.pachtbeginn, - verpachtung.pachtende, - target_year + verpachtung.pachtbeginn, verpachtung.pachtende, target_year ) - + for year in years_affected: - stats['years_processed'].add(year) - rent_amount = self._calculate_new_rent_for_year(verpachtung, year) - umlage_amount = Decimal('0.00') # To be calculated later - + stats["years_processed"].add(year) + rent_amount = self._calculate_new_rent_for_year( + verpachtung, year + ) + umlage_amount = Decimal("0.00") # To be calculated later + if not dry_run: created, updated = self._update_abrechnung( verpachtung.land, @@ -123,131 +129,143 @@ class Command(BaseCommand): rent_amount, umlage_amount, f"LandVerpachtung {verpachtung.vertragsnummer}", - force + force, ) if created: - stats['abrechnungen_created'] += 1 + stats["abrechnungen_created"] += 1 if updated: - stats['abrechnungen_updated'] += 1 - - stats['total_rent_amount'] += rent_amount - + stats["abrechnungen_updated"] += 1 + + stats["total_rent_amount"] += rent_amount + self.stdout.write( f" 📊 {verpachtung.vertragsnummer} ({year}): {rent_amount:.2f}€" ) - + if dry_run: # Rollback transaction in dry run transaction.set_rollback(True) - + except Exception as e: self.stdout.write( - self.style.ERROR(f'❌ Error during synchronization: {str(e)}') + self.style.ERROR(f"❌ Error during synchronization: {str(e)}") ) - raise CommandError(f'Synchronization failed: {str(e)}') - + raise CommandError(f"Synchronization failed: {str(e)}") + # Print summary - self.stdout.write('\n' + '='*50) - self.stdout.write(self.style.SUCCESS('📈 SYNCHRONIZATION SUMMARY')) - self.stdout.write('='*50) + self.stdout.write("\n" + "=" * 50) + self.stdout.write(self.style.SUCCESS("📈 SYNCHRONIZATION SUMMARY")) + self.stdout.write("=" * 50) self.stdout.write(f"Legacy contracts processed: {stats['legacy_contracts']}") self.stdout.write(f"New contracts processed: {stats['new_contracts']}") - self.stdout.write(f"Years affected: {', '.join(map(str, sorted(stats['years_processed'])))}") + self.stdout.write( + f"Years affected: {', '.join(map(str, sorted(stats['years_processed'])))}" + ) self.stdout.write(f"Abrechnungen created: {stats['abrechnungen_created']}") self.stdout.write(f"Abrechnungen updated: {stats['abrechnungen_updated']}") self.stdout.write(f"Total rent amount: {stats['total_rent_amount']:.2f}€") - + if dry_run: - self.stdout.write(self.style.WARNING('\n📋 This was a DRY RUN - no changes were saved')) + self.stdout.write( + self.style.WARNING("\n📋 This was a DRY RUN - no changes were saved") + ) else: - self.stdout.write(self.style.SUCCESS('\n✅ Synchronization completed successfully!')) + self.stdout.write( + self.style.SUCCESS("\n✅ Synchronization completed successfully!") + ) def _get_affected_years(self, start_date, end_date, target_year=None): """Get all years affected by a contract""" if not start_date: return [] - + years = [] start_year = start_date.year end_year = end_date.year if end_date else date.today().year - + if target_year: if start_year <= target_year <= end_year: return [target_year] else: return [] - + for year in range(start_year, end_year + 1): years.append(year) - + return years - + def _calculate_legacy_rent_for_year(self, verpachtung, year): """Calculate rent for legacy Verpachtung for specific year""" if not verpachtung.pachtzins_jaehrlich or not verpachtung.pachtbeginn: - return Decimal('0.00') - + return Decimal("0.00") + year_start = date(year, 1, 1) year_end = date(year, 12, 31) - - contract_end_date = verpachtung.verlaengerung if verpachtung.verlaengerung else verpachtung.pachtende + + contract_end_date = ( + verpachtung.verlaengerung + if verpachtung.verlaengerung + else verpachtung.pachtende + ) contract_start = max(verpachtung.pachtbeginn, year_start) contract_end = min(contract_end_date or year_end, year_end) - + if contract_start > contract_end: - return Decimal('0.00') - + return Decimal("0.00") + days_in_year = (year_end - year_start).days + 1 days_active = (contract_end - contract_start).days + 1 proportion = Decimal(str(days_active)) / Decimal(str(days_in_year)) - + return Decimal(str(verpachtung.pachtzins_jaehrlich)) * proportion - + def _calculate_new_rent_for_year(self, verpachtung, year): """Calculate rent for new LandVerpachtung for specific year""" if not verpachtung.pachtzins_pauschal or not verpachtung.pachtbeginn: - return Decimal('0.00') - + return Decimal("0.00") + year_start = date(year, 1, 1) year_end = date(year, 12, 31) - + contract_start = max(verpachtung.pachtbeginn, year_start) contract_end = min(verpachtung.pachtende or year_end, year_end) - + if contract_start > contract_end: - return Decimal('0.00') - + return Decimal("0.00") + days_in_year = (year_end - year_start).days + 1 days_active = (contract_end - contract_start).days + 1 proportion = Decimal(str(days_active)) / Decimal(str(days_in_year)) - + return Decimal(str(verpachtung.pachtzins_pauschal)) * proportion - - def _update_abrechnung(self, land, year, rent_amount, umlage_amount, source_note, force): + + def _update_abrechnung( + self, land, year, rent_amount, umlage_amount, source_note, force + ): """Update or create Abrechnung for specific land and year""" abrechnung, created = LandAbrechnung.objects.get_or_create( land=land, abrechnungsjahr=year, defaults={ - 'pacht_vereinnahmt': rent_amount, - 'umlagen_vereinnahmt': umlage_amount, - 'bemerkungen': f'[{date.today().strftime("%d.%m.%Y")}] Automatisch synchronisiert von {source_note}' - } + "pacht_vereinnahmt": rent_amount, + "umlagen_vereinnahmt": umlage_amount, + "bemerkungen": f'[{date.today().strftime("%d.%m.%Y")}] Automatisch synchronisiert von {source_note}', + }, ) - + updated = False if not created and force: # Update existing abrechnung.pacht_vereinnahmt += rent_amount abrechnung.umlagen_vereinnahmt += umlage_amount - + sync_note = f'[{date.today().strftime("%d.%m.%Y")}] Resync: +{rent_amount:.2f}€ von {source_note}' if abrechnung.bemerkungen: - abrechnung.bemerkungen += f'\n{sync_note}' + abrechnung.bemerkungen += f"\n{sync_note}" else: abrechnung.bemerkungen = sync_note - + abrechnung.save() updated = True - + return created, updated diff --git a/app/stiftung/management/commands/unify_verpachtungen.py b/app/stiftung/management/commands/unify_verpachtungen.py index eb36baa..95a2a01 100644 --- a/app/stiftung/management/commands/unify_verpachtungen.py +++ b/app/stiftung/management/commands/unify_verpachtungen.py @@ -1,111 +1,127 @@ +import logging +from datetime import datetime + from django.core.management.base import BaseCommand from django.db import transaction -from stiftung.models import Land, Verpachtung, Paechter, LandAbrechnung -from datetime import datetime -import logging + +from stiftung.models import Land, LandAbrechnung, Paechter, Verpachtung logger = logging.getLogger(__name__) + class Command(BaseCommand): - help = 'Vereinheitlicht Verpachtungen, Land und Abrechnungen zu einem konsistenten System' + help = "Vereinheitlicht Verpachtungen, Land und Abrechnungen zu einem konsistenten System" def add_arguments(self, parser): parser.add_argument( - '--dry-run', - action='store_true', - help='Zeigt nur an, was gemacht würde, ohne Änderungen zu speichern', + "--dry-run", + action="store_true", + help="Zeigt nur an, was gemacht würde, ohne Änderungen zu speichern", ) parser.add_argument( - '--create-abrechnungen', - action='store_true', - help='Erstellt automatisch Abrechnungen aus Verpachtungsdaten', + "--create-abrechnungen", + action="store_true", + help="Erstellt automatisch Abrechnungen aus Verpachtungsdaten", ) def handle(self, *args, **options): - dry_run = options['dry_run'] - create_abrechnungen = options['create_abrechnungen'] - + dry_run = options["dry_run"] + create_abrechnungen = options["create_abrechnungen"] + if dry_run: - self.stdout.write(self.style.WARNING('DRY RUN - Keine Änderungen werden gespeichert!')) - + self.stdout.write( + self.style.WARNING("DRY RUN - Keine Änderungen werden gespeichert!") + ) + # Schritt 1: Alle Verpachtungen analysieren - alle_verpachtungen = Verpachtung.objects.all().order_by('land', '-pachtbeginn') - self.stdout.write(f'Gefunden: {alle_verpachtungen.count()} Verpachtungen insgesamt') - + alle_verpachtungen = Verpachtung.objects.all().order_by("land", "-pachtbeginn") + self.stdout.write( + f"Gefunden: {alle_verpachtungen.count()} Verpachtungen insgesamt" + ) + land_updates = 0 abrechnungen_created = 0 - + with transaction.atomic(): current_land = None - + for verpachtung in alle_verpachtungen: land = verpachtung.land - + # Für jedes Land nur die neueste aktive Verpachtung als "aktuell" setzen if current_land != land: current_land = land - + # Prüfen ob dies die neueste aktive Verpachtung ist - if verpachtung.status == 'aktiv' and not land.aktueller_paechter: - self.stdout.write(f'Setze aktuelle Verpachtung: {land} -> {verpachtung.paechter}') - + if verpachtung.status == "aktiv" and not land.aktueller_paechter: + self.stdout.write( + f"Setze aktuelle Verpachtung: {land} -> {verpachtung.paechter}" + ) + if not dry_run: # Land-Felder aktualisieren land.aktueller_paechter = verpachtung.paechter land.paechter_name = verpachtung.paechter.get_full_name() - land.paechter_anschrift = self._get_paechter_anschrift(verpachtung.paechter) + land.paechter_anschrift = self._get_paechter_anschrift( + verpachtung.paechter + ) land.pachtbeginn = verpachtung.pachtbeginn land.pachtende = verpachtung.pachtende land.verlaengerung_klausel = bool(verpachtung.verlaengerung) land.pachtzins_pauschal = verpachtung.pachtzins_jaehrlich - + # Verpachtete Fläche synchronisieren land.verp_flaeche_aktuell = verpachtung.verpachtete_flaeche - + land.save() land_updates += 1 - + # Schritt 2: Abrechnungen aus Verpachtungen erstellen (optional) - if create_abrechnungen and verpachtung.status == 'aktiv': + if create_abrechnungen and verpachtung.status == "aktiv": # Erstelle Abrechnungen für die letzten 3 Jahre current_year = datetime.now().year for jahr in range(current_year - 2, current_year + 1): - + # Prüfen ob Abrechnung bereits existiert existing = LandAbrechnung.objects.filter( - land=land, - abrechnungsjahr=jahr + land=land, abrechnungsjahr=jahr ).first() - + if not existing: - self.stdout.write(f'Erstelle Abrechnung: {land} - {jahr}') - + self.stdout.write(f"Erstelle Abrechnung: {land} - {jahr}") + if not dry_run: abrechnung = LandAbrechnung.objects.create( land=land, abrechnungsjahr=jahr, pacht_vereinnahmt=verpachtung.pachtzins_jaehrlich, - bemerkungen=f'Automatisch erstellt aus Verpachtung {verpachtung.vertragsnummer}' + bemerkungen=f"Automatisch erstellt aus Verpachtung {verpachtung.vertragsnummer}", ) abrechnungen_created += 1 - + # Zusammenfassung - self.stdout.write(self.style.SUCCESS('\n=== MIGRATION ABGESCHLOSSEN ===')) + self.stdout.write(self.style.SUCCESS("\n=== MIGRATION ABGESCHLOSSEN ===")) if dry_run: - self.stdout.write(f'DRY RUN: {land_updates} Länder würden aktualisiert') + self.stdout.write(f"DRY RUN: {land_updates} Länder würden aktualisiert") if create_abrechnungen: - self.stdout.write(f'DRY RUN: {abrechnungen_created} Abrechnungen würden erstellt') + self.stdout.write( + f"DRY RUN: {abrechnungen_created} Abrechnungen würden erstellt" + ) else: - self.stdout.write(f'✓ {land_updates} Länder aktualisiert') + self.stdout.write(f"✓ {land_updates} Länder aktualisiert") if create_abrechnungen: - self.stdout.write(f'✓ {abrechnungen_created} Abrechnungen erstellt') - + self.stdout.write(f"✓ {abrechnungen_created} Abrechnungen erstellt") + # Empfehlungen - self.stdout.write(self.style.WARNING('\n=== NÄCHSTE SCHRITTE ===')) - self.stdout.write('1. Prüfen Sie die migrierten Daten in der Weboberfläche') - self.stdout.write('2. Alte Verpachtungs-Views können als "Legacy" markiert werden') - self.stdout.write('3. Neue Verpachtungen sollten direkt im Land-Model erstellt werden') - + self.stdout.write(self.style.WARNING("\n=== NÄCHSTE SCHRITTE ===")) + self.stdout.write("1. Prüfen Sie die migrierten Daten in der Weboberfläche") + self.stdout.write( + '2. Alte Verpachtungs-Views können als "Legacy" markiert werden' + ) + self.stdout.write( + "3. Neue Verpachtungen sollten direkt im Land-Model erstellt werden" + ) + def _get_paechter_anschrift(self, paechter): """Erstellt eine Anschrift aus den Pächter-Daten""" parts = [] @@ -115,5 +131,5 @@ class Command(BaseCommand): parts.append(f"{paechter.plz} {paechter.ort}") elif paechter.ort: parts.append(paechter.ort) - - return '\n'.join(parts) if parts else '' + + return "\n".join(parts) if parts else "" diff --git a/app/stiftung/middleware.py b/app/stiftung/middleware.py index 3be3021..c99f033 100644 --- a/app/stiftung/middleware.py +++ b/app/stiftung/middleware.py @@ -4,11 +4,13 @@ Automatically tracks all model changes throughout the application """ import threading -from django.utils.deprecation import MiddlewareMixin + from django.contrib.auth.signals import user_logged_in, user_logged_out -from django.db.models.signals import post_save, post_delete, pre_save +from django.db.models.signals import post_delete, post_save, pre_save from django.dispatch import receiver -from stiftung.audit import log_action, track_model_changes, get_client_ip +from django.utils.deprecation import MiddlewareMixin + +from stiftung.audit import get_client_ip, log_action, track_model_changes # Thread-local storage for request context _local = threading.local() @@ -18,54 +20,54 @@ class AuditMiddleware(MiddlewareMixin): """ Middleware that sets up request context for audit logging """ - + def process_request(self, request): """Store request in thread-local storage for access in signal handlers""" _local.request = request _local.user_changes = {} # Store pre-save state for change tracking return None - + def process_response(self, request, response): """Clean up thread-local storage""" - if hasattr(_local, 'request'): - delattr(_local, 'request') - if hasattr(_local, 'user_changes'): - delattr(_local, 'user_changes') + if hasattr(_local, "request"): + delattr(_local, "request") + if hasattr(_local, "user_changes"): + delattr(_local, "user_changes") return response def get_current_request(): """Get the current request from thread-local storage""" - return getattr(_local, 'request', None) + return getattr(_local, "request", None) def get_entity_type_from_model(model): """Map Django model to audit entity type""" model_name = model.__name__.lower() - + mapping = { - 'destinataer': 'destinataer', - 'land': 'land', - 'paechter': 'paechter', - 'verpachtung': 'verpachtung', - 'foerderung': 'foerderung', - 'rentmeister': 'rentmeister', - 'stiftungskonto': 'stiftungskonto', - 'verwaltungskosten': 'verwaltungskosten', - 'banktransaction': 'banktransaction', - 'dokumentlink': 'dokumentlink', - 'user': 'user', - 'person': 'destinataer', # Legacy model maps to destinataer + "destinataer": "destinataer", + "land": "land", + "paechter": "paechter", + "verpachtung": "verpachtung", + "foerderung": "foerderung", + "rentmeister": "rentmeister", + "stiftungskonto": "stiftungskonto", + "verwaltungskosten": "verwaltungskosten", + "banktransaction": "banktransaction", + "dokumentlink": "dokumentlink", + "user": "user", + "person": "destinataer", # Legacy model maps to destinataer } - - return mapping.get(model_name, 'unknown') + + return mapping.get(model_name, "unknown") def get_entity_name(instance): """Get a human-readable name for an entity""" - if hasattr(instance, 'get_full_name') and callable(instance.get_full_name): + if hasattr(instance, "get_full_name") and callable(instance.get_full_name): return instance.get_full_name() - elif hasattr(instance, '__str__'): + elif hasattr(instance, "__str__"): return str(instance) else: return f"{instance.__class__.__name__} #{instance.pk}" @@ -76,22 +78,22 @@ def get_entity_name(instance): def store_pre_save_state(sender, instance, **kwargs): """Store the pre-save state for change tracking""" request = get_current_request() - if not request or not hasattr(request, 'user'): + if not request or not hasattr(request, "user"): return - + # Skip if user is not authenticated if not request.user.is_authenticated: return - + # Skip audit log entries themselves to avoid infinite loops - if sender.__name__ == 'AuditLog': + if sender.__name__ == "AuditLog": return - + # Store the current state if this is an update if instance.pk: try: old_instance = sender.objects.get(pk=instance.pk) - if not hasattr(_local, 'user_changes'): + if not hasattr(_local, "user_changes"): _local.user_changes = {} _local.user_changes[instance.pk] = old_instance except sender.DoesNotExist: @@ -102,53 +104,53 @@ def store_pre_save_state(sender, instance, **kwargs): def log_model_save(sender, instance, created, **kwargs): """Log model creation and updates""" request = get_current_request() - if not request or not hasattr(request, 'user'): + if not request or not hasattr(request, "user"): return - + # Skip if user is not authenticated if not request.user.is_authenticated: return - + # Skip audit log entries themselves to avoid infinite loops - if sender.__name__ == 'AuditLog': + if sender.__name__ == "AuditLog": return - + # Skip certain system models - if sender.__name__ in ['Session', 'LogEntry', 'ContentType', 'Permission']: + if sender.__name__ in ["Session", "LogEntry", "ContentType", "Permission"]: return - + entity_type = get_entity_type_from_model(sender) entity_name = get_entity_name(instance) entity_id = str(instance.pk) - + if created: # Log creation description = f"Neue {entity_type.replace('_', ' ').title()} '{entity_name}' wurde erstellt" log_action( request=request, - action='create', + action="create", entity_type=entity_type, entity_id=entity_id, entity_name=entity_name, - description=description + description=description, ) else: # Log update with changes changes = {} - if hasattr(_local, 'user_changes') and instance.pk in _local.user_changes: + if hasattr(_local, "user_changes") and instance.pk in _local.user_changes: old_instance = _local.user_changes[instance.pk] changes = track_model_changes(old_instance, instance) - + if changes: # Only log if there are actual changes description = f"{entity_type.replace('_', ' ').title()} '{entity_name}' wurde aktualisiert" log_action( request=request, - action='update', + action="update", entity_type=entity_type, entity_id=entity_id, entity_name=entity_name, description=description, - changes=changes + changes=changes, ) @@ -156,33 +158,35 @@ def log_model_save(sender, instance, created, **kwargs): def log_model_delete(sender, instance, **kwargs): """Log model deletion""" request = get_current_request() - if not request or not hasattr(request, 'user'): + if not request or not hasattr(request, "user"): return - + # Skip if user is not authenticated if not request.user.is_authenticated: return - + # Skip audit log entries themselves - if sender.__name__ == 'AuditLog': + if sender.__name__ == "AuditLog": return - + # Skip certain system models - if sender.__name__ in ['Session', 'LogEntry', 'ContentType', 'Permission']: + if sender.__name__ in ["Session", "LogEntry", "ContentType", "Permission"]: return - + entity_type = get_entity_type_from_model(sender) entity_name = get_entity_name(instance) entity_id = str(instance.pk) - - description = f"{entity_type.replace('_', ' ').title()} '{entity_name}' wurde gelöscht" + + description = ( + f"{entity_type.replace('_', ' ').title()} '{entity_name}' wurde gelöscht" + ) log_action( request=request, - action='delete', + action="delete", entity_type=entity_type, entity_id=entity_id, entity_name=entity_name, - description=description + description=description, ) @@ -192,11 +196,11 @@ def log_user_login(sender, request, user, **kwargs): """Log user login""" log_action( request=request, - action='login', - entity_type='user', + action="login", + entity_type="user", entity_id=str(user.pk), entity_name=user.username, - description=f"Benutzer {user.username} hat sich angemeldet" + description=f"Benutzer {user.username} hat sich angemeldet", ) @@ -206,9 +210,9 @@ def log_user_logout(sender, request, user, **kwargs): if user: # user might be None if session expired log_action( request=request, - action='logout', - entity_type='user', + action="logout", + entity_type="user", entity_id=str(user.pk), entity_name=user.username, - description=f"Benutzer {user.username} hat sich abgemeldet" + description=f"Benutzer {user.username} hat sich abgemeldet", ) diff --git a/app/stiftung/migrations/0001_initial.py b/app/stiftung/migrations/0001_initial.py index cccf69c..b5c06ea 100644 --- a/app/stiftung/migrations/0001_initial.py +++ b/app/stiftung/migrations/0001_initial.py @@ -1,7 +1,8 @@ # Generated by Django 5.0.6 on 2025-08-13 20:59 -import django.db.models.deletion import uuid + +import django.db.models.deletion from django.db import migrations, models @@ -9,39 +10,76 @@ class Migration(migrations.Migration): initial = True - dependencies = [ - ] + dependencies = [] operations = [ migrations.CreateModel( - name='DokumentLink', + name="DokumentLink", fields=[ - ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), - ('paperless_document_id', models.IntegerField()), - ('kontext', models.CharField(max_length=30)), - ('titel', models.CharField(max_length=255)), + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("paperless_document_id", models.IntegerField()), + ("kontext", models.CharField(max_length=30)), + ("titel", models.CharField(max_length=255)), ], ), migrations.CreateModel( - name='Person', + name="Person", fields=[ - ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), - ('familienzweig', models.CharField(max_length=100)), - ('vorname', models.CharField(max_length=100)), - ('nachname', models.CharField(max_length=100)), - ('geburtsdatum', models.DateField(blank=True, null=True)), - ('email', models.EmailField(blank=True, max_length=254, null=True)), - ('iban', models.CharField(blank=True, max_length=34, null=True)), + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("familienzweig", models.CharField(max_length=100)), + ("vorname", models.CharField(max_length=100)), + ("nachname", models.CharField(max_length=100)), + ("geburtsdatum", models.DateField(blank=True, null=True)), + ("email", models.EmailField(blank=True, max_length=254, null=True)), + ("iban", models.CharField(blank=True, max_length=34, null=True)), ], ), migrations.CreateModel( - name='Foerderung', + name="Foerderung", fields=[ - ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), - ('jahr', models.IntegerField()), - ('betrag', models.DecimalField(decimal_places=2, max_digits=12)), - ('verwendungsnachweis', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='stiftung.dokumentlink')), - ('person', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='stiftung.person')), + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("jahr", models.IntegerField()), + ("betrag", models.DecimalField(decimal_places=2, max_digits=12)), + ( + "verwendungsnachweis", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="stiftung.dokumentlink", + ), + ), + ( + "person", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="stiftung.person", + ), + ), ], ), ] diff --git a/app/stiftung/migrations/0002_alter_dokumentlink_options_alter_foerderung_options_and_more.py b/app/stiftung/migrations/0002_alter_dokumentlink_options_alter_foerderung_options_and_more.py index ab07d72..ed4ee0e 100644 --- a/app/stiftung/migrations/0002_alter_dokumentlink_options_alter_foerderung_options_and_more.py +++ b/app/stiftung/migrations/0002_alter_dokumentlink_options_alter_foerderung_options_and_more.py @@ -9,104 +9,172 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('stiftung', '0001_initial'), + ("stiftung", "0001_initial"), ] operations = [ migrations.AlterModelOptions( - name='dokumentlink', - options={'ordering': ['titel'], 'verbose_name': 'Dokument', 'verbose_name_plural': 'Dokumente'}, + name="dokumentlink", + options={ + "ordering": ["titel"], + "verbose_name": "Dokument", + "verbose_name_plural": "Dokumente", + }, ), migrations.AlterModelOptions( - name='foerderung', - options={'ordering': ['-jahr', '-betrag'], 'verbose_name': 'Förderung', 'verbose_name_plural': 'Förderungen'}, + name="foerderung", + options={ + "ordering": ["-jahr", "-betrag"], + "verbose_name": "Förderung", + "verbose_name_plural": "Förderungen", + }, ), migrations.AlterModelOptions( - name='person', - options={'ordering': ['nachname', 'vorname'], 'verbose_name': 'Person', 'verbose_name_plural': 'Personen'}, + name="person", + options={ + "ordering": ["nachname", "vorname"], + "verbose_name": "Person", + "verbose_name_plural": "Personen", + }, ), migrations.AddField( - model_name='dokumentlink', - name='beschreibung', + model_name="dokumentlink", + name="beschreibung", field=models.TextField(blank=True, null=True), ), migrations.AddField( - model_name='foerderung', - name='antragsdatum', + model_name="foerderung", + name="antragsdatum", field=models.DateField(default=django.utils.timezone.now), ), migrations.AddField( - model_name='foerderung', - name='bemerkungen', + model_name="foerderung", + name="bemerkungen", field=models.TextField(blank=True, null=True), ), migrations.AddField( - model_name='foerderung', - name='entscheidungsdatum', + model_name="foerderung", + name="entscheidungsdatum", field=models.DateField(blank=True, null=True), ), migrations.AddField( - model_name='foerderung', - name='kategorie', - field=models.CharField(choices=[('bildung', 'Bildung'), ('forschung', 'Forschung'), ('kultur', 'Kultur'), ('soziales', 'Soziales'), ('umwelt', 'Umwelt'), ('anderes', 'Anderes')], default='anderes', max_length=20), + model_name="foerderung", + name="kategorie", + field=models.CharField( + choices=[ + ("bildung", "Bildung"), + ("forschung", "Forschung"), + ("kultur", "Kultur"), + ("soziales", "Soziales"), + ("umwelt", "Umwelt"), + ("anderes", "Anderes"), + ], + default="anderes", + max_length=20, + ), ), migrations.AddField( - model_name='foerderung', - name='status', - field=models.CharField(choices=[('beantragt', 'Beantragt'), ('genehmigt', 'Genehmigt'), ('ausgezahlt', 'Ausgezahlt'), ('abgelehnt', 'Abgelehnt'), ('storniert', 'Storniert')], default='beantragt', max_length=20), + model_name="foerderung", + name="status", + field=models.CharField( + choices=[ + ("beantragt", "Beantragt"), + ("genehmigt", "Genehmigt"), + ("ausgezahlt", "Ausgezahlt"), + ("abgelehnt", "Abgelehnt"), + ("storniert", "Storniert"), + ], + default="beantragt", + max_length=20, + ), ), migrations.AddField( - model_name='person', - name='adresse', + model_name="person", + name="adresse", field=models.TextField(blank=True, null=True), ), migrations.AddField( - model_name='person', - name='aktiv', + model_name="person", + name="aktiv", field=models.BooleanField(default=True), ), migrations.AddField( - model_name='person', - name='notizen', + model_name="person", + name="notizen", field=models.TextField(blank=True, null=True), ), migrations.AddField( - model_name='person', - name='telefon', + model_name="person", + name="telefon", field=models.CharField(blank=True, max_length=20, null=True), ), migrations.AlterField( - model_name='dokumentlink', - name='kontext', - field=models.CharField(choices=[('antrag', 'Antrag'), ('verwendungsnachweis', 'Verwendungsnachweis'), ('rechnung', 'Rechnung'), ('vertrag', 'Vertrag'), ('bericht', 'Bericht'), ('anderes', 'Anderes')], default='anderes', max_length=30), + model_name="dokumentlink", + name="kontext", + field=models.CharField( + choices=[ + ("antrag", "Antrag"), + ("verwendungsnachweis", "Verwendungsnachweis"), + ("rechnung", "Rechnung"), + ("vertrag", "Vertrag"), + ("bericht", "Bericht"), + ("anderes", "Anderes"), + ], + default="anderes", + max_length=30, + ), ), migrations.AlterField( - model_name='dokumentlink', - name='paperless_document_id', + model_name="dokumentlink", + name="paperless_document_id", field=models.IntegerField(unique=True), ), migrations.AlterField( - model_name='foerderung', - name='jahr', - field=models.IntegerField(validators=[django.core.validators.MinValueValidator(1900), django.core.validators.MaxValueValidator(2100)]), + model_name="foerderung", + name="jahr", + field=models.IntegerField( + validators=[ + django.core.validators.MinValueValidator(1900), + django.core.validators.MaxValueValidator(2100), + ] + ), ), migrations.AlterField( - model_name='foerderung', - name='person', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='stiftung.person', verbose_name='Person'), + model_name="foerderung", + name="person", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="stiftung.person", + verbose_name="Person", + ), ), migrations.AlterField( - model_name='foerderung', - name='verwendungsnachweis', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='stiftung.dokumentlink', verbose_name='Verwendungsnachweis'), + model_name="foerderung", + name="verwendungsnachweis", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="stiftung.dokumentlink", + verbose_name="Verwendungsnachweis", + ), ), migrations.AlterField( - model_name='person', - name='familienzweig', - field=models.CharField(choices=[('hauptzweig', 'Hauptzweig'), ('nebenzweig', 'Nebenzweig'), ('verwandt', 'Verwandt'), ('anderer', 'Anderer')], default='hauptzweig', max_length=100), + model_name="person", + name="familienzweig", + field=models.CharField( + choices=[ + ("hauptzweig", "Hauptzweig"), + ("nebenzweig", "Nebenzweig"), + ("verwandt", "Verwandt"), + ("anderer", "Anderer"), + ], + default="hauptzweig", + max_length=100, + ), ), migrations.AlterUniqueTogether( - name='foerderung', - unique_together={('person', 'jahr', 'kategorie')}, + name="foerderung", + unique_together={("person", "jahr", "kategorie")}, ), ] diff --git a/app/stiftung/migrations/0003_land_alter_dokumentlink_kontext_verpachtung.py b/app/stiftung/migrations/0003_land_alter_dokumentlink_kontext_verpachtung.py index 3dc7a23..f1cf9e3 100644 --- a/app/stiftung/migrations/0003_land_alter_dokumentlink_kontext_verpachtung.py +++ b/app/stiftung/migrations/0003_land_alter_dokumentlink_kontext_verpachtung.py @@ -1,78 +1,293 @@ # Generated by Django 5.0.6 on 2025-08-13 21:43 +import uuid + import django.core.validators import django.db.models.deletion -import uuid from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('stiftung', '0002_alter_dokumentlink_options_alter_foerderung_options_and_more'), + ( + "stiftung", + "0002_alter_dokumentlink_options_alter_foerderung_options_and_more", + ), ] operations = [ migrations.CreateModel( - name='Land', + name="Land", fields=[ - ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), - ('lfd_nr', models.CharField(max_length=20, unique=True, verbose_name='Lfd. Nr.')), - ('ew_nummer', models.CharField(blank=True, max_length=50, null=True, verbose_name='EW-Nummer')), - ('amtsgericht', models.CharField(max_length=100, verbose_name='Amtsgericht')), - ('gemeinde', models.CharField(max_length=100, verbose_name='Gemeinde')), - ('gemarkung', models.CharField(max_length=100, verbose_name='Gemarkung')), - ('flur', models.CharField(max_length=50, verbose_name='Flur')), - ('flurstueck', models.CharField(max_length=50, verbose_name='Flurstück')), - ('groesse_qm', models.DecimalField(decimal_places=2, max_digits=12, validators=[django.core.validators.MinValueValidator(0.01)], verbose_name='Größe in qm')), - ('gruenland_qm', models.DecimalField(decimal_places=2, default=0, max_digits=12, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Grünland (qm)')), - ('acker_qm', models.DecimalField(decimal_places=2, default=0, max_digits=12, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Acker (qm)')), - ('wald_qm', models.DecimalField(decimal_places=2, default=0, max_digits=12, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Wald (qm)')), - ('sonstiges_qm', models.DecimalField(decimal_places=2, default=0, max_digits=12, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Sonstiges (qm)')), - ('verpachtete_gesamtflaeche', models.DecimalField(decimal_places=2, max_digits=12, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Verpachtete Gesamtfläche (qm)')), - ('flaeche_alte_liste', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True, verbose_name='Fläche alte Liste (qm)')), - ('verp_flaeche_aktuell', models.DecimalField(decimal_places=2, max_digits=12, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Verp. Fläche aktuell (qm)')), - ('anteil_grundsteuer', models.DecimalField(blank=True, decimal_places=2, max_digits=8, null=True, verbose_name='Anteil Grundsteuer (%)')), - ('anteil_lwk', models.DecimalField(blank=True, decimal_places=2, max_digits=8, null=True, verbose_name='Anteil LWK (%)')), - ('aktiv', models.BooleanField(default=True, verbose_name='Aktiv')), - ('notizen', models.TextField(blank=True, null=True, verbose_name='Ergänzende Kommentare')), - ('erstellt_am', models.DateTimeField(auto_now_add=True)), - ('aktualisiert_am', models.DateTimeField(auto_now=True)), + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ( + "lfd_nr", + models.CharField( + max_length=20, unique=True, verbose_name="Lfd. Nr." + ), + ), + ( + "ew_nummer", + models.CharField( + blank=True, max_length=50, null=True, verbose_name="EW-Nummer" + ), + ), + ( + "amtsgericht", + models.CharField(max_length=100, verbose_name="Amtsgericht"), + ), + ("gemeinde", models.CharField(max_length=100, verbose_name="Gemeinde")), + ( + "gemarkung", + models.CharField(max_length=100, verbose_name="Gemarkung"), + ), + ("flur", models.CharField(max_length=50, verbose_name="Flur")), + ( + "flurstueck", + models.CharField(max_length=50, verbose_name="Flurstück"), + ), + ( + "groesse_qm", + models.DecimalField( + decimal_places=2, + max_digits=12, + validators=[django.core.validators.MinValueValidator(0.01)], + verbose_name="Größe in qm", + ), + ), + ( + "gruenland_qm", + models.DecimalField( + decimal_places=2, + default=0, + max_digits=12, + validators=[django.core.validators.MinValueValidator(0)], + verbose_name="Grünland (qm)", + ), + ), + ( + "acker_qm", + models.DecimalField( + decimal_places=2, + default=0, + max_digits=12, + validators=[django.core.validators.MinValueValidator(0)], + verbose_name="Acker (qm)", + ), + ), + ( + "wald_qm", + models.DecimalField( + decimal_places=2, + default=0, + max_digits=12, + validators=[django.core.validators.MinValueValidator(0)], + verbose_name="Wald (qm)", + ), + ), + ( + "sonstiges_qm", + models.DecimalField( + decimal_places=2, + default=0, + max_digits=12, + validators=[django.core.validators.MinValueValidator(0)], + verbose_name="Sonstiges (qm)", + ), + ), + ( + "verpachtete_gesamtflaeche", + models.DecimalField( + decimal_places=2, + max_digits=12, + validators=[django.core.validators.MinValueValidator(0)], + verbose_name="Verpachtete Gesamtfläche (qm)", + ), + ), + ( + "flaeche_alte_liste", + models.DecimalField( + blank=True, + decimal_places=2, + max_digits=12, + null=True, + verbose_name="Fläche alte Liste (qm)", + ), + ), + ( + "verp_flaeche_aktuell", + models.DecimalField( + decimal_places=2, + max_digits=12, + validators=[django.core.validators.MinValueValidator(0)], + verbose_name="Verp. Fläche aktuell (qm)", + ), + ), + ( + "anteil_grundsteuer", + models.DecimalField( + blank=True, + decimal_places=2, + max_digits=8, + null=True, + verbose_name="Anteil Grundsteuer (%)", + ), + ), + ( + "anteil_lwk", + models.DecimalField( + blank=True, + decimal_places=2, + max_digits=8, + null=True, + verbose_name="Anteil LWK (%)", + ), + ), + ("aktiv", models.BooleanField(default=True, verbose_name="Aktiv")), + ( + "notizen", + models.TextField( + blank=True, null=True, verbose_name="Ergänzende Kommentare" + ), + ), + ("erstellt_am", models.DateTimeField(auto_now_add=True)), + ("aktualisiert_am", models.DateTimeField(auto_now=True)), ], options={ - 'verbose_name': 'Land', - 'verbose_name_plural': 'Ländereien', - 'ordering': ['gemeinde', 'gemarkung', 'flur', 'flurstueck'], + "verbose_name": "Land", + "verbose_name_plural": "Ländereien", + "ordering": ["gemeinde", "gemarkung", "flur", "flurstueck"], }, ), migrations.AlterField( - model_name='dokumentlink', - name='kontext', - field=models.CharField(choices=[('antrag', 'Antrag'), ('verwendungsnachweis', 'Verwendungsnachweis'), ('rechnung', 'Rechnung'), ('vertrag', 'Vertrag'), ('bericht', 'Bericht'), ('landkarte', 'Landkarte'), ('kataster', 'Kataster'), ('anderes', 'Anderes')], default='anderes', max_length=30), + model_name="dokumentlink", + name="kontext", + field=models.CharField( + choices=[ + ("antrag", "Antrag"), + ("verwendungsnachweis", "Verwendungsnachweis"), + ("rechnung", "Rechnung"), + ("vertrag", "Vertrag"), + ("bericht", "Bericht"), + ("landkarte", "Landkarte"), + ("kataster", "Kataster"), + ("anderes", "Anderes"), + ], + default="anderes", + max_length=30, + ), ), migrations.CreateModel( - name='Verpachtung', + name="Verpachtung", fields=[ - ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), - ('vertragsnummer', models.CharField(max_length=50, unique=True, verbose_name='Vertragsnummer')), - ('pachtbeginn', models.DateField(verbose_name='Pachtbeginn')), - ('pachtende', models.DateField(verbose_name='Pachtende')), - ('verlaengerung', models.DateField(blank=True, null=True, verbose_name='Verlängerung bis')), - ('pachtzins_pro_qm', models.DecimalField(decimal_places=4, max_digits=8, verbose_name='Pachtzins pro qm (€)')), - ('pachtzins_jaehrlich', models.DecimalField(decimal_places=2, max_digits=12, verbose_name='Jährlicher Pachtzins (€)')), - ('verpachtete_flaeche', models.DecimalField(decimal_places=2, max_digits=12, verbose_name='Verpachtete Fläche (qm)')), - ('status', models.CharField(choices=[('aktiv', 'Aktiv'), ('beendet', 'Beendet'), ('gekuendigt', 'Gekündigt'), ('verlängert', 'Verlängert')], default='aktiv', max_length=20)), - ('bemerkungen', models.TextField(blank=True, null=True, verbose_name='Ergänzende Kommentare')), - ('erstellt_am', models.DateTimeField(auto_now_add=True)), - ('aktualisiert_am', models.DateTimeField(auto_now=True)), - ('land', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='stiftung.land', verbose_name='Land')), - ('paechter', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='stiftung.person', verbose_name='Pächter')), - ('verwendungsnachweis', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='stiftung.dokumentlink', verbose_name='Verwendungsnachweis')), + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ( + "vertragsnummer", + models.CharField( + max_length=50, unique=True, verbose_name="Vertragsnummer" + ), + ), + ("pachtbeginn", models.DateField(verbose_name="Pachtbeginn")), + ("pachtende", models.DateField(verbose_name="Pachtende")), + ( + "verlaengerung", + models.DateField( + blank=True, null=True, verbose_name="Verlängerung bis" + ), + ), + ( + "pachtzins_pro_qm", + models.DecimalField( + decimal_places=4, + max_digits=8, + verbose_name="Pachtzins pro qm (€)", + ), + ), + ( + "pachtzins_jaehrlich", + models.DecimalField( + decimal_places=2, + max_digits=12, + verbose_name="Jährlicher Pachtzins (€)", + ), + ), + ( + "verpachtete_flaeche", + models.DecimalField( + decimal_places=2, + max_digits=12, + verbose_name="Verpachtete Fläche (qm)", + ), + ), + ( + "status", + models.CharField( + choices=[ + ("aktiv", "Aktiv"), + ("beendet", "Beendet"), + ("gekuendigt", "Gekündigt"), + ("verlängert", "Verlängert"), + ], + default="aktiv", + max_length=20, + ), + ), + ( + "bemerkungen", + models.TextField( + blank=True, null=True, verbose_name="Ergänzende Kommentare" + ), + ), + ("erstellt_am", models.DateTimeField(auto_now_add=True)), + ("aktualisiert_am", models.DateTimeField(auto_now=True)), + ( + "land", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="stiftung.land", + verbose_name="Land", + ), + ), + ( + "paechter", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="stiftung.person", + verbose_name="Pächter", + ), + ), + ( + "verwendungsnachweis", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="stiftung.dokumentlink", + verbose_name="Verwendungsnachweis", + ), + ), ], options={ - 'verbose_name': 'Verpachtung', - 'verbose_name_plural': 'Verpachtungen', - 'ordering': ['-pachtbeginn'], + "verbose_name": "Verpachtung", + "verbose_name_plural": "Verpachtungen", + "ordering": ["-pachtbeginn"], }, ), ] diff --git a/app/stiftung/migrations/0004_csvimport.py b/app/stiftung/migrations/0004_csvimport.py index 470c44a..a070cc9 100644 --- a/app/stiftung/migrations/0004_csvimport.py +++ b/app/stiftung/migrations/0004_csvimport.py @@ -1,36 +1,106 @@ # Generated by Django 5.0.6 on 2025-08-13 22:18 import uuid + from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('stiftung', '0003_land_alter_dokumentlink_kontext_verpachtung'), + ("stiftung", "0003_land_alter_dokumentlink_kontext_verpachtung"), ] operations = [ migrations.CreateModel( - name='CSVImport', + name="CSVImport", fields=[ - ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), - ('import_type', models.CharField(choices=[('personen', 'Personen'), ('laendereien', 'Ländereien'), ('verpachtungen', 'Verpachtungen')], max_length=20, verbose_name='Import-Typ')), - ('filename', models.CharField(max_length=255, verbose_name='Dateiname')), - ('file_size', models.IntegerField(verbose_name='Dateigröße (Bytes)')), - ('status', models.CharField(choices=[('pending', 'Ausstehend'), ('processing', 'Wird verarbeitet'), ('completed', 'Abgeschlossen'), ('failed', 'Fehlgeschlagen'), ('partial', 'Teilweise erfolgreich')], default='pending', max_length=20)), - ('total_rows', models.IntegerField(default=0, verbose_name='Gesamtzeilen')), - ('imported_rows', models.IntegerField(default=0, verbose_name='Importierte Zeilen')), - ('failed_rows', models.IntegerField(default=0, verbose_name='Fehlgeschlagene Zeilen')), - ('error_log', models.TextField(blank=True, null=True, verbose_name='Fehlerprotokoll')), - ('created_by', models.CharField(blank=True, max_length=100, null=True, verbose_name='Erstellt von')), - ('started_at', models.DateTimeField(auto_now_add=True, verbose_name='Gestartet um')), - ('completed_at', models.DateTimeField(blank=True, null=True, verbose_name='Abgeschlossen um')), + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ( + "import_type", + models.CharField( + choices=[ + ("personen", "Personen"), + ("laendereien", "Ländereien"), + ("verpachtungen", "Verpachtungen"), + ], + max_length=20, + verbose_name="Import-Typ", + ), + ), + ( + "filename", + models.CharField(max_length=255, verbose_name="Dateiname"), + ), + ("file_size", models.IntegerField(verbose_name="Dateigröße (Bytes)")), + ( + "status", + models.CharField( + choices=[ + ("pending", "Ausstehend"), + ("processing", "Wird verarbeitet"), + ("completed", "Abgeschlossen"), + ("failed", "Fehlgeschlagen"), + ("partial", "Teilweise erfolgreich"), + ], + default="pending", + max_length=20, + ), + ), + ( + "total_rows", + models.IntegerField(default=0, verbose_name="Gesamtzeilen"), + ), + ( + "imported_rows", + models.IntegerField(default=0, verbose_name="Importierte Zeilen"), + ), + ( + "failed_rows", + models.IntegerField( + default=0, verbose_name="Fehlgeschlagene Zeilen" + ), + ), + ( + "error_log", + models.TextField( + blank=True, null=True, verbose_name="Fehlerprotokoll" + ), + ), + ( + "created_by", + models.CharField( + blank=True, + max_length=100, + null=True, + verbose_name="Erstellt von", + ), + ), + ( + "started_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Gestartet um" + ), + ), + ( + "completed_at", + models.DateTimeField( + blank=True, null=True, verbose_name="Abgeschlossen um" + ), + ), ], options={ - 'verbose_name': 'CSV Import', - 'verbose_name_plural': 'CSV Imports', - 'ordering': ['-started_at'], + "verbose_name": "CSV Import", + "verbose_name_plural": "CSV Imports", + "ordering": ["-started_at"], }, ), ] diff --git a/app/stiftung/migrations/0005_destinataer_paechter_alter_person_options_and_more.py b/app/stiftung/migrations/0005_destinataer_paechter_alter_person_options_and_more.py index a2b2d1b..375d539 100644 --- a/app/stiftung/migrations/0005_destinataer_paechter_alter_person_options_and_more.py +++ b/app/stiftung/migrations/0005_destinataer_paechter_alter_person_options_and_more.py @@ -1,93 +1,298 @@ # Generated by Django 5.0.6 on 2025-08-14 10:38 -import django.db.models.deletion import uuid + +import django.db.models.deletion from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('stiftung', '0004_csvimport'), + ("stiftung", "0004_csvimport"), ] operations = [ migrations.CreateModel( - name='Destinataer', + name="Destinataer", fields=[ - ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), - ('familienzweig', models.CharField(choices=[('hauptzweig', 'Hauptzweig'), ('nebenzweig', 'Nebenzweig'), ('verwandt', 'Verwandt'), ('anderer', 'Anderer')], default='hauptzweig', max_length=100)), - ('vorname', models.CharField(max_length=100, verbose_name='Vorname')), - ('nachname', models.CharField(max_length=100, verbose_name='Nachname')), - ('geburtsdatum', models.DateField(blank=True, null=True, verbose_name='Geburtsdatum')), - ('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='E-Mail')), - ('telefon', models.CharField(blank=True, max_length=20, null=True, verbose_name='Telefon')), - ('iban', models.CharField(blank=True, max_length=34, null=True, verbose_name='IBAN')), - ('adresse', models.TextField(blank=True, null=True, verbose_name='Adresse')), - ('berufsgruppe', models.CharField(choices=[('student', 'Student/Studentin'), ('wissenschaftler', 'Wissenschaftler/in'), ('künstler', 'Künstler/in'), ('sozialarbeiter', 'Sozialarbeiter/in'), ('umweltschützer', 'Umweltschützer/in'), ('andere', 'Andere')], default='andere', max_length=20, verbose_name='Berufsgruppe')), - ('ausbildungsstand', models.CharField(blank=True, max_length=100, null=True, verbose_name='Ausbildungsstand')), - ('institution', models.CharField(blank=True, max_length=200, null=True, verbose_name='Institution/Organisation')), - ('projekt_beschreibung', models.TextField(blank=True, null=True, verbose_name='Projektbeschreibung')), - ('jaehrliches_einkommen', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True, verbose_name='Jährliches Einkommen (€)')), - ('finanzielle_notlage', models.BooleanField(default=False, verbose_name='Finanzielle Notlage')), - ('notizen', models.TextField(blank=True, null=True, verbose_name='Notizen')), - ('aktiv', models.BooleanField(default=True, verbose_name='Aktiv')), + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ( + "familienzweig", + models.CharField( + choices=[ + ("hauptzweig", "Hauptzweig"), + ("nebenzweig", "Nebenzweig"), + ("verwandt", "Verwandt"), + ("anderer", "Anderer"), + ], + default="hauptzweig", + max_length=100, + ), + ), + ("vorname", models.CharField(max_length=100, verbose_name="Vorname")), + ("nachname", models.CharField(max_length=100, verbose_name="Nachname")), + ( + "geburtsdatum", + models.DateField( + blank=True, null=True, verbose_name="Geburtsdatum" + ), + ), + ( + "email", + models.EmailField( + blank=True, max_length=254, null=True, verbose_name="E-Mail" + ), + ), + ( + "telefon", + models.CharField( + blank=True, max_length=20, null=True, verbose_name="Telefon" + ), + ), + ( + "iban", + models.CharField( + blank=True, max_length=34, null=True, verbose_name="IBAN" + ), + ), + ( + "adresse", + models.TextField(blank=True, null=True, verbose_name="Adresse"), + ), + ( + "berufsgruppe", + models.CharField( + choices=[ + ("student", "Student/Studentin"), + ("wissenschaftler", "Wissenschaftler/in"), + ("künstler", "Künstler/in"), + ("sozialarbeiter", "Sozialarbeiter/in"), + ("umweltschützer", "Umweltschützer/in"), + ("andere", "Andere"), + ], + default="andere", + max_length=20, + verbose_name="Berufsgruppe", + ), + ), + ( + "ausbildungsstand", + models.CharField( + blank=True, + max_length=100, + null=True, + verbose_name="Ausbildungsstand", + ), + ), + ( + "institution", + models.CharField( + blank=True, + max_length=200, + null=True, + verbose_name="Institution/Organisation", + ), + ), + ( + "projekt_beschreibung", + models.TextField( + blank=True, null=True, verbose_name="Projektbeschreibung" + ), + ), + ( + "jaehrliches_einkommen", + models.DecimalField( + blank=True, + decimal_places=2, + max_digits=12, + null=True, + verbose_name="Jährliches Einkommen (€)", + ), + ), + ( + "finanzielle_notlage", + models.BooleanField( + default=False, verbose_name="Finanzielle Notlage" + ), + ), + ( + "notizen", + models.TextField(blank=True, null=True, verbose_name="Notizen"), + ), + ("aktiv", models.BooleanField(default=True, verbose_name="Aktiv")), ], options={ - 'verbose_name': 'Destinatär', - 'verbose_name_plural': 'Destinatäre', - 'ordering': ['nachname', 'vorname'], + "verbose_name": "Destinatär", + "verbose_name_plural": "Destinatäre", + "ordering": ["nachname", "vorname"], }, ), migrations.CreateModel( - name='Paechter', + name="Paechter", fields=[ - ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), - ('familienzweig', models.CharField(choices=[('hauptzweig', 'Hauptzweig'), ('nebenzweig', 'Nebenzweig'), ('verwandt', 'Verwandt'), ('anderer', 'Anderer')], default='hauptzweig', max_length=100)), - ('vorname', models.CharField(max_length=100, verbose_name='Vorname')), - ('nachname', models.CharField(max_length=100, verbose_name='Nachname')), - ('geburtsdatum', models.DateField(blank=True, null=True, verbose_name='Geburtsdatum')), - ('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='E-Mail')), - ('telefon', models.CharField(blank=True, max_length=20, null=True, verbose_name='Telefon')), - ('iban', models.CharField(blank=True, max_length=34, null=True, verbose_name='IBAN')), - ('adresse', models.TextField(blank=True, null=True, verbose_name='Adresse')), - ('pachtnummer', models.CharField(blank=True, max_length=50, null=True, verbose_name='Pachtnummer')), - ('pachtbeginn_erste', models.DateField(blank=True, null=True, verbose_name='Erster Pachtbeginn')), - ('pachtende_letzte', models.DateField(blank=True, null=True, verbose_name='Letztes Pachtende')), - ('pachtzins_aktuell', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True, verbose_name='Aktueller Pachtzins (€/Jahr)')), - ('landwirtschaftliche_ausbildung', models.BooleanField(default=False, verbose_name='Landwirtschaftliche Ausbildung')), - ('berufserfahrung_jahre', models.IntegerField(blank=True, null=True, verbose_name='Berufserfahrung (Jahre)')), - ('spezialisierung', models.CharField(blank=True, max_length=100, null=True, verbose_name='Spezialisierung')), - ('notizen', models.TextField(blank=True, null=True, verbose_name='Notizen')), - ('aktiv', models.BooleanField(default=True, verbose_name='Aktiv')), + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ( + "familienzweig", + models.CharField( + choices=[ + ("hauptzweig", "Hauptzweig"), + ("nebenzweig", "Nebenzweig"), + ("verwandt", "Verwandt"), + ("anderer", "Anderer"), + ], + default="hauptzweig", + max_length=100, + ), + ), + ("vorname", models.CharField(max_length=100, verbose_name="Vorname")), + ("nachname", models.CharField(max_length=100, verbose_name="Nachname")), + ( + "geburtsdatum", + models.DateField( + blank=True, null=True, verbose_name="Geburtsdatum" + ), + ), + ( + "email", + models.EmailField( + blank=True, max_length=254, null=True, verbose_name="E-Mail" + ), + ), + ( + "telefon", + models.CharField( + blank=True, max_length=20, null=True, verbose_name="Telefon" + ), + ), + ( + "iban", + models.CharField( + blank=True, max_length=34, null=True, verbose_name="IBAN" + ), + ), + ( + "adresse", + models.TextField(blank=True, null=True, verbose_name="Adresse"), + ), + ( + "pachtnummer", + models.CharField( + blank=True, max_length=50, null=True, verbose_name="Pachtnummer" + ), + ), + ( + "pachtbeginn_erste", + models.DateField( + blank=True, null=True, verbose_name="Erster Pachtbeginn" + ), + ), + ( + "pachtende_letzte", + models.DateField( + blank=True, null=True, verbose_name="Letztes Pachtende" + ), + ), + ( + "pachtzins_aktuell", + models.DecimalField( + blank=True, + decimal_places=2, + max_digits=12, + null=True, + verbose_name="Aktueller Pachtzins (€/Jahr)", + ), + ), + ( + "landwirtschaftliche_ausbildung", + models.BooleanField( + default=False, verbose_name="Landwirtschaftliche Ausbildung" + ), + ), + ( + "berufserfahrung_jahre", + models.IntegerField( + blank=True, null=True, verbose_name="Berufserfahrung (Jahre)" + ), + ), + ( + "spezialisierung", + models.CharField( + blank=True, + max_length=100, + null=True, + verbose_name="Spezialisierung", + ), + ), + ( + "notizen", + models.TextField(blank=True, null=True, verbose_name="Notizen"), + ), + ("aktiv", models.BooleanField(default=True, verbose_name="Aktiv")), ], options={ - 'verbose_name': 'Pächter', - 'verbose_name_plural': 'Pächter', - 'ordering': ['nachname', 'vorname'], + "verbose_name": "Pächter", + "verbose_name_plural": "Pächter", + "ordering": ["nachname", "vorname"], }, ), migrations.AlterModelOptions( - name='person', - options={'ordering': ['nachname', 'vorname'], 'verbose_name': 'Person (Legacy)', 'verbose_name_plural': 'Personen (Legacy)'}, + name="person", + options={ + "ordering": ["nachname", "vorname"], + "verbose_name": "Person (Legacy)", + "verbose_name_plural": "Personen (Legacy)", + }, ), migrations.AlterUniqueTogether( - name='foerderung', + name="foerderung", unique_together=set(), ), migrations.AlterField( - model_name='foerderung', - name='person', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='stiftung.person', verbose_name='Person (Legacy)'), + model_name="foerderung", + name="person", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="stiftung.person", + verbose_name="Person (Legacy)", + ), ), migrations.AddField( - model_name='foerderung', - name='destinataer', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='stiftung.destinataer', verbose_name='Destinatär'), + model_name="foerderung", + name="destinataer", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="stiftung.destinataer", + verbose_name="Destinatär", + ), ), migrations.AlterField( - model_name='verpachtung', - name='paechter', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='stiftung.paechter', verbose_name='Pächter'), + model_name="verpachtung", + name="paechter", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="stiftung.paechter", + verbose_name="Pächter", + ), ), ] diff --git a/app/stiftung/migrations/0006_remove_paechter_familienzweig_and_more.py b/app/stiftung/migrations/0006_remove_paechter_familienzweig_and_more.py index 89d7dd1..6175307 100644 --- a/app/stiftung/migrations/0006_remove_paechter_familienzweig_and_more.py +++ b/app/stiftung/migrations/0006_remove_paechter_familienzweig_and_more.py @@ -6,17 +6,27 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('stiftung', '0005_destinataer_paechter_alter_person_options_and_more'), + ("stiftung", "0005_destinataer_paechter_alter_person_options_and_more"), ] operations = [ migrations.RemoveField( - model_name='paechter', - name='familienzweig', + model_name="paechter", + name="familienzweig", ), migrations.AlterField( - model_name='csvimport', - name='import_type', - field=models.CharField(choices=[('destinataere', 'Destinatäre'), ('paechter', 'Pächter'), ('laendereien', 'Ländereien'), ('verpachtungen', 'Verpachtungen'), ('personen', 'Personen (Legacy)')], max_length=20, verbose_name='Import-Typ'), + model_name="csvimport", + name="import_type", + field=models.CharField( + choices=[ + ("destinataere", "Destinatäre"), + ("paechter", "Pächter"), + ("laendereien", "Ländereien"), + ("verpachtungen", "Verpachtungen"), + ("personen", "Personen (Legacy)"), + ], + max_length=20, + verbose_name="Import-Typ", + ), ), ] diff --git a/app/stiftung/migrations/0007_remove_destinataer_adresse_remove_paechter_adresse_and_more.py b/app/stiftung/migrations/0007_remove_destinataer_adresse_remove_paechter_adresse_and_more.py index 2542d52..e70c132 100644 --- a/app/stiftung/migrations/0007_remove_destinataer_adresse_remove_paechter_adresse_and_more.py +++ b/app/stiftung/migrations/0007_remove_destinataer_adresse_remove_paechter_adresse_and_more.py @@ -6,51 +6,71 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('stiftung', '0006_remove_paechter_familienzweig_and_more'), + ("stiftung", "0006_remove_paechter_familienzweig_and_more"), ] operations = [ migrations.RemoveField( - model_name='destinataer', - name='adresse', + model_name="destinataer", + name="adresse", ), migrations.RemoveField( - model_name='paechter', - name='adresse', + model_name="paechter", + name="adresse", ), migrations.AddField( - model_name='destinataer', - name='ort', - field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Ort'), + model_name="destinataer", + name="ort", + field=models.CharField( + blank=True, max_length=100, null=True, verbose_name="Ort" + ), ), migrations.AddField( - model_name='destinataer', - name='plz', - field=models.CharField(blank=True, max_length=10, null=True, verbose_name='PLZ'), + model_name="destinataer", + name="plz", + field=models.CharField( + blank=True, max_length=10, null=True, verbose_name="PLZ" + ), ), migrations.AddField( - model_name='destinataer', - name='strasse', - field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Straße'), + model_name="destinataer", + name="strasse", + field=models.CharField( + blank=True, max_length=200, null=True, verbose_name="Straße" + ), ), migrations.AddField( - model_name='paechter', - name='ort', - field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Ort'), + model_name="paechter", + name="ort", + field=models.CharField( + blank=True, max_length=100, null=True, verbose_name="Ort" + ), ), migrations.AddField( - model_name='paechter', - name='personentyp', - field=models.CharField(choices=[('natuerlich', 'Natürliche Person'), ('gesellschaft', 'Gesellschaft (GmbH, KG, etc.)')], default='natuerlich', max_length=20, verbose_name='Typ des Pächters'), + model_name="paechter", + name="personentyp", + field=models.CharField( + choices=[ + ("natuerlich", "Natürliche Person"), + ("gesellschaft", "Gesellschaft (GmbH, KG, etc.)"), + ], + default="natuerlich", + max_length=20, + verbose_name="Typ des Pächters", + ), ), migrations.AddField( - model_name='paechter', - name='plz', - field=models.CharField(blank=True, max_length=10, null=True, verbose_name='PLZ'), + model_name="paechter", + name="plz", + field=models.CharField( + blank=True, max_length=10, null=True, verbose_name="PLZ" + ), ), migrations.AddField( - model_name='paechter', - name='strasse', - field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Straße'), + model_name="paechter", + name="strasse", + field=models.CharField( + blank=True, max_length=200, null=True, verbose_name="Straße" + ), ), ] diff --git a/app/stiftung/migrations/0008_dokumentlink_destinataer_id_and_more.py b/app/stiftung/migrations/0008_dokumentlink_destinataer_id_and_more.py index 671cfa3..3cda78c 100644 --- a/app/stiftung/migrations/0008_dokumentlink_destinataer_id_and_more.py +++ b/app/stiftung/migrations/0008_dokumentlink_destinataer_id_and_more.py @@ -6,38 +6,57 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('stiftung', '0007_remove_destinataer_adresse_remove_paechter_adresse_and_more'), + ( + "stiftung", + "0007_remove_destinataer_adresse_remove_paechter_adresse_and_more", + ), ] operations = [ migrations.AddField( - model_name='dokumentlink', - name='destinataer_id', - field=models.UUIDField(blank=True, null=True, verbose_name='Destinatär ID'), + model_name="dokumentlink", + name="destinataer_id", + field=models.UUIDField(blank=True, null=True, verbose_name="Destinatär ID"), ), migrations.AddField( - model_name='dokumentlink', - name='foerderung_id', - field=models.UUIDField(blank=True, null=True, verbose_name='Förderung ID'), + model_name="dokumentlink", + name="foerderung_id", + field=models.UUIDField(blank=True, null=True, verbose_name="Förderung ID"), ), migrations.AddField( - model_name='dokumentlink', - name='land_id', - field=models.UUIDField(blank=True, null=True, verbose_name='Länderei ID'), + model_name="dokumentlink", + name="land_id", + field=models.UUIDField(blank=True, null=True, verbose_name="Länderei ID"), ), migrations.AddField( - model_name='dokumentlink', - name='paechter_id', - field=models.UUIDField(blank=True, null=True, verbose_name='Pächter ID'), + model_name="dokumentlink", + name="paechter_id", + field=models.UUIDField(blank=True, null=True, verbose_name="Pächter ID"), ), migrations.AddField( - model_name='dokumentlink', - name='verpachtung_id', - field=models.UUIDField(blank=True, null=True, verbose_name='Verpachtung ID'), + model_name="dokumentlink", + name="verpachtung_id", + field=models.UUIDField( + blank=True, null=True, verbose_name="Verpachtung ID" + ), ), migrations.AlterField( - model_name='dokumentlink', - name='kontext', - field=models.CharField(choices=[('pachtvertrag', 'Pachtvertrag'), ('antrag', 'Antrag'), ('verwendungsnachweis', 'Verwendungsnachweis'), ('rechnung', 'Rechnung'), ('vertrag', 'Vertrag'), ('bericht', 'Bericht'), ('landkarte', 'Landkarte'), ('kataster', 'Kataster'), ('anderes', 'Anderes')], default='anderes', max_length=30), + model_name="dokumentlink", + name="kontext", + field=models.CharField( + choices=[ + ("pachtvertrag", "Pachtvertrag"), + ("antrag", "Antrag"), + ("verwendungsnachweis", "Verwendungsnachweis"), + ("rechnung", "Rechnung"), + ("vertrag", "Vertrag"), + ("bericht", "Bericht"), + ("landkarte", "Landkarte"), + ("kataster", "Kataster"), + ("anderes", "Anderes"), + ], + default="anderes", + max_length=30, + ), ), ] diff --git a/app/stiftung/migrations/0009_alter_dokumentlink_paperless_document_id.py b/app/stiftung/migrations/0009_alter_dokumentlink_paperless_document_id.py index ab988a8..217e30e 100644 --- a/app/stiftung/migrations/0009_alter_dokumentlink_paperless_document_id.py +++ b/app/stiftung/migrations/0009_alter_dokumentlink_paperless_document_id.py @@ -6,13 +6,13 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('stiftung', '0008_dokumentlink_destinataer_id_and_more'), + ("stiftung", "0008_dokumentlink_destinataer_id_and_more"), ] operations = [ migrations.AlterField( - model_name='dokumentlink', - name='paperless_document_id', + model_name="dokumentlink", + name="paperless_document_id", field=models.IntegerField(), ), ] diff --git a/app/stiftung/migrations/0010_rentmeister_stiftungskonto_verwaltungskosten.py b/app/stiftung/migrations/0010_rentmeister_stiftungskonto_verwaltungskosten.py index dc65fe9..575e587 100644 --- a/app/stiftung/migrations/0010_rentmeister_stiftungskonto_verwaltungskosten.py +++ b/app/stiftung/migrations/0010_rentmeister_stiftungskonto_verwaltungskosten.py @@ -1,100 +1,344 @@ # Generated by Django 5.0.6 on 2025-08-24 17:48 -import django.db.models.deletion import uuid + +import django.db.models.deletion from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('stiftung', '0009_alter_dokumentlink_paperless_document_id'), + ("stiftung", "0009_alter_dokumentlink_paperless_document_id"), ] operations = [ migrations.CreateModel( - name='Rentmeister', + name="Rentmeister", fields=[ - ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), - ('anrede', models.CharField(blank=True, choices=[('herr', 'Herr'), ('frau', 'Frau'), ('dr', 'Dr.'), ('prof', 'Prof.'), ('prof_dr', 'Prof. Dr.')], max_length=10, verbose_name='Anrede')), - ('vorname', models.CharField(max_length=100, verbose_name='Vorname')), - ('nachname', models.CharField(max_length=100, verbose_name='Nachname')), - ('titel', models.CharField(blank=True, max_length=50, verbose_name='Titel')), - ('email', models.EmailField(blank=True, max_length=254, verbose_name='E-Mail')), - ('telefon', models.CharField(blank=True, max_length=20, verbose_name='Telefon')), - ('mobil', models.CharField(blank=True, max_length=20, verbose_name='Mobil')), - ('strasse', models.CharField(blank=True, max_length=200, verbose_name='Straße')), - ('plz', models.CharField(blank=True, max_length=10, verbose_name='PLZ')), - ('ort', models.CharField(blank=True, max_length=100, verbose_name='Ort')), - ('iban', models.CharField(blank=True, max_length=34, verbose_name='IBAN')), - ('bic', models.CharField(blank=True, max_length=11, verbose_name='BIC')), - ('bank_name', models.CharField(blank=True, max_length=100, verbose_name='Bank')), - ('seit_datum', models.DateField(verbose_name='Rentmeister seit')), - ('bis_datum', models.DateField(blank=True, null=True, verbose_name='Rentmeister bis')), - ('aktiv', models.BooleanField(default=True, verbose_name='Aktiv')), - ('monatliche_verguetung', models.DecimalField(blank=True, decimal_places=2, max_digits=8, null=True, verbose_name='Monatliche Vergütung (€)')), - ('km_pauschale', models.DecimalField(decimal_places=2, default=0.3, max_digits=4, verbose_name='Kilometerpauschale (€/km)')), - ('notizen', models.TextField(blank=True, verbose_name='Notizen')), - ('erstellt_am', models.DateTimeField(auto_now_add=True)), - ('aktualisiert_am', models.DateTimeField(auto_now=True)), + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ( + "anrede", + models.CharField( + blank=True, + choices=[ + ("herr", "Herr"), + ("frau", "Frau"), + ("dr", "Dr."), + ("prof", "Prof."), + ("prof_dr", "Prof. Dr."), + ], + max_length=10, + verbose_name="Anrede", + ), + ), + ("vorname", models.CharField(max_length=100, verbose_name="Vorname")), + ("nachname", models.CharField(max_length=100, verbose_name="Nachname")), + ( + "titel", + models.CharField(blank=True, max_length=50, verbose_name="Titel"), + ), + ( + "email", + models.EmailField( + blank=True, max_length=254, verbose_name="E-Mail" + ), + ), + ( + "telefon", + models.CharField(blank=True, max_length=20, verbose_name="Telefon"), + ), + ( + "mobil", + models.CharField(blank=True, max_length=20, verbose_name="Mobil"), + ), + ( + "strasse", + models.CharField(blank=True, max_length=200, verbose_name="Straße"), + ), + ( + "plz", + models.CharField(blank=True, max_length=10, verbose_name="PLZ"), + ), + ( + "ort", + models.CharField(blank=True, max_length=100, verbose_name="Ort"), + ), + ( + "iban", + models.CharField(blank=True, max_length=34, verbose_name="IBAN"), + ), + ( + "bic", + models.CharField(blank=True, max_length=11, verbose_name="BIC"), + ), + ( + "bank_name", + models.CharField(blank=True, max_length=100, verbose_name="Bank"), + ), + ("seit_datum", models.DateField(verbose_name="Rentmeister seit")), + ( + "bis_datum", + models.DateField( + blank=True, null=True, verbose_name="Rentmeister bis" + ), + ), + ("aktiv", models.BooleanField(default=True, verbose_name="Aktiv")), + ( + "monatliche_verguetung", + models.DecimalField( + blank=True, + decimal_places=2, + max_digits=8, + null=True, + verbose_name="Monatliche Vergütung (€)", + ), + ), + ( + "km_pauschale", + models.DecimalField( + decimal_places=2, + default=0.3, + max_digits=4, + verbose_name="Kilometerpauschale (€/km)", + ), + ), + ("notizen", models.TextField(blank=True, verbose_name="Notizen")), + ("erstellt_am", models.DateTimeField(auto_now_add=True)), + ("aktualisiert_am", models.DateTimeField(auto_now=True)), ], options={ - 'verbose_name': 'Rentmeister', - 'verbose_name_plural': 'Rentmeister', - 'ordering': ['nachname', 'vorname'], + "verbose_name": "Rentmeister", + "verbose_name_plural": "Rentmeister", + "ordering": ["nachname", "vorname"], }, ), migrations.CreateModel( - name='StiftungsKonto', + name="StiftungsKonto", fields=[ - ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), - ('kontoname', models.CharField(max_length=200, verbose_name='Kontoname')), - ('bank_name', models.CharField(max_length=200, verbose_name='Bank')), - ('iban', models.CharField(max_length=34, verbose_name='IBAN')), - ('bic', models.CharField(blank=True, max_length=11, verbose_name='BIC')), - ('konto_typ', models.CharField(choices=[('girokonto', 'Girokonto'), ('sparkonto', 'Sparkonto'), ('festgeld', 'Festgeld'), ('tagesgeld', 'Tagesgeld'), ('depot', 'Depot'), ('sonstiges', 'Sonstiges')], default='girokonto', max_length=20, verbose_name='Kontotyp')), - ('saldo', models.DecimalField(decimal_places=2, default=0.0, max_digits=10, verbose_name='Aktueller Saldo')), - ('saldo_datum', models.DateField(blank=True, null=True, verbose_name='Saldo-Datum')), - ('zinssatz', models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True, verbose_name='Zinssatz (%)')), - ('laufzeit_bis', models.DateField(blank=True, null=True, verbose_name='Laufzeit bis')), - ('aktiv', models.BooleanField(default=True, verbose_name='Aktiv')), - ('notizen', models.TextField(blank=True, verbose_name='Notizen')), - ('erstellt_am', models.DateTimeField(auto_now_add=True)), - ('aktualisiert_am', models.DateTimeField(auto_now=True)), + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ( + "kontoname", + models.CharField(max_length=200, verbose_name="Kontoname"), + ), + ("bank_name", models.CharField(max_length=200, verbose_name="Bank")), + ("iban", models.CharField(max_length=34, verbose_name="IBAN")), + ( + "bic", + models.CharField(blank=True, max_length=11, verbose_name="BIC"), + ), + ( + "konto_typ", + models.CharField( + choices=[ + ("girokonto", "Girokonto"), + ("sparkonto", "Sparkonto"), + ("festgeld", "Festgeld"), + ("tagesgeld", "Tagesgeld"), + ("depot", "Depot"), + ("sonstiges", "Sonstiges"), + ], + default="girokonto", + max_length=20, + verbose_name="Kontotyp", + ), + ), + ( + "saldo", + models.DecimalField( + decimal_places=2, + default=0.0, + max_digits=10, + verbose_name="Aktueller Saldo", + ), + ), + ( + "saldo_datum", + models.DateField(blank=True, null=True, verbose_name="Saldo-Datum"), + ), + ( + "zinssatz", + models.DecimalField( + blank=True, + decimal_places=2, + max_digits=5, + null=True, + verbose_name="Zinssatz (%)", + ), + ), + ( + "laufzeit_bis", + models.DateField( + blank=True, null=True, verbose_name="Laufzeit bis" + ), + ), + ("aktiv", models.BooleanField(default=True, verbose_name="Aktiv")), + ("notizen", models.TextField(blank=True, verbose_name="Notizen")), + ("erstellt_am", models.DateTimeField(auto_now_add=True)), + ("aktualisiert_am", models.DateTimeField(auto_now=True)), ], options={ - 'verbose_name': 'Stiftungskonto', - 'verbose_name_plural': 'Stiftungskonten', - 'ordering': ['bank_name', 'kontoname'], + "verbose_name": "Stiftungskonto", + "verbose_name_plural": "Stiftungskonten", + "ordering": ["bank_name", "kontoname"], }, ), migrations.CreateModel( - name='Verwaltungskosten', + name="Verwaltungskosten", fields=[ - ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), - ('bezeichnung', models.CharField(max_length=200, verbose_name='Bezeichnung')), - ('kategorie', models.CharField(choices=[('rechnung_intern', 'Interne Rechnung'), ('bueroausstattung', 'Büroausstattung'), ('fahrtkosten', 'Fahrtkosten'), ('porto', 'Porto & Versand'), ('telefon_internet', 'Telefon & Internet'), ('software', 'Software & Lizenzen'), ('beratung', 'Beratung & Dienstleistungen'), ('versicherung', 'Versicherungen'), ('steuerberatung', 'Steuerberatung'), ('bankgebuehren', 'Bankgebühren'), ('sonstiges', 'Sonstiges')], max_length=30, verbose_name='Kategorie')), - ('betrag', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Betrag (€)')), - ('datum', models.DateField(verbose_name='Datum')), - ('lieferant_firma', models.CharField(blank=True, max_length=200, verbose_name='Lieferant/Firma')), - ('rechnungsnummer', models.CharField(blank=True, max_length=100, verbose_name='Rechnungsnummer')), - ('status', models.CharField(choices=[('geplant', 'Geplant'), ('bestellt', 'Bestellt'), ('erhalten', 'Erhalten'), ('bezahlt', 'Bezahlt'), ('storniert', 'Storniert')], default='geplant', max_length=20, verbose_name='Status')), - ('km_anzahl', models.DecimalField(blank=True, decimal_places=1, max_digits=8, null=True, verbose_name='Kilometer')), - ('km_satz', models.DecimalField(blank=True, decimal_places=2, max_digits=4, null=True, verbose_name='€/km')), - ('von_ort', models.CharField(blank=True, max_length=100, verbose_name='Von (Ort)')), - ('nach_ort', models.CharField(blank=True, max_length=100, verbose_name='Nach (Ort)')), - ('zweck', models.CharField(blank=True, max_length=200, verbose_name='Zweck der Fahrt')), - ('beschreibung', models.TextField(blank=True, verbose_name='Beschreibung')), - ('notizen', models.TextField(blank=True, verbose_name='Notizen')), - ('erstellt_am', models.DateTimeField(auto_now_add=True)), - ('aktualisiert_am', models.DateTimeField(auto_now=True)), - ('konto', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='stiftung.stiftungskonto', verbose_name='Konto')), - ('rentmeister', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='stiftung.rentmeister', verbose_name='Rentmeister')), + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ( + "bezeichnung", + models.CharField(max_length=200, verbose_name="Bezeichnung"), + ), + ( + "kategorie", + models.CharField( + choices=[ + ("rechnung_intern", "Interne Rechnung"), + ("bueroausstattung", "Büroausstattung"), + ("fahrtkosten", "Fahrtkosten"), + ("porto", "Porto & Versand"), + ("telefon_internet", "Telefon & Internet"), + ("software", "Software & Lizenzen"), + ("beratung", "Beratung & Dienstleistungen"), + ("versicherung", "Versicherungen"), + ("steuerberatung", "Steuerberatung"), + ("bankgebuehren", "Bankgebühren"), + ("sonstiges", "Sonstiges"), + ], + max_length=30, + verbose_name="Kategorie", + ), + ), + ( + "betrag", + models.DecimalField( + decimal_places=2, max_digits=10, verbose_name="Betrag (€)" + ), + ), + ("datum", models.DateField(verbose_name="Datum")), + ( + "lieferant_firma", + models.CharField( + blank=True, max_length=200, verbose_name="Lieferant/Firma" + ), + ), + ( + "rechnungsnummer", + models.CharField( + blank=True, max_length=100, verbose_name="Rechnungsnummer" + ), + ), + ( + "status", + models.CharField( + choices=[ + ("geplant", "Geplant"), + ("bestellt", "Bestellt"), + ("erhalten", "Erhalten"), + ("bezahlt", "Bezahlt"), + ("storniert", "Storniert"), + ], + default="geplant", + max_length=20, + verbose_name="Status", + ), + ), + ( + "km_anzahl", + models.DecimalField( + blank=True, + decimal_places=1, + max_digits=8, + null=True, + verbose_name="Kilometer", + ), + ), + ( + "km_satz", + models.DecimalField( + blank=True, + decimal_places=2, + max_digits=4, + null=True, + verbose_name="€/km", + ), + ), + ( + "von_ort", + models.CharField( + blank=True, max_length=100, verbose_name="Von (Ort)" + ), + ), + ( + "nach_ort", + models.CharField( + blank=True, max_length=100, verbose_name="Nach (Ort)" + ), + ), + ( + "zweck", + models.CharField( + blank=True, max_length=200, verbose_name="Zweck der Fahrt" + ), + ), + ( + "beschreibung", + models.TextField(blank=True, verbose_name="Beschreibung"), + ), + ("notizen", models.TextField(blank=True, verbose_name="Notizen")), + ("erstellt_am", models.DateTimeField(auto_now_add=True)), + ("aktualisiert_am", models.DateTimeField(auto_now=True)), + ( + "konto", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="stiftung.stiftungskonto", + verbose_name="Konto", + ), + ), + ( + "rentmeister", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="stiftung.rentmeister", + verbose_name="Rentmeister", + ), + ), ], options={ - 'verbose_name': 'Verwaltungskosten', - 'verbose_name_plural': 'Verwaltungskosten', - 'ordering': ['-datum', '-erstellt_am'], + "verbose_name": "Verwaltungskosten", + "verbose_name_plural": "Verwaltungskosten", + "ordering": ["-datum", "-erstellt_am"], }, ), ] diff --git a/app/stiftung/migrations/0011_banktransaction.py b/app/stiftung/migrations/0011_banktransaction.py index 9a21b3c..bb478a2 100644 --- a/app/stiftung/migrations/0011_banktransaction.py +++ b/app/stiftung/migrations/0011_banktransaction.py @@ -1,44 +1,156 @@ # Generated by Django 5.0.6 on 2025-08-24 19:27 -import django.db.models.deletion import uuid + +import django.db.models.deletion from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('stiftung', '0010_rentmeister_stiftungskonto_verwaltungskosten'), + ("stiftung", "0010_rentmeister_stiftungskonto_verwaltungskosten"), ] operations = [ migrations.CreateModel( - name='BankTransaction', + name="BankTransaction", fields=[ - ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), - ('datum', models.DateField(verbose_name='Buchungsdatum')), - ('valuta', models.DateField(blank=True, null=True, verbose_name='Valutadatum')), - ('betrag', models.DecimalField(decimal_places=2, max_digits=12, verbose_name='Betrag (€)')), - ('waehrung', models.CharField(default='EUR', max_length=3, verbose_name='Währung')), - ('verwendungszweck', models.TextField(verbose_name='Verwendungszweck')), - ('empfaenger_zahlungspflichtiger', models.CharField(blank=True, max_length=200, verbose_name='Empfänger/Zahlungspflichtiger')), - ('iban_gegenpartei', models.CharField(blank=True, max_length=34, verbose_name='IBAN Gegenpartei')), - ('bic_gegenpartei', models.CharField(blank=True, max_length=11, verbose_name='BIC Gegenpartei')), - ('referenz', models.CharField(blank=True, max_length=100, verbose_name='Referenz/Transaktions-ID')), - ('transaction_type', models.CharField(choices=[('eingang', 'Eingang'), ('ausgang', 'Ausgang'), ('lastschrift', 'Lastschrift'), ('ueberweisung', 'Überweisung'), ('dauerauftrag', 'Dauerauftrag'), ('kartenzahlung', 'Kartenzahlung'), ('zinsen', 'Zinsen'), ('gebuehren', 'Gebühren'), ('sonstiges', 'Sonstiges')], default='sonstiges', max_length=20, verbose_name='Transaktionsart')), - ('status', models.CharField(choices=[('imported', 'Importiert'), ('verified', 'Geprüft'), ('assigned', 'Zugeordnet'), ('ignored', 'Ignoriert')], default='imported', max_length=20, verbose_name='Status')), - ('kommentare', models.TextField(blank=True, verbose_name='Kommentare')), - ('import_datei', models.CharField(blank=True, max_length=255, verbose_name='Import-Datei')), - ('importiert_am', models.DateTimeField(auto_now_add=True, verbose_name='Importiert am')), - ('saldo_nach_buchung', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True, verbose_name='Saldo nach Buchung')), - ('konto', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='stiftung.stiftungskonto', verbose_name='Konto')), - ('verwaltungskosten', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='stiftung.verwaltungskosten', verbose_name='Zugeordnete Verwaltungskosten')), + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("datum", models.DateField(verbose_name="Buchungsdatum")), + ( + "valuta", + models.DateField(blank=True, null=True, verbose_name="Valutadatum"), + ), + ( + "betrag", + models.DecimalField( + decimal_places=2, max_digits=12, verbose_name="Betrag (€)" + ), + ), + ( + "waehrung", + models.CharField( + default="EUR", max_length=3, verbose_name="Währung" + ), + ), + ("verwendungszweck", models.TextField(verbose_name="Verwendungszweck")), + ( + "empfaenger_zahlungspflichtiger", + models.CharField( + blank=True, + max_length=200, + verbose_name="Empfänger/Zahlungspflichtiger", + ), + ), + ( + "iban_gegenpartei", + models.CharField( + blank=True, max_length=34, verbose_name="IBAN Gegenpartei" + ), + ), + ( + "bic_gegenpartei", + models.CharField( + blank=True, max_length=11, verbose_name="BIC Gegenpartei" + ), + ), + ( + "referenz", + models.CharField( + blank=True, + max_length=100, + verbose_name="Referenz/Transaktions-ID", + ), + ), + ( + "transaction_type", + models.CharField( + choices=[ + ("eingang", "Eingang"), + ("ausgang", "Ausgang"), + ("lastschrift", "Lastschrift"), + ("ueberweisung", "Überweisung"), + ("dauerauftrag", "Dauerauftrag"), + ("kartenzahlung", "Kartenzahlung"), + ("zinsen", "Zinsen"), + ("gebuehren", "Gebühren"), + ("sonstiges", "Sonstiges"), + ], + default="sonstiges", + max_length=20, + verbose_name="Transaktionsart", + ), + ), + ( + "status", + models.CharField( + choices=[ + ("imported", "Importiert"), + ("verified", "Geprüft"), + ("assigned", "Zugeordnet"), + ("ignored", "Ignoriert"), + ], + default="imported", + max_length=20, + verbose_name="Status", + ), + ), + ("kommentare", models.TextField(blank=True, verbose_name="Kommentare")), + ( + "import_datei", + models.CharField( + blank=True, max_length=255, verbose_name="Import-Datei" + ), + ), + ( + "importiert_am", + models.DateTimeField( + auto_now_add=True, verbose_name="Importiert am" + ), + ), + ( + "saldo_nach_buchung", + models.DecimalField( + blank=True, + decimal_places=2, + max_digits=12, + null=True, + verbose_name="Saldo nach Buchung", + ), + ), + ( + "konto", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="stiftung.stiftungskonto", + verbose_name="Konto", + ), + ), + ( + "verwaltungskosten", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="stiftung.verwaltungskosten", + verbose_name="Zugeordnete Verwaltungskosten", + ), + ), ], options={ - 'verbose_name': 'Banktransaktion', - 'verbose_name_plural': 'Banktransaktionen', - 'ordering': ['-datum', '-importiert_am'], - 'unique_together': {('konto', 'datum', 'betrag', 'referenz')}, + "verbose_name": "Banktransaktion", + "verbose_name_plural": "Banktransaktionen", + "ordering": ["-datum", "-importiert_am"], + "unique_together": {("konto", "datum", "betrag", "referenz")}, }, ), ] diff --git a/app/stiftung/migrations/0012_verwaltungskosten_quellkonto_and_more.py b/app/stiftung/migrations/0012_verwaltungskosten_quellkonto_and_more.py index 6e8017d..a8987f6 100644 --- a/app/stiftung/migrations/0012_verwaltungskosten_quellkonto_and_more.py +++ b/app/stiftung/migrations/0012_verwaltungskosten_quellkonto_and_more.py @@ -7,28 +7,55 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('stiftung', '0011_banktransaction'), + ("stiftung", "0011_banktransaction"), ] operations = [ migrations.AddField( - model_name='verwaltungskosten', - name='quellkonto', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='ausgaben', to='stiftung.stiftungskonto', verbose_name='Quellkonto'), + model_name="verwaltungskosten", + name="quellkonto", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="ausgaben", + to="stiftung.stiftungskonto", + verbose_name="Quellkonto", + ), ), migrations.AddField( - model_name='verwaltungskosten', - name='zahlungskonto', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='zahlungen', to='stiftung.stiftungskonto', verbose_name='Zahlungskonto'), + model_name="verwaltungskosten", + name="zahlungskonto", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="zahlungen", + to="stiftung.stiftungskonto", + verbose_name="Zahlungskonto", + ), ), migrations.AlterField( - model_name='verwaltungskosten', - name='konto', - field=models.ForeignKey(blank=True, help_text='Veraltet - verwende Zahlungskonto und Quellkonto', null=True, on_delete=django.db.models.deletion.SET_NULL, to='stiftung.stiftungskonto', verbose_name='Konto (Legacy)'), + model_name="verwaltungskosten", + name="konto", + field=models.ForeignKey( + blank=True, + help_text="Veraltet - verwende Zahlungskonto und Quellkonto", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="stiftung.stiftungskonto", + verbose_name="Konto (Legacy)", + ), ), migrations.AlterField( - model_name='verwaltungskosten', - name='rentmeister', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='stiftung.rentmeister', verbose_name='Zuständiger Rentmeister'), + model_name="verwaltungskosten", + name="rentmeister", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="stiftung.rentmeister", + verbose_name="Zuständiger Rentmeister", + ), ), ] diff --git a/app/stiftung/migrations/0013_alter_verwaltungskosten_status.py b/app/stiftung/migrations/0013_alter_verwaltungskosten_status.py index 371b067..e3b3d7d 100644 --- a/app/stiftung/migrations/0013_alter_verwaltungskosten_status.py +++ b/app/stiftung/migrations/0013_alter_verwaltungskosten_status.py @@ -6,13 +6,25 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('stiftung', '0012_verwaltungskosten_quellkonto_and_more'), + ("stiftung", "0012_verwaltungskosten_quellkonto_and_more"), ] operations = [ migrations.AlterField( - model_name='verwaltungskosten', - name='status', - field=models.CharField(choices=[('geplant', 'Geplant'), ('bestellt', 'Bestellt'), ('erhalten', 'Erhalten'), ('in_bearbeitung', 'In Bearbeitung'), ('bezahlt', 'Bezahlt'), ('storniert', 'Storniert')], default='geplant', max_length=20, verbose_name='Status'), + model_name="verwaltungskosten", + name="status", + field=models.CharField( + choices=[ + ("geplant", "Geplant"), + ("bestellt", "Bestellt"), + ("erhalten", "Erhalten"), + ("in_bearbeitung", "In Bearbeitung"), + ("bezahlt", "Bezahlt"), + ("storniert", "Storniert"), + ], + default="geplant", + max_length=20, + verbose_name="Status", + ), ), ] diff --git a/app/stiftung/migrations/0014_dokumentlink_rentmeister_id.py b/app/stiftung/migrations/0014_dokumentlink_rentmeister_id.py index 7e12549..087a174 100644 --- a/app/stiftung/migrations/0014_dokumentlink_rentmeister_id.py +++ b/app/stiftung/migrations/0014_dokumentlink_rentmeister_id.py @@ -6,13 +6,15 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('stiftung', '0013_alter_verwaltungskosten_status'), + ("stiftung", "0013_alter_verwaltungskosten_status"), ] operations = [ migrations.AddField( - model_name='dokumentlink', - name='rentmeister_id', - field=models.UUIDField(blank=True, null=True, verbose_name='Rentmeister ID'), + model_name="dokumentlink", + name="rentmeister_id", + field=models.UUIDField( + blank=True, null=True, verbose_name="Rentmeister ID" + ), ), ] diff --git a/app/stiftung/migrations/0015_backupjob_auditlog.py b/app/stiftung/migrations/0015_backupjob_auditlog.py index 28bc7af..b1dfc9b 100644 --- a/app/stiftung/migrations/0015_backupjob_auditlog.py +++ b/app/stiftung/migrations/0015_backupjob_auditlog.py @@ -1,7 +1,8 @@ # Generated by Django 5.0.6 on 2025-08-26 08:33 -import django.db.models.deletion import uuid + +import django.db.models.deletion from django.conf import settings from django.db import migrations, models @@ -9,55 +10,229 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('stiftung', '0014_dokumentlink_rentmeister_id'), + ("stiftung", "0014_dokumentlink_rentmeister_id"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ migrations.CreateModel( - name='BackupJob', + name="BackupJob", fields=[ - ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), - ('backup_type', models.CharField(choices=[('full', 'Vollständiges Backup'), ('database', 'Nur Datenbank'), ('files', 'Nur Dateien')], max_length=20, verbose_name='Backup-Typ')), - ('status', models.CharField(choices=[('pending', 'Wartend'), ('running', 'Läuft'), ('completed', 'Abgeschlossen'), ('failed', 'Fehlgeschlagen')], default='pending', max_length=20, verbose_name='Status')), - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Erstellt am')), - ('started_at', models.DateTimeField(blank=True, null=True, verbose_name='Gestartet am')), - ('completed_at', models.DateTimeField(blank=True, null=True, verbose_name='Abgeschlossen am')), - ('backup_filename', models.CharField(blank=True, max_length=255, verbose_name='Backup-Dateiname')), - ('backup_size', models.BigIntegerField(blank=True, null=True, verbose_name='Backup-Größe (Bytes)')), - ('error_message', models.TextField(blank=True, verbose_name='Fehlermeldung')), - ('database_size', models.BigIntegerField(blank=True, null=True, verbose_name='Datenbankgröße (Bytes)')), - ('files_count', models.IntegerField(blank=True, null=True, verbose_name='Anzahl Dateien')), - ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Erstellt von')), + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ( + "backup_type", + models.CharField( + choices=[ + ("full", "Vollständiges Backup"), + ("database", "Nur Datenbank"), + ("files", "Nur Dateien"), + ], + max_length=20, + verbose_name="Backup-Typ", + ), + ), + ( + "status", + models.CharField( + choices=[ + ("pending", "Wartend"), + ("running", "Läuft"), + ("completed", "Abgeschlossen"), + ("failed", "Fehlgeschlagen"), + ], + default="pending", + max_length=20, + verbose_name="Status", + ), + ), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="Erstellt am"), + ), + ( + "started_at", + models.DateTimeField( + blank=True, null=True, verbose_name="Gestartet am" + ), + ), + ( + "completed_at", + models.DateTimeField( + blank=True, null=True, verbose_name="Abgeschlossen am" + ), + ), + ( + "backup_filename", + models.CharField( + blank=True, max_length=255, verbose_name="Backup-Dateiname" + ), + ), + ( + "backup_size", + models.BigIntegerField( + blank=True, null=True, verbose_name="Backup-Größe (Bytes)" + ), + ), + ( + "error_message", + models.TextField(blank=True, verbose_name="Fehlermeldung"), + ), + ( + "database_size", + models.BigIntegerField( + blank=True, null=True, verbose_name="Datenbankgröße (Bytes)" + ), + ), + ( + "files_count", + models.IntegerField( + blank=True, null=True, verbose_name="Anzahl Dateien" + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to=settings.AUTH_USER_MODEL, + verbose_name="Erstellt von", + ), + ), ], options={ - 'verbose_name': 'Backup-Job', - 'verbose_name_plural': 'Backup-Jobs', - 'ordering': ['-created_at'], + "verbose_name": "Backup-Job", + "verbose_name_plural": "Backup-Jobs", + "ordering": ["-created_at"], }, ), migrations.CreateModel( - name='AuditLog', + name="AuditLog", fields=[ - ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), - ('username', models.CharField(max_length=150, verbose_name='Benutzername')), - ('timestamp', models.DateTimeField(auto_now_add=True, verbose_name='Zeitpunkt')), - ('action', models.CharField(choices=[('create', 'Erstellt'), ('update', 'Aktualisiert'), ('delete', 'Gelöscht'), ('link', 'Verknüpft'), ('unlink', 'Verknüpfung entfernt'), ('login', 'Anmeldung'), ('logout', 'Abmeldung'), ('backup', 'Backup erstellt'), ('restore', 'Wiederherstellung'), ('export', 'Export'), ('import', 'Import')], max_length=20, verbose_name='Aktion')), - ('entity_type', models.CharField(choices=[('destinataer', 'Destinatär'), ('land', 'Länderei'), ('paechter', 'Pächter'), ('verpachtung', 'Verpachtung'), ('foerderung', 'Förderung'), ('rentmeister', 'Rentmeister'), ('stiftungskonto', 'Stiftungskonto'), ('verwaltungskosten', 'Verwaltungskosten'), ('banktransaction', 'Bank-Transaktion'), ('dokumentlink', 'Dokument-Verknüpfung'), ('system', 'System'), ('user', 'Benutzer')], max_length=20, verbose_name='Entitätstyp')), - ('entity_id', models.CharField(blank=True, max_length=100, verbose_name='Entitäts-ID')), - ('entity_name', models.CharField(max_length=255, verbose_name='Entitätsname')), - ('description', models.TextField(verbose_name='Beschreibung')), - ('changes', models.JSONField(blank=True, null=True, verbose_name='Änderungen')), - ('ip_address', models.GenericIPAddressField(blank=True, null=True, verbose_name='IP-Adresse')), - ('user_agent', models.TextField(blank=True, verbose_name='User Agent')), - ('session_key', models.CharField(blank=True, max_length=40, verbose_name='Session-Key')), - ('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Benutzer')), + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ( + "username", + models.CharField(max_length=150, verbose_name="Benutzername"), + ), + ( + "timestamp", + models.DateTimeField(auto_now_add=True, verbose_name="Zeitpunkt"), + ), + ( + "action", + models.CharField( + choices=[ + ("create", "Erstellt"), + ("update", "Aktualisiert"), + ("delete", "Gelöscht"), + ("link", "Verknüpft"), + ("unlink", "Verknüpfung entfernt"), + ("login", "Anmeldung"), + ("logout", "Abmeldung"), + ("backup", "Backup erstellt"), + ("restore", "Wiederherstellung"), + ("export", "Export"), + ("import", "Import"), + ], + max_length=20, + verbose_name="Aktion", + ), + ), + ( + "entity_type", + models.CharField( + choices=[ + ("destinataer", "Destinatär"), + ("land", "Länderei"), + ("paechter", "Pächter"), + ("verpachtung", "Verpachtung"), + ("foerderung", "Förderung"), + ("rentmeister", "Rentmeister"), + ("stiftungskonto", "Stiftungskonto"), + ("verwaltungskosten", "Verwaltungskosten"), + ("banktransaction", "Bank-Transaktion"), + ("dokumentlink", "Dokument-Verknüpfung"), + ("system", "System"), + ("user", "Benutzer"), + ], + max_length=20, + verbose_name="Entitätstyp", + ), + ), + ( + "entity_id", + models.CharField( + blank=True, max_length=100, verbose_name="Entitäts-ID" + ), + ), + ( + "entity_name", + models.CharField(max_length=255, verbose_name="Entitätsname"), + ), + ("description", models.TextField(verbose_name="Beschreibung")), + ( + "changes", + models.JSONField(blank=True, null=True, verbose_name="Änderungen"), + ), + ( + "ip_address", + models.GenericIPAddressField( + blank=True, null=True, verbose_name="IP-Adresse" + ), + ), + ("user_agent", models.TextField(blank=True, verbose_name="User Agent")), + ( + "session_key", + models.CharField( + blank=True, max_length=40, verbose_name="Session-Key" + ), + ), + ( + "user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to=settings.AUTH_USER_MODEL, + verbose_name="Benutzer", + ), + ), ], options={ - 'verbose_name': 'Audit Log Eintrag', - 'verbose_name_plural': 'Audit Log Einträge', - 'ordering': ['-timestamp'], - 'indexes': [models.Index(fields=['timestamp'], name='stiftung_au_timesta_c4591e_idx'), models.Index(fields=['user', 'timestamp'], name='stiftung_au_user_id_e3fc12_idx'), models.Index(fields=['entity_type', 'timestamp'], name='stiftung_au_entity__68f25d_idx'), models.Index(fields=['action', 'timestamp'], name='stiftung_au_action_288765_idx')], + "verbose_name": "Audit Log Eintrag", + "verbose_name_plural": "Audit Log Einträge", + "ordering": ["-timestamp"], + "indexes": [ + models.Index( + fields=["timestamp"], name="stiftung_au_timesta_c4591e_idx" + ), + models.Index( + fields=["user", "timestamp"], + name="stiftung_au_user_id_e3fc12_idx", + ), + models.Index( + fields=["entity_type", "timestamp"], + name="stiftung_au_entity__68f25d_idx", + ), + models.Index( + fields=["action", "timestamp"], + name="stiftung_au_action_288765_idx", + ), + ], }, ), ] diff --git a/app/stiftung/migrations/0016_applicationpermission.py b/app/stiftung/migrations/0016_applicationpermission.py index e0f4195..3095660 100644 --- a/app/stiftung/migrations/0016_applicationpermission.py +++ b/app/stiftung/migrations/0016_applicationpermission.py @@ -6,19 +6,57 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('stiftung', '0015_backupjob_auditlog'), + ("stiftung", "0015_backupjob_auditlog"), ] operations = [ migrations.CreateModel( - name='ApplicationPermission', + name="ApplicationPermission", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), ], options={ - 'permissions': [('manage_destinataere', 'Kann Destinatäre verwalten'), ('view_destinataere', 'Kann Destinatäre anzeigen'), ('manage_land', 'Kann Ländereien verwalten'), ('view_land', 'Kann Ländereien anzeigen'), ('manage_paechter', 'Kann Pächter verwalten'), ('view_paechter', 'Kann Pächter anzeigen'), ('manage_verpachtungen', 'Kann Verpachtungen verwalten'), ('view_verpachtungen', 'Kann Verpachtungen anzeigen'), ('manage_foerderungen', 'Kann Förderungen verwalten'), ('view_foerderungen', 'Kann Förderungen anzeigen'), ('manage_documents', 'Kann Dokumente verwalten'), ('view_documents', 'Kann Dokumente anzeigen'), ('link_documents', 'Kann Dokumente verknüpfen'), ('manage_verwaltungskosten', 'Kann Verwaltungskosten verwalten'), ('view_verwaltungskosten', 'Kann Verwaltungskosten anzeigen'), ('approve_payments', 'Kann Zahlungen genehmigen'), ('manage_konten', 'Kann Stiftungskonten verwalten'), ('view_konten', 'Kann Stiftungskonten anzeigen'), ('manage_rentmeister', 'Kann Rentmeister verwalten'), ('view_rentmeister', 'Kann Rentmeister anzeigen'), ('access_administration', 'Kann Administration aufrufen'), ('view_audit_logs', 'Kann Audit-Logs anzeigen'), ('manage_backups', 'Kann Backups erstellen und verwalten'), ('manage_users', 'Kann Benutzer verwalten'), ('manage_permissions', 'Kann Berechtigungen verwalten'), ('import_data', 'Kann Daten importieren'), ('export_data', 'Kann Daten exportieren'), ('access_django_admin', 'Kann Django Admin aufrufen'), ('view_system_stats', 'Kann Systemstatistiken anzeigen')], - 'managed': False, - 'default_permissions': (), + "permissions": [ + ("manage_destinataere", "Kann Destinatäre verwalten"), + ("view_destinataere", "Kann Destinatäre anzeigen"), + ("manage_land", "Kann Ländereien verwalten"), + ("view_land", "Kann Ländereien anzeigen"), + ("manage_paechter", "Kann Pächter verwalten"), + ("view_paechter", "Kann Pächter anzeigen"), + ("manage_verpachtungen", "Kann Verpachtungen verwalten"), + ("view_verpachtungen", "Kann Verpachtungen anzeigen"), + ("manage_foerderungen", "Kann Förderungen verwalten"), + ("view_foerderungen", "Kann Förderungen anzeigen"), + ("manage_documents", "Kann Dokumente verwalten"), + ("view_documents", "Kann Dokumente anzeigen"), + ("link_documents", "Kann Dokumente verknüpfen"), + ("manage_verwaltungskosten", "Kann Verwaltungskosten verwalten"), + ("view_verwaltungskosten", "Kann Verwaltungskosten anzeigen"), + ("approve_payments", "Kann Zahlungen genehmigen"), + ("manage_konten", "Kann Stiftungskonten verwalten"), + ("view_konten", "Kann Stiftungskonten anzeigen"), + ("manage_rentmeister", "Kann Rentmeister verwalten"), + ("view_rentmeister", "Kann Rentmeister anzeigen"), + ("access_administration", "Kann Administration aufrufen"), + ("view_audit_logs", "Kann Audit-Logs anzeigen"), + ("manage_backups", "Kann Backups erstellen und verwalten"), + ("manage_users", "Kann Benutzer verwalten"), + ("manage_permissions", "Kann Berechtigungen verwalten"), + ("import_data", "Kann Daten importieren"), + ("export_data", "Kann Daten exportieren"), + ("access_django_admin", "Kann Django Admin aufrufen"), + ("view_system_stats", "Kann Systemstatistiken anzeigen"), + ], + "managed": False, + "default_permissions": (), }, ), ] diff --git a/app/stiftung/migrations/0017_destinataer_haushaltsgroesse_and_more.py b/app/stiftung/migrations/0017_destinataer_haushaltsgroesse_and_more.py index 32f0a58..19dc900 100644 --- a/app/stiftung/migrations/0017_destinataer_haushaltsgroesse_and_more.py +++ b/app/stiftung/migrations/0017_destinataer_haushaltsgroesse_and_more.py @@ -7,48 +7,74 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('stiftung', '0016_applicationpermission'), + ("stiftung", "0016_applicationpermission"), ] operations = [ migrations.AddField( - model_name='destinataer', - name='haushaltsgroesse', - field=models.PositiveIntegerField(default=1, verbose_name='Haushaltsgröße'), + model_name="destinataer", + name="haushaltsgroesse", + field=models.PositiveIntegerField(default=1, verbose_name="Haushaltsgröße"), ), migrations.AddField( - model_name='destinataer', - name='ist_abkoemmling', - field=models.BooleanField(default=False, verbose_name='Abkömmling gem. Satzung'), + model_name="destinataer", + name="ist_abkoemmling", + field=models.BooleanField( + default=False, verbose_name="Abkömmling gem. Satzung" + ), ), migrations.AddField( - model_name='destinataer', - name='letzter_studiennachweis', - field=models.DateField(blank=True, null=True, verbose_name='Letzter Studiennachweis'), + model_name="destinataer", + name="letzter_studiennachweis", + field=models.DateField( + blank=True, null=True, verbose_name="Letzter Studiennachweis" + ), ), migrations.AddField( - model_name='destinataer', - name='monatliche_bezuege', - field=models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True, verbose_name='Monatliche Bezüge (€)'), + model_name="destinataer", + name="monatliche_bezuege", + field=models.DecimalField( + blank=True, + decimal_places=2, + max_digits=10, + null=True, + verbose_name="Monatliche Bezüge (€)", + ), ), migrations.AddField( - model_name='destinataer', - name='standard_konto', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='stiftung.stiftungskonto', verbose_name='Standard Auszahlungskonto'), + model_name="destinataer", + name="standard_konto", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="stiftung.stiftungskonto", + verbose_name="Standard Auszahlungskonto", + ), ), migrations.AddField( - model_name='destinataer', - name='studiennachweis_erforderlich', - field=models.BooleanField(default=False, verbose_name='Studiennachweis erforderlich'), + model_name="destinataer", + name="studiennachweis_erforderlich", + field=models.BooleanField( + default=False, verbose_name="Studiennachweis erforderlich" + ), ), migrations.AddField( - model_name='destinataer', - name='unterstuetzung_bestaetigt', - field=models.BooleanField(default=False, verbose_name='Unterstützung bestätigt'), + model_name="destinataer", + name="unterstuetzung_bestaetigt", + field=models.BooleanField( + default=False, verbose_name="Unterstützung bestätigt" + ), ), migrations.AddField( - model_name='destinataer', - name='vermoegen', - field=models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True, verbose_name='Vermögen (€)'), + model_name="destinataer", + name="vermoegen", + field=models.DecimalField( + blank=True, + decimal_places=2, + max_digits=12, + null=True, + verbose_name="Vermögen (€)", + ), ), ] diff --git a/app/stiftung/migrations/0018_destinataer_vierteljaehrlicher_betrag.py b/app/stiftung/migrations/0018_destinataer_vierteljaehrlicher_betrag.py index 12dc94b..f24ca63 100644 --- a/app/stiftung/migrations/0018_destinataer_vierteljaehrlicher_betrag.py +++ b/app/stiftung/migrations/0018_destinataer_vierteljaehrlicher_betrag.py @@ -6,13 +6,19 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('stiftung', '0017_destinataer_haushaltsgroesse_and_more'), + ("stiftung", "0017_destinataer_haushaltsgroesse_and_more"), ] operations = [ migrations.AddField( - model_name='destinataer', - name='vierteljaehrlicher_betrag', - field=models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True, verbose_name='Vierteljährlicher Betrag (€)'), + model_name="destinataer", + name="vierteljaehrlicher_betrag", + field=models.DecimalField( + blank=True, + decimal_places=2, + max_digits=12, + null=True, + verbose_name="Vierteljährlicher Betrag (€)", + ), ), ] diff --git a/app/stiftung/migrations/0019_destinataerunterstuetzung.py b/app/stiftung/migrations/0019_destinataerunterstuetzung.py index 516cce0..2e95f5e 100644 --- a/app/stiftung/migrations/0019_destinataerunterstuetzung.py +++ b/app/stiftung/migrations/0019_destinataerunterstuetzung.py @@ -1,35 +1,91 @@ # Generated by Django 5.0.6 on 2025-08-29 13:40 -import django.db.models.deletion import uuid + +import django.db.models.deletion from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('stiftung', '0018_destinataer_vierteljaehrlicher_betrag'), + ("stiftung", "0018_destinataer_vierteljaehrlicher_betrag"), ] operations = [ migrations.CreateModel( - name='DestinataerUnterstuetzung', + name="DestinataerUnterstuetzung", fields=[ - ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), - ('betrag', models.DecimalField(decimal_places=2, max_digits=12, verbose_name='Betrag (€)')), - ('faellig_am', models.DateField(verbose_name='Fällig am')), - ('status', models.CharField(choices=[('geplant', 'Geplant'), ('in_bearbeitung', 'In Bearbeitung'), ('ausgezahlt', 'Ausgezahlt'), ('storniert', 'Storniert')], default='geplant', max_length=20, verbose_name='Status')), - ('beschreibung', models.CharField(blank=True, max_length=255, verbose_name='Beschreibung')), - ('erstellt_am', models.DateTimeField(auto_now_add=True)), - ('aktualisiert_am', models.DateTimeField(auto_now=True)), - ('destinataer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='unterstuetzungen', to='stiftung.destinataer', verbose_name='Destinatär')), - ('konto', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='stiftung.stiftungskonto', verbose_name='Zahlungskonto')), + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ( + "betrag", + models.DecimalField( + decimal_places=2, max_digits=12, verbose_name="Betrag (€)" + ), + ), + ("faellig_am", models.DateField(verbose_name="Fällig am")), + ( + "status", + models.CharField( + choices=[ + ("geplant", "Geplant"), + ("in_bearbeitung", "In Bearbeitung"), + ("ausgezahlt", "Ausgezahlt"), + ("storniert", "Storniert"), + ], + default="geplant", + max_length=20, + verbose_name="Status", + ), + ), + ( + "beschreibung", + models.CharField( + blank=True, max_length=255, verbose_name="Beschreibung" + ), + ), + ("erstellt_am", models.DateTimeField(auto_now_add=True)), + ("aktualisiert_am", models.DateTimeField(auto_now=True)), + ( + "destinataer", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="unterstuetzungen", + to="stiftung.destinataer", + verbose_name="Destinatär", + ), + ), + ( + "konto", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + to="stiftung.stiftungskonto", + verbose_name="Zahlungskonto", + ), + ), ], options={ - 'verbose_name': 'Destinatärunterstützung', - 'verbose_name_plural': 'Destinatärunterstützungen', - 'ordering': ['-faellig_am', '-erstellt_am'], - 'indexes': [models.Index(fields=['status', 'faellig_am'], name='stiftung_de_status_1e9799_idx'), models.Index(fields=['destinataer', 'status'], name='stiftung_de_destina_ba7286_idx')], + "verbose_name": "Destinatärunterstützung", + "verbose_name_plural": "Destinatärunterstützungen", + "ordering": ["-faellig_am", "-erstellt_am"], + "indexes": [ + models.Index( + fields=["status", "faellig_am"], + name="stiftung_de_status_1e9799_idx", + ), + models.Index( + fields=["destinataer", "status"], + name="stiftung_de_destina_ba7286_idx", + ), + ], }, ), ] diff --git a/app/stiftung/migrations/0020_destinataernotiz.py b/app/stiftung/migrations/0020_destinataernotiz.py index 78bd15d..32a2668 100644 --- a/app/stiftung/migrations/0020_destinataernotiz.py +++ b/app/stiftung/migrations/0020_destinataernotiz.py @@ -1,7 +1,8 @@ # Generated by Django 5.0.6 on 2025-08-29 16:05 -import django.db.models.deletion import uuid + +import django.db.models.deletion from django.conf import settings from django.db import migrations, models @@ -9,26 +10,65 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('stiftung', '0019_destinataerunterstuetzung'), + ("stiftung", "0019_destinataerunterstuetzung"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ migrations.CreateModel( - name='DestinataerNotiz', + name="DestinataerNotiz", fields=[ - ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), - ('titel', models.CharField(blank=True, max_length=200, verbose_name='Titel')), - ('text', models.TextField(blank=True, verbose_name='Notiz')), - ('datei', models.FileField(blank=True, null=True, upload_to='destinataer_notizen/', verbose_name='Anhang')), - ('erstellt_am', models.DateTimeField(auto_now_add=True, verbose_name='Erstellt am')), - ('destinataer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notizen_eintraege', to='stiftung.destinataer', verbose_name='Destinatär')), - ('erstellt_von', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Erstellt von')), + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ( + "titel", + models.CharField(blank=True, max_length=200, verbose_name="Titel"), + ), + ("text", models.TextField(blank=True, verbose_name="Notiz")), + ( + "datei", + models.FileField( + blank=True, + null=True, + upload_to="destinataer_notizen/", + verbose_name="Anhang", + ), + ), + ( + "erstellt_am", + models.DateTimeField(auto_now_add=True, verbose_name="Erstellt am"), + ), + ( + "destinataer", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="notizen_eintraege", + to="stiftung.destinataer", + verbose_name="Destinatär", + ), + ), + ( + "erstellt_von", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to=settings.AUTH_USER_MODEL, + verbose_name="Erstellt von", + ), + ), ], options={ - 'verbose_name': 'Destinatär-Notiz', - 'verbose_name_plural': 'Destinatär-Notizen', - 'ordering': ['-erstellt_am'], + "verbose_name": "Destinatär-Notiz", + "verbose_name_plural": "Destinatär-Notizen", + "ordering": ["-erstellt_am"], }, ), ] diff --git a/app/stiftung/migrations/0021_land_adresse_land_aktueller_paechter_and_more.py b/app/stiftung/migrations/0021_land_adresse_land_aktueller_paechter_and_more.py index 657e16b..5a8febf 100644 --- a/app/stiftung/migrations/0021_land_adresse_land_aktueller_paechter_and_more.py +++ b/app/stiftung/migrations/0021_land_adresse_land_aktueller_paechter_and_more.py @@ -1,134 +1,354 @@ # Generated by Django 5.0.6 on 2025-08-30 14:20 +import uuid + import django.core.validators import django.db.models.deletion -import uuid from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('stiftung', '0020_destinataernotiz'), + ("stiftung", "0020_destinataernotiz"), ] operations = [ migrations.AddField( - model_name='land', - name='adresse', - field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Adresse/Ortsangabe'), + model_name="land", + name="adresse", + field=models.CharField( + blank=True, max_length=200, null=True, verbose_name="Adresse/Ortsangabe" + ), ), migrations.AddField( - model_name='land', - name='aktueller_paechter', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='gepachtete_laendereien', to='stiftung.paechter', verbose_name='Aktueller Pächter'), + model_name="land", + name="aktueller_paechter", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="gepachtete_laendereien", + to="stiftung.paechter", + verbose_name="Aktueller Pächter", + ), ), migrations.AddField( - model_name='land', - name='grundbuchblatt', - field=models.CharField(blank=True, max_length=50, null=True, verbose_name='Grundbuchblatt'), + model_name="land", + name="grundbuchblatt", + field=models.CharField( + blank=True, max_length=50, null=True, verbose_name="Grundbuchblatt" + ), ), migrations.AddField( - model_name='land', - name='grundsteuer_umlage', - field=models.BooleanField(default=True, verbose_name='Grundsteuer umlagefähig'), + model_name="land", + name="grundsteuer_umlage", + field=models.BooleanField( + default=True, verbose_name="Grundsteuer umlagefähig" + ), ), migrations.AddField( - model_name='land', - name='jagdpacht_anteil_umlage', - field=models.BooleanField(default=False, verbose_name='Jagdpachtanteile umlagefähig'), + model_name="land", + name="jagdpacht_anteil_umlage", + field=models.BooleanField( + default=False, verbose_name="Jagdpachtanteile umlagefähig" + ), ), migrations.AddField( - model_name='land', - name='pachtbeginn', - field=models.DateField(blank=True, null=True, verbose_name='Pachtbeginn'), + model_name="land", + name="pachtbeginn", + field=models.DateField(blank=True, null=True, verbose_name="Pachtbeginn"), ), migrations.AddField( - model_name='land', - name='pachtende', - field=models.DateField(blank=True, null=True, verbose_name='Pachtende'), + model_name="land", + name="pachtende", + field=models.DateField(blank=True, null=True, verbose_name="Pachtende"), ), migrations.AddField( - model_name='land', - name='pachtzins_pauschal', - field=models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Pachtzins pauschal/Jahr (€)'), + model_name="land", + name="pachtzins_pauschal", + field=models.DecimalField( + blank=True, + decimal_places=2, + max_digits=12, + null=True, + validators=[django.core.validators.MinValueValidator(0)], + verbose_name="Pachtzins pauschal/Jahr (€)", + ), ), migrations.AddField( - model_name='land', - name='pachtzins_pro_ha', - field=models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Pachtzins pro ha (€)'), + model_name="land", + name="pachtzins_pro_ha", + field=models.DecimalField( + blank=True, + decimal_places=2, + max_digits=12, + null=True, + validators=[django.core.validators.MinValueValidator(0)], + verbose_name="Pachtzins pro ha (€)", + ), ), migrations.AddField( - model_name='land', - name='paechter_anschrift', - field=models.TextField(blank=True, null=True, verbose_name='Pächter Anschrift'), + model_name="land", + name="paechter_anschrift", + field=models.TextField( + blank=True, null=True, verbose_name="Pächter Anschrift" + ), ), migrations.AddField( - model_name='land', - name='paechter_name', - field=models.CharField(blank=True, max_length=150, null=True, verbose_name='Pächter Name'), + model_name="land", + name="paechter_name", + field=models.CharField( + blank=True, max_length=150, null=True, verbose_name="Pächter Name" + ), ), migrations.AddField( - model_name='land', - name='ust_option', - field=models.BooleanField(default=False, verbose_name='USt-Option'), + model_name="land", + name="ust_option", + field=models.BooleanField(default=False, verbose_name="USt-Option"), ), migrations.AddField( - model_name='land', - name='ust_satz', - field=models.DecimalField(decimal_places=2, default=19.0, max_digits=4, verbose_name='USt-Satz (%)'), + model_name="land", + name="ust_satz", + field=models.DecimalField( + decimal_places=2, + default=19.0, + max_digits=4, + verbose_name="USt-Satz (%)", + ), ), migrations.AddField( - model_name='land', - name='verbandsbeitraege_umlage', - field=models.BooleanField(default=True, verbose_name='Verbandsbeiträge umlagefähig'), + model_name="land", + name="verbandsbeitraege_umlage", + field=models.BooleanField( + default=True, verbose_name="Verbandsbeiträge umlagefähig" + ), ), migrations.AddField( - model_name='land', - name='verlaengerung_klausel', - field=models.BooleanField(default=False, verbose_name='Automatische Verlängerung'), + model_name="land", + name="verlaengerung_klausel", + field=models.BooleanField( + default=False, verbose_name="Automatische Verlängerung" + ), ), migrations.AddField( - model_name='land', - name='versicherungen_umlage', - field=models.BooleanField(default=True, verbose_name='Versicherungen umlagefähig'), + model_name="land", + name="versicherungen_umlage", + field=models.BooleanField( + default=True, verbose_name="Versicherungen umlagefähig" + ), ), migrations.AddField( - model_name='land', - name='zahlungsweise', - field=models.CharField(choices=[('jaehrlich', 'Jährlich'), ('halbjaehrlich', 'Halbjährlich'), ('vierteljaehrlich', 'Vierteljährlich'), ('monatlich', 'Monatlich')], default='jaehrlich', max_length=20, verbose_name='Zahlungsweise'), + model_name="land", + name="zahlungsweise", + field=models.CharField( + choices=[ + ("jaehrlich", "Jährlich"), + ("halbjaehrlich", "Halbjährlich"), + ("vierteljaehrlich", "Vierteljährlich"), + ("monatlich", "Monatlich"), + ], + default="jaehrlich", + max_length=20, + verbose_name="Zahlungsweise", + ), ), migrations.CreateModel( - name='LandAbrechnung', + name="LandAbrechnung", fields=[ - ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), - ('abrechnungsjahr', models.IntegerField(validators=[django.core.validators.MinValueValidator(2000)], verbose_name='Abrechnungsjahr')), - ('pacht_vereinnahmt', models.DecimalField(decimal_places=2, default=0, max_digits=12, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Pacht vereinnahmt (€)')), - ('umlagen_vereinnahmt', models.DecimalField(decimal_places=2, default=0, max_digits=12, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Umlagen vereinnahmt (€)')), - ('sonstige_einnahmen', models.DecimalField(decimal_places=2, default=0, max_digits=12, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Sonstige Einnahmen (€)')), - ('zahlungen', models.JSONField(blank=True, help_text='Liste von Objekten {datum, betrag, art}', null=True, verbose_name='Zahlungstermine')), - ('grundsteuer_bescheid_nr', models.CharField(blank=True, max_length=80, null=True, verbose_name='Grundsteuer-Bescheid Nr.')), - ('grundsteuer_betrag', models.DecimalField(decimal_places=2, default=0, max_digits=12, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Grundsteuer Betrag (€)')), - ('versicherungen_betrag', models.DecimalField(decimal_places=2, default=0, max_digits=12, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Versicherungen Betrag (€)')), - ('verbandsbeitraege_betrag', models.DecimalField(decimal_places=2, default=0, max_digits=12, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Verbandsbeiträge Betrag (€)')), - ('sonstige_abgaben_betrag', models.DecimalField(decimal_places=2, default=0, max_digits=12, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Sonstige öffentliche Abgaben (€)')), - ('instandhaltung_betrag', models.DecimalField(decimal_places=2, default=0, max_digits=12, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Instandhaltung/Reparaturen (€)')), - ('verwaltung_recht_betrag', models.DecimalField(decimal_places=2, default=0, max_digits=12, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Verwaltung/Recht (€)')), - ('vorsteuer_aus_umlagen', models.DecimalField(decimal_places=2, default=0, max_digits=12, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Vorsteuer aus umgelegten Kosten (€)')), - ('offene_posten', models.DecimalField(decimal_places=2, default=0, max_digits=12, verbose_name='Offene Posten (€)')), - ('bemerkungen', models.TextField(blank=True, null=True, verbose_name='Bemerkungen Abrechnung')), - ('pachtvertrag_datei', models.FileField(blank=True, null=True, upload_to='land_abrechnungen/vertraege/', verbose_name='Pachtvertrag (Datei)')), - ('grundsteuer_bescheid_datei', models.FileField(blank=True, null=True, upload_to='land_abrechnungen/bescheide/', verbose_name='Grundsteuerbescheid (Datei)')), - ('versicherungsnachweis_datei', models.FileField(blank=True, null=True, upload_to='land_abrechnungen/versicherungen/', verbose_name='Versicherungsnachweis (Datei)')), - ('erstellt_am', models.DateTimeField(auto_now_add=True)), - ('aktualisiert_am', models.DateTimeField(auto_now=True)), - ('land', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='abrechnungen', to='stiftung.land', verbose_name='Länderei')), + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ( + "abrechnungsjahr", + models.IntegerField( + validators=[django.core.validators.MinValueValidator(2000)], + verbose_name="Abrechnungsjahr", + ), + ), + ( + "pacht_vereinnahmt", + models.DecimalField( + decimal_places=2, + default=0, + max_digits=12, + validators=[django.core.validators.MinValueValidator(0)], + verbose_name="Pacht vereinnahmt (€)", + ), + ), + ( + "umlagen_vereinnahmt", + models.DecimalField( + decimal_places=2, + default=0, + max_digits=12, + validators=[django.core.validators.MinValueValidator(0)], + verbose_name="Umlagen vereinnahmt (€)", + ), + ), + ( + "sonstige_einnahmen", + models.DecimalField( + decimal_places=2, + default=0, + max_digits=12, + validators=[django.core.validators.MinValueValidator(0)], + verbose_name="Sonstige Einnahmen (€)", + ), + ), + ( + "zahlungen", + models.JSONField( + blank=True, + help_text="Liste von Objekten {datum, betrag, art}", + null=True, + verbose_name="Zahlungstermine", + ), + ), + ( + "grundsteuer_bescheid_nr", + models.CharField( + blank=True, + max_length=80, + null=True, + verbose_name="Grundsteuer-Bescheid Nr.", + ), + ), + ( + "grundsteuer_betrag", + models.DecimalField( + decimal_places=2, + default=0, + max_digits=12, + validators=[django.core.validators.MinValueValidator(0)], + verbose_name="Grundsteuer Betrag (€)", + ), + ), + ( + "versicherungen_betrag", + models.DecimalField( + decimal_places=2, + default=0, + max_digits=12, + validators=[django.core.validators.MinValueValidator(0)], + verbose_name="Versicherungen Betrag (€)", + ), + ), + ( + "verbandsbeitraege_betrag", + models.DecimalField( + decimal_places=2, + default=0, + max_digits=12, + validators=[django.core.validators.MinValueValidator(0)], + verbose_name="Verbandsbeiträge Betrag (€)", + ), + ), + ( + "sonstige_abgaben_betrag", + models.DecimalField( + decimal_places=2, + default=0, + max_digits=12, + validators=[django.core.validators.MinValueValidator(0)], + verbose_name="Sonstige öffentliche Abgaben (€)", + ), + ), + ( + "instandhaltung_betrag", + models.DecimalField( + decimal_places=2, + default=0, + max_digits=12, + validators=[django.core.validators.MinValueValidator(0)], + verbose_name="Instandhaltung/Reparaturen (€)", + ), + ), + ( + "verwaltung_recht_betrag", + models.DecimalField( + decimal_places=2, + default=0, + max_digits=12, + validators=[django.core.validators.MinValueValidator(0)], + verbose_name="Verwaltung/Recht (€)", + ), + ), + ( + "vorsteuer_aus_umlagen", + models.DecimalField( + decimal_places=2, + default=0, + max_digits=12, + validators=[django.core.validators.MinValueValidator(0)], + verbose_name="Vorsteuer aus umgelegten Kosten (€)", + ), + ), + ( + "offene_posten", + models.DecimalField( + decimal_places=2, + default=0, + max_digits=12, + verbose_name="Offene Posten (€)", + ), + ), + ( + "bemerkungen", + models.TextField( + blank=True, null=True, verbose_name="Bemerkungen Abrechnung" + ), + ), + ( + "pachtvertrag_datei", + models.FileField( + blank=True, + null=True, + upload_to="land_abrechnungen/vertraege/", + verbose_name="Pachtvertrag (Datei)", + ), + ), + ( + "grundsteuer_bescheid_datei", + models.FileField( + blank=True, + null=True, + upload_to="land_abrechnungen/bescheide/", + verbose_name="Grundsteuerbescheid (Datei)", + ), + ), + ( + "versicherungsnachweis_datei", + models.FileField( + blank=True, + null=True, + upload_to="land_abrechnungen/versicherungen/", + verbose_name="Versicherungsnachweis (Datei)", + ), + ), + ("erstellt_am", models.DateTimeField(auto_now_add=True)), + ("aktualisiert_am", models.DateTimeField(auto_now=True)), + ( + "land", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="abrechnungen", + to="stiftung.land", + verbose_name="Länderei", + ), + ), ], options={ - 'verbose_name': 'Landabrechnung', - 'verbose_name_plural': 'Landabrechnungen', - 'ordering': ['-abrechnungsjahr', 'land__gemeinde', 'land__gemarkung'], - 'unique_together': {('land', 'abrechnungsjahr')}, + "verbose_name": "Landabrechnung", + "verbose_name_plural": "Landabrechnungen", + "ordering": ["-abrechnungsjahr", "land__gemeinde", "land__gemarkung"], + "unique_together": {("land", "abrechnungsjahr")}, }, ), ] diff --git a/app/stiftung/migrations/0022_dokumentlink_land_verpachtung_id_and_more.py b/app/stiftung/migrations/0022_dokumentlink_land_verpachtung_id_and_more.py index 7bab9df..c5da371 100644 --- a/app/stiftung/migrations/0022_dokumentlink_land_verpachtung_id_and_more.py +++ b/app/stiftung/migrations/0022_dokumentlink_land_verpachtung_id_and_more.py @@ -1,57 +1,185 @@ # Generated by Django 5.0.6 on 2025-08-30 16:59 +import uuid + import django.core.validators import django.db.models.deletion -import uuid from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('stiftung', '0021_land_adresse_land_aktueller_paechter_and_more'), + ("stiftung", "0021_land_adresse_land_aktueller_paechter_and_more"), ] operations = [ migrations.AddField( - model_name='dokumentlink', - name='land_verpachtung_id', - field=models.UUIDField(blank=True, null=True, verbose_name='Landverpachtung ID (Neu)'), + model_name="dokumentlink", + name="land_verpachtung_id", + field=models.UUIDField( + blank=True, null=True, verbose_name="Landverpachtung ID (Neu)" + ), ), migrations.AlterField( - model_name='dokumentlink', - name='verpachtung_id', - field=models.UUIDField(blank=True, null=True, verbose_name='Verpachtung ID (Legacy)'), + model_name="dokumentlink", + name="verpachtung_id", + field=models.UUIDField( + blank=True, null=True, verbose_name="Verpachtung ID (Legacy)" + ), ), migrations.CreateModel( - name='LandVerpachtung', + name="LandVerpachtung", fields=[ - ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), - ('vertragsnummer', models.CharField(max_length=50, unique=True, verbose_name='Vertragsnummer')), - ('pachtbeginn', models.DateField(verbose_name='Pachtbeginn')), - ('pachtende', models.DateField(blank=True, null=True, verbose_name='Pachtende')), - ('verlaengerung_klausel', models.BooleanField(default=False, verbose_name='Automatische Verlängerung')), - ('verpachtete_flaeche', models.DecimalField(decimal_places=2, max_digits=12, validators=[django.core.validators.MinValueValidator(0.01)], verbose_name='Verpachtete Fläche (qm)')), - ('pachtzins_pauschal', models.DecimalField(decimal_places=2, max_digits=12, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Pachtzins pauschal/Jahr (€)')), - ('pachtzins_pro_ha', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Pachtzins pro ha (€)')), - ('zahlungsweise', models.CharField(choices=[('jaehrlich', 'Jährlich'), ('halbjaehrlich', 'Halbjährlich'), ('vierteljaehrlich', 'Vierteljährlich'), ('monatlich', 'Monatlich')], default='jaehrlich', max_length=20, verbose_name='Zahlungsweise')), - ('ust_option', models.BooleanField(default=False, verbose_name='USt-Option')), - ('ust_satz', models.DecimalField(decimal_places=2, default=19.0, max_digits=4, verbose_name='USt-Satz (%)')), - ('grundsteuer_umlage', models.BooleanField(default=True, verbose_name='Grundsteuer umlagefähig')), - ('versicherungen_umlage', models.BooleanField(default=True, verbose_name='Versicherungen umlagefähig')), - ('verbandsbeitraege_umlage', models.BooleanField(default=True, verbose_name='Verbandsbeiträge umlagefähig')), - ('jagdpacht_anteil_umlage', models.BooleanField(default=False, verbose_name='Jagdpachtanteile umlagefähig')), - ('status', models.CharField(choices=[('aktiv', 'Aktiv'), ('beendet', 'Beendet'), ('gekuendigt', 'Gekündigt'), ('verlängert', 'Verlängert')], default='aktiv', max_length=20, verbose_name='Status')), - ('bemerkungen', models.TextField(blank=True, null=True, verbose_name='Bemerkungen')), - ('erstellt_am', models.DateTimeField(auto_now_add=True)), - ('aktualisiert_am', models.DateTimeField(auto_now=True)), - ('land', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='neue_verpachtungen', to='stiftung.land', verbose_name='Länderei')), - ('paechter', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='neue_verpachtungen', to='stiftung.paechter', verbose_name='Pächter')), + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ( + "vertragsnummer", + models.CharField( + max_length=50, unique=True, verbose_name="Vertragsnummer" + ), + ), + ("pachtbeginn", models.DateField(verbose_name="Pachtbeginn")), + ( + "pachtende", + models.DateField(blank=True, null=True, verbose_name="Pachtende"), + ), + ( + "verlaengerung_klausel", + models.BooleanField( + default=False, verbose_name="Automatische Verlängerung" + ), + ), + ( + "verpachtete_flaeche", + models.DecimalField( + decimal_places=2, + max_digits=12, + validators=[django.core.validators.MinValueValidator(0.01)], + verbose_name="Verpachtete Fläche (qm)", + ), + ), + ( + "pachtzins_pauschal", + models.DecimalField( + decimal_places=2, + max_digits=12, + validators=[django.core.validators.MinValueValidator(0)], + verbose_name="Pachtzins pauschal/Jahr (€)", + ), + ), + ( + "pachtzins_pro_ha", + models.DecimalField( + blank=True, + decimal_places=2, + max_digits=12, + null=True, + validators=[django.core.validators.MinValueValidator(0)], + verbose_name="Pachtzins pro ha (€)", + ), + ), + ( + "zahlungsweise", + models.CharField( + choices=[ + ("jaehrlich", "Jährlich"), + ("halbjaehrlich", "Halbjährlich"), + ("vierteljaehrlich", "Vierteljährlich"), + ("monatlich", "Monatlich"), + ], + default="jaehrlich", + max_length=20, + verbose_name="Zahlungsweise", + ), + ), + ( + "ust_option", + models.BooleanField(default=False, verbose_name="USt-Option"), + ), + ( + "ust_satz", + models.DecimalField( + decimal_places=2, + default=19.0, + max_digits=4, + verbose_name="USt-Satz (%)", + ), + ), + ( + "grundsteuer_umlage", + models.BooleanField( + default=True, verbose_name="Grundsteuer umlagefähig" + ), + ), + ( + "versicherungen_umlage", + models.BooleanField( + default=True, verbose_name="Versicherungen umlagefähig" + ), + ), + ( + "verbandsbeitraege_umlage", + models.BooleanField( + default=True, verbose_name="Verbandsbeiträge umlagefähig" + ), + ), + ( + "jagdpacht_anteil_umlage", + models.BooleanField( + default=False, verbose_name="Jagdpachtanteile umlagefähig" + ), + ), + ( + "status", + models.CharField( + choices=[ + ("aktiv", "Aktiv"), + ("beendet", "Beendet"), + ("gekuendigt", "Gekündigt"), + ("verlängert", "Verlängert"), + ], + default="aktiv", + max_length=20, + verbose_name="Status", + ), + ), + ( + "bemerkungen", + models.TextField(blank=True, null=True, verbose_name="Bemerkungen"), + ), + ("erstellt_am", models.DateTimeField(auto_now_add=True)), + ("aktualisiert_am", models.DateTimeField(auto_now=True)), + ( + "land", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="neue_verpachtungen", + to="stiftung.land", + verbose_name="Länderei", + ), + ), + ( + "paechter", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="neue_verpachtungen", + to="stiftung.paechter", + verbose_name="Pächter", + ), + ), ], options={ - 'verbose_name': 'Landverpachtung', - 'verbose_name_plural': 'Landverpachtungen', - 'ordering': ['-pachtbeginn', 'land'], + "verbose_name": "Landverpachtung", + "verbose_name_plural": "Landverpachtungen", + "ordering": ["-pachtbeginn", "land"], }, ), ] diff --git a/app/stiftung/migrations/0023_remove_legacy_verpachtung.py b/app/stiftung/migrations/0023_remove_legacy_verpachtung.py index e99e416..1f2ed89 100644 --- a/app/stiftung/migrations/0023_remove_legacy_verpachtung.py +++ b/app/stiftung/migrations/0023_remove_legacy_verpachtung.py @@ -6,11 +6,11 @@ from django.db import migrations class Migration(migrations.Migration): dependencies = [ - ('stiftung', '0022_dokumentlink_land_verpachtung_id_and_more'), + ("stiftung", "0022_dokumentlink_land_verpachtung_id_and_more"), ] operations = [ migrations.DeleteModel( - name='Verpachtung', + name="Verpachtung", ), ] diff --git a/app/stiftung/migrations/0024_dokumentlink_abrechnung_id.py b/app/stiftung/migrations/0024_dokumentlink_abrechnung_id.py index 7dfb952..1b34321 100644 --- a/app/stiftung/migrations/0024_dokumentlink_abrechnung_id.py +++ b/app/stiftung/migrations/0024_dokumentlink_abrechnung_id.py @@ -6,13 +6,13 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('stiftung', '0023_remove_legacy_verpachtung'), + ("stiftung", "0023_remove_legacy_verpachtung"), ] operations = [ migrations.AddField( - model_name='dokumentlink', - name='abrechnung_id', - field=models.UUIDField(blank=True, null=True, verbose_name='Abrechnung ID'), + model_name="dokumentlink", + name="abrechnung_id", + field=models.UUIDField(blank=True, null=True, verbose_name="Abrechnung ID"), ), ] diff --git a/app/stiftung/migrations/0025_appconfiguration.py b/app/stiftung/migrations/0025_appconfiguration.py index fc33980..3aa5d18 100644 --- a/app/stiftung/migrations/0025_appconfiguration.py +++ b/app/stiftung/migrations/0025_appconfiguration.py @@ -1,37 +1,90 @@ # Generated by Django 5.0.6 on 2025-08-31 22:08 import uuid + from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('stiftung', '0024_dokumentlink_abrechnung_id'), + ("stiftung", "0024_dokumentlink_abrechnung_id"), ] operations = [ migrations.CreateModel( - name='AppConfiguration', + name="AppConfiguration", fields=[ - ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), - ('key', models.CharField(max_length=100, unique=True, verbose_name='Setting Key')), - ('display_name', models.CharField(max_length=200, verbose_name='Display Name')), - ('description', models.TextField(blank=True, null=True, verbose_name='Description')), - ('value', models.TextField(verbose_name='Value')), - ('default_value', models.TextField(verbose_name='Default Value')), - ('setting_type', models.CharField(choices=[('text', 'Text'), ('number', 'Number'), ('boolean', 'Boolean'), ('url', 'URL'), ('tag', 'Tag Name'), ('tag_id', 'Tag ID')], default='text', max_length=20, verbose_name='Type')), - ('category', models.CharField(choices=[('paperless', 'Paperless Integration'), ('general', 'General Settings'), ('notifications', 'Notifications'), ('system', 'System Settings')], default='general', max_length=50, verbose_name='Category')), - ('is_active', models.BooleanField(default=True, verbose_name='Active')), - ('is_system', models.BooleanField(default=False, verbose_name='System Setting (read-only)')), - ('order', models.IntegerField(default=0, verbose_name='Display Order')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ( + "key", + models.CharField( + max_length=100, unique=True, verbose_name="Setting Key" + ), + ), + ( + "display_name", + models.CharField(max_length=200, verbose_name="Display Name"), + ), + ( + "description", + models.TextField(blank=True, null=True, verbose_name="Description"), + ), + ("value", models.TextField(verbose_name="Value")), + ("default_value", models.TextField(verbose_name="Default Value")), + ( + "setting_type", + models.CharField( + choices=[ + ("text", "Text"), + ("number", "Number"), + ("boolean", "Boolean"), + ("url", "URL"), + ("tag", "Tag Name"), + ("tag_id", "Tag ID"), + ], + default="text", + max_length=20, + verbose_name="Type", + ), + ), + ( + "category", + models.CharField( + choices=[ + ("paperless", "Paperless Integration"), + ("general", "General Settings"), + ("notifications", "Notifications"), + ("system", "System Settings"), + ], + default="general", + max_length=50, + verbose_name="Category", + ), + ), + ("is_active", models.BooleanField(default=True, verbose_name="Active")), + ( + "is_system", + models.BooleanField( + default=False, verbose_name="System Setting (read-only)" + ), + ), + ("order", models.IntegerField(default=0, verbose_name="Display Order")), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), ], options={ - 'verbose_name': 'App Configuration', - 'verbose_name_plural': 'App Configurations', - 'ordering': ['category', 'order', 'display_name'], + "verbose_name": "App Configuration", + "verbose_name_plural": "App Configurations", + "ordering": ["category", "order", "display_name"], }, ), ] diff --git a/app/stiftung/migrations/0026_enhance_unterstuetzung_model.py b/app/stiftung/migrations/0026_enhance_unterstuetzung_model.py index c043555..d7504dd 100644 --- a/app/stiftung/migrations/0026_enhance_unterstuetzung_model.py +++ b/app/stiftung/migrations/0026_enhance_unterstuetzung_model.py @@ -1,7 +1,8 @@ # Generated by Django 5.0.6 on 2025-09-01 20:03 -import django.db.models.deletion import uuid + +import django.db.models.deletion from django.conf import settings from django.db import migrations, models @@ -9,81 +10,192 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('stiftung', '0025_appconfiguration'), + ("stiftung", "0025_appconfiguration"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ migrations.AddField( - model_name='destinataerunterstuetzung', - name='ausgezahlt_am', - field=models.DateField(blank=True, null=True, verbose_name='Ausgezahlt am'), + model_name="destinataerunterstuetzung", + name="ausgezahlt_am", + field=models.DateField(blank=True, null=True, verbose_name="Ausgezahlt am"), ), migrations.AddField( - model_name='destinataerunterstuetzung', - name='ausgezahlt_von', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Ausgezahlt von'), + model_name="destinataerunterstuetzung", + name="ausgezahlt_von", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to=settings.AUTH_USER_MODEL, + verbose_name="Ausgezahlt von", + ), ), migrations.AddField( - model_name='destinataerunterstuetzung', - name='empfaenger_iban', - field=models.CharField(blank=True, max_length=34, verbose_name='Empfänger IBAN'), + model_name="destinataerunterstuetzung", + name="empfaenger_iban", + field=models.CharField( + blank=True, max_length=34, verbose_name="Empfänger IBAN" + ), ), migrations.AddField( - model_name='destinataerunterstuetzung', - name='empfaenger_name', - field=models.CharField(blank=True, max_length=200, verbose_name='Empfänger Name'), + model_name="destinataerunterstuetzung", + name="empfaenger_name", + field=models.CharField( + blank=True, max_length=200, verbose_name="Empfänger Name" + ), ), migrations.AddField( - model_name='destinataerunterstuetzung', - name='verwendungszweck', - field=models.CharField(blank=True, max_length=140, verbose_name='Verwendungszweck'), + model_name="destinataerunterstuetzung", + name="verwendungszweck", + field=models.CharField( + blank=True, max_length=140, verbose_name="Verwendungszweck" + ), ), migrations.AlterField( - model_name='destinataerunterstuetzung', - name='status', - field=models.CharField(choices=[('geplant', 'Geplant'), ('faellig', 'Fällig'), ('in_bearbeitung', 'In Bearbeitung'), ('ausgezahlt', 'Ausgezahlt'), ('storniert', 'Storniert')], default='geplant', max_length=20, verbose_name='Status'), + model_name="destinataerunterstuetzung", + name="status", + field=models.CharField( + choices=[ + ("geplant", "Geplant"), + ("faellig", "Fällig"), + ("in_bearbeitung", "In Bearbeitung"), + ("ausgezahlt", "Ausgezahlt"), + ("storniert", "Storniert"), + ], + default="geplant", + max_length=20, + verbose_name="Status", + ), ), migrations.CreateModel( - name='UnterstuetzungWiederkehrend', + name="UnterstuetzungWiederkehrend", fields=[ - ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), - ('betrag', models.DecimalField(decimal_places=2, max_digits=12, verbose_name='Betrag (€)')), - ('intervall', models.CharField(choices=[('monatlich', 'Monatlich'), ('quartalsweise', 'Vierteljährlich'), ('halbjaehrlich', 'Halbjährlich'), ('jaehrlich', 'Jährlich')], max_length=20, verbose_name='Intervall')), - ('beschreibung', models.CharField(blank=True, max_length=255, verbose_name='Beschreibung')), - ('empfaenger_iban', models.CharField(max_length=34, verbose_name='Empfänger IBAN')), - ('empfaenger_name', models.CharField(max_length=200, verbose_name='Empfänger Name')), - ('verwendungszweck', models.CharField(blank=True, max_length=140, verbose_name='Verwendungszweck')), - ('erste_zahlung_am', models.DateField(verbose_name='Erste Zahlung am')), - ('letzte_zahlung_am', models.DateField(blank=True, null=True, verbose_name='Letzte Zahlung am (optional)')), - ('naechste_generierung', models.DateField(verbose_name='Nächste Generierung')), - ('aktiv', models.BooleanField(default=True, verbose_name='Aktiv')), - ('erstellt_am', models.DateTimeField(auto_now_add=True)), - ('destinataer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='wiederkehrende_unterstuetzungen', to='stiftung.destinataer', verbose_name='Destinatär')), - ('erstellt_von', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Erstellt von')), - ('konto', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='stiftung.stiftungskonto', verbose_name='Zahlungskonto')), + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ( + "betrag", + models.DecimalField( + decimal_places=2, max_digits=12, verbose_name="Betrag (€)" + ), + ), + ( + "intervall", + models.CharField( + choices=[ + ("monatlich", "Monatlich"), + ("quartalsweise", "Vierteljährlich"), + ("halbjaehrlich", "Halbjährlich"), + ("jaehrlich", "Jährlich"), + ], + max_length=20, + verbose_name="Intervall", + ), + ), + ( + "beschreibung", + models.CharField( + blank=True, max_length=255, verbose_name="Beschreibung" + ), + ), + ( + "empfaenger_iban", + models.CharField(max_length=34, verbose_name="Empfänger IBAN"), + ), + ( + "empfaenger_name", + models.CharField(max_length=200, verbose_name="Empfänger Name"), + ), + ( + "verwendungszweck", + models.CharField( + blank=True, max_length=140, verbose_name="Verwendungszweck" + ), + ), + ("erste_zahlung_am", models.DateField(verbose_name="Erste Zahlung am")), + ( + "letzte_zahlung_am", + models.DateField( + blank=True, + null=True, + verbose_name="Letzte Zahlung am (optional)", + ), + ), + ( + "naechste_generierung", + models.DateField(verbose_name="Nächste Generierung"), + ), + ("aktiv", models.BooleanField(default=True, verbose_name="Aktiv")), + ("erstellt_am", models.DateTimeField(auto_now_add=True)), + ( + "destinataer", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="wiederkehrende_unterstuetzungen", + to="stiftung.destinataer", + verbose_name="Destinatär", + ), + ), + ( + "erstellt_von", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to=settings.AUTH_USER_MODEL, + verbose_name="Erstellt von", + ), + ), + ( + "konto", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + to="stiftung.stiftungskonto", + verbose_name="Zahlungskonto", + ), + ), ], options={ - 'verbose_name': 'Wiederkehrende Unterstützung', - 'verbose_name_plural': 'Wiederkehrende Unterstützungen', - 'ordering': ['-erstellt_am'], + "verbose_name": "Wiederkehrende Unterstützung", + "verbose_name_plural": "Wiederkehrende Unterstützungen", + "ordering": ["-erstellt_am"], }, ), migrations.AddField( - model_name='destinataerunterstuetzung', - name='wiederkehrend_von', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='stiftung.unterstuetzungwiederkehrend', verbose_name='Wiederkehrende Zahlung'), + model_name="destinataerunterstuetzung", + name="wiederkehrend_von", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="stiftung.unterstuetzungwiederkehrend", + verbose_name="Wiederkehrende Zahlung", + ), ), migrations.AddIndex( - model_name='destinataerunterstuetzung', - index=models.Index(fields=['wiederkehrend_von'], name='stiftung_de_wiederk_3d5afc_idx'), + model_name="destinataerunterstuetzung", + index=models.Index( + fields=["wiederkehrend_von"], name="stiftung_de_wiederk_3d5afc_idx" + ), ), migrations.AddIndex( - model_name='unterstuetzungwiederkehrend', - index=models.Index(fields=['aktiv', 'naechste_generierung'], name='stiftung_un_aktiv_b957e5_idx'), + model_name="unterstuetzungwiederkehrend", + index=models.Index( + fields=["aktiv", "naechste_generierung"], + name="stiftung_un_aktiv_b957e5_idx", + ), ), migrations.AddIndex( - model_name='unterstuetzungwiederkehrend', - index=models.Index(fields=['destinataer', 'aktiv'], name='stiftung_un_destina_2232fc_idx'), + model_name="unterstuetzungwiederkehrend", + index=models.Index( + fields=["destinataer", "aktiv"], name="stiftung_un_destina_2232fc_idx" + ), ), ] diff --git a/app/stiftung/migrations/0027_helpbox_alter_appconfiguration_category.py b/app/stiftung/migrations/0027_helpbox_alter_appconfiguration_category.py index 6c55aca..8eff4b7 100644 --- a/app/stiftung/migrations/0027_helpbox_alter_appconfiguration_category.py +++ b/app/stiftung/migrations/0027_helpbox_alter_appconfiguration_category.py @@ -1,38 +1,106 @@ # Generated by Django 5.0.6 on 2025-09-02 19:56 import uuid + from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('stiftung', '0026_enhance_unterstuetzung_model'), + ("stiftung", "0026_enhance_unterstuetzung_model"), ] operations = [ migrations.CreateModel( - name='HelpBox', + name="HelpBox", fields=[ - ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), - ('page_key', models.CharField(choices=[('destinataer_new', 'Neuer Destinatär'), ('unterstuetzung_new', 'Neue Unterstützung'), ('foerderung_new', 'Neue Förderung'), ('paechter_new', 'Neuer Pächter'), ('laenderei_new', 'Neue Länderei'), ('verpachtung_new', 'Neue Verpachtung'), ('person_new', 'Neue Person'), ('konto_new', 'Neues Konto')], max_length=50, unique=True, verbose_name='Seite')), - ('title', models.CharField(max_length=200, verbose_name='Titel der Hilfsbox')), - ('content', models.TextField(help_text='Sie können Markdown verwenden: **fett**, *kursiv*, `code`, [Link](url), etc.', verbose_name='Inhalt (Markdown unterstützt)')), - ('is_active', models.BooleanField(default=True, verbose_name='Aktiv')), - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Erstellt am')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Aktualisiert am')), - ('created_by', models.CharField(blank=True, max_length=100, null=True, verbose_name='Erstellt von')), - ('updated_by', models.CharField(blank=True, max_length=100, null=True, verbose_name='Aktualisiert von')), + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ( + "page_key", + models.CharField( + choices=[ + ("destinataer_new", "Neuer Destinatär"), + ("unterstuetzung_new", "Neue Unterstützung"), + ("foerderung_new", "Neue Förderung"), + ("paechter_new", "Neuer Pächter"), + ("laenderei_new", "Neue Länderei"), + ("verpachtung_new", "Neue Verpachtung"), + ("person_new", "Neue Person"), + ("konto_new", "Neues Konto"), + ], + max_length=50, + unique=True, + verbose_name="Seite", + ), + ), + ( + "title", + models.CharField(max_length=200, verbose_name="Titel der Hilfsbox"), + ), + ( + "content", + models.TextField( + help_text="Sie können Markdown verwenden: **fett**, *kursiv*, `code`, [Link](url), etc.", + verbose_name="Inhalt (Markdown unterstützt)", + ), + ), + ("is_active", models.BooleanField(default=True, verbose_name="Aktiv")), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="Erstellt am"), + ), + ( + "updated_at", + models.DateTimeField(auto_now=True, verbose_name="Aktualisiert am"), + ), + ( + "created_by", + models.CharField( + blank=True, + max_length=100, + null=True, + verbose_name="Erstellt von", + ), + ), + ( + "updated_by", + models.CharField( + blank=True, + max_length=100, + null=True, + verbose_name="Aktualisiert von", + ), + ), ], options={ - 'verbose_name': 'Hilfs-Infobox', - 'verbose_name_plural': 'Hilfs-Infoboxen', - 'ordering': ['page_key'], + "verbose_name": "Hilfs-Infobox", + "verbose_name_plural": "Hilfs-Infoboxen", + "ordering": ["page_key"], }, ), migrations.AlterField( - model_name='appconfiguration', - name='category', - field=models.CharField(choices=[('paperless', 'Paperless Integration'), ('general', 'General Settings'), ('corporate', 'Corporate Identity'), ('notifications', 'Notifications'), ('system', 'System Settings')], default='general', max_length=50, verbose_name='Category'), + model_name="appconfiguration", + name="category", + field=models.CharField( + choices=[ + ("paperless", "Paperless Integration"), + ("general", "General Settings"), + ("corporate", "Corporate Identity"), + ("notifications", "Notifications"), + ("system", "System Settings"), + ], + default="general", + max_length=50, + verbose_name="Category", + ), ), ] diff --git a/app/stiftung/migrations/0028_alter_helpbox_page_key.py b/app/stiftung/migrations/0028_alter_helpbox_page_key.py index 4b3885f..d15deff 100644 --- a/app/stiftung/migrations/0028_alter_helpbox_page_key.py +++ b/app/stiftung/migrations/0028_alter_helpbox_page_key.py @@ -6,13 +6,34 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('stiftung', '0027_helpbox_alter_appconfiguration_category'), + ("stiftung", "0027_helpbox_alter_appconfiguration_category"), ] operations = [ migrations.AlterField( - model_name='helpbox', - name='page_key', - field=models.CharField(choices=[('destinataer_new', 'Neuer Destinatär'), ('unterstuetzung_new', 'Neue Unterstützung'), ('foerderung_new', 'Neue Förderung'), ('paechter_new', 'Neuer Pächter'), ('laenderei_new', 'Neue Länderei'), ('verpachtung_new', 'Neue Verpachtung'), ('land_abrechnung_new', 'Neue Landabrechnung'), ('person_new', 'Neue Person'), ('konto_new', 'Neues Konto'), ('verwaltungskosten_new', 'Neue Verwaltungskosten'), ('rentmeister_new', 'Neuer Rentmeister'), ('dokument_new', 'Neues Dokument'), ('user_new', 'Neuer Benutzer'), ('csv_import_new', 'CSV Import'), ('destinataer_notiz_new', 'Destinatär Notiz')], max_length=50, unique=True, verbose_name='Seite'), + model_name="helpbox", + name="page_key", + field=models.CharField( + choices=[ + ("destinataer_new", "Neuer Destinatär"), + ("unterstuetzung_new", "Neue Unterstützung"), + ("foerderung_new", "Neue Förderung"), + ("paechter_new", "Neuer Pächter"), + ("laenderei_new", "Neue Länderei"), + ("verpachtung_new", "Neue Verpachtung"), + ("land_abrechnung_new", "Neue Landabrechnung"), + ("person_new", "Neue Person"), + ("konto_new", "Neues Konto"), + ("verwaltungskosten_new", "Neue Verwaltungskosten"), + ("rentmeister_new", "Neuer Rentmeister"), + ("dokument_new", "Neues Dokument"), + ("user_new", "Neuer Benutzer"), + ("csv_import_new", "CSV Import"), + ("destinataer_notiz_new", "Destinatär Notiz"), + ], + max_length=50, + unique=True, + verbose_name="Seite", + ), ), ] diff --git a/app/stiftung/models.py b/app/stiftung/models.py index 870d0eb..0db750b 100644 --- a/app/stiftung/models.py +++ b/app/stiftung/models.py @@ -1,113 +1,139 @@ -import uuid import csv +import uuid from io import StringIO -from django.db import models -from django.core.validators import MinValueValidator, MaxValueValidator -from django.utils import timezone + from dateutil.relativedelta import relativedelta +from django.core.validators import MaxValueValidator, MinValueValidator +from django.db import models +from django.utils import timezone + from stiftung.utils.date_utils import ensure_date, get_year_from_date + class CSVImport(models.Model): """Track CSV import operations for audit purposes""" - + IMPORT_TYPE_CHOICES = [ - ('destinataere', 'Destinatäre'), - ('paechter', 'Pächter'), - ('laendereien', 'Ländereien'), - ('verpachtungen', 'Verpachtungen'), - ('personen', 'Personen (Legacy)'), + ("destinataere", "Destinatäre"), + ("paechter", "Pächter"), + ("laendereien", "Ländereien"), + ("verpachtungen", "Verpachtungen"), + ("personen", "Personen (Legacy)"), ] - + STATUS_CHOICES = [ - ('pending', 'Ausstehend'), - ('processing', 'Wird verarbeitet'), - ('completed', 'Abgeschlossen'), - ('failed', 'Fehlgeschlagen'), - ('partial', 'Teilweise erfolgreich'), + ("pending", "Ausstehend"), + ("processing", "Wird verarbeitet"), + ("completed", "Abgeschlossen"), + ("failed", "Fehlgeschlagen"), + ("partial", "Teilweise erfolgreich"), ] - + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - import_type = models.CharField(max_length=20, choices=IMPORT_TYPE_CHOICES, verbose_name="Import-Typ") + import_type = models.CharField( + max_length=20, choices=IMPORT_TYPE_CHOICES, verbose_name="Import-Typ" + ) filename = models.CharField(max_length=255, verbose_name="Dateiname") file_size = models.IntegerField(verbose_name="Dateigröße (Bytes)") - status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending') - + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="pending") + # Results total_rows = models.IntegerField(default=0, verbose_name="Gesamtzeilen") imported_rows = models.IntegerField(default=0, verbose_name="Importierte Zeilen") failed_rows = models.IntegerField(default=0, verbose_name="Fehlgeschlagene Zeilen") error_log = models.TextField(null=True, blank=True, verbose_name="Fehlerprotokoll") - + # Metadata - created_by = models.CharField(max_length=100, null=True, blank=True, verbose_name="Erstellt von") + created_by = models.CharField( + max_length=100, null=True, blank=True, verbose_name="Erstellt von" + ) started_at = models.DateTimeField(auto_now_add=True, verbose_name="Gestartet um") - completed_at = models.DateTimeField(null=True, blank=True, verbose_name="Abgeschlossen um") - + completed_at = models.DateTimeField( + null=True, blank=True, verbose_name="Abgeschlossen um" + ) + class Meta: verbose_name = "CSV Import" verbose_name_plural = "CSV Imports" - ordering = ['-started_at'] + ordering = ["-started_at"] def __str__(self): return f"{self.get_import_type_display()} - {self.filename} ({self.status})" - + def get_duration(self): """Calculate import duration""" if self.completed_at and self.started_at: return self.completed_at - self.started_at return None - + def get_success_rate(self): """Calculate success rate percentage""" if self.total_rows > 0: return (self.imported_rows / self.total_rows) * 100 return 0 + class Paechter(models.Model): """Pächter (Tenants) für Ländereien und Verpachtungen""" - + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) vorname = models.CharField(max_length=100, verbose_name="Vorname") nachname = models.CharField(max_length=100, verbose_name="Nachname") geburtsdatum = models.DateField(null=True, blank=True, verbose_name="Geburtsdatum") email = models.EmailField(null=True, blank=True, verbose_name="E-Mail") - telefon = models.CharField(max_length=20, null=True, blank=True, verbose_name="Telefon") + telefon = models.CharField( + max_length=20, null=True, blank=True, verbose_name="Telefon" + ) iban = models.CharField(max_length=34, null=True, blank=True, verbose_name="IBAN") - + # Adressfelder - strasse = models.CharField(max_length=200, verbose_name="Straße", blank=True, null=True) + strasse = models.CharField( + max_length=200, verbose_name="Straße", blank=True, null=True + ) plz = models.CharField(max_length=10, verbose_name="PLZ", blank=True, null=True) ort = models.CharField(max_length=100, verbose_name="Ort", blank=True, null=True) - + # Typ des Pächters PERSONENTYP_CHOICES = [ - ('natuerlich', 'Natürliche Person'), - ('gesellschaft', 'Gesellschaft (GmbH, KG, etc.)'), + ("natuerlich", "Natürliche Person"), + ("gesellschaft", "Gesellschaft (GmbH, KG, etc.)"), ] personentyp = models.CharField( - max_length=20, - choices=PERSONENTYP_CHOICES, - default='natuerlich', - verbose_name="Typ des Pächters" + max_length=20, + choices=PERSONENTYP_CHOICES, + default="natuerlich", + verbose_name="Typ des Pächters", ) - + # Pacht-spezifische Felder - pachtnummer = models.CharField(max_length=50, null=True, blank=True, verbose_name="Pachtnummer") - pachtbeginn_erste = models.DateField(null=True, blank=True, verbose_name="Erster Pachtbeginn") - pachtende_letzte = models.DateField(null=True, blank=True, verbose_name="Letztes Pachtende") - pachtzins_aktuell = models.DecimalField( - max_digits=12, - decimal_places=2, - null=True, - blank=True, - verbose_name="Aktueller Pachtzins (€/Jahr)" + pachtnummer = models.CharField( + max_length=50, null=True, blank=True, verbose_name="Pachtnummer" ) - + pachtbeginn_erste = models.DateField( + null=True, blank=True, verbose_name="Erster Pachtbeginn" + ) + pachtende_letzte = models.DateField( + null=True, blank=True, verbose_name="Letztes Pachtende" + ) + pachtzins_aktuell = models.DecimalField( + max_digits=12, + decimal_places=2, + null=True, + blank=True, + verbose_name="Aktueller Pachtzins (€/Jahr)", + ) + # Landwirtschaftliche Informationen - landwirtschaftliche_ausbildung = models.BooleanField(default=False, verbose_name="Landwirtschaftliche Ausbildung") - berufserfahrung_jahre = models.IntegerField(null=True, blank=True, verbose_name="Berufserfahrung (Jahre)") - spezialisierung = models.CharField(max_length=100, null=True, blank=True, verbose_name="Spezialisierung") - + landwirtschaftliche_ausbildung = models.BooleanField( + default=False, verbose_name="Landwirtschaftliche Ausbildung" + ) + berufserfahrung_jahre = models.IntegerField( + null=True, blank=True, verbose_name="Berufserfahrung (Jahre)" + ) + spezialisierung = models.CharField( + max_length=100, null=True, blank=True, verbose_name="Spezialisierung" + ) + # Kontakt und Notizen notizen = models.TextField(null=True, blank=True, verbose_name="Notizen") aktiv = models.BooleanField(default=True, verbose_name="Aktiv") @@ -115,130 +141,190 @@ class Paechter(models.Model): class Meta: verbose_name = "Pächter" verbose_name_plural = "Pächter" - ordering = ['nachname', 'vorname'] + ordering = ["nachname", "vorname"] def __str__(self): if self.vorname: return f"{self.nachname}, {self.vorname}" else: return self.nachname - + def get_full_name(self): if self.vorname: return f"{self.vorname} {self.nachname}" else: return self.nachname - + def get_aktive_verpachtungen(self): """Get all active leases for this tenant""" - return self.neue_verpachtungen.filter(status='aktiv') - + return self.neue_verpachtungen.filter(status="aktiv") + def get_gesamt_pachtflaeche(self): """Calculate total leased area""" - return self.neue_verpachtungen.filter(status='aktiv').aggregate( - total=models.Sum('verpachtete_flaeche') - )['total'] or 0 - + return ( + self.neue_verpachtungen.filter(status="aktiv").aggregate( + total=models.Sum("verpachtete_flaeche") + )["total"] + or 0 + ) + def get_gesamt_pachtzins(self): """Calculate total annual rent""" - return self.neue_verpachtungen.filter(status='aktiv').aggregate( - total=models.Sum('pachtzins_pauschal') - )['total'] or 0 + return ( + self.neue_verpachtungen.filter(status="aktiv").aggregate( + total=models.Sum("pachtzins_pauschal") + )["total"] + or 0 + ) + class Destinataer(models.Model): """Destinatäre (Beneficiaries) für Förderungen""" - + FAMILIENZWIG_CHOICES = [ - ('hauptzweig', 'Hauptzweig'), - ('nebenzweig', 'Nebenzweig'), - ('verwandt', 'Verwandt'), - ('anderer', 'Anderer'), + ("hauptzweig", "Hauptzweig"), + ("nebenzweig", "Nebenzweig"), + ("verwandt", "Verwandt"), + ("anderer", "Anderer"), ] - + BERUFSGRUPPE_CHOICES = [ - ('student', 'Student/Studentin'), - ('wissenschaftler', 'Wissenschaftler/in'), - ('künstler', 'Künstler/in'), - ('sozialarbeiter', 'Sozialarbeiter/in'), - ('umweltschützer', 'Umweltschützer/in'), - ('andere', 'Andere'), + ("student", "Student/Studentin"), + ("wissenschaftler", "Wissenschaftler/in"), + ("künstler", "Künstler/in"), + ("sozialarbeiter", "Sozialarbeiter/in"), + ("umweltschützer", "Umweltschützer/in"), + ("andere", "Andere"), ] - + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - familienzweig = models.CharField(max_length=100, choices=FAMILIENZWIG_CHOICES, default='hauptzweig') + familienzweig = models.CharField( + max_length=100, choices=FAMILIENZWIG_CHOICES, default="hauptzweig" + ) vorname = models.CharField(max_length=100, verbose_name="Vorname") nachname = models.CharField(max_length=100, verbose_name="Nachname") geburtsdatum = models.DateField(null=True, blank=True, verbose_name="Geburtsdatum") email = models.EmailField(null=True, blank=True, verbose_name="E-Mail") - telefon = models.CharField(max_length=20, null=True, blank=True, verbose_name="Telefon") + telefon = models.CharField( + max_length=20, null=True, blank=True, verbose_name="Telefon" + ) iban = models.CharField(max_length=34, null=True, blank=True, verbose_name="IBAN") - + # Adressfelder - strasse = models.CharField(max_length=200, verbose_name="Straße", blank=True, null=True) + strasse = models.CharField( + max_length=200, verbose_name="Straße", blank=True, null=True + ) plz = models.CharField(max_length=10, verbose_name="PLZ", blank=True, null=True) ort = models.CharField(max_length=100, verbose_name="Ort", blank=True, null=True) - + # Förderungs-spezifische Felder - berufsgruppe = models.CharField(max_length=20, choices=BERUFSGRUPPE_CHOICES, default='andere', verbose_name="Berufsgruppe") - ausbildungsstand = models.CharField(max_length=100, null=True, blank=True, verbose_name="Ausbildungsstand") - institution = models.CharField(max_length=200, null=True, blank=True, verbose_name="Institution/Organisation") - projekt_beschreibung = models.TextField(null=True, blank=True, verbose_name="Projektbeschreibung") - + berufsgruppe = models.CharField( + max_length=20, + choices=BERUFSGRUPPE_CHOICES, + default="andere", + verbose_name="Berufsgruppe", + ) + ausbildungsstand = models.CharField( + max_length=100, null=True, blank=True, verbose_name="Ausbildungsstand" + ) + institution = models.CharField( + max_length=200, null=True, blank=True, verbose_name="Institution/Organisation" + ) + projekt_beschreibung = models.TextField( + null=True, blank=True, verbose_name="Projektbeschreibung" + ) + # Finanzielle Informationen jaehrliches_einkommen = models.DecimalField( - max_digits=12, - decimal_places=2, - null=True, - blank=True, - verbose_name="Jährliches Einkommen (€)" + max_digits=12, + decimal_places=2, + null=True, + blank=True, + verbose_name="Jährliches Einkommen (€)", ) - finanzielle_notlage = models.BooleanField(default=False, verbose_name="Finanzielle Notlage") - + finanzielle_notlage = models.BooleanField( + default=False, verbose_name="Finanzielle Notlage" + ) + # Kontakt und Notizen notizen = models.TextField(null=True, blank=True, verbose_name="Notizen") aktiv = models.BooleanField(default=True, verbose_name="Aktiv") # Unterstützung – Prüf- und Verwaltungsfelder - ist_abkoemmling = models.BooleanField(default=False, verbose_name="Abkömmling gem. Satzung") - haushaltsgroesse = models.PositiveIntegerField(default=1, verbose_name="Haushaltsgröße") - monatliche_bezuege = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True, verbose_name="Monatliche Bezüge (€)") - vermoegen = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True, verbose_name="Vermögen (€)") - unterstuetzung_bestaetigt = models.BooleanField(default=False, verbose_name="Unterstützung bestätigt") - standard_konto = models.ForeignKey('StiftungsKonto', null=True, blank=True, on_delete=models.SET_NULL, verbose_name="Standard Auszahlungskonto") - vierteljaehrlicher_betrag = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True, verbose_name="Vierteljährlicher Betrag (€)") + ist_abkoemmling = models.BooleanField( + default=False, verbose_name="Abkömmling gem. Satzung" + ) + haushaltsgroesse = models.PositiveIntegerField( + default=1, verbose_name="Haushaltsgröße" + ) + monatliche_bezuege = models.DecimalField( + max_digits=10, + decimal_places=2, + null=True, + blank=True, + verbose_name="Monatliche Bezüge (€)", + ) + vermoegen = models.DecimalField( + max_digits=12, + decimal_places=2, + null=True, + blank=True, + verbose_name="Vermögen (€)", + ) + unterstuetzung_bestaetigt = models.BooleanField( + default=False, verbose_name="Unterstützung bestätigt" + ) + standard_konto = models.ForeignKey( + "StiftungsKonto", + null=True, + blank=True, + on_delete=models.SET_NULL, + verbose_name="Standard Auszahlungskonto", + ) + vierteljaehrlicher_betrag = models.DecimalField( + max_digits=12, + decimal_places=2, + null=True, + blank=True, + verbose_name="Vierteljährlicher Betrag (€)", + ) # Studiennachweise - studiennachweis_erforderlich = models.BooleanField(default=False, verbose_name="Studiennachweis erforderlich") - letzter_studiennachweis = models.DateField(null=True, blank=True, verbose_name="Letzter Studiennachweis") + studiennachweis_erforderlich = models.BooleanField( + default=False, verbose_name="Studiennachweis erforderlich" + ) + letzter_studiennachweis = models.DateField( + null=True, blank=True, verbose_name="Letzter Studiennachweis" + ) class Meta: verbose_name = "Destinatär" verbose_name_plural = "Destinatäre" - ordering = ['nachname', 'vorname'] + ordering = ["nachname", "vorname"] def __str__(self): if self.vorname: return f"{self.nachname}, {self.vorname}" else: return self.nachname - + def get_full_name(self): if self.vorname: return f"{self.vorname} {self.nachname}" else: return self.nachname - + def get_total_foerderungen(self): """Calculate total funding received""" - return self.foerderung_set.aggregate(total=models.Sum('betrag'))['total'] or 0 - + return self.foerderung_set.aggregate(total=models.Sum("betrag"))["total"] or 0 + def get_foerderungen_count(self): """Count total funding grants""" return self.foerderung_set.count() - + def get_letzte_foerderung(self): """Get the most recent funding grant""" - return self.foerderung_set.order_by('-jahr', '-betrag').first() + return self.foerderung_set.order_by("-jahr", "-betrag").first() def erfuellt_voraussetzungen(self): """Prüft die Unterstützungsvoraussetzungen gemäß Angaben. @@ -249,17 +335,21 @@ class Destinataer(models.Model): und + 0.8 * Regelsatz je weiterer Person approximiert. """ from decimal import Decimal - regelsatz = Decimal('563.00') + + regelsatz = Decimal("563.00") basis = regelsatz * 5 - zuschlag = max(0, (self.haushaltsgroesse or 1) - 1) * (regelsatz * Decimal('0.80')) + zuschlag = max(0, (self.haushaltsgroesse or 1) - 1) * ( + regelsatz * Decimal("0.80") + ) grenze = basis + zuschlag - einkommen_ok = (self.monatliche_bezuege or Decimal('0')) <= grenze - vermoegen_ok = (self.vermoegen or Decimal('0')) <= Decimal('15500') + einkommen_ok = (self.monatliche_bezuege or Decimal("0")) <= grenze + vermoegen_ok = (self.vermoegen or Decimal("0")) <= Decimal("15500") return bool(self.ist_abkoemmling and einkommen_ok and vermoegen_ok) def naechste_studiennachweis_termine(self): """Gibt die nächsten beiden Stichtage (15.03, 15.09) zurück.""" import datetime as _dt + today = _dt.date.today() jahr = today.year maerz = _dt.date(jahr, 3, 15) @@ -275,17 +365,20 @@ class Destinataer(models.Model): termine.append(_dt.date(jahr + 1, 9, 15)) return termine[:2] + # Keep the old Person model for backward compatibility (will be removed in future) class Person(models.Model): FAMILIENZWIG_CHOICES = [ - ('hauptzweig', 'Hauptzweig'), - ('nebenzweig', 'Nebenzweig'), - ('verwandt', 'Verwandt'), - ('anderer', 'Anderer'), + ("hauptzweig", "Hauptzweig"), + ("nebenzweig", "Nebenzweig"), + ("verwandt", "Verwandt"), + ("anderer", "Anderer"), ] - + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - familienzweig = models.CharField(max_length=100, choices=FAMILIENZWIG_CHOICES, default='hauptzweig') + familienzweig = models.CharField( + max_length=100, choices=FAMILIENZWIG_CHOICES, default="hauptzweig" + ) vorname = models.CharField(max_length=100) nachname = models.CharField(max_length=100) geburtsdatum = models.DateField(null=True, blank=True) @@ -299,176 +392,196 @@ class Person(models.Model): class Meta: verbose_name = "Person (Legacy)" verbose_name_plural = "Personen (Legacy)" - ordering = ['nachname', 'vorname'] + ordering = ["nachname", "vorname"] def __str__(self): return f"{self.nachname}, {self.vorname} (Legacy)" - + def get_full_name(self): return f"{self.vorname} {self.nachname}" - + def get_total_foerderungen(self): - return self.foerderung_set.aggregate(total=models.Sum('betrag'))['total'] or 0 + return self.foerderung_set.aggregate(total=models.Sum("betrag"))["total"] or 0 + class Land(models.Model): """Landverwaltung für verpachtete Ländereien""" - + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - + # Grundlegende Identifikation lfd_nr = models.CharField(max_length=20, unique=True, verbose_name="Lfd. Nr.") - ew_nummer = models.CharField(max_length=50, null=True, blank=True, verbose_name="EW-Nummer") - grundbuchblatt = models.CharField(max_length=50, null=True, blank=True, verbose_name="Grundbuchblatt") - + ew_nummer = models.CharField( + max_length=50, null=True, blank=True, verbose_name="EW-Nummer" + ) + grundbuchblatt = models.CharField( + max_length=50, null=True, blank=True, verbose_name="Grundbuchblatt" + ) + # Gerichtliche Zuständigkeit amtsgericht = models.CharField(max_length=100, verbose_name="Amtsgericht") - + # Verwaltungsstruktur gemeinde = models.CharField(max_length=100, verbose_name="Gemeinde") gemarkung = models.CharField(max_length=100, verbose_name="Gemarkung") flur = models.CharField(max_length=50, verbose_name="Flur") flurstueck = models.CharField(max_length=50, verbose_name="Flurstück") - adresse = models.CharField(max_length=200, null=True, blank=True, verbose_name="Adresse/Ortsangabe") - + adresse = models.CharField( + max_length=200, null=True, blank=True, verbose_name="Adresse/Ortsangabe" + ) + # Flächenangaben groesse_qm = models.DecimalField( - max_digits=12, - decimal_places=2, + max_digits=12, + decimal_places=2, verbose_name="Größe in qm", - validators=[MinValueValidator(0.01)] + validators=[MinValueValidator(0.01)], ) - + # Landnutzung gruenland_qm = models.DecimalField( - max_digits=12, - decimal_places=2, + max_digits=12, + decimal_places=2, default=0, verbose_name="Grünland (qm)", - validators=[MinValueValidator(0)] + validators=[MinValueValidator(0)], ) acker_qm = models.DecimalField( - max_digits=12, - decimal_places=2, + max_digits=12, + decimal_places=2, default=0, verbose_name="Acker (qm)", - validators=[MinValueValidator(0)] + validators=[MinValueValidator(0)], ) wald_qm = models.DecimalField( - max_digits=12, - decimal_places=2, + max_digits=12, + decimal_places=2, default=0, verbose_name="Wald (qm)", - validators=[MinValueValidator(0)] + validators=[MinValueValidator(0)], ) sonstiges_qm = models.DecimalField( - max_digits=12, - decimal_places=2, + max_digits=12, + decimal_places=2, default=0, verbose_name="Sonstiges (qm)", - validators=[MinValueValidator(0)] + validators=[MinValueValidator(0)], ) - + # Verpachtung (Legacy-Felder für Kompatibilität) verpachtete_gesamtflaeche = models.DecimalField( - max_digits=12, - decimal_places=2, + max_digits=12, + decimal_places=2, verbose_name="Verpachtete Gesamtfläche (qm)", - validators=[MinValueValidator(0)] + validators=[MinValueValidator(0)], ) flaeche_alte_liste = models.DecimalField( - max_digits=12, - decimal_places=2, - null=True, + max_digits=12, + decimal_places=2, + null=True, blank=True, - verbose_name="Fläche alte Liste (qm)" + verbose_name="Fläche alte Liste (qm)", ) verp_flaeche_aktuell = models.DecimalField( - max_digits=12, - decimal_places=2, + max_digits=12, + decimal_places=2, verbose_name="Verp. Fläche aktuell (qm)", - validators=[MinValueValidator(0)] + validators=[MinValueValidator(0)], ) - + # Aktuelle Verpachtung (Neue Struktur) aktueller_paechter = models.ForeignKey( - 'Paechter', - on_delete=models.SET_NULL, - null=True, - blank=True, + "Paechter", + on_delete=models.SET_NULL, + null=True, + blank=True, verbose_name="Aktueller Pächter", - related_name="gepachtete_laendereien" + related_name="gepachtete_laendereien", + ) + paechter_name = models.CharField( + max_length=150, null=True, blank=True, verbose_name="Pächter Name" + ) + paechter_anschrift = models.TextField( + null=True, blank=True, verbose_name="Pächter Anschrift" ) - paechter_name = models.CharField(max_length=150, null=True, blank=True, verbose_name="Pächter Name") - paechter_anschrift = models.TextField(null=True, blank=True, verbose_name="Pächter Anschrift") pachtbeginn = models.DateField(null=True, blank=True, verbose_name="Pachtbeginn") pachtende = models.DateField(null=True, blank=True, verbose_name="Pachtende") - verlaengerung_klausel = models.BooleanField(default=False, verbose_name="Automatische Verlängerung") - + verlaengerung_klausel = models.BooleanField( + default=False, verbose_name="Automatische Verlängerung" + ) + # Pachtzins und Zahlungsweise ZAHLUNGSWEISE_CHOICES = [ - ('jaehrlich', 'Jährlich'), - ('halbjaehrlich', 'Halbjährlich'), - ('vierteljaehrlich', 'Vierteljährlich'), - ('monatlich', 'Monatlich'), + ("jaehrlich", "Jährlich"), + ("halbjaehrlich", "Halbjährlich"), + ("vierteljaehrlich", "Vierteljährlich"), + ("monatlich", "Monatlich"), ] zahlungsweise = models.CharField( - max_length=20, - choices=ZAHLUNGSWEISE_CHOICES, - default='jaehrlich', - verbose_name="Zahlungsweise" + max_length=20, + choices=ZAHLUNGSWEISE_CHOICES, + default="jaehrlich", + verbose_name="Zahlungsweise", ) pachtzins_pro_ha = models.DecimalField( - max_digits=12, - decimal_places=2, - null=True, - blank=True, + max_digits=12, + decimal_places=2, + null=True, + blank=True, verbose_name="Pachtzins pro ha (€)", - validators=[MinValueValidator(0)] + validators=[MinValueValidator(0)], ) pachtzins_pauschal = models.DecimalField( - max_digits=12, - decimal_places=2, - null=True, - blank=True, + max_digits=12, + decimal_places=2, + null=True, + blank=True, verbose_name="Pachtzins pauschal/Jahr (€)", - validators=[MinValueValidator(0)] + validators=[MinValueValidator(0)], ) - + # Umsatzsteuer ust_option = models.BooleanField(default=False, verbose_name="USt-Option") 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, verbose_name="USt-Satz (%)" ) - + # Umlagen (Durchreichungen) - grundsteuer_umlage = models.BooleanField(default=True, verbose_name="Grundsteuer umlagefähig") - versicherungen_umlage = models.BooleanField(default=True, verbose_name="Versicherungen umlagefähig") - verbandsbeitraege_umlage = models.BooleanField(default=True, verbose_name="Verbandsbeiträge umlagefähig") - jagdpacht_anteil_umlage = models.BooleanField(default=False, verbose_name="Jagdpachtanteile umlagefähig") - + grundsteuer_umlage = models.BooleanField( + default=True, verbose_name="Grundsteuer umlagefähig" + ) + versicherungen_umlage = models.BooleanField( + default=True, verbose_name="Versicherungen umlagefähig" + ) + verbandsbeitraege_umlage = models.BooleanField( + default=True, verbose_name="Verbandsbeiträge umlagefähig" + ) + jagdpacht_anteil_umlage = models.BooleanField( + default=False, verbose_name="Jagdpachtanteile umlagefähig" + ) + # Steuern und Abgaben anteil_grundsteuer = models.DecimalField( - max_digits=8, - decimal_places=2, - null=True, + max_digits=8, + decimal_places=2, + null=True, blank=True, - verbose_name="Anteil Grundsteuer (%)" + verbose_name="Anteil Grundsteuer (%)", ) anteil_lwk = models.DecimalField( - max_digits=8, - decimal_places=2, - null=True, + max_digits=8, + decimal_places=2, + null=True, blank=True, - verbose_name="Anteil LWK (%)" + verbose_name="Anteil LWK (%)", ) - + # Status aktiv = models.BooleanField(default=True, verbose_name="Aktiv") - notizen = models.TextField(null=True, blank=True, verbose_name="Ergänzende Kommentare") - + notizen = models.TextField( + null=True, blank=True, verbose_name="Ergänzende Kommentare" + ) + # Zeitstempel erstellt_am = models.DateTimeField(auto_now_add=True) aktualisiert_am = models.DateTimeField(auto_now=True) @@ -476,110 +589,116 @@ class Land(models.Model): class Meta: verbose_name = "Land" verbose_name_plural = "Ländereien" - ordering = ['gemeinde', 'gemarkung', 'flur', 'flurstueck'] + ordering = ["gemeinde", "gemarkung", "flur", "flurstueck"] def __str__(self): return f"{self.gemeinde} - {self.gemarkung} Flur {self.flur} Flurstück {self.flurstueck}" - + def get_gesamtflaeche(self): """Berechnet die Gesamtfläche aus allen Nutzungsarten""" - return (self.gruenland_qm + self.acker_qm + self.wald_qm + self.sonstiges_qm) - + return self.gruenland_qm + self.acker_qm + self.wald_qm + self.sonstiges_qm + def get_verpachtungsgrad(self): """Berechnet den Verpachtungsgrad in Prozent""" if self.get_gesamtflaeche() > 0: - return (self.get_verpachtete_flaeche_aktuell() / self.get_gesamtflaeche()) * 100 + return ( + self.get_verpachtete_flaeche_aktuell() / self.get_gesamtflaeche() + ) * 100 return 0 - + def get_verpachtete_flaeche_aktuell(self): """Gibt die aktuell verpachtete Fläche zurück (aus neuen Verpachtungen oder Legacy)""" from django.db.models import Sum - + # Priorität 1: Neue Verpachtungen (LandVerpachtung) - neue_total = self.neue_verpachtungen.filter(status='aktiv').aggregate( - total=Sum('verpachtete_flaeche') - )['total'] or 0 - + neue_total = ( + self.neue_verpachtungen.filter(status="aktiv").aggregate( + total=Sum("verpachtete_flaeche") + )["total"] + or 0 + ) + if neue_total > 0: return neue_total - + # Priorität 2: Einzelverpachtung im Land-Model (verp_flaeche_aktuell) if self.verp_flaeche_aktuell and self.verp_flaeche_aktuell > 0: return self.verp_flaeche_aktuell - + # No legacy system - return neue_total (could be 0) return neue_total - + def get_verfuegbare_flaeche(self): """Berechnet die noch verfügbare Fläche für neue Verpachtungen""" return self.groesse_qm - self.get_verpachtete_flaeche_aktuell() - + def get_verpachtungsgrad_neu(self): """Berechnet den Verpachtungsgrad basierend auf neuen Verpachtungen""" if self.groesse_qm > 0: return (self.get_verpachtete_flaeche_aktuell() / self.groesse_qm) * 100 return 0 - + def get_steuer_gesamt(self): """Berechnet den Gesamtsteueranteil""" grundsteuer = self.anteil_grundsteuer or 0 lwk = self.anteil_lwk or 0 return grundsteuer + lwk - + def _qm_to_hektar(self, qm_value): """Hilfsmethode zur Umrechnung von qm in Hektar""" - from decimal import Decimal, ROUND_HALF_UP + from decimal import ROUND_HALF_UP, Decimal + if qm_value and qm_value > 0: # Umrechnung: 1 Hektar = 10.000 qm - hektar = Decimal(str(qm_value)) / Decimal('10000') + hektar = Decimal(str(qm_value)) / Decimal("10000") # Runden auf 2 Nachkommastellen - return hektar.quantize(Decimal('0.01'), rounding=ROUND_HALF_UP) - return Decimal('0.00') - + return hektar.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + return Decimal("0.00") + @property def groesse_hektar(self): """Berechnet die Gesamtgröße in Hektar""" return self._qm_to_hektar(self.groesse_qm) - + @property def gruenland_hektar(self): """Berechnet die Grünlandfläche in Hektar""" return self._qm_to_hektar(self.gruenland_qm) - + @property def acker_hektar(self): """Berechnet die Ackerfläche in Hektar""" return self._qm_to_hektar(self.acker_qm) - + @property def wald_hektar(self): """Berechnet die Waldfläche in Hektar""" return self._qm_to_hektar(self.wald_qm) - + @property def sonstiges_hektar(self): """Berechnet die sonstige Fläche in Hektar""" return self._qm_to_hektar(self.sonstiges_qm) - + @property def verpachtete_gesamtflaeche_hektar(self): """Berechnet die verpachtete Gesamtfläche in Hektar""" return self._qm_to_hektar(self.verpachtete_gesamtflaeche) - + @property def flaeche_alte_liste_hektar(self): """Berechnet die Fläche aus alter Liste in Hektar""" return self._qm_to_hektar(self.flaeche_alte_liste) - + @property def verp_flaeche_aktuell_hektar(self): """Berechnet die aktuell verpachtete Fläche in Hektar""" return self._qm_to_hektar(self.verp_flaeche_aktuell) - + def get_gesamtflaeche_hektar(self): """Berechnet die Gesamtfläche aus allen Nutzungsarten in Hektar""" return self._qm_to_hektar(self.get_gesamtflaeche()) - + def get_verpachtete_flaeche_aktuell_hektar(self): """Berechnet die aktuell verpachtete Fläche basierend auf aktiven Verpachtungen in Hektar""" return self._qm_to_hektar(self.get_verpachtete_flaeche_aktuell()) @@ -587,211 +706,235 @@ class Land(models.Model): class LandVerpachtung(models.Model): """Neue Verpachtungsverträge - mehrere pro Land möglich""" - + STATUS_CHOICES = [ - ('aktiv', 'Aktiv'), - ('beendet', 'Beendet'), - ('gekuendigt', 'Gekündigt'), - ('verlängert', 'Verlängert'), + ("aktiv", "Aktiv"), + ("beendet", "Beendet"), + ("gekuendigt", "Gekündigt"), + ("verlängert", "Verlängert"), ] - + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - + # Grundlegende Verknüpfungen - land = models.ForeignKey(Land, on_delete=models.CASCADE, related_name='neue_verpachtungen', verbose_name="Länderei") - paechter = models.ForeignKey(Paechter, on_delete=models.CASCADE, related_name='neue_verpachtungen', verbose_name="Pächter") - + land = models.ForeignKey( + Land, + on_delete=models.CASCADE, + related_name="neue_verpachtungen", + verbose_name="Länderei", + ) + paechter = models.ForeignKey( + Paechter, + on_delete=models.CASCADE, + related_name="neue_verpachtungen", + verbose_name="Pächter", + ) + # Vertragsdaten - vertragsnummer = models.CharField(max_length=50, unique=True, verbose_name="Vertragsnummer") + vertragsnummer = models.CharField( + max_length=50, unique=True, verbose_name="Vertragsnummer" + ) pachtbeginn = models.DateField(verbose_name="Pachtbeginn") pachtende = models.DateField(null=True, blank=True, verbose_name="Pachtende") - verlaengerung_klausel = models.BooleanField(default=False, verbose_name="Automatische Verlängerung") - + verlaengerung_klausel = models.BooleanField( + default=False, verbose_name="Automatische Verlängerung" + ) + # Flächenangaben verpachtete_flaeche = models.DecimalField( - max_digits=12, - decimal_places=2, + max_digits=12, + decimal_places=2, verbose_name="Verpachtete Fläche (qm)", - validators=[MinValueValidator(0.01)] + validators=[MinValueValidator(0.01)], ) - + # Pachtzins pachtzins_pauschal = models.DecimalField( - max_digits=12, - decimal_places=2, + max_digits=12, + decimal_places=2, verbose_name="Pachtzins pauschal/Jahr (€)", - validators=[MinValueValidator(0)] + validators=[MinValueValidator(0)], ) pachtzins_pro_ha = models.DecimalField( - max_digits=12, - decimal_places=2, - null=True, - blank=True, + max_digits=12, + decimal_places=2, + null=True, + blank=True, verbose_name="Pachtzins pro ha (€)", - validators=[MinValueValidator(0)] + validators=[MinValueValidator(0)], ) - + # Zahlungsweise ZAHLUNGSWEISE_CHOICES = [ - ('jaehrlich', 'Jährlich'), - ('halbjaehrlich', 'Halbjährlich'), - ('vierteljaehrlich', 'Vierteljährlich'), - ('monatlich', 'Monatlich'), + ("jaehrlich", "Jährlich"), + ("halbjaehrlich", "Halbjährlich"), + ("vierteljaehrlich", "Vierteljährlich"), + ("monatlich", "Monatlich"), ] zahlungsweise = models.CharField( - max_length=20, - choices=ZAHLUNGSWEISE_CHOICES, - default='jaehrlich', - verbose_name="Zahlungsweise" + max_length=20, + choices=ZAHLUNGSWEISE_CHOICES, + default="jaehrlich", + verbose_name="Zahlungsweise", ) - + # Umsatzsteuer ust_option = models.BooleanField(default=False, verbose_name="USt-Option") 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, verbose_name="USt-Satz (%)" ) - + # Umlagen (Durchreichungen) - grundsteuer_umlage = models.BooleanField(default=True, verbose_name="Grundsteuer umlagefähig") - versicherungen_umlage = models.BooleanField(default=True, verbose_name="Versicherungen umlagefähig") - verbandsbeitraege_umlage = models.BooleanField(default=True, verbose_name="Verbandsbeiträge umlagefähig") - jagdpacht_anteil_umlage = models.BooleanField(default=False, verbose_name="Jagdpachtanteile umlagefähig") - + grundsteuer_umlage = models.BooleanField( + default=True, verbose_name="Grundsteuer umlagefähig" + ) + versicherungen_umlage = models.BooleanField( + default=True, verbose_name="Versicherungen umlagefähig" + ) + verbandsbeitraege_umlage = models.BooleanField( + default=True, verbose_name="Verbandsbeiträge umlagefähig" + ) + jagdpacht_anteil_umlage = models.BooleanField( + default=False, verbose_name="Jagdpachtanteile umlagefähig" + ) + # Status und Notizen - status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='aktiv', verbose_name="Status") + status = models.CharField( + max_length=20, choices=STATUS_CHOICES, default="aktiv", verbose_name="Status" + ) bemerkungen = models.TextField(null=True, blank=True, verbose_name="Bemerkungen") - + # Zeitstempel erstellt_am = models.DateTimeField(auto_now_add=True) aktualisiert_am = models.DateTimeField(auto_now=True) - + class Meta: verbose_name = "Landverpachtung" verbose_name_plural = "Landverpachtungen" - ordering = ['-pachtbeginn', 'land'] - + ordering = ["-pachtbeginn", "land"] + def __str__(self): return f"{self.land} - {self.paechter} ({self.vertragsnummer})" - + @property def verpachtete_flaeche_hektar(self): """Berechnet die verpachtete Fläche in Hektar""" - from decimal import Decimal, ROUND_HALF_UP + from decimal import ROUND_HALF_UP, Decimal + if self.verpachtete_flaeche and self.verpachtete_flaeche > 0: - hektar = Decimal(str(self.verpachtete_flaeche)) / Decimal('10000') - return hektar.quantize(Decimal('0.01'), rounding=ROUND_HALF_UP) - return Decimal('0.00') - + hektar = Decimal(str(self.verpachtete_flaeche)) / Decimal("10000") + return hektar.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + return Decimal("0.00") + def is_aktiv(self): """Prüft ob der Vertrag noch aktiv ist""" from datetime import date - - + heute = date.today() pachtbeginn_date = ensure_date(self.pachtbeginn) pachtende_date = ensure_date(self.pachtende) - + if not pachtbeginn_date: return False - + if pachtende_date: return pachtbeginn_date <= heute <= pachtende_date return pachtbeginn_date <= heute # Unbefristet - + def get_restlaufzeit_tage(self): """Berechnet die Restlaufzeit in Tagen""" from datetime import date - - + heute = date.today() pachtende_date = ensure_date(self.pachtende) - + if pachtende_date and pachtende_date > heute: return (pachtende_date - heute).days return None # Unbefristet - + @property def ust_pacht_betrag(self): """Berechnet die USt auf Pacht (falls optiert)""" - from decimal import Decimal, ROUND_HALF_UP + from decimal import ROUND_HALF_UP, Decimal + if self.ust_option and self.pachtzins_pauschal: - ust_betrag = Decimal(str(self.pachtzins_pauschal)) * Decimal(str(self.ust_satz)) / Decimal('100') - return ust_betrag.quantize(Decimal('0.01'), rounding=ROUND_HALF_UP) - return Decimal('0.00') + ust_betrag = ( + Decimal(str(self.pachtzins_pauschal)) + * Decimal(str(self.ust_satz)) + / Decimal("100") + ) + return ust_betrag.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + return Decimal("0.00") def save(self, *args, **kwargs): """Override save to trigger Abrechnung updates""" is_new = self.pk is None old_instance = None - + if not is_new: try: old_instance = LandVerpachtung.objects.get(pk=self.pk) except LandVerpachtung.DoesNotExist: old_instance = None - + super().save(*args, **kwargs) - + # Update Abrechnungen after save self._update_abrechnungen(old_instance, is_new) - + def _update_abrechnungen(self, old_instance, is_new): """Update LandAbrechnung records when Verpachtung changes""" from datetime import date - - + # Determine affected years years_to_update = set() - + pachtbeginn_year = get_year_from_date(self.pachtbeginn) if pachtbeginn_year: years_to_update.add(pachtbeginn_year) - + pachtende_year = get_year_from_date(self.pachtende) if pachtende_year: years_to_update.add(pachtende_year) - + # If updated, check old dates too if old_instance: old_pachtbeginn_year = get_year_from_date(old_instance.pachtbeginn) if old_pachtbeginn_year: years_to_update.add(old_pachtbeginn_year) - + old_pachtende_year = get_year_from_date(old_instance.pachtende) if old_pachtende_year: years_to_update.add(old_pachtende_year) - + # Add current year if contract is active if self.is_aktiv(): years_to_update.add(date.today().year) - + # Update each affected year for year in years_to_update: self._update_abrechnung_for_year(year, old_instance, is_new) - + def _update_abrechnung_for_year(self, year, old_instance, is_new): """Update or create LandAbrechnung for specific year""" - from decimal import Decimal from datetime import date - + from decimal import Decimal + # Get or create Abrechnung for this year abrechnung, created = LandAbrechnung.objects.get_or_create( land=self.land, abrechnungsjahr=year, defaults={ - 'pacht_vereinnahmt': Decimal('0.00'), - 'umlagen_vereinnahmt': Decimal('0.00'), - 'bemerkungen': f'Automatisch erstellt für {self.vertragsnummer}' - } + "pacht_vereinnahmt": Decimal("0.00"), + "umlagen_vereinnahmt": Decimal("0.00"), + "bemerkungen": f"Automatisch erstellt für {self.vertragsnummer}", + }, ) - + # Calculate rent for this year rent_for_year = self._calculate_rent_for_year(year) umlage_for_year = self._calculate_umlage_for_year(year) - + # Update or add to existing amounts if created or is_new: # New Abrechnung or new Verpachtung @@ -800,35 +943,48 @@ class LandVerpachtung(models.Model): change_note = f"Neue Verpachtung {self.vertragsnummer} hinzugefügt" else: # Update existing - calculate difference - old_rent = old_instance._calculate_rent_for_year(year) if old_instance else Decimal('0.00') - old_umlage = old_instance._calculate_umlage_for_year(year) if old_instance else Decimal('0.00') - + old_rent = ( + old_instance._calculate_rent_for_year(year) + if old_instance + else Decimal("0.00") + ) + old_umlage = ( + old_instance._calculate_umlage_for_year(year) + if old_instance + else Decimal("0.00") + ) + rent_diff = rent_for_year - old_rent umlage_diff = umlage_for_year - old_umlage - + abrechnung.pacht_vereinnahmt += rent_diff abrechnung.umlagen_vereinnahmt += umlage_diff - + if rent_diff != 0 or umlage_diff != 0: change_note = f"Verpachtung {self.vertragsnummer} geändert: Pacht {rent_diff:+.2f}€, Umlagen {umlage_diff:+.2f}€" else: change_note = f"Verpachtung {self.vertragsnummer} aktualisiert (keine Betragsänderung)" - + # Add change tracking to bemerkungen (if significant change) - if change_note and ('hinzugefügt' in change_note or 'geändert' in change_note): + if change_note and ("hinzugefügt" in change_note or "geändert" in change_note): if abrechnung.bemerkungen: - abrechnung.bemerkungen += f"\n[{date.today().strftime('%d.%m.%Y')}] {change_note}" + abrechnung.bemerkungen += ( + f"\n[{date.today().strftime('%d.%m.%Y')}] {change_note}" + ) else: - abrechnung.bemerkungen = f"[{date.today().strftime('%d.%m.%Y')}] {change_note}" - + abrechnung.bemerkungen = ( + f"[{date.today().strftime('%d.%m.%Y')}] {change_note}" + ) + abrechnung.save() - + def _calculate_rent_for_year(self, year): """Calculate rent amount for specific year""" - from decimal import Decimal from datetime import date + from decimal import Decimal + from django.utils.dateparse import parse_date - + # Helper function to convert date strings to date objects def ensure_date(date_value): if not date_value: @@ -836,294 +992,331 @@ class LandVerpachtung(models.Model): if isinstance(date_value, str): return parse_date(date_value) return date_value - + if not self.pachtzins_pauschal or not self.pachtbeginn: - return Decimal('0.00') - + return Decimal("0.00") + # Check if contract is active in this year year_start = date(year, 1, 1) year_end = date(year, 12, 31) - + # Convert dates to ensure they are date objects pachtbeginn_date = ensure_date(self.pachtbeginn) pachtende_date = ensure_date(self.pachtende) - + if not pachtbeginn_date: - return Decimal('0.00') - + return Decimal("0.00") + contract_start = max(pachtbeginn_date, year_start) contract_end = min(pachtende_date or year_end, year_end) - + if contract_start > contract_end: - return Decimal('0.00') # No overlap - + return Decimal("0.00") # No overlap + # Calculate proportion of year days_in_year = (year_end - year_start).days + 1 days_active = (contract_end - contract_start).days + 1 proportion = Decimal(str(days_active)) / Decimal(str(days_in_year)) - + return Decimal(str(self.pachtzins_pauschal)) * proportion - + def _calculate_umlage_for_year(self, year): """Calculate Umlage amount for specific year based on what can be passed through""" from decimal import Decimal + # This would need to be calculated based on actual costs and what's umlagefähig # For now, return 0 - this can be enhanced later with actual cost calculation - return Decimal('0.00') - + return Decimal("0.00") + def delete(self, *args, **kwargs): """Override delete to update Abrechnungen when Verpachtung is removed""" - - + # Calculate what needs to be removed from Abrechnungen years_to_update = set() - + pachtbeginn_year = get_year_from_date(self.pachtbeginn) if pachtbeginn_year: years_to_update.add(pachtbeginn_year) - + pachtende_year = get_year_from_date(self.pachtende) if pachtende_year: years_to_update.add(pachtende_year) - + # Remove from Abrechnungen before deleting for year in years_to_update: try: - abrechnung = LandAbrechnung.objects.get(land=self.land, abrechnungsjahr=year) - + abrechnung = LandAbrechnung.objects.get( + land=self.land, abrechnungsjahr=year + ) + rent_to_remove = self._calculate_rent_for_year(year) umlage_to_remove = self._calculate_umlage_for_year(year) - + abrechnung.pacht_vereinnahmt -= rent_to_remove abrechnung.umlagen_vereinnahmt -= umlage_to_remove - + # Add deletion note from datetime import date + change_note = f"Verpachtung {self.vertragsnummer} gelöscht: Pacht -{rent_to_remove:.2f}€, Umlagen -{umlage_to_remove:.2f}€" if abrechnung.bemerkungen: - abrechnung.bemerkungen += f"\n[{date.today().strftime('%d.%m.%Y')}] {change_note}" + abrechnung.bemerkungen += ( + f"\n[{date.today().strftime('%d.%m.%Y')}] {change_note}" + ) else: - abrechnung.bemerkungen = f"[{date.today().strftime('%d.%m.%Y')}] {change_note}" - + abrechnung.bemerkungen = ( + f"[{date.today().strftime('%d.%m.%Y')}] {change_note}" + ) + abrechnung.save() except LandAbrechnung.DoesNotExist: pass # No Abrechnung to update - + super().delete(*args, **kwargs) class LandAbrechnung(models.Model): """Jahresabrechnung für Ländereien""" - + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - land = models.ForeignKey(Land, on_delete=models.CASCADE, related_name='abrechnungen', verbose_name="Länderei") - abrechnungsjahr = models.IntegerField(verbose_name="Abrechnungsjahr", validators=[MinValueValidator(2000)]) - + land = models.ForeignKey( + Land, + on_delete=models.CASCADE, + related_name="abrechnungen", + verbose_name="Länderei", + ) + abrechnungsjahr = models.IntegerField( + verbose_name="Abrechnungsjahr", validators=[MinValueValidator(2000)] + ) + # Einnahmen pacht_vereinnahmt = models.DecimalField( - max_digits=12, - decimal_places=2, - default=0, + max_digits=12, + decimal_places=2, + default=0, verbose_name="Pacht vereinnahmt (€)", - validators=[MinValueValidator(0)] + validators=[MinValueValidator(0)], ) umlagen_vereinnahmt = models.DecimalField( - max_digits=12, - decimal_places=2, - default=0, + max_digits=12, + decimal_places=2, + default=0, verbose_name="Umlagen vereinnahmt (€)", - validators=[MinValueValidator(0)] + validators=[MinValueValidator(0)], ) sonstige_einnahmen = models.DecimalField( - max_digits=12, - decimal_places=2, - default=0, + max_digits=12, + decimal_places=2, + default=0, verbose_name="Sonstige Einnahmen (€)", - validators=[MinValueValidator(0)] + validators=[MinValueValidator(0)], ) - + # Zahlungstermine (optional) zahlungen = models.JSONField( - null=True, - blank=True, + null=True, + blank=True, verbose_name="Zahlungstermine", - help_text="Liste von Objekten {datum, betrag, art}" + help_text="Liste von Objekten {datum, betrag, art}", ) - + # Ausgaben - grundsteuer_bescheid_nr = models.CharField(max_length=80, null=True, blank=True, verbose_name="Grundsteuer-Bescheid Nr.") + grundsteuer_bescheid_nr = models.CharField( + max_length=80, null=True, blank=True, verbose_name="Grundsteuer-Bescheid Nr." + ) grundsteuer_betrag = models.DecimalField( - max_digits=12, - decimal_places=2, - default=0, + max_digits=12, + decimal_places=2, + default=0, verbose_name="Grundsteuer Betrag (€)", - validators=[MinValueValidator(0)] + validators=[MinValueValidator(0)], ) versicherungen_betrag = models.DecimalField( - max_digits=12, - decimal_places=2, - default=0, + max_digits=12, + decimal_places=2, + default=0, verbose_name="Versicherungen Betrag (€)", - validators=[MinValueValidator(0)] + validators=[MinValueValidator(0)], ) verbandsbeitraege_betrag = models.DecimalField( - max_digits=12, - decimal_places=2, - default=0, + max_digits=12, + decimal_places=2, + default=0, verbose_name="Verbandsbeiträge Betrag (€)", - validators=[MinValueValidator(0)] + validators=[MinValueValidator(0)], ) sonstige_abgaben_betrag = models.DecimalField( - max_digits=12, - decimal_places=2, - default=0, + max_digits=12, + decimal_places=2, + default=0, verbose_name="Sonstige öffentliche Abgaben (€)", - validators=[MinValueValidator(0)] + validators=[MinValueValidator(0)], ) instandhaltung_betrag = models.DecimalField( - max_digits=12, - decimal_places=2, - default=0, + max_digits=12, + decimal_places=2, + default=0, verbose_name="Instandhaltung/Reparaturen (€)", - validators=[MinValueValidator(0)] + validators=[MinValueValidator(0)], ) verwaltung_recht_betrag = models.DecimalField( - max_digits=12, - decimal_places=2, - default=0, + max_digits=12, + decimal_places=2, + default=0, verbose_name="Verwaltung/Recht (€)", - validators=[MinValueValidator(0)] + validators=[MinValueValidator(0)], ) - + # Umsatzsteuer/Vorsteuer vorsteuer_aus_umlagen = models.DecimalField( - max_digits=12, - decimal_places=2, - default=0, + max_digits=12, + decimal_places=2, + default=0, verbose_name="Vorsteuer aus umgelegten Kosten (€)", - validators=[MinValueValidator(0)] + validators=[MinValueValidator(0)], ) - + # Sonstiges offene_posten = models.DecimalField( - max_digits=12, - decimal_places=2, - default=0, - verbose_name="Offene Posten (€)" + max_digits=12, decimal_places=2, default=0, verbose_name="Offene Posten (€)" ) - bemerkungen = models.TextField(null=True, blank=True, verbose_name="Bemerkungen Abrechnung") - + bemerkungen = models.TextField( + null=True, blank=True, verbose_name="Bemerkungen Abrechnung" + ) + # Dokumente pachtvertrag_datei = models.FileField( - upload_to='land_abrechnungen/vertraege/', - null=True, - blank=True, - verbose_name="Pachtvertrag (Datei)" + upload_to="land_abrechnungen/vertraege/", + null=True, + blank=True, + verbose_name="Pachtvertrag (Datei)", ) grundsteuer_bescheid_datei = models.FileField( - upload_to='land_abrechnungen/bescheide/', - null=True, - blank=True, - verbose_name="Grundsteuerbescheid (Datei)" + upload_to="land_abrechnungen/bescheide/", + null=True, + blank=True, + verbose_name="Grundsteuerbescheid (Datei)", ) versicherungsnachweis_datei = models.FileField( - upload_to='land_abrechnungen/versicherungen/', - null=True, - blank=True, - verbose_name="Versicherungsnachweis (Datei)" + upload_to="land_abrechnungen/versicherungen/", + null=True, + blank=True, + verbose_name="Versicherungsnachweis (Datei)", ) - + # Zeitstempel erstellt_am = models.DateTimeField(auto_now_add=True) aktualisiert_am = models.DateTimeField(auto_now=True) - + class Meta: verbose_name = "Landabrechnung" verbose_name_plural = "Landabrechnungen" - ordering = ['-abrechnungsjahr', 'land__gemeinde', 'land__gemarkung'] - unique_together = ['land', 'abrechnungsjahr'] # Ein Jahr pro Land - + ordering = ["-abrechnungsjahr", "land__gemeinde", "land__gemarkung"] + unique_together = ["land", "abrechnungsjahr"] # Ein Jahr pro Land + def __str__(self): return f"{self.land} - Abrechnung {self.abrechnungsjahr}" - + @property def einnahmen_gesamt(self): """Berechnet die Gesamteinnahmen""" from decimal import Decimal - return (self.pacht_vereinnahmt + self.umlagen_vereinnahmt + self.sonstige_einnahmen) - + + return ( + self.pacht_vereinnahmt + self.umlagen_vereinnahmt + self.sonstige_einnahmen + ) + @property def ausgaben_gesamt(self): """Berechnet die Gesamtausgaben""" from decimal import Decimal + return ( - self.grundsteuer_betrag + self.versicherungen_betrag + - self.verbandsbeitraege_betrag + self.sonstige_abgaben_betrag + - self.instandhaltung_betrag + self.verwaltung_recht_betrag + self.grundsteuer_betrag + + self.versicherungen_betrag + + self.verbandsbeitraege_betrag + + self.sonstige_abgaben_betrag + + self.instandhaltung_betrag + + self.verwaltung_recht_betrag ) - + @property def nettoergebnis(self): """Berechnet das Nettoergebnis""" return self.einnahmen_gesamt - self.ausgaben_gesamt - + @property def ust_pacht_betrag(self): """Berechnet die USt auf Pacht (falls optiert)""" - from decimal import Decimal, ROUND_HALF_UP + from decimal import ROUND_HALF_UP, Decimal + if self.land.ust_option and self.pacht_vereinnahmt: - ust = self.pacht_vereinnahmt * (self.land.ust_satz / Decimal('100')) - return ust.quantize(Decimal('0.01'), rounding=ROUND_HALF_UP) - return Decimal('0.00') + ust = self.pacht_vereinnahmt * (self.land.ust_satz / Decimal("100")) + return ust.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + return Decimal("0.00") + class DokumentLink(models.Model): KONTEXT_CHOICES = [ - ('pachtvertrag', 'Pachtvertrag'), - ('antrag', 'Antrag'), - ('verwendungsnachweis', 'Verwendungsnachweis'), - ('rechnung', 'Rechnung'), - ('vertrag', 'Vertrag'), - ('bericht', 'Bericht'), - ('landkarte', 'Landkarte'), - ('kataster', 'Kataster'), - ('anderes', 'Anderes'), + ("pachtvertrag", "Pachtvertrag"), + ("antrag", "Antrag"), + ("verwendungsnachweis", "Verwendungsnachweis"), + ("rechnung", "Rechnung"), + ("vertrag", "Vertrag"), + ("bericht", "Bericht"), + ("landkarte", "Landkarte"), + ("kataster", "Kataster"), + ("anderes", "Anderes"), ] - + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) paperless_document_id = models.IntegerField() - kontext = models.CharField(max_length=30, choices=KONTEXT_CHOICES, default='anderes') + kontext = models.CharField( + max_length=30, choices=KONTEXT_CHOICES, default="anderes" + ) titel = models.CharField(max_length=255) beschreibung = models.TextField(null=True, blank=True) - + # Verknüpfungen zu anderen Modellen (als Strings für Flexibilität) - verpachtung_id = models.UUIDField(null=True, blank=True, verbose_name="Verpachtung ID (Legacy)") - land_verpachtung_id = models.UUIDField(null=True, blank=True, verbose_name="Landverpachtung ID (Neu)") + verpachtung_id = models.UUIDField( + null=True, blank=True, verbose_name="Verpachtung ID (Legacy)" + ) + land_verpachtung_id = models.UUIDField( + null=True, blank=True, verbose_name="Landverpachtung ID (Neu)" + ) land_id = models.UUIDField(null=True, blank=True, verbose_name="Länderei ID") paechter_id = models.UUIDField(null=True, blank=True, verbose_name="Pächter ID") - destinataer_id = models.UUIDField(null=True, blank=True, verbose_name="Destinatär ID") + destinataer_id = models.UUIDField( + null=True, blank=True, verbose_name="Destinatär ID" + ) foerderung_id = models.UUIDField(null=True, blank=True, verbose_name="Förderung ID") - rentmeister_id = models.UUIDField(null=True, blank=True, verbose_name="Rentmeister ID") - abrechnung_id = models.UUIDField(null=True, blank=True, verbose_name="Abrechnung ID") + rentmeister_id = models.UUIDField( + null=True, blank=True, verbose_name="Rentmeister ID" + ) + abrechnung_id = models.UUIDField( + null=True, blank=True, verbose_name="Abrechnung ID" + ) class Meta: verbose_name = "Dokument" verbose_name_plural = "Dokumente" - ordering = ['titel'] + ordering = ["titel"] def __str__(self): return f"{self.titel} ({self.get_kontext_display()})" - + def get_paperless_url(self): """Gibt die URL zum Dokument in Paperless zurück (über Django Redirect)""" return f"/api/paperless/documents/{self.paperless_document_id}/" - + def get_paperless_thumbnail_url(self): """Gibt die URL zum Thumbnail in Paperless zurück""" from django.conf import settings + if settings.PAPERLESS_API_URL: return f"{settings.PAPERLESS_API_URL}/api/paperless/documents/{self.paperless_document_id}/thumb/" return None - + def get_verpachtung(self): """Gibt die verknüpfte Verpachtung zurück""" if self.verpachtung_id: @@ -1132,7 +1325,7 @@ class DokumentLink(models.Model): except LandVerpachtung.DoesNotExist: return None return None - + def get_land(self): """Gibt die verknüpfte Länderei zurück""" if self.land_id: @@ -1141,7 +1334,7 @@ class DokumentLink(models.Model): except Land.DoesNotExist: return None return None - + def get_paechter(self): """Gibt den verknüpften Pächter zurück""" if self.paechter_id: @@ -1150,7 +1343,7 @@ class DokumentLink(models.Model): except Paechter.DoesNotExist: return None return None - + def get_destinataer(self): """Gibt den verknüpften Destinatär zurück""" if self.destinataer_id: @@ -1159,7 +1352,7 @@ class DokumentLink(models.Model): except Destinataer.DoesNotExist: return None return None - + def get_foerderung(self): """Gibt die verknüpfte Förderung zurück""" if self.foerderung_id: @@ -1168,7 +1361,7 @@ class DokumentLink(models.Model): except Foerderung.DoesNotExist: return None return None - + def get_land_verpachtung(self): """Gibt die verknüpfte neue Landverpachtung zurück""" if self.land_verpachtung_id: @@ -1178,35 +1371,58 @@ class DokumentLink(models.Model): return None return None + class Foerderung(models.Model): KATEGORIE_CHOICES = [ - ('bildung', 'Bildung'), - ('forschung', 'Forschung'), - ('kultur', 'Kultur'), - ('soziales', 'Soziales'), - ('umwelt', 'Umwelt'), - ('anderes', 'Anderes'), + ("bildung", "Bildung"), + ("forschung", "Forschung"), + ("kultur", "Kultur"), + ("soziales", "Soziales"), + ("umwelt", "Umwelt"), + ("anderes", "Anderes"), ] - + STATUS_CHOICES = [ - ('beantragt', 'Beantragt'), - ('genehmigt', 'Genehmigt'), - ('ausgezahlt', 'Ausgezahlt'), - ('abgelehnt', 'Abgelehnt'), - ('storniert', 'Storniert'), + ("beantragt", "Beantragt"), + ("genehmigt", "Genehmigt"), + ("ausgezahlt", "Ausgezahlt"), + ("abgelehnt", "Abgelehnt"), + ("storniert", "Storniert"), ] - + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) # Legacy field for migration - will be removed after data migration - person = models.ForeignKey(Person, on_delete=models.CASCADE, verbose_name="Person (Legacy)", null=True, blank=True) - destinataer = models.ForeignKey(Destinataer, on_delete=models.CASCADE, verbose_name="Destinatär", null=True, blank=True) + person = models.ForeignKey( + Person, + on_delete=models.CASCADE, + verbose_name="Person (Legacy)", + null=True, + blank=True, + ) + destinataer = models.ForeignKey( + Destinataer, + on_delete=models.CASCADE, + verbose_name="Destinatär", + null=True, + blank=True, + ) jahr = models.IntegerField( validators=[MinValueValidator(1900), MaxValueValidator(2100)] ) betrag = models.DecimalField(max_digits=12, decimal_places=2) - kategorie = models.CharField(max_length=20, choices=KATEGORIE_CHOICES, default='anderes') - status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='beantragt') - verwendungsnachweis = models.ForeignKey(DokumentLink, null=True, blank=True, on_delete=models.SET_NULL, verbose_name="Verwendungsnachweis") + kategorie = models.CharField( + max_length=20, choices=KATEGORIE_CHOICES, default="anderes" + ) + status = models.CharField( + max_length=20, choices=STATUS_CHOICES, default="beantragt" + ) + verwendungsnachweis = models.ForeignKey( + DokumentLink, + null=True, + blank=True, + on_delete=models.SET_NULL, + verbose_name="Verwendungsnachweis", + ) bemerkungen = models.TextField(null=True, blank=True) antragsdatum = models.DateField(default=timezone.now) entscheidungsdatum = models.DateField(null=True, blank=True) @@ -1214,7 +1430,7 @@ class Foerderung(models.Model): class Meta: verbose_name = "Förderung" verbose_name_plural = "Förderungen" - ordering = ['-jahr', '-betrag'] + ordering = ["-jahr", "-betrag"] # Note: unique_together will be updated after migration def __str__(self): @@ -1223,232 +1439,330 @@ class Foerderung(models.Model): elif self.person: return f"{self.person} (Legacy) - {self.jahr} - €{self.betrag}" return f"Unbekannt - {self.jahr} - €{self.betrag}" - + def get_status_color(self): colors = { - 'beantragt': 'orange', - 'genehmigt': 'blue', - 'ausgezahlt': 'green', - 'abgelehnt': 'red', - 'storniert': 'gray', + "beantragt": "orange", + "genehmigt": "blue", + "ausgezahlt": "green", + "abgelehnt": "red", + "storniert": "gray", } - return colors.get(self.status, 'black') + return colors.get(self.status, "black") class DestinataerUnterstuetzung(models.Model): """Geplante/ausgeführte Unterstützungszahlungen an Destinatäre""" + STATUS_CHOICES = [ - ('geplant', 'Geplant'), - ('faellig', 'Fällig'), - ('in_bearbeitung', 'In Bearbeitung'), - ('ausgezahlt', 'Ausgezahlt'), - ('storniert', 'Storniert'), + ("geplant", "Geplant"), + ("faellig", "Fällig"), + ("in_bearbeitung", "In Bearbeitung"), + ("ausgezahlt", "Ausgezahlt"), + ("storniert", "Storniert"), ] id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - destinataer = models.ForeignKey('Destinataer', on_delete=models.CASCADE, related_name='unterstuetzungen', verbose_name='Destinatär') - konto = models.ForeignKey('StiftungsKonto', on_delete=models.PROTECT, verbose_name='Zahlungskonto') - betrag = models.DecimalField(max_digits=12, decimal_places=2, verbose_name='Betrag (€)') - faellig_am = models.DateField(verbose_name='Fällig am') - status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='geplant', verbose_name='Status') - beschreibung = models.CharField(max_length=255, blank=True, verbose_name='Beschreibung') - + destinataer = models.ForeignKey( + "Destinataer", + on_delete=models.CASCADE, + related_name="unterstuetzungen", + verbose_name="Destinatär", + ) + konto = models.ForeignKey( + "StiftungsKonto", on_delete=models.PROTECT, verbose_name="Zahlungskonto" + ) + betrag = models.DecimalField( + max_digits=12, decimal_places=2, verbose_name="Betrag (€)" + ) + faellig_am = models.DateField(verbose_name="Fällig am") + status = models.CharField( + max_length=20, choices=STATUS_CHOICES, default="geplant", verbose_name="Status" + ) + beschreibung = models.CharField( + max_length=255, blank=True, verbose_name="Beschreibung" + ) + # Enhanced fields for recurrent payments and IBAN tracking - empfaenger_iban = models.CharField(max_length=34, blank=True, verbose_name='Empfänger IBAN') - empfaenger_name = models.CharField(max_length=200, blank=True, verbose_name='Empfänger Name') - verwendungszweck = models.CharField(max_length=140, blank=True, verbose_name='Verwendungszweck') - ausgezahlt_am = models.DateField(null=True, blank=True, verbose_name='Ausgezahlt am') - ausgezahlt_von = models.ForeignKey('auth.User', on_delete=models.SET_NULL, null=True, blank=True, verbose_name='Ausgezahlt von') - + empfaenger_iban = models.CharField( + max_length=34, blank=True, verbose_name="Empfänger IBAN" + ) + empfaenger_name = models.CharField( + max_length=200, blank=True, verbose_name="Empfänger Name" + ) + verwendungszweck = models.CharField( + max_length=140, blank=True, verbose_name="Verwendungszweck" + ) + ausgezahlt_am = models.DateField( + null=True, blank=True, verbose_name="Ausgezahlt am" + ) + ausgezahlt_von = models.ForeignKey( + "auth.User", + on_delete=models.SET_NULL, + null=True, + blank=True, + verbose_name="Ausgezahlt von", + ) + # Link to recurrent payment template if this was auto-generated - wiederkehrend_von = models.ForeignKey('UnterstuetzungWiederkehrend', on_delete=models.SET_NULL, null=True, blank=True, verbose_name='Wiederkehrende Zahlung') - + wiederkehrend_von = models.ForeignKey( + "UnterstuetzungWiederkehrend", + on_delete=models.SET_NULL, + null=True, + blank=True, + verbose_name="Wiederkehrende Zahlung", + ) + erstellt_am = models.DateTimeField(auto_now_add=True) aktualisiert_am = models.DateTimeField(auto_now=True) class Meta: - verbose_name = 'Destinatärunterstützung' - verbose_name_plural = 'Destinatärunterstützungen' - ordering = ['-faellig_am', '-erstellt_am'] + verbose_name = "Destinatärunterstützung" + verbose_name_plural = "Destinatärunterstützungen" + ordering = ["-faellig_am", "-erstellt_am"] indexes = [ - models.Index(fields=['status', 'faellig_am']), - models.Index(fields=['destinataer', 'status']), - models.Index(fields=['wiederkehrend_von']), + models.Index(fields=["status", "faellig_am"]), + models.Index(fields=["destinataer", "status"]), + models.Index(fields=["wiederkehrend_von"]), ] def __str__(self): return f"{self.destinataer.get_full_name()} – €{self.betrag} am {self.faellig_am} ({self.get_status_display()})" - + def is_overdue(self): """Check if payment is overdue""" from django.utils import timezone - return self.faellig_am < timezone.now().date() and self.status in ['geplant', 'faellig'] - + + return self.faellig_am < timezone.now().date() and self.status in [ + "geplant", + "faellig", + ] + def can_be_marked_paid(self): """Check if payment can be marked as paid""" - return self.status in ['geplant', 'faellig', 'in_bearbeitung'] + return self.status in ["geplant", "faellig", "in_bearbeitung"] class UnterstuetzungWiederkehrend(models.Model): """Template for recurring support payments""" + INTERVALL_CHOICES = [ - ('monatlich', 'Monatlich'), - ('quartalsweise', 'Vierteljährlich'), - ('halbjaehrlich', 'Halbjährlich'), - ('jaehrlich', 'Jährlich'), + ("monatlich", "Monatlich"), + ("quartalsweise", "Vierteljährlich"), + ("halbjaehrlich", "Halbjährlich"), + ("jaehrlich", "Jährlich"), ] - + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - destinataer = models.ForeignKey('Destinataer', on_delete=models.CASCADE, related_name='wiederkehrende_unterstuetzungen', verbose_name='Destinatär') - konto = models.ForeignKey('StiftungsKonto', on_delete=models.PROTECT, verbose_name='Zahlungskonto') - betrag = models.DecimalField(max_digits=12, decimal_places=2, verbose_name='Betrag (€)') - intervall = models.CharField(max_length=20, choices=INTERVALL_CHOICES, verbose_name='Intervall') - beschreibung = models.CharField(max_length=255, blank=True, verbose_name='Beschreibung') - + destinataer = models.ForeignKey( + "Destinataer", + on_delete=models.CASCADE, + related_name="wiederkehrende_unterstuetzungen", + verbose_name="Destinatär", + ) + konto = models.ForeignKey( + "StiftungsKonto", on_delete=models.PROTECT, verbose_name="Zahlungskonto" + ) + betrag = models.DecimalField( + max_digits=12, decimal_places=2, verbose_name="Betrag (€)" + ) + intervall = models.CharField( + max_length=20, choices=INTERVALL_CHOICES, verbose_name="Intervall" + ) + beschreibung = models.CharField( + max_length=255, blank=True, verbose_name="Beschreibung" + ) + # IBAN and payment details - empfaenger_iban = models.CharField(max_length=34, verbose_name='Empfänger IBAN') - empfaenger_name = models.CharField(max_length=200, verbose_name='Empfänger Name') - verwendungszweck = models.CharField(max_length=140, blank=True, verbose_name='Verwendungszweck') - + empfaenger_iban = models.CharField(max_length=34, verbose_name="Empfänger IBAN") + empfaenger_name = models.CharField(max_length=200, verbose_name="Empfänger Name") + verwendungszweck = models.CharField( + max_length=140, blank=True, verbose_name="Verwendungszweck" + ) + # Schedule settings - erste_zahlung_am = models.DateField(verbose_name='Erste Zahlung am') - letzte_zahlung_am = models.DateField(null=True, blank=True, verbose_name='Letzte Zahlung am (optional)') - naechste_generierung = models.DateField(verbose_name='Nächste Generierung') - - aktiv = models.BooleanField(default=True, verbose_name='Aktiv') + erste_zahlung_am = models.DateField(verbose_name="Erste Zahlung am") + letzte_zahlung_am = models.DateField( + null=True, blank=True, verbose_name="Letzte Zahlung am (optional)" + ) + naechste_generierung = models.DateField(verbose_name="Nächste Generierung") + + aktiv = models.BooleanField(default=True, verbose_name="Aktiv") erstellt_am = models.DateTimeField(auto_now_add=True) - erstellt_von = models.ForeignKey('auth.User', on_delete=models.SET_NULL, null=True, blank=True, verbose_name='Erstellt von') - + erstellt_von = models.ForeignKey( + "auth.User", + on_delete=models.SET_NULL, + null=True, + blank=True, + verbose_name="Erstellt von", + ) + class Meta: - verbose_name = 'Wiederkehrende Unterstützung' - verbose_name_plural = 'Wiederkehrende Unterstützungen' - ordering = ['-erstellt_am'] + verbose_name = "Wiederkehrende Unterstützung" + verbose_name_plural = "Wiederkehrende Unterstützungen" + ordering = ["-erstellt_am"] indexes = [ - models.Index(fields=['aktiv', 'naechste_generierung']), - models.Index(fields=['destinataer', 'aktiv']), + models.Index(fields=["aktiv", "naechste_generierung"]), + models.Index(fields=["destinataer", "aktiv"]), ] def __str__(self): return f"{self.destinataer.get_full_name()} – {self.get_intervall_display()} €{self.betrag}" - + def generiere_naechste_zahlung(self): """Generate the next scheduled payment""" from datetime import timedelta + from dateutil.relativedelta import relativedelta - + if not self.aktiv: return None - + heute = timezone.now().date() if self.naechste_generierung > heute: return None # Not yet time to generate - + # Check if we've reached the end date - if self.letzte_zahlung_am and self.naechste_generierung > self.letzte_zahlung_am: + if ( + self.letzte_zahlung_am + and self.naechste_generierung > self.letzte_zahlung_am + ): return None - + # Create the next payment neue_zahlung = DestinataerUnterstuetzung.objects.create( destinataer=self.destinataer, konto=self.konto, betrag=self.betrag, faellig_am=self.naechste_generierung, - beschreibung=self.beschreibung or f"{self.get_intervall_display()} Unterstützung", + beschreibung=self.beschreibung + or f"{self.get_intervall_display()} Unterstützung", empfaenger_iban=self.empfaenger_iban, empfaenger_name=self.empfaenger_name, verwendungszweck=self.verwendungszweck, wiederkehrend_von=self, - status='geplant' + status="geplant", ) - + # Calculate next generation date - if self.intervall == 'monatlich': - self.naechste_generierung = self.naechste_generierung + relativedelta(months=1) - elif self.intervall == 'quartalsweise': - self.naechste_generierung = self.naechste_generierung + relativedelta(months=3) - elif self.intervall == 'halbjaehrlich': - self.naechste_generierung = self.naechste_generierung + relativedelta(months=6) - elif self.intervall == 'jaehrlich': - self.naechste_generierung = self.naechste_generierung + relativedelta(years=1) - + if self.intervall == "monatlich": + self.naechste_generierung = self.naechste_generierung + relativedelta( + months=1 + ) + elif self.intervall == "quartalsweise": + self.naechste_generierung = self.naechste_generierung + relativedelta( + months=3 + ) + elif self.intervall == "halbjaehrlich": + self.naechste_generierung = self.naechste_generierung + relativedelta( + months=6 + ) + elif self.intervall == "jaehrlich": + self.naechste_generierung = self.naechste_generierung + relativedelta( + years=1 + ) + self.save() return neue_zahlung class DestinataerNotiz(models.Model): """Zeitgestempelte Notizen/Telefonvermerke zu einem Destinatär, optional mit Datei.""" + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - destinataer = models.ForeignKey('Destinataer', on_delete=models.CASCADE, related_name='notizen_eintraege', verbose_name='Destinatär') - titel = models.CharField(max_length=200, blank=True, verbose_name='Titel') - text = models.TextField(blank=True, verbose_name='Notiz') - datei = models.FileField(upload_to='destinataer_notizen/', null=True, blank=True, verbose_name='Anhang') - erstellt_von = models.ForeignKey('auth.User', on_delete=models.SET_NULL, null=True, blank=True, verbose_name='Erstellt von') - erstellt_am = models.DateTimeField(auto_now_add=True, verbose_name='Erstellt am') + destinataer = models.ForeignKey( + "Destinataer", + on_delete=models.CASCADE, + related_name="notizen_eintraege", + verbose_name="Destinatär", + ) + titel = models.CharField(max_length=200, blank=True, verbose_name="Titel") + text = models.TextField(blank=True, verbose_name="Notiz") + datei = models.FileField( + upload_to="destinataer_notizen/", null=True, blank=True, verbose_name="Anhang" + ) + erstellt_von = models.ForeignKey( + "auth.User", + on_delete=models.SET_NULL, + null=True, + blank=True, + verbose_name="Erstellt von", + ) + erstellt_am = models.DateTimeField(auto_now_add=True, verbose_name="Erstellt am") class Meta: - verbose_name = 'Destinatär-Notiz' - verbose_name_plural = 'Destinatär-Notizen' - ordering = ['-erstellt_am'] + verbose_name = "Destinatär-Notiz" + verbose_name_plural = "Destinatär-Notizen" + ordering = ["-erstellt_am"] def __str__(self): return self.titel or f"Notiz {self.erstellt_am.strftime('%d.%m.%Y %H:%M')}" + class Rentmeister(models.Model): """Geschäftsführer der Stiftung (natürliche Personen)""" + ANREDE_CHOICES = [ - ('herr', 'Herr'), - ('frau', 'Frau'), - ('dr', 'Dr.'), - ('prof', 'Prof.'), - ('prof_dr', 'Prof. Dr.'), + ("herr", "Herr"), + ("frau", "Frau"), + ("dr", "Dr."), + ("prof", "Prof."), + ("prof_dr", "Prof. Dr."), ] - + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - anrede = models.CharField(max_length=10, choices=ANREDE_CHOICES, blank=True, verbose_name="Anrede") + anrede = models.CharField( + max_length=10, choices=ANREDE_CHOICES, blank=True, verbose_name="Anrede" + ) vorname = models.CharField(max_length=100, verbose_name="Vorname") nachname = models.CharField(max_length=100, verbose_name="Nachname") titel = models.CharField(max_length=50, blank=True, verbose_name="Titel") - + # Kontaktdaten email = models.EmailField(blank=True, verbose_name="E-Mail") telefon = models.CharField(max_length=20, blank=True, verbose_name="Telefon") mobil = models.CharField(max_length=20, blank=True, verbose_name="Mobil") - + # Adresse strasse = models.CharField(max_length=200, blank=True, verbose_name="Straße") plz = models.CharField(max_length=10, blank=True, verbose_name="PLZ") ort = models.CharField(max_length=100, blank=True, verbose_name="Ort") - + # Bankdaten für Abrechnungen iban = models.CharField(max_length=34, blank=True, verbose_name="IBAN") bic = models.CharField(max_length=11, blank=True, verbose_name="BIC") bank_name = models.CharField(max_length=100, blank=True, verbose_name="Bank") - + # Stiftungs-spezifisch seit_datum = models.DateField(verbose_name="Rentmeister seit") bis_datum = models.DateField(null=True, blank=True, verbose_name="Rentmeister bis") aktiv = models.BooleanField(default=True, verbose_name="Aktiv") - + # Vergütung/Aufwandsentschädigung monatliche_verguetung = models.DecimalField( - max_digits=8, decimal_places=2, null=True, blank=True, - verbose_name="Monatliche Vergütung (€)" + max_digits=8, + decimal_places=2, + null=True, + blank=True, + verbose_name="Monatliche Vergütung (€)", ) km_pauschale = models.DecimalField( - max_digits=4, decimal_places=2, default=0.30, - verbose_name="Kilometerpauschale (€/km)" + max_digits=4, + decimal_places=2, + default=0.30, + verbose_name="Kilometerpauschale (€/km)", ) - + notizen = models.TextField(blank=True, verbose_name="Notizen") - + erstellt_am = models.DateTimeField(auto_now_add=True) aktualisiert_am = models.DateTimeField(auto_now=True) - + class Meta: verbose_name = "Rentmeister" verbose_name_plural = "Rentmeister" - ordering = ['nachname', 'vorname'] - + ordering = ["nachname", "vorname"] + def __str__(self): name_parts = [] if self.anrede: @@ -1459,13 +1773,13 @@ class Rentmeister(models.Model): if self.titel: name_parts.append(f"({self.titel})") return " ".join(name_parts) - + def get_full_name(self): """Vollständiger Name ohne Anrede""" if self.vorname: return f"{self.vorname} {self.nachname}" return self.nachname - + def get_address(self): """Vollständige Adresse als String""" parts = [] @@ -1480,104 +1794,153 @@ class Rentmeister(models.Model): class StiftungsKonto(models.Model): """Bankkonten der Stiftung""" + KONTO_TYP_CHOICES = [ - ('girokonto', 'Girokonto'), - ('sparkonto', 'Sparkonto'), - ('festgeld', 'Festgeld'), - ('tagesgeld', 'Tagesgeld'), - ('depot', 'Depot'), - ('sonstiges', 'Sonstiges'), + ("girokonto", "Girokonto"), + ("sparkonto", "Sparkonto"), + ("festgeld", "Festgeld"), + ("tagesgeld", "Tagesgeld"), + ("depot", "Depot"), + ("sonstiges", "Sonstiges"), ] - + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) kontoname = models.CharField(max_length=200, verbose_name="Kontoname") bank_name = models.CharField(max_length=200, verbose_name="Bank") iban = models.CharField(max_length=34, verbose_name="IBAN") bic = models.CharField(max_length=11, blank=True, verbose_name="BIC") - konto_typ = models.CharField(max_length=20, choices=KONTO_TYP_CHOICES, default='girokonto', verbose_name="Kontotyp") - saldo = models.DecimalField(max_digits=10, decimal_places=2, default=0.00, verbose_name="Aktueller Saldo") + konto_typ = models.CharField( + max_length=20, + choices=KONTO_TYP_CHOICES, + default="girokonto", + verbose_name="Kontotyp", + ) + saldo = models.DecimalField( + max_digits=10, decimal_places=2, default=0.00, verbose_name="Aktueller Saldo" + ) saldo_datum = models.DateField(null=True, blank=True, verbose_name="Saldo-Datum") - zinssatz = models.DecimalField(max_digits=5, decimal_places=2, null=True, blank=True, verbose_name="Zinssatz (%)") + zinssatz = models.DecimalField( + max_digits=5, + decimal_places=2, + null=True, + blank=True, + verbose_name="Zinssatz (%)", + ) laufzeit_bis = models.DateField(null=True, blank=True, verbose_name="Laufzeit bis") aktiv = models.BooleanField(default=True, verbose_name="Aktiv") notizen = models.TextField(blank=True, verbose_name="Notizen") - + erstellt_am = models.DateTimeField(auto_now_add=True) aktualisiert_am = models.DateTimeField(auto_now=True) - + class Meta: verbose_name = "Stiftungskonto" verbose_name_plural = "Stiftungskonten" - ordering = ['bank_name', 'kontoname'] - + ordering = ["bank_name", "kontoname"] + def __str__(self): return f"{self.bank_name} - {self.kontoname}" class BankTransaction(models.Model): """Banktransaktionen aus importierten Kontodaten""" - + TRANSACTION_TYPE_CHOICES = [ - ('eingang', 'Eingang'), - ('ausgang', 'Ausgang'), - ('lastschrift', 'Lastschrift'), - ('ueberweisung', 'Überweisung'), - ('dauerauftrag', 'Dauerauftrag'), - ('kartenzahlung', 'Kartenzahlung'), - ('zinsen', 'Zinsen'), - ('gebuehren', 'Gebühren'), - ('sonstiges', 'Sonstiges'), + ("eingang", "Eingang"), + ("ausgang", "Ausgang"), + ("lastschrift", "Lastschrift"), + ("ueberweisung", "Überweisung"), + ("dauerauftrag", "Dauerauftrag"), + ("kartenzahlung", "Kartenzahlung"), + ("zinsen", "Zinsen"), + ("gebuehren", "Gebühren"), + ("sonstiges", "Sonstiges"), ] - + STATUS_CHOICES = [ - ('imported', 'Importiert'), - ('verified', 'Geprüft'), - ('assigned', 'Zugeordnet'), - ('ignored', 'Ignoriert'), + ("imported", "Importiert"), + ("verified", "Geprüft"), + ("assigned", "Zugeordnet"), + ("ignored", "Ignoriert"), ] - + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - konto = models.ForeignKey(StiftungsKonto, on_delete=models.CASCADE, verbose_name="Konto") - + konto = models.ForeignKey( + StiftungsKonto, on_delete=models.CASCADE, verbose_name="Konto" + ) + # Transaktionsdaten datum = models.DateField(verbose_name="Buchungsdatum") valuta = models.DateField(null=True, blank=True, verbose_name="Valutadatum") - betrag = models.DecimalField(max_digits=12, decimal_places=2, verbose_name="Betrag (€)") - waehrung = models.CharField(max_length=3, default='EUR', verbose_name="Währung") - + betrag = models.DecimalField( + max_digits=12, decimal_places=2, verbose_name="Betrag (€)" + ) + waehrung = models.CharField(max_length=3, default="EUR", verbose_name="Währung") + # Transaktionsdetails verwendungszweck = models.TextField(verbose_name="Verwendungszweck") - empfaenger_zahlungspflichtiger = models.CharField(max_length=200, blank=True, verbose_name="Empfänger/Zahlungspflichtiger") - iban_gegenpartei = models.CharField(max_length=34, blank=True, verbose_name="IBAN Gegenpartei") - bic_gegenpartei = models.CharField(max_length=11, blank=True, verbose_name="BIC Gegenpartei") - + empfaenger_zahlungspflichtiger = models.CharField( + max_length=200, blank=True, verbose_name="Empfänger/Zahlungspflichtiger" + ) + iban_gegenpartei = models.CharField( + max_length=34, blank=True, verbose_name="IBAN Gegenpartei" + ) + bic_gegenpartei = models.CharField( + max_length=11, blank=True, verbose_name="BIC Gegenpartei" + ) + # Bankspezifische Daten - referenz = models.CharField(max_length=100, blank=True, verbose_name="Referenz/Transaktions-ID") - transaction_type = models.CharField(max_length=20, choices=TRANSACTION_TYPE_CHOICES, default='sonstiges', verbose_name="Transaktionsart") - + referenz = models.CharField( + max_length=100, blank=True, verbose_name="Referenz/Transaktions-ID" + ) + transaction_type = models.CharField( + max_length=20, + choices=TRANSACTION_TYPE_CHOICES, + default="sonstiges", + verbose_name="Transaktionsart", + ) + # Verwaltung - status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='imported', verbose_name="Status") + status = models.CharField( + max_length=20, choices=STATUS_CHOICES, default="imported", verbose_name="Status" + ) kommentare = models.TextField(blank=True, verbose_name="Kommentare") - verwaltungskosten = models.ForeignKey('Verwaltungskosten', null=True, blank=True, on_delete=models.SET_NULL, verbose_name="Zugeordnete Verwaltungskosten") - + verwaltungskosten = models.ForeignKey( + "Verwaltungskosten", + null=True, + blank=True, + on_delete=models.SET_NULL, + verbose_name="Zugeordnete Verwaltungskosten", + ) + # Import-Metadaten - import_datei = models.CharField(max_length=255, blank=True, verbose_name="Import-Datei") - importiert_am = models.DateTimeField(auto_now_add=True, verbose_name="Importiert am") - saldo_nach_buchung = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True, verbose_name="Saldo nach Buchung") - + import_datei = models.CharField( + max_length=255, blank=True, verbose_name="Import-Datei" + ) + importiert_am = models.DateTimeField( + auto_now_add=True, verbose_name="Importiert am" + ) + saldo_nach_buchung = models.DecimalField( + max_digits=12, + decimal_places=2, + null=True, + blank=True, + verbose_name="Saldo nach Buchung", + ) + class Meta: verbose_name = "Banktransaktion" verbose_name_plural = "Banktransaktionen" - ordering = ['-datum', '-importiert_am'] - unique_together = ['konto', 'datum', 'betrag', 'referenz'] # Prevent duplicates - + ordering = ["-datum", "-importiert_am"] + unique_together = ["konto", "datum", "betrag", "referenz"] # Prevent duplicates + def __str__(self): return f"{self.datum} - {self.betrag}€ - {self.verwendungszweck[:50]}" - + def is_income(self): """Prüft ob es sich um einen Geldeingang handelt""" return self.betrag > 0 - + def get_absolute_amount(self): """Gibt den absoluten Betrag zurück""" return abs(self.betrag) @@ -1585,93 +1948,132 @@ class BankTransaction(models.Model): class Verwaltungskosten(models.Model): """Administrative Kosten und Ausgaben der Stiftung""" + KATEGORIE_CHOICES = [ - ('rechnung_intern', 'Interne Rechnung'), - ('bueroausstattung', 'Büroausstattung'), - ('fahrtkosten', 'Fahrtkosten'), - ('porto', 'Porto & Versand'), - ('telefon_internet', 'Telefon & Internet'), - ('software', 'Software & Lizenzen'), - ('beratung', 'Beratung & Dienstleistungen'), - ('versicherung', 'Versicherungen'), - ('steuerberatung', 'Steuerberatung'), - ('bankgebuehren', 'Bankgebühren'), - ('sonstiges', 'Sonstiges'), + ("rechnung_intern", "Interne Rechnung"), + ("bueroausstattung", "Büroausstattung"), + ("fahrtkosten", "Fahrtkosten"), + ("porto", "Porto & Versand"), + ("telefon_internet", "Telefon & Internet"), + ("software", "Software & Lizenzen"), + ("beratung", "Beratung & Dienstleistungen"), + ("versicherung", "Versicherungen"), + ("steuerberatung", "Steuerberatung"), + ("bankgebuehren", "Bankgebühren"), + ("sonstiges", "Sonstiges"), ] - + STATUS_CHOICES = [ - ('geplant', 'Geplant'), - ('bestellt', 'Bestellt'), - ('erhalten', 'Erhalten'), - ('in_bearbeitung', 'In Bearbeitung'), - ('bezahlt', 'Bezahlt'), - ('storniert', 'Storniert'), + ("geplant", "Geplant"), + ("bestellt", "Bestellt"), + ("erhalten", "Erhalten"), + ("in_bearbeitung", "In Bearbeitung"), + ("bezahlt", "Bezahlt"), + ("storniert", "Storniert"), ] - + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) bezeichnung = models.CharField(max_length=200, verbose_name="Bezeichnung") - kategorie = models.CharField(max_length=30, choices=KATEGORIE_CHOICES, verbose_name="Kategorie") - betrag = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="Betrag (€)") + kategorie = models.CharField( + max_length=30, choices=KATEGORIE_CHOICES, verbose_name="Kategorie" + ) + betrag = models.DecimalField( + max_digits=10, decimal_places=2, verbose_name="Betrag (€)" + ) datum = models.DateField(verbose_name="Datum") - lieferant_firma = models.CharField(max_length=200, blank=True, verbose_name="Lieferant/Firma") - rechnungsnummer = models.CharField(max_length=100, blank=True, verbose_name="Rechnungsnummer") - status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='geplant', verbose_name="Status") - + lieferant_firma = models.CharField( + max_length=200, blank=True, verbose_name="Lieferant/Firma" + ) + rechnungsnummer = models.CharField( + max_length=100, blank=True, verbose_name="Rechnungsnummer" + ) + status = models.CharField( + max_length=20, choices=STATUS_CHOICES, default="geplant", verbose_name="Status" + ) + # Zuständigkeit und Zahlung - rentmeister = models.ForeignKey(Rentmeister, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="Zuständiger Rentmeister") - zahlungskonto = models.ForeignKey(StiftungsKonto, on_delete=models.SET_NULL, null=True, blank=True, - related_name='zahlungen', verbose_name="Zahlungskonto") - quellkonto = models.ForeignKey(StiftungsKonto, on_delete=models.SET_NULL, null=True, blank=True, - related_name='ausgaben', verbose_name="Quellkonto") - + rentmeister = models.ForeignKey( + Rentmeister, + on_delete=models.SET_NULL, + null=True, + blank=True, + verbose_name="Zuständiger Rentmeister", + ) + zahlungskonto = models.ForeignKey( + StiftungsKonto, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="zahlungen", + verbose_name="Zahlungskonto", + ) + quellkonto = models.ForeignKey( + StiftungsKonto, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="ausgaben", + verbose_name="Quellkonto", + ) + # Legacy field für Rückwärtskompatibilität - konto = models.ForeignKey(StiftungsKonto, on_delete=models.SET_NULL, null=True, blank=True, - verbose_name="Konto (Legacy)", help_text="Veraltet - verwende Zahlungskonto und Quellkonto") - + konto = models.ForeignKey( + StiftungsKonto, + on_delete=models.SET_NULL, + null=True, + blank=True, + verbose_name="Konto (Legacy)", + help_text="Veraltet - verwende Zahlungskonto und Quellkonto", + ) + # Fahrtkosten spezifisch - km_anzahl = models.DecimalField(max_digits=8, decimal_places=1, null=True, blank=True, verbose_name="Kilometer") - km_satz = models.DecimalField(max_digits=4, decimal_places=2, null=True, blank=True, verbose_name="€/km") + km_anzahl = models.DecimalField( + max_digits=8, decimal_places=1, null=True, blank=True, verbose_name="Kilometer" + ) + km_satz = models.DecimalField( + max_digits=4, decimal_places=2, null=True, blank=True, verbose_name="€/km" + ) von_ort = models.CharField(max_length=100, blank=True, verbose_name="Von (Ort)") nach_ort = models.CharField(max_length=100, blank=True, verbose_name="Nach (Ort)") zweck = models.CharField(max_length=200, blank=True, verbose_name="Zweck der Fahrt") - + beschreibung = models.TextField(blank=True, verbose_name="Beschreibung") notizen = models.TextField(blank=True, verbose_name="Notizen") - + erstellt_am = models.DateTimeField(auto_now_add=True) aktualisiert_am = models.DateTimeField(auto_now=True) - + class Meta: verbose_name = "Verwaltungskosten" verbose_name_plural = "Verwaltungskosten" - ordering = ['-datum', '-erstellt_am'] - + ordering = ["-datum", "-erstellt_am"] + def __str__(self): return f"{self.bezeichnung} - €{self.betrag} ({self.datum})" - + def get_status_color(self): colors = { - 'geplant': 'secondary', - 'bestellt': 'warning', - 'erhalten': 'info', - 'in_bearbeitung': 'primary', - 'bezahlt': 'success', - 'storniert': 'danger', + "geplant": "secondary", + "bestellt": "warning", + "erhalten": "info", + "in_bearbeitung": "primary", + "bezahlt": "success", + "storniert": "danger", } - return colors.get(self.status, 'secondary') - + return colors.get(self.status, "secondary") + def get_effective_zahlungskonto(self): """Gibt das Zahlungskonto zurück, fallback auf Legacy-Konto""" return self.zahlungskonto or self.konto - + def get_effective_quellkonto(self): """Gibt das Quellkonto zurück, fallback auf Zahlungskonto oder Legacy-Konto""" return self.quellkonto or self.zahlungskonto or self.konto - + def is_fahrtkosten(self): """Prüft ob es sich um Fahrtkosten handelt""" - return self.kategorie == 'fahrtkosten' - + return self.kategorie == "fahrtkosten" + def calculate_fahrtkosten(self): """Berechnet Fahrtkosten automatisch wenn km_anzahl und km_satz gesetzt sind""" if self.km_anzahl and self.km_satz: @@ -1681,198 +2083,228 @@ class Verwaltungskosten(models.Model): class ApplicationPermission(models.Model): """Custom permissions for application functions""" - + class Meta: managed = False # No database table creation default_permissions = () # Remove default Django permissions permissions = [ # Entity Management Permissions - ('manage_destinataere', 'Kann Destinatäre verwalten'), - ('view_destinataere', 'Kann Destinatäre anzeigen'), - ('manage_land', 'Kann Ländereien verwalten'), - ('view_land', 'Kann Ländereien anzeigen'), - ('manage_paechter', 'Kann Pächter verwalten'), - ('view_paechter', 'Kann Pächter anzeigen'), - ('manage_verpachtungen', 'Kann Verpachtungen verwalten'), - ('view_verpachtungen', 'Kann Verpachtungen anzeigen'), - ('manage_foerderungen', 'Kann Förderungen verwalten'), - ('view_foerderungen', 'Kann Förderungen anzeigen'), - + ("manage_destinataere", "Kann Destinatäre verwalten"), + ("view_destinataere", "Kann Destinatäre anzeigen"), + ("manage_land", "Kann Ländereien verwalten"), + ("view_land", "Kann Ländereien anzeigen"), + ("manage_paechter", "Kann Pächter verwalten"), + ("view_paechter", "Kann Pächter anzeigen"), + ("manage_verpachtungen", "Kann Verpachtungen verwalten"), + ("view_verpachtungen", "Kann Verpachtungen anzeigen"), + ("manage_foerderungen", "Kann Förderungen verwalten"), + ("view_foerderungen", "Kann Förderungen anzeigen"), # Document Management Permissions - ('manage_documents', 'Kann Dokumente verwalten'), - ('view_documents', 'Kann Dokumente anzeigen'), - ('link_documents', 'Kann Dokumente verknüpfen'), - + ("manage_documents", "Kann Dokumente verwalten"), + ("view_documents", "Kann Dokumente anzeigen"), + ("link_documents", "Kann Dokumente verknüpfen"), # Financial Management Permissions - ('manage_verwaltungskosten', 'Kann Verwaltungskosten verwalten'), - ('view_verwaltungskosten', 'Kann Verwaltungskosten anzeigen'), - ('approve_payments', 'Kann Zahlungen genehmigen'), - ('manage_konten', 'Kann Stiftungskonten verwalten'), - ('view_konten', 'Kann Stiftungskonten anzeigen'), - ('manage_rentmeister', 'Kann Rentmeister verwalten'), - ('view_rentmeister', 'Kann Rentmeister anzeigen'), - + ("manage_verwaltungskosten", "Kann Verwaltungskosten verwalten"), + ("view_verwaltungskosten", "Kann Verwaltungskosten anzeigen"), + ("approve_payments", "Kann Zahlungen genehmigen"), + ("manage_konten", "Kann Stiftungskonten verwalten"), + ("view_konten", "Kann Stiftungskonten anzeigen"), + ("manage_rentmeister", "Kann Rentmeister verwalten"), + ("view_rentmeister", "Kann Rentmeister anzeigen"), # Administration Permissions - ('access_administration', 'Kann Administration aufrufen'), - ('view_audit_logs', 'Kann Audit-Logs anzeigen'), - ('manage_backups', 'Kann Backups erstellen und verwalten'), - ('manage_users', 'Kann Benutzer verwalten'), - ('manage_permissions', 'Kann Berechtigungen verwalten'), - + ("access_administration", "Kann Administration aufrufen"), + ("view_audit_logs", "Kann Audit-Logs anzeigen"), + ("manage_backups", "Kann Backups erstellen und verwalten"), + ("manage_users", "Kann Benutzer verwalten"), + ("manage_permissions", "Kann Berechtigungen verwalten"), # Import/Export Permissions - ('import_data', 'Kann Daten importieren'), - ('export_data', 'Kann Daten exportieren'), - + ("import_data", "Kann Daten importieren"), + ("export_data", "Kann Daten exportieren"), # System Permissions - ('access_django_admin', 'Kann Django Admin aufrufen'), - ('view_system_stats', 'Kann Systemstatistiken anzeigen'), + ("access_django_admin", "Kann Django Admin aufrufen"), + ("view_system_stats", "Kann Systemstatistiken anzeigen"), ] class AuditLog(models.Model): """Audit Log für alle Benutzeraktionen im System""" + ACTION_TYPES = [ - ('create', 'Erstellt'), - ('update', 'Aktualisiert'), - ('delete', 'Gelöscht'), - ('link', 'Verknüpft'), - ('unlink', 'Verknüpfung entfernt'), - ('login', 'Anmeldung'), - ('logout', 'Abmeldung'), - ('backup', 'Backup erstellt'), - ('restore', 'Wiederherstellung'), - ('export', 'Export'), - ('import', 'Import'), + ("create", "Erstellt"), + ("update", "Aktualisiert"), + ("delete", "Gelöscht"), + ("link", "Verknüpft"), + ("unlink", "Verknüpfung entfernt"), + ("login", "Anmeldung"), + ("logout", "Abmeldung"), + ("backup", "Backup erstellt"), + ("restore", "Wiederherstellung"), + ("export", "Export"), + ("import", "Import"), ] - + ENTITY_TYPES = [ - ('destinataer', 'Destinatär'), - ('land', 'Länderei'), - ('paechter', 'Pächter'), - ('verpachtung', 'Verpachtung'), - ('foerderung', 'Förderung'), - ('rentmeister', 'Rentmeister'), - ('stiftungskonto', 'Stiftungskonto'), - ('verwaltungskosten', 'Verwaltungskosten'), - ('banktransaction', 'Bank-Transaktion'), - ('dokumentlink', 'Dokument-Verknüpfung'), - ('system', 'System'), - ('user', 'Benutzer'), + ("destinataer", "Destinatär"), + ("land", "Länderei"), + ("paechter", "Pächter"), + ("verpachtung", "Verpachtung"), + ("foerderung", "Förderung"), + ("rentmeister", "Rentmeister"), + ("stiftungskonto", "Stiftungskonto"), + ("verwaltungskosten", "Verwaltungskosten"), + ("banktransaction", "Bank-Transaktion"), + ("dokumentlink", "Dokument-Verknüpfung"), + ("system", "System"), + ("user", "Benutzer"), ] - + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - + # Benutzer und Zeitpunkt - user = models.ForeignKey('auth.User', on_delete=models.SET_NULL, null=True, verbose_name="Benutzer") - username = models.CharField(max_length=150, verbose_name="Benutzername") # Fallback falls User gelöscht wird + user = models.ForeignKey( + "auth.User", on_delete=models.SET_NULL, null=True, verbose_name="Benutzer" + ) + username = models.CharField( + max_length=150, verbose_name="Benutzername" + ) # Fallback falls User gelöscht wird timestamp = models.DateTimeField(auto_now_add=True, verbose_name="Zeitpunkt") - + # Aktion - action = models.CharField(max_length=20, choices=ACTION_TYPES, verbose_name="Aktion") - entity_type = models.CharField(max_length=20, choices=ENTITY_TYPES, verbose_name="Entitätstyp") + action = models.CharField( + max_length=20, choices=ACTION_TYPES, verbose_name="Aktion" + ) + entity_type = models.CharField( + max_length=20, choices=ENTITY_TYPES, verbose_name="Entitätstyp" + ) entity_id = models.CharField(max_length=100, blank=True, verbose_name="Entitäts-ID") entity_name = models.CharField(max_length=255, verbose_name="Entitätsname") - + # Details description = models.TextField(verbose_name="Beschreibung") - changes = models.JSONField(null=True, blank=True, verbose_name="Änderungen") # Alte und neue Werte - + changes = models.JSONField( + null=True, blank=True, verbose_name="Änderungen" + ) # Alte und neue Werte + # Request-Informationen - ip_address = models.GenericIPAddressField(null=True, blank=True, verbose_name="IP-Adresse") + ip_address = models.GenericIPAddressField( + null=True, blank=True, verbose_name="IP-Adresse" + ) user_agent = models.TextField(blank=True, verbose_name="User Agent") - session_key = models.CharField(max_length=40, blank=True, verbose_name="Session-Key") - + session_key = models.CharField( + max_length=40, blank=True, verbose_name="Session-Key" + ) + class Meta: verbose_name = "Audit Log Eintrag" verbose_name_plural = "Audit Log Einträge" - ordering = ['-timestamp'] + ordering = ["-timestamp"] indexes = [ - models.Index(fields=['timestamp']), - models.Index(fields=['user', 'timestamp']), - models.Index(fields=['entity_type', 'timestamp']), - models.Index(fields=['action', 'timestamp']), + models.Index(fields=["timestamp"]), + models.Index(fields=["user", "timestamp"]), + models.Index(fields=["entity_type", "timestamp"]), + models.Index(fields=["action", "timestamp"]), ] - + def __str__(self): return f"{self.username} - {self.get_action_display()} {self.get_entity_type_display()} '{self.entity_name}' ({self.timestamp.strftime('%d.%m.%Y %H:%M')})" - + def get_changes_summary(self): """Erstellt eine lesbare Zusammenfassung der Änderungen""" if not self.changes: return "Keine Details verfügbar" - + if isinstance(self.changes, dict): summary = [] for field, values in self.changes.items(): - if isinstance(values, dict) and 'old' in values and 'new' in values: - old_val = values['old'] or 'Leer' - new_val = values['new'] or 'Leer' + if isinstance(values, dict) and "old" in values and "new" in values: + old_val = values["old"] or "Leer" + new_val = values["new"] or "Leer" summary.append(f"{field}: '{old_val}' → '{new_val}'") return "; ".join(summary) if summary else "Keine Änderungen dokumentiert" - + return str(self.changes) class BackupJob(models.Model): """Backup-Jobs und deren Status""" + STATUS_CHOICES = [ - ('pending', 'Wartend'), - ('running', 'Läuft'), - ('completed', 'Abgeschlossen'), - ('failed', 'Fehlgeschlagen'), + ("pending", "Wartend"), + ("running", "Läuft"), + ("completed", "Abgeschlossen"), + ("failed", "Fehlgeschlagen"), ] - + TYPE_CHOICES = [ - ('full', 'Vollständiges Backup'), - ('database', 'Nur Datenbank'), - ('files', 'Nur Dateien'), + ("full", "Vollständiges Backup"), + ("database", "Nur Datenbank"), + ("files", "Nur Dateien"), ] - + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - + # Job-Details - backup_type = models.CharField(max_length=20, choices=TYPE_CHOICES, verbose_name="Backup-Typ") - status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending', verbose_name="Status") - + backup_type = models.CharField( + max_length=20, choices=TYPE_CHOICES, verbose_name="Backup-Typ" + ) + status = models.CharField( + max_length=20, choices=STATUS_CHOICES, default="pending", verbose_name="Status" + ) + # Ausführung - created_by = models.ForeignKey('auth.User', on_delete=models.SET_NULL, null=True, verbose_name="Erstellt von") + created_by = models.ForeignKey( + "auth.User", on_delete=models.SET_NULL, null=True, verbose_name="Erstellt von" + ) created_at = models.DateTimeField(auto_now_add=True, verbose_name="Erstellt am") - started_at = models.DateTimeField(null=True, blank=True, verbose_name="Gestartet am") - completed_at = models.DateTimeField(null=True, blank=True, verbose_name="Abgeschlossen am") - + started_at = models.DateTimeField( + null=True, blank=True, verbose_name="Gestartet am" + ) + completed_at = models.DateTimeField( + null=True, blank=True, verbose_name="Abgeschlossen am" + ) + # Ergebnis - backup_filename = models.CharField(max_length=255, blank=True, verbose_name="Backup-Dateiname") - backup_size = models.BigIntegerField(null=True, blank=True, verbose_name="Backup-Größe (Bytes)") + backup_filename = models.CharField( + max_length=255, blank=True, verbose_name="Backup-Dateiname" + ) + backup_size = models.BigIntegerField( + null=True, blank=True, verbose_name="Backup-Größe (Bytes)" + ) error_message = models.TextField(blank=True, verbose_name="Fehlermeldung") - + # Metadaten - database_size = models.BigIntegerField(null=True, blank=True, verbose_name="Datenbankgröße (Bytes)") - files_count = models.IntegerField(null=True, blank=True, verbose_name="Anzahl Dateien") - + database_size = models.BigIntegerField( + null=True, blank=True, verbose_name="Datenbankgröße (Bytes)" + ) + files_count = models.IntegerField( + null=True, blank=True, verbose_name="Anzahl Dateien" + ) + class Meta: verbose_name = "Backup-Job" verbose_name_plural = "Backup-Jobs" - ordering = ['-created_at'] - + ordering = ["-created_at"] + def __str__(self): return f"{self.get_backup_type_display()} - {self.get_status_display()} ({self.created_at.strftime('%d.%m.%Y %H:%M')})" - + def get_duration(self): """Berechnet die Dauer des Backup-Jobs""" if self.started_at and self.completed_at: return self.completed_at - self.started_at elif self.started_at: from django.utils import timezone + return timezone.now() - self.started_at return None - + def get_size_display(self): """Formatiert die Backup-Größe für die Anzeige""" if not self.backup_size: return "Unbekannt" - + size = self.backup_size - for unit in ['B', 'KB', 'MB', 'GB']: + for unit in ["B", "KB", "MB", "GB"]: if size < 1024: return f"{size:.1f} {unit}" size /= 1024 @@ -1881,61 +2313,70 @@ class BackupJob(models.Model): class AppConfiguration(models.Model): """Application configuration settings that can be managed through the admin interface""" - + SETTING_TYPE_CHOICES = [ - ('text', 'Text'), - ('number', 'Number'), - ('boolean', 'Boolean'), - ('url', 'URL'), - ('tag', 'Tag Name'), - ('tag_id', 'Tag ID'), + ("text", "Text"), + ("number", "Number"), + ("boolean", "Boolean"), + ("url", "URL"), + ("tag", "Tag Name"), + ("tag_id", "Tag ID"), ] - + CATEGORY_CHOICES = [ - ('paperless', 'Paperless Integration'), - ('general', 'General Settings'), - ('corporate', 'Corporate Identity'), - ('notifications', 'Notifications'), - ('system', 'System Settings'), + ("paperless", "Paperless Integration"), + ("general", "General Settings"), + ("corporate", "Corporate Identity"), + ("notifications", "Notifications"), + ("system", "System Settings"), ] - + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) key = models.CharField(max_length=100, unique=True, verbose_name="Setting Key") display_name = models.CharField(max_length=200, verbose_name="Display Name") description = models.TextField(blank=True, null=True, verbose_name="Description") value = models.TextField(verbose_name="Value") default_value = models.TextField(verbose_name="Default Value") - setting_type = models.CharField(max_length=20, choices=SETTING_TYPE_CHOICES, default='text', verbose_name="Type") - category = models.CharField(max_length=50, choices=CATEGORY_CHOICES, default='general', verbose_name="Category") + setting_type = models.CharField( + max_length=20, choices=SETTING_TYPE_CHOICES, default="text", verbose_name="Type" + ) + category = models.CharField( + max_length=50, + choices=CATEGORY_CHOICES, + default="general", + verbose_name="Category", + ) is_active = models.BooleanField(default=True, verbose_name="Active") - is_system = models.BooleanField(default=False, verbose_name="System Setting (read-only)") + is_system = models.BooleanField( + default=False, verbose_name="System Setting (read-only)" + ) order = models.IntegerField(default=0, verbose_name="Display Order") - + # Metadata created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) - + class Meta: verbose_name = "App Configuration" verbose_name_plural = "App Configurations" - ordering = ['category', 'order', 'display_name'] - + ordering = ["category", "order", "display_name"] + def __str__(self): return f"{self.display_name} ({self.key})" - + def get_typed_value(self): """Return the value converted to the appropriate type""" - if self.setting_type == 'boolean': - return self.value.lower() in ('true', '1', 'yes', 'on') - elif self.setting_type == 'number': + if self.setting_type == "boolean": + return self.value.lower() in ("true", "1", "yes", "on") + elif self.setting_type == "number": try: - if '.' in self.value: + if "." in self.value: return float(self.value) return int(self.value) except (ValueError, TypeError): return 0 return self.value - + @classmethod def get_setting(cls, key, default=None): """Get a setting value by key""" @@ -1944,20 +2385,28 @@ class AppConfiguration(models.Model): return setting.get_typed_value() except cls.DoesNotExist: return default - + @classmethod - def set_setting(cls, key, value, display_name=None, description=None, setting_type='text', category='general'): + def set_setting( + cls, + key, + value, + display_name=None, + description=None, + setting_type="text", + category="general", + ): """Set or update a setting value""" setting, created = cls.objects.get_or_create( key=key, defaults={ - 'display_name': display_name or key, - 'description': description, - 'value': str(value), - 'default_value': str(value), - 'setting_type': setting_type, - 'category': category, - } + "display_name": display_name or key, + "description": description, + "value": str(value), + "default_value": str(value), + "setting_type": setting_type, + "category": category, + }, ) if not created: setting.value = str(value) @@ -1967,59 +2416,54 @@ class AppConfiguration(models.Model): class HelpBox(models.Model): """Editierbare Hilfe-Infoboxen für Formulare""" - + PAGE_CHOICES = [ - ('destinataer_new', 'Neuer Destinatär'), - ('unterstuetzung_new', 'Neue Unterstützung'), - ('foerderung_new', 'Neue Förderung'), - ('paechter_new', 'Neuer Pächter'), - ('laenderei_new', 'Neue Länderei'), - ('verpachtung_new', 'Neue Verpachtung'), - ('land_abrechnung_new', 'Neue Landabrechnung'), - ('person_new', 'Neue Person'), - ('konto_new', 'Neues Konto'), - ('verwaltungskosten_new', 'Neue Verwaltungskosten'), - ('rentmeister_new', 'Neuer Rentmeister'), - ('dokument_new', 'Neues Dokument'), - ('user_new', 'Neuer Benutzer'), - ('csv_import_new', 'CSV Import'), - ('destinataer_notiz_new', 'Destinatär Notiz'), + ("destinataer_new", "Neuer Destinatär"), + ("unterstuetzung_new", "Neue Unterstützung"), + ("foerderung_new", "Neue Förderung"), + ("paechter_new", "Neuer Pächter"), + ("laenderei_new", "Neue Länderei"), + ("verpachtung_new", "Neue Verpachtung"), + ("land_abrechnung_new", "Neue Landabrechnung"), + ("person_new", "Neue Person"), + ("konto_new", "Neues Konto"), + ("verwaltungskosten_new", "Neue Verwaltungskosten"), + ("rentmeister_new", "Neuer Rentmeister"), + ("dokument_new", "Neues Dokument"), + ("user_new", "Neuer Benutzer"), + ("csv_import_new", "CSV Import"), + ("destinataer_notiz_new", "Destinatär Notiz"), ] - + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) page_key = models.CharField( - max_length=50, - choices=PAGE_CHOICES, - unique=True, - verbose_name="Seite" - ) - title = models.CharField( - max_length=200, - verbose_name="Titel der Hilfsbox" + max_length=50, choices=PAGE_CHOICES, unique=True, verbose_name="Seite" ) + title = models.CharField(max_length=200, verbose_name="Titel der Hilfsbox") content = models.TextField( verbose_name="Inhalt (Markdown unterstützt)", - help_text="Sie können Markdown verwenden: **fett**, *kursiv*, `code`, [Link](url), etc." + help_text="Sie können Markdown verwenden: **fett**, *kursiv*, `code`, [Link](url), etc.", ) - is_active = models.BooleanField( - default=True, - verbose_name="Aktiv" - ) - + is_active = models.BooleanField(default=True, verbose_name="Aktiv") + # Metadata created_at = models.DateTimeField(auto_now_add=True, verbose_name="Erstellt am") updated_at = models.DateTimeField(auto_now=True, verbose_name="Aktualisiert am") - created_by = models.CharField(max_length=100, null=True, blank=True, verbose_name="Erstellt von") - updated_by = models.CharField(max_length=100, null=True, blank=True, verbose_name="Aktualisiert von") - + created_by = models.CharField( + max_length=100, null=True, blank=True, verbose_name="Erstellt von" + ) + updated_by = models.CharField( + max_length=100, null=True, blank=True, verbose_name="Aktualisiert von" + ) + class Meta: verbose_name = "Hilfs-Infobox" verbose_name_plural = "Hilfs-Infoboxen" - ordering = ['page_key'] - + ordering = ["page_key"] + def __str__(self): return f"{self.get_page_key_display()}: {self.title}" - + @classmethod def get_help_for_page(cls, page_key): """Hole die aktive Hilfs-Infobox für eine bestimmte Seite""" diff --git a/app/stiftung/serializers.py b/app/stiftung/serializers.py index f7783de..f090c00 100644 --- a/app/stiftung/serializers.py +++ b/app/stiftung/serializers.py @@ -1,11 +1,14 @@ from rest_framework import serializers -from .models import Person, Foerderung + +from .models import Foerderung, Person + class PersonSerializer(serializers.ModelSerializer): class Meta: model = Person fields = "__all__" + class FoerderungSerializer(serializers.ModelSerializer): class Meta: model = Foerderung diff --git a/app/stiftung/templatetags/help_tags.py b/app/stiftung/templatetags/help_tags.py index 12f852d..172d8d2 100644 --- a/app/stiftung/templatetags/help_tags.py +++ b/app/stiftung/templatetags/help_tags.py @@ -1,28 +1,33 @@ import markdown from django import template from django.utils.safestring import mark_safe + from stiftung.models import HelpBox register = template.Library() -@register.inclusion_tag('stiftung/help_box.html') + +@register.inclusion_tag("stiftung/help_box.html") def help_box(page_key, user=None): """Rendere eine Hilfs-Infobox für eine bestimmte Seite""" help_obj = HelpBox.get_help_for_page(page_key) - + context = { - 'help_obj': help_obj, - 'page_key': page_key, - 'can_edit': user and (user.username == 'root' or user.is_superuser) if user else False + "help_obj": help_obj, + "page_key": page_key, + "can_edit": ( + user and (user.username == "root" or user.is_superuser) if user else False + ), } - + if help_obj: # Konvertiere Markdown zu HTML - md = markdown.Markdown(extensions=['nl2br', 'fenced_code']) - context['content_html'] = mark_safe(md.convert(help_obj.content)) - + md = markdown.Markdown(extensions=["nl2br", "fenced_code"]) + context["content_html"] = mark_safe(md.convert(help_obj.content)) + return context + @register.simple_tag def help_box_exists(page_key): """Prüfe, ob eine Hilfs-Infobox für eine Seite existiert""" diff --git a/app/stiftung/templatetags/pdf_tags.py b/app/stiftung/templatetags/pdf_tags.py index 613972b..96b76e0 100644 --- a/app/stiftung/templatetags/pdf_tags.py +++ b/app/stiftung/templatetags/pdf_tags.py @@ -1,6 +1,7 @@ """ PDF-specific template tags and filters """ + from django import template from django.utils.safestring import mark_safe @@ -15,14 +16,14 @@ def lookup(obj, field_name): """ if obj is None: return None - + # Handle dict-like objects - if hasattr(obj, '__getitem__') and not isinstance(obj, str): + if hasattr(obj, "__getitem__") and not isinstance(obj, str): try: return obj[field_name] except (KeyError, TypeError): pass - + # Handle objects with attributes if hasattr(obj, field_name): attr = getattr(obj, field_name) @@ -34,17 +35,17 @@ def lookup(obj, field_name): # Method requires arguments, return as is return attr return attr - + # Try to handle nested field access (e.g., "person.name") - if '.' in field_name: - parts = field_name.split('.') + if "." in field_name: + parts = field_name.split(".") current = obj for part in parts: if current is None: return None current = lookup(current, part) return current - + return None @@ -55,14 +56,14 @@ def get_display_value(obj, field_name): Usage: {{ object|get_display_value:"field_name" }} """ value = lookup(obj, field_name) - + # Try to get display value for choice fields - display_method = f'get_{field_name}_display' + display_method = f"get_{field_name}_display" if hasattr(obj, display_method): display_value = getattr(obj, display_method)() if display_value: return display_value - + return value @@ -73,9 +74,9 @@ def format_currency(value): Usage: {{ value|format_currency }} """ if value is None: - return '-' + return "-" try: - return f"€{float(value):,.2f}".replace(',', ' ') + return f"€{float(value):,.2f}".replace(",", " ") except (ValueError, TypeError): return str(value) @@ -87,11 +88,11 @@ def format_status_badge(status): Usage: {{ status|format_status_badge }} """ if not status: - return '-' - + return "-" + status_lower = str(status).lower() - css_class = f'status-{status_lower}' - + css_class = f"status-{status_lower}" + return mark_safe(f'{status}') @@ -102,13 +103,13 @@ def truncate_field(value, max_length=50): Usage: {{ value|truncate_field:30 }} """ if value is None: - return '-' - + return "-" + str_value = str(value) if len(str_value) <= max_length: return str_value - - return str_value[:max_length-3] + '...' + + return str_value[: max_length - 3] + "..." @register.simple_tag @@ -117,29 +118,29 @@ def get_field_value(obj, field_config): Get formatted field value based on field configuration Usage: {% get_field_value object field_config %} """ - field_name = field_config.get('field_name') - field_type = field_config.get('field_type', 'text') - + field_name = field_config.get("field_name") + field_type = field_config.get("field_type", "text") + value = lookup(obj, field_name) - + if value is None: - return '-' - - if field_type == 'currency': + return "-" + + if field_type == "currency": return format_currency(value) - elif field_type == 'date': + elif field_type == "date": try: - return value.strftime('%d.%m.%Y') + return value.strftime("%d.%m.%Y") except (AttributeError, ValueError): return str(value) - elif field_type == 'datetime': + elif field_type == "datetime": try: - return value.strftime('%d.%m.%Y %H:%M') + return value.strftime("%d.%m.%Y %H:%M") except (AttributeError, ValueError): return str(value) - elif field_type == 'status': + elif field_type == "status": return format_status_badge(value) - elif field_type == 'boolean': - return 'Ja' if value else 'Nein' + elif field_type == "boolean": + return "Ja" if value else "Nein" else: return truncate_field(value) diff --git a/app/stiftung/urls.py b/app/stiftung/urls.py index b51a0e1..c87a323 100644 --- a/app/stiftung/urls.py +++ b/app/stiftung/urls.py @@ -1,160 +1,337 @@ from django.urls import path + from . import views -app_name = 'stiftung' +app_name = "stiftung" urlpatterns = [ # Dashboard (Startseite) - path('', views.dashboard, name='dashboard'), - + path("", views.dashboard, name="dashboard"), # Home (für Kompatibilität mit bestehenden Templates) - path('home/', views.home, name='home'), - + path("home/", views.home, name="home"), # CSV Import URLs - path('import/', views.csv_import_list, name='csv_import_list'), - path('import/neu/', views.csv_import_create, name='csv_import_create'), - + path("import/", views.csv_import_list, name="csv_import_list"), + path("import/neu/", views.csv_import_create, name="csv_import_create"), # Destinatär URLs (Förderungsempfänger) - path('destinataere/', views.destinataer_list, name='destinataer_list'), - path('destinataere//', views.destinataer_detail, name='destinataer_detail'), - path('destinataere/neu/', views.destinataer_create, name='destinataer_create'), - path('destinataere//bearbeiten/', views.destinataer_update, name='destinataer_update'), - path('destinataere//loeschen/', views.destinataer_delete, name='destinataer_delete'), - path('destinataere//notiz/', views.destinataer_notiz_create, name='destinataer_notiz_create'), - path('destinataere//export/', views.destinataer_export, name='destinataer_export'), - + path("destinataere/", views.destinataer_list, name="destinataer_list"), + path( + "destinataere//", views.destinataer_detail, name="destinataer_detail" + ), + path("destinataere/neu/", views.destinataer_create, name="destinataer_create"), + path( + "destinataere//bearbeiten/", + views.destinataer_update, + name="destinataer_update", + ), + path( + "destinataere//loeschen/", + views.destinataer_delete, + name="destinataer_delete", + ), + path( + "destinataere//notiz/", + views.destinataer_notiz_create, + name="destinataer_notiz_create", + ), + path( + "destinataere//export/", + views.destinataer_export, + name="destinataer_export", + ), # Paechter URLs (Landpächter) - path('paechter/', views.paechter_list, name='paechter_list'), - path('paechter//', views.paechter_detail, name='paechter_detail'), - path('paechter/neu/', views.paechter_create, name='paechter_create'), - path('paechter//bearbeiten/', views.paechter_update, name='paechter_update'), - path('paechter//loeschen/', views.paechter_delete, name='paechter_delete'), - path('paechter//export/', views.paechter_export, name='paechter_export'), - + path("paechter/", views.paechter_list, name="paechter_list"), + path("paechter//", views.paechter_detail, name="paechter_detail"), + path("paechter/neu/", views.paechter_create, name="paechter_create"), + path( + "paechter//bearbeiten/", views.paechter_update, name="paechter_update" + ), + path("paechter//loeschen/", views.paechter_delete, name="paechter_delete"), + path("paechter//export/", views.paechter_export, name="paechter_export"), # Legacy Person URLs removed (Destinatäre ersetzen Personen) - # Land URLs - path('laendereien/', views.land_list, name='land_list'), - path('laendereien//', views.land_detail, name='land_detail'), - path('laendereien/neu/', views.land_create, name='land_create'), - path('laendereien//bearbeiten/', views.land_update, name='land_update'), - path('laendereien//loeschen/', views.land_delete, name='land_delete'), - path('laendereien//export/', views.land_export, name='land_export'), - + path("laendereien/", views.land_list, name="land_list"), + path("laendereien//", views.land_detail, name="land_detail"), + path("laendereien/neu/", views.land_create, name="land_create"), + path("laendereien//bearbeiten/", views.land_update, name="land_update"), + path("laendereien//loeschen/", views.land_delete, name="land_delete"), + path("laendereien//export/", views.land_export, name="land_export"), # Landabrechnung URLs - path('landabrechnungen/', views.land_abrechnung_list, name='land_abrechnung_list'), - path('landabrechnungen//', views.land_abrechnung_detail, name='land_abrechnung_detail'), - path('landabrechnungen/neu/', views.land_abrechnung_create, name='land_abrechnung_create'), - path('landabrechnungen//bearbeiten/', views.land_abrechnung_update, name='land_abrechnung_update'), - path('landabrechnungen//loeschen/', views.land_abrechnung_delete, name='land_abrechnung_delete'), - + path("landabrechnungen/", views.land_abrechnung_list, name="land_abrechnung_list"), + path( + "landabrechnungen//", + views.land_abrechnung_detail, + name="land_abrechnung_detail", + ), + path( + "landabrechnungen/neu/", + views.land_abrechnung_create, + name="land_abrechnung_create", + ), + path( + "landabrechnungen//bearbeiten/", + views.land_abrechnung_update, + name="land_abrechnung_update", + ), + path( + "landabrechnungen//loeschen/", + views.land_abrechnung_delete, + name="land_abrechnung_delete", + ), # Vereinheitlichte Verpachtung URLs (direkt im Land) - path('laendereien//verpachtung/neu/', views.land_verpachtung_create, name='land_verpachtung_create'), - path('laendereien//verpachtung/bearbeiten/', views.land_verpachtung_edit, name='land_verpachtung_edit'), - path('laendereien//verpachtung/beenden/', views.land_verpachtung_end, name='land_verpachtung_end'), - + path( + "laendereien//verpachtung/neu/", + views.land_verpachtung_create, + name="land_verpachtung_create", + ), + path( + "laendereien//verpachtung/bearbeiten/", + views.land_verpachtung_edit, + name="land_verpachtung_edit", + ), + path( + "laendereien//verpachtung/beenden/", + views.land_verpachtung_end, + name="land_verpachtung_end", + ), # LandVerpachtung URLs (neue Verpachtungen) - path('laendereien/verpachtungen//', views.land_verpachtung_detail, name='land_verpachtung_detail'), - path('laendereien/verpachtungen//bearbeiten/', views.land_verpachtung_update, name='land_verpachtung_update'), - path('laendereien/verpachtungen//beenden/', views.land_verpachtung_end_direct, name='land_verpachtung_end_direct'), - + path( + "laendereien/verpachtungen//", + views.land_verpachtung_detail, + name="land_verpachtung_detail", + ), + path( + "laendereien/verpachtungen//bearbeiten/", + views.land_verpachtung_update, + name="land_verpachtung_update", + ), + path( + "laendereien/verpachtungen//beenden/", + views.land_verpachtung_end_direct, + name="land_verpachtung_end_direct", + ), # Förderung URLs - path('foerderungen/', views.foerderung_list, name='foerderung_list'), - path('foerderungen//', views.foerderung_detail, name='foerderung_detail'), - path('foerderungen/neu/', views.foerderung_create, name='foerderung_create'), - path('foerderungen//bearbeiten/', views.foerderung_update, name='foerderung_update'), - path('foerderungen//loeschen/', views.foerderung_delete, name='foerderung_delete'), - + path("foerderungen/", views.foerderung_list, name="foerderung_list"), + path("foerderungen//", views.foerderung_detail, name="foerderung_detail"), + path("foerderungen/neu/", views.foerderung_create, name="foerderung_create"), + path( + "foerderungen//bearbeiten/", + views.foerderung_update, + name="foerderung_update", + ), + path( + "foerderungen//loeschen/", + views.foerderung_delete, + name="foerderung_delete", + ), # Dokumente URLs - path('dokumente/', views.dokument_list, name='dokument_list'), - path('dokumente//', views.dokument_detail, name='dokument_detail'), - path('dokumente/neu/', views.dokument_create, name='dokument_create'), - path('dokumente//bearbeiten/', views.dokument_update, name='dokument_update'), - path('dokumente//loeschen/', views.dokument_delete, name='dokument_delete'), - + path("dokumente/", views.dokument_list, name="dokument_list"), + path("dokumente//", views.dokument_detail, name="dokument_detail"), + path("dokumente/neu/", views.dokument_create, name="dokument_create"), + path( + "dokumente//bearbeiten/", views.dokument_update, name="dokument_update" + ), + path( + "dokumente//loeschen/", views.dokument_delete, name="dokument_delete" + ), # Dokumentenverwaltung (Paperless-Integration, Verwaltung & Verknüpfung) - path('dokumente/verwaltung/', views.dokument_management, name='dokument_management'), - + path( + "dokumente/verwaltung/", views.dokument_management, name="dokument_management" + ), # Legacy document URLs removed - use dokument_management instead - # Dokument-Verknüpfung - path('api/link-document/search/', views.link_document_search, name='link_document_search'), - path('api/link-document/create/', views.link_document_create, name='link_document_create'), - path('api/link-document/list/', views.link_document_list, name='link_document_list'), - path('api/link-document/update/', views.link_document_update, name='link_document_update'), - path('api/link-document/delete//', views.link_document_delete, name='link_document_delete'), + path( + "api/link-document/search/", + views.link_document_search, + name="link_document_search", + ), + path( + "api/link-document/create/", + views.link_document_create, + name="link_document_create", + ), + path( + "api/link-document/list/", views.link_document_list, name="link_document_list" + ), + path( + "api/link-document/update/", + views.link_document_update, + name="link_document_update", + ), + path( + "api/link-document/delete//", + views.link_document_delete, + name="link_document_delete", + ), # Legacy dokument_verknuepfung URL removed - use dokument_management instead - # Jahresbericht URLs - path('berichte/', views.bericht_list, name='bericht_list'), - path('berichte/jahresbericht/', views.jahresbericht_generate_redirect, name='jahresbericht_generate_redirect'), - path('berichte/jahresbericht//', views.jahresbericht_generate, name='jahresbericht_generate'), - path('berichte/jahresbericht//pdf/', views.jahresbericht_pdf, name='jahresbericht_pdf'), - + path("berichte/", views.bericht_list, name="bericht_list"), + path( + "berichte/jahresbericht/", + views.jahresbericht_generate_redirect, + name="jahresbericht_generate_redirect", + ), + path( + "berichte/jahresbericht//", + views.jahresbericht_generate, + name="jahresbericht_generate", + ), + path( + "berichte/jahresbericht//pdf/", + views.jahresbericht_pdf, + name="jahresbericht_pdf", + ), # Geschäftsführung URLs - path('geschaeftsfuehrung/', views.geschaeftsfuehrung, name='geschaeftsfuehrung'), - path('geschaeftsfuehrung/konten/', views.konto_list, name='konto_list'), - path('geschaeftsfuehrung/konten/neu/', views.konto_create, name='konto_create'), - path('geschaeftsfuehrung/konten//', views.konto_detail, name='konto_detail'), - path('geschaeftsfuehrung/konten//bearbeiten/', views.konto_edit, name='konto_edit'), - path('geschaeftsfuehrung/verwaltungskosten/', views.verwaltungskosten_list, name='verwaltungskosten_list'), - path('geschaeftsfuehrung/verwaltungskosten/neu/', views.verwaltungskosten_create, name='verwaltungskosten_create'), - path('geschaeftsfuehrung/verwaltungskosten//bearbeiten/', views.verwaltungskosten_edit, name='verwaltungskosten_edit'), - path('verwaltungskosten/mark-paid/', views.mark_expense_paid, name='mark_expense_paid'), - path('geschaeftsfuehrung/rentmeister/', views.rentmeister_list, name='rentmeister_list'), - path('geschaeftsfuehrung/rentmeister/neu/', views.rentmeister_create, name='rentmeister_create'), - path('geschaeftsfuehrung/rentmeister//', views.rentmeister_detail, name='rentmeister_detail'), - path('geschaeftsfuehrung/rentmeister//bearbeiten/', views.rentmeister_edit, name='rentmeister_edit'), - path('geschaeftsfuehrung/rentmeister//ausgaben/', views.rentmeister_ausgaben, name='rentmeister_ausgaben'), - + path("geschaeftsfuehrung/", views.geschaeftsfuehrung, name="geschaeftsfuehrung"), + path("geschaeftsfuehrung/konten/", views.konto_list, name="konto_list"), + path("geschaeftsfuehrung/konten/neu/", views.konto_create, name="konto_create"), + path( + "geschaeftsfuehrung/konten//", views.konto_detail, name="konto_detail" + ), + path( + "geschaeftsfuehrung/konten//bearbeiten/", + views.konto_edit, + name="konto_edit", + ), + path( + "geschaeftsfuehrung/verwaltungskosten/", + views.verwaltungskosten_list, + name="verwaltungskosten_list", + ), + path( + "geschaeftsfuehrung/verwaltungskosten/neu/", + views.verwaltungskosten_create, + name="verwaltungskosten_create", + ), + path( + "geschaeftsfuehrung/verwaltungskosten//bearbeiten/", + views.verwaltungskosten_edit, + name="verwaltungskosten_edit", + ), + path( + "verwaltungskosten/mark-paid/", + views.mark_expense_paid, + name="mark_expense_paid", + ), + path( + "geschaeftsfuehrung/rentmeister/", + views.rentmeister_list, + name="rentmeister_list", + ), + path( + "geschaeftsfuehrung/rentmeister/neu/", + views.rentmeister_create, + name="rentmeister_create", + ), + path( + "geschaeftsfuehrung/rentmeister//", + views.rentmeister_detail, + name="rentmeister_detail", + ), + path( + "geschaeftsfuehrung/rentmeister//bearbeiten/", + views.rentmeister_edit, + name="rentmeister_edit", + ), + path( + "geschaeftsfuehrung/rentmeister//ausgaben/", + views.rentmeister_ausgaben, + name="rentmeister_ausgaben", + ), # Administration URLs - path('administration/', views.administration, name='administration'), - path('administration/settings/', views.app_settings, name='app_settings'), - path('administration/audit-log/', views.audit_log_list, name='audit_log_list'), - path('administration/backup/', views.backup_management, name='backup_management'), - path('administration/backup//download/', views.backup_download, name='backup_download'), - path('administration/backup/restore/', views.backup_restore, name='backup_restore'), - path('administration/unterstuetzungen/', views.unterstuetzungen_list, name='unterstuetzungen_list'), - path('administration/unterstuetzungen//bearbeiten/', views.unterstuetzung_edit, name='unterstuetzung_edit'), - path('administration/unterstuetzungen//loeschen/', views.unterstuetzung_delete, name='unterstuetzung_delete'), - + path("administration/", views.administration, name="administration"), + path("administration/settings/", views.app_settings, name="app_settings"), + path("administration/audit-log/", views.audit_log_list, name="audit_log_list"), + path("administration/backup/", views.backup_management, name="backup_management"), + path( + "administration/backup//download/", + views.backup_download, + name="backup_download", + ), + path("administration/backup/restore/", views.backup_restore, name="backup_restore"), + path( + "administration/unterstuetzungen/", + views.unterstuetzungen_list, + name="unterstuetzungen_list", + ), + path( + "administration/unterstuetzungen//bearbeiten/", + views.unterstuetzung_edit, + name="unterstuetzung_edit", + ), + path( + "administration/unterstuetzungen//loeschen/", + views.unterstuetzung_delete, + name="unterstuetzung_delete", + ), # Unterstützungen URLs (direct access from Destinataer) - path('unterstuetzungen/', views.unterstuetzungen_all, name='unterstuetzungen_all'), - path('unterstuetzungen/neu/', views.unterstuetzung_create, name='unterstuetzung_create'), - path('unterstuetzungen//', views.unterstuetzung_detail, name='unterstuetzung_detail'), - path('unterstuetzungen//bezahlt/', views.unterstuetzung_mark_paid, name='unterstuetzung_mark_paid'), - path('unterstuetzungen/wiederkehrend/', views.wiederkehrende_unterstuetzungen, name='wiederkehrende_unterstuetzungen'), - + path("unterstuetzungen/", views.unterstuetzungen_all, name="unterstuetzungen_all"), + path( + "unterstuetzungen/neu/", + views.unterstuetzung_create, + name="unterstuetzung_create", + ), + path( + "unterstuetzungen//", + views.unterstuetzung_detail, + name="unterstuetzung_detail", + ), + path( + "unterstuetzungen//bezahlt/", + views.unterstuetzung_mark_paid, + name="unterstuetzung_mark_paid", + ), + path( + "unterstuetzungen/wiederkehrend/", + views.wiederkehrende_unterstuetzungen, + name="wiederkehrende_unterstuetzungen", + ), # AJAX endpoints - path('api/destinataer//info/', views.get_destinataer_info, name='get_destinataer_info'), - + path( + "api/destinataer//info/", + views.get_destinataer_info, + name="get_destinataer_info", + ), # Authentication URLs - path('login/', views.user_login, name='login'), - path('logout/', views.user_logout, name='logout'), - + path("login/", views.user_login, name="login"), + path("logout/", views.user_logout, name="logout"), # User Management URLs - path('administration/users/', views.user_management, name='user_management'), - path('administration/users/create/', views.user_create, name='user_create'), - path('administration/users//', views.user_detail, name='user_detail'), - path('administration/users//edit/', views.user_edit, name='user_edit'), - path('administration/users//password/', views.user_change_password, name='user_change_password'), - path('administration/users//permissions/', views.user_permissions, name='user_permissions'), - path('administration/users//delete/', views.user_delete, name='user_delete'), - + path("administration/users/", views.user_management, name="user_management"), + path("administration/users/create/", views.user_create, name="user_create"), + path("administration/users//", views.user_detail, name="user_detail"), + path("administration/users//edit/", views.user_edit, name="user_edit"), + path( + "administration/users//password/", + views.user_change_password, + name="user_change_password", + ), + path( + "administration/users//permissions/", + views.user_permissions, + name="user_permissions", + ), + path( + "administration/users//delete/", views.user_delete, name="user_delete" + ), # Hilfsbox URLs - path('help-box/edit/', views.edit_help_box, name='edit_help_box'), - path('help-box/admin/', views.edit_help_box, name='help_boxes_admin'), - + path("help-box/edit/", views.edit_help_box, name="edit_help_box"), + path("help-box/admin/", views.edit_help_box, name="help_boxes_admin"), # API URLs - path('api/land-stats/', views.land_stats_api, name='land_stats_api'), - path('api/health/', views.health_check, name='health_check'), - path('api/paperless/ping/', views.paperless_ping, name='paperless_ping'), - path('api/paperless/documents/', views.paperless_documents, name='paperless_documents'), - path('api/paperless/tags/', views.paperless_tags_only, name='paperless_tags_only'), - path('api/paperless/debug/', views.paperless_debug, name='paperless_debug'), - path('api/paperless/documents//', views.paperless_document_redirect, name='paperless_document_redirect'), - + path("api/land-stats/", views.land_stats_api, name="land_stats_api"), + path("api/health/", views.health_check, name="health_check"), + path("api/paperless/ping/", views.paperless_ping, name="paperless_ping"), + path( + "api/paperless/documents/", + views.paperless_documents, + name="paperless_documents", + ), + path("api/paperless/tags/", views.paperless_tags_only, name="paperless_tags_only"), + path("api/paperless/debug/", views.paperless_debug, name="paperless_debug"), + path( + "api/paperless/documents//", + views.paperless_document_redirect, + name="paperless_document_redirect", + ), # Gramps integration (probe) - path('api/gramps/search/', views.gramps_search_api, name='gramps_search_api'), - path('api/gramps/debug/', views.gramps_debug_api, name='gramps_debug_api'), + path("api/gramps/search/", views.gramps_search_api, name="gramps_search_api"), + path("api/gramps/debug/", views.gramps_debug_api, name="gramps_debug_api"), ] diff --git a/app/stiftung/utils/config.py b/app/stiftung/utils/config.py index d6c612d..8862cb5 100644 --- a/app/stiftung/utils/config.py +++ b/app/stiftung/utils/config.py @@ -1,61 +1,65 @@ """ Configuration utilities for the Stiftung application """ + from django.conf import settings + from stiftung.models import AppConfiguration def get_config(key, default=None, fallback_to_settings=True): """ Get a configuration value from the database or fall back to Django settings - + Args: key: The configuration key default: Default value if not found fallback_to_settings: If True, try to get from Django settings using the key in uppercase - + Returns: The configuration value """ # Try to get from AppConfiguration first value = AppConfiguration.get_setting(key, None) - + # Fall back to Django settings if value is None or empty string if not value and fallback_to_settings: settings_key = key.upper() return getattr(settings, settings_key, default) - + return value if value is not None else default def get_paperless_config(): """ Get all Paperless-related configuration values - + Returns: dict: Dictionary containing all Paperless configuration """ return { - 'api_url': get_config('paperless_api_url', 'http://192.168.178.167:30070'), - 'api_token': get_config('paperless_api_token', ''), - 'destinataere_tag': get_config('paperless_destinataere_tag', 'Stiftung_Destinatäre'), - 'destinataere_tag_id': get_config('paperless_destinataere_tag_id', '210'), - 'land_tag': get_config('paperless_land_tag', 'Stiftung_Land_und_Pächter'), - 'land_tag_id': get_config('paperless_land_tag_id', '204'), - 'admin_tag': get_config('paperless_admin_tag', 'Stiftung_Administration'), - 'admin_tag_id': get_config('paperless_admin_tag_id', '216'), + "api_url": get_config("paperless_api_url", "http://192.168.178.167:30070"), + "api_token": get_config("paperless_api_token", ""), + "destinataere_tag": get_config( + "paperless_destinataere_tag", "Stiftung_Destinatäre" + ), + "destinataere_tag_id": get_config("paperless_destinataere_tag_id", "210"), + "land_tag": get_config("paperless_land_tag", "Stiftung_Land_und_Pächter"), + "land_tag_id": get_config("paperless_land_tag_id", "204"), + "admin_tag": get_config("paperless_admin_tag", "Stiftung_Administration"), + "admin_tag_id": get_config("paperless_admin_tag_id", "216"), } def set_config(key, value, **kwargs): """ Set a configuration value - + Args: key: The configuration key value: The value to set **kwargs: Additional parameters for AppConfiguration.set_setting - + Returns: AppConfiguration: The configuration object """ @@ -65,9 +69,9 @@ def set_config(key, value, **kwargs): def is_paperless_configured(): """ Check if Paperless is properly configured - + Returns: bool: True if API URL and token are configured """ config = get_paperless_config() - return bool(config['api_url'] and config['api_token']) + return bool(config["api_url"] and config["api_token"]) diff --git a/app/stiftung/utils/pdf_generator.py b/app/stiftung/utils/pdf_generator.py index fe19e38..aa3d34c 100644 --- a/app/stiftung/utils/pdf_generator.py +++ b/app/stiftung/utils/pdf_generator.py @@ -1,18 +1,21 @@ """ PDF generation utilities with corporate identity support """ -import os + import base64 +import os from io import BytesIO + from django.conf import settings -from django.template.loader import render_to_string from django.http import HttpResponse +from django.template.loader import render_to_string from django.utils import timezone # Try to import WeasyPrint, fall back gracefully if not available try: - from weasyprint import HTML, CSS + from weasyprint import CSS, HTML from weasyprint.text.fonts import FontConfiguration + WEASYPRINT_AVAILABLE = True IMPORT_ERROR = None except ImportError as e: @@ -35,72 +38,84 @@ from stiftung.models import AppConfiguration class PDFGenerator: """Corporate identity PDF generator""" - + def __init__(self): if WEASYPRINT_AVAILABLE: self.font_config = FontConfiguration() else: self.font_config = None - + def is_available(self): """Check if PDF generation is available""" return WEASYPRINT_AVAILABLE - + def get_corporate_settings(self): """Get corporate identity settings from configuration""" return { - 'stiftung_name': AppConfiguration.get_setting('corporate_stiftung_name', 'Stiftung'), - 'logo_path': AppConfiguration.get_setting('corporate_logo_path', ''), - 'primary_color': AppConfiguration.get_setting('corporate_primary_color', '#2c3e50'), - 'secondary_color': AppConfiguration.get_setting('corporate_secondary_color', '#3498db'), - 'address_line1': AppConfiguration.get_setting('corporate_address_line1', ''), - 'address_line2': AppConfiguration.get_setting('corporate_address_line2', ''), - 'phone': AppConfiguration.get_setting('corporate_phone', ''), - 'email': AppConfiguration.get_setting('corporate_email', ''), - 'website': AppConfiguration.get_setting('corporate_website', ''), - 'footer_text': AppConfiguration.get_setting('corporate_footer_text', 'Dieser Bericht wurde automatisch generiert.'), + "stiftung_name": AppConfiguration.get_setting( + "corporate_stiftung_name", "Stiftung" + ), + "logo_path": AppConfiguration.get_setting("corporate_logo_path", ""), + "primary_color": AppConfiguration.get_setting( + "corporate_primary_color", "#2c3e50" + ), + "secondary_color": AppConfiguration.get_setting( + "corporate_secondary_color", "#3498db" + ), + "address_line1": AppConfiguration.get_setting( + "corporate_address_line1", "" + ), + "address_line2": AppConfiguration.get_setting( + "corporate_address_line2", "" + ), + "phone": AppConfiguration.get_setting("corporate_phone", ""), + "email": AppConfiguration.get_setting("corporate_email", ""), + "website": AppConfiguration.get_setting("corporate_website", ""), + "footer_text": AppConfiguration.get_setting( + "corporate_footer_text", "Dieser Bericht wurde automatisch generiert." + ), } - + def get_logo_base64(self, logo_path): """Convert logo to base64 for embedding in PDF""" if not logo_path: return None - + # Try different possible paths possible_paths = [ logo_path, os.path.join(settings.MEDIA_ROOT, logo_path), - os.path.join(settings.STATIC_ROOT or '', logo_path), - os.path.join(settings.BASE_DIR, 'static', logo_path), + os.path.join(settings.STATIC_ROOT or "", logo_path), + os.path.join(settings.BASE_DIR, "static", logo_path), ] - + for path in possible_paths: if os.path.exists(path): try: - with open(path, 'rb') as img_file: - img_data = base64.b64encode(img_file.read()).decode('utf-8') + with open(path, "rb") as img_file: + img_data = base64.b64encode(img_file.read()).decode("utf-8") # Determine MIME type ext = os.path.splitext(path)[1].lower() - if ext in ['.jpg', '.jpeg']: - mime_type = 'image/jpeg' - elif ext == '.png': - mime_type = 'image/png' - elif ext == '.svg': - mime_type = 'image/svg+xml' + if ext in [".jpg", ".jpeg"]: + mime_type = "image/jpeg" + elif ext == ".png": + mime_type = "image/png" + elif ext == ".svg": + mime_type = "image/svg+xml" else: - mime_type = 'image/png' # default - + mime_type = "image/png" # default + return f"data:{mime_type};base64,{img_data}" except Exception: continue - + return None - + def get_base_css(self, corporate_settings): """Generate base CSS for corporate identity""" - primary_color = corporate_settings.get('primary_color', '#2c3e50') - secondary_color = corporate_settings.get('secondary_color', '#3498db') - + primary_color = corporate_settings.get("primary_color", "#2c3e50") + secondary_color = corporate_settings.get("secondary_color", "#3498db") + return f""" @page {{ size: A4; @@ -291,7 +306,7 @@ class PDFGenerator: page-break-before: always; }} """ - + def generate_pdf_response(self, html_content, filename, css_content=None): """Generate PDF response from HTML content""" if not WEASYPRINT_AVAILABLE: @@ -320,27 +335,30 @@ class PDFGenerator: """ - response = HttpResponse(error_html, content_type='text/html') - response['Content-Disposition'] = f'inline; filename="{filename.replace(".pdf", "_preview.html")}"' + response = HttpResponse(error_html, content_type="text/html") + response["Content-Disposition"] = ( + f'inline; filename="{filename.replace(".pdf", "_preview.html")}"' + ) return response - + try: # Create CSS string if css_content: css = CSS(string=css_content, font_config=self.font_config) else: css = None - + # Generate PDF html_doc = HTML(string=html_content) - pdf_bytes = html_doc.write_pdf(stylesheets=[css] if css else None, - font_config=self.font_config) - + pdf_bytes = html_doc.write_pdf( + stylesheets=[css] if css else None, font_config=self.font_config + ) + # Create response - response = HttpResponse(pdf_bytes, content_type='application/pdf') - response['Content-Disposition'] = f'attachment; filename="{filename}"' + response = HttpResponse(pdf_bytes, content_type="application/pdf") + response["Content-Disposition"] = f'attachment; filename="{filename}"' return response - + except Exception as e: # Fallback: return error message as HTML error_html = f""" @@ -368,15 +386,19 @@ class PDFGenerator: """ - - response = HttpResponse(error_html, content_type='text/html') - response['Content-Disposition'] = f'inline; filename="error_{filename.replace(".pdf", ".html")}"' + + response = HttpResponse(error_html, content_type="text/html") + response["Content-Disposition"] = ( + f'inline; filename="error_{filename.replace(".pdf", ".html")}"' + ) return response - - def export_data_list_pdf(self, data, fields_config, title, filename_prefix, request_user=None): + + def export_data_list_pdf( + self, data, fields_config, title, filename_prefix, request_user=None + ): """ Export a list of data as formatted PDF - + Args: data: QuerySet or list of model instances fields_config: dict with field names as keys and display names as values @@ -385,34 +407,39 @@ class PDFGenerator: request_user: User making the request (for audit purposes) """ corporate_settings = self.get_corporate_settings() - logo_base64 = self.get_logo_base64(corporate_settings.get('logo_path', '')) - + logo_base64 = self.get_logo_base64(corporate_settings.get("logo_path", "")) + # Prepare context context = { - 'corporate_settings': corporate_settings, - 'logo_base64': logo_base64, - 'title': title, - 'data': data, - 'fields_config': fields_config, - 'generation_date': timezone.now(), - 'generated_by': (request_user.get_full_name() - if hasattr(request_user, 'get_full_name') and request_user.get_full_name() - else request_user.username - if hasattr(request_user, 'username') and request_user.username - else 'System'), - 'total_count': len(data) if hasattr(data, '__len__') else data.count(), + "corporate_settings": corporate_settings, + "logo_base64": logo_base64, + "title": title, + "data": data, + "fields_config": fields_config, + "generation_date": timezone.now(), + "generated_by": ( + request_user.get_full_name() + if hasattr(request_user, "get_full_name") + and request_user.get_full_name() + else ( + request_user.username + if hasattr(request_user, "username") and request_user.username + else "System" + ) + ), + "total_count": len(data) if hasattr(data, "__len__") else data.count(), } - + # Render HTML - html_content = render_to_string('pdf/data_list.html', context) - + html_content = render_to_string("pdf/data_list.html", context) + # Generate CSS css_content = self.get_base_css(corporate_settings) - + # Generate filename - timestamp = timezone.now().strftime('%Y%m%d_%H%M%S') + timestamp = timezone.now().strftime("%Y%m%d_%H%M%S") filename = f"{filename_prefix}_{timestamp}.pdf" - + return self.generate_pdf_response(html_content, filename, css_content) diff --git a/app/stiftung/views.py b/app/stiftung/views.py index 0560e2a..2106c77 100644 --- a/app/stiftung/views.py +++ b/app/stiftung/views.py @@ -1,40 +1,50 @@ -import os -import requests import csv import io +import json +import os +import time from datetime import datetime -from django.shortcuts import render, get_object_or_404, redirect +from decimal import Decimal + +import requests +from django.conf import settings from django.contrib import messages from django.contrib.auth.decorators import login_required from django.core.paginator import Paginator -from django.db.models import Q, Sum, Count, Avg, Value, IntegerField, DecimalField, F -from django.db.models.functions import Coalesce, Cast, Replace, NullIf -from decimal import Decimal -import time +from django.db.models import (Avg, Count, DecimalField, F, IntegerField, Q, + Sum, Value) +from django.db.models.functions import Cast, Coalesce, NullIf, Replace from django.http import JsonResponse -from django.views.decorators.csrf import csrf_exempt +from django.shortcuts import get_object_or_404, redirect, render from django.utils import timezone -from rest_framework.response import Response +from django.views.decorators.csrf import csrf_exempt from rest_framework.decorators import api_view -from django.conf import settings -from .models import Person, Paechter, Destinataer, DokumentLink, Foerderung, Land, CSVImport, LandAbrechnung, LandVerpachtung, AppConfiguration, DestinataerUnterstuetzung, UnterstuetzungWiederkehrend -import json +from rest_framework.response import Response + +from .models import (AppConfiguration, CSVImport, Destinataer, + DestinataerUnterstuetzung, DokumentLink, Foerderung, Land, + LandAbrechnung, LandVerpachtung, Paechter, Person, + UnterstuetzungWiederkehrend) + def get_pdf_generator(): """Lazy load PDF generator to handle missing dependencies gracefully""" try: from .utils.pdf_generator import pdf_generator + return pdf_generator except ImportError as e: # Store the error message for use in MockPDFGenerator error_message = str(e) - + # Return a mock generator if dependencies are missing class MockPDFGenerator: def is_available(self): return False + def export_data_list_pdf(self, *args, **kwargs): from django.http import HttpResponse + error_html = f""" @@ -47,15 +57,22 @@ def get_pdf_generator(): """ - response = HttpResponse(error_html, content_type='text/html') - response['Content-Disposition'] = 'inline; filename="pdf_not_available.html"' + response = HttpResponse(error_html, content_type="text/html") + response["Content-Disposition"] = ( + 'inline; filename="pdf_not_available.html"' + ) return response + return MockPDFGenerator() + class GrampsClient: """Lightweight client for Gramps Web API.""" - def __init__(self, base_url: str, token: str = '', username: str = '', password: str = ''): - self.base_url = base_url.rstrip('/') + + def __init__( + self, base_url: str, token: str = "", username: str = "", password: str = "" + ): + self.base_url = base_url.rstrip("/") self.session = requests.Session() if token: self.session.headers.update({"Authorization": f"Bearer {token}"}) @@ -65,23 +82,29 @@ class GrampsClient: def search_people(self, query: str, limit: int = 5): try: - r = self.session.get(f"{self.base_url}/api/people/", params={"q": query, "limit": limit}, timeout=10) + r = self.session.get( + f"{self.base_url}/api/people/", + params={"q": query, "limit": limit}, + timeout=10, + ) r.raise_for_status() return r.json() except Exception as e: # try login-once if unauthorized and we have credentials - if self.username and self.password and '401' in str(e): + if self.username and self.password and "401" in str(e): if self._login(): return self.search_people(query, limit) return {"error": str(e)} def get_person(self, handle_or_id: str): try: - r = self.session.get(f"{self.base_url}/api/people/{handle_or_id}", timeout=10) + r = self.session.get( + f"{self.base_url}/api/people/{handle_or_id}", timeout=10 + ) r.raise_for_status() return r.json() except Exception as e: - if self.username and self.password and '401' in str(e): + if self.username and self.password and "401" in str(e): if self._login(): return self.get_person(handle_or_id) return {"error": str(e)} @@ -90,29 +113,68 @@ class GrampsClient: try: # try common endpoints endpoints = [ - ("/api/auth/login", {"username": self.username, "password": self.password}, "json"), - ("/auth/login", {"username": self.username, "password": self.password}, "json"), - ("/api/token", {"username": self.username, "password": self.password}, "form"), - ("/login", {"username": self.username, "password": self.password}, "form"), - ("/token", {"username": self.username, "password": self.password}, "form"), - ("/api/login", {"username": self.username, "password": self.password}, "json"), + ( + "/api/auth/login", + {"username": self.username, "password": self.password}, + "json", + ), + ( + "/auth/login", + {"username": self.username, "password": self.password}, + "json", + ), + ( + "/api/token", + {"username": self.username, "password": self.password}, + "form", + ), + ( + "/login", + {"username": self.username, "password": self.password}, + "form", + ), + ( + "/token", + {"username": self.username, "password": self.password}, + "form", + ), + ( + "/api/login", + {"username": self.username, "password": self.password}, + "json", + ), ] for path, payload, mode in endpoints: url = f"{self.base_url}{path}" - if mode == 'json': - r = self.session.post(url, json=payload, timeout=10, allow_redirects=False) + if mode == "json": + r = self.session.post( + url, json=payload, timeout=10, allow_redirects=False + ) else: - r = self.session.post(url, data=payload, timeout=10, allow_redirects=False) + r = self.session.post( + url, data=payload, timeout=10, allow_redirects=False + ) # Success with token body - if r.status_code in (200, 201) and 'application/json' in r.headers.get('Content-Type',''): + if r.status_code in (200, 201) and "application/json" in r.headers.get( + "Content-Type", "" + ): data = r.json() - token = data.get('access_token') or data.get('token') or data.get('access') or data.get('jwt') + token = ( + data.get("access_token") + or data.get("token") + or data.get("access") + or data.get("jwt") + ) if token: self._cached_token = token - self.session.headers.update({"Authorization": f"Bearer {token}"}) + self.session.headers.update( + {"Authorization": f"Bearer {token}"} + ) return True # Success via session cookie and redirect - if r.status_code in (200, 302) and ('set-cookie' in {k.lower():v for k,v in r.headers.items()}): + if r.status_code in (200, 302) and ( + "set-cookie" in {k.lower(): v for k, v in r.headers.items()} + ): return True # Basic Auth fallback (some setups protect API with Basic) try: @@ -126,199 +188,241 @@ class GrampsClient: except Exception: return False + def get_gramps_client() -> GrampsClient: return GrampsClient( - getattr(settings, 'GRAMPS_URL', ''), - getattr(settings, 'GRAMPS_API_TOKEN', ''), - getattr(settings, 'GRAMPS_USERNAME', ''), - getattr(settings, 'GRAMPS_PASSWORD', ''), + getattr(settings, "GRAMPS_URL", ""), + getattr(settings, "GRAMPS_API_TOKEN", ""), + getattr(settings, "GRAMPS_USERNAME", ""), + getattr(settings, "GRAMPS_PASSWORD", ""), ) -@api_view(['GET']) -def gramps_debug_api(_request): - return Response({ - 'GRAMPS_URL': getattr(settings, 'GRAMPS_URL', ''), - 'has_username': bool(getattr(settings, 'GRAMPS_USERNAME', '')), - 'has_password': bool(getattr(settings, 'GRAMPS_PASSWORD', '')), - }) -from .forms import PersonForm, PaechterForm, DestinataerForm, DokumentLinkForm, FoerderungForm, UnterstuetzungForm, UnterstuetzungMarkAsPaidForm, LandForm, DestinataerUnterstuetzungForm, DestinataerNotizForm -from stiftung.models import DestinataerUnterstuetzung, DestinataerNotiz +@api_view(["GET"]) +def gramps_debug_api(_request): + return Response( + { + "GRAMPS_URL": getattr(settings, "GRAMPS_URL", ""), + "has_username": bool(getattr(settings, "GRAMPS_USERNAME", "")), + "has_password": bool(getattr(settings, "GRAMPS_PASSWORD", "")), + } + ) + + +from stiftung.models import DestinataerNotiz, DestinataerUnterstuetzung + +from .forms import (DestinataerForm, DestinataerNotizForm, + DestinataerUnterstuetzungForm, DokumentLinkForm, + FoerderungForm, LandForm, PaechterForm, PersonForm, + UnterstuetzungForm, UnterstuetzungMarkAsPaidForm) + def home(request): """Home page for the Stiftungsverwaltung application""" - return render(request, 'stiftung/home.html', { - 'title': 'Stiftungsverwaltung', - 'description': 'Foundation Management System' - }) + return render( + request, + "stiftung/home.html", + {"title": "Stiftungsverwaltung", "description": "Foundation Management System"}, + ) + @login_required def dokument_management(request): """Dokumentenverwaltung: zeigt Paperless-Dokumente (mit Stiftung-Tags) und bestehende Verknüpfungen. Bietet Filter und ermöglicht Re-Linking. """ - return render(request, 'stiftung/dokument_management.html') + return render(request, "stiftung/dokument_management.html") -@api_view(['GET']) + +@api_view(["GET"]) def paperless_document_redirect(_request, doc_id: int): """Redirects to the Paperless UI document URL and supports thumbnails if needed later.""" from stiftung.utils.config import get_paperless_config - + config = get_paperless_config() - url = config['api_url'] + url = config["api_url"] if not url: - return Response({'error': 'Paperless API not configured'}, status=400) - base_url = url.rstrip('/api') if url.endswith('/api') else url + return Response({"error": "Paperless API not configured"}, status=400) + base_url = url.rstrip("/api") if url.endswith("/api") else url return redirect(f"{base_url}/documents/{doc_id}/") -@api_view(['GET']) + +@api_view(["GET"]) def health_check(request): """Simple health check endpoint for deployment monitoring""" - return JsonResponse({ - 'status': 'healthy', - 'timestamp': timezone.now().isoformat(), - 'service': 'stiftung-web' - }) + return JsonResponse( + { + "status": "healthy", + "timestamp": timezone.now().isoformat(), + "service": "stiftung-web", + } + ) + ## Removed duplicate paperless_ping referencing non-existent PAPERLESS_URL + # CSV Import Views @login_required def csv_import_list(request): """List all CSV import operations""" - imports = CSVImport.objects.all().order_by('-started_at') - + imports = CSVImport.objects.all().order_by("-started_at") + paginator = Paginator(imports, 20) - page_number = request.GET.get('page') + page_number = request.GET.get("page") page_obj = paginator.get_page(page_number) - + context = { - 'page_obj': page_obj, - 'import_types': CSVImport.IMPORT_TYPE_CHOICES, - 'status_choices': CSVImport.STATUS_CHOICES, + "page_obj": page_obj, + "import_types": CSVImport.IMPORT_TYPE_CHOICES, + "status_choices": CSVImport.STATUS_CHOICES, } - return render(request, 'stiftung/csv_import_list.html', context) + return render(request, "stiftung/csv_import_list.html", context) + @login_required def csv_import_create(request): """Show CSV import form and handle file upload""" - if request.method == 'POST': - import_type = request.POST.get('import_type') - csv_file = request.FILES.get('csv_file') - + if request.method == "POST": + import_type = request.POST.get("import_type") + csv_file = request.FILES.get("csv_file") + if not csv_file or not import_type: - messages.error(request, 'Bitte wählen Sie einen Import-Typ und eine CSV-Datei aus.') - return redirect('stiftung:csv_import_create') - - if not csv_file.name.endswith('.csv'): - messages.error(request, 'Bitte wählen Sie eine gültige CSV-Datei aus.') - return redirect('stiftung:csv_import_create') - + messages.error( + request, "Bitte wählen Sie einen Import-Typ und eine CSV-Datei aus." + ) + return redirect("stiftung:csv_import_create") + + if not csv_file.name.endswith(".csv"): + messages.error(request, "Bitte wählen Sie eine gültige CSV-Datei aus.") + return redirect("stiftung:csv_import_create") + try: # Create import record csv_import = CSVImport.objects.create( import_type=import_type, filename=csv_file.name, file_size=csv_file.size, - created_by=request.user.username if request.user.is_authenticated else 'Unknown', - status='processing' + created_by=( + request.user.username + if request.user.is_authenticated + else "Unknown" + ), + status="processing", ) - + # Process the CSV file - if import_type == 'destinataere': + if import_type == "destinataere": result = process_destinataere_csv(csv_file, csv_import) - elif import_type == 'paechter': + elif import_type == "paechter": result = process_paechter_csv(csv_file, csv_import) - elif import_type == 'personen': + elif import_type == "personen": result = process_personen_csv(csv_file, csv_import) - elif import_type == 'laendereien': + elif import_type == "laendereien": result = process_laendereien_csv(csv_file, csv_import) else: - messages.error(request, 'Unbekannter Import-Typ.') - csv_import.status = 'failed' + messages.error(request, "Unbekannter Import-Typ.") + csv_import.status = "failed" csv_import.save() - return redirect('stiftung:csv_import_create') - + return redirect("stiftung:csv_import_create") + # Update import record - csv_import.total_rows = result['total_rows'] - csv_import.imported_rows = result['imported_rows'] - csv_import.failed_rows = result['failed_rows'] - csv_import.error_log = result['error_log'] - csv_import.status = result['status'] + csv_import.total_rows = result["total_rows"] + csv_import.imported_rows = result["imported_rows"] + csv_import.failed_rows = result["failed_rows"] + csv_import.error_log = result["error_log"] + csv_import.status = result["status"] csv_import.completed_at = timezone.now() csv_import.save() - - if result['status'] == 'completed': - messages.success(request, f'CSV-Import erfolgreich! {result["imported_rows"]} Datensätze importiert.') - elif result['status'] == 'partial': - messages.warning(request, f'CSV-Import teilweise erfolgreich. {result["imported_rows"]} importiert, {result["failed_rows"]} fehlgeschlagen.') + + if result["status"] == "completed": + messages.success( + request, + f'CSV-Import erfolgreich! {result["imported_rows"]} Datensätze importiert.', + ) + elif result["status"] == "partial": + messages.warning( + request, + f'CSV-Import teilweise erfolgreich. {result["imported_rows"]} importiert, {result["failed_rows"]} fehlgeschlagen.', + ) else: - messages.error(request, f'CSV-Import fehlgeschlagen. {result["error_log"]}') - - return redirect('stiftung:csv_import_list') - + messages.error( + request, f'CSV-Import fehlgeschlagen. {result["error_log"]}' + ) + + return redirect("stiftung:csv_import_list") + except Exception as e: - messages.error(request, f'Fehler beim CSV-Import: {str(e)}') - return redirect('stiftung:csv_import_create') - + messages.error(request, f"Fehler beim CSV-Import: {str(e)}") + return redirect("stiftung:csv_import_create") + context = { - 'import_types': CSVImport.IMPORT_TYPE_CHOICES, + "import_types": CSVImport.IMPORT_TYPE_CHOICES, } - return render(request, 'stiftung/csv_import_form.html', context) + return render(request, "stiftung/csv_import_form.html", context) + def process_personen_csv(csv_file, csv_import): """Process CSV file for Personen import""" - decoded_file = csv_file.read().decode('utf-8') + decoded_file = csv_file.read().decode("utf-8") # Handle both comma and semicolon separated files - if ';' in decoded_file.split('\n')[0]: - csv_data = csv.DictReader(io.StringIO(decoded_file), delimiter=';') + if ";" in decoded_file.split("\n")[0]: + csv_data = csv.DictReader(io.StringIO(decoded_file), delimiter=";") else: csv_data = csv.DictReader(io.StringIO(decoded_file)) - + total_rows = 0 imported_rows = 0 failed_rows = 0 error_log = [] - - for row_num, row in enumerate(csv_data, start=2): # Start at 2 because row 1 is header + + for row_num, row in enumerate( + csv_data, start=2 + ): # Start at 2 because row 1 is header total_rows += 1 - + try: # Map CSV columns to model fields person_data = { - 'vorname': row.get('Vorname', '').strip(), - 'nachname': row.get('Nachname', '').strip(), - 'familienzweig': row.get('Familienzweig', 'hauptzweig').strip(), - 'email': row.get('E-Mail', '').strip() or None, - 'telefon': row.get('Telefon', '').strip() or None, - 'iban': row.get('IBAN', '').strip() or None, - 'adresse': row.get('Adresse', '').strip() or None, - 'notizen': row.get('Notizen', '').strip() or None, - 'aktiv': row.get('Aktiv', 'true').lower() == 'true', + "vorname": row.get("Vorname", "").strip(), + "nachname": row.get("Nachname", "").strip(), + "familienzweig": row.get("Familienzweig", "hauptzweig").strip(), + "email": row.get("E-Mail", "").strip() or None, + "telefon": row.get("Telefon", "").strip() or None, + "iban": row.get("IBAN", "").strip() or None, + "adresse": row.get("Adresse", "").strip() or None, + "notizen": row.get("Notizen", "").strip() or None, + "aktiv": row.get("Aktiv", "true").lower() == "true", } - + # Handle date fields - if row.get('Geburtsdatum'): + if row.get("Geburtsdatum"): try: - person_data['geburtsdatum'] = datetime.strptime(row['Geburtsdatum'], '%d.%m.%Y').date() + person_data["geburtsdatum"] = datetime.strptime( + row["Geburtsdatum"], "%d.%m.%Y" + ).date() except ValueError: try: - person_data['geburtsdatum'] = datetime.strptime(row['Geburtsdatum'], '%Y-%m-%d').date() + person_data["geburtsdatum"] = datetime.strptime( + row["Geburtsdatum"], "%Y-%m-%d" + ).date() except ValueError: - person_data['geburtsdatum'] = None - + person_data["geburtsdatum"] = None + # Validate required fields - if not person_data['vorname'] or not person_data['nachname']: - error_log.append(f"Zeile {row_num}: Vorname und Nachname sind erforderlich") + if not person_data["vorname"] or not person_data["nachname"]: + error_log.append( + f"Zeile {row_num}: Vorname und Nachname sind erforderlich" + ) failed_rows += 1 continue - + # Check if person already exists existing_person = Person.objects.filter( - vorname__iexact=person_data['vorname'], - nachname__iexact=person_data['nachname'] + vorname__iexact=person_data["vorname"], + nachname__iexact=person_data["nachname"], ).first() - + if existing_person: # Update existing person for field, value in person_data.items(): @@ -328,90 +432,105 @@ def process_personen_csv(csv_file, csv_import): else: # Create new person Person.objects.create(**person_data) - + imported_rows += 1 - + except Exception as e: error_log.append(f"Zeile {row_num}: {str(e)}") failed_rows += 1 - + # Determine status if failed_rows == 0: - status = 'completed' + status = "completed" elif imported_rows > 0: - status = 'partial' + status = "partial" else: - status = 'failed' - + status = "failed" + return { - 'total_rows': total_rows, - 'imported_rows': imported_rows, - 'failed_rows': failed_rows, - 'error_log': '\n'.join(error_log) if error_log else None, - 'status': status + "total_rows": total_rows, + "imported_rows": imported_rows, + "failed_rows": failed_rows, + "error_log": "\n".join(error_log) if error_log else None, + "status": status, } + def process_destinataere_csv(csv_file, csv_import): """Process CSV file for Destinatäre import""" - decoded_file = csv_file.read().decode('utf-8') + decoded_file = csv_file.read().decode("utf-8") # Handle both comma and semicolon separated files - if ';' in decoded_file.split('\n')[0]: - csv_data = csv.DictReader(io.StringIO(decoded_file), delimiter=';') + if ";" in decoded_file.split("\n")[0]: + csv_data = csv.DictReader(io.StringIO(decoded_file), delimiter=";") else: csv_data = csv.DictReader(io.StringIO(decoded_file)) - + total_rows = 0 imported_rows = 0 failed_rows = 0 error_log = [] - - for row_num, row in enumerate(csv_data, start=2): # Start at 2 because row 1 is header + + for row_num, row in enumerate( + csv_data, start=2 + ): # Start at 2 because row 1 is header total_rows += 1 - + try: # Map CSV columns to model fields destinataer_data = { - 'vorname': row.get('Vorname', '').strip(), - 'nachname': row.get('Nachname', '').strip(), - 'familienzweig': row.get('Familienzweig', 'hauptzweig').strip(), - 'email': row.get('E-Mail', '').strip() or None, - 'telefon': row.get('Telefon', '').strip() or None, - 'iban': row.get('IBAN', '').strip() or None, - 'strasse': row.get('Straße', '').strip() or None, - 'plz': row.get('PLZ', '').strip() or None, - 'ort': row.get('Ort', '').strip() or None, - 'berufsgruppe': row.get('Berufsgruppe', 'andere').strip(), - 'ausbildungsstand': row.get('Ausbildungsstand', '').strip() or None, - 'institution': row.get('Institution', '').strip() or None, - 'projekt_beschreibung': row.get('Projektbeschreibung', '').strip() or None, - 'jaehrliches_einkommen': float(row.get('Jährliches_Einkommen', 0)) if row.get('Jährliches_Einkommen') else None, - 'finanzielle_notlage': row.get('Finanzielle_Notlage', 'false').lower() == 'true', - 'notizen': row.get('Notizen', '').strip() or None, - 'aktiv': row.get('Aktiv', 'true').lower() == 'true', + "vorname": row.get("Vorname", "").strip(), + "nachname": row.get("Nachname", "").strip(), + "familienzweig": row.get("Familienzweig", "hauptzweig").strip(), + "email": row.get("E-Mail", "").strip() or None, + "telefon": row.get("Telefon", "").strip() or None, + "iban": row.get("IBAN", "").strip() or None, + "strasse": row.get("Straße", "").strip() or None, + "plz": row.get("PLZ", "").strip() or None, + "ort": row.get("Ort", "").strip() or None, + "berufsgruppe": row.get("Berufsgruppe", "andere").strip(), + "ausbildungsstand": row.get("Ausbildungsstand", "").strip() or None, + "institution": row.get("Institution", "").strip() or None, + "projekt_beschreibung": row.get("Projektbeschreibung", "").strip() + or None, + "jaehrliches_einkommen": ( + float(row.get("Jährliches_Einkommen", 0)) + if row.get("Jährliches_Einkommen") + else None + ), + "finanzielle_notlage": row.get("Finanzielle_Notlage", "false").lower() + == "true", + "notizen": row.get("Notizen", "").strip() or None, + "aktiv": row.get("Aktiv", "true").lower() == "true", } - + # Handle date fields - if row.get('Geburtsdatum'): + if row.get("Geburtsdatum"): try: - destinataer_data['geburtsdatum'] = datetime.strptime(row['Geburtsdatum'], '%d.%m.%Y').date() + destinataer_data["geburtsdatum"] = datetime.strptime( + row["Geburtsdatum"], "%d.%m.%Y" + ).date() except ValueError: try: - destinataer_data['geburtsdatum'] = datetime.strptime(row['Geburtsdatum'], '%Y-%m-%d').date() + destinataer_data["geburtsdatum"] = datetime.strptime( + row["Geburtsdatum"], "%Y-%m-%d" + ).date() except ValueError: - destinataer_data['geburtsdatum'] = None - + destinataer_data["geburtsdatum"] = None + # Validate required fields - if not destinataer_data['vorname'] or not destinataer_data['nachname']: - error_log.append(f"Zeile {row_num}: Vorname und Nachname sind erforderlich") + if not destinataer_data["vorname"] or not destinataer_data["nachname"]: + error_log.append( + f"Zeile {row_num}: Vorname und Nachname sind erforderlich" + ) failed_rows += 1 continue - + # Check if destinataer already exists existing_destinataer = Destinataer.objects.filter( - vorname__iexact=destinataer_data['vorname'], - nachname__iexact=destinataer_data['nachname'] + vorname__iexact=destinataer_data["vorname"], + nachname__iexact=destinataer_data["nachname"], ).first() - + if existing_destinataer: # Update existing destinataer for field, value in destinataer_data.items(): @@ -421,163 +540,205 @@ def process_destinataere_csv(csv_file, csv_import): else: # Create new destinataer Destinataer.objects.create(**destinataer_data) - + imported_rows += 1 - + except Exception as e: error_log.append(f"Zeile {row_num}: {str(e)}") failed_rows += 1 - + # Determine status if failed_rows == 0: - status = 'completed' + status = "completed" elif imported_rows > 0: - status = 'partial' + status = "partial" else: - status = 'failed' - + status = "failed" + return { - 'total_rows': total_rows, - 'imported_rows': imported_rows, - 'failed_rows': failed_rows, - 'error_log': '\n'.join(error_log) if error_log else None, - 'status': status + "total_rows": total_rows, + "imported_rows": imported_rows, + "failed_rows": failed_rows, + "error_log": "\n".join(error_log) if error_log else None, + "status": status, } + def process_paechter_csv(csv_file, csv_import): """Process CSV file for Paechter import""" - decoded_file = csv_file.read().decode('utf-8') - + decoded_file = csv_file.read().decode("utf-8") + # Handle both comma and semicolon separated files - if ';' in decoded_file.split('\n')[0]: - csv_data = csv.DictReader(io.StringIO(decoded_file), delimiter=';') + if ";" in decoded_file.split("\n")[0]: + csv_data = csv.DictReader(io.StringIO(decoded_file), delimiter=";") else: csv_data = csv.DictReader(io.StringIO(decoded_file)) - + total_rows = 0 imported_rows = 0 failed_rows = 0 error_log = [] - - for row_num, row in enumerate(csv_data, start=2): # Start at 2 because row 1 is header + + for row_num, row in enumerate( + csv_data, start=2 + ): # Start at 2 because row 1 is header total_rows += 1 - + try: # Get raw values from CSV - handle both semicolon and comma separated # Handle BOM in column names - vorname_raw = row.get('Vorname', '') or row.get('\ufeffVorname', '') - nachname_raw = row.get('Nachname', '') - personentyp_raw = row.get('Personentyp', '') - + vorname_raw = row.get("Vorname", "") or row.get("\ufeffVorname", "") + nachname_raw = row.get("Nachname", "") + personentyp_raw = row.get("Personentyp", "") + # Clean up the values (remove extra whitespace but keep empty strings) - vorname_raw = vorname_raw.strip() if vorname_raw else '' - nachname_raw = nachname_raw.strip() if nachname_raw else '' - personentyp_raw = personentyp_raw.strip() if personentyp_raw else '' - + vorname_raw = vorname_raw.strip() if vorname_raw else "" + nachname_raw = nachname_raw.strip() if nachname_raw else "" + personentyp_raw = personentyp_raw.strip() if personentyp_raw else "" + # Debug: Log raw values and available columns error_log.append(f"Zeile {row_num}: Available columns: {list(row.keys())}") - error_log.append(f"Zeile {row_num}: RAW Vorname='{vorname_raw}', Nachname='{nachname_raw}', Personentyp='{personentyp_raw}'") - + error_log.append( + f"Zeile {row_num}: RAW Vorname='{vorname_raw}', Nachname='{nachname_raw}', Personentyp='{personentyp_raw}'" + ) + # Determine personentyp based on the data - if personentyp_raw in ['Gesellschaft', 'KG', 'GbR', 'GmbH']: - personentyp = 'gesellschaft' - elif personentyp_raw in ['Herrn', 'Frau']: - personentyp = 'natuerlich' + if personentyp_raw in ["Gesellschaft", "KG", "GbR", "GmbH"]: + personentyp = "gesellschaft" + elif personentyp_raw in ["Herrn", "Frau"]: + personentyp = "natuerlich" else: # Fallback: analyze the Nachname to detect companies nachname_lower = nachname_raw.lower() - if any(keyword in nachname_lower for keyword in ['kg', 'gbr', 'gmbh', 'ag', 'ohg', 'e.v.', 'stiftung', 'genossenschaft']): - personentyp = 'gesellschaft' + if any( + keyword in nachname_lower + for keyword in [ + "kg", + "gbr", + "gmbh", + "ag", + "ohg", + "e.v.", + "stiftung", + "genossenschaft", + ] + ): + personentyp = "gesellschaft" else: - personentyp = 'natuerlich' - + personentyp = "natuerlich" + # Handle Vorname - keep original value unless it's 'N/A' - vorname = vorname_raw if vorname_raw and vorname_raw != 'N/A' else '' - + vorname = vorname_raw if vorname_raw and vorname_raw != "N/A" else "" + # Debug: Log processed values - error_log.append(f"Zeile {row_num}: PROCESSED Vorname='{vorname}', Nachname='{nachname_raw}', Personentyp='{personentyp}'") - + error_log.append( + f"Zeile {row_num}: PROCESSED Vorname='{vorname}', Nachname='{nachname_raw}', Personentyp='{personentyp}'" + ) + paechter_data = { - 'vorname': vorname, - 'nachname': nachname_raw, - 'email': row.get('E-Mail', '').strip() or None, - 'telefon': row.get('Telefon', '').strip() or None, - 'iban': row.get('IBAN', '').strip() or None, - 'strasse': row.get('Straße', '').strip() or None, - 'plz': row.get('PLZ', '').strip() or None, - 'ort': row.get('Ort', '').strip() or None, - 'personentyp': personentyp, - 'pachtnummer': row.get('Pachtnummer', '').strip() or None, - 'landwirtschaftliche_ausbildung': row.get('Landwirtschaftliche_Ausbildung', 'false').lower() == 'true', - 'berufserfahrung_jahre': int(row.get('Berufserfahrung_Jahre', 0)) if row.get('Berufserfahrung_Jahre') else None, - 'spezialisierung': row.get('Spezialisierung', '').strip() or None, - 'notizen': row.get('Notizen', '').strip() or None, - 'aktiv': row.get('Aktiv', 'true').lower() in ['true', 'wahr', 'ja', '1'], + "vorname": vorname, + "nachname": nachname_raw, + "email": row.get("E-Mail", "").strip() or None, + "telefon": row.get("Telefon", "").strip() or None, + "iban": row.get("IBAN", "").strip() or None, + "strasse": row.get("Straße", "").strip() or None, + "plz": row.get("PLZ", "").strip() or None, + "ort": row.get("Ort", "").strip() or None, + "personentyp": personentyp, + "pachtnummer": row.get("Pachtnummer", "").strip() or None, + "landwirtschaftliche_ausbildung": row.get( + "Landwirtschaftliche_Ausbildung", "false" + ).lower() + == "true", + "berufserfahrung_jahre": ( + int(row.get("Berufserfahrung_Jahre", 0)) + if row.get("Berufserfahrung_Jahre") + else None + ), + "spezialisierung": row.get("Spezialisierung", "").strip() or None, + "notizen": row.get("Notizen", "").strip() or None, + "aktiv": row.get("Aktiv", "true").lower() + in ["true", "wahr", "ja", "1"], } - + # Handle date fields - if row.get('Geburtsdatum'): + if row.get("Geburtsdatum"): try: - paechter_data['geburtsdatum'] = datetime.strptime(row['Geburtsdatum'], '%d.%m.%Y').date() + paechter_data["geburtsdatum"] = datetime.strptime( + row["Geburtsdatum"], "%d.%m.%Y" + ).date() except ValueError: try: - paechter_data['geburtsdatum'] = datetime.strptime(row['Geburtsdatum'], '%Y-%m-%d').date() + paechter_data["geburtsdatum"] = datetime.strptime( + row["Geburtsdatum"], "%Y-%m-%d" + ).date() except ValueError: - paechter_data['geburtsdatum'] = None - - if row.get('Pachtbeginn_Erste'): + paechter_data["geburtsdatum"] = None + + if row.get("Pachtbeginn_Erste"): try: - paechter_data['pachtbeginn_erste'] = datetime.strptime(row['Pachtbeginn_Erste'], '%d.%m.%Y').date() + paechter_data["pachtbeginn_erste"] = datetime.strptime( + row["Pachtbeginn_Erste"], "%d.%m.%Y" + ).date() except ValueError: try: - paechter_data['pachtbeginn_erste'] = datetime.strptime(row['Pachtbeginn_Erste'], '%Y-%m-%d').date() + paechter_data["pachtbeginn_erste"] = datetime.strptime( + row["Pachtbeginn_Erste"], "%Y-%m-%d" + ).date() except ValueError: - paechter_data['pachtbeginn_erste'] = None - - if row.get('Pachtende_Letzte'): + paechter_data["pachtbeginn_erste"] = None + + if row.get("Pachtende_Letzte"): try: - paechter_data['pachtende_letzte'] = datetime.strptime(row['Pachtende_Letzte'], '%d.%m.%Y').date() + paechter_data["pachtende_letzte"] = datetime.strptime( + row["Pachtende_Letzte"], "%d.%m.%Y" + ).date() except ValueError: try: - paechter_data['pachtende_letzte'] = datetime.strptime(row['Pachtende_Letzte'], '%Y-%m-%d').date() + paechter_data["pachtende_letzte"] = datetime.strptime( + row["Pachtende_Letzte"], "%Y-%m-%d" + ).date() except ValueError: - paechter_data['pachtende_letzte'] = None - + paechter_data["pachtende_letzte"] = None + # Handle decimal fields - if row.get('Pachtzins_Aktuell'): + if row.get("Pachtzins_Aktuell"): try: - paechter_data['pachtzins_aktuell'] = float(row['Pachtzins_Aktuell']) + paechter_data["pachtzins_aktuell"] = float(row["Pachtzins_Aktuell"]) except ValueError: - paechter_data['pachtzins_aktuell'] = None - + paechter_data["pachtzins_aktuell"] = None + # Validate required fields - if personentyp == 'gesellschaft': + if personentyp == "gesellschaft": # For companies, only Nachname is required - if not paechter_data['nachname']: - error_log.append(f"Zeile {row_num}: Nachname ist für Gesellschaften erforderlich") + if not paechter_data["nachname"]: + error_log.append( + f"Zeile {row_num}: Nachname ist für Gesellschaften erforderlich" + ) failed_rows += 1 continue else: # For natural persons, only Nachname is required - if not paechter_data['nachname']: - error_log.append(f"Zeile {row_num}: Nachname ist für natürliche Personen erforderlich") + if not paechter_data["nachname"]: + error_log.append( + f"Zeile {row_num}: Nachname ist für natürliche Personen erforderlich" + ) failed_rows += 1 continue - + # Check if paechter already exists - if personentyp == 'gesellschaft': + if personentyp == "gesellschaft": # For companies, search by Nachname only existing_paechter = Paechter.objects.filter( - nachname__iexact=paechter_data['nachname'], - personentyp='gesellschaft' + nachname__iexact=paechter_data["nachname"], + personentyp="gesellschaft", ).first() else: # For natural persons, search by Nachname only (since Vorname can be empty) existing_paechter = Paechter.objects.filter( - nachname__iexact=paechter_data['nachname'], - personentyp='natuerlich' + nachname__iexact=paechter_data["nachname"], personentyp="natuerlich" ).first() - + if existing_paechter: # Update existing paechter for field, value in paechter_data.items(): @@ -587,55 +748,63 @@ def process_paechter_csv(csv_file, csv_import): else: # Create new paechter Paechter.objects.create(**paechter_data) - + imported_rows += 1 - + except Exception as e: error_log.append(f"Zeile {row_num}: {str(e)}") failed_rows += 1 - + # Determine status if failed_rows == 0: - status = 'completed' + status = "completed" elif imported_rows > 0: - status = 'partial' + status = "partial" else: - status = 'failed' - + status = "failed" + return { - 'total_rows': total_rows, - 'imported_rows': imported_rows, - 'failed_rows': failed_rows, - 'error_log': '\n'.join(error_log) if error_log else None, - 'status': status + "total_rows": total_rows, + "imported_rows": imported_rows, + "failed_rows": failed_rows, + "error_log": "\n".join(error_log) if error_log else None, + "status": status, } + def process_laendereien_csv(csv_file, csv_import): """Process CSV file for Ländereien import""" - decoded_file = csv_file.read().decode('utf-8') + decoded_file = csv_file.read().decode("utf-8") # Handle both comma and semicolon separated files - if ';' in decoded_file.split('\n')[0]: - csv_data = csv.DictReader(io.StringIO(decoded_file), delimiter=';') + if ";" in decoded_file.split("\n")[0]: + csv_data = csv.DictReader(io.StringIO(decoded_file), delimiter=";") else: csv_data = csv.DictReader(io.StringIO(decoded_file)) - + total_rows = 0 imported_rows = 0 failed_rows = 0 error_log = [] - + last_gemeinde = None for row_num, row in enumerate(csv_data, start=2): total_rows += 1 - + try: # Build case-insensitive access helpers (strip BOM, normalize separators) def clean_key(key: str) -> str: - return (key or '').replace('\ufeff', '').replace('\uFEFF', '').strip() - normalized_row = { clean_key(k): (v or '').strip() for k, v in row.items() } - lower_row = { clean_key(k).lower(): (v or '').strip() for k, v in row.items() } + return (key or "").replace("\ufeff", "").replace("\ufeff", "").strip() + + normalized_row = {clean_key(k): (v or "").strip() for k, v in row.items()} + lower_row = { + clean_key(k).lower(): (v or "").strip() for k, v in row.items() + } sanitized_row = { - clean_key(k).lower().replace('-', '_').replace(' ', '_'): (v or '').strip() + clean_key(k) + .lower() + .replace("-", "_") + .replace(" ", "_"): (v or "") + .strip() for k, v in row.items() } @@ -648,61 +817,99 @@ def process_laendereien_csv(csv_file, csv_import): lk = key.lower() if lk in lower_row: return lower_row[lk] - sk = lk.replace('-', '_').replace(' ', '_') + sk = lk.replace("-", "_").replace(" ", "_") if sk in sanitized_row: return sanitized_row[sk] - return '' + return "" def parse_float(value): if not value: return 0 # replace comma decimal if present - v = value.replace('.', '').replace(',', '.') if value.count(',') == 1 and value.count('.') > 1 else value.replace(',', '.') + v = ( + value.replace(".", "").replace(",", ".") + if value.count(",") == 1 and value.count(".") > 1 + else value.replace(",", ".") + ) try: return float(v) except ValueError: return 0 # Map CSV columns to model fields (robust to header variants) - lfd_nr_val = get_val('Lfd_Nr', 'lfd_nr', 'LfdNr', 'lfdnr', 'laufende_nummer', 'laufende-nummer') + lfd_nr_val = get_val( + "Lfd_Nr", + "lfd_nr", + "LfdNr", + "lfdnr", + "laufende_nummer", + "laufende-nummer", + ) land_data = { - 'lfd_nr': lfd_nr_val, - 'ew_nummer': get_val('EW_Nummer', 'ew_nummer') or None, - 'amtsgericht': get_val('Amtsgericht', 'amtsgericht'), - 'gemeinde': get_val('Gemeinde', 'gemeinde'), - 'gemarkung': get_val('Gemarkung', 'gemarkung'), - 'flur': get_val('Flur', 'flur'), - 'flurstueck': get_val('Flurstück', 'Flurstueck', 'flurstück', 'flurstueck'), - 'groesse_qm': parse_float(get_val('Größe_qm', 'Groesse_qm', 'groesse_qm', 'größe_qm')), - 'gruenland_qm': parse_float(get_val('Grünland_qm', 'Gruenland_qm', 'gruenland_qm', 'grünland_qm')), - 'acker_qm': parse_float(get_val('Acker_qm', 'acker_qm')), - 'wald_qm': parse_float(get_val('Wald_qm', 'wald_qm')), - 'sonstiges_qm': parse_float(get_val('Sonstiges_qm', 'sonstiges_qm')), - 'verpachtete_gesamtflaeche': parse_float(get_val('Verpachtete_Gesamtfläche_qm', 'Verpachtete_Gesamtflaeche_qm', 'verpachtete_gesamtfläche_qm', 'verpachtete_gesamtflaeche_qm')), - 'verp_flaeche_aktuell': parse_float(get_val('Verp_Fläche_aktuell_qm', 'Verp_Flaeche_aktuell_qm', 'verp_flaeche_aktuell_qm', 'verp_fläche_aktuell_qm')), - 'aktiv': get_val('Aktiv', 'aktiv').lower() in ['true', 'wahr', 'ja', '1'], - 'notizen': get_val('Notizen', 'notizen') or None, + "lfd_nr": lfd_nr_val, + "ew_nummer": get_val("EW_Nummer", "ew_nummer") or None, + "amtsgericht": get_val("Amtsgericht", "amtsgericht"), + "gemeinde": get_val("Gemeinde", "gemeinde"), + "gemarkung": get_val("Gemarkung", "gemarkung"), + "flur": get_val("Flur", "flur"), + "flurstueck": get_val( + "Flurstück", "Flurstueck", "flurstück", "flurstueck" + ), + "groesse_qm": parse_float( + get_val("Größe_qm", "Groesse_qm", "groesse_qm", "größe_qm") + ), + "gruenland_qm": parse_float( + get_val( + "Grünland_qm", "Gruenland_qm", "gruenland_qm", "grünland_qm" + ) + ), + "acker_qm": parse_float(get_val("Acker_qm", "acker_qm")), + "wald_qm": parse_float(get_val("Wald_qm", "wald_qm")), + "sonstiges_qm": parse_float(get_val("Sonstiges_qm", "sonstiges_qm")), + "verpachtete_gesamtflaeche": parse_float( + get_val( + "Verpachtete_Gesamtfläche_qm", + "Verpachtete_Gesamtflaeche_qm", + "verpachtete_gesamtfläche_qm", + "verpachtete_gesamtflaeche_qm", + ) + ), + "verp_flaeche_aktuell": parse_float( + get_val( + "Verp_Fläche_aktuell_qm", + "Verp_Flaeche_aktuell_qm", + "verp_flaeche_aktuell_qm", + "verp_fläche_aktuell_qm", + ) + ), + "aktiv": get_val("Aktiv", "aktiv").lower() + in ["true", "wahr", "ja", "1"], + "notizen": get_val("Notizen", "notizen") or None, } - + # Fallback for missing 'Gemeinde' -> set explicit placeholder - if not land_data['gemeinde']: - land_data['gemeinde'] = 'FEHLT' + if not land_data["gemeinde"]: + land_data["gemeinde"] = "FEHLT" # Validate required fields - required_fields = ['lfd_nr', 'gemeinde', 'gemarkung', 'flur', 'flurstueck'] - missing_fields = [field for field in required_fields if not land_data[field]] - + required_fields = ["lfd_nr", "gemeinde", "gemarkung", "flur", "flurstueck"] + missing_fields = [ + field for field in required_fields if not land_data[field] + ] + if missing_fields: # Log header diagnostics on first failure only to help debugging if row_num == 2: error_log.append(f"Erkannte Spalten: {list(normalized_row.keys())}") - error_log.append(f"Zeile {row_num}: Fehlende Pflichtfelder: {', '.join(missing_fields)}") + error_log.append( + f"Zeile {row_num}: Fehlende Pflichtfelder: {', '.join(missing_fields)}" + ) failed_rows += 1 continue - + # Check if land already exists - existing_land = Land.objects.filter(lfd_nr=land_data['lfd_nr']).first() - + existing_land = Land.objects.filter(lfd_nr=land_data["lfd_nr"]).first() + if existing_land: # Update existing land for field, value in land_data.items(): @@ -712,247 +919,282 @@ def process_laendereien_csv(csv_file, csv_import): else: # Create new land Land.objects.create(**land_data) - + imported_rows += 1 - if land_data['gemeinde']: - last_gemeinde = land_data['gemeinde'] - + if land_data["gemeinde"]: + last_gemeinde = land_data["gemeinde"] + except Exception as e: error_log.append(f"Zeile {row_num}: {str(e)}") failed_rows += 1 - + # Determine status if failed_rows == 0: - status = 'completed' + status = "completed" elif imported_rows > 0: - status = 'partial' + status = "partial" else: - status = 'failed' - + status = "failed" + return { - 'total_rows': total_rows, - 'imported_rows': imported_rows, - 'failed_rows': failed_rows, - 'error_log': '\n'.join(error_log) if error_log else None, - 'status': status + "total_rows": total_rows, + "imported_rows": imported_rows, + "failed_rows": failed_rows, + "error_log": "\n".join(error_log) if error_log else None, + "status": status, } + # Person Views @login_required def person_list(request): - search_query = request.GET.get('search', '') - familienzweig_filter = request.GET.get('familienzweig', '') - aktiv_filter = request.GET.get('aktiv', '') - + search_query = request.GET.get("search", "") + familienzweig_filter = request.GET.get("familienzweig", "") + aktiv_filter = request.GET.get("aktiv", "") + persons = Person.objects.all() - + if search_query: persons = persons.filter( - Q(nachname__icontains=search_query) | - Q(vorname__icontains=search_query) | - Q(email__icontains=search_query) | - Q(familienzweig__icontains=search_query) + Q(nachname__icontains=search_query) + | Q(vorname__icontains=search_query) + | Q(email__icontains=search_query) + | Q(familienzweig__icontains=search_query) ) - + if familienzweig_filter: persons = persons.filter(familienzweig=familienzweig_filter) - - if aktiv_filter == 'true': + + if aktiv_filter == "true": persons = persons.filter(aktiv=True) - elif aktiv_filter == 'false': + elif aktiv_filter == "false": persons = persons.filter(aktiv=False) - + # Annotate with total funding - persons = persons.annotate(total_foerderungen=Sum('foerderung__betrag')) - + persons = persons.annotate(total_foerderungen=Sum("foerderung__betrag")) + paginator = Paginator(persons, 20) - page_number = request.GET.get('page') + page_number = request.GET.get("page") page_obj = paginator.get_page(page_number) - + context = { - 'page_obj': page_obj, - 'search_query': search_query, - 'familienzweig_filter': familienzweig_filter, - 'aktiv_filter': aktiv_filter, - 'familienzweig_choices': Person.FAMILIENZWIG_CHOICES, + "page_obj": page_obj, + "search_query": search_query, + "familienzweig_filter": familienzweig_filter, + "aktiv_filter": aktiv_filter, + "familienzweig_choices": Person.FAMILIENZWIG_CHOICES, } - return render(request, 'stiftung/person_list.html', context) + return render(request, "stiftung/person_list.html", context) + @login_required def person_detail(request, pk): person = get_object_or_404(Person, pk=pk) - foerderungen = person.foerderung_set.all().order_by('-jahr', '-betrag') + foerderungen = person.foerderung_set.all().order_by("-jahr", "-betrag") # Get new LandVerpachtungen for this person's Paechter instances - verpachtungen = LandVerpachtung.objects.filter(paechter__person=person).order_by('-pachtbeginn') - + verpachtungen = LandVerpachtung.objects.filter(paechter__person=person).order_by( + "-pachtbeginn" + ) + context = { - 'person': person, - 'foerderungen': foerderungen, - 'verpachtungen': verpachtungen, + "person": person, + "foerderungen": foerderungen, + "verpachtungen": verpachtungen, } - return render(request, 'stiftung/person_detail.html', context) + return render(request, "stiftung/person_detail.html", context) + @login_required def person_create(request): - if request.method == 'POST': + if request.method == "POST": form = PersonForm(request.POST) if form.is_valid(): person = form.save() - messages.success(request, f'Person "{person.get_full_name()}" wurde erfolgreich erstellt.') - return redirect('stiftung:person_detail', pk=person.pk) + messages.success( + request, + f'Person "{person.get_full_name()}" wurde erfolgreich erstellt.', + ) + return redirect("stiftung:person_detail", pk=person.pk) else: form = PersonForm() - - context = {'form': form, 'title': 'Neue Person erstellen'} - return render(request, 'stiftung/person_form.html', context) + + context = {"form": form, "title": "Neue Person erstellen"} + return render(request, "stiftung/person_form.html", context) + @login_required def person_update(request, pk): person = get_object_or_404(Person, pk=pk) - if request.method == 'POST': + if request.method == "POST": form = PersonForm(request.POST, instance=person) if form.is_valid(): person = form.save() - messages.success(request, f'Person "{person.get_full_name()}" wurde erfolgreich aktualisiert.') - return redirect('stiftung:person_detail', pk=person.pk) + messages.success( + request, + f'Person "{person.get_full_name()}" wurde erfolgreich aktualisiert.', + ) + return redirect("stiftung:person_detail", pk=person.pk) else: form = PersonForm(instance=person) - - context = {'form': form, 'person': person, 'title': f'Person bearbeiten: {person.get_full_name()}'} - return render(request, 'stiftung/person_form.html', context) + + context = { + "form": form, + "person": person, + "title": f"Person bearbeiten: {person.get_full_name()}", + } + return render(request, "stiftung/person_form.html", context) + @login_required def person_delete(request, pk): person = get_object_or_404(Person, pk=pk) - if request.method == 'POST': + if request.method == "POST": person.delete() - messages.success(request, f'Person "{person.get_full_name()}" wurde erfolgreich gelöscht.') - return redirect('stiftung:person_list') - - context = {'person': person} - return render(request, 'stiftung/person_confirm_delete.html', context) + messages.success( + request, f'Person "{person.get_full_name()}" wurde erfolgreich gelöscht.' + ) + return redirect("stiftung:person_list") + + context = {"person": person} + return render(request, "stiftung/person_confirm_delete.html", context) + # Destinatär Views (Förderungsempfänger) @login_required def destinataer_list(request): - search_query = request.GET.get('search', '') - familienzweig_filter = request.GET.get('familienzweig', '') - berufsgruppe_filter = request.GET.get('berufsgruppe', '') - aktiv_filter = request.GET.get('aktiv', '') - sort = request.GET.get('sort', '') - direction = request.GET.get('dir', 'asc') - + search_query = request.GET.get("search", "") + familienzweig_filter = request.GET.get("familienzweig", "") + berufsgruppe_filter = request.GET.get("berufsgruppe", "") + aktiv_filter = request.GET.get("aktiv", "") + sort = request.GET.get("sort", "") + direction = request.GET.get("dir", "asc") + destinataere = Destinataer.objects.all() - + if search_query: destinataere = destinataere.filter( - Q(nachname__icontains=search_query) | - Q(vorname__icontains=search_query) | - Q(email__icontains=search_query) | - Q(institution__icontains=search_query) | - Q(familienzweig__icontains=search_query) + Q(nachname__icontains=search_query) + | Q(vorname__icontains=search_query) + | Q(email__icontains=search_query) + | Q(institution__icontains=search_query) + | Q(familienzweig__icontains=search_query) ) - + if familienzweig_filter: destinataere = destinataere.filter(familienzweig=familienzweig_filter) - + if berufsgruppe_filter: destinataere = destinataere.filter(berufsgruppe=berufsgruppe_filter) - - if aktiv_filter == 'true': + + if aktiv_filter == "true": destinataere = destinataere.filter(aktiv=True) - elif aktiv_filter == 'false': + elif aktiv_filter == "false": destinataere = destinataere.filter(aktiv=False) - + # Annotate with total funding (coalesce nulls to Decimal for stable sorting) destinataere = destinataere.annotate( total_foerderungen=Coalesce( - Sum('foerderung__betrag'), - Value(Decimal('0.00'), output_field=DecimalField(max_digits=12, decimal_places=2)), + Sum("foerderung__betrag"), + Value( + Decimal("0.00"), + output_field=DecimalField(max_digits=12, decimal_places=2), + ), output_field=DecimalField(max_digits=12, decimal_places=2), ) ) # Sorting sort_map = { - 'name': ['nachname', 'vorname'], - 'familienzweig': ['familienzweig'], - 'berufsgruppe': ['berufsgruppe'], - 'institution': ['institution'], - 'email': ['email'], - 'foerderungen': ['total_foerderungen'], - 'status': ['aktiv'], + "name": ["nachname", "vorname"], + "familienzweig": ["familienzweig"], + "berufsgruppe": ["berufsgruppe"], + "institution": ["institution"], + "email": ["email"], + "foerderungen": ["total_foerderungen"], + "status": ["aktiv"], } if sort in sort_map: fields = sort_map[sort] - if direction == 'desc': - order_fields = [f'-{f}' for f in fields] + if direction == "desc": + order_fields = [f"-{f}" for f in fields] else: order_fields = fields destinataere = destinataere.order_by(*order_fields) - + paginator = Paginator(destinataere, 20) - page_number = request.GET.get('page') + page_number = request.GET.get("page") page_obj = paginator.get_page(page_number) - + context = { - 'page_obj': page_obj, - 'search_query': search_query, - 'familienzweig_filter': familienzweig_filter, - 'berufsgruppe_filter': berufsgruppe_filter, - 'aktiv_filter': aktiv_filter, - 'familienzweig_choices': Destinataer.FAMILIENZWIG_CHOICES, - 'berufsgruppe_choices': Destinataer.BERUFSGRUPPE_CHOICES, - 'sort': sort, - 'dir': direction, + "page_obj": page_obj, + "search_query": search_query, + "familienzweig_filter": familienzweig_filter, + "berufsgruppe_filter": berufsgruppe_filter, + "aktiv_filter": aktiv_filter, + "familienzweig_choices": Destinataer.FAMILIENZWIG_CHOICES, + "berufsgruppe_choices": Destinataer.BERUFSGRUPPE_CHOICES, + "sort": sort, + "dir": direction, } - return render(request, 'stiftung/destinataer_list.html', context) + return render(request, "stiftung/destinataer_list.html", context) + @login_required def destinataer_detail(request, pk): destinataer = get_object_or_404(Destinataer, pk=pk) - + # Alle mit diesem Destinatär verknüpften Dokumente laden verknuepfte_dokumente = DokumentLink.objects.filter( destinataer_id=destinataer.pk - ).order_by('kontext', 'titel') - + ).order_by("kontext", "titel") + # Förderungen für diesen Destinatär laden - foerderungen = Foerderung.objects.filter(destinataer=destinataer).order_by('-jahr', '-betrag') - + foerderungen = Foerderung.objects.filter(destinataer=destinataer).order_by( + "-jahr", "-betrag" + ) + # Unterstützungen für diesen Destinatär laden - unterstuetzungen = DestinataerUnterstuetzung.objects.filter(destinataer=destinataer).order_by('-faellig_am') + unterstuetzungen = DestinataerUnterstuetzung.objects.filter( + destinataer=destinataer + ).order_by("-faellig_am") # Notizen laden - notizen_eintraege = DestinataerNotiz.objects.filter(destinataer=destinataer).order_by('-erstellt_am') + notizen_eintraege = DestinataerNotiz.objects.filter( + destinataer=destinataer + ).order_by("-erstellt_am") context = { - 'destinataer': destinataer, - 'verknuepfte_dokumente': verknuepfte_dokumente, - 'foerderungen': foerderungen, - 'unterstuetzungen': unterstuetzungen, - 'notizen_eintraege': notizen_eintraege, + "destinataer": destinataer, + "verknuepfte_dokumente": verknuepfte_dokumente, + "foerderungen": foerderungen, + "unterstuetzungen": unterstuetzungen, + "notizen_eintraege": notizen_eintraege, } - return render(request, 'stiftung/destinataer_detail.html', context) + return render(request, "stiftung/destinataer_detail.html", context) + @login_required def destinataer_create(request): - if request.method == 'POST': + if request.method == "POST": form = DestinataerForm(request.POST) if form.is_valid(): destinataer = form.save() - messages.success(request, f'Destinatär "{destinataer.get_full_name()}" wurde erfolgreich erstellt.') - return redirect('stiftung:destinataer_detail', pk=destinataer.pk) + messages.success( + request, + f'Destinatär "{destinataer.get_full_name()}" wurde erfolgreich erstellt.', + ) + return redirect("stiftung:destinataer_detail", pk=destinataer.pk) else: form = DestinataerForm() - - context = {'form': form, 'title': 'Neuen Destinatär erstellen'} - return render(request, 'stiftung/destinataer_form.html', context) + + context = {"form": form, "title": "Neuen Destinatär erstellen"} + return render(request, "stiftung/destinataer_form.html", context) + @login_required def destinataer_update(request, pk): destinataer = get_object_or_404(Destinataer, pk=pk) - if request.method == 'POST': + if request.method == "POST": form = DestinataerForm(request.POST, instance=destinataer) if form.is_valid(): destinataer = form.save() @@ -966,14 +1208,19 @@ def destinataer_update(request, pk): and destinataer.vierteljaehrlicher_betrag > 0 ): from decimal import Decimal + from stiftung.models import DestinataerUnterstuetzung + heute = timezone.now().date() beschreibung = f"Vierteljährliche Vorauszahlung für {destinataer.get_full_name()}" # ensure only one upcoming planned entry; update if one exists - existing = DestinataerUnterstuetzung.objects.filter( - destinataer=destinataer, - status='geplant' - ).order_by('faellig_am').first() + existing = ( + DestinataerUnterstuetzung.objects.filter( + destinataer=destinataer, status="geplant" + ) + .order_by("faellig_am") + .first() + ) if existing: existing.konto = destinataer.standard_konto existing.betrag = Decimal(destinataer.vierteljaehrlicher_betrag) @@ -986,489 +1233,562 @@ def destinataer_update(request, pk): konto=destinataer.standard_konto, betrag=Decimal(destinataer.vierteljaehrlicher_betrag), faellig_am=heute, - status='geplant', + status="geplant", beschreibung=beschreibung, ) except Exception: pass - messages.success(request, f'Destinatär "{destinataer.get_full_name()}" wurde erfolgreich aktualisiert.') - return redirect('stiftung:destinataer_detail', pk=destinataer.pk) + messages.success( + request, + f'Destinatär "{destinataer.get_full_name()}" wurde erfolgreich aktualisiert.', + ) + return redirect("stiftung:destinataer_detail", pk=destinataer.pk) else: form = DestinataerForm(instance=destinataer) - - context = {'form': form, 'destinataer': destinataer, 'title': f'Destinatär bearbeiten: {destinataer.get_full_name()}'} - return render(request, 'stiftung/destinataer_form.html', context) + + context = { + "form": form, + "destinataer": destinataer, + "title": f"Destinatär bearbeiten: {destinataer.get_full_name()}", + } + return render(request, "stiftung/destinataer_form.html", context) + @login_required def destinataer_delete(request, pk): destinataer = get_object_or_404(Destinataer, pk=pk) - if request.method == 'POST': + if request.method == "POST": destinataer.delete() - messages.success(request, f'Destinatär "{destinataer.get_full_name()}" wurde erfolgreich gelöscht.') - return redirect('stiftung:destinataer_list') - - context = {'destinataer': destinataer} - return render(request, 'stiftung/destinataer_confirm_delete.html', context) + messages.success( + request, + f'Destinatär "{destinataer.get_full_name()}" wurde erfolgreich gelöscht.', + ) + return redirect("stiftung:destinataer_list") + + context = {"destinataer": destinataer} + return render(request, "stiftung/destinataer_confirm_delete.html", context) + # Paechter Views (Landpächter) @login_required def paechter_list(request): - search_query = request.GET.get('search', '') - ausbildung_filter = request.GET.get('ausbildung', '') - aktiv_filter = request.GET.get('aktiv', '') - sort = request.GET.get('sort', '') - direction = request.GET.get('dir', 'asc') - + search_query = request.GET.get("search", "") + ausbildung_filter = request.GET.get("ausbildung", "") + aktiv_filter = request.GET.get("aktiv", "") + sort = request.GET.get("sort", "") + direction = request.GET.get("dir", "asc") + paechter = Paechter.objects.all() - + if search_query: paechter = paechter.filter( - Q(nachname__icontains=search_query) | - Q(vorname__icontains=search_query) | - Q(email__icontains=search_query) | - Q(pachtnummer__icontains=search_query) + Q(nachname__icontains=search_query) + | Q(vorname__icontains=search_query) + | Q(email__icontains=search_query) + | Q(pachtnummer__icontains=search_query) ) - - if ausbildung_filter == 'true': + + if ausbildung_filter == "true": paechter = paechter.filter(landwirtschaftliche_ausbildung=True) - elif ausbildung_filter == 'false': + elif ausbildung_filter == "false": paechter = paechter.filter(landwirtschaftliche_ausbildung=False) - - if aktiv_filter == 'true': + + if aktiv_filter == "true": paechter = paechter.filter(aktiv=True) - elif aktiv_filter == 'false': + elif aktiv_filter == "false": paechter = paechter.filter(aktiv=False) - + # Annotate with total leased area and rent (coalesce nulls to Decimal for stable sorting) paechter = paechter.annotate( gesamt_flaeche=Coalesce( - Sum('neue_verpachtungen__verpachtete_flaeche'), - Value(Decimal('0.00'), output_field=DecimalField(max_digits=12, decimal_places=2)), + Sum("neue_verpachtungen__verpachtete_flaeche"), + Value( + Decimal("0.00"), + output_field=DecimalField(max_digits=12, decimal_places=2), + ), output_field=DecimalField(max_digits=12, decimal_places=2), ), gesamt_pachtzins=Coalesce( - Sum('neue_verpachtungen__pachtzins_pauschal'), - Value(Decimal('0.00'), output_field=DecimalField(max_digits=12, decimal_places=2)), - output_field=DecimalField(max_digits=12, decimal_places=2)), + Sum("neue_verpachtungen__pachtzins_pauschal"), + Value( + Decimal("0.00"), + output_field=DecimalField(max_digits=12, decimal_places=2), + ), + output_field=DecimalField(max_digits=12, decimal_places=2), + ), ) # Sorting sort_map = { - 'name': ['nachname', 'vorname'], - 'pachtnummer': ['pachtnummer'], - 'ausbildung': ['landwirtschaftliche_ausbildung'], - 'spezialisierung': ['spezialisierung'], - 'flaeche': ['gesamt_flaeche'], - 'pachtzins': ['gesamt_pachtzins'], - 'status': ['aktiv'], + "name": ["nachname", "vorname"], + "pachtnummer": ["pachtnummer"], + "ausbildung": ["landwirtschaftliche_ausbildung"], + "spezialisierung": ["spezialisierung"], + "flaeche": ["gesamt_flaeche"], + "pachtzins": ["gesamt_pachtzins"], + "status": ["aktiv"], } if sort in sort_map: fields = sort_map[sort] - if direction == 'desc': - order_fields = [f'-{f}' for f in fields] + if direction == "desc": + order_fields = [f"-{f}" for f in fields] else: order_fields = fields paechter = paechter.order_by(*order_fields) - + paginator = Paginator(paechter, 20) - page_number = request.GET.get('page') + page_number = request.GET.get("page") page_obj = paginator.get_page(page_number) - + context = { - 'page_obj': page_obj, - 'search_query': search_query, - 'ausbildung_filter': ausbildung_filter, - 'aktiv_filter': aktiv_filter, - 'sort': sort, - 'dir': direction, + "page_obj": page_obj, + "search_query": search_query, + "ausbildung_filter": ausbildung_filter, + "aktiv_filter": aktiv_filter, + "sort": sort, + "dir": direction, } - return render(request, 'stiftung/paechter_list.html', context) + return render(request, "stiftung/paechter_list.html", context) + @login_required def paechter_detail(request, pk): paechter = get_object_or_404(Paechter, pk=pk) - + # Alle mit diesem Pächter verknüpften Dokumente laden verknuepfte_dokumente = DokumentLink.objects.filter( paechter_id=paechter.pk - ).order_by('kontext', 'titel') - + ).order_by("kontext", "titel") + # Neue LandVerpachtungen für diesen Pächter laden - verpachtungen = LandVerpachtung.objects.filter(paechter=paechter).order_by('-pachtbeginn') - + verpachtungen = LandVerpachtung.objects.filter(paechter=paechter).order_by( + "-pachtbeginn" + ) + # Neue gepachtete Ländereien (über aktueller_paechter) - gepachtete_laendereien = paechter.gepachtete_laendereien.filter(aktiv=True).order_by('gemeinde', 'gemarkung') - + gepachtete_laendereien = paechter.gepachtete_laendereien.filter( + aktiv=True + ).order_by("gemeinde", "gemarkung") + # Statistiken berechnen - total_flaeche_neu = sum(land.verp_flaeche_aktuell or 0 for land in gepachtete_laendereien) - total_pachtzins_neu = sum(land.pachtzins_pauschal or 0 for land in gepachtete_laendereien) - + total_flaeche_neu = sum( + land.verp_flaeche_aktuell or 0 for land in gepachtete_laendereien + ) + total_pachtzins_neu = sum( + land.pachtzins_pauschal or 0 for land in gepachtete_laendereien + ) + context = { - 'paechter': paechter, - 'verknuepfte_dokumente': verknuepfte_dokumente, - 'verpachtungen': verpachtungen, # Now using LandVerpachtung - 'gepachtete_laendereien': gepachtete_laendereien, # Neu - 'total_flaeche_neu': total_flaeche_neu, - 'total_pachtzins_neu': total_pachtzins_neu, + "paechter": paechter, + "verknuepfte_dokumente": verknuepfte_dokumente, + "verpachtungen": verpachtungen, # Now using LandVerpachtung + "gepachtete_laendereien": gepachtete_laendereien, # Neu + "total_flaeche_neu": total_flaeche_neu, + "total_pachtzins_neu": total_pachtzins_neu, } - return render(request, 'stiftung/paechter_detail.html', context) + return render(request, "stiftung/paechter_detail.html", context) + @login_required def paechter_create(request): - if request.method == 'POST': + if request.method == "POST": form = PaechterForm(request.POST) if form.is_valid(): paechter = form.save() - messages.success(request, f'Pächter "{paechter.get_full_name()}" wurde erfolgreich erstellt.') - return redirect('stiftung:paechter_detail', pk=paechter.pk) + messages.success( + request, + f'Pächter "{paechter.get_full_name()}" wurde erfolgreich erstellt.', + ) + return redirect("stiftung:paechter_detail", pk=paechter.pk) else: # Debug: Log form errors and show them to user print(f"Form errors: {form.errors}") print(f"Form data: {request.POST}") - messages.error(request, f'Formular-Fehler: {form.errors}') + messages.error(request, f"Formular-Fehler: {form.errors}") for field, errors in form.errors.items(): for error in errors: - messages.error(request, f'{field}: {error}') + messages.error(request, f"{field}: {error}") else: form = PaechterForm() - - context = {'form': form, 'title': 'Neuen Pächter erstellen'} - return render(request, 'stiftung/paechter_form.html', context) + + context = {"form": form, "title": "Neuen Pächter erstellen"} + return render(request, "stiftung/paechter_form.html", context) + @login_required def paechter_update(request, pk): paechter = get_object_or_404(Paechter, pk=pk) - if request.method == 'POST': + if request.method == "POST": form = PaechterForm(request.POST, instance=paechter) if form.is_valid(): paechter = form.save() - messages.success(request, f'Pächter "{paechter.get_full_name()}" wurde erfolgreich aktualisiert.') - return redirect('stiftung:paechter_detail', pk=paechter.pk) + messages.success( + request, + f'Pächter "{paechter.get_full_name()}" wurde erfolgreich aktualisiert.', + ) + return redirect("stiftung:paechter_detail", pk=paechter.pk) else: # Debug: Log form errors and show them to user print(f"Form errors: {form.errors}") print(f"Form data: {request.POST}") - messages.error(request, f'Formular-Fehler: {form.errors}') + messages.error(request, f"Formular-Fehler: {form.errors}") for field, errors in form.errors.items(): for error in errors: - messages.error(request, f'{field}: {error}') + messages.error(request, f"{field}: {error}") else: form = PaechterForm(instance=paechter) - - context = {'form': form, 'paechter': paechter, 'title': f'Pächter bearbeiten: {paechter.get_full_name()}'} - return render(request, 'stiftung/paechter_form.html', context) + + context = { + "form": form, + "paechter": paechter, + "title": f"Pächter bearbeiten: {paechter.get_full_name()}", + } + return render(request, "stiftung/paechter_form.html", context) + @login_required def paechter_delete(request, pk): paechter = get_object_or_404(Paechter, pk=pk) - if request.method == 'POST': + if request.method == "POST": paechter.delete() - messages.success(request, f'Pächter "{paechter.get_full_name()}" wurde erfolgreich gelöscht.') - return redirect('stiftung:paechter_list') - - context = {'paechter': paechter} - return render(request, 'stiftung/paechter_confirm_delete.html', context) + messages.success( + request, f'Pächter "{paechter.get_full_name()}" wurde erfolgreich gelöscht.' + ) + return redirect("stiftung:paechter_list") + + context = {"paechter": paechter} + return render(request, "stiftung/paechter_confirm_delete.html", context) + # Land Views @login_required def land_list(request): - search_query = request.GET.get('search', '') - gemeinde_filter = request.GET.get('gemeinde', '') - aktiv_filter = request.GET.get('aktiv', '') - sort = request.GET.get('sort', '') - direction = request.GET.get('dir', 'asc') - + search_query = request.GET.get("search", "") + gemeinde_filter = request.GET.get("gemeinde", "") + aktiv_filter = request.GET.get("aktiv", "") + sort = request.GET.get("sort", "") + direction = request.GET.get("dir", "asc") + lands = Land.objects.all() - + if search_query: lands = lands.filter( - Q(lfd_nr__icontains=search_query) | - Q(gemeinde__icontains=search_query) | - Q(gemarkung__icontains=search_query) | - Q(flur__icontains=search_query) | - Q(flurstueck__icontains=search_query) + Q(lfd_nr__icontains=search_query) + | Q(gemeinde__icontains=search_query) + | Q(gemarkung__icontains=search_query) + | Q(flur__icontains=search_query) + | Q(flurstueck__icontains=search_query) ) - + if gemeinde_filter: lands = lands.filter(gemeinde=gemeinde_filter) - - if aktiv_filter == 'true': + + if aktiv_filter == "true": lands = lands.filter(aktiv=True) - elif aktiv_filter == 'false': + elif aktiv_filter == "false": lands = lands.filter(aktiv=False) - + # Annotate with verpachtungsgrad and numeric casts for natural sorting # Prepare numeric versions of textual fields by stripping common non-digits def digits_only(field_expr): - expr = Replace(field_expr, Value(' '), Value('')) - expr = Replace(expr, Value('-'), Value('')) - expr = Replace(expr, Value('.'), Value('')) - expr = Replace(expr, Value('/'), Value('')) - expr = Replace(expr, Value('L'), Value('')) + expr = Replace(field_expr, Value(" "), Value("")) + expr = Replace(expr, Value("-"), Value("")) + expr = Replace(expr, Value("."), Value("")) + expr = Replace(expr, Value("/"), Value("")) + expr = Replace(expr, Value("L"), Value("")) return expr lands = lands.extra( - select={'verpachtungsgrad': 'CASE WHEN groesse_qm > 0 THEN (verp_flaeche_aktuell / groesse_qm) * 100 ELSE 0 END'} + select={ + "verpachtungsgrad": "CASE WHEN groesse_qm > 0 THEN (verp_flaeche_aktuell / groesse_qm) * 100 ELSE 0 END" + } ).annotate( - lfd_nr_num=Cast(NullIf(digits_only(F('lfd_nr')), Value('')), IntegerField()), - flur_num=Cast(NullIf(digits_only(F('flur')), Value('')), IntegerField()), - flurstueck_num=Cast(NullIf(digits_only(F('flurstueck')), Value('')), IntegerField()), + lfd_nr_num=Cast(NullIf(digits_only(F("lfd_nr")), Value("")), IntegerField()), + flur_num=Cast(NullIf(digits_only(F("flur")), Value("")), IntegerField()), + flurstueck_num=Cast( + NullIf(digits_only(F("flurstueck")), Value("")), IntegerField() + ), ) # Sorting sort_map = { - 'lfd_nr': ['lfd_nr_num', 'lfd_nr'], - 'gemeinde': ['gemeinde'], - 'gemarkung': ['gemarkung'], - 'flur': ['flur_num', 'flur'], - 'flurstueck': ['flurstueck_num', 'flurstueck'], - 'groesse': ['groesse_qm'], - 'verp': ['verp_flaeche_aktuell'], - 'grad': ['verpachtungsgrad'], + "lfd_nr": ["lfd_nr_num", "lfd_nr"], + "gemeinde": ["gemeinde"], + "gemarkung": ["gemarkung"], + "flur": ["flur_num", "flur"], + "flurstueck": ["flurstueck_num", "flurstueck"], + "groesse": ["groesse_qm"], + "verp": ["verp_flaeche_aktuell"], + "grad": ["verpachtungsgrad"], } if sort in sort_map: fields = sort_map[sort] - if direction == 'desc': - order_fields = [f'-{f}' for f in fields] + if direction == "desc": + order_fields = [f"-{f}" for f in fields] else: order_fields = fields lands = lands.order_by(*order_fields) # Aggregated statistics for current filter set aggregates = lands.aggregate( - sum_groesse_qm=Sum('groesse_qm'), - sum_gruenland_qm=Sum('gruenland_qm'), - sum_acker_qm=Sum('acker_qm'), - sum_wald_qm=Sum('wald_qm'), - sum_sonstiges_qm=Sum('sonstiges_qm'), + sum_groesse_qm=Sum("groesse_qm"), + sum_gruenland_qm=Sum("gruenland_qm"), + sum_acker_qm=Sum("acker_qm"), + sum_wald_qm=Sum("wald_qm"), + sum_sonstiges_qm=Sum("sonstiges_qm"), ) - sum_gruenland_qm = float(aggregates.get('sum_gruenland_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_sonstiges_qm = float(aggregates.get('sum_sonstiges_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_wald_qm = float(aggregates.get("sum_wald_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 + def pct(part, total): return round((part / total) * 100, 1) if total and part is not None else 0.0 + stats = { - 'sum_gruenland_qm': sum_gruenland_qm, - 'sum_acker_qm': sum_acker_qm, - 'sum_wald_qm': sum_wald_qm, - 'sum_sonstiges_qm': sum_sonstiges_qm, - 'sum_total_use_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_wald': pct(sum_wald_qm, sum_total_use_qm), + "sum_gruenland_qm": sum_gruenland_qm, + "sum_acker_qm": sum_acker_qm, + "sum_wald_qm": sum_wald_qm, + "sum_sonstiges_qm": sum_sonstiges_qm, + "sum_total_use_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_wald": pct(sum_wald_qm, sum_total_use_qm), } # Prepare size chart data (top 30 by size) top_sizes = list( - lands.order_by('-groesse_qm').values_list('lfd_nr', 'groesse_qm')[:30] + lands.order_by("-groesse_qm").values_list("lfd_nr", "groesse_qm")[:30] ) - size_chart_labels = [label or '' for label, _ in top_sizes] + size_chart_labels = [label or "" for label, _ in top_sizes] size_chart_values = [float(val or 0) for _, val in top_sizes] - + paginator = Paginator(lands, 20) - page_number = request.GET.get('page') + page_number = request.GET.get("page") page_obj = paginator.get_page(page_number) - + # Get unique gemeinden for filter - gemeinden = Land.objects.values_list('gemeinde', flat=True).distinct().order_by('gemeinde') - + gemeinden = ( + Land.objects.values_list("gemeinde", flat=True).distinct().order_by("gemeinde") + ) + context = { - 'page_obj': page_obj, - 'search_query': search_query, - 'gemeinde_filter': gemeinde_filter, - 'aktiv_filter': aktiv_filter, - 'gemeinden': gemeinden, - 'stats': stats, - 'size_chart_labels_json': json.dumps(size_chart_labels), - 'size_chart_values_json': json.dumps(size_chart_values), - 'sort': sort, - 'dir': direction, + "page_obj": page_obj, + "search_query": search_query, + "gemeinde_filter": gemeinde_filter, + "aktiv_filter": aktiv_filter, + "gemeinden": gemeinden, + "stats": stats, + "size_chart_labels_json": json.dumps(size_chart_labels), + "size_chart_values_json": json.dumps(size_chart_values), + "sort": sort, + "dir": direction, } - return render(request, 'stiftung/land_list.html', context) + return render(request, "stiftung/land_list.html", context) + @login_required def land_detail(request, pk): land = get_object_or_404(Land, pk=pk) - + # Alle mit dieser Länderei verknüpften Dokumente laden - verknuepfte_dokumente = DokumentLink.objects.filter( - land_id=land.pk - ).order_by('kontext', 'titel') - + verknuepfte_dokumente = DokumentLink.objects.filter(land_id=land.pk).order_by( + "kontext", "titel" + ) + # Neue LandVerpachtungen laden (mit related data) - neue_verpachtungen = land.neue_verpachtungen.select_related('paechter').order_by('-pachtbeginn') - + neue_verpachtungen = land.neue_verpachtungen.select_related("paechter").order_by( + "-pachtbeginn" + ) + context = { - 'land': land, - 'verknuepfte_dokumente': verknuepfte_dokumente, - 'verpachtungen': neue_verpachtungen, # Using only new system now - 'neue_verpachtungen': neue_verpachtungen, + "land": land, + "verknuepfte_dokumente": verknuepfte_dokumente, + "verpachtungen": neue_verpachtungen, # Using only new system now + "neue_verpachtungen": neue_verpachtungen, } - return render(request, 'stiftung/land_detail.html', context) + return render(request, "stiftung/land_detail.html", context) + @login_required def land_create(request): - if request.method == 'POST': + if request.method == "POST": form = LandForm(request.POST) if form.is_valid(): land = form.save() messages.success(request, f'Länderei "{land}" wurde erfolgreich erstellt.') - return redirect('stiftung:land_detail', pk=land.pk) + return redirect("stiftung:land_detail", pk=land.pk) else: form = LandForm() - - context = {'form': form, 'title': 'Neue Länderei erstellen'} - return render(request, 'stiftung/land_form.html', context) + + context = {"form": form, "title": "Neue Länderei erstellen"} + return render(request, "stiftung/land_form.html", context) + @login_required def land_update(request, pk): land = get_object_or_404(Land, pk=pk) - if request.method == 'POST': + if request.method == "POST": form = LandForm(request.POST, instance=land) if form.is_valid(): land = form.save() - messages.success(request, f'Länderei "{land}" wurde erfolgreich aktualisiert.') - return redirect('stiftung:land_detail', pk=land.pk) + messages.success( + request, f'Länderei "{land}" wurde erfolgreich aktualisiert.' + ) + return redirect("stiftung:land_detail", pk=land.pk) else: form = LandForm(instance=land) - - context = {'form': form, 'land': land, 'title': f'Länderei bearbeiten: {land}'} - return render(request, 'stiftung/land_form.html', context) + + context = {"form": form, "land": land, "title": f"Länderei bearbeiten: {land}"} + return render(request, "stiftung/land_form.html", context) + @login_required def land_delete(request, pk): land = get_object_or_404(Land, pk=pk) - if request.method == 'POST': + if request.method == "POST": land.delete() messages.success(request, f'Länderei "{land}" wurde erfolgreich gelöscht.') - return redirect('stiftung:land_list') - - context = {'land': land} - return render(request, 'stiftung/land_confirm_delete.html', context) + return redirect("stiftung:land_list") + + context = {"land": land} + return render(request, "stiftung/land_confirm_delete.html", context) + # Verpachtung Views @login_required def verpachtung_list(request): - search_query = request.GET.get('search', '') - status_filter = request.GET.get('status', '') - gemeinde_filter = request.GET.get('gemeinde', '') - sort = request.GET.get('sort', '') - direction = request.GET.get('dir', 'asc') - - verpachtungen = LandVerpachtung.objects.select_related('land', 'paechter').all() - + search_query = request.GET.get("search", "") + status_filter = request.GET.get("status", "") + gemeinde_filter = request.GET.get("gemeinde", "") + sort = request.GET.get("sort", "") + direction = request.GET.get("dir", "asc") + + verpachtungen = LandVerpachtung.objects.select_related("land", "paechter").all() + if search_query: verpachtungen = verpachtungen.filter( - Q(vertragsnummer__icontains=search_query) | - Q(land__gemeinde__icontains=search_query) | - Q(paechter__nachname__icontains=search_query) | - Q(paechter__vorname__icontains=search_query) + Q(vertragsnummer__icontains=search_query) + | Q(land__gemeinde__icontains=search_query) + | Q(paechter__nachname__icontains=search_query) + | Q(paechter__vorname__icontains=search_query) ) - + if status_filter: verpachtungen = verpachtungen.filter(status=status_filter) - + if gemeinde_filter: verpachtungen = verpachtungen.filter(land__gemeinde=gemeinde_filter) - + # Sorting sort_map = { - 'vertragsnummer': ['vertragsnummer'], - 'land': ['land__gemeinde'], - 'paechter': ['paechter__nachname', 'paechter__vorname'], - 'beginn': ['pachtbeginn'], - 'ende': ['pachtende'], - 'flaeche': ['verpachtete_flaeche'], - 'pachtzins': ['pachtzins_jaehrlich'], - 'status': ['status'], + "vertragsnummer": ["vertragsnummer"], + "land": ["land__gemeinde"], + "paechter": ["paechter__nachname", "paechter__vorname"], + "beginn": ["pachtbeginn"], + "ende": ["pachtende"], + "flaeche": ["verpachtete_flaeche"], + "pachtzins": ["pachtzins_jaehrlich"], + "status": ["status"], } if sort in sort_map: fields = sort_map[sort] - if direction == 'desc': - order_fields = [f'-{f}' for f in fields] + if direction == "desc": + order_fields = [f"-{f}" for f in fields] else: order_fields = fields verpachtungen = verpachtungen.order_by(*order_fields) - + paginator = Paginator(verpachtungen, 20) - page_number = request.GET.get('page') + page_number = request.GET.get("page") page_obj = paginator.get_page(page_number) - + # Calculate statistics for the summary cards # Get ALL verpachtungen (not filtered) for accurate statistics - all_verpachtungen = LandVerpachtung.objects.select_related('land', 'paechter').all() - + all_verpachtungen = LandVerpachtung.objects.select_related("land", "paechter").all() + # Active verpachtungen count - aktive_verpachtungen = all_verpachtungen.filter(status='aktiv').count() - + aktive_verpachtungen = all_verpachtungen.filter(status="aktiv").count() + # Total leased area (only active verpachtungen) - gesamt_flaeche_result = all_verpachtungen.filter(status='aktiv').aggregate( - total=Sum('verpachtete_flaeche') + gesamt_flaeche_result = all_verpachtungen.filter(status="aktiv").aggregate( + total=Sum("verpachtete_flaeche") ) - gesamt_flaeche = gesamt_flaeche_result['total'] if gesamt_flaeche_result['total'] is not None else 0 - + gesamt_flaeche = ( + gesamt_flaeche_result["total"] + if gesamt_flaeche_result["total"] is not None + else 0 + ) + # Total annual rent (only active verpachtungen) - jaehrlicher_pachtzins_result = all_verpachtungen.filter(status='aktiv').aggregate( - total=Sum('pachtzins_jaehrlich') + jaehrlicher_pachtzins_result = all_verpachtungen.filter(status="aktiv").aggregate( + total=Sum("pachtzins_jaehrlich") ) - jaehrlicher_pachtzins = jaehrlicher_pachtzins_result['total'] if jaehrlicher_pachtzins_result['total'] is not None else 0 - + jaehrlicher_pachtzins = ( + jaehrlicher_pachtzins_result["total"] + if jaehrlicher_pachtzins_result["total"] is not None + else 0 + ) + # Total count of all verpachtungen anzahl_verpachtungen = all_verpachtungen.count() - + # Get unique gemeinden and statuses for filters - gemeinden = Land.objects.values_list('gemeinde', flat=True).distinct().order_by('gemeinde') + gemeinden = ( + Land.objects.values_list("gemeinde", flat=True).distinct().order_by("gemeinde") + ) status_choices = LandVerpachtung.STATUS_CHOICES - + context = { - 'page_obj': page_obj, - 'search_query': search_query, - 'status_filter': status_filter, - 'gemeinde_filter': gemeinde_filter, - 'gemeinden': gemeinden, - 'status_choices': status_choices, + "page_obj": page_obj, + "search_query": search_query, + "status_filter": status_filter, + "gemeinde_filter": gemeinde_filter, + "gemeinden": gemeinden, + "status_choices": status_choices, # Statistics for summary cards - 'aktive_verpachtungen': aktive_verpachtungen, - 'gesamt_flaeche': gesamt_flaeche, - 'jaehrlicher_pachtzins': jaehrlicher_pachtzins, - 'anzahl_verpachtungen': anzahl_verpachtungen, - 'sort': sort, - 'dir': direction, + "aktive_verpachtungen": aktive_verpachtungen, + "gesamt_flaeche": gesamt_flaeche, + "jaehrlicher_pachtzins": jaehrlicher_pachtzins, + "anzahl_verpachtungen": anzahl_verpachtungen, + "sort": sort, + "dir": direction, } - return render(request, 'stiftung/verpachtung_list.html', context) + return render(request, "stiftung/verpachtung_list.html", context) + @login_required @login_required def land_verpachtung_detail(request, pk): """Detail view for LandVerpachtung""" verpachtung = get_object_or_404(LandVerpachtung, pk=pk) - + # Alle mit dieser Verpachtung verknüpften Dokumente laden verknuepfte_dokumente = DokumentLink.objects.filter( land_verpachtung_id=verpachtung.pk - ).order_by('kontext', 'titel') - + ).order_by("kontext", "titel") + context = { - 'verpachtung': verpachtung, - 'landverpachtung': verpachtung, # Template expects this variable name - 'verknuepfte_dokumente': verknuepfte_dokumente, + "verpachtung": verpachtung, + "landverpachtung": verpachtung, # Template expects this variable name + "verknuepfte_dokumente": verknuepfte_dokumente, } - return render(request, 'stiftung/land_verpachtung_detail.html', context) + return render(request, "stiftung/land_verpachtung_detail.html", context) + @login_required def land_verpachtung_update(request, pk): """Update an existing LandVerpachtung by its primary key""" verpachtung = get_object_or_404(LandVerpachtung, pk=pk) - - if request.method == 'POST': + + if request.method == "POST": # Handle the update form submission - vertragsnummer = request.POST.get('vertragsnummer') - pachtbeginn = request.POST.get('pachtbeginn') - pachtende = request.POST.get('pachtende') - pachtzins_jaehrlich = request.POST.get('pachtzins_jaehrlich') - + vertragsnummer = request.POST.get("vertragsnummer") + pachtbeginn = request.POST.get("pachtbeginn") + pachtende = request.POST.get("pachtende") + pachtzins_jaehrlich = request.POST.get("pachtzins_jaehrlich") + if vertragsnummer: verpachtung.vertragsnummer = vertragsnummer if pachtbeginn: @@ -1477,53 +1797,67 @@ def land_verpachtung_update(request, pk): verpachtung.pachtende = pachtende if pachtzins_jaehrlich: verpachtung.pachtzins_jaehrlich = pachtzins_jaehrlich - - verpachtung.save() - messages.success(request, 'Verpachtung wurde erfolgreich aktualisiert.') - return redirect('stiftung:land_verpachtung_detail', pk=verpachtung.pk) - - context = { - 'verpachtung': verpachtung, - 'landverpachtung': verpachtung, # Template expects this variable name - 'is_edit': True, - 'is_update': True, # Form template uses this flag - } - return render(request, 'stiftung/land_verpachtung_form.html', context) -@login_required + verpachtung.save() + messages.success(request, "Verpachtung wurde erfolgreich aktualisiert.") + return redirect("stiftung:land_verpachtung_detail", pk=verpachtung.pk) + + context = { + "verpachtung": verpachtung, + "landverpachtung": verpachtung, # Template expects this variable name + "is_edit": True, + "is_update": True, # Form template uses this flag + } + return render(request, "stiftung/land_verpachtung_form.html", context) + + +@login_required def land_verpachtung_end_direct(request, pk): """End a LandVerpachtung directly by its primary key""" verpachtung = get_object_or_404(LandVerpachtung, pk=pk) - - if request.method == 'POST': - verpachtung.status = 'beendet' + + if request.method == "POST": + verpachtung.status = "beendet" verpachtung.pachtende = timezone.now().date() verpachtung.save() - messages.success(request, 'Verpachtung wurde erfolgreich beendet.') - return redirect('stiftung:land_detail', pk=verpachtung.land.pk) - + messages.success(request, "Verpachtung wurde erfolgreich beendet.") + return redirect("stiftung:land_detail", pk=verpachtung.land.pk) + context = { - 'verpachtung': verpachtung, + "verpachtung": verpachtung, } - return render(request, 'stiftung/land_verpachtung_end_confirm.html', context) + return render(request, "stiftung/land_verpachtung_end_confirm.html", context) + # Förderung Views @login_required def foerderung_list(request): """List all funding grants with filtering and pagination""" - foerderungen = Foerderung.objects.select_related('destinataer', 'verwendungsnachweis').all() - + foerderungen = Foerderung.objects.select_related( + "destinataer", "verwendungsnachweis" + ).all() + # Check for export request - handle both GET and POST - export_format = request.POST.get('format') if request.method == 'POST' else request.GET.get('format', '') - selected_ids_param = request.POST.get('selected_entries', '') if request.method == 'POST' else request.GET.get('selected_entries', '') - selected_ids = [id for id in selected_ids_param.split(',') if id] if selected_ids_param else [] - + export_format = ( + request.POST.get("format") + if request.method == "POST" + else request.GET.get("format", "") + ) + selected_ids_param = ( + request.POST.get("selected_entries", "") + if request.method == "POST" + else request.GET.get("selected_entries", "") + ) + selected_ids = ( + [id for id in selected_ids_param.split(",") if id] if selected_ids_param else [] + ) + # Filtering - jahr = request.GET.get('jahr') - kategorie = request.GET.get('kategorie') - status = request.GET.get('status') - destinataer = request.GET.get('destinataer') - + jahr = request.GET.get("jahr") + kategorie = request.GET.get("kategorie") + status = request.GET.get("status") + destinataer = request.GET.get("destinataer") + if jahr: foerderungen = foerderungen.filter(jahr=int(jahr)) if kategorie: @@ -1532,313 +1866,368 @@ def foerderung_list(request): foerderungen = foerderungen.filter(status=status) if destinataer: foerderungen = foerderungen.filter(destinataer__nachname__icontains=destinataer) - + # Handle exports - if export_format == 'csv': + if export_format == "csv": return export_foerderungen_csv(request, foerderungen, selected_ids) - elif export_format == 'pdf': + elif export_format == "pdf": return export_foerderungen_pdf(request, foerderungen, selected_ids) - + # Pagination paginator = Paginator(foerderungen, 25) - page_number = request.GET.get('page') + page_number = request.GET.get("page") page_obj = paginator.get_page(page_number) - + # Statistics - total_betrag = foerderungen.aggregate(total=Sum('betrag'))['total'] or 0 - avg_betrag = foerderungen.aggregate(avg=Avg('betrag'))['avg'] or 0 - + total_betrag = foerderungen.aggregate(total=Sum("betrag"))["total"] or 0 + avg_betrag = foerderungen.aggregate(avg=Avg("betrag"))["avg"] or 0 + # Year choices for filters - jahre = sorted(set( - list(Foerderung.objects.values_list('jahr', flat=True)) - ), reverse=True) + jahre = sorted( + set(list(Foerderung.objects.values_list("jahr", flat=True))), reverse=True + ) context = { - 'page_obj': page_obj, - 'foerderungen': foerderungen, # Add for counting - 'total_betrag': total_betrag, - 'avg_betrag': avg_betrag, - 'kategorien': Foerderung.KATEGORIE_CHOICES, - 'status_choices': Foerderung.STATUS_CHOICES, - 'filter_jahr': jahr, - 'filter_kategorie': kategorie, - 'filter_status': status, - 'filter_person': destinataer, - 'jahre': jahre, + "page_obj": page_obj, + "foerderungen": foerderungen, # Add for counting + "total_betrag": total_betrag, + "avg_betrag": avg_betrag, + "kategorien": Foerderung.KATEGORIE_CHOICES, + "status_choices": Foerderung.STATUS_CHOICES, + "filter_jahr": jahr, + "filter_kategorie": kategorie, + "filter_status": status, + "filter_person": destinataer, + "jahre": jahre, } - return render(request, 'stiftung/foerderung_list.html', context) + return render(request, "stiftung/foerderung_list.html", context) + @login_required def foerderung_detail(request, pk): """Show details of a specific funding grant""" - foerderung = get_object_or_404(Foerderung.objects.select_related('person', 'verwendungsnachweis'), pk=pk) - + foerderung = get_object_or_404( + Foerderung.objects.select_related("person", "verwendungsnachweis"), pk=pk + ) + # Alle mit dieser Förderung verknüpften Dokumente laden verknuepfte_dokumente = DokumentLink.objects.filter( foerderung_id=foerderung.pk - ).order_by('kontext', 'titel') - + ).order_by("kontext", "titel") + context = { - 'foerderung': foerderung, - 'verknuepfte_dokumente': verknuepfte_dokumente, - 'title': f'Förderung: {foerderung}', + "foerderung": foerderung, + "verknuepfte_dokumente": verknuepfte_dokumente, + "title": f"Förderung: {foerderung}", } - return render(request, 'stiftung/foerderung_detail.html', context) + return render(request, "stiftung/foerderung_detail.html", context) + @login_required def foerderung_create(request): """Create a new funding grant""" # Get destinataer from URL parameter if provided - destinataer_id = request.GET.get('destinataer') + destinataer_id = request.GET.get("destinataer") initial = {} if destinataer_id: - initial['destinataer'] = destinataer_id - - if request.method == 'POST': + initial["destinataer"] = destinataer_id + + if request.method == "POST": form = FoerderungForm(request.POST) if form.is_valid(): foerderung = form.save() - messages.success(request, f'Förderung für {foerderung.destinataer} wurde erfolgreich erstellt.') - return redirect('stiftung:foerderung_detail', pk=foerderung.pk) + messages.success( + request, + f"Förderung für {foerderung.destinataer} wurde erfolgreich erstellt.", + ) + return redirect("stiftung:foerderung_detail", pk=foerderung.pk) else: form = FoerderungForm(initial=initial) - + context = { - 'form': form, - 'title': 'Neue Förderung erstellen', + "form": form, + "title": "Neue Förderung erstellen", } - return render(request, 'stiftung/foerderung_form.html', context) + return render(request, "stiftung/foerderung_form.html", context) + @login_required def foerderung_update(request, pk): """Update an existing funding grant""" foerderung = get_object_or_404(Foerderung, pk=pk) - - if request.method == 'POST': + + if request.method == "POST": form = FoerderungForm(request.POST, instance=foerderung) if form.is_valid(): form.save() - messages.success(request, f'Förderung für {foerderung.person} wurde erfolgreich aktualisiert.') - return redirect('stiftung:foerderung_detail', pk=foerderung.pk) + messages.success( + request, + f"Förderung für {foerderung.person} wurde erfolgreich aktualisiert.", + ) + return redirect("stiftung:foerderung_detail", pk=foerderung.pk) else: form = FoerderungForm(instance=foerderung) - + context = { - 'form': form, - 'foerderung': foerderung, - 'title': f'Förderung bearbeiten: {foerderung}', + "form": form, + "foerderung": foerderung, + "title": f"Förderung bearbeiten: {foerderung}", } - return render(request, 'stiftung/foerderung_form.html', context) + return render(request, "stiftung/foerderung_form.html", context) + @login_required def foerderung_delete(request, pk): """Delete a funding grant""" foerderung = get_object_or_404(Foerderung, pk=pk) - - if request.method == 'POST': + + if request.method == "POST": # Get the recipient name before deletion - recipient_name = foerderung.destinataer.get_full_name() if foerderung.destinataer else ( - foerderung.person.get_full_name() if foerderung.person else "Unbekannter Empfänger" + recipient_name = ( + foerderung.destinataer.get_full_name() + if foerderung.destinataer + else ( + foerderung.person.get_full_name() + if foerderung.person + else "Unbekannter Empfänger" + ) ) - + foerderung.delete() - messages.success(request, f'Förderung für {recipient_name} wurde erfolgreich gelöscht.') - return redirect('stiftung:foerderung_list') - + messages.success( + request, f"Förderung für {recipient_name} wurde erfolgreich gelöscht." + ) + return redirect("stiftung:foerderung_list") + context = { - 'foerderung': foerderung, - 'title': f'Förderung löschen: {foerderung}', + "foerderung": foerderung, + "title": f"Förderung löschen: {foerderung}", } - return render(request, 'stiftung/foerderung_confirm_delete.html', context) + return render(request, "stiftung/foerderung_confirm_delete.html", context) + # DokumentLink Views @login_required def dokument_list(request): """Zeigt alle verknüpften Dokumente an""" # Alle verknüpften Dokumente laden - dokumente = DokumentLink.objects.all().order_by('-id') - + dokumente = DokumentLink.objects.all().order_by("-id") + # Paperless-API-Konfiguration für verfügbare Dokumente - from stiftung.utils.config import get_paperless_config import requests - + + from stiftung.utils.config import get_paperless_config + config = get_paperless_config() - url = config['api_url'] - token = config['api_token'] - + url = config["api_url"] + token = config["api_token"] + available_dokumente = [] if url and token: try: - base_url = url.rstrip('/api') if url.endswith('/api') else url - headers = {'Authorization': f'Token {token}'} - + base_url = url.rstrip("/api") if url.endswith("/api") else url + headers = {"Authorization": f"Token {token}"} + # Alle verfügbaren Dokumente abrufen (mit Paginierung) all_dokumente = [] page = 1 page_size = 100 - + while True: - response = requests.get(f"{base_url}/api/documents/?page={page}&page_size={page_size}", headers=headers, timeout=10) + response = requests.get( + f"{base_url}/api/documents/?page={page}&page_size={page_size}", + headers=headers, + timeout=10, + ) response.raise_for_status() data = response.json() - - all_dokumente.extend(data.get('results', [])) - - if not data.get('next'): + + all_dokumente.extend(data.get("results", [])) + + if not data.get("next"): break page += 1 - + # Stiftung-Dokumente filtern for doc in all_dokumente: try: tags = [] - doc_tags = doc.get('tags', []) - + doc_tags = doc.get("tags", []) + if isinstance(doc_tags, list): for tag in doc_tags: - if isinstance(tag, dict) and 'name' in tag: - tags.append(tag['name']) + if isinstance(tag, dict) and "name" in tag: + tags.append(tag["name"]) elif isinstance(tag, str): tags.append(tag) elif isinstance(tag, int): tags.append(f"Tag_{tag}") elif isinstance(doc_tags, str): - tags = [tag.strip() for tag in doc_tags.split(',')] - - if any(tag in [config['destinataere_tag'], config['land_tag'], config['admin_tag']] for tag in tags): + tags = [tag.strip() for tag in doc_tags.split(",")] + + if any( + tag + in [ + config["destinataere_tag"], + config["land_tag"], + config["admin_tag"], + ] + for tag in tags + ): bereits_verknuepft = DokumentLink.objects.filter( - paperless_document_id=doc['id'] + paperless_document_id=doc["id"] ).exists() - + if not bereits_verknuepft: - available_dokumente.append({ - 'id': doc['id'], - 'title': doc.get('title', f'Dokument {doc["id"]}'), - 'created_date': doc.get('created_date', ''), - 'tags': tags, - 'thumbnail_url': f"{base_url}/api/documents/{doc['id']}/thumb/", - 'document_url': f"{base_url}/documents/{doc['id']}/", - }) + available_dokumente.append( + { + "id": doc["id"], + "title": doc.get("title", f'Dokument {doc["id"]}'), + "created_date": doc.get("created_date", ""), + "tags": tags, + "thumbnail_url": f"{base_url}/api/documents/{doc['id']}/thumb/", + "document_url": f"{base_url}/documents/{doc['id']}/", + } + ) except Exception: continue - + # Nach Erstellungsdatum sortieren - available_dokumente.sort(key=lambda x: x['created_date'], reverse=True) - + available_dokumente.sort(key=lambda x: x["created_date"], reverse=True) + except Exception: pass - + context = { - 'dokumente': dokumente, - 'available_dokumente': available_dokumente, - 'title': 'Alle verknüpften Dokumente', + "dokumente": dokumente, + "available_dokumente": available_dokumente, + "title": "Alle verknüpften Dokumente", } - return render(request, 'stiftung/dokument_list.html', context) + return render(request, "stiftung/dokument_list.html", context) + @login_required def dokument_detail(request, pk): """Show details of a specific document link""" dokument = get_object_or_404(DokumentLink, pk=pk) - + context = { - 'dokument': dokument, - 'title': f'Dokument: {dokument}', + "dokument": dokument, + "title": f"Dokument: {dokument}", } - return render(request, 'stiftung/dokument_detail.html', context) + return render(request, "stiftung/dokument_detail.html", context) + @login_required def dokument_create(request): """Create a new document link""" - if request.method == 'POST': + if request.method == "POST": form = DokumentLinkForm(request.POST) if form.is_valid(): dokument = form.save() - messages.success(request, f'Dokument "{dokument.titel}" wurde erfolgreich verknüpft.') - + messages.success( + request, f'Dokument "{dokument.titel}" wurde erfolgreich verknüpft.' + ) + # Zurück zur verknüpften Entität leiten if dokument.verpachtung_id: - return redirect('stiftung:verpachtung_detail', pk=dokument.verpachtung_id) + return redirect( + "stiftung:verpachtung_detail", pk=dokument.verpachtung_id + ) elif dokument.land_id: - return redirect('stiftung:land_detail', pk=dokument.land_id) + return redirect("stiftung:land_detail", pk=dokument.land_id) elif dokument.paechter_id: - return redirect('stiftung:paechter_detail', pk=dokument.paechter_id) + return redirect("stiftung:paechter_detail", pk=dokument.paechter_id) elif dokument.destinataer_id: - return redirect('stiftung:destinataer_detail', pk=dokument.destinataer_id) + return redirect( + "stiftung:destinataer_detail", pk=dokument.destinataer_id + ) elif dokument.foerderung_id: - return redirect('stiftung:foerderung_detail', pk=dokument.foerderung_id) + return redirect("stiftung:foerderung_detail", pk=dokument.foerderung_id) else: - return redirect('stiftung:dokument_detail', pk=dokument.pk) + return redirect("stiftung:dokument_detail", pk=dokument.pk) else: # Initial-Werte aus GET-Parametern setzen initial_data = {} - if request.GET.get('verpachtung'): - initial_data['verpachtung_id'] = request.GET.get('verpachtung') - if request.GET.get('land'): - initial_data['land_id'] = request.GET.get('land') - if request.GET.get('paechter'): - initial_data['paechter_id'] = request.GET.get('paechter') - if request.GET.get('destinataer'): - initial_data['destinataer_id'] = request.GET.get('destinataer') - if request.GET.get('foerderung'): - initial_data['foerderung_id'] = request.GET.get('foerderung') - + if request.GET.get("verpachtung"): + initial_data["verpachtung_id"] = request.GET.get("verpachtung") + if request.GET.get("land"): + initial_data["land_id"] = request.GET.get("land") + if request.GET.get("paechter"): + initial_data["paechter_id"] = request.GET.get("paechter") + if request.GET.get("destinataer"): + initial_data["destinataer_id"] = request.GET.get("destinataer") + if request.GET.get("foerderung"): + initial_data["foerderung_id"] = request.GET.get("foerderung") + form = DokumentLinkForm(initial=initial_data) - + context = { - 'form': form, - 'title': 'Neues Dokument verknüpfen', + "form": form, + "title": "Neues Dokument verknüpfen", } - return render(request, 'stiftung/dokument_form.html', context) + return render(request, "stiftung/dokument_form.html", context) + @login_required def dokument_update(request, pk): """Update an existing document link""" dokument = get_object_or_404(DokumentLink, pk=pk) - - if request.method == 'POST': + + if request.method == "POST": form = DokumentLinkForm(request.POST, instance=dokument) if form.is_valid(): form.save() - messages.success(request, f'Dokument "{dokument.titel}" wurde erfolgreich aktualisiert.') - return redirect('stiftung:dokument_detail', pk=dokument.pk) + messages.success( + request, f'Dokument "{dokument.titel}" wurde erfolgreich aktualisiert.' + ) + return redirect("stiftung:dokument_detail", pk=dokument.pk) else: form = DokumentLinkForm(instance=dokument) - + context = { - 'form': form, - 'dokument': dokument, - 'title': f'Dokument bearbeiten: {dokument}', + "form": form, + "dokument": dokument, + "title": f"Dokument bearbeiten: {dokument}", } - return render(request, 'stiftung/dokument_form.html', context) + return render(request, "stiftung/dokument_form.html", context) + @login_required def dokument_delete(request, pk): """Delete a document link""" dokument = get_object_or_404(DokumentLink, pk=pk) - - if request.method == 'POST': + + if request.method == "POST": dokument.delete() - messages.success(request, f'Dokument "{dokument.titel}" wurde erfolgreich gelöscht.') - return redirect('stiftung:dokument_list') - + messages.success( + request, f'Dokument "{dokument.titel}" wurde erfolgreich gelöscht.' + ) + return redirect("stiftung:dokument_list") + context = { - 'dokument': dokument, - 'title': f'Dokument löschen: {dokument}', + "dokument": dokument, + "title": f"Dokument löschen: {dokument}", } - return render(request, 'stiftung/dokument_confirm_delete.html', context) + return render(request, "stiftung/dokument_confirm_delete.html", context) + # Legacy document views removed - use dokument_management instead + # Jahresbericht Views @login_required def bericht_list(request): """List available reports""" # Get available years from data - jahre = sorted(set( - list(Foerderung.objects.values_list('jahr', flat=True)) + - list(LandVerpachtung.objects.values_list('pachtbeginn__year', flat=True)) - ), reverse=True) - + jahre = sorted( + set( + list(Foerderung.objects.values_list("jahr", flat=True)) + + list(LandVerpachtung.objects.values_list("pachtbeginn__year", flat=True)) + ), + reverse=True, + ) + # Statistics for overview tiles (removed legacy Person and Verpachtung) total_destinataere = Destinataer.objects.count() total_laendereien = Land.objects.count() @@ -1846,304 +2235,355 @@ def bericht_list(request): total_foerderungen = Foerderung.objects.count() context = { - 'jahre': jahre, - 'title': 'Berichte', - 'total_destinataere': total_destinataere, - 'total_laendereien': total_laendereien, - 'total_verpachtungen': total_verpachtungen, - 'total_foerderungen': total_foerderungen, + "jahre": jahre, + "title": "Berichte", + "total_destinataere": total_destinataere, + "total_laendereien": total_laendereien, + "total_verpachtungen": total_verpachtungen, + "total_foerderungen": total_foerderungen, } - return render(request, 'stiftung/bericht_list.html', context) + return render(request, "stiftung/bericht_list.html", context) + @login_required def jahresbericht_generate(request, jahr): """Generate annual report for a specific year""" # Get data for the year - foerderungen = Foerderung.objects.filter(jahr=jahr).select_related('person') + foerderungen = Foerderung.objects.filter(jahr=jahr).select_related("person") verpachtungen = LandVerpachtung.objects.filter( - pachtbeginn__year__lte=jahr, - pachtende__year__gte=jahr - ).select_related('land', 'paechter') - + pachtbeginn__year__lte=jahr, pachtende__year__gte=jahr + ).select_related("land", "paechter") + # Calculate statistics - total_foerderungen = foerderungen.aggregate(total=Sum('betrag'))['total'] or 0 - total_pachtzins = verpachtungen.aggregate(total=Sum('pachtzins_pauschal'))['total'] or 0 - + total_foerderungen = foerderungen.aggregate(total=Sum("betrag"))["total"] or 0 + total_pachtzins = ( + verpachtungen.aggregate(total=Sum("pachtzins_pauschal"))["total"] or 0 + ) + context = { - 'jahr': jahr, - 'foerderungen': foerderungen, - 'verpachtungen': verpachtungen, - 'total_foerderungen': total_foerderungen, - 'total_pachtzins': total_pachtzins, - 'title': f'Jahresbericht {jahr}', + "jahr": jahr, + "foerderungen": foerderungen, + "verpachtungen": verpachtungen, + "total_foerderungen": total_foerderungen, + "total_pachtzins": total_pachtzins, + "title": f"Jahresbericht {jahr}", } - return render(request, 'stiftung/jahresbericht.html', context) + return render(request, "stiftung/jahresbericht.html", context) + @login_required def jahresbericht_generate_redirect(request): """Redirects the GET form without path param to the proper URL using the provided query param 'jahr'.""" - jahr = request.GET.get('jahr') + jahr = request.GET.get("jahr") if jahr and str(jahr).isdigit(): - return redirect('stiftung:jahresbericht_generate', jahr=int(jahr)) - messages.error(request, 'Bitte wählen Sie ein gültiges Jahr aus.') - return redirect('stiftung:bericht_list') + return redirect("stiftung:jahresbericht_generate", jahr=int(jahr)) + messages.error(request, "Bitte wählen Sie ein gültiges Jahr aus.") + return redirect("stiftung:bericht_list") + @login_required def jahresbericht_pdf(request, jahr): """Generate PDF version of annual report""" from django.http import HttpResponse - from weasyprint import HTML from django.template.loader import render_to_string - + from weasyprint import HTML + # Get data for the year - foerderungen = Foerderung.objects.filter(jahr=jahr).select_related('person') + foerderungen = Foerderung.objects.filter(jahr=jahr).select_related("person") verpachtungen = LandVerpachtung.objects.filter( - pachtbeginn__year__lte=jahr, - pachtende__year__gte=jahr - ).select_related('land', 'paechter') - + pachtbeginn__year__lte=jahr, pachtende__year__gte=jahr + ).select_related("land", "paechter") + # Calculate statistics - total_foerderungen = foerderungen.aggregate(total=Sum('betrag'))['total'] or 0 - total_pachtzins = verpachtungen.aggregate(total=Sum('pachtzins_pauschal'))['total'] or 0 - + total_foerderungen = foerderungen.aggregate(total=Sum("betrag"))["total"] or 0 + total_pachtzins = ( + verpachtungen.aggregate(total=Sum("pachtzins_pauschal"))["total"] or 0 + ) + context = { - 'jahr': jahr, - 'foerderungen': foerderungen, - 'verpachtungen': verpachtungen, - 'total_foerderungen': total_foerderungen, - 'total_pachtzins': total_pachtzins, + "jahr": jahr, + "foerderungen": foerderungen, + "verpachtungen": verpachtungen, + "total_foerderungen": total_foerderungen, + "total_pachtzins": total_pachtzins, } - + # Render HTML - html_string = render_to_string('stiftung/jahresbericht.html', context) - + html_string = render_to_string("stiftung/jahresbericht.html", context) + # Generate PDF pdf = HTML(string=html_string).write_pdf() - + # Create response - response = HttpResponse(pdf, content_type='application/pdf') - response['Content-Disposition'] = f'attachment; filename="jahresbericht_{jahr}.pdf"' - + response = HttpResponse(pdf, content_type="application/pdf") + response["Content-Disposition"] = f'attachment; filename="jahresbericht_{jahr}.pdf"' + return response + # Dashboard Views @login_required def dashboard(request): # Foerderung statistics (Person statistics removed - was legacy Verpachtung system) - total_foerderungen = Foerderung.objects.aggregate(total=Sum('betrag'))['total'] or 0 - + total_foerderungen = Foerderung.objects.aggregate(total=Sum("betrag"))["total"] or 0 + # Land statistics total_land = Land.objects.count() active_land = Land.objects.filter(aktiv=True).count() - total_flaeche = Land.objects.aggregate(total=Sum('groesse_qm'))['total'] or 0 - + total_flaeche = Land.objects.aggregate(total=Sum("groesse_qm"))["total"] or 0 + # Calculate total verpachtet from active verpachtungen - total_verpachtet = LandVerpachtung.objects.filter(status='aktiv').aggregate( - total=Sum('verpachtete_flaeche') - )['total'] or 0 - + total_verpachtet = ( + LandVerpachtung.objects.filter(status="aktiv").aggregate( + total=Sum("verpachtete_flaeche") + )["total"] + or 0 + ) + # Verpachtung statistics total_verpachtungen = LandVerpachtung.objects.count() - active_verpachtungen = LandVerpachtung.objects.filter(status='aktiv').count() - total_pachtzins = LandVerpachtung.objects.filter(status='aktiv').aggregate( - total=Sum('pachtzins_pauschal') - )['total'] or 0 - + active_verpachtungen = LandVerpachtung.objects.filter(status="aktiv").count() + total_pachtzins = ( + LandVerpachtung.objects.filter(status="aktiv").aggregate( + total=Sum("pachtzins_pauschal") + )["total"] + or 0 + ) + # Recent activities - recent_lands = Land.objects.order_by('-erstellt_am')[:5] - recent_verpachtungen = LandVerpachtung.objects.select_related('land', 'paechter').order_by('-erstellt_am')[:5] - + recent_lands = Land.objects.order_by("-erstellt_am")[:5] + recent_verpachtungen = LandVerpachtung.objects.select_related( + "land", "paechter" + ).order_by("-erstellt_am")[:5] + # Dokumentenübersicht - dokumente_uebersicht = DokumentLink.objects.all().order_by('-id')[:10] - + dokumente_uebersicht = DokumentLink.objects.all().order_by("-id")[:10] + # Verfügbare Paperless-Dokumente für Dashboard available_paperless_docs = [] from stiftung.utils.config import get_paperless_config - + config = get_paperless_config() - url = config['api_url'] - token = config['api_token'] - + url = config["api_url"] + token = config["api_token"] + if url and token: try: - base_url = url.rstrip('/api') if url.endswith('/api') else url - headers = {'Authorization': f'Token {token}'} - + base_url = url.rstrip("/api") if url.endswith("/api") else url + headers = {"Authorization": f"Token {token}"} + # Alle verfügbaren Dokumente abrufen (mit Paginierung) all_dokumente = [] page = 1 page_size = 100 - + while True: - response = requests.get(f"{base_url}/api/documents/?page={page}&page_size={page_size}", headers=headers, timeout=10) + response = requests.get( + f"{base_url}/api/documents/?page={page}&page_size={page_size}", + headers=headers, + timeout=10, + ) response.raise_for_status() data = response.json() - - all_dokumente.extend(data.get('results', [])) - - if not data.get('next'): + + all_dokumente.extend(data.get("results", [])) + + if not data.get("next"): break page += 1 - + # Stiftung-Dokumente filtern for doc in all_dokumente: try: tags = [] - doc_tags = doc.get('tags', []) - + doc_tags = doc.get("tags", []) + if isinstance(doc_tags, list): for tag in doc_tags: - if isinstance(tag, dict) and 'name' in tag: - tags.append(tag['name']) + if isinstance(tag, dict) and "name" in tag: + tags.append(tag["name"]) elif isinstance(tag, str): tags.append(tag) elif isinstance(tag, int): tags.append(f"Tag_{tag}") elif isinstance(doc_tags, str): - tags = [tag.strip() for tag in doc_tags.split(',')] - - if any(tag in [config['destinataere_tag'], config['land_tag'], config['admin_tag']] for tag in tags): + tags = [tag.strip() for tag in doc_tags.split(",")] + + if any( + tag + in [ + config["destinataere_tag"], + config["land_tag"], + config["admin_tag"], + ] + for tag in tags + ): bereits_verknuepft = DokumentLink.objects.filter( - paperless_document_id=doc['id'] + paperless_document_id=doc["id"] ).exists() - + if not bereits_verknuepft: - available_paperless_docs.append({ - 'id': doc['id'], - 'title': doc.get('title', f'Dokument {doc["id"]}'), - 'created_date': doc.get('created_date', ''), - 'tags': tags, - 'thumbnail_url': f"{base_url}/api/documents/{doc['id']}/thumb/", - 'document_url': f"{base_url}/documents/{doc['id']}/", - }) + available_paperless_docs.append( + { + "id": doc["id"], + "title": doc.get("title", f'Dokument {doc["id"]}'), + "created_date": doc.get("created_date", ""), + "tags": tags, + "thumbnail_url": f"{base_url}/api/documents/{doc['id']}/thumb/", + "document_url": f"{base_url}/documents/{doc['id']}/", + } + ) except Exception: continue - + # Nach Erstellungsdatum sortieren (neueste zuerst) - available_paperless_docs.sort(key=lambda x: x['created_date'], reverse=True) - + available_paperless_docs.sort(key=lambda x: x["created_date"], reverse=True) + except Exception: pass - + context = { # Person statistics removed - was legacy Verpachtung system - 'total_foerderungen': total_foerderungen, - 'total_land': total_land, - 'active_land': active_land, - 'total_flaeche': total_flaeche, - 'total_verpachtet': total_verpachtet, - 'total_verpachtungen': total_verpachtungen, - 'active_verpachtungen': active_verpachtungen, - 'total_pachtzins': total_pachtzins, - 'recent_lands': recent_lands, - 'recent_verpachtungen': recent_verpachtungen, - 'dokumente_uebersicht': dokumente_uebersicht, - 'available_paperless_docs': available_paperless_docs, + "total_foerderungen": total_foerderungen, + "total_land": total_land, + "active_land": active_land, + "total_flaeche": total_flaeche, + "total_verpachtet": total_verpachtet, + "total_verpachtungen": total_verpachtungen, + "active_verpachtungen": active_verpachtungen, + "total_pachtzins": total_pachtzins, + "recent_lands": recent_lands, + "recent_verpachtungen": recent_verpachtungen, + "dokumente_uebersicht": dokumente_uebersicht, + "available_paperless_docs": available_paperless_docs, } - return render(request, 'stiftung/dashboard.html', context) + return render(request, "stiftung/dashboard.html", context) + # API Views for AJAX @login_required def land_stats_api(request): """API endpoint for land statistics""" - if request.method == 'GET': - gemeinde = request.GET.get('gemeinde', '') - + if request.method == "GET": + gemeinde = request.GET.get("gemeinde", "") + if gemeinde: lands = Land.objects.filter(gemeinde=gemeinde) else: lands = Land.objects.all() - + stats = { - 'total_count': lands.count(), - 'total_flaeche': float(lands.aggregate(total=Sum('groesse_qm'))['total'] or 0), - 'total_verpachtet': float(LandVerpachtung.objects.filter(status='aktiv', land__in=lands).aggregate( - total=Sum('verpachtete_flaeche') - )['total'] or 0), - 'avg_verpachtungsgrad': 0, + "total_count": lands.count(), + "total_flaeche": float( + lands.aggregate(total=Sum("groesse_qm"))["total"] or 0 + ), + "total_verpachtet": float( + LandVerpachtung.objects.filter( + status="aktiv", land__in=lands + ).aggregate(total=Sum("verpachtete_flaeche"))["total"] + or 0 + ), + "avg_verpachtungsgrad": 0, } - - if stats['total_flaeche'] > 0: - stats['avg_verpachtungsgrad'] = (stats['total_verpachtet'] / stats['total_flaeche']) * 100 - + + if stats["total_flaeche"] > 0: + stats["avg_verpachtungsgrad"] = ( + stats["total_verpachtet"] / stats["total_flaeche"] + ) * 100 + return JsonResponse(stats) - - return JsonResponse({'error': 'Invalid request method'}, status=400) -@api_view(['GET']) + return JsonResponse({"error": "Invalid request method"}, status=400) + + +@api_view(["GET"]) def health(_request): - return Response({'status': 'ok'}) + return Response({"status": "ok"}) -@api_view(['GET']) + +@api_view(["GET"]) def paperless_ping(_request): from stiftung.utils.config import get_paperless_config - + config = get_paperless_config() - url = config['api_url'] - token = config['api_token'] + url = config["api_url"] + token = config["api_token"] if not url or not token: - return Response({'ok': False, 'reason': 'Paperless API not configured'}, status=400) + return Response( + {"ok": False, "reason": "Paperless API not configured"}, status=400 + ) try: # Entferne /api vom Ende der URL falls vorhanden - base_url = url.rstrip('/api') if url.endswith('/api') else url - r = requests.get(f"{base_url}/api/tags/", headers={"Authorization": f"Token {token}"}, timeout=5) - return Response({'ok': r.ok, 'status_code': r.status_code}) + base_url = url.rstrip("/api") if url.endswith("/api") else url + r = requests.get( + f"{base_url}/api/tags/", + headers={"Authorization": f"Token {token}"}, + timeout=5, + ) + return Response({"ok": r.ok, "status_code": r.status_code}) except Exception as e: - return Response({'ok': False, 'error': str(e)}, status=500) + return Response({"ok": False, "error": str(e)}, status=500) -@api_view(['GET']) + +@api_view(["GET"]) def paperless_documents(request): """Holt Dokumente aus Paperless mit den erforderlichen Tags. Optionales Polling: ?poll=1 wartet kurz und versucht erneut, damit frisch ingestete Dokumente in Paperless erscheinen, bevor die Liste zurückgegeben wird. """ from stiftung.utils.config import get_paperless_config - + config = get_paperless_config() - url = config['api_url'] - token = config['api_token'] - required_tag = config['destinataere_tag'] - land_tag = config['land_tag'] - admin_tag = config['admin_tag'] - destinaere_tag_id = config['destinataere_tag_id'] - land_tag_id = config['land_tag_id'] - admin_tag_id = config['admin_tag_id'] - + url = config["api_url"] + token = config["api_token"] + required_tag = config["destinataere_tag"] + land_tag = config["land_tag"] + admin_tag = config["admin_tag"] + destinaere_tag_id = config["destinataere_tag_id"] + land_tag_id = config["land_tag_id"] + admin_tag_id = config["admin_tag_id"] + if not url or not token: - return Response({ - 'error': 'Paperless API not configured', - 'message': 'Please set PAPERLESS_API_URL and PAPERLESS_API_TOKEN in your environment variables', - 'documents': [], - 'total_destinaere': 0, - 'total_land': 0, - 'total_admin': 0, - 'total_all': 0 - }, status=400) - + return Response( + { + "error": "Paperless API not configured", + "message": "Please set PAPERLESS_API_URL and PAPERLESS_API_TOKEN in your environment variables", + "documents": [], + "total_destinaere": 0, + "total_land": 0, + "total_admin": 0, + "total_all": 0, + }, + status=400, + ) + try: # Entferne /api vom Ende der URL falls vorhanden - base_url = url.rstrip('/api') if url.endswith('/api') else url - headers = {'Authorization': f'Token {token}'} + base_url = url.rstrip("/api") if url.endswith("/api") else url + headers = {"Authorization": f"Token {token}"} def fetch_tagged(): # mit ordering=-created neueste zuerst dest_resp = requests.get( f"{base_url}/api/documents/?tags__id={destinaere_tag_id}&ordering=-created", - headers=headers, timeout=10 + headers=headers, + timeout=10, ) dest_resp.raise_for_status() dest_docs = dest_resp.json() land_resp = requests.get( f"{base_url}/api/documents/?tags__id={land_tag_id}&ordering=-created", - headers=headers, timeout=10 + headers=headers, + timeout=10, ) land_resp.raise_for_status() land_docs = land_resp.json() admin_resp = requests.get( f"{base_url}/api/documents/?tags__id={admin_tag_id}&ordering=-created", - headers=headers, timeout=10 + headers=headers, + timeout=10, ) admin_resp.raise_for_status() admin_docs = admin_resp.json() @@ -2153,471 +2593,555 @@ def paperless_documents(request): dest_docs, land_docs, admin_docs = fetch_tagged() # Optionales kurzes Polling, wenn angefordert - if request.GET.get('poll') in ('1', 'true', 'yes'): - start_total = sum([ - dest_docs.get('count', 0), - land_docs.get('count', 0), - admin_docs.get('count', 0), - ]) + if request.GET.get("poll") in ("1", "true", "yes"): + start_total = sum( + [ + dest_docs.get("count", 0), + land_docs.get("count", 0), + admin_docs.get("count", 0), + ] + ) deadline = time.time() + 6.0 # bis zu 6 Sekunden warten while time.time() < deadline: time.sleep(1.0) d2, l2, a2 = fetch_tagged() - new_total = sum([d2.get('count', 0), l2.get('count', 0), a2.get('count', 0)]) + new_total = sum( + [d2.get("count", 0), l2.get("count", 0), a2.get("count", 0)] + ) if new_total > start_total: dest_docs, land_docs, admin_docs = d2, l2, a2 break # Alle Dokumente zusammenfassen all_documents = [] - for doc in dest_docs.get('results', []): - doc['tag_category'] = 'destinaere' + for doc in dest_docs.get("results", []): + doc["tag_category"] = "destinaere" all_documents.append(doc) - for doc in land_docs.get('results', []): - doc['tag_category'] = 'land' + for doc in land_docs.get("results", []): + doc["tag_category"] = "land" all_documents.append(doc) - for doc in admin_docs.get('results', []): - doc['tag_category'] = 'admin' + for doc in admin_docs.get("results", []): + doc["tag_category"] = "admin" all_documents.append(doc) - return Response({ - 'documents': all_documents, - 'total_destinaere': dest_docs.get('count', 0), - 'total_land': land_docs.get('count', 0), - 'total_admin': admin_docs.get('count', 0), - 'total_all': len(all_documents) - }) - + return Response( + { + "documents": all_documents, + "total_destinaere": dest_docs.get("count", 0), + "total_land": land_docs.get("count", 0), + "total_admin": admin_docs.get("count", 0), + "total_all": len(all_documents), + } + ) + except requests.exceptions.RequestException as e: - return Response({ - 'error': f'API-Fehler: {e}', - 'message': 'Could not connect to Paperless API. Please check your configuration.', - 'documents': [], - 'total_destinaere': 0, - 'total_land': 0, - 'total_admin': 0, - 'total_all': 0 - }, status=500) + return Response( + { + "error": f"API-Fehler: {e}", + "message": "Could not connect to Paperless API. Please check your configuration.", + "documents": [], + "total_destinaere": 0, + "total_land": 0, + "total_admin": 0, + "total_all": 0, + }, + status=500, + ) except Exception as e: - return Response({ - 'error': f'Unerwarteter Fehler: {e}', - 'message': 'An unexpected error occurred while fetching documents.', - 'documents': [], - 'total_destinaere': 0, - 'total_land': 0, - 'total_admin': 0, - 'total_all': 0 - }, status=500) + return Response( + { + "error": f"Unerwarteter Fehler: {e}", + "message": "An unexpected error occurred while fetching documents.", + "documents": [], + "total_destinaere": 0, + "total_land": 0, + "total_admin": 0, + "total_all": 0, + }, + status=500, + ) + # Legacy dokument_integration view removed - use dokument_management instead -@api_view(['GET']) + +@api_view(["GET"]) def paperless_debug(request): """Debug-View für Paperless-Integration""" from stiftung.utils.config import get_paperless_config - + config = get_paperless_config() - url = config['api_url'] - token = config['api_token'] - required_tag = config['destinataere_tag'] - land_tag = config['land_tag'] - admin_tag = config['admin_tag'] - destinaere_tag_id = config['destinataere_tag_id'] - land_tag_id = config['land_tag_id'] - admin_tag_id = config['admin_tag_id'] - + url = config["api_url"] + token = config["api_token"] + required_tag = config["destinataere_tag"] + land_tag = config["land_tag"] + admin_tag = config["admin_tag"] + destinaere_tag_id = config["destinataere_tag_id"] + land_tag_id = config["land_tag_id"] + admin_tag_id = config["admin_tag_id"] + if not url or not token: - return Response({'error': 'Paperless API not configured'}, status=400) - + return Response({"error": "Paperless API not configured"}, status=400) + try: # Entferne /api vom Ende der URL falls vorhanden - base_url = url.rstrip('/api') if url.endswith('/api') else url - - headers = {'Authorization': f'Token {token}'} - + base_url = url.rstrip("/api") if url.endswith("/api") else url + + headers = {"Authorization": f"Token {token}"} + # Alle Tags abrufen - tags_response = requests.get(f"{base_url}/api/tags/?page_size=1000", headers=headers, timeout=10) + tags_response = requests.get( + f"{base_url}/api/tags/?page_size=1000", headers=headers, timeout=10 + ) tags_response.raise_for_status() tags_data = tags_response.json() - + # Alle Tags durchsuchen - all_tags = tags_data.get('results', []) + all_tags = tags_data.get("results", []) exact_match_destinaere = None exact_match_land = None exact_match_admin = None similar_tags = [] - + # Nach den neuen Tag-Namen suchen (mit Unterstrichen) for tag in all_tags: - tag_name = tag.get('name', '') - tag_id = tag.get('id') - + tag_name = tag.get("name", "") + tag_id = tag.get("id") + # Suche nach den neuen Tag-Namen if tag_name == "Stiftung_Destinatäre": - exact_match_destinaere = {'id': tag_id, 'name': tag_name} + exact_match_destinaere = {"id": tag_id, "name": tag_name} elif tag_name == "Stiftung_Land_und_Pächter": - exact_match_land = {'id': tag_id, 'name': tag_name} + exact_match_land = {"id": tag_id, "name": tag_name} elif tag_name == "Stiftung_Administration": - exact_match_admin = {'id': tag_id, 'name': tag_name} - + exact_match_admin = {"id": tag_id, "name": tag_name} + # Ähnliche Tags finden - if 'stiftung' in tag_name.lower() or 'destinat' in tag_name.lower() or 'land' in tag_name.lower() or 'admin' in tag_name.lower(): - similar_tags.append({'id': tag_id, 'name': tag_name}) - + if ( + "stiftung" in tag_name.lower() + or "destinat" in tag_name.lower() + or "land" in tag_name.lower() + or "admin" in tag_name.lower() + ): + similar_tags.append({"id": tag_id, "name": tag_name}) + # Alle Tag-Namen sammeln - all_tag_names = [tag.get('name', '') for tag in all_tags] - + all_tag_names = [tag.get("name", "") for tag in all_tags] + # Dokumente abrufen - documents_response = requests.get(f"{base_url}/api/documents/?page_size=10", headers=headers, timeout=10) + documents_response = requests.get( + f"{base_url}/api/documents/?page_size=10", headers=headers, timeout=10 + ) documents_response.raise_for_status() documents_data = documents_response.json() - + # Stiftung-Dokumente finden (mit Tag 21 "Stiftung") stiftung_documents = [] - for doc in documents_data.get('results', []): - doc_tags = doc.get('tags', []) + for doc in documents_data.get("results", []): + doc_tags = doc.get("tags", []) if 21 in doc_tags: # Tag 21 ist "Stiftung" stiftung_documents.append(doc) - + # Sample-Dokumente mit Tag-Namen anreichern - sample_documents = documents_data.get('results', [])[:5] + sample_documents = documents_data.get("results", [])[:5] enriched_documents = [] - + for doc in sample_documents: doc_copy = doc.copy() tag_names = [] - for tag_id in doc.get('tags', []): + for tag_id in doc.get("tags", []): # Tag-Namen aus der Tag-Liste finden - tag_name = next((tag.get('name', f'Unknown({tag_id})') for tag in all_tags if tag.get('id') == tag_id), f'Unknown({tag_id})') + tag_name = next( + ( + tag.get("name", f"Unknown({tag_id})") + for tag in all_tags + if tag.get("id") == tag_id + ), + f"Unknown({tag_id})", + ) tag_names.append(tag_name) - doc_copy['tag_names'] = tag_names + doc_copy["tag_names"] = tag_names enriched_documents.append(doc_copy) - - return Response({ - 'paperless_url': url, - 'base_url': base_url, - 'required_tag': required_tag, - 'land_tag': land_tag, - 'admin_tag': admin_tag, - 'destinaere_tag_id': destinaere_tag_id, - 'land_tag_id': land_tag_id, - 'admin_tag_id': admin_tag_id, - 'exact_match_destinaere': exact_match_destinaere, - 'exact_match_land': exact_match_land, - 'exact_match_admin': exact_match_admin, - 'similar_tags': similar_tags, - 'all_tag_names': all_tag_names, - 'total_tags': len(all_tags), - 'total_documents': documents_data.get('count', 0), - 'sample_documents': sample_documents, - 'api_token_length': len(token) if token else 0, - 'enriched_documents': enriched_documents, - 'stiftung_documents': stiftung_documents - }) - - except requests.exceptions.RequestException as e: - return Response({'error': f'API-Fehler: {e}'}, status=500) - except Exception as e: - return Response({'error': f'Unerwarteter Fehler: {e}'}, status=500) -@api_view(['GET']) + return Response( + { + "paperless_url": url, + "base_url": base_url, + "required_tag": required_tag, + "land_tag": land_tag, + "admin_tag": admin_tag, + "destinaere_tag_id": destinaere_tag_id, + "land_tag_id": land_tag_id, + "admin_tag_id": admin_tag_id, + "exact_match_destinaere": exact_match_destinaere, + "exact_match_land": exact_match_land, + "exact_match_admin": exact_match_admin, + "similar_tags": similar_tags, + "all_tag_names": all_tag_names, + "total_tags": len(all_tags), + "total_documents": documents_data.get("count", 0), + "sample_documents": sample_documents, + "api_token_length": len(token) if token else 0, + "enriched_documents": enriched_documents, + "stiftung_documents": stiftung_documents, + } + ) + + except requests.exceptions.RequestException as e: + return Response({"error": f"API-Fehler: {e}"}, status=500) + except Exception as e: + return Response({"error": f"Unerwarteter Fehler: {e}"}, status=500) + + +@api_view(["GET"]) def paperless_tags_only(request): """Holt nur die Tag-Liste aus Paperless - ohne Dokumente""" from stiftung.utils.config import get_paperless_config - + config = get_paperless_config() - url = config['api_url'] - token = config['api_token'] - + url = config["api_url"] + token = config["api_token"] + if not url or not token: - return Response({'error': 'Paperless API not configured'}, status=400) - + return Response({"error": "Paperless API not configured"}, status=400) + try: # Entferne /api vom Ende der URL falls vorhanden - base_url = url.rstrip('/api') if url.endswith('/api') else url - + base_url = url.rstrip("/api") if url.endswith("/api") else url + # Alle Tags abrufen (mit großer page_size) - headers = {'Authorization': f'Token {token}'} - + headers = {"Authorization": f"Token {token}"} + # Erste Anfrage mit großer page_size - tags_response = requests.get(f"{base_url}/api/tags/?page_size=1000", headers=headers, timeout=10) + tags_response = requests.get( + f"{base_url}/api/tags/?page_size=1000", headers=headers, timeout=10 + ) tags_response.raise_for_status() tags_data = tags_response.json() - + all_tags = [] - + # Erste Seite verarbeiten - for tag in tags_data.get('results', []): + for tag in tags_data.get("results", []): tag_detail = { - 'id': tag.get('id'), - 'name': tag.get('name', ''), - 'slug': tag.get('slug', ''), - 'color': tag.get('color', ''), - 'text_color': tag.get('text_color', ''), - 'match': tag.get('match', ''), - 'matching_algorithm': tag.get('matching_algorithm'), - 'is_inbox_tag': tag.get('is_inbox_tag'), - 'document_count': tag.get('document_count', 0) + "id": tag.get("id"), + "name": tag.get("name", ""), + "slug": tag.get("slug", ""), + "color": tag.get("color", ""), + "text_color": tag.get("text_color", ""), + "match": tag.get("match", ""), + "matching_algorithm": tag.get("matching_algorithm"), + "is_inbox_tag": tag.get("is_inbox_tag"), + "document_count": tag.get("document_count", 0), } all_tags.append(tag_detail) - + # Weitere Seiten abrufen falls vorhanden - next_url = tags_data.get('next') + next_url = tags_data.get("next") while next_url: next_response = requests.get(next_url, headers=headers, timeout=10) next_response.raise_for_status() next_data = next_response.json() - - for tag in next_data.get('results', []): + + for tag in next_data.get("results", []): tag_detail = { - 'id': tag.get('id'), - 'name': tag.get('name', ''), - 'slug': tag.get('slug', ''), - 'color': tag.get('color', ''), - 'text_color': tag.get('text_color', ''), - 'match': tag.get('match', ''), - 'matching_algorithm': tag.get('matching_algorithm'), - 'is_inbox_tag': tag.get('is_inbox_tag'), - 'document_count': tag.get('document_count', 0) + "id": tag.get("id"), + "name": tag.get("name", ""), + "slug": tag.get("slug", ""), + "color": tag.get("color", ""), + "text_color": tag.get("text_color", ""), + "match": tag.get("match", ""), + "matching_algorithm": tag.get("matching_algorithm"), + "is_inbox_tag": tag.get("is_inbox_tag"), + "document_count": tag.get("document_count", 0), } all_tags.append(tag_detail) - - next_url = next_data.get('next') - - # Nach ID sortieren - all_tags.sort(key=lambda x: x['id']) - - return Response({ - 'total_tags': len(all_tags), - 'tags': all_tags, - 'tag_ids': [tag['id'] for tag in all_tags], - 'tag_names': [tag['name'] for tag in all_tags], - 'api_info': { - 'page_size_used': 1000, - 'total_count_from_api': tags_data.get('count', 0) - } - }) - - except requests.exceptions.RequestException as e: - return Response({'error': f'API-Fehler: {e}'}, status=500) - except Exception as e: - return Response({'error': f'Unerwarteter Fehler: {e}'}, status=500) -@api_view(['GET']) + next_url = next_data.get("next") + + # Nach ID sortieren + all_tags.sort(key=lambda x: x["id"]) + + return Response( + { + "total_tags": len(all_tags), + "tags": all_tags, + "tag_ids": [tag["id"] for tag in all_tags], + "tag_names": [tag["name"] for tag in all_tags], + "api_info": { + "page_size_used": 1000, + "total_count_from_api": tags_data.get("count", 0), + }, + } + ) + + except requests.exceptions.RequestException as e: + return Response({"error": f"API-Fehler: {e}"}, status=500) + except Exception as e: + return Response({"error": f"Unerwarteter Fehler: {e}"}, status=500) + + +@api_view(["GET"]) def link_document_search(request): """Sucht nach Datensätzen für die Dokument-Verknüpfung""" from django.db.models import Q - - query = request.GET.get('q', '') - category = request.GET.get('category', 'all') - + + query = request.GET.get("q", "") + category = request.GET.get("category", "all") + results = {} - - if category in ['all', 'destinataer']: + + if category in ["all", "destinataer"]: # Suche nach Destinatären destinataer_query = Q() - if query and query != 'all': + if query and query != "all": destinataer_query = ( - Q(nachname__icontains=query) | Q(vorname__icontains=query) | - Q(email__icontains=query) | Q(telefon__icontains=query) | - Q(strasse__icontains=query) | Q(ort__icontains=query) | Q(plz__icontains=query) | - Q(institution__icontains=query) | Q(familienzweig__icontains=query) | - Q(notizen__icontains=query) + Q(nachname__icontains=query) + | Q(vorname__icontains=query) + | Q(email__icontains=query) + | Q(telefon__icontains=query) + | Q(strasse__icontains=query) + | Q(ort__icontains=query) + | Q(plz__icontains=query) + | Q(institution__icontains=query) + | Q(familienzweig__icontains=query) + | Q(notizen__icontains=query) ) - + destinataer_results = Destinataer.objects.filter(destinataer_query)[:25] - results['destinataer'] = [ + results["destinataer"] = [ { - 'id': d.id, - 'name': f"{d.vorname} {d.nachname}".strip() if d.vorname else (d.institution or d.nachname), - 'type': 'Destinatär', - 'details': f"{d.strasse or ''}{(', ' + d.plz + ' ' + d.ort) if d.plz and d.ort else (' ' + d.ort) if d.ort else ''} {('• ' + d.email) if d.email else ''} {('• ' + d.institution) if d.institution else ''} {('• ' + d.familienzweig) if d.familienzweig else ''}".strip() + "id": d.id, + "name": ( + f"{d.vorname} {d.nachname}".strip() + if d.vorname + else (d.institution or d.nachname) + ), + "type": "Destinatär", + "details": f"{d.strasse or ''}{(', ' + d.plz + ' ' + d.ort) if d.plz and d.ort else (' ' + d.ort) if d.ort else ''} {('• ' + d.email) if d.email else ''} {('• ' + d.institution) if d.institution else ''} {('• ' + d.familienzweig) if d.familienzweig else ''}".strip(), } for d in destinataer_results ] - - if category in ['all', 'land']: + + if category in ["all", "land"]: # Suche nach Ländereien land_query = Q() - if query and query != 'all': + if query and query != "all": # Extract numbers from search terms like "Flur 9" or "Flurstück 11" import re - flur_match = re.search(r'flur\s*(\d+)', query, re.IGNORECASE) - flurstuck_match = re.search(r'flurstück\s*(\d+)', query, re.IGNORECASE) - + + flur_match = re.search(r"flur\s*(\d+)", query, re.IGNORECASE) + flurstuck_match = re.search(r"flurstück\s*(\d+)", query, re.IGNORECASE) + land_query = ( - Q(gemarkung__icontains=query) | Q(gemeinde__icontains=query) | - Q(flur__icontains=query) | Q(flurstueck__icontains=query) | - Q(lfd_nr__icontains=query) | Q(ew_nummer__icontains=query) | - Q(notizen__icontains=query) + Q(gemarkung__icontains=query) + | Q(gemeinde__icontains=query) + | Q(flur__icontains=query) + | Q(flurstueck__icontains=query) + | Q(lfd_nr__icontains=query) + | Q(ew_nummer__icontains=query) + | Q(notizen__icontains=query) ) - + # Add specific searches for extracted numbers if flur_match: land_query |= Q(flur__exact=flur_match.group(1)) if flurstuck_match: land_query |= Q(flurstueck__exact=flurstuck_match.group(1)) - + land_results = Land.objects.filter(land_query)[:25] - results['land'] = [ + results["land"] = [ { - 'id': l.id, - 'name': f"{l.gemarkung} - {l.gemeinde}, Flur {l.flur}, Flurstück {l.flurstueck or 'N/A'}", - 'type': 'Land', - 'details': f"Lfd.Nr: {l.lfd_nr or 'N/A'} • EW-Nr: {l.ew_nummer or 'N/A'} • Größe: {l.groesse_qm or 0} m²" + "id": l.id, + "name": f"{l.gemarkung} - {l.gemeinde}, Flur {l.flur}, Flurstück {l.flurstueck or 'N/A'}", + "type": "Land", + "details": f"Lfd.Nr: {l.lfd_nr or 'N/A'} • EW-Nr: {l.ew_nummer or 'N/A'} • Größe: {l.groesse_qm or 0} m²", } for l in land_results ] - - if category in ['all', 'verpachtung']: + + if category in ["all", "verpachtung"]: # Suche nach Verpachtungen (using new LandVerpachtung model) verpachtung_query = Q() - if query and query != 'all': + if query and query != "all": verpachtung_query = ( - Q(paechter__nachname__icontains=query) | Q(paechter__vorname__icontains=query) | - Q(paechter__ort__icontains=query) | Q(paechter__email__icontains=query) | - Q(paechter__pachtnummer__icontains=query) | - Q(land__gemarkung__icontains=query) | Q(land__gemeinde__icontains=query) | - Q(land__flur__icontains=query) | Q(land__flurstueck__icontains=query) | - Q(land__lfd_nr__icontains=query) | - Q(vertragsnummer__icontains=query) | Q(pachtzins_pauschal__icontains=query) | Q(bemerkungen__icontains=query) + Q(paechter__nachname__icontains=query) + | Q(paechter__vorname__icontains=query) + | Q(paechter__ort__icontains=query) + | Q(paechter__email__icontains=query) + | Q(paechter__pachtnummer__icontains=query) + | Q(land__gemarkung__icontains=query) + | Q(land__gemeinde__icontains=query) + | Q(land__flur__icontains=query) + | Q(land__flurstueck__icontains=query) + | Q(land__lfd_nr__icontains=query) + | Q(vertragsnummer__icontains=query) + | Q(pachtzins_pauschal__icontains=query) + | Q(bemerkungen__icontains=query) ) - - verpachtung_results = LandVerpachtung.objects.filter(verpachtung_query).select_related('paechter', 'land')[:25] - results['verpachtung'] = [ + + verpachtung_results = LandVerpachtung.objects.filter( + verpachtung_query + ).select_related("paechter", "land")[:25] + results["verpachtung"] = [ { - 'id': v.id, - 'name': f"{(v.paechter.vorname + ' ') if v.paechter and v.paechter.vorname else ''}{v.paechter.nachname if v.paechter else 'Unbekannt'} → {v.land.gemarkung}, Flur {v.land.flur}", - 'type': 'Verpachtung', - 'details': f"Vertrag: {v.vertragsnummer} • Flurstück: {v.land.flurstueck or 'N/A'} • Pachtzins: {v.pachtzins_pauschal or 'N/A'} €/Jahr • Zeitraum: {v.pachtbeginn.strftime('%Y') if v.pachtbeginn else '?'}-{v.pachtende.strftime('%Y') if v.pachtende else 'laufend'}" + "id": v.id, + "name": f"{(v.paechter.vorname + ' ') if v.paechter and v.paechter.vorname else ''}{v.paechter.nachname if v.paechter else 'Unbekannt'} → {v.land.gemarkung}, Flur {v.land.flur}", + "type": "Verpachtung", + "details": f"Vertrag: {v.vertragsnummer} • Flurstück: {v.land.flurstueck or 'N/A'} • Pachtzins: {v.pachtzins_pauschal or 'N/A'} €/Jahr • Zeitraum: {v.pachtbeginn.strftime('%Y') if v.pachtbeginn else '?'}-{v.pachtende.strftime('%Y') if v.pachtende else 'laufend'}", } for v in verpachtung_results ] - if category in ['all', 'paechter']: + if category in ["all", "paechter"]: # Suche nach Pächtern paechter_query = Q() - if query and query != 'all': + if query and query != "all": paechter_query = ( - Q(nachname__icontains=query) | Q(vorname__icontains=query) | - Q(ort__icontains=query) | Q(email__icontains=query) | - Q(telefon__icontains=query) | Q(strasse__icontains=query) | - Q(pachtnummer__icontains=query) | Q(plz__icontains=query) | - Q(notizen__icontains=query) + Q(nachname__icontains=query) + | Q(vorname__icontains=query) + | Q(ort__icontains=query) + | Q(email__icontains=query) + | Q(telefon__icontains=query) + | Q(strasse__icontains=query) + | Q(pachtnummer__icontains=query) + | Q(plz__icontains=query) + | Q(notizen__icontains=query) ) paechter_results = Paechter.objects.filter(paechter_query)[:25] - results['paechter'] = [ + results["paechter"] = [ { - 'id': p.id, - 'name': f"{(p.vorname + ' ') if p.vorname else ''}{p.nachname}" + (f" (#{p.pachtnummer})" if p.pachtnummer else ""), - 'type': 'Pächter', - 'details': f"{p.strasse or ''}{(', ' + p.plz + ' ' + p.ort) if p.plz and p.ort else (' ' + p.ort) if p.ort else ''} {('• ' + p.email) if p.email else ''} {('• Tel: ' + p.telefon) if p.telefon else ''}".strip() + "id": p.id, + "name": f"{(p.vorname + ' ') if p.vorname else ''}{p.nachname}" + + (f" (#{p.pachtnummer})" if p.pachtnummer else ""), + "type": "Pächter", + "details": f"{p.strasse or ''}{(', ' + p.plz + ' ' + p.ort) if p.plz and p.ort else (' ' + p.ort) if p.ort else ''} {('• ' + p.email) if p.email else ''} {('• Tel: ' + p.telefon) if p.telefon else ''}".strip(), } for p in paechter_results ] - - if category in ['all', 'rentmeister']: + + if category in ["all", "rentmeister"]: # Suche nach Rentmeistern from stiftung.models import Rentmeister + rentmeister_query = Q() - if query and query != 'all': + if query and query != "all": rentmeister_query = ( - Q(nachname__icontains=query) | Q(vorname__icontains=query) | - Q(ort__icontains=query) | Q(email__icontains=query) | - Q(telefon__icontains=query) | Q(strasse__icontains=query) | - Q(plz__icontains=query) | Q(notizen__icontains=query) | - Q(titel__icontains=query) | Q(mobil__icontains=query) + Q(nachname__icontains=query) + | Q(vorname__icontains=query) + | Q(ort__icontains=query) + | Q(email__icontains=query) + | Q(telefon__icontains=query) + | Q(strasse__icontains=query) + | Q(plz__icontains=query) + | Q(notizen__icontains=query) + | Q(titel__icontains=query) + | Q(mobil__icontains=query) ) rentmeister_results = Rentmeister.objects.filter(rentmeister_query)[:25] - results['rentmeister'] = [ + results["rentmeister"] = [ { - 'id': r.id, - 'name': f"{(r.anrede + ' ') if r.anrede else ''}{(r.vorname + ' ') if r.vorname else ''}{r.nachname}" + (f" ({r.titel})" if r.titel else ""), - 'type': 'Rentmeister', - 'details': f"{r.strasse or ''}{(', ' + r.plz + ' ' + r.ort) if r.plz and r.ort else (' ' + r.ort) if r.ort else ''} {('• ' + r.email) if r.email else ''} {('• Tel: ' + r.telefon) if r.telefon else ''} {('• Mobil: ' + r.mobil) if r.mobil else ''}".strip() + "id": r.id, + "name": f"{(r.anrede + ' ') if r.anrede else ''}{(r.vorname + ' ') if r.vorname else ''}{r.nachname}" + + (f" ({r.titel})" if r.titel else ""), + "type": "Rentmeister", + "details": f"{r.strasse or ''}{(', ' + r.plz + ' ' + r.ort) if r.plz and r.ort else (' ' + r.ort) if r.ort else ''} {('• ' + r.email) if r.email else ''} {('• Tel: ' + r.telefon) if r.telefon else ''} {('• Mobil: ' + r.mobil) if r.mobil else ''}".strip(), } for r in rentmeister_results ] - if category in ['all', 'abrechnung']: + if category in ["all", "abrechnung"]: # Suche nach Abrechnungen abrechnung_query = Q() - if query and query != 'all': + if query and query != "all": abrechnung_query = ( - Q(land__gemarkung__icontains=query) | Q(land__gemeinde__icontains=query) | - Q(land__flur__icontains=query) | Q(land__flurstueck__icontains=query) | - Q(land__lfd_nr__icontains=query) | Q(abrechnungsjahr__icontains=query) | - Q(bemerkungen__icontains=query) + Q(land__gemarkung__icontains=query) + | Q(land__gemeinde__icontains=query) + | Q(land__flur__icontains=query) + | Q(land__flurstueck__icontains=query) + | Q(land__lfd_nr__icontains=query) + | Q(abrechnungsjahr__icontains=query) + | Q(bemerkungen__icontains=query) ) - - abrechnung_results = LandAbrechnung.objects.filter(abrechnung_query).select_related('land')[:25] - results['abrechnung'] = [ + + abrechnung_results = LandAbrechnung.objects.filter( + abrechnung_query + ).select_related("land")[:25] + results["abrechnung"] = [ { - 'id': a.id, - 'name': f"Abrechnung {a.abrechnungsjahr} - {a.land.gemarkung}, Flur {a.land.flur}", - 'type': 'Abrechnung', - 'details': f"Flurstück: {a.land.flurstueck or 'N/A'} • Jahr: {a.abrechnungsjahr} • Grundsteuer: {a.grundsteuer_betrag or 0} € • Versicherung: {a.versicherungen_betrag or 0} €" + "id": a.id, + "name": f"Abrechnung {a.abrechnungsjahr} - {a.land.gemarkung}, Flur {a.land.flur}", + "type": "Abrechnung", + "details": f"Flurstück: {a.land.flurstueck or 'N/A'} • Jahr: {a.abrechnungsjahr} • Grundsteuer: {a.grundsteuer_betrag or 0} € • Versicherung: {a.versicherungen_betrag or 0} €", } for a in abrechnung_results ] - - if category in ['all', 'foerderung']: + + if category in ["all", "foerderung"]: # Suche nach Förderungen foerderung_query = Q() - if query and query != 'all': + if query and query != "all": foerderung_query = ( - Q(destinataer__nachname__icontains=query) | Q(destinataer__vorname__icontains=query) | - Q(destinataer__institution__icontains=query) | Q(destinataer__email__icontains=query) | - Q(jahr__icontains=query) | Q(betrag__icontains=query) | - Q(kategorie__icontains=query) | Q(status__icontains=query) | - Q(bemerkungen__icontains=query) + Q(destinataer__nachname__icontains=query) + | Q(destinataer__vorname__icontains=query) + | Q(destinataer__institution__icontains=query) + | Q(destinataer__email__icontains=query) + | Q(jahr__icontains=query) + | Q(betrag__icontains=query) + | Q(kategorie__icontains=query) + | Q(status__icontains=query) + | Q(bemerkungen__icontains=query) ) - - foerderung_results = Foerderung.objects.filter(foerderung_query).select_related('destinataer')[:25] - results['foerderung'] = [ + + foerderung_results = Foerderung.objects.filter(foerderung_query).select_related( + "destinataer" + )[:25] + results["foerderung"] = [ { - 'id': str(f.id), # Convert UUID to string for JSON serialization - 'name': f"{f.destinataer.get_full_name() if f.destinataer else 'Unbekannt'} - {f.jahr}", - 'type': 'Förderung', - 'details': f"Betrag: {f.betrag} € • Kategorie: {f.get_kategorie_display()} • Status: {f.get_status_display()} • Jahr: {f.jahr}" + "id": str(f.id), # Convert UUID to string for JSON serialization + "name": f"{f.destinataer.get_full_name() if f.destinataer else 'Unbekannt'} - {f.jahr}", + "type": "Förderung", + "details": f"Betrag: {f.betrag} € • Kategorie: {f.get_kategorie_display()} • Status: {f.get_status_display()} • Jahr: {f.jahr}", } for f in foerderung_results ] - - + return Response(results) + def create_paechter_link_for_verpachtung(paperless_id, paperless_title, verpachtung_id): """Hilfsfunktion: Erstellt automatisch eine Pächter-Verknüpfung für eine Verpachtung""" try: # Hole die LandVerpachtung und den zugehörigen Pächter - verpachtung = LandVerpachtung.objects.select_related('paechter').get(id=verpachtung_id) + verpachtung = LandVerpachtung.objects.select_related("paechter").get( + id=verpachtung_id + ) if verpachtung.paechter: # Prüfe, ob bereits eine Verknüpfung für dieses Dokument und diesen Pächter existiert existing_link = DokumentLink.objects.filter( - paperless_document_id=paperless_id, - paechter_id=verpachtung.paechter.id + paperless_document_id=paperless_id, paechter_id=verpachtung.paechter.id ).first() - + if not existing_link: # Erstelle automatische Pächter-Verknüpfung DokumentLink.objects.create( paperless_document_id=paperless_id, titel=paperless_title, - kontext='paechter', - paechter_id=verpachtung.paechter.id + kontext="paechter", + paechter_id=verpachtung.paechter.id, ) return True except (LandVerpachtung.DoesNotExist, Exception): pass return False + @csrf_exempt -@api_view(['POST']) +@api_view(["POST"]) def link_document_create(request): """Erstellt eine Verknüpfung zwischen einem Paperless-Dokument und einem Datensatz""" from django.db import transaction - + try: # Robust JSON parsing to tolerate Latin-1 encoded payloads (e.g., umlauts) try: @@ -2625,241 +3149,302 @@ def link_document_create(request): except Exception: raw = request.body try: - payload = json.loads(raw.decode('utf-8')) + payload = json.loads(raw.decode("utf-8")) except UnicodeDecodeError: - payload = json.loads(raw.decode('latin-1')) + payload = json.loads(raw.decode("latin-1")) + + paperless_id = payload.get("paperless_id") + paperless_title = payload.get("paperless_title") + paperless_url = payload.get("paperless_url") + link_type = payload.get( + "link_type" + ) # 'destinataer', 'land', 'verpachtung', 'paechter', 'abrechnung' + link_id = payload.get("link_id") - paperless_id = payload.get('paperless_id') - paperless_title = payload.get('paperless_title') - paperless_url = payload.get('paperless_url') - link_type = payload.get('link_type') # 'destinataer', 'land', 'verpachtung', 'paechter', 'abrechnung' - link_id = payload.get('link_id') - if not all([paperless_id, paperless_title, paperless_url, link_type, link_id]): - return Response({'error': 'Alle Felder sind erforderlich'}, status=400) - + return Response({"error": "Alle Felder sind erforderlich"}, status=400) + with transaction.atomic(): # Erstelle den DokumentLink dokument_link = DokumentLink.objects.create( paperless_document_id=paperless_id, # Korrigiert: 'paperless_document_id' statt 'paperless_id' - titel=paperless_title, # Korrigiert: 'titel' statt 'title' - kontext='anderes' + titel=paperless_title, # Korrigiert: 'titel' statt 'title' + kontext="anderes", ) - + # Setze die entsprechenden UUID-Felder basierend auf dem Link-Typ - if link_type == 'destinataer': + if link_type == "destinataer": dokument_link.destinataer_id = link_id - elif link_type == 'land': + elif link_type == "land": dokument_link.land_id = link_id - elif link_type == 'verpachtung': + elif link_type == "verpachtung": # Use new LandVerpachtung field instead of legacy dokument_link.land_verpachtung_id = link_id - elif link_type == 'paechter': + elif link_type == "paechter": dokument_link.paechter_id = link_id - elif link_type == 'foerderung': + elif link_type == "foerderung": dokument_link.foerderung_id = link_id - elif link_type == 'rentmeister': + elif link_type == "rentmeister": dokument_link.rentmeister_id = link_id - elif link_type == 'abrechnung': + elif link_type == "abrechnung": dokument_link.abrechnung_id = link_id - + dokument_link.save() - + # Log the document linking action from stiftung.audit import log_link + try: # Get the linked entity name for logging entity_name = paperless_title - if link_type == 'destinataer': + if link_type == "destinataer": from stiftung.models import Destinataer + entity = Destinataer.objects.get(id=link_id) target_name = entity.get_full_name() - elif link_type == 'land': + elif link_type == "land": from stiftung.models import Land + entity = Land.objects.get(id=link_id) target_name = str(entity) - elif link_type == 'paechter': + elif link_type == "paechter": from stiftung.models import Paechter + entity = Paechter.objects.get(id=link_id) target_name = f"{entity.vorname} {entity.nachname}".strip() - elif link_type == 'foerderung': + elif link_type == "foerderung": from stiftung.models import Foerderung + entity = Foerderung.objects.get(id=link_id) target_name = f"{entity.destinataer.get_full_name() if entity.destinataer else 'Unbekannt'} - {entity.jahr}" - elif link_type == 'verpachtung': + elif link_type == "verpachtung": entity = LandVerpachtung.objects.get(id=link_id) target_name = str(entity) - elif link_type == 'rentmeister': + elif link_type == "rentmeister": from stiftung.models import Rentmeister + entity = Rentmeister.objects.get(id=link_id) target_name = entity.get_full_name() else: target_name = f"ID {link_id}" - + log_link( request=request, - entity_type='dokumentlink', + entity_type="dokumentlink", entity_id=str(dokument_link.id), entity_name=entity_name, target_type=link_type, - target_name=target_name + target_name=target_name, ) except Exception as e: # Don't fail the main operation if logging fails print(f"Audit logging failed: {e}") - + # Automatische Pächter-Verknüpfung NACH der Haupttransaktion paechter_linked = False - if link_type == 'verpachtung': - paechter_linked = create_paechter_link_for_verpachtung(paperless_id, paperless_title, link_id) - - message = f'Dokument erfolgreich mit {link_type} verknüpft' + if link_type == "verpachtung": + paechter_linked = create_paechter_link_for_verpachtung( + paperless_id, paperless_title, link_id + ) + + message = f"Dokument erfolgreich mit {link_type} verknüpft" if paechter_linked: - message += ' (automatisch auch mit Pächter verknüpft)' - - return Response({ - 'success': True, - 'message': message, - 'dokument_id': dokument_link.id - }) - + message += " (automatisch auch mit Pächter verknüpft)" + + return Response( + {"success": True, "message": message, "dokument_id": dokument_link.id} + ) + except Exception as e: - return Response({'error': f'Fehler beim Erstellen der Verknüpfung: {str(e)}'}, status=500) + return Response( + {"error": f"Fehler beim Erstellen der Verknüpfung: {str(e)}"}, status=500 + ) + # Legacy dokument_verknuepfung view removed - use dokument_management instead -@api_view(['GET']) + +@api_view(["GET"]) def link_document_list(request): """Listet alle bestehenden Dokument-Verknüpfungen auf, gruppiert nach Paperless-Document-ID""" try: - dokument_links = DokumentLink.objects.all().order_by('-id') - + dokument_links = DokumentLink.objects.all().order_by("-id") + # Group links by paperless_document_id to show multiple links per document links_by_document = {} - + for link in dokument_links: paperless_id = link.paperless_document_id - + if paperless_id not in links_by_document: links_by_document[paperless_id] = { - 'paperless_id': paperless_id, - 'title': link.titel, - 'paperless_url': f"/api/paperless/documents/{paperless_id}/", - 'links': [] + "paperless_id": paperless_id, + "title": link.titel, + "paperless_url": f"/api/paperless/documents/{paperless_id}/", + "links": [], } - + # Create link info link_info = { - 'id': str(link.id), # Ensure UUID is stringified - 'kontext': link.kontext, - 'link_type': None, - 'linked_object': None + "id": str(link.id), # Ensure UUID is stringified + "kontext": link.kontext, + "link_type": None, + "linked_object": None, } - + # Determine link type and get linked object details if link.destinataer_id: - link_info['link_type'] = 'destinataer' + link_info["link_type"] = "destinataer" try: dest = Destinataer.objects.get(id=link.destinataer_id) - link_info['linked_object'] = { - 'id': str(dest.id), - 'type': 'Destinatär', - 'name': f"{dest.vorname} {dest.nachname}".strip() if dest.vorname else dest.institution, - 'details': f"Institution: {dest.institution}" if dest.institution else f"Person: {dest.vorname} {dest.nachname}".strip() + link_info["linked_object"] = { + "id": str(dest.id), + "type": "Destinatär", + "name": ( + f"{dest.vorname} {dest.nachname}".strip() + if dest.vorname + else dest.institution + ), + "details": ( + f"Institution: {dest.institution}" + if dest.institution + else f"Person: {dest.vorname} {dest.nachname}".strip() + ), } except Destinataer.DoesNotExist: - link_info['linked_object'] = {'type': 'Destinatär', 'name': 'Gelöscht', 'details': 'Datensatz nicht mehr verfügbar'} - + link_info["linked_object"] = { + "type": "Destinatär", + "name": "Gelöscht", + "details": "Datensatz nicht mehr verfügbar", + } + elif link.land_id: - link_info['link_type'] = 'land' + link_info["link_type"] = "land" try: land = Land.objects.get(id=link.land_id) - link_info['linked_object'] = { - 'id': str(land.id), - 'type': 'Land', - 'name': f"{land.gemarkung} - {land.gemeinde}", - 'details': f"Flur: {land.flur}, Größe: {land.groesse_qm or 0} m²" + link_info["linked_object"] = { + "id": str(land.id), + "type": "Land", + "name": f"{land.gemarkung} - {land.gemeinde}", + "details": f"Flur: {land.flur}, Größe: {land.groesse_qm or 0} m²", } except Land.DoesNotExist: - link_info['linked_object'] = {'type': 'Land', 'name': 'Gelöscht', 'details': 'Datensatz nicht mehr verfügbar'} - + link_info["linked_object"] = { + "type": "Land", + "name": "Gelöscht", + "details": "Datensatz nicht mehr verfügbar", + } + elif link.paechter_id: - link_info['link_type'] = 'paechter' + link_info["link_type"] = "paechter" try: p = Paechter.objects.get(id=link.paechter_id) - link_info['linked_object'] = { - 'id': str(p.id), - 'type': 'Pächter', - 'name': f"{(p.vorname + ' ') if p.vorname else ''}{p.nachname}", - 'details': f"{p.ort or ''}" + link_info["linked_object"] = { + "id": str(p.id), + "type": "Pächter", + "name": f"{(p.vorname + ' ') if p.vorname else ''}{p.nachname}", + "details": f"{p.ort or ''}", } except Paechter.DoesNotExist: - link_info['linked_object'] = {'type': 'Pächter', 'name': 'Gelöscht', 'details': 'Datensatz nicht mehr verfügbar'} + link_info["linked_object"] = { + "type": "Pächter", + "name": "Gelöscht", + "details": "Datensatz nicht mehr verfügbar", + } elif link.land_verpachtung_id: - link_info['link_type'] = 'verpachtung' + link_info["link_type"] = "verpachtung" try: from stiftung.models import LandVerpachtung - verp = LandVerpachtung.objects.select_related('paechter', 'land').get(id=link.land_verpachtung_id) - link_info['linked_object'] = { - 'id': str(verp.id), - 'type': 'Verpachtung', - 'name': f"Verpachtung {(verp.paechter.vorname + ' ') if verp.paechter and verp.paechter.vorname else ''}{verp.paechter.nachname if verp.paechter else 'Unbekannt'}", - 'details': f"Land: {verp.land.gemarkung} - {verp.land.gemeinde}, Pächter: {(verp.paechter.vorname + ' ') if verp.paechter and verp.paechter.vorname else ''}{verp.paechter.nachname if verp.paechter else 'Unbekannt'}, Vertrag: {verp.vertragsnummer}" + + verp = LandVerpachtung.objects.select_related( + "paechter", "land" + ).get(id=link.land_verpachtung_id) + link_info["linked_object"] = { + "id": str(verp.id), + "type": "Verpachtung", + "name": f"Verpachtung {(verp.paechter.vorname + ' ') if verp.paechter and verp.paechter.vorname else ''}{verp.paechter.nachname if verp.paechter else 'Unbekannt'}", + "details": f"Land: {verp.land.gemarkung} - {verp.land.gemeinde}, Pächter: {(verp.paechter.vorname + ' ') if verp.paechter and verp.paechter.vorname else ''}{verp.paechter.nachname if verp.paechter else 'Unbekannt'}, Vertrag: {verp.vertragsnummer}", } except LandVerpachtung.DoesNotExist: - link_info['linked_object'] = {'type': 'Verpachtung', 'name': 'Gelöscht', 'details': 'Datensatz nicht mehr verfügbar'} - + link_info["linked_object"] = { + "type": "Verpachtung", + "name": "Gelöscht", + "details": "Datensatz nicht mehr verfügbar", + } + elif link.rentmeister_id: - link_info['link_type'] = 'rentmeister' + link_info["link_type"] = "rentmeister" try: from stiftung.models import Rentmeister + rentmeister = Rentmeister.objects.get(id=link.rentmeister_id) - link_info['linked_object'] = { - 'id': str(rentmeister.id), - 'type': 'Rentmeister', - 'name': f"{(rentmeister.anrede + ' ') if rentmeister.anrede else ''}{(rentmeister.vorname + ' ') if rentmeister.vorname else ''}{rentmeister.nachname}" + (f" ({rentmeister.titel})" if rentmeister.titel else ""), - 'details': f"Seit: {rentmeister.seit_datum.strftime('%d.%m.%Y') if rentmeister.seit_datum else 'Unbekannt'}" + (f", Tel: {rentmeister.telefon}" if rentmeister.telefon else "") + (f", {rentmeister.email}" if rentmeister.email else ""), - 'url': f"/geschaeftsfuehrung/rentmeister/{rentmeister.id}/" + link_info["linked_object"] = { + "id": str(rentmeister.id), + "type": "Rentmeister", + "name": f"{(rentmeister.anrede + ' ') if rentmeister.anrede else ''}{(rentmeister.vorname + ' ') if rentmeister.vorname else ''}{rentmeister.nachname}" + + (f" ({rentmeister.titel})" if rentmeister.titel else ""), + "details": f"Seit: {rentmeister.seit_datum.strftime('%d.%m.%Y') if rentmeister.seit_datum else 'Unbekannt'}" + + ( + f", Tel: {rentmeister.telefon}" + if rentmeister.telefon + else "" + ) + + (f", {rentmeister.email}" if rentmeister.email else ""), + "url": f"/geschaeftsfuehrung/rentmeister/{rentmeister.id}/", } except Rentmeister.DoesNotExist: - link_info['linked_object'] = {'type': 'Rentmeister', 'name': 'Gelöscht', 'details': 'Datensatz nicht mehr verfügbar'} - + link_info["linked_object"] = { + "type": "Rentmeister", + "name": "Gelöscht", + "details": "Datensatz nicht mehr verfügbar", + } + elif link.abrechnung_id: - link_info['link_type'] = 'abrechnung' + link_info["link_type"] = "abrechnung" try: - abrechnung = LandAbrechnung.objects.select_related('land').get(id=link.abrechnung_id) - link_info['linked_object'] = { - 'id': str(abrechnung.id), - 'type': 'Abrechnung', - 'name': f"Abrechnung {abrechnung.abrechnungsjahr} - {abrechnung.land.gemarkung}", - 'details': f"Land: {abrechnung.land.gemarkung} - {abrechnung.land.gemeinde}, Flur {abrechnung.land.flur}, Jahr {abrechnung.abrechnungsjahr}", - 'url': f"/laendereien/abrechnungen/{abrechnung.id}/" + abrechnung = LandAbrechnung.objects.select_related("land").get( + id=link.abrechnung_id + ) + link_info["linked_object"] = { + "id": str(abrechnung.id), + "type": "Abrechnung", + "name": f"Abrechnung {abrechnung.abrechnungsjahr} - {abrechnung.land.gemarkung}", + "details": f"Land: {abrechnung.land.gemarkung} - {abrechnung.land.gemeinde}, Flur {abrechnung.land.flur}, Jahr {abrechnung.abrechnungsjahr}", + "url": f"/laendereien/abrechnungen/{abrechnung.id}/", } except LandAbrechnung.DoesNotExist: - link_info['linked_object'] = {'type': 'Abrechnung', 'name': 'Gelöscht', 'details': 'Datensatz nicht mehr verfügbar'} - - links_by_document[paperless_id]['links'].append(link_info) - + link_info["linked_object"] = { + "type": "Abrechnung", + "name": "Gelöscht", + "details": "Datensatz nicht mehr verfügbar", + } + + links_by_document[paperless_id]["links"].append(link_info) + # Convert to list format for frontend results = list(links_by_document.values()) - - return Response({ - 'total_documents': len(results), - 'total_links': sum(len(doc['links']) for doc in results), - 'links': results - }) - + + return Response( + { + "total_documents": len(results), + "total_links": sum(len(doc["links"]) for doc in results), + "links": results, + } + ) + except Exception as e: - return Response({'error': f'Fehler beim Abrufen der Verknüpfungen: {str(e)}'}, status=500) + return Response( + {"error": f"Fehler beim Abrufen der Verknüpfungen: {str(e)}"}, status=500 + ) @csrf_exempt -@api_view(['POST']) +@api_view(["POST"]) def link_document_update(request): """Aktualisiert die Verknüpfung eines Dokuments (Re-Linking auf anderen Datensatz/Kontext).""" from django.db import transaction - + try: # Robust JSON parsing to tolerate Latin-1 encoded payloads (e.g., umlauts) try: @@ -2867,22 +3452,29 @@ def link_document_update(request): except Exception: raw = request.body try: - payload = json.loads(raw.decode('utf-8')) + payload = json.loads(raw.decode("utf-8")) except UnicodeDecodeError: - payload = json.loads(raw.decode('latin-1')) - - link_id = payload.get('link_id') - link_type = payload.get('link_type') # 'destinataer' | 'land' | 'verpachtung' | 'paechter' | 'rentmeister' - link_target_id = payload.get('link_id_target') + payload = json.loads(raw.decode("latin-1")) + + link_id = payload.get("link_id") + link_type = payload.get( + "link_type" + ) # 'destinataer' | 'land' | 'verpachtung' | 'paechter' | 'rentmeister' + link_target_id = payload.get("link_id_target") if not all([link_id, link_type, link_target_id]): - return Response({'error': 'link_id, link_type und link_id_target sind erforderlich'}, status=400) + return Response( + {"error": "link_id, link_type und link_id_target sind erforderlich"}, + status=400, + ) with transaction.atomic(): link = DokumentLink.objects.get(id=link_id) - old_verpachtung_id = link.verpachtung_id # Merke alte Verpachtung für Cleanup + old_verpachtung_id = ( + link.verpachtung_id + ) # Merke alte Verpachtung für Cleanup paperless_id_for_cleanup = link.paperless_document_id titel_for_new_link = link.titel - + # Reset all associations first link.destinataer_id = None link.land_id = None @@ -2892,81 +3484,84 @@ def link_document_update(request): link.rentmeister_id = None link.kontext = link_type - if link_type == 'destinataer': + if link_type == "destinataer": link.destinataer_id = link_target_id - elif link_type == 'land': + elif link_type == "land": link.land_id = link_target_id - elif link_type == 'verpachtung': + elif link_type == "verpachtung": link.verpachtung_id = link_target_id - elif link_type == 'paechter': + elif link_type == "paechter": link.paechter_id = link_target_id - elif link_type == 'foerderung': + elif link_type == "foerderung": link.foerderung_id = link_target_id - elif link_type == 'rentmeister': + elif link_type == "rentmeister": link.rentmeister_id = link_target_id else: - return Response({'error': 'Ungültiger link_type'}, status=400) + return Response({"error": "Ungültiger link_type"}, status=400) link.save() - + # Automatische Pächter-Verknüpfung und Cleanup NACH der Haupttransaktion paechter_linked = False - if link_type == 'verpachtung': + if link_type == "verpachtung": paechter_linked = create_paechter_link_for_verpachtung( paperless_id_for_cleanup, titel_for_new_link, link_target_id ) - + # Cleanup: Entferne verwaiste Pächter-Verknüpfungen wenn von Verpachtung weg geändert - if old_verpachtung_id and link_type != 'verpachtung': + if old_verpachtung_id and link_type != "verpachtung": try: - old_verpachtung = LandVerpachtung.objects.select_related('paechter').get(id=old_verpachtung_id) + old_verpachtung = LandVerpachtung.objects.select_related( + "paechter" + ).get(id=old_verpachtung_id) if old_verpachtung.paechter: # Prüfe ob noch andere Verpachtungs-Links für diesen Pächter existieren other_verpachtung_links = DokumentLink.objects.filter( paperless_document_id=paperless_id_for_cleanup, - verpachtung__paechter_id=old_verpachtung.paechter.id + verpachtung__paechter_id=old_verpachtung.paechter.id, ).exists() - + if not other_verpachtung_links: # Entferne automatisch erstellte Pächter-Verknüpfung DokumentLink.objects.filter( paperless_document_id=paperless_id_for_cleanup, paechter_id=old_verpachtung.paechter.id, - kontext='paechter' + kontext="paechter", ).delete() except (LandVerpachtung.DoesNotExist, Exception): pass - - message = 'Verknüpfung aktualisiert' + + message = "Verknüpfung aktualisiert" if paechter_linked: - message += ' (automatisch auch mit Pächter verknüpft)' - - return Response({'success': True, 'message': message}) + message += " (automatisch auch mit Pächter verknüpft)" + + return Response({"success": True, "message": message}) except DokumentLink.DoesNotExist: - return Response({'error': 'Verknüpfung nicht gefunden'}, status=404) + return Response({"error": "Verknüpfung nicht gefunden"}, status=404) except Exception as e: - return Response({'error': f'Unerwarteter Fehler: {e}'}, status=500) + return Response({"error": f"Unerwarteter Fehler: {e}"}, status=500) @csrf_exempt -@api_view(['DELETE']) +@api_view(["DELETE"]) def link_document_delete(request, link_id): """Löscht eine bestehende Verknüpfung.""" from django.db import transaction - + try: with transaction.atomic(): link = DokumentLink.objects.get(id=link_id) verpachtung_id_for_cleanup = link.verpachtung_id paperless_id_for_cleanup = link.paperless_document_id - + # Log the unlinking action before deletion from stiftung.audit import log_unlink + try: # Determine what entity this was linked to target_type = "unknown" target_name = "Unknown" - + if link.destinataer_id: target_type = "destinataer" try: @@ -2999,59 +3594,62 @@ def link_document_delete(request, link_id): target_type = "rentmeister" try: from stiftung.models import Rentmeister + entity = Rentmeister.objects.get(id=link.rentmeister_id) target_name = entity.get_full_name() except Rentmeister.DoesNotExist: target_name = f"Rentmeister ID {link.rentmeister_id}" - + log_unlink( request=request, - entity_type='dokumentlink', + entity_type="dokumentlink", entity_id=str(link.id), entity_name=link.titel, target_type=target_type, - target_name=target_name + target_name=target_name, ) except Exception as e: # Don't fail the main operation if logging fails print(f"Audit logging failed: {e}") - + link.delete() - + # Cleanup NACH der Haupttransaktion: Wenn eine Verpachtungs-Verknüpfung gelöscht wurde, prüfe Pächter-Links if verpachtung_id_for_cleanup: try: - verpachtung = LandVerpachtung.objects.select_related('paechter').get(id=verpachtung_id_for_cleanup) + verpachtung = LandVerpachtung.objects.select_related("paechter").get( + id=verpachtung_id_for_cleanup + ) if verpachtung.paechter: # Prüfe ob noch andere Verpachtungs-Links für diesen Pächter existieren other_verpachtung_links = DokumentLink.objects.filter( paperless_document_id=paperless_id_for_cleanup, - verpachtung__paechter_id=verpachtung.paechter.id + verpachtung__paechter_id=verpachtung.paechter.id, ).exists() - + if not other_verpachtung_links: # Entferne automatisch erstellte Pächter-Verknüpfung DokumentLink.objects.filter( paperless_document_id=paperless_id_for_cleanup, paechter_id=verpachtung.paechter.id, - kontext='paechter' + kontext="paechter", ).delete() except (LandVerpachtung.DoesNotExist, Exception): pass - - return Response({'success': True}) + + return Response({"success": True}) except DokumentLink.DoesNotExist: - return Response({'error': 'Verknüpfung nicht gefunden'}, status=404) + return Response({"error": "Verknüpfung nicht gefunden"}, status=404) except Exception as e: - return Response({'error': f'Unerwarteter Fehler: {e}'}, status=500) + return Response({"error": f"Unerwarteter Fehler: {e}"}, status=500) -@api_view(['GET']) +@api_view(["GET"]) def gramps_search_api(request): """Probe-Endpoint: Suche Personen in Gramps Web nach q (Nachname, Vorname).""" - q = request.GET.get('q', '') + q = request.GET.get("q", "") if not q: - return Response({'error': 'Parameter q erforderlich'}, status=400) + return Response({"error": "Parameter q erforderlich"}, status=400) client = get_gramps_client() result = client.search_people(q) return Response(result) @@ -3061,278 +3659,311 @@ def gramps_search_api(request): @login_required def geschaeftsfuehrung(request): """Hauptansicht für die Geschäftsführung mit Übersicht""" - from stiftung.models import StiftungsKonto, Verwaltungskosten, Rentmeister - from django.db.models import Sum, Count from datetime import datetime, timedelta - + + from django.db.models import Count, Sum + + from stiftung.models import Rentmeister, StiftungsKonto, Verwaltungskosten + # Rentmeister-Übersicht - rentmeister = Rentmeister.objects.filter(aktiv=True).order_by('nachname', 'vorname') - + rentmeister = Rentmeister.objects.filter(aktiv=True).order_by("nachname", "vorname") + # Konten-Übersicht - konten = StiftungsKonto.objects.filter(aktiv=True).order_by('bank_name', 'kontoname') - gesamtsaldo = konten.aggregate(total=Sum('saldo'))['total'] or 0 - + konten = StiftungsKonto.objects.filter(aktiv=True).order_by( + "bank_name", "kontoname" + ) + gesamtsaldo = konten.aggregate(total=Sum("saldo"))["total"] or 0 + # Aktuelle Kosten (letzten 30 Tage) heute = datetime.now().date() vor_30_tagen = heute - timedelta(days=30) - + aktuelle_kosten = Verwaltungskosten.objects.filter( datum__gte=vor_30_tagen - ).order_by('-datum')[:10] - + ).order_by("-datum")[:10] + # Statistiken - kosten_summe_monat = Verwaltungskosten.objects.filter( - datum__gte=vor_30_tagen - ).aggregate(total=Sum('betrag'))['total'] or 0 - - kosten_statistik = Verwaltungskosten.objects.filter( - datum__gte=vor_30_tagen - ).values('kategorie').annotate( - summe=Sum('betrag'), - anzahl=Count('id') - ).order_by('-summe') - + kosten_summe_monat = ( + Verwaltungskosten.objects.filter(datum__gte=vor_30_tagen).aggregate( + total=Sum("betrag") + )["total"] + or 0 + ) + + kosten_statistik = ( + Verwaltungskosten.objects.filter(datum__gte=vor_30_tagen) + .values("kategorie") + .annotate(summe=Sum("betrag"), anzahl=Count("id")) + .order_by("-summe") + ) + context = { - 'rentmeister': rentmeister, - 'konten': konten, - 'gesamtsaldo': gesamtsaldo, - 'aktuelle_kosten': aktuelle_kosten, - 'kosten_summe_monat': kosten_summe_monat, - 'kosten_statistik': kosten_statistik, + "rentmeister": rentmeister, + "konten": konten, + "gesamtsaldo": gesamtsaldo, + "aktuelle_kosten": aktuelle_kosten, + "kosten_summe_monat": kosten_summe_monat, + "kosten_statistik": kosten_statistik, } - - return render(request, 'stiftung/geschaeftsfuehrung.html', context) + + return render(request, "stiftung/geschaeftsfuehrung.html", context) @login_required def konto_list(request): """Liste aller Stiftungskonten""" - from stiftung.models import StiftungsKonto from django.db.models import Sum - - konten = StiftungsKonto.objects.all().order_by('bank_name', 'kontoname') - gesamtsaldo = konten.aggregate(total=Sum('saldo'))['total'] or 0 - + + from stiftung.models import StiftungsKonto + + konten = StiftungsKonto.objects.all().order_by("bank_name", "kontoname") + gesamtsaldo = konten.aggregate(total=Sum("saldo"))["total"] or 0 + context = { - 'konten': konten, - 'gesamtsaldo': gesamtsaldo, + "konten": konten, + "gesamtsaldo": gesamtsaldo, } - - return render(request, 'stiftung/konto_list.html', context) + + return render(request, "stiftung/konto_list.html", context) -@login_required +@login_required def verwaltungskosten_list(request): """Liste aller Verwaltungskosten""" - from stiftung.models import Verwaltungskosten from django.core.paginator import Paginator - - kosten = Verwaltungskosten.objects.all().order_by('-datum', '-erstellt_am') - + + from stiftung.models import Verwaltungskosten + + kosten = Verwaltungskosten.objects.all().order_by("-datum", "-erstellt_am") + # Filter nach Kategorie - kategorie_filter = request.GET.get('kategorie') + kategorie_filter = request.GET.get("kategorie") if kategorie_filter: kosten = kosten.filter(kategorie=kategorie_filter) - + # Filter nach Status - status_filter = request.GET.get('status') + status_filter = request.GET.get("status") if status_filter: kosten = kosten.filter(status=status_filter) - + # Pagination paginator = Paginator(kosten, 25) - page_number = request.GET.get('page') + page_number = request.GET.get("page") page_obj = paginator.get_page(page_number) - + # Für Filter-Dropdowns kategorien = Verwaltungskosten.KATEGORIE_CHOICES status_choices = Verwaltungskosten.STATUS_CHOICES - + context = { - 'page_obj': page_obj, - 'kategorien': kategorien, - 'status_choices': status_choices, - 'kategorie_filter': kategorie_filter, - 'status_filter': status_filter, + "page_obj": page_obj, + "kategorien": kategorien, + "status_choices": status_choices, + "kategorie_filter": kategorie_filter, + "status_filter": status_filter, } - - return render(request, 'stiftung/verwaltungskosten_list.html', context) + + return render(request, "stiftung/verwaltungskosten_list.html", context) @login_required def rentmeister_list(request): """Liste aller Rentmeister""" from stiftung.models import Rentmeister - - rentmeister = Rentmeister.objects.all().order_by('nachname', 'vorname') - + + rentmeister = Rentmeister.objects.all().order_by("nachname", "vorname") + # Aktive/Inaktive aufteilen aktive_rentmeister = rentmeister.filter(aktiv=True) ehemalige_rentmeister = rentmeister.filter(aktiv=False) - + context = { - 'aktive_rentmeister': aktive_rentmeister, - 'ehemalige_rentmeister': ehemalige_rentmeister, - 'total_count': rentmeister.count(), + "aktive_rentmeister": aktive_rentmeister, + "ehemalige_rentmeister": ehemalige_rentmeister, + "total_count": rentmeister.count(), } - - return render(request, 'stiftung/rentmeister_list.html', context) + + return render(request, "stiftung/rentmeister_list.html", context) @login_required def rentmeister_detail(request, pk): """Detailansicht eines Rentmeisters mit seinen Ausgaben""" - from stiftung.models import Rentmeister, Verwaltungskosten - from django.db.models import Sum, Count, Q from datetime import datetime, timedelta - + + from django.db.models import Count, Q, Sum + + from stiftung.models import Rentmeister, Verwaltungskosten + rentmeister = get_object_or_404(Rentmeister, pk=pk) - + # Ausgaben des Rentmeisters - ausgaben = Verwaltungskosten.objects.filter(rentmeister=rentmeister).order_by('-datum') - + ausgaben = Verwaltungskosten.objects.filter(rentmeister=rentmeister).order_by( + "-datum" + ) + # Statistiken heute = datetime.now().date() aktueller_monat = heute.replace(day=1) aktuelles_jahr = heute.replace(month=1, day=1) - + stats = { - 'gesamt_ausgaben': ausgaben.aggregate(total=Sum('betrag'))['total'] or 0, - 'monat_ausgaben': ausgaben.filter(datum__gte=aktueller_monat).aggregate(total=Sum('betrag'))['total'] or 0, - 'jahr_ausgaben': ausgaben.filter(datum__gte=aktuelles_jahr).aggregate(total=Sum('betrag'))['total'] or 0, - 'anzahl_ausgaben': ausgaben.count(), - 'offene_ausgaben': ausgaben.exclude(status='bezahlt').count(), + "gesamt_ausgaben": ausgaben.aggregate(total=Sum("betrag"))["total"] or 0, + "monat_ausgaben": ausgaben.filter(datum__gte=aktueller_monat).aggregate( + total=Sum("betrag") + )["total"] + or 0, + "jahr_ausgaben": ausgaben.filter(datum__gte=aktuelles_jahr).aggregate( + total=Sum("betrag") + )["total"] + or 0, + "anzahl_ausgaben": ausgaben.count(), + "offene_ausgaben": ausgaben.exclude(status="bezahlt").count(), } - + # Kategorie-Aufschlüsselung - kategorie_stats = ausgaben.values('kategorie').annotate( - summe=Sum('betrag'), - anzahl=Count('id') - ).order_by('-summe') - + kategorie_stats = ( + ausgaben.values("kategorie") + .annotate(summe=Sum("betrag"), anzahl=Count("id")) + .order_by("-summe") + ) + # Aktuelle Ausgaben (letzten 30 Tage) vor_30_tagen = heute - timedelta(days=30) aktuelle_ausgaben = ausgaben.filter(datum__gte=vor_30_tagen)[:10] - + # Verknüpfte Dokumente laden from stiftung.models import DokumentLink + verknuepfte_dokumente = DokumentLink.objects.filter( rentmeister_id=rentmeister.id - ).order_by('-id')[:10] # Neueste 10 Dokumente - + ).order_by("-id")[ + :10 + ] # Neueste 10 Dokumente + context = { - 'rentmeister': rentmeister, - 'ausgaben': ausgaben[:20], # Nur erste 20 für Übersicht - 'stats': stats, - 'kategorie_stats': kategorie_stats, - 'aktuelle_ausgaben': aktuelle_ausgaben, - 'verknuepfte_dokumente': verknuepfte_dokumente, + "rentmeister": rentmeister, + "ausgaben": ausgaben[:20], # Nur erste 20 für Übersicht + "stats": stats, + "kategorie_stats": kategorie_stats, + "aktuelle_ausgaben": aktuelle_ausgaben, + "verknuepfte_dokumente": verknuepfte_dokumente, } - - return render(request, 'stiftung/rentmeister_detail.html', context) + + return render(request, "stiftung/rentmeister_detail.html", context) @login_required def rentmeister_ausgaben(request, pk): """Vollständige Ausgabenliste eines Rentmeisters mit PDF Export""" - from stiftung.models import Rentmeister, Verwaltungskosten from django.core.paginator import Paginator - from django.db.models import Sum, Count, Q from django.db import models - + from django.db.models import Count, Q, Sum + + from stiftung.models import Rentmeister, Verwaltungskosten + rentmeister = get_object_or_404(Rentmeister, pk=pk) - + # Handle PDF export request - if request.method == 'POST' and 'export_pdf' in request.POST: - selected_ids = request.POST.getlist('selected_expenses') + if request.method == "POST" and "export_pdf" in request.POST: + selected_ids = request.POST.getlist("selected_expenses") if selected_ids: # Update status to 'in_bearbeitung' and log each change from stiftung.audit import log_action + expenses_to_update = Verwaltungskosten.objects.filter( - id__in=selected_ids, - rentmeister=rentmeister + id__in=selected_ids, rentmeister=rentmeister ) - + updated_count = 0 for expense in expenses_to_update: old_status = expense.status - expense.status = 'in_bearbeitung' + expense.status = "in_bearbeitung" expense.save() updated_count += 1 - + # Log the status change log_action( request=request, - action='update', - entity_type='verwaltungskosten', + action="update", + entity_type="verwaltungskosten", entity_id=str(expense.pk), entity_name=expense.bezeichnung, description=f'Ausgaben-Status für PDF-Export geändert von "{old_status}" zu "in_bearbeitung"', - changes={'status': {'old': old_status, 'new': 'in_bearbeitung'}} + changes={"status": {"old": old_status, "new": "in_bearbeitung"}}, ) - - messages.success(request, f'{updated_count} Ausgaben wurden zur Bearbeitung markiert und sind bereit für PDF Export.') - return redirect('stiftung:rentmeister_ausgaben_pdf', pk=pk, expense_ids=','.join(selected_ids)) - + + messages.success( + request, + f"{updated_count} Ausgaben wurden zur Bearbeitung markiert und sind bereit für PDF Export.", + ) + return redirect( + "stiftung:rentmeister_ausgaben_pdf", + pk=pk, + expense_ids=",".join(selected_ids), + ) + # Get expenses grouped by status ausgaben_by_status = {} for status_code, status_name in Verwaltungskosten.STATUS_CHOICES: ausgaben_by_status[status_code] = { - 'name': status_name, - 'ausgaben': Verwaltungskosten.objects.filter( - rentmeister=rentmeister, - status=status_code - ).order_by('-datum', '-erstellt_am'), - 'total': Verwaltungskosten.objects.filter( - rentmeister=rentmeister, - status=status_code - ).aggregate(total=Sum('betrag'))['total'] or 0 + "name": status_name, + "ausgaben": Verwaltungskosten.objects.filter( + rentmeister=rentmeister, status=status_code + ).order_by("-datum", "-erstellt_am"), + "total": Verwaltungskosten.objects.filter( + rentmeister=rentmeister, status=status_code + ).aggregate(total=Sum("betrag"))["total"] + or 0, } - + # Get statistics stats = Verwaltungskosten.objects.filter(rentmeister=rentmeister).aggregate( - total_count=Count('id'), - total_amount=Sum('betrag'), - geplant_count=Count('id', filter=Q(status='geplant')), - geplant_amount=Sum('betrag', filter=Q(status='geplant')), - in_bearbeitung_count=Count('id', filter=Q(status='in_bearbeitung')), - in_bearbeitung_amount=Sum('betrag', filter=Q(status='in_bearbeitung')), - bezahlt_count=Count('id', filter=Q(status='bezahlt')), - bezahlt_amount=Sum('betrag', filter=Q(status='bezahlt')), + total_count=Count("id"), + total_amount=Sum("betrag"), + geplant_count=Count("id", filter=Q(status="geplant")), + geplant_amount=Sum("betrag", filter=Q(status="geplant")), + in_bearbeitung_count=Count("id", filter=Q(status="in_bearbeitung")), + in_bearbeitung_amount=Sum("betrag", filter=Q(status="in_bearbeitung")), + bezahlt_count=Count("id", filter=Q(status="bezahlt")), + bezahlt_amount=Sum("betrag", filter=Q(status="bezahlt")), ) - + context = { - 'rentmeister': rentmeister, - 'ausgaben_by_status': ausgaben_by_status, - 'stats': stats, - 'kategorien': Verwaltungskosten.KATEGORIE_CHOICES, - 'status_choices': Verwaltungskosten.STATUS_CHOICES, + "rentmeister": rentmeister, + "ausgaben_by_status": ausgaben_by_status, + "stats": stats, + "kategorien": Verwaltungskosten.KATEGORIE_CHOICES, + "status_choices": Verwaltungskosten.STATUS_CHOICES, } - - return render(request, 'stiftung/rentmeister_ausgaben.html', context) + + return render(request, "stiftung/rentmeister_ausgaben.html", context) @login_required def rentmeister_create(request): """Erstelle einen neuen Rentmeister""" from stiftung.forms import RentmeisterForm - - if request.method == 'POST': + + if request.method == "POST": form = RentmeisterForm(request.POST) if form.is_valid(): rentmeister = form.save() - messages.success(request, f'Rentmeister {rentmeister.get_full_name()} wurde erfolgreich angelegt.') - return redirect('stiftung:rentmeister_detail', pk=rentmeister.pk) + messages.success( + request, + f"Rentmeister {rentmeister.get_full_name()} wurde erfolgreich angelegt.", + ) + return redirect("stiftung:rentmeister_detail", pk=rentmeister.pk) else: form = RentmeisterForm() - + context = { - 'form': form, - 'title': 'Neuen Rentmeister anlegen', - 'submit_text': 'Rentmeister anlegen', + "form": form, + "title": "Neuen Rentmeister anlegen", + "submit_text": "Rentmeister anlegen", } - - return render(request, 'stiftung/rentmeister_form.html', context) + + return render(request, "stiftung/rentmeister_form.html", context) @login_required @@ -3340,49 +3971,54 @@ def rentmeister_edit(request, pk): """Bearbeite einen bestehenden Rentmeister""" from stiftung.forms import RentmeisterForm from stiftung.models import Rentmeister - + rentmeister = get_object_or_404(Rentmeister, pk=pk) - - if request.method == 'POST': + + if request.method == "POST": form = RentmeisterForm(request.POST, instance=rentmeister) if form.is_valid(): rentmeister = form.save() - messages.success(request, f'Rentmeister {rentmeister.get_full_name()} wurde erfolgreich aktualisiert.') - return redirect('stiftung:rentmeister_detail', pk=rentmeister.pk) + messages.success( + request, + f"Rentmeister {rentmeister.get_full_name()} wurde erfolgreich aktualisiert.", + ) + return redirect("stiftung:rentmeister_detail", pk=rentmeister.pk) else: form = RentmeisterForm(instance=rentmeister) - + context = { - 'form': form, - 'rentmeister': rentmeister, - 'title': f'{rentmeister.get_full_name()} bearbeiten', - 'submit_text': 'Änderungen speichern', + "form": form, + "rentmeister": rentmeister, + "title": f"{rentmeister.get_full_name()} bearbeiten", + "submit_text": "Änderungen speichern", } - - return render(request, 'stiftung/rentmeister_form.html', context) + + return render(request, "stiftung/rentmeister_form.html", context) @login_required def konto_create(request): """Erstelle ein neues Stiftungskonto""" from stiftung.forms import StiftungsKontoForm - - if request.method == 'POST': + + if request.method == "POST": form = StiftungsKontoForm(request.POST) if form.is_valid(): konto = form.save() - messages.success(request, f'Konto {konto.kontoname} wurde erfolgreich angelegt.') - return redirect('stiftung:konto_list') + messages.success( + request, f"Konto {konto.kontoname} wurde erfolgreich angelegt." + ) + return redirect("stiftung:konto_list") else: form = StiftungsKontoForm() - + context = { - 'form': form, - 'title': 'Neues Konto anlegen', - 'submit_text': 'Konto anlegen', + "form": form, + "title": "Neues Konto anlegen", + "submit_text": "Konto anlegen", } - - return render(request, 'stiftung/konto_form.html', context) + + return render(request, "stiftung/konto_form.html", context) @login_required @@ -3390,56 +4026,59 @@ def konto_edit(request, pk): """Bearbeite ein bestehendes Stiftungskonto""" from stiftung.forms import StiftungsKontoForm from stiftung.models import StiftungsKonto - + konto = get_object_or_404(StiftungsKonto, pk=pk) - - if request.method == 'POST': + + if request.method == "POST": form = StiftungsKontoForm(request.POST, instance=konto) if form.is_valid(): konto = form.save() - messages.success(request, f'Konto {konto.kontoname} wurde erfolgreich aktualisiert.') - return redirect('stiftung:konto_list') + messages.success( + request, f"Konto {konto.kontoname} wurde erfolgreich aktualisiert." + ) + return redirect("stiftung:konto_list") else: form = StiftungsKontoForm(instance=konto) - + context = { - 'form': form, - 'konto': konto, - 'title': f'Konto {konto.kontoname} bearbeiten', - 'submit_text': 'Änderungen speichern', + "form": form, + "konto": konto, + "title": f"Konto {konto.kontoname} bearbeiten", + "submit_text": "Änderungen speichern", } - - return render(request, 'stiftung/konto_form.html', context) + + return render(request, "stiftung/konto_form.html", context) @login_required def konto_detail(request, pk): """Zeige Details eines Stiftungskontos""" - from stiftung.models import StiftungsKonto, BankTransaction - from django.db.models import Sum, Count, Max, Q from django.db import models - + from django.db.models import Count, Max, Q, Sum + + from stiftung.models import BankTransaction, StiftungsKonto + konto = get_object_or_404(StiftungsKonto, pk=pk) - + # Get transaction statistics transactions = BankTransaction.objects.filter(konto=konto) transaction_stats = transactions.aggregate( - total_count=Count('id'), - total_eingang=Sum('betrag', filter=Q(betrag__gt=0)), - total_ausgang=Sum('betrag', filter=Q(betrag__lt=0)), - last_transaction_date=Max('datum') + total_count=Count("id"), + total_eingang=Sum("betrag", filter=Q(betrag__gt=0)), + total_ausgang=Sum("betrag", filter=Q(betrag__lt=0)), + last_transaction_date=Max("datum"), ) - + # Recent transactions - recent_transactions = transactions.order_by('-datum', '-importiert_am')[:10] - + recent_transactions = transactions.order_by("-datum", "-importiert_am")[:10] + context = { - 'konto': konto, - 'transaction_stats': transaction_stats, - 'recent_transactions': recent_transactions, + "konto": konto, + "transaction_stats": transaction_stats, + "recent_transactions": recent_transactions, } - - return render(request, 'stiftung/konto_detail.html', context) + + return render(request, "stiftung/konto_detail.html", context) @login_required @@ -3447,39 +4086,42 @@ def verwaltungskosten_create(request): """Erstelle neue Verwaltungskosten""" from stiftung.forms import VerwaltungskostenForm from stiftung.models import Rentmeister - + # Check if we're coming from a specific Rentmeister - rentmeister_id = request.GET.get('rentmeister') + rentmeister_id = request.GET.get("rentmeister") initial_data = {} - redirect_url = 'stiftung:verwaltungskosten_list' - + redirect_url = "stiftung:verwaltungskosten_list" + if rentmeister_id: try: rentmeister = Rentmeister.objects.get(pk=rentmeister_id) - initial_data['rentmeister'] = rentmeister - redirect_url = 'stiftung:rentmeister_detail' + initial_data["rentmeister"] = rentmeister + redirect_url = "stiftung:rentmeister_detail" redirect_args = [rentmeister_id] except Rentmeister.DoesNotExist: pass - - if request.method == 'POST': + + if request.method == "POST": form = VerwaltungskostenForm(request.POST) if form.is_valid(): kosten = form.save() - messages.success(request, f'Verwaltungskosten "{kosten.bezeichnung}" wurden erfolgreich angelegt.') + messages.success( + request, + f'Verwaltungskosten "{kosten.bezeichnung}" wurden erfolgreich angelegt.', + ) if rentmeister_id: return redirect(redirect_url, pk=rentmeister_id) - return redirect('stiftung:verwaltungskosten_list') + return redirect("stiftung:verwaltungskosten_list") else: form = VerwaltungskostenForm(initial=initial_data) - + context = { - 'form': form, - 'title': 'Neue Verwaltungskosten anlegen', - 'submit_text': 'Kosten anlegen', + "form": form, + "title": "Neue Verwaltungskosten anlegen", + "submit_text": "Kosten anlegen", } - - return render(request, 'stiftung/verwaltungskosten_form.html', context) + + return render(request, "stiftung/verwaltungskosten_form.html", context) @login_required @@ -3487,210 +4129,340 @@ def verwaltungskosten_edit(request, pk): """Bearbeite bestehende Verwaltungskosten""" from stiftung.forms import VerwaltungskostenForm from stiftung.models import Verwaltungskosten - + verwaltungskosten = get_object_or_404(Verwaltungskosten, pk=pk) - - if request.method == 'POST': + + if request.method == "POST": form = VerwaltungskostenForm(request.POST, instance=verwaltungskosten) if form.is_valid(): verwaltungskosten = form.save() - messages.success(request, f'Verwaltungskosten "{verwaltungskosten.bezeichnung}" wurden erfolgreich aktualisiert.') - return redirect('stiftung:verwaltungskosten_list') + messages.success( + request, + f'Verwaltungskosten "{verwaltungskosten.bezeichnung}" wurden erfolgreich aktualisiert.', + ) + return redirect("stiftung:verwaltungskosten_list") else: form = VerwaltungskostenForm(instance=verwaltungskosten) - + context = { - 'form': form, - 'verwaltungskosten': verwaltungskosten, - 'title': f'Verwaltungskosten bearbeiten: {verwaltungskosten.bezeichnung}', - 'submit_text': 'Änderungen speichern', + "form": form, + "verwaltungskosten": verwaltungskosten, + "title": f"Verwaltungskosten bearbeiten: {verwaltungskosten.bezeichnung}", + "submit_text": "Änderungen speichern", } - - return render(request, 'stiftung/verwaltungskosten_form.html', context) + + return render(request, "stiftung/verwaltungskosten_form.html", context) @login_required def mark_expense_paid(request): """Markiere eine Ausgabe als bezahlt""" - if request.method == 'POST': - expense_id = request.POST.get('expense_id') + if request.method == "POST": + expense_id = request.POST.get("expense_id") if expense_id: try: from stiftung.models import Verwaltungskosten + expense = Verwaltungskosten.objects.get(pk=expense_id) old_status = expense.status - expense.status = 'bezahlt' + expense.status = "bezahlt" expense.save() - + # Log the status change from stiftung.audit import log_action + log_action( request=request, - action='update', - entity_type='verwaltungskosten', + action="update", + entity_type="verwaltungskosten", entity_id=str(expense.pk), entity_name=expense.bezeichnung, description=f'Ausgaben-Status geändert von "{old_status}" zu "bezahlt"', - changes={'status': {'old': old_status, 'new': 'bezahlt'}} + changes={"status": {"old": old_status, "new": "bezahlt"}}, + ) + + messages.success( + request, + f'Ausgabe "{expense.bezeichnung}" wurde als bezahlt markiert.', + ) + return redirect( + "stiftung:rentmeister_ausgaben", pk=expense.rentmeister.pk ) - - messages.success(request, f'Ausgabe "{expense.bezeichnung}" wurde als bezahlt markiert.') - return redirect('stiftung:rentmeister_ausgaben', pk=expense.rentmeister.pk) except Verwaltungskosten.DoesNotExist: - messages.error(request, 'Ausgabe nicht gefunden.') - - return redirect('stiftung:verwaltungskosten_list') + messages.error(request, "Ausgabe nicht gefunden.") + + return redirect("stiftung:verwaltungskosten_list") # ============================================================================= -# ADMINISTRATION VIEWS +# ADMINISTRATION VIEWS # ============================================================================= + @login_required def administration(request): """Administration Dashboard""" - from stiftung.models import AuditLog, BackupJob - from django.db.models import Count from datetime import datetime, timedelta - + + from django.db.models import Count + + from stiftung.models import AuditLog, BackupJob + # Recent audit activity recent_audit = AuditLog.objects.all()[:10] - + # Audit statistics heute = datetime.now().date() stats = { - 'total_logs': AuditLog.objects.count(), - 'logs_today': AuditLog.objects.filter(timestamp__date=heute).count(), - 'logs_week': AuditLog.objects.filter(timestamp__gte=heute - timedelta(days=7)).count(), - 'recent_backups': BackupJob.objects.all()[:5], - 'last_backup': BackupJob.objects.filter(status='completed').first(), + "total_logs": AuditLog.objects.count(), + "logs_today": AuditLog.objects.filter(timestamp__date=heute).count(), + "logs_week": AuditLog.objects.filter( + timestamp__gte=heute - timedelta(days=7) + ).count(), + "recent_backups": BackupJob.objects.all()[:5], + "last_backup": BackupJob.objects.filter(status="completed").first(), } - + # User activity summary - user_activity = AuditLog.objects.values('username').annotate( - count=Count('id') - ).order_by('-count')[:10] - + user_activity = ( + AuditLog.objects.values("username") + .annotate(count=Count("id")) + .order_by("-count")[:10] + ) + context = { - 'recent_audit': recent_audit, - 'stats': stats, - 'user_activity': user_activity, + "recent_audit": recent_audit, + "stats": stats, + "user_activity": user_activity, } - - return render(request, 'stiftung/administration.html', context) + + return render(request, "stiftung/administration.html", context) @login_required def unterstuetzungen_list(request): """Liste der Destinatärunterstützungen (Administration).""" - status = request.GET.get('status', '') - export_format = request.POST.get('format') if request.method == 'POST' else request.GET.get('format', '') - selected_ids_param = request.POST.get('selected_entries', '') if request.method == 'POST' else request.GET.get('selected_entries', '') - selected_ids = [id for id in selected_ids_param.split(',') if id] if selected_ids_param else [] - + status = request.GET.get("status", "") + export_format = ( + request.POST.get("format") + if request.method == "POST" + else request.GET.get("format", "") + ) + selected_ids_param = ( + request.POST.get("selected_entries", "") + if request.method == "POST" + else request.GET.get("selected_entries", "") + ) + selected_ids = ( + [id for id in selected_ids_param.split(",") if id] if selected_ids_param else [] + ) + qs = DestinataerUnterstuetzung.objects.select_related( - 'destinataer', 'konto', 'ausgezahlt_von', 'wiederkehrend_von' - ).order_by('-faellig_am', 'destinataer__nachname') - + "destinataer", "konto", "ausgezahlt_von", "wiederkehrend_von" + ).order_by("-faellig_am", "destinataer__nachname") + if status: qs = qs.filter(status=status) - + # Enhanced CSV export with field selection - if export_format == 'csv': + if export_format == "csv": return export_unterstuetzungen_csv(request, qs, selected_ids) - + # Enhanced PDF export with corporate identity - elif export_format == 'pdf': + elif export_format == "pdf": return export_unterstuetzungen_pdf(request, qs, selected_ids) context = { - 'unterstuetzungen': qs, - 'status_filter': status, + "unterstuetzungen": qs, + "status_filter": status, } - return render(request, 'stiftung/unterstuetzungen_list.html', context) + return render(request, "stiftung/unterstuetzungen_list.html", context) def export_unterstuetzungen_csv(request, queryset, selected_ids=None): """Enhanced CSV export with field selection""" import csv - from django.http import HttpResponse from datetime import datetime - + + from django.http import HttpResponse + # If specific entries are selected, filter to only those if selected_ids: queryset = queryset.filter(id__in=selected_ids) - + # Get selected fields from request (default to all if none specified) - selected_fields_param = request.POST.get('selected_fields', '') if request.method == 'POST' else request.GET.get('selected_fields', '') - selected_fields = selected_fields_param.split(',') if selected_fields_param else [] - + selected_fields_param = ( + request.POST.get("selected_fields", "") + if request.method == "POST" + else request.GET.get("selected_fields", "") + ) + selected_fields = selected_fields_param.split(",") if selected_fields_param else [] + if not selected_fields: # Default field set selected_fields = [ - 'destinataer_name', 'betrag', 'faellig_am', 'status', - 'empfaenger_iban', 'empfaenger_name', 'beschreibung' + "destinataer_name", + "betrag", + "faellig_am", + "status", + "empfaenger_iban", + "empfaenger_name", + "beschreibung", ] - + # Field definitions with headers and data extraction field_definitions = { # Core payment fields - 'id': ('ID', lambda u: str(u.id)), - 'betrag': ('Betrag (€)', lambda u: f"{u.betrag:.2f}"), - 'faellig_am': ('Fällig am', lambda u: u.faellig_am.strftime('%d.%m.%Y') if u.faellig_am else ''), - 'status': ('Status', lambda u: u.get_status_display()), - 'beschreibung': ('Beschreibung', lambda u: u.beschreibung or ''), - 'ausgezahlt_am': ('Ausgezahlt am', lambda u: u.ausgezahlt_am.strftime('%d.%m.%Y') if u.ausgezahlt_am else ''), - 'erstellt_am': ('Erstellt am', lambda u: u.erstellt_am.strftime('%d.%m.%Y %H:%M') if u.erstellt_am else ''), - 'aktualisiert_am': ('Aktualisiert am', lambda u: u.aktualisiert_am.strftime('%d.%m.%Y %H:%M') if u.aktualisiert_am else ''), - + "id": ("ID", lambda u: str(u.id)), + "betrag": ("Betrag (€)", lambda u: f"{u.betrag:.2f}"), + "faellig_am": ( + "Fällig am", + lambda u: u.faellig_am.strftime("%d.%m.%Y") if u.faellig_am else "", + ), + "status": ("Status", lambda u: u.get_status_display()), + "beschreibung": ("Beschreibung", lambda u: u.beschreibung or ""), + "ausgezahlt_am": ( + "Ausgezahlt am", + lambda u: u.ausgezahlt_am.strftime("%d.%m.%Y") if u.ausgezahlt_am else "", + ), + "erstellt_am": ( + "Erstellt am", + lambda u: u.erstellt_am.strftime("%d.%m.%Y %H:%M") if u.erstellt_am else "", + ), + "aktualisiert_am": ( + "Aktualisiert am", + lambda u: ( + u.aktualisiert_am.strftime("%d.%m.%Y %H:%M") + if u.aktualisiert_am + else "" + ), + ), # Destinataer fields - 'destinataer_name': ('Destinatär Name', lambda u: u.destinataer.get_full_name() if u.destinataer else ''), - 'destinataer_vorname': ('Vorname', lambda u: u.destinataer.vorname if u.destinataer else ''), - 'destinataer_nachname': ('Nachname', lambda u: u.destinataer.nachname if u.destinataer else ''), - 'familienzweig': ('Familienzweig', lambda u: u.destinataer.familienzweig if u.destinataer else ''), - 'geburtsdatum': ('Geburtsdatum', lambda u: u.destinataer.geburtsdatum.strftime('%d.%m.%Y') if u.destinataer and u.destinataer.geburtsdatum else ''), - 'email': ('E-Mail', lambda u: u.destinataer.email if u.destinataer else ''), - 'telefon': ('Telefon', lambda u: u.destinataer.telefon if u.destinataer else ''), - 'destinataer_iban': ('Destinatär IBAN', lambda u: u.destinataer.iban if u.destinataer else ''), - 'strasse': ('Straße', lambda u: u.destinataer.strasse if u.destinataer else ''), - 'plz': ('PLZ', lambda u: u.destinataer.plz if u.destinataer else ''), - 'ort': ('Ort', lambda u: u.destinataer.ort if u.destinataer else ''), - 'adresse': ('Adresse', lambda u: f"{u.destinataer.strasse}, {u.destinataer.plz} {u.destinataer.ort}".strip(', ') if u.destinataer else ''), - 'berufsgruppe': ('Berufsgruppe', lambda u: u.destinataer.berufsgruppe if u.destinataer else ''), - 'ausbildungsstand': ('Ausbildungsstand', lambda u: u.destinataer.ausbildungsstand if u.destinataer else ''), - 'institution': ('Institution', lambda u: u.destinataer.institution if u.destinataer else ''), - 'jaehrliches_einkommen': ('Jährliches Einkommen (€)', lambda u: f"{u.destinataer.jaehrliches_einkommen:.2f}" if u.destinataer and u.destinataer.jaehrliches_einkommen else ''), - 'haushaltsgroesse': ('Haushaltsgröße', lambda u: str(u.destinataer.haushaltsgroesse) if u.destinataer and u.destinataer.haushaltsgroesse else ''), - 'monatliche_bezuege': ('Monatliche Bezüge (€)', lambda u: f"{u.destinataer.monatliche_bezuege:.2f}" if u.destinataer and u.destinataer.monatliche_bezuege else ''), - 'vermoegen': ('Vermögen (€)', lambda u: f"{u.destinataer.vermoegen:.2f}" if u.destinataer and u.destinataer.vermoegen else ''), - + "destinataer_name": ( + "Destinatär Name", + lambda u: u.destinataer.get_full_name() if u.destinataer else "", + ), + "destinataer_vorname": ( + "Vorname", + lambda u: u.destinataer.vorname if u.destinataer else "", + ), + "destinataer_nachname": ( + "Nachname", + lambda u: u.destinataer.nachname if u.destinataer else "", + ), + "familienzweig": ( + "Familienzweig", + lambda u: u.destinataer.familienzweig if u.destinataer else "", + ), + "geburtsdatum": ( + "Geburtsdatum", + lambda u: ( + u.destinataer.geburtsdatum.strftime("%d.%m.%Y") + if u.destinataer and u.destinataer.geburtsdatum + else "" + ), + ), + "email": ("E-Mail", lambda u: u.destinataer.email if u.destinataer else ""), + "telefon": ( + "Telefon", + lambda u: u.destinataer.telefon if u.destinataer else "", + ), + "destinataer_iban": ( + "Destinatär IBAN", + lambda u: u.destinataer.iban if u.destinataer else "", + ), + "strasse": ("Straße", lambda u: u.destinataer.strasse if u.destinataer else ""), + "plz": ("PLZ", lambda u: u.destinataer.plz if u.destinataer else ""), + "ort": ("Ort", lambda u: u.destinataer.ort if u.destinataer else ""), + "adresse": ( + "Adresse", + lambda u: ( + f"{u.destinataer.strasse}, {u.destinataer.plz} {u.destinataer.ort}".strip( + ", " + ) + if u.destinataer + else "" + ), + ), + "berufsgruppe": ( + "Berufsgruppe", + lambda u: u.destinataer.berufsgruppe if u.destinataer else "", + ), + "ausbildungsstand": ( + "Ausbildungsstand", + lambda u: u.destinataer.ausbildungsstand if u.destinataer else "", + ), + "institution": ( + "Institution", + lambda u: u.destinataer.institution if u.destinataer else "", + ), + "jaehrliches_einkommen": ( + "Jährliches Einkommen (€)", + lambda u: ( + f"{u.destinataer.jaehrliches_einkommen:.2f}" + if u.destinataer and u.destinataer.jaehrliches_einkommen + else "" + ), + ), + "haushaltsgroesse": ( + "Haushaltsgröße", + lambda u: ( + str(u.destinataer.haushaltsgroesse) + if u.destinataer and u.destinataer.haushaltsgroesse + else "" + ), + ), + "monatliche_bezuege": ( + "Monatliche Bezüge (€)", + lambda u: ( + f"{u.destinataer.monatliche_bezuege:.2f}" + if u.destinataer and u.destinataer.monatliche_bezuege + else "" + ), + ), + "vermoegen": ( + "Vermögen (€)", + lambda u: ( + f"{u.destinataer.vermoegen:.2f}" + if u.destinataer and u.destinataer.vermoegen + else "" + ), + ), # Payment details - 'empfaenger_iban': ('Empfänger IBAN', lambda u: u.empfaenger_iban or ''), - 'empfaenger_name': ('Empfänger Name', lambda u: u.empfaenger_name or ''), - 'verwendungszweck': ('Verwendungszweck', lambda u: u.verwendungszweck or ''), - + "empfaenger_iban": ("Empfänger IBAN", lambda u: u.empfaenger_iban or ""), + "empfaenger_name": ("Empfänger Name", lambda u: u.empfaenger_name or ""), + "verwendungszweck": ("Verwendungszweck", lambda u: u.verwendungszweck or ""), # Account fields - 'konto_name': ('Konto', lambda u: str(u.konto) if u.konto else ''), - 'konto_bank': ('Bank', lambda u: u.konto.bank_name if u.konto else ''), - 'konto_iban': ('Konto IBAN', lambda u: u.konto.iban if u.konto else ''), - + "konto_name": ("Konto", lambda u: str(u.konto) if u.konto else ""), + "konto_bank": ("Bank", lambda u: u.konto.bank_name if u.konto else ""), + "konto_iban": ("Konto IBAN", lambda u: u.konto.iban if u.konto else ""), # System fields - 'ausgezahlt_von': ('Ausgezahlt von', lambda u: u.ausgezahlt_von.get_full_name() if u.ausgezahlt_von else ''), - 'ist_wiederkehrend': ('Wiederkehrend', lambda u: 'Ja' if u.wiederkehrend_von else 'Nein'), + "ausgezahlt_von": ( + "Ausgezahlt von", + lambda u: u.ausgezahlt_von.get_full_name() if u.ausgezahlt_von else "", + ), + "ist_wiederkehrend": ( + "Wiederkehrend", + lambda u: "Ja" if u.wiederkehrend_von else "Nein", + ), } - + # Create CSV response - timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') - filename = f'unterstuetzungen_{timestamp}.csv' - - response = HttpResponse(content_type='text/csv; charset=utf-8') - response['Content-Disposition'] = f'attachment; filename="{filename}"' - - writer = csv.writer(response, delimiter=';', quoting=csv.QUOTE_ALL) - + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + filename = f"unterstuetzungen_{timestamp}.csv" + + response = HttpResponse(content_type="text/csv; charset=utf-8") + response["Content-Disposition"] = f'attachment; filename="{filename}"' + + writer = csv.writer(response, delimiter=";", quoting=csv.QUOTE_ALL) + # Write headers - headers = [field_definitions[field][0] for field in selected_fields if field in field_definitions] + headers = [ + field_definitions[field][0] + for field in selected_fields + if field in field_definitions + ] writer.writerow(headers) - + # Write data rows for u in queryset: row = [] @@ -3700,11 +4472,11 @@ def export_unterstuetzungen_csv(request, queryset, selected_ids=None): value = field_definitions[field][1](u) row.append(value) except Exception: - row.append('') # Fallback for any errors + row.append("") # Fallback for any errors else: - row.append('') # Unknown field + row.append("") # Unknown field writer.writerow(row) - + return response @@ -3713,76 +4485,98 @@ def export_unterstuetzungen_pdf(request, queryset, selected_ids=None): # If specific entries are selected, filter to only those if selected_ids: queryset = queryset.filter(id__in=selected_ids) - + # Get selected fields from request (default to key fields if none specified) - selected_fields_param = request.POST.get('selected_fields', '') if request.method == 'POST' else request.GET.get('selected_fields', '') - selected_fields = selected_fields_param.split(',') if selected_fields_param else [] - + selected_fields_param = ( + request.POST.get("selected_fields", "") + if request.method == "POST" + else request.GET.get("selected_fields", "") + ) + selected_fields = selected_fields_param.split(",") if selected_fields_param else [] + if not selected_fields: # Default field set for PDF (fewer fields than CSV for better readability) selected_fields = [ - 'destinataer_name', 'betrag', 'faellig_am', 'status', - 'beschreibung', 'ausgezahlt_am' + "destinataer_name", + "betrag", + "faellig_am", + "status", + "beschreibung", + "ausgezahlt_am", ] - + # Field definitions with display names (reuse from CSV but select PDF-appropriate subset) field_definitions = { # Core payment fields - 'destinataer_name': 'Destinatär', - 'betrag': 'Betrag (€)', - 'faellig_am': 'Fällig am', - 'status': 'Status', - 'beschreibung': 'Beschreibung', - 'ausgezahlt_am': 'Ausgezahlt am', - 'erstellt_am': 'Erstellt am', - 'empfaenger_iban': 'Empfänger IBAN', - 'empfaenger_name': 'Empfänger', - 'verwendungszweck': 'Verwendungszweck', - 'konto_name': 'Konto', - 'ist_wiederkehrend': 'Wiederkehrend', + "destinataer_name": "Destinatär", + "betrag": "Betrag (€)", + "faellig_am": "Fällig am", + "status": "Status", + "beschreibung": "Beschreibung", + "ausgezahlt_am": "Ausgezahlt am", + "erstellt_am": "Erstellt am", + "empfaenger_iban": "Empfänger IBAN", + "empfaenger_name": "Empfänger", + "verwendungszweck": "Verwendungszweck", + "konto_name": "Konto", + "ist_wiederkehrend": "Wiederkehrend", } - + # Filter to only include fields that are both selected and defined - filtered_fields = {k: v for k, v in field_definitions.items() if k in selected_fields} - + filtered_fields = { + k: v for k, v in field_definitions.items() if k in selected_fields + } + # Prepare data with field extraction logic data_for_pdf = [] for item in queryset: row_data = {} for field_key in filtered_fields.keys(): try: - if field_key == 'destinataer_name': - row_data[field_key] = item.destinataer.get_full_name() if item.destinataer else '' - elif field_key == 'betrag': - row_data[field_key] = f"€{item.betrag:.2f}" if item.betrag else '' - elif field_key == 'faellig_am': - row_data[field_key] = item.faellig_am.strftime('%d.%m.%Y') if item.faellig_am else '' - elif field_key == 'status': + if field_key == "destinataer_name": + row_data[field_key] = ( + item.destinataer.get_full_name() if item.destinataer else "" + ) + elif field_key == "betrag": + row_data[field_key] = f"€{item.betrag:.2f}" if item.betrag else "" + elif field_key == "faellig_am": + row_data[field_key] = ( + item.faellig_am.strftime("%d.%m.%Y") if item.faellig_am else "" + ) + elif field_key == "status": row_data[field_key] = item.get_status_display() - elif field_key == 'beschreibung': - row_data[field_key] = item.beschreibung or '' - elif field_key == 'ausgezahlt_am': - row_data[field_key] = item.ausgezahlt_am.strftime('%d.%m.%Y') if item.ausgezahlt_am else '' - elif field_key == 'erstellt_am': - row_data[field_key] = item.erstellt_am.strftime('%d.%m.%Y') if item.erstellt_am else '' - elif field_key == 'empfaenger_iban': - row_data[field_key] = item.empfaenger_iban or '' - elif field_key == 'empfaenger_name': - row_data[field_key] = item.empfaenger_name or '' - elif field_key == 'verwendungszweck': - row_data[field_key] = item.verwendungszweck or '' - elif field_key == 'konto_name': - row_data[field_key] = str(item.konto) if item.konto else '' - elif field_key == 'ist_wiederkehrend': - row_data[field_key] = 'Ja' if item.wiederkehrend_von else 'Nein' + elif field_key == "beschreibung": + row_data[field_key] = item.beschreibung or "" + elif field_key == "ausgezahlt_am": + row_data[field_key] = ( + item.ausgezahlt_am.strftime("%d.%m.%Y") + if item.ausgezahlt_am + else "" + ) + elif field_key == "erstellt_am": + row_data[field_key] = ( + item.erstellt_am.strftime("%d.%m.%Y") + if item.erstellt_am + else "" + ) + elif field_key == "empfaenger_iban": + row_data[field_key] = item.empfaenger_iban or "" + elif field_key == "empfaenger_name": + row_data[field_key] = item.empfaenger_name or "" + elif field_key == "verwendungszweck": + row_data[field_key] = item.verwendungszweck or "" + elif field_key == "konto_name": + row_data[field_key] = str(item.konto) if item.konto else "" + elif field_key == "ist_wiederkehrend": + row_data[field_key] = "Ja" if item.wiederkehrend_von else "Nein" else: # Generic field access - row_data[field_key] = getattr(item, field_key, '') or '' + row_data[field_key] = getattr(item, field_key, "") or "" except Exception: - row_data[field_key] = '' # Fallback for any errors - + row_data[field_key] = "" # Fallback for any errors + data_for_pdf.append(row_data) - + # Use PDF generator pdf_gen = get_pdf_generator() return pdf_gen.export_data_list_pdf( @@ -3790,77 +4584,159 @@ def export_unterstuetzungen_pdf(request, queryset, selected_ids=None): fields_config=filtered_fields, title="Unterstützungen Export", filename_prefix="unterstuetzungen", - request_user=request.user + request_user=request.user, ) def export_foerderungen_csv(request, queryset, selected_ids=None): """Enhanced CSV export for Förderungen with field selection""" import csv - from django.http import HttpResponse from datetime import datetime - + + from django.http import HttpResponse + # If specific entries are selected, filter to only those if selected_ids: queryset = queryset.filter(id__in=selected_ids) - + # Get selected fields from request (default to all if none specified) - selected_fields_param = request.POST.get('selected_fields', '') if request.method == 'POST' else request.GET.get('selected_fields', '') - selected_fields = selected_fields_param.split(',') if selected_fields_param else [] - + selected_fields_param = ( + request.POST.get("selected_fields", "") + if request.method == "POST" + else request.GET.get("selected_fields", "") + ) + selected_fields = selected_fields_param.split(",") if selected_fields_param else [] + if not selected_fields: # Default field set selected_fields = [ - 'destinataer_name', 'jahr', 'betrag', 'kategorie', 'status', - 'antragsdatum', 'beschreibung' + "destinataer_name", + "jahr", + "betrag", + "kategorie", + "status", + "antragsdatum", + "beschreibung", ] - + # Field definitions with headers and data extraction field_definitions = { # Core fields - 'id': ('ID', lambda f: str(f.id)), - 'destinataer_name': ('Destinatär Name', lambda f: f.destinataer.get_full_name() if f.destinataer else ''), - 'jahr': ('Jahr', lambda f: str(f.jahr)), - 'betrag': ('Betrag (€)', lambda f: f"{f.betrag:.2f}"), - 'kategorie': ('Kategorie', lambda f: f.get_kategorie_display()), - 'status': ('Status', lambda f: f.get_status_display()), - 'antragsdatum': ('Antragsdatum', lambda f: f.antragsdatum.strftime('%d.%m.%Y') if f.antragsdatum else ''), - 'bewilligungsdatum': ('Bewilligungsdatum', lambda f: f.bewilligungsdatum.strftime('%d.%m.%Y') if f.bewilligungsdatum else ''), - 'auszahlungsdatum': ('Auszahlungsdatum', lambda f: f.auszahlungsdatum.strftime('%d.%m.%Y') if f.auszahlungsdatum else ''), - 'beschreibung': ('Beschreibung', lambda f: f.beschreibung or ''), - 'begruendung': ('Begründung', lambda f: f.begruendung or ''), - 'verwendungsnachweis_datum': ('Verwendungsnachweis Datum', lambda f: f.verwendungsnachweis_datum.strftime('%d.%m.%Y') if f.verwendungsnachweis_datum else ''), - 'verwendungsnachweis_status': ('Verwendungsnachweis Status', lambda f: f.get_verwendungsnachweis_status_display() if f.verwendungsnachweis_status else ''), - + "id": ("ID", lambda f: str(f.id)), + "destinataer_name": ( + "Destinatär Name", + lambda f: f.destinataer.get_full_name() if f.destinataer else "", + ), + "jahr": ("Jahr", lambda f: str(f.jahr)), + "betrag": ("Betrag (€)", lambda f: f"{f.betrag:.2f}"), + "kategorie": ("Kategorie", lambda f: f.get_kategorie_display()), + "status": ("Status", lambda f: f.get_status_display()), + "antragsdatum": ( + "Antragsdatum", + lambda f: f.antragsdatum.strftime("%d.%m.%Y") if f.antragsdatum else "", + ), + "bewilligungsdatum": ( + "Bewilligungsdatum", + lambda f: ( + f.bewilligungsdatum.strftime("%d.%m.%Y") if f.bewilligungsdatum else "" + ), + ), + "auszahlungsdatum": ( + "Auszahlungsdatum", + lambda f: ( + f.auszahlungsdatum.strftime("%d.%m.%Y") if f.auszahlungsdatum else "" + ), + ), + "beschreibung": ("Beschreibung", lambda f: f.beschreibung or ""), + "begruendung": ("Begründung", lambda f: f.begruendung or ""), + "verwendungsnachweis_datum": ( + "Verwendungsnachweis Datum", + lambda f: ( + f.verwendungsnachweis_datum.strftime("%d.%m.%Y") + if f.verwendungsnachweis_datum + else "" + ), + ), + "verwendungsnachweis_status": ( + "Verwendungsnachweis Status", + lambda f: ( + f.get_verwendungsnachweis_status_display() + if f.verwendungsnachweis_status + else "" + ), + ), # Destinataer fields - 'destinataer_vorname': ('Vorname', lambda f: f.destinataer.vorname if f.destinataer else ''), - 'destinataer_nachname': ('Nachname', lambda f: f.destinataer.nachname if f.destinataer else ''), - 'familienzweig': ('Familienzweig', lambda f: f.destinataer.familienzweig if f.destinataer else ''), - 'email': ('E-Mail', lambda f: f.destinataer.email if f.destinataer else ''), - 'telefon': ('Telefon', lambda f: f.destinataer.telefon if f.destinataer else ''), - 'adresse': ('Adresse', lambda f: f"{f.destinataer.strasse}, {f.destinataer.plz} {f.destinataer.ort}".strip(', ') if f.destinataer else ''), - 'berufsgruppe': ('Berufsgruppe', lambda f: f.destinataer.berufsgruppe if f.destinataer else ''), - 'ausbildungsstand': ('Ausbildungsstand', lambda f: f.destinataer.ausbildungsstand if f.destinataer else ''), - 'institution': ('Institution', lambda f: f.destinataer.institution if f.destinataer else ''), - + "destinataer_vorname": ( + "Vorname", + lambda f: f.destinataer.vorname if f.destinataer else "", + ), + "destinataer_nachname": ( + "Nachname", + lambda f: f.destinataer.nachname if f.destinataer else "", + ), + "familienzweig": ( + "Familienzweig", + lambda f: f.destinataer.familienzweig if f.destinataer else "", + ), + "email": ("E-Mail", lambda f: f.destinataer.email if f.destinataer else ""), + "telefon": ( + "Telefon", + lambda f: f.destinataer.telefon if f.destinataer else "", + ), + "adresse": ( + "Adresse", + lambda f: ( + f"{f.destinataer.strasse}, {f.destinataer.plz} {f.destinataer.ort}".strip( + ", " + ) + if f.destinataer + else "" + ), + ), + "berufsgruppe": ( + "Berufsgruppe", + lambda f: f.destinataer.berufsgruppe if f.destinataer else "", + ), + "ausbildungsstand": ( + "Ausbildungsstand", + lambda f: f.destinataer.ausbildungsstand if f.destinataer else "", + ), + "institution": ( + "Institution", + lambda f: f.destinataer.institution if f.destinataer else "", + ), # System fields - 'erstellt_am': ('Erstellt am', lambda f: f.erstellt_am.strftime('%d.%m.%Y %H:%M') if f.erstellt_am else ''), - 'aktualisiert_am': ('Aktualisiert am', lambda f: f.aktualisiert_am.strftime('%d.%m.%Y %H:%M') if f.aktualisiert_am else ''), + "erstellt_am": ( + "Erstellt am", + lambda f: f.erstellt_am.strftime("%d.%m.%Y %H:%M") if f.erstellt_am else "", + ), + "aktualisiert_am": ( + "Aktualisiert am", + lambda f: ( + f.aktualisiert_am.strftime("%d.%m.%Y %H:%M") + if f.aktualisiert_am + else "" + ), + ), } - + # Create CSV response - timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') - filename = f'foerderungen_{timestamp}.csv' - - response = HttpResponse(content_type='text/csv; charset=utf-8') - response['Content-Disposition'] = f'attachment; filename="{filename}"' - - writer = csv.writer(response, delimiter=';', quoting=csv.QUOTE_ALL) - + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + filename = f"foerderungen_{timestamp}.csv" + + response = HttpResponse(content_type="text/csv; charset=utf-8") + response["Content-Disposition"] = f'attachment; filename="{filename}"' + + writer = csv.writer(response, delimiter=";", quoting=csv.QUOTE_ALL) + # Write headers - headers = [field_definitions[field][0] for field in selected_fields if field in field_definitions] + headers = [ + field_definitions[field][0] + for field in selected_fields + if field in field_definitions + ] writer.writerow(headers) - + # Write data rows for f in queryset: row = [] @@ -3870,11 +4746,11 @@ def export_foerderungen_csv(request, queryset, selected_ids=None): value = field_definitions[field][1](f) row.append(value) except Exception: - row.append('') # Fallback for any errors + row.append("") # Fallback for any errors else: - row.append('') # Unknown field + row.append("") # Unknown field writer.writerow(row) - + return response @@ -3883,72 +4759,104 @@ def export_foerderungen_pdf(request, queryset, selected_ids=None): # If specific entries are selected, filter to only those if selected_ids: queryset = queryset.filter(id__in=selected_ids) - + # Get selected fields from request (default to key fields if none specified) - selected_fields_param = request.POST.get('selected_fields', '') if request.method == 'POST' else request.GET.get('selected_fields', '') - selected_fields = selected_fields_param.split(',') if selected_fields_param else [] - + selected_fields_param = ( + request.POST.get("selected_fields", "") + if request.method == "POST" + else request.GET.get("selected_fields", "") + ) + selected_fields = selected_fields_param.split(",") if selected_fields_param else [] + if not selected_fields: # Default field set for PDF (fewer fields than CSV for better readability) selected_fields = [ - 'destinataer_name', 'jahr', 'betrag', 'kategorie', 'status', - 'antragsdatum' + "destinataer_name", + "jahr", + "betrag", + "kategorie", + "status", + "antragsdatum", ] - + # Field definitions with display names field_definitions = { - 'destinataer_name': 'Destinatär', - 'jahr': 'Jahr', - 'betrag': 'Betrag (€)', - 'kategorie': 'Kategorie', - 'status': 'Status', - 'antragsdatum': 'Antragsdatum', - 'bewilligungsdatum': 'Bewilligungsdatum', - 'auszahlungsdatum': 'Auszahlungsdatum', - 'beschreibung': 'Beschreibung', - 'begruendung': 'Begründung', - 'verwendungsnachweis_status': 'Verwendungsnachweis', + "destinataer_name": "Destinatär", + "jahr": "Jahr", + "betrag": "Betrag (€)", + "kategorie": "Kategorie", + "status": "Status", + "antragsdatum": "Antragsdatum", + "bewilligungsdatum": "Bewilligungsdatum", + "auszahlungsdatum": "Auszahlungsdatum", + "beschreibung": "Beschreibung", + "begruendung": "Begründung", + "verwendungsnachweis_status": "Verwendungsnachweis", } - + # Filter to only include fields that are both selected and defined - filtered_fields = {k: v for k, v in field_definitions.items() if k in selected_fields} - + filtered_fields = { + k: v for k, v in field_definitions.items() if k in selected_fields + } + # Prepare data with field extraction logic data_for_pdf = [] for item in queryset: row_data = {} for field_key in filtered_fields.keys(): try: - if field_key == 'destinataer_name': - row_data[field_key] = item.destinataer.get_full_name() if item.destinataer else '' - elif field_key == 'jahr': + if field_key == "destinataer_name": + row_data[field_key] = ( + item.destinataer.get_full_name() if item.destinataer else "" + ) + elif field_key == "jahr": row_data[field_key] = str(item.jahr) - elif field_key == 'betrag': - row_data[field_key] = f"€{item.betrag:.2f}" if item.betrag else '' - elif field_key == 'kategorie': + elif field_key == "betrag": + row_data[field_key] = f"€{item.betrag:.2f}" if item.betrag else "" + elif field_key == "kategorie": row_data[field_key] = item.get_kategorie_display() - elif field_key == 'status': + elif field_key == "status": row_data[field_key] = item.get_status_display() - elif field_key == 'antragsdatum': - row_data[field_key] = item.antragsdatum.strftime('%d.%m.%Y') if item.antragsdatum else '' - elif field_key == 'bewilligungsdatum': - row_data[field_key] = item.bewilligungsdatum.strftime('%d.%m.%Y') if item.bewilligungsdatum else '' - elif field_key == 'auszahlungsdatum': - row_data[field_key] = item.auszahlungsdatum.strftime('%d.%m.%Y') if item.auszahlungsdatum else '' - elif field_key == 'beschreibung': - row_data[field_key] = (item.beschreibung or '')[:100] + ('...' if len(item.beschreibung or '') > 100 else '') - elif field_key == 'begruendung': - row_data[field_key] = (item.begruendung or '')[:100] + ('...' if len(item.begruendung or '') > 100 else '') - elif field_key == 'verwendungsnachweis_status': - row_data[field_key] = item.get_verwendungsnachweis_status_display() if item.verwendungsnachweis_status else '' + elif field_key == "antragsdatum": + row_data[field_key] = ( + item.antragsdatum.strftime("%d.%m.%Y") + if item.antragsdatum + else "" + ) + elif field_key == "bewilligungsdatum": + row_data[field_key] = ( + item.bewilligungsdatum.strftime("%d.%m.%Y") + if item.bewilligungsdatum + else "" + ) + elif field_key == "auszahlungsdatum": + row_data[field_key] = ( + item.auszahlungsdatum.strftime("%d.%m.%Y") + if item.auszahlungsdatum + else "" + ) + elif field_key == "beschreibung": + row_data[field_key] = (item.beschreibung or "")[:100] + ( + "..." if len(item.beschreibung or "") > 100 else "" + ) + elif field_key == "begruendung": + row_data[field_key] = (item.begruendung or "")[:100] + ( + "..." if len(item.begruendung or "") > 100 else "" + ) + elif field_key == "verwendungsnachweis_status": + row_data[field_key] = ( + item.get_verwendungsnachweis_status_display() + if item.verwendungsnachweis_status + else "" + ) else: # Generic field access - row_data[field_key] = getattr(item, field_key, '') or '' + row_data[field_key] = getattr(item, field_key, "") or "" except Exception: - row_data[field_key] = '' # Fallback for any errors - + row_data[field_key] = "" # Fallback for any errors + data_for_pdf.append(row_data) - + # Use PDF generator pdf_gen = get_pdf_generator() return pdf_gen.export_data_list_pdf( @@ -3956,43 +4864,51 @@ def export_foerderungen_pdf(request, queryset, selected_ids=None): fields_config=filtered_fields, title="Förderungen Export", filename_prefix="foerderungen", - request_user=request.user + request_user=request.user, ) @login_required def unterstuetzung_edit(request, pk): obj = get_object_or_404(DestinataerUnterstuetzung, pk=pk) - if request.method == 'POST': + if request.method == "POST": form = DestinataerUnterstuetzungForm(request.POST, instance=obj) if form.is_valid(): form.save() - messages.success(request, 'Unterstützung aktualisiert.') - return redirect('stiftung:unterstuetzungen_list') + messages.success(request, "Unterstützung aktualisiert.") + return redirect("stiftung:unterstuetzungen_list") else: form = DestinataerUnterstuetzungForm(instance=obj) - return render(request, 'stiftung/unterstuetzung_form.html', {'form': form, 'title': 'Unterstützung bearbeiten'}) + return render( + request, + "stiftung/unterstuetzung_form.html", + {"form": form, "title": "Unterstützung bearbeiten"}, + ) @login_required def unterstuetzung_delete(request, pk): obj = get_object_or_404(DestinataerUnterstuetzung, pk=pk) - + # Check if this will also delete the recurring template will_delete_template = False if obj.wiederkehrend_von: - andere_zahlungen = DestinataerUnterstuetzung.objects.filter( - wiederkehrend_von=obj.wiederkehrend_von - ).exclude(pk=pk).exists() + andere_zahlungen = ( + DestinataerUnterstuetzung.objects.filter( + wiederkehrend_von=obj.wiederkehrend_von + ) + .exclude(pk=pk) + .exists() + ) will_delete_template = not andere_zahlungen - - if request.method == 'POST': + + if request.method == "POST": # Check if this support payment is linked to a recurring payment template wiederkehrend_template = obj.wiederkehrend_von - + # Delete the support payment obj.delete() - + # If this was generated from a recurring template and there are no other # payments from this template, delete the template too if wiederkehrend_template: @@ -4000,168 +4916,227 @@ def unterstuetzung_delete(request, pk): andere_zahlungen = DestinataerUnterstuetzung.objects.filter( wiederkehrend_von=wiederkehrend_template ).exists() - + # If no other payments exist from this template, delete the template too if not andere_zahlungen: wiederkehrend_template.delete() - messages.success(request, 'Unterstützung und wiederkehrende Zahlungsvorlage gelöscht.') + messages.success( + request, + "Unterstützung und wiederkehrende Zahlungsvorlage gelöscht.", + ) else: - messages.success(request, 'Unterstützung gelöscht.') + messages.success(request, "Unterstützung gelöscht.") else: - messages.success(request, 'Unterstützung gelöscht.') - - return redirect('stiftung:unterstuetzungen_list') - + messages.success(request, "Unterstützung gelöscht.") + + return redirect("stiftung:unterstuetzungen_list") + context = { - 'obj': obj, - 'will_delete_template': will_delete_template, + "obj": obj, + "will_delete_template": will_delete_template, } - return render(request, 'stiftung/unterstuetzung_confirm_delete.html', context) + return render(request, "stiftung/unterstuetzung_confirm_delete.html", context) @login_required def destinataer_notiz_create(request, pk): destinataer = get_object_or_404(Destinataer, pk=pk) - if request.method == 'POST': + if request.method == "POST": form = DestinataerNotizForm(request.POST, request.FILES) if form.is_valid(): note = form.save(commit=False) note.destinataer = destinataer note.erstellt_von = request.user note.save() - messages.success(request, 'Notiz wurde gespeichert.') - return redirect('stiftung:destinataer_detail', pk=destinataer.pk) + messages.success(request, "Notiz wurde gespeichert.") + return redirect("stiftung:destinataer_detail", pk=destinataer.pk) else: # Debug: show what validation failed for field, errors in form.errors.items(): messages.error(request, f'Fehler in {field}: {", ".join(errors)}') else: form = DestinataerNotizForm() - return render(request, 'stiftung/destinataer_notiz_form.html', {'form': form, 'destinataer': destinataer, 'title': 'Notiz hinzufügen'}) + return render( + request, + "stiftung/destinataer_notiz_form.html", + {"form": form, "destinataer": destinataer, "title": "Notiz hinzufügen"}, + ) @login_required def destinataer_export(request, pk): """Export complete Destinatär data as ZIP with documents""" - import zipfile import json - import tempfile import os + import tempfile + import zipfile + from django.http import HttpResponse - + destinataer = get_object_or_404(Destinataer, pk=pk) - + # Create a temporary file for the ZIP - temp_file = tempfile.NamedTemporaryFile(delete=False, suffix='.zip') - + temp_file = tempfile.NamedTemporaryFile(delete=False, suffix=".zip") + try: - with zipfile.ZipFile(temp_file.name, 'w', zipfile.ZIP_DEFLATED) as zipf: + with zipfile.ZipFile(temp_file.name, "w", zipfile.ZIP_DEFLATED) as zipf: # 1. Entity data as JSON entity_data = { - 'id': str(destinataer.id), - 'vorname': destinataer.vorname, - 'nachname': destinataer.nachname, - 'geburtsdatum': destinataer.geburtsdatum.isoformat() if destinataer.geburtsdatum else None, - 'email': destinataer.email, - 'telefon': destinataer.telefon, - 'iban': destinataer.iban, - 'strasse': destinataer.strasse, - 'plz': destinataer.plz, - 'ort': destinataer.ort, - 'familienzweig': destinataer.get_familienzweig_display(), - 'berufsgruppe': destinataer.get_berufsgruppe_display(), - 'ausbildungsstand': destinataer.ausbildungsstand, - 'institution': destinataer.institution, - 'projekt_beschreibung': destinataer.projekt_beschreibung, - 'jaehrliches_einkommen': str(destinataer.jaehrliches_einkommen) if destinataer.jaehrliches_einkommen else None, - 'finanzielle_notlage': destinataer.finanzielle_notlage, - 'ist_abkoemmling': destinataer.ist_abkoemmling, - 'haushaltsgroesse': destinataer.haushaltsgroesse, - 'monatliche_bezuege': str(destinataer.monatliche_bezuege) if destinataer.monatliche_bezuege else None, - 'vermoegen': str(destinataer.vermoegen) if destinataer.vermoegen else None, - 'unterstuetzung_bestaetigt': destinataer.unterstuetzung_bestaetigt, - 'vierteljaehrlicher_betrag': str(destinataer.vierteljaehrlicher_betrag) if destinataer.vierteljaehrlicher_betrag else None, - 'standard_konto': str(destinataer.standard_konto) if destinataer.standard_konto else None, - 'studiennachweis_erforderlich': destinataer.studiennachweis_erforderlich, - 'letzter_studiennachweis': destinataer.letzter_studiennachweis.isoformat() if destinataer.letzter_studiennachweis else None, - 'notizen': destinataer.notizen, - 'aktiv': destinataer.aktiv, - 'export_datum': timezone.now().isoformat(), - 'export_user': request.user.username, + "id": str(destinataer.id), + "vorname": destinataer.vorname, + "nachname": destinataer.nachname, + "geburtsdatum": ( + destinataer.geburtsdatum.isoformat() + if destinataer.geburtsdatum + else None + ), + "email": destinataer.email, + "telefon": destinataer.telefon, + "iban": destinataer.iban, + "strasse": destinataer.strasse, + "plz": destinataer.plz, + "ort": destinataer.ort, + "familienzweig": destinataer.get_familienzweig_display(), + "berufsgruppe": destinataer.get_berufsgruppe_display(), + "ausbildungsstand": destinataer.ausbildungsstand, + "institution": destinataer.institution, + "projekt_beschreibung": destinataer.projekt_beschreibung, + "jaehrliches_einkommen": ( + str(destinataer.jaehrliches_einkommen) + if destinataer.jaehrliches_einkommen + else None + ), + "finanzielle_notlage": destinataer.finanzielle_notlage, + "ist_abkoemmling": destinataer.ist_abkoemmling, + "haushaltsgroesse": destinataer.haushaltsgroesse, + "monatliche_bezuege": ( + str(destinataer.monatliche_bezuege) + if destinataer.monatliche_bezuege + else None + ), + "vermoegen": ( + str(destinataer.vermoegen) if destinataer.vermoegen else None + ), + "unterstuetzung_bestaetigt": destinataer.unterstuetzung_bestaetigt, + "vierteljaehrlicher_betrag": ( + str(destinataer.vierteljaehrlicher_betrag) + if destinataer.vierteljaehrlicher_betrag + else None + ), + "standard_konto": ( + str(destinataer.standard_konto) + if destinataer.standard_konto + else None + ), + "studiennachweis_erforderlich": destinataer.studiennachweis_erforderlich, + "letzter_studiennachweis": ( + destinataer.letzter_studiennachweis.isoformat() + if destinataer.letzter_studiennachweis + else None + ), + "notizen": destinataer.notizen, + "aktiv": destinataer.aktiv, + "export_datum": timezone.now().isoformat(), + "export_user": request.user.username, } - zipf.writestr('destinataer_data.json', json.dumps(entity_data, indent=2, ensure_ascii=False)) - + zipf.writestr( + "destinataer_data.json", + json.dumps(entity_data, indent=2, ensure_ascii=False), + ) + # 2. Notes with attachments - notizen = DestinataerNotiz.objects.filter(destinataer=destinataer).order_by('-erstellt_am') + notizen = DestinataerNotiz.objects.filter(destinataer=destinataer).order_by( + "-erstellt_am" + ) notes_data = [] for note in notizen: note_data = { - 'titel': note.titel, - 'text': note.text, - 'erstellt_am': note.erstellt_am.isoformat(), - 'erstellt_von': note.erstellt_von.username if note.erstellt_von else None, - 'datei_name': note.datei.name if note.datei else None, + "titel": note.titel, + "text": note.text, + "erstellt_am": note.erstellt_am.isoformat(), + "erstellt_von": ( + note.erstellt_von.username if note.erstellt_von else None + ), + "datei_name": note.datei.name if note.datei else None, } notes_data.append(note_data) - + # Add attachment file if exists if note.datei and os.path.exists(note.datei.path): - zipf.write(note.datei.path, f'notizen_anhaenge/{os.path.basename(note.datei.name)}') - + zipf.write( + note.datei.path, + f"notizen_anhaenge/{os.path.basename(note.datei.name)}", + ) + if notes_data: - zipf.writestr('notizen.json', json.dumps(notes_data, indent=2, ensure_ascii=False)) - + zipf.writestr( + "notizen.json", json.dumps(notes_data, indent=2, ensure_ascii=False) + ) + # 3. Linked documents from Paperless dokumente = DokumentLink.objects.filter(destinataer_id=destinataer.pk) docs_data = [] for doc in dokumente: doc_data = { - 'paperless_id': doc.paperless_document_id, - 'titel': doc.titel, - 'kontext': doc.get_kontext_display(), - 'beschreibung': doc.beschreibung, + "paperless_id": doc.paperless_document_id, + "titel": doc.titel, + "kontext": doc.get_kontext_display(), + "beschreibung": doc.beschreibung, } docs_data.append(doc_data) - + # Try to download document from Paperless try: - if hasattr(settings, 'PAPERLESS_API_URL') and settings.PAPERLESS_API_URL: + if ( + hasattr(settings, "PAPERLESS_API_URL") + and settings.PAPERLESS_API_URL + ): doc_url = f"{settings.PAPERLESS_API_URL}/api/documents/{doc.paperless_document_id}/download/" headers = {} - if hasattr(settings, 'PAPERLESS_API_TOKEN') and settings.PAPERLESS_API_TOKEN: - headers['Authorization'] = f'Token {settings.PAPERLESS_API_TOKEN}' - + if ( + hasattr(settings, "PAPERLESS_API_TOKEN") + and settings.PAPERLESS_API_TOKEN + ): + headers["Authorization"] = ( + f"Token {settings.PAPERLESS_API_TOKEN}" + ) + response = requests.get(doc_url, headers=headers, timeout=30) if response.status_code == 200: # Determine file extension from Content-Type or use .pdf as fallback - content_type = response.headers.get('content-type', '') - if 'pdf' in content_type: - ext = '.pdf' - elif 'jpeg' in content_type or 'jpg' in content_type: - ext = '.jpg' - elif 'png' in content_type: - ext = '.png' + content_type = response.headers.get("content-type", "") + if "pdf" in content_type: + ext = ".pdf" + elif "jpeg" in content_type or "jpg" in content_type: + ext = ".jpg" + elif "png" in content_type: + ext = ".png" else: - ext = '.pdf' # fallback - + ext = ".pdf" # fallback + safe_filename = f"dokument_{doc.paperless_document_id}_{doc.titel.replace('/', '_')[:50]}{ext}" - zipf.writestr(f'dokumente/{safe_filename}', response.content) - doc_data['downloaded'] = True + zipf.writestr( + f"dokumente/{safe_filename}", response.content + ) + doc_data["downloaded"] = True else: - doc_data['download_error'] = f'HTTP {response.status_code}' + doc_data["download_error"] = f"HTTP {response.status_code}" except Exception as e: - doc_data['download_error'] = str(e) - + doc_data["download_error"] = str(e) + if docs_data: - zipf.writestr('dokumente.json', json.dumps(docs_data, indent=2, ensure_ascii=False)) - + zipf.writestr( + "dokumente.json", + json.dumps(docs_data, indent=2, ensure_ascii=False), + ) + # Prepare response - with open(temp_file.name, 'rb') as f: - response = HttpResponse(f.read(), content_type='application/zip') + with open(temp_file.name, "rb") as f: + response = HttpResponse(f.read(), content_type="application/zip") filename = f"destinataer_{destinataer.nachname}_{destinataer.vorname}_{timezone.now().strftime('%Y%m%d_%H%M%S')}.zip" - response['Content-Disposition'] = f'attachment; filename="{filename}"' + response["Content-Disposition"] = f'attachment; filename="{filename}"' return response - + finally: # Clean up temp file try: @@ -4173,98 +5148,129 @@ def destinataer_export(request, pk): @login_required def paechter_export(request, pk): """Export complete Pächter data as ZIP with documents""" - import zipfile import json - import tempfile import os + import tempfile + import zipfile + from django.http import HttpResponse - + paechter = get_object_or_404(Paechter, pk=pk) - + # Create a temporary file for the ZIP - temp_file = tempfile.NamedTemporaryFile(delete=False, suffix='.zip') - + temp_file = tempfile.NamedTemporaryFile(delete=False, suffix=".zip") + try: - with zipfile.ZipFile(temp_file.name, 'w', zipfile.ZIP_DEFLATED) as zipf: + with zipfile.ZipFile(temp_file.name, "w", zipfile.ZIP_DEFLATED) as zipf: # 1. Entity data as JSON entity_data = { - 'id': str(paechter.id), - 'vorname': paechter.vorname, - 'nachname': paechter.nachname, - 'geburtsdatum': paechter.geburtsdatum.isoformat() if paechter.geburtsdatum else None, - 'email': paechter.email, - 'telefon': paechter.telefon, - 'iban': paechter.iban, - 'strasse': paechter.strasse, - 'plz': paechter.plz, - 'ort': paechter.ort, - 'personentyp': paechter.get_personentyp_display(), - 'pachtnummer': paechter.pachtnummer, - 'pachtbeginn_erste': paechter.pachtbeginn_erste.isoformat() if paechter.pachtbeginn_erste else None, - 'pachtende_letzte': paechter.pachtende_letzte.isoformat() if paechter.pachtende_letzte else None, - 'pachtzins_aktuell': str(paechter.pachtzins_aktuell) if paechter.pachtzins_aktuell else None, - 'landwirtschaftliche_ausbildung': paechter.landwirtschaftliche_ausbildung, - 'berufserfahrung_jahre': paechter.berufserfahrung_jahre, - 'spezialisierung': paechter.spezialisierung, - 'notizen': paechter.notizen, - 'aktiv': paechter.aktiv, - 'gesamt_pachtflaeche': float(paechter.get_gesamt_pachtflaeche()), - 'gesamt_pachtzins': float(paechter.get_gesamt_pachtzins()), - 'export_datum': timezone.now().isoformat(), - 'export_user': request.user.username, + "id": str(paechter.id), + "vorname": paechter.vorname, + "nachname": paechter.nachname, + "geburtsdatum": ( + paechter.geburtsdatum.isoformat() if paechter.geburtsdatum else None + ), + "email": paechter.email, + "telefon": paechter.telefon, + "iban": paechter.iban, + "strasse": paechter.strasse, + "plz": paechter.plz, + "ort": paechter.ort, + "personentyp": paechter.get_personentyp_display(), + "pachtnummer": paechter.pachtnummer, + "pachtbeginn_erste": ( + paechter.pachtbeginn_erste.isoformat() + if paechter.pachtbeginn_erste + else None + ), + "pachtende_letzte": ( + paechter.pachtende_letzte.isoformat() + if paechter.pachtende_letzte + else None + ), + "pachtzins_aktuell": ( + str(paechter.pachtzins_aktuell) + if paechter.pachtzins_aktuell + else None + ), + "landwirtschaftliche_ausbildung": paechter.landwirtschaftliche_ausbildung, + "berufserfahrung_jahre": paechter.berufserfahrung_jahre, + "spezialisierung": paechter.spezialisierung, + "notizen": paechter.notizen, + "aktiv": paechter.aktiv, + "gesamt_pachtflaeche": float(paechter.get_gesamt_pachtflaeche()), + "gesamt_pachtzins": float(paechter.get_gesamt_pachtzins()), + "export_datum": timezone.now().isoformat(), + "export_user": request.user.username, } - zipf.writestr('paechter_data.json', json.dumps(entity_data, indent=2, ensure_ascii=False)) - + zipf.writestr( + "paechter_data.json", + json.dumps(entity_data, indent=2, ensure_ascii=False), + ) + # 2. Linked documents from Paperless dokumente = DokumentLink.objects.filter(paechter_id=paechter.pk) docs_data = [] for doc in dokumente: doc_data = { - 'paperless_id': doc.paperless_document_id, - 'titel': doc.titel, - 'kontext': doc.get_kontext_display(), - 'beschreibung': doc.beschreibung, + "paperless_id": doc.paperless_document_id, + "titel": doc.titel, + "kontext": doc.get_kontext_display(), + "beschreibung": doc.beschreibung, } docs_data.append(doc_data) - + # Try to download document from Paperless try: - if hasattr(settings, 'PAPERLESS_API_URL') and settings.PAPERLESS_API_URL: + if ( + hasattr(settings, "PAPERLESS_API_URL") + and settings.PAPERLESS_API_URL + ): doc_url = f"{settings.PAPERLESS_API_URL}/api/documents/{doc.paperless_document_id}/download/" headers = {} - if hasattr(settings, 'PAPERLESS_API_TOKEN') and settings.PAPERLESS_API_TOKEN: - headers['Authorization'] = f'Token {settings.PAPERLESS_API_TOKEN}' - + if ( + hasattr(settings, "PAPERLESS_API_TOKEN") + and settings.PAPERLESS_API_TOKEN + ): + headers["Authorization"] = ( + f"Token {settings.PAPERLESS_API_TOKEN}" + ) + response = requests.get(doc_url, headers=headers, timeout=30) if response.status_code == 200: - content_type = response.headers.get('content-type', '') - if 'pdf' in content_type: - ext = '.pdf' - elif 'jpeg' in content_type or 'jpg' in content_type: - ext = '.jpg' - elif 'png' in content_type: - ext = '.png' + content_type = response.headers.get("content-type", "") + if "pdf" in content_type: + ext = ".pdf" + elif "jpeg" in content_type or "jpg" in content_type: + ext = ".jpg" + elif "png" in content_type: + ext = ".png" else: - ext = '.pdf' - + ext = ".pdf" + safe_filename = f"dokument_{doc.paperless_document_id}_{doc.titel.replace('/', '_')[:50]}{ext}" - zipf.writestr(f'dokumente/{safe_filename}', response.content) - doc_data['downloaded'] = True + zipf.writestr( + f"dokumente/{safe_filename}", response.content + ) + doc_data["downloaded"] = True else: - doc_data['download_error'] = f'HTTP {response.status_code}' + doc_data["download_error"] = f"HTTP {response.status_code}" except Exception as e: - doc_data['download_error'] = str(e) - + doc_data["download_error"] = str(e) + if docs_data: - zipf.writestr('dokumente.json', json.dumps(docs_data, indent=2, ensure_ascii=False)) - + zipf.writestr( + "dokumente.json", + json.dumps(docs_data, indent=2, ensure_ascii=False), + ) + # Prepare response - with open(temp_file.name, 'rb') as f: - response = HttpResponse(f.read(), content_type='application/zip') + with open(temp_file.name, "rb") as f: + response = HttpResponse(f.read(), content_type="application/zip") filename = f"paechter_{paechter.nachname}_{paechter.vorname}_{timezone.now().strftime('%Y%m%d_%H%M%S')}.zip" - response['Content-Disposition'] = f'attachment; filename="{filename}"' + response["Content-Disposition"] = f'attachment; filename="{filename}"' return response - + finally: try: os.unlink(temp_file.name) @@ -4275,100 +5281,120 @@ def paechter_export(request, pk): @login_required def land_export(request, pk): """Export complete Land data as ZIP with documents""" - import zipfile import json - import tempfile import os + import tempfile + import zipfile + from django.http import HttpResponse - + land = get_object_or_404(Land, pk=pk) - + # Create a temporary file for the ZIP - temp_file = tempfile.NamedTemporaryFile(delete=False, suffix='.zip') - + temp_file = tempfile.NamedTemporaryFile(delete=False, suffix=".zip") + try: - with zipfile.ZipFile(temp_file.name, 'w', zipfile.ZIP_DEFLATED) as zipf: + with zipfile.ZipFile(temp_file.name, "w", zipfile.ZIP_DEFLATED) as zipf: # 1. Entity data as JSON entity_data = { - 'id': str(land.id), - 'lfd_nr': land.lfd_nr, - 'ew_nummer': land.ew_nummer, - 'amtsgericht': land.amtsgericht, - 'gemeinde': land.gemeinde, - 'gemarkung': land.gemarkung, - 'flur': land.flur, - 'flurstueck': land.flurstueck, - 'groesse_qm': str(land.groesse_qm), - 'gruenland_qm': str(land.gruenland_qm), - 'acker_qm': str(land.acker_qm), - 'wald_qm': str(land.wald_qm), - 'sonstiges_qm': str(land.sonstiges_qm), - 'verpachtete_gesamtflaeche': str(land.verpachtete_gesamtflaeche), - 'flaeche_alte_liste': str(land.flaeche_alte_liste) if land.flaeche_alte_liste else None, - 'verp_flaeche_aktuell': str(land.verp_flaeche_aktuell), - 'anteil_grundsteuer': str(land.anteil_grundsteuer) if land.anteil_grundsteuer else None, - 'anteil_lwk': str(land.anteil_lwk) if land.anteil_lwk else None, - 'aktiv': land.aktiv, - 'notizen': land.notizen, - 'erstellt_am': land.erstellt_am.isoformat(), - 'aktualisiert_am': land.aktualisiert_am.isoformat(), - 'gesamtflaeche_berechnet': float(land.get_gesamtflaeche()), - 'verpachtungsgrad': float(land.get_verpachtungsgrad()), - 'export_datum': timezone.now().isoformat(), - 'export_user': request.user.username, + "id": str(land.id), + "lfd_nr": land.lfd_nr, + "ew_nummer": land.ew_nummer, + "amtsgericht": land.amtsgericht, + "gemeinde": land.gemeinde, + "gemarkung": land.gemarkung, + "flur": land.flur, + "flurstueck": land.flurstueck, + "groesse_qm": str(land.groesse_qm), + "gruenland_qm": str(land.gruenland_qm), + "acker_qm": str(land.acker_qm), + "wald_qm": str(land.wald_qm), + "sonstiges_qm": str(land.sonstiges_qm), + "verpachtete_gesamtflaeche": str(land.verpachtete_gesamtflaeche), + "flaeche_alte_liste": ( + str(land.flaeche_alte_liste) if land.flaeche_alte_liste else None + ), + "verp_flaeche_aktuell": str(land.verp_flaeche_aktuell), + "anteil_grundsteuer": ( + str(land.anteil_grundsteuer) if land.anteil_grundsteuer else None + ), + "anteil_lwk": str(land.anteil_lwk) if land.anteil_lwk else None, + "aktiv": land.aktiv, + "notizen": land.notizen, + "erstellt_am": land.erstellt_am.isoformat(), + "aktualisiert_am": land.aktualisiert_am.isoformat(), + "gesamtflaeche_berechnet": float(land.get_gesamtflaeche()), + "verpachtungsgrad": float(land.get_verpachtungsgrad()), + "export_datum": timezone.now().isoformat(), + "export_user": request.user.username, } - zipf.writestr('land_data.json', json.dumps(entity_data, indent=2, ensure_ascii=False)) - + zipf.writestr( + "land_data.json", json.dumps(entity_data, indent=2, ensure_ascii=False) + ) + # 2. Linked documents from Paperless dokumente = DokumentLink.objects.filter(land_id=land.pk) docs_data = [] for doc in dokumente: doc_data = { - 'paperless_id': doc.paperless_document_id, - 'titel': doc.titel, - 'kontext': doc.get_kontext_display(), - 'beschreibung': doc.beschreibung, + "paperless_id": doc.paperless_document_id, + "titel": doc.titel, + "kontext": doc.get_kontext_display(), + "beschreibung": doc.beschreibung, } docs_data.append(doc_data) - + # Try to download document from Paperless try: - if hasattr(settings, 'PAPERLESS_API_URL') and settings.PAPERLESS_API_URL: + if ( + hasattr(settings, "PAPERLESS_API_URL") + and settings.PAPERLESS_API_URL + ): doc_url = f"{settings.PAPERLESS_API_URL}/api/documents/{doc.paperless_document_id}/download/" headers = {} - if hasattr(settings, 'PAPERLESS_API_TOKEN') and settings.PAPERLESS_API_TOKEN: - headers['Authorization'] = f'Token {settings.PAPERLESS_API_TOKEN}' - + if ( + hasattr(settings, "PAPERLESS_API_TOKEN") + and settings.PAPERLESS_API_TOKEN + ): + headers["Authorization"] = ( + f"Token {settings.PAPERLESS_API_TOKEN}" + ) + response = requests.get(doc_url, headers=headers, timeout=30) if response.status_code == 200: - content_type = response.headers.get('content-type', '') - if 'pdf' in content_type: - ext = '.pdf' - elif 'jpeg' in content_type or 'jpg' in content_type: - ext = '.jpg' - elif 'png' in content_type: - ext = '.png' + content_type = response.headers.get("content-type", "") + if "pdf" in content_type: + ext = ".pdf" + elif "jpeg" in content_type or "jpg" in content_type: + ext = ".jpg" + elif "png" in content_type: + ext = ".png" else: - ext = '.pdf' - + ext = ".pdf" + safe_filename = f"dokument_{doc.paperless_document_id}_{doc.titel.replace('/', '_')[:50]}{ext}" - zipf.writestr(f'dokumente/{safe_filename}', response.content) - doc_data['downloaded'] = True + zipf.writestr( + f"dokumente/{safe_filename}", response.content + ) + doc_data["downloaded"] = True else: - doc_data['download_error'] = f'HTTP {response.status_code}' + doc_data["download_error"] = f"HTTP {response.status_code}" except Exception as e: - doc_data['download_error'] = str(e) - + doc_data["download_error"] = str(e) + if docs_data: - zipf.writestr('dokumente.json', json.dumps(docs_data, indent=2, ensure_ascii=False)) - + zipf.writestr( + "dokumente.json", + json.dumps(docs_data, indent=2, ensure_ascii=False), + ) + # Prepare response - with open(temp_file.name, 'rb') as f: - response = HttpResponse(f.read(), content_type='application/zip') + with open(temp_file.name, "rb") as f: + response = HttpResponse(f.read(), content_type="application/zip") filename = f"land_{land.gemeinde}_{land.gemarkung}_flur{land.flur}_{timezone.now().strftime('%Y%m%d_%H%M%S')}.zip" - response['Content-Disposition'] = f'attachment; filename="{filename}"' + response["Content-Disposition"] = f'attachment; filename="{filename}"' return response - + finally: try: os.unlink(temp_file.name) @@ -4379,96 +5405,121 @@ def land_export(request, pk): @login_required def verpachtung_export(request, pk): """Export complete Verpachtung data as ZIP with documents""" - import zipfile import json - import tempfile import os + import tempfile + import zipfile + from django.http import HttpResponse - + verpachtung = get_object_or_404(LandVerpachtung, pk=pk) - + # Create a temporary file for the ZIP - temp_file = tempfile.NamedTemporaryFile(delete=False, suffix='.zip') - + temp_file = tempfile.NamedTemporaryFile(delete=False, suffix=".zip") + try: - with zipfile.ZipFile(temp_file.name, 'w', zipfile.ZIP_DEFLATED) as zipf: + with zipfile.ZipFile(temp_file.name, "w", zipfile.ZIP_DEFLATED) as zipf: # 1. Entity data as JSON entity_data = { - 'id': str(verpachtung.id), - 'vertragsnummer': verpachtung.vertragsnummer, - 'land': str(verpachtung.land), - 'land_id': str(verpachtung.land.id), - 'paechter': str(verpachtung.paechter), - 'paechter_id': str(verpachtung.paechter.id), - 'pachtbeginn': verpachtung.pachtbeginn.isoformat(), - 'pachtende': verpachtung.pachtende.isoformat(), - 'verlaengerung': verpachtung.verlaengerung.isoformat() if verpachtung.verlaengerung else None, - 'pachtzins_pro_qm': str(verpachtung.pachtzins_pro_qm), - 'pachtzins_jaehrlich': str(verpachtung.pachtzins_jaehrlich), - 'verpachtete_flaeche': str(verpachtung.verpachtete_flaeche), - 'status': verpachtung.get_status_display(), - 'verwendungsnachweis': str(verpachtung.verwendungsnachweis) if verpachtung.verwendungsnachweis else None, - 'bemerkungen': verpachtung.bemerkungen, - 'erstellt_am': verpachtung.erstellt_am.isoformat(), - 'aktualisiert_am': verpachtung.aktualisiert_am.isoformat(), - 'vertragsdauer_tage': verpachtung.get_vertragsdauer_tage(), - 'restlaufzeit_tage': verpachtung.get_restlaufzeit_tage(), - 'ist_aktiv': verpachtung.is_aktiv(), - 'export_datum': timezone.now().isoformat(), - 'export_user': request.user.username, + "id": str(verpachtung.id), + "vertragsnummer": verpachtung.vertragsnummer, + "land": str(verpachtung.land), + "land_id": str(verpachtung.land.id), + "paechter": str(verpachtung.paechter), + "paechter_id": str(verpachtung.paechter.id), + "pachtbeginn": verpachtung.pachtbeginn.isoformat(), + "pachtende": verpachtung.pachtende.isoformat(), + "verlaengerung": ( + verpachtung.verlaengerung.isoformat() + if verpachtung.verlaengerung + else None + ), + "pachtzins_pro_qm": str(verpachtung.pachtzins_pro_qm), + "pachtzins_jaehrlich": str(verpachtung.pachtzins_jaehrlich), + "verpachtete_flaeche": str(verpachtung.verpachtete_flaeche), + "status": verpachtung.get_status_display(), + "verwendungsnachweis": ( + str(verpachtung.verwendungsnachweis) + if verpachtung.verwendungsnachweis + else None + ), + "bemerkungen": verpachtung.bemerkungen, + "erstellt_am": verpachtung.erstellt_am.isoformat(), + "aktualisiert_am": verpachtung.aktualisiert_am.isoformat(), + "vertragsdauer_tage": verpachtung.get_vertragsdauer_tage(), + "restlaufzeit_tage": verpachtung.get_restlaufzeit_tage(), + "ist_aktiv": verpachtung.is_aktiv(), + "export_datum": timezone.now().isoformat(), + "export_user": request.user.username, } - zipf.writestr('verpachtung_data.json', json.dumps(entity_data, indent=2, ensure_ascii=False)) - + zipf.writestr( + "verpachtung_data.json", + json.dumps(entity_data, indent=2, ensure_ascii=False), + ) + # 2. Linked documents from Paperless dokumente = DokumentLink.objects.filter(verpachtung_id=verpachtung.pk) docs_data = [] for doc in dokumente: doc_data = { - 'paperless_id': doc.paperless_document_id, - 'titel': doc.titel, - 'kontext': doc.get_kontext_display(), - 'beschreibung': doc.beschreibung, + "paperless_id": doc.paperless_document_id, + "titel": doc.titel, + "kontext": doc.get_kontext_display(), + "beschreibung": doc.beschreibung, } docs_data.append(doc_data) - + # Try to download document from Paperless try: - if hasattr(settings, 'PAPERLESS_API_URL') and settings.PAPERLESS_API_URL: + if ( + hasattr(settings, "PAPERLESS_API_URL") + and settings.PAPERLESS_API_URL + ): doc_url = f"{settings.PAPERLESS_API_URL}/api/documents/{doc.paperless_document_id}/download/" headers = {} - if hasattr(settings, 'PAPERLESS_API_TOKEN') and settings.PAPERLESS_API_TOKEN: - headers['Authorization'] = f'Token {settings.PAPERLESS_API_TOKEN}' - + if ( + hasattr(settings, "PAPERLESS_API_TOKEN") + and settings.PAPERLESS_API_TOKEN + ): + headers["Authorization"] = ( + f"Token {settings.PAPERLESS_API_TOKEN}" + ) + response = requests.get(doc_url, headers=headers, timeout=30) if response.status_code == 200: - content_type = response.headers.get('content-type', '') - if 'pdf' in content_type: - ext = '.pdf' - elif 'jpeg' in content_type or 'jpg' in content_type: - ext = '.jpg' - elif 'png' in content_type: - ext = '.png' + content_type = response.headers.get("content-type", "") + if "pdf" in content_type: + ext = ".pdf" + elif "jpeg" in content_type or "jpg" in content_type: + ext = ".jpg" + elif "png" in content_type: + ext = ".png" else: - ext = '.pdf' - + ext = ".pdf" + safe_filename = f"dokument_{doc.paperless_document_id}_{doc.titel.replace('/', '_')[:50]}{ext}" - zipf.writestr(f'dokumente/{safe_filename}', response.content) - doc_data['downloaded'] = True + zipf.writestr( + f"dokumente/{safe_filename}", response.content + ) + doc_data["downloaded"] = True else: - doc_data['download_error'] = f'HTTP {response.status_code}' + doc_data["download_error"] = f"HTTP {response.status_code}" except Exception as e: - doc_data['download_error'] = str(e) - + doc_data["download_error"] = str(e) + if docs_data: - zipf.writestr('dokumente.json', json.dumps(docs_data, indent=2, ensure_ascii=False)) - + zipf.writestr( + "dokumente.json", + json.dumps(docs_data, indent=2, ensure_ascii=False), + ) + # Prepare response - with open(temp_file.name, 'rb') as f: - response = HttpResponse(f.read(), content_type='application/zip') + with open(temp_file.name, "rb") as f: + response = HttpResponse(f.read(), content_type="application/zip") filename = f"verpachtung_{verpachtung.vertragsnummer}_{timezone.now().strftime('%Y%m%d_%H%M%S')}.zip" - response['Content-Disposition'] = f'attachment; filename="{filename}"' + response["Content-Disposition"] = f'attachment; filename="{filename}"' return response - + finally: try: os.unlink(temp_file.name) @@ -4479,130 +5530,138 @@ def verpachtung_export(request, pk): @login_required def audit_log_list(request): """Liste aller Audit Log Einträge""" - from stiftung.models import AuditLog from django.core.paginator import Paginator - + + from stiftung.models import AuditLog + logs = AuditLog.objects.all() - + # Filter - user_filter = request.GET.get('user') + user_filter = request.GET.get("user") if user_filter: logs = logs.filter(username__icontains=user_filter) - - action_filter = request.GET.get('action') + + action_filter = request.GET.get("action") if action_filter: logs = logs.filter(action=action_filter) - - entity_filter = request.GET.get('entity_type') + + entity_filter = request.GET.get("entity_type") if entity_filter: logs = logs.filter(entity_type=entity_filter) - - date_from = request.GET.get('date_from') + + date_from = request.GET.get("date_from") if date_from: logs = logs.filter(timestamp__date__gte=date_from) - - date_to = request.GET.get('date_to') + + date_to = request.GET.get("date_to") if date_to: logs = logs.filter(timestamp__date__lte=date_to) - + # Pagination paginator = Paginator(logs, 50) - page_number = request.GET.get('page') + page_number = request.GET.get("page") page_obj = paginator.get_page(page_number) - + context = { - 'page_obj': page_obj, - 'action_choices': AuditLog.ACTION_TYPES, - 'entity_choices': AuditLog.ENTITY_TYPES, - 'user_filter': user_filter, - 'action_filter': action_filter, - 'entity_filter': entity_filter, - 'date_from': date_from, - 'date_to': date_to, + "page_obj": page_obj, + "action_choices": AuditLog.ACTION_TYPES, + "entity_choices": AuditLog.ENTITY_TYPES, + "user_filter": user_filter, + "action_filter": action_filter, + "entity_filter": entity_filter, + "date_from": date_from, + "date_to": date_to, } - - return render(request, 'stiftung/audit_log_list.html', context) + + return render(request, "stiftung/audit_log_list.html", context) @login_required def backup_management(request): """Backup Management Interface""" - from stiftung.models import BackupJob from django.core.paginator import Paginator - + + from stiftung.models import BackupJob + # Handle backup creation - if request.method == 'POST': - backup_type = request.POST.get('backup_type', 'full') - + if request.method == "POST": + backup_type = request.POST.get("backup_type", "full") + # Create backup job backup_job = BackupJob.objects.create( - backup_type=backup_type, - created_by=request.user + backup_type=backup_type, created_by=request.user ) - + # Log the backup initiation from stiftung.audit import log_system_action + log_system_action( request=request, - action='backup', + action="backup", description=f"Backup-Job erstellt: {backup_job.get_backup_type_display()}", - details={'backup_job_id': str(backup_job.id), 'backup_type': backup_type} + details={"backup_job_id": str(backup_job.id), "backup_type": backup_type}, ) - + # Start backup process asynchronously (we'll create a simple version for now) import threading + from stiftung.backup_utils import run_backup + backup_thread = threading.Thread(target=run_backup, args=(str(backup_job.id),)) backup_thread.start() - - messages.success(request, f'Backup-Job "{backup_job.get_backup_type_display()}" wurde gestartet.') - return redirect('stiftung:backup_management') - + + messages.success( + request, + f'Backup-Job "{backup_job.get_backup_type_display()}" wurde gestartet.', + ) + return redirect("stiftung:backup_management") + # List backup jobs backup_jobs = BackupJob.objects.all() - + # Pagination paginator = Paginator(backup_jobs, 20) - page_number = request.GET.get('page') + page_number = request.GET.get("page") page_obj = paginator.get_page(page_number) - + context = { - 'page_obj': page_obj, - 'backup_types': BackupJob.TYPE_CHOICES, + "page_obj": page_obj, + "backup_types": BackupJob.TYPE_CHOICES, } - - return render(request, 'stiftung/backup_management.html', context) + + return render(request, "stiftung/backup_management.html", context) @login_required def backup_download(request, backup_id): """Download a backup file""" - from stiftung.models import BackupJob - from django.http import FileResponse, Http404 import os - + + from django.http import FileResponse, Http404 + + from stiftung.models import BackupJob + try: - backup_job = BackupJob.objects.get(id=backup_id, status='completed') + backup_job = BackupJob.objects.get(id=backup_id, status="completed") except BackupJob.DoesNotExist: raise Http404("Backup nicht gefunden oder nicht vollständig") - - backup_path = os.path.join('/app/backups', backup_job.backup_filename) + + backup_path = os.path.join("/app/backups", backup_job.backup_filename) if not os.path.exists(backup_path): raise Http404("Backup-Datei nicht gefunden") - + # Log download from stiftung.audit import log_system_action + log_system_action( request=request, - action='export', + action="export", description=f"Backup heruntergeladen: {backup_job.backup_filename}", - details={'backup_job_id': str(backup_job.id)} + details={"backup_job_id": str(backup_job.id)}, ) - + response = FileResponse( - open(backup_path, 'rb'), - as_attachment=True, - filename=backup_job.backup_filename + open(backup_path, "rb"), as_attachment=True, filename=backup_job.backup_filename ) return response @@ -4610,580 +5669,648 @@ def backup_download(request, backup_id): @login_required def backup_restore(request): """Restore from backup""" - if request.method == 'POST': + if request.method == "POST": from stiftung.models import BackupJob - backup_file = request.FILES.get('backup_file') - + + backup_file = request.FILES.get("backup_file") + if not backup_file: - messages.error(request, 'Bitte wählen Sie eine Backup-Datei aus.') - return redirect('stiftung:backup_management') - + messages.error(request, "Bitte wählen Sie eine Backup-Datei aus.") + return redirect("stiftung:backup_management") + # Validate file - if not backup_file.name.endswith('.tar.gz'): - messages.error(request, 'Ungültiges Backup-Format. Nur .tar.gz Dateien sind erlaubt.') - return redirect('stiftung:backup_management') - + if not backup_file.name.endswith(".tar.gz"): + messages.error( + request, "Ungültiges Backup-Format. Nur .tar.gz Dateien sind erlaubt." + ) + return redirect("stiftung:backup_management") + # Save uploaded file import os import tempfile - + temp_dir = tempfile.mkdtemp() backup_path = os.path.join(temp_dir, backup_file.name) - - with open(backup_path, 'wb+') as destination: + + with open(backup_path, "wb+") as destination: for chunk in backup_file.chunks(): destination.write(chunk) - + # Create restore job restore_job = BackupJob.objects.create( - backup_type='full', + backup_type="full", created_by=request.user, - backup_filename=backup_file.name + backup_filename=backup_file.name, ) - + # Log restore initiation from stiftung.audit import log_system_action + log_system_action( request=request, - action='restore', + action="restore", description=f"Wiederherstellung gestartet von: {backup_file.name}", - details={'restore_job_id': str(restore_job.id), 'filename': 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 = 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.') - return redirect('stiftung:backup_management') - - return redirect('stiftung:backup_management') + + messages.success( + request, f'Wiederherstellung von "{backup_file.name}" wurde gestartet.' + ) + return redirect("stiftung:backup_management") + + return redirect("stiftung:backup_management") # ============================================================================= # USER MANAGEMENT VIEWS # ============================================================================= + @login_required def user_management(request): """User Management Dashboard""" from django.contrib.auth.models import User from django.core.paginator import Paginator from django.db.models import Q - + # Check permission - if not request.user.has_perm('stiftung.manage_users'): - messages.error(request, 'Sie haben keine Berechtigung für die Benutzerverwaltung.') - return redirect('stiftung:administration') - - users = User.objects.all().order_by('username') - + if not request.user.has_perm("stiftung.manage_users"): + messages.error( + request, "Sie haben keine Berechtigung für die Benutzerverwaltung." + ) + return redirect("stiftung:administration") + + users = User.objects.all().order_by("username") + # Search functionality - search = request.GET.get('search') + search = request.GET.get("search") if search: users = users.filter( - Q(username__icontains=search) | - Q(email__icontains=search) | - Q(first_name__icontains=search) | - Q(last_name__icontains=search) + Q(username__icontains=search) + | Q(email__icontains=search) + | Q(first_name__icontains=search) + | Q(last_name__icontains=search) ) - + # Filter by status - status_filter = request.GET.get('status') - if status_filter == 'active': + status_filter = request.GET.get("status") + if status_filter == "active": users = users.filter(is_active=True) - elif status_filter == 'inactive': + elif status_filter == "inactive": users = users.filter(is_active=False) - elif status_filter == 'staff': + elif status_filter == "staff": users = users.filter(is_staff=True) - + # Pagination paginator = Paginator(users, 20) - page_number = request.GET.get('page') + page_number = request.GET.get("page") page_obj = paginator.get_page(page_number) - + # Statistics stats = { - 'total_users': User.objects.count(), - 'active_users': User.objects.filter(is_active=True).count(), - 'staff_users': User.objects.filter(is_staff=True).count(), - 'inactive_users': User.objects.filter(is_active=False).count(), + "total_users": User.objects.count(), + "active_users": User.objects.filter(is_active=True).count(), + "staff_users": User.objects.filter(is_staff=True).count(), + "inactive_users": User.objects.filter(is_active=False).count(), } - + context = { - 'page_obj': page_obj, - 'stats': stats, - 'search': search, - 'status_filter': status_filter, + "page_obj": page_obj, + "stats": stats, + "search": search, + "status_filter": status_filter, } - - return render(request, 'stiftung/user_management.html', context) + + return render(request, "stiftung/user_management.html", context) @login_required def user_create(request): """Create a new user""" - from stiftung.forms import UserCreationForm from django.contrib.auth.models import User - + + from stiftung.forms import UserCreationForm + # Check permission - if not request.user.has_perm('stiftung.manage_users'): - messages.error(request, 'Sie haben keine Berechtigung für die Benutzerverwaltung.') - return redirect('stiftung:administration') - - if request.method == 'POST': + if not request.user.has_perm("stiftung.manage_users"): + messages.error( + request, "Sie haben keine Berechtigung für die Benutzerverwaltung." + ) + return redirect("stiftung:administration") + + if request.method == "POST": form = UserCreationForm(request.POST) if form.is_valid(): # Create user user = User.objects.create_user( - username=form.cleaned_data['username'], - email=form.cleaned_data['email'], - password=form.cleaned_data['password1'], - first_name=form.cleaned_data['first_name'], - last_name=form.cleaned_data['last_name'], - is_active=form.cleaned_data['is_active'], - is_staff=form.cleaned_data['is_staff'] + username=form.cleaned_data["username"], + email=form.cleaned_data["email"], + password=form.cleaned_data["password1"], + first_name=form.cleaned_data["first_name"], + last_name=form.cleaned_data["last_name"], + is_active=form.cleaned_data["is_active"], + is_staff=form.cleaned_data["is_staff"], ) - + # Log user creation from stiftung.audit import log_action + log_action( request=request, - action='create', - entity_type='user', + action="create", + entity_type="user", entity_id=str(user.pk), entity_name=user.username, - description=f'Neuer Benutzer "{user.username}" wurde erstellt' + description=f'Neuer Benutzer "{user.username}" wurde erstellt', ) - - messages.success(request, f'Benutzer "{user.username}" wurde erfolgreich erstellt.') - return redirect('stiftung:user_detail', pk=user.pk) + + messages.success( + request, f'Benutzer "{user.username}" wurde erfolgreich erstellt.' + ) + return redirect("stiftung:user_detail", pk=user.pk) else: form = UserCreationForm() - - context = { - 'form': form, - 'title': 'Neuen Benutzer erstellen' - } - - return render(request, 'stiftung/user_form.html', context) + + context = {"form": form, "title": "Neuen Benutzer erstellen"} + + return render(request, "stiftung/user_form.html", context) @login_required def user_detail(request, pk): """User detail view""" from django.contrib.auth.models import User - + # Check permission - if not request.user.has_perm('stiftung.manage_users'): - messages.error(request, 'Sie haben keine Berechtigung für die Benutzerverwaltung.') - return redirect('stiftung:administration') - + if not request.user.has_perm("stiftung.manage_users"): + messages.error( + request, "Sie haben keine Berechtigung für die Benutzerverwaltung." + ) + return redirect("stiftung:administration") + user = get_object_or_404(User, pk=pk) - + # Get user's permissions user_permissions = user.get_all_permissions() - stiftung_permissions = [perm for perm in user_permissions if perm.startswith('stiftung.')] - + stiftung_permissions = [ + perm for perm in user_permissions if perm.startswith("stiftung.") + ] + # Get recent audit activity from stiftung.models import AuditLog - recent_activity = AuditLog.objects.filter(user=user).order_by('-timestamp')[:10] - + + recent_activity = AuditLog.objects.filter(user=user).order_by("-timestamp")[:10] + context = { - 'user_obj': user, # Use user_obj to avoid conflict with request.user - 'stiftung_permissions': stiftung_permissions, - 'recent_activity': recent_activity, + "user_obj": user, # Use user_obj to avoid conflict with request.user + "stiftung_permissions": stiftung_permissions, + "recent_activity": recent_activity, } - - return render(request, 'stiftung/user_detail.html', context) + + return render(request, "stiftung/user_detail.html", context) @login_required def user_edit(request, pk): """Edit user""" - from stiftung.forms import UserUpdateForm from django.contrib.auth.models import User - + + from stiftung.forms import UserUpdateForm + # Check permission - if not request.user.has_perm('stiftung.manage_users'): - messages.error(request, 'Sie haben keine Berechtigung für die Benutzerverwaltung.') - return redirect('stiftung:administration') - + if not request.user.has_perm("stiftung.manage_users"): + messages.error( + request, "Sie haben keine Berechtigung für die Benutzerverwaltung." + ) + return redirect("stiftung:administration") + user = get_object_or_404(User, pk=pk) - - if request.method == 'POST': + + if request.method == "POST": form = UserUpdateForm(request.POST, instance=user) if form.is_valid(): # Track changes - from stiftung.audit import track_model_changes, log_action + from stiftung.audit import log_action, track_model_changes + old_user = User.objects.get(pk=user.pk) - + updated_user = form.save() - + # Log changes changes = track_model_changes(old_user, updated_user) if changes: log_action( request=request, - action='update', - entity_type='user', + action="update", + entity_type="user", entity_id=str(updated_user.pk), entity_name=updated_user.username, description=f'Benutzer "{updated_user.username}" wurde aktualisiert', - changes=changes + changes=changes, ) - - messages.success(request, f'Benutzer "{updated_user.username}" wurde erfolgreich aktualisiert.') - return redirect('stiftung:user_detail', pk=updated_user.pk) + + messages.success( + request, + f'Benutzer "{updated_user.username}" wurde erfolgreich aktualisiert.', + ) + return redirect("stiftung:user_detail", pk=updated_user.pk) else: form = UserUpdateForm(instance=user) - + context = { - 'form': form, - 'user_obj': user, - 'title': f'Benutzer "{user.username}" bearbeiten' + "form": form, + "user_obj": user, + "title": f'Benutzer "{user.username}" bearbeiten', } - - return render(request, 'stiftung/user_form.html', context) + + return render(request, "stiftung/user_form.html", context) @login_required def user_change_password(request, pk): """Change user password""" - from stiftung.forms import PasswordChangeForm from django.contrib.auth.models import User - + + from stiftung.forms import PasswordChangeForm + # Check permission - if not request.user.has_perm('stiftung.manage_users'): - messages.error(request, 'Sie haben keine Berechtigung für die Benutzerverwaltung.') - return redirect('stiftung:administration') - + if not request.user.has_perm("stiftung.manage_users"): + messages.error( + request, "Sie haben keine Berechtigung für die Benutzerverwaltung." + ) + return redirect("stiftung:administration") + user = get_object_or_404(User, pk=pk) - - if request.method == 'POST': + + if request.method == "POST": form = PasswordChangeForm(request.POST) if form.is_valid(): - user.set_password(form.cleaned_data['new_password1']) + user.set_password(form.cleaned_data["new_password1"]) user.save() - + # Log password change from stiftung.audit import log_action + log_action( request=request, - action='update', - entity_type='user', + action="update", + entity_type="user", entity_id=str(user.pk), entity_name=user.username, - description=f'Passwort für Benutzer "{user.username}" wurde geändert' + description=f'Passwort für Benutzer "{user.username}" wurde geändert', ) - - messages.success(request, f'Passwort für Benutzer "{user.username}" wurde erfolgreich geändert.') - return redirect('stiftung:user_detail', pk=user.pk) + + messages.success( + request, + f'Passwort für Benutzer "{user.username}" wurde erfolgreich geändert.', + ) + return redirect("stiftung:user_detail", pk=user.pk) else: form = PasswordChangeForm() - + context = { - 'form': form, - 'user_obj': user, - 'title': f'Passwort für "{user.username}" ändern' + "form": form, + "user_obj": user, + "title": f'Passwort für "{user.username}" ändern', } - - return render(request, 'stiftung/user_change_password.html', context) + + return render(request, "stiftung/user_change_password.html", context) @login_required def user_permissions(request, pk): """Manage user permissions""" + from django.contrib.auth.models import Permission, User + from stiftung.forms import UserPermissionForm - from django.contrib.auth.models import User, Permission - + # Check permission - if not request.user.has_perm('stiftung.manage_permissions'): - messages.error(request, 'Sie haben keine Berechtigung für die Berechtigungsverwaltung.') - return redirect('stiftung:administration') - + if not request.user.has_perm("stiftung.manage_permissions"): + messages.error( + request, "Sie haben keine Berechtigung für die Berechtigungsverwaltung." + ) + return redirect("stiftung:administration") + user = get_object_or_404(User, pk=pk) - - if request.method == 'POST': + + if request.method == "POST": form = UserPermissionForm(request.POST, user=user) if form.is_valid(): # Get selected permissions selected_perms = [] for field_name, value in form.cleaned_data.items(): - if field_name.startswith('perm_') and value: - perm_id = field_name.replace('perm_', '') + if field_name.startswith("perm_") and value: + perm_id = field_name.replace("perm_", "") selected_perms.append(int(perm_id)) - + # Get current stiftung permissions - current_perms = user.user_permissions.filter(content_type__app_label='stiftung') - current_perm_ids = set(current_perms.values_list('id', flat=True)) + current_perms = user.user_permissions.filter( + content_type__app_label="stiftung" + ) + current_perm_ids = set(current_perms.values_list("id", flat=True)) selected_perm_ids = set(selected_perms) - + # Remove permissions that are no longer selected to_remove = current_perm_ids - selected_perm_ids if to_remove: - user.user_permissions.remove(*Permission.objects.filter(id__in=to_remove)) - + user.user_permissions.remove( + *Permission.objects.filter(id__in=to_remove) + ) + # Add new permissions to_add = selected_perm_ids - current_perm_ids if to_add: user.user_permissions.add(*Permission.objects.filter(id__in=to_add)) - + # Log permission changes from stiftung.audit import log_action + if to_remove or to_add: changes = { - 'removed_permissions': list(Permission.objects.filter(id__in=to_remove).values_list('name', flat=True)), - 'added_permissions': list(Permission.objects.filter(id__in=to_add).values_list('name', flat=True)) + "removed_permissions": list( + Permission.objects.filter(id__in=to_remove).values_list( + "name", flat=True + ) + ), + "added_permissions": list( + Permission.objects.filter(id__in=to_add).values_list( + "name", flat=True + ) + ), } log_action( request=request, - action='update', - entity_type='user', + action="update", + entity_type="user", entity_id=str(user.pk), entity_name=user.username, description=f'Berechtigungen für Benutzer "{user.username}" wurden aktualisiert', - changes=changes + changes=changes, ) - - messages.success(request, f'Berechtigungen für Benutzer "{user.username}" wurden erfolgreich aktualisiert.') - return redirect('stiftung:user_detail', pk=user.pk) + + messages.success( + request, + f'Berechtigungen für Benutzer "{user.username}" wurden erfolgreich aktualisiert.', + ) + return redirect("stiftung:user_detail", pk=user.pk) else: form = UserPermissionForm(user=user) - + context = { - 'form': form, - 'user_obj': user, - 'permission_groups': form.get_permission_groups(), - 'title': f'Berechtigungen für "{user.username}"' + "form": form, + "user_obj": user, + "permission_groups": form.get_permission_groups(), + "title": f'Berechtigungen für "{user.username}"', } - - return render(request, 'stiftung/user_permissions.html', context) + + return render(request, "stiftung/user_permissions.html", context) -@login_required +@login_required def user_delete(request, pk): """Delete user""" from django.contrib.auth.models import User - + # Check permission - if not request.user.has_perm('stiftung.manage_users'): - messages.error(request, 'Sie haben keine Berechtigung für die Benutzerverwaltung.') - return redirect('stiftung:administration') - + if not request.user.has_perm("stiftung.manage_users"): + messages.error( + request, "Sie haben keine Berechtigung für die Benutzerverwaltung." + ) + return redirect("stiftung:administration") + user = get_object_or_404(User, pk=pk) - + # Prevent deletion of current user if user == request.user: - messages.error(request, 'Sie können sich nicht selbst löschen.') - return redirect('stiftung:user_detail', pk=pk) - - if request.method == 'POST': + messages.error(request, "Sie können sich nicht selbst löschen.") + return redirect("stiftung:user_detail", pk=pk) + + if request.method == "POST": username = user.username - + # Log deletion before deleting from stiftung.audit import log_action + log_action( request=request, - action='delete', - entity_type='user', + action="delete", + entity_type="user", entity_id=str(user.pk), entity_name=username, - description=f'Benutzer "{username}" wurde gelöscht' + description=f'Benutzer "{username}" wurde gelöscht', ) - + user.delete() - + messages.success(request, f'Benutzer "{username}" wurde erfolgreich gelöscht.') - return redirect('stiftung:user_management') - - context = { - 'user_obj': user, - 'title': f'Benutzer "{user.username}" löschen' - } - - return render(request, 'stiftung/user_delete.html', context) + return redirect("stiftung:user_management") + + context = {"user_obj": user, "title": f'Benutzer "{user.username}" löschen'} + + return render(request, "stiftung/user_delete.html", context) # ============================================================================= # AUTHENTICATION VIEWS # ============================================================================= + def user_login(request): """User login view""" from django.contrib.auth import authenticate, login from django.contrib.auth.forms import AuthenticationForm - + if request.user.is_authenticated: - return redirect('stiftung:dashboard') - - if request.method == 'POST': + return redirect("stiftung:dashboard") + + if request.method == "POST": form = AuthenticationForm(request, data=request.POST) if form.is_valid(): - username = form.cleaned_data.get('username') - password = form.cleaned_data.get('password') + username = form.cleaned_data.get("username") + password = form.cleaned_data.get("password") user = authenticate(username=username, password=password) if user is not None: login(request, user) - + # Log the login from stiftung.audit import log_login + log_login(request, user) - - messages.success(request, f'Willkommen zurück, {user.username}!') - + + messages.success(request, f"Willkommen zurück, {user.username}!") + # Redirect to safe next URL path or dashboard - next_param = request.GET.get('next') or request.POST.get('next') - if next_param and next_param.startswith('/'): + next_param = request.GET.get("next") or request.POST.get("next") + if next_param and next_param.startswith("/"): return redirect(next_param) - return redirect('stiftung:dashboard') + return redirect("stiftung:dashboard") else: - messages.error(request, 'Ungültige Anmeldedaten.') + messages.error(request, "Ungültige Anmeldedaten.") else: - messages.error(request, 'Bitte korrigieren Sie die Fehler im Formular.') + messages.error(request, "Bitte korrigieren Sie die Fehler im Formular.") else: form = AuthenticationForm() - - context = { - 'form': form, - 'next': request.GET.get('next', '') - } - - return render(request, 'stiftung/login.html', context) + + context = {"form": form, "next": request.GET.get("next", "")} + + return render(request, "stiftung/login.html", context) @login_required def user_logout(request): """User logout view""" from django.contrib.auth import logout - + # Log the logout before actually logging out from stiftung.audit import log_logout + log_logout(request, request.user) - + username = request.user.username logout(request) - - messages.success(request, f'Sie wurden erfolgreich abgemeldet, {username}.') - return redirect('stiftung:login') + + messages.success(request, f"Sie wurden erfolgreich abgemeldet, {username}.") + return redirect("stiftung:login") # ============================================================================ # LANDABRECHNUNGS VIEWS # ============================================================================ + @login_required def land_abrechnung_list(request): """Liste aller Landabrechnungen""" - abrechnungen = LandAbrechnung.objects.select_related('land').all() - + abrechnungen = LandAbrechnung.objects.select_related("land").all() + # Filter - jahr_filter = request.GET.get('jahr') - land_filter = request.GET.get('land') - + jahr_filter = request.GET.get("jahr") + land_filter = request.GET.get("land") + if jahr_filter: abrechnungen = abrechnungen.filter(abrechnungsjahr=jahr_filter) if land_filter: abrechnungen = abrechnungen.filter(land__pk=land_filter) - + # Pagination paginator = Paginator(abrechnungen, 20) - page_number = request.GET.get('page') + page_number = request.GET.get("page") abrechnungen = paginator.get_page(page_number) - + # Statistiken stats = LandAbrechnung.objects.aggregate( - total_einnahmen=Sum('pacht_vereinnahmt'), - total_ausgaben=Sum('grundsteuer_betrag'), - anzahl_abrechnungen=Count('id') + total_einnahmen=Sum("pacht_vereinnahmt"), + total_ausgaben=Sum("grundsteuer_betrag"), + anzahl_abrechnungen=Count("id"), ) - + context = { - 'abrechnungen': abrechnungen, - 'stats': stats, - 'jahre': LandAbrechnung.objects.values_list('abrechnungsjahr', flat=True).distinct().order_by('-abrechnungsjahr'), - 'laendereien': Land.objects.filter(aktiv=True).order_by('gemeinde', 'gemarkung'), - 'jahr_filter': jahr_filter, - 'land_filter': land_filter, + "abrechnungen": abrechnungen, + "stats": stats, + "jahre": LandAbrechnung.objects.values_list("abrechnungsjahr", flat=True) + .distinct() + .order_by("-abrechnungsjahr"), + "laendereien": Land.objects.filter(aktiv=True).order_by( + "gemeinde", "gemarkung" + ), + "jahr_filter": jahr_filter, + "land_filter": land_filter, } - - return render(request, 'stiftung/land_abrechnung_list.html', context) + + return render(request, "stiftung/land_abrechnung_list.html", context) @login_required def land_abrechnung_detail(request, pk): """Detail-Ansicht einer Landabrechnung""" abrechnung = get_object_or_404(LandAbrechnung, pk=pk) - + context = { - 'abrechnung': abrechnung, - 'land': abrechnung.land, + "abrechnung": abrechnung, + "land": abrechnung.land, } - - return render(request, 'stiftung/land_abrechnung_detail.html', context) + + return render(request, "stiftung/land_abrechnung_detail.html", context) @login_required def land_abrechnung_create(request): """Neue Landabrechnung erstellen""" from .forms import LandAbrechnungForm - - land_pk = request.GET.get('land') + + land_pk = request.GET.get("land") initial = {} land = None - + if land_pk: land = get_object_or_404(Land, pk=land_pk) - initial['land'] = land - initial['abrechnungsjahr'] = datetime.now().year - + initial["land"] = land + initial["abrechnungsjahr"] = datetime.now().year + # Automatische Vorausfüllung aus Verpachtungsdaten if land.pachtzins_pauschal: - initial['pacht_vereinnahmt'] = land.pachtzins_pauschal - - if request.method == 'POST': + initial["pacht_vereinnahmt"] = land.pachtzins_pauschal + + if request.method == "POST": form = LandAbrechnungForm(request.POST, request.FILES) if form.is_valid(): abrechnung = form.save() - messages.success(request, f'Landabrechnung für {abrechnung.land} ({abrechnung.abrechnungsjahr}) wurde erfolgreich erstellt.') - return redirect('stiftung:land_abrechnung_detail', pk=abrechnung.pk) + messages.success( + request, + f"Landabrechnung für {abrechnung.land} ({abrechnung.abrechnungsjahr}) wurde erfolgreich erstellt.", + ) + return redirect("stiftung:land_abrechnung_detail", pk=abrechnung.pk) else: form = LandAbrechnungForm(initial=initial) - + context = { - 'form': form, - 'title': 'Neue Landabrechnung', - 'land': land, + "form": form, + "title": "Neue Landabrechnung", + "land": land, } - - return render(request, 'stiftung/land_abrechnung_form.html', context) + + return render(request, "stiftung/land_abrechnung_form.html", context) @login_required def land_abrechnung_update(request, pk): """Landabrechnung bearbeiten""" from .forms import LandAbrechnungForm - + abrechnung = get_object_or_404(LandAbrechnung, pk=pk) - - if request.method == 'POST': + + if request.method == "POST": form = LandAbrechnungForm(request.POST, request.FILES, instance=abrechnung) if form.is_valid(): abrechnung = form.save() - messages.success(request, f'Landabrechnung für {abrechnung.land} ({abrechnung.abrechnungsjahr}) wurde erfolgreich aktualisiert.') - return redirect('stiftung:land_abrechnung_detail', pk=abrechnung.pk) + messages.success( + request, + f"Landabrechnung für {abrechnung.land} ({abrechnung.abrechnungsjahr}) wurde erfolgreich aktualisiert.", + ) + return redirect("stiftung:land_abrechnung_detail", pk=abrechnung.pk) else: form = LandAbrechnungForm(instance=abrechnung) - + context = { - 'form': form, - 'abrechnung': abrechnung, - 'title': f'Abrechnung bearbeiten - {abrechnung.land} ({abrechnung.abrechnungsjahr})', + "form": form, + "abrechnung": abrechnung, + "title": f"Abrechnung bearbeiten - {abrechnung.land} ({abrechnung.abrechnungsjahr})", } - - return render(request, 'stiftung/land_abrechnung_form.html', context) + + return render(request, "stiftung/land_abrechnung_form.html", context) @login_required @@ -5191,66 +6318,79 @@ def land_abrechnung_delete(request, pk): """Landabrechnung löschen""" abrechnung = get_object_or_404(LandAbrechnung, pk=pk) land = abrechnung.land - - if request.method == 'POST': + + if request.method == "POST": abrechnung.delete() - messages.success(request, f'Landabrechnung für {land} ({abrechnung.abrechnungsjahr}) wurde erfolgreich gelöscht.') - return redirect('stiftung:land_detail', pk=land.pk) - + messages.success( + request, + f"Landabrechnung für {land} ({abrechnung.abrechnungsjahr}) wurde erfolgreich gelöscht.", + ) + return redirect("stiftung:land_detail", pk=land.pk) + context = { - 'abrechnung': abrechnung, - 'land': land, + "abrechnung": abrechnung, + "land": land, } - - return render(request, 'stiftung/land_abrechnung_confirm_delete.html', context) + + return render(request, "stiftung/land_abrechnung_confirm_delete.html", context) # ============================================================================ # VEREINHEITLICHTE VERPACHTUNGS VIEWS # ============================================================================ + @login_required def land_verpachtung_create(request, land_pk): """Erstelle eine neue Verpachtung direkt im Land-Model""" from datetime import datetime as dt - + land = get_object_or_404(Land, pk=land_pk) - - if request.method == 'POST': + + if request.method == "POST": # Einfaches Formular für die wichtigsten Verpachtungsfelder - aktueller_paechter_id = request.POST.get('aktueller_paechter') - pachtbeginn = request.POST.get('pachtbeginn') - pachtende = request.POST.get('pachtende') - pachtzins_pauschal = request.POST.get('pachtzins_pauschal') - zahlungsweise = request.POST.get('zahlungsweise') - ust_option = request.POST.get('ust_option') == 'on' - + aktueller_paechter_id = request.POST.get("aktueller_paechter") + pachtbeginn = request.POST.get("pachtbeginn") + pachtende = request.POST.get("pachtende") + pachtzins_pauschal = request.POST.get("pachtzins_pauschal") + zahlungsweise = request.POST.get("zahlungsweise") + ust_option = request.POST.get("ust_option") == "on" + if aktueller_paechter_id and pachtbeginn: paechter = get_object_or_404(Paechter, pk=aktueller_paechter_id) - verpachtete_flaeche = request.POST.get('verpachtete_flaeche') - + verpachtete_flaeche = request.POST.get("verpachtete_flaeche") + # Validiere verpachtete Fläche if not verpachtete_flaeche: verpachtete_flaeche = land.groesse_qm # Standard: gesamte Fläche else: verpachtete_flaeche = float(verpachtete_flaeche) if verpachtete_flaeche > land.groesse_qm: - messages.error(request, f'Die verpachtete Fläche ({verpachtete_flaeche} qm) kann nicht größer als die Gesamtfläche ({land.groesse_qm} qm) sein.') + messages.error( + request, + f"Die verpachtete Fläche ({verpachtete_flaeche} qm) kann nicht größer als die Gesamtfläche ({land.groesse_qm} qm) sein.", + ) # Erstelle context für Fehlerfall - paechter_list = Paechter.objects.filter(aktiv=True).order_by('nachname', 'vorname') + paechter_list = Paechter.objects.filter(aktiv=True).order_by( + "nachname", "vorname" + ) verfuegbare_flaeche = land.groesse_qm if land.verp_flaeche_aktuell and land.verp_flaeche_aktuell > 0: - verfuegbare_flaeche = land.groesse_qm - land.verp_flaeche_aktuell - + verfuegbare_flaeche = ( + land.groesse_qm - land.verp_flaeche_aktuell + ) + context = { - 'land': land, - 'paechter_list': paechter_list, - 'current_year': dt.now().year, - 'is_edit': False, - 'verfuegbare_flaeche': verfuegbare_flaeche, + "land": land, + "paechter_list": paechter_list, + "current_year": dt.now().year, + "is_edit": False, + "verfuegbare_flaeche": verfuegbare_flaeche, } - return render(request, 'stiftung/land_verpachtung_form.html', context) - + return render( + request, "stiftung/land_verpachtung_form.html", context + ) + # Land aktualisieren land.aktueller_paechter = paechter land.paechter_name = paechter.get_full_name() @@ -5263,7 +6403,7 @@ def land_verpachtung_create(request, land_pk): land.verp_flaeche_aktuell = verpachtete_flaeche land.verpachtete_gesamtflaeche = verpachtete_flaeche land.save() - + # Erstelle LandVerpachtung-Objekt für bessere Nachverfolgung land_verpachtung = LandVerpachtung.objects.create( land=land, @@ -5275,105 +6415,107 @@ def land_verpachtung_create(request, land_pk): pachtzins_pauschal=pachtzins_pauschal if pachtzins_pauschal else 0, zahlungsweise=zahlungsweise, ust_option=ust_option, - status='aktiv' + status="aktiv", ) - + # Erstelle automatisch eine Abrechnung für das aktuelle Jahr current_year = dt.now().year - + # Berechne erwartete jährliche Pacht basierend auf Zahlungsweise expected_annual_rent = pachtzins_pauschal if pachtzins_pauschal else 0 - + abrechnung, created = LandAbrechnung.objects.get_or_create( land=land, abrechnungsjahr=current_year, defaults={ - 'pacht_vereinnahmt': expected_annual_rent, # Setze erwartete Jahrespacht - 'umlagen_vereinnahmt': 0, - 'grundsteuer_betrag': 0, - 'versicherungen_betrag': 0, - } + "pacht_vereinnahmt": expected_annual_rent, # Setze erwartete Jahrespacht + "umlagen_vereinnahmt": 0, + "grundsteuer_betrag": 0, + "versicherungen_betrag": 0, + }, ) - + # Falls Abrechnung bereits existiert, aktualisiere die Pacht wenn höher if not created and expected_annual_rent > abrechnung.pacht_vereinnahmt: abrechnung.pacht_vereinnahmt = expected_annual_rent abrechnung.save() - - success_msg = f'Verpachtung von {land} an {paechter.get_full_name()} wurde erfolgreich erstellt.' + + success_msg = f"Verpachtung von {land} an {paechter.get_full_name()} wurde erfolgreich erstellt." if created: - success_msg += f' Abrechnung für {current_year} wurde automatisch angelegt' + success_msg += ( + f" Abrechnung für {current_year} wurde automatisch angelegt" + ) if expected_annual_rent > 0: - success_msg += f' (Erwartete Jahrespacht: {expected_annual_rent}€)' - success_msg += '.' + success_msg += f" (Erwartete Jahrespacht: {expected_annual_rent}€)" + success_msg += "." elif expected_annual_rent > 0: - success_msg += f' Erwartete Jahrespacht in Abrechnung {current_year} wurde aktualisiert ({expected_annual_rent}€).' - + success_msg += f" Erwartete Jahrespacht in Abrechnung {current_year} wurde aktualisiert ({expected_annual_rent}€)." + messages.success(request, success_msg) - return redirect('stiftung:land_detail', pk=land.pk) + return redirect("stiftung:land_detail", pk=land.pk) else: - messages.error(request, 'Bitte füllen Sie alle Pflichtfelder aus.') - + messages.error(request, "Bitte füllen Sie alle Pflichtfelder aus.") + # Verfügbare Pächter - paechter_list = Paechter.objects.filter(aktiv=True).order_by('nachname', 'vorname') - + paechter_list = Paechter.objects.filter(aktiv=True).order_by("nachname", "vorname") + # Berechne verfügbare Fläche (Gesamtfläche minus bereits verpachtete Fläche) verfuegbare_flaeche = land.groesse_qm if land.verp_flaeche_aktuell and land.verp_flaeche_aktuell > 0: verfuegbare_flaeche = land.groesse_qm - land.verp_flaeche_aktuell - + context = { - 'land': land, - 'paechter_list': paechter_list, - 'current_year': dt.now().year, - 'is_edit': False, - 'verfuegbare_flaeche': verfuegbare_flaeche, + "land": land, + "paechter_list": paechter_list, + "current_year": dt.now().year, + "is_edit": False, + "verfuegbare_flaeche": verfuegbare_flaeche, } - - return render(request, 'stiftung/land_verpachtung_form.html', context) + + return render(request, "stiftung/land_verpachtung_form.html", context) -@login_required +@login_required def land_verpachtung_end(request, land_pk): """Beende die aktuelle Verpachtung eines Landes""" land = get_object_or_404(Land, pk=land_pk) - - if request.method == 'POST': + + if request.method == "POST": # Verpachtung beenden land.aktueller_paechter = None land.paechter_name = None land.paechter_anschrift = None land.pachtende = datetime.now().date() land.save() - - messages.success(request, f'Verpachtung von {land} wurde beendet.') - return redirect('stiftung:land_detail', pk=land.pk) - + + messages.success(request, f"Verpachtung von {land} wurde beendet.") + return redirect("stiftung:land_detail", pk=land.pk) + context = { - 'land': land, + "land": land, } - - return render(request, 'stiftung/land_verpachtung_end.html', context) + + return render(request, "stiftung/land_verpachtung_end.html", context) @login_required def land_verpachtung_edit(request, land_pk): """Bearbeite eine bestehende Verpachtung direkt im Land-Model""" land = get_object_or_404(Land, pk=land_pk) - - if request.method == 'POST': + + if request.method == "POST": # Einfaches Formular für die wichtigsten Verpachtungsfelder - aktueller_paechter_id = request.POST.get('aktueller_paechter') - pachtbeginn = request.POST.get('pachtbeginn') - pachtende = request.POST.get('pachtende') - pachtzins_pauschal = request.POST.get('pachtzins_pauschal') - zahlungsweise = request.POST.get('zahlungsweise') - ust_option = request.POST.get('ust_option') == 'on' - verpachtete_flaeche = request.POST.get('verpachtete_flaeche') - + aktueller_paechter_id = request.POST.get("aktueller_paechter") + pachtbeginn = request.POST.get("pachtbeginn") + pachtende = request.POST.get("pachtende") + pachtzins_pauschal = request.POST.get("pachtzins_pauschal") + zahlungsweise = request.POST.get("zahlungsweise") + ust_option = request.POST.get("ust_option") == "on" + verpachtete_flaeche = request.POST.get("verpachtete_flaeche") + if aktueller_paechter_id and pachtbeginn: paechter = get_object_or_404(Paechter, pk=aktueller_paechter_id) - + # Land aktualisieren land.aktueller_paechter = paechter land.paechter_name = paechter.get_full_name() @@ -5386,188 +6528,208 @@ def land_verpachtung_edit(request, land_pk): if verpachtete_flaeche: land.verp_flaeche_aktuell = verpachtete_flaeche land.save() - - messages.success(request, f'Verpachtung von {land} an {paechter.get_full_name()} wurde erfolgreich aktualisiert.') - return redirect('stiftung:land_detail', pk=land.pk) + + messages.success( + request, + f"Verpachtung von {land} an {paechter.get_full_name()} wurde erfolgreich aktualisiert.", + ) + return redirect("stiftung:land_detail", pk=land.pk) else: - messages.error(request, 'Bitte füllen Sie alle Pflichtfelder aus.') - + messages.error(request, "Bitte füllen Sie alle Pflichtfelder aus.") + # Verfügbare Pächter - paechter_list = Paechter.objects.filter(aktiv=True).order_by('nachname', 'vorname') - + paechter_list = Paechter.objects.filter(aktiv=True).order_by("nachname", "vorname") + # Berechne verfügbare Fläche (Gesamtfläche minus bereits verpachtete Fläche) verfuegbare_flaeche = land.groesse_qm if land.verp_flaeche_aktuell and land.verp_flaeche_aktuell > 0: verfuegbare_flaeche = land.groesse_qm - land.verp_flaeche_aktuell - + context = { - 'land': land, - 'paechter_list': paechter_list, - 'current_year': datetime.now().year, - 'is_edit': True, - 'verfuegbare_flaeche': verfuegbare_flaeche, + "land": land, + "paechter_list": paechter_list, + "current_year": datetime.now().year, + "is_edit": True, + "verfuegbare_flaeche": verfuegbare_flaeche, } - - return render(request, 'stiftung/land_verpachtung_form.html', context) + + return render(request, "stiftung/land_verpachtung_form.html", context) # Settings Management Views @login_required def app_settings(request): """Application settings management interface""" - + # Group settings by category categories = {} - for setting in AppConfiguration.objects.filter(is_active=True).order_by('category', 'order', 'display_name'): + for setting in AppConfiguration.objects.filter(is_active=True).order_by( + "category", "order", "display_name" + ): if setting.category not in categories: categories[setting.category] = [] categories[setting.category].append(setting) - - if request.method == 'POST': + + if request.method == "POST": # Handle form submission updated_count = 0 for key, value in request.POST.items(): - if key.startswith('setting_'): - setting_key = key.replace('setting_', '') + if key.startswith("setting_"): + setting_key = key.replace("setting_", "") try: - setting = AppConfiguration.objects.get(key=setting_key, is_active=True) + setting = AppConfiguration.objects.get( + key=setting_key, is_active=True + ) if not setting.is_system and setting.value != value: setting.value = value setting.save() updated_count += 1 except AppConfiguration.DoesNotExist: continue - + if updated_count > 0: - messages.success(request, f'Successfully updated {updated_count} settings!') + messages.success(request, f"Successfully updated {updated_count} settings!") else: - messages.info(request, 'No changes were made.') - - return redirect('stiftung:app_settings') - + messages.info(request, "No changes were made.") + + return redirect("stiftung:app_settings") + context = { - 'categories': categories, - 'title': 'Application Settings', + "categories": categories, + "title": "Application Settings", } - return render(request, 'stiftung/app_settings.html', context) + return render(request, "stiftung/app_settings.html", context) # Unterstützungen Views (Destinataer-focused) @login_required def unterstuetzungen_all(request): """List all support payments - destinataer-focused view""" - status = request.GET.get('status') - destinataer_id = request.GET.get('destinataer') - export = request.GET.get('format', '') - selected_ids = request.POST.getlist('selected_entries') if request.method == 'POST' else [] - + status = request.GET.get("status") + destinataer_id = request.GET.get("destinataer") + export = request.GET.get("format", "") + selected_ids = ( + request.POST.getlist("selected_entries") if request.method == "POST" else [] + ) + unterstuetzungen = DestinataerUnterstuetzung.objects.select_related( - 'destinataer', 'konto', 'ausgezahlt_von', 'wiederkehrend_von' - ).order_by('-faellig_am') - + "destinataer", "konto", "ausgezahlt_von", "wiederkehrend_von" + ).order_by("-faellig_am") + # Filtering if status: unterstuetzungen = unterstuetzungen.filter(status=status) if destinataer_id: unterstuetzungen = unterstuetzungen.filter(destinataer_id=destinataer_id) - + # Enhanced CSV export with field selection - if export == 'csv': + if export == "csv": return export_unterstuetzungen_csv(request, unterstuetzungen, selected_ids) - + # PDF export (simple table via WeasyPrint; graceful fallback if missing) - if export == 'pdf': + if export == "pdf": try: from django.template.loader import render_to_string from weasyprint import HTML - html = render_to_string('stiftung/unterstuetzungen_pdf.html', {'unterstuetzungen': unterstuetzungen}) + + html = render_to_string( + "stiftung/unterstuetzungen_pdf.html", + {"unterstuetzungen": unterstuetzungen}, + ) from django.http import HttpResponse + pdf = HTML(string=html).write_pdf() - resp = HttpResponse(pdf, content_type='application/pdf') - resp['Content-Disposition'] = 'inline; filename=unterstuetzungen.pdf' + resp = HttpResponse(pdf, content_type="application/pdf") + resp["Content-Disposition"] = "inline; filename=unterstuetzungen.pdf" return resp except Exception: pass - + # Statistics - total_betrag = unterstuetzungen.aggregate(total=Sum('betrag'))['total'] or 0 - avg_betrag = unterstuetzungen.aggregate(avg=Avg('betrag'))['avg'] or 0 - + total_betrag = unterstuetzungen.aggregate(total=Sum("betrag"))["total"] or 0 + avg_betrag = unterstuetzungen.aggregate(avg=Avg("betrag"))["avg"] or 0 + # Available destinataer for filter - destinataer = Destinataer.objects.all().order_by('nachname', 'vorname') - + destinataer = Destinataer.objects.all().order_by("nachname", "vorname") + context = { - 'page_obj': unterstuetzungen, # Use directly for now (pagination can be added later) - 'unterstuetzungen': unterstuetzungen, - 'title': 'Alle Unterstützungen', - 'status_filter': status, - 'total_betrag': total_betrag, - 'avg_betrag': avg_betrag, - 'status_choices': DestinataerUnterstuetzung.STATUS_CHOICES, - 'destinataer': destinataer, + "page_obj": unterstuetzungen, # Use directly for now (pagination can be added later) + "unterstuetzungen": unterstuetzungen, + "title": "Alle Unterstützungen", + "status_filter": status, + "total_betrag": total_betrag, + "avg_betrag": avg_betrag, + "status_choices": DestinataerUnterstuetzung.STATUS_CHOICES, + "destinataer": destinataer, } - return render(request, 'stiftung/unterstuetzungen_all.html', context) + return render(request, "stiftung/unterstuetzungen_all.html", context) -@login_required +@login_required def unterstuetzung_create(request): """Create a new support payment""" # Get destinataer from URL parameter if provided - destinataer_id = request.GET.get('destinataer') + destinataer_id = request.GET.get("destinataer") initial = {} if destinataer_id: - initial['destinataer'] = destinataer_id + initial["destinataer"] = destinataer_id # Pre-populate IBAN and name if destinataer is specified try: destinataer = Destinataer.objects.get(pk=destinataer_id) - if hasattr(destinataer, 'iban') and destinataer.iban: - initial['empfaenger_iban'] = destinataer.iban - initial['empfaenger_name'] = destinataer.get_full_name() + if hasattr(destinataer, "iban") and destinataer.iban: + initial["empfaenger_iban"] = destinataer.iban + initial["empfaenger_name"] = destinataer.get_full_name() except Destinataer.DoesNotExist: pass - - if request.method == 'POST': + + if request.method == "POST": form = UnterstuetzungForm(request.POST) if form.is_valid(): - ist_wiederkehrend = form.cleaned_data.get('ist_wiederkehrend', False) - + ist_wiederkehrend = form.cleaned_data.get("ist_wiederkehrend", False) + if ist_wiederkehrend: # Create recurring payment template wiederkehrend = UnterstuetzungWiederkehrend.objects.create( - destinataer=form.cleaned_data['destinataer'], - konto=form.cleaned_data['konto'], - betrag=form.cleaned_data['betrag'], - intervall=form.cleaned_data['intervall'], - beschreibung=form.cleaned_data['beschreibung'], - empfaenger_iban=form.cleaned_data['empfaenger_iban'], - empfaenger_name=form.cleaned_data['empfaenger_name'], - verwendungszweck=form.cleaned_data['verwendungszweck'], - erste_zahlung_am=form.cleaned_data['faellig_am'], - letzte_zahlung_am=form.cleaned_data.get('letzte_zahlung_am'), - naechste_generierung=form.cleaned_data['faellig_am'], - erstellt_von=request.user + destinataer=form.cleaned_data["destinataer"], + konto=form.cleaned_data["konto"], + betrag=form.cleaned_data["betrag"], + intervall=form.cleaned_data["intervall"], + beschreibung=form.cleaned_data["beschreibung"], + empfaenger_iban=form.cleaned_data["empfaenger_iban"], + empfaenger_name=form.cleaned_data["empfaenger_name"], + verwendungszweck=form.cleaned_data["verwendungszweck"], + erste_zahlung_am=form.cleaned_data["faellig_am"], + letzte_zahlung_am=form.cleaned_data.get("letzte_zahlung_am"), + naechste_generierung=form.cleaned_data["faellig_am"], + erstellt_von=request.user, ) - + # Create the first payment unterstuetzung = form.save(commit=False) unterstuetzung.wiederkehrend_von = wiederkehrend unterstuetzung.save() - - messages.success(request, f'Wiederkehrende Unterstützung für {unterstuetzung.destinataer} wurde erfolgreich erstellt. Die erste Zahlung ist am {unterstuetzung.faellig_am} fällig.') + + messages.success( + request, + f"Wiederkehrende Unterstützung für {unterstuetzung.destinataer} wurde erfolgreich erstellt. Die erste Zahlung ist am {unterstuetzung.faellig_am} fällig.", + ) else: # Create single payment unterstuetzung = form.save() - messages.success(request, f'Unterstützung für {unterstuetzung.destinataer} wurde erfolgreich erstellt.') - - return redirect('stiftung:unterstuetzung_detail', pk=unterstuetzung.pk) + messages.success( + request, + f"Unterstützung für {unterstuetzung.destinataer} wurde erfolgreich erstellt.", + ) + + return redirect("stiftung:unterstuetzung_detail", pk=unterstuetzung.pk) else: form = UnterstuetzungForm(initial=initial) - + context = { - 'form': form, - 'title': 'Neue Unterstützung erstellen', + "form": form, + "title": "Neue Unterstützung erstellen", } - return render(request, 'stiftung/unterstuetzung_form.html', context) + return render(request, "stiftung/unterstuetzung_form.html", context) @login_required @@ -5576,16 +6738,13 @@ def get_destinataer_info(request, destinataer_id): try: destinataer = Destinataer.objects.get(pk=destinataer_id) data = { - 'success': True, - 'name': destinataer.get_full_name(), - 'iban': getattr(destinataer, 'iban', '') or '', + "success": True, + "name": destinataer.get_full_name(), + "iban": getattr(destinataer, "iban", "") or "", } except Destinataer.DoesNotExist: - data = { - 'success': False, - 'error': 'Destinataer not found' - } - + data = {"success": False, "error": "Destinataer not found"} + return JsonResponse(data) @@ -5593,127 +6752,133 @@ def get_destinataer_info(request, destinataer_id): def unterstuetzung_detail(request, pk): """View support payment details""" unterstuetzung = get_object_or_404(DestinataerUnterstuetzung, pk=pk) - + # Check if this payment can be marked as paid can_mark_paid = unterstuetzung.can_be_marked_paid() - + context = { - 'unterstuetzung': unterstuetzung, - 'title': f'Unterstützung für {unterstuetzung.destinataer.get_full_name()}', - 'can_mark_paid': can_mark_paid, + "unterstuetzung": unterstuetzung, + "title": f"Unterstützung für {unterstuetzung.destinataer.get_full_name()}", + "can_mark_paid": can_mark_paid, } - return render(request, 'stiftung/unterstuetzung_detail.html', context) + return render(request, "stiftung/unterstuetzung_detail.html", context) @login_required def unterstuetzung_mark_paid(request, pk): """Mark a support payment as paid""" unterstuetzung = get_object_or_404(DestinataerUnterstuetzung, pk=pk) - + if not unterstuetzung.can_be_marked_paid(): - messages.error(request, 'Diese Unterstützung kann nicht als bezahlt markiert werden.') - return redirect('stiftung:unterstuetzung_detail', pk=pk) - - if request.method == 'POST': + messages.error( + request, "Diese Unterstützung kann nicht als bezahlt markiert werden." + ) + return redirect("stiftung:unterstuetzung_detail", pk=pk) + + if request.method == "POST": form = UnterstuetzungMarkAsPaidForm(request.POST) if form.is_valid(): - unterstuetzung.status = 'ausgezahlt' - unterstuetzung.ausgezahlt_am = form.cleaned_data['ausgezahlt_am'] + unterstuetzung.status = "ausgezahlt" + unterstuetzung.ausgezahlt_am = form.cleaned_data["ausgezahlt_am"] unterstuetzung.ausgezahlt_von = request.user - + # Add optional note to description - bemerkung = form.cleaned_data.get('bemerkung') + bemerkung = form.cleaned_data.get("bemerkung") if bemerkung: if unterstuetzung.beschreibung: - unterstuetzung.beschreibung += f' | Zahlung: {bemerkung}' + unterstuetzung.beschreibung += f" | Zahlung: {bemerkung}" else: - unterstuetzung.beschreibung = f'Zahlung: {bemerkung}' - + unterstuetzung.beschreibung = f"Zahlung: {bemerkung}" + unterstuetzung.save() - messages.success(request, f'Unterstützung wurde als bezahlt markiert.') - return redirect('stiftung:unterstuetzung_detail', pk=pk) + messages.success(request, f"Unterstützung wurde als bezahlt markiert.") + return redirect("stiftung:unterstuetzung_detail", pk=pk) else: form = UnterstuetzungMarkAsPaidForm() - + context = { - 'form': form, - 'unterstuetzung': unterstuetzung, - 'title': f'Zahlung markieren - {unterstuetzung.destinataer.get_full_name()}', + "form": form, + "unterstuetzung": unterstuetzung, + "title": f"Zahlung markieren - {unterstuetzung.destinataer.get_full_name()}", } - return render(request, 'stiftung/unterstuetzung_mark_paid.html', context) + return render(request, "stiftung/unterstuetzung_mark_paid.html", context) @login_required def wiederkehrende_unterstuetzungen(request): """List all recurring support payment templates""" from django.db.models import Count - + # Check for cleanup request - if request.GET.get('cleanup') == '1': + if request.GET.get("cleanup") == "1": # Find templates with no associated payments verwaiste_templates = UnterstuetzungWiederkehrend.objects.annotate( - zahlung_count=Count('destinataerunterstuetzung') + zahlung_count=Count("destinataerunterstuetzung") ).filter(zahlung_count=0) - + if verwaiste_templates.exists(): anzahl_geloescht = verwaiste_templates.count() - template_namen = list(verwaiste_templates.values_list('destinataer__nachname', flat=True)) + template_namen = list( + verwaiste_templates.values_list("destinataer__nachname", flat=True) + ) verwaiste_templates.delete() messages.success( - request, - f'{anzahl_geloescht} verwaiste Zahlungsvorlagen bereinigt: {", ".join(template_namen[:5])}{"..." if len(template_namen) > 5 else ""}' + request, + f'{anzahl_geloescht} verwaiste Zahlungsvorlagen bereinigt: {", ".join(template_namen[:5])}{"..." if len(template_namen) > 5 else ""}', ) else: - messages.info(request, 'Keine verwaisten Zahlungsvorlagen gefunden.') - - return redirect('stiftung:wiederkehrende_unterstuetzungen') - + messages.info(request, "Keine verwaisten Zahlungsvorlagen gefunden.") + + return redirect("stiftung:wiederkehrende_unterstuetzungen") + # Get all templates with payment counts - templates = UnterstuetzungWiederkehrend.objects.select_related( - 'destinataer', 'konto' - ).annotate( - aktive_zahlungen=Count('destinataerunterstuetzung') - ).all() - + templates = ( + UnterstuetzungWiederkehrend.objects.select_related("destinataer", "konto") + .annotate(aktive_zahlungen=Count("destinataerunterstuetzung")) + .all() + ) + context = { - 'templates': templates, - 'title': 'Wiederkehrende Unterstützungen', + "templates": templates, + "title": "Wiederkehrende Unterstützungen", } - return render(request, 'stiftung/wiederkehrende_unterstuetzungen.html', context) + return render(request, "stiftung/wiederkehrende_unterstuetzungen.html", context) @login_required def edit_help_box(request): """Bearbeite oder erstelle eine Hilfs-Infobox""" from .models import HelpBox - + # Nur root oder Superuser dürfen bearbeiten - if request.user.username != 'root' and not request.user.is_superuser: - messages.error(request, 'Sie haben keine Berechtigung, Hilfsboxen zu bearbeiten.') - return redirect('stiftung:dashboard') - - if request.method == 'POST': - page_key = request.POST.get('page_key') - title = request.POST.get('title') - content = request.POST.get('content') - is_active = request.POST.get('is_active') == 'on' - + if request.user.username != "root" and not request.user.is_superuser: + messages.error( + request, "Sie haben keine Berechtigung, Hilfsboxen zu bearbeiten." + ) + return redirect("stiftung:dashboard") + + if request.method == "POST": + page_key = request.POST.get("page_key") + title = request.POST.get("title") + content = request.POST.get("content") + is_active = request.POST.get("is_active") == "on" + if not page_key or not title or not content: - messages.error(request, 'Alle Felder sind erforderlich.') - return redirect(request.META.get('HTTP_REFERER', 'stiftung:dashboard')) - + messages.error(request, "Alle Felder sind erforderlich.") + return redirect(request.META.get("HTTP_REFERER", "stiftung:dashboard")) + # Hilfsbox erstellen oder aktualisieren help_box, created = HelpBox.objects.get_or_create( page_key=page_key, defaults={ - 'title': title, - 'content': content, - 'is_active': is_active, - 'created_by': request.user.username, - 'updated_by': request.user.username, - } + "title": title, + "content": content, + "is_active": is_active, + "created_by": request.user.username, + "updated_by": request.user.username, + }, ) - + if not created: # Existierende Hilfsbox aktualisieren help_box.title = title @@ -5721,31 +6886,31 @@ def edit_help_box(request): help_box.is_active = is_active help_box.updated_by = request.user.username help_box.save() - + messages.success(request, f'Hilfsbox "{title}" wurde aktualisiert.') else: messages.success(request, f'Hilfsbox "{title}" wurde erstellt.') - + # Zurück zur vorherigen Seite - return redirect(request.META.get('HTTP_REFERER', 'stiftung:dashboard')) - + return redirect(request.META.get("HTTP_REFERER", "stiftung:dashboard")) + # GET Request - Zeige Admin-Übersicht der Hilfsboxen - help_boxes = HelpBox.objects.all().order_by('page_key', '-updated_at') - + help_boxes = HelpBox.objects.all().order_by("page_key", "-updated_at") + # Statistiken berechnen active_count = help_boxes.filter(is_active=True).count() inactive_count = help_boxes.filter(is_active=False).count() - existing_pages = set(help_boxes.values_list('page_key', flat=True)) - + existing_pages = set(help_boxes.values_list("page_key", flat=True)) + # Verfügbare Seiten aus dem Model holen available_pages = HelpBox.PAGE_CHOICES - + context = { - 'help_boxes': help_boxes, - 'active_count': active_count, - 'inactive_count': inactive_count, - 'existing_pages': existing_pages, - 'available_pages': available_pages, - 'title': 'Hilfs-Infoboxen verwalten', + "help_boxes": help_boxes, + "active_count": active_count, + "inactive_count": inactive_count, + "existing_pages": existing_pages, + "available_pages": available_pages, + "title": "Hilfs-Infoboxen verwalten", } - return render(request, 'stiftung/help_boxes_admin.html', context) + return render(request, "stiftung/help_boxes_admin.html", context)