Enhanced quarterly confirmation system with approval workflow and export improvements

Features added:
-  Fixed quarterly confirmation approval system with URL pattern
-  Added re-approval and status reset functionality for quarterly confirmations
-  Synchronized quarterly approval status with support payment system
-  Enhanced Destinataer export with missing fields (anrede, titel, mobil)
-  Added quarterly confirmation data and documents to export system
-  Fixed address field display issues in destinataer template
-  Added quarterly statistics dashboard to support payment lists
-  Implemented duplicate support payment prevention and cleanup
-  Added visual indicators for quarterly-linked support payments

Technical improvements:
- Enhanced create_quarterly_support_payment() with duplicate detection
- Added get_related_support_payment() method to VierteljahresNachweis model
- Improved quarterly confirmation workflow with proper status transitions
- Added computed address property to Destinataer model
- Fixed template field mismatches (anrede, titel, mobil vs strasse, plz, ort)
- Enhanced backup system with operation tracking and cancellation

Workflow enhancements:
- Quarterly confirmations now properly sync with support payments
- Single support payment per destinataer per quarter (no duplicates)
- Approval button works for both eingereicht and geprueft status
- Reset functionality allows workflow restart
- Export includes complete quarterly data with uploaded documents
This commit is contained in:
2025-09-28 19:09:08 +02:00
parent b00cf62d87
commit acac8695fd
73 changed files with 283380 additions and 206 deletions

View File

@@ -189,42 +189,65 @@ def run_restore(restore_job_id, backup_file_path):
restore_job.started_at = timezone.now()
restore_job.save()
# Verify backup file exists
if not os.path.exists(backup_file_path):
raise Exception(f"Backup file not found: {backup_file_path}")
# Extract backup
with tempfile.TemporaryDirectory() as temp_dir:
extract_dir = os.path.join(temp_dir, "restore")
os.makedirs(extract_dir)
# Extract tar.gz
with tarfile.open(backup_file_path, "r:gz") as tar:
tar.extractall(extract_dir)
try:
with tarfile.open(backup_file_path, "r:gz") as tar:
tar.extractall(extract_dir)
except Exception as e:
raise Exception(f"Failed to extract backup file: {e}")
# Validate backup
metadata_file = os.path.join(extract_dir, "backup_metadata.json")
if not os.path.exists(metadata_file):
raise Exception("Invalid backup: missing metadata")
metadata_files = [name for name in os.listdir(extract_dir) if name.endswith('backup_metadata.json')]
if not metadata_files:
raise Exception("Invalid backup: missing metadata file")
# Read metadata
import json
with open(metadata_file, "r") as f:
metadata = json.load(f)
try:
metadata_file = os.path.join(extract_dir, metadata_files[0])
with open(metadata_file, "r") as f:
metadata = json.load(f)
print(f"Restoring backup created at: {metadata.get('created_at', 'unknown')}")
except Exception as e:
print(f"Warning: Could not read backup metadata: {e}")
# Restore database
db_backup_file = os.path.join(extract_dir, "database.sql")
if os.path.exists(db_backup_file):
print("Restoring database...")
restore_database(db_backup_file)
print("Database restore completed")
else:
print("No database backup found in archive")
# Restore files
files_dir = os.path.join(extract_dir, "files")
if os.path.exists(files_dir):
print("Restoring files...")
restore_files(files_dir)
print("Files restore completed")
else:
print("No files backup found in archive")
# Update job status
restore_job.status = "completed"
restore_job.completed_at = timezone.now()
restore_job.save()
print(f"Restore job {restore_job_id} completed successfully")
except Exception as e:
print(f"Restore job {restore_job_id} failed: {e}")
restore_job = BackupJob.objects.get(id=restore_job_id)
restore_job.status = "failed"
restore_job.error_message = str(e)
restore_job.completed_at = timezone.now()
@@ -234,49 +257,151 @@ def run_restore(restore_job_id, backup_file_path):
def restore_database(db_backup_file):
"""Restore database from backup"""
try:
print(f"Starting database restore from: {db_backup_file}")
# Get database settings
db_settings = settings.DATABASES["default"]
print(f"Database settings: {db_settings.get('NAME')} at {db_settings.get('HOST')}:{db_settings.get('PORT')}")
# Build pg_restore command
cmd = [
"pg_restore",
"--host",
db_settings.get("HOST", "localhost"),
"--port",
str(db_settings.get("PORT", 5432)),
"--username",
db_settings.get("USER", "postgres"),
"--dbname",
db_settings.get("NAME", "stiftung"),
"--clean", # Drop existing objects first
"--if-exists", # Don't error if objects don't exist
"--no-owner", # don't attempt to set original owners
"--role",
db_settings.get("USER", "postgres"), # set target owner
"--single-transaction", # restore atomically when possible
"--disable-triggers", # avoid FK issues during data load
"--no-password",
"--verbose",
db_backup_file,
]
# First, try to determine if this is a custom format or SQL format
# by checking if the file starts with binary data (custom format)
is_custom_format = False
try:
with open(db_backup_file, 'rb') as f:
header = f.read(8)
# Custom format files start with 'PGDMP' followed by version info
if header.startswith(b'PGDMP'):
is_custom_format = True
print(f"Detected custom format backup (header: {header})")
else:
print(f"Detected SQL format backup (header: {header})")
except Exception as e:
print(f"Could not determine backup format, assuming SQL: {e}")
if is_custom_format:
print("Using pg_restore for custom format")
# Use pg_restore for custom format
cmd = [
"pg_restore",
"--host",
db_settings.get("HOST", "localhost"),
"--port",
str(db_settings.get("PORT", 5432)),
"--username",
db_settings.get("USER", "postgres"),
"--dbname",
db_settings.get("NAME", "stiftung"),
"--clean", # Drop existing objects first
"--if-exists", # Don't error if objects don't exist
"--no-owner", # don't attempt to set original owners
"--role",
db_settings.get("USER", "postgres"), # set target owner
# Remove --single-transaction to allow partial restore even with configuration errors
"--disable-triggers", # avoid FK issues during data load
"--no-password",
"--verbose",
# Remove --exit-on-error to allow continuation after configuration warnings
db_backup_file,
]
else:
print("Using psql for SQL format")
# Use psql for SQL format
cmd = [
"psql",
"--host",
db_settings.get("HOST", "localhost"),
"--port",
str(db_settings.get("PORT", 5432)),
"--username",
db_settings.get("USER", "postgres"),
"--dbname",
db_settings.get("NAME", "stiftung"),
"--no-password",
"--file",
db_backup_file,
]
print(f"Running command: {' '.join(cmd)}")
# Set environment variables for authentication
env = os.environ.copy()
env["PGPASSWORD"] = db_settings.get("PASSWORD", "")
# Run pg_restore
# Run the restore command
result = subprocess.run(cmd, env=env, capture_output=True, text=True)
print(f"Command exit code: {result.returncode}")
print(f"STDOUT length: {len(result.stdout)} chars")
print(f"STDERR length: {len(result.stderr)} chars")
# Show first 500 chars of output for debugging
if result.stdout:
print(f"STDOUT (first 500 chars): {result.stdout[:500]}...")
if result.stderr:
print(f"STDERR (first 500 chars): {result.stderr[:500]}...")
# Fail if there are real errors
# Handle different error conditions more gracefully
if result.returncode != 0:
stderr = result.stderr or ""
# escalate only if we see ERROR
if "ERROR" in stderr.upper():
raise Exception(f"pg_restore failed: {stderr}")
stdout = result.stdout or ""
# Check for known configuration parameter issues
if "unrecognized configuration parameter" in stderr:
print(f"Warning: Configuration parameter issues detected, but continuing: {stderr[:200]}...")
# For configuration parameter issues, we'll consider this a warning, not a fatal error
# if there are no other serious errors
serious_errors = [line for line in stderr.split('\n')
if 'ERROR' in line and 'unrecognized configuration parameter' not in line]
if serious_errors:
print(f"Serious errors found: {serious_errors}")
raise Exception(f"pg_restore failed with serious errors: {'; '.join(serious_errors)}")
else:
print("Restore completed with configuration warnings (non-fatal)")
elif "ERROR" in stderr.upper():
# Look for specific error patterns we can ignore
ignorable_patterns = [
"already exists",
"does not exist",
"unrecognized configuration parameter"
]
error_lines = [line for line in stderr.split('\n') if 'ERROR' in line]
serious_errors = []
for error_line in error_lines:
is_ignorable = any(pattern in error_line for pattern in ignorable_patterns)
if not is_ignorable:
serious_errors.append(error_line)
if serious_errors:
print(f"Serious errors found: {serious_errors}")
raise Exception(f"Database restore failed with errors: {'; '.join(serious_errors)}")
else:
print(f"Restore completed with ignorable warnings")
else:
print(f"pg_restore completed with warnings: {stderr}")
print(f"Restore completed with warnings but no errors")
else:
print("Database restore completed successfully with no errors")
# Verify data was actually restored by checking table counts
try:
print("Verifying data was restored...")
from django.db import connection
with connection.cursor() as cursor:
# Check some key tables
test_tables = ['stiftung_person', 'stiftung_land', 'stiftung_destinataer']
for table in test_tables:
try:
cursor.execute(f"SELECT COUNT(*) FROM {table}")
count = cursor.fetchone()[0]
print(f"Table {table}: {count} rows")
except Exception as e:
print(f"Could not check table {table}: {e}")
except Exception as e:
print(f"Could not verify data restoration: {e}")
except Exception as e:
print(f"Database restore failed with exception: {e}")
raise Exception(f"Database restore failed: {e}")
@@ -335,3 +460,51 @@ def cleanup_old_backups(keep_count=10):
except Exception as e:
print(f"Cleanup failed: {e}")
def validate_backup_file(backup_file_path):
"""Validate that a backup file is valid and can be restored"""
try:
if not os.path.exists(backup_file_path):
return False, "Backup file does not exist"
if not backup_file_path.endswith('.tar.gz'):
return False, "Invalid file format. Only .tar.gz files are supported"
# Try to open and extract metadata
with tempfile.TemporaryDirectory() as temp_dir:
try:
with tarfile.open(backup_file_path, "r:gz") as tar:
# Check if it contains expected files
names = tar.getnames()
# Look for metadata file (could be with or without ./ prefix)
metadata_files = [name for name in names if name.endswith('backup_metadata.json')]
if not metadata_files:
return False, "Invalid backup: missing metadata"
# Extract and validate metadata
metadata_file = metadata_files[0]
tar.extract(metadata_file, temp_dir)
extracted_metadata = os.path.join(temp_dir, metadata_file)
import json
with open(extracted_metadata, "r") as f:
metadata = json.load(f)
# Check metadata structure
if "backup_type" not in metadata:
return False, "Invalid backup metadata"
created_at = metadata.get('created_at', 'unknown date')
backup_type = metadata.get('backup_type', 'unknown type')
return True, f"Valid {backup_type} backup from {created_at}"
except tarfile.TarError as e:
return False, f"Corrupted backup file: {e}"
except json.JSONDecodeError:
return False, "Invalid backup metadata format"
except Exception as e:
return False, f"Validation failed: {e}"

View File

@@ -0,0 +1,23 @@
# Generated by Django 5.0.6 on 2025-09-24 20:59
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('stiftung', '0030_vierteljahresnachweis'),
]
operations = [
migrations.AlterField(
model_name='land',
name='ust_satz',
field=models.DecimalField(blank=True, decimal_places=2, default=19.0, max_digits=4, null=True, verbose_name='USt-Satz (%)'),
),
migrations.AlterField(
model_name='land',
name='zahlungsweise',
field=models.CharField(blank=True, choices=[('jaehrlich', 'Jährlich'), ('halbjaehrlich', 'Halbjährlich'), ('vierteljaehrlich', 'Vierteljährlich'), ('monatlich', 'Monatlich')], default='jaehrlich', max_length=20, null=True, verbose_name='Zahlungsweise'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.0.6 on 2025-09-24 21:28
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('stiftung', '0031_alter_land_ust_satz_alter_land_zahlungsweise'),
]
operations = [
migrations.AddField(
model_name='backupjob',
name='operation',
field=models.CharField(choices=[('backup', 'Backup'), ('restore', 'Wiederherstellung')], default='backup', max_length=20, verbose_name='Vorgang'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.0.6 on 2025-09-24 21:42
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('stiftung', '0032_backupjob_operation'),
]
operations = [
migrations.AlterField(
model_name='backupjob',
name='status',
field=models.CharField(choices=[('pending', 'Wartend'), ('running', 'Läuft'), ('completed', 'Abgeschlossen'), ('failed', 'Fehlgeschlagen'), ('cancelled', 'Abgebrochen')], default='pending', max_length=20, verbose_name='Status'),
),
]

View File

@@ -327,6 +327,21 @@ class Destinataer(models.Model):
"""Get the most recent funding grant"""
return self.foerderung_set.order_by("-jahr", "-betrag").first()
@property
def adresse(self):
"""Construct full address from separate fields"""
parts = []
if self.strasse:
parts.append(self.strasse)
if self.plz or self.ort:
city_part = []
if self.plz:
city_part.append(self.plz)
if self.ort:
city_part.append(self.ort)
parts.append(" ".join(city_part))
return "\n".join(parts) if parts else ""
def erfuellt_voraussetzungen(self):
"""Prüft die Unterstützungsvoraussetzungen gemäß Angaben.
- Abkömmling muss True sein
@@ -347,6 +362,20 @@ class Destinataer(models.Model):
vermoegen_ok = (self.vermoegen or Decimal("0")) <= Decimal("15500")
return bool(self.ist_abkoemmling and einkommen_ok and vermoegen_ok)
@property
def adresse(self):
"""Computed address property combining strasse, plz, ort"""
parts = []
if self.strasse:
parts.append(self.strasse)
if self.plz and self.ort:
parts.append(f"{self.plz} {self.ort}")
elif self.plz:
parts.append(self.plz)
elif self.ort:
parts.append(self.ort)
return "\n".join(parts) if parts else None
def naechste_studiennachweis_termine(self):
"""Gibt die nächsten beiden Stichtage (15.03, 15.09) zurück."""
import datetime as _dt
@@ -522,6 +551,8 @@ class Land(models.Model):
max_length=20,
choices=ZAHLUNGSWEISE_CHOICES,
default="jaehrlich",
null=True,
blank=True,
verbose_name="Zahlungsweise",
)
pachtzins_pro_ha = models.DecimalField(
@@ -544,7 +575,12 @@ class Land(models.Model):
# Umsatzsteuer
ust_option = models.BooleanField(default=False, verbose_name="USt-Option")
ust_satz = models.DecimalField(
max_digits=4, decimal_places=2, default=19.00, verbose_name="USt-Satz (%)"
max_digits=4,
decimal_places=2,
default=19.00,
null=True,
blank=True,
verbose_name="USt-Satz (%)"
)
# Umlagen (Durchreichungen)
@@ -2234,6 +2270,7 @@ class BackupJob(models.Model):
("running", "Läuft"),
("completed", "Abgeschlossen"),
("failed", "Fehlgeschlagen"),
("cancelled", "Abgebrochen"),
]
TYPE_CHOICES = [
@@ -2242,9 +2279,17 @@ class BackupJob(models.Model):
("files", "Nur Dateien"),
]
OPERATION_CHOICES = [
("backup", "Backup"),
("restore", "Wiederherstellung"),
]
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
# Job-Details
operation = models.CharField(
max_length=20, choices=OPERATION_CHOICES, default="backup", verbose_name="Vorgang"
)
backup_type = models.CharField(
max_length=20, choices=TYPE_CHOICES, verbose_name="Backup-Typ"
)
@@ -2725,6 +2770,20 @@ class VierteljahresNachweis(models.Model):
super().save(*args, **kwargs)
def get_related_support_payment(self):
"""Get the related support payment for this quarterly confirmation"""
from datetime import datetime
quarter_start = datetime(self.jahr, (self.quartal - 1) * 3 + 1, 1).date()
quarter_end = datetime(self.jahr, self.quartal * 3, 1).date()
return DestinataerUnterstuetzung.objects.filter(
destinataer=self.destinataer,
faellig_am__gte=quarter_start,
faellig_am__lt=quarter_end,
beschreibung__contains=f"Q{self.quartal}/{self.jahr}"
).first()
@classmethod
def get_or_create_for_period(cls, destinataer, jahr, quartal):
"""Get or create a quarterly confirmation for a specific period"""

View File

@@ -262,6 +262,11 @@ urlpatterns = [
name="backup_download",
),
path("administration/backup/restore/", views.backup_restore, name="backup_restore"),
path(
"administration/backup/<uuid:backup_id>/cancel/",
views.backup_cancel,
name="backup_cancel",
),
path(
"administration/unterstuetzungen/",
views.unterstuetzungen_list,
@@ -364,4 +369,14 @@ urlpatterns = [
views.quarterly_confirmation_update,
name="quarterly_confirmation_update",
),
path(
"quarterly-confirmations/<uuid:pk>/approve/",
views.quarterly_confirmation_approve,
name="quarterly_confirmation_approve",
),
path(
"quarterly-confirmations/<uuid:pk>/reset/",
views.quarterly_confirmation_reset,
name="quarterly_confirmation_reset",
),
]

View File

@@ -1705,16 +1705,23 @@ def land_list(request):
sum_wald_qm=Sum("wald_qm"),
sum_sonstiges_qm=Sum("sonstiges_qm"),
)
sum_groesse_qm = float(aggregates.get("sum_groesse_qm") or 0)
sum_gruenland_qm = float(aggregates.get("sum_gruenland_qm") or 0)
sum_acker_qm = float(aggregates.get("sum_acker_qm") or 0)
sum_wald_qm = float(aggregates.get("sum_wald_qm") or 0)
sum_sonstiges_qm = float(aggregates.get("sum_sonstiges_qm") or 0)
sum_total_use_qm = sum_gruenland_qm + sum_acker_qm + sum_wald_qm + sum_sonstiges_qm
# Calculate verpachtung statistics
total_plots = lands.count()
verpachtete_plots = lands.filter(verp_flaeche_aktuell__gt=0).count()
unveerpachtete_plots = total_plots - verpachtete_plots
def pct(part, total):
return round((part / total) * 100, 1) if total and part is not None else 0.0
stats = {
"sum_groesse_qm": sum_groesse_qm,
"sum_gruenland_qm": sum_gruenland_qm,
"sum_acker_qm": sum_acker_qm,
"sum_wald_qm": sum_wald_qm,
@@ -1723,6 +1730,11 @@ def land_list(request):
"pct_gruenland": pct(sum_gruenland_qm, sum_total_use_qm),
"pct_acker": pct(sum_acker_qm, sum_total_use_qm),
"pct_wald": pct(sum_wald_qm, sum_total_use_qm),
"total_plots": total_plots,
"verpachtete_plots": verpachtete_plots,
"unveerpachtete_plots": unveerpachtete_plots,
"pct_verpachtet": pct(verpachtete_plots, total_plots),
"pct_unveerpachtet": pct(unveerpachtete_plots, total_plots),
}
# Prepare size chart data (top 30 by size)
@@ -1783,10 +1795,31 @@ def land_detail(request, pk):
def land_create(request):
if request.method == "POST":
form = LandForm(request.POST)
# Debug: Print form data
print("=== LAND CREATE DEBUG ===")
print(f"POST data: {dict(request.POST)}")
print(f"Form is valid: {form.is_valid()}")
if not form.is_valid():
print(f"Form errors: {form.errors}")
print(f"Form non-field errors: {form.non_field_errors()}")
# Add error messages for debugging
for field, errors in form.errors.items():
for error in errors:
messages.error(request, f"{field}: {error}")
if form.is_valid():
land = form.save()
messages.success(request, f'Länderei "{land}" wurde erfolgreich erstellt.')
return redirect("stiftung:land_detail", pk=land.pk)
try:
land = form.save()
messages.success(request, f'Länderei "{land}" wurde erfolgreich erstellt.')
print(f"Successfully created land: {land}")
return redirect("stiftung:land_detail", pk=land.pk)
except Exception as e:
print(f"Error saving land: {e}")
messages.error(request, f"Fehler beim Speichern: {str(e)}")
else:
messages.error(request, "Bitte korrigieren Sie die Fehler im Formular.")
else:
form = LandForm()
@@ -4456,9 +4489,22 @@ def unterstuetzungen_list(request):
# Enhanced PDF export with corporate identity
elif export_format == "pdf":
return export_unterstuetzungen_pdf(request, qs, selected_ids)
# Get quarterly confirmation statistics
quarterly_stats = {}
total_quarterly = VierteljahresNachweis.objects.count()
for status_code, status_name in VierteljahresNachweis.STATUS_CHOICES:
count = VierteljahresNachweis.objects.filter(status=status_code).count()
quarterly_stats[status_code] = {
'name': status_name,
'count': count
}
context = {
"unterstuetzungen": qs,
"status_filter": status,
"quarterly_stats": quarterly_stats,
"total_quarterly": total_quarterly,
}
return render(request, "stiftung/unterstuetzungen_list.html", context)
@@ -5193,6 +5239,8 @@ def destinataer_export(request, pk):
# 1. Entity data as JSON
entity_data = {
"id": str(destinataer.id),
"anrede": destinataer.get_anrede_display() if hasattr(destinataer, 'anrede') and destinataer.anrede else None,
"titel": destinataer.titel if hasattr(destinataer, 'titel') else None,
"vorname": destinataer.vorname,
"nachname": destinataer.nachname,
"geburtsdatum": (
@@ -5202,6 +5250,7 @@ def destinataer_export(request, pk):
),
"email": destinataer.email,
"telefon": destinataer.telefon,
"mobil": destinataer.mobil if hasattr(destinataer, 'mobil') else None,
"iban": destinataer.iban,
"strasse": destinataer.strasse,
"plz": destinataer.plz,
@@ -5340,6 +5389,69 @@ def destinataer_export(request, pk):
json.dumps(docs_data, indent=2, ensure_ascii=False),
)
# 4. Quarterly Confirmations with documents
quarterly_confirmations = destinataer.quartalseinreichungen.all().order_by("-jahr", "-quartal")
quarterly_data = []
for confirmation in quarterly_confirmations:
confirmation_data = {
"id": str(confirmation.id),
"jahr": confirmation.jahr,
"quartal": confirmation.quartal,
"quartal_display": confirmation.get_quartal_display(),
"status": confirmation.status,
"status_display": confirmation.get_status_display(),
"studiennachweis_erforderlich": confirmation.studiennachweis_erforderlich,
"studiennachweis_eingereicht": confirmation.studiennachweis_eingereicht,
"studiennachweis_bemerkung": confirmation.studiennachweis_bemerkung,
"einkommenssituation_bestaetigt": confirmation.einkommenssituation_bestaetigt,
"einkommenssituation_text": confirmation.einkommenssituation_text,
"vermogenssituation_bestaetigt": confirmation.vermogenssituation_bestaetigt,
"vermogenssituation_text": confirmation.vermogenssituation_text,
"weitere_dokumente_beschreibung": confirmation.weitere_dokumente_beschreibung,
"interne_notizen": confirmation.interne_notizen,
"erstellt_am": confirmation.erstellt_am.isoformat(),
"aktualisiert_am": confirmation.aktualisiert_am.isoformat(),
"eingereicht_am": confirmation.eingereicht_am.isoformat() if confirmation.eingereicht_am else None,
"geprueft_am": confirmation.geprueft_am.isoformat() if confirmation.geprueft_am else None,
"geprueft_von": confirmation.geprueft_von.username if confirmation.geprueft_von else None,
"faelligkeitsdatum": confirmation.faelligkeitsdatum.isoformat() if confirmation.faelligkeitsdatum else None,
"completion_percentage": confirmation.completion_percentage(),
"uploaded_files": []
}
# Add uploaded files from quarterly confirmation
quarterly_files = [
("studiennachweis", confirmation.studiennachweis_datei),
("einkommenssituation", confirmation.einkommenssituation_datei),
("vermogenssituation", confirmation.vermogenssituation_datei),
("weitere_dokumente", confirmation.weitere_dokumente),
]
for file_type, file_field in quarterly_files:
if file_field and os.path.exists(file_field.path):
file_info = {
"type": file_type,
"name": os.path.basename(file_field.name),
"path": file_field.name
}
confirmation_data["uploaded_files"].append(file_info)
# Add file to ZIP
safe_filename = f"quarterly_{confirmation.jahr}_Q{confirmation.quartal}_{file_type}_{os.path.basename(file_field.name)}"
zipf.write(
file_field.path,
f"vierteljahresnachweis/{safe_filename}"
)
quarterly_data.append(confirmation_data)
if quarterly_data:
zipf.writestr(
"vierteljahresnachweis.json",
json.dumps(quarterly_data, indent=2, ensure_ascii=False),
)
# Prepare response
with open(temp_file.name, "rb") as f:
response = HttpResponse(f.read(), content_type="application/zip")
@@ -5888,59 +6000,119 @@ def backup_restore(request):
messages.error(request, "Bitte wählen Sie eine Backup-Datei aus.")
return redirect("stiftung:backup_management")
# Validate file
# Validate file format
if not backup_file.name.endswith(".tar.gz"):
messages.error(
request, "Ungültiges Backup-Format. Nur .tar.gz Dateien sind erlaubt."
)
return redirect("stiftung:backup_management")
# Save uploaded file
# Save uploaded file to temporary location
import os
import tempfile
temp_dir = tempfile.mkdtemp()
backup_path = os.path.join(temp_dir, backup_file.name)
with open(backup_path, "wb+") as destination:
for chunk in backup_file.chunks():
destination.write(chunk)
try:
with open(backup_path, "wb+") as destination:
for chunk in backup_file.chunks():
destination.write(chunk)
# Create restore job
restore_job = BackupJob.objects.create(
backup_type="full",
created_by=request.user,
backup_filename=backup_file.name,
)
# Validate the backup file
from stiftung.backup_utils import validate_backup_file
is_valid, message = validate_backup_file(backup_path)
if not is_valid:
messages.error(request, f"Ungültiges Backup: {message}")
return redirect("stiftung:backup_management")
# Show validation success
messages.info(request, f"Backup validiert: {message}")
# Log restore initiation
# Create restore job
restore_job = BackupJob.objects.create(
operation="restore",
backup_type="full",
created_by=request.user,
backup_filename=backup_file.name,
)
# Log restore initiation
from stiftung.audit import log_system_action
log_system_action(
request=request,
action="restore",
description=f"Wiederherstellung gestartet von: {backup_file.name}",
details={
"restore_job_id": str(restore_job.id),
"filename": backup_file.name,
},
)
# Start restore process
import threading
from stiftung.backup_utils import run_restore
restore_thread = threading.Thread(
target=run_restore, args=(str(restore_job.id), backup_path)
)
restore_thread.start()
messages.success(
request, f'Wiederherstellung von "{backup_file.name}" wurde gestartet. '
f'Überwachen Sie den Fortschritt in der Backup-Historie.'
)
return redirect("stiftung:backup_management")
except Exception as e:
messages.error(request, f"Fehler beim Verarbeiten der Backup-Datei: {e}")
return redirect("stiftung:backup_management")
return redirect("stiftung:backup_management")
@login_required
def backup_cancel(request, backup_id):
"""Cancel a running backup job"""
try:
backup_job = BackupJob.objects.get(id=backup_id)
# Only allow cancelling running or pending jobs
if backup_job.status not in ['running', 'pending']:
messages.error(request, "Nur laufende oder wartende Backups können abgebrochen werden.")
return redirect("stiftung:backup_management")
# Check if user has permission to cancel (either own job or admin)
if backup_job.created_by != request.user and not request.user.is_staff:
messages.error(request, "Sie können nur Ihre eigenen Backup-Jobs abbrechen.")
return redirect("stiftung:backup_management")
# Mark as cancelled
from django.utils import timezone
backup_job.status = "cancelled"
backup_job.completed_at = timezone.now()
backup_job.error_message = f"Abgebrochen von {request.user.username}"
backup_job.save()
# Log the cancellation
from stiftung.audit import log_system_action
log_system_action(
request=request,
action="restore",
description=f"Wiederherstellung gestartet von: {backup_file.name}",
details={
"restore_job_id": str(restore_job.id),
"filename": backup_file.name,
},
action="backup_cancel",
description=f"Backup-Job abgebrochen: {backup_job.get_backup_type_display()}",
details={"backup_job_id": str(backup_job.id)},
)
# Start restore process
import threading
from stiftung.backup_utils import run_restore
restore_thread = threading.Thread(
target=run_restore, args=(str(restore_job.id), backup_path)
)
restore_thread.start()
messages.success(
request, f'Wiederherstellung von "{backup_file.name}" wurde gestartet.'
)
return redirect("stiftung:backup_management")
messages.success(request, f"Backup-Job wurde abgebrochen.")
except BackupJob.DoesNotExist:
messages.error(request, "Backup-Job nicht gefunden.")
except Exception as e:
messages.error(request, f"Fehler beim Abbrechen des Backup-Jobs: {e}")
return redirect("stiftung:backup_management")
@@ -6857,7 +7029,16 @@ def unterstuetzungen_all(request):
# Statistics
total_betrag = unterstuetzungen.aggregate(total=Sum("betrag"))["total"] or 0
avg_betrag = unterstuetzungen.aggregate(avg=Avg("betrag"))["avg"] or 0
# Get quarterly confirmation statistics
quarterly_stats = {}
total_quarterly = VierteljahresNachweis.objects.count()
for status_code, status_name in VierteljahresNachweis.STATUS_CHOICES:
count = VierteljahresNachweis.objects.filter(status=status_code).count()
quarterly_stats[status_code] = {
'name': status_name,
'count': count
}
# Available destinataer for filter
destinataer = Destinataer.objects.all().order_by("nachname", "vorname")
@@ -6868,7 +7049,8 @@ def unterstuetzungen_all(request):
"title": "Alle Unterstützungen",
"status_filter": status,
"total_betrag": total_betrag,
"avg_betrag": avg_betrag,
"quarterly_stats": quarterly_stats,
"total_quarterly": total_quarterly,
"status_choices": DestinataerUnterstuetzung.STATUS_CHOICES,
"destinataer": destinataer,
}
@@ -7325,7 +7507,8 @@ def quarterly_confirmation_update(request, pk):
def create_quarterly_support_payment(nachweis):
"""
Create an automatic support payment when all quarterly requirements are met
Get or create a single support payment for this quarterly confirmation
Ensures only one payment exists per destinataer per quarter
"""
destinataer = nachweis.destinataer
@@ -7340,18 +7523,31 @@ def create_quarterly_support_payment(nachweis):
if not destinataer.iban:
return None
# Check if a payment for this quarter already exists
# Calculate quarter date range for more robust search
quarter_start = datetime(nachweis.jahr, (nachweis.quartal - 1) * 3 + 1, 1).date()
quarter_end = datetime(nachweis.jahr, nachweis.quartal * 3, 1).date()
if nachweis.quartal == 4: # Q4 special case
quarter_end = datetime(nachweis.jahr + 1, 1, 1).date()
else:
quarter_end = datetime(nachweis.jahr, nachweis.quartal * 3 + 1, 1).date()
# Search for existing payment - use broader criteria to catch all possibilities
existing_payment = DestinataerUnterstuetzung.objects.filter(
destinataer=destinataer,
faellig_am__gte=quarter_start,
faellig_am__lt=quarter_end,
beschreibung__contains=f"Q{nachweis.quartal}/{nachweis.jahr}"
faellig_am__lt=quarter_end
).filter(
Q(beschreibung__contains=f"Q{nachweis.quartal}/{nachweis.jahr}") |
Q(beschreibung__contains=f"Vierteljährliche Unterstützung")
).first()
if existing_payment:
# Update existing payment to ensure it matches current requirements
existing_payment.betrag = destinataer.vierteljaehrlicher_betrag
existing_payment.empfaenger_iban = destinataer.iban
existing_payment.empfaenger_name = destinataer.get_full_name()
existing_payment.verwendungszweck = f"Vierteljährliche Unterstützung Q{nachweis.quartal}/{nachweis.jahr} - {destinataer.get_full_name()}"
existing_payment.beschreibung = f"Vierteljährliche Unterstützung Q{nachweis.quartal}/{nachweis.jahr} (automatisch erstellt)"
existing_payment.save()
return existing_payment
# Get default payment account
@@ -7363,8 +7559,6 @@ def create_quarterly_support_payment(nachweis):
return None
# Calculate payment due date (last day of quarter)
# Quarter end months and their last days:
# Q1: March (31), Q2: June (30), Q3: September (30), Q4: December (31)
quarter_end_month = nachweis.quartal * 3
if nachweis.quartal == 1: # Q1: January-March (ends March 31)
@@ -7544,21 +7738,103 @@ def quarterly_confirmation_approve(request, pk):
nachweis = get_object_or_404(VierteljahresNachweis, pk=pk)
if request.method == "POST":
if nachweis.status == 'eingereicht':
nachweis.status = 'geprueft'
nachweis.geprueft_am = timezone.now()
nachweis.geprueft_von = request.user
nachweis.save()
if nachweis.status in ['eingereicht', 'geprueft']:
# Check if we need to create or update support payment
related_payment = nachweis.get_related_support_payment()
messages.success(
request,
f"Vierteljahresnachweis für {nachweis.destinataer.get_full_name()} "
f"({nachweis.jahr} Q{nachweis.quartal}) wurde freigegeben."
)
if nachweis.status == 'eingereicht' or (nachweis.status == 'geprueft' and not related_payment):
# Approve the quarterly confirmation
nachweis.status = 'geprueft'
nachweis.geprueft_am = timezone.now()
nachweis.geprueft_von = request.user
nachweis.save()
# Handle support payment - create if missing, update if exists
if not related_payment:
# Create new support payment
related_payment = create_quarterly_support_payment(nachweis)
if related_payment:
related_payment.status = 'in_bearbeitung'
related_payment.aktualisiert_am = timezone.now()
related_payment.save()
messages.success(
request,
f"Vierteljahresnachweis freigegeben und neue Unterstützung über {related_payment.betrag}€ für {nachweis.destinataer.get_full_name()} "
f"({nachweis.jahr} Q{nachweis.quartal}) wurde erstellt."
)
else:
messages.warning(
request,
f"Vierteljahresnachweis freigegeben, aber Unterstützung konnte nicht erstellt werden. "
f"Bitte prüfen Sie die Einstellungen für {nachweis.destinataer.get_full_name()}."
)
elif related_payment.status == 'geplant':
# Update existing payment
related_payment.status = 'in_bearbeitung'
related_payment.aktualisiert_am = timezone.now()
related_payment.save()
messages.success(
request,
f"Vierteljahresnachweis und zugehörige Unterstützung für {nachweis.destinataer.get_full_name()} "
f"({nachweis.jahr} Q{nachweis.quartal}) wurden freigegeben."
)
else:
messages.success(
request,
f"Vierteljahresnachweis für {nachweis.destinataer.get_full_name()} "
f"({nachweis.jahr} Q{nachweis.quartal}) wurde freigegeben."
)
else:
messages.error(
request,
"Nur eingereichte Nachweise können freigegeben werden."
"Nur eingereichte oder bereits genehmigte Nachweise können verarbeitet werden."
)
return redirect("stiftung:destinataer_detail", pk=nachweis.destinataer.pk)
@login_required
def quarterly_confirmation_reset(request, pk):
"""Reset quarterly confirmation status (staff only)"""
if not request.user.is_staff:
messages.error(request, "Sie haben keine Berechtigung für diese Aktion.")
return redirect("stiftung:destinataer_list")
nachweis = get_object_or_404(VierteljahresNachweis, pk=pk)
if request.method == "POST":
if nachweis.status in ['geprueft', 'eingereicht']:
# Reset the quarterly confirmation status
nachweis.status = 'eingereicht' if nachweis.is_complete() else 'teilweise'
nachweis.geprueft_am = None
nachweis.geprueft_von = None
nachweis.aktualisiert_am = timezone.now()
nachweis.save()
# Reset related support payment status if it exists
related_payment = nachweis.get_related_support_payment()
if related_payment and related_payment.status == 'in_bearbeitung':
related_payment.status = 'geplant'
related_payment.aktualisiert_am = timezone.now()
related_payment.save()
messages.success(
request,
f"Vierteljahresnachweis und zugehörige Unterstützung für {nachweis.destinataer.get_full_name()} "
f"({nachweis.jahr} Q{nachweis.quartal}) wurden zurückgesetzt."
)
else:
messages.success(
request,
f"Vierteljahresnachweis für {nachweis.destinataer.get_full_name()} "
f"({nachweis.jahr} Q{nachweis.quartal}) wurde zurückgesetzt."
)
else:
messages.error(
request,
"Nur genehmigte oder eingereichte Nachweise können zurückgesetzt werden."
)
return redirect("stiftung:destinataer_detail", pk=nachweis.destinataer.pk)