forked from github/plane
dev: email notifications (#3421)
* dev: create email notification preference model * dev: intiate models * dev: user notification preferences * dev: create notification logs for the user. * dev: email notification stacking and sending logic * feat: email notification preference settings page. * dev: delete subscribers * dev: issue update ui implementation in email notification * chore: integrate email notification endpoint. * chore: remove toggle switch. * chore: added labels part * fix: refactored base design with tables * dev: email notification templates * dev: template updates * dev: update models * dev: update template for labels and new migrations * fix: profile settings preference sidebar. * dev: update preference endpoints * dev: update the schedule to 5 minutes * dev: update template with priority data * dev: update templates * chore: enable `issue subscribe` button for all users. * chore: notification handling for external api * dev: update origin request --------- Co-authored-by: Prateek Shourya <prateekshourya29@gmail.com> Co-authored-by: LAKHAN BAHETI <lakhanbaheti9@gmail.com> Co-authored-by: Ramesh Kumar Chandra <rameshkumar2299@gmail.com> Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
This commit is contained in:
parent
c1e1b81b99
commit
f27efb80e1
@ -115,7 +115,7 @@ from .inbox import (
|
|||||||
|
|
||||||
from .analytic import AnalyticViewSerializer
|
from .analytic import AnalyticViewSerializer
|
||||||
|
|
||||||
from .notification import NotificationSerializer
|
from .notification import NotificationSerializer, UserNotificationPreferenceSerializer
|
||||||
|
|
||||||
from .exporter import ExporterHistorySerializer
|
from .exporter import ExporterHistorySerializer
|
||||||
|
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
# Module imports
|
# Module imports
|
||||||
from .base import BaseSerializer
|
from .base import BaseSerializer
|
||||||
from .user import UserLiteSerializer
|
from .user import UserLiteSerializer
|
||||||
from plane.db.models import Notification
|
from plane.db.models import Notification, UserNotificationPreference
|
||||||
|
|
||||||
|
|
||||||
class NotificationSerializer(BaseSerializer):
|
class NotificationSerializer(BaseSerializer):
|
||||||
@ -12,3 +12,10 @@ class NotificationSerializer(BaseSerializer):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = Notification
|
model = Notification
|
||||||
fields = "__all__"
|
fields = "__all__"
|
||||||
|
|
||||||
|
|
||||||
|
class UserNotificationPreferenceSerializer(BaseSerializer):
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = UserNotificationPreference
|
||||||
|
fields = "__all__"
|
||||||
|
@ -5,6 +5,7 @@ from plane.app.views import (
|
|||||||
NotificationViewSet,
|
NotificationViewSet,
|
||||||
UnreadNotificationEndpoint,
|
UnreadNotificationEndpoint,
|
||||||
MarkAllReadNotificationViewSet,
|
MarkAllReadNotificationViewSet,
|
||||||
|
UserNotificationPreferenceEndpoint,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -63,4 +64,9 @@ urlpatterns = [
|
|||||||
),
|
),
|
||||||
name="mark-all-read-notifications",
|
name="mark-all-read-notifications",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"users/me/notification-preferences/",
|
||||||
|
UserNotificationPreferenceEndpoint.as_view(),
|
||||||
|
name="user-notification-preferences",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
@ -167,6 +167,7 @@ from .notification import (
|
|||||||
NotificationViewSet,
|
NotificationViewSet,
|
||||||
UnreadNotificationEndpoint,
|
UnreadNotificationEndpoint,
|
||||||
MarkAllReadNotificationViewSet,
|
MarkAllReadNotificationViewSet,
|
||||||
|
UserNotificationPreferenceEndpoint,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .exporter import ExportIssuesEndpoint
|
from .exporter import ExportIssuesEndpoint
|
||||||
|
@ -530,6 +530,8 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
|||||||
project_id=str(project_id),
|
project_id=str(project_id),
|
||||||
current_instance=None,
|
current_instance=None,
|
||||||
epoch=int(timezone.now().timestamp()),
|
epoch=int(timezone.now().timestamp()),
|
||||||
|
notification=True,
|
||||||
|
origin=request.META.get("HTTP_ORIGIN"),
|
||||||
)
|
)
|
||||||
# Delete the cycle
|
# Delete the cycle
|
||||||
cycle.delete()
|
cycle.delete()
|
||||||
@ -721,6 +723,8 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet):
|
|||||||
}
|
}
|
||||||
),
|
),
|
||||||
epoch=int(timezone.now().timestamp()),
|
epoch=int(timezone.now().timestamp()),
|
||||||
|
notification=True,
|
||||||
|
origin=request.META.get("HTTP_ORIGIN"),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Return all Cycle Issues
|
# Return all Cycle Issues
|
||||||
@ -753,6 +757,8 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet):
|
|||||||
project_id=str(self.kwargs.get("project_id", None)),
|
project_id=str(self.kwargs.get("project_id", None)),
|
||||||
current_instance=None,
|
current_instance=None,
|
||||||
epoch=int(timezone.now().timestamp()),
|
epoch=int(timezone.now().timestamp()),
|
||||||
|
notification=True,
|
||||||
|
origin=request.META.get("HTTP_ORIGIN"),
|
||||||
)
|
)
|
||||||
cycle_issue.delete()
|
cycle_issue.delete()
|
||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
|
@ -200,6 +200,8 @@ class InboxIssueViewSet(BaseViewSet):
|
|||||||
project_id=str(project_id),
|
project_id=str(project_id),
|
||||||
current_instance=None,
|
current_instance=None,
|
||||||
epoch=int(timezone.now().timestamp()),
|
epoch=int(timezone.now().timestamp()),
|
||||||
|
notification=True,
|
||||||
|
origin=request.META.get("HTTP_ORIGIN"),
|
||||||
)
|
)
|
||||||
# create an inbox issue
|
# create an inbox issue
|
||||||
InboxIssue.objects.create(
|
InboxIssue.objects.create(
|
||||||
@ -277,6 +279,8 @@ class InboxIssueViewSet(BaseViewSet):
|
|||||||
cls=DjangoJSONEncoder,
|
cls=DjangoJSONEncoder,
|
||||||
),
|
),
|
||||||
epoch=int(timezone.now().timestamp()),
|
epoch=int(timezone.now().timestamp()),
|
||||||
|
notification=True,
|
||||||
|
origin=request.META.get("HTTP_ORIGIN"),
|
||||||
)
|
)
|
||||||
issue_serializer.save()
|
issue_serializer.save()
|
||||||
else:
|
else:
|
||||||
|
@ -259,6 +259,8 @@ class IssueViewSet(WebhookMixin, BaseViewSet):
|
|||||||
project_id=str(project_id),
|
project_id=str(project_id),
|
||||||
current_instance=None,
|
current_instance=None,
|
||||||
epoch=int(timezone.now().timestamp()),
|
epoch=int(timezone.now().timestamp()),
|
||||||
|
notification=True,
|
||||||
|
origin=request.META.get("HTTP_ORIGIN"),
|
||||||
)
|
)
|
||||||
issue = (
|
issue = (
|
||||||
self.get_queryset().filter(pk=serializer.data["id"]).first()
|
self.get_queryset().filter(pk=serializer.data["id"]).first()
|
||||||
@ -297,6 +299,8 @@ class IssueViewSet(WebhookMixin, BaseViewSet):
|
|||||||
project_id=str(project_id),
|
project_id=str(project_id),
|
||||||
current_instance=current_instance,
|
current_instance=current_instance,
|
||||||
epoch=int(timezone.now().timestamp()),
|
epoch=int(timezone.now().timestamp()),
|
||||||
|
notification=True,
|
||||||
|
origin=request.META.get("HTTP_ORIGIN"),
|
||||||
)
|
)
|
||||||
issue = self.get_queryset().filter(pk=pk).first()
|
issue = self.get_queryset().filter(pk=pk).first()
|
||||||
return Response(
|
return Response(
|
||||||
@ -320,6 +324,8 @@ class IssueViewSet(WebhookMixin, BaseViewSet):
|
|||||||
project_id=str(project_id),
|
project_id=str(project_id),
|
||||||
current_instance=current_instance,
|
current_instance=current_instance,
|
||||||
epoch=int(timezone.now().timestamp()),
|
epoch=int(timezone.now().timestamp()),
|
||||||
|
notification=True,
|
||||||
|
origin=request.META.get("HTTP_ORIGIN"),
|
||||||
)
|
)
|
||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
@ -595,6 +601,8 @@ class IssueCommentViewSet(WebhookMixin, BaseViewSet):
|
|||||||
project_id=str(self.kwargs.get("project_id")),
|
project_id=str(self.kwargs.get("project_id")),
|
||||||
current_instance=None,
|
current_instance=None,
|
||||||
epoch=int(timezone.now().timestamp()),
|
epoch=int(timezone.now().timestamp()),
|
||||||
|
notification=True,
|
||||||
|
origin=request.META.get("HTTP_ORIGIN"),
|
||||||
)
|
)
|
||||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
@ -624,6 +632,8 @@ class IssueCommentViewSet(WebhookMixin, BaseViewSet):
|
|||||||
project_id=str(project_id),
|
project_id=str(project_id),
|
||||||
current_instance=current_instance,
|
current_instance=current_instance,
|
||||||
epoch=int(timezone.now().timestamp()),
|
epoch=int(timezone.now().timestamp()),
|
||||||
|
notification=True,
|
||||||
|
origin=request.META.get("HTTP_ORIGIN"),
|
||||||
)
|
)
|
||||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
@ -648,6 +658,8 @@ class IssueCommentViewSet(WebhookMixin, BaseViewSet):
|
|||||||
project_id=str(project_id),
|
project_id=str(project_id),
|
||||||
current_instance=current_instance,
|
current_instance=current_instance,
|
||||||
epoch=int(timezone.now().timestamp()),
|
epoch=int(timezone.now().timestamp()),
|
||||||
|
notification=True,
|
||||||
|
origin=request.META.get("HTTP_ORIGIN"),
|
||||||
)
|
)
|
||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
@ -850,6 +862,8 @@ class SubIssuesEndpoint(BaseAPIView):
|
|||||||
project_id=str(project_id),
|
project_id=str(project_id),
|
||||||
current_instance=json.dumps({"parent": str(sub_issue_id)}),
|
current_instance=json.dumps({"parent": str(sub_issue_id)}),
|
||||||
epoch=int(timezone.now().timestamp()),
|
epoch=int(timezone.now().timestamp()),
|
||||||
|
notification=True,
|
||||||
|
origin=request.META.get("HTTP_ORIGIN"),
|
||||||
)
|
)
|
||||||
for sub_issue_id in sub_issue_ids
|
for sub_issue_id in sub_issue_ids
|
||||||
]
|
]
|
||||||
@ -909,6 +923,8 @@ class IssueLinkViewSet(BaseViewSet):
|
|||||||
project_id=str(self.kwargs.get("project_id")),
|
project_id=str(self.kwargs.get("project_id")),
|
||||||
current_instance=None,
|
current_instance=None,
|
||||||
epoch=int(timezone.now().timestamp()),
|
epoch=int(timezone.now().timestamp()),
|
||||||
|
notification=True,
|
||||||
|
origin=request.META.get("HTTP_ORIGIN"),
|
||||||
)
|
)
|
||||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
@ -938,6 +954,8 @@ class IssueLinkViewSet(BaseViewSet):
|
|||||||
project_id=str(project_id),
|
project_id=str(project_id),
|
||||||
current_instance=current_instance,
|
current_instance=current_instance,
|
||||||
epoch=int(timezone.now().timestamp()),
|
epoch=int(timezone.now().timestamp()),
|
||||||
|
notification=True,
|
||||||
|
origin=request.META.get("HTTP_ORIGIN"),
|
||||||
)
|
)
|
||||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
@ -961,6 +979,8 @@ class IssueLinkViewSet(BaseViewSet):
|
|||||||
project_id=str(project_id),
|
project_id=str(project_id),
|
||||||
current_instance=current_instance,
|
current_instance=current_instance,
|
||||||
epoch=int(timezone.now().timestamp()),
|
epoch=int(timezone.now().timestamp()),
|
||||||
|
notification=True,
|
||||||
|
origin=request.META.get("HTTP_ORIGIN"),
|
||||||
)
|
)
|
||||||
issue_link.delete()
|
issue_link.delete()
|
||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
@ -1017,6 +1037,8 @@ class IssueAttachmentEndpoint(BaseAPIView):
|
|||||||
cls=DjangoJSONEncoder,
|
cls=DjangoJSONEncoder,
|
||||||
),
|
),
|
||||||
epoch=int(timezone.now().timestamp()),
|
epoch=int(timezone.now().timestamp()),
|
||||||
|
notification=True,
|
||||||
|
origin=request.META.get("HTTP_ORIGIN"),
|
||||||
)
|
)
|
||||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
@ -1033,6 +1055,8 @@ class IssueAttachmentEndpoint(BaseAPIView):
|
|||||||
project_id=str(self.kwargs.get("project_id", None)),
|
project_id=str(self.kwargs.get("project_id", None)),
|
||||||
current_instance=None,
|
current_instance=None,
|
||||||
epoch=int(timezone.now().timestamp()),
|
epoch=int(timezone.now().timestamp()),
|
||||||
|
notification=True,
|
||||||
|
origin=request.META.get("HTTP_ORIGIN"),
|
||||||
)
|
)
|
||||||
|
|
||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
@ -1211,6 +1235,8 @@ class IssueArchiveViewSet(BaseViewSet):
|
|||||||
IssueSerializer(issue).data, cls=DjangoJSONEncoder
|
IssueSerializer(issue).data, cls=DjangoJSONEncoder
|
||||||
),
|
),
|
||||||
epoch=int(timezone.now().timestamp()),
|
epoch=int(timezone.now().timestamp()),
|
||||||
|
notification=True,
|
||||||
|
origin=request.META.get("HTTP_ORIGIN"),
|
||||||
)
|
)
|
||||||
issue.archived_at = None
|
issue.archived_at = None
|
||||||
issue.save()
|
issue.save()
|
||||||
@ -1356,6 +1382,8 @@ class IssueReactionViewSet(BaseViewSet):
|
|||||||
project_id=str(project_id),
|
project_id=str(project_id),
|
||||||
current_instance=None,
|
current_instance=None,
|
||||||
epoch=int(timezone.now().timestamp()),
|
epoch=int(timezone.now().timestamp()),
|
||||||
|
notification=True,
|
||||||
|
origin=request.META.get("HTTP_ORIGIN"),
|
||||||
)
|
)
|
||||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
@ -1381,6 +1409,8 @@ class IssueReactionViewSet(BaseViewSet):
|
|||||||
}
|
}
|
||||||
),
|
),
|
||||||
epoch=int(timezone.now().timestamp()),
|
epoch=int(timezone.now().timestamp()),
|
||||||
|
notification=True,
|
||||||
|
origin=request.META.get("HTTP_ORIGIN"),
|
||||||
)
|
)
|
||||||
issue_reaction.delete()
|
issue_reaction.delete()
|
||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
@ -1421,6 +1451,8 @@ class CommentReactionViewSet(BaseViewSet):
|
|||||||
project_id=str(project_id),
|
project_id=str(project_id),
|
||||||
current_instance=None,
|
current_instance=None,
|
||||||
epoch=int(timezone.now().timestamp()),
|
epoch=int(timezone.now().timestamp()),
|
||||||
|
notification=True,
|
||||||
|
origin=request.META.get("HTTP_ORIGIN"),
|
||||||
)
|
)
|
||||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
@ -1447,6 +1479,8 @@ class CommentReactionViewSet(BaseViewSet):
|
|||||||
}
|
}
|
||||||
),
|
),
|
||||||
epoch=int(timezone.now().timestamp()),
|
epoch=int(timezone.now().timestamp()),
|
||||||
|
notification=True,
|
||||||
|
origin=request.META.get("HTTP_ORIGIN"),
|
||||||
)
|
)
|
||||||
comment_reaction.delete()
|
comment_reaction.delete()
|
||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
@ -1575,6 +1609,8 @@ class IssueRelationViewSet(BaseViewSet):
|
|||||||
project_id=str(project_id),
|
project_id=str(project_id),
|
||||||
current_instance=None,
|
current_instance=None,
|
||||||
epoch=int(timezone.now().timestamp()),
|
epoch=int(timezone.now().timestamp()),
|
||||||
|
notification=True,
|
||||||
|
origin=request.META.get("HTTP_ORIGIN"),
|
||||||
)
|
)
|
||||||
|
|
||||||
if relation_type == "blocking":
|
if relation_type == "blocking":
|
||||||
@ -1619,6 +1655,8 @@ class IssueRelationViewSet(BaseViewSet):
|
|||||||
project_id=str(project_id),
|
project_id=str(project_id),
|
||||||
current_instance=current_instance,
|
current_instance=current_instance,
|
||||||
epoch=int(timezone.now().timestamp()),
|
epoch=int(timezone.now().timestamp()),
|
||||||
|
notification=True,
|
||||||
|
origin=request.META.get("HTTP_ORIGIN"),
|
||||||
)
|
)
|
||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
@ -1790,6 +1828,8 @@ class IssueDraftViewSet(BaseViewSet):
|
|||||||
project_id=str(project_id),
|
project_id=str(project_id),
|
||||||
current_instance=None,
|
current_instance=None,
|
||||||
epoch=int(timezone.now().timestamp()),
|
epoch=int(timezone.now().timestamp()),
|
||||||
|
notification=True,
|
||||||
|
origin=request.META.get("HTTP_ORIGIN"),
|
||||||
)
|
)
|
||||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
@ -1820,6 +1860,8 @@ class IssueDraftViewSet(BaseViewSet):
|
|||||||
cls=DjangoJSONEncoder,
|
cls=DjangoJSONEncoder,
|
||||||
),
|
),
|
||||||
epoch=int(timezone.now().timestamp()),
|
epoch=int(timezone.now().timestamp()),
|
||||||
|
notification=True,
|
||||||
|
origin=request.META.get("HTTP_ORIGIN"),
|
||||||
)
|
)
|
||||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
@ -1846,5 +1888,7 @@ class IssueDraftViewSet(BaseViewSet):
|
|||||||
project_id=str(project_id),
|
project_id=str(project_id),
|
||||||
current_instance=current_instance,
|
current_instance=current_instance,
|
||||||
epoch=int(timezone.now().timestamp()),
|
epoch=int(timezone.now().timestamp()),
|
||||||
|
notification=True,
|
||||||
|
origin=request.META.get("HTTP_ORIGIN"),
|
||||||
)
|
)
|
||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
|
@ -310,6 +310,8 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
|
|||||||
project_id=str(project_id),
|
project_id=str(project_id),
|
||||||
current_instance=None,
|
current_instance=None,
|
||||||
epoch=int(timezone.now().timestamp()),
|
epoch=int(timezone.now().timestamp()),
|
||||||
|
notification=True,
|
||||||
|
origin=request.META.get("HTTP_ORIGIN"),
|
||||||
)
|
)
|
||||||
module.delete()
|
module.delete()
|
||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
@ -488,6 +490,8 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
|
|||||||
}
|
}
|
||||||
),
|
),
|
||||||
epoch=int(timezone.now().timestamp()),
|
epoch=int(timezone.now().timestamp()),
|
||||||
|
notification=True,
|
||||||
|
origin=request.META.get("HTTP_ORIGIN"),
|
||||||
)
|
)
|
||||||
|
|
||||||
issues = self.get_queryset().values_list("issue_id", flat=True)
|
issues = self.get_queryset().values_list("issue_id", flat=True)
|
||||||
@ -519,6 +523,8 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
|
|||||||
project_id=str(project_id),
|
project_id=str(project_id),
|
||||||
current_instance=None,
|
current_instance=None,
|
||||||
epoch=int(timezone.now().timestamp()),
|
epoch=int(timezone.now().timestamp()),
|
||||||
|
notification=True,
|
||||||
|
origin=request.META.get("HTTP_ORIGIN"),
|
||||||
)
|
)
|
||||||
module_issue.delete()
|
module_issue.delete()
|
||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
# Django imports
|
# Django imports
|
||||||
from django.db.models import Q
|
from django.db.models import Q, OuterRef, Exists
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
# Third party imports
|
# Third party imports
|
||||||
@ -15,8 +15,9 @@ from plane.db.models import (
|
|||||||
IssueSubscriber,
|
IssueSubscriber,
|
||||||
Issue,
|
Issue,
|
||||||
WorkspaceMember,
|
WorkspaceMember,
|
||||||
|
UserNotificationPreference,
|
||||||
)
|
)
|
||||||
from plane.app.serializers import NotificationSerializer
|
from plane.app.serializers import NotificationSerializer, UserNotificationPreferenceSerializer
|
||||||
|
|
||||||
|
|
||||||
class NotificationViewSet(BaseViewSet, BasePaginator):
|
class NotificationViewSet(BaseViewSet, BasePaginator):
|
||||||
@ -71,11 +72,29 @@ class NotificationViewSet(BaseViewSet, BasePaginator):
|
|||||||
|
|
||||||
# Subscribed issues
|
# Subscribed issues
|
||||||
if type == "watching":
|
if type == "watching":
|
||||||
issue_ids = IssueSubscriber.objects.filter(
|
issue_ids = (
|
||||||
|
IssueSubscriber.objects.filter(
|
||||||
workspace__slug=slug, subscriber_id=request.user.id
|
workspace__slug=slug, subscriber_id=request.user.id
|
||||||
).values_list("issue_id", flat=True)
|
)
|
||||||
|
.annotate(
|
||||||
|
created=Exists(
|
||||||
|
Issue.objects.filter(
|
||||||
|
created_by=request.user, pk=OuterRef("issue_id")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
assigned=Exists(
|
||||||
|
IssueAssignee.objects.filter(
|
||||||
|
pk=OuterRef("issue_id"), assignee=request.user
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.filter(created=False, assigned=False)
|
||||||
|
.values_list("issue_id", flat=True)
|
||||||
|
)
|
||||||
notifications = notifications.filter(
|
notifications = notifications.filter(
|
||||||
entity_identifier__in=issue_ids
|
entity_identifier__in=issue_ids,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Assigned Issues
|
# Assigned Issues
|
||||||
@ -295,3 +314,31 @@ class MarkAllReadNotificationViewSet(BaseViewSet):
|
|||||||
updated_notifications, ["read_at"], batch_size=100
|
updated_notifications, ["read_at"], batch_size=100
|
||||||
)
|
)
|
||||||
return Response({"message": "Successful"}, status=status.HTTP_200_OK)
|
return Response({"message": "Successful"}, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
||||||
|
class UserNotificationPreferenceEndpoint(BaseAPIView):
|
||||||
|
model = UserNotificationPreference
|
||||||
|
serializer_class = UserNotificationPreferenceSerializer
|
||||||
|
|
||||||
|
# request the object
|
||||||
|
def get(self, request):
|
||||||
|
user_notification_preference = UserNotificationPreference.objects.get(
|
||||||
|
user=request.user
|
||||||
|
)
|
||||||
|
serializer = UserNotificationPreferenceSerializer(
|
||||||
|
user_notification_preference
|
||||||
|
)
|
||||||
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
# update the object
|
||||||
|
def patch(self, request):
|
||||||
|
user_notification_preference = UserNotificationPreference.objects.get(
|
||||||
|
user=request.user
|
||||||
|
)
|
||||||
|
serializer = UserNotificationPreferenceSerializer(
|
||||||
|
user_notification_preference, data=request.data, partial=True
|
||||||
|
)
|
||||||
|
if serializer.is_valid():
|
||||||
|
serializer.save()
|
||||||
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||||
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
243
apiserver/plane/bgtasks/email_notification_task.py
Normal file
243
apiserver/plane/bgtasks/email_notification_task.py
Normal file
@ -0,0 +1,243 @@
|
|||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
# Third party imports
|
||||||
|
from celery import shared_task
|
||||||
|
|
||||||
|
# Django imports
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.core.mail import EmailMultiAlternatives, get_connection
|
||||||
|
from django.template.loader import render_to_string
|
||||||
|
from django.utils.html import strip_tags
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
# Module imports
|
||||||
|
from plane.db.models import EmailNotificationLog, User, Issue
|
||||||
|
from plane.license.utils.instance_value import get_email_configuration
|
||||||
|
from plane.settings.redis import redis_instance
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def stack_email_notification():
|
||||||
|
# get all email notifications
|
||||||
|
email_notifications = (
|
||||||
|
EmailNotificationLog.objects.filter(processed_at__isnull=True)
|
||||||
|
.order_by("receiver")
|
||||||
|
.values()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create the below format for each of the issues
|
||||||
|
# {"issue_id" : { "actor_id1": [ { data }, { data } ], "actor_id2": [ { data }, { data } ] }}
|
||||||
|
|
||||||
|
# Convert to unique receivers list
|
||||||
|
receivers = list(
|
||||||
|
set(
|
||||||
|
[
|
||||||
|
str(notification.get("receiver_id"))
|
||||||
|
for notification in email_notifications
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
processed_notifications = []
|
||||||
|
# Loop through all the issues to create the emails
|
||||||
|
for receiver_id in receivers:
|
||||||
|
# Notifcation triggered for the receiver
|
||||||
|
receiver_notifications = [
|
||||||
|
notification
|
||||||
|
for notification in email_notifications
|
||||||
|
if str(notification.get("receiver_id")) == receiver_id
|
||||||
|
]
|
||||||
|
# create payload for all issues
|
||||||
|
payload = {}
|
||||||
|
email_notification_ids = []
|
||||||
|
for receiver_notification in receiver_notifications:
|
||||||
|
payload.setdefault(
|
||||||
|
receiver_notification.get("entity_identifier"), {}
|
||||||
|
).setdefault(
|
||||||
|
str(receiver_notification.get("triggered_by_id")), []
|
||||||
|
).append(
|
||||||
|
receiver_notification.get("data")
|
||||||
|
)
|
||||||
|
# append processed notifications
|
||||||
|
processed_notifications.append(receiver_notification.get("id"))
|
||||||
|
email_notification_ids.append(receiver_notification.get("id"))
|
||||||
|
|
||||||
|
# Create emails for all the issues
|
||||||
|
for issue_id, notification_data in payload.items():
|
||||||
|
send_email_notification.delay(
|
||||||
|
issue_id=issue_id,
|
||||||
|
notification_data=notification_data,
|
||||||
|
receiver_id=receiver_id,
|
||||||
|
email_notification_ids=email_notification_ids,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update the email notification log
|
||||||
|
EmailNotificationLog.objects.filter(pk__in=processed_notifications).update(
|
||||||
|
processed_at=timezone.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_payload(notification_data):
|
||||||
|
# return format {"actor_id": { "key": { "old_value": [], "new_value": [] } }}
|
||||||
|
data = {}
|
||||||
|
for actor_id, changes in notification_data.items():
|
||||||
|
for change in changes:
|
||||||
|
issue_activity = change.get("issue_activity")
|
||||||
|
if issue_activity: # Ensure issue_activity is not None
|
||||||
|
field = issue_activity.get("field")
|
||||||
|
old_value = str(issue_activity.get("old_value"))
|
||||||
|
new_value = str(issue_activity.get("new_value"))
|
||||||
|
|
||||||
|
# Append old_value if it's not empty and not already in the list
|
||||||
|
if old_value:
|
||||||
|
data.setdefault(actor_id, {}).setdefault(
|
||||||
|
field, {}
|
||||||
|
).setdefault("old_value", []).append(
|
||||||
|
old_value
|
||||||
|
) if old_value not in data.setdefault(
|
||||||
|
actor_id, {}
|
||||||
|
).setdefault(
|
||||||
|
field, {}
|
||||||
|
).get(
|
||||||
|
"old_value", []
|
||||||
|
) else None
|
||||||
|
|
||||||
|
# Append new_value if it's not empty and not already in the list
|
||||||
|
if new_value:
|
||||||
|
data.setdefault(actor_id, {}).setdefault(
|
||||||
|
field, {}
|
||||||
|
).setdefault("new_value", []).append(
|
||||||
|
new_value
|
||||||
|
) if new_value not in data.setdefault(
|
||||||
|
actor_id, {}
|
||||||
|
).setdefault(
|
||||||
|
field, {}
|
||||||
|
).get(
|
||||||
|
"new_value", []
|
||||||
|
) else None
|
||||||
|
|
||||||
|
if not data.get("actor_id", {}).get("activity_time", False):
|
||||||
|
data[actor_id]["activity_time"] = str(
|
||||||
|
datetime.fromisoformat(
|
||||||
|
issue_activity.get("activity_time").rstrip("Z")
|
||||||
|
).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
)
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def send_email_notification(
|
||||||
|
issue_id, notification_data, receiver_id, email_notification_ids
|
||||||
|
):
|
||||||
|
ri = redis_instance()
|
||||||
|
base_api = (ri.get(str(issue_id)).decode())
|
||||||
|
data = create_payload(notification_data=notification_data)
|
||||||
|
|
||||||
|
# Get email configurations
|
||||||
|
(
|
||||||
|
EMAIL_HOST,
|
||||||
|
EMAIL_HOST_USER,
|
||||||
|
EMAIL_HOST_PASSWORD,
|
||||||
|
EMAIL_PORT,
|
||||||
|
EMAIL_USE_TLS,
|
||||||
|
EMAIL_FROM,
|
||||||
|
) = get_email_configuration()
|
||||||
|
|
||||||
|
receiver = User.objects.get(pk=receiver_id)
|
||||||
|
issue = Issue.objects.get(pk=issue_id)
|
||||||
|
template_data = []
|
||||||
|
total_changes = 0
|
||||||
|
comments = []
|
||||||
|
for actor_id, changes in data.items():
|
||||||
|
actor = User.objects.get(pk=actor_id)
|
||||||
|
total_changes = total_changes + len(changes)
|
||||||
|
comment = changes.pop("comment", False)
|
||||||
|
if comment:
|
||||||
|
comments.append(
|
||||||
|
{
|
||||||
|
"actor_comments": comment,
|
||||||
|
"actor_detail": {
|
||||||
|
"avatar_url": actor.avatar,
|
||||||
|
"first_name": actor.first_name,
|
||||||
|
"last_name": actor.last_name,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
activity_time = changes.pop("activity_time")
|
||||||
|
template_data.append(
|
||||||
|
{
|
||||||
|
"actor_detail": {
|
||||||
|
"avatar_url": actor.avatar,
|
||||||
|
"first_name": actor.first_name,
|
||||||
|
"last_name": actor.last_name,
|
||||||
|
},
|
||||||
|
"changes": changes,
|
||||||
|
"issue_details": {
|
||||||
|
"name": issue.name,
|
||||||
|
"identifier": f"{issue.project.identifier}-{issue.sequence_id}",
|
||||||
|
},
|
||||||
|
"activity_time": str(activity_time),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
span = f"""<span style='
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 28px;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{template_data[0]['actor_detail']['first_name']} {template_data[0]['actor_detail']['last_name']}
|
||||||
|
</span>"""
|
||||||
|
|
||||||
|
summary = "updates were made to the issue by"
|
||||||
|
|
||||||
|
# Send the mail
|
||||||
|
subject = f"{issue.project.identifier}-{issue.sequence_id} {issue.name}"
|
||||||
|
context = {
|
||||||
|
"data": template_data,
|
||||||
|
"summary": summary,
|
||||||
|
"issue": {
|
||||||
|
"issue_identifier": f"{str(issue.project.identifier)}-{str(issue.sequence_id)}",
|
||||||
|
"name": issue.name,
|
||||||
|
"issue_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/{str(issue.id)}",
|
||||||
|
},
|
||||||
|
"receiver": {
|
||||||
|
"email": receiver.email,
|
||||||
|
},
|
||||||
|
"issue_unsubscribe": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/{str(issue.id)}",
|
||||||
|
"user_preference": f"{base_api}/profile/preferences/email",
|
||||||
|
"comments": comments,
|
||||||
|
}
|
||||||
|
print(json.dumps(context))
|
||||||
|
html_content = render_to_string(
|
||||||
|
"emails/notifications/issue-updates.html", context
|
||||||
|
)
|
||||||
|
text_content = strip_tags(html_content)
|
||||||
|
|
||||||
|
try:
|
||||||
|
connection = get_connection(
|
||||||
|
host=EMAIL_HOST,
|
||||||
|
port=int(EMAIL_PORT),
|
||||||
|
username=EMAIL_HOST_USER,
|
||||||
|
password=EMAIL_HOST_PASSWORD,
|
||||||
|
use_tls=EMAIL_USE_TLS == "1",
|
||||||
|
)
|
||||||
|
|
||||||
|
msg = EmailMultiAlternatives(
|
||||||
|
subject=subject,
|
||||||
|
body=text_content,
|
||||||
|
from_email=EMAIL_FROM,
|
||||||
|
to=[receiver.email],
|
||||||
|
connection=connection,
|
||||||
|
)
|
||||||
|
msg.attach_alternative(html_content, "text/html")
|
||||||
|
msg.send()
|
||||||
|
|
||||||
|
EmailNotificationLog.objects.filter(
|
||||||
|
pk__in=email_notification_ids
|
||||||
|
).update(sent_at=timezone.now())
|
||||||
|
print("Email Sent")
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
print(e)
|
||||||
|
return
|
@ -24,6 +24,7 @@ from plane.db.models import (
|
|||||||
Label,
|
Label,
|
||||||
User,
|
User,
|
||||||
IssueProperty,
|
IssueProperty,
|
||||||
|
UserNotificationPreference,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -50,10 +51,24 @@ def service_importer(service, importer_id):
|
|||||||
for user in users
|
for user in users
|
||||||
if user.get("import", False) == "invite"
|
if user.get("import", False) == "invite"
|
||||||
],
|
],
|
||||||
batch_size=10,
|
batch_size=100,
|
||||||
ignore_conflicts=True,
|
ignore_conflicts=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_ = UserNotificationPreference.objects.bulk_create(
|
||||||
|
[UserNotificationPreference(user=user) for user in new_users],
|
||||||
|
batch_size=100,
|
||||||
|
)
|
||||||
|
|
||||||
|
_ = [
|
||||||
|
send_welcome_slack.delay(
|
||||||
|
str(user.id),
|
||||||
|
True,
|
||||||
|
f"{user.email} was imported to Plane from {service}",
|
||||||
|
)
|
||||||
|
for user in new_users
|
||||||
|
]
|
||||||
|
|
||||||
workspace_users = User.objects.filter(
|
workspace_users = User.objects.filter(
|
||||||
email__in=[
|
email__in=[
|
||||||
user.get("email").strip().lower()
|
user.get("email").strip().lower()
|
||||||
|
@ -24,10 +24,11 @@ from plane.db.models import (
|
|||||||
IssueReaction,
|
IssueReaction,
|
||||||
CommentReaction,
|
CommentReaction,
|
||||||
IssueComment,
|
IssueComment,
|
||||||
|
IssueSubscriber,
|
||||||
)
|
)
|
||||||
from plane.app.serializers import IssueActivitySerializer
|
from plane.app.serializers import IssueActivitySerializer
|
||||||
from plane.bgtasks.notification_task import notifications
|
from plane.bgtasks.notification_task import notifications
|
||||||
|
from plane.settings.redis import redis_instance
|
||||||
|
|
||||||
# Track Changes in name
|
# Track Changes in name
|
||||||
def track_name(
|
def track_name(
|
||||||
@ -190,7 +191,9 @@ def track_state(
|
|||||||
):
|
):
|
||||||
if current_instance.get("state_id") != requested_data.get("state_id"):
|
if current_instance.get("state_id") != requested_data.get("state_id"):
|
||||||
new_state = State.objects.get(pk=requested_data.get("state_id", None))
|
new_state = State.objects.get(pk=requested_data.get("state_id", None))
|
||||||
old_state = State.objects.get(pk=current_instance.get("state_id", None))
|
old_state = State.objects.get(
|
||||||
|
pk=current_instance.get("state_id", None)
|
||||||
|
)
|
||||||
|
|
||||||
issue_activities.append(
|
issue_activities.append(
|
||||||
IssueActivity(
|
IssueActivity(
|
||||||
@ -359,6 +362,7 @@ def track_assignees(
|
|||||||
added_assignees = requested_assignees - current_assignees
|
added_assignees = requested_assignees - current_assignees
|
||||||
dropped_assginees = current_assignees - requested_assignees
|
dropped_assginees = current_assignees - requested_assignees
|
||||||
|
|
||||||
|
bulk_subscribers = []
|
||||||
for added_asignee in added_assignees:
|
for added_asignee in added_assignees:
|
||||||
assignee = User.objects.get(pk=added_asignee)
|
assignee = User.objects.get(pk=added_asignee)
|
||||||
issue_activities.append(
|
issue_activities.append(
|
||||||
@ -376,6 +380,21 @@ def track_assignees(
|
|||||||
epoch=epoch,
|
epoch=epoch,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
bulk_subscribers.append(
|
||||||
|
IssueSubscriber(
|
||||||
|
subscriber_id=assignee.id,
|
||||||
|
issue_id=issue_id,
|
||||||
|
workspace_id=workspace_id,
|
||||||
|
project_id=project_id,
|
||||||
|
created_by_id=assignee.id,
|
||||||
|
updated_by_id=assignee.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create assignees subscribers to the issue and ignore if already
|
||||||
|
IssueSubscriber.objects.bulk_create(
|
||||||
|
bulk_subscribers, batch_size=10, ignore_conflicts=True
|
||||||
|
)
|
||||||
|
|
||||||
for dropped_assignee in dropped_assginees:
|
for dropped_assignee in dropped_assginees:
|
||||||
assignee = User.objects.get(pk=dropped_assignee)
|
assignee = User.objects.get(pk=dropped_assignee)
|
||||||
@ -1543,6 +1562,8 @@ def issue_activity(
|
|||||||
project_id,
|
project_id,
|
||||||
epoch,
|
epoch,
|
||||||
subscriber=True,
|
subscriber=True,
|
||||||
|
notification=False,
|
||||||
|
origin=None,
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
issue_activities = []
|
issue_activities = []
|
||||||
@ -1551,6 +1572,10 @@ def issue_activity(
|
|||||||
workspace_id = project.workspace_id
|
workspace_id = project.workspace_id
|
||||||
|
|
||||||
if issue_id is not None:
|
if issue_id is not None:
|
||||||
|
if origin:
|
||||||
|
ri = redis_instance()
|
||||||
|
# set the request origin in redis
|
||||||
|
ri.set(str(issue_id), origin, ex=600)
|
||||||
issue = Issue.objects.filter(pk=issue_id).first()
|
issue = Issue.objects.filter(pk=issue_id).first()
|
||||||
if issue:
|
if issue:
|
||||||
try:
|
try:
|
||||||
@ -1624,6 +1649,8 @@ def issue_activity(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
capture_exception(e)
|
capture_exception(e)
|
||||||
|
|
||||||
|
|
||||||
|
if notification:
|
||||||
notifications.delay(
|
notifications.delay(
|
||||||
type=type,
|
type=type,
|
||||||
issue_id=issue_id,
|
issue_id=issue_id,
|
||||||
|
@ -87,6 +87,7 @@ def archive_old_issues():
|
|||||||
current_instance=json.dumps({"archived_at": None}),
|
current_instance=json.dumps({"archived_at": None}),
|
||||||
subscriber=False,
|
subscriber=False,
|
||||||
epoch=int(timezone.now().timestamp()),
|
epoch=int(timezone.now().timestamp()),
|
||||||
|
notification=True,
|
||||||
)
|
)
|
||||||
for issue in issues_to_update
|
for issue in issues_to_update
|
||||||
]
|
]
|
||||||
@ -169,6 +170,7 @@ def close_old_issues():
|
|||||||
current_instance=None,
|
current_instance=None,
|
||||||
subscriber=False,
|
subscriber=False,
|
||||||
epoch=int(timezone.now().timestamp()),
|
epoch=int(timezone.now().timestamp()),
|
||||||
|
notification=True,
|
||||||
)
|
)
|
||||||
for issue in issues_to_update
|
for issue in issues_to_update
|
||||||
]
|
]
|
||||||
|
@ -10,9 +10,12 @@ from plane.db.models import (
|
|||||||
User,
|
User,
|
||||||
IssueAssignee,
|
IssueAssignee,
|
||||||
Issue,
|
Issue,
|
||||||
|
State,
|
||||||
|
EmailNotificationLog,
|
||||||
Notification,
|
Notification,
|
||||||
IssueComment,
|
IssueComment,
|
||||||
IssueActivity,
|
IssueActivity,
|
||||||
|
UserNotificationPreference,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Third Party imports
|
# Third Party imports
|
||||||
@ -20,7 +23,7 @@ from celery import shared_task
|
|||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
|
||||||
# =========== Issue Description Html Parsing and Notification Functions ======================
|
# =========== Issue Description Html Parsing and notification Functions ======================
|
||||||
|
|
||||||
|
|
||||||
def update_mentions_for_issue(issue, project, new_mentions, removed_mention):
|
def update_mentions_for_issue(issue, project, new_mentions, removed_mention):
|
||||||
@ -37,9 +40,7 @@ def update_mentions_for_issue(issue, project, new_mentions, removed_mention):
|
|||||||
)
|
)
|
||||||
|
|
||||||
IssueMention.objects.bulk_create(aggregated_issue_mentions, batch_size=100)
|
IssueMention.objects.bulk_create(aggregated_issue_mentions, batch_size=100)
|
||||||
IssueMention.objects.filter(
|
IssueMention.objects.filter(issue=issue, mention__in=removed_mention).delete()
|
||||||
issue=issue, mention__in=removed_mention
|
|
||||||
).delete()
|
|
||||||
|
|
||||||
|
|
||||||
def get_new_mentions(requested_instance, current_instance):
|
def get_new_mentions(requested_instance, current_instance):
|
||||||
@ -60,8 +61,6 @@ def get_new_mentions(requested_instance, current_instance):
|
|||||||
|
|
||||||
|
|
||||||
# Get Removed Mention
|
# Get Removed Mention
|
||||||
|
|
||||||
|
|
||||||
def get_removed_mentions(requested_instance, current_instance):
|
def get_removed_mentions(requested_instance, current_instance):
|
||||||
# requested_data is the newer instance of the current issue
|
# requested_data is the newer instance of the current issue
|
||||||
# current_instance is the older instance of the current issue, saved in the database
|
# current_instance is the older instance of the current issue, saved in the database
|
||||||
@ -79,8 +78,6 @@ def get_removed_mentions(requested_instance, current_instance):
|
|||||||
|
|
||||||
|
|
||||||
# Adds mentions as subscribers
|
# Adds mentions as subscribers
|
||||||
|
|
||||||
|
|
||||||
def extract_mentions_as_subscribers(project_id, issue_id, mentions):
|
def extract_mentions_as_subscribers(project_id, issue_id, mentions):
|
||||||
# mentions is an array of User IDs representing the FILTERED set of mentioned users
|
# mentions is an array of User IDs representing the FILTERED set of mentioned users
|
||||||
|
|
||||||
@ -95,9 +92,7 @@ def extract_mentions_as_subscribers(project_id, issue_id, mentions):
|
|||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
).exists()
|
).exists()
|
||||||
and not IssueAssignee.objects.filter(
|
and not IssueAssignee.objects.filter(
|
||||||
project_id=project_id,
|
project_id=project_id, issue_id=issue_id, assignee_id=mention_id
|
||||||
issue_id=issue_id,
|
|
||||||
assignee_id=mention_id,
|
|
||||||
).exists()
|
).exists()
|
||||||
and not Issue.objects.filter(
|
and not Issue.objects.filter(
|
||||||
project_id=project_id, pk=issue_id, created_by_id=mention_id
|
project_id=project_id, pk=issue_id, created_by_id=mention_id
|
||||||
@ -125,9 +120,7 @@ def extract_mentions(issue_instance):
|
|||||||
data = json.loads(issue_instance)
|
data = json.loads(issue_instance)
|
||||||
html = data.get("description_html")
|
html = data.get("description_html")
|
||||||
soup = BeautifulSoup(html, "html.parser")
|
soup = BeautifulSoup(html, "html.parser")
|
||||||
mention_tags = soup.find_all(
|
mention_tags = soup.find_all("mention-component", attrs={"target": "users"})
|
||||||
"mention-component", attrs={"target": "users"}
|
|
||||||
)
|
|
||||||
|
|
||||||
mentions = [mention_tag["id"] for mention_tag in mention_tags]
|
mentions = [mention_tag["id"] for mention_tag in mention_tags]
|
||||||
|
|
||||||
@ -136,14 +129,12 @@ def extract_mentions(issue_instance):
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
# =========== Comment Parsing and Notification Functions ======================
|
# =========== Comment Parsing and notification Functions ======================
|
||||||
def extract_comment_mentions(comment_value):
|
def extract_comment_mentions(comment_value):
|
||||||
try:
|
try:
|
||||||
mentions = []
|
mentions = []
|
||||||
soup = BeautifulSoup(comment_value, "html.parser")
|
soup = BeautifulSoup(comment_value, "html.parser")
|
||||||
mentions_tags = soup.find_all(
|
mentions_tags = soup.find_all("mention-component", attrs={"target": "users"})
|
||||||
"mention-component", attrs={"target": "users"}
|
|
||||||
)
|
|
||||||
for mention_tag in mentions_tags:
|
for mention_tag in mentions_tags:
|
||||||
mentions.append(mention_tag["id"])
|
mentions.append(mention_tag["id"])
|
||||||
return list(set(mentions))
|
return list(set(mentions))
|
||||||
@ -165,14 +156,8 @@ def get_new_comment_mentions(new_value, old_value):
|
|||||||
return new_mentions
|
return new_mentions
|
||||||
|
|
||||||
|
|
||||||
def createMentionNotification(
|
def create_mention_notification(
|
||||||
project,
|
project, notification_comment, issue, actor_id, mention_id, issue_id, activity
|
||||||
notification_comment,
|
|
||||||
issue,
|
|
||||||
actor_id,
|
|
||||||
mention_id,
|
|
||||||
issue_id,
|
|
||||||
activity,
|
|
||||||
):
|
):
|
||||||
return Notification(
|
return Notification(
|
||||||
workspace=project.workspace,
|
workspace=project.workspace,
|
||||||
@ -215,6 +200,7 @@ def notifications(
|
|||||||
requested_data,
|
requested_data,
|
||||||
current_instance,
|
current_instance,
|
||||||
):
|
):
|
||||||
|
try:
|
||||||
issue_activities_created = (
|
issue_activities_created = (
|
||||||
json.loads(issue_activities_created)
|
json.loads(issue_activities_created)
|
||||||
if issue_activities_created is not None
|
if issue_activities_created is not None
|
||||||
@ -238,6 +224,7 @@ def notifications(
|
|||||||
]:
|
]:
|
||||||
# Create Notifications
|
# Create Notifications
|
||||||
bulk_notifications = []
|
bulk_notifications = []
|
||||||
|
bulk_email_logs = []
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Mention Tasks
|
Mention Tasks
|
||||||
@ -259,7 +246,9 @@ def notifications(
|
|||||||
all_comment_mentions = []
|
all_comment_mentions = []
|
||||||
|
|
||||||
# Get New Subscribers from the mentions of the newer instance
|
# Get New Subscribers from the mentions of the newer instance
|
||||||
requested_mentions = extract_mentions(issue_instance=requested_data)
|
requested_mentions = extract_mentions(
|
||||||
|
issue_instance=requested_data
|
||||||
|
)
|
||||||
mention_subscribers = extract_mentions_as_subscribers(
|
mention_subscribers = extract_mentions_as_subscribers(
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
issue_id=issue_id,
|
issue_id=issue_id,
|
||||||
@ -296,14 +285,7 @@ def notifications(
|
|||||||
- When the activity is a comment_updated and there exist a mention change, then also we have to send the "mention_in_comment" notification
|
- When the activity is a comment_updated and there exist a mention change, then also we have to send the "mention_in_comment" notification
|
||||||
"""
|
"""
|
||||||
|
|
||||||
issue_assignees = list(
|
# ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- #
|
||||||
IssueAssignee.objects.filter(
|
|
||||||
project_id=project_id, issue_id=issue_id
|
|
||||||
)
|
|
||||||
.exclude(assignee_id__in=list(new_mentions + comment_mentions))
|
|
||||||
.values_list("assignee", flat=True)
|
|
||||||
)
|
|
||||||
|
|
||||||
issue_subscribers = list(
|
issue_subscribers = list(
|
||||||
IssueSubscriber.objects.filter(
|
IssueSubscriber.objects.filter(
|
||||||
project_id=project_id, issue_id=issue_id
|
project_id=project_id, issue_id=issue_id
|
||||||
@ -318,56 +300,85 @@ def notifications(
|
|||||||
|
|
||||||
issue = Issue.objects.filter(pk=issue_id).first()
|
issue = Issue.objects.filter(pk=issue_id).first()
|
||||||
|
|
||||||
if issue.created_by_id is not None and str(issue.created_by_id) != str(
|
|
||||||
actor_id
|
|
||||||
):
|
|
||||||
issue_subscribers = issue_subscribers + [issue.created_by_id]
|
|
||||||
|
|
||||||
if subscriber:
|
if subscriber:
|
||||||
# add the user to issue subscriber
|
# add the user to issue subscriber
|
||||||
try:
|
try:
|
||||||
if (
|
|
||||||
str(issue.created_by_id) != str(actor_id)
|
|
||||||
and uuid.UUID(actor_id) not in issue_assignees
|
|
||||||
):
|
|
||||||
_ = IssueSubscriber.objects.get_or_create(
|
_ = IssueSubscriber.objects.get_or_create(
|
||||||
project_id=project_id,
|
project_id=project_id, issue_id=issue_id, subscriber_id=actor_id
|
||||||
issue_id=issue_id,
|
|
||||||
subscriber_id=actor_id,
|
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
project = Project.objects.get(pk=project_id)
|
project = Project.objects.get(pk=project_id)
|
||||||
|
|
||||||
|
issue_assignees = IssueAssignee.objects.filter(
|
||||||
|
issue_id=issue_id, project_id=project_id
|
||||||
|
).values_list("assignee", flat=True)
|
||||||
|
|
||||||
issue_subscribers = list(
|
issue_subscribers = list(
|
||||||
set(issue_subscribers + issue_assignees) - {uuid.UUID(actor_id)}
|
set(issue_subscribers) - {uuid.UUID(actor_id)}
|
||||||
)
|
)
|
||||||
|
|
||||||
for subscriber in issue_subscribers:
|
for subscriber in issue_subscribers:
|
||||||
if subscriber in issue_subscribers:
|
if issue.created_by_id and issue.created_by_id == subscriber:
|
||||||
sender = "in_app:issue_activities:subscribed"
|
|
||||||
if (
|
|
||||||
issue.created_by_id is not None
|
|
||||||
and subscriber == issue.created_by_id
|
|
||||||
):
|
|
||||||
sender = "in_app:issue_activities:created"
|
sender = "in_app:issue_activities:created"
|
||||||
if subscriber in issue_assignees:
|
elif (
|
||||||
|
subscriber in issue_assignees
|
||||||
|
and issue.created_by_id not in issue_assignees
|
||||||
|
):
|
||||||
sender = "in_app:issue_activities:assigned"
|
sender = "in_app:issue_activities:assigned"
|
||||||
|
else:
|
||||||
|
sender = "in_app:issue_activities:subscribed"
|
||||||
|
|
||||||
|
preference = UserNotificationPreference.objects.get(
|
||||||
|
user_id=subscriber
|
||||||
|
)
|
||||||
|
|
||||||
for issue_activity in issue_activities_created:
|
for issue_activity in issue_activities_created:
|
||||||
# Do not send notification for description update
|
# Do not send notification for description update
|
||||||
if issue_activity.get("field") == "description":
|
if issue_activity.get("field") == "description":
|
||||||
continue
|
continue
|
||||||
issue_comment = issue_activity.get("issue_comment")
|
|
||||||
if issue_comment is not None:
|
# Check if the value should be sent or not
|
||||||
issue_comment = IssueComment.objects.get(
|
send_email = False
|
||||||
id=issue_comment,
|
if (
|
||||||
|
issue_activity.get("field") == "state"
|
||||||
|
and preference.state_change
|
||||||
|
):
|
||||||
|
send_email = True
|
||||||
|
elif (
|
||||||
|
issue_activity.get("field") == "state"
|
||||||
|
and preference.issue_completed
|
||||||
|
and State.objects.filter(
|
||||||
|
project_id=project_id,
|
||||||
|
pk=issue_activity.get("new_identifier"),
|
||||||
|
group="completed",
|
||||||
|
).exists()
|
||||||
|
):
|
||||||
|
send_email = True
|
||||||
|
elif (
|
||||||
|
issue_activity.get("field") == "comment"
|
||||||
|
and preference.comment
|
||||||
|
):
|
||||||
|
send_email = True
|
||||||
|
elif preference.property_change:
|
||||||
|
send_email = True
|
||||||
|
else:
|
||||||
|
send_email = False
|
||||||
|
|
||||||
|
# If activity is of issue comment fetch the comment
|
||||||
|
issue_comment = (
|
||||||
|
IssueComment.objects.filter(
|
||||||
|
id=issue_activity.get("issue_comment"),
|
||||||
issue_id=issue_id,
|
issue_id=issue_id,
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
workspace_id=project.workspace_id,
|
workspace_id=project.workspace_id,
|
||||||
|
).first()
|
||||||
|
if issue_activity.get("issue_comment")
|
||||||
|
else None
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Create in app notification
|
||||||
bulk_notifications.append(
|
bulk_notifications.append(
|
||||||
Notification(
|
Notification(
|
||||||
workspace=project.workspace,
|
workspace=project.workspace,
|
||||||
@ -382,7 +393,9 @@ def notifications(
|
|||||||
"issue": {
|
"issue": {
|
||||||
"id": str(issue_id),
|
"id": str(issue_id),
|
||||||
"name": str(issue.name),
|
"name": str(issue.name),
|
||||||
"identifier": str(issue.project.identifier),
|
"identifier": str(
|
||||||
|
issue.project.identifier
|
||||||
|
),
|
||||||
"sequence_id": issue.sequence_id,
|
"sequence_id": issue.sequence_id,
|
||||||
"state_name": issue.state.name,
|
"state_name": issue.state.name,
|
||||||
"state_group": issue.state.group,
|
"state_group": issue.state.group,
|
||||||
@ -391,7 +404,9 @@ def notifications(
|
|||||||
"id": str(issue_activity.get("id")),
|
"id": str(issue_activity.get("id")),
|
||||||
"verb": str(issue_activity.get("verb")),
|
"verb": str(issue_activity.get("verb")),
|
||||||
"field": str(issue_activity.get("field")),
|
"field": str(issue_activity.get("field")),
|
||||||
"actor": str(issue_activity.get("actor_id")),
|
"actor": str(
|
||||||
|
issue_activity.get("actor_id")
|
||||||
|
),
|
||||||
"new_value": str(
|
"new_value": str(
|
||||||
issue_activity.get("new_value")
|
issue_activity.get("new_value")
|
||||||
),
|
),
|
||||||
@ -400,18 +415,71 @@ def notifications(
|
|||||||
),
|
),
|
||||||
"issue_comment": str(
|
"issue_comment": str(
|
||||||
issue_comment.comment_stripped
|
issue_comment.comment_stripped
|
||||||
if issue_activity.get("issue_comment")
|
if issue_comment is not None
|
||||||
is not None
|
|
||||||
else ""
|
else ""
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
# Create email notification
|
||||||
|
if send_email:
|
||||||
|
bulk_email_logs.append(
|
||||||
|
EmailNotificationLog(
|
||||||
|
triggered_by_id=actor_id,
|
||||||
|
receiver_id=subscriber,
|
||||||
|
entity_identifier=issue_id,
|
||||||
|
entity_name="issue",
|
||||||
|
data={
|
||||||
|
"issue": {
|
||||||
|
"id": str(issue_id),
|
||||||
|
"name": str(issue.name),
|
||||||
|
"identifier": str(
|
||||||
|
issue.project.identifier
|
||||||
|
),
|
||||||
|
"project_id": str(issue.project.id),
|
||||||
|
"workspace_slug": str(
|
||||||
|
issue.project.workspace.slug
|
||||||
|
),
|
||||||
|
"sequence_id": issue.sequence_id,
|
||||||
|
"state_name": issue.state.name,
|
||||||
|
"state_group": issue.state.group,
|
||||||
|
},
|
||||||
|
"issue_activity": {
|
||||||
|
"id": str(issue_activity.get("id")),
|
||||||
|
"verb": str(
|
||||||
|
issue_activity.get("verb")
|
||||||
|
),
|
||||||
|
"field": str(
|
||||||
|
issue_activity.get("field")
|
||||||
|
),
|
||||||
|
"actor": str(
|
||||||
|
issue_activity.get("actor_id")
|
||||||
|
),
|
||||||
|
"new_value": str(
|
||||||
|
issue_activity.get("new_value")
|
||||||
|
),
|
||||||
|
"old_value": str(
|
||||||
|
issue_activity.get("old_value")
|
||||||
|
),
|
||||||
|
"issue_comment": str(
|
||||||
|
issue_comment.comment_stripped
|
||||||
|
if issue_comment is not None
|
||||||
|
else ""
|
||||||
|
),
|
||||||
|
"activity_time": issue_activity.get("created_at"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------------------------------------------- #
|
||||||
|
|
||||||
# Add Mentioned as Issue Subscribers
|
# Add Mentioned as Issue Subscribers
|
||||||
IssueSubscriber.objects.bulk_create(
|
IssueSubscriber.objects.bulk_create(
|
||||||
mention_subscribers + comment_mention_subscribers, batch_size=100
|
mention_subscribers + comment_mention_subscribers,
|
||||||
|
batch_size=100,
|
||||||
|
ignore_conflicts=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
last_activity = (
|
last_activity = (
|
||||||
@ -424,8 +492,11 @@ def notifications(
|
|||||||
|
|
||||||
for mention_id in comment_mentions:
|
for mention_id in comment_mentions:
|
||||||
if mention_id != actor_id:
|
if mention_id != actor_id:
|
||||||
|
preference = UserNotificationPreference.objects.get(
|
||||||
|
user_id=mention_id
|
||||||
|
)
|
||||||
for issue_activity in issue_activities_created:
|
for issue_activity in issue_activities_created:
|
||||||
notification = createMentionNotification(
|
notification = create_mention_notification(
|
||||||
project=project,
|
project=project,
|
||||||
issue=issue,
|
issue=issue,
|
||||||
notification_comment=f"{actor.display_name} has mentioned you in a comment in issue {issue.name}",
|
notification_comment=f"{actor.display_name} has mentioned you in a comment in issue {issue.name}",
|
||||||
@ -434,10 +505,60 @@ def notifications(
|
|||||||
issue_id=issue_id,
|
issue_id=issue_id,
|
||||||
activity=issue_activity,
|
activity=issue_activity,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# check for email notifications
|
||||||
|
if preference.mention:
|
||||||
|
bulk_email_logs.append(
|
||||||
|
EmailNotificationLog(
|
||||||
|
triggered_by_id=actor_id,
|
||||||
|
receiver_id=subscriber,
|
||||||
|
entity_identifier=issue_id,
|
||||||
|
entity_name="issue",
|
||||||
|
data={
|
||||||
|
"issue": {
|
||||||
|
"id": str(issue_id),
|
||||||
|
"name": str(issue.name),
|
||||||
|
"identifier": str(
|
||||||
|
issue.project.identifier
|
||||||
|
),
|
||||||
|
"sequence_id": issue.sequence_id,
|
||||||
|
"state_name": issue.state.name,
|
||||||
|
"state_group": issue.state.group,
|
||||||
|
"project_id": str(
|
||||||
|
issue.project.id
|
||||||
|
),
|
||||||
|
"workspace_slug": str(
|
||||||
|
issue.project.workspace.slug
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"issue_activity": {
|
||||||
|
"id": str(
|
||||||
|
issue_activity.get("id")
|
||||||
|
),
|
||||||
|
"verb": str(
|
||||||
|
issue_activity.get("verb")
|
||||||
|
),
|
||||||
|
"field": str("mention"),
|
||||||
|
"actor": str(
|
||||||
|
issue_activity.get("actor_id")
|
||||||
|
),
|
||||||
|
"new_value": str(
|
||||||
|
issue_activity.get("new_value")
|
||||||
|
),
|
||||||
|
"old_value": str(
|
||||||
|
issue_activity.get("old_value")
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
bulk_notifications.append(notification)
|
bulk_notifications.append(notification)
|
||||||
|
|
||||||
for mention_id in new_mentions:
|
for mention_id in new_mentions:
|
||||||
if mention_id != actor_id:
|
if mention_id != actor_id:
|
||||||
|
preference = UserNotificationPreference.objects.get(
|
||||||
|
user_id=mention_id
|
||||||
|
)
|
||||||
if (
|
if (
|
||||||
last_activity is not None
|
last_activity is not None
|
||||||
and last_activity.field == "description"
|
and last_activity.field == "description"
|
||||||
@ -463,21 +584,64 @@ def notifications(
|
|||||||
"sequence_id": issue.sequence_id,
|
"sequence_id": issue.sequence_id,
|
||||||
"state_name": issue.state.name,
|
"state_name": issue.state.name,
|
||||||
"state_group": issue.state.group,
|
"state_group": issue.state.group,
|
||||||
|
"project_id": str(issue.project.id),
|
||||||
|
"workspace_slug": str(
|
||||||
|
issue.project.workspace.slug
|
||||||
|
),
|
||||||
},
|
},
|
||||||
"issue_activity": {
|
"issue_activity": {
|
||||||
"id": str(last_activity.id),
|
"id": str(last_activity.id),
|
||||||
"verb": str(last_activity.verb),
|
"verb": str(last_activity.verb),
|
||||||
"field": str(last_activity.field),
|
"field": str(last_activity.field),
|
||||||
"actor": str(last_activity.actor_id),
|
"actor": str(last_activity.actor_id),
|
||||||
"new_value": str(last_activity.new_value),
|
"new_value": str(
|
||||||
"old_value": str(last_activity.old_value),
|
last_activity.new_value
|
||||||
|
),
|
||||||
|
"old_value": str(
|
||||||
|
last_activity.old_value
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if preference.mention:
|
||||||
|
bulk_email_logs.append(
|
||||||
|
EmailNotificationLog(
|
||||||
|
triggered_by_id=actor_id,
|
||||||
|
receiver_id=subscriber,
|
||||||
|
entity_identifier=issue_id,
|
||||||
|
entity_name="issue",
|
||||||
|
data={
|
||||||
|
"issue": {
|
||||||
|
"id": str(issue_id),
|
||||||
|
"name": str(issue.name),
|
||||||
|
"identifier": str(
|
||||||
|
issue.project.identifier
|
||||||
|
),
|
||||||
|
"sequence_id": issue.sequence_id,
|
||||||
|
"state_name": issue.state.name,
|
||||||
|
"state_group": issue.state.group,
|
||||||
|
},
|
||||||
|
"issue_activity": {
|
||||||
|
"id": str(last_activity.id),
|
||||||
|
"verb": str(last_activity.verb),
|
||||||
|
"field": "mention",
|
||||||
|
"actor": str(
|
||||||
|
last_activity.actor_id
|
||||||
|
),
|
||||||
|
"new_value": str(
|
||||||
|
last_activity.new_value
|
||||||
|
),
|
||||||
|
"old_value": str(
|
||||||
|
last_activity.old_value
|
||||||
|
),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
for issue_activity in issue_activities_created:
|
for issue_activity in issue_activities_created:
|
||||||
notification = createMentionNotification(
|
notification = create_mention_notification(
|
||||||
project=project,
|
project=project,
|
||||||
issue=issue,
|
issue=issue,
|
||||||
notification_comment=f"You have been mentioned in the issue {issue.name}",
|
notification_comment=f"You have been mentioned in the issue {issue.name}",
|
||||||
@ -486,6 +650,51 @@ def notifications(
|
|||||||
issue_id=issue_id,
|
issue_id=issue_id,
|
||||||
activity=issue_activity,
|
activity=issue_activity,
|
||||||
)
|
)
|
||||||
|
if preference.mention:
|
||||||
|
bulk_email_logs.append(
|
||||||
|
EmailNotificationLog(
|
||||||
|
triggered_by_id=actor_id,
|
||||||
|
receiver_id=subscriber,
|
||||||
|
entity_identifier=issue_id,
|
||||||
|
entity_name="issue",
|
||||||
|
data={
|
||||||
|
"issue": {
|
||||||
|
"id": str(issue_id),
|
||||||
|
"name": str(issue.name),
|
||||||
|
"identifier": str(
|
||||||
|
issue.project.identifier
|
||||||
|
),
|
||||||
|
"sequence_id": issue.sequence_id,
|
||||||
|
"state_name": issue.state.name,
|
||||||
|
"state_group": issue.state.group,
|
||||||
|
},
|
||||||
|
"issue_activity": {
|
||||||
|
"id": str(
|
||||||
|
issue_activity.get("id")
|
||||||
|
),
|
||||||
|
"verb": str(
|
||||||
|
issue_activity.get("verb")
|
||||||
|
),
|
||||||
|
"field": str("mention"),
|
||||||
|
"actor": str(
|
||||||
|
issue_activity.get(
|
||||||
|
"actor_id"
|
||||||
|
)
|
||||||
|
),
|
||||||
|
"new_value": str(
|
||||||
|
issue_activity.get(
|
||||||
|
"new_value"
|
||||||
|
)
|
||||||
|
),
|
||||||
|
"old_value": str(
|
||||||
|
issue_activity.get(
|
||||||
|
"old_value"
|
||||||
|
)
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
bulk_notifications.append(notification)
|
bulk_notifications.append(notification)
|
||||||
|
|
||||||
# save new mentions for the particular issue and remove the mentions that has been deleted from the description
|
# save new mentions for the particular issue and remove the mentions that has been deleted from the description
|
||||||
@ -495,6 +704,14 @@ def notifications(
|
|||||||
new_mentions=new_mentions,
|
new_mentions=new_mentions,
|
||||||
removed_mention=removed_mention,
|
removed_mention=removed_mention,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Bulk create notifications
|
# Bulk create notifications
|
||||||
Notification.objects.bulk_create(bulk_notifications, batch_size=100)
|
Notification.objects.bulk_create(
|
||||||
|
bulk_notifications, batch_size=100
|
||||||
|
)
|
||||||
|
EmailNotificationLog.objects.bulk_create(
|
||||||
|
bulk_email_logs, batch_size=100, ignore_conflicts=True
|
||||||
|
)
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
print(e)
|
||||||
|
return
|
||||||
|
@ -2,6 +2,7 @@ import os
|
|||||||
from celery import Celery
|
from celery import Celery
|
||||||
from plane.settings.redis import redis_instance
|
from plane.settings.redis import redis_instance
|
||||||
from celery.schedules import crontab
|
from celery.schedules import crontab
|
||||||
|
from django.utils.timezone import timedelta
|
||||||
|
|
||||||
# Set the default Django settings module for the 'celery' program.
|
# Set the default Django settings module for the 'celery' program.
|
||||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "plane.settings.production")
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "plane.settings.production")
|
||||||
@ -28,6 +29,10 @@ app.conf.beat_schedule = {
|
|||||||
"task": "plane.bgtasks.file_asset_task.delete_file_asset",
|
"task": "plane.bgtasks.file_asset_task.delete_file_asset",
|
||||||
"schedule": crontab(hour=0, minute=0),
|
"schedule": crontab(hour=0, minute=0),
|
||||||
},
|
},
|
||||||
|
"check-every-five-minutes-to-send-email-notifications": {
|
||||||
|
"task": "plane.bgtasks.email_notification_task.stack_email_notification",
|
||||||
|
"schedule": crontab(minute='*/1')
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
# Load task modules from all registered Django app configs.
|
# Load task modules from all registered Django app configs.
|
||||||
|
@ -0,0 +1,184 @@
|
|||||||
|
# Generated by Django 4.2.7 on 2024-01-22 08:55
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("db", "0055_auto_20240108_0648"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="UserNotificationPreference",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"created_at",
|
||||||
|
models.DateTimeField(
|
||||||
|
auto_now_add=True, verbose_name="Created At"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"updated_at",
|
||||||
|
models.DateTimeField(
|
||||||
|
auto_now=True, verbose_name="Last Modified At"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.UUIDField(
|
||||||
|
db_index=True,
|
||||||
|
default=uuid.uuid4,
|
||||||
|
editable=False,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
unique=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("property_change", models.BooleanField(default=True)),
|
||||||
|
("state_change", models.BooleanField(default=True)),
|
||||||
|
("comment", models.BooleanField(default=True)),
|
||||||
|
("mention", models.BooleanField(default=True)),
|
||||||
|
("issue_completed", models.BooleanField(default=True)),
|
||||||
|
(
|
||||||
|
"created_by",
|
||||||
|
models.ForeignKey(
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="%(class)s_created_by",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
verbose_name="Created By",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"project",
|
||||||
|
models.ForeignKey(
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="project_notification_preferences",
|
||||||
|
to="db.project",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"updated_by",
|
||||||
|
models.ForeignKey(
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="%(class)s_updated_by",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
verbose_name="Last Modified By",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"user",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="notification_preferences",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"workspace",
|
||||||
|
models.ForeignKey(
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="workspace_notification_preferences",
|
||||||
|
to="db.workspace",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "UserNotificationPreference",
|
||||||
|
"verbose_name_plural": "UserNotificationPreferences",
|
||||||
|
"db_table": "user_notification_preferences",
|
||||||
|
"ordering": ("-created_at",),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="EmailNotificationLog",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"created_at",
|
||||||
|
models.DateTimeField(
|
||||||
|
auto_now_add=True, verbose_name="Created At"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"updated_at",
|
||||||
|
models.DateTimeField(
|
||||||
|
auto_now=True, verbose_name="Last Modified At"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.UUIDField(
|
||||||
|
db_index=True,
|
||||||
|
default=uuid.uuid4,
|
||||||
|
editable=False,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
unique=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("entity_identifier", models.UUIDField(null=True)),
|
||||||
|
("entity_name", models.CharField(max_length=255)),
|
||||||
|
("data", models.JSONField(null=True)),
|
||||||
|
("processed_at", models.DateTimeField(null=True)),
|
||||||
|
("sent_at", models.DateTimeField(null=True)),
|
||||||
|
("entity", models.CharField(max_length=200)),
|
||||||
|
(
|
||||||
|
"old_value",
|
||||||
|
models.CharField(blank=True, max_length=300, null=True),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"new_value",
|
||||||
|
models.CharField(blank=True, max_length=300, null=True),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"created_by",
|
||||||
|
models.ForeignKey(
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="%(class)s_created_by",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
verbose_name="Created By",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"receiver",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="email_notifications",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"triggered_by",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="triggered_emails",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"updated_by",
|
||||||
|
models.ForeignKey(
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="%(class)s_updated_by",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
verbose_name="Last Modified By",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "Email Notification Log",
|
||||||
|
"verbose_name_plural": "Email Notification Logs",
|
||||||
|
"db_table": "email_notification_logs",
|
||||||
|
"ordering": ("-created_at",),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
28
apiserver/plane/db/migrations/0057_auto_20240122_0901.py
Normal file
28
apiserver/plane/db/migrations/0057_auto_20240122_0901.py
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
# Generated by Django 4.2.7 on 2024-01-22 09:01
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
def create_notification_preferences(apps, schema_editor):
|
||||||
|
UserNotificationPreference = apps.get_model("db", "UserNotificationPreference")
|
||||||
|
User = apps.get_model("db", "User")
|
||||||
|
|
||||||
|
bulk_notification_preferences = []
|
||||||
|
for user_id in User.objects.filter(is_bot=False).values_list("id", flat=True):
|
||||||
|
bulk_notification_preferences.append(
|
||||||
|
UserNotificationPreference(
|
||||||
|
user_id=user_id,
|
||||||
|
created_by_id=user_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
UserNotificationPreference.objects.bulk_create(
|
||||||
|
bulk_notification_preferences, batch_size=1000, ignore_conflicts=True
|
||||||
|
)
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("db", "0056_usernotificationpreference_emailnotificationlog"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(create_notification_preferences)
|
||||||
|
]
|
@ -85,7 +85,7 @@ from .inbox import Inbox, InboxIssue
|
|||||||
|
|
||||||
from .analytic import AnalyticView
|
from .analytic import AnalyticView
|
||||||
|
|
||||||
from .notification import Notification
|
from .notification import Notification, UserNotificationPreference, EmailNotificationLog
|
||||||
|
|
||||||
from .exporter import ExporterHistory
|
from .exporter import ExporterHistory
|
||||||
|
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
# Django imports
|
# Django imports
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
# Third party imports
|
# Module imports
|
||||||
from .base import BaseModel
|
from . import BaseModel
|
||||||
|
|
||||||
|
|
||||||
class Notification(BaseModel):
|
class Notification(BaseModel):
|
||||||
workspace = models.ForeignKey(
|
workspace = models.ForeignKey(
|
||||||
@ -47,3 +47,82 @@ class Notification(BaseModel):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
"""Return name of the notifications"""
|
"""Return name of the notifications"""
|
||||||
return f"{self.receiver.email} <{self.workspace.name}>"
|
return f"{self.receiver.email} <{self.workspace.name}>"
|
||||||
|
|
||||||
|
|
||||||
|
def get_default_preference():
|
||||||
|
return {
|
||||||
|
"property_change": {
|
||||||
|
"email": True,
|
||||||
|
},
|
||||||
|
"state": {
|
||||||
|
"email": True,
|
||||||
|
},
|
||||||
|
"comment": {
|
||||||
|
"email": True,
|
||||||
|
},
|
||||||
|
"mentions": {
|
||||||
|
"email": True,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class UserNotificationPreference(BaseModel):
|
||||||
|
# user it is related to
|
||||||
|
user = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="notification_preferences",
|
||||||
|
)
|
||||||
|
# workspace if it is applicable
|
||||||
|
workspace = models.ForeignKey(
|
||||||
|
"db.Workspace",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="workspace_notification_preferences",
|
||||||
|
null=True,
|
||||||
|
)
|
||||||
|
# project
|
||||||
|
project = models.ForeignKey(
|
||||||
|
"db.Project",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="project_notification_preferences",
|
||||||
|
null=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# preference fields
|
||||||
|
property_change = models.BooleanField(default=True)
|
||||||
|
state_change = models.BooleanField(default=True)
|
||||||
|
comment = models.BooleanField(default=True)
|
||||||
|
mention = models.BooleanField(default=True)
|
||||||
|
issue_completed = models.BooleanField(default=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "UserNotificationPreference"
|
||||||
|
verbose_name_plural = "UserNotificationPreferences"
|
||||||
|
db_table = "user_notification_preferences"
|
||||||
|
ordering = ("-created_at",)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
"""Return the user"""
|
||||||
|
return f"<{self.user}>"
|
||||||
|
|
||||||
|
class EmailNotificationLog(BaseModel):
|
||||||
|
# receiver
|
||||||
|
receiver = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="email_notifications")
|
||||||
|
triggered_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="triggered_emails")
|
||||||
|
# entity - can be issues, pages, etc.
|
||||||
|
entity_identifier = models.UUIDField(null=True)
|
||||||
|
entity_name = models.CharField(max_length=255)
|
||||||
|
# data
|
||||||
|
data = models.JSONField(null=True)
|
||||||
|
# sent at
|
||||||
|
processed_at = models.DateTimeField(null=True)
|
||||||
|
sent_at = models.DateTimeField(null=True)
|
||||||
|
entity = models.CharField(max_length=200)
|
||||||
|
old_value = models.CharField(max_length=300, blank=True, null=True)
|
||||||
|
new_value = models.CharField(max_length=300, blank=True, null=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Email Notification Log"
|
||||||
|
verbose_name_plural = "Email Notification Logs"
|
||||||
|
db_table = "email_notification_logs"
|
||||||
|
ordering = ("-created_at",)
|
||||||
|
@ -11,8 +11,16 @@ from django.contrib.auth.models import (
|
|||||||
UserManager,
|
UserManager,
|
||||||
PermissionsMixin,
|
PermissionsMixin,
|
||||||
)
|
)
|
||||||
|
from django.db.models.signals import post_save
|
||||||
|
from django.conf import settings
|
||||||
|
from django.dispatch import receiver
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
|
# Third party imports
|
||||||
|
from sentry_sdk import capture_exception
|
||||||
|
from slack_sdk import WebClient
|
||||||
|
from slack_sdk.errors import SlackApiError
|
||||||
|
|
||||||
|
|
||||||
def get_default_onboarding():
|
def get_default_onboarding():
|
||||||
return {
|
return {
|
||||||
@ -134,3 +142,34 @@ class User(AbstractBaseUser, PermissionsMixin):
|
|||||||
self.is_staff = True
|
self.is_staff = True
|
||||||
|
|
||||||
super(User, self).save(*args, **kwargs)
|
super(User, self).save(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(post_save, sender=User)
|
||||||
|
def send_welcome_slack(sender, instance, created, **kwargs):
|
||||||
|
try:
|
||||||
|
if created and not instance.is_bot:
|
||||||
|
# Send message on slack as well
|
||||||
|
if settings.SLACK_BOT_TOKEN:
|
||||||
|
client = WebClient(token=settings.SLACK_BOT_TOKEN)
|
||||||
|
try:
|
||||||
|
_ = client.chat_postMessage(
|
||||||
|
channel="#trackers",
|
||||||
|
text=f"New user {instance.email} has signed up and begun the onboarding journey.",
|
||||||
|
)
|
||||||
|
except SlackApiError as e:
|
||||||
|
print(f"Got an error: {e.response['error']}")
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
capture_exception(e)
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(post_save, sender=User)
|
||||||
|
def create_user_notification(sender, instance, created, **kwargs):
|
||||||
|
# create preferences
|
||||||
|
if created and not instance.is_bot:
|
||||||
|
# Module imports
|
||||||
|
from plane.db.models import UserNotificationPreference
|
||||||
|
UserNotificationPreference.objects.create(
|
||||||
|
user=instance,
|
||||||
|
)
|
||||||
|
@ -291,6 +291,7 @@ CELERY_IMPORTS = (
|
|||||||
"plane.bgtasks.issue_automation_task",
|
"plane.bgtasks.issue_automation_task",
|
||||||
"plane.bgtasks.exporter_expired_task",
|
"plane.bgtasks.exporter_expired_task",
|
||||||
"plane.bgtasks.file_asset_task",
|
"plane.bgtasks.file_asset_task",
|
||||||
|
"plane.bgtasks.email_notification_task",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Sentry Settings
|
# Sentry Settings
|
||||||
|
903
apiserver/templates/emails/notifications/issue-updates.html
Normal file
903
apiserver/templates/emails/notifications/issue-updates.html
Normal file
@ -0,0 +1,903 @@
|
|||||||
|
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
|
||||||
|
<html
|
||||||
|
xmlns="http://www.w3.org/1999/xhtml"
|
||||||
|
xmlns:v="urn:schemas-microsoft-com:vml"
|
||||||
|
xmlns:o="urn:schemas-microsoft-com:office:office"
|
||||||
|
>
|
||||||
|
<head>
|
||||||
|
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||||
|
<meta name="format-detection" content="telephone=no" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Updates on Issue</title>
|
||||||
|
<style type="text/css" emogrify="no">
|
||||||
|
html {
|
||||||
|
font-family: system-ui;
|
||||||
|
}
|
||||||
|
p,
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
ol,
|
||||||
|
ul {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
h-full {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body
|
||||||
|
bgcolor="#ffffff"
|
||||||
|
text="#3b3f44"
|
||||||
|
link="#3f76ff"
|
||||||
|
yahoo="fix"
|
||||||
|
style="background-color: #f7f9ff; margin: 20px"
|
||||||
|
>
|
||||||
|
<table
|
||||||
|
cellspacing="0"
|
||||||
|
cellpadding="0"
|
||||||
|
border="0"
|
||||||
|
style="
|
||||||
|
background-color: #f7f9ff;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
padding-left: calc((100vw - 676px) / 2);
|
||||||
|
padding-right: calc((100vw - 676px) / 2);
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<!-- Header -->
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<table style="width: 600px">
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div style="margin: 20px">
|
||||||
|
<!-- TODO: Get Plane logo -->
|
||||||
|
<img
|
||||||
|
src="https://docs.plane.so/logos/logo.svg"
|
||||||
|
width="28"
|
||||||
|
height="28"
|
||||||
|
border="0"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
style="font-weight: 700; font-size: 32px; color: #3f76ff"
|
||||||
|
>
|
||||||
|
Plane
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<!-- Body -->
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<table style="width: 600px" celspacing="0">
|
||||||
|
<tr style="background-color: #fcfcfd">
|
||||||
|
<td style="color: #0b0c10; padding: 30px; border-radius: 4px">
|
||||||
|
<div>
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<p style="font-size: 1rem; font-weight: 600">
|
||||||
|
{{ issue.identifier }} updates
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
style="
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 28px;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ issue.name }}
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<hr
|
||||||
|
style="
|
||||||
|
background-color: #f0f0f3;
|
||||||
|
height: 1px;
|
||||||
|
border: 0;
|
||||||
|
margin-top: 15px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<p style="font-size: 1rem; line-height: 28px">
|
||||||
|
{{ summary }}
|
||||||
|
<span
|
||||||
|
style="
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 28px;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ data.0.actor_detail.first_name}} {{data.0.actor_detail.last_name }}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<!-- Outer update/comment Box -->
|
||||||
|
<table
|
||||||
|
style="
|
||||||
|
background-color: #f7f9ff;
|
||||||
|
border-radius: 8px;
|
||||||
|
border-style: solid;
|
||||||
|
border-width: 1px;
|
||||||
|
border-color: #c1d0ff;
|
||||||
|
padding: 20px;
|
||||||
|
margin-top: 15px;
|
||||||
|
width: 100%;
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 100%;
|
||||||
|
"
|
||||||
|
cellspacing="0"
|
||||||
|
>
|
||||||
|
<!-- Block Heading -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding-bottom: 20px">
|
||||||
|
<p
|
||||||
|
style="
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #121a26;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
Updates
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<!-- Property Updates -->
|
||||||
|
{% for update in data %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<table
|
||||||
|
cellspacing="0"
|
||||||
|
style="background-color: white; width: 100%"
|
||||||
|
>
|
||||||
|
<!-- action performer -->
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<table cellspacing="0">
|
||||||
|
<tr
|
||||||
|
style="
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-top: 20px;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<td style="padding-left: 15px">
|
||||||
|
<img
|
||||||
|
src="{{ update.actor_detail.avatar_url }}"
|
||||||
|
width="15"
|
||||||
|
height="15"
|
||||||
|
border="0"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
style="
|
||||||
|
padding-top: 20px;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
style="
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #1c2024;
|
||||||
|
margin-left: 8px;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ update.actor_detail.first_name }} {{ update.actor_detail.last_name }}
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
<td style="width: fit-content">
|
||||||
|
<p
|
||||||
|
style="
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
color: #80838d;
|
||||||
|
margin-left: 10px;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ update.activity_time }}
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<!-- Only assignee changed -->
|
||||||
|
{% if update.changes.assignees %}
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
style="padding-left: 15px; padding-bottom: 20px"
|
||||||
|
>
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td></td>
|
||||||
|
<td style="width: 55px">
|
||||||
|
<p
|
||||||
|
style="
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #525252;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
Assignee:
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
{% for assignee in update.changes.assignees.old_value %}
|
||||||
|
<td>
|
||||||
|
<p
|
||||||
|
style="
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration: line-through;
|
||||||
|
color: #641723;
|
||||||
|
background-color: #feebec;
|
||||||
|
margin-left: 5px;
|
||||||
|
padding: 3px;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ assignee }}
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
{% endfor %}
|
||||||
|
{% if update.changes.assignees.old_value and update.changes.assignee.new_value %}
|
||||||
|
<td>
|
||||||
|
<i
|
||||||
|
data-lucide="move-right"
|
||||||
|
style="
|
||||||
|
color: #525252;
|
||||||
|
height: 15px;
|
||||||
|
width: 15px;
|
||||||
|
margin-left: 10px;
|
||||||
|
margin-right: 10px;
|
||||||
|
"
|
||||||
|
></i>
|
||||||
|
</td>
|
||||||
|
{% endif %}
|
||||||
|
{% for assignee in update.changes.assignees.new_value %}
|
||||||
|
<td>
|
||||||
|
<p
|
||||||
|
style="
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #0d74ce;
|
||||||
|
background-color: #e6f4fe;
|
||||||
|
padding: 3px;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ assginee }}
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
<!-- due date changed -->
|
||||||
|
{% if update.changes.target_date %}
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
style="padding-left: 15px; padding-bottom: 20px"
|
||||||
|
>
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<i
|
||||||
|
data-lucide="calendar"
|
||||||
|
style="
|
||||||
|
color: #525252;
|
||||||
|
height: 12px;
|
||||||
|
width: 12px;
|
||||||
|
margin-right: 5px;
|
||||||
|
"
|
||||||
|
></i>
|
||||||
|
</td>
|
||||||
|
<td style="width: 55px">
|
||||||
|
<p
|
||||||
|
style="
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #525252;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
Due Date:
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<p
|
||||||
|
style="
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #171717;
|
||||||
|
margin-left: 5px;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ update.changes.target_date.new_value.0 }}
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %} -->
|
||||||
|
<!-- duplicate changed -->
|
||||||
|
{% if update.changes.duplicate %}
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
style="padding-left: 15px; padding-bottom: 20px"
|
||||||
|
>
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<i
|
||||||
|
data-lucide="layout-panel-top"
|
||||||
|
style="
|
||||||
|
color: #525252;
|
||||||
|
height: 12px;
|
||||||
|
width: 12px;
|
||||||
|
margin-right: 5px;
|
||||||
|
"
|
||||||
|
></i>
|
||||||
|
</td>
|
||||||
|
<td style="width: 55px">
|
||||||
|
<p
|
||||||
|
style="
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #525252;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
Duplicate:
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
{% for dup in update.changes.duplicate.new_value %}
|
||||||
|
<td>
|
||||||
|
<p
|
||||||
|
style="
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #3a5bc7;
|
||||||
|
margin-left: 5px;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ dup }}
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
<!-- Labels -->
|
||||||
|
{% if update.changes.labels %}
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
style="padding-left: 15px; padding-bottom: 20px"
|
||||||
|
>
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td valign="top">
|
||||||
|
<i
|
||||||
|
data-lucide="layout-panel-top"
|
||||||
|
style="
|
||||||
|
color: #525252;
|
||||||
|
height: 12px;
|
||||||
|
width: 12px;
|
||||||
|
margin-right: 5px;
|
||||||
|
"
|
||||||
|
></i>
|
||||||
|
</td>
|
||||||
|
<td valign="top">
|
||||||
|
<p
|
||||||
|
style="
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #525252;
|
||||||
|
margin-right: 5px;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
Labels:
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td valign="top">
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
{% for label in update.changes.labels.new_value %}
|
||||||
|
<td>
|
||||||
|
<p
|
||||||
|
style="
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #9747ff;
|
||||||
|
background-color: #9747ff1a;
|
||||||
|
padding: 3px;
|
||||||
|
padding-left: 5px;
|
||||||
|
padding-right: 5px;
|
||||||
|
margin-right: 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ label }}
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
<!-- State changed -->
|
||||||
|
{% if update.changes.state %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
style="
|
||||||
|
padding-left: 15px;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<i
|
||||||
|
data-lucide="calendar"
|
||||||
|
style="
|
||||||
|
color: #525252;
|
||||||
|
height: 12px;
|
||||||
|
width: 12px;
|
||||||
|
margin-right: 5px;
|
||||||
|
"
|
||||||
|
></i>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<p
|
||||||
|
style="
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #525252;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
State:
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<p
|
||||||
|
style="
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #60646c;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ update.changes.state.old_value.0 }}
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
<td>-></td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
<p
|
||||||
|
style="
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #60646c;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ update.changes.state.new_value.0 }}
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
<!-- Link Added -->
|
||||||
|
{% if update.changes.link %}
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
style="padding-left: 15px; padding-bottom: 20px"
|
||||||
|
>
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<i
|
||||||
|
data-lucide="layout-panel-top"
|
||||||
|
style="
|
||||||
|
color: #525252;
|
||||||
|
height: 12px;
|
||||||
|
width: 12px;
|
||||||
|
margin-right: 5px;
|
||||||
|
"
|
||||||
|
></i>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<p
|
||||||
|
style="
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #525252;
|
||||||
|
margin-right: 5px;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
Link:
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a
|
||||||
|
href=""
|
||||||
|
style="
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #3a5bc7;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ update.changes.link.new_value.0 }}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
<!-- Priority changed -->
|
||||||
|
{% if update.changes.priority %}
|
||||||
|
<tr>
|
||||||
|
<td style="padding-left: 15px; padding-bottom: 20px">
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<i
|
||||||
|
data-lucide="layout-panel-top"
|
||||||
|
style="
|
||||||
|
color: #525252;
|
||||||
|
height: 12px;
|
||||||
|
width: 12px;
|
||||||
|
margin-right: 5px;
|
||||||
|
"
|
||||||
|
></i>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<p
|
||||||
|
style="
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #525252;
|
||||||
|
margin-right: 5px;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
Priority:
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<p
|
||||||
|
style="
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 2px;
|
||||||
|
font-weight: 500;
|
||||||
|
{% if update.changes.priority.old_value.0 == 'urgent' %}background-color: #EF4444; color: white;{% endif %}
|
||||||
|
{% if update.changes.priority.old_value.0 == 'high' %}background-color: #F97316; color: white;{% endif %}
|
||||||
|
{% if update.changes.priority.old_value.0 == 'medium' %}background-color: #EAB308; color: white;{% endif %}
|
||||||
|
{% if update.changes.priority.old_value.0 == 'low' %}background-color: #3F76FF; color: white;{% endif %}
|
||||||
|
{% if update.changes.priority.old_value.0 == 'none' %}background-color: #A3A3A3; color: white;{% endif %}
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ update.changes.priority.old_value.0 }}
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
style="
|
||||||
|
padding-left: 10px;
|
||||||
|
padding-right: 10px;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
->
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<p
|
||||||
|
style="
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 2px;
|
||||||
|
font-weight: 500;
|
||||||
|
{% if update.changes.priority.old_value.0 == 'urgent' %}background-color: #EF4444; color: white;{% endif %}
|
||||||
|
{% if update.changes.priority.old_value.0 == 'high' %}background-color: #F97316; color: white;{% endif %}
|
||||||
|
{% if update.changes.priority.old_value.0 == 'medium' %}background-color: #EAB308; color: white;{% endif %}
|
||||||
|
{% if update.changes.priority.old_value.0 == 'low' %}background-color: #3F76FF; color: white;{% endif %}
|
||||||
|
{% if update.changes.priority.old_value.0 == 'none' %}background-color: #A3A3A3; color: white;{% endif %}
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ update.changes.priority.new_value.0 }}
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
<!-- Blocking changed -->
|
||||||
|
{% if update.changes.blocking %}
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
style="padding-left: 15px; padding-bottom: 20px"
|
||||||
|
>
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<i
|
||||||
|
data-lucide="layout-panel-top"
|
||||||
|
style="
|
||||||
|
color: #525252;
|
||||||
|
height: 12px;
|
||||||
|
width: 12px;
|
||||||
|
margin-right: 5px;
|
||||||
|
"
|
||||||
|
></i>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<p
|
||||||
|
style="
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #525252;
|
||||||
|
margin-right: 5px;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
Blocking:
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
{% for bl in update.changes.blocking.new_value %}
|
||||||
|
<td>
|
||||||
|
<a
|
||||||
|
href=""
|
||||||
|
style="font-size: 0.8rem; color: #3358d4"
|
||||||
|
>
|
||||||
|
{{ bl }}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
<!-- Comments outer update Box -->
|
||||||
|
{% if comments %}
|
||||||
|
<table
|
||||||
|
style="
|
||||||
|
background-color: #f7f9ff;
|
||||||
|
border-radius: 8px;
|
||||||
|
border-style: solid;
|
||||||
|
border-width: 1px;
|
||||||
|
border-color: #c1d0ff;
|
||||||
|
padding: 20px;
|
||||||
|
margin-top: 15px;
|
||||||
|
width: 600px;
|
||||||
|
max-width: 600px;
|
||||||
|
"
|
||||||
|
cellspacing="0"
|
||||||
|
>
|
||||||
|
<!-- Block Heading -->
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<p
|
||||||
|
style="
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #121a26;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
Comments
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<!-- Comments Updates -->
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<table
|
||||||
|
cellspacing="0"
|
||||||
|
style="background-color: white; width: 100%"
|
||||||
|
></table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<!-- Comments -->
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<table cellspacing="0" style="padding-top: 20px">
|
||||||
|
<tr style="border-radius: 8px">
|
||||||
|
<td valign="top">
|
||||||
|
<div
|
||||||
|
style="
|
||||||
|
height: 25px;
|
||||||
|
width: 25px;
|
||||||
|
border-radius: 20px;
|
||||||
|
background-color: #4f3422;
|
||||||
|
text-align: center;
|
||||||
|
justify-items: center;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style="
|
||||||
|
color: white;
|
||||||
|
font-weight: 500;
|
||||||
|
text-align: center;
|
||||||
|
padding-top: 0px;
|
||||||
|
font-size: 12px;
|
||||||
|
"
|
||||||
|
>S</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<!-- <img src="https://docs.plane.so/logos/logo.svg" width="25"
|
||||||
|
height="25" border="0" /> -->
|
||||||
|
</td>
|
||||||
|
{% for comment in comments %}
|
||||||
|
<td style="padding-bottom: 20px">
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<p
|
||||||
|
style="
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #1c2024;
|
||||||
|
margin-left: 8px;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ comment.actor_detail.first_name }} {{ comment.actor_detail.last_name }}
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% for actor_comment in comment.actor_comments %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div
|
||||||
|
style="
|
||||||
|
padding-left: 10px;
|
||||||
|
padding-right: 10px;
|
||||||
|
line-height: 24px;
|
||||||
|
padding-top: 15px;
|
||||||
|
margin-left: 10px;
|
||||||
|
padding-bottom: 15px;
|
||||||
|
background-color: white;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #525252;
|
||||||
|
margin-top: 5px;
|
||||||
|
border-radius: 4px;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ actor_comment.new_value.0 }}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onclick="window.location.href='{{ issue.issue_url }}'"
|
||||||
|
href="{{ issue.issue_url }}"
|
||||||
|
style="
|
||||||
|
background-color: #3e63dd;
|
||||||
|
padding: 10px 15px;
|
||||||
|
border: 1px solid #2f4ba8;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-top: 15px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: white;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
View issue
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<!-- Footer -->
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<table style="width: 100%; padding: 20px; justify-content: center">
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div style="font-size: 0.8rem; color: #1c2024">
|
||||||
|
This email was sent to
|
||||||
|
<a
|
||||||
|
href="mailto:"
|
||||||
|
style="
|
||||||
|
color: #3a5bc7;
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration: none;
|
||||||
|
"
|
||||||
|
>{{ receiver.email }}.</a
|
||||||
|
>
|
||||||
|
If you'd rather not receive this kind of email,
|
||||||
|
<a
|
||||||
|
href="{{ issue_unsubscribe }}"
|
||||||
|
style="color: #3a5bc7; text-decoration: none"
|
||||||
|
>you can unsubscribe to the issue</a
|
||||||
|
>
|
||||||
|
or
|
||||||
|
<a
|
||||||
|
href="{{ user_preference }}"
|
||||||
|
style="color: #3a5bc7; text-decoration: none"
|
||||||
|
>manage your email preferences</a
|
||||||
|
>.
|
||||||
|
<!-- Github | LinkedIn | Twitter -->
|
||||||
|
<div style="margin-top: 60px; float: right">
|
||||||
|
<a
|
||||||
|
href="https://github.com/makeplane"
|
||||||
|
target="_blank"
|
||||||
|
style="margin-left: 10px; text-decoration: none"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="https://creative-assets.mailinblue.com/editor/social-icons/rounded_colored/github_32px.png"
|
||||||
|
width="25"
|
||||||
|
height="25"
|
||||||
|
border="0"
|
||||||
|
style="display: inline-block"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://www.linkedin.com/company/planepowers/"
|
||||||
|
target="_blank"
|
||||||
|
style="margin-left: 10px; text-decoration: none"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="https://creative-assets.mailinblue.com/editor/social-icons/rounded_colored/linkedin_32px.png"
|
||||||
|
width="25"
|
||||||
|
height="25"
|
||||||
|
border="0"
|
||||||
|
style="display: inline-block"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://twitter.com/planepowers"
|
||||||
|
target="_blank"
|
||||||
|
style="margin-left: 10px; text-decoration: none"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="https://creative-assets.mailinblue.com/editor/social-icons/rounded_colored/twitter_32px.png"
|
||||||
|
width="25"
|
||||||
|
height="25"
|
||||||
|
border="0"
|
||||||
|
style="display: inline-block"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<!-- Lucid Icon Scripts -->
|
||||||
|
<script src="https://unpkg.com/lucide@latest"></script>
|
||||||
|
<script>
|
||||||
|
lucide.createIcons();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
8
packages/types/src/users.d.ts
vendored
8
packages/types/src/users.d.ts
vendored
@ -166,6 +166,14 @@ export interface IUserProjectsRole {
|
|||||||
[projectId: string]: EUserProjectRoles;
|
[projectId: string]: EUserProjectRoles;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IUserEmailNotificationSettings {
|
||||||
|
property_change: boolean;
|
||||||
|
state_change: boolean;
|
||||||
|
comment: boolean;
|
||||||
|
mention: boolean;
|
||||||
|
issue_completed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
// export interface ICurrentUser {
|
// export interface ICurrentUser {
|
||||||
// id: readonly string;
|
// id: readonly string;
|
||||||
// avatar: string;
|
// avatar: string;
|
||||||
|
@ -154,12 +154,7 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
|
|||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{currentUser && !is_archived && (fieldsToShow.includes("all") || fieldsToShow.includes("subscribe")) && (
|
{currentUser && !is_archived && (fieldsToShow.includes("all") || fieldsToShow.includes("subscribe")) && (
|
||||||
<IssueSubscription
|
<IssueSubscription workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} />
|
||||||
workspaceSlug={workspaceSlug}
|
|
||||||
projectId={projectId}
|
|
||||||
issueId={issueId}
|
|
||||||
currentUserId={currentUser?.id}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("link")) && (
|
{(fieldsToShow.includes("all") || fieldsToShow.includes("link")) && (
|
||||||
|
@ -11,14 +11,12 @@ export type TIssueSubscription = {
|
|||||||
workspaceSlug: string;
|
workspaceSlug: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
issueId: string;
|
issueId: string;
|
||||||
currentUserId: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const IssueSubscription: FC<TIssueSubscription> = observer((props) => {
|
export const IssueSubscription: FC<TIssueSubscription> = observer((props) => {
|
||||||
const { workspaceSlug, projectId, issueId, currentUserId } = props;
|
const { workspaceSlug, projectId, issueId } = props;
|
||||||
// hooks
|
// hooks
|
||||||
const {
|
const {
|
||||||
issue: { getIssueById },
|
|
||||||
subscription: { getSubscriptionByIssueId },
|
subscription: { getSubscriptionByIssueId },
|
||||||
createSubscription,
|
createSubscription,
|
||||||
removeSubscription,
|
removeSubscription,
|
||||||
@ -27,7 +25,6 @@ export const IssueSubscription: FC<TIssueSubscription> = observer((props) => {
|
|||||||
// state
|
// state
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const issue = getIssueById(issueId);
|
|
||||||
const subscription = getSubscriptionByIssueId(issueId);
|
const subscription = getSubscriptionByIssueId(issueId);
|
||||||
|
|
||||||
const handleSubscription = async () => {
|
const handleSubscription = async () => {
|
||||||
@ -51,8 +48,6 @@ export const IssueSubscription: FC<TIssueSubscription> = observer((props) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (issue?.created_by === currentUserId || issue?.assignee_ids?.includes(currentUserId)) return <></>;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Button
|
<Button
|
||||||
|
@ -188,12 +188,7 @@ export const IssueView: FC<IIssueView> = observer((props) => {
|
|||||||
<IssueUpdateStatus isSubmitting={isSubmitting} />
|
<IssueUpdateStatus isSubmitting={isSubmitting} />
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
{currentUser && !is_archived && (
|
{currentUser && !is_archived && (
|
||||||
<IssueSubscription
|
<IssueSubscription workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} />
|
||||||
workspaceSlug={workspaceSlug}
|
|
||||||
projectId={projectId}
|
|
||||||
issueId={issueId}
|
|
||||||
currentUserId={currentUser?.id}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
<button onClick={handleCopyText}>
|
<button onClick={handleCopyText}>
|
||||||
<Link2 className="h-4 w-4 -rotate-45 text-custom-text-300 hover:text-custom-text-200" />
|
<Link2 className="h-4 w-4 -rotate-45 text-custom-text-300 hover:text-custom-text-200" />
|
||||||
|
188
web/components/profile/preferences/email-notification-form.tsx
Normal file
188
web/components/profile/preferences/email-notification-form.tsx
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
import { FC } from "react";
|
||||||
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
// ui
|
||||||
|
import { Button } from "@plane/ui";
|
||||||
|
// hooks
|
||||||
|
import useToast from "hooks/use-toast";
|
||||||
|
// services
|
||||||
|
import { UserService } from "services/user.service";
|
||||||
|
// types
|
||||||
|
import { IUserEmailNotificationSettings } from "@plane/types";
|
||||||
|
|
||||||
|
interface IEmailNotificationFormProps {
|
||||||
|
data: IUserEmailNotificationSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
// services
|
||||||
|
const userService = new UserService();
|
||||||
|
|
||||||
|
export const EmailNotificationForm: FC<IEmailNotificationFormProps> = (props) => {
|
||||||
|
const { data } = props;
|
||||||
|
// toast
|
||||||
|
const { setToastAlert } = useToast();
|
||||||
|
// form data
|
||||||
|
const {
|
||||||
|
handleSubmit,
|
||||||
|
control,
|
||||||
|
setValue,
|
||||||
|
formState: { isSubmitting, isDirty, dirtyFields },
|
||||||
|
} = useForm<IUserEmailNotificationSettings>({
|
||||||
|
defaultValues: {
|
||||||
|
...data,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (formData: IUserEmailNotificationSettings) => {
|
||||||
|
// Get the dirty fields from the form data and create a payload
|
||||||
|
let payload = {};
|
||||||
|
Object.keys(dirtyFields).forEach((key) => {
|
||||||
|
payload = {
|
||||||
|
...payload,
|
||||||
|
[key]: formData[key as keyof IUserEmailNotificationSettings],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
await userService
|
||||||
|
.updateCurrentUserEmailNotificationSettings(payload)
|
||||||
|
.then(() =>
|
||||||
|
setToastAlert({
|
||||||
|
title: "Success",
|
||||||
|
type: "success",
|
||||||
|
message: "Email Notification Settings updated successfully",
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.catch((err) => console.error(err));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex gap-2 items-center pt-6 mb-2 pb-6 border-b border-custom-border-100">
|
||||||
|
<div className="grow">
|
||||||
|
<div className="pb-1 text-xl font-medium text-custom-text-100">Email notifications</div>
|
||||||
|
<div className="text-sm font-normal text-custom-text-300">
|
||||||
|
Stay in the loop on Issues you are subscribed to. Enable this to get notified.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="pt-2 text-lg font-medium text-custom-text-100">Notify me when:</div>
|
||||||
|
{/* Notification Settings */}
|
||||||
|
<div className="flex flex-col py-2">
|
||||||
|
<div className="flex gap-2 items-center pt-6">
|
||||||
|
<div className="grow">
|
||||||
|
<div className="pb-1 text-base font-medium text-custom-text-100">Property changes</div>
|
||||||
|
<div className="text-sm font-normal text-custom-text-300">
|
||||||
|
Notify me when issue’s properties like assignees, priority, estimates or anything else changes.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0">
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="property_change"
|
||||||
|
render={({ field: { value, onChange } }) => (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={value}
|
||||||
|
onChange={() => onChange(!value)}
|
||||||
|
className="w-3.5 h-3.5 mx-2 cursor-pointer !border-custom-border-100"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 items-center pt-6 pb-2">
|
||||||
|
<div className="grow">
|
||||||
|
<div className="pb-1 text-base font-medium text-custom-text-100">State Change</div>
|
||||||
|
<div className="text-sm font-normal text-custom-text-300">
|
||||||
|
Notify me when the issues moves to a different state
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0">
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="state_change"
|
||||||
|
render={({ field: { value, onChange } }) => (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={value}
|
||||||
|
onChange={() => {
|
||||||
|
if (!value) setValue("issue_completed", true);
|
||||||
|
onChange(!value);
|
||||||
|
}}
|
||||||
|
className="w-3.5 h-3.5 mx-2 cursor-pointer"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 items-center border-0 border-l-[3px] border-custom-border-300 pl-3">
|
||||||
|
<div className="grow">
|
||||||
|
<div className="pb-1 text-base font-medium text-custom-text-100">Issue completed</div>
|
||||||
|
<div className="text-sm font-normal text-custom-text-300">Notify me only when an issue is completed</div>
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0">
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="issue_completed"
|
||||||
|
render={({ field: { value, onChange } }) => (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={value}
|
||||||
|
onChange={() => onChange(!value)}
|
||||||
|
className="w-3.5 h-3.5 mx-2 cursor-pointer"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 items-center pt-6">
|
||||||
|
<div className="grow">
|
||||||
|
<div className="pb-1 text-base font-medium text-custom-text-100">Comments</div>
|
||||||
|
<div className="text-sm font-normal text-custom-text-300">
|
||||||
|
Notify me when someone leaves a comment on the issue
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0">
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="comment"
|
||||||
|
render={({ field: { value, onChange } }) => (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={value}
|
||||||
|
onChange={() => onChange(!value)}
|
||||||
|
className="w-3.5 h-3.5 mx-2 cursor-pointer"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 items-center pt-6">
|
||||||
|
<div className="grow">
|
||||||
|
<div className="pb-1 text-base font-medium text-custom-text-100">Mentions</div>
|
||||||
|
<div className="text-sm font-normal text-custom-text-300">
|
||||||
|
Notify me only when someone mentions me in the comments or description
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0">
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="mention"
|
||||||
|
render={({ field: { value, onChange } }) => (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={value}
|
||||||
|
onChange={() => onChange(!value)}
|
||||||
|
className="w-3.5 h-3.5 mx-2 cursor-pointer"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center py-12">
|
||||||
|
<Button variant="primary" onClick={handleSubmit(onSubmit)} loading={isSubmitting} disabled={!isDirty}>
|
||||||
|
{isSubmitting ? "Saving..." : "Save changes"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
1
web/components/profile/preferences/index.ts
Normal file
1
web/components/profile/preferences/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from "./email-notification-form";
|
@ -33,7 +33,7 @@ export const PROFILE_ACTION_LINKS: {
|
|||||||
{
|
{
|
||||||
key: "preferences",
|
key: "preferences",
|
||||||
label: "Preferences",
|
label: "Preferences",
|
||||||
href: `/profile/preferences`,
|
href: `/profile/preferences/theme`,
|
||||||
highlight: (pathname: string) => pathname.includes("/profile/preferences"),
|
highlight: (pathname: string) => pathname.includes("/profile/preferences"),
|
||||||
Icon: Settings2,
|
Icon: Settings2,
|
||||||
},
|
},
|
||||||
|
2
web/layouts/settings-layout/profile/preferences/index.ts
Normal file
2
web/layouts/settings-layout/profile/preferences/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from "./layout";
|
||||||
|
export * from "./sidebar";
|
25
web/layouts/settings-layout/profile/preferences/layout.tsx
Normal file
25
web/layouts/settings-layout/profile/preferences/layout.tsx
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { FC, ReactNode } from "react";
|
||||||
|
// layout
|
||||||
|
import { ProfileSettingsLayout } from "layouts/settings-layout";
|
||||||
|
import { ProfilePreferenceSettingsSidebar } from "./sidebar";
|
||||||
|
|
||||||
|
interface IProfilePreferenceSettingsLayout {
|
||||||
|
children: ReactNode;
|
||||||
|
header?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProfilePreferenceSettingsLayout: FC<IProfilePreferenceSettingsLayout> = (props) => {
|
||||||
|
const { children, header } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProfileSettingsLayout>
|
||||||
|
<div className="relative flex h-screen w-full overflow-hidden">
|
||||||
|
<ProfilePreferenceSettingsSidebar />
|
||||||
|
<main className="relative flex h-full w-full flex-col overflow-hidden bg-custom-background-100">
|
||||||
|
{header}
|
||||||
|
<div className="h-full w-full overflow-x-hidden overflow-y-scroll">{children}</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</ProfileSettingsLayout>
|
||||||
|
);
|
||||||
|
};
|
43
web/layouts/settings-layout/profile/preferences/sidebar.tsx
Normal file
43
web/layouts/settings-layout/profile/preferences/sidebar.tsx
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export const ProfilePreferenceSettingsSidebar = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const profilePreferenceLinks: Array<{
|
||||||
|
label: string;
|
||||||
|
href: string;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
label: "Theme",
|
||||||
|
href: `/profile/preferences/theme`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Email",
|
||||||
|
href: `/profile/preferences/email`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<div className="flex w-96 flex-col gap-6 px-8 py-12">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<span className="text-xs font-semibold text-custom-text-400">Preference</span>
|
||||||
|
<div className="flex w-full flex-col gap-2">
|
||||||
|
{profilePreferenceLinks.map((link) => (
|
||||||
|
<Link key={link.href} href={link.href}>
|
||||||
|
<div
|
||||||
|
className={`rounded-md px-4 py-2 text-sm font-medium ${
|
||||||
|
(link.label === "Import" ? router.asPath.includes(link.href) : router.asPath === link.href)
|
||||||
|
? "bg-custom-primary-100/10 text-custom-primary-100"
|
||||||
|
: "text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80 focus:bg-custom-sidebar-background-80"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
36
web/pages/profile/preferences/email.tsx
Normal file
36
web/pages/profile/preferences/email.tsx
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import { ReactElement } from "react";
|
||||||
|
import useSWR from "swr";
|
||||||
|
// layouts
|
||||||
|
import { ProfilePreferenceSettingsLayout } from "layouts/settings-layout/profile/preferences";
|
||||||
|
// components
|
||||||
|
import { EmailNotificationForm } from "components/profile/preferences";
|
||||||
|
// services
|
||||||
|
import { UserService } from "services/user.service";
|
||||||
|
// type
|
||||||
|
import { NextPageWithLayout } from "lib/types";
|
||||||
|
|
||||||
|
// services
|
||||||
|
const userService = new UserService();
|
||||||
|
|
||||||
|
const ProfilePreferencesThemePage: NextPageWithLayout = () => {
|
||||||
|
// fetching user email notification settings
|
||||||
|
const { data } = useSWR("CURRENT_USER_EMAIL_NOTIFICATION_SETTINGS", () =>
|
||||||
|
userService.currentUserEmailNotificationSettings()
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto mt-8 h-full w-full overflow-y-auto px-6 lg:px-20 pb-8">
|
||||||
|
<EmailNotificationForm data={data} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
ProfilePreferencesThemePage.getLayout = function getLayout(page: ReactElement) {
|
||||||
|
return <ProfilePreferenceSettingsLayout>{page}</ProfilePreferenceSettingsLayout>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProfilePreferencesThemePage;
|
@ -5,7 +5,7 @@ import { useTheme } from "next-themes";
|
|||||||
import { useUser } from "hooks/store";
|
import { useUser } from "hooks/store";
|
||||||
import useToast from "hooks/use-toast";
|
import useToast from "hooks/use-toast";
|
||||||
// layouts
|
// layouts
|
||||||
import { ProfileSettingsLayout } from "layouts/settings-layout";
|
import { ProfilePreferenceSettingsLayout } from "layouts/settings-layout/profile/preferences";
|
||||||
// components
|
// components
|
||||||
import { CustomThemeSelector, ThemeSwitch } from "components/core";
|
import { CustomThemeSelector, ThemeSwitch } from "components/core";
|
||||||
// ui
|
// ui
|
||||||
@ -15,7 +15,7 @@ import { I_THEME_OPTION, THEME_OPTIONS } from "constants/themes";
|
|||||||
// type
|
// type
|
||||||
import { NextPageWithLayout } from "lib/types";
|
import { NextPageWithLayout } from "lib/types";
|
||||||
|
|
||||||
const ProfilePreferencesPage: NextPageWithLayout = observer(() => {
|
const ProfilePreferencesThemePage: NextPageWithLayout = observer(() => {
|
||||||
// states
|
// states
|
||||||
const [currentTheme, setCurrentTheme] = useState<I_THEME_OPTION | null>(null);
|
const [currentTheme, setCurrentTheme] = useState<I_THEME_OPTION | null>(null);
|
||||||
// store hooks
|
// store hooks
|
||||||
@ -48,7 +48,7 @@ const ProfilePreferencesPage: NextPageWithLayout = observer(() => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{currentUser ? (
|
{currentUser ? (
|
||||||
<div className="mx-auto mt-16 h-full w-full overflow-y-auto px-8 pb-8 lg:w-3/5">
|
<div className="mx-auto mt-14 h-full w-full overflow-y-auto px-6 lg:px-20 pb-8">
|
||||||
<div className="flex items-center border-b border-custom-border-100 pb-3.5">
|
<div className="flex items-center border-b border-custom-border-100 pb-3.5">
|
||||||
<h3 className="text-xl font-medium">Preferences</h3>
|
<h3 className="text-xl font-medium">Preferences</h3>
|
||||||
</div>
|
</div>
|
||||||
@ -72,8 +72,8 @@ const ProfilePreferencesPage: NextPageWithLayout = observer(() => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
ProfilePreferencesPage.getLayout = function getLayout(page: ReactElement) {
|
ProfilePreferencesThemePage.getLayout = function getLayout(page: ReactElement) {
|
||||||
return <ProfileSettingsLayout>{page}</ProfileSettingsLayout>;
|
return <ProfilePreferenceSettingsLayout>{page}</ProfilePreferenceSettingsLayout>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ProfilePreferencesPage;
|
export default ProfilePreferencesThemePage;
|
@ -10,7 +10,7 @@ import type {
|
|||||||
IUserProfileProjectSegregation,
|
IUserProfileProjectSegregation,
|
||||||
IUserSettings,
|
IUserSettings,
|
||||||
IUserWorkspaceDashboard,
|
IUserWorkspaceDashboard,
|
||||||
TIssueMap,
|
IUserEmailNotificationSettings,
|
||||||
} from "@plane/types";
|
} from "@plane/types";
|
||||||
// helpers
|
// helpers
|
||||||
import { API_BASE_URL } from "helpers/common.helper";
|
import { API_BASE_URL } from "helpers/common.helper";
|
||||||
@ -69,6 +69,14 @@ export class UserService extends APIService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async currentUserEmailNotificationSettings(): Promise<IUserEmailNotificationSettings> {
|
||||||
|
return this.get("/api/users/me/notification-preferences/")
|
||||||
|
.then((response) => response?.data)
|
||||||
|
.catch((error) => {
|
||||||
|
throw error?.response;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async updateUser(data: Partial<IUser>): Promise<any> {
|
async updateUser(data: Partial<IUser>): Promise<any> {
|
||||||
return this.patch("/api/users/me/", data)
|
return this.patch("/api/users/me/", data)
|
||||||
.then((response) => response?.data)
|
.then((response) => response?.data)
|
||||||
@ -97,6 +105,14 @@ export class UserService extends APIService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateCurrentUserEmailNotificationSettings(data: Partial<IUserEmailNotificationSettings>): Promise<any> {
|
||||||
|
return this.patch("/api/users/me/notification-preferences/", data)
|
||||||
|
.then((response) => response?.data)
|
||||||
|
.catch((error) => {
|
||||||
|
throw error?.response?.data;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async getUserActivity(): Promise<IUserActivityResponse> {
|
async getUserActivity(): Promise<IUserActivityResponse> {
|
||||||
return this.get(`/api/users/me/activities/`)
|
return this.get(`/api/users/me/activities/`)
|
||||||
.then((response) => response?.data)
|
.then((response) => response?.data)
|
||||||
|
Loading…
Reference in New Issue
Block a user