From f7dbc5e9c016e4bd102c41cb17ba92ccbeb78743 Mon Sep 17 00:00:00 2001 From: pablohashescobar <118773738+pablohashescobar@users.noreply.github.com> Date: Wed, 22 Mar 2023 01:34:10 +0530 Subject: [PATCH] feat: page and page-blocks (#468) * dev: initiate paper models * feat: page and page-blocks * dev: page id filter for page blocks --- apiserver/plane/api/serializers/__init__.py | 2 + apiserver/plane/api/serializers/page.py | 46 +++++ apiserver/plane/api/urls.py | 74 ++++++++ apiserver/plane/api/views/__init__.py | 2 + apiserver/plane/api/views/page.py | 184 ++++++++++++++++++++ apiserver/plane/db/models/__init__.py | 2 + apiserver/plane/db/models/issue.py | 10 +- apiserver/plane/db/models/page.py | 73 ++++++++ 8 files changed, 392 insertions(+), 1 deletion(-) create mode 100644 apiserver/plane/api/serializers/page.py create mode 100644 apiserver/plane/api/views/page.py create mode 100644 apiserver/plane/db/models/page.py diff --git a/apiserver/plane/api/serializers/__init__.py b/apiserver/plane/api/serializers/__init__.py index 5481ba6c6..71ee447f5 100644 --- a/apiserver/plane/api/serializers/__init__.py +++ b/apiserver/plane/api/serializers/__init__.py @@ -60,3 +60,5 @@ from .integration import ( ) from .importer import ImporterSerializer + +from .page import PageSerializer, PageBlockSerializer, PageFavoriteSerializer diff --git a/apiserver/plane/api/serializers/page.py b/apiserver/plane/api/serializers/page.py new file mode 100644 index 000000000..047b6f2d0 --- /dev/null +++ b/apiserver/plane/api/serializers/page.py @@ -0,0 +1,46 @@ +# Third party imports +from rest_framework import serializers + +# Module imports +from .base import BaseSerializer +from .issue import IssueFlatSerializer +from plane.db.models import Page, PageBlock, PageFavorite + + +class PageSerializer(BaseSerializer): + is_favorite = serializers.BooleanField(read_only=True) + + class Meta: + model = Page + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "owned_by", + ] + + +class PageBlockSerializer(BaseSerializer): + issue_detail = IssueFlatSerializer(source="issue", read_only=True) + + class Meta: + model = PageBlock + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "page", + ] + + +class PageFavoriteSerializer(BaseSerializer): + page_detail = PageSerializer(source="page", read_only=True) + + class Meta: + model = PageFavorite + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "user", + ] diff --git a/apiserver/plane/api/urls.py b/apiserver/plane/api/urls.py index 2050106b9..26e94d477 100644 --- a/apiserver/plane/api/urls.py +++ b/apiserver/plane/api/urls.py @@ -100,6 +100,12 @@ from plane.api.views import ( ModuleFavoriteViewSet, ModuleLinkViewSet, ## End Modules + # Pages + PageViewSet, + PageBlockViewSet, + PageFavoriteViewSet, + CreateIssueFromPageBlockEndpoint, + ## End Pages # Api Tokens ApiTokenEndpoint, ## End Api Tokens @@ -899,6 +905,74 @@ urlpatterns = [ name="user-favorite-module", ), ## End Modules + # Pages + path( + "workspaces//projects//pages/", + PageViewSet.as_view( + { + "get": "list", + "post": "create", + } + ), + name="project-pages", + ), + path( + "workspaces//projects//pages//", + PageViewSet.as_view( + { + "get": "retrieve", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="project-pages", + ), + path( + "workspaces//projects//pages//page-blocks/", + PageBlockViewSet.as_view( + { + "get": "list", + "post": "create", + } + ), + name="project-page-blocks", + ), + path( + "workspaces//projects//pages//page-blocks//", + PageBlockViewSet.as_view( + { + "get": "retrieve", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="project-page-blocks", + ), + path( + "workspaces//projects//user-favorite-pages/", + PageFavoriteViewSet.as_view( + { + "get": "list", + "post": "create", + } + ), + name="user-favorite-pages", + ), + path( + "workspaces//projects//user-favorite-pages//", + PageFavoriteViewSet.as_view( + { + "delete": "destroy", + } + ), + name="user-favorite-pages", + ), + path( + "workspaces//projects//pages//page-blocks//issues/", + CreateIssueFromPageBlockEndpoint.as_view(), + name="page-block-issues", + ), + ## End Pages # API Tokens path("api-tokens/", ApiTokenEndpoint.as_view(), name="api-tokens"), path("api-tokens//", ApiTokenEndpoint.as_view(), name="api-tokens"), diff --git a/apiserver/plane/api/views/__init__.py b/apiserver/plane/api/views/__init__.py index fe9cdb96a..0e99069b5 100644 --- a/apiserver/plane/api/views/__init__.py +++ b/apiserver/plane/api/views/__init__.py @@ -109,3 +109,5 @@ from .importer import ( UpdateServiceImportStatusEndpoint, BulkImportIssuesEndpoint, ) + +from .page import PageViewSet, PageBlockViewSet, PageFavoriteViewSet, CreateIssueFromPageBlockEndpoint diff --git a/apiserver/plane/api/views/page.py b/apiserver/plane/api/views/page.py new file mode 100644 index 000000000..cff613bd4 --- /dev/null +++ b/apiserver/plane/api/views/page.py @@ -0,0 +1,184 @@ +# Django imports +from django.db import IntegrityError +from django.db.models import Exists, OuterRef, Q + +# Third party imports +from rest_framework import status +from rest_framework.response import Response +from sentry_sdk import capture_exception + +# Module imports +from .base import BaseViewSet, BaseAPIView +from plane.api.permissions import ProjectEntityPermission +from plane.db.models import ( + Page, + PageBlock, + PageFavorite, + Issue, + IssueAssignee, + IssueActivity, +) +from plane.api.serializers import ( + PageSerializer, + PageBlockSerializer, + PageFavoriteSerializer, + IssueSerializer, +) + + +class PageViewSet(BaseViewSet): + serializer_class = PageSerializer + model = Page + permission_classes = [ + ProjectEntityPermission, + ] + + def get_queryset(self): + subquery = PageFavorite.objects.filter( + user=self.request.user, + page_id=OuterRef("pk"), + project_id=self.kwargs.get("project_id"), + workspace__slug=self.kwargs.get("slug"), + ) + return self.filter_queryset( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(project_id=self.kwargs.get("project_id")) + .filter(project__project_projectmember__member=self.request.user) + .filter(Q(owned_by=self.request.user) | Q(access=0)) + .select_related("project") + .select_related("workspace") + .select_related("owned_by") + .annotate(is_favorite=Exists(subquery)) + .distinct() + ) + + def perform_create(self, serializer): + serializer.save( + project_id=self.kwargs.get("project_id"), owned_by=self.request.user + ) + + +class PageBlockViewSet(BaseViewSet): + serializer_class = PageBlockSerializer + model = PageBlock + permission_classes = [ + ProjectEntityPermission, + ] + + def get_queryset(self): + return self.filter_queryset( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(project_id=self.kwargs.get("project_id")) + .filter(page_id=self.kwargs.get("page_id")) + .filter(project__project_projectmember__member=self.request.user) + .select_related("project") + .select_related("workspace") + .select_related("page") + .select_related("issue") + .distinct() + ) + + def perform_create(self, serializer): + serializer.save( + project_id=self.kwargs.get("project_id"), + page_id=self.kwargs.get("page_id"), + ) + + +class PageFavoriteViewSet(BaseViewSet): + permission_classes = [ + ProjectEntityPermission, + ] + + serializer_class = PageFavoriteSerializer + model = PageFavorite + + def get_queryset(self): + return self.filter_queryset( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(user=self.request.user) + .select_related("page", "page__owned_by") + ) + + def create(self, request, slug, project_id): + try: + serializer = PageFavoriteSerializer(data=request.data) + if serializer.is_valid(): + serializer.save(user=request.user, project_id=project_id) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + except IntegrityError as e: + if "already exists" in str(e): + return Response( + {"error": "The page is already added to favorites"}, + status=status.HTTP_410_GONE, + ) + else: + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_400_BAD_REQUEST, + ) + except Exception as e: + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + def destroy(self, request, slug, project_id, page_id): + try: + page_favorite = PageFavorite.objects.get( + project=project_id, + user=request.user, + workspace__slug=slug, + page_id=page_id, + ) + page_favorite.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + except PageFavorite.DoesNotExist: + return Response( + {"error": "Page is not in favorites"}, + status=status.HTTP_400_BAD_REQUEST, + ) + except Exception as e: + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + +class CreateIssueFromPageBlockEndpoint(BaseAPIView): + def post(self, request, slug, project_id, page_id, page_block_id): + try: + page_block = PageBlock.objects.get( + pk=page_block_id, + workspace__slug=slug, + project_id=project_id, + page_id=page_id, + ) + issue = Issue.objects.create(name=page_block.name, project_id=project_id) + _ = IssueAssignee.objects.create( + issue=issue, assignee=request.user, project_id=project_id + ) + page_block.issue = issue + page_block.save() + + return Response(IssueSerializer(issue).data, status=status.HTTP_200_OK) + except PageBlock.DoesNotExist: + return Response( + {"error": "Page Block does not exist"}, status=status.HTTP_404_NOT_FOUND + ) + except Exception as e: + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_400_BAD_REQUEST, + ) diff --git a/apiserver/plane/db/models/__init__.py b/apiserver/plane/db/models/__init__.py index 05507a00e..648562425 100644 --- a/apiserver/plane/db/models/__init__.py +++ b/apiserver/plane/db/models/__init__.py @@ -60,3 +60,5 @@ from .integration import ( ) from .importer import Importer + +from .page import Page, PageBlock, PageFavorite \ No newline at end of file diff --git a/apiserver/plane/db/models/issue.py b/apiserver/plane/db/models/issue.py index f5e8b5c20..655a03e75 100644 --- a/apiserver/plane/db/models/issue.py +++ b/apiserver/plane/db/models/issue.py @@ -85,7 +85,7 @@ class Issue(ProjectBaseModel): pass else: try: - from plane.db.models import State + from plane.db.models import State, PageBlock # Get the completed states of the project completed_states = State.objects.filter( @@ -94,7 +94,15 @@ class Issue(ProjectBaseModel): # Check if the current issue state and completed state id are same if self.state.id in completed_states: self.completed_at = timezone.now() + # check if there are any page blocks + PageBlock.objects.filter(issue_id=self.id).filter().update( + completed_at=timezone.now() + ) + else: + PageBlock.objects.filter(issue_id=self.id).filter().update( + completed_at=None + ) self.completed_at = None except ImportError: diff --git a/apiserver/plane/db/models/page.py b/apiserver/plane/db/models/page.py new file mode 100644 index 000000000..5770c7b09 --- /dev/null +++ b/apiserver/plane/db/models/page.py @@ -0,0 +1,73 @@ +# Django imports +from django.db import models +from django.conf import settings + +# Module imports +from . import ProjectBaseModel + + +class Page(ProjectBaseModel): + name = models.CharField(max_length=255) + description = models.JSONField(default=dict, blank=True) + description_html = models.TextField(blank=True, default="

") + description_stripped = models.TextField(blank=True, null=True) + owned_by = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="pages" + ) + access = models.PositiveSmallIntegerField( + choices=((0, "Public"), (1, "Private")), default=0 + ) + + class Meta: + verbose_name = "Page" + verbose_name_plural = "Pages" + db_table = "pages" + ordering = ("-created_at",) + + def __str__(self): + """Return owner email and page name""" + return f"{self.owned_by.email} <{self.name}>" + + +class PageBlock(ProjectBaseModel): + page = models.ForeignKey("db.Page", on_delete=models.CASCADE, related_name="blocks") + name = models.CharField(max_length=255) + description = models.JSONField(default=dict, blank=True) + description_html = models.TextField(blank=True, default="

") + description_stripped = models.TextField(blank=True, null=True) + issue = models.ForeignKey( + "db.Issue", on_delete=models.SET_NULL, related_name="blocks", null=True + ) + completed_at = models.DateTimeField(null=True) + + class Meta: + verbose_name = "Page Block" + verbose_name_plural = "Page Blocks" + db_table = "page_blocks" + ordering = ("-created_at",) + + def __str__(self): + """Return page and page block""" + return f"{self.page.name} <{self.name}>" + + +class PageFavorite(ProjectBaseModel): + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="page_favorites", + ) + page = models.ForeignKey( + "db.Page", on_delete=models.CASCADE, related_name="page_favorites" + ) + + class Meta: + unique_together = ["page", "user"] + verbose_name = "Page Favorite" + verbose_name_plural = "Page Favorites" + db_table = "page_favorites" + ordering = ("-created_at",) + + def __str__(self): + """Return user and the page""" + return f"{self.user.email} <{self.page.name}>"