505 lines
17 KiB
Python
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)
|