forked from github/plane
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:
parent
c1e1b81b99
commit
f27efb80e1
@ -115,7 +115,7 @@ from .inbox import (
|
||||
|
||||
from .analytic import AnalyticViewSerializer
|
||||
|
||||
from .notification import NotificationSerializer
|
||||
from .notification import NotificationSerializer, UserNotificationPreferenceSerializer
|
||||
|
||||
from .exporter import ExporterHistorySerializer
|
||||
|
||||
|
@ -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__"
|
||||
|
@ -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",
|
||||
),
|
||||
]
|
||||
|
@ -167,6 +167,7 @@ from .notification import (
|
||||
NotificationViewSet,
|
||||
UnreadNotificationEndpoint,
|
||||
MarkAllReadNotificationViewSet,
|
||||
UserNotificationPreferenceEndpoint,
|
||||
)
|
||||
|
||||
from .exporter import ExportIssuesEndpoint
|
||||
|
@ -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)
|
||||
|
@ -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:
|
||||
|
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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(
|
||||
issue_ids = (
|
||||
IssueSubscriber.objects.filter(
|
||||
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(
|
||||
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)
|
||||
|
243
apiserver/plane/bgtasks/email_notification_task.py
Normal file
243
apiserver/plane/bgtasks/email_notification_task.py
Normal 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
|
@ -24,6 +24,7 @@ from plane.db.models import (
|
||||
Label,
|
||||
User,
|
||||
IssueProperty,
|
||||
UserNotificationPreference,
|
||||
)
|
||||
|
||||
|
||||
@ -50,10 +51,24 @@ def service_importer(service, importer_id):
|
||||
for user in users
|
||||
if user.get("import", False) == "invite"
|
||||
],
|
||||
batch_size=10,
|
||||
batch_size=100,
|
||||
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(
|
||||
email__in=[
|
||||
user.get("email").strip().lower()
|
||||
|
@ -24,10 +24,11 @@ from plane.db.models import (
|
||||
IssueReaction,
|
||||
CommentReaction,
|
||||
IssueComment,
|
||||
IssueSubscriber,
|
||||
)
|
||||
from plane.app.serializers import IssueActivitySerializer
|
||||
from plane.bgtasks.notification_task import notifications
|
||||
|
||||
from plane.settings.redis import redis_instance
|
||||
|
||||
# Track Changes in name
|
||||
def track_name(
|
||||
@ -190,7 +191,9 @@ def track_state(
|
||||
):
|
||||
if current_instance.get("state_id") != requested_data.get("state_id"):
|
||||
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(
|
||||
IssueActivity(
|
||||
@ -359,6 +362,7 @@ def track_assignees(
|
||||
added_assignees = requested_assignees - current_assignees
|
||||
dropped_assginees = current_assignees - requested_assignees
|
||||
|
||||
bulk_subscribers = []
|
||||
for added_asignee in added_assignees:
|
||||
assignee = User.objects.get(pk=added_asignee)
|
||||
issue_activities.append(
|
||||
@ -376,6 +380,21 @@ def track_assignees(
|
||||
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:
|
||||
assignee = User.objects.get(pk=dropped_assignee)
|
||||
@ -1543,6 +1562,8 @@ def issue_activity(
|
||||
project_id,
|
||||
epoch,
|
||||
subscriber=True,
|
||||
notification=False,
|
||||
origin=None,
|
||||
):
|
||||
try:
|
||||
issue_activities = []
|
||||
@ -1551,6 +1572,10 @@ def issue_activity(
|
||||
workspace_id = project.workspace_id
|
||||
|
||||
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()
|
||||
if issue:
|
||||
try:
|
||||
@ -1624,6 +1649,8 @@ def issue_activity(
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
|
||||
|
||||
if notification:
|
||||
notifications.delay(
|
||||
type=type,
|
||||
issue_id=issue_id,
|
||||
|
@ -87,6 +87,7 @@ def archive_old_issues():
|
||||
current_instance=json.dumps({"archived_at": None}),
|
||||
subscriber=False,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
)
|
||||
for issue in issues_to_update
|
||||
]
|
||||
@ -169,6 +170,7 @@ def close_old_issues():
|
||||
current_instance=None,
|
||||
subscriber=False,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
)
|
||||
for issue in issues_to_update
|
||||
]
|
||||
|
@ -10,9 +10,12 @@ from plane.db.models import (
|
||||
User,
|
||||
IssueAssignee,
|
||||
Issue,
|
||||
State,
|
||||
EmailNotificationLog,
|
||||
Notification,
|
||||
IssueComment,
|
||||
IssueActivity,
|
||||
UserNotificationPreference,
|
||||
)
|
||||
|
||||
# Third Party imports
|
||||
@ -20,7 +23,7 @@ from celery import shared_task
|
||||
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):
|
||||
@ -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.filter(
|
||||
issue=issue, mention__in=removed_mention
|
||||
).delete()
|
||||
IssueMention.objects.filter(issue=issue, mention__in=removed_mention).delete()
|
||||
|
||||
|
||||
def get_new_mentions(requested_instance, current_instance):
|
||||
@ -60,8 +61,6 @@ def get_new_mentions(requested_instance, current_instance):
|
||||
|
||||
|
||||
# Get Removed Mention
|
||||
|
||||
|
||||
def get_removed_mentions(requested_instance, current_instance):
|
||||
# requested_data is the newer instance of the current issue
|
||||
# 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
|
||||
|
||||
|
||||
def extract_mentions_as_subscribers(project_id, issue_id, mentions):
|
||||
# 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,
|
||||
).exists()
|
||||
and not IssueAssignee.objects.filter(
|
||||
project_id=project_id,
|
||||
issue_id=issue_id,
|
||||
assignee_id=mention_id,
|
||||
project_id=project_id, issue_id=issue_id, assignee_id=mention_id
|
||||
).exists()
|
||||
and not Issue.objects.filter(
|
||||
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)
|
||||
html = data.get("description_html")
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
mention_tags = soup.find_all(
|
||||
"mention-component", attrs={"target": "users"}
|
||||
)
|
||||
mention_tags = soup.find_all("mention-component", attrs={"target": "users"})
|
||||
|
||||
mentions = [mention_tag["id"] for mention_tag in mention_tags]
|
||||
|
||||
@ -136,14 +129,12 @@ def extract_mentions(issue_instance):
|
||||
return []
|
||||
|
||||
|
||||
# =========== Comment Parsing and Notification Functions ======================
|
||||
# =========== Comment Parsing and notification Functions ======================
|
||||
def extract_comment_mentions(comment_value):
|
||||
try:
|
||||
mentions = []
|
||||
soup = BeautifulSoup(comment_value, "html.parser")
|
||||
mentions_tags = soup.find_all(
|
||||
"mention-component", attrs={"target": "users"}
|
||||
)
|
||||
mentions_tags = soup.find_all("mention-component", attrs={"target": "users"})
|
||||
for mention_tag in mentions_tags:
|
||||
mentions.append(mention_tag["id"])
|
||||
return list(set(mentions))
|
||||
@ -165,14 +156,8 @@ def get_new_comment_mentions(new_value, old_value):
|
||||
return new_mentions
|
||||
|
||||
|
||||
def createMentionNotification(
|
||||
project,
|
||||
notification_comment,
|
||||
issue,
|
||||
actor_id,
|
||||
mention_id,
|
||||
issue_id,
|
||||
activity,
|
||||
def create_mention_notification(
|
||||
project, notification_comment, issue, actor_id, mention_id, issue_id, activity
|
||||
):
|
||||
return Notification(
|
||||
workspace=project.workspace,
|
||||
@ -215,6 +200,7 @@ def notifications(
|
||||
requested_data,
|
||||
current_instance,
|
||||
):
|
||||
try:
|
||||
issue_activities_created = (
|
||||
json.loads(issue_activities_created)
|
||||
if issue_activities_created is not None
|
||||
@ -238,6 +224,7 @@ def notifications(
|
||||
]:
|
||||
# Create Notifications
|
||||
bulk_notifications = []
|
||||
bulk_email_logs = []
|
||||
|
||||
"""
|
||||
Mention Tasks
|
||||
@ -259,7 +246,9 @@ def notifications(
|
||||
all_comment_mentions = []
|
||||
|
||||
# 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(
|
||||
project_id=project_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
|
||||
"""
|
||||
|
||||
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(
|
||||
IssueSubscriber.objects.filter(
|
||||
project_id=project_id, issue_id=issue_id
|
||||
@ -318,56 +300,85 @@ def notifications(
|
||||
|
||||
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:
|
||||
# add the user to issue subscriber
|
||||
try:
|
||||
if (
|
||||
str(issue.created_by_id) != str(actor_id)
|
||||
and uuid.UUID(actor_id) not in issue_assignees
|
||||
):
|
||||
_ = IssueSubscriber.objects.get_or_create(
|
||||
project_id=project_id,
|
||||
issue_id=issue_id,
|
||||
subscriber_id=actor_id,
|
||||
project_id=project_id, issue_id=issue_id, subscriber_id=actor_id
|
||||
)
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
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(
|
||||
set(issue_subscribers + issue_assignees) - {uuid.UUID(actor_id)}
|
||||
set(issue_subscribers) - {uuid.UUID(actor_id)}
|
||||
)
|
||||
|
||||
for subscriber in issue_subscribers:
|
||||
if subscriber in issue_subscribers:
|
||||
sender = "in_app:issue_activities:subscribed"
|
||||
if (
|
||||
issue.created_by_id is not None
|
||||
and subscriber == issue.created_by_id
|
||||
):
|
||||
if issue.created_by_id and issue.created_by_id == subscriber:
|
||||
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"
|
||||
else:
|
||||
sender = "in_app:issue_activities:subscribed"
|
||||
|
||||
preference = UserNotificationPreference.objects.get(
|
||||
user_id=subscriber
|
||||
)
|
||||
|
||||
for issue_activity in issue_activities_created:
|
||||
# Do not send notification for description update
|
||||
if issue_activity.get("field") == "description":
|
||||
continue
|
||||
issue_comment = issue_activity.get("issue_comment")
|
||||
if issue_comment is not None:
|
||||
issue_comment = IssueComment.objects.get(
|
||||
id=issue_comment,
|
||||
|
||||
# Check if the value should be sent or not
|
||||
send_email = False
|
||||
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,
|
||||
project_id=project_id,
|
||||
workspace_id=project.workspace_id,
|
||||
).first()
|
||||
if issue_activity.get("issue_comment")
|
||||
else None
|
||||
)
|
||||
|
||||
# Create in app notification
|
||||
bulk_notifications.append(
|
||||
Notification(
|
||||
workspace=project.workspace,
|
||||
@ -382,7 +393,9 @@ def notifications(
|
||||
"issue": {
|
||||
"id": str(issue_id),
|
||||
"name": str(issue.name),
|
||||
"identifier": str(issue.project.identifier),
|
||||
"identifier": str(
|
||||
issue.project.identifier
|
||||
),
|
||||
"sequence_id": issue.sequence_id,
|
||||
"state_name": issue.state.name,
|
||||
"state_group": issue.state.group,
|
||||
@ -391,7 +404,9 @@ def notifications(
|
||||
"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")),
|
||||
"actor": str(
|
||||
issue_activity.get("actor_id")
|
||||
),
|
||||
"new_value": str(
|
||||
issue_activity.get("new_value")
|
||||
),
|
||||
@ -400,18 +415,71 @@ def notifications(
|
||||
),
|
||||
"issue_comment": str(
|
||||
issue_comment.comment_stripped
|
||||
if issue_activity.get("issue_comment")
|
||||
is not None
|
||||
if issue_comment is not None
|
||||
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
|
||||
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 = (
|
||||
@ -424,8 +492,11 @@ def notifications(
|
||||
|
||||
for mention_id in comment_mentions:
|
||||
if mention_id != actor_id:
|
||||
preference = UserNotificationPreference.objects.get(
|
||||
user_id=mention_id
|
||||
)
|
||||
for issue_activity in issue_activities_created:
|
||||
notification = createMentionNotification(
|
||||
notification = create_mention_notification(
|
||||
project=project,
|
||||
issue=issue,
|
||||
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,
|
||||
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)
|
||||
|
||||
for mention_id in new_mentions:
|
||||
if mention_id != actor_id:
|
||||
preference = UserNotificationPreference.objects.get(
|
||||
user_id=mention_id
|
||||
)
|
||||
if (
|
||||
last_activity is not None
|
||||
and last_activity.field == "description"
|
||||
@ -463,21 +584,64 @@ def notifications(
|
||||
"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(last_activity.id),
|
||||
"verb": str(last_activity.verb),
|
||||
"field": str(last_activity.field),
|
||||
"actor": str(last_activity.actor_id),
|
||||
"new_value": str(last_activity.new_value),
|
||||
"old_value": str(last_activity.old_value),
|
||||
"new_value": str(
|
||||
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:
|
||||
for issue_activity in issue_activities_created:
|
||||
notification = createMentionNotification(
|
||||
notification = create_mention_notification(
|
||||
project=project,
|
||||
issue=issue,
|
||||
notification_comment=f"You have been mentioned in the issue {issue.name}",
|
||||
@ -486,6 +650,51 @@ def notifications(
|
||||
issue_id=issue_id,
|
||||
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)
|
||||
|
||||
# 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,
|
||||
removed_mention=removed_mention,
|
||||
)
|
||||
|
||||
# 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
|
||||
|
@ -2,6 +2,7 @@ import os
|
||||
from celery import Celery
|
||||
from plane.settings.redis import redis_instance
|
||||
from celery.schedules import crontab
|
||||
from django.utils.timezone import timedelta
|
||||
|
||||
# Set the default Django settings module for the 'celery' program.
|
||||
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",
|
||||
"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.
|
||||
|
@ -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",),
|
||||
},
|
||||
),
|
||||
]
|
28
apiserver/plane/db/migrations/0057_auto_20240122_0901.py
Normal file
28
apiserver/plane/db/migrations/0057_auto_20240122_0901.py
Normal 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)
|
||||
]
|
@ -85,7 +85,7 @@ from .inbox import Inbox, InboxIssue
|
||||
|
||||
from .analytic import AnalyticView
|
||||
|
||||
from .notification import Notification
|
||||
from .notification import Notification, UserNotificationPreference, EmailNotificationLog
|
||||
|
||||
from .exporter import ExporterHistory
|
||||
|
||||
|
@ -1,9 +1,9 @@
|
||||
# Django imports
|
||||
from django.db import models
|
||||
from django.conf import settings
|
||||
|
||||
# Third party imports
|
||||
from .base import BaseModel
|
||||
|
||||
# Module imports
|
||||
from . import BaseModel
|
||||
|
||||
class Notification(BaseModel):
|
||||
workspace = models.ForeignKey(
|
||||
@ -47,3 +47,82 @@ class Notification(BaseModel):
|
||||
def __str__(self):
|
||||
"""Return name of the notifications"""
|
||||
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",)
|
||||
|
@ -11,8 +11,16 @@ from django.contrib.auth.models import (
|
||||
UserManager,
|
||||
PermissionsMixin,
|
||||
)
|
||||
from django.db.models.signals import post_save
|
||||
from django.conf import settings
|
||||
from django.dispatch import receiver
|
||||
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():
|
||||
return {
|
||||
@ -134,3 +142,34 @@ class User(AbstractBaseUser, PermissionsMixin):
|
||||
self.is_staff = True
|
||||
|
||||
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,
|
||||
)
|
||||
|
@ -291,6 +291,7 @@ CELERY_IMPORTS = (
|
||||
"plane.bgtasks.issue_automation_task",
|
||||
"plane.bgtasks.exporter_expired_task",
|
||||
"plane.bgtasks.file_asset_task",
|
||||
"plane.bgtasks.email_notification_task",
|
||||
)
|
||||
|
||||
# Sentry Settings
|
||||
|
903
apiserver/templates/emails/notifications/issue-updates.html
Normal file
903
apiserver/templates/emails/notifications/issue-updates.html
Normal 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>
|
8
packages/types/src/users.d.ts
vendored
8
packages/types/src/users.d.ts
vendored
@ -166,6 +166,14 @@ export interface IUserProjectsRole {
|
||||
[projectId: string]: EUserProjectRoles;
|
||||
}
|
||||
|
||||
export interface IUserEmailNotificationSettings {
|
||||
property_change: boolean;
|
||||
state_change: boolean;
|
||||
comment: boolean;
|
||||
mention: boolean;
|
||||
issue_completed: boolean;
|
||||
}
|
||||
|
||||
// export interface ICurrentUser {
|
||||
// id: readonly string;
|
||||
// avatar: string;
|
||||
|
@ -154,12 +154,7 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{currentUser && !is_archived && (fieldsToShow.includes("all") || fieldsToShow.includes("subscribe")) && (
|
||||
<IssueSubscription
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
currentUserId={currentUser?.id}
|
||||
/>
|
||||
<IssueSubscription workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} />
|
||||
)}
|
||||
|
||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("link")) && (
|
||||
|
@ -11,14 +11,12 @@ export type TIssueSubscription = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
currentUserId: string;
|
||||
};
|
||||
|
||||
export const IssueSubscription: FC<TIssueSubscription> = observer((props) => {
|
||||
const { workspaceSlug, projectId, issueId, currentUserId } = props;
|
||||
const { workspaceSlug, projectId, issueId } = props;
|
||||
// hooks
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
subscription: { getSubscriptionByIssueId },
|
||||
createSubscription,
|
||||
removeSubscription,
|
||||
@ -27,7 +25,6 @@ export const IssueSubscription: FC<TIssueSubscription> = observer((props) => {
|
||||
// state
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const issue = getIssueById(issueId);
|
||||
const subscription = getSubscriptionByIssueId(issueId);
|
||||
|
||||
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 (
|
||||
<div>
|
||||
<Button
|
||||
|
@ -188,12 +188,7 @@ export const IssueView: FC<IIssueView> = observer((props) => {
|
||||
<IssueUpdateStatus isSubmitting={isSubmitting} />
|
||||
<div className="flex items-center gap-4">
|
||||
{currentUser && !is_archived && (
|
||||
<IssueSubscription
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
currentUserId={currentUser?.id}
|
||||
/>
|
||||
<IssueSubscription workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} />
|
||||
)}
|
||||
<button onClick={handleCopyText}>
|
||||
<Link2 className="h-4 w-4 -rotate-45 text-custom-text-300 hover:text-custom-text-200" />
|
||||
|
188
web/components/profile/preferences/email-notification-form.tsx
Normal file
188
web/components/profile/preferences/email-notification-form.tsx
Normal 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 issue’s 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>
|
||||
</>
|
||||
);
|
||||
};
|
1
web/components/profile/preferences/index.ts
Normal file
1
web/components/profile/preferences/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./email-notification-form";
|
@ -33,7 +33,7 @@ export const PROFILE_ACTION_LINKS: {
|
||||
{
|
||||
key: "preferences",
|
||||
label: "Preferences",
|
||||
href: `/profile/preferences`,
|
||||
href: `/profile/preferences/theme`,
|
||||
highlight: (pathname: string) => pathname.includes("/profile/preferences"),
|
||||
Icon: Settings2,
|
||||
},
|
||||
|
2
web/layouts/settings-layout/profile/preferences/index.ts
Normal file
2
web/layouts/settings-layout/profile/preferences/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from "./layout";
|
||||
export * from "./sidebar";
|
25
web/layouts/settings-layout/profile/preferences/layout.tsx
Normal file
25
web/layouts/settings-layout/profile/preferences/layout.tsx
Normal 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>
|
||||
);
|
||||
};
|
43
web/layouts/settings-layout/profile/preferences/sidebar.tsx
Normal file
43
web/layouts/settings-layout/profile/preferences/sidebar.tsx
Normal 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>
|
||||
);
|
||||
};
|
36
web/pages/profile/preferences/email.tsx
Normal file
36
web/pages/profile/preferences/email.tsx
Normal 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;
|
@ -5,7 +5,7 @@ import { useTheme } from "next-themes";
|
||||
import { useUser } from "hooks/store";
|
||||
import useToast from "hooks/use-toast";
|
||||
// layouts
|
||||
import { ProfileSettingsLayout } from "layouts/settings-layout";
|
||||
import { ProfilePreferenceSettingsLayout } from "layouts/settings-layout/profile/preferences";
|
||||
// components
|
||||
import { CustomThemeSelector, ThemeSwitch } from "components/core";
|
||||
// ui
|
||||
@ -15,7 +15,7 @@ import { I_THEME_OPTION, THEME_OPTIONS } from "constants/themes";
|
||||
// type
|
||||
import { NextPageWithLayout } from "lib/types";
|
||||
|
||||
const ProfilePreferencesPage: NextPageWithLayout = observer(() => {
|
||||
const ProfilePreferencesThemePage: NextPageWithLayout = observer(() => {
|
||||
// states
|
||||
const [currentTheme, setCurrentTheme] = useState<I_THEME_OPTION | null>(null);
|
||||
// store hooks
|
||||
@ -48,7 +48,7 @@ const ProfilePreferencesPage: NextPageWithLayout = observer(() => {
|
||||
return (
|
||||
<>
|
||||
{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">
|
||||
<h3 className="text-xl font-medium">Preferences</h3>
|
||||
</div>
|
||||
@ -72,8 +72,8 @@ const ProfilePreferencesPage: NextPageWithLayout = observer(() => {
|
||||
);
|
||||
});
|
||||
|
||||
ProfilePreferencesPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return <ProfileSettingsLayout>{page}</ProfileSettingsLayout>;
|
||||
ProfilePreferencesThemePage.getLayout = function getLayout(page: ReactElement) {
|
||||
return <ProfilePreferenceSettingsLayout>{page}</ProfilePreferenceSettingsLayout>;
|
||||
};
|
||||
|
||||
export default ProfilePreferencesPage;
|
||||
export default ProfilePreferencesThemePage;
|
@ -10,7 +10,7 @@ import type {
|
||||
IUserProfileProjectSegregation,
|
||||
IUserSettings,
|
||||
IUserWorkspaceDashboard,
|
||||
TIssueMap,
|
||||
IUserEmailNotificationSettings,
|
||||
} from "@plane/types";
|
||||
// helpers
|
||||
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> {
|
||||
return this.patch("/api/users/me/", 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> {
|
||||
return this.get(`/api/users/me/activities/`)
|
||||
.then((response) => response?.data)
|
||||
|
Loading…
Reference in New Issue
Block a user