Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44ab9ce24f | ||
|
|
14656d2879 | ||
|
|
003376fc81 | ||
|
|
956922fc66 | ||
|
|
246a3a1d33 | ||
|
|
eb036f1f47 | ||
|
|
e8ea1bb1fc | ||
|
|
86115deb6e | ||
|
|
a86753e9e4 | ||
|
|
6095f86fa5 | ||
|
|
c65d2ff277 | ||
|
|
0daf5244ef | ||
|
|
24f8010406 | ||
|
|
f18e1203ee | ||
|
|
ea1e95c711 | ||
|
|
8a72d2ab4d | ||
|
|
69120b8b04 | ||
|
|
ac85e59e6d | ||
|
|
098794395f | ||
|
|
df965a7b4f |
@@ -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,35 @@
|
||||
# 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'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='workspaceuserproperties',
|
||||
name='is_horizontal_navigation_bar',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='workspaceuserproperties',
|
||||
name='num_of_projects',
|
||||
field=models.IntegerField(default=10),
|
||||
),
|
||||
|
||||
]
|
||||
@@ -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"
|
||||
|
||||
@@ -315,6 +315,8 @@ class WorkspaceUserProperties(BaseModel):
|
||||
display_filters = models.JSONField(default=get_default_display_filters)
|
||||
display_properties = models.JSONField(default=get_default_display_properties)
|
||||
rich_filters = models.JSONField(default=dict)
|
||||
num_of_projects = models.IntegerField(default=10)
|
||||
is_horizontal_navigation_bar = models.BooleanField(default=False)
|
||||
|
||||
class Meta:
|
||||
unique_together = ["workspace", "user", "deleted_at"]
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user