# Python imports import json from datetime import datetime from django.core.serializers.json import DjangoJSONEncoder # Django imports from django.db import connection from django.db.models import ( Exists, OuterRef, Q, Value, UUIDField, Count, Case, When, IntegerField, ) from django.http import StreamingHttpResponse from django.contrib.postgres.aggregates import ArrayAgg from django.contrib.postgres.fields import ArrayField from django.db.models.functions import Coalesce # Third party imports from rest_framework import status from rest_framework.response import Response # Module imports from plane.app.permissions import allow_permission, ROLE from plane.app.serializers import ( PageSerializer, PageDetailSerializer, PageBinaryUpdateSerializer, ) from plane.db.models import ( Page, PageLog, UserFavorite, ProjectMember, ProjectPage, Project, UserRecentVisit, ) from plane.utils.error_codes import ERROR_CODES # Local imports from ..base import BaseAPIView, BaseViewSet from plane.bgtasks.page_transaction_task import page_transaction from plane.bgtasks.page_version_task import page_version from plane.bgtasks.recent_visited_task import recent_visited_task from plane.bgtasks.copy_s3_object import copy_s3_objects_of_description_and_assets from plane.app.permissions import ProjectPagePermission def unarchive_archive_page_and_descendants(page_id, archived_at): # Your SQL query sql = """ WITH RECURSIVE descendants AS ( SELECT id FROM pages WHERE id = %s UNION ALL SELECT pages.id FROM pages, descendants WHERE pages.parent_id = descendants.id ) UPDATE pages SET archived_at = %s WHERE id IN (SELECT id FROM descendants); """ # Execute the SQL query with connection.cursor() as cursor: cursor.execute(sql, [page_id, archived_at]) class PageViewSet(BaseViewSet): serializer_class = PageSerializer model = Page permission_classes = [ProjectPagePermission] search_fields = ["name"] def get_queryset(self): subquery = UserFavorite.objects.filter( user=self.request.user, entity_type="page", entity_identifier=OuterRef("pk"), workspace__slug=self.kwargs.get("slug"), ) return self.filter_queryset( super() .get_queryset() .filter(workspace__slug=self.kwargs.get("slug")) .filter( projects__project_projectmember__member=self.request.user, projects__project_projectmember__is_active=True, projects__archived_at__isnull=True, ) .filter(parent__isnull=True) .filter(Q(owned_by=self.request.user) | Q(access=0)) .prefetch_related("projects") .select_related("workspace") .select_related("owned_by") .annotate(is_favorite=Exists(subquery)) .order_by(self.request.GET.get("order_by", "-created_at")) .prefetch_related("labels") .order_by("-is_favorite", "-created_at") .annotate( project=Exists( ProjectPage.objects.filter(page_id=OuterRef("id"), project_id=self.kwargs.get("project_id")) ) ) .annotate( label_ids=Coalesce( ArrayAgg( "page_labels__label_id", distinct=True, filter=~Q(page_labels__label_id__isnull=True), ), Value([], output_field=ArrayField(UUIDField())), ), project_ids=Coalesce( ArrayAgg("projects__id", distinct=True, filter=~Q(projects__id=True)), Value([], output_field=ArrayField(UUIDField())), ), ) .filter(project=True) .distinct() ) def create(self, request, slug, project_id): serializer = PageSerializer( data=request.data, context={ "project_id": project_id, "owned_by_id": request.user.id, "description_json": request.data.get("description_json", {}), "description_binary": request.data.get("description_binary", None), "description_html": request.data.get("description_html", "
"), }, ) if serializer.is_valid(): serializer.save() # capture the page transaction page_transaction.delay( new_description_html=request.data.get("description_html", ""), old_description_html=None, page_id=serializer.data["id"], ) page = self.get_queryset().get(pk=serializer.data["id"]) serializer = PageDetailSerializer(page) return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def partial_update(self, request, slug, project_id, page_id): try: page = Page.objects.get( pk=page_id, workspace__slug=slug, projects__id=project_id, project_pages__deleted_at__isnull=True, ) if page.is_locked: return Response({"error": "Page is locked"}, status=status.HTTP_400_BAD_REQUEST) parent = request.data.get("parent", None) if parent: _ = Page.objects.get( pk=parent, workspace__slug=slug, projects__id=project_id, project_pages__deleted_at__isnull=True, ) # Only update access if the page owner is the requesting user if page.access != request.data.get("access", page.access) and page.owned_by_id != request.user.id: return Response( {"error": "Access cannot be updated since this page is owned by someone else"}, status=status.HTTP_400_BAD_REQUEST, ) serializer = PageDetailSerializer(page, data=request.data, partial=True) page_description = page.description_html if serializer.is_valid(): serializer.save() # capture the page transaction if request.data.get("description_html"): page_transaction.delay( new_description_html=request.data.get("description_html", ""), old_description_html=page_description, page_id=page_id, ) return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) except Page.DoesNotExist: return Response( {"error": "Access cannot be updated since this page is owned by someone else"}, status=status.HTTP_400_BAD_REQUEST, ) def retrieve(self, request, slug, project_id, page_id=None): page = self.get_queryset().filter(pk=page_id).first() project = Project.objects.get(pk=project_id) track_visit = request.query_params.get("track_visit", "true").lower() == "true" """ if the role is guest and guest_view_all_features is false and owned by is not the requesting user then dont show the page """ if ( ProjectMember.objects.filter( workspace__slug=slug, project_id=project_id, member=request.user, role=5, is_active=True, ).exists() and not project.guest_view_all_features and not page.owned_by == request.user ): return Response( {"error": "You are not allowed to view this page"}, status=status.HTTP_400_BAD_REQUEST, ) if page is None: return Response({"error": "Page not found"}, status=status.HTTP_404_NOT_FOUND) else: issue_ids = PageLog.objects.filter(page_id=page_id, entity_name="issue").values_list( "entity_identifier", flat=True ) data = PageDetailSerializer(page).data data["issue_ids"] = issue_ids if track_visit: recent_visited_task.delay( slug=slug, entity_name="page", entity_identifier=page_id, user_id=request.user.id, project_id=project_id, ) return Response(data, status=status.HTTP_200_OK) def lock(self, request, slug, project_id, page_id): page = Page.objects.get( pk=page_id, workspace__slug=slug, projects__id=project_id, project_pages__deleted_at__isnull=True, ) page.is_locked = True page.save() return Response(status=status.HTTP_204_NO_CONTENT) def unlock(self, request, slug, project_id, page_id): page = Page.objects.get( pk=page_id, workspace__slug=slug, projects__id=project_id, project_pages__deleted_at__isnull=True, ) page.is_locked = False page.save() return Response(status=status.HTTP_204_NO_CONTENT) def access(self, request, slug, project_id, page_id): access = request.data.get("access", 0) page = Page.objects.get( pk=page_id, workspace__slug=slug, projects__id=project_id, project_pages__deleted_at__isnull=True, ) # Only update access if the page owner is the requesting user if page.access != request.data.get("access", page.access) and page.owned_by_id != request.user.id: return Response( {"error": "Access cannot be updated since this page is owned by someone else"}, status=status.HTTP_400_BAD_REQUEST, ) page.access = access page.save() return Response(status=status.HTTP_204_NO_CONTENT) def list(self, request, slug, project_id): queryset = self.get_queryset() project = Project.objects.get(pk=project_id) if ( ProjectMember.objects.filter( workspace__slug=slug, project_id=project_id, member=request.user, role=5, is_active=True, ).exists() and not project.guest_view_all_features ): queryset = queryset.filter(owned_by=request.user) pages = PageSerializer(queryset, many=True).data return Response(pages, status=status.HTTP_200_OK) def archive(self, request, slug, project_id, page_id): page = Page.objects.get( pk=page_id, workspace__slug=slug, projects__id=project_id, project_pages__deleted_at__isnull=True, ) # only the owner or admin can archive the page if ( ProjectMember.objects.filter( project_id=project_id, member=request.user, is_active=True, role__lte=15 ).exists() and request.user.id != page.owned_by_id ): return Response( {"error": "Only the owner or admin can archive the page"}, status=status.HTTP_400_BAD_REQUEST, ) UserFavorite.objects.filter( entity_type="page", entity_identifier=page_id, project_id=project_id, workspace__slug=slug, ).delete() unarchive_archive_page_and_descendants(page_id, datetime.now()) return Response({"archived_at": str(datetime.now())}, status=status.HTTP_200_OK) def unarchive(self, request, slug, project_id, page_id): page = Page.objects.get( pk=page_id, workspace__slug=slug, projects__id=project_id, project_pages__deleted_at__isnull=True, ) # only the owner or admin can un archive the page if ( ProjectMember.objects.filter( project_id=project_id, member=request.user, is_active=True, role__lte=15 ).exists() and request.user.id != page.owned_by_id ): return Response( {"error": "Only the owner or admin can un archive the page"}, status=status.HTTP_400_BAD_REQUEST, ) # if parent archived then page will be un archived breaking hierarchy if page.parent_id and page.parent.archived_at: page.parent = None page.save(update_fields=["parent"]) unarchive_archive_page_and_descendants(page_id, None) return Response(status=status.HTTP_204_NO_CONTENT) def destroy(self, request, slug, project_id, page_id): page = Page.objects.get( pk=page_id, workspace__slug=slug, projects__id=project_id, project_pages__deleted_at__isnull=True, ) if page.archived_at is None: return Response( {"error": "The page should be archived before deleting"}, status=status.HTTP_400_BAD_REQUEST, ) if page.owned_by_id != request.user.id and ( not ProjectMember.objects.filter( workspace__slug=slug, member=request.user, role=20, project_id=project_id, is_active=True, ).exists() ): return Response( {"error": "Only admin or owner can delete the page"}, status=status.HTTP_403_FORBIDDEN, ) # remove parent from all the children _ = Page.objects.filter( parent_id=page_id, projects__id=project_id, workspace__slug=slug, project_pages__deleted_at__isnull=True, ).update(parent=None) page.delete() # Delete the user favorite page UserFavorite.objects.filter( project=project_id, workspace__slug=slug, entity_identifier=page_id, entity_type="page", ).delete() # Delete the page from recent visit UserRecentVisit.objects.filter( project_id=project_id, workspace__slug=slug, entity_identifier=page_id, entity_name="page", ).delete(soft=False) return Response(status=status.HTTP_204_NO_CONTENT) def summary(self, request, slug, project_id): queryset = ( Page.objects.filter(workspace__slug=slug) .filter( projects__project_projectmember__member=self.request.user, projects__project_projectmember__is_active=True, projects__archived_at__isnull=True, ) .filter(parent__isnull=True) .filter(Q(owned_by=request.user) | Q(access=0)) .annotate( project=Exists( ProjectPage.objects.filter(page_id=OuterRef("id"), project_id=self.kwargs.get("project_id")) ) ) .filter(project=True) .distinct() ) project = Project.objects.get(pk=project_id) if ( ProjectMember.objects.filter( workspace__slug=slug, project_id=project_id, member=request.user, role=ROLE.GUEST.value, is_active=True, ).exists() and not project.guest_view_all_features ): queryset = queryset.filter(owned_by=request.user) stats = queryset.aggregate( public_pages=Count( Case( When(access=Page.PUBLIC_ACCESS, archived_at__isnull=True, then=1), output_field=IntegerField(), ) ), private_pages=Count( Case( When(access=Page.PRIVATE_ACCESS, archived_at__isnull=True, then=1), output_field=IntegerField(), ) ), archived_pages=Count(Case(When(archived_at__isnull=False, then=1), output_field=IntegerField())), ) return Response(stats, status=status.HTTP_200_OK) class PageFavoriteViewSet(BaseViewSet): model = UserFavorite @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) def create(self, request, slug, project_id, page_id): _ = UserFavorite.objects.create( project_id=project_id, entity_identifier=page_id, entity_type="page", user=request.user, ) return Response(status=status.HTTP_204_NO_CONTENT) @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) def destroy(self, request, slug, project_id, page_id): page_favorite = UserFavorite.objects.get( project=project_id, user=request.user, workspace__slug=slug, entity_identifier=page_id, entity_type="page", ) page_favorite.delete(soft=False) return Response(status=status.HTTP_204_NO_CONTENT) class PagesDescriptionViewSet(BaseViewSet): permission_classes = [ProjectPagePermission] def retrieve(self, request, slug, project_id, page_id): page = Page.objects.get( Q(owned_by=self.request.user) | Q(access=0), pk=page_id, workspace__slug=slug, projects__id=project_id, project_pages__deleted_at__isnull=True, ) binary_data = page.description_binary def stream_data(): if binary_data: yield binary_data else: yield b"" response = StreamingHttpResponse(stream_data(), content_type="application/octet-stream") response["Content-Disposition"] = 'attachment; filename="page_description.bin"' return response def partial_update(self, request, slug, project_id, page_id): page = Page.objects.get( Q(owned_by=self.request.user) | Q(access=0), pk=page_id, workspace__slug=slug, projects__id=project_id, project_pages__deleted_at__isnull=True, ) if page.is_locked: return Response( { "error_code": ERROR_CODES["PAGE_LOCKED"], "error_message": "PAGE_LOCKED", }, status=status.HTTP_400_BAD_REQUEST, ) if page.archived_at: return Response( { "error_code": ERROR_CODES["PAGE_ARCHIVED"], "error_message": "PAGE_ARCHIVED", }, status=status.HTTP_400_BAD_REQUEST, ) # Serialize the existing instance existing_instance = json.dumps({"description_html": page.description_html}, cls=DjangoJSONEncoder) # Use serializer for validation and update serializer = PageBinaryUpdateSerializer(page, data=request.data, partial=True) if serializer.is_valid(): # Capture the page transaction if request.data.get("description_html"): page_transaction.delay( new_description_html=request.data.get("description_html", ""), old_description_html=page.description_html, page_id=page_id, ) # Update the page using serializer updated_page = serializer.save() # Run background tasks page_version.delay( page_id=updated_page.id, existing_instance=existing_instance, user_id=request.user.id, ) return Response({"message": "Updated successfully"}) else: return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) class PageDuplicateEndpoint(BaseAPIView): permission_classes = [ProjectPagePermission] def post(self, request, slug, project_id, page_id): page = Page.objects.get( pk=page_id, workspace__slug=slug, projects__id=project_id, project_pages__deleted_at__isnull=True, ) # check for permission if page.access == Page.PRIVATE_ACCESS and page.owned_by_id != request.user.id: return Response({"error": "Permission denied"}, status=status.HTTP_403_FORBIDDEN) # get all the project ids where page is present project_ids = ProjectPage.objects.filter(page_id=page_id).values_list("project_id", flat=True) page.pk = None page.name = f"{page.name} (Copy)" page.description_binary = None page.owned_by = request.user page.created_by = request.user page.updated_by = request.user page.save() for project_id in project_ids: ProjectPage.objects.create( workspace_id=page.workspace_id, project_id=project_id, page_id=page.id, created_by_id=page.created_by_id, updated_by_id=page.updated_by_id, ) page_transaction.delay( new_description_html=page.description_html, old_description_html=None, page_id=page.id, ) # Copy the s3 objects uploaded in the page copy_s3_objects_of_description_and_assets.delay( entity_name="PAGE", entity_identifier=page.id, project_id=project_id, slug=slug, user_id=request.user.id, ) page = ( Page.objects.filter(pk=page.id) .annotate( project_ids=Coalesce( ArrayAgg("projects__id", distinct=True, filter=~Q(projects__id=True)), Value([], output_field=ArrayField(UUIDField())), ) ) .first() ) serializer = PageDetailSerializer(page) return Response(serializer.data, status=status.HTTP_201_CREATED)