Files
TimeTracker/app/utils/pdf_template_schema.py
T
Dries Peeters 5bc637cc6b fix(invoices): include extra goods and expenses in PDF invoice exports
PDF invoices were missing extra goods (and expenses) because the ReportLab
template renderer only used invoice.items as the table data source.

- Add invoice.all_line_items to template context: merged list of items,
  extra_goods, and expenses with normalized description/quantity/price fields
- Update default template schema to use invoice.all_line_items instead of
  invoice.items for the items table
- Add migration to update existing saved templates with the new data source
- Update PDF layout designer: add all_line_items and extra_goods loop options,
  default items table to all_line_items
- Add expenses to fallback ReportLab generator for consistency with
  pdf_default.html

Fixes #503

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-07 22:24:45 +01:00

413 lines
12 KiB
Python

"""
ReportLab PDF Template Schema Definitions
Defines the JSON schema for ReportLab PDF templates generated by the visual editor.
This schema allows storing template definitions that can be rendered by ReportLab
without requiring HTML/CSS parsing.
"""
from typing import Dict, List, Any, Optional, Union
from enum import Enum
class PageSize(str, Enum):
"""Standard page sizes"""
A4 = "A4"
A5 = "A5"
A3 = "A3"
LETTER = "Letter"
LEGAL = "Legal"
TABLOID = "Tabloid"
class ElementType(str, Enum):
"""Types of elements that can be placed on a PDF template"""
TEXT = "text"
IMAGE = "image"
RECTANGLE = "rectangle"
CIRCLE = "circle"
LINE = "line"
TABLE = "table"
SPACER = "spacer"
class TextAlign(str, Enum):
"""Text alignment options"""
LEFT = "left"
CENTER = "center"
RIGHT = "right"
JUSTIFY = "justify"
# Page size dimensions in mm (standard ISO/ANSI sizes)
PAGE_SIZE_DIMENSIONS_MM = {
PageSize.A4: {"width": 210, "height": 297},
PageSize.A5: {"width": 148, "height": 210},
PageSize.A3: {"width": 297, "height": 420},
PageSize.LETTER: {"width": 216, "height": 279},
PageSize.LEGAL: {"width": 216, "height": 356},
PageSize.TABLOID: {"width": 279, "height": 432},
}
def validate_template_json(template_json: Dict[str, Any]) -> tuple[bool, Optional[str]]:
"""
Validate that a template JSON structure is correct.
Args:
template_json: Dictionary containing template definition
Returns:
tuple: (is_valid: bool, error_message: str or None)
"""
if not isinstance(template_json, dict):
return False, "Template must be a JSON object"
# Validate page configuration
if "page" not in template_json:
return False, "Template must contain 'page' configuration"
page = template_json["page"]
if not isinstance(page, dict):
return False, "Page configuration must be an object"
if "size" not in page:
return False, "Page must specify 'size'"
page_size = page["size"]
if page_size not in [ps.value for ps in PageSize]:
return False, f"Invalid page size: {page_size}"
# Validate elements
if "elements" not in template_json:
return False, "Template must contain 'elements' array"
if not isinstance(template_json["elements"], list):
return False, "Elements must be an array"
# Validate each element
for idx, element in enumerate(template_json["elements"]):
if not isinstance(element, dict):
return False, f"Element {idx} must be an object"
if "type" not in element:
return False, f"Element {idx} must specify 'type'"
element_type = element["type"]
if element_type not in [et.value for et in ElementType]:
return False, f"Element {idx} has invalid type: {element_type}"
# Type-specific validation
if element_type == ElementType.TEXT:
if "text" not in element:
return False, f"Text element {idx} must specify 'text'"
elif element_type == ElementType.IMAGE:
if "source" not in element:
return False, f"Image element {idx} must specify 'source'"
elif element_type == ElementType.TABLE:
if "columns" not in element:
return False, f"Table element {idx} must specify 'columns'"
return True, None
def get_default_template(page_size: str = "A4") -> Dict[str, Any]:
"""
Get a default clean and simple template structure for invoices/quotes.
Args:
page_size: Page size identifier (A4, A5, Letter, etc.)
Returns:
Dictionary containing default template structure with clean layout
"""
# Get page dimensions in points for positioning
dims_pt = get_page_dimensions_points(page_size)
page_width_pt = dims_pt["width"]
page_height_pt = dims_pt["height"]
# Margins in mm, convert to points (20mm = 56.69 points)
margin_mm = 20
margin_pt = (margin_mm / 25.4) * 72
# Calculate usable area
usable_width = page_width_pt - (margin_pt * 2)
# Layout positions (relative to top-left, accounting for margins)
header_y = margin_pt
client_y = header_y + 80
table_y = client_y + 60
totals_y = table_y + 200
footer_y = totals_y + 60
# Build elements list
elements = []
# Header: Title (works for both invoice and quote)
elements.append({
"type": "text",
"x": margin_pt,
"y": header_y + 10,
"text": "INVOICE",
"width": 300,
"style": {
"font": "Helvetica-Bold",
"size": 28,
"color": "#000000",
"align": "left"
}
})
# Company info section (left side)
elements.append({
"type": "text",
"x": margin_pt,
"y": header_y + 50,
"text": "{{ settings.company_name if settings.company_name else 'Your Company Name' }}",
"width": 300,
"style": {
"font": "Helvetica-Bold",
"size": 12,
"color": "#000000",
"align": "left"
}
})
elements.append({
"type": "text",
"x": margin_pt,
"y": header_y + 70,
"text": "{{ settings.company_address if settings.company_address else 'Company Address' }}",
"width": 300,
"style": {
"font": "Helvetica",
"size": 10,
"color": "#000000",
"align": "left"
}
})
# Invoice/Quote details (right side, no box - cleaner)
details_x = page_width_pt - margin_pt - 200
detail_items = [
("Invoice #:", "{{ invoice.invoice_number }}", header_y + 30),
("Date:", "{{ format_date(invoice.issue_date) if invoice.issue_date else 'N/A' }}", header_y + 50),
("Due Date:", "{{ format_date(invoice.due_date) if invoice.due_date else 'N/A' }}", header_y + 70),
]
for label, value, y_pos in detail_items:
# Label
elements.append({
"type": "text",
"x": details_x,
"y": y_pos,
"text": label,
"width": 80,
"style": {
"font": "Helvetica-Bold",
"size": 9,
"color": "#000000",
"align": "right"
}
})
# Value
elements.append({
"type": "text",
"x": details_x + 90,
"y": y_pos,
"text": value,
"width": 110,
"style": {
"font": "Helvetica",
"size": 9,
"color": "#000000",
"align": "left"
}
})
# Client info section
elements.append({
"type": "text",
"x": margin_pt,
"y": client_y,
"text": "Bill To:",
"width": 300,
"style": {
"font": "Helvetica-Bold",
"size": 10,
"color": "#000000",
"align": "left"
}
})
elements.append({
"type": "text",
"x": margin_pt,
"y": client_y + 20,
"text": "{{ invoice.client_name if invoice.client_name else 'Client Name' }}",
"width": 300,
"style": {
"font": "Helvetica",
"size": 10,
"color": "#000000",
"align": "left"
}
})
elements.append({
"type": "text",
"x": margin_pt,
"y": client_y + 40,
"text": "{{ invoice.client_address if invoice.client_address else 'Client Address' }}",
"width": 300,
"style": {
"font": "Helvetica",
"size": 10,
"color": "#000000",
"align": "left"
}
})
# Items table (no separator lines - cleaner)
elements.append({
"type": "table",
"x": margin_pt,
"y": table_y,
"width": usable_width,
"columns": [
{"header": "Description", "width": 250, "field": "description", "align": "left"},
{"header": "Qty", "width": 70, "field": "quantity", "align": "center"},
{"header": "Unit Price", "width": 110, "field": "unit_price", "align": "right"},
{"header": "Total", "width": 110, "field": "total_amount", "align": "right"}
],
"data": "{{ invoice.all_line_items }}",
"row_template": {
"description": "{{ item.description }}",
"quantity": "{{ item.quantity }}",
"unit_price": "{{ format_money(item.unit_price) }}",
"total_amount": "{{ format_money(item.total_amount) }}"
},
"style": {
"headerBackground": "#f8f9fa",
"headerTextColor": "#000000",
"rowBackground": "#ffffff",
"rowTextColor": "#000000"
}
})
# Totals section (right-aligned, no separator lines)
totals_x = page_width_pt - margin_pt - 200
total_items = [
("Subtotal:", "{{ format_money(invoice.subtotal) if invoice.subtotal else '0.00' }}", totals_y),
("Tax:", "{{ format_money(invoice.tax_amount) if invoice.tax_amount else '0.00' }}", totals_y + 20),
("Total:", "{{ format_money(invoice.total_amount) if invoice.total_amount else '0.00' }}", totals_y + 45),
]
for label, value, y_pos in total_items:
# Label
elements.append({
"type": "text",
"x": totals_x,
"y": y_pos,
"text": label,
"width": 100,
"style": {
"font": "Helvetica-Bold" if "Total:" in label else "Helvetica",
"size": 11 if "Total:" in label else 10,
"color": "#000000",
"align": "right"
}
})
# Value
elements.append({
"type": "text",
"x": totals_x + 110,
"y": y_pos,
"text": value,
"width": 90,
"style": {
"font": "Helvetica-Bold" if "Total:" in label else "Helvetica",
"size": 11 if "Total:" in label else 10,
"color": "#000000",
"align": "right"
}
})
# Footer notes section
if footer_y + 40 < page_height_pt - margin_pt:
elements.append({
"type": "text",
"x": margin_pt,
"y": footer_y,
"text": "{{ invoice.notes if invoice.notes else 'Thank you for your business!' }}",
"width": usable_width,
"style": {
"font": "Helvetica",
"size": 9,
"color": "#666666",
"align": "left"
}
})
return {
"page": {
"size": page_size,
"margin": {
"top": 20,
"right": 20,
"bottom": 20,
"left": 20
}
},
"elements": elements,
"styles": {
"default": {
"font": "Helvetica",
"size": 10,
"color": "#000000"
},
"heading": {
"font": "Helvetica-Bold",
"size": 18,
"color": "#000000"
},
"normal": {
"font": "Helvetica",
"size": 10,
"color": "#000000"
}
}
}
def get_page_dimensions_mm(page_size: str) -> Dict[str, float]:
"""
Get page dimensions in millimeters for a given page size.
Args:
page_size: Page size identifier
Returns:
Dictionary with 'width' and 'height' in mm
"""
return PAGE_SIZE_DIMENSIONS_MM.get(PageSize(page_size), PAGE_SIZE_DIMENSIONS_MM[PageSize.A4])
def get_page_dimensions_points(page_size: str) -> Dict[str, float]:
"""
Get page dimensions in points (ReportLab standard) for a given page size.
1 point = 1/72 inch, 1 inch = 25.4 mm
Args:
page_size: Page size identifier
Returns:
Dictionary with 'width' and 'height' in points
"""
dims_mm = get_page_dimensions_mm(page_size)
# Convert mm to points: 1 mm = (72 / 25.4) points
width_pt = (dims_mm["width"] / 25.4) * 72
height_pt = (dims_mm["height"] / 25.4) * 72
return {"width": width_pt, "height": height_pt}