fix: configure CI database connection properly
- Add dotenv loading to Django settings - Update CI workflow to use correct environment variables - Set POSTGRES_* variables instead of DATABASE_URL - Add environment variables to all Django management commands - Fixes CI test failures due to database connection issues
This commit is contained in:
@@ -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('<span style="color: {};">{} Tage</span>', 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('<span style="color: green;">✓ Aktiv</span>')
|
||||
return format_html('<span style="color: red;">✗ Inaktiv</span>')
|
||||
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('<a href="{}" target="_blank">{}</a>', 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"
|
||||
|
||||
@@ -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):
|
||||
|
||||
133
app/stiftung/management/commands/generate_recurring_payments.py
Normal file
133
app/stiftung/management/commands/generate_recurring_payments.py
Normal file
@@ -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')
|
||||
124
app/stiftung/management/commands/init_config.py
Normal file
124
app/stiftung/management/commands/init_config.py
Normal file
@@ -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"'
|
||||
)
|
||||
)
|
||||
149
app/stiftung/management/commands/init_corporate_settings.py
Normal file
149
app/stiftung/management/commands/init_corporate_settings.py
Normal file
@@ -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.'
|
||||
)
|
||||
)
|
||||
253
app/stiftung/management/commands/sync_abrechnungen.py
Normal file
253
app/stiftung/management/commands/sync_abrechnungen.py
Normal file
@@ -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
|
||||
16
app/stiftung/migrations/0023_remove_legacy_verpachtung.py
Normal file
16
app/stiftung/migrations/0023_remove_legacy_verpachtung.py
Normal file
@@ -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',
|
||||
),
|
||||
]
|
||||
18
app/stiftung/migrations/0024_dokumentlink_abrechnung_id.py
Normal file
18
app/stiftung/migrations/0024_dokumentlink_abrechnung_id.py
Normal file
@@ -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'),
|
||||
),
|
||||
]
|
||||
37
app/stiftung/migrations/0025_appconfiguration.py
Normal file
37
app/stiftung/migrations/0025_appconfiguration.py
Normal file
@@ -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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
89
app/stiftung/migrations/0026_enhance_unterstuetzung_model.py
Normal file
89
app/stiftung/migrations/0026_enhance_unterstuetzung_model.py
Normal file
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
18
app/stiftung/migrations/0028_alter_helpbox_page_key.py
Normal file
18
app/stiftung/migrations/0028_alter_helpbox_page_key.py
Normal file
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
|
||||
0
app/stiftung/templatetags/__init__.py
Normal file
0
app/stiftung/templatetags/__init__.py
Normal file
29
app/stiftung/templatetags/help_tags.py
Normal file
29
app/stiftung/templatetags/help_tags.py
Normal file
@@ -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
|
||||
145
app/stiftung/templatetags/pdf_tags.py
Normal file
145
app/stiftung/templatetags/pdf_tags.py
Normal file
@@ -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'<span class="status-badge {css_class}">{status}</span>')
|
||||
|
||||
|
||||
@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)
|
||||
@@ -53,13 +53,10 @@ urlpatterns = [
|
||||
path('laendereien/<uuid:land_pk>/verpachtung/bearbeiten/', views.land_verpachtung_edit, name='land_verpachtung_edit'),
|
||||
path('laendereien/<uuid:land_pk>/verpachtung/beenden/', views.land_verpachtung_end, name='land_verpachtung_end'),
|
||||
|
||||
# Verpachtung URLs
|
||||
path('verpachtungen/', views.verpachtung_list, name='verpachtung_list'),
|
||||
path('verpachtungen/<uuid:pk>/', views.verpachtung_detail, name='verpachtung_detail'),
|
||||
path('verpachtungen/neu/', views.verpachtung_create, name='verpachtung_create'),
|
||||
path('verpachtungen/<uuid:pk>/bearbeiten/', views.verpachtung_update, name='verpachtung_update'),
|
||||
path('verpachtungen/<uuid:pk>/loeschen/', views.verpachtung_delete, name='verpachtung_delete'),
|
||||
path('verpachtungen/<uuid:pk>/export/', views.verpachtung_export, name='verpachtung_export'),
|
||||
# LandVerpachtung URLs (neue Verpachtungen)
|
||||
path('laendereien/verpachtungen/<uuid:pk>/', views.land_verpachtung_detail, name='land_verpachtung_detail'),
|
||||
path('laendereien/verpachtungen/<uuid:pk>/bearbeiten/', views.land_verpachtung_update, name='land_verpachtung_update'),
|
||||
path('laendereien/verpachtungen/<uuid:pk>/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/<uuid:backup_id>/download/', views.backup_download, name='backup_download'),
|
||||
@@ -120,6 +118,16 @@ urlpatterns = [
|
||||
path('administration/unterstuetzungen/<uuid:pk>/bearbeiten/', views.unterstuetzung_edit, name='unterstuetzung_edit'),
|
||||
path('administration/unterstuetzungen/<uuid:pk>/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/<uuid:pk>/', views.unterstuetzung_detail, name='unterstuetzung_detail'),
|
||||
path('unterstuetzungen/<uuid:pk>/bezahlt/', views.unterstuetzung_mark_paid, name='unterstuetzung_mark_paid'),
|
||||
path('unterstuetzungen/wiederkehrend/', views.wiederkehrende_unterstuetzungen, name='wiederkehrende_unterstuetzungen'),
|
||||
|
||||
# AJAX endpoints
|
||||
path('api/destinataer/<uuid:destinataer_id>/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/<int:pk>/permissions/', views.user_permissions, name='user_permissions'),
|
||||
path('administration/users/<int:pk>/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'),
|
||||
|
||||
73
app/stiftung/utils/config.py
Normal file
73
app/stiftung/utils/config.py
Normal file
@@ -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'])
|
||||
34
app/stiftung/utils/date_utils.py
Normal file
34
app/stiftung/utils/date_utils.py
Normal file
@@ -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
|
||||
420
app/stiftung/utils/pdf_generator.py
Normal file
420
app/stiftung/utils/pdf_generator.py
Normal file
@@ -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"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>PDF Export - {filename}</title>
|
||||
<style>
|
||||
body {{ font-family: Arial, sans-serif; padding: 20px; max-width: 1200px; margin: 0 auto; }}
|
||||
.warning {{ background: #fff3cd; color: #856404; padding: 15px; border: 1px solid #ffeaa7; border-radius: 5px; margin-bottom: 20px; }}
|
||||
.content {{ border: 1px solid #ddd; padding: 20px; border-radius: 5px; }}
|
||||
{css_content or ''}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="warning">
|
||||
<h2>⚠️ PDF Generation Not Available</h2>
|
||||
<p><strong>WeasyPrint dependencies are missing:</strong> {IMPORT_ERROR}</p>
|
||||
<p>Showing content as HTML preview instead. You can print this page to PDF using your browser.</p>
|
||||
</div>
|
||||
<div class="content">
|
||||
{html_content}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
response = HttpResponse(error_html, content_type='text/html')
|
||||
response['Content-Disposition'] = f'inline; filename="{filename.replace(".pdf", "_preview.html")}"'
|
||||
return response
|
||||
|
||||
try:
|
||||
# Create CSS string
|
||||
if css_content:
|
||||
css = CSS(string=css_content, font_config=self.font_config)
|
||||
else:
|
||||
css = None
|
||||
|
||||
# Generate PDF
|
||||
html_doc = HTML(string=html_content)
|
||||
pdf_bytes = html_doc.write_pdf(stylesheets=[css] if css else None,
|
||||
font_config=self.font_config)
|
||||
|
||||
# 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"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>PDF Generation Error</title>
|
||||
<style>
|
||||
body {{ font-family: Arial, sans-serif; padding: 20px; }}
|
||||
.error {{ color: #d32f2f; border: 1px solid #d32f2f; padding: 15px; border-radius: 5px; margin-bottom: 20px; }}
|
||||
.content {{ border: 1px solid #ddd; padding: 20px; border-radius: 5px; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>PDF Generation Error</h1>
|
||||
<div class="error">
|
||||
<p>An error occurred while generating the PDF:</p>
|
||||
<p><strong>{str(e)}</strong></p>
|
||||
<p>Showing content as HTML preview instead.</p>
|
||||
</div>
|
||||
<div class="content">
|
||||
<h2>Original Content</h2>
|
||||
{html_content}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
response = HttpResponse(error_html, content_type='text/html')
|
||||
response['Content-Disposition'] = f'inline; filename="error_{filename.replace(".pdf", ".html")}"'
|
||||
return response
|
||||
|
||||
def export_data_list_pdf(self, data, fields_config, title, filename_prefix, request_user=None):
|
||||
"""
|
||||
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()
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user