mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-01-25 05:58:50 -06:00
- Normalize line endings from CRLF to LF across all files to match .editorconfig - Standardize quote style from single quotes to double quotes - Normalize whitespace and formatting throughout codebase - Apply consistent code style across 372 files including: * Application code (models, routes, services, utils) * Test files * Configuration files * CI/CD workflows This ensures consistency with the project's .editorconfig settings and improves code maintainability.
129 lines
5.3 KiB
Python
129 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()
|