Make comments editable

This commit is contained in:
Guido A.J. Stevens 2013-09-17 12:03:46 +00:00
parent 0463b20f78
commit e6172a219e
20 changed files with 359 additions and 13 deletions

View File

@ -1,9 +1,13 @@
Changelog Changelog
========= =========
2.2.9 (unreleased) 2.2.9 (unreleased)
------------------ ------------------
- Make comments editable.
[pjstevns, gyst]
- Rename CHANGES.txt to CHANGES.rst. - Rename CHANGES.txt to CHANGES.rst.
[timo] [timo]

View File

@ -1,8 +1,16 @@
from Acquisition import aq_inner, aq_parent from Acquisition import aq_inner, aq_parent
from AccessControl import getSecurityManager
from zope.component import getMultiAdapter
from Products.statusmessages.interfaces import IStatusMessage
from Products.Five.browser import BrowserView from Products.Five.browser import BrowserView
from Products.CMFCore.utils import getToolByName from Products.CMFCore.utils import getToolByName
from plone.app.discussion import PloneAppDiscussionMessageFactory as _
from comments import CommentForm
from z3c.form import button
from plone.z3cform.layout import wrap_form
class View(BrowserView): class View(BrowserView):
"""Comment View. """Comment View.
@ -37,3 +45,64 @@ class View(BrowserView):
url = "%s/view" % url url = "%s/view" % url
self.request.response.redirect('%s#%s' % (url, context.id)) self.request.response.redirect('%s#%s' % (url, context.id))
class EditCommentForm(CommentForm):
"""Form to edit an existing comment."""
ignoreContext = True
id = "edit-comment-form"
label = _(u'edit_comment_form_title', default=u'Edit comment')
def updateWidgets(self):
super(EditCommentForm, self).updateWidgets()
self.widgets['text'].value = self.context.text
# We have to rename the id, otherwise TinyMCE can't initialize
# because there are two textareas with the same id.
self.widgets['text'].id = 'overlay-comment-text'
def _redirect(self, target=''):
if not target:
portal_state = getMultiAdapter((self.context, self.request),
name=u'plone_portal_state')
target = portal_state.portal_url()
self.request.response.redirect(target)
@button.buttonAndHandler(_(u"edit_comment_form_button",
default=u"Edit comment"), name='comment')
def handleComment(self, action):
# Validate form
data, errors = self.extractData()
if errors:
return
# Check permissions
can_edit = getSecurityManager().checkPermission(
'Edit comments',
self.context)
mtool = getToolByName(self.context, 'portal_membership')
if mtool.isAnonymousUser() or not can_edit:
return
# Update text
self.context.text = data['text']
# Redirect to comment
IStatusMessage(self.request).add(_(u'comment_edit_notification',
default="Comment was edited"),
type='info')
return self._redirect(
target=self.action.replace("@@edit-comment", "@@view"))
@button.buttonAndHandler(_(u'cancel_form_button',
default=u'Cancel'), name='cancel')
def handle_cancel(self, action):
IStatusMessage(self.request).add(
_(u'comment_edit_cancel_notification',
default=u'Edit comment cancelled'),
type='info')
return self._redirect(target=self.context.absolute_url())
EditComment = wrap_form(EditCommentForm)
#EOF

View File

@ -1,6 +1,7 @@
<tal:block tal:define="userHasReplyPermission view/can_reply; <tal:block tal:define="userHasReplyPermission view/can_reply;
isDiscussionAllowed view/is_discussion_allowed; isDiscussionAllowed view/is_discussion_allowed;
isAnonymousDiscussionAllowed view/anonymous_discussion_allowed; isAnonymousDiscussionAllowed view/anonymous_discussion_allowed;
isEditCommentAllowed view/edit_comment_allowed;
isAnon view/is_anonymous; isAnon view/is_anonymous;
canReview view/can_review; canReview view/can_review;
replies python:view.get_replies(canReview); replies python:view.get_replies(canReview);
@ -34,7 +35,8 @@
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);
has_author_link python:author_home_url and not isAnon; has_author_link python:author_home_url and not isAnon;
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)"
tal:attributes="class python:'comment replyTreeLevel'+str(depth)+' state-'+str(review_state); tal:attributes="class python:'comment replyTreeLevel'+str(depth)+' state-'+str(review_state);
id string:${reply/getId}" id string:${reply/getId}"
tal:condition="python:canReview or review_state == 'published'"> tal:condition="python:canReview or review_state == 'published'">
@ -42,14 +44,14 @@
<div class="commentImage" tal:condition="showCommenterImage"> <div class="commentImage" tal:condition="showCommenterImage">
<a href="" tal:condition="has_author_link" <a href="" tal:condition="has_author_link"
tal:attributes="href author_home_url"> tal:attributes="href author_home_url">
<img src="defaultUser.gif" <img src="defaultUser.png"
alt="" alt=""
class="defaultuserimg" class="defaultuserimg"
height="32" height="32"
tal:attributes="src portrait_url; tal:attributes="src portrait_url;
alt reply/author_name" /> alt reply/author_name" />
</a> </a>
<img src="defaultUser.gif" <img src="defaultUser.png"
alt="" alt=""
class="defaultuserimg" class="defaultuserimg"
height="32" height="32"
@ -87,7 +89,7 @@
action="" action=""
method="post" method="post"
class="commentactionsform" class="commentactionsform"
tal:condition="canReview" tal:condition="python:canReview"
tal:attributes="action string:${reply/absolute_url}/@@moderate-delete-comment"> tal:attributes="action string:${reply/absolute_url}/@@moderate-delete-comment">
<input name="form.button.DeleteComment" <input name="form.button.DeleteComment"
class="destructive" class="destructive"
@ -97,6 +99,21 @@
/> />
</form> </form>
<form name="edit"
action=""
method="get"
class="commentactionsform"
tal:condition="python:isEditCommentAllowed and canEdit"
tal:attributes="action string:${reply/absolute_url}/@@edit-comment">
<input name="form.button.EditComment"
class="context"
type="submit"
value="Edit"
i18n:attributes="value label_edit;"
/>
</form>
<!-- Workflow actions (e.g. 'publish') --> <!-- Workflow actions (e.g. 'publish') -->
<form name="" <form name=""
action="" action=""

View File

@ -298,6 +298,13 @@ class CommentsViewlet(ViewletBase):
return getSecurityManager().checkPermission('Review comments', return getSecurityManager().checkPermission('Review comments',
aq_inner(self.context)) aq_inner(self.context))
def can_edit(self, reply):
"""Returns true if current user has the 'Delete objects'
permission.
"""
return getSecurityManager().checkPermission('Edit comments',
aq_inner(reply))
def is_discussion_allowed(self): def is_discussion_allowed(self):
context = aq_inner(self.context) context = aq_inner(self.context)
return context.restrictedTraverse('@@conversation_view').enabled() return context.restrictedTraverse('@@conversation_view').enabled()
@ -408,7 +415,7 @@ class CommentsViewlet(ViewletBase):
if username is None: if username is None:
# return the default user image if no username is given # return the default user image if no username is given
return 'defaultUser.gif' return 'defaultUser.png'
else: else:
portal_membership = getToolByName(self.context, portal_membership = getToolByName(self.context,
'portal_membership', 'portal_membership',
@ -423,6 +430,12 @@ class CommentsViewlet(ViewletBase):
settings = registry.forInterface(IDiscussionSettings, check=False) settings = registry.forInterface(IDiscussionSettings, check=False)
return settings.anonymous_comments return settings.anonymous_comments
def edit_comment_allowed(self):
# Check if editing comments is allowed in the registry
registry = queryUtility(IRegistry)
settings = registry.forInterface(IDiscussionSettings, check=False)
return settings.edit_comment_enabled
def show_commenter_image(self): def show_commenter_image(self):
# Check if showing commenter image is enabled in the registry # Check if showing commenter image is enabled in the registry
registry = queryUtility(IRegistry) registry = queryUtility(IRegistry)

View File

@ -71,6 +71,15 @@
permission="zope2.View" permission="zope2.View"
/> />
<!-- Edit comment view -->
<browser:page
for="plone.app.discussion.interfaces.IComment"
name="edit-comment"
layer="..interfaces.IDiscussionLayer"
class=".comment.EditComment"
permission="plone.app.discussion.EditComments"
/>
<!-- Delete comment view --> <!-- Delete comment view -->
<browser:page <browser:page
for="plone.app.discussion.interfaces.IComment" for="plone.app.discussion.interfaces.IComment"

View File

@ -24,6 +24,7 @@ from z3c.form import button
from z3c.form.browser.checkbox import SingleCheckBoxFieldWidget from z3c.form.browser.checkbox import SingleCheckBoxFieldWidget
from plone.app.discussion.interfaces import IDiscussionSettings, _ from plone.app.discussion.interfaces import IDiscussionSettings, _
from plone.app.discussion.upgrades import update_registry
class DiscussionSettingsEditForm(controlpanel.RegistryEditForm): class DiscussionSettingsEditForm(controlpanel.RegistryEditForm):
@ -51,6 +52,8 @@ class DiscussionSettingsEditForm(controlpanel.RegistryEditForm):
SingleCheckBoxFieldWidget SingleCheckBoxFieldWidget
self.fields['moderation_enabled'].widgetFactory = \ self.fields['moderation_enabled'].widgetFactory = \
SingleCheckBoxFieldWidget SingleCheckBoxFieldWidget
self.fields['edit_comment_enabled'].widgetFactory = \
SingleCheckBoxFieldWidget
self.fields['anonymous_comments'].widgetFactory = \ self.fields['anonymous_comments'].widgetFactory = \
SingleCheckBoxFieldWidget SingleCheckBoxFieldWidget
self.fields['show_commenter_image'].widgetFactory = \ self.fields['show_commenter_image'].widgetFactory = \
@ -61,7 +64,13 @@ class DiscussionSettingsEditForm(controlpanel.RegistryEditForm):
SingleCheckBoxFieldWidget SingleCheckBoxFieldWidget
def updateWidgets(self): def updateWidgets(self):
super(DiscussionSettingsEditForm, self).updateWidgets() try:
super(DiscussionSettingsEditForm, self).updateWidgets()
except KeyError:
# upgrade profile not visible in prefs_install_products_form
# provide auto-upgrade
update_registry(self.context)
super(DiscussionSettingsEditForm, self).updateWidgets()
self.widgets['globally_enabled'].label = _(u"Enable Comments") self.widgets['globally_enabled'].label = _(u"Enable Comments")
self.widgets['anonymous_comments'].label = _(u"Anonymous Comments") self.widgets['anonymous_comments'].label = _(u"Anonymous Comments")
self.widgets['show_commenter_image'].label = _(u"Commenter Image") self.widgets['show_commenter_image'].label = _(u"Commenter Image")
@ -118,6 +127,9 @@ class DiscussionSettingsControlPanel(controlpanel.ControlPanelFormWrapper):
elif settings.moderation_enabled: elif settings.moderation_enabled:
output.append("moderation_enabled") output.append("moderation_enabled")
if settings.edit_comment_enabled:
output.append("edit_comment_enabled")
# Anonymous comments # Anonymous comments
if settings.anonymous_comments: if settings.anonymous_comments:
output.append("anonymous_comments") output.append("anonymous_comments")

View File

@ -165,6 +165,15 @@
return false; return false;
}); });
/**********************************************************************
* Edit a comment
**********************************************************************/
$("form[name='edit']").prepOverlay({
cssclass: 'overlay-edit-comment',
width: '60%',
subtype: 'ajax',
filter: '#content>*'
})
/********************************************************************** /**********************************************************************
* Delete a comment and its answers. * Delete a comment and its answers.

View File

@ -40,6 +40,7 @@
$.enableSettings([ $.enableSettings([
$('#formfield-form-widgets-anonymous_comments'), $('#formfield-form-widgets-anonymous_comments'),
$('#formfield-form-widgets-moderation_enabled'), $('#formfield-form-widgets-moderation_enabled'),
$('#formfield-form-widgets-edit_comment_enabled'),
$('#formfield-form-widgets-text_transform'), $('#formfield-form-widgets-text_transform'),
$('#formfield-form-widgets-captcha'), $('#formfield-form-widgets-captcha'),
$('#formfield-form-widgets-show_commenter_image'), $('#formfield-form-widgets-show_commenter_image'),
@ -52,6 +53,7 @@
$.disableSettings([ $.disableSettings([
$('#formfield-form-widgets-anonymous_comments'), $('#formfield-form-widgets-anonymous_comments'),
$('#formfield-form-widgets-moderation_enabled'), $('#formfield-form-widgets-moderation_enabled'),
$('#formfield-form-widgets-edit_comment_enabled'),
$('#formfield-form-widgets-text_transform'), $('#formfield-form-widgets-text_transform'),
$('#formfield-form-widgets-captcha'), $('#formfield-form-widgets-captcha'),
$('#formfield-form-widgets-show_commenter_image'), $('#formfield-form-widgets-show_commenter_image'),

View File

@ -223,3 +223,9 @@
.row .discussion label { .row .discussion label {
font-weight:bold; font-weight:bold;
} }
/* editing comments */
.overlay-edit-comment textarea {
height: 10em;
}

View File

@ -42,6 +42,7 @@ from Products.CMFCore.CMFCatalogAware import WorkflowAware
from OFS.role import RoleManager from OFS.role import RoleManager
from AccessControl import ClassSecurityInfo from AccessControl import ClassSecurityInfo
from AccessControl.SecurityManagement import getSecurityManager
from Products.CMFCore import permissions from Products.CMFCore import permissions
@ -115,6 +116,14 @@ class Comment(CatalogAware, WorkflowAware, DynamicType, Traversable,
self.creation_date = self.modification_date = datetime.utcnow() self.creation_date = self.modification_date = datetime.utcnow()
self.mime_type = 'text/plain' self.mime_type = 'text/plain'
user = getSecurityManager().getUser()
if user and user.getId():
aclpath = [x for x in user.getPhysicalPath() if x]
self._owner = (aclpath, user.getId(),)
self.__ac_local_roles__ = {
user.getId(): ['Owner']
}
@property @property
def __name__(self): def __name__(self):
return self.comment_id and unicode(self.comment_id) or None return self.comment_id and unicode(self.comment_id) or None
@ -193,7 +202,7 @@ class Comment(CatalogAware, WorkflowAware, DynamicType, Traversable,
def Creator(self): def Creator(self):
"""The name of the person who wrote the comment. """The name of the person who wrote the comment.
""" """
return self.creator return self.creator or self.author_name
security.declareProtected(permissions.View, 'Type') security.declareProtected(permissions.View, 'Type')

View File

@ -43,6 +43,17 @@
for="Products.CMFPlone.interfaces.IPloneSiteRoot" for="Products.CMFPlone.interfaces.IPloneSiteRoot"
/> />
<genericsetup:upgradeStep
title="edit comments"
description="reload registry config to enable new field edit_comment_enabled"
source="100"
destination="101"
handler=".upgrades.update_registry"
sortkey="1"
profile="plone.app.discussion:default"
/>
<!-- Monkey Patches --> <!-- Monkey Patches -->
<include package="collective.monkeypatcher" /> <include package="collective.monkeypatcher" />

View File

@ -274,6 +274,17 @@ class IDiscussionSettings(Interface):
default=False, default=False,
) )
edit_comment_enabled = schema.Bool(
title=_(u"label_edit_comment_enabled",
default="Enable editing of comments"),
description=_(u"help_edit_comment_enabled",
default=u"If selected, supports editing and deletion "
"of comments for users with the 'Edit comments' "
"permission."),
required=False,
default=False,
)
text_transform = schema.Choice( text_transform = schema.Choice(
title=_(u"label_text_transform", title=_(u"label_text_transform",
default="Comment text transform"), default="Comment text transform"),

View File

@ -9,4 +9,10 @@
title="Review comments" title="Review comments"
/> />
<permission
id="plone.app.discussion.EditComments"
title="Edit comments"
/>
</configure> </configure>

View File

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

View File

@ -1,4 +1,6 @@
<?xml version="1.0"?> <?xml version="1.0"?>
<registry> <registry>
<records interface="plone.app.discussion.interfaces.IDiscussionSettings" /> <records interface="plone.app.discussion.interfaces.IDiscussionSettings">
<value key="edit_comment_enabled">False</value>
</records>
</registry> </registry>

View File

@ -6,6 +6,12 @@
<role name="Site Administrator"/> <role name="Site Administrator"/>
<role name="Reviewer"/> <role name="Reviewer"/>
</permission> </permission>
<permission name="Edit comments" acquire="True">
<role name="Manager"/>
<role name="Site Administrator"/>
<role name="Reviewer"/>
<role name="Owner"/>
</permission>
<permission name="Reply to item" acquire="False"> <permission name="Reply to item" acquire="False">
<role name="Authenticated"/> <role name="Authenticated"/>
</permission> </permission>

View File

@ -250,6 +250,77 @@ Check that the reply has been posted properly.
True True
Edit an existing comment
------------------------
Log in as admin
>>> browser.getLink('Log out').click()
>>> browser.open(portal_url + '/login_form')
>>> browser.getControl('Login Name').value = 'admin'
>>> browser.getControl('Password').value = 'secret'
>>> browser.getControl('Log in').click()
Use the Plone control panel to enable comment editing.
>>> browser.open(portal_url + '/plone_control_panel')
>>> browser.getLink('Discussion').click()
>>> browser.getControl('Enable editing of comments').selected = True
>>> browser.getControl(name='form.buttons.save').click()
Extract the edit comment url from the first "edit comment" button
>>> browser.open(urldoc1)
>>> form = browser.getForm(name='edit', index=0)
>>> '@@edit-comment' in form.action
True
Open the edit comment view
>>> browser.open(form.action)
>>> ctrl = browser.getControl('Comment')
>>> ctrl.value
'Comment from admin'
Change and save the comment
>>> ctrl.value = 'Comment from admin / was edited'
>>> browser.getControl('Edit comment').click()
This used to trigger permissions problems in some portlet configurations.
Check it ain't so.
>>> 'require_login' in browser.url
False
>>> browser.url.startswith('http://nohost/plone/doc1')
True
>>> 'Comment from admin / was edited' in browser.contents
True
Opening the edit comment view, then cancel, does nothing.
>>> form = browser.getForm(name='edit', index=0)
>>> '@@edit-comment' in form.action
True
>>> browser.open(form.action)
>>> browser.getControl('Cancel').click()
>>> browser.url.startswith('http://nohost/plone/doc1')
True
Anon cannot edit comments.
>>> unprivileged_browser.open(urldoc1)
>>> '@@edit-comments' in browser.contents
False
But Anon can see the edited comment.
>>> 'Comment from admin / was edited' in unprivileged_browser.contents
True
Post a comment with comment review workflow enabled Post a comment with comment review workflow enabled
--------------------------------------------------- ---------------------------------------------------

View File

@ -130,6 +130,16 @@ class CommentTest(unittest.TestCase):
comment1.creator = "jim" comment1.creator = "jim"
self.assertEqual("jim", comment1.Creator()) self.assertEqual("jim", comment1.Creator())
def test_creator_author_name(self):
comment1 = createObject('plone.Comment')
comment1.author_name = "joey"
self.assertEqual("joey", comment1.Creator())
def test_owner(self):
comment1 = createObject('plone.Comment')
self.assertEqual((['plone', 'acl_users'], TEST_USER_ID),
comment1.getOwnerTuple())
def test_type(self): def test_type(self):
comment1 = createObject('plone.Comment') comment1 = createObject('plone.Comment')
self.assertEqual(comment1.Type(), 'Comment') self.assertEqual(comment1.Type(), 'Comment')

View File

@ -33,12 +33,14 @@ from plone.app.testing import login
from plone.app.discussion.browser.comments import CommentsViewlet from plone.app.discussion.browser.comments import CommentsViewlet
from plone.app.discussion.browser.comments import CommentForm from plone.app.discussion.browser.comments import CommentForm
from plone.app.discussion.browser.comment import EditCommentForm
from plone.app.discussion import interfaces from plone.app.discussion import interfaces
from plone.app.discussion.interfaces import IConversation from plone.app.discussion.interfaces import IConversation
from plone.app.discussion.testing import ( from plone.app.discussion.testing import (
PLONE_APP_DISCUSSION_INTEGRATION_TESTING PLONE_APP_DISCUSSION_INTEGRATION_TESTING
) )
from plone.app.discussion.interfaces import IDiscussionSettings from plone.app.discussion.interfaces import IDiscussionSettings
from plone.app.discussion.interfaces import IConversation
class TestCommentForm(unittest.TestCase): class TestCommentForm(unittest.TestCase):
@ -125,6 +127,65 @@ class TestCommentForm(unittest.TestCase):
self.assertEqual(len(errors), 0) self.assertEqual(len(errors), 0)
self.assertFalse(commentForm.handleComment(commentForm, "foo")) self.assertFalse(commentForm.handleComment(commentForm, "foo"))
def test_edit_comment(self):
"""Edit a comment as logged-in user.
"""
# Allow discussion
self.discussionTool.overrideDiscussionFor(self.portal.doc1, True)
self.viewlet = CommentsViewlet(self.context, self.request, None, None)
def make_request(form={}):
request = TestRequest()
request.form.update(form)
alsoProvides(request, IFormLayer)
alsoProvides(request, IAttributeAnnotatable)
return request
provideAdapter(
adapts=(Interface, IBrowserRequest),
provides=Interface,
factory=CommentForm,
name=u"comment-form"
)
provideAdapter(
adapts=(Interface, IBrowserRequest),
provides=Interface,
factory=EditCommentForm,
name=u"edit-comment-form"
)
# The form is submitted successfully, if the required text field is
# filled out
request = make_request(form={'form.widgets.text': u'bar'})
commentForm = getMultiAdapter(
(self.context, request),
name=u"comment-form"
)
commentForm.update()
data, errors = commentForm.extractData() # pylint: disable-msg=W0612
self.assertEqual(len(errors), 0)
self.assertFalse(commentForm.handleComment(commentForm, "foo"))
# Edit the last comment
conversation = IConversation(self.context)
comment = [x for x in conversation.getComments()][-1]
request = make_request(form={'form.widgets.text': u'foobar'})
editForm = getMultiAdapter(
(comment, request),
name=u"edit-comment-form"
)
editForm.update()
data, errors = editForm.extractData() # pylint: disable-msg=W0612
self.assertEqual(len(errors), 0)
self.assertFalse(editForm.handleComment(editForm, "foo"))
comment = [x for x in conversation.getComments()][-1]
self.assertEquals(comment.text, u"foobar")
def test_add_anonymous_comment(self): def test_add_anonymous_comment(self):
self.discussionTool.overrideDiscussionFor(self.portal.doc1, True) self.discussionTool.overrideDiscussionFor(self.portal.doc1, True)
@ -459,9 +520,11 @@ class TestCommentsViewlet(unittest.TestCase):
) )
def test_get_commenter_portrait_is_none(self): def test_get_commenter_portrait_is_none(self):
self.assertEqual( self.assertTrue(
self.viewlet.get_commenter_portrait(), self.viewlet.get_commenter_portrait() in (
'defaultUser.gif' 'defaultUser.png',
'defaultUser.gif',
)
) )
def test_get_commenter_portrait_without_userimage(self): def test_get_commenter_portrait_without_userimage(self):

View File

@ -81,6 +81,22 @@ class RegistryTest(unittest.TestCase):
False False
) )
def test_edit_comment_enabled(self):
# Check edit_comment_enabled record
self.assertTrue('edit_comment_enabled' in IDiscussionSettings)
self.assertEqual(
self.registry['plone.app.discussion.interfaces.' +
'IDiscussionSettings.edit_comment_enabled'],
False)
def test_edit_comment_enabled(self):
# Check edit_comment_enabled record
self.assertTrue('edit_comment_enabled' in IDiscussionSettings)
self.assertEqual(
self.registry['plone.app.discussion.interfaces.' +
'IDiscussionSettings.edit_comment_enabled'],
False)
def test_text_transform(self): def test_text_transform(self):
self.assertTrue('text_transform' in IDiscussionSettings) self.assertTrue('text_transform' in IDiscussionSettings)
self.assertEqual( self.assertEqual(