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>
This commit is contained in:
920
app/stiftung/views/import_export.py
Normal file
920
app/stiftung/views/import_export.py
Normal file
@@ -0,0 +1,920 @@
|
||||
# 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")
|
||||
Reference in New Issue
Block a user