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 sriram veeraghanta
parent c305cf2c72
commit 34e6ef0d8d
45 changed files with 811 additions and 270 deletions

View File

@ -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

View File

@ -46,4 +46,11 @@ class CycleIssueSerializer(BaseSerializer):
"workspace",
"project",
"cycle",
]
]
class CycleLiteSerializer(BaseSerializer):
class Meta:
model = Cycle
fields = "__all__"

View File

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

View File

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

View File

@ -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)
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
fields = "__all__"
read_only_fields = [
"workspace",
"id",
"workspace",
"created_at",
"updated_at",
"created_by",
"updated_by",
]
def validate(self, data):

View File

@ -13,7 +13,7 @@ urlpatterns = [
name="cycles",
),
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(),
name="cycles",
),
@ -23,7 +23,7 @@ urlpatterns = [
name="cycle-issues",
),
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(),
name="cycle-issues",
),

View File

@ -5,12 +5,12 @@ from plane.api.views import InboxIssueAPIEndpoint
urlpatterns = [
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(),
name="inbox-issue",
),
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(),
name="inbox-issue",
),

View File

@ -15,27 +15,27 @@ urlpatterns = [
name="issue",
),
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(),
name="issue",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issue-labels/",
"workspaces/<str:slug>/projects/<uuid:project_id>/labels/",
LabelAPIEndpoint.as_view(),
name="label",
),
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(),
name="label",
),
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(),
name="link",
),
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(),
name="link",
),
@ -50,12 +50,12 @@ urlpatterns = [
name="comment",
),
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(),
name="activity",
),
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(),
name="activity",
),

View File

@ -19,7 +19,7 @@ urlpatterns = [
name="module-issues",
),
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(),
name="module-issues",
),

View File

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

View File

@ -8,4 +8,9 @@ urlpatterns = [
StateAPIEndpoint.as_view(),
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.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
return expand if expand else None

View File

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

View File

@ -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)
return Response(status=status.HTTP_204_NO_CONTENT)

View File

@ -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(

View File

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

View File

@ -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,
)
)
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):
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()

View File

@ -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__"

View File

@ -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__"

View File

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

View File

@ -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

View File

@ -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"

View File

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

View File

@ -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 = {

View File

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

View File

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

View File

@ -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)
return Response(serializer.data, status=status.HTTP_200_OK)

View File

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

View File

@ -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",

View File

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

View File

@ -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 <team@mailer.plane.so>"),
),
to=[email],
connection=connection,
)
msg.attach(f"{slug}-analytics.csv", csv_buffer.getvalue())
msg.send(fail_silently=False)

View File

@ -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 <team@mailer.plane.so>"),
),
to=[email],
connection=connection,
)
msg.attach_alternative(html_content, "text/html")
msg.send()
return

View File

@ -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 <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.send()
return

View File

@ -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 <team@mailer.plane.so>"),
),
to=[email],
connection=connection,
)
msg.attach_alternative(html_content, "text/html")
msg.send()
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
)
if type not in [
"issue.activity.deleted",
"cycle.activity.created",
"cycle.activity.deleted",
"module.activity.created",

View File

@ -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 <team@mailer.plane.so>"),
),
to=[email],
connection=connection,
)
msg.attach_alternative(html_content, "text/html")
msg.send()
return

View File

@ -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:

View File

@ -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 <team@mailer.plane.so>"),
),
to=[email],
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):
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

View File

@ -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.")

View File

@ -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):

View File

@ -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():