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 <narayan3119@gmail.com>
This commit is contained in:
Nikhil 2023-11-24 12:19:26 +05:30 committed by GitHub
parent 8d76c96a6f
commit a6d5eab634
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
45 changed files with 811 additions and 270 deletions

View File

@ -9,8 +9,9 @@ from .issue import (
IssueCommentSerializer, IssueCommentSerializer,
IssueAttachmentSerializer, IssueAttachmentSerializer,
IssueActivitySerializer, IssueActivitySerializer,
IssueExpandSerializer,
) )
from .state import StateLiteSerializer, StateSerializer from .state import StateLiteSerializer, StateSerializer
from .cycle import CycleSerializer, CycleIssueSerializer from .cycle import CycleSerializer, CycleIssueSerializer, CycleLiteSerializer
from .module import ModuleSerializer, ModuleIssueSerializer from .module import ModuleSerializer, ModuleIssueSerializer, ModuleLiteSerializer
from .inbox import InboxIssueSerializer from .inbox import InboxIssueSerializer

View File

@ -47,3 +47,10 @@ class CycleIssueSerializer(BaseSerializer):
"project", "project",
"cycle", "cycle",
] ]
class CycleLiteSerializer(BaseSerializer):
class Meta:
model = Cycle
fields = "__all__"

View File

@ -8,6 +8,12 @@ class InboxIssueSerializer(BaseSerializer):
model = InboxIssue model = InboxIssue
fields = "__all__" fields = "__all__"
read_only_fields = [ read_only_fields = [
"project", "id",
"workspace", "workspace",
"project",
"issue",
"created_by",
"updated_by",
"created_at",
"updated_at",
] ]

View File

@ -19,6 +19,8 @@ from plane.db.models import (
ProjectMember, ProjectMember,
) )
from .base import BaseSerializer from .base import BaseSerializer
from .cycle import CycleSerializer, CycleLiteSerializer
from .module import ModuleSerializer, ModuleLiteSerializer
class IssueSerializer(BaseSerializer): class IssueSerializer(BaseSerializer):
@ -42,6 +44,7 @@ class IssueSerializer(BaseSerializer):
model = Issue model = Issue
fields = "__all__" fields = "__all__"
read_only_fields = [ read_only_fields = [
"id",
"workspace", "workspace",
"project", "project",
"created_by", "created_by",
@ -60,9 +63,9 @@ class IssueSerializer(BaseSerializer):
# Validate assignees are from project # Validate assignees are from project
if data.get("assignees", []): if data.get("assignees", []):
print(data.get("assignees"))
data["assignees"] = ProjectMember.objects.filter( data["assignees"] = ProjectMember.objects.filter(
project_id=self.context.get("project_id"), project_id=self.context.get("project_id"),
is_active=True,
member_id__in=data["assignees"], member_id__in=data["assignees"],
).values_list("member_id", flat=True) ).values_list("member_id", flat=True)
@ -88,7 +91,7 @@ class IssueSerializer(BaseSerializer):
if ( if (
data.get("parent") data.get("parent")
and not Issue.objects.filter( 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() ).exists()
): ):
raise serializers.ValidationError( raise serializers.ValidationError(
@ -231,8 +234,13 @@ class LabelSerializer(BaseSerializer):
model = Label model = Label
fields = "__all__" fields = "__all__"
read_only_fields = [ read_only_fields = [
"id",
"workspace", "workspace",
"project", "project",
"created_by",
"updated_by",
"created_at",
"updated_at",
] ]
@ -241,13 +249,14 @@ class IssueLinkSerializer(BaseSerializer):
model = IssueLink model = IssueLink
fields = "__all__" fields = "__all__"
read_only_fields = [ read_only_fields = [
"id",
"workspace", "workspace",
"project", "project",
"issue",
"created_by", "created_by",
"updated_by", "updated_by",
"created_at", "created_at",
"updated_at", "updated_at",
"issue",
] ]
# Validation if url already exists # Validation if url already exists
@ -266,13 +275,14 @@ class IssueAttachmentSerializer(BaseSerializer):
model = IssueAttachment model = IssueAttachment
fields = "__all__" fields = "__all__"
read_only_fields = [ read_only_fields = [
"id",
"workspace",
"project",
"issue",
"created_by", "created_by",
"updated_by", "updated_by",
"created_at", "created_at",
"updated_at", "updated_at",
"workspace",
"project",
"issue",
] ]
@ -282,38 +292,61 @@ class IssueCommentSerializer(BaseSerializer):
class Meta: class Meta:
model = IssueComment model = IssueComment
fields = "__all__" 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 = [ read_only_fields = [
"id", "id",
"workspace",
"project",
"issue",
"created_by", "created_by",
"updated_by", "updated_by",
"created_at", "created_at",
"updated_at", "updated_at",
"workspace",
"project",
"issue",
] ]
class IssueActivitySerializer(BaseSerializer): class IssueActivitySerializer(BaseSerializer):
class Meta: class Meta:
model = IssueActivity model = IssueActivity
fields = "__all__"
exclude = [ exclude = [
"created_by", "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",
] ]

View File

@ -21,7 +21,6 @@ class ModuleSerializer(BaseSerializer):
write_only=True, write_only=True,
required=False, required=False,
) )
is_favorite = serializers.BooleanField(read_only=True)
total_issues = serializers.IntegerField(read_only=True) total_issues = serializers.IntegerField(read_only=True)
cancelled_issues = serializers.IntegerField(read_only=True) cancelled_issues = serializers.IntegerField(read_only=True)
completed_issues = serializers.IntegerField(read_only=True) completed_issues = serializers.IntegerField(read_only=True)
@ -33,6 +32,7 @@ class ModuleSerializer(BaseSerializer):
model = Module model = Module
fields = "__all__" fields = "__all__"
read_only_fields = [ read_only_fields = [
"id",
"workspace", "workspace",
"project", "project",
"created_by", "created_by",
@ -153,3 +153,10 @@ class ModuleLinkSerializer(BaseSerializer):
{"error": "URL already exists for this Issue"} {"error": "URL already exists for this Issue"}
) )
return ModuleLink.objects.create(**validated_data) return ModuleLink.objects.create(**validated_data)
class ModuleLiteSerializer(BaseSerializer):
class Meta:
model = Module
fields = "__all__"

View File

@ -20,8 +20,12 @@ class ProjectSerializer(BaseSerializer):
model = Project model = Project
fields = "__all__" fields = "__all__"
read_only_fields = [ read_only_fields = [
"workspace",
"id", "id",
"workspace",
"created_at",
"updated_at",
"created_by",
"updated_by",
] ]
def validate(self, data): def validate(self, data):

View File

@ -13,7 +13,7 @@ urlpatterns = [
name="cycles", name="cycles",
), ),
path( path(
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/<uuid:cycle_id>/", "workspaces/<str:slug>/projects/<uuid:project_id>/cycles/<uuid:pk>/",
CycleAPIEndpoint.as_view(), CycleAPIEndpoint.as_view(),
name="cycles", name="cycles",
), ),
@ -23,7 +23,7 @@ urlpatterns = [
name="cycle-issues", name="cycle-issues",
), ),
path( path(
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/<uuid:cycle_id>/cycle-issues/<uuid:pk>/", "workspaces/<str:slug>/projects/<uuid:project_id>/cycles/<uuid:cycle_id>/cycle-issues/<uuid:issue_id>/",
CycleIssueAPIEndpoint.as_view(), CycleIssueAPIEndpoint.as_view(),
name="cycle-issues", name="cycle-issues",
), ),

View File

@ -5,12 +5,12 @@ from plane.api.views import InboxIssueAPIEndpoint
urlpatterns = [ urlpatterns = [
path( path(
"workspaces/<str:slug>/projects/<uuid:project_id>/inboxes/<uuid:inbox_id>/inbox-issues/", "workspaces/<str:slug>/projects/<uuid:project_id>/inbox-issues/",
InboxIssueAPIEndpoint.as_view(), InboxIssueAPIEndpoint.as_view(),
name="inbox-issue", name="inbox-issue",
), ),
path( path(
"workspaces/<str:slug>/projects/<uuid:project_id>/inboxes/<uuid:inbox_id>/inbox-issues/<uuid:pk>/", "workspaces/<str:slug>/projects/<uuid:project_id>/inbox-issues/<uuid:pk>/",
InboxIssueAPIEndpoint.as_view(), InboxIssueAPIEndpoint.as_view(),
name="inbox-issue", name="inbox-issue",
), ),

View File

@ -15,27 +15,27 @@ urlpatterns = [
name="issue", name="issue",
), ),
path( path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/", "workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:pk>/",
IssueAPIEndpoint.as_view(), IssueAPIEndpoint.as_view(),
name="issue", name="issue",
), ),
path( path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issue-labels/", "workspaces/<str:slug>/projects/<uuid:project_id>/labels/",
LabelAPIEndpoint.as_view(), LabelAPIEndpoint.as_view(),
name="label", name="label",
), ),
path( path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issue-labels/<uuid:pk>/", "workspaces/<str:slug>/projects/<uuid:project_id>/labels/<uuid:pk>/",
LabelAPIEndpoint.as_view(), LabelAPIEndpoint.as_view(),
name="label", name="label",
), ),
path( path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/issue-links/", "workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/links/",
IssueLinkAPIEndpoint.as_view(), IssueLinkAPIEndpoint.as_view(),
name="link", name="link",
), ),
path( path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/issue-links/<uuid:pk>/", "workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/links/<uuid:pk>/",
IssueLinkAPIEndpoint.as_view(), IssueLinkAPIEndpoint.as_view(),
name="link", name="link",
), ),
@ -50,12 +50,12 @@ urlpatterns = [
name="comment", name="comment",
), ),
path( path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/activites/", "workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/activities/",
IssueActivityAPIEndpoint.as_view(), IssueActivityAPIEndpoint.as_view(),
name="activity", name="activity",
), ),
path( path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/activites/<uuid:pk>/", "workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/activities/<uuid:pk>/",
IssueActivityAPIEndpoint.as_view(), IssueActivityAPIEndpoint.as_view(),
name="activity", name="activity",
), ),

View File

@ -19,7 +19,7 @@ urlpatterns = [
name="module-issues", name="module-issues",
), ),
path( path(
"workspaces/<str:slug>/projects/<uuid:project_id>/modules/<uuid:module_id>/module-issues/<uuid:pk>/", "workspaces/<str:slug>/projects/<uuid:project_id>/modules/<uuid:module_id>/module-issues/<uuid:issue_id>/",
ModuleIssueAPIEndpoint.as_view(), ModuleIssueAPIEndpoint.as_view(),
name="module-issues", name="module-issues",
), ),

View File

@ -9,7 +9,7 @@ urlpatterns = [
name="project", name="project",
), ),
path( path(
"workspaces/<str:slug>/projects/<uuid:pk>/", "workspaces/<str:slug>/projects/<uuid:project_id>/",
ProjectAPIEndpoint.as_view(), ProjectAPIEndpoint.as_view(),
name="project", name="project",
), ),

View File

@ -8,4 +8,9 @@ urlpatterns = [
StateAPIEndpoint.as_view(), StateAPIEndpoint.as_view(),
name="states", name="states",
), ),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/states/<uuid:state_id>/",
StateAPIEndpoint.as_view(),
name="states",
),
] ]

View File

@ -7,7 +7,6 @@ from django.conf import settings
from django.db import IntegrityError from django.db import IntegrityError
from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.core.exceptions import ObjectDoesNotExist, ValidationError
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.views import APIView from rest_framework.views import APIView
@ -36,28 +35,33 @@ class TimezoneMixin:
else: else:
timezone.deactivate() timezone.deactivate()
class WebhookMixin: class WebhookMixin:
webhook_event = None webhook_event = None
bulk = False
def finalize_response(self, request, response, *args, **kwargs): def finalize_response(self, request, response, *args, **kwargs):
response = super().finalize_response(request, response, *args, **kwargs) response = super().finalize_response(request, response, *args, **kwargs)
# Check for the case should webhook be sent
if ( if (
self.webhook_event self.webhook_event
and self.request.method in ["POST", "PATCH", "DELETE"] and self.request.method in ["POST", "PATCH", "DELETE"]
and response.status_code in [200, 201, 204] and response.status_code in [200, 201, 204]
): ):
# Push the object to delay
send_webhook.delay( send_webhook.delay(
event=self.webhook_event, event=self.webhook_event,
event_data=json.dumps(response.data, cls=DjangoJSONEncoder), payload=response.data,
kw=self.kwargs,
action=self.request.method, action=self.request.method,
slug=self.workspace_slug, slug=self.workspace_slug,
bulk=self.bulk,
) )
return response return response
class BaseAPIView(TimezoneMixin, APIView, BasePaginator): class BaseAPIView(TimezoneMixin, APIView, BasePaginator):
authentication_classes = [ authentication_classes = [
APIKeyAuthentication, APIKeyAuthentication,
@ -139,13 +143,13 @@ class BaseAPIView(TimezoneMixin, APIView, BasePaginator):
response = super().finalize_response(request, response, *args, **kwargs) response = super().finalize_response(request, response, *args, **kwargs)
# Add custom headers if they exist in the request META # 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: 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: if ratelimit_reset is not None:
response['X-RateLimit-Reset'] = ratelimit_reset response["X-RateLimit-Reset"] = ratelimit_reset
return response return response

View File

@ -17,7 +17,6 @@ from plane.app.permissions import ProjectEntityPermission
from plane.api.serializers import ( from plane.api.serializers import (
CycleSerializer, CycleSerializer,
CycleIssueSerializer, CycleIssueSerializer,
IssueSerializer,
) )
from plane.bgtasks.issue_activites_task import issue_activity from plane.bgtasks.issue_activites_task import issue_activity
@ -142,7 +141,6 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView):
) )
queryset = self.get_queryset() queryset = self.get_queryset()
cycle_view = request.GET.get("cycle_view", "all") cycle_view = request.GET.get("cycle_view", "all")
queryset = queryset.order_by("-is_favorite", "-created_at")
# Current Cycle # Current Cycle
if cycle_view == "current": if cycle_view == "current":
@ -293,7 +291,7 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView):
} }
), ),
actor_id=str(request.user.id), actor_id=str(request.user.id),
issue_id=str(pk), issue_id=None,
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()),
@ -305,14 +303,15 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView):
class CycleIssueAPIEndpoint(WebhookMixin, BaseAPIView): class CycleIssueAPIEndpoint(WebhookMixin, BaseAPIView):
""" """
This viewset automatically provides `list`, `create`, `retrieve`, This viewset automatically provides `list`, `create`,
`update` and `destroy` actions related to cycle issues. and `destroy` actions related to cycle issues.
""" """
serializer_class = CycleIssueSerializer serializer_class = CycleIssueSerializer
model = CycleIssue model = CycleIssue
webhook_event = "cycle" webhook_event = "cycle_issue"
bulk = True
permission_classes = [ permission_classes = [
ProjectEntityPermission, ProjectEntityPermission,
] ]
@ -457,7 +456,7 @@ class CycleIssueAPIEndpoint(WebhookMixin, BaseAPIView):
# Capture Issue Activity # Capture Issue Activity
issue_activity.delay( issue_activity.delay(
type="cycle.activity.created", 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), actor_id=str(self.request.user.id),
issue_id=None, issue_id=None,
project_id=str(self.kwargs.get("project_id", None)), project_id=str(self.kwargs.get("project_id", None)),
@ -478,9 +477,9 @@ class CycleIssueAPIEndpoint(WebhookMixin, BaseAPIView):
status=status.HTTP_200_OK, 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( 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 issue_id = cycle_issue.issue_id
cycle_issue.delete() cycle_issue.delete()
@ -493,7 +492,7 @@ class CycleIssueAPIEndpoint(WebhookMixin, BaseAPIView):
} }
), ),
actor_id=str(self.request.user.id), 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)), project_id=str(self.kwargs.get("project_id", None)),
current_instance=None, current_instance=None,
epoch=int(timezone.now().timestamp()), epoch=int(timezone.now().timestamp()),

View File

@ -14,7 +14,7 @@ from rest_framework.response import Response
from .base import BaseAPIView from .base import BaseAPIView
from plane.app.permissions import ProjectLitePermission from plane.app.permissions import ProjectLitePermission
from plane.api.serializers import InboxIssueSerializer, IssueSerializer 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 from plane.bgtasks.issue_activites_task import issue_activity
@ -37,29 +37,39 @@ class InboxIssueAPIEndpoint(BaseAPIView):
] ]
def get_queryset(self): def get_queryset(self):
return self.filter_queryset( inbox = Inbox.objects.filter(
super() workspace__slug=self.kwargs.get("slug"),
.get_queryset() project_id=self.kwargs.get("project_id"),
.filter( ).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), Q(snoozed_till__gte=timezone.now()) | Q(snoozed_till__isnull=True),
workspace__slug=self.kwargs.get("slug"), workspace__slug=self.kwargs.get("slug"),
project_id=self.kwargs.get("project_id"), project_id=self.kwargs.get("project_id"),
inbox_id=self.kwargs.get("inbox_id"), inbox_id=inbox.id,
) )
.select_related("issue", "workspace", "project") .select_related("issue", "workspace", "project")
.order_by(self.kwargs.get("order_by", "-created_at")) .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: if pk:
issue_queryset = self.get_queryset().get(pk=pk) inbox_issue_queryset = self.get_queryset().get(pk=pk)
issues_data = InboxIssueSerializer( inbox_issue_data = InboxIssueSerializer(
issue_queryset, inbox_issue_queryset,
fields=self.fields, fields=self.fields,
expand=self.expand, expand=self.expand,
).data ).data
return Response( return Response(
issues_data, inbox_issue_data,
status=status.HTTP_200_OK, status=status.HTTP_200_OK,
) )
issue_queryset = self.get_queryset() issue_queryset = self.get_queryset()
@ -74,12 +84,30 @@ class InboxIssueAPIEndpoint(BaseAPIView):
).data, ).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): if not request.data.get("issue", {}).get("name", False):
return Response( return Response(
{"error": "Name is required"}, status=status.HTTP_400_BAD_REQUEST {"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 # Check for valid priority
if not request.data.get("issue", {}).get("priority", "none") in [ if not request.data.get("issue", {}).get("priority", "none") in [
"low", "low",
@ -123,21 +151,45 @@ class InboxIssueAPIEndpoint(BaseAPIView):
current_instance=None, current_instance=None,
epoch=int(timezone.now().timestamp()), epoch=int(timezone.now().timestamp()),
) )
# create an inbox issue # create an inbox issue
InboxIssue.objects.create( inbox_issue = InboxIssue.objects.create(
inbox_id=inbox_id, inbox_id=inbox.id,
project_id=project_id, project_id=project_id,
issue=issue, issue=issue,
source=request.data.get("source", "in-app"), source=request.data.get("source", "in-app"),
) )
serializer = IssueSerializer(issue) serializer = InboxIssueSerializer(inbox_issue)
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)
def patch(self, request, slug, project_id, inbox_id, pk): def patch(self, request, slug, project_id, pk):
inbox_issue = InboxIssue.objects.get( inbox = Inbox.objects.filter(
pk=pk, workspace__slug=slug, project_id=project_id, inbox_id=inbox_id 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 # Get the project member
project_member = ProjectMember.objects.get( project_member = ProjectMember.objects.get(
workspace__slug=slug, workspace__slug=slug,
@ -145,6 +197,7 @@ class InboxIssueAPIEndpoint(BaseAPIView):
member=request.user, member=request.user,
is_active=True, is_active=True,
) )
# Only project members admins and created_by users can access this endpoint # 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( if project_member.role <= 10 and str(inbox_issue.created_by_id) != str(
request.user.id request.user.id
@ -244,10 +297,33 @@ class InboxIssueAPIEndpoint(BaseAPIView):
InboxIssueSerializer(inbox_issue).data, status=status.HTTP_200_OK InboxIssueSerializer(inbox_issue).data, status=status.HTTP_200_OK
) )
def delete(self, request, slug, project_id, inbox_id, pk): def delete(self, request, slug, project_id, pk):
inbox_issue = InboxIssue.objects.get( inbox = Inbox.objects.filter(
pk=pk, workspace__slug=slug, project_id=project_id, inbox_id=inbox_id 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 # Get the project member
project_member = ProjectMember.objects.get( project_member = ProjectMember.objects.get(
workspace__slug=slug, workspace__slug=slug,
@ -256,6 +332,7 @@ class InboxIssueAPIEndpoint(BaseAPIView):
is_active=True, is_active=True,
) )
# Check the inbox issue created
if project_member.role <= 10 and str(inbox_issue.created_by_id) != str( if project_member.role <= 10 and str(inbox_issue.created_by_id) != str(
request.user.id request.user.id
): ):

View File

@ -22,7 +22,6 @@ from django.utils import timezone
# Third party imports # Third party imports
from rest_framework import status from rest_framework import status
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.parsers import MultiPartParser, FormParser
# Module imports # Module imports
from .base import BaseAPIView, WebhookMixin from .base import BaseAPIView, WebhookMixin
@ -41,14 +40,12 @@ from plane.db.models import (
IssueComment, IssueComment,
IssueActivity, IssueActivity,
) )
from plane.utils.issue_filters import issue_filters
from plane.bgtasks.issue_activites_task import issue_activity from plane.bgtasks.issue_activites_task import issue_activity
from plane.api.serializers import ( from plane.api.serializers import (
IssueSerializer, IssueSerializer,
LabelSerializer, LabelSerializer,
IssueLinkSerializer, IssueLinkSerializer,
IssueCommentSerializer, IssueCommentSerializer,
IssueAttachmentSerializer,
IssueActivitySerializer, IssueActivitySerializer,
) )
@ -103,7 +100,6 @@ class IssueAPIEndpoint(WebhookMixin, BaseAPIView):
status=status.HTTP_200_OK, status=status.HTTP_200_OK,
) )
filters = issue_filters(request.query_params, "GET")
# Custom ordering for priority and state # Custom ordering for priority and state
priority_order = ["urgent", "high", "medium", "low", "none"] priority_order = ["urgent", "high", "medium", "low", "none"]
state_order = ["backlog", "unstarted", "started", "completed", "cancelled"] state_order = ["backlog", "unstarted", "started", "completed", "cancelled"]
@ -112,7 +108,6 @@ class IssueAPIEndpoint(WebhookMixin, BaseAPIView):
issue_queryset = ( issue_queryset = (
self.get_queryset() self.get_queryset()
.filter(**filters)
.annotate(cycle_id=F("issue_cycle__cycle_id")) .annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(module_id=F("issue_module__module_id")) .annotate(module_id=F("issue_module__module_id"))
.annotate( .annotate(
@ -278,7 +273,7 @@ class LabelAPIEndpoint(BaseAPIView):
def get_queryset(self): def get_queryset(self):
return ( 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_id=self.kwargs.get("project_id"))
.filter(project__project_projectmember__member=self.request.user) .filter(project__project_projectmember__member=self.request.user)
.select_related("project") .select_related("project")
@ -302,14 +297,7 @@ class LabelAPIEndpoint(BaseAPIView):
) )
def get(self, request, slug, project_id, pk=None): def get(self, request, slug, project_id, pk=None):
if pk: if pk is None:
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)
return self.paginate( return self.paginate(
request=request, request=request,
queryset=(self.get_queryset()), queryset=(self.get_queryset()),
@ -320,11 +308,18 @@ class LabelAPIEndpoint(BaseAPIView):
expand=self.expand, expand=self.expand,
).data, ).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): def patch(self, request, slug, project_id, pk=None):
label = self.get_queryset().get(pk=pk) label = self.get_queryset().get(pk=pk)
serializer = LabelSerializer(label, data=request.data, partial=True) serializer = LabelSerializer(label, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
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)
def delete(self, request, slug, project_id, pk=None): def delete(self, request, slug, project_id, pk=None):
label = self.get_queryset().get(pk=pk) label = self.get_queryset().get(pk=pk)
@ -356,15 +351,14 @@ class IssueLinkAPIEndpoint(BaseAPIView):
.distinct() .distinct()
) )
def get(self, request, slug, project_id, pk=None): def get(self, request, slug, project_id, issue_id, pk=None):
if pk: if pk is None:
label = self.get_queryset().get(pk=pk) issue_links = self.get_queryset()
serializer = IssueLinkSerializer( serializer = IssueLinkSerializer(
label, issue_links,
fields=self.fields, fields=self.fields,
expand=self.expand, expand=self.expand,
) )
return Response(serializer.data, status=status.HTTP_200_OK)
return self.paginate( return self.paginate(
request=request, request=request,
queryset=(self.get_queryset()), queryset=(self.get_queryset()),
@ -375,6 +369,13 @@ class IssueLinkAPIEndpoint(BaseAPIView):
expand=self.expand, expand=self.expand,
).data, ).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): def post(self, request, slug, project_id, issue_id):
serializer = IssueLinkSerializer(data=request.data) serializer = IssueLinkSerializer(data=request.data)
@ -449,7 +450,7 @@ class IssueCommentAPIEndpoint(WebhookMixin, BaseAPIView):
serializer_class = IssueCommentSerializer serializer_class = IssueCommentSerializer
model = IssueComment model = IssueComment
webhook_event = "issue-comment" webhook_event = "issue_comment"
permission_classes = [ permission_classes = [
ProjectLitePermission, ProjectLitePermission,
] ]
@ -587,7 +588,7 @@ class IssueActivityAPIEndpoint(BaseAPIView):
serializer = IssueActivitySerializer(issue_activities) serializer = IssueActivitySerializer(issue_activities)
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)
self.paginate( return self.paginate(
request=request, request=request,
queryset=(issue_activities), queryset=(issue_activities),
on_results=lambda issue_activity: IssueActivitySerializer( on_results=lambda issue_activity: IssueActivitySerializer(

View File

@ -130,6 +130,14 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView):
return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
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): def get(self, request, slug, project_id, pk=None):
if pk: if pk:
queryset = self.get_queryset().get(pk=pk) queryset = self.get_queryset().get(pk=pk)
@ -168,7 +176,7 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView):
} }
), ),
actor_id=str(request.user.id), actor_id=str(request.user.id),
issue_id=str(pk), issue_id=None,
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()),
@ -186,7 +194,8 @@ class ModuleIssueAPIEndpoint(WebhookMixin, BaseAPIView):
serializer_class = ModuleIssueSerializer serializer_class = ModuleIssueSerializer
model = ModuleIssue model = ModuleIssue
webhook_event = "module" webhook_event = "module_issue"
bulk = True
permission_classes = [ permission_classes = [
ProjectEntityPermission, ProjectEntityPermission,
@ -323,7 +332,7 @@ class ModuleIssueAPIEndpoint(WebhookMixin, BaseAPIView):
# Capture Issue Activity # Capture Issue Activity
issue_activity.delay( issue_activity.delay(
type="module.activity.created", 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), actor_id=str(self.request.user.id),
issue_id=None, issue_id=None,
project_id=str(self.kwargs.get("project_id", None)), project_id=str(self.kwargs.get("project_id", None)),
@ -343,9 +352,9 @@ class ModuleIssueAPIEndpoint(WebhookMixin, BaseAPIView):
status=status.HTTP_200_OK, 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( 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() module_issue.delete()
issue_activity.delay( issue_activity.delay(
@ -357,7 +366,7 @@ class ModuleIssueAPIEndpoint(WebhookMixin, BaseAPIView):
} }
), ),
actor_id=str(request.user.id), actor_id=str(request.user.id),
issue_id=str(pk), issue_id=str(issue_id),
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()),

View File

@ -94,8 +94,8 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView):
.distinct() .distinct()
) )
def get(self, request, slug, pk=None): def get(self, request, slug, project_id=None):
if pk is None: if project_id is None:
sort_order_query = ProjectMember.objects.filter( sort_order_query = ProjectMember.objects.filter(
member=request.user, member=request.user,
project_id=OuterRef("pk"), project_id=OuterRef("pk"),
@ -114,7 +114,7 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView):
).select_related("member"), ).select_related("member"),
) )
) )
.order_by("sort_order", "name") .order_by(request.GET.get("order_by", "sort_order"))
) )
return self.paginate( return self.paginate(
request=request, request=request,
@ -123,15 +123,13 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView):
projects, many=True, fields=self.fields, expand=self.expand, projects, many=True, fields=self.fields, expand=self.expand,
).data, ).data,
) )
else: project = self.get_queryset().get(workspace__slug=slug, pk=project_id)
project = self.get_queryset().get(workspace__slug=slug, pk=pk)
serializer = ProjectSerializer(project, fields=self.fields, expand=self.expand,) serializer = ProjectSerializer(project, fields=self.fields, expand=self.expand,)
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)
def post(self, request, slug): def post(self, request, slug):
try: try:
workspace = Workspace.objects.get(slug=slug) workspace = Workspace.objects.get(slug=slug)
serializer = ProjectSerializer( serializer = ProjectSerializer(
data={**request.data}, context={"workspace_id": workspace.id} data={**request.data}, context={"workspace_id": workspace.id}
) )
@ -236,10 +234,10 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView):
status=status.HTTP_410_GONE, status=status.HTTP_410_GONE,
) )
def patch(self, request, slug, pk=None): def patch(self, request, slug, project_id=None):
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=project_id)
serializer = ProjectSerializer( serializer = ProjectSerializer(
project, project,
@ -260,7 +258,7 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView):
name="Triage", name="Triage",
group="backlog", group="backlog",
description="Default state for managing all Inbox Issues", description="Default state for managing all Inbox Issues",
project_id=pk, project_id=project_id,
color="#ff7700", color="#ff7700",
) )
@ -283,3 +281,8 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView):
{"identifier": "The project identifier is already taken"}, {"identifier": "The project identifier is already taken"},
status=status.HTTP_410_GONE, status=status.HTTP_410_GONE,
) )
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)

View File

@ -23,10 +23,8 @@ class StateAPIEndpoint(BaseAPIView):
] ]
def get_queryset(self): def get_queryset(self):
return self.filter_queryset( return (
super() State.objects.filter(workspace__slug=self.kwargs.get("slug"))
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id")) .filter(project_id=self.kwargs.get("project_id"))
.filter(project__project_projectmember__member=self.request.user) .filter(project__project_projectmember__member=self.request.user)
.filter(~Q(name="Triage")) .filter(~Q(name="Triage"))
@ -42,9 +40,9 @@ class StateAPIEndpoint(BaseAPIView):
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)
def get(self, request, slug, project_id, pk=None): def get(self, request, slug, project_id, state_id=None):
if pk: if state_id:
serializer = StateSerializer(self.get_queryset().get(pk=pk)) serializer = StateSerializer(self.get_queryset().get(pk=state_id))
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)
return self.paginate( return self.paginate(
request=request, request=request,
@ -57,10 +55,10 @@ class StateAPIEndpoint(BaseAPIView):
).data, ).data,
) )
def delete(self, request, slug, project_id, pk): def delete(self, request, slug, project_id, state_id):
state = State.objects.get( state = State.objects.get(
~Q(name="Triage"), ~Q(name="Triage"),
pk=pk, pk=state_id,
project_id=project_id, project_id=project_id,
workspace__slug=slug, workspace__slug=slug,
) )
@ -69,7 +67,7 @@ class StateAPIEndpoint(BaseAPIView):
return Response({"error": "Default state cannot be deleted"}, status=False) return Response({"error": "Default state cannot be deleted"}, status=False)
# Check for any issues in the state # 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: if issue_exist:
return Response( return Response(
@ -80,8 +78,8 @@ class StateAPIEndpoint(BaseAPIView):
state.delete() state.delete()
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)
def patch(self, request, slug, project_id, pk=None): def patch(self, request, slug, project_id, state_id=None):
state = State.objects.filter(workspace__slug=slug, project_id=project_id, pk=pk) state = State.objects.get(workspace__slug=slug, project_id=project_id, pk=state_id)
serializer = StateSerializer(state, data=request.data, partial=True) serializer = StateSerializer(state, data=request.data, partial=True)
if serializer.is_valid(): if serializer.is_valid():
serializer.save() serializer.save()

View File

@ -1,3 +1,9 @@
# Python imports
import urllib
import socket
import ipaddress
from urllib.parse import urlparse
# Third party imports # Third party imports
from rest_framework import serializers from rest_framework import serializers
@ -9,6 +15,42 @@ from plane.db.models.webhook import validate_domain, validate_schema
class WebhookSerializer(DynamicBaseSerializer): class WebhookSerializer(DynamicBaseSerializer):
url = serializers.URLField(validators=[validate_schema, validate_domain]) 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: class Meta:
model = Webhook model = Webhook
fields = "__all__" fields = "__all__"

View File

@ -21,6 +21,22 @@ class WorkSpaceSerializer(BaseSerializer):
total_members = serializers.IntegerField(read_only=True) total_members = serializers.IntegerField(read_only=True)
total_issues = 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: class Meta:
model = Workspace model = Workspace
fields = "__all__" fields = "__all__"

View File

@ -65,7 +65,7 @@ urlpatterns = [
name="project-member-invite", name="project-member-invite",
), ),
path( path(
"users/me/invitations/projects/", "users/me/workspaces/<str:slug>/projects/invitations/",
UserProjectInvitationsViewset.as_view( UserProjectInvitationsViewset.as_view(
{ {
"get": "list", "get": "list",
@ -75,7 +75,7 @@ urlpatterns = [
name="user-project-invitations", name="user-project-invitations",
), ),
path( path(
"workspaces/<str:slug>/projects/join/", "workspaces/<str:slug>/projects/<uuid:project_id>/join/<uuid:pk>/",
ProjectJoinEndpoint.as_view(), ProjectJoinEndpoint.as_view(),
name="project-join", name="project-join",
), ),

View File

@ -43,20 +43,25 @@ class TimezoneMixin:
class WebhookMixin: class WebhookMixin:
webhook_event = None webhook_event = None
bulk = False
def finalize_response(self, request, response, *args, **kwargs): def finalize_response(self, request, response, *args, **kwargs):
response = super().finalize_response(request, response, *args, **kwargs) response = super().finalize_response(request, response, *args, **kwargs)
# Check for the case should webhook be sent
if ( if (
self.webhook_event self.webhook_event
and self.request.method in ["POST", "PATCH", "DELETE"] and self.request.method in ["POST", "PATCH", "DELETE"]
and response.status_code in [200, 201, 204] and response.status_code in [200, 201, 204]
): ):
# Push the object to delay
send_webhook.delay( send_webhook.delay(
event=self.webhook_event, event=self.webhook_event,
event_data=json.dumps(response.data, cls=DjangoJSONEncoder), payload=response.data,
kw=self.kwargs,
action=self.request.method, action=self.request.method,
slug=self.workspace_slug, slug=self.workspace_slug,
bulk=self.bulk,
) )
return response return response

View File

@ -58,6 +58,7 @@ class ConfigurationEndpoint(BaseAPIView):
) and get_configuration_value( ) and get_configuration_value(
instance_configuration, "ENABLE_MAGIC_LINK_LOGIN", "0" instance_configuration, "ENABLE_MAGIC_LINK_LOGIN", "0"
) == "1" ) == "1"
data["email_password_login"] = ( data["email_password_login"] = (
get_configuration_value( get_configuration_value(
instance_configuration, "ENABLE_EMAIL_PASSWORD", "0" instance_configuration, "ENABLE_EMAIL_PASSWORD", "0"

View File

@ -502,7 +502,10 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
class CycleIssueViewSet(WebhookMixin, BaseViewSet): class CycleIssueViewSet(WebhookMixin, BaseViewSet):
serializer_class = CycleIssueSerializer serializer_class = CycleIssueSerializer
model = CycleIssue model = CycleIssue
webhook_event = "cycle"
webhook_event = "cycle_issue"
bulk = True
permission_classes = [ permission_classes = [
ProjectEntityPermission, ProjectEntityPermission,
] ]
@ -688,7 +691,6 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet):
pk=pk, workspace__slug=slug, project_id=project_id, cycle_id=cycle_id pk=pk, workspace__slug=slug, project_id=project_id, cycle_id=cycle_id
) )
issue_id = cycle_issue.issue_id issue_id = cycle_issue.issue_id
cycle_issue.delete()
issue_activity.delay( issue_activity.delay(
type="cycle.activity.deleted", type="cycle.activity.deleted",
requested_data=json.dumps( requested_data=json.dumps(
@ -698,11 +700,12 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet):
} }
), ),
actor_id=str(self.request.user.id), 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)), project_id=str(self.kwargs.get("project_id", None)),
current_instance=None, current_instance=None,
epoch=int(timezone.now().timestamp()), epoch=int(timezone.now().timestamp()),
) )
cycle_issue.delete()
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)

View File

@ -1,6 +1,6 @@
# Python imports # Python imports
import requests import requests
import os
# Third party imports # Third party imports
from openai import OpenAI from openai import OpenAI
from rest_framework.response import Response from rest_framework.response import Response
@ -27,8 +27,8 @@ class GPTIntegrationEndpoint(BaseAPIView):
# Get the configuration value # Get the configuration value
instance_configuration = InstanceConfiguration.objects.values("key", "value") instance_configuration = InstanceConfiguration.objects.values("key", "value")
api_key = get_configuration_value(instance_configuration, "OPENAI_API_KEY") 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") gpt_engine = get_configuration_value(instance_configuration, "GPT_ENGINE", os.environ.get("GPT_ENGINE", "gpt-3.5-turbo"))
# Check the keys # Check the keys
if not api_key or not gpt_engine: if not api_key or not gpt_engine:
@ -47,10 +47,6 @@ class GPTIntegrationEndpoint(BaseAPIView):
final_text = task + "\n" + prompt final_text = task + "\n" + prompt
instance_configuration = InstanceConfiguration.objects.values("key", "value")
gpt_engine = get_configuration_value(instance_configuration, "GPT_ENGINE")
client = OpenAI( client = OpenAI(
api_key=api_key, api_key=api_key,
) )
@ -85,14 +81,22 @@ class ReleaseNotesEndpoint(BaseAPIView):
class UnsplashEndpoint(BaseAPIView): class UnsplashEndpoint(BaseAPIView):
def get(self, request): 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) query = request.GET.get("query", False)
page = request.GET.get("page", 1) page = request.GET.get("page", 1)
per_page = request.GET.get("per_page", 20) per_page = request.GET.get("per_page", 20)
url = ( 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 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 = { headers = {

View File

@ -596,7 +596,7 @@ class IssueActivityEndpoint(BaseAPIView):
class IssueCommentViewSet(WebhookMixin, BaseViewSet): class IssueCommentViewSet(WebhookMixin, BaseViewSet):
serializer_class = IssueCommentSerializer serializer_class = IssueCommentSerializer
model = IssueComment model = IssueComment
webhook_event = "issue-comment" webhook_event = "issue_comment"
permission_classes = [ permission_classes = [
ProjectLitePermission, ProjectLitePermission,
] ]

View File

@ -283,9 +283,12 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)
class ModuleIssueViewSet(BaseViewSet): class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
serializer_class = ModuleIssueSerializer serializer_class = ModuleIssueSerializer
model = ModuleIssue model = ModuleIssue
webhook_event = "module_issue"
bulk = True
filterset_fields = [ filterset_fields = [
"issue__labels__id", "issue__labels__id",
@ -461,7 +464,6 @@ class ModuleIssueViewSet(BaseViewSet):
module_issue = ModuleIssue.objects.get( 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, pk=pk
) )
module_issue.delete()
issue_activity.delay( issue_activity.delay(
type="module.activity.deleted", type="module.activity.deleted",
requested_data=json.dumps( requested_data=json.dumps(
@ -471,11 +473,12 @@ class ModuleIssueViewSet(BaseViewSet):
} }
), ),
actor_id=str(request.user.id), actor_id=str(request.user.id),
issue_id=str(pk), issue_id=str(module_issue.issue_id),
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()),
) )
module_issue.delete()
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)

View File

@ -424,7 +424,7 @@ class ProjectInvitationsViewset(BaseViewSet):
project_invitations = ProjectMemberInvite.objects.bulk_create( project_invitations = ProjectMemberInvite.objects.bulk_create(
project_invitations, batch_size=10, ignore_conflicts=True project_invitations, batch_size=10, ignore_conflicts=True
) )
current_site = request.META.get('HTTP_ORIGIN') current_site = request.META.get("HTTP_ORIGIN")
# Send invitations # Send invitations
for invitation in project_invitations: for invitation in project_invitations:
@ -469,6 +469,13 @@ class UserProjectInvitationsViewset(BaseViewSet):
workspace_role = workspace_member.role workspace_role = workspace_member.role
workspace = workspace_member.workspace 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.objects.bulk_create(
[ [
ProjectMember( ProjectMember(

View File

@ -48,7 +48,7 @@ class UserEndpoint(BaseViewSet):
if WorkspaceMember.objects.filter(member=request.user, is_active=True).exists(): if WorkspaceMember.objects.filter(member=request.user, is_active=True).exists():
return Response( 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, status=status.HTTP_400_BAD_REQUEST,
) )

View File

@ -20,9 +20,10 @@ class WebhookEndpoint(BaseAPIView):
def post(self, request, slug): def post(self, request, slug):
workspace = Workspace.objects.get(slug=slug) workspace = Workspace.objects.get(slug=slug)
try: try:
serializer = WebhookSerializer(data=request.data) serializer = WebhookSerializer(
data=request.data, context={"request": request}
)
if serializer.is_valid(): if serializer.is_valid():
serializer.save(workspace_id=workspace.id) serializer.save(workspace_id=workspace.id)
return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.data, status=status.HTTP_201_CREATED)
@ -79,6 +80,7 @@ class WebhookEndpoint(BaseAPIView):
serializer = WebhookSerializer( serializer = WebhookSerializer(
webhook, webhook,
data=request.data, data=request.data,
context={request: request},
partial=True, partial=True,
fields=( fields=(
"id", "id",

View File

@ -590,7 +590,7 @@ class WorkSpaceMemberViewSet(BaseViewSet):
member_with_role=Count( member_with_role=Count(
"project_projectmember", "project_projectmember",
filter=Q( filter=Q(
project_projectmember__member_id=request.user.id, project_projectmember__member_id=workspace_member.id,
project_projectmember__role=20, project_projectmember__role=20,
), ),
), ),
@ -600,7 +600,7 @@ class WorkSpaceMemberViewSet(BaseViewSet):
): ):
return Response( 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, status=status.HTTP_400_BAD_REQUEST,
) )
@ -635,7 +635,7 @@ class WorkSpaceMemberViewSet(BaseViewSet):
): ):
return Response( 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, status=status.HTTP_400_BAD_REQUEST,
) )
@ -656,7 +656,7 @@ class WorkSpaceMemberViewSet(BaseViewSet):
): ):
return Response( 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, status=status.HTTP_400_BAD_REQUEST,
) )

View File

@ -1,6 +1,7 @@
# Python imports # Python imports
import csv import csv
import io import io
import os
# Django imports # Django imports
from django.core.mail import EmailMultiAlternatives, get_connection from django.core.mail import EmailMultiAlternatives, get_connection
@ -32,7 +33,7 @@ row_mapping = {
"priority": "Priority", "priority": "Priority",
"estimate": "Estimate", "estimate": "Estimate",
"issue_cycle__cycle_id": "Cycle", "issue_cycle__cycle_id": "Cycle",
"issue_module__module_id": "Module" "issue_module__module_id": "Module",
} }
ASSIGNEE_ID = "assignees__id" ASSIGNEE_ID = "assignees__id"
@ -51,17 +52,48 @@ def send_export_email(email, slug, csv_buffer):
csv_buffer.seek(0) csv_buffer.seek(0)
# Configure email connection from the database # 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( connection = get_connection(
host=get_configuration_value(instance_configuration, "EMAIL_HOST"), host=get_configuration_value(
port=int(get_configuration_value(instance_configuration, "EMAIL_PORT", "587")), instance_configuration, "EMAIL_HOST", os.environ.get("EMAIL_HOST")
username=get_configuration_value(instance_configuration, "EMAIL_HOST_USER"), ),
password=get_configuration_value(instance_configuration, "EMAIL_HOST_PASSWORD"), port=int(
use_tls=bool(get_configuration_value(instance_configuration, "EMAIL_USE_TLS", "1")), get_configuration_value(
use_ssl=bool(get_configuration_value(instance_configuration, "EMAIL_USE_SSL", "0")), 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 <team@mailer.plane.so>"),
),
to=[email],
connection=connection,
)
msg.attach(f"{slug}-analytics.csv", csv_buffer.getvalue()) msg.attach(f"{slug}-analytics.csv", csv_buffer.getvalue())
msg.send(fail_silently=False) msg.send(fail_silently=False)

View File

@ -1,3 +1,6 @@
# Python imports
import os
# Django imports # Django imports
from django.core.mail import EmailMultiAlternatives, get_connection from django.core.mail import EmailMultiAlternatives, get_connection
from django.template.loader import render_to_string 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.models import InstanceConfiguration
from plane.license.utils.instance_value import get_configuration_value from plane.license.utils.instance_value import get_configuration_value
@shared_task @shared_task
def email_verification(first_name, email, token, current_site): def email_verification(first_name, email, token, current_site):
try: try:
realtivelink = "/request-email-verification/" + "?token=" + str(token) realtivelink = "/request-email-verification/" + "?token=" + str(token)
abs_url = current_site + realtivelink abs_url = current_site + realtivelink
from_email_string = settings.EMAIL_FROM
subject = "Verify your Email!" subject = "Verify your Email!"
context = { context = {
@ -35,17 +36,49 @@ def email_verification(first_name, email, token, current_site):
text_content = strip_tags(html_content) text_content = strip_tags(html_content)
# Configure email connection from the database # 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( connection = get_connection(
host=get_configuration_value(instance_configuration, "EMAIL_HOST"), host=get_configuration_value(
port=int(get_configuration_value(instance_configuration, "EMAIL_PORT", "587")), instance_configuration, "EMAIL_HOST", os.environ.get("EMAIL_HOST")
username=get_configuration_value(instance_configuration, "EMAIL_HOST_USER"), ),
password=get_configuration_value(instance_configuration, "EMAIL_HOST_PASSWORD"), port=int(
use_tls=bool(get_configuration_value(instance_configuration, "EMAIL_USE_TLS", "1")), 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 # 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 <team@mailer.plane.so>"),
),
to=[email],
connection=connection,
)
msg.attach_alternative(html_content, "text/html") msg.attach_alternative(html_content, "text/html")
msg.send() msg.send()
return return

View File

@ -1,3 +1,6 @@
# Python import
import os
# Django imports # Django imports
from django.core.mail import EmailMultiAlternatives, get_connection from django.core.mail import EmailMultiAlternatives, get_connection
from django.template.loader import render_to_string 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}" realtivelink = f"/accounts/reset-password/?uidb64={uidb64}&token={token}"
abs_url = current_site + realtivelink abs_url = current_site + realtivelink
from_email_string = settings.EMAIL_FROM
subject = "Reset Your Password - Plane" subject = "Reset Your Password - Plane"
context = { 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") instance_configuration = InstanceConfiguration.objects.filter(key__startswith='EMAIL_').values("key", "value")
connection = get_connection( connection = get_connection(
host=get_configuration_value(instance_configuration, "EMAIL_HOST"), host=get_configuration_value(
port=int(get_configuration_value(instance_configuration, "EMAIL_PORT", "587")), instance_configuration, "EMAIL_HOST", os.environ.get("EMAIL_HOST")
username=get_configuration_value(instance_configuration, "EMAIL_HOST_USER"), ),
password=get_configuration_value(instance_configuration, "EMAIL_HOST_PASSWORD"), port=int(
use_tls=bool(get_configuration_value(instance_configuration, "EMAIL_USE_TLS", "1")), 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 <team@mailer.plane.so>"),
),
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.attach_alternative(html_content, "text/html")
msg.send() msg.send()
return return

View File

@ -1,3 +1,6 @@
# Python imports
import os
# Django imports # Django imports
from django.core.mail import EmailMultiAlternatives, get_connection from django.core.mail import EmailMultiAlternatives, get_connection
from django.template.loader import render_to_string 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.models import InstanceConfiguration
from plane.license.utils.instance_value import get_configuration_value from plane.license.utils.instance_value import get_configuration_value
@shared_task @shared_task
def magic_link(email, key, token, current_site): def magic_link(email, key, token, current_site):
try: try:
@ -26,17 +30,48 @@ def magic_link(email, key, token, current_site):
text_content = strip_tags(html_content) 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( connection = get_connection(
host=get_configuration_value(instance_configuration, "EMAIL_HOST"), host=get_configuration_value(
port=int(get_configuration_value(instance_configuration, "EMAIL_PORT", "587")), instance_configuration, "EMAIL_HOST", os.environ.get("EMAIL_HOST")
username=get_configuration_value(instance_configuration, "EMAIL_HOST_USER"), ),
password=get_configuration_value(instance_configuration, "EMAIL_HOST_PASSWORD"), port=int(
use_tls=bool(get_configuration_value(instance_configuration, "EMAIL_USE_TLS", "1")), 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(
msg = EmailMultiAlternatives(subject=subject, body=text_content, from_email=get_configuration_value(instance_configuration, "EMAIL_FROM"), to=[email], connection=connection) subject=subject,
body=text_content,
from_email=get_configuration_value(
instance_configuration,
"EMAIL_FROM",
os.environ.get("EMAIL_FROM", "Team Plane <team@mailer.plane.so>"),
),
to=[email],
connection=connection,
)
msg.attach_alternative(html_content, "text/html") msg.attach_alternative(html_content, "text/html")
msg.send() msg.send()
return return

View File

@ -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 issue_activities_created) if issue_activities_created is not None else None
) )
if type not in [ if type not in [
"issue.activity.deleted",
"cycle.activity.created", "cycle.activity.created",
"cycle.activity.deleted", "cycle.activity.deleted",
"module.activity.created", "module.activity.created",

View File

@ -1,3 +1,6 @@
# Python import
import os
# Django imports # Django imports
from django.core.mail import EmailMultiAlternatives, get_connection from django.core.mail import EmailMultiAlternatives, get_connection
from django.template.loader import render_to_string 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)}" 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 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" subject = f"{user.first_name or user.display_name or user.email} invited you to join {project.name} on Plane"
context = { context = {
@ -48,14 +49,45 @@ def project_invitation(email, project_id, token, current_site, invitor):
# Configure email connection from the database # 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( connection = get_connection(
host=get_configuration_value(instance_configuration, "EMAIL_HOST"), host=get_configuration_value(
port=int(get_configuration_value(instance_configuration, "EMAIL_PORT", "587")), instance_configuration, "EMAIL_HOST", os.environ.get("EMAIL_HOST")
username=get_configuration_value(instance_configuration, "EMAIL_HOST_USER"), ),
password=get_configuration_value(instance_configuration, "EMAIL_HOST_PASSWORD"), port=int(
use_tls=bool(get_configuration_value(instance_configuration, "EMAIL_USE_TLS", "1")), get_configuration_value(
instance_configuration, "EMAIL_PORT", os.environ.get("EMAIL_PORT")
) )
# Initiate email alternatives ),
msg = EmailMultiAlternatives(subject=subject, body=text_content, from_email=get_configuration_value(instance_configuration, "EMAIL_FROM"), to=[email], connection=connection) 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 <team@mailer.plane.so>"),
),
to=[email],
connection=connection,
)
msg.attach_alternative(html_content, "text/html") msg.attach_alternative(html_content, "text/html")
msg.send() msg.send()
return return

View File

@ -2,15 +2,67 @@ import requests
import uuid import uuid
import hashlib import hashlib
import json import json
import hmac
# Django imports # Django imports
from django.conf import settings from django.conf import settings
from django.core.serializers.json import DjangoJSONEncoder
# Third party imports # Third party imports
from celery import shared_task from celery import shared_task
from sentry_sdk import capture_exception 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( @shared_task(
@ -31,19 +83,24 @@ def webhook_task(self, webhook, slug, event, event_data, action):
"X-Plane-Event": event, "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: if webhook.secret_key:
# Concatenate the data and the secret key event_data_json = json.dumps(event_data) if event_data is not None else "{}"
message = event_data + webhook.secret_key hmac_signature = hmac.new(
webhook.secret_key.encode("utf-8"),
# Create a SHA-256 hash of the message event_data_json.encode("utf-8"),
sha256 = hashlib.sha256() hashlib.sha256,
sha256.update(message.encode("utf-8")) )
signature = sha256.hexdigest() signature = hmac_signature.hexdigest()
headers["X-Plane-Signature"] = signature headers["X-Plane-Signature"] = signature
event_data = json.loads(event_data) if event_data is not None else None
action = { action = {
"POST": "create", "POST": "create",
"PATCH": "update", "PATCH": "update",
@ -96,10 +153,6 @@ def webhook_task(self, webhook, slug, event, event_data, action):
retry_count=str(self.request.retries), 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() raise requests.RequestException()
except Exception as e: except Exception as e:
@ -110,7 +163,7 @@ def webhook_task(self, webhook, slug, event, event_data, action):
@shared_task() @shared_task()
def send_webhook(event, event_data, action, slug): def send_webhook(event, payload, kw, action, slug, bulk):
try: try:
webhooks = Webhook.objects.filter(workspace__slug=slug, is_active=True) 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": if event == "issue":
webhooks = webhooks.filter(issue=True) webhooks = webhooks.filter(issue=True)
if event == "module": if event == "module" or event == "module_issue":
webhooks = webhooks.filter(module=True) webhooks = webhooks.filter(module=True)
if event == "cycle": if event == "cycle" or event == "cycle_issue":
webhooks = webhooks.filter(cycle=True) webhooks = webhooks.filter(cycle=True)
if event == "issue-comment": if event == "issue_comment":
webhooks = webhooks.filter(issue_comment=True) webhooks = webhooks.filter(issue_comment=True)
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 webhook in webhooks:
webhook_task.delay(webhook.id, slug, event, event_data, action) for data in event_data:
webhook_task.delay(
webhook=webhook.id,
slug=slug,
event=event,
event_data=data,
action=action,
)
except Exception as e: except Exception as e:
if settings.DEBUG: if settings.DEBUG:

View File

@ -1,3 +1,6 @@
# Python imports
import os
# Django imports # Django imports
from django.core.mail import EmailMultiAlternatives, get_connection from django.core.mail import EmailMultiAlternatives, get_connection
from django.template.loader import render_to_string 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 # The complete url including the domain
abs_url = current_site + relative_link abs_url = current_site + relative_link
# The email from
from_email_string = settings.EMAIL_FROM
# Subject of the email # Subject of the email
subject = f"{user.first_name or user.display_name or user.email} invited you to join {workspace.name} on Plane" 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_" key__startswith="EMAIL_"
).values("key", "value") ).values("key", "value")
connection = get_connection( connection = get_connection(
host=get_configuration_value(instance_configuration, "EMAIL_HOST"), host=get_configuration_value(
port=int( instance_configuration, "EMAIL_HOST", os.environ.get("EMAIL_HOST")
get_configuration_value(instance_configuration, "EMAIL_PORT", "587") ),
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( password=get_configuration_value(
instance_configuration, "EMAIL_HOST_PASSWORD" instance_configuration,
"EMAIL_HOST_PASSWORD",
os.environ.get("EMAIL_HOST_PASSWORD"),
), ),
use_tls=bool( 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( msg = EmailMultiAlternatives(
subject=subject, subject=subject,
body=text_content, 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 <team@mailer.plane.so>"),
),
to=[email], to=[email],
connection=connection, connection=connection,
) )

View File

@ -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]),
),
]

View File

@ -51,9 +51,9 @@ class Module(ProjectBaseModel):
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
if self._state.adding: if self._state.adding:
smallest_sort_order = Module.objects.filter( smallest_sort_order = Module.objects.filter(project=self.project).aggregate(
project=self.project smallest=models.Min("sort_order")
).aggregate(smallest=models.Min("sort_order"))["smallest"] )["smallest"]
if smallest_sort_order is not None: if smallest_sort_order is not None:
self.sort_order = smallest_sort_order - 10000 self.sort_order = smallest_sort_order - 10000

View File

@ -16,7 +16,6 @@ def generate_token():
def validate_schema(value): def validate_schema(value):
parsed_url = urlparse(value) parsed_url = urlparse(value)
print(parsed_url)
if parsed_url.scheme not in ["http", "https"]: if parsed_url.scheme not in ["http", "https"]:
raise ValidationError("Invalid schema. Only HTTP and HTTPS are allowed.") raise ValidationError("Invalid schema. Only HTTP and HTTPS are allowed.")

View File

@ -1,6 +1,7 @@
# Django imports # Django imports
from django.db import models from django.db import models
from django.conf import settings from django.conf import settings
from django.core.exceptions import ValidationError
# Module imports # Module imports
from . import BaseModel from . import BaseModel
@ -50,7 +51,7 @@ def get_default_props():
"state": True, "state": True,
"sub_issue_count": True, "sub_issue_count": True,
"updated_on": 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): class Workspace(BaseModel):
name = models.CharField(max_length=80, verbose_name="Workspace Name") name = models.CharField(max_length=80, verbose_name="Workspace Name")
logo = models.URLField(verbose_name="Logo", blank=True, null=True) logo = models.URLField(verbose_name="Logo", blank=True, null=True)
@ -71,7 +89,7 @@ class Workspace(BaseModel):
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name="owner_workspace", 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) organization_size = models.CharField(max_length=20, blank=True, null=True)
def __str__(self): def __str__(self):

View File

@ -30,9 +30,11 @@ class Command(BaseCommand):
"EMAIL_USE_TLS": os.environ.get("EMAIL_USE_TLS", "1"), "EMAIL_USE_TLS": os.environ.get("EMAIL_USE_TLS", "1"),
"EMAIL_USE_SSL": os.environ.get("EMAIL_USE_SSL", "0"), "EMAIL_USE_SSL": os.environ.get("EMAIL_USE_SSL", "0"),
# Open AI Settings # Open AI Settings
"OPENAI_API_BASE": os.environ.get("", "https://api.openai.com/v1"), "OPENAI_API_BASE": os.environ.get("OPENAI_API_BASE", "https://api.openai.com/v1"),
"OPENAI_API_KEY": os.environ.get("OPENAI_API_KEY", "sk-"), "OPENAI_API_KEY": os.environ.get("OPENAI_API_KEY", ""),
"GPT_ENGINE": os.environ.get("GPT_ENGINE", "gpt-3.5-turbo"), "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(): for key, value in config_keys.items():