Phase 0: forms.py, admin.py und views.py in Domain-Packages aufteilen

- forms.py → forms/ Package (8 Domänen: destinataere, land, finanzen,
  foerderung, dokumente, veranstaltung, system, geschichte)
- admin.py → admin/ Package (7 Domänen, alle 22 @admin.register dekoriert)
- views.py (8845 Zeilen) → views/ Package (10 Domänen: dashboard, destinataere,
  land, paechter, finanzen, foerderung, dokumente, unterstuetzungen,
  veranstaltung, geschichte, system)
- __init__.py in jedem Package re-exportiert alle Symbole für Rückwärtskompatibilität
- urls.py bleibt unverändert (funktioniert durch Re-Exports)
- Django system check: 0 Fehler, alle URL-Auflösungen funktionieren

Keine funktionalen Änderungen – reine Strukturverbesserung für Vision 2026.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
SysAdmin Agent
2026-03-11 09:55:15 +00:00
parent 7e9e4fddf1
commit 3ca2706e5d
31 changed files with 12891 additions and 12042 deletions

View File

@@ -0,0 +1,199 @@
# views/__init__.py
# Phase 0: Vision 2026 Re-exportiert alle View-Funktionen für Rückwärtskompatibilität
from .dashboard import ( # noqa: F401
home,
health_check,
health,
)
from .destinataere import ( # noqa: F401
person_list,
person_detail,
person_create,
person_update,
person_delete,
destinataer_list,
destinataer_detail,
destinataer_create,
destinataer_update,
destinataer_delete,
destinataer_notiz_create,
destinataer_export,
)
from .dokumente import ( # noqa: F401
dokument_management,
paperless_document_redirect,
dokument_list,
dokument_detail,
dokument_create,
dokument_update,
dokument_delete,
paperless_ping,
paperless_documents,
paperless_debug,
paperless_tags_only,
link_document_search,
create_paechter_link_for_verpachtung,
link_document_create,
link_document_list,
link_document_update,
link_document_delete,
)
from .finanzen import ( # noqa: F401
bericht_list,
jahresbericht_generate,
jahresbericht_generate_redirect,
jahresbericht_pdf,
geschaeftsfuehrung,
konto_list,
verwaltungskosten_list,
rentmeister_list,
rentmeister_detail,
rentmeister_ausgaben,
rentmeister_create,
rentmeister_edit,
konto_create,
konto_edit,
konto_detail,
verwaltungskosten_create,
verwaltungskosten_edit,
verwaltungskosten_delete,
mark_expense_paid,
)
from .foerderung import ( # noqa: F401
foerderung_list,
foerderung_detail,
foerderung_create,
foerderung_update,
foerderung_delete,
)
from .geschichte import ( # noqa: F401
geschichte_list,
geschichte_detail,
geschichte_create,
geschichte_edit,
geschichte_bild_upload,
geschichte_bild_delete,
kalender_view,
kalender_create,
kalender_detail,
kalender_edit,
kalender_delete,
kalender_admin,
kalender_api_events,
email_eingang_list,
email_eingang_detail,
email_eingang_poll_trigger,
)
from .land import ( # noqa: F401
paechter_list,
paechter_detail,
paechter_create,
paechter_update,
paechter_delete,
land_list,
land_detail,
land_create,
land_update,
land_delete,
verpachtung_list,
land_verpachtung_detail,
land_verpachtung_update,
land_verpachtung_end_direct,
land_stats_api,
paechter_export,
land_export,
verpachtung_export,
land_abrechnung_list,
land_abrechnung_detail,
land_abrechnung_create,
land_abrechnung_update,
land_abrechnung_delete,
land_verpachtung_create,
land_verpachtung_end,
land_verpachtung_edit,
verpachtung_detail,
verpachtung_create,
verpachtung_update,
verpachtung_delete,
)
from .system import ( # noqa: F401
get_pdf_generator,
GrampsClient,
get_gramps_client,
gramps_debug_api,
csv_import_list,
csv_import_create,
process_personen_csv,
process_destinataere_csv,
process_paechter_csv,
process_laendereien_csv,
gramps_search_api,
administration,
audit_log_list,
backup_management,
backup_download,
backup_restore,
backup_cancel,
user_management,
user_create,
user_detail,
user_edit,
user_change_password,
user_permissions,
user_delete,
user_login,
user_logout,
app_settings,
edit_help_box,
two_factor_setup,
two_factor_qr,
two_factor_verify,
two_factor_disable,
backup_tokens,
)
from .unterstuetzungen import ( # noqa: F401
unterstuetzungen_list,
export_unterstuetzungen_csv,
export_unterstuetzungen_pdf,
export_foerderungen_csv,
export_foerderungen_pdf,
unterstuetzung_edit,
unterstuetzung_delete,
unterstuetzungen_all,
unterstuetzung_create,
get_destinataer_info,
unterstuetzung_detail,
unterstuetzung_mark_paid,
wiederkehrende_unterstuetzungen,
quarterly_confirmation_update,
create_quarterly_support_payment,
quarterly_confirmation_create,
quarterly_confirmation_edit,
quarterly_confirmation_approve,
quarterly_confirmation_reset,
)
from .veranstaltung import ( # noqa: F401
veranstaltung_list,
veranstaltung_detail,
veranstaltung_serienbrief_pdf,
veranstaltung_serienbrief_vorschau,
veranstaltung_create,
veranstaltung_update,
veranstaltung_delete,
teilnehmer_create,
teilnehmer_update,
teilnehmer_delete,
)
# Non-view exports (helpers used elsewhere)
from .system import GrampsClient, get_gramps_client, get_pdf_generator # noqa: F401

View File

@@ -0,0 +1,117 @@
# views/dashboard.py
# Phase 0: Vision 2026 Code-Refactoring
import csv
import io
import json
import os
import time
from datetime import datetime, timedelta, date
from decimal import Decimal
import qrcode
import qrcode.image.svg
import requests
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.db.models import (Avg, Count, DecimalField, F, IntegerField, Q,
Sum, Value)
from django.db.models.functions import Cast, Coalesce, NullIf, Replace
from django.http import HttpResponse, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils import timezone
from django.views.decorators.csrf import csrf_exempt
from django_otp.decorators import otp_required
from django_otp.plugins.otp_totp.models import TOTPDevice
from django_otp.plugins.otp_static.models import StaticDevice, StaticToken
from django_otp.util import random_hex
from rest_framework.decorators import api_view
from rest_framework.response import Response
from stiftung.models import (AppConfiguration, AuditLog, BackupJob, BankTransaction,
BriefVorlage, CSVImport, Destinataer,
DestinataerEmailEingang, DestinataerNotiz,
DestinataerUnterstuetzung,
DokumentLink, Foerderung, GeschichteBild, GeschichteSeite,
Land, LandAbrechnung, LandVerpachtung, Paechter, Person,
Rentmeister, StiftungsKalenderEintrag, StiftungsKonto,
UnterstuetzungWiederkehrend, Veranstaltung,
Veranstaltungsteilnehmer, Verwaltungskosten,
VierteljahresNachweis)
from stiftung.forms import (
DestinataerForm, DestinataerUnterstuetzungForm, DestinataerNotizForm,
FoerderungForm, GeschichteBildForm, GeschichteSeiteForm,
LandForm, LandVerpachtungForm, LandAbrechnungForm,
PaechterForm, DokumentLinkForm,
RentmeisterForm, StiftungsKontoForm, VerwaltungskostenForm,
BankTransactionForm, BankImportForm,
UnterstuetzungForm, UnterstuetzungWiederkehrendForm,
UnterstuetzungMarkAsPaidForm, VierteljahresNachweisForm,
UserCreationForm, UserUpdateForm, PasswordChangeForm, UserPermissionForm,
TwoFactorSetupForm, TwoFactorVerifyForm, TwoFactorDisableForm,
BackupTokenRegenerateForm, PersonForm,
VeranstaltungForm, VeranstaltungsteilnehmerForm,
)
@login_required
def home(request):
"""Home page for the Stiftungsverwaltung application"""
from stiftung.services.calendar_service import StiftungsKalenderService
# Get upcoming events for the calendar widget
calendar_service = StiftungsKalenderService()
# Get all events for the next 14 days
from datetime import timedelta
today = timezone.now().date()
end_date = today + timedelta(days=14)
all_events = calendar_service.get_all_events(today, end_date)
# Filter for upcoming and overdue
upcoming_events = [e for e in all_events if not getattr(e, 'overdue', False)]
overdue_events = [e for e in all_events if getattr(e, 'overdue', False)]
# Get current month events for mini calendar
from calendar import monthrange
_, last_day = monthrange(today.year, today.month)
month_start = today.replace(day=1)
month_end = today.replace(day=last_day)
current_month_events = calendar_service.get_all_events(month_start, month_end)
context = {
"title": "Stiftungsverwaltung",
"description": "Foundation Management System",
"upcoming_events": upcoming_events[:5], # Show only 5 upcoming events
"overdue_events": overdue_events[:3], # Show only 3 overdue events
"current_month_events": current_month_events,
"today": today,
}
return render(request, "stiftung/home.html", context)
@api_view(["GET"])
def health_check(request):
"""Simple health check endpoint for deployment monitoring"""
return JsonResponse(
{
"status": "healthy",
"timestamp": timezone.now().isoformat(),
"service": "stiftung-web",
}
)
## Removed duplicate paperless_ping referencing non-existent PAPERLESS_URL
# CSV Import Views
@api_view(["GET"])
def health(_request):
return Response({"status": "ok"})

View File

@@ -0,0 +1,697 @@
# views/destinataere.py
# Phase 0: Vision 2026 Code-Refactoring
import csv
import io
import json
import os
import time
from datetime import datetime, timedelta, date
from decimal import Decimal
import qrcode
import qrcode.image.svg
import requests
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.db.models import (Avg, Count, DecimalField, F, IntegerField, Q,
Sum, Value)
from django.db.models.functions import Cast, Coalesce, NullIf, Replace
from django.http import HttpResponse, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils import timezone
from django.views.decorators.csrf import csrf_exempt
from django_otp.decorators import otp_required
from django_otp.plugins.otp_totp.models import TOTPDevice
from django_otp.plugins.otp_static.models import StaticDevice, StaticToken
from django_otp.util import random_hex
from rest_framework.decorators import api_view
from rest_framework.response import Response
from stiftung.models import (AppConfiguration, AuditLog, BackupJob, BankTransaction,
BriefVorlage, CSVImport, Destinataer,
DestinataerEmailEingang, DestinataerNotiz,
DestinataerUnterstuetzung,
DokumentLink, Foerderung, GeschichteBild, GeschichteSeite,
Land, LandAbrechnung, LandVerpachtung, Paechter, Person,
Rentmeister, StiftungsKalenderEintrag, StiftungsKonto,
UnterstuetzungWiederkehrend, Veranstaltung,
Veranstaltungsteilnehmer, Verwaltungskosten,
VierteljahresNachweis)
from stiftung.forms import (
DestinataerForm, DestinataerUnterstuetzungForm, DestinataerNotizForm,
FoerderungForm, GeschichteBildForm, GeschichteSeiteForm,
LandForm, LandVerpachtungForm, LandAbrechnungForm,
PaechterForm, DokumentLinkForm,
RentmeisterForm, StiftungsKontoForm, VerwaltungskostenForm,
BankTransactionForm, BankImportForm,
UnterstuetzungForm, UnterstuetzungWiederkehrendForm,
UnterstuetzungMarkAsPaidForm, VierteljahresNachweisForm,
UserCreationForm, UserUpdateForm, PasswordChangeForm, UserPermissionForm,
TwoFactorSetupForm, TwoFactorVerifyForm, TwoFactorDisableForm,
BackupTokenRegenerateForm, PersonForm,
VeranstaltungForm, VeranstaltungsteilnehmerForm,
)
@login_required
def person_list(request):
search_query = request.GET.get("search", "")
familienzweig_filter = request.GET.get("familienzweig", "")
aktiv_filter = request.GET.get("aktiv", "")
persons = Person.objects.all()
if search_query:
persons = persons.filter(
Q(nachname__icontains=search_query)
| Q(vorname__icontains=search_query)
| Q(email__icontains=search_query)
| Q(familienzweig__icontains=search_query)
)
if familienzweig_filter:
persons = persons.filter(familienzweig=familienzweig_filter)
if aktiv_filter == "true":
persons = persons.filter(aktiv=True)
elif aktiv_filter == "false":
persons = persons.filter(aktiv=False)
# Annotate with total funding
persons = persons.annotate(total_foerderungen=Sum("foerderung__betrag"))
paginator = Paginator(persons, 20)
page_number = request.GET.get("page")
page_obj = paginator.get_page(page_number)
context = {
"page_obj": page_obj,
"search_query": search_query,
"familienzweig_filter": familienzweig_filter,
"aktiv_filter": aktiv_filter,
"familienzweig_choices": Person.FAMILIENZWIG_CHOICES,
}
return render(request, "stiftung/person_list.html", context)
@login_required
def person_detail(request, pk):
person = get_object_or_404(Person, pk=pk)
foerderungen = person.foerderung_set.all().order_by("-jahr", "-betrag")
# Get new LandVerpachtungen for this person's Paechter instances
verpachtungen = LandVerpachtung.objects.filter(paechter__person=person).order_by(
"-pachtbeginn"
)
context = {
"person": person,
"foerderungen": foerderungen,
"verpachtungen": verpachtungen,
}
return render(request, "stiftung/person_detail.html", context)
@login_required
def person_create(request):
if request.method == "POST":
form = PersonForm(request.POST)
if form.is_valid():
person = form.save()
messages.success(
request,
f'Person "{person.get_full_name()}" wurde erfolgreich erstellt.',
)
return redirect("stiftung:person_detail", pk=person.pk)
else:
form = PersonForm()
context = {"form": form, "title": "Neue Person erstellen"}
return render(request, "stiftung/person_form.html", context)
@login_required
def person_update(request, pk):
person = get_object_or_404(Person, pk=pk)
if request.method == "POST":
form = PersonForm(request.POST, instance=person)
if form.is_valid():
person = form.save()
messages.success(
request,
f'Person "{person.get_full_name()}" wurde erfolgreich aktualisiert.',
)
return redirect("stiftung:person_detail", pk=person.pk)
else:
form = PersonForm(instance=person)
context = {
"form": form,
"person": person,
"title": f"Person bearbeiten: {person.get_full_name()}",
}
return render(request, "stiftung/person_form.html", context)
@login_required
def person_delete(request, pk):
person = get_object_or_404(Person, pk=pk)
if request.method == "POST":
person.delete()
messages.success(
request, f'Person "{person.get_full_name()}" wurde erfolgreich gelöscht.'
)
return redirect("stiftung:person_list")
context = {"person": person}
return render(request, "stiftung/person_confirm_delete.html", context)
# Destinatär Views (Förderungsempfänger)
@login_required
def destinataer_list(request):
search_query = request.GET.get("search", "")
familienzweig_filter = request.GET.get("familienzweig", "")
berufsgruppe_filter = request.GET.get("berufsgruppe", "")
aktiv_filter = request.GET.get("aktiv", "")
sort = request.GET.get("sort", "")
direction = request.GET.get("dir", "asc")
destinataere = Destinataer.objects.all()
if search_query:
destinataere = destinataere.filter(
Q(nachname__icontains=search_query)
| Q(vorname__icontains=search_query)
| Q(email__icontains=search_query)
| Q(institution__icontains=search_query)
| Q(familienzweig__icontains=search_query)
)
if familienzweig_filter:
destinataere = destinataere.filter(familienzweig=familienzweig_filter)
if berufsgruppe_filter:
destinataere = destinataere.filter(berufsgruppe=berufsgruppe_filter)
if aktiv_filter == "true":
destinataere = destinataere.filter(aktiv=True)
elif aktiv_filter == "false":
destinataere = destinataere.filter(aktiv=False)
# Annotate with total funding (coalesce nulls to Decimal for stable sorting)
destinataere = destinataere.annotate(
total_foerderungen=Coalesce(
Sum("foerderung__betrag"),
Value(
Decimal("0.00"),
output_field=DecimalField(max_digits=12, decimal_places=2),
),
output_field=DecimalField(max_digits=12, decimal_places=2),
)
)
# Sorting
sort_map = {
"vorname": ["vorname"],
"nachname": ["nachname"],
"email": ["email"],
"vierteljaehrlicher_betrag": ["vierteljaehrlicher_betrag"],
"letzter_studiennachweis": ["letzter_studiennachweis"],
"unterstuetzung_bestaetigt": ["unterstuetzung_bestaetigt"],
# Keep old mappings for backward compatibility
"name": ["nachname", "vorname"],
"familienzweig": ["familienzweig"],
"berufsgruppe": ["berufsgruppe"],
"institution": ["institution"],
"foerderungen": ["total_foerderungen"],
"status": ["aktiv"],
}
if sort in sort_map:
fields = sort_map[sort]
if direction == "desc":
order_fields = [f"-{f}" for f in fields]
else:
order_fields = fields
destinataere = destinataere.order_by(*order_fields)
else:
# Default sorting by last name (nachname) ascending
destinataere = destinataere.order_by("nachname", "vorname")
paginator = Paginator(destinataere, 50) # Increased from 20 to 50 entries per page
page_number = request.GET.get("page")
page_obj = paginator.get_page(page_number)
# Set default sort to nachname if no sort is specified
effective_sort = sort if sort else "nachname"
effective_direction = direction if sort else "asc"
context = {
"page_obj": page_obj,
"search_query": search_query,
"familienzweig_filter": familienzweig_filter,
"berufsgruppe_filter": berufsgruppe_filter,
"aktiv_filter": aktiv_filter,
"familienzweig_choices": Destinataer.FAMILIENZWIG_CHOICES,
"berufsgruppe_choices": Destinataer.BERUFSGRUPPE_CHOICES,
"sort": effective_sort,
"dir": effective_direction,
}
return render(request, "stiftung/destinataer_list.html", context)
@login_required
def destinataer_detail(request, pk):
destinataer = get_object_or_404(Destinataer, pk=pk)
# Alle mit diesem Destinatär verknüpften Dokumente laden
verknuepfte_dokumente = DokumentLink.objects.filter(
destinataer_id=destinataer.pk
).order_by("kontext", "titel")
# Förderungen für diesen Destinatär laden
foerderungen = Foerderung.objects.filter(destinataer=destinataer).order_by(
"-jahr", "-betrag"
)
# Unterstützungen für diesen Destinatär laden
unterstuetzungen = DestinataerUnterstuetzung.objects.filter(
destinataer=destinataer
).order_by("-faellig_am")
# Notizen laden
notizen_eintraege = DestinataerNotiz.objects.filter(
destinataer=destinataer
).order_by("-erstellt_am")
# Quarterly confirmations - load for current and next year
from datetime import date
current_year = date.today().year
quarterly_confirmations = VierteljahresNachweis.objects.filter(
destinataer=destinataer,
jahr__in=[current_year, current_year + 1]
).order_by('-jahr', '-quartal')
# Create missing quarterly confirmations for current year
# Quarterly tracking is now always available regardless of study proof requirements
for quartal in range(1, 5): # Q1-Q4
nachweis, created = VierteljahresNachweis.get_or_create_for_period(
destinataer, current_year, quartal
)
# Reload to get any newly created confirmations
quarterly_confirmations = VierteljahresNachweis.objects.filter(
destinataer=destinataer,
jahr__in=[current_year, current_year + 1]
).order_by('-jahr', '-quartal')
# Modal forms removed - only using full-screen editor now
# Generate available years for the add quarter dropdown (current year + next 5 years)
available_years = list(range(current_year, current_year + 6))
# Alle verfügbaren StiftungsKonten für das Select-Feld laden
stiftungskonten = StiftungsKonto.objects.all().order_by("kontoname")
context = {
"destinataer": destinataer,
"verknuepfte_dokumente": verknuepfte_dokumente,
"foerderungen": foerderungen,
"unterstuetzungen": unterstuetzungen,
"notizen_eintraege": notizen_eintraege,
"stiftungskonten": stiftungskonten,
"quarterly_confirmations": quarterly_confirmations,
"available_years": available_years,
"current_year": current_year,
}
return render(request, "stiftung/destinataer_detail.html", context)
@login_required
def destinataer_create(request):
if request.method == "POST":
form = DestinataerForm(request.POST)
if form.is_valid():
destinataer = form.save()
messages.success(
request,
f'Destinatär "{destinataer.get_full_name()}" wurde erfolgreich erstellt.',
)
return redirect("stiftung:destinataer_detail", pk=destinataer.pk)
else:
form = DestinataerForm()
context = {"form": form, "title": "Neuen Destinatär erstellen"}
return render(request, "stiftung/destinataer_form.html", context)
@login_required
def destinataer_update(request, pk):
destinataer = get_object_or_404(Destinataer, pk=pk)
if request.method == "POST":
form = DestinataerForm(request.POST, instance=destinataer)
# Handle AJAX requests
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
if form.is_valid():
try:
destinataer = form.save()
# Note: Support payments are now only created through quarterly confirmations
# No automatic creation when unterstuetzung_bestaetigt is checked
return JsonResponse({
'success': True,
'message': f'Destinatär "{destinataer.get_full_name()}" wurde erfolgreich aktualisiert.'
})
except Exception as e:
return JsonResponse({
'success': False,
'error': f'Fehler beim Speichern: {str(e)}'
})
else:
# Return form errors for AJAX requests
errors = []
for field, field_errors in form.errors.items():
for error in field_errors:
errors.append(f'{form[field].label}: {error}')
return JsonResponse({
'success': False,
'error': 'Formular enthält Fehler: ' + '; '.join(errors)
})
# Handle regular form submission
if form.is_valid():
destinataer = form.save()
# Note: Support payments are now only created through quarterly confirmations
# No automatic creation when unterstuetzung_bestaetigt is checked
messages.success(
request,
f'Destinatär "{destinataer.get_full_name()}" wurde erfolgreich aktualisiert.',
)
return redirect("stiftung:destinataer_detail", pk=destinataer.pk)
else:
form = DestinataerForm(instance=destinataer)
context = {
"form": form,
"destinataer": destinataer,
"title": f"Destinatär bearbeiten: {destinataer.get_full_name()}",
}
return render(request, "stiftung/destinataer_form.html", context)
@login_required
def destinataer_delete(request, pk):
destinataer = get_object_or_404(Destinataer, pk=pk)
if request.method == "POST":
destinataer.delete()
messages.success(
request,
f'Destinatär "{destinataer.get_full_name()}" wurde erfolgreich gelöscht.',
)
return redirect("stiftung:destinataer_list")
context = {"destinataer": destinataer}
return render(request, "stiftung/destinataer_confirm_delete.html", context)
# Paechter Views (Landpächter)
@login_required
def destinataer_notiz_create(request, pk):
destinataer = get_object_or_404(Destinataer, pk=pk)
if request.method == "POST":
form = DestinataerNotizForm(request.POST, request.FILES)
if form.is_valid():
note = form.save(commit=False)
note.destinataer = destinataer
note.erstellt_von = request.user
note.save()
messages.success(request, "Notiz wurde gespeichert.")
return redirect("stiftung:destinataer_detail", pk=destinataer.pk)
else:
# Debug: show what validation failed
for field, errors in form.errors.items():
messages.error(request, f'Fehler in {field}: {", ".join(errors)}')
else:
form = DestinataerNotizForm()
return render(
request,
"stiftung/destinataer_notiz_form.html",
{"form": form, "destinataer": destinataer, "title": "Notiz hinzufügen"},
)
@login_required
def destinataer_export(request, pk):
"""Export complete Destinatär data as ZIP with documents"""
import json
import os
import tempfile
import zipfile
from django.http import HttpResponse
destinataer = get_object_or_404(Destinataer, pk=pk)
# Create a temporary file for the ZIP
temp_file = tempfile.NamedTemporaryFile(delete=False, suffix=".zip")
try:
with zipfile.ZipFile(temp_file.name, "w", zipfile.ZIP_DEFLATED) as zipf:
# 1. Entity data as JSON
entity_data = {
"id": str(destinataer.id),
"anrede": destinataer.get_anrede_display() if hasattr(destinataer, 'anrede') and destinataer.anrede else None,
"titel": destinataer.titel if hasattr(destinataer, 'titel') else None,
"vorname": destinataer.vorname,
"nachname": destinataer.nachname,
"geburtsdatum": (
destinataer.geburtsdatum.isoformat()
if destinataer.geburtsdatum
else None
),
"email": destinataer.email,
"telefon": destinataer.telefon,
"mobil": destinataer.mobil if hasattr(destinataer, 'mobil') else None,
"iban": destinataer.iban,
"strasse": destinataer.strasse,
"plz": destinataer.plz,
"ort": destinataer.ort,
"familienzweig": destinataer.get_familienzweig_display(),
"berufsgruppe": destinataer.get_berufsgruppe_display(),
"ausbildungsstand": destinataer.ausbildungsstand,
"institution": destinataer.institution,
"projekt_beschreibung": destinataer.projekt_beschreibung,
"jaehrliches_einkommen": (
str(destinataer.jaehrliches_einkommen)
if destinataer.jaehrliches_einkommen
else None
),
"finanzielle_notlage": destinataer.finanzielle_notlage,
"ist_abkoemmling": destinataer.ist_abkoemmling,
"haushaltsgroesse": destinataer.haushaltsgroesse,
"monatliche_bezuege": (
str(destinataer.monatliche_bezuege)
if destinataer.monatliche_bezuege
else None
),
"vermoegen": (
str(destinataer.vermoegen) if destinataer.vermoegen else None
),
"unterstuetzung_bestaetigt": destinataer.unterstuetzung_bestaetigt,
"vierteljaehrlicher_betrag": (
str(destinataer.vierteljaehrlicher_betrag)
if destinataer.vierteljaehrlicher_betrag
else None
),
"standard_konto": (
str(destinataer.standard_konto)
if destinataer.standard_konto
else None
),
"studiennachweis_erforderlich": destinataer.studiennachweis_erforderlich,
"letzter_studiennachweis": (
destinataer.letzter_studiennachweis.isoformat()
if destinataer.letzter_studiennachweis
else None
),
"notizen": destinataer.notizen,
"aktiv": destinataer.aktiv,
"export_datum": timezone.now().isoformat(),
"export_user": request.user.username,
}
zipf.writestr(
"destinataer_data.json",
json.dumps(entity_data, indent=2, ensure_ascii=False),
)
# 2. Notes with attachments
notizen = DestinataerNotiz.objects.filter(destinataer=destinataer).order_by(
"-erstellt_am"
)
notes_data = []
for note in notizen:
note_data = {
"titel": note.titel,
"text": note.text,
"erstellt_am": note.erstellt_am.isoformat(),
"erstellt_von": (
note.erstellt_von.username if note.erstellt_von else None
),
"datei_name": note.datei.name if note.datei else None,
}
notes_data.append(note_data)
# Add attachment file if exists
if note.datei and os.path.exists(note.datei.path):
zipf.write(
note.datei.path,
f"notizen_anhaenge/{os.path.basename(note.datei.name)}",
)
if notes_data:
zipf.writestr(
"notizen.json", json.dumps(notes_data, indent=2, ensure_ascii=False)
)
# 3. Linked documents from Paperless
dokumente = DokumentLink.objects.filter(destinataer_id=destinataer.pk)
docs_data = []
for doc in dokumente:
doc_data = {
"paperless_id": doc.paperless_document_id,
"titel": doc.titel,
"kontext": doc.get_kontext_display(),
"beschreibung": doc.beschreibung,
}
docs_data.append(doc_data)
# Try to download document from Paperless
try:
if (
hasattr(settings, "PAPERLESS_API_URL")
and settings.PAPERLESS_API_URL
):
doc_url = f"{settings.PAPERLESS_API_URL}/api/documents/{doc.paperless_document_id}/download/"
headers = {}
if (
hasattr(settings, "PAPERLESS_API_TOKEN")
and settings.PAPERLESS_API_TOKEN
):
headers["Authorization"] = (
f"Token {settings.PAPERLESS_API_TOKEN}"
)
response = requests.get(doc_url, headers=headers, timeout=30)
if response.status_code == 200:
# Determine file extension from Content-Type or use .pdf as fallback
content_type = response.headers.get("content-type", "")
if "pdf" in content_type:
ext = ".pdf"
elif "jpeg" in content_type or "jpg" in content_type:
ext = ".jpg"
elif "png" in content_type:
ext = ".png"
else:
ext = ".pdf" # fallback
safe_filename = f"dokument_{doc.paperless_document_id}_{doc.titel.replace('/', '_')[:50]}{ext}"
zipf.writestr(
f"dokumente/{safe_filename}", response.content
)
doc_data["downloaded"] = True
else:
doc_data["download_error"] = f"HTTP {response.status_code}"
except Exception as e:
doc_data["download_error"] = str(e)
if docs_data:
zipf.writestr(
"dokumente.json",
json.dumps(docs_data, indent=2, ensure_ascii=False),
)
# 4. Quarterly Confirmations with documents
quarterly_confirmations = destinataer.quartalseinreichungen.all().order_by("-jahr", "-quartal")
quarterly_data = []
for confirmation in quarterly_confirmations:
confirmation_data = {
"id": str(confirmation.id),
"jahr": confirmation.jahr,
"quartal": confirmation.quartal,
"quartal_display": confirmation.get_quartal_display(),
"status": confirmation.status,
"status_display": confirmation.get_status_display(),
"studiennachweis_erforderlich": confirmation.studiennachweis_erforderlich,
"studiennachweis_eingereicht": confirmation.studiennachweis_eingereicht,
"studiennachweis_bemerkung": confirmation.studiennachweis_bemerkung,
"einkommenssituation_bestaetigt": confirmation.einkommenssituation_bestaetigt,
"einkommenssituation_text": confirmation.einkommenssituation_text,
"vermogenssituation_bestaetigt": confirmation.vermogenssituation_bestaetigt,
"vermogenssituation_text": confirmation.vermogenssituation_text,
"weitere_dokumente_beschreibung": confirmation.weitere_dokumente_beschreibung,
"interne_notizen": confirmation.interne_notizen,
"erstellt_am": confirmation.erstellt_am.isoformat(),
"aktualisiert_am": confirmation.aktualisiert_am.isoformat(),
"eingereicht_am": confirmation.eingereicht_am.isoformat() if confirmation.eingereicht_am else None,
"geprueft_am": confirmation.geprueft_am.isoformat() if confirmation.geprueft_am else None,
"geprueft_von": confirmation.geprueft_von.username if confirmation.geprueft_von else None,
"faelligkeitsdatum": confirmation.faelligkeitsdatum.isoformat() if confirmation.faelligkeitsdatum else None,
"completion_percentage": confirmation.get_completion_percentage(),
"uploaded_files": []
}
# Add uploaded files from quarterly confirmation
quarterly_files = [
("studiennachweis", confirmation.studiennachweis_datei),
("einkommenssituation", confirmation.einkommenssituation_datei),
("vermogenssituation", confirmation.vermogenssituation_datei),
("weitere_dokumente", confirmation.weitere_dokumente),
]
for file_type, file_field in quarterly_files:
if file_field and os.path.exists(file_field.path):
file_info = {
"type": file_type,
"name": os.path.basename(file_field.name),
"path": file_field.name
}
confirmation_data["uploaded_files"].append(file_info)
# Add file to ZIP
safe_filename = f"quarterly_{confirmation.jahr}_Q{confirmation.quartal}_{file_type}_{os.path.basename(file_field.name)}"
zipf.write(
file_field.path,
f"vierteljahresnachweis/{safe_filename}"
)
quarterly_data.append(confirmation_data)
if quarterly_data:
zipf.writestr(
"vierteljahresnachweis.json",
json.dumps(quarterly_data, indent=2, ensure_ascii=False),
)
# Prepare response
with open(temp_file.name, "rb") as f:
response = HttpResponse(f.read(), content_type="application/zip")
filename = f"destinataer_{destinataer.nachname}_{destinataer.vorname}_{timezone.now().strftime('%Y%m%d_%H%M%S')}.zip"
response["Content-Disposition"] = f'attachment; filename="{filename}"'
return response
finally:
# Clean up temp file
try:
os.unlink(temp_file.name)
except:
pass

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,743 @@
# views/finanzen.py
# Phase 0: Vision 2026 Code-Refactoring
import csv
import io
import json
import os
import time
from datetime import datetime, timedelta, date
from decimal import Decimal
import qrcode
import qrcode.image.svg
import requests
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.db.models import (Avg, Count, DecimalField, F, IntegerField, Q,
Sum, Value)
from django.db.models.functions import Cast, Coalesce, NullIf, Replace
from django.http import HttpResponse, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils import timezone
from django.views.decorators.csrf import csrf_exempt
from django_otp.decorators import otp_required
from django_otp.plugins.otp_totp.models import TOTPDevice
from django_otp.plugins.otp_static.models import StaticDevice, StaticToken
from django_otp.util import random_hex
from rest_framework.decorators import api_view
from rest_framework.response import Response
from stiftung.models import (AppConfiguration, AuditLog, BackupJob, BankTransaction,
BriefVorlage, CSVImport, Destinataer,
DestinataerEmailEingang, DestinataerNotiz,
DestinataerUnterstuetzung,
DokumentLink, Foerderung, GeschichteBild, GeschichteSeite,
Land, LandAbrechnung, LandVerpachtung, Paechter, Person,
Rentmeister, StiftungsKalenderEintrag, StiftungsKonto,
UnterstuetzungWiederkehrend, Veranstaltung,
Veranstaltungsteilnehmer, Verwaltungskosten,
VierteljahresNachweis)
from stiftung.forms import (
DestinataerForm, DestinataerUnterstuetzungForm, DestinataerNotizForm,
FoerderungForm, GeschichteBildForm, GeschichteSeiteForm,
LandForm, LandVerpachtungForm, LandAbrechnungForm,
PaechterForm, DokumentLinkForm,
RentmeisterForm, StiftungsKontoForm, VerwaltungskostenForm,
BankTransactionForm, BankImportForm,
UnterstuetzungForm, UnterstuetzungWiederkehrendForm,
UnterstuetzungMarkAsPaidForm, VierteljahresNachweisForm,
UserCreationForm, UserUpdateForm, PasswordChangeForm, UserPermissionForm,
TwoFactorSetupForm, TwoFactorVerifyForm, TwoFactorDisableForm,
BackupTokenRegenerateForm, PersonForm,
VeranstaltungForm, VeranstaltungsteilnehmerForm,
)
@login_required
def bericht_list(request):
"""List available reports"""
# Get available years from data
jahre = sorted(
set(
list(Foerderung.objects.values_list("jahr", flat=True))
+ list(LandVerpachtung.objects.values_list("pachtbeginn__year", flat=True))
),
reverse=True,
)
# Statistics for overview tiles (removed legacy Person and Verpachtung)
total_destinataere = Destinataer.objects.count()
total_laendereien = Land.objects.count()
total_verpachtungen = LandVerpachtung.objects.count()
total_foerderungen = Foerderung.objects.count()
context = {
"jahre": jahre,
"title": "Berichte",
"total_destinataere": total_destinataere,
"total_laendereien": total_laendereien,
"total_verpachtungen": total_verpachtungen,
"total_foerderungen": total_foerderungen,
}
return render(request, "stiftung/bericht_list.html", context)
@login_required
def jahresbericht_generate(request, jahr):
"""Generate annual report for a specific year"""
# Get data for the year
foerderungen = Foerderung.objects.filter(jahr=jahr).select_related("person")
verpachtungen = LandVerpachtung.objects.filter(
pachtbeginn__year__lte=jahr, pachtende__year__gte=jahr
).select_related("land", "paechter")
# Calculate statistics
total_foerderungen = foerderungen.aggregate(total=Sum("betrag"))["total"] or 0
total_pachtzins = (
verpachtungen.aggregate(total=Sum("pachtzins_pauschal"))["total"] or 0
)
context = {
"jahr": jahr,
"foerderungen": foerderungen,
"verpachtungen": verpachtungen,
"total_foerderungen": total_foerderungen,
"total_pachtzins": total_pachtzins,
"title": f"Jahresbericht {jahr}",
}
return render(request, "stiftung/jahresbericht.html", context)
@login_required
def jahresbericht_generate_redirect(request):
"""Redirects the GET form without path param to the proper URL using the provided query param 'jahr'."""
jahr = request.GET.get("jahr")
if jahr and str(jahr).isdigit():
return redirect("stiftung:jahresbericht_generate", jahr=int(jahr))
messages.error(request, "Bitte wählen Sie ein gültiges Jahr aus.")
return redirect("stiftung:bericht_list")
@login_required
def jahresbericht_pdf(request, jahr):
"""Generate PDF version of annual report"""
from django.http import HttpResponse
from django.template.loader import render_to_string
from weasyprint import HTML
# Get data for the year
foerderungen = Foerderung.objects.filter(jahr=jahr).select_related("person")
verpachtungen = LandVerpachtung.objects.filter(
pachtbeginn__year__lte=jahr, pachtende__year__gte=jahr
).select_related("land", "paechter")
# Calculate statistics
total_foerderungen = foerderungen.aggregate(total=Sum("betrag"))["total"] or 0
total_pachtzins = (
verpachtungen.aggregate(total=Sum("pachtzins_pauschal"))["total"] or 0
)
context = {
"jahr": jahr,
"foerderungen": foerderungen,
"verpachtungen": verpachtungen,
"total_foerderungen": total_foerderungen,
"total_pachtzins": total_pachtzins,
}
# Render HTML
html_string = render_to_string("stiftung/jahresbericht.html", context)
# Generate PDF
pdf = HTML(string=html_string).write_pdf()
# Create response
response = HttpResponse(pdf, content_type="application/pdf")
response["Content-Disposition"] = f'attachment; filename="jahresbericht_{jahr}.pdf"'
return response
# API Views for AJAX
@login_required
def geschaeftsfuehrung(request):
"""Hauptansicht für die Geschäftsführung mit Übersicht"""
from datetime import datetime, timedelta
from django.db.models import Count, Sum
from stiftung.models import Rentmeister, StiftungsKonto, Verwaltungskosten
# Rentmeister-Übersicht
rentmeister = Rentmeister.objects.filter(aktiv=True).order_by("nachname", "vorname")
# Konten-Übersicht
konten = StiftungsKonto.objects.filter(aktiv=True).order_by(
"bank_name", "kontoname"
)
gesamtsaldo = konten.aggregate(total=Sum("saldo"))["total"] or 0
# Aktuelle Kosten (letzten 30 Tage)
heute = datetime.now().date()
vor_30_tagen = heute - timedelta(days=30)
aktuelle_kosten = Verwaltungskosten.objects.filter(
datum__gte=vor_30_tagen
).order_by("-datum")[:10]
# Statistiken
kosten_summe_monat = (
Verwaltungskosten.objects.filter(datum__gte=vor_30_tagen).aggregate(
total=Sum("betrag")
)["total"]
or 0
)
kosten_statistik = (
Verwaltungskosten.objects.filter(datum__gte=vor_30_tagen)
.values("kategorie")
.annotate(summe=Sum("betrag"), anzahl=Count("id"))
.order_by("-summe")
)
context = {
"rentmeister": rentmeister,
"konten": konten,
"gesamtsaldo": gesamtsaldo,
"aktuelle_kosten": aktuelle_kosten,
"kosten_summe_monat": kosten_summe_monat,
"kosten_statistik": kosten_statistik,
}
return render(request, "stiftung/geschaeftsfuehrung.html", context)
@login_required
def konto_list(request):
"""Liste aller Stiftungskonten"""
from django.db.models import Sum
from stiftung.models import StiftungsKonto
konten = StiftungsKonto.objects.all().order_by("bank_name", "kontoname")
gesamtsaldo = konten.aggregate(total=Sum("saldo"))["total"] or 0
context = {
"konten": konten,
"gesamtsaldo": gesamtsaldo,
}
return render(request, "stiftung/konto_list.html", context)
@login_required
def verwaltungskosten_list(request):
"""Liste aller Verwaltungskosten"""
from django.core.paginator import Paginator
from stiftung.models import Verwaltungskosten
kosten = Verwaltungskosten.objects.all().order_by("-datum", "-erstellt_am")
# Filter nach Kategorie
kategorie_filter = request.GET.get("kategorie")
if kategorie_filter:
kosten = kosten.filter(kategorie=kategorie_filter)
# Filter nach Status
status_filter = request.GET.get("status")
if status_filter:
kosten = kosten.filter(status=status_filter)
# Pagination
paginator = Paginator(kosten, 25)
page_number = request.GET.get("page")
page_obj = paginator.get_page(page_number)
# Für Filter-Dropdowns
kategorien = Verwaltungskosten.KATEGORIE_CHOICES
status_choices = Verwaltungskosten.STATUS_CHOICES
context = {
"page_obj": page_obj,
"kategorien": kategorien,
"status_choices": status_choices,
"kategorie_filter": kategorie_filter,
"status_filter": status_filter,
}
return render(request, "stiftung/verwaltungskosten_list.html", context)
@login_required
def rentmeister_list(request):
"""Liste aller Rentmeister"""
from stiftung.models import Rentmeister
rentmeister = Rentmeister.objects.all().order_by("nachname", "vorname")
# Aktive/Inaktive aufteilen
aktive_rentmeister = rentmeister.filter(aktiv=True)
ehemalige_rentmeister = rentmeister.filter(aktiv=False)
context = {
"aktive_rentmeister": aktive_rentmeister,
"ehemalige_rentmeister": ehemalige_rentmeister,
"total_count": rentmeister.count(),
}
return render(request, "stiftung/rentmeister_list.html", context)
@login_required
def rentmeister_detail(request, pk):
"""Detailansicht eines Rentmeisters mit seinen Ausgaben"""
from datetime import datetime, timedelta
from django.db.models import Count, Q, Sum
from stiftung.models import Rentmeister, Verwaltungskosten
rentmeister = get_object_or_404(Rentmeister, pk=pk)
# Ausgaben des Rentmeisters
ausgaben = Verwaltungskosten.objects.filter(rentmeister=rentmeister).order_by(
"-datum"
)
# Statistiken
heute = datetime.now().date()
aktueller_monat = heute.replace(day=1)
aktuelles_jahr = heute.replace(month=1, day=1)
stats = {
"gesamt_ausgaben": ausgaben.aggregate(total=Sum("betrag"))["total"] or 0,
"monat_ausgaben": ausgaben.filter(datum__gte=aktueller_monat).aggregate(
total=Sum("betrag")
)["total"]
or 0,
"jahr_ausgaben": ausgaben.filter(datum__gte=aktuelles_jahr).aggregate(
total=Sum("betrag")
)["total"]
or 0,
"anzahl_ausgaben": ausgaben.count(),
"offene_ausgaben": ausgaben.exclude(status="bezahlt").count(),
}
# Kategorie-Aufschlüsselung
kategorie_stats = (
ausgaben.values("kategorie")
.annotate(summe=Sum("betrag"), anzahl=Count("id"))
.order_by("-summe")
)
# Aktuelle Ausgaben (letzten 30 Tage)
vor_30_tagen = heute - timedelta(days=30)
aktuelle_ausgaben = ausgaben.filter(datum__gte=vor_30_tagen)[:10]
# Verknüpfte Dokumente laden
from stiftung.models import DokumentLink
verknuepfte_dokumente = DokumentLink.objects.filter(
rentmeister_id=rentmeister.id
).order_by("-id")[
:10
] # Neueste 10 Dokumente
context = {
"rentmeister": rentmeister,
"ausgaben": ausgaben[:20], # Nur erste 20 für Übersicht
"stats": stats,
"kategorie_stats": kategorie_stats,
"aktuelle_ausgaben": aktuelle_ausgaben,
"verknuepfte_dokumente": verknuepfte_dokumente,
}
return render(request, "stiftung/rentmeister_detail.html", context)
@login_required
def rentmeister_ausgaben(request, pk):
"""Vollständige Ausgabenliste eines Rentmeisters mit PDF Export"""
from django.core.paginator import Paginator
from django.db import models
from django.db.models import Count, Q, Sum
from stiftung.models import Rentmeister, Verwaltungskosten
rentmeister = get_object_or_404(Rentmeister, pk=pk)
# Handle PDF export request
if request.method == "POST" and "export_pdf" in request.POST:
selected_ids = request.POST.getlist("selected_expenses")
if selected_ids:
# Update status to 'in_bearbeitung' and log each change
from stiftung.audit import log_action
expenses_to_update = Verwaltungskosten.objects.filter(
id__in=selected_ids, rentmeister=rentmeister
)
updated_count = 0
for expense in expenses_to_update:
old_status = expense.status
expense.status = "in_bearbeitung"
expense.save()
updated_count += 1
# Log the status change
log_action(
request=request,
action="update",
entity_type="verwaltungskosten",
entity_id=str(expense.pk),
entity_name=expense.bezeichnung,
description=f'Ausgaben-Status für PDF-Export geändert von "{old_status}" zu "in_bearbeitung"',
changes={"status": {"old": old_status, "new": "in_bearbeitung"}},
)
messages.success(
request,
f"{updated_count} Ausgaben wurden zur Bearbeitung markiert und sind bereit für PDF Export.",
)
return redirect(
"stiftung:rentmeister_ausgaben_pdf",
pk=pk,
expense_ids=",".join(selected_ids),
)
# Get expenses grouped by status
ausgaben_by_status = {}
for status_code, status_name in Verwaltungskosten.STATUS_CHOICES:
ausgaben_by_status[status_code] = {
"name": status_name,
"ausgaben": Verwaltungskosten.objects.filter(
rentmeister=rentmeister, status=status_code
).order_by("-datum", "-erstellt_am"),
"total": Verwaltungskosten.objects.filter(
rentmeister=rentmeister, status=status_code
).aggregate(total=Sum("betrag"))["total"]
or 0,
}
# Get statistics
stats = Verwaltungskosten.objects.filter(rentmeister=rentmeister).aggregate(
total_count=Count("id"),
total_amount=Sum("betrag"),
geplant_count=Count("id", filter=Q(status="geplant")),
geplant_amount=Sum("betrag", filter=Q(status="geplant")),
in_bearbeitung_count=Count("id", filter=Q(status="in_bearbeitung")),
in_bearbeitung_amount=Sum("betrag", filter=Q(status="in_bearbeitung")),
bezahlt_count=Count("id", filter=Q(status="bezahlt")),
bezahlt_amount=Sum("betrag", filter=Q(status="bezahlt")),
)
context = {
"rentmeister": rentmeister,
"ausgaben_by_status": ausgaben_by_status,
"stats": stats,
"kategorien": Verwaltungskosten.KATEGORIE_CHOICES,
"status_choices": Verwaltungskosten.STATUS_CHOICES,
}
return render(request, "stiftung/rentmeister_ausgaben.html", context)
@login_required
def rentmeister_create(request):
"""Erstelle einen neuen Rentmeister"""
from stiftung.forms import RentmeisterForm
if request.method == "POST":
form = RentmeisterForm(request.POST)
if form.is_valid():
rentmeister = form.save()
messages.success(
request,
f"Rentmeister {rentmeister.get_full_name()} wurde erfolgreich angelegt.",
)
return redirect("stiftung:rentmeister_detail", pk=rentmeister.pk)
else:
form = RentmeisterForm()
context = {
"form": form,
"title": "Neuen Rentmeister anlegen",
"submit_text": "Rentmeister anlegen",
}
return render(request, "stiftung/rentmeister_form.html", context)
@login_required
def rentmeister_edit(request, pk):
"""Bearbeite einen bestehenden Rentmeister"""
from stiftung.forms import RentmeisterForm
from stiftung.models import Rentmeister
rentmeister = get_object_or_404(Rentmeister, pk=pk)
if request.method == "POST":
form = RentmeisterForm(request.POST, instance=rentmeister)
if form.is_valid():
rentmeister = form.save()
messages.success(
request,
f"Rentmeister {rentmeister.get_full_name()} wurde erfolgreich aktualisiert.",
)
return redirect("stiftung:rentmeister_detail", pk=rentmeister.pk)
else:
form = RentmeisterForm(instance=rentmeister)
context = {
"form": form,
"rentmeister": rentmeister,
"title": f"{rentmeister.get_full_name()} bearbeiten",
"submit_text": "Änderungen speichern",
}
return render(request, "stiftung/rentmeister_form.html", context)
@login_required
def konto_create(request):
"""Erstelle ein neues Stiftungskonto"""
from stiftung.forms import StiftungsKontoForm
if request.method == "POST":
form = StiftungsKontoForm(request.POST)
if form.is_valid():
konto = form.save()
messages.success(
request, f"Konto {konto.kontoname} wurde erfolgreich angelegt."
)
return redirect("stiftung:konto_list")
else:
form = StiftungsKontoForm()
context = {
"form": form,
"title": "Neues Konto anlegen",
"submit_text": "Konto anlegen",
}
return render(request, "stiftung/konto_form.html", context)
@login_required
def konto_edit(request, pk):
"""Bearbeite ein bestehendes Stiftungskonto"""
from stiftung.forms import StiftungsKontoForm
from stiftung.models import StiftungsKonto
konto = get_object_or_404(StiftungsKonto, pk=pk)
if request.method == "POST":
form = StiftungsKontoForm(request.POST, instance=konto)
if form.is_valid():
konto = form.save()
messages.success(
request, f"Konto {konto.kontoname} wurde erfolgreich aktualisiert."
)
return redirect("stiftung:konto_list")
else:
form = StiftungsKontoForm(instance=konto)
context = {
"form": form,
"konto": konto,
"title": f"Konto {konto.kontoname} bearbeiten",
"submit_text": "Änderungen speichern",
}
return render(request, "stiftung/konto_form.html", context)
@login_required
def konto_detail(request, pk):
"""Zeige Details eines Stiftungskontos"""
from django.db import models
from django.db.models import Count, Max, Q, Sum
from stiftung.models import BankTransaction, StiftungsKonto
konto = get_object_or_404(StiftungsKonto, pk=pk)
# Get transaction statistics
transactions = BankTransaction.objects.filter(konto=konto)
transaction_stats = transactions.aggregate(
total_count=Count("id"),
total_eingang=Sum("betrag", filter=Q(betrag__gt=0)),
total_ausgang=Sum("betrag", filter=Q(betrag__lt=0)),
last_transaction_date=Max("datum"),
)
# Recent transactions
recent_transactions = transactions.order_by("-datum", "-importiert_am")[:10]
context = {
"konto": konto,
"transaction_stats": transaction_stats,
"recent_transactions": recent_transactions,
}
return render(request, "stiftung/konto_detail.html", context)
@login_required
def verwaltungskosten_create(request):
"""Erstelle neue Verwaltungskosten"""
from stiftung.forms import VerwaltungskostenForm
from stiftung.models import Rentmeister
# Check if we're coming from a specific Rentmeister
rentmeister_id = request.GET.get("rentmeister")
initial_data = {}
redirect_url = "stiftung:verwaltungskosten_list"
if rentmeister_id:
try:
rentmeister = Rentmeister.objects.get(pk=rentmeister_id)
initial_data["rentmeister"] = rentmeister
redirect_url = "stiftung:rentmeister_detail"
except Rentmeister.DoesNotExist:
pass
if request.method == "POST":
form = VerwaltungskostenForm(request.POST)
if form.is_valid():
kosten = form.save()
messages.success(
request,
f'Verwaltungskosten "{kosten.bezeichnung}" wurden erfolgreich angelegt.',
)
if rentmeister_id:
return redirect(redirect_url, pk=rentmeister_id)
return redirect("stiftung:verwaltungskosten_list")
else:
form = VerwaltungskostenForm(initial=initial_data)
context = {
"form": form,
"title": "Neue Verwaltungskosten anlegen",
"submit_text": "Kosten anlegen",
}
return render(request, "stiftung/verwaltungskosten_form.html", context)
@login_required
def verwaltungskosten_edit(request, pk):
"""Bearbeite bestehende Verwaltungskosten"""
from stiftung.forms import VerwaltungskostenForm
from stiftung.models import Verwaltungskosten
verwaltungskosten = get_object_or_404(Verwaltungskosten, pk=pk)
if request.method == "POST":
form = VerwaltungskostenForm(request.POST, instance=verwaltungskosten)
if form.is_valid():
verwaltungskosten = form.save()
messages.success(
request,
f'Verwaltungskosten "{verwaltungskosten.bezeichnung}" wurden erfolgreich aktualisiert.',
)
return redirect("stiftung:verwaltungskosten_list")
else:
form = VerwaltungskostenForm(instance=verwaltungskosten)
context = {
"form": form,
"verwaltungskosten": verwaltungskosten,
"title": f"Verwaltungskosten bearbeiten: {verwaltungskosten.bezeichnung}",
"submit_text": "Änderungen speichern",
}
return render(request, "stiftung/verwaltungskosten_form.html", context)
@login_required
def verwaltungskosten_delete(request, pk):
"""Lösche Verwaltungskosten"""
from stiftung.models import Verwaltungskosten
verwaltungskosten = get_object_or_404(Verwaltungskosten, pk=pk)
if request.method == "POST":
bezeichnung = verwaltungskosten.bezeichnung
# Log the deletion
from stiftung.audit import log_action
log_action(
request=request,
action="delete",
entity_type="verwaltungskosten",
entity_id=str(verwaltungskosten.pk),
entity_name=bezeichnung,
description=f'Verwaltungskosten "{bezeichnung}" wurden gelöscht',
)
verwaltungskosten.delete()
messages.success(
request,
f'Verwaltungskosten "{bezeichnung}" wurden erfolgreich gelöscht.',
)
return redirect("stiftung:verwaltungskosten_list")
context = {
"verwaltungskosten": verwaltungskosten,
"title": f"Verwaltungskosten löschen: {verwaltungskosten.bezeichnung}",
}
return render(request, "stiftung/verwaltungskosten_delete.html", context)
@login_required
def mark_expense_paid(request):
"""Markiere eine Ausgabe als bezahlt"""
if request.method == "POST":
expense_id = request.POST.get("expense_id")
if expense_id:
try:
from stiftung.models import Verwaltungskosten
expense = Verwaltungskosten.objects.get(pk=expense_id)
old_status = expense.status
expense.status = "bezahlt"
expense.save()
# Log the status change
from stiftung.audit import log_action
log_action(
request=request,
action="update",
entity_type="verwaltungskosten",
entity_id=str(expense.pk),
entity_name=expense.bezeichnung,
description=f'Ausgaben-Status geändert von "{old_status}" zu "bezahlt"',
changes={"status": {"old": old_status, "new": "bezahlt"}},
)
messages.success(
request,
f'Ausgabe "{expense.bezeichnung}" wurde als bezahlt markiert.',
)
return redirect(
"stiftung:rentmeister_ausgaben", pk=expense.rentmeister.pk
)
except Verwaltungskosten.DoesNotExist:
messages.error(request, "Ausgabe nicht gefunden.")
return redirect("stiftung:verwaltungskosten_list")
# =============================================================================
# ADMINISTRATION VIEWS
# =============================================================================

View File

@@ -0,0 +1,236 @@
# views/foerderung.py
# Phase 0: Vision 2026 Code-Refactoring
import csv
import io
import json
import os
import time
from datetime import datetime, timedelta, date
from decimal import Decimal
import qrcode
import qrcode.image.svg
import requests
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.db.models import (Avg, Count, DecimalField, F, IntegerField, Q,
Sum, Value)
from django.db.models.functions import Cast, Coalesce, NullIf, Replace
from django.http import HttpResponse, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils import timezone
from django.views.decorators.csrf import csrf_exempt
from django_otp.decorators import otp_required
from django_otp.plugins.otp_totp.models import TOTPDevice
from django_otp.plugins.otp_static.models import StaticDevice, StaticToken
from django_otp.util import random_hex
from rest_framework.decorators import api_view
from rest_framework.response import Response
from stiftung.models import (AppConfiguration, AuditLog, BackupJob, BankTransaction,
BriefVorlage, CSVImport, Destinataer,
DestinataerEmailEingang, DestinataerNotiz,
DestinataerUnterstuetzung,
DokumentLink, Foerderung, GeschichteBild, GeschichteSeite,
Land, LandAbrechnung, LandVerpachtung, Paechter, Person,
Rentmeister, StiftungsKalenderEintrag, StiftungsKonto,
UnterstuetzungWiederkehrend, Veranstaltung,
Veranstaltungsteilnehmer, Verwaltungskosten,
VierteljahresNachweis)
from stiftung.forms import (
DestinataerForm, DestinataerUnterstuetzungForm, DestinataerNotizForm,
FoerderungForm, GeschichteBildForm, GeschichteSeiteForm,
LandForm, LandVerpachtungForm, LandAbrechnungForm,
PaechterForm, DokumentLinkForm,
RentmeisterForm, StiftungsKontoForm, VerwaltungskostenForm,
BankTransactionForm, BankImportForm,
UnterstuetzungForm, UnterstuetzungWiederkehrendForm,
UnterstuetzungMarkAsPaidForm, VierteljahresNachweisForm,
UserCreationForm, UserUpdateForm, PasswordChangeForm, UserPermissionForm,
TwoFactorSetupForm, TwoFactorVerifyForm, TwoFactorDisableForm,
BackupTokenRegenerateForm, PersonForm,
VeranstaltungForm, VeranstaltungsteilnehmerForm,
)
@login_required
def foerderung_list(request):
"""List all funding grants with filtering and pagination"""
foerderungen = Foerderung.objects.select_related(
"destinataer", "verwendungsnachweis"
).all()
# Check for export request - handle both GET and POST
export_format = (
request.POST.get("format")
if request.method == "POST"
else request.GET.get("format", "")
)
selected_ids_param = (
request.POST.get("selected_entries", "")
if request.method == "POST"
else request.GET.get("selected_entries", "")
)
selected_ids = (
[id for id in selected_ids_param.split(",") if id] if selected_ids_param else []
)
# Filtering
jahr = request.GET.get("jahr")
kategorie = request.GET.get("kategorie")
status = request.GET.get("status")
destinataer = request.GET.get("destinataer")
if jahr:
foerderungen = foerderungen.filter(jahr=int(jahr))
if kategorie:
foerderungen = foerderungen.filter(kategorie=kategorie)
if status:
foerderungen = foerderungen.filter(status=status)
if destinataer:
foerderungen = foerderungen.filter(destinataer__nachname__icontains=destinataer)
# Handle exports
if export_format == "csv":
return export_foerderungen_csv(request, foerderungen, selected_ids)
elif export_format == "pdf":
return export_foerderungen_pdf(request, foerderungen, selected_ids)
# Pagination
paginator = Paginator(foerderungen, 25)
page_number = request.GET.get("page")
page_obj = paginator.get_page(page_number)
# Statistics
total_betrag = foerderungen.aggregate(total=Sum("betrag"))["total"] or 0
avg_betrag = foerderungen.aggregate(avg=Avg("betrag"))["avg"] or 0
# Year choices for filters
jahre = sorted(
set(list(Foerderung.objects.values_list("jahr", flat=True))), reverse=True
)
context = {
"page_obj": page_obj,
"foerderungen": foerderungen, # Add for counting
"total_betrag": total_betrag,
"avg_betrag": avg_betrag,
"kategorien": Foerderung.KATEGORIE_CHOICES,
"status_choices": Foerderung.STATUS_CHOICES,
"filter_jahr": jahr,
"filter_kategorie": kategorie,
"filter_status": status,
"filter_person": destinataer,
"jahre": jahre,
}
return render(request, "stiftung/foerderung_list.html", context)
@login_required
def foerderung_detail(request, pk):
"""Show details of a specific funding grant"""
foerderung = get_object_or_404(
Foerderung.objects.select_related("person", "verwendungsnachweis"), pk=pk
)
# Alle mit dieser Förderung verknüpften Dokumente laden
verknuepfte_dokumente = DokumentLink.objects.filter(
foerderung_id=foerderung.pk
).order_by("kontext", "titel")
context = {
"foerderung": foerderung,
"verknuepfte_dokumente": verknuepfte_dokumente,
"title": f"Förderung: {foerderung}",
}
return render(request, "stiftung/foerderung_detail.html", context)
@login_required
def foerderung_create(request):
"""Create a new funding grant"""
# Get destinataer from URL parameter if provided
destinataer_id = request.GET.get("destinataer")
initial = {}
if destinataer_id:
initial["destinataer"] = destinataer_id
if request.method == "POST":
form = FoerderungForm(request.POST)
if form.is_valid():
foerderung = form.save()
messages.success(
request,
f"Förderung für {foerderung.destinataer} wurde erfolgreich erstellt.",
)
return redirect("stiftung:foerderung_detail", pk=foerderung.pk)
else:
form = FoerderungForm(initial=initial)
context = {
"form": form,
"title": "Neue Förderung erstellen",
}
return render(request, "stiftung/foerderung_form.html", context)
@login_required
def foerderung_update(request, pk):
"""Update an existing funding grant"""
foerderung = get_object_or_404(Foerderung, pk=pk)
if request.method == "POST":
form = FoerderungForm(request.POST, instance=foerderung)
if form.is_valid():
form.save()
messages.success(
request,
f"Förderung für {foerderung.person} wurde erfolgreich aktualisiert.",
)
return redirect("stiftung:foerderung_detail", pk=foerderung.pk)
else:
form = FoerderungForm(instance=foerderung)
context = {
"form": form,
"foerderung": foerderung,
"title": f"Förderung bearbeiten: {foerderung}",
}
return render(request, "stiftung/foerderung_form.html", context)
@login_required
def foerderung_delete(request, pk):
"""Delete a funding grant"""
foerderung = get_object_or_404(Foerderung, pk=pk)
if request.method == "POST":
# Get the recipient name before deletion
recipient_name = (
foerderung.destinataer.get_full_name()
if foerderung.destinataer
else (
foerderung.person.get_full_name()
if foerderung.person
else "Unbekannter Empfänger"
)
)
foerderung.delete()
messages.success(
request, f"Förderung für {recipient_name} wurde erfolgreich gelöscht."
)
return redirect("stiftung:foerderung_list")
context = {
"foerderung": foerderung,
"title": f"Förderung löschen: {foerderung}",
}
return render(request, "stiftung/foerderung_confirm_delete.html", context)
# DokumentLink Views

View File

@@ -0,0 +1,710 @@
# views/geschichte.py
# Phase 0: Vision 2026 Code-Refactoring
import csv
import io
import json
import os
import time
from datetime import datetime, timedelta, date
from decimal import Decimal
import qrcode
import qrcode.image.svg
import requests
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.db.models import (Avg, Count, DecimalField, F, IntegerField, Q,
Sum, Value)
from django.db.models.functions import Cast, Coalesce, NullIf, Replace
from django.http import HttpResponse, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils import timezone
from django.views.decorators.csrf import csrf_exempt
from django_otp.decorators import otp_required
from django_otp.plugins.otp_totp.models import TOTPDevice
from django_otp.plugins.otp_static.models import StaticDevice, StaticToken
from django_otp.util import random_hex
from rest_framework.decorators import api_view
from rest_framework.response import Response
from stiftung.models import (AppConfiguration, AuditLog, BackupJob, BankTransaction,
BriefVorlage, CSVImport, Destinataer,
DestinataerEmailEingang, DestinataerNotiz,
DestinataerUnterstuetzung,
DokumentLink, Foerderung, GeschichteBild, GeschichteSeite,
Land, LandAbrechnung, LandVerpachtung, Paechter, Person,
Rentmeister, StiftungsKalenderEintrag, StiftungsKonto,
UnterstuetzungWiederkehrend, Veranstaltung,
Veranstaltungsteilnehmer, Verwaltungskosten,
VierteljahresNachweis)
from stiftung.forms import (
DestinataerForm, DestinataerUnterstuetzungForm, DestinataerNotizForm,
FoerderungForm, GeschichteBildForm, GeschichteSeiteForm,
LandForm, LandVerpachtungForm, LandAbrechnungForm,
PaechterForm, DokumentLinkForm,
RentmeisterForm, StiftungsKontoForm, VerwaltungskostenForm,
BankTransactionForm, BankImportForm,
UnterstuetzungForm, UnterstuetzungWiederkehrendForm,
UnterstuetzungMarkAsPaidForm, VierteljahresNachweisForm,
UserCreationForm, UserUpdateForm, PasswordChangeForm, UserPermissionForm,
TwoFactorSetupForm, TwoFactorVerifyForm, TwoFactorDisableForm,
BackupTokenRegenerateForm, PersonForm,
VeranstaltungForm, VeranstaltungsteilnehmerForm,
)
@login_required
def geschichte_list(request):
"""List all published history pages"""
seiten = GeschichteSeite.objects.filter(ist_veroeffentlicht=True).order_by('sortierung', 'titel')
context = {
'seiten': seiten,
'title': 'Geschichte der Stiftung'
}
return render(request, 'stiftung/geschichte/liste.html', context)
@login_required
def geschichte_detail(request, slug):
"""Display a specific history page"""
seite = get_object_or_404(GeschichteSeite, slug=slug, ist_veroeffentlicht=True)
bilder = seite.bilder.all().order_by('sortierung', 'titel')
context = {
'seite': seite,
'bilder': bilder,
'title': seite.titel
}
return render(request, 'stiftung/geschichte/detail.html', context)
@login_required
def geschichte_create(request):
"""Create a new history page"""
if not request.user.has_perm('stiftung.add_geschichteseite'):
messages.error(request, 'Sie haben keine Berechtigung, neue Geschichtsseiten zu erstellen.')
return redirect('stiftung:geschichte_list')
if request.method == 'POST':
form = GeschichteSeiteForm(request.POST)
if form.is_valid():
seite = form.save(commit=False)
seite.erstellt_von = request.user
seite.aktualisiert_von = request.user
seite.save()
messages.success(request, f'Geschichtsseite "{seite.titel}" wurde erfolgreich erstellt.')
return redirect('stiftung:geschichte_detail', slug=seite.slug)
else:
form = GeschichteSeiteForm()
context = {
'form': form,
'title': 'Neue Geschichtsseite'
}
return render(request, 'stiftung/geschichte/form.html', context)
@login_required
def geschichte_edit(request, slug):
"""Edit an existing history page"""
seite = get_object_or_404(GeschichteSeite, slug=slug)
if not request.user.has_perm('stiftung.change_geschichteseite'):
messages.error(request, 'Sie haben keine Berechtigung, diese Geschichtsseite zu bearbeiten.')
return redirect('stiftung:geschichte_detail', slug=slug)
if request.method == 'POST':
form = GeschichteSeiteForm(request.POST, instance=seite)
if form.is_valid():
seite = form.save(commit=False)
seite.aktualisiert_von = request.user
seite.save()
messages.success(request, f'Geschichtsseite "{seite.titel}" wurde erfolgreich aktualisiert.')
return redirect('stiftung:geschichte_detail', slug=seite.slug)
else:
form = GeschichteSeiteForm(instance=seite)
context = {
'form': form,
'seite': seite,
'title': f'Bearbeiten: {seite.titel}'
}
return render(request, 'stiftung/geschichte/form.html', context)
@login_required
def geschichte_bild_upload(request, slug):
"""Upload images to a history page"""
seite = get_object_or_404(GeschichteSeite, slug=slug)
if not request.user.has_perm('stiftung.add_geschichtebild'):
messages.error(request, 'Sie haben keine Berechtigung, Bilder hochzuladen.')
return redirect('stiftung:geschichte_detail', slug=slug)
if request.method == 'POST':
form = GeschichteBildForm(request.POST, request.FILES)
if form.is_valid():
bild = form.save(commit=False)
bild.seite = seite
bild.hochgeladen_von = request.user
bild.save()
messages.success(request, f'Bild "{bild.titel}" wurde erfolgreich hochgeladen.')
return redirect('stiftung:geschichte_detail', slug=slug)
else:
form = GeschichteBildForm()
context = {
'form': form,
'seite': seite,
'title': f'Bild hochladen: {seite.titel}'
}
return render(request, 'stiftung/geschichte/bild_form.html', context)
@login_required
def geschichte_bild_delete(request, slug, bild_id):
"""Delete an image from a history page"""
seite = get_object_or_404(GeschichteSeite, slug=slug)
bild = get_object_or_404(GeschichteBild, id=bild_id, seite=seite)
if not request.user.has_perm('stiftung.delete_geschichtebild'):
messages.error(request, 'Sie haben keine Berechtigung, Bilder zu löschen.')
return redirect('stiftung:geschichte_detail', slug=slug)
if request.method == 'POST':
bild_titel = bild.titel
bild.delete()
messages.success(request, f'Bild "{bild_titel}" wurde erfolgreich gelöscht.')
return redirect('stiftung:geschichte_detail', slug=slug)
context = {
'bild': bild,
'seite': seite,
'title': f'Bild löschen: {bild.titel}'
}
return render(request, 'stiftung/geschichte/bild_delete.html', context)
# Calendar Views
@login_required
def kalender_view(request):
"""Main calendar view with different view types"""
from stiftung.services.calendar_service import StiftungsKalenderService
import calendar as cal
calendar_service = StiftungsKalenderService()
# Get current date and view parameters
today = timezone.now().date()
view_type = request.GET.get('view', 'month') # month, week, list, agenda
year = int(request.GET.get('year', today.year))
month = int(request.GET.get('month', today.month))
# Calculate date ranges based on view type
if view_type == 'month':
# Get events for the entire month
start_date = date(year, month, 1)
_, last_day = cal.monthrange(year, month)
end_date = date(year, month, last_day)
title_suffix = f"{cal.month_name[month]} {year}"
elif view_type == 'week':
# Get current week
week_start = today - timedelta(days=today.weekday())
start_date = week_start
end_date = week_start + timedelta(days=6)
title_suffix = f"Woche vom {start_date.strftime('%d.%m')} - {end_date.strftime('%d.%m.%Y')}"
elif view_type == 'agenda':
# Next 30 days
start_date = today
end_date = today + timedelta(days=30)
title_suffix = "Nächste 30 Tage"
else: # list view
# Next 90 days
start_date = today
end_date = today + timedelta(days=90)
title_suffix = "Liste (nächste 90 Tage)"
# Get events for the date range
events = calendar_service.get_all_events(start_date, end_date)
# Generate calendar grid for month view
calendar_grid = None
if view_type == 'month':
calendar_grid = []
first_day = date(year, month, 1)
month_cal = cal.monthcalendar(year, month)
for week in month_cal:
week_data = []
for day in week:
if day == 0:
week_data.append(None)
else:
day_date = date(year, month, day)
day_events = [e for e in events if e.date == day_date]
week_data.append({
'day': day,
'date': day_date,
'is_today': day_date == today,
'events': day_events[:3], # Show max 3 events per day
'event_count': len(day_events)
})
calendar_grid.append(week_data)
# Navigation dates for month view
if month > 1:
prev_month = month - 1
prev_year = year
else:
prev_month = 12
prev_year = year - 1
if month < 12:
next_month = month + 1
next_year = year
else:
next_month = 1
next_year = year + 1
context = {
'title': f'Kalender - {title_suffix}',
'events': events,
'calendar_grid': calendar_grid,
'view_type': view_type,
'year': year,
'month': month,
'today': today,
'start_date': start_date,
'end_date': end_date,
'prev_year': prev_year,
'prev_month': prev_month,
'next_year': next_year,
'next_month': next_month,
'month_name': cal.month_name[month],
'weekdays': ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'],
}
# Choose template based on view type
if view_type == 'month':
template = 'stiftung/kalender/month_view.html'
elif view_type == 'week':
template = 'stiftung/kalender/week_view.html'
elif view_type == 'agenda':
template = 'stiftung/kalender/agenda_view.html'
else:
template = 'stiftung/kalender/list_view.html'
return render(request, template, context)
@login_required
def kalender_create(request):
"""Create new calendar event"""
from stiftung.models import StiftungsKalenderEintrag
if request.method == 'POST':
# Simple form handling - you can enhance this with Django forms
titel = request.POST.get('titel')
beschreibung = request.POST.get('beschreibung', '')
datum = request.POST.get('datum')
kategorie = request.POST.get('kategorie', 'termin')
prioritaet = request.POST.get('prioritaet', 'normal')
if titel and datum:
zeit_str = request.POST.get('zeit')
uhrzeit = zeit_str if zeit_str else None
ganztags = not bool(zeit_str)
StiftungsKalenderEintrag.objects.create(
titel=titel,
beschreibung=beschreibung,
datum=datum,
uhrzeit=uhrzeit,
ganztags=ganztags,
kategorie=kategorie,
prioritaet=prioritaet,
erstellt_von=request.user.username
)
messages.success(request, 'Kalendereintrag wurde erfolgreich erstellt.')
return redirect('stiftung:kalender')
else:
messages.error(request, 'Titel und Datum sind erforderlich.')
context = {
'title': 'Neuer Kalendereintrag',
}
return render(request, 'stiftung/kalender/create.html', context)
@login_required
def kalender_detail(request, pk):
"""Calendar event detail view"""
from stiftung.models import StiftungsKalenderEintrag
event = get_object_or_404(StiftungsKalenderEintrag, pk=pk)
context = {
'title': f'Kalendereintrag: {event.titel}',
'event': event,
}
return render(request, 'stiftung/kalender/detail.html', context)
@login_required
def kalender_edit(request, pk):
"""Edit calendar event"""
from stiftung.models import StiftungsKalenderEintrag
event = get_object_or_404(StiftungsKalenderEintrag, pk=pk)
if request.method == 'POST':
event.titel = request.POST.get('titel', event.titel)
event.beschreibung = request.POST.get('beschreibung', event.beschreibung)
event.datum = request.POST.get('datum', event.datum)
zeit_str = request.POST.get('zeit')
if zeit_str:
event.uhrzeit = zeit_str
event.ganztags = False
else:
event.uhrzeit = None
event.ganztags = True
event.kategorie = request.POST.get('kategorie', event.kategorie)
event.prioritaet = request.POST.get('prioritaet', event.prioritaet)
event.erledigt = 'erledigt' in request.POST
event.save()
messages.success(request, 'Kalendereintrag wurde aktualisiert.')
return redirect('stiftung:kalender_detail', pk=pk)
context = {
'title': f'Bearbeiten: {event.titel}',
'event': event,
}
return render(request, 'stiftung/kalender/edit.html', context)
@login_required
def kalender_delete(request, pk):
"""Delete calendar event"""
from stiftung.models import StiftungsKalenderEintrag
event = get_object_or_404(StiftungsKalenderEintrag, pk=pk)
if request.method == 'POST':
event_titel = event.titel
event.delete()
messages.success(request, f'Kalendereintrag "{event_titel}" wurde gelöscht.')
return redirect('stiftung:kalender')
context = {
'title': f'Löschen: {event.titel}',
'event': event,
}
return render(request, 'stiftung/kalender/delete_confirm.html', context)
@login_required
def kalender_admin(request):
"""Calendar administration with event sources and management"""
from stiftung.models import StiftungsKalenderEintrag
from stiftung.services.calendar_service import StiftungsKalenderService
# Get filter parameters
show_custom = request.GET.get('show_custom', 'true') == 'true'
show_payments = request.GET.get('show_payments', 'true') == 'true'
show_leases = request.GET.get('show_leases', 'true') == 'true'
show_birthdays = request.GET.get('show_birthdays', 'true') == 'true'
category_filter = request.GET.get('category', '')
priority_filter = request.GET.get('priority', '')
# Initialize calendar service
calendar_service = StiftungsKalenderService()
# Get events based on filters
from datetime import date, timedelta
start_date = date.today() - timedelta(days=30)
end_date = date.today() + timedelta(days=90)
all_events = []
# Custom calendar entries
if show_custom:
custom_events = calendar_service.get_calendar_events(start_date, end_date)
all_events.extend(custom_events)
# Payment events
if show_payments:
payment_events = calendar_service.get_support_payment_events(start_date, end_date)
all_events.extend(payment_events)
# Lease events
if show_leases:
lease_events = calendar_service.get_lease_events(start_date, end_date)
all_events.extend(lease_events)
# Birthday events
if show_birthdays:
birthday_events = calendar_service.get_birthday_events(start_date, end_date)
all_events.extend(birthday_events)
# Filter by category and priority if specified
if category_filter:
all_events = [e for e in all_events if getattr(e, 'category', '') == category_filter]
if priority_filter:
all_events = [e for e in all_events if getattr(e, 'priority', '') == priority_filter]
# Sort events by date
all_events.sort(key=lambda x: x.date)
# Get statistics
custom_count = StiftungsKalenderEintrag.objects.count()
total_events = len(all_events)
# Event source statistics
stats = {
'custom_events': len([e for e in all_events if getattr(e, 'source', '') == 'custom']),
'payment_events': len([e for e in all_events if getattr(e, 'source', '') == 'payment']),
'lease_events': len([e for e in all_events if getattr(e, 'source', '') == 'lease']),
'birthday_events': len([e for e in all_events if getattr(e, 'source', '') == 'birthday']),
'total_events': total_events,
'custom_count': custom_count,
}
context = {
'title': 'Kalender Administration',
'events': all_events,
'stats': stats,
'show_custom': show_custom,
'show_payments': show_payments,
'show_leases': show_leases,
'show_birthdays': show_birthdays,
'category_filter': category_filter,
'priority_filter': priority_filter,
'categories': StiftungsKalenderEintrag.KATEGORIE_CHOICES,
'priorities': StiftungsKalenderEintrag.PRIORITAET_CHOICES,
}
return render(request, 'stiftung/kalender/admin.html', context)
@login_required
def kalender_api_events(request):
"""API endpoint for calendar events (JSON)"""
from django.http import JsonResponse
from stiftung.services.calendar_service import StiftungsKalenderService
from datetime import datetime
calendar_service = StiftungsKalenderService()
# Get date range from request
start_date = request.GET.get('start')
end_date = request.GET.get('end')
if start_date and end_date:
try:
start_date = datetime.strptime(start_date, '%Y-%m-%d').date()
end_date = datetime.strptime(end_date, '%Y-%m-%d').date()
except ValueError:
return JsonResponse({'error': 'Invalid date format'}, status=400)
events = calendar_service.get_all_events(start_date, end_date)
else:
events = calendar_service.get_all_events()
# Convert to FullCalendar format
calendar_events = []
for event in events:
calendar_events.append({
'id': getattr(event, 'id', str(event.title)),
'title': event.title,
'start': event.date.strftime('%Y-%m-%d'),
'description': getattr(event, 'description', ''),
'className': f"event-{event.category}",
'backgroundColor': f"var(--bs-{event.color})",
'borderColor': f"var(--bs-{event.color})",
})
return JsonResponse(calendar_events, safe=False)
# Calendar Views
@login_required
def kalender_view(request):
"""Full calendar view with all events"""
from stiftung.services.calendar_service import StiftungsKalenderService
calendar_service = StiftungsKalenderService()
# Get current month events by default
today = timezone.now().date()
events = calendar_service.get_events_for_month(today.year, today.month)
context = {
'events': events,
'title': 'Stiftungskalender',
'current_month': today.strftime('%B %Y'),
}
return render(request, 'stiftung/kalender/kalender.html', context)
context = {
'title': 'Kalendereintrag löschen'
}
return render(request, 'stiftung/kalender/delete.html', context)
# =============================================================================
# E-Mail-Eingang Destinatäre
# =============================================================================
@login_required
def email_eingang_list(request):
"""
Übersicht aller eingegangenen E-Mails von Destinatären.
Zeigt ungeklärte Absender zuerst, dann chronologisch absteigend.
"""
status_filter = request.GET.get("status", "")
search = request.GET.get("q", "").strip()
qs = DestinataerEmailEingang.objects.select_related("destinataer", "quartalsnachweis")
if status_filter:
qs = qs.filter(status=status_filter)
if search:
qs = qs.filter(
Q(absender_email__icontains=search)
| Q(absender_name__icontains=search)
| Q(betreff__icontains=search)
| Q(destinataer__vorname__icontains=search)
| Q(destinataer__nachname__icontains=search)
)
# Unbekannte Absender zuerst, dann nach Datum absteigend
qs = qs.order_by(
"status", # "unbekannt" kommt alphabetisch vor "verarbeitet" / "zugewiesen"
"-eingangsdatum",
)
paginator = Paginator(qs, 30)
page_obj = paginator.get_page(request.GET.get("page"))
context = {
"title": "E-Mail-Eingang (Destinatäre)",
"page_obj": page_obj,
"status_filter": status_filter,
"search": search,
"status_choices": DestinataerEmailEingang.STATUS_CHOICES,
"counts": {
"gesamt": DestinataerEmailEingang.objects.count(),
"neu": DestinataerEmailEingang.objects.filter(status="neu").count(),
"unbekannt": DestinataerEmailEingang.objects.filter(status="unbekannt").count(),
"fehler": DestinataerEmailEingang.objects.filter(status="fehler").count(),
},
}
return render(request, "stiftung/email_eingang/list.html", context)
@login_required
def email_eingang_detail(request, pk):
"""Detailansicht einer eingegangenen E-Mail mit Möglichkeit zur manuellen Zuordnung."""
eingang = get_object_or_404(DestinataerEmailEingang, pk=pk)
if request.method == "POST":
action = request.POST.get("action")
if action == "assign_destinataer":
dest_id = request.POST.get("destinataer_id")
if dest_id:
try:
destinataer = Destinataer.objects.get(pk=dest_id)
eingang.destinataer = destinataer
eingang.status = "zugewiesen"
eingang.save()
messages.success(
request,
f"E-Mail wurde {destinataer} zugeordnet.",
)
except Destinataer.DoesNotExist:
messages.error(request, "Destinatär nicht gefunden.")
return redirect("email_eingang_detail", pk=pk)
elif action == "mark_verarbeitet":
eingang.status = "verarbeitet"
eingang.notizen = request.POST.get("notizen", eingang.notizen)
eingang.save()
messages.success(request, "E-Mail als verarbeitet markiert.")
return redirect("email_eingang_list")
elif action == "save_notizen":
eingang.notizen = request.POST.get("notizen", "")
eingang.save()
messages.success(request, "Notizen gespeichert.")
return redirect("email_eingang_detail", pk=pk)
# Paperless-Links zusammenstellen
paperless_links = eingang.get_paperless_links()
# DokumentLinks für diese E-Mail (über paperless_dokument_ids)
dokument_links = []
if eingang.paperless_dokument_ids:
dokument_links = DokumentLink.objects.filter(
paperless_document_id__in=eingang.paperless_dokument_ids
)
# Alle aktiven Destinatäre für manuelle Zuordnung
alle_destinataere = Destinataer.objects.filter(aktiv=True).order_by("nachname", "vorname")
context = {
"title": f"E-Mail-Eingang: {eingang}",
"eingang": eingang,
"paperless_links": paperless_links,
"dokument_links": dokument_links,
"alle_destinataere": alle_destinataere,
}
return render(request, "stiftung/email_eingang/detail.html", context)
@login_required
def email_eingang_poll_trigger(request):
"""Löst den IMAP-Poll-Task manuell aus (für Tests und manuelle Verarbeitung)."""
if request.method == "POST":
from stiftung.tasks import poll_destinataer_emails
try:
task = poll_destinataer_emails.delay()
messages.success(
request,
f"E-Mail-Abruf wurde gestartet (Task-ID: {task.id}). "
"Bitte Seite in ca. 30 Sekunden neu laden.",
)
except Exception as exc:
messages.error(request, f"Fehler beim Starten des Tasks: {exc}")
return redirect("email_eingang_list")
# ============================================================
# Veranstaltungsmodul
# ============================================================

1553
app/stiftung/views/land.py Normal file

File diff suppressed because it is too large Load Diff

2139
app/stiftung/views/system.py Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,254 @@
# views/veranstaltung.py
# Phase 0: Vision 2026 Code-Refactoring
import csv
import io
import json
import os
import time
from datetime import datetime, timedelta, date
from decimal import Decimal
import qrcode
import qrcode.image.svg
import requests
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.db.models import (Avg, Count, DecimalField, F, IntegerField, Q,
Sum, Value)
from django.db.models.functions import Cast, Coalesce, NullIf, Replace
from django.http import HttpResponse, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils import timezone
from django.views.decorators.csrf import csrf_exempt
from django_otp.decorators import otp_required
from django_otp.plugins.otp_totp.models import TOTPDevice
from django_otp.plugins.otp_static.models import StaticDevice, StaticToken
from django_otp.util import random_hex
from rest_framework.decorators import api_view
from rest_framework.response import Response
from stiftung.models import (AppConfiguration, AuditLog, BackupJob, BankTransaction,
BriefVorlage, CSVImport, Destinataer,
DestinataerEmailEingang, DestinataerNotiz,
DestinataerUnterstuetzung,
DokumentLink, Foerderung, GeschichteBild, GeschichteSeite,
Land, LandAbrechnung, LandVerpachtung, Paechter, Person,
Rentmeister, StiftungsKalenderEintrag, StiftungsKonto,
UnterstuetzungWiederkehrend, Veranstaltung,
Veranstaltungsteilnehmer, Verwaltungskosten,
VierteljahresNachweis)
from stiftung.forms import (
DestinataerForm, DestinataerUnterstuetzungForm, DestinataerNotizForm,
FoerderungForm, GeschichteBildForm, GeschichteSeiteForm,
LandForm, LandVerpachtungForm, LandAbrechnungForm,
PaechterForm, DokumentLinkForm,
RentmeisterForm, StiftungsKontoForm, VerwaltungskostenForm,
BankTransactionForm, BankImportForm,
UnterstuetzungForm, UnterstuetzungWiederkehrendForm,
UnterstuetzungMarkAsPaidForm, VierteljahresNachweisForm,
UserCreationForm, UserUpdateForm, PasswordChangeForm, UserPermissionForm,
TwoFactorSetupForm, TwoFactorVerifyForm, TwoFactorDisableForm,
BackupTokenRegenerateForm, PersonForm,
VeranstaltungForm, VeranstaltungsteilnehmerForm,
)
@login_required
def veranstaltung_list(request):
"""Liste aller Veranstaltungen"""
veranstaltungen = Veranstaltung.objects.all()
return render(request, "stiftung/veranstaltung/list.html", {"veranstaltungen": veranstaltungen})
@login_required
def veranstaltung_detail(request, pk):
"""Detail-Ansicht einer Veranstaltung mit RSVP-Übersicht"""
veranstaltung = get_object_or_404(Veranstaltung, pk=pk)
teilnehmer = veranstaltung.teilnehmer.all()
context = {
"veranstaltung": veranstaltung,
"teilnehmer": teilnehmer,
"zugesagte": teilnehmer.filter(rsvp_status="zugesagt"),
"abgesagte": teilnehmer.filter(rsvp_status="abgesagt"),
"keine_rueckmeldung": teilnehmer.filter(rsvp_status="keine_rueckmeldung"),
"eingeladen": teilnehmer.filter(rsvp_status="eingeladen"),
}
return render(request, "stiftung/veranstaltung/detail.html", context)
@login_required
def veranstaltung_serienbrief_pdf(request, pk):
"""Generiert Serienbrief-PDF für alle Teilnehmer einer Veranstaltung"""
from weasyprint import HTML
from django.template.loader import render_to_string
veranstaltung = get_object_or_404(Veranstaltung, pk=pk)
teilnehmer = veranstaltung.teilnehmer.all().order_by("nachname", "vorname")
# Render HTML for all letters
html_string = render_to_string(
"stiftung/veranstaltung/serienbrief_pdf.html",
{
"veranstaltung": veranstaltung,
"teilnehmer": teilnehmer,
},
)
pdf = HTML(string=html_string).write_pdf()
filename = f"einladungen_{veranstaltung.datum}_{veranstaltung.titel[:30].replace(' ', '_')}.pdf"
response = HttpResponse(pdf, content_type="application/pdf")
response["Content-Disposition"] = f'attachment; filename="{filename}"'
return response
@login_required
def veranstaltung_serienbrief_vorschau(request, pk):
"""HTML-Vorschau des Serienbriefs im Browser (kein PDF-Download)"""
veranstaltung = get_object_or_404(Veranstaltung, pk=pk)
teilnehmer = veranstaltung.teilnehmer.all().order_by("nachname", "vorname")
return render(
request,
"stiftung/veranstaltung/serienbrief_vorschau.html",
{
"veranstaltung": veranstaltung,
"teilnehmer": teilnehmer,
},
)
@login_required
def veranstaltung_create(request):
"""Neue Veranstaltung erstellen"""
from stiftung.forms import VeranstaltungForm
if request.method == "POST":
form = VeranstaltungForm(request.POST)
if form.is_valid():
veranstaltung = form.save()
messages.success(request, f'Veranstaltung "{veranstaltung.titel}" wurde erstellt.')
return redirect("stiftung:veranstaltung_detail", pk=veranstaltung.pk)
else:
messages.error(request, "Bitte korrigieren Sie die Fehler im Formular.")
else:
form = VeranstaltungForm()
return render(request, "stiftung/veranstaltung/form.html", {
"form": form,
"title": "Neue Veranstaltung erstellen",
})
@login_required
def veranstaltung_update(request, pk):
"""Veranstaltung bearbeiten (inkl. Serienbrief-Felder)"""
from stiftung.forms import VeranstaltungForm
veranstaltung = get_object_or_404(Veranstaltung, pk=pk)
if request.method == "POST":
form = VeranstaltungForm(request.POST, instance=veranstaltung)
if form.is_valid():
form.save()
messages.success(request, f'Veranstaltung "{veranstaltung.titel}" wurde aktualisiert.')
return redirect("stiftung:veranstaltung_detail", pk=veranstaltung.pk)
else:
messages.error(request, "Bitte korrigieren Sie die Fehler im Formular.")
else:
form = VeranstaltungForm(instance=veranstaltung)
return render(request, "stiftung/veranstaltung/form.html", {
"form": form,
"veranstaltung": veranstaltung,
"title": f"Veranstaltung bearbeiten: {veranstaltung.titel}",
})
@login_required
def veranstaltung_delete(request, pk):
"""Veranstaltung löschen"""
veranstaltung = get_object_or_404(Veranstaltung, pk=pk)
if request.method == "POST":
titel = veranstaltung.titel
veranstaltung.delete()
messages.success(request, f'Veranstaltung "{titel}" wurde gelöscht.')
return redirect("stiftung:veranstaltung_list")
return render(request, "stiftung/veranstaltung/delete.html", {
"veranstaltung": veranstaltung,
})
@login_required
def teilnehmer_create(request, veranstaltung_pk):
"""Teilnehmer zu einer Veranstaltung hinzufügen"""
from stiftung.forms import VeranstaltungsteilnehmerForm
veranstaltung = get_object_or_404(Veranstaltung, pk=veranstaltung_pk)
if request.method == "POST":
form = VeranstaltungsteilnehmerForm(request.POST)
if form.is_valid():
teilnehmer = form.save(commit=False)
teilnehmer.veranstaltung = veranstaltung
teilnehmer.save()
messages.success(request, f"{teilnehmer.vorname} {teilnehmer.nachname} wurde hinzugefügt.")
return redirect("stiftung:veranstaltung_detail", pk=veranstaltung.pk)
else:
messages.error(request, "Bitte korrigieren Sie die Fehler im Formular.")
else:
form = VeranstaltungsteilnehmerForm()
return render(request, "stiftung/veranstaltung/teilnehmer_form.html", {
"form": form,
"veranstaltung": veranstaltung,
"title": "Teilnehmer hinzufügen",
})
@login_required
def teilnehmer_update(request, veranstaltung_pk, pk):
"""Teilnehmer bearbeiten"""
from stiftung.forms import VeranstaltungsteilnehmerForm
veranstaltung = get_object_or_404(Veranstaltung, pk=veranstaltung_pk)
teilnehmer = get_object_or_404(Veranstaltungsteilnehmer, pk=pk, veranstaltung=veranstaltung)
if request.method == "POST":
form = VeranstaltungsteilnehmerForm(request.POST, instance=teilnehmer)
if form.is_valid():
form.save()
messages.success(request, f"{teilnehmer.vorname} {teilnehmer.nachname} wurde aktualisiert.")
return redirect("stiftung:veranstaltung_detail", pk=veranstaltung.pk)
else:
messages.error(request, "Bitte korrigieren Sie die Fehler im Formular.")
else:
form = VeranstaltungsteilnehmerForm(instance=teilnehmer)
return render(request, "stiftung/veranstaltung/teilnehmer_form.html", {
"form": form,
"veranstaltung": veranstaltung,
"teilnehmer": teilnehmer,
"title": f"Teilnehmer bearbeiten: {teilnehmer.vorname} {teilnehmer.nachname}",
})
@login_required
def teilnehmer_delete(request, veranstaltung_pk, pk):
"""Teilnehmer aus Veranstaltung entfernen"""
veranstaltung = get_object_or_404(Veranstaltung, pk=veranstaltung_pk)
teilnehmer = get_object_or_404(Veranstaltungsteilnehmer, pk=pk, veranstaltung=veranstaltung)
if request.method == "POST":
name = f"{teilnehmer.vorname} {teilnehmer.nachname}"
teilnehmer.delete()
messages.success(request, f"{name} wurde aus der Teilnehmerliste entfernt.")
return redirect("stiftung:veranstaltung_detail", pk=veranstaltung.pk)
return render(request, "stiftung/veranstaltung/teilnehmer_delete.html", {
"veranstaltung": veranstaltung,
"teilnehmer": teilnehmer,
})