diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 000000000..be1ad0f9d --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,10 @@ +module.exports = { + root: true, + // This tells ESLint to load the config from the package `config` + // extends: ["custom"], + settings: { + next: { + rootDir: ["apps/*/"], + }, + }, +}; diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index bffb357a7..000000000 --- a/.eslintrc.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "extends": "next/core-web-vitals" -} diff --git a/apiserver/plane/api/permissions/project.py b/apiserver/plane/api/permissions/project.py index 019496cda..eea5192d5 100644 --- a/apiserver/plane/api/permissions/project.py +++ b/apiserver/plane/api/permissions/project.py @@ -4,6 +4,12 @@ from rest_framework.permissions import BasePermission, SAFE_METHODS # Module import from plane.db.models import WorkspaceMember, ProjectMember +# Permission Mappings +Admin = 20 +Member = 15 +Viewer = 10 +Guest = 5 + class ProjectBasePermission(BasePermission): def has_permission(self, request, view): @@ -13,16 +19,24 @@ class ProjectBasePermission(BasePermission): ## Safe Methods -> Handle the filtering logic in queryset if request.method in SAFE_METHODS: - return True + return WorkspaceMember.objects.filter( + workspace__slug=view.workspace_slug, member=request.user + ).exists() + ## Only workspace owners or admins can create the projects if request.method == "POST": return WorkspaceMember.objects.filter( - workspace=view.workspace, member=request.user, role__in=[15, 20] + workspace__slug=view.workspace_slug, + member=request.user, + role__in=[Admin, Member], ).exists() ## Only Project Admins can update project attributes return ProjectMember.objects.filter( - workspace=view.workspace, member=request.user, role=20 + workspace__slug=view.workspace_slug, + member=request.user, + role=Admin, + project_id=view.project_id, ).exists() @@ -34,16 +48,23 @@ class ProjectMemberPermission(BasePermission): ## Safe Methods -> Handle the filtering logic in queryset if request.method in SAFE_METHODS: - return True + return ProjectMember.objects.filter( + workspace__slug=view.workspace_slug, member=request.user + ).exists() ## Only workspace owners or admins can create the projects if request.method == "POST": return WorkspaceMember.objects.filter( - workspace=view.workspace, member=request.user, role__in=[15, 20] + workspace__slug=view.workspace_slug, + member=request.user, + role__in=[Admin, Member], ).exists() ## Only Project Admins can update project attributes return ProjectMember.objects.filter( - workspace=view.workspace, member=request.user, role__in=[15, 20] + workspace__slug=view.workspace_slug, + member=request.user, + role__in=[Admin, Member], + project_id=view.project_id, ).exists() @@ -52,12 +73,19 @@ class ProjectEntityPermission(BasePermission): if request.user.is_anonymous: return False - + ## Safe Methods -> Handle the filtering logic in queryset if request.method in SAFE_METHODS: - return True - ## Only workspace owners or admins can create the projects + return ProjectMember.objects.filter( + workspace__slug=view.workspace_slug, + member=request.user, + project_id=view.project_id, + ).exists() + ## Only project members or admins can create and edit the project attributes return ProjectMember.objects.filter( - workspace=view.workspace, member=request.user, role__in=[15, 20] + workspace__slug=view.workspace_slug, + member=request.user, + role__in=[Admin, Member], + project_id=view.project_id, ).exists() diff --git a/apiserver/plane/api/permissions/workspace.py b/apiserver/plane/api/permissions/workspace.py index 510d87ce2..2a2e1d339 100644 --- a/apiserver/plane/api/permissions/workspace.py +++ b/apiserver/plane/api/permissions/workspace.py @@ -2,7 +2,15 @@ from rest_framework.permissions import BasePermission, SAFE_METHODS # Module imports -from plane.db.models import WorkspaceMember, ProjectMember +from plane.db.models import WorkspaceMember + + + +# Permission Mappings +Owner = 20 +Admin = 15 +Member = 10 +Guest = 5 # TODO: Move the below logic to python match - python v3.10 @@ -22,13 +30,15 @@ class WorkSpaceBasePermission(BasePermission): # allow only admins and owners to update the workspace settings if request.method in ["PUT", "PATCH"]: return WorkspaceMember.objects.filter( - member=request.user, workspace=view.workspace, role__in=[15, 20] + member=request.user, + workspace__slug=view.workspace_slug, + role__in=[Owner, Admin], ).exists() # allow only owner to delete the workspace if request.method == "DELETE": return WorkspaceMember.objects.filter( - member=request.user, workspace=view.workspace, role=20 + member=request.user, workspace__slug=view.workspace_slug, role=Owner ).exists() @@ -39,5 +49,7 @@ class WorkSpaceAdminPermission(BasePermission): return False return WorkspaceMember.objects.filter( - member=request.user, workspace=view.workspace, role__in=[15, 20] + member=request.user, + workspace__slug=view.workspace_slug, + role__in=[Owner, Admin], ).exists() diff --git a/apiserver/plane/api/serializers/__init__.py b/apiserver/plane/api/serializers/__init__.py index e2d474901..ba494ec9e 100644 --- a/apiserver/plane/api/serializers/__init__.py +++ b/apiserver/plane/api/serializers/__init__.py @@ -29,7 +29,6 @@ from .issue import ( IssueCommentSerializer, TimeLineIssueSerializer, IssuePropertySerializer, - IssueLabelSerializer, BlockerIssueSerializer, BlockedIssueSerializer, IssueAssigneeSerializer, diff --git a/apiserver/plane/api/serializers/cycle.py b/apiserver/plane/api/serializers/cycle.py index 4e125bfae..09f35b669 100644 --- a/apiserver/plane/api/serializers/cycle.py +++ b/apiserver/plane/api/serializers/cycle.py @@ -1,3 +1,6 @@ +# Third party imports +from rest_framework import serializers + # Module imports from .base import BaseSerializer from .user import UserLiteSerializer @@ -22,6 +25,7 @@ class CycleSerializer(BaseSerializer): class CycleIssueSerializer(BaseSerializer): issue_detail = IssueStateSerializer(read_only=True, source="issue") + sub_issues_count = serializers.IntegerField(read_only=True) class Meta: model = CycleIssue diff --git a/apiserver/plane/api/serializers/issue.py b/apiserver/plane/api/serializers/issue.py index 569eef88c..a148cbfb5 100644 --- a/apiserver/plane/api/serializers/issue.py +++ b/apiserver/plane/api/serializers/issue.py @@ -432,6 +432,7 @@ class IssueSerializer(BaseSerializer): blocker_issues = BlockerIssueSerializer(read_only=True, many=True) issue_cycle = IssueCycleDetailSerializer(read_only=True) issue_module = IssueModuleDetailSerializer(read_only=True) + sub_issues_count = serializers.IntegerField(read_only=True) class Meta: model = Issue diff --git a/apiserver/plane/api/serializers/module.py b/apiserver/plane/api/serializers/module.py index 1d3748f8d..9f165dd28 100644 --- a/apiserver/plane/api/serializers/module.py +++ b/apiserver/plane/api/serializers/module.py @@ -150,6 +150,7 @@ class ModuleIssueSerializer(BaseSerializer): module_detail = ModuleFlatSerializer(read_only=True, source="module") issue_detail = IssueStateSerializer(read_only=True, source="issue") + sub_issues_count = serializers.IntegerField(read_only=True) class Meta: model = ModuleIssue @@ -200,4 +201,4 @@ class ModuleSerializer(BaseSerializer): "updated_by", "created_at", "updated_at", - ] \ No newline at end of file + ] diff --git a/apiserver/plane/api/urls.py b/apiserver/plane/api/urls.py index 872f5953f..38d2b4013 100644 --- a/apiserver/plane/api/urls.py +++ b/apiserver/plane/api/urls.py @@ -4,73 +4,95 @@ from django.urls import path # Create your urls here. from plane.api.views import ( + # Authentication + SignUpEndpoint, SignInEndpoint, SignOutEndpoint, MagicSignInEndpoint, MagicSignInGenerateEndpoint, + OauthEndpoint, + ## End Authentication + # Auth Extended ForgotPasswordEndpoint, - PeopleEndpoint, - UserEndpoint, VerifyEmailEndpoint, ResetPasswordEndpoint, RequestEmailVerificationEndpoint, - OauthEndpoint, ChangePasswordEndpoint, -) - -from plane.api.views import ( - UserWorkspaceInvitationsEndpoint, + ## End Auth Extender + # User + UserEndpoint, + UpdateUserOnBoardedEndpoint, + ## End User + # Workspaces WorkSpaceViewSet, + UserWorkspaceInvitationsEndpoint, UserWorkSpacesEndpoint, InviteWorkspaceEndpoint, JoinWorkspaceEndpoint, WorkSpaceMemberViewSet, WorkspaceInvitationsViewset, UserWorkspaceInvitationsEndpoint, + WorkspaceMemberUserEndpoint, + WorkspaceMemberUserViewsEndpoint, + WorkSpaceAvailabilityCheckEndpoint, + TeamMemberViewSet, + AddTeamToProjectEndpoint, + UserLastProjectWithWorkspaceEndpoint, + UserWorkspaceInvitationEndpoint, + ## End Workspaces + # File Assets + FileAssetEndpoint, + ## End File Assets + # Projects ProjectViewSet, InviteProjectEndpoint, ProjectMemberViewSet, ProjectMemberInvitationsViewset, - StateViewSet, - ShortCutViewSet, - ViewViewSet, - CycleViewSet, - FileAssetEndpoint, + ProjectMemberUserEndpoint, + AddMemberToProjectEndpoint, + ProjectJoinEndpoint, + UserProjectInvitationsViewset, + ProjectIdentifierEndpoint, + ## End Projects + # Issues IssueViewSet, WorkSpaceIssuesEndpoint, IssueActivityEndpoint, IssueCommentViewSet, - TeamMemberViewSet, - TimeLineIssueViewSet, - CycleIssueViewSet, - IssuePropertyViewSet, - UpdateUserOnBoardedEndpoint, - UserWorkspaceInvitationEndpoint, - UserProjectInvitationsViewset, - ProjectIdentifierEndpoint, - LabelViewSet, - AddMemberToProjectEndpoint, - ProjectJoinEndpoint, + UserWorkSpaceIssues, BulkDeleteIssuesEndpoint, ProjectUserViewsEndpoint, + TimeLineIssueViewSet, + IssuePropertyViewSet, + LabelViewSet, + SubIssuesEndpoint, + ## End Issues + # States + StateViewSet, + ## End States + # Shortcuts + ShortCutViewSet, + ## End Shortcuts + # Views + ViewViewSet, + ## End Views + # Cycles + CycleViewSet, + CycleIssueViewSet, + ## End Cycles + # Modules ModuleViewSet, ModuleIssueViewSet, - UserLastProjectWithWorkspaceEndpoint, - UserWorkSpaceIssues, - ProjectMemberUserEndpoint, - WorkspaceMemberUserEndpoint, - WorkspaceMemberUserViewsEndpoint, - WorkSpaceAvailabilityCheckEndpoint, + ## End Modules ) -from plane.api.views.project import AddTeamToProjectEndpoint - urlpatterns = [ # Social Auth path("social-auth/", OauthEndpoint.as_view(), name="oauth"), # Auth path("sign-in/", SignInEndpoint.as_view(), name="sign-in"), + path("sign-up/", SignUpEndpoint.as_view(), name="sign-up"), path("sign-out/", SignOutEndpoint.as_view(), name="sign-out"), # Magic Sign In/Up path( @@ -95,8 +117,6 @@ urlpatterns = [ ForgotPasswordEndpoint.as_view(), name="forgot-password", ), - # List Users - path("users/", PeopleEndpoint.as_view()), # User Profile path( "users/me/", @@ -521,6 +541,11 @@ urlpatterns = [ UserWorkSpaceIssues.as_view(), name="workspace-issues", ), + path( + "workspaces//projects//issues//sub-issues/", + SubIssuesEndpoint.as_view(), + name="sub-issues", + ), ## End Issues ## Issue Activity path( @@ -654,9 +679,4 @@ urlpatterns = [ name="project-module-issues", ), ## End Modules - # path( - # "issues//all/", - # IssueViewSet.as_view({"get": "list_issue_history_comments"}), - # name="Issue history and comments", - # ), ] diff --git a/apiserver/plane/api/views/__init__.py b/apiserver/plane/api/views/__init__.py index 3a0193f8a..933315277 100644 --- a/apiserver/plane/api/views/__init__.py +++ b/apiserver/plane/api/views/__init__.py @@ -13,7 +13,6 @@ from .project import ( ProjectMemberUserEndpoint, ) from .people import ( - PeopleEndpoint, UserEndpoint, UpdateUserOnBoardedEndpoint, ) @@ -52,6 +51,7 @@ from .issue import ( LabelViewSet, BulkDeleteIssuesEndpoint, UserWorkSpaceIssues, + SubIssuesEndpoint, ) from .auth_extended import ( @@ -64,6 +64,7 @@ from .auth_extended import ( from .authentication import ( + SignUpEndpoint, SignInEndpoint, SignOutEndpoint, MagicSignInEndpoint, diff --git a/apiserver/plane/api/views/asset.py b/apiserver/plane/api/views/asset.py index 902ae7009..657383553 100644 --- a/apiserver/plane/api/views/asset.py +++ b/apiserver/plane/api/views/asset.py @@ -6,7 +6,7 @@ from sentry_sdk import capture_exception # Module imports from .base import BaseAPIView -from plane.db.models import FileAsset, Workspace +from plane.db.models import FileAsset from plane.api.serializers import FileAssetSerializer @@ -18,8 +18,8 @@ class FileAssetEndpoint(BaseAPIView): A viewset for viewing and editing task instances. """ - def get(self, request): - files = FileAsset.objects.all() + def get(self, request, slug): + files = FileAsset.objects.filter(workspace__slug=slug) serializer = FileAssetSerializer(files, context={"request": request}, many=True) return Response(serializer.data) diff --git a/apiserver/plane/api/views/base.py b/apiserver/plane/api/views/base.py index 5d8e75fb3..a4b9ac584 100644 --- a/apiserver/plane/api/views/base.py +++ b/apiserver/plane/api/views/base.py @@ -1,6 +1,7 @@ # Django imports from django.urls import resolve from django.conf import settings + # Third part imports from rest_framework import status from rest_framework.viewsets import ModelViewSet @@ -39,32 +40,23 @@ class BaseViewSet(ModelViewSet, BasePaginator): return self.model.objects.all() except Exception as e: print(e) - raise APIException( - "Please check the view", status.HTTP_400_BAD_REQUEST - ) + raise APIException("Please check the view", status.HTTP_400_BAD_REQUEST) def dispatch(self, request, *args, **kwargs): response = super().dispatch(request, *args, **kwargs) if settings.DEBUG: from django.db import connection - print(f'# of Queries: {len(connection.queries)}') + + print( + f"{request.method} - {request.get_full_path()} of Queries: {len(connection.queries)}" + ) return response @property def workspace_slug(self): return self.kwargs.get("slug", None) - @property - def workspace(self): - if self.workspace_slug: - try: - return Workspace.objects.get(slug=self.workspace_slug) - except Workspace.DoesNotExist: - raise NotFound(detail="Workspace does not exist") - else: - return None - @property def project_id(self): project_id = self.kwargs.get("project_id", None) @@ -74,16 +66,6 @@ class BaseViewSet(ModelViewSet, BasePaginator): if resolve(self.request.path_info).url_name == "project": return self.kwargs.get("pk", None) - @property - def project(self): - if self.project_id: - try: - return Project.objects.get(pk=self.project_id) - except Project.DoesNotExist: - raise NotFound(detail="Project does not exist") - else: - return None - class BaseAPIView(APIView, BasePaginator): @@ -110,33 +92,16 @@ class BaseAPIView(APIView, BasePaginator): if settings.DEBUG: from django.db import connection - print(f'# of Queries: {len(connection.queries)}') + + print( + f"{request.method} - {request.get_full_path()} of Queries: {len(connection.queries)}" + ) return response @property def workspace_slug(self): return self.kwargs.get("slug", None) - @property - def workspace(self): - if self.workspace_slug: - try: - return Workspace.objects.get(slug=self.workspace_slug) - except Workspace.DoesNotExist: - raise NotFound(detail="Workspace does not exist") - else: - return None - @property def project_id(self): return self.kwargs.get("project_id", None) - - @property - def project(self): - if self.project_id: - try: - return Project.objects.get(pk=self.project_id) - except Project.DoesNotExist: - raise NotFound(detail="Project does not exist") - else: - return None diff --git a/apiserver/plane/api/views/cycle.py b/apiserver/plane/api/views/cycle.py index 62c0376b3..d1b291d9a 100644 --- a/apiserver/plane/api/views/cycle.py +++ b/apiserver/plane/api/views/cycle.py @@ -1,3 +1,6 @@ +# Django imports +from django.db.models import OuterRef, Func, F + # Third party imports from rest_framework.response import Response from rest_framework import status @@ -32,6 +35,7 @@ class CycleViewSet(BaseViewSet): .filter(project__project_projectmember__member=self.request.user) .select_related("project") .select_related("workspace") + .select_related("owned_by") .distinct() ) @@ -55,6 +59,12 @@ class CycleIssueViewSet(BaseViewSet): return self.filter_queryset( super() .get_queryset() + .annotate( + sub_issues_count=Issue.objects.filter(parent=OuterRef("issue_id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) .filter(workspace__slug=self.kwargs.get("slug")) .filter(project_id=self.kwargs.get("project_id")) .filter(project__project_projectmember__member=self.request.user) @@ -62,8 +72,8 @@ class CycleIssueViewSet(BaseViewSet): .select_related("project") .select_related("workspace") .select_related("cycle") - .select_related("issue") - .select_related("issue__state") + .select_related("issue", "issue__state", "issue__project") + .prefetch_related("issue__assignees", "issue__labels") .distinct() ) diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py index 78f050af8..f716c2b11 100644 --- a/apiserver/plane/api/views/issue.py +++ b/apiserver/plane/api/views/issue.py @@ -3,8 +3,7 @@ import json from itertools import groupby, chain # Django imports -from django.db.models import Prefetch -from django.db.models import Count, Sum +from django.db.models import Prefetch, OuterRef, Func, F from django.core.serializers.json import DjangoJSONEncoder # Third Party imports @@ -94,9 +93,16 @@ class IssueViewSet(BaseViewSet): return super().perform_update(serializer) def get_queryset(self): + return ( super() .get_queryset() + .annotate( + sub_issues_count=Issue.objects.filter(parent=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) .filter(project_id=self.kwargs.get("project_id")) .filter(workspace__slug=self.kwargs.get("slug")) .select_related("project") @@ -126,7 +132,9 @@ class IssueViewSet(BaseViewSet): .prefetch_related( Prefetch( "issue_module", - queryset=ModuleIssue.objects.select_related("module", "issue"), + queryset=ModuleIssue.objects.select_related( + "module", "issue" + ).prefetch_related("module__members"), ), ) ) @@ -162,13 +170,22 @@ class IssueViewSet(BaseViewSet): return Response(issue_dict, status=status.HTTP_200_OK) - return self.paginate( - request=request, - queryset=issue_queryset, - on_results=lambda issues: IssueSerializer(issues, many=True).data, + return Response( + { + "next_cursor": str(0), + "prev_cursor": str(0), + "next_page_results": False, + "prev_page_results": False, + "count": issue_queryset.count(), + "total_pages": 1, + "extra_stats": {}, + "results": IssueSerializer(issue_queryset, many=True).data, + }, + status=status.HTTP_200_OK, ) except Exception as e: + print(e) capture_exception(e) return Response( {"error": "Something went wrong please try again later"}, @@ -207,8 +224,48 @@ class IssueViewSet(BaseViewSet): class UserWorkSpaceIssues(BaseAPIView): def get(self, request, slug): try: - issues = Issue.objects.filter( - assignees__in=[request.user], workspace__slug=slug + issues = ( + Issue.objects.filter(assignees__in=[request.user], workspace__slug=slug) + .annotate( + sub_issues_count=Issue.objects.filter(parent=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .select_related("project") + .select_related("workspace") + .select_related("state") + .select_related("parent") + .prefetch_related("assignees") + .prefetch_related("labels") + .prefetch_related( + Prefetch( + "blocked_issues", + queryset=IssueBlocker.objects.select_related( + "blocked_by", "block" + ), + ) + ) + .prefetch_related( + Prefetch( + "blocker_issues", + queryset=IssueBlocker.objects.select_related( + "block", "blocked_by" + ), + ) + ) + .prefetch_related( + Prefetch( + "issue_cycle", + queryset=CycleIssue.objects.select_related("cycle", "issue"), + ), + ) + .prefetch_related( + Prefetch( + "issue_module", + queryset=ModuleIssue.objects.select_related("module", "issue"), + ), + ) ) serializer = IssueSerializer(issues, many=True) return Response(serializer.data, status=status.HTTP_200_OK) @@ -468,3 +525,62 @@ class BulkDeleteIssuesEndpoint(BaseAPIView): {"error": "Something went wrong please try again later"}, status=status.HTTP_400_BAD_REQUEST, ) + + +class SubIssuesEndpoint(BaseAPIView): + + permission_classes = [ + ProjectEntityPermission, + ] + + def get(self, request, slug, project_id, issue_id): + try: + + sub_issues = ( + Issue.objects.filter( + parent_id=issue_id, workspace__slug=slug, project_id=project_id + ) + .select_related("project") + .select_related("workspace") + .select_related("state") + .select_related("parent") + .prefetch_related("assignees") + .prefetch_related("labels") + .prefetch_related( + Prefetch( + "blocked_issues", + queryset=IssueBlocker.objects.select_related( + "blocked_by", "block" + ), + ) + ) + .prefetch_related( + Prefetch( + "blocker_issues", + queryset=IssueBlocker.objects.select_related( + "block", "blocked_by" + ), + ) + ) + .prefetch_related( + Prefetch( + "issue_cycle", + queryset=CycleIssue.objects.select_related("cycle", "issue"), + ), + ) + .prefetch_related( + Prefetch( + "issue_module", + queryset=ModuleIssue.objects.select_related("module", "issue"), + ), + ) + ) + + serializer = IssueSerializer(sub_issues, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + except Exception as e: + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_400_BAD_REQUEST, + ) diff --git a/apiserver/plane/api/views/module.py b/apiserver/plane/api/views/module.py index a1aff67a0..9955ded76 100644 --- a/apiserver/plane/api/views/module.py +++ b/apiserver/plane/api/views/module.py @@ -1,6 +1,6 @@ # Django Imports from django.db import IntegrityError -from django.db.models import Prefetch +from django.db.models import Prefetch, F, OuterRef, Func # Third party imports from rest_framework.response import Response @@ -15,7 +15,13 @@ from plane.api.serializers import ( ModuleIssueSerializer, ) from plane.api.permissions import ProjectEntityPermission -from plane.db.models import Module, ModuleIssue, Project, Issue, ModuleLink +from plane.db.models import ( + Module, + ModuleIssue, + Project, + Issue, + ModuleLink, +) class ModuleViewSet(BaseViewSet): @@ -45,13 +51,15 @@ class ModuleViewSet(BaseViewSet): .prefetch_related( Prefetch( "issue_module", - queryset=ModuleIssue.objects.select_related("module", "issue"), + queryset=ModuleIssue.objects.select_related( + "module", "issue", "issue__state", "issue__project" + ).prefetch_related("issue__assignees", "issue__labels"), ) ) .prefetch_related( Prefetch( "link_module", - queryset=ModuleLink.objects.select_related("module"), + queryset=ModuleLink.objects.select_related("module", "created_by"), ) ) ) @@ -110,6 +118,12 @@ class ModuleIssueViewSet(BaseViewSet): return self.filter_queryset( super() .get_queryset() + .annotate( + sub_issues_count=Issue.objects.filter(parent=OuterRef("issue")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) .filter(workspace__slug=self.kwargs.get("slug")) .filter(project_id=self.kwargs.get("project_id")) .filter(module_id=self.kwargs.get("module_id")) @@ -117,7 +131,9 @@ class ModuleIssueViewSet(BaseViewSet): .select_related("project") .select_related("workspace") .select_related("module") - .select_related("issue") + .select_related("issue", "issue__state", "issue__project") + .prefetch_related("issue__assignees", "issue__labels") + .prefetch_related("module__members") .distinct() ) @@ -164,4 +180,4 @@ class ModuleIssueViewSet(BaseViewSet): return Response( {"error": "Something went wrong please try again later"}, status=status.HTTP_400_BAD_REQUEST, - ) \ No newline at end of file + ) diff --git a/apiserver/plane/api/views/oauth.py b/apiserver/plane/api/views/oauth.py index ac8b55c7e..bcebfb294 100644 --- a/apiserver/plane/api/views/oauth.py +++ b/apiserver/plane/api/views/oauth.py @@ -223,8 +223,8 @@ class OauthEndpoint(BaseAPIView): username=username, email=email, mobile_number=mobile_number, - first_name=data["first_name"], - last_name=data["last_name"], + first_name=data.get("first_name", ""), + last_name=data.get("last_name", ""), is_email_verified=email_verified, is_password_autoset=True, ) diff --git a/apiserver/plane/api/views/people.py b/apiserver/plane/api/views/people.py index b45176d1d..154888812 100644 --- a/apiserver/plane/api/views/people.py +++ b/apiserver/plane/api/views/people.py @@ -7,48 +7,11 @@ from sentry_sdk import capture_exception # Module imports from plane.api.serializers import ( UserSerializer, - WorkSpaceSerializer, ) from plane.api.views.base import BaseViewSet, BaseAPIView from plane.db.models import User, Workspace - -class PeopleEndpoint(BaseAPIView): - - filterset_fields = ("date_joined",) - - search_fields = ( - "^first_name", - "^last_name", - "^email", - "^username", - ) - - def get(self, request): - try: - users = User.objects.all().order_by("-date_joined") - if ( - request.GET.get("search", None) is not None - and len(request.GET.get("search")) < 3 - ): - return Response( - {"message": "Search term must be at least 3 characters long"}, - status=status.HTTP_400_BAD_REQUEST, - ) - return self.paginate( - request=request, - queryset=self.filter_queryset(users), - on_results=lambda data: UserSerializer(data, many=True).data, - ) - except Exception as e: - capture_exception(e) - return Response( - {"message": "Something went wrong please try again later"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - class UserEndpoint(BaseViewSet): serializer_class = UserSerializer model = User diff --git a/apiserver/plane/api/views/project.py b/apiserver/plane/api/views/project.py index a3113a10a..2ec6faf1e 100644 --- a/apiserver/plane/api/views/project.py +++ b/apiserver/plane/api/views/project.py @@ -67,7 +67,9 @@ class ProjectViewSet(BaseViewSet): .get_queryset() .filter(workspace__slug=self.kwargs.get("slug")) .filter(Q(project_projectmember__member=self.request.user) | Q(network=2)) - .select_related("workspace", "workspace__owner") + .select_related( + "workspace", "workspace__owner", "default_assignee", "project_lead" + ) .distinct() ) @@ -294,7 +296,7 @@ class UserProjectInvitationsViewset(BaseViewSet): super() .get_queryset() .filter(email=self.request.user.email) - .select_related("workspace") + .select_related("workspace", "workspace__owner", "project") ) def create(self, request): @@ -349,6 +351,7 @@ class ProjectMemberViewSet(BaseViewSet): .filter(project_id=self.kwargs.get("project_id")) .select_related("project") .select_related("member") + .select_related("workspace", "workspace__owner") ) @@ -481,6 +484,7 @@ class ProjectMemberInvitationsViewset(BaseViewSet): .filter(workspace__slug=self.kwargs.get("slug")) .filter(project_id=self.kwargs.get("project_id")) .select_related("project") + .select_related("workspace", "workspace__owner") ) @@ -496,7 +500,12 @@ class ProjectMemberInviteDetailViewSet(BaseViewSet): ] def get_queryset(self): - return self.filter_queryset(super().get_queryset().select_related("project")) + return self.filter_queryset( + super() + .get_queryset() + .select_related("project") + .select_related("workspace", "workspace__owner") + ) class ProjectIdentifierEndpoint(BaseAPIView): diff --git a/apiserver/plane/api/views/workspace.py b/apiserver/plane/api/views/workspace.py index 53f0159c4..4b06275aa 100644 --- a/apiserver/plane/api/views/workspace.py +++ b/apiserver/plane/api/views/workspace.py @@ -10,7 +10,7 @@ from django.utils import timezone from django.core.exceptions import ValidationError from django.core.validators import validate_email from django.contrib.sites.shortcuts import get_current_site -from django.db.models import CharField, Count +from django.db.models import CharField, Count, OuterRef, Func, F from django.db.models.functions import Cast # Third party modules @@ -111,6 +111,14 @@ class UserWorkSpacesEndpoint(BaseAPIView): def get(self, request): try: + + member_count = ( + WorkspaceMember.objects.filter(workspace=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + workspace = ( Workspace.objects.prefetch_related( Prefetch("workspace_member", queryset=WorkspaceMember.objects.all()) @@ -119,7 +127,7 @@ class UserWorkSpacesEndpoint(BaseAPIView): workspace_member__member=request.user, ) .select_related("owner") - ).annotate(total_members=Count("workspace_member")) + ).annotate(total_members=member_count) serializer = WorkSpaceSerializer(self.filter_queryset(workspace), many=True) return Response(serializer.data, status=status.HTTP_200_OK) @@ -176,7 +184,7 @@ class InviteWorkspaceEndpoint(BaseAPIView): workspace_members = WorkspaceMember.objects.filter( workspace_id=workspace.id, member__email__in=[email.get("email") for email in emails], - ) + ).select_related("member", "workspace", "workspace__owner") if len(workspace_members): return Response( @@ -339,7 +347,7 @@ class WorkspaceInvitationsViewset(BaseViewSet): super() .get_queryset() .filter(workspace__slug=self.kwargs.get("slug")) - .select_related("workspace") + .select_related("workspace", "workspace__owner") ) @@ -353,7 +361,7 @@ class UserWorkspaceInvitationsEndpoint(BaseViewSet): super() .get_queryset() .filter(email=self.request.user.email) - .select_related("workspace") + .select_related("workspace", "workspace__owner") ) def create(self, request): @@ -524,7 +532,7 @@ class UserLastProjectWithWorkspaceEndpoint(BaseAPIView): project_member = ProjectMember.objects.filter( workspace_id=last_workspace_id, member=request.user - ).select_related("workspace", "project", "member") + ).select_related("workspace", "project", "member", "workspace__owner") project_member_serializer = ProjectMemberSerializer( project_member, many=True diff --git a/apiserver/plane/db/models/issue.py b/apiserver/plane/db/models/issue.py index ed72e9b19..f69f11574 100644 --- a/apiserver/plane/db/models/issue.py +++ b/apiserver/plane/db/models/issue.py @@ -32,9 +32,9 @@ class Issue(ProjectBaseModel): related_name="state_issue", ) name = models.CharField(max_length=255, verbose_name="Issue Name") - description = models.JSONField(blank=True) - description_html = models.TextField(blank=True) - description_stripped = models.TextField(blank=True) + description = models.JSONField(blank=True, null=True) + description_html = models.TextField(blank=True, null=True) + description_stripped = models.TextField(blank=True, null=True) priority = models.CharField( max_length=30, choices=PRIORITY_CHOICES, @@ -84,10 +84,12 @@ class Issue(ProjectBaseModel): ) except ImportError: pass - + # Strip the html tags using html parser self.description_stripped = ( - strip_tags(self.description_html) if self.description_html != "" else "" + None + if (self.description_html == "" or self.description_html is None) + else strip_tags(self.description_html) ) super(Issue, self).save(*args, **kwargs) @@ -211,10 +213,11 @@ class IssueComment(ProjectBaseModel): ) def save(self, *args, **kwargs): - self.comment_stripped = strip_tags(self.comment_html) if self.comment_html != "" else "" + self.comment_stripped = ( + strip_tags(self.comment_html) if self.comment_html != "" else "" + ) return super(IssueComment, self).save(*args, **kwargs) - class Meta: verbose_name = "Issue Comment" verbose_name_plural = "Issue Comments" diff --git a/apiserver/plane/settings/staging.py b/apiserver/plane/settings/staging.py index 2804d4e29..429530d94 100644 --- a/apiserver/plane/settings/staging.py +++ b/apiserver/plane/settings/staging.py @@ -14,7 +14,7 @@ from sentry_sdk.integrations.redis import RedisIntegration from .common import * # noqa # Database -DEBUG = False +DEBUG = True DATABASES = { "default": { "ENGINE": "django.db.backends.postgresql_psycopg2", diff --git a/apiserver/requirements/base.txt b/apiserver/requirements/base.txt index 407c8b8c8..7dff0a765 100644 --- a/apiserver/requirements/base.txt +++ b/apiserver/requirements/base.txt @@ -6,7 +6,7 @@ django-taggit==2.1.0 psycopg2==2.9.3 django-oauth-toolkit==2.0.0 mistune==2.0.3 -djangorestframework==3.13.1 +djangorestframework==3.14.0 redis==4.2.2 django-nested-admin==3.4.0 django-cors-headers==3.11.0 @@ -16,7 +16,7 @@ faker==13.4.0 django-filter==21.1 jsonmodels==2.5.0 djangorestframework-simplejwt==5.1.0 -sentry-sdk==1.5.12 +sentry-sdk==1.13.0 django-s3-storage==0.13.6 django-crum==0.7.9 django-guardian==2.4.0 diff --git a/apps/app/.eslintrc.js b/apps/app/.eslintrc.js new file mode 100644 index 000000000..64b6ff36b --- /dev/null +++ b/apps/app/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require("config/.eslintrc"); diff --git a/apps/app/Dockerfile.web b/apps/app/Dockerfile.web index 8b3bc4656..be8abf5fa 100644 --- a/apps/app/Dockerfile.web +++ b/apps/app/Dockerfile.web @@ -14,7 +14,7 @@ ENV PATH="${PATH}:./pnpm" COPY ./apps ./apps COPY ./package.json ./package.json -COPY ./.eslintrc.json ./.eslintrc.json +COPY ./.eslintrc.js ./.eslintrc.js COPY ./turbo.json ./turbo.json COPY ./pnpm-workspace.yaml ./pnpm-workspace.yaml COPY ./pnpm-lock.yaml ./pnpm-lock.yaml diff --git a/apps/app/components/forms/EmailCodeForm.tsx b/apps/app/components/account/email-code-form.tsx similarity index 84% rename from apps/app/components/forms/EmailCodeForm.tsx rename to apps/app/components/account/email-code-form.tsx index 6c3ef8f86..ff5f8632d 100644 --- a/apps/app/components/forms/EmailCodeForm.tsx +++ b/apps/app/components/account/email-code-form.tsx @@ -1,28 +1,28 @@ import React, { useState } from "react"; -// react hook form import { useForm } from "react-hook-form"; // ui -import { Button, Input } from "ui"; -import authenticationService from "lib/services/authentication.service"; -// icons import { CheckCircleIcon } from "@heroicons/react/20/solid"; +import { Button, Input } from "components/ui"; +// services +import authenticationService from "services/authentication.service"; +// icons // types -type SignIn = { +type EmailCodeFormValues = { email: string; key?: string; token?: string; }; -const EmailCodeForm = ({ onSuccess }: any) => { +export const EmailCodeForm = ({ onSuccess }: any) => { const [codeSent, setCodeSent] = useState(false); const { register, handleSubmit, setError, setValue, - formState: { errors, isSubmitting, dirtyFields, isValid, isDirty }, - } = useForm({ + formState: { errors, isSubmitting, isValid, isDirty }, + } = useForm({ defaultValues: { email: "", key: "", @@ -32,9 +32,8 @@ const EmailCodeForm = ({ onSuccess }: any) => { reValidateMode: "onChange", }); - const onSubmit = ({ email }: SignIn) => { + const onSubmit = ({ email }: EmailCodeFormValues) => { console.log(email); - authenticationService .emailCode({ email }) .then((res) => { @@ -46,15 +45,15 @@ const EmailCodeForm = ({ onSuccess }: any) => { }); }; - const handleSignin = (formData: SignIn) => { + const handleSignin = (formData: EmailCodeFormValues) => { authenticationService .magicSignIn(formData) - .then(async (response) => { - await onSuccess(response); + .then((response) => { + onSuccess(response); }) .catch((error) => { console.log(error); - setError("token" as keyof SignIn, { + setError("token" as keyof EmailCodeFormValues, { type: "manual", message: error.error, }); @@ -127,5 +126,3 @@ const EmailCodeForm = ({ onSuccess }: any) => { ); }; - -export default EmailCodeForm; diff --git a/apps/app/components/forms/EmailPasswordForm.tsx b/apps/app/components/account/email-password-form.tsx similarity index 79% rename from apps/app/components/forms/EmailPasswordForm.tsx rename to apps/app/components/account/email-password-form.tsx index 384b77598..029485b8b 100644 --- a/apps/app/components/forms/EmailPasswordForm.tsx +++ b/apps/app/components/account/email-password-form.tsx @@ -1,29 +1,26 @@ import React from "react"; // next import Link from "next/link"; -import { useRouter } from "next/router"; // react hook form import { useForm } from "react-hook-form"; // ui -import { Button, Input } from "ui"; -import authenticationService from "lib/services/authentication.service"; +import { Button, Input } from "components/ui"; +import authenticationService from "services/authentication.service"; // types -type SignIn = { +type EmailPasswordFormValues = { email: string; password?: string; medium?: string; }; -const EmailPasswordForm = ({ onSuccess }: any) => { +export const EmailPasswordForm = ({ onSuccess }: any) => { const { register, handleSubmit, setError, - setValue, - getValues, - formState: { errors, isSubmitting, dirtyFields, isValid, isDirty }, - } = useForm({ + formState: { errors, isSubmitting, isValid, isDirty }, + } = useForm({ defaultValues: { email: "", password: "", @@ -33,11 +30,11 @@ const EmailPasswordForm = ({ onSuccess }: any) => { reValidateMode: "onChange", }); - const onSubmit = (formData: SignIn) => { + const onSubmit = (formData: EmailPasswordFormValues) => { authenticationService .emailLogin(formData) - .then(async (response) => { - await onSuccess(response); + .then((response) => { + onSuccess(response); }) .catch((error) => { console.log(error); @@ -45,7 +42,7 @@ const EmailPasswordForm = ({ onSuccess }: any) => { Object.keys(error.response.data).forEach((key) => { const err = error.response.data[key]; console.log("err", err); - setError(key as keyof SignIn, { + setError(key as keyof EmailPasswordFormValues, { type: "manual", message: Array.isArray(err) ? err.join(", ") : err, }); @@ -85,8 +82,8 @@ const EmailPasswordForm = ({ onSuccess }: any) => { placeholder="Enter your password" /> -
-
+
+
Forgot your password? @@ -105,5 +102,3 @@ const EmailPasswordForm = ({ onSuccess }: any) => { ); }; - -export default EmailPasswordForm; diff --git a/apps/app/components/account/email-signin-form.tsx b/apps/app/components/account/email-signin-form.tsx new file mode 100644 index 000000000..3d13ca5a1 --- /dev/null +++ b/apps/app/components/account/email-signin-form.tsx @@ -0,0 +1,46 @@ +import { useState, FC } from "react"; +import { KeyIcon } from "@heroicons/react/24/outline"; +// components +import { EmailCodeForm, EmailPasswordForm } from "components/account"; + +export interface EmailSignInFormProps { + handleSuccess: () => void; +} + +export const EmailSignInForm: FC = (props) => { + const { handleSuccess } = props; + // states + const [useCode, setUseCode] = useState(true); + + return ( + <> + {useCode ? ( + + ) : ( + + )} +
+
+
+
+
+
+ or +
+
+
+ +
+
+ + ); +}; diff --git a/apps/app/components/account/github-login-button.tsx b/apps/app/components/account/github-login-button.tsx new file mode 100644 index 000000000..e93abde88 --- /dev/null +++ b/apps/app/components/account/github-login-button.tsx @@ -0,0 +1,51 @@ +import { useEffect, useState, FC } from "react"; +import Link from "next/link"; +import Image from "next/image"; +import { useRouter } from "next/router"; +// images +import githubImage from "/public/logos/github.png"; + +const { NEXT_PUBLIC_GITHUB_ID } = process.env; + +export interface GithubLoginButtonProps { + handleSignIn: React.Dispatch; +} + +export const GithubLoginButton: FC = (props) => { + const { handleSignIn } = props; + // router + const { + query: { code }, + } = useRouter(); + // states + const [loginCallBackURL, setLoginCallBackURL] = useState(undefined); + + useEffect(() => { + if (code) { + handleSignIn(code.toString()); + } + }, [code, handleSignIn]); + + useEffect(() => { + const origin = + typeof window !== "undefined" && window.location.origin ? window.location.origin : ""; + setLoginCallBackURL(`${origin}/signin` as any); + }, []); + + return ( + + + + ); +}; diff --git a/apps/app/components/socialbuttons/google-login.tsx b/apps/app/components/account/google-login.tsx similarity index 89% rename from apps/app/components/socialbuttons/google-login.tsx rename to apps/app/components/account/google-login.tsx index 6c39c58af..078eef518 100644 --- a/apps/app/components/socialbuttons/google-login.tsx +++ b/apps/app/components/account/google-login.tsx @@ -4,12 +4,13 @@ import Script from "next/script"; export interface IGoogleLoginButton { text?: string; - onSuccess?: (res: any) => void; - onFailure?: (res: any) => void; + handleSignIn: React.Dispatch; styles?: CSSProperties; } export const GoogleLoginButton: FC = (props) => { + const { handleSignIn } = props; + const googleSignInButton = useRef(null); const [gsiScriptLoaded, setGsiScriptLoaded] = useState(false); @@ -17,7 +18,7 @@ export const GoogleLoginButton: FC = (props) => { if (!googleSignInButton.current || gsiScriptLoaded) return; window?.google?.accounts.id.initialize({ client_id: process.env.NEXT_PUBLIC_GOOGLE_CLIENTID || "", - callback: props.onSuccess as any, + callback: handleSignIn, }); window?.google?.accounts.id.renderButton( googleSignInButton.current, @@ -32,7 +33,7 @@ export const GoogleLoginButton: FC = (props) => { ); window?.google?.accounts.id.prompt(); // also display the One Tap dialog setGsiScriptLoaded(true); - }, [props.onSuccess, gsiScriptLoaded]); + }, [handleSignIn, gsiScriptLoaded]); useEffect(() => { if (window?.google?.accounts?.id) { @@ -46,7 +47,7 @@ export const GoogleLoginButton: FC = (props) => { return ( <> ); } diff --git a/apps/app/pages/_document.tsx b/apps/app/pages/_document.tsx new file mode 100644 index 000000000..befe7cd17 --- /dev/null +++ b/apps/app/pages/_document.tsx @@ -0,0 +1,22 @@ +import Document, { Html, Head, Main, NextScript } from "next/document"; + +class MyDocument extends Document { + render() { + return ( + + + + + +