270 lines
8.5 KiB
Python
270 lines
8.5 KiB
Python
from django.db import models
|
|
from django.contrib.contenttypes.fields import GenericForeignKey
|
|
from django.contrib.contenttypes.models import ContentType
|
|
from django.contrib.postgres.fields import ArrayField
|
|
from django_choices_field import TextChoicesField
|
|
|
|
from core.models.base import BaseModel
|
|
from core.models.enums import (
|
|
EventTypeChoices,
|
|
NotificationChannelChoices,
|
|
NotificationStatusChoices,
|
|
DeliveryStatusChoices,
|
|
RoleChoices
|
|
)
|
|
|
|
|
|
class Event(BaseModel):
|
|
"""
|
|
Event model to track system events that may trigger notifications.
|
|
Provides audit trail and basis for notification system.
|
|
"""
|
|
event_type = TextChoicesField(
|
|
choices_enum=EventTypeChoices,
|
|
help_text="Type of event that occurred"
|
|
)
|
|
entity_type = models.CharField(
|
|
max_length=100,
|
|
help_text="Type of entity (e.g., 'Project', 'Report', 'Invoice')"
|
|
)
|
|
entity_id = models.UUIDField(
|
|
help_text="UUID of the entity that triggered this event"
|
|
)
|
|
metadata = models.JSONField(
|
|
default=dict,
|
|
blank=True,
|
|
help_text="Additional event metadata (e.g., old_status, new_status, changed_fields)"
|
|
)
|
|
# Generic foreign key to support both TeamProfile and CustomerProfile
|
|
triggered_by_content_type = models.ForeignKey(
|
|
ContentType,
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name='triggered_events'
|
|
)
|
|
triggered_by_object_id = models.UUIDField(
|
|
null=True,
|
|
blank=True
|
|
)
|
|
triggered_by = GenericForeignKey('triggered_by_content_type', 'triggered_by_object_id')
|
|
|
|
class Meta:
|
|
db_table = 'events'
|
|
ordering = ['-created_at']
|
|
indexes = [
|
|
models.Index(fields=['event_type', 'created_at']),
|
|
models.Index(fields=['entity_type', 'entity_id']),
|
|
models.Index(fields=['created_at']),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.event_type} - {self.entity_type}:{self.entity_id} at {self.created_at}"
|
|
|
|
|
|
class NotificationRule(BaseModel):
|
|
"""
|
|
Admin-defined rules for generating notifications based on events.
|
|
"""
|
|
name = models.CharField(
|
|
max_length=200,
|
|
help_text="Descriptive name for this notification rule"
|
|
)
|
|
description = models.TextField(
|
|
blank=True,
|
|
help_text="Description of when and how this rule applies"
|
|
)
|
|
event_types = ArrayField(
|
|
TextChoicesField(choices_enum=EventTypeChoices),
|
|
help_text="List of event types that trigger this rule"
|
|
)
|
|
channels = ArrayField(
|
|
TextChoicesField(choices_enum=NotificationChannelChoices),
|
|
help_text="Delivery channels for notifications (IN_APP, EMAIL, SMS)"
|
|
)
|
|
target_roles = ArrayField(
|
|
TextChoicesField(choices_enum=RoleChoices),
|
|
blank=True,
|
|
default=list,
|
|
help_text="Roles that should receive notifications (empty = all authenticated users)"
|
|
)
|
|
target_team_profiles = models.ManyToManyField(
|
|
'TeamProfile',
|
|
blank=True,
|
|
related_name='notification_rules',
|
|
help_text="Specific team profiles to notify"
|
|
)
|
|
target_customer_profiles = models.ManyToManyField(
|
|
'CustomerProfile',
|
|
blank=True,
|
|
related_name='notification_rules',
|
|
help_text="Specific customer profiles to notify"
|
|
)
|
|
is_active = models.BooleanField(
|
|
default=True,
|
|
help_text="Whether this rule is currently active"
|
|
)
|
|
template_subject = models.CharField(
|
|
max_length=500,
|
|
blank=True,
|
|
help_text="Template for notification subject (supports variables)"
|
|
)
|
|
template_body = models.TextField(
|
|
blank=True,
|
|
help_text="Template for notification body (supports variables)"
|
|
)
|
|
conditions = models.JSONField(
|
|
default=dict,
|
|
blank=True,
|
|
help_text="Additional conditions for when this rule applies (e.g., {'status': 'COMPLETED'})"
|
|
)
|
|
|
|
class Meta:
|
|
db_table = 'notification_rules'
|
|
ordering = ['name']
|
|
|
|
def __str__(self):
|
|
return f"{self.name} ({', '.join(self.event_types)})"
|
|
|
|
|
|
class Notification(BaseModel):
|
|
"""
|
|
Individual notification instance sent to a specific recipient.
|
|
"""
|
|
event = models.ForeignKey(
|
|
Event,
|
|
on_delete=models.CASCADE,
|
|
related_name='notifications',
|
|
help_text="Event that triggered this notification"
|
|
)
|
|
rule = models.ForeignKey(
|
|
NotificationRule,
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name='notifications',
|
|
help_text="Rule that generated this notification"
|
|
)
|
|
# Generic foreign key to support both TeamProfile and CustomerProfile
|
|
recipient_content_type = models.ForeignKey(
|
|
ContentType,
|
|
on_delete=models.CASCADE,
|
|
related_name='notifications'
|
|
)
|
|
recipient_object_id = models.UUIDField()
|
|
recipient = GenericForeignKey('recipient_content_type', 'recipient_object_id')
|
|
status = TextChoicesField(
|
|
choices_enum=NotificationStatusChoices,
|
|
default=NotificationStatusChoices.PENDING,
|
|
help_text="Current status of the notification"
|
|
)
|
|
subject = models.CharField(
|
|
max_length=500,
|
|
help_text="Notification subject line"
|
|
)
|
|
body = models.TextField(
|
|
help_text="Notification body content"
|
|
)
|
|
action_url = models.URLField(
|
|
blank=True,
|
|
max_length=500,
|
|
help_text="Optional URL for action button (e.g., link to project detail)"
|
|
)
|
|
read_at = models.DateTimeField(
|
|
null=True,
|
|
blank=True,
|
|
help_text="Timestamp when notification was marked as read"
|
|
)
|
|
metadata = models.JSONField(
|
|
default=dict,
|
|
blank=True,
|
|
help_text="Additional notification metadata"
|
|
)
|
|
|
|
class Meta:
|
|
db_table = 'notifications'
|
|
ordering = ['-created_at']
|
|
indexes = [
|
|
models.Index(fields=['recipient_content_type', 'recipient_object_id', 'status', 'created_at']),
|
|
models.Index(fields=['recipient_content_type', 'recipient_object_id', 'read_at']),
|
|
models.Index(fields=['event']),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"Notification for {self.recipient} - {self.subject}"
|
|
|
|
def mark_as_read(self):
|
|
"""Mark notification as read"""
|
|
if not self.read_at:
|
|
from django.utils import timezone
|
|
self.read_at = timezone.now()
|
|
self.status = NotificationStatusChoices.READ
|
|
self.save(update_fields=['read_at', 'status', 'updated_at'])
|
|
|
|
|
|
class NotificationDelivery(BaseModel):
|
|
"""
|
|
Track delivery attempts for a notification via specific channels.
|
|
"""
|
|
notification = models.ForeignKey(
|
|
Notification,
|
|
on_delete=models.CASCADE,
|
|
related_name='deliveries',
|
|
help_text="Notification being delivered"
|
|
)
|
|
channel = TextChoicesField(
|
|
choices_enum=NotificationChannelChoices,
|
|
help_text="Delivery channel (IN_APP, EMAIL, SMS)"
|
|
)
|
|
status = TextChoicesField(
|
|
choices_enum=DeliveryStatusChoices,
|
|
default=DeliveryStatusChoices.PENDING,
|
|
help_text="Current delivery status"
|
|
)
|
|
attempts = models.PositiveIntegerField(
|
|
default=0,
|
|
help_text="Number of delivery attempts"
|
|
)
|
|
last_attempt_at = models.DateTimeField(
|
|
null=True,
|
|
blank=True,
|
|
help_text="Timestamp of last delivery attempt"
|
|
)
|
|
sent_at = models.DateTimeField(
|
|
null=True,
|
|
blank=True,
|
|
help_text="Timestamp when successfully sent"
|
|
)
|
|
delivered_at = models.DateTimeField(
|
|
null=True,
|
|
blank=True,
|
|
help_text="Timestamp when delivery was confirmed (if supported by channel)"
|
|
)
|
|
error_message = models.TextField(
|
|
blank=True,
|
|
help_text="Error message from failed delivery attempts"
|
|
)
|
|
external_id = models.CharField(
|
|
max_length=200,
|
|
blank=True,
|
|
help_text="External service ID (e.g., Twilio message SID, email message ID)"
|
|
)
|
|
metadata = models.JSONField(
|
|
default=dict,
|
|
blank=True,
|
|
help_text="Additional delivery metadata"
|
|
)
|
|
|
|
class Meta:
|
|
db_table = 'notification_deliveries'
|
|
ordering = ['-created_at']
|
|
indexes = [
|
|
models.Index(fields=['notification', 'channel']),
|
|
models.Index(fields=['status', 'last_attempt_at']),
|
|
models.Index(fields=['channel', 'status']),
|
|
]
|
|
unique_together = [['notification', 'channel']]
|
|
|
|
def __str__(self):
|
|
return f"{self.channel} delivery for notification {self.notification_id} - {self.status}"
|