mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-02 02:09:23 -05:00
acd30bc015
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
126 lines
5.3 KiB
Python
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()
|
|
|