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 .analytic import AnalyticViewSerializer
from .notification import NotificationSerializer from .notification import NotificationSerializer, UserNotificationPreferenceSerializer
from .exporter import ExporterHistorySerializer from .exporter import ExporterHistorySerializer

View File

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

View File

@ -5,6 +5,7 @@ from plane.app.views import (
NotificationViewSet, NotificationViewSet,
UnreadNotificationEndpoint, UnreadNotificationEndpoint,
MarkAllReadNotificationViewSet, MarkAllReadNotificationViewSet,
UserNotificationPreferenceEndpoint,
) )
@ -63,4 +64,9 @@ urlpatterns = [
), ),
name="mark-all-read-notifications", 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, NotificationViewSet,
UnreadNotificationEndpoint, UnreadNotificationEndpoint,
MarkAllReadNotificationViewSet, MarkAllReadNotificationViewSet,
UserNotificationPreferenceEndpoint,
) )
from .exporter import ExportIssuesEndpoint from .exporter import ExportIssuesEndpoint

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,243 @@
import json
from datetime import datetime
# Third party imports
from celery import shared_task
# Django imports
from django.utils import timezone
from django.core.mail import EmailMultiAlternatives, get_connection
from django.template.loader import render_to_string
from django.utils.html import strip_tags
from django.conf import settings
# Module imports
from plane.db.models import EmailNotificationLog, User, Issue
from plane.license.utils.instance_value import get_email_configuration
from plane.settings.redis import redis_instance
@shared_task
def stack_email_notification():
# get all email notifications
email_notifications = (
EmailNotificationLog.objects.filter(processed_at__isnull=True)
.order_by("receiver")
.values()
)
# Create the below format for each of the issues
# {"issue_id" : { "actor_id1": [ { data }, { data } ], "actor_id2": [ { data }, { data } ] }}
# Convert to unique receivers list
receivers = list(
set(
[
str(notification.get("receiver_id"))
for notification in email_notifications
]
)
)
processed_notifications = []
# Loop through all the issues to create the emails
for receiver_id in receivers:
# Notifcation triggered for the receiver
receiver_notifications = [
notification
for notification in email_notifications
if str(notification.get("receiver_id")) == receiver_id
]
# create payload for all issues
payload = {}
email_notification_ids = []
for receiver_notification in receiver_notifications:
payload.setdefault(
receiver_notification.get("entity_identifier"), {}
).setdefault(
str(receiver_notification.get("triggered_by_id")), []
).append(
receiver_notification.get("data")
)
# append processed notifications
processed_notifications.append(receiver_notification.get("id"))
email_notification_ids.append(receiver_notification.get("id"))
# Create emails for all the issues
for issue_id, notification_data in payload.items():
send_email_notification.delay(
issue_id=issue_id,
notification_data=notification_data,
receiver_id=receiver_id,
email_notification_ids=email_notification_ids,
)
# Update the email notification log
EmailNotificationLog.objects.filter(pk__in=processed_notifications).update(
processed_at=timezone.now()
)
def create_payload(notification_data):
# return format {"actor_id": { "key": { "old_value": [], "new_value": [] } }}
data = {}
for actor_id, changes in notification_data.items():
for change in changes:
issue_activity = change.get("issue_activity")
if issue_activity: # Ensure issue_activity is not None
field = issue_activity.get("field")
old_value = str(issue_activity.get("old_value"))
new_value = str(issue_activity.get("new_value"))
# Append old_value if it's not empty and not already in the list
if old_value:
data.setdefault(actor_id, {}).setdefault(
field, {}
).setdefault("old_value", []).append(
old_value
) if old_value not in data.setdefault(
actor_id, {}
).setdefault(
field, {}
).get(
"old_value", []
) else None
# Append new_value if it's not empty and not already in the list
if new_value:
data.setdefault(actor_id, {}).setdefault(
field, {}
).setdefault("new_value", []).append(
new_value
) if new_value not in data.setdefault(
actor_id, {}
).setdefault(
field, {}
).get(
"new_value", []
) else None
if not data.get("actor_id", {}).get("activity_time", False):
data[actor_id]["activity_time"] = str(
datetime.fromisoformat(
issue_activity.get("activity_time").rstrip("Z")
).strftime("%Y-%m-%d %H:%M:%S")
)
return data
@shared_task
def send_email_notification(
issue_id, notification_data, receiver_id, email_notification_ids
):
ri = redis_instance()
base_api = (ri.get(str(issue_id)).decode())
data = create_payload(notification_data=notification_data)
# Get email configurations
(
EMAIL_HOST,
EMAIL_HOST_USER,
EMAIL_HOST_PASSWORD,
EMAIL_PORT,
EMAIL_USE_TLS,
EMAIL_FROM,
) = get_email_configuration()
receiver = User.objects.get(pk=receiver_id)
issue = Issue.objects.get(pk=issue_id)
template_data = []
total_changes = 0
comments = []
for actor_id, changes in data.items():
actor = User.objects.get(pk=actor_id)
total_changes = total_changes + len(changes)
comment = changes.pop("comment", False)
if comment:
comments.append(
{
"actor_comments": comment,
"actor_detail": {
"avatar_url": actor.avatar,
"first_name": actor.first_name,
"last_name": actor.last_name,
},
}
)
activity_time = changes.pop("activity_time")
template_data.append(
{
"actor_detail": {
"avatar_url": actor.avatar,
"first_name": actor.first_name,
"last_name": actor.last_name,
},
"changes": changes,
"issue_details": {
"name": issue.name,
"identifier": f"{issue.project.identifier}-{issue.sequence_id}",
},
"activity_time": str(activity_time),
}
)
span = f"""<span style='
font-size: 1rem;
font-weight: 700;
line-height: 28px;
"
>
{template_data[0]['actor_detail']['first_name']} {template_data[0]['actor_detail']['last_name']}
</span>"""
summary = "updates were made to the issue by"
# Send the mail
subject = f"{issue.project.identifier}-{issue.sequence_id} {issue.name}"
context = {
"data": template_data,
"summary": summary,
"issue": {
"issue_identifier": f"{str(issue.project.identifier)}-{str(issue.sequence_id)}",
"name": issue.name,
"issue_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/{str(issue.id)}",
},
"receiver": {
"email": receiver.email,
},
"issue_unsubscribe": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/{str(issue.id)}",
"user_preference": f"{base_api}/profile/preferences/email",
"comments": comments,
}
print(json.dumps(context))
html_content = render_to_string(
"emails/notifications/issue-updates.html", context
)
text_content = strip_tags(html_content)
try:
connection = get_connection(
host=EMAIL_HOST,
port=int(EMAIL_PORT),
username=EMAIL_HOST_USER,
password=EMAIL_HOST_PASSWORD,
use_tls=EMAIL_USE_TLS == "1",
)
msg = EmailMultiAlternatives(
subject=subject,
body=text_content,
from_email=EMAIL_FROM,
to=[receiver.email],
connection=connection,
)
msg.attach_alternative(html_content, "text/html")
msg.send()
EmailNotificationLog.objects.filter(
pk__in=email_notification_ids
).update(sent_at=timezone.now())
print("Email Sent")
return
except Exception as e:
print(e)
return

View File

@ -24,6 +24,7 @@ from plane.db.models import (
Label, Label,
User, User,
IssueProperty, IssueProperty,
UserNotificationPreference,
) )
@ -50,10 +51,24 @@ def service_importer(service, importer_id):
for user in users for user in users
if user.get("import", False) == "invite" if user.get("import", False) == "invite"
], ],
batch_size=10, batch_size=100,
ignore_conflicts=True, ignore_conflicts=True,
) )
_ = UserNotificationPreference.objects.bulk_create(
[UserNotificationPreference(user=user) for user in new_users],
batch_size=100,
)
_ = [
send_welcome_slack.delay(
str(user.id),
True,
f"{user.email} was imported to Plane from {service}",
)
for user in new_users
]
workspace_users = User.objects.filter( workspace_users = User.objects.filter(
email__in=[ email__in=[
user.get("email").strip().lower() user.get("email").strip().lower()

View File

@ -24,10 +24,11 @@ from plane.db.models import (
IssueReaction, IssueReaction,
CommentReaction, CommentReaction,
IssueComment, IssueComment,
IssueSubscriber,
) )
from plane.app.serializers import IssueActivitySerializer from plane.app.serializers import IssueActivitySerializer
from plane.bgtasks.notification_task import notifications from plane.bgtasks.notification_task import notifications
from plane.settings.redis import redis_instance
# Track Changes in name # Track Changes in name
def track_name( def track_name(
@ -190,7 +191,9 @@ def track_state(
): ):
if current_instance.get("state_id") != requested_data.get("state_id"): if current_instance.get("state_id") != requested_data.get("state_id"):
new_state = State.objects.get(pk=requested_data.get("state_id", None)) new_state = State.objects.get(pk=requested_data.get("state_id", None))
old_state = State.objects.get(pk=current_instance.get("state_id", None)) old_state = State.objects.get(
pk=current_instance.get("state_id", None)
)
issue_activities.append( issue_activities.append(
IssueActivity( IssueActivity(
@ -359,6 +362,7 @@ def track_assignees(
added_assignees = requested_assignees - current_assignees added_assignees = requested_assignees - current_assignees
dropped_assginees = current_assignees - requested_assignees dropped_assginees = current_assignees - requested_assignees
bulk_subscribers = []
for added_asignee in added_assignees: for added_asignee in added_assignees:
assignee = User.objects.get(pk=added_asignee) assignee = User.objects.get(pk=added_asignee)
issue_activities.append( issue_activities.append(
@ -376,6 +380,21 @@ def track_assignees(
epoch=epoch, epoch=epoch,
) )
) )
bulk_subscribers.append(
IssueSubscriber(
subscriber_id=assignee.id,
issue_id=issue_id,
workspace_id=workspace_id,
project_id=project_id,
created_by_id=assignee.id,
updated_by_id=assignee.id,
)
)
# Create assignees subscribers to the issue and ignore if already
IssueSubscriber.objects.bulk_create(
bulk_subscribers, batch_size=10, ignore_conflicts=True
)
for dropped_assignee in dropped_assginees: for dropped_assignee in dropped_assginees:
assignee = User.objects.get(pk=dropped_assignee) assignee = User.objects.get(pk=dropped_assignee)
@ -1543,6 +1562,8 @@ def issue_activity(
project_id, project_id,
epoch, epoch,
subscriber=True, subscriber=True,
notification=False,
origin=None,
): ):
try: try:
issue_activities = [] issue_activities = []
@ -1551,6 +1572,10 @@ def issue_activity(
workspace_id = project.workspace_id workspace_id = project.workspace_id
if issue_id is not None: if issue_id is not None:
if origin:
ri = redis_instance()
# set the request origin in redis
ri.set(str(issue_id), origin, ex=600)
issue = Issue.objects.filter(pk=issue_id).first() issue = Issue.objects.filter(pk=issue_id).first()
if issue: if issue:
try: try:
@ -1624,6 +1649,8 @@ def issue_activity(
except Exception as e: except Exception as e:
capture_exception(e) capture_exception(e)
if notification:
notifications.delay( notifications.delay(
type=type, type=type,
issue_id=issue_id, issue_id=issue_id,

View File

@ -87,6 +87,7 @@ def archive_old_issues():
current_instance=json.dumps({"archived_at": None}), current_instance=json.dumps({"archived_at": None}),
subscriber=False, subscriber=False,
epoch=int(timezone.now().timestamp()), epoch=int(timezone.now().timestamp()),
notification=True,
) )
for issue in issues_to_update for issue in issues_to_update
] ]
@ -169,6 +170,7 @@ def close_old_issues():
current_instance=None, current_instance=None,
subscriber=False, subscriber=False,
epoch=int(timezone.now().timestamp()), epoch=int(timezone.now().timestamp()),
notification=True,
) )
for issue in issues_to_update for issue in issues_to_update
] ]

View File

@ -10,9 +10,12 @@ from plane.db.models import (
User, User,
IssueAssignee, IssueAssignee,
Issue, Issue,
State,
EmailNotificationLog,
Notification, Notification,
IssueComment, IssueComment,
IssueActivity, IssueActivity,
UserNotificationPreference,
) )
# Third Party imports # Third Party imports
@ -20,7 +23,7 @@ from celery import shared_task
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
# =========== Issue Description Html Parsing and Notification Functions ====================== # =========== Issue Description Html Parsing and notification Functions ======================
def update_mentions_for_issue(issue, project, new_mentions, removed_mention): def update_mentions_for_issue(issue, project, new_mentions, removed_mention):
@ -37,9 +40,7 @@ def update_mentions_for_issue(issue, project, new_mentions, removed_mention):
) )
IssueMention.objects.bulk_create(aggregated_issue_mentions, batch_size=100) IssueMention.objects.bulk_create(aggregated_issue_mentions, batch_size=100)
IssueMention.objects.filter( IssueMention.objects.filter(issue=issue, mention__in=removed_mention).delete()
issue=issue, mention__in=removed_mention
).delete()
def get_new_mentions(requested_instance, current_instance): def get_new_mentions(requested_instance, current_instance):
@ -60,8 +61,6 @@ def get_new_mentions(requested_instance, current_instance):
# Get Removed Mention # Get Removed Mention
def get_removed_mentions(requested_instance, current_instance): def get_removed_mentions(requested_instance, current_instance):
# requested_data is the newer instance of the current issue # requested_data is the newer instance of the current issue
# current_instance is the older instance of the current issue, saved in the database # current_instance is the older instance of the current issue, saved in the database
@ -79,8 +78,6 @@ def get_removed_mentions(requested_instance, current_instance):
# Adds mentions as subscribers # Adds mentions as subscribers
def extract_mentions_as_subscribers(project_id, issue_id, mentions): def extract_mentions_as_subscribers(project_id, issue_id, mentions):
# mentions is an array of User IDs representing the FILTERED set of mentioned users # mentions is an array of User IDs representing the FILTERED set of mentioned users
@ -95,9 +92,7 @@ def extract_mentions_as_subscribers(project_id, issue_id, mentions):
project_id=project_id, project_id=project_id,
).exists() ).exists()
and not IssueAssignee.objects.filter( and not IssueAssignee.objects.filter(
project_id=project_id, project_id=project_id, issue_id=issue_id, assignee_id=mention_id
issue_id=issue_id,
assignee_id=mention_id,
).exists() ).exists()
and not Issue.objects.filter( and not Issue.objects.filter(
project_id=project_id, pk=issue_id, created_by_id=mention_id project_id=project_id, pk=issue_id, created_by_id=mention_id
@ -125,9 +120,7 @@ def extract_mentions(issue_instance):
data = json.loads(issue_instance) data = json.loads(issue_instance)
html = data.get("description_html") html = data.get("description_html")
soup = BeautifulSoup(html, "html.parser") soup = BeautifulSoup(html, "html.parser")
mention_tags = soup.find_all( mention_tags = soup.find_all("mention-component", attrs={"target": "users"})
"mention-component", attrs={"target": "users"}
)
mentions = [mention_tag["id"] for mention_tag in mention_tags] mentions = [mention_tag["id"] for mention_tag in mention_tags]
@ -136,14 +129,12 @@ def extract_mentions(issue_instance):
return [] return []
# =========== Comment Parsing and Notification Functions ====================== # =========== Comment Parsing and notification Functions ======================
def extract_comment_mentions(comment_value): def extract_comment_mentions(comment_value):
try: try:
mentions = [] mentions = []
soup = BeautifulSoup(comment_value, "html.parser") soup = BeautifulSoup(comment_value, "html.parser")
mentions_tags = soup.find_all( mentions_tags = soup.find_all("mention-component", attrs={"target": "users"})
"mention-component", attrs={"target": "users"}
)
for mention_tag in mentions_tags: for mention_tag in mentions_tags:
mentions.append(mention_tag["id"]) mentions.append(mention_tag["id"])
return list(set(mentions)) return list(set(mentions))
@ -165,14 +156,8 @@ def get_new_comment_mentions(new_value, old_value):
return new_mentions return new_mentions
def createMentionNotification( def create_mention_notification(
project, project, notification_comment, issue, actor_id, mention_id, issue_id, activity
notification_comment,
issue,
actor_id,
mention_id,
issue_id,
activity,
): ):
return Notification( return Notification(
workspace=project.workspace, workspace=project.workspace,
@ -215,6 +200,7 @@ def notifications(
requested_data, requested_data,
current_instance, current_instance,
): ):
try:
issue_activities_created = ( issue_activities_created = (
json.loads(issue_activities_created) json.loads(issue_activities_created)
if issue_activities_created is not None if issue_activities_created is not None
@ -238,6 +224,7 @@ def notifications(
]: ]:
# Create Notifications # Create Notifications
bulk_notifications = [] bulk_notifications = []
bulk_email_logs = []
""" """
Mention Tasks Mention Tasks
@ -259,7 +246,9 @@ def notifications(
all_comment_mentions = [] all_comment_mentions = []
# Get New Subscribers from the mentions of the newer instance # Get New Subscribers from the mentions of the newer instance
requested_mentions = extract_mentions(issue_instance=requested_data) requested_mentions = extract_mentions(
issue_instance=requested_data
)
mention_subscribers = extract_mentions_as_subscribers( mention_subscribers = extract_mentions_as_subscribers(
project_id=project_id, project_id=project_id,
issue_id=issue_id, issue_id=issue_id,
@ -296,14 +285,7 @@ def notifications(
- When the activity is a comment_updated and there exist a mention change, then also we have to send the "mention_in_comment" notification - When the activity is a comment_updated and there exist a mention change, then also we have to send the "mention_in_comment" notification
""" """
issue_assignees = list( # ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- #
IssueAssignee.objects.filter(
project_id=project_id, issue_id=issue_id
)
.exclude(assignee_id__in=list(new_mentions + comment_mentions))
.values_list("assignee", flat=True)
)
issue_subscribers = list( issue_subscribers = list(
IssueSubscriber.objects.filter( IssueSubscriber.objects.filter(
project_id=project_id, issue_id=issue_id project_id=project_id, issue_id=issue_id
@ -318,56 +300,85 @@ def notifications(
issue = Issue.objects.filter(pk=issue_id).first() issue = Issue.objects.filter(pk=issue_id).first()
if issue.created_by_id is not None and str(issue.created_by_id) != str(
actor_id
):
issue_subscribers = issue_subscribers + [issue.created_by_id]
if subscriber: if subscriber:
# add the user to issue subscriber # add the user to issue subscriber
try: try:
if (
str(issue.created_by_id) != str(actor_id)
and uuid.UUID(actor_id) not in issue_assignees
):
_ = IssueSubscriber.objects.get_or_create( _ = IssueSubscriber.objects.get_or_create(
project_id=project_id, project_id=project_id, issue_id=issue_id, subscriber_id=actor_id
issue_id=issue_id,
subscriber_id=actor_id,
) )
except Exception as e: except Exception as e:
pass pass
project = Project.objects.get(pk=project_id) project = Project.objects.get(pk=project_id)
issue_assignees = IssueAssignee.objects.filter(
issue_id=issue_id, project_id=project_id
).values_list("assignee", flat=True)
issue_subscribers = list( issue_subscribers = list(
set(issue_subscribers + issue_assignees) - {uuid.UUID(actor_id)} set(issue_subscribers) - {uuid.UUID(actor_id)}
) )
for subscriber in issue_subscribers: for subscriber in issue_subscribers:
if subscriber in issue_subscribers: if issue.created_by_id and issue.created_by_id == subscriber:
sender = "in_app:issue_activities:subscribed"
if (
issue.created_by_id is not None
and subscriber == issue.created_by_id
):
sender = "in_app:issue_activities:created" sender = "in_app:issue_activities:created"
if subscriber in issue_assignees: elif (
subscriber in issue_assignees
and issue.created_by_id not in issue_assignees
):
sender = "in_app:issue_activities:assigned" sender = "in_app:issue_activities:assigned"
else:
sender = "in_app:issue_activities:subscribed"
preference = UserNotificationPreference.objects.get(
user_id=subscriber
)
for issue_activity in issue_activities_created: for issue_activity in issue_activities_created:
# Do not send notification for description update # Do not send notification for description update
if issue_activity.get("field") == "description": if issue_activity.get("field") == "description":
continue continue
issue_comment = issue_activity.get("issue_comment")
if issue_comment is not None: # Check if the value should be sent or not
issue_comment = IssueComment.objects.get( send_email = False
id=issue_comment, if (
issue_activity.get("field") == "state"
and preference.state_change
):
send_email = True
elif (
issue_activity.get("field") == "state"
and preference.issue_completed
and State.objects.filter(
project_id=project_id,
pk=issue_activity.get("new_identifier"),
group="completed",
).exists()
):
send_email = True
elif (
issue_activity.get("field") == "comment"
and preference.comment
):
send_email = True
elif preference.property_change:
send_email = True
else:
send_email = False
# If activity is of issue comment fetch the comment
issue_comment = (
IssueComment.objects.filter(
id=issue_activity.get("issue_comment"),
issue_id=issue_id, issue_id=issue_id,
project_id=project_id, project_id=project_id,
workspace_id=project.workspace_id, workspace_id=project.workspace_id,
).first()
if issue_activity.get("issue_comment")
else None
) )
# Create in app notification
bulk_notifications.append( bulk_notifications.append(
Notification( Notification(
workspace=project.workspace, workspace=project.workspace,
@ -382,7 +393,9 @@ def notifications(
"issue": { "issue": {
"id": str(issue_id), "id": str(issue_id),
"name": str(issue.name), "name": str(issue.name),
"identifier": str(issue.project.identifier), "identifier": str(
issue.project.identifier
),
"sequence_id": issue.sequence_id, "sequence_id": issue.sequence_id,
"state_name": issue.state.name, "state_name": issue.state.name,
"state_group": issue.state.group, "state_group": issue.state.group,
@ -391,7 +404,9 @@ def notifications(
"id": str(issue_activity.get("id")), "id": str(issue_activity.get("id")),
"verb": str(issue_activity.get("verb")), "verb": str(issue_activity.get("verb")),
"field": str(issue_activity.get("field")), "field": str(issue_activity.get("field")),
"actor": str(issue_activity.get("actor_id")), "actor": str(
issue_activity.get("actor_id")
),
"new_value": str( "new_value": str(
issue_activity.get("new_value") issue_activity.get("new_value")
), ),
@ -400,18 +415,71 @@ def notifications(
), ),
"issue_comment": str( "issue_comment": str(
issue_comment.comment_stripped issue_comment.comment_stripped
if issue_activity.get("issue_comment") if issue_comment is not None
is not None
else "" else ""
), ),
}, },
}, },
) )
) )
# Create email notification
if send_email:
bulk_email_logs.append(
EmailNotificationLog(
triggered_by_id=actor_id,
receiver_id=subscriber,
entity_identifier=issue_id,
entity_name="issue",
data={
"issue": {
"id": str(issue_id),
"name": str(issue.name),
"identifier": str(
issue.project.identifier
),
"project_id": str(issue.project.id),
"workspace_slug": str(
issue.project.workspace.slug
),
"sequence_id": issue.sequence_id,
"state_name": issue.state.name,
"state_group": issue.state.group,
},
"issue_activity": {
"id": str(issue_activity.get("id")),
"verb": str(
issue_activity.get("verb")
),
"field": str(
issue_activity.get("field")
),
"actor": str(
issue_activity.get("actor_id")
),
"new_value": str(
issue_activity.get("new_value")
),
"old_value": str(
issue_activity.get("old_value")
),
"issue_comment": str(
issue_comment.comment_stripped
if issue_comment is not None
else ""
),
"activity_time": issue_activity.get("created_at"),
},
},
)
)
# ----------------------------------------------------------------------------------------------------------------- #
# Add Mentioned as Issue Subscribers # Add Mentioned as Issue Subscribers
IssueSubscriber.objects.bulk_create( IssueSubscriber.objects.bulk_create(
mention_subscribers + comment_mention_subscribers, batch_size=100 mention_subscribers + comment_mention_subscribers,
batch_size=100,
ignore_conflicts=True,
) )
last_activity = ( last_activity = (
@ -424,8 +492,11 @@ def notifications(
for mention_id in comment_mentions: for mention_id in comment_mentions:
if mention_id != actor_id: if mention_id != actor_id:
preference = UserNotificationPreference.objects.get(
user_id=mention_id
)
for issue_activity in issue_activities_created: for issue_activity in issue_activities_created:
notification = createMentionNotification( notification = create_mention_notification(
project=project, project=project,
issue=issue, issue=issue,
notification_comment=f"{actor.display_name} has mentioned you in a comment in issue {issue.name}", notification_comment=f"{actor.display_name} has mentioned you in a comment in issue {issue.name}",
@ -434,10 +505,60 @@ def notifications(
issue_id=issue_id, issue_id=issue_id,
activity=issue_activity, activity=issue_activity,
) )
# check for email notifications
if preference.mention:
bulk_email_logs.append(
EmailNotificationLog(
triggered_by_id=actor_id,
receiver_id=subscriber,
entity_identifier=issue_id,
entity_name="issue",
data={
"issue": {
"id": str(issue_id),
"name": str(issue.name),
"identifier": str(
issue.project.identifier
),
"sequence_id": issue.sequence_id,
"state_name": issue.state.name,
"state_group": issue.state.group,
"project_id": str(
issue.project.id
),
"workspace_slug": str(
issue.project.workspace.slug
),
},
"issue_activity": {
"id": str(
issue_activity.get("id")
),
"verb": str(
issue_activity.get("verb")
),
"field": str("mention"),
"actor": str(
issue_activity.get("actor_id")
),
"new_value": str(
issue_activity.get("new_value")
),
"old_value": str(
issue_activity.get("old_value")
),
},
},
)
)
bulk_notifications.append(notification) bulk_notifications.append(notification)
for mention_id in new_mentions: for mention_id in new_mentions:
if mention_id != actor_id: if mention_id != actor_id:
preference = UserNotificationPreference.objects.get(
user_id=mention_id
)
if ( if (
last_activity is not None last_activity is not None
and last_activity.field == "description" and last_activity.field == "description"
@ -463,21 +584,64 @@ def notifications(
"sequence_id": issue.sequence_id, "sequence_id": issue.sequence_id,
"state_name": issue.state.name, "state_name": issue.state.name,
"state_group": issue.state.group, "state_group": issue.state.group,
"project_id": str(issue.project.id),
"workspace_slug": str(
issue.project.workspace.slug
),
}, },
"issue_activity": { "issue_activity": {
"id": str(last_activity.id), "id": str(last_activity.id),
"verb": str(last_activity.verb), "verb": str(last_activity.verb),
"field": str(last_activity.field), "field": str(last_activity.field),
"actor": str(last_activity.actor_id), "actor": str(last_activity.actor_id),
"new_value": str(last_activity.new_value), "new_value": str(
"old_value": str(last_activity.old_value), last_activity.new_value
),
"old_value": str(
last_activity.old_value
),
},
},
)
)
if preference.mention:
bulk_email_logs.append(
EmailNotificationLog(
triggered_by_id=actor_id,
receiver_id=subscriber,
entity_identifier=issue_id,
entity_name="issue",
data={
"issue": {
"id": str(issue_id),
"name": str(issue.name),
"identifier": str(
issue.project.identifier
),
"sequence_id": issue.sequence_id,
"state_name": issue.state.name,
"state_group": issue.state.group,
},
"issue_activity": {
"id": str(last_activity.id),
"verb": str(last_activity.verb),
"field": "mention",
"actor": str(
last_activity.actor_id
),
"new_value": str(
last_activity.new_value
),
"old_value": str(
last_activity.old_value
),
}, },
}, },
) )
) )
else: else:
for issue_activity in issue_activities_created: for issue_activity in issue_activities_created:
notification = createMentionNotification( notification = create_mention_notification(
project=project, project=project,
issue=issue, issue=issue,
notification_comment=f"You have been mentioned in the issue {issue.name}", notification_comment=f"You have been mentioned in the issue {issue.name}",
@ -486,6 +650,51 @@ def notifications(
issue_id=issue_id, issue_id=issue_id,
activity=issue_activity, activity=issue_activity,
) )
if preference.mention:
bulk_email_logs.append(
EmailNotificationLog(
triggered_by_id=actor_id,
receiver_id=subscriber,
entity_identifier=issue_id,
entity_name="issue",
data={
"issue": {
"id": str(issue_id),
"name": str(issue.name),
"identifier": str(
issue.project.identifier
),
"sequence_id": issue.sequence_id,
"state_name": issue.state.name,
"state_group": issue.state.group,
},
"issue_activity": {
"id": str(
issue_activity.get("id")
),
"verb": str(
issue_activity.get("verb")
),
"field": str("mention"),
"actor": str(
issue_activity.get(
"actor_id"
)
),
"new_value": str(
issue_activity.get(
"new_value"
)
),
"old_value": str(
issue_activity.get(
"old_value"
)
),
},
},
)
)
bulk_notifications.append(notification) bulk_notifications.append(notification)
# save new mentions for the particular issue and remove the mentions that has been deleted from the description # save new mentions for the particular issue and remove the mentions that has been deleted from the description
@ -495,6 +704,14 @@ def notifications(
new_mentions=new_mentions, new_mentions=new_mentions,
removed_mention=removed_mention, removed_mention=removed_mention,
) )
# Bulk create notifications # Bulk create notifications
Notification.objects.bulk_create(bulk_notifications, batch_size=100) Notification.objects.bulk_create(
bulk_notifications, batch_size=100
)
EmailNotificationLog.objects.bulk_create(
bulk_email_logs, batch_size=100, ignore_conflicts=True
)
return
except Exception as e:
print(e)
return

View File

@ -2,6 +2,7 @@ import os
from celery import Celery from celery import Celery
from plane.settings.redis import redis_instance from plane.settings.redis import redis_instance
from celery.schedules import crontab from celery.schedules import crontab
from django.utils.timezone import timedelta
# Set the default Django settings module for the 'celery' program. # Set the default Django settings module for the 'celery' program.
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "plane.settings.production") os.environ.setdefault("DJANGO_SETTINGS_MODULE", "plane.settings.production")
@ -28,6 +29,10 @@ app.conf.beat_schedule = {
"task": "plane.bgtasks.file_asset_task.delete_file_asset", "task": "plane.bgtasks.file_asset_task.delete_file_asset",
"schedule": crontab(hour=0, minute=0), "schedule": crontab(hour=0, minute=0),
}, },
"check-every-five-minutes-to-send-email-notifications": {
"task": "plane.bgtasks.email_notification_task.stack_email_notification",
"schedule": crontab(minute='*/1')
},
} }
# Load task modules from all registered Django app configs. # Load task modules from all registered Django app configs.

View File

@ -0,0 +1,184 @@
# Generated by Django 4.2.7 on 2024-01-22 08:55
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
dependencies = [
("db", "0055_auto_20240108_0648"),
]
operations = [
migrations.CreateModel(
name="UserNotificationPreference",
fields=[
(
"created_at",
models.DateTimeField(
auto_now_add=True, verbose_name="Created At"
),
),
(
"updated_at",
models.DateTimeField(
auto_now=True, verbose_name="Last Modified At"
),
),
(
"id",
models.UUIDField(
db_index=True,
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
unique=True,
),
),
("property_change", models.BooleanField(default=True)),
("state_change", models.BooleanField(default=True)),
("comment", models.BooleanField(default=True)),
("mention", models.BooleanField(default=True)),
("issue_completed", models.BooleanField(default=True)),
(
"created_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="%(class)s_created_by",
to=settings.AUTH_USER_MODEL,
verbose_name="Created By",
),
),
(
"project",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="project_notification_preferences",
to="db.project",
),
),
(
"updated_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="%(class)s_updated_by",
to=settings.AUTH_USER_MODEL,
verbose_name="Last Modified By",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="notification_preferences",
to=settings.AUTH_USER_MODEL,
),
),
(
"workspace",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="workspace_notification_preferences",
to="db.workspace",
),
),
],
options={
"verbose_name": "UserNotificationPreference",
"verbose_name_plural": "UserNotificationPreferences",
"db_table": "user_notification_preferences",
"ordering": ("-created_at",),
},
),
migrations.CreateModel(
name="EmailNotificationLog",
fields=[
(
"created_at",
models.DateTimeField(
auto_now_add=True, verbose_name="Created At"
),
),
(
"updated_at",
models.DateTimeField(
auto_now=True, verbose_name="Last Modified At"
),
),
(
"id",
models.UUIDField(
db_index=True,
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
unique=True,
),
),
("entity_identifier", models.UUIDField(null=True)),
("entity_name", models.CharField(max_length=255)),
("data", models.JSONField(null=True)),
("processed_at", models.DateTimeField(null=True)),
("sent_at", models.DateTimeField(null=True)),
("entity", models.CharField(max_length=200)),
(
"old_value",
models.CharField(blank=True, max_length=300, null=True),
),
(
"new_value",
models.CharField(blank=True, max_length=300, null=True),
),
(
"created_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="%(class)s_created_by",
to=settings.AUTH_USER_MODEL,
verbose_name="Created By",
),
),
(
"receiver",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="email_notifications",
to=settings.AUTH_USER_MODEL,
),
),
(
"triggered_by",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="triggered_emails",
to=settings.AUTH_USER_MODEL,
),
),
(
"updated_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="%(class)s_updated_by",
to=settings.AUTH_USER_MODEL,
verbose_name="Last Modified By",
),
),
],
options={
"verbose_name": "Email Notification Log",
"verbose_name_plural": "Email Notification Logs",
"db_table": "email_notification_logs",
"ordering": ("-created_at",),
},
),
]

View File

@ -0,0 +1,28 @@
# Generated by Django 4.2.7 on 2024-01-22 09:01
from django.db import migrations
def create_notification_preferences(apps, schema_editor):
UserNotificationPreference = apps.get_model("db", "UserNotificationPreference")
User = apps.get_model("db", "User")
bulk_notification_preferences = []
for user_id in User.objects.filter(is_bot=False).values_list("id", flat=True):
bulk_notification_preferences.append(
UserNotificationPreference(
user_id=user_id,
created_by_id=user_id,
)
)
UserNotificationPreference.objects.bulk_create(
bulk_notification_preferences, batch_size=1000, ignore_conflicts=True
)
class Migration(migrations.Migration):
dependencies = [
("db", "0056_usernotificationpreference_emailnotificationlog"),
]
operations = [
migrations.RunPython(create_notification_preferences)
]

View File

@ -85,7 +85,7 @@ from .inbox import Inbox, InboxIssue
from .analytic import AnalyticView from .analytic import AnalyticView
from .notification import Notification from .notification import Notification, UserNotificationPreference, EmailNotificationLog
from .exporter import ExporterHistory from .exporter import ExporterHistory

View File

@ -1,9 +1,9 @@
# Django imports # Django imports
from django.db import models from django.db import models
from django.conf import settings
# Third party imports # Module imports
from .base import BaseModel from . import BaseModel
class Notification(BaseModel): class Notification(BaseModel):
workspace = models.ForeignKey( workspace = models.ForeignKey(
@ -47,3 +47,82 @@ class Notification(BaseModel):
def __str__(self): def __str__(self):
"""Return name of the notifications""" """Return name of the notifications"""
return f"{self.receiver.email} <{self.workspace.name}>" return f"{self.receiver.email} <{self.workspace.name}>"
def get_default_preference():
return {
"property_change": {
"email": True,
},
"state": {
"email": True,
},
"comment": {
"email": True,
},
"mentions": {
"email": True,
},
}
class UserNotificationPreference(BaseModel):
# user it is related to
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="notification_preferences",
)
# workspace if it is applicable
workspace = models.ForeignKey(
"db.Workspace",
on_delete=models.CASCADE,
related_name="workspace_notification_preferences",
null=True,
)
# project
project = models.ForeignKey(
"db.Project",
on_delete=models.CASCADE,
related_name="project_notification_preferences",
null=True,
)
# preference fields
property_change = models.BooleanField(default=True)
state_change = models.BooleanField(default=True)
comment = models.BooleanField(default=True)
mention = models.BooleanField(default=True)
issue_completed = models.BooleanField(default=True)
class Meta:
verbose_name = "UserNotificationPreference"
verbose_name_plural = "UserNotificationPreferences"
db_table = "user_notification_preferences"
ordering = ("-created_at",)
def __str__(self):
"""Return the user"""
return f"<{self.user}>"
class EmailNotificationLog(BaseModel):
# receiver
receiver = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="email_notifications")
triggered_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="triggered_emails")
# entity - can be issues, pages, etc.
entity_identifier = models.UUIDField(null=True)
entity_name = models.CharField(max_length=255)
# data
data = models.JSONField(null=True)
# sent at
processed_at = models.DateTimeField(null=True)
sent_at = models.DateTimeField(null=True)
entity = models.CharField(max_length=200)
old_value = models.CharField(max_length=300, blank=True, null=True)
new_value = models.CharField(max_length=300, blank=True, null=True)
class Meta:
verbose_name = "Email Notification Log"
verbose_name_plural = "Email Notification Logs"
db_table = "email_notification_logs"
ordering = ("-created_at",)

View File

@ -11,8 +11,16 @@ from django.contrib.auth.models import (
UserManager, UserManager,
PermissionsMixin, PermissionsMixin,
) )
from django.db.models.signals import post_save
from django.conf import settings
from django.dispatch import receiver
from django.utils import timezone from django.utils import timezone
# Third party imports
from sentry_sdk import capture_exception
from slack_sdk import WebClient
from slack_sdk.errors import SlackApiError
def get_default_onboarding(): def get_default_onboarding():
return { return {
@ -134,3 +142,34 @@ class User(AbstractBaseUser, PermissionsMixin):
self.is_staff = True self.is_staff = True
super(User, self).save(*args, **kwargs) super(User, self).save(*args, **kwargs)
@receiver(post_save, sender=User)
def send_welcome_slack(sender, instance, created, **kwargs):
try:
if created and not instance.is_bot:
# Send message on slack as well
if settings.SLACK_BOT_TOKEN:
client = WebClient(token=settings.SLACK_BOT_TOKEN)
try:
_ = client.chat_postMessage(
channel="#trackers",
text=f"New user {instance.email} has signed up and begun the onboarding journey.",
)
except SlackApiError as e:
print(f"Got an error: {e.response['error']}")
return
except Exception as e:
capture_exception(e)
return
@receiver(post_save, sender=User)
def create_user_notification(sender, instance, created, **kwargs):
# create preferences
if created and not instance.is_bot:
# Module imports
from plane.db.models import UserNotificationPreference
UserNotificationPreference.objects.create(
user=instance,
)

View File

@ -291,6 +291,7 @@ CELERY_IMPORTS = (
"plane.bgtasks.issue_automation_task", "plane.bgtasks.issue_automation_task",
"plane.bgtasks.exporter_expired_task", "plane.bgtasks.exporter_expired_task",
"plane.bgtasks.file_asset_task", "plane.bgtasks.file_asset_task",
"plane.bgtasks.email_notification_task",
) )
# Sentry Settings # Sentry Settings

View File

@ -0,0 +1,903 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html
xmlns="http://www.w3.org/1999/xhtml"
xmlns:v="urn:schemas-microsoft-com:vml"
xmlns:o="urn:schemas-microsoft-com:office:office"
>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="format-detection" content="telephone=no" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Updates on Issue</title>
<style type="text/css" emogrify="no">
html {
font-family: system-ui;
}
p,
h1,
h2,
h3,
h4,
ol,
ul {
margin: 0;
}
h-full {
height: 100%;
}
</style>
</head>
<body
bgcolor="#ffffff"
text="#3b3f44"
link="#3f76ff"
yahoo="fix"
style="background-color: #f7f9ff; margin: 20px"
>
<table
cellspacing="0"
cellpadding="0"
border="0"
style="
background-color: #f7f9ff;
width: 100%;
height: 100%;
padding-left: calc((100vw - 676px) / 2);
padding-right: calc((100vw - 676px) / 2);
"
>
<!-- Header -->
<tr>
<td>
<table style="width: 600px">
<tr>
<td>
<div style="margin: 20px">
<!-- TODO: Get Plane logo -->
<img
src="https://docs.plane.so/logos/logo.svg"
width="28"
height="28"
border="0"
/>
<span
style="font-weight: 700; font-size: 32px; color: #3f76ff"
>
Plane
</span>
</div>
</td>
</tr>
</table>
</td>
</tr>
<!-- Body -->
<tr>
<td>
<table style="width: 600px" celspacing="0">
<tr style="background-color: #fcfcfd">
<td style="color: #0b0c10; padding: 30px; border-radius: 4px">
<div>
<table>
<tr>
<td>
<p style="font-size: 1rem; font-weight: 600">
{{ issue.identifier }} updates
</p>
<p
style="
font-size: 1rem;
font-weight: 500;
line-height: 28px;
"
>
{{ issue.name }}
</p>
</td>
</tr>
</table>
<hr
style="
background-color: #f0f0f3;
height: 1px;
border: 0;
margin-top: 15px;
margin-bottom: 15px;
"
/>
<p style="font-size: 1rem; line-height: 28px">
{{ summary }}
<span
style="
font-size: 1rem;
font-weight: 700;
line-height: 28px;
"
>
{{ data.0.actor_detail.first_name}} {{data.0.actor_detail.last_name }}
</span>
</p>
<!-- Outer update/comment Box -->
<table
style="
background-color: #f7f9ff;
border-radius: 8px;
border-style: solid;
border-width: 1px;
border-color: #c1d0ff;
padding: 20px;
margin-top: 15px;
width: 100%;
white-space: nowrap;
max-width: 100%;
"
cellspacing="0"
>
<!-- Block Heading -->
<tr>
<td style="padding-bottom: 20px">
<p
style="
font-size: 0.8rem;
font-weight: 600;
color: #121a26;
"
>
Updates
</p>
</td>
</tr>
<!-- Property Updates -->
{% for update in data %}
<tr>
<td>
<table
cellspacing="0"
style="background-color: white; width: 100%"
>
<!-- action performer -->
<tr>
<td>
<table cellspacing="0">
<tr
style="
background-color: #ffffff;
border-radius: 8px;
margin-top: 20px;
"
>
<td style="padding-left: 15px">
<img
src="{{ update.actor_detail.avatar_url }}"
width="15"
height="15"
border="0"
/>
</td>
<td
style="
padding-top: 20px;
padding-bottom: 20px;
"
>
<p
style="
font-weight: 500;
font-size: 0.8rem;
color: #1c2024;
margin-left: 8px;
"
>
{{ update.actor_detail.first_name }} {{ update.actor_detail.last_name }}
</p>
</td>
<td style="width: fit-content">
<p
style="
font-weight: 500;
font-size: 0.6rem;
color: #80838d;
margin-left: 10px;
"
>
{{ update.activity_time }}
</p>
</td>
</tr>
</table>
</td>
</tr>
<!-- Only assignee changed -->
{% if update.changes.assignees %}
<tr>
<td
style="padding-left: 15px; padding-bottom: 20px"
>
<table>
<tr>
<td></td>
<td style="width: 55px">
<p
style="
font-size: 0.8rem;
font-weight: 500;
color: #525252;
"
>
Assignee:
</p>
</td>
{% for assignee in update.changes.assignees.old_value %}
<td>
<p
style="
font-size: 0.8rem;
font-weight: 500;
text-decoration: line-through;
color: #641723;
background-color: #feebec;
margin-left: 5px;
padding: 3px;
"
>
{{ assignee }}
</p>
</td>
{% endfor %}
{% if update.changes.assignees.old_value and update.changes.assignee.new_value %}
<td>
<i
data-lucide="move-right"
style="
color: #525252;
height: 15px;
width: 15px;
margin-left: 10px;
margin-right: 10px;
"
></i>
</td>
{% endif %}
{% for assignee in update.changes.assignees.new_value %}
<td>
<p
style="
font-size: 0.8rem;
font-weight: 500;
color: #0d74ce;
background-color: #e6f4fe;
padding: 3px;
"
>
{{ assginee }}
</p>
</td>
{% endfor %}
</tr>
</table>
</td>
</tr>
{% endif %}
<!-- due date changed -->
{% if update.changes.target_date %}
<tr>
<td
style="padding-left: 15px; padding-bottom: 20px"
>
<table>
<tr>
<td>
<i
data-lucide="calendar"
style="
color: #525252;
height: 12px;
width: 12px;
margin-right: 5px;
"
></i>
</td>
<td style="width: 55px">
<p
style="
font-size: 0.8rem;
font-weight: 500;
color: #525252;
"
>
Due Date:
</p>
</td>
<td>
<p
style="
font-size: 0.8rem;
font-weight: 500;
color: #171717;
margin-left: 5px;
"
>
{{ update.changes.target_date.new_value.0 }}
</p>
</td>
</tr>
</table>
</td>
</tr>
{% endif %} -->
<!-- duplicate changed -->
{% if update.changes.duplicate %}
<tr>
<td
style="padding-left: 15px; padding-bottom: 20px"
>
<table>
<tr>
<td>
<i
data-lucide="layout-panel-top"
style="
color: #525252;
height: 12px;
width: 12px;
margin-right: 5px;
"
></i>
</td>
<td style="width: 55px">
<p
style="
font-size: 0.8rem;
font-weight: 500;
color: #525252;
"
>
Duplicate:
</p>
</td>
{% for dup in update.changes.duplicate.new_value %}
<td>
<p
style="
font-size: 0.8rem;
font-weight: 500;
color: #3a5bc7;
margin-left: 5px;
"
>
{{ dup }}
</p>
</td>
{% endfor %}
</tr>
</table>
</td>
</tr>
{% endif %}
<!-- Labels -->
{% if update.changes.labels %}
<tr>
<td
style="padding-left: 15px; padding-bottom: 20px"
>
<table>
<tr>
<td valign="top">
<i
data-lucide="layout-panel-top"
style="
color: #525252;
height: 12px;
width: 12px;
margin-right: 5px;
"
></i>
</td>
<td valign="top">
<p
style="
font-size: 0.8rem;
font-weight: 500;
color: #525252;
margin-right: 5px;
"
>
Labels:
</p>
</td>
<td valign="top">
<table>
<tr>
{% for label in update.changes.labels.new_value %}
<td>
<p
style="
font-size: 0.8rem;
font-weight: 500;
color: #9747ff;
background-color: #9747ff1a;
padding: 3px;
padding-left: 5px;
padding-right: 5px;
margin-right: 4px;
border-radius: 2px;
"
>
{{ label }}
</p>
</td>
{% endfor %}
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
{% endif %}
<!-- State changed -->
{% if update.changes.state %}
<tr>
<td>
<tr>
<td
style="
padding-left: 15px;
padding-bottom: 20px;
"
>
<table>
<tr>
<td>
<i
data-lucide="calendar"
style="
color: #525252;
height: 12px;
width: 12px;
margin-right: 5px;
"
></i>
</td>
<td>
<p
style="
font-size: 0.8rem;
font-weight: 500;
color: #525252;
"
>
State:
</p>
</td>
<td>
<p
style="
font-size: 0.8rem;
font-weight: 600;
color: #60646c;
"
>
{{ update.changes.state.old_value.0 }}
</p>
</td>
<td>-></td>
<td>
<p
style="
font-size: 0.8rem;
font-weight: 600;
color: #60646c;
"
>
{{ update.changes.state.new_value.0 }}
</p>
</td>
</tr>
</table>
</td>
</tr>
</td>
</tr>
{% endif %}
<!-- Link Added -->
{% if update.changes.link %}
<tr>
<td
style="padding-left: 15px; padding-bottom: 20px"
>
<table>
<tr>
<td>
<i
data-lucide="layout-panel-top"
style="
color: #525252;
height: 12px;
width: 12px;
margin-right: 5px;
"
></i>
</td>
<td>
<p
style="
font-size: 0.8rem;
font-weight: 500;
color: #525252;
margin-right: 5px;
"
>
Link:
</p>
</td>
<td>
<a
href=""
style="
font-size: 0.8rem;
font-weight: 600;
color: #3a5bc7;
"
>
{{ update.changes.link.new_value.0 }}
</a>
</td>
</tr>
</table>
</td>
</tr>
{% endif %}
<!-- Priority changed -->
{% if update.changes.priority %}
<tr>
<td style="padding-left: 15px; padding-bottom: 20px">
<table>
<tr>
<td>
<i
data-lucide="layout-panel-top"
style="
color: #525252;
height: 12px;
width: 12px;
margin-right: 5px;
"
></i>
</td>
<td>
<p
style="
font-size: 0.8rem;
font-weight: 500;
color: #525252;
margin-right: 5px;
"
>
Priority:
</p>
</td>
<td>
<p
style="
font-size: 0.8rem;
padding: 2px;
font-weight: 500;
{% if update.changes.priority.old_value.0 == 'urgent' %}background-color: #EF4444; color: white;{% endif %}
{% if update.changes.priority.old_value.0 == 'high' %}background-color: #F97316; color: white;{% endif %}
{% if update.changes.priority.old_value.0 == 'medium' %}background-color: #EAB308; color: white;{% endif %}
{% if update.changes.priority.old_value.0 == 'low' %}background-color: #3F76FF; color: white;{% endif %}
{% if update.changes.priority.old_value.0 == 'none' %}background-color: #A3A3A3; color: white;{% endif %}
"
>
{{ update.changes.priority.old_value.0 }}
</p>
</td>
<td
style="
padding-left: 10px;
padding-right: 10px;
"
>
->
</td>
<td>
<p
style="
font-size: 0.8rem;
padding: 2px;
font-weight: 500;
{% if update.changes.priority.old_value.0 == 'urgent' %}background-color: #EF4444; color: white;{% endif %}
{% if update.changes.priority.old_value.0 == 'high' %}background-color: #F97316; color: white;{% endif %}
{% if update.changes.priority.old_value.0 == 'medium' %}background-color: #EAB308; color: white;{% endif %}
{% if update.changes.priority.old_value.0 == 'low' %}background-color: #3F76FF; color: white;{% endif %}
{% if update.changes.priority.old_value.0 == 'none' %}background-color: #A3A3A3; color: white;{% endif %}
"
>
{{ update.changes.priority.new_value.0 }}
</p>
</td>
</tr>
</table>
</td>
</tr>
{% endif %}
<!-- Blocking changed -->
{% if update.changes.blocking %}
<tr>
<td
style="padding-left: 15px; padding-bottom: 20px"
>
<table>
<tr>
<td>
<i
data-lucide="layout-panel-top"
style="
color: #525252;
height: 12px;
width: 12px;
margin-right: 5px;
"
></i>
</td>
<td>
<p
style="
font-size: 0.8rem;
font-weight: 500;
color: #525252;
margin-right: 5px;
"
>
Blocking:
</p>
</td>
{% for bl in update.changes.blocking.new_value %}
<td>
<a
href=""
style="font-size: 0.8rem; color: #3358d4"
>
{{ bl }}
</a>
</td>
{% endfor %}
</tr>
</table>
</td>
</tr>
{% endif %}
</table>
</td>
</tr>
{% endfor %}
</table>
<!-- Comments outer update Box -->
{% if comments %}
<table
style="
background-color: #f7f9ff;
border-radius: 8px;
border-style: solid;
border-width: 1px;
border-color: #c1d0ff;
padding: 20px;
margin-top: 15px;
width: 600px;
max-width: 600px;
"
cellspacing="0"
>
<!-- Block Heading -->
<tr>
<td>
<p
style="
font-size: 0.8rem;
font-weight: 600;
color: #121a26;
"
>
Comments
</p>
</td>
</tr>
<!-- Comments Updates -->
<tr>
<td>
<table
cellspacing="0"
style="background-color: white; width: 100%"
></table>
</td>
</tr>
<!-- Comments -->
<tr>
<td>
<table cellspacing="0" style="padding-top: 20px">
<tr style="border-radius: 8px">
<td valign="top">
<div
style="
height: 25px;
width: 25px;
border-radius: 20px;
background-color: #4f3422;
text-align: center;
justify-items: center;
"
>
<span
style="
color: white;
font-weight: 500;
text-align: center;
padding-top: 0px;
font-size: 12px;
"
>S</span
>
</div>
<!-- <img src="https://docs.plane.so/logos/logo.svg" width="25"
height="25" border="0" /> -->
</td>
{% for comment in comments %}
<td style="padding-bottom: 20px">
<table>
<tr>
<td>
<p
style="
font-weight: 500;
font-size: 0.8rem;
color: #1c2024;
margin-left: 8px;
"
>
{{ comment.actor_detail.first_name }} {{ comment.actor_detail.last_name }}
</p>
</td>
</tr>
{% for actor_comment in comment.actor_comments %}
<tr>
<td>
<div
style="
padding-left: 10px;
padding-right: 10px;
line-height: 24px;
padding-top: 15px;
margin-left: 10px;
padding-bottom: 15px;
background-color: white;
font-size: 0.8rem;
color: #525252;
margin-top: 5px;
border-radius: 4px;
"
>
{{ actor_comment.new_value.0 }}
</div>
</td>
</tr>
{% endfor %}
</table>
</td>
{% endfor %}
</tr>
</table>
</td>
</tr>
</table>
{% endif %}
</div>
<button
onclick="window.location.href='{{ issue.issue_url }}'"
href="{{ issue.issue_url }}"
style="
background-color: #3e63dd;
padding: 10px 15px;
border: 1px solid #2f4ba8;
border-radius: 4px;
margin-top: 15px;
cursor: pointer;
font-size: 0.8rem;
color: white;
"
>
View issue
</button>
</td>
</tr>
</table>
</td>
</tr>
<!-- Footer -->
<tr>
<td>
<table style="width: 100%; padding: 20px; justify-content: center">
<tr>
<td>
<div style="font-size: 0.8rem; color: #1c2024">
This email was sent to
<a
href="mailto:"
style="
color: #3a5bc7;
font-weight: 500;
text-decoration: none;
"
>{{ receiver.email }}.</a
>
If you'd rather not receive this kind of email,
<a
href="{{ issue_unsubscribe }}"
style="color: #3a5bc7; text-decoration: none"
>you can unsubscribe to the issue</a
>
or
<a
href="{{ user_preference }}"
style="color: #3a5bc7; text-decoration: none"
>manage your email preferences</a
>.
<!-- Github | LinkedIn | Twitter -->
<div style="margin-top: 60px; float: right">
<a
href="https://github.com/makeplane"
target="_blank"
style="margin-left: 10px; text-decoration: none"
>
<img
src="https://creative-assets.mailinblue.com/editor/social-icons/rounded_colored/github_32px.png"
width="25"
height="25"
border="0"
style="display: inline-block"
/>
</a>
<a
href="https://www.linkedin.com/company/planepowers/"
target="_blank"
style="margin-left: 10px; text-decoration: none"
>
<img
src="https://creative-assets.mailinblue.com/editor/social-icons/rounded_colored/linkedin_32px.png"
width="25"
height="25"
border="0"
style="display: inline-block"
/>
</a>
<a
href="https://twitter.com/planepowers"
target="_blank"
style="margin-left: 10px; text-decoration: none"
>
<img
src="https://creative-assets.mailinblue.com/editor/social-icons/rounded_colored/twitter_32px.png"
width="25"
height="25"
border="0"
style="display: inline-block"
/>
</a>
</div>
</div>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!-- Lucid Icon Scripts -->
<script src="https://unpkg.com/lucide@latest"></script>
<script>
lucide.createIcons();
</script>
</body>
</html>

View File

@ -166,6 +166,14 @@ export interface IUserProjectsRole {
[projectId: string]: EUserProjectRoles; [projectId: string]: EUserProjectRoles;
} }
export interface IUserEmailNotificationSettings {
property_change: boolean;
state_change: boolean;
comment: boolean;
mention: boolean;
issue_completed: boolean;
}
// export interface ICurrentUser { // export interface ICurrentUser {
// id: readonly string; // id: readonly string;
// avatar: string; // avatar: string;

View File

@ -154,12 +154,7 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
{currentUser && !is_archived && (fieldsToShow.includes("all") || fieldsToShow.includes("subscribe")) && ( {currentUser && !is_archived && (fieldsToShow.includes("all") || fieldsToShow.includes("subscribe")) && (
<IssueSubscription <IssueSubscription workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} />
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
currentUserId={currentUser?.id}
/>
)} )}
{(fieldsToShow.includes("all") || fieldsToShow.includes("link")) && ( {(fieldsToShow.includes("all") || fieldsToShow.includes("link")) && (

View File

@ -11,14 +11,12 @@ export type TIssueSubscription = {
workspaceSlug: string; workspaceSlug: string;
projectId: string; projectId: string;
issueId: string; issueId: string;
currentUserId: string;
}; };
export const IssueSubscription: FC<TIssueSubscription> = observer((props) => { export const IssueSubscription: FC<TIssueSubscription> = observer((props) => {
const { workspaceSlug, projectId, issueId, currentUserId } = props; const { workspaceSlug, projectId, issueId } = props;
// hooks // hooks
const { const {
issue: { getIssueById },
subscription: { getSubscriptionByIssueId }, subscription: { getSubscriptionByIssueId },
createSubscription, createSubscription,
removeSubscription, removeSubscription,
@ -27,7 +25,6 @@ export const IssueSubscription: FC<TIssueSubscription> = observer((props) => {
// state // state
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const issue = getIssueById(issueId);
const subscription = getSubscriptionByIssueId(issueId); const subscription = getSubscriptionByIssueId(issueId);
const handleSubscription = async () => { const handleSubscription = async () => {
@ -51,8 +48,6 @@ export const IssueSubscription: FC<TIssueSubscription> = observer((props) => {
} }
}; };
if (issue?.created_by === currentUserId || issue?.assignee_ids?.includes(currentUserId)) return <></>;
return ( return (
<div> <div>
<Button <Button

View File

@ -188,12 +188,7 @@ export const IssueView: FC<IIssueView> = observer((props) => {
<IssueUpdateStatus isSubmitting={isSubmitting} /> <IssueUpdateStatus isSubmitting={isSubmitting} />
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
{currentUser && !is_archived && ( {currentUser && !is_archived && (
<IssueSubscription <IssueSubscription workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} />
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
currentUserId={currentUser?.id}
/>
)} )}
<button onClick={handleCopyText}> <button onClick={handleCopyText}>
<Link2 className="h-4 w-4 -rotate-45 text-custom-text-300 hover:text-custom-text-200" /> <Link2 className="h-4 w-4 -rotate-45 text-custom-text-300 hover:text-custom-text-200" />

View File

@ -0,0 +1,188 @@
import { FC } from "react";
import { Controller, useForm } from "react-hook-form";
// ui
import { Button } from "@plane/ui";
// hooks
import useToast from "hooks/use-toast";
// services
import { UserService } from "services/user.service";
// types
import { IUserEmailNotificationSettings } from "@plane/types";
interface IEmailNotificationFormProps {
data: IUserEmailNotificationSettings;
}
// services
const userService = new UserService();
export const EmailNotificationForm: FC<IEmailNotificationFormProps> = (props) => {
const { data } = props;
// toast
const { setToastAlert } = useToast();
// form data
const {
handleSubmit,
control,
setValue,
formState: { isSubmitting, isDirty, dirtyFields },
} = useForm<IUserEmailNotificationSettings>({
defaultValues: {
...data,
},
});
const onSubmit = async (formData: IUserEmailNotificationSettings) => {
// Get the dirty fields from the form data and create a payload
let payload = {};
Object.keys(dirtyFields).forEach((key) => {
payload = {
...payload,
[key]: formData[key as keyof IUserEmailNotificationSettings],
};
});
await userService
.updateCurrentUserEmailNotificationSettings(payload)
.then(() =>
setToastAlert({
title: "Success",
type: "success",
message: "Email Notification Settings updated successfully",
})
)
.catch((err) => console.error(err));
};
return (
<>
<div className="flex gap-2 items-center pt-6 mb-2 pb-6 border-b border-custom-border-100">
<div className="grow">
<div className="pb-1 text-xl font-medium text-custom-text-100">Email notifications</div>
<div className="text-sm font-normal text-custom-text-300">
Stay in the loop on Issues you are subscribed to. Enable this to get notified.
</div>
</div>
</div>
<div className="pt-2 text-lg font-medium text-custom-text-100">Notify me when:</div>
{/* Notification Settings */}
<div className="flex flex-col py-2">
<div className="flex gap-2 items-center pt-6">
<div className="grow">
<div className="pb-1 text-base font-medium text-custom-text-100">Property changes</div>
<div className="text-sm font-normal text-custom-text-300">
Notify me when issues properties like assignees, priority, estimates or anything else changes.
</div>
</div>
<div className="shrink-0">
<Controller
control={control}
name="property_change"
render={({ field: { value, onChange } }) => (
<input
type="checkbox"
checked={value}
onChange={() => onChange(!value)}
className="w-3.5 h-3.5 mx-2 cursor-pointer !border-custom-border-100"
/>
)}
/>
</div>
</div>
<div className="flex gap-2 items-center pt-6 pb-2">
<div className="grow">
<div className="pb-1 text-base font-medium text-custom-text-100">State Change</div>
<div className="text-sm font-normal text-custom-text-300">
Notify me when the issues moves to a different state
</div>
</div>
<div className="shrink-0">
<Controller
control={control}
name="state_change"
render={({ field: { value, onChange } }) => (
<input
type="checkbox"
checked={value}
onChange={() => {
if (!value) setValue("issue_completed", true);
onChange(!value);
}}
className="w-3.5 h-3.5 mx-2 cursor-pointer"
/>
)}
/>
</div>
</div>
<div className="flex gap-2 items-center border-0 border-l-[3px] border-custom-border-300 pl-3">
<div className="grow">
<div className="pb-1 text-base font-medium text-custom-text-100">Issue completed</div>
<div className="text-sm font-normal text-custom-text-300">Notify me only when an issue is completed</div>
</div>
<div className="shrink-0">
<Controller
control={control}
name="issue_completed"
render={({ field: { value, onChange } }) => (
<input
type="checkbox"
checked={value}
onChange={() => onChange(!value)}
className="w-3.5 h-3.5 mx-2 cursor-pointer"
/>
)}
/>
</div>
</div>
<div className="flex gap-2 items-center pt-6">
<div className="grow">
<div className="pb-1 text-base font-medium text-custom-text-100">Comments</div>
<div className="text-sm font-normal text-custom-text-300">
Notify me when someone leaves a comment on the issue
</div>
</div>
<div className="shrink-0">
<Controller
control={control}
name="comment"
render={({ field: { value, onChange } }) => (
<input
type="checkbox"
checked={value}
onChange={() => onChange(!value)}
className="w-3.5 h-3.5 mx-2 cursor-pointer"
/>
)}
/>
</div>
</div>
<div className="flex gap-2 items-center pt-6">
<div className="grow">
<div className="pb-1 text-base font-medium text-custom-text-100">Mentions</div>
<div className="text-sm font-normal text-custom-text-300">
Notify me only when someone mentions me in the comments or description
</div>
</div>
<div className="shrink-0">
<Controller
control={control}
name="mention"
render={({ field: { value, onChange } }) => (
<input
type="checkbox"
checked={value}
onChange={() => onChange(!value)}
className="w-3.5 h-3.5 mx-2 cursor-pointer"
/>
)}
/>
</div>
</div>
</div>
<div className="flex items-center py-12">
<Button variant="primary" onClick={handleSubmit(onSubmit)} loading={isSubmitting} disabled={!isDirty}>
{isSubmitting ? "Saving..." : "Save changes"}
</Button>
</div>
</>
);
};

View File

@ -0,0 +1 @@
export * from "./email-notification-form";

View File

@ -33,7 +33,7 @@ export const PROFILE_ACTION_LINKS: {
{ {
key: "preferences", key: "preferences",
label: "Preferences", label: "Preferences",
href: `/profile/preferences`, href: `/profile/preferences/theme`,
highlight: (pathname: string) => pathname.includes("/profile/preferences"), highlight: (pathname: string) => pathname.includes("/profile/preferences"),
Icon: Settings2, Icon: Settings2,
}, },

View File

@ -0,0 +1,2 @@
export * from "./layout";
export * from "./sidebar";

View File

@ -0,0 +1,25 @@
import { FC, ReactNode } from "react";
// layout
import { ProfileSettingsLayout } from "layouts/settings-layout";
import { ProfilePreferenceSettingsSidebar } from "./sidebar";
interface IProfilePreferenceSettingsLayout {
children: ReactNode;
header?: ReactNode;
}
export const ProfilePreferenceSettingsLayout: FC<IProfilePreferenceSettingsLayout> = (props) => {
const { children, header } = props;
return (
<ProfileSettingsLayout>
<div className="relative flex h-screen w-full overflow-hidden">
<ProfilePreferenceSettingsSidebar />
<main className="relative flex h-full w-full flex-col overflow-hidden bg-custom-background-100">
{header}
<div className="h-full w-full overflow-x-hidden overflow-y-scroll">{children}</div>
</main>
</div>
</ProfileSettingsLayout>
);
};

View File

@ -0,0 +1,43 @@
import React from "react";
import { useRouter } from "next/router";
import Link from "next/link";
export const ProfilePreferenceSettingsSidebar = () => {
const router = useRouter();
const profilePreferenceLinks: Array<{
label: string;
href: string;
}> = [
{
label: "Theme",
href: `/profile/preferences/theme`,
},
{
label: "Email",
href: `/profile/preferences/email`,
},
];
return (
<div className="flex w-96 flex-col gap-6 px-8 py-12">
<div className="flex flex-col gap-4">
<span className="text-xs font-semibold text-custom-text-400">Preference</span>
<div className="flex w-full flex-col gap-2">
{profilePreferenceLinks.map((link) => (
<Link key={link.href} href={link.href}>
<div
className={`rounded-md px-4 py-2 text-sm font-medium ${
(link.label === "Import" ? router.asPath.includes(link.href) : router.asPath === link.href)
? "bg-custom-primary-100/10 text-custom-primary-100"
: "text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80 focus:bg-custom-sidebar-background-80"
}`}
>
{link.label}
</div>
</Link>
))}
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,36 @@
import { ReactElement } from "react";
import useSWR from "swr";
// layouts
import { ProfilePreferenceSettingsLayout } from "layouts/settings-layout/profile/preferences";
// components
import { EmailNotificationForm } from "components/profile/preferences";
// services
import { UserService } from "services/user.service";
// type
import { NextPageWithLayout } from "lib/types";
// services
const userService = new UserService();
const ProfilePreferencesThemePage: NextPageWithLayout = () => {
// fetching user email notification settings
const { data } = useSWR("CURRENT_USER_EMAIL_NOTIFICATION_SETTINGS", () =>
userService.currentUserEmailNotificationSettings()
);
if (!data) {
return null;
}
return (
<div className="mx-auto mt-8 h-full w-full overflow-y-auto px-6 lg:px-20 pb-8">
<EmailNotificationForm data={data} />
</div>
);
};
ProfilePreferencesThemePage.getLayout = function getLayout(page: ReactElement) {
return <ProfilePreferenceSettingsLayout>{page}</ProfilePreferenceSettingsLayout>;
};
export default ProfilePreferencesThemePage;

View File

@ -5,7 +5,7 @@ import { useTheme } from "next-themes";
import { useUser } from "hooks/store"; import { useUser } from "hooks/store";
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
// layouts // layouts
import { ProfileSettingsLayout } from "layouts/settings-layout"; import { ProfilePreferenceSettingsLayout } from "layouts/settings-layout/profile/preferences";
// components // components
import { CustomThemeSelector, ThemeSwitch } from "components/core"; import { CustomThemeSelector, ThemeSwitch } from "components/core";
// ui // ui
@ -15,7 +15,7 @@ import { I_THEME_OPTION, THEME_OPTIONS } from "constants/themes";
// type // type
import { NextPageWithLayout } from "lib/types"; import { NextPageWithLayout } from "lib/types";
const ProfilePreferencesPage: NextPageWithLayout = observer(() => { const ProfilePreferencesThemePage: NextPageWithLayout = observer(() => {
// states // states
const [currentTheme, setCurrentTheme] = useState<I_THEME_OPTION | null>(null); const [currentTheme, setCurrentTheme] = useState<I_THEME_OPTION | null>(null);
// store hooks // store hooks
@ -48,7 +48,7 @@ const ProfilePreferencesPage: NextPageWithLayout = observer(() => {
return ( return (
<> <>
{currentUser ? ( {currentUser ? (
<div className="mx-auto mt-16 h-full w-full overflow-y-auto px-8 pb-8 lg:w-3/5"> <div className="mx-auto mt-14 h-full w-full overflow-y-auto px-6 lg:px-20 pb-8">
<div className="flex items-center border-b border-custom-border-100 pb-3.5"> <div className="flex items-center border-b border-custom-border-100 pb-3.5">
<h3 className="text-xl font-medium">Preferences</h3> <h3 className="text-xl font-medium">Preferences</h3>
</div> </div>
@ -72,8 +72,8 @@ const ProfilePreferencesPage: NextPageWithLayout = observer(() => {
); );
}); });
ProfilePreferencesPage.getLayout = function getLayout(page: ReactElement) { ProfilePreferencesThemePage.getLayout = function getLayout(page: ReactElement) {
return <ProfileSettingsLayout>{page}</ProfileSettingsLayout>; return <ProfilePreferenceSettingsLayout>{page}</ProfilePreferenceSettingsLayout>;
}; };
export default ProfilePreferencesPage; export default ProfilePreferencesThemePage;

View File

@ -10,7 +10,7 @@ import type {
IUserProfileProjectSegregation, IUserProfileProjectSegregation,
IUserSettings, IUserSettings,
IUserWorkspaceDashboard, IUserWorkspaceDashboard,
TIssueMap, IUserEmailNotificationSettings,
} from "@plane/types"; } from "@plane/types";
// helpers // helpers
import { API_BASE_URL } from "helpers/common.helper"; import { API_BASE_URL } from "helpers/common.helper";
@ -69,6 +69,14 @@ export class UserService extends APIService {
}); });
} }
async currentUserEmailNotificationSettings(): Promise<IUserEmailNotificationSettings> {
return this.get("/api/users/me/notification-preferences/")
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
async updateUser(data: Partial<IUser>): Promise<any> { async updateUser(data: Partial<IUser>): Promise<any> {
return this.patch("/api/users/me/", data) return this.patch("/api/users/me/", data)
.then((response) => response?.data) .then((response) => response?.data)
@ -97,6 +105,14 @@ export class UserService extends APIService {
}); });
} }
async updateCurrentUserEmailNotificationSettings(data: Partial<IUserEmailNotificationSettings>): Promise<any> {
return this.patch("/api/users/me/notification-preferences/", data)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async getUserActivity(): Promise<IUserActivityResponse> { async getUserActivity(): Promise<IUserActivityResponse> {
return this.get(`/api/users/me/activities/`) return this.get(`/api/users/me/activities/`)
.then((response) => response?.data) .then((response) => response?.data)