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)
@button.buttonAndHandler(_(u'edit_comment_form_button',
default=u'Edit comment'), name='comment')
default=u'Save'), name='comment')
def handleComment(self, action):
# Validate form

View File

@ -32,6 +32,7 @@
<div class="comment"
tal:define="reply reply_dict/comment;
comment_id reply/getId;
depth reply_dict/depth|python:0;
depth python: depth > 10 and '10' or depth;
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);
review_state python:wtool.getInfoFor(reply, 'review_state', 'none');
canEdit python:view.can_edit(reply);
canDelete python:view.can_delete(reply)"
tal:attributes="class python:'comment replyTreeLevel'+str(depth)+' state-'+str(review_state);
id string:${reply/getId}"
canDelete python:view.can_delete(reply);
colorclass python:lambda x: 'state-private' if x=='rejected' else ('state-internal' if x=='spam' else 'state-'+x);"
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'">
<div class="commentImage" tal:condition="showCommenterImage">
@ -94,7 +96,8 @@
class="commentactionsform"
tal:condition="python:not canDelete and isDeleteOwnCommentAllowed and view.could_delete_own(reply)"
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"
class="destructive"
type="submit"
@ -107,7 +110,8 @@
method="post"
class="commentactionsform"
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"
class="destructive"
type="submit"
@ -119,7 +123,7 @@
<tal:edit tal:condition="python:isEditCommentAllowed and canEdit">
<!-- plone 5 will have auth_token available
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:attributes="href string:${reply/absolute_url}/@@edit-comment?_authenticator=${auth_token}"
i18n:translate="Edit">Edit</a>
@ -128,7 +132,8 @@
method="get"
class="commentactionsform"
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"
class="context"
type="submit"
@ -146,10 +151,11 @@
class="commentactionsform"
tal:condition="canReview"
tal:repeat="action reply_dict/actions|nothing"
tal:attributes="action string:${reply/absolute_url}/@@moderate-publish-comment;
name action/id">
tal:attributes="action string:${reply/absolute_url}/@@transmit-comment;
name action/id;
id string:${action/id}-${comment_id}">
<input type="hidden" name="workflow_action" tal:attributes="value action/id" />
<input name="form.button.PublishComment"
<input name="form.button.TransmitComment"
class="context"
type="submit"
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"
/>
<!-- 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 -->
<browser:page
for="Products.CMFCore.interfaces.ISiteRoot"
@ -108,15 +91,24 @@
permission="plone.app.discussion.DeleteOwnComments"
/>
<!-- Publish comment view -->
<!-- Comment Transition -->
<browser:page
for="plone.app.discussion.interfaces.IComment"
name="moderate-publish-comment"
name="transmit-comment"
layer="..interfaces.IDiscussionLayer"
class=".moderation.PublishComment"
class=".moderation.CommentTransition"
permission="plone.app.discussion.ReviewComments"
/>
<browser:page
for="*"
name="translationhelper"
layer="..interfaces.IDiscussionLayer"
class=".moderation.TranslationHelper"
permission="plone.app.discussion.ReviewComments"
/>
<!-- Comments viewlet -->
<browser:viewlet
name="plone.comments"

View File

@ -177,15 +177,15 @@ class DiscussionSettingsControlPanel(controlpanel.ControlPanelFormWrapper):
return True
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)
workflow_chain = wftool.getChainForPortalType('Discussion Item')
one_state_workflow_enabled = \
'comment_one_state_workflow' in workflow_chain
comment_review_workflow_enabled = \
'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 True

View File

@ -5,17 +5,19 @@
******************************************************************************/
/* global require */
if(require === undefined){
require = function(reqs, torun){ // jshint ignore:line
'use strict';
if (require === undefined) {
require = function(reqs, torun) {
// jshint ignore:line
"use strict";
return torun(window.jQuery);
};
}
require([ // jshint ignore:line
'jquery'
], function ($) {
'use strict';
require([
// jshint ignore:line
"jquery"
], function($) {
"use strict";
// This unnamed function allows us to use $ inside of a block of code
// without permanently overwriting $.
@ -25,161 +27,115 @@ require([ // jshint ignore:line
* the function. We do this by copying the regular comment form and
* 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);
var reply_div = $("#commenting").clone(true);
/* Remove the ReCaptcha JS code before appending the form. If not
* removed, this causes problems
*/
reply_div.find('#formfield-form-widgets-captcha')
.find('script')
reply_div
.find("#formfield-form-widgets-captcha")
.find("script")
.remove();
/* Insert the cloned comment form right after the reply button of the
* current comment.
*/
reply_div.appendTo(comment_div).css('display', 'none');
reply_div.appendTo(comment_div).css("display", "none");
/* Remove id='commenting' attribute, since we use it to uniquely define
the main reply form. */
// 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
* 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 */
var reply_form = reply_div.find('form');
var reply_form = reply_div.find("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
* */
reply_form.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 );
reply_form
.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
id */
reply_form.find('input[name="form.widgets.in_reply_to"]')
.val(comment_id);
reply_form.find('input[name="form.widgets.in_reply_to"]').val(comment_id);
/* Add a remove-reply-to-comment Javascript function to remove the
form */
var cancel_reply_button = reply_div.find('.cancelreplytocomment');
cancel_reply_button.attr('id', comment_id);
var cancel_reply_button = reply_div.find(".cancelreplytocomment");
cancel_reply_button.attr("id", comment_id);
/* Show the cancel buttons. */
reply_form.find('input[name="form.buttons.cancel"]')
.css('display', 'inline');
reply_form
.find('input[name="form.buttons.cancel"]')
.css("display", "inline");
/* 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 */
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', '');
$.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. */
};
//#JSCOVERAGE_IF 0
/**************************************************************************
* Window Load Function: Executes when complete page is fully loaded,
* including all frames,
**************************************************************************/
$(window).load(function () {
function init_comment_eventhandler () {
/**********************************************************************
* 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
* request), create a reply-to-comment form right under this comment.
* Transmit a single comment.
**********************************************************************/
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() !== '') {
var current_reply_id = '#' + in_reply_to_field.val();
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);
});
/**********************************************************************
* 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) {
$('input[name="form.button.TransmitComment"]').on("click", function(e) {
e.preventDefault();
var reply_to_comment_button = $(this).
parents().
filter('.comment').
find('.reply-to-comment-button');
/* 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');
});
/**********************************************************************
* Publish a single comment.
**********************************************************************/
$('input[name="form.button.PublishComment"]').on('click', function () {
var trigger = this;
var form = $(this).parents('form');
var form = $(this).parents("form");
var data = $(form).serialize();
var form_url = $(form).attr('action');
var form_url = $(form).attr("action");
var comment_id = $(this).parents(".comment").attr("id");
$.ajax({
type: 'GET',
type: "GET",
url: form_url,
data: data,
context: trigger,
success: function (msg) { // jshint ignore:line
// 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');
success: function(msg) {
let url = location.href;
$(this).parents(".comment").load(
// loading child nodes is not enough,
// class attributes are needed for visualization of workflow_state
url + " #" + comment_id + ".comment",
function() {
$(this).find(".comment").unwrap();
init_comment_eventhandler();
$(".pat-plone-modal").patPloneModal();
}
);
},
error: function (msg) { // jshint ignore:line
error: function(msg) {
return true;
}
});
@ -189,79 +145,139 @@ require([ // jshint ignore:line
/**********************************************************************
* Edit a comment
**********************************************************************/
if($.fn.prepOverlay){
if ($.fn.prepOverlay) {
$('form[name="edit"]').prepOverlay({
cssclass: 'overlay-edit-comment',
width: '60%',
subtype: 'ajax',
filter: '#content>*'
cssclass: "overlay-edit-comment",
width: "60%",
subtype: "ajax",
filter: "#content>*"
});
}
/**********************************************************************
* Delete a comment and its answers.
**********************************************************************/
$('input[name="form.button.DeleteComment"]').on('click', function () {
$('input[name="form.button.DeleteComment"]').on("click", function(e) {
e.preventDefault();
var trigger = this;
var form = $(this).parents('form');
var form = $(this).parents("form");
var data = $(form).serialize();
var form_url = $(form).attr('action');
var form_url = $(form).attr("action");
$.ajax({
type: 'POST',
type: "POST",
url: form_url,
data: data,
context: $(trigger).parents('.comment'),
success: function (data) { // jshint ignore:line
context: $(trigger).parents(".comment"),
success: function(data) {
// jshint ignore:line
var comment = $(this);
var clss = comment.attr('class');
var clss = comment.attr("class");
// remove replies
var treelevel = parseInt(clss[clss.indexOf('replyTreeLevel') + 'replyTreeLevel'.length], 10);
var treelevel = parseInt(
clss[clss.indexOf("replyTreeLevel") + "replyTreeLevel".length],
10
);
// selector for all the following elements of lower level
var selector = '.replyTreeLevel' + treelevel;
var selector = ".replyTreeLevel" + treelevel;
for (var i = 0; i < treelevel; i++) {
selector += ', .replyTreeLevel' + i;
selector += ", .replyTreeLevel" + i;
}
comment.nextUntil(selector).each(function () {
$(this).fadeOut('fast', function () {
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');
var parent = comment.prev(
'[class*="replyTreeLevel' + (treelevel - 1) + '"]'
);
parent.find('form[name="delete"]').css("display", "inline");
// remove comment
$(this).fadeOut('fast', function () {
$(this).fadeOut("fast", function() {
$(this).remove();
});
},
error: function (req, error) { // jshint ignore:line
error: function(req, error) {
// jshint ignore:line
return true;
}
});
return false;
});
};
$(document).ready(function() {
init_comment_eventhandler();
/**********************************************************************
* 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
* request), create a reply-to-comment form right under this comment.
**********************************************************************/
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() !== "") {
var current_reply_id = "#" + in_reply_to_field.val();
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);
});
/**********************************************************************
* 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");
/* 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');
$(".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');
$(".reply-to-comment-button").removeClass("hide");
});
//#JSCOVERAGE_ENDIF
});

View File

@ -1,49 +1,41 @@
/******************************************************************************
*
* 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){
require = function(reqs, torun){ // jshint ignore:line
'use strict';
if (require === undefined) {
require = function(reqs, torun) {
"use strict";
return torun(window.jQuery);
};
}
require([ // jshint ignore:line
'jquery'
], 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
require(["jquery", "pat-registry"], function($, registry) {
"use strict";
//#JSCOVERAGE_IF 0
/**************************************************************************
* Document Ready Function: Executes when DOM is ready.
**************************************************************************/
$(document).ready(function () {
$(document).ready(function() {
init_moderation_eventhandler();
});
function init_moderation_eventhandler() {
/**********************************************************************
* Delete a single comment.
**********************************************************************/
$("input[name='form.button.Delete']").click(function (e) {
$("input[name='form.button.moderation.DeleteComment']").click(function(e) {
e.preventDefault();
var row = $(this).parent().parent();
var path = $(row).find("[name='selected_obj_paths:list']").attr("value");
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;
var target =
path + "/@@moderate-delete-comment?_authenticator=" + auth_key;
$.ajax({
type: "GET",
url: target,
success: function (msg) { // jshint ignore:line
success: function(msg) {
// fade out row
$(row).fadeOut("normal", function () {
$(this).remove();
row.fadeOut(250).fadeIn(250, function() {
row.remove();
});
// reload page if all comments have been removed
var comments = $("table#review-comments > tbody > tr");
@ -51,132 +43,186 @@ require([ // jshint ignore:line
location.reload();
}
},
error: function (msg) { // jshint ignore:line
error: function(msg) {
alert("Error sending AJAX request:" + target);
}
});
});
/**********************************************************************
* Publish a single comment.
* Transmit a single comment.
**********************************************************************/
$("input[name='form.button.Publish']").click(function (e) {
$('input[name="form.button.moderation.TransmitComment"]').click(function(
e
) {
e.preventDefault();
var row = $(this).parent().parent();
var path = $(row).find("[name='selected_obj_paths:list']").attr("value");
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();
var target = path + "/@@moderate-publish-comment?_authenticator=" + auth_key;
// 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) { // 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) {
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) { // jshint ignore:line
alert("Error sending AJAX request:" + target);
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) {
$("input[name='form.button.BulkAction']").click(function(e) {
e.preventDefault();
var form = $(this).parents("form");
var target = $(form).attr('action');
var form = $(this).closest("form");
var target = $(form).attr("action");
var params = $(form).serialize();
var valArray = $('input:checkbox:checked');
var valArray = $("input:checkbox:checked");
var selectField = $(form).find("[name='form.select.BulkAction']");
if (selectField.val() === '-1') {
// XXX: translate message
if (selectField.val() === "-1") {
// TODO: 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.");
// 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) { // 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();
}
});
$.post(target, params, function(data) {
// reset the bulkaction select
selectField.find("option[value='-1']").attr('selected', 'selected');
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")
$("input[name='check_all']").click(function() {
if ($(this).val() === "0") {
$(this)
.parents("table")
.find("input:checkbox")
.attr("checked", "checked");
.prop("checked", true);
$(this).val("1");
} else {
$(this).parents("table")
$(this)
.parents("table")
.find("input:checkbox")
.attr("checked", "");
.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) {
$(".show-full-comment-text").click(function(e) {
e.preventDefault();
var target = $(this).attr("href");
var td = $(this).parent();
var parent = $(this).parent();
$.ajax({
type: "GET",
url: target,
data: "",
success: function (data) {
success: function(data) {
// show full text
td.replaceWith("<td>" + data + "</td>");
parent.html(data);
},
error: function (msg) { // jshint ignore:line
error: function(msg) {
alert("Error getting full comment text:" + target);
}
});
});
/**********************************************************************
* Comments approved: Load history for approved date.
* Comments published: Load history for publishing date.
**********************************************************************/
$(".last-history-entry").each(function() {
$(this).load($(this).attr("data-href") + " .historyByLine", function() {
$(this).children(".historyByLine").last().remove();
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.");
}
});
});
});
//#JSCOVERAGE_ENDIF
} // end init_moderation_eventhandler
});

View File

@ -7,31 +7,39 @@
i18n:domain="plone">
<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">
<tal:main-macro metal:define-macro="main"
tal:define="toLocalizedTime nocall:context/@@plone/toLocalizedTime;
items view/comments;
filter view/filter|nothing;
filter request/review_state|nothing;
Batch python:modules['Products.CMFPlone'].Batch;
b_size python:30;
b_start python:0;
b_start request/b_start | b_start;
moderation_enabled view/moderation_enabled;">
moderation_enabled view/moderation_enabled;
colorclass python:lambda x: 'state-private' if x=='rejected' else ('state-internal' if x=='spam' else 'state-'+x);
translationhelper nocall:context/@@translationhelper;
">
<script type="text/javascript"
tal:attributes="src string:${context/portal_url}/++plone++plone.app.discussion.javascripts/moderation.js">
</script>
<style>
#review-comments th label {
margin-right: 1em;
}
</style>
<h1 class="documentFirstHeading" i18n:translate="heading_moderate_comments">
Moderate comments
</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"
role="status"
tal:condition="not: view/moderation_enabled">
tal:condition="not: moderation_enabled">
<strong i18n:translate="">Warning</strong>
<span tal:omit-tag="" i18n:translate="message_moderation_disabled">
Moderation workflow is disabled. You have to
@ -43,36 +51,47 @@
</span>
</div>
<form tal:condition="not:items">
<fieldset id="fieldset-moderate-comments" class="formPanel">
<p id="no-comments-message" i18n:translate="message_nothing_to_moderate">
No comments to moderate.
</p>
</fieldset>
</form>
<form method="post"
<form
method="post"
action="#"
tal:condition="moderation_enabled"
tal:attributes="action string:${context/absolute_url}/@@bulk-actions"
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 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' and moderation_enabled">Approve</option>
tal:condition="python: filter != 'published'">Approve</option>
<option value="mark_as_spam"
tal:condition="python: filter != 'spam'">Spam</option>
<option value="delete" i18n:translate="bulkactions_delete">Delete</option>
</select>
<input type="hidden" name="form.button.Filter" tal:attributes="value filter" value="" />
<input type="hidden" name="filter" tal:attributes="value filter"/>
<input id="dobulkaction"
type="submit"
class="standalone allowMultiSubmit"
@ -81,20 +100,23 @@
i18n:attributes="value label_apply;" />
</th>
</tr>
<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_action">Action</th>
<th class="nosort" i18n:translate="heading_changedby">Last Action</th>
</tr>
</thead>
<tbody>
<tal:block repeat="item batch">
<tal:block repeat="item batch"
tal:condition="items">
<tr class="commentrow"
tal:define="even repeat/item/even;
email python:getattr(item.getObject(), 'author_email');"
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"
@ -118,37 +140,63 @@
</a>
</tal:email>
</td>
<td tal:content="python:toLocalizedTime(item.created, long_format=1)"/>
<td style="white-space: nowrap;" tal:content="python:toLocalizedTime(item.created, long_format=1)"/>
<td>
<a tal:attributes="href item/getURL"
<a tal:attributes="href item_url" target="_blank"
tal:content="item/in_response_to" />
</td>
<td>
<span tal:replace="item/Description" />
<td tal:attributes="class python:colorclass(item.review_state)">
<div>
<span tal:replace="item/Description"/>
<a href=""
tal:attributes="href string:${item/getURL}/getText"
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 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'"
</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.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>
<!-- 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>
@ -157,6 +205,8 @@
<div metal:use-macro="here/batch_macros/macros/navigation" />
</fieldset>
</form>
</tal:main-macro>
</metal:main>

View File

@ -1,9 +1,11 @@
# -*- coding: utf-8 -*-
# coding: utf-8
from AccessControl import getSecurityManager
from AccessControl import Unauthorized
from Acquisition import aq_inner
from Acquisition import aq_parent
from plone import api
from plone.app.discussion.events import CommentPublishedEvent
from plone.app.discussion.events import CommentTransitionEvent
from plone.app.discussion.events import CommentDeletedEvent
from plone.app.discussion.interfaces import _
from plone.app.discussion.interfaces import IComment
@ -13,12 +15,34 @@ from Products.Five.browser import BrowserView
from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile
from Products.statusmessages.interfaces import IStatusMessage
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):
"""Comment moderation view.
"""
"""Show comment moderation view."""
template = ViewPageTemplateFile('moderation.pt')
try:
template.id = '@@moderate-comments'
@ -26,56 +50,84 @@ class View(BrowserView):
# id is not writeable in Zope 2.12
pass
def __init__(self, context, request):
super(View, self).__init__(context, request)
self.workflowTool = getToolByName(self.context, 'portal_workflow')
self.transitions = []
def __call__(self):
self.request.set('disable_border', True)
context = aq_inner(self.context)
catalog = getToolByName(context, 'portal_catalog')
self.comments = catalog(object_provides=IComment.__identifier__,
review_state='pending',
sort_on='created',
sort_order='reverse')
self.request.set('review_state',
self.request.get('review_state', 'pending'))
return self.template()
def moderation_enabled(self):
"""Returns true if a 'review workflow' is enabled on 'Discussion Item'
content type. A 'review workflow' is characterized by implementing
a 'pending' workflow state.
def comments(self):
"""Return comments of defined review_state.
review_state is string or list of strings.
"""
context = aq_inner(self.context)
workflowTool = getToolByName(context, 'portal_workflow')
comment_workflow = workflowTool.getChainForPortalType(
catalog = getToolByName(self.context, 'portal_catalog')
if self.request.review_state == 'all':
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')
if comment_workflow:
comment_workflow = comment_workflow[0]
comment_workflow = workflowTool[comment_workflow]
if workflows:
comment_workflow = self.workflowTool[workflows[0]]
if 'pending' in comment_workflow.states:
return True
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):
"""Overview comments already approved."""
template = ViewPageTemplateFile('comments_approved.pt')
try:
template.id = '@@comments-approved'
except AttributeError:
# id is not writeable in Zope 2.12
pass
A 'review multipe state workflow' is characterized by implementing
a 'rejected' workflow state and a 'spam' workflow state.
"""
workflows = self.workflowTool.getChainForPortalType(
'Discussion Item')
if workflows:
comment_workflow = self.workflowTool[workflows[0]]
if 'spam' in comment_workflow.states:
return True
return False
def __call__(self):
self.request.set('disable_border', True)
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')
def allowed_transitions(self, obj=None):
"""Return allowed workflow transitions for obj.
# print("*** approved comments")
# print(self.comments)
# for el in self.comments:
# print(el.id, el.review_state)
return self.template()
Example: pending
[{'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},
{'id': 'publish',
'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):
@ -184,13 +236,13 @@ class DeleteOwnComment(DeleteComment):
raise Unauthorized("You're not allowed to delete this comment.")
class PublishComment(BrowserView):
"""Publish a comment.
class CommentTransition(BrowserView):
r"""Publish, reject, recall a comment or mark it as spam.
This view is always called directly on the comment object:
http://nohost/front-page/++conversation++default/1286289644723317/\
@@moderate-publish-comment
@@transmit-comment
Each table row (comment) in the moderation view contains a hidden input
field with the absolute URL of the content object:
@ -206,35 +258,39 @@ class PublishComment(BrowserView):
"""
def __call__(self):
"""Call CommentTransition."""
comment = aq_inner(self.context)
content_object = aq_parent(aq_parent(comment))
workflowTool = getToolByName(comment, 'portal_workflow', None)
workflow_action = self.request.form.get('workflow_action', 'publish')
review_state = workflowTool.getInfoFor(comment, 'review_state', '')
if review_state == "pending":
workflowTool.doActionFor(comment, workflow_action)
api.content.transition(comment, transition=workflow_action)
comment.reindexObject()
content_object.reindexObject(idxs=['total_comments'])
notify(CommentPublishedEvent(self.context, comment))
IStatusMessage(self.context.REQUEST).addStatusMessage(
_('Comment approved.'),
type='info')
else:
IStatusMessage(self.context.REQUEST).addStatusMessage(
_('Comment already approved.'),
type='info')
# for complexer workflows:
notify(CommentTransitionEvent(self.context, comment))
review_state_new = api.content.get_state(comment, '')
comment_state_translated = self.context.restrictedTraverse("translationhelper").translate_comment_review_state(review_state_new)
msgid = _(
"comment_transmitted",
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
# if the referrer already has a came_from in it, don't redirect back
if (len(came_from) == 0 or 'came_from=' in came_from or
not getToolByName(
content_object, 'portal_url').isURLInPortal(came_from) or
'@@confirm-action' in came_from):
if (len(came_from) == 0
or 'came_from=' in came_from
or not getToolByName(
content_object, 'portal_url').isURLInPortal(came_from)):
came_from = content_object.absolute_url()
return self.context.REQUEST.RESPONSE.redirect(came_from)
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
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):
"""Call BulkActionsView."""
if 'form.select.BulkAction' in self.request:
bulkaction = self.request.get('form.select.BulkAction')
self.paths = self.request.get('paths')
@ -264,50 +325,40 @@ class BulkActionsView(BrowserView):
if bulkaction == '-1':
# no bulk action was selected
pass
elif bulkaction == 'retract':
self.retract()
elif bulkaction == 'publish':
self.publish()
elif bulkaction == 'mark_as_spam':
self.mark_as_spam()
elif bulkaction == 'delete':
self.delete()
else:
raise KeyError # pragma: no cover
self.transmit(bulkaction)
def retract(self):
raise NotImplementedError
def publish(self):
"""Publishes all comments in the paths variable.
def transmit(self, action=None):
"""Transmit all comments in the paths variable to requested review_state.
Expects a list of absolute paths (without host and port):
/Plone/startseite/++conversation++default/1286200010610352
"""
context = aq_inner(self.context)
for path in self.paths:
comment = context.restrictedTraverse(path)
content_object = aq_parent(aq_parent(comment))
workflowTool = getToolByName(comment, 'portal_workflow')
current_state = workflowTool.getInfoFor(comment, 'review_state')
if current_state != 'published':
workflowTool.doActionFor(comment, 'publish')
allowed_transitions = [
transition['id'] for transition in self.workflowTool.listActionInfos(object=comment)
if transition['category'] == 'workflow' and transition['allowed']
]
if action in allowed_transitions:
self.workflowTool.doActionFor(comment, action)
comment.reindexObject()
content_object.reindexObject(idxs=['total_comments'])
notify(CommentPublishedEvent(content_object, comment))
def mark_as_spam(self):
raise NotImplementedError
# for complexer workflows:
notify(CommentTransitionEvent(self.context, comment))
def delete(self):
"""Deletes all comments in the paths variable.
"""Delete all comments in the paths variable.
Expects a list of absolute paths (without host and port):
/Plone/startseite/++conversation++default/1286200010610352
"""
context = aq_inner(self.context)
for path in self.paths:

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -284,7 +284,7 @@ Open the edit comment view
Change and save the comment
>>> 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.
Check it ain't so.
@ -325,19 +325,19 @@ Deleting existing comments | 'Delete comments' permission
Anonymous cannot delete comments
>>> unprivileged_browser.open(urldoc1)
>>> 'form.button.Delete' in unprivileged_browser.contents
>>> 'form.button.DeleteComment' in unprivileged_browser.contents
False
A member cannot delete his own comments if he can't review or he isn't a Site Administrator
>>> browser_member.open(urldoc1)
>>> 'form.button.Delete' in browser_member.contents
>>> 'form.button.DeleteComment' in browser_member.contents
False
Admin can delete comments
>>> browser.open(urldoc1)
>>> 'form.button.Delete' in browser.contents
>>> 'form.button.DeleteComment' in browser.contents
True
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.assertEqual(
reply['actions'][0]['id'],
'publish',
'mark_as_spam',
)
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(
reply['actions'][0]['url'],
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 -*-
from plone.app.discussion.browser.moderation import BulkActionsView
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.interfaces import IConversation
from plone.app.discussion.interfaces import IDiscussionSettings
@ -110,14 +110,6 @@ class ModerationBulkActionsViewTest(unittest.TestCase):
self.assertFalse(view())
def test_retract(self):
self.request.set('form.select.BulkAction', 'retract')
self.request.set('paths', ['/'.join(self.comment1.getPhysicalPath())])
view = BulkActionsView(self.portal, self.request)
self.assertRaises(NotImplementedError, view)
def test_publish(self):
self.request.set('form.select.BulkAction', 'publish')
self.request.set('paths', ['/'.join(self.comment1.getPhysicalPath())])
@ -135,15 +127,6 @@ class ModerationBulkActionsViewTest(unittest.TestCase):
# Make sure the comment has been published
self.assertEqual(published_comments, 1)
def test_mark_as_spam(self):
self.request.set('form.select.BulkAction', 'mark_as_spam')
self.request.set('paths', ['/'.join(self.comment1.getPhysicalPath())])
view = BulkActionsView(self.portal, self.request)
self.assertRaises(NotImplementedError,
view)
def test_delete(self):
# Initially we have three comments
self.assertEqual(len(self.conversation.objectIds()), 3)
@ -195,14 +178,14 @@ class RedirectionTest(unittest.TestCase):
def test_regression(self):
page_url = self.page.absolute_url()
self.request['HTTP_REFERER'] = page_url
for Klass in (DeleteComment, PublishComment):
for Klass in (DeleteComment, CommentTransition):
view = Klass(self.comment, self.request)
view.__parent__ = self.comment
self.assertEqual(page_url, view())
def test_valid_next_url(self):
self.request['HTTP_REFERER'] = 'http://attacker.com'
for Klass in (DeleteComment, PublishComment):
for Klass in (DeleteComment, CommentTransition):
view = Klass(self.comment, self.request)
view.__parent__ = self.comment
self.assertNotEqual('http://attacker.com', view())

View File

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

View File

@ -20,7 +20,7 @@ def update_rolemap(context):
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
# 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
@ -46,13 +46,16 @@ def upgrade_comment_workflows(context):
orig_chain[idx] = 'comment_one_state_workflow'
# Restore the 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
# allowedRolesAndUsers index.
portal_type = 'Discussion Item'
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):
try:
comment = brain.getObject()
@ -63,5 +66,15 @@ def upgrade_comment_workflows(context):
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):
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
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>