Files
TimeTracker/app/utils/pdf_template_schema.py
T
Dries Peeters b4486a627f fix: CI tests, code quality, and duplicate DB indexes
- Webhook models: remove duplicate index definitions so db.create_all()
  no longer raises 'index already exists' (columns already have index=True)
- ImportService: fix circular import by late-importing ClientService,
  ProjectService, TimeTrackingService in __init__
- reports: fix F823 by renaming unpack variable _ to _entry_count to avoid
  shadowing gettext _ in export_task_excel()
- Code quality: add .flake8 with extend-ignore so flake8 CI passes;
  simplify pyproject.toml isort config (drop unsupported options)
- Format: run black and isort on app/
- tests: restore minimal app fixture in test_import_export_models
2026-03-15 10:51:52 +01:00

375 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 enum import Enum
from typing import Any, Dict, List, Optional, Union
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}