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>
This commit is contained in:
299
smoothschedule/contracts/pdf_service.py
Normal file
299
smoothschedule/contracts/pdf_service.py
Normal file
@@ -0,0 +1,299 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user