agdar/core/pdf_service.py
Marwan Alwali 25c9701c34 update
2025-11-06 18:18:43 +03:00

505 lines
17 KiB
Python

"""
Reusable PDF Generation Service for Clinical Documents
This module provides a base PDF generator that can be extended by all clinical modules
to create consistent, professional PDFs with bilingual support (English/Arabic).
Features:
- Tenant branding (logo + name)
- Arabic font support
- Bilingual labels and content
- Consistent styling
- Email functionality
"""
from io import BytesIO
import os
from typing import List, Dict, Any, Optional, Tuple
from django.conf import settings
from django.http import HttpResponse
from django.core.mail import EmailMessage
from django.utils import timezone
from django.utils.translation import gettext as _
from reportlab.lib.pagesizes import A4
from reportlab.lib.units import inch
from reportlab.lib import colors
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.enums import TA_CENTER, TA_RIGHT, TA_LEFT
from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer, Image
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
import arabic_reshaper
from bidi.algorithm import get_display
class BasePDFGenerator:
"""
Base class for generating PDFs with consistent styling and bilingual support.
All clinical document PDF generators should extend this class and implement
the abstract methods to customize content.
"""
# Class-level flag for Arabic font availability
ARABIC_FONT_AVAILABLE = False
ARABIC_FONT_REGISTERED = False
def __init__(self, document, request=None):
"""
Initialize PDF generator.
Args:
document: The document object (appointment, consultation, etc.)
request: Optional HTTP request object for user context
"""
self.document = document
self.request = request
self.buffer = BytesIO()
self.elements = []
self.styles = getSampleStyleSheet()
# Register Arabic font if not already done
if not BasePDFGenerator.ARABIC_FONT_REGISTERED:
self._register_arabic_font()
@classmethod
def _register_arabic_font(cls):
"""Register Arabic font for PDF generation."""
try:
pdfmetrics.registerFont(TTFont('Arabic', '/System/Library/Fonts/SFArabic.ttf'))
cls.ARABIC_FONT_AVAILABLE = True
cls.ARABIC_FONT_REGISTERED = True
except Exception:
cls.ARABIC_FONT_AVAILABLE = False
cls.ARABIC_FONT_REGISTERED = True
@staticmethod
def format_arabic(text: str) -> str:
"""
Format Arabic text for proper display in PDF.
Args:
text: Text containing Arabic characters
Returns:
str: Reshaped text ready for PDF rendering
"""
if not text:
return ""
try:
reshaped_text = arabic_reshaper.reshape(text)
return get_display(reshaped_text)
except Exception:
return text
def create_custom_styles(self):
"""Create custom paragraph styles for the PDF."""
self.title_style = ParagraphStyle(
'CustomTitle',
parent=self.styles['Heading1'],
fontSize=18,
textColor=colors.HexColor('#0d6efd'),
spaceAfter=20,
alignment=TA_CENTER
)
self.heading_style = ParagraphStyle(
'CustomHeading',
parent=self.styles['Heading2'],
fontSize=14,
textColor=colors.HexColor('#212529'),
spaceAfter=12,
spaceBefore=12
)
self.cell_style = ParagraphStyle(
'Cell',
parent=self.styles['Normal'],
fontSize=10
)
self.label_style = ParagraphStyle(
'Label',
parent=self.styles['Normal'],
fontSize=10,
fontName='Helvetica-Bold'
)
def add_header(self, tenant):
"""
Add header with tenant logo and name.
Args:
tenant: Tenant object
"""
# Try to load logo
logo = self._get_tenant_logo(tenant)
if logo:
# Header with logo
tenant_info_html = f'<b>{tenant.name}</b><br/>'
if tenant.name_ar and self.ARABIC_FONT_AVAILABLE:
tenant_info_html += f'<font name="Arabic" size=11>{self.format_arabic(tenant.name_ar)}</font><br/>'
header_data = [[logo, Paragraph(tenant_info_html, ParagraphStyle(
'TenantInfo',
parent=self.styles['Normal'],
fontSize=12,
alignment=TA_CENTER
))]]
header_table = Table(header_data, colWidths=[2*inch, 4*inch])
header_table.setStyle(TableStyle([
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
('ALIGN', (0, 0), (0, 0), 'LEFT'),
('ALIGN', (1, 0), (1, 0), 'CENTER'),
]))
self.elements.append(header_table)
else:
# Header without logo
tenant_name_html = f'<b>{tenant.name}</b><br/>'
if tenant.name_ar and self.ARABIC_FONT_AVAILABLE:
tenant_name_html += f'<font name="Arabic" size=14>{self.format_arabic(tenant.name_ar)}</font>'
tenant_name = Paragraph(
tenant_name_html,
ParagraphStyle('TenantName', parent=self.styles['Heading1'], fontSize=16, alignment=TA_CENTER)
)
self.elements.append(tenant_name)
self.elements.append(Spacer(1, 0.15*inch))
def _get_tenant_logo(self, tenant) -> Optional[Image]:
"""
Get tenant logo as ReportLab Image object.
Args:
tenant: Tenant object
Returns:
Image object or None
"""
try:
from core.models import TenantSetting
logo_setting = TenantSetting.objects.filter(
tenant=tenant,
template__key='basic_logo'
).first()
if logo_setting and logo_setting.file_value:
logo_path = os.path.join(settings.MEDIA_ROOT, str(logo_setting.file_value))
if os.path.exists(logo_path):
return Image(logo_path, width=0.8*inch, height=0.8*inch)
except Exception:
pass
return None
def add_title(self, title_en: str, title_ar: str = ""):
"""
Add bilingual title to PDF.
Args:
title_en: Title in English
title_ar: Title in Arabic (optional)
"""
title_html = f"{title_en}<br/>"
if title_ar and self.ARABIC_FONT_AVAILABLE:
title_html += f'<font name="Arabic" size=16>{self.format_arabic(title_ar)}</font>'
title = Paragraph(title_html, self.title_style)
self.elements.append(title)
self.elements.append(Spacer(1, 0.15*inch))
def add_section_heading(self, heading_en: str, heading_ar: str = ""):
"""
Add bilingual section heading.
Args:
heading_en: Heading in English
heading_ar: Heading in Arabic (optional)
"""
heading_html = f"{heading_en}"
if heading_ar and self.ARABIC_FONT_AVAILABLE:
heading_html += f' / <font name="Arabic" size=12>{self.format_arabic(heading_ar)}</font>'
self.elements.append(Paragraph(heading_html, self.heading_style))
def create_bilingual_table(self, data: List[Tuple[str, str, str, str]], col_widths: List[float] = None):
"""
Create a table with bilingual labels.
Args:
data: List of tuples (label_en, label_ar, value, value_ar)
col_widths: Column widths in inches
Returns:
Table object
"""
if col_widths is None:
col_widths = [2.5*inch, 3.5*inch]
table_data = []
for label_en, label_ar, value, value_ar in data:
# Create label with bilingual support
label_html = label_en
if label_ar and self.ARABIC_FONT_AVAILABLE:
label_html += f' / <font name="Arabic" size=9>{self.format_arabic(label_ar)}</font>'
# Create value with bilingual support
value_html = str(value)
if value_ar and self.ARABIC_FONT_AVAILABLE:
value_html += f' / <font name="Arabic" size=9>{self.format_arabic(value_ar)}</font>'
table_data.append([
Paragraph(label_html + ':', self.label_style),
Paragraph(value_html, self.cell_style)
])
table = Table(table_data, colWidths=col_widths)
table.setStyle(TableStyle([
('BACKGROUND', (0, 0), (0, -1), colors.HexColor('#f8f9fa')),
('TEXTCOLOR', (0, 0), (-1, -1), colors.black),
('ALIGN', (0, 0), (0, -1), 'RIGHT'),
('ALIGN', (1, 0), (1, -1), 'LEFT'),
('FONTNAME', (0, 0), (0, -1), 'Helvetica-Bold'),
('FONTNAME', (1, 0), (1, -1), 'Helvetica'),
('FONTSIZE', (0, 0), (-1, -1), 10),
('GRID', (0, 0), (-1, -1), 0.5, colors.grey),
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
('LEFTPADDING', (0, 0), (-1, -1), 8),
('RIGHTPADDING', (0, 0), (-1, -1), 8),
('TOPPADDING', (0, 0), (-1, -1), 6),
('BOTTOMPADDING', (0, 0), (-1, -1), 6),
]))
return table
def add_footer(self):
"""Add footer with generation timestamp."""
self.elements.append(Spacer(1, 0.5*inch))
footer_text = f"Generated on: {timezone.now().strftime('%Y-%m-%d %H:%M:%S')}"
footer = Paragraph(footer_text, ParagraphStyle(
'Footer',
parent=self.styles['Normal'],
fontSize=8,
textColor=colors.grey,
alignment=TA_CENTER
))
self.elements.append(footer)
# Abstract methods to be implemented by subclasses
def get_document_title(self) -> Tuple[str, str]:
"""
Get document title in English and Arabic.
Returns:
Tuple of (title_en, title_ar)
"""
raise NotImplementedError("Subclasses must implement get_document_title()")
def get_document_sections(self) -> List[Dict[str, Any]]:
"""
Get document sections to be rendered.
Returns:
List of section dictionaries with keys:
- heading_en: Section heading in English
- heading_ar: Section heading in Arabic
- content: Section content (table data or paragraphs)
- type: 'table' or 'text'
"""
raise NotImplementedError("Subclasses must implement get_document_sections()")
def generate_pdf(self, view_mode: str = 'download') -> HttpResponse:
"""
Generate PDF and return as HTTP response.
Args:
view_mode: 'inline' for browser viewing, 'download' for download
Returns:
HttpResponse with PDF content
"""
# Create PDF document
doc = SimpleDocTemplate(
self.buffer,
pagesize=A4,
rightMargin=0.75*inch,
leftMargin=0.75*inch,
topMargin=1.5*inch,
bottomMargin=0.75*inch
)
# Create custom styles
self.create_custom_styles()
# Add header
tenant = getattr(self.document, 'tenant', None)
if tenant:
self.add_header(tenant)
# Add title
title_en, title_ar = self.get_document_title()
self.add_title(title_en, title_ar)
# Add sections
sections = self.get_document_sections()
for section in sections:
# Add section heading
self.add_section_heading(
section.get('heading_en', ''),
section.get('heading_ar', '')
)
# Add section content
if section.get('type') == 'table':
table = self.create_bilingual_table(
section.get('content', []),
section.get('col_widths')
)
self.elements.append(table)
elif section.get('type') == 'text':
for text in section.get('content', []):
para = Paragraph(text, self.cell_style)
self.elements.append(para)
self.elements.append(Spacer(1, 0.1*inch))
self.elements.append(Spacer(1, 0.3*inch))
# Add footer
self.add_footer()
# Build PDF
doc.build(self.elements)
# Get PDF content
pdf_content = self.buffer.getvalue()
self.buffer.close()
# Create HTTP response
response = HttpResponse(content_type='application/pdf')
# Set content disposition based on view mode
filename = self.get_pdf_filename()
if view_mode == 'inline':
response['Content-Disposition'] = f'inline; filename="{filename}"'
else:
response['Content-Disposition'] = f'attachment; filename="{filename}"'
response.write(pdf_content)
return response
def get_pdf_filename(self) -> str:
"""
Get PDF filename.
Returns:
str: Filename for the PDF
"""
# Default implementation - subclasses should override
return f"document_{timezone.now().strftime('%Y%m%d_%H%M%S')}.pdf"
def send_email(self, email_address: str, subject: str, body: str, custom_message: str = "") -> Tuple[bool, str]:
"""
Send PDF via email.
Args:
email_address: Recipient email address
subject: Email subject
body: Email body
custom_message: Optional custom message to append
Returns:
Tuple of (success: bool, message: str)
"""
try:
# Generate PDF content
self.buffer = BytesIO()
self.elements = []
# Create PDF document
doc = SimpleDocTemplate(
self.buffer,
pagesize=A4,
rightMargin=0.75*inch,
leftMargin=0.75*inch,
topMargin=1.5*inch,
bottomMargin=0.75*inch
)
# Create custom styles
self.create_custom_styles()
# Add header
tenant = getattr(self.document, 'tenant', None)
if tenant:
self.add_header(tenant)
# Add title
title_en, title_ar = self.get_document_title()
self.add_title(title_en, title_ar)
# Add sections
sections = self.get_document_sections()
for section in sections:
self.add_section_heading(
section.get('heading_en', ''),
section.get('heading_ar', '')
)
if section.get('type') == 'table':
table = self.create_bilingual_table(
section.get('content', []),
section.get('col_widths')
)
self.elements.append(table)
elif section.get('type') == 'text':
for text in section.get('content', []):
para = Paragraph(text, self.cell_style)
self.elements.append(para)
self.elements.append(Spacer(1, 0.1*inch))
self.elements.append(Spacer(1, 0.3*inch))
# Add footer
self.add_footer()
# Build PDF
doc.build(self.elements)
# Get PDF content
pdf_content = self.buffer.getvalue()
self.buffer.close()
# Append custom message if provided
if custom_message:
body += f"\n\n{custom_message}\n\n"
# Create email
email = EmailMessage(
subject=subject,
body=body,
from_email=None, # Uses DEFAULT_FROM_EMAIL from settings
to=[email_address],
)
# Attach PDF
email.attach(
self.get_pdf_filename(),
pdf_content,
'application/pdf'
)
# Send email
email.send(fail_silently=False)
return True, _('Email sent successfully!')
except Exception as e:
return False, str(e)