[WIKI-635] feat: page comments in teamspace (#4122)

This commit is contained in:
Bavisetti Narayan
2025-09-25 20:25:44 +05:30
committed by GitHub
parent 2fabde97f2
commit dc44268ac4
16 changed files with 744 additions and 66 deletions

View File

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

View File

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

View File

@@ -139,29 +139,29 @@ urlpatterns = [
name="workspace-page-comments",
),
path(
"workspaces/<str:slug>/pages/<uuid:page_id>/comments/<uuid:pk>/resolve/",
"workspaces/<str:slug>/pages/<uuid:page_id>/comments/<uuid:comment_id>/resolve/",
WorkspacePageCommentViewSet.as_view({"post": "resolve"}),
name="workspace-page-comments-resolve",
),
path(
"workspaces/<str:slug>/pages/<uuid:page_id>/comments/<uuid:pk>/un-resolve/",
"workspaces/<str:slug>/pages/<uuid:page_id>/comments/<uuid:comment_id>/un-resolve/",
WorkspacePageCommentViewSet.as_view({"post": "un_resolve"}),
name="workspace-page-comments-un-resolve",
),
path(
"workspaces/<str:slug>/pages/<uuid:page_id>/comments/<uuid:pk>/",
"workspaces/<str:slug>/pages/<uuid:page_id>/comments/<uuid:comment_id>/",
WorkspacePageCommentViewSet.as_view(
{"patch": "partial_update", "delete": "destroy", "get": "list"}
),
name="workspace-page-comments",
),
path(
"workspaces/<str:slug>/pages/<uuid:page_id>/comments/<uuid:pk>/restore/",
"workspaces/<str:slug>/pages/<uuid:page_id>/comments/<uuid:comment_id>/restore/",
WorkspacePageCommentViewSet.as_view({"post": "restore"}),
name="workspace-page-comments-restore",
),
path(
"workspaces/<str:slug>/pages/<uuid:page_id>/comments/<uuid:pk>/replies/",
"workspaces/<str:slug>/pages/<uuid:page_id>/comments/<uuid:comment_id>/replies/",
WorkspacePageCommentViewSet.as_view({"get": "replies"}),
name="workspace-page-comments-replies",
),
@@ -278,29 +278,29 @@ urlpatterns = [
name="project-page-comments",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/comments/<uuid:pk>/",
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/comments/<uuid:comment_id>/",
ProjectPageCommentViewSet.as_view(
{"get": "list", "patch": "partial_update", "delete": "destroy"}
),
name="project-page-comments",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/comments/<uuid:pk>/resolve/",
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/comments/<uuid:comment_id>/resolve/",
ProjectPageCommentViewSet.as_view({"post": "resolve"}),
name="project-page-comments-resolve",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/comments/<uuid:pk>/un-resolve/",
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/comments/<uuid:comment_id>/un-resolve/",
ProjectPageCommentViewSet.as_view({"post": "un_resolve"}),
name="project-page-comments-un-resolve",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/comments/<uuid:pk>/restore/",
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/comments/<uuid:comment_id>/restore/",
ProjectPageCommentViewSet.as_view({"post": "restore"}),
name="project-page-comments-restore",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/comments/<uuid:pk>/replies/",
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/comments/<uuid:comment_id>/replies/",
ProjectPageCommentViewSet.as_view({"get": "replies"}),
name="project-page-comments-replies",
),

View File

@@ -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/<str:slug>/teamspaces/<uuid:team_space_id>/pages/<uuid:page_id>/publish/",
# TeamspacePagePublishEndpoint.as_view(),
# name="teamspace-pages-publish",
# ),
# path(
# "workspaces/<str:slug>/teamspaces/<uuid:team_space_id>/pages/<uuid:page_id>/publish/<uuid:pk>/",
# TeamspacePagePublishEndpoint.as_view(),
# name="teamspace-pages-publish",
# ),
# path(
# "workspaces/<str:slug>/teamspaces/<uuid:team_space_id>/pages/<uuid:page_id>/share/",
# TeamspacePageUserEndpoint.as_view(),
# name="teamspace-page-shared",
# ),
# path(
# "workspaces/<str:slug>/teamspaces/<uuid:team_space_id>/pages/<uuid:page_id>/share/<uuid:user_id>/",
# TeamspacePageUserEndpoint.as_view(),
# name="teamspace-page-shared",
# ),
# teamspace page comments
path(
"workspaces/<str:slug>/teamspaces/<uuid:team_space_id>/pages/<uuid:page_id>/comments/",
TeamspacePageCommentEndpoint.as_view(),
name="teamspace-page-comments",
),
path(
"workspaces/<str:slug>/teamspaces/<uuid:team_space_id>/pages/<uuid:page_id>/comments/<uuid:comment_id>/",
TeamspacePageCommentEndpoint.as_view(),
name="teamspace-page-comments",
),
path(
"workspaces/<str:slug>/teamspaces/<uuid:team_space_id>/pages/<uuid:page_id>/comments/<uuid:comment_id>/resolve/",
TeamspacePageResolveCommentEndpoint.as_view(),
name="teamspace-page-comments-resolve",
),
path(
"workspaces/<str:slug>/teamspaces/<uuid:team_space_id>/pages/<uuid:page_id>/comments/<uuid:comment_id>/un-resolve/",
TeamspacePageUnresolveCommentEndpoint.as_view(),
name="teamspace-page-comments-un-resolve",
),
path(
"workspaces/<str:slug>/teamspaces/<uuid:team_space_id>/pages/<uuid:page_id>/comments/<uuid:comment_id>/restore/",
TeamspacePageRestoreCommentEndpoint.as_view(),
name="teamspace-page-comments-restore",
),
path(
"workspaces/<str:slug>/teamspaces/<uuid:team_space_id>/pages/<uuid:page_id>/comments/<uuid:comment_id>/replies/",
TeamspacePageCommentRepliesEndpoint.as_view(),
name="teamspace-page-comments-replies",
),
# # Comment Reactions
path(
"workspaces/<str:slug>/teamspaces/<uuid:team_space_id>/pages/<uuid:page_id>/comments/<uuid:comment_id>/reactions/",
TeamspacePageCommentReactionEndpoint.as_view(),
name="teamspace-page-comment-reactions",
),
path(
"workspaces/<str:slug>/teamspaces/<uuid:team_space_id>/pages/<uuid:page_id>/comments/<uuid:comment_id>/reactions/<str:reaction_code>/",
TeamspacePageCommentReactionEndpoint.as_view(),
name="teamspace-page-comment-reactions",
),
# end teamspace page comments
]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 = <T extends keyof TPageFilters>(filterKey: T, filterValue: TPageFilters[T]) => {
runInAction(() => {

View File

@@ -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;
}
/**