570 lines
16 KiB
Python
570 lines
16 KiB
Python
"""
|
|
PDF Generation for Invoices and Estimates using WeasyPrint.
|
|
|
|
This module provides functions to generate professional PDF documents
|
|
for invoices and estimates with customizable templates.
|
|
"""
|
|
|
|
import io
|
|
import os
|
|
from decimal import Decimal
|
|
|
|
from django.conf import settings
|
|
from django.template.loader import render_to_string
|
|
|
|
try:
|
|
from weasyprint import CSS, HTML
|
|
from weasyprint.text.fonts import FontConfiguration
|
|
|
|
WEASYPRINT_AVAILABLE = True
|
|
except ImportError:
|
|
WEASYPRINT_AVAILABLE = False
|
|
|
|
|
|
def check_weasyprint():
|
|
"""Check if WeasyPrint is available."""
|
|
if not WEASYPRINT_AVAILABLE:
|
|
raise ImportError(
|
|
"WeasyPrint is not installed. Install it with: pip install weasyprint"
|
|
)
|
|
|
|
|
|
def get_default_css():
|
|
"""Get the default CSS for PDF generation."""
|
|
css_path = os.path.join(
|
|
os.path.dirname(__file__), "templates", "invoices", "pdf", "invoice.css"
|
|
)
|
|
if os.path.exists(css_path):
|
|
with open(css_path, "r") as f:
|
|
return f.read()
|
|
return get_fallback_css()
|
|
|
|
|
|
def get_fallback_css():
|
|
"""Fallback CSS if the template file doesn't exist."""
|
|
return """
|
|
@page {
|
|
size: A4;
|
|
margin: 1.5cm;
|
|
}
|
|
|
|
* {
|
|
box-sizing: border-box;
|
|
margin: 0;
|
|
padding: 0;
|
|
}
|
|
|
|
body {
|
|
font-family: 'Helvetica', 'Arial', sans-serif;
|
|
font-size: 10pt;
|
|
line-height: 1.4;
|
|
color: #333;
|
|
}
|
|
|
|
.invoice-container {
|
|
width: 100%;
|
|
max-width: 800px;
|
|
margin: 0 auto;
|
|
}
|
|
|
|
/* Header */
|
|
.header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: flex-start;
|
|
margin-bottom: 30px;
|
|
padding-bottom: 20px;
|
|
border-bottom: 2px solid #3B82F6;
|
|
}
|
|
|
|
.logo {
|
|
max-height: 60px;
|
|
max-width: 200px;
|
|
}
|
|
|
|
.invoice-title {
|
|
text-align: right;
|
|
}
|
|
|
|
.invoice-title h1 {
|
|
font-size: 28pt;
|
|
color: #3B82F6;
|
|
margin-bottom: 5px;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.invoice-number {
|
|
font-size: 12pt;
|
|
color: #666;
|
|
}
|
|
|
|
/* Addresses */
|
|
.addresses {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
margin-bottom: 30px;
|
|
}
|
|
|
|
.address-block {
|
|
width: 45%;
|
|
}
|
|
|
|
.address-block h3 {
|
|
font-size: 9pt;
|
|
color: #666;
|
|
text-transform: uppercase;
|
|
margin-bottom: 8px;
|
|
letter-spacing: 0.5px;
|
|
}
|
|
|
|
.address-block p {
|
|
margin-bottom: 3px;
|
|
}
|
|
|
|
.company-name {
|
|
font-weight: bold;
|
|
font-size: 11pt;
|
|
color: #333;
|
|
}
|
|
|
|
/* Invoice Details */
|
|
.invoice-details {
|
|
margin-bottom: 30px;
|
|
padding: 15px;
|
|
background-color: #f8fafc;
|
|
border-radius: 4px;
|
|
}
|
|
|
|
.details-row {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.details-row:last-child {
|
|
margin-bottom: 0;
|
|
}
|
|
|
|
.details-label {
|
|
font-weight: bold;
|
|
color: #666;
|
|
}
|
|
|
|
.details-value {
|
|
text-align: right;
|
|
}
|
|
|
|
.status-badge {
|
|
display: inline-block;
|
|
padding: 3px 10px;
|
|
border-radius: 12px;
|
|
font-size: 9pt;
|
|
font-weight: bold;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.status-draft { background-color: #e5e7eb; color: #374151; }
|
|
.status-sent { background-color: #dbeafe; color: #1d4ed8; }
|
|
.status-paid { background-color: #d1fae5; color: #047857; }
|
|
.status-overdue { background-color: #fee2e2; color: #dc2626; }
|
|
.status-cancelled { background-color: #fef3c7; color: #d97706; }
|
|
|
|
/* Line Items Table */
|
|
.line-items {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
margin-bottom: 30px;
|
|
}
|
|
|
|
.line-items th {
|
|
background-color: #3B82F6;
|
|
color: white;
|
|
text-align: left;
|
|
padding: 12px 10px;
|
|
font-size: 9pt;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
}
|
|
|
|
.line-items th:last-child,
|
|
.line-items td:last-child {
|
|
text-align: right;
|
|
}
|
|
|
|
.line-items td {
|
|
padding: 12px 10px;
|
|
border-bottom: 1px solid #e5e7eb;
|
|
}
|
|
|
|
.line-items tr:nth-child(even) td {
|
|
background-color: #f9fafb;
|
|
}
|
|
|
|
.item-description {
|
|
max-width: 250px;
|
|
}
|
|
|
|
/* Totals */
|
|
.totals-section {
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
margin-bottom: 30px;
|
|
}
|
|
|
|
.totals-table {
|
|
width: 300px;
|
|
}
|
|
|
|
.totals-row {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
padding: 8px 0;
|
|
border-bottom: 1px solid #e5e7eb;
|
|
}
|
|
|
|
.totals-row.grand-total {
|
|
border-top: 2px solid #333;
|
|
border-bottom: none;
|
|
font-size: 14pt;
|
|
font-weight: bold;
|
|
color: #3B82F6;
|
|
padding-top: 12px;
|
|
}
|
|
|
|
.totals-label {
|
|
color: #666;
|
|
}
|
|
|
|
.totals-value {
|
|
text-align: right;
|
|
font-weight: 500;
|
|
}
|
|
|
|
/* Notes and Terms */
|
|
.notes-section {
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.notes-section h3 {
|
|
font-size: 10pt;
|
|
color: #333;
|
|
margin-bottom: 8px;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.notes-section p {
|
|
color: #666;
|
|
font-size: 9pt;
|
|
line-height: 1.5;
|
|
}
|
|
|
|
/* Footer */
|
|
.footer {
|
|
margin-top: 40px;
|
|
padding-top: 20px;
|
|
border-top: 1px solid #e5e7eb;
|
|
text-align: center;
|
|
font-size: 9pt;
|
|
color: #999;
|
|
}
|
|
|
|
/* Payment Info */
|
|
.payment-info {
|
|
background-color: #f0f9ff;
|
|
padding: 15px;
|
|
border-radius: 4px;
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.payment-info h3 {
|
|
color: #0369a1;
|
|
margin-bottom: 10px;
|
|
}
|
|
"""
|
|
|
|
|
|
def format_currency(amount, currency="USD"):
|
|
"""Format amount with currency symbol."""
|
|
symbols = {
|
|
"USD": "$",
|
|
"EUR": "\u20ac",
|
|
"GBP": "\u00a3",
|
|
"INR": "\u20b9",
|
|
"JPY": "\u00a5",
|
|
"CAD": "C$",
|
|
"AUD": "A$",
|
|
"AED": "AED ",
|
|
"SAR": "SAR ",
|
|
"QAR": "QAR ",
|
|
"KWD": "KWD ",
|
|
"BHD": "BHD ",
|
|
"OMR": "OMR ",
|
|
"CHF": "CHF ",
|
|
"SGD": "S$",
|
|
"HKD": "HK$",
|
|
"NZD": "NZ$",
|
|
"ZAR": "R",
|
|
"MXN": "MX$",
|
|
"BRL": "R$",
|
|
"CNY": "\u00a5",
|
|
"KRW": "\u20a9",
|
|
"THB": "\u0e3f",
|
|
"MYR": "RM",
|
|
"PHP": "\u20b1",
|
|
"IDR": "Rp",
|
|
"VND": "\u20ab",
|
|
"SEK": "kr",
|
|
"NOK": "kr",
|
|
"DKK": "kr",
|
|
"PLN": "z\u0142",
|
|
"CZK": "K\u010d",
|
|
"HUF": "Ft",
|
|
"RUB": "\u20bd",
|
|
"TRY": "\u20ba",
|
|
"ILS": "\u20aa",
|
|
"EGP": "E\u00a3",
|
|
"NGN": "\u20a6",
|
|
"KES": "KSh",
|
|
}
|
|
symbol = symbols.get(currency, currency + " ")
|
|
if amount is None:
|
|
amount = Decimal("0.00")
|
|
return f"{symbol}{amount:,.2f}"
|
|
|
|
|
|
def generate_invoice_pdf(invoice, include_payments=True):
|
|
"""
|
|
Generate a PDF for an invoice.
|
|
|
|
Args:
|
|
invoice: Invoice model instance
|
|
include_payments: Whether to include payment history
|
|
|
|
Returns:
|
|
bytes: PDF file content
|
|
"""
|
|
check_weasyprint()
|
|
|
|
# Get the template - use invoice's template, or fall back to org's default template
|
|
template = invoice.template
|
|
if not template:
|
|
from invoices.models import InvoiceTemplate
|
|
|
|
template = InvoiceTemplate.objects.filter(
|
|
org=invoice.org, is_default=True
|
|
).first()
|
|
|
|
# Pre-format line items with currency
|
|
line_items = []
|
|
for item in invoice.line_items.all().order_by("order"):
|
|
# Use name field if present, otherwise fall back to description
|
|
line_items.append(
|
|
{
|
|
"name": item.name if item.name else item.description,
|
|
"description": item.description
|
|
if item.name
|
|
else "", # Show description as subtitle if name is set
|
|
"quantity": item.quantity,
|
|
"unit_price": format_currency(item.unit_price, invoice.currency),
|
|
"tax_rate": item.tax_rate,
|
|
"total": format_currency(item.total, invoice.currency),
|
|
}
|
|
)
|
|
|
|
# Pre-format payments
|
|
payments = []
|
|
if include_payments:
|
|
for payment in invoice.payments.all().order_by("-payment_date"):
|
|
payments.append(
|
|
{
|
|
"payment_date": payment.payment_date,
|
|
"payment_method": payment.payment_method,
|
|
"get_payment_method_display": payment.get_payment_method_display(),
|
|
"reference_number": payment.reference_number,
|
|
"amount": format_currency(payment.amount, invoice.currency),
|
|
}
|
|
)
|
|
|
|
# Prepare context with pre-formatted currency values
|
|
context = {
|
|
"invoice": invoice,
|
|
"line_items": line_items,
|
|
"payments": payments,
|
|
"org": invoice.org,
|
|
"status_class": f"status-{invoice.status.lower().replace('_', '-')}",
|
|
# Pre-formatted currency values
|
|
"subtotal": format_currency(invoice.subtotal, invoice.currency),
|
|
"discount_amount": format_currency(invoice.discount_amount, invoice.currency),
|
|
"tax_amount": format_currency(invoice.tax_amount, invoice.currency),
|
|
"shipping_amount": format_currency(invoice.shipping_amount, invoice.currency),
|
|
"total_amount": format_currency(invoice.total_amount, invoice.currency),
|
|
"amount_paid": format_currency(invoice.amount_paid, invoice.currency),
|
|
"amount_due": format_currency(invoice.amount_due, invoice.currency),
|
|
}
|
|
|
|
# Check for custom template HTML
|
|
if template and template.template_html:
|
|
html_content = template.template_html
|
|
# Replace placeholders with actual values
|
|
html_content = render_invoice_template(html_content, context)
|
|
else:
|
|
# Use default template
|
|
html_content = render_to_string("invoices/pdf/invoice.html", context)
|
|
|
|
# Get CSS
|
|
if template and template.template_css:
|
|
css_content = template.template_css
|
|
else:
|
|
css_content = get_default_css()
|
|
|
|
# Apply template colors if set
|
|
if template:
|
|
css_content = css_content.replace("#3B82F6", template.primary_color)
|
|
if template.secondary_color:
|
|
css_content = css_content.replace("#1E40AF", template.secondary_color)
|
|
|
|
# Generate PDF
|
|
font_config = FontConfiguration()
|
|
html = HTML(string=html_content, base_url=settings.BASE_DIR)
|
|
css = CSS(string=css_content, font_config=font_config)
|
|
|
|
pdf_buffer = io.BytesIO()
|
|
html.write_pdf(pdf_buffer, stylesheets=[css], font_config=font_config)
|
|
pdf_buffer.seek(0)
|
|
|
|
return pdf_buffer.getvalue()
|
|
|
|
|
|
def generate_estimate_pdf(estimate):
|
|
"""
|
|
Generate a PDF for an estimate.
|
|
|
|
Args:
|
|
estimate: Estimate model instance
|
|
|
|
Returns:
|
|
bytes: PDF file content
|
|
"""
|
|
check_weasyprint()
|
|
|
|
# Get the org's default template for styling
|
|
from invoices.models import InvoiceTemplate
|
|
|
|
template = InvoiceTemplate.objects.filter(org=estimate.org, is_default=True).first()
|
|
|
|
# Pre-format line items with currency
|
|
line_items = []
|
|
for item in estimate.line_items.all().order_by("order"):
|
|
# Use name field if present, otherwise fall back to description
|
|
line_items.append(
|
|
{
|
|
"name": item.name if item.name else item.description,
|
|
"description": item.description
|
|
if item.name
|
|
else "", # Show description as subtitle if name is set
|
|
"quantity": item.quantity,
|
|
"unit_price": format_currency(item.unit_price, estimate.currency),
|
|
"tax_rate": item.tax_rate,
|
|
"total": format_currency(item.total, estimate.currency),
|
|
}
|
|
)
|
|
|
|
# Prepare context with pre-formatted currency values
|
|
context = {
|
|
"estimate": estimate,
|
|
"line_items": line_items,
|
|
"org": estimate.org,
|
|
"status_class": f"status-{estimate.status.lower().replace('_', '-')}",
|
|
"is_expired": estimate.is_expired,
|
|
# Pre-formatted currency values
|
|
"subtotal": format_currency(estimate.subtotal, estimate.currency),
|
|
"discount_amount": format_currency(estimate.discount_amount, estimate.currency),
|
|
"tax_amount": format_currency(estimate.tax_amount, estimate.currency),
|
|
"total_amount": format_currency(estimate.total_amount, estimate.currency),
|
|
}
|
|
|
|
# Use default template
|
|
html_content = render_to_string("invoices/pdf/estimate.html", context)
|
|
css_content = get_default_css()
|
|
|
|
# Apply template colors if set
|
|
if template:
|
|
css_content = css_content.replace("#3B82F6", template.primary_color)
|
|
if template.secondary_color:
|
|
css_content = css_content.replace("#1E40AF", template.secondary_color)
|
|
|
|
# Generate PDF
|
|
font_config = FontConfiguration()
|
|
html = HTML(string=html_content, base_url=settings.BASE_DIR)
|
|
css = CSS(string=css_content, font_config=font_config)
|
|
|
|
pdf_buffer = io.BytesIO()
|
|
html.write_pdf(pdf_buffer, stylesheets=[css], font_config=font_config)
|
|
pdf_buffer.seek(0)
|
|
|
|
return pdf_buffer.getvalue()
|
|
|
|
|
|
def render_invoice_template(template_html, context):
|
|
"""
|
|
Render a custom invoice template with context variables.
|
|
|
|
Supports basic variable substitution like {{invoice.invoice_number}}.
|
|
"""
|
|
invoice = context.get("invoice")
|
|
org = context.get("org")
|
|
currency = invoice.currency if invoice else "USD"
|
|
|
|
# Basic variable substitutions
|
|
replacements = {
|
|
"{{invoice.invoice_number}}": invoice.invoice_number,
|
|
"{{invoice.invoice_title}}": invoice.invoice_title,
|
|
"{{invoice.status}}": invoice.status,
|
|
"{{invoice.client_name}}": invoice.client_name,
|
|
"{{invoice.client_email}}": invoice.client_email,
|
|
"{{invoice.client_phone}}": invoice.client_phone or "",
|
|
"{{invoice.issue_date}}": str(invoice.issue_date) if invoice.issue_date else "",
|
|
"{{invoice.due_date}}": str(invoice.due_date) if invoice.due_date else "",
|
|
"{{invoice.subtotal}}": format_currency(invoice.subtotal, currency),
|
|
"{{invoice.discount_amount}}": format_currency(
|
|
invoice.discount_amount, currency
|
|
),
|
|
"{{invoice.tax_amount}}": format_currency(invoice.tax_amount, currency),
|
|
"{{invoice.total_amount}}": format_currency(invoice.total_amount, currency),
|
|
"{{invoice.amount_paid}}": format_currency(invoice.amount_paid, currency),
|
|
"{{invoice.amount_due}}": format_currency(invoice.amount_due, currency),
|
|
"{{invoice.notes}}": invoice.notes or "",
|
|
"{{invoice.terms}}": invoice.terms or "",
|
|
"{{org.name}}": org.name if org else "",
|
|
}
|
|
|
|
# Address fields
|
|
address_fields = [
|
|
"billing_address_line",
|
|
"billing_city",
|
|
"billing_state",
|
|
"billing_postcode",
|
|
"billing_country",
|
|
"client_address_line",
|
|
"client_city",
|
|
"client_state",
|
|
"client_postcode",
|
|
"client_country",
|
|
]
|
|
for field in address_fields:
|
|
replacements[f"{{{{invoice.{field}}}}}"] = getattr(invoice, field, "") or ""
|
|
|
|
for placeholder, value in replacements.items():
|
|
template_html = template_html.replace(placeholder, str(value))
|
|
|
|
return template_html
|
|
|
|
|
|
def generate_invoice_filename(invoice):
|
|
"""Generate a filename for the invoice PDF."""
|
|
return f"Invoice_{invoice.invoice_number}.pdf"
|
|
|
|
|
|
def generate_estimate_filename(estimate):
|
|
"""Generate a filename for the estimate PDF."""
|
|
return f"Estimate_{estimate.estimate_number}.pdf"
|