Files
stiftung-management-system/app/stiftung/utils/pdf_generator.py
Stiftung Development 6256371e8f Adjust CI code quality settings for legacy codebase
- Update flake8 configuration to be more lenient with legacy code issues
- Increase max-line-length to 120 characters for better compatibility
- Ignore common legacy code patterns (unused imports, variables, etc.)
- Maintain critical error checking while allowing gradual improvement
- Fix whitespace issues in PDF generator
- Enable successful CI pipeline completion for existing codebase
2025-09-06 21:05:30 +02:00

448 lines
14 KiB
Python

"""
PDF generation utilities with corporate identity support
"""
import base64
import os
from io import BytesIO
from django.conf import settings
from django.http import HttpResponse
from django.template.loader import render_to_string
from django.utils import timezone
# Try to import WeasyPrint, fall back gracefully if not available
try:
from weasyprint import CSS, HTML
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()