Implementierung des Veranstaltungsmoduls inkl. Serienbrief-PDF-Generator mit dynamischen, editierbaren Feldern für Betreff und Unterschriften. ### Veranstaltungsmodul (STI-35) - Neues Veranstaltungs-Modell: Titel, Datum, Uhrzeit, Ort, Gasthaus-Adresse, Briefvorlage, Gästeliste (VerstaltungsGast mit freien/Destinatär-Feldern) - Views: Veranstaltungsliste, -detail, Serienbrief-PDF-Generator - Templates: list.html, detail.html, serienbrief_pdf.html (A4, einseitig) - API: Serializer + Endpunkte für Veranstaltungen - Admin: Inline-Bearbeitung der Gästeliste - Migration: 0044_veranstaltungsmodul ### Serienbrief editierbare Felder + PDF-Fix (STI-39) - Neue Felder an Veranstaltung: betreff, unterschrift_1_name/titel, unterschrift_2_name/titel (mit Defaults: Katrin Kleinpaß / Jan Remmer Siebels) - PDF-CSS: Margins, Font-Sizes und Line-Heights reduziert für einseitigen Druck - Migration: 0045_add_serienbrief_editable_fields ### Infrastruktur - scripts/init-paperless-db.sh: Erstellt separate Paperless-DB beim DB-Init - compose.yml: init-paperless-db.sh eingebunden, PAPERLESS_DBNAME-Fix - .gitignore: .claude/ ausgeschlossen Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
8688 lines
321 KiB
Python
8688 lines
321 KiB
Python
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.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 .models import (AppConfiguration, CSVImport, Destinataer,
|
||
DestinataerEmailEingang, DestinataerUnterstuetzung,
|
||
DokumentLink, Foerderung, Land,
|
||
LandAbrechnung, LandVerpachtung, Paechter, Person,
|
||
StiftungsKonto, UnterstuetzungWiederkehrend, Veranstaltung,
|
||
Veranstaltungsteilnehmer, VierteljahresNachweis)
|
||
|
||
|
||
def get_pdf_generator():
|
||
"""Lazy load PDF generator to handle missing dependencies gracefully"""
|
||
try:
|
||
from .utils.pdf_generator import pdf_generator
|
||
|
||
return pdf_generator
|
||
except ImportError as e:
|
||
# Store the error message for use in MockPDFGenerator
|
||
error_message = str(e)
|
||
|
||
# Return a mock generator if dependencies are missing
|
||
class MockPDFGenerator:
|
||
def is_available(self):
|
||
return False
|
||
|
||
def export_data_list_pdf(self, *args, **kwargs):
|
||
from django.http import HttpResponse
|
||
|
||
error_html = f"""
|
||
<!DOCTYPE html>
|
||
<html>
|
||
<head><title>PDF Not Available</title></head>
|
||
<body>
|
||
<h1>PDF Export Not Available</h1>
|
||
<p>PDF generation requires additional system dependencies that are not installed.</p>
|
||
<p>Error: {error_message}</p>
|
||
<p>Please install WeasyPrint dependencies or use CSV export instead.</p>
|
||
</body>
|
||
</html>
|
||
"""
|
||
response = HttpResponse(error_html, content_type="text/html")
|
||
response["Content-Disposition"] = (
|
||
'inline; filename="pdf_not_available.html"'
|
||
)
|
||
return response
|
||
|
||
return MockPDFGenerator()
|
||
|
||
|
||
class GrampsClient:
|
||
"""Lightweight client for Gramps Web API."""
|
||
|
||
def __init__(
|
||
self, base_url: str, token: str = "", username: str = "", password: str = ""
|
||
):
|
||
self.base_url = base_url.rstrip("/")
|
||
self.session = requests.Session()
|
||
if token:
|
||
self.session.headers.update({"Authorization": f"Bearer {token}"})
|
||
self.username = username
|
||
self.password = password
|
||
self._cached_token = token
|
||
|
||
def search_people(self, query: str, limit: int = 5):
|
||
try:
|
||
r = self.session.get(
|
||
f"{self.base_url}/api/people/",
|
||
params={"q": query, "limit": limit},
|
||
timeout=10,
|
||
)
|
||
r.raise_for_status()
|
||
return r.json()
|
||
except Exception as e:
|
||
# try login-once if unauthorized and we have credentials
|
||
if self.username and self.password and "401" in str(e):
|
||
if self._login():
|
||
return self.search_people(query, limit)
|
||
return {"error": str(e)}
|
||
|
||
def get_person(self, handle_or_id: str):
|
||
try:
|
||
r = self.session.get(
|
||
f"{self.base_url}/api/people/{handle_or_id}", timeout=10
|
||
)
|
||
r.raise_for_status()
|
||
return r.json()
|
||
except Exception as e:
|
||
if self.username and self.password and "401" in str(e):
|
||
if self._login():
|
||
return self.get_person(handle_or_id)
|
||
return {"error": str(e)}
|
||
|
||
def _login(self) -> bool:
|
||
try:
|
||
# try common endpoints
|
||
endpoints = [
|
||
(
|
||
"/api/auth/login",
|
||
{"username": self.username, "password": self.password},
|
||
"json",
|
||
),
|
||
(
|
||
"/auth/login",
|
||
{"username": self.username, "password": self.password},
|
||
"json",
|
||
),
|
||
(
|
||
"/api/token",
|
||
{"username": self.username, "password": self.password},
|
||
"form",
|
||
),
|
||
(
|
||
"/login",
|
||
{"username": self.username, "password": self.password},
|
||
"form",
|
||
),
|
||
(
|
||
"/token",
|
||
{"username": self.username, "password": self.password},
|
||
"form",
|
||
),
|
||
(
|
||
"/api/login",
|
||
{"username": self.username, "password": self.password},
|
||
"json",
|
||
),
|
||
]
|
||
for path, payload, mode in endpoints:
|
||
url = f"{self.base_url}{path}"
|
||
if mode == "json":
|
||
r = self.session.post(
|
||
url, json=payload, timeout=10, allow_redirects=False
|
||
)
|
||
else:
|
||
r = self.session.post(
|
||
url, data=payload, timeout=10, allow_redirects=False
|
||
)
|
||
# Success with token body
|
||
if r.status_code in (200, 201) and "application/json" in r.headers.get(
|
||
"Content-Type", ""
|
||
):
|
||
data = r.json()
|
||
token = (
|
||
data.get("access_token")
|
||
or data.get("token")
|
||
or data.get("access")
|
||
or data.get("jwt")
|
||
)
|
||
if token:
|
||
self._cached_token = token
|
||
self.session.headers.update(
|
||
{"Authorization": f"Bearer {token}"}
|
||
)
|
||
return True
|
||
# Success via session cookie and redirect
|
||
if r.status_code in (200, 302) and (
|
||
"set-cookie" in {k.lower(): v for k, v in r.headers.items()}
|
||
):
|
||
return True
|
||
# Basic Auth fallback (some setups protect API with Basic)
|
||
try:
|
||
self.session.auth = (self.username, self.password)
|
||
r = self.session.get(f"{self.base_url}/api/people/?limit=1", timeout=10)
|
||
if r.status_code == 200:
|
||
return True
|
||
except Exception:
|
||
pass
|
||
return False
|
||
except Exception:
|
||
return False
|
||
|
||
|
||
def get_gramps_client() -> GrampsClient:
|
||
return GrampsClient(
|
||
getattr(settings, "GRAMPS_URL", ""),
|
||
getattr(settings, "GRAMPS_API_TOKEN", ""),
|
||
getattr(settings, "GRAMPS_USERNAME", ""),
|
||
getattr(settings, "GRAMPS_PASSWORD", ""),
|
||
)
|
||
|
||
|
||
@api_view(["GET"])
|
||
def gramps_debug_api(_request):
|
||
return Response(
|
||
{
|
||
"GRAMPS_URL": getattr(settings, "GRAMPS_URL", ""),
|
||
"has_username": bool(getattr(settings, "GRAMPS_USERNAME", "")),
|
||
"has_password": bool(getattr(settings, "GRAMPS_PASSWORD", "")),
|
||
}
|
||
)
|
||
|
||
|
||
from stiftung.models import DestinataerNotiz, DestinataerUnterstuetzung
|
||
|
||
from .forms import (DestinataerForm, DestinataerNotizForm,
|
||
DestinataerUnterstuetzungForm, DokumentLinkForm,
|
||
FoerderungForm, LandForm, PaechterForm, PersonForm,
|
||
UnterstuetzungForm, UnterstuetzungMarkAsPaidForm, VierteljahresNachweisForm)
|
||
|
||
@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)
|
||
|
||
|
||
@login_required
|
||
def dokument_management(request):
|
||
"""Dokumentenverwaltung: zeigt Paperless-Dokumente (mit Stiftung-Tags) und bestehende Verknüpfungen.
|
||
Bietet Filter und ermöglicht Re-Linking.
|
||
"""
|
||
return render(request, "stiftung/dokument_management.html")
|
||
|
||
|
||
@api_view(["GET"])
|
||
def paperless_document_redirect(_request, doc_id: int):
|
||
"""Redirects to the Paperless UI document URL and supports thumbnails if needed later."""
|
||
from stiftung.utils.config import get_paperless_config
|
||
|
||
config = get_paperless_config()
|
||
url = config["api_url"]
|
||
if not url:
|
||
return Response({"error": "Paperless API not configured"}, status=400)
|
||
|
||
# Remove /api suffix if present, then construct the document URL
|
||
base_url = url[:-4] if url.endswith("/api") else url
|
||
|
||
# For external Paperless (already includes /paperless/ in base URL)
|
||
return redirect(f"{base_url}/documents/{doc_id}/details/")
|
||
|
||
|
||
@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
|
||
@login_required
|
||
def csv_import_list(request):
|
||
"""List all CSV import operations"""
|
||
imports = CSVImport.objects.all().order_by("-started_at")
|
||
|
||
paginator = Paginator(imports, 20)
|
||
page_number = request.GET.get("page")
|
||
page_obj = paginator.get_page(page_number)
|
||
|
||
context = {
|
||
"page_obj": page_obj,
|
||
"import_types": CSVImport.IMPORT_TYPE_CHOICES,
|
||
"status_choices": CSVImport.STATUS_CHOICES,
|
||
}
|
||
return render(request, "stiftung/csv_import_list.html", context)
|
||
|
||
|
||
@login_required
|
||
def csv_import_create(request):
|
||
"""Show CSV import form and handle file upload"""
|
||
if request.method == "POST":
|
||
import_type = request.POST.get("import_type")
|
||
csv_file = request.FILES.get("csv_file")
|
||
|
||
if not csv_file or not import_type:
|
||
messages.error(
|
||
request, "Bitte wählen Sie einen Import-Typ und eine CSV-Datei aus."
|
||
)
|
||
return redirect("stiftung:csv_import_create")
|
||
|
||
if not csv_file.name.endswith(".csv"):
|
||
messages.error(request, "Bitte wählen Sie eine gültige CSV-Datei aus.")
|
||
return redirect("stiftung:csv_import_create")
|
||
|
||
try:
|
||
# Create import record
|
||
csv_import = CSVImport.objects.create(
|
||
import_type=import_type,
|
||
filename=csv_file.name,
|
||
file_size=csv_file.size,
|
||
created_by=(
|
||
request.user.username
|
||
if request.user.is_authenticated
|
||
else "Unknown"
|
||
),
|
||
status="processing",
|
||
)
|
||
|
||
# Process the CSV file
|
||
if import_type == "destinataere":
|
||
result = process_destinataere_csv(csv_file, csv_import)
|
||
elif import_type == "paechter":
|
||
result = process_paechter_csv(csv_file, csv_import)
|
||
elif import_type == "personen":
|
||
result = process_personen_csv(csv_file, csv_import)
|
||
elif import_type == "laendereien":
|
||
result = process_laendereien_csv(csv_file, csv_import)
|
||
else:
|
||
messages.error(request, "Unbekannter Import-Typ.")
|
||
csv_import.status = "failed"
|
||
csv_import.save()
|
||
return redirect("stiftung:csv_import_create")
|
||
|
||
# Update import record
|
||
csv_import.total_rows = result["total_rows"]
|
||
csv_import.imported_rows = result["imported_rows"]
|
||
csv_import.failed_rows = result["failed_rows"]
|
||
csv_import.error_log = result["error_log"]
|
||
csv_import.status = result["status"]
|
||
csv_import.completed_at = timezone.now()
|
||
csv_import.save()
|
||
|
||
if result["status"] == "completed":
|
||
messages.success(
|
||
request,
|
||
f'CSV-Import erfolgreich! {result["imported_rows"]} Datensätze importiert.',
|
||
)
|
||
elif result["status"] == "partial":
|
||
messages.warning(
|
||
request,
|
||
f'CSV-Import teilweise erfolgreich. {result["imported_rows"]} importiert, {result["failed_rows"]} fehlgeschlagen.',
|
||
)
|
||
else:
|
||
messages.error(
|
||
request, f'CSV-Import fehlgeschlagen. {result["error_log"]}'
|
||
)
|
||
|
||
return redirect("stiftung:csv_import_list")
|
||
|
||
except Exception as e:
|
||
messages.error(request, f"Fehler beim CSV-Import: {str(e)}")
|
||
return redirect("stiftung:csv_import_create")
|
||
|
||
context = {
|
||
"import_types": CSVImport.IMPORT_TYPE_CHOICES,
|
||
}
|
||
return render(request, "stiftung/csv_import_form.html", context)
|
||
|
||
|
||
def process_personen_csv(csv_file, csv_import):
|
||
"""Process CSV file for Personen import"""
|
||
decoded_file = csv_file.read().decode("utf-8")
|
||
# Handle both comma and semicolon separated files
|
||
if ";" in decoded_file.split("\n")[0]:
|
||
csv_data = csv.DictReader(io.StringIO(decoded_file), delimiter=";")
|
||
else:
|
||
csv_data = csv.DictReader(io.StringIO(decoded_file))
|
||
|
||
total_rows = 0
|
||
imported_rows = 0
|
||
failed_rows = 0
|
||
error_log = []
|
||
|
||
for row_num, row in enumerate(
|
||
csv_data, start=2
|
||
): # Start at 2 because row 1 is header
|
||
total_rows += 1
|
||
|
||
try:
|
||
# Map CSV columns to model fields
|
||
person_data = {
|
||
"vorname": row.get("Vorname", "").strip(),
|
||
"nachname": row.get("Nachname", "").strip(),
|
||
"familienzweig": row.get("Familienzweig", "hauptzweig").strip(),
|
||
"email": row.get("E-Mail", "").strip() or None,
|
||
"telefon": row.get("Telefon", "").strip() or None,
|
||
"iban": row.get("IBAN", "").strip() or None,
|
||
"adresse": row.get("Adresse", "").strip() or None,
|
||
"notizen": row.get("Notizen", "").strip() or None,
|
||
"aktiv": row.get("Aktiv", "true").lower() == "true",
|
||
}
|
||
|
||
# Handle date fields
|
||
if row.get("Geburtsdatum"):
|
||
try:
|
||
person_data["geburtsdatum"] = datetime.strptime(
|
||
row["Geburtsdatum"], "%d.%m.%Y"
|
||
).date()
|
||
except ValueError:
|
||
try:
|
||
person_data["geburtsdatum"] = datetime.strptime(
|
||
row["Geburtsdatum"], "%Y-%m-%d"
|
||
).date()
|
||
except ValueError:
|
||
person_data["geburtsdatum"] = None
|
||
|
||
# Validate required fields
|
||
if not person_data["vorname"] or not person_data["nachname"]:
|
||
error_log.append(
|
||
f"Zeile {row_num}: Vorname und Nachname sind erforderlich"
|
||
)
|
||
failed_rows += 1
|
||
continue
|
||
|
||
# Check if person already exists
|
||
existing_person = Person.objects.filter(
|
||
vorname__iexact=person_data["vorname"],
|
||
nachname__iexact=person_data["nachname"],
|
||
).first()
|
||
|
||
if existing_person:
|
||
# Update existing person
|
||
for field, value in person_data.items():
|
||
if value is not None:
|
||
setattr(existing_person, field, value)
|
||
existing_person.save()
|
||
else:
|
||
# Create new person
|
||
Person.objects.create(**person_data)
|
||
|
||
imported_rows += 1
|
||
|
||
except Exception as e:
|
||
error_log.append(f"Zeile {row_num}: {str(e)}")
|
||
failed_rows += 1
|
||
|
||
# Determine status
|
||
if failed_rows == 0:
|
||
status = "completed"
|
||
elif imported_rows > 0:
|
||
status = "partial"
|
||
else:
|
||
status = "failed"
|
||
|
||
return {
|
||
"total_rows": total_rows,
|
||
"imported_rows": imported_rows,
|
||
"failed_rows": failed_rows,
|
||
"error_log": "\n".join(error_log) if error_log else None,
|
||
"status": status,
|
||
}
|
||
|
||
|
||
def process_destinataere_csv(csv_file, csv_import):
|
||
"""Process CSV file for Destinatäre import"""
|
||
decoded_file = csv_file.read().decode("utf-8")
|
||
# Handle both comma and semicolon separated files
|
||
if ";" in decoded_file.split("\n")[0]:
|
||
csv_data = csv.DictReader(io.StringIO(decoded_file), delimiter=";")
|
||
else:
|
||
csv_data = csv.DictReader(io.StringIO(decoded_file))
|
||
|
||
total_rows = 0
|
||
imported_rows = 0
|
||
failed_rows = 0
|
||
error_log = []
|
||
|
||
for row_num, row in enumerate(
|
||
csv_data, start=2
|
||
): # Start at 2 because row 1 is header
|
||
total_rows += 1
|
||
|
||
try:
|
||
# Helper function to parse boolean values from CSV
|
||
def parse_boolean(value, default=False):
|
||
"""Parse boolean values from CSV with multiple accepted formats"""
|
||
if not value:
|
||
return default
|
||
value_str = str(value).strip().lower()
|
||
# Accept various true values
|
||
true_values = ['true', 'ja', 'yes', '1', 'wahr', 'x']
|
||
# Accept various false values
|
||
false_values = ['false', 'nein', 'no', '0', 'falsch', '']
|
||
|
||
if value_str in true_values:
|
||
return True
|
||
elif value_str in false_values:
|
||
return False
|
||
else:
|
||
# If unclear, return default
|
||
return default
|
||
|
||
# Map CSV columns to model fields
|
||
destinataer_data = {
|
||
"vorname": row.get("Vorname", "").strip(),
|
||
"nachname": row.get("Nachname", "").strip(),
|
||
"familienzweig": row.get("Familienzweig", "hauptzweig").strip(),
|
||
"email": row.get("E-Mail", "").strip() or None,
|
||
"telefon": row.get("Telefon", "").strip() or None,
|
||
"iban": row.get("IBAN", "").strip() or None,
|
||
"strasse": row.get("Straße", "").strip() or None,
|
||
"plz": row.get("PLZ", "").strip() or None,
|
||
"ort": row.get("Ort", "").strip() or None,
|
||
"berufsgruppe": row.get("Berufsgruppe", "andere").strip(),
|
||
"ausbildungsstand": row.get("Ausbildungsstand", "").strip() or None,
|
||
"institution": row.get("Institution", "").strip() or None,
|
||
"projekt_beschreibung": row.get("Projektbeschreibung", "").strip()
|
||
or None,
|
||
"jaehrliches_einkommen": (
|
||
float(row.get("Jährliches_Einkommen", 0))
|
||
if row.get("Jährliches_Einkommen")
|
||
else None
|
||
),
|
||
"notizen": row.get("Notizen", "").strip() or None,
|
||
# Boolean fields with improved parsing
|
||
"finanzielle_notlage": parse_boolean(row.get("Finanzielle_Notlage"), False),
|
||
"aktiv": parse_boolean(row.get("Aktiv"), True),
|
||
"ist_abkoemmling": parse_boolean(row.get("Ist_Abkömmling"), False),
|
||
"unterstuetzung_bestaetigt": parse_boolean(row.get("Unterstützung_bestätigt"), False),
|
||
"studiennachweis_erforderlich": parse_boolean(row.get("Studiennachweis_erforderlich"), False),
|
||
}
|
||
|
||
# Handle numeric fields
|
||
if row.get("Haushaltsgröße"):
|
||
try:
|
||
destinataer_data["haushaltsgroesse"] = int(row["Haushaltsgröße"])
|
||
except ValueError:
|
||
pass
|
||
|
||
if row.get("Monatliche_Bezüge"):
|
||
try:
|
||
destinataer_data["monatliche_bezuege"] = float(row["Monatliche_Bezüge"])
|
||
except ValueError:
|
||
pass
|
||
|
||
if row.get("Vermögen"):
|
||
try:
|
||
destinataer_data["vermoegen"] = float(row["Vermögen"])
|
||
except ValueError:
|
||
pass
|
||
|
||
if row.get("Vierteljährlicher_Betrag"):
|
||
try:
|
||
destinataer_data["vierteljaehrlicher_betrag"] = float(row["Vierteljährlicher_Betrag"])
|
||
except ValueError:
|
||
pass
|
||
|
||
# Handle date fields
|
||
if row.get("Geburtsdatum"):
|
||
try:
|
||
destinataer_data["geburtsdatum"] = datetime.strptime(
|
||
row["Geburtsdatum"], "%d.%m.%Y"
|
||
).date()
|
||
except ValueError:
|
||
try:
|
||
destinataer_data["geburtsdatum"] = datetime.strptime(
|
||
row["Geburtsdatum"], "%Y-%m-%d"
|
||
).date()
|
||
except ValueError:
|
||
destinataer_data["geburtsdatum"] = None
|
||
|
||
if row.get("Letzter_Studiennachweis"):
|
||
try:
|
||
destinataer_data["letzter_studiennachweis"] = datetime.strptime(
|
||
row["Letzter_Studiennachweis"], "%d.%m.%Y"
|
||
).date()
|
||
except ValueError:
|
||
try:
|
||
destinataer_data["letzter_studiennachweis"] = datetime.strptime(
|
||
row["Letzter_Studiennachweis"], "%Y-%m-%d"
|
||
).date()
|
||
except ValueError:
|
||
destinataer_data["letzter_studiennachweis"] = None
|
||
|
||
# Validate required fields
|
||
if not destinataer_data["vorname"] or not destinataer_data["nachname"]:
|
||
error_log.append(
|
||
f"Zeile {row_num}: Vorname und Nachname sind erforderlich"
|
||
)
|
||
failed_rows += 1
|
||
continue
|
||
|
||
# Check if destinataer already exists
|
||
existing_destinataer = Destinataer.objects.filter(
|
||
vorname__iexact=destinataer_data["vorname"],
|
||
nachname__iexact=destinataer_data["nachname"],
|
||
).first()
|
||
|
||
if existing_destinataer:
|
||
# Update existing destinataer
|
||
for field, value in destinataer_data.items():
|
||
if value is not None:
|
||
setattr(existing_destinataer, field, value)
|
||
existing_destinataer.save()
|
||
else:
|
||
# Create new destinataer
|
||
Destinataer.objects.create(**destinataer_data)
|
||
|
||
imported_rows += 1
|
||
|
||
except Exception as e:
|
||
error_log.append(f"Zeile {row_num}: {str(e)}")
|
||
failed_rows += 1
|
||
|
||
# Determine status
|
||
if failed_rows == 0:
|
||
status = "completed"
|
||
elif imported_rows > 0:
|
||
status = "partial"
|
||
else:
|
||
status = "failed"
|
||
|
||
return {
|
||
"total_rows": total_rows,
|
||
"imported_rows": imported_rows,
|
||
"failed_rows": failed_rows,
|
||
"error_log": "\n".join(error_log) if error_log else None,
|
||
"status": status,
|
||
}
|
||
|
||
|
||
def process_paechter_csv(csv_file, csv_import):
|
||
"""Process CSV file for Paechter import"""
|
||
decoded_file = csv_file.read().decode("utf-8")
|
||
|
||
# Handle both comma and semicolon separated files
|
||
if ";" in decoded_file.split("\n")[0]:
|
||
csv_data = csv.DictReader(io.StringIO(decoded_file), delimiter=";")
|
||
else:
|
||
csv_data = csv.DictReader(io.StringIO(decoded_file))
|
||
|
||
total_rows = 0
|
||
imported_rows = 0
|
||
failed_rows = 0
|
||
error_log = []
|
||
|
||
for row_num, row in enumerate(
|
||
csv_data, start=2
|
||
): # Start at 2 because row 1 is header
|
||
total_rows += 1
|
||
|
||
try:
|
||
# Get raw values from CSV - handle both semicolon and comma separated
|
||
# Handle BOM in column names
|
||
vorname_raw = row.get("Vorname", "") or row.get("\ufeffVorname", "")
|
||
nachname_raw = row.get("Nachname", "")
|
||
personentyp_raw = row.get("Personentyp", "")
|
||
|
||
# Clean up the values (remove extra whitespace but keep empty strings)
|
||
vorname_raw = vorname_raw.strip() if vorname_raw else ""
|
||
nachname_raw = nachname_raw.strip() if nachname_raw else ""
|
||
personentyp_raw = personentyp_raw.strip() if personentyp_raw else ""
|
||
|
||
# Debug: Log raw values and available columns
|
||
error_log.append(f"Zeile {row_num}: Available columns: {list(row.keys())}")
|
||
error_log.append(
|
||
f"Zeile {row_num}: RAW Vorname='{vorname_raw}', Nachname='{nachname_raw}', Personentyp='{personentyp_raw}'"
|
||
)
|
||
|
||
# Determine personentyp based on the data
|
||
if personentyp_raw in ["Gesellschaft", "KG", "GbR", "GmbH"]:
|
||
personentyp = "gesellschaft"
|
||
elif personentyp_raw in ["Herrn", "Frau"]:
|
||
personentyp = "natuerlich"
|
||
else:
|
||
# Fallback: analyze the Nachname to detect companies
|
||
nachname_lower = nachname_raw.lower()
|
||
if any(
|
||
keyword in nachname_lower
|
||
for keyword in [
|
||
"kg",
|
||
"gbr",
|
||
"gmbh",
|
||
"ag",
|
||
"ohg",
|
||
"e.v.",
|
||
"stiftung",
|
||
"genossenschaft",
|
||
]
|
||
):
|
||
personentyp = "gesellschaft"
|
||
else:
|
||
personentyp = "natuerlich"
|
||
|
||
# Handle Vorname - keep original value unless it's 'N/A'
|
||
vorname = vorname_raw if vorname_raw and vorname_raw != "N/A" else ""
|
||
|
||
# Debug: Log processed values
|
||
error_log.append(
|
||
f"Zeile {row_num}: PROCESSED Vorname='{vorname}', Nachname='{nachname_raw}', Personentyp='{personentyp}'"
|
||
)
|
||
|
||
paechter_data = {
|
||
"vorname": vorname,
|
||
"nachname": nachname_raw,
|
||
"email": row.get("E-Mail", "").strip() or None,
|
||
"telefon": row.get("Telefon", "").strip() or None,
|
||
"iban": row.get("IBAN", "").strip() or None,
|
||
"strasse": row.get("Straße", "").strip() or None,
|
||
"plz": row.get("PLZ", "").strip() or None,
|
||
"ort": row.get("Ort", "").strip() or None,
|
||
"personentyp": personentyp,
|
||
"pachtnummer": row.get("Pachtnummer", "").strip() or None,
|
||
"landwirtschaftliche_ausbildung": row.get(
|
||
"Landwirtschaftliche_Ausbildung", "false"
|
||
).lower()
|
||
== "true",
|
||
"berufserfahrung_jahre": (
|
||
int(row.get("Berufserfahrung_Jahre", 0))
|
||
if row.get("Berufserfahrung_Jahre")
|
||
else None
|
||
),
|
||
"spezialisierung": row.get("Spezialisierung", "").strip() or None,
|
||
"notizen": row.get("Notizen", "").strip() or None,
|
||
"aktiv": row.get("Aktiv", "true").lower()
|
||
in ["true", "wahr", "ja", "1"],
|
||
}
|
||
|
||
# Handle date fields
|
||
if row.get("Geburtsdatum"):
|
||
try:
|
||
paechter_data["geburtsdatum"] = datetime.strptime(
|
||
row["Geburtsdatum"], "%d.%m.%Y"
|
||
).date()
|
||
except ValueError:
|
||
try:
|
||
paechter_data["geburtsdatum"] = datetime.strptime(
|
||
row["Geburtsdatum"], "%Y-%m-%d"
|
||
).date()
|
||
except ValueError:
|
||
paechter_data["geburtsdatum"] = None
|
||
|
||
if row.get("Pachtbeginn_Erste"):
|
||
try:
|
||
paechter_data["pachtbeginn_erste"] = datetime.strptime(
|
||
row["Pachtbeginn_Erste"], "%d.%m.%Y"
|
||
).date()
|
||
except ValueError:
|
||
try:
|
||
paechter_data["pachtbeginn_erste"] = datetime.strptime(
|
||
row["Pachtbeginn_Erste"], "%Y-%m-%d"
|
||
).date()
|
||
except ValueError:
|
||
paechter_data["pachtbeginn_erste"] = None
|
||
|
||
if row.get("Pachtende_Letzte"):
|
||
try:
|
||
paechter_data["pachtende_letzte"] = datetime.strptime(
|
||
row["Pachtende_Letzte"], "%d.%m.%Y"
|
||
).date()
|
||
except ValueError:
|
||
try:
|
||
paechter_data["pachtende_letzte"] = datetime.strptime(
|
||
row["Pachtende_Letzte"], "%Y-%m-%d"
|
||
).date()
|
||
except ValueError:
|
||
paechter_data["pachtende_letzte"] = None
|
||
|
||
# Handle decimal fields
|
||
if row.get("Pachtzins_Aktuell"):
|
||
try:
|
||
paechter_data["pachtzins_aktuell"] = float(row["Pachtzins_Aktuell"])
|
||
except ValueError:
|
||
paechter_data["pachtzins_aktuell"] = None
|
||
|
||
# Validate required fields
|
||
if personentyp == "gesellschaft":
|
||
# For companies, only Nachname is required
|
||
if not paechter_data["nachname"]:
|
||
error_log.append(
|
||
f"Zeile {row_num}: Nachname ist für Gesellschaften erforderlich"
|
||
)
|
||
failed_rows += 1
|
||
continue
|
||
else:
|
||
# For natural persons, only Nachname is required
|
||
if not paechter_data["nachname"]:
|
||
error_log.append(
|
||
f"Zeile {row_num}: Nachname ist für natürliche Personen erforderlich"
|
||
)
|
||
failed_rows += 1
|
||
continue
|
||
|
||
# Check if paechter already exists
|
||
if personentyp == "gesellschaft":
|
||
# For companies, search by Nachname only
|
||
existing_paechter = Paechter.objects.filter(
|
||
nachname__iexact=paechter_data["nachname"],
|
||
personentyp="gesellschaft",
|
||
).first()
|
||
else:
|
||
# For natural persons, search by Nachname only (since Vorname can be empty)
|
||
existing_paechter = Paechter.objects.filter(
|
||
nachname__iexact=paechter_data["nachname"], personentyp="natuerlich"
|
||
).first()
|
||
|
||
if existing_paechter:
|
||
# Update existing paechter
|
||
for field, value in paechter_data.items():
|
||
if value is not None:
|
||
setattr(existing_paechter, field, value)
|
||
existing_paechter.save()
|
||
else:
|
||
# Create new paechter
|
||
Paechter.objects.create(**paechter_data)
|
||
|
||
imported_rows += 1
|
||
|
||
except Exception as e:
|
||
error_log.append(f"Zeile {row_num}: {str(e)}")
|
||
failed_rows += 1
|
||
|
||
# Determine status
|
||
if failed_rows == 0:
|
||
status = "completed"
|
||
elif imported_rows > 0:
|
||
status = "partial"
|
||
else:
|
||
status = "failed"
|
||
|
||
return {
|
||
"total_rows": total_rows,
|
||
"imported_rows": imported_rows,
|
||
"failed_rows": failed_rows,
|
||
"error_log": "\n".join(error_log) if error_log else None,
|
||
"status": status,
|
||
}
|
||
|
||
|
||
def process_laendereien_csv(csv_file, csv_import):
|
||
"""Process CSV file for Ländereien import"""
|
||
decoded_file = csv_file.read().decode("utf-8")
|
||
# Handle both comma and semicolon separated files
|
||
if ";" in decoded_file.split("\n")[0]:
|
||
csv_data = csv.DictReader(io.StringIO(decoded_file), delimiter=";")
|
||
else:
|
||
csv_data = csv.DictReader(io.StringIO(decoded_file))
|
||
|
||
total_rows = 0
|
||
imported_rows = 0
|
||
failed_rows = 0
|
||
error_log = []
|
||
|
||
last_gemeinde = None
|
||
for row_num, row in enumerate(csv_data, start=2):
|
||
total_rows += 1
|
||
|
||
try:
|
||
# Build case-insensitive access helpers (strip BOM, normalize separators)
|
||
def clean_key(key: str) -> str:
|
||
return (key or "").replace("\ufeff", "").replace("\ufeff", "").strip()
|
||
|
||
normalized_row = {clean_key(k): (v or "").strip() for k, v in row.items()}
|
||
lower_row = {
|
||
clean_key(k).lower(): (v or "").strip() for k, v in row.items()
|
||
}
|
||
sanitized_row = {
|
||
clean_key(k)
|
||
.lower()
|
||
.replace("-", "_")
|
||
.replace(" ", "_"): (v or "")
|
||
.strip()
|
||
for k, v in row.items()
|
||
}
|
||
|
||
def get_val(*keys):
|
||
# Try exact keys first, then case-insensitive
|
||
for key in keys:
|
||
if key in normalized_row:
|
||
return normalized_row[key]
|
||
for key in keys:
|
||
lk = key.lower()
|
||
if lk in lower_row:
|
||
return lower_row[lk]
|
||
sk = lk.replace("-", "_").replace(" ", "_")
|
||
if sk in sanitized_row:
|
||
return sanitized_row[sk]
|
||
return ""
|
||
|
||
def parse_float(value):
|
||
if not value:
|
||
return 0
|
||
# replace comma decimal if present
|
||
v = (
|
||
value.replace(".", "").replace(",", ".")
|
||
if value.count(",") == 1 and value.count(".") > 1
|
||
else value.replace(",", ".")
|
||
)
|
||
try:
|
||
return float(v)
|
||
except ValueError:
|
||
return 0
|
||
|
||
# Map CSV columns to model fields (robust to header variants)
|
||
lfd_nr_val = get_val(
|
||
"Lfd_Nr",
|
||
"lfd_nr",
|
||
"LfdNr",
|
||
"lfdnr",
|
||
"laufende_nummer",
|
||
"laufende-nummer",
|
||
)
|
||
land_data = {
|
||
"lfd_nr": lfd_nr_val,
|
||
"ew_nummer": get_val("EW_Nummer", "ew_nummer") or None,
|
||
"amtsgericht": get_val("Amtsgericht", "amtsgericht"),
|
||
"gemeinde": get_val("Gemeinde", "gemeinde"),
|
||
"gemarkung": get_val("Gemarkung", "gemarkung"),
|
||
"flur": get_val("Flur", "flur"),
|
||
"flurstueck": get_val(
|
||
"Flurstück", "Flurstueck", "flurstück", "flurstueck"
|
||
),
|
||
"groesse_qm": parse_float(
|
||
get_val("Größe_qm", "Groesse_qm", "groesse_qm", "größe_qm")
|
||
),
|
||
"gruenland_qm": parse_float(
|
||
get_val(
|
||
"Grünland_qm", "Gruenland_qm", "gruenland_qm", "grünland_qm"
|
||
)
|
||
),
|
||
"acker_qm": parse_float(get_val("Acker_qm", "acker_qm")),
|
||
"wald_qm": parse_float(get_val("Wald_qm", "wald_qm")),
|
||
"sonstiges_qm": parse_float(get_val("Sonstiges_qm", "sonstiges_qm")),
|
||
"verpachtete_gesamtflaeche": parse_float(
|
||
get_val(
|
||
"Verpachtete_Gesamtfläche_qm",
|
||
"Verpachtete_Gesamtflaeche_qm",
|
||
"verpachtete_gesamtfläche_qm",
|
||
"verpachtete_gesamtflaeche_qm",
|
||
)
|
||
),
|
||
"verp_flaeche_aktuell": parse_float(
|
||
get_val(
|
||
"Verp_Fläche_aktuell_qm",
|
||
"Verp_Flaeche_aktuell_qm",
|
||
"verp_flaeche_aktuell_qm",
|
||
"verp_fläche_aktuell_qm",
|
||
)
|
||
),
|
||
"aktiv": get_val("Aktiv", "aktiv").lower()
|
||
in ["true", "wahr", "ja", "1"],
|
||
"notizen": get_val("Notizen", "notizen") or None,
|
||
}
|
||
|
||
# Fallback for missing 'Gemeinde' -> set explicit placeholder
|
||
if not land_data["gemeinde"]:
|
||
land_data["gemeinde"] = "FEHLT"
|
||
|
||
# Validate required fields
|
||
required_fields = ["lfd_nr", "gemeinde", "gemarkung", "flur", "flurstueck"]
|
||
missing_fields = [
|
||
field for field in required_fields if not land_data[field]
|
||
]
|
||
|
||
if missing_fields:
|
||
# Log header diagnostics on first failure only to help debugging
|
||
if row_num == 2:
|
||
error_log.append(f"Erkannte Spalten: {list(normalized_row.keys())}")
|
||
error_log.append(
|
||
f"Zeile {row_num}: Fehlende Pflichtfelder: {', '.join(missing_fields)}"
|
||
)
|
||
failed_rows += 1
|
||
continue
|
||
|
||
# Check if land already exists
|
||
existing_land = Land.objects.filter(lfd_nr=land_data["lfd_nr"]).first()
|
||
|
||
if existing_land:
|
||
# Update existing land
|
||
for field, value in land_data.items():
|
||
if value is not None:
|
||
setattr(existing_land, field, value)
|
||
existing_land.save()
|
||
else:
|
||
# Create new land
|
||
Land.objects.create(**land_data)
|
||
|
||
imported_rows += 1
|
||
if land_data["gemeinde"]:
|
||
last_gemeinde = land_data["gemeinde"]
|
||
|
||
except Exception as e:
|
||
error_log.append(f"Zeile {row_num}: {str(e)}")
|
||
failed_rows += 1
|
||
|
||
# Determine status
|
||
if failed_rows == 0:
|
||
status = "completed"
|
||
elif imported_rows > 0:
|
||
status = "partial"
|
||
else:
|
||
status = "failed"
|
||
|
||
return {
|
||
"total_rows": total_rows,
|
||
"imported_rows": imported_rows,
|
||
"failed_rows": failed_rows,
|
||
"error_log": "\n".join(error_log) if error_log else None,
|
||
"status": status,
|
||
}
|
||
|
||
|
||
# Person Views
|
||
@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 paechter_list(request):
|
||
search_query = request.GET.get("search", "")
|
||
ausbildung_filter = request.GET.get("ausbildung", "")
|
||
aktiv_filter = request.GET.get("aktiv", "")
|
||
sort = request.GET.get("sort", "")
|
||
direction = request.GET.get("dir", "asc")
|
||
|
||
paechter = Paechter.objects.all()
|
||
|
||
if search_query:
|
||
paechter = paechter.filter(
|
||
Q(nachname__icontains=search_query)
|
||
| Q(vorname__icontains=search_query)
|
||
| Q(email__icontains=search_query)
|
||
| Q(pachtnummer__icontains=search_query)
|
||
)
|
||
|
||
if ausbildung_filter == "true":
|
||
paechter = paechter.filter(landwirtschaftliche_ausbildung=True)
|
||
elif ausbildung_filter == "false":
|
||
paechter = paechter.filter(landwirtschaftliche_ausbildung=False)
|
||
|
||
if aktiv_filter == "true":
|
||
paechter = paechter.filter(aktiv=True)
|
||
elif aktiv_filter == "false":
|
||
paechter = paechter.filter(aktiv=False)
|
||
|
||
# Annotate with total leased area and rent (coalesce nulls to Decimal for stable sorting)
|
||
paechter = paechter.annotate(
|
||
gesamt_flaeche=Coalesce(
|
||
Sum("neue_verpachtungen__verpachtete_flaeche"),
|
||
Value(
|
||
Decimal("0.00"),
|
||
output_field=DecimalField(max_digits=12, decimal_places=2),
|
||
),
|
||
output_field=DecimalField(max_digits=12, decimal_places=2),
|
||
),
|
||
gesamt_pachtzins=Coalesce(
|
||
Sum("neue_verpachtungen__pachtzins_pauschal"),
|
||
Value(
|
||
Decimal("0.00"),
|
||
output_field=DecimalField(max_digits=12, decimal_places=2),
|
||
),
|
||
output_field=DecimalField(max_digits=12, decimal_places=2),
|
||
),
|
||
)
|
||
|
||
# Sorting
|
||
sort_map = {
|
||
"name": ["nachname", "vorname"],
|
||
"pachtnummer": ["pachtnummer"],
|
||
"ausbildung": ["landwirtschaftliche_ausbildung"],
|
||
"spezialisierung": ["spezialisierung"],
|
||
"flaeche": ["gesamt_flaeche"],
|
||
"pachtzins": ["gesamt_pachtzins"],
|
||
"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
|
||
paechter = paechter.order_by(*order_fields)
|
||
|
||
paginator = Paginator(paechter, 20)
|
||
page_number = request.GET.get("page")
|
||
page_obj = paginator.get_page(page_number)
|
||
|
||
context = {
|
||
"page_obj": page_obj,
|
||
"search_query": search_query,
|
||
"ausbildung_filter": ausbildung_filter,
|
||
"aktiv_filter": aktiv_filter,
|
||
"sort": sort,
|
||
"dir": direction,
|
||
}
|
||
return render(request, "stiftung/paechter_list.html", context)
|
||
|
||
|
||
@login_required
|
||
def paechter_detail(request, pk):
|
||
paechter = get_object_or_404(Paechter, pk=pk)
|
||
|
||
# Alle mit diesem Pächter verknüpften Dokumente laden
|
||
verknuepfte_dokumente = DokumentLink.objects.filter(
|
||
paechter_id=paechter.pk
|
||
).order_by("kontext", "titel")
|
||
|
||
# Neue LandVerpachtungen für diesen Pächter laden
|
||
verpachtungen = LandVerpachtung.objects.filter(paechter=paechter).order_by(
|
||
"-pachtbeginn"
|
||
)
|
||
|
||
# Neue gepachtete Ländereien (über aktueller_paechter)
|
||
gepachtete_laendereien = paechter.gepachtete_laendereien.filter(
|
||
aktiv=True
|
||
).order_by("gemeinde", "gemarkung")
|
||
|
||
# Statistiken berechnen
|
||
total_flaeche_neu = sum(
|
||
land.verp_flaeche_aktuell or 0 for land in gepachtete_laendereien
|
||
)
|
||
total_pachtzins_neu = sum(
|
||
land.pachtzins_pauschal or 0 for land in gepachtete_laendereien
|
||
)
|
||
|
||
context = {
|
||
"paechter": paechter,
|
||
"verknuepfte_dokumente": verknuepfte_dokumente,
|
||
"verpachtungen": verpachtungen, # Now using LandVerpachtung
|
||
"gepachtete_laendereien": gepachtete_laendereien, # Neu
|
||
"total_flaeche_neu": total_flaeche_neu,
|
||
"total_pachtzins_neu": total_pachtzins_neu,
|
||
}
|
||
return render(request, "stiftung/paechter_detail.html", context)
|
||
|
||
|
||
@login_required
|
||
def paechter_create(request):
|
||
if request.method == "POST":
|
||
form = PaechterForm(request.POST)
|
||
if form.is_valid():
|
||
paechter = form.save()
|
||
messages.success(
|
||
request,
|
||
f'Pächter "{paechter.get_full_name()}" wurde erfolgreich erstellt.',
|
||
)
|
||
return redirect("stiftung:paechter_detail", pk=paechter.pk)
|
||
else:
|
||
# Debug: Log form errors and show them to user
|
||
print(f"Form errors: {form.errors}")
|
||
print(f"Form data: {request.POST}")
|
||
messages.error(request, f"Formular-Fehler: {form.errors}")
|
||
for field, errors in form.errors.items():
|
||
for error in errors:
|
||
messages.error(request, f"{field}: {error}")
|
||
else:
|
||
form = PaechterForm()
|
||
|
||
context = {"form": form, "title": "Neuen Pächter erstellen"}
|
||
return render(request, "stiftung/paechter_form.html", context)
|
||
|
||
|
||
@login_required
|
||
def paechter_update(request, pk):
|
||
paechter = get_object_or_404(Paechter, pk=pk)
|
||
if request.method == "POST":
|
||
form = PaechterForm(request.POST, instance=paechter)
|
||
if form.is_valid():
|
||
paechter = form.save()
|
||
messages.success(
|
||
request,
|
||
f'Pächter "{paechter.get_full_name()}" wurde erfolgreich aktualisiert.',
|
||
)
|
||
return redirect("stiftung:paechter_detail", pk=paechter.pk)
|
||
else:
|
||
# Debug: Log form errors and show them to user
|
||
print(f"Form errors: {form.errors}")
|
||
print(f"Form data: {request.POST}")
|
||
messages.error(request, f"Formular-Fehler: {form.errors}")
|
||
for field, errors in form.errors.items():
|
||
for error in errors:
|
||
messages.error(request, f"{field}: {error}")
|
||
else:
|
||
form = PaechterForm(instance=paechter)
|
||
|
||
context = {
|
||
"form": form,
|
||
"paechter": paechter,
|
||
"title": f"Pächter bearbeiten: {paechter.get_full_name()}",
|
||
}
|
||
return render(request, "stiftung/paechter_form.html", context)
|
||
|
||
|
||
@login_required
|
||
def paechter_delete(request, pk):
|
||
paechter = get_object_or_404(Paechter, pk=pk)
|
||
if request.method == "POST":
|
||
paechter.delete()
|
||
messages.success(
|
||
request, f'Pächter "{paechter.get_full_name()}" wurde erfolgreich gelöscht.'
|
||
)
|
||
return redirect("stiftung:paechter_list")
|
||
|
||
context = {"paechter": paechter}
|
||
return render(request, "stiftung/paechter_confirm_delete.html", context)
|
||
|
||
|
||
# Land Views
|
||
@login_required
|
||
def land_list(request):
|
||
search_query = request.GET.get("search", "")
|
||
gemeinde_filter = request.GET.get("gemeinde", "")
|
||
aktiv_filter = request.GET.get("aktiv", "")
|
||
sort = request.GET.get("sort", "")
|
||
direction = request.GET.get("dir", "asc")
|
||
|
||
lands = Land.objects.all()
|
||
|
||
if search_query:
|
||
lands = lands.filter(
|
||
Q(lfd_nr__icontains=search_query)
|
||
| Q(gemeinde__icontains=search_query)
|
||
| Q(gemarkung__icontains=search_query)
|
||
| Q(flur__icontains=search_query)
|
||
| Q(flurstueck__icontains=search_query)
|
||
)
|
||
|
||
if gemeinde_filter:
|
||
lands = lands.filter(gemeinde=gemeinde_filter)
|
||
|
||
if aktiv_filter == "true":
|
||
lands = lands.filter(aktiv=True)
|
||
elif aktiv_filter == "false":
|
||
lands = lands.filter(aktiv=False)
|
||
|
||
# Annotate with verpachtungsgrad and numeric casts for natural sorting
|
||
# Prepare numeric versions of textual fields by stripping common non-digits
|
||
def digits_only(field_expr):
|
||
expr = Replace(field_expr, Value(" "), Value(""))
|
||
expr = Replace(expr, Value("-"), Value(""))
|
||
expr = Replace(expr, Value("."), Value(""))
|
||
expr = Replace(expr, Value("/"), Value(""))
|
||
expr = Replace(expr, Value("L"), Value(""))
|
||
return expr
|
||
|
||
lands = lands.extra(
|
||
select={
|
||
"verpachtungsgrad": "CASE WHEN groesse_qm > 0 THEN (verp_flaeche_aktuell / groesse_qm) * 100 ELSE 0 END"
|
||
}
|
||
).annotate(
|
||
lfd_nr_num=Cast(NullIf(digits_only(F("lfd_nr")), Value("")), IntegerField()),
|
||
flur_num=Cast(NullIf(digits_only(F("flur")), Value("")), IntegerField()),
|
||
flurstueck_num=Cast(
|
||
NullIf(digits_only(F("flurstueck")), Value("")), IntegerField()
|
||
),
|
||
)
|
||
|
||
# Sorting
|
||
sort_map = {
|
||
"lfd_nr": ["lfd_nr_num", "lfd_nr"],
|
||
"gemeinde": ["gemeinde"],
|
||
"gemarkung": ["gemarkung"],
|
||
"flur": ["flur_num", "flur"],
|
||
"flurstueck": ["flurstueck_num", "flurstueck"],
|
||
"groesse": ["groesse_qm"],
|
||
"verp": ["verp_flaeche_aktuell"],
|
||
"grad": ["verpachtungsgrad"],
|
||
}
|
||
if sort in sort_map:
|
||
fields = sort_map[sort]
|
||
if direction == "desc":
|
||
order_fields = [f"-{f}" for f in fields]
|
||
else:
|
||
order_fields = fields
|
||
lands = lands.order_by(*order_fields)
|
||
|
||
# Aggregated statistics for current filter set
|
||
aggregates = lands.aggregate(
|
||
sum_groesse_qm=Sum("groesse_qm"),
|
||
sum_gruenland_qm=Sum("gruenland_qm"),
|
||
sum_acker_qm=Sum("acker_qm"),
|
||
sum_wald_qm=Sum("wald_qm"),
|
||
sum_sonstiges_qm=Sum("sonstiges_qm"),
|
||
)
|
||
sum_groesse_qm = float(aggregates.get("sum_groesse_qm") or 0)
|
||
sum_gruenland_qm = float(aggregates.get("sum_gruenland_qm") or 0)
|
||
sum_acker_qm = float(aggregates.get("sum_acker_qm") or 0)
|
||
sum_wald_qm = float(aggregates.get("sum_wald_qm") or 0)
|
||
sum_sonstiges_qm = float(aggregates.get("sum_sonstiges_qm") or 0)
|
||
sum_total_use_qm = sum_gruenland_qm + sum_acker_qm + sum_wald_qm + sum_sonstiges_qm
|
||
|
||
# Calculate verpachtung statistics
|
||
total_plots = lands.count()
|
||
verpachtete_plots = lands.filter(verp_flaeche_aktuell__gt=0).count()
|
||
unveerpachtete_plots = total_plots - verpachtete_plots
|
||
|
||
def pct(part, total):
|
||
return round((part / total) * 100, 1) if total and part is not None else 0.0
|
||
|
||
stats = {
|
||
"sum_groesse_qm": sum_groesse_qm,
|
||
"sum_gruenland_qm": sum_gruenland_qm,
|
||
"sum_acker_qm": sum_acker_qm,
|
||
"sum_wald_qm": sum_wald_qm,
|
||
"sum_sonstiges_qm": sum_sonstiges_qm,
|
||
"sum_total_use_qm": sum_total_use_qm,
|
||
"pct_gruenland": pct(sum_gruenland_qm, sum_total_use_qm),
|
||
"pct_acker": pct(sum_acker_qm, sum_total_use_qm),
|
||
"pct_wald": pct(sum_wald_qm, sum_total_use_qm),
|
||
"total_plots": total_plots,
|
||
"verpachtete_plots": verpachtete_plots,
|
||
"unveerpachtete_plots": unveerpachtete_plots,
|
||
"pct_verpachtet": pct(verpachtete_plots, total_plots),
|
||
"pct_unveerpachtet": pct(unveerpachtete_plots, total_plots),
|
||
}
|
||
|
||
# Prepare size chart data (top 30 by size)
|
||
top_sizes = list(
|
||
lands.order_by("-groesse_qm").values_list("lfd_nr", "groesse_qm")[:30]
|
||
)
|
||
size_chart_labels = [label or "" for label, _ in top_sizes]
|
||
size_chart_values = [float(val or 0) for _, val in top_sizes]
|
||
|
||
paginator = Paginator(lands, 20)
|
||
page_number = request.GET.get("page")
|
||
page_obj = paginator.get_page(page_number)
|
||
|
||
# Get unique gemeinden for filter
|
||
gemeinden = (
|
||
Land.objects.values_list("gemeinde", flat=True).distinct().order_by("gemeinde")
|
||
)
|
||
|
||
context = {
|
||
"page_obj": page_obj,
|
||
"search_query": search_query,
|
||
"gemeinde_filter": gemeinde_filter,
|
||
"aktiv_filter": aktiv_filter,
|
||
"gemeinden": gemeinden,
|
||
"stats": stats,
|
||
"size_chart_labels_json": json.dumps(size_chart_labels),
|
||
"size_chart_values_json": json.dumps(size_chart_values),
|
||
"sort": sort,
|
||
"dir": direction,
|
||
}
|
||
return render(request, "stiftung/land_list.html", context)
|
||
|
||
|
||
@login_required
|
||
def land_detail(request, pk):
|
||
land = get_object_or_404(Land, pk=pk)
|
||
|
||
# Alle mit dieser Länderei verknüpften Dokumente laden
|
||
verknuepfte_dokumente = DokumentLink.objects.filter(land_id=land.pk).order_by(
|
||
"kontext", "titel"
|
||
)
|
||
|
||
# Neue LandVerpachtungen laden (mit related data)
|
||
neue_verpachtungen = land.neue_verpachtungen.select_related("paechter").order_by(
|
||
"-pachtbeginn"
|
||
)
|
||
|
||
context = {
|
||
"land": land,
|
||
"verknuepfte_dokumente": verknuepfte_dokumente,
|
||
"verpachtungen": neue_verpachtungen, # Using only new system now
|
||
"neue_verpachtungen": neue_verpachtungen,
|
||
}
|
||
return render(request, "stiftung/land_detail.html", context)
|
||
|
||
|
||
@login_required
|
||
def land_create(request):
|
||
if request.method == "POST":
|
||
form = LandForm(request.POST)
|
||
|
||
# Debug: Print form data
|
||
print("=== LAND CREATE DEBUG ===")
|
||
print(f"POST data: {dict(request.POST)}")
|
||
print(f"Form is valid: {form.is_valid()}")
|
||
|
||
if not form.is_valid():
|
||
print(f"Form errors: {form.errors}")
|
||
print(f"Form non-field errors: {form.non_field_errors()}")
|
||
# Add error messages for debugging
|
||
for field, errors in form.errors.items():
|
||
for error in errors:
|
||
messages.error(request, f"{field}: {error}")
|
||
|
||
if form.is_valid():
|
||
try:
|
||
land = form.save()
|
||
messages.success(request, f'Länderei "{land}" wurde erfolgreich erstellt.')
|
||
print(f"Successfully created land: {land}")
|
||
return redirect("stiftung:land_detail", pk=land.pk)
|
||
except Exception as e:
|
||
print(f"Error saving land: {e}")
|
||
messages.error(request, f"Fehler beim Speichern: {str(e)}")
|
||
else:
|
||
messages.error(request, "Bitte korrigieren Sie die Fehler im Formular.")
|
||
else:
|
||
form = LandForm()
|
||
|
||
context = {"form": form, "title": "Neue Länderei erstellen"}
|
||
return render(request, "stiftung/land_form.html", context)
|
||
|
||
|
||
@login_required
|
||
def land_update(request, pk):
|
||
land = get_object_or_404(Land, pk=pk)
|
||
if request.method == "POST":
|
||
form = LandForm(request.POST, instance=land)
|
||
if form.is_valid():
|
||
land = form.save()
|
||
messages.success(
|
||
request, f'Länderei "{land}" wurde erfolgreich aktualisiert.'
|
||
)
|
||
return redirect("stiftung:land_detail", pk=land.pk)
|
||
else:
|
||
form = LandForm(instance=land)
|
||
|
||
context = {"form": form, "land": land, "title": f"Länderei bearbeiten: {land}"}
|
||
return render(request, "stiftung/land_form.html", context)
|
||
|
||
|
||
@login_required
|
||
def land_delete(request, pk):
|
||
land = get_object_or_404(Land, pk=pk)
|
||
if request.method == "POST":
|
||
land.delete()
|
||
messages.success(request, f'Länderei "{land}" wurde erfolgreich gelöscht.')
|
||
return redirect("stiftung:land_list")
|
||
|
||
context = {"land": land}
|
||
return render(request, "stiftung/land_confirm_delete.html", context)
|
||
|
||
|
||
# Verpachtung Views
|
||
@login_required
|
||
def verpachtung_list(request):
|
||
search_query = request.GET.get("search", "")
|
||
status_filter = request.GET.get("status", "")
|
||
gemeinde_filter = request.GET.get("gemeinde", "")
|
||
sort = request.GET.get("sort", "")
|
||
direction = request.GET.get("dir", "asc")
|
||
|
||
verpachtungen = LandVerpachtung.objects.select_related("land", "paechter").all()
|
||
|
||
if search_query:
|
||
verpachtungen = verpachtungen.filter(
|
||
Q(vertragsnummer__icontains=search_query)
|
||
| Q(land__gemeinde__icontains=search_query)
|
||
| Q(paechter__nachname__icontains=search_query)
|
||
| Q(paechter__vorname__icontains=search_query)
|
||
)
|
||
|
||
if status_filter:
|
||
verpachtungen = verpachtungen.filter(status=status_filter)
|
||
|
||
if gemeinde_filter:
|
||
verpachtungen = verpachtungen.filter(land__gemeinde=gemeinde_filter)
|
||
|
||
# Sorting
|
||
sort_map = {
|
||
"vertragsnummer": ["vertragsnummer"],
|
||
"land": ["land__gemeinde"],
|
||
"paechter": ["paechter__nachname", "paechter__vorname"],
|
||
"beginn": ["pachtbeginn"],
|
||
"ende": ["pachtende"],
|
||
"flaeche": ["verpachtete_flaeche"],
|
||
"pachtzins": ["pachtzins_pauschal"],
|
||
"status": ["status"],
|
||
}
|
||
if sort in sort_map:
|
||
fields = sort_map[sort]
|
||
if direction == "desc":
|
||
order_fields = [f"-{f}" for f in fields]
|
||
else:
|
||
order_fields = fields
|
||
verpachtungen = verpachtungen.order_by(*order_fields)
|
||
|
||
paginator = Paginator(verpachtungen, 20)
|
||
page_number = request.GET.get("page")
|
||
page_obj = paginator.get_page(page_number)
|
||
|
||
# Calculate statistics for the summary cards
|
||
# Get ALL verpachtungen (not filtered) for accurate statistics
|
||
all_verpachtungen = LandVerpachtung.objects.select_related("land", "paechter").all()
|
||
|
||
# Active verpachtungen count
|
||
aktive_verpachtungen = all_verpachtungen.filter(status="aktiv").count()
|
||
|
||
# Total leased area (only active verpachtungen)
|
||
gesamt_flaeche_result = all_verpachtungen.filter(status="aktiv").aggregate(
|
||
total=Sum("verpachtete_flaeche")
|
||
)
|
||
gesamt_flaeche = (
|
||
gesamt_flaeche_result["total"]
|
||
if gesamt_flaeche_result["total"] is not None
|
||
else 0
|
||
)
|
||
|
||
# Total annual rent (only active verpachtungen)
|
||
jaehrlicher_pachtzins_result = all_verpachtungen.filter(status="aktiv").aggregate(
|
||
total=Sum("pachtzins_pauschal")
|
||
)
|
||
jaehrlicher_pachtzins = (
|
||
jaehrlicher_pachtzins_result["total"]
|
||
if jaehrlicher_pachtzins_result["total"] is not None
|
||
else 0
|
||
)
|
||
|
||
# Total count of all verpachtungen
|
||
anzahl_verpachtungen = all_verpachtungen.count()
|
||
|
||
# Get unique gemeinden and statuses for filters
|
||
gemeinden = (
|
||
Land.objects.values_list("gemeinde", flat=True).distinct().order_by("gemeinde")
|
||
)
|
||
status_choices = LandVerpachtung.STATUS_CHOICES
|
||
|
||
context = {
|
||
"page_obj": page_obj,
|
||
"search_query": search_query,
|
||
"status_filter": status_filter,
|
||
"gemeinde_filter": gemeinde_filter,
|
||
"gemeinden": gemeinden,
|
||
"status_choices": status_choices,
|
||
# Statistics for summary cards
|
||
"aktive_verpachtungen": aktive_verpachtungen,
|
||
"gesamt_flaeche": gesamt_flaeche,
|
||
"jaehrlicher_pachtzins": jaehrlicher_pachtzins,
|
||
"anzahl_verpachtungen": anzahl_verpachtungen,
|
||
"sort": sort,
|
||
"dir": direction,
|
||
}
|
||
return render(request, "stiftung/verpachtung_list.html", context)
|
||
|
||
|
||
@login_required
|
||
def land_verpachtung_detail(request, pk):
|
||
"""Detail view for LandVerpachtung"""
|
||
verpachtung = get_object_or_404(LandVerpachtung, pk=pk)
|
||
|
||
# Alle mit dieser Verpachtung verknüpften Dokumente laden
|
||
verknuepfte_dokumente = DokumentLink.objects.filter(
|
||
land_verpachtung_id=verpachtung.pk
|
||
).order_by("kontext", "titel")
|
||
|
||
context = {
|
||
"verpachtung": verpachtung,
|
||
"landverpachtung": verpachtung, # Template expects this variable name
|
||
"verknuepfte_dokumente": verknuepfte_dokumente,
|
||
}
|
||
return render(request, "stiftung/land_verpachtung_detail.html", context)
|
||
|
||
|
||
@login_required
|
||
def land_verpachtung_update(request, pk):
|
||
"""Update an existing LandVerpachtung by its primary key"""
|
||
verpachtung = get_object_or_404(LandVerpachtung, pk=pk)
|
||
|
||
if request.method == "POST":
|
||
# Handle the update form submission
|
||
vertragsnummer = request.POST.get("vertragsnummer")
|
||
pachtbeginn = request.POST.get("pachtbeginn")
|
||
pachtende = request.POST.get("pachtende")
|
||
pachtzins_pauschal = request.POST.get("pachtzins_pauschal")
|
||
|
||
if vertragsnummer:
|
||
verpachtung.vertragsnummer = vertragsnummer
|
||
if pachtbeginn:
|
||
verpachtung.pachtbeginn = pachtbeginn
|
||
if pachtende:
|
||
verpachtung.pachtende = pachtende
|
||
if pachtzins_pauschal:
|
||
verpachtung.pachtzins_pauschal = pachtzins_pauschal
|
||
|
||
verpachtung.save()
|
||
messages.success(request, "Verpachtung wurde erfolgreich aktualisiert.")
|
||
return redirect("stiftung:land_verpachtung_detail", pk=verpachtung.pk)
|
||
|
||
context = {
|
||
"verpachtung": verpachtung,
|
||
"landverpachtung": verpachtung, # Template expects this variable name
|
||
"is_edit": True,
|
||
"is_update": True, # Form template uses this flag
|
||
}
|
||
return render(request, "stiftung/land_verpachtung_form.html", context)
|
||
|
||
|
||
@login_required
|
||
def land_verpachtung_end_direct(request, pk):
|
||
"""End a LandVerpachtung directly by its primary key"""
|
||
verpachtung = get_object_or_404(LandVerpachtung, pk=pk)
|
||
|
||
if request.method == "POST":
|
||
verpachtung.status = "beendet"
|
||
verpachtung.pachtende = timezone.now().date()
|
||
verpachtung.save()
|
||
messages.success(request, "Verpachtung wurde erfolgreich beendet.")
|
||
return redirect("stiftung:land_detail", pk=verpachtung.land.pk)
|
||
|
||
context = {
|
||
"verpachtung": verpachtung,
|
||
}
|
||
return render(request, "stiftung/land_verpachtung_end_confirm.html", context)
|
||
|
||
|
||
# Förderung Views
|
||
@login_required
|
||
def foerderung_list(request):
|
||
"""List all funding grants with filtering and pagination"""
|
||
foerderungen = Foerderung.objects.select_related(
|
||
"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
|
||
@login_required
|
||
def dokument_list(request):
|
||
"""Zeigt alle verknüpften Dokumente an"""
|
||
# Alle verknüpften Dokumente laden
|
||
dokumente = DokumentLink.objects.all().order_by("-id")
|
||
|
||
# Paperless-API-Konfiguration für verfügbare Dokumente
|
||
import requests
|
||
|
||
from stiftung.utils.config import get_paperless_config
|
||
|
||
config = get_paperless_config()
|
||
url = config["api_url"]
|
||
token = config["api_token"]
|
||
|
||
available_dokumente = []
|
||
if url and token:
|
||
try:
|
||
base_url = url[:-4] if url.endswith("/api") else url
|
||
headers = {"Authorization": f"Token {token}"}
|
||
|
||
# Alle verfügbaren Dokumente abrufen (mit Paginierung)
|
||
all_dokumente = []
|
||
page = 1
|
||
page_size = 100
|
||
|
||
while True:
|
||
response = requests.get(
|
||
f"{base_url}/api/documents/?page={page}&page_size={page_size}",
|
||
headers=headers,
|
||
timeout=10,
|
||
)
|
||
response.raise_for_status()
|
||
data = response.json()
|
||
|
||
all_dokumente.extend(data.get("results", []))
|
||
|
||
if not data.get("next"):
|
||
break
|
||
page += 1
|
||
|
||
# Stiftung-Dokumente filtern
|
||
for doc in all_dokumente:
|
||
try:
|
||
tags = []
|
||
doc_tags = doc.get("tags", [])
|
||
|
||
if isinstance(doc_tags, list):
|
||
for tag in doc_tags:
|
||
if isinstance(tag, dict) and "name" in tag:
|
||
tags.append(tag["name"])
|
||
elif isinstance(tag, str):
|
||
tags.append(tag)
|
||
elif isinstance(tag, int):
|
||
tags.append(f"Tag_{tag}")
|
||
elif isinstance(doc_tags, str):
|
||
tags = [tag.strip() for tag in doc_tags.split(",")]
|
||
|
||
if any(
|
||
tag
|
||
in [
|
||
config["destinataere_tag"],
|
||
config["land_tag"],
|
||
config["admin_tag"],
|
||
]
|
||
for tag in tags
|
||
):
|
||
bereits_verknuepft = DokumentLink.objects.filter(
|
||
paperless_document_id=doc["id"]
|
||
).exists()
|
||
|
||
if not bereits_verknuepft:
|
||
available_dokumente.append(
|
||
{
|
||
"id": doc["id"],
|
||
"title": doc.get("title", f'Dokument {doc["id"]}'),
|
||
"created_date": doc.get("created_date", ""),
|
||
"tags": tags,
|
||
"thumbnail_url": f"{base_url}/api/documents/{doc['id']}/thumb/",
|
||
"document_url": f"{base_url}/documents/{doc['id']}/",
|
||
}
|
||
)
|
||
except Exception:
|
||
continue
|
||
|
||
# Nach Erstellungsdatum sortieren
|
||
available_dokumente.sort(key=lambda x: x["created_date"], reverse=True)
|
||
|
||
except Exception:
|
||
pass
|
||
|
||
context = {
|
||
"dokumente": dokumente,
|
||
"available_dokumente": available_dokumente,
|
||
"title": "Alle verknüpften Dokumente",
|
||
}
|
||
return render(request, "stiftung/dokument_list.html", context)
|
||
|
||
|
||
@login_required
|
||
def dokument_detail(request, pk):
|
||
"""Show details of a specific document link"""
|
||
dokument = get_object_or_404(DokumentLink, pk=pk)
|
||
|
||
context = {
|
||
"dokument": dokument,
|
||
"title": f"Dokument: {dokument}",
|
||
}
|
||
return render(request, "stiftung/dokument_detail.html", context)
|
||
|
||
|
||
@login_required
|
||
def dokument_create(request):
|
||
"""Create a new document link"""
|
||
if request.method == "POST":
|
||
form = DokumentLinkForm(request.POST)
|
||
if form.is_valid():
|
||
dokument = form.save()
|
||
messages.success(
|
||
request, f'Dokument "{dokument.titel}" wurde erfolgreich verknüpft.'
|
||
)
|
||
|
||
# Zurück zur verknüpften Entität leiten
|
||
if dokument.land_verpachtung_id:
|
||
return redirect(
|
||
"stiftung:verpachtung_detail", pk=dokument.land_verpachtung_id
|
||
)
|
||
elif dokument.verpachtung_id:
|
||
return redirect(
|
||
"stiftung:verpachtung_detail", pk=dokument.verpachtung_id
|
||
)
|
||
elif dokument.land_id:
|
||
return redirect("stiftung:land_detail", pk=dokument.land_id)
|
||
elif dokument.paechter_id:
|
||
return redirect("stiftung:paechter_detail", pk=dokument.paechter_id)
|
||
elif dokument.destinataer_id:
|
||
return redirect(
|
||
"stiftung:destinataer_detail", pk=dokument.destinataer_id
|
||
)
|
||
elif dokument.foerderung_id:
|
||
return redirect("stiftung:foerderung_detail", pk=dokument.foerderung_id)
|
||
else:
|
||
return redirect("stiftung:dokument_detail", pk=dokument.pk)
|
||
else:
|
||
# Initial-Werte aus GET-Parametern setzen
|
||
initial_data = {}
|
||
if request.GET.get("land_verpachtung_id"):
|
||
initial_data["land_verpachtung_id"] = request.GET.get("land_verpachtung_id")
|
||
if request.GET.get("verpachtung"):
|
||
initial_data["verpachtung_id"] = request.GET.get("verpachtung")
|
||
if request.GET.get("land"):
|
||
initial_data["land_id"] = request.GET.get("land")
|
||
if request.GET.get("paechter"):
|
||
initial_data["paechter_id"] = request.GET.get("paechter")
|
||
if request.GET.get("destinataer"):
|
||
initial_data["destinataer_id"] = request.GET.get("destinataer")
|
||
if request.GET.get("foerderung"):
|
||
initial_data["foerderung_id"] = request.GET.get("foerderung")
|
||
|
||
form = DokumentLinkForm(initial=initial_data)
|
||
|
||
context = {
|
||
"form": form,
|
||
"title": "Neues Dokument verknüpfen",
|
||
}
|
||
return render(request, "stiftung/dokument_form.html", context)
|
||
|
||
|
||
@login_required
|
||
def dokument_update(request, pk):
|
||
"""Update an existing document link"""
|
||
dokument = get_object_or_404(DokumentLink, pk=pk)
|
||
|
||
if request.method == "POST":
|
||
form = DokumentLinkForm(request.POST, instance=dokument)
|
||
if form.is_valid():
|
||
form.save()
|
||
messages.success(
|
||
request, f'Dokument "{dokument.titel}" wurde erfolgreich aktualisiert.'
|
||
)
|
||
return redirect("stiftung:dokument_detail", pk=dokument.pk)
|
||
else:
|
||
form = DokumentLinkForm(instance=dokument)
|
||
|
||
context = {
|
||
"form": form,
|
||
"dokument": dokument,
|
||
"title": f"Dokument bearbeiten: {dokument}",
|
||
}
|
||
return render(request, "stiftung/dokument_form.html", context)
|
||
|
||
|
||
@login_required
|
||
def dokument_delete(request, pk):
|
||
"""Delete a document link"""
|
||
dokument = get_object_or_404(DokumentLink, pk=pk)
|
||
|
||
if request.method == "POST":
|
||
dokument.delete()
|
||
messages.success(
|
||
request, f'Dokument "{dokument.titel}" wurde erfolgreich gelöscht.'
|
||
)
|
||
return redirect("stiftung:dokument_list")
|
||
|
||
context = {
|
||
"dokument": dokument,
|
||
"title": f"Dokument löschen: {dokument}",
|
||
}
|
||
return render(request, "stiftung/dokument_confirm_delete.html", context)
|
||
|
||
|
||
# Legacy document views removed - use dokument_management instead
|
||
|
||
|
||
# Jahresbericht Views
|
||
@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 land_stats_api(request):
|
||
"""API endpoint for land statistics"""
|
||
if request.method == "GET":
|
||
gemeinde = request.GET.get("gemeinde", "")
|
||
|
||
if gemeinde:
|
||
lands = Land.objects.filter(gemeinde=gemeinde)
|
||
else:
|
||
lands = Land.objects.all()
|
||
|
||
stats = {
|
||
"total_count": lands.count(),
|
||
"total_flaeche": float(
|
||
lands.aggregate(total=Sum("groesse_qm"))["total"] or 0
|
||
),
|
||
"total_verpachtet": float(
|
||
LandVerpachtung.objects.filter(
|
||
status="aktiv", land__in=lands
|
||
).aggregate(total=Sum("verpachtete_flaeche"))["total"]
|
||
or 0
|
||
),
|
||
"avg_verpachtungsgrad": 0,
|
||
}
|
||
|
||
if stats["total_flaeche"] > 0:
|
||
stats["avg_verpachtungsgrad"] = (
|
||
stats["total_verpachtet"] / stats["total_flaeche"]
|
||
) * 100
|
||
|
||
return JsonResponse(stats)
|
||
|
||
return JsonResponse({"error": "Invalid request method"}, status=400)
|
||
|
||
|
||
@api_view(["GET"])
|
||
def health(_request):
|
||
return Response({"status": "ok"})
|
||
|
||
|
||
@api_view(["GET"])
|
||
def paperless_ping(_request):
|
||
from stiftung.utils.config import get_paperless_config
|
||
|
||
config = get_paperless_config()
|
||
url = config["api_url"]
|
||
token = config["api_token"]
|
||
if not url or not token:
|
||
return Response(
|
||
{"ok": False, "reason": "Paperless API not configured"}, status=400
|
||
)
|
||
try:
|
||
# Entferne /api vom Ende der URL falls vorhanden
|
||
base_url = url[:-4] if url.endswith("/api") else url
|
||
r = requests.get(
|
||
f"{base_url}/api/tags/",
|
||
headers={"Authorization": f"Token {token}"},
|
||
timeout=5,
|
||
)
|
||
return Response({"ok": r.ok, "status_code": r.status_code})
|
||
except Exception as e:
|
||
return Response({"ok": False, "error": str(e)}, status=500)
|
||
|
||
|
||
@api_view(["GET"])
|
||
def paperless_documents(request):
|
||
"""Holt Dokumente aus Paperless mit den erforderlichen Tags.
|
||
Optionales Polling: ?poll=1 wartet kurz und versucht erneut, damit frisch ingestete
|
||
Dokumente in Paperless erscheinen, bevor die Liste zurückgegeben wird.
|
||
"""
|
||
from stiftung.utils.config import get_paperless_config
|
||
|
||
config = get_paperless_config()
|
||
url = config["api_url"]
|
||
token = config["api_token"]
|
||
required_tag = config["destinataere_tag"]
|
||
land_tag = config["land_tag"]
|
||
admin_tag = config["admin_tag"]
|
||
destinaere_tag_id = config["destinataere_tag_id"]
|
||
land_tag_id = config["land_tag_id"]
|
||
admin_tag_id = config["admin_tag_id"]
|
||
|
||
if not url or not token:
|
||
return Response(
|
||
{
|
||
"error": "Paperless API not configured",
|
||
"message": "Please set PAPERLESS_API_URL and PAPERLESS_API_TOKEN in your environment variables",
|
||
"documents": [],
|
||
"total_destinaere": 0,
|
||
"total_land": 0,
|
||
"total_admin": 0,
|
||
"total_all": 0,
|
||
},
|
||
status=400,
|
||
)
|
||
|
||
try:
|
||
# Entferne /api vom Ende der URL falls vorhanden
|
||
base_url = url[:-4] if url.endswith("/api") else url
|
||
headers = {"Authorization": f"Token {token}"}
|
||
|
||
def fetch_tagged():
|
||
# mit ordering=-created neueste zuerst
|
||
dest_resp = requests.get(
|
||
f"{base_url}/api/documents/?tags__id={destinaere_tag_id}&ordering=-created",
|
||
headers=headers,
|
||
timeout=10,
|
||
)
|
||
dest_resp.raise_for_status()
|
||
dest_docs = dest_resp.json()
|
||
|
||
land_resp = requests.get(
|
||
f"{base_url}/api/documents/?tags__id={land_tag_id}&ordering=-created",
|
||
headers=headers,
|
||
timeout=10,
|
||
)
|
||
land_resp.raise_for_status()
|
||
land_docs = land_resp.json()
|
||
|
||
admin_resp = requests.get(
|
||
f"{base_url}/api/documents/?tags__id={admin_tag_id}&ordering=-created",
|
||
headers=headers,
|
||
timeout=10,
|
||
)
|
||
admin_resp.raise_for_status()
|
||
admin_docs = admin_resp.json()
|
||
|
||
return dest_docs, land_docs, admin_docs
|
||
|
||
dest_docs, land_docs, admin_docs = fetch_tagged()
|
||
|
||
# Optionales kurzes Polling, wenn angefordert
|
||
if request.GET.get("poll") in ("1", "true", "yes"):
|
||
start_total = sum(
|
||
[
|
||
dest_docs.get("count", 0),
|
||
land_docs.get("count", 0),
|
||
admin_docs.get("count", 0),
|
||
]
|
||
)
|
||
deadline = time.time() + 6.0 # bis zu 6 Sekunden warten
|
||
while time.time() < deadline:
|
||
time.sleep(1.0)
|
||
d2, l2, a2 = fetch_tagged()
|
||
new_total = sum(
|
||
[d2.get("count", 0), l2.get("count", 0), a2.get("count", 0)]
|
||
)
|
||
if new_total > start_total:
|
||
dest_docs, land_docs, admin_docs = d2, l2, a2
|
||
break
|
||
|
||
# Alle Dokumente zusammenfassen
|
||
all_documents = []
|
||
for doc in dest_docs.get("results", []):
|
||
doc["tag_category"] = "destinaere"
|
||
all_documents.append(doc)
|
||
for doc in land_docs.get("results", []):
|
||
doc["tag_category"] = "land"
|
||
all_documents.append(doc)
|
||
for doc in admin_docs.get("results", []):
|
||
doc["tag_category"] = "admin"
|
||
all_documents.append(doc)
|
||
|
||
return Response(
|
||
{
|
||
"documents": all_documents,
|
||
"total_destinaere": dest_docs.get("count", 0),
|
||
"total_land": land_docs.get("count", 0),
|
||
"total_admin": admin_docs.get("count", 0),
|
||
"total_all": len(all_documents),
|
||
}
|
||
)
|
||
|
||
except requests.exceptions.RequestException as e:
|
||
import logging
|
||
logger = logging.getLogger(__name__)
|
||
logger.error(f"Paperless API request failed: {e}")
|
||
logger.error(f"Paperless API URL: {base_url}")
|
||
logger.error(f"Token configured: {'Yes' if token else 'No'}")
|
||
|
||
return Response(
|
||
{
|
||
"error": f"API-Fehler: {e}",
|
||
"message": f"Could not connect to Paperless API at {base_url}. Please check your configuration.",
|
||
"debug_info": {
|
||
"api_url": base_url,
|
||
"has_token": bool(token),
|
||
"error_type": type(e).__name__
|
||
},
|
||
"documents": [],
|
||
"total_destinaere": 0,
|
||
"total_land": 0,
|
||
"total_admin": 0,
|
||
"total_all": 0,
|
||
},
|
||
status=500,
|
||
)
|
||
except Exception as e:
|
||
return Response(
|
||
{
|
||
"error": f"Unerwarteter Fehler: {e}",
|
||
"message": "An unexpected error occurred while fetching documents.",
|
||
"documents": [],
|
||
"total_destinaere": 0,
|
||
"total_land": 0,
|
||
"total_admin": 0,
|
||
"total_all": 0,
|
||
},
|
||
status=500,
|
||
)
|
||
|
||
|
||
# Legacy dokument_integration view removed - use dokument_management instead
|
||
|
||
|
||
@api_view(["GET"])
|
||
def paperless_debug(request):
|
||
"""Debug-View für Paperless-Integration"""
|
||
from stiftung.utils.config import get_paperless_config
|
||
|
||
config = get_paperless_config()
|
||
url = config["api_url"]
|
||
token = config["api_token"]
|
||
required_tag = config["destinataere_tag"]
|
||
land_tag = config["land_tag"]
|
||
admin_tag = config["admin_tag"]
|
||
destinaere_tag_id = config["destinataere_tag_id"]
|
||
land_tag_id = config["land_tag_id"]
|
||
admin_tag_id = config["admin_tag_id"]
|
||
|
||
if not url or not token:
|
||
return Response({"error": "Paperless API not configured"}, status=400)
|
||
|
||
try:
|
||
# Entferne /api vom Ende der URL falls vorhanden
|
||
base_url = url[:-4] if url.endswith("/api") else url
|
||
|
||
headers = {"Authorization": f"Token {token}"}
|
||
|
||
# Alle Tags abrufen
|
||
tags_response = requests.get(
|
||
f"{base_url}/api/tags/?page_size=1000", headers=headers, timeout=10
|
||
)
|
||
tags_response.raise_for_status()
|
||
tags_data = tags_response.json()
|
||
|
||
# Alle Tags durchsuchen
|
||
all_tags = tags_data.get("results", [])
|
||
exact_match_destinaere = None
|
||
exact_match_land = None
|
||
exact_match_admin = None
|
||
similar_tags = []
|
||
|
||
# Nach den neuen Tag-Namen suchen (mit Unterstrichen)
|
||
for tag in all_tags:
|
||
tag_name = tag.get("name", "")
|
||
tag_id = tag.get("id")
|
||
|
||
# Suche nach den neuen Tag-Namen
|
||
if tag_name == "Stiftung_Destinatäre":
|
||
exact_match_destinaere = {"id": tag_id, "name": tag_name}
|
||
elif tag_name == "Stiftung_Land_und_Pächter":
|
||
exact_match_land = {"id": tag_id, "name": tag_name}
|
||
elif tag_name == "Stiftung_Administration":
|
||
exact_match_admin = {"id": tag_id, "name": tag_name}
|
||
|
||
# Ähnliche Tags finden
|
||
if (
|
||
"stiftung" in tag_name.lower()
|
||
or "destinat" in tag_name.lower()
|
||
or "land" in tag_name.lower()
|
||
or "admin" in tag_name.lower()
|
||
):
|
||
similar_tags.append({"id": tag_id, "name": tag_name})
|
||
|
||
# Alle Tag-Namen sammeln
|
||
all_tag_names = [tag.get("name", "") for tag in all_tags]
|
||
|
||
# Dokumente abrufen
|
||
documents_response = requests.get(
|
||
f"{base_url}/api/documents/?page_size=10", headers=headers, timeout=10
|
||
)
|
||
documents_response.raise_for_status()
|
||
documents_data = documents_response.json()
|
||
|
||
# Stiftung-Dokumente finden (mit Tag 21 "Stiftung")
|
||
stiftung_documents = []
|
||
for doc in documents_data.get("results", []):
|
||
doc_tags = doc.get("tags", [])
|
||
if 21 in doc_tags: # Tag 21 ist "Stiftung"
|
||
stiftung_documents.append(doc)
|
||
|
||
# Sample-Dokumente mit Tag-Namen anreichern
|
||
sample_documents = documents_data.get("results", [])[:5]
|
||
enriched_documents = []
|
||
|
||
for doc in sample_documents:
|
||
doc_copy = doc.copy()
|
||
tag_names = []
|
||
for tag_id in doc.get("tags", []):
|
||
# Tag-Namen aus der Tag-Liste finden
|
||
tag_name = next(
|
||
(
|
||
tag.get("name", f"Unknown({tag_id})")
|
||
for tag in all_tags
|
||
if tag.get("id") == tag_id
|
||
),
|
||
f"Unknown({tag_id})",
|
||
)
|
||
tag_names.append(tag_name)
|
||
doc_copy["tag_names"] = tag_names
|
||
enriched_documents.append(doc_copy)
|
||
|
||
return Response(
|
||
{
|
||
"paperless_url": url,
|
||
"base_url": base_url,
|
||
"required_tag": required_tag,
|
||
"land_tag": land_tag,
|
||
"admin_tag": admin_tag,
|
||
"destinaere_tag_id": destinaere_tag_id,
|
||
"land_tag_id": land_tag_id,
|
||
"admin_tag_id": admin_tag_id,
|
||
"exact_match_destinaere": exact_match_destinaere,
|
||
"exact_match_land": exact_match_land,
|
||
"exact_match_admin": exact_match_admin,
|
||
"similar_tags": similar_tags,
|
||
"all_tag_names": all_tag_names,
|
||
"total_tags": len(all_tags),
|
||
"total_documents": documents_data.get("count", 0),
|
||
"sample_documents": sample_documents,
|
||
"api_token_length": len(token) if token else 0,
|
||
"enriched_documents": enriched_documents,
|
||
"stiftung_documents": stiftung_documents,
|
||
}
|
||
)
|
||
|
||
except requests.exceptions.RequestException as e:
|
||
return Response({"error": f"API-Fehler: {e}"}, status=500)
|
||
except Exception as e:
|
||
return Response({"error": f"Unerwarteter Fehler: {e}"}, status=500)
|
||
|
||
|
||
@api_view(["GET"])
|
||
def paperless_tags_only(request):
|
||
"""Holt nur die Tag-Liste aus Paperless - ohne Dokumente"""
|
||
from stiftung.utils.config import get_paperless_config
|
||
|
||
config = get_paperless_config()
|
||
url = config["api_url"]
|
||
token = config["api_token"]
|
||
|
||
if not url or not token:
|
||
return Response({"error": "Paperless API not configured"}, status=400)
|
||
|
||
try:
|
||
# Entferne /api vom Ende der URL falls vorhanden
|
||
base_url = url[:-4] if url.endswith("/api") else url
|
||
|
||
# Alle Tags abrufen (mit großer page_size)
|
||
headers = {"Authorization": f"Token {token}"}
|
||
|
||
# Erste Anfrage mit großer page_size
|
||
tags_response = requests.get(
|
||
f"{base_url}/api/tags/?page_size=1000", headers=headers, timeout=10
|
||
)
|
||
tags_response.raise_for_status()
|
||
tags_data = tags_response.json()
|
||
|
||
all_tags = []
|
||
|
||
# Erste Seite verarbeiten
|
||
for tag in tags_data.get("results", []):
|
||
tag_detail = {
|
||
"id": tag.get("id"),
|
||
"name": tag.get("name", ""),
|
||
"slug": tag.get("slug", ""),
|
||
"color": tag.get("color", ""),
|
||
"text_color": tag.get("text_color", ""),
|
||
"match": tag.get("match", ""),
|
||
"matching_algorithm": tag.get("matching_algorithm"),
|
||
"is_inbox_tag": tag.get("is_inbox_tag"),
|
||
"document_count": tag.get("document_count", 0),
|
||
}
|
||
all_tags.append(tag_detail)
|
||
|
||
# Weitere Seiten abrufen falls vorhanden
|
||
next_url = tags_data.get("next")
|
||
while next_url:
|
||
next_response = requests.get(next_url, headers=headers, timeout=10)
|
||
next_response.raise_for_status()
|
||
next_data = next_response.json()
|
||
|
||
for tag in next_data.get("results", []):
|
||
tag_detail = {
|
||
"id": tag.get("id"),
|
||
"name": tag.get("name", ""),
|
||
"slug": tag.get("slug", ""),
|
||
"color": tag.get("color", ""),
|
||
"text_color": tag.get("text_color", ""),
|
||
"match": tag.get("match", ""),
|
||
"matching_algorithm": tag.get("matching_algorithm"),
|
||
"is_inbox_tag": tag.get("is_inbox_tag"),
|
||
"document_count": tag.get("document_count", 0),
|
||
}
|
||
all_tags.append(tag_detail)
|
||
|
||
next_url = next_data.get("next")
|
||
|
||
# Nach ID sortieren
|
||
all_tags.sort(key=lambda x: x["id"])
|
||
|
||
return Response(
|
||
{
|
||
"total_tags": len(all_tags),
|
||
"tags": all_tags,
|
||
"tag_ids": [tag["id"] for tag in all_tags],
|
||
"tag_names": [tag["name"] for tag in all_tags],
|
||
"api_info": {
|
||
"page_size_used": 1000,
|
||
"total_count_from_api": tags_data.get("count", 0),
|
||
},
|
||
}
|
||
)
|
||
|
||
except requests.exceptions.RequestException as e:
|
||
return Response({"error": f"API-Fehler: {e}"}, status=500)
|
||
except Exception as e:
|
||
return Response({"error": f"Unerwarteter Fehler: {e}"}, status=500)
|
||
|
||
|
||
@api_view(["GET"])
|
||
def link_document_search(request):
|
||
"""Sucht nach Datensätzen für die Dokument-Verknüpfung"""
|
||
from django.db.models import Q
|
||
|
||
query = request.GET.get("q", "")
|
||
category = request.GET.get("category", "all")
|
||
|
||
results = {}
|
||
|
||
if category in ["all", "destinataer"]:
|
||
# Suche nach Destinatären
|
||
destinataer_query = Q()
|
||
if query and query != "all":
|
||
destinataer_query = (
|
||
Q(nachname__icontains=query)
|
||
| Q(vorname__icontains=query)
|
||
| Q(email__icontains=query)
|
||
| Q(telefon__icontains=query)
|
||
| Q(strasse__icontains=query)
|
||
| Q(ort__icontains=query)
|
||
| Q(plz__icontains=query)
|
||
| Q(institution__icontains=query)
|
||
| Q(familienzweig__icontains=query)
|
||
| Q(notizen__icontains=query)
|
||
)
|
||
|
||
destinataer_results = Destinataer.objects.filter(destinataer_query)[:25]
|
||
results["destinataer"] = [
|
||
{
|
||
"id": d.id,
|
||
"name": (
|
||
f"{d.vorname} {d.nachname}".strip()
|
||
if d.vorname
|
||
else (d.institution or d.nachname)
|
||
),
|
||
"type": "Destinatär",
|
||
"details": f"{d.strasse or ''}{(', ' + d.plz + ' ' + d.ort) if d.plz and d.ort else (' ' + d.ort) if d.ort else ''} {('• ' + d.email) if d.email else ''} {('• ' + d.institution) if d.institution else ''} {('• ' + d.familienzweig) if d.familienzweig else ''}".strip(),
|
||
}
|
||
for d in destinataer_results
|
||
]
|
||
|
||
if category in ["all", "land"]:
|
||
# Suche nach Ländereien
|
||
land_query = Q()
|
||
if query and query != "all":
|
||
# Extract numbers from search terms like "Flur 9" or "Flurstück 11"
|
||
import re
|
||
|
||
flur_match = re.search(r"flur\s*(\d+)", query, re.IGNORECASE)
|
||
flurstuck_match = re.search(r"flurstück\s*(\d+)", query, re.IGNORECASE)
|
||
|
||
land_query = (
|
||
Q(gemarkung__icontains=query)
|
||
| Q(gemeinde__icontains=query)
|
||
| Q(flur__icontains=query)
|
||
| Q(flurstueck__icontains=query)
|
||
| Q(lfd_nr__icontains=query)
|
||
| Q(ew_nummer__icontains=query)
|
||
| Q(notizen__icontains=query)
|
||
)
|
||
|
||
# Add specific searches for extracted numbers
|
||
if flur_match:
|
||
land_query |= Q(flur__exact=flur_match.group(1))
|
||
if flurstuck_match:
|
||
land_query |= Q(flurstueck__exact=flurstuck_match.group(1))
|
||
|
||
land_results = Land.objects.filter(land_query)[:25]
|
||
results["land"] = [
|
||
{
|
||
"id": l.id,
|
||
"name": f"{l.gemarkung} - {l.gemeinde}, Flur {l.flur}, Flurstück {l.flurstueck or 'N/A'}",
|
||
"type": "Land",
|
||
"details": f"Lfd.Nr: {l.lfd_nr or 'N/A'} • EW-Nr: {l.ew_nummer or 'N/A'} • Größe: {l.groesse_qm or 0} m²",
|
||
}
|
||
for l in land_results
|
||
]
|
||
|
||
if category in ["all", "verpachtung"]:
|
||
# Suche nach Verpachtungen (using new LandVerpachtung model)
|
||
verpachtung_query = Q()
|
||
if query and query != "all":
|
||
verpachtung_query = (
|
||
Q(paechter__nachname__icontains=query)
|
||
| Q(paechter__vorname__icontains=query)
|
||
| Q(paechter__ort__icontains=query)
|
||
| Q(paechter__email__icontains=query)
|
||
| Q(paechter__pachtnummer__icontains=query)
|
||
| Q(land__gemarkung__icontains=query)
|
||
| Q(land__gemeinde__icontains=query)
|
||
| Q(land__flur__icontains=query)
|
||
| Q(land__flurstueck__icontains=query)
|
||
| Q(land__lfd_nr__icontains=query)
|
||
| Q(vertragsnummer__icontains=query)
|
||
| Q(pachtzins_pauschal__icontains=query)
|
||
| Q(bemerkungen__icontains=query)
|
||
)
|
||
|
||
verpachtung_results = LandVerpachtung.objects.filter(
|
||
verpachtung_query
|
||
).select_related("paechter", "land")[:25]
|
||
results["verpachtung"] = [
|
||
{
|
||
"id": v.id,
|
||
"name": f"{(v.paechter.vorname + ' ') if v.paechter and v.paechter.vorname else ''}{v.paechter.nachname if v.paechter else 'Unbekannt'} → {v.land.gemarkung}, Flur {v.land.flur}",
|
||
"type": "Verpachtung",
|
||
"details": f"Vertrag: {v.vertragsnummer} • Flurstück: {v.land.flurstueck or 'N/A'} • Pachtzins: {v.pachtzins_pauschal or 'N/A'} €/Jahr • Zeitraum: {v.pachtbeginn.strftime('%Y') if v.pachtbeginn else '?'}-{v.pachtende.strftime('%Y') if v.pachtende else 'laufend'}",
|
||
}
|
||
for v in verpachtung_results
|
||
]
|
||
|
||
if category in ["all", "paechter"]:
|
||
# Suche nach Pächtern
|
||
paechter_query = Q()
|
||
if query and query != "all":
|
||
paechter_query = (
|
||
Q(nachname__icontains=query)
|
||
| Q(vorname__icontains=query)
|
||
| Q(ort__icontains=query)
|
||
| Q(email__icontains=query)
|
||
| Q(telefon__icontains=query)
|
||
| Q(strasse__icontains=query)
|
||
| Q(pachtnummer__icontains=query)
|
||
| Q(plz__icontains=query)
|
||
| Q(notizen__icontains=query)
|
||
)
|
||
paechter_results = Paechter.objects.filter(paechter_query)[:25]
|
||
results["paechter"] = [
|
||
{
|
||
"id": p.id,
|
||
"name": f"{(p.vorname + ' ') if p.vorname else ''}{p.nachname}"
|
||
+ (f" (#{p.pachtnummer})" if p.pachtnummer else ""),
|
||
"type": "Pächter",
|
||
"details": f"{p.strasse or ''}{(', ' + p.plz + ' ' + p.ort) if p.plz and p.ort else (' ' + p.ort) if p.ort else ''} {('• ' + p.email) if p.email else ''} {('• Tel: ' + p.telefon) if p.telefon else ''}".strip(),
|
||
}
|
||
for p in paechter_results
|
||
]
|
||
|
||
if category in ["all", "rentmeister"]:
|
||
# Suche nach Rentmeistern
|
||
from stiftung.models import Rentmeister
|
||
|
||
rentmeister_query = Q()
|
||
if query and query != "all":
|
||
rentmeister_query = (
|
||
Q(nachname__icontains=query)
|
||
| Q(vorname__icontains=query)
|
||
| Q(ort__icontains=query)
|
||
| Q(email__icontains=query)
|
||
| Q(telefon__icontains=query)
|
||
| Q(strasse__icontains=query)
|
||
| Q(plz__icontains=query)
|
||
| Q(notizen__icontains=query)
|
||
| Q(titel__icontains=query)
|
||
| Q(mobil__icontains=query)
|
||
)
|
||
rentmeister_results = Rentmeister.objects.filter(rentmeister_query)[:25]
|
||
results["rentmeister"] = [
|
||
{
|
||
"id": r.id,
|
||
"name": f"{(r.anrede + ' ') if r.anrede else ''}{(r.vorname + ' ') if r.vorname else ''}{r.nachname}"
|
||
+ (f" ({r.titel})" if r.titel else ""),
|
||
"type": "Rentmeister",
|
||
"details": f"{r.strasse or ''}{(', ' + r.plz + ' ' + r.ort) if r.plz and r.ort else (' ' + r.ort) if r.ort else ''} {('• ' + r.email) if r.email else ''} {('• Tel: ' + r.telefon) if r.telefon else ''} {('• Mobil: ' + r.mobil) if r.mobil else ''}".strip(),
|
||
}
|
||
for r in rentmeister_results
|
||
]
|
||
|
||
if category in ["all", "abrechnung"]:
|
||
# Suche nach Abrechnungen
|
||
abrechnung_query = Q()
|
||
if query and query != "all":
|
||
abrechnung_query = (
|
||
Q(land__gemarkung__icontains=query)
|
||
| Q(land__gemeinde__icontains=query)
|
||
| Q(land__flur__icontains=query)
|
||
| Q(land__flurstueck__icontains=query)
|
||
| Q(land__lfd_nr__icontains=query)
|
||
| Q(abrechnungsjahr__icontains=query)
|
||
| Q(bemerkungen__icontains=query)
|
||
)
|
||
|
||
abrechnung_results = LandAbrechnung.objects.filter(
|
||
abrechnung_query
|
||
).select_related("land")[:25]
|
||
results["abrechnung"] = [
|
||
{
|
||
"id": a.id,
|
||
"name": f"Abrechnung {a.abrechnungsjahr} - {a.land.gemarkung}, Flur {a.land.flur}",
|
||
"type": "Abrechnung",
|
||
"details": f"Flurstück: {a.land.flurstueck or 'N/A'} • Jahr: {a.abrechnungsjahr} • Grundsteuer: {a.grundsteuer_betrag or 0} € • Versicherung: {a.versicherungen_betrag or 0} €",
|
||
}
|
||
for a in abrechnung_results
|
||
]
|
||
|
||
if category in ["all", "foerderung"]:
|
||
# Suche nach Förderungen
|
||
foerderung_query = Q()
|
||
if query and query != "all":
|
||
foerderung_query = (
|
||
Q(destinataer__nachname__icontains=query)
|
||
| Q(destinataer__vorname__icontains=query)
|
||
| Q(destinataer__institution__icontains=query)
|
||
| Q(destinataer__email__icontains=query)
|
||
| Q(jahr__icontains=query)
|
||
| Q(betrag__icontains=query)
|
||
| Q(kategorie__icontains=query)
|
||
| Q(status__icontains=query)
|
||
| Q(bemerkungen__icontains=query)
|
||
)
|
||
|
||
foerderung_results = Foerderung.objects.filter(foerderung_query).select_related(
|
||
"destinataer"
|
||
)[:25]
|
||
results["foerderung"] = [
|
||
{
|
||
"id": str(f.id), # Convert UUID to string for JSON serialization
|
||
"name": f"{f.destinataer.get_full_name() if f.destinataer else 'Unbekannt'} - {f.jahr}",
|
||
"type": "Förderung",
|
||
"details": f"Betrag: {f.betrag} € • Kategorie: {f.get_kategorie_display()} • Status: {f.get_status_display()} • Jahr: {f.jahr}",
|
||
}
|
||
for f in foerderung_results
|
||
]
|
||
|
||
return Response(results)
|
||
|
||
|
||
def create_paechter_link_for_verpachtung(paperless_id, paperless_title, verpachtung_id):
|
||
"""Hilfsfunktion: Erstellt automatisch eine Pächter-Verknüpfung für eine Verpachtung"""
|
||
try:
|
||
# Hole die LandVerpachtung und den zugehörigen Pächter
|
||
verpachtung = LandVerpachtung.objects.select_related("paechter").get(
|
||
id=verpachtung_id
|
||
)
|
||
if verpachtung.paechter:
|
||
# Prüfe, ob bereits eine Verknüpfung für dieses Dokument und diesen Pächter existiert
|
||
existing_link = DokumentLink.objects.filter(
|
||
paperless_document_id=paperless_id, paechter_id=verpachtung.paechter.id
|
||
).first()
|
||
|
||
if not existing_link:
|
||
# Erstelle automatische Pächter-Verknüpfung
|
||
DokumentLink.objects.create(
|
||
paperless_document_id=paperless_id,
|
||
titel=paperless_title,
|
||
kontext="paechter",
|
||
paechter_id=verpachtung.paechter.id,
|
||
)
|
||
return True
|
||
except (LandVerpachtung.DoesNotExist, Exception):
|
||
pass
|
||
return False
|
||
|
||
|
||
@csrf_exempt
|
||
@api_view(["POST"])
|
||
def link_document_create(request):
|
||
"""Erstellt eine Verknüpfung zwischen einem Paperless-Dokument und einem Datensatz"""
|
||
from django.db import transaction
|
||
|
||
try:
|
||
# Robust JSON parsing to tolerate Latin-1 encoded payloads (e.g., umlauts)
|
||
try:
|
||
payload = request.data
|
||
except Exception:
|
||
raw = request.body
|
||
try:
|
||
payload = json.loads(raw.decode("utf-8"))
|
||
except UnicodeDecodeError:
|
||
payload = json.loads(raw.decode("latin-1"))
|
||
|
||
paperless_id = payload.get("paperless_id")
|
||
paperless_title = payload.get("paperless_title")
|
||
paperless_url = payload.get("paperless_url")
|
||
link_type = payload.get(
|
||
"link_type"
|
||
) # 'destinataer', 'land', 'verpachtung', 'paechter', 'abrechnung'
|
||
link_id = payload.get("link_id")
|
||
|
||
if not all([paperless_id, paperless_title, paperless_url, link_type, link_id]):
|
||
return Response({"error": "Alle Felder sind erforderlich"}, status=400)
|
||
|
||
with transaction.atomic():
|
||
# Erstelle den DokumentLink
|
||
dokument_link = DokumentLink.objects.create(
|
||
paperless_document_id=paperless_id, # Korrigiert: 'paperless_document_id' statt 'paperless_id'
|
||
titel=paperless_title, # Korrigiert: 'titel' statt 'title'
|
||
kontext="anderes",
|
||
)
|
||
|
||
# Setze die entsprechenden UUID-Felder basierend auf dem Link-Typ
|
||
if link_type == "destinataer":
|
||
dokument_link.destinataer_id = link_id
|
||
elif link_type == "land":
|
||
dokument_link.land_id = link_id
|
||
elif link_type == "verpachtung":
|
||
# Use new LandVerpachtung field instead of legacy
|
||
dokument_link.land_verpachtung_id = link_id
|
||
elif link_type == "paechter":
|
||
dokument_link.paechter_id = link_id
|
||
elif link_type == "foerderung":
|
||
dokument_link.foerderung_id = link_id
|
||
elif link_type == "rentmeister":
|
||
dokument_link.rentmeister_id = link_id
|
||
elif link_type == "abrechnung":
|
||
dokument_link.abrechnung_id = link_id
|
||
|
||
dokument_link.save()
|
||
|
||
# Log the document linking action
|
||
from stiftung.audit import log_link
|
||
|
||
try:
|
||
# Get the linked entity name for logging
|
||
entity_name = paperless_title
|
||
if link_type == "destinataer":
|
||
from stiftung.models import Destinataer
|
||
|
||
entity = Destinataer.objects.get(id=link_id)
|
||
target_name = entity.get_full_name()
|
||
elif link_type == "land":
|
||
from stiftung.models import Land
|
||
|
||
entity = Land.objects.get(id=link_id)
|
||
target_name = str(entity)
|
||
elif link_type == "paechter":
|
||
from stiftung.models import Paechter
|
||
|
||
entity = Paechter.objects.get(id=link_id)
|
||
target_name = f"{entity.vorname} {entity.nachname}".strip()
|
||
elif link_type == "foerderung":
|
||
from stiftung.models import Foerderung
|
||
|
||
entity = Foerderung.objects.get(id=link_id)
|
||
target_name = f"{entity.destinataer.get_full_name() if entity.destinataer else 'Unbekannt'} - {entity.jahr}"
|
||
elif link_type == "verpachtung":
|
||
entity = LandVerpachtung.objects.get(id=link_id)
|
||
target_name = str(entity)
|
||
elif link_type == "rentmeister":
|
||
from stiftung.models import Rentmeister
|
||
|
||
entity = Rentmeister.objects.get(id=link_id)
|
||
target_name = entity.get_full_name()
|
||
else:
|
||
target_name = f"ID {link_id}"
|
||
|
||
log_link(
|
||
request=request,
|
||
entity_type="dokumentlink",
|
||
entity_id=str(dokument_link.id),
|
||
entity_name=entity_name,
|
||
target_type=link_type,
|
||
target_name=target_name,
|
||
)
|
||
except Exception as e:
|
||
# Don't fail the main operation if logging fails
|
||
print(f"Audit logging failed: {e}")
|
||
|
||
# Automatische Pächter-Verknüpfung NACH der Haupttransaktion
|
||
paechter_linked = False
|
||
if link_type == "verpachtung":
|
||
paechter_linked = create_paechter_link_for_verpachtung(
|
||
paperless_id, paperless_title, link_id
|
||
)
|
||
|
||
message = f"Dokument erfolgreich mit {link_type} verknüpft"
|
||
if paechter_linked:
|
||
message += " (automatisch auch mit Pächter verknüpft)"
|
||
|
||
return Response(
|
||
{"success": True, "message": message, "dokument_id": dokument_link.id}
|
||
)
|
||
|
||
except Exception as e:
|
||
return Response(
|
||
{"error": f"Fehler beim Erstellen der Verknüpfung: {str(e)}"}, status=500
|
||
)
|
||
|
||
|
||
# Legacy dokument_verknuepfung view removed - use dokument_management instead
|
||
|
||
|
||
@api_view(["GET"])
|
||
def link_document_list(request):
|
||
"""Listet alle bestehenden Dokument-Verknüpfungen auf, gruppiert nach Paperless-Document-ID"""
|
||
try:
|
||
dokument_links = DokumentLink.objects.all().order_by("-id")
|
||
|
||
# Group links by paperless_document_id to show multiple links per document
|
||
links_by_document = {}
|
||
|
||
for link in dokument_links:
|
||
paperless_id = link.paperless_document_id
|
||
|
||
if paperless_id not in links_by_document:
|
||
links_by_document[paperless_id] = {
|
||
"paperless_id": paperless_id,
|
||
"title": link.titel,
|
||
"paperless_url": f"/api/paperless/documents/{paperless_id}/",
|
||
"links": [],
|
||
}
|
||
|
||
# Create link info
|
||
link_info = {
|
||
"id": str(link.id), # Ensure UUID is stringified
|
||
"kontext": link.kontext,
|
||
"link_type": None,
|
||
"linked_object": None,
|
||
}
|
||
|
||
# Determine link type and get linked object details
|
||
if link.destinataer_id:
|
||
link_info["link_type"] = "destinataer"
|
||
try:
|
||
dest = Destinataer.objects.get(id=link.destinataer_id)
|
||
link_info["linked_object"] = {
|
||
"id": str(dest.id),
|
||
"type": "Destinatär",
|
||
"name": (
|
||
f"{dest.vorname} {dest.nachname}".strip()
|
||
if dest.vorname
|
||
else dest.institution
|
||
),
|
||
"details": (
|
||
f"Institution: {dest.institution}"
|
||
if dest.institution
|
||
else f"Person: {dest.vorname} {dest.nachname}".strip()
|
||
),
|
||
}
|
||
except Destinataer.DoesNotExist:
|
||
link_info["linked_object"] = {
|
||
"type": "Destinatär",
|
||
"name": "Gelöscht",
|
||
"details": "Datensatz nicht mehr verfügbar",
|
||
}
|
||
|
||
elif link.land_id:
|
||
link_info["link_type"] = "land"
|
||
try:
|
||
land = Land.objects.get(id=link.land_id)
|
||
link_info["linked_object"] = {
|
||
"id": str(land.id),
|
||
"type": "Land",
|
||
"name": f"{land.gemarkung} - {land.gemeinde}",
|
||
"details": f"Flur: {land.flur}, Größe: {land.groesse_qm or 0} m²",
|
||
}
|
||
except Land.DoesNotExist:
|
||
link_info["linked_object"] = {
|
||
"type": "Land",
|
||
"name": "Gelöscht",
|
||
"details": "Datensatz nicht mehr verfügbar",
|
||
}
|
||
|
||
elif link.paechter_id:
|
||
link_info["link_type"] = "paechter"
|
||
try:
|
||
p = Paechter.objects.get(id=link.paechter_id)
|
||
link_info["linked_object"] = {
|
||
"id": str(p.id),
|
||
"type": "Pächter",
|
||
"name": f"{(p.vorname + ' ') if p.vorname else ''}{p.nachname}",
|
||
"details": f"{p.ort or ''}",
|
||
}
|
||
except Paechter.DoesNotExist:
|
||
link_info["linked_object"] = {
|
||
"type": "Pächter",
|
||
"name": "Gelöscht",
|
||
"details": "Datensatz nicht mehr verfügbar",
|
||
}
|
||
|
||
elif link.land_verpachtung_id:
|
||
link_info["link_type"] = "verpachtung"
|
||
try:
|
||
from stiftung.models import LandVerpachtung
|
||
|
||
verp = LandVerpachtung.objects.select_related(
|
||
"paechter", "land"
|
||
).get(id=link.land_verpachtung_id)
|
||
link_info["linked_object"] = {
|
||
"id": str(verp.id),
|
||
"type": "Verpachtung",
|
||
"name": f"Verpachtung {(verp.paechter.vorname + ' ') if verp.paechter and verp.paechter.vorname else ''}{verp.paechter.nachname if verp.paechter else 'Unbekannt'}",
|
||
"details": f"Land: {verp.land.gemarkung} - {verp.land.gemeinde}, Pächter: {(verp.paechter.vorname + ' ') if verp.paechter and verp.paechter.vorname else ''}{verp.paechter.nachname if verp.paechter else 'Unbekannt'}, Vertrag: {verp.vertragsnummer}",
|
||
}
|
||
except LandVerpachtung.DoesNotExist:
|
||
link_info["linked_object"] = {
|
||
"type": "Verpachtung",
|
||
"name": "Gelöscht",
|
||
"details": "Datensatz nicht mehr verfügbar",
|
||
}
|
||
|
||
elif link.rentmeister_id:
|
||
link_info["link_type"] = "rentmeister"
|
||
try:
|
||
from stiftung.models import Rentmeister
|
||
|
||
rentmeister = Rentmeister.objects.get(id=link.rentmeister_id)
|
||
link_info["linked_object"] = {
|
||
"id": str(rentmeister.id),
|
||
"type": "Rentmeister",
|
||
"name": f"{(rentmeister.anrede + ' ') if rentmeister.anrede else ''}{(rentmeister.vorname + ' ') if rentmeister.vorname else ''}{rentmeister.nachname}"
|
||
+ (f" ({rentmeister.titel})" if rentmeister.titel else ""),
|
||
"details": f"Seit: {rentmeister.seit_datum.strftime('%d.%m.%Y') if rentmeister.seit_datum else 'Unbekannt'}"
|
||
+ (
|
||
f", Tel: {rentmeister.telefon}"
|
||
if rentmeister.telefon
|
||
else ""
|
||
)
|
||
+ (f", {rentmeister.email}" if rentmeister.email else ""),
|
||
"url": f"/geschaeftsfuehrung/rentmeister/{rentmeister.id}/",
|
||
}
|
||
except Rentmeister.DoesNotExist:
|
||
link_info["linked_object"] = {
|
||
"type": "Rentmeister",
|
||
"name": "Gelöscht",
|
||
"details": "Datensatz nicht mehr verfügbar",
|
||
}
|
||
|
||
elif link.abrechnung_id:
|
||
link_info["link_type"] = "abrechnung"
|
||
try:
|
||
abrechnung = LandAbrechnung.objects.select_related("land").get(
|
||
id=link.abrechnung_id
|
||
)
|
||
link_info["linked_object"] = {
|
||
"id": str(abrechnung.id),
|
||
"type": "Abrechnung",
|
||
"name": f"Abrechnung {abrechnung.abrechnungsjahr} - {abrechnung.land.gemarkung}",
|
||
"details": f"Land: {abrechnung.land.gemarkung} - {abrechnung.land.gemeinde}, Flur {abrechnung.land.flur}, Jahr {abrechnung.abrechnungsjahr}",
|
||
"url": f"/laendereien/abrechnungen/{abrechnung.id}/",
|
||
}
|
||
except LandAbrechnung.DoesNotExist:
|
||
link_info["linked_object"] = {
|
||
"type": "Abrechnung",
|
||
"name": "Gelöscht",
|
||
"details": "Datensatz nicht mehr verfügbar",
|
||
}
|
||
|
||
links_by_document[paperless_id]["links"].append(link_info)
|
||
|
||
# Convert to list format for frontend
|
||
results = list(links_by_document.values())
|
||
|
||
return Response(
|
||
{
|
||
"total_documents": len(results),
|
||
"total_links": sum(len(doc["links"]) for doc in results),
|
||
"links": results,
|
||
}
|
||
)
|
||
|
||
except Exception as e:
|
||
return Response(
|
||
{"error": f"Fehler beim Abrufen der Verknüpfungen: {str(e)}"}, status=500
|
||
)
|
||
|
||
|
||
@csrf_exempt
|
||
@api_view(["POST"])
|
||
def link_document_update(request):
|
||
"""Aktualisiert die Verknüpfung eines Dokuments (Re-Linking auf anderen Datensatz/Kontext)."""
|
||
from django.db import transaction
|
||
|
||
try:
|
||
# Robust JSON parsing to tolerate Latin-1 encoded payloads (e.g., umlauts)
|
||
try:
|
||
payload = request.data
|
||
except Exception:
|
||
raw = request.body
|
||
try:
|
||
payload = json.loads(raw.decode("utf-8"))
|
||
except UnicodeDecodeError:
|
||
payload = json.loads(raw.decode("latin-1"))
|
||
|
||
link_id = payload.get("link_id")
|
||
link_type = payload.get(
|
||
"link_type"
|
||
) # 'destinataer' | 'land' | 'verpachtung' | 'paechter' | 'rentmeister'
|
||
link_target_id = payload.get("link_id_target")
|
||
if not all([link_id, link_type, link_target_id]):
|
||
return Response(
|
||
{"error": "link_id, link_type und link_id_target sind erforderlich"},
|
||
status=400,
|
||
)
|
||
|
||
with transaction.atomic():
|
||
link = DokumentLink.objects.get(id=link_id)
|
||
old_verpachtung_id = (
|
||
link.verpachtung_id
|
||
) # Merke alte Verpachtung für Cleanup
|
||
paperless_id_for_cleanup = link.paperless_document_id
|
||
titel_for_new_link = link.titel
|
||
|
||
# Reset all associations first
|
||
link.destinataer_id = None
|
||
link.land_id = None
|
||
link.verpachtung_id = None
|
||
link.paechter_id = None
|
||
link.foerderung_id = None
|
||
link.rentmeister_id = None
|
||
link.kontext = link_type
|
||
|
||
if link_type == "destinataer":
|
||
link.destinataer_id = link_target_id
|
||
elif link_type == "land":
|
||
link.land_id = link_target_id
|
||
elif link_type == "verpachtung":
|
||
link.verpachtung_id = link_target_id
|
||
elif link_type == "paechter":
|
||
link.paechter_id = link_target_id
|
||
elif link_type == "foerderung":
|
||
link.foerderung_id = link_target_id
|
||
elif link_type == "rentmeister":
|
||
link.rentmeister_id = link_target_id
|
||
else:
|
||
return Response({"error": "Ungültiger link_type"}, status=400)
|
||
|
||
link.save()
|
||
|
||
# Automatische Pächter-Verknüpfung und Cleanup NACH der Haupttransaktion
|
||
paechter_linked = False
|
||
if link_type == "verpachtung":
|
||
paechter_linked = create_paechter_link_for_verpachtung(
|
||
paperless_id_for_cleanup, titel_for_new_link, link_target_id
|
||
)
|
||
|
||
# Cleanup: Entferne verwaiste Pächter-Verknüpfungen wenn von Verpachtung weg geändert
|
||
if old_verpachtung_id and link_type != "verpachtung":
|
||
try:
|
||
old_verpachtung = LandVerpachtung.objects.select_related(
|
||
"paechter"
|
||
).get(id=old_verpachtung_id)
|
||
if old_verpachtung.paechter:
|
||
# Prüfe ob noch andere Verpachtungs-Links für diesen Pächter existieren
|
||
other_verpachtung_links = DokumentLink.objects.filter(
|
||
paperless_document_id=paperless_id_for_cleanup,
|
||
verpachtung__paechter_id=old_verpachtung.paechter.id,
|
||
).exists()
|
||
|
||
if not other_verpachtung_links:
|
||
# Entferne automatisch erstellte Pächter-Verknüpfung
|
||
DokumentLink.objects.filter(
|
||
paperless_document_id=paperless_id_for_cleanup,
|
||
paechter_id=old_verpachtung.paechter.id,
|
||
kontext="paechter",
|
||
).delete()
|
||
except (LandVerpachtung.DoesNotExist, Exception):
|
||
pass
|
||
|
||
message = "Verknüpfung aktualisiert"
|
||
if paechter_linked:
|
||
message += " (automatisch auch mit Pächter verknüpft)"
|
||
|
||
return Response({"success": True, "message": message})
|
||
except DokumentLink.DoesNotExist:
|
||
return Response({"error": "Verknüpfung nicht gefunden"}, status=404)
|
||
except Exception as e:
|
||
return Response({"error": f"Unerwarteter Fehler: {e}"}, status=500)
|
||
|
||
|
||
@csrf_exempt
|
||
@api_view(["DELETE"])
|
||
def link_document_delete(request, link_id):
|
||
"""Löscht eine bestehende Verknüpfung."""
|
||
from django.db import transaction
|
||
|
||
try:
|
||
with transaction.atomic():
|
||
link = DokumentLink.objects.get(id=link_id)
|
||
verpachtung_id_for_cleanup = link.verpachtung_id
|
||
paperless_id_for_cleanup = link.paperless_document_id
|
||
|
||
# Log the unlinking action before deletion
|
||
from stiftung.audit import log_unlink
|
||
|
||
try:
|
||
# Determine what entity this was linked to
|
||
target_type = "unknown"
|
||
target_name = "Unknown"
|
||
|
||
if link.destinataer_id:
|
||
target_type = "destinataer"
|
||
try:
|
||
entity = Destinataer.objects.get(id=link.destinataer_id)
|
||
target_name = entity.get_full_name()
|
||
except Destinataer.DoesNotExist:
|
||
target_name = f"Destinatär ID {link.destinataer_id}"
|
||
elif link.land_id:
|
||
target_type = "land"
|
||
try:
|
||
entity = Land.objects.get(id=link.land_id)
|
||
target_name = str(entity)
|
||
except Land.DoesNotExist:
|
||
target_name = f"Land ID {link.land_id}"
|
||
elif link.paechter_id:
|
||
target_type = "paechter"
|
||
try:
|
||
entity = Paechter.objects.get(id=link.paechter_id)
|
||
target_name = f"{entity.vorname} {entity.nachname}".strip()
|
||
except Paechter.DoesNotExist:
|
||
target_name = f"Pächter ID {link.paechter_id}"
|
||
elif link.verpachtung_id:
|
||
target_type = "verpachtung"
|
||
try:
|
||
entity = LandVerpachtung.objects.get(id=link.verpachtung_id)
|
||
target_name = str(entity)
|
||
except LandVerpachtung.DoesNotExist:
|
||
target_name = f"Verpachtung ID {link.verpachtung_id}"
|
||
elif link.rentmeister_id:
|
||
target_type = "rentmeister"
|
||
try:
|
||
from stiftung.models import Rentmeister
|
||
|
||
entity = Rentmeister.objects.get(id=link.rentmeister_id)
|
||
target_name = entity.get_full_name()
|
||
except Rentmeister.DoesNotExist:
|
||
target_name = f"Rentmeister ID {link.rentmeister_id}"
|
||
|
||
log_unlink(
|
||
request=request,
|
||
entity_type="dokumentlink",
|
||
entity_id=str(link.id),
|
||
entity_name=link.titel,
|
||
target_type=target_type,
|
||
target_name=target_name,
|
||
)
|
||
except Exception as e:
|
||
# Don't fail the main operation if logging fails
|
||
print(f"Audit logging failed: {e}")
|
||
|
||
link.delete()
|
||
|
||
# Cleanup NACH der Haupttransaktion: Wenn eine Verpachtungs-Verknüpfung gelöscht wurde, prüfe Pächter-Links
|
||
if verpachtung_id_for_cleanup:
|
||
try:
|
||
verpachtung = LandVerpachtung.objects.select_related("paechter").get(
|
||
id=verpachtung_id_for_cleanup
|
||
)
|
||
if verpachtung.paechter:
|
||
# Prüfe ob noch andere Verpachtungs-Links für diesen Pächter existieren
|
||
other_verpachtung_links = DokumentLink.objects.filter(
|
||
paperless_document_id=paperless_id_for_cleanup,
|
||
verpachtung__paechter_id=verpachtung.paechter.id,
|
||
).exists()
|
||
|
||
if not other_verpachtung_links:
|
||
# Entferne automatisch erstellte Pächter-Verknüpfung
|
||
DokumentLink.objects.filter(
|
||
paperless_document_id=paperless_id_for_cleanup,
|
||
paechter_id=verpachtung.paechter.id,
|
||
kontext="paechter",
|
||
).delete()
|
||
except (LandVerpachtung.DoesNotExist, Exception):
|
||
pass
|
||
|
||
return Response({"success": True})
|
||
except DokumentLink.DoesNotExist:
|
||
return Response({"error": "Verknüpfung nicht gefunden"}, status=404)
|
||
except Exception as e:
|
||
return Response({"error": f"Unerwarteter Fehler: {e}"}, status=500)
|
||
|
||
|
||
@api_view(["GET"])
|
||
def gramps_search_api(request):
|
||
"""Probe-Endpoint: Suche Personen in Gramps Web nach q (Nachname, Vorname)."""
|
||
q = request.GET.get("q", "")
|
||
if not q:
|
||
return Response({"error": "Parameter q erforderlich"}, status=400)
|
||
client = get_gramps_client()
|
||
result = client.search_people(q)
|
||
return Response(result)
|
||
|
||
|
||
# Geschäftsführung Views
|
||
@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
|
||
# =============================================================================
|
||
|
||
|
||
@login_required
|
||
def administration(request):
|
||
"""Administration Dashboard"""
|
||
from datetime import datetime, timedelta
|
||
|
||
from django.db.models import Count
|
||
|
||
from stiftung.models import AuditLog, BackupJob
|
||
|
||
# Recent audit activity
|
||
recent_audit = AuditLog.objects.all()[:10]
|
||
|
||
# Audit statistics
|
||
heute = datetime.now().date()
|
||
stats = {
|
||
"total_logs": AuditLog.objects.count(),
|
||
"logs_today": AuditLog.objects.filter(timestamp__date=heute).count(),
|
||
"logs_week": AuditLog.objects.filter(
|
||
timestamp__gte=heute - timedelta(days=7)
|
||
).count(),
|
||
"recent_backups": BackupJob.objects.all()[:5],
|
||
"last_backup": BackupJob.objects.filter(status="completed").first(),
|
||
}
|
||
|
||
# User activity summary
|
||
user_activity = (
|
||
AuditLog.objects.values("username")
|
||
.annotate(count=Count("id"))
|
||
.order_by("-count")[:10]
|
||
)
|
||
|
||
context = {
|
||
"recent_audit": recent_audit,
|
||
"stats": stats,
|
||
"user_activity": user_activity,
|
||
}
|
||
|
||
return render(request, "stiftung/administration.html", context)
|
||
|
||
|
||
@login_required
|
||
def unterstuetzungen_list(request):
|
||
"""Liste der Destinatärunterstützungen (Administration)."""
|
||
status = request.GET.get("status", "")
|
||
export_format = (
|
||
request.POST.get("format")
|
||
if request.method == "POST"
|
||
else request.GET.get("format", "")
|
||
)
|
||
selected_ids_param = (
|
||
request.POST.get("selected_entries", "")
|
||
if request.method == "POST"
|
||
else request.GET.get("selected_entries", "")
|
||
)
|
||
selected_ids = (
|
||
[id for id in selected_ids_param.split(",") if id] if selected_ids_param else []
|
||
)
|
||
|
||
qs = DestinataerUnterstuetzung.objects.select_related(
|
||
"destinataer", "konto", "ausgezahlt_von", "wiederkehrend_von"
|
||
).order_by("-faellig_am", "destinataer__nachname")
|
||
|
||
if status:
|
||
qs = qs.filter(status=status)
|
||
|
||
# Enhanced CSV export with field selection
|
||
if export_format == "csv":
|
||
return export_unterstuetzungen_csv(request, qs, selected_ids)
|
||
|
||
# Enhanced PDF export with corporate identity
|
||
elif export_format == "pdf":
|
||
return export_unterstuetzungen_pdf(request, qs, selected_ids)
|
||
|
||
# Get quarterly confirmation statistics
|
||
quarterly_stats = {}
|
||
total_quarterly = VierteljahresNachweis.objects.count()
|
||
for status_code, status_name in VierteljahresNachweis.STATUS_CHOICES:
|
||
count = VierteljahresNachweis.objects.filter(status=status_code).count()
|
||
quarterly_stats[status_code] = {
|
||
'name': status_name,
|
||
'count': count
|
||
}
|
||
|
||
context = {
|
||
"unterstuetzungen": qs,
|
||
"status_filter": status,
|
||
"quarterly_stats": quarterly_stats,
|
||
"total_quarterly": total_quarterly,
|
||
}
|
||
return render(request, "stiftung/unterstuetzungen_list.html", context)
|
||
|
||
|
||
def export_unterstuetzungen_csv(request, queryset, selected_ids=None):
|
||
"""Enhanced CSV export with field selection"""
|
||
import csv
|
||
from datetime import datetime
|
||
|
||
from django.http import HttpResponse
|
||
|
||
# If specific entries are selected, filter to only those
|
||
if selected_ids:
|
||
queryset = queryset.filter(id__in=selected_ids)
|
||
|
||
# Get selected fields from request (handle both 'fields' and 'selected_fields' parameter names)
|
||
selected_fields_param = ""
|
||
if request.method == "POST":
|
||
# Try 'fields' first (new format), then 'selected_fields' (legacy)
|
||
fields_list = request.POST.getlist("fields")
|
||
if fields_list:
|
||
selected_fields_param = ",".join(fields_list)
|
||
else:
|
||
selected_fields_param = request.POST.get("selected_fields", "")
|
||
else:
|
||
# Try 'fields' first (new format), then 'selected_fields' (legacy)
|
||
fields_list = request.GET.getlist("fields")
|
||
if fields_list:
|
||
selected_fields_param = ",".join(fields_list)
|
||
else:
|
||
selected_fields_param = request.GET.get("selected_fields", "")
|
||
|
||
selected_fields = selected_fields_param.split(",") if selected_fields_param else []
|
||
|
||
if not selected_fields:
|
||
# Default field set
|
||
selected_fields = [
|
||
"destinataer_name",
|
||
"betrag",
|
||
"faellig_am",
|
||
"empfaenger_iban",
|
||
"verwendungszweck",
|
||
"status",
|
||
"empfaenger_name",
|
||
"beschreibung",
|
||
]
|
||
|
||
# Field definitions with headers and data extraction
|
||
field_definitions = {
|
||
# Core payment fields
|
||
"id": ("ID", lambda u: str(u.id)),
|
||
"betrag": ("Betrag (€)", lambda u: f"{u.betrag:.2f}"),
|
||
"faellig_am": (
|
||
"Fällig am",
|
||
lambda u: u.faellig_am.strftime("%d.%m.%Y") if u.faellig_am else "",
|
||
),
|
||
"status": ("Status", lambda u: u.get_status_display()),
|
||
"beschreibung": ("Beschreibung", lambda u: u.beschreibung or ""),
|
||
"ausgezahlt_am": (
|
||
"Ausgezahlt am",
|
||
lambda u: u.ausgezahlt_am.strftime("%d.%m.%Y") if u.ausgezahlt_am else "",
|
||
),
|
||
"erstellt_am": (
|
||
"Erstellt am",
|
||
lambda u: u.erstellt_am.strftime("%d.%m.%Y %H:%M") if u.erstellt_am else "",
|
||
),
|
||
"aktualisiert_am": (
|
||
"Aktualisiert am",
|
||
lambda u: (
|
||
u.aktualisiert_am.strftime("%d.%m.%Y %H:%M")
|
||
if u.aktualisiert_am
|
||
else ""
|
||
),
|
||
),
|
||
# Destinataer fields
|
||
"destinataer_name": (
|
||
"Destinatär Name",
|
||
lambda u: u.destinataer.get_full_name() if u.destinataer else "",
|
||
),
|
||
"destinataer_vorname": (
|
||
"Vorname",
|
||
lambda u: u.destinataer.vorname if u.destinataer else "",
|
||
),
|
||
"destinataer_nachname": (
|
||
"Nachname",
|
||
lambda u: u.destinataer.nachname if u.destinataer else "",
|
||
),
|
||
"familienzweig": (
|
||
"Familienzweig",
|
||
lambda u: u.destinataer.familienzweig if u.destinataer else "",
|
||
),
|
||
"geburtsdatum": (
|
||
"Geburtsdatum",
|
||
lambda u: (
|
||
u.destinataer.geburtsdatum.strftime("%d.%m.%Y")
|
||
if u.destinataer and u.destinataer.geburtsdatum
|
||
else ""
|
||
),
|
||
),
|
||
"email": ("E-Mail", lambda u: u.destinataer.email if u.destinataer else ""),
|
||
"telefon": (
|
||
"Telefon",
|
||
lambda u: u.destinataer.telefon if u.destinataer else "",
|
||
),
|
||
"destinataer_iban": (
|
||
"Destinatär IBAN",
|
||
lambda u: u.destinataer.iban if u.destinataer else "",
|
||
),
|
||
"strasse": ("Straße", lambda u: u.destinataer.strasse if u.destinataer else ""),
|
||
"plz": ("PLZ", lambda u: u.destinataer.plz if u.destinataer else ""),
|
||
"ort": ("Ort", lambda u: u.destinataer.ort if u.destinataer else ""),
|
||
"adresse": (
|
||
"Adresse",
|
||
lambda u: (
|
||
f"{u.destinataer.strasse}, {u.destinataer.plz} {u.destinataer.ort}".strip(
|
||
", "
|
||
)
|
||
if u.destinataer
|
||
else ""
|
||
),
|
||
),
|
||
"berufsgruppe": (
|
||
"Berufsgruppe",
|
||
lambda u: u.destinataer.berufsgruppe if u.destinataer else "",
|
||
),
|
||
"ausbildungsstand": (
|
||
"Ausbildungsstand",
|
||
lambda u: u.destinataer.ausbildungsstand if u.destinataer else "",
|
||
),
|
||
"institution": (
|
||
"Institution",
|
||
lambda u: u.destinataer.institution if u.destinataer else "",
|
||
),
|
||
"jaehrliches_einkommen": (
|
||
"Jährliches Einkommen (€)",
|
||
lambda u: (
|
||
f"{u.destinataer.jaehrliches_einkommen:.2f}"
|
||
if u.destinataer and u.destinataer.jaehrliches_einkommen
|
||
else ""
|
||
),
|
||
),
|
||
"haushaltsgroesse": (
|
||
"Haushaltsgröße",
|
||
lambda u: (
|
||
str(u.destinataer.haushaltsgroesse)
|
||
if u.destinataer and u.destinataer.haushaltsgroesse
|
||
else ""
|
||
),
|
||
),
|
||
"monatliche_bezuege": (
|
||
"Monatliche Bezüge (€)",
|
||
lambda u: (
|
||
f"{u.destinataer.monatliche_bezuege:.2f}"
|
||
if u.destinataer and u.destinataer.monatliche_bezuege
|
||
else ""
|
||
),
|
||
),
|
||
"vermoegen": (
|
||
"Vermögen (€)",
|
||
lambda u: (
|
||
f"{u.destinataer.vermoegen:.2f}"
|
||
if u.destinataer and u.destinataer.vermoegen
|
||
else ""
|
||
),
|
||
),
|
||
# Payment details
|
||
"empfaenger_iban": ("Empfänger IBAN", lambda u: u.empfaenger_iban or ""),
|
||
"empfaenger_name": ("Empfänger Name", lambda u: u.empfaenger_name or ""),
|
||
"verwendungszweck": ("Verwendungszweck", lambda u: u.verwendungszweck or ""),
|
||
# Account fields
|
||
"konto_name": ("Konto", lambda u: str(u.konto) if u.konto else ""),
|
||
"konto_bank": ("Bank", lambda u: u.konto.bank_name if u.konto else ""),
|
||
"konto_iban": ("Konto IBAN", lambda u: u.konto.iban if u.konto else ""),
|
||
# System fields
|
||
"ausgezahlt_von": (
|
||
"Ausgezahlt von",
|
||
lambda u: u.ausgezahlt_von.get_full_name() if u.ausgezahlt_von else "",
|
||
),
|
||
"ist_wiederkehrend": (
|
||
"Wiederkehrend",
|
||
lambda u: "Ja" if u.wiederkehrend_von else "Nein",
|
||
),
|
||
}
|
||
|
||
# Create CSV response
|
||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||
filename = f"unterstuetzungen_{timestamp}.csv"
|
||
|
||
response = HttpResponse(content_type="text/csv; charset=utf-8")
|
||
response["Content-Disposition"] = f'attachment; filename="{filename}"'
|
||
|
||
writer = csv.writer(response, delimiter=";", quoting=csv.QUOTE_ALL)
|
||
|
||
# Write headers
|
||
headers = [
|
||
field_definitions[field][0]
|
||
for field in selected_fields
|
||
if field in field_definitions
|
||
]
|
||
writer.writerow(headers)
|
||
|
||
# Write data rows
|
||
for u in queryset:
|
||
row = []
|
||
for field in selected_fields:
|
||
if field in field_definitions:
|
||
try:
|
||
value = field_definitions[field][1](u)
|
||
row.append(value)
|
||
except Exception:
|
||
row.append("") # Fallback for any errors
|
||
else:
|
||
row.append("") # Unknown field
|
||
writer.writerow(row)
|
||
|
||
return response
|
||
|
||
|
||
def export_unterstuetzungen_pdf(request, queryset, selected_ids=None):
|
||
"""Enhanced PDF export with corporate identity and field selection"""
|
||
# If specific entries are selected, filter to only those
|
||
if selected_ids:
|
||
queryset = queryset.filter(id__in=selected_ids)
|
||
|
||
# Get selected fields from request (handle both 'fields' and 'selected_fields' parameter names)
|
||
selected_fields_param = ""
|
||
if request.method == "POST":
|
||
# Try 'fields' first (new format), then 'selected_fields' (legacy)
|
||
fields_list = request.POST.getlist("fields")
|
||
if fields_list:
|
||
selected_fields_param = ",".join(fields_list)
|
||
else:
|
||
selected_fields_param = request.POST.get("selected_fields", "")
|
||
else:
|
||
# Try 'fields' first (new format), then 'selected_fields' (legacy)
|
||
fields_list = request.GET.getlist("fields")
|
||
if fields_list:
|
||
selected_fields_param = ",".join(fields_list)
|
||
else:
|
||
selected_fields_param = request.GET.get("selected_fields", "")
|
||
|
||
selected_fields = selected_fields_param.split(",") if selected_fields_param else []
|
||
|
||
if not selected_fields:
|
||
# Default field set for PDF (fewer fields than CSV for better readability)
|
||
selected_fields = [
|
||
"destinataer_name",
|
||
"betrag",
|
||
"faellig_am",
|
||
"empfaenger_iban",
|
||
"verwendungszweck",
|
||
"status",
|
||
"beschreibung",
|
||
"ausgezahlt_am",
|
||
]
|
||
|
||
# Field definitions with display names (reuse from CSV but select PDF-appropriate subset)
|
||
field_definitions = {
|
||
# Core payment fields
|
||
"destinataer_name": "Destinatär",
|
||
"betrag": "Betrag (€)",
|
||
"faellig_am": "Fällig am",
|
||
"status": "Status",
|
||
"beschreibung": "Beschreibung",
|
||
"ausgezahlt_am": "Ausgezahlt am",
|
||
"erstellt_am": "Erstellt am",
|
||
"empfaenger_iban": "Empfänger IBAN",
|
||
"empfaenger_name": "Empfänger",
|
||
"verwendungszweck": "Verwendungszweck",
|
||
"konto_name": "Konto",
|
||
"ist_wiederkehrend": "Wiederkehrend",
|
||
}
|
||
|
||
# Filter to only include fields that are both selected and defined
|
||
filtered_fields = {
|
||
k: v for k, v in field_definitions.items() if k in selected_fields
|
||
}
|
||
|
||
# Prepare data with field extraction logic
|
||
data_for_pdf = []
|
||
for item in queryset:
|
||
row_data = {}
|
||
for field_key in filtered_fields.keys():
|
||
try:
|
||
if field_key == "destinataer_name":
|
||
row_data[field_key] = (
|
||
item.destinataer.get_full_name() if item.destinataer else ""
|
||
)
|
||
elif field_key == "betrag":
|
||
row_data[field_key] = f"€{item.betrag:.2f}" if item.betrag else ""
|
||
elif field_key == "faellig_am":
|
||
row_data[field_key] = (
|
||
item.faellig_am.strftime("%d.%m.%Y") if item.faellig_am else ""
|
||
)
|
||
elif field_key == "status":
|
||
row_data[field_key] = item.get_status_display()
|
||
elif field_key == "beschreibung":
|
||
row_data[field_key] = item.beschreibung or ""
|
||
elif field_key == "ausgezahlt_am":
|
||
row_data[field_key] = (
|
||
item.ausgezahlt_am.strftime("%d.%m.%Y")
|
||
if item.ausgezahlt_am
|
||
else ""
|
||
)
|
||
elif field_key == "erstellt_am":
|
||
row_data[field_key] = (
|
||
item.erstellt_am.strftime("%d.%m.%Y")
|
||
if item.erstellt_am
|
||
else ""
|
||
)
|
||
elif field_key == "empfaenger_iban":
|
||
row_data[field_key] = item.empfaenger_iban or ""
|
||
elif field_key == "empfaenger_name":
|
||
row_data[field_key] = item.empfaenger_name or ""
|
||
elif field_key == "verwendungszweck":
|
||
row_data[field_key] = item.verwendungszweck or ""
|
||
elif field_key == "konto_name":
|
||
row_data[field_key] = str(item.konto) if item.konto else ""
|
||
elif field_key == "ist_wiederkehrend":
|
||
row_data[field_key] = "Ja" if item.wiederkehrend_von else "Nein"
|
||
else:
|
||
# Generic field access
|
||
row_data[field_key] = getattr(item, field_key, "") or ""
|
||
except Exception:
|
||
row_data[field_key] = "" # Fallback for any errors
|
||
|
||
data_for_pdf.append(row_data)
|
||
|
||
# Use PDF generator
|
||
pdf_gen = get_pdf_generator()
|
||
return pdf_gen.export_data_list_pdf(
|
||
data=data_for_pdf,
|
||
fields_config=filtered_fields,
|
||
title="Unterstützungen Export",
|
||
filename_prefix="unterstuetzungen",
|
||
request_user=request.user,
|
||
)
|
||
|
||
|
||
def export_foerderungen_csv(request, queryset, selected_ids=None):
|
||
"""Enhanced CSV export for Förderungen with field selection"""
|
||
import csv
|
||
from datetime import datetime
|
||
|
||
from django.http import HttpResponse
|
||
|
||
# If specific entries are selected, filter to only those
|
||
if selected_ids:
|
||
queryset = queryset.filter(id__in=selected_ids)
|
||
|
||
# Get selected fields from request (default to all if none specified)
|
||
selected_fields_param = (
|
||
request.POST.get("selected_fields", "")
|
||
if request.method == "POST"
|
||
else request.GET.get("selected_fields", "")
|
||
)
|
||
selected_fields = selected_fields_param.split(",") if selected_fields_param else []
|
||
|
||
if not selected_fields:
|
||
# Default field set
|
||
selected_fields = [
|
||
"destinataer_name",
|
||
"jahr",
|
||
"betrag",
|
||
"kategorie",
|
||
"status",
|
||
"antragsdatum",
|
||
"beschreibung",
|
||
]
|
||
|
||
# Field definitions with headers and data extraction
|
||
field_definitions = {
|
||
# Core fields
|
||
"id": ("ID", lambda f: str(f.id)),
|
||
"destinataer_name": (
|
||
"Destinatär Name",
|
||
lambda f: f.destinataer.get_full_name() if f.destinataer else "",
|
||
),
|
||
"jahr": ("Jahr", lambda f: str(f.jahr)),
|
||
"betrag": ("Betrag (€)", lambda f: f"{f.betrag:.2f}"),
|
||
"kategorie": ("Kategorie", lambda f: f.get_kategorie_display()),
|
||
"status": ("Status", lambda f: f.get_status_display()),
|
||
"antragsdatum": (
|
||
"Antragsdatum",
|
||
lambda f: f.antragsdatum.strftime("%d.%m.%Y") if f.antragsdatum else "",
|
||
),
|
||
"bewilligungsdatum": (
|
||
"Bewilligungsdatum",
|
||
lambda f: (
|
||
f.bewilligungsdatum.strftime("%d.%m.%Y") if f.bewilligungsdatum else ""
|
||
),
|
||
),
|
||
"auszahlungsdatum": (
|
||
"Auszahlungsdatum",
|
||
lambda f: (
|
||
f.auszahlungsdatum.strftime("%d.%m.%Y") if f.auszahlungsdatum else ""
|
||
),
|
||
),
|
||
"beschreibung": ("Beschreibung", lambda f: f.beschreibung or ""),
|
||
"begruendung": ("Begründung", lambda f: f.begruendung or ""),
|
||
"verwendungsnachweis_datum": (
|
||
"Verwendungsnachweis Datum",
|
||
lambda f: (
|
||
f.verwendungsnachweis_datum.strftime("%d.%m.%Y")
|
||
if f.verwendungsnachweis_datum
|
||
else ""
|
||
),
|
||
),
|
||
"verwendungsnachweis_status": (
|
||
"Verwendungsnachweis Status",
|
||
lambda f: (
|
||
f.get_verwendungsnachweis_status_display()
|
||
if f.verwendungsnachweis_status
|
||
else ""
|
||
),
|
||
),
|
||
# Destinataer fields
|
||
"destinataer_vorname": (
|
||
"Vorname",
|
||
lambda f: f.destinataer.vorname if f.destinataer else "",
|
||
),
|
||
"destinataer_nachname": (
|
||
"Nachname",
|
||
lambda f: f.destinataer.nachname if f.destinataer else "",
|
||
),
|
||
"familienzweig": (
|
||
"Familienzweig",
|
||
lambda f: f.destinataer.familienzweig if f.destinataer else "",
|
||
),
|
||
"email": ("E-Mail", lambda f: f.destinataer.email if f.destinataer else ""),
|
||
"telefon": (
|
||
"Telefon",
|
||
lambda f: f.destinataer.telefon if f.destinataer else "",
|
||
),
|
||
"adresse": (
|
||
"Adresse",
|
||
lambda f: (
|
||
f"{f.destinataer.strasse}, {f.destinataer.plz} {f.destinataer.ort}".strip(
|
||
", "
|
||
)
|
||
if f.destinataer
|
||
else ""
|
||
),
|
||
),
|
||
"berufsgruppe": (
|
||
"Berufsgruppe",
|
||
lambda f: f.destinataer.berufsgruppe if f.destinataer else "",
|
||
),
|
||
"ausbildungsstand": (
|
||
"Ausbildungsstand",
|
||
lambda f: f.destinataer.ausbildungsstand if f.destinataer else "",
|
||
),
|
||
"institution": (
|
||
"Institution",
|
||
lambda f: f.destinataer.institution if f.destinataer else "",
|
||
),
|
||
# System fields
|
||
"erstellt_am": (
|
||
"Erstellt am",
|
||
lambda f: f.erstellt_am.strftime("%d.%m.%Y %H:%M") if f.erstellt_am else "",
|
||
),
|
||
"aktualisiert_am": (
|
||
"Aktualisiert am",
|
||
lambda f: (
|
||
f.aktualisiert_am.strftime("%d.%m.%Y %H:%M")
|
||
if f.aktualisiert_am
|
||
else ""
|
||
),
|
||
),
|
||
}
|
||
|
||
# Create CSV response
|
||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||
filename = f"foerderungen_{timestamp}.csv"
|
||
|
||
response = HttpResponse(content_type="text/csv; charset=utf-8")
|
||
response["Content-Disposition"] = f'attachment; filename="{filename}"'
|
||
|
||
writer = csv.writer(response, delimiter=";", quoting=csv.QUOTE_ALL)
|
||
|
||
# Write headers
|
||
headers = [
|
||
field_definitions[field][0]
|
||
for field in selected_fields
|
||
if field in field_definitions
|
||
]
|
||
writer.writerow(headers)
|
||
|
||
# Write data rows
|
||
for f in queryset:
|
||
row = []
|
||
for field in selected_fields:
|
||
if field in field_definitions:
|
||
try:
|
||
value = field_definitions[field][1](f)
|
||
row.append(value)
|
||
except Exception:
|
||
row.append("") # Fallback for any errors
|
||
else:
|
||
row.append("") # Unknown field
|
||
writer.writerow(row)
|
||
|
||
return response
|
||
|
||
|
||
def export_foerderungen_pdf(request, queryset, selected_ids=None):
|
||
"""Enhanced PDF export for Förderungen with corporate identity and field selection"""
|
||
# If specific entries are selected, filter to only those
|
||
if selected_ids:
|
||
queryset = queryset.filter(id__in=selected_ids)
|
||
|
||
# Get selected fields from request (default to key fields if none specified)
|
||
selected_fields_param = (
|
||
request.POST.get("selected_fields", "")
|
||
if request.method == "POST"
|
||
else request.GET.get("selected_fields", "")
|
||
)
|
||
selected_fields = selected_fields_param.split(",") if selected_fields_param else []
|
||
|
||
if not selected_fields:
|
||
# Default field set for PDF (fewer fields than CSV for better readability)
|
||
selected_fields = [
|
||
"destinataer_name",
|
||
"jahr",
|
||
"betrag",
|
||
"kategorie",
|
||
"status",
|
||
"antragsdatum",
|
||
]
|
||
|
||
# Field definitions with display names
|
||
field_definitions = {
|
||
"destinataer_name": "Destinatär",
|
||
"jahr": "Jahr",
|
||
"betrag": "Betrag (€)",
|
||
"kategorie": "Kategorie",
|
||
"status": "Status",
|
||
"antragsdatum": "Antragsdatum",
|
||
"bewilligungsdatum": "Bewilligungsdatum",
|
||
"auszahlungsdatum": "Auszahlungsdatum",
|
||
"beschreibung": "Beschreibung",
|
||
"begruendung": "Begründung",
|
||
"verwendungsnachweis_status": "Verwendungsnachweis",
|
||
}
|
||
|
||
# Filter to only include fields that are both selected and defined
|
||
filtered_fields = {
|
||
k: v for k, v in field_definitions.items() if k in selected_fields
|
||
}
|
||
|
||
# Prepare data with field extraction logic
|
||
data_for_pdf = []
|
||
for item in queryset:
|
||
row_data = {}
|
||
for field_key in filtered_fields.keys():
|
||
try:
|
||
if field_key == "destinataer_name":
|
||
row_data[field_key] = (
|
||
item.destinataer.get_full_name() if item.destinataer else ""
|
||
)
|
||
elif field_key == "jahr":
|
||
row_data[field_key] = str(item.jahr)
|
||
elif field_key == "betrag":
|
||
row_data[field_key] = f"€{item.betrag:.2f}" if item.betrag else ""
|
||
elif field_key == "kategorie":
|
||
row_data[field_key] = item.get_kategorie_display()
|
||
elif field_key == "status":
|
||
row_data[field_key] = item.get_status_display()
|
||
elif field_key == "antragsdatum":
|
||
row_data[field_key] = (
|
||
item.antragsdatum.strftime("%d.%m.%Y")
|
||
if item.antragsdatum
|
||
else ""
|
||
)
|
||
elif field_key == "bewilligungsdatum":
|
||
row_data[field_key] = (
|
||
item.bewilligungsdatum.strftime("%d.%m.%Y")
|
||
if item.bewilligungsdatum
|
||
else ""
|
||
)
|
||
elif field_key == "auszahlungsdatum":
|
||
row_data[field_key] = (
|
||
item.auszahlungsdatum.strftime("%d.%m.%Y")
|
||
if item.auszahlungsdatum
|
||
else ""
|
||
)
|
||
elif field_key == "beschreibung":
|
||
row_data[field_key] = (item.beschreibung or "")[:100] + (
|
||
"..." if len(item.beschreibung or "") > 100 else ""
|
||
)
|
||
elif field_key == "begruendung":
|
||
row_data[field_key] = (item.begruendung or "")[:100] + (
|
||
"..." if len(item.begruendung or "") > 100 else ""
|
||
)
|
||
elif field_key == "verwendungsnachweis_status":
|
||
row_data[field_key] = (
|
||
item.get_verwendungsnachweis_status_display()
|
||
if item.verwendungsnachweis_status
|
||
else ""
|
||
)
|
||
else:
|
||
# Generic field access
|
||
row_data[field_key] = getattr(item, field_key, "") or ""
|
||
except Exception:
|
||
row_data[field_key] = "" # Fallback for any errors
|
||
|
||
data_for_pdf.append(row_data)
|
||
|
||
# Use PDF generator
|
||
pdf_gen = get_pdf_generator()
|
||
return pdf_gen.export_data_list_pdf(
|
||
data=data_for_pdf,
|
||
fields_config=filtered_fields,
|
||
title="Förderungen Export",
|
||
filename_prefix="foerderungen",
|
||
request_user=request.user,
|
||
)
|
||
|
||
|
||
@login_required
|
||
def unterstuetzung_edit(request, pk):
|
||
obj = get_object_or_404(DestinataerUnterstuetzung, pk=pk)
|
||
if request.method == "POST":
|
||
form = DestinataerUnterstuetzungForm(request.POST, instance=obj)
|
||
if form.is_valid():
|
||
form.save()
|
||
messages.success(request, "Unterstützung aktualisiert.")
|
||
return redirect("stiftung:unterstuetzungen_list")
|
||
else:
|
||
form = DestinataerUnterstuetzungForm(instance=obj)
|
||
return render(
|
||
request,
|
||
"stiftung/unterstuetzung_form.html",
|
||
{"form": form, "title": "Unterstützung bearbeiten"},
|
||
)
|
||
|
||
|
||
@login_required
|
||
def unterstuetzung_delete(request, pk):
|
||
obj = get_object_or_404(DestinataerUnterstuetzung, pk=pk)
|
||
|
||
# Check if this will also delete the recurring template
|
||
will_delete_template = False
|
||
if obj.wiederkehrend_von:
|
||
andere_zahlungen = (
|
||
DestinataerUnterstuetzung.objects.filter(
|
||
wiederkehrend_von=obj.wiederkehrend_von
|
||
)
|
||
.exclude(pk=pk)
|
||
.exists()
|
||
)
|
||
will_delete_template = not andere_zahlungen
|
||
|
||
if request.method == "POST":
|
||
# Check if this support payment is linked to a recurring payment template
|
||
wiederkehrend_template = obj.wiederkehrend_von
|
||
|
||
# Delete the support payment
|
||
obj.delete()
|
||
|
||
# If this was generated from a recurring template and there are no other
|
||
# payments from this template, delete the template too
|
||
if wiederkehrend_template:
|
||
# Check if there are other payments from this recurring template
|
||
andere_zahlungen = DestinataerUnterstuetzung.objects.filter(
|
||
wiederkehrend_von=wiederkehrend_template
|
||
).exists()
|
||
|
||
# If no other payments exist from this template, delete the template too
|
||
if not andere_zahlungen:
|
||
wiederkehrend_template.delete()
|
||
messages.success(
|
||
request,
|
||
"Unterstützung und wiederkehrende Zahlungsvorlage gelöscht.",
|
||
)
|
||
else:
|
||
messages.success(request, "Unterstützung gelöscht.")
|
||
else:
|
||
messages.success(request, "Unterstützung gelöscht.")
|
||
|
||
return redirect("stiftung:unterstuetzungen_list")
|
||
|
||
context = {
|
||
"obj": obj,
|
||
"will_delete_template": will_delete_template,
|
||
}
|
||
return render(request, "stiftung/unterstuetzung_confirm_delete.html", context)
|
||
|
||
|
||
@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
|
||
|
||
|
||
@login_required
|
||
def paechter_export(request, pk):
|
||
"""Export complete Pächter data as ZIP with documents"""
|
||
import json
|
||
import os
|
||
import tempfile
|
||
import zipfile
|
||
|
||
from django.http import HttpResponse
|
||
|
||
paechter = get_object_or_404(Paechter, 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(paechter.id),
|
||
"vorname": paechter.vorname,
|
||
"nachname": paechter.nachname,
|
||
"geburtsdatum": (
|
||
paechter.geburtsdatum.isoformat() if paechter.geburtsdatum else None
|
||
),
|
||
"email": paechter.email,
|
||
"telefon": paechter.telefon,
|
||
"iban": paechter.iban,
|
||
"strasse": paechter.strasse,
|
||
"plz": paechter.plz,
|
||
"ort": paechter.ort,
|
||
"personentyp": paechter.get_personentyp_display(),
|
||
"pachtnummer": paechter.pachtnummer,
|
||
"pachtbeginn_erste": (
|
||
paechter.pachtbeginn_erste.isoformat()
|
||
if paechter.pachtbeginn_erste
|
||
else None
|
||
),
|
||
"pachtende_letzte": (
|
||
paechter.pachtende_letzte.isoformat()
|
||
if paechter.pachtende_letzte
|
||
else None
|
||
),
|
||
"pachtzins_aktuell": (
|
||
str(paechter.pachtzins_aktuell)
|
||
if paechter.pachtzins_aktuell
|
||
else None
|
||
),
|
||
"landwirtschaftliche_ausbildung": paechter.landwirtschaftliche_ausbildung,
|
||
"berufserfahrung_jahre": paechter.berufserfahrung_jahre,
|
||
"spezialisierung": paechter.spezialisierung,
|
||
"notizen": paechter.notizen,
|
||
"aktiv": paechter.aktiv,
|
||
"gesamt_pachtflaeche": float(paechter.get_gesamt_pachtflaeche()),
|
||
"gesamt_pachtzins": float(paechter.get_gesamt_pachtzins()),
|
||
"export_datum": timezone.now().isoformat(),
|
||
"export_user": request.user.username,
|
||
}
|
||
zipf.writestr(
|
||
"paechter_data.json",
|
||
json.dumps(entity_data, indent=2, ensure_ascii=False),
|
||
)
|
||
|
||
# 2. Linked documents from Paperless
|
||
dokumente = DokumentLink.objects.filter(paechter_id=paechter.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:
|
||
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"
|
||
|
||
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),
|
||
)
|
||
|
||
# Prepare response
|
||
with open(temp_file.name, "rb") as f:
|
||
response = HttpResponse(f.read(), content_type="application/zip")
|
||
filename = f"paechter_{paechter.nachname}_{paechter.vorname}_{timezone.now().strftime('%Y%m%d_%H%M%S')}.zip"
|
||
response["Content-Disposition"] = f'attachment; filename="{filename}"'
|
||
return response
|
||
|
||
finally:
|
||
try:
|
||
os.unlink(temp_file.name)
|
||
except:
|
||
pass
|
||
|
||
|
||
@login_required
|
||
def land_export(request, pk):
|
||
"""Export complete Land data as ZIP with documents"""
|
||
import json
|
||
import os
|
||
import tempfile
|
||
import zipfile
|
||
|
||
from django.http import HttpResponse
|
||
|
||
land = get_object_or_404(Land, 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(land.id),
|
||
"lfd_nr": land.lfd_nr,
|
||
"ew_nummer": land.ew_nummer,
|
||
"amtsgericht": land.amtsgericht,
|
||
"gemeinde": land.gemeinde,
|
||
"gemarkung": land.gemarkung,
|
||
"flur": land.flur,
|
||
"flurstueck": land.flurstueck,
|
||
"groesse_qm": str(land.groesse_qm),
|
||
"gruenland_qm": str(land.gruenland_qm),
|
||
"acker_qm": str(land.acker_qm),
|
||
"wald_qm": str(land.wald_qm),
|
||
"sonstiges_qm": str(land.sonstiges_qm),
|
||
"verpachtete_gesamtflaeche": str(land.verpachtete_gesamtflaeche),
|
||
"flaeche_alte_liste": (
|
||
str(land.flaeche_alte_liste) if land.flaeche_alte_liste else None
|
||
),
|
||
"verp_flaeche_aktuell": str(land.verp_flaeche_aktuell),
|
||
"anteil_grundsteuer": (
|
||
str(land.anteil_grundsteuer) if land.anteil_grundsteuer else None
|
||
),
|
||
"anteil_lwk": str(land.anteil_lwk) if land.anteil_lwk else None,
|
||
"aktiv": land.aktiv,
|
||
"notizen": land.notizen,
|
||
"erstellt_am": land.erstellt_am.isoformat(),
|
||
"aktualisiert_am": land.aktualisiert_am.isoformat(),
|
||
"gesamtflaeche_berechnet": float(land.get_gesamtflaeche()),
|
||
"verpachtungsgrad": float(land.get_verpachtungsgrad()),
|
||
"export_datum": timezone.now().isoformat(),
|
||
"export_user": request.user.username,
|
||
}
|
||
zipf.writestr(
|
||
"land_data.json", json.dumps(entity_data, indent=2, ensure_ascii=False)
|
||
)
|
||
|
||
# 2. Linked documents from Paperless
|
||
dokumente = DokumentLink.objects.filter(land_id=land.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:
|
||
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"
|
||
|
||
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),
|
||
)
|
||
|
||
# Prepare response
|
||
with open(temp_file.name, "rb") as f:
|
||
response = HttpResponse(f.read(), content_type="application/zip")
|
||
filename = f"land_{land.gemeinde}_{land.gemarkung}_flur{land.flur}_{timezone.now().strftime('%Y%m%d_%H%M%S')}.zip"
|
||
response["Content-Disposition"] = f'attachment; filename="{filename}"'
|
||
return response
|
||
|
||
finally:
|
||
try:
|
||
os.unlink(temp_file.name)
|
||
except:
|
||
pass
|
||
|
||
|
||
@login_required
|
||
def verpachtung_export(request, pk):
|
||
"""Export complete Verpachtung data as ZIP with documents"""
|
||
import json
|
||
import os
|
||
import tempfile
|
||
import zipfile
|
||
|
||
from django.http import HttpResponse
|
||
|
||
verpachtung = get_object_or_404(LandVerpachtung, 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(verpachtung.id),
|
||
"vertragsnummer": verpachtung.vertragsnummer,
|
||
"land": str(verpachtung.land),
|
||
"land_id": str(verpachtung.land.id),
|
||
"paechter": str(verpachtung.paechter),
|
||
"paechter_id": str(verpachtung.paechter.id),
|
||
"pachtbeginn": verpachtung.pachtbeginn.isoformat(),
|
||
"pachtende": verpachtung.pachtende.isoformat(),
|
||
"verlaengerung": (
|
||
verpachtung.verlaengerung.isoformat()
|
||
if verpachtung.verlaengerung
|
||
else None
|
||
),
|
||
"pachtzins_pro_qm": str(verpachtung.pachtzins_pro_qm),
|
||
"pachtzins_jaehrlich": str(verpachtung.pachtzins_pauschal),
|
||
"verpachtete_flaeche": str(verpachtung.verpachtete_flaeche),
|
||
"status": verpachtung.get_status_display(),
|
||
"verwendungsnachweis": (
|
||
str(verpachtung.verwendungsnachweis)
|
||
if verpachtung.verwendungsnachweis
|
||
else None
|
||
),
|
||
"bemerkungen": verpachtung.bemerkungen,
|
||
"erstellt_am": verpachtung.erstellt_am.isoformat(),
|
||
"aktualisiert_am": verpachtung.aktualisiert_am.isoformat(),
|
||
"vertragsdauer_tage": verpachtung.get_vertragsdauer_tage(),
|
||
"restlaufzeit_tage": verpachtung.get_restlaufzeit_tage(),
|
||
"ist_aktiv": verpachtung.is_aktiv(),
|
||
"export_datum": timezone.now().isoformat(),
|
||
"export_user": request.user.username,
|
||
}
|
||
zipf.writestr(
|
||
"verpachtung_data.json",
|
||
json.dumps(entity_data, indent=2, ensure_ascii=False),
|
||
)
|
||
|
||
# 2. Linked documents from Paperless
|
||
dokumente = DokumentLink.objects.filter(verpachtung_id=verpachtung.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:
|
||
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"
|
||
|
||
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),
|
||
)
|
||
|
||
# Prepare response
|
||
with open(temp_file.name, "rb") as f:
|
||
response = HttpResponse(f.read(), content_type="application/zip")
|
||
filename = f"verpachtung_{verpachtung.vertragsnummer}_{timezone.now().strftime('%Y%m%d_%H%M%S')}.zip"
|
||
response["Content-Disposition"] = f'attachment; filename="{filename}"'
|
||
return response
|
||
|
||
finally:
|
||
try:
|
||
os.unlink(temp_file.name)
|
||
except:
|
||
pass
|
||
|
||
|
||
@login_required
|
||
def audit_log_list(request):
|
||
"""Liste aller Audit Log Einträge"""
|
||
from django.core.paginator import Paginator
|
||
|
||
from stiftung.models import AuditLog
|
||
|
||
logs = AuditLog.objects.all()
|
||
|
||
# Filter
|
||
user_filter = request.GET.get("user")
|
||
if user_filter:
|
||
logs = logs.filter(username__icontains=user_filter)
|
||
|
||
action_filter = request.GET.get("action")
|
||
if action_filter:
|
||
logs = logs.filter(action=action_filter)
|
||
|
||
entity_filter = request.GET.get("entity_type")
|
||
if entity_filter:
|
||
logs = logs.filter(entity_type=entity_filter)
|
||
|
||
date_from = request.GET.get("date_from")
|
||
if date_from:
|
||
logs = logs.filter(timestamp__date__gte=date_from)
|
||
|
||
date_to = request.GET.get("date_to")
|
||
if date_to:
|
||
logs = logs.filter(timestamp__date__lte=date_to)
|
||
|
||
# Pagination
|
||
paginator = Paginator(logs, 50)
|
||
page_number = request.GET.get("page")
|
||
page_obj = paginator.get_page(page_number)
|
||
|
||
context = {
|
||
"page_obj": page_obj,
|
||
"action_choices": AuditLog.ACTION_TYPES,
|
||
"entity_choices": AuditLog.ENTITY_TYPES,
|
||
"user_filter": user_filter,
|
||
"action_filter": action_filter,
|
||
"entity_filter": entity_filter,
|
||
"date_from": date_from,
|
||
"date_to": date_to,
|
||
}
|
||
|
||
return render(request, "stiftung/audit_log_list.html", context)
|
||
|
||
|
||
@login_required
|
||
def backup_management(request):
|
||
"""Backup Management Interface"""
|
||
from django.core.paginator import Paginator
|
||
|
||
from stiftung.models import BackupJob
|
||
|
||
# Handle backup creation
|
||
if request.method == "POST":
|
||
backup_type = request.POST.get("backup_type", "full")
|
||
|
||
# Create backup job
|
||
backup_job = BackupJob.objects.create(
|
||
backup_type=backup_type, created_by=request.user
|
||
)
|
||
|
||
# Log the backup initiation
|
||
from stiftung.audit import log_system_action
|
||
|
||
log_system_action(
|
||
request=request,
|
||
action="backup",
|
||
description=f"Backup-Job erstellt: {backup_job.get_backup_type_display()}",
|
||
details={"backup_job_id": str(backup_job.id), "backup_type": backup_type},
|
||
)
|
||
|
||
# Start backup process asynchronously (we'll create a simple version for now)
|
||
import threading
|
||
|
||
from stiftung.backup_utils import run_backup
|
||
|
||
backup_thread = threading.Thread(target=run_backup, args=(str(backup_job.id),))
|
||
backup_thread.start()
|
||
|
||
messages.success(
|
||
request,
|
||
f'Backup-Job "{backup_job.get_backup_type_display()}" wurde gestartet.',
|
||
)
|
||
return redirect("stiftung:backup_management")
|
||
|
||
# List backup jobs
|
||
backup_jobs = BackupJob.objects.all()
|
||
|
||
# Pagination
|
||
paginator = Paginator(backup_jobs, 20)
|
||
page_number = request.GET.get("page")
|
||
page_obj = paginator.get_page(page_number)
|
||
|
||
context = {
|
||
"page_obj": page_obj,
|
||
"backup_types": BackupJob.TYPE_CHOICES,
|
||
}
|
||
|
||
return render(request, "stiftung/backup_management.html", context)
|
||
|
||
|
||
@login_required
|
||
def backup_download(request, backup_id):
|
||
"""Download a backup file"""
|
||
import os
|
||
|
||
from django.http import FileResponse, Http404
|
||
|
||
from stiftung.models import BackupJob
|
||
|
||
try:
|
||
backup_job = BackupJob.objects.get(id=backup_id, status="completed")
|
||
except BackupJob.DoesNotExist:
|
||
raise Http404("Backup nicht gefunden oder nicht vollständig")
|
||
|
||
backup_path = os.path.join("/app/backups", backup_job.backup_filename)
|
||
if not os.path.exists(backup_path):
|
||
raise Http404("Backup-Datei nicht gefunden")
|
||
|
||
# Log download
|
||
from stiftung.audit import log_system_action
|
||
|
||
log_system_action(
|
||
request=request,
|
||
action="export",
|
||
description=f"Backup heruntergeladen: {backup_job.backup_filename}",
|
||
details={"backup_job_id": str(backup_job.id)},
|
||
)
|
||
|
||
response = FileResponse(
|
||
open(backup_path, "rb"), as_attachment=True, filename=backup_job.backup_filename
|
||
)
|
||
return response
|
||
|
||
|
||
@login_required
|
||
def backup_restore(request):
|
||
"""Restore from backup"""
|
||
if request.method == "POST":
|
||
from stiftung.models import BackupJob
|
||
|
||
backup_file = request.FILES.get("backup_file")
|
||
|
||
if not backup_file:
|
||
messages.error(request, "Bitte wählen Sie eine Backup-Datei aus.")
|
||
return redirect("stiftung:backup_management")
|
||
|
||
# Validate file format
|
||
if not backup_file.name.endswith(".tar.gz"):
|
||
messages.error(
|
||
request, "Ungültiges Backup-Format. Nur .tar.gz Dateien sind erlaubt."
|
||
)
|
||
return redirect("stiftung:backup_management")
|
||
|
||
# Save uploaded file to temporary location
|
||
import os
|
||
import tempfile
|
||
|
||
temp_dir = tempfile.mkdtemp()
|
||
backup_path = os.path.join(temp_dir, backup_file.name)
|
||
|
||
try:
|
||
with open(backup_path, "wb+") as destination:
|
||
for chunk in backup_file.chunks():
|
||
destination.write(chunk)
|
||
|
||
# Validate the backup file
|
||
from stiftung.backup_utils import validate_backup_file
|
||
|
||
is_valid, message = validate_backup_file(backup_path)
|
||
if not is_valid:
|
||
messages.error(request, f"Ungültiges Backup: {message}")
|
||
return redirect("stiftung:backup_management")
|
||
|
||
# Show validation success
|
||
messages.info(request, f"Backup validiert: {message}")
|
||
|
||
# Create restore job
|
||
restore_job = BackupJob.objects.create(
|
||
operation="restore",
|
||
backup_type="full",
|
||
created_by=request.user,
|
||
backup_filename=backup_file.name,
|
||
)
|
||
|
||
# Log restore initiation
|
||
from stiftung.audit import log_system_action
|
||
|
||
log_system_action(
|
||
request=request,
|
||
action="restore",
|
||
description=f"Wiederherstellung gestartet von: {backup_file.name}",
|
||
details={
|
||
"restore_job_id": str(restore_job.id),
|
||
"filename": backup_file.name,
|
||
},
|
||
)
|
||
|
||
# Start restore process
|
||
import threading
|
||
|
||
from stiftung.backup_utils import run_restore
|
||
|
||
restore_thread = threading.Thread(
|
||
target=run_restore, args=(str(restore_job.id), backup_path)
|
||
)
|
||
restore_thread.start()
|
||
|
||
messages.success(
|
||
request, f'Wiederherstellung von "{backup_file.name}" wurde gestartet. '
|
||
f'Überwachen Sie den Fortschritt in der Backup-Historie.'
|
||
)
|
||
return redirect("stiftung:backup_management")
|
||
|
||
except Exception as e:
|
||
messages.error(request, f"Fehler beim Verarbeiten der Backup-Datei: {e}")
|
||
return redirect("stiftung:backup_management")
|
||
|
||
return redirect("stiftung:backup_management")
|
||
|
||
|
||
@login_required
|
||
def backup_cancel(request, backup_id):
|
||
"""Cancel a running backup job"""
|
||
from stiftung.models import BackupJob
|
||
import traceback
|
||
|
||
try:
|
||
print(f"DEBUG: Attempting to cancel backup job {backup_id}")
|
||
backup_job = BackupJob.objects.get(id=backup_id)
|
||
print(f"DEBUG: Found backup job - ID: {backup_job.id}, Status: {backup_job.status}")
|
||
|
||
# Use created_by_id instead of created_by to avoid triggering the foreign key lookup
|
||
print(f"DEBUG: Created by ID: {backup_job.created_by_id}, Current user ID: {request.user.id}")
|
||
|
||
# Only allow cancelling running or pending jobs
|
||
if backup_job.status not in ['running', 'pending']:
|
||
messages.error(request, "Nur laufende oder wartende Backups können abgebrochen werden.")
|
||
return redirect("stiftung:backup_management")
|
||
|
||
# Check if user has permission to cancel (either own job or admin)
|
||
# Use created_by_id to avoid database lookup for potentially non-existent user
|
||
print(f"DEBUG: Checking permissions - created_by_id: {backup_job.created_by_id}, is_staff: {request.user.is_staff}")
|
||
if backup_job.created_by_id is not None and backup_job.created_by_id != request.user.id and not request.user.is_staff:
|
||
messages.error(request, "Sie können nur Ihre eigenen Backup-Jobs abbrechen.")
|
||
return redirect("stiftung:backup_management")
|
||
|
||
# Mark as cancelled
|
||
print("DEBUG: About to mark job as cancelled")
|
||
from django.utils import timezone
|
||
backup_job.status = "cancelled"
|
||
backup_job.completed_at = timezone.now()
|
||
|
||
print(f"DEBUG: About to set error message with username: {request.user.username}")
|
||
backup_job.error_message = f"Abgebrochen von {request.user.username}"
|
||
|
||
print("DEBUG: About to save backup job")
|
||
backup_job.save()
|
||
print("DEBUG: Backup job saved successfully")
|
||
|
||
# Log the cancellation (with error handling)
|
||
try:
|
||
print("DEBUG: About to log system action")
|
||
from stiftung.audit import log_system_action
|
||
|
||
print(f"DEBUG: About to call get_backup_type_display")
|
||
backup_type_display = backup_job.get_backup_type_display()
|
||
print(f"DEBUG: Backup type display: {backup_type_display}")
|
||
|
||
log_system_action(
|
||
request=request,
|
||
action="backup_cancel",
|
||
description=f"Backup-Job abgebrochen: {backup_type_display}",
|
||
details={"backup_job_id": str(backup_job.id)},
|
||
)
|
||
print("DEBUG: System action logged successfully")
|
||
except Exception as audit_error:
|
||
print(f"ERROR in audit logging: {audit_error}")
|
||
print(f"ERROR traceback: {traceback.format_exc()}")
|
||
# Don't fail the cancellation if logging fails
|
||
|
||
messages.success(request, f"Backup-Job wurde abgebrochen.")
|
||
|
||
except BackupJob.DoesNotExist:
|
||
print(f"ERROR: Backup job {backup_id} not found")
|
||
messages.error(request, "Backup-Job nicht gefunden.")
|
||
except Exception as e:
|
||
print(f"ERROR: Unexpected error in backup_cancel: {e}")
|
||
print(f"ERROR traceback: {traceback.format_exc()}")
|
||
messages.error(request, f"Fehler beim Abbrechen des Backup-Jobs: {e}")
|
||
|
||
return redirect("stiftung:backup_management")
|
||
|
||
|
||
# =============================================================================
|
||
# USER MANAGEMENT VIEWS
|
||
# =============================================================================
|
||
|
||
|
||
@login_required
|
||
def user_management(request):
|
||
"""User Management Dashboard"""
|
||
from django.contrib.auth.models import User
|
||
from django.core.paginator import Paginator
|
||
from django.db.models import Q
|
||
|
||
# Check permission
|
||
if not request.user.has_perm("stiftung.manage_users"):
|
||
messages.error(
|
||
request, "Sie haben keine Berechtigung für die Benutzerverwaltung."
|
||
)
|
||
return redirect("stiftung:administration")
|
||
|
||
users = User.objects.all().order_by("username")
|
||
|
||
# Search functionality
|
||
search = request.GET.get("search")
|
||
if search:
|
||
users = users.filter(
|
||
Q(username__icontains=search)
|
||
| Q(email__icontains=search)
|
||
| Q(first_name__icontains=search)
|
||
| Q(last_name__icontains=search)
|
||
)
|
||
|
||
# Filter by status
|
||
status_filter = request.GET.get("status")
|
||
if status_filter == "active":
|
||
users = users.filter(is_active=True)
|
||
elif status_filter == "inactive":
|
||
users = users.filter(is_active=False)
|
||
elif status_filter == "staff":
|
||
users = users.filter(is_staff=True)
|
||
|
||
# Pagination
|
||
paginator = Paginator(users, 20)
|
||
page_number = request.GET.get("page")
|
||
page_obj = paginator.get_page(page_number)
|
||
|
||
# Statistics
|
||
stats = {
|
||
"total_users": User.objects.count(),
|
||
"active_users": User.objects.filter(is_active=True).count(),
|
||
"staff_users": User.objects.filter(is_staff=True).count(),
|
||
"inactive_users": User.objects.filter(is_active=False).count(),
|
||
}
|
||
|
||
context = {
|
||
"page_obj": page_obj,
|
||
"stats": stats,
|
||
"search": search,
|
||
"status_filter": status_filter,
|
||
}
|
||
|
||
return render(request, "stiftung/user_management.html", context)
|
||
|
||
|
||
@login_required
|
||
def user_create(request):
|
||
"""Create a new user"""
|
||
from django.contrib.auth.models import User
|
||
|
||
from stiftung.forms import UserCreationForm
|
||
|
||
# Check permission
|
||
if not request.user.has_perm("stiftung.manage_users"):
|
||
messages.error(
|
||
request, "Sie haben keine Berechtigung für die Benutzerverwaltung."
|
||
)
|
||
return redirect("stiftung:administration")
|
||
|
||
if request.method == "POST":
|
||
form = UserCreationForm(request.POST)
|
||
if form.is_valid():
|
||
# Create user
|
||
user = User.objects.create_user(
|
||
username=form.cleaned_data["username"],
|
||
email=form.cleaned_data["email"],
|
||
password=form.cleaned_data["password1"],
|
||
first_name=form.cleaned_data["first_name"],
|
||
last_name=form.cleaned_data["last_name"],
|
||
is_active=form.cleaned_data["is_active"],
|
||
is_staff=form.cleaned_data["is_staff"],
|
||
)
|
||
|
||
# Log user creation
|
||
from stiftung.audit import log_action
|
||
|
||
log_action(
|
||
request=request,
|
||
action="create",
|
||
entity_type="user",
|
||
entity_id=str(user.pk),
|
||
entity_name=user.username,
|
||
description=f'Neuer Benutzer "{user.username}" wurde erstellt',
|
||
)
|
||
|
||
messages.success(
|
||
request, f'Benutzer "{user.username}" wurde erfolgreich erstellt.'
|
||
)
|
||
return redirect("stiftung:user_detail", pk=user.pk)
|
||
else:
|
||
form = UserCreationForm()
|
||
|
||
context = {"form": form, "title": "Neuen Benutzer erstellen"}
|
||
|
||
return render(request, "stiftung/user_form.html", context)
|
||
|
||
|
||
@login_required
|
||
def user_detail(request, pk):
|
||
"""User detail view"""
|
||
from django.contrib.auth.models import User
|
||
|
||
# Check permission
|
||
if not request.user.has_perm("stiftung.manage_users"):
|
||
messages.error(
|
||
request, "Sie haben keine Berechtigung für die Benutzerverwaltung."
|
||
)
|
||
return redirect("stiftung:administration")
|
||
|
||
user = get_object_or_404(User, pk=pk)
|
||
|
||
# Get user's permissions
|
||
user_permissions = user.get_all_permissions()
|
||
stiftung_permissions = [
|
||
perm for perm in user_permissions if perm.startswith("stiftung.")
|
||
]
|
||
|
||
# Get recent audit activity
|
||
from stiftung.models import AuditLog
|
||
|
||
recent_activity = AuditLog.objects.filter(user=user).order_by("-timestamp")[:10]
|
||
|
||
context = {
|
||
"user_obj": user, # Use user_obj to avoid conflict with request.user
|
||
"stiftung_permissions": stiftung_permissions,
|
||
"recent_activity": recent_activity,
|
||
}
|
||
|
||
return render(request, "stiftung/user_detail.html", context)
|
||
|
||
|
||
@login_required
|
||
def user_edit(request, pk):
|
||
"""Edit user"""
|
||
from django.contrib.auth.models import User
|
||
|
||
from stiftung.forms import UserUpdateForm
|
||
|
||
# Check permission
|
||
if not request.user.has_perm("stiftung.manage_users"):
|
||
messages.error(
|
||
request, "Sie haben keine Berechtigung für die Benutzerverwaltung."
|
||
)
|
||
return redirect("stiftung:administration")
|
||
|
||
user = get_object_or_404(User, pk=pk)
|
||
|
||
if request.method == "POST":
|
||
form = UserUpdateForm(request.POST, instance=user)
|
||
if form.is_valid():
|
||
# Track changes
|
||
from stiftung.audit import log_action, track_model_changes
|
||
|
||
old_user = User.objects.get(pk=user.pk)
|
||
|
||
updated_user = form.save()
|
||
|
||
# Log changes
|
||
changes = track_model_changes(old_user, updated_user)
|
||
if changes:
|
||
log_action(
|
||
request=request,
|
||
action="update",
|
||
entity_type="user",
|
||
entity_id=str(updated_user.pk),
|
||
entity_name=updated_user.username,
|
||
description=f'Benutzer "{updated_user.username}" wurde aktualisiert',
|
||
changes=changes,
|
||
)
|
||
|
||
messages.success(
|
||
request,
|
||
f'Benutzer "{updated_user.username}" wurde erfolgreich aktualisiert.',
|
||
)
|
||
return redirect("stiftung:user_detail", pk=updated_user.pk)
|
||
else:
|
||
form = UserUpdateForm(instance=user)
|
||
|
||
context = {
|
||
"form": form,
|
||
"user_obj": user,
|
||
"title": f'Benutzer "{user.username}" bearbeiten',
|
||
}
|
||
|
||
return render(request, "stiftung/user_form.html", context)
|
||
|
||
|
||
@login_required
|
||
def user_change_password(request, pk):
|
||
"""Change user password"""
|
||
from django.contrib.auth.models import User
|
||
|
||
from stiftung.forms import PasswordChangeForm
|
||
|
||
# Check permission
|
||
if not request.user.has_perm("stiftung.manage_users"):
|
||
messages.error(
|
||
request, "Sie haben keine Berechtigung für die Benutzerverwaltung."
|
||
)
|
||
return redirect("stiftung:administration")
|
||
|
||
user = get_object_or_404(User, pk=pk)
|
||
|
||
if request.method == "POST":
|
||
form = PasswordChangeForm(request.POST)
|
||
if form.is_valid():
|
||
user.set_password(form.cleaned_data["new_password1"])
|
||
user.save()
|
||
|
||
# Log password change
|
||
from stiftung.audit import log_action
|
||
|
||
log_action(
|
||
request=request,
|
||
action="update",
|
||
entity_type="user",
|
||
entity_id=str(user.pk),
|
||
entity_name=user.username,
|
||
description=f'Passwort für Benutzer "{user.username}" wurde geändert',
|
||
)
|
||
|
||
messages.success(
|
||
request,
|
||
f'Passwort für Benutzer "{user.username}" wurde erfolgreich geändert.',
|
||
)
|
||
return redirect("stiftung:user_detail", pk=user.pk)
|
||
else:
|
||
form = PasswordChangeForm()
|
||
|
||
context = {
|
||
"form": form,
|
||
"user_obj": user,
|
||
"title": f'Passwort für "{user.username}" ändern',
|
||
}
|
||
|
||
return render(request, "stiftung/user_change_password.html", context)
|
||
|
||
|
||
@login_required
|
||
def user_permissions(request, pk):
|
||
"""Manage user permissions"""
|
||
from django.contrib.auth.models import Permission, User
|
||
|
||
from stiftung.forms import UserPermissionForm
|
||
|
||
# Check permission
|
||
if not request.user.has_perm("stiftung.manage_permissions"):
|
||
messages.error(
|
||
request, "Sie haben keine Berechtigung für die Berechtigungsverwaltung."
|
||
)
|
||
return redirect("stiftung:administration")
|
||
|
||
user = get_object_or_404(User, pk=pk)
|
||
|
||
if request.method == "POST":
|
||
form = UserPermissionForm(request.POST, user=user)
|
||
if form.is_valid():
|
||
# Get selected permissions
|
||
selected_perms = []
|
||
for field_name, value in form.cleaned_data.items():
|
||
if field_name.startswith("perm_") and value:
|
||
perm_id = field_name.replace("perm_", "")
|
||
selected_perms.append(int(perm_id))
|
||
|
||
# Get current stiftung permissions
|
||
current_perms = user.user_permissions.filter(
|
||
content_type__app_label="stiftung"
|
||
)
|
||
current_perm_ids = set(current_perms.values_list("id", flat=True))
|
||
selected_perm_ids = set(selected_perms)
|
||
|
||
# Remove permissions that are no longer selected
|
||
to_remove = current_perm_ids - selected_perm_ids
|
||
if to_remove:
|
||
user.user_permissions.remove(
|
||
*Permission.objects.filter(id__in=to_remove)
|
||
)
|
||
|
||
# Add new permissions
|
||
to_add = selected_perm_ids - current_perm_ids
|
||
if to_add:
|
||
user.user_permissions.add(*Permission.objects.filter(id__in=to_add))
|
||
|
||
# Log permission changes
|
||
from stiftung.audit import log_action
|
||
|
||
if to_remove or to_add:
|
||
changes = {
|
||
"removed_permissions": list(
|
||
Permission.objects.filter(id__in=to_remove).values_list(
|
||
"name", flat=True
|
||
)
|
||
),
|
||
"added_permissions": list(
|
||
Permission.objects.filter(id__in=to_add).values_list(
|
||
"name", flat=True
|
||
)
|
||
),
|
||
}
|
||
log_action(
|
||
request=request,
|
||
action="update",
|
||
entity_type="user",
|
||
entity_id=str(user.pk),
|
||
entity_name=user.username,
|
||
description=f'Berechtigungen für Benutzer "{user.username}" wurden aktualisiert',
|
||
changes=changes,
|
||
)
|
||
|
||
messages.success(
|
||
request,
|
||
f'Berechtigungen für Benutzer "{user.username}" wurden erfolgreich aktualisiert.',
|
||
)
|
||
return redirect("stiftung:user_detail", pk=user.pk)
|
||
else:
|
||
form = UserPermissionForm(user=user)
|
||
|
||
context = {
|
||
"form": form,
|
||
"user_obj": user,
|
||
"permission_groups": form.get_permission_groups(),
|
||
"title": f'Berechtigungen für "{user.username}"',
|
||
}
|
||
|
||
return render(request, "stiftung/user_permissions.html", context)
|
||
|
||
|
||
@login_required
|
||
def user_delete(request, pk):
|
||
"""Delete user"""
|
||
from django.contrib.auth.models import User
|
||
|
||
# Check permission
|
||
if not request.user.has_perm("stiftung.manage_users"):
|
||
messages.error(
|
||
request, "Sie haben keine Berechtigung für die Benutzerverwaltung."
|
||
)
|
||
return redirect("stiftung:administration")
|
||
|
||
user = get_object_or_404(User, pk=pk)
|
||
|
||
# Prevent deletion of current user
|
||
if user == request.user:
|
||
messages.error(request, "Sie können sich nicht selbst löschen.")
|
||
return redirect("stiftung:user_detail", pk=pk)
|
||
|
||
if request.method == "POST":
|
||
username = user.username
|
||
|
||
# Log deletion before deleting
|
||
from stiftung.audit import log_action
|
||
|
||
log_action(
|
||
request=request,
|
||
action="delete",
|
||
entity_type="user",
|
||
entity_id=str(user.pk),
|
||
entity_name=username,
|
||
description=f'Benutzer "{username}" wurde gelöscht',
|
||
)
|
||
|
||
user.delete()
|
||
|
||
messages.success(request, f'Benutzer "{username}" wurde erfolgreich gelöscht.')
|
||
return redirect("stiftung:user_management")
|
||
|
||
context = {"user_obj": user, "title": f'Benutzer "{user.username}" löschen'}
|
||
|
||
return render(request, "stiftung/user_delete.html", context)
|
||
|
||
|
||
# =============================================================================
|
||
# AUTHENTICATION VIEWS
|
||
# =============================================================================
|
||
|
||
|
||
def user_login(request):
|
||
"""User login view"""
|
||
from django.contrib.auth import authenticate, login
|
||
from django.contrib.auth.forms import AuthenticationForm
|
||
|
||
if request.user.is_authenticated:
|
||
return redirect("stiftung:home")
|
||
|
||
if request.method == "POST":
|
||
form = AuthenticationForm(request, data=request.POST)
|
||
if form.is_valid():
|
||
username = form.cleaned_data.get("username")
|
||
password = form.cleaned_data.get("password")
|
||
user = authenticate(username=username, password=password)
|
||
if user is not None:
|
||
login(request, user)
|
||
|
||
# Log the login
|
||
from stiftung.audit import log_login
|
||
|
||
log_login(request, user)
|
||
|
||
messages.success(request, f"Willkommen zurück, {user.username}!")
|
||
|
||
# Redirect to safe next URL path or home
|
||
next_param = request.GET.get("next") or request.POST.get("next")
|
||
if next_param and next_param.startswith("/"):
|
||
return redirect(next_param)
|
||
return redirect("stiftung:home")
|
||
else:
|
||
messages.error(request, "Ungültige Anmeldedaten.")
|
||
else:
|
||
messages.error(request, "Bitte korrigieren Sie die Fehler im Formular.")
|
||
else:
|
||
form = AuthenticationForm()
|
||
|
||
context = {"form": form, "next": request.GET.get("next", "")}
|
||
|
||
return render(request, "stiftung/login.html", context)
|
||
|
||
|
||
@login_required
|
||
def user_logout(request):
|
||
"""User logout view"""
|
||
from django.contrib.auth import logout
|
||
|
||
# Log the logout before actually logging out
|
||
from stiftung.audit import log_logout
|
||
|
||
log_logout(request, request.user)
|
||
|
||
username = request.user.username
|
||
logout(request)
|
||
|
||
messages.success(request, f"Sie wurden erfolgreich abgemeldet, {username}.")
|
||
return redirect("stiftung:login")
|
||
|
||
|
||
# ============================================================================
|
||
# LANDABRECHNUNGS VIEWS
|
||
# ============================================================================
|
||
|
||
|
||
@login_required
|
||
def land_abrechnung_list(request):
|
||
"""Liste aller Landabrechnungen"""
|
||
abrechnungen = LandAbrechnung.objects.select_related("land").all()
|
||
|
||
# Filter
|
||
jahr_filter = request.GET.get("jahr")
|
||
land_filter = request.GET.get("land")
|
||
|
||
if jahr_filter:
|
||
abrechnungen = abrechnungen.filter(abrechnungsjahr=jahr_filter)
|
||
if land_filter:
|
||
abrechnungen = abrechnungen.filter(land__pk=land_filter)
|
||
|
||
# Pagination
|
||
paginator = Paginator(abrechnungen, 20)
|
||
page_number = request.GET.get("page")
|
||
abrechnungen = paginator.get_page(page_number)
|
||
|
||
# Statistiken
|
||
stats = LandAbrechnung.objects.aggregate(
|
||
total_einnahmen=Sum("pacht_vereinnahmt"),
|
||
total_ausgaben=Sum("grundsteuer_betrag"),
|
||
anzahl_abrechnungen=Count("id"),
|
||
)
|
||
|
||
context = {
|
||
"abrechnungen": abrechnungen,
|
||
"stats": stats,
|
||
"jahre": LandAbrechnung.objects.values_list("abrechnungsjahr", flat=True)
|
||
.distinct()
|
||
.order_by("-abrechnungsjahr"),
|
||
"laendereien": Land.objects.filter(aktiv=True).order_by(
|
||
"gemeinde", "gemarkung"
|
||
),
|
||
"jahr_filter": jahr_filter,
|
||
"land_filter": land_filter,
|
||
}
|
||
|
||
return render(request, "stiftung/land_abrechnung_list.html", context)
|
||
|
||
|
||
@login_required
|
||
def land_abrechnung_detail(request, pk):
|
||
"""Detail-Ansicht einer Landabrechnung"""
|
||
abrechnung = get_object_or_404(LandAbrechnung, pk=pk)
|
||
|
||
context = {
|
||
"abrechnung": abrechnung,
|
||
"land": abrechnung.land,
|
||
}
|
||
|
||
return render(request, "stiftung/land_abrechnung_detail.html", context)
|
||
|
||
|
||
@login_required
|
||
def land_abrechnung_create(request):
|
||
"""Neue Landabrechnung erstellen"""
|
||
from .forms import LandAbrechnungForm
|
||
|
||
land_pk = request.GET.get("land")
|
||
initial = {}
|
||
land = None
|
||
|
||
if land_pk:
|
||
land = get_object_or_404(Land, pk=land_pk)
|
||
initial["land"] = land
|
||
initial["abrechnungsjahr"] = datetime.now().year
|
||
|
||
# Automatische Vorausfüllung aus Verpachtungsdaten
|
||
if land.pachtzins_pauschal:
|
||
initial["pacht_vereinnahmt"] = land.pachtzins_pauschal
|
||
|
||
if request.method == "POST":
|
||
form = LandAbrechnungForm(request.POST, request.FILES)
|
||
if form.is_valid():
|
||
abrechnung = form.save()
|
||
messages.success(
|
||
request,
|
||
f"Landabrechnung für {abrechnung.land} ({abrechnung.abrechnungsjahr}) wurde erfolgreich erstellt.",
|
||
)
|
||
return redirect("stiftung:land_abrechnung_detail", pk=abrechnung.pk)
|
||
else:
|
||
form = LandAbrechnungForm(initial=initial)
|
||
|
||
context = {
|
||
"form": form,
|
||
"title": "Neue Landabrechnung",
|
||
"land": land,
|
||
}
|
||
|
||
return render(request, "stiftung/land_abrechnung_form.html", context)
|
||
|
||
|
||
@login_required
|
||
def land_abrechnung_update(request, pk):
|
||
"""Landabrechnung bearbeiten"""
|
||
from .forms import LandAbrechnungForm
|
||
|
||
abrechnung = get_object_or_404(LandAbrechnung, pk=pk)
|
||
|
||
if request.method == "POST":
|
||
form = LandAbrechnungForm(request.POST, request.FILES, instance=abrechnung)
|
||
if form.is_valid():
|
||
abrechnung = form.save()
|
||
messages.success(
|
||
request,
|
||
f"Landabrechnung für {abrechnung.land} ({abrechnung.abrechnungsjahr}) wurde erfolgreich aktualisiert.",
|
||
)
|
||
return redirect("stiftung:land_abrechnung_detail", pk=abrechnung.pk)
|
||
else:
|
||
form = LandAbrechnungForm(instance=abrechnung)
|
||
|
||
context = {
|
||
"form": form,
|
||
"abrechnung": abrechnung,
|
||
"title": f"Abrechnung bearbeiten - {abrechnung.land} ({abrechnung.abrechnungsjahr})",
|
||
}
|
||
|
||
return render(request, "stiftung/land_abrechnung_form.html", context)
|
||
|
||
|
||
@login_required
|
||
def land_abrechnung_delete(request, pk):
|
||
"""Landabrechnung löschen"""
|
||
abrechnung = get_object_or_404(LandAbrechnung, pk=pk)
|
||
land = abrechnung.land
|
||
|
||
if request.method == "POST":
|
||
abrechnung.delete()
|
||
messages.success(
|
||
request,
|
||
f"Landabrechnung für {land} ({abrechnung.abrechnungsjahr}) wurde erfolgreich gelöscht.",
|
||
)
|
||
return redirect("stiftung:land_detail", pk=land.pk)
|
||
|
||
context = {
|
||
"abrechnung": abrechnung,
|
||
"land": land,
|
||
}
|
||
|
||
return render(request, "stiftung/land_abrechnung_confirm_delete.html", context)
|
||
|
||
|
||
# ============================================================================
|
||
# VEREINHEITLICHTE VERPACHTUNGS VIEWS
|
||
# ============================================================================
|
||
|
||
|
||
@login_required
|
||
def land_verpachtung_create(request, land_pk):
|
||
"""Erstelle eine neue Verpachtung direkt im Land-Model"""
|
||
from datetime import datetime as dt
|
||
|
||
land = get_object_or_404(Land, pk=land_pk)
|
||
|
||
if request.method == "POST":
|
||
# Einfaches Formular für die wichtigsten Verpachtungsfelder
|
||
aktueller_paechter_id = request.POST.get("aktueller_paechter")
|
||
pachtbeginn = request.POST.get("pachtbeginn")
|
||
pachtende = request.POST.get("pachtende")
|
||
pachtzins_pauschal = request.POST.get("pachtzins_pauschal")
|
||
zahlungsweise = request.POST.get("zahlungsweise")
|
||
ust_option = request.POST.get("ust_option") == "on"
|
||
|
||
if aktueller_paechter_id and pachtbeginn:
|
||
paechter = get_object_or_404(Paechter, pk=aktueller_paechter_id)
|
||
verpachtete_flaeche = request.POST.get("verpachtete_flaeche")
|
||
|
||
# Validiere verpachtete Fläche
|
||
if not verpachtete_flaeche:
|
||
verpachtete_flaeche = land.groesse_qm # Standard: gesamte Fläche
|
||
else:
|
||
verpachtete_flaeche = float(verpachtete_flaeche)
|
||
if verpachtete_flaeche > land.groesse_qm:
|
||
messages.error(
|
||
request,
|
||
f"Die verpachtete Fläche ({verpachtete_flaeche} qm) kann nicht größer als die Gesamtfläche ({land.groesse_qm} qm) sein.",
|
||
)
|
||
# Erstelle context für Fehlerfall
|
||
paechter_list = Paechter.objects.filter(aktiv=True).order_by(
|
||
"nachname", "vorname"
|
||
)
|
||
verfuegbare_flaeche = land.groesse_qm
|
||
if land.verp_flaeche_aktuell and land.verp_flaeche_aktuell > 0:
|
||
verfuegbare_flaeche = (
|
||
land.groesse_qm - land.verp_flaeche_aktuell
|
||
)
|
||
|
||
context = {
|
||
"land": land,
|
||
"paechter_list": paechter_list,
|
||
"current_year": dt.now().year,
|
||
"is_edit": False,
|
||
"verfuegbare_flaeche": verfuegbare_flaeche,
|
||
}
|
||
return render(
|
||
request, "stiftung/land_verpachtung_form.html", context
|
||
)
|
||
|
||
# Land aktualisieren
|
||
land.aktueller_paechter = paechter
|
||
land.paechter_name = paechter.get_full_name()
|
||
land.paechter_anschrift = f"{paechter.strasse or ''}\n{paechter.plz or ''} {paechter.ort or ''}".strip()
|
||
land.pachtbeginn = pachtbeginn
|
||
land.pachtende = pachtende if pachtende else None
|
||
land.pachtzins_pauschal = pachtzins_pauschal if pachtzins_pauschal else None
|
||
land.zahlungsweise = zahlungsweise
|
||
land.ust_option = ust_option
|
||
land.verp_flaeche_aktuell = verpachtete_flaeche
|
||
land.verpachtete_gesamtflaeche = verpachtete_flaeche
|
||
land.save()
|
||
|
||
# Erstelle LandVerpachtung-Objekt für bessere Nachverfolgung
|
||
land_verpachtung = LandVerpachtung.objects.create(
|
||
land=land,
|
||
paechter=paechter,
|
||
vertragsnummer=f"V-{land.lfd_nr}-{dt.now().year}",
|
||
pachtbeginn=pachtbeginn,
|
||
pachtende=pachtende if pachtende else None,
|
||
verpachtete_flaeche=verpachtete_flaeche,
|
||
pachtzins_pauschal=pachtzins_pauschal if pachtzins_pauschal else 0,
|
||
zahlungsweise=zahlungsweise,
|
||
ust_option=ust_option,
|
||
status="aktiv",
|
||
)
|
||
|
||
# Erstelle automatisch eine Abrechnung für das aktuelle Jahr
|
||
current_year = dt.now().year
|
||
|
||
# Berechne erwartete jährliche Pacht basierend auf Zahlungsweise
|
||
expected_annual_rent = pachtzins_pauschal if pachtzins_pauschal else 0
|
||
|
||
abrechnung, created = LandAbrechnung.objects.get_or_create(
|
||
land=land,
|
||
abrechnungsjahr=current_year,
|
||
defaults={
|
||
"pacht_vereinnahmt": expected_annual_rent, # Setze erwartete Jahrespacht
|
||
"umlagen_vereinnahmt": 0,
|
||
"grundsteuer_betrag": 0,
|
||
"versicherungen_betrag": 0,
|
||
},
|
||
)
|
||
|
||
# Falls Abrechnung bereits existiert, aktualisiere die Pacht wenn höher
|
||
if not created and expected_annual_rent > abrechnung.pacht_vereinnahmt:
|
||
abrechnung.pacht_vereinnahmt = expected_annual_rent
|
||
abrechnung.save()
|
||
|
||
success_msg = f"Verpachtung von {land} an {paechter.get_full_name()} wurde erfolgreich erstellt."
|
||
if created:
|
||
success_msg += (
|
||
f" Abrechnung für {current_year} wurde automatisch angelegt"
|
||
)
|
||
if expected_annual_rent > 0:
|
||
success_msg += f" (Erwartete Jahrespacht: {expected_annual_rent}€)"
|
||
success_msg += "."
|
||
elif expected_annual_rent > 0:
|
||
success_msg += f" Erwartete Jahrespacht in Abrechnung {current_year} wurde aktualisiert ({expected_annual_rent}€)."
|
||
|
||
messages.success(request, success_msg)
|
||
return redirect("stiftung:land_detail", pk=land.pk)
|
||
else:
|
||
messages.error(request, "Bitte füllen Sie alle Pflichtfelder aus.")
|
||
|
||
# Verfügbare Pächter
|
||
paechter_list = Paechter.objects.filter(aktiv=True).order_by("nachname", "vorname")
|
||
|
||
# Berechne verfügbare Fläche (Gesamtfläche minus bereits verpachtete Fläche)
|
||
verfuegbare_flaeche = land.groesse_qm
|
||
if land.verp_flaeche_aktuell and land.verp_flaeche_aktuell > 0:
|
||
verfuegbare_flaeche = land.groesse_qm - land.verp_flaeche_aktuell
|
||
|
||
context = {
|
||
"land": land,
|
||
"paechter_list": paechter_list,
|
||
"current_year": dt.now().year,
|
||
"is_edit": False,
|
||
"verfuegbare_flaeche": verfuegbare_flaeche,
|
||
}
|
||
|
||
return render(request, "stiftung/land_verpachtung_form.html", context)
|
||
|
||
|
||
@login_required
|
||
def land_verpachtung_end(request, land_pk):
|
||
"""Beende die aktuelle Verpachtung eines Landes"""
|
||
land = get_object_or_404(Land, pk=land_pk)
|
||
|
||
if request.method == "POST":
|
||
# Verpachtung beenden
|
||
land.aktueller_paechter = None
|
||
land.paechter_name = None
|
||
land.paechter_anschrift = None
|
||
land.pachtende = datetime.now().date()
|
||
land.save()
|
||
|
||
messages.success(request, f"Verpachtung von {land} wurde beendet.")
|
||
return redirect("stiftung:land_detail", pk=land.pk)
|
||
|
||
context = {
|
||
"land": land,
|
||
}
|
||
|
||
return render(request, "stiftung/land_verpachtung_end.html", context)
|
||
|
||
|
||
@login_required
|
||
def land_verpachtung_edit(request, land_pk):
|
||
"""Bearbeite eine bestehende Verpachtung direkt im Land-Model"""
|
||
land = get_object_or_404(Land, pk=land_pk)
|
||
|
||
if request.method == "POST":
|
||
# Einfaches Formular für die wichtigsten Verpachtungsfelder
|
||
aktueller_paechter_id = request.POST.get("aktueller_paechter")
|
||
pachtbeginn = request.POST.get("pachtbeginn")
|
||
pachtende = request.POST.get("pachtende")
|
||
pachtzins_pauschal = request.POST.get("pachtzins_pauschal")
|
||
zahlungsweise = request.POST.get("zahlungsweise")
|
||
ust_option = request.POST.get("ust_option") == "on"
|
||
verpachtete_flaeche = request.POST.get("verpachtete_flaeche")
|
||
|
||
if aktueller_paechter_id and pachtbeginn:
|
||
paechter = get_object_or_404(Paechter, pk=aktueller_paechter_id)
|
||
|
||
# Land aktualisieren
|
||
land.aktueller_paechter = paechter
|
||
land.paechter_name = paechter.get_full_name()
|
||
land.paechter_anschrift = f"{paechter.strasse or ''}\n{paechter.plz or ''} {paechter.ort or ''}".strip()
|
||
land.pachtbeginn = pachtbeginn
|
||
land.pachtende = pachtende if pachtende else None
|
||
land.pachtzins_pauschal = pachtzins_pauschal if pachtzins_pauschal else None
|
||
land.zahlungsweise = zahlungsweise
|
||
land.ust_option = ust_option
|
||
if verpachtete_flaeche:
|
||
land.verp_flaeche_aktuell = verpachtete_flaeche
|
||
land.save()
|
||
|
||
messages.success(
|
||
request,
|
||
f"Verpachtung von {land} an {paechter.get_full_name()} wurde erfolgreich aktualisiert.",
|
||
)
|
||
return redirect("stiftung:land_detail", pk=land.pk)
|
||
else:
|
||
messages.error(request, "Bitte füllen Sie alle Pflichtfelder aus.")
|
||
|
||
# Verfügbare Pächter
|
||
paechter_list = Paechter.objects.filter(aktiv=True).order_by("nachname", "vorname")
|
||
|
||
# Berechne verfügbare Fläche (Gesamtfläche minus bereits verpachtete Fläche)
|
||
verfuegbare_flaeche = land.groesse_qm
|
||
if land.verp_flaeche_aktuell and land.verp_flaeche_aktuell > 0:
|
||
verfuegbare_flaeche = land.groesse_qm - land.verp_flaeche_aktuell
|
||
|
||
context = {
|
||
"land": land,
|
||
"paechter_list": paechter_list,
|
||
"current_year": datetime.now().year,
|
||
"is_edit": True,
|
||
"verfuegbare_flaeche": verfuegbare_flaeche,
|
||
}
|
||
|
||
return render(request, "stiftung/land_verpachtung_form.html", context)
|
||
|
||
|
||
# Settings Management Views
|
||
@login_required
|
||
def app_settings(request):
|
||
"""Application settings management interface"""
|
||
|
||
# Group settings by category
|
||
categories = {}
|
||
for setting in AppConfiguration.objects.filter(is_active=True).order_by(
|
||
"category", "order", "display_name"
|
||
):
|
||
if setting.category not in categories:
|
||
categories[setting.category] = []
|
||
categories[setting.category].append(setting)
|
||
|
||
if request.method == "POST":
|
||
# Handle form submission
|
||
updated_count = 0
|
||
for key, value in request.POST.items():
|
||
if key.startswith("setting_"):
|
||
setting_key = key.replace("setting_", "")
|
||
try:
|
||
setting = AppConfiguration.objects.get(
|
||
key=setting_key, is_active=True
|
||
)
|
||
if not setting.is_system and setting.value != value:
|
||
setting.value = value
|
||
setting.save()
|
||
updated_count += 1
|
||
except AppConfiguration.DoesNotExist:
|
||
continue
|
||
|
||
if updated_count > 0:
|
||
messages.success(request, f"Successfully updated {updated_count} settings!")
|
||
else:
|
||
messages.info(request, "No changes were made.")
|
||
|
||
return redirect("stiftung:app_settings")
|
||
|
||
context = {
|
||
"categories": categories,
|
||
"title": "Application Settings",
|
||
}
|
||
return render(request, "stiftung/app_settings.html", context)
|
||
|
||
|
||
# Unterstützungen Views (Destinataer-focused)
|
||
@login_required
|
||
def unterstuetzungen_all(request):
|
||
"""List all support payments - destinataer-focused view"""
|
||
status = request.GET.get("status")
|
||
destinataer_id = request.GET.get("destinataer")
|
||
export = request.GET.get("format", "")
|
||
selected_ids = (
|
||
request.POST.getlist("selected_entries") if request.method == "POST" else []
|
||
)
|
||
|
||
unterstuetzungen = DestinataerUnterstuetzung.objects.select_related(
|
||
"destinataer", "konto", "ausgezahlt_von", "wiederkehrend_von"
|
||
).order_by("-faellig_am")
|
||
|
||
# Filtering
|
||
if status:
|
||
unterstuetzungen = unterstuetzungen.filter(status=status)
|
||
if destinataer_id:
|
||
unterstuetzungen = unterstuetzungen.filter(destinataer_id=destinataer_id)
|
||
|
||
# Enhanced CSV export with field selection
|
||
if export == "csv":
|
||
return export_unterstuetzungen_csv(request, unterstuetzungen, selected_ids)
|
||
|
||
# PDF export (simple table via WeasyPrint; graceful fallback if missing)
|
||
if export == "pdf":
|
||
try:
|
||
from django.template.loader import render_to_string
|
||
from weasyprint import HTML
|
||
|
||
html = render_to_string(
|
||
"stiftung/unterstuetzungen_pdf.html",
|
||
{"unterstuetzungen": unterstuetzungen},
|
||
)
|
||
from django.http import HttpResponse
|
||
|
||
pdf = HTML(string=html).write_pdf()
|
||
resp = HttpResponse(pdf, content_type="application/pdf")
|
||
resp["Content-Disposition"] = "inline; filename=unterstuetzungen.pdf"
|
||
return resp
|
||
except Exception:
|
||
pass
|
||
|
||
# Statistics
|
||
total_betrag = unterstuetzungen.aggregate(total=Sum("betrag"))["total"] or 0
|
||
|
||
# Get quarterly confirmation statistics
|
||
quarterly_stats = {}
|
||
total_quarterly = VierteljahresNachweis.objects.count()
|
||
for status_code, status_name in VierteljahresNachweis.STATUS_CHOICES:
|
||
count = VierteljahresNachweis.objects.filter(status=status_code).count()
|
||
quarterly_stats[status_code] = {
|
||
'name': status_name,
|
||
'count': count
|
||
}
|
||
|
||
# Available destinataer for filter
|
||
destinataer = Destinataer.objects.all().order_by("nachname", "vorname")
|
||
|
||
context = {
|
||
"page_obj": unterstuetzungen, # Use directly for now (pagination can be added later)
|
||
"unterstuetzungen": unterstuetzungen,
|
||
"title": "Alle Unterstützungen",
|
||
"status_filter": status,
|
||
"total_betrag": total_betrag,
|
||
"quarterly_stats": quarterly_stats,
|
||
"total_quarterly": total_quarterly,
|
||
"status_choices": DestinataerUnterstuetzung.STATUS_CHOICES,
|
||
"destinataer": destinataer,
|
||
}
|
||
return render(request, "stiftung/unterstuetzungen_all.html", context)
|
||
|
||
|
||
@login_required
|
||
def unterstuetzung_create(request):
|
||
"""Create a new support payment"""
|
||
# Get destinataer from URL parameter if provided
|
||
destinataer_id = request.GET.get("destinataer")
|
||
initial = {}
|
||
if destinataer_id:
|
||
initial["destinataer"] = destinataer_id
|
||
# Pre-populate IBAN and name if destinataer is specified
|
||
try:
|
||
destinataer = Destinataer.objects.get(pk=destinataer_id)
|
||
if hasattr(destinataer, "iban") and destinataer.iban:
|
||
initial["empfaenger_iban"] = destinataer.iban
|
||
initial["empfaenger_name"] = destinataer.get_full_name()
|
||
except Destinataer.DoesNotExist:
|
||
pass
|
||
|
||
if request.method == "POST":
|
||
form = UnterstuetzungForm(request.POST)
|
||
if form.is_valid():
|
||
ist_wiederkehrend = form.cleaned_data.get("ist_wiederkehrend", False)
|
||
|
||
if ist_wiederkehrend:
|
||
# Create recurring payment template
|
||
wiederkehrend = UnterstuetzungWiederkehrend.objects.create(
|
||
destinataer=form.cleaned_data["destinataer"],
|
||
konto=form.cleaned_data["konto"],
|
||
betrag=form.cleaned_data["betrag"],
|
||
intervall=form.cleaned_data["intervall"],
|
||
beschreibung=form.cleaned_data["beschreibung"],
|
||
empfaenger_iban=form.cleaned_data["empfaenger_iban"],
|
||
empfaenger_name=form.cleaned_data["empfaenger_name"],
|
||
verwendungszweck=form.cleaned_data["verwendungszweck"],
|
||
erste_zahlung_am=form.cleaned_data["faellig_am"],
|
||
letzte_zahlung_am=form.cleaned_data.get("letzte_zahlung_am"),
|
||
naechste_generierung=form.cleaned_data["faellig_am"],
|
||
erstellt_von=request.user,
|
||
)
|
||
|
||
# Create the first payment
|
||
unterstuetzung = form.save(commit=False)
|
||
unterstuetzung.wiederkehrend_von = wiederkehrend
|
||
unterstuetzung.save()
|
||
|
||
messages.success(
|
||
request,
|
||
f"Wiederkehrende Unterstützung für {unterstuetzung.destinataer} wurde erfolgreich erstellt. Die erste Zahlung ist am {unterstuetzung.faellig_am} fällig.",
|
||
)
|
||
else:
|
||
# Create single payment
|
||
unterstuetzung = form.save()
|
||
messages.success(
|
||
request,
|
||
f"Unterstützung für {unterstuetzung.destinataer} wurde erfolgreich erstellt.",
|
||
)
|
||
|
||
return redirect("stiftung:unterstuetzung_detail", pk=unterstuetzung.pk)
|
||
else:
|
||
form = UnterstuetzungForm(initial=initial)
|
||
|
||
context = {
|
||
"form": form,
|
||
"title": "Neue Unterstützung erstellen",
|
||
}
|
||
return render(request, "stiftung/unterstuetzung_form.html", context)
|
||
|
||
|
||
@login_required
|
||
def get_destinataer_info(request, destinataer_id):
|
||
"""AJAX endpoint to get Destinataer IBAN and name information"""
|
||
try:
|
||
destinataer = Destinataer.objects.get(pk=destinataer_id)
|
||
data = {
|
||
"success": True,
|
||
"name": destinataer.get_full_name(),
|
||
"iban": getattr(destinataer, "iban", "") or "",
|
||
}
|
||
except Destinataer.DoesNotExist:
|
||
data = {"success": False, "error": "Destinataer not found"}
|
||
|
||
return JsonResponse(data)
|
||
|
||
|
||
@login_required
|
||
def unterstuetzung_detail(request, pk):
|
||
"""View support payment details"""
|
||
unterstuetzung = get_object_or_404(DestinataerUnterstuetzung, pk=pk)
|
||
|
||
# Check if this payment can be marked as paid
|
||
can_mark_paid = unterstuetzung.can_be_marked_paid()
|
||
|
||
context = {
|
||
"unterstuetzung": unterstuetzung,
|
||
"title": f"Unterstützung für {unterstuetzung.destinataer.get_full_name()}",
|
||
"can_mark_paid": can_mark_paid,
|
||
}
|
||
return render(request, "stiftung/unterstuetzung_detail.html", context)
|
||
|
||
|
||
@login_required
|
||
def unterstuetzung_mark_paid(request, pk):
|
||
"""Mark a support payment as paid"""
|
||
unterstuetzung = get_object_or_404(DestinataerUnterstuetzung, pk=pk)
|
||
|
||
if not unterstuetzung.can_be_marked_paid():
|
||
messages.error(
|
||
request, "Diese Unterstützung kann nicht als bezahlt markiert werden."
|
||
)
|
||
return redirect("stiftung:unterstuetzung_detail", pk=pk)
|
||
|
||
if request.method == "POST":
|
||
form = UnterstuetzungMarkAsPaidForm(request.POST)
|
||
if form.is_valid():
|
||
unterstuetzung.status = "ausgezahlt"
|
||
unterstuetzung.ausgezahlt_am = form.cleaned_data["ausgezahlt_am"]
|
||
unterstuetzung.ausgezahlt_von = request.user
|
||
|
||
# Add optional note to description
|
||
bemerkung = form.cleaned_data.get("bemerkung")
|
||
if bemerkung:
|
||
if unterstuetzung.beschreibung:
|
||
unterstuetzung.beschreibung += f" | Zahlung: {bemerkung}"
|
||
else:
|
||
unterstuetzung.beschreibung = f"Zahlung: {bemerkung}"
|
||
|
||
unterstuetzung.save()
|
||
messages.success(request, f"Unterstützung wurde als bezahlt markiert.")
|
||
return redirect("stiftung:unterstuetzung_detail", pk=pk)
|
||
else:
|
||
form = UnterstuetzungMarkAsPaidForm()
|
||
|
||
context = {
|
||
"form": form,
|
||
"unterstuetzung": unterstuetzung,
|
||
"title": f"Zahlung markieren - {unterstuetzung.destinataer.get_full_name()}",
|
||
}
|
||
return render(request, "stiftung/unterstuetzung_mark_paid.html", context)
|
||
|
||
|
||
@login_required
|
||
def wiederkehrende_unterstuetzungen(request):
|
||
"""List all recurring support payment templates"""
|
||
from django.db.models import Count
|
||
|
||
# Check for cleanup request
|
||
if request.GET.get("cleanup") == "1":
|
||
# Find templates with no associated payments
|
||
verwaiste_templates = UnterstuetzungWiederkehrend.objects.annotate(
|
||
zahlung_count=Count("destinataerunterstuetzung")
|
||
).filter(zahlung_count=0)
|
||
|
||
if verwaiste_templates.exists():
|
||
anzahl_geloescht = verwaiste_templates.count()
|
||
template_namen = list(
|
||
verwaiste_templates.values_list("destinataer__nachname", flat=True)
|
||
)
|
||
verwaiste_templates.delete()
|
||
messages.success(
|
||
request,
|
||
f'{anzahl_geloescht} verwaiste Zahlungsvorlagen bereinigt: {", ".join(template_namen[:5])}{"..." if len(template_namen) > 5 else ""}',
|
||
)
|
||
else:
|
||
messages.info(request, "Keine verwaisten Zahlungsvorlagen gefunden.")
|
||
|
||
return redirect("stiftung:wiederkehrende_unterstuetzungen")
|
||
|
||
# Get all templates with payment counts
|
||
templates = (
|
||
UnterstuetzungWiederkehrend.objects.select_related("destinataer", "konto")
|
||
.annotate(aktive_zahlungen=Count("destinataerunterstuetzung"))
|
||
.all()
|
||
)
|
||
|
||
context = {
|
||
"templates": templates,
|
||
"title": "Wiederkehrende Unterstützungen",
|
||
}
|
||
return render(request, "stiftung/wiederkehrende_unterstuetzungen.html", context)
|
||
|
||
|
||
@login_required
|
||
def edit_help_box(request):
|
||
"""Bearbeite oder erstelle eine Hilfs-Infobox"""
|
||
from .models import HelpBox
|
||
|
||
# Nur root oder Superuser dürfen bearbeiten
|
||
if request.user.username != "root" and not request.user.is_superuser:
|
||
messages.error(
|
||
request, "Sie haben keine Berechtigung, Hilfsboxen zu bearbeiten."
|
||
)
|
||
return redirect("stiftung:home")
|
||
|
||
if request.method == "POST":
|
||
page_key = request.POST.get("page_key")
|
||
title = request.POST.get("title")
|
||
content = request.POST.get("content")
|
||
is_active = request.POST.get("is_active") == "on"
|
||
|
||
if not page_key or not title or not content:
|
||
messages.error(request, "Alle Felder sind erforderlich.")
|
||
return redirect(request.META.get("HTTP_REFERER", "stiftung:home"))
|
||
|
||
# Hilfsbox erstellen oder aktualisieren
|
||
help_box, created = HelpBox.objects.get_or_create(
|
||
page_key=page_key,
|
||
defaults={
|
||
"title": title,
|
||
"content": content,
|
||
"is_active": is_active,
|
||
"created_by": request.user.username,
|
||
"updated_by": request.user.username,
|
||
},
|
||
)
|
||
|
||
if not created:
|
||
# Existierende Hilfsbox aktualisieren
|
||
help_box.title = title
|
||
help_box.content = content
|
||
help_box.is_active = is_active
|
||
help_box.updated_by = request.user.username
|
||
help_box.save()
|
||
|
||
messages.success(request, f'Hilfsbox "{title}" wurde aktualisiert.')
|
||
else:
|
||
messages.success(request, f'Hilfsbox "{title}" wurde erstellt.')
|
||
|
||
# Zurück zur vorherigen Seite
|
||
return redirect(request.META.get("HTTP_REFERER", "stiftung:home"))
|
||
|
||
# GET Request - Zeige Admin-Übersicht der Hilfsboxen
|
||
help_boxes = HelpBox.objects.all().order_by("page_key", "-updated_at")
|
||
|
||
# Statistiken berechnen
|
||
active_count = help_boxes.filter(is_active=True).count()
|
||
inactive_count = help_boxes.filter(is_active=False).count()
|
||
existing_pages = set(help_boxes.values_list("page_key", flat=True))
|
||
|
||
# Verfügbare Seiten aus dem Model holen
|
||
available_pages = HelpBox.PAGE_CHOICES
|
||
|
||
context = {
|
||
"help_boxes": help_boxes,
|
||
"active_count": active_count,
|
||
"inactive_count": inactive_count,
|
||
"existing_pages": existing_pages,
|
||
"available_pages": available_pages,
|
||
"title": "Hilfs-Infoboxen verwalten",
|
||
}
|
||
return render(request, "stiftung/help_boxes_admin.html", context)
|
||
|
||
|
||
# =============================================================================
|
||
# Verpachtung Management Views (Standalone CRUD)
|
||
# =============================================================================
|
||
|
||
@login_required
|
||
def verpachtung_detail(request, pk):
|
||
"""Standalone detail view for verpachtung"""
|
||
verpachtung = get_object_or_404(LandVerpachtung, pk=pk)
|
||
|
||
# Alle mit dieser Verpachtung verknüpften Dokumente laden
|
||
verknuepfte_dokumente = DokumentLink.objects.filter(
|
||
land_verpachtung_id=verpachtung.pk
|
||
).order_by("kontext", "titel")
|
||
|
||
context = {
|
||
"verpachtung": verpachtung,
|
||
"landverpachtung": verpachtung, # Template compatibility
|
||
"verknuepfte_dokumente": verknuepfte_dokumente,
|
||
"title": f"Verpachtung {verpachtung.vertragsnummer}",
|
||
}
|
||
return render(request, "stiftung/verpachtung_detail.html", context)
|
||
|
||
|
||
@login_required
|
||
def verpachtung_create(request):
|
||
"""Standalone create view for verpachtung"""
|
||
from .forms import LandVerpachtungForm
|
||
from datetime import datetime as dt
|
||
|
||
if request.method == 'POST':
|
||
form = LandVerpachtungForm(request.POST, request.FILES)
|
||
if form.is_valid():
|
||
verpachtung = form.save()
|
||
|
||
# Update the Land model to reflect this verpachtung
|
||
land = verpachtung.land
|
||
land.aktueller_paechter = verpachtung.paechter
|
||
land.paechter_name = verpachtung.paechter.get_full_name()
|
||
land.paechter_anschrift = f"{verpachtung.paechter.strasse or ''}\n{verpachtung.paechter.plz or ''} {verpachtung.paechter.ort or ''}".strip()
|
||
land.pachtbeginn = verpachtung.pachtbeginn
|
||
land.pachtende = verpachtung.pachtende
|
||
land.pachtzins_pauschal = verpachtung.pachtzins_pauschal
|
||
land.zahlungsweise = verpachtung.zahlungsweise
|
||
land.ust_option = verpachtung.ust_option
|
||
land.verpachtete_gesamtflaeche = verpachtung.verpachtete_flaeche
|
||
land.verp_flaeche_aktuell = verpachtung.verpachtete_flaeche
|
||
land.save()
|
||
|
||
# Create automatic abrechnung
|
||
current_year = dt.now().year
|
||
expected_annual_rent = verpachtung.pachtzins_pauschal if verpachtung.pachtzins_pauschal else 0
|
||
|
||
abrechnung, created = LandAbrechnung.objects.get_or_create(
|
||
land=land,
|
||
abrechnungsjahr=current_year,
|
||
defaults={
|
||
"pacht_vereinnahmt": expected_annual_rent,
|
||
"umlagen_vereinnahmt": 0,
|
||
"grundsteuer_betrag": 0,
|
||
"versicherungen_betrag": 0,
|
||
},
|
||
)
|
||
|
||
if not created and expected_annual_rent > abrechnung.pacht_vereinnahmt:
|
||
abrechnung.pacht_vereinnahmt = expected_annual_rent
|
||
abrechnung.save()
|
||
|
||
success_msg = f'Verpachtung "{verpachtung.vertragsnummer}" wurde erfolgreich erstellt.'
|
||
if created:
|
||
success_msg += f" Abrechnung für {current_year} wurde automatisch angelegt"
|
||
if expected_annual_rent > 0:
|
||
success_msg += f" (Erwartete Jahrespacht: {expected_annual_rent}€)"
|
||
success_msg += "."
|
||
elif expected_annual_rent > 0:
|
||
success_msg += f" Erwartete Jahrespacht in Abrechnung {current_year} wurde aktualisiert ({expected_annual_rent}€)."
|
||
|
||
messages.success(request, success_msg)
|
||
return redirect('stiftung:verpachtung_detail', pk=verpachtung.pk)
|
||
else:
|
||
form = LandVerpachtungForm()
|
||
|
||
# Get available Länder and Pächter for the template
|
||
laender_list = Land.objects.all().order_by('lfd_nr')
|
||
paechter_list = Paechter.objects.filter(aktiv=True).order_by('nachname', 'vorname')
|
||
|
||
context = {
|
||
'form': form,
|
||
'title': 'Neue Verpachtung erstellen',
|
||
'laender_list': laender_list,
|
||
'paechter_list': paechter_list,
|
||
'current_year': dt.now().year,
|
||
'is_edit': False,
|
||
}
|
||
return render(request, 'stiftung/verpachtung_form.html', context)
|
||
|
||
|
||
@login_required
|
||
def verpachtung_update(request, pk):
|
||
"""Standalone update view for verpachtung"""
|
||
return land_verpachtung_update(request, pk)
|
||
|
||
|
||
@login_required
|
||
def verpachtung_delete(request, pk):
|
||
"""Standalone delete view for verpachtung"""
|
||
verpachtung = get_object_or_404(LandVerpachtung, pk=pk)
|
||
|
||
if request.method == 'POST':
|
||
vertragsnummer = verpachtung.vertragsnummer
|
||
verpachtung.delete()
|
||
messages.success(
|
||
request,
|
||
f'Verpachtung "{vertragsnummer}" wurde erfolgreich gelöscht.'
|
||
)
|
||
return redirect('stiftung:verpachtung_list')
|
||
|
||
context = {
|
||
'verpachtung': verpachtung,
|
||
'title': f'Verpachtung {verpachtung.vertragsnummer} löschen',
|
||
}
|
||
return render(request, 'stiftung/verpachtung_confirm_delete.html', context)
|
||
|
||
|
||
@login_required
|
||
def quarterly_confirmation_update(request, pk):
|
||
"""Update quarterly confirmation for destinataer"""
|
||
nachweis = get_object_or_404(VierteljahresNachweis, pk=pk)
|
||
|
||
if request.method == "POST":
|
||
form = VierteljahresNachweisForm(request.POST, request.FILES, instance=nachweis)
|
||
if form.is_valid():
|
||
quarterly_proof = form.save(commit=False)
|
||
|
||
# Calculate current status before saving
|
||
old_status = nachweis.status
|
||
|
||
# Auto-update status based on completion
|
||
if quarterly_proof.is_complete():
|
||
if quarterly_proof.status in ['offen', 'teilweise']:
|
||
quarterly_proof.status = 'eingereicht'
|
||
quarterly_proof.eingereicht_am = timezone.now()
|
||
else:
|
||
# If not complete, set to teilweise if some fields are filled
|
||
has_partial_data = (
|
||
quarterly_proof.einkommenssituation_bestaetigt or
|
||
quarterly_proof.vermogenssituation_bestaetigt or
|
||
quarterly_proof.studiennachweis_eingereicht
|
||
)
|
||
if has_partial_data and quarterly_proof.status == 'offen':
|
||
quarterly_proof.status = 'teilweise'
|
||
|
||
quarterly_proof.save()
|
||
|
||
# Try to create automatic support payment if complete
|
||
if quarterly_proof.is_complete() and quarterly_proof.status == 'eingereicht':
|
||
support_payment = create_quarterly_support_payment(quarterly_proof)
|
||
if support_payment:
|
||
messages.success(
|
||
request,
|
||
f"Automatische Unterstützung über {support_payment.betrag}€ für {support_payment.destinataer.get_full_name()} wurde erstellt (fällig am {support_payment.faellig_am.strftime('%d.%m.%Y')})."
|
||
)
|
||
else:
|
||
# Log why payment wasn't created
|
||
reasons = []
|
||
if not quarterly_proof.destinataer.vierteljaehrlicher_betrag:
|
||
reasons.append("kein vierteljährlicher Betrag hinterlegt")
|
||
if not quarterly_proof.destinataer.iban:
|
||
reasons.append("keine IBAN hinterlegt")
|
||
if not quarterly_proof.destinataer.standard_konto and not StiftungsKonto.objects.exists():
|
||
reasons.append("kein Auszahlungskonto verfügbar")
|
||
|
||
if reasons:
|
||
messages.warning(
|
||
request,
|
||
f"Automatische Unterstützung konnte nicht erstellt werden: {', '.join(reasons)}"
|
||
)
|
||
|
||
# Debug message to see what happened
|
||
status_changed = old_status != quarterly_proof.status
|
||
status_msg = f" (Status: {old_status} → {quarterly_proof.status})" if status_changed else f" (Status: {quarterly_proof.status})"
|
||
|
||
messages.success(
|
||
request,
|
||
f"Vierteljahresnachweis für {nachweis.destinataer.get_full_name()} "
|
||
f"({nachweis.jahr} Q{nachweis.quartal}) wurde erfolgreich aktualisiert{status_msg}."
|
||
)
|
||
return redirect("stiftung:destinataer_detail", pk=nachweis.destinataer.pk)
|
||
else:
|
||
# Add form errors to messages
|
||
for field, errors in form.errors.items():
|
||
for error in errors:
|
||
messages.error(request, f"Fehler in {field}: {error}")
|
||
|
||
# If GET request or form errors, redirect back to destinataer detail
|
||
return redirect("stiftung:destinataer_detail", pk=nachweis.destinataer.pk)
|
||
|
||
|
||
def create_quarterly_support_payment(nachweis):
|
||
"""
|
||
Get or create a single support payment for this quarterly confirmation
|
||
Ensures only one payment exists per destinataer per quarter
|
||
"""
|
||
from datetime import date
|
||
destinataer = nachweis.destinataer
|
||
|
||
# Check if all requirements are met
|
||
if not nachweis.is_complete():
|
||
return None
|
||
|
||
# Check if destinataer has required payment info
|
||
if not destinataer.vierteljaehrlicher_betrag or destinataer.vierteljaehrlicher_betrag <= 0:
|
||
return None
|
||
|
||
if not destinataer.iban:
|
||
return None
|
||
|
||
# Search for existing payment using payment due date from quarterly confirmation
|
||
# This is more accurate than using quarter date range, especially for Q1 (Dec 15 prev year)
|
||
payment_due_date = nachweis.zahlung_faelligkeitsdatum
|
||
if not payment_due_date:
|
||
# Fallback: calculate if not set
|
||
if nachweis.quartal == 1:
|
||
payment_due_date = date(nachweis.jahr - 1, 12, 15)
|
||
elif nachweis.quartal == 2:
|
||
payment_due_date = date(nachweis.jahr, 3, 15)
|
||
elif nachweis.quartal == 3:
|
||
payment_due_date = date(nachweis.jahr, 6, 15)
|
||
else: # Q4
|
||
payment_due_date = date(nachweis.jahr, 9, 15)
|
||
|
||
# Search for existing payment - match by payment due date and description
|
||
# Use a date range around the due date (±30 days) to catch any variations
|
||
from datetime import timedelta
|
||
date_start = payment_due_date - timedelta(days=30)
|
||
date_end = payment_due_date + timedelta(days=30)
|
||
|
||
existing_payment = DestinataerUnterstuetzung.objects.filter(
|
||
destinataer=destinataer,
|
||
faellig_am__gte=date_start,
|
||
faellig_am__lte=date_end
|
||
).filter(
|
||
Q(beschreibung__contains=f"Q{nachweis.quartal}/{nachweis.jahr}") |
|
||
Q(beschreibung__contains=f"Vierteljährliche Unterstützung Q{nachweis.quartal}/{nachweis.jahr}")
|
||
).first()
|
||
|
||
if existing_payment:
|
||
# Update existing payment to ensure it matches current requirements
|
||
existing_payment.betrag = destinataer.vierteljaehrlicher_betrag
|
||
existing_payment.empfaenger_iban = destinataer.iban
|
||
existing_payment.empfaenger_name = destinataer.get_full_name()
|
||
existing_payment.verwendungszweck = f"Vierteljährliche Unterstützung Q{nachweis.quartal}/{nachweis.jahr} - {destinataer.get_full_name()}"
|
||
existing_payment.beschreibung = f"Vierteljährliche Unterstützung Q{nachweis.quartal}/{nachweis.jahr} (automatisch erstellt)"
|
||
existing_payment.save()
|
||
return existing_payment
|
||
|
||
# Get default payment account
|
||
default_konto = destinataer.standard_konto
|
||
if not default_konto:
|
||
# Try to get any StiftungsKonto
|
||
default_konto = StiftungsKonto.objects.first()
|
||
if not default_konto:
|
||
return None
|
||
|
||
# Use payment due date from quarterly confirmation (already calculated by model)
|
||
# This ensures consistency with zahlung_faelligkeitsdatum
|
||
payment_due_date = nachweis.zahlung_faelligkeitsdatum
|
||
if not payment_due_date:
|
||
# Fallback: calculate if not set (should not happen, but safety check)
|
||
if nachweis.quartal == 1: # Q1 payment due December 15 of previous year
|
||
payment_due_date = date(nachweis.jahr - 1, 12, 15)
|
||
elif nachweis.quartal == 2: # Q2 payment due March 15
|
||
payment_due_date = date(nachweis.jahr, 3, 15)
|
||
elif nachweis.quartal == 3: # Q3 payment due June 15
|
||
payment_due_date = date(nachweis.jahr, 6, 15)
|
||
else: # Q4 payment due September 15
|
||
payment_due_date = date(nachweis.jahr, 9, 15)
|
||
|
||
# Create the support payment
|
||
payment = DestinataerUnterstuetzung.objects.create(
|
||
destinataer=destinataer,
|
||
konto=default_konto,
|
||
betrag=destinataer.vierteljaehrlicher_betrag,
|
||
faellig_am=payment_due_date,
|
||
status='geplant',
|
||
beschreibung=f"Vierteljährliche Unterstützung Q{nachweis.quartal}/{nachweis.jahr} (automatisch erstellt)",
|
||
empfaenger_iban=destinataer.iban,
|
||
empfaenger_name=destinataer.get_full_name(),
|
||
verwendungszweck=f"Vierteljährliche Unterstützung Q{nachweis.quartal}/{nachweis.jahr} - {destinataer.get_full_name()}",
|
||
erstellt_am=timezone.now(),
|
||
aktualisiert_am=timezone.now()
|
||
)
|
||
|
||
return payment
|
||
|
||
|
||
@login_required
|
||
def quarterly_confirmation_create(request, destinataer_id):
|
||
"""Create a new quarterly confirmation for a destinataer"""
|
||
import logging
|
||
logger = logging.getLogger(__name__)
|
||
logger.info(f"quarterly_confirmation_create called: method={request.method}, destinataer_id={destinataer_id}")
|
||
|
||
destinataer = get_object_or_404(Destinataer, pk=destinataer_id)
|
||
|
||
if request.method == "POST":
|
||
logger.info(f"POST data: {request.POST}")
|
||
jahr = request.POST.get('jahr')
|
||
quartal = request.POST.get('quartal')
|
||
|
||
if jahr and quartal:
|
||
try:
|
||
jahr = int(jahr)
|
||
quartal = int(quartal)
|
||
|
||
# Check if this quarter already exists
|
||
existing = VierteljahresNachweis.objects.filter(
|
||
destinataer=destinataer,
|
||
jahr=jahr,
|
||
quartal=quartal
|
||
).exists()
|
||
|
||
if existing:
|
||
messages.warning(
|
||
request,
|
||
f"Quartal {jahr} Q{quartal} existiert bereits für {destinataer.get_full_name()}."
|
||
)
|
||
else:
|
||
# Create new quarterly confirmation
|
||
try:
|
||
nachweis = VierteljahresNachweis.objects.create(
|
||
destinataer=destinataer,
|
||
jahr=jahr,
|
||
quartal=quartal,
|
||
studiennachweis_erforderlich=True, # Always required now
|
||
)
|
||
# Deadlines are automatically set by the model's save() method
|
||
# studiennachweis_faelligkeitsdatum: semester-based (Q1/Q2→Mar 15, Q3/Q4→Sep 15)
|
||
# zahlung_faelligkeitsdatum: quarterly advance (Q1→Dec 15 prev year, Q2→Mar 15, Q3→Jun 15, Q4→Sep 15)
|
||
|
||
# Refresh from database to ensure deadlines are set
|
||
nachweis.refresh_from_db()
|
||
|
||
studiennachweis_str = nachweis.studiennachweis_faelligkeitsdatum.strftime('%d.%m.%Y') if nachweis.studiennachweis_faelligkeitsdatum else "Nicht gesetzt"
|
||
zahlung_str = nachweis.zahlung_faelligkeitsdatum.strftime('%d.%m.%Y') if nachweis.zahlung_faelligkeitsdatum else "Nicht gesetzt"
|
||
|
||
messages.success(
|
||
request,
|
||
f"Quartal {jahr} Q{quartal} wurde erfolgreich für {destinataer.get_full_name()} erstellt. "
|
||
f"Studiennachweis fällig: {studiennachweis_str}, "
|
||
f"Zahlung fällig: {zahlung_str}."
|
||
)
|
||
except Exception as e:
|
||
from django.db import IntegrityError
|
||
if isinstance(e, IntegrityError):
|
||
messages.error(
|
||
request,
|
||
f"Quartal {jahr} Q{quartal} existiert bereits für {destinataer.get_full_name()}."
|
||
)
|
||
else:
|
||
messages.error(
|
||
request,
|
||
f"Fehler beim Erstellen des Quartals: {str(e)}"
|
||
)
|
||
|
||
except (ValueError, TypeError):
|
||
messages.error(request, "Ungültige Jahr- oder Quartalswerte.")
|
||
else:
|
||
messages.error(request, "Jahr und Quartal müssen angegeben werden.")
|
||
|
||
return redirect("stiftung:destinataer_detail", pk=destinataer.pk)
|
||
|
||
|
||
@login_required
|
||
def quarterly_confirmation_edit(request, pk):
|
||
"""Standalone edit view for quarterly confirmation"""
|
||
nachweis = get_object_or_404(VierteljahresNachweis, pk=pk)
|
||
|
||
if request.method == "POST":
|
||
form = VierteljahresNachweisForm(request.POST, request.FILES, instance=nachweis)
|
||
if form.is_valid():
|
||
quarterly_proof = form.save(commit=False)
|
||
|
||
# Calculate current status before saving
|
||
old_status = nachweis.status
|
||
|
||
# Auto-update status based on completion
|
||
if quarterly_proof.is_complete():
|
||
if quarterly_proof.status in ['offen', 'teilweise']:
|
||
quarterly_proof.status = 'eingereicht'
|
||
quarterly_proof.eingereicht_am = timezone.now()
|
||
else:
|
||
# If not complete, set to teilweise if some fields are filled
|
||
has_partial_data = (
|
||
quarterly_proof.einkommenssituation_bestaetigt or
|
||
quarterly_proof.vermogenssituation_bestaetigt or
|
||
quarterly_proof.studiennachweis_eingereicht
|
||
)
|
||
if has_partial_data and quarterly_proof.status == 'offen':
|
||
quarterly_proof.status = 'teilweise'
|
||
|
||
quarterly_proof.save()
|
||
|
||
# Try to create automatic support payment if complete
|
||
if quarterly_proof.is_complete() and quarterly_proof.status == 'eingereicht':
|
||
support_payment = create_quarterly_support_payment(quarterly_proof)
|
||
if support_payment:
|
||
messages.success(
|
||
request,
|
||
f"Automatische Unterstützung über {support_payment.betrag}€ für {support_payment.destinataer.get_full_name()} wurde erstellt (fällig am {support_payment.faellig_am.strftime('%d.%m.%Y')})."
|
||
)
|
||
else:
|
||
# Log why payment wasn't created
|
||
reasons = []
|
||
if not quarterly_proof.destinataer.vierteljaehrlicher_betrag:
|
||
reasons.append("kein vierteljährlicher Betrag hinterlegt")
|
||
if not quarterly_proof.destinataer.iban:
|
||
reasons.append("keine IBAN hinterlegt")
|
||
if not quarterly_proof.destinataer.standard_konto and not StiftungsKonto.objects.exists():
|
||
reasons.append("kein Auszahlungskonto verfügbar")
|
||
|
||
if reasons:
|
||
messages.warning(
|
||
request,
|
||
f"Automatische Unterstützung konnte nicht erstellt werden: {', '.join(reasons)}"
|
||
)
|
||
|
||
# Debug message to see what happened
|
||
status_changed = old_status != quarterly_proof.status
|
||
status_msg = f" (Status: {old_status} → {quarterly_proof.status})" if status_changed else f" (Status: {quarterly_proof.status})"
|
||
|
||
messages.success(
|
||
request,
|
||
f"Vierteljahresnachweis für {nachweis.destinataer.get_full_name()} "
|
||
f"({nachweis.jahr} Q{nachweis.quartal}) wurde erfolgreich aktualisiert{status_msg}."
|
||
)
|
||
return redirect("stiftung:destinataer_detail", pk=nachweis.destinataer.pk)
|
||
else:
|
||
# Add form errors to messages
|
||
for field, errors in form.errors.items():
|
||
for error in errors:
|
||
messages.error(request, f"Fehler in {field}: {error}")
|
||
else:
|
||
form = VierteljahresNachweisForm(instance=nachweis)
|
||
|
||
context = {
|
||
'form': form,
|
||
'nachweis': nachweis,
|
||
'destinataer': nachweis.destinataer,
|
||
'title': f'Vierteljahresnachweis bearbeiten - {nachweis.destinataer.get_full_name()} {nachweis.jahr} Q{nachweis.quartal}',
|
||
}
|
||
return render(request, 'stiftung/quarterly_confirmation_edit.html', context)
|
||
|
||
|
||
@login_required
|
||
def quarterly_confirmation_approve(request, pk):
|
||
"""Approve quarterly confirmation (staff only)"""
|
||
if not request.user.is_staff:
|
||
messages.error(request, "Sie haben keine Berechtigung für diese Aktion.")
|
||
return redirect("stiftung:destinataer_list")
|
||
|
||
nachweis = get_object_or_404(VierteljahresNachweis, pk=pk)
|
||
|
||
if request.method == "POST":
|
||
if nachweis.status in ['eingereicht', 'geprueft']:
|
||
# Check if we need to create or update support payment
|
||
related_payment = nachweis.get_related_support_payment()
|
||
|
||
if nachweis.status == 'eingereicht' or (nachweis.status == 'geprueft' and not related_payment):
|
||
# Approve the quarterly confirmation
|
||
nachweis.status = 'geprueft'
|
||
nachweis.geprueft_am = timezone.now()
|
||
nachweis.geprueft_von = request.user
|
||
nachweis.save()
|
||
|
||
# Auto-approve next quarter for semester-based tracking (Q1→Q2, Q3→Q4)
|
||
auto_approved_next = nachweis.auto_approve_next_quarter()
|
||
if auto_approved_next:
|
||
messages.info(
|
||
request,
|
||
f"Q{auto_approved_next.quartal} wurde automatisch auf Basis der Q{nachweis.quartal}-Nachweise freigegeben."
|
||
)
|
||
|
||
# Handle support payment - create if missing, update if exists
|
||
# Check if payment already exists before calling create_quarterly_support_payment()
|
||
payment_existed_before = related_payment is not None
|
||
|
||
# Use create_quarterly_support_payment() which handles both cases (find existing or create new)
|
||
related_payment = create_quarterly_support_payment(nachweis)
|
||
if related_payment:
|
||
# Update status to 'in_bearbeitung' for both new and existing payments
|
||
old_status = related_payment.status
|
||
related_payment.status = 'in_bearbeitung'
|
||
related_payment.aktualisiert_am = timezone.now()
|
||
related_payment.save()
|
||
|
||
if payment_existed_before:
|
||
messages.success(
|
||
request,
|
||
f"Vierteljahresnachweis freigegeben und bestehende Unterstützung für {nachweis.destinataer.get_full_name()} "
|
||
f"({nachweis.jahr} Q{nachweis.quartal}) wurde von '{old_status}' auf 'in Bearbeitung' aktualisiert."
|
||
)
|
||
else:
|
||
messages.success(
|
||
request,
|
||
f"Vierteljahresnachweis freigegeben und neue Unterstützung über {related_payment.betrag}€ für {nachweis.destinataer.get_full_name()} "
|
||
f"({nachweis.jahr} Q{nachweis.quartal}) wurde erstellt."
|
||
)
|
||
else:
|
||
messages.warning(
|
||
request,
|
||
f"Vierteljahresnachweis freigegeben, aber Unterstützung konnte nicht erstellt werden. "
|
||
f"Bitte prüfen Sie die Einstellungen für {nachweis.destinataer.get_full_name()}."
|
||
)
|
||
else:
|
||
messages.error(
|
||
request,
|
||
"Nur eingereichte oder bereits genehmigte Nachweise können verarbeitet werden."
|
||
)
|
||
|
||
return redirect("stiftung:destinataer_detail", pk=nachweis.destinataer.pk)
|
||
|
||
|
||
@login_required
|
||
def quarterly_confirmation_reset(request, pk):
|
||
"""Reset quarterly confirmation status (staff only)"""
|
||
if not request.user.is_staff:
|
||
messages.error(request, "Sie haben keine Berechtigung für diese Aktion.")
|
||
return redirect("stiftung:destinataer_list")
|
||
|
||
nachweis = get_object_or_404(VierteljahresNachweis, pk=pk)
|
||
|
||
if request.method == "POST":
|
||
if nachweis.status in ['geprueft', 'eingereicht']:
|
||
# Reset the quarterly confirmation status
|
||
nachweis.status = 'eingereicht' if nachweis.is_complete() else 'teilweise'
|
||
nachweis.geprueft_am = None
|
||
nachweis.geprueft_von = None
|
||
nachweis.aktualisiert_am = timezone.now()
|
||
nachweis.save()
|
||
|
||
# Reset related support payment status if it exists
|
||
related_payment = nachweis.get_related_support_payment()
|
||
if related_payment and related_payment.status == 'in_bearbeitung':
|
||
related_payment.status = 'geplant'
|
||
related_payment.aktualisiert_am = timezone.now()
|
||
related_payment.save()
|
||
|
||
messages.success(
|
||
request,
|
||
f"Vierteljahresnachweis und zugehörige Unterstützung für {nachweis.destinataer.get_full_name()} "
|
||
f"({nachweis.jahr} Q{nachweis.quartal}) wurden zurückgesetzt."
|
||
)
|
||
else:
|
||
messages.success(
|
||
request,
|
||
f"Vierteljahresnachweis für {nachweis.destinataer.get_full_name()} "
|
||
f"({nachweis.jahr} Q{nachweis.quartal}) wurde zurückgesetzt."
|
||
)
|
||
else:
|
||
messages.error(
|
||
request,
|
||
"Nur genehmigte oder eingereichte Nachweise können zurückgesetzt werden."
|
||
)
|
||
|
||
return redirect("stiftung:destinataer_detail", pk=nachweis.destinataer.pk)
|
||
|
||
|
||
# Two-Factor Authentication Views
|
||
|
||
@login_required
|
||
def two_factor_setup(request):
|
||
"""Setup or manage TOTP 2FA for the current user"""
|
||
|
||
# Check if user already has TOTP device
|
||
device = TOTPDevice.objects.filter(user=request.user, confirmed=True).first()
|
||
static_device = StaticDevice.objects.filter(user=request.user).first()
|
||
|
||
if device:
|
||
# User has 2FA enabled - show management options
|
||
context = {
|
||
'has_2fa': True,
|
||
'device': device,
|
||
'backup_token_count': static_device.token_set.count() if static_device else 0,
|
||
'title': 'Zwei-Faktor-Authentifizierung verwalten'
|
||
}
|
||
return render(request, 'stiftung/auth/two_factor_manage.html', context)
|
||
|
||
# User doesn't have 2FA - show setup
|
||
# Get or create unconfirmed TOTP device
|
||
device, created = TOTPDevice.objects.get_or_create(
|
||
user=request.user,
|
||
name='default',
|
||
defaults={'confirmed': False}
|
||
)
|
||
|
||
if request.method == "POST":
|
||
token = request.POST.get('token', '').strip()
|
||
if device.verify_token(token):
|
||
device.confirmed = True
|
||
device.save()
|
||
|
||
# Generate backup tokens
|
||
static_device = StaticDevice.objects.create(
|
||
user=request.user,
|
||
name='backup'
|
||
)
|
||
|
||
backup_tokens = []
|
||
for _ in range(10): # Generate 10 backup codes
|
||
token_value = random_hex()[:8] # 8 character backup codes
|
||
StaticToken.objects.create(
|
||
device=static_device,
|
||
token=token_value
|
||
)
|
||
backup_tokens.append(token_value)
|
||
|
||
messages.success(
|
||
request,
|
||
"Zwei-Faktor-Authentifizierung wurde erfolgreich aktiviert! "
|
||
"Bitte speichern Sie Ihre Backup-Codes sicher."
|
||
)
|
||
|
||
return render(request, 'stiftung/auth/backup_tokens.html', {
|
||
'backup_tokens': backup_tokens,
|
||
'title': 'Backup-Codes'
|
||
})
|
||
else:
|
||
messages.error(request, "Ungültiger Bestätigungscode. Bitte versuchen Sie es erneut.")
|
||
|
||
# Generate QR code URL
|
||
qr_url = device.config_url
|
||
|
||
context = {
|
||
'device': device,
|
||
'qr_url': qr_url,
|
||
'title': 'Zwei-Faktor-Authentifizierung einrichten'
|
||
}
|
||
|
||
return render(request, 'stiftung/auth/two_factor_setup.html', context)
|
||
|
||
|
||
@login_required
|
||
def two_factor_qr(request):
|
||
"""Generate QR code for TOTP setup"""
|
||
device = TOTPDevice.objects.filter(user=request.user, confirmed=False).first()
|
||
|
||
if not device:
|
||
return HttpResponse("Kein Setup-Device gefunden", status=404)
|
||
|
||
# Generate QR code
|
||
qr = qrcode.QRCode(version=1, box_size=10, border=5)
|
||
qr.add_data(device.config_url)
|
||
qr.make(fit=True)
|
||
|
||
img = qr.make_image(fill_color="black", back_color="white")
|
||
|
||
response = HttpResponse(content_type="image/png")
|
||
img.save(response, "PNG")
|
||
|
||
return response
|
||
|
||
|
||
@login_required
|
||
def two_factor_verify(request):
|
||
"""Verify TOTP token during login process"""
|
||
if request.method == "POST":
|
||
token = request.POST.get('otp_token', '').strip()
|
||
|
||
# Check TOTP devices
|
||
devices = TOTPDevice.objects.filter(user=request.user, confirmed=True)
|
||
for device in devices:
|
||
if device.verify_token(token):
|
||
request.session['2fa_verified'] = True
|
||
messages.success(request, "Zwei-Faktor-Authentifizierung erfolgreich.")
|
||
return redirect(request.GET.get('next', 'stiftung:home'))
|
||
|
||
# Check static backup tokens
|
||
static_devices = StaticDevice.objects.filter(user=request.user)
|
||
for device in static_devices:
|
||
if device.verify_token(token):
|
||
request.session['2fa_verified'] = True
|
||
messages.success(request, "Backup-Code erfolgreich verwendet.")
|
||
return redirect(request.GET.get('next', 'stiftung:home'))
|
||
|
||
messages.error(request, "Ungültiger Code. Bitte versuchen Sie es erneut.")
|
||
|
||
context = {
|
||
'title': 'Zwei-Faktor-Authentifizierung',
|
||
'next': request.GET.get('next', '')
|
||
}
|
||
|
||
return render(request, 'stiftung/auth/two_factor_verify.html', context)
|
||
|
||
|
||
@login_required
|
||
def two_factor_disable(request):
|
||
"""Disable TOTP 2FA for the current user"""
|
||
if request.method == "POST":
|
||
password = request.POST.get('password', '')
|
||
|
||
if request.user.check_password(password):
|
||
# Remove all TOTP devices
|
||
TOTPDevice.objects.filter(user=request.user).delete()
|
||
|
||
# Remove all static backup token devices
|
||
StaticDevice.objects.filter(user=request.user).delete()
|
||
|
||
messages.success(
|
||
request,
|
||
"Zwei-Faktor-Authentifizierung wurde erfolgreich deaktiviert."
|
||
)
|
||
return redirect("stiftung:home")
|
||
else:
|
||
messages.error(request, "Ungültiges Passwort.")
|
||
|
||
context = {
|
||
'title': 'Zwei-Faktor-Authentifizierung deaktivieren'
|
||
}
|
||
|
||
return render(request, 'stiftung/auth/two_factor_disable.html', context)
|
||
|
||
|
||
@login_required
|
||
def backup_tokens(request):
|
||
"""Display or regenerate backup tokens"""
|
||
static_device = StaticDevice.objects.filter(user=request.user).first()
|
||
|
||
if request.method == "POST" and 'regenerate' in request.POST:
|
||
password = request.POST.get('password', '')
|
||
|
||
if request.user.check_password(password):
|
||
# Delete old tokens
|
||
if static_device:
|
||
static_device.delete()
|
||
|
||
# Generate new backup tokens
|
||
static_device = StaticDevice.objects.create(
|
||
user=request.user,
|
||
name='backup'
|
||
)
|
||
|
||
backup_tokens = []
|
||
for _ in range(10): # Generate 10 backup codes
|
||
token_value = random_hex()[:8] # 8 character backup codes
|
||
StaticToken.objects.create(
|
||
device=static_device,
|
||
token=token_value
|
||
)
|
||
backup_tokens.append(token_value)
|
||
|
||
messages.success(
|
||
request,
|
||
"Neue Backup-Codes wurden generiert. Bitte speichern Sie diese sicher."
|
||
)
|
||
|
||
context = {
|
||
'backup_tokens': backup_tokens,
|
||
'title': 'Neue Backup-Codes'
|
||
}
|
||
|
||
return render(request, 'stiftung/auth/backup_tokens.html', context)
|
||
else:
|
||
messages.error(request, "Ungültiges Passwort.")
|
||
|
||
# Show existing tokens (count only for security)
|
||
token_count = 0
|
||
if static_device:
|
||
token_count = static_device.token_set.count()
|
||
|
||
context = {
|
||
'token_count': token_count,
|
||
'has_tokens': token_count > 0,
|
||
'title': 'Backup-Codes'
|
||
}
|
||
|
||
return render(request, 'stiftung/auth/backup_tokens_manage.html', context)
|
||
|
||
|
||
# Geschichte (History) Views
|
||
from .models import GeschichteSeite, GeschichteBild
|
||
from .forms import GeschichteSeiteForm, GeschichteBildForm
|
||
|
||
|
||
@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
|
||
# ============================================================
|
||
|
||
@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
|