diff --git a/apps/api/plane/bgtasks/dummy_data_task.py b/apps/api/plane/bgtasks/dummy_data_task.py index 390bc160b5f..eefd64177c2 100644 --- a/apps/api/plane/bgtasks/dummy_data_task.py +++ b/apps/api/plane/bgtasks/dummy_data_task.py @@ -219,7 +219,7 @@ def create_pages(workspace, project, user_id, pages_count): Faker.seed(0) pages = [] - for _ in range(0, pages_count): + for index in range(0, pages_count): text = fake.text(max_nb_chars=60000) pages.append( Page( @@ -231,13 +231,14 @@ def create_pages(workspace, project, user_id, pages_count): description_html=f"
{text}
", archived_at=None, is_locked=False, + sort_order=index * 10000, ) ) # Bulk create pages pages = Page.objects.bulk_create(pages, ignore_conflicts=True) # Add Page to project ProjectPage.objects.bulk_create( - [ProjectPage(page=page, project=project, workspace=workspace) for page in pages], + [ProjectPage(page=page, project=project, workspace=workspace, sort_order=page.sort_order) for page in pages], batch_size=1000, ) diff --git a/apps/api/plane/db/migrations/0114_projectpage_sort_order.py b/apps/api/plane/db/migrations/0114_projectpage_sort_order.py new file mode 100644 index 00000000000..756c92d02a2 --- /dev/null +++ b/apps/api/plane/db/migrations/0114_projectpage_sort_order.py @@ -0,0 +1,32 @@ +# Generated by Django 4.2.22 on 2026-01-05 13:28 + +from django.db import migrations, models + +def update_projectpage_sort_order(apps, schema_editor): + ProjectPage = apps.get_model("db", "ProjectPage") + Project = apps.get_model("db", "Project") + + for project in Project.objects.all(): + pages = list( + ProjectPage.objects.filter(project=project).order_by("created_at") + ) + + for index, page in enumerate(pages): + page.sort_order = index * 10000 + + ProjectPage.objects.bulk_update(pages, ["sort_order"], batch_size=3000) + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0113_webhook_version'), + ] + + operations = [ + migrations.AddField( + model_name='projectpage', + name='sort_order', + field=models.FloatField(default=65535), + ), + migrations.RunPython(update_projectpage_sort_order, reverse_code=migrations.RunPython.noop), + ] diff --git a/apps/api/plane/db/models/page.py b/apps/api/plane/db/models/page.py index 213954d1498..184e2996a21 100644 --- a/apps/api/plane/db/models/page.py +++ b/apps/api/plane/db/models/page.py @@ -4,10 +4,11 @@ from django.utils import timezone # Django imports -from django.db import models +from django.db import connection, models, transaction # Module imports from plane.utils.html_processor import strip_tags +from plane.utils.uuid import convert_uuid_to_integer from .base import BaseModel @@ -63,6 +64,22 @@ def __str__(self): """Return owner email and page name""" return f"{self.owned_by.email} <{self.name}>" + def _get_sort_order(self, project): + """Get the next sort order for the page within a specific project.""" + if self.access == Page.PRIVATE_ACCESS: + largest = ProjectPage.objects.filter( + page__access=Page.PRIVATE_ACCESS, + page__owned_by=self.owned_by, + project=project, + ).aggregate(largest=models.Max("sort_order"))["largest"] + else: + largest = ProjectPage.objects.filter( + page__access=Page.PUBLIC_ACCESS, + project=project, + ).aggregate(largest=models.Max("sort_order"))["largest"] + + return (largest or self.DEFAULT_SORT_ORDER) + 10000 + def save(self, *args, **kwargs): # Strip the html tags using html parser self.description_stripped = ( @@ -70,7 +87,27 @@ def save(self, *args, **kwargs): if (self.description_html == "" or self.description_html is None) else strip_tags(self.description_html) ) - super(Page, self).save(*args, **kwargs) + + if not self._state.adding: + original = Page.objects.get(pk=self.pk) + if original.access != self.access: + with transaction.atomic(): + # Get the project pages for the page and update the sort order + project_pages = list(ProjectPage.objects.filter(page=self).select_related("project")) + + # Acquire advisory locks for all projects to prevent race conditions + for project_page in project_pages: + lock_key = convert_uuid_to_integer(project_page.project_id) + with connection.cursor() as cursor: + cursor.execute("SELECT pg_advisory_xact_lock(%s)", [lock_key]) + + for project_page in project_pages: + project_page.sort_order = self._get_sort_order(project_page.project) + # Bulk update all project pages in a single query + if project_pages: + ProjectPage.objects.bulk_update(project_pages, ["sort_order"]) + + super(Page, self).save(*args, **kwargs) class PageLog(BaseModel): @@ -129,9 +166,12 @@ def __str__(self): class ProjectPage(BaseModel): + DEFAULT_SORT_ORDER = 65535 + 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") + sort_order = models.FloatField(default=DEFAULT_SORT_ORDER) class Meta: unique_together = ["project", "page", "deleted_at"] @@ -150,6 +190,42 @@ class Meta: def __str__(self): return f"{self.project.name} {self.page.name}" + def _get_sort_order(self): + """Get the next sort order for the project page based on page access type.""" + if self.page.access == Page.PRIVATE_ACCESS: + # For private pages, get max sort_order among pages owned by same user in same project + largest = ProjectPage.objects.filter( + page__access=Page.PRIVATE_ACCESS, + page__owned_by=self.page.owned_by, + project=self.project, + ).aggregate(largest=models.Max("sort_order"))["largest"] + else: + # For public pages, get max sort_order among all public pages in same project + largest = ProjectPage.objects.filter( + page__access=Page.PUBLIC_ACCESS, + project=self.project, + ).aggregate(largest=models.Max("sort_order"))["largest"] + + return (largest or self.DEFAULT_SORT_ORDER) + 10000 + + def save(self, *args, **kwargs): + # Set sort_order for new project pages + if self._state.adding: + with transaction.atomic(): + # Create a lock for this specific project using a transaction-level advisory lock + # This ensures only one transaction per project can execute this code at a time + # The lock is automatically released when the transaction ends + lock_key = convert_uuid_to_integer(self.project_id) + + with connection.cursor() as cursor: + # Get an exclusive transaction-level lock using the project ID as the lock key + cursor.execute("SELECT pg_advisory_xact_lock(%s)", [lock_key]) + + self.sort_order = self._get_sort_order() + super(ProjectPage, self).save(*args, **kwargs) + else: + super(ProjectPage, self).save(*args, **kwargs) + class PageVersion(BaseModel): workspace = models.ForeignKey("db.Workspace", on_delete=models.CASCADE, related_name="page_versions")