dev: email notifications (#3421)

* dev: create email notification preference model

* dev: intiate models

* dev: user notification preferences

* dev: create notification logs for the user.

* dev: email notification stacking and sending logic

* feat: email notification preference settings page.

* dev: delete subscribers

* dev: issue update ui implementation in email notification

* chore: integrate email notification endpoint.

* chore: remove toggle switch.

* chore: added labels part

* fix: refactored base design with tables

* dev: email notification templates

* dev: template updates

* dev: update models

* dev: update template for labels and new migrations

* fix: profile settings preference sidebar.

* dev: update preference endpoints

* dev: update the schedule to 5 minutes

* dev: update template with priority data

* dev: update templates

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

* chore: notification handling for external api

* dev: update origin request

---------

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -24,6 +24,7 @@ from plane.db.models import (
Label,
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()

View File

@ -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:
@ -1623,22 +1648,24 @@ def issue_activity(
)
except Exception as e:
capture_exception(e)
notifications.delay(
type=type,
issue_id=issue_id,
actor_id=actor_id,
project_id=project_id,
subscriber=subscriber,
issue_activities_created=json.dumps(
IssueActivitySerializer(
issue_activities_created, many=True
).data,
cls=DjangoJSONEncoder,
),
requested_data=requested_data,
current_instance=current_instance,
)
if notification:
notifications.delay(
type=type,
issue_id=issue_id,
actor_id=actor_id,
project_id=project_id,
subscriber=subscriber,
issue_activities_created=json.dumps(
IssueActivitySerializer(
issue_activities_created, many=True
).data,
cls=DjangoJSONEncoder,
),
requested_data=requested_data,
current_instance=current_instance,
)
return
except Exception as e:
@ -1646,4 +1673,4 @@ def issue_activity(
if settings.DEBUG:
print(e)
capture_exception(e)
return
return

View File

@ -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
]

View File

@ -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,244 +200,195 @@ def notifications(
requested_data,
current_instance,
):
issue_activities_created = (
json.loads(issue_activities_created)
if issue_activities_created is not None
else None
)
if type not in [
"issue.activity.deleted",
"cycle.activity.created",
"cycle.activity.deleted",
"module.activity.created",
"module.activity.deleted",
"issue_reaction.activity.created",
"issue_reaction.activity.deleted",
"comment_reaction.activity.created",
"comment_reaction.activity.deleted",
"issue_vote.activity.created",
"issue_vote.activity.deleted",
"issue_draft.activity.created",
"issue_draft.activity.updated",
"issue_draft.activity.deleted",
]:
# Create Notifications
bulk_notifications = []
"""
Mention Tasks
1. Perform Diffing and Extract the mentions, that mention notification needs to be sent
2. From the latest set of mentions, extract the users which are not a subscribers & make them subscribers
"""
# Get new mentions from the newer instance
new_mentions = get_new_mentions(
requested_instance=requested_data,
current_instance=current_instance,
)
removed_mention = get_removed_mentions(
requested_instance=requested_data,
current_instance=current_instance,
try:
issue_activities_created = (
json.loads(issue_activities_created)
if issue_activities_created is not None
else None
)
if type not in [
"issue.activity.deleted",
"cycle.activity.created",
"cycle.activity.deleted",
"module.activity.created",
"module.activity.deleted",
"issue_reaction.activity.created",
"issue_reaction.activity.deleted",
"comment_reaction.activity.created",
"comment_reaction.activity.deleted",
"issue_vote.activity.created",
"issue_vote.activity.deleted",
"issue_draft.activity.created",
"issue_draft.activity.updated",
"issue_draft.activity.deleted",
]:
# Create Notifications
bulk_notifications = []
bulk_email_logs = []
comment_mentions = []
all_comment_mentions = []
"""
Mention Tasks
1. Perform Diffing and Extract the mentions, that mention notification needs to be sent
2. From the latest set of mentions, extract the users which are not a subscribers & make them subscribers
"""
# Get New Subscribers from the mentions of the newer instance
requested_mentions = extract_mentions(issue_instance=requested_data)
mention_subscribers = extract_mentions_as_subscribers(
project_id=project_id,
issue_id=issue_id,
mentions=requested_mentions,
)
for issue_activity in issue_activities_created:
issue_comment = issue_activity.get("issue_comment")
issue_comment_new_value = issue_activity.get("new_value")
issue_comment_old_value = issue_activity.get("old_value")
if issue_comment is not None:
# TODO: Maybe save the comment mentions, so that in future, we can filter out the issues based on comment mentions as well.
all_comment_mentions = (
all_comment_mentions
+ extract_comment_mentions(issue_comment_new_value)
)
new_comment_mentions = get_new_comment_mentions(
old_value=issue_comment_old_value,
new_value=issue_comment_new_value,
)
comment_mentions = comment_mentions + new_comment_mentions
comment_mention_subscribers = extract_mentions_as_subscribers(
project_id=project_id,
issue_id=issue_id,
mentions=all_comment_mentions,
)
"""
We will not send subscription activity notification to the below mentioned user sets
- Those who have been newly mentioned in the issue description, we will send mention notification to them.
- When the activity is a comment_created and there exist a mention in the comment, then we have to send the "mention_in_comment" notification
- When the activity is a comment_updated and there exist a mention change, then also we have to send the "mention_in_comment" notification
"""
issue_assignees = list(
IssueAssignee.objects.filter(
project_id=project_id, issue_id=issue_id
# Get new mentions from the newer instance
new_mentions = get_new_mentions(
requested_instance=requested_data,
current_instance=current_instance,
)
.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
removed_mention = get_removed_mentions(
requested_instance=requested_data,
current_instance=current_instance,
)
.exclude(
subscriber_id__in=list(
new_mentions + comment_mentions + [actor_id]
)
comment_mentions = []
all_comment_mentions = []
# Get New Subscribers from the mentions of the newer instance
requested_mentions = extract_mentions(
issue_instance=requested_data
)
mention_subscribers = extract_mentions_as_subscribers(
project_id=project_id,
issue_id=issue_id,
mentions=requested_mentions,
)
.values_list("subscriber", flat=True)
)
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,
)
except Exception as e:
pass
project = Project.objects.get(pk=project_id)
issue_subscribers = list(
set(issue_subscribers + issue_assignees) - {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
):
sender = "in_app:issue_activities:created"
if subscriber in issue_assignees:
sender = "in_app:issue_activities:assigned"
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")
issue_comment_new_value = issue_activity.get("new_value")
issue_comment_old_value = issue_activity.get("old_value")
if issue_comment is not None:
issue_comment = IssueComment.objects.get(
id=issue_comment,
issue_id=issue_id,
project_id=project_id,
workspace_id=project.workspace_id,
# TODO: Maybe save the comment mentions, so that in future, we can filter out the issues based on comment mentions as well.
all_comment_mentions = (
all_comment_mentions
+ extract_comment_mentions(issue_comment_new_value)
)
bulk_notifications.append(
Notification(
workspace=project.workspace,
sender=sender,
triggered_by_id=actor_id,
receiver_id=subscriber,
entity_identifier=issue_id,
entity_name="issue",
project=project,
title=issue_activity.get("comment"),
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(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_activity.get("issue_comment")
is not None
else ""
),
},
},
new_comment_mentions = get_new_comment_mentions(
old_value=issue_comment_old_value,
new_value=issue_comment_new_value,
)
comment_mentions = comment_mentions + new_comment_mentions
comment_mention_subscribers = extract_mentions_as_subscribers(
project_id=project_id,
issue_id=issue_id,
mentions=all_comment_mentions,
)
"""
We will not send subscription activity notification to the below mentioned user sets
- Those who have been newly mentioned in the issue description, we will send mention notification to them.
- When the activity is a comment_created and there exist a mention in the comment, then we have to send the "mention_in_comment" notification
- When the activity is a comment_updated and there exist a mention change, then also we have to send the "mention_in_comment" notification
"""
# ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- #
issue_subscribers = list(
IssueSubscriber.objects.filter(
project_id=project_id, issue_id=issue_id
)
.exclude(
subscriber_id__in=list(
new_mentions + comment_mentions + [actor_id]
)
)
.values_list("subscriber", flat=True)
)
# Add Mentioned as Issue Subscribers
IssueSubscriber.objects.bulk_create(
mention_subscribers + comment_mention_subscribers, batch_size=100
)
issue = Issue.objects.filter(pk=issue_id).first()
last_activity = (
IssueActivity.objects.filter(issue_id=issue_id)
.order_by("-created_at")
.first()
)
actor = User.objects.get(pk=actor_id)
for mention_id in comment_mentions:
if mention_id != actor_id:
for issue_activity in issue_activities_created:
notification = createMentionNotification(
project=project,
issue=issue,
notification_comment=f"{actor.display_name} has mentioned you in a comment in issue {issue.name}",
actor_id=actor_id,
mention_id=mention_id,
issue_id=issue_id,
activity=issue_activity,
if subscriber:
# add the user to issue subscriber
try:
_ = IssueSubscriber.objects.get_or_create(
project_id=project_id, issue_id=issue_id, subscriber_id=actor_id
)
bulk_notifications.append(notification)
except Exception as e:
pass
for mention_id in new_mentions:
if mention_id != actor_id:
if (
last_activity is not None
and last_activity.field == "description"
and actor_id == str(last_activity.actor_id)
project = Project.objects.get(pk=project_id)
issue_assignees = IssueAssignee.objects.filter(
issue_id=issue_id, project_id=project_id
).values_list("assignee", flat=True)
issue_subscribers = list(
set(issue_subscribers) - {uuid.UUID(actor_id)}
)
for subscriber in issue_subscribers:
if issue.created_by_id and issue.created_by_id == subscriber:
sender = "in_app:issue_activities:created"
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
# 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,
sender="in_app:issue_activities:mentioned",
sender=sender,
triggered_by_id=actor_id,
receiver_id=mention_id,
receiver_id=subscriber,
entity_identifier=issue_id,
entity_name="issue",
project=project,
message=f"You have been mentioned in the issue {issue.name}",
title=issue_activity.get("comment"),
data={
"issue": {
"id": str(issue_id),
@ -465,36 +401,317 @@ def notifications(
"state_group": issue.state.group,
},
"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),
"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 ""
),
},
},
)
)
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,
ignore_conflicts=True,
)
last_activity = (
IssueActivity.objects.filter(issue_id=issue_id)
.order_by("-created_at")
.first()
)
actor = User.objects.get(pk=actor_id)
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"You have been mentioned in the issue {issue.name}",
notification_comment=f"{actor.display_name} has mentioned you in a comment in issue {issue.name}",
actor_id=actor_id,
mention_id=mention_id,
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)
# save new mentions for the particular issue and remove the mentions that has been deleted from the description
update_mentions_for_issue(
issue=issue,
project=project,
new_mentions=new_mentions,
removed_mention=removed_mention,
)
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"
and actor_id == str(last_activity.actor_id)
):
bulk_notifications.append(
Notification(
workspace=project.workspace,
sender="in_app:issue_activities:mentioned",
triggered_by_id=actor_id,
receiver_id=mention_id,
entity_identifier=issue_id,
entity_name="issue",
project=project,
message=f"You have been mentioned in the issue {issue.name}",
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(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
),
},
},
)
)
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 = create_mention_notification(
project=project,
issue=issue,
notification_comment=f"You have been mentioned in the issue {issue.name}",
actor_id=actor_id,
mention_id=mention_id,
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)
# Bulk create notifications
Notification.objects.bulk_create(bulk_notifications, batch_size=100)
# save new mentions for the particular issue and remove the mentions that has been deleted from the description
update_mentions_for_issue(
issue=issue,
project=project,
new_mentions=new_mentions,
removed_mention=removed_mention,
)
# Bulk create notifications
Notification.objects.bulk_create(
bulk_notifications, batch_size=100
)
EmailNotificationLog.objects.bulk_create(
bulk_email_logs, batch_size=100, ignore_conflicts=True
)
return
except Exception as e:
print(e)
return

View File

@ -2,6 +2,7 @@ import os
from celery import Celery
from 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.

View File

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

View File

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

View File

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

View File

@ -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",)

View File

@ -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,
)

View File

@ -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

View File

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

View File

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

View File

@ -154,12 +154,7 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
<div className="flex flex-wrap items-center gap-2">
{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")) && (

View File

@ -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

View File

@ -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" />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -5,7 +5,7 @@ import { useTheme } from "next-themes";
import { useUser } from "hooks/store";
import 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;

View File

@ -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)