diff --git a/apiserver/plane/api/serializers/issue.py b/apiserver/plane/api/serializers/issue.py index 61958d89e..7aeee7d70 100644 --- a/apiserver/plane/api/serializers/issue.py +++ b/apiserver/plane/api/serializers/issue.py @@ -462,9 +462,9 @@ class IssueAttachmentSerializer(BaseSerializer): # Issue Serializer with state details class IssueStateSerializer(BaseSerializer): + label_details = LabelLiteSerializer(read_only=True, source="labels", many=True) state_detail = StateLiteSerializer(read_only=True, source="state") project_detail = ProjectLiteSerializer(read_only=True, source="project") - label_details = LabelSerializer(read_only=True, source="labels", many=True) assignee_details = UserLiteSerializer(read_only=True, source="assignees", many=True) sub_issues_count = serializers.IntegerField(read_only=True) bridge_id = serializers.UUIDField(read_only=True) diff --git a/apiserver/plane/api/serializers/notification.py b/apiserver/plane/api/serializers/notification.py index 529cb9f9c..56dcc0dd8 100644 --- a/apiserver/plane/api/serializers/notification.py +++ b/apiserver/plane/api/serializers/notification.py @@ -1,9 +1,12 @@ # Module imports from .base import BaseSerializer +from .user import UserLiteSerializer from plane.db.models import Notification class NotificationSerializer(BaseSerializer): + triggered_by_details = UserLiteSerializer(read_only=True, source="triggered_by") + class Meta: model = Notification fields = "__all__" diff --git a/apiserver/plane/api/urls.py b/apiserver/plane/api/urls.py index dc5b0e1dc..04bbc2a47 100644 --- a/apiserver/plane/api/urls.py +++ b/apiserver/plane/api/urls.py @@ -153,6 +153,7 @@ from plane.api.views import ( ## End Analytics # Notification NotificationViewSet, + UnreadNotificationEndpoint, ## End Notification ) @@ -1382,5 +1383,10 @@ urlpatterns = [ ), name="notifications", ), + path( + "workspaces//users/notifications/unread/", + UnreadNotificationEndpoint.as_view(), + name="unread-notifications", + ), ## End Notification ] diff --git a/apiserver/plane/api/views/__init__.py b/apiserver/plane/api/views/__init__.py index 2f0a54c1d..076cdd006 100644 --- a/apiserver/plane/api/views/__init__.py +++ b/apiserver/plane/api/views/__init__.py @@ -145,4 +145,4 @@ from .analytic import ( DefaultAnalyticsEndpoint, ) -from .notification import NotificationViewSet \ No newline at end of file +from .notification import NotificationViewSet, UnreadNotificationEndpoint \ No newline at end of file diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py index 1c2c95a96..476fd4c5a 100644 --- a/apiserver/plane/api/views/issue.py +++ b/apiserver/plane/api/views/issue.py @@ -169,8 +169,8 @@ class IssueViewSet(BaseViewSet): issue_queryset = ( self.get_queryset() .filter(**filters) - .annotate(cycle_id=F("issue_cycle__id")) - .annotate(module_id=F("issue_module__id")) + .annotate(cycle_id=F("issue_cycle__cycle_id")) + .annotate(module_id=F("issue_module__module_id")) .annotate( link_count=IssueLink.objects.filter(issue=OuterRef("id")) .order_by() @@ -955,8 +955,8 @@ class IssueArchiveViewSet(BaseViewSet): issue_queryset = ( self.get_queryset() .filter(**filters) - .annotate(cycle_id=F("issue_cycle__id")) - .annotate(module_id=F("issue_module__id")) + .annotate(cycle_id=F("issue_cycle__cycle_id")) + .annotate(module_id=F("issue_module__module_id")) .annotate( link_count=IssueLink.objects.filter(issue=OuterRef("id")) .order_by() diff --git a/apiserver/plane/api/views/notification.py b/apiserver/plane/api/views/notification.py index ac0082430..802cbb03f 100644 --- a/apiserver/plane/api/views/notification.py +++ b/apiserver/plane/api/views/notification.py @@ -8,7 +8,7 @@ from rest_framework.response import Response from sentry_sdk import capture_exception # Module imports -from .base import BaseViewSet +from .base import BaseViewSet, BaseAPIView from plane.db.models import Notification, IssueAssignee, IssueSubscriber, Issue from plane.api.serializers import NotificationSerializer @@ -25,7 +25,7 @@ class NotificationViewSet(BaseViewSet): workspace__slug=self.kwargs.get("slug"), receiver_id=self.request.user.id, ) - .select_related("workspace") + .select_related("workspace", "project," "triggered_by", "receiver") ) def list(self, request, slug): @@ -123,7 +123,7 @@ class NotificationViewSet(BaseViewSet): {"error": "Something went wrong please try again later"}, status=status.HTTP_400_BAD_REQUEST, ) - + def mark_read(self, request, slug, pk): try: notification = Notification.objects.get( @@ -166,7 +166,6 @@ class NotificationViewSet(BaseViewSet): status=status.HTTP_400_BAD_REQUEST, ) - def archive(self, request, slug, pk): try: notification = Notification.objects.get( @@ -209,3 +208,48 @@ class NotificationViewSet(BaseViewSet): status=status.HTTP_400_BAD_REQUEST, ) + +class UnreadNotificationEndpoint(BaseAPIView): + def get(self, request, slug): + try: + # Watching Issues Count + watching_notification_count = Notification.objects.filter( + workspace__slug=slug, + receiver_id=request.user.id, + entity_identifier__in=IssueSubscriber.objects.filter( + workspace__slug=slug, subscriber_id=request.user.id + ).values_list("issue_id", flat=True), + ).count() + + # My Issues Count + my_issues_count = Notification.objects.filter( + workspace__slug=slug, + receiver_id=request.user.id, + entity_identifier__in=IssueAssignee.objects.filter( + workspace__slug=slug, assignee_id=request.user.id + ).values_list("issue_id", flat=True), + ).count() + + # Created Issues Count + created_issues_count = Notification.objects.filter( + workspace__slug=slug, + receiver_id=request.user.id, + entity_identifier__in=Issue.objects.filter( + workspace__slug=slug, created_by=request.user + ).values_list("pk", flat=True), + ).count() + + return Response( + { + "watching_notifications": watching_notification_count, + "my_issues": my_issues_count, + "created_issues": created_issues_count, + }, + status=status.HTTP_200_OK, + ) + except Exception as e: + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_400_BAD_REQUEST, + ) diff --git a/apiserver/plane/api/views/workspace.py b/apiserver/plane/api/views/workspace.py index 4c136ed8c..305deb525 100644 --- a/apiserver/plane/api/views/workspace.py +++ b/apiserver/plane/api/views/workspace.py @@ -101,6 +101,7 @@ class WorkSpaceViewSet(BaseViewSet): .filter(workspace_member__member=self.request.user) .annotate(total_members=member_count) .annotate(total_issues=issue_count) + .select_related("owner") ) def create(self, request): diff --git a/apiserver/plane/bgtasks/issue_activites_task.py b/apiserver/plane/bgtasks/issue_activites_task.py new file mode 100644 index 000000000..abbbb5f5f --- /dev/null +++ b/apiserver/plane/bgtasks/issue_activites_task.py @@ -0,0 +1,1162 @@ +# Python imports +import json +import requests + +# Django imports +from django.conf import settings +from django.core.serializers.json import DjangoJSONEncoder +from django.utils import timezone + +# Third Party imports +from celery import shared_task +from sentry_sdk import capture_exception + +# Module imports +from plane.db.models import ( + User, + Issue, + Project, + Label, + IssueActivity, + State, + Cycle, + Module, + IssueSubscriber, + Notification, + IssueAssignee, +) +from plane.api.serializers import IssueActivitySerializer + + +# Track Chnages in name +def track_name( + requested_data, + current_instance, + issue_id, + project, + actor, + issue_activities, +): + if current_instance.get("name") != requested_data.get("name"): + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor=actor, + verb="updated", + old_value=current_instance.get("name"), + new_value=requested_data.get("name"), + field="name", + project=project, + workspace=project.workspace, + comment=f"{actor.email} updated the name to {requested_data.get('name')}", + ) + ) + + +# Track changes in parent issue +def track_parent( + requested_data, + current_instance, + issue_id, + project, + actor, + issue_activities, +): + if current_instance.get("parent") != requested_data.get("parent"): + if requested_data.get("parent") == None: + old_parent = Issue.objects.get(pk=current_instance.get("parent")) + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor=actor, + verb="updated", + old_value=f"{project.identifier}-{old_parent.sequence_id}", + new_value=None, + field="parent", + project=project, + workspace=project.workspace, + comment=f"{actor.email} updated the parent issue to None", + old_identifier=old_parent.id, + new_identifier=None, + ) + ) + else: + new_parent = Issue.objects.get(pk=requested_data.get("parent")) + old_parent = Issue.objects.filter(pk=current_instance.get("parent")).first() + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor=actor, + verb="updated", + old_value=f"{project.identifier}-{old_parent.sequence_id}" + if old_parent is not None + else None, + new_value=f"{project.identifier}-{new_parent.sequence_id}", + field="parent", + project=project, + workspace=project.workspace, + comment=f"{actor.email} updated the parent issue to {new_parent.name}", + old_identifier=old_parent.id if old_parent is not None else None, + new_identifier=new_parent.id, + ) + ) + + +# Track changes in priority +def track_priority( + requested_data, + current_instance, + issue_id, + project, + actor, + issue_activities, +): + if current_instance.get("priority") != requested_data.get("priority"): + if requested_data.get("priority") == None: + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor=actor, + verb="updated", + old_value=current_instance.get("priority"), + new_value=None, + field="priority", + project=project, + workspace=project.workspace, + comment=f"{actor.email} updated the priority to None", + ) + ) + else: + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor=actor, + verb="updated", + old_value=current_instance.get("priority"), + new_value=requested_data.get("priority"), + field="priority", + project=project, + workspace=project.workspace, + comment=f"{actor.email} updated the priority to {requested_data.get('priority')}", + ) + ) + + +# Track chnages in state of the issue +def track_state( + requested_data, + current_instance, + issue_id, + project, + actor, + issue_activities, +): + if current_instance.get("state") != requested_data.get("state"): + new_state = State.objects.get(pk=requested_data.get("state", None)) + old_state = State.objects.get(pk=current_instance.get("state", None)) + + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor=actor, + verb="updated", + old_value=old_state.name, + new_value=new_state.name, + field="state", + project=project, + workspace=project.workspace, + comment=f"{actor.email} updated the state to {new_state.name}", + old_identifier=old_state.id, + new_identifier=new_state.id, + ) + ) + + +# Track issue description +def track_description( + requested_data, + current_instance, + issue_id, + project, + actor, + issue_activities, +): + if current_instance.get("description_html") != requested_data.get( + "description_html" + ): + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor=actor, + verb="updated", + old_value=current_instance.get("description_html"), + new_value=requested_data.get("description_html"), + field="description", + project=project, + workspace=project.workspace, + comment=f"{actor.email} updated the description to {requested_data.get('description_html')}", + ) + ) + + +# Track changes in issue target date +def track_target_date( + requested_data, + current_instance, + issue_id, + project, + actor, + issue_activities, +): + if current_instance.get("target_date") != requested_data.get("target_date"): + if requested_data.get("target_date") == None: + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor=actor, + verb="updated", + old_value=current_instance.get("target_date"), + new_value=requested_data.get("target_date"), + field="target_date", + project=project, + workspace=project.workspace, + comment=f"{actor.email} updated the target date to None", + ) + ) + else: + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor=actor, + verb="updated", + old_value=current_instance.get("target_date"), + new_value=requested_data.get("target_date"), + field="target_date", + project=project, + workspace=project.workspace, + comment=f"{actor.email} updated the target date to {requested_data.get('target_date')}", + ) + ) + + +# Track changes in issue start date +def track_start_date( + requested_data, + current_instance, + issue_id, + project, + actor, + issue_activities, +): + if current_instance.get("start_date") != requested_data.get("start_date"): + if requested_data.get("start_date") == None: + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor=actor, + verb="updated", + old_value=current_instance.get("start_date"), + new_value=requested_data.get("start_date"), + field="start_date", + project=project, + workspace=project.workspace, + comment=f"{actor.email} updated the start date to None", + ) + ) + else: + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor=actor, + verb="updated", + old_value=current_instance.get("start_date"), + new_value=requested_data.get("start_date"), + field="start_date", + project=project, + workspace=project.workspace, + comment=f"{actor.email} updated the start date to {requested_data.get('start_date')}", + ) + ) + + +# Track changes in issue labels +def track_labels( + requested_data, + current_instance, + issue_id, + project, + actor, + issue_activities, +): + # Label Addition + if len(requested_data.get("labels_list")) > len(current_instance.get("labels")): + for label in requested_data.get("labels_list"): + if label not in current_instance.get("labels"): + label = Label.objects.get(pk=label) + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor=actor, + verb="updated", + old_value="", + new_value=label.name, + field="labels", + project=project, + workspace=project.workspace, + comment=f"{actor.email} added label {label.name}", + new_identifier=label.id, + old_identifier=None, + ) + ) + + # Label Removal + if len(requested_data.get("labels_list")) < len(current_instance.get("labels")): + for label in current_instance.get("labels"): + if label not in requested_data.get("labels_list"): + label = Label.objects.get(pk=label) + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor=actor, + verb="updated", + old_value=label.name, + new_value="", + field="labels", + project=project, + workspace=project.workspace, + comment=f"{actor.email} removed label {label.name}", + old_identifier=label.id, + new_identifier=None, + ) + ) + + +# Track changes in issue assignees +def track_assignees( + requested_data, + current_instance, + issue_id, + project, + actor, + issue_activities, +): + # Assignee Addition + if len(requested_data.get("assignees_list")) > len( + current_instance.get("assignees") + ): + for assignee in requested_data.get("assignees_list"): + if assignee not in current_instance.get("assignees"): + assignee = User.objects.get(pk=assignee) + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor=actor, + verb="updated", + old_value="", + new_value=assignee.email, + field="assignees", + project=project, + workspace=project.workspace, + comment=f"{actor.email} added assignee {assignee.email}", + new_identifier=actor.id, + ) + ) + + # Assignee Removal + if len(requested_data.get("assignees_list")) < len( + current_instance.get("assignees") + ): + for assignee in current_instance.get("assignees"): + if assignee not in requested_data.get("assignees_list"): + assignee = User.objects.get(pk=assignee) + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor=actor, + verb="updated", + old_value=assignee.email, + new_value="", + field="assignee", + project=project, + workspace=project.workspace, + comment=f"{actor.email} removed assignee {assignee.email}", + old_identifier=actor.id, + ) + ) + + +# Track changes in blocking issues +def track_blocks( + requested_data, + current_instance, + issue_id, + project, + actor, + issue_activities, +): + if len(requested_data.get("blocks_list")) > len( + current_instance.get("blocked_issues") + ): + for block in requested_data.get("blocks_list"): + if ( + len( + [ + blocked + for blocked in current_instance.get("blocked_issues") + if blocked.get("block") == block + ] + ) + == 0 + ): + issue = Issue.objects.get(pk=block) + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor=actor, + verb="updated", + old_value="", + new_value=f"{project.identifier}-{issue.sequence_id}", + field="blocks", + project=project, + workspace=project.workspace, + comment=f"{actor.email} added blocking issue {project.identifier}-{issue.sequence_id}", + new_identifier=issue.id, + ) + ) + + # Blocked Issue Removal + if len(requested_data.get("blocks_list")) < len( + current_instance.get("blocked_issues") + ): + for blocked in current_instance.get("blocked_issues"): + if blocked.get("block") not in requested_data.get("blocks_list"): + issue = Issue.objects.get(pk=blocked.get("block")) + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor=actor, + verb="updated", + old_value=f"{project.identifier}-{issue.sequence_id}", + new_value="", + field="blocks", + project=project, + workspace=project.workspace, + comment=f"{actor.email} removed blocking issue {project.identifier}-{issue.sequence_id}", + old_identifier=issue.id, + ) + ) + + +# Track changes in blocked_by issues +def track_blockings( + requested_data, + current_instance, + issue_id, + project, + actor, + issue_activities, +): + if len(requested_data.get("blockers_list")) > len( + current_instance.get("blocker_issues") + ): + for block in requested_data.get("blockers_list"): + if ( + len( + [ + blocked + for blocked in current_instance.get("blocker_issues") + if blocked.get("blocked_by") == block + ] + ) + == 0 + ): + issue = Issue.objects.get(pk=block) + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor=actor, + verb="updated", + old_value="", + new_value=f"{project.identifier}-{issue.sequence_id}", + field="blocking", + project=project, + workspace=project.workspace, + comment=f"{actor.email} added blocked by issue {project.identifier}-{issue.sequence_id}", + new_identifier=issue.id, + ) + ) + + # Blocked Issue Removal + if len(requested_data.get("blockers_list")) < len( + current_instance.get("blocker_issues") + ): + for blocked in current_instance.get("blocker_issues"): + if blocked.get("blocked_by") not in requested_data.get("blockers_list"): + issue = Issue.objects.get(pk=blocked.get("blocked_by")) + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor=actor, + verb="updated", + old_value=f"{project.identifier}-{issue.sequence_id}", + new_value="", + field="blocking", + project=project, + workspace=project.workspace, + comment=f"{actor.email} removed blocked by issue {project.identifier}-{issue.sequence_id}", + old_identifier=issue.id, + ) + ) + + +def create_issue_activity( + requested_data, current_instance, issue_id, project, actor, issue_activities +): + issue_activities.append( + IssueActivity( + issue_id=issue_id, + project=project, + workspace=project.workspace, + comment=f"{actor.email} created the issue", + verb="created", + actor=actor, + ) + ) + + +def track_estimate_points( + requested_data, current_instance, issue_id, project, actor, issue_activities +): + if current_instance.get("estimate_point") != requested_data.get("estimate_point"): + if requested_data.get("estimate_point") == None: + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor=actor, + verb="updated", + old_value=current_instance.get("estimate_point"), + new_value=requested_data.get("estimate_point"), + field="estimate_point", + project=project, + workspace=project.workspace, + comment=f"{actor.email} updated the estimate point to None", + ) + ) + else: + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor=actor, + verb="updated", + old_value=current_instance.get("estimate_point"), + new_value=requested_data.get("estimate_point"), + field="estimate_point", + project=project, + workspace=project.workspace, + comment=f"{actor.email} updated the estimate point to {requested_data.get('estimate_point')}", + ) + ) + + +def track_archive_at( + requested_data, current_instance, issue_id, project, actor, issue_activities +): + if requested_data.get("archived_at") is None: + issue_activities.append( + IssueActivity( + issue_id=issue_id, + project=project, + workspace=project.workspace, + comment=f"{actor.email} has restored the issue", + verb="updated", + actor=actor, + field="archived_at", + old_value="archive", + new_value="restore", + ) + ) + else: + issue_activities.append( + IssueActivity( + issue_id=issue_id, + project=project, + workspace=project.workspace, + comment=f"Plane has archived the issue", + verb="updated", + actor=actor, + field="archived_at", + old_value=None, + new_value="archive", + ) + ) + + +def track_closed_to( + requested_data, current_instance, issue_id, project, actor, issue_activities +): + if requested_data.get("closed_to") is not None: + updated_state = State.objects.get( + pk=requested_data.get("closed_to"), project=project + ) + + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor=actor, + verb="updated", + old_value=None, + new_value=updated_state.name, + field="state", + project=project, + workspace=project.workspace, + comment=f"Plane updated the state to {updated_state.name}", + old_identifier=None, + new_identifier=updated_state.id, + ) + ) + + +def update_issue_activity( + requested_data, current_instance, issue_id, project, actor, issue_activities +): + ISSUE_ACTIVITY_MAPPER = { + "name": track_name, + "parent": track_parent, + "priority": track_priority, + "state": track_state, + "description": track_description, + "target_date": track_target_date, + "start_date": track_start_date, + "labels_list": track_labels, + "assignees_list": track_assignees, + "blocks_list": track_blocks, + "blockers_list": track_blockings, + "estimate_point": track_estimate_points, + "archived_at": track_archive_at, + "closed_to": track_closed_to, + } + + requested_data = json.loads(requested_data) if requested_data is not None else None + current_instance = ( + json.loads(current_instance) if current_instance is not None else None + ) + + for key in requested_data: + func = ISSUE_ACTIVITY_MAPPER.get(key, None) + if func is not None: + func( + requested_data, + current_instance, + issue_id, + project, + actor, + issue_activities, + ) + + +def delete_issue_activity( + requested_data, current_instance, issue_id, project, actor, issue_activities +): + issue_activities.append( + IssueActivity( + project=project, + workspace=project.workspace, + comment=f"{actor.email} deleted the issue", + verb="deleted", + actor=actor, + field="issue", + ) + ) + + +def create_comment_activity( + requested_data, current_instance, issue_id, project, actor, issue_activities +): + requested_data = json.loads(requested_data) if requested_data is not None else None + current_instance = ( + json.loads(current_instance) if current_instance is not None else None + ) + + issue_activities.append( + IssueActivity( + issue_id=issue_id, + project=project, + workspace=project.workspace, + comment=f"{actor.email} created a comment", + verb="created", + actor=actor, + field="comment", + new_value=requested_data.get("comment_html", ""), + new_identifier=requested_data.get("id", None), + issue_comment_id=requested_data.get("id", None), + ) + ) + + +def update_comment_activity( + requested_data, current_instance, issue_id, project, actor, issue_activities +): + requested_data = json.loads(requested_data) if requested_data is not None else None + current_instance = ( + json.loads(current_instance) if current_instance is not None else None + ) + + if current_instance.get("comment_html") != requested_data.get("comment_html"): + issue_activities.append( + IssueActivity( + issue_id=issue_id, + project=project, + workspace=project.workspace, + comment=f"{actor.email} updated a comment", + verb="updated", + actor=actor, + field="comment", + old_value=current_instance.get("comment_html", ""), + old_identifier=current_instance.get("id"), + new_value=requested_data.get("comment_html", ""), + new_identifier=current_instance.get("id", None), + issue_comment_id=current_instance.get("id", None), + ) + ) + + +def delete_comment_activity( + requested_data, current_instance, issue_id, project, actor, issue_activities +): + issue_activities.append( + IssueActivity( + issue_id=issue_id, + project=project, + workspace=project.workspace, + comment=f"{actor.email} deleted the comment", + verb="deleted", + actor=actor, + field="comment", + ) + ) + + +def create_cycle_issue_activity( + requested_data, current_instance, issue_id, project, actor, issue_activities +): + requested_data = json.loads(requested_data) if requested_data is not None else None + current_instance = ( + json.loads(current_instance) if current_instance is not None else None + ) + + # Updated Records: + updated_records = current_instance.get("updated_cycle_issues", []) + created_records = json.loads(current_instance.get("created_cycle_issues", [])) + + for updated_record in updated_records: + old_cycle = Cycle.objects.filter( + pk=updated_record.get("old_cycle_id", None) + ).first() + new_cycle = Cycle.objects.filter( + pk=updated_record.get("new_cycle_id", None) + ).first() + + issue_activities.append( + IssueActivity( + issue_id=updated_record.get("issue_id"), + actor=actor, + verb="updated", + old_value=old_cycle.name, + new_value=new_cycle.name, + field="cycles", + project=project, + workspace=project.workspace, + comment=f"{actor.email} updated cycle from {old_cycle.name} to {new_cycle.name}", + old_identifier=old_cycle.id, + new_identifier=new_cycle.id, + ) + ) + + for created_record in created_records: + cycle = Cycle.objects.filter( + pk=created_record.get("fields").get("cycle") + ).first() + + issue_activities.append( + IssueActivity( + issue_id=created_record.get("fields").get("issue"), + actor=actor, + verb="created", + old_value="", + new_value=cycle.name, + field="cycles", + project=project, + workspace=project.workspace, + comment=f"{actor.email} added cycle {cycle.name}", + new_identifier=cycle.id, + ) + ) + + +def delete_cycle_issue_activity( + requested_data, current_instance, issue_id, project, actor, issue_activities +): + requested_data = json.loads(requested_data) if requested_data is not None else None + current_instance = ( + json.loads(current_instance) if current_instance is not None else None + ) + + cycle_id = requested_data.get("cycle_id", "") + cycle = Cycle.objects.filter(pk=cycle_id).first() + issues = requested_data.get("issues") + + for issue in issues: + issue_activities.append( + IssueActivity( + issue_id=issue, + actor=actor, + verb="deleted", + old_value=cycle.name if cycle is not None else "", + new_value="", + field="cycles", + project=project, + workspace=project.workspace, + comment=f"{actor.email} removed this issue from {cycle.name if cycle is not None else None}", + old_identifier=cycle.id if cycle is not None else None, + ) + ) + + +def create_module_issue_activity( + requested_data, current_instance, issue_id, project, actor, issue_activities +): + requested_data = json.loads(requested_data) if requested_data is not None else None + current_instance = ( + json.loads(current_instance) if current_instance is not None else None + ) + + # Updated Records: + updated_records = current_instance.get("updated_module_issues", []) + created_records = json.loads(current_instance.get("created_module_issues", [])) + + for updated_record in updated_records: + old_module = Module.objects.filter( + pk=updated_record.get("old_module_id", None) + ).first() + new_module = Module.objects.filter( + pk=updated_record.get("new_module_id", None) + ).first() + + issue_activities.append( + IssueActivity( + issue_id=updated_record.get("issue_id"), + actor=actor, + verb="updated", + old_value=old_module.name, + new_value=new_module.name, + field="modules", + project=project, + workspace=project.workspace, + comment=f"{actor.email} updated module from {old_module.name} to {new_module.name}", + old_identifier=old_module.id, + new_identifier=new_module.id, + ) + ) + + for created_record in created_records: + module = Module.objects.filter( + pk=created_record.get("fields").get("module") + ).first() + issue_activities.append( + IssueActivity( + issue_id=created_record.get("fields").get("issue"), + actor=actor, + verb="created", + old_value="", + new_value=module.name, + field="modules", + project=project, + workspace=project.workspace, + comment=f"{actor.email} added module {module.name}", + new_identifier=module.id, + ) + ) + + +def delete_module_issue_activity( + requested_data, current_instance, issue_id, project, actor, issue_activities +): + requested_data = json.loads(requested_data) if requested_data is not None else None + current_instance = ( + json.loads(current_instance) if current_instance is not None else None + ) + + module_id = requested_data.get("module_id", "") + module = Module.objects.filter(pk=module_id).first() + issues = requested_data.get("issues") + + for issue in issues: + issue_activities.append( + IssueActivity( + issue_id=issue, + actor=actor, + verb="deleted", + old_value=module.name if module is not None else "", + new_value="", + field="modules", + project=project, + workspace=project.workspace, + comment=f"{actor.email} removed this issue from {module.name if module is not None else None}", + old_identifier=module.id if module is not None else None, + ) + ) + + +def create_link_activity( + requested_data, current_instance, issue_id, project, actor, issue_activities +): + requested_data = json.loads(requested_data) if requested_data is not None else None + current_instance = ( + json.loads(current_instance) if current_instance is not None else None + ) + + issue_activities.append( + IssueActivity( + issue_id=issue_id, + project=project, + workspace=project.workspace, + comment=f"{actor.email} created a link", + verb="created", + actor=actor, + field="link", + new_value=requested_data.get("url", ""), + new_identifier=requested_data.get("id", None), + ) + ) + + +def update_link_activity( + requested_data, current_instance, issue_id, project, actor, issue_activities +): + requested_data = json.loads(requested_data) if requested_data is not None else None + current_instance = ( + json.loads(current_instance) if current_instance is not None else None + ) + + if current_instance.get("url") != requested_data.get("url"): + issue_activities.append( + IssueActivity( + issue_id=issue_id, + project=project, + workspace=project.workspace, + comment=f"{actor.email} updated a link", + verb="updated", + actor=actor, + field="link", + old_value=current_instance.get("url", ""), + old_identifier=current_instance.get("id"), + new_value=requested_data.get("url", ""), + new_identifier=current_instance.get("id", None), + ) + ) + + +def delete_link_activity( + requested_data, current_instance, issue_id, project, actor, issue_activities +): + issue_activities.append( + IssueActivity( + issue_id=issue_id, + project=project, + workspace=project.workspace, + comment=f"{actor.email} deleted the link", + verb="deleted", + actor=actor, + field="link", + ) + ) + + +def create_attachment_activity( + requested_data, current_instance, issue_id, project, actor, issue_activities +): + requested_data = json.loads(requested_data) if requested_data is not None else None + current_instance = ( + json.loads(current_instance) if current_instance is not None else None + ) + + issue_activities.append( + IssueActivity( + issue_id=issue_id, + project=project, + workspace=project.workspace, + comment=f"{actor.email} created an attachment", + verb="created", + actor=actor, + field="attachment", + new_value=current_instance.get("access", ""), + new_identifier=current_instance.get("id", None), + ) + ) + + +def delete_attachment_activity( + requested_data, current_instance, issue_id, project, actor, issue_activities +): + issue_activities.append( + IssueActivity( + issue_id=issue_id, + project=project, + workspace=project.workspace, + comment=f"{actor.email} deleted the attachment", + verb="deleted", + actor=actor, + field="attachment", + ) + ) + + +# Receive message from room group +@shared_task +def issue_activity( + type, + requested_data, + current_instance, + issue_id, + actor_id, + project_id, + subscriber=True, +): + try: + issue_activities = [] + + actor = User.objects.get(pk=actor_id) + project = Project.objects.get(pk=project_id) + + issue = Issue.objects.filter(pk=issue_id).first() + if issue is not None: + issue.updated_at = timezone.now() + issue.save() + + if subscriber: + # add the user to issue subscriber + try: + _ = IssueSubscriber.objects.get_or_create( + issue_id=issue_id, subscriber=actor + ) + except Exception as e: + pass + + ACTIVITY_MAPPER = { + "issue.activity.created": create_issue_activity, + "issue.activity.updated": update_issue_activity, + "issue.activity.deleted": delete_issue_activity, + "comment.activity.created": create_comment_activity, + "comment.activity.updated": update_comment_activity, + "comment.activity.deleted": delete_comment_activity, + "cycle.activity.created": create_cycle_issue_activity, + "cycle.activity.deleted": delete_cycle_issue_activity, + "module.activity.created": create_module_issue_activity, + "module.activity.deleted": delete_module_issue_activity, + "link.activity.created": create_link_activity, + "link.activity.updated": update_link_activity, + "link.activity.deleted": delete_link_activity, + "attachment.activity.created": create_attachment_activity, + "attachment.activity.deleted": delete_attachment_activity, + } + + func = ACTIVITY_MAPPER.get(type) + if func is not None: + func( + requested_data, + current_instance, + issue_id, + project, + actor, + issue_activities, + ) + + # Save all the values to database + issue_activities_created = IssueActivity.objects.bulk_create(issue_activities) + # Post the updates to segway for integrations and webhooks + if len(issue_activities_created): + # Don't send activities if the actor is a bot + try: + if settings.PROXY_BASE_URL: + for issue_activity in issue_activities_created: + headers = {"Content-Type": "application/json"} + issue_activity_json = json.dumps( + IssueActivitySerializer(issue_activity).data, + cls=DjangoJSONEncoder, + ) + _ = requests.post( + f"{settings.PROXY_BASE_URL}/hooks/workspaces/{str(issue_activity.workspace_id)}/projects/{str(issue_activity.project_id)}/issues/{str(issue_activity.issue_id)}/issue-activity-hooks/", + json=issue_activity_json, + headers=headers, + ) + except Exception as e: + capture_exception(e) + + # Create Notifications + bulk_notifications = [] + + issue_subscribers = list( + IssueSubscriber.objects.filter(project=project, issue_id=issue_id) + .exclude(subscriber_id=actor_id) + .values_list("subscriber", flat=True) + ) + + issue_assignees = list( + IssueAssignee.objects.filter(project=project, issue_id=issue_id) + .exclude(assignee_id=actor_id) + .values_list("assignee", flat=True) + ) + + issue_subscribers = issue_subscribers + issue_assignees + + # Add bot filtering + if issue.created_by_id is not None and not issue.created_by.is_bot: + issue_subscribers = issue_subscribers + [issue.created_by_id] + + issue = Issue.objects.get(project=project, pk=issue_id) + for subscriber in issue_subscribers: + for issue_activity in issue_activities_created: + bulk_notifications.append( + Notification( + workspace=project.workspace, + sender="in_app:issue_activities", + triggered_by_id=actor_id, + receiver_id=subscriber, + entity_identifier=issue_id, + entity_name="issue", + project=project, + title=issue_activity.comment, + data={ + "issue": { + "id": str(issue_id), + "name": str(issue.name), + "identifier": str(project.identifier), + "sequence_id": issue.sequence_id, + "state_name": issue.state.name, + "state_group": issue.state.group, + }, + "issue_activity": { + "id": str(issue_activity.id), + "verb": str(issue_activity.verb), + "field": str(issue_activity.field), + "actor": str(issue_activity.actor_id), + "new_value": str(issue_activity.new_value), + "old_value": str(issue_activity.old_value), + "issue_comment": str( + issue_activity.issue_comment.comment_stripped if issue_activity.issue_comment is not None else "" + ), + }, + }, + ) + ) + + # Bulk create notifications + Notification.objects.bulk_create(bulk_notifications, batch_size=100) + + return + except Exception as e: + # Print logs if in DEBUG mode + if settings.DEBUG: + print(e) + capture_exception(e) + return diff --git a/apps/app/components/account/email-code-form.tsx b/apps/app/components/account/email-code-form.tsx index ba8c759af..e6b3eeac3 100644 --- a/apps/app/components/account/email-code-form.tsx +++ b/apps/app/components/account/email-code-form.tsx @@ -32,6 +32,7 @@ export const EmailCodeForm = ({ handleSignIn }: any) => { setError, setValue, getValues, + watch, formState: { errors, isSubmitting, isValid, isDirty }, } = useForm({ defaultValues: { @@ -112,43 +113,34 @@ export const EmailCodeForm = ({ handleSignIn }: any) => { return ( <> -
- {(codeSent || codeResent) && ( -
-
-
-
-
-

- {codeResent - ? "Please check your mail for new code." - : "Please check your mail for code."} -

-
-
-
- )} -
+ {(codeSent || codeResent) && ( +

+ We have sent the sign in code. +
+ Please check your inbox at {watch("email")} +

+ )} + +
/^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test( value - ) || "Email ID is not valid", + ) || "Email address is not valid", }} error={errors.email} - placeholder="Enter your Email ID" + placeholder="Enter your email address..." />
{codeSent && ( -
+ <> { required: "Code is required", }} error={errors.token} - placeholder="Enter code" + placeholder="Enter code..." /> -
+ + )} + {codeSent ? ( + + {isLoading ? "Signing in..." : "Sign in"} + + ) : ( + { + handleSubmit(onSubmit)().then(() => { + setResendCodeTimer(30); + }); + }} + disabled={!isValid && isDirty} + loading={isSubmitting} + > + {isSubmitting ? "Sending code..." : "Send sign in code"} + )} -
- {codeSent ? ( - - {isLoading ? "Signing in..." : "Sign in"} - - ) : ( - { - handleSubmit(onSubmit)().then(() => { - setResendCodeTimer(30); - }); - }} - loading={isSubmitting || (!isValid && isDirty)} - > - {isSubmitting ? "Sending code..." : "Send code"} - - )} -
); diff --git a/apps/app/components/account/email-password-form.tsx b/apps/app/components/account/email-password-form.tsx index 97da2b9e4..ab902ee96 100644 --- a/apps/app/components/account/email-password-form.tsx +++ b/apps/app/components/account/email-password-form.tsx @@ -8,7 +8,7 @@ import { useForm } from "react-hook-form"; // components import { EmailResetPasswordForm } from "components/account"; // ui -import { Input, SecondaryButton } from "components/ui"; +import { Input, PrimaryButton } from "components/ui"; // types type EmailPasswordFormValues = { email: string; @@ -42,28 +42,38 @@ export const EmailPasswordForm: React.FC = ({ onSubmit }) => { return ( <> +

+ {isResettingPassword + ? "Reset your password" + : isSignUpPage + ? "Sign up on Plane" + : "Sign in to Plane"} +

{isResettingPassword ? ( ) : ( -
-
+ +
/^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test( value - ) || "Email ID is not valid", + ) || "Email address is not valid", }} error={errors.email} - placeholder="Enter your email ID" + placeholder="Enter your email address..." />
-
+
= ({ onSubmit }) => { required: "Password is required", }} error={errors.password} - placeholder="Enter your password" + placeholder="Enter your password..." />
-
-
- {isSignUpPage ? ( - - - Already have an account? Sign in. - - - ) : ( - - )} -
+
+ {isSignUpPage ? ( + + + Already have an account? Sign in. + + + ) : ( + + )}
-
- + = ({ onSubmit }) => { {isSignUpPage ? isSubmitting ? "Signing up..." - : "Sign Up" + : "Sign up" : isSubmitting ? "Signing in..." - : "Sign In"} - + : "Sign in"} + {!isSignUpPage && ( - + Don{"'"}t have an account? Sign up. diff --git a/apps/app/components/account/email-reset-password-form.tsx b/apps/app/components/account/email-reset-password-form.tsx index 03ea69042..2fa787a5f 100644 --- a/apps/app/components/account/email-reset-password-form.tsx +++ b/apps/app/components/account/email-reset-password-form.tsx @@ -59,25 +59,28 @@ export const EmailResetPasswordForm: React.FC = ({ setIsResettingPassword }; return ( - -
+ +
/^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test( value - ) || "Email ID is not valid", + ) || "Email address is not valid", }} error={errors.email} - placeholder="Enter registered Email ID" + placeholder="Enter registered email address.." />
-
+
setIsResettingPassword(false)} diff --git a/apps/app/components/account/github-login-button.tsx b/apps/app/components/account/github-login-button.tsx index 889d46405..844f32355 100644 --- a/apps/app/components/account/github-login-button.tsx +++ b/apps/app/components/account/github-login-button.tsx @@ -1,9 +1,14 @@ import { useEffect, useState, FC } from "react"; + import Link from "next/link"; import Image from "next/image"; import { useRouter } from "next/router"; + +// next-themes +import { useTheme } from "next-themes"; // images -import githubImage from "/public/logos/github-black.png"; +import githubBlackImage from "/public/logos/github-black.png"; +import githubWhiteImage from "/public/logos/github-white.png"; const { NEXT_PUBLIC_GITHUB_ID } = process.env; @@ -11,15 +16,15 @@ export interface GithubLoginButtonProps { handleSignIn: React.Dispatch; } -export const GithubLoginButton: FC = (props) => { - const { handleSignIn } = props; - // router +export const GithubLoginButton: FC = ({ handleSignIn }) => { + const [loginCallBackURL, setLoginCallBackURL] = useState(undefined); + const [gitCode, setGitCode] = useState(null); + const { query: { code }, } = useRouter(); - // states - const [loginCallBackURL, setLoginCallBackURL] = useState(undefined); - const [gitCode, setGitCode] = useState(null); + + const { theme } = useTheme(); useEffect(() => { if (code && !gitCode) { @@ -35,13 +40,18 @@ export const GithubLoginButton: FC = (props) => { }, []); return ( -
+
-
diff --git a/apps/app/components/account/google-login.tsx b/apps/app/components/account/google-login.tsx index c12fb4e24..a2cdb7750 100644 --- a/apps/app/components/account/google-login.tsx +++ b/apps/app/components/account/google-login.tsx @@ -27,7 +27,7 @@ export const GoogleLoginButton: FC = (props) => { theme: "outline", size: "large", logo_alignment: "center", - width: "410", + width: "360", text: "continue_with", } as GsiButtonConfiguration // customization attributes ); @@ -48,7 +48,7 @@ export const GoogleLoginButton: FC = (props) => { <>