mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-22 22:31:31 -05:00
5bc637cc6b
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>
413 lines
12 KiB
Python
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}
|