feat: add comprehensive GitHub workflow and development tools

This commit is contained in:
Stiftung Development
2025-09-06 18:31:54 +02:00
commit ab23d7187e
10224 changed files with 2075210 additions and 0 deletions

0
app/stiftung/__init__.py Normal file
View File

547
app/stiftung/admin.py Normal file
View File

@@ -0,0 +1,547 @@
from django.contrib import admin
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 .models import (
Person, Paechter, Destinataer, DokumentLink, Foerderung, Land, Verpachtung, CSVImport,
Rentmeister, StiftungsKonto, Verwaltungskosten, BankTransaction, AuditLog, BackupJob
)
@admin.register(CSVImport)
class CSVImportAdmin(admin.ModelAdmin):
list_display = ['import_type', 'filename', 'status', 'total_rows', 'imported_rows', 'failed_rows', 'created_by', 'started_at', 'duration_display']
list_filter = ['import_type', 'status', 'started_at']
search_fields = ['filename', 'created_by']
readonly_fields = ['id', 'started_at', 'completed_at', 'get_success_rate']
ordering = ['-started_at']
fieldsets = (
('Grundinformationen', {
'fields': ('import_type', 'filename', 'file_size', 'status')
}),
('Ergebnisse', {
'fields': ('total_rows', 'imported_rows', 'failed_rows', 'get_success_rate', 'error_log')
}),
('Metadaten', {
'fields': ('created_by', 'started_at', 'completed_at')
}),
)
def duration_display(self, obj):
duration = obj.get_duration()
if duration:
return f"{duration.total_seconds():.1f}s"
return "-"
duration_display.short_description = "Dauer"
def get_success_rate(self, obj):
rate = obj.get_success_rate()
if rate >= 90:
color = "success"
elif rate >= 70:
color = "warning"
else:
color = "danger"
return format_html('<span class="badge bg-{}">{:.1f}%</span>', color, rate)
get_success_rate.short_description = "Erfolgsrate"
@admin.register(Person)
class PersonAdmin(admin.ModelAdmin):
list_display = ['nachname', 'vorname', 'familienzweig', 'geburtsdatum', 'email', 'iban_display']
list_filter = ['familienzweig', 'geburtsdatum']
search_fields = ['nachname', 'vorname', 'email', 'familienzweig']
ordering = ['nachname', 'vorname']
readonly_fields = ['id']
fieldsets = (
('Persönliche Daten', {
'fields': ('vorname', 'nachname', 'geburtsdatum', 'email', 'telefon')
}),
('Stiftungsdaten', {
'fields': ('familienzweig', 'iban', 'adresse')
}),
('Zusätzlich', {
'fields': ('notizen', 'aktiv')
}),
('System', {
'fields': ('id',),
'classes': ('collapse',)
}),
)
def iban_display(self, obj):
if obj.iban:
return format_html('<span style="font-family: monospace;">{}</span>', obj.iban)
return '-'
iban_display.short_description = 'IBAN'
def get_queryset(self, request):
return super().get_queryset(request).annotate(
total_foerderungen=Sum('foerderung__betrag')
)
@admin.register(Paechter)
class PaechterAdmin(admin.ModelAdmin):
list_display = ['nachname', 'vorname', 'pachtnummer', 'pachtzins_aktuell', 'landwirtschaftliche_ausbildung', 'aktiv']
list_filter = ['landwirtschaftliche_ausbildung', 'aktiv']
search_fields = ['nachname', 'vorname', 'email', 'pachtnummer']
ordering = ['nachname', 'vorname']
readonly_fields = ['id']
fieldsets = (
('Persönliche Daten', {
'fields': ('vorname', 'nachname', 'geburtsdatum', 'email', 'telefon')
}),
('Pacht-Informationen', {
'fields': ('pachtnummer', 'pachtbeginn_erste', 'pachtende_letzte', 'pachtzins_aktuell')
}),
('Landwirtschaftliche Qualifikation', {
'fields': ('landwirtschaftliche_ausbildung', 'berufserfahrung_jahre', 'spezialisierung')
}),
('Kontaktdaten', {
'fields': ('iban', 'strasse', 'plz', 'ort')
}),
('Pächter-Typ', {
'fields': ('personentyp',)
}),
('Zusätzlich', {
'fields': ('notizen', 'aktiv')
}),
('System', {
'fields': ('id',),
'classes': ('collapse',)
}),
)
def iban_display(self, obj):
if obj.iban:
return format_html('<span style="font-family: monospace;">{}</span>', obj.iban)
return '-'
iban_display.short_description = 'IBAN'
@admin.register(Destinataer)
class DestinataerAdmin(admin.ModelAdmin):
list_display = ['nachname', 'vorname', 'familienzweig', 'berufsgruppe', 'institution', 'finanzielle_notlage', 'aktiv']
list_filter = ['familienzweig', 'berufsgruppe', 'finanzielle_notlage', 'aktiv']
search_fields = ['nachname', 'vorname', 'email', 'institution', 'familienzweig']
ordering = ['nachname', 'vorname']
readonly_fields = ['id']
fieldsets = (
('Persönliche Daten', {
'fields': ('vorname', 'nachname', 'geburtsdatum', 'email', 'telefon')
}),
('Berufliche Informationen', {
'fields': ('berufsgruppe', 'ausbildungsstand', 'institution')
}),
('Projekt & Finanzen', {
'fields': ('projekt_beschreibung', 'jaehrliches_einkommen', 'finanzielle_notlage')
}),
('Stiftungsdaten', {
'fields': ('familienzweig', 'iban', 'strasse', 'plz', 'ort')
}),
('Zusätzlich', {
'fields': ('notizen', 'aktiv')
}),
('System', {
'fields': ('id',),
'classes': ('collapse',)
}),
)
def iban_display(self, obj):
if obj.iban:
return format_html('<span style="font-family: monospace;">{}</span>', obj.iban)
return '-'
iban_display.short_description = 'IBAN'
@admin.register(Land)
class LandAdmin(admin.ModelAdmin):
list_display = [
'lfd_nr', 'gemeinde', 'gemarkung', 'flur', 'flurstueck',
'groesse_qm', 'verp_flaeche_aktuell', 'verpachtungsgrad_display', 'aktiv'
]
list_filter = ['gemeinde', 'gemarkung', 'aktiv']
search_fields = ['lfd_nr', 'gemeinde', 'gemarkung', 'flur', 'flurstueck']
ordering = ['gemeinde', 'gemarkung', 'flur', 'flurstueck']
readonly_fields = ['id', 'gesamtflaeche_berechnet', 'verpachtungsgrad_berechnet']
fieldsets = (
('Identifikation', {
'fields': ('lfd_nr', 'ew_nummer')
}),
('Gerichtliche Zuständigkeit', {
'fields': ('amtsgericht',)
}),
('Verwaltungsstruktur', {
'fields': ('gemeinde', 'gemarkung', 'flur', 'flurstueck')
}),
('Flächenangaben', {
'fields': ('groesse_qm', 'gruenland_qm', 'acker_qm', 'wald_qm', 'sonstiges_qm')
}),
('Verpachtung', {
'fields': ('verpachtete_gesamtflaeche', 'flaeche_alte_liste', 'verp_flaeche_aktuell')
}),
('Steuern und Abgaben', {
'fields': ('anteil_grundsteuer', 'anteil_lwk')
}),
('Status', {
'fields': ('aktiv', 'notizen')
}),
('System', {
'fields': ('id', 'erstellt_am', 'aktualisiert_am'),
'classes': ('collapse',)
}),
)
def verpachtungsgrad_display(self, obj):
grad = obj.get_verpachtungsgrad()
if grad > 90:
color = 'green'
elif grad > 70:
color = 'orange'
else:
color = 'red'
return format_html('<span style="color: {};">{:.1f}%</span>', color, grad)
verpachtungsgrad_display.short_description = 'Verpachtungsgrad'
def gesamtflaeche_berechnet(self, obj):
return f"{obj.get_gesamtflaeche():.2f} qm"
gesamtflaeche_berechnet.short_description = 'Berechnete Gesamtfläche'
def verpachtungsgrad_berechnet(self, obj):
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']
list_filter = ['kontext']
search_fields = ['titel', 'kontext']
ordering = ['titel']
readonly_fields = ['id']
fieldsets = (
('Dokument', {
'fields': ('titel', 'kontext', 'paperless_document_id', 'beschreibung')
}),
('System', {
'fields': ('id',),
'classes': ('collapse',)
}),
)
@admin.register(Foerderung)
class FoerderungAdmin(admin.ModelAdmin):
list_display = ['destinataer', 'jahr', 'betrag', 'verwendungsnachweis_link', 'total_for_destinataer']
list_filter = ['jahr', 'destinataer__familienzweig']
search_fields = ['destinataer__nachname', 'destinataer__vorname', 'destinataer__familienzweig']
ordering = ['-jahr', '-betrag']
readonly_fields = ['id']
fieldsets = (
('Förderung', {
'fields': ('destinataer', 'person', 'jahr', 'betrag', 'kategorie', 'status')
}),
('Dokumentation', {
'fields': ('verwendungsnachweis', 'bemerkungen')
}),
('Daten', {
'fields': ('antragsdatum', 'entscheidungsdatum')
}),
('System', {
'fields': ('id',),
'classes': ('collapse',)
}),
)
def verwendungsnachweis_link(self, obj):
if obj.verwendungsnachweis:
return format_html(
'<a href="{}">{}</a>',
reverse('admin:stiftung_dokumentlink_change', args=[obj.verwendungsnachweis.id]),
obj.verwendungsnachweis.titel
)
return '-'
verwendungsnachweis_link.short_description = 'Verwendungsnachweis'
def total_for_destinataer(self, obj):
total = Foerderung.objects.filter(destinataer=obj.destinataer).aggregate(Sum('betrag'))['betrag__sum'] or 0
return f"{total:,.2f}"
total_for_destinataer.short_description = 'Gesamt für Destinatär'
@admin.register(Rentmeister)
class RentmeisterAdmin(admin.ModelAdmin):
list_display = ['__str__', 'email', 'telefon', 'seit_datum', 'bis_datum', 'aktiv', 'monatliche_verguetung']
list_filter = ['aktiv', 'seit_datum', 'anrede']
search_fields = ['vorname', 'nachname', 'email', 'telefon', 'ort']
ordering = ['nachname', 'vorname']
readonly_fields = ['id', 'erstellt_am', 'aktualisiert_am']
fieldsets = (
('Persönliche Daten', {
'fields': ('anrede', 'vorname', 'nachname', 'titel')
}),
('Kontaktdaten', {
'fields': ('email', 'telefon', 'mobil', 'strasse', 'plz', 'ort')
}),
('Bankdaten', {
'fields': ('iban', 'bic', 'bank_name'),
'classes': ['collapse']
}),
('Stiftungsdaten', {
'fields': ('seit_datum', 'bis_datum', 'aktiv', 'monatliche_verguetung', 'km_pauschale')
}),
('Zusätzliche Informationen', {
'fields': ('notizen',),
'classes': ['collapse']
}),
('System', {
'fields': ('id', 'erstellt_am', 'aktualisiert_am'),
'classes': ['collapse']
})
)
@admin.register(StiftungsKonto)
class StiftungsKontoAdmin(admin.ModelAdmin):
list_display = ['kontoname', 'bank_name', 'konto_typ', 'saldo', 'saldo_datum', 'aktiv']
list_filter = ['konto_typ', 'aktiv', 'bank_name']
search_fields = ['kontoname', 'bank_name', 'iban']
ordering = ['bank_name', 'kontoname']
readonly_fields = ['id', 'erstellt_am', 'aktualisiert_am']
fieldsets = (
('Kontodaten', {
'fields': ('kontoname', 'bank_name', 'iban', 'bic', 'konto_typ')
}),
('Finanzdaten', {
'fields': ('saldo', 'saldo_datum', 'zinssatz', 'laufzeit_bis')
}),
('Status', {
'fields': ('aktiv', 'notizen')
}),
('System', {
'fields': ('id', 'erstellt_am', 'aktualisiert_am'),
'classes': ['collapse']
})
)
@admin.register(Verwaltungskosten)
class VerwaltungskostenAdmin(admin.ModelAdmin):
list_display = ['bezeichnung', 'kategorie', 'betrag', 'datum', 'status', 'rentmeister', 'konto']
list_filter = ['kategorie', 'status', 'datum', 'rentmeister', 'konto']
search_fields = ['bezeichnung', 'lieferant_firma', 'rechnungsnummer', 'beschreibung']
ordering = ['-datum', '-erstellt_am']
readonly_fields = ['id', 'erstellt_am', 'aktualisiert_am']
date_hierarchy = 'datum'
fieldsets = (
('Grunddaten', {
'fields': ('bezeichnung', 'kategorie', 'betrag', 'datum', 'status')
}),
('Zuordnung', {
'fields': ('rentmeister', 'konto')
}),
('Lieferant/Rechnung', {
'fields': ('lieferant_firma', 'rechnungsnummer'),
'classes': ['collapse']
}),
('Fahrtkosten', {
'fields': ('km_anzahl', 'km_satz', 'von_ort', 'nach_ort', 'zweck'),
'classes': ['collapse'],
'description': 'Nur für Kategorie "Fahrtkosten" relevant'
}),
('Zusätzliche Informationen', {
'fields': ('beschreibung', 'notizen'),
'classes': ['collapse']
}),
('System', {
'fields': ('id', 'erstellt_am', 'aktualisiert_am'),
'classes': ['collapse']
})
)
@admin.register(BankTransaction)
class BankTransactionAdmin(admin.ModelAdmin):
list_display = [
'datum', 'konto', 'betrag', 'empfaenger_zahlungspflichtiger',
'transaction_type', 'status', 'verwaltungskosten'
]
list_filter = [
'konto', 'transaction_type', 'status', 'datum', 'importiert_am'
]
search_fields = [
'verwendungszweck', 'empfaenger_zahlungspflichtiger', 'referenz'
]
readonly_fields = ['importiert_am', 'import_datei']
ordering = ['-datum', '-importiert_am']
fieldsets = (
('Basisdaten', {
'fields': ('konto', 'datum', 'valuta', 'betrag', 'waehrung')
}),
('Transaktionsdetails', {
'fields': ('verwendungszweck', 'empfaenger_zahlungspflichtiger', 'iban_gegenpartei', 'bic_gegenpartei', 'referenz', 'transaction_type')
}),
('Verwaltung', {
'fields': ('status', 'kommentare', 'verwaltungskosten')
}),
('Import-Information', {
'fields': ('import_datei', 'importiert_am', 'saldo_nach_buchung'),
'classes': ('collapse',)
}),
)
def get_queryset(self, request):
return super().get_queryset(request).select_related('konto', 'verwaltungskosten')
@admin.register(AuditLog)
class AuditLogAdmin(admin.ModelAdmin):
list_display = ['timestamp', 'username', 'action', 'entity_type', 'entity_name', 'ip_address']
list_filter = ['action', 'entity_type', 'timestamp', 'username']
search_fields = ['username', 'entity_name', 'description', 'ip_address']
readonly_fields = ['id', 'timestamp', 'user', 'username', 'action', 'entity_type', 'entity_id', 'entity_name', 'description', 'changes', 'ip_address', 'user_agent', 'session_key']
ordering = ['-timestamp']
date_hierarchy = 'timestamp'
fieldsets = (
('Benutzer und Zeit', {
'fields': ('timestamp', 'user', 'username', 'session_key')
}),
('Aktion', {
'fields': ('action', 'entity_type', 'entity_id', 'entity_name', 'description')
}),
('Änderungsdetails', {
'fields': ('changes',),
'classes': ['collapse']
}),
('Request-Informationen', {
'fields': ('ip_address', 'user_agent'),
'classes': ['collapse']
}),
('System', {
'fields': ('id',),
'classes': ['collapse']
})
)
def has_add_permission(self, request):
return False # Don't allow manual creation
def has_change_permission(self, request, obj=None):
return False # Don't allow editing
@admin.register(BackupJob)
class BackupJobAdmin(admin.ModelAdmin):
list_display = ['created_at', 'backup_type', 'status', 'backup_size_display', 'duration_display', 'created_by']
list_filter = ['backup_type', 'status', 'created_at']
search_fields = ['backup_filename', 'created_by__username']
readonly_fields = ['id', 'created_at', 'started_at', 'completed_at', 'backup_size', 'get_duration']
ordering = ['-created_at']
fieldsets = (
('Job-Details', {
'fields': ('backup_type', 'status', 'created_by')
}),
('Zeitpunkte', {
'fields': ('created_at', 'started_at', 'completed_at', 'get_duration')
}),
('Ergebnis', {
'fields': ('backup_filename', 'backup_size', 'database_size', 'files_count')
}),
('Fehlerbehandlung', {
'fields': ('error_message',),
'classes': ['collapse']
}),
('System', {
'fields': ('id',),
'classes': ['collapse']
})
)
def backup_size_display(self, obj):
return obj.get_size_display()
backup_size_display.short_description = 'Backup-Größe'
def duration_display(self, obj):
duration = obj.get_duration()
if duration:
return f"{duration.total_seconds():.1f}s"
return "-"
duration_display.short_description = "Dauer"
def has_add_permission(self, request):
return False # Use the web interface for creating backups
# Customize admin site
admin.site.site_header = "Stiftungsverwaltung Administration"
admin.site.site_title = "Stiftungsverwaltung Admin"
admin.site.index_title = "Willkommen zur Stiftungsverwaltung"

5
app/stiftung/apps.py Normal file
View File

@@ -0,0 +1,5 @@
from django.apps import AppConfig
class StiftungConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'stiftung'

281
app/stiftung/audit.py Normal file
View File

@@ -0,0 +1,281 @@
"""
Audit Logging Utilities
Provides functions to log user actions throughout the application
"""
import json
from django.utils import timezone
from django.contrib.auth import get_user_model
from stiftung.models import AuditLog
User = get_user_model()
def get_client_ip(request):
"""Extract the client IP address from the request"""
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
if x_forwarded_for:
ip = x_forwarded_for.split(',')[0]
else:
ip = request.META.get('REMOTE_ADDR')
return ip
def log_action(request, action, entity_type, entity_id, entity_name, description, changes=None):
"""
Log a user action to the audit log
Args:
request: Django request object
action: Action type (create, update, delete, etc.)
entity_type: Type of entity (destinataer, land, etc.)
entity_id: ID of the entity
entity_name: Human-readable name of the entity
description: Description of the action
changes: Dictionary of field changes (optional)
"""
user = request.user if request.user.is_authenticated else None
username = user.username if user else 'Anonymous'
# Get request metadata
ip_address = get_client_ip(request)
user_agent = request.META.get('HTTP_USER_AGENT', '')
session_key = request.session.session_key if hasattr(request, 'session') else ''
# Create audit log entry
audit_entry = AuditLog.objects.create(
user=user,
username=username,
action=action,
entity_type=entity_type,
entity_id=str(entity_id) if entity_id else '',
entity_name=entity_name,
description=description,
changes=changes,
ip_address=ip_address,
user_agent=user_agent[:500], # Truncate to avoid very long user agents
session_key=session_key
)
return audit_entry
def log_create(request, entity_type, entity_id, entity_name, description=None):
"""Log entity creation"""
if not description:
description = f"Neue {entity_type.replace('_', ' ').title()} '{entity_name}' wurde erstellt"
return log_action(
request=request,
action='create',
entity_type=entity_type,
entity_id=entity_id,
entity_name=entity_name,
description=description
)
def log_update(request, entity_type, entity_id, entity_name, changes, description=None):
"""Log entity update with field changes"""
if not description:
changed_fields = list(changes.keys()) if changes else []
fields_str = ", ".join(changed_fields)
description = f"{entity_type.replace('_', ' ').title()} '{entity_name}' wurde aktualisiert: {fields_str}"
return log_action(
request=request,
action='update',
entity_type=entity_type,
entity_id=entity_id,
entity_name=entity_name,
description=description,
changes=changes
)
def log_delete(request, entity_type, entity_id, entity_name, description=None):
"""Log entity deletion"""
if not description:
description = f"{entity_type.replace('_', ' ').title()} '{entity_name}' wurde gelöscht"
return log_action(
request=request,
action='delete',
entity_type=entity_type,
entity_id=entity_id,
entity_name=entity_name,
description=description
)
def log_link(request, entity_type, entity_id, entity_name, target_type, target_name, description=None):
"""Log entity linking"""
if not description:
description = f"{entity_type.replace('_', ' ').title()} '{entity_name}' wurde mit {target_type.replace('_', ' ')} '{target_name}' verknüpft"
return log_action(
request=request,
action='link',
entity_type=entity_type,
entity_id=entity_id,
entity_name=entity_name,
description=description
)
def log_unlink(request, entity_type, entity_id, entity_name, target_type, target_name, description=None):
"""Log entity unlinking"""
if not description:
description = f"Verknüpfung zwischen {entity_type.replace('_', ' ').title()} '{entity_name}' und {target_type.replace('_', ' ')} '{target_name}' wurde entfernt"
return log_action(
request=request,
action='unlink',
entity_type=entity_type,
entity_id=entity_id,
entity_name=entity_name,
description=description
)
def log_system_action(request, action, description, details=None):
"""Log system-level actions like backup, restore, etc."""
return log_action(
request=request,
action=action,
entity_type='system',
entity_id='',
entity_name='System',
description=description,
changes=details
)
def track_model_changes(old_instance, new_instance, exclude_fields=None):
"""
Track changes between model instances
Args:
old_instance: Original model instance
new_instance: Updated model instance
exclude_fields: List of fields to exclude from tracking
Returns:
Dictionary of changes in format {field: {'old': old_value, 'new': new_value}}
"""
if exclude_fields is None:
exclude_fields = ['id', 'erstellt_am', 'aktualisiert_am', 'created_at', 'updated_at']
changes = {}
if old_instance and new_instance:
for field in new_instance._meta.fields:
field_name = field.name
if field_name in exclude_fields:
continue
old_value = getattr(old_instance, field_name, None)
new_value = getattr(new_instance, field_name, None)
# Convert to string for comparison
old_str = str(old_value) if old_value is not None else None
new_str = str(new_value) if new_value is not None else None
if old_str != new_str:
changes[field_name] = {
'old': old_str,
'new': new_str
}
return changes
class AuditLogMixin:
"""
Mixin for views that provides audit logging functionality
"""
audit_entity_type = None
audit_entity_name_field = 'name'
def get_audit_entity_type(self):
"""Get the entity type for audit logging"""
if self.audit_entity_type:
return self.audit_entity_type
# Try to derive from model name
if hasattr(self, 'model') and self.model:
return self.model.__name__.lower()
return 'unknown'
def get_audit_entity_name(self, instance):
"""Get the entity name for audit logging"""
if hasattr(instance, self.audit_entity_name_field):
return str(getattr(instance, self.audit_entity_name_field))
elif hasattr(instance, '__str__'):
return str(instance)
else:
return f"{self.get_audit_entity_type()} #{instance.pk}"
def log_create_action(self, instance):
"""Log creation of an instance"""
log_create(
request=self.request,
entity_type=self.get_audit_entity_type(),
entity_id=instance.pk,
entity_name=self.get_audit_entity_name(instance)
)
def log_update_action(self, old_instance, new_instance):
"""Log update of an instance"""
changes = track_model_changes(old_instance, new_instance)
if changes: # Only log if there are actual changes
log_update(
request=self.request,
entity_type=self.get_audit_entity_type(),
entity_id=new_instance.pk,
entity_name=self.get_audit_entity_name(new_instance),
changes=changes
)
def log_delete_action(self, instance):
"""Log deletion of an instance"""
log_delete(
request=self.request,
entity_type=self.get_audit_entity_type(),
entity_id=instance.pk,
entity_name=self.get_audit_entity_name(instance)
)
# Simple login/logout audit helpers expected by views
def log_login(request, user):
"""Log a successful user login."""
try:
return log_action(
request=request,
action='login',
entity_type='user',
entity_id=user.pk,
entity_name=user.get_username(),
description=f"User '{user.get_username()}' logged in"
)
except Exception:
return None
def log_logout(request, user):
"""Log a successful user logout."""
try:
username = user.get_username() if user else 'Unknown'
return log_action(
request=request,
action='logout',
entity_type='user',
entity_id=getattr(user, 'pk', ''),
entity_name=username,
description=f"User '{username}' logged out"
)
except Exception:
return None

View File

@@ -0,0 +1,320 @@
"""
Backup and Restore Utilities
Handles creation and restoration of complete system backups
"""
import os
import shutil
import subprocess
import tempfile
import tarfile
from datetime import datetime
from django.conf import settings
from django.utils import timezone
from stiftung.models import BackupJob
def get_backup_directory():
"""Get or create the backup directory"""
backup_dir = '/app/backups'
os.makedirs(backup_dir, exist_ok=True)
return backup_dir
def run_backup(backup_job_id):
"""
Run a backup job
This runs in a separate thread to avoid blocking the web interface
"""
try:
backup_job = BackupJob.objects.get(id=backup_job_id)
backup_job.status = 'running'
backup_job.started_at = timezone.now()
backup_job.save()
backup_dir = get_backup_directory()
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
backup_filename = f"stiftung_backup_{timestamp}.tar.gz"
backup_path = os.path.join(backup_dir, backup_filename)
# Create temporary directory for backup staging
with tempfile.TemporaryDirectory() as temp_dir:
staging_dir = os.path.join(temp_dir, 'backup_staging')
os.makedirs(staging_dir)
# 1. Database backup
if backup_job.backup_type in ['full', 'database']:
db_backup_path = create_database_backup(staging_dir)
if not db_backup_path:
raise Exception("Database backup failed")
# 2. Files backup
if backup_job.backup_type in ['full', 'files']:
files_backup_path = create_files_backup(staging_dir)
if not files_backup_path:
raise Exception("Files backup failed")
# 3. Create metadata file
create_backup_metadata(staging_dir, backup_job)
# 4. Create compressed archive
create_compressed_backup(staging_dir, backup_path)
# 5. Update job status
backup_size = os.path.getsize(backup_path)
backup_job.status = 'completed'
backup_job.completed_at = timezone.now()
backup_job.backup_filename = backup_filename
backup_job.backup_size = backup_size
backup_job.save()
except Exception as e:
backup_job.status = 'failed'
backup_job.error_message = str(e)
backup_job.completed_at = timezone.now()
backup_job.save()
def create_database_backup(staging_dir):
"""Create a database backup using pg_dump"""
try:
db_backup_file = os.path.join(staging_dir, 'database.sql')
# Get database settings
db_settings = settings.DATABASES['default']
# Build pg_dump command
cmd = [
'pg_dump',
'--host', db_settings.get('HOST', 'localhost'),
'--port', str(db_settings.get('PORT', 5432)),
'--username', db_settings.get('USER', 'postgres'),
'--format', 'custom',
'--no-owner', # portability across environments
'--no-privileges', # skip GRANT/REVOKE
'--no-password',
'--file', db_backup_file,
db_settings.get('NAME', 'stiftung')
]
# Set environment variables for authentication
env = os.environ.copy()
env['PGPASSWORD'] = db_settings.get('PASSWORD', '')
# Run pg_dump
result = subprocess.run(cmd, env=env, capture_output=True, text=True)
if result.returncode != 0:
raise Exception(f"pg_dump failed: {result.stderr}")
return db_backup_file
except Exception as e:
print(f"Database backup failed: {e}")
return None
def create_files_backup(staging_dir):
"""Create backup of application files"""
try:
files_dir = os.path.join(staging_dir, 'files')
os.makedirs(files_dir)
# Files to backup
backup_paths = [
'/app/media', # User uploads
'/app/static', # Static files
'/app/.env', # Environment configuration
]
for source_path in backup_paths:
if os.path.exists(source_path):
basename = os.path.basename(source_path)
dest_path = os.path.join(files_dir, basename)
if os.path.isdir(source_path):
shutil.copytree(source_path, dest_path)
else:
shutil.copy2(source_path, dest_path)
return files_dir
except Exception as e:
print(f"Files backup failed: {e}")
return None
def create_backup_metadata(staging_dir, backup_job):
"""Create metadata file with backup information"""
import json
metadata = {
'backup_id': str(backup_job.id),
'backup_type': backup_job.backup_type,
'created_at': backup_job.created_at.isoformat(),
'created_by': backup_job.created_by.username if backup_job.created_by else 'system',
'django_version': '5.0.6',
'app_version': '1.0.0',
'python_version': '3.12',
}
metadata_file = os.path.join(staging_dir, 'backup_metadata.json')
with open(metadata_file, 'w') as f:
json.dump(metadata, f, indent=2)
def create_compressed_backup(staging_dir, backup_path):
"""Create compressed tar.gz archive"""
with tarfile.open(backup_path, 'w:gz') as tar:
tar.add(staging_dir, arcname='.')
def run_restore(restore_job_id, backup_file_path):
"""
Run a restore job
This runs in a separate thread
"""
try:
restore_job = BackupJob.objects.get(id=restore_job_id)
restore_job.status = 'running'
restore_job.started_at = timezone.now()
restore_job.save()
# Extract backup
with tempfile.TemporaryDirectory() as temp_dir:
extract_dir = os.path.join(temp_dir, 'restore')
os.makedirs(extract_dir)
# Extract tar.gz
with tarfile.open(backup_file_path, 'r:gz') as tar:
tar.extractall(extract_dir)
# Validate backup
metadata_file = os.path.join(extract_dir, 'backup_metadata.json')
if not os.path.exists(metadata_file):
raise Exception("Invalid backup: missing metadata")
# Read metadata
import json
with open(metadata_file, 'r') as f:
metadata = json.load(f)
# Restore database
db_backup_file = os.path.join(extract_dir, 'database.sql')
if os.path.exists(db_backup_file):
restore_database(db_backup_file)
# Restore files
files_dir = os.path.join(extract_dir, 'files')
if os.path.exists(files_dir):
restore_files(files_dir)
# Update job status
restore_job.status = 'completed'
restore_job.completed_at = timezone.now()
restore_job.save()
except Exception as e:
restore_job.status = 'failed'
restore_job.error_message = str(e)
restore_job.completed_at = timezone.now()
restore_job.save()
def restore_database(db_backup_file):
"""Restore database from backup"""
try:
# Get database settings
db_settings = settings.DATABASES['default']
# Build pg_restore command
cmd = [
'pg_restore',
'--host', db_settings.get('HOST', 'localhost'),
'--port', str(db_settings.get('PORT', 5432)),
'--username', db_settings.get('USER', 'postgres'),
'--dbname', db_settings.get('NAME', 'stiftung'),
'--clean', # Drop existing objects first
'--if-exists', # Don't error if objects don't exist
'--no-owner', # don't attempt to set original owners
'--role', db_settings.get('USER', 'postgres'), # set target owner
'--single-transaction', # restore atomically when possible
'--disable-triggers', # avoid FK issues during data load
'--no-password',
'--verbose',
db_backup_file
]
# Set environment variables for authentication
env = os.environ.copy()
env['PGPASSWORD'] = db_settings.get('PASSWORD', '')
# Run pg_restore
result = subprocess.run(cmd, env=env, capture_output=True, text=True)
# Fail if there are real errors
if result.returncode != 0:
stderr = result.stderr or ''
# escalate only if we see ERROR
if 'ERROR' in stderr.upper():
raise Exception(f"pg_restore failed: {stderr}")
else:
print(f"pg_restore completed with warnings: {stderr}")
except Exception as e:
raise Exception(f"Database restore failed: {e}")
def restore_files(files_dir):
"""Restore application files"""
try:
# Restore paths
restore_mappings = {
'media': '/app/media',
'static': '/app/static',
'.env': '/app/.env',
}
for source_name, dest_path in restore_mappings.items():
source_path = os.path.join(files_dir, source_name)
if os.path.exists(source_path):
# Backup existing files first
if os.path.exists(dest_path):
backup_path = f"{dest_path}.backup.{datetime.now().strftime('%Y%m%d_%H%M%S')}"
if os.path.isdir(dest_path):
shutil.move(dest_path, backup_path)
else:
shutil.copy2(dest_path, backup_path)
# Restore files
if os.path.isdir(source_path):
shutil.copytree(source_path, dest_path)
else:
shutil.copy2(source_path, dest_path)
except Exception as e:
raise Exception(f"Files restore failed: {e}")
def cleanup_old_backups(keep_count=10):
"""Clean up old backup files, keeping only the newest ones"""
try:
backup_dir = get_backup_directory()
backup_files = []
for filename in os.listdir(backup_dir):
if filename.startswith('stiftung_backup_') and filename.endswith('.tar.gz'):
filepath = os.path.join(backup_dir, filename)
backup_files.append((filepath, os.path.getmtime(filepath)))
# Sort by modification time (newest first)
backup_files.sort(key=lambda x: x[1], reverse=True)
# Remove old backups
for filepath, _ in backup_files[keep_count:]:
os.remove(filepath)
print(f"Removed old backup: {os.path.basename(filepath)}")
except Exception as e:
print(f"Cleanup failed: {e}")

801
app/stiftung/forms.py Normal file
View File

@@ -0,0 +1,801 @@
from django import forms
from django.core.exceptions import ValidationError
from .models import (
Rentmeister, StiftungsKonto, Verwaltungskosten, Person,
Paechter, Destinataer, Land, Verpachtung, DokumentLink, Foerderung, BankTransaction,
DestinataerUnterstuetzung, DestinataerNotiz, LandAbrechnung,
)
import re
class RentmeisterForm(forms.ModelForm):
"""Form für das Erstellen und Bearbeiten von Rentmeistern"""
class Meta:
model = Rentmeister
fields = [
'anrede', 'vorname', 'nachname', 'titel',
'email', 'telefon', 'mobil',
'strasse', 'plz', 'ort',
'iban', 'bic', 'bank_name',
'seit_datum', 'bis_datum', 'aktiv',
'monatliche_verguetung', 'km_pauschale',
'notizen'
]
widgets = {
'anrede': forms.Select(attrs={'class': 'form-select'}),
'vorname': forms.TextInput(attrs={'class': 'form-control'}),
'nachname': forms.TextInput(attrs={'class': 'form-control'}),
'titel': forms.TextInput(attrs={'class': 'form-control'}),
'email': forms.EmailInput(attrs={'class': 'form-control'}),
'telefon': forms.TextInput(attrs={'class': 'form-control'}),
'mobil': forms.TextInput(attrs={'class': 'form-control'}),
'strasse': forms.TextInput(attrs={'class': 'form-control'}),
'plz': forms.TextInput(attrs={'class': 'form-control'}),
'ort': forms.TextInput(attrs={'class': 'form-control'}),
'iban': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'DE89370400440532013000'}),
'bic': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'COBADEFFXXX'}),
'bank_name': forms.TextInput(attrs={'class': 'form-control'}),
'seit_datum': forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}),
'bis_datum': forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}),
'aktiv': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'monatliche_verguetung': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}),
'km_pauschale': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01', 'value': '0.30'}),
'notizen': forms.Textarea(attrs={'class': 'form-control', 'rows': 4}),
}
labels = {
'anrede': 'Anrede',
'vorname': 'Vorname *',
'nachname': 'Nachname *',
'titel': 'Titel',
'email': 'E-Mail',
'telefon': 'Telefon',
'mobil': 'Mobil',
'strasse': 'Straße',
'plz': 'PLZ',
'ort': 'Ort',
'iban': 'IBAN',
'bic': 'BIC',
'bank_name': 'Bank',
'seit_datum': 'Rentmeister seit *',
'bis_datum': 'Rentmeister bis',
'aktiv': 'Aktiv',
'monatliche_verguetung': 'Monatliche Vergütung (€)',
'km_pauschale': 'Kilometerpauschale (€/km)',
'notizen': 'Notizen',
}
help_texts = {
'iban': 'Internationale Bankkontonummer für Abrechnungen',
'km_pauschale': 'Standard: 0,30 € pro Kilometer',
'seit_datum': 'Datum des Amtsantritts als Rentmeister',
'bis_datum': 'Leer lassen für aktive Rentmeister',
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Markiere Pflichtfelder
self.fields['vorname'].required = True
self.fields['nachname'].required = True
self.fields['seit_datum'].required = True
def clean_iban(self):
"""Validierung der IBAN"""
iban = self.cleaned_data.get('iban')
if iban:
# Entferne Leerzeichen und konvertiere zu Großbuchstaben
iban = re.sub(r'\s+', '', iban.upper())
# Einfache IBAN-Längenvalidierung für deutsche IBANs
if iban.startswith('DE') and len(iban) != 22:
raise ValidationError('Deutsche IBANs müssen 22 Zeichen lang sein.')
# Speichere die bereinigte IBAN
return iban
return iban
def clean_plz(self):
"""Validierung der PLZ"""
plz = self.cleaned_data.get('plz')
if plz and not re.match(r'^\d{5}$', plz):
raise ValidationError('PLZ muss aus 5 Ziffern bestehen.')
return plz
def clean(self):
"""Übergreifende Validierung"""
cleaned_data = super().clean()
seit_datum = cleaned_data.get('seit_datum')
bis_datum = cleaned_data.get('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.')
return cleaned_data
class StiftungsKontoForm(forms.ModelForm):
"""Form für das Erstellen und Bearbeiten von Stiftungskonten"""
class Meta:
model = StiftungsKonto
fields = [
'kontoname', 'bank_name', 'iban', 'bic', 'konto_typ',
'saldo', 'saldo_datum', 'zinssatz', 'laufzeit_bis',
'aktiv', 'notizen'
]
widgets = {
'kontoname': forms.TextInput(attrs={'class': 'form-control'}),
'bank_name': forms.TextInput(attrs={'class': 'form-control'}),
'iban': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'DE89370400440532013000'}),
'bic': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'COBADEFFXXX'}),
'konto_typ': forms.Select(attrs={'class': 'form-select'}),
'saldo': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}),
'saldo_datum': forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}),
'zinssatz': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}),
'laufzeit_bis': forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}),
'aktiv': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'notizen': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
}
class VerwaltungskostenForm(forms.ModelForm):
"""Form für das Erstellen und Bearbeiten von Verwaltungskosten"""
class Meta:
model = Verwaltungskosten
fields = [
'bezeichnung', 'kategorie', 'betrag', 'datum', 'status',
'rentmeister', 'zahlungskonto', 'quellkonto', 'lieferant_firma', 'rechnungsnummer',
'km_anzahl', 'km_satz', 'von_ort', 'nach_ort', 'zweck',
'beschreibung', 'notizen'
]
widgets = {
'bezeichnung': forms.TextInput(attrs={'class': 'form-control'}),
'kategorie': forms.Select(attrs={'class': 'form-select'}),
'betrag': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}),
'datum': forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}),
'status': forms.Select(attrs={'class': 'form-select'}),
'rentmeister': forms.Select(attrs={'class': 'form-select'}),
'zahlungskonto': forms.Select(attrs={'class': 'form-select'}),
'quellkonto': forms.Select(attrs={'class': 'form-select'}),
'lieferant_firma': forms.TextInput(attrs={'class': 'form-control'}),
'rechnungsnummer': forms.TextInput(attrs={'class': 'form-control'}),
'km_anzahl': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.1'}),
'km_satz': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}),
'von_ort': forms.TextInput(attrs={'class': 'form-control'}),
'nach_ort': forms.TextInput(attrs={'class': 'form-control'}),
'zweck': forms.TextInput(attrs={'class': 'form-control'}),
'beschreibung': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
'notizen': forms.Textarea(attrs={'class': 'form-control', 'rows': 2}),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Filtere nur aktive Rentmeister und Konten
self.fields['rentmeister'].queryset = Rentmeister.objects.filter(aktiv=True)
self.fields['zahlungskonto'].queryset = StiftungsKonto.objects.filter(aktiv=True)
self.fields['quellkonto'].queryset = StiftungsKonto.objects.filter(aktiv=True)
# Standardwerte setzen
if not self.instance.pk: # Nur bei neuen Objekten
# Standard km_satz auf 0.30 Euro setzen
self.fields['km_satz'].initial = 0.30
class PersonForm(forms.ModelForm):
"""Form für das Erstellen und Bearbeiten von Personen (Legacy)"""
class Meta:
model = Person
fields = [
'familienzweig', 'vorname', 'nachname', 'geburtsdatum',
'email', 'telefon', 'iban', 'adresse', 'notizen', 'aktiv'
]
widgets = {
'familienzweig': forms.Select(attrs={'class': 'form-select'}),
'vorname': forms.TextInput(attrs={'class': 'form-control'}),
'nachname': forms.TextInput(attrs={'class': 'form-control'}),
'geburtsdatum': forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}),
'email': forms.EmailInput(attrs={'class': 'form-control'}),
'telefon': forms.TextInput(attrs={'class': 'form-control'}),
'iban': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'DE89370400440532013000'}),
'adresse': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
'notizen': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
'aktiv': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
}
labels = {
'familienzweig': 'Familienzweig',
'vorname': 'Vorname *',
'nachname': 'Nachname *',
'geburtsdatum': 'Geburtsdatum',
'email': 'E-Mail',
'telefon': 'Telefon',
'iban': 'IBAN',
'adresse': 'Adresse',
'notizen': 'Notizen',
'aktiv': 'Aktiv',
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Markiere Pflichtfelder
self.fields['vorname'].required = True
self.fields['nachname'].required = True
class PaechterForm(forms.ModelForm):
"""Form für das Erstellen und Bearbeiten von Pächtern"""
class Meta:
model = Paechter
fields = '__all__'
widgets = {
'anrede': forms.Select(attrs={'class': 'form-select'}),
'vorname': forms.TextInput(attrs={'class': 'form-control'}),
'nachname': forms.TextInput(attrs={'class': 'form-control'}),
'email': forms.EmailInput(attrs={'class': 'form-control'}),
'telefon': forms.TextInput(attrs={'class': 'form-control'}),
'mobil': forms.TextInput(attrs={'class': 'form-control'}),
'geburtsdatum': forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}),
'strasse': forms.TextInput(attrs={'class': 'form-control'}),
'plz': forms.TextInput(attrs={'class': 'form-control'}),
'ort': forms.TextInput(attrs={'class': 'form-control'}),
'aktiv': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'notizen': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
}
class DestinataerForm(forms.ModelForm):
"""Form für das Erstellen und Bearbeiten von Destinatären"""
class Meta:
model = Destinataer
fields = '__all__'
widgets = {
'anrede': forms.Select(attrs={'class': 'form-select'}),
'vorname': forms.TextInput(attrs={'class': 'form-control'}),
'nachname': forms.TextInput(attrs={'class': 'form-control'}),
'titel': forms.TextInput(attrs={'class': 'form-control'}),
'strasse': forms.TextInput(attrs={'class': 'form-control'}),
'plz': forms.TextInput(attrs={'class': 'form-control'}),
'ort': forms.TextInput(attrs={'class': 'form-control'}),
'telefon': forms.TextInput(attrs={'class': 'form-control'}),
'mobil': forms.TextInput(attrs={'class': 'form-control'}),
'email': forms.EmailInput(attrs={'class': 'form-control'}),
'aktiv': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'notizen': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
'ist_abkoemmling': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'haushaltsgroesse': forms.NumberInput(attrs={'class': 'form-control', 'min': 1}),
# renamed in UI: use vierteljaehrlicher_betrag field
'vermoegen': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}),
'unterstuetzung_bestaetigt': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'standard_konto': forms.Select(attrs={'class': 'form-select'}),
'vierteljaehrlicher_betrag': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}),
'studiennachweis_erforderlich': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'letzter_studiennachweis': forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}),
}
class LandForm(forms.ModelForm):
"""Form für das Erstellen und Bearbeiten von Ländern"""
class Meta:
model = Land
fields = [
# Grundlegende Identifikation
'lfd_nr', 'ew_nummer', 'grundbuchblatt',
# Gerichtliche Zuständigkeit
'amtsgericht',
# Verwaltungsstruktur
'gemeinde', 'gemarkung', 'flur', 'flurstueck', 'adresse',
# Flächenangaben
'groesse_qm', 'gruenland_qm', 'acker_qm', 'wald_qm', 'sonstiges_qm',
# Legacy Verpachtung (für Kompatibilität)
'verpachtete_gesamtflaeche', 'flaeche_alte_liste', 'verp_flaeche_aktuell',
# Aktuelle Verpachtung
'aktueller_paechter', 'paechter_name', 'paechter_anschrift',
'pachtbeginn', 'pachtende', 'verlaengerung_klausel',
'zahlungsweise', 'pachtzins_pro_ha', 'pachtzins_pauschal',
# Umsatzsteuer
'ust_option', 'ust_satz',
# Umlagen
'grundsteuer_umlage', 'versicherungen_umlage', 'verbandsbeitraege_umlage', 'jagdpacht_anteil_umlage',
# Legacy Steuern
'anteil_grundsteuer', 'anteil_lwk',
# Status
'aktiv', 'notizen',
]
widgets = {
# Grundlegende Identifikation
'lfd_nr': forms.TextInput(attrs={'class': 'form-control'}),
'ew_nummer': forms.TextInput(attrs={'class': 'form-control'}),
'grundbuchblatt': forms.TextInput(attrs={'class': 'form-control'}),
# Gerichtliche Zuständigkeit
'amtsgericht': forms.TextInput(attrs={'class': 'form-control'}),
# Verwaltungsstruktur
'gemeinde': forms.TextInput(attrs={'class': 'form-control'}),
'gemarkung': forms.TextInput(attrs={'class': 'form-control'}),
'flur': forms.TextInput(attrs={'class': 'form-control'}),
'flurstueck': forms.TextInput(attrs={'class': 'form-control'}),
'adresse': forms.TextInput(attrs={'class': 'form-control'}),
# Flächenangaben
'groesse_qm': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}),
'gruenland_qm': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}),
'acker_qm': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}),
'wald_qm': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}),
'sonstiges_qm': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}),
# Legacy Verpachtung
'verpachtete_gesamtflaeche': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}),
'flaeche_alte_liste': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}),
'verp_flaeche_aktuell': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}),
# Aktuelle Verpachtung
'aktueller_paechter': forms.Select(attrs={'class': 'form-select'}),
'paechter_name': forms.TextInput(attrs={'class': 'form-control'}),
'paechter_anschrift': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
'pachtbeginn': forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}),
'pachtende': forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}),
'verlaengerung_klausel': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'zahlungsweise': forms.Select(attrs={'class': 'form-select'}),
'pachtzins_pro_ha': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}),
'pachtzins_pauschal': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}),
# Umsatzsteuer
'ust_option': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'ust_satz': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}),
# Umlagen
'grundsteuer_umlage': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'versicherungen_umlage': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'verbandsbeitraege_umlage': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'jagdpacht_anteil_umlage': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
# Legacy
'anteil_grundsteuer': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}),
'anteil_lwk': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}),
# Status
'aktiv': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'notizen': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
}
class LandAbrechnungForm(forms.ModelForm):
"""Form für das Erstellen und Bearbeiten von Landabrechnungen"""
class Meta:
model = LandAbrechnung
fields = [
'land', 'abrechnungsjahr',
# Einnahmen
'pacht_vereinnahmt', 'umlagen_vereinnahmt', 'sonstige_einnahmen',
# Ausgaben
'grundsteuer_bescheid_nr', 'grundsteuer_betrag',
'versicherungen_betrag', 'verbandsbeitraege_betrag',
'sonstige_abgaben_betrag', 'instandhaltung_betrag', 'verwaltung_recht_betrag',
# Umsatzsteuer
'vorsteuer_aus_umlagen',
# Sonstiges
'offene_posten', 'bemerkungen',
# Dokumente werden über Paperless verknüpft, nicht hochgeladen
]
widgets = {
'land': forms.Select(attrs={'class': 'form-select'}),
'abrechnungsjahr': forms.NumberInput(attrs={'class': 'form-control', 'min': '2000', 'max': '2050'}),
# Einnahmen
'pacht_vereinnahmt': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}),
'umlagen_vereinnahmt': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}),
'sonstige_einnahmen': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}),
# Ausgaben
'grundsteuer_bescheid_nr': forms.TextInput(attrs={'class': 'form-control'}),
'grundsteuer_betrag': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}),
'versicherungen_betrag': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}),
'verbandsbeitraege_betrag': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}),
'sonstige_abgaben_betrag': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}),
'instandhaltung_betrag': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}),
'verwaltung_recht_betrag': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}),
# Umsatzsteuer
'vorsteuer_aus_umlagen': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}),
# Sonstiges
'offene_posten': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}),
'bemerkungen': forms.Textarea(attrs={'class': 'form-control', 'rows': 4}),
}
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"""
class Meta:
model = DokumentLink
fields = '__all__'
widgets = {
'paperless_id': forms.NumberInput(attrs={'class': 'form-control'}),
'content_type': forms.Select(attrs={'class': 'form-select'}),
'object_id': forms.TextInput(attrs={'class': 'form-control'}),
'verknuepft_am': forms.DateTimeInput(attrs={'class': 'form-control', 'type': 'datetime-local'}),
}
class FoerderungForm(forms.ModelForm):
"""Form für das Erstellen und Bearbeiten von Förderungen"""
class Meta:
model = Foerderung
fields = '__all__'
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}),
}
class BankTransactionForm(forms.ModelForm):
"""Form für das Bearbeiten von Banktransaktionen"""
class Meta:
model = BankTransaction
fields = [
'konto', 'datum', 'valuta', 'betrag', 'waehrung',
'verwendungszweck', 'empfaenger_zahlungspflichtiger',
'iban_gegenpartei', 'bic_gegenpartei', 'transaction_type',
'status', 'kommentare', 'verwaltungskosten'
]
widgets = {
'konto': forms.Select(attrs={'class': 'form-select'}),
'datum': forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}),
'valuta': forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}),
'betrag': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}),
'waehrung': forms.TextInput(attrs={'class': 'form-control'}),
'verwendungszweck': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
'empfaenger_zahlungspflichtiger': forms.TextInput(attrs={'class': 'form-control'}),
'iban_gegenpartei': forms.TextInput(attrs={'class': 'form-control'}),
'bic_gegenpartei': forms.TextInput(attrs={'class': 'form-control'}),
'transaction_type': forms.Select(attrs={'class': 'form-select'}),
'status': forms.Select(attrs={'class': 'form-select'}),
'kommentare': forms.Textarea(attrs={'class': 'form-control', 'rows': 4}),
'verwaltungskosten': forms.Select(attrs={'class': 'form-select'}),
}
class DestinataerUnterstuetzungForm(forms.ModelForm):
"""Form für geplante/ausgeführte Destinatärunterstützungen"""
class Meta:
model = DestinataerUnterstuetzung
fields = ['destinataer', 'konto', 'betrag', 'faellig_am', 'status', 'beschreibung']
widgets = {
'destinataer': forms.Select(attrs={'class': 'form-select'}),
'konto': forms.Select(attrs={'class': 'form-select'}),
'betrag': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}),
'faellig_am': forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}),
'status': forms.Select(attrs={'class': 'form-select'}),
'beschreibung': forms.TextInput(attrs={'class': 'form-control'}),
}
class DestinataerNotizForm(forms.ModelForm):
class Meta:
model = DestinataerNotiz
fields = ['titel', 'text', 'datei']
widgets = {
'titel': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'z.B. Telefonat vom 29.08.2025'}),
'text': forms.Textarea(attrs={'class': 'form-control', 'rows': 5, 'placeholder': 'Notiztext...'}),
'datei': forms.ClearableFileInput(attrs={'class': 'form-control'}),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Make all fields optional
self.fields['datei'].required = False
self.fields['titel'].required = False
self.fields['text'].required = False
def clean(self):
cleaned = super().clean()
titel = cleaned.get('titel', '').strip()
text = cleaned.get('text', '').strip()
if not (titel or text):
raise forms.ValidationError('Bitte geben Sie einen Titel oder einen Text ein.')
return cleaned
class BankImportForm(forms.Form):
"""Form für den Import von Bankdaten"""
konto = forms.ModelChoiceField(
queryset=StiftungsKonto.objects.filter(aktiv=True),
widget=forms.Select(attrs={'class': 'form-select'}),
label="Zielkonto"
)
datei = forms.FileField(
widget=forms.FileInput(attrs={'class': 'form-control', 'accept': '.csv,.txt'}),
label="Bankdatei",
help_text="Unterstützte Formate: CSV, TXT (Sparkasse, Volksbank, etc.)"
)
encoding = forms.ChoiceField(
choices=[
('utf-8', 'UTF-8'),
('latin1', 'Latin-1 / ISO-8859-1'),
('cp1252', 'Windows-1252'),
],
initial='utf-8',
widget=forms.Select(attrs={'class': 'form-select'}),
label="Zeichenkodierung"
)
delimiter = forms.ChoiceField(
choices=[
(';', 'Semikolon (;)'),
(',', 'Komma (,)'),
('\t', 'Tab'),
],
initial=';',
widget=forms.Select(attrs={'class': 'form-select'}),
label="Trennzeichen"
)
skip_header = forms.BooleanField(
initial=True,
required=False,
widget=forms.CheckboxInput(attrs={'class': 'form-check-input'}),
label="Erste Zeile überspringen (Spaltenüberschriften)"
)
# =============================================================================
# USER MANAGEMENT FORMS
# =============================================================================
class UserCreationForm(forms.Form):
"""Form für die Erstellung neuer Benutzer"""
username = forms.CharField(
label="Benutzername",
max_length=150,
help_text="Eindeutiger Benutzername für die Anmeldung",
widget=forms.TextInput(attrs={'class': 'form-control'})
)
email = forms.EmailField(
label="E-Mail-Adresse",
help_text="E-Mail-Adresse des Benutzers",
widget=forms.EmailInput(attrs={'class': 'form-control'})
)
first_name = forms.CharField(
label="Vorname",
max_length=30,
required=False,
widget=forms.TextInput(attrs={'class': 'form-control'})
)
last_name = forms.CharField(
label="Nachname",
max_length=150,
required=False,
widget=forms.TextInput(attrs={'class': 'form-control'})
)
password1 = forms.CharField(
label="Passwort",
widget=forms.PasswordInput(attrs={'class': 'form-control'}),
help_text="Mindestens 8 Zeichen"
)
password2 = forms.CharField(
label="Passwort bestätigen",
widget=forms.PasswordInput(attrs={'class': 'form-control'}),
help_text="Geben Sie das Passwort zur Bestätigung erneut ein"
)
is_active = forms.BooleanField(
label="Aktiv",
required=False,
initial=True,
help_text="Benutzer kann sich anmelden",
widget=forms.CheckboxInput(attrs={'class': 'form-check-input'})
)
is_staff = forms.BooleanField(
label="Staff-Status",
required=False,
help_text="Benutzer kann auf Django Admin zugreifen",
widget=forms.CheckboxInput(attrs={'class': 'form-check-input'})
)
def clean_username(self):
username = self.cleaned_data['username']
from django.contrib.auth.models import User
if User.objects.filter(username=username).exists():
raise forms.ValidationError("Ein Benutzer mit diesem Namen existiert bereits.")
return username
def clean_email(self):
email = self.cleaned_data['email']
from django.contrib.auth.models import User
if User.objects.filter(email=email).exists():
raise forms.ValidationError("Ein Benutzer mit dieser E-Mail-Adresse existiert bereits.")
return email
def clean(self):
cleaned_data = super().clean()
password1 = cleaned_data.get("password1")
password2 = cleaned_data.get("password2")
if password1 and password2:
if password1 != password2:
raise forms.ValidationError("Die Passwörter stimmen nicht überein.")
if len(password1) < 8:
raise forms.ValidationError("Das Passwort muss mindestens 8 Zeichen lang sein.")
return cleaned_data
class UserUpdateForm(forms.ModelForm):
"""Form für die Bearbeitung bestehender Benutzer"""
class Meta:
from django.contrib.auth.models import User
model = User
fields = ['username', 'email', 'first_name', 'last_name', 'is_active', 'is_staff']
widgets = {
'username': forms.TextInput(attrs={'class': 'form-control'}),
'email': forms.EmailInput(attrs={'class': 'form-control'}),
'first_name': forms.TextInput(attrs={'class': 'form-control'}),
'last_name': forms.TextInput(attrs={'class': 'form-control'}),
'is_active': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'is_staff': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
}
labels = {
'username': 'Benutzername',
'email': 'E-Mail-Adresse',
'first_name': 'Vorname',
'last_name': 'Nachname',
'is_active': 'Aktiv',
'is_staff': 'Staff-Status',
}
help_texts = {
'username': 'Eindeutiger Benutzername für die Anmeldung',
'email': 'E-Mail-Adresse des Benutzers',
'is_active': 'Benutzer kann sich anmelden',
'is_staff': 'Benutzer kann auf Django Admin zugreifen',
}
class PasswordChangeForm(forms.Form):
"""Form für Passwort-Änderungen"""
new_password1 = forms.CharField(
label="Neues Passwort",
widget=forms.PasswordInput(attrs={'class': 'form-control'}),
help_text="Mindestens 8 Zeichen"
)
new_password2 = forms.CharField(
label="Neues Passwort bestätigen",
widget=forms.PasswordInput(attrs={'class': 'form-control'}),
help_text="Geben Sie das neue Passwort zur Bestätigung erneut ein"
)
def clean(self):
cleaned_data = super().clean()
password1 = cleaned_data.get("new_password1")
password2 = cleaned_data.get("new_password2")
if password1 and password2:
if password1 != password2:
raise forms.ValidationError("Die Passwörter stimmen nicht überein.")
if len(password1) < 8:
raise forms.ValidationError("Das Passwort muss mindestens 8 Zeichen lang sein.")
return cleaned_data
class UserPermissionForm(forms.Form):
"""Form für die Zuweisung von Berechtigungen"""
def __init__(self, *args, **kwargs):
user = kwargs.pop('user', None)
super().__init__(*args, **kwargs)
from django.contrib.auth.models import Permission
# Get all custom permissions for stiftung app
app_permissions = Permission.objects.filter(content_type__app_label='stiftung').order_by('name')
# Create checkbox fields for each permission
for perm in app_permissions:
field_name = f'perm_{perm.id}'
self.fields[field_name] = forms.BooleanField(
label=perm.name,
required=False,
widget=forms.CheckboxInput(attrs={'class': 'form-check-input'})
)
# Set initial values if user is provided
if user:
self.fields[field_name].initial = user.has_perm(f'stiftung.{perm.codename}')
def get_permission_groups(self):
"""Group permissions by functionality for template rendering"""
from django.contrib.auth.models import Permission
groups = {
'entities': {
'name': 'Entitäten verwalten',
'permissions': [],
'icon': 'fas fa-users'
},
'documents': {
'name': 'Dokumentenverwaltung',
'permissions': [],
'icon': 'fas fa-folder-open'
},
'financial': {
'name': 'Finanzverwaltung',
'permissions': [],
'icon': 'fas fa-euro-sign'
},
'administration': {
'name': 'Administration',
'permissions': [],
'icon': 'fas fa-cogs'
},
'system': {
'name': 'System',
'permissions': [],
'icon': 'fas fa-server'
}
}
# Get all permissions to properly categorize them
for field_name, field in self.fields.items():
if field_name.startswith('perm_'):
# Extract permission ID from field name
perm_id = field_name.replace('perm_', '')
try:
permission = Permission.objects.get(id=perm_id)
label = permission.name.lower()
codename = permission.codename.lower()
# More precise categorization based on both name and codename
if any(word in codename for word in ['destinataer', 'land', 'paechter', 'verpachtung', 'foerderung']) and 'manage_' in codename or 'view_' in codename:
groups['entities']['permissions'].append((field_name, field, permission))
elif any(word in codename for word in ['documents', 'link_documents']) or 'dokument' in label:
groups['documents']['permissions'].append((field_name, field, permission))
elif any(word in codename for word in ['verwaltungskosten', 'konten', 'rentmeister', 'approve_payments']) or any(word in label for word in ['verwaltungskosten', 'konto', 'rentmeister', 'zahlung']):
groups['financial']['permissions'].append((field_name, field, permission))
elif any(word in codename for word in ['administration', 'audit', 'backup', 'manage_users', 'manage_permissions']) or any(word in label for word in ['administration', 'audit', 'backup', 'benutzer', 'berechtigung']):
groups['administration']['permissions'].append((field_name, field, permission))
else:
groups['system']['permissions'].append((field_name, field, permission))
except Permission.DoesNotExist:
# Fallback for permissions that don't exist
groups['system']['permissions'].append((field_name, field, None))
return groups

View File

@@ -0,0 +1 @@
# Management package

View File

@@ -0,0 +1 @@
# Management commands package

View File

@@ -0,0 +1,92 @@
from django.core.management.base import BaseCommand
from django.db import transaction
from stiftung.models import Land, Verpachtung, Paechter
import logging
logger = logging.getLogger(__name__)
class Command(BaseCommand):
help = 'Migriert bestehende Verpachtungen in die neue Land-Struktur'
def add_arguments(self, parser):
parser.add_argument(
'--dry-run',
action='store_true',
help='Zeigt nur an, was gemacht würde, ohne Änderungen zu speichern',
)
def handle(self, *args, **options):
dry_run = options['dry_run']
if dry_run:
self.stdout.write(self.style.WARNING('DRY RUN - Keine Änderungen werden gespeichert!'))
# Alle aktiven Verpachtungen finden
aktive_verpachtungen = Verpachtung.objects.filter(status='aktiv')
self.stdout.write(f'Gefunden: {aktive_verpachtungen.count()} aktive Verpachtungen')
migrated_count = 0
skipped_count = 0
with transaction.atomic():
for verpachtung in aktive_verpachtungen:
land = verpachtung.land
# Prüfen ob bereits migriert
if land.aktueller_paechter is not None:
self.stdout.write(
self.style.WARNING(
f'Übersprungen: {land} hat bereits einen aktuellen Pächter'
)
)
skipped_count += 1
continue
# Migration durchführen
self.stdout.write(f'Migriere: {land} -> {verpachtung.paechter}')
if not dry_run:
# Pächter-Daten ins Land übertragen
land.aktueller_paechter = verpachtung.paechter
land.paechter_name = verpachtung.paechter.get_full_name()
land.paechter_anschrift = self._get_paechter_anschrift(verpachtung.paechter)
land.pachtbeginn = verpachtung.pachtbeginn
land.pachtende = verpachtung.pachtende
land.verlaengerung_klausel = bool(verpachtung.verlaengerung)
# Pachtzins übertragen
land.pachtzins_pauschal = verpachtung.pachtzins_jaehrlich
# Verpachtete Fläche aktualisieren (falls nicht gesetzt)
if land.verp_flaeche_aktuell == 0:
land.verp_flaeche_aktuell = verpachtung.verpachtete_flaeche
land.save()
migrated_count += 1
if dry_run:
self.stdout.write(
self.style.SUCCESS(
f'DRY RUN abgeschlossen: {migrated_count} Verpachtungen würden migriert, {skipped_count} übersprungen'
)
)
else:
self.stdout.write(
self.style.SUCCESS(
f'Migration abgeschlossen: {migrated_count} Verpachtungen migriert, {skipped_count} übersprungen'
)
)
def _get_paechter_anschrift(self, paechter):
"""Erstellt eine Anschrift aus den Pächter-Daten"""
parts = []
if paechter.strasse:
parts.append(paechter.strasse)
if paechter.plz and paechter.ort:
parts.append(f"{paechter.plz} {paechter.ort}")
elif paechter.ort:
parts.append(paechter.ort)
return '\n'.join(parts) if parts else ''

View File

@@ -0,0 +1,119 @@
from django.core.management.base import BaseCommand
from django.db import transaction
from stiftung.models import Land, Verpachtung, Paechter, LandAbrechnung
from datetime import datetime
import logging
logger = logging.getLogger(__name__)
class Command(BaseCommand):
help = 'Vereinheitlicht Verpachtungen, Land und Abrechnungen zu einem konsistenten System'
def add_arguments(self, parser):
parser.add_argument(
'--dry-run',
action='store_true',
help='Zeigt nur an, was gemacht würde, ohne Änderungen zu speichern',
)
parser.add_argument(
'--create-abrechnungen',
action='store_true',
help='Erstellt automatisch Abrechnungen aus Verpachtungsdaten',
)
def handle(self, *args, **options):
dry_run = options['dry_run']
create_abrechnungen = options['create_abrechnungen']
if dry_run:
self.stdout.write(self.style.WARNING('DRY RUN - Keine Änderungen werden gespeichert!'))
# Schritt 1: Alle Verpachtungen analysieren
alle_verpachtungen = Verpachtung.objects.all().order_by('land', '-pachtbeginn')
self.stdout.write(f'Gefunden: {alle_verpachtungen.count()} Verpachtungen insgesamt')
land_updates = 0
abrechnungen_created = 0
with transaction.atomic():
current_land = None
for verpachtung in alle_verpachtungen:
land = verpachtung.land
# Für jedes Land nur die neueste aktive Verpachtung als "aktuell" setzen
if current_land != land:
current_land = land
# Prüfen ob dies die neueste aktive Verpachtung ist
if verpachtung.status == 'aktiv' and not land.aktueller_paechter:
self.stdout.write(f'Setze aktuelle Verpachtung: {land} -> {verpachtung.paechter}')
if not dry_run:
# Land-Felder aktualisieren
land.aktueller_paechter = verpachtung.paechter
land.paechter_name = verpachtung.paechter.get_full_name()
land.paechter_anschrift = self._get_paechter_anschrift(verpachtung.paechter)
land.pachtbeginn = verpachtung.pachtbeginn
land.pachtende = verpachtung.pachtende
land.verlaengerung_klausel = bool(verpachtung.verlaengerung)
land.pachtzins_pauschal = verpachtung.pachtzins_jaehrlich
# Verpachtete Fläche synchronisieren
land.verp_flaeche_aktuell = verpachtung.verpachtete_flaeche
land.save()
land_updates += 1
# Schritt 2: Abrechnungen aus Verpachtungen erstellen (optional)
if create_abrechnungen and verpachtung.status == 'aktiv':
# Erstelle Abrechnungen für die letzten 3 Jahre
current_year = datetime.now().year
for jahr in range(current_year - 2, current_year + 1):
# Prüfen ob Abrechnung bereits existiert
existing = LandAbrechnung.objects.filter(
land=land,
abrechnungsjahr=jahr
).first()
if not existing:
self.stdout.write(f'Erstelle Abrechnung: {land} - {jahr}')
if not dry_run:
abrechnung = LandAbrechnung.objects.create(
land=land,
abrechnungsjahr=jahr,
pacht_vereinnahmt=verpachtung.pachtzins_jaehrlich,
bemerkungen=f'Automatisch erstellt aus Verpachtung {verpachtung.vertragsnummer}'
)
abrechnungen_created += 1
# Zusammenfassung
self.stdout.write(self.style.SUCCESS('\n=== MIGRATION ABGESCHLOSSEN ==='))
if dry_run:
self.stdout.write(f'DRY RUN: {land_updates} Länder würden aktualisiert')
if create_abrechnungen:
self.stdout.write(f'DRY RUN: {abrechnungen_created} Abrechnungen würden erstellt')
else:
self.stdout.write(f'{land_updates} Länder aktualisiert')
if create_abrechnungen:
self.stdout.write(f'{abrechnungen_created} Abrechnungen erstellt')
# Empfehlungen
self.stdout.write(self.style.WARNING('\n=== NÄCHSTE SCHRITTE ==='))
self.stdout.write('1. Prüfen Sie die migrierten Daten in der Weboberfläche')
self.stdout.write('2. Alte Verpachtungs-Views können als "Legacy" markiert werden')
self.stdout.write('3. Neue Verpachtungen sollten direkt im Land-Model erstellt werden')
def _get_paechter_anschrift(self, paechter):
"""Erstellt eine Anschrift aus den Pächter-Daten"""
parts = []
if paechter.strasse:
parts.append(paechter.strasse)
if paechter.plz and paechter.ort:
parts.append(f"{paechter.plz} {paechter.ort}")
elif paechter.ort:
parts.append(paechter.ort)
return '\n'.join(parts) if parts else ''

214
app/stiftung/middleware.py Normal file
View File

@@ -0,0 +1,214 @@
"""
Audit Middleware
Automatically tracks all model changes throughout the application
"""
import threading
from django.utils.deprecation import MiddlewareMixin
from django.contrib.auth.signals import user_logged_in, user_logged_out
from django.db.models.signals import post_save, post_delete, pre_save
from django.dispatch import receiver
from stiftung.audit import log_action, track_model_changes, get_client_ip
# Thread-local storage for request context
_local = threading.local()
class AuditMiddleware(MiddlewareMixin):
"""
Middleware that sets up request context for audit logging
"""
def process_request(self, request):
"""Store request in thread-local storage for access in signal handlers"""
_local.request = request
_local.user_changes = {} # Store pre-save state for change tracking
return None
def process_response(self, request, response):
"""Clean up thread-local storage"""
if hasattr(_local, 'request'):
delattr(_local, 'request')
if hasattr(_local, 'user_changes'):
delattr(_local, 'user_changes')
return response
def get_current_request():
"""Get the current request from thread-local storage"""
return getattr(_local, 'request', None)
def get_entity_type_from_model(model):
"""Map Django model to audit entity type"""
model_name = model.__name__.lower()
mapping = {
'destinataer': 'destinataer',
'land': 'land',
'paechter': 'paechter',
'verpachtung': 'verpachtung',
'foerderung': 'foerderung',
'rentmeister': 'rentmeister',
'stiftungskonto': 'stiftungskonto',
'verwaltungskosten': 'verwaltungskosten',
'banktransaction': 'banktransaction',
'dokumentlink': 'dokumentlink',
'user': 'user',
'person': 'destinataer', # Legacy model maps to destinataer
}
return mapping.get(model_name, 'unknown')
def get_entity_name(instance):
"""Get a human-readable name for an entity"""
if hasattr(instance, 'get_full_name') and callable(instance.get_full_name):
return instance.get_full_name()
elif hasattr(instance, '__str__'):
return str(instance)
else:
return f"{instance.__class__.__name__} #{instance.pk}"
# Signal handlers for automatic audit logging
@receiver(pre_save)
def store_pre_save_state(sender, instance, **kwargs):
"""Store the pre-save state for change tracking"""
request = get_current_request()
if not request or not hasattr(request, 'user'):
return
# Skip if user is not authenticated
if not request.user.is_authenticated:
return
# Skip audit log entries themselves to avoid infinite loops
if sender.__name__ == 'AuditLog':
return
# Store the current state if this is an update
if instance.pk:
try:
old_instance = sender.objects.get(pk=instance.pk)
if not hasattr(_local, 'user_changes'):
_local.user_changes = {}
_local.user_changes[instance.pk] = old_instance
except sender.DoesNotExist:
pass
@receiver(post_save)
def log_model_save(sender, instance, created, **kwargs):
"""Log model creation and updates"""
request = get_current_request()
if not request or not hasattr(request, 'user'):
return
# Skip if user is not authenticated
if not request.user.is_authenticated:
return
# Skip audit log entries themselves to avoid infinite loops
if sender.__name__ == 'AuditLog':
return
# Skip certain system models
if sender.__name__ in ['Session', 'LogEntry', 'ContentType', 'Permission']:
return
entity_type = get_entity_type_from_model(sender)
entity_name = get_entity_name(instance)
entity_id = str(instance.pk)
if created:
# Log creation
description = f"Neue {entity_type.replace('_', ' ').title()} '{entity_name}' wurde erstellt"
log_action(
request=request,
action='create',
entity_type=entity_type,
entity_id=entity_id,
entity_name=entity_name,
description=description
)
else:
# Log update with changes
changes = {}
if hasattr(_local, 'user_changes') and instance.pk in _local.user_changes:
old_instance = _local.user_changes[instance.pk]
changes = track_model_changes(old_instance, instance)
if changes: # Only log if there are actual changes
description = f"{entity_type.replace('_', ' ').title()} '{entity_name}' wurde aktualisiert"
log_action(
request=request,
action='update',
entity_type=entity_type,
entity_id=entity_id,
entity_name=entity_name,
description=description,
changes=changes
)
@receiver(post_delete)
def log_model_delete(sender, instance, **kwargs):
"""Log model deletion"""
request = get_current_request()
if not request or not hasattr(request, 'user'):
return
# Skip if user is not authenticated
if not request.user.is_authenticated:
return
# Skip audit log entries themselves
if sender.__name__ == 'AuditLog':
return
# Skip certain system models
if sender.__name__ in ['Session', 'LogEntry', 'ContentType', 'Permission']:
return
entity_type = get_entity_type_from_model(sender)
entity_name = get_entity_name(instance)
entity_id = str(instance.pk)
description = f"{entity_type.replace('_', ' ').title()} '{entity_name}' wurde gelöscht"
log_action(
request=request,
action='delete',
entity_type=entity_type,
entity_id=entity_id,
entity_name=entity_name,
description=description
)
# Authentication logging
@receiver(user_logged_in)
def log_user_login(sender, request, user, **kwargs):
"""Log user login"""
log_action(
request=request,
action='login',
entity_type='user',
entity_id=str(user.pk),
entity_name=user.username,
description=f"Benutzer {user.username} hat sich angemeldet"
)
@receiver(user_logged_out)
def log_user_logout(sender, request, user, **kwargs):
"""Log user logout"""
if user: # user might be None if session expired
log_action(
request=request,
action='logout',
entity_type='user',
entity_id=str(user.pk),
entity_name=user.username,
description=f"Benutzer {user.username} hat sich abgemeldet"
)

View File

@@ -0,0 +1,47 @@
# Generated by Django 5.0.6 on 2025-08-13 20:59
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='DokumentLink',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('paperless_document_id', models.IntegerField()),
('kontext', models.CharField(max_length=30)),
('titel', models.CharField(max_length=255)),
],
),
migrations.CreateModel(
name='Person',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('familienzweig', models.CharField(max_length=100)),
('vorname', models.CharField(max_length=100)),
('nachname', models.CharField(max_length=100)),
('geburtsdatum', models.DateField(blank=True, null=True)),
('email', models.EmailField(blank=True, max_length=254, null=True)),
('iban', models.CharField(blank=True, max_length=34, null=True)),
],
),
migrations.CreateModel(
name='Foerderung',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('jahr', models.IntegerField()),
('betrag', models.DecimalField(decimal_places=2, max_digits=12)),
('verwendungsnachweis', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='stiftung.dokumentlink')),
('person', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='stiftung.person')),
],
),
]

View File

@@ -0,0 +1,112 @@
# Generated by Django 5.0.6 on 2025-08-13 21:07
import django.core.validators
import django.db.models.deletion
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('stiftung', '0001_initial'),
]
operations = [
migrations.AlterModelOptions(
name='dokumentlink',
options={'ordering': ['titel'], 'verbose_name': 'Dokument', 'verbose_name_plural': 'Dokumente'},
),
migrations.AlterModelOptions(
name='foerderung',
options={'ordering': ['-jahr', '-betrag'], 'verbose_name': 'Förderung', 'verbose_name_plural': 'Förderungen'},
),
migrations.AlterModelOptions(
name='person',
options={'ordering': ['nachname', 'vorname'], 'verbose_name': 'Person', 'verbose_name_plural': 'Personen'},
),
migrations.AddField(
model_name='dokumentlink',
name='beschreibung',
field=models.TextField(blank=True, null=True),
),
migrations.AddField(
model_name='foerderung',
name='antragsdatum',
field=models.DateField(default=django.utils.timezone.now),
),
migrations.AddField(
model_name='foerderung',
name='bemerkungen',
field=models.TextField(blank=True, null=True),
),
migrations.AddField(
model_name='foerderung',
name='entscheidungsdatum',
field=models.DateField(blank=True, null=True),
),
migrations.AddField(
model_name='foerderung',
name='kategorie',
field=models.CharField(choices=[('bildung', 'Bildung'), ('forschung', 'Forschung'), ('kultur', 'Kultur'), ('soziales', 'Soziales'), ('umwelt', 'Umwelt'), ('anderes', 'Anderes')], default='anderes', max_length=20),
),
migrations.AddField(
model_name='foerderung',
name='status',
field=models.CharField(choices=[('beantragt', 'Beantragt'), ('genehmigt', 'Genehmigt'), ('ausgezahlt', 'Ausgezahlt'), ('abgelehnt', 'Abgelehnt'), ('storniert', 'Storniert')], default='beantragt', max_length=20),
),
migrations.AddField(
model_name='person',
name='adresse',
field=models.TextField(blank=True, null=True),
),
migrations.AddField(
model_name='person',
name='aktiv',
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name='person',
name='notizen',
field=models.TextField(blank=True, null=True),
),
migrations.AddField(
model_name='person',
name='telefon',
field=models.CharField(blank=True, max_length=20, null=True),
),
migrations.AlterField(
model_name='dokumentlink',
name='kontext',
field=models.CharField(choices=[('antrag', 'Antrag'), ('verwendungsnachweis', 'Verwendungsnachweis'), ('rechnung', 'Rechnung'), ('vertrag', 'Vertrag'), ('bericht', 'Bericht'), ('anderes', 'Anderes')], default='anderes', max_length=30),
),
migrations.AlterField(
model_name='dokumentlink',
name='paperless_document_id',
field=models.IntegerField(unique=True),
),
migrations.AlterField(
model_name='foerderung',
name='jahr',
field=models.IntegerField(validators=[django.core.validators.MinValueValidator(1900), django.core.validators.MaxValueValidator(2100)]),
),
migrations.AlterField(
model_name='foerderung',
name='person',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='stiftung.person', verbose_name='Person'),
),
migrations.AlterField(
model_name='foerderung',
name='verwendungsnachweis',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='stiftung.dokumentlink', verbose_name='Verwendungsnachweis'),
),
migrations.AlterField(
model_name='person',
name='familienzweig',
field=models.CharField(choices=[('hauptzweig', 'Hauptzweig'), ('nebenzweig', 'Nebenzweig'), ('verwandt', 'Verwandt'), ('anderer', 'Anderer')], default='hauptzweig', max_length=100),
),
migrations.AlterUniqueTogether(
name='foerderung',
unique_together={('person', 'jahr', 'kategorie')},
),
]

View File

@@ -0,0 +1,78 @@
# Generated by Django 5.0.6 on 2025-08-13 21:43
import django.core.validators
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('stiftung', '0002_alter_dokumentlink_options_alter_foerderung_options_and_more'),
]
operations = [
migrations.CreateModel(
name='Land',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('lfd_nr', models.CharField(max_length=20, unique=True, verbose_name='Lfd. Nr.')),
('ew_nummer', models.CharField(blank=True, max_length=50, null=True, verbose_name='EW-Nummer')),
('amtsgericht', models.CharField(max_length=100, verbose_name='Amtsgericht')),
('gemeinde', models.CharField(max_length=100, verbose_name='Gemeinde')),
('gemarkung', models.CharField(max_length=100, verbose_name='Gemarkung')),
('flur', models.CharField(max_length=50, verbose_name='Flur')),
('flurstueck', models.CharField(max_length=50, verbose_name='Flurstück')),
('groesse_qm', models.DecimalField(decimal_places=2, max_digits=12, validators=[django.core.validators.MinValueValidator(0.01)], verbose_name='Größe in qm')),
('gruenland_qm', models.DecimalField(decimal_places=2, default=0, max_digits=12, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Grünland (qm)')),
('acker_qm', models.DecimalField(decimal_places=2, default=0, max_digits=12, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Acker (qm)')),
('wald_qm', models.DecimalField(decimal_places=2, default=0, max_digits=12, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Wald (qm)')),
('sonstiges_qm', models.DecimalField(decimal_places=2, default=0, max_digits=12, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Sonstiges (qm)')),
('verpachtete_gesamtflaeche', models.DecimalField(decimal_places=2, max_digits=12, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Verpachtete Gesamtfläche (qm)')),
('flaeche_alte_liste', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True, verbose_name='Fläche alte Liste (qm)')),
('verp_flaeche_aktuell', models.DecimalField(decimal_places=2, max_digits=12, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Verp. Fläche aktuell (qm)')),
('anteil_grundsteuer', models.DecimalField(blank=True, decimal_places=2, max_digits=8, null=True, verbose_name='Anteil Grundsteuer (%)')),
('anteil_lwk', models.DecimalField(blank=True, decimal_places=2, max_digits=8, null=True, verbose_name='Anteil LWK (%)')),
('aktiv', models.BooleanField(default=True, verbose_name='Aktiv')),
('notizen', models.TextField(blank=True, null=True, verbose_name='Ergänzende Kommentare')),
('erstellt_am', models.DateTimeField(auto_now_add=True)),
('aktualisiert_am', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': 'Land',
'verbose_name_plural': 'Ländereien',
'ordering': ['gemeinde', 'gemarkung', 'flur', 'flurstueck'],
},
),
migrations.AlterField(
model_name='dokumentlink',
name='kontext',
field=models.CharField(choices=[('antrag', 'Antrag'), ('verwendungsnachweis', 'Verwendungsnachweis'), ('rechnung', 'Rechnung'), ('vertrag', 'Vertrag'), ('bericht', 'Bericht'), ('landkarte', 'Landkarte'), ('kataster', 'Kataster'), ('anderes', 'Anderes')], default='anderes', max_length=30),
),
migrations.CreateModel(
name='Verpachtung',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('vertragsnummer', models.CharField(max_length=50, unique=True, verbose_name='Vertragsnummer')),
('pachtbeginn', models.DateField(verbose_name='Pachtbeginn')),
('pachtende', models.DateField(verbose_name='Pachtende')),
('verlaengerung', models.DateField(blank=True, null=True, verbose_name='Verlängerung bis')),
('pachtzins_pro_qm', models.DecimalField(decimal_places=4, max_digits=8, verbose_name='Pachtzins pro qm (€)')),
('pachtzins_jaehrlich', models.DecimalField(decimal_places=2, max_digits=12, verbose_name='Jährlicher Pachtzins (€)')),
('verpachtete_flaeche', models.DecimalField(decimal_places=2, max_digits=12, verbose_name='Verpachtete Fläche (qm)')),
('status', models.CharField(choices=[('aktiv', 'Aktiv'), ('beendet', 'Beendet'), ('gekuendigt', 'Gekündigt'), ('verlängert', 'Verlängert')], default='aktiv', max_length=20)),
('bemerkungen', models.TextField(blank=True, null=True, verbose_name='Ergänzende Kommentare')),
('erstellt_am', models.DateTimeField(auto_now_add=True)),
('aktualisiert_am', models.DateTimeField(auto_now=True)),
('land', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='stiftung.land', verbose_name='Land')),
('paechter', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='stiftung.person', verbose_name='Pächter')),
('verwendungsnachweis', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='stiftung.dokumentlink', verbose_name='Verwendungsnachweis')),
],
options={
'verbose_name': 'Verpachtung',
'verbose_name_plural': 'Verpachtungen',
'ordering': ['-pachtbeginn'],
},
),
]

View File

@@ -0,0 +1,36 @@
# Generated by Django 5.0.6 on 2025-08-13 22:18
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('stiftung', '0003_land_alter_dokumentlink_kontext_verpachtung'),
]
operations = [
migrations.CreateModel(
name='CSVImport',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('import_type', models.CharField(choices=[('personen', 'Personen'), ('laendereien', 'Ländereien'), ('verpachtungen', 'Verpachtungen')], max_length=20, verbose_name='Import-Typ')),
('filename', models.CharField(max_length=255, verbose_name='Dateiname')),
('file_size', models.IntegerField(verbose_name='Dateigröße (Bytes)')),
('status', models.CharField(choices=[('pending', 'Ausstehend'), ('processing', 'Wird verarbeitet'), ('completed', 'Abgeschlossen'), ('failed', 'Fehlgeschlagen'), ('partial', 'Teilweise erfolgreich')], default='pending', max_length=20)),
('total_rows', models.IntegerField(default=0, verbose_name='Gesamtzeilen')),
('imported_rows', models.IntegerField(default=0, verbose_name='Importierte Zeilen')),
('failed_rows', models.IntegerField(default=0, verbose_name='Fehlgeschlagene Zeilen')),
('error_log', models.TextField(blank=True, null=True, verbose_name='Fehlerprotokoll')),
('created_by', models.CharField(blank=True, max_length=100, null=True, verbose_name='Erstellt von')),
('started_at', models.DateTimeField(auto_now_add=True, verbose_name='Gestartet um')),
('completed_at', models.DateTimeField(blank=True, null=True, verbose_name='Abgeschlossen um')),
],
options={
'verbose_name': 'CSV Import',
'verbose_name_plural': 'CSV Imports',
'ordering': ['-started_at'],
},
),
]

View File

@@ -0,0 +1,93 @@
# Generated by Django 5.0.6 on 2025-08-14 10:38
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('stiftung', '0004_csvimport'),
]
operations = [
migrations.CreateModel(
name='Destinataer',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('familienzweig', models.CharField(choices=[('hauptzweig', 'Hauptzweig'), ('nebenzweig', 'Nebenzweig'), ('verwandt', 'Verwandt'), ('anderer', 'Anderer')], default='hauptzweig', max_length=100)),
('vorname', models.CharField(max_length=100, verbose_name='Vorname')),
('nachname', models.CharField(max_length=100, verbose_name='Nachname')),
('geburtsdatum', models.DateField(blank=True, null=True, verbose_name='Geburtsdatum')),
('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='E-Mail')),
('telefon', models.CharField(blank=True, max_length=20, null=True, verbose_name='Telefon')),
('iban', models.CharField(blank=True, max_length=34, null=True, verbose_name='IBAN')),
('adresse', models.TextField(blank=True, null=True, verbose_name='Adresse')),
('berufsgruppe', models.CharField(choices=[('student', 'Student/Studentin'), ('wissenschaftler', 'Wissenschaftler/in'), ('künstler', 'Künstler/in'), ('sozialarbeiter', 'Sozialarbeiter/in'), ('umweltschützer', 'Umweltschützer/in'), ('andere', 'Andere')], default='andere', max_length=20, verbose_name='Berufsgruppe')),
('ausbildungsstand', models.CharField(blank=True, max_length=100, null=True, verbose_name='Ausbildungsstand')),
('institution', models.CharField(blank=True, max_length=200, null=True, verbose_name='Institution/Organisation')),
('projekt_beschreibung', models.TextField(blank=True, null=True, verbose_name='Projektbeschreibung')),
('jaehrliches_einkommen', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True, verbose_name='Jährliches Einkommen (€)')),
('finanzielle_notlage', models.BooleanField(default=False, verbose_name='Finanzielle Notlage')),
('notizen', models.TextField(blank=True, null=True, verbose_name='Notizen')),
('aktiv', models.BooleanField(default=True, verbose_name='Aktiv')),
],
options={
'verbose_name': 'Destinatär',
'verbose_name_plural': 'Destinatäre',
'ordering': ['nachname', 'vorname'],
},
),
migrations.CreateModel(
name='Paechter',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('familienzweig', models.CharField(choices=[('hauptzweig', 'Hauptzweig'), ('nebenzweig', 'Nebenzweig'), ('verwandt', 'Verwandt'), ('anderer', 'Anderer')], default='hauptzweig', max_length=100)),
('vorname', models.CharField(max_length=100, verbose_name='Vorname')),
('nachname', models.CharField(max_length=100, verbose_name='Nachname')),
('geburtsdatum', models.DateField(blank=True, null=True, verbose_name='Geburtsdatum')),
('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='E-Mail')),
('telefon', models.CharField(blank=True, max_length=20, null=True, verbose_name='Telefon')),
('iban', models.CharField(blank=True, max_length=34, null=True, verbose_name='IBAN')),
('adresse', models.TextField(blank=True, null=True, verbose_name='Adresse')),
('pachtnummer', models.CharField(blank=True, max_length=50, null=True, verbose_name='Pachtnummer')),
('pachtbeginn_erste', models.DateField(blank=True, null=True, verbose_name='Erster Pachtbeginn')),
('pachtende_letzte', models.DateField(blank=True, null=True, verbose_name='Letztes Pachtende')),
('pachtzins_aktuell', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True, verbose_name='Aktueller Pachtzins (€/Jahr)')),
('landwirtschaftliche_ausbildung', models.BooleanField(default=False, verbose_name='Landwirtschaftliche Ausbildung')),
('berufserfahrung_jahre', models.IntegerField(blank=True, null=True, verbose_name='Berufserfahrung (Jahre)')),
('spezialisierung', models.CharField(blank=True, max_length=100, null=True, verbose_name='Spezialisierung')),
('notizen', models.TextField(blank=True, null=True, verbose_name='Notizen')),
('aktiv', models.BooleanField(default=True, verbose_name='Aktiv')),
],
options={
'verbose_name': 'Pächter',
'verbose_name_plural': 'Pächter',
'ordering': ['nachname', 'vorname'],
},
),
migrations.AlterModelOptions(
name='person',
options={'ordering': ['nachname', 'vorname'], 'verbose_name': 'Person (Legacy)', 'verbose_name_plural': 'Personen (Legacy)'},
),
migrations.AlterUniqueTogether(
name='foerderung',
unique_together=set(),
),
migrations.AlterField(
model_name='foerderung',
name='person',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='stiftung.person', verbose_name='Person (Legacy)'),
),
migrations.AddField(
model_name='foerderung',
name='destinataer',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='stiftung.destinataer', verbose_name='Destinatär'),
),
migrations.AlterField(
model_name='verpachtung',
name='paechter',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='stiftung.paechter', verbose_name='Pächter'),
),
]

View File

@@ -0,0 +1,22 @@
# Generated by Django 5.0.6 on 2025-08-14 12:00
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('stiftung', '0005_destinataer_paechter_alter_person_options_and_more'),
]
operations = [
migrations.RemoveField(
model_name='paechter',
name='familienzweig',
),
migrations.AlterField(
model_name='csvimport',
name='import_type',
field=models.CharField(choices=[('destinataere', 'Destinatäre'), ('paechter', 'Pächter'), ('laendereien', 'Ländereien'), ('verpachtungen', 'Verpachtungen'), ('personen', 'Personen (Legacy)')], max_length=20, verbose_name='Import-Typ'),
),
]

View File

@@ -0,0 +1,56 @@
# Generated by Django 5.0.6 on 2025-08-14 12:10
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('stiftung', '0006_remove_paechter_familienzweig_and_more'),
]
operations = [
migrations.RemoveField(
model_name='destinataer',
name='adresse',
),
migrations.RemoveField(
model_name='paechter',
name='adresse',
),
migrations.AddField(
model_name='destinataer',
name='ort',
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Ort'),
),
migrations.AddField(
model_name='destinataer',
name='plz',
field=models.CharField(blank=True, max_length=10, null=True, verbose_name='PLZ'),
),
migrations.AddField(
model_name='destinataer',
name='strasse',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Straße'),
),
migrations.AddField(
model_name='paechter',
name='ort',
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Ort'),
),
migrations.AddField(
model_name='paechter',
name='personentyp',
field=models.CharField(choices=[('natuerlich', 'Natürliche Person'), ('gesellschaft', 'Gesellschaft (GmbH, KG, etc.)')], default='natuerlich', max_length=20, verbose_name='Typ des Pächters'),
),
migrations.AddField(
model_name='paechter',
name='plz',
field=models.CharField(blank=True, max_length=10, null=True, verbose_name='PLZ'),
),
migrations.AddField(
model_name='paechter',
name='strasse',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Straße'),
),
]

View File

@@ -0,0 +1,43 @@
# Generated by Django 5.0.6 on 2025-08-14 19:27
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('stiftung', '0007_remove_destinataer_adresse_remove_paechter_adresse_and_more'),
]
operations = [
migrations.AddField(
model_name='dokumentlink',
name='destinataer_id',
field=models.UUIDField(blank=True, null=True, verbose_name='Destinatär ID'),
),
migrations.AddField(
model_name='dokumentlink',
name='foerderung_id',
field=models.UUIDField(blank=True, null=True, verbose_name='Förderung ID'),
),
migrations.AddField(
model_name='dokumentlink',
name='land_id',
field=models.UUIDField(blank=True, null=True, verbose_name='Länderei ID'),
),
migrations.AddField(
model_name='dokumentlink',
name='paechter_id',
field=models.UUIDField(blank=True, null=True, verbose_name='Pächter ID'),
),
migrations.AddField(
model_name='dokumentlink',
name='verpachtung_id',
field=models.UUIDField(blank=True, null=True, verbose_name='Verpachtung ID'),
),
migrations.AlterField(
model_name='dokumentlink',
name='kontext',
field=models.CharField(choices=[('pachtvertrag', 'Pachtvertrag'), ('antrag', 'Antrag'), ('verwendungsnachweis', 'Verwendungsnachweis'), ('rechnung', 'Rechnung'), ('vertrag', 'Vertrag'), ('bericht', 'Bericht'), ('landkarte', 'Landkarte'), ('kataster', 'Kataster'), ('anderes', 'Anderes')], default='anderes', max_length=30),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.0.6 on 2025-08-23 21:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('stiftung', '0008_dokumentlink_destinataer_id_and_more'),
]
operations = [
migrations.AlterField(
model_name='dokumentlink',
name='paperless_document_id',
field=models.IntegerField(),
),
]

View File

@@ -0,0 +1,100 @@
# Generated by Django 5.0.6 on 2025-08-24 17:48
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('stiftung', '0009_alter_dokumentlink_paperless_document_id'),
]
operations = [
migrations.CreateModel(
name='Rentmeister',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('anrede', models.CharField(blank=True, choices=[('herr', 'Herr'), ('frau', 'Frau'), ('dr', 'Dr.'), ('prof', 'Prof.'), ('prof_dr', 'Prof. Dr.')], max_length=10, verbose_name='Anrede')),
('vorname', models.CharField(max_length=100, verbose_name='Vorname')),
('nachname', models.CharField(max_length=100, verbose_name='Nachname')),
('titel', models.CharField(blank=True, max_length=50, verbose_name='Titel')),
('email', models.EmailField(blank=True, max_length=254, verbose_name='E-Mail')),
('telefon', models.CharField(blank=True, max_length=20, verbose_name='Telefon')),
('mobil', models.CharField(blank=True, max_length=20, verbose_name='Mobil')),
('strasse', models.CharField(blank=True, max_length=200, verbose_name='Straße')),
('plz', models.CharField(blank=True, max_length=10, verbose_name='PLZ')),
('ort', models.CharField(blank=True, max_length=100, verbose_name='Ort')),
('iban', models.CharField(blank=True, max_length=34, verbose_name='IBAN')),
('bic', models.CharField(blank=True, max_length=11, verbose_name='BIC')),
('bank_name', models.CharField(blank=True, max_length=100, verbose_name='Bank')),
('seit_datum', models.DateField(verbose_name='Rentmeister seit')),
('bis_datum', models.DateField(blank=True, null=True, verbose_name='Rentmeister bis')),
('aktiv', models.BooleanField(default=True, verbose_name='Aktiv')),
('monatliche_verguetung', models.DecimalField(blank=True, decimal_places=2, max_digits=8, null=True, verbose_name='Monatliche Vergütung (€)')),
('km_pauschale', models.DecimalField(decimal_places=2, default=0.3, max_digits=4, verbose_name='Kilometerpauschale (€/km)')),
('notizen', models.TextField(blank=True, verbose_name='Notizen')),
('erstellt_am', models.DateTimeField(auto_now_add=True)),
('aktualisiert_am', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': 'Rentmeister',
'verbose_name_plural': 'Rentmeister',
'ordering': ['nachname', 'vorname'],
},
),
migrations.CreateModel(
name='StiftungsKonto',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('kontoname', models.CharField(max_length=200, verbose_name='Kontoname')),
('bank_name', models.CharField(max_length=200, verbose_name='Bank')),
('iban', models.CharField(max_length=34, verbose_name='IBAN')),
('bic', models.CharField(blank=True, max_length=11, verbose_name='BIC')),
('konto_typ', models.CharField(choices=[('girokonto', 'Girokonto'), ('sparkonto', 'Sparkonto'), ('festgeld', 'Festgeld'), ('tagesgeld', 'Tagesgeld'), ('depot', 'Depot'), ('sonstiges', 'Sonstiges')], default='girokonto', max_length=20, verbose_name='Kontotyp')),
('saldo', models.DecimalField(decimal_places=2, default=0.0, max_digits=10, verbose_name='Aktueller Saldo')),
('saldo_datum', models.DateField(blank=True, null=True, verbose_name='Saldo-Datum')),
('zinssatz', models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True, verbose_name='Zinssatz (%)')),
('laufzeit_bis', models.DateField(blank=True, null=True, verbose_name='Laufzeit bis')),
('aktiv', models.BooleanField(default=True, verbose_name='Aktiv')),
('notizen', models.TextField(blank=True, verbose_name='Notizen')),
('erstellt_am', models.DateTimeField(auto_now_add=True)),
('aktualisiert_am', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': 'Stiftungskonto',
'verbose_name_plural': 'Stiftungskonten',
'ordering': ['bank_name', 'kontoname'],
},
),
migrations.CreateModel(
name='Verwaltungskosten',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('bezeichnung', models.CharField(max_length=200, verbose_name='Bezeichnung')),
('kategorie', models.CharField(choices=[('rechnung_intern', 'Interne Rechnung'), ('bueroausstattung', 'Büroausstattung'), ('fahrtkosten', 'Fahrtkosten'), ('porto', 'Porto & Versand'), ('telefon_internet', 'Telefon & Internet'), ('software', 'Software & Lizenzen'), ('beratung', 'Beratung & Dienstleistungen'), ('versicherung', 'Versicherungen'), ('steuerberatung', 'Steuerberatung'), ('bankgebuehren', 'Bankgebühren'), ('sonstiges', 'Sonstiges')], max_length=30, verbose_name='Kategorie')),
('betrag', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Betrag (€)')),
('datum', models.DateField(verbose_name='Datum')),
('lieferant_firma', models.CharField(blank=True, max_length=200, verbose_name='Lieferant/Firma')),
('rechnungsnummer', models.CharField(blank=True, max_length=100, verbose_name='Rechnungsnummer')),
('status', models.CharField(choices=[('geplant', 'Geplant'), ('bestellt', 'Bestellt'), ('erhalten', 'Erhalten'), ('bezahlt', 'Bezahlt'), ('storniert', 'Storniert')], default='geplant', max_length=20, verbose_name='Status')),
('km_anzahl', models.DecimalField(blank=True, decimal_places=1, max_digits=8, null=True, verbose_name='Kilometer')),
('km_satz', models.DecimalField(blank=True, decimal_places=2, max_digits=4, null=True, verbose_name='€/km')),
('von_ort', models.CharField(blank=True, max_length=100, verbose_name='Von (Ort)')),
('nach_ort', models.CharField(blank=True, max_length=100, verbose_name='Nach (Ort)')),
('zweck', models.CharField(blank=True, max_length=200, verbose_name='Zweck der Fahrt')),
('beschreibung', models.TextField(blank=True, verbose_name='Beschreibung')),
('notizen', models.TextField(blank=True, verbose_name='Notizen')),
('erstellt_am', models.DateTimeField(auto_now_add=True)),
('aktualisiert_am', models.DateTimeField(auto_now=True)),
('konto', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='stiftung.stiftungskonto', verbose_name='Konto')),
('rentmeister', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='stiftung.rentmeister', verbose_name='Rentmeister')),
],
options={
'verbose_name': 'Verwaltungskosten',
'verbose_name_plural': 'Verwaltungskosten',
'ordering': ['-datum', '-erstellt_am'],
},
),
]

View File

@@ -0,0 +1,44 @@
# Generated by Django 5.0.6 on 2025-08-24 19:27
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('stiftung', '0010_rentmeister_stiftungskonto_verwaltungskosten'),
]
operations = [
migrations.CreateModel(
name='BankTransaction',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('datum', models.DateField(verbose_name='Buchungsdatum')),
('valuta', models.DateField(blank=True, null=True, verbose_name='Valutadatum')),
('betrag', models.DecimalField(decimal_places=2, max_digits=12, verbose_name='Betrag (€)')),
('waehrung', models.CharField(default='EUR', max_length=3, verbose_name='Währung')),
('verwendungszweck', models.TextField(verbose_name='Verwendungszweck')),
('empfaenger_zahlungspflichtiger', models.CharField(blank=True, max_length=200, verbose_name='Empfänger/Zahlungspflichtiger')),
('iban_gegenpartei', models.CharField(blank=True, max_length=34, verbose_name='IBAN Gegenpartei')),
('bic_gegenpartei', models.CharField(blank=True, max_length=11, verbose_name='BIC Gegenpartei')),
('referenz', models.CharField(blank=True, max_length=100, verbose_name='Referenz/Transaktions-ID')),
('transaction_type', models.CharField(choices=[('eingang', 'Eingang'), ('ausgang', 'Ausgang'), ('lastschrift', 'Lastschrift'), ('ueberweisung', 'Überweisung'), ('dauerauftrag', 'Dauerauftrag'), ('kartenzahlung', 'Kartenzahlung'), ('zinsen', 'Zinsen'), ('gebuehren', 'Gebühren'), ('sonstiges', 'Sonstiges')], default='sonstiges', max_length=20, verbose_name='Transaktionsart')),
('status', models.CharField(choices=[('imported', 'Importiert'), ('verified', 'Geprüft'), ('assigned', 'Zugeordnet'), ('ignored', 'Ignoriert')], default='imported', max_length=20, verbose_name='Status')),
('kommentare', models.TextField(blank=True, verbose_name='Kommentare')),
('import_datei', models.CharField(blank=True, max_length=255, verbose_name='Import-Datei')),
('importiert_am', models.DateTimeField(auto_now_add=True, verbose_name='Importiert am')),
('saldo_nach_buchung', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True, verbose_name='Saldo nach Buchung')),
('konto', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='stiftung.stiftungskonto', verbose_name='Konto')),
('verwaltungskosten', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='stiftung.verwaltungskosten', verbose_name='Zugeordnete Verwaltungskosten')),
],
options={
'verbose_name': 'Banktransaktion',
'verbose_name_plural': 'Banktransaktionen',
'ordering': ['-datum', '-importiert_am'],
'unique_together': {('konto', 'datum', 'betrag', 'referenz')},
},
),
]

View File

@@ -0,0 +1,34 @@
# Generated by Django 5.0.6 on 2025-08-24 20:03
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('stiftung', '0011_banktransaction'),
]
operations = [
migrations.AddField(
model_name='verwaltungskosten',
name='quellkonto',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='ausgaben', to='stiftung.stiftungskonto', verbose_name='Quellkonto'),
),
migrations.AddField(
model_name='verwaltungskosten',
name='zahlungskonto',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='zahlungen', to='stiftung.stiftungskonto', verbose_name='Zahlungskonto'),
),
migrations.AlterField(
model_name='verwaltungskosten',
name='konto',
field=models.ForeignKey(blank=True, help_text='Veraltet - verwende Zahlungskonto und Quellkonto', null=True, on_delete=django.db.models.deletion.SET_NULL, to='stiftung.stiftungskonto', verbose_name='Konto (Legacy)'),
),
migrations.AlterField(
model_name='verwaltungskosten',
name='rentmeister',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='stiftung.rentmeister', verbose_name='Zuständiger Rentmeister'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.0.6 on 2025-08-24 20:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('stiftung', '0012_verwaltungskosten_quellkonto_and_more'),
]
operations = [
migrations.AlterField(
model_name='verwaltungskosten',
name='status',
field=models.CharField(choices=[('geplant', 'Geplant'), ('bestellt', 'Bestellt'), ('erhalten', 'Erhalten'), ('in_bearbeitung', 'In Bearbeitung'), ('bezahlt', 'Bezahlt'), ('storniert', 'Storniert')], default='geplant', max_length=20, verbose_name='Status'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.0.6 on 2025-08-26 08:15
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('stiftung', '0013_alter_verwaltungskosten_status'),
]
operations = [
migrations.AddField(
model_name='dokumentlink',
name='rentmeister_id',
field=models.UUIDField(blank=True, null=True, verbose_name='Rentmeister ID'),
),
]

View File

@@ -0,0 +1,63 @@
# Generated by Django 5.0.6 on 2025-08-26 08:33
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('stiftung', '0014_dokumentlink_rentmeister_id'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='BackupJob',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('backup_type', models.CharField(choices=[('full', 'Vollständiges Backup'), ('database', 'Nur Datenbank'), ('files', 'Nur Dateien')], max_length=20, verbose_name='Backup-Typ')),
('status', models.CharField(choices=[('pending', 'Wartend'), ('running', 'Läuft'), ('completed', 'Abgeschlossen'), ('failed', 'Fehlgeschlagen')], default='pending', max_length=20, verbose_name='Status')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Erstellt am')),
('started_at', models.DateTimeField(blank=True, null=True, verbose_name='Gestartet am')),
('completed_at', models.DateTimeField(blank=True, null=True, verbose_name='Abgeschlossen am')),
('backup_filename', models.CharField(blank=True, max_length=255, verbose_name='Backup-Dateiname')),
('backup_size', models.BigIntegerField(blank=True, null=True, verbose_name='Backup-Größe (Bytes)')),
('error_message', models.TextField(blank=True, verbose_name='Fehlermeldung')),
('database_size', models.BigIntegerField(blank=True, null=True, verbose_name='Datenbankgröße (Bytes)')),
('files_count', models.IntegerField(blank=True, null=True, verbose_name='Anzahl Dateien')),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Erstellt von')),
],
options={
'verbose_name': 'Backup-Job',
'verbose_name_plural': 'Backup-Jobs',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='AuditLog',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('username', models.CharField(max_length=150, verbose_name='Benutzername')),
('timestamp', models.DateTimeField(auto_now_add=True, verbose_name='Zeitpunkt')),
('action', models.CharField(choices=[('create', 'Erstellt'), ('update', 'Aktualisiert'), ('delete', 'Gelöscht'), ('link', 'Verknüpft'), ('unlink', 'Verknüpfung entfernt'), ('login', 'Anmeldung'), ('logout', 'Abmeldung'), ('backup', 'Backup erstellt'), ('restore', 'Wiederherstellung'), ('export', 'Export'), ('import', 'Import')], max_length=20, verbose_name='Aktion')),
('entity_type', models.CharField(choices=[('destinataer', 'Destinatär'), ('land', 'Länderei'), ('paechter', 'Pächter'), ('verpachtung', 'Verpachtung'), ('foerderung', 'Förderung'), ('rentmeister', 'Rentmeister'), ('stiftungskonto', 'Stiftungskonto'), ('verwaltungskosten', 'Verwaltungskosten'), ('banktransaction', 'Bank-Transaktion'), ('dokumentlink', 'Dokument-Verknüpfung'), ('system', 'System'), ('user', 'Benutzer')], max_length=20, verbose_name='Entitätstyp')),
('entity_id', models.CharField(blank=True, max_length=100, verbose_name='Entitäts-ID')),
('entity_name', models.CharField(max_length=255, verbose_name='Entitätsname')),
('description', models.TextField(verbose_name='Beschreibung')),
('changes', models.JSONField(blank=True, null=True, verbose_name='Änderungen')),
('ip_address', models.GenericIPAddressField(blank=True, null=True, verbose_name='IP-Adresse')),
('user_agent', models.TextField(blank=True, verbose_name='User Agent')),
('session_key', models.CharField(blank=True, max_length=40, verbose_name='Session-Key')),
('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Benutzer')),
],
options={
'verbose_name': 'Audit Log Eintrag',
'verbose_name_plural': 'Audit Log Einträge',
'ordering': ['-timestamp'],
'indexes': [models.Index(fields=['timestamp'], name='stiftung_au_timesta_c4591e_idx'), models.Index(fields=['user', 'timestamp'], name='stiftung_au_user_id_e3fc12_idx'), models.Index(fields=['entity_type', 'timestamp'], name='stiftung_au_entity__68f25d_idx'), models.Index(fields=['action', 'timestamp'], name='stiftung_au_action_288765_idx')],
},
),
]

View File

@@ -0,0 +1,24 @@
# Generated by Django 5.0.6 on 2025-08-26 08:50
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('stiftung', '0015_backupjob_auditlog'),
]
operations = [
migrations.CreateModel(
name='ApplicationPermission',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
],
options={
'permissions': [('manage_destinataere', 'Kann Destinatäre verwalten'), ('view_destinataere', 'Kann Destinatäre anzeigen'), ('manage_land', 'Kann Ländereien verwalten'), ('view_land', 'Kann Ländereien anzeigen'), ('manage_paechter', 'Kann Pächter verwalten'), ('view_paechter', 'Kann Pächter anzeigen'), ('manage_verpachtungen', 'Kann Verpachtungen verwalten'), ('view_verpachtungen', 'Kann Verpachtungen anzeigen'), ('manage_foerderungen', 'Kann Förderungen verwalten'), ('view_foerderungen', 'Kann Förderungen anzeigen'), ('manage_documents', 'Kann Dokumente verwalten'), ('view_documents', 'Kann Dokumente anzeigen'), ('link_documents', 'Kann Dokumente verknüpfen'), ('manage_verwaltungskosten', 'Kann Verwaltungskosten verwalten'), ('view_verwaltungskosten', 'Kann Verwaltungskosten anzeigen'), ('approve_payments', 'Kann Zahlungen genehmigen'), ('manage_konten', 'Kann Stiftungskonten verwalten'), ('view_konten', 'Kann Stiftungskonten anzeigen'), ('manage_rentmeister', 'Kann Rentmeister verwalten'), ('view_rentmeister', 'Kann Rentmeister anzeigen'), ('access_administration', 'Kann Administration aufrufen'), ('view_audit_logs', 'Kann Audit-Logs anzeigen'), ('manage_backups', 'Kann Backups erstellen und verwalten'), ('manage_users', 'Kann Benutzer verwalten'), ('manage_permissions', 'Kann Berechtigungen verwalten'), ('import_data', 'Kann Daten importieren'), ('export_data', 'Kann Daten exportieren'), ('access_django_admin', 'Kann Django Admin aufrufen'), ('view_system_stats', 'Kann Systemstatistiken anzeigen')],
'managed': False,
'default_permissions': (),
},
),
]

View File

@@ -0,0 +1,54 @@
# Generated by Django 5.0.6 on 2025-08-29 13:15
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('stiftung', '0016_applicationpermission'),
]
operations = [
migrations.AddField(
model_name='destinataer',
name='haushaltsgroesse',
field=models.PositiveIntegerField(default=1, verbose_name='Haushaltsgröße'),
),
migrations.AddField(
model_name='destinataer',
name='ist_abkoemmling',
field=models.BooleanField(default=False, verbose_name='Abkömmling gem. Satzung'),
),
migrations.AddField(
model_name='destinataer',
name='letzter_studiennachweis',
field=models.DateField(blank=True, null=True, verbose_name='Letzter Studiennachweis'),
),
migrations.AddField(
model_name='destinataer',
name='monatliche_bezuege',
field=models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True, verbose_name='Monatliche Bezüge (€)'),
),
migrations.AddField(
model_name='destinataer',
name='standard_konto',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='stiftung.stiftungskonto', verbose_name='Standard Auszahlungskonto'),
),
migrations.AddField(
model_name='destinataer',
name='studiennachweis_erforderlich',
field=models.BooleanField(default=False, verbose_name='Studiennachweis erforderlich'),
),
migrations.AddField(
model_name='destinataer',
name='unterstuetzung_bestaetigt',
field=models.BooleanField(default=False, verbose_name='Unterstützung bestätigt'),
),
migrations.AddField(
model_name='destinataer',
name='vermoegen',
field=models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True, verbose_name='Vermögen (€)'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.0.6 on 2025-08-29 13:27
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('stiftung', '0017_destinataer_haushaltsgroesse_and_more'),
]
operations = [
migrations.AddField(
model_name='destinataer',
name='vierteljaehrlicher_betrag',
field=models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True, verbose_name='Vierteljährlicher Betrag (€)'),
),
]

View File

@@ -0,0 +1,35 @@
# Generated by Django 5.0.6 on 2025-08-29 13:40
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('stiftung', '0018_destinataer_vierteljaehrlicher_betrag'),
]
operations = [
migrations.CreateModel(
name='DestinataerUnterstuetzung',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('betrag', models.DecimalField(decimal_places=2, max_digits=12, verbose_name='Betrag (€)')),
('faellig_am', models.DateField(verbose_name='Fällig am')),
('status', models.CharField(choices=[('geplant', 'Geplant'), ('in_bearbeitung', 'In Bearbeitung'), ('ausgezahlt', 'Ausgezahlt'), ('storniert', 'Storniert')], default='geplant', max_length=20, verbose_name='Status')),
('beschreibung', models.CharField(blank=True, max_length=255, verbose_name='Beschreibung')),
('erstellt_am', models.DateTimeField(auto_now_add=True)),
('aktualisiert_am', models.DateTimeField(auto_now=True)),
('destinataer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='unterstuetzungen', to='stiftung.destinataer', verbose_name='Destinatär')),
('konto', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='stiftung.stiftungskonto', verbose_name='Zahlungskonto')),
],
options={
'verbose_name': 'Destinatärunterstützung',
'verbose_name_plural': 'Destinatärunterstützungen',
'ordering': ['-faellig_am', '-erstellt_am'],
'indexes': [models.Index(fields=['status', 'faellig_am'], name='stiftung_de_status_1e9799_idx'), models.Index(fields=['destinataer', 'status'], name='stiftung_de_destina_ba7286_idx')],
},
),
]

View File

@@ -0,0 +1,34 @@
# Generated by Django 5.0.6 on 2025-08-29 16:05
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('stiftung', '0019_destinataerunterstuetzung'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='DestinataerNotiz',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('titel', models.CharField(blank=True, max_length=200, verbose_name='Titel')),
('text', models.TextField(blank=True, verbose_name='Notiz')),
('datei', models.FileField(blank=True, null=True, upload_to='destinataer_notizen/', verbose_name='Anhang')),
('erstellt_am', models.DateTimeField(auto_now_add=True, verbose_name='Erstellt am')),
('destinataer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notizen_eintraege', to='stiftung.destinataer', verbose_name='Destinatär')),
('erstellt_von', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Erstellt von')),
],
options={
'verbose_name': 'Destinatär-Notiz',
'verbose_name_plural': 'Destinatär-Notizen',
'ordering': ['-erstellt_am'],
},
),
]

View File

@@ -0,0 +1,134 @@
# Generated by Django 5.0.6 on 2025-08-30 14:20
import django.core.validators
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('stiftung', '0020_destinataernotiz'),
]
operations = [
migrations.AddField(
model_name='land',
name='adresse',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Adresse/Ortsangabe'),
),
migrations.AddField(
model_name='land',
name='aktueller_paechter',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='gepachtete_laendereien', to='stiftung.paechter', verbose_name='Aktueller Pächter'),
),
migrations.AddField(
model_name='land',
name='grundbuchblatt',
field=models.CharField(blank=True, max_length=50, null=True, verbose_name='Grundbuchblatt'),
),
migrations.AddField(
model_name='land',
name='grundsteuer_umlage',
field=models.BooleanField(default=True, verbose_name='Grundsteuer umlagefähig'),
),
migrations.AddField(
model_name='land',
name='jagdpacht_anteil_umlage',
field=models.BooleanField(default=False, verbose_name='Jagdpachtanteile umlagefähig'),
),
migrations.AddField(
model_name='land',
name='pachtbeginn',
field=models.DateField(blank=True, null=True, verbose_name='Pachtbeginn'),
),
migrations.AddField(
model_name='land',
name='pachtende',
field=models.DateField(blank=True, null=True, verbose_name='Pachtende'),
),
migrations.AddField(
model_name='land',
name='pachtzins_pauschal',
field=models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Pachtzins pauschal/Jahr (€)'),
),
migrations.AddField(
model_name='land',
name='pachtzins_pro_ha',
field=models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Pachtzins pro ha (€)'),
),
migrations.AddField(
model_name='land',
name='paechter_anschrift',
field=models.TextField(blank=True, null=True, verbose_name='Pächter Anschrift'),
),
migrations.AddField(
model_name='land',
name='paechter_name',
field=models.CharField(blank=True, max_length=150, null=True, verbose_name='Pächter Name'),
),
migrations.AddField(
model_name='land',
name='ust_option',
field=models.BooleanField(default=False, verbose_name='USt-Option'),
),
migrations.AddField(
model_name='land',
name='ust_satz',
field=models.DecimalField(decimal_places=2, default=19.0, max_digits=4, verbose_name='USt-Satz (%)'),
),
migrations.AddField(
model_name='land',
name='verbandsbeitraege_umlage',
field=models.BooleanField(default=True, verbose_name='Verbandsbeiträge umlagefähig'),
),
migrations.AddField(
model_name='land',
name='verlaengerung_klausel',
field=models.BooleanField(default=False, verbose_name='Automatische Verlängerung'),
),
migrations.AddField(
model_name='land',
name='versicherungen_umlage',
field=models.BooleanField(default=True, verbose_name='Versicherungen umlagefähig'),
),
migrations.AddField(
model_name='land',
name='zahlungsweise',
field=models.CharField(choices=[('jaehrlich', 'Jährlich'), ('halbjaehrlich', 'Halbjährlich'), ('vierteljaehrlich', 'Vierteljährlich'), ('monatlich', 'Monatlich')], default='jaehrlich', max_length=20, verbose_name='Zahlungsweise'),
),
migrations.CreateModel(
name='LandAbrechnung',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('abrechnungsjahr', models.IntegerField(validators=[django.core.validators.MinValueValidator(2000)], verbose_name='Abrechnungsjahr')),
('pacht_vereinnahmt', models.DecimalField(decimal_places=2, default=0, max_digits=12, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Pacht vereinnahmt (€)')),
('umlagen_vereinnahmt', models.DecimalField(decimal_places=2, default=0, max_digits=12, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Umlagen vereinnahmt (€)')),
('sonstige_einnahmen', models.DecimalField(decimal_places=2, default=0, max_digits=12, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Sonstige Einnahmen (€)')),
('zahlungen', models.JSONField(blank=True, help_text='Liste von Objekten {datum, betrag, art}', null=True, verbose_name='Zahlungstermine')),
('grundsteuer_bescheid_nr', models.CharField(blank=True, max_length=80, null=True, verbose_name='Grundsteuer-Bescheid Nr.')),
('grundsteuer_betrag', models.DecimalField(decimal_places=2, default=0, max_digits=12, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Grundsteuer Betrag (€)')),
('versicherungen_betrag', models.DecimalField(decimal_places=2, default=0, max_digits=12, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Versicherungen Betrag (€)')),
('verbandsbeitraege_betrag', models.DecimalField(decimal_places=2, default=0, max_digits=12, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Verbandsbeiträge Betrag (€)')),
('sonstige_abgaben_betrag', models.DecimalField(decimal_places=2, default=0, max_digits=12, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Sonstige öffentliche Abgaben (€)')),
('instandhaltung_betrag', models.DecimalField(decimal_places=2, default=0, max_digits=12, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Instandhaltung/Reparaturen (€)')),
('verwaltung_recht_betrag', models.DecimalField(decimal_places=2, default=0, max_digits=12, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Verwaltung/Recht (€)')),
('vorsteuer_aus_umlagen', models.DecimalField(decimal_places=2, default=0, max_digits=12, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Vorsteuer aus umgelegten Kosten (€)')),
('offene_posten', models.DecimalField(decimal_places=2, default=0, max_digits=12, verbose_name='Offene Posten (€)')),
('bemerkungen', models.TextField(blank=True, null=True, verbose_name='Bemerkungen Abrechnung')),
('pachtvertrag_datei', models.FileField(blank=True, null=True, upload_to='land_abrechnungen/vertraege/', verbose_name='Pachtvertrag (Datei)')),
('grundsteuer_bescheid_datei', models.FileField(blank=True, null=True, upload_to='land_abrechnungen/bescheide/', verbose_name='Grundsteuerbescheid (Datei)')),
('versicherungsnachweis_datei', models.FileField(blank=True, null=True, upload_to='land_abrechnungen/versicherungen/', verbose_name='Versicherungsnachweis (Datei)')),
('erstellt_am', models.DateTimeField(auto_now_add=True)),
('aktualisiert_am', models.DateTimeField(auto_now=True)),
('land', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='abrechnungen', to='stiftung.land', verbose_name='Länderei')),
],
options={
'verbose_name': 'Landabrechnung',
'verbose_name_plural': 'Landabrechnungen',
'ordering': ['-abrechnungsjahr', 'land__gemeinde', 'land__gemarkung'],
'unique_together': {('land', 'abrechnungsjahr')},
},
),
]

View File

@@ -0,0 +1,57 @@
# Generated by Django 5.0.6 on 2025-08-30 16:59
import django.core.validators
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('stiftung', '0021_land_adresse_land_aktueller_paechter_and_more'),
]
operations = [
migrations.AddField(
model_name='dokumentlink',
name='land_verpachtung_id',
field=models.UUIDField(blank=True, null=True, verbose_name='Landverpachtung ID (Neu)'),
),
migrations.AlterField(
model_name='dokumentlink',
name='verpachtung_id',
field=models.UUIDField(blank=True, null=True, verbose_name='Verpachtung ID (Legacy)'),
),
migrations.CreateModel(
name='LandVerpachtung',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('vertragsnummer', models.CharField(max_length=50, unique=True, verbose_name='Vertragsnummer')),
('pachtbeginn', models.DateField(verbose_name='Pachtbeginn')),
('pachtende', models.DateField(blank=True, null=True, verbose_name='Pachtende')),
('verlaengerung_klausel', models.BooleanField(default=False, verbose_name='Automatische Verlängerung')),
('verpachtete_flaeche', models.DecimalField(decimal_places=2, max_digits=12, validators=[django.core.validators.MinValueValidator(0.01)], verbose_name='Verpachtete Fläche (qm)')),
('pachtzins_pauschal', models.DecimalField(decimal_places=2, max_digits=12, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Pachtzins pauschal/Jahr (€)')),
('pachtzins_pro_ha', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Pachtzins pro ha (€)')),
('zahlungsweise', models.CharField(choices=[('jaehrlich', 'Jährlich'), ('halbjaehrlich', 'Halbjährlich'), ('vierteljaehrlich', 'Vierteljährlich'), ('monatlich', 'Monatlich')], default='jaehrlich', max_length=20, verbose_name='Zahlungsweise')),
('ust_option', models.BooleanField(default=False, verbose_name='USt-Option')),
('ust_satz', models.DecimalField(decimal_places=2, default=19.0, max_digits=4, verbose_name='USt-Satz (%)')),
('grundsteuer_umlage', models.BooleanField(default=True, verbose_name='Grundsteuer umlagefähig')),
('versicherungen_umlage', models.BooleanField(default=True, verbose_name='Versicherungen umlagefähig')),
('verbandsbeitraege_umlage', models.BooleanField(default=True, verbose_name='Verbandsbeiträge umlagefähig')),
('jagdpacht_anteil_umlage', models.BooleanField(default=False, verbose_name='Jagdpachtanteile umlagefähig')),
('status', models.CharField(choices=[('aktiv', 'Aktiv'), ('beendet', 'Beendet'), ('gekuendigt', 'Gekündigt'), ('verlängert', 'Verlängert')], default='aktiv', max_length=20, verbose_name='Status')),
('bemerkungen', models.TextField(blank=True, null=True, verbose_name='Bemerkungen')),
('erstellt_am', models.DateTimeField(auto_now_add=True)),
('aktualisiert_am', models.DateTimeField(auto_now=True)),
('land', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='neue_verpachtungen', to='stiftung.land', verbose_name='Länderei')),
('paechter', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='neue_verpachtungen', to='stiftung.paechter', verbose_name='Pächter')),
],
options={
'verbose_name': 'Landverpachtung',
'verbose_name_plural': 'Landverpachtungen',
'ordering': ['-pachtbeginn', 'land'],
},
),
]

View File

1667
app/stiftung/models.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,12 @@
from rest_framework import serializers
from .models import Person, Foerderung
class PersonSerializer(serializers.ModelSerializer):
class Meta:
model = Person
fields = "__all__"
class FoerderungSerializer(serializers.ModelSerializer):
class Meta:
model = Foerderung
fields = "__all__"

148
app/stiftung/urls.py Normal file
View File

@@ -0,0 +1,148 @@
from django.urls import path
from . import views
app_name = 'stiftung'
urlpatterns = [
# Dashboard (Startseite)
path('', views.dashboard, name='dashboard'),
# Home (für Kompatibilität mit bestehenden Templates)
path('home/', views.home, name='home'),
# CSV Import URLs
path('import/', views.csv_import_list, name='csv_import_list'),
path('import/neu/', views.csv_import_create, name='csv_import_create'),
# Destinatär URLs (Förderungsempfänger)
path('destinataere/', views.destinataer_list, name='destinataer_list'),
path('destinataere/<uuid:pk>/', views.destinataer_detail, name='destinataer_detail'),
path('destinataere/neu/', views.destinataer_create, name='destinataer_create'),
path('destinataere/<uuid:pk>/bearbeiten/', views.destinataer_update, name='destinataer_update'),
path('destinataere/<uuid:pk>/loeschen/', views.destinataer_delete, name='destinataer_delete'),
path('destinataere/<uuid:pk>/notiz/', views.destinataer_notiz_create, name='destinataer_notiz_create'),
path('destinataere/<uuid:pk>/export/', views.destinataer_export, name='destinataer_export'),
# Paechter URLs (Landpächter)
path('paechter/', views.paechter_list, name='paechter_list'),
path('paechter/<uuid:pk>/', views.paechter_detail, name='paechter_detail'),
path('paechter/neu/', views.paechter_create, name='paechter_create'),
path('paechter/<uuid:pk>/bearbeiten/', views.paechter_update, name='paechter_update'),
path('paechter/<uuid:pk>/loeschen/', views.paechter_delete, name='paechter_delete'),
path('paechter/<uuid:pk>/export/', views.paechter_export, name='paechter_export'),
# Legacy Person URLs removed (Destinatäre ersetzen Personen)
# Land URLs
path('laendereien/', views.land_list, name='land_list'),
path('laendereien/<uuid:pk>/', views.land_detail, name='land_detail'),
path('laendereien/neu/', views.land_create, name='land_create'),
path('laendereien/<uuid:pk>/bearbeiten/', views.land_update, name='land_update'),
path('laendereien/<uuid:pk>/loeschen/', views.land_delete, name='land_delete'),
path('laendereien/<uuid:pk>/export/', views.land_export, name='land_export'),
# Landabrechnung URLs
path('landabrechnungen/', views.land_abrechnung_list, name='land_abrechnung_list'),
path('landabrechnungen/<uuid:pk>/', views.land_abrechnung_detail, name='land_abrechnung_detail'),
path('landabrechnungen/neu/', views.land_abrechnung_create, name='land_abrechnung_create'),
path('landabrechnungen/<uuid:pk>/bearbeiten/', views.land_abrechnung_update, name='land_abrechnung_update'),
path('landabrechnungen/<uuid:pk>/loeschen/', views.land_abrechnung_delete, name='land_abrechnung_delete'),
# Vereinheitlichte Verpachtung URLs (direkt im Land)
path('laendereien/<uuid:land_pk>/verpachtung/neu/', views.land_verpachtung_create, name='land_verpachtung_create'),
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'),
# Förderung URLs
path('foerderungen/', views.foerderung_list, name='foerderung_list'),
path('foerderungen/<uuid:pk>/', views.foerderung_detail, name='foerderung_detail'),
path('foerderungen/neu/', views.foerderung_create, name='foerderung_create'),
path('foerderungen/<uuid:pk>/bearbeiten/', views.foerderung_update, name='foerderung_update'),
path('foerderungen/<uuid:pk>/loeschen/', views.foerderung_delete, name='foerderung_delete'),
# Dokumente URLs
path('dokumente/', views.dokument_list, name='dokument_list'),
path('dokumente/<uuid:pk>/', views.dokument_detail, name='dokument_detail'),
path('dokumente/neu/', views.dokument_create, name='dokument_create'),
path('dokumente/<uuid:pk>/bearbeiten/', views.dokument_update, name='dokument_update'),
path('dokumente/<uuid:pk>/loeschen/', views.dokument_delete, name='dokument_delete'),
# Dokumentenverwaltung (Paperless-Integration, Verwaltung & Verknüpfung)
path('dokumente/verwaltung/', views.dokument_management, name='dokument_management'),
# Legacy document URLs removed - use dokument_management instead
# Dokument-Verknüpfung
path('api/link-document/search/', views.link_document_search, name='link_document_search'),
path('api/link-document/create/', views.link_document_create, name='link_document_create'),
path('api/link-document/list/', views.link_document_list, name='link_document_list'),
path('api/link-document/update/', views.link_document_update, name='link_document_update'),
path('api/link-document/delete/<uuid:link_id>/', views.link_document_delete, name='link_document_delete'),
# Legacy dokument_verknuepfung URL removed - use dokument_management instead
# Jahresbericht URLs
path('berichte/', views.bericht_list, name='bericht_list'),
path('berichte/jahresbericht/', views.jahresbericht_generate_redirect, name='jahresbericht_generate_redirect'),
path('berichte/jahresbericht/<int:jahr>/', views.jahresbericht_generate, name='jahresbericht_generate'),
path('berichte/jahresbericht/<int:jahr>/pdf/', views.jahresbericht_pdf, name='jahresbericht_pdf'),
# Geschäftsführung URLs
path('geschaeftsfuehrung/', views.geschaeftsfuehrung, name='geschaeftsfuehrung'),
path('geschaeftsfuehrung/konten/', views.konto_list, name='konto_list'),
path('geschaeftsfuehrung/konten/neu/', views.konto_create, name='konto_create'),
path('geschaeftsfuehrung/konten/<uuid:pk>/', views.konto_detail, name='konto_detail'),
path('geschaeftsfuehrung/konten/<uuid:pk>/bearbeiten/', views.konto_edit, name='konto_edit'),
path('geschaeftsfuehrung/verwaltungskosten/', views.verwaltungskosten_list, name='verwaltungskosten_list'),
path('geschaeftsfuehrung/verwaltungskosten/neu/', views.verwaltungskosten_create, name='verwaltungskosten_create'),
path('geschaeftsfuehrung/verwaltungskosten/<uuid:pk>/bearbeiten/', views.verwaltungskosten_edit, name='verwaltungskosten_edit'),
path('verwaltungskosten/mark-paid/', views.mark_expense_paid, name='mark_expense_paid'),
path('geschaeftsfuehrung/rentmeister/', views.rentmeister_list, name='rentmeister_list'),
path('geschaeftsfuehrung/rentmeister/neu/', views.rentmeister_create, name='rentmeister_create'),
path('geschaeftsfuehrung/rentmeister/<uuid:pk>/', views.rentmeister_detail, name='rentmeister_detail'),
path('geschaeftsfuehrung/rentmeister/<uuid:pk>/bearbeiten/', views.rentmeister_edit, name='rentmeister_edit'),
path('geschaeftsfuehrung/rentmeister/<uuid:pk>/ausgaben/', views.rentmeister_ausgaben, name='rentmeister_ausgaben'),
# Administration URLs
path('administration/', views.administration, name='administration'),
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'),
path('administration/backup/restore/', views.backup_restore, name='backup_restore'),
path('administration/unterstuetzungen/', views.unterstuetzungen_list, name='unterstuetzungen_list'),
path('administration/unterstuetzungen/<uuid:pk>/bearbeiten/', views.unterstuetzung_edit, name='unterstuetzung_edit'),
path('administration/unterstuetzungen/<uuid:pk>/loeschen/', views.unterstuetzung_delete, name='unterstuetzung_delete'),
# Authentication URLs
path('login/', views.user_login, name='login'),
path('logout/', views.user_logout, name='logout'),
# User Management URLs
path('administration/users/', views.user_management, name='user_management'),
path('administration/users/create/', views.user_create, name='user_create'),
path('administration/users/<int:pk>/', views.user_detail, name='user_detail'),
path('administration/users/<int:pk>/edit/', views.user_edit, name='user_edit'),
path('administration/users/<int:pk>/password/', views.user_change_password, name='user_change_password'),
path('administration/users/<int:pk>/permissions/', views.user_permissions, name='user_permissions'),
path('administration/users/<int:pk>/delete/', views.user_delete, name='user_delete'),
# API URLs
path('api/land-stats/', views.land_stats_api, name='land_stats_api'),
path('api/health/', views.health_check, name='health_check'),
path('api/paperless/ping/', views.paperless_ping, name='paperless_ping'),
path('api/paperless/documents/', views.paperless_documents, name='paperless_documents'),
path('api/paperless/tags/', views.paperless_tags_only, name='paperless_tags_only'),
path('api/paperless/debug/', views.paperless_debug, name='paperless_debug'),
path('api/paperless/documents/<int:doc_id>/', views.paperless_document_redirect, name='paperless_document_redirect'),
# Gramps integration (probe)
path('api/gramps/search/', views.gramps_search_api, name='gramps_search_api'),
path('api/gramps/debug/', views.gramps_debug_api, name='gramps_debug_api'),
]

4897
app/stiftung/views.py Normal file

File diff suppressed because it is too large Load Diff