Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| def49be2d3 |
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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"""
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user