fix: configure CI database connection properly

- Add dotenv loading to Django settings
- Update CI workflow to use correct environment variables
- Set POSTGRES_* variables instead of DATABASE_URL
- Add environment variables to all Django management commands
- Fixes CI test failures due to database connection issues
This commit is contained in:
Stiftung Development
2025-09-06 18:47:23 +02:00
parent dcc91b9f49
commit 35ba089a84
64 changed files with 7040 additions and 1419 deletions

View File

@@ -0,0 +1,133 @@
"""
Management command to generate due recurring support payments.
This command should be run daily via cron or similar scheduling system.
"""
from django.core.management.base import BaseCommand
from django.utils import timezone
from datetime import timedelta
from stiftung.models import UnterstuetzungWiederkehrend
import logging
logger = logging.getLogger(__name__)
class Command(BaseCommand):
help = 'Generate due recurring support payments'
def add_arguments(self, parser):
parser.add_argument(
'--dry-run',
action='store_true',
help='Show what would be generated without actually creating payments',
)
parser.add_argument(
'--days-ahead',
type=int,
default=0,
help='Generate payments that are due within this many days (default: 0 = only today)',
)
def handle(self, *args, **options):
dry_run = options['dry_run']
days_ahead = options['days_ahead']
heute = timezone.now().date()
cutoff_date = heute + timedelta(days=days_ahead)
self.stdout.write(
self.style.SUCCESS(
f'Checking for recurring payments due up to {cutoff_date.strftime("%d.%m.%Y")}...'
)
)
if dry_run:
self.stdout.write(self.style.WARNING('DRY RUN MODE - No payments will be created'))
# Get all active recurring payment templates that are due
templates = UnterstuetzungWiederkehrend.objects.filter(
aktiv=True,
naechste_generierung__lte=cutoff_date
).select_related('destinataer', 'konto')
generated_count = 0
error_count = 0
for template in templates:
try:
if dry_run:
self.stdout.write(
f'Would generate: {template.destinataer.get_full_name()} - '
f'{template.betrag} due {template.naechste_generierung.strftime("%d.%m.%Y")}'
)
generated_count += 1
else:
# Actually generate the payment
neue_zahlung = template.generiere_naechste_zahlung()
if neue_zahlung:
self.stdout.write(
self.style.SUCCESS(
f'Generated: {neue_zahlung.destinataer.get_full_name()} - '
f'{neue_zahlung.betrag} due {neue_zahlung.faellig_am.strftime("%d.%m.%Y")}'
)
)
generated_count += 1
logger.info(f'Generated recurring payment: {neue_zahlung.pk}')
else:
self.stdout.write(
self.style.WARNING(
f'No payment generated for {template.destinataer.get_full_name()} '
f'(may have reached end date or not yet due)'
)
)
except Exception as e:
error_count += 1
self.stdout.write(
self.style.ERROR(
f'Error generating payment for {template.destinataer.get_full_name()}: {str(e)}'
)
)
logger.error(f'Error generating recurring payment for template {template.pk}: {str(e)}')
# Summary
self.stdout.write('\n' + '='*50)
if dry_run:
self.stdout.write(
self.style.SUCCESS(
f'DRY RUN COMPLETE: {generated_count} payments would be generated'
)
)
else:
self.stdout.write(
self.style.SUCCESS(
f'GENERATION COMPLETE: {generated_count} payments generated'
)
)
if error_count > 0:
self.stdout.write(
self.style.ERROR(f'{error_count} errors encountered')
)
# Also check for overdue payments and report them
from stiftung.models import DestinataerUnterstuetzung
overdue_payments = DestinataerUnterstuetzung.objects.filter(
faellig_am__lt=heute,
status__in=['geplant', 'faellig']
).select_related('destinataer')
if overdue_payments.exists():
self.stdout.write('\n' + '='*50)
self.stdout.write(
self.style.WARNING(
f'WARNING: {overdue_payments.count()} overdue payments found:'
)
)
for payment in overdue_payments[:10]: # Limit to first 10
days_overdue = (heute - payment.faellig_am).days
self.stdout.write(
f' - {payment.destinataer.get_full_name()}: €{payment.betrag} '
f'({days_overdue} days overdue)'
)
if overdue_payments.count() > 10:
self.stdout.write(f' ... and {overdue_payments.count() - 10} more')

View File

@@ -0,0 +1,124 @@
from django.core.management.base import BaseCommand
from stiftung.models import AppConfiguration
class Command(BaseCommand):
help = 'Initialize default app configuration settings'
def handle(self, *args, **options):
# Paperless Integration Settings
paperless_settings = [
{
'key': 'paperless_api_url',
'display_name': 'Paperless API URL',
'description': 'The base URL for your Paperless-NGX API (e.g., http://paperless.example.com:8000)',
'value': 'http://192.168.178.167:30070',
'default_value': 'http://192.168.178.167:30070',
'setting_type': 'url',
'category': 'paperless',
'order': 1
},
{
'key': 'paperless_api_token',
'display_name': 'Paperless API Token',
'description': 'The authentication token for Paperless API access',
'value': '',
'default_value': '',
'setting_type': 'text',
'category': 'paperless',
'order': 2
},
{
'key': 'paperless_destinataere_tag',
'display_name': 'Destinatäre Tag Name',
'description': 'The tag name used to identify Destinatäre documents in Paperless',
'value': 'Stiftung_Destinatäre',
'default_value': 'Stiftung_Destinatäre',
'setting_type': 'tag',
'category': 'paperless',
'order': 3
},
{
'key': 'paperless_destinataere_tag_id',
'display_name': 'Destinatäre Tag ID',
'description': 'The numeric ID of the Destinatäre tag in Paperless',
'value': '210',
'default_value': '210',
'setting_type': 'tag_id',
'category': 'paperless',
'order': 4
},
{
'key': 'paperless_land_tag',
'display_name': 'Land & Pächter Tag Name',
'description': 'The tag name used to identify Land and Pächter documents in Paperless',
'value': 'Stiftung_Land_und_Pächter',
'default_value': 'Stiftung_Land_und_Pächter',
'setting_type': 'tag',
'category': 'paperless',
'order': 5
},
{
'key': 'paperless_land_tag_id',
'display_name': 'Land & Pächter Tag ID',
'description': 'The numeric ID of the Land & Pächter tag in Paperless',
'value': '204',
'default_value': '204',
'setting_type': 'tag_id',
'category': 'paperless',
'order': 6
},
{
'key': 'paperless_admin_tag',
'display_name': 'Administration Tag Name',
'description': 'The tag name used to identify Administration documents in Paperless',
'value': 'Stiftung_Administration',
'default_value': 'Stiftung_Administration',
'setting_type': 'tag',
'category': 'paperless',
'order': 7
},
{
'key': 'paperless_admin_tag_id',
'display_name': 'Administration Tag ID',
'description': 'The numeric ID of the Administration tag in Paperless',
'value': '216',
'default_value': '216',
'setting_type': 'tag_id',
'category': 'paperless',
'order': 8
}
]
created_count = 0
updated_count = 0
for setting_data in paperless_settings:
setting, created = AppConfiguration.objects.get_or_create(
key=setting_data['key'],
defaults=setting_data
)
if created:
created_count += 1
self.stdout.write(
self.style.SUCCESS(f'Created setting: {setting.display_name}')
)
else:
# Update existing setting with new defaults if needed
if not setting.description:
setting.description = setting_data['description']
setting.save()
updated_count += 1
self.stdout.write(
self.style.SUCCESS(
f'Configuration initialized successfully! '
f'Created {created_count} new settings, updated {updated_count} existing settings.'
)
)
self.stdout.write(
self.style.WARNING(
'You can now manage these settings in the Django Admin under "App Configurations"'
)
)

View File

@@ -0,0 +1,149 @@
"""
Management command to initialize corporate identity settings
"""
from django.core.management.base import BaseCommand
from stiftung.models import AppConfiguration
class Command(BaseCommand):
help = 'Initialize corporate identity settings for PDF generation'
def handle(self, *args, **options):
corporate_settings = [
{
'key': 'corporate_stiftung_name',
'display_name': 'Name der Stiftung',
'description': 'Der offizielle Name der Stiftung für PDF-Dokumente',
'value': 'Stiftung',
'default_value': 'Stiftung',
'setting_type': 'text',
'category': 'corporate',
'order': 1
},
{
'key': 'corporate_logo_path',
'display_name': 'Logo-Pfad',
'description': 'Pfad zur Logo-Datei (relativ zu MEDIA_ROOT oder STATIC_ROOT)',
'value': '',
'default_value': '',
'setting_type': 'text',
'category': 'corporate',
'order': 2
},
{
'key': 'corporate_primary_color',
'display_name': 'Primärfarbe',
'description': 'Hauptfarbe für Überschriften und Akzente (Hex-Code)',
'value': '#2c3e50',
'default_value': '#2c3e50',
'setting_type': 'text',
'category': 'corporate',
'order': 3
},
{
'key': 'corporate_secondary_color',
'display_name': 'Sekundärfarbe',
'description': 'Zweitfarbe für Akzente und Details (Hex-Code)',
'value': '#3498db',
'default_value': '#3498db',
'setting_type': 'text',
'category': 'corporate',
'order': 4
},
{
'key': 'corporate_address_line1',
'display_name': 'Adresse Zeile 1',
'description': 'Erste Zeile der Stiftungsadresse',
'value': '',
'default_value': '',
'setting_type': 'text',
'category': 'corporate',
'order': 5
},
{
'key': 'corporate_address_line2',
'display_name': 'Adresse Zeile 2',
'description': 'Zweite Zeile der Stiftungsadresse (PLZ, Ort)',
'value': '',
'default_value': '',
'setting_type': 'text',
'category': 'corporate',
'order': 6
},
{
'key': 'corporate_phone',
'display_name': 'Telefonnummer',
'description': 'Telefonnummer der Stiftung',
'value': '',
'default_value': '',
'setting_type': 'text',
'category': 'corporate',
'order': 7
},
{
'key': 'corporate_email',
'display_name': 'E-Mail-Adresse',
'description': 'Offizielle E-Mail-Adresse der Stiftung',
'value': '',
'default_value': '',
'setting_type': 'text',
'category': 'corporate',
'order': 8
},
{
'key': 'corporate_website',
'display_name': 'Website',
'description': 'Website der Stiftung',
'value': '',
'default_value': '',
'setting_type': 'url',
'category': 'corporate',
'order': 9
},
{
'key': 'corporate_footer_text',
'display_name': 'Fußzeilen-Text',
'description': 'Text für die Fußzeile in PDF-Dokumenten',
'value': 'Dieser Bericht wurde automatisch generiert.',
'default_value': 'Dieser Bericht wurde automatisch generiert.',
'setting_type': 'text',
'category': 'corporate',
'order': 10
},
]
created_count = 0
updated_count = 0
for setting_data in corporate_settings:
setting, created = AppConfiguration.objects.get_or_create(
key=setting_data['key'],
defaults=setting_data
)
if created:
created_count += 1
self.stdout.write(
self.style.SUCCESS(f'Created setting: {setting.display_name}')
)
else:
# Update existing setting with new defaults if needed
if not setting.description:
setting.description = setting_data['description']
setting.save()
updated_count += 1
self.stdout.write(
self.style.SUCCESS(
f'Corporate identity settings initialized! '
f'Created {created_count} new settings, updated {updated_count} existing settings.'
)
)
if created_count > 0:
self.stdout.write(
self.style.WARNING(
'Please configure your corporate identity settings in '
'Administration -> Application Settings before generating PDFs.'
)
)

View File

@@ -0,0 +1,253 @@
#!/usr/bin/env python3
"""
Management command to synchronize existing Verpachtungen with LandAbrechnungen.
This command will:
1. Find all existing Verpachtungen (both legacy and new LandVerpachtung)
2. Calculate the financial impact for each year they're active
3. Update or create corresponding LandAbrechnung records
4. Provide a summary of changes made
Usage:
python manage.py sync_abrechnungen [--dry-run] [--year YEAR]
"""
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from decimal import Decimal
from datetime import date
from stiftung.models import Verpachtung, LandVerpachtung, LandAbrechnung
class Command(BaseCommand):
help = 'Synchronize existing Verpachtungen with LandAbrechnungen'
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(
'--year',
type=int,
help='Only sync data for specific year',
)
parser.add_argument(
'--force',
action='store_true',
help='Force update even if Abrechnungen already exist',
)
def handle(self, *args, **options):
dry_run = options['dry_run']
target_year = options['year']
force = options['force']
self.stdout.write(
self.style.SUCCESS('🔄 Starting Abrechnung synchronization...')
)
if dry_run:
self.stdout.write(self.style.WARNING('📋 DRY RUN MODE - No changes will be made'))
# Statistics
stats = {
'legacy_contracts': 0,
'new_contracts': 0,
'abrechnungen_created': 0,
'abrechnungen_updated': 0,
'total_rent_amount': Decimal('0.00'),
'years_processed': set(),
}
try:
with transaction.atomic():
# Process Legacy Verpachtungen
self.stdout.write('\n📄 Processing Legacy Verpachtungen...')
legacy_verpachtungen = Verpachtung.objects.all()
for verpachtung in legacy_verpachtungen:
stats['legacy_contracts'] += 1
years_affected = self._get_affected_years(
verpachtung.pachtbeginn,
verpachtung.verlaengerung or verpachtung.pachtende,
target_year
)
for year in years_affected:
stats['years_processed'].add(year)
rent_amount = self._calculate_legacy_rent_for_year(verpachtung, year)
if not dry_run:
created, updated = self._update_abrechnung(
verpachtung.land,
year,
rent_amount,
Decimal('0.00'), # No umlage for legacy
f"Legacy-Verpachtung {verpachtung.vertragsnummer}",
force
)
if created:
stats['abrechnungen_created'] += 1
if updated:
stats['abrechnungen_updated'] += 1
stats['total_rent_amount'] += rent_amount
self.stdout.write(
f" 📊 {verpachtung.vertragsnummer} ({year}): {rent_amount:.2f}"
)
# Process New LandVerpachtungen
self.stdout.write('\n🆕 Processing New LandVerpachtungen...')
land_verpachtungen = LandVerpachtung.objects.all()
for verpachtung in land_verpachtungen:
stats['new_contracts'] += 1
years_affected = self._get_affected_years(
verpachtung.pachtbeginn,
verpachtung.pachtende,
target_year
)
for year in years_affected:
stats['years_processed'].add(year)
rent_amount = self._calculate_new_rent_for_year(verpachtung, year)
umlage_amount = Decimal('0.00') # To be calculated later
if not dry_run:
created, updated = self._update_abrechnung(
verpachtung.land,
year,
rent_amount,
umlage_amount,
f"LandVerpachtung {verpachtung.vertragsnummer}",
force
)
if created:
stats['abrechnungen_created'] += 1
if updated:
stats['abrechnungen_updated'] += 1
stats['total_rent_amount'] += rent_amount
self.stdout.write(
f" 📊 {verpachtung.vertragsnummer} ({year}): {rent_amount:.2f}"
)
if dry_run:
# Rollback transaction in dry run
transaction.set_rollback(True)
except Exception as e:
self.stdout.write(
self.style.ERROR(f'❌ Error during synchronization: {str(e)}')
)
raise CommandError(f'Synchronization failed: {str(e)}')
# Print summary
self.stdout.write('\n' + '='*50)
self.stdout.write(self.style.SUCCESS('📈 SYNCHRONIZATION SUMMARY'))
self.stdout.write('='*50)
self.stdout.write(f"Legacy contracts processed: {stats['legacy_contracts']}")
self.stdout.write(f"New contracts processed: {stats['new_contracts']}")
self.stdout.write(f"Years affected: {', '.join(map(str, sorted(stats['years_processed'])))}")
self.stdout.write(f"Abrechnungen created: {stats['abrechnungen_created']}")
self.stdout.write(f"Abrechnungen updated: {stats['abrechnungen_updated']}")
self.stdout.write(f"Total rent amount: {stats['total_rent_amount']:.2f}")
if dry_run:
self.stdout.write(self.style.WARNING('\n📋 This was a DRY RUN - no changes were saved'))
else:
self.stdout.write(self.style.SUCCESS('\n✅ Synchronization completed successfully!'))
def _get_affected_years(self, start_date, end_date, target_year=None):
"""Get all years affected by a contract"""
if not start_date:
return []
years = []
start_year = start_date.year
end_year = end_date.year if end_date else date.today().year
if target_year:
if start_year <= target_year <= end_year:
return [target_year]
else:
return []
for year in range(start_year, end_year + 1):
years.append(year)
return years
def _calculate_legacy_rent_for_year(self, verpachtung, year):
"""Calculate rent for legacy Verpachtung for specific year"""
if not verpachtung.pachtzins_jaehrlich or not verpachtung.pachtbeginn:
return Decimal('0.00')
year_start = date(year, 1, 1)
year_end = date(year, 12, 31)
contract_end_date = verpachtung.verlaengerung if verpachtung.verlaengerung else verpachtung.pachtende
contract_start = max(verpachtung.pachtbeginn, year_start)
contract_end = min(contract_end_date or year_end, year_end)
if contract_start > contract_end:
return Decimal('0.00')
days_in_year = (year_end - year_start).days + 1
days_active = (contract_end - contract_start).days + 1
proportion = Decimal(str(days_active)) / Decimal(str(days_in_year))
return Decimal(str(verpachtung.pachtzins_jaehrlich)) * proportion
def _calculate_new_rent_for_year(self, verpachtung, year):
"""Calculate rent for new LandVerpachtung for specific year"""
if not verpachtung.pachtzins_pauschal or not verpachtung.pachtbeginn:
return Decimal('0.00')
year_start = date(year, 1, 1)
year_end = date(year, 12, 31)
contract_start = max(verpachtung.pachtbeginn, year_start)
contract_end = min(verpachtung.pachtende or year_end, year_end)
if contract_start > contract_end:
return Decimal('0.00')
days_in_year = (year_end - year_start).days + 1
days_active = (contract_end - contract_start).days + 1
proportion = Decimal(str(days_active)) / Decimal(str(days_in_year))
return Decimal(str(verpachtung.pachtzins_pauschal)) * proportion
def _update_abrechnung(self, land, year, rent_amount, umlage_amount, source_note, force):
"""Update or create Abrechnung for specific land and year"""
abrechnung, created = LandAbrechnung.objects.get_or_create(
land=land,
abrechnungsjahr=year,
defaults={
'pacht_vereinnahmt': rent_amount,
'umlagen_vereinnahmt': umlage_amount,
'bemerkungen': f'[{date.today().strftime("%d.%m.%Y")}] Automatisch synchronisiert von {source_note}'
}
)
updated = False
if not created and force:
# Update existing
abrechnung.pacht_vereinnahmt += rent_amount
abrechnung.umlagen_vereinnahmt += umlage_amount
sync_note = f'[{date.today().strftime("%d.%m.%Y")}] Resync: +{rent_amount:.2f}€ von {source_note}'
if abrechnung.bemerkungen:
abrechnung.bemerkungen += f'\n{sync_note}'
else:
abrechnung.bemerkungen = sync_note
abrechnung.save()
updated = True
return created, updated