From 0c1097592e83737736659b63ff4e07d0bb427e53 Mon Sep 17 00:00:00 2001 From: Bavisetti Narayan <72156168+NarayanBavisetti@users.noreply.github.com> Date: Thu, 16 Nov 2023 14:38:12 +0530 Subject: [PATCH] fix: pages revamping (#2760) * fix: page transaction model * fix: page transaction model * fix: migration and optimisation * fix: back migration of page blocks * fix: added issue embed * fix: migration fixes * fix: resolved changes --- apiserver/plane/api/serializers/__init__.py | 2 +- apiserver/plane/api/serializers/page.py | 59 ++-- apiserver/plane/api/urls/page.py | 106 ++++-- apiserver/plane/api/urls_deprecated.py | 75 +++- apiserver/plane/api/views/__init__.py | 5 +- apiserver/plane/api/views/page.py | 327 +++++++++++++----- .../plane/bgtasks/issue_automation_task.py | 34 +- .../db/migrations/0048_auto_20231116_0713.py | 54 +++ .../db/migrations/0049_auto_20231116_0713.py | 72 ++++ apiserver/plane/db/models/__init__.py | 2 +- apiserver/plane/db/models/issue.py | 18 - apiserver/plane/db/models/page.py | 47 +++ 12 files changed, 634 insertions(+), 167 deletions(-) create mode 100644 apiserver/plane/db/migrations/0048_auto_20231116_0713.py create mode 100644 apiserver/plane/db/migrations/0049_auto_20231116_0713.py diff --git a/apiserver/plane/api/serializers/__init__.py b/apiserver/plane/api/serializers/__init__.py index 901f0bc01..c406453b7 100644 --- a/apiserver/plane/api/serializers/__init__.py +++ b/apiserver/plane/api/serializers/__init__.py @@ -85,7 +85,7 @@ from .integration import ( from .importer import ImporterSerializer -from .page import PageSerializer, PageBlockSerializer, PageFavoriteSerializer +from .page import PageSerializer, PageLogSerializer, SubPageSerializer, PageFavoriteSerializer from .estimate import ( EstimateSerializer, diff --git a/apiserver/plane/api/serializers/page.py b/apiserver/plane/api/serializers/page.py index abdf958cb..ff152627a 100644 --- a/apiserver/plane/api/serializers/page.py +++ b/apiserver/plane/api/serializers/page.py @@ -6,28 +6,7 @@ from .base import BaseSerializer from .issue import IssueFlatSerializer, LabelLiteSerializer from .workspace import WorkspaceLiteSerializer from .project import ProjectLiteSerializer -from plane.db.models import Page, PageBlock, PageFavorite, PageLabel, Label - - -class PageBlockSerializer(BaseSerializer): - issue_detail = IssueFlatSerializer(source="issue", read_only=True) - project_detail = ProjectLiteSerializer(source="project", read_only=True) - workspace_detail = WorkspaceLiteSerializer(source="workspace", read_only=True) - - class Meta: - model = PageBlock - fields = "__all__" - read_only_fields = [ - "workspace", - "project", - "page", - ] - -class PageBlockLiteSerializer(BaseSerializer): - - class Meta: - model = PageBlock - fields = "__all__" +from plane.db.models import Page, PageLog, PageFavorite, PageLabel, Label, Issue, Module class PageSerializer(BaseSerializer): @@ -38,7 +17,6 @@ class PageSerializer(BaseSerializer): write_only=True, required=False, ) - blocks = PageBlockLiteSerializer(read_only=True, many=True) project_detail = ProjectLiteSerializer(source="project", read_only=True) workspace_detail = WorkspaceLiteSerializer(source="workspace", read_only=True) @@ -102,6 +80,41 @@ class PageSerializer(BaseSerializer): return super().update(instance, validated_data) +class SubPageSerializer(BaseSerializer): + entity_details = serializers.SerializerMethodField() + + class Meta: + model = PageLog + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "page", + ] + + def get_entity_details(self, obj): + entity_name = obj.entity_name + if entity_name == 'forward_link' or entity_name == 'back_link': + try: + page = Page.objects.get(pk=obj.entity_identifier) + return PageSerializer(page).data + except Page.DoesNotExist: + return None + return None + + +class PageLogSerializer(BaseSerializer): + + class Meta: + model = PageLog + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "page", + ] + + class PageFavoriteSerializer(BaseSerializer): page_detail = PageSerializer(source="page", read_only=True) diff --git a/apiserver/plane/api/urls/page.py b/apiserver/plane/api/urls/page.py index 648702283..8b08dcc79 100644 --- a/apiserver/plane/api/urls/page.py +++ b/apiserver/plane/api/urls/page.py @@ -3,9 +3,9 @@ from django.urls import path from plane.api.views import ( PageViewSet, - PageBlockViewSet, PageFavoriteViewSet, - CreateIssueFromPageBlockEndpoint, + PageLogEndpoint, + SubPagesEndpoint, ) @@ -31,27 +31,6 @@ urlpatterns = [ ), 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( @@ -72,8 +51,83 @@ urlpatterns = [ name="user-favorite-pages", ), path( - "workspaces//projects//pages//page-blocks//issues/", - CreateIssueFromPageBlockEndpoint.as_view(), - name="page-block-issues", + "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//archive/", + PageViewSet.as_view( + { + "post": "archive", + } + ), + name="project-page-archive", + ), + path( + "workspaces//projects//pages//unarchive/", + PageViewSet.as_view( + { + "post": "unarchive", + } + ), + name="project-page-unarchive", + ), + path( + "workspaces//projects//archived-pages/", + PageViewSet.as_view( + { + "get": "archive_list", + } + ), + name="project-pages", + ), + path( + "workspaces//projects//pages//lock/", + PageViewSet.as_view( + { + "post": "lock", + } + ), + name="project-pages", + ), + path( + "workspaces//projects//pages//unlock/", + PageViewSet.as_view( + { + "post": "unlock", + } + ), + ), + path( + "workspaces//projects//pages//transactions/", + PageLogEndpoint.as_view(), + name="page-transactions", + ), + path( + "workspaces//projects//pages//transactions//", + PageLogEndpoint.as_view(), + name="page-transactions", + ), + path( + "workspaces//projects//pages//sub-pages/", + SubPagesEndpoint.as_view(), + name="sub-page", ), ] diff --git a/apiserver/plane/api/urls_deprecated.py b/apiserver/plane/api/urls_deprecated.py index 67cc62e46..1f05675a2 100644 --- a/apiserver/plane/api/urls_deprecated.py +++ b/apiserver/plane/api/urls_deprecated.py @@ -124,9 +124,10 @@ from plane.api.views import ( ## End Modules # Pages PageViewSet, - PageBlockViewSet, + PageLogEndpoint, + SubPagesEndpoint, PageFavoriteViewSet, - CreateIssueFromPageBlockEndpoint, + CreateIssueFromBlockEndpoint, ## End Pages # Api Tokens ApiTokenEndpoint, @@ -1222,25 +1223,81 @@ urlpatterns = [ name="project-pages", ), path( - "workspaces//projects//pages//page-blocks/", - PageBlockViewSet.as_view( + "workspaces//projects//pages//archive/", + PageViewSet.as_view( + { + "post": "archive", + } + ), + name="project-page-archive", + ), + path( + "workspaces//projects//pages//unarchive/", + PageViewSet.as_view( + { + "post": "unarchive", + } + ), + name="project-page-unarchive" + ), + path( + "workspaces//projects//archived-pages/", + PageViewSet.as_view( + { + "get": "archive_list", + } + ), + name="project-pages", + ), + path( + "workspaces//projects//pages//lock/", + PageViewSet.as_view( + { + "post": "lock", + } + ), + name="project-pages", + ), + path( + "workspaces//projects//pages//unlock/", + PageViewSet.as_view( + { + "post": "unlock", + } + ) + ), + path( + "workspaces//projects//pages//transactions/", + PageLogEndpoint.as_view(), name="page-transactions" + ), + path( + "workspaces//projects//pages//transactions//", + PageLogEndpoint.as_view(), name="page-transactions" + ), + path( + "workspaces//projects//pages//sub-pages/", + SubPagesEndpoint.as_view(), name="sub-page" + ), + path( + "workspaces//projects//estimates/", + BulkEstimatePointEndpoint.as_view( { "get": "list", "post": "create", } ), - name="project-page-blocks", + name="bulk-create-estimate-points", ), path( - "workspaces//projects//pages//page-blocks//", - PageBlockViewSet.as_view( + "workspaces//projects//estimates//", + BulkEstimatePointEndpoint.as_view( { "get": "retrieve", "patch": "partial_update", "delete": "destroy", } ), - name="project-page-blocks", + name="bulk-create-estimate-points", ), path( "workspaces//projects//user-favorite-pages/", @@ -1263,7 +1320,7 @@ urlpatterns = [ ), path( "workspaces//projects//pages//page-blocks//issues/", - CreateIssueFromPageBlockEndpoint.as_view(), + CreateIssueFromBlockEndpoint.as_view(), name="page-block-issues", ), ## End Pages diff --git a/apiserver/plane/api/views/__init__.py b/apiserver/plane/api/views/__init__.py index 787dfb3e2..46e88b0bc 100644 --- a/apiserver/plane/api/views/__init__.py +++ b/apiserver/plane/api/views/__init__.py @@ -138,9 +138,10 @@ from .importer import ( from .page import ( PageViewSet, - PageBlockViewSet, PageFavoriteViewSet, - CreateIssueFromPageBlockEndpoint, + PageLogEndpoint, + SubPagesEndpoint, + CreateIssueFromBlockEndpoint, ) from .search import GlobalSearchEndpoint, IssueSearchEndpoint diff --git a/apiserver/plane/api/views/page.py b/apiserver/plane/api/views/page.py index ca0927a51..d8c90fc8f 100644 --- a/apiserver/plane/api/views/page.py +++ b/apiserver/plane/api/views/page.py @@ -1,9 +1,19 @@ # Python imports -from datetime import timedelta, date +from datetime import timedelta, date, datetime # Django imports +from django.db import connection from django.db.models import Exists, OuterRef, Q, Prefetch from django.utils import timezone +from django.utils.decorators import method_decorator +from django.views.decorators.gzip import gzip_page +from django.db.models import ( + OuterRef, + Func, + F, + Q, + Exists, +) # Third party imports from rest_framework import status @@ -15,20 +25,37 @@ from .base import BaseViewSet, BaseAPIView from plane.api.permissions import ProjectEntityPermission from plane.db.models import ( Page, - PageBlock, PageFavorite, Issue, IssueAssignee, IssueActivity, + PageLog, ) from plane.api.serializers import ( PageSerializer, - PageBlockSerializer, PageFavoriteSerializer, + PageLogSerializer, IssueLiteSerializer, + SubPageSerializer, ) +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 @@ -52,6 +79,7 @@ class PageViewSet(BaseViewSet): .filter(workspace__slug=self.kwargs.get("slug")) .filter(project_id=self.kwargs.get("project_id")) .filter(project__project_projectmember__member=self.request.user) + .filter(parent__isnull=True) .filter(Q(owned_by=self.request.user) | Q(access=0)) .select_related("project") .select_related("workspace") @@ -59,15 +87,7 @@ class PageViewSet(BaseViewSet): .annotate(is_favorite=Exists(subquery)) .order_by(self.request.GET.get("order_by", "-created_at")) .prefetch_related("labels") - .order_by("name", "-is_favorite") - .prefetch_related( - Prefetch( - "blocks", - queryset=PageBlock.objects.select_related( - "page", "issue", "workspace", "project" - ), - ) - ) + .order_by("-is_favorite","-created_at") .distinct() ) @@ -88,34 +108,90 @@ class PageViewSet(BaseViewSet): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def partial_update(self, request, slug, project_id, pk): - page = Page.objects.get(pk=pk, workspace__slug=slug, project_id=project_id) - # 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 - ): + try: + page = Page.objects.get(pk=pk, workspace__slug=slug, project_id=project_id) + + 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, project_id=project_id + ) + + # 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 = PageSerializer(page, data=request.data, partial=True) + if serializer.is_valid(): + serializer.save() + 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, ) - serializer = PageSerializer(page, data=request.data, partial=True) - if serializer.is_valid(): - serializer.save() - return Response(serializer.data, status=status.HTTP_200_OK) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - + + def lock(self, request, slug, project_id, pk): + page = Page.objects.filter( + pk=pk, workspace__slug=slug, project_id=project_id + ) + + # only the owner can lock the page + if request.user.id != page.owned_by_id: + return Response( + {"error": "Only the page owner can lock the page"}, + ) + + page.is_locked = True + page.save() + return Response(status=status.HTTP_204_NO_CONTENT) + + def unlock(self, request, slug, project_id, pk): + page = Page.objects.get(pk=pk, workspace__slug=slug, project_id=project_id) + + # only the owner can unlock the page + if request.user.id != page.owned_by_id: + return Response( + {"error": "Only the page owner can unlock the page"}, + status=status.HTTP_400_BAD_REQUEST, + ) + page.is_locked = False + page.save() + + return Response(status=status.HTTP_204_NO_CONTENT) + def list(self, request, slug, project_id): - queryset = self.get_queryset() + queryset = self.get_queryset().filter(archived_at__isnull=True) page_view = request.GET.get("page_view", False) if not page_view: - return Response({"error": "Page View parameter is required"}, status=status.HTTP_400_BAD_REQUEST) + return Response( + {"error": "Page View parameter is required"}, + status=status.HTTP_400_BAD_REQUEST, + ) # All Pages if page_view == "all": - return Response(PageSerializer(queryset, many=True).data, status=status.HTTP_200_OK) + return Response( + PageSerializer(queryset, many=True).data, status=status.HTTP_200_OK + ) # Recent pages if page_view == "recent": @@ -123,66 +199,95 @@ class PageViewSet(BaseViewSet): day_before = current_time - timedelta(days=1) todays_pages = queryset.filter(updated_at__date=date.today()) yesterdays_pages = queryset.filter(updated_at__date=day_before) - earlier_this_week = queryset.filter( updated_at__date__range=( + earlier_this_week = queryset.filter( + updated_at__date__range=( (timezone.now() - timedelta(days=7)), (timezone.now() - timedelta(days=2)), - )) + ) + ) return Response( - { - "today": PageSerializer(todays_pages, many=True).data, - "yesterday": PageSerializer(yesterdays_pages, many=True).data, - "earlier_this_week": PageSerializer(earlier_this_week, many=True).data, - }, - status=status.HTTP_200_OK, - ) + { + "today": PageSerializer(todays_pages, many=True).data, + "yesterday": PageSerializer(yesterdays_pages, many=True).data, + "earlier_this_week": PageSerializer( + earlier_this_week, many=True + ).data, + }, + status=status.HTTP_200_OK, + ) # Favorite Pages if page_view == "favorite": queryset = queryset.filter(is_favorite=True) - return Response(PageSerializer(queryset, many=True).data, status=status.HTTP_200_OK) - + return Response( + PageSerializer(queryset, many=True).data, status=status.HTTP_200_OK + ) + # My pages if page_view == "created_by_me": queryset = queryset.filter(owned_by=request.user) - return Response(PageSerializer(queryset, many=True).data, status=status.HTTP_200_OK) + return Response( + PageSerializer(queryset, many=True).data, status=status.HTTP_200_OK + ) # Created by other Pages if page_view == "created_by_other": - queryset = queryset.filter(~Q(owned_by=request.user), access=0) - return Response(PageSerializer(queryset, many=True).data, status=status.HTTP_200_OK) + queryset = queryset.filter(~Q(owned_by=request.user), access=0) + return Response( + PageSerializer(queryset, many=True).data, status=status.HTTP_200_OK + ) - return Response({"error": "No matching view found"}, status=status.HTTP_400_BAD_REQUEST) - - -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") - .order_by("sort_order") - .distinct() + return Response( + {"error": "No matching view found"}, status=status.HTTP_400_BAD_REQUEST ) - def perform_create(self, serializer): - serializer.save( - project_id=self.kwargs.get("project_id"), - page_id=self.kwargs.get("page_id"), + def archive(self, request, slug, project_id, page_id): + _ = Page.objects.get( + project_id=project_id, + owned_by_id=request.user.id, + workspace__slug=slug, + pk=page_id, ) + unarchive_archive_page_and_descendants(page_id, datetime.now()) + + return Response(status=status.HTTP_204_NO_CONTENT) + + def unarchive(self, request, slug, project_id, page_id): + page = Page.objects.get( + project_id=project_id, + owned_by_id=request.user.id, + workspace__slug=slug, + pk=page_id, + ) + + page.parent = None + page.save() + + unarchive_archive_page_and_descendants(page_id, None) + + return Response(status=status.HTTP_204_NO_CONTENT) + + def archive_list(self, request, slug, project_id): + pages = ( + Page.objects.filter( + project_id=project_id, + workspace__slug=slug, + ) + .filter(archived_at__isnull=False) + .filter(parent_id__isnull=True) + ) + + if not pages: + return Response( + {"error": "No pages found"}, status=status.HTTP_400_BAD_REQUEST + ) + + return Response( + PageSerializer(pages, many=True).data, status=status.HTTP_200_OK + ) + + class PageFavoriteViewSet(BaseViewSet): permission_classes = [ @@ -196,6 +301,7 @@ class PageFavoriteViewSet(BaseViewSet): return self.filter_queryset( super() .get_queryset() + .filter(archived_at__isnull=True) .filter(workspace__slug=self.kwargs.get("slug")) .filter(user=self.request.user) .select_related("page", "page__owned_by") @@ -218,24 +324,62 @@ class PageFavoriteViewSet(BaseViewSet): page_favorite.delete() return Response(status=status.HTTP_204_NO_CONTENT) -class CreateIssueFromPageBlockEndpoint(BaseAPIView): +class PageLogEndpoint(BaseAPIView): permission_classes = [ ProjectEntityPermission, ] - def post(self, request, slug, project_id, page_id, page_block_id): - page_block = PageBlock.objects.get( - pk=page_block_id, + serializer_class = PageLogSerializer + model = PageLog + + def post(self, request, slug, project_id, page_id): + serializer = PageLogSerializer(data=request.data) + if serializer.is_valid(): + serializer.save(project_id=project_id, page_id=page_id) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def patch(self, request, slug, project_id, page_id, transaction): + page_transaction = PageLog.objects.get( workspace__slug=slug, project_id=project_id, page_id=page_id, + transaction=transaction, + ) + serializer = PageLogSerializer( + page_transaction, data=request.data, partial=True + ) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def delete(self, request, slug, project_id, page_id, transaction): + transaction = PageLog.objects.get( + workspace__slug=slug, + project_id=project_id, + page_id=page_id, + transaction=transaction, + ) + # Delete the transaction object + transaction.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class CreateIssueFromBlockEndpoint(BaseAPIView): + permission_classes = [ + ProjectEntityPermission, + ] + + def post(self, request, slug, project_id, page_id): + page = Page.objects.get( + workspace__slug=slug, + project_id=project_id, + pk=page_id, ) issue = Issue.objects.create( - name=page_block.name, + name=request.data.get("name"), project_id=project_id, - description=page_block.description, - description_html=page_block.description_html, - description_stripped=page_block.description_stripped, ) _ = IssueAssignee.objects.create( issue=issue, assignee=request.user, project_id=project_id @@ -245,11 +389,32 @@ class CreateIssueFromPageBlockEndpoint(BaseAPIView): issue=issue, actor=request.user, project_id=project_id, - comment=f"created the issue from {page_block.name} block", + comment=f"created the issue from {page.name} block", verb="created", ) - page_block.issue = issue - page_block.save() - return Response(IssueLiteSerializer(issue).data, status=status.HTTP_200_OK) + + +class SubPagesEndpoint(BaseAPIView): + permission_classes = [ + ProjectEntityPermission, + ] + + @method_decorator(gzip_page) + def get(self, request, slug, project_id, page_id): + pages = ( + PageLog.objects.filter( + page_id=page_id, + project_id=project_id, + workspace__slug=slug, + entity_name__in=["forward_link", "back_link"], + ) + .filter(archived_at__isnull=True) + .select_related("project") + .select_related("workspace") + ) + return Response( + SubPageSerializer(pages, many=True).data, status=status.HTTP_200_OK + ) + diff --git a/apiserver/plane/bgtasks/issue_automation_task.py b/apiserver/plane/bgtasks/issue_automation_task.py index 4d77eb124..d9e1e8ef2 100644 --- a/apiserver/plane/bgtasks/issue_automation_task.py +++ b/apiserver/plane/bgtasks/issue_automation_task.py @@ -12,7 +12,7 @@ from celery import shared_task from sentry_sdk import capture_exception # Module imports -from plane.db.models import Issue, Project, State +from plane.db.models import Issue, Project, State, Page from plane.bgtasks.issue_activites_task import issue_activity @@ -20,6 +20,7 @@ from plane.bgtasks.issue_activites_task import issue_activity def archive_and_close_old_issues(): archive_old_issues() close_old_issues() + delete_archived_pages() def archive_old_issues(): @@ -67,7 +68,7 @@ def archive_old_issues(): issues_to_update.append(issue) # Bulk Update the issues and log the activity - if issues_to_update: + if issues_to_update: Issue.objects.bulk_update( issues_to_update, ["archived_at"], batch_size=100 ) @@ -80,7 +81,7 @@ def archive_old_issues(): project_id=project_id, current_instance=json.dumps({"archived_at": None}), subscriber=False, - epoch=int(timezone.now().timestamp()) + epoch=int(timezone.now().timestamp()), ) for issue in issues_to_update ] @@ -142,17 +143,21 @@ def close_old_issues(): # Bulk Update the issues and log the activity if issues_to_update: - Issue.objects.bulk_update(issues_to_update, ["state"], batch_size=100) + Issue.objects.bulk_update( + issues_to_update, ["state"], batch_size=100 + ) [ issue_activity.delay( type="issue.activity.updated", - requested_data=json.dumps({"closed_to": str(issue.state_id)}), + requested_data=json.dumps( + {"closed_to": str(issue.state_id)} + ), actor_id=str(project.created_by_id), issue_id=issue.id, project_id=project_id, current_instance=None, subscriber=False, - epoch=int(timezone.now().timestamp()) + epoch=int(timezone.now().timestamp()), ) for issue in issues_to_update ] @@ -162,3 +167,20 @@ def close_old_issues(): print(e) capture_exception(e) return + + +def delete_archived_pages(): + try: + pages_to_delete = Page.objects.filter( + archived_at__isnull=False, + archived_at__lte=(timezone.now() - timedelta(days=30)), + ) + + pages_to_delete._raw_delete(pages_to_delete.db) + return + except Exception as e: + if settings.DEBUG: + print(e) + capture_exception(e) + return + diff --git a/apiserver/plane/db/migrations/0048_auto_20231116_0713.py b/apiserver/plane/db/migrations/0048_auto_20231116_0713.py new file mode 100644 index 000000000..8c5de417e --- /dev/null +++ b/apiserver/plane/db/migrations/0048_auto_20231116_0713.py @@ -0,0 +1,54 @@ +# Generated by Django 4.2.5 on 2023-11-13 12:53 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0047_webhook_apitoken_description_apitoken_expired_at_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='PageLog', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), + ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('transaction', models.UUIDField(default=uuid.uuid4)), + ('entity_identifier', models.UUIDField(null=True)), + ('entity_name', models.CharField(choices=[('to_do', 'To Do'), ('issue', 'issue'), ('image', 'Image'), ('video', 'Video'), ('file', 'File'), ('link', 'Link'), ('cycle', 'Cycle'), ('module', 'Module'), ('back_link', 'Back Link'), ('forward_link', 'Forward Link'), ('mention', 'Mention')], max_length=30, verbose_name='Transaction Type')), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('page', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='page_log', to='db.page')), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project')), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace')), + ], + options={ + 'verbose_name': 'Page Log', + 'verbose_name_plural': 'Page Logs', + 'db_table': 'page_logs', + 'ordering': ('-created_at',), + 'unique_together': {('page', 'transaction')} + }, + ), + migrations.AddField( + model_name='page', + name='archived_at', + field=models.DateField(null=True), + ), + migrations.AddField( + model_name='page', + name='is_locked', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='page', + name='parent', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='child_page', to='db.page'), + ), + ] \ No newline at end of file diff --git a/apiserver/plane/db/migrations/0049_auto_20231116_0713.py b/apiserver/plane/db/migrations/0049_auto_20231116_0713.py new file mode 100644 index 000000000..75d5e5982 --- /dev/null +++ b/apiserver/plane/db/migrations/0049_auto_20231116_0713.py @@ -0,0 +1,72 @@ +# Generated by Django 4.2.5 on 2023-11-15 09:16 + +# Python imports +import uuid + +from django.db import migrations + + +def update_pages(apps, schema_editor): + try: + Page = apps.get_model("db", "Page") + PageBlock = apps.get_model("db", "PageBlock") + PageLog = apps.get_model("db", "PageLog") + + updated_pages = [] + page_logs = [] + + # looping through all the pages + for page in Page.objects.all(): + page_blocks = PageBlock.objects.filter( + page_id=page.id, project_id=page.project_id, workspace_id=page.workspace_id + ).order_by("sort_order") + + if page_blocks: + # looping through all the page blocks in a page + for page_block in page_blocks: + if page_block.issue is not None: + project_identifier = page.project.identifier + sequence_id = page_block.issue.sequence_id + transaction = uuid.uuid4().hex + embed_component = f'' + page.description_html += embed_component + + # create the page transaction for the issue + page_logs.append( + PageLog( + page_id=page_block.page_id, + transaction=transaction, + entity_identifier=page_block.issue_id, + entity_name="issue", + project_id=page.project_id, + workspace_id=page.workspace_id, + created_by_id=page_block.created_by_id, + updated_by_id=page_block.updated_by_id, + ) + ) + else: + # adding the page block name and description to the page description + page.description_html += f"

{page_block.name}

" + page.description_html += page_block.description_html + + updated_pages.append(page) + + Page.objects.bulk_update( + updated_pages, + ["description_html"], + batch_size=100, + ) + PageLog.objects.bulk_create(page_logs, batch_size=100) + + except Exception as e: + print(e) + + +class Migration(migrations.Migration): + dependencies = [ + ("db", "0048_auto_20231116_0713"), + ] + + operations = [ + migrations.RunPython(update_pages), + ] \ No newline at end of file diff --git a/apiserver/plane/db/models/__init__.py b/apiserver/plane/db/models/__init__.py index 37ac6dfb5..c76df6e5b 100644 --- a/apiserver/plane/db/models/__init__.py +++ b/apiserver/plane/db/models/__init__.py @@ -68,7 +68,7 @@ from .integration import ( from .importer import Importer -from .page import Page, PageBlock, PageFavorite, PageLabel +from .page import Page, PageLog, PageFavorite, PageLabel from .estimate import Estimate, EstimatePoint diff --git a/apiserver/plane/db/models/issue.py b/apiserver/plane/db/models/issue.py index a951e5c11..da415058d 100644 --- a/apiserver/plane/db/models/issue.py +++ b/apiserver/plane/db/models/issue.py @@ -132,25 +132,7 @@ class Issue(ProjectBaseModel): self.state = default_state except ImportError: pass - else: - try: - from plane.db.models import State, PageBlock - # Check if the current issue state and completed state id are same - if self.state.group == "completed": - 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: - pass if self._state.adding: # Get the maximum display_id value from the database last_id = IssueSequence.objects.filter(project=self.project).aggregate( diff --git a/apiserver/plane/db/models/page.py b/apiserver/plane/db/models/page.py index 557fcb323..a8e284bb6 100644 --- a/apiserver/plane/db/models/page.py +++ b/apiserver/plane/db/models/page.py @@ -1,3 +1,5 @@ +import uuid + # Django imports from django.db import models from django.conf import settings @@ -22,6 +24,15 @@ class Page(ProjectBaseModel): labels = models.ManyToManyField( "db.Label", blank=True, related_name="pages", through="db.PageLabel" ) + parent = models.ForeignKey( + "self", + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="child_page", + ) + archived_at = models.DateField(null=True) + is_locked = models.BooleanField(default=False) class Meta: verbose_name = "Page" @@ -34,6 +45,42 @@ class Page(ProjectBaseModel): return f"{self.owned_by.email} <{self.name}>" +class PageLog(ProjectBaseModel): + TYPE_CHOICES = ( + ("to_do", "To Do"), + ("issue", "issue"), + ("image", "Image"), + ("video", "Video"), + ("file", "File"), + ("link", "Link"), + ("cycle","Cycle"), + ("module", "Module"), + ("back_link", "Back Link"), + ("forward_link", "Forward Link"), + ("mention", "Mention"), + ) + transaction = models.UUIDField(default=uuid.uuid4) + page = models.ForeignKey( + Page, related_name="page_log", on_delete=models.CASCADE + ) + entity_identifier = models.UUIDField(null=True) + entity_name = models.CharField( + max_length=30, + choices=TYPE_CHOICES, + verbose_name="Transaction Type", + ) + + class Meta: + unique_together = ["page", "transaction"] + verbose_name = "Page Log" + verbose_name_plural = "Page Logs" + db_table = "page_logs" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.page.name} {self.type}" + + class PageBlock(ProjectBaseModel): page = models.ForeignKey("db.Page", on_delete=models.CASCADE, related_name="blocks") name = models.CharField(max_length=255)