diff --git a/apps/api/plane/db/management/commands/copy_issue_comment_to_description.py b/apps/api/plane/db/management/commands/copy_issue_comment_to_description.py new file mode 100644 index 000000000..8813f34db --- /dev/null +++ b/apps/api/plane/db/management/commands/copy_issue_comment_to_description.py @@ -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")) diff --git a/apps/api/plane/db/migrations/0109_issuecomment_description_and_parent_id.py b/apps/api/plane/db/migrations/0109_issuecomment_description_and_parent_id.py new file mode 100644 index 000000000..4376c4ab8 --- /dev/null +++ b/apps/api/plane/db/migrations/0109_issuecomment_description_and_parent_id.py @@ -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'), + ), + ] diff --git a/apps/api/plane/db/models/issue.py b/apps/api/plane/db/models/issue.py index 8f9de3409..83376c00d 100644 --- a/apps/api/plane/db/models/issue.py +++ b/apps/api/plane/db/models/issue.py @@ -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="
") + 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" diff --git a/apps/api/plane/tests/unit/models/test_issue_comment_modal.py b/apps/api/plane/tests/unit/models/test_issue_comment_modal.py new file mode 100644 index 000000000..98a0b05b2 --- /dev/null +++ b/apps/api/plane/tests/unit/models/test_issue_comment_modal.py @@ -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 = "This is a test comment
" + 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 = "Initial comment
" + 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 = "Updated comment
" + 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 = "Initial comment
" + 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 = "Updated comment only HTML
" + + 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 = "Test comment
" + 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 = "Legacy comment
" + 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 = "Updated legacy comment
" + 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 = "This is bold and italic text
" + 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