diff --git a/CHANGES.txt b/CHANGES.txt index 3007ef9..5c54851 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -4,6 +4,13 @@ Changelog 2.0.1 (2011-04-22) ------------------ +- Add links to delete/approve a comment in the moderator notification email. + [timo] + +- Remove the unnecessary workflow_action parameter from the PublishComments + request. + [timo] + - Make sure the email settings in the control panel are disabled when commenting is disabled globally. [timo] diff --git a/plone/app/discussion/browser/javascripts/moderation.js b/plone/app/discussion/browser/javascripts/moderation.js index df6d69e..bc26f09 100644 --- a/plone/app/discussion/browser/javascripts/moderation.js +++ b/plone/app/discussion/browser/javascripts/moderation.js @@ -57,7 +57,6 @@ $.ajax({ type: "GET", url: target, - data: "workflow_action=publish", success: function (msg) { // fade out row $(row).fadeOut("normal", function () { diff --git a/plone/app/discussion/browser/moderation.py b/plone/app/discussion/browser/moderation.py index 35397b0..4209f11 100644 --- a/plone/app/discussion/browser/moderation.py +++ b/plone/app/discussion/browser/moderation.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- from Acquisition import aq_inner, aq_parent from Products.Five.browser import BrowserView @@ -12,39 +13,34 @@ from plone.app.discussion.interfaces import IComment class View(BrowserView): - """Main moderation View. + """Comment moderation view. """ - template = ViewPageTemplateFile('moderation.pt') try: template.id = '@@moderate-comments' except AttributeError: # id is not writeable in Zope 2.12 pass - + def __call__(self): - # Hide the editable-object border 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') 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. """ context = aq_inner(self.context) - wf_tool = getToolByName(context, 'portal_workflow') - comment_workflow = wf_tool.getChainForPortalType('Discussion Item')[0] - comment_workflow = wf_tool[comment_workflow] + workflowTool = getToolByName(context, 'portal_workflow') + comment_workflow = workflowTool.getChainForPortalType('Discussion Item')[0] + comment_workflow = workflowTool[comment_workflow] if 'pending' in comment_workflow.states: return True else: @@ -59,9 +55,9 @@ class ModerateCommentsEnabled(BrowserView): a 'pending' workflow state. """ context = aq_inner(self.context) - wf_tool = getToolByName(context, 'portal_workflow', None) - comment_workflow = wf_tool.getChainForPortalType('Discussion Item')[0] - comment_workflow = wf_tool[comment_workflow] + workflowTool = getToolByName(context, 'portal_workflow', None) + comment_workflow = workflowTool.getChainForPortalType('Discussion Item')[0] + comment_workflow = workflowTool[comment_workflow] if 'pending' in comment_workflow.states: return True else: @@ -70,110 +66,103 @@ class ModerateCommentsEnabled(BrowserView): class DeleteComment(BrowserView): """Delete a comment from a conversation. - + This view is always called directly on the comment object: - + http://nohost/front-page/++conversation++default/1286289644723317/\ @@moderate-delete-comment - + Each table row (comment) in the moderation view contains a hidden input field with the absolute URL of the content object: - + - + This absolute URL is called from a jQuery method that is bind to the 'delete' button of the table row. See javascripts/moderation.js for more details. """ - + def __call__(self): - - context = aq_inner(self.context) - comment_id = self.context.id - - conversation = aq_parent(context) - - del conversation[comment_id] - + comment = aq_inner(self.context) + conversation = aq_parent(comment) + content_object = aq_parent(conversation) + del conversation[comment.id] IStatusMessage(self.context.REQUEST).addStatusMessage( _("Comment deleted."), type="info") - - return self.context.REQUEST.RESPONSE.redirect( - self.context.REQUEST.HTTP_REFERER) + came_from = self.context.REQUEST.HTTP_REFERER + if len(came_from) == 0: + came_from = content_object.absolute_url() + return self.context.REQUEST.RESPONSE.redirect(came_from) class PublishComment(BrowserView): """Publish a comment. - + This view is always called directly on the comment object: - + http://nohost/front-page/++conversation++default/1286289644723317/\ @@moderate-publish-comment - + Each table row (comment) in the moderation view contains a hidden input field with the absolute URL of the content object: - + - + This absolute URL is called from a jQuery method that is bind to the 'delete' button of the table row. See javascripts/moderation.js for more details. """ - + def __call__(self): - comment = aq_inner(self.context) - workflow_action = self.request.form['workflow_action'] - portal_workflow = getToolByName(comment, 'portal_workflow') - portal_workflow.doActionFor(comment, workflow_action) - - catalog = getToolByName(comment, 'portal_catalog') - catalog.reindexObject(comment) - + content_object = aq_parent(aq_parent(comment)) + workflowTool = getToolByName(comment, 'portal_workflow', None) + current_state = workflowTool.getInfoFor(comment, 'review_state') + if current_state != 'published': + workflowTool.doActionFor(comment, 'publish') + catalogTool = getToolByName(comment, 'portal_catalog') + catalogTool.reindexObject(comment) IStatusMessage(self.context.REQUEST).addStatusMessage( _("Comment approved."), type="info") - - return self.context.REQUEST.RESPONSE.redirect( - self.context.REQUEST.HTTP_REFERER) - + came_from = self.context.REQUEST.HTTP_REFERER + if len(came_from) == 0: + 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). - + Each table row of the moderation view has a checkbox with the absolute path (without host and port) of the comment objects: - + - + If checked, the comment path will occur in the 'paths' variable of the request when the bulk actions view is called. The bulk action (delete, publish, etc.) will be applied to all comments that are included. - + The paths have to be 'traversable': - + /plone/front-page/++conversation++default/1286289644723317 - + """ - + def __call__(self): - if 'form.select.BulkAction' in self.request: - bulkaction = self.request.get('form.select.BulkAction') - self.paths = self.request.get('paths') if self.paths: if bulkaction == '-1': @@ -189,38 +178,38 @@ class BulkActionsView(BrowserView): self.delete() else: raise KeyError # pragma: no cover - + def retract(self): raise NotImplementedError - + def publish(self): """Publishes all comments in the paths variable. - + Expects a list of absolute paths (without host and port): - + /Plone/startseite/++conversation++default/1286200010610352 - + """ context = aq_inner(self.context) for path in self.paths: comment = context.restrictedTraverse(path) - portal_workflow = getToolByName(comment, 'portal_workflow') - current_state = portal_workflow.getInfoFor(comment, 'review_state') + workflowTool = getToolByName(comment, 'portal_workflow') + current_state = workflowTool.getInfoFor(comment, 'review_state') if current_state != 'published': - portal_workflow.doActionFor(comment, 'publish') + workflowTool.doActionFor(comment, 'publish') catalog = getToolByName(comment, 'portal_catalog') catalog.reindexObject(comment) - + def mark_as_spam(self): raise NotImplementedError - + def delete(self): """Deletes 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: diff --git a/plone/app/discussion/comment.py b/plone/app/discussion/comment.py index 9458acb..6452961 100644 --- a/plone/app/discussion/comment.py +++ b/plone/app/discussion/comment.py @@ -43,83 +43,95 @@ from Products.CMFCore.CMFCatalogAware import WorkflowAware from OFS.role import RoleManager -COMMENT_TITLE = _(u"comment_title", +COMMENT_TITLE = _( + u"comment_title", default=u"${creator} on ${content}") -MAIL_NOTIFICATION_MESSAGE = _(u"mail_notification_message", +MAIL_NOTIFICATION_MESSAGE = _( + u"mail_notification_message", default=u"A comment on '${title}' " "has been posted here: ${link}\n\n" - "---\n\n" - "${text}" + "---\n" + "${text}\n" "---\n") +MAIL_NOTIFICATION_MESSAGE_MODERATOR = _( + u"mail_notification_message_moderator", + default=u"A comment on '${title}' " + "has been posted here: ${link}\n\n" + "---\n" + "${text}\n" + "---\n\n" + "Approve comment:\n${link_approve}\n\n" + "Delete comment:\n${link_delete}\n") + logger = logging.getLogger("plone.app.discussion") class Comment(CatalogAware, WorkflowAware, DynamicType, Traversable, RoleManager, Owned, Implicit, Persistent): """A comment. - + This object attempts to be as lightweight as possible. We implement a number of standard methods instead of subclassing, to have total control over what goes into the object. """ - + implements(IComment) - + meta_type = portal_type = 'Discussion Item' # This needs to be kept in sync with types/Discussion_Item.xml title fti_title = 'Comment' - + __parent__ = None - + comment_id = None # long in_reply_to = None # long - + title = u"" - + mime_type = None text = u"" - + creator = None creation_date = None modification_date = None - + author_username = None - + author_name = None author_email = None - + user_notification = None - + # Note: we want to use zope.component.createObject() to instantiate # comments as far as possible. comment_id and __parent__ are set via # IConversation.addComment(). - + def __init__(self): self.creation_date = self.modification_date = datetime.utcnow() - + @property def __name__(self): return self.comment_id and unicode(self.comment_id) or None - + @property def id(self): return self.comment_id and str(self.comment_id) or None - + def getId(self): """The id of the comment, as a string. """ return self.id - + def getText(self, targetMimetype=None): """The body text of a comment. """ transforms = getToolByName(self, 'portal_transforms') - + if targetMimetype is None: targetMimetype = 'text/x-html-safe' - + sourceMimetype = getattr(self, 'mime_type', None) if sourceMimetype is None: registry = queryUtility(IRegistry) @@ -132,7 +144,7 @@ class Comment(CatalogAware, WorkflowAware, DynamicType, Traversable, text, context=self, mimetype=sourceMimetype).getData() - + def Title(self): """The title of the comment. """ @@ -146,7 +158,7 @@ class Comment(CatalogAware, WorkflowAware, DynamicType, Traversable, else: creator = self.creator creator = creator - + # Fetch the content object (the parent of the comment is the # conversation, the parent of the conversation is the content object). content = aq_base(self.__parent__.__parent__) @@ -155,30 +167,31 @@ class Comment(CatalogAware, WorkflowAware, DynamicType, Traversable, mapping={'creator': creator, 'content': safe_unicode(content.Title())})) return title - + def Creator(self): """The name of the person who wrote the comment. """ return self.creator - + def Type(self): """The Discussion Item content type. """ return self.fti_title - + # CMF's event handlers assume any IDynamicType has these :( - + def opaqueItems(self): # pragma: no cover return [] - + def opaqueIds(self): # pragma: no cover return [] - + def opaqueValues(self): # pragma: no cover return [] CommentFactory = Factory(Comment) + def notify_workflow(obj, event): """Tell the workflow tool when a comment is added """ @@ -186,6 +199,7 @@ def notify_workflow(obj, event): if tool is not None: tool.notifyCreated(obj) + def notify_content_object(obj, event): """Tell the content object when a comment is added """ @@ -207,20 +221,20 @@ def notify_content_object_deleted(obj, event): def notify_user(obj, event): """Tell users when a comment has been added. - + This method composes and sends emails to all users that have added a comment to this conversation and enabled user notification. - + This requires the user_notification setting to be enabled in the discussion control panel. """ - + # Check if user notification is enabled registry = queryUtility(IRegistry) settings = registry.forInterface(IDiscussionSettings, check=False) if not settings.user_notification_enabled: return - + # Get informations that are necessary to send an email mail_host = getToolByName(obj, 'MailHost') portal_url = getToolByName(obj, 'portal_url') @@ -273,24 +287,23 @@ def notify_user(obj, event): def notify_moderator(obj, event): """Tell the moderator when a comment needs attention. - - This method sends an email to the moderator if comment moderation is - enabled and a new comment has been added that needs to be approved. + + This method sends an email to the moderator if comment moderation a new + comment has been added that needs to be approved. + + The moderator_notification setting has to be enabled in the discussion + control panel. Configure the moderator e-mail address in the discussion control panel. If no moderator is configured but moderator notifications are turned on, the site admin email (from the mail control panel) will be used. - - This requires the moderator_notification to be enabled in the discussion - control panel and the comment_review_workflow enabled for the comment - content type. """ # Check if moderator notification is enabled registry = queryUtility(IRegistry) settings = registry.forInterface(IDiscussionSettings, check=False) if not settings.moderator_notification_enabled: return - + # Get informations that are necessary to send an email mail_host = getToolByName(obj, 'MailHost') portal_url = getToolByName(obj, 'portal_url') @@ -301,24 +314,26 @@ def notify_moderator(obj, event): mto = settings.moderator_email else: mto = sender - + # Check if a sender address is available if not sender: return - + conversation = aq_parent(obj) content_object = aq_parent(conversation) - + # Compose email - #comment = conversation.getComments().next() subject = translate(_(u"A comment has been posted."), context=obj.REQUEST) - message = translate(Message(MAIL_NOTIFICATION_MESSAGE, - mapping={'title': safe_unicode(content_object.title), - 'link': content_object.absolute_url() + - '/view#' + obj.id, - 'text': obj.text}), + message = translate(Message(MAIL_NOTIFICATION_MESSAGE_MODERATOR, + mapping={ + 'title': safe_unicode(content_object.title), + 'link': content_object.absolute_url() + '/view#' + obj.id, + 'text': obj.text, + 'link_approve': obj.absolute_url() + '/@@moderate-publish-comment', + 'link_delete': obj.absolute_url() + '/@@moderate-delete-comment', + }), context=obj.REQUEST) - + # Send email try: mail_host.send(message, mto, sender, subject, charset='utf-8') diff --git a/plone/app/discussion/tests/test_notifications.py b/plone/app/discussion/tests/test_notifications.py index f0d401d..25277de 100644 --- a/plone/app/discussion/tests/test_notifications.py +++ b/plone/app/discussion/tests/test_notifications.py @@ -88,6 +88,8 @@ class TestUserNotificationUnit(unittest.TestCase): % comment_id in msg) self.assertTrue('Comment text' in msg) + self.assertFalse('Approve comment' in msg) + self.assertFalse('Delete comment' in msg) def test_do_not_notify_user_when_notification_is_disabled(self): registry = queryUtility(IRegistry) @@ -222,6 +224,12 @@ class TestModeratorNotificationUnit(unittest.TestCase): % comment_id in msg) self.assertTrue('Comment text' in msg) + self.assertTrue( + 'Approve comment:\nhttp://nohost/plone/doc1/++conversation++default/%s/@@moderat=\ne-publish-comment' + % comment_id in msg) + self.assertTrue( + 'Delete comment:\nhttp://nohost/plone/doc1/++conversation++default/%s/@@moderat=\ne-delete-comment' + % comment_id in msg) def test_notify_moderator_specific_address(self): # A moderator email address can be specified in the control panel.