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:
@@ -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}"
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
18
app/stiftung/migrations/0032_backupjob_operation.py
Normal file
18
app/stiftung/migrations/0032_backupjob_operation.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
18
app/stiftung/migrations/0033_alter_backupjob_status.py
Normal file
18
app/stiftung/migrations/0033_alter_backupjob_status.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
@@ -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"""
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user