- Update quarterly confirmation deadlines to semester-based schedule: - Q1: March 15 (covers Spring semester Q1+Q2) - Q2: June 15 (auto-approved when Q1 approved) - Q3: September 15 (covers Fall semester Q3+Q4) - Q4: December 15 (auto-approved when Q3 approved) - Add auto-approval functionality: - Q1 approval automatically approves Q2 with same document status - Q3 approval automatically approves Q4 with same document status - New 'auto_geprueft' status with distinct badge UI - Maintain quarterly payment cycle while simplifying document submissions - Remove modal edit functionality, keep full-screen editor only - Update copilot instructions documentation Changes align with academic semester system where students submit documents twice yearly instead of quarterly.
8034 lines
298 KiB
Python
8034 lines
298 KiB
Python
import csv
|
|
import io
|
|
import json
|
|
import os
|
|
import time
|
|
from datetime import datetime, timedelta
|
|
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,
|
|
DestinataerUnterstuetzung, DokumentLink, Foerderung, Land,
|
|
LandAbrechnung, LandVerpachtung, Paechter, Person,
|
|
StiftungsKonto, UnterstuetzungWiederkehrend, 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)
|
|
|
|
|
|
def home(request):
|
|
"""Home page for the Stiftungsverwaltung application"""
|
|
return render(
|
|
request,
|
|
"stiftung/home.html",
|
|
{"title": "Stiftungsverwaltung", "description": "Foundation Management System"},
|
|
)
|
|
|
|
|
|
@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.rstrip("/api") 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
|
|
@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.rstrip("/api") 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
|
|
|
|
|
|
# Dashboard Views
|
|
@login_required
|
|
def dashboard(request):
|
|
# Foerderung statistics (Person statistics removed - was legacy Verpachtung system)
|
|
total_foerderungen = Foerderung.objects.aggregate(total=Sum("betrag"))["total"] or 0
|
|
|
|
# Land statistics
|
|
total_land = Land.objects.count()
|
|
active_land = Land.objects.filter(aktiv=True).count()
|
|
total_flaeche = Land.objects.aggregate(total=Sum("groesse_qm"))["total"] or 0
|
|
|
|
# Calculate total verpachtet from active verpachtungen
|
|
total_verpachtet = (
|
|
LandVerpachtung.objects.filter(status="aktiv").aggregate(
|
|
total=Sum("verpachtete_flaeche")
|
|
)["total"]
|
|
or 0
|
|
)
|
|
|
|
# Verpachtung statistics
|
|
total_verpachtungen = LandVerpachtung.objects.count()
|
|
active_verpachtungen = LandVerpachtung.objects.filter(status="aktiv").count()
|
|
total_pachtzins = (
|
|
LandVerpachtung.objects.filter(status="aktiv").aggregate(
|
|
total=Sum("pachtzins_pauschal")
|
|
)["total"]
|
|
or 0
|
|
)
|
|
|
|
# Recent activities
|
|
recent_lands = Land.objects.order_by("-erstellt_am")[:5]
|
|
recent_verpachtungen = LandVerpachtung.objects.select_related(
|
|
"land", "paechter"
|
|
).order_by("-erstellt_am")[:5]
|
|
|
|
# Dokumentenübersicht
|
|
dokumente_uebersicht = DokumentLink.objects.all().order_by("-id")[:10]
|
|
|
|
# Verfügbare Paperless-Dokumente für Dashboard
|
|
available_paperless_docs = []
|
|
from stiftung.utils.config import get_paperless_config
|
|
|
|
config = get_paperless_config()
|
|
url = config["api_url"]
|
|
token = config["api_token"]
|
|
|
|
if url and token:
|
|
try:
|
|
base_url = url.rstrip("/api") 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_paperless_docs.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 (neueste zuerst)
|
|
available_paperless_docs.sort(key=lambda x: x["created_date"], reverse=True)
|
|
|
|
except Exception:
|
|
pass
|
|
|
|
context = {
|
|
# Person statistics removed - was legacy Verpachtung system
|
|
"total_foerderungen": total_foerderungen,
|
|
"total_land": total_land,
|
|
"active_land": active_land,
|
|
"total_flaeche": total_flaeche,
|
|
"total_verpachtet": total_verpachtet,
|
|
"total_verpachtungen": total_verpachtungen,
|
|
"active_verpachtungen": active_verpachtungen,
|
|
"total_pachtzins": total_pachtzins,
|
|
"recent_lands": recent_lands,
|
|
"recent_verpachtungen": recent_verpachtungen,
|
|
"dokumente_uebersicht": dokumente_uebersicht,
|
|
"available_paperless_docs": available_paperless_docs,
|
|
}
|
|
return render(request, "stiftung/dashboard.html", context)
|
|
|
|
|
|
# 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.rstrip("/api") 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.rstrip("/api") 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.rstrip("/api") 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.rstrip("/api") 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"
|
|
redirect_args = [rentmeister_id]
|
|
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 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:dashboard")
|
|
|
|
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 dashboard
|
|
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:dashboard")
|
|
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:dashboard")
|
|
|
|
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:dashboard"))
|
|
|
|
# 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:dashboard"))
|
|
|
|
# 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
|
|
"""
|
|
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
|
|
|
|
# Calculate quarter date range for more robust search
|
|
quarter_start = datetime(nachweis.jahr, (nachweis.quartal - 1) * 3 + 1, 1).date()
|
|
if nachweis.quartal == 4: # Q4 special case
|
|
quarter_end = datetime(nachweis.jahr + 1, 1, 1).date()
|
|
else:
|
|
quarter_end = datetime(nachweis.jahr, nachweis.quartal * 3 + 1, 1).date()
|
|
|
|
# Search for existing payment - use broader criteria to catch all possibilities
|
|
existing_payment = DestinataerUnterstuetzung.objects.filter(
|
|
destinataer=destinataer,
|
|
faellig_am__gte=quarter_start,
|
|
faellig_am__lt=quarter_end
|
|
).filter(
|
|
Q(beschreibung__contains=f"Q{nachweis.quartal}/{nachweis.jahr}") |
|
|
Q(beschreibung__contains=f"Vierteljährliche Unterstützung")
|
|
).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
|
|
|
|
# Calculate payment due date (last day of quarter)
|
|
quarter_end_month = nachweis.quartal * 3
|
|
|
|
if nachweis.quartal == 1: # Q1: January-March (ends March 31)
|
|
quarter_end_day = 31
|
|
elif nachweis.quartal == 2: # Q2: April-June (ends June 30)
|
|
quarter_end_day = 30
|
|
elif nachweis.quartal == 3: # Q3: July-September (ends September 30)
|
|
quarter_end_day = 30
|
|
else: # Q4: October-December (ends December 31)
|
|
quarter_end_day = 31
|
|
|
|
payment_due_date = datetime(nachweis.jahr, quarter_end_month, quarter_end_day).date()
|
|
|
|
# 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"""
|
|
destinataer = get_object_or_404(Destinataer, pk=destinataer_id)
|
|
|
|
if request.method == "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
|
|
nachweis = VierteljahresNachweis.objects.create(
|
|
destinataer=destinataer,
|
|
jahr=jahr,
|
|
quartal=quartal,
|
|
studiennachweis_erforderlich=True, # Always required now
|
|
)
|
|
|
|
# Set deadline (15th of second month of quarter)
|
|
deadline_months = {1: 5, 2: 8, 3: 11, 4: 2} # Q1->May, Q2->Aug, Q3->Nov, Q4->Feb(next year)
|
|
deadline_month = deadline_months[quartal]
|
|
deadline_year = jahr if quartal != 4 else jahr + 1
|
|
|
|
from datetime import date
|
|
nachweis.faelligkeitsdatum = date(deadline_year, deadline_month, 15)
|
|
nachweis.save()
|
|
|
|
messages.success(
|
|
request,
|
|
f"Quartal {jahr} Q{quartal} wurde erfolgreich für {destinataer.get_full_name()} erstellt."
|
|
)
|
|
|
|
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
|
|
if not related_payment:
|
|
# Create new support payment
|
|
related_payment = create_quarterly_support_payment(nachweis)
|
|
if related_payment:
|
|
related_payment.status = 'in_bearbeitung'
|
|
related_payment.aktualisiert_am = timezone.now()
|
|
related_payment.save()
|
|
|
|
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()}."
|
|
)
|
|
elif related_payment.status == 'geplant':
|
|
# Update existing payment
|
|
related_payment.status = 'in_bearbeitung'
|
|
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 freigegeben."
|
|
)
|
|
else:
|
|
messages.success(
|
|
request,
|
|
f"Vierteljahresnachweis für {nachweis.destinataer.get_full_name()} "
|
|
f"({nachweis.jahr} Q{nachweis.quartal}) wurde freigegeben."
|
|
)
|
|
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:dashboard'))
|
|
|
|
# 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:dashboard'))
|
|
|
|
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:dashboard")
|
|
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)
|