Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions news/286.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Implement Soft Deletion [rohnsha0]
47 changes: 44 additions & 3 deletions src/plone/app/discussion/browser/comments.pt
Original file line number Diff line number Diff line change
Expand Up @@ -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="
Expand All @@ -58,7 +59,9 @@
"
>

<div class="d-flex flex-row align-items-center mb-3">
<div class="d-flex flex-row align-items-center mb-3"
tal:condition="not:is_deleted"
>

<!-- commenter image -->
<div class="comment-image me-3"
Expand Down Expand Up @@ -120,10 +123,48 @@
<!-- comment body -->
<div class="comment-body">

<span tal:replace="structure reply/getText"></span>
<!-- Show deleted message if comment is deleted -->
<div class="text-muted fst-italic"
tal:condition="is_deleted"
i18n:translate="comment_deleted_message"
>
This comment was deleted.
</div>

<!-- Show restore button for deleted comments if user can restore -->
<div class="d-flex flex-row justify-content-end mb-3"
tal:condition="python:is_deleted and view.can_restore(reply)"
>
<div class="comment-actions actions-restore">
<form class="comment-action action-restore"
action=""
method="post"
name="restore"
tal:attributes="
action string:${reply/absolute_url}/@@moderate-restore-comment;
id string:restore-${comment_id};
"
>
<button class="btn btn-success btn-sm"
name="form.button.RestoreComment"
type="submit"
value="Restore"
i18n:attributes="value label_restore;"
i18n:translate="label_restore"
>Restore</button>
</form>
</div>
</div>

<!-- Show normal comment content if not deleted -->
<span tal:condition="not:is_deleted"
tal:replace="structure reply/getText"
></span>

<!-- comment actions -->
<div class="d-flex flex-row justify-content-end mb-3">
<div class="d-flex flex-row justify-content-end mb-3"
tal:condition="not:is_deleted"
>

<div class="comment-actions actions-edit"
tal:condition="python:isEditCommentAllowed and canEdit"
Expand Down
7 changes: 7 additions & 0 deletions src/plone/app/discussion/browser/comments.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ class CommentForm(extensible.ExtensibleForm, form.Form):
"modification_date",
"author_username",
"title",
"is_deleted",
)
# We do not want the focus to be on this form when loading a page.
# See https://github.com/plone/Products.CMFPlone/issues/3623
Expand Down Expand Up @@ -371,6 +372,12 @@ def can_delete(self, reply):
"""
return getSecurityManager().checkPermission("Delete comments", aq_inner(reply))

def can_restore(self, reply):
"""Returns true if current user has the 'Delete comments'
permission.
"""
return getSecurityManager().checkPermission("Delete comments", aq_inner(reply))

def is_discussion_allowed(self):
context = aq_inner(self.context)
return context.restrictedTraverse("@@conversation_view").enabled()
Expand Down
9 changes: 9 additions & 0 deletions src/plone/app/discussion/browser/configure.zcml
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,15 @@
layer="..interfaces.IDiscussionLayer"
/>

<!-- Restore comment view -->
<browser:page
name="moderate-restore-comment"
for="plone.app.discussion.interfaces.IComment"
class=".moderation.RestoreComment"
permission="plone.app.discussion.DeleteComments"
layer="..interfaces.IDiscussionLayer"
/>

<!-- Comment Transition -->
<browser:page
name="transmit-comment"
Expand Down
4 changes: 4 additions & 0 deletions src/plone/app/discussion/browser/controlpanel.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,10 @@ def settings(self):
if settings.delete_own_comment_enabled:
output.append("delete_own_comment_enabled")

# Hard delete comments
if settings.hard_delete_comments:
output.append("hard_delete_comments")

# Anonymous comments
if settings.anonymous_comments:
output.append("anonymous_comments")
Expand Down
108 changes: 101 additions & 7 deletions src/plone/app/discussion/browser/moderation.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,18 @@
from plone.app.discussion.browser.utils import format_author_name_with_suffix
from plone.app.discussion.events import CommentDeletedEvent
from plone.app.discussion.events import CommentPublishedEvent
from plone.app.discussion.events import CommentRestoredEvent
from plone.app.discussion.events import CommentTransitionEvent
from plone.app.discussion.interfaces import _
from plone.app.discussion.interfaces import IComment
from plone.app.discussion.interfaces import IDiscussionSettings
from plone.app.discussion.interfaces import IReplies
from plone.registry.interfaces import IRegistry
from Products.CMFCore.utils import getToolByName
from Products.Five.browser import BrowserView
from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile
from Products.statusmessages.interfaces import IStatusMessage
from zope.component import queryUtility
from zope.event import notify


Expand Down Expand Up @@ -175,12 +179,29 @@ def __call__(self):
# base ZCML condition zope2.deleteObject allows 'delete own object'
# modify this for 'delete_own_comment_allowed' controlpanel setting
if self.can_delete(comment):
del conversation[comment.id]
# Check registry setting to determine deletion mode
registry = queryUtility(IRegistry)
settings = registry.forInterface(IDiscussionSettings, check=False)

if settings.hard_delete_comments:
# Hard delete: actually remove the comment from the conversation
comment_id = comment.comment_id
del conversation[comment_id]
IStatusMessage(self.context.REQUEST).addStatusMessage(
_("Comment permanently deleted."), type="info"
)
else:
# Soft delete: mark as deleted but keep in database
comment.is_deleted = True
comment.reindexObject()
notify(CommentDeletedEvent(self.context, comment))
IStatusMessage(self.context.REQUEST).addStatusMessage(
_("Comment deleted."), type="info"
)

# Reindex the content object to update comment counts
content_object.reindexObject()
notify(CommentDeletedEvent(self.context, comment))
IStatusMessage(self.context.REQUEST).addStatusMessage(
_("Comment deleted."), type="info"
)

came_from = self.context.REQUEST.HTTP_REFERER
# if the referrer already has a came_from in it, don't redirect back
if (
Expand Down Expand Up @@ -230,6 +251,47 @@ def __call__(self):
raise Unauthorized("You're not allowed to delete this comment.")


class RestoreComment(BrowserView):
"""Restore a deleted comment from a conversation.

This view is always called directly on the comment object:

http://nohost/front-page/++conversation++default/1286289644723317/\
@@moderate-restore-comment
"""

def __call__(self):
comment = aq_inner(self.context)
conversation = aq_parent(comment)
content_object = aq_parent(conversation)
# conditional security
# Only users with 'Delete comments' permission can restore comments
if self.can_restore(comment):
# Mark comment as not deleted
comment.is_deleted = False
comment.reindexObject()
content_object.reindexObject()
notify(CommentRestoredEvent(self.context, comment))
IStatusMessage(self.context.REQUEST).addStatusMessage(
_("Comment restored."), type="info"
)
came_from = self.context.REQUEST.HTTP_REFERER
# if the referrer already has a came_from in it, don't redirect back
if (
len(came_from) == 0
or "came_from=" in came_from
or not getToolByName(content_object, "portal_url").isURLInPortal(came_from)
):
came_from = content_object.absolute_url()
return self.context.REQUEST.RESPONSE.redirect(came_from)

def can_restore(self, reply):
"""Returns true if current user has the 'Delete comments'
permission.
"""
return getSecurityManager().checkPermission("Delete comments", aq_inner(reply))


class CommentTransition(BrowserView):
r"""Publish, reject, recall a comment or mark it as spam.

Expand Down Expand Up @@ -348,13 +410,45 @@ def delete(self):

Expects a list of absolute paths (without host and port):

/Plone/startseite/++conversation++default/1286200010610352
"""
context = aq_inner(self.context)
# Check registry setting to determine deletion mode
registry = queryUtility(IRegistry)
settings = registry.forInterface(IDiscussionSettings, check=False)

for path in self.paths:
comment = context.restrictedTraverse(path)
conversation = aq_parent(comment)
content_object = aq_parent(conversation)

if settings.hard_delete_comments:
# Hard delete: actually remove the comment from the conversation
comment_id = comment.comment_id
del conversation[comment_id]
else:
# Soft delete: mark as deleted but keep in database
comment.is_deleted = True
comment.reindexObject()
notify(CommentDeletedEvent(content_object, comment))

# Reindex the content object to update comment counts
content_object.reindexObject(idxs=["total_comments"])

def restore(self):
"""Restore all comments in the paths variable.

Expects a list of absolute paths (without host and port):

/Plone/startseite/++conversation++default/1286200010610352
"""
context = aq_inner(self.context)
for path in self.paths:
comment = context.restrictedTraverse(path)
conversation = aq_parent(comment)
content_object = aq_parent(conversation)
del conversation[comment.id]
# Mark comment as not deleted
comment.is_deleted = False
comment.reindexObject()
content_object.reindexObject(idxs=["total_comments"])
notify(CommentDeletedEvent(content_object, comment))
notify(CommentRestoredEvent(content_object, comment))
10 changes: 10 additions & 0 deletions src/plone/app/discussion/comment.py
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It shouldn't be possible to search for text in a deleted comment and find it that way. So we probably need to return an empty string from the getText and Title methods below if is_deleted is true.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done in 0ad6e20

ps: some tests are failing coz of this change, will address later!

Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,8 @@ class Comment(

user_notification = None

is_deleted = False

# Note: we want to use zope.component.createObject() to instantiate
# comments as far as possible. comment_id and __parent__ are set via
# IConversation.addComment().
Expand Down Expand Up @@ -159,6 +161,10 @@ def getId(self):

def getText(self, targetMimetype=None):
"""The body text of a comment."""
# Return empty string for deleted comments to prevent searchability
if getattr(self, "is_deleted", False):
return ""

transforms = getToolByName(self, "portal_transforms")

if targetMimetype is None:
Expand Down Expand Up @@ -195,6 +201,10 @@ def getText(self, targetMimetype=None):
def Title(self):
# The title of the comment.

# Return empty string for deleted comments to prevent searchability
if getattr(self, "is_deleted", False):
return ""

if self.title:
return self.title

Expand Down
11 changes: 9 additions & 2 deletions src/plone/app/discussion/conversation.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,10 @@ def enabled(self):

def total_comments(self):
public_comments = [
x for x in self.values() if user_nobody.has_permission("View", x)
x
for x in self.values()
if user_nobody.has_permission("View", x)
and not getattr(x, "is_deleted", False)
]
return len(public_comments)

Expand All @@ -86,7 +89,9 @@ def last_comment_date(self):
comment_keys = self._comments.keys()
for comment_key in reversed(comment_keys):
comment = self._comments[comment_key]
if user_nobody.has_permission("View", comment):
if user_nobody.has_permission("View", comment) and not getattr(
comment, "is_deleted", False
):
return comment.creation_date
return None

Expand All @@ -100,6 +105,8 @@ def public_commentators(self):
for comment in self._comments.values():
if not user_nobody.has_permission("View", comment):
continue
if getattr(comment, "is_deleted", False):
continue
retval.add(comment.author_username)
return tuple(retval)

Expand Down
6 changes: 6 additions & 0 deletions src/plone/app/discussion/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from plone.app.discussion.interfaces import ICommentModifiedEvent
from plone.app.discussion.interfaces import ICommentPublishedEvent
from plone.app.discussion.interfaces import ICommentRemovedEvent
from plone.app.discussion.interfaces import ICommentRestoredEvent
from plone.app.discussion.interfaces import ICommentTransitionEvent
from plone.app.discussion.interfaces import IDiscussionEvent
from plone.app.discussion.interfaces import IDiscussionSettings
Expand Down Expand Up @@ -68,6 +69,11 @@ class CommentDeletedEvent(DiscussionEvent):
"""Event to be triggered when a Comment is deleted"""


@implementer(ICommentRestoredEvent)
class CommentRestoredEvent(DiscussionEvent):
"""Event to be triggered when a Comment is restored"""


@implementer(ICommentPublishedEvent)
class CommentPublishedEvent(DiscussionEvent):
"""Event to be triggered when a Comment is publicated"""
Expand Down
Loading