dev: email notifications (#3421)

* dev: create email notification preference model

* dev: intiate models

* dev: user notification preferences

* dev: create notification logs for the user.

* dev: email notification stacking and sending logic

* feat: email notification preference settings page.

* dev: delete subscribers

* dev: issue update ui implementation in email notification

* chore: integrate email notification endpoint.

* chore: remove toggle switch.

* chore: added labels part

* fix: refactored base design with tables

* dev: email notification templates

* dev: template updates

* dev: update models

* dev: update template for labels and new migrations

* fix: profile settings preference sidebar.

* dev: update preference endpoints

* dev: update the schedule to 5 minutes

* dev: update template with priority data

* dev: update templates

* chore: enable `issue subscribe` button for all users.

* chore: notification handling for external api

* dev: update origin request

---------

Co-authored-by: Prateek Shourya <prateekshourya29@gmail.com>
Co-authored-by: LAKHAN BAHETI <lakhanbaheti9@gmail.com>
Co-authored-by: Ramesh Kumar Chandra <rameshkumar2299@gmail.com>
Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
This commit is contained in:
Nikhil 2024-01-23 17:49:22 +05:30 committed by GitHub
parent c1e1b81b99
commit f27efb80e1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 2482 additions and 314 deletions

View file

@ -115,7 +115,7 @@ from .inbox import (
from .analytic import AnalyticViewSerializer
from .notification import NotificationSerializer
from .notification import NotificationSerializer, UserNotificationPreferenceSerializer
from .exporter import ExporterHistorySerializer

View file

@ -1,7 +1,7 @@
# Module imports
from .base import BaseSerializer
from .user import UserLiteSerializer
from plane.db.models import Notification
from plane.db.models import Notification, UserNotificationPreference
class NotificationSerializer(BaseSerializer):
@ -12,3 +12,10 @@ class NotificationSerializer(BaseSerializer):
class Meta:
model = Notification
fields = "__all__"
class UserNotificationPreferenceSerializer(BaseSerializer):
class Meta:
model = UserNotificationPreference
fields = "__all__"

View file

@ -5,6 +5,7 @@ from plane.app.views import (
NotificationViewSet,
UnreadNotificationEndpoint,
MarkAllReadNotificationViewSet,
UserNotificationPreferenceEndpoint,
)
@ -63,4 +64,9 @@ urlpatterns = [
),
name="mark-all-read-notifications",
),
path(
"users/me/notification-preferences/",
UserNotificationPreferenceEndpoint.as_view(),
name="user-notification-preferences",
),
]

View file

@ -167,6 +167,7 @@ from .notification import (
NotificationViewSet,
UnreadNotificationEndpoint,
MarkAllReadNotificationViewSet,
UserNotificationPreferenceEndpoint,
)
from .exporter import ExportIssuesEndpoint

View file

@ -530,6 +530,8 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
project_id=str(project_id),
current_instance=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
# Delete the cycle
cycle.delete()
@ -721,6 +723,8 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet):
}
),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
# Return all Cycle Issues
@ -753,6 +757,8 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet):
project_id=str(self.kwargs.get("project_id", None)),
current_instance=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
cycle_issue.delete()
return Response(status=status.HTTP_204_NO_CONTENT)

View file

@ -200,6 +200,8 @@ class InboxIssueViewSet(BaseViewSet):
project_id=str(project_id),
current_instance=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
# create an inbox issue
InboxIssue.objects.create(
@ -277,6 +279,8 @@ class InboxIssueViewSet(BaseViewSet):
cls=DjangoJSONEncoder,
),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
issue_serializer.save()
else:

View file

@ -259,6 +259,8 @@ class IssueViewSet(WebhookMixin, BaseViewSet):
project_id=str(project_id),
current_instance=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
issue = (
self.get_queryset().filter(pk=serializer.data["id"]).first()
@ -297,6 +299,8 @@ class IssueViewSet(WebhookMixin, BaseViewSet):
project_id=str(project_id),
current_instance=current_instance,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
issue = self.get_queryset().filter(pk=pk).first()
return Response(
@ -320,6 +324,8 @@ class IssueViewSet(WebhookMixin, BaseViewSet):
project_id=str(project_id),
current_instance=current_instance,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
return Response(status=status.HTTP_204_NO_CONTENT)
@ -528,7 +534,7 @@ class IssueActivityEndpoint(BaseAPIView):
if request.GET.get("activity_type", None) == "issue-property":
return Response(issue_activities, status=status.HTTP_200_OK)
if request.GET.get("activity_type", None) == "issue-comment":
return Response(issue_comments, status=status.HTTP_200_OK)
@ -595,6 +601,8 @@ class IssueCommentViewSet(WebhookMixin, BaseViewSet):
project_id=str(self.kwargs.get("project_id")),
current_instance=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@ -624,6 +632,8 @@ class IssueCommentViewSet(WebhookMixin, BaseViewSet):
project_id=str(project_id),
current_instance=current_instance,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@ -648,6 +658,8 @@ class IssueCommentViewSet(WebhookMixin, BaseViewSet):
project_id=str(project_id),
current_instance=current_instance,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
return Response(status=status.HTTP_204_NO_CONTENT)
@ -850,6 +862,8 @@ class SubIssuesEndpoint(BaseAPIView):
project_id=str(project_id),
current_instance=json.dumps({"parent": str(sub_issue_id)}),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
for sub_issue_id in sub_issue_ids
]
@ -909,6 +923,8 @@ class IssueLinkViewSet(BaseViewSet):
project_id=str(self.kwargs.get("project_id")),
current_instance=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@ -938,6 +954,8 @@ class IssueLinkViewSet(BaseViewSet):
project_id=str(project_id),
current_instance=current_instance,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@ -961,6 +979,8 @@ class IssueLinkViewSet(BaseViewSet):
project_id=str(project_id),
current_instance=current_instance,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
issue_link.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@ -1017,6 +1037,8 @@ class IssueAttachmentEndpoint(BaseAPIView):
cls=DjangoJSONEncoder,
),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@ -1033,6 +1055,8 @@ class IssueAttachmentEndpoint(BaseAPIView):
project_id=str(self.kwargs.get("project_id", None)),
current_instance=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
return Response(status=status.HTTP_204_NO_CONTENT)
@ -1211,6 +1235,8 @@ class IssueArchiveViewSet(BaseViewSet):
IssueSerializer(issue).data, cls=DjangoJSONEncoder
),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
issue.archived_at = None
issue.save()
@ -1356,6 +1382,8 @@ class IssueReactionViewSet(BaseViewSet):
project_id=str(project_id),
current_instance=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@ -1381,6 +1409,8 @@ class IssueReactionViewSet(BaseViewSet):
}
),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
issue_reaction.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@ -1421,6 +1451,8 @@ class CommentReactionViewSet(BaseViewSet):
project_id=str(project_id),
current_instance=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@ -1447,6 +1479,8 @@ class CommentReactionViewSet(BaseViewSet):
}
),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
comment_reaction.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@ -1575,6 +1609,8 @@ class IssueRelationViewSet(BaseViewSet):
project_id=str(project_id),
current_instance=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
if relation_type == "blocking":
@ -1619,6 +1655,8 @@ class IssueRelationViewSet(BaseViewSet):
project_id=str(project_id),
current_instance=current_instance,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
return Response(status=status.HTTP_204_NO_CONTENT)
@ -1790,6 +1828,8 @@ class IssueDraftViewSet(BaseViewSet):
project_id=str(project_id),
current_instance=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@ -1820,6 +1860,8 @@ class IssueDraftViewSet(BaseViewSet):
cls=DjangoJSONEncoder,
),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@ -1846,5 +1888,7 @@ class IssueDraftViewSet(BaseViewSet):
project_id=str(project_id),
current_instance=current_instance,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
return Response(status=status.HTTP_204_NO_CONTENT)

View file

@ -310,6 +310,8 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
project_id=str(project_id),
current_instance=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
module.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@ -488,6 +490,8 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
}
),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
issues = self.get_queryset().values_list("issue_id", flat=True)
@ -519,6 +523,8 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
project_id=str(project_id),
current_instance=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
module_issue.delete()
return Response(status=status.HTTP_204_NO_CONTENT)

View file

@ -1,5 +1,5 @@
# Django imports
from django.db.models import Q
from django.db.models import Q, OuterRef, Exists
from django.utils import timezone
# Third party imports
@ -15,8 +15,9 @@ from plane.db.models import (
IssueSubscriber,
Issue,
WorkspaceMember,
UserNotificationPreference,
)
from plane.app.serializers import NotificationSerializer
from plane.app.serializers import NotificationSerializer, UserNotificationPreferenceSerializer
class NotificationViewSet(BaseViewSet, BasePaginator):
@ -71,11 +72,29 @@ class NotificationViewSet(BaseViewSet, BasePaginator):
# Subscribed issues
if type == "watching":
issue_ids = IssueSubscriber.objects.filter(
workspace__slug=slug, subscriber_id=request.user.id
).values_list("issue_id", flat=True)
issue_ids = (
IssueSubscriber.objects.filter(
workspace__slug=slug, subscriber_id=request.user.id
)
.annotate(
created=Exists(
Issue.objects.filter(
created_by=request.user, pk=OuterRef("issue_id")
)
)
)
.annotate(
assigned=Exists(
IssueAssignee.objects.filter(
pk=OuterRef("issue_id"), assignee=request.user
)
)
)
.filter(created=False, assigned=False)
.values_list("issue_id", flat=True)
)
notifications = notifications.filter(
entity_identifier__in=issue_ids
entity_identifier__in=issue_ids,
)
# Assigned Issues
@ -295,3 +314,31 @@ class MarkAllReadNotificationViewSet(BaseViewSet):
updated_notifications, ["read_at"], batch_size=100
)
return Response({"message": "Successful"}, status=status.HTTP_200_OK)
class UserNotificationPreferenceEndpoint(BaseAPIView):
model = UserNotificationPreference
serializer_class = UserNotificationPreferenceSerializer
# request the object
def get(self, request):
user_notification_preference = UserNotificationPreference.objects.get(
user=request.user
)
serializer = UserNotificationPreferenceSerializer(
user_notification_preference
)
return Response(serializer.data, status=status.HTTP_200_OK)
# update the object
def patch(self, request):
user_notification_preference = UserNotificationPreference.objects.get(
user=request.user
)
serializer = UserNotificationPreferenceSerializer(
user_notification_preference, data=request.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)