- 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>
921 lines
38 KiB
Python
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")
|