From a6d5eab6349194a0f1291f23d6aafd92b473a047 Mon Sep 17 00:00:00 2001 From: Nikhil <118773738+pablohashescobar@users.noreply.github.com> Date: Fri, 24 Nov 2023 12:19:26 +0530 Subject: [PATCH] chore: api and webhook refactor (#2861) * chore: bug fix * dev: changes in api endpoints for invitations and inbox * chore: improvements * dev: update webhook send * dev: webhook validation and fix webhook flow for app * dev: error messages for deactivation * chore: api fixes * dev: update webhook and workspace leave * chore: issue comment * dev: default values for environment variables * dev: make the user active if he was already part of project member * chore: webhook cycle and module event * dev: disable ssl for emails * dev: webhooks restructuring * dev: updated webhook configuration * dev: webhooks * dev: state get object * dev: update workspace slug validation * dev: remove deactivation flag if max retries exceeded --------- Co-authored-by: NarayanBavisetti --- apiserver/plane/api/serializers/__init__.py | 5 +- apiserver/plane/api/serializers/cycle.py | 9 +- apiserver/plane/api/serializers/inbox.py | 8 +- apiserver/plane/api/serializers/issue.py | 85 ++++++++---- apiserver/plane/api/serializers/module.py | 11 +- apiserver/plane/api/serializers/project.py | 6 +- apiserver/plane/api/urls/cycle.py | 4 +- apiserver/plane/api/urls/inbox.py | 4 +- apiserver/plane/api/urls/issue.py | 14 +- apiserver/plane/api/urls/module.py | 2 +- apiserver/plane/api/urls/project.py | 2 +- apiserver/plane/api/urls/state.py | 5 + apiserver/plane/api/views/base.py | 20 +-- apiserver/plane/api/views/cycle.py | 19 ++- apiserver/plane/api/views/inbox.py | 121 ++++++++++++++--- apiserver/plane/api/views/issue.py | 81 +++++------ apiserver/plane/api/views/module.py | 21 ++- apiserver/plane/api/views/project.py | 27 ++-- apiserver/plane/api/views/state.py | 22 ++- apiserver/plane/app/serializers/webhook.py | 42 ++++++ apiserver/plane/app/serializers/workspace.py | 16 +++ apiserver/plane/app/urls/project.py | 4 +- apiserver/plane/app/views/base.py | 7 +- apiserver/plane/app/views/config.py | 1 + apiserver/plane/app/views/cycle.py | 9 +- apiserver/plane/app/views/external.py | 22 +-- apiserver/plane/app/views/issue.py | 2 +- apiserver/plane/app/views/module.py | 9 +- apiserver/plane/app/views/project.py | 13 +- apiserver/plane/app/views/user.py | 2 +- apiserver/plane/app/views/webhook.py | 6 +- apiserver/plane/app/views/workspace.py | 8 +- .../plane/bgtasks/analytic_plot_export.py | 50 +++++-- .../plane/bgtasks/email_verification_task.py | 53 ++++++-- .../plane/bgtasks/forgot_password_task.py | 49 +++++-- .../plane/bgtasks/magic_link_code_task.py | 51 +++++-- apiserver/plane/bgtasks/notification_task.py | 1 + .../plane/bgtasks/project_invitation_task.py | 50 +++++-- apiserver/plane/bgtasks/webhook_task.py | 126 +++++++++++++++--- .../bgtasks/workspace_invitation_task.py | 40 ++++-- .../migrations/0052_alter_workspace_slug.py | 19 +++ apiserver/plane/db/models/module.py | 6 +- apiserver/plane/db/models/webhook.py | 1 - apiserver/plane/db/models/workspace.py | 22 ++- .../management/commands/configure_instance.py | 6 +- 45 files changed, 811 insertions(+), 270 deletions(-) create mode 100644 apiserver/plane/db/migrations/0052_alter_workspace_slug.py diff --git a/apiserver/plane/api/serializers/__init__.py b/apiserver/plane/api/serializers/__init__.py index 1c08e6f86..1fd1bce78 100644 --- a/apiserver/plane/api/serializers/__init__.py +++ b/apiserver/plane/api/serializers/__init__.py @@ -9,8 +9,9 @@ from .issue import ( IssueCommentSerializer, IssueAttachmentSerializer, IssueActivitySerializer, + IssueExpandSerializer, ) from .state import StateLiteSerializer, StateSerializer -from .cycle import CycleSerializer, CycleIssueSerializer -from .module import ModuleSerializer, ModuleIssueSerializer +from .cycle import CycleSerializer, CycleIssueSerializer, CycleLiteSerializer +from .module import ModuleSerializer, ModuleIssueSerializer, ModuleLiteSerializer from .inbox import InboxIssueSerializer \ No newline at end of file diff --git a/apiserver/plane/api/serializers/cycle.py b/apiserver/plane/api/serializers/cycle.py index b3e7708ef..5895a1bfc 100644 --- a/apiserver/plane/api/serializers/cycle.py +++ b/apiserver/plane/api/serializers/cycle.py @@ -46,4 +46,11 @@ class CycleIssueSerializer(BaseSerializer): "workspace", "project", "cycle", - ] \ No newline at end of file + ] + + +class CycleLiteSerializer(BaseSerializer): + + class Meta: + model = Cycle + fields = "__all__" \ No newline at end of file diff --git a/apiserver/plane/api/serializers/inbox.py b/apiserver/plane/api/serializers/inbox.py index df3fb9eb5..17ae8c1ed 100644 --- a/apiserver/plane/api/serializers/inbox.py +++ b/apiserver/plane/api/serializers/inbox.py @@ -8,6 +8,12 @@ class InboxIssueSerializer(BaseSerializer): model = InboxIssue fields = "__all__" read_only_fields = [ - "project", + "id", "workspace", + "project", + "issue", + "created_by", + "updated_by", + "created_at", + "updated_at", ] \ No newline at end of file diff --git a/apiserver/plane/api/serializers/issue.py b/apiserver/plane/api/serializers/issue.py index 8fcab0a38..2dbdddfc6 100644 --- a/apiserver/plane/api/serializers/issue.py +++ b/apiserver/plane/api/serializers/issue.py @@ -19,6 +19,8 @@ from plane.db.models import ( ProjectMember, ) from .base import BaseSerializer +from .cycle import CycleSerializer, CycleLiteSerializer +from .module import ModuleSerializer, ModuleLiteSerializer class IssueSerializer(BaseSerializer): @@ -42,6 +44,7 @@ class IssueSerializer(BaseSerializer): model = Issue fields = "__all__" read_only_fields = [ + "id", "workspace", "project", "created_by", @@ -60,9 +63,9 @@ class IssueSerializer(BaseSerializer): # Validate assignees are from project if data.get("assignees", []): - print(data.get("assignees")) data["assignees"] = ProjectMember.objects.filter( project_id=self.context.get("project_id"), + is_active=True, member_id__in=data["assignees"], ).values_list("member_id", flat=True) @@ -88,7 +91,7 @@ class IssueSerializer(BaseSerializer): if ( data.get("parent") and not Issue.objects.filter( - workspce_id=self.context.get("workspace_id"), pk=data.get("parent") + workspace_id=self.context.get("workspace_id"), pk=data.get("parent") ).exists() ): raise serializers.ValidationError( @@ -231,8 +234,13 @@ class LabelSerializer(BaseSerializer): model = Label fields = "__all__" read_only_fields = [ + "id", "workspace", "project", + "created_by", + "updated_by", + "created_at", + "updated_at", ] @@ -241,13 +249,14 @@ class IssueLinkSerializer(BaseSerializer): model = IssueLink fields = "__all__" read_only_fields = [ + "id", "workspace", "project", + "issue", "created_by", "updated_by", "created_at", "updated_at", - "issue", ] # Validation if url already exists @@ -266,13 +275,14 @@ class IssueAttachmentSerializer(BaseSerializer): model = IssueAttachment fields = "__all__" read_only_fields = [ + "id", + "workspace", + "project", + "issue", "created_by", "updated_by", "created_at", "updated_at", - "workspace", - "project", - "issue", ] @@ -282,38 +292,61 @@ class IssueCommentSerializer(BaseSerializer): class Meta: model = IssueComment fields = "__all__" - read_only_fields = [ - "workspace", - "project", - "issue", - "created_by", - "updated_by", - "created_at", - "updated_at", - ] - - -class IssueAttachmentSerializer(BaseSerializer): - class Meta: - model = IssueAttachment - fields = "__all__" read_only_fields = [ "id", + "workspace", + "project", + "issue", "created_by", "updated_by", "created_at", "updated_at", - "workspace", - "project", - "issue", ] class IssueActivitySerializer(BaseSerializer): class Meta: model = IssueActivity - fields = "__all__" exclude = [ "created_by", - "udpated_by", + "updated_by", ] + + +class CycleIssueSerializer(BaseSerializer): + cycle = CycleSerializer(read_only=True) + + class Meta: + fields = [ + "cycle", + ] + + +class ModuleIssueSerializer(BaseSerializer): + module = ModuleSerializer(read_only=True) + + class Meta: + fields = [ + "module", + ] + + +class IssueExpandSerializer(BaseSerializer): + # Serialize the related cycle. It's a OneToOne relation. + cycle = CycleLiteSerializer(source="issue_cycle.cycle", read_only=True) + + # Serialize the related module. It's a OneToOne relation. + module = ModuleLiteSerializer(source="issue_module.module", read_only=True) + + class Meta: + model = Issue + fields = "__all__" + read_only_fields = [ + "id", + "workspace", + "project", + "created_by", + "updated_by", + "created_at", + "updated_at", + ] \ No newline at end of file diff --git a/apiserver/plane/api/serializers/module.py b/apiserver/plane/api/serializers/module.py index fb2f2c870..81d07f884 100644 --- a/apiserver/plane/api/serializers/module.py +++ b/apiserver/plane/api/serializers/module.py @@ -21,7 +21,6 @@ class ModuleSerializer(BaseSerializer): write_only=True, required=False, ) - is_favorite = serializers.BooleanField(read_only=True) total_issues = serializers.IntegerField(read_only=True) cancelled_issues = serializers.IntegerField(read_only=True) completed_issues = serializers.IntegerField(read_only=True) @@ -33,6 +32,7 @@ class ModuleSerializer(BaseSerializer): model = Module fields = "__all__" read_only_fields = [ + "id", "workspace", "project", "created_by", @@ -152,4 +152,11 @@ class ModuleLinkSerializer(BaseSerializer): raise serializers.ValidationError( {"error": "URL already exists for this Issue"} ) - return ModuleLink.objects.create(**validated_data) \ No newline at end of file + return ModuleLink.objects.create(**validated_data) + + +class ModuleLiteSerializer(BaseSerializer): + + class Meta: + model = Module + fields = "__all__" \ No newline at end of file diff --git a/apiserver/plane/api/serializers/project.py b/apiserver/plane/api/serializers/project.py index 61f4d6f60..932597799 100644 --- a/apiserver/plane/api/serializers/project.py +++ b/apiserver/plane/api/serializers/project.py @@ -20,8 +20,12 @@ class ProjectSerializer(BaseSerializer): model = Project fields = "__all__" read_only_fields = [ - "workspace", "id", + "workspace", + "created_at", + "updated_at", + "created_by", + "updated_by", ] def validate(self, data): diff --git a/apiserver/plane/api/urls/cycle.py b/apiserver/plane/api/urls/cycle.py index b859c28b2..f557f8af0 100644 --- a/apiserver/plane/api/urls/cycle.py +++ b/apiserver/plane/api/urls/cycle.py @@ -13,7 +13,7 @@ urlpatterns = [ name="cycles", ), path( - "workspaces//projects//cycles//", + "workspaces//projects//cycles//", CycleAPIEndpoint.as_view(), name="cycles", ), @@ -23,7 +23,7 @@ urlpatterns = [ name="cycle-issues", ), path( - "workspaces//projects//cycles//cycle-issues//", + "workspaces//projects//cycles//cycle-issues//", CycleIssueAPIEndpoint.as_view(), name="cycle-issues", ), diff --git a/apiserver/plane/api/urls/inbox.py b/apiserver/plane/api/urls/inbox.py index 3284fd81b..884676cbc 100644 --- a/apiserver/plane/api/urls/inbox.py +++ b/apiserver/plane/api/urls/inbox.py @@ -5,12 +5,12 @@ from plane.api.views import InboxIssueAPIEndpoint urlpatterns = [ path( - "workspaces//projects//inboxes//inbox-issues/", + "workspaces//projects//inbox-issues/", InboxIssueAPIEndpoint.as_view(), name="inbox-issue", ), path( - "workspaces//projects//inboxes//inbox-issues//", + "workspaces//projects//inbox-issues//", InboxIssueAPIEndpoint.as_view(), name="inbox-issue", ), diff --git a/apiserver/plane/api/urls/issue.py b/apiserver/plane/api/urls/issue.py index 910fda5e1..070ea8bd9 100644 --- a/apiserver/plane/api/urls/issue.py +++ b/apiserver/plane/api/urls/issue.py @@ -15,27 +15,27 @@ urlpatterns = [ name="issue", ), path( - "workspaces//projects//issues//", + "workspaces//projects//issues//", IssueAPIEndpoint.as_view(), name="issue", ), path( - "workspaces//projects//issue-labels/", + "workspaces//projects//labels/", LabelAPIEndpoint.as_view(), name="label", ), path( - "workspaces//projects//issue-labels//", + "workspaces//projects//labels//", LabelAPIEndpoint.as_view(), name="label", ), path( - "workspaces//projects//issues//issue-links/", + "workspaces//projects//issues//links/", IssueLinkAPIEndpoint.as_view(), name="link", ), path( - "workspaces//projects//issues//issue-links//", + "workspaces//projects//issues//links//", IssueLinkAPIEndpoint.as_view(), name="link", ), @@ -50,12 +50,12 @@ urlpatterns = [ name="comment", ), path( - "workspaces//projects//issues//activites/", + "workspaces//projects//issues//activities/", IssueActivityAPIEndpoint.as_view(), name="activity", ), path( - "workspaces//projects//issues//activites//", + "workspaces//projects//issues//activities//", IssueActivityAPIEndpoint.as_view(), name="activity", ), diff --git a/apiserver/plane/api/urls/module.py b/apiserver/plane/api/urls/module.py index 7860a0fce..7117a9e8b 100644 --- a/apiserver/plane/api/urls/module.py +++ b/apiserver/plane/api/urls/module.py @@ -19,7 +19,7 @@ urlpatterns = [ name="module-issues", ), path( - "workspaces//projects//modules//module-issues//", + "workspaces//projects//modules//module-issues//", ModuleIssueAPIEndpoint.as_view(), name="module-issues", ), diff --git a/apiserver/plane/api/urls/project.py b/apiserver/plane/api/urls/project.py index ffd2af843..c73e84c89 100644 --- a/apiserver/plane/api/urls/project.py +++ b/apiserver/plane/api/urls/project.py @@ -9,7 +9,7 @@ urlpatterns = [ name="project", ), path( - "workspaces//projects//", + "workspaces//projects//", ProjectAPIEndpoint.as_view(), name="project", ), diff --git a/apiserver/plane/api/urls/state.py b/apiserver/plane/api/urls/state.py index cf5eefd53..0676ac5ad 100644 --- a/apiserver/plane/api/urls/state.py +++ b/apiserver/plane/api/urls/state.py @@ -8,4 +8,9 @@ urlpatterns = [ StateAPIEndpoint.as_view(), name="states", ), + path( + "workspaces//projects//states//", + StateAPIEndpoint.as_view(), + name="states", + ), ] \ No newline at end of file diff --git a/apiserver/plane/api/views/base.py b/apiserver/plane/api/views/base.py index 6cd8b2356..abde4e8b0 100644 --- a/apiserver/plane/api/views/base.py +++ b/apiserver/plane/api/views/base.py @@ -7,7 +7,6 @@ from django.conf import settings from django.db import IntegrityError from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.utils import timezone -from django.core.serializers.json import DjangoJSONEncoder # Third party imports from rest_framework.views import APIView @@ -36,28 +35,33 @@ class TimezoneMixin: else: 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, - event_data=json.dumps(response.data, cls=DjangoJSONEncoder), + payload=response.data, + kw=self.kwargs, action=self.request.method, slug=self.workspace_slug, + bulk=self.bulk, ) return response - class BaseAPIView(TimezoneMixin, APIView, BasePaginator): authentication_classes = [ APIKeyAuthentication, @@ -139,13 +143,13 @@ class BaseAPIView(TimezoneMixin, APIView, BasePaginator): response = super().finalize_response(request, response, *args, **kwargs) # Add custom headers if they exist in the request META - ratelimit_remaining = request.META.get('X-RateLimit-Remaining') + ratelimit_remaining = request.META.get("X-RateLimit-Remaining") if ratelimit_remaining is not None: - response['X-RateLimit-Remaining'] = ratelimit_remaining + response["X-RateLimit-Remaining"] = ratelimit_remaining - ratelimit_reset = request.META.get('X-RateLimit-Reset') + ratelimit_reset = request.META.get("X-RateLimit-Reset") if ratelimit_reset is not None: - response['X-RateLimit-Reset'] = ratelimit_reset + response["X-RateLimit-Reset"] = ratelimit_reset return response @@ -169,4 +173,4 @@ class BaseAPIView(TimezoneMixin, APIView, BasePaginator): expand = [ expand for expand in self.request.GET.get("expand", "").split(",") if expand ] - return expand if expand else None \ No newline at end of file + return expand if expand else None diff --git a/apiserver/plane/api/views/cycle.py b/apiserver/plane/api/views/cycle.py index f9ed5a7a4..310332333 100644 --- a/apiserver/plane/api/views/cycle.py +++ b/apiserver/plane/api/views/cycle.py @@ -17,7 +17,6 @@ from plane.app.permissions import ProjectEntityPermission from plane.api.serializers import ( CycleSerializer, CycleIssueSerializer, - IssueSerializer, ) from plane.bgtasks.issue_activites_task import issue_activity @@ -142,7 +141,6 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView): ) queryset = self.get_queryset() cycle_view = request.GET.get("cycle_view", "all") - queryset = queryset.order_by("-is_favorite", "-created_at") # Current Cycle if cycle_view == "current": @@ -293,7 +291,7 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView): } ), actor_id=str(request.user.id), - issue_id=str(pk), + issue_id=None, project_id=str(project_id), current_instance=None, epoch=int(timezone.now().timestamp()), @@ -305,14 +303,15 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView): class CycleIssueAPIEndpoint(WebhookMixin, BaseAPIView): """ - This viewset automatically provides `list`, `create`, `retrieve`, - `update` and `destroy` actions related to cycle issues. + This viewset automatically provides `list`, `create`, + and `destroy` actions related to cycle issues. """ serializer_class = CycleIssueSerializer model = CycleIssue - webhook_event = "cycle" + webhook_event = "cycle_issue" + bulk = True permission_classes = [ ProjectEntityPermission, ] @@ -457,7 +456,7 @@ class CycleIssueAPIEndpoint(WebhookMixin, BaseAPIView): # Capture Issue Activity issue_activity.delay( type="cycle.activity.created", - requested_data=json.dumps({"cycles_list": issues}), + requested_data=json.dumps({"cycles_list": str(issues)}), actor_id=str(self.request.user.id), issue_id=None, project_id=str(self.kwargs.get("project_id", None)), @@ -478,9 +477,9 @@ class CycleIssueAPIEndpoint(WebhookMixin, BaseAPIView): status=status.HTTP_200_OK, ) - def delete(self, request, slug, project_id, cycle_id, pk): + def delete(self, request, slug, project_id, cycle_id, issue_id): cycle_issue = CycleIssue.objects.get( - pk=pk, workspace__slug=slug, project_id=project_id, cycle_id=cycle_id + issue_id=issue_id, workspace__slug=slug, project_id=project_id, cycle_id=cycle_id ) issue_id = cycle_issue.issue_id cycle_issue.delete() @@ -493,7 +492,7 @@ class CycleIssueAPIEndpoint(WebhookMixin, BaseAPIView): } ), actor_id=str(self.request.user.id), - issue_id=str(self.kwargs.get("pk", None)), + issue_id=str(issue_id), project_id=str(self.kwargs.get("project_id", None)), current_instance=None, epoch=int(timezone.now().timestamp()), diff --git a/apiserver/plane/api/views/inbox.py b/apiserver/plane/api/views/inbox.py index e670578d1..5217c32e5 100644 --- a/apiserver/plane/api/views/inbox.py +++ b/apiserver/plane/api/views/inbox.py @@ -14,7 +14,7 @@ from rest_framework.response import Response from .base import BaseAPIView from plane.app.permissions import ProjectLitePermission from plane.api.serializers import InboxIssueSerializer, IssueSerializer -from plane.db.models import InboxIssue, Issue, State, ProjectMember +from plane.db.models import InboxIssue, Issue, State, ProjectMember, Project, Inbox from plane.bgtasks.issue_activites_task import issue_activity @@ -37,29 +37,39 @@ class InboxIssueAPIEndpoint(BaseAPIView): ] def get_queryset(self): - return self.filter_queryset( - super() - .get_queryset() - .filter( + inbox = Inbox.objects.filter( + workspace__slug=self.kwargs.get("slug"), + project_id=self.kwargs.get("project_id"), + ).first() + + project = Project.objects.get( + workspace__slug=self.kwargs.get("slug"), pk=self.kwargs.get("project_id") + ) + + if inbox is None and not project.inbox_view: + return InboxIssue.objects.none() + + return ( + InboxIssue.objects.filter( Q(snoozed_till__gte=timezone.now()) | Q(snoozed_till__isnull=True), workspace__slug=self.kwargs.get("slug"), project_id=self.kwargs.get("project_id"), - inbox_id=self.kwargs.get("inbox_id"), + inbox_id=inbox.id, ) .select_related("issue", "workspace", "project") .order_by(self.kwargs.get("order_by", "-created_at")) ) - def get(self, request, slug, project_id, inbox_id, pk=None): + def get(self, request, slug, project_id, pk=None): if pk: - issue_queryset = self.get_queryset().get(pk=pk) - issues_data = InboxIssueSerializer( - issue_queryset, + inbox_issue_queryset = self.get_queryset().get(pk=pk) + inbox_issue_data = InboxIssueSerializer( + inbox_issue_queryset, fields=self.fields, expand=self.expand, ).data return Response( - issues_data, + inbox_issue_data, status=status.HTTP_200_OK, ) issue_queryset = self.get_queryset() @@ -74,12 +84,30 @@ class InboxIssueAPIEndpoint(BaseAPIView): ).data, ) - def post(self, request, slug, project_id, inbox_id): + def post(self, request, slug, project_id): if not request.data.get("issue", {}).get("name", False): return Response( {"error": "Name is required"}, status=status.HTTP_400_BAD_REQUEST ) + inbox = Inbox.objects.filter( + workspace__slug=slug, project_id=project_id + ).first() + + project = Project.objects.get( + workspace__slug=slug, + pk=project_id, + ) + + # Inbox view + if inbox is None and not project.inbox_view: + return Response( + { + "error": "Inbox is not enabled for this project enable it through the project settings" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + # Check for valid priority if not request.data.get("issue", {}).get("priority", "none") in [ "low", @@ -123,21 +151,45 @@ class InboxIssueAPIEndpoint(BaseAPIView): current_instance=None, epoch=int(timezone.now().timestamp()), ) + # create an inbox issue - InboxIssue.objects.create( - inbox_id=inbox_id, + inbox_issue = InboxIssue.objects.create( + inbox_id=inbox.id, project_id=project_id, issue=issue, source=request.data.get("source", "in-app"), ) - serializer = IssueSerializer(issue) + serializer = InboxIssueSerializer(inbox_issue) return Response(serializer.data, status=status.HTTP_200_OK) - def patch(self, request, slug, project_id, inbox_id, pk): - inbox_issue = InboxIssue.objects.get( - pk=pk, workspace__slug=slug, project_id=project_id, inbox_id=inbox_id + def patch(self, request, slug, project_id, pk): + inbox = Inbox.objects.filter( + workspace__slug=slug, project_id=project_id + ).first() + + project = Project.objects.get( + workspace__slug=slug, + pk=project_id, ) + + # Inbox view + if inbox is None and not project.inbox_view: + return Response( + { + "error": "Inbox is not enabled for this project enable it through the project settings" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Get the inbox issue + inbox_issue = InboxIssue.objects.get( + pk=pk, + workspace__slug=slug, + project_id=project_id, + inbox_id=inbox.id, + ) + # Get the project member project_member = ProjectMember.objects.get( workspace__slug=slug, @@ -145,6 +197,7 @@ class InboxIssueAPIEndpoint(BaseAPIView): member=request.user, is_active=True, ) + # Only project members admins and created_by users can access this endpoint if project_member.role <= 10 and str(inbox_issue.created_by_id) != str( request.user.id @@ -244,10 +297,33 @@ class InboxIssueAPIEndpoint(BaseAPIView): InboxIssueSerializer(inbox_issue).data, status=status.HTTP_200_OK ) - def delete(self, request, slug, project_id, inbox_id, pk): - inbox_issue = InboxIssue.objects.get( - pk=pk, workspace__slug=slug, project_id=project_id, inbox_id=inbox_id + def delete(self, request, slug, project_id, pk): + inbox = Inbox.objects.filter( + workspace__slug=slug, project_id=project_id + ).first() + + project = Project.objects.get( + workspace__slug=slug, + pk=project_id, ) + + # Inbox view + if inbox is None and not project.inbox_view: + return Response( + { + "error": "Inbox is not enabled for this project enable it through the project settings" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Get the inbox issue + inbox_issue = InboxIssue.objects.get( + pk=pk, + workspace__slug=slug, + project_id=project_id, + inbox_id=inbox.id, + ) + # Get the project member project_member = ProjectMember.objects.get( workspace__slug=slug, @@ -256,6 +332,7 @@ class InboxIssueAPIEndpoint(BaseAPIView): is_active=True, ) + # Check the inbox issue created if project_member.role <= 10 and str(inbox_issue.created_by_id) != str( request.user.id ): @@ -272,4 +349,4 @@ class InboxIssueAPIEndpoint(BaseAPIView): ).delete() inbox_issue.delete() - return Response(status=status.HTTP_204_NO_CONTENT) \ No newline at end of file + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py index 04efdd6ff..41745010f 100644 --- a/apiserver/plane/api/views/issue.py +++ b/apiserver/plane/api/views/issue.py @@ -22,7 +22,6 @@ from django.utils import timezone # Third party imports from rest_framework import status from rest_framework.response import Response -from rest_framework.parsers import MultiPartParser, FormParser # Module imports from .base import BaseAPIView, WebhookMixin @@ -41,14 +40,12 @@ from plane.db.models import ( IssueComment, IssueActivity, ) -from plane.utils.issue_filters import issue_filters from plane.bgtasks.issue_activites_task import issue_activity from plane.api.serializers import ( IssueSerializer, LabelSerializer, IssueLinkSerializer, IssueCommentSerializer, - IssueAttachmentSerializer, IssueActivitySerializer, ) @@ -103,7 +100,6 @@ class IssueAPIEndpoint(WebhookMixin, BaseAPIView): status=status.HTTP_200_OK, ) - filters = issue_filters(request.query_params, "GET") # Custom ordering for priority and state priority_order = ["urgent", "high", "medium", "low", "none"] state_order = ["backlog", "unstarted", "started", "completed", "cancelled"] @@ -112,7 +108,6 @@ class IssueAPIEndpoint(WebhookMixin, BaseAPIView): issue_queryset = ( self.get_queryset() - .filter(**filters) .annotate(cycle_id=F("issue_cycle__cycle_id")) .annotate(module_id=F("issue_module__module_id")) .annotate( @@ -278,7 +273,7 @@ class LabelAPIEndpoint(BaseAPIView): def get_queryset(self): return ( - Project.objects.filter(workspace__slug=self.kwargs.get("slug")) + Label.objects.filter(workspace__slug=self.kwargs.get("slug")) .filter(project_id=self.kwargs.get("project_id")) .filter(project__project_projectmember__member=self.request.user) .select_related("project") @@ -302,29 +297,29 @@ class LabelAPIEndpoint(BaseAPIView): ) def get(self, request, slug, project_id, pk=None): - if pk: - label = self.get_queryset().get(pk=pk) - serializer = LabelSerializer( - label, - fields=self.fields, - expand=self.expand, + if pk is None: + return self.paginate( + request=request, + queryset=(self.get_queryset()), + on_results=lambda labels: LabelSerializer( + labels, + many=True, + fields=self.fields, + expand=self.expand, + ).data, ) - return Response(serializer.data, status=status.HTTP_200_OK) - return self.paginate( - request=request, - queryset=(self.get_queryset()), - on_results=lambda labels: LabelSerializer( - labels, - many=True, - fields=self.fields, - expand=self.expand, - ).data, - ) + label = self.get_queryset().get(pk=pk) + serializer = LabelSerializer(label, fields=self.fields, expand=self.expand,) + return Response(serializer.data, status=status.HTTP_200_OK) def patch(self, request, slug, project_id, pk=None): label = self.get_queryset().get(pk=pk) serializer = LabelSerializer(label, data=request.data, partial=True) - return Response(serializer.data, status=status.HTTP_200_OK) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + def delete(self, request, slug, project_id, pk=None): label = self.get_queryset().get(pk=pk) @@ -356,25 +351,31 @@ class IssueLinkAPIEndpoint(BaseAPIView): .distinct() ) - def get(self, request, slug, project_id, pk=None): - if pk: - label = self.get_queryset().get(pk=pk) + def get(self, request, slug, project_id, issue_id, pk=None): + if pk is None: + issue_links = self.get_queryset() serializer = IssueLinkSerializer( - label, + issue_links, fields=self.fields, expand=self.expand, ) - return Response(serializer.data, status=status.HTTP_200_OK) - return self.paginate( - request=request, - queryset=(self.get_queryset()), - on_results=lambda issue_links: IssueLinkSerializer( - issue_links, - many=True, - fields=self.fields, - expand=self.expand, - ).data, + return self.paginate( + request=request, + queryset=(self.get_queryset()), + on_results=lambda issue_links: IssueLinkSerializer( + issue_links, + many=True, + fields=self.fields, + expand=self.expand, + ).data, + ) + issue_link = self.get_queryset().get(pk=pk) + serializer = IssueLinkSerializer( + issue_link, + fields=self.fields, + expand=self.expand, ) + return Response(serializer.data, status=status.HTTP_200_OK) def post(self, request, slug, project_id, issue_id): serializer = IssueLinkSerializer(data=request.data) @@ -449,7 +450,7 @@ class IssueCommentAPIEndpoint(WebhookMixin, BaseAPIView): serializer_class = IssueCommentSerializer model = IssueComment - webhook_event = "issue-comment" + webhook_event = "issue_comment" permission_classes = [ ProjectLitePermission, ] @@ -587,7 +588,7 @@ class IssueActivityAPIEndpoint(BaseAPIView): serializer = IssueActivitySerializer(issue_activities) return Response(serializer.data, status=status.HTTP_200_OK) - self.paginate( + return self.paginate( request=request, queryset=(issue_activities), on_results=lambda issue_activity: IssueActivitySerializer( diff --git a/apiserver/plane/api/views/module.py b/apiserver/plane/api/views/module.py index 78f721adc..221c7f31b 100644 --- a/apiserver/plane/api/views/module.py +++ b/apiserver/plane/api/views/module.py @@ -129,6 +129,14 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView): serializer = ModuleSerializer(module) return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def patch(self, request, slug, project_id, pk): + module = Module.objects.get(pk=pk, project_id=project_id, workspace__slug=slug) + serializer = ModuleSerializer(module, data=request.data) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def get(self, request, slug, project_id, pk=None): if pk: @@ -168,7 +176,7 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView): } ), actor_id=str(request.user.id), - issue_id=str(pk), + issue_id=None, project_id=str(project_id), current_instance=None, epoch=int(timezone.now().timestamp()), @@ -186,7 +194,8 @@ class ModuleIssueAPIEndpoint(WebhookMixin, BaseAPIView): serializer_class = ModuleIssueSerializer model = ModuleIssue - webhook_event = "module" + webhook_event = "module_issue" + bulk = True permission_classes = [ ProjectEntityPermission, @@ -323,7 +332,7 @@ class ModuleIssueAPIEndpoint(WebhookMixin, BaseAPIView): # Capture Issue Activity issue_activity.delay( type="module.activity.created", - requested_data=json.dumps({"modules_list": issues}), + requested_data=json.dumps({"modules_list": str(issues)}), actor_id=str(self.request.user.id), issue_id=None, project_id=str(self.kwargs.get("project_id", None)), @@ -343,9 +352,9 @@ class ModuleIssueAPIEndpoint(WebhookMixin, BaseAPIView): status=status.HTTP_200_OK, ) - def delete(self, request, slug, project_id, module_id, pk): + def delete(self, request, slug, project_id, module_id, issue_id): module_issue = ModuleIssue.objects.get( - workspace__slug=slug, project_id=project_id, module_id=module_id, pk=pk + workspace__slug=slug, project_id=project_id, module_id=module_id, issue_id=issue_id ) module_issue.delete() issue_activity.delay( @@ -357,7 +366,7 @@ class ModuleIssueAPIEndpoint(WebhookMixin, BaseAPIView): } ), actor_id=str(request.user.id), - issue_id=str(pk), + issue_id=str(issue_id), project_id=str(project_id), current_instance=None, epoch=int(timezone.now().timestamp()), diff --git a/apiserver/plane/api/views/project.py b/apiserver/plane/api/views/project.py index 674e82acc..e8dc9f5a9 100644 --- a/apiserver/plane/api/views/project.py +++ b/apiserver/plane/api/views/project.py @@ -94,8 +94,8 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView): .distinct() ) - def get(self, request, slug, pk=None): - if pk is None: + def get(self, request, slug, project_id=None): + if project_id is None: sort_order_query = ProjectMember.objects.filter( member=request.user, project_id=OuterRef("pk"), @@ -114,7 +114,7 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView): ).select_related("member"), ) ) - .order_by("sort_order", "name") + .order_by(request.GET.get("order_by", "sort_order")) ) return self.paginate( request=request, @@ -123,15 +123,13 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView): projects, many=True, fields=self.fields, expand=self.expand, ).data, ) - else: - project = self.get_queryset().get(workspace__slug=slug, pk=pk) - serializer = ProjectSerializer(project, fields=self.fields, expand=self.expand,) - return Response(serializer.data, status=status.HTTP_200_OK) + project = self.get_queryset().get(workspace__slug=slug, pk=project_id) + serializer = ProjectSerializer(project, fields=self.fields, expand=self.expand,) + return Response(serializer.data, status=status.HTTP_200_OK) def post(self, request, slug): try: workspace = Workspace.objects.get(slug=slug) - serializer = ProjectSerializer( data={**request.data}, context={"workspace_id": workspace.id} ) @@ -236,10 +234,10 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView): status=status.HTTP_410_GONE, ) - def patch(self, request, slug, pk=None): + def patch(self, request, slug, project_id=None): try: workspace = Workspace.objects.get(slug=slug) - project = Project.objects.get(pk=pk) + project = Project.objects.get(pk=project_id) serializer = ProjectSerializer( project, @@ -260,7 +258,7 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView): name="Triage", group="backlog", description="Default state for managing all Inbox Issues", - project_id=pk, + project_id=project_id, color="#ff7700", ) @@ -282,4 +280,9 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView): return Response( {"identifier": "The project identifier is already taken"}, status=status.HTTP_410_GONE, - ) \ No newline at end of file + ) + + def delete(self, request, slug, project_id): + project = Project.objects.get(pk=project_id, workspace__slug=slug) + project.delete() + return Response(status=status.HTTP_204_NO_CONTENT) \ No newline at end of file diff --git a/apiserver/plane/api/views/state.py b/apiserver/plane/api/views/state.py index 8e7a73d9b..679c12964 100644 --- a/apiserver/plane/api/views/state.py +++ b/apiserver/plane/api/views/state.py @@ -23,10 +23,8 @@ class StateAPIEndpoint(BaseAPIView): ] def get_queryset(self): - return self.filter_queryset( - super() - .get_queryset() - .filter(workspace__slug=self.kwargs.get("slug")) + return ( + State.objects.filter(workspace__slug=self.kwargs.get("slug")) .filter(project_id=self.kwargs.get("project_id")) .filter(project__project_projectmember__member=self.request.user) .filter(~Q(name="Triage")) @@ -42,9 +40,9 @@ class StateAPIEndpoint(BaseAPIView): return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - def get(self, request, slug, project_id, pk=None): - if pk: - serializer = StateSerializer(self.get_queryset().get(pk=pk)) + def get(self, request, slug, project_id, state_id=None): + if state_id: + serializer = StateSerializer(self.get_queryset().get(pk=state_id)) return Response(serializer.data, status=status.HTTP_200_OK) return self.paginate( request=request, @@ -57,10 +55,10 @@ class StateAPIEndpoint(BaseAPIView): ).data, ) - def delete(self, request, slug, project_id, pk): + def delete(self, request, slug, project_id, state_id): state = State.objects.get( ~Q(name="Triage"), - pk=pk, + pk=state_id, project_id=project_id, workspace__slug=slug, ) @@ -69,7 +67,7 @@ class StateAPIEndpoint(BaseAPIView): return Response({"error": "Default state cannot be deleted"}, status=False) # Check for any issues in the state - issue_exist = Issue.issue_objects.filter(state=pk).exists() + issue_exist = Issue.issue_objects.filter(state=state_id).exists() if issue_exist: return Response( @@ -80,8 +78,8 @@ class StateAPIEndpoint(BaseAPIView): state.delete() return Response(status=status.HTTP_204_NO_CONTENT) - def patch(self, request, slug, project_id, pk=None): - state = State.objects.filter(workspace__slug=slug, project_id=project_id, pk=pk) + def patch(self, request, slug, project_id, state_id=None): + state = State.objects.get(workspace__slug=slug, project_id=project_id, pk=state_id) serializer = StateSerializer(state, data=request.data, partial=True) if serializer.is_valid(): serializer.save() diff --git a/apiserver/plane/app/serializers/webhook.py b/apiserver/plane/app/serializers/webhook.py index 351b6fe7d..d5b3eeddd 100644 --- a/apiserver/plane/app/serializers/webhook.py +++ b/apiserver/plane/app/serializers/webhook.py @@ -1,3 +1,9 @@ +# Python imports +import urllib +import socket +import ipaddress +from urllib.parse import urlparse + # Third party imports from rest_framework import serializers @@ -9,6 +15,42 @@ from plane.db.models.webhook import validate_domain, validate_schema class WebhookSerializer(DynamicBaseSerializer): url = serializers.URLField(validators=[validate_schema, validate_domain]) + def validate(self, data): + url = data.get("url", None) + + # Extract the hostname from the URL + hostname = urlparse(url).hostname + if not hostname: + raise serializers.ValidationError({"url": "Invalid URL: No hostname found."}) + + # Resolve the hostname to IP addresses + try: + ip_addresses = socket.getaddrinfo(hostname, None) + except socket.gaierror: + raise serializers.ValidationError({"url": "Hostname could not be resolved."}) + + if not ip_addresses: + raise serializers.ValidationError({"url": "No IP addresses found for the hostname."}) + + for addr in ip_addresses: + ip = ipaddress.ip_address(addr[4][0]) + if ip.is_private or ip.is_loopback: + raise serializers.ValidationError({"url": "URL resolves to a blocked IP address."}) + + # Additional validation for multiple request domains and their subdomains + request = self.context.get('request') + disallowed_domains = ['plane.so',] # Add your disallowed domains here + if request: + request_host = request.get_host().split(':')[0] # Remove port if present + disallowed_domains.append(request_host) + + # Check if hostname is a subdomain or exact match of any disallowed domain + if any(hostname == domain or hostname.endswith('.' + domain) for domain in disallowed_domains): + raise serializers.ValidationError({"url": "URL domain or its subdomain is not allowed."}) + + return data + + class Meta: model = Webhook fields = "__all__" diff --git a/apiserver/plane/app/serializers/workspace.py b/apiserver/plane/app/serializers/workspace.py index 0a80ce8b7..2720c177a 100644 --- a/apiserver/plane/app/serializers/workspace.py +++ b/apiserver/plane/app/serializers/workspace.py @@ -21,6 +21,22 @@ class WorkSpaceSerializer(BaseSerializer): total_members = serializers.IntegerField(read_only=True) total_issues = serializers.IntegerField(read_only=True) + def validated(self, data): + if data.get("slug") in [ + "404", + "accounts", + "api", + "create-workspace", + "god-mode", + "installations", + "invitations", + "onboarding", + "profile", + "spaces", + "workspace-invitations", + ]: + raise serializers.ValidationError({"slug": "Slug is not valid"}) + class Meta: model = Workspace fields = "__all__" diff --git a/apiserver/plane/app/urls/project.py b/apiserver/plane/app/urls/project.py index b2819176c..f1b4200ed 100644 --- a/apiserver/plane/app/urls/project.py +++ b/apiserver/plane/app/urls/project.py @@ -65,7 +65,7 @@ urlpatterns = [ name="project-member-invite", ), path( - "users/me/invitations/projects/", + "users/me/workspaces//projects/invitations/", UserProjectInvitationsViewset.as_view( { "get": "list", @@ -75,7 +75,7 @@ urlpatterns = [ name="user-project-invitations", ), path( - "workspaces//projects/join/", + "workspaces//projects//join//", ProjectJoinEndpoint.as_view(), name="project-join", ), diff --git a/apiserver/plane/app/views/base.py b/apiserver/plane/app/views/base.py index de7bafd57..32449597b 100644 --- a/apiserver/plane/app/views/base.py +++ b/apiserver/plane/app/views/base.py @@ -43,20 +43,25 @@ class TimezoneMixin: 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, - event_data=json.dumps(response.data, cls=DjangoJSONEncoder), + payload=response.data, + kw=self.kwargs, action=self.request.method, slug=self.workspace_slug, + bulk=self.bulk, ) return response diff --git a/apiserver/plane/app/views/config.py b/apiserver/plane/app/views/config.py index 411f9c5dd..3052b6077 100644 --- a/apiserver/plane/app/views/config.py +++ b/apiserver/plane/app/views/config.py @@ -58,6 +58,7 @@ class ConfigurationEndpoint(BaseAPIView): ) and get_configuration_value( instance_configuration, "ENABLE_MAGIC_LINK_LOGIN", "0" ) == "1" + data["email_password_login"] = ( get_configuration_value( instance_configuration, "ENABLE_EMAIL_PASSWORD", "0" diff --git a/apiserver/plane/app/views/cycle.py b/apiserver/plane/app/views/cycle.py index 7228aa088..b0fec588c 100644 --- a/apiserver/plane/app/views/cycle.py +++ b/apiserver/plane/app/views/cycle.py @@ -502,7 +502,10 @@ class CycleViewSet(WebhookMixin, BaseViewSet): class CycleIssueViewSet(WebhookMixin, BaseViewSet): serializer_class = CycleIssueSerializer model = CycleIssue - webhook_event = "cycle" + + webhook_event = "cycle_issue" + bulk = True + permission_classes = [ ProjectEntityPermission, ] @@ -688,7 +691,6 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet): pk=pk, workspace__slug=slug, project_id=project_id, cycle_id=cycle_id ) issue_id = cycle_issue.issue_id - cycle_issue.delete() issue_activity.delay( type="cycle.activity.deleted", requested_data=json.dumps( @@ -698,11 +700,12 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet): } ), actor_id=str(self.request.user.id), - issue_id=str(self.kwargs.get("pk", None)), + issue_id=str(cycle_issue.issue_id), project_id=str(self.kwargs.get("project_id", None)), current_instance=None, epoch=int(timezone.now().timestamp()), ) + cycle_issue.delete() return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apiserver/plane/app/views/external.py b/apiserver/plane/app/views/external.py index ac502c186..b9f8a0cf0 100644 --- a/apiserver/plane/app/views/external.py +++ b/apiserver/plane/app/views/external.py @@ -1,6 +1,6 @@ # Python imports import requests - +import os # Third party imports from openai import OpenAI from rest_framework.response import Response @@ -27,8 +27,8 @@ class GPTIntegrationEndpoint(BaseAPIView): # Get the configuration value instance_configuration = InstanceConfiguration.objects.values("key", "value") - api_key = get_configuration_value(instance_configuration, "OPENAI_API_KEY") - gpt_engine = get_configuration_value(instance_configuration, "GPT_ENGINE") + api_key = get_configuration_value(instance_configuration, "OPENAI_API_KEY", os.environ.get("OPENAI_API_KEY")) + gpt_engine = get_configuration_value(instance_configuration, "GPT_ENGINE", os.environ.get("GPT_ENGINE", "gpt-3.5-turbo")) # Check the keys if not api_key or not gpt_engine: @@ -47,10 +47,6 @@ class GPTIntegrationEndpoint(BaseAPIView): final_text = task + "\n" + prompt - instance_configuration = InstanceConfiguration.objects.values("key", "value") - - gpt_engine = get_configuration_value(instance_configuration, "GPT_ENGINE") - client = OpenAI( api_key=api_key, ) @@ -85,14 +81,22 @@ class ReleaseNotesEndpoint(BaseAPIView): class UnsplashEndpoint(BaseAPIView): def get(self, request): + instance_configuration = InstanceConfiguration.objects.values("key", "value") + unsplash_access_key = get_configuration_value(instance_configuration, "UNSPLASH_ACCESS_KEY", os.environ.get("UNSPLASH_ACCESS_KEY")) + + # Check unsplash access key + if not unsplash_access_key: + return Response([], status=status.HTTP_200_OK) + + # Query parameters query = request.GET.get("query", False) page = request.GET.get("page", 1) per_page = request.GET.get("per_page", 20) url = ( - f"https://api.unsplash.com/search/photos/?client_id={settings.UNSPLASH_ACCESS_KEY}&query={query}&page=${page}&per_page={per_page}" + f"https://api.unsplash.com/search/photos/?client_id={unsplash_access_key}&query={query}&page=${page}&per_page={per_page}" if query - else f"https://api.unsplash.com/photos/?client_id={settings.UNSPLASH_ACCESS_KEY}&page={page}&per_page={per_page}" + else f"https://api.unsplash.com/photos/?client_id={unsplash_access_key}&page={page}&per_page={per_page}" ) headers = { diff --git a/apiserver/plane/app/views/issue.py b/apiserver/plane/app/views/issue.py index b03c0ea4f..e7605bf14 100644 --- a/apiserver/plane/app/views/issue.py +++ b/apiserver/plane/app/views/issue.py @@ -596,7 +596,7 @@ class IssueActivityEndpoint(BaseAPIView): class IssueCommentViewSet(WebhookMixin, BaseViewSet): serializer_class = IssueCommentSerializer model = IssueComment - webhook_event = "issue-comment" + webhook_event = "issue_comment" permission_classes = [ ProjectLitePermission, ] diff --git a/apiserver/plane/app/views/module.py b/apiserver/plane/app/views/module.py index 28986ea0f..3a9968bfd 100644 --- a/apiserver/plane/app/views/module.py +++ b/apiserver/plane/app/views/module.py @@ -283,9 +283,12 @@ class ModuleViewSet(WebhookMixin, BaseViewSet): return Response(status=status.HTTP_204_NO_CONTENT) -class ModuleIssueViewSet(BaseViewSet): +class ModuleIssueViewSet(WebhookMixin, BaseViewSet): serializer_class = ModuleIssueSerializer model = ModuleIssue + webhook_event = "module_issue" + bulk = True + filterset_fields = [ "issue__labels__id", @@ -461,7 +464,6 @@ class ModuleIssueViewSet(BaseViewSet): module_issue = ModuleIssue.objects.get( workspace__slug=slug, project_id=project_id, module_id=module_id, pk=pk ) - module_issue.delete() issue_activity.delay( type="module.activity.deleted", requested_data=json.dumps( @@ -471,11 +473,12 @@ class ModuleIssueViewSet(BaseViewSet): } ), actor_id=str(request.user.id), - issue_id=str(pk), + issue_id=str(module_issue.issue_id), project_id=str(project_id), current_instance=None, epoch=int(timezone.now().timestamp()), ) + module_issue.delete() return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apiserver/plane/app/views/project.py b/apiserver/plane/app/views/project.py index 2d616e5a6..f2f48e7c3 100644 --- a/apiserver/plane/app/views/project.py +++ b/apiserver/plane/app/views/project.py @@ -388,7 +388,7 @@ class ProjectInvitationsViewset(BaseViewSet): {"error": "You cannot invite a user with higher role"}, status=status.HTTP_400_BAD_REQUEST, ) - + workspace = Workspace.objects.get(slug=slug) project_invitations = [] @@ -424,7 +424,7 @@ class ProjectInvitationsViewset(BaseViewSet): project_invitations = ProjectMemberInvite.objects.bulk_create( project_invitations, batch_size=10, ignore_conflicts=True ) - current_site = request.META.get('HTTP_ORIGIN') + current_site = request.META.get("HTTP_ORIGIN") # Send invitations for invitation in project_invitations: @@ -469,6 +469,13 @@ class UserProjectInvitationsViewset(BaseViewSet): workspace_role = workspace_member.role workspace = workspace_member.workspace + # If the user was already part of workspace + _ = ProjectMember.objects.filter( + workspace__slug=slug, + project_id__in=project_ids, + member=request.user, + ).update(is_active=True) + ProjectMember.objects.bulk_create( [ ProjectMember( @@ -1040,4 +1047,4 @@ class ProjectDeployBoardViewSet(BaseViewSet): project_deploy_board.save() serializer = ProjectDeployBoardSerializer(project_deploy_board) - return Response(serializer.data, status=status.HTTP_200_OK) \ No newline at end of file + return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/apiserver/plane/app/views/user.py b/apiserver/plane/app/views/user.py index f8298301c..95ea5fedc 100644 --- a/apiserver/plane/app/views/user.py +++ b/apiserver/plane/app/views/user.py @@ -48,7 +48,7 @@ class UserEndpoint(BaseViewSet): if WorkspaceMember.objects.filter(member=request.user, is_active=True).exists(): return Response( { - "error": "User cannot deactivate account as user is active in some workspaces" + "error": "You cannot deactivate account as you are a member in some workspaces." }, status=status.HTTP_400_BAD_REQUEST, ) diff --git a/apiserver/plane/app/views/webhook.py b/apiserver/plane/app/views/webhook.py index 74d23dd91..48608d583 100644 --- a/apiserver/plane/app/views/webhook.py +++ b/apiserver/plane/app/views/webhook.py @@ -20,9 +20,10 @@ class WebhookEndpoint(BaseAPIView): def post(self, request, slug): workspace = Workspace.objects.get(slug=slug) - try: - serializer = WebhookSerializer(data=request.data) + serializer = WebhookSerializer( + data=request.data, context={"request": request} + ) if serializer.is_valid(): serializer.save(workspace_id=workspace.id) return Response(serializer.data, status=status.HTTP_201_CREATED) @@ -79,6 +80,7 @@ class WebhookEndpoint(BaseAPIView): serializer = WebhookSerializer( webhook, data=request.data, + context={request: request}, partial=True, fields=( "id", diff --git a/apiserver/plane/app/views/workspace.py b/apiserver/plane/app/views/workspace.py index 637fc95b5..56f567bf4 100644 --- a/apiserver/plane/app/views/workspace.py +++ b/apiserver/plane/app/views/workspace.py @@ -590,7 +590,7 @@ class WorkSpaceMemberViewSet(BaseViewSet): member_with_role=Count( "project_projectmember", filter=Q( - project_projectmember__member_id=request.user.id, + project_projectmember__member_id=workspace_member.id, project_projectmember__role=20, ), ), @@ -600,7 +600,7 @@ class WorkSpaceMemberViewSet(BaseViewSet): ): return Response( { - "error": "User is part of some projects where they are the only admin you should leave that project first" + "error": "User is a part of some projects where they are the only admin, they should either leave that project or promote another user to admin." }, status=status.HTTP_400_BAD_REQUEST, ) @@ -635,7 +635,7 @@ class WorkSpaceMemberViewSet(BaseViewSet): ): return Response( { - "error": "You cannot leave the workspace as your the only admin of the workspace you will have to either delete the workspace or create an another admin" + "error": "You cannot leave the workspace as you are the only admin of the workspace you will have to either delete the workspace or promote another user to admin." }, status=status.HTTP_400_BAD_REQUEST, ) @@ -656,7 +656,7 @@ class WorkSpaceMemberViewSet(BaseViewSet): ): return Response( { - "error": "User is part of some projects where they are the only admin you should leave that project first" + "error": "You are a part of some projects where you are the only admin, you should either leave the project or promote another user to admin." }, status=status.HTTP_400_BAD_REQUEST, ) diff --git a/apiserver/plane/bgtasks/analytic_plot_export.py b/apiserver/plane/bgtasks/analytic_plot_export.py index 8cccc2299..e32c2fd68 100644 --- a/apiserver/plane/bgtasks/analytic_plot_export.py +++ b/apiserver/plane/bgtasks/analytic_plot_export.py @@ -1,6 +1,7 @@ # Python imports import csv import io +import os # Django imports from django.core.mail import EmailMultiAlternatives, get_connection @@ -32,7 +33,7 @@ row_mapping = { "priority": "Priority", "estimate": "Estimate", "issue_cycle__cycle_id": "Cycle", - "issue_module__module_id": "Module" + "issue_module__module_id": "Module", } ASSIGNEE_ID = "assignees__id" @@ -51,17 +52,48 @@ def send_export_email(email, slug, csv_buffer): csv_buffer.seek(0) # Configure email connection from the database - instance_configuration = InstanceConfiguration.objects.filter(key__startswith='EMAIL_').values("key", "value") + instance_configuration = InstanceConfiguration.objects.filter( + key__startswith="EMAIL_" + ).values("key", "value") connection = get_connection( - host=get_configuration_value(instance_configuration, "EMAIL_HOST"), - port=int(get_configuration_value(instance_configuration, "EMAIL_PORT", "587")), - username=get_configuration_value(instance_configuration, "EMAIL_HOST_USER"), - password=get_configuration_value(instance_configuration, "EMAIL_HOST_PASSWORD"), - use_tls=bool(get_configuration_value(instance_configuration, "EMAIL_USE_TLS", "1")), - use_ssl=bool(get_configuration_value(instance_configuration, "EMAIL_USE_SSL", "0")), + host=get_configuration_value( + instance_configuration, "EMAIL_HOST", os.environ.get("EMAIL_HOST") + ), + port=int( + get_configuration_value( + instance_configuration, "EMAIL_PORT", os.environ.get("EMAIL_PORT") + ) + ), + username=get_configuration_value( + instance_configuration, + "EMAIL_HOST_USER", + os.environ.get("EMAIL_HOST_USER"), + ), + password=get_configuration_value( + instance_configuration, + "EMAIL_HOST_PASSWORD", + os.environ.get("EMAIL_HOST_PASSWORD"), + ), + use_tls=bool( + get_configuration_value( + instance_configuration, + "EMAIL_USE_TLS", + os.environ.get("EMAIL_USE_TLS", "1"), + ) + ), ) - msg = EmailMultiAlternatives(subject=subject, body=text_content, from_email=get_configuration_value(instance_configuration, "EMAIL_FROM"), to=[email], connection=connection) + msg = EmailMultiAlternatives( + subject=subject, + body=text_content, + from_email=get_configuration_value( + instance_configuration, + "EMAIL_FROM", + os.environ.get("EMAIL_FROM", "Team Plane "), + ), + to=[email], + connection=connection, + ) msg.attach(f"{slug}-analytics.csv", csv_buffer.getvalue()) msg.send(fail_silently=False) diff --git a/apiserver/plane/bgtasks/email_verification_task.py b/apiserver/plane/bgtasks/email_verification_task.py index ba4ce6490..3f8f19fe6 100644 --- a/apiserver/plane/bgtasks/email_verification_task.py +++ b/apiserver/plane/bgtasks/email_verification_task.py @@ -1,3 +1,6 @@ +# Python imports +import os + # Django imports from django.core.mail import EmailMultiAlternatives, get_connection from django.template.loader import render_to_string @@ -14,15 +17,13 @@ from sentry_sdk import capture_exception from plane.license.models import InstanceConfiguration from plane.license.utils.instance_value import get_configuration_value + @shared_task def email_verification(first_name, email, token, current_site): - try: realtivelink = "/request-email-verification/" + "?token=" + str(token) abs_url = current_site + realtivelink - from_email_string = settings.EMAIL_FROM - subject = "Verify your Email!" context = { @@ -35,17 +36,49 @@ def email_verification(first_name, email, token, current_site): text_content = strip_tags(html_content) # Configure email connection from the database - instance_configuration = InstanceConfiguration.objects.filter(key__startswith='EMAIL_').values("key", "value") + instance_configuration = InstanceConfiguration.objects.filter( + key__startswith="EMAIL_" + ).values("key", "value") connection = get_connection( - host=get_configuration_value(instance_configuration, "EMAIL_HOST"), - port=int(get_configuration_value(instance_configuration, "EMAIL_PORT", "587")), - username=get_configuration_value(instance_configuration, "EMAIL_HOST_USER"), - password=get_configuration_value(instance_configuration, "EMAIL_HOST_PASSWORD"), - use_tls=bool(get_configuration_value(instance_configuration, "EMAIL_USE_TLS", "1")), + host=get_configuration_value( + instance_configuration, "EMAIL_HOST", os.environ.get("EMAIL_HOST") + ), + port=int( + get_configuration_value( + instance_configuration, "EMAIL_PORT", os.environ.get("EMAIL_PORT") + ) + ), + username=get_configuration_value( + instance_configuration, + "EMAIL_HOST_USER", + os.environ.get("EMAIL_HOST_USER"), + ), + password=get_configuration_value( + instance_configuration, + "EMAIL_HOST_PASSWORD", + os.environ.get("EMAIL_HOST_PASSWORD"), + ), + use_tls=bool( + get_configuration_value( + instance_configuration, + "EMAIL_USE_TLS", + os.environ.get("EMAIL_USE_TLS", "1"), + ) + ), ) # Initiate email alternatives - msg = EmailMultiAlternatives(subject=subject, body=text_content, from_email=get_configuration_value(instance_configuration, "EMAIL_FROM"), to=[email], connection=connection) + msg = EmailMultiAlternatives( + subject=subject, + body=text_content, + from_email=get_configuration_value( + instance_configuration, + "EMAIL_FROM", + os.environ.get("EMAIL_FROM", "Team Plane "), + ), + to=[email], + connection=connection, + ) msg.attach_alternative(html_content, "text/html") msg.send() return diff --git a/apiserver/plane/bgtasks/forgot_password_task.py b/apiserver/plane/bgtasks/forgot_password_task.py index b924ad3a2..ca0eeb91d 100644 --- a/apiserver/plane/bgtasks/forgot_password_task.py +++ b/apiserver/plane/bgtasks/forgot_password_task.py @@ -1,3 +1,6 @@ +# Python import +import os + # Django imports from django.core.mail import EmailMultiAlternatives, get_connection from django.template.loader import render_to_string @@ -19,8 +22,6 @@ def forgot_password(first_name, email, uidb64, token, current_site): realtivelink = f"/accounts/reset-password/?uidb64={uidb64}&token={token}" abs_url = current_site + realtivelink - from_email_string = settings.EMAIL_FROM - subject = "Reset Your Password - Plane" context = { @@ -34,14 +35,44 @@ def forgot_password(first_name, email, uidb64, token, current_site): instance_configuration = InstanceConfiguration.objects.filter(key__startswith='EMAIL_').values("key", "value") connection = get_connection( - host=get_configuration_value(instance_configuration, "EMAIL_HOST"), - port=int(get_configuration_value(instance_configuration, "EMAIL_PORT", "587")), - username=get_configuration_value(instance_configuration, "EMAIL_HOST_USER"), - password=get_configuration_value(instance_configuration, "EMAIL_HOST_PASSWORD"), - use_tls=bool(get_configuration_value(instance_configuration, "EMAIL_USE_TLS", "1")), + host=get_configuration_value( + instance_configuration, "EMAIL_HOST", os.environ.get("EMAIL_HOST") + ), + port=int( + get_configuration_value( + instance_configuration, "EMAIL_PORT", os.environ.get("EMAIL_PORT") + ) + ), + username=get_configuration_value( + instance_configuration, + "EMAIL_HOST_USER", + os.environ.get("EMAIL_HOST_USER"), + ), + password=get_configuration_value( + instance_configuration, + "EMAIL_HOST_PASSWORD", + os.environ.get("EMAIL_HOST_PASSWORD"), + ), + use_tls=bool( + get_configuration_value( + instance_configuration, + "EMAIL_USE_TLS", + os.environ.get("EMAIL_USE_TLS", "1"), + ) + ), + ) + + msg = EmailMultiAlternatives( + subject=subject, + body=text_content, + from_email=get_configuration_value( + instance_configuration, + "EMAIL_FROM", + os.environ.get("EMAIL_FROM", "Team Plane "), + ), + to=[email], + connection=connection, ) - # Initiate email alternatives - msg = EmailMultiAlternatives(subject=subject, body=text_content, from_email=get_configuration_value(instance_configuration, "EMAIL_FROM"), to=[email], connection=connection) msg.attach_alternative(html_content, "text/html") msg.send() return diff --git a/apiserver/plane/bgtasks/magic_link_code_task.py b/apiserver/plane/bgtasks/magic_link_code_task.py index 372cafa6e..0db8e5504 100644 --- a/apiserver/plane/bgtasks/magic_link_code_task.py +++ b/apiserver/plane/bgtasks/magic_link_code_task.py @@ -1,3 +1,6 @@ +# Python imports +import os + # Django imports from django.core.mail import EmailMultiAlternatives, get_connection from django.template.loader import render_to_string @@ -12,6 +15,7 @@ from sentry_sdk import capture_exception from plane.license.models import InstanceConfiguration from plane.license.utils.instance_value import get_configuration_value + @shared_task def magic_link(email, key, token, current_site): try: @@ -26,17 +30,48 @@ def magic_link(email, key, token, current_site): text_content = strip_tags(html_content) - instance_configuration = InstanceConfiguration.objects.filter(key__startswith='EMAIL_').values("key", "value") + instance_configuration = InstanceConfiguration.objects.filter( + key__startswith="EMAIL_" + ).values("key", "value") connection = get_connection( - host=get_configuration_value(instance_configuration, "EMAIL_HOST"), - port=int(get_configuration_value(instance_configuration, "EMAIL_PORT", "587")), - username=get_configuration_value(instance_configuration, "EMAIL_HOST_USER"), - password=get_configuration_value(instance_configuration, "EMAIL_HOST_PASSWORD"), - use_tls=bool(get_configuration_value(instance_configuration, "EMAIL_USE_TLS", "1")), + host=get_configuration_value( + instance_configuration, "EMAIL_HOST", os.environ.get("EMAIL_HOST") + ), + port=int( + get_configuration_value( + instance_configuration, "EMAIL_PORT", os.environ.get("EMAIL_PORT") + ) + ), + username=get_configuration_value( + instance_configuration, + "EMAIL_HOST_USER", + os.environ.get("EMAIL_HOST_USER"), + ), + password=get_configuration_value( + instance_configuration, + "EMAIL_HOST_PASSWORD", + os.environ.get("EMAIL_HOST_PASSWORD"), + ), + use_tls=bool( + get_configuration_value( + instance_configuration, + "EMAIL_USE_TLS", + os.environ.get("EMAIL_USE_TLS", "1"), + ) + ), ) - # Initiate email alternatives - msg = EmailMultiAlternatives(subject=subject, body=text_content, from_email=get_configuration_value(instance_configuration, "EMAIL_FROM"), to=[email], connection=connection) + msg = EmailMultiAlternatives( + subject=subject, + body=text_content, + from_email=get_configuration_value( + instance_configuration, + "EMAIL_FROM", + os.environ.get("EMAIL_FROM", "Team Plane "), + ), + to=[email], + connection=connection, + ) msg.attach_alternative(html_content, "text/html") msg.send() return diff --git a/apiserver/plane/bgtasks/notification_task.py b/apiserver/plane/bgtasks/notification_task.py index 0c2199e44..4bc27d3ee 100644 --- a/apiserver/plane/bgtasks/notification_task.py +++ b/apiserver/plane/bgtasks/notification_task.py @@ -190,6 +190,7 @@ def notifications(type, issue_id, project_id, actor_id, subscriber, issue_activi issue_activities_created) if issue_activities_created is not None else None ) if type not in [ + "issue.activity.deleted", "cycle.activity.created", "cycle.activity.deleted", "module.activity.created", diff --git a/apiserver/plane/bgtasks/project_invitation_task.py b/apiserver/plane/bgtasks/project_invitation_task.py index 311ccec0a..fc740afc5 100644 --- a/apiserver/plane/bgtasks/project_invitation_task.py +++ b/apiserver/plane/bgtasks/project_invitation_task.py @@ -1,3 +1,6 @@ +# Python import +import os + # Django imports from django.core.mail import EmailMultiAlternatives, get_connection from django.template.loader import render_to_string @@ -25,8 +28,6 @@ def project_invitation(email, project_id, token, current_site, invitor): relativelink = f"/project-invitations/?invitation_id={project_member_invite.id}&email={email}&slug={project.workspace.slug}&project_id={str(project_id)}" abs_url = current_site + relativelink - from_email_string = settings.EMAIL_FROM - subject = f"{user.first_name or user.display_name or user.email} invited you to join {project.name} on Plane" context = { @@ -48,14 +49,45 @@ def project_invitation(email, project_id, token, current_site, invitor): # Configure email connection from the database instance_configuration = InstanceConfiguration.objects.filter(key__startswith='EMAIL_').values("key", "value") connection = get_connection( - host=get_configuration_value(instance_configuration, "EMAIL_HOST"), - port=int(get_configuration_value(instance_configuration, "EMAIL_PORT", "587")), - username=get_configuration_value(instance_configuration, "EMAIL_HOST_USER"), - password=get_configuration_value(instance_configuration, "EMAIL_HOST_PASSWORD"), - use_tls=bool(get_configuration_value(instance_configuration, "EMAIL_USE_TLS", "1")), + host=get_configuration_value( + instance_configuration, "EMAIL_HOST", os.environ.get("EMAIL_HOST") + ), + port=int( + get_configuration_value( + instance_configuration, "EMAIL_PORT", os.environ.get("EMAIL_PORT") + ) + ), + username=get_configuration_value( + instance_configuration, + "EMAIL_HOST_USER", + os.environ.get("EMAIL_HOST_USER"), + ), + password=get_configuration_value( + instance_configuration, + "EMAIL_HOST_PASSWORD", + os.environ.get("EMAIL_HOST_PASSWORD"), + ), + use_tls=bool( + get_configuration_value( + instance_configuration, + "EMAIL_USE_TLS", + os.environ.get("EMAIL_USE_TLS", "1"), + ) + ), ) - # Initiate email alternatives - msg = EmailMultiAlternatives(subject=subject, body=text_content, from_email=get_configuration_value(instance_configuration, "EMAIL_FROM"), to=[email], connection=connection) + + msg = EmailMultiAlternatives( + subject=subject, + body=text_content, + from_email=get_configuration_value( + instance_configuration, + "EMAIL_FROM", + os.environ.get("EMAIL_FROM", "Team Plane "), + ), + to=[email], + connection=connection, + ) + msg.attach_alternative(html_content, "text/html") msg.send() return diff --git a/apiserver/plane/bgtasks/webhook_task.py b/apiserver/plane/bgtasks/webhook_task.py index 57f94dc03..c9e0ddceb 100644 --- a/apiserver/plane/bgtasks/webhook_task.py +++ b/apiserver/plane/bgtasks/webhook_task.py @@ -2,15 +2,67 @@ import requests import uuid import hashlib import json +import hmac # Django imports from django.conf import settings +from django.core.serializers.json import DjangoJSONEncoder # Third party imports from celery import shared_task from sentry_sdk import capture_exception -from plane.db.models import Webhook, WebhookLog +from plane.db.models import ( + Webhook, + WebhookLog, + Project, + Issue, + Cycle, + Module, + ModuleIssue, + CycleIssue, + IssueComment, +) +from plane.api.serializers import ( + ProjectSerializer, + IssueSerializer, + CycleSerializer, + ModuleSerializer, + CycleIssueSerializer, + ModuleIssueSerializer, + IssueCommentSerializer, + IssueExpandSerializer, +) + +SERIALIZER_MAPPER = { + "project": ProjectSerializer, + "issue": IssueExpandSerializer, + "cycle": CycleSerializer, + "module": ModuleSerializer, + "cycle_issue": CycleIssueSerializer, + "module_issue": ModuleIssueSerializer, + "issue_comment": IssueCommentSerializer, +} + +MODEL_MAPPER = { + "project": Project, + "issue": Issue, + "cycle": Cycle, + "module": Module, + "cycle_issue": CycleIssue, + "module_issue": ModuleIssue, + "issue_comment": IssueComment, +} + + +def get_model_data(event, event_id, many=False): + model = MODEL_MAPPER.get(event) + if many: + queryset = model.objects.filter(pk__in=event_id) + else: + queryset = model.objects.get(pk=event_id) + serializer = SERIALIZER_MAPPER.get(event) + return serializer(queryset, many=many).data @shared_task( @@ -31,19 +83,24 @@ def webhook_task(self, webhook, slug, event, event_data, action): "X-Plane-Event": event, } - # Your secret key + # # Your secret key + event_data = ( + json.loads(json.dumps(event_data, cls=DjangoJSONEncoder)) + if event_data is not None + else None + ) + + # Use HMAC for generating signature if webhook.secret_key: - # Concatenate the data and the secret key - message = event_data + webhook.secret_key - - # Create a SHA-256 hash of the message - sha256 = hashlib.sha256() - sha256.update(message.encode("utf-8")) - signature = sha256.hexdigest() + event_data_json = json.dumps(event_data) if event_data is not None else "{}" + hmac_signature = hmac.new( + webhook.secret_key.encode("utf-8"), + event_data_json.encode("utf-8"), + hashlib.sha256, + ) + signature = hmac_signature.hexdigest() headers["X-Plane-Signature"] = signature - event_data = json.loads(event_data) if event_data is not None else None - action = { "POST": "create", "PATCH": "update", @@ -96,10 +153,6 @@ def webhook_task(self, webhook, slug, event, event_data, action): 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) - return raise requests.RequestException() except Exception as e: @@ -110,7 +163,7 @@ def webhook_task(self, webhook, slug, event, event_data, action): @shared_task() -def send_webhook(event, event_data, action, slug): +def send_webhook(event, payload, kw, action, slug, bulk): try: webhooks = Webhook.objects.filter(workspace__slug=slug, is_active=True) @@ -120,17 +173,48 @@ def send_webhook(event, event_data, action, slug): if event == "issue": webhooks = webhooks.filter(issue=True) - if event == "module": + if event == "module" or event == "module_issue": webhooks = webhooks.filter(module=True) - if event == "cycle": + if event == "cycle" or event == "cycle_issue": webhooks = webhooks.filter(cycle=True) - if event == "issue-comment": + if event == "issue_comment": webhooks = webhooks.filter(issue_comment=True) - for webhook in webhooks: - webhook_task.delay(webhook.id, slug, event, event_data, action) + if webhooks: + if action in ["POST", "PATCH"]: + if bulk and event in ["cycle_issue", "module_issue"]: + event_data = IssueExpandSerializer( + Issue.objects.filter( + pk__in=[ + str(event.get("issue")) for event in payload + ] + ).prefetch_related("issue_cycle", "issue_module"), many=True + ).data + event = "issue" + action = "PATCH" + else: + event_data = [ + get_model_data( + event=event, + event_id=payload.get("id") if isinstance(payload, dict) else None, + 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, + ) except Exception as e: if settings.DEBUG: diff --git a/apiserver/plane/bgtasks/workspace_invitation_task.py b/apiserver/plane/bgtasks/workspace_invitation_task.py index 7be1dbf60..27a1d1d38 100644 --- a/apiserver/plane/bgtasks/workspace_invitation_task.py +++ b/apiserver/plane/bgtasks/workspace_invitation_task.py @@ -1,3 +1,6 @@ +# Python imports +import os + # Django imports from django.core.mail import EmailMultiAlternatives, get_connection from django.template.loader import render_to_string @@ -32,9 +35,6 @@ def workspace_invitation(email, workspace_id, token, current_site, invitor): # The complete url including the domain abs_url = current_site + relative_link - # The email from - from_email_string = settings.EMAIL_FROM - # Subject of the email subject = f"{user.first_name or user.display_name or user.email} invited you to join {workspace.name} on Plane" @@ -58,23 +58,41 @@ def workspace_invitation(email, workspace_id, token, current_site, invitor): key__startswith="EMAIL_" ).values("key", "value") connection = get_connection( - host=get_configuration_value(instance_configuration, "EMAIL_HOST"), - port=int( - get_configuration_value(instance_configuration, "EMAIL_PORT", "587") + host=get_configuration_value( + instance_configuration, "EMAIL_HOST", os.environ.get("EMAIL_HOST") + ), + port=int( + get_configuration_value( + instance_configuration, "EMAIL_PORT", os.environ.get("EMAIL_PORT") + ) + ), + username=get_configuration_value( + instance_configuration, + "EMAIL_HOST_USER", + os.environ.get("EMAIL_HOST_USER"), ), - username=get_configuration_value(instance_configuration, "EMAIL_HOST_USER"), password=get_configuration_value( - instance_configuration, "EMAIL_HOST_PASSWORD" + instance_configuration, + "EMAIL_HOST_PASSWORD", + os.environ.get("EMAIL_HOST_PASSWORD"), ), use_tls=bool( - get_configuration_value(instance_configuration, "EMAIL_USE_TLS", "1") + get_configuration_value( + instance_configuration, + "EMAIL_USE_TLS", + os.environ.get("EMAIL_USE_TLS", "1"), + ) ), ) - # Initiate email alternatives + msg = EmailMultiAlternatives( subject=subject, body=text_content, - from_email=get_configuration_value(instance_configuration, "EMAIL_FROM"), + from_email=get_configuration_value( + instance_configuration, + "EMAIL_FROM", + os.environ.get("EMAIL_FROM", "Team Plane "), + ), to=[email], connection=connection, ) diff --git a/apiserver/plane/db/migrations/0052_alter_workspace_slug.py b/apiserver/plane/db/migrations/0052_alter_workspace_slug.py new file mode 100644 index 000000000..8126c1fa3 --- /dev/null +++ b/apiserver/plane/db/migrations/0052_alter_workspace_slug.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2.5 on 2023-11-23 14:57 + +from django.db import migrations, models +import plane.db.models.workspace + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0051_fileasset_is_deleted'), + ] + + operations = [ + migrations.AlterField( + model_name='workspace', + name='slug', + field=models.SlugField(max_length=48, unique=True, validators=[plane.db.models.workspace.slug_validator]), + ), + ] diff --git a/apiserver/plane/db/models/module.py b/apiserver/plane/db/models/module.py index e286d297a..ae540cc6c 100644 --- a/apiserver/plane/db/models/module.py +++ b/apiserver/plane/db/models/module.py @@ -51,9 +51,9 @@ class Module(ProjectBaseModel): def save(self, *args, **kwargs): if self._state.adding: - smallest_sort_order = Module.objects.filter( - project=self.project - ).aggregate(smallest=models.Min("sort_order"))["smallest"] + smallest_sort_order = Module.objects.filter(project=self.project).aggregate( + smallest=models.Min("sort_order") + )["smallest"] if smallest_sort_order is not None: self.sort_order = smallest_sort_order - 10000 diff --git a/apiserver/plane/db/models/webhook.py b/apiserver/plane/db/models/webhook.py index 6698ec5b0..ea2b508e5 100644 --- a/apiserver/plane/db/models/webhook.py +++ b/apiserver/plane/db/models/webhook.py @@ -16,7 +16,6 @@ def generate_token(): def validate_schema(value): parsed_url = urlparse(value) - print(parsed_url) if parsed_url.scheme not in ["http", "https"]: raise ValidationError("Invalid schema. Only HTTP and HTTPS are allowed.") diff --git a/apiserver/plane/db/models/workspace.py b/apiserver/plane/db/models/workspace.py index cbf4d97df..505bfbcfa 100644 --- a/apiserver/plane/db/models/workspace.py +++ b/apiserver/plane/db/models/workspace.py @@ -1,6 +1,7 @@ # Django imports from django.db import models from django.conf import settings +from django.core.exceptions import ValidationError # Module imports from . import BaseModel @@ -50,7 +51,7 @@ def get_default_props(): "state": True, "sub_issue_count": True, "updated_on": True, - } + }, } @@ -63,6 +64,23 @@ def get_issue_props(): } +def slug_validator(value): + if value in [ + "404", + "accounts", + "api", + "create-workspace", + "god-mode", + "installations", + "invitations", + "onboarding", + "profile", + "spaces", + "workspace-invitations", + ]: + raise ValidationError("Slug is not valid") + + class Workspace(BaseModel): name = models.CharField(max_length=80, verbose_name="Workspace Name") logo = models.URLField(verbose_name="Logo", blank=True, null=True) @@ -71,7 +89,7 @@ class Workspace(BaseModel): on_delete=models.CASCADE, related_name="owner_workspace", ) - slug = models.SlugField(max_length=48, db_index=True, unique=True) + slug = models.SlugField(max_length=48, db_index=True, unique=True, validators=[slug_validator,]) organization_size = models.CharField(max_length=20, blank=True, null=True) def __str__(self): diff --git a/apiserver/plane/license/management/commands/configure_instance.py b/apiserver/plane/license/management/commands/configure_instance.py index 9e3e253ad..c0df0d154 100644 --- a/apiserver/plane/license/management/commands/configure_instance.py +++ b/apiserver/plane/license/management/commands/configure_instance.py @@ -30,9 +30,11 @@ class Command(BaseCommand): "EMAIL_USE_TLS": os.environ.get("EMAIL_USE_TLS", "1"), "EMAIL_USE_SSL": os.environ.get("EMAIL_USE_SSL", "0"), # Open AI Settings - "OPENAI_API_BASE": os.environ.get("", "https://api.openai.com/v1"), - "OPENAI_API_KEY": os.environ.get("OPENAI_API_KEY", "sk-"), + "OPENAI_API_BASE": os.environ.get("OPENAI_API_BASE", "https://api.openai.com/v1"), + "OPENAI_API_KEY": os.environ.get("OPENAI_API_KEY", ""), "GPT_ENGINE": os.environ.get("GPT_ENGINE", "gpt-3.5-turbo"), + # Unsplash Access Key + "UNSPLASH_ACCESS_KEY": os.environ.get("UNSPLASH_ACESS_KEY", "") } for key, value in config_keys.items():