From 0230563ad0ea8b9ef817ce22ae2f66b3b453a5fe Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Wed, 10 Jan 2024 12:14:08 +0530 Subject: [PATCH 01/24] dev: create email notification preference model --- apiserver/plane/db/models/__init__.py | 2 +- apiserver/plane/db/models/notification.py | 43 ++++++++++++++++++++++- 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/apiserver/plane/db/models/__init__.py b/apiserver/plane/db/models/__init__.py index 3a07a33f3d..b2d3393244 100644 --- a/apiserver/plane/db/models/__init__.py +++ b/apiserver/plane/db/models/__init__.py @@ -85,7 +85,7 @@ from .analytic import AnalyticView -from .notification import Notification +from .notification import Notification, NotificationPreference from .exporter import ExporterHistory diff --git a/apiserver/plane/db/models/notification.py b/apiserver/plane/db/models/notification.py index 8e6a48e146..389069077c 100644 --- a/apiserver/plane/db/models/notification.py +++ b/apiserver/plane/db/models/notification.py @@ -1,9 +1,10 @@ # Django imports from django.db import models +from django.conf import settings # Third party imports from .base import BaseModel - +from .project import ProjectBaseModel class Notification(BaseModel): workspace = models.ForeignKey( @@ -47,3 +48,43 @@ class Meta: 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 NotificationPreference(ProjectBaseModel): + created_by = models.JSONField(default=get_default_preference) + assigned = models.JSONField(default=get_default_preference) + subscribed = models.JSONField(default=get_default_preference) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="notification_preferences", + ) + + class Meta: + unique_together = ["project", "user"] + + verbose_name = "Notification Preference" + verbose_name_plural = "Notification Preferences" + db_table = "notification_preferences" + ordering = ("-created_at",) + + def __str__(self): + """Return name of the notifications""" + return f"{self.user.email} <{self.workspace.name}>" From 88f5d2bcced32a69411478d487fe6c33c8b9daa4 Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Wed, 10 Jan 2024 18:07:35 +0530 Subject: [PATCH 02/24] dev: intiate models --- apiserver/plane/bgtasks/notification_task.py | 97 +++++++++---------- ...lnotificationlog_notificationpreference.py | 62 ++++++++++++ apiserver/plane/db/models/__init__.py | 2 +- apiserver/plane/db/models/notification.py | 21 +++- 4 files changed, 128 insertions(+), 54 deletions(-) create mode 100644 apiserver/plane/db/migrations/0054_emailnotificationlog_notificationpreference.py diff --git a/apiserver/plane/bgtasks/notification_task.py b/apiserver/plane/bgtasks/notification_task.py index 5649ad6b7d..5784bea6f0 100644 --- a/apiserver/plane/bgtasks/notification_task.py +++ b/apiserver/plane/bgtasks/notification_task.py @@ -13,6 +13,8 @@ Notification, IssueComment, IssueActivity, + NotificationPreference, + EmailNotificationLog, ) # Third Party imports @@ -37,9 +39,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): @@ -95,9 +95,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 +123,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] @@ -141,9 +137,7 @@ 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 +159,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, @@ -238,6 +226,7 @@ def notifications( ]: # Create Notifications bulk_notifications = [] + bulk_email_logs = [] """ Mention Tasks @@ -247,12 +236,10 @@ def notifications( # Get new mentions from the newer instance new_mentions = get_new_mentions( - requested_instance=requested_data, - current_instance=current_instance, + requested_instance=requested_data, current_instance=current_instance ) removed_mention = get_removed_mentions( - requested_instance=requested_data, - current_instance=current_instance, + requested_instance=requested_data, current_instance=current_instance ) comment_mentions = [] @@ -261,9 +248,7 @@ def notifications( # 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, + project_id=project_id, issue_id=issue_id, mentions=requested_mentions ) for issue_activity in issue_activities_created: @@ -273,21 +258,17 @@ def notifications( 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) + 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, + 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, + 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 @@ -297,21 +278,15 @@ def notifications( """ issue_assignees = list( - IssueAssignee.objects.filter( - project_id=project_id, issue_id=issue_id - ) + 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 - ) + IssueSubscriber.objects.filter(project_id=project_id, issue_id=issue_id) .exclude( - subscriber_id__in=list( - new_mentions + comment_mentions + [actor_id] - ) + subscriber_id__in=list(new_mentions + comment_mentions + [actor_id]) ) .values_list("subscriber", flat=True) ) @@ -346,13 +321,13 @@ def notifications( for subscriber in issue_subscribers: if subscriber in issue_subscribers: + trigger = "subscribed" 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 is not None and subscriber == issue.created_by_id: + trigger = "created" sender = "in_app:issue_activities:created" if subscriber in issue_assignees: + trigger = "assigned" sender = "in_app:issue_activities:assigned" for issue_activity in issue_activities_created: @@ -368,6 +343,19 @@ def notifications( workspace_id=project.workspace_id, ) + preference = NotificationPreference.objects.filter( + user=subscriber, + workspace_id=project.workspace_id, + project_id=project_id, + ).values("created", "assigned", "subscribed").first() + + if issue_activity.get("field") == "state": + preference = preference.get(trigger).get("state") + elif issue_activity.get("field") == "comment": + preference = preference.get(trigger).get("comment") + else: + preference = preference.get(trigger).get("property_change") + bulk_notifications.append( Notification( workspace=project.workspace, @@ -409,6 +397,15 @@ def notifications( ) ) + if preference: + bulk_email_logs( + EmailNotificationLog( + user_id=subscriber, + + ) + ) + + # Add Mentioned as Issue Subscribers IssueSubscriber.objects.bulk_create( mention_subscribers + comment_mention_subscribers, batch_size=100 @@ -425,7 +422,7 @@ def notifications( for mention_id in comment_mentions: if mention_id != actor_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}", @@ -477,7 +474,7 @@ def notifications( ) 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}", diff --git a/apiserver/plane/db/migrations/0054_emailnotificationlog_notificationpreference.py b/apiserver/plane/db/migrations/0054_emailnotificationlog_notificationpreference.py new file mode 100644 index 0000000000..97a507a6e5 --- /dev/null +++ b/apiserver/plane/db/migrations/0054_emailnotificationlog_notificationpreference.py @@ -0,0 +1,62 @@ +# Generated by Django 4.2.7 on 2024-01-10 08:42 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import plane.db.models.notification +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0053_auto_20240102_1315'), + ] + + operations = [ + 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)), + ('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')), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', 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_logs', to=settings.AUTH_USER_MODEL)), + ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace')), + ], + options={ + 'verbose_name': 'Email Notification Log', + 'verbose_name_plural': 'Email Notification Logs', + 'db_table': 'email_notification_logs', + 'ordering': ('-created_at',), + }, + ), + migrations.CreateModel( + name='NotificationPreference', + 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)), + ('created_by', models.JSONField(default=plane.db.models.notification.get_default_preference)), + ('assigned', models.JSONField(default=plane.db.models.notification.get_default_preference)), + ('subscribed', models.JSONField(default=plane.db.models.notification.get_default_preference)), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', 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(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace')), + ], + options={ + 'verbose_name': 'Notification Preference', + 'verbose_name_plural': 'Notification Preferences', + 'db_table': 'notification_preferences', + 'ordering': ('-created_at',), + 'unique_together': {('project', 'user')}, + }, + ), + ] diff --git a/apiserver/plane/db/models/__init__.py b/apiserver/plane/db/models/__init__.py index b2d3393244..09430591f4 100644 --- a/apiserver/plane/db/models/__init__.py +++ b/apiserver/plane/db/models/__init__.py @@ -85,7 +85,7 @@ from .analytic import AnalyticView -from .notification import Notification, NotificationPreference +from .notification import Notification, NotificationPreference, EmailNotificationLog from .exporter import ExporterHistory diff --git a/apiserver/plane/db/models/notification.py b/apiserver/plane/db/models/notification.py index 389069077c..8ae2a1ef3f 100644 --- a/apiserver/plane/db/models/notification.py +++ b/apiserver/plane/db/models/notification.py @@ -67,8 +67,8 @@ def get_default_preference(): } -class NotificationPreference(ProjectBaseModel): - created_by = models.JSONField(default=get_default_preference) +class UserNotificationPreference(BaseModel): + created = models.JSONField(default=get_default_preference) assigned = models.JSONField(default=get_default_preference) subscribed = models.JSONField(default=get_default_preference) user = models.ForeignKey( @@ -82,9 +82,24 @@ class Meta: verbose_name = "Notification Preference" verbose_name_plural = "Notification Preferences" - db_table = "notification_preferences" + db_table = "user_notification_preferences" ordering = ("-created_at",) def __str__(self): """Return name of the notifications""" return f"{self.user.email} <{self.workspace.name}>" + + +class EmailNotificationLog(ProjectBaseModel): + receiver = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="notifications") + triggered_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="triggered_by_notifications") + 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",) From 793d85eb2b8e16a59743197290feedb9b396b8c5 Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Fri, 12 Jan 2024 12:31:10 +0530 Subject: [PATCH 03/24] dev: user notification preferences --- apiserver/plane/bgtasks/importer_task.py | 17 +++- apiserver/plane/bgtasks/notification_task.py | 92 ++++++------------- .../db/migrations/0056_auto_20240111_1454.py | 30 ++++++ apiserver/plane/db/models/user.py | 32 +++++++ 4 files changed, 106 insertions(+), 65 deletions(-) create mode 100644 apiserver/plane/db/migrations/0056_auto_20240111_1454.py diff --git a/apiserver/plane/bgtasks/importer_task.py b/apiserver/plane/bgtasks/importer_task.py index f580852491..4215213635 100644 --- a/apiserver/plane/bgtasks/importer_task.py +++ b/apiserver/plane/bgtasks/importer_task.py @@ -24,6 +24,7 @@ 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() diff --git a/apiserver/plane/bgtasks/notification_task.py b/apiserver/plane/bgtasks/notification_task.py index 5784bea6f0..03889f4f27 100644 --- a/apiserver/plane/bgtasks/notification_task.py +++ b/apiserver/plane/bgtasks/notification_task.py @@ -13,8 +13,6 @@ Notification, IssueComment, IssueActivity, - NotificationPreference, - EmailNotificationLog, ) # Third Party imports @@ -22,7 +20,7 @@ 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): @@ -60,8 +58,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 +75,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 @@ -132,7 +126,7 @@ def extract_mentions(issue_instance): return [] -# =========== Comment Parsing and Notification Functions ====================== +# =========== Comment Parsing and notification Functions ====================== def extract_comment_mentions(comment_value): try: mentions = [] @@ -277,12 +271,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) .exclude( @@ -293,68 +282,50 @@ 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, - ) + _ = 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)} - ) + issue_assignees = IssueAssignee.objects.filter( + issue_id=issue_id, project_id=project_id + ).values_list("assignees", flat=True) + + issue_subscribers = list(set(issue_subscribers) - {uuid.UUID(actor_id)}) for subscriber in issue_subscribers: - if subscriber in issue_subscribers: - trigger = "subscribed" - sender = "in_app:issue_activities:subscribed" - if issue.created_by_id is not None and subscriber == issue.created_by_id: - trigger = "created" + if issue.created_by_id and issue.created_by_id == subscriber: sender = "in_app:issue_activities:created" - if subscriber in issue_assignees: - trigger = "assigned" + 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" 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, + + # If activity is of issue comment fetch the comment + issue_comment = ( + IssueComment.objects.get( + id=issue_activity.get("issue_comment"), issue_id=issue_id, project_id=project_id, workspace_id=project.workspace_id, ) - - preference = NotificationPreference.objects.filter( - user=subscriber, - workspace_id=project.workspace_id, - project_id=project_id, - ).values("created", "assigned", "subscribed").first() - - if issue_activity.get("field") == "state": - preference = preference.get(trigger).get("state") - elif issue_activity.get("field") == "comment": - preference = preference.get(trigger).get("comment") - else: - preference = preference.get(trigger).get("property_change") + if issue_activity.get("issue_comment") + else None + ) bulk_notifications.append( Notification( @@ -388,8 +359,7 @@ 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 "" ), }, @@ -397,14 +367,8 @@ def notifications( ) ) - if preference: - bulk_email_logs( - EmailNotificationLog( - user_id=subscriber, - - ) - ) +# ----------------------------------------------------------------------------------------------------------------- # # Add Mentioned as Issue Subscribers IssueSubscriber.objects.bulk_create( diff --git a/apiserver/plane/db/migrations/0056_auto_20240111_1454.py b/apiserver/plane/db/migrations/0056_auto_20240111_1454.py new file mode 100644 index 0000000000..a0d0ccfa34 --- /dev/null +++ b/apiserver/plane/db/migrations/0056_auto_20240111_1454.py @@ -0,0 +1,30 @@ +# Generated by Django 4.2.7 on 2024-01-11 14:54 + +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", "0055_usernotificationpreference_emailnotificationlog"), + ] + + operations = [ + migrations.RunPython(create_notification_preferences), + ] diff --git a/apiserver/plane/db/models/user.py b/apiserver/plane/db/models/user.py index 087162ca51..0a11439db4 100644 --- a/apiserver/plane/db/models/user.py +++ b/apiserver/plane/db/models/user.py @@ -13,6 +13,9 @@ ) from django.utils import timezone +# Module imports +from plane.db.models import UserNotificationPreference + def get_default_onboarding(): return { @@ -134,3 +137,32 @@ def save(self, *args, **kwargs): 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: + UserNotificationPreference.objects.create( + user=instance, + ) From 0bd3d161c69f4ee1272c4a46b69305694fd19c48 Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Fri, 12 Jan 2024 14:34:55 +0530 Subject: [PATCH 04/24] dev: create notification logs for the user. --- apiserver/plane/bgtasks/notification_task.py | 527 +++++++++++-------- apiserver/plane/db/models/notification.py | 5 +- apiserver/plane/db/models/user.py | 5 +- 3 files changed, 322 insertions(+), 215 deletions(-) diff --git a/apiserver/plane/bgtasks/notification_task.py b/apiserver/plane/bgtasks/notification_task.py index 03889f4f27..c2ad2a593d 100644 --- a/apiserver/plane/bgtasks/notification_task.py +++ b/apiserver/plane/bgtasks/notification_task.py @@ -10,9 +10,12 @@ User, IssueAssignee, Issue, + State, + EmailNotificationLog, Notification, IssueComment, IssueActivity, + UserNotificationPreference, ) # Third Party imports @@ -197,223 +200,170 @@ 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 = [] - bulk_email_logs = [] - - """ - 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 = [] + + """ + 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 + ) - 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 - ) + comment_mentions = [] + all_comment_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. + # 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 + ) - all_comment_mentions = all_comment_mentions + extract_comment_mentions( - issue_comment_new_value - ) + 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 + 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]) + comment_mention_subscribers = extract_mentions_as_subscribers( + project_id=project_id, issue_id=issue_id, mentions=all_comment_mentions ) - .values_list("subscriber", flat=True) - ) - - issue = Issue.objects.filter(pk=issue_id).first() - - 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 + """ + 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]) ) - 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("assignees", flat=True) - - issue_subscribers = list(set(issue_subscribers) - {uuid.UUID(actor_id)}) + .values_list("subscriber", flat=True) + ) - 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" + issue = Issue.objects.filter(pk=issue_id).first() - for issue_activity in issue_activities_created: - # Do not send notification for description update - if issue_activity.get("field") == "description": - continue - - # If activity is of issue comment fetch the comment - issue_comment = ( - IssueComment.objects.get( - id=issue_activity.get("issue_comment"), - issue_id=issue_id, - project_id=project_id, - workspace_id=project.workspace_id, - ) - if issue_activity.get("issue_comment") - else None - ) - - 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_comment is not None - else "" - ), - }, - }, + 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 ) - ) + 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) - # Add Mentioned as Issue Subscribers - IssueSubscriber.objects.bulk_create( - mention_subscribers + comment_mention_subscribers, batch_size=100 - ) + issue_subscribers = list(set(issue_subscribers) - {uuid.UUID(actor_id)}) - last_activity = ( - IssueActivity.objects.filter(issue_id=issue_id) - .order_by("-created_at") - .first() - ) + 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" - actor = User.objects.get(pk=actor_id) + preference = UserNotificationPreference.objects.get(user_id=subscriber) - for mention_id in comment_mentions: - if mention_id != actor_id: for issue_activity in issue_activities_created: - notification = create_mention_notification( - 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, + # 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 ) - bulk_notifications.append(notification) - - 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) - ): + 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), @@ -426,36 +376,195 @@ 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: + 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(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 "" + ), + }, + }, + ) + ) + + # ----------------------------------------------------------------------------------------------------------------- # + + # 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 = 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, ) - 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, - ) + # 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=notification.data, + ) + ) + bulk_notifications.append(notification) - # Bulk create notifications - Notification.objects.bulk_create(bulk_notifications, batch_size=100) + 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, + }, + "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": str(last_activity.field), + "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=notification.data, + ) + ) + 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, + ) + print(bulk_email_logs) + # 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 diff --git a/apiserver/plane/db/models/notification.py b/apiserver/plane/db/models/notification.py index 8ae2a1ef3f..da5c65d57c 100644 --- a/apiserver/plane/db/models/notification.py +++ b/apiserver/plane/db/models/notification.py @@ -2,9 +2,8 @@ from django.db import models from django.conf import settings -# Third party imports -from .base import BaseModel -from .project import ProjectBaseModel +# Module imports +from . import BaseModel class Notification(BaseModel): workspace = models.ForeignKey( diff --git a/apiserver/plane/db/models/user.py b/apiserver/plane/db/models/user.py index 0a11439db4..cf9b9f68a8 100644 --- a/apiserver/plane/db/models/user.py +++ b/apiserver/plane/db/models/user.py @@ -13,9 +13,6 @@ ) from django.utils import timezone -# Module imports -from plane.db.models import UserNotificationPreference - def get_default_onboarding(): return { @@ -163,6 +160,8 @@ def send_welcome_slack(sender, instance, created, **kwargs): 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, ) From ef7503b922c0e1d60a5899f53be568237958fc77 Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Tue, 16 Jan 2024 15:17:58 +0530 Subject: [PATCH 05/24] dev: email notification stacking and sending logic --- .../plane/bgtasks/email_notification_task.py | 132 ++++++++++++++++++ apiserver/plane/bgtasks/notification_task.py | 88 ++++++++++-- apiserver/plane/celery.py | 5 + .../0057_emailnotificationlog_processed_at.py | 18 +++ apiserver/plane/db/models/notification.py | 15 +- apiserver/plane/settings/common.py | 1 + 6 files changed, 242 insertions(+), 17 deletions(-) create mode 100644 apiserver/plane/bgtasks/email_notification_task.py create mode 100644 apiserver/plane/db/migrations/0057_emailnotificationlog_processed_at.py diff --git a/apiserver/plane/bgtasks/email_notification_task.py b/apiserver/plane/bgtasks/email_notification_task.py new file mode 100644 index 0000000000..6f50cc84d8 --- /dev/null +++ b/apiserver/plane/bgtasks/email_notification_task.py @@ -0,0 +1,132 @@ +# Third party imports +from celery import shared_task + +# Django imports +from django.utils import timezone + +# Module imports +from plane.db.models import EmailNotificationLog, User, Issue + + +@shared_task +def stack_email_notification(): + # get all email notifications + email_notifications = ( + EmailNotificationLog.objects.filter(processed_at__isnull=True) + .order_by("receiver") + .values() + ) + + # {"issue_id" : { "actor_id": [ { 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, issue_data in payload.items(): + send_email_notification.delay( + issue_id=issue_id, + issue_data=issue_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(payload): + # {"actor_id": { "key": { "old_value": [], "new_value": [] } }} + data = {} + for actor_id, changes in payload.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 = issue_activity.get("old_value") + new_value = 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 + + return data + + +@shared_task +def send_email_notification(issue_id, issue_data, receiver_id, email_notification_ids): + data = create_payload(payload=issue_data) + + receiver = User.objects.get(pk=receiver_id) + issue = Issue.objects.get(pk=issue_id) + template_data = [] + for actor_id, changes in data.items(): + actor = User.objects.get(pk=actor_id) + template_data.append( + { + "actor_details": { + "avatar": 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}", + }, + } + ) + + EmailNotificationLog.objects.filter(pk__in=email_notification_ids).update( + sent_at=timezone.now() + ) diff --git a/apiserver/plane/bgtasks/notification_task.py b/apiserver/plane/bgtasks/notification_task.py index c2ad2a593d..a73d39c206 100644 --- a/apiserver/plane/bgtasks/notification_task.py +++ b/apiserver/plane/bgtasks/notification_task.py @@ -256,12 +256,14 @@ def notifications( 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 + 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 + old_value=issue_comment_old_value, + new_value=issue_comment_new_value, ) comment_mentions = comment_mentions + new_comment_mentions @@ -323,7 +325,10 @@ def notifications( # Check if the value should be sent or not send_email = False - if issue_activity.get("field") == "state" and preference.state_change: + if ( + issue_activity.get("field") == "state" + and preference.state_change + ): send_email = True elif ( issue_activity.get("field") == "state" @@ -335,7 +340,9 @@ def notifications( ).exists() ): send_email = True - elif issue_activity.get("field") == "comment" and preference.comment: + elif ( + issue_activity.get("field") == "comment" and preference.comment + ): send_email = True elif preference.property_change: send_email = True @@ -427,7 +434,9 @@ def notifications( # Add Mentioned as Issue Subscribers IssueSubscriber.objects.bulk_create( - mention_subscribers + comment_mention_subscribers, batch_size=100, ignore_conflicts=True, + mention_subscribers + comment_mention_subscribers, + batch_size=100, + ignore_conflicts=True, ) last_activity = ( @@ -440,7 +449,9 @@ def notifications( for mention_id in comment_mentions: if mention_id != actor_id: - preference = UserNotificationPreference.objects.get(user_id=mention_id) + preference = UserNotificationPreference.objects.get( + user_id=mention_id + ) for issue_activity in issue_activities_created: notification = create_mention_notification( project=project, @@ -460,14 +471,39 @@ def notifications( receiver_id=subscriber, entity_identifier=issue_id, entity_name="issue", - data=notification.data, + 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) for mention_id in new_mentions: if mention_id != actor_id: - preference = UserNotificationPreference.objects.get(user_id=mention_id) + preference = UserNotificationPreference.objects.get( + user_id=mention_id + ) if ( last_activity is not None and last_activity.field == "description" @@ -522,7 +558,7 @@ def notifications( "issue_activity": { "id": str(last_activity.id), "verb": str(last_activity.verb), - "field": str(last_activity.field), + "field": "mention", "actor": str(last_activity.actor_id), "new_value": str(last_activity.new_value), "old_value": str(last_activity.old_value), @@ -548,7 +584,32 @@ def notifications( receiver_id=subscriber, entity_identifier=issue_id, entity_name="issue", - data=notification.data, + 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) @@ -560,10 +621,11 @@ def notifications( new_mentions=new_mentions, removed_mention=removed_mention, ) - print(bulk_email_logs) # 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) + EmailNotificationLog.objects.bulk_create( + bulk_email_logs, batch_size=100, ignore_conflicts=True + ) return except Exception as e: print(e) diff --git a/apiserver/plane/celery.py b/apiserver/plane/celery.py index 442e728364..6872eb43c3 100644 --- a/apiserver/plane/celery.py +++ b/apiserver/plane/celery.py @@ -2,6 +2,7 @@ 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 @@ "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": timedelta(seconds=10) + }, } # Load task modules from all registered Django app configs. diff --git a/apiserver/plane/db/migrations/0057_emailnotificationlog_processed_at.py b/apiserver/plane/db/migrations/0057_emailnotificationlog_processed_at.py new file mode 100644 index 0000000000..3d773dba9b --- /dev/null +++ b/apiserver/plane/db/migrations/0057_emailnotificationlog_processed_at.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.7 on 2024-01-16 07:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0056_auto_20240111_1454'), + ] + + operations = [ + migrations.AddField( + model_name='emailnotificationlog', + name='processed_at', + field=models.DateTimeField(null=True), + ), + ] diff --git a/apiserver/plane/db/models/notification.py b/apiserver/plane/db/models/notification.py index da5c65d57c..5f640086f8 100644 --- a/apiserver/plane/db/models/notification.py +++ b/apiserver/plane/db/models/notification.py @@ -88,10 +88,17 @@ def __str__(self): """Return name of the notifications""" return f"{self.user.email} <{self.workspace.name}>" - -class EmailNotificationLog(ProjectBaseModel): - receiver = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="notifications") - triggered_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="triggered_by_notifications") +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) diff --git a/apiserver/plane/settings/common.py b/apiserver/plane/settings/common.py index 623583840d..444248382f 100644 --- a/apiserver/plane/settings/common.py +++ b/apiserver/plane/settings/common.py @@ -291,6 +291,7 @@ "plane.bgtasks.issue_automation_task", "plane.bgtasks.exporter_expired_task", "plane.bgtasks.file_asset_task", + "plane.bgtasks.email_notification_task", ) # Sentry Settings From 76a6e9a19ed78706ce51109ba56b0a0cefdd0e5a Mon Sep 17 00:00:00 2001 From: Prateek Shourya Date: Tue, 16 Jan 2024 17:54:26 +0530 Subject: [PATCH 06/24] feat: email notification preference settings page. --- .../preferences/email-notification-form.tsx | 176 ++++++++++++++++++ web/components/profile/preferences/index.ts | 1 + .../profile/preferences/index.ts | 2 + .../profile/preferences/layout.tsx | 25 +++ .../profile/preferences/sidebar.tsx | 43 +++++ web/pages/profile/preferences/email.tsx | 19 ++ .../theme.tsx} | 12 +- 7 files changed, 272 insertions(+), 6 deletions(-) create mode 100644 web/components/profile/preferences/email-notification-form.tsx create mode 100644 web/components/profile/preferences/index.ts create mode 100644 web/layouts/settings-layout/profile/preferences/index.ts create mode 100644 web/layouts/settings-layout/profile/preferences/layout.tsx create mode 100644 web/layouts/settings-layout/profile/preferences/sidebar.tsx create mode 100644 web/pages/profile/preferences/email.tsx rename web/pages/profile/{preferences.tsx => preferences/theme.tsx} (83%) diff --git a/web/components/profile/preferences/email-notification-form.tsx b/web/components/profile/preferences/email-notification-form.tsx new file mode 100644 index 0000000000..eb9d313c75 --- /dev/null +++ b/web/components/profile/preferences/email-notification-form.tsx @@ -0,0 +1,176 @@ +import { FC } from "react"; +import { Controller, useForm } from "react-hook-form"; +// ui +import { ToggleSwitch } from "@plane/ui"; + +export interface EmailPreferenceValues { + email_notification: boolean; + property_change: boolean; + state_change: boolean; + issue_completed: boolean; + comment: boolean; + mention: boolean; +} + +export const EmailNotificationForm: FC = () => { + // form data + const { + handleSubmit, + control, + formState: { isSubmitting }, + } = useForm({ + defaultValues: { + email_notification: true, + property_change: true, + state_change: true, + issue_completed: true, + comment: true, + mention: true, + }, + }); + + const onSubmit = async (formData: EmailPreferenceValues) => { + console.log(formData); + }; + + return ( + <> +
+
+
Email notifications
+
+ Get emails to find out what’s going on when you’re not on Plane. You can turn them off anytime +
+
+
+ ( + onChange(!value)} size="sm" /> + )} + /> +
+
+
Send me email notifications for:
+ {/* Notification Settings */} +
+
+
+
Property changes
+
+ You’ll be notified about the property changes of an issue you’re a subscriber to you. +
+
+
+ ( + onChange(!value)} + className="w-3.5 h-3.5 mx-2 cursor-pointer !border-custom-border-100" + /> + )} + /> +
+
+
+
+
State Change
+
+ You’ll be notified about the state changes to the issues you’re a subscriber to +
+
+
+ ( + onChange(!value)} + className="w-3.5 h-3.5 mx-2 cursor-pointer" + /> + )} + /> +
+
+
+
+
Issue completed
+
+ We’ll notify you only with the issue is moved to completed state or state group +
+
+
+ ( + onChange(!value)} + className="w-3.5 h-3.5 mx-2 cursor-pointer" + /> + )} + /> +
+
+
+
+
Comments
+
+ You will be notified when somebody comments on an issue you’re subscribed to +
+
+
+ ( + onChange(!value)} + className="w-3.5 h-3.5 mx-2 cursor-pointer" + /> + )} + /> +
+
+
+
+
Mentions
+
+ You’ll be notified every time someone mentions you in any issue. +
+
+
+ ( + onChange(!value)} + className="w-3.5 h-3.5 mx-2 cursor-pointer" + /> + )} + /> +
+
+
+ {/*
+ +
*/} + + ); +}; diff --git a/web/components/profile/preferences/index.ts b/web/components/profile/preferences/index.ts new file mode 100644 index 0000000000..ddda5712c5 --- /dev/null +++ b/web/components/profile/preferences/index.ts @@ -0,0 +1 @@ +export * from "./email-notification-form"; \ No newline at end of file diff --git a/web/layouts/settings-layout/profile/preferences/index.ts b/web/layouts/settings-layout/profile/preferences/index.ts new file mode 100644 index 0000000000..34e2302584 --- /dev/null +++ b/web/layouts/settings-layout/profile/preferences/index.ts @@ -0,0 +1,2 @@ +export * from "./layout"; +export * from "./sidebar"; \ No newline at end of file diff --git a/web/layouts/settings-layout/profile/preferences/layout.tsx b/web/layouts/settings-layout/profile/preferences/layout.tsx new file mode 100644 index 0000000000..9d17350a98 --- /dev/null +++ b/web/layouts/settings-layout/profile/preferences/layout.tsx @@ -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 = (props) => { + const { children, header } = props; + + return ( + +
+ +
+ {header} +
{children}
+
+
+
+ ); +}; diff --git a/web/layouts/settings-layout/profile/preferences/sidebar.tsx b/web/layouts/settings-layout/profile/preferences/sidebar.tsx new file mode 100644 index 0000000000..d1eec12331 --- /dev/null +++ b/web/layouts/settings-layout/profile/preferences/sidebar.tsx @@ -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 ( +
+
+ Preference +
+ {profilePreferenceLinks.map((link) => ( + +
+ {link.label} +
+ + ))} +
+
+
+ ); +}; diff --git a/web/pages/profile/preferences/email.tsx b/web/pages/profile/preferences/email.tsx new file mode 100644 index 0000000000..3f5cef9cd5 --- /dev/null +++ b/web/pages/profile/preferences/email.tsx @@ -0,0 +1,19 @@ +import { ReactElement } from "react"; +// layouts +import { ProfilePreferenceSettingsLayout } from "layouts/settings-layout/profile/preferences"; +// components +import { EmailNotificationForm } from "components/profile/preferences"; +// type +import { NextPageWithLayout } from "lib/types"; + +const ProfilePreferencesThemePage: NextPageWithLayout = () => ( +
+ +
+); + +ProfilePreferencesThemePage.getLayout = function getLayout(page: ReactElement) { + return {page}; +}; + +export default ProfilePreferencesThemePage; diff --git a/web/pages/profile/preferences.tsx b/web/pages/profile/preferences/theme.tsx similarity index 83% rename from web/pages/profile/preferences.tsx rename to web/pages/profile/preferences/theme.tsx index 23c85134ef..51386bc29d 100644 --- a/web/pages/profile/preferences.tsx +++ b/web/pages/profile/preferences/theme.tsx @@ -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(null); // store hooks @@ -48,7 +48,7 @@ const ProfilePreferencesPage: NextPageWithLayout = observer(() => { return ( <> {currentUser ? ( -
+

Preferences

@@ -72,8 +72,8 @@ const ProfilePreferencesPage: NextPageWithLayout = observer(() => { ); }); -ProfilePreferencesPage.getLayout = function getLayout(page: ReactElement) { - return {page}; +ProfilePreferencesThemePage.getLayout = function getLayout(page: ReactElement) { + return {page}; }; -export default ProfilePreferencesPage; +export default ProfilePreferencesThemePage; From 211cde8d5e5cdeec19d91818850cc575c31a4a7c Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Thu, 18 Jan 2024 18:20:03 +0530 Subject: [PATCH 07/24] dev: delete subscribers --- apiserver/plane/app/views/notification.py | 56 +++++++++++++++++-- .../plane/bgtasks/issue_activites_task.py | 15 +++++ 2 files changed, 66 insertions(+), 5 deletions(-) diff --git a/apiserver/plane/app/views/notification.py b/apiserver/plane/app/views/notification.py index 15eef9cf0c..3112c5b39a 100644 --- a/apiserver/plane/app/views/notification.py +++ b/apiserver/plane/app/views/notification.py @@ -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 @@ -71,11 +71,29 @@ def list(self, request, slug): # 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 +313,31 @@ def create(self, request, slug): 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) diff --git a/apiserver/plane/bgtasks/issue_activites_task.py b/apiserver/plane/bgtasks/issue_activites_task.py index 686f06a20b..7e8a27862a 100644 --- a/apiserver/plane/bgtasks/issue_activites_task.py +++ b/apiserver/plane/bgtasks/issue_activites_task.py @@ -376,6 +376,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) From f0806ba7a7f877955ec91d3200925eb42c437cb1 Mon Sep 17 00:00:00 2001 From: LAKHAN BAHETI Date: Thu, 18 Jan 2024 20:24:26 +0530 Subject: [PATCH 08/24] dev: issue update ui implementation in email notification --- .../emails/notifications/issue-updates.html | 280 ++++++++++++++++++ 1 file changed, 280 insertions(+) create mode 100644 apiserver/templates/emails/notifications/issue-updates.html diff --git a/apiserver/templates/emails/notifications/issue-updates.html b/apiserver/templates/emails/notifications/issue-updates.html new file mode 100644 index 0000000000..f8a1931779 --- /dev/null +++ b/apiserver/templates/emails/notifications/issue-updates.html @@ -0,0 +1,280 @@ + + + + + + + + + Your unique Plane login code is code + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+
+ +

+ Plane +

+
+
+ +
+ + + + +
+
+

+ PLW-1 updates +

+

+ workspacename/projectname/issueId: Sign up flow +

+
+
+ updates were made to the issue by + + Srinivas Pendem + + +
+
+

+ {{ Updates / Comments }} +

+ +
+
+ +

+ Srinivas Pendem +

+

+ 11:36 am IST +

+
+ +
+ + +

+ Assignee :

+ +

+ {{ assginee.name }}

+ + +

+ {{ assginee.name }}

+
+ +
+ + +

+ Assignee :

+ +

+ {{ assginee.name }}

+ + +

+ +{{length}} more

+
+ +
+ + +

+ Assignee :

+ +
+ +

+ {{ assginee.name }}

+ + +

+ {{ assginee.name }}

+ +

+ +{{length}} more

+
+ +
+ +
+ +

+ Due Date :

+

+ 28 Jun, 2023

+
+
+ +

+ Duplicate :

+

+ PLW-2

+
+ +
+ + + +
+
+ +
+

+ Bhavesh Raja +

+
+ Connect with folks in finance and operations to verify the data + provided before presenting it. +
+
+
+
+ +
+
+

+ View issue +

+
+
+ +
+
+ + +
+
+ This email was sent to akhil@plane.so. + If you'd rather not + receive this + kind of + email, you can unsubscribe to + the issue or manage + your email + preferences. + + +
+ + + + + + + + + + + + +
+ +
+
+ + + + + + + \ No newline at end of file From ba51a85f52cd532af7b88a898003f0bc56a55f33 Mon Sep 17 00:00:00 2001 From: Prateek Shourya Date: Thu, 18 Jan 2024 21:10:24 +0530 Subject: [PATCH 09/24] chore: integrate email notification endpoint. --- packages/types/src/users.d.ts | 8 ++ .../preferences/email-notification-form.tsx | 134 +++++++++++++----- web/pages/profile/preferences/email.tsx | 27 +++- web/services/user.service.ts | 18 ++- 4 files changed, 142 insertions(+), 45 deletions(-) diff --git a/packages/types/src/users.d.ts b/packages/types/src/users.d.ts index bbca953f61..81c8abcd5f 100644 --- a/packages/types/src/users.d.ts +++ b/packages/types/src/users.d.ts @@ -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; diff --git a/web/components/profile/preferences/email-notification-form.tsx b/web/components/profile/preferences/email-notification-form.tsx index eb9d313c75..a54deef1a6 100644 --- a/web/components/profile/preferences/email-notification-form.tsx +++ b/web/components/profile/preferences/email-notification-form.tsx @@ -1,36 +1,88 @@ -import { FC } from "react"; +import { FC, useEffect, useState } from "react"; import { Controller, useForm } from "react-hook-form"; // ui -import { ToggleSwitch } from "@plane/ui"; +import { Button, ToggleSwitch } from "@plane/ui"; +// hooks +import useToast from "hooks/use-toast"; +// services +import { UserService } from "services/user.service"; +// types +import { IUserEmailNotificationSettings } from "@plane/types"; -export interface EmailPreferenceValues { - email_notification: boolean; - property_change: boolean; - state_change: boolean; - issue_completed: boolean; - comment: boolean; - mention: boolean; +interface IEmailNotificationFormProps { + data: IUserEmailNotificationSettings; } -export const EmailNotificationForm: FC = () => { +// services +const userService = new UserService(); + +export const EmailNotificationForm: FC = (props) => { + const { data } = props; + // states + const [notificationSettings, setNotificationSettings] = useState(false); + const [isUpdateAllSettings, setIsUpdateAllSettings] = useState(false); + // toast + const { setToastAlert } = useToast(); // form data const { handleSubmit, + watch, control, - formState: { isSubmitting }, - } = useForm({ + setValue, + reset, + formState: { isSubmitting, isDirty, dirtyFields }, + } = useForm({ defaultValues: { - email_notification: true, - property_change: true, - state_change: true, - issue_completed: true, - comment: true, - mention: true, + ...data, }, }); - const onSubmit = async (formData: EmailPreferenceValues) => { - console.log(formData); + useEffect(() => { + // Update notificationSettings whenever any of the data values change + setNotificationSettings( + watch("comment") || + watch("issue_completed") || + watch("mention") || + watch("property_change") || + watch("state_change") + ); + }, [ + // eslint-disable-next-line react-hooks/exhaustive-deps + watch, watch("comment"), watch("issue_completed"), watch("mention"), watch("property_change"), watch("state_change"), + ]); + + 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)) + .finally(() => setIsUpdateAllSettings(false)); + }; + + const updateAllSettings = async (value: boolean) => { + setNotificationSettings(value); + setIsUpdateAllSettings(true); + reset({ + comment: value, + issue_completed: value, + mention: value, + property_change: value, + state_change: value, + }); }; return ( @@ -39,27 +91,25 @@ export const EmailNotificationForm: FC = () => {
Email notifications
- Get emails to find out what’s going on when you’re not on Plane. You can turn them off anytime + Stay in the loop on Issues you are subscribed to. Enable this to get notified.
- ( - onChange(!value)} size="sm" /> - )} + updateAllSettings(!notificationSettings)} + size="sm" />
-
Send me email notifications for:
+
Notify me when:
{/* Notification Settings */}
Property changes
- You’ll be notified about the property changes of an issue you’re a subscriber to you. + Notify me when issue’s properties like assignees, priority, estimates or anything else changes.
@@ -81,7 +131,7 @@ export const EmailNotificationForm: FC = () => {
State Change
- You’ll be notified about the state changes to the issues you’re a subscriber to + Notify me when the issues moves to a different state
@@ -92,7 +142,10 @@ export const EmailNotificationForm: FC = () => { onChange(!value)} + onChange={() => { + if (!value) setValue("issue_completed", true); + onChange(!value); + }} className="w-3.5 h-3.5 mx-2 cursor-pointer" /> )} @@ -102,9 +155,7 @@ export const EmailNotificationForm: FC = () => {
Issue completed
-
- We’ll notify you only with the issue is moved to completed state or state group -
+
Notify me only when an issue is completed
{
Comments
- You will be notified when somebody comments on an issue you’re subscribed to + Notify me when someone leaves a comment on the issue
@@ -147,7 +198,7 @@ export const EmailNotificationForm: FC = () => {
Mentions
- You’ll be notified every time someone mentions you in any issue. + Notify me only when someone mentions me in the comments or description
@@ -166,11 +217,16 @@ export const EmailNotificationForm: FC = () => {
- {/*
- -
*/} +
); }; diff --git a/web/pages/profile/preferences/email.tsx b/web/pages/profile/preferences/email.tsx index 3f5cef9cd5..714d8b5558 100644 --- a/web/pages/profile/preferences/email.tsx +++ b/web/pages/profile/preferences/email.tsx @@ -1,16 +1,33 @@ 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"; -const ProfilePreferencesThemePage: NextPageWithLayout = () => ( -
- -
-); +// 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 ( +
+ +
+ ); +}; ProfilePreferencesThemePage.getLayout = function getLayout(page: ReactElement) { return {page}; diff --git a/web/services/user.service.ts b/web/services/user.service.ts index 2608b6d17d..71004ed24e 100644 --- a/web/services/user.service.ts +++ b/web/services/user.service.ts @@ -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 { + return this.get("/api/notification-preferences/") + .then((response) => response?.data) + .catch((error) => { + throw error?.response; + }); + } + async updateUser(data: Partial): Promise { return this.patch("/api/users/me/", data) .then((response) => response?.data) @@ -97,6 +105,14 @@ export class UserService extends APIService { }); } + async updateCurrentUserEmailNotificationSettings(data: Partial): Promise { + return this.patch("/api/notification-preferences/", data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + async getUserActivity(): Promise { return this.get(`/api/users/me/activities/`) .then((response) => response?.data) From a967fd9cadcb567666d5869bec22295ba9f57615 Mon Sep 17 00:00:00 2001 From: Prateek Shourya Date: Thu, 18 Jan 2024 21:13:50 +0530 Subject: [PATCH 10/24] chore: remove toggle switch. --- .../preferences/email-notification-form.tsx | 52 ++----------------- 1 file changed, 4 insertions(+), 48 deletions(-) diff --git a/web/components/profile/preferences/email-notification-form.tsx b/web/components/profile/preferences/email-notification-form.tsx index a54deef1a6..ee019e1dde 100644 --- a/web/components/profile/preferences/email-notification-form.tsx +++ b/web/components/profile/preferences/email-notification-form.tsx @@ -1,7 +1,7 @@ -import { FC, useEffect, useState } from "react"; +import { FC } from "react"; import { Controller, useForm } from "react-hook-form"; // ui -import { Button, ToggleSwitch } from "@plane/ui"; +import { Button } from "@plane/ui"; // hooks import useToast from "hooks/use-toast"; // services @@ -18,18 +18,13 @@ const userService = new UserService(); export const EmailNotificationForm: FC = (props) => { const { data } = props; - // states - const [notificationSettings, setNotificationSettings] = useState(false); - const [isUpdateAllSettings, setIsUpdateAllSettings] = useState(false); // toast const { setToastAlert } = useToast(); // form data const { handleSubmit, - watch, control, setValue, - reset, formState: { isSubmitting, isDirty, dirtyFields }, } = useForm({ defaultValues: { @@ -37,20 +32,6 @@ export const EmailNotificationForm: FC = (props) => }, }); - useEffect(() => { - // Update notificationSettings whenever any of the data values change - setNotificationSettings( - watch("comment") || - watch("issue_completed") || - watch("mention") || - watch("property_change") || - watch("state_change") - ); - }, [ - // eslint-disable-next-line react-hooks/exhaustive-deps - watch, watch("comment"), watch("issue_completed"), watch("mention"), watch("property_change"), watch("state_change"), - ]); - const onSubmit = async (formData: IUserEmailNotificationSettings) => { // Get the dirty fields from the form data and create a payload let payload = {}; @@ -69,20 +50,7 @@ export const EmailNotificationForm: FC = (props) => message: "Email Notification Settings updated successfully", }) ) - .catch((err) => console.error(err)) - .finally(() => setIsUpdateAllSettings(false)); - }; - - const updateAllSettings = async (value: boolean) => { - setNotificationSettings(value); - setIsUpdateAllSettings(true); - reset({ - comment: value, - issue_completed: value, - mention: value, - property_change: value, - state_change: value, - }); + .catch((err) => console.error(err)); }; return ( @@ -94,13 +62,6 @@ export const EmailNotificationForm: FC = (props) => Stay in the loop on Issues you are subscribed to. Enable this to get notified.
-
- updateAllSettings(!notificationSettings)} - size="sm" - /> -
Notify me when:
{/* Notification Settings */} @@ -218,12 +179,7 @@ export const EmailNotificationForm: FC = (props) =>
-
From 6bebb96c50ce2cae565d2702e6c16ccd1153b4fd Mon Sep 17 00:00:00 2001 From: Ramesh Kumar Chandra Date: Fri, 19 Jan 2024 15:24:28 +0530 Subject: [PATCH 11/24] chore: added labels part --- .../emails/notifications/issue-updates.html | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/apiserver/templates/emails/notifications/issue-updates.html b/apiserver/templates/emails/notifications/issue-updates.html index f8a1931779..510ded8101 100644 --- a/apiserver/templates/emails/notifications/issue-updates.html +++ b/apiserver/templates/emails/notifications/issue-updates.html @@ -173,6 +173,53 @@ PLW-2

+ + + + + + + +
+ + Labels : + + + + + + + + + + + + +
+

+ Improvement +

+
+

+ Jinx +

+
+

+ Ready to ship +

+
+

+ Ready to ship +

+
+

+ Feature +

+
+ 5more
+
+ + + From b3de9e0eb6621ce5f91dfd6baf37ee9848a4ab3a Mon Sep 17 00:00:00 2001 From: LAKHAN BAHETI Date: Fri, 19 Jan 2024 15:36:47 +0530 Subject: [PATCH 12/24] fix: refactored base design with tables --- .../emails/notifications/issue-updates.html | 588 +++++++++++------- 1 file changed, 365 insertions(+), 223 deletions(-) diff --git a/apiserver/templates/emails/notifications/issue-updates.html b/apiserver/templates/emails/notifications/issue-updates.html index 510ded8101..fc94508e9d 100644 --- a/apiserver/templates/emails/notifications/issue-updates.html +++ b/apiserver/templates/emails/notifications/issue-updates.html @@ -51,270 +51,412 @@ - +
-

- PLW-1 updates -

-

- workspacename/projectname/issueId: Sign up flow -

+ + + + +
+

+ PLW-1 updates +

+

+ workspacename/projectname/issueId: Sign up flow +

+

-
+ +

updates were made to the issue by Srinivas Pendem -

-
-

- {{ Updates / Comments }} -

- -
-
- -

- Srinivas Pendem -

-

- 11:36 am IST +

+ + + + + + + + + + + + + - -
-
- -
-

- Bhavesh Raja -

-
- Connect with folks in finance and operations to verify the data - provided before presenting it. -
-
-
-
- - -
-

- View issue -

-
- - + + + +
+

+ {{ Updates / Comments }}

- - -
- - -

- Assignee :

- -

- {{ assginee.name }}

- - -

- {{ assginee.name }}

-
- -
- - -

- Assignee :

- -

- {{ assginee.name }}

- - -

- +{{length}} more

-
- -
- - -

- Assignee :

- -
- -

- {{ assginee.name }}

- - -

- {{ assginee.name }}

- -

- +{{length}} more

-
- -
- -
- -

- Due Date :

-

- 28 Jun, 2023

-
-
- -

- Duplicate :

-

- PLW-2

-
- - - - - - - - -
- - Labels : - - - - - - - - - - - - -
-

- Improvement -

-
-

- Jinx -

-
-

- Ready to ship -

-
-

- Ready to ship -

-
-

- Feature -

-
+ 5more
-
- - - - +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + +
+ + + +

+ Srinivas Pendem +

+
+

+ 11:36 am IST +

+
+
+ + + + + + + + + +
+ + +

+ Assignee :

+
+

+ {{ assginee.name }}

+
+ + +

+ {{ assginee.name }}

+
+ +
+ + + + + + +
+ + +

+ Due Date :

+
+

+ 28 Jun, 2023

+
+
+ + + + + + +
+ + +

+ Duplicate :

+
+

+ PLW-2

+
+
+ + + + + + + +
+ + +

+ Assignee :

+
+

+ {{ assginee.name }}

+
+

+ +{{length}} more

+
+
+ + + + + + + + + + + +
+ + +

+ Assignee :

+
+

+ {{ assginee.name }}

+
+ + +

+ {{ assginee.name }}

+
+

+ +{{length}} more

+
+
+ + + + + +
+ + Labels + : + + + + + + + + + + + + +
+

+ Improvement +

+
+

+ Jinx +

+
+

+ Ready to ship +

+
+

+ Ready to ship +

+
+

+ Feature +

+
+ + 5more
+
+
+
+ + + + + +
+ + + + + + + + + + +
+

+ Srinivas Pendem +

+
+
+ Connect with folks in finance and operations to + verify the data + provided before presenting it. +
+
+
+
+ - - - - - - - - + +
-
- This email was sent to akhil@plane.so. - If you'd rather not - receive this - kind of - email, you can unsubscribe to - the issue or manage - your email - preferences. - - - +
+ +
+ + + + + + + + + +
+
+ This email was sent to akhil@plane.so. + If you'd rather not + receive this + kind of + email, you can unsubscribe to + the issue or manage + your email + preferences. + + +
+ + + - - - - - -
+
-
- - + + + From a471a031759da287e3d0a990d4a0a3680c17f689 Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Mon, 22 Jan 2024 13:37:12 +0530 Subject: [PATCH 13/24] dev: email notification templates --- .../plane/bgtasks/email_notification_task.py | 115 ++++++++-- .../plane/bgtasks/issue_activites_task.py | 35 +-- apiserver/plane/bgtasks/notification_task.py | 200 +++++++++++++----- .../emails/notifications/issue-updates.html | 124 +++-------- 4 files changed, 288 insertions(+), 186 deletions(-) diff --git a/apiserver/plane/bgtasks/email_notification_task.py b/apiserver/plane/bgtasks/email_notification_task.py index 6f50cc84d8..676f0f7353 100644 --- a/apiserver/plane/bgtasks/email_notification_task.py +++ b/apiserver/plane/bgtasks/email_notification_task.py @@ -1,11 +1,16 @@ # Third party imports from celery import shared_task +import os # 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 # Module imports from plane.db.models import EmailNotificationLog, User, Issue +from plane.license.utils.instance_value import get_email_configuration @shared_task @@ -17,7 +22,8 @@ def stack_email_notification(): .values() ) - # {"issue_id" : { "actor_id": [ { data }, { data } ] }} + # 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( @@ -43,7 +49,9 @@ def stack_email_notification(): for receiver_notification in receiver_notifications: payload.setdefault( receiver_notification.get("entity_identifier"), {} - ).setdefault(str(receiver_notification.get("triggered_by_id")), []).append( + ).setdefault( + str(receiver_notification.get("triggered_by_id")), [] + ).append( receiver_notification.get("data") ) # append processed notifications @@ -51,10 +59,10 @@ def stack_email_notification(): email_notification_ids.append(receiver_notification.get("id")) # Create emails for all the issues - for issue_id, issue_data in payload.items(): + for issue_id, notification_data in payload.items(): send_email_notification.delay( issue_id=issue_id, - issue_data=issue_data, + notification_data=notification_data, receiver_id=receiver_id, email_notification_ids=email_notification_ids, ) @@ -65,10 +73,10 @@ def stack_email_notification(): ) -def create_payload(payload): - # {"actor_id": { "key": { "old_value": [], "new_value": [] } }} +def create_payload(notification_data): + # return format {"actor_id": { "key": { "old_value": [], "new_value": [] } }} data = {} - for actor_id, changes in payload.items(): + 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 @@ -78,9 +86,11 @@ def create_payload(payload): # 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( + data.setdefault(actor_id, {}).setdefault( + field, {} + ).setdefault("old_value", []).append( + old_value + ) if old_value not in data.setdefault( actor_id, {} ).setdefault( field, {} @@ -90,9 +100,11 @@ def create_payload(payload): # 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( + data.setdefault(actor_id, {}).setdefault( + field, {} + ).setdefault("new_value", []).append( + new_value + ) if new_value not in data.setdefault( actor_id, {} ).setdefault( field, {} @@ -104,17 +116,32 @@ def create_payload(payload): @shared_task -def send_email_notification(issue_id, issue_data, receiver_id, email_notification_ids): - data = create_payload(payload=issue_data) +def send_email_notification( + issue_id, notification_data, receiver_id, email_notification_ids +): + base_api = "http://localhost:3000" + data = create_payload(payload=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 for actor_id, changes in data.items(): actor = User.objects.get(pk=actor_id) + total_changes = total_changes + len(changes) template_data.append( { - "actor_details": { + "actor_detail": { "avatar": actor.avatar, "first_name": actor.first_name, "last_name": actor.last_name, @@ -126,7 +153,57 @@ def send_email_notification(issue_id, issue_data, receiver_id, email_notificatio }, } ) - - EmailNotificationLog.objects.filter(pk__in=email_notification_ids).update( - sent_at=timezone.now() + + summary = "" + if len(template_data) == 1: + summary = f"{template_data[0]['actor_detail']['first_name']} {template_data[0]['actor_detail']['last_name']} made {total_changes} to the issue" + else: + summary = f"{template_data[0]['actor_detail']['first_name']} {template_data[0]['actor_detail']['last_name']} and others made {total_changes} to the issue" + + # 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, + }, + "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" + } + 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 diff --git a/apiserver/plane/bgtasks/issue_activites_task.py b/apiserver/plane/bgtasks/issue_activites_task.py index 7e8a27862a..e8d0cc4b80 100644 --- a/apiserver/plane/bgtasks/issue_activites_task.py +++ b/apiserver/plane/bgtasks/issue_activites_task.py @@ -111,15 +111,15 @@ def track_parent( issue_activities, epoch, ): - if current_instance.get("parent") != requested_data.get("parent"): + if current_instance.get("parent_id") != requested_data.get("parent_id"): old_parent = ( - Issue.objects.filter(pk=current_instance.get("parent")).first() - if current_instance.get("parent") is not None + Issue.objects.filter(pk=current_instance.get("parent_id")).first() + if current_instance.get("parent_id") is not None else None ) new_parent = ( - Issue.objects.filter(pk=requested_data.get("parent")).first() - if requested_data.get("parent") is not None + Issue.objects.filter(pk=requested_data.get("parent_id")).first() + if requested_data.get("parent_id") is not None else None ) @@ -188,9 +188,11 @@ def track_state( issue_activities, epoch, ): - if current_instance.get("state") != requested_data.get("state"): - new_state = State.objects.get(pk=requested_data.get("state", None)) - old_state = State.objects.get(pk=current_instance.get("state", None)) + 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) + ) issue_activities.append( IssueActivity( @@ -288,10 +290,10 @@ def track_labels( epoch, ): requested_labels = set( - [str(lab) for lab in requested_data.get("labels", [])] + [str(lab) for lab in requested_data.get("label_ids", [])] ) current_labels = set( - [str(lab) for lab in current_instance.get("labels", [])] + [str(lab) for lab in current_instance.get("label_ids", [])] ) added_labels = requested_labels - current_labels @@ -350,10 +352,10 @@ def track_assignees( epoch, ): requested_assignees = set( - [str(asg) for asg in requested_data.get("assignees", [])] + [str(asg) for asg in requested_data.get("assignee_ids", [])] ) current_assignees = set( - [str(asg) for asg in current_instance.get("assignees", [])] + [str(asg) for asg in current_instance.get("assignee_ids", [])] ) added_assignees = requested_assignees - current_assignees @@ -556,14 +558,14 @@ def update_issue_activity( ): ISSUE_ACTIVITY_MAPPER = { "name": track_name, - "parent": track_parent, + "parent_id": track_parent, "priority": track_priority, - "state": track_state, + "state_id": track_state, "description_html": track_description, "target_date": track_target_date, "start_date": track_start_date, - "labels": track_labels, - "assignees": track_assignees, + "label_ids": track_labels, + "assignee_ids": track_assignees, "estimate_point": track_estimate_points, "archived_at": track_archive_at, "closed_to": track_closed_to, @@ -578,6 +580,7 @@ def update_issue_activity( for key in requested_data: func = ISSUE_ACTIVITY_MAPPER.get(key) + print(key, func) if func is not None: func( requested_data=requested_data, diff --git a/apiserver/plane/bgtasks/notification_task.py b/apiserver/plane/bgtasks/notification_task.py index a73d39c206..26dc83f856 100644 --- a/apiserver/plane/bgtasks/notification_task.py +++ b/apiserver/plane/bgtasks/notification_task.py @@ -234,19 +234,25 @@ def notifications( # Get new mentions from the newer instance new_mentions = get_new_mentions( - requested_instance=requested_data, current_instance=current_instance + requested_instance=requested_data, + current_instance=current_instance, ) removed_mention = get_removed_mentions( - requested_instance=requested_data, current_instance=current_instance + requested_instance=requested_data, + current_instance=current_instance, ) comment_mentions = [] 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, mentions=requested_mentions + project_id=project_id, + issue_id=issue_id, + mentions=requested_mentions, ) for issue_activity in issue_activities_created: @@ -268,7 +274,9 @@ def notifications( 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 + 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 @@ -279,9 +287,13 @@ def notifications( # ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- # issue_subscribers = list( - IssueSubscriber.objects.filter(project_id=project_id, issue_id=issue_id) + IssueSubscriber.objects.filter( + project_id=project_id, issue_id=issue_id + ) .exclude( - subscriber_id__in=list(new_mentions + comment_mentions + [actor_id]) + subscriber_id__in=list( + new_mentions + comment_mentions + [actor_id] + ) ) .values_list("subscriber", flat=True) ) @@ -303,7 +315,9 @@ def notifications( issue_id=issue_id, project_id=project_id ).values_list("assignee", flat=True) - issue_subscribers = list(set(issue_subscribers) - {uuid.UUID(actor_id)}) + 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: @@ -316,7 +330,9 @@ def notifications( else: sender = "in_app:issue_activities:subscribed" - preference = UserNotificationPreference.objects.get(user_id=subscriber) + preference = UserNotificationPreference.objects.get( + user_id=subscriber + ) for issue_activity in issue_activities_created: # Do not send notification for description update @@ -341,7 +357,8 @@ def notifications( ): send_email = True elif ( - issue_activity.get("field") == "comment" and preference.comment + issue_activity.get("field") == "comment" + and preference.comment ): send_email = True elif preference.property_change: @@ -361,6 +378,7 @@ def notifications( else None ) + # Create in app notification bulk_notifications.append( Notification( workspace=project.workspace, @@ -386,9 +404,15 @@ 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")), - "new_value": str(issue_activity.get("new_value")), - "old_value": str(issue_activity.get("old_value")), + "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 @@ -398,37 +422,55 @@ def notifications( }, ) ) - 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(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 "" - ), + # 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 "" + ), + }, }, - }, + ) ) - ) # ----------------------------------------------------------------------------------------------------------------- # @@ -475,14 +517,26 @@ 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, + "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")), + "id": str( + issue_activity.get("id") + ), + "verb": str( + issue_activity.get("verb") + ), "field": str("mention"), "actor": str( issue_activity.get("actor_id") @@ -523,18 +577,28 @@ 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, + "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 + ), }, }, ) @@ -550,7 +614,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, @@ -559,9 +625,15 @@ def notifications( "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), + "actor": str( + last_activity.actor_id + ), + "new_value": str( + last_activity.new_value + ), + "old_value": str( + last_activity.old_value + ), }, }, ) @@ -596,17 +668,27 @@ def notifications( "state_group": issue.state.group, }, "issue_activity": { - "id": str(issue_activity.get("id")), - "verb": str(issue_activity.get("verb")), + "id": str( + issue_activity.get("id") + ), + "verb": str( + issue_activity.get("verb") + ), "field": str("mention"), "actor": str( - issue_activity.get("actor_id") + issue_activity.get( + "actor_id" + ) ), "new_value": str( - issue_activity.get("new_value") + issue_activity.get( + "new_value" + ) ), "old_value": str( - issue_activity.get("old_value") + issue_activity.get( + "old_value" + ) ), }, }, @@ -622,7 +704,9 @@ def notifications( 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 ) diff --git a/apiserver/templates/emails/notifications/issue-updates.html b/apiserver/templates/emails/notifications/issue-updates.html index fc94508e9d..66ec71b1e2 100644 --- a/apiserver/templates/emails/notifications/issue-updates.html +++ b/apiserver/templates/emails/notifications/issue-updates.html @@ -59,10 +59,10 @@

- PLW-1 updates + {{ issue.issue_identifier }} updates

- workspacename/projectname/issueId: Sign up flow + {{ issue.name }}

@@ -71,11 +71,7 @@ style="background-color: #F0F0F3; height: 1px; border: 0;margin-top: 15px; margin-bottom: 15px;" />

- updates were made to the issue by - - Srinivas Pendem - - + {{ summary }}

@@ -87,12 +83,13 @@

- {{ Updates / Comments }} + Updates

+ {% for update in data %} @@ -103,14 +100,14 @@
-

- Srinivas Pendem + {{ update.actor_detail.first_name }} {{ change.actor_detail.last_name }}

@@ -123,8 +120,9 @@
- + + {% %} @@ -140,25 +138,30 @@ Assignee :

+ + {% for assignee in update.changes.assignees.old_value %}

- {{ assginee.name }}

+ {{ assginee }}

{% endfor %}
+ {% for assignee in update.changes.assignees.new_value %}

- {{ assginee.name }}

+ {{ assginee }}

{% endfor %}
- + {% endif %} + + {% if update.changes.target_date %} @@ -176,14 +179,15 @@

- 28 Jun, 2023

+ { update.changes.target_date.new_value[0] }

- + {% endif %} + {% if update.changes.duplicate %} @@ -198,85 +202,18 @@ Duplicate :

-

- PLW-2

+ {{ new_value }} + {% endfor %}
- - - - - - - - - - - -
- - -

- Assignee :

-
-

- {{ assginee.name }}

-
-

- +{{length}} more

-
- - - - - - - - - - - - - - - - - -
- - -

- Assignee :

-
-

- {{ assginee.name }}

-
- - -

- {{ assginee.name }}

-
-

- +{{length}} more

-
- - - + {% endif %} + {% if update.changes.labels %} @@ -338,7 +275,8 @@ - + {% endif %} + {% endfor %} @@ -408,12 +346,12 @@
This email was sent to akhil@plane.so. + style="color: #3A5BC7; font-weight: 500; text-decoration: none;">{{ receiver.email }} If you'd rather not receive this kind of - email, you can unsubscribe to - the issue or manage + email, you can unsubscribe to + the issue or manage your email preferences. From ff698a2cac20e062079e8f78849ccd22464643a0 Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Mon, 22 Jan 2024 13:40:30 +0530 Subject: [PATCH 14/24] dev: template updates --- apiserver/plane/bgtasks/email_notification_task.py | 2 +- apiserver/templates/emails/notifications/issue-updates.html | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apiserver/plane/bgtasks/email_notification_task.py b/apiserver/plane/bgtasks/email_notification_task.py index 676f0f7353..532dbaaefd 100644 --- a/apiserver/plane/bgtasks/email_notification_task.py +++ b/apiserver/plane/bgtasks/email_notification_task.py @@ -120,7 +120,7 @@ def send_email_notification( issue_id, notification_data, receiver_id, email_notification_ids ): base_api = "http://localhost:3000" - data = create_payload(payload=notification_data) + data = create_payload(notification_data=notification_data) # Get email configurations ( diff --git a/apiserver/templates/emails/notifications/issue-updates.html b/apiserver/templates/emails/notifications/issue-updates.html index 66ec71b1e2..2b0886937e 100644 --- a/apiserver/templates/emails/notifications/issue-updates.html +++ b/apiserver/templates/emails/notifications/issue-updates.html @@ -122,7 +122,7 @@ - {% %} + {% if update.changes.assignees %} From 6ed7bff4e1e4fdb735f5ef1f1c1d194e39dab462 Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Mon, 22 Jan 2024 14:25:15 +0530 Subject: [PATCH 15/24] dev: update models --- apiserver/plane/app/serializers/__init__.py | 2 +- .../plane/app/serializers/notification.py | 9 ++- apiserver/plane/app/views/notification.py | 3 +- ...lnotificationlog_notificationpreference.py | 62 ------------------- .../db/migrations/0056_auto_20240111_1454.py | 30 --------- .../0057_emailnotificationlog_processed_at.py | 18 ------ apiserver/plane/db/models/__init__.py | 2 +- apiserver/plane/db/models/notification.py | 35 ++++++++--- apiserver/plane/db/models/user.py | 8 +++ 9 files changed, 46 insertions(+), 123 deletions(-) delete mode 100644 apiserver/plane/db/migrations/0054_emailnotificationlog_notificationpreference.py delete mode 100644 apiserver/plane/db/migrations/0056_auto_20240111_1454.py delete mode 100644 apiserver/plane/db/migrations/0057_emailnotificationlog_processed_at.py diff --git a/apiserver/plane/app/serializers/__init__.py b/apiserver/plane/app/serializers/__init__.py index 094328fffa..b9ee019dd2 100644 --- a/apiserver/plane/app/serializers/__init__.py +++ b/apiserver/plane/app/serializers/__init__.py @@ -114,7 +114,7 @@ from .analytic import AnalyticViewSerializer -from .notification import NotificationSerializer +from .notification import NotificationSerializer, UserNotificationPreferenceSerializer from .exporter import ExporterHistorySerializer diff --git a/apiserver/plane/app/serializers/notification.py b/apiserver/plane/app/serializers/notification.py index 70d8762418..2152fcf0f9 100644 --- a/apiserver/plane/app/serializers/notification.py +++ b/apiserver/plane/app/serializers/notification.py @@ -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__" diff --git a/apiserver/plane/app/views/notification.py b/apiserver/plane/app/views/notification.py index 3112c5b39a..ebe8e50822 100644 --- a/apiserver/plane/app/views/notification.py +++ b/apiserver/plane/app/views/notification.py @@ -15,8 +15,9 @@ IssueSubscriber, Issue, WorkspaceMember, + UserNotificationPreference, ) -from plane.app.serializers import NotificationSerializer +from plane.app.serializers import NotificationSerializer, UserNotificationPreferenceSerializer class NotificationViewSet(BaseViewSet, BasePaginator): diff --git a/apiserver/plane/db/migrations/0054_emailnotificationlog_notificationpreference.py b/apiserver/plane/db/migrations/0054_emailnotificationlog_notificationpreference.py deleted file mode 100644 index 97a507a6e5..0000000000 --- a/apiserver/plane/db/migrations/0054_emailnotificationlog_notificationpreference.py +++ /dev/null @@ -1,62 +0,0 @@ -# Generated by Django 4.2.7 on 2024-01-10 08:42 - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion -import plane.db.models.notification -import uuid - - -class Migration(migrations.Migration): - - dependencies = [ - ('db', '0053_auto_20240102_1315'), - ] - - operations = [ - 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)), - ('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')), - ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', 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_logs', to=settings.AUTH_USER_MODEL)), - ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace')), - ], - options={ - 'verbose_name': 'Email Notification Log', - 'verbose_name_plural': 'Email Notification Logs', - 'db_table': 'email_notification_logs', - 'ordering': ('-created_at',), - }, - ), - migrations.CreateModel( - name='NotificationPreference', - 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)), - ('created_by', models.JSONField(default=plane.db.models.notification.get_default_preference)), - ('assigned', models.JSONField(default=plane.db.models.notification.get_default_preference)), - ('subscribed', models.JSONField(default=plane.db.models.notification.get_default_preference)), - ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', 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(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace')), - ], - options={ - 'verbose_name': 'Notification Preference', - 'verbose_name_plural': 'Notification Preferences', - 'db_table': 'notification_preferences', - 'ordering': ('-created_at',), - 'unique_together': {('project', 'user')}, - }, - ), - ] diff --git a/apiserver/plane/db/migrations/0056_auto_20240111_1454.py b/apiserver/plane/db/migrations/0056_auto_20240111_1454.py deleted file mode 100644 index a0d0ccfa34..0000000000 --- a/apiserver/plane/db/migrations/0056_auto_20240111_1454.py +++ /dev/null @@ -1,30 +0,0 @@ -# Generated by Django 4.2.7 on 2024-01-11 14:54 - -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", "0055_usernotificationpreference_emailnotificationlog"), - ] - - operations = [ - migrations.RunPython(create_notification_preferences), - ] diff --git a/apiserver/plane/db/migrations/0057_emailnotificationlog_processed_at.py b/apiserver/plane/db/migrations/0057_emailnotificationlog_processed_at.py deleted file mode 100644 index 3d773dba9b..0000000000 --- a/apiserver/plane/db/migrations/0057_emailnotificationlog_processed_at.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 4.2.7 on 2024-01-16 07:08 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('db', '0056_auto_20240111_1454'), - ] - - operations = [ - migrations.AddField( - model_name='emailnotificationlog', - name='processed_at', - field=models.DateTimeField(null=True), - ), - ] diff --git a/apiserver/plane/db/models/__init__.py b/apiserver/plane/db/models/__init__.py index 09430591f4..d9096bd01f 100644 --- a/apiserver/plane/db/models/__init__.py +++ b/apiserver/plane/db/models/__init__.py @@ -85,7 +85,7 @@ from .analytic import AnalyticView -from .notification import Notification, NotificationPreference, EmailNotificationLog +from .notification import Notification, UserNotificationPreference, EmailNotificationLog from .exporter import ExporterHistory diff --git a/apiserver/plane/db/models/notification.py b/apiserver/plane/db/models/notification.py index 5f640086f8..b42ae54a92 100644 --- a/apiserver/plane/db/models/notification.py +++ b/apiserver/plane/db/models/notification.py @@ -67,26 +67,43 @@ def get_default_preference(): class UserNotificationPreference(BaseModel): - created = models.JSONField(default=get_default_preference) - assigned = models.JSONField(default=get_default_preference) - subscribed = models.JSONField(default=get_default_preference) + # 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, + ) - class Meta: - unique_together = ["project", "user"] + # 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) - verbose_name = "Notification Preference" - verbose_name_plural = "Notification Preferences" + class Meta: + verbose_name = "UserNotificationPreference" + verbose_name_plural = "UserNotificationPreferences" db_table = "user_notification_preferences" ordering = ("-created_at",) def __str__(self): - """Return name of the notifications""" - return f"{self.user.email} <{self.workspace.name}>" + """Return the user""" + return f"<{self.user}>" class EmailNotificationLog(BaseModel): # receiver diff --git a/apiserver/plane/db/models/user.py b/apiserver/plane/db/models/user.py index cf9b9f68a8..6f8a82e567 100644 --- a/apiserver/plane/db/models/user.py +++ b/apiserver/plane/db/models/user.py @@ -11,8 +11,16 @@ 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 { From 7aeffac288029ec9ab78e7dbcb4daa2c4f91c027 Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Mon, 22 Jan 2024 14:55:00 +0530 Subject: [PATCH 16/24] dev: update template for labels and new migrations --- .../plane/bgtasks/email_notification_task.py | 4 +- .../plane/bgtasks/issue_activites_task.py | 3 +- ...ficationpreference_emailnotificationlog.py | 184 ++++++++++++++++++ .../db/migrations/0057_auto_20240122_0901.py | 28 +++ .../emails/notifications/issue-updates.html | 40 +--- 5 files changed, 224 insertions(+), 35 deletions(-) create mode 100644 apiserver/plane/db/migrations/0056_usernotificationpreference_emailnotificationlog.py create mode 100644 apiserver/plane/db/migrations/0057_auto_20240122_0901.py diff --git a/apiserver/plane/bgtasks/email_notification_task.py b/apiserver/plane/bgtasks/email_notification_task.py index 532dbaaefd..809bd7b975 100644 --- a/apiserver/plane/bgtasks/email_notification_task.py +++ b/apiserver/plane/bgtasks/email_notification_task.py @@ -156,9 +156,9 @@ def send_email_notification( summary = "" if len(template_data) == 1: - summary = f"{template_data[0]['actor_detail']['first_name']} {template_data[0]['actor_detail']['last_name']} made {total_changes} to the issue" + summary = f"{template_data[0]['actor_detail']['first_name']} {template_data[0]['actor_detail']['last_name']} made {total_changes} changes to the issue" else: - summary = f"{template_data[0]['actor_detail']['first_name']} {template_data[0]['actor_detail']['last_name']} and others made {total_changes} to the issue" + summary = f"{template_data[0]['actor_detail']['first_name']} {template_data[0]['actor_detail']['last_name']} and others made {total_changes} changes to the issue" # Send the mail subject = f"{issue.project.identifier}-{issue.sequence_id} {issue.name}" diff --git a/apiserver/plane/bgtasks/issue_activites_task.py b/apiserver/plane/bgtasks/issue_activites_task.py index e8d0cc4b80..e93bf1c5db 100644 --- a/apiserver/plane/bgtasks/issue_activites_task.py +++ b/apiserver/plane/bgtasks/issue_activites_task.py @@ -24,6 +24,7 @@ IssueReaction, CommentReaction, IssueComment, + IssueSubscriber, ) from plane.app.serializers import IssueActivitySerializer from plane.bgtasks.notification_task import notifications @@ -361,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( @@ -580,7 +582,6 @@ def update_issue_activity( for key in requested_data: func = ISSUE_ACTIVITY_MAPPER.get(key) - print(key, func) if func is not None: func( requested_data=requested_data, diff --git a/apiserver/plane/db/migrations/0056_usernotificationpreference_emailnotificationlog.py b/apiserver/plane/db/migrations/0056_usernotificationpreference_emailnotificationlog.py new file mode 100644 index 0000000000..2e6645945a --- /dev/null +++ b/apiserver/plane/db/migrations/0056_usernotificationpreference_emailnotificationlog.py @@ -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",), + }, + ), + ] diff --git a/apiserver/plane/db/migrations/0057_auto_20240122_0901.py b/apiserver/plane/db/migrations/0057_auto_20240122_0901.py new file mode 100644 index 0000000000..9204d43b3f --- /dev/null +++ b/apiserver/plane/db/migrations/0057_auto_20240122_0901.py @@ -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) + ] diff --git a/apiserver/templates/emails/notifications/issue-updates.html b/apiserver/templates/emails/notifications/issue-updates.html index 2b0886937e..aae2cfb92b 100644 --- a/apiserver/templates/emails/notifications/issue-updates.html +++ b/apiserver/templates/emails/notifications/issue-updates.html @@ -230,41 +230,14 @@ @@ -276,8 +249,9 @@ {% endif %} - {% endfor %} + {% if update.changes.comment %} + { for comment in update.changes.comment } + {% endif %} + {% endfor %}
+ {% for label in update.changes.labels.new_value %} - - - - - - - + {% endfor %}

- Improvement -

-
-

- Jinx -

-
-

- Ready to ship -

-
-

- Ready to ship -

-
-

- Feature + {{ label }}

- + 5more
@@ -292,7 +266,7 @@ @@ -313,6 +287,8 @@

- Srinivas Pendem +

From d269c31d205d40b2170aa44bfae41066526b0488 Mon Sep 17 00:00:00 2001 From: Prateek Shourya Date: Mon, 22 Jan 2024 14:28:00 +0530 Subject: [PATCH 17/24] fix: profile settings preference sidebar. --- web/constants/profile.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/constants/profile.ts b/web/constants/profile.ts index 3f17bb329d..063bb7e440 100644 --- a/web/constants/profile.ts +++ b/web/constants/profile.ts @@ -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, }, From 02531f6ed8bcaca40e78e4f36462ef299d95763f Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Mon, 22 Jan 2024 15:06:45 +0530 Subject: [PATCH 18/24] dev: update preference endpoints --- apiserver/plane/app/urls/notification.py | 6 ++++++ apiserver/plane/app/views/__init__.py | 1 + web/services/user.service.ts | 4 ++-- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/apiserver/plane/app/urls/notification.py b/apiserver/plane/app/urls/notification.py index 0c96e5f15b..0bbf4f3c79 100644 --- a/apiserver/plane/app/urls/notification.py +++ b/apiserver/plane/app/urls/notification.py @@ -5,6 +5,7 @@ NotificationViewSet, UnreadNotificationEndpoint, MarkAllReadNotificationViewSet, + UserNotificationPreferenceEndpoint, ) @@ -63,4 +64,9 @@ ), name="mark-all-read-notifications", ), + path( + "users/me/notification-preferences/", + UserNotificationPreferenceEndpoint.as_view(), + name="user-notification-preferences", + ), ] diff --git a/apiserver/plane/app/views/__init__.py b/apiserver/plane/app/views/__init__.py index d3c4f4baf4..457738fe31 100644 --- a/apiserver/plane/app/views/__init__.py +++ b/apiserver/plane/app/views/__init__.py @@ -165,6 +165,7 @@ NotificationViewSet, UnreadNotificationEndpoint, MarkAllReadNotificationViewSet, + UserNotificationPreferenceEndpoint, ) from .exporter import ExportIssuesEndpoint diff --git a/web/services/user.service.ts b/web/services/user.service.ts index 71004ed24e..13ffa9c51e 100644 --- a/web/services/user.service.ts +++ b/web/services/user.service.ts @@ -70,7 +70,7 @@ export class UserService extends APIService { } async currentUserEmailNotificationSettings(): Promise { - return this.get("/api/notification-preferences/") + return this.get("/api/users/me/notification-preferences/") .then((response) => response?.data) .catch((error) => { throw error?.response; @@ -106,7 +106,7 @@ export class UserService extends APIService { } async updateCurrentUserEmailNotificationSettings(data: Partial): Promise { - return this.patch("/api/notification-preferences/", data) + return this.patch("/api/users/me/notification-preferences/", data) .then((response) => response?.data) .catch((error) => { throw error?.response?.data; From 73e84224fb92852316dad787ed9b05069b7eb46e Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Mon, 22 Jan 2024 15:25:35 +0530 Subject: [PATCH 19/24] dev: update the schedule to 5 minutes --- apiserver/plane/celery.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apiserver/plane/celery.py b/apiserver/plane/celery.py index 6872eb43c3..0912e276af 100644 --- a/apiserver/plane/celery.py +++ b/apiserver/plane/celery.py @@ -31,7 +31,7 @@ }, "check-every-five-minutes-to-send-email-notifications": { "task": "plane.bgtasks.email_notification_task.stack_email_notification", - "schedule": timedelta(seconds=10) + "schedule": crontab(minute='*/5') }, } From 5b7787550bf42c9f94415b8cf42914de27a1c455 Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Mon, 22 Jan 2024 19:21:37 +0530 Subject: [PATCH 20/24] dev: update template with priority data --- .../plane/bgtasks/email_notification_task.py | 56 +- apiserver/plane/bgtasks/notification_task.py | 1 + .../emails/notifications/issue-updates.html | 1237 ++++++++++++----- 3 files changed, 923 insertions(+), 371 deletions(-) diff --git a/apiserver/plane/bgtasks/email_notification_task.py b/apiserver/plane/bgtasks/email_notification_task.py index 809bd7b975..c6b7f61cfc 100644 --- a/apiserver/plane/bgtasks/email_notification_task.py +++ b/apiserver/plane/bgtasks/email_notification_task.py @@ -1,12 +1,15 @@ +import json +from datetime import datetime + # Third party imports from celery import shared_task -import os # 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 @@ -81,8 +84,8 @@ def create_payload(notification_data): issue_activity = change.get("issue_activity") if issue_activity: # Ensure issue_activity is not None field = issue_activity.get("field") - old_value = issue_activity.get("old_value") - new_value = issue_activity.get("new_value") + 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: @@ -112,6 +115,13 @@ def create_payload(notification_data): "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 @@ -119,7 +129,7 @@ def create_payload(notification_data): def send_email_notification( issue_id, notification_data, receiver_id, email_notification_ids ): - base_api = "http://localhost:3000" + base_api = "https://app.plane.so" data = create_payload(notification_data=notification_data) # Get email configurations @@ -136,13 +146,28 @@ def send_email_notification( 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": str(activity_time), + } + ) + activity_time = changes.pop("activity_time") template_data.append( { "actor_detail": { - "avatar": actor.avatar, + "avatar_url": actor.avatar, "first_name": actor.first_name, "last_name": actor.last_name, }, @@ -151,14 +176,20 @@ def send_email_notification( "name": issue.name, "identifier": f"{issue.project.identifier}-{issue.sequence_id}", }, + "activity_time": str(activity_time), } ) - summary = "" - if len(template_data) == 1: - summary = f"{template_data[0]['actor_detail']['first_name']} {template_data[0]['actor_detail']['last_name']} made {total_changes} changes to the issue" - else: - summary = f"{template_data[0]['actor_detail']['first_name']} {template_data[0]['actor_detail']['last_name']} and others made {total_changes} changes to the issue" + span = f"""""" + + summary = "updates were made to the issue by" # Send the mail subject = f"{issue.project.identifier}-{issue.sequence_id} {issue.name}" @@ -168,13 +199,16 @@ def send_email_notification( "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" + "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 ) diff --git a/apiserver/plane/bgtasks/notification_task.py b/apiserver/plane/bgtasks/notification_task.py index 26dc83f856..bcd0f85437 100644 --- a/apiserver/plane/bgtasks/notification_task.py +++ b/apiserver/plane/bgtasks/notification_task.py @@ -467,6 +467,7 @@ def notifications( if issue_comment is not None else "" ), + "activity_time": issue_activity.get("created_at"), }, }, ) diff --git a/apiserver/templates/emails/notifications/issue-updates.html b/apiserver/templates/emails/notifications/issue-updates.html index aae2cfb92b..047c9c7e12 100644 --- a/apiserver/templates/emails/notifications/issue-updates.html +++ b/apiserver/templates/emails/notifications/issue-updates.html @@ -1,383 +1,900 @@ - - - + + - Your unique Plane login code is code + Updates on Issue - - - - - - - - -
-

- View issue -

-
- - - - -
- + + +
+ + + + + + + - -
+ + + + +
+
+ + + + Plane + +
+
+
+ + + - - - - - + + +
+
+ - + -
-
- -

- Plane -

-
-
+

+ {{ issue.identifier }} updates +

+

+ {{ issue.name }} +

+
- -
- - - + + {% endfor %} +
-
- - - - -
-

- {{ issue.issue_identifier }} updates -

-

- {{ issue.name }} -

-
-
- -

- {{ summary }} -

- - - - - - - - +
-

- Updates -

-
+
+

+ {{ summary }} + + {{ data.0.actor_detail.first_name}} {{data.0.actor_detail.last_name }} + +

+ + + + + + + + {% for update in data %} + + + + + + {% endif %} + + {% if update.changes.link %} + + + + {% endif %} + + {% if update.changes.priority %} + + + + {% endif %} + + {% if update.changes.blocking %} + + + + {% endif %} +
+

+ Updates +

+
+ + + + + + + {% if update.changes.assignee %} + + + + {% endif %} + + {% if update.changes.target_date %} + + + + {% endif %} --> + + {% if update.changes.duplicate %} + + + + {% endif %} + + {% if update.changes.labels %} + + + + {% endif %} + + {% if update.changes.state %} + + + +
+ + + + + + +
+ + +

+ {{ update.actor_detail.first_name }} {{ update.actor_detail.last_name }} +

+
+

+ {{ update.activity_time }} +

+
+
+ + + + + {% for assignee in update.changes.assignee.new_value %} + + {% endfor %} + + + +
+

+ Assignee: +

+
+

+ {{ assignee }} +

+
+ + +

+ {{ assginee.name }} +

+
+
+ + + + + + +
+ + +

+ Due Date: +

+
+

+ {{ update.changes.target_date.new_value.0 }} +

+
+
+ + + + + {% for dup in update.changes.duplicate.new_value %} + + {% endfor %} + +
+ + +

+ Duplicate: +

+
+

+ {{ dup }} +

+
+
+ + + + - - {% for update in data %} - + + +
+ + +

+ Labels: +

+
+ + + {% for label in update.changes.labels.new_value %} - - {% endif %} - {% if update.changes.comment %} - - { for comment in update.changes.comment } + {% endfor %} + +
- - - - - - - - {% if update.changes.assignees %} - - - - {% endif %} - - {% if update.changes.target_date %} - - - - - {% endif %} - - {% if update.changes.duplicate %} - - - - {% endif %} - {% if update.changes.labels %} - - - - -
- - - - - - -
- - - -

- {{ update.actor_detail.first_name }} {{ change.actor_detail.last_name }} -

-
-

- 11:36 am IST -

-
-
- - - - - - - - - -
- - -

- Assignee :

-
- - {% for assignee in update.changes.assignees.old_value %} -

- {{ assginee }}

{% endfor %} -
- - - {% for assignee in update.changes.assignees.new_value %} -

- {{ assginee }}

{% endfor %} -
- -
- - - - - - -
- - -

- Due Date :

-
-

- { update.changes.target_date.new_value[0] }

-
-
- - - - - - -
- - -

- Duplicate :

-
- {% for new_value in update.changes.duplicate.new_value %} - - {{ new_value }} - {% endfor %} -
-
- - - - - -
- - Labels - : - - - - {% for label in update.changes.labels.new_value %} - - {% endfor %} - -
-

- {{ label }} -

-
-
-
+

+ {{ label }} +

+
+
+
+ - + - {% endif %} - {% endfor %} -
- - - - - -
- - - - - - - - + + + + - -
-

- -

-
-
- Connect with folks in finance and operations to - verify the data - provided before presenting it. -
-
+ + +

+ State: +

+
+

+ {{ update.changes.state.old_value.0 }} +

+
->
-
-
+

+ {{ update.changes.state.new_value.0 }} +

+
-
+
+ + + + + + +
+ + +

+ Link: +

+
+ + {{ update.changes.link.new_value.0 }} + +
+
+ + + + + + + + +
+ + +

+ Priority: +

+
+

+ {{ update.changes.priority.old_value.0 }} +

+
+ -> + +

+ {{ update.changes.priority.new_value.0 }} +

+
+
+ + + + + {% for bl in update.changes.blocking.new_value %} + + {% endfor %} + +
+ + +

+ Blocking: +

+
+ + {{ bl }} + +
+
+
+ + {% if comments %} + + + + + + + + -
+

+ Comments +

+
+
+
+ +
+ + + + {% for comment in comments %} + + {% endfor %} + +
+
+ S +
+ +
+ + + + + {% for actor_comment in comment.actor_comments %} + + + + {% endfor %} +
+

+ {{ comment.actor_detail.first_name }} {{ + comment.actor_detail.last_name }} +

+
+
+ {{ actor_comment.comment }} +
+
+
+
+ {% endif %} -
-
- - - - - - + + + + + + + + - - -
-
- This email was sent to {{ receiver.email }} - If you'd rather not - receive this - kind of - email, you can unsubscribe to - the issue or manage - your email - preferences. - - -
- - - - - - - - - - - - -
- -
+ + + + +
+
+ This email was sent to + {{ receiver.email }}. + If you'd rather not receive this kind of email, + you can unsubscribe to the issue + or + manage your email preferences. + + +
+
- + - - + - - - \ No newline at end of file + + From e2f54db65c866d00381c74fffe4028c74161787c Mon Sep 17 00:00:00 2001 From: pablohashescobar Date: Mon, 22 Jan 2024 20:38:01 +0530 Subject: [PATCH 21/24] dev: update templates --- .../emails/notifications/issue-updates.html | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/apiserver/templates/emails/notifications/issue-updates.html b/apiserver/templates/emails/notifications/issue-updates.html index 047c9c7e12..99470535f9 100644 --- a/apiserver/templates/emails/notifications/issue-updates.html +++ b/apiserver/templates/emails/notifications/issue-updates.html @@ -209,7 +209,7 @@ - {% if update.changes.assignee %} + {% if update.changes.assignees %} - {% for assignee in update.changes.assignee.new_value %} + {% for assignee in update.changes.assignees.old_value %}

+ {% endif %} + {% for assignee in update.changes.assignees.new_value %}

- {{ assginee.name }} + {{ assginee }}

+ {% endfor %} @@ -754,8 +758,7 @@ margin-left: 8px; " > - {{ comment.actor_detail.first_name }} {{ - comment.actor_detail.last_name }} + {{ comment.actor_detail.first_name }} {{ comment.actor_detail.last_name }}

@@ -777,7 +780,7 @@ border-radius: 4px; " > - {{ actor_comment.comment }} + {{ actor_comment.new_value }}
From 73d9ff8133fb1142f8d025f7443eb902b8ec710a Mon Sep 17 00:00:00 2001 From: Prateek Shourya Date: Tue, 23 Jan 2024 12:28:15 +0530 Subject: [PATCH 22/24] chore: enable `issue subscribe` button for all users. --- web/components/issues/issue-detail/sidebar.tsx | 7 +------ web/components/issues/issue-detail/subscription.tsx | 7 +------ web/components/issues/peek-overview/view.tsx | 7 +------ 3 files changed, 3 insertions(+), 18 deletions(-) diff --git a/web/components/issues/issue-detail/sidebar.tsx b/web/components/issues/issue-detail/sidebar.tsx index 6b249f4bd5..b4f8ac9976 100644 --- a/web/components/issues/issue-detail/sidebar.tsx +++ b/web/components/issues/issue-detail/sidebar.tsx @@ -154,12 +154,7 @@ export const IssueDetailsSidebar: React.FC = observer((props) => {
{currentUser && !is_archived && (fieldsToShow.includes("all") || fieldsToShow.includes("subscribe")) && ( - + )} {(fieldsToShow.includes("all") || fieldsToShow.includes("link")) && ( diff --git a/web/components/issues/issue-detail/subscription.tsx b/web/components/issues/issue-detail/subscription.tsx index d20bc53cf6..b57e75beda 100644 --- a/web/components/issues/issue-detail/subscription.tsx +++ b/web/components/issues/issue-detail/subscription.tsx @@ -11,14 +11,12 @@ export type TIssueSubscription = { workspaceSlug: string; projectId: string; issueId: string; - currentUserId: string; }; export const IssueSubscription: FC = 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 = 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 = observer((props) => { } }; - if (issue?.created_by === currentUserId || issue?.assignee_ids?.includes(currentUserId)) return <>; - return (