mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-19 04:40:32 -05:00
b4486a627f
- Webhook models: remove duplicate index definitions so db.create_all() no longer raises 'index already exists' (columns already have index=True) - ImportService: fix circular import by late-importing ClientService, ProjectService, TimeTrackingService in __init__ - reports: fix F823 by renaming unpack variable _ to _entry_count to avoid shadowing gettext _ in export_task_excel() - Code quality: add .flake8 with extend-ignore so flake8 CI passes; simplify pyproject.toml isort config (drop unsupported options) - Format: run black and isort on app/ - tests: restore minimal app fixture in test_import_export_models
168 lines
6.6 KiB
Python
168 lines
6.6 KiB
Python
"""
|
|
Client Notification models for client portal notifications
|
|
"""
|
|
|
|
import enum
|
|
from datetime import datetime
|
|
|
|
from app import db
|
|
from app.utils.timezone import now_in_app_timezone
|
|
|
|
|
|
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,
|
|
}
|