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:
73
app/stiftung/utils/config.py
Normal file
73
app/stiftung/utils/config.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""
|
||||
Configuration utilities for the Stiftung application
|
||||
"""
|
||||
from django.conf import settings
|
||||
from stiftung.models import AppConfiguration
|
||||
|
||||
|
||||
def get_config(key, default=None, fallback_to_settings=True):
|
||||
"""
|
||||
Get a configuration value from the database or fall back to Django settings
|
||||
|
||||
Args:
|
||||
key: The configuration key
|
||||
default: Default value if not found
|
||||
fallback_to_settings: If True, try to get from Django settings using the key in uppercase
|
||||
|
||||
Returns:
|
||||
The configuration value
|
||||
"""
|
||||
# Try to get from AppConfiguration first
|
||||
value = AppConfiguration.get_setting(key, None)
|
||||
|
||||
# Fall back to Django settings if value is None or empty string
|
||||
if not value and fallback_to_settings:
|
||||
settings_key = key.upper()
|
||||
return getattr(settings, settings_key, default)
|
||||
|
||||
return value if value is not None else default
|
||||
|
||||
|
||||
def get_paperless_config():
|
||||
"""
|
||||
Get all Paperless-related configuration values
|
||||
|
||||
Returns:
|
||||
dict: Dictionary containing all Paperless configuration
|
||||
"""
|
||||
return {
|
||||
'api_url': get_config('paperless_api_url', 'http://192.168.178.167:30070'),
|
||||
'api_token': get_config('paperless_api_token', ''),
|
||||
'destinataere_tag': get_config('paperless_destinataere_tag', 'Stiftung_Destinatäre'),
|
||||
'destinataere_tag_id': get_config('paperless_destinataere_tag_id', '210'),
|
||||
'land_tag': get_config('paperless_land_tag', 'Stiftung_Land_und_Pächter'),
|
||||
'land_tag_id': get_config('paperless_land_tag_id', '204'),
|
||||
'admin_tag': get_config('paperless_admin_tag', 'Stiftung_Administration'),
|
||||
'admin_tag_id': get_config('paperless_admin_tag_id', '216'),
|
||||
}
|
||||
|
||||
|
||||
def set_config(key, value, **kwargs):
|
||||
"""
|
||||
Set a configuration value
|
||||
|
||||
Args:
|
||||
key: The configuration key
|
||||
value: The value to set
|
||||
**kwargs: Additional parameters for AppConfiguration.set_setting
|
||||
|
||||
Returns:
|
||||
AppConfiguration: The configuration object
|
||||
"""
|
||||
return AppConfiguration.set_setting(key, value, **kwargs)
|
||||
|
||||
|
||||
def is_paperless_configured():
|
||||
"""
|
||||
Check if Paperless is properly configured
|
||||
|
||||
Returns:
|
||||
bool: True if API URL and token are configured
|
||||
"""
|
||||
config = get_paperless_config()
|
||||
return bool(config['api_url'] and config['api_token'])
|
||||
34
app/stiftung/utils/date_utils.py
Normal file
34
app/stiftung/utils/date_utils.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date as _date
|
||||
from typing import Optional, Union
|
||||
|
||||
try:
|
||||
from django.utils.dateparse import parse_date as _parse_date
|
||||
except Exception: # pragma: no cover - django not loaded in some tools
|
||||
_parse_date = None # type: ignore
|
||||
|
||||
DateLike = Union[_date, str, None]
|
||||
|
||||
|
||||
def ensure_date(value: DateLike) -> Optional[_date]:
|
||||
"""Return a date from a date or ISO string; None stays None.
|
||||
|
||||
- Accepts datetime.date, 'YYYY-MM-DD' string, or None.
|
||||
- Returns None if parsing fails or value falsy.
|
||||
"""
|
||||
if not value:
|
||||
return None
|
||||
if isinstance(value, _date):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
if _parse_date is None:
|
||||
return None
|
||||
return _parse_date(value)
|
||||
return None
|
||||
|
||||
|
||||
def get_year_from_date(value: DateLike) -> Optional[int]:
|
||||
"""Extract year from date or ISO string, else None."""
|
||||
d = ensure_date(value)
|
||||
return d.year if d else None
|
||||
420
app/stiftung/utils/pdf_generator.py
Normal file
420
app/stiftung/utils/pdf_generator.py
Normal file
@@ -0,0 +1,420 @@
|
||||
"""
|
||||
PDF generation utilities with corporate identity support
|
||||
"""
|
||||
import os
|
||||
import base64
|
||||
from io import BytesIO
|
||||
from django.conf import settings
|
||||
from django.template.loader import render_to_string
|
||||
from django.http import HttpResponse
|
||||
from django.utils import timezone
|
||||
|
||||
# Try to import WeasyPrint, fall back gracefully if not available
|
||||
try:
|
||||
from weasyprint import HTML, CSS
|
||||
from weasyprint.text.fonts import FontConfiguration
|
||||
WEASYPRINT_AVAILABLE = True
|
||||
IMPORT_ERROR = None
|
||||
except ImportError as e:
|
||||
# WeasyPrint dependencies not available
|
||||
HTML = None
|
||||
CSS = None
|
||||
FontConfiguration = None
|
||||
WEASYPRINT_AVAILABLE = False
|
||||
IMPORT_ERROR = str(e)
|
||||
except OSError as e:
|
||||
# System dependencies missing (like pango)
|
||||
HTML = None
|
||||
CSS = None
|
||||
FontConfiguration = None
|
||||
WEASYPRINT_AVAILABLE = False
|
||||
IMPORT_ERROR = str(e)
|
||||
|
||||
from stiftung.models import AppConfiguration
|
||||
|
||||
|
||||
class PDFGenerator:
|
||||
"""Corporate identity PDF generator"""
|
||||
|
||||
def __init__(self):
|
||||
if WEASYPRINT_AVAILABLE:
|
||||
self.font_config = FontConfiguration()
|
||||
else:
|
||||
self.font_config = None
|
||||
|
||||
def is_available(self):
|
||||
"""Check if PDF generation is available"""
|
||||
return WEASYPRINT_AVAILABLE
|
||||
|
||||
def get_corporate_settings(self):
|
||||
"""Get corporate identity settings from configuration"""
|
||||
return {
|
||||
'stiftung_name': AppConfiguration.get_setting('corporate_stiftung_name', 'Stiftung'),
|
||||
'logo_path': AppConfiguration.get_setting('corporate_logo_path', ''),
|
||||
'primary_color': AppConfiguration.get_setting('corporate_primary_color', '#2c3e50'),
|
||||
'secondary_color': AppConfiguration.get_setting('corporate_secondary_color', '#3498db'),
|
||||
'address_line1': AppConfiguration.get_setting('corporate_address_line1', ''),
|
||||
'address_line2': AppConfiguration.get_setting('corporate_address_line2', ''),
|
||||
'phone': AppConfiguration.get_setting('corporate_phone', ''),
|
||||
'email': AppConfiguration.get_setting('corporate_email', ''),
|
||||
'website': AppConfiguration.get_setting('corporate_website', ''),
|
||||
'footer_text': AppConfiguration.get_setting('corporate_footer_text', 'Dieser Bericht wurde automatisch generiert.'),
|
||||
}
|
||||
|
||||
def get_logo_base64(self, logo_path):
|
||||
"""Convert logo to base64 for embedding in PDF"""
|
||||
if not logo_path:
|
||||
return None
|
||||
|
||||
# Try different possible paths
|
||||
possible_paths = [
|
||||
logo_path,
|
||||
os.path.join(settings.MEDIA_ROOT, logo_path),
|
||||
os.path.join(settings.STATIC_ROOT or '', logo_path),
|
||||
os.path.join(settings.BASE_DIR, 'static', logo_path),
|
||||
]
|
||||
|
||||
for path in possible_paths:
|
||||
if os.path.exists(path):
|
||||
try:
|
||||
with open(path, 'rb') as img_file:
|
||||
img_data = base64.b64encode(img_file.read()).decode('utf-8')
|
||||
# Determine MIME type
|
||||
ext = os.path.splitext(path)[1].lower()
|
||||
if ext in ['.jpg', '.jpeg']:
|
||||
mime_type = 'image/jpeg'
|
||||
elif ext == '.png':
|
||||
mime_type = 'image/png'
|
||||
elif ext == '.svg':
|
||||
mime_type = 'image/svg+xml'
|
||||
else:
|
||||
mime_type = 'image/png' # default
|
||||
|
||||
return f"data:{mime_type};base64,{img_data}"
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
def get_base_css(self, corporate_settings):
|
||||
"""Generate base CSS for corporate identity"""
|
||||
primary_color = corporate_settings.get('primary_color', '#2c3e50')
|
||||
secondary_color = corporate_settings.get('secondary_color', '#3498db')
|
||||
|
||||
return f"""
|
||||
@page {{
|
||||
size: A4;
|
||||
margin: 2cm 1.5cm 2cm 1.5cm;
|
||||
@bottom-center {{
|
||||
content: "Seite " counter(page) " von " counter(pages);
|
||||
font-size: 10pt;
|
||||
color: #666;
|
||||
}}
|
||||
}}
|
||||
|
||||
body {{
|
||||
font-family: 'Segoe UI', 'DejaVu Sans', Arial, sans-serif;
|
||||
font-size: 10pt;
|
||||
line-height: 1.4;
|
||||
color: #333;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}}
|
||||
|
||||
.header {{
|
||||
border-bottom: 2px solid {primary_color};
|
||||
padding-bottom: 15px;
|
||||
margin-bottom: 25px;
|
||||
page-break-inside: avoid;
|
||||
}}
|
||||
|
||||
.header-content {{
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
}}
|
||||
|
||||
.header-left {{
|
||||
flex: 1;
|
||||
}}
|
||||
|
||||
.header-right {{
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
margin-left: 20px;
|
||||
}}
|
||||
|
||||
.logo {{
|
||||
max-height: 60px;
|
||||
max-width: 150px;
|
||||
margin-bottom: 10px;
|
||||
}}
|
||||
|
||||
.stiftung-name {{
|
||||
font-size: 20pt;
|
||||
font-weight: bold;
|
||||
color: {primary_color};
|
||||
margin: 0;
|
||||
line-height: 1.2;
|
||||
}}
|
||||
|
||||
.document-title {{
|
||||
font-size: 16pt;
|
||||
color: {secondary_color};
|
||||
margin: 5px 0 0 0;
|
||||
}}
|
||||
|
||||
.header-info {{
|
||||
font-size: 9pt;
|
||||
color: #666;
|
||||
margin-top: 10px;
|
||||
}}
|
||||
|
||||
.contact-info {{
|
||||
font-size: 8pt;
|
||||
color: #666;
|
||||
line-height: 1.3;
|
||||
}}
|
||||
|
||||
h1, h2, h3 {{
|
||||
color: {primary_color};
|
||||
page-break-inside: avoid;
|
||||
page-break-after: avoid;
|
||||
}}
|
||||
|
||||
h1 {{
|
||||
font-size: 14pt;
|
||||
margin: 20px 0 15px 0;
|
||||
border-bottom: 1px solid {secondary_color};
|
||||
padding-bottom: 5px;
|
||||
}}
|
||||
|
||||
h2 {{
|
||||
font-size: 12pt;
|
||||
margin: 15px 0 10px 0;
|
||||
}}
|
||||
|
||||
h3 {{
|
||||
font-size: 11pt;
|
||||
margin: 10px 0 8px 0;
|
||||
}}
|
||||
|
||||
table {{
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 10px 0;
|
||||
page-break-inside: avoid;
|
||||
}}
|
||||
|
||||
th, td {{
|
||||
border: 1px solid #ddd;
|
||||
padding: 6px 8px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}}
|
||||
|
||||
th {{
|
||||
background-color: #f8f9fa;
|
||||
font-weight: 600;
|
||||
color: {primary_color};
|
||||
}}
|
||||
|
||||
tr:nth-child(even) {{
|
||||
background-color: #f9f9f9;
|
||||
}}
|
||||
|
||||
.amount {{
|
||||
text-align: right;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-weight: 500;
|
||||
}}
|
||||
|
||||
.status-badge {{
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 8pt;
|
||||
font-weight: 500;
|
||||
}}
|
||||
|
||||
.status-beantragt {{ background-color: #fff3cd; color: #856404; }}
|
||||
.status-genehmigt {{ background-color: #d1ecf1; color: #0c5460; }}
|
||||
.status-ausgezahlt {{ background-color: #d4edda; color: #155724; }}
|
||||
.status-abgelehnt {{ background-color: #f8d7da; color: #721c24; }}
|
||||
.status-storniert {{ background-color: #e2e3e5; color: #383d41; }}
|
||||
|
||||
.footer {{
|
||||
margin-top: 30px;
|
||||
padding-top: 15px;
|
||||
border-top: 1px solid #ddd;
|
||||
font-size: 8pt;
|
||||
color: #666;
|
||||
text-align: center;
|
||||
}}
|
||||
|
||||
.stats-grid {{
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 15px;
|
||||
margin: 15px 0;
|
||||
}}
|
||||
|
||||
.stat-card {{
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
background: #f8f9fa;
|
||||
}}
|
||||
|
||||
.stat-value {{
|
||||
font-size: 14pt;
|
||||
font-weight: bold;
|
||||
color: {primary_color};
|
||||
}}
|
||||
|
||||
.stat-label {{
|
||||
font-size: 8pt;
|
||||
color: #666;
|
||||
margin-top: 3px;
|
||||
}}
|
||||
|
||||
.section {{
|
||||
margin-bottom: 20px;
|
||||
page-break-inside: avoid;
|
||||
}}
|
||||
|
||||
.no-page-break {{
|
||||
page-break-inside: avoid;
|
||||
}}
|
||||
|
||||
.page-break-before {{
|
||||
page-break-before: always;
|
||||
}}
|
||||
"""
|
||||
|
||||
def generate_pdf_response(self, html_content, filename, css_content=None):
|
||||
"""Generate PDF response from HTML content"""
|
||||
if not WEASYPRINT_AVAILABLE:
|
||||
# Return HTML fallback if WeasyPrint is not available
|
||||
error_html = f"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>PDF Export - {filename}</title>
|
||||
<style>
|
||||
body {{ font-family: Arial, sans-serif; padding: 20px; max-width: 1200px; margin: 0 auto; }}
|
||||
.warning {{ background: #fff3cd; color: #856404; padding: 15px; border: 1px solid #ffeaa7; border-radius: 5px; margin-bottom: 20px; }}
|
||||
.content {{ border: 1px solid #ddd; padding: 20px; border-radius: 5px; }}
|
||||
{css_content or ''}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="warning">
|
||||
<h2>⚠️ PDF Generation Not Available</h2>
|
||||
<p><strong>WeasyPrint dependencies are missing:</strong> {IMPORT_ERROR}</p>
|
||||
<p>Showing content as HTML preview instead. You can print this page to PDF using your browser.</p>
|
||||
</div>
|
||||
<div class="content">
|
||||
{html_content}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
response = HttpResponse(error_html, content_type='text/html')
|
||||
response['Content-Disposition'] = f'inline; filename="{filename.replace(".pdf", "_preview.html")}"'
|
||||
return response
|
||||
|
||||
try:
|
||||
# Create CSS string
|
||||
if css_content:
|
||||
css = CSS(string=css_content, font_config=self.font_config)
|
||||
else:
|
||||
css = None
|
||||
|
||||
# Generate PDF
|
||||
html_doc = HTML(string=html_content)
|
||||
pdf_bytes = html_doc.write_pdf(stylesheets=[css] if css else None,
|
||||
font_config=self.font_config)
|
||||
|
||||
# Create response
|
||||
response = HttpResponse(pdf_bytes, content_type='application/pdf')
|
||||
response['Content-Disposition'] = f'attachment; filename="{filename}"'
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
# Fallback: return error message as HTML
|
||||
error_html = f"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>PDF Generation Error</title>
|
||||
<style>
|
||||
body {{ font-family: Arial, sans-serif; padding: 20px; }}
|
||||
.error {{ color: #d32f2f; border: 1px solid #d32f2f; padding: 15px; border-radius: 5px; margin-bottom: 20px; }}
|
||||
.content {{ border: 1px solid #ddd; padding: 20px; border-radius: 5px; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>PDF Generation Error</h1>
|
||||
<div class="error">
|
||||
<p>An error occurred while generating the PDF:</p>
|
||||
<p><strong>{str(e)}</strong></p>
|
||||
<p>Showing content as HTML preview instead.</p>
|
||||
</div>
|
||||
<div class="content">
|
||||
<h2>Original Content</h2>
|
||||
{html_content}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
response = HttpResponse(error_html, content_type='text/html')
|
||||
response['Content-Disposition'] = f'inline; filename="error_{filename.replace(".pdf", ".html")}"'
|
||||
return response
|
||||
|
||||
def export_data_list_pdf(self, data, fields_config, title, filename_prefix, request_user=None):
|
||||
"""
|
||||
Export a list of data as formatted PDF
|
||||
|
||||
Args:
|
||||
data: QuerySet or list of model instances
|
||||
fields_config: dict with field names as keys and display names as values
|
||||
title: Document title
|
||||
filename_prefix: Prefix for the generated filename
|
||||
request_user: User making the request (for audit purposes)
|
||||
"""
|
||||
corporate_settings = self.get_corporate_settings()
|
||||
logo_base64 = self.get_logo_base64(corporate_settings.get('logo_path', ''))
|
||||
|
||||
# Prepare context
|
||||
context = {
|
||||
'corporate_settings': corporate_settings,
|
||||
'logo_base64': logo_base64,
|
||||
'title': title,
|
||||
'data': data,
|
||||
'fields_config': fields_config,
|
||||
'generation_date': timezone.now(),
|
||||
'generated_by': (request_user.get_full_name()
|
||||
if hasattr(request_user, 'get_full_name') and request_user.get_full_name()
|
||||
else request_user.username
|
||||
if hasattr(request_user, 'username') and request_user.username
|
||||
else 'System'),
|
||||
'total_count': len(data) if hasattr(data, '__len__') else data.count(),
|
||||
}
|
||||
|
||||
# Render HTML
|
||||
html_content = render_to_string('pdf/data_list.html', context)
|
||||
|
||||
# Generate CSS
|
||||
css_content = self.get_base_css(corporate_settings)
|
||||
|
||||
# Generate filename
|
||||
timestamp = timezone.now().strftime('%Y%m%d_%H%M%S')
|
||||
filename = f"{filename_prefix}_{timestamp}.pdf"
|
||||
|
||||
return self.generate_pdf_response(html_content, filename, css_content)
|
||||
|
||||
|
||||
# Global instance
|
||||
pdf_generator = PDFGenerator()
|
||||
Reference in New Issue
Block a user