Merge pull request #166 from plone/moderation-extended

Extended Moderation of comments
This commit is contained in:
Maurits van Rees 2020-03-16 23:18:14 +01:00 committed by GitHub
commit d39b2fcbbe
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 1196 additions and 931 deletions

3
news/164.feature Normal file
View File

@ -0,0 +1,3 @@
Extended existing review workflow by stati ``rejected`` and ``spam``
Moderation view extended to handle four workflow states.
[ksuess and precious input of agitator]

View File

@ -76,7 +76,7 @@ class EditCommentForm(CommentForm):
self.request.response.redirect(target) self.request.response.redirect(target)
@button.buttonAndHandler(_(u'edit_comment_form_button', @button.buttonAndHandler(_(u'edit_comment_form_button',
default=u'Edit comment'), name='comment') default=u'Save'), name='comment')
def handleComment(self, action): def handleComment(self, action):
# Validate form # Validate form

View File

@ -32,6 +32,7 @@
<div class="comment" <div class="comment"
tal:define="reply reply_dict/comment; tal:define="reply reply_dict/comment;
comment_id reply/getId;
depth reply_dict/depth|python:0; depth reply_dict/depth|python:0;
depth python: depth > 10 and '10' or depth; depth python: depth > 10 and '10' or depth;
author_home_url python:view.get_commenter_home_url(username=reply.author_username); author_home_url python:view.get_commenter_home_url(username=reply.author_username);
@ -39,9 +40,10 @@
portrait_url python:view.get_commenter_portrait(reply.author_username); portrait_url python:view.get_commenter_portrait(reply.author_username);
review_state python:wtool.getInfoFor(reply, 'review_state', 'none'); review_state python:wtool.getInfoFor(reply, 'review_state', 'none');
canEdit python:view.can_edit(reply); canEdit python:view.can_edit(reply);
canDelete python:view.can_delete(reply)" canDelete python:view.can_delete(reply);
tal:attributes="class python:'comment replyTreeLevel'+str(depth)+' state-'+str(review_state); colorclass python:lambda x: 'state-private' if x=='rejected' else ('state-internal' if x=='spam' else 'state-'+x);"
id string:${reply/getId}" tal:attributes="class python:'comment replyTreeLevel{depth} {state}'.format(depth= depth, state=colorclass(review_state));
id comment_id"
tal:condition="python:canReview or review_state == 'published'"> tal:condition="python:canReview or review_state == 'published'">
<div class="commentImage" tal:condition="showCommenterImage"> <div class="commentImage" tal:condition="showCommenterImage">
@ -94,7 +96,8 @@
class="commentactionsform" class="commentactionsform"
tal:condition="python:not canDelete and isDeleteOwnCommentAllowed and view.could_delete_own(reply)" tal:condition="python:not canDelete and isDeleteOwnCommentAllowed and view.could_delete_own(reply)"
tal:attributes="action string:${reply/absolute_url}/@@delete-own-comment; tal:attributes="action string:${reply/absolute_url}/@@delete-own-comment;
style python:view.can_delete_own(reply) and 'display: inline' or 'display: none'"> style python:view.can_delete_own(reply) and 'display: inline' or 'display: none';
id string:delete-${comment_id}">
<input name="form.button.DeleteComment" <input name="form.button.DeleteComment"
class="destructive" class="destructive"
type="submit" type="submit"
@ -107,7 +110,8 @@
method="post" method="post"
class="commentactionsform" class="commentactionsform"
tal:condition="python:canDelete" tal:condition="python:canDelete"
tal:attributes="action string:${reply/absolute_url}/@@moderate-delete-comment"> tal:attributes="action string:${reply/absolute_url}/@@moderate-delete-comment;
id string:delete-${comment_id}">
<input name="form.button.DeleteComment" <input name="form.button.DeleteComment"
class="destructive" class="destructive"
type="submit" type="submit"
@ -119,7 +123,7 @@
<tal:edit tal:condition="python:isEditCommentAllowed and canEdit"> <tal:edit tal:condition="python:isEditCommentAllowed and canEdit">
<!-- plone 5 will have auth_token available <!-- plone 5 will have auth_token available
so we'll use modal pattern --> so we'll use modal pattern -->
<a class="commentactionsform pat-plone-modal context" <a class="pat-plone-modal context commentactionsform"
tal:condition="auth_token" tal:condition="auth_token"
tal:attributes="href string:${reply/absolute_url}/@@edit-comment?_authenticator=${auth_token}" tal:attributes="href string:${reply/absolute_url}/@@edit-comment?_authenticator=${auth_token}"
i18n:translate="Edit">Edit</a> i18n:translate="Edit">Edit</a>
@ -128,7 +132,8 @@
method="get" method="get"
class="commentactionsform" class="commentactionsform"
tal:condition="not: auth_token" tal:condition="not: auth_token"
tal:attributes="action string:${reply/absolute_url}/@@edit-comment"> tal:attributes="action string:${reply/absolute_url}/@@edit-comment;
id string:edit-${comment_id}">
<input name="form.button.EditComment" <input name="form.button.EditComment"
class="context" class="context"
type="submit" type="submit"
@ -146,10 +151,11 @@
class="commentactionsform" class="commentactionsform"
tal:condition="canReview" tal:condition="canReview"
tal:repeat="action reply_dict/actions|nothing" tal:repeat="action reply_dict/actions|nothing"
tal:attributes="action string:${reply/absolute_url}/@@moderate-publish-comment; tal:attributes="action string:${reply/absolute_url}/@@transmit-comment;
name action/id"> name action/id;
id string:${action/id}-${comment_id}">
<input type="hidden" name="workflow_action" tal:attributes="value action/id" /> <input type="hidden" name="workflow_action" tal:attributes="value action/id" />
<input name="form.button.PublishComment" <input name="form.button.TransmitComment"
class="context" class="context"
type="submit" type="submit"
tal:attributes="value action/title" tal:attributes="value action/title"

View File

@ -1,129 +0,0 @@
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en"
xmlns:tal="http://xml.zope.org/namespaces/tal"
xmlns:metal="http://xml.zope.org/namespaces/metal"
xmlns:i18n="http://xml.zope.org/namespaces/i18n"
lang="en"
metal:use-macro="context/main_template/macros/master"
i18n:domain="plone">
<body>
<metal:main fill-slot="main">
<tal:main-macro metal:define-macro="main"
tal:define="toLocalizedTime nocall:context/@@plone/toLocalizedTime;
items view/comments;
filter view/filter|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;">
<script type="text/javascript"
tal:attributes="src string:${context/portal_url}/++plone++plone.app.discussion.javascripts/moderation.js">
</script>
<h1 class="documentFirstHeading" i18n:translate="heading_comments_approved">
Comments approved
</h1>
<div class="portalMessage warning"
tal:condition="not: view/moderation_enabled">
<strong i18n:translate="">Warning</strong>
<span tal:omit-tag="" i18n:translate="message_moderation_disabled">
Moderation workflow is disabled. You have to
<a i18n:name="enable_comment_workflow"
i18n:translate="message_enable_comment_workflow" href=""
tal:attributes="href string:${context/portal_url}/@@content-controlpanel?type_id=Discussion Item">
enable the 'Comment Review Workflow' for the Comment content
type</a> before you can moderate comments here.
</span>
</div>
<form tal:condition="not:items">
<fieldset id="fieldset-moderate-comments" class="formPanel">
<p id="no-comments-message" i18n:translate="message_no_comments_approved">
No comments approved
</p>
</fieldset>
</form>
<form method="post"
action="#"
tal:condition="items"
tal:define="batch python:Batch(items, b_size, int(b_start), orphan=1);">
<fieldset id="fieldset-moderate-comments" class="formPanel">
<div metal:use-macro="here/batch_macros/macros/navigation" />
<table id="review-comments" class="listing">
<thead>
<tr>
<th class="nosort" i18n:translate="heading_commenter">Commenter</th>
<th class="nosort" i18n:translate="heading_date">Date</th>
<th class="nosort" i18n:translate="heading_in_reponse_to">In Response To</th>
<th class="nosort" i18n:translate="heading_comment">Comment</th>
<th class="nosort" i18n:translate="heading_approvedby">Approved by</th>
<th class="nosort" i18n:translate="heading_action">Action</th>
</tr>
</thead>
<tbody>
<tal:block repeat="item batch">
<tr class="commentrow"
tal:define="even repeat/item/even;
email python:getattr(item.getObject(), 'author_email');
item_url item/getURL;"
tal:attributes="class python: even and 'odd' or 'even'">
<td>
<span tal:content="python:item.author_name or item.Creator">Name</span>
<tal:email tal:condition="email">
<br/>
<a tal:attributes="href string:mailto:$email;"
tal:content="email">Email
</a>
</tal:email>
</td>
<td tal:content="python:toLocalizedTime(item.created, long_format=1)"/>
<td>
<a tal:attributes="href item_url"
tal:content="item/in_response_to" />
</td>
<td>
<span tal:replace="item/Description" />
<a href=""
tal:attributes="href string:$item_url/getText"
tal:condition="python:item.Description.endswith('[...]')"
i18n:translate="label_show_full_comment_text"
class="show-full-comment-text">show full comment text</a>
</td>
<td>
<span class="last-history-entry"
tal:attributes="data-href string:$item_url/@@historyview">
last history entry
</span>
</td>
<td class="actions">
<input type="hidden" name="selected_obj_paths:list" value="#"
tal:attributes="value item/getURL" />
<input id=""
class="destructive comment-delete-button"
type="submit"
value="Delete"
name="form.button.Delete"
i18n:attributes="value label_delete;"
tal:attributes="id item/id"
/>
</td>
</tr>
</tal:block>
</tbody>
</table>
<div metal:use-macro="here/batch_macros/macros/navigation" />
</fieldset>
</form>
</tal:main-macro>
</metal:main>
</body>
</html>

View File

@ -29,23 +29,6 @@
permission="plone.app.discussion.ReviewComments" permission="plone.app.discussion.ReviewComments"
/> />
<!-- Approved comments view -->
<browser:page
for="Products.CMFCore.interfaces.ISiteRoot"
name="comments-approved"
layer="..interfaces.IDiscussionLayer"
class=".moderation.ApprovedView"
permission="plone.app.discussion.ReviewComments"
/>
<browser:page
for="plone.app.layout.navigation.interfaces.INavigationRoot"
name="comments-approved"
layer="..interfaces.IDiscussionLayer"
class=".moderation.ApprovedView"
permission="plone.app.discussion.ReviewComments"
/>
<!-- Moderation bulk actions view --> <!-- Moderation bulk actions view -->
<browser:page <browser:page
for="Products.CMFCore.interfaces.ISiteRoot" for="Products.CMFCore.interfaces.ISiteRoot"
@ -108,15 +91,24 @@
permission="plone.app.discussion.DeleteOwnComments" permission="plone.app.discussion.DeleteOwnComments"
/> />
<!-- Publish comment view --> <!-- Comment Transition -->
<browser:page <browser:page
for="plone.app.discussion.interfaces.IComment" for="plone.app.discussion.interfaces.IComment"
name="moderate-publish-comment" name="transmit-comment"
layer="..interfaces.IDiscussionLayer" layer="..interfaces.IDiscussionLayer"
class=".moderation.PublishComment" class=".moderation.CommentTransition"
permission="plone.app.discussion.ReviewComments" permission="plone.app.discussion.ReviewComments"
/> />
<browser:page
for="*"
name="translationhelper"
layer="..interfaces.IDiscussionLayer"
class=".moderation.TranslationHelper"
permission="plone.app.discussion.ReviewComments"
/>
<!-- Comments viewlet --> <!-- Comments viewlet -->
<browser:viewlet <browser:viewlet
name="plone.comments" name="plone.comments"

View File

@ -177,15 +177,15 @@ class DiscussionSettingsControlPanel(controlpanel.ControlPanelFormWrapper):
return True return True
def custom_comment_workflow_warning(self): def custom_comment_workflow_warning(self):
"""Returns a warning string if a custom comment workflow is enabled. """Return True if a custom comment workflow is enabled."""
"""
wftool = getToolByName(self.context, 'portal_workflow', None) wftool = getToolByName(self.context, 'portal_workflow', None)
workflow_chain = wftool.getChainForPortalType('Discussion Item') workflow_chain = wftool.getChainForPortalType('Discussion Item')
one_state_workflow_enabled = \ one_state_workflow_enabled = \
'comment_one_state_workflow' in workflow_chain 'comment_one_state_workflow' in workflow_chain
comment_review_workflow_enabled = \ comment_review_workflow_enabled = \
'comment_review_workflow' in workflow_chain 'comment_review_workflow' in workflow_chain
if one_state_workflow_enabled or comment_review_workflow_enabled: if one_state_workflow_enabled \
or comment_review_workflow_enabled:
return return
return True return True

View File

@ -5,263 +5,279 @@
******************************************************************************/ ******************************************************************************/
/* global require */ /* global require */
if(require === undefined){ if (require === undefined) {
require = function(reqs, torun){ // jshint ignore:line require = function(reqs, torun) {
'use strict'; // jshint ignore:line
return torun(window.jQuery); "use strict";
}; return torun(window.jQuery);
};
} }
require([ // jshint ignore:line require([
'jquery' // jshint ignore:line
], function ($) { "jquery"
'use strict'; ], function($) {
"use strict";
// This unnamed function allows us to use $ inside of a block of code // This unnamed function allows us to use $ inside of a block of code
// without permanently overwriting $. // without permanently overwriting $.
// http://docs.jquery.com/Using_jQuery_with_Other_Libraries // http://docs.jquery.com/Using_jQuery_with_Other_Libraries
/************************************************************************** /**************************************************************************
* Create a reply-to-comment form right beneath the form that is passed to * Create a reply-to-comment form right beneath the form that is passed to
* the function. We do this by copying the regular comment form and * the function. We do this by copying the regular comment form and
* adding a hidden in_reply_to field to the form. * adding a hidden in_reply_to field to the form.
**************************************************************************/ **************************************************************************/
$.createReplyForm = function (comment_div) { $.createReplyForm = function(comment_div) {
var comment_id = comment_div.attr("id");
var comment_id = comment_div.attr('id'); var reply_button = comment_div.find(".reply-to-comment-button");
var reply_button = comment_div.find('.reply-to-comment-button'); /* Clone the reply div at the end of the page template that contains
* the regular comment form.
*/
var reply_div = $("#commenting").clone(true);
/* Clone the reply div at the end of the page template that contains /* Remove the ReCaptcha JS code before appending the form. If not
* the regular comment form. * removed, this causes problems
*/ */
var reply_div = $('#commenting').clone(true); reply_div
.find("#formfield-form-widgets-captcha")
.find("script")
.remove();
/* Remove the ReCaptcha JS code before appending the form. If not /* Insert the cloned comment form right after the reply button of the
* removed, this causes problems * current comment.
*/ */
reply_div.find('#formfield-form-widgets-captcha') reply_div.appendTo(comment_div).css("display", "none");
.find('script')
.remove();
/* Insert the cloned comment form right after the reply button of the /* Remove id='commenting' attribute, since we use it to uniquely define
* current comment.
*/
reply_div.appendTo(comment_div).css('display', 'none');
/* Remove id='commenting' attribute, since we use it to uniquely define
the main reply form. */ the main reply form. */
// Still belongs to class='reply' // Still belongs to class='reply'
reply_div.removeAttr('id'); reply_div.removeAttr("id");
/* Hide the reply button (only hide, because we may want to show it /* Hide the reply button (only hide, because we may want to show it
* again if the user hits the cancel button). * again if the user hits the cancel button).
*/ */
$(reply_button).css('display', 'none'); $(reply_button).css("display", "none");
/* Fetch the reply form inside the reply div */ /* Fetch the reply form inside the reply div */
var reply_form = reply_div.find('form'); var reply_form = reply_div.find("form");
/* Change the id of the textarea of the reply form /* Change the id of the textarea of the reply form
* To avoid conflict later between textareas with same id 'form-widgets-comment-text' while implementing a seperate instance of TinyMCE * To avoid conflict later between textareas with same id 'form-widgets-comment-text' while implementing a seperate instance of TinyMCE
* */ * */
reply_form.find('#formfield-form-widgets-comment-text').attr('id', 'formfield-form-widgets-new-textarea'+comment_id ); reply_form
reply_form.find('#form-widgets-comment-text').attr('id', 'form-widgets-new-textarea'+comment_id ); .find("#formfield-form-widgets-comment-text")
.attr("id", "formfield-form-widgets-new-textarea" + comment_id);
reply_form
.find("#form-widgets-comment-text")
.attr("id", "form-widgets-new-textarea" + comment_id);
/* Populate the hidden 'in_reply_to' field with the correct comment
/* Populate the hidden 'in_reply_to' field with the correct comment
id */ id */
reply_form.find('input[name="form.widgets.in_reply_to"]') reply_form.find('input[name="form.widgets.in_reply_to"]').val(comment_id);
.val(comment_id);
/* Add a remove-reply-to-comment Javascript function to remove the /* Add a remove-reply-to-comment Javascript function to remove the
form */ form */
var cancel_reply_button = reply_div.find('.cancelreplytocomment'); var cancel_reply_button = reply_div.find(".cancelreplytocomment");
cancel_reply_button.attr('id', comment_id); cancel_reply_button.attr("id", comment_id);
/* Show the cancel buttons. */ /* Show the cancel buttons. */
reply_form.find('input[name="form.buttons.cancel"]') reply_form
.css('display', 'inline'); .find('input[name="form.buttons.cancel"]')
.css("display", "inline");
/* Show the reply layer with a slide down effect */ /* Show the reply layer with a slide down effect */
reply_div.slideDown('slow'); reply_div.slideDown("slow");
/* Show the cancel button in the reply-to-comment form */ /* Show the cancel button in the reply-to-comment form */
cancel_reply_button.css('display', 'inline'); cancel_reply_button.css("display", "inline");
}; };
/**************************************************************************
* Remove all error messages and field values from the form that is passed
* to the function.
**************************************************************************/
$.clearForm = function(form_div) {
form_div.find(".error").removeClass("error");
form_div.find(".fieldErrorBox").remove();
form_div.find('input[type="text"]').attr("value", "");
form_div.find("textarea").attr("value", "");
/* XXX: Clean all additional form extender fields. */
};
/************************************************************************** function init_comment_eventhandler () {
* Remove all error messages and field values from the form that is passed /**********************************************************************
* to the function. * Transmit a single comment.
**************************************************************************/ **********************************************************************/
$.clearForm = function (form_div) { $('input[name="form.button.TransmitComment"]').on("click", function(e) {
form_div.find('.error').removeClass('error'); e.preventDefault();
form_div.find('.fieldErrorBox').remove(); var trigger = this;
form_div.find('input[type="text"]').attr('value', ''); var form = $(this).parents("form");
form_div.find('textarea').attr('value', ''); var data = $(form).serialize();
/* XXX: Clean all additional form extender fields. */ var form_url = $(form).attr("action");
}; var comment_id = $(this).parents(".comment").attr("id");
$.ajax({
//#JSCOVERAGE_IF 0 type: "GET",
url: form_url,
/************************************************************************** data: data,
* Window Load Function: Executes when complete page is fully loaded, context: trigger,
* including all frames, success: function(msg) {
**************************************************************************/ let url = location.href;
$(window).load(function () { $(this).parents(".comment").load(
// loading child nodes is not enough,
// class attributes are needed for visualization of workflow_state
/********************************************************************** url + " #" + comment_id + ".comment",
* If the user has hit the reply button of a reply-to-comment form function() {
* (form was submitted with a value for the 'in_reply_to' field in the $(this).find(".comment").unwrap();
* request), create a reply-to-comment form right under this comment. init_comment_eventhandler();
**********************************************************************/ $(".pat-plone-modal").patPloneModal();
var post_comment_div = $('#commenting'); }
var in_reply_to_field = );
post_comment_div.find('input[name="form.widgets.in_reply_to"]'); },
if (in_reply_to_field.length !== 0 && in_reply_to_field.val() !== '') { error: function(msg) {
var current_reply_id = '#' + in_reply_to_field.val(); return true;
var current_reply_to_div = $('.discussion').find(current_reply_id); }
$.createReplyForm(current_reply_to_div);
$.clearForm(post_comment_div);
}
/**********************************************************************
* If the user hits the 'reply' button of an existing comment, create a
* reply form right beneath this comment.
**********************************************************************/
$('.reply-to-comment-button').bind('click', function (e) { // jshint ignore:line
var comment_div = $(this).parents().filter('.comment');
$.createReplyForm(comment_div);
$.clearForm(comment_div);
}); });
return false;
});
/**********************************************************************
* Edit a comment
**********************************************************************/
if ($.fn.prepOverlay) {
$('form[name="edit"]').prepOverlay({
cssclass: "overlay-edit-comment",
width: "60%",
subtype: "ajax",
filter: "#content>*"
});
}
/********************************************************************** /**********************************************************************
* If the user hits the 'clear' button of an open reply-to-comment form, * Delete a comment and its answers.
* remove the form and show the 'reply' button again. **********************************************************************/
**********************************************************************/ $('input[name="form.button.DeleteComment"]').on("click", function(e) {
$('#commenting #form-buttons-cancel').bind('click', function (e) { e.preventDefault();
e.preventDefault(); var trigger = this;
var reply_to_comment_button = $(this). var form = $(this).parents("form");
parents(). var data = $(form).serialize();
filter('.comment'). var form_url = $(form).attr("action");
find('.reply-to-comment-button'); $.ajax({
type: "POST",
/* Find the reply-to-comment form and hide and remove it again. */ url: form_url,
$.reply_to_comment_form = $(this).parents().filter('.reply'); data: data,
$.reply_to_comment_form.slideUp('slow', function () { context: $(trigger).parents(".comment"),
success: function(data) {
// jshint ignore:line
var comment = $(this);
var clss = comment.attr("class");
// remove replies
var treelevel = parseInt(
clss[clss.indexOf("replyTreeLevel") + "replyTreeLevel".length],
10
);
// selector for all the following elements of lower level
var selector = ".replyTreeLevel" + treelevel;
for (var i = 0; i < treelevel; i++) {
selector += ", .replyTreeLevel" + i;
}
comment.nextUntil(selector).each(function() {
$(this).fadeOut("fast", function() {
$(this).remove(); $(this).remove();
});
}); });
// Add delete button to the parent
/* Show the reply-to-comment button again. */ var parent = comment.prev(
reply_to_comment_button.css('display', 'inline'); '[class*="replyTreeLevel' + (treelevel - 1) + '"]'
);
parent.find('form[name="delete"]').css("display", "inline");
// remove comment
$(this).fadeOut("fast", function() {
$(this).remove();
});
},
error: function(req, error) {
// jshint ignore:line
return true;
}
}); });
return false;
});
};
$(document).ready(function() {
init_comment_eventhandler();
/********************************************************************** /**********************************************************************
* Publish a single comment. * If the user has hit the reply button of a reply-to-comment form
**********************************************************************/ * (form was submitted with a value for the 'in_reply_to' field in the
$('input[name="form.button.PublishComment"]').on('click', function () { * request), create a reply-to-comment form right under this comment.
var trigger = this; **********************************************************************/
var form = $(this).parents('form'); var post_comment_div = $("#commenting");
var data = $(form).serialize(); var in_reply_to_field = post_comment_div.find(
var form_url = $(form).attr('action'); 'input[name="form.widgets.in_reply_to"]'
$.ajax({ );
type: 'GET', if (in_reply_to_field.length !== 0 && in_reply_to_field.val() !== "") {
url: form_url, var current_reply_id = "#" + in_reply_to_field.val();
data: data, var current_reply_to_div = $(".discussion").find(current_reply_id);
context: trigger, $.createReplyForm(current_reply_to_div);
success: function (msg) { // jshint ignore:line $.clearForm(post_comment_div);
// remove button (trigger object can't be directly removed) }
form.find('input[name="form.button.PublishComment"]').remove();
form.parents('.state-pending').toggleClass('state-pending').toggleClass('state-published');
},
error: function (msg) { // jshint ignore:line
return true;
}
});
return false;
});
/**********************************************************************
* Edit a comment
**********************************************************************/
if($.fn.prepOverlay){
$('form[name="edit"]').prepOverlay({
cssclass: 'overlay-edit-comment',
width: '60%',
subtype: 'ajax',
filter: '#content>*'
});
}
/**********************************************************************
* Delete a comment and its answers.
**********************************************************************/
$('input[name="form.button.DeleteComment"]').on('click', function () {
var trigger = this;
var form = $(this).parents('form');
var data = $(form).serialize();
var form_url = $(form).attr('action');
$.ajax({
type: 'POST',
url: form_url,
data: data,
context: $(trigger).parents('.comment'),
success: function (data) { // jshint ignore:line
var comment = $(this);
var clss = comment.attr('class');
// remove replies
var treelevel = parseInt(clss[clss.indexOf('replyTreeLevel') + 'replyTreeLevel'.length], 10);
// selector for all the following elements of lower level
var selector = '.replyTreeLevel' + treelevel;
for (var i = 0; i < treelevel; i++) {
selector += ', .replyTreeLevel' + i;
}
comment.nextUntil(selector).each(function () {
$(this).fadeOut('fast', function () {
$(this).remove();
});
});
// Add delete button to the parent
var parent = comment.prev('[class*="replyTreeLevel' + (treelevel - 1) + '"]');
parent.find('form[name="delete"]').css('display', 'inline');
// remove comment
$(this).fadeOut('fast', function () {
$(this).remove();
});
},
error: function (req, error) { // jshint ignore:line
return true;
}
});
return false;
});
/**********************************************************************
* By default, hide the reply and the cancel button for the regular add
* comment form.
**********************************************************************/
$('.reply').find('input[name="form.buttons.reply"]')
.css('display', 'none');
$('.reply').find('input[name="form.buttons.cancel"]')
.css('display', 'none');
/**********************************************************************
* By default, show the reply button only when Javascript is enabled.
* Otherwise hide it, since the reply functions only work with JS
* enabled.
**********************************************************************/
$('.reply-to-comment-button').removeClass('hide');
/**********************************************************************
* If the user hits the 'reply' button of an existing comment, create a
* reply form right beneath this comment.
**********************************************************************/
$(".reply-to-comment-button").bind("click", function(e) {
// jshint ignore:line
var comment_div = $(this)
.parents()
.filter(".comment");
$.createReplyForm(comment_div);
$.clearForm(comment_div);
}); });
/**********************************************************************
* If the user hits the 'clear' button of an open reply-to-comment form,
* remove the form and show the 'reply' button again.
**********************************************************************/
$("#commenting #form-buttons-cancel").bind("click", function(e) {
e.preventDefault();
var reply_to_comment_button = $(this)
.parents()
.filter(".comment")
.find(".reply-to-comment-button");
//#JSCOVERAGE_ENDIF /* Find the reply-to-comment form and hide and remove it again. */
$.reply_to_comment_form = $(this)
.parents()
.filter(".reply");
$.reply_to_comment_form.slideUp("slow", function() {
$(this).remove();
});
/* Show the reply-to-comment button again. */
reply_to_comment_button.css("display", "inline");
});
/**********************************************************************
* By default, hide the reply and the cancel button for the regular add
* comment form.
**********************************************************************/
$(".reply")
.find('input[name="form.buttons.reply"]')
.css("display", "none");
$(".reply")
.find('input[name="form.buttons.cancel"]')
.css("display", "none");
/**********************************************************************
* By default, show the reply button only when Javascript is enabled.
* Otherwise hide it, since the reply functions only work with JS
* enabled.
**********************************************************************/
$(".reply-to-comment-button").removeClass("hide");
});
}); });

View File

@ -1,182 +1,228 @@
/****************************************************************************** /******************************************************************************
* *
* jQuery functions for the plone.app.discussion bulk moderation. * jQuery functions for the plone.app.discussion moderation.
* *
******************************************************************************/ ******************************************************************************/
/* global require, alert */
/* jshint quotmark: false */
if(require === undefined){ if (require === undefined) {
require = function(reqs, torun){ // jshint ignore:line require = function(reqs, torun) {
'use strict'; "use strict";
return torun(window.jQuery); return torun(window.jQuery);
}; };
} }
require([ // jshint ignore:line require(["jquery", "pat-registry"], function($, registry) {
'jquery' "use strict";
], function ($) {
'use strict';
// This unnamed function allows us to use $ inside of a block of code
// without permanently overwriting $.
// http://docs.jquery.com/Using_jQuery_with_Other_Libraries
//#JSCOVERAGE_IF 0 $(document).ready(function() {
init_moderation_eventhandler();
/************************************************************************** });
* Document Ready Function: Executes when DOM is ready.
**************************************************************************/
$(document).ready(function () {
/**********************************************************************
* Delete a single comment.
**********************************************************************/
$("input[name='form.button.Delete']").click(function (e) {
e.preventDefault();
var row = $(this).parent().parent();
var path = $(row).find("[name='selected_obj_paths:list']").attr("value");
var auth_key = $('input[name="_authenticator"]').val();
var target = path + "/@@moderate-delete-comment?_authenticator=" + auth_key;
$.ajax({
type: "GET",
url: target,
success: function (msg) { // jshint ignore:line
// fade out row
$(row).fadeOut("normal", function () {
$(this).remove();
});
// reload page if all comments have been removed
var comments = $("table#review-comments > tbody > tr");
if (comments.length === 1) {
location.reload();
}
},
error: function (msg) { // jshint ignore:line
alert("Error sending AJAX request:" + target);
}
});
});
/**********************************************************************
* Publish a single comment.
**********************************************************************/
$("input[name='form.button.Publish']").click(function (e) {
e.preventDefault();
var row = $(this).parent().parent();
var path = $(row).find("[name='selected_obj_paths:list']").attr("value");
var auth_key = $('input[name="_authenticator"]').val();
var target = path + "/@@moderate-publish-comment?_authenticator=" + auth_key;
$.ajax({
type: "GET",
url: target,
success: function (msg) { // jshint ignore:line
// fade out row
$(row).fadeOut("normal", function () {
$(this).remove();
});
// reload page if all comments have been removed
var comments = $("table#review-comments > tbody > tr");
if (comments.length === 1) {
location.reload();
}
},
error: function (msg) { // jshint ignore:line
alert("Error sending AJAX request:" + target);
}
});
});
/**********************************************************************
* Bulk actions for comments (delete, publish)
**********************************************************************/
$("input[name='form.button.BulkAction']").click(function (e) {
e.preventDefault();
var form = $(this).parents("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') {
// XXX: translate message
alert("You haven't selected a bulk action. Please select one.");
} else if (valArray.length === 0) {
// XXX: 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) { // jshint ignore:line
valArray.each(function () {
/* Remove all selected lines. */
var row = $(this).parent().parent();
row.fadeOut("normal", function () {
row.remove();
});
});
// reload page if all comments have been removed
var comments = $("table#review-comments > tbody > tr");
if (comments.length <= valArray.length) {
location.reload();
}
});
// reset the bulkaction select
selectField.find("option[value='-1']").attr('selected', 'selected');
}
});
/**********************************************************************
* 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")
.attr("checked", "checked");
$(this).val("1");
} else {
$(this).parents("table")
.find("input:checkbox")
.attr("checked", "");
$(this).val("0");
}
});
/**********************************************************************
* Show full text of a comment in the batch moderation page.
**********************************************************************/
$(".show-full-comment-text").click(function (e) {
e.preventDefault();
var target = $(this).attr("href");
var td = $(this).parent();
$.ajax({
type: "GET",
url: target,
data: "",
success: function (data) {
// show full text
td.replaceWith("<td>" + data + "</td>");
},
error: function (msg) { // jshint ignore:line
alert("Error getting full comment text:" + target);
}
});
});
/**********************************************************************
* Comments approved: Load history for approved date.
**********************************************************************/
$(".last-history-entry").each(function() {
$(this).load($(this).attr("data-href") + " .historyByLine", function() {
$(this).children(".historyByLine").last().remove();
});
});
function init_moderation_eventhandler() {
/**********************************************************************
* Delete a single comment.
**********************************************************************/
$("input[name='form.button.moderation.DeleteComment']").click(function(e) {
e.preventDefault();
var row = $(this).closest("tr");
var path = row.find("[name='selected_obj_paths:list']").attr("value");
var auth_key = $('input[name="_authenticator"]').val();
var target =
path + "/@@moderate-delete-comment?_authenticator=" + auth_key;
$.ajax({
type: "GET",
url: target,
success: function(msg) {
// fade out row
row.fadeOut(250).fadeIn(250, function() {
row.remove();
});
// reload page if all comments have been removed
var comments = $("table#review-comments > tbody > tr");
if (comments.length === 1) {
location.reload();
}
},
error: function(msg) {
alert("Error sending AJAX request:" + target);
}
});
}); });
//#JSCOVERAGE_ENDIF /**********************************************************************
* Transmit a single comment.
**********************************************************************/
$('input[name="form.button.moderation.TransmitComment"]').click(function(
e
) {
e.preventDefault();
let button = $(this);
var row = $(this).closest("tr");
var path = $(row)
.find("[name='selected_obj_paths:list']")
.attr("value");
var workflow_action = $(this).attr("data-transition");
var auth_key = $('input[name="_authenticator"]').val();
// distinction of workflow_action
var target =
path +
"/@@transmit-comment?_authenticator=" +
auth_key +
"&workflow_action=" +
workflow_action;
var moderate =
$(this)
.closest("fieldset")
.attr("id") == "fieldset-moderate-comments";
$.ajax({
type: "GET",
url: target,
success: function(msg) {
if (moderate) {
let url = location.href;
$("#review-comments").load(
url + " #review-comments > *",
function() {
init_moderation_eventhandler();
$(".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_moderation_eventhandler();
$(".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() {
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;
}
$("#fieldset-moderate-comments")
.parent()
.load(url + " #content form > *", function() {
init_moderation_eventhandler();
$(".pat-plone-modal").patPloneModal();
let stateObj = { review_state: review_state };
history.pushState(stateObj, "moderate comments", url);
});
});
/**********************************************************************
* Show full text of a comment in the batch moderation page.
**********************************************************************/
$(".show-full-comment-text").click(function(e) {
e.preventDefault();
var target = $(this).attr("href");
var parent = $(this).parent();
$.ajax({
type: "GET",
url: target,
data: "",
success: function(data) {
// show full text
parent.html(data);
},
error: function(msg) {
alert("Error getting full comment text:" + target);
}
});
});
/**********************************************************************
* Comments published: Load history for publishing date.
**********************************************************************/
$(".last-history-entry").each(function() {
var lasthistoryentry = $(this);
$.ajax({
url: lasthistoryentry.attr("data-href"),
success: function(data) {
lasthistoryentry.html(
$(data)
.find(".historyByLine")
.first()
);
// format date
registry.scan(lasthistoryentry);
},
error: function(msg) {
console.error("Error getting history.");
}
});
});
} // end init_moderation_eventhandler
}); });

View File

@ -7,31 +7,39 @@
i18n:domain="plone"> i18n:domain="plone">
<body> <body>
<metal:override fill-slot="top_slot"
tal:define="disable_column_one python:request.set('disable_plone.leftcolumn',1);
disable_column_two python:request.set('disable_plone.rightcolumn',1);"/>
<metal:main fill-slot="main"> <metal:main fill-slot="main">
<tal:main-macro metal:define-macro="main" <tal:main-macro metal:define-macro="main"
tal:define="toLocalizedTime nocall:context/@@plone/toLocalizedTime; tal:define="toLocalizedTime nocall:context/@@plone/toLocalizedTime;
items view/comments; items view/comments;
filter view/filter|nothing; filter request/review_state|nothing;
Batch python:modules['Products.CMFPlone'].Batch; Batch python:modules['Products.CMFPlone'].Batch;
b_size python:30; b_size python:30;
b_start python:0; b_start python:0;
b_start request/b_start | b_start; b_start request/b_start | b_start;
moderation_enabled view/moderation_enabled;"> 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" <script type="text/javascript"
tal:attributes="src string:${context/portal_url}/++plone++plone.app.discussion.javascripts/moderation.js"> tal:attributes="src string:${context/portal_url}/++plone++plone.app.discussion.javascripts/moderation.js">
</script> </script>
<style>
#review-comments th label {
margin-right: 1em;
}
</style>
<h1 class="documentFirstHeading" i18n:translate="heading_moderate_comments"> <h1 class="documentFirstHeading" i18n:translate="heading_moderate_comments">
Moderate comments Moderate comments
</h1> </h1>
<p>
<a tal:attributes="href string:${context/@@plone_portal_state/navigation_root_url}/comments-approved;">&gt; <span
i18n:translate="heading_comments_approved">Comments approved</span></a>
</p>
<div class="portalMessage warning" <div class="portalMessage warning"
role="status" role="status"
tal:condition="not: view/moderation_enabled"> tal:condition="not: moderation_enabled">
<strong i18n:translate="">Warning</strong> <strong i18n:translate="">Warning</strong>
<span tal:omit-tag="" i18n:translate="message_moderation_disabled"> <span tal:omit-tag="" i18n:translate="message_moderation_disabled">
Moderation workflow is disabled. You have to Moderation workflow is disabled. You have to
@ -43,120 +51,162 @@
</span> </span>
</div> </div>
<form tal:condition="not:items"> <form
<fieldset id="fieldset-moderate-comments" class="formPanel"> method="post"
<p id="no-comments-message" i18n:translate="message_nothing_to_moderate"> action="#"
No comments to moderate. tal:condition="moderation_enabled"
</p> tal:attributes="action string:${context/absolute_url}/@@bulk-actions"
</fieldset> tal:define="batch python:Batch(items, b_size, int(b_start), orphan=1);">
</form> <fieldset id="fieldset-moderate-comments" class="formPanel">
<form method="post" <div metal:use-macro="here/batch_macros/macros/navigation" />
action="#" <table id="review-comments" class="listing">
tal:attributes="action string:${context/absolute_url}/@@bulk-actions" <thead>
tal:condition="items" <tr>
tal:define="batch python:Batch(items, b_size, int(b_start), orphan=1);"> <th colspan="7">
<fieldset tal:condition="view/moderation_multiple_state_enabled">
<input type="radio" id="all" name="review_state" value="all"
tal:attributes="checked python:request.review_state=='all'">
<label for="all" i18n:translate="">all</label>
<tal:workflow-filter tal:repeat="review_state python:['pending', 'published', 'rejected', 'spam']">
<input type="radio" name="review_state"
tal:attributes="
value review_state;
id review_state;
checked python:request.review_state==review_state">
<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>
</tr>
<tr tal:condition="items">
<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'">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="filter" tal:attributes="value filter"/>
<input id="dobulkaction"
type="submit"
class="standalone allowMultiSubmit"
value="Apply"
name="form.button.BulkAction"
i18n:attributes="value label_apply;" />
</th>
</tr>
<tr tal:condition="items">
<th class="nosort"><input name="check_all" type="checkbox" value="0" /></th>
<th class="nosort" i18n:translate="heading_commenter">Commenter</th>
<th class="nosort" i18n:translate="heading_date">Date</th>
<th class="nosort" i18n:translate="heading_in_reponse_to">In Response To</th>
<th class="nosort" i18n:translate="heading_comment">Comment</th>
<th class="nosort" i18n:translate="heading_changedby">Last Action</th>
</tr>
</thead>
<tbody>
<tal:block repeat="item batch"
tal:condition="items">
<tr class="commentrow"
tal:define="even repeat/item/even;
item_obj item/getObject;
email item_obj/author_email;
item_url item/getURL;"
tal:attributes="class python: even and 'odd' or 'even'">
<td class="notDraggable">
<input type="checkbox"
class="noborder"
name="paths:list" id="#"
value="#"
tal:attributes="value item/getPath;
id string:cb_${item/id};
checked item/checked|nothing;
alt string:Select ${item/Title};
title string:Select ${item/Title}" />
<input type="hidden" name="selected_obj_paths:list" value="#"
tal:attributes="value item/getURL" />
</td>
<td>
<span tal:content="python:item.author_name or item.Creator">Name</span>
<tal:email tal:condition="email">
<br/>
<a tal:attributes="href string:mailto:$email;"
tal:content="email">Email
</a>
</tal:email>
</td>
<td style="white-space: nowrap;" tal:content="python:toLocalizedTime(item.created, long_format=1)"/>
<td>
<a tal:attributes="href item_url" target="_blank"
tal:content="item/in_response_to" />
</td>
<td tal:attributes="class python:colorclass(item.review_state)">
<div>
<span tal:replace="item/Description"/>
<a href=""
tal:attributes="href string:$item_url/getText"
tal:condition="python:item.Description.endswith('[...]')"
i18n:translate="label_show_full_comment_text"
class="show-full-comment-text">show full comment text</a>
</div>
<div class="actions input-group-addon">
<input
type="hidden"
name="selected_obj_paths:list"
value="#"
tal:attributes="value item/getURL"
/>
<!-- delete -->
<input id=""
class="destructive comment-delete-button"
type="submit"
value="Delete"
name="form.button.moderation.DeleteComment"
i18n:attributes="value label_delete;"
tal:attributes="id item/id"
/>
<!-- edit -->
<a class="pat-plone-modal context" href="#"
tal:attributes="href python:item_url+'/@@edit-comment?review_state=' + item.review_state">Edit</a>
<fieldset id="fieldset-moderate-comments" class="formPanel"> <!-- workflow actions -->
<tal:transitions
tal:define="
transitions python:view.allowed_transitions(item_obj)">
<input name="form.button.moderation.TransmitComment"
tal:repeat="transition transitions"
class="context"
type="submit"
value="Label"
tal:attributes="id string:${item/id}_${transition/id};
data-transition transition/id;
value python:translationhelper.translate(transition['title']);
style python:transition['id']=='publish' and 'background-color: #5cb85c;; border-color: #4cae4c;;' or '';
"
/>
</tal:transitions>
</div>
</td>
<td>
<span class="last-history-entry"
tal:attributes="data-href string:$item_url/@@contenthistorypopup">
last history entry
</span>
</td>
</tr>
</tal:block>
</tbody>
</table>
<div metal:use-macro="here/batch_macros/macros/navigation" />
</fieldset>
</form>
<div metal:use-macro="here/batch_macros/macros/navigation" />
<table id="review-comments" class="listing">
<thead>
<tr>
<th id="bulkactions" class="nosort" colspan="7">
<select name="form.select.BulkAction">
<option selected="selected" value="-1" i18n:translate="title_bulkactions">Bulk Actions</option>
<option value="publish"
i18n:translate="bulkactions_publish"
tal:condition="python: filter != 'published' and moderation_enabled">Approve</option>
<option value="delete" i18n:translate="bulkactions_delete">Delete</option>
</select>
<input type="hidden" name="form.button.Filter" tal:attributes="value filter" value="" />
<input id="dobulkaction"
type="submit"
class="standalone allowMultiSubmit"
value="Apply"
name="form.button.BulkAction"
i18n:attributes="value label_apply;" />
</th>
</tr>
<tr>
<th class="nosort"><input name="check_all" type="checkbox" value="0" /></th>
<th class="nosort" i18n:translate="heading_commenter">Commenter</th>
<th class="nosort" i18n:translate="heading_date">Date</th>
<th class="nosort" i18n:translate="heading_in_reponse_to">In Response To</th>
<th class="nosort" i18n:translate="heading_comment">Comment</th>
<th class="nosort" i18n:translate="heading_action">Action</th>
</tr>
</thead>
<tbody>
<tal:block repeat="item batch">
<tr class="commentrow"
tal:define="even repeat/item/even;
email python:getattr(item.getObject(), 'author_email');"
tal:attributes="class python: even and 'odd' or 'even'">
<td class="notDraggable">
<input type="checkbox"
class="noborder"
name="paths:list" id="#"
value="#"
tal:attributes="value item/getPath;
id string:cb_${item/id};
checked item/checked|nothing;
alt string:Select ${item/Title};
title string:Select ${item/Title}" />
<input type="hidden" name="selected_obj_paths:list" value="#"
tal:attributes="value item/getURL" />
</td>
<td>
<span tal:content="python:item.author_name or item.Creator">Name</span>
<tal:email tal:condition="email">
<br/>
<a tal:attributes="href string:mailto:$email;"
tal:content="email">Email
</a>
</tal:email>
</td>
<td tal:content="python:toLocalizedTime(item.created, long_format=1)"/>
<td>
<a tal:attributes="href item/getURL"
tal:content="item/in_response_to" />
</td>
<td>
<span tal:replace="item/Description" />
<a href=""
tal:attributes="href string:${item/getURL}/getText"
tal:condition="python:item.Description.endswith('[...]')"
i18n:translate="label_show_full_comment_text"
class="show-full-comment-text">show full comment text</a>
</td>
<td class="actions">
<input id=""
class="context comment-publish-button"
type="submit"
value="Approve"
name="form.button.Publish"
i18n:attributes="value label_publish;"
tal:attributes="id item/id"
tal:condition="python:item.review_state == 'pending'"
/>
<input id=""
class="destructive comment-delete-button"
type="submit"
value="Delete"
name="form.button.Delete"
i18n:attributes="value label_delete;"
tal:attributes="id item/id"
/>
</td>
</tr>
</tal:block>
</tbody>
</table>
<div metal:use-macro="here/batch_macros/macros/navigation" />
</fieldset>
</form>
</tal:main-macro> </tal:main-macro>
</metal:main> </metal:main>

View File

@ -1,9 +1,11 @@
# -*- coding: utf-8 -*- # coding: utf-8
from AccessControl import getSecurityManager from AccessControl import getSecurityManager
from AccessControl import Unauthorized from AccessControl import Unauthorized
from Acquisition import aq_inner from Acquisition import aq_inner
from Acquisition import aq_parent from Acquisition import aq_parent
from plone import api
from plone.app.discussion.events import CommentPublishedEvent 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.events import CommentDeletedEvent
from plone.app.discussion.interfaces import _ from plone.app.discussion.interfaces import _
from plone.app.discussion.interfaces import IComment from plone.app.discussion.interfaces import IComment
@ -13,12 +15,34 @@ from Products.Five.browser import BrowserView
from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile
from Products.statusmessages.interfaces import IStatusMessage from Products.statusmessages.interfaces import IStatusMessage
from zope.event import notify from zope.event import notify
from zope.interface import alsoProvides
# Translations for generated values in buttons
# States
_('comment_pending', default='pending')
# _('comment_approved', default='published')
_('comment_published', default='published')
_('comment_rejected', default='rejected')
_('comment_spam', default='marked as spam')
# Transitions
_('Recall')
_('Approve')
_('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): class View(BrowserView):
"""Comment moderation view. """Show comment moderation view."""
"""
template = ViewPageTemplateFile('moderation.pt') template = ViewPageTemplateFile('moderation.pt')
try: try:
template.id = '@@moderate-comments' template.id = '@@moderate-comments'
@ -26,56 +50,84 @@ class View(BrowserView):
# id is not writeable in Zope 2.12 # id is not writeable in Zope 2.12
pass pass
def __init__(self, context, request):
super(View, self).__init__(context, request)
self.workflowTool = getToolByName(self.context, 'portal_workflow')
self.transitions = []
def __call__(self): def __call__(self):
self.request.set('disable_border', True) self.request.set('disable_border', True)
context = aq_inner(self.context) self.request.set('review_state',
catalog = getToolByName(context, 'portal_catalog') self.request.get('review_state', 'pending'))
self.comments = catalog(object_provides=IComment.__identifier__,
review_state='pending',
sort_on='created',
sort_order='reverse')
return self.template() return self.template()
def moderation_enabled(self): def comments(self):
"""Returns true if a 'review workflow' is enabled on 'Discussion Item' """Return comments of defined review_state.
content type. A 'review workflow' is characterized by implementing
a 'pending' workflow state. review_state is string or list of strings.
""" """
context = aq_inner(self.context) catalog = getToolByName(self.context, 'portal_catalog')
workflowTool = getToolByName(context, 'portal_workflow') if self.request.review_state == 'all':
comment_workflow = workflowTool.getChainForPortalType( return catalog(object_provides=IComment.__identifier__,
sort_on='created',
sort_order='reverse')
return catalog(object_provides=IComment.__identifier__,
review_state=self.request.review_state,
sort_on='created',
sort_order='reverse')
def moderation_enabled(self):
"""Return true if a review workflow is enabled on 'Discussion Item'
content type.
A 'review workflow' is characterized by implementing a 'pending'
workflow state.
"""
workflows = self.workflowTool.getChainForPortalType(
'Discussion Item') 'Discussion Item')
if comment_workflow: if workflows:
comment_workflow = comment_workflow[0] comment_workflow = self.workflowTool[workflows[0]]
comment_workflow = workflowTool[comment_workflow]
if 'pending' in comment_workflow.states: if 'pending' in comment_workflow.states:
return True return True
return False return False
@property
def moderation_multiple_state_enabled(self):
"""Return true if a 'review multiple state workflow' is enabled on
'Discussion Item' content type.
class ApprovedView(View): A 'review multipe state workflow' is characterized by implementing
"""Overview comments already approved.""" a 'rejected' workflow state and a 'spam' workflow state.
template = ViewPageTemplateFile('comments_approved.pt') """
try: workflows = self.workflowTool.getChainForPortalType(
template.id = '@@comments-approved' 'Discussion Item')
except AttributeError: if workflows:
# id is not writeable in Zope 2.12 comment_workflow = self.workflowTool[workflows[0]]
pass if 'spam' in comment_workflow.states:
return True
return False
def __call__(self): def allowed_transitions(self, obj=None):
self.request.set('disable_border', True) """Return allowed workflow transitions for obj.
context = aq_inner(self.context)
catalog = getToolByName(context, 'portal_catalog')
self.comments = catalog(object_provides=IComment.__identifier__,
review_state='published',
sort_on='created',
sort_order='reverse')
# print("*** approved comments") Example: pending
# print(self.comments)
# for el in self.comments: [{'id': 'mark_as_spam', 'url': 'http://localhost:8083/PloneRejected/testfolder/testpage/++conversation++default/1575415863542780/content_status_modify?workflow_action=mark_as_spam', 'icon': '', 'category': 'workflow', 'transition': <TransitionDefinition at /PloneRejected/portal_workflow/comment_review_workflow/transitions/mark_as_spam>, 'title': 'Spam', 'link_target': None, 'visible': True, 'available': True, 'allowed': True},
# print(el.id, el.review_state) {'id': 'publish',
return self.template() 'url': 'http://localhost:8083/PloneRejected/testfolder/testpage/++conversation++default/1575415863542780/content_status_modify?workflow_action=publish',
'icon': '',
'category': 'workflow',
'transition': <TransitionDefinition at /PloneRejected/portal_workflow/comment_review_workflow/transitions/publish>,
'title': 'Approve',
'link_target': None, 'visible': True, 'available': True, 'allowed': True},
{'id': 'reject', 'url': 'http://localhost:8083/PloneRejected/testfolder/testpage/++conversation++default/1575415863542780/content_status_modify?workflow_action=reject', 'icon': '', 'category': 'workflow', 'transition': <TransitionDefinition at /PloneRejected/portal_workflow/comment_review_workflow/transitions/reject>, 'title': 'Reject', 'link_target': None, 'visible': True, 'available': True, 'allowed': True}]
"""
if obj:
transitions = [
a for a in self.workflowTool.listActionInfos(object=obj)
if a['category'] == 'workflow' and a['allowed']
]
return transitions
class ModerateCommentsEnabled(BrowserView): class ModerateCommentsEnabled(BrowserView):
@ -184,57 +236,61 @@ class DeleteOwnComment(DeleteComment):
raise Unauthorized("You're not allowed to delete this comment.") raise Unauthorized("You're not allowed to delete this comment.")
class PublishComment(BrowserView): class CommentTransition(BrowserView):
"""Publish a comment. r"""Publish, reject, recall a comment or mark it as spam.
This view is always called directly on the comment object: This view is always called directly on the comment object:
http://nohost/front-page/++conversation++default/1286289644723317/\ http://nohost/front-page/++conversation++default/1286289644723317/\
@@moderate-publish-comment @@transmit-comment
Each table row (comment) in the moderation view contains a hidden input Each table row (comment) in the moderation view contains a hidden input
field with the absolute URL of the content object: field with the absolute URL of the content object:
<input type="hidden" <input type="hidden"
value="http://nohost/front-page/++conversation++default/\ value="http://nohost/front-page/++conversation++default/\
1286289644723317" 1286289644723317"
name="selected_obj_paths:list"> name="selected_obj_paths:list">
This absolute URL is called from a jQuery method that is bind to the This absolute URL is called from a jQuery method that is bind to the
'delete' button of the table row. See javascripts/moderation.js for more 'delete' button of the table row. See javascripts/moderation.js for more
details. details.
""" """
def __call__(self): def __call__(self):
"""Call CommentTransition."""
comment = aq_inner(self.context) comment = aq_inner(self.context)
content_object = aq_parent(aq_parent(comment)) content_object = aq_parent(aq_parent(comment))
workflowTool = getToolByName(comment, 'portal_workflow', None)
workflow_action = self.request.form.get('workflow_action', 'publish') workflow_action = self.request.form.get('workflow_action', 'publish')
review_state = workflowTool.getInfoFor(comment, 'review_state', '') api.content.transition(comment, transition=workflow_action)
if review_state == "pending": comment.reindexObject()
workflowTool.doActionFor(comment, workflow_action) content_object.reindexObject(idxs=['total_comments'])
comment.reindexObject() notify(CommentPublishedEvent(self.context, comment))
content_object.reindexObject(idxs=['total_comments']) # for complexer workflows:
notify(CommentPublishedEvent(self.context, comment)) notify(CommentTransitionEvent(self.context, comment))
IStatusMessage(self.context.REQUEST).addStatusMessage( review_state_new = api.content.get_state(comment, '')
_('Comment approved.'),
type='info') comment_state_translated = self.context.restrictedTraverse("translationhelper").translate_comment_review_state(review_state_new)
else:
IStatusMessage(self.context.REQUEST).addStatusMessage( msgid = _(
_('Comment already approved.'), "comment_transmitted",
type='info') default='Comment ${comment_state_translated}.',
mapping={"comment_state_translated": comment_state_translated})
translated = self.context.translate(msgid)
api.portal.show_message(translated, self.context.REQUEST)
came_from = self.context.REQUEST.HTTP_REFERER came_from = self.context.REQUEST.HTTP_REFERER
# if the referrer already has a came_from in it, don't redirect back # 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 if (len(came_from) == 0
not getToolByName( or 'came_from=' in came_from
content_object, 'portal_url').isURLInPortal(came_from) or or not getToolByName(
'@@confirm-action' in came_from): content_object, 'portal_url').isURLInPortal(came_from)):
came_from = content_object.absolute_url() came_from = content_object.absolute_url()
return self.context.REQUEST.RESPONSE.redirect(came_from) return self.context.REQUEST.RESPONSE.redirect(came_from)
class BulkActionsView(BrowserView): class BulkActionsView(BrowserView):
"""Bulk actions (unapprove, approve, delete, 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 Each table row of the moderation view has a checkbox with the absolute
path (without host and port) of the comment objects: path (without host and port) of the comment objects:
@ -256,7 +312,12 @@ class BulkActionsView(BrowserView):
""" """
def __init__(self, context, request):
super(BulkActionsView, self).__init__(context, request)
self.workflowTool = getToolByName(context, 'portal_workflow')
def __call__(self): def __call__(self):
"""Call BulkActionsView."""
if 'form.select.BulkAction' in self.request: if 'form.select.BulkAction' in self.request:
bulkaction = self.request.get('form.select.BulkAction') bulkaction = self.request.get('form.select.BulkAction')
self.paths = self.request.get('paths') self.paths = self.request.get('paths')
@ -264,50 +325,40 @@ class BulkActionsView(BrowserView):
if bulkaction == '-1': if bulkaction == '-1':
# no bulk action was selected # no bulk action was selected
pass pass
elif bulkaction == 'retract':
self.retract()
elif bulkaction == 'publish':
self.publish()
elif bulkaction == 'mark_as_spam':
self.mark_as_spam()
elif bulkaction == 'delete': elif bulkaction == 'delete':
self.delete() self.delete()
else: else:
raise KeyError # pragma: no cover self.transmit(bulkaction)
def retract(self): def transmit(self, action=None):
raise NotImplementedError """Transmit all comments in the paths variable to requested review_state.
def publish(self): Expects a list of absolute paths (without host and port):
"""Publishes all comments in the paths variable.
Expects a list of absolute paths (without host and port):
/Plone/startseite/++conversation++default/1286200010610352
/Plone/startseite/++conversation++default/1286200010610352
""" """
context = aq_inner(self.context) context = aq_inner(self.context)
for path in self.paths: for path in self.paths:
comment = context.restrictedTraverse(path) comment = context.restrictedTraverse(path)
content_object = aq_parent(aq_parent(comment)) content_object = aq_parent(aq_parent(comment))
workflowTool = getToolByName(comment, 'portal_workflow') allowed_transitions = [
current_state = workflowTool.getInfoFor(comment, 'review_state') transition['id'] for transition in self.workflowTool.listActionInfos(object=comment)
if current_state != 'published': if transition['category'] == 'workflow' and transition['allowed']
workflowTool.doActionFor(comment, 'publish') ]
comment.reindexObject() if action in allowed_transitions:
content_object.reindexObject(idxs=['total_comments']) self.workflowTool.doActionFor(comment, action)
notify(CommentPublishedEvent(content_object, comment)) comment.reindexObject()
content_object.reindexObject(idxs=['total_comments'])
def mark_as_spam(self): notify(CommentPublishedEvent(content_object, comment))
raise NotImplementedError # for complexer workflows:
notify(CommentTransitionEvent(self.context, comment))
def delete(self): 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): Expects a list of absolute paths (without host and port):
/Plone/startseite/++conversation++default/1286200010610352
/Plone/startseite/++conversation++default/1286200010610352
""" """
context = aq_inner(self.context) context = aq_inner(self.context)
for path in self.paths: 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 IDiscussionEvent
from plone.app.discussion.interfaces import ICommentDeletedEvent from plone.app.discussion.interfaces import ICommentDeletedEvent
from plone.app.discussion.interfaces import ICommentPublishedEvent 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 IReplyAddedEvent
from plone.app.discussion.interfaces import IReplyRemovedEvent from plone.app.discussion.interfaces import IReplyRemovedEvent
from zope.interface import implementer from zope.interface import implementer
@ -62,3 +63,8 @@ class CommentDeletedEvent(DiscussionEvent):
class CommentPublishedEvent(DiscussionEvent): class CommentPublishedEvent(DiscussionEvent):
""" Event to be triggered when a Comment is publicated """ 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): class ICommentDeletedEvent(IDiscussionEvent):
""" Notify user on comment delete """ Notify user on comment delete
""" """
class ICommentTransitionEvent(IDiscussionEvent):
"""Notify user on comment transition / change of review_state."""

View File

@ -1,5 +1,5 @@
<metadata> <metadata>
<version>1001</version> <version>1002</version>
<dependencies> <dependencies>
<dependency>profile-plone.resource:default</dependency> <dependency>profile-plone.resource:default</dependency>
<dependency>profile-plone.app.registry:default</dependency> <dependency>profile-plone.app.registry:default</dependency>

View File

@ -7,125 +7,130 @@
state_variable="review_state" state_variable="review_state"
initial_state="pending" initial_state="pending"
i18n:attributes="title; description"> i18n:attributes="title; description">
<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="mark_as_spam"/>
<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="mark_as_spam"/>
<exit-transition transition_id="recall"/>
<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="Rejected">
<exit-transition transition_id="mark_as_spam"/>
<exit-transition transition_id="publish"/>
<exit-transition transition_id="recall"/>
</state>
<state state_id="spam" title="Spam">
<exit-transition transition_id="publish"/>
<exit-transition transition_id="recall"/>
<exit-transition transition_id="reject"/>
</state>
<transition transition_id="mark_as_spam" title="Mark as spam" new_state="spam" trigger="USER" before_script="" after_script="">
<description>Spam comments are invisible to other users.</description>
<action url="%(content_url)s/content_status_modify?workflow_action=mark_as_spam" category="workflow" icon="">Spam</action>
<guard>
<guard-permission>Review comments</guard-permission>
</guard>
</transition>
<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="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="">Recall</action>
<guard>
<guard-permission>Review comments</guard-permission>
</guard>
</transition>
<transition transition_id="reject" title="Reviewer rejects comment" new_state="rejected" trigger="USER" before_script="" after_script="">
<description>Rejected comments are invisible to other users.</description>
<action url="%(content_url)s/content_status_modify?workflow_action=reject" 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>
<permission>Access contents information</permission> <expression>transition/getId|nothing</expression>
<permission>Modify portal content</permission> </default>
<permission>View</permission> <guard>
<permission>Reply to item</permission> </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>
<state state_id="pending" title="Pending" i18n:attributes="title"> <expression>user/getUserName</expression>
<exit-transition transition_id="publish"/> </default>
<description i18n:translate=""> <guard>
Submitted, pending review. </guard>
</description> </variable>
<permission-map name="Access contents information" acquired="False"> <variable variable_id="comments" for_catalog="False" for_status="True" update_always="True">
<permission-role>Manager</permission-role> <description>Comment about the last transition</description>
<permission-role>Owner</permission-role> <default>
<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="View" 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>
</state>
<state state_id="published" title="Published" i18n:attributes="title"> <expression>python:state_change.kwargs.get('comment', '')</expression>
<description i18n:translate=""> </default>
Visible to everyone, non-editable. <guard>
</description> </guard>
<permission-map name="Access contents information" acquired="True"> </variable>
</permission-map> <variable variable_id="review_history" for_catalog="False" for_status="False" update_always="False">
<permission-map name="Modify portal content" acquired="False"> <description>Provides access to workflow history</description>
<permission-role>Manager</permission-role> <default>
</permission-map>
<permission-map name="View" acquired="True">
</permission-map>
<permission-map name="Reply to item" acquired="True">
</permission-map>
</state>
<transition transition_id="publish" new_state="published" <expression>state_change/getHistory</expression>
title="Reviewer approves content" </default>
trigger="USER" <guard>
before_script="" after_script="" <guard-permission>Request review</guard-permission>
i18n:attributes="title"> <guard-permission>Review portal content</guard-permission>
<description i18n:translate=""> </guard>
Approving the comment makes it visible to other users. </variable>
</description> <variable variable_id="time" for_catalog="False" for_status="True" update_always="True">
<action url="%(content_url)s/content_status_modify?workflow_action=publish" <description>When the previous transition was performed</description>
category="workflow" <default>
i18n:translate="">Approve</action>
<guard>
<guard-permission>Review comments</guard-permission>
</guard>
</transition>
<variable variable_id="action" for_catalog="False" <expression>state_change/getDateTime</expression>
for_status="True" update_always="True"> </default>
<description i18n:translate="">Previous transition</description> <guard>
<default> </guard>
<expression>transition/getId|nothing</expression> </variable>
</default> </dc-workflow>
<guard>
</guard>
</variable>
<variable variable_id="actor" for_catalog="False"
for_status="True" update_always="True">
<description i18n:translate="">
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 i18n:translate="">
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 i18n:translate="">
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 i18n:translate="">
When the previous transition was performed
</description>
<default>
<expression>state_change/getDateTime</expression>
</default>
<guard>
</guard>
</variable>
</dc-workflow>

View File

@ -112,7 +112,7 @@ Administrators can see all posts and comment actions
>>> 'form.button.DeleteComment' in browser.contents >>> 'form.button.DeleteComment' in browser.contents
True True
>>> 'form.button.PublishComment' in browser.contents >>> 'form.button.TransmitComment' in browser.contents
True True
Anonymous user can not see any posts or comment actions Anonymous user can not see any posts or comment actions
@ -128,7 +128,7 @@ Anonymous user can not see any posts or comment actions
>>> 'form.button.DeleteComment' in unprivileged_browser.contents >>> 'form.button.DeleteComment' in unprivileged_browser.contents
False False
>>> 'form.button.PublishComment' in unprivileged_browser.contents >>> 'form.button.TransmitComment' in unprivileged_browser.contents
False False
The catalog does not list the comments yet: The catalog does not list the comments yet:
@ -156,7 +156,7 @@ actions.
>>> 'form.button.DeleteComment' in browser.contents >>> 'form.button.DeleteComment' in browser.contents
True True
>>> 'form.button.PublishComment' in browser.contents >>> 'form.button.TransmitComment' in browser.contents
True True
@ -176,7 +176,7 @@ flaw? Though, the comment is published properly.
>>> browser.raiseHttpErrors = False >>> browser.raiseHttpErrors = False
>>> browser.getControl('Approve', index=0).click() >>> browser.getControl('Approve', index=0).click()
>>> 'Comment approved' in browser.contents >>> 'Comment published' in browser.contents
True True
>>> browser.handleErrors = False >>> browser.handleErrors = False

View File

@ -284,7 +284,7 @@ Open the edit comment view
Change and save the comment Change and save the comment
>>> ctrl.value = 'Comment from admin / was edited' >>> ctrl.value = 'Comment from admin / was edited'
>>> browser.getControl('Edit comment').click() >>> browser.getControl('Save').click()
This used to trigger permissions problems in some portlet configurations. This used to trigger permissions problems in some portlet configurations.
Check it ain't so. Check it ain't so.
@ -325,19 +325,19 @@ Deleting existing comments | 'Delete comments' permission
Anonymous cannot delete comments Anonymous cannot delete comments
>>> unprivileged_browser.open(urldoc1) >>> unprivileged_browser.open(urldoc1)
>>> 'form.button.Delete' in unprivileged_browser.contents >>> 'form.button.DeleteComment' in unprivileged_browser.contents
False False
A member cannot delete his own comments if he can't review or he isn't a Site Administrator A member cannot delete his own comments if he can't review or he isn't a Site Administrator
>>> browser_member.open(urldoc1) >>> browser_member.open(urldoc1)
>>> 'form.button.Delete' in browser_member.contents >>> 'form.button.DeleteComment' in browser_member.contents
False False
Admin can delete comments Admin can delete comments
>>> browser.open(urldoc1) >>> browser.open(urldoc1)
>>> 'form.button.Delete' in browser.contents >>> 'form.button.DeleteComment' in browser.contents
True True
Extract the delete comment url from the first "delete comment" button Extract the delete comment url from the first "delete comment" button

View File

@ -0,0 +1,84 @@
*** 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
Last history entry is shown
Given a logged-in Site Administrator
and workflow multiple enabled
and a document with discussion enabled
When I add a comment
Then I can see the last history entry in moderation view
*** 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
Wait until page contains element id=form-widgets-comment-text
Input Text id=form-widgets-comment-text This is a comment
Click Button Comment
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
Wait until page contains element name=form.select.BulkAction
Select from list by value xpath://select[@name='form.select.BulkAction'] delete
Select Checkbox name=check_all
Click Button Apply
Wait Until Page Does Not Contain This is a comment
workflow multiple enabled
Go To ${PLONE_URL}/@@content-controlpanel?type_id=Discussion%20Item&new_workflow=comment_review_workflow
Click Button Save
# Then
I can not see the comment below the document
Go To ${PLONE_URL}/my-document/view
Wait until page contains My Document
Page should not contain This is a comment
I can see the last history entry in moderation view
Go To ${PLONE_URL}/@@moderate-comments?review_state=all
Wait until page contains element name=form.select.BulkAction
Page should contain Create

View File

@ -599,10 +599,10 @@ class TestCommentsViewlet(unittest.TestCase):
self.assertTrue('actions' in reply) self.assertTrue('actions' in reply)
self.assertEqual( self.assertEqual(
reply['actions'][0]['id'], reply['actions'][0]['id'],
'publish', 'mark_as_spam',
) )
expected_url = 'http://nohost/plone/doc1/++conversation++default/{0}' \ expected_url = 'http://nohost/plone/doc1/++conversation++default/{0}' \
'/content_status_modify?workflow_action=publish' '/content_status_modify?workflow_action=mark_as_spam'
self.assertEqual( self.assertEqual(
reply['actions'][0]['url'], reply['actions'][0]['url'],
expected_url.format(int(c1)), expected_url.format(int(c1)),

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_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

@ -1,7 +1,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from plone.app.discussion.browser.moderation import BulkActionsView from plone.app.discussion.browser.moderation import BulkActionsView
from plone.app.discussion.browser.moderation import DeleteComment from plone.app.discussion.browser.moderation import DeleteComment
from plone.app.discussion.browser.moderation import PublishComment from plone.app.discussion.browser.moderation import CommentTransition
from plone.app.discussion.browser.moderation import View from plone.app.discussion.browser.moderation import View
from plone.app.discussion.interfaces import IConversation from plone.app.discussion.interfaces import IConversation
from plone.app.discussion.interfaces import IDiscussionSettings from plone.app.discussion.interfaces import IDiscussionSettings
@ -110,14 +110,6 @@ class ModerationBulkActionsViewTest(unittest.TestCase):
self.assertFalse(view()) 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): def test_publish(self):
self.request.set('form.select.BulkAction', 'publish') self.request.set('form.select.BulkAction', 'publish')
self.request.set('paths', ['/'.join(self.comment1.getPhysicalPath())]) self.request.set('paths', ['/'.join(self.comment1.getPhysicalPath())])
@ -135,15 +127,6 @@ class ModerationBulkActionsViewTest(unittest.TestCase):
# Make sure the comment has been published # Make sure the comment has been published
self.assertEqual(published_comments, 1) 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): def test_delete(self):
# Initially we have three comments # Initially we have three comments
self.assertEqual(len(self.conversation.objectIds()), 3) self.assertEqual(len(self.conversation.objectIds()), 3)
@ -195,14 +178,14 @@ class RedirectionTest(unittest.TestCase):
def test_regression(self): def test_regression(self):
page_url = self.page.absolute_url() page_url = self.page.absolute_url()
self.request['HTTP_REFERER'] = page_url self.request['HTTP_REFERER'] = page_url
for Klass in (DeleteComment, PublishComment): for Klass in (DeleteComment, CommentTransition):
view = Klass(self.comment, self.request) view = Klass(self.comment, self.request)
view.__parent__ = self.comment view.__parent__ = self.comment
self.assertEqual(page_url, view()) self.assertEqual(page_url, view())
def test_valid_next_url(self): def test_valid_next_url(self):
self.request['HTTP_REFERER'] = 'http://attacker.com' self.request['HTTP_REFERER'] = 'http://attacker.com'
for Klass in (DeleteComment, PublishComment): for Klass in (DeleteComment, CommentTransition):
view = Klass(self.comment, self.request) view = Klass(self.comment, self.request)
view.__parent__ = self.comment view.__parent__ = self.comment
self.assertNotEqual('http://attacker.com', view()) self.assertNotEqual('http://attacker.com', view())

View File

@ -272,7 +272,7 @@ class CommentReviewWorkflowTest(unittest.TestCase):
'review_state', 'review_state',
), ),
) )
view = self.comment.restrictedTraverse('@@moderate-publish-comment') view = self.comment.restrictedTraverse('@@transmit-comment')
view() view()
self.assertEqual( self.assertEqual(
'published', 'published',
@ -295,7 +295,7 @@ class CommentReviewWorkflowTest(unittest.TestCase):
self.assertRaises( self.assertRaises(
Unauthorized, Unauthorized,
self.comment.restrictedTraverse, self.comment.restrictedTraverse,
'@@moderate-publish-comment', '@@transmit-comment',
) )
self.assertEqual( self.assertEqual(
'pending', 'pending',

View File

@ -20,7 +20,7 @@ def update_rolemap(context):
context.runImportStepFromProfile(default_profile, 'rolemap') context.runImportStepFromProfile(default_profile, 'rolemap')
def upgrade_comment_workflows(context): def upgrade_comment_workflows_retain_current_workflow(context):
# If the current comment workflow is the one_state_workflow, running our # If the current comment workflow is the one_state_workflow, running our
# import step will change it to comment_one_state_workflow. This is good. # import step will change it to comment_one_state_workflow. This is good.
# If it was anything else, we should restore this. So get the original # If it was anything else, we should restore this. So get the original
@ -46,13 +46,16 @@ def upgrade_comment_workflows(context):
orig_chain[idx] = 'comment_one_state_workflow' orig_chain[idx] = 'comment_one_state_workflow'
# Restore the chain. # Restore the chain.
wf_tool.setChainForPortalTypes([portal_type], orig_chain) wf_tool.setChainForPortalTypes([portal_type], orig_chain)
new_chain = list(wf_tool.getChainFor(portal_type))
workflows = [wf_tool.getWorkflowById(wf_id)
for wf_id in new_chain]
def upgrade_comment_workflows_apply_rolemapping(context):
# Now go over the comments, update their role mappings, and reindex the # Now go over the comments, update their role mappings, and reindex the
# allowedRolesAndUsers index. # allowedRolesAndUsers index.
portal_type = 'Discussion Item'
catalog = getToolByName(context, 'portal_catalog') catalog = getToolByName(context, 'portal_catalog')
wf_tool = getToolByName(context, 'portal_workflow')
new_chain = list(wf_tool.getChainFor(portal_type))
workflows = [wf_tool.getWorkflowById(wf_id) for wf_id in new_chain]
for brain in catalog.unrestrictedSearchResults(portal_type=portal_type): for brain in catalog.unrestrictedSearchResults(portal_type=portal_type):
try: try:
comment = brain.getObject() comment = brain.getObject()
@ -63,5 +66,15 @@ def upgrade_comment_workflows(context):
logger.info('Could not reindex comment {0}'.format(brain.getURL())) logger.info('Could not reindex comment {0}'.format(brain.getURL()))
def upgrade_comment_workflows(context):
upgrade_comment_workflows_retain_current_workflow(context)
upgrade_comment_workflows_apply_rolemapping(context)
def add_js_to_plone_legacy(context): def add_js_to_plone_legacy(context):
context.runImportStepFromProfile(default_profile, 'plone.app.registry') context.runImportStepFromProfile(default_profile, 'plone.app.registry')
def extend_review_workflow(context):
"""Apply changes made to review workflow."""
upgrade_comment_workflows_retain_current_workflow(context)

View File

@ -62,4 +62,15 @@
/> />
</genericsetup:upgradeSteps> </genericsetup:upgradeSteps>
<genericsetup:upgradeSteps
source="1001"
destination="1002"
profile="plone.app.discussion:default">
<genericsetup:upgradeStep
title="Extended review workflow with states pending, published and new: rejected and spam"
description="Additional states allows moderator to review history of publishing and rejection"
handler=".upgrades.extend_review_workflow"
/>
</genericsetup:upgradeSteps>
</configure> </configure>