[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
This commit is contained in:
Nikhil 2024-05-06 14:13:49 +05:30 committed by GitHub
parent fb74875cde
commit f1fda4ae4a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 545 additions and 191 deletions

View File

@ -1,9 +1,13 @@
# Module improts # Module improts
from .base import BaseSerializer from .base import BaseSerializer
from .issue import IssueExpandSerializer
from plane.db.models import InboxIssue from plane.db.models import InboxIssue
class InboxIssueSerializer(BaseSerializer): class InboxIssueSerializer(BaseSerializer):
issue_detail = IssueExpandSerializer(read_only=True, source="issue")
class Meta: class Meta:
model = InboxIssue model = InboxIssue
fields = "__all__" fields = "__all__"

View File

@ -19,7 +19,6 @@ from rest_framework.views import APIView
# Module imports # Module imports
from plane.api.middleware.api_authentication import APIKeyAuthentication from plane.api.middleware.api_authentication import APIKeyAuthentication
from plane.api.rate_limit import ApiKeyRateThrottle 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.exception_logger import log_exception
from plane.utils.paginator import BasePaginator from plane.utils.paginator import BasePaginator
@ -38,40 +37,6 @@ class TimezoneMixin:
timezone.deactivate() 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): class BaseAPIView(TimezoneMixin, APIView, BasePaginator):
authentication_classes = [ authentication_classes = [
APIKeyAuthentication, APIKeyAuthentication,

View File

@ -5,6 +5,7 @@ import json
from django.core import serializers from django.core import serializers
from django.db.models import Count, F, Func, OuterRef, Q, Sum from django.db.models import Count, F, Func, OuterRef, Q, Sum
from django.utils import timezone from django.utils import timezone
from django.core.serializers.json import DjangoJSONEncoder
# Third party imports # Third party imports
from rest_framework import status from rest_framework import status
@ -26,10 +27,11 @@ from plane.db.models import (
) )
from plane.utils.analytics_plot import burndown_plot 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`, This viewset automatically provides `list`, `create`, `retrieve`,
`update` and `destroy` actions related to cycle. `update` and `destroy` actions related to cycle.
@ -277,6 +279,16 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView):
project_id=project_id, project_id=project_id,
owned_by=request.user, 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( return Response(
serializer.data, status=status.HTTP_201_CREATED serializer.data, status=status.HTTP_201_CREATED
) )
@ -295,6 +307,11 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView):
cycle = Cycle.objects.get( cycle = Cycle.objects.get(
workspace__slug=slug, project_id=project_id, pk=pk workspace__slug=slug, project_id=project_id, pk=pk
) )
current_instance = json.dumps(
CycleSerializer(cycle).data, cls=DjangoJSONEncoder
)
if cycle.archived_at: if cycle.archived_at:
return Response( return Response(
{"error": "Archived cycle cannot be edited"}, {"error": "Archived cycle cannot be edited"},
@ -344,6 +361,17 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView):
status=status.HTTP_409_CONFLICT, status=status.HTTP_409_CONFLICT,
) )
serializer.save() 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.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)
@ -515,7 +543,7 @@ class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView):
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)
class CycleIssueAPIEndpoint(WebhookMixin, BaseAPIView): class CycleIssueAPIEndpoint(BaseAPIView):
""" """
This viewset automatically provides `list`, `create`, This viewset automatically provides `list`, `create`,
and `destroy` actions related to cycle issues. and `destroy` actions related to cycle issues.

View File

@ -154,6 +154,13 @@ class InboxIssueAPIEndpoint(BaseAPIView):
state=state, 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 # Create an Issue Activity
issue_activity.delay( issue_activity.delay(
type="issue.activity.created", type="issue.activity.created",
@ -163,14 +170,7 @@ class InboxIssueAPIEndpoint(BaseAPIView):
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()),
) inbox=str(inbox_issue.id),
# 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"),
) )
serializer = InboxIssueSerializer(inbox_issue) serializer = InboxIssueSerializer(inbox_issue)
@ -260,6 +260,7 @@ class InboxIssueAPIEndpoint(BaseAPIView):
cls=DjangoJSONEncoder, cls=DjangoJSONEncoder,
), ),
epoch=int(timezone.now().timestamp()), epoch=int(timezone.now().timestamp()),
inbox=(inbox_issue.id),
) )
issue_serializer.save() issue_serializer.save()
else: else:
@ -327,6 +328,7 @@ class InboxIssueAPIEndpoint(BaseAPIView):
epoch=int(timezone.now().timestamp()), epoch=int(timezone.now().timestamp()),
notification=False, notification=False,
origin=request.META.get("HTTP_ORIGIN"), origin=request.META.get("HTTP_ORIGIN"),
inbox=str(inbox_issue.id),
) )
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)

View File

@ -48,11 +48,10 @@ from plane.db.models import (
ProjectMember, ProjectMember,
) )
from .base import BaseAPIView, WebhookMixin from .base import BaseAPIView
class WorkspaceIssueAPIEndpoint(BaseAPIView):
class WorkspaceIssueAPIEndpoint(WebhookMixin, BaseAPIView):
""" """
This viewset provides `retrieveByIssueId` on workspace level This viewset provides `retrieveByIssueId` on workspace level
@ -60,12 +59,9 @@ class WorkspaceIssueAPIEndpoint(WebhookMixin, BaseAPIView):
model = Issue model = Issue
webhook_event = "issue" webhook_event = "issue"
permission_classes = [ permission_classes = [ProjectEntityPermission]
ProjectEntityPermission
]
serializer_class = IssueSerializer serializer_class = IssueSerializer
@property @property
def project__identifier(self): def project__identifier(self):
return self.kwargs.get("project__identifier", None) return self.kwargs.get("project__identifier", None)
@ -91,7 +87,9 @@ class WorkspaceIssueAPIEndpoint(WebhookMixin, BaseAPIView):
.order_by(self.kwargs.get("order_by", "-created_at")) .order_by(self.kwargs.get("order_by", "-created_at"))
).distinct() ).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: if issue__identifier and project__identifier:
issue = Issue.issue_objects.annotate( issue = Issue.issue_objects.annotate(
sub_issues_count=Issue.issue_objects.filter( sub_issues_count=Issue.issue_objects.filter(
@ -100,7 +98,11 @@ class WorkspaceIssueAPIEndpoint(WebhookMixin, BaseAPIView):
.order_by() .order_by()
.annotate(count=Func(F("id"), function="Count")) .annotate(count=Func(F("id"), function="Count"))
.values("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( return Response(
IssueSerializer( IssueSerializer(
issue, issue,
@ -110,7 +112,8 @@ class WorkspaceIssueAPIEndpoint(WebhookMixin, BaseAPIView):
status=status.HTTP_200_OK, status=status.HTTP_200_OK,
) )
class IssueAPIEndpoint(WebhookMixin, BaseAPIView):
class IssueAPIEndpoint(BaseAPIView):
""" """
This viewset automatically provides `list`, `create`, `retrieve`, This viewset automatically provides `list`, `create`, `retrieve`,
`update` and `destroy` actions related to issue. `update` and `destroy` actions related to issue.
@ -652,7 +655,7 @@ class IssueLinkAPIEndpoint(BaseAPIView):
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)
class IssueCommentAPIEndpoint(WebhookMixin, BaseAPIView): class IssueCommentAPIEndpoint(BaseAPIView):
""" """
This viewset automatically provides `list`, `create`, `retrieve`, This viewset automatically provides `list`, `create`, `retrieve`,
`update` and `destroy` actions related to comments of the particular issue. `update` and `destroy` actions related to comments of the particular issue.

View File

@ -5,6 +5,7 @@ import json
from django.core import serializers from django.core import serializers
from django.db.models import Count, F, Func, OuterRef, Prefetch, Q from django.db.models import Count, F, Func, OuterRef, Prefetch, Q
from django.utils import timezone from django.utils import timezone
from django.core.serializers.json import DjangoJSONEncoder
# Third party imports # Third party imports
from rest_framework import status from rest_framework import status
@ -28,10 +29,11 @@ from plane.db.models import (
Project, 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`, This viewset automatically provides `list`, `create`, `retrieve`,
`update` and `destroy` actions related to module. `update` and `destroy` actions related to module.
@ -163,6 +165,16 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView):
status=status.HTTP_409_CONFLICT, status=status.HTTP_409_CONFLICT,
) )
serializer.save() 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"]) module = Module.objects.get(pk=serializer.data["id"])
serializer = ModuleSerializer(module) serializer = ModuleSerializer(module)
return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.data, status=status.HTTP_201_CREATED)
@ -172,6 +184,11 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView):
module = Module.objects.get( module = Module.objects.get(
pk=pk, project_id=project_id, workspace__slug=slug pk=pk, project_id=project_id, workspace__slug=slug
) )
current_instance = json.dumps(
ModuleSerializer(module).data, cls=DjangoJSONEncoder
)
if module.archived_at: if module.archived_at:
return Response( return Response(
{"error": "Archived module cannot be edited"}, {"error": "Archived module cannot be edited"},
@ -204,6 +221,18 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView):
status=status.HTTP_409_CONFLICT, status=status.HTTP_409_CONFLICT,
) )
serializer.save() 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.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)
@ -260,7 +289,7 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView):
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)
class ModuleIssueAPIEndpoint(WebhookMixin, BaseAPIView): class ModuleIssueAPIEndpoint(BaseAPIView):
""" """
This viewset automatically provides `list`, `create`, `retrieve`, This viewset automatically provides `list`, `create`, `retrieve`,
`update` and `destroy` actions related to module issues. `update` and `destroy` actions related to module issues.

View File

@ -1,7 +1,11 @@
# Python imports
import json
# Django imports # Django imports
from django.db import IntegrityError from django.db import IntegrityError
from django.db.models import Exists, F, Func, OuterRef, Prefetch, Q, Subquery from django.db.models import Exists, F, Func, OuterRef, Prefetch, Q, Subquery
from django.utils import timezone from django.utils import timezone
from django.core.serializers.json import DjangoJSONEncoder
# Third party imports # Third party imports
from rest_framework import status from rest_framework import status
@ -23,11 +27,11 @@ from plane.db.models import (
State, State,
Workspace, Workspace,
) )
from plane.bgtasks.webhook_task import model_activity
from .base import BaseAPIView, WebhookMixin from .base import BaseAPIView
class ProjectAPIEndpoint(WebhookMixin, BaseAPIView): class ProjectAPIEndpoint(BaseAPIView):
"""Project Endpoints to create, update, list, retrieve and delete endpoint""" """Project Endpoints to create, update, list, retrieve and delete endpoint"""
serializer_class = ProjectSerializer serializer_class = ProjectSerializer
@ -236,6 +240,17 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView):
.filter(pk=serializer.data["id"]) .filter(pk=serializer.data["id"])
.first() .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) serializer = ProjectSerializer(project)
return Response( return Response(
serializer.data, status=status.HTTP_201_CREATED serializer.data, status=status.HTTP_201_CREATED
@ -265,7 +280,9 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView):
try: try:
workspace = Workspace.objects.get(slug=slug) workspace = Workspace.objects.get(slug=slug)
project = Project.objects.get(pk=pk) project = Project.objects.get(pk=pk)
current_instance = json.dumps(
ProjectSerializer(project).data, cls=DjangoJSONEncoder
)
if project.archived_at: if project.archived_at:
return Response( return Response(
{"error": "Archived project cannot be updated"}, {"error": "Archived project cannot be updated"},
@ -303,6 +320,17 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView):
.filter(pk=serializer.data["id"]) .filter(pk=serializer.data["id"])
.first() .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) serializer = ProjectSerializer(project)
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)
return Response( return Response(

View File

@ -442,7 +442,7 @@ class IssueLinkSerializer(BaseSerializer):
raise serializers.ValidationError("Invalid URL format.") raise serializers.ValidationError("Invalid URL format.")
# Check URL scheme # Check URL scheme
if not value.startswith(('http://', 'https://')): if not value.startswith(("http://", "https://")):
raise serializers.ValidationError("Invalid URL scheme.") raise serializers.ValidationError("Invalid URL scheme.")
return value return value

View File

@ -30,7 +30,7 @@ from .user.base import (
from .oauth import OauthEndpoint from .oauth import OauthEndpoint
from .base import BaseAPIView, BaseViewSet, WebhookMixin from .base import BaseAPIView, BaseViewSet
from .workspace.base import ( from .workspace.base import (
WorkSpaceViewSet, WorkSpaceViewSet,

View File

@ -19,7 +19,6 @@ from rest_framework.views import APIView
from rest_framework.viewsets import ModelViewSet from rest_framework.viewsets import ModelViewSet
# Module imports # Module imports
from plane.bgtasks.webhook_task import send_webhook
from plane.utils.exception_logger import log_exception from plane.utils.exception_logger import log_exception
from plane.utils.paginator import BasePaginator from plane.utils.paginator import BasePaginator
@ -38,35 +37,6 @@ class TimezoneMixin:
timezone.deactivate() 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): class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator):
model = None model = None

View File

@ -20,6 +20,7 @@ from django.db.models import (
) )
from django.db.models.functions import Coalesce from django.db.models.functions import Coalesce
from django.utils import timezone from django.utils import timezone
from django.core.serializers.json import DjangoJSONEncoder
# Third party imports # Third party imports
from rest_framework import status from rest_framework import status
@ -47,10 +48,11 @@ from plane.db.models import (
from plane.utils.analytics_plot import burndown_plot from plane.utils.analytics_plot import burndown_plot
# Module imports # 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 serializer_class = CycleSerializer
model = Cycle model = Cycle
webhook_event = "cycle" webhook_event = "cycle"
@ -412,6 +414,17 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
) )
.first() .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(cycle, status=status.HTTP_201_CREATED)
return Response( return Response(
serializer.errors, status=status.HTTP_400_BAD_REQUEST serializer.errors, status=status.HTTP_400_BAD_REQUEST
@ -434,6 +447,11 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
{"error": "Archived cycle cannot be updated"}, {"error": "Archived cycle cannot be updated"},
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
current_instance = json.dumps(
CycleSerializer(cycle).data, cls=DjangoJSONEncoder
)
request_data = request.data request_data = request.data
if ( if (
@ -487,6 +505,18 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
"assignee_ids", "assignee_ids",
"status", "status",
).first() ).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(cycle, 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)

View File

@ -23,7 +23,7 @@ from rest_framework.response import Response
from rest_framework import status from rest_framework import status
# Module imports # Module imports
from .. import BaseViewSet, WebhookMixin from .. import BaseViewSet
from plane.app.serializers import ( from plane.app.serializers import (
IssueSerializer, IssueSerializer,
CycleIssueSerializer, 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.issue_filters import issue_filters
from plane.utils.user_timezone_converter import user_timezone_converter from plane.utils.user_timezone_converter import user_timezone_converter
class CycleIssueViewSet(WebhookMixin, BaseViewSet): class CycleIssueViewSet(BaseViewSet):
serializer_class = CycleIssueSerializer serializer_class = CycleIssueSerializer
model = CycleIssue model = CycleIssue
@ -254,6 +254,7 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet):
update_cycle_issue_activity = [] update_cycle_issue_activity = []
# Iterate over each cycle_issue in cycle_issues # Iterate over each cycle_issue in cycle_issues
for 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 # Update the cycle_issue's cycle_id
cycle_issue.cycle_id = cycle_id cycle_issue.cycle_id = cycle_id
# Add the modified cycle_issue to the records_to_update list # Add the modified cycle_issue to the records_to_update list
@ -261,7 +262,7 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet):
# Record the update activity # Record the update activity
update_cycle_issue_activity.append( 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), "new_cycle_id": str(cycle_id),
"issue_id": str(cycle_issue.issue_id), "issue_id": str(cycle_issue.issue_id),
} }

View File

@ -251,6 +251,16 @@ class InboxIssueViewSet(BaseViewSet):
) )
if serializer.is_valid(): if serializer.is_valid():
serializer.save() 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 # Create an Issue Activity
issue_activity.delay( issue_activity.delay(
type="issue.activity.created", type="issue.activity.created",
@ -262,16 +272,7 @@ class InboxIssueViewSet(BaseViewSet):
epoch=int(timezone.now().timestamp()), epoch=int(timezone.now().timestamp()),
notification=True, notification=True,
origin=request.META.get("HTTP_ORIGIN"), origin=request.META.get("HTTP_ORIGIN"),
) inbox=str(inbox_issue.id),
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_issue = ( inbox_issue = (
InboxIssue.objects.select_related("issue") InboxIssue.objects.select_related("issue")
@ -339,7 +340,24 @@ class InboxIssueViewSet(BaseViewSet):
# Get issue data # Get issue data
issue_data = request.data.pop("issue", False) issue_data = request.data.pop("issue", False)
if bool(issue_data): 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, pk=inbox_issue.issue_id,
workspace__slug=slug, workspace__slug=slug,
project_id=project_id, project_id=project_id,
@ -379,6 +397,7 @@ class InboxIssueViewSet(BaseViewSet):
epoch=int(timezone.now().timestamp()), epoch=int(timezone.now().timestamp()),
notification=True, notification=True,
origin=request.META.get("HTTP_ORIGIN"), origin=request.META.get("HTTP_ORIGIN"),
inbox=str(inbox_issue.id),
) )
issue_serializer.save() issue_serializer.save()
else: else:
@ -444,6 +463,7 @@ class InboxIssueViewSet(BaseViewSet):
epoch=int(timezone.now().timestamp()), epoch=int(timezone.now().timestamp()),
notification=False, notification=False,
origin=request.META.get("HTTP_ORIGIN"), origin=request.META.get("HTTP_ORIGIN"),
inbox=(inbox_issue.id),
) )
inbox_issue = ( inbox_issue = (
@ -480,7 +500,8 @@ class InboxIssueViewSet(BaseViewSet):
output_field=ArrayField(UUIDField()), output_field=ArrayField(UUIDField()),
), ),
), ),
).first() )
.first()
) )
serializer = InboxIssueDetailSerializer(inbox_issue).data serializer = InboxIssueDetailSerializer(inbox_issue).data
return Response(serializer, status=status.HTTP_200_OK) return Response(serializer, status=status.HTTP_200_OK)

View File

@ -53,7 +53,7 @@ from plane.utils.issue_filters import issue_filters
from plane.utils.user_timezone_converter import user_timezone_converter from plane.utils.user_timezone_converter import user_timezone_converter
# Module imports # Module imports
from .. import BaseAPIView, BaseViewSet, WebhookMixin from .. import BaseAPIView, BaseViewSet
class IssueListEndpoint(BaseAPIView): class IssueListEndpoint(BaseAPIView):
@ -249,7 +249,7 @@ class IssueListEndpoint(BaseAPIView):
return Response(issues, status=status.HTTP_200_OK) return Response(issues, status=status.HTTP_200_OK)
class IssueViewSet(WebhookMixin, BaseViewSet): class IssueViewSet(BaseViewSet):
def get_serializer_class(self): def get_serializer_class(self):
return ( return (
IssueCreateSerializer IssueCreateSerializer

View File

@ -11,7 +11,7 @@ from rest_framework.response import Response
from rest_framework import status from rest_framework import status
# Module imports # Module imports
from .. import BaseViewSet, WebhookMixin from .. import BaseViewSet
from plane.app.serializers import ( from plane.app.serializers import (
IssueCommentSerializer, IssueCommentSerializer,
CommentReactionSerializer, CommentReactionSerializer,
@ -25,7 +25,7 @@ from plane.db.models import (
from plane.bgtasks.issue_activites_task import issue_activity from plane.bgtasks.issue_activites_task import issue_activity
class IssueCommentViewSet(WebhookMixin, BaseViewSet): class IssueCommentViewSet(BaseViewSet):
serializer_class = IssueCommentSerializer serializer_class = IssueCommentSerializer
model = IssueComment model = IssueComment
webhook_event = "issue_comment" webhook_event = "issue_comment"

View File

@ -1,6 +1,7 @@
# Python imports # Python imports
import json import json
# Django Imports
from django.contrib.postgres.aggregates import ArrayAgg from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField from django.contrib.postgres.fields import ArrayField
from django.db.models import ( from django.db.models import (
@ -17,14 +18,14 @@ from django.db.models import (
Value, Value,
) )
from django.db.models.functions import Coalesce from django.db.models.functions import Coalesce
from django.core.serializers.json import DjangoJSONEncoder
# Django Imports
from django.utils import timezone from django.utils import timezone
from rest_framework import status
# Third party imports # Third party imports
from rest_framework import status
from rest_framework.response import Response from rest_framework.response import Response
# Module imports
from plane.app.permissions import ( from plane.app.permissions import (
ProjectEntityPermission, ProjectEntityPermission,
ProjectLitePermission, ProjectLitePermission,
@ -49,13 +50,11 @@ from plane.db.models import (
) )
from plane.utils.analytics_plot import burndown_plot from plane.utils.analytics_plot import burndown_plot
from plane.utils.user_timezone_converter import user_timezone_converter from plane.utils.user_timezone_converter import user_timezone_converter
from plane.bgtasks.webhook_task import model_activity
from .. import BaseAPIView, BaseViewSet
# Module imports class ModuleViewSet(BaseViewSet):
from .. import BaseAPIView, BaseViewSet, WebhookMixin
class ModuleViewSet(WebhookMixin, BaseViewSet):
model = Module model = Module
permission_classes = [ permission_classes = [
ProjectEntityPermission, ProjectEntityPermission,
@ -238,6 +237,16 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
"updated_at", "updated_at",
) )
).first() ).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"] datetime_fields = ["created_at", "updated_at"]
module = user_timezone_converter( module = user_timezone_converter(
module, datetime_fields, request.user.user_timezone module, datetime_fields, request.user.user_timezone
@ -422,6 +431,9 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
def partial_update(self, request, slug, project_id, pk): def partial_update(self, request, slug, project_id, pk):
module = self.get_queryset().filter(pk=pk) module = self.get_queryset().filter(pk=pk)
current_instance = json.dumps(
ModuleSerializer(module).data, cls=DjangoJSONEncoder
)
if module.first().archived_at: if module.first().archived_at:
return Response( return Response(
@ -464,6 +476,18 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
"created_at", "created_at",
"updated_at", "updated_at",
).first() ).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"] datetime_fields = ["created_at", "updated_at"]
module = user_timezone_converter( module = user_timezone_converter(
module, datetime_fields, request.user.user_timezone module, datetime_fields, request.user.user_timezone

View File

@ -16,7 +16,7 @@ from rest_framework.response import Response
from rest_framework import status from rest_framework import status
# Module imports # Module imports
from .. import BaseViewSet, WebhookMixin from .. import BaseViewSet
from plane.app.serializers import ( from plane.app.serializers import (
ModuleIssueSerializer, ModuleIssueSerializer,
IssueSerializer, 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.issue_filters import issue_filters
from plane.utils.user_timezone_converter import user_timezone_converter from plane.utils.user_timezone_converter import user_timezone_converter
class ModuleIssueViewSet(WebhookMixin, BaseViewSet): class ModuleIssueViewSet(BaseViewSet):
serializer_class = ModuleIssueSerializer serializer_class = ModuleIssueSerializer
model = ModuleIssue model = ModuleIssue
webhook_event = "module_issue" webhook_event = "module_issue"

View File

@ -1,5 +1,6 @@
# Python imports # Python imports
import boto3 import boto3
import json
# Django imports # Django imports
from django.db import IntegrityError from django.db import IntegrityError
@ -14,6 +15,7 @@ from django.db.models import (
) )
from django.conf import settings from django.conf import settings
from django.utils import timezone from django.utils import timezone
from django.core.serializers.json import DjangoJSONEncoder
# Third Party imports # Third Party imports
from rest_framework.response import Response from rest_framework.response import Response
@ -22,7 +24,7 @@ from rest_framework import serializers
from rest_framework.permissions import AllowAny from rest_framework.permissions import AllowAny
# Module imports # Module imports
from plane.app.views.base import BaseViewSet, BaseAPIView, WebhookMixin from plane.app.views.base import BaseViewSet, BaseAPIView
from plane.app.serializers import ( from plane.app.serializers import (
ProjectSerializer, ProjectSerializer,
ProjectListSerializer, ProjectListSerializer,
@ -50,9 +52,10 @@ from plane.db.models import (
Issue, Issue,
) )
from plane.utils.cache import cache_response 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 serializer_class = ProjectListSerializer
model = Project model = Project
webhook_event = "project" webhook_event = "project"
@ -334,6 +337,17 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
.filter(pk=serializer.data["id"]) .filter(pk=serializer.data["id"])
.first() .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) serializer = ProjectListSerializer(project)
return Response( return Response(
serializer.data, status=status.HTTP_201_CREATED serializer.data, status=status.HTTP_201_CREATED
@ -364,7 +378,9 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
workspace = Workspace.objects.get(slug=slug) workspace = Workspace.objects.get(slug=slug)
project = Project.objects.get(pk=pk) project = Project.objects.get(pk=pk)
current_instance = json.dumps(
ProjectSerializer(project).data, cls=DjangoJSONEncoder
)
if project.archived_at: if project.archived_at:
return Response( return Response(
{"error": "Archived projects cannot be updated"}, {"error": "Archived projects cannot be updated"},
@ -402,6 +418,16 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
.filter(pk=serializer.data["id"]) .filter(pk=serializer.data["id"])
.first() .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) serializer = ProjectListSerializer(project)
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)
return Response( return Response(

View File

@ -31,6 +31,7 @@ from plane.db.models import (
) )
from plane.settings.redis import redis_instance from plane.settings.redis import redis_instance
from plane.utils.exception_logger import log_exception from plane.utils.exception_logger import log_exception
from plane.bgtasks.webhook_task import webhook_activity
# Track Changes in name # Track Changes in name
@ -1296,7 +1297,7 @@ def create_issue_vote_activity(
IssueActivity( IssueActivity(
issue_id=issue_id, issue_id=issue_id,
actor_id=actor_id, actor_id=actor_id,
verb="created", verb="updated",
old_value=None, old_value=None,
new_value=requested_data.get("vote"), new_value=requested_data.get("vote"),
field="vote", field="vote",
@ -1365,7 +1366,7 @@ def create_issue_relation_activity(
IssueActivity( IssueActivity(
issue_id=issue_id, issue_id=issue_id,
actor_id=actor_id, actor_id=actor_id,
verb="created", verb="updated",
old_value="", old_value="",
new_value=f"{issue.project.identifier}-{issue.sequence_id}", new_value=f"{issue.project.identifier}-{issue.sequence_id}",
field=requested_data.get("relation_type"), field=requested_data.get("relation_type"),
@ -1380,7 +1381,7 @@ def create_issue_relation_activity(
IssueActivity( IssueActivity(
issue_id=related_issue, issue_id=related_issue,
actor_id=actor_id, actor_id=actor_id,
verb="created", verb="updated",
old_value="", old_value="",
new_value=f"{issue.project.identifier}-{issue.sequence_id}", new_value=f"{issue.project.identifier}-{issue.sequence_id}",
field=( field=(
@ -1606,6 +1607,7 @@ def issue_activity(
subscriber=True, subscriber=True,
notification=False, notification=False,
origin=None, origin=None,
inbox=None,
): ):
try: try:
issue_activities = [] issue_activities = []
@ -1692,6 +1694,41 @@ def issue_activity(
except Exception as e: except Exception as e:
log_exception(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: if notification:
notifications.delay( notifications.delay(
type=type, type=type,

View File

@ -25,6 +25,8 @@ from plane.api.serializers import (
ModuleIssueSerializer, ModuleIssueSerializer,
ModuleSerializer, ModuleSerializer,
ProjectSerializer, ProjectSerializer,
UserLiteSerializer,
InboxIssueSerializer,
) )
from plane.db.models import ( from plane.db.models import (
Cycle, Cycle,
@ -37,6 +39,7 @@ from plane.db.models import (
User, User,
Webhook, Webhook,
WebhookLog, WebhookLog,
InboxIssue,
) )
from plane.license.utils.instance_value import get_email_configuration from plane.license.utils.instance_value import get_email_configuration
from plane.utils.exception_logger import log_exception from plane.utils.exception_logger import log_exception
@ -49,6 +52,8 @@ SERIALIZER_MAPPER = {
"cycle_issue": CycleIssueSerializer, "cycle_issue": CycleIssueSerializer,
"module_issue": ModuleIssueSerializer, "module_issue": ModuleIssueSerializer,
"issue_comment": IssueCommentSerializer, "issue_comment": IssueCommentSerializer,
"user": UserLiteSerializer,
"inbox_issue": InboxIssueSerializer,
} }
MODEL_MAPPER = { MODEL_MAPPER = {
@ -59,6 +64,8 @@ MODEL_MAPPER = {
"cycle_issue": CycleIssue, "cycle_issue": CycleIssue,
"module_issue": ModuleIssue, "module_issue": ModuleIssue,
"issue_comment": IssueComment, "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 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 @shared_task
def send_webhook_deactivation_email( def send_webhook_deactivation_email(
webhook_id, receiver_id, current_site, reason webhook_id, receiver_id, current_site, reason
@ -294,3 +243,240 @@ def send_webhook_deactivation_email(
except Exception as e: except Exception as e:
log_exception(e) log_exception(e)
return 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