feat: notifications (#1363)

* feat: added new issue subscriber table

* dev: notification model

* feat: added CRUD operation for issue subscriber

* Revert "feat: added CRUD operation for issue subscriber"

This reverts commit b22e062576.

* feat: added CRUD operation for issue subscriber

* dev: notification models and operations

* dev: remove delete endpoint response data

* dev: notification endpoints and fix bg worker for saving notifications

* feat: added list and unsubscribe function in issue subscriber

* dev: filter by snoozed and response update for list and permissions

* dev: update issue notifications

* dev: notification  segregation

* dev: update notifications

* dev: notification filtering

* dev: add issue name in notifications

* dev: notification new endpoints

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
This commit is contained in:
pablohashescobar 2023-07-11 13:36:31 +05:30 committed by GitHub
parent ad1a074292
commit abdb4a4778
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 623 additions and 11 deletions

View File

@ -21,6 +21,7 @@ from .project import (
ProjectIdentifierSerializer, ProjectIdentifierSerializer,
ProjectFavoriteSerializer, ProjectFavoriteSerializer,
ProjectLiteSerializer, ProjectLiteSerializer,
ProjectMemberLiteSerializer,
) )
from .state import StateSerializer, StateLiteSerializer from .state import StateSerializer, StateLiteSerializer
from .view import IssueViewSerializer, IssueViewFavoriteSerializer from .view import IssueViewSerializer, IssueViewFavoriteSerializer
@ -41,6 +42,7 @@ from .issue import (
IssueLinkSerializer, IssueLinkSerializer,
IssueLiteSerializer, IssueLiteSerializer,
IssueAttachmentSerializer, IssueAttachmentSerializer,
IssueSubscriberSerializer,
) )
from .module import ( from .module import (
@ -74,4 +76,7 @@ from .estimate import (
) )
from .inbox import InboxSerializer, InboxIssueSerializer, IssueStateInboxSerializer from .inbox import InboxSerializer, InboxIssueSerializer, IssueStateInboxSerializer
from .analytic import AnalyticViewSerializer from .analytic import AnalyticViewSerializer
from .notification import NotificationSerializer

View File

@ -19,6 +19,7 @@ from plane.db.models import (
IssueProperty, IssueProperty,
IssueBlocker, IssueBlocker,
IssueAssignee, IssueAssignee,
IssueSubscriber,
IssueLabel, IssueLabel,
Label, Label,
IssueBlocker, IssueBlocker,
@ -530,3 +531,14 @@ class IssueLiteSerializer(BaseSerializer):
"created_at", "created_at",
"updated_at", "updated_at",
] ]
class IssueSubscriberSerializer(BaseSerializer):
class Meta:
model = IssueSubscriber
fields = "__all__"
read_only_fields = [
"workspace",
"project",
"issue",
]

View File

@ -0,0 +1,10 @@
# Module imports
from .base import BaseSerializer
from plane.db.models import Notification
class NotificationSerializer(BaseSerializer):
class Meta:
model = Notification
fields = "__all__"

View File

@ -134,3 +134,20 @@ class ProjectFavoriteSerializer(BaseSerializer):
"workspace", "workspace",
"user", "user",
] ]
class ProjectLiteSerializer(BaseSerializer):
class Meta:
model = Project
fields = ["id", "identifier", "name"]
read_only_fields = fields
class ProjectMemberLiteSerializer(BaseSerializer):
member = UserLiteSerializer(read_only=True)
is_subscribed = serializers.BooleanField(read_only=True)
class Meta:
model = ProjectMember
fields = ["member", "id", "is_subscribed"]
read_only_fields = fields

View File

@ -76,6 +76,7 @@ from plane.api.views import (
IssueLinkViewSet, IssueLinkViewSet,
BulkCreateIssueLabelsEndpoint, BulkCreateIssueLabelsEndpoint,
IssueAttachmentEndpoint, IssueAttachmentEndpoint,
IssueSubscriberViewSet,
## End Issues ## End Issues
# States # States
StateViewSet, StateViewSet,
@ -148,6 +149,9 @@ from plane.api.views import (
ExportAnalyticsEndpoint, ExportAnalyticsEndpoint,
DefaultAnalyticsEndpoint, DefaultAnalyticsEndpoint,
## End Analytics ## End Analytics
# Notification
NotificationViewSet,
## End Notification
) )
@ -797,6 +801,34 @@ urlpatterns = [
name="project-issue-comment", name="project-issue-comment",
), ),
## End IssueComments ## End IssueComments
# Issue Subscribers
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/issue-subscribers/",
IssueSubscriberViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
name="project-issue-subscribers",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/issue-subscribers/<uuid:subscriber_id>/",
IssueSubscriberViewSet.as_view({"delete": "destroy"}),
name="project-issue-subscribers",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/subscribe/",
IssueSubscriberViewSet.as_view(
{
"get": "subscription_status",
"post": "subscribe",
"delete": "unsubscribe",
}
),
name="project-issue-subscribers",
),
## End Issue Subscribers
## IssueProperty ## IssueProperty
path( path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issue-properties/", "workspaces/<str:slug>/projects/<uuid:project_id>/issue-properties/",
@ -1273,4 +1305,46 @@ urlpatterns = [
name="default-analytics", name="default-analytics",
), ),
## End Analytics ## End Analytics
# Notification
path(
"workspaces/<str:slug>/users/notifications/",
NotificationViewSet.as_view(
{
"get": "list",
}
),
name="notifications",
),
path(
"workspaces/<str:slug>/users/notifications/<uuid:pk>/",
NotificationViewSet.as_view(
{
"get": "retrieve",
"patch": "partial_update",
"delete": "destroy",
}
),
name="notifications",
),
path(
"workspaces/<str:slug>/users/notifications/<uuid:pk>/read/",
NotificationViewSet.as_view(
{
"post": "mark_read",
"delete": "mark_unread",
}
),
name="notifications",
),
path(
"workspaces/<str:slug>/users/notifications/<uuid:pk>/archive/",
NotificationViewSet.as_view(
{
"post": "archive",
"delete": "unarchive",
}
),
name="notifications",
),
## End Notification
] ]

View File

@ -65,6 +65,7 @@ from .issue import (
IssueLinkViewSet, IssueLinkViewSet,
BulkCreateIssueLabelsEndpoint, BulkCreateIssueLabelsEndpoint,
IssueAttachmentEndpoint, IssueAttachmentEndpoint,
IssueSubscriberViewSet,
) )
from .auth_extended import ( from .auth_extended import (
@ -133,6 +134,7 @@ from .estimate import (
from .release import ReleaseNotesEndpoint from .release import ReleaseNotesEndpoint
from .inbox import InboxViewSet, InboxIssueViewSet from .inbox import InboxViewSet, InboxIssueViewSet
from .analytic import ( from .analytic import (
AnalyticsEndpoint, AnalyticsEndpoint,
AnalyticViewViewset, AnalyticViewViewset,
@ -140,3 +142,5 @@ from .analytic import (
ExportAnalyticsEndpoint, ExportAnalyticsEndpoint,
DefaultAnalyticsEndpoint, DefaultAnalyticsEndpoint,
) )
from .notification import NotificationViewSet

View File

@ -15,6 +15,7 @@ from django.db.models import (
Value, Value,
CharField, CharField,
When, When,
Exists,
Max, Max,
) )
from django.core.serializers.json import DjangoJSONEncoder from django.core.serializers.json import DjangoJSONEncoder
@ -43,11 +44,15 @@ from plane.api.serializers import (
IssueLinkSerializer, IssueLinkSerializer,
IssueLiteSerializer, IssueLiteSerializer,
IssueAttachmentSerializer, IssueAttachmentSerializer,
IssueSubscriberSerializer,
ProjectMemberSerializer,
ProjectMemberLiteSerializer,
) )
from plane.api.permissions import ( from plane.api.permissions import (
ProjectEntityPermission, ProjectEntityPermission,
WorkSpaceAdminPermission, WorkSpaceAdminPermission,
ProjectMemberPermission, ProjectMemberPermission,
ProjectLitePermission,
) )
from plane.db.models import ( from plane.db.models import (
Project, Project,
@ -59,6 +64,8 @@ from plane.db.models import (
IssueLink, IssueLink,
IssueAttachment, IssueAttachment,
State, State,
IssueSubscriber,
ProjectMember,
) )
from plane.bgtasks.issue_activites_task import issue_activity from plane.bgtasks.issue_activites_task import issue_activity
from plane.utils.grouper import group_results from plane.utils.grouper import group_results
@ -905,3 +912,156 @@ class IssueAttachmentEndpoint(BaseAPIView):
{"error": "Something went wrong please try again later"}, {"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
class IssueSubscriberViewSet(BaseViewSet):
serializer_class = IssueSubscriberSerializer
model = IssueSubscriber
permission_classes = [
ProjectEntityPermission,
]
def get_permissions(self):
if self.action in ["subscribe", "unsubscribe", "subscription_status"]:
self.permission_classes = [
ProjectLitePermission,
]
else:
self.permission_classes = [
ProjectEntityPermission,
]
return super(IssueSubscriberViewSet, self).get_permissions()
def perform_create(self, serializer):
serializer.save(
project_id=self.kwargs.get("project_id"),
issue_id=self.kwargs.get("issue_id"),
)
def get_queryset(self):
return (
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id"))
.filter(issue_id=self.kwargs.get("issue_id"))
.filter(project__project_projectmember__member=self.request.user)
.order_by("-created_at")
.distinct()
)
def list(self, request, slug, project_id, issue_id):
try:
members = ProjectMember.objects.filter(
workspace__slug=slug, project_id=project_id
).annotate(
is_subscribed=Exists(
IssueSubscriber.objects.filter(
workspace__slug=slug,
project_id=project_id,
issue_id=issue_id,
subscriber=OuterRef("member"),
)
)
).select_related("member")
serializer = ProjectMemberLiteSerializer(members, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
except Exception as e:
capture_exception(e)
return Response(
{"error": e},
status=status.HTTP_400_BAD_REQUEST,
)
def destroy(self, request, slug, project_id, issue_id, subscriber_id):
try:
issue_subscriber = IssueSubscriber.objects.get(
project=project_id,
subscriber=subscriber_id,
workspace__slug=slug,
issue=issue_id,
)
issue_subscriber.delete()
return Response(
status=status.HTTP_204_NO_CONTENT,
)
except IssueSubscriber.DoesNotExist:
return Response(
{"error": "User is not subscribed to this issue"},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
def subscribe(self, request, slug, project_id, issue_id):
try:
if IssueSubscriber.objects.filter(
issue_id=issue_id,
subscriber=request.user,
workspace__slug=slug,
project=project_id,
).exists():
return Response(
{"message": "User already subscribed to the issue."},
status=status.HTTP_400_BAD_REQUEST,
)
subscriber = IssueSubscriber.objects.create(
issue_id=issue_id,
subscriber_id=request.user.id,
project_id=project_id,
)
serilaizer = IssueSubscriberSerializer(subscriber)
return Response(serilaizer.data, status=status.HTTP_201_CREATED)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong, please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
def unsubscribe(self, request, slug, project_id, issue_id):
try:
issue_subscriber = IssueSubscriber.objects.get(
project=project_id,
subscriber=request.user,
workspace__slug=slug,
issue=issue_id,
)
issue_subscriber.delete()
return Response(
status=status.HTTP_204_NO_CONTENT,
)
except IssueSubscriber.DoesNotExist:
return Response(
{"error": "User subscribed to this issue"},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
def subscription_status(self, request, slug, project_id, issue_id):
try:
issue_subscriber = IssueSubscriber.objects.filter(
issue=issue_id,
subscriber=request.user,
workspace__slug=slug,
project=project_id,
).exists()
return Response({"subscribed": issue_subscriber}, status=status.HTTP_200_OK)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong, please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)

View File

@ -0,0 +1,205 @@
# Django imports
from django.db.models import Q
from django.utils import timezone
# Third party imports
from rest_framework import status
from rest_framework.response import Response
from sentry_sdk import capture_exception
# Module imports
from .base import BaseViewSet
from plane.db.models import Notification, IssueAssignee, IssueSubscriber, Issue
from plane.api.serializers import NotificationSerializer
class NotificationViewSet(BaseViewSet):
model = Notification
serializer_class = NotificationSerializer
def get_queryset(self):
return (
super()
.get_queryset()
.filter(
workspace__slug=self.kwargs.get("slug"),
receiver_id=self.request.user.id,
)
.select_related("workspace")
)
def list(self, request, slug):
try:
order_by = request.GET.get("order_by", "-created_at")
snoozed = request.GET.get("snoozed", "false")
archived = request.GET.get("archived", "false")
# Filter type
type = request.GET.get("type", "all")
notifications = Notification.objects.filter(
workspace__slug=slug, receiver_id=request.user.id
).order_by(order_by)
# Filter for snoozed notifications
if snoozed == "false":
notifications = notifications.filter(
Q(snoozed_till__gte=timezone.now()) | Q(snoozed_till__isnull=True),
)
if snoozed == "true":
notifications = notifications.filter(
snoozed_till__lt=timezone.now(),
)
# Filter for archived or unarchive
if archived == "true":
notifications = notifications.filter(archived_at__isnull=True)
if archived == "false":
notifications = notifications.filter(archived_at__isnull=False)
# Subscribed issues
if type == "watching":
issue_ids = IssueSubscriber.objects.filter(
workspace__slug=slug, subsriber_id=request.user.id
).values_list("issue_id", flat=True)
notifications = notifications.filter(entity_identifier__in=issue_ids)
# Assigned Issues
if type == "assigned":
issue_ids = IssueAssignee.objects.filter(
workspace__slug=slug, assignee_id=request.user.id
).values_list("issue_id", flat=True)
notifications = notifications.filter(entity_identifier__in=issue_ids)
# Created issues
if type == "created":
issue_ids = Issue.objects.filter(
workspace__slug=slug, created_by=request.user
).values_list("pk", flat=True)
notifications = notifications.filter(entity_identifier__in=issue_ids)
serializer = NotificationSerializer(notifications, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
def partial_update(self, request, slug, pk):
try:
notification = Notification.objects.get(
workspace__slug=slug, pk=pk, receiver=request.user
)
# Only read_at and snoozed_till can be updated
notification_data = {
"snoozed_till": request.data.get("snoozed_till", None),
}
serializer = NotificationSerializer(
notification, data=notification_data, partial=True
)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
except Notification.DoesNotExist:
return Response(
{"error": "Notification does not exists"},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
def mark_read(self, request, slug, pk):
try:
notification = Notification.objects.get(
receiver=request.user, workspace__slug=slug, pk=pk
)
notification.read_at = timezone.now()
notification.save()
serializer = NotificationSerializer(notification)
return Response(serializer.data, status=status.HTTP_200_OK)
except Notification.DoesNotExist:
return Response(
{"error": "Notification does not exists"},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
def mark_unread(self, request, slug, pk):
try:
notification = Notification.objects.get(
receiver=request.user, workspace__slug=slug, pk=pk
)
notification.read_at = None
notification.save()
serializer = NotificationSerializer(notification)
return Response(serializer.data, status=status.HTTP_200_OK)
except Notification.DoesNotExist:
return Response(
{"error": "Notification does not exists"},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
def archive(self, request, slug, pk):
try:
notification = Notification.objects.get(
receiver=request.user, workspace__slug=slug, pk=pk
)
notification.archived_at = timezone.now()
notification.save()
serializer = NotificationSerializer(notification)
return Response(serializer.data, status=status.HTTP_200_OK)
except Notification.DoesNotExist:
return Response(
{"error": "Notification does not exists"},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
def unarchive(self, request, slug, pk):
try:
notification = Notification.objects.get(
receiver=request.user, workspace__slug=slug, pk=pk
)
notification.archived_at = None
notification.save()
serializer = NotificationSerializer(notification)
return Response(serializer.data, status=status.HTTP_200_OK)
except Notification.DoesNotExist:
return Response(
{"error": "Notification does not exists"},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)

View File

@ -20,6 +20,9 @@ from plane.db.models import (
State, State,
Cycle, Cycle,
Module, Module,
IssueSubscriber,
Notification,
IssueAssignee,
) )
from plane.api.serializers import IssueActivitySerializer from plane.api.serializers import IssueActivitySerializer
@ -958,6 +961,12 @@ def issue_activity(
actor = User.objects.get(pk=actor_id) actor = User.objects.get(pk=actor_id)
project = Project.objects.get(pk=project_id) project = Project.objects.get(pk=project_id)
# add the user to issue subscriber
try:
_ = IssueSubscriber.objects.create(issue_id=issue_id, subscriber=actor)
except Exception as e:
pass
ACTIVITY_MAPPER = { ACTIVITY_MAPPER = {
"issue.activity.created": create_issue_activity, "issue.activity.created": create_issue_activity,
"issue.activity.updated": update_issue_activity, "issue.activity.updated": update_issue_activity,
@ -992,18 +1001,72 @@ def issue_activity(
# Post the updates to segway for integrations and webhooks # Post the updates to segway for integrations and webhooks
if len(issue_activities_created): if len(issue_activities_created):
# Don't send activities if the actor is a bot # Don't send activities if the actor is a bot
if settings.PROXY_BASE_URL: try:
for issue_activity in issue_activities_created: if settings.PROXY_BASE_URL:
headers = {"Content-Type": "application/json"} for issue_activity in issue_activities_created:
issue_activity_json = json.dumps( headers = {"Content-Type": "application/json"}
IssueActivitySerializer(issue_activity).data, issue_activity_json = json.dumps(
cls=DjangoJSONEncoder, IssueActivitySerializer(issue_activity).data,
) cls=DjangoJSONEncoder,
_ = requests.post( )
f"{settings.PROXY_BASE_URL}/hooks/workspaces/{str(issue_activity.workspace_id)}/projects/{str(issue_activity.project_id)}/issues/{str(issue_activity.issue_id)}/issue-activity-hooks/", _ = requests.post(
json=issue_activity_json, f"{settings.PROXY_BASE_URL}/hooks/workspaces/{str(issue_activity.workspace_id)}/projects/{str(issue_activity.project_id)}/issues/{str(issue_activity.issue_id)}/issue-activity-hooks/",
headers=headers, json=issue_activity_json,
headers=headers,
)
except Exception as e:
capture_exception(e)
# Create Notifications
bulk_notifications = []
issue_subscribers = list(
IssueSubscriber.objects.filter(project=project, issue_id=issue_id)
.exclude(subscriber_id=actor_id)
.values_list("subscriber", flat=True)
)
issue_assignees = list(
IssueAssignee.objects.filter(project=project, issue_id=issue_id)
.exclude(assignee_id=actor_id)
.values_list("assignee", flat=True)
)
issue_subscribers = issue_subscribers + issue_assignees
if issue.created_by_id:
issue_subscribers = issue_subscribers + [issue.created_by_id]
issue = Issue.objects.get(project=project, pk=issue_id)
for subscriber in issue_subscribers:
for issue_activity in issue_activities_created:
bulk_notifications.append(
Notification(
workspace=project.workspace,
sender="in_app:issue_activities",
triggered_by_id=actor_id,
receiver_id=subscriber,
entity_identifier=issue_id,
entity_name="issue",
project=project,
title=issue_activity.comment,
data={
"issue": {
"id": str(issue_id),
"name": str(issue.name),
"identifier": str(project.identifier),
"sequence_id": issue.sequence_id,
"state_name": issue.state.name,
"state_group": issue.state.group,
},
"issue_activity": str(issue_activity.id),
},
) )
)
# Bulk create notifications
Notification.objects.bulk_create(bulk_notifications, batch_size=100)
return return
except Exception as e: except Exception as e:
# Print logs if in DEBUG mode # Print logs if in DEBUG mode

View File

@ -33,6 +33,7 @@ from .issue import (
IssueLink, IssueLink,
IssueSequence, IssueSequence,
IssueAttachment, IssueAttachment,
IssueSubscriber,
) )
from .asset import FileAsset from .asset import FileAsset
@ -66,4 +67,7 @@ from .page import Page, PageBlock, PageFavorite, PageLabel
from .estimate import Estimate, EstimatePoint from .estimate import Estimate, EstimatePoint
from .inbox import Inbox, InboxIssue from .inbox import Inbox, InboxIssue
from .analytic import AnalyticView from .analytic import AnalyticView
from .notification import Notification

View File

@ -401,6 +401,27 @@ class IssueSequence(ProjectBaseModel):
ordering = ("-created_at",) ordering = ("-created_at",)
class IssueSubscriber(ProjectBaseModel):
issue = models.ForeignKey(
Issue, on_delete=models.CASCADE, related_name="issue_subscribers"
)
subscriber = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="issue_subscribers",
)
class Meta:
unique_together = ["issue", "subscriber"]
verbose_name = "Issue Subscriber"
verbose_name_plural = "Issue Subscribers"
db_table = "issue_subscribers"
ordering = ("-created_at",)
def __str__(self):
return f"{self.issue.name} {self.subscriber.email}"
# TODO: Find a better method to save the model # TODO: Find a better method to save the model
@receiver(post_save, sender=Issue) @receiver(post_save, sender=Issue)
def create_issue_sequence(sender, instance, created, **kwargs): def create_issue_sequence(sender, instance, created, **kwargs):

View File

@ -0,0 +1,37 @@
# Django imports
from django.db import models
# Third party imports
from .base import BaseModel
class Notification(BaseModel):
workspace = models.ForeignKey(
"db.Workspace", related_name="notifications", on_delete=models.CASCADE
)
project = models.ForeignKey(
"db.Project", related_name="notifications", on_delete=models.CASCADE, null=True
)
data = models.JSONField(null=True)
entity_identifier = models.UUIDField(null=True)
entity_name = models.CharField(max_length=255)
title = models.TextField()
message = models.JSONField(null=True)
message_html = models.TextField(blank=True, default="<p></p>")
message_stripped = models.TextField(blank=True, null=True)
sender = models.CharField(max_length=255)
triggered_by = models.ForeignKey("db.User", related_name="triggered_notifications", on_delete=models.SET_NULL, null=True)
receiver = models.ForeignKey("db.User", related_name="received_notifications", on_delete=models.CASCADE)
read_at = models.DateTimeField(null=True)
snoozed_till = models.DateTimeField(null=True)
archived_at = models.DateTimeField(null=True)
class Meta:
verbose_name = "Notification"
verbose_name_plural = "Notifications"
db_table = "notifications"
ordering = ("-created_at",)
def __str__(self):
"""Return name of the notifications"""
return f"{self.receiver.email} <{self.workspace.name}>"