Files
TimeTracker/app/models/quote.py
T
Dries Peeters 4eeaa2a842 feat: Migrate PDF templates to ReportLab JSON format
- Add ReportLab template renderer with JSON-based template system
- Implement template schema validation and helper functions
- Add database migration for template_json columns
- Update visual editor to generate ReportLab JSON alongside HTML/CSS
- Maintain backward compatibility with legacy templates
- Add comprehensive migration documentation

BREAKING CHANGE: Existing PDF templates need to be saved again through
the visual editor to generate the new template_json format. Templates
will continue to work using the legacy fallback generator until saved.
2026-01-09 11:43:42 +01:00

573 lines
24 KiB
Python

from datetime import datetime
from decimal import Decimal
from sqlalchemy import and_
from app import db
from app.utils.timezone import now_in_app_timezone
def local_now():
"""Get current time in local timezone as naive datetime (for database storage)"""
return now_in_app_timezone().replace(tzinfo=None)
class Quote(db.Model):
"""Quote model for managing client quotes that can be accepted as projects"""
__tablename__ = "quotes"
id = db.Column(db.Integer, primary_key=True)
quote_number = db.Column(db.String(50), unique=True, nullable=False, index=True)
client_id = db.Column(db.Integer, db.ForeignKey("clients.id"), nullable=False, index=True)
# Quote details
title = db.Column(db.String(200), nullable=False)
description = db.Column(db.Text, nullable=True)
status = db.Column(
db.String(20), default="draft", nullable=False
) # 'draft', 'sent', 'accepted', 'rejected', 'expired'
# Financial details (calculated from items)
subtotal = db.Column(db.Numeric(10, 2), nullable=False, default=0)
tax_rate = db.Column(db.Numeric(5, 2), nullable=False, default=0) # Tax rate percentage
tax_amount = db.Column(db.Numeric(10, 2), nullable=False, default=0)
total_amount = db.Column(db.Numeric(10, 2), nullable=False, default=0)
currency_code = db.Column(db.String(3), nullable=False, default="EUR")
# Discount fields
discount_type = db.Column(db.String(20), nullable=True) # 'percentage' or 'fixed'
discount_amount = db.Column(db.Numeric(10, 2), nullable=True, default=0) # Discount value
discount_reason = db.Column(db.String(500), nullable=True) # Reason for discount
coupon_code = db.Column(db.String(50), nullable=True, index=True) # Optional coupon code
# Validity and dates
valid_until = db.Column(db.Date, nullable=True) # Quote expiration date
sent_at = db.Column(db.DateTime, nullable=True) # When quote was sent to client
accepted_at = db.Column(db.DateTime, nullable=True) # When quote was accepted
rejected_at = db.Column(db.DateTime, nullable=True) # When quote was rejected
# Approval Workflow fields
approval_status = db.Column(
db.String(20), default="not_required", nullable=False
) # 'not_required', 'pending', 'approved', 'rejected'
approved_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True)
approved_at = db.Column(db.DateTime, nullable=True)
rejection_reason = db.Column(db.Text, nullable=True)
rejected_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True)
# Client portal visibility
visible_to_client = db.Column(
db.Boolean, default=False, nullable=False
) # Whether quote is visible in client portal
# PDF template
template_id = db.Column(db.Integer, db.ForeignKey("quote_pdf_templates.id"), nullable=True, index=True)
# Relationships
project_id = db.Column(
db.Integer, db.ForeignKey("projects.id"), nullable=True, index=True
) # Created project when accepted
# Metadata
created_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
accepted_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True)
created_at = db.Column(db.DateTime, default=local_now, nullable=False)
updated_at = db.Column(db.DateTime, default=local_now, onupdate=local_now, nullable=False)
# Notes
notes = db.Column(db.Text, nullable=True) # Internal notes
terms = db.Column(db.Text, nullable=True) # Terms and conditions
# Payment terms
payment_terms = db.Column(
db.String(100), nullable=True
) # e.g., "Net 30", "Net 60", "Due on Receipt", "2/10 Net 30"
# Relationships
client = db.relationship("Client", backref="quotes")
project = db.relationship(
"Project", primaryjoin="Quote.project_id == Project.id", foreign_keys="[Quote.project_id]", uselist=False
)
creator = db.relationship("User", foreign_keys=[created_by], backref="created_quotes")
accepter = db.relationship("User", foreign_keys=[accepted_by], backref="accepted_quotes")
approver = db.relationship("User", foreign_keys=[approved_by], backref="approved_quotes")
rejecter = db.relationship("User", foreign_keys=[rejected_by], backref="rejected_quotes")
items = db.relationship("QuoteItem", backref="quote", lazy="selectin", cascade="all, delete-orphan")
template = db.relationship("QuotePDFTemplate", backref="quotes", lazy="joined")
def __init__(self, quote_number, client_id, title, created_by, **kwargs):
self.quote_number = quote_number
self.client_id = client_id
self.title = title.strip()
self.created_by = created_by
# Set optional fields
self.description = kwargs.get("description", "").strip() if kwargs.get("description") else None
self.status = kwargs.get("status", "draft")
self.tax_rate = Decimal(str(kwargs.get("tax_rate", 0)))
self.currency_code = kwargs.get("currency_code", "EUR")
self.valid_until = kwargs.get("valid_until")
self.notes = kwargs.get("notes", "").strip() if kwargs.get("notes") else None
self.terms = kwargs.get("terms", "").strip() if kwargs.get("terms") else None
self.payment_terms = kwargs.get("payment_terms", "").strip() if kwargs.get("payment_terms") else None
self.visible_to_client = kwargs.get("visible_to_client", False)
self.template_id = kwargs.get("template_id")
# Discount fields
self.discount_type = kwargs.get("discount_type")
if kwargs.get("discount_amount"):
self.discount_amount = Decimal(str(kwargs.get("discount_amount")))
else:
self.discount_amount = Decimal("0")
self.discount_reason = kwargs.get("discount_reason", "").strip() if kwargs.get("discount_reason") else None
self.coupon_code = kwargs.get("coupon_code", "").strip().upper() if kwargs.get("coupon_code") else None
def __repr__(self):
return f"<Quote {self.quote_number} ({self.title})>"
@property
def is_draft(self):
"""Check if quote is in draft status"""
return self.status == "draft"
@property
def is_sent(self):
"""Check if quote has been sent"""
return self.status == "sent"
@property
def is_accepted(self):
"""Check if quote has been accepted"""
return self.status == "accepted"
@property
def is_rejected(self):
"""Check if quote has been rejected"""
return self.status == "rejected"
@property
def is_expired(self):
"""Check if quote has expired"""
if not self.valid_until:
return False
return local_now().date() > self.valid_until
@property
def can_be_accepted(self):
"""Check if quote can be accepted (sent and not expired)"""
return self.status == "sent" and not self.is_expired
@property
def has_project(self):
"""Check if quote has been converted to a project"""
return self.project_id is not None
def calculate_totals(self):
"""Calculate quote totals from items, applying discount if any"""
items_total = sum(item.total_amount for item in self.items)
self.subtotal = items_total
# Apply discount if set
discount_value = Decimal("0")
if self.discount_type and self.discount_amount:
if self.discount_type == "percentage":
# Percentage discount applied to subtotal
discount_value = self.subtotal * (self.discount_amount / 100)
elif self.discount_type == "fixed":
# Fixed discount amount
discount_value = min(self.discount_amount, self.subtotal) # Can't discount more than subtotal
# Calculate subtotal after discount
subtotal_after_discount = self.subtotal - discount_value
# Calculate tax on discounted amount
self.tax_amount = subtotal_after_discount * (self.tax_rate / 100)
self.total_amount = subtotal_after_discount + self.tax_amount
@property
def discount_value(self):
"""Calculate the discount value based on type"""
if not self.discount_type or not self.discount_amount:
return Decimal("0")
if self.discount_type == "percentage":
return self.subtotal * (self.discount_amount / 100)
elif self.discount_type == "fixed":
return min(self.discount_amount, self.subtotal)
return Decimal("0")
@property
def subtotal_after_discount(self):
"""Get subtotal after discount is applied"""
return self.subtotal - self.discount_value
def calculate_due_date_from_payment_terms(self, issue_date=None):
"""Calculate due date based on payment terms
Args:
issue_date: Date to calculate from (defaults to today)
Returns:
Date object or None if payment terms cannot be parsed
"""
from datetime import timedelta
from app.utils.timezone import local_now
if not self.payment_terms:
return None
if issue_date is None:
issue_date = local_now().date()
payment_terms = self.payment_terms.strip().upper()
# Parse common payment terms
# "Net 30" -> 30 days
# "Net 60" -> 60 days
# "Due on Receipt" -> 0 days
# "2/10 Net 30" -> 30 days (ignore early payment discount)
# "Net 15" -> 15 days
# etc.
if "DUE ON RECEIPT" in payment_terms or "IMMEDIATE" in payment_terms:
return issue_date
# Extract number from "Net XX" pattern
import re
match = re.search(r"NET\s*(\d+)", payment_terms)
if match:
days = int(match.group(1))
return issue_date + timedelta(days=days)
# Try to extract any number (fallback)
numbers = re.findall(r"\d+", payment_terms)
if numbers:
days = int(numbers[-1]) # Use last number found
return issue_date + timedelta(days=days)
return None
def send(self):
"""Mark quote as sent"""
if self.requires_approval and self.approval_status != "approved":
raise ValueError("Quote requires approval before it can be sent")
self.status = "sent"
self.sent_at = local_now()
self.updated_at = local_now()
def request_approval(self):
"""Request approval for the quote"""
if not self.requires_approval:
raise ValueError("Quote does not require approval")
if self.approval_status == "approved":
raise ValueError("Quote is already approved")
self.approval_status = "pending"
self.updated_at = local_now()
def approve(self, user_id, notes=None):
"""Approve the quote"""
if not self.requires_approval:
raise ValueError("Quote does not require approval")
if self.approval_status != "pending":
raise ValueError("Quote is not pending approval")
self.approval_status = "approved"
self.approved_by = user_id
self.approved_at = local_now()
if notes:
self.notes = (self.notes or "") + f"\n\nApproval notes: {notes}"
self.updated_at = local_now()
def reject_approval(self, user_id, reason):
"""Reject the quote in approval workflow"""
if not self.requires_approval:
raise ValueError("Quote does not require approval")
if self.approval_status != "pending":
raise ValueError("Quote is not pending approval")
self.approval_status = "rejected"
self.rejected_by = user_id
self.rejected_at = local_now()
self.rejection_reason = reason
self.updated_at = local_now()
def accept(self, user_id, project_id=None):
"""Accept the quote and optionally link to a project"""
if not self.can_be_accepted:
raise ValueError("Quote cannot be accepted in its current state")
self.status = "accepted"
self.accepted_at = local_now()
self.accepted_by = user_id
if project_id:
self.project_id = project_id
self.updated_at = local_now()
def reject(self):
"""Reject the quote"""
if self.status not in ["sent", "draft"]:
raise ValueError("Quote cannot be rejected in its current state")
self.status = "rejected"
self.rejected_at = local_now()
self.updated_at = local_now()
def expire(self):
"""Mark quote as expired"""
if self.status == "sent":
self.status = "expired"
self.updated_at = local_now()
def to_dict(self):
"""Convert quote to dictionary for API responses"""
self.calculate_totals() # Ensure totals are up to date
return {
"id": self.id,
"quote_number": self.quote_number,
"client_id": self.client_id,
"title": self.title,
"description": self.description,
"status": self.status,
"subtotal": float(self.subtotal),
"discount_type": self.discount_type,
"discount_amount": float(self.discount_amount) if self.discount_amount else 0,
"discount_value": float(self.discount_value),
"discount_reason": self.discount_reason,
"coupon_code": self.coupon_code,
"subtotal_after_discount": float(self.subtotal_after_discount),
"tax_rate": float(self.tax_rate),
"tax_amount": float(self.tax_amount),
"total_amount": float(self.total_amount),
"currency_code": self.currency_code,
"valid_until": self.valid_until.isoformat() if self.valid_until else None,
"sent_at": self.sent_at.isoformat() if self.sent_at else None,
"accepted_at": self.accepted_at.isoformat() if self.accepted_at else None,
"rejected_at": self.rejected_at.isoformat() if self.rejected_at else None,
"project_id": self.project_id,
"created_by": self.created_by,
"accepted_by": self.accepted_by,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
"notes": self.notes,
"terms": self.terms,
"visible_to_client": self.visible_to_client,
"template_id": self.template_id,
"is_draft": self.is_draft,
"is_sent": self.is_sent,
"is_accepted": self.is_accepted,
"is_rejected": self.is_rejected,
"is_expired": self.is_expired,
"can_be_accepted": self.can_be_accepted,
"has_project": self.has_project,
"items": [item.to_dict() for item in self.items],
}
@classmethod
def generate_quote_number(cls):
"""Generate a unique quote number"""
# Format: QUO-YYYYMMDD-XXX
today = local_now()
date_prefix = today.strftime("%Y%m%d")
# Find the next available number for today
existing = (
cls.query.filter(cls.quote_number.like(f"QUO-{date_prefix}-%")).order_by(cls.quote_number.desc()).first()
)
if existing:
# Extract the number part and increment
try:
last_num = int(existing.quote_number.split("-")[-1])
next_num = last_num + 1
except (ValueError, IndexError):
next_num = 1
else:
next_num = 1
return f"QUO-{date_prefix}-{next_num:03d}"
class QuoteItem(db.Model):
"""Quote line item model"""
__tablename__ = "quote_items"
id = db.Column(db.Integer, primary_key=True)
quote_id = db.Column(db.Integer, db.ForeignKey("quotes.id"), nullable=False, index=True)
# Item details
description = db.Column(db.String(500), nullable=False)
quantity = db.Column(db.Numeric(10, 2), nullable=False, default=1)
unit_price = db.Column(db.Numeric(10, 2), nullable=False)
total_amount = db.Column(db.Numeric(10, 2), nullable=False)
# Optional fields
unit = db.Column(db.String(20), nullable=True) # 'hours', 'days', 'items', etc.
# Inventory integration
stock_item_id = db.Column(db.Integer, db.ForeignKey("stock_items.id"), nullable=True, index=True)
warehouse_id = db.Column(db.Integer, db.ForeignKey("warehouses.id"), nullable=True)
is_stock_item = db.Column(db.Boolean, default=False, nullable=False)
# Metadata
created_at = db.Column(db.DateTime, default=local_now, nullable=False)
# Relationships
stock_item = db.relationship("StockItem", foreign_keys=[stock_item_id], lazy="joined")
warehouse = db.relationship("Warehouse", foreign_keys=[warehouse_id], lazy="joined")
def __init__(self, quote_id, description, quantity, unit_price, unit=None, stock_item_id=None, warehouse_id=None):
self.quote_id = quote_id
self.description = description.strip()
self.quantity = Decimal(str(quantity))
self.unit_price = Decimal(str(unit_price))
self.total_amount = self.quantity * self.unit_price
self.unit = unit.strip() if unit else None
self.stock_item_id = stock_item_id
self.warehouse_id = warehouse_id
self.is_stock_item = stock_item_id is not None
def __repr__(self):
return f"<QuoteItem {self.description} ({self.quantity} @ {self.unit_price})>"
def to_dict(self):
"""Convert quote item to dictionary"""
return {
"id": self.id,
"quote_id": self.quote_id,
"description": self.description,
"quantity": float(self.quantity),
"unit_price": float(self.unit_price),
"total_amount": float(self.total_amount),
"unit": self.unit,
"stock_item_id": self.stock_item_id,
"warehouse_id": self.warehouse_id,
"is_stock_item": self.is_stock_item,
"created_at": self.created_at.isoformat() if self.created_at else None,
}
class QuotePDFTemplate(db.Model):
"""Model for storing quote PDF templates by page size"""
__tablename__ = "quote_pdf_templates"
id = db.Column(db.Integer, primary_key=True)
page_size = db.Column(db.String(20), nullable=False, unique=True) # A4, Letter, A3, 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)
is_default = db.Column(db.Boolean, default=False, nullable=False)
created_at = db.Column(db.DateTime, default=local_now, nullable=False)
updated_at = db.Column(db.DateTime, default=local_now, onupdate=local_now, 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"<QuotePDFTemplate {self.page_size}>"
@classmethod
def get_template(cls, page_size="A4"):
"""Get template for a specific page size, creating default if needed"""
template = cls.query.filter_by(page_size=page_size).first()
if not template:
# Create default template for this size with default JSON
from app.utils.pdf_template_schema import get_default_template
import json
default_json = get_default_template(page_size)
template = cls(
page_size=page_size,
template_json=json.dumps(default_json),
is_default=(page_size == "A4")
)
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"""
return cls.query.order_by(cls.page_size).all()
@classmethod
def get_default_template(cls):
"""Get the default template"""
template = cls.query.filter_by(is_default=True).first()
if not template:
template = cls.get_template("A4")
template.is_default = True
db.session.commit()
return template
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 ensure_template_json(self):
"""Ensure template has valid JSON, generate if missing"""
from flask import current_app
import json
# 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] Quote 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] Quote 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] Quote 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 quote 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 quote template JSON - PageSize: '{self.page_size}', TemplateID: {self.id}, Reason: existing JSON is invalid")
from app.utils.pdf_template_schema import get_default_template
import json
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 quote template JSON saved - PageSize: '{self.page_size}', TemplateID: {self.id}")
except Exception as e:
current_app.logger.error(f"[TEMPLATE] Failed to save default quote template JSON - PageSize: '{self.page_size}', TemplateID: {self.id}, Error: {str(e)}", exc_info=True)
db.session.rollback()