[WEB-5312] migration: work item comments (#8072)
* migration: description field on issue_comment sync: issue_comment and description * fix: update if description already exists for the IssueComment * feat: management command to copy IssueComment to Description * fix: description creation order * chore: add while loop * fix: move write outside loop * chore: change sync logic chore: test cases * chore: removed deleted_at filter and added order_by in management command * fix: description_id * migration: added parent_id for IssueComment * fix: update update_by_id * fix: use ChangeTrackerMixin in save * chore: add docstring fix: remove self.pk check chore: wrap the description creation logic in transaction.atomic() * fix: tests * fix: use super save method * fix: mulitple if conditions * fix: update updated_at
This commit is contained in:
parent
6a26ce3a2b
commit
5cfb9538df
4 changed files with 416 additions and 2 deletions
|
|
@ -0,0 +1,49 @@
|
|||
# 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 IssueComment
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Create Description records for existing IssueComment"
|
||||
|
||||
def handle(self, *args, **kwargs):
|
||||
batch_size = 500
|
||||
|
||||
while True:
|
||||
comments = list(
|
||||
IssueComment.objects.filter(description_id__isnull=True).order_by("created_at")[:batch_size]
|
||||
)
|
||||
|
||||
if not comments:
|
||||
break
|
||||
|
||||
with transaction.atomic():
|
||||
descriptions = [
|
||||
Description(
|
||||
created_at=comment.created_at,
|
||||
updated_at=comment.updated_at,
|
||||
description_json=comment.comment_json,
|
||||
description_html=comment.comment_html,
|
||||
description_stripped=comment.comment_stripped,
|
||||
project_id=comment.project_id,
|
||||
created_by_id=comment.created_by_id,
|
||||
updated_by_id=comment.updated_by_id,
|
||||
workspace_id=comment.workspace_id,
|
||||
)
|
||||
for comment in comments
|
||||
]
|
||||
|
||||
created_descriptions = Description.objects.bulk_create(descriptions)
|
||||
|
||||
comments_to_update = []
|
||||
for comment, description in zip(comments, created_descriptions):
|
||||
comment.description_id = description.id
|
||||
comments_to_update.append(comment)
|
||||
|
||||
IssueComment.objects.bulk_update(comments_to_update, ["description_id"])
|
||||
|
||||
self.stdout.write(self.style.SUCCESS("Successfully Copied IssueComment to Description"))
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
# Generated by Django 4.2.22 on 2025-11-06 08:28
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('db', '0108_alter_issueactivity_issue_comment'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='issuecomment',
|
||||
name='description',
|
||||
field=models.OneToOneField(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='issue_comment_description', to='db.description'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='issuecomment',
|
||||
name='parent',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='parent_issue_comment', to='db.issuecomment'),
|
||||
),
|
||||
]
|
||||
|
|
@ -17,6 +17,8 @@ from plane.db.mixins import SoftDeletionManager
|
|||
from plane.utils.exception_logger import log_exception
|
||||
from .project import ProjectBaseModel
|
||||
from plane.utils.uuid import convert_uuid_to_integer
|
||||
from .description import Description
|
||||
from plane.db.mixins import ChangeTrackerMixin
|
||||
|
||||
|
||||
def get_default_properties():
|
||||
|
|
@ -442,10 +444,13 @@ class IssueActivity(ProjectBaseModel):
|
|||
return str(self.issue)
|
||||
|
||||
|
||||
class IssueComment(ProjectBaseModel):
|
||||
class IssueComment(ChangeTrackerMixin, ProjectBaseModel):
|
||||
comment_stripped = models.TextField(verbose_name="Comment", blank=True)
|
||||
comment_json = models.JSONField(blank=True, default=dict)
|
||||
comment_html = models.TextField(blank=True, default="<p></p>")
|
||||
description = models.OneToOneField(
|
||||
"db.Description", on_delete=models.CASCADE, related_name="issue_comment_description", null=True
|
||||
)
|
||||
attachments = ArrayField(models.URLField(), size=10, blank=True, default=list)
|
||||
issue = models.ForeignKey(Issue, on_delete=models.CASCADE, related_name="issue_comments")
|
||||
# System can also create comment
|
||||
|
|
@ -463,10 +468,60 @@ class IssueComment(ProjectBaseModel):
|
|||
external_source = models.CharField(max_length=255, null=True, blank=True)
|
||||
external_id = models.CharField(max_length=255, blank=True, null=True)
|
||||
edited_at = models.DateTimeField(null=True, blank=True)
|
||||
parent = models.ForeignKey(
|
||||
"self", on_delete=models.CASCADE, null=True, blank=True, related_name="parent_issue_comment"
|
||||
)
|
||||
|
||||
TRACKED_FIELDS = ["comment_stripped", "comment_json", "comment_html"]
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""
|
||||
Custom save method for IssueComment that manages the associated Description model.
|
||||
|
||||
This method handles creation and updates of both the comment and its description in a
|
||||
single atomic transaction to ensure data consistency.
|
||||
"""
|
||||
|
||||
self.comment_stripped = strip_tags(self.comment_html) if self.comment_html != "" else ""
|
||||
return super(IssueComment, self).save(*args, **kwargs)
|
||||
is_creating = self._state.adding
|
||||
|
||||
# Prepare description defaults
|
||||
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.comment_stripped,
|
||||
"description_json": self.comment_json,
|
||||
"description_html": self.comment_html,
|
||||
}
|
||||
|
||||
with transaction.atomic():
|
||||
super(IssueComment, self).save(*args, **kwargs)
|
||||
|
||||
if is_creating or not self.description_id:
|
||||
# Create new description for new comment
|
||||
description = Description.objects.create(**description_defaults)
|
||||
self.description_id = description.id
|
||||
super(IssueComment, self).save(update_fields=["description_id"])
|
||||
else:
|
||||
field_mapping = {
|
||||
"comment_html": "description_html",
|
||||
"comment_stripped": "description_stripped",
|
||||
"comment_json": "description_json",
|
||||
}
|
||||
|
||||
changed_fields = {
|
||||
desc_field: getattr(self, comment_field)
|
||||
for comment_field, desc_field in field_mapping.items()
|
||||
if self.has_changed(comment_field)
|
||||
}
|
||||
|
||||
# Update description only if comment fields changed
|
||||
if changed_fields and self.description_id:
|
||||
Description.objects.filter(pk=self.description_id).update(
|
||||
**changed_fields, updated_by_id=self.updated_by_id, updated_at=self.updated_at
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Issue Comment"
|
||||
|
|
|
|||
286
apps/api/plane/tests/unit/models/test_issue_comment_modal.py
Normal file
286
apps/api/plane/tests/unit/models/test_issue_comment_modal.py
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
import pytest
|
||||
|
||||
from plane.db.models import IssueComment, Description, Project, Issue, Workspace, State
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def workspace(create_user):
|
||||
"""Create a test workspace"""
|
||||
return Workspace.objects.create(
|
||||
name="Test Workspace",
|
||||
slug="test-workspace",
|
||||
owner=create_user,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def project(workspace, create_user):
|
||||
"""Create a test project"""
|
||||
return Project.objects.create(
|
||||
name="Test Project",
|
||||
identifier="TP",
|
||||
workspace=workspace,
|
||||
created_by=create_user,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def state(project):
|
||||
"""Create a test state"""
|
||||
return State.objects.create(
|
||||
name="Todo",
|
||||
project=project,
|
||||
group="backlog",
|
||||
default=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def issue(workspace, project, state, create_user):
|
||||
"""Create a test issue"""
|
||||
return Issue.objects.create(
|
||||
name="Test Issue",
|
||||
workspace=workspace,
|
||||
project=project,
|
||||
state=state,
|
||||
created_by=create_user,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestIssueCommentModel:
|
||||
"""Test the IssueComment model"""
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_issue_comment_creation_creates_description(self, workspace, project, issue, create_user):
|
||||
"""Test that creating a comment automatically creates a description"""
|
||||
# Arrange
|
||||
comment_html = "<p>This is a test comment</p>"
|
||||
comment_json = {"type": "doc", "content": [{"type": "paragraph", "text": "This is a test comment"}]}
|
||||
|
||||
# Act
|
||||
issue_comment = IssueComment.objects.create(
|
||||
workspace=workspace,
|
||||
project=project,
|
||||
issue=issue,
|
||||
comment_html=comment_html,
|
||||
comment_json=comment_json,
|
||||
created_by=create_user,
|
||||
updated_by=create_user,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert issue_comment.id is not None
|
||||
assert issue_comment.comment_stripped == "This is a test comment"
|
||||
assert issue_comment.description_id is not None
|
||||
|
||||
# Verify description was created
|
||||
description = Description.objects.get(pk=issue_comment.description_id)
|
||||
assert description is not None
|
||||
assert description.description_html == comment_html
|
||||
assert description.description_json == comment_json
|
||||
assert description.description_stripped == "This is a test comment"
|
||||
assert description.workspace_id == workspace.id
|
||||
assert description.project_id == project.id
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_issue_comment_update_updates_description(self, workspace, project, issue, create_user):
|
||||
"""Test that updating a comment updates its associated description"""
|
||||
# Arrange - Create initial comment
|
||||
initial_html = "<p>Initial comment</p>"
|
||||
initial_json = {"type": "doc", "content": [{"type": "paragraph", "text": "Initial comment"}]}
|
||||
|
||||
issue_comment = IssueComment.objects.create(
|
||||
workspace=workspace,
|
||||
project=project,
|
||||
issue=issue,
|
||||
comment_html=initial_html,
|
||||
comment_json=initial_json,
|
||||
created_by=create_user,
|
||||
updated_by=create_user,
|
||||
)
|
||||
|
||||
initial_description_id = issue_comment.description_id
|
||||
|
||||
# Act - Update the comment
|
||||
updated_html = "<p>Updated comment</p>"
|
||||
updated_json = {"type": "doc", "content": [{"type": "paragraph", "text": "Updated comment"}]}
|
||||
|
||||
issue_comment.comment_html = updated_html
|
||||
issue_comment.comment_json = updated_json
|
||||
issue_comment.save()
|
||||
|
||||
# Assert
|
||||
# Refresh from database
|
||||
issue_comment.refresh_from_db()
|
||||
updated_description = Description.objects.get(pk=initial_description_id)
|
||||
|
||||
# Verify comment was updated
|
||||
assert issue_comment.comment_stripped == "Updated comment"
|
||||
assert issue_comment.description_id == initial_description_id # Same description object
|
||||
|
||||
# Verify description was updated
|
||||
assert updated_description.description_html == updated_html
|
||||
assert updated_description.description_json == updated_json
|
||||
assert updated_description.description_stripped == "Updated comment"
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_issue_comment_update_only_changed_fields_in_description(self, workspace, project, issue, create_user):
|
||||
"""Test that only changed fields are updated in description"""
|
||||
# Arrange - Create initial comment
|
||||
initial_html = "<p>Initial comment</p>"
|
||||
initial_json = {"type": "doc", "content": [{"type": "paragraph", "text": "Initial comment"}]}
|
||||
|
||||
issue_comment = IssueComment.objects.create(
|
||||
workspace=workspace,
|
||||
project=project,
|
||||
issue=issue,
|
||||
comment_html=initial_html,
|
||||
comment_json=initial_json,
|
||||
created_by=create_user,
|
||||
updated_by=create_user,
|
||||
)
|
||||
|
||||
initial_description_id = issue_comment.description_id
|
||||
|
||||
# Act - Update only the HTML (not JSON)
|
||||
updated_html = "<p>Updated comment only HTML</p>"
|
||||
|
||||
issue_comment.comment_html = updated_html
|
||||
# comment_json remains the same
|
||||
issue_comment.save()
|
||||
|
||||
# Assert
|
||||
updated_description = Description.objects.get(pk=initial_description_id)
|
||||
|
||||
# Verify HTML was updated
|
||||
assert updated_description.description_html == updated_html
|
||||
assert updated_description.description_stripped == "Updated comment only HTML"
|
||||
|
||||
# Verify JSON remained the same
|
||||
assert updated_description.description_json == initial_json
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_issue_comment_no_update_when_content_unchanged(self, workspace, project, issue, create_user):
|
||||
"""Test that description is not updated when comment content doesn't change"""
|
||||
# Arrange - Create initial comment
|
||||
initial_html = "<p>Test comment</p>"
|
||||
initial_json = {"type": "doc", "content": [{"type": "paragraph", "text": "Test comment"}]}
|
||||
|
||||
issue_comment = IssueComment.objects.create(
|
||||
workspace=workspace,
|
||||
project=project,
|
||||
issue=issue,
|
||||
comment_html=initial_html,
|
||||
comment_json=initial_json,
|
||||
created_by=create_user,
|
||||
updated_by=create_user,
|
||||
)
|
||||
|
||||
initial_description_id = issue_comment.description_id
|
||||
|
||||
# Act - Save without changing content
|
||||
issue_comment.save()
|
||||
|
||||
# Assert
|
||||
updated_description = Description.objects.get(pk=initial_description_id)
|
||||
|
||||
# Verify description was not updated (updated_at should be the same)
|
||||
# Note: This test assumes updated_at is not changed when no fields change
|
||||
assert updated_description.description_html == initial_html
|
||||
assert updated_description.description_json == initial_json
|
||||
assert updated_description.description_stripped == "Test comment"
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_issue_comment_update_creates_description_if_missing(self, workspace, project, issue, create_user):
|
||||
"""Test that updating a comment creates description if it doesn't exist (legacy data)"""
|
||||
# Arrange - Create comment and manually remove description (simulating legacy data)
|
||||
initial_html = "<p>Legacy comment</p>"
|
||||
initial_json = {"type": "doc", "content": [{"type": "paragraph", "text": "Legacy comment"}]}
|
||||
|
||||
issue_comment = IssueComment.objects.create(
|
||||
workspace=workspace,
|
||||
project=project,
|
||||
issue=issue,
|
||||
comment_html=initial_html,
|
||||
comment_json=initial_json,
|
||||
created_by=create_user,
|
||||
updated_by=create_user,
|
||||
)
|
||||
|
||||
# Simulate legacy data by removing the description
|
||||
if issue_comment.description_id:
|
||||
Description.objects.filter(pk=issue_comment.description_id).delete()
|
||||
IssueComment.objects.filter(pk=issue_comment.pk).update(description_id=None)
|
||||
issue_comment.refresh_from_db()
|
||||
|
||||
assert issue_comment.description_id is None
|
||||
|
||||
# Act - Update the comment
|
||||
updated_html = "<p>Updated legacy comment</p>"
|
||||
updated_json = {"type": "doc", "content": [{"type": "paragraph", "text": "Updated legacy comment"}]}
|
||||
|
||||
issue_comment.comment_html = updated_html
|
||||
issue_comment.comment_json = updated_json
|
||||
issue_comment.save()
|
||||
|
||||
# Assert
|
||||
issue_comment.refresh_from_db()
|
||||
|
||||
# Verify description was created
|
||||
assert issue_comment.description_id is not None
|
||||
description = Description.objects.get(pk=issue_comment.description_id)
|
||||
assert description.description_html == updated_html
|
||||
assert description.description_json == updated_json
|
||||
assert description.description_stripped == "Updated legacy comment"
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_issue_comment_strips_html_tags(self, workspace, project, issue, create_user):
|
||||
"""Test that HTML tags are properly stripped from comment_html"""
|
||||
# Arrange
|
||||
comment_html = "<p>This is <strong>bold</strong> and <em>italic</em> text</p>"
|
||||
comment_json = {"type": "doc", "content": []}
|
||||
|
||||
# Act
|
||||
issue_comment = IssueComment.objects.create(
|
||||
workspace=workspace,
|
||||
project=project,
|
||||
issue=issue,
|
||||
comment_html=comment_html,
|
||||
comment_json=comment_json,
|
||||
created_by=create_user,
|
||||
updated_by=create_user,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert issue_comment.comment_stripped == "This is bold and italic text"
|
||||
|
||||
# Verify description has the same stripped content
|
||||
description = Description.objects.get(pk=issue_comment.description_id)
|
||||
assert description.description_stripped == "This is bold and italic text"
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_issue_comment_empty_html_creates_empty_stripped(self, workspace, project, issue, create_user):
|
||||
"""Test that empty HTML results in empty comment_stripped"""
|
||||
# Arrange
|
||||
comment_html = ""
|
||||
comment_json = {"type": "doc", "content": []}
|
||||
|
||||
# Act
|
||||
issue_comment = IssueComment.objects.create(
|
||||
workspace=workspace,
|
||||
project=project,
|
||||
issue=issue,
|
||||
comment_html=comment_html,
|
||||
comment_json=comment_json,
|
||||
created_by=create_user,
|
||||
updated_by=create_user,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert issue_comment.comment_stripped == ""
|
||||
|
||||
# Verify description was created with empty stripped content
|
||||
description = Description.objects.get(pk=issue_comment.description_id)
|
||||
|
||||
assert description.description_stripped is None
|
||||
Loading…
Add table
Add a link
Reference in a new issue