Files
TimeTracker/app/models/quote_version.py
T
Dries Peeters acd30bc015 feat: implement comprehensive quote management system with PDF editor
Major Features:
- Complete quote management system with CRUD operations
- Quote items management with dynamic add/remove functionality
- Discount system (percentage and fixed amount)
- Payment terms integration with invoice creation
- Approval workflow with status tracking
- Quote attachments with client visibility control
- Quote templates for reusable configurations
- Quote versioning for revision history
- Email notifications for quote lifecycle events
- Scheduled tasks for expiring quote reminders
- Client portal integration for quote viewing/acceptance
- Bulk actions for quote management
- Analytics dashboard for quote metrics

UI/UX Improvements:
- Consistent table layout matching projects/clients pages
- Professional quote view page with improved action buttons
- Enhanced create/edit forms with organized sections
- Dynamic line items management in quote forms
- PDF template editor accessible via admin menu
- PDF submenu under Admin with Invoice and Quote options
- Fixed admin menu collapse when opening nested dropdowns

PDF Template System:
- Quote PDF layout editor with visual design tools
- Separate preview route for quote PDF templates
- Template reset functionality
- Support for multiple page sizes (A4, Letter, Legal, A3, A5, Tabloid)

Bug Fixes:
- Fixed 405 Method Not Allowed error on quote PDF save
- Fixed UnboundLocalError with translation function shadowing
- Fixed quote preview template context (quote vs invoice)
- Updated template references from invoice to quote variables

Database:
- Added 9 Alembic migrations for quote system schema
- Support for quotes, quote_items, quote_attachments, quote_templates, quote_versions
- Integration with existing comments system

Technical:
- Added Quote, QuoteItem, QuoteAttachment, QuoteTemplate, QuoteVersion models
- Extended comment routes to support quotes
- Integrated payment terms from quotes to invoices
- Email notification system for quote events
- Scheduled task for expiring quote checks
2025-11-23 16:08:31 +01:00

126 lines
5.3 KiB
Python

from datetime import datetime
from app import db
from app.utils.timezone import now_in_app_timezone
import json
def local_now():
"""Get current time in local timezone as naive datetime (for database storage)"""
return now_in_app_timezone().replace(tzinfo=None)
class QuoteVersion(db.Model):
"""Model for tracking quote version history"""
__tablename__ = 'quote_versions'
id = db.Column(db.Integer, primary_key=True)
quote_id = db.Column(db.Integer, db.ForeignKey('quotes.id', ondelete='CASCADE'), nullable=False, index=True)
version_number = db.Column(db.Integer, nullable=False) # 1, 2, 3, etc.
# Snapshot of quote data at this version (stored as JSON)
quote_data = db.Column(db.Text, nullable=False) # JSON string with complete quote state
# Change information
changed_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True)
changed_at = db.Column(db.DateTime, default=local_now, nullable=False)
change_summary = db.Column(db.String(500), nullable=True) # Brief description of changes
# What changed (for quick reference)
fields_changed = db.Column(db.String(500), nullable=True) # Comma-separated list of changed fields
# Relationships
quote = db.relationship('Quote', backref='versions')
changer = db.relationship('User', foreign_keys=[changed_by], backref='quote_version_changes')
def __init__(self, quote_id, version_number, quote_data, changed_by, **kwargs):
self.quote_id = quote_id
self.version_number = version_number
self.quote_data = quote_data if isinstance(quote_data, str) else json.dumps(quote_data)
self.changed_by = changed_by
self.change_summary = kwargs.get('change_summary', '').strip() if kwargs.get('change_summary') else None
self.fields_changed = kwargs.get('fields_changed', '').strip() if kwargs.get('fields_changed') else None
def __repr__(self):
return f'<QuoteVersion {self.version_number} for Quote {self.quote_id}>'
@property
def data_dict(self):
"""Get quote data as a dictionary"""
try:
return json.loads(self.quote_data)
except (json.JSONDecodeError, TypeError):
return {}
def to_dict(self):
"""Convert version to dictionary for API responses"""
return {
'id': self.id,
'quote_id': self.quote_id,
'version_number': self.version_number,
'quote_data': self.data_dict,
'changed_by': self.changed_by,
'changer': self.changer.username if self.changer else None,
'changed_at': self.changed_at.isoformat() if self.changed_at else None,
'change_summary': self.change_summary,
'fields_changed': self.fields_changed.split(',') if self.fields_changed else []
}
@classmethod
def create_version(cls, quote, changed_by, change_summary=None, fields_changed=None):
"""Create a new version snapshot of a quote"""
# Get current version number
last_version = cls.query.filter_by(quote_id=quote.id).order_by(cls.version_number.desc()).first()
version_number = (last_version.version_number + 1) if last_version else 1
# Create snapshot of quote data
quote_data = {
'title': quote.title,
'description': quote.description,
'status': quote.status,
'subtotal': float(quote.subtotal),
'tax_rate': float(quote.tax_rate),
'tax_amount': float(quote.tax_amount),
'total_amount': float(quote.total_amount),
'currency_code': quote.currency_code,
'discount_type': quote.discount_type,
'discount_amount': float(quote.discount_amount) if quote.discount_amount else None,
'discount_reason': quote.discount_reason,
'coupon_code': quote.coupon_code,
'payment_terms': quote.payment_terms,
'valid_until': quote.valid_until.isoformat() if quote.valid_until else None,
'notes': quote.notes,
'terms': quote.terms,
'visible_to_client': quote.visible_to_client,
'requires_approval': quote.requires_approval,
'approval_status': quote.approval_status,
'items': [{
'description': item.description,
'quantity': float(item.quantity),
'unit_price': float(item.unit_price),
'unit': item.unit
} for item in quote.items]
}
version = cls(
quote_id=quote.id,
version_number=version_number,
quote_data=json.dumps(quote_data),
changed_by=changed_by,
change_summary=change_summary,
fields_changed=','.join(fields_changed) if fields_changed else None
)
db.session.add(version)
return version
@classmethod
def get_quote_versions(cls, quote_id):
"""Get all versions for a quote"""
return cls.query.filter_by(quote_id=quote_id).order_by(cls.version_number.desc()).all()
@classmethod
def get_latest_version(cls, quote_id):
"""Get the latest version of a quote"""
return cls.query.filter_by(quote_id=quote_id).order_by(cls.version_number.desc()).first()