diff --git a/news/286.feature b/news/286.feature new file mode 100644 index 00000000..ca0f2de3 --- /dev/null +++ b/news/286.feature @@ -0,0 +1 @@ +Implement Soft Deletion [rohnsha0] \ No newline at end of file diff --git a/src/plone/app/discussion/browser/comments.pt b/src/plone/app/discussion/browser/comments.pt index f3ed08ae..cf52d3f4 100644 --- a/src/plone/app/discussion/browser/comments.pt +++ b/src/plone/app/discussion/browser/comments.pt @@ -50,6 +50,7 @@ canEdit python:view.can_edit(reply); canDelete python:view.can_delete(reply); colorclass python:lambda x: 'state-private' if x=='rejected' else ('state-internal' if x=='spam' else 'state-'+x); + is_deleted reply/is_deleted; " tal:condition="python:canReview or review_state == 'published'" tal:attributes=" @@ -58,7 +59,9 @@ " > -
+
- + +
+ This comment was deleted. +
+ + +
+
+
+ +
+
+
+ + + -
+
+ + + >> registry = queryUtility(IRegistry) >>> settings = registry.forInterface(IDiscussionSettings) >>> settings.globally_enabled = True + >>> settings.hard_delete_comments = False Enable comment review workflow diff --git a/src/plone/app/discussion/tests/functional_test_comments.rst b/src/plone/app/discussion/tests/functional_test_comments.rst index b458ae97..74a52bf2 100644 --- a/src/plone/app/discussion/tests/functional_test_comments.rst +++ b/src/plone/app/discussion/tests/functional_test_comments.rst @@ -52,6 +52,7 @@ Enable commenting. >>> registry = queryUtility(IRegistry) >>> settings = registry.forInterface(IDiscussionSettings) >>> settings.globally_enabled = True + >>> settings.hard_delete_comments = True >>> import transaction >>> transaction.commit() diff --git a/src/plone/app/discussion/tests/test_comment.py b/src/plone/app/discussion/tests/test_comment.py index 742b4563..11035348 100644 --- a/src/plone/app/discussion/tests/test_comment.py +++ b/src/plone/app/discussion/tests/test_comment.py @@ -590,3 +590,47 @@ def test_traversal(self): "http://nohost/plone/doc1/++conversation++default/" + str(new_re_re_re_id), re_re_re_comment.absolute_url(), ) + + def test_getText_deleted_comment(self): + """Test that getText returns empty string for deleted comments.""" + conversation = IConversation(self.portal.doc1) + comment1 = createObject("plone.Comment") + comment1.text = "This is secret content that should not be searchable" + comment_id = conversation.addComment(comment1) + comment = self.portal.doc1.restrictedTraverse( + f"++conversation++default/{comment_id}", + ) + + # Normal comment should return text + self.assertIn("secret content", comment.getText()) + + # Mark comment as deleted + comment.is_deleted = True + + # Deleted comment should return empty string + self.assertEqual("", comment.getText()) + + # Test with different mime types + self.assertEqual("", comment.getText(targetMimetype="text/plain")) + self.assertEqual("", comment.getText(targetMimetype="text/html")) + + def test_Title_deleted_comment(self): + """Test that Title returns empty string for deleted comments.""" + conversation = IConversation(self.portal.doc1) + comment1 = createObject("plone.Comment") + comment1.author_name = "Secret User" + comment_id = conversation.addComment(comment1) + comment = self.portal.doc1.restrictedTraverse( + f"++conversation++default/{comment_id}", + ) + + # Normal comment should return title with author + original_title = comment.Title() + self.assertIn("Secret User", original_title) + self.assertIn("Document 1", original_title) + + # Mark comment as deleted + comment.is_deleted = True + + # Deleted comment should return empty string + self.assertEqual("", comment.Title()) diff --git a/src/plone/app/discussion/tests/test_comments_viewlet.py b/src/plone/app/discussion/tests/test_comments_viewlet.py index e9806b57..7c00ef5c 100644 --- a/src/plone/app/discussion/tests/test_comments_viewlet.py +++ b/src/plone/app/discussion/tests/test_comments_viewlet.py @@ -92,6 +92,8 @@ def setUp(self): registry = queryUtility(IRegistry) settings = registry.forInterface(IDiscussionSettings) settings.globally_enabled = True + # Enable hard deletion for these tests that expect comments to be completely removed + settings.hard_delete_comments = True def test_add_comment(self): """Post a comment as logged-in user.""" @@ -492,8 +494,8 @@ def setUp(self): self.membershipTool = getToolByName(self.folder, "portal_membership") self.memberdata = self.portal.portal_memberdata - context = getattr(self.portal, "doc1") - self.viewlet = CommentsViewlet(context, self.request, None, None) + self.context = getattr(self.portal, "doc1") + self.viewlet = CommentsViewlet(self.context, self.request, None, None) # Allow discussion registry = queryUtility(IRegistry) @@ -535,6 +537,21 @@ def test_can_manage(self): login(self.portal, "reviewer") self.assertTrue(self.viewlet.can_manage()) + def test_can_restore(self): + # Portal owner can restore comments + comment = createObject("plone.Comment") + comment.author_username = "jim" + conversation = IConversation(self.context) + conversation.addComment(comment) + + # Add comment to context so we can check permissions + comment = comment.__of__(conversation) + + self.assertTrue(self.viewlet.can_restore(comment)) + logout() + # Anonymous users cannot restore comments + self.assertFalse(self.viewlet.can_restore(comment)) + def test_is_discussion_allowed(self): # By default, discussion is disabled self.assertFalse(self.viewlet.is_discussion_allowed()) diff --git a/src/plone/app/discussion/tests/test_controlpanel.py b/src/plone/app/discussion/tests/test_controlpanel.py index e2271dbe..e5fc72a9 100644 --- a/src/plone/app/discussion/tests/test_controlpanel.py +++ b/src/plone/app/discussion/tests/test_controlpanel.py @@ -140,6 +140,17 @@ def test_moderator_notification_enabled(self): False, ) + def test_hard_delete_comments(self): + # Check hard_delete_comments record + self.assertTrue("hard_delete_comments" in IDiscussionSettings) + self.assertEqual( + self.registry[ + "plone.app.discussion.interfaces." + + "IDiscussionSettings.hard_delete_comments" + ], + True, + ) + # def test_user_notification_enabled(self): # # Check show_commenter_image record # show_commenter_image = self.registry.records['plone.app.discussion.' + diff --git a/src/plone/app/discussion/tests/test_events.py b/src/plone/app/discussion/tests/test_events.py index 24851f35..dee8e639 100644 --- a/src/plone/app/discussion/tests/test_events.py +++ b/src/plone/app/discussion/tests/test_events.py @@ -23,6 +23,7 @@ class EventsRegistry: commentAdded = False commentModified = False commentRemoved = False + commentRestored = False replyAdded = False replyModified = False replyRemoved = False @@ -45,6 +46,10 @@ def comment_removed(doc, evt): EventsRegistry.commentRemoved = True +def comment_restored(doc, evt): + EventsRegistry.commentRestored = True + + def reply_added(doc, evt): EventsRegistry.replyAdded = True diff --git a/src/plone/app/discussion/tests/test_indexers.py b/src/plone/app/discussion/tests/test_indexers.py index 723f5804..14123681 100644 --- a/src/plone/app/discussion/tests/test_indexers.py +++ b/src/plone/app/discussion/tests/test_indexers.py @@ -224,3 +224,32 @@ def test_in_response_to(self): # make sure in_response_to returns the title or id of the content # object the comment was added to self.assertEqual(catalog.in_response_to(self.comment)(), "Document 1") + + def test_searchable_text_deleted_comment(self): + """Test that searchable_text returns empty string for deleted comments.""" + # Normal comment should return text + self.assertEqual( + catalog.searchable_text(self.comment)(), + ("Lorem ipsum dolor sit amet."), + ) + + # Mark comment as deleted + self.comment.is_deleted = True + + # Deleted comment should return empty string + self.assertEqual( + catalog.searchable_text(self.comment)(), + "", + ) + + def test_title_deleted_comment(self): + """Test that title indexer returns empty string for deleted comments.""" + # Normal comment should return title with author + original_title = catalog.title(self.comment)() + self.assertIn("Jim on Document 1", original_title) + + # Mark comment as deleted + self.comment.is_deleted = True + + # Deleted comment should return empty string + self.assertEqual(catalog.title(self.comment)(), "") diff --git a/src/plone/app/discussion/tests/test_moderation_multiple_state_view.py b/src/plone/app/discussion/tests/test_moderation_multiple_state_view.py index ea5005e2..4b9b178e 100644 --- a/src/plone/app/discussion/tests/test_moderation_multiple_state_view.py +++ b/src/plone/app/discussion/tests/test_moderation_multiple_state_view.py @@ -1,10 +1,13 @@ from ..browser.moderation import BulkActionsView from ..interfaces import IConversation +from ..interfaces import IDiscussionSettings from ..testing import PLONE_APP_DISCUSSION_INTEGRATION_TESTING from plone.app.testing import setRoles from plone.app.testing import TEST_USER_ID +from plone.registry.interfaces import IRegistry from Products.CMFCore.utils import getToolByName from zope.component import createObject +from zope.component import queryUtility import unittest @@ -17,6 +20,11 @@ def setUp(self): self.portal = self.layer["portal"] self.request = self.layer["request"] setRoles(self.portal, TEST_USER_ID, ["Manager"]) + + # Enable hard deletion for these tests that expect comments to be completely removed + registry = queryUtility(IRegistry) + settings = registry.forInterface(IDiscussionSettings) + settings.hard_delete_comments = True self.wf = getToolByName(self.portal, "portal_workflow", None) self.context = self.portal self.portal.portal_workflow.setChainForPortalTypes( diff --git a/src/plone/app/discussion/tests/test_moderation_view.py b/src/plone/app/discussion/tests/test_moderation_view.py index 949185d2..2f0bea46 100644 --- a/src/plone/app/discussion/tests/test_moderation_view.py +++ b/src/plone/app/discussion/tests/test_moderation_view.py @@ -105,6 +105,11 @@ def setUp(self): self.portal = self.layer["portal"] self.request = self.layer["request"] setRoles(self.portal, TEST_USER_ID, ["Manager"]) + + # Enable hard deletion for these tests that expect comments to be completely removed + registry = queryUtility(IRegistry) + settings = registry.forInterface(IDiscussionSettings) + settings.hard_delete_comments = True self.wf = getToolByName(self.portal, "portal_workflow", None) self.context = self.portal self.portal.portal_workflow.setChainForPortalTypes( @@ -232,3 +237,291 @@ def test_valid_next_url(self): view = Klass(self.comment, self.request) view.__parent__ = self.comment self.assertNotEqual("http://attacker.com", view()) + + +class SoftDeletionTest(unittest.TestCase): + """Test soft deletion functionality for comments.""" + + layer = PLONE_APP_DISCUSSION_INTEGRATION_TESTING + + def setUp(self): + self.app = self.layer["app"] + self.portal = self.layer["portal"] + self.request = self.layer["request"] + setRoles(self.portal, TEST_USER_ID, ["Manager"]) + + # Enable global discussion + registry = queryUtility(IRegistry) + settings = registry.forInterface(IDiscussionSettings) + settings.globally_enabled = True + # Ensure soft deletion is enabled (hard_delete_comments = False) + settings.hard_delete_comments = False + + # Set up workflow + self.portal.portal_workflow.setChainForPortalTypes( + ("Discussion Item",), "comment_review_workflow" + ) + + # Create test document + self.portal.invokeFactory( + id="test_doc", + title="Test Document", + type_name="Document", + ) + self.doc = self.portal.test_doc + self.doc.allow_discussion = True + + # Create conversation with comments + self.conversation = IConversation(self.doc) + + # Create test comments + self.comment1 = createObject("plone.Comment") + self.comment1.text = "First comment" + self.comment1.author_name = "John Doe" + self.comment1_id = self.conversation.addComment(self.comment1) + + self.comment2 = createObject("plone.Comment") + self.comment2.text = "Second comment" + self.comment2.author_name = "Jane Smith" + self.comment2_id = self.conversation.addComment(self.comment2) + + # Refresh objects from conversation + self.comment1 = self.conversation[self.comment1_id] + self.comment2 = self.conversation[self.comment2_id] + + def test_comment_has_is_deleted_attribute(self): + """Test that new comments have is_deleted attribute set to False.""" + comment = createObject("plone.Comment") + self.assertFalse(getattr(comment, "is_deleted", None)) + + # Test existing comments + self.assertFalse(getattr(self.comment1, "is_deleted", False)) + self.assertFalse(getattr(self.comment2, "is_deleted", False)) + + def test_soft_delete_single_comment(self): + """Test soft deletion of a single comment using DeleteComment view.""" + # Verify comment exists and is not deleted + self.assertEqual(len(list(self.conversation.getComments())), 2) + self.assertFalse(getattr(self.comment1, "is_deleted", False)) + + # Delete comment using DeleteComment view + delete_view = DeleteComment(self.comment1, self.request) + delete_view() + + # Verify comment is marked as deleted but still exists in conversation + self.assertEqual(len(self.conversation.objectIds()), 2) # Still 2 objects + self.assertTrue(getattr(self.comment1, "is_deleted", False)) + self.assertFalse(getattr(self.comment2, "is_deleted", False)) + + # Verify comment is still accessible + self.assertIn(self.comment1_id, self.conversation.objectIds()) + self.assertEqual(self.conversation[self.comment1_id], self.comment1) + + def test_restore_soft_deleted_comment(self): + """Test restoring a soft deleted comment using RestoreComment view.""" + # First, soft delete the comment + delete_view = DeleteComment(self.comment1, self.request) + delete_view() + + # Verify comment is marked as deleted + self.assertTrue(getattr(self.comment1, "is_deleted", False)) + + # Import RestoreComment view + from plone.app.discussion.browser.moderation import RestoreComment + + # Restore comment using RestoreComment view + restore_view = RestoreComment(self.comment1, self.request) + restore_view() + + # Verify comment is no longer marked as deleted + self.assertFalse(getattr(self.comment1, "is_deleted", False)) + + # Verify comment is still accessible + self.assertIn(self.comment1_id, self.conversation.objectIds()) + self.assertEqual(self.conversation[self.comment1_id], self.comment1) + + def test_restore_comment_permission_required(self): + """Test that restore requires the same permission as delete.""" + # First, soft delete the comment as admin + delete_view = DeleteComment(self.comment1, self.request) + delete_view() + + # Verify comment is marked as deleted + self.assertTrue(getattr(self.comment1, "is_deleted", False)) + + # Try to restore as member without permission + from plone.app.discussion.browser.moderation import RestoreComment + + setRoles(self.portal, TEST_USER_ID, ["Member"]) + + restore_view = RestoreComment(self.comment1, self.request) + # This should not restore the comment due to lack of permission + restore_view() + + # Comment should still be marked as deleted + self.assertTrue(getattr(self.comment1, "is_deleted", False)) + + # Reset to Manager + setRoles(self.portal, TEST_USER_ID, ["Manager"]) + + def test_bulk_restore_action(self): + """Test bulk restore action for multiple comments.""" + # First, soft delete multiple comments + from plone.app.discussion.browser.moderation import DeleteComment + + delete_view1 = DeleteComment(self.comment1, self.request) + delete_view1() + + delete_view2 = DeleteComment(self.comment2, self.request) + delete_view2() + + # Verify both comments are marked as deleted + self.assertTrue(getattr(self.comment1, "is_deleted", False)) + self.assertTrue(getattr(self.comment2, "is_deleted", False)) + + # Test bulk restore action + from plone.app.discussion.browser.moderation import BulkActionsView + + bulk_view = BulkActionsView(self.portal, self.request) + paths = [ + f"/plone/test_doc/++conversation++default/{self.comment1_id}", + f"/plone/test_doc/++conversation++default/{self.comment2_id}", + ] + bulk_view.paths = paths + bulk_view.restore() + + # Verify both comments are no longer marked as deleted + self.assertFalse(getattr(self.comment1, "is_deleted", False)) + self.assertFalse(getattr(self.comment2, "is_deleted", False)) + + def test_bulk_soft_delete_comments(self): + """Test soft deletion of multiple comments using BulkActionsView.""" + # Verify initial state + self.assertEqual(len(list(self.conversation.getComments())), 2) + self.assertFalse(getattr(self.comment1, "is_deleted", False)) + self.assertFalse(getattr(self.comment2, "is_deleted", False)) + + # Delete both comments with bulk action + self.request.set("form.select.BulkAction", "delete") + self.request.set( + "paths", + [ + "/".join(self.comment1.getPhysicalPath()), + "/".join(self.comment2.getPhysicalPath()), + ], + ) + view = BulkActionsView(self.app, self.request) + view() + + # Verify both comments are marked as deleted but still exist + self.assertEqual(len(self.conversation.objectIds()), 2) # Still 2 objects + self.assertTrue(getattr(self.comment1, "is_deleted", False)) + self.assertTrue(getattr(self.comment2, "is_deleted", False)) + + def test_conversation_statistics_exclude_deleted_comments(self): + """Test that conversation statistics exclude deleted comments.""" + # Mark comment as deleted + self.comment1.is_deleted = True + + # Test that is_deleted attribute is set + self.assertTrue(getattr(self.comment1, "is_deleted", False)) + + # Test that the conversation's total_comments method exists and handles deleted comments + # (The method should return 0 if all visible comments are deleted) + count = self.conversation.total_comments() + self.assertIsInstance(count, int) + self.assertGreaterEqual(count, 0) + + def test_last_comment_date_excludes_deleted(self): + """Test that last_comment_date excludes deleted comments.""" + # Delete one comment + self.comment2.is_deleted = True + + # Test that last_comment_date property exists and handles deleted comments properly + last_date = self.conversation.last_comment_date + # The method should return None or a valid date, not raise an error + self.assertTrue(last_date is None or hasattr(last_date, "year")) + + # Delete both comments + self.comment1.is_deleted = True + + # Should return None when all comments are deleted + self.assertIsNone(self.conversation.last_comment_date) + + def test_getComments_includes_deleted_comments(self): + """Test that getComments() still returns deleted comments.""" + # Delete one comment + self.comment1.is_deleted = True + + # getComments should still return both comments + comments = list(self.conversation.getComments()) + self.assertEqual(len(comments), 2) + self.assertIn(self.comment1, comments) + self.assertIn(self.comment2, comments) + + def test_comment_form_omits_is_deleted_field(self): + """Test that comment forms don't expose the is_deleted field.""" + from ..browser.comments import CommentForm + + # Check that 'is_deleted' is in the statically defined omitted fields + form = CommentForm(None, None) + field_names = list(form.fields.keys()) + self.assertNotIn("is_deleted", field_names) + + def test_deleted_comment_display_behavior(self): + """Test the display behavior of deleted comments in templates.""" + # This test would ideally render the template, but we'll test the logic + # by checking the is_deleted attribute that the template uses + + # Mark comment as deleted + self.comment1.is_deleted = True + + # Verify the attribute is set correctly for template logic + self.assertTrue(getattr(self.comment1, "is_deleted", False)) + self.assertFalse(getattr(self.comment2, "is_deleted", False)) + + # Test that original text and author are still accessible + # (even though template won't display them) + self.assertEqual(self.comment1.text, "First comment") + self.assertEqual(self.comment1.author_name, "John Doe") + + def test_mixed_deleted_and_active_comments_statistics(self): + """Test statistics with a mix of deleted and active comments.""" + # Add a third comment + comment3 = createObject("plone.Comment") + comment3.text = "Third comment" + comment3.author_name = "Bob Wilson" + self.conversation.addComment(comment3) + + # Delete the middle comment + self.comment2.is_deleted = True + + # Test that the conversation methods work with mixed deleted/active comments + count = self.conversation.total_comments() + commentators = list(self.conversation.public_commentators) + + self.assertIsInstance(count, int) + self.assertIsInstance(commentators, list) + self.assertGreaterEqual(count, 0) + + def test_delete_comment_preserves_threading(self): + """Test that deleting comments preserves reply threading.""" + from ..interfaces import IReplies + + # Create a reply to comment1 + replies = IReplies(self.comment1) + reply = createObject("plone.Comment") + reply.text = "Reply to first comment" + reply.author_name = "Replier" + replies.addComment(reply) + + # Delete the parent comment + self.comment1.is_deleted = True + + # Threading structure should be preserved - replies adapter should still work + replies_after = IReplies(self.comment1) + self.assertIsNotNone(replies_after) + + # The reply should still be accessible through the IReplies adapter + # We just test that the adapter works, not the exact count + self.assertTrue(hasattr(replies_after, "addComment")) diff --git a/src/plone/app/discussion/tests/test_workflow.py b/src/plone/app/discussion/tests/test_workflow.py index 6a0d8a4d..89fffc98 100644 --- a/src/plone/app/discussion/tests/test_workflow.py +++ b/src/plone/app/discussion/tests/test_workflow.py @@ -2,6 +2,7 @@ from ..interfaces import IConversation from ..interfaces import IDiscussionLayer +from ..interfaces import IDiscussionSettings from ..testing import PLONE_APP_DISCUSSION_INTEGRATION_TESTING from AccessControl import Unauthorized from plone.app.testing import login @@ -10,9 +11,11 @@ from plone.app.testing import TEST_USER_ID from plone.app.testing import TEST_USER_NAME from plone.app.testing import TEST_USER_PASSWORD +from plone.registry.interfaces import IRegistry from Products.CMFCore.permissions import View from Products.CMFCore.utils import _checkPermission as checkPerm from zope.component import createObject +from zope.component import queryUtility from zope.interface import alsoProvides import unittest @@ -204,6 +207,11 @@ class CommentReviewWorkflowTest(unittest.TestCase): def setUp(self): self.portal = self.layer["portal"] setRoles(self.portal, TEST_USER_ID, ["Manager"]) + + # Enable hard deletion for this test that expects comments to be completely removed + registry = queryUtility(IRegistry) + settings = registry.forInterface(IDiscussionSettings) + settings.hard_delete_comments = True self.portal.invokeFactory("Folder", "test-folder") self.folder = self.portal["test-folder"]