[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,
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",
]
]

View File

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

View File

@ -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":

View File

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

View File

@ -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(),
)

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,
UserNotificationPreference,
)
from .page import Page, PageFavorite, PageLabel, PageLog
from .page import Page, PageFavorite, PageLabel, PageLog, ProjectPage
from .project import (
Project,
ProjectBaseModel,

View File

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

View File

@ -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)