Files
stiftung-management-system/app/stiftung/views/import_export.py
SysAdmin Agent e0b377014c
Some checks failed
CI/CD Pipeline / test (push) Has been cancelled
CI/CD Pipeline / deploy (push) Has been cancelled
Code Quality / quality (push) Has been cancelled
v4.1.0: DMS email documents, category-specific Nachweis linking, version system
- Save cover email body as DMS document with new 'email' context type
- Show email body separately from attachments in email detail view
- Add per-category DMS document assignment in quarterly confirmation
  (Studiennachweis, Einkommenssituation, Vermögenssituation)
- Add VERSION file and context processor for automatic version display
- Add MCP server, agent system, import/export, and new migrations
- Update compose files and production environment template

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 18:48:52 +00:00

921 lines
38 KiB
Python

# views/import_export.py
# Unified Import/Export Workflow for all Stiftung content types
import csv
import io
import json
from datetime import datetime
from decimal import Decimal, InvalidOperation
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.http import HttpResponse
from django.shortcuts import redirect, render
from django.utils import timezone
from stiftung.models import (
CSVImport, Destinataer, Foerderung, Land, LandAbrechnung,
LandVerpachtung, Paechter, Person, Rentmeister,
StiftungsKonto, Verwaltungskosten,
)
# ---------------------------------------------------------------------------
# Export field definitions for each entity type
# Each entry: (csv_header, model_field_or_lambda)
# ---------------------------------------------------------------------------
def _date_fmt(val):
"""Format date for CSV export."""
if val is None:
return ""
return val.strftime("%d.%m.%Y")
def _datetime_fmt(val):
if val is None:
return ""
return val.strftime("%d.%m.%Y %H:%M")
def _decimal_fmt(val):
if val is None:
return ""
return f"{val:.2f}"
def _bool_fmt(val):
if val is None:
return ""
return "ja" if val else "nein"
EXPORT_DEFINITIONS = {
"destinataere": {
"model": Destinataer,
"label": "Destinatäre",
"queryset": lambda: Destinataer.objects.all().order_by("nachname", "vorname"),
"fields": [
("Vorname", lambda o: o.vorname),
("Nachname", lambda o: o.nachname),
("Geburtsdatum", lambda o: _date_fmt(o.geburtsdatum)),
("E-Mail", lambda o: o.email or ""),
("Telefon", lambda o: o.telefon or ""),
("IBAN", lambda o: o.iban or ""),
("Straße", lambda o: o.strasse or ""),
("PLZ", lambda o: o.plz or ""),
("Ort", lambda o: o.ort or ""),
("Familienzweig", lambda o: o.familienzweig or ""),
("Berufsgruppe", lambda o: o.berufsgruppe or ""),
("Ausbildungsstand", lambda o: o.ausbildungsstand or ""),
("Institution", lambda o: o.institution or ""),
("Projektbeschreibung", lambda o: o.projekt_beschreibung or ""),
("Jährliches_Einkommen", lambda o: _decimal_fmt(o.jaehrliches_einkommen)),
("Finanzielle_Notlage", lambda o: _bool_fmt(o.finanzielle_notlage)),
("Ist_Abkömmling", lambda o: _bool_fmt(o.ist_abkoemmling)),
("Haushaltsgroesse", lambda o: str(o.haushaltsgroesse) if o.haushaltsgroesse else ""),
("Monatliche_Bezuege", lambda o: _decimal_fmt(o.monatliche_bezuege)),
("Vermoegen", lambda o: _decimal_fmt(o.vermoegen)),
("Unterstuetzung_Bestaetigt", lambda o: _bool_fmt(o.unterstuetzung_bestaetigt)),
("Vierteljaehrlicher_Betrag", lambda o: _decimal_fmt(o.vierteljaehrlicher_betrag)),
("Studiennachweis_Erforderlich", lambda o: _bool_fmt(o.studiennachweis_erforderlich)),
("Letzter_Studiennachweis", lambda o: _date_fmt(o.letzter_studiennachweis)),
("Notizen", lambda o: o.notizen or ""),
("Aktiv", lambda o: _bool_fmt(o.aktiv)),
],
},
"paechter": {
"model": Paechter,
"label": "Pächter",
"queryset": lambda: Paechter.objects.all().order_by("nachname", "vorname"),
"fields": [
("Vorname", lambda o: o.vorname),
("Nachname", lambda o: o.nachname),
("Geburtsdatum", lambda o: _date_fmt(o.geburtsdatum)),
("E-Mail", lambda o: o.email or ""),
("Telefon", lambda o: o.telefon or ""),
("IBAN", lambda o: o.iban or ""),
("Straße", lambda o: o.strasse or ""),
("PLZ", lambda o: o.plz or ""),
("Ort", lambda o: o.ort or ""),
("Personentyp", lambda o: o.personentyp or ""),
("Pachtnummer", lambda o: o.pachtnummer or ""),
("Pachtbeginn_Erste", lambda o: _date_fmt(o.pachtbeginn_erste)),
("Pachtende_Letzte", lambda o: _date_fmt(o.pachtende_letzte)),
("Pachtzins_Aktuell", lambda o: _decimal_fmt(o.pachtzins_aktuell)),
("Landwirtschaftliche_Ausbildung", lambda o: _bool_fmt(o.landwirtschaftliche_ausbildung)),
("Berufserfahrung_Jahre", lambda o: str(o.berufserfahrung_jahre) if o.berufserfahrung_jahre else ""),
("Spezialisierung", lambda o: o.spezialisierung or ""),
("Notizen", lambda o: o.notizen or ""),
("Aktiv", lambda o: _bool_fmt(o.aktiv)),
],
},
"laendereien": {
"model": Land,
"label": "Ländereien",
"queryset": lambda: Land.objects.select_related("aktueller_paechter").all().order_by("lfd_nr"),
"fields": [
("Lfd_Nr", lambda o: o.lfd_nr or ""),
("EW_Nummer", lambda o: o.ew_nummer or ""),
("Grundbuchblatt", lambda o: o.grundbuchblatt or ""),
("ALKIS_Kennzeichen", lambda o: o.alkis_kennzeichen or ""),
("Amtsgericht", lambda o: o.amtsgericht or ""),
("Gemeinde", lambda o: o.gemeinde or ""),
("Gemarkung", lambda o: o.gemarkung or ""),
("Flur", lambda o: o.flur or ""),
("Flurstück", lambda o: o.flurstueck or ""),
("Adresse", lambda o: o.adresse or ""),
("Größe_qm", lambda o: _decimal_fmt(o.groesse_qm)),
("Grünland_qm", lambda o: _decimal_fmt(o.gruenland_qm)),
("Acker_qm", lambda o: _decimal_fmt(o.acker_qm)),
("Wald_qm", lambda o: _decimal_fmt(o.wald_qm)),
("Sonstiges_qm", lambda o: _decimal_fmt(o.sonstiges_qm)),
("Verpachtete_Gesamtfläche_qm", lambda o: _decimal_fmt(o.verpachtete_gesamtflaeche)),
("Verp_Fläche_aktuell_qm", lambda o: _decimal_fmt(o.verp_flaeche_aktuell)),
("Pächter_Name", lambda o: o.paechter_name or ""),
("Pachtbeginn", lambda o: _date_fmt(o.pachtbeginn)),
("Pachtende", lambda o: _date_fmt(o.pachtende)),
("Zahlungsweise", lambda o: o.zahlungsweise or ""),
("Pachtzins_pro_ha", lambda o: _decimal_fmt(o.pachtzins_pro_ha)),
("Pachtzins_pauschal", lambda o: _decimal_fmt(o.pachtzins_pauschal)),
("USt_Option", lambda o: _bool_fmt(o.ust_option)),
("USt_Satz", lambda o: _decimal_fmt(o.ust_satz)),
("Aktiv", lambda o: _bool_fmt(o.aktiv)),
("Notizen", lambda o: o.notizen or ""),
],
},
"verpachtungen": {
"model": LandVerpachtung,
"label": "Verpachtungen",
"queryset": lambda: LandVerpachtung.objects.select_related("land", "paechter").all().order_by("vertragsnummer"),
"fields": [
("Vertragsnummer", lambda o: o.vertragsnummer or ""),
("Land_Lfd_Nr", lambda o: o.land.lfd_nr if o.land else ""),
("Land_Gemeinde", lambda o: o.land.gemeinde if o.land else ""),
("Land_Gemarkung", lambda o: o.land.gemarkung if o.land else ""),
("Pächter_Name", lambda o: f"{o.paechter.vorname} {o.paechter.nachname}" if o.paechter else ""),
("Pachtbeginn", lambda o: _date_fmt(o.pachtbeginn)),
("Pachtende", lambda o: _date_fmt(o.pachtende)),
("Verlängerung_Klausel", lambda o: o.verlaengerung_klausel or ""),
("Verpachtete_Fläche_qm", lambda o: _decimal_fmt(o.verpachtete_flaeche)),
("Pachtzins_pauschal", lambda o: _decimal_fmt(o.pachtzins_pauschal)),
("Pachtzins_pro_ha", lambda o: _decimal_fmt(o.pachtzins_pro_ha)),
("Zahlungsweise", lambda o: o.zahlungsweise or ""),
("USt_Option", lambda o: _bool_fmt(o.ust_option)),
("USt_Satz", lambda o: _decimal_fmt(o.ust_satz)),
("Status", lambda o: o.status or ""),
("Bemerkungen", lambda o: o.bemerkungen or ""),
],
},
"foerderungen": {
"model": Foerderung,
"label": "Förderungen",
"queryset": lambda: Foerderung.objects.select_related("destinataer").all().order_by("-jahr"),
"fields": [
("Destinatär_Vorname", lambda o: o.destinataer.vorname if o.destinataer else ""),
("Destinatär_Nachname", lambda o: o.destinataer.nachname if o.destinataer else ""),
("Jahr", lambda o: str(o.jahr)),
("Betrag", lambda o: _decimal_fmt(o.betrag)),
("Kategorie", lambda o: o.get_kategorie_display() if o.kategorie else ""),
("Status", lambda o: o.get_status_display() if o.status else ""),
("Antragsdatum", lambda o: _date_fmt(o.antragsdatum)),
("Entscheidungsdatum", lambda o: _date_fmt(o.entscheidungsdatum)),
("Bemerkungen", lambda o: o.bemerkungen or ""),
],
},
"konten": {
"model": StiftungsKonto,
"label": "Stiftungskonten",
"queryset": lambda: StiftungsKonto.objects.all().order_by("kontoname"),
"fields": [
("Kontoname", lambda o: o.kontoname),
("Bank", lambda o: o.bank_name or ""),
("IBAN", lambda o: o.iban or ""),
("BIC", lambda o: o.bic or ""),
("Konto_Typ", lambda o: o.get_konto_typ_display() if o.konto_typ else ""),
("Saldo", lambda o: _decimal_fmt(o.saldo)),
("Saldo_Datum", lambda o: _date_fmt(o.saldo_datum)),
("Zinssatz", lambda o: _decimal_fmt(o.zinssatz)),
("Laufzeit_Bis", lambda o: _date_fmt(o.laufzeit_bis)),
("Aktiv", lambda o: _bool_fmt(o.aktiv)),
("Notizen", lambda o: o.notizen or ""),
],
},
"verwaltungskosten": {
"model": Verwaltungskosten,
"label": "Verwaltungskosten",
"queryset": lambda: Verwaltungskosten.objects.select_related("rentmeister").all().order_by("-datum"),
"fields": [
("Bezeichnung", lambda o: o.bezeichnung),
("Kategorie", lambda o: o.get_kategorie_display() if o.kategorie else ""),
("Betrag", lambda o: _decimal_fmt(o.betrag)),
("Datum", lambda o: _date_fmt(o.datum)),
("Lieferant", lambda o: o.lieferant_firma or ""),
("Rechnungsnummer", lambda o: o.rechnungsnummer or ""),
("Status", lambda o: o.get_status_display() if o.status else ""),
("Rentmeister", lambda o: f"{o.rentmeister.vorname} {o.rentmeister.nachname}" if o.rentmeister else ""),
("Beschreibung", lambda o: o.beschreibung or ""),
("Notizen", lambda o: o.notizen or ""),
],
},
"rentmeister": {
"model": Rentmeister,
"label": "Rentmeister",
"queryset": lambda: Rentmeister.objects.all().order_by("nachname", "vorname"),
"fields": [
("Anrede", lambda o: o.get_anrede_display() if o.anrede else ""),
("Vorname", lambda o: o.vorname),
("Nachname", lambda o: o.nachname),
("Titel", lambda o: o.titel or ""),
("E-Mail", lambda o: o.email or ""),
("Telefon", lambda o: o.telefon or ""),
("Mobil", lambda o: o.mobil or ""),
("Straße", lambda o: o.strasse or ""),
("PLZ", lambda o: o.plz or ""),
("Ort", lambda o: o.ort or ""),
("IBAN", lambda o: o.iban or ""),
("BIC", lambda o: o.bic or ""),
("Bank", lambda o: o.bank_name or ""),
("Seit", lambda o: _date_fmt(o.seit_datum)),
("Bis", lambda o: _date_fmt(o.bis_datum)),
("Monatliche_Vergütung", lambda o: _decimal_fmt(o.monatliche_verguetung)),
("Km_Pauschale", lambda o: _decimal_fmt(o.km_pauschale)),
("Aktiv", lambda o: _bool_fmt(o.aktiv)),
("Notizen", lambda o: o.notizen or ""),
],
},
}
# ---------------------------------------------------------------------------
# Import field definitions for field mapping
# Each: (display_label, model_field, field_type, required)
# ---------------------------------------------------------------------------
IMPORT_FIELD_DEFINITIONS = {
"destinataere": {
"model": Destinataer,
"label": "Destinatäre",
"unique_fields": ["vorname", "nachname"],
"fields": [
("Vorname", "vorname", "text", True),
("Nachname", "nachname", "text", True),
("Geburtsdatum", "geburtsdatum", "date", False),
("E-Mail", "email", "text", False),
("Telefon", "telefon", "text", False),
("IBAN", "iban", "text", False),
("Straße", "strasse", "text", False),
("PLZ", "plz", "text", False),
("Ort", "ort", "text", False),
("Familienzweig", "familienzweig", "text", False),
("Berufsgruppe", "berufsgruppe", "text", False),
("Ausbildungsstand", "ausbildungsstand", "text", False),
("Institution", "institution", "text", False),
("Projektbeschreibung", "projekt_beschreibung", "text", False),
("Jährliches Einkommen", "jaehrliches_einkommen", "decimal", False),
("Finanzielle Notlage", "finanzielle_notlage", "bool", False),
("Ist Abkömmling", "ist_abkoemmling", "bool", False),
("Haushaltsgröße", "haushaltsgroesse", "int", False),
("Monatliche Bezüge", "monatliche_bezuege", "decimal", False),
("Vermögen", "vermoegen", "decimal", False),
("Unterstützung bestätigt", "unterstuetzung_bestaetigt", "bool", False),
("Vierteljährlicher Betrag", "vierteljaehrlicher_betrag", "decimal", False),
("Studiennachweis erforderlich", "studiennachweis_erforderlich", "bool", False),
("Letzter Studiennachweis", "letzter_studiennachweis", "date", False),
("Notizen", "notizen", "text", False),
("Aktiv", "aktiv", "bool", False),
],
},
"paechter": {
"model": Paechter,
"label": "Pächter",
"unique_fields": ["vorname", "nachname"],
"fields": [
("Vorname", "vorname", "text", True),
("Nachname", "nachname", "text", True),
("Geburtsdatum", "geburtsdatum", "date", False),
("E-Mail", "email", "text", False),
("Telefon", "telefon", "text", False),
("IBAN", "iban", "text", False),
("Straße", "strasse", "text", False),
("PLZ", "plz", "text", False),
("Ort", "ort", "text", False),
("Personentyp", "personentyp", "text", False),
("Pachtnummer", "pachtnummer", "text", False),
("Pachtbeginn Erste", "pachtbeginn_erste", "date", False),
("Pachtende Letzte", "pachtende_letzte", "date", False),
("Pachtzins Aktuell", "pachtzins_aktuell", "decimal", False),
("Landw. Ausbildung", "landwirtschaftliche_ausbildung", "bool", False),
("Berufserfahrung Jahre", "berufserfahrung_jahre", "int", False),
("Spezialisierung", "spezialisierung", "text", False),
("Notizen", "notizen", "text", False),
("Aktiv", "aktiv", "bool", False),
],
},
"laendereien": {
"model": Land,
"label": "Ländereien",
"unique_fields": ["lfd_nr"],
"unique_fields_alt": ["gemeinde", "gemarkung", "flur", "flurstueck"],
"fields": [
("Lfd Nr", "lfd_nr", "text", False),
("EW Nummer", "ew_nummer", "text", False),
("Grundbuchblatt", "grundbuchblatt", "text", False),
("ALKIS Kennzeichen", "alkis_kennzeichen", "text", False),
("Amtsgericht", "amtsgericht", "text", False),
("Gemeinde", "gemeinde", "text", False),
("Gemarkung", "gemarkung", "text", False),
("Flur", "flur", "text", False),
("Flurstück", "flurstueck", "text", False),
("Adresse", "adresse", "text", False),
("Größe qm", "groesse_qm", "decimal", False),
("Grünland qm", "gruenland_qm", "decimal", False),
("Acker qm", "acker_qm", "decimal", False),
("Wald qm", "wald_qm", "decimal", False),
("Sonstiges qm", "sonstiges_qm", "decimal", False),
("Verpachtete Gesamtfläche", "verpachtete_gesamtflaeche", "decimal", False),
("Verp Fläche aktuell", "verp_flaeche_aktuell", "decimal", False),
("Pächter Name", "paechter_name", "text", False),
("Pächter Anschrift", "paechter_anschrift", "text", False),
("Pachtbeginn", "pachtbeginn", "date", False),
("Pachtende", "pachtende", "date", False),
("Verlängerung Klausel", "verlaengerung_klausel", "text", False),
("Zahlungsweise", "zahlungsweise", "text", False),
("Pachtzins pro ha", "pachtzins_pro_ha", "decimal", False),
("Pachtzins pauschal", "pachtzins_pauschal", "decimal", False),
("USt Option", "ust_option", "bool", False),
("USt Satz", "ust_satz", "decimal", False),
("Grundsteuer Umlage", "grundsteuer_umlage", "bool", False),
("Versicherungen Umlage", "versicherungen_umlage", "bool", False),
("Verbandsbeiträge Umlage", "verbandsbeitraege_umlage", "bool", False),
("Jagdpacht Anteil Umlage", "jagdpacht_anteil_umlage", "bool", False),
("Anteil Grundsteuer", "anteil_grundsteuer", "decimal", False),
("Anteil LWK", "anteil_lwk", "decimal", False),
("Aktiv", "aktiv", "bool", False),
("Notizen", "notizen", "text", False),
],
},
"foerderungen": {
"model": Foerderung,
"label": "Förderungen",
"unique_fields": [],
"fields": [
("Destinatär Vorname", "_destinataer_vorname", "text", True),
("Destinatär Nachname", "_destinataer_nachname", "text", True),
("Jahr", "jahr", "int", True),
("Betrag", "betrag", "decimal", True),
("Kategorie", "kategorie", "text", False),
("Status", "status", "text", False),
("Antragsdatum", "antragsdatum", "date", False),
("Entscheidungsdatum", "entscheidungsdatum", "date", False),
("Bemerkungen", "bemerkungen", "text", False),
],
},
"konten": {
"model": StiftungsKonto,
"label": "Stiftungskonten",
"unique_fields": ["iban"],
"fields": [
("Kontoname", "kontoname", "text", True),
("Bank", "bank_name", "text", False),
("IBAN", "iban", "text", False),
("BIC", "bic", "text", False),
("Konto Typ", "konto_typ", "text", False),
("Saldo", "saldo", "decimal", False),
("Zinssatz", "zinssatz", "decimal", False),
("Aktiv", "aktiv", "bool", False),
("Notizen", "notizen", "text", False),
],
},
"verwaltungskosten": {
"model": Verwaltungskosten,
"label": "Verwaltungskosten",
"unique_fields": [],
"fields": [
("Bezeichnung", "bezeichnung", "text", True),
("Kategorie", "kategorie", "text", False),
("Betrag", "betrag", "decimal", True),
("Datum", "datum", "date", True),
("Lieferant", "lieferant_firma", "text", False),
("Rechnungsnummer", "rechnungsnummer", "text", False),
("Status", "status", "text", False),
("Beschreibung", "beschreibung", "text", False),
("Notizen", "notizen", "text", False),
],
},
"rentmeister": {
"model": Rentmeister,
"label": "Rentmeister",
"unique_fields": ["vorname", "nachname"],
"fields": [
("Anrede", "anrede", "text", False),
("Vorname", "vorname", "text", True),
("Nachname", "nachname", "text", True),
("Titel", "titel", "text", False),
("E-Mail", "email", "text", False),
("Telefon", "telefon", "text", False),
("Mobil", "mobil", "text", False),
("Straße", "strasse", "text", False),
("PLZ", "plz", "text", False),
("Ort", "ort", "text", False),
("IBAN", "iban", "text", False),
("BIC", "bic", "text", False),
("Bank", "bank_name", "text", False),
("Seit", "seit_datum", "date", False),
("Bis", "bis_datum", "date", False),
("Monatliche Vergütung", "monatliche_verguetung", "decimal", False),
("Km Pauschale", "km_pauschale", "decimal", False),
("Aktiv", "aktiv", "bool", False),
("Notizen", "notizen", "text", False),
],
},
}
# ---------------------------------------------------------------------------
# Value parsing helpers
# ---------------------------------------------------------------------------
def _parse_bool(value):
if not value:
return None
v = str(value).strip().lower()
if v in ("true", "ja", "yes", "1", "wahr", "x"):
return True
if v in ("false", "nein", "no", "0", "falsch", ""):
return False
return None
def _parse_date(value):
if not value or not str(value).strip():
return None
v = str(value).strip()
for fmt in ("%d.%m.%Y", "%Y-%m-%d", "%d/%m/%Y"):
try:
return datetime.strptime(v, fmt).date()
except ValueError:
continue
return None
def _parse_decimal(value):
if not value or not str(value).strip():
return None
v = str(value).strip().replace(",", ".")
try:
return Decimal(v)
except (InvalidOperation, ValueError):
return None
def _parse_int(value):
if not value or not str(value).strip():
return None
try:
return int(str(value).strip().replace(",", "").replace(".", ""))
except ValueError:
return None
def _parse_value(raw, field_type):
"""Parse a raw CSV string into the appropriate Python type."""
if field_type == "date":
return _parse_date(raw)
elif field_type == "decimal":
return _parse_decimal(raw)
elif field_type == "int":
return _parse_int(raw)
elif field_type == "bool":
return _parse_bool(raw)
else:
return str(raw).strip() if raw else None
# ---------------------------------------------------------------------------
# Views
# ---------------------------------------------------------------------------
@login_required
def import_export_hub(request):
"""Unified import/export hub page."""
# Get recent imports for display
recent_imports = CSVImport.objects.all().order_by("-started_at")[:10]
# Count records per entity type
entity_counts = {}
for key, defn in EXPORT_DEFINITIONS.items():
try:
entity_counts[key] = defn["model"].objects.count()
except Exception:
entity_counts[key] = 0
export_types = [
{"key": k, "label": v["label"], "count": entity_counts.get(k, 0)}
for k, v in EXPORT_DEFINITIONS.items()
]
import_types = [
{"key": k, "label": v["label"]}
for k, v in IMPORT_FIELD_DEFINITIONS.items()
]
context = {
"export_types": export_types,
"import_types": import_types,
"recent_imports": recent_imports,
}
return render(request, "stiftung/import_export_hub.html", context)
@login_required
def csv_export(request):
"""Export any entity type as CSV."""
export_type = request.GET.get("type")
if export_type not in EXPORT_DEFINITIONS:
messages.error(request, "Unbekannter Export-Typ.")
return redirect("stiftung:import_export_hub")
defn = EXPORT_DEFINITIONS[export_type]
queryset = defn["queryset"]()
response = HttpResponse(content_type="text/csv; charset=utf-8")
response["Content-Disposition"] = f'attachment; filename="{export_type}_{timezone.now().strftime("%Y%m%d_%H%M")}.csv"'
# BOM for Excel compatibility
response.write("\ufeff")
writer = csv.writer(response, delimiter=";")
headers = [f[0] for f in defn["fields"]]
writer.writerow(headers)
for obj in queryset:
row = []
for _header, extractor in defn["fields"]:
try:
row.append(extractor(obj))
except Exception:
row.append("")
writer.writerow(row)
return response
@login_required
def csv_import_upload(request):
"""Step 1: Upload CSV file and show field mapping UI."""
if request.method != "POST":
return redirect("stiftung:import_export_hub")
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:import_export_hub")
if not csv_file.name.lower().endswith(".csv"):
messages.error(request, "Bitte wählen Sie eine gültige CSV-Datei aus.")
return redirect("stiftung:import_export_hub")
if import_type not in IMPORT_FIELD_DEFINITIONS:
messages.error(request, "Unbekannter Import-Typ.")
return redirect("stiftung:import_export_hub")
defn = IMPORT_FIELD_DEFINITIONS[import_type]
try:
raw_bytes = csv_file.read()
# Try UTF-8 first, fallback to latin-1
try:
decoded = raw_bytes.decode("utf-8-sig")
except UnicodeDecodeError:
decoded = raw_bytes.decode("latin-1")
# Detect delimiter
first_line = decoded.split("\n")[0]
delimiter = ";" if ";" in first_line else ","
reader = csv.reader(io.StringIO(decoded), delimiter=delimiter)
csv_headers = next(reader)
csv_headers = [h.strip() for h in csv_headers]
# Read preview rows (up to 5)
preview_rows = []
for i, row in enumerate(reader):
if i >= 5:
break
preview_rows.append(row)
# Count remaining rows
remaining = sum(1 for _ in reader)
total_rows = len(preview_rows) + remaining
# Auto-match CSV headers to model fields
# Build mapping suggestions based on fuzzy matching
model_fields = defn["fields"] # list of (label, field_name, type, required)
auto_mapping = {}
for csv_idx, csv_header in enumerate(csv_headers):
csv_h_lower = csv_header.lower().replace("_", " ").replace("-", " ").strip()
best_match = ""
best_score = 0
for label, field_name, _ftype, _req in model_fields:
label_lower = label.lower().replace("_", " ").replace("-", " ").strip()
field_lower = field_name.lower().replace("_", " ").strip()
# Exact match
if csv_h_lower == label_lower or csv_h_lower == field_lower:
best_match = field_name
best_score = 100
break
# Partial match
if csv_h_lower in label_lower or label_lower in csv_h_lower:
score = 80
if score > best_score:
best_match = field_name
best_score = score
elif csv_h_lower in field_lower or field_lower in csv_h_lower:
score = 70
if score > best_score:
best_match = field_name
best_score = score
if best_score >= 70:
auto_mapping[str(csv_idx)] = best_match
# Build header_previews: list of dicts with header + first-row preview
header_previews = []
first_row = preview_rows[0] if preview_rows else []
for idx, header in enumerate(csv_headers):
preview_val = first_row[idx] if idx < len(first_row) else ""
header_previews.append({"header": header, "preview": preview_val})
# Store CSV data in session for step 2
request.session["csv_import_data"] = decoded
request.session["csv_import_delimiter"] = delimiter
request.session["csv_import_type"] = import_type
request.session["csv_import_filename"] = csv_file.name
request.session["csv_import_filesize"] = len(raw_bytes)
context = {
"import_type": import_type,
"import_label": defn["label"],
"header_previews": header_previews,
"model_fields": model_fields,
"preview_rows": preview_rows,
"total_rows": total_rows,
"filename": csv_file.name,
"auto_mapping_json": json.dumps(auto_mapping),
}
return render(request, "stiftung/csv_import_mapping.html", context)
except Exception as e:
messages.error(request, f"Fehler beim Lesen der CSV-Datei: {str(e)}")
return redirect("stiftung:import_export_hub")
@login_required
def csv_import_execute(request):
"""Step 2: Execute the import with user-defined field mapping."""
if request.method != "POST":
return redirect("stiftung:import_export_hub")
import_type = request.session.get("csv_import_type")
csv_data = request.session.get("csv_import_data")
delimiter = request.session.get("csv_import_delimiter", ",")
filename = request.session.get("csv_import_filename", "unknown.csv")
filesize = request.session.get("csv_import_filesize", 0)
if not import_type or not csv_data:
messages.error(request, "Keine Import-Daten gefunden. Bitte starten Sie den Import erneut.")
return redirect("stiftung:import_export_hub")
if import_type not in IMPORT_FIELD_DEFINITIONS:
messages.error(request, "Unbekannter Import-Typ.")
return redirect("stiftung:import_export_hub")
defn = IMPORT_FIELD_DEFINITIONS[import_type]
# Import mode: merge (update existing), skip (skip existing), create (always new)
import_mode = request.POST.get("import_mode", "merge")
if import_mode not in ("merge", "skip", "create"):
import_mode = "merge"
# Parse field mapping from POST data
# Format: mapping_0=field_name, mapping_1=field_name, ...
reader = csv.reader(io.StringIO(csv_data), delimiter=delimiter)
csv_headers = next(reader)
csv_headers = [h.strip() for h in csv_headers]
field_mapping = {} # csv_index -> (model_field, field_type)
field_types = {f[1]: f[2] for f in defn["fields"]}
for i in range(len(csv_headers)):
mapped_field = request.POST.get(f"mapping_{i}", "")
if mapped_field and mapped_field != "__skip__":
ftype = field_types.get(mapped_field, "text")
field_mapping[i] = (mapped_field, ftype)
if not field_mapping:
messages.error(request, "Keine Felder zugeordnet. Bitte ordnen Sie mindestens ein Feld zu.")
return redirect("stiftung:import_export_hub")
# Check required fields - warn but don't block (per-row validation will handle it)
required_fields = {f[1] for f in defn["fields"] if f[3]}
mapped_fields = {v[0] for v in field_mapping.values()}
missing_required = required_fields - mapped_fields
missing_required = {f for f in missing_required if not f.startswith("_")}
if missing_required:
field_labels = {f[1]: f[0] for f in defn["fields"]}
missing_labels = [field_labels.get(f, f) for f in missing_required]
messages.warning(
request,
f"Hinweis: Pflichtfelder nicht zugeordnet: {', '.join(missing_labels)}. "
f"Zeilen ohne diese Daten werden übersprungen."
)
# Create import record
csv_import = CSVImport.objects.create(
import_type=import_type,
filename=filename,
file_size=filesize,
created_by=request.user.username if request.user.is_authenticated else "Unknown",
status="processing",
)
# Process the import
model = defn["model"]
unique_fields = defn["unique_fields"]
unique_fields_alt = defn.get("unique_fields_alt", [])
total_rows = 0
imported_rows = 0
failed_rows = 0
skipped_rows = 0
error_log = []
reader = csv.reader(io.StringIO(csv_data), delimiter=delimiter)
next(reader) # Skip header
for row_num, row in enumerate(reader, start=2):
total_rows += 1
try:
# Build data dict from mapping
data = {}
for csv_idx, (model_field, field_type) in field_mapping.items():
if csv_idx < len(row):
raw_value = row[csv_idx]
parsed = _parse_value(raw_value, field_type)
data[model_field] = parsed
# Special handling for Förderungen (link to Destinatär)
if import_type == "foerderungen":
vorname = data.pop("_destinataer_vorname", None)
nachname = data.pop("_destinataer_nachname", None)
if vorname and nachname:
dest = Destinataer.objects.filter(
vorname__iexact=vorname, nachname__iexact=nachname
).first()
if dest:
data["destinataer"] = dest
else:
error_log.append(
f"Zeile {row_num}: Destinatär '{vorname} {nachname}' nicht gefunden"
)
failed_rows += 1
continue
else:
error_log.append(f"Zeile {row_num}: Destinatär Vor-/Nachname erforderlich")
failed_rows += 1
continue
# Validate required fields
required_missing = []
for label, field_name, _ftype, required in defn["fields"]:
if required and not field_name.startswith("_"):
val = data.get(field_name)
if val is None or (isinstance(val, str) and not val.strip()):
required_missing.append(label)
if required_missing:
error_log.append(
f"Zeile {row_num}: Pflichtfelder leer: {', '.join(required_missing)}"
)
failed_rows += 1
continue
# Clean None values from data - don't set fields that weren't mapped
clean_data = {k: v for k, v in data.items() if v is not None and not k.startswith("_")}
# For fields not in clean_data, check if the DB column requires
# a value (NOT NULL without a model default). If so, provide a
# sensible zero-value so the INSERT doesn't fail.
for fname, ftype in ((f[1], f[2]) for f in defn["fields"]):
if fname.startswith("_") or fname in clean_data:
continue
try:
mf = model._meta.get_field(fname)
if not mf.null and not mf.has_default():
if ftype == "decimal":
clean_data[fname] = Decimal("0")
elif ftype == "int":
clean_data[fname] = 0
elif ftype == "bool":
clean_data[fname] = False
elif ftype == "text":
# For unique text fields, generate a value
# instead of empty string to avoid unique violations
if mf.unique:
import uuid as _uuid
clean_data[fname] = f"AUTO-{_uuid.uuid4().hex[:8]}"
else:
clean_data[fname] = ""
except Exception:
pass
# Try to find existing record using user-mapped data (not auto-generated defaults)
existing = None
if import_mode != "create":
# Use original user data (before defaults) for dedup lookup
for uf_set in ([unique_fields] if unique_fields else []) + ([unique_fields_alt] if unique_fields_alt else []):
if existing:
break
lookup = {}
for uf in uf_set:
val = data.get(uf)
if val and (not isinstance(val, str) or val.strip()):
lookup[f"{uf}__iexact"] = val if isinstance(val, str) else val
else:
lookup = None
break
if lookup:
existing = model.objects.filter(**lookup).first()
if existing:
if import_mode == "skip":
skipped_rows += 1
continue
else:
# Merge mode: update existing record with mapped values
for field, value in clean_data.items():
setattr(existing, field, value)
existing.save()
else:
model.objects.create(**clean_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 and (imported_rows > 0 or skipped_rows > 0):
status = "completed"
elif imported_rows > 0 or skipped_rows > 0:
status = "partial"
elif total_rows == 0:
status = "completed"
else:
status = "failed"
# Build skip info for error log
if skipped_rows > 0:
error_log.insert(0, f"Übersprungen: {skipped_rows} bereits vorhandene Einträge")
# Update import record
csv_import.total_rows = total_rows
csv_import.imported_rows = imported_rows
csv_import.failed_rows = failed_rows
csv_import.error_log = "\n".join(error_log) if error_log else None
csv_import.status = status
csv_import.completed_at = timezone.now()
csv_import.save()
# Clean session
for key in ["csv_import_data", "csv_import_delimiter", "csv_import_type",
"csv_import_filename", "csv_import_filesize"]:
request.session.pop(key, None)
skip_info = f", {skipped_rows} übersprungen" if skipped_rows > 0 else ""
if status == "completed":
messages.success(
request,
f"Import erfolgreich! {imported_rows} Datensätze importiert{skip_info}.",
)
elif status == "partial":
messages.warning(
request,
f"Import teilweise erfolgreich. {imported_rows} importiert, {failed_rows} fehlgeschlagen{skip_info}.",
)
else:
messages.error(
request,
f"Import fehlgeschlagen. {failed_rows} Zeilen konnten nicht importiert werden{skip_info}.",
)
return redirect("stiftung:import_export_hub")