Compare commits

...

1 Commits

Author SHA1 Message Date
NarayanBavisetti def49be2d3 chore: added description sync for work item and pages 2025-12-02 14:15:01 +05:30
5 changed files with 261 additions and 18 deletions
@@ -0,0 +1,55 @@
# Django imports
from django.core.management.base import BaseCommand
from django.db import transaction
# Module imports
from plane.db.models import Description
from plane.db.models import Issue
class Command(BaseCommand):
help = "Create Description records for existing Issue"
def handle(self, *args, **kwargs):
batch_size = 3000
total_processed = 0
self.stdout.write(self.style.NOTICE("Starting Issue to Description migration..."))
while True:
issues = list(Issue.objects.filter(description_obj_id__isnull=True).order_by("created_at")[:batch_size])
if not issues:
break
with transaction.atomic():
descriptions = [
Description(
created_at=issue.created_at,
updated_at=issue.updated_at,
description_json=issue.description,
description_html=issue.description_html,
description_stripped=issue.description_stripped,
project_id=issue.project_id,
created_by_id=issue.created_by_id,
updated_by_id=issue.updated_by_id,
workspace_id=issue.workspace_id,
)
for issue in issues
]
created_descriptions = Description.objects.bulk_create(descriptions)
issues_to_update = []
for issue, description in zip(issues, created_descriptions):
issue.description_obj_id = description.id
issues_to_update.append(issue)
Issue.objects.bulk_update(issues_to_update, ["description_obj_id"])
total_processed += len(issues)
self.stdout.write(self.style.SUCCESS(f"Processed {total_processed} issues..."))
self.stdout.write(
self.style.SUCCESS(f"Successfully copied {total_processed} Issue records to Description table")
)
@@ -0,0 +1,55 @@
# Django imports
from django.core.management.base import BaseCommand
from django.db import transaction
# Module imports
from plane.db.models import Description
from plane.db.models import Page
class Command(BaseCommand):
help = "Create Description records for existing Page"
def handle(self, *args, **kwargs):
batch_size = 2000
total_processed = 0
self.stdout.write(self.style.NOTICE("Starting Page to Description migration..."))
while True:
pages = list(Page.objects.filter(description_obj_id__isnull=True).order_by("created_at")[:batch_size])
if not pages:
break
with transaction.atomic():
descriptions = [
Description(
created_at=page.created_at,
updated_at=page.updated_at,
description_json=page.description,
description_html=page.description_html,
description_stripped=page.description_stripped,
project_id=None, # Pages are workspace-level, not project-level
created_by_id=page.created_by_id,
updated_by_id=page.updated_by_id,
workspace_id=page.workspace_id,
)
for page in pages
]
created_descriptions = Description.objects.bulk_create(descriptions)
pages_to_update = []
for page, description in zip(pages, created_descriptions):
page.description_obj_id = description.id
pages_to_update.append(page)
Page.objects.bulk_update(pages_to_update, ["description_obj_id"])
total_processed += len(pages)
self.stdout.write(self.style.SUCCESS(f"Processed {total_processed} pages..."))
self.stdout.write(
self.style.SUCCESS(f"Successfully copied {total_processed} Page records to Description table")
)
@@ -0,0 +1,24 @@
# Generated by Django 4.2.25 on 2025-12-01 10:51
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('db', '0112_auto_20251124_0603'),
]
operations = [
migrations.AddField(
model_name='issue',
name='description_obj',
field=models.OneToOneField(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='issue_description', to='db.description'),
),
migrations.AddField(
model_name='page',
name='description_obj',
field=models.OneToOneField(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='page_description', to='db.description'),
),
]
+72 -15
View File
@@ -105,7 +105,7 @@ class IssueManager(SoftDeletionManager):
)
class Issue(ProjectBaseModel):
class Issue(ChangeTrackerMixin, ProjectBaseModel):
PRIORITY_CHOICES = (
("urgent", "Urgent"),
("high", "High"),
@@ -140,6 +140,9 @@ class Issue(ProjectBaseModel):
description_html = models.TextField(blank=True, default="<p></p>")
description_stripped = models.TextField(blank=True, null=True)
description_binary = models.BinaryField(null=True)
description_obj = models.OneToOneField(
"db.Description", on_delete=models.CASCADE, related_name="issue_description", null=True
)
priority = models.CharField(
max_length=30,
choices=PRIORITY_CHOICES,
@@ -173,6 +176,8 @@ class Issue(ProjectBaseModel):
issue_objects = IssueManager()
TRACKED_FIELDS = ["description_stripped", "description", "description_html"]
class Meta:
verbose_name = "Issue"
verbose_name_plural = "Issues"
@@ -180,6 +185,12 @@ class Issue(ProjectBaseModel):
ordering = ("-created_at",)
def save(self, *args, **kwargs):
"""
Custom save method for Issue that manages the associated Description model.
This method handles creation and updates of both the issue and its description in a
single atomic transaction to ensure data consistency.
"""
if self.state is None:
try:
from plane.db.models import State
@@ -205,7 +216,16 @@ class Issue(ProjectBaseModel):
except ImportError:
pass
if self._state.adding:
# Strip the html tags using html parser
self.description_stripped = (
None
if (self.description_html == "" or self.description_html is None)
else strip_tags(self.description_html)
)
is_creating = self._state.adding
if is_creating:
with transaction.atomic():
# Create a lock for this specific project using an advisory lock
# This ensures only one transaction per project can execute this code at a time
@@ -221,12 +241,7 @@ class Issue(ProjectBaseModel):
largest=models.Max("sequence")
)["largest"]
self.sequence_id = last_sequence + 1 if last_sequence else 1
# Strip the html tags using html parser
self.description_stripped = (
None
if (self.description_html == "" or self.description_html is None)
else strip_tags(self.description_html)
)
largest_sort_order = Issue.objects.filter(project=self.project, state=self.state).aggregate(
largest=models.Max("sort_order")
)["largest"]
@@ -236,18 +251,60 @@ class Issue(ProjectBaseModel):
super(Issue, self).save(*args, **kwargs)
IssueSequence.objects.create(issue=self, sequence=self.sequence_id, project=self.project)
# Create new description for new issue
description_defaults = {
"workspace_id": self.workspace_id,
"project_id": self.project_id,
"created_by_id": self.created_by_id,
"updated_by_id": self.updated_by_id,
"description_stripped": self.description_stripped,
"description_json": self.description,
"description_html": self.description_html,
}
description = Description.objects.create(**description_defaults)
self.description_obj_id = description.id
super(Issue, self).save(update_fields=["description_obj_id"])
finally:
# Release the lock
with connection.cursor() as cursor:
cursor.execute("SELECT pg_advisory_unlock(%s)", [lock_key])
else:
# Strip the html tags using html parser
self.description_stripped = (
None
if (self.description_html == "" or self.description_html is None)
else strip_tags(self.description_html)
)
super(Issue, self).save(*args, **kwargs)
with transaction.atomic():
super(Issue, self).save(*args, **kwargs)
if not self.description_obj_id:
# Create description if it doesn't exist (for existing issues)
description_defaults = {
"workspace_id": self.workspace_id,
"project_id": self.project_id,
"created_by_id": self.created_by_id,
"updated_by_id": self.updated_by_id,
"description_stripped": self.description_stripped,
"description_json": self.description,
"description_html": self.description_html,
}
description = Description.objects.create(**description_defaults)
self.description_obj_id = description.id
super(Issue, self).save(update_fields=["description_obj_id"])
else:
# Update description only if fields changed
field_mapping = {
"description_html": "description_html",
"description_stripped": "description_stripped",
"description": "description_json",
}
changed_fields = {
desc_field: getattr(self, issue_field)
for issue_field, desc_field in field_mapping.items()
if self.has_changed(issue_field)
}
if changed_fields and self.description_obj_id:
Description.objects.filter(pk=self.description_obj_id).update(
**changed_fields, updated_by_id=self.updated_by_id, updated_at=self.updated_at
)
def __str__(self):
"""Return name of the issue"""
+55 -3
View File
@@ -4,19 +4,21 @@ from django.conf import settings
from django.utils import timezone
# Django imports
from django.db import models
from django.db import models, transaction
# Module imports
from plane.utils.html_processor import strip_tags
from plane.db.mixins import ChangeTrackerMixin
from .base import BaseModel
from .description import Description
def get_view_props():
return {"full_width": False}
class Page(BaseModel):
class Page(ChangeTrackerMixin, BaseModel):
PRIVATE_ACCESS = 1
PUBLIC_ACCESS = 0
DEFAULT_SORT_ORDER = 65535
@@ -29,6 +31,9 @@ class Page(BaseModel):
description_binary = models.BinaryField(null=True)
description_html = models.TextField(blank=True, default="<p></p>")
description_stripped = models.TextField(blank=True, null=True)
description_obj = models.OneToOneField(
"db.Description", on_delete=models.CASCADE, related_name="page_description", null=True
)
owned_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="pages")
access = models.PositiveSmallIntegerField(choices=((0, "Public"), (1, "Private")), default=0)
color = models.CharField(max_length=255, blank=True)
@@ -53,6 +58,8 @@ class Page(BaseModel):
external_id = models.CharField(max_length=255, null=True, blank=True)
external_source = models.CharField(max_length=255, null=True, blank=True)
TRACKED_FIELDS = ["description_stripped", "description", "description_html"]
class Meta:
verbose_name = "Page"
verbose_name_plural = "Pages"
@@ -64,13 +71,58 @@ class Page(BaseModel):
return f"{self.owned_by.email} <{self.name}>"
def save(self, *args, **kwargs):
"""
Custom save method for Page that manages the associated Description model.
This method handles creation and updates of both the page and its description in a
single atomic transaction to ensure data consistency.
"""
# Strip the html tags using html parser
self.description_stripped = (
None
if (self.description_html == "" or self.description_html is None)
else strip_tags(self.description_html)
)
super(Page, self).save(*args, **kwargs)
is_creating = self._state.adding
# Prepare description defaults
description_defaults = {
"workspace_id": self.workspace_id,
"project_id": None,
"created_by_id": self.created_by_id,
"updated_by_id": self.updated_by_id,
"description_stripped": self.description_stripped,
"description_json": self.description,
"description_html": self.description_html,
}
with transaction.atomic():
super(Page, self).save(*args, **kwargs)
if is_creating or not self.description_obj_id:
# Create new description for new page
description = Description.objects.create(**description_defaults)
self.description_obj_id = description.id
super(Page, self).save(update_fields=["description_obj_id"])
else:
# Update description only if fields changed
field_mapping = {
"description_html": "description_html",
"description_stripped": "description_stripped",
"description": "description_json",
}
changed_fields = {
desc_field: getattr(self, page_field)
for page_field, desc_field in field_mapping.items()
if self.has_changed(page_field)
}
if changed_fields and self.description_obj_id:
Description.objects.filter(pk=self.description_obj_id).update(
**changed_fields, updated_by_id=self.updated_by_id, updated_at=self.updated_at
)
class PageLog(BaseModel):