[WEB - 1552]chore: attach pages to multiple projects (#4741)

* dev: pages migrations

* dev: page models

* dev: api migrations

* chore: apis for pages migrations

* chore: dropped project id from page label and logs

* dev: pages logger exception

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
This commit is contained in:
Nikhil 2024-06-12 19:09:21 +05:30 committed by GitHub
parent 61d8586f7f
commit cb593538e4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 370 additions and 38 deletions

View File

@ -8,6 +8,8 @@ from plane.db.models import (
PageLog, PageLog,
PageLabel, PageLabel,
Label, Label,
ProjectPage,
Project,
) )
@ -18,6 +20,7 @@ class PageSerializer(BaseSerializer):
write_only=True, write_only=True,
required=False, required=False,
) )
project = serializers.UUIDField(read_only=True)
class Meta: class Meta:
model = Page model = Page
@ -33,17 +36,16 @@ class PageSerializer(BaseSerializer):
"is_locked", "is_locked",
"archived_at", "archived_at",
"workspace", "workspace",
"project",
"created_at", "created_at",
"updated_at", "updated_at",
"created_by", "created_by",
"updated_by", "updated_by",
"view_props", "view_props",
"logo_props", "logo_props",
"project",
] ]
read_only_fields = [ read_only_fields = [
"workspace", "workspace",
"project",
"owned_by", "owned_by",
] ]
@ -57,11 +59,23 @@ class PageSerializer(BaseSerializer):
project_id = self.context["project_id"] project_id = self.context["project_id"]
owned_by_id = self.context["owned_by_id"] owned_by_id = self.context["owned_by_id"]
description_html = self.context["description_html"] description_html = self.context["description_html"]
# Get the workspace id from the project
project = Project.objects.get(pk=project_id)
page = Page.objects.create( page = Page.objects.create(
**validated_data, **validated_data,
description_html=description_html, description_html=description_html,
project_id=project_id,
owned_by_id=owned_by_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: if labels is not None:
@ -70,7 +84,6 @@ class PageSerializer(BaseSerializer):
PageLabel( PageLabel(
label=label, label=label,
page=page, page=page,
project_id=project_id,
workspace_id=page.workspace_id, workspace_id=page.workspace_id,
created_by_id=page.created_by_id, created_by_id=page.created_by_id,
updated_by_id=page.updated_by_id, updated_by_id=page.updated_by_id,
@ -90,7 +103,6 @@ class PageSerializer(BaseSerializer):
PageLabel( PageLabel(
label=label, label=label,
page=instance, page=instance,
project_id=instance.project_id,
workspace_id=instance.workspace_id, workspace_id=instance.workspace_id,
created_by_id=instance.created_by_id, created_by_id=instance.created_by_id,
updated_by_id=instance.updated_by_id, updated_by_id=instance.updated_by_id,
@ -120,7 +132,6 @@ class SubPageSerializer(BaseSerializer):
fields = "__all__" fields = "__all__"
read_only_fields = [ read_only_fields = [
"workspace", "workspace",
"project",
"page", "page",
] ]
@ -141,6 +152,5 @@ class PageLogSerializer(BaseSerializer):
fields = "__all__" fields = "__all__"
read_only_fields = [ read_only_fields = [
"workspace", "workspace",
"project",
"page", "page",
] ]

View File

@ -6,7 +6,7 @@ from django.core.serializers.json import DjangoJSONEncoder
# Django imports # Django imports
from django.db import connection 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.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page from django.views.decorators.gzip import gzip_page
from django.http import StreamingHttpResponse from django.http import StreamingHttpResponse
@ -15,6 +15,7 @@ from django.http import StreamingHttpResponse
from rest_framework import status from rest_framework import status
from rest_framework.response import Response from rest_framework.response import Response
from plane.app.permissions import ProjectEntityPermission from plane.app.permissions import ProjectEntityPermission
from plane.app.serializers import ( from plane.app.serializers import (
PageLogSerializer, PageLogSerializer,
@ -27,6 +28,7 @@ from plane.db.models import (
PageLog, PageLog,
UserFavorite, UserFavorite,
ProjectMember, ProjectMember,
ProjectPage,
) )
# Module imports # Module imports
@ -66,28 +68,31 @@ class PageViewSet(BaseViewSet):
user=self.request.user, user=self.request.user,
entity_type="page", entity_type="page",
entity_identifier=OuterRef("pk"), entity_identifier=OuterRef("pk"),
project_id=self.kwargs.get("project_id"),
workspace__slug=self.kwargs.get("slug"), 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( return self.filter_queryset(
super() super()
.get_queryset() .get_queryset()
.filter(workspace__slug=self.kwargs.get("slug")) .filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id"))
.filter( .filter(
project__project_projectmember__member=self.request.user, projects__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True, projects__project_projectmember__is_active=True,
project__archived_at__isnull=True, projects__archived_at__isnull=True,
) )
.filter(parent__isnull=True) .filter(parent__isnull=True)
.filter(Q(owned_by=self.request.user) | Q(access=0)) .filter(Q(owned_by=self.request.user) | Q(access=0))
.select_related("project") .prefetch_related("projects")
.select_related("workspace") .select_related("workspace")
.select_related("owned_by") .select_related("owned_by")
.annotate(is_favorite=Exists(subquery)) .annotate(is_favorite=Exists(subquery))
.order_by(self.request.GET.get("order_by", "-created_at")) .order_by(self.request.GET.get("order_by", "-created_at"))
.prefetch_related("labels") .prefetch_related("labels")
.order_by("-is_favorite", "-created_at") .order_by("-is_favorite", "-created_at")
.annotate(project=Subquery(project_subquery))
.filter(project=self.kwargs.get("project_id"))
.distinct() .distinct()
) )
@ -115,7 +120,9 @@ class PageViewSet(BaseViewSet):
def partial_update(self, request, slug, project_id, pk): def partial_update(self, request, slug, project_id, pk):
try: try:
page = Page.objects.get( 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: if page.is_locked:
@ -127,7 +134,9 @@ class PageViewSet(BaseViewSet):
parent = request.data.get("parent", None) parent = request.data.get("parent", None)
if parent: if parent:
_ = Page.objects.get( _ = 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 # 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): def lock(self, request, slug, project_id, pk):
page = Page.objects.filter( page = Page.objects.filter(
pk=pk, workspace__slug=slug, project_id=project_id pk=pk, workspace__slug=slug, projects__id=project_id
).first() ).first()
page.is_locked = True page.is_locked = True
@ -196,7 +205,7 @@ class PageViewSet(BaseViewSet):
def unlock(self, request, slug, project_id, pk): def unlock(self, request, slug, project_id, pk):
page = Page.objects.filter( page = Page.objects.filter(
pk=pk, workspace__slug=slug, project_id=project_id pk=pk, workspace__slug=slug, projects__id=project_id
).first() ).first()
page.is_locked = False page.is_locked = False
@ -211,7 +220,7 @@ class PageViewSet(BaseViewSet):
def archive(self, request, slug, project_id, pk): def archive(self, request, slug, project_id, pk):
page = Page.objects.get( 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 # only the owner or admin can archive the page
@ -238,7 +247,7 @@ class PageViewSet(BaseViewSet):
def unarchive(self, request, slug, project_id, pk): def unarchive(self, request, slug, project_id, pk):
page = Page.objects.get( 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 # 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): def destroy(self, request, slug, project_id, pk):
page = Page.objects.get( 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 # only the owner and admin can delete the page
@ -380,7 +389,6 @@ class SubPagesEndpoint(BaseAPIView):
pages = ( pages = (
PageLog.objects.filter( PageLog.objects.filter(
page_id=page_id, page_id=page_id,
project_id=project_id,
workspace__slug=slug, workspace__slug=slug,
entity_name__in=["forward_link", "back_link"], entity_name__in=["forward_link", "back_link"],
) )
@ -399,7 +407,7 @@ class PagesDescriptionViewSet(BaseViewSet):
def retrieve(self, request, slug, project_id, pk): def retrieve(self, request, slug, project_id, pk):
page = Page.objects.get( 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 binary_data = page.description_binary
@ -419,7 +427,7 @@ class PagesDescriptionViewSet(BaseViewSet):
def partial_update(self, request, slug, project_id, pk): def partial_update(self, request, slug, project_id, pk):
page = Page.objects.get( 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") base64_data = request.data.get("description_binary")

View File

@ -147,9 +147,9 @@ class GlobalSearchEndpoint(BaseAPIView):
pages = Page.objects.filter( pages = Page.objects.filter(
q, q,
project__project_projectmember__member=self.request.user, projects__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True, projects__project_projectmember__is_active=True,
project__archived_at__isnull=True, projects__archived_at__isnull=True,
workspace__slug=slug, workspace__slug=slug,
) )
@ -249,7 +249,7 @@ class IssueSearchEndpoint(BaseAPIView):
workspace__slug=slug, workspace__slug=slug,
project__project_projectmember__member=self.request.user, project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True, project__project_projectmember__is_active=True,
project__archived_at__isnull=True project__archived_at__isnull=True,
) )
if workspace_search == "false": if workspace_search == "false":

View File

@ -278,7 +278,6 @@ def create_page_labels(workspace, project, user_id, pages_count):
PageLabel( PageLabel(
page_id=page, page_id=page,
label_id=label, label_id=label,
project=project,
workspace=workspace, workspace=workspace,
) )
) )

View File

@ -59,7 +59,6 @@ def page_transaction(new_value, old_value, page_id):
entity_identifier=mention["entity_identifier"], entity_identifier=mention["entity_identifier"],
entity_name=mention["entity_name"], entity_name=mention["entity_name"],
workspace_id=page.workspace_id, workspace_id=page.workspace_id,
project_id=page.project_id,
created_at=timezone.now(), created_at=timezone.now(),
updated_at=timezone.now(), updated_at=timezone.now(),
) )

View File

@ -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",
),
),
]

View File

@ -50,7 +50,7 @@ from .notification import (
Notification, Notification,
UserNotificationPreference, UserNotificationPreference,
) )
from .page import Page, PageFavorite, PageLabel, PageLog from .page import Page, PageFavorite, PageLabel, PageLog, ProjectPage
from .project import ( from .project import (
Project, Project,
ProjectBaseModel, ProjectBaseModel,

View File

@ -9,13 +9,17 @@ from django.db import models
from plane.utils.html_processor import strip_tags from plane.utils.html_processor import strip_tags
from .project import ProjectBaseModel from .project import ProjectBaseModel
from .base import BaseModel
def get_view_props(): def get_view_props():
return {"full_width": False} 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) name = models.CharField(max_length=255, blank=True)
description = models.JSONField(default=dict, blank=True) description = models.JSONField(default=dict, blank=True)
description_binary = models.BinaryField(null=True) description_binary = models.BinaryField(null=True)
@ -44,6 +48,13 @@ class Page(ProjectBaseModel):
is_locked = models.BooleanField(default=False) is_locked = models.BooleanField(default=False)
view_props = models.JSONField(default=get_view_props) view_props = models.JSONField(default=get_view_props)
logo_props = models.JSONField(default=dict) 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: class Meta:
verbose_name = "Page" verbose_name = "Page"
@ -56,7 +67,7 @@ class Page(ProjectBaseModel):
return f"{self.owned_by.email} <{self.name}>" return f"{self.owned_by.email} <{self.name}>"
class PageLog(ProjectBaseModel): class PageLog(BaseModel):
TYPE_CHOICES = ( TYPE_CHOICES = (
("to_do", "To Do"), ("to_do", "To Do"),
("issue", "issue"), ("issue", "issue"),
@ -81,6 +92,9 @@ class PageLog(ProjectBaseModel):
choices=TYPE_CHOICES, choices=TYPE_CHOICES,
verbose_name="Transaction Type", verbose_name="Transaction Type",
) )
workspace = models.ForeignKey(
"db.Workspace", on_delete=models.CASCADE, related_name="workspace_page_log"
)
class Meta: class Meta:
unique_together = ["page", "transaction"] unique_together = ["page", "transaction"]
@ -171,13 +185,18 @@ class PageFavorite(ProjectBaseModel):
return f"{self.user.email} <{self.page.name}>" return f"{self.user.email} <{self.page.name}>"
class PageLabel(ProjectBaseModel): class PageLabel(BaseModel):
label = models.ForeignKey( label = models.ForeignKey(
"db.Label", on_delete=models.CASCADE, related_name="page_labels" "db.Label", on_delete=models.CASCADE, related_name="page_labels"
) )
page = models.ForeignKey( page = models.ForeignKey(
"db.Page", on_delete=models.CASCADE, related_name="page_labels" "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: class Meta:
verbose_name = "Page Label" verbose_name = "Page Label"
@ -187,3 +206,44 @@ class PageLabel(ProjectBaseModel):
def __str__(self): def __str__(self):
return f"{self.page.name} {self.label.name}" 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",)

View File

@ -10,14 +10,13 @@ from sentry_sdk import capture_exception
def log_exception(e): def log_exception(e):
print(e)
# Log the error # Log the error
logger = logging.getLogger("plane") logger = logging.getLogger("plane")
logger.error(e) logger.error(e)
# Log traceback if running in Debug
if settings.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 in sentry if configured
capture_exception(e) capture_exception(e)