Files
stiftung-management-system/app/stiftung/utils/pdf_generator.py
Stiftung Development 35ba089a84 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
2025-09-06 18:47:23 +02:00

421 lines
14 KiB
Python

"""
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()