diff --git a/apiserver/plane/app/serializers/page.py b/apiserver/plane/app/serializers/page.py index f13923831..853d854b3 100644 --- a/apiserver/plane/app/serializers/page.py +++ b/apiserver/plane/app/serializers/page.py @@ -8,6 +8,8 @@ from plane.db.models import ( PageLog, PageLabel, Label, + ProjectPage, + Project, ) @@ -18,6 +20,7 @@ class PageSerializer(BaseSerializer): write_only=True, required=False, ) + project = serializers.UUIDField(read_only=True) class Meta: model = Page @@ -33,17 +36,16 @@ class PageSerializer(BaseSerializer): "is_locked", "archived_at", "workspace", - "project", "created_at", "updated_at", "created_by", "updated_by", "view_props", "logo_props", + "project", ] read_only_fields = [ "workspace", - "project", "owned_by", ] @@ -57,11 +59,23 @@ class PageSerializer(BaseSerializer): project_id = self.context["project_id"] owned_by_id = self.context["owned_by_id"] description_html = self.context["description_html"] + + # Get the workspace id from the project + project = Project.objects.get(pk=project_id) + page = Page.objects.create( **validated_data, description_html=description_html, - project_id=project_id, owned_by_id=owned_by_id, + workspace_id=project.workspace_id, + ) + + 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, ) if labels is not None: @@ -70,7 +84,6 @@ class PageSerializer(BaseSerializer): PageLabel( label=label, page=page, - project_id=project_id, workspace_id=page.workspace_id, created_by_id=page.created_by_id, updated_by_id=page.updated_by_id, @@ -90,7 +103,6 @@ class PageSerializer(BaseSerializer): PageLabel( label=label, page=instance, - project_id=instance.project_id, workspace_id=instance.workspace_id, created_by_id=instance.created_by_id, updated_by_id=instance.updated_by_id, @@ -120,7 +132,6 @@ class SubPageSerializer(BaseSerializer): fields = "__all__" read_only_fields = [ "workspace", - "project", "page", ] @@ -141,6 +152,5 @@ class PageLogSerializer(BaseSerializer): fields = "__all__" read_only_fields = [ "workspace", - "project", "page", - ] \ No newline at end of file + ] diff --git a/apiserver/plane/app/views/page/base.py b/apiserver/plane/app/views/page/base.py index c7f53b9fe..dbe85f2bc 100644 --- a/apiserver/plane/app/views/page/base.py +++ b/apiserver/plane/app/views/page/base.py @@ -6,7 +6,7 @@ from django.core.serializers.json import DjangoJSONEncoder # Django imports from django.db import connection -from django.db.models import Exists, OuterRef, Q +from django.db.models import Exists, OuterRef, Q, Subquery from django.utils.decorators import method_decorator from django.views.decorators.gzip import gzip_page from django.http import StreamingHttpResponse @@ -15,6 +15,7 @@ from django.http import StreamingHttpResponse from rest_framework import status from rest_framework.response import Response + from plane.app.permissions import ProjectEntityPermission from plane.app.serializers import ( PageLogSerializer, @@ -27,6 +28,7 @@ from plane.db.models import ( PageLog, UserFavorite, ProjectMember, + ProjectPage, ) # Module imports @@ -66,28 +68,31 @@ class PageViewSet(BaseViewSet): user=self.request.user, entity_type="page", entity_identifier=OuterRef("pk"), - project_id=self.kwargs.get("project_id"), workspace__slug=self.kwargs.get("slug"), ) + project_subquery = ProjectPage.objects.filter( + page_id=OuterRef("id"), project_id=self.kwargs.get("project_id") + ).values_list("project_id", flat=True)[:1] 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, - project__project_projectmember__is_active=True, - project__archived_at__isnull=True, + 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)) - .select_related("project") + .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=Subquery(project_subquery)) + .filter(project=self.kwargs.get("project_id")) .distinct() ) @@ -115,7 +120,9 @@ class PageViewSet(BaseViewSet): def partial_update(self, request, slug, project_id, pk): try: page = Page.objects.get( - pk=pk, workspace__slug=slug, project_id=project_id + pk=pk, + workspace__slug=slug, + projects__id=project_id, ) if page.is_locked: @@ -127,7 +134,9 @@ class PageViewSet(BaseViewSet): parent = request.data.get("parent", None) if parent: _ = Page.objects.get( - pk=parent, workspace__slug=slug, project_id=project_id + pk=parent, + workspace__slug=slug, + projects__id=project_id, ) # Only update access if the page owner is the requesting user @@ -187,7 +196,7 @@ class PageViewSet(BaseViewSet): def lock(self, request, slug, project_id, pk): page = Page.objects.filter( - pk=pk, workspace__slug=slug, project_id=project_id + pk=pk, workspace__slug=slug, projects__id=project_id ).first() page.is_locked = True @@ -196,7 +205,7 @@ class PageViewSet(BaseViewSet): def unlock(self, request, slug, project_id, pk): page = Page.objects.filter( - pk=pk, workspace__slug=slug, project_id=project_id + pk=pk, workspace__slug=slug, projects__id=project_id ).first() page.is_locked = False @@ -211,7 +220,7 @@ class PageViewSet(BaseViewSet): def archive(self, request, slug, project_id, pk): page = Page.objects.get( - pk=pk, workspace__slug=slug, project_id=project_id + pk=pk, workspace__slug=slug, projects__id=project_id ) # only the owner or admin can archive the page @@ -238,7 +247,7 @@ class PageViewSet(BaseViewSet): def unarchive(self, request, slug, project_id, pk): page = Page.objects.get( - pk=pk, workspace__slug=slug, project_id=project_id + pk=pk, workspace__slug=slug, projects__id=project_id ) # only the owner or admin can un archive the page @@ -267,7 +276,7 @@ class PageViewSet(BaseViewSet): def destroy(self, request, slug, project_id, pk): page = Page.objects.get( - pk=pk, workspace__slug=slug, project_id=project_id + pk=pk, workspace__slug=slug, projects__id=project_id ) # only the owner and admin can delete the page @@ -380,7 +389,6 @@ class SubPagesEndpoint(BaseAPIView): pages = ( PageLog.objects.filter( page_id=page_id, - project_id=project_id, workspace__slug=slug, entity_name__in=["forward_link", "back_link"], ) @@ -399,7 +407,7 @@ class PagesDescriptionViewSet(BaseViewSet): def retrieve(self, request, slug, project_id, pk): page = Page.objects.get( - pk=pk, workspace__slug=slug, project_id=project_id + pk=pk, workspace__slug=slug, projects__id=project_id ) binary_data = page.description_binary @@ -419,7 +427,7 @@ class PagesDescriptionViewSet(BaseViewSet): def partial_update(self, request, slug, project_id, pk): page = Page.objects.get( - pk=pk, workspace__slug=slug, project_id=project_id + pk=pk, workspace__slug=slug, projects__id=project_id ) base64_data = request.data.get("description_binary") diff --git a/apiserver/plane/app/views/search.py b/apiserver/plane/app/views/search.py index 93bab2de3..15b05e83f 100644 --- a/apiserver/plane/app/views/search.py +++ b/apiserver/plane/app/views/search.py @@ -147,9 +147,9 @@ class GlobalSearchEndpoint(BaseAPIView): pages = Page.objects.filter( q, - project__project_projectmember__member=self.request.user, - project__project_projectmember__is_active=True, - project__archived_at__isnull=True, + projects__project_projectmember__member=self.request.user, + projects__project_projectmember__is_active=True, + projects__archived_at__isnull=True, workspace__slug=slug, ) @@ -249,7 +249,7 @@ class IssueSearchEndpoint(BaseAPIView): workspace__slug=slug, project__project_projectmember__member=self.request.user, project__project_projectmember__is_active=True, - project__archived_at__isnull=True + project__archived_at__isnull=True, ) if workspace_search == "false": diff --git a/apiserver/plane/bgtasks/dummy_data_task.py b/apiserver/plane/bgtasks/dummy_data_task.py index e76cdac22..83ba513d7 100644 --- a/apiserver/plane/bgtasks/dummy_data_task.py +++ b/apiserver/plane/bgtasks/dummy_data_task.py @@ -278,7 +278,6 @@ def create_page_labels(workspace, project, user_id, pages_count): PageLabel( page_id=page, label_id=label, - project=project, workspace=workspace, ) ) diff --git a/apiserver/plane/bgtasks/page_transaction_task.py b/apiserver/plane/bgtasks/page_transaction_task.py index eceb3693e..e3cf81a6e 100644 --- a/apiserver/plane/bgtasks/page_transaction_task.py +++ b/apiserver/plane/bgtasks/page_transaction_task.py @@ -59,7 +59,6 @@ def page_transaction(new_value, old_value, page_id): entity_identifier=mention["entity_identifier"], entity_name=mention["entity_name"], workspace_id=page.workspace_id, - project_id=page.project_id, created_at=timezone.now(), updated_at=timezone.now(), ) diff --git a/apiserver/plane/db/migrations/0068_remove_pagelabel_project_remove_pagelog_project_and_more.py b/apiserver/plane/db/migrations/0068_remove_pagelabel_project_remove_pagelog_project_and_more.py new file mode 100644 index 000000000..50475c2a8 --- /dev/null +++ b/apiserver/plane/db/migrations/0068_remove_pagelabel_project_remove_pagelog_project_and_more.py @@ -0,0 +1,257 @@ +# Generated by Django 4.2.11 on 2024-06-07 12:04 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +def migrate_pages(apps, schema_editor): + ProjectPage = apps.get_model("db", "ProjectPage") + Page = apps.get_model("db", "Page") + ProjectPage.objects.bulk_create( + [ + ProjectPage( + workspace_id=page.get("workspace_id"), + project_id=page.get("project_id"), + page_id=page.get("id"), + created_by_id=page.get("created_by_id"), + updated_by_id=page.get("updated_by_id"), + ) + for page in Page.objects.values( + "workspace_id", + "project_id", + "id", + "created_by_id", + "updated_by_id", + ) + ], + batch_size=1000, + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ("db", "0067_issue_estimate"), + ] + + operations = [ + migrations.AddField( + model_name="page", + name="is_global", + field=models.BooleanField(default=False), + ), + migrations.CreateModel( + name="ProjectPage", + 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, + ), + ), + ( + "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="project_pages", + to="db.page", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_pages", + 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="project_pages", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "Project Page", + "verbose_name_plural": "Project Pages", + "db_table": "project_pages", + "ordering": ("-created_at",), + "unique_together": {("project", "page")}, + }, + ), + migrations.CreateModel( + name="TeamPage", + 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, + ), + ), + ( + "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="team_pages", + to="db.page", + ), + ), + ( + "team", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="team_pages", + to="db.team", + ), + ), + ( + "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="team_pages", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "Team Page", + "verbose_name_plural": "Team Pages", + "db_table": "team_pages", + "ordering": ("-created_at",), + "unique_together": {("team", "page")}, + }, + ), + migrations.AddField( + model_name="page", + name="projects", + field=models.ManyToManyField( + related_name="pages", through="db.ProjectPage", to="db.project" + ), + ), + migrations.AddField( + model_name="page", + name="teams", + field=models.ManyToManyField( + related_name="pages", through="db.TeamPage", to="db.team" + ), + ), + migrations.RunPython(migrate_pages), + migrations.RemoveField( + model_name="page", + name="project", + ), + migrations.AlterField( + model_name="page", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="pages", + to="db.workspace", + ), + ), + migrations.RemoveField( + model_name="pagelabel", + name="project", + ), + migrations.RemoveField( + model_name="pagelog", + name="project", + ), + migrations.AlterField( + model_name="pagelabel", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_page_label", + to="db.workspace", + ), + ), + migrations.AlterField( + model_name="pagelog", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_page_log", + to="db.workspace", + ), + ), + ] diff --git a/apiserver/plane/db/models/__init__.py b/apiserver/plane/db/models/__init__.py index 51b0e70e5..a1c2b5ecf 100644 --- a/apiserver/plane/db/models/__init__.py +++ b/apiserver/plane/db/models/__init__.py @@ -50,7 +50,7 @@ from .notification import ( Notification, UserNotificationPreference, ) -from .page import Page, PageFavorite, PageLabel, PageLog +from .page import Page, PageFavorite, PageLabel, PageLog, ProjectPage from .project import ( Project, ProjectBaseModel, diff --git a/apiserver/plane/db/models/page.py b/apiserver/plane/db/models/page.py index e079dcbe5..9a8b3078d 100644 --- a/apiserver/plane/db/models/page.py +++ b/apiserver/plane/db/models/page.py @@ -9,13 +9,17 @@ from django.db import models from plane.utils.html_processor import strip_tags from .project import ProjectBaseModel +from .base import BaseModel def get_view_props(): return {"full_width": False} -class Page(ProjectBaseModel): +class Page(BaseModel): + workspace = models.ForeignKey( + "db.Workspace", on_delete=models.CASCADE, related_name="pages" + ) name = models.CharField(max_length=255, blank=True) description = models.JSONField(default=dict, blank=True) description_binary = models.BinaryField(null=True) @@ -44,6 +48,13 @@ class Page(ProjectBaseModel): is_locked = models.BooleanField(default=False) view_props = models.JSONField(default=get_view_props) logo_props = models.JSONField(default=dict) + is_global = models.BooleanField(default=False) + projects = models.ManyToManyField( + "db.Project", related_name="pages", through="db.ProjectPage" + ) + teams = models.ManyToManyField( + "db.Team", related_name="pages", through="db.TeamPage" + ) class Meta: verbose_name = "Page" @@ -56,7 +67,7 @@ class Page(ProjectBaseModel): return f"{self.owned_by.email} <{self.name}>" -class PageLog(ProjectBaseModel): +class PageLog(BaseModel): TYPE_CHOICES = ( ("to_do", "To Do"), ("issue", "issue"), @@ -81,6 +92,9 @@ class PageLog(ProjectBaseModel): choices=TYPE_CHOICES, verbose_name="Transaction Type", ) + workspace = models.ForeignKey( + "db.Workspace", on_delete=models.CASCADE, related_name="workspace_page_log" + ) class Meta: unique_together = ["page", "transaction"] @@ -171,13 +185,18 @@ class PageFavorite(ProjectBaseModel): return f"{self.user.email} <{self.page.name}>" -class PageLabel(ProjectBaseModel): +class PageLabel(BaseModel): label = models.ForeignKey( "db.Label", on_delete=models.CASCADE, related_name="page_labels" ) page = models.ForeignKey( "db.Page", on_delete=models.CASCADE, related_name="page_labels" ) + workspace = models.ForeignKey( + "db.Workspace", + on_delete=models.CASCADE, + related_name="workspace_page_label", + ) class Meta: verbose_name = "Page Label" @@ -187,3 +206,44 @@ class PageLabel(ProjectBaseModel): def __str__(self): return f"{self.page.name} {self.label.name}" + + +class ProjectPage(BaseModel): + project = models.ForeignKey( + "db.Project", on_delete=models.CASCADE, related_name="project_pages" + ) + page = models.ForeignKey( + "db.Page", on_delete=models.CASCADE, related_name="project_pages" + ) + workspace = models.ForeignKey( + "db.Workspace", on_delete=models.CASCADE, related_name="project_pages" + ) + + class Meta: + unique_together = ["project", "page"] + verbose_name = "Project Page" + verbose_name_plural = "Project Pages" + db_table = "project_pages" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.project.name} {self.page.name}" + + +class TeamPage(BaseModel): + team = models.ForeignKey( + "db.Team", on_delete=models.CASCADE, related_name="team_pages" + ) + page = models.ForeignKey( + "db.Page", on_delete=models.CASCADE, related_name="team_pages" + ) + workspace = models.ForeignKey( + "db.Workspace", on_delete=models.CASCADE, related_name="team_pages" + ) + + class Meta: + unique_together = ["team", "page"] + verbose_name = "Team Page" + verbose_name_plural = "Team Pages" + db_table = "team_pages" + ordering = ("-created_at",) diff --git a/apiserver/plane/utils/exception_logger.py b/apiserver/plane/utils/exception_logger.py index 4f4b5ae06..e1d4ea26f 100644 --- a/apiserver/plane/utils/exception_logger.py +++ b/apiserver/plane/utils/exception_logger.py @@ -10,14 +10,13 @@ from sentry_sdk import capture_exception def log_exception(e): - print(e) # Log the error logger = logging.getLogger("plane") logger.error(e) - # Log traceback if running in Debug if settings.DEBUG: - logger.error(traceback.format_exc(e)) + # Print the traceback if in debug mode + traceback.print_exc(e) # Capture in sentry if configured capture_exception(e)