From f1fda4ae4a5b0e278dafdbcf387e46a546125fb7 Mon Sep 17 00:00:00 2001 From: Nikhil <118773738+pablohashescobar@users.noreply.github.com> Date: Mon, 6 May 2024 14:13:49 +0530 Subject: [PATCH] [WEB - 1122] fix: webhook for issues, issue comments, projects, cycles and modules. (#4330) * dev: update webhook logic for issues * dev: update issue webhooks for cycle and module * dev: webhook for comment * dev: issue attachment webhooks * dev: add logging * dev: add inbox issue webhooks * dev: update the webhook send task * dev: project webhooks for api * dev: webhooks update for projects, cycles and modules * dev: fix webhook on cycle and module create from external apis --- apiserver/plane/api/serializers/inbox.py | 4 + apiserver/plane/api/views/base.py | 35 -- apiserver/plane/api/views/cycle.py | 34 +- apiserver/plane/api/views/inbox.py | 18 +- apiserver/plane/api/views/issue.py | 25 +- apiserver/plane/api/views/module.py | 35 +- apiserver/plane/api/views/project.py | 36 ++- apiserver/plane/app/serializers/issue.py | 2 +- apiserver/plane/app/views/__init__.py | 2 +- apiserver/plane/app/views/base.py | 30 -- apiserver/plane/app/views/cycle/base.py | 34 +- apiserver/plane/app/views/cycle/issue.py | 7 +- apiserver/plane/app/views/inbox/base.py | 45 ++- apiserver/plane/app/views/issue/base.py | 4 +- apiserver/plane/app/views/issue/comment.py | 4 +- apiserver/plane/app/views/module/base.py | 40 ++- apiserver/plane/app/views/module/issue.py | 4 +- apiserver/plane/app/views/project/base.py | 32 +- .../plane/bgtasks/issue_activites_task.py | 43 ++- apiserver/plane/bgtasks/webhook_task.py | 302 ++++++++++++++---- 20 files changed, 545 insertions(+), 191 deletions(-) diff --git a/apiserver/plane/api/serializers/inbox.py b/apiserver/plane/api/serializers/inbox.py index 78bb74d13..a0c79235d 100644 --- a/apiserver/plane/api/serializers/inbox.py +++ b/apiserver/plane/api/serializers/inbox.py @@ -1,9 +1,13 @@ # Module improts from .base import BaseSerializer +from .issue import IssueExpandSerializer from plane.db.models import InboxIssue class InboxIssueSerializer(BaseSerializer): + + issue_detail = IssueExpandSerializer(read_only=True, source="issue") + class Meta: model = InboxIssue fields = "__all__" diff --git a/apiserver/plane/api/views/base.py b/apiserver/plane/api/views/base.py index 13047eb78..1f6bd70af 100644 --- a/apiserver/plane/api/views/base.py +++ b/apiserver/plane/api/views/base.py @@ -19,7 +19,6 @@ from rest_framework.views import APIView # Module imports from plane.api.middleware.api_authentication import APIKeyAuthentication from plane.api.rate_limit import ApiKeyRateThrottle -from plane.bgtasks.webhook_task import send_webhook from plane.utils.exception_logger import log_exception from plane.utils.paginator import BasePaginator @@ -38,40 +37,6 @@ class TimezoneMixin: timezone.deactivate() -class WebhookMixin: - webhook_event = None - bulk = False - - def finalize_response(self, request, response, *args, **kwargs): - response = super().finalize_response( - request, response, *args, **kwargs - ) - - # Check for the case should webhook be sent - if ( - self.webhook_event - and self.request.method in ["POST", "PATCH", "DELETE"] - and response.status_code in [200, 201, 204] - ): - url = request.build_absolute_uri() - parsed_url = urlparse(url) - # Extract the scheme and netloc - scheme = parsed_url.scheme - netloc = parsed_url.netloc - # Push the object to delay - send_webhook.delay( - event=self.webhook_event, - payload=response.data, - kw=self.kwargs, - action=self.request.method, - slug=self.workspace_slug, - bulk=self.bulk, - current_site=f"{scheme}://{netloc}", - ) - - return response - - class BaseAPIView(TimezoneMixin, APIView, BasePaginator): authentication_classes = [ APIKeyAuthentication, diff --git a/apiserver/plane/api/views/cycle.py b/apiserver/plane/api/views/cycle.py index d9c75ff41..6e1e5e057 100644 --- a/apiserver/plane/api/views/cycle.py +++ b/apiserver/plane/api/views/cycle.py @@ -5,6 +5,7 @@ import json from django.core import serializers from django.db.models import Count, F, Func, OuterRef, Q, Sum from django.utils import timezone +from django.core.serializers.json import DjangoJSONEncoder # Third party imports from rest_framework import status @@ -26,10 +27,11 @@ from plane.db.models import ( ) from plane.utils.analytics_plot import burndown_plot -from .base import BaseAPIView, WebhookMixin +from .base import BaseAPIView +from plane.bgtasks.webhook_task import model_activity -class CycleAPIEndpoint(WebhookMixin, BaseAPIView): +class CycleAPIEndpoint(BaseAPIView): """ This viewset automatically provides `list`, `create`, `retrieve`, `update` and `destroy` actions related to cycle. @@ -277,6 +279,16 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView): project_id=project_id, owned_by=request.user, ) + # Send the model activity + model_activity.delay( + model_name="cycle", + model_id=str(serializer.data["id"]), + requested_data=request.data, + current_instance=None, + actor_id=request.user.id, + slug=slug, + origin=request.META.get("HTTP_ORIGIN"), + ) return Response( serializer.data, status=status.HTTP_201_CREATED ) @@ -295,6 +307,11 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView): cycle = Cycle.objects.get( workspace__slug=slug, project_id=project_id, pk=pk ) + + current_instance = json.dumps( + CycleSerializer(cycle).data, cls=DjangoJSONEncoder + ) + if cycle.archived_at: return Response( {"error": "Archived cycle cannot be edited"}, @@ -344,6 +361,17 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView): status=status.HTTP_409_CONFLICT, ) serializer.save() + + # Send the model activity + model_activity.delay( + model_name="cycle", + model_id=str(serializer.data["id"]), + requested_data=request.data, + current_instance=current_instance, + actor_id=request.user.id, + slug=slug, + origin=request.META.get("HTTP_ORIGIN"), + ) return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -515,7 +543,7 @@ class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView): return Response(status=status.HTTP_204_NO_CONTENT) -class CycleIssueAPIEndpoint(WebhookMixin, BaseAPIView): +class CycleIssueAPIEndpoint(BaseAPIView): """ This viewset automatically provides `list`, `create`, and `destroy` actions related to cycle issues. diff --git a/apiserver/plane/api/views/inbox.py b/apiserver/plane/api/views/inbox.py index 5e6e4a215..8987e4f63 100644 --- a/apiserver/plane/api/views/inbox.py +++ b/apiserver/plane/api/views/inbox.py @@ -154,6 +154,13 @@ class InboxIssueAPIEndpoint(BaseAPIView): state=state, ) + # create an inbox issue + inbox_issue = InboxIssue.objects.create( + inbox_id=inbox.id, + project_id=project_id, + issue=issue, + source=request.data.get("source", "in-app"), + ) # Create an Issue Activity issue_activity.delay( type="issue.activity.created", @@ -163,14 +170,7 @@ class InboxIssueAPIEndpoint(BaseAPIView): project_id=str(project_id), current_instance=None, epoch=int(timezone.now().timestamp()), - ) - - # create an inbox issue - inbox_issue = InboxIssue.objects.create( - inbox_id=inbox.id, - project_id=project_id, - issue=issue, - source=request.data.get("source", "in-app"), + inbox=str(inbox_issue.id), ) serializer = InboxIssueSerializer(inbox_issue) @@ -260,6 +260,7 @@ class InboxIssueAPIEndpoint(BaseAPIView): cls=DjangoJSONEncoder, ), epoch=int(timezone.now().timestamp()), + inbox=(inbox_issue.id), ) issue_serializer.save() else: @@ -327,6 +328,7 @@ class InboxIssueAPIEndpoint(BaseAPIView): epoch=int(timezone.now().timestamp()), notification=False, origin=request.META.get("HTTP_ORIGIN"), + inbox=str(inbox_issue.id), ) return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py index 8d72ac5db..a62278b19 100644 --- a/apiserver/plane/api/views/issue.py +++ b/apiserver/plane/api/views/issue.py @@ -48,11 +48,10 @@ from plane.db.models import ( ProjectMember, ) -from .base import BaseAPIView, WebhookMixin +from .base import BaseAPIView - -class WorkspaceIssueAPIEndpoint(WebhookMixin, BaseAPIView): +class WorkspaceIssueAPIEndpoint(BaseAPIView): """ This viewset provides `retrieveByIssueId` on workspace level @@ -60,12 +59,9 @@ class WorkspaceIssueAPIEndpoint(WebhookMixin, BaseAPIView): model = Issue webhook_event = "issue" - permission_classes = [ - ProjectEntityPermission - ] + permission_classes = [ProjectEntityPermission] serializer_class = IssueSerializer - @property def project__identifier(self): return self.kwargs.get("project__identifier", None) @@ -91,7 +87,9 @@ class WorkspaceIssueAPIEndpoint(WebhookMixin, BaseAPIView): .order_by(self.kwargs.get("order_by", "-created_at")) ).distinct() - def get(self, request, slug, project__identifier=None, issue__identifier=None): + def get( + self, request, slug, project__identifier=None, issue__identifier=None + ): if issue__identifier and project__identifier: issue = Issue.issue_objects.annotate( sub_issues_count=Issue.issue_objects.filter( @@ -100,7 +98,11 @@ class WorkspaceIssueAPIEndpoint(WebhookMixin, BaseAPIView): .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") - ).get(workspace__slug=slug, project__identifier=project__identifier, sequence_id=issue__identifier) + ).get( + workspace__slug=slug, + project__identifier=project__identifier, + sequence_id=issue__identifier, + ) return Response( IssueSerializer( issue, @@ -110,7 +112,8 @@ class WorkspaceIssueAPIEndpoint(WebhookMixin, BaseAPIView): status=status.HTTP_200_OK, ) -class IssueAPIEndpoint(WebhookMixin, BaseAPIView): + +class IssueAPIEndpoint(BaseAPIView): """ This viewset automatically provides `list`, `create`, `retrieve`, `update` and `destroy` actions related to issue. @@ -652,7 +655,7 @@ class IssueLinkAPIEndpoint(BaseAPIView): return Response(status=status.HTTP_204_NO_CONTENT) -class IssueCommentAPIEndpoint(WebhookMixin, BaseAPIView): +class IssueCommentAPIEndpoint(BaseAPIView): """ This viewset automatically provides `list`, `create`, `retrieve`, `update` and `destroy` actions related to comments of the particular issue. diff --git a/apiserver/plane/api/views/module.py b/apiserver/plane/api/views/module.py index 38744eaa5..eeb29dad2 100644 --- a/apiserver/plane/api/views/module.py +++ b/apiserver/plane/api/views/module.py @@ -5,6 +5,7 @@ import json from django.core import serializers from django.db.models import Count, F, Func, OuterRef, Prefetch, Q from django.utils import timezone +from django.core.serializers.json import DjangoJSONEncoder # Third party imports from rest_framework import status @@ -28,10 +29,11 @@ from plane.db.models import ( Project, ) -from .base import BaseAPIView, WebhookMixin +from .base import BaseAPIView +from plane.bgtasks.webhook_task import model_activity -class ModuleAPIEndpoint(WebhookMixin, BaseAPIView): +class ModuleAPIEndpoint(BaseAPIView): """ This viewset automatically provides `list`, `create`, `retrieve`, `update` and `destroy` actions related to module. @@ -163,6 +165,16 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView): status=status.HTTP_409_CONFLICT, ) serializer.save() + # Send the model activity + model_activity.delay( + model_name="module", + model_id=str(serializer.data["id"]), + requested_data=request.data, + current_instance=None, + actor_id=request.user.id, + slug=slug, + origin=request.META.get("HTTP_ORIGIN"), + ) module = Module.objects.get(pk=serializer.data["id"]) serializer = ModuleSerializer(module) return Response(serializer.data, status=status.HTTP_201_CREATED) @@ -172,6 +184,11 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView): module = Module.objects.get( pk=pk, project_id=project_id, workspace__slug=slug ) + + current_instance = json.dumps( + ModuleSerializer(module).data, cls=DjangoJSONEncoder + ) + if module.archived_at: return Response( {"error": "Archived module cannot be edited"}, @@ -204,6 +221,18 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView): status=status.HTTP_409_CONFLICT, ) serializer.save() + + # Send the model activity + model_activity.delay( + model_name="module", + model_id=str(serializer.data["id"]), + requested_data=request.data, + current_instance=current_instance, + actor_id=request.user.id, + slug=slug, + origin=request.META.get("HTTP_ORIGIN"), + ) + return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -260,7 +289,7 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView): return Response(status=status.HTTP_204_NO_CONTENT) -class ModuleIssueAPIEndpoint(WebhookMixin, BaseAPIView): +class ModuleIssueAPIEndpoint(BaseAPIView): """ This viewset automatically provides `list`, `create`, `retrieve`, `update` and `destroy` actions related to module issues. diff --git a/apiserver/plane/api/views/project.py b/apiserver/plane/api/views/project.py index fcb0cc4fb..019ab704e 100644 --- a/apiserver/plane/api/views/project.py +++ b/apiserver/plane/api/views/project.py @@ -1,7 +1,11 @@ +# Python imports +import json + # Django imports from django.db import IntegrityError from django.db.models import Exists, F, Func, OuterRef, Prefetch, Q, Subquery from django.utils import timezone +from django.core.serializers.json import DjangoJSONEncoder # Third party imports from rest_framework import status @@ -23,11 +27,11 @@ from plane.db.models import ( State, Workspace, ) - -from .base import BaseAPIView, WebhookMixin +from plane.bgtasks.webhook_task import model_activity +from .base import BaseAPIView -class ProjectAPIEndpoint(WebhookMixin, BaseAPIView): +class ProjectAPIEndpoint(BaseAPIView): """Project Endpoints to create, update, list, retrieve and delete endpoint""" serializer_class = ProjectSerializer @@ -236,6 +240,17 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView): .filter(pk=serializer.data["id"]) .first() ) + # Model activity + model_activity.delay( + model_name="project", + model_id=str(project.id), + requested_data=request.data, + current_instance=None, + actor_id=request.user.id, + slug=slug, + origin=request.META.get("HTTP_ORIGIN"), + ) + serializer = ProjectSerializer(project) return Response( serializer.data, status=status.HTTP_201_CREATED @@ -265,7 +280,9 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView): try: workspace = Workspace.objects.get(slug=slug) project = Project.objects.get(pk=pk) - + current_instance = json.dumps( + ProjectSerializer(project).data, cls=DjangoJSONEncoder + ) if project.archived_at: return Response( {"error": "Archived project cannot be updated"}, @@ -303,6 +320,17 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView): .filter(pk=serializer.data["id"]) .first() ) + + model_activity.delay( + model_name="project", + model_id=str(project.id), + requested_data=request.data, + current_instance=current_instance, + actor_id=request.user.id, + slug=slug, + origin=request.META.get("HTTP_ORIGIN"), + ) + serializer = ProjectSerializer(project) return Response(serializer.data, status=status.HTTP_200_OK) return Response( diff --git a/apiserver/plane/app/serializers/issue.py b/apiserver/plane/app/serializers/issue.py index 8c641b720..b884d60a3 100644 --- a/apiserver/plane/app/serializers/issue.py +++ b/apiserver/plane/app/serializers/issue.py @@ -442,7 +442,7 @@ class IssueLinkSerializer(BaseSerializer): raise serializers.ValidationError("Invalid URL format.") # Check URL scheme - if not value.startswith(('http://', 'https://')): + if not value.startswith(("http://", "https://")): raise serializers.ValidationError("Invalid URL scheme.") return value diff --git a/apiserver/plane/app/views/__init__.py b/apiserver/plane/app/views/__init__.py index 3d7603e24..bb61aad3a 100644 --- a/apiserver/plane/app/views/__init__.py +++ b/apiserver/plane/app/views/__init__.py @@ -30,7 +30,7 @@ from .user.base import ( from .oauth import OauthEndpoint -from .base import BaseAPIView, BaseViewSet, WebhookMixin +from .base import BaseAPIView, BaseViewSet from .workspace.base import ( WorkSpaceViewSet, diff --git a/apiserver/plane/app/views/base.py b/apiserver/plane/app/views/base.py index 1908cfdc9..c14540945 100644 --- a/apiserver/plane/app/views/base.py +++ b/apiserver/plane/app/views/base.py @@ -19,7 +19,6 @@ from rest_framework.views import APIView from rest_framework.viewsets import ModelViewSet # Module imports -from plane.bgtasks.webhook_task import send_webhook from plane.utils.exception_logger import log_exception from plane.utils.paginator import BasePaginator @@ -38,35 +37,6 @@ class TimezoneMixin: timezone.deactivate() -class WebhookMixin: - webhook_event = None - bulk = False - - def finalize_response(self, request, response, *args, **kwargs): - response = super().finalize_response( - request, response, *args, **kwargs - ) - - # Check for the case should webhook be sent - if ( - self.webhook_event - and self.request.method in ["POST", "PATCH", "DELETE"] - and response.status_code in [200, 201, 204] - ): - # Push the object to delay - send_webhook.delay( - event=self.webhook_event, - payload=response.data, - kw=self.kwargs, - action=self.request.method, - slug=self.workspace_slug, - bulk=self.bulk, - current_site=request.META.get("HTTP_ORIGIN"), - ) - - return response - - class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator): model = None diff --git a/apiserver/plane/app/views/cycle/base.py b/apiserver/plane/app/views/cycle/base.py index dd9826c56..621c1dcb7 100644 --- a/apiserver/plane/app/views/cycle/base.py +++ b/apiserver/plane/app/views/cycle/base.py @@ -20,6 +20,7 @@ from django.db.models import ( ) from django.db.models.functions import Coalesce from django.utils import timezone +from django.core.serializers.json import DjangoJSONEncoder # Third party imports from rest_framework import status @@ -47,10 +48,11 @@ from plane.db.models import ( from plane.utils.analytics_plot import burndown_plot # Module imports -from .. import BaseAPIView, BaseViewSet, WebhookMixin +from .. import BaseAPIView, BaseViewSet +from plane.bgtasks.webhook_task import model_activity -class CycleViewSet(WebhookMixin, BaseViewSet): +class CycleViewSet(BaseViewSet): serializer_class = CycleSerializer model = Cycle webhook_event = "cycle" @@ -412,6 +414,17 @@ class CycleViewSet(WebhookMixin, BaseViewSet): ) .first() ) + + # Send the model activity + model_activity.delay( + model_name="cycle", + model_id=str(cycle["id"]), + requested_data=request.data, + current_instance=None, + actor_id=request.user.id, + slug=slug, + origin=request.META.get("HTTP_ORIGIN"), + ) return Response(cycle, status=status.HTTP_201_CREATED) return Response( serializer.errors, status=status.HTTP_400_BAD_REQUEST @@ -434,6 +447,11 @@ class CycleViewSet(WebhookMixin, BaseViewSet): {"error": "Archived cycle cannot be updated"}, status=status.HTTP_400_BAD_REQUEST, ) + + current_instance = json.dumps( + CycleSerializer(cycle).data, cls=DjangoJSONEncoder + ) + request_data = request.data if ( @@ -487,6 +505,18 @@ class CycleViewSet(WebhookMixin, BaseViewSet): "assignee_ids", "status", ).first() + + # Send the model activity + model_activity.delay( + model_name="cycle", + model_id=str(cycle["id"]), + requested_data=request.data, + current_instance=current_instance, + actor_id=request.user.id, + slug=slug, + origin=request.META.get("HTTP_ORIGIN"), + ) + return Response(cycle, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) diff --git a/apiserver/plane/app/views/cycle/issue.py b/apiserver/plane/app/views/cycle/issue.py index 9a029eb25..fdc998f6d 100644 --- a/apiserver/plane/app/views/cycle/issue.py +++ b/apiserver/plane/app/views/cycle/issue.py @@ -23,7 +23,7 @@ from rest_framework.response import Response from rest_framework import status # Module imports -from .. import BaseViewSet, WebhookMixin +from .. import BaseViewSet from plane.app.serializers import ( IssueSerializer, CycleIssueSerializer, @@ -40,7 +40,7 @@ from plane.bgtasks.issue_activites_task import issue_activity from plane.utils.issue_filters import issue_filters from plane.utils.user_timezone_converter import user_timezone_converter -class CycleIssueViewSet(WebhookMixin, BaseViewSet): +class CycleIssueViewSet(BaseViewSet): serializer_class = CycleIssueSerializer model = CycleIssue @@ -254,6 +254,7 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet): update_cycle_issue_activity = [] # Iterate over each cycle_issue in cycle_issues for cycle_issue in cycle_issues: + old_cycle_id = cycle_issue.cycle_id # Update the cycle_issue's cycle_id cycle_issue.cycle_id = cycle_id # Add the modified cycle_issue to the records_to_update list @@ -261,7 +262,7 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet): # Record the update activity update_cycle_issue_activity.append( { - "old_cycle_id": str(cycle_issue.cycle_id), + "old_cycle_id": str(old_cycle_id), "new_cycle_id": str(cycle_id), "issue_id": str(cycle_issue.issue_id), } diff --git a/apiserver/plane/app/views/inbox/base.py b/apiserver/plane/app/views/inbox/base.py index 8e433a127..d688a8853 100644 --- a/apiserver/plane/app/views/inbox/base.py +++ b/apiserver/plane/app/views/inbox/base.py @@ -251,6 +251,16 @@ class InboxIssueViewSet(BaseViewSet): ) if serializer.is_valid(): serializer.save() + inbox_id = Inbox.objects.filter( + workspace__slug=slug, project_id=project_id + ).first() + # create an inbox issue + inbox_issue = InboxIssue.objects.create( + inbox_id=inbox_id.id, + project_id=project_id, + issue_id=serializer.data["id"], + source=request.data.get("source", "in-app"), + ) # Create an Issue Activity issue_activity.delay( type="issue.activity.created", @@ -262,16 +272,7 @@ class InboxIssueViewSet(BaseViewSet): epoch=int(timezone.now().timestamp()), notification=True, origin=request.META.get("HTTP_ORIGIN"), - ) - inbox_id = Inbox.objects.filter( - workspace__slug=slug, project_id=project_id - ).first() - # create an inbox issue - inbox_issue = InboxIssue.objects.create( - inbox_id=inbox_id.id, - project_id=project_id, - issue_id=serializer.data["id"], - source=request.data.get("source", "in-app"), + inbox=str(inbox_issue.id), ) inbox_issue = ( InboxIssue.objects.select_related("issue") @@ -339,7 +340,24 @@ class InboxIssueViewSet(BaseViewSet): # Get issue data issue_data = request.data.pop("issue", False) if bool(issue_data): - issue = Issue.objects.get( + issue = Issue.objects.annotate( + label_ids=Coalesce( + ArrayAgg( + "labels__id", + distinct=True, + filter=~Q(labels__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + assignee_ids=Coalesce( + ArrayAgg( + "assignees__id", + distinct=True, + filter=~Q(assignees__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + ).get( pk=inbox_issue.issue_id, workspace__slug=slug, project_id=project_id, @@ -379,6 +397,7 @@ class InboxIssueViewSet(BaseViewSet): epoch=int(timezone.now().timestamp()), notification=True, origin=request.META.get("HTTP_ORIGIN"), + inbox=str(inbox_issue.id), ) issue_serializer.save() else: @@ -444,6 +463,7 @@ class InboxIssueViewSet(BaseViewSet): epoch=int(timezone.now().timestamp()), notification=False, origin=request.META.get("HTTP_ORIGIN"), + inbox=(inbox_issue.id), ) inbox_issue = ( @@ -480,7 +500,8 @@ class InboxIssueViewSet(BaseViewSet): output_field=ArrayField(UUIDField()), ), ), - ).first() + ) + .first() ) serializer = InboxIssueDetailSerializer(inbox_issue).data return Response(serializer, status=status.HTTP_200_OK) diff --git a/apiserver/plane/app/views/issue/base.py b/apiserver/plane/app/views/issue/base.py index 7a0e5d9b1..b1fd1a9bc 100644 --- a/apiserver/plane/app/views/issue/base.py +++ b/apiserver/plane/app/views/issue/base.py @@ -53,7 +53,7 @@ from plane.utils.issue_filters import issue_filters from plane.utils.user_timezone_converter import user_timezone_converter # Module imports -from .. import BaseAPIView, BaseViewSet, WebhookMixin +from .. import BaseAPIView, BaseViewSet class IssueListEndpoint(BaseAPIView): @@ -249,7 +249,7 @@ class IssueListEndpoint(BaseAPIView): return Response(issues, status=status.HTTP_200_OK) -class IssueViewSet(WebhookMixin, BaseViewSet): +class IssueViewSet(BaseViewSet): def get_serializer_class(self): return ( IssueCreateSerializer diff --git a/apiserver/plane/app/views/issue/comment.py b/apiserver/plane/app/views/issue/comment.py index 0d61f1325..1698efef8 100644 --- a/apiserver/plane/app/views/issue/comment.py +++ b/apiserver/plane/app/views/issue/comment.py @@ -11,7 +11,7 @@ from rest_framework.response import Response from rest_framework import status # Module imports -from .. import BaseViewSet, WebhookMixin +from .. import BaseViewSet from plane.app.serializers import ( IssueCommentSerializer, CommentReactionSerializer, @@ -25,7 +25,7 @@ from plane.db.models import ( from plane.bgtasks.issue_activites_task import issue_activity -class IssueCommentViewSet(WebhookMixin, BaseViewSet): +class IssueCommentViewSet(BaseViewSet): serializer_class = IssueCommentSerializer model = IssueComment webhook_event = "issue_comment" diff --git a/apiserver/plane/app/views/module/base.py b/apiserver/plane/app/views/module/base.py index 59f26a036..5a987dad8 100644 --- a/apiserver/plane/app/views/module/base.py +++ b/apiserver/plane/app/views/module/base.py @@ -1,6 +1,7 @@ # Python imports import json +# Django Imports from django.contrib.postgres.aggregates import ArrayAgg from django.contrib.postgres.fields import ArrayField from django.db.models import ( @@ -17,14 +18,14 @@ from django.db.models import ( Value, ) from django.db.models.functions import Coalesce - -# Django Imports +from django.core.serializers.json import DjangoJSONEncoder from django.utils import timezone -from rest_framework import status # Third party imports +from rest_framework import status from rest_framework.response import Response +# Module imports from plane.app.permissions import ( ProjectEntityPermission, ProjectLitePermission, @@ -49,13 +50,11 @@ from plane.db.models import ( ) from plane.utils.analytics_plot import burndown_plot from plane.utils.user_timezone_converter import user_timezone_converter +from plane.bgtasks.webhook_task import model_activity +from .. import BaseAPIView, BaseViewSet -# Module imports -from .. import BaseAPIView, BaseViewSet, WebhookMixin - - -class ModuleViewSet(WebhookMixin, BaseViewSet): +class ModuleViewSet(BaseViewSet): model = Module permission_classes = [ ProjectEntityPermission, @@ -238,6 +237,16 @@ class ModuleViewSet(WebhookMixin, BaseViewSet): "updated_at", ) ).first() + # Send the model activity + model_activity.delay( + model_name="module", + model_id=str(module["id"]), + requested_data=request.data, + current_instance=None, + actor_id=request.user.id, + slug=slug, + origin=request.META.get("HTTP_ORIGIN"), + ) datetime_fields = ["created_at", "updated_at"] module = user_timezone_converter( module, datetime_fields, request.user.user_timezone @@ -422,6 +431,9 @@ class ModuleViewSet(WebhookMixin, BaseViewSet): def partial_update(self, request, slug, project_id, pk): module = self.get_queryset().filter(pk=pk) + current_instance = json.dumps( + ModuleSerializer(module).data, cls=DjangoJSONEncoder + ) if module.first().archived_at: return Response( @@ -464,6 +476,18 @@ class ModuleViewSet(WebhookMixin, BaseViewSet): "created_at", "updated_at", ).first() + + # Send the model activity + model_activity.delay( + model_name="module", + model_id=str(module["id"]), + requested_data=request.data, + current_instance=current_instance, + actor_id=request.user.id, + slug=slug, + origin=request.META.get("HTTP_ORIGIN"), + ) + datetime_fields = ["created_at", "updated_at"] module = user_timezone_converter( module, datetime_fields, request.user.user_timezone diff --git a/apiserver/plane/app/views/module/issue.py b/apiserver/plane/app/views/module/issue.py index e0fcb2d3c..3e79e7ec7 100644 --- a/apiserver/plane/app/views/module/issue.py +++ b/apiserver/plane/app/views/module/issue.py @@ -16,7 +16,7 @@ from rest_framework.response import Response from rest_framework import status # Module imports -from .. import BaseViewSet, WebhookMixin +from .. import BaseViewSet from plane.app.serializers import ( ModuleIssueSerializer, IssueSerializer, @@ -33,7 +33,7 @@ from plane.bgtasks.issue_activites_task import issue_activity from plane.utils.issue_filters import issue_filters from plane.utils.user_timezone_converter import user_timezone_converter -class ModuleIssueViewSet(WebhookMixin, BaseViewSet): +class ModuleIssueViewSet(BaseViewSet): serializer_class = ModuleIssueSerializer model = ModuleIssue webhook_event = "module_issue" diff --git a/apiserver/plane/app/views/project/base.py b/apiserver/plane/app/views/project/base.py index d8791ae9b..6017a420f 100644 --- a/apiserver/plane/app/views/project/base.py +++ b/apiserver/plane/app/views/project/base.py @@ -1,5 +1,6 @@ # Python imports import boto3 +import json # Django imports from django.db import IntegrityError @@ -14,6 +15,7 @@ from django.db.models import ( ) from django.conf import settings from django.utils import timezone +from django.core.serializers.json import DjangoJSONEncoder # Third Party imports from rest_framework.response import Response @@ -22,7 +24,7 @@ from rest_framework import serializers from rest_framework.permissions import AllowAny # Module imports -from plane.app.views.base import BaseViewSet, BaseAPIView, WebhookMixin +from plane.app.views.base import BaseViewSet, BaseAPIView from plane.app.serializers import ( ProjectSerializer, ProjectListSerializer, @@ -50,9 +52,10 @@ from plane.db.models import ( Issue, ) from plane.utils.cache import cache_response +from plane.bgtasks.webhook_task import model_activity -class ProjectViewSet(WebhookMixin, BaseViewSet): +class ProjectViewSet(BaseViewSet): serializer_class = ProjectListSerializer model = Project webhook_event = "project" @@ -334,6 +337,17 @@ class ProjectViewSet(WebhookMixin, BaseViewSet): .filter(pk=serializer.data["id"]) .first() ) + + model_activity.delay( + model_name="project", + model_id=str(project.id), + requested_data=request.data, + current_instance=None, + actor_id=request.user.id, + slug=slug, + origin=request.META.get("HTTP_ORIGIN"), + ) + serializer = ProjectListSerializer(project) return Response( serializer.data, status=status.HTTP_201_CREATED @@ -364,7 +378,9 @@ class ProjectViewSet(WebhookMixin, BaseViewSet): workspace = Workspace.objects.get(slug=slug) project = Project.objects.get(pk=pk) - + current_instance = json.dumps( + ProjectSerializer(project).data, cls=DjangoJSONEncoder + ) if project.archived_at: return Response( {"error": "Archived projects cannot be updated"}, @@ -402,6 +418,16 @@ class ProjectViewSet(WebhookMixin, BaseViewSet): .filter(pk=serializer.data["id"]) .first() ) + + model_activity.delay( + model_name="project", + model_id=str(project.id), + requested_data=request.data, + current_instance=current_instance, + actor_id=request.user.id, + slug=slug, + origin=request.META.get("HTTP_ORIGIN"), + ) serializer = ProjectListSerializer(project) return Response(serializer.data, status=status.HTTP_200_OK) return Response( diff --git a/apiserver/plane/bgtasks/issue_activites_task.py b/apiserver/plane/bgtasks/issue_activites_task.py index 2d55d5579..007b3e48c 100644 --- a/apiserver/plane/bgtasks/issue_activites_task.py +++ b/apiserver/plane/bgtasks/issue_activites_task.py @@ -31,6 +31,7 @@ from plane.db.models import ( ) from plane.settings.redis import redis_instance from plane.utils.exception_logger import log_exception +from plane.bgtasks.webhook_task import webhook_activity # Track Changes in name @@ -1296,7 +1297,7 @@ def create_issue_vote_activity( IssueActivity( issue_id=issue_id, actor_id=actor_id, - verb="created", + verb="updated", old_value=None, new_value=requested_data.get("vote"), field="vote", @@ -1365,7 +1366,7 @@ def create_issue_relation_activity( IssueActivity( issue_id=issue_id, actor_id=actor_id, - verb="created", + verb="updated", old_value="", new_value=f"{issue.project.identifier}-{issue.sequence_id}", field=requested_data.get("relation_type"), @@ -1380,7 +1381,7 @@ def create_issue_relation_activity( IssueActivity( issue_id=related_issue, actor_id=actor_id, - verb="created", + verb="updated", old_value="", new_value=f"{issue.project.identifier}-{issue.sequence_id}", field=( @@ -1606,6 +1607,7 @@ def issue_activity( subscriber=True, notification=False, origin=None, + inbox=None, ): try: issue_activities = [] @@ -1692,6 +1694,41 @@ def issue_activity( except Exception as e: log_exception(e) + for activity in issue_activities_created: + webhook_activity.delay( + event=( + "issue_comment" + if activity.field == "comment" + else "inbox_issue" if inbox else "issue" + ), + event_id=( + activity.issue_comment_id + if activity.field == "comment" + else inbox if inbox else activity.issue_id + ), + verb=activity.verb, + field=( + "description" + if activity.field == "comment" + else activity.field + ), + old_value=( + activity.old_value + if activity.old_value != "" + else None + ), + new_value=( + activity.new_value + if activity.new_value != "" + else None + ), + actor_id=activity.actor_id, + current_site=origin, + slug=activity.workspace.slug, + old_identifier=activity.old_identifier, + new_identifier=activity.new_identifier, + ) + if notification: notifications.delay( type=type, diff --git a/apiserver/plane/bgtasks/webhook_task.py b/apiserver/plane/bgtasks/webhook_task.py index 5ee0244c7..d1e1cb34c 100644 --- a/apiserver/plane/bgtasks/webhook_task.py +++ b/apiserver/plane/bgtasks/webhook_task.py @@ -25,6 +25,8 @@ from plane.api.serializers import ( ModuleIssueSerializer, ModuleSerializer, ProjectSerializer, + UserLiteSerializer, + InboxIssueSerializer, ) from plane.db.models import ( Cycle, @@ -37,6 +39,7 @@ from plane.db.models import ( User, Webhook, WebhookLog, + InboxIssue, ) from plane.license.utils.instance_value import get_email_configuration from plane.utils.exception_logger import log_exception @@ -49,6 +52,8 @@ SERIALIZER_MAPPER = { "cycle_issue": CycleIssueSerializer, "module_issue": ModuleIssueSerializer, "issue_comment": IssueCommentSerializer, + "user": UserLiteSerializer, + "inbox_issue": InboxIssueSerializer, } MODEL_MAPPER = { @@ -59,6 +64,8 @@ MODEL_MAPPER = { "cycle_issue": CycleIssue, "module_issue": ModuleIssue, "issue_comment": IssueComment, + "user": User, + "inbox_issue": InboxIssue, } @@ -179,64 +186,6 @@ def webhook_task(self, webhook, slug, event, event_data, action, current_site): return -@shared_task() -def send_webhook(event, payload, kw, action, slug, bulk, current_site): - try: - webhooks = Webhook.objects.filter(workspace__slug=slug, is_active=True) - - if event == "project": - webhooks = webhooks.filter(project=True) - - if event == "issue": - webhooks = webhooks.filter(issue=True) - - if event == "module" or event == "module_issue": - webhooks = webhooks.filter(module=True) - - if event == "cycle" or event == "cycle_issue": - webhooks = webhooks.filter(cycle=True) - - if event == "issue_comment": - webhooks = webhooks.filter(issue_comment=True) - - if webhooks: - if action in ["POST", "PATCH"]: - if bulk and event in ["cycle_issue", "module_issue"]: - return - else: - event_data = [ - get_model_data( - event=event, - event_id=( - payload.get("id") - if isinstance(payload, dict) - else kw.get("pk") - ), - many=False, - ) - ] - - if action == "DELETE": - event_data = [{"id": kw.get("pk")}] - - for webhook in webhooks: - for data in event_data: - webhook_task.delay( - webhook=webhook.id, - slug=slug, - event=event, - event_data=data, - action=action, - current_site=current_site, - ) - - except Exception as e: - if settings.DEBUG: - print(e) - log_exception(e) - return - - @shared_task def send_webhook_deactivation_email( webhook_id, receiver_id, current_site, reason @@ -294,3 +243,240 @@ def send_webhook_deactivation_email( except Exception as e: log_exception(e) return + + +@shared_task( + bind=True, + autoretry_for=(requests.RequestException,), + retry_backoff=600, + max_retries=5, + retry_jitter=True, +) +def webhook_send_task( + self, + webhook, + slug, + event, + event_data, + action, + current_site, + activity, +): + try: + webhook = Webhook.objects.get(id=webhook, workspace__slug=slug) + + headers = { + "Content-Type": "application/json", + "User-Agent": "Autopilot", + "X-Plane-Delivery": str(uuid.uuid4()), + "X-Plane-Event": event, + } + + # # Your secret key + event_data = ( + json.loads(json.dumps(event_data, cls=DjangoJSONEncoder)) + if event_data is not None + else None + ) + + activity = ( + json.loads(json.dumps(activity, cls=DjangoJSONEncoder)) + if activity is not None + else None + ) + + action = { + "POST": "create", + "PATCH": "update", + "PUT": "update", + "DELETE": "delete", + }.get(action, action) + + payload = { + "event": event, + "action": action, + "webhook_id": str(webhook.id), + "workspace_id": str(webhook.workspace_id), + "data": event_data, + "activity": activity, + } + + # Use HMAC for generating signature + if webhook.secret_key: + hmac_signature = hmac.new( + webhook.secret_key.encode("utf-8"), + json.dumps(payload).encode("utf-8"), + hashlib.sha256, + ) + signature = hmac_signature.hexdigest() + headers["X-Plane-Signature"] = signature + + # Send the webhook event + response = requests.post( + webhook.url, + headers=headers, + json=payload, + timeout=30, + ) + + # Log the webhook request + WebhookLog.objects.create( + workspace_id=str(webhook.workspace_id), + webhook_id=str(webhook.id), + event_type=str(event), + request_method=str(action), + request_headers=str(headers), + request_body=str(payload), + response_status=str(response.status_code), + response_headers=str(response.headers), + response_body=str(response.text), + retry_count=str(self.request.retries), + ) + + except requests.RequestException as e: + # Log the failed webhook request + WebhookLog.objects.create( + workspace_id=str(webhook.workspace_id), + webhook_id=str(webhook.id), + event_type=str(event), + request_method=str(action), + request_headers=str(headers), + request_body=str(payload), + response_status=500, + response_headers="", + response_body=str(e), + retry_count=str(self.request.retries), + ) + # Retry logic + if self.request.retries >= self.max_retries: + Webhook.objects.filter(pk=webhook.id).update(is_active=False) + if webhook: + # send email for the deactivation of the webhook + send_webhook_deactivation_email( + webhook_id=webhook.id, + receiver_id=webhook.created_by_id, + reason=str(e), + current_site=current_site, + ) + return + raise requests.RequestException() + + except Exception as e: + if settings.DEBUG: + print(e) + log_exception(e) + return + + +@shared_task +def webhook_activity( + event, + verb, + field, + old_value, + new_value, + actor_id, + slug, + current_site, + event_id, + old_identifier, + new_identifier, +): + try: + webhooks = Webhook.objects.filter(workspace__slug=slug, is_active=True) + + if event == "project": + webhooks = webhooks.filter(project=True) + + if event == "issue": + webhooks = webhooks.filter(issue=True) + + if event == "module" or event == "module_issue": + webhooks = webhooks.filter(module=True) + + if event == "cycle" or event == "cycle_issue": + webhooks = webhooks.filter(cycle=True) + + if event == "issue_comment": + webhooks = webhooks.filter(issue_comment=True) + + for webhook in webhooks: + webhook_send_task.delay( + webhook=webhook.id, + slug=slug, + event=event, + event_data=get_model_data( + event=event, + event_id=event_id, + ), + action=verb, + current_site=current_site, + activity={ + "field": field, + "new_value": new_value, + "old_value": old_value, + "actor": get_model_data(event="user", event_id=actor_id), + "old_identifier": old_identifier, + "new_identifier": new_identifier, + }, + ) + return + except Exception as e: + if settings.DEBUG: + print(e) + log_exception(e) + return + + +@shared_task +def model_activity( + model_name, + model_id, + requested_data, + current_instance, + actor_id, + slug, + origin=None, +): + """Function takes in two json and computes differences between keys of both the json""" + if current_instance is None: + webhook_activity.delay( + event=model_name, + verb="created", + field=None, + old_value=None, + new_value=None, + actor_id=actor_id, + slug=slug, + current_site=origin, + event_id=model_id, + old_identifier=None, + new_identifier=None, + ) + return + + # Load the current instance + current_instance = ( + json.loads(current_instance) if current_instance is not None else None + ) + + # Loop through all keys in requested data and check the current value and requested value + for key in requested_data: + current_value = current_instance.get(key, None) + requested_value = requested_data.get(key, None) + if current_value != requested_value: + webhook_activity.delay( + event=model_name, + verb="updated", + field=key, + old_value=current_value, + new_value=requested_value, + actor_id=actor_id, + slug=slug, + current_site=origin, + event_id=model_id, + old_identifier=None, + new_identifier=None, + ) + + return