diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index fa6453d..2f7cac2 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -58,28 +58,64 @@ jobs: - name: Set up environment run: | cp env-template.txt .env - echo "DEBUG=True" >> .env - echo "SECRET_KEY=test-secret-key-for-ci" >> .env - echo "DATABASE_URL=postgresql://postgres:postgres@localhost:5432/test_stiftung" >> .env + echo "DJANGO_DEBUG=1" >> .env + echo "DJANGO_SECRET_KEY=test-secret-key-for-ci" >> .env + echo "POSTGRES_DB=test_stiftung" >> .env + echo "POSTGRES_USER=postgres" >> .env + echo "POSTGRES_PASSWORD=postgres" >> .env + echo "DB_HOST=localhost" >> .env + echo "DB_PORT=5432" >> .env echo "REDIS_URL=redis://localhost:6379/0" >> .env - name: Run migrations working-directory: ./app + env: + DJANGO_DEBUG: "1" + DJANGO_SECRET_KEY: "test-secret-key-for-ci" + POSTGRES_DB: "test_stiftung" + POSTGRES_USER: "postgres" + POSTGRES_PASSWORD: "postgres" + DB_HOST: "localhost" + DB_PORT: "5432" run: | python manage.py migrate - name: Run tests working-directory: ./app + env: + DJANGO_DEBUG: "1" + DJANGO_SECRET_KEY: "test-secret-key-for-ci" + POSTGRES_DB: "test_stiftung" + POSTGRES_USER: "postgres" + POSTGRES_PASSWORD: "postgres" + DB_HOST: "localhost" + DB_PORT: "5432" run: | python manage.py test - name: Check Django configuration working-directory: ./app + env: + DJANGO_DEBUG: "1" + DJANGO_SECRET_KEY: "test-secret-key-for-ci" + POSTGRES_DB: "test_stiftung" + POSTGRES_USER: "postgres" + POSTGRES_PASSWORD: "postgres" + DB_HOST: "localhost" + DB_PORT: "5432" run: | python manage.py check --deploy - name: Collect static files working-directory: ./app + env: + DJANGO_DEBUG: "1" + DJANGO_SECRET_KEY: "test-secret-key-for-ci" + POSTGRES_DB: "test_stiftung" + POSTGRES_USER: "postgres" + POSTGRES_PASSWORD: "postgres" + DB_HOST: "localhost" + DB_PORT: "5432" run: | python manage.py collectstatic --noinput diff --git a/app/Dockerfile b/app/Dockerfile index 500722f..1e6d6d2 100644 --- a/app/Dockerfile +++ b/app/Dockerfile @@ -4,7 +4,8 @@ ENV PYTHONDONTWRITEBYTECODE=1 \ RUN apt-get update && apt-get install -y --no-install-recommends \ build-essential libpq-dev postgresql-client \ - libpango-1.0-0 libcairo2 libffi-dev \ + libpango-1.0-0 libpangoft2-1.0-0 libcairo2 libffi-dev \ + libgdk-pixbuf-xlib-2.0-0 libharfbuzz0b libfribidi0 \ libjpeg-dev libpng-dev \ && rm -rf /var/lib/apt/lists/* diff --git a/app/check_orphaned_templates.py b/app/check_orphaned_templates.py new file mode 100644 index 0000000..448d0cb --- /dev/null +++ b/app/check_orphaned_templates.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python +import os +import django + +# Setup Django environment +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings') +django.setup() + +from stiftung.models import UnterstuetzungWiederkehrend, DestinataerUnterstuetzung + +# Find orphaned templates +orphaned = [] +for template in UnterstuetzungWiederkehrend.objects.all(): + count = DestinataerUnterstuetzung.objects.filter(wiederkehrend_von=template).count() + if count == 0: + orphaned.append((template, count)) + +print(f'Verwaiste Vorlagen gefunden: {len(orphaned)}') +if orphaned: + print('Details:') + for template, count in orphaned[:10]: # Show first 10 + print(f'- ID {template.id}: {template.destinataer} - {template.beschreibung} ({template.betrag}€)') +else: + print('Keine verwaisten Vorlagen!') + +# Also show all templates with their payment counts +print('\n--- Alle wiederkehrende Vorlagen ---') +all_templates = UnterstuetzungWiederkehrend.objects.all() +for template in all_templates[:10]: # Show first 10 + count = DestinataerUnterstuetzung.objects.filter(wiederkehrend_von=template).count() + status = "VERWAIST" if count == 0 else f"{count} Zahlungen" + print(f'ID {template.id}: {template.destinataer} - {template.beschreibung} ({template.betrag}€) - {status}') diff --git a/app/core/settings.py b/app/core/settings.py index 8c97504..38ade65 100644 --- a/app/core/settings.py +++ b/app/core/settings.py @@ -1,5 +1,9 @@ import os from pathlib import Path +from dotenv import load_dotenv + +# Load environment variables from .env file +load_dotenv() BASE_DIR = Path(__file__).resolve().parent.parent diff --git a/app/requirements.txt b/app/requirements.txt index 2253129..28db971 100644 --- a/app/requirements.txt +++ b/app/requirements.txt @@ -7,3 +7,5 @@ weasyprint==62.3 python-dotenv==1.0.1 requests==2.32.3 gunicorn==22.0.0 +python-dateutil==2.9.0 +markdown==3.6 diff --git a/app/stiftung/admin.py b/app/stiftung/admin.py index 44f7bcf..dff21b6 100644 --- a/app/stiftung/admin.py +++ b/app/stiftung/admin.py @@ -3,9 +3,11 @@ from django.utils.html import format_html from django.urls import reverse from django.db.models import Sum, Count from django.utils.safestring import mark_safe +from . import models from .models import ( - Person, Paechter, Destinataer, DokumentLink, Foerderung, Land, Verpachtung, CSVImport, - Rentmeister, StiftungsKonto, Verwaltungskosten, BankTransaction, AuditLog, BackupJob + Person, Paechter, Destinataer, DokumentLink, Foerderung, Land, CSVImport, + Rentmeister, StiftungsKonto, Verwaltungskosten, BankTransaction, AuditLog, BackupJob, AppConfiguration, + DestinataerUnterstuetzung, UnterstuetzungWiederkehrend ) @admin.register(CSVImport) @@ -214,66 +216,6 @@ class LandAdmin(admin.ModelAdmin): return f"{obj.get_verpachtungsgrad():.1f}%" verpachtungsgrad_berechnet.short_description = 'Verpachtungsgrad' -@admin.register(Verpachtung) -class VerpachtungAdmin(admin.ModelAdmin): - list_display = [ - 'vertragsnummer', 'land', 'paechter', 'pachtbeginn', 'pachtende', - 'pachtzins_jaehrlich', 'verpachtete_flaeche', 'status', 'restlaufzeit' - ] - list_filter = ['status', 'pachtbeginn', 'pachtende', 'land__gemeinde'] - search_fields = ['vertragsnummer', 'land__gemeinde', 'paechter__nachname', 'paechter__vorname'] - ordering = ['-pachtbeginn'] - readonly_fields = ['id', 'vertragsdauer_tage', 'restlaufzeit_tage', 'is_aktiv_status'] - - fieldsets = ( - ('Vertragsdaten', { - 'fields': ('vertragsnummer', 'land', 'paechter', 'pachtbeginn', 'pachtende', 'verlaengerung') - }), - ('Finanzielle Bedingungen', { - 'fields': ('pachtzins_pro_qm', 'pachtzins_jaehrlich') - }), - ('Flächenangaben', { - 'fields': ('verpachtete_flaeche',) - }), - ('Status', { - 'fields': ('status',) - }), - ('Dokumentation', { - 'fields': ('verwendungsnachweis', 'bemerkungen') - }), - ('System', { - 'fields': ('id', 'erstellt_am', 'aktualisiert_am'), - 'classes': ('collapse',) - }), - ) - - def restlaufzeit(self, obj): - tage = obj.get_restlaufzeit_tage() - if tage > 0: - if tage < 30: - color = 'red' - elif tage < 90: - color = 'orange' - else: - color = 'green' - return format_html('{} Tage', color, tage) - return 'Abgelaufen' - restlaufzeit.short_description = 'Restlaufzeit' - - def vertragsdauer_tage(self, obj): - return f"{obj.get_vertragsdauer_tage()} Tage" - vertragsdauer_tage.short_description = 'Vertragsdauer' - - def restlaufzeit_tage(self, obj): - return f"{obj.get_restlaufzeit_tage()} Tage" - restlaufzeit_tage.short_description = 'Restlaufzeit' - - def is_aktiv_status(self, obj): - if obj.is_aktiv(): - return format_html('✓ Aktiv') - return format_html('✗ Inaktiv') - is_aktiv_status.short_description = 'Aktueller Status' - @admin.register(DokumentLink) class DokumentLinkAdmin(admin.ModelAdmin): list_display = ['titel', 'kontext', 'paperless_document_id'] @@ -541,6 +483,134 @@ class BackupJobAdmin(admin.ModelAdmin): 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'] + + 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',) + }), + ) + + 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) + elif len(value) > 100: + return value[:100] + '...' + return 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']) + 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'] + + 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',) + }), + ) + + +@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'] + + 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',) + }), + ) + + +@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'] + + 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',) + }), + ) + + 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 + obj.updated_by = request.user.username + super().save_model(request, obj, form, change) + + # Customize admin site admin.site.site_header = "Stiftungsverwaltung Administration" admin.site.site_title = "Stiftungsverwaltung Admin" diff --git a/app/stiftung/forms.py b/app/stiftung/forms.py index cfb4fda..7cdff86 100644 --- a/app/stiftung/forms.py +++ b/app/stiftung/forms.py @@ -1,9 +1,10 @@ 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, Verpachtung, DokumentLink, Foerderung, BankTransaction, - DestinataerUnterstuetzung, DestinataerNotiz, LandAbrechnung, + Paechter, Destinataer, Land, DokumentLink, Foerderung, BankTransaction, + DestinataerUnterstuetzung, UnterstuetzungWiederkehrend, DestinataerNotiz, LandAbrechnung, ) import re @@ -105,10 +106,24 @@ class RentmeisterForm(forms.ModelForm): 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') + # Helper function to ensure we have date objects + def ensure_date(date_value): + if not date_value: + return None + 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.') @@ -404,23 +419,6 @@ class LandAbrechnungForm(forms.ModelForm): } -class VerpachtungForm(forms.ModelForm): - """Form für das Erstellen und Bearbeiten von Verpachtungen""" - - class Meta: - model = Verpachtung - fields = '__all__' - widgets = { - 'paechter': forms.Select(attrs={'class': 'form-select'}), - 'land': forms.Select(attrs={'class': 'form-select'}), - 'pacht_pro_qm': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}), - 'pachtbeginn': forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}), - 'pachtende': 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 DokumentLinkForm(forms.ModelForm): """Form für das Erstellen und Bearbeiten von Dokumentverknüpfungen""" @@ -438,20 +436,164 @@ class DokumentLinkForm(forms.ModelForm): 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 ---" + # 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') + # Set current year as default for new forms + if not self.instance.pk: + self.fields['jahr'].initial = timezone.now().year + class Meta: model = Foerderung - fields = '__all__' + fields = [ + 'destinataer', 'jahr', 'betrag', 'kategorie', 'status', + 'antragsdatum', 'entscheidungsdatum', 'verwendungsnachweis', 'bemerkungen' + ] widgets = { - 'person': forms.Select(attrs={'class': 'form-select'}), 'destinataer': forms.Select(attrs={'class': 'form-select'}), 'jahr': forms.NumberInput(attrs={'class': 'form-control'}), 'betrag': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}), - 'zweck': forms.TextInput(attrs={'class': 'form-control'}), 'kategorie': forms.Select(attrs={'class': 'form-select'}), 'status': forms.Select(attrs={'class': 'form-select'}), - 'ausgezahlt_am': forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}), - 'notizen': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}), + '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', + } + + 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', + } + + +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' + ) + intervall = forms.ChoiceField( + choices=[('', '--- Wählen Sie ein Intervall ---')] + UnterstuetzungWiederkehrend.INTERVALL_CHOICES, + required=False, + 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' + ) + + class Meta: + model = DestinataerUnterstuetzung + fields = [ + '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'}), + } + + 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', + } + + 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()' + + def clean(self): + cleaned_data = super().clean() + 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.') + + 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' + ] + + 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'}), + } + + +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() + ) + + bemerkung = forms.CharField( + widget=forms.Textarea(attrs={'class': 'form-control', 'rows': 3}), + label='Bemerkung (optional)', + required=False, + help_text='Optionale Notiz zur Zahlung' + ) + + class BankTransactionForm(forms.ModelForm): diff --git a/app/stiftung/management/commands/generate_recurring_payments.py b/app/stiftung/management/commands/generate_recurring_payments.py new file mode 100644 index 0000000..0e4149c --- /dev/null +++ b/app/stiftung/management/commands/generate_recurring_payments.py @@ -0,0 +1,133 @@ +""" +Management command to generate due recurring support payments. +This command should be run daily via cron or similar scheduling system. +""" + +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' + + def add_arguments(self, parser): + parser.add_argument( + '--dry-run', + action='store_true', + help='Show what would be generated without actually creating payments', + ) + parser.add_argument( + '--days-ahead', + type=int, + default=0, + 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'] + + 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')) + + # Get all active recurring payment templates that are due + templates = UnterstuetzungWiederkehrend.objects.filter( + 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'€{template.betrag} due {template.naechste_generierung.strftime("%d.%m.%Y")}' + ) + generated_count += 1 + else: + # Actually generate the payment + neue_zahlung = template.generiere_naechste_zahlung() + if neue_zahlung: + self.stdout.write( + self.style.SUCCESS( + 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}') + 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)' + ) + ) + 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)}' + ) + ) + logger.error(f'Error generating recurring payment for template {template.pk}: {str(e)}') + + # Summary + self.stdout.write('\n' + '='*50) + if dry_run: + self.stdout.write( + self.style.SUCCESS( + f'DRY RUN COMPLETE: {generated_count} payments would be generated' + ) + ) + else: + self.stdout.write( + self.style.SUCCESS( + f'GENERATION COMPLETE: {generated_count} payments generated' + ) + ) + + if error_count > 0: + 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') + + if overdue_payments.exists(): + self.stdout.write('\n' + '='*50) + self.stdout.write( + self.style.WARNING( + 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)' + ) + if overdue_payments.count() > 10: + 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 new file mode 100644 index 0000000..c9b233b --- /dev/null +++ b/app/stiftung/management/commands/init_config.py @@ -0,0 +1,124 @@ +from django.core.management.base import BaseCommand +from stiftung.models import AppConfiguration + + +class Command(BaseCommand): + help = 'Initialize default app configuration settings' + + def handle(self, *args, **options): + # Paperless Integration Settings + paperless_settings = [ + { + 'key': 'paperless_api_url', + 'display_name': 'Paperless API URL', + 'description': 'The base URL for your Paperless-NGX API (e.g., http://paperless.example.com:8000)', + 'value': 'http://192.168.178.167:30070', + 'default_value': 'http://192.168.178.167:30070', + 'setting_type': 'url', + 'category': 'paperless', + 'order': 1 + }, + { + 'key': 'paperless_api_token', + 'display_name': 'Paperless API Token', + 'description': 'The authentication token for Paperless API access', + 'value': '', + 'default_value': '', + 'setting_type': 'text', + 'category': 'paperless', + 'order': 2 + }, + { + 'key': 'paperless_destinataere_tag', + 'display_name': 'Destinatäre Tag Name', + 'description': 'The tag name used to identify Destinatäre documents in Paperless', + 'value': 'Stiftung_Destinatäre', + 'default_value': 'Stiftung_Destinatäre', + 'setting_type': 'tag', + 'category': 'paperless', + 'order': 3 + }, + { + 'key': 'paperless_destinataere_tag_id', + 'display_name': 'Destinatäre Tag ID', + 'description': 'The numeric ID of the Destinatäre tag in Paperless', + 'value': '210', + 'default_value': '210', + 'setting_type': 'tag_id', + 'category': 'paperless', + 'order': 4 + }, + { + 'key': 'paperless_land_tag', + 'display_name': 'Land & Pächter Tag Name', + 'description': 'The tag name used to identify Land and Pächter documents in Paperless', + 'value': 'Stiftung_Land_und_Pächter', + 'default_value': 'Stiftung_Land_und_Pächter', + 'setting_type': 'tag', + 'category': 'paperless', + 'order': 5 + }, + { + 'key': 'paperless_land_tag_id', + 'display_name': 'Land & Pächter Tag ID', + 'description': 'The numeric ID of the Land & Pächter tag in Paperless', + 'value': '204', + 'default_value': '204', + 'setting_type': 'tag_id', + 'category': 'paperless', + 'order': 6 + }, + { + 'key': 'paperless_admin_tag', + 'display_name': 'Administration Tag Name', + 'description': 'The tag name used to identify Administration documents in Paperless', + 'value': 'Stiftung_Administration', + 'default_value': 'Stiftung_Administration', + 'setting_type': 'tag', + 'category': 'paperless', + 'order': 7 + }, + { + 'key': 'paperless_admin_tag_id', + 'display_name': 'Administration Tag ID', + 'description': 'The numeric ID of the Administration tag in Paperless', + 'value': '216', + 'default_value': '216', + 'setting_type': 'tag_id', + 'category': 'paperless', + 'order': 8 + } + ] + + created_count = 0 + updated_count = 0 + + for setting_data in paperless_settings: + setting, created = AppConfiguration.objects.get_or_create( + key=setting_data['key'], + defaults=setting_data + ) + + if created: + created_count += 1 + self.stdout.write( + 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.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.' + ) + ) + self.stdout.write( + self.style.WARNING( + 'You can now manage these settings in the Django Admin under "App Configurations"' + ) + ) diff --git a/app/stiftung/management/commands/init_corporate_settings.py b/app/stiftung/management/commands/init_corporate_settings.py new file mode 100644 index 0000000..e32d60c --- /dev/null +++ b/app/stiftung/management/commands/init_corporate_settings.py @@ -0,0 +1,149 @@ +""" +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' + + 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_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_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_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_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_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 + }, + ] + + created_count = 0 + updated_count = 0 + + for setting_data in corporate_settings: + setting, created = AppConfiguration.objects.get_or_create( + key=setting_data['key'], + defaults=setting_data + ) + + if created: + created_count += 1 + self.stdout.write( + 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.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.' + ) + ) + + if created_count > 0: + self.stdout.write( + self.style.WARNING( + 'Please configure your corporate identity settings in ' + 'Administration -> Application Settings before generating PDFs.' + ) + ) diff --git a/app/stiftung/management/commands/sync_abrechnungen.py b/app/stiftung/management/commands/sync_abrechnungen.py new file mode 100644 index 0000000..8af3c9f --- /dev/null +++ b/app/stiftung/management/commands/sync_abrechnungen.py @@ -0,0 +1,253 @@ +#!/usr/bin/env python3 +""" +Management command to synchronize existing Verpachtungen with LandAbrechnungen. + +This command will: +1. Find all existing Verpachtungen (both legacy and new LandVerpachtung) +2. Calculate the financial impact for each year they're active +3. Update or create corresponding LandAbrechnung records +4. Provide a summary of changes made + +Usage: + python manage.py sync_abrechnungen [--dry-run] [--year YEAR] +""" + +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 + + +class Command(BaseCommand): + 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', + ) + parser.add_argument( + '--year', + type=int, + help='Only sync data for specific year', + ) + parser.add_argument( + '--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'] + + self.stdout.write( + self.style.SUCCESS('🔄 Starting Abrechnung synchronization...') + ) + + if dry_run: + 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(), + } + + try: + with transaction.atomic(): + # Process Legacy Verpachtungen + self.stdout.write('\n📄 Processing Legacy Verpachtungen...') + legacy_verpachtungen = Verpachtung.objects.all() + + for verpachtung in legacy_verpachtungen: + stats['legacy_contracts'] += 1 + years_affected = self._get_affected_years( + verpachtung.pachtbeginn, + verpachtung.verlaengerung or verpachtung.pachtende, + target_year + ) + + for year in years_affected: + 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 + f"Legacy-Verpachtung {verpachtung.vertragsnummer}", + force + ) + if created: + stats['abrechnungen_created'] += 1 + if updated: + 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...') + land_verpachtungen = LandVerpachtung.objects.all() + + for verpachtung in land_verpachtungen: + stats['new_contracts'] += 1 + years_affected = self._get_affected_years( + 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 + + if not dry_run: + created, updated = self._update_abrechnung( + verpachtung.land, + year, + rent_amount, + umlage_amount, + f"LandVerpachtung {verpachtung.vertragsnummer}", + force + ) + if created: + stats['abrechnungen_created'] += 1 + if updated: + 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)}') + ) + 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(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"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')) + else: + 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') + + year_start = date(year, 1, 1) + year_end = date(year, 12, 31) + + 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') + + 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') + + 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') + + 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): + """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}' + } + ) + + 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}' + else: + abrechnung.bemerkungen = sync_note + + abrechnung.save() + updated = True + + return created, updated diff --git a/app/stiftung/migrations/0023_remove_legacy_verpachtung.py b/app/stiftung/migrations/0023_remove_legacy_verpachtung.py new file mode 100644 index 0000000..e99e416 --- /dev/null +++ b/app/stiftung/migrations/0023_remove_legacy_verpachtung.py @@ -0,0 +1,16 @@ +# Generated by Django 5.0.6 on 2025-08-31 20:25 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('stiftung', '0022_dokumentlink_land_verpachtung_id_and_more'), + ] + + operations = [ + migrations.DeleteModel( + name='Verpachtung', + ), + ] diff --git a/app/stiftung/migrations/0024_dokumentlink_abrechnung_id.py b/app/stiftung/migrations/0024_dokumentlink_abrechnung_id.py new file mode 100644 index 0000000..7dfb952 --- /dev/null +++ b/app/stiftung/migrations/0024_dokumentlink_abrechnung_id.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.6 on 2025-08-31 21:22 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('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'), + ), + ] diff --git a/app/stiftung/migrations/0025_appconfiguration.py b/app/stiftung/migrations/0025_appconfiguration.py new file mode 100644 index 0000000..fc33980 --- /dev/null +++ b/app/stiftung/migrations/0025_appconfiguration.py @@ -0,0 +1,37 @@ +# 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'), + ] + + operations = [ + migrations.CreateModel( + 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)), + ], + options={ + '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 new file mode 100644 index 0000000..c043555 --- /dev/null +++ b/app/stiftung/migrations/0026_enhance_unterstuetzung_model.py @@ -0,0 +1,89 @@ +# Generated by Django 5.0.6 on 2025-09-01 20:03 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('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'), + ), + 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'), + ), + migrations.AddField( + 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'), + ), + migrations.AddField( + 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'), + ), + migrations.CreateModel( + 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')), + ], + options={ + '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'), + ), + migrations.AddIndex( + 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'), + ), + migrations.AddIndex( + 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 new file mode 100644 index 0000000..6c55aca --- /dev/null +++ b/app/stiftung/migrations/0027_helpbox_alter_appconfiguration_category.py @@ -0,0 +1,38 @@ +# 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'), + ] + + operations = [ + migrations.CreateModel( + 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')), + ], + options={ + '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'), + ), + ] diff --git a/app/stiftung/migrations/0028_alter_helpbox_page_key.py b/app/stiftung/migrations/0028_alter_helpbox_page_key.py new file mode 100644 index 0000000..4b3885f --- /dev/null +++ b/app/stiftung/migrations/0028_alter_helpbox_page_key.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.6 on 2025-09-02 20:53 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('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'), + ), + ] diff --git a/app/stiftung/models.py b/app/stiftung/models.py index a8671c4..3e4adcf 100644 --- a/app/stiftung/models.py +++ b/app/stiftung/models.py @@ -4,6 +4,8 @@ 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 stiftung.utils.date_utils import ensure_date, get_year_from_date class CSVImport(models.Model): """Track CSV import operations for audit purposes""" @@ -129,18 +131,18 @@ class Paechter(models.Model): def get_aktive_verpachtungen(self): """Get all active leases for this tenant""" - return self.verpachtung_set.filter(status='aktiv') + return self.neue_verpachtungen.filter(status='aktiv') def get_gesamt_pachtflaeche(self): """Calculate total leased area""" - return self.verpachtung_set.filter(status='aktiv').aggregate( + 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.verpachtung_set.filter(status='aktiv').aggregate( - total=models.Sum('pachtzins_jaehrlich') + return self.neue_verpachtungen.filter(status='aktiv').aggregate( + total=models.Sum('pachtzins_pauschal') )['total'] or 0 class Destinataer(models.Model): @@ -505,12 +507,8 @@ class Land(models.Model): if self.verp_flaeche_aktuell and self.verp_flaeche_aktuell > 0: return self.verp_flaeche_aktuell - # Fallback: Legacy Verpachtungen (für Rückwärtskompatibilität) - legacy_total = self.verpachtung_set.filter(status='aktiv').aggregate( - total=Sum('verpachtete_flaeche') - )['total'] or 0 - - return legacy_total + # 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""" @@ -690,17 +688,29 @@ class LandVerpachtung(models.Model): def is_aktiv(self): """Prüft ob der Vertrag noch aktiv ist""" from datetime import date + + heute = date.today() - if self.pachtende: - return self.pachtbeginn <= heute <= self.pachtende - return self.pachtbeginn <= heute # Unbefristet + 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() - if self.pachtende and self.pachtende > heute: - return (self.pachtende - heute).days + pachtende_date = ensure_date(self.pachtende) + + if pachtende_date and pachtende_date > heute: + return (pachtende_date - heute).days return None # Unbefristet @property @@ -708,10 +718,199 @@ class LandVerpachtung(models.Model): """Berechnet die USt auf Pacht (falls optiert)""" from decimal import Decimal, ROUND_HALF_UP if self.ust_option and self.pachtzins_pauschal: - ust = self.pachtzins_pauschal * (self.ust_satz / Decimal('100')) - return ust.quantize(Decimal('0.01'), rounding=ROUND_HALF_UP) + 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 + + # 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}' + } + ) + + # 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 + abrechnung.pacht_vereinnahmt += rent_for_year + abrechnung.umlagen_vereinnahmt += umlage_for_year + 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') + + 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 abrechnung.bemerkungen: + 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.save() + + def _calculate_rent_for_year(self, year): + """Calculate rent amount for specific year""" + from decimal import Decimal + from datetime import date + 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: + return None + 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') + + # 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') + + 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 + + # 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') + + 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) + + 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}" + else: + 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""" @@ -877,99 +1076,6 @@ class LandAbrechnung(models.Model): return ust.quantize(Decimal('0.01'), rounding=ROUND_HALF_UP) return Decimal('0.00') - -class Verpachtung(models.Model): - """Verpachtungsverträge für Ländereien""" - - STATUS_CHOICES = [ - ('aktiv', 'Aktiv'), - ('beendet', 'Beendet'), - ('gekuendigt', 'Gekündigt'), - ('verlängert', 'Verlängert'), - ] - - id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - - # Verpachtete Ländereien - land = models.ForeignKey(Land, on_delete=models.CASCADE, verbose_name="Land") - paechter = models.ForeignKey(Paechter, on_delete=models.CASCADE, verbose_name="Pächter") - - # Vertragsdaten - 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(null=True, blank=True, verbose_name="Verlängerung bis") - - # Finanzielle Bedingungen - pachtzins_pro_qm = models.DecimalField( - max_digits=8, - decimal_places=4, - verbose_name="Pachtzins pro qm (€)" - ) - pachtzins_jaehrlich = models.DecimalField( - max_digits=12, - decimal_places=2, - verbose_name="Jährlicher Pachtzins (€)" - ) - - # Flächenangaben - verpachtete_flaeche = models.DecimalField( - max_digits=12, - decimal_places=2, - verbose_name="Verpachtete Fläche (qm)" - ) - - # Status - status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='aktiv') - - # Dokumentation - verwendungsnachweis = models.ForeignKey('DokumentLink', null=True, blank=True, on_delete=models.SET_NULL, verbose_name="Verwendungsnachweis") - bemerkungen = 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) - - class Meta: - verbose_name = "Verpachtung" - verbose_name_plural = "Verpachtungen" - ordering = ['-pachtbeginn'] - - def __str__(self): - return f"{self.land} - {self.paechter} ({self.vertragsnummer})" - - def get_vertragsdauer_tage(self): - """Berechnet die Vertragsdauer in Tagen""" - from datetime import date - if self.pachtende: - return (self.pachtende - self.pachtbeginn).days - return 0 - - def get_restlaufzeit_tage(self): - """Berechnet die Restlaufzeit in Tagen""" - from datetime import date - heute = date.today() - if self.pachtende and self.pachtende > heute: - return (self.pachtende - heute).days - return 0 - - def is_aktiv(self): - """Prüft ob der Vertrag noch aktiv ist""" - from datetime import date - heute = date.today() - return self.pachtbeginn <= heute <= self.pachtende - - @property - def verpachtete_flaeche_hektar(self): - """Berechnet die verpachtete Fläche in Hektar""" - from decimal import Decimal, ROUND_HALF_UP - if self.verpachtete_flaeche and self.verpachtete_flaeche > 0: - # Umrechnung: 1 Hektar = 10.000 qm - hektar = Decimal(str(self.verpachtete_flaeche)) / Decimal('10000') - # Runden auf 2 Nachkommastellen - return hektar.quantize(Decimal('0.01'), rounding=ROUND_HALF_UP) - return Decimal('0.00') - class DokumentLink(models.Model): KONTEXT_CHOICES = [ ('pachtvertrag', 'Pachtvertrag'), @@ -997,6 +1103,7 @@ class DokumentLink(models.Model): 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") class Meta: verbose_name = "Dokument" @@ -1007,17 +1114,14 @@ class DokumentLink(models.Model): return f"{self.titel} ({self.get_kontext_display()})" def get_paperless_url(self): - """Gibt die URL zum Dokument in Paperless zurück""" - from django.conf import settings - if settings.PAPERLESS_API_URL: - return f"{settings.PAPERLESS_API_URL}/documents/{self.paperless_document_id}/" - return None + """Gibt die URL zum Dokument in Paperless zurück (über Django Redirect)""" + return f"/api/paperless/documents/{self.paperless_document_id}/" def get_paperless_thumbnail_url(self): """Gibt die URL zum Thumbnail in Paperless zurück""" from django.conf import settings if settings.PAPERLESS_API_URL: - return f"{settings.PAPERLESS_API_URL}/api/documents/{self.paperless_document_id}/thumb/" + return f"{settings.PAPERLESS_API_URL}/api/paperless/documents/{self.paperless_document_id}/thumb/" return None def get_verpachtung(self): @@ -1135,6 +1239,7 @@ 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'), @@ -1147,6 +1252,17 @@ class DestinataerUnterstuetzung(models.Model): 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') + + # 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') + erstellt_am = models.DateTimeField(auto_now_add=True) aktualisiert_am = models.DateTimeField(auto_now=True) @@ -1157,10 +1273,106 @@ class DestinataerUnterstuetzung(models.Model): indexes = [ 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'] + + def can_be_marked_paid(self): + """Check if payment can be marked as paid""" + 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'), + ] + + 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') + + # 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') + + # 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') + 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') + + class Meta: + 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']), + ] + + 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: + 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", + empfaenger_iban=self.empfaenger_iban, + empfaenger_name=self.empfaenger_name, + verwendungszweck=self.verwendungszweck, + wiederkehrend_von=self, + 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) + + self.save() + return neue_zahlung class DestinataerNotiz(models.Model): @@ -1665,3 +1877,153 @@ class BackupJob(models.Model): return f"{size:.1f} {unit}" size /= 1024 return f"{size:.1f} TB" + + +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'), + ] + + CATEGORY_CHOICES = [ + ('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") + 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") + + # 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'] + + 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': + try: + 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""" + try: + setting = cls.objects.get(key=key, is_active=True) + 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'): + """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, + } + ) + if not created: + setting.value = str(value) + setting.save() + return setting + + +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'), + ] + + 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" + ) + content = models.TextField( + verbose_name="Inhalt (Markdown unterstützt)", + help_text="Sie können Markdown verwenden: **fett**, *kursiv*, `code`, [Link](url), etc." + ) + 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") + + class Meta: + verbose_name = "Hilfs-Infobox" + verbose_name_plural = "Hilfs-Infoboxen" + 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""" + try: + return cls.objects.get(page_key=page_key, is_active=True) + except cls.DoesNotExist: + return None diff --git a/app/stiftung/templatetags/__init__.py b/app/stiftung/templatetags/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/stiftung/templatetags/help_tags.py b/app/stiftung/templatetags/help_tags.py new file mode 100644 index 0000000..12f852d --- /dev/null +++ b/app/stiftung/templatetags/help_tags.py @@ -0,0 +1,29 @@ +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') +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 + } + + if help_obj: + # Konvertiere Markdown zu HTML + 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""" + return HelpBox.get_help_for_page(page_key) is not None diff --git a/app/stiftung/templatetags/pdf_tags.py b/app/stiftung/templatetags/pdf_tags.py new file mode 100644 index 0000000..613972b --- /dev/null +++ b/app/stiftung/templatetags/pdf_tags.py @@ -0,0 +1,145 @@ +""" +PDF-specific template tags and filters +""" +from django import template +from django.utils.safestring import mark_safe + +register = template.Library() + + +@register.filter +def lookup(obj, field_name): + """ + Template filter to dynamically access object attributes or dict keys + Usage: {{ object|lookup:"field_name" }} + """ + if obj is None: + return None + + # Handle dict-like objects + 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) + # If it's a callable (method), call it + if callable(attr): + try: + return attr() + except TypeError: + # 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('.') + current = obj + for part in parts: + if current is None: + return None + current = lookup(current, part) + return current + + return None + + +@register.filter +def get_display_value(obj, field_name): + """ + Get the display value for a field, including choices + 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' + if hasattr(obj, display_method): + display_value = getattr(obj, display_method)() + if display_value: + return display_value + + return value + + +@register.filter +def format_currency(value): + """ + Format a number as currency + Usage: {{ value|format_currency }} + """ + if value is None: + return '-' + try: + return f"€{float(value):,.2f}".replace(',', ' ') + except (ValueError, TypeError): + return str(value) + + +@register.filter +def format_status_badge(status): + """ + Format status as HTML badge + Usage: {{ status|format_status_badge }} + """ + if not status: + return '-' + + status_lower = str(status).lower() + css_class = f'status-{status_lower}' + + return mark_safe(f'{status}') + + +@register.filter +def truncate_field(value, max_length=50): + """ + Truncate field value for display + Usage: {{ value|truncate_field:30 }} + """ + if value is None: + return '-' + + str_value = str(value) + if len(str_value) <= max_length: + return str_value + + return str_value[:max_length-3] + '...' + + +@register.simple_tag +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') + + value = lookup(obj, field_name) + + if value is None: + return '-' + + if field_type == 'currency': + return format_currency(value) + elif field_type == 'date': + try: + return value.strftime('%d.%m.%Y') + except (AttributeError, ValueError): + return str(value) + elif field_type == 'datetime': + try: + return value.strftime('%d.%m.%Y %H:%M') + except (AttributeError, ValueError): + return str(value) + elif field_type == 'status': + return format_status_badge(value) + 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 af9e0c8..b51a0e1 100644 --- a/app/stiftung/urls.py +++ b/app/stiftung/urls.py @@ -53,13 +53,10 @@ urlpatterns = [ path('laendereien//verpachtung/bearbeiten/', views.land_verpachtung_edit, name='land_verpachtung_edit'), path('laendereien//verpachtung/beenden/', views.land_verpachtung_end, name='land_verpachtung_end'), - # Verpachtung URLs - path('verpachtungen/', views.verpachtung_list, name='verpachtung_list'), - path('verpachtungen//', views.verpachtung_detail, name='verpachtung_detail'), - path('verpachtungen/neu/', views.verpachtung_create, name='verpachtung_create'), - path('verpachtungen//bearbeiten/', views.verpachtung_update, name='verpachtung_update'), - path('verpachtungen//loeschen/', views.verpachtung_delete, name='verpachtung_delete'), - path('verpachtungen//export/', views.verpachtung_export, name='verpachtung_export'), + # 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'), # Förderung URLs path('foerderungen/', views.foerderung_list, name='foerderung_list'), @@ -112,6 +109,7 @@ urlpatterns = [ # 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'), @@ -120,6 +118,16 @@ urlpatterns = [ 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'), + + # AJAX endpoints + 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'), @@ -133,6 +141,10 @@ urlpatterns = [ 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'), + # API URLs path('api/land-stats/', views.land_stats_api, name='land_stats_api'), path('api/health/', views.health_check, name='health_check'), diff --git a/app/stiftung/utils/config.py b/app/stiftung/utils/config.py new file mode 100644 index 0000000..d6c612d --- /dev/null +++ b/app/stiftung/utils/config.py @@ -0,0 +1,73 @@ +""" +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'), + } + + +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 + """ + return AppConfiguration.set_setting(key, value, **kwargs) + + +def is_paperless_configured(): + """ + Check if Paperless is properly configured + + Returns: + bool: True if API URL and token are configured + """ + config = get_paperless_config() + return bool(config['api_url'] and config['api_token']) diff --git a/app/stiftung/utils/date_utils.py b/app/stiftung/utils/date_utils.py new file mode 100644 index 0000000..bf12db1 --- /dev/null +++ b/app/stiftung/utils/date_utils.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from datetime import date as _date +from typing import Optional, Union + +try: + from django.utils.dateparse import parse_date as _parse_date +except Exception: # pragma: no cover - django not loaded in some tools + _parse_date = None # type: ignore + +DateLike = Union[_date, str, None] + + +def ensure_date(value: DateLike) -> Optional[_date]: + """Return a date from a date or ISO string; None stays None. + + - Accepts datetime.date, 'YYYY-MM-DD' string, or None. + - Returns None if parsing fails or value falsy. + """ + if not value: + return None + if isinstance(value, _date): + return value + if isinstance(value, str): + if _parse_date is None: + return None + return _parse_date(value) + return None + + +def get_year_from_date(value: DateLike) -> Optional[int]: + """Extract year from date or ISO string, else None.""" + d = ensure_date(value) + return d.year if d else None diff --git a/app/stiftung/utils/pdf_generator.py b/app/stiftung/utils/pdf_generator.py new file mode 100644 index 0000000..fe19e38 --- /dev/null +++ b/app/stiftung/utils/pdf_generator.py @@ -0,0 +1,420 @@ +""" +PDF generation utilities with corporate identity support +""" +import os +import base64 +from io import BytesIO +from django.conf import settings +from django.template.loader import render_to_string +from django.http import HttpResponse +from django.utils import timezone + +# Try to import WeasyPrint, fall back gracefully if not available +try: + from weasyprint import HTML, CSS + from weasyprint.text.fonts import FontConfiguration + WEASYPRINT_AVAILABLE = True + IMPORT_ERROR = None +except ImportError as e: + # WeasyPrint dependencies not available + HTML = None + CSS = None + FontConfiguration = None + WEASYPRINT_AVAILABLE = False + IMPORT_ERROR = str(e) +except OSError as e: + # System dependencies missing (like pango) + HTML = None + CSS = None + FontConfiguration = None + WEASYPRINT_AVAILABLE = False + IMPORT_ERROR = str(e) + +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.'), + } + + 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), + ] + + 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') + # 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' + else: + 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') + + return f""" + @page {{ + size: A4; + margin: 2cm 1.5cm 2cm 1.5cm; + @bottom-center {{ + content: "Seite " counter(page) " von " counter(pages); + font-size: 10pt; + color: #666; + }} + }} + + body {{ + font-family: 'Segoe UI', 'DejaVu Sans', Arial, sans-serif; + font-size: 10pt; + line-height: 1.4; + color: #333; + margin: 0; + padding: 0; + }} + + .header {{ + border-bottom: 2px solid {primary_color}; + padding-bottom: 15px; + margin-bottom: 25px; + page-break-inside: avoid; + }} + + .header-content {{ + display: flex; + justify-content: space-between; + align-items: flex-start; + }} + + .header-left {{ + flex: 1; + }} + + .header-right {{ + text-align: right; + flex-shrink: 0; + margin-left: 20px; + }} + + .logo {{ + max-height: 60px; + max-width: 150px; + margin-bottom: 10px; + }} + + .stiftung-name {{ + font-size: 20pt; + font-weight: bold; + color: {primary_color}; + margin: 0; + line-height: 1.2; + }} + + .document-title {{ + font-size: 16pt; + color: {secondary_color}; + margin: 5px 0 0 0; + }} + + .header-info {{ + font-size: 9pt; + color: #666; + margin-top: 10px; + }} + + .contact-info {{ + font-size: 8pt; + color: #666; + line-height: 1.3; + }} + + h1, h2, h3 {{ + color: {primary_color}; + page-break-inside: avoid; + page-break-after: avoid; + }} + + h1 {{ + font-size: 14pt; + margin: 20px 0 15px 0; + border-bottom: 1px solid {secondary_color}; + padding-bottom: 5px; + }} + + h2 {{ + font-size: 12pt; + margin: 15px 0 10px 0; + }} + + h3 {{ + font-size: 11pt; + margin: 10px 0 8px 0; + }} + + table {{ + width: 100%; + border-collapse: collapse; + margin: 10px 0; + page-break-inside: avoid; + }} + + th, td {{ + border: 1px solid #ddd; + padding: 6px 8px; + text-align: left; + vertical-align: top; + }} + + th {{ + background-color: #f8f9fa; + font-weight: 600; + color: {primary_color}; + }} + + tr:nth-child(even) {{ + background-color: #f9f9f9; + }} + + .amount {{ + text-align: right; + font-family: 'Courier New', monospace; + font-weight: 500; + }} + + .status-badge {{ + padding: 2px 6px; + border-radius: 3px; + font-size: 8pt; + font-weight: 500; + }} + + .status-beantragt {{ background-color: #fff3cd; color: #856404; }} + .status-genehmigt {{ background-color: #d1ecf1; color: #0c5460; }} + .status-ausgezahlt {{ background-color: #d4edda; color: #155724; }} + .status-abgelehnt {{ background-color: #f8d7da; color: #721c24; }} + .status-storniert {{ background-color: #e2e3e5; color: #383d41; }} + + .footer {{ + margin-top: 30px; + padding-top: 15px; + border-top: 1px solid #ddd; + font-size: 8pt; + color: #666; + text-align: center; + }} + + .stats-grid {{ + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 15px; + margin: 15px 0; + }} + + .stat-card {{ + border: 1px solid #ddd; + border-radius: 4px; + padding: 12px; + text-align: center; + background: #f8f9fa; + }} + + .stat-value {{ + font-size: 14pt; + font-weight: bold; + color: {primary_color}; + }} + + .stat-label {{ + font-size: 8pt; + color: #666; + margin-top: 3px; + }} + + .section {{ + margin-bottom: 20px; + page-break-inside: avoid; + }} + + .no-page-break {{ + page-break-inside: avoid; + }} + + .page-break-before {{ + 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: + # Return HTML fallback if WeasyPrint is not available + error_html = f""" + + + + PDF Export - {filename} + + + +
+

⚠️ PDF Generation Not Available

+

WeasyPrint dependencies are missing: {IMPORT_ERROR}

+

Showing content as HTML preview instead. You can print this page to PDF using your browser.

+
+
+ {html_content} +
+ + + """ + 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) + + # Create response + 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""" + + + + PDF Generation Error + + + +

PDF Generation Error

+
+

An error occurred while generating the PDF:

+

{str(e)}

+

Showing content as HTML preview instead.

+
+
+

Original Content

+ {html_content} +
+ + + """ + + 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): + """ + 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 + title: Document title + filename_prefix: Prefix for the generated filename + 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', '')) + + # 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(), + } + + # Render HTML + 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') + filename = f"{filename_prefix}_{timestamp}.pdf" + + return self.generate_pdf_response(html_content, filename, css_content) + + +# Global instance +pdf_generator = PDFGenerator() diff --git a/app/stiftung/views.py b/app/stiftung/views.py index 96d7ca0..ff877d5 100644 --- a/app/stiftung/views.py +++ b/app/stiftung/views.py @@ -17,9 +17,39 @@ from django.utils import timezone from rest_framework.response import Response from rest_framework.decorators import api_view from django.conf import settings -from .models import Person, Paechter, Destinataer, DokumentLink, Foerderung, Land, Verpachtung, CSVImport, LandAbrechnung, LandVerpachtung +from .models import Person, Paechter, Destinataer, DokumentLink, Foerderung, Land, CSVImport, LandAbrechnung, LandVerpachtung, AppConfiguration, DestinataerUnterstuetzung, UnterstuetzungWiederkehrend import json +# Lazy import for PDF generator to avoid startup errors +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: + # 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""" + + + PDF Not Available + +

PDF Export Not Available

+

PDF generation requires additional system dependencies that are not installed.

+

Error: {str(e)}

+

Please install WeasyPrint dependencies or use CSV export instead.

+ + + """ + 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 = ''): @@ -110,7 +140,7 @@ def gramps_debug_api(_request): 'has_password': bool(getattr(settings, 'GRAMPS_PASSWORD', '')), }) -from .forms import PersonForm, PaechterForm, DestinataerForm, DokumentLinkForm, FoerderungForm, LandForm, VerpachtungForm, DestinataerUnterstuetzungForm, DestinataerNotizForm +from .forms import PersonForm, PaechterForm, DestinataerForm, DokumentLinkForm, FoerderungForm, UnterstuetzungForm, UnterstuetzungMarkAsPaidForm, LandForm, DestinataerUnterstuetzungForm, DestinataerNotizForm from stiftung.models import DestinataerUnterstuetzung, DestinataerNotiz def home(request): @@ -130,7 +160,10 @@ def dokument_management(request): @api_view(['GET']) def paperless_document_redirect(_request, doc_id: int): """Redirects to the Paperless UI document URL and supports thumbnails if needed later.""" - url = getattr(settings, "PAPERLESS_API_URL", None) + from stiftung.utils.config import get_paperless_config + + config = get_paperless_config() + url = config['api_url'] if not url: return Response({'error': 'Paperless API not configured'}, status=400) base_url = url.rstrip('/api') if url.endswith('/api') else url @@ -747,7 +780,8 @@ def person_list(request): def person_detail(request, pk): person = get_object_or_404(Person, pk=pk) foerderungen = person.foerderung_set.all().order_by('-jahr', '-betrag') - verpachtungen = person.verpachtung_set.all().order_by('-pachtbeginn') + # Get new LandVerpachtungen for this person's Paechter instances + verpachtungen = LandVerpachtung.objects.filter(paechter__person=person).order_by('-pachtbeginn') context = { 'person': person, @@ -884,7 +918,8 @@ def destinataer_detail(request, pk): # Förderungen für diesen Destinatär laden foerderungen = Foerderung.objects.filter(destinataer=destinataer).order_by('-jahr', '-betrag') - # No inline form anymore + # Unterstützungen für diesen Destinatär laden + unterstuetzungen = DestinataerUnterstuetzung.objects.filter(destinataer=destinataer).order_by('-faellig_am') # Notizen laden notizen_eintraege = DestinataerNotiz.objects.filter(destinataer=destinataer).order_by('-erstellt_am') @@ -893,6 +928,7 @@ def destinataer_detail(request, pk): 'destinataer': destinataer, 'verknuepfte_dokumente': verknuepfte_dokumente, 'foerderungen': foerderungen, + 'unterstuetzungen': unterstuetzungen, 'notizen_eintraege': notizen_eintraege, } return render(request, 'stiftung/destinataer_detail.html', context) @@ -1004,15 +1040,14 @@ def paechter_list(request): # Annotate with total leased area and rent (coalesce nulls to Decimal for stable sorting) paechter = paechter.annotate( gesamt_flaeche=Coalesce( - Sum('verpachtung__verpachtete_flaeche'), + 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('verpachtung__pachtzins_jaehrlich'), + 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), - ), + output_field=DecimalField(max_digits=12, decimal_places=2)), ) # Sorting @@ -1056,8 +1091,8 @@ def paechter_detail(request, pk): paechter_id=paechter.pk ).order_by('kontext', 'titel') - # Legacy Verpachtungen für diesen Pächter laden - verpachtungen = Verpachtung.objects.filter(paechter=paechter).order_by('-pachtbeginn') + # Neue LandVerpachtungen für diesen Pächter laden + 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') @@ -1069,7 +1104,7 @@ def paechter_detail(request, pk): context = { 'paechter': paechter, 'verknuepfte_dokumente': verknuepfte_dokumente, - 'verpachtungen': verpachtungen, # Legacy + 'verpachtungen': verpachtungen, # Now using LandVerpachtung 'gepachtete_laendereien': gepachtete_laendereien, # Neu 'total_flaeche_neu': total_flaeche_neu, 'total_pachtzins_neu': total_pachtzins_neu, @@ -1260,16 +1295,13 @@ def land_detail(request, pk): land_id=land.pk ).order_by('kontext', 'titel') - # Legacy Verpachtungen für diese Länderei laden - verpachtungen = Verpachtung.objects.filter(land=land).order_by('-pachtbeginn') - # Neue LandVerpachtungen laden (mit related data) neue_verpachtungen = land.neue_verpachtungen.select_related('paechter').order_by('-pachtbeginn') context = { 'land': land, 'verknuepfte_dokumente': verknuepfte_dokumente, - 'verpachtungen': verpachtungen, + 'verpachtungen': neue_verpachtungen, # Using only new system now 'neue_verpachtungen': neue_verpachtungen, } return render(request, 'stiftung/land_detail.html', context) @@ -1323,7 +1355,7 @@ def verpachtung_list(request): sort = request.GET.get('sort', '') direction = request.GET.get('dir', 'asc') - verpachtungen = Verpachtung.objects.select_related('land', 'paechter').all() + verpachtungen = LandVerpachtung.objects.select_related('land', 'paechter').all() if search_query: verpachtungen = verpachtungen.filter( @@ -1364,7 +1396,7 @@ def verpachtung_list(request): # Calculate statistics for the summary cards # Get ALL verpachtungen (not filtered) for accurate statistics - all_verpachtungen = Verpachtung.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() @@ -1406,71 +1438,89 @@ def verpachtung_list(request): return render(request, 'stiftung/verpachtung_list.html', context) @login_required -def verpachtung_detail(request, pk): - verpachtung = get_object_or_404(Verpachtung, pk=pk) +@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( - verpachtung_id=verpachtung.pk + land_verpachtung_id=verpachtung.pk ).order_by('kontext', 'titel') context = { 'verpachtung': verpachtung, + 'landverpachtung': verpachtung, # Template expects this variable name 'verknuepfte_dokumente': verknuepfte_dokumente, } - return render(request, 'stiftung/verpachtung_detail.html', context) + return render(request, 'stiftung/land_verpachtung_detail.html', context) @login_required -def verpachtung_create(request): - if request.method == 'POST': - form = VerpachtungForm(request.POST) - if form.is_valid(): - verpachtung = form.save() - messages.success(request, f'Verpachtung "{verpachtung}" wurde erfolgreich erstellt.') - return redirect('stiftung:verpachtung_detail', pk=verpachtung.pk) - else: - form = VerpachtungForm() +def land_verpachtung_update(request, pk): + """Update an existing LandVerpachtung by its primary key""" + verpachtung = get_object_or_404(LandVerpachtung, pk=pk) - context = {'form': form, 'title': 'Neue Verpachtung erstellen'} - return render(request, 'stiftung/verpachtung_form.html', context) + 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') + + if vertragsnummer: + verpachtung.vertragsnummer = vertragsnummer + if pachtbeginn: + verpachtung.pachtbeginn = pachtbeginn + if pachtende: + 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 -def verpachtung_update(request, pk): - verpachtung = get_object_or_404(Verpachtung, pk=pk) - if request.method == 'POST': - form = VerpachtungForm(request.POST, instance=verpachtung) - if form.is_valid(): - verpachtung = form.save() - messages.success(request, f'Verpachtung "{verpachtung}" wurde erfolgreich aktualisiert.') - return redirect('stiftung:verpachtung_list') - else: - form = VerpachtungForm(instance=verpachtung) +@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) - context = {'form': form, 'verpachtung': verpachtung, 'title': f'Verpachtung bearbeiten: {verpachtung}'} - return render(request, 'stiftung/verpachtung_form.html', context) - -@login_required -def verpachtung_delete(request, pk): - verpachtung = get_object_or_404(Verpachtung, pk=pk) if request.method == 'POST': - verpachtung.delete() - messages.success(request, f'Verpachtung "{verpachtung}" wurde erfolgreich gelöscht.') - return redirect('stiftung:verpachtung_list') + 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) - context = {'verpachtung': verpachtung} - return render(request, 'stiftung/verpachtung_confirm_delete.html', context) + context = { + 'verpachtung': verpachtung, + } + 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('person', '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 [] # Filtering jahr = request.GET.get('jahr') kategorie = request.GET.get('kategorie') status = request.GET.get('status') - person = request.GET.get('person') + destinataer = request.GET.get('destinataer') if jahr: foerderungen = foerderungen.filter(jahr=int(jahr)) @@ -1478,8 +1528,14 @@ def foerderung_list(request): foerderungen = foerderungen.filter(kategorie=kategorie) if status: foerderungen = foerderungen.filter(status=status) - if person: - foerderungen = foerderungen.filter(person__nachname__icontains=person) + if destinataer: + foerderungen = foerderungen.filter(destinataer__nachname__icontains=destinataer) + + # Handle exports + if export_format == 'csv': + return export_foerderungen_csv(request, foerderungen, selected_ids) + elif export_format == 'pdf': + return export_foerderungen_pdf(request, foerderungen, selected_ids) # Pagination paginator = Paginator(foerderungen, 25) @@ -1497,6 +1553,7 @@ def foerderung_list(request): context = { 'page_obj': page_obj, + 'foerderungen': foerderungen, # Add for counting 'total_betrag': total_betrag, 'avg_betrag': avg_betrag, 'kategorien': Foerderung.KATEGORIE_CHOICES, @@ -1504,7 +1561,7 @@ def foerderung_list(request): 'filter_jahr': jahr, 'filter_kategorie': kategorie, 'filter_status': status, - 'filter_person': person, + 'filter_person': destinataer, 'jahre': jahre, } return render(request, 'stiftung/foerderung_list.html', context) @@ -1529,14 +1586,20 @@ def foerderung_detail(request, pk): @login_required def foerderung_create(request): """Create a new funding grant""" + # Get destinataer from URL parameter if provided + destinataer_id = request.GET.get('destinataer') + initial = {} + if destinataer_id: + 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.person} wurde erfolgreich erstellt.') + messages.success(request, f'Förderung für {foerderung.destinataer} wurde erfolgreich erstellt.') return redirect('stiftung:foerderung_detail', pk=foerderung.pk) else: - form = FoerderungForm() + form = FoerderungForm(initial=initial) context = { 'form': form, @@ -1571,8 +1634,13 @@ def foerderung_delete(request, pk): foerderung = get_object_or_404(Foerderung, pk=pk) 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" + ) + foerderung.delete() - messages.success(request, f'Förderung für {foerderung.person} wurde erfolgreich gelöscht.') + messages.success(request, f'Förderung für {recipient_name} wurde erfolgreich gelöscht.') return redirect('stiftung:foerderung_list') context = { @@ -1589,11 +1657,12 @@ def dokument_list(request): dokumente = DokumentLink.objects.all().order_by('-id') # Paperless-API-Konfiguration für verfügbare Dokumente - from django.conf import settings + from stiftung.utils.config import get_paperless_config import requests - url = getattr(settings, "PAPERLESS_API_URL", None) - token = getattr(settings, "PAPERLESS_API_TOKEN", None) + config = get_paperless_config() + url = config['api_url'] + token = config['api_token'] available_dokumente = [] if url and token: @@ -1634,7 +1703,7 @@ def dokument_list(request): elif isinstance(doc_tags, str): tags = [tag.strip() for tag in doc_tags.split(',')] - if any(tag in ['Stiftung_Destinatäre', 'Stiftung_Land_und_Pächter', 'Stiftung_Administration'] for tag in tags): + if any(tag in [config['destinataere_tag'], config['land_tag'], config['admin_tag']] for tag in tags): bereits_verknuepft = DokumentLink.objects.filter( paperless_document_id=doc['id'] ).exists() @@ -1765,20 +1834,18 @@ def bericht_list(request): # Get available years from data jahre = sorted(set( list(Foerderung.objects.values_list('jahr', flat=True)) + - list(Verpachtung.objects.values_list('pachtbeginn__year', flat=True)) + list(LandVerpachtung.objects.values_list('pachtbeginn__year', flat=True)) ), reverse=True) - # Statistics for overview tiles - total_persons = Person.objects.count() + # Statistics for overview tiles (removed legacy Person and Verpachtung) total_destinataere = Destinataer.objects.count() total_laendereien = Land.objects.count() - total_verpachtungen = Verpachtung.objects.count() + total_verpachtungen = LandVerpachtung.objects.count() total_foerderungen = Foerderung.objects.count() context = { 'jahre': jahre, 'title': 'Berichte', - 'total_persons': total_persons, 'total_destinataere': total_destinataere, 'total_laendereien': total_laendereien, 'total_verpachtungen': total_verpachtungen, @@ -1791,14 +1858,14 @@ 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') - verpachtungen = Verpachtung.objects.filter( + verpachtungen = LandVerpachtung.objects.filter( 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_jaehrlich'))['total'] or 0 + total_pachtzins = verpachtungen.aggregate(total=Sum('pachtzins_pauschal'))['total'] or 0 context = { 'jahr': jahr, @@ -1828,14 +1895,14 @@ def jahresbericht_pdf(request, jahr): # Get data for the year foerderungen = Foerderung.objects.filter(jahr=jahr).select_related('person') - verpachtungen = Verpachtung.objects.filter( + verpachtungen = LandVerpachtung.objects.filter( 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_jaehrlich'))['total'] or 0 + total_pachtzins = verpachtungen.aggregate(total=Sum('pachtzins_pauschal'))['total'] or 0 context = { 'jahr': jahr, @@ -1860,9 +1927,7 @@ def jahresbericht_pdf(request, jahr): # Dashboard Views @login_required def dashboard(request): - # Person statistics - total_persons = Person.objects.count() - active_persons = Person.objects.filter(aktiv=True).count() + # Foerderung statistics (Person statistics removed - was legacy Verpachtung system) total_foerderungen = Foerderung.objects.aggregate(total=Sum('betrag'))['total'] or 0 # Land statistics @@ -1871,28 +1936,31 @@ def dashboard(request): total_flaeche = Land.objects.aggregate(total=Sum('groesse_qm'))['total'] or 0 # Calculate total verpachtet from active verpachtungen - total_verpachtet = Verpachtung.objects.filter(status='aktiv').aggregate( + total_verpachtet = LandVerpachtung.objects.filter(status='aktiv').aggregate( total=Sum('verpachtete_flaeche') )['total'] or 0 # Verpachtung statistics - total_verpachtungen = Verpachtung.objects.count() - active_verpachtungen = Verpachtung.objects.filter(status='aktiv').count() - total_pachtzins = Verpachtung.objects.filter(status='aktiv').aggregate( - total=Sum('pachtzins_jaehrlich') + 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 # Recent activities recent_lands = Land.objects.order_by('-erstellt_am')[:5] - recent_verpachtungen = Verpachtung.objects.select_related('land', 'paechter').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] # Verfügbare Paperless-Dokumente für Dashboard available_paperless_docs = [] - url = getattr(settings, "PAPERLESS_API_URL", None) - token = getattr(settings, "PAPERLESS_API_TOKEN", None) + from stiftung.utils.config import get_paperless_config + + config = get_paperless_config() + url = config['api_url'] + token = config['api_token'] if url and token: try: @@ -1932,7 +2000,7 @@ def dashboard(request): elif isinstance(doc_tags, str): tags = [tag.strip() for tag in doc_tags.split(',')] - if any(tag in ['Stiftung_Destinatäre', 'Stiftung_Land_und_Pächter', 'Stiftung_Administration'] for tag in tags): + if any(tag in [config['destinataere_tag'], config['land_tag'], config['admin_tag']] for tag in tags): bereits_verknuepft = DokumentLink.objects.filter( paperless_document_id=doc['id'] ).exists() @@ -1956,8 +2024,7 @@ def dashboard(request): pass context = { - 'total_persons': total_persons, - 'active_persons': active_persons, + # Person statistics removed - was legacy Verpachtung system 'total_foerderungen': total_foerderungen, 'total_land': total_land, 'active_land': active_land, @@ -2007,8 +2074,11 @@ def health(_request): @api_view(['GET']) def paperless_ping(_request): - url = getattr(settings, "PAPERLESS_API_URL", None) - token = getattr(settings, "PAPERLESS_API_TOKEN", None) + from stiftung.utils.config import get_paperless_config + + config = get_paperless_config() + url = config['api_url'] + token = config['api_token'] if not url or not token: return Response({'ok': False, 'reason': 'Paperless API not configured'}, status=400) try: @@ -2025,16 +2095,17 @@ def paperless_documents(request): Optionales Polling: ?poll=1 wartet kurz und versucht erneut, damit frisch ingestete Dokumente in Paperless erscheinen, bevor die Liste zurückgegeben wird. """ - from django.conf import settings + from stiftung.utils.config import get_paperless_config - url = getattr(settings, "PAPERLESS_API_URL", None) - token = getattr(settings, "PAPERLESS_API_TOKEN", None) - required_tag = getattr(settings, "PAPERLESS_REQUIRED_TAG", "Stiftung_Destinatäre") - land_tag = getattr(settings, "PAPERLESS_LAND_TAG", "Stiftung_Land_und_Pächter") - admin_tag = getattr(settings, "PAPERLESS_ADMIN_TAG", "Stiftung_Administration") - destinaere_tag_id = getattr(settings, "PAPERLESS_DESTINATAERE_TAG_ID", "210") - land_tag_id = getattr(settings, "PAPERLESS_LAND_TAG_ID", "204") - admin_tag_id = getattr(settings, "PAPERLESS_ADMIN_TAG_ID", "216") + config = get_paperless_config() + url = config['api_url'] + token = config['api_token'] + required_tag = config['destinataere_tag'] + land_tag = config['land_tag'] + admin_tag = config['admin_tag'] + destinaere_tag_id = config['destinataere_tag_id'] + land_tag_id = config['land_tag_id'] + admin_tag_id = config['admin_tag_id'] if not url or not token: return Response({ @@ -2141,16 +2212,17 @@ def paperless_documents(request): @api_view(['GET']) def paperless_debug(request): """Debug-View für Paperless-Integration""" - from django.conf import settings + from stiftung.utils.config import get_paperless_config - url = getattr(settings, "PAPERLESS_API_URL", None) - token = getattr(settings, "PAPERLESS_API_TOKEN", None) - required_tag = getattr(settings, "PAPERLESS_REQUIRED_TAG", "Stiftung_Destinatäre") - land_tag = getattr(settings, "PAPERLESS_LAND_TAG", "Stiftung_Land_und_Pächter") - admin_tag = getattr(settings, "PAPERLESS_ADMIN_TAG", "Stiftung_Administration") - destinaere_tag_id = getattr(settings, "PAPERLESS_DESTINATAERE_TAG_ID", "210") - land_tag_id = getattr(settings, "PAPERLESS_LAND_TAG_ID", "204") - admin_tag_id = getattr(settings, "PAPERLESS_ADMIN_TAG_ID", "216") + config = get_paperless_config() + url = config['api_url'] + token = config['api_token'] + required_tag = config['destinataere_tag'] + land_tag = config['land_tag'] + admin_tag = config['admin_tag'] + destinaere_tag_id = config['destinataere_tag_id'] + land_tag_id = config['land_tag_id'] + admin_tag_id = config['admin_tag_id'] if not url or not token: return Response({'error': 'Paperless API not configured'}, status=400) @@ -2249,10 +2321,11 @@ def paperless_debug(request): @api_view(['GET']) def paperless_tags_only(request): """Holt nur die Tag-Liste aus Paperless - ohne Dokumente""" - from django.conf import settings + from stiftung.utils.config import get_paperless_config - url = getattr(settings, "PAPERLESS_API_URL", None) - token = getattr(settings, "PAPERLESS_API_TOKEN", None) + config = get_paperless_config() + url = config['api_url'] + token = config['api_token'] if not url or not token: return Response({'error': 'Paperless API not configured'}, status=400) @@ -2395,7 +2468,7 @@ def link_document_search(request): ] if category in ['all', 'verpachtung']: - # Suche nach Verpachtungen + # Suche nach Verpachtungen (using new LandVerpachtung model) verpachtung_query = Q() if query and query != 'all': verpachtung_query = ( @@ -2405,16 +2478,16 @@ def link_document_search(request): 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(pachtzins_jaehrlich__icontains=query) | Q(notizen__icontains=query) + Q(vertragsnummer__icontains=query) | Q(pachtzins_pauschal__icontains=query) | Q(bemerkungen__icontains=query) ) - verpachtung_results = Verpachtung.objects.filter(verpachtung_query).select_related('paechter', 'land')[:25] + 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"Flurstück: {v.land.flurstueck or 'N/A'} • Pachtzins: {v.pachtzins_jaehrlich or 'N/A'} • Zeitraum: {v.pachtbeginn.strftime('%Y') if v.pachtbeginn else '?'}-{v.pachtende.strftime('%Y') if v.pachtende else '?'}" + '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 ] @@ -2463,14 +2536,60 @@ def link_document_search(request): } for r in rentmeister_results ] + + if category in ['all', 'abrechnung']: + # Suche nach Abrechnungen + abrechnung_query = Q() + if query and query != 'all': + abrechnung_query = ( + Q(land__gemarkung__icontains=query) | Q(land__gemeinde__icontains=query) | + Q(land__flur__icontains=query) | Q(land__flurstueck__icontains=query) | + Q(land__lfd_nr__icontains=query) | Q(abrechnungsjahr__icontains=query) | + Q(bemerkungen__icontains=query) + ) + + abrechnung_results = LandAbrechnung.objects.filter(abrechnung_query).select_related('land')[:25] + results['abrechnung'] = [ + { + 'id': a.id, + 'name': f"Abrechnung {a.abrechnungsjahr} - {a.land.gemarkung}, Flur {a.land.flur}", + 'type': 'Abrechnung', + 'details': f"Flurstück: {a.land.flurstueck or 'N/A'} • Jahr: {a.abrechnungsjahr} • Grundsteuer: {a.grundsteuer_betrag or 0} € • Versicherung: {a.versicherungen_betrag or 0} €" + } + for a in abrechnung_results + ] + + if category in ['all', 'foerderung']: + # Suche nach Förderungen + foerderung_query = Q() + if query and query != 'all': + foerderung_query = ( + Q(destinataer__nachname__icontains=query) | Q(destinataer__vorname__icontains=query) | + Q(destinataer__institution__icontains=query) | Q(destinataer__email__icontains=query) | + Q(jahr__icontains=query) | Q(betrag__icontains=query) | + Q(kategorie__icontains=query) | Q(status__icontains=query) | + Q(bemerkungen__icontains=query) + ) + + foerderung_results = Foerderung.objects.filter(foerderung_query).select_related('destinataer')[:25] + results['foerderung'] = [ + { + 'id': str(f.id), # Convert UUID to string for JSON serialization + 'name': f"{f.destinataer.get_full_name() if f.destinataer else 'Unbekannt'} - {f.jahr}", + 'type': 'Förderung', + 'details': f"Betrag: {f.betrag} € • Kategorie: {f.get_kategorie_display()} • Status: {f.get_status_display()} • Jahr: {f.jahr}" + } + for f in foerderung_results + ] + return Response(results) def create_paechter_link_for_verpachtung(paperless_id, paperless_title, verpachtung_id): """Hilfsfunktion: Erstellt automatisch eine Pächter-Verknüpfung für eine Verpachtung""" try: - # Hole die Verpachtung und den zugehörigen Pächter - verpachtung = Verpachtung.objects.select_related('paechter').get(id=verpachtung_id) + # Hole die LandVerpachtung und den zugehörigen Pächter + verpachtung = LandVerpachtung.objects.select_related('paechter').get(id=verpachtung_id) if verpachtung.paechter: # Prüfe, ob bereits eine Verknüpfung für dieses Dokument und diesen Pächter existiert existing_link = DokumentLink.objects.filter( @@ -2487,7 +2606,7 @@ def create_paechter_link_for_verpachtung(paperless_id, paperless_title, verpacht paechter_id=verpachtung.paechter.id ) return True - except (Verpachtung.DoesNotExist, Exception): + except (LandVerpachtung.DoesNotExist, Exception): pass return False @@ -2511,7 +2630,7 @@ def link_document_create(request): 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' + 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]): @@ -2531,11 +2650,16 @@ def link_document_create(request): elif link_type == 'land': dokument_link.land_id = link_id elif link_type == 'verpachtung': - dokument_link.verpachtung_id = link_id + # Use new LandVerpachtung field instead of legacy + dokument_link.land_verpachtung_id = link_id elif link_type == 'paechter': dokument_link.paechter_id = link_id + elif link_type == 'foerderung': + dokument_link.foerderung_id = link_id elif link_type == 'rentmeister': dokument_link.rentmeister_id = link_id + elif link_type == 'abrechnung': + dokument_link.abrechnung_id = link_id dokument_link.save() @@ -2556,6 +2680,10 @@ def link_document_create(request): from stiftung.models import Paechter entity = Paechter.objects.get(id=link_id) target_name = f"{entity.vorname} {entity.nachname}".strip() + elif link_type == 'foerderung': + from stiftung.models import Foerderung + entity = Foerderung.objects.get(id=link_id) + target_name = f"{entity.destinataer.get_full_name() if entity.destinataer else 'Unbekannt'} - {entity.jahr}" elif link_type == 'verpachtung': from stiftung.models import Verpachtung entity = Verpachtung.objects.get(id=link_id) @@ -2667,17 +2795,18 @@ def link_document_list(request): except Paechter.DoesNotExist: link_info['linked_object'] = {'type': 'Pächter', 'name': 'Gelöscht', 'details': 'Datensatz nicht mehr verfügbar'} - elif link.verpachtung_id: + elif link.land_verpachtung_id: link_info['link_type'] = 'verpachtung' try: - verp = Verpachtung.objects.select_related('paechter', 'land').get(id=link.verpachtung_id) + 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'}" + '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 Verpachtung.DoesNotExist: + except LandVerpachtung.DoesNotExist: link_info['linked_object'] = {'type': 'Verpachtung', 'name': 'Gelöscht', 'details': 'Datensatz nicht mehr verfügbar'} elif link.rentmeister_id: @@ -2695,6 +2824,20 @@ def link_document_list(request): except Rentmeister.DoesNotExist: link_info['linked_object'] = {'type': 'Rentmeister', 'name': 'Gelöscht', 'details': 'Datensatz nicht mehr verfügbar'} + elif link.abrechnung_id: + link_info['link_type'] = 'abrechnung' + try: + abrechnung = LandAbrechnung.objects.select_related('land').get(id=link.abrechnung_id) + link_info['linked_object'] = { + 'id': str(abrechnung.id), + 'type': 'Abrechnung', + 'name': f"Abrechnung {abrechnung.abrechnungsjahr} - {abrechnung.land.gemarkung}", + 'details': f"Land: {abrechnung.land.gemarkung} - {abrechnung.land.gemeinde}, Flur {abrechnung.land.flur}, Jahr {abrechnung.abrechnungsjahr}", + 'url': f"/laendereien/abrechnungen/{abrechnung.id}/" + } + except LandAbrechnung.DoesNotExist: + link_info['linked_object'] = {'type': 'Abrechnung', 'name': 'Gelöscht', 'details': 'Datensatz nicht mehr verfügbar'} + links_by_document[paperless_id]['links'].append(link_info) # Convert to list format for frontend @@ -2744,6 +2887,7 @@ def link_document_update(request): link.land_id = None link.verpachtung_id = None link.paechter_id = None + link.foerderung_id = None link.rentmeister_id = None link.kontext = link_type @@ -2755,6 +2899,8 @@ def link_document_update(request): link.verpachtung_id = link_target_id elif link_type == 'paechter': link.paechter_id = link_target_id + elif link_type == 'foerderung': + link.foerderung_id = link_target_id elif link_type == 'rentmeister': link.rentmeister_id = link_target_id else: @@ -3437,43 +3583,24 @@ def administration(request): def unterstuetzungen_list(request): """Liste der Destinatärunterstützungen (Administration).""" status = request.GET.get('status', '') - export = request.GET.get('format', '') - qs = DestinataerUnterstuetzung.objects.select_related('destinataer', 'konto').order_by('-faellig_am', 'destinataer__nachname') + 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') + if status: qs = qs.filter(status=status) - # CSV export - if export == 'csv': - import csv - from django.http import HttpResponse - response = HttpResponse(content_type='text/csv; charset=utf-8') - response['Content-Disposition'] = 'attachment; filename=unterstuetzungen.csv' - writer = csv.writer(response, delimiter=';') - writer.writerow(['Destinatär','Betrag','Fällig am','Status','Bank','IBAN','Kontoname','Beschreibung']) - for u in qs: - writer.writerow([ - u.destinataer.get_full_name(), - f"{u.betrag:.2f}", - u.faellig_am.strftime('%d.%m.%Y'), - u.get_status_display(), - getattr(u.konto, 'bank_name', ''), - getattr(u.konto, 'iban', ''), - str(u.konto), - u.beschreibung or '', - ]) - return response - # PDF export (simple table via WeasyPrint; graceful fallback if missing) - 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': qs}) - 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' - return resp - except Exception: - pass + + # Enhanced CSV export with field selection + if export_format == 'csv': + return export_unterstuetzungen_csv(request, qs, selected_ids) + + # Enhanced PDF export with corporate identity + elif export_format == 'pdf': + return export_unterstuetzungen_pdf(request, qs, selected_ids) context = { 'unterstuetzungen': qs, 'status_filter': status, @@ -3481,6 +3608,357 @@ def unterstuetzungen_list(request): 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 + + # 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 [] + + if not selected_fields: + # Default field set + selected_fields = [ + '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 ''), + + # 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 ''), + + # 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 ''), + + # 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 ''), + + # 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'), + } + + # 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) + + # Write headers + 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 = [] + for field in selected_fields: + if field in field_definitions: + try: + value = field_definitions[field][1](u) + row.append(value) + except Exception: + row.append('') # Fallback for any errors + else: + row.append('') # Unknown field + writer.writerow(row) + + return response + + +def export_unterstuetzungen_pdf(request, queryset, selected_ids=None): + """Enhanced PDF export with corporate identity and field selection""" + # 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 [] + + 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' + ] + + # 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', + } + + # 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} + + # 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': + 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' + else: + # Generic field access + row_data[field_key] = getattr(item, field_key, '') or '' + except Exception: + 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( + data=data_for_pdf, + fields_config=filtered_fields, + title="Unterstützungen Export", + filename_prefix="unterstuetzungen", + 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 + + # 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 [] + + if not selected_fields: + # Default field set + selected_fields = [ + '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 ''), + + # 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 ''), + + # 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 ''), + } + + # 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) + + # Write headers + 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 = [] + for field in selected_fields: + if field in field_definitions: + try: + value = field_definitions[field][1](f) + row.append(value) + except Exception: + row.append('') # Fallback for any errors + else: + row.append('') # Unknown field + writer.writerow(row) + + return response + + +def export_foerderungen_pdf(request, queryset, selected_ids=None): + """Enhanced PDF export for Förderungen with corporate identity and field selection""" + # 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 [] + + 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' + ] + + # 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', + } + + # 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} + + # 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': + 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': + row_data[field_key] = item.get_kategorie_display() + 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 '' + else: + # Generic field access + row_data[field_key] = getattr(item, field_key, '') or '' + except Exception: + 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( + data=data_for_pdf, + fields_config=filtered_fields, + title="Förderungen Export", + filename_prefix="foerderungen", + request_user=request.user + ) + + @login_required def unterstuetzung_edit(request, pk): obj = get_object_or_404(DestinataerUnterstuetzung, pk=pk) @@ -3498,11 +3976,46 @@ def unterstuetzung_edit(request, pk): @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() + will_delete_template = not andere_zahlungen + 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() - messages.success(request, 'Unterstützung gelöscht.') + + # If this was generated from a recurring template and there are no other + # payments from this template, delete the template too + if wiederkehrend_template: + # Check if there are other payments from this recurring template + 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.') + else: + messages.success(request, 'Unterstützung gelöscht.') + else: + messages.success(request, 'Unterstützung gelöscht.') + return redirect('stiftung:unterstuetzungen_list') - return render(request, 'stiftung/unterstuetzung_confirm_delete.html', {'obj': obj}) + + context = { + 'obj': obj, + 'will_delete_template': will_delete_template, + } + return render(request, 'stiftung/unterstuetzung_confirm_delete.html', context) @login_required @@ -4895,3 +5408,343 @@ def land_verpachtung_edit(request, land_pk): } 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'): + if setting.category not in categories: + categories[setting.category] = [] + categories[setting.category].append(setting) + + 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_', '') + try: + 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!') + else: + messages.info(request, 'No changes were made.') + + return redirect('stiftung:app_settings') + + context = { + 'categories': categories, + 'title': 'Application Settings', + } + 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 [] + + unterstuetzungen = DestinataerUnterstuetzung.objects.select_related( + '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': + return export_unterstuetzungen_csv(request, unterstuetzungen, selected_ids) + + # PDF export (simple table via WeasyPrint; graceful fallback if missing) + 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}) + 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' + 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 + + # Available destinataer for filter + 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, + } + return render(request, 'stiftung/unterstuetzungen_all.html', context) + + +@login_required +def unterstuetzung_create(request): + """Create a new support payment""" + # Get destinataer from URL parameter if provided + destinataer_id = request.GET.get('destinataer') + initial = {} + if 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() + except Destinataer.DoesNotExist: + pass + + if request.method == 'POST': + form = UnterstuetzungForm(request.POST) + if form.is_valid(): + 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 + ) + + # 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.') + 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) + else: + form = UnterstuetzungForm(initial=initial) + + context = { + 'form': form, + 'title': 'Neue Unterstützung erstellen', + } + return render(request, 'stiftung/unterstuetzung_form.html', context) + + +@login_required +def get_destinataer_info(request, destinataer_id): + """AJAX endpoint to get Destinataer IBAN and name information""" + try: + destinataer = Destinataer.objects.get(pk=destinataer_id) + data = { + 'success': True, + 'name': destinataer.get_full_name(), + 'iban': getattr(destinataer, 'iban', '') or '', + } + except Destinataer.DoesNotExist: + data = { + 'success': False, + 'error': 'Destinataer not found' + } + + return JsonResponse(data) + + +@login_required +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, + } + 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': + form = UnterstuetzungMarkAsPaidForm(request.POST) + if form.is_valid(): + 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') + if bemerkung: + if unterstuetzung.beschreibung: + unterstuetzung.beschreibung += f' | Zahlung: {bemerkung}' + else: + unterstuetzung.beschreibung = f'Zahlung: {bemerkung}' + + unterstuetzung.save() + 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()}', + } + 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': + # Find templates with no associated payments + verwaiste_templates = UnterstuetzungWiederkehrend.objects.annotate( + 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)) + verwaiste_templates.delete() + messages.success( + 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') + + # Get all templates with payment counts + templates = UnterstuetzungWiederkehrend.objects.select_related( + 'destinataer', 'konto' + ).annotate( + aktive_zahlungen=Count('destinataerunterstuetzung') + ).all() + + context = { + 'templates': templates, + 'title': 'Wiederkehrende Unterstützungen', + } + 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 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')) + + # 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, + } + ) + + if not created: + # Existierende Hilfsbox aktualisieren + help_box.title = title + help_box.content = content + 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')) + + # GET Request - Zeige Admin-Übersicht der Hilfsboxen + 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)) + + # 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', + } + return render(request, 'stiftung/help_boxes_admin.html', context) diff --git a/app/templates/base.html b/app/templates/base.html index 4bdcf90..850001a 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -292,10 +292,32 @@ Dashboard -