Skip to content
2 changes: 2 additions & 0 deletions apps/api/plane/api/serializers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,5 @@
GenericAssetUpdateSerializer,
FileAssetSerializer,
)
from .invite import WorkspaceInviteSerializer
from .member import ProjectMemberSerializer
56 changes: 56 additions & 0 deletions apps/api/plane/api/serializers/invite.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Django imports
from django.core.exceptions import ValidationError
from django.core.validators import validate_email
from rest_framework import serializers

# Module imports
from plane.db.models import WorkspaceMemberInvite
from .base import BaseSerializer
from plane.app.permissions.base import ROLE


class WorkspaceInviteSerializer(BaseSerializer):
"""
Serializer for workspace invites.
"""

class Meta:
model = WorkspaceMemberInvite
fields = [
"id",
"email",
"role",
"created_at",
"updated_at",
"responded_at",
"accepted",
]
read_only_fields = [
"id",
"workspace",
"created_at",
"updated_at",
"responded_at",
"accepted",
]

def validate_email(self, value):
try:
validate_email(value)
except ValidationError:
raise serializers.ValidationError("Invalid email address", code="INVALID_EMAIL_ADDRESS")
return value

def validate_role(self, value):
if value not in [ROLE.ADMIN.value, ROLE.MEMBER.value, ROLE.GUEST.value]:
raise serializers.ValidationError("Invalid role", code="INVALID_WORKSPACE_MEMBER_ROLE")
return value

def validate(self, data):
slug = self.context["slug"]
if (
data.get("email")
and WorkspaceMemberInvite.objects.filter(email=data["email"], workspace__slug=slug).exists()
):
raise serializers.ValidationError("Email already invited", code="EMAIL_ALREADY_INVITED")
return data
35 changes: 35 additions & 0 deletions apps/api/plane/api/serializers/member.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Third party imports
from rest_framework import serializers

# Module imports
from plane.db.models import ProjectMember, WorkspaceMember
from .base import BaseSerializer
from plane.db.models import User


class ProjectMemberSerializer(BaseSerializer):
"""
Serializer for project members.
"""

member = serializers.PrimaryKeyRelatedField(
queryset=User.objects.all(),
required=True,
)

def validate_member(self, value):
slug = self.context["slug"]

if not value:
raise serializers.ValidationError("Member is required", code="INVALID_MEMBER")

if not User.objects.filter(id=value).exists():
raise serializers.ValidationError("Member not found", code="INVALID_MEMBER")
if not WorkspaceMember.objects.filter(workspace__slug=slug, member=value).exists():
raise serializers.ValidationError("Member not found in workspace", code="INVALID_MEMBER")
return value

class Meta:
model = ProjectMember
fields = ["id", "member", "role"]
read_only_fields = ["id"]
2 changes: 2 additions & 0 deletions apps/api/plane/api/urls/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from .state import urlpatterns as state_patterns
from .user import urlpatterns as user_patterns
from .work_item import urlpatterns as work_item_patterns
from .invite import urlpatterns as invite_patterns

urlpatterns = [
*asset_patterns,
Expand All @@ -20,4 +21,5 @@
*state_patterns,
*user_patterns,
*work_item_patterns,
*invite_patterns,
]
18 changes: 18 additions & 0 deletions apps/api/plane/api/urls/invite.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Django imports
from django.urls import path, include

# Third party imports
from rest_framework.routers import DefaultRouter

# Module imports
from plane.api.views import WorkspaceInvitationsViewset


# Create router with just the invitations prefix (no workspace slug)
router = DefaultRouter()
router.register(r"invitations", WorkspaceInvitationsViewset, basename="workspace-invitations")

# Wrap the router URLs with the workspace slug path
urlpatterns = [
path("workspaces/<str:slug>/", include(router.urls)),
]
7 changes: 6 additions & 1 deletion apps/api/plane/api/urls/member.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@
urlpatterns = [
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/members/",
ProjectMemberAPIEndpoint.as_view(http_method_names=["get"]),
ProjectMemberAPIEndpoint.as_view(http_method_names=["get", "post"]),
name="project-members",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/members/<uuid:pk>/",
ProjectMemberAPIEndpoint.as_view(http_method_names=["patch", "delete"]),
name="project-members",
),
path(
Expand Down
2 changes: 2 additions & 0 deletions apps/api/plane/api/views/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,5 @@
from .asset import UserAssetEndpoint, UserServerAssetEndpoint, GenericAssetEndpoint

from .user import UserEndpoint

from .invite import WorkspaceInvitationsViewset
129 changes: 126 additions & 3 deletions apps/api/plane/api/views/base.py
Original file line number Diff line number Diff line change
@@ -1,28 +1,36 @@
# Python imports
import zoneinfo
import logging

# Django imports
from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.db import IntegrityError
from django.urls import resolve
from django.utils import timezone
from plane.db.models.api import APIToken

# Third party imports
from rest_framework import status
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response

# Third party imports
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.filters import SearchFilter
from rest_framework.viewsets import ModelViewSet
from rest_framework.exceptions import APIException
from rest_framework.generics import GenericAPIView

# Module imports
from plane.db.models.api import APIToken
from plane.api.middleware.api_authentication import APIKeyAuthentication
from plane.api.rate_limit import ApiKeyRateThrottle, ServiceTokenRateThrottle
from plane.utils.exception_logger import log_exception
from plane.utils.paginator import BasePaginator
from plane.utils.core.mixins import ReadReplicaControlMixin


logger = logging.getLogger("plane.api")


class TimezoneMixin:
"""
This enables timezone conversion according
Expand Down Expand Up @@ -152,3 +160,118 @@ def fields(self):
def expand(self):
expand = [expand for expand in self.request.GET.get("expand", "").split(",") if expand]
return expand if expand else None


class BaseViewSet(TimezoneMixin, ReadReplicaControlMixin, ModelViewSet, BasePaginator):
model = None

authentication_classes = [APIKeyAuthentication]
permission_classes = [
IsAuthenticated,
]
use_read_replica = False

def get_queryset(self):
try:
return self.model.objects.all()
except Exception as e:
log_exception(e)
raise APIException("Please check the view", status.HTTP_400_BAD_REQUEST)

def handle_exception(self, exc):
"""
Handle any exception that occurs, by returning an appropriate response,
or re-raising the error.
"""
try:
response = super().handle_exception(exc)
return response
except Exception as e:
if isinstance(e, IntegrityError):
log_exception(e)
return Response(
{"error": "The payload is not valid"},
status=status.HTTP_400_BAD_REQUEST,
)

if isinstance(e, ValidationError):
logger.warning(
"Validation Error",
extra={
"error_code": "VALIDATION_ERROR",
"error_message": str(e),
},
)
return Response(
{"error": "Please provide valid detail"},
status=status.HTTP_400_BAD_REQUEST,
)

if isinstance(e, ObjectDoesNotExist):
logger.warning(
"Object Does Not Exist",
extra={
"error_code": "OBJECT_DOES_NOT_EXIST",
"error_message": str(e),
},
)
return Response(
{"error": "The required object does not exist."},
status=status.HTTP_404_NOT_FOUND,
)

if isinstance(e, KeyError):
logger.error(
"Key Error",
extra={
"error_code": "KEY_ERROR",
"error_message": str(e),
},
)
return Response(
{"error": "The required key does not exist."},
status=status.HTTP_400_BAD_REQUEST,
)

log_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)

def dispatch(self, request, *args, **kwargs):
try:
response = super().dispatch(request, *args, **kwargs)

if settings.DEBUG:
from django.db import connection

print(f"{request.method} - {request.get_full_path()} of Queries: {len(connection.queries)}")

return response
except Exception as exc:
response = self.handle_exception(exc)
return response

@property
def workspace_slug(self):
return self.kwargs.get("slug", None)

@property
def project_id(self):
project_id = self.kwargs.get("project_id", None)
if project_id:
return project_id

if resolve(self.request.path_info).url_name == "project":
return self.kwargs.get("pk", None)

@property
def fields(self):
fields = [field for field in self.request.GET.get("fields", "").split(",") if field]
return fields if fields else None

@property
def expand(self):
expand = [expand for expand in self.request.GET.get("expand", "").split(",") if expand]
return expand if expand else None
Loading
Loading