Fix payment system balance integration and add calendar functionality
- Implement automated payment tracking with Django signals - Fix duplicate transaction creation with unique referenz system - Add calendar system with CRUD operations and event management - Reorganize navigation menu (rename sections, move admin functions) - Replace Geschichte editor with EasyMDE markdown editor - Add management commands for balance reconciliation - Create missing transactions for previously paid payments - Ensure account balances accurately reflect all payment activity Features added: - Calendar entries creation and administration via menu - Payment status tracking with automatic balance updates - Duplicate prevention for payment transactions - Markdown editor with live preview for Geschichte pages - Database reconciliation tools for payment/balance sync Bug fixes: - Resolved IntegrityError on payment status changes - Fixed missing account balance updates for paid payments - Prevented duplicate balance deductions on re-saves - Corrected menu structure and admin function placement
This commit is contained in:
@@ -14,3 +14,9 @@ class StiftungConfig(AppConfig):
|
||||
except ImportError:
|
||||
# django-otp not installed
|
||||
pass
|
||||
|
||||
# Import signals to register them
|
||||
try:
|
||||
import stiftung.signals
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
@@ -1648,14 +1648,29 @@ class GeschichteSeiteForm(forms.ModelForm):
|
||||
|
||||
def clean_slug(self):
|
||||
slug = self.cleaned_data.get('slug')
|
||||
titel = self.cleaned_data.get('titel')
|
||||
titel = self.cleaned_data.get('titel', '')
|
||||
|
||||
if not slug and titel:
|
||||
# Auto-generate slug from title
|
||||
from django.utils.text import slugify
|
||||
slug = slugify(titel)
|
||||
|
||||
if not slug:
|
||||
raise forms.ValidationError('Slug ist erforderlich. Bitte geben Sie einen Titel ein.')
|
||||
|
||||
return slug
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
titel = cleaned_data.get('titel', '')
|
||||
slug = cleaned_data.get('slug', '')
|
||||
|
||||
# Auto-generate slug if empty
|
||||
if titel and not slug:
|
||||
from django.utils.text import slugify
|
||||
cleaned_data['slug'] = slugify(titel)
|
||||
|
||||
return cleaned_data
|
||||
|
||||
|
||||
class GeschichteBildForm(forms.ModelForm):
|
||||
|
||||
58
app/stiftung/management/commands/create_sample_events.py
Normal file
58
app/stiftung/management/commands/create_sample_events.py
Normal file
@@ -0,0 +1,58 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from stiftung.models import StiftungsKalenderEintrag
|
||||
from datetime import date, timedelta
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Creates sample calendar events for testing'
|
||||
|
||||
def handle(self, *args, **options):
|
||||
today = date.today()
|
||||
|
||||
events = [
|
||||
{
|
||||
'titel': 'Vorstandssitzung',
|
||||
'beschreibung': 'Monatliche Vorstandssitzung zur Besprechung aktueller Stiftungsangelegenheiten',
|
||||
'datum': today + timedelta(days=7),
|
||||
'kategorie': 'termin',
|
||||
'prioritaet': 'hoch',
|
||||
},
|
||||
{
|
||||
'titel': 'Zahlungserinnerung Familie Müller',
|
||||
'beschreibung': 'Quartalsweise Unterstützung €500',
|
||||
'datum': today + timedelta(days=3),
|
||||
'kategorie': 'zahlung',
|
||||
'prioritaet': 'kritisch',
|
||||
},
|
||||
{
|
||||
'titel': 'Pachtvertrag Müller läuft aus',
|
||||
'beschreibung': 'Vertrag für Grundstück A123 muss verlängert werden',
|
||||
'datum': today + timedelta(days=30),
|
||||
'kategorie': 'vertrag',
|
||||
'prioritaet': 'hoch',
|
||||
},
|
||||
{
|
||||
'titel': 'Geburtstag Maria Schmidt',
|
||||
'beschreibung': '75. Geburtstag',
|
||||
'datum': today + timedelta(days=5),
|
||||
'kategorie': 'geburtstag',
|
||||
'prioritaet': 'normal',
|
||||
}
|
||||
]
|
||||
|
||||
created_count = 0
|
||||
for event_data in events:
|
||||
event, created = StiftungsKalenderEintrag.objects.get_or_create(
|
||||
titel=event_data['titel'],
|
||||
defaults=event_data
|
||||
)
|
||||
if created:
|
||||
created_count += 1
|
||||
self.stdout.write(f'Created: {event.titel}')
|
||||
else:
|
||||
self.stdout.write(f'Already exists: {event.titel}')
|
||||
|
||||
total = StiftungsKalenderEintrag.objects.count()
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f'✅ Created {created_count} new events. Total: {total}')
|
||||
)
|
||||
169
app/stiftung/management/commands/fix_account_balances.py
Normal file
169
app/stiftung/management/commands/fix_account_balances.py
Normal file
@@ -0,0 +1,169 @@
|
||||
"""
|
||||
Management command to fix account balances for existing paid payments.
|
||||
|
||||
This command will:
|
||||
1. Find all payments marked as 'ausgezahlt' (paid)
|
||||
2. Check if corresponding bank transactions exist
|
||||
3. Create missing bank transactions
|
||||
4. Update account balances to reflect all paid payments
|
||||
|
||||
Usage:
|
||||
python manage.py fix_account_balances
|
||||
"""
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.utils import timezone
|
||||
from decimal import Decimal
|
||||
|
||||
from stiftung.models import DestinataerUnterstuetzung, BankTransaction, StiftungsKonto
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Fix account balances for existing paid payments'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--dry-run',
|
||||
action='store_true',
|
||||
help='Show what would be done without making changes',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--account',
|
||||
type=str,
|
||||
help='Only process payments for specific account (by kontoname)',
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
dry_run = options['dry_run']
|
||||
account_filter = options.get('account')
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS('🔍 Analyzing paid payments and account balances...')
|
||||
)
|
||||
|
||||
if dry_run:
|
||||
self.stdout.write(
|
||||
self.style.WARNING('DRY RUN MODE - No changes will be made')
|
||||
)
|
||||
|
||||
# Get all paid payments
|
||||
paid_payments_query = DestinataerUnterstuetzung.objects.filter(
|
||||
status='ausgezahlt'
|
||||
).select_related('konto', 'destinataer')
|
||||
|
||||
if account_filter:
|
||||
paid_payments_query = paid_payments_query.filter(
|
||||
konto__kontoname__icontains=account_filter
|
||||
)
|
||||
|
||||
paid_payments = paid_payments_query.all()
|
||||
|
||||
self.stdout.write(f"Found {paid_payments.count()} paid payments")
|
||||
|
||||
# Group payments by account
|
||||
accounts_data = {}
|
||||
missing_transactions = []
|
||||
|
||||
for payment in paid_payments:
|
||||
konto_id = payment.konto.id
|
||||
if konto_id not in accounts_data:
|
||||
accounts_data[konto_id] = {
|
||||
'konto': payment.konto,
|
||||
'payments': [],
|
||||
'total_paid': Decimal('0.00'),
|
||||
'missing_transactions': []
|
||||
}
|
||||
|
||||
accounts_data[konto_id]['payments'].append(payment)
|
||||
accounts_data[konto_id]['total_paid'] += payment.betrag
|
||||
|
||||
# Check if bank transaction exists for this payment
|
||||
existing_transaction = BankTransaction.objects.filter(
|
||||
konto=payment.konto,
|
||||
betrag=-payment.betrag, # Negative for outgoing payment
|
||||
kommentare__contains=f'Unterstützung {payment.id}'
|
||||
).first()
|
||||
|
||||
if not existing_transaction:
|
||||
accounts_data[konto_id]['missing_transactions'].append(payment)
|
||||
missing_transactions.append(payment)
|
||||
|
||||
# Report findings
|
||||
self.stdout.write("\n📊 ANALYSIS RESULTS:")
|
||||
self.stdout.write("=" * 50)
|
||||
|
||||
for account_data in accounts_data.values():
|
||||
konto = account_data['konto']
|
||||
payments_count = len(account_data['payments'])
|
||||
total_paid = account_data['total_paid']
|
||||
missing_count = len(account_data['missing_transactions'])
|
||||
|
||||
self.stdout.write(f"\n🏦 {konto.bank_name} - {konto.kontoname}")
|
||||
self.stdout.write(f" Current Balance: €{konto.saldo}")
|
||||
self.stdout.write(f" Paid Payments: {payments_count} (Total: €{total_paid})")
|
||||
self.stdout.write(f" Missing Transactions: {missing_count}")
|
||||
|
||||
if missing_count > 0:
|
||||
expected_balance = konto.saldo - sum(p.betrag for p in account_data['missing_transactions'])
|
||||
self.stdout.write(
|
||||
self.style.WARNING(f" Expected Balance after fix: €{expected_balance}")
|
||||
)
|
||||
|
||||
if not missing_transactions:
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS("\n✅ All paid payments have corresponding transactions!")
|
||||
)
|
||||
return
|
||||
|
||||
self.stdout.write(f"\n⚠️ Found {len(missing_transactions)} payments without transactions")
|
||||
|
||||
if not dry_run:
|
||||
self.stdout.write("\n🔧 CREATING MISSING TRANSACTIONS...")
|
||||
|
||||
created_count = 0
|
||||
for payment in missing_transactions:
|
||||
# Create bank transaction
|
||||
transaction = BankTransaction.objects.create(
|
||||
konto=payment.konto,
|
||||
datum=payment.ausgezahlt_am or payment.faellig_am,
|
||||
valuta=payment.ausgezahlt_am or payment.faellig_am,
|
||||
betrag=-payment.betrag, # Negative for outgoing payment
|
||||
waehrung='EUR',
|
||||
verwendungszweck=f"Unterstützungszahlung: {payment.beschreibung or payment.destinataer.get_full_name()}",
|
||||
empfaenger_zahlungspflichtiger=payment.empfaenger_name or payment.destinataer.get_full_name(),
|
||||
iban_gegenpartei=payment.empfaenger_iban or '',
|
||||
transaction_type='ueberweisung',
|
||||
status='verified',
|
||||
kommentare=f'Nachträglich erstellt für Unterstützung {payment.id} - Zahlung vom {payment.ausgezahlt_am or payment.faellig_am}',
|
||||
)
|
||||
|
||||
# Update account balance
|
||||
payment.konto.saldo -= payment.betrag
|
||||
payment.konto.saldo_datum = payment.ausgezahlt_am or payment.faellig_am
|
||||
payment.konto.save()
|
||||
|
||||
created_count += 1
|
||||
self.stdout.write(
|
||||
f" ✅ Created transaction for {payment.destinataer.get_full_name()}: €{payment.betrag}"
|
||||
)
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f"\n🎉 Successfully created {created_count} transactions and updated account balances!")
|
||||
)
|
||||
|
||||
# Show final balances
|
||||
self.stdout.write("\n📈 UPDATED ACCOUNT BALANCES:")
|
||||
for account_data in accounts_data.values():
|
||||
if account_data['missing_transactions']:
|
||||
konto = account_data['konto']
|
||||
konto.refresh_from_db() # Get updated balance
|
||||
self.stdout.write(f" {konto.bank_name} - {konto.kontoname}: €{konto.saldo}")
|
||||
|
||||
else:
|
||||
self.stdout.write("\n📝 DRY RUN - Would create the following transactions:")
|
||||
for payment in missing_transactions:
|
||||
self.stdout.write(
|
||||
f" - {payment.destinataer.get_full_name()}: €{payment.betrag} "
|
||||
f"on {payment.ausgezahlt_am or payment.faellig_am} "
|
||||
f"from {payment.konto.kontoname}"
|
||||
)
|
||||
145
app/stiftung/management/commands/reconcile_balances.py
Normal file
145
app/stiftung/management/commands/reconcile_balances.py
Normal file
@@ -0,0 +1,145 @@
|
||||
"""
|
||||
Management command to reconcile account balances with actual transactions.
|
||||
|
||||
This command will:
|
||||
1. Calculate the correct balance based on all bank transactions
|
||||
2. Update the account balance to match the calculated balance
|
||||
3. Show discrepancies between stored and calculated balances
|
||||
|
||||
Usage:
|
||||
python manage.py reconcile_balances
|
||||
"""
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.utils import timezone
|
||||
from decimal import Decimal
|
||||
|
||||
from stiftung.models import StiftungsKonto, BankTransaction
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Reconcile account balances with bank transactions'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--dry-run',
|
||||
action='store_true',
|
||||
help='Show what would be done without making changes',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--account',
|
||||
type=str,
|
||||
help='Only process specific account (by kontoname)',
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
dry_run = options['dry_run']
|
||||
account_filter = options.get('account')
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS('🔍 Reconciling account balances with transactions...')
|
||||
)
|
||||
|
||||
if dry_run:
|
||||
self.stdout.write(
|
||||
self.style.WARNING('DRY RUN MODE - No changes will be made')
|
||||
)
|
||||
|
||||
# Get accounts to process
|
||||
accounts_query = StiftungsKonto.objects.filter(aktiv=True)
|
||||
if account_filter:
|
||||
accounts_query = accounts_query.filter(kontoname__icontains=account_filter)
|
||||
|
||||
accounts = accounts_query.all()
|
||||
|
||||
if not accounts:
|
||||
self.stdout.write(
|
||||
self.style.ERROR('No accounts found matching criteria')
|
||||
)
|
||||
return
|
||||
|
||||
self.stdout.write(f"Processing {accounts.count()} account(s)...")
|
||||
|
||||
total_discrepancies = 0
|
||||
fixed_accounts = 0
|
||||
|
||||
for account in accounts:
|
||||
self.stdout.write(f"\n🏦 {account.bank_name} - {account.kontoname}")
|
||||
self.stdout.write(f" Stored Balance: €{account.saldo}")
|
||||
self.stdout.write(f" Last Updated: {account.saldo_datum}")
|
||||
|
||||
# Calculate balance from transactions
|
||||
transactions = BankTransaction.objects.filter(konto=account).order_by('datum')
|
||||
calculated_balance = Decimal('0.00')
|
||||
transaction_count = transactions.count()
|
||||
|
||||
for transaction in transactions:
|
||||
calculated_balance += transaction.betrag
|
||||
|
||||
self.stdout.write(f" Transactions: {transaction_count}")
|
||||
self.stdout.write(f" Calculated Balance: €{calculated_balance}")
|
||||
|
||||
discrepancy = account.saldo - calculated_balance
|
||||
if discrepancy != 0:
|
||||
total_discrepancies += 1
|
||||
self.stdout.write(
|
||||
self.style.WARNING(f" ⚠️ Discrepancy: €{discrepancy}")
|
||||
)
|
||||
|
||||
if not dry_run:
|
||||
# Update the account balance
|
||||
old_balance = account.saldo
|
||||
account.saldo = calculated_balance
|
||||
|
||||
# Update the balance date to the latest transaction date or today
|
||||
if transactions.exists():
|
||||
latest_transaction = transactions.order_by('-datum').first()
|
||||
account.saldo_datum = latest_transaction.datum
|
||||
else:
|
||||
account.saldo_datum = timezone.now().date()
|
||||
|
||||
account.save()
|
||||
fixed_accounts += 1
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f" ✅ Updated: €{old_balance} → €{calculated_balance}"
|
||||
)
|
||||
)
|
||||
else:
|
||||
self.stdout.write(
|
||||
f" 📝 Would update: €{account.saldo} → €{calculated_balance}"
|
||||
)
|
||||
else:
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(" ✅ Balance is correct")
|
||||
)
|
||||
|
||||
# Show recent transactions
|
||||
if transaction_count > 0:
|
||||
self.stdout.write(" Recent transactions:")
|
||||
recent = transactions.order_by('-datum')[:3]
|
||||
for trans in recent:
|
||||
self.stdout.write(
|
||||
f" {trans.datum}: €{trans.betrag} - {trans.verwendungszweck[:50]}"
|
||||
)
|
||||
|
||||
# Summary
|
||||
self.stdout.write("\n" + "=" * 50)
|
||||
if dry_run:
|
||||
self.stdout.write(
|
||||
f"📊 Found {total_discrepancies} account(s) with balance discrepancies"
|
||||
)
|
||||
if total_discrepancies > 0:
|
||||
self.stdout.write(" Run without --dry-run to fix these discrepancies")
|
||||
else:
|
||||
if fixed_accounts > 0:
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f"🎉 Fixed {fixed_accounts} account balance(s)"
|
||||
)
|
||||
)
|
||||
else:
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS("✅ All account balances were already correct")
|
||||
)
|
||||
18
app/stiftung/migrations/0038_allow_blank_content.py
Normal file
18
app/stiftung/migrations/0038_allow_blank_content.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.0.6 on 2025-10-02 20:07
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('stiftung', '0037_add_geschichte_models'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='geschichteseite',
|
||||
name='inhalt',
|
||||
field=models.TextField(blank=True, verbose_name='Inhalt'),
|
||||
),
|
||||
]
|
||||
41
app/stiftung/migrations/0039_stiftungskalendereintrag.py
Normal file
41
app/stiftung/migrations/0039_stiftungskalendereintrag.py
Normal file
@@ -0,0 +1,41 @@
|
||||
# Generated by Django 5.0.6 on 2025-10-04 20:21
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('stiftung', '0038_allow_blank_content'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='StiftungsKalenderEintrag',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('titel', models.CharField(max_length=200, verbose_name='Titel')),
|
||||
('beschreibung', models.TextField(blank=True, verbose_name='Beschreibung')),
|
||||
('datum', models.DateField(verbose_name='Datum')),
|
||||
('uhrzeit', models.TimeField(blank=True, null=True, verbose_name='Uhrzeit')),
|
||||
('ganztags', models.BooleanField(default=True, verbose_name='Ganztägig')),
|
||||
('kategorie', models.CharField(choices=[('termin', 'Termin/Meeting'), ('zahlung', 'Zahlungserinnerung'), ('deadline', 'Frist/Deadline'), ('geburtstag', 'Geburtstag'), ('vertrag', 'Vertrag läuft aus'), ('pruefung', 'Prüfung/Nachweis'), ('sonstiges', 'Sonstiges')], default='termin', max_length=20, verbose_name='Kategorie')),
|
||||
('prioritaet', models.CharField(choices=[('niedrig', 'Niedrig'), ('normal', 'Normal'), ('hoch', 'Hoch'), ('kritisch', 'Kritisch')], default='normal', max_length=20, verbose_name='Priorität')),
|
||||
('erledigt', models.BooleanField(default=False, verbose_name='Erledigt')),
|
||||
('erledigt_am', models.DateTimeField(blank=True, null=True, verbose_name='Erledigt am')),
|
||||
('erstellt_von', models.CharField(blank=True, max_length=100, null=True, verbose_name='Erstellt von')),
|
||||
('erstellt_am', models.DateTimeField(auto_now_add=True, verbose_name='Erstellt am')),
|
||||
('aktualisiert_am', models.DateTimeField(auto_now=True, verbose_name='Aktualisiert am')),
|
||||
('destinataer', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='stiftung.destinataer', verbose_name='Bezogener Destinatär')),
|
||||
('verpachtung', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='stiftung.landverpachtung', verbose_name='Bezogene Verpachtung')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Kalender Eintrag',
|
||||
'verbose_name_plural': 'Kalender Einträge',
|
||||
'ordering': ['datum', 'uhrzeit'],
|
||||
'indexes': [models.Index(fields=['datum'], name='stiftung_st_datum_9e97ed_idx'), models.Index(fields=['kategorie', 'datum'], name='stiftung_st_kategor_d7c7f9_idx'), models.Index(fields=['erledigt', 'datum'], name='stiftung_st_erledig_115235_idx')],
|
||||
},
|
||||
),
|
||||
]
|
||||
13
app/stiftung/migrations/0040_add_calendar_model.py
Normal file
13
app/stiftung/migrations/0040_add_calendar_model.py
Normal file
@@ -0,0 +1,13 @@
|
||||
# Generated by Django 5.0.6 on 2025-10-04 20:28
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('stiftung', '0039_stiftungskalendereintrag'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
]
|
||||
18
app/stiftung/migrations/0041_alter_geschichteseite_inhalt.py
Normal file
18
app/stiftung/migrations/0041_alter_geschichteseite_inhalt.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.0.6 on 2025-10-04 21:49
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('stiftung', '0040_add_calendar_model'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='geschichteseite',
|
||||
name='inhalt',
|
||||
field=models.TextField(blank=True, help_text='Sie können Markdown verwenden: **fett**, *kursiv*, # Überschriften, [Links](URL), Listen, etc.', verbose_name='Inhalt (Markdown)'),
|
||||
),
|
||||
]
|
||||
@@ -2864,7 +2864,11 @@ class GeschichteSeite(models.Model):
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
titel = models.CharField(max_length=200, verbose_name="Titel")
|
||||
slug = models.SlugField(max_length=200, unique=True, verbose_name="URL-Slug")
|
||||
inhalt = models.TextField(verbose_name="Inhalt")
|
||||
inhalt = models.TextField(
|
||||
verbose_name="Inhalt (Markdown)",
|
||||
blank=True,
|
||||
help_text="Sie können Markdown verwenden: **fett**, *kursiv*, # Überschriften, [Links](URL), Listen, etc."
|
||||
)
|
||||
|
||||
# Metadata
|
||||
erstellt_am = models.DateTimeField(auto_now_add=True, verbose_name="Erstellt am")
|
||||
@@ -2940,3 +2944,124 @@ class GeschichteBild(models.Model):
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.titel} ({self.seite.titel})"
|
||||
|
||||
|
||||
class StiftungsKalenderEintrag(models.Model):
|
||||
"""Custom calendar events for foundation management"""
|
||||
|
||||
KATEGORIE_CHOICES = [
|
||||
('termin', 'Termin/Meeting'),
|
||||
('zahlung', 'Zahlungserinnerung'),
|
||||
('deadline', 'Frist/Deadline'),
|
||||
('geburtstag', 'Geburtstag'),
|
||||
('vertrag', 'Vertrag läuft aus'),
|
||||
('pruefung', 'Prüfung/Nachweis'),
|
||||
('sonstiges', 'Sonstiges'),
|
||||
]
|
||||
|
||||
PRIORITAET_CHOICES = [
|
||||
('niedrig', 'Niedrig'),
|
||||
('normal', 'Normal'),
|
||||
('hoch', 'Hoch'),
|
||||
('kritisch', 'Kritisch'),
|
||||
]
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
titel = models.CharField(max_length=200, verbose_name="Titel")
|
||||
beschreibung = models.TextField(blank=True, verbose_name="Beschreibung")
|
||||
|
||||
# Date and time
|
||||
datum = models.DateField(verbose_name="Datum")
|
||||
uhrzeit = models.TimeField(null=True, blank=True, verbose_name="Uhrzeit")
|
||||
ganztags = models.BooleanField(default=True, verbose_name="Ganztägig")
|
||||
|
||||
# Categorization
|
||||
kategorie = models.CharField(
|
||||
max_length=20,
|
||||
choices=KATEGORIE_CHOICES,
|
||||
default='termin',
|
||||
verbose_name="Kategorie"
|
||||
)
|
||||
prioritaet = models.CharField(
|
||||
max_length=20,
|
||||
choices=PRIORITAET_CHOICES,
|
||||
default='normal',
|
||||
verbose_name="Priorität"
|
||||
)
|
||||
|
||||
# Links to related objects
|
||||
destinataer = models.ForeignKey(
|
||||
'Destinataer',
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.CASCADE,
|
||||
verbose_name="Bezogener Destinatär"
|
||||
)
|
||||
verpachtung = models.ForeignKey(
|
||||
'LandVerpachtung',
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.CASCADE,
|
||||
verbose_name="Bezogene Verpachtung"
|
||||
)
|
||||
|
||||
# Status and completion
|
||||
erledigt = models.BooleanField(default=False, verbose_name="Erledigt")
|
||||
erledigt_am = models.DateTimeField(null=True, blank=True, verbose_name="Erledigt am")
|
||||
|
||||
# Metadata
|
||||
erstellt_von = models.CharField(
|
||||
max_length=100,
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name="Erstellt von"
|
||||
)
|
||||
erstellt_am = models.DateTimeField(auto_now_add=True, verbose_name="Erstellt am")
|
||||
aktualisiert_am = models.DateTimeField(auto_now=True, verbose_name="Aktualisiert am")
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Kalender Eintrag"
|
||||
verbose_name_plural = "Kalender Einträge"
|
||||
ordering = ['datum', 'uhrzeit']
|
||||
indexes = [
|
||||
models.Index(fields=['datum']),
|
||||
models.Index(fields=['kategorie', 'datum']),
|
||||
models.Index(fields=['erledigt', 'datum']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.datum}: {self.titel}"
|
||||
|
||||
def get_kategorie_icon(self):
|
||||
icons = {
|
||||
'termin': 'fas fa-calendar-alt',
|
||||
'zahlung': 'fas fa-euro-sign',
|
||||
'deadline': 'fas fa-exclamation-triangle',
|
||||
'geburtstag': 'fas fa-birthday-cake',
|
||||
'vertrag': 'fas fa-file-contract',
|
||||
'pruefung': 'fas fa-clipboard-check',
|
||||
'sonstiges': 'fas fa-calendar',
|
||||
}
|
||||
return icons.get(self.kategorie, 'fas fa-calendar')
|
||||
|
||||
def get_prioritaet_color(self):
|
||||
colors = {
|
||||
'niedrig': 'success',
|
||||
'normal': 'primary',
|
||||
'hoch': 'warning',
|
||||
'kritisch': 'danger',
|
||||
}
|
||||
return colors.get(self.prioritaet, 'primary')
|
||||
|
||||
def is_overdue(self):
|
||||
"""Check if event is overdue (past due and not completed)"""
|
||||
if self.erledigt:
|
||||
return False
|
||||
return self.datum < timezone.now().date()
|
||||
|
||||
def is_upcoming(self, days=7):
|
||||
"""Check if event is upcoming within specified days"""
|
||||
if self.erledigt:
|
||||
return False
|
||||
today = timezone.now().date()
|
||||
return today <= self.datum <= (today + timezone.timedelta(days=days))
|
||||
|
||||
1
app/stiftung/services/__init__.py
Normal file
1
app/stiftung/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Services package
|
||||
269
app/stiftung/services/calendar_service.py
Normal file
269
app/stiftung/services/calendar_service.py
Normal file
@@ -0,0 +1,269 @@
|
||||
"""
|
||||
Calendar service for aggregating date-based events from the foundation management system
|
||||
"""
|
||||
from datetime import date, timedelta
|
||||
import calendar as cal
|
||||
try:
|
||||
from django.utils import timezone
|
||||
except ImportError:
|
||||
from datetime import datetime as timezone
|
||||
timezone.now = datetime.now
|
||||
|
||||
try:
|
||||
from stiftung.models import (
|
||||
DestinataerUnterstuetzung,
|
||||
LandVerpachtung,
|
||||
Destinataer,
|
||||
StiftungsKalenderEintrag
|
||||
)
|
||||
except ImportError:
|
||||
# Fallback for when Django apps aren't ready
|
||||
DestinataerUnterstuetzung = None
|
||||
LandVerpachtung = None
|
||||
Destinataer = None
|
||||
StiftungsKalenderEintrag = None
|
||||
|
||||
|
||||
class StiftungsKalenderService:
|
||||
"""Service to aggregate calendar events from various models"""
|
||||
|
||||
def __init__(self):
|
||||
self.today = timezone.now().date()
|
||||
|
||||
def get_calendar_events(self, start_date=None, end_date=None):
|
||||
"""
|
||||
Get custom calendar entries only within date range
|
||||
Returns list of event objects
|
||||
"""
|
||||
if not start_date:
|
||||
start_date = self.today
|
||||
if not end_date:
|
||||
end_date = start_date + timedelta(days=30)
|
||||
|
||||
return self._get_custom_calendar_events(start_date, end_date)
|
||||
|
||||
def get_all_events(self, start_date=None, end_date=None):
|
||||
"""
|
||||
Get all calendar events from all sources within date range
|
||||
Returns list of event dictionaries
|
||||
"""
|
||||
if not start_date:
|
||||
start_date = self.today
|
||||
if not end_date:
|
||||
end_date = start_date + timedelta(days=30)
|
||||
|
||||
events = []
|
||||
|
||||
# Add support payment due dates
|
||||
events.extend(self.get_support_payment_events(start_date, end_date))
|
||||
|
||||
# Add lease expiration dates
|
||||
events.extend(self.get_lease_events(start_date, end_date))
|
||||
|
||||
# Add birthdays
|
||||
events.extend(self.get_birthday_events(start_date, end_date))
|
||||
|
||||
# Add custom calendar entries
|
||||
events.extend(self.get_calendar_events(start_date, end_date))
|
||||
|
||||
# Sort by date
|
||||
events.sort(key=lambda x: (x.date, getattr(x, 'time', '00:00')))
|
||||
|
||||
return events
|
||||
|
||||
def get_support_payment_events(self, start_date, end_date):
|
||||
"""Get support payment due dates"""
|
||||
if not DestinataerUnterstuetzung:
|
||||
return []
|
||||
|
||||
events = []
|
||||
|
||||
payments = DestinataerUnterstuetzung.objects.filter(
|
||||
faellig_am__range=[start_date, end_date],
|
||||
status__in=['geplant', 'faellig']
|
||||
).select_related('destinataer')
|
||||
|
||||
for payment in payments:
|
||||
is_overdue = payment.is_overdue() if hasattr(payment, 'is_overdue') else False
|
||||
|
||||
class PaymentEvent:
|
||||
def __init__(self, payment_obj, overdue):
|
||||
self.id = f'payment_{payment_obj.id}'
|
||||
self.title = f'Zahlung an {payment_obj.destinataer.get_full_name()}'
|
||||
self.description = f'€{payment_obj.betrag} - {payment_obj.beschreibung}'
|
||||
self.date = payment_obj.faellig_am
|
||||
self.time = None
|
||||
self.category = 'zahlung'
|
||||
self.category_display = 'Zahlung'
|
||||
self.priority = 'hoch' if overdue else 'normal'
|
||||
self.priority_display = 'Hoch' if overdue else 'Normal'
|
||||
self.icon = 'fas fa-euro-sign'
|
||||
self.color = 'danger' if overdue else 'warning'
|
||||
self.source = 'payment'
|
||||
self.overdue = overdue
|
||||
self.completed = False
|
||||
|
||||
events.append(PaymentEvent(payment, is_overdue))
|
||||
|
||||
return events
|
||||
|
||||
def get_lease_events(self, start_date, end_date):
|
||||
"""Get lease start/end dates"""
|
||||
if not LandVerpachtung:
|
||||
return []
|
||||
|
||||
events = []
|
||||
|
||||
# Lease expirations
|
||||
leases = LandVerpachtung.objects.filter(
|
||||
pachtende__range=[start_date, end_date],
|
||||
status='aktiv'
|
||||
).select_related('paechter', 'land')
|
||||
|
||||
for lease in leases:
|
||||
# Check if expiring soon (within 90 days)
|
||||
days_until_expiry = (lease.pachtende - self.today).days if lease.pachtende else 999
|
||||
priority = 'hoch' if days_until_expiry <= 30 else 'mittel' if days_until_expiry <= 90 else 'normal'
|
||||
|
||||
class LeaseEvent:
|
||||
def __init__(self, lease_obj, priority_val):
|
||||
self.id = f'lease_end_{lease_obj.id}'
|
||||
self.title = f'Pachtende: {lease_obj.paechter.get_full_name()}'
|
||||
self.description = f'{lease_obj.land.bezeichnung} - {lease_obj.verpachtete_flaeche}m²'
|
||||
self.date = lease_obj.pachtende
|
||||
self.time = None
|
||||
self.category = 'vertrag'
|
||||
self.category_display = 'Vertrag'
|
||||
self.priority = priority_val
|
||||
self.priority_display = 'Hoch' if priority_val == 'hoch' else 'Mittel' if priority_val == 'mittel' else 'Normal'
|
||||
self.icon = 'fas fa-file-contract'
|
||||
self.color = 'danger' if priority_val == 'hoch' else 'warning' if priority_val == 'mittel' else 'info'
|
||||
self.source = 'lease'
|
||||
self.overdue = False
|
||||
self.completed = False
|
||||
|
||||
events.append(LeaseEvent(lease, priority))
|
||||
|
||||
return events
|
||||
|
||||
def get_birthday_events(self, start_date, end_date):
|
||||
"""Get birthdays within date range"""
|
||||
if not Destinataer:
|
||||
return []
|
||||
|
||||
events = []
|
||||
|
||||
# Get all destinatäre with birth dates
|
||||
destinataere = Destinataer.objects.filter(
|
||||
geburtsdatum__isnull=False
|
||||
)
|
||||
|
||||
for destinataer in destinataere:
|
||||
# Calculate birthday for each year in range
|
||||
birth_date = destinataer.geburtsdatum
|
||||
for year in range(start_date.year, end_date.year + 1):
|
||||
try:
|
||||
birthday_this_year = birth_date.replace(year=year)
|
||||
if start_date <= birthday_this_year <= end_date:
|
||||
age = year - birth_date.year
|
||||
|
||||
class BirthdayEvent:
|
||||
def __init__(self, person, birthday_date, age_val):
|
||||
self.id = f'birthday_{person.id}_{year}'
|
||||
self.title = f'🎂 {person.get_full_name()}'
|
||||
self.description = f'{age_val}. Geburtstag'
|
||||
self.date = birthday_date
|
||||
self.time = None
|
||||
self.category = 'geburtstag'
|
||||
self.category_display = 'Geburtstag'
|
||||
self.priority = 'normal'
|
||||
self.priority_display = 'Normal'
|
||||
self.icon = 'fas fa-birthday-cake'
|
||||
self.color = 'success'
|
||||
self.source = 'birthday'
|
||||
self.overdue = False
|
||||
self.completed = False
|
||||
|
||||
events.append(BirthdayEvent(destinataer, birthday_this_year, age))
|
||||
except ValueError:
|
||||
# Handle leap year edge case (Feb 29)
|
||||
pass
|
||||
|
||||
return events
|
||||
|
||||
def _get_custom_calendar_events(self, start_date, end_date):
|
||||
"""Get custom calendar entries"""
|
||||
if not StiftungsKalenderEintrag:
|
||||
return []
|
||||
|
||||
events = []
|
||||
|
||||
calendar_entries = StiftungsKalenderEintrag.objects.filter(
|
||||
datum__range=[start_date, end_date]
|
||||
).select_related('destinataer', 'verpachtung')
|
||||
|
||||
for entry in calendar_entries:
|
||||
class CustomEvent:
|
||||
def __init__(self, entry_obj, today_date):
|
||||
self.id = entry_obj.id
|
||||
self.title = entry_obj.titel
|
||||
self.description = entry_obj.beschreibung
|
||||
self.date = entry_obj.datum
|
||||
self.time = entry_obj.uhrzeit
|
||||
self.category = entry_obj.kategorie
|
||||
self.category_display = entry_obj.get_kategorie_display()
|
||||
self.priority = entry_obj.prioritaet
|
||||
self.priority_display = entry_obj.get_prioritaet_display()
|
||||
self.icon = self._get_kategorie_icon(entry_obj.kategorie)
|
||||
self.color = self._get_prioritaet_color(entry_obj.prioritaet)
|
||||
self.source = 'custom'
|
||||
self.completed = entry_obj.erledigt
|
||||
self.overdue = entry_obj.datum < today_date if entry_obj.datum else False
|
||||
|
||||
def _get_kategorie_icon(self, kategorie):
|
||||
icons = {
|
||||
'termin': 'fas fa-calendar-alt',
|
||||
'zahlung': 'fas fa-euro-sign',
|
||||
'deadline': 'fas fa-exclamation-triangle',
|
||||
'geburtstag': 'fas fa-birthday-cake',
|
||||
'vertrag': 'fas fa-file-contract',
|
||||
'pruefung': 'fas fa-search',
|
||||
}
|
||||
return icons.get(kategorie, 'fas fa-calendar')
|
||||
|
||||
def _get_prioritaet_color(self, prioritaet):
|
||||
colors = {
|
||||
'niedrig': 'secondary',
|
||||
'normal': 'primary',
|
||||
'mittel': 'warning',
|
||||
'hoch': 'danger',
|
||||
}
|
||||
return colors.get(prioritaet, 'primary')
|
||||
|
||||
events.append(CustomEvent(entry, self.today))
|
||||
|
||||
return events
|
||||
|
||||
def get_upcoming_events(self, days=7):
|
||||
"""Get upcoming events within specified days"""
|
||||
end_date = self.today + timedelta(days=days)
|
||||
return self.get_calendar_events(self.today, end_date)
|
||||
|
||||
def get_overdue_events(self):
|
||||
"""Get overdue/past due events"""
|
||||
events = self.get_calendar_events(
|
||||
start_date=self.today - timedelta(days=30),
|
||||
end_date=self.today - timedelta(days=1)
|
||||
)
|
||||
|
||||
return [event for event in events if event.get('overdue', False)]
|
||||
|
||||
def get_events_for_month(self, year, month):
|
||||
"""Get all events for a specific month"""
|
||||
from calendar import monthrange
|
||||
|
||||
start_date = date(year, month, 1)
|
||||
_, last_day = monthrange(year, month)
|
||||
end_date = date(year, month, last_day)
|
||||
|
||||
return self.get_calendar_events(start_date, end_date)
|
||||
166
app/stiftung/signals.py
Normal file
166
app/stiftung/signals.py
Normal file
@@ -0,0 +1,166 @@
|
||||
"""
|
||||
Django signals for the Stiftung app.
|
||||
Handles automatic # Check if a bank transaction already exists for this specific payment
|
||||
existing_transaction = BankTransaction.objects.filter(
|
||||
konto=instance.konto,
|
||||
betrag=-instance.betrag, # Negative for outgoing payment
|
||||
kommentare__contains=f'Unterstützung {instance.id}'
|
||||
).first()
|
||||
|
||||
if existing_transaction:
|
||||
print(f"⚠️ Transaction already exists for payment {instance.id} - skipping creation")
|
||||
return
|
||||
|
||||
# Create a bank transaction for this payment
|
||||
BankTransaction.objects.create(
|
||||
konto=instance.konto,
|
||||
datum=instance.ausgezahlt_am or timezone.now().date(),
|
||||
valuta=instance.ausgezahlt_am or timezone.now().date(),
|
||||
betrag=-instance.betrag, # Negative because it's an outgoing payment
|
||||
waehrung='EUR',
|
||||
verwendungszweck=f"Unterstützungszahlung: {instance.beschreibung or instance.destinataer.get_full_name()}",
|
||||
empfaenger_zahlungspflichtiger=instance.empfaenger_name or instance.destinataer.get_full_name(),
|
||||
iban_gegenpartei=instance.empfaenger_iban or '',
|
||||
transaction_type='ueberweisung',
|
||||
status='verified',
|
||||
kommentare=f'Automatisch erstellt bei Markierung als ausgezahlt für Unterstützung {instance.id}',
|
||||
referenz=f'PAY-{instance.id}', # Add unique reference to avoid conflicts
|
||||
)model instances change.
|
||||
"""
|
||||
|
||||
from django.db.models.signals import post_save, pre_save
|
||||
from django.dispatch import receiver
|
||||
from django.utils import timezone
|
||||
from decimal import Decimal
|
||||
|
||||
from .models import DestinataerUnterstuetzung, BankTransaction
|
||||
|
||||
|
||||
@receiver(pre_save, sender=DestinataerUnterstuetzung)
|
||||
def unterstuetzung_pre_save(sender, instance, **kwargs):
|
||||
"""Store the old status before saving to detect status changes"""
|
||||
if instance.pk:
|
||||
try:
|
||||
old_instance = DestinataerUnterstuetzung.objects.get(pk=instance.pk)
|
||||
instance._old_status = old_instance.status
|
||||
except DestinataerUnterstuetzung.DoesNotExist:
|
||||
instance._old_status = None
|
||||
else:
|
||||
instance._old_status = None
|
||||
|
||||
|
||||
@receiver(post_save, sender=DestinataerUnterstuetzung)
|
||||
def update_account_balance_on_payment(sender, instance, created, **kwargs):
|
||||
"""
|
||||
Update account balance when a payment is marked as paid (ausgezahlt).
|
||||
Creates a corresponding bank transaction and updates the account balance.
|
||||
Prevents duplicate transactions by checking if one already exists.
|
||||
"""
|
||||
# Only process if payment was just marked as paid
|
||||
old_status = getattr(instance, '_old_status', None)
|
||||
|
||||
if instance.status == 'ausgezahlt' and old_status != 'ausgezahlt':
|
||||
# Payment was just marked as paid
|
||||
|
||||
# Check if a transaction already exists for this payment to prevent duplicates
|
||||
existing_transaction = BankTransaction.objects.filter(
|
||||
konto=instance.konto,
|
||||
betrag=-instance.betrag, # Negative for outgoing payment
|
||||
kommentare__contains=f'Unterstützung {instance.id}'
|
||||
).first()
|
||||
|
||||
if existing_transaction:
|
||||
print(f"⚠️ Transaction already exists for payment {instance.id} to {instance.destinataer.get_full_name()}, skipping duplicate")
|
||||
return
|
||||
|
||||
# Set the ausgezahlt_am date if not already set
|
||||
if not instance.ausgezahlt_am:
|
||||
instance.ausgezahlt_am = timezone.now().date()
|
||||
# Avoid infinite recursion by updating without triggering signals
|
||||
DestinataerUnterstuetzung.objects.filter(pk=instance.pk).update(
|
||||
ausgezahlt_am=instance.ausgezahlt_am
|
||||
)
|
||||
|
||||
# Check if a transaction already exists for this payment
|
||||
existing_transaction = BankTransaction.objects.filter(
|
||||
kommentare__contains=f'Unterstützung {instance.id}'
|
||||
).first()
|
||||
|
||||
if not existing_transaction:
|
||||
# Create a bank transaction for this payment
|
||||
transaction = BankTransaction.objects.create(
|
||||
konto=instance.konto,
|
||||
datum=instance.ausgezahlt_am or timezone.now().date(),
|
||||
valuta=instance.ausgezahlt_am or timezone.now().date(),
|
||||
betrag=-instance.betrag, # Negative because it's an outgoing payment
|
||||
waehrung='EUR',
|
||||
verwendungszweck=f"Unterstützungszahlung: {instance.beschreibung or instance.destinataer.get_full_name()}",
|
||||
empfaenger_zahlungspflichtiger=instance.empfaenger_name or instance.destinataer.get_full_name(),
|
||||
iban_gegenpartei=instance.empfaenger_iban or '',
|
||||
transaction_type='ueberweisung',
|
||||
status='verified',
|
||||
referenz=f'PAY-{instance.id}', # Unique reference to prevent duplicates
|
||||
kommentare=f'Automatisch erstellt bei Markierung als ausgezahlt für Unterstützung {instance.id}',
|
||||
)
|
||||
# Update account balance only for new transactions
|
||||
instance.konto.saldo -= instance.betrag
|
||||
instance.konto.saldo_datum = instance.ausgezahlt_am or timezone.now().date()
|
||||
instance.konto.save()
|
||||
print(f"✅ Account balance updated: {instance.konto.kontoname} - €{instance.betrag} (Payment to {instance.destinataer.get_full_name()}) - Transaction {transaction.id}")
|
||||
else:
|
||||
transaction = existing_transaction
|
||||
print(f"ℹ️ Transaction already exists for payment {instance.id}, balance not modified")
|
||||
|
||||
# Handle reversal if payment is changed from paid back to unpaid
|
||||
elif old_status == 'ausgezahlt' and instance.status != 'ausgezahlt':
|
||||
# Payment was unmarked as paid - reverse the transaction
|
||||
|
||||
# Find and delete the corresponding bank transaction
|
||||
try:
|
||||
# Look for the transaction created for this payment
|
||||
transaction = BankTransaction.objects.filter(
|
||||
konto=instance.konto,
|
||||
betrag=-instance.betrag,
|
||||
kommentare__contains=f'Unterstützung {instance.id}'
|
||||
).first()
|
||||
|
||||
if transaction:
|
||||
transaction.delete()
|
||||
|
||||
# Reverse the account balance update
|
||||
instance.konto.saldo += instance.betrag
|
||||
instance.konto.saldo_datum = timezone.now().date()
|
||||
instance.konto.save()
|
||||
|
||||
print(f"✅ Account balance reversed: {instance.konto.kontoname} + €{instance.betrag} (Payment reversal for {instance.destinataer.get_full_name()})")
|
||||
else:
|
||||
print(f"⚠️ No transaction found to reverse for payment {instance.id}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"⚠️ Error reversing payment transaction: {e}")
|
||||
|
||||
# Clear the ausgezahlt_am date
|
||||
if instance.ausgezahlt_am:
|
||||
# Update without triggering signals
|
||||
DestinataerUnterstuetzung.objects.filter(pk=instance.pk).update(
|
||||
ausgezahlt_am=None
|
||||
)
|
||||
|
||||
|
||||
@receiver(post_save, sender=BankTransaction)
|
||||
def update_account_balance_on_transaction(sender, instance, created, **kwargs):
|
||||
"""
|
||||
Update account balance when a new bank transaction is imported or created.
|
||||
Only update if the transaction has a saldo_nach_buchung value or if it's manually created.
|
||||
"""
|
||||
if created and instance.status in ['verified', 'imported']:
|
||||
# If the transaction has a balance after booking, use that
|
||||
if instance.saldo_nach_buchung is not None:
|
||||
instance.konto.saldo = instance.saldo_nach_buchung
|
||||
instance.konto.saldo_datum = instance.datum
|
||||
instance.konto.save()
|
||||
else:
|
||||
# Otherwise, calculate the new balance
|
||||
instance.konto.saldo += instance.betrag
|
||||
instance.konto.saldo_datum = instance.datum
|
||||
instance.konto.save()
|
||||
@@ -32,3 +32,19 @@ def help_box(page_key, user=None):
|
||||
def help_box_exists(page_key):
|
||||
"""Prüfe, ob eine Hilfs-Infobox für eine Seite existiert"""
|
||||
return HelpBox.get_help_for_page(page_key) is not None
|
||||
|
||||
|
||||
@register.filter
|
||||
def markdown_to_html(text):
|
||||
"""Konvertiere Markdown-Text zu HTML"""
|
||||
if not text:
|
||||
return ""
|
||||
|
||||
md = markdown.Markdown(extensions=[
|
||||
"nl2br",
|
||||
"fenced_code",
|
||||
"tables",
|
||||
"toc",
|
||||
"codehilite"
|
||||
])
|
||||
return mark_safe(md.convert(text))
|
||||
|
||||
@@ -5,10 +5,10 @@ from . import views
|
||||
app_name = "stiftung"
|
||||
|
||||
urlpatterns = [
|
||||
# Dashboard (Startseite)
|
||||
path("", views.dashboard, name="dashboard"),
|
||||
# Home (für Kompatibilität mit bestehenden Templates)
|
||||
path("home/", views.home, name="home"),
|
||||
# Home - Main landing page after login
|
||||
path("", views.home, name="home"),
|
||||
# Dashboard (detailed view)
|
||||
path("dashboard/", views.dashboard, name="dashboard"),
|
||||
# CSV Import URLs
|
||||
path("import/", views.csv_import_list, name="csv_import_list"),
|
||||
path("import/neu/", views.csv_import_create, name="csv_import_create"),
|
||||
@@ -391,4 +391,14 @@ urlpatterns = [
|
||||
path("geschichte/<slug:slug>/", views.geschichte_detail, name="geschichte_detail"),
|
||||
path("geschichte/<slug:slug>/bearbeiten/", views.geschichte_edit, name="geschichte_edit"),
|
||||
path("geschichte/<slug:slug>/bild-upload/", views.geschichte_bild_upload, name="geschichte_bild_upload"),
|
||||
path("geschichte/<slug:slug>/bild/<uuid:bild_id>/loeschen/", views.geschichte_bild_delete, name="geschichte_bild_delete"),
|
||||
|
||||
# Kalender URLs
|
||||
path("kalender/", views.kalender_view, name="kalender"),
|
||||
path("kalender/admin/", views.kalender_admin, name="kalender_admin"),
|
||||
path("kalender/neu/", views.kalender_create, name="kalender_create"),
|
||||
path("kalender/<uuid:pk>/", views.kalender_detail, name="kalender_detail"),
|
||||
path("kalender/<uuid:pk>/bearbeiten/", views.kalender_edit, name="kalender_edit"),
|
||||
path("kalender/<uuid:pk>/loeschen/", views.kalender_delete, name="kalender_delete"),
|
||||
path("kalender/api/events/", views.kalender_api_events, name="kalender_api_events"),
|
||||
]
|
||||
|
||||
@@ -3,7 +3,7 @@ import io
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime, timedelta, date
|
||||
from decimal import Decimal
|
||||
|
||||
import qrcode
|
||||
@@ -225,11 +225,38 @@ from .forms import (DestinataerForm, DestinataerNotizForm,
|
||||
|
||||
def home(request):
|
||||
"""Home page for the Stiftungsverwaltung application"""
|
||||
return render(
|
||||
request,
|
||||
"stiftung/home.html",
|
||||
{"title": "Stiftungsverwaltung", "description": "Foundation Management System"},
|
||||
)
|
||||
from stiftung.services.calendar_service import StiftungsKalenderService
|
||||
|
||||
# Get upcoming events for the calendar widget
|
||||
calendar_service = StiftungsKalenderService()
|
||||
|
||||
# Get all events for the next 14 days
|
||||
from datetime import timedelta
|
||||
today = timezone.now().date()
|
||||
end_date = today + timedelta(days=14)
|
||||
all_events = calendar_service.get_all_events(today, end_date)
|
||||
|
||||
# Filter for upcoming and overdue
|
||||
upcoming_events = [e for e in all_events if not getattr(e, 'overdue', False)]
|
||||
overdue_events = [e for e in all_events if getattr(e, 'overdue', False)]
|
||||
|
||||
# Get current month events for mini calendar
|
||||
from calendar import monthrange
|
||||
_, last_day = monthrange(today.year, today.month)
|
||||
month_start = today.replace(day=1)
|
||||
month_end = today.replace(day=last_day)
|
||||
current_month_events = calendar_service.get_all_events(month_start, month_end)
|
||||
|
||||
context = {
|
||||
"title": "Stiftungsverwaltung",
|
||||
"description": "Foundation Management System",
|
||||
"upcoming_events": upcoming_events[:5], # Show only 5 upcoming events
|
||||
"overdue_events": overdue_events[:3], # Show only 3 overdue events
|
||||
"current_month_events": current_month_events,
|
||||
"today": today,
|
||||
}
|
||||
|
||||
return render(request, "stiftung/home.html", context)
|
||||
|
||||
|
||||
@login_required
|
||||
@@ -8152,3 +8179,412 @@ def geschichte_bild_upload(request, slug):
|
||||
}
|
||||
|
||||
return render(request, 'stiftung/geschichte/bild_form.html', context)
|
||||
|
||||
|
||||
@login_required
|
||||
def geschichte_bild_delete(request, slug, bild_id):
|
||||
"""Delete an image from a history page"""
|
||||
seite = get_object_or_404(GeschichteSeite, slug=slug)
|
||||
bild = get_object_or_404(GeschichteBild, id=bild_id, seite=seite)
|
||||
|
||||
if not request.user.has_perm('stiftung.delete_geschichtebild'):
|
||||
messages.error(request, 'Sie haben keine Berechtigung, Bilder zu löschen.')
|
||||
return redirect('stiftung:geschichte_detail', slug=slug)
|
||||
|
||||
if request.method == 'POST':
|
||||
bild_titel = bild.titel
|
||||
bild.delete()
|
||||
messages.success(request, f'Bild "{bild_titel}" wurde erfolgreich gelöscht.')
|
||||
return redirect('stiftung:geschichte_detail', slug=slug)
|
||||
|
||||
context = {
|
||||
'bild': bild,
|
||||
'seite': seite,
|
||||
'title': f'Bild löschen: {bild.titel}'
|
||||
}
|
||||
|
||||
return render(request, 'stiftung/geschichte/bild_delete.html', context)
|
||||
|
||||
|
||||
# Calendar Views
|
||||
@login_required
|
||||
def kalender_view(request):
|
||||
"""Main calendar view with different view types"""
|
||||
from stiftung.services.calendar_service import StiftungsKalenderService
|
||||
import calendar as cal
|
||||
|
||||
calendar_service = StiftungsKalenderService()
|
||||
|
||||
# Get current date and view parameters
|
||||
today = timezone.now().date()
|
||||
view_type = request.GET.get('view', 'month') # month, week, list, agenda
|
||||
year = int(request.GET.get('year', today.year))
|
||||
month = int(request.GET.get('month', today.month))
|
||||
|
||||
# Calculate date ranges based on view type
|
||||
if view_type == 'month':
|
||||
# Get events for the entire month
|
||||
start_date = date(year, month, 1)
|
||||
_, last_day = cal.monthrange(year, month)
|
||||
end_date = date(year, month, last_day)
|
||||
title_suffix = f"{cal.month_name[month]} {year}"
|
||||
|
||||
elif view_type == 'week':
|
||||
# Get current week
|
||||
week_start = today - timedelta(days=today.weekday())
|
||||
start_date = week_start
|
||||
end_date = week_start + timedelta(days=6)
|
||||
title_suffix = f"Woche vom {start_date.strftime('%d.%m')} - {end_date.strftime('%d.%m.%Y')}"
|
||||
|
||||
elif view_type == 'agenda':
|
||||
# Next 30 days
|
||||
start_date = today
|
||||
end_date = today + timedelta(days=30)
|
||||
title_suffix = "Nächste 30 Tage"
|
||||
|
||||
else: # list view
|
||||
# Next 90 days
|
||||
start_date = today
|
||||
end_date = today + timedelta(days=90)
|
||||
title_suffix = "Liste (nächste 90 Tage)"
|
||||
|
||||
# Get events for the date range
|
||||
events = calendar_service.get_all_events(start_date, end_date)
|
||||
|
||||
# Generate calendar grid for month view
|
||||
calendar_grid = None
|
||||
if view_type == 'month':
|
||||
calendar_grid = []
|
||||
first_day = date(year, month, 1)
|
||||
month_cal = cal.monthcalendar(year, month)
|
||||
|
||||
for week in month_cal:
|
||||
week_data = []
|
||||
for day in week:
|
||||
if day == 0:
|
||||
week_data.append(None)
|
||||
else:
|
||||
day_date = date(year, month, day)
|
||||
day_events = [e for e in events if e.date == day_date]
|
||||
week_data.append({
|
||||
'day': day,
|
||||
'date': day_date,
|
||||
'is_today': day_date == today,
|
||||
'events': day_events[:3], # Show max 3 events per day
|
||||
'event_count': len(day_events)
|
||||
})
|
||||
calendar_grid.append(week_data)
|
||||
|
||||
# Navigation dates for month view
|
||||
if month > 1:
|
||||
prev_month = month - 1
|
||||
prev_year = year
|
||||
else:
|
||||
prev_month = 12
|
||||
prev_year = year - 1
|
||||
|
||||
if month < 12:
|
||||
next_month = month + 1
|
||||
next_year = year
|
||||
else:
|
||||
next_month = 1
|
||||
next_year = year + 1
|
||||
|
||||
context = {
|
||||
'title': f'Kalender - {title_suffix}',
|
||||
'events': events,
|
||||
'calendar_grid': calendar_grid,
|
||||
'view_type': view_type,
|
||||
'year': year,
|
||||
'month': month,
|
||||
'today': today,
|
||||
'start_date': start_date,
|
||||
'end_date': end_date,
|
||||
'prev_year': prev_year,
|
||||
'prev_month': prev_month,
|
||||
'next_year': next_year,
|
||||
'next_month': next_month,
|
||||
'month_name': cal.month_name[month],
|
||||
'weekdays': ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'],
|
||||
}
|
||||
|
||||
# Choose template based on view type
|
||||
if view_type == 'month':
|
||||
template = 'stiftung/kalender/month_view.html'
|
||||
elif view_type == 'week':
|
||||
template = 'stiftung/kalender/week_view.html'
|
||||
elif view_type == 'agenda':
|
||||
template = 'stiftung/kalender/agenda_view.html'
|
||||
else:
|
||||
template = 'stiftung/kalender/list_view.html'
|
||||
|
||||
return render(request, template, context)
|
||||
|
||||
|
||||
@login_required
|
||||
def kalender_create(request):
|
||||
"""Create new calendar event"""
|
||||
from stiftung.models import StiftungsKalenderEintrag
|
||||
|
||||
if request.method == 'POST':
|
||||
# Simple form handling - you can enhance this with Django forms
|
||||
titel = request.POST.get('titel')
|
||||
beschreibung = request.POST.get('beschreibung', '')
|
||||
datum = request.POST.get('datum')
|
||||
kategorie = request.POST.get('kategorie', 'termin')
|
||||
prioritaet = request.POST.get('prioritaet', 'normal')
|
||||
|
||||
if titel and datum:
|
||||
zeit_str = request.POST.get('zeit')
|
||||
uhrzeit = zeit_str if zeit_str else None
|
||||
ganztags = not bool(zeit_str)
|
||||
|
||||
StiftungsKalenderEintrag.objects.create(
|
||||
titel=titel,
|
||||
beschreibung=beschreibung,
|
||||
datum=datum,
|
||||
uhrzeit=uhrzeit,
|
||||
ganztags=ganztags,
|
||||
kategorie=kategorie,
|
||||
prioritaet=prioritaet,
|
||||
erstellt_von=request.user.username
|
||||
)
|
||||
messages.success(request, 'Kalendereintrag wurde erfolgreich erstellt.')
|
||||
return redirect('stiftung:kalender')
|
||||
else:
|
||||
messages.error(request, 'Titel und Datum sind erforderlich.')
|
||||
|
||||
context = {
|
||||
'title': 'Neuer Kalendereintrag',
|
||||
}
|
||||
|
||||
return render(request, 'stiftung/kalender/create.html', context)
|
||||
|
||||
|
||||
@login_required
|
||||
def kalender_detail(request, pk):
|
||||
"""Calendar event detail view"""
|
||||
from stiftung.models import StiftungsKalenderEintrag
|
||||
|
||||
event = get_object_or_404(StiftungsKalenderEintrag, pk=pk)
|
||||
|
||||
context = {
|
||||
'title': f'Kalendereintrag: {event.titel}',
|
||||
'event': event,
|
||||
}
|
||||
|
||||
return render(request, 'stiftung/kalender/detail.html', context)
|
||||
|
||||
|
||||
@login_required
|
||||
def kalender_edit(request, pk):
|
||||
"""Edit calendar event"""
|
||||
from stiftung.models import StiftungsKalenderEintrag
|
||||
|
||||
event = get_object_or_404(StiftungsKalenderEintrag, pk=pk)
|
||||
|
||||
if request.method == 'POST':
|
||||
event.titel = request.POST.get('titel', event.titel)
|
||||
event.beschreibung = request.POST.get('beschreibung', event.beschreibung)
|
||||
event.datum = request.POST.get('datum', event.datum)
|
||||
zeit_str = request.POST.get('zeit')
|
||||
if zeit_str:
|
||||
event.uhrzeit = zeit_str
|
||||
event.ganztags = False
|
||||
else:
|
||||
event.uhrzeit = None
|
||||
event.ganztags = True
|
||||
event.kategorie = request.POST.get('kategorie', event.kategorie)
|
||||
event.prioritaet = request.POST.get('prioritaet', event.prioritaet)
|
||||
event.erledigt = 'erledigt' in request.POST
|
||||
|
||||
event.save()
|
||||
messages.success(request, 'Kalendereintrag wurde aktualisiert.')
|
||||
return redirect('stiftung:kalender_detail', pk=pk)
|
||||
|
||||
context = {
|
||||
'title': f'Bearbeiten: {event.titel}',
|
||||
'event': event,
|
||||
}
|
||||
|
||||
return render(request, 'stiftung/kalender/edit.html', context)
|
||||
|
||||
|
||||
@login_required
|
||||
def kalender_delete(request, pk):
|
||||
"""Delete calendar event"""
|
||||
from stiftung.models import StiftungsKalenderEintrag
|
||||
|
||||
event = get_object_or_404(StiftungsKalenderEintrag, pk=pk)
|
||||
|
||||
if request.method == 'POST':
|
||||
event_titel = event.titel
|
||||
event.delete()
|
||||
messages.success(request, f'Kalendereintrag "{event_titel}" wurde gelöscht.')
|
||||
return redirect('stiftung:kalender')
|
||||
|
||||
context = {
|
||||
'title': f'Löschen: {event.titel}',
|
||||
'event': event,
|
||||
}
|
||||
|
||||
return render(request, 'stiftung/kalender/delete_confirm.html', context)
|
||||
|
||||
|
||||
@login_required
|
||||
def kalender_admin(request):
|
||||
"""Calendar administration with event sources and management"""
|
||||
from stiftung.models import StiftungsKalenderEintrag
|
||||
from stiftung.services.calendar_service import StiftungsKalenderService
|
||||
|
||||
# Get filter parameters
|
||||
show_custom = request.GET.get('show_custom', 'true') == 'true'
|
||||
show_payments = request.GET.get('show_payments', 'true') == 'true'
|
||||
show_leases = request.GET.get('show_leases', 'true') == 'true'
|
||||
show_birthdays = request.GET.get('show_birthdays', 'true') == 'true'
|
||||
category_filter = request.GET.get('category', '')
|
||||
priority_filter = request.GET.get('priority', '')
|
||||
|
||||
# Initialize calendar service
|
||||
calendar_service = StiftungsKalenderService()
|
||||
|
||||
# Get events based on filters
|
||||
from datetime import date, timedelta
|
||||
start_date = date.today() - timedelta(days=30)
|
||||
end_date = date.today() + timedelta(days=90)
|
||||
|
||||
all_events = []
|
||||
|
||||
# Custom calendar entries
|
||||
if show_custom:
|
||||
custom_events = calendar_service.get_calendar_events(start_date, end_date)
|
||||
all_events.extend(custom_events)
|
||||
|
||||
# Payment events
|
||||
if show_payments:
|
||||
payment_events = calendar_service.get_support_payment_events(start_date, end_date)
|
||||
all_events.extend(payment_events)
|
||||
|
||||
# Lease events
|
||||
if show_leases:
|
||||
lease_events = calendar_service.get_lease_events(start_date, end_date)
|
||||
all_events.extend(lease_events)
|
||||
|
||||
# Birthday events
|
||||
if show_birthdays:
|
||||
birthday_events = calendar_service.get_birthday_events(start_date, end_date)
|
||||
all_events.extend(birthday_events)
|
||||
|
||||
# Filter by category and priority if specified
|
||||
if category_filter:
|
||||
all_events = [e for e in all_events if getattr(e, 'category', '') == category_filter]
|
||||
|
||||
if priority_filter:
|
||||
all_events = [e for e in all_events if getattr(e, 'priority', '') == priority_filter]
|
||||
|
||||
# Sort events by date
|
||||
all_events.sort(key=lambda x: x.date)
|
||||
|
||||
# Get statistics
|
||||
custom_count = StiftungsKalenderEintrag.objects.count()
|
||||
total_events = len(all_events)
|
||||
|
||||
# Event source statistics
|
||||
stats = {
|
||||
'custom_events': len([e for e in all_events if getattr(e, 'source', '') == 'custom']),
|
||||
'payment_events': len([e for e in all_events if getattr(e, 'source', '') == 'payment']),
|
||||
'lease_events': len([e for e in all_events if getattr(e, 'source', '') == 'lease']),
|
||||
'birthday_events': len([e for e in all_events if getattr(e, 'source', '') == 'birthday']),
|
||||
'total_events': total_events,
|
||||
'custom_count': custom_count,
|
||||
}
|
||||
|
||||
context = {
|
||||
'title': 'Kalender Administration',
|
||||
'events': all_events,
|
||||
'stats': stats,
|
||||
'show_custom': show_custom,
|
||||
'show_payments': show_payments,
|
||||
'show_leases': show_leases,
|
||||
'show_birthdays': show_birthdays,
|
||||
'category_filter': category_filter,
|
||||
'priority_filter': priority_filter,
|
||||
'categories': StiftungsKalenderEintrag.KATEGORIE_CHOICES,
|
||||
'priorities': StiftungsKalenderEintrag.PRIORITAET_CHOICES,
|
||||
}
|
||||
|
||||
return render(request, 'stiftung/kalender/admin.html', context)
|
||||
|
||||
context = {
|
||||
'title': f'Löschen: {event.titel}',
|
||||
'event': event,
|
||||
}
|
||||
|
||||
return render(request, 'stiftung/kalender/delete.html', context)
|
||||
|
||||
|
||||
@login_required
|
||||
def kalender_api_events(request):
|
||||
"""API endpoint for calendar events (JSON)"""
|
||||
from django.http import JsonResponse
|
||||
from stiftung.services.calendar_service import StiftungsKalenderService
|
||||
from datetime import datetime
|
||||
|
||||
calendar_service = StiftungsKalenderService()
|
||||
|
||||
# Get date range from request
|
||||
start_date = request.GET.get('start')
|
||||
end_date = request.GET.get('end')
|
||||
|
||||
if start_date and end_date:
|
||||
try:
|
||||
start_date = datetime.strptime(start_date, '%Y-%m-%d').date()
|
||||
end_date = datetime.strptime(end_date, '%Y-%m-%d').date()
|
||||
except ValueError:
|
||||
return JsonResponse({'error': 'Invalid date format'}, status=400)
|
||||
|
||||
events = calendar_service.get_all_events(start_date, end_date)
|
||||
else:
|
||||
events = calendar_service.get_all_events()
|
||||
|
||||
# Convert to FullCalendar format
|
||||
calendar_events = []
|
||||
for event in events:
|
||||
calendar_events.append({
|
||||
'id': getattr(event, 'id', str(event.title)),
|
||||
'title': event.title,
|
||||
'start': event.date.strftime('%Y-%m-%d'),
|
||||
'description': getattr(event, 'description', ''),
|
||||
'className': f"event-{event.category}",
|
||||
'backgroundColor': f"var(--bs-{event.color})",
|
||||
'borderColor': f"var(--bs-{event.color})",
|
||||
})
|
||||
|
||||
return JsonResponse(calendar_events, safe=False)
|
||||
|
||||
|
||||
# Calendar Views
|
||||
@login_required
|
||||
def kalender_view(request):
|
||||
"""Full calendar view with all events"""
|
||||
from stiftung.services.calendar_service import StiftungsKalenderService
|
||||
|
||||
calendar_service = StiftungsKalenderService()
|
||||
|
||||
# Get current month events by default
|
||||
today = timezone.now().date()
|
||||
events = calendar_service.get_events_for_month(today.year, today.month)
|
||||
|
||||
context = {
|
||||
'events': events,
|
||||
'title': 'Stiftungskalender',
|
||||
'current_month': today.strftime('%B %Y'),
|
||||
}
|
||||
|
||||
return render(request, 'stiftung/kalender/kalender.html', context)
|
||||
|
||||
|
||||
context = {
|
||||
'title': 'Kalendereintrag löschen'
|
||||
}
|
||||
return render(request, 'stiftung/kalender/delete.html', context)
|
||||
|
||||
Reference in New Issue
Block a user