diff --git a/apps/api/plane/db/models/deploy_board.py b/apps/api/plane/db/models/deploy_board.py index 60cfe589d0..43e5c1542e 100644 --- a/apps/api/plane/db/models/deploy_board.py +++ b/apps/api/plane/db/models/deploy_board.py @@ -14,6 +14,7 @@ def get_anchor(): class DeployBoard(WorkspaceBaseModel): class DeployBoardType(models.TextChoices): + TEAMSPACE_PAGE = "teamspace_page", "Teamspace Page" PROJECT = "project", "Project" ISSUE = "issue", "Issue" MODULE = "module", "Module" diff --git a/apps/api/plane/ee/permissions/page.py b/apps/api/plane/ee/permissions/page.py index 48fbc89ee8..bbcad63a89 100644 --- a/apps/api/plane/ee/permissions/page.py +++ b/apps/api/plane/ee/permissions/page.py @@ -3,7 +3,7 @@ from rest_framework.permissions import BasePermission, SAFE_METHODS from plane.payment.flags.flag_decorator import check_workspace_feature_flag from plane.db.models import WorkspaceMember, ProjectMember, Page from plane.app.permissions import ROLE -from plane.ee.models import PageUser, TeamspaceMember +from plane.ee.models import PageUser, TeamspaceMember, PageComment from plane.payment.flags.flag import FeatureFlag from plane.ee.utils.check_user_teamspace_member import ( check_if_current_user_is_teamspace_member, @@ -61,6 +61,27 @@ def has_shared_page_access(request, slug, page_id, project_id=None): return False +def has_comment_access(request, slug, page_id, comment_id, page_owner_id): + """ + Check if the user has permission to access a comment + """ + user_id = request.user.id + method = request.method + + page_comment = PageComment.objects.filter( + id=comment_id, workspace__slug=slug, page_id=page_id + ).first() + + if method in ["GET", "POST"]: + return True + + if method == "PATCH": + return page_comment.created_by_id == user_id + + if method == "DELETE": + return page_comment.created_by_id == user_id or page_owner_id == user_id + + class WorkspacePagePermission(BasePermission): """ Custom permission to control access to pages within a workspace @@ -71,6 +92,7 @@ class WorkspacePagePermission(BasePermission): user_id = request.user.id slug = view.kwargs.get("slug") page_id = view.kwargs.get("page_id") + comment_id = view.kwargs.get("comment_id", None) if request.user.is_anonymous: return False @@ -82,9 +104,10 @@ class WorkspacePagePermission(BasePermission): if page_id: page = Page.objects.get(id=page_id, workspace__slug=slug) + page_owner_id = page.owned_by_id # Allow access if the user is the owner of the page - if page.owned_by_id == user_id: + if page_owner_id == user_id: return True # If the page is private, check access based on shared page feature flag @@ -98,6 +121,11 @@ class WorkspacePagePermission(BasePermission): # If shared pages feature is not enabled, only the owner can access return False + if comment_id: + return has_comment_access( + request, slug, page_id, comment_id, page_owner_id + ) + # If the page is public, check access based on workspace role return self._has_public_page_access(request, slug) @@ -168,6 +196,7 @@ class ProjectPagePermission(BasePermission): slug = view.kwargs.get("slug") project_id = view.kwargs.get("project_id") page_id = view.kwargs.get("page_id") + comment_id = view.kwargs.get("comment_id", None) is_teamspace_member = None @@ -187,9 +216,10 @@ class ProjectPagePermission(BasePermission): if page_id: page = Page.objects.get(id=page_id, workspace__slug=slug) + page_owner_id = page.owned_by_id # Allow access if the user is the owner of the page - if page.owned_by_id == user_id: + if page_owner_id == user_id: return True # If the page is private, check access based on shared page feature flag @@ -203,6 +233,11 @@ class ProjectPagePermission(BasePermission): # If shared pages feature is not enabled, only the owner can access return False + if comment_id: + return has_comment_access( + request, slug, page_id, comment_id, page_owner_id + ) + # If the page is public, check access based on workspace role # Short-circuit: if project-level access suffices, avoid teamspace check if self._has_public_page_access(request, slug, project_id): @@ -295,8 +330,9 @@ class TeamspacePagePermission(BasePermission): user_id = request.user.id slug = view.kwargs.get("slug") - team_space_id = view.kwargs.get("team_space_id") page_id = view.kwargs.get("page_id") + team_space_id = view.kwargs.get("team_space_id") + comment_id = view.kwargs.get("comment_id", None) if not TeamspaceMember.objects.filter( member_id=user_id, @@ -307,22 +343,21 @@ class TeamspacePagePermission(BasePermission): if page_id: page = Page.objects.get(id=page_id, workspace__slug=slug) + page_owner_id = page.owned_by_id + + # we dont have private pages in teamspace + if page.access == Page.PRIVATE_ACCESS: + return False + + if comment_id: + return has_comment_access( + request, slug, page_id, comment_id, page_owner_id + ) # Allow access if the user is the owner of the page - if page.owned_by_id == user_id: + if page_owner_id == user_id: return True - # If the page is private, check access based on shared page feature flag - if page.access == Page.PRIVATE_ACCESS: - if check_workspace_feature_flag( - feature_key=FeatureFlag.SHARED_PAGES, - slug=slug, - user_id=user_id, - ): - return has_shared_page_access(request, slug, page.id) - # If shared pages feature is not enabled, only the owner can access - return False - # If the page is public return True diff --git a/apps/api/plane/ee/urls/app/page.py b/apps/api/plane/ee/urls/app/page.py index 78cde634c1..46d7844ebb 100644 --- a/apps/api/plane/ee/urls/app/page.py +++ b/apps/api/plane/ee/urls/app/page.py @@ -139,29 +139,29 @@ urlpatterns = [ name="workspace-page-comments", ), path( - "workspaces//pages//comments//resolve/", + "workspaces//pages//comments//resolve/", WorkspacePageCommentViewSet.as_view({"post": "resolve"}), name="workspace-page-comments-resolve", ), path( - "workspaces//pages//comments//un-resolve/", + "workspaces//pages//comments//un-resolve/", WorkspacePageCommentViewSet.as_view({"post": "un_resolve"}), name="workspace-page-comments-un-resolve", ), path( - "workspaces//pages//comments//", + "workspaces//pages//comments//", WorkspacePageCommentViewSet.as_view( {"patch": "partial_update", "delete": "destroy", "get": "list"} ), name="workspace-page-comments", ), path( - "workspaces//pages//comments//restore/", + "workspaces//pages//comments//restore/", WorkspacePageCommentViewSet.as_view({"post": "restore"}), name="workspace-page-comments-restore", ), path( - "workspaces//pages//comments//replies/", + "workspaces//pages//comments//replies/", WorkspacePageCommentViewSet.as_view({"get": "replies"}), name="workspace-page-comments-replies", ), @@ -278,29 +278,29 @@ urlpatterns = [ name="project-page-comments", ), path( - "workspaces//projects//pages//comments//", + "workspaces//projects//pages//comments//", ProjectPageCommentViewSet.as_view( {"get": "list", "patch": "partial_update", "delete": "destroy"} ), name="project-page-comments", ), path( - "workspaces//projects//pages//comments//resolve/", + "workspaces//projects//pages//comments//resolve/", ProjectPageCommentViewSet.as_view({"post": "resolve"}), name="project-page-comments-resolve", ), path( - "workspaces//projects//pages//comments//un-resolve/", + "workspaces//projects//pages//comments//un-resolve/", ProjectPageCommentViewSet.as_view({"post": "un_resolve"}), name="project-page-comments-un-resolve", ), path( - "workspaces//projects//pages//comments//restore/", + "workspaces//projects//pages//comments//restore/", ProjectPageCommentViewSet.as_view({"post": "restore"}), name="project-page-comments-restore", ), path( - "workspaces//projects//pages//comments//replies/", + "workspaces//projects//pages//comments//replies/", ProjectPageCommentViewSet.as_view({"get": "replies"}), name="project-page-comments-replies", ), diff --git a/apps/api/plane/ee/urls/app/teamspace.py b/apps/api/plane/ee/urls/app/teamspace.py index 5a14b4ba41..03366835bc 100644 --- a/apps/api/plane/ee/urls/app/teamspace.py +++ b/apps/api/plane/ee/urls/app/teamspace.py @@ -26,6 +26,16 @@ from plane.ee.views.app.teamspace import ( TeamspacePageDuplicateEndpoint, TeamspaceProgressSummaryEndpoint, AddTeamspaceProjectEndpoint, + TeamspacePagePublishEndpoint, + TeamspaceSubPageEndpoint, + TeamspaceParentPageEndpoint, + TeamspacePageUserEndpoint, + TeamspacePageCommentEndpoint, + TeamspacePageCommentReactionEndpoint, + TeamspacePageResolveCommentEndpoint, + TeamspacePageUnresolveCommentEndpoint, + TeamspacePageRestoreCommentEndpoint, + TeamspacePageCommentRepliesEndpoint, TeamspaceSubPageEndpoint, TeamspaceParentPageEndpoint, TeamspacePageSummaryEndpoint, @@ -200,4 +210,67 @@ urlpatterns = [ AddTeamspaceProjectEndpoint.as_view(), name="teamspace-projects", ), + # path( + # "workspaces//teamspaces//pages//publish/", + # TeamspacePagePublishEndpoint.as_view(), + # name="teamspace-pages-publish", + # ), + # path( + # "workspaces//teamspaces//pages//publish//", + # TeamspacePagePublishEndpoint.as_view(), + # name="teamspace-pages-publish", + # ), + # path( + # "workspaces//teamspaces//pages//share/", + # TeamspacePageUserEndpoint.as_view(), + # name="teamspace-page-shared", + # ), + # path( + # "workspaces//teamspaces//pages//share//", + # TeamspacePageUserEndpoint.as_view(), + # name="teamspace-page-shared", + # ), + # teamspace page comments + path( + "workspaces//teamspaces//pages//comments/", + TeamspacePageCommentEndpoint.as_view(), + name="teamspace-page-comments", + ), + path( + "workspaces//teamspaces//pages//comments//", + TeamspacePageCommentEndpoint.as_view(), + name="teamspace-page-comments", + ), + path( + "workspaces//teamspaces//pages//comments//resolve/", + TeamspacePageResolveCommentEndpoint.as_view(), + name="teamspace-page-comments-resolve", + ), + path( + "workspaces//teamspaces//pages//comments//un-resolve/", + TeamspacePageUnresolveCommentEndpoint.as_view(), + name="teamspace-page-comments-un-resolve", + ), + path( + "workspaces//teamspaces//pages//comments//restore/", + TeamspacePageRestoreCommentEndpoint.as_view(), + name="teamspace-page-comments-restore", + ), + path( + "workspaces//teamspaces//pages//comments//replies/", + TeamspacePageCommentRepliesEndpoint.as_view(), + name="teamspace-page-comments-replies", + ), + # # Comment Reactions + path( + "workspaces//teamspaces//pages//comments//reactions/", + TeamspacePageCommentReactionEndpoint.as_view(), + name="teamspace-page-comment-reactions", + ), + path( + "workspaces//teamspaces//pages//comments//reactions//", + TeamspacePageCommentReactionEndpoint.as_view(), + name="teamspace-page-comment-reactions", + ), + # end teamspace page comments ] diff --git a/apps/api/plane/ee/views/app/page/project/comment.py b/apps/api/plane/ee/views/app/page/project/comment.py index 80b39aed36..a5d10805c0 100644 --- a/apps/api/plane/ee/views/app/page/project/comment.py +++ b/apps/api/plane/ee/views/app/page/project/comment.py @@ -28,11 +28,14 @@ class ProjectPageCommentViewSet(BaseViewSet): permission_classes = [ProjectPagePermission] @check_feature_flag(FeatureFlag.PAGE_COMMENTS) - def list(self, request, slug, project_id, page_id, pk=None): - if pk: + def list(self, request, slug, project_id, page_id, comment_id=None): + if comment_id: page_comments = ( PageComment.objects.filter( - workspace__slug=slug, project_id=project_id, page_id=page_id, pk=pk + workspace__slug=slug, + project_id=project_id, + page_id=page_id, + pk=comment_id, ) .select_related("created_by", "updated_by", "workspace", "page") .prefetch_related( @@ -107,9 +110,9 @@ class ProjectPageCommentViewSet(BaseViewSet): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @check_feature_flag(FeatureFlag.PAGE_COMMENTS) - def partial_update(self, request, slug, project_id, page_id, pk): + def partial_update(self, request, slug, project_id, page_id, comment_id): page_comment = PageComment.objects.get( - workspace__slug=slug, page_id=page_id, pk=pk + workspace__slug=slug, page_id=page_id, pk=comment_id ) serializer = PageCommentSerializer( page_comment, data=request.data, partial=True @@ -127,21 +130,21 @@ class ProjectPageCommentViewSet(BaseViewSet): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @check_feature_flag(FeatureFlag.PAGE_COMMENTS) - def destroy(self, request, slug, project_id, page_id, pk): + def destroy(self, request, slug, project_id, page_id, comment_id): page_comment = PageComment.objects.get( - workspace__slug=slug, page_id=page_id, pk=pk + workspace__slug=slug, page_id=page_id, pk=comment_id ) page_comment.delete() return Response(status=status.HTTP_204_NO_CONTENT) @check_feature_flag(FeatureFlag.PAGE_COMMENTS) - def resolve(self, request, slug, project_id, page_id, pk): + def resolve(self, request, slug, project_id, page_id, comment_id): page_comment = PageComment.objects.get( workspace__slug=slug, project_id=project_id, page_id=page_id, - pk=pk, + pk=comment_id, parent__isnull=True, ) page_comment.is_resolved = True @@ -151,14 +154,14 @@ class ProjectPageCommentViewSet(BaseViewSet): action=PageAction.RESOLVED_COMMENT, slug=slug, user_id=request.user.id, - extra={"comment_id": str(pk)}, + extra={"comment_id": str(comment_id)}, ) return Response(status=status.HTTP_200_OK) @check_feature_flag(FeatureFlag.PAGE_COMMENTS) - def un_resolve(self, request, slug, project_id, page_id, pk): + def un_resolve(self, request, slug, project_id, page_id, comment_id): page_comment = PageComment.objects.get( - project_id=project_id, page_id=page_id, pk=pk, parent__isnull=True + project_id=project_id, page_id=page_id, pk=comment_id, parent__isnull=True ) page_comment.is_resolved = False page_comment.save() @@ -167,14 +170,14 @@ class ProjectPageCommentViewSet(BaseViewSet): action=PageAction.UNRESOLVED_COMMENT, slug=slug, user_id=request.user.id, - extra={"comment_id": str(pk)}, + extra={"comment_id": str(comment_id)}, ) return Response(status=status.HTTP_200_OK) @check_feature_flag(FeatureFlag.PAGE_COMMENTS) - def restore(self, request, slug, project_id, page_id, pk): + def restore(self, request, slug, project_id, page_id, comment_id): page_comment = PageComment.all_objects.filter( - Q(pk=pk) | Q(parent_id=pk), + Q(pk=comment_id) | Q(parent_id=comment_id), workspace__slug=slug, page_id=page_id, project_id=project_id, @@ -183,9 +186,12 @@ class ProjectPageCommentViewSet(BaseViewSet): return Response(status=status.HTTP_200_OK) @check_feature_flag(FeatureFlag.PAGE_COMMENTS) - def replies(self, request, slug, project_id, page_id, pk): + def replies(self, request, slug, project_id, page_id, comment_id): page_replies = PageComment.objects.filter( - workspace__slug=slug, project_id=project_id, page_id=page_id, parent_id=pk + workspace__slug=slug, + project_id=project_id, + page_id=page_id, + parent_id=comment_id, ) serializer = PageCommentSerializer(page_replies, many=True) return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/apps/api/plane/ee/views/app/page/project/share.py b/apps/api/plane/ee/views/app/page/project/share.py index 54ffaca352..80a2480f1d 100644 --- a/apps/api/plane/ee/views/app/page/project/share.py +++ b/apps/api/plane/ee/views/app/page/project/share.py @@ -148,7 +148,7 @@ class ProjectPageUserViewSet(BaseViewSet): slug=slug, project_id=project_id, user_id=request.user.id, - extra=json.dumps({"user_ids": list(user_id)}), + extra=json.dumps({"user_ids": [str(user_id)]}), ) return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apps/api/plane/ee/views/app/page/workspace/comment.py b/apps/api/plane/ee/views/app/page/workspace/comment.py index 497e1a2b38..0673d9c7c7 100644 --- a/apps/api/plane/ee/views/app/page/workspace/comment.py +++ b/apps/api/plane/ee/views/app/page/workspace/comment.py @@ -30,10 +30,10 @@ class WorkspacePageCommentViewSet(BaseViewSet): permission_classes = [WorkspacePagePermission] @check_feature_flag(FeatureFlag.PAGE_COMMENTS) - def list(self, request, slug, page_id, pk=None): - if pk: + def list(self, request, slug, page_id, comment_id=None): + if comment_id: page_comments = ( - PageComment.objects.filter(workspace__slug=slug, page_id=page_id, pk=pk) + PageComment.objects.filter(workspace__slug=slug, page_id=page_id, pk=comment_id) .select_related("created_by", "updated_by", "workspace", "page") .prefetch_related( Prefetch( @@ -100,9 +100,9 @@ class WorkspacePageCommentViewSet(BaseViewSet): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @check_feature_flag(FeatureFlag.PAGE_COMMENTS) - def partial_update(self, request, slug, page_id, pk): + def partial_update(self, request, slug, page_id, comment_id): page_comment = PageComment.objects.get( - workspace__slug=slug, page_id=page_id, pk=pk + workspace__slug=slug, page_id=page_id, pk=comment_id ) serializer = PageCommentSerializer( page_comment, data=request.data, partial=True @@ -120,18 +120,18 @@ class WorkspacePageCommentViewSet(BaseViewSet): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @check_feature_flag(FeatureFlag.PAGE_COMMENTS) - def destroy(self, request, slug, page_id, pk): + def destroy(self, request, slug, page_id, comment_id): page_comment = PageComment.objects.get( - workspace__slug=slug, page_id=page_id, pk=pk + workspace__slug=slug, page_id=page_id, pk=comment_id ) page_comment.delete() return Response(status=status.HTTP_204_NO_CONTENT) @check_feature_flag(FeatureFlag.PAGE_COMMENTS) - def resolve(self, request, slug, page_id, pk): + def resolve(self, request, slug, page_id, comment_id): page_comment = PageComment.objects.get( - workspace__slug=slug, page_id=page_id, pk=pk, parent__isnull=True + workspace__slug=slug, page_id=page_id, pk=comment_id, parent__isnull=True ) page_comment.is_resolved = True page_comment.save() @@ -140,14 +140,14 @@ class WorkspacePageCommentViewSet(BaseViewSet): action=PageAction.RESOLVED_COMMENT, slug=slug, user_id=request.user.id, - extra={"comment_id": str(pk)}, + extra={"comment_id": str(comment_id)}, ) return Response(status=status.HTTP_200_OK) @check_feature_flag(FeatureFlag.PAGE_COMMENTS) - def un_resolve(self, request, slug, page_id, pk): + def un_resolve(self, request, slug, page_id, comment_id): page_comment = PageComment.objects.get( - workspace__slug=slug, page_id=page_id, pk=pk, parent__isnull=True + workspace__slug=slug, page_id=page_id, pk=comment_id, parent__isnull=True ) page_comment.is_resolved = False page_comment.save() @@ -156,24 +156,26 @@ class WorkspacePageCommentViewSet(BaseViewSet): action=PageAction.UNRESOLVED_COMMENT, slug=slug, user_id=request.user.id, - extra={"comment_id": str(pk)}, + extra={"comment_id": str(comment_id)}, ) return Response(status=status.HTTP_200_OK) @check_feature_flag(FeatureFlag.PAGE_COMMENTS) - def restore(self, request, slug, page_id, pk): + def restore(self, request, slug, page_id, comment_id): page_comment = PageComment.all_objects.filter( - Q(pk=pk) | Q(parent_id=pk), workspace__slug=slug, page_id=page_id + Q(pk=comment_id) | Q(parent_id=comment_id), + workspace__slug=slug, + page_id=page_id, ) page_comment.update(deleted_at=None) return Response(status=status.HTTP_200_OK) @check_feature_flag(FeatureFlag.PAGE_COMMENTS) - def replies(self, request, slug, page_id, pk): + def replies(self, request, slug, page_id, comment_id): page_replies = ( PageComment.objects.filter( - workspace__slug=slug, page_id=page_id, parent_id=pk + workspace__slug=slug, page_id=page_id, parent_id=comment_id ) .select_related("created_by", "updated_by", "workspace", "page") .prefetch_related( diff --git a/apps/api/plane/ee/views/app/page/workspace/share.py b/apps/api/plane/ee/views/app/page/workspace/share.py index ec0cd58480..89f134677a 100644 --- a/apps/api/plane/ee/views/app/page/workspace/share.py +++ b/apps/api/plane/ee/views/app/page/workspace/share.py @@ -159,7 +159,7 @@ class WorkspacePageUserViewSet(BaseViewSet): action=PageAction.UNSHARED, slug=slug, user_id=request.user.id, - extra=json.dumps({"user_ids": list(user_id)}), + extra=json.dumps({"user_ids": [str(user_id)]}), ) return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apps/api/plane/ee/views/app/teamspace/__init__.py b/apps/api/plane/ee/views/app/teamspace/__init__.py index 5a551a47ff..9a18bf1fcf 100644 --- a/apps/api/plane/ee/views/app/teamspace/__init__.py +++ b/apps/api/plane/ee/views/app/teamspace/__init__.py @@ -9,7 +9,7 @@ from .analytic import ( ) from .views import TeamspaceViewEndpoint from .cycle import TeamspaceCycleEndpoint -from .page import ( +from .page.base import ( TeamspacePageEndpoint, TeamspacePageVersionEndpoint, TeamspacePagesDescriptionEndpoint, @@ -22,6 +22,17 @@ from .page import ( TeamspaceParentPageEndpoint, TeamspacePageSummaryEndpoint, ) +from .page.publish import TeamspacePagePublishEndpoint +from .page.share import TeamspacePageUserEndpoint +from .page.comment import ( + TeamspacePageCommentEndpoint, + TeamspacePageCommentReactionEndpoint, + TeamspacePageResolveCommentEndpoint, + TeamspacePageUnresolveCommentEndpoint, + TeamspacePageRestoreCommentEndpoint, + TeamspacePageCommentRepliesEndpoint, +) + from .issue import TeamspaceIssueEndpoint, TeamspaceUserPropertiesEndpoint from .activity import TeamspaceActivityEndpoint from .comment import TeamspaceCommentEndpoint, TeamspaceCommentReactionEndpoint diff --git a/apps/api/plane/ee/views/app/teamspace/page.py b/apps/api/plane/ee/views/app/teamspace/page/base.py similarity index 99% rename from apps/api/plane/ee/views/app/teamspace/page.py rename to apps/api/plane/ee/views/app/teamspace/page/base.py index 16fcdfbede..febb855fca 100644 --- a/apps/api/plane/ee/views/app/teamspace/page.py +++ b/apps/api/plane/ee/views/app/teamspace/page/base.py @@ -42,7 +42,7 @@ from plane.db.models import ( ProjectMember, ) -from plane.ee.models import TeamspacePage, TeamspaceMember, PageUser +from plane.ee.models import TeamspacePage, PageUser from plane.ee.serializers import ( TeamspacePageDetailSerializer, TeamspacePageSerializer, diff --git a/apps/api/plane/ee/views/app/teamspace/page/comment.py b/apps/api/plane/ee/views/app/teamspace/page/comment.py new file mode 100644 index 0000000000..0c7aefd9e5 --- /dev/null +++ b/apps/api/plane/ee/views/app/teamspace/page/comment.py @@ -0,0 +1,247 @@ +# Django imports +from django.utils import timezone +from django.db import IntegrityError +from django.db.models import Prefetch, OuterRef, Func, F, Q + +# Third Party imports +from rest_framework.response import Response +from rest_framework import status + +# Module imports +from plane.db.models import Workspace +from plane.ee.models import PageComment, PageCommentReaction +from plane.ee.permissions.page import TeamspacePagePermission +from plane.ee.serializers.app.page import ( + PageCommentSerializer, + PageCommentReactionSerializer, +) +from plane.ee.views.base import BaseAPIView +from plane.payment.flags.flag import FeatureFlag +from plane.payment.flags.flag_decorator import check_feature_flag +from plane.ee.bgtasks.page_update import nested_page_update, PageAction + + +class TeamspacePageCommentEndpoint(BaseAPIView): + serializer_class = PageCommentSerializer + model = PageComment + + permission_classes = [TeamspacePagePermission] + + @check_feature_flag(FeatureFlag.PAGE_COMMENTS) + def get(self, request, slug, team_space_id, page_id, comment_id=None): + if comment_id: + page_comments = ( + PageComment.objects.filter(workspace__slug=slug, page_id=page_id, pk=comment_id) + .select_related("created_by", "updated_by", "workspace", "page") + .prefetch_related( + Prefetch( + "page_comment_reactions", + queryset=PageCommentReaction.objects.select_related("actor"), + ) + ) + .annotate( + total_replies=PageComment.objects.filter( + parent=OuterRef("id"), workspace__slug=slug, page_id=page_id + ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .order_by("-created_at") + ) + else: + # fetch all the latest child comments + latest_child_comments = ( + PageComment.objects.filter( + workspace__slug=slug, page_id=page_id, parent__isnull=False + ) + .order_by("parent_id", "-created_at") + .distinct("parent_id") + .values_list("id", flat=True) + ) + + page_comments = ( + PageComment.objects.filter( + Q(id__in=latest_child_comments) | Q(parent__isnull=True) + ) + .filter( + workspace__slug=slug, + page_id=page_id, + ) + .select_related("created_by", "updated_by", "workspace", "page") + .prefetch_related( + Prefetch( + "page_comment_reactions", + queryset=PageCommentReaction.objects.select_related("actor"), + ) + ) + .annotate( + total_replies=PageComment.objects.filter( + parent=OuterRef("id"), workspace__slug=slug, page_id=page_id + ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .order_by("-created_at") + ) + serializer = PageCommentSerializer(page_comments, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + + @check_feature_flag(FeatureFlag.PAGE_COMMENTS) + def post(self, request, slug, team_space_id, page_id): + workspace_id = Workspace.objects.get(slug=slug).id + serializer = PageCommentSerializer( + data=request.data, + context={ + "workspace_id": workspace_id, + }, + ) + if serializer.is_valid(): + serializer.save(page_id=page_id, workspace_id=workspace_id) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + @check_feature_flag(FeatureFlag.PAGE_COMMENTS) + def patch(self, request, slug, team_space_id, page_id, comment_id): + page_comment = PageComment.objects.get( + workspace__slug=slug, page_id=page_id, pk=comment_id + ) + serializer = PageCommentSerializer( + page_comment, data=request.data, partial=True + ) + if serializer.is_valid(): + if ( + "comment_html" in request.data + and request.data["comment_html"] != page_comment.comment_html + ): + serializer.save(edited_at=timezone.now()) + else: + serializer.save() + + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + @check_feature_flag(FeatureFlag.PAGE_COMMENTS) + def delete(self, request, slug, team_space_id, page_id, comment_id): + page_comment = PageComment.objects.get( + workspace__slug=slug, page_id=page_id, pk=comment_id + ) + page_comment.delete() + + return Response(status=status.HTTP_204_NO_CONTENT) + + +class TeamspacePageResolveCommentEndpoint(BaseAPIView): + + permission_classes = [TeamspacePagePermission] + + @check_feature_flag(FeatureFlag.PAGE_COMMENTS) + def post(self, request, slug, team_space_id, page_id, comment_id): + page_comment = PageComment.objects.get( + workspace__slug=slug, + page_id=page_id, + pk=comment_id, + parent__isnull=True, + ) + page_comment.is_resolved = True + page_comment.save() + nested_page_update.delay( + page_id=page_id, + action=PageAction.RESOLVED_COMMENT, + slug=slug, + user_id=request.user.id, + extra={"comment_id": str(comment_id)}, + ) + return Response(status=status.HTTP_200_OK) + + +class TeamspacePageUnresolveCommentEndpoint(BaseAPIView): + permission_classes = [TeamspacePagePermission] + + @check_feature_flag(FeatureFlag.PAGE_COMMENTS) + def post(self, request, slug, team_space_id, page_id, comment_id): + page_comment = PageComment.objects.get( + workspace__slug=slug, page_id=page_id, pk=comment_id, parent__isnull=True + ) + page_comment.is_resolved = False + page_comment.save() + nested_page_update.delay( + page_id=page_id, + action=PageAction.UNRESOLVED_COMMENT, + slug=slug, + user_id=request.user.id, + extra={"comment_id": str(comment_id)}, + ) + return Response(status=status.HTTP_200_OK) + + +class TeamspacePageRestoreCommentEndpoint(BaseAPIView): + permission_classes = [TeamspacePagePermission] + + @check_feature_flag(FeatureFlag.PAGE_COMMENTS) + def post(self, request, slug, team_space_id, page_id, comment_id): + page_comment = PageComment.all_objects.filter( + Q(pk=comment_id) | Q(parent_id=comment_id), + workspace__slug=slug, + page_id=page_id, + ) + page_comment.update(deleted_at=None) + return Response(status=status.HTTP_200_OK) + + +class TeamspacePageCommentRepliesEndpoint(BaseAPIView): + permission_classes = [TeamspacePagePermission] + + @check_feature_flag(FeatureFlag.PAGE_COMMENTS) + def get(self, request, slug, team_space_id, page_id, comment_id): + page_replies = PageComment.objects.filter( + workspace__slug=slug, page_id=page_id, parent_id=comment_id + ) + serializer = PageCommentSerializer(page_replies, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + + +class TeamspacePageCommentReactionEndpoint(BaseAPIView): + serializer_class = PageCommentReactionSerializer + model = PageCommentReaction + + permission_classes = [TeamspacePagePermission] + + def get_queryset(self): + return ( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(comment_id=self.kwargs.get("comment_id")) + .order_by("-created_at") + .distinct() + ) + + @check_feature_flag(FeatureFlag.PAGE_COMMENTS) + def post(self, request, slug, team_space_id, page_id, comment_id): + try: + serializer = PageCommentReactionSerializer(data=request.data) + if serializer.is_valid(): + serializer.save( + actor_id=request.user.id, + comment_id=comment_id, + ) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + except IntegrityError: + return Response( + {"error": "Reaction already exists for the user"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + @check_feature_flag(FeatureFlag.PAGE_COMMENTS) + def delete(self, request, slug, team_space_id, page_id, comment_id, reaction_code): + comment_reaction = PageCommentReaction.objects.get( + workspace__slug=slug, + comment_id=comment_id, + reaction=reaction_code, + actor=request.user, + ) + comment_reaction.delete() + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apps/api/plane/ee/views/app/teamspace/page/publish.py b/apps/api/plane/ee/views/app/teamspace/page/publish.py new file mode 100644 index 0000000000..f2d4929a6a --- /dev/null +++ b/apps/api/plane/ee/views/app/teamspace/page/publish.py @@ -0,0 +1,134 @@ +# Third party imports +from rest_framework.response import Response +from rest_framework import status + +# Module imports +from plane.ee.views.base import BaseAPIView +from plane.db.models import DeployBoard, Workspace, Page +from plane.app.serializers import DeployBoardSerializer +from plane.payment.flags.flag_decorator import check_feature_flag +from plane.payment.flags.flag import FeatureFlag +from plane.ee.bgtasks.page_update import nested_page_update +from plane.ee.utils.page_events import PageAction +from plane.ee.permissions.page import TeamspacePagePermission + + +class TeamspacePagePublishEndpoint(BaseAPIView): + + permission_classes = [TeamspacePagePermission] + + @check_feature_flag(FeatureFlag.PAGE_PUBLISH) + def post(self, request, slug, team_space_id, page_id): + workspace = Workspace.objects.get(slug=slug) + # Fetch the page + page = Page.objects.get(pk=page_id, workspace=workspace, is_global=False) + + if page.archived_at: + return Response( + {"error": "You cannot publish an archived page"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Throw error if the page is a workspace page + if page.is_global: + return Response( + {"error": "Workspace pages cannot be published as teamspace pages"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Get the deploy board attributes + comments = request.data.get("is_comments_enabled", False) + reactions = request.data.get("is_reactions_enabled", False) + intake = request.data.get("intake", None) + votes = request.data.get("is_votes_enabled", False) + view_props = request.data.get("view_props", {}) + + # Create a deploy board for the page + deploy_board, _ = DeployBoard.objects.get_or_create( + entity_identifier=page_id, + entity_name=DeployBoard.DeployBoardType.TEAMSPACE_PAGE, + defaults={ + "is_comments_enabled": comments, + "is_reactions_enabled": reactions, + "intake": intake, + "is_votes_enabled": votes, + "view_props": view_props, + "workspace": workspace, + }, + ) + + nested_page_update.delay( + page_id=page_id, + action=PageAction.PUBLISHED, + slug=slug, + user_id=request.user.id, + ) + # Return the deploy board + serializer = DeployBoardSerializer(deploy_board) + return Response(serializer.data, status=status.HTTP_201_CREATED) + + @check_feature_flag(FeatureFlag.PAGE_PUBLISH) + def patch(self, request, slug, team_space_id, page_id): + # Get the deploy board + deploy_board = DeployBoard.objects.get( + entity_identifier=page_id, + entity_name=DeployBoard.DeployBoardType.TEAMSPACE_PAGE, + workspace__slug=slug, + ) + # Get the deploy board attributes + data = { + "is_comments_enabled": request.data.get( + "is_comments_enabled", deploy_board.is_comments_enabled + ), + "is_reactions_enabled": request.data.get( + "is_reactions_enabled", deploy_board.is_reactions_enabled + ), + "intake": request.data.get("intake", deploy_board.intake), + "is_votes_enabled": request.data.get( + "is_votes_enabled", deploy_board.is_votes_enabled + ), + "view_props": request.data.get("view_props", deploy_board.view_props), + } + + # Update the deploy board + serializer = DeployBoardSerializer(deploy_board, data=data, partial=True) + # Return the updated deploy board + if serializer.is_valid(): + # Save the updated deploy board + serializer.save() + # Return the updated deploy board + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + @check_feature_flag(FeatureFlag.PAGE_PUBLISH) + def get(self, request, slug, team_space_id, page_id): + # Get the deploy board + deploy_board = DeployBoard.objects.get( + entity_identifier=page_id, + entity_name=DeployBoard.DeployBoardType.TEAMSPACE_PAGE, + workspace__slug=slug, + ) + # Return the deploy board + serializer = DeployBoardSerializer(deploy_board) + # Return the deploy board + return Response(serializer.data, status=status.HTTP_200_OK) + + @check_feature_flag(FeatureFlag.PAGE_PUBLISH) + def delete(self, request, slug, team_space_id, page_id): + # Get the deploy board and un publish all the sub page as well. + deploy_board = DeployBoard.objects.get( + entity_identifier=page_id, + entity_name=DeployBoard.DeployBoardType.TEAMSPACE_PAGE, + workspace__slug=slug, + ) + # Delete the deploy board + deploy_board.delete() + + nested_page_update.delay( + page_id=page_id, + action=PageAction.UNPUBLISHED, + slug=slug, + user_id=request.user.id, + ) + # Return the response + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apps/api/plane/ee/views/app/teamspace/page/share.py b/apps/api/plane/ee/views/app/teamspace/page/share.py new file mode 100644 index 0000000000..613312494b --- /dev/null +++ b/apps/api/plane/ee/views/app/teamspace/page/share.py @@ -0,0 +1,151 @@ +import json + +from django.utils import timezone + +from plane.db.models import Page +from plane.ee.models import PageUser +from plane.ee.views.base import BaseAPIView +from plane.payment.flags.flag import FeatureFlag +from plane.app.serializers import PageUserSerializer +from plane.payment.flags.flag_decorator import check_feature_flag + + +## EE imports +from plane.ee.permissions.page import TeamspacePagePermission +from plane.ee.bgtasks.page_update import PageAction, nested_page_update + +from rest_framework import status +from rest_framework.response import Response + + +class TeamspacePageUserEndpoint(BaseAPIView): + serializer_class = PageUserSerializer + model = PageUser + permission_classes = [TeamspacePagePermission] + + @check_feature_flag(FeatureFlag.SHARED_PAGES) + def post(self, request, slug, team_space_id, page_id): + page = Page.objects.get(id=page_id, workspace__slug=slug) + if page.parent_id is not None: + return Response( + {"detail": "You can only share the root page"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if page.access == Page.PUBLIC_ACCESS: + return Response( + {"detail": "You can only share the private page"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + owner_id = page.owned_by_id + + # remove owner from the requested users + requested_user_map = { + str(user["user_id"]): user["access"] + for user in request.data + if str(user["user_id"]) != str(owner_id) + } + requested_user_ids = set(requested_user_map.keys()) + + existing_users = PageUser.objects.filter(page_id=page_id, workspace__slug=slug) + existing_user_map = {str(pu.user_id): pu for pu in existing_users} + existing_user_ids = set(existing_user_map.keys()) + + # 1. Users to create (in request but not in existing) + new_user_ids = requested_user_ids - existing_user_ids + users_to_create = [ + PageUser( + user_id=user_id, + page_id=page_id, + access=requested_user_map[user_id], + workspace_id=page.workspace_id, + created_by_id=request.user.id, + updated_by_id=request.user.id, + created_by=request.user, + updated_by=request.user, + ) + for user_id in new_user_ids + ] + PageUser.objects.bulk_create(users_to_create, batch_size=10) + + # 2. Users to delete (in existing but not in request) + deleted_user_ids = existing_user_ids - requested_user_ids + PageUser.objects.filter( + page_id=page_id, + user_id__in=deleted_user_ids, + workspace__slug=slug, + ).delete() + + # 3. Users to update access + common_user_ids = requested_user_ids & existing_user_ids + users_to_update = [] + for user_id in common_user_ids: + existing = existing_user_map[user_id] + new_access = requested_user_map[user_id] + if existing.access != new_access: + existing.access = new_access + existing.updated_by = request.user + existing.updated_at = timezone.now() + users_to_update.append(existing) + + if users_to_update: + PageUser.objects.bulk_update( + users_to_update, ["access", "updated_by", "updated_at"] + ) + + # Fire shared and unshared events if needed + if users_to_create or users_to_update: + nested_page_update.delay( + page_id=page.id, + action=PageAction.SHARED, + slug=slug, + user_id=request.user.id, + extra=json.dumps({"create_user_access": request.data}), + ) + + if deleted_user_ids: + nested_page_update.delay( + page_id=page_id, + action=PageAction.UNSHARED, + slug=slug, + user_id=request.user.id, + extra=json.dumps({"user_ids": list(deleted_user_ids)}), + ) + + return Response(status=status.HTTP_201_CREATED) + + @check_feature_flag(FeatureFlag.SHARED_PAGES) + def get(self, request, slug, team_space_id, page_id): + shared_pages = PageUser.objects.filter( + page_id=page_id, + workspace__slug=slug, + ) + serializer = PageUserSerializer(shared_pages, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + + @check_feature_flag(FeatureFlag.SHARED_PAGES) + def delete(self, request, slug, team_space_id, page_id, user_id): + page_user = PageUser.objects.filter( + page_id=page_id, + user_id=user_id, + workspace__slug=slug, + ).first() + + if not page_user: + return Response( + {"detail": "Page user not found"}, + status=status.HTTP_404_NOT_FOUND, + ) + + if request.user.id == user_id: + page_user.delete() + nested_page_update.delay( + page_id=page_id, + action=PageAction.UNSHARED, + slug=slug, + user_id=request.user.id, + extra=json.dumps({"user_ids": [str(user_id)]}), + ) + + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apps/web/core/store/pages/base-page.ts b/apps/web/core/store/pages/base-page.ts index e0305e9df8..f3a2f7e623 100644 --- a/apps/web/core/store/pages/base-page.ts +++ b/apps/web/core/store/pages/base-page.ts @@ -297,6 +297,20 @@ export class BasePage extends ExtendedBasePage implements TBasePage { return `/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}`; } ); + } else if (this.team) { + // Set config for team page + this.setConfig( + { + workspaceSlug, + teamspaceId: this.team, + }, + // Custom getBasePath function for team pages + (params: TPageConfigParams) => { + const { pageId, config } = params; + const { workspaceSlug, teamspaceId } = config; + return `/api/workspaces/${workspaceSlug}/teamspaces/${teamspaceId}/pages/${pageId}`; + } + ); } else { // Set config for workspace page this.setConfig( diff --git a/apps/web/ee/store/teamspace/pages/teamspace-page.store.ts b/apps/web/ee/store/teamspace/pages/teamspace-page.store.ts index 809dfb9464..044136d4d8 100644 --- a/apps/web/ee/store/teamspace/pages/teamspace-page.store.ts +++ b/apps/web/ee/store/teamspace/pages/teamspace-page.store.ts @@ -388,7 +388,10 @@ export class TeamspacePageStore implements ITeamspacePageStore { * Returns true if comments in pages feature is enabled * @returns boolean */ - isCommentsEnabled = computedFn(() => false); + isCommentsEnabled = computedFn((workspaceSlug: string) => { + const { getFeatureFlag } = this.rootStore.featureFlags; + return getFeatureFlag(workspaceSlug, "PAGE_COMMENTS", false); + }); updateFilters = (filterKey: T, filterValue: TPageFilters[T]) => { runInAction(() => { diff --git a/apps/web/ee/store/teamspace/pages/teamspace-page.ts b/apps/web/ee/store/teamspace/pages/teamspace-page.ts index 5a41ba070a..307eed73f5 100644 --- a/apps/web/ee/store/teamspace/pages/teamspace-page.ts +++ b/apps/web/ee/store/teamspace/pages/teamspace-page.ts @@ -224,7 +224,8 @@ export class TeamspacePage extends BasePage implements TTeamspacePage { * @description returns true if the current logged in user can comment on the page/reply to the comments */ get canCurrentUserCommentOnPage() { - return false; + const userRole = this.getUserWorkspaceRole(); + return this.isCurrentUserOwner || userRole === EUserWorkspaceRoles.ADMIN; } /**