Files
smoothschedule/smoothschedule/contracts/pdf_service.py
poduck 8d0cc1e90a feat(time-blocks): Add comprehensive time blocking system with contracts
- Add TimeBlock and Holiday models with recurrence support (one-time, weekly, monthly, yearly, holiday)
- Implement business-level and resource-level blocking with hard/soft block types
- Add multi-select holiday picker for bulk holiday blocking
- Add calendar overlay visualization with distinct colors:
  - Business blocks: Red (hard) / Yellow (soft)
  - Resource blocks: Purple (hard) / Cyan (soft)
- Add month view resource indicators showing 1/n width per resource
- Add yearly calendar view for block overview
- Add My Availability page for staff self-service
- Add contracts module with templates, signing flow, and PDF generation
- Update scheduler with click-to-day navigation in week view

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 17:19:12 -05:00

300 lines
10 KiB
Python

"""
PDF generation service for contract documents using WeasyPrint.
Generates legally compliant contract PDFs with audit trails.
NOTE: WeasyPrint requires system dependencies (Pango, GObject, Cairo).
To enable PDF generation, add these to the Docker image:
apt-get install -y libpango-1.0-0 libpangoft2-1.0-0 libgobject-2.0-0 libcairo2
pip install weasyprint
"""
import logging
from io import BytesIO
from django.template.loader import render_to_string
from django.conf import settings
logger = logging.getLogger(__name__)
try:
from weasyprint import HTML, CSS
from weasyprint.text.fonts import FontConfiguration
WEASYPRINT_AVAILABLE = True
except (ImportError, OSError) as e:
logger.warning(f"WeasyPrint not available: {e}")
WEASYPRINT_AVAILABLE = False
HTML = None
CSS = None
FontConfiguration = None
class ContractPDFService:
"""
Service for generating contract PDFs with audit trail and legal compliance.
"""
@staticmethod
def generate_pdf(contract):
"""
Generate a PDF from a signed contract.
Args:
contract: Contract instance (must have signature)
Returns:
BytesIO: PDF file as bytes
Raises:
ValueError: If contract is not signed or signature data is missing
RuntimeError: If WeasyPrint is not available
"""
if not WEASYPRINT_AVAILABLE:
raise RuntimeError(
"WeasyPrint is not available. Please install system dependencies: "
"libpango-1.0-0 libpangoft2-1.0-0 libgobject-2.0-0 libcairo2, "
"then pip install weasyprint"
)
if contract.status != 'SIGNED':
raise ValueError("Contract must be signed before generating PDF")
if not hasattr(contract, 'signature') or not contract.signature:
raise ValueError("Contract signature data is missing")
signature = contract.signature
# Get tenant/business info
from django.db import connection
from core.models import Tenant
tenant = None
if hasattr(connection, 'tenant'):
tenant = connection.tenant
else:
# Fallback - try to get tenant from schema
try:
tenant = Tenant.objects.get(schema_name=connection.schema_name)
except Tenant.DoesNotExist:
logger.warning(f"Could not find tenant for schema: {connection.schema_name}")
# Prepare context for template
context = {
'contract': contract,
'signature': signature,
'tenant': tenant,
'business_name': tenant.name if tenant else 'SmoothSchedule',
'business_logo_url': tenant.logo.url if tenant and tenant.logo else None,
'customer': contract.customer,
'event': contract.event,
# Geolocation display
'geolocation': None,
# Legal compliance text
'esign_notice': (
"This document is a legally binding contract executed under the "
"U.S. Electronic Signatures in Global and National Commerce Act (ESIGN Act) "
"and the Uniform Electronic Transactions Act (UETA). "
"By electronically signing this document, all parties agree that such signature "
"is the legal equivalent of their manual signature."
),
}
# Format geolocation if available
if signature.latitude and signature.longitude:
context['geolocation'] = f"{signature.latitude}, {signature.longitude}"
# Render HTML from template
html_string = render_to_string('contracts/pdf_template.html', context)
# Configure fonts
font_config = FontConfiguration()
# Generate PDF
html = HTML(string=html_string, base_url=settings.STATIC_URL or '/')
# Custom CSS for better PDF rendering
css_string = """
@page {
size: letter;
margin: 1in 0.75in;
@bottom-right {
content: "Page " counter(page) " of " counter(pages);
font-size: 9pt;
color: #666;
}
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
line-height: 1.6;
color: #1f2937;
}
table {
page-break-inside: avoid;
}
"""
css = CSS(string=css_string, font_config=font_config)
# Render to PDF
pdf_bytes = BytesIO()
html.write_pdf(pdf_bytes, stylesheets=[css], font_config=font_config)
pdf_bytes.seek(0)
logger.info(f"Generated PDF for contract {contract.id} ({contract.title})")
return pdf_bytes
@staticmethod
def save_contract_pdf(contract, storage_path=None):
"""
Generate and save contract PDF to storage.
Args:
contract: Contract instance
storage_path: Optional custom storage path
Returns:
str: Path where PDF was saved
Raises:
ValueError: If contract validation fails
RuntimeError: If WeasyPrint is not available
"""
from django.core.files.base import ContentFile
from django.core.files.storage import default_storage
import os
# Generate PDF
pdf_bytes = ContractPDFService.generate_pdf(contract)
# Determine storage path
if not storage_path:
# Use contract signing token as filename (unique)
filename = f"contract_{contract.signing_token}.pdf"
storage_path = os.path.join('contracts', str(contract.customer.id), filename)
# Save to storage
saved_path = default_storage.save(storage_path, ContentFile(pdf_bytes.read()))
# Update contract model
contract.pdf_path = saved_path
contract.save(update_fields=['pdf_path'])
logger.info(f"Saved contract PDF to: {saved_path}")
return saved_path
@staticmethod
def is_available():
"""Check if PDF generation is available."""
return WEASYPRINT_AVAILABLE
@staticmethod
def generate_template_preview(template, user=None):
"""
Generate a PDF preview from a contract template with sample data.
Args:
template: ContractTemplate instance
user: Optional user requesting the preview
Returns:
BytesIO: PDF file as bytes
Raises:
RuntimeError: If WeasyPrint is not available
"""
if not WEASYPRINT_AVAILABLE:
raise RuntimeError(
"WeasyPrint is not available. Please install system dependencies."
)
from django.db import connection
from django.utils import timezone
from core.models import Tenant
# Get tenant info
tenant = None
try:
tenant = Tenant.objects.get(schema_name=connection.schema_name)
except Tenant.DoesNotExist:
logger.warning(f"Could not find tenant for schema: {connection.schema_name}")
# Sample data for variable substitution
sample_context = {
"CUSTOMER_NAME": "John Smith",
"CUSTOMER_FIRST_NAME": "John",
"CUSTOMER_LAST_NAME": "Smith",
"CUSTOMER_EMAIL": "john.smith@example.com",
"CUSTOMER_PHONE": "(555) 123-4567",
"BUSINESS_NAME": tenant.name if tenant else "Your Business",
"BUSINESS_EMAIL": tenant.contact_email if tenant else "contact@example.com",
"BUSINESS_PHONE": tenant.phone if tenant else "(555) 000-0000",
"DATE": timezone.now().strftime("%B %d, %Y"),
"YEAR": timezone.now().strftime("%Y"),
"APPOINTMENT_DATE": timezone.now().strftime("%B %d, %Y"),
"APPOINTMENT_TIME": "10:00 AM",
"SERVICE_NAME": "Sample Service",
}
# Substitute variables in content
content = template.content
for key, value in sample_context.items():
content = content.replace(f"{{{{{key}}}}}", str(value or ""))
# Prepare context for template
context = {
'template': template,
'content_html': content,
'tenant': tenant,
'business_name': tenant.name if tenant else 'SmoothSchedule',
'business_logo_url': tenant.logo.url if tenant and tenant.logo else None,
'is_preview': True,
'preview_notice': 'PREVIEW - This is a sample preview with placeholder data',
}
# Render HTML from template
html_string = render_to_string('contracts/pdf_preview_template.html', context)
# Configure fonts
font_config = FontConfiguration()
# Generate PDF
html = HTML(string=html_string, base_url=settings.STATIC_URL or '/')
# Custom CSS for PDF rendering
css_string = """
@page {
size: letter;
margin: 1in 0.75in;
@bottom-right {
content: "Page " counter(page) " of " counter(pages);
font-size: 9pt;
color: #666;
}
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
line-height: 1.6;
color: #1f2937;
}
.preview-banner {
background: #fef3c7;
border: 2px solid #f59e0b;
color: #92400e;
padding: 12px;
text-align: center;
font-weight: bold;
margin-bottom: 24px;
border-radius: 4px;
}
"""
css = CSS(string=css_string, font_config=font_config)
# Render to PDF
pdf_bytes = BytesIO()
html.write_pdf(pdf_bytes, stylesheets=[css], font_config=font_config)
pdf_bytes.seek(0)
logger.info(f"Generated preview PDF for template {template.id} ({template.name})")
return pdf_bytes