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:
Stiftung Development
2025-09-06 18:47:23 +02:00
parent dcc91b9f49
commit 35ba089a84
64 changed files with 7040 additions and 1419 deletions

View File

@@ -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"

View File

@@ -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):

View 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')

View 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"'
)
)

View 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.'
)
)

View 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

View 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',
),
]

View 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'),
),
]

View 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'],
},
),
]

View 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'),
),
]

View File

@@ -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'),
),
]

View 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'),
),
]

View File

@@ -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

View File

View 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

View 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)

View File

@@ -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'),

View 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'])

View 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

View 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