Files
TimeTracker/app/models/invoice_pdf_template.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

208 lines
8.4 KiB
Python

"""Invoice PDF Template Model
Stores PDF templates for different page sizes (A4, Letter, A3, etc.)
"""
from datetime import datetime
from app import db
class InvoicePDFTemplate(db.Model):
"""Model for storing invoice PDF templates by page size"""
__tablename__ = "invoice_pdf_templates"
id = db.Column(db.Integer, primary_key=True)
page_size = db.Column(db.String(20), nullable=False, unique=True) # A4, Letter, A3, Legal, A5, etc.
template_html = db.Column(db.Text, nullable=True) # Legacy HTML template (backward compatibility)
template_css = db.Column(db.Text, nullable=True) # Legacy CSS template (backward compatibility)
design_json = db.Column(db.Text, nullable=True) # Konva.js design state
template_json = db.Column(db.Text, nullable=True) # ReportLab template JSON (new format)
date_format = db.Column(
db.String(50), default="%d.%m.%Y", nullable=False
) # Date format for invoices (strftime format)
is_default = db.Column(db.Boolean, default=False, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
# Standard page sizes and their dimensions in mm (for reference)
PAGE_SIZES = {
"A4": {"width": 210, "height": 297},
"Letter": {"width": 216, "height": 279},
"Legal": {"width": 216, "height": 356},
"A3": {"width": 297, "height": 420},
"A5": {"width": 148, "height": 210},
"Tabloid": {"width": 279, "height": 432},
}
def __repr__(self):
return f"<InvoicePDFTemplate {self.page_size}>"
@classmethod
def get_template(cls, page_size="A4"):
"""Get template for a specific page size, create default if doesn't exist"""
template = cls.query.filter_by(page_size=page_size).first()
if not template:
# Create default template for this size with default JSON
import json
from app.utils.pdf_template_schema import get_default_template
default_json = get_default_template(page_size)
template = cls(
page_size=page_size,
template_html="",
template_css="",
design_json="",
template_json=json.dumps(default_json),
date_format="%d.%m.%Y",
is_default=True,
)
db.session.add(template)
try:
db.session.commit()
except Exception:
db.session.rollback()
# Try to get again in case it was created concurrently
template = cls.query.filter_by(page_size=page_size).first()
if not template:
raise
# DON'T call ensure_template_json() here - it may overwrite saved templates
# Only validate that template exists - if it has no JSON, it will be handled during export
# This prevents overwriting saved custom templates with defaults
return template
@classmethod
def get_all_templates(cls):
"""Get all templates ordered by page size"""
return cls.query.order_by(cls.page_size).all()
@classmethod
def get_default_template(cls):
"""Get the default template (A4)"""
return cls.get_template("A4")
@classmethod
def ensure_default_templates(cls):
"""Ensure all default templates exist"""
import json
from app.utils.pdf_template_schema import get_default_template
default_sizes = ["A4", "Letter", "Legal", "A3", "A5"]
for size in default_sizes:
template = cls.query.filter_by(page_size=size).first()
if not template:
default_json = get_default_template(size)
template = cls(
page_size=size,
template_html="",
template_css="",
design_json="",
template_json=json.dumps(default_json),
is_default=True,
)
db.session.add(template)
try:
db.session.commit()
except Exception:
db.session.rollback()
def to_dict(self):
"""Convert template to dictionary"""
return {
"id": self.id,
"page_size": self.page_size,
"template_html": self.template_html or "",
"template_css": self.template_css or "",
"design_json": self.design_json or "",
"template_json": self.template_json or "",
"is_default": self.is_default,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
def get_template_json(self):
"""Get template JSON, parsing from string if needed"""
if not self.template_json:
return None
import json
try:
return json.loads(self.template_json)
except Exception:
return None
def set_template_json(self, template_dict):
"""Set template JSON from dictionary"""
import json
self.template_json = json.dumps(template_dict) if template_dict else None
def get_page_dimensions_mm(self):
"""Get page dimensions in mm"""
return self.PAGE_SIZES.get(self.page_size, {"width": 210, "height": 297})
def get_page_dimensions_px(self, dpi=72):
"""Get page dimensions in pixels at given DPI"""
dims_mm = self.get_page_dimensions_mm()
# Convert mm to pixels: 1 mm = (dpi / 25.4) pixels
width_px = int((dims_mm["width"] / 25.4) * dpi)
height_px = int((dims_mm["height"] / 25.4) * dpi)
return {"width": width_px, "height": height_px}
def ensure_template_json(self):
"""Ensure template has valid JSON, generate if missing"""
import json
from flask import current_app
# First check if template_json exists and is not empty
if self.template_json and self.template_json.strip():
# Validate that it's valid JSON
try:
parsed_json = json.loads(self.template_json)
# If it's valid JSON with at least a page property, consider it valid
if isinstance(parsed_json, dict) and "page" in parsed_json:
current_app.logger.info(
f"[TEMPLATE] Template JSON is valid - PageSize: '{self.page_size}', TemplateID: {self.id}"
)
return # Template JSON is valid, don't overwrite
else:
current_app.logger.warning(
f"[TEMPLATE] Template JSON exists but missing 'page' property - PageSize: '{self.page_size}', TemplateID: {self.id}"
)
except json.JSONDecodeError as e:
current_app.logger.warning(
f"[TEMPLATE] Template JSON exists but is invalid JSON - PageSize: '{self.page_size}', TemplateID: {self.id}, Error: {str(e)}"
)
# Invalid JSON - will generate default below
# Only generate default if template_json is truly None or empty, or invalid
if not self.template_json or not self.template_json.strip():
current_app.logger.warning(
f"[TEMPLATE] Generating default template JSON - PageSize: '{self.page_size}', TemplateID: {self.id}, Reason: template_json is missing or empty"
)
else:
current_app.logger.warning(
f"[TEMPLATE] Generating default template JSON - PageSize: '{self.page_size}', TemplateID: {self.id}, Reason: existing JSON is invalid"
)
from app.utils.pdf_template_schema import get_default_template
default_json = get_default_template(self.page_size)
self.template_json = json.dumps(default_json)
try:
db.session.commit()
current_app.logger.info(
f"[TEMPLATE] Default template JSON saved - PageSize: '{self.page_size}', TemplateID: {self.id}"
)
except Exception as e:
current_app.logger.error(
f"[TEMPLATE] Failed to save default template JSON - PageSize: '{self.page_size}', TemplateID: {self.id}, Error: {str(e)}",
exc_info=True,
)
db.session.rollback()