mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-19 12:50:11 -05:00
ad9bfbf1ed
This commit fixes multiple issues preventing client deletion and adds proper validation to prevent deletion when invoices exist. Database Schema Fixes: - Migration 103: Add missing quote_number column to quotes table - Handles migration from offer_number to quote_number - Generates quote numbers for existing quotes if needed - Creates required unique index - Migration 104: Add all missing columns to quotes table - Adds subtotal, tax_amount, visible_to_client columns - Adds discount fields (discount_type, discount_amount, discount_reason, coupon_code) - Adds payment_terms column - Adds approval workflow columns (approval_status, approved_by, approved_at, rejected_by, rejection_reason) - Creates required indexes and foreign keys - Migration 105: Fix client_notifications foreign key cascade - Updates client_notifications.client_id FK to ON DELETE CASCADE - Updates client_notification_preferences.client_id FK to ON DELETE CASCADE - Prevents NOT NULL constraint violations during client deletion Model Updates: - Add passive_deletes=True to ClientNotification.client relationship - Add passive_deletes=True to ClientNotificationPreferences.client relationship - Add passive_deletes=True to ClientAttachment.client relationship - Update ClientNote.client relationship to use passive_deletes Route Updates: - Add invoice validation to delete_client() and bulk_delete_clients() - Manually delete notifications before client deletion to prevent SQLAlchemy update issues Fixes: - Resolves IntegrityError when deleting clients with notifications - Resolves missing quote_number column errors - Resolves missing quotes table columns errors - Prevents deletion of clients with invoices (data integrity)
158 lines
6.6 KiB
Python
158 lines
6.6 KiB
Python
"""
|
|
Client Notification models for client portal notifications
|
|
"""
|
|
|
|
from datetime import datetime
|
|
from app import db
|
|
from app.utils.timezone import now_in_app_timezone
|
|
import enum
|
|
|
|
|
|
class NotificationType(enum.Enum):
|
|
"""Client notification types"""
|
|
INVOICE_CREATED = "invoice_created"
|
|
INVOICE_PAID = "invoice_paid"
|
|
INVOICE_OVERDUE = "invoice_overdue"
|
|
PROJECT_MILESTONE = "project_milestone"
|
|
BUDGET_ALERT = "budget_alert"
|
|
TIME_ENTRY_APPROVAL = "time_entry_approval"
|
|
PROJECT_STATUS_CHANGE = "project_status_change"
|
|
QUOTE_AVAILABLE = "quote_available"
|
|
COMMENT_ADDED = "comment_added"
|
|
FILE_UPLOADED = "file_uploaded"
|
|
GENERAL = "general"
|
|
|
|
|
|
class ClientNotification(db.Model):
|
|
"""In-app notifications for client portal users"""
|
|
|
|
__tablename__ = "client_notifications"
|
|
|
|
id = db.Column(db.Integer, primary_key=True)
|
|
client_id = db.Column(db.Integer, db.ForeignKey("clients.id", ondelete="CASCADE"), nullable=False, index=True)
|
|
|
|
# Notification details
|
|
type = db.Column(db.String(50), nullable=False, index=True) # NotificationType enum value
|
|
title = db.Column(db.String(200), nullable=False)
|
|
message = db.Column(db.Text, nullable=False)
|
|
|
|
# Link/action
|
|
link_url = db.Column(db.String(500), nullable=True) # URL to related resource
|
|
link_text = db.Column(db.String(100), nullable=True) # Text for the link
|
|
|
|
# Status
|
|
is_read = db.Column(db.Boolean, default=False, nullable=False, index=True)
|
|
read_at = db.Column(db.DateTime, nullable=True)
|
|
|
|
# Metadata (renamed from 'metadata' to avoid SQLAlchemy reserved word conflict)
|
|
extra_data = db.Column(db.JSON, nullable=True) # Additional data (invoice_id, project_id, etc.)
|
|
|
|
# Timestamps
|
|
created_at = db.Column(db.DateTime, default=now_in_app_timezone, nullable=False, index=True)
|
|
|
|
# Relationships
|
|
client = db.relationship("Client", backref=db.backref("notifications", lazy="dynamic", order_by="desc(ClientNotification.created_at)"), passive_deletes=True)
|
|
|
|
def __repr__(self):
|
|
return f"<ClientNotification {self.id} for client {self.client_id} - {self.type}>"
|
|
|
|
def mark_as_read(self):
|
|
"""Mark notification as read"""
|
|
self.is_read = True
|
|
self.read_at = now_in_app_timezone()
|
|
db.session.commit()
|
|
|
|
def to_dict(self):
|
|
"""Convert to dictionary for API responses"""
|
|
return {
|
|
"id": self.id,
|
|
"client_id": self.client_id,
|
|
"type": self.type,
|
|
"title": self.title,
|
|
"message": self.message,
|
|
"link_url": self.link_url,
|
|
"link_text": self.link_text,
|
|
"is_read": self.is_read,
|
|
"read_at": self.read_at.isoformat() if self.read_at else None,
|
|
"metadata": self.extra_data, # API compatibility: return as 'metadata'
|
|
"created_at": self.created_at.isoformat() if self.created_at else None,
|
|
}
|
|
|
|
@classmethod
|
|
def get_unread_count(cls, client_id):
|
|
"""Get count of unread notifications for a client"""
|
|
return cls.query.filter_by(client_id=client_id, is_read=False).count()
|
|
|
|
@classmethod
|
|
def get_recent_notifications(cls, client_id, limit=20):
|
|
"""Get recent notifications for a client"""
|
|
return cls.query.filter_by(client_id=client_id).order_by(cls.created_at.desc()).limit(limit).all()
|
|
|
|
|
|
class ClientNotificationPreferences(db.Model):
|
|
"""Notification preferences for clients"""
|
|
|
|
__tablename__ = "client_notification_preferences"
|
|
|
|
id = db.Column(db.Integer, primary_key=True)
|
|
client_id = db.Column(db.Integer, db.ForeignKey("clients.id", ondelete="CASCADE"), nullable=False, unique=True, index=True)
|
|
|
|
# Email preferences
|
|
email_enabled = db.Column(db.Boolean, default=True, nullable=False)
|
|
email_invoice_created = db.Column(db.Boolean, default=True, nullable=False)
|
|
email_invoice_paid = db.Column(db.Boolean, default=True, nullable=False)
|
|
email_invoice_overdue = db.Column(db.Boolean, default=True, nullable=False)
|
|
email_project_milestone = db.Column(db.Boolean, default=True, nullable=False)
|
|
email_budget_alert = db.Column(db.Boolean, default=True, nullable=False)
|
|
email_time_entry_approval = db.Column(db.Boolean, default=True, nullable=False)
|
|
email_project_status_change = db.Column(db.Boolean, default=False, nullable=False)
|
|
email_quote_available = db.Column(db.Boolean, default=True, nullable=False)
|
|
|
|
# In-app preferences
|
|
in_app_enabled = db.Column(db.Boolean, default=True, nullable=False)
|
|
|
|
# Timestamps
|
|
created_at = db.Column(db.DateTime, default=now_in_app_timezone, nullable=False)
|
|
updated_at = db.Column(db.DateTime, default=now_in_app_timezone, onupdate=now_in_app_timezone, nullable=False)
|
|
|
|
# Relationships
|
|
client = db.relationship("Client", backref=db.backref("notification_preferences", uselist=False), passive_deletes=True)
|
|
|
|
def __repr__(self):
|
|
return f"<ClientNotificationPreferences client={self.client_id}>"
|
|
|
|
def should_send_email(self, notification_type):
|
|
"""Check if email should be sent for this notification type"""
|
|
if not self.email_enabled:
|
|
return False
|
|
|
|
type_map = {
|
|
NotificationType.INVOICE_CREATED: self.email_invoice_created,
|
|
NotificationType.INVOICE_PAID: self.email_invoice_paid,
|
|
NotificationType.INVOICE_OVERDUE: self.email_invoice_overdue,
|
|
NotificationType.PROJECT_MILESTONE: self.email_project_milestone,
|
|
NotificationType.BUDGET_ALERT: self.email_budget_alert,
|
|
NotificationType.TIME_ENTRY_APPROVAL: self.email_time_entry_approval,
|
|
NotificationType.PROJECT_STATUS_CHANGE: self.email_project_status_change,
|
|
NotificationType.QUOTE_AVAILABLE: self.email_quote_available,
|
|
}
|
|
|
|
return type_map.get(notification_type, True)
|
|
|
|
def to_dict(self):
|
|
"""Convert to dictionary"""
|
|
return {
|
|
"id": self.id,
|
|
"client_id": self.client_id,
|
|
"email_enabled": self.email_enabled,
|
|
"email_invoice_created": self.email_invoice_created,
|
|
"email_invoice_paid": self.email_invoice_paid,
|
|
"email_invoice_overdue": self.email_invoice_overdue,
|
|
"email_project_milestone": self.email_project_milestone,
|
|
"email_budget_alert": self.email_budget_alert,
|
|
"email_time_entry_approval": self.email_time_entry_approval,
|
|
"email_project_status_change": self.email_project_status_change,
|
|
"email_quote_available": self.email_quote_available,
|
|
"in_app_enabled": self.in_app_enabled,
|
|
}
|