bulk moderation of comments

extended for comment_multiple_state_review_workflow, refactoring and tests
This commit is contained in:
Katja Suess 2019-12-08 20:01:40 +01:00
parent d93525ff27
commit bf20752b69
11 changed files with 341 additions and 193 deletions

View File

@ -100,6 +100,14 @@
permission="plone.app.discussion.ReviewComments"
/>
<browser:page
for="*"
name="translationhelper"
layer="..interfaces.IDiscussionLayer"
class=".moderation.TranslationHelper"
permission="plone.app.discussion.ReviewComments"
/>
<!-- Comments viewlet -->
<browser:viewlet

View File

@ -1,6 +1,6 @@
/******************************************************************************
*
* jQuery functions for the plone.app.discussion bulk moderation.
* jQuery functions for the plone.app.discussion moderation.
*
******************************************************************************/
@ -84,19 +84,80 @@ require(["jquery", "pat-registry"], function($, registry) {
init();
$(".pat-plone-modal").patPloneModal();
});
} else {
location.reload();
}
},
error: function(msg) {
alert(
"Error transmitting comment. (Error sending AJAX request:" +
target +
")"
);
}
});
});
});
/**********************************************************************
* Bulk actions for comments (delete, publish)
**********************************************************************/
$("input[name='form.button.BulkAction']").click(function (e) {
e.preventDefault();
var form = $(this).closest("form");
var target = $(form).attr('action');
var params = $(form).serialize();
var valArray = $('input:checkbox:checked');
var selectField = $(form).find("[name='form.select.BulkAction']");
if (selectField.val() === '-1') {
// TODO: translate message
alert("You haven't selected a bulk action. Please select one.");
} else if (valArray.length === 0) {
// TODO: translate message
alert("You haven't selected any comment for this bulk action." +
"Please select at least one comment.");
} else {
$.post(target, params, function (data) {
// reset the bulkaction select
selectField.find("option[value='-1']").attr('selected', 'selected');
// reload filtered comments
$("#review-comments").load(window.location + " #review-comments", function() {
init();
$('.pat-plone-modal').patPloneModal();
});
});
}
});
/**********************************************************************
* Check or uncheck all checkboxes from the batch moderation page.
**********************************************************************/
$("input[name='check_all']").click(function () {
if ($(this).val() === '0') {
$(this).parents("table")
.find("input:checkbox")
.prop("checked", true);
$(this).val("1");
} else {
$(this).parents("table")
.find("input:checkbox")
.prop("checked", false);
$(this).val("0");
}
});
/**********************************************************************
* select comments with review_state
**********************************************************************/
$("input[name='review_state']").click(function () {
// location.search = 'review_state=' + $(this).val();
let review_state = $(this).val();
let url = location.href;
if (location.search) {
url = location.href.replace(location.search, "?review_state=" + review_state);
} else {
url = location.href + "?review_state=" + review_state;
}
$("#review-comments").load(url + " #review-comments", function() {
init();
$('.pat-plone-modal').patPloneModal();
let stateObj = { review_state: review_state };
history.pushState(stateObj, "moderate comments", url);
});
});
/**********************************************************************
* Bulk actions for comments (delete, publish)

View File

@ -15,13 +15,14 @@
<tal:main-macro metal:define-macro="main"
tal:define="toLocalizedTime nocall:context/@@plone/toLocalizedTime;
items view/comments;
filter view/filter|nothing;
filter request/review_state|nothing;
Batch python:modules['Products.CMFPlone'].Batch;
b_size python:30;
b_start python:0;
b_start request/b_start | b_start;
moderation_enabled view/moderation_enabled;
colorclass python:lambda x: 'state-private' if x=='rejected' else ('state-internal' if x=='spam' else 'state-'+x);
translationhelper nocall:context/@@translationhelper;
">
<script type="text/javascript"
tal:attributes="src string:${context/portal_url}/++plone++plone.app.discussion.javascripts/moderation.js">
@ -33,7 +34,7 @@
<div class="portalMessage warning"
role="status"
tal:condition="not: view/moderation_enabled">
tal:condition="not: moderation_enabled">
<strong i18n:translate="">Warning</strong>
<span tal:omit-tag="" i18n:translate="message_moderation_disabled">
Moderation workflow is disabled. You have to
@ -56,6 +57,7 @@
<form method="post"
action="#"
tal:condition="moderation_enabled"
tal:attributes="action string:${context/absolute_url}/@@bulk-actions"
tal:define="batch python:Batch(items, b_size, int(b_start), orphan=1);">
@ -76,7 +78,7 @@
value review_state;
id review_state;
checked python:request.review_state==review_state">
<label tal:attributes="for review_state"><span tal:content="python:view.translate('comment_{}'.format(review_state))">review_state</span></label>
<label tal:attributes="for review_state"><span tal:content="python:translationhelper.translate_comment_review_state(review_state)">review_state</span></label>
</tal:workflow-filter>
</fieldset>
</th>
@ -85,12 +87,15 @@
<th id="bulkactions" class="nosort" colspan="7">
<select name="form.select.BulkAction">
<option selected="selected" value="-1" i18n:translate="title_bulkactions">Bulk Actions</option>
<tal:comment tal:replace="nothing"></tal:comment>
<option value="publish"
i18n:translate="bulkactions_publish"
tal:condition="python: filter != 'published' and moderation_enabled">Approve</option>
tal:condition="python: filter != 'published'">Approve</option>
<option value="mark_as_spam"
tal:condition="python: filter != 'spam'">Spam</option>
<option value="delete" i18n:translate="bulkactions_delete">Delete</option>
</select>
<input type="hidden" name="form.button.Filter" tal:attributes="value filter" value="" />
<input type="hidden" name="filter" tal:attributes="value filter"/>
<input id="dobulkaction"
type="submit"
class="standalone allowMultiSubmit"
@ -184,7 +189,7 @@
value="Label"
tal:attributes="id item/id;
data-transition transition/id;
value python:view.translate(transition['title'])"
value python:translationhelper.translate(transition['title'])"
/>
</tal:transitions>
</div>

View File

@ -4,6 +4,7 @@ from AccessControl import Unauthorized
from Acquisition import aq_inner
from Acquisition import aq_parent
from plone.app.discussion.events import CommentPublishedEvent
from plone.app.discussion.events import CommentTransitionEvent
from plone.app.discussion.events import CommentDeletedEvent
from plone.app.discussion.interfaces import _
from plone.app.discussion.interfaces import IComment
@ -31,6 +32,15 @@ _('Reject')
_('Spam')
class TranslationHelper(BrowserView):
def translate(self, text=""):
return _(text)
def translate_comment_review_state(self, rs):
return _("comment_" + rs, default=rs)
class View(BrowserView):
"""Show comment moderation view."""
@ -42,9 +52,9 @@ class View(BrowserView):
pass
def __init__(self, context, request):
self.context = context
self.request = request
super(View, self).__init__(context, request)
self.workflowTool = getToolByName(self.context, 'portal_workflow')
self.transitions = []
def __call__(self):
self.request.set('disable_border', True)
@ -74,11 +84,10 @@ class View(BrowserView):
A 'review workflow' is characterized by implementing a 'pending'
workflow state.
"""
comment_workflow = self.workflowTool.getChainForPortalType(
workflows = self.workflowTool.getChainForPortalType(
'Discussion Item')
if comment_workflow:
comment_workflow = comment_workflow[0]
comment_workflow = self.workflowTool[comment_workflow]
if workflows:
comment_workflow = self.workflowTool[workflows[0]]
if 'pending' in comment_workflow.states:
return True
return False
@ -91,17 +100,16 @@ class View(BrowserView):
A 'review multipe state workflow' is characterized by implementing
a 'rejected' workflow state and a 'spam' workflow state.
"""
comment_workflow = self.workflowTool.getChainForPortalType(
workflows = self.workflowTool.getChainForPortalType(
'Discussion Item')
if comment_workflow:
comment_workflow = comment_workflow[0]
comment_workflow = self.workflowTool[comment_workflow]
if 'rejected' in comment_workflow.states:
if workflows:
comment_workflow = self.workflowTool[workflows[0]]
if 'spam' in comment_workflow.states:
return True
return False
def allowed_transitions(self, obj=None):
"""Return allowed workflow transitions.
"""Return allowed workflow transitions for obj.
Example: pending
@ -123,8 +131,6 @@ class View(BrowserView):
]
return transitions
def translate(self, text=""):
return _(text)
class ModerateCommentsEnabled(BrowserView):
@ -265,13 +271,11 @@ class CommentTransition(BrowserView):
comment.reindexObject()
content_object.reindexObject(idxs=['total_comments'])
notify(CommentPublishedEvent(self.context, comment))
# for complexer workflows:
notify(CommentTransitionEvent(self.context, comment))
review_state_new = workflowTool.getInfoFor(comment, 'review_state', '')
# context.translate() does not know a default for untranslated msgids
comment_state_translated = \
self.context.translate("comment_"+review_state_new)
if comment_state_translated == "comment_"+review_state_new:
comment_state_translated = review_state_new
comment_state_translated = self.context.restrictedTraverse("translationhelper").translate_comment_review_state(review_state_new)
msgid = _(
"comment_transmitted",
@ -327,7 +331,7 @@ class RejectComment(BrowserView):
class BulkActionsView(BrowserView):
"""Bulk actions (approve, delete, reject, recall, mark as spam).
"""Call bulk action: publish/approve, delete (, reject, recall, mark as spam).
Each table row of the moderation view has a checkbox with the absolute
path (without host and port) of the comment objects:
@ -349,6 +353,10 @@ class BulkActionsView(BrowserView):
"""
def __init__(self, context, request):
super(BulkActionsView, self).__init__(context, request)
self.workflowTool = getToolByName(context, 'portal_workflow')
def __call__(self):
"""Call BulkActionsView."""
if 'form.select.BulkAction' in self.request:
@ -358,50 +366,40 @@ class BulkActionsView(BrowserView):
if bulkaction == '-1':
# no bulk action was selected
pass
elif bulkaction == 'retract':
self.retract()
elif bulkaction == 'publish':
self.publish()
elif bulkaction == 'mark_as_spam':
self.mark_as_spam()
elif bulkaction == 'delete':
self.delete()
else:
raise KeyError # pragma: no cover
self.transmit(bulkaction)
def retract(self):
raise NotImplementedError
def transmit(self, action=None):
"""Transmit all comments in the paths variable to requested review_state.
def publish(self):
"""Publishes all comments in the paths variable.
Expects a list of absolute paths (without host and port):
/Plone/startseite/++conversation++default/1286200010610352
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)
content_object = aq_parent(aq_parent(comment))
workflowTool = getToolByName(comment, 'portal_workflow')
current_state = workflowTool.getInfoFor(comment, 'review_state')
if current_state != 'published':
workflowTool.doActionFor(comment, 'publish')
comment.reindexObject()
content_object.reindexObject(idxs=['total_comments'])
notify(CommentPublishedEvent(content_object, comment))
def mark_as_spam(self):
raise NotImplementedError
allowed_transitions = [
transition['id'] for transition in self.workflowTool.listActionInfos(object=comment)
if transition['category'] == 'workflow' and transition['allowed']
]
if action in allowed_transitions:
self.workflowTool.doActionFor(comment, action)
comment.reindexObject()
content_object.reindexObject(idxs=['total_comments'])
notify(CommentPublishedEvent(content_object, comment))
# for complexer workflows:
notify(CommentTransitionEvent(self.context, comment))
def delete(self):
"""Deletes all comments in the paths variable.
"""Delete all comments in the paths variable.
Expects a list of absolute paths (without host and port):
/Plone/startseite/++conversation++default/1286200010610352
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:

View File

@ -6,6 +6,7 @@ from plone.app.discussion.interfaces import ICommentRemovedEvent
from plone.app.discussion.interfaces import IDiscussionEvent
from plone.app.discussion.interfaces import ICommentDeletedEvent
from plone.app.discussion.interfaces import ICommentPublishedEvent
from plone.app.discussion.interfaces import ICommentTransitionEvent
from plone.app.discussion.interfaces import IReplyAddedEvent
from plone.app.discussion.interfaces import IReplyRemovedEvent
from zope.interface import implementer
@ -62,3 +63,8 @@ class CommentDeletedEvent(DiscussionEvent):
class CommentPublishedEvent(DiscussionEvent):
""" Event to be triggered when a Comment is publicated
"""
@implementer(ICommentTransitionEvent)
class CommentTransitionEvent(DiscussionEvent):
"""Event to be triggered when a Comments review_state changed."""

View File

@ -422,3 +422,7 @@ class ICommentPublishedEvent(IDiscussionEvent):
class ICommentDeletedEvent(IDiscussionEvent):
""" Notify user on comment delete
"""
class ICommentTransitionEvent(IDiscussionEvent):
"""Notify user on comment transition / change of review_state."""

View File

@ -1,105 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<dc-workflow workflow_id="comment_3state_review_workflow" title="Comment Review Workflow" description="A simple review workflow for comments" state_variable="review_state" initial_state="pending" manager_bypass="False">
<permission>Access contents information</permission>
<permission>Modify portal content</permission>
<permission>Reply to item</permission>
<permission>View</permission>
<state state_id="pending" title="Pending">
<description>Submitted, pending review.</description>
<exit-transition transition_id="publish"/>
<exit-transition transition_id="reject"/>
<permission-map name="Access contents information" acquired="False">
<permission-role>Manager</permission-role>
<permission-role>Owner</permission-role>
<permission-role>Reviewer</permission-role>
</permission-map>
<permission-map name="Modify portal content" acquired="False">
<permission-role>Manager</permission-role>
<permission-role>Owner</permission-role>
<permission-role>Reviewer</permission-role>
</permission-map>
<permission-map name="Reply to item" acquired="False">
</permission-map>
<permission-map name="View" acquired="False">
<permission-role>Manager</permission-role>
<permission-role>Owner</permission-role>
<permission-role>Reviewer</permission-role>
</permission-map>
</state>
<state state_id="published" title="Published">
<description>Visible to everyone, non-editable.</description>
<exit-transition transition_id="reject"/>
<permission-map name="Access contents information" acquired="True">
</permission-map>
<permission-map name="Modify portal content" acquired="False">
<permission-role>Manager</permission-role>
</permission-map>
<permission-map name="Reply to item" acquired="True">
</permission-map>
<permission-map name="View" acquired="True">
</permission-map>
</state>
<state state_id="rejected" title="">
<exit-transition transition_id="publish"/>
</state>
<transition transition_id="publish" title="Reviewer approves content" new_state="published" trigger="USER" before_script="" after_script="">
<description>Approving the comment makes it visible to other users.</description>
<action url="%(content_url)s/content_status_modify?workflow_action=publish" category="workflow" icon="">Approve</action>
<guard>
<guard-permission>Review comments</guard-permission>
</guard>
</transition>
<transition transition_id="reject" title="Reject" new_state="rejected" trigger="USER" before_script="" after_script="">
<action url="%(content_url)s/content_status_modify?workflow_action=rejected" category="workflow" icon="">Reject</action>
<guard>
<guard-permission>Review comments</guard-permission>
</guard>
</transition>
<variable variable_id="action" for_catalog="False" for_status="True" update_always="True">
<description>Previous transition</description>
<default>
<expression>transition/getId|nothing</expression>
</default>
<guard>
</guard>
</variable>
<variable variable_id="actor" for_catalog="False" for_status="True" update_always="True">
<description>The ID of the user who performed the previous transition</description>
<default>
<expression>user/getUserName</expression>
</default>
<guard>
</guard>
</variable>
<variable variable_id="comments" for_catalog="False" for_status="True" update_always="True">
<description>Comment about the last transition</description>
<default>
<expression>python:state_change.kwargs.get('comment', '')</expression>
</default>
<guard>
</guard>
</variable>
<variable variable_id="review_history" for_catalog="False" for_status="False" update_always="False">
<description>Provides access to workflow history</description>
<default>
<expression>state_change/getHistory</expression>
</default>
<guard>
<guard-permission>Request review</guard-permission>
<guard-permission>Review portal content</guard-permission>
</guard>
</variable>
<variable variable_id="time" for_catalog="False" for_status="True" update_always="True">
<description>When the previous transition was performed</description>
<default>
<expression>state_change/getDateTime</expression>
</default>
<guard>
</guard>
</variable>
</dc-workflow>

View File

@ -36,7 +36,7 @@
<permission-role>Reviewer</permission-role>
</permission-map>
</state>
<state state_id="published" title="Published">
<state state_id="published" title="Approved">
<description>Visible to everyone, non-editable.</description>
<exit-transition transition_id="mark_as_spam"/>
<exit-transition transition_id="recall"/>
@ -76,7 +76,7 @@
</guard>
</transition>
<transition transition_id="recall" title="Reviewer recalls comment back to pending state" new_state="pending" trigger="USER" before_script="" after_script="">
<action url="%(content_url)s/content_status_modify?workflow_action=recall" category="workflow" icon="">Reject</action>
<action url="%(content_url)s/content_status_modify?workflow_action=recall" category="workflow" icon="">Recall</action>
<guard>
<guard-permission>Review comments</guard-permission>
</guard>

View File

@ -0,0 +1,64 @@
*** Settings ***
Resource plone/app/robotframework/saucelabs.robot
Resource plone/app/robotframework/selenium.robot
Library Remote ${PLONE_URL}/RobotRemote
Test Setup Run Keywords Plone test setup
Test Teardown Run keywords Plone test teardown
*** Test Cases ***
Add a Comment to a Document and bulk delete it
Given a logged-in Site Administrator
and workflow multiple enabled
and a document with discussion enabled
When I add a comment and delete it
Then I can not see the comment below the document
*** Keywords ***
# Given
a logged-in Site Administrator
Enable autologin as Site Administrator
a document
Create content type=Document id=my-document title=My Document
a document with discussion enabled
a document
I enable discussion on the document
# When
I enable discussion on the document
Go To ${PLONE_URL}/my-document/edit
Wait until page contains Settings
Click Link Settings
Wait until element is visible name=form.widgets.IAllowDiscussion.allow_discussion:list
Select From List name=form.widgets.IAllowDiscussion.allow_discussion:list True
Click Button Save
I add a comment and delete it
Wait until page contains element id=form-widgets-comment-text
Input Text id=form-widgets-comment-text This is a comment
Click Button Comment
Go To ${PLONE_URL}/@@moderate-comments?review_state=all
Select from list by value xpath://select[@name='form.select.BulkAction'] delete
Select Checkbox name=check_all
Click Button Apply
workflow multiple enabled
Go To ${PLONE_URL}/@@content-controlpanel?type_id=Discussion%20Item&new_workflow=comment_multiple_state_review_workflow
Click Button Save
# Then
I can not see the comment below the document
Go To ${PLONE_URL}/my-document/view
Page should not contain This is a comment

View File

@ -0,0 +1,124 @@
# -*- coding: utf-8 -*-
from plone.app.discussion.browser.moderation import BulkActionsView
from plone.app.discussion.browser.moderation import DeleteComment
from plone.app.discussion.browser.moderation import CommentTransition
from plone.app.discussion.browser.moderation import View
from plone.app.discussion.interfaces import IConversation
from plone.app.discussion.interfaces import IDiscussionSettings
from plone.app.discussion.testing import PLONE_APP_DISCUSSION_INTEGRATION_TESTING # noqa
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
class ModerationBulkActionsViewTest(unittest.TestCase):
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'])
self.wf = getToolByName(self.portal,
'portal_workflow',
None)
self.context = self.portal
self.portal.portal_workflow.setChainForPortalTypes(
('Discussion Item',),
'comment_multiple_state_review_workflow',
)
self.wf_tool = self.portal.portal_workflow
# Add a conversation with three comments
conversation = IConversation(self.portal.doc1)
comment1 = createObject('plone.Comment')
comment1.title = 'Comment 1'
comment1.text = 'Comment text'
comment1.Creator = 'Jim'
new_id_1 = conversation.addComment(comment1)
self.comment1 = self.portal.doc1.restrictedTraverse(
'++conversation++default/{0}'.format(new_id_1),
)
comment2 = createObject('plone.Comment')
comment2.title = 'Comment 2'
comment2.text = 'Comment text'
comment2.Creator = 'Joe'
new_id_2 = conversation.addComment(comment2)
self.comment2 = self.portal.doc1.restrictedTraverse(
'++conversation++default/{0}'.format(new_id_2),
)
comment3 = createObject('plone.Comment')
comment3.title = 'Comment 3'
comment3.text = 'Comment text'
comment3.Creator = 'Emma'
new_id_3 = conversation.addComment(comment3)
self.comment3 = self.portal.doc1.restrictedTraverse(
'++conversation++default/{0}'.format(new_id_3),
)
self.conversation = conversation
def test_default_bulkaction(self):
# Make sure no error is raised when no bulk actions has been supplied
self.request.set('form.select.BulkAction', '-1')
self.request.set('paths', ['/'.join(self.comment1.getPhysicalPath())])
view = BulkActionsView(self.portal, self.request)
self.assertFalse(view())
def test_publish(self):
self.request.set('form.select.BulkAction', 'publish')
self.request.set('paths', ['/'.join(self.comment1.getPhysicalPath())])
view = BulkActionsView(self.portal, self.request)
view()
# Count published comments
published_comments = 0
for r in self.conversation.getThreads():
comment_obj = r['comment']
workflow_status = self.wf.getInfoFor(comment_obj, 'review_state')
if workflow_status == 'published':
published_comments += 1
# Make sure the comment has been published
self.assertEqual(published_comments, 1)
def test_mark_as_spam(self):
self.request.set('form.select.BulkAction', 'mark_as_spam')
self.request.set('paths', ['/'.join(self.comment1.getPhysicalPath())])
view = BulkActionsView(self.portal, self.request)
view()
# Count spam comments
spam_comments = 0
for r in self.conversation.getThreads():
comment_obj = r['comment']
workflow_status = self.wf.getInfoFor(comment_obj, 'review_state')
if workflow_status == 'spam':
spam_comments += 1
# Make sure the comment has been marked as spam
self.assertEqual(spam_comments, 1)
def test_delete(self):
# Initially we have three comments
self.assertEqual(len(self.conversation.objectIds()), 3)
# Delete two comments with bulk actions
self.request.set('form.select.BulkAction', 'delete')
self.request.set('paths', ['/'.join(self.comment1.getPhysicalPath()),
'/'.join(self.comment3.getPhysicalPath())])
view = BulkActionsView(self.app, self.request)
view()
# Make sure that the two comments have been deleted
self.assertEqual(len(self.conversation.objectIds()), 1)
comment = next(self.conversation.getComments())
self.assertTrue(comment)
self.assertEqual(comment, self.comment2)

View File

@ -110,14 +110,6 @@ class ModerationBulkActionsViewTest(unittest.TestCase):
self.assertFalse(view())
def test_retract(self):
self.request.set('form.select.BulkAction', 'retract')
self.request.set('paths', ['/'.join(self.comment1.getPhysicalPath())])
view = BulkActionsView(self.portal, self.request)
self.assertRaises(NotImplementedError, view)
def test_publish(self):
self.request.set('form.select.BulkAction', 'publish')
self.request.set('paths', ['/'.join(self.comment1.getPhysicalPath())])
@ -135,15 +127,6 @@ class ModerationBulkActionsViewTest(unittest.TestCase):
# Make sure the comment has been published
self.assertEqual(published_comments, 1)
def test_mark_as_spam(self):
self.request.set('form.select.BulkAction', 'mark_as_spam')
self.request.set('paths', ['/'.join(self.comment1.getPhysicalPath())])
view = BulkActionsView(self.portal, self.request)
self.assertRaises(NotImplementedError,
view)
def test_delete(self):
# Initially we have three comments
self.assertEqual(len(self.conversation.objectIds()), 3)