- 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>
300 lines
10 KiB
Python
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
|