Merge notification branch into trunk:

New feature: As a moderator, I am notified when new comments require my attention;
New feature: As a commenter, I can enable/disable email notification of additional comments on this object;
Make p.a.d. work with the recent version of plone.z3cform (0.5.10);

svn path=/plone.app.discussion/trunk/; revision=34602
This commit is contained in:
Timo Stollenwerk 2010-03-06 10:04:34 +00:00
commit 1d39b53c4b
12 changed files with 454 additions and 28 deletions

View File

@ -4,6 +4,17 @@ Changelog
1.0b4 (unreleased)
------------------
* New feature: As a moderator, I am notified when new comments require my
attention.
[timo]
* New feature: As a commenter, I can enable/disable email notification of
additional comments on this object
[timo]
* Make p.a.d. work with the recent version of plone.z3cform (0.5.10)
[timo]
* Make p.a.d. styles less generic. This fixes #10253.
[timo]

View File

@ -150,7 +150,7 @@
formatting.
</p>
<div tal:replace="structure view/contents" />
<div tal:replace="structure view/form/render" />
</fieldset>
</div>

View File

@ -18,7 +18,9 @@ from zope.interface import Interface, implements
from zope.viewlet.interfaces import IViewlet
from z3c.form import form, field, button, interfaces, widget
from z3c.form.interfaces import IFormLayer
from z3c.form.browser.textarea import TextAreaWidget
from z3c.form.browser.checkbox import SingleCheckBoxFieldWidget
from Products.Five.browser import BrowserView
from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile
@ -55,19 +57,29 @@ class CommentForm(extensible.ExtensibleForm, form.Form):
'modification_date',
'author_username')
def updateFields(self):
super(CommentForm, self).updateFields()
self.fields['author_notification'].widgetFactory = SingleCheckBoxFieldWidget
def updateWidgets(self):
super(CommentForm, self).updateWidgets()
# Widgets
self.widgets['in_reply_to'].mode = interfaces.HIDDEN_MODE
portal_membership = getToolByName(self.context, 'portal_membership')
self.widgets['text'].addClass("autoresize")
self.widgets['author_notification'].label = _(u"")
# Anonymous / Logged-in
portal_membership = getToolByName(self.context, 'portal_membership')
if not portal_membership.isAnonymousUser():
self.widgets['author_name'].mode = interfaces.HIDDEN_MODE
self.widgets['author_email'].mode = interfaces.HIDDEN_MODE
# XXX: Since we are not using the author_email field in the
# current state, we hide it by default. But we keep the field for
# integrators or later use.
self.widgets['author_email'].mode = interfaces.HIDDEN_MODE
# Notification enabled
registry = queryUtility(IRegistry)
settings = registry.forInterface(IDiscussionSettings)
if not settings.user_notification_enabled:
self.widgets['author_notification'].mode = interfaces.HIDDEN_MODE
def updateActions(self):
super(CommentForm, self).updateActions()
@ -86,6 +98,7 @@ class CommentForm(extensible.ExtensibleForm, form.Form):
author_name = u""
author_username = u""
author_email = u""
author_notification = None
# Captcha check for anonymous users (if Captcha is enabled)
registry = queryUtility(IRegistry)
@ -110,7 +123,9 @@ class CommentForm(extensible.ExtensibleForm, form.Form):
author_username = data['author_username']
if 'author_email' in data:
author_email = data['author_email']
if 'author_notification' in data:
author_notification = data['author_notification']
# The add-comment view is called on the conversation object
conversation = IConversation(self.__parent__)
@ -130,6 +145,7 @@ class CommentForm(extensible.ExtensibleForm, form.Form):
comment.creator = author_name
comment.author_name = author_name
comment.author_email = author_email
comment.author_notification = author_notification
comment.creation_date = comment.modification_date = datetime.now()
else:
member = portal_membership.getAuthenticatedMember()
@ -141,6 +157,7 @@ class CommentForm(extensible.ExtensibleForm, form.Form):
comment.author_username = member.getUserName()
comment.author_name = member.getProperty('fullname')
comment.author_email = member.getProperty('email')
comment.author_notification = comment.author_notification
comment.creation_date = comment.modification_date = datetime.now()
# Check if the added comment is a reply to an existing comment
@ -174,25 +191,16 @@ class CommentForm(extensible.ExtensibleForm, form.Form):
pass
class CommentsViewlet(ViewletBase, layout.FormWrapper):
class CommentsViewlet(ViewletBase):
form = CommentForm
index = ViewPageTemplateFile('comments.pt')
def __init__(self, context, request, view, manager):
super(CommentsViewlet, self).__init__(context, request, view, manager)
if self.form is not None:
self.form_instance = self.form(self.context.aq_inner, self.request)
self.form_instance.__name__ = self.__name__
self.portal_discussion = getToolByName(self.context, 'portal_discussion', None)
self.portal_membership = getToolByName(self.context, 'portal_membership', None)
def render_form(self):
z2.switch_on(self, request_layer=self.request_layer)
self.form.update(self.form_instance)
return self.form.render(self.form_instance)
def update(self):
super(CommentsViewlet, self).update()
z2.switch_on(self, request_layer=IFormLayer)
self.form = CommentForm(aq_inner(self.context), self.request)
self.form.update()
# view methods
@ -291,7 +299,8 @@ class CommentsViewlet(ViewletBase, layout.FormWrapper):
return settings.show_commenter_image
def is_anonymous(self):
return self.portal_membership.isAnonymousUser()
portal_membership = getToolByName(self.context, 'portal_membership', None)
return portal_membership.isAnonymousUser()
def login_action(self):
return '%s/login_form?came_from=%s' % (self.navigation_root_url, url_quote(self.request.get('URL', '')),)

View File

@ -29,12 +29,16 @@ class DiscussionSettingsEditForm(controlpanel.RegistryEditForm):
self.fields['globally_enabled'].widgetFactory = SingleCheckBoxFieldWidget
self.fields['anonymous_comments'].widgetFactory = SingleCheckBoxFieldWidget
self.fields['show_commenter_image'].widgetFactory = SingleCheckBoxFieldWidget
self.fields['moderator_notification_enabled'].widgetFactory = SingleCheckBoxFieldWidget
self.fields['user_notification_enabled'].widgetFactory = SingleCheckBoxFieldWidget
def updateWidgets(self):
super(DiscussionSettingsEditForm, self).updateWidgets()
self.widgets['globally_enabled'].label = _(u"Enable Comments")
self.widgets['anonymous_comments'].label = _(u"Anonymous Comments")
self.widgets['show_commenter_image'].label = _(u"Commenter Image")
self.widgets['moderator_notification_enabled'].label = _(u"Moderator Email Notification")
self.widgets['user_notification_enabled'].label = _(u"User Email Notification")
class DiscussionSettingsControlPanel(controlpanel.ControlPanelFormWrapper):

View File

@ -1,10 +1,14 @@
"""The default comment class and factory.
"""
from datetime import datetime
from zope.interface import implements
from zope.component.factory import Factory
from zope.component import queryUtility
from Acquisition import aq_parent, Implicit
from AccessControl.Role import RoleManager
from AccessControl.Owned import Owned
@ -15,7 +19,9 @@ from Products.CMFCore.utils import getToolByName
from OFS.Traversable import Traversable
from plone.app.discussion.interfaces import IComment
from plone.registry.interfaces import IRegistry
from plone.app.discussion.interfaces import IComment, IDiscussionSettings
try:
# Plone 4:
@ -61,6 +67,8 @@ class Comment(CatalogAware, WorkflowAware, DynamicType, Traversable,
author_name = None
author_email = None
author_notification = None
# Note: we want to use zope.component.createObject() to instantiate
# comments as far as possible. comment_id and __parent__ are set via
@ -125,4 +133,73 @@ def notify_content_object(obj, event):
"""Tell the content object when a comment is added
"""
content_obj = aq_parent(aq_parent(obj))
content_obj.reindexObject(idxs=('total_comments', 'last_comment_date', 'commentators',))
content_obj.reindexObject(idxs=('total_comments',
'last_comment_date',
'commentators',))
def notify_user(obj, event):
"""Tell the user when a comment is added
"""
# check if notification is enabled
registry = queryUtility(IRegistry)
settings = registry.forInterface(IDiscussionSettings)
if not settings.user_notification_enabled:
return
mail_host = getToolByName(obj, 'MailHost')
portal_url = getToolByName(obj, 'portal_url')
portal = portal_url.getPortalObject()
sender = portal.getProperty('email_from_address')
if not sender:
return
conversation = aq_parent(obj)
content_object = aq_parent(conversation)
for comment in conversation.getComments():
if obj != comment and \
comment.author_notification and \
comment.author_email:
subject = "A comment has been posted."
message = "A comment with the title '%s' has been posted here: %s" \
% (obj.title,
content_object.absolute_url(),)
mail_host.send(message, comment.author_email, sender, subject)
def notify_moderator(obj, index):
"""Tell the moderator when a comment needs attention
"""
# check if notification is enabled
registry = queryUtility(IRegistry)
settings = registry.forInterface(IDiscussionSettings)
if not settings.moderator_notification_enabled:
return
# check if comment review workflow is enabled
wf = getToolByName(obj, 'portal_workflow')
if wf.getChainForPortalType('Discussion Item') != \
('comment_review_workflow',):
return
mail_host = getToolByName(obj, 'MailHost')
portal_url = getToolByName(obj, 'portal_url')
portal = portal_url.getPortalObject()
sender = portal.getProperty('email_from_address')
mto = portal.getProperty('email_from_address')
# check if a sender address is available
if not sender:
return
conversation = aq_parent(obj)
content_object = aq_parent(conversation)
comment = conversation.getComments().next()
subject = "A comment has been posted."
message = "A comment with the title '%s' has been posted here: %s" \
% (obj.title,
content_object.absolute_url(),)
mail_host.send(message, mto, sender, subject)

View File

@ -55,6 +55,31 @@ class IDiscussionSettings(Interface):
required=False,
default=True)
moderator_notification_enabled = schema.Bool(
title=_(u"label_moderator_notification_enabled",
default=u"Enable moderator email notification"),
description=_(u"help_moderator_notification_enabled",
default=u"If selected, "
"the moderator "
"is notified "
"if a comment "
"needs "
"attention."),
required=False,
default=False)
user_notification_enabled = schema.Bool(
title=_(u"label_user_notification_enabled",
default=u"Enable user email notification"),
description=_(u"help_user_notification_enabled",
default=u"If selected, users can "
"choose to be notified "
"of new comments by "
"email."),
required=False,
default=False)
class IConversation(IIterableMapping):
"""A conversation about a content object.
@ -157,7 +182,8 @@ class IComment(Interface):
Comments are indexed in the catalog and subject to workflow and security.
"""
portal_type = schema.ASCIILine(title=_(u"Portal type"), default="Discussion Item")
portal_type = schema.ASCIILine(title=_(u"Portal type"),
default="Discussion Item")
__parent__ = schema.Object(title=_(u"Conversation"), schema=Interface)
__name__ = schema.TextLine(title=_(u"Name"))
@ -171,12 +197,16 @@ class IComment(Interface):
# for anonymous comments only, set to None for logged in comments
author_name = schema.TextLine(title=_(u"Name"), required=False)
author_email = schema.TextLine(title=_(u"Email"), required=False)
title = schema.TextLine(title=_(u"Subject"))
mime_type = schema.ASCIILine(title=_(u"MIME type"), default="text/plain")
text = schema.Text(title=_(u"Comment"))
author_notification = schema.Bool(title=_(u"Notify me of new comments via "
"email."),
required=False)
creator = schema.TextLine(title=_(u"Author name (for display)"))
creation_date = schema.Date(title=_(u"Creation date"))
modification_date = schema.Date(title=_(u"Modification date"))

View File

@ -37,6 +37,18 @@
handler=".tool.unindex_object"
/>
<subscriber
for="plone.app.discussion.interfaces.IComment
zope.lifecycleevent.interfaces.IObjectAddedEvent"
handler=".comment.notify_user"
/>
<subscriber
for="plone.app.discussion.interfaces.IComment
zope.lifecycleevent.interfaces.IObjectAddedEvent"
handler=".comment.notify_moderator"
/>
</configure>
<configure zcml:condition="installed zope.app.container">
@ -71,6 +83,18 @@
handler=".tool.unindex_object"
/>
<subscriber
for="plone.app.discussion.interfaces.IComment
zope.app.container.interfaces.IObjectAddedEvent"
handler=".comment.notify_user"
/>
<subscriber
for="plone.app.discussion.interfaces.IComment
zope.app.container.interfaces.IObjectAddedEvent"
handler=".comment.notify_moderator"
/>
</configure>
</configure>

View File

@ -228,6 +228,22 @@ class CommentCatalogTest(PloneTestCase):
# object the comment was added to
self.assertEquals(self.comment_brain.in_response_to, 'doc1')
def test_add_comment(self):
self.failUnless(self.comment_brain)
def test_delete_comment(self):
# Make sure a comment is removed from the catalog as well when it is
# deleted.
del self.conversation[self.comment_id]
brains = self.catalog.searchResults(
path = {'query' : '/'.join(self.comment.getPhysicalPath()) })
self.assertEquals(len(brains), 0)
def test_remove_comments_when_content_object_is_removed(self):
# Make sure all comments are removed from the catalog, if the content
# object is removed.
pass
def test_clear_and_rebuild_catalog(self):
# Clear and rebuild catalog
self.catalog.clearFindAndRebuild()

View File

@ -61,5 +61,19 @@ class RegistryTest(PloneTestCase):
self.failUnless('show_commenter_image' in IDiscussionSettings)
self.assertEquals(self.registry['plone.app.discussion.interfaces.IDiscussionSettings.show_commenter_image'], True)
def test_moderator_notification_enabled(self):
# Check show_commenter_image record
show_commenter_image = self.registry.records['plone.app.discussion.interfaces.IDiscussionSettings.moderator_notification_enabled']
self.failUnless('moderator_notification_enabled' in IDiscussionSettings)
self.assertEquals(self.registry['plone.app.discussion.interfaces.IDiscussionSettings.moderator_notification_enabled'], False)
def test_user_notification_enabled(self):
# Check show_commenter_image record
show_commenter_image = self.registry.records['plone.app.discussion.interfaces.IDiscussionSettings.user_notification_enabled']
self.failUnless('user_notification_enabled' in IDiscussionSettings)
self.assertEquals(self.registry['plone.app.discussion.interfaces.IDiscussionSettings.user_notification_enabled'], False)
def test_suite():
return unittest.defaultTestLoader.loadTestsFromName(__name__)

View File

@ -17,6 +17,8 @@ from plone.app.discussion.interfaces import IConversation, IComment
class MigrationTest(PloneTestCase):
layer = DiscussionLayer
def afterSetUp(self):
self.loginAsPortalOwner()
typetool = self.portal.portal_types

View File

@ -0,0 +1,239 @@
import unittest
from Acquisition import aq_base
from zope.app.container.contained import ObjectAddedEvent
from zope.app.container.interfaces import IObjectAddedEvent
from zope.component import createObject
from zope.component import getSiteManager
from zope.component import queryUtility
from Products.PloneTestCase.ptc import PloneTestCase
from Products.MailHost.interfaces import IMailHost
from Products.CMFPlone.tests.utils import MockMailHost
from plone.registry.interfaces import IRegistry
from plone.app.discussion.comment import notify_user
from plone.app.discussion.interfaces import IComment, IConversation, IReplies
from plone.app.discussion.interfaces import IDiscussionSettings
from plone.app.discussion.tests.layer import DiscussionLayer
class TestUserNotificationUnit(PloneTestCase):
layer = DiscussionLayer
def afterSetUp(self):
# Set up a mock mailhost
self.portal._original_MailHost = self.portal.MailHost
self.portal.MailHost = mailhost = MockMailHost('MailHost')
sm = getSiteManager(context=self.portal)
sm.unregisterUtility(provided=IMailHost)
sm.registerUtility(mailhost, provided=IMailHost)
# We need to fake a valid mail setup
self.portal.email_from_address = "portal@plone.test"
self.mailhost = self.portal.MailHost
# Enable user notification setting
registry = queryUtility(IRegistry)
settings = registry.forInterface(IDiscussionSettings)
registry['plone.app.discussion.interfaces.IDiscussionSettings.user_notification_enabled'] = True
# Create test content
self.loginAsPortalOwner()
self.portal.invokeFactory('Document', 'doc1')
self.portal_discussion = self.portal.portal_discussion
self.conversation = IConversation(self.portal.doc1)
def beforeTearDown(self):
self.portal.MailHost = self.portal._original_MailHost
sm = getSiteManager(context=self.portal)
sm.unregisterUtility(provided=IMailHost)
sm.registerUtility(aq_base(self.portal._original_MailHost), provided=IMailHost)
def test_notify_user(self):
# Add a comment with user notification enabled. Add another comment
# and make sure an email is send to the user of the first comment.
comment = createObject('plone.Comment')
comment.title = 'Comment 1'
comment.text = 'Comment text'
comment.author_notification = True
comment.author_email = "john@plone.test"
self.conversation.addComment(comment)
comment = createObject('plone.Comment')
comment.title = 'Comment 2'
comment.text = 'Comment text'
self.conversation.addComment(comment)
self.assertEquals(len(self.mailhost.messages), 1)
self.failUnless(self.mailhost.messages[0])
msg = self.mailhost.messages[0]
self.failUnless('To: john@plone.test' in msg)
self.failUnless('From: portal@plone.test' in msg)
#We expect the headers to be properly header encoded (7-bit):
#>>> 'Subject: =?utf-8?q?Some_t=C3=A4st_subject=2E?=' in msg
#True
#The output should be encoded in a reasonable manner (in this case quoted-printable):
#>>> msg
#'...Another t=C3=A4st message...You are receiving this mail because T=C3=A4st user\ntest@plone.test...is sending feedback about the site you administer at...
def test_do_not_notify_user_when_notification_is_disabled(self):
# Disable user notification and make sure no email is send to the user.
registry = queryUtility(IRegistry)
settings = registry.forInterface(IDiscussionSettings)
registry['plone.app.discussion.interfaces.IDiscussionSettings.user_notification_enabled'] = False
comment = createObject('plone.Comment')
comment.title = 'Comment 1'
comment.text = 'Comment text'
comment.author_notification = True
comment.author_email = "john@plone.test"
self.conversation.addComment(comment)
comment = createObject('plone.Comment')
comment.title = 'Comment 2'
comment.text = 'Comment text'
self.conversation.addComment(comment)
self.assertEquals(len(self.mailhost.messages), 0)
def test_do_not_notify_user_when_email_address_is_given(self):
comment = createObject('plone.Comment')
comment.title = 'Comment 1'
comment.text = 'Comment text'
comment.author_notification = True
self.conversation.addComment(comment)
comment = createObject('plone.Comment')
comment.title = 'Comment 2'
comment.text = 'Comment text'
self.conversation.addComment(comment)
self.assertEquals(len(self.mailhost.messages), 0)
def test_do_not_notify_user_when_no_sender_is_available(self):
# Set sender mail address to nonw and make sure no email is send to the
# moderator.
self.portal.email_from_address = None
comment = createObject('plone.Comment')
comment.title = 'Comment 1'
comment.text = 'Comment text'
comment.author_notification = True
comment.author_email = "john@plone.test"
self.conversation.addComment(comment)
comment = createObject('plone.Comment')
comment.title = 'Comment 2'
comment.text = 'Comment text'
self.conversation.addComment(comment)
self.assertEquals(len(self.mailhost.messages), 0)
class TestModeratorNotificationUnit(PloneTestCase):
layer = DiscussionLayer
def afterSetUp(self):
# Set up a mock mailhost
self.portal._original_MailHost = self.portal.MailHost
self.portal.MailHost = mailhost = MockMailHost('MailHost')
sm = getSiteManager(context=self.portal)
sm.unregisterUtility(provided=IMailHost)
sm.registerUtility(mailhost, provided=IMailHost)
# We need to fake a valid mail setup
self.portal.email_from_address = "portal@plone.test"
self.mailhost = self.portal.MailHost
# Enable comment moderation
self.portal.portal_types['Document'].allow_discussion = True
self.portal.portal_workflow.setChainForPortalTypes(
('Discussion Item',),
('comment_review_workflow',))
# Enable moderator notification setting
registry = queryUtility(IRegistry)
settings = registry.forInterface(IDiscussionSettings)
registry['plone.app.discussion.interfaces.IDiscussionSettings.moderator_notification_enabled'] = True
# Create test content
self.loginAsPortalOwner()
self.portal.invokeFactory('Document', 'doc1')
self.portal_discussion = self.portal.portal_discussion
self.conversation = IConversation(self.portal.doc1)
def beforeTearDown(self):
self.portal.MailHost = self.portal._original_MailHost
sm = getSiteManager(context=self.portal)
sm.unregisterUtility(provided=IMailHost)
sm.registerUtility(aq_base(self.portal._original_MailHost), provided=IMailHost)
def test_notify_moderator(self):
# Add a comment and make sure an email is send to the moderator.
comment = createObject('plone.Comment')
comment.title = 'Comment 1'
comment.text = 'Comment text'
self.conversation.addComment(comment)
self.assertEquals(len(self.mailhost.messages), 1)
self.failUnless(self.mailhost.messages[0])
msg = self.mailhost.messages[0]
self.failUnless('To: portal@plone.test' in msg)
self.failUnless('From: portal@plone.test' in msg)
#We expect the headers to be properly header encoded (7-bit):
#>>> 'Subject: =?utf-8?q?Some_t=C3=A4st_subject=2E?=' in msg
#True
#The output should be encoded in a reasonable manner (in this case quoted-printable):
#>>> msg
#'...Another t=C3=A4st message...You are receiving this mail because T=C3=A4st user\ntest@plone.test...is sending feedback about the site you administer at...
def test_do_not_notify_moderator_when_no_sender_is_available(self):
# Set sender mail address to nonw and make sure no email is send to the
# moderator.
self.portal.email_from_address = None
comment = createObject('plone.Comment')
comment.title = 'Comment 1'
comment.text = 'Comment text'
self.conversation.addComment(comment)
self.assertEquals(len(self.mailhost.messages), 0)
def test_do_not_notify_moderator_when_notification_is_disabled(self):
# Disable moderator notification setting and make sure no email is send
# to the moderator.
registry = queryUtility(IRegistry)
settings = registry.forInterface(IDiscussionSettings)
registry['plone.app.discussion.interfaces.IDiscussionSettings.moderator_notification_enabled'] = False
comment = createObject('plone.Comment')
comment.title = 'Comment 1'
comment.text = 'Comment text'
self.conversation.addComment(comment)
self.assertEquals(len(self.mailhost.messages), 0)
def test_do_not_notify_moderator_when_moderation_workflow_is_disabled(self):
# Disable comment moderation and make sure no email is send to the
# moderator.
self.portal.portal_types['Document'].allow_discussion = True
self.portal.portal_workflow.setChainForPortalTypes(
('Discussion Item',),
('simple_publication_workflow',))
comment = createObject('plone.Comment')
comment.title = 'Comment 1'
comment.text = 'Comment text'
self.conversation.addComment(comment)
self.assertEquals(len(self.mailhost.messages), 0)
def test_suite():
return unittest.defaultTestLoader.loadTestsFromName(__name__)

View File

@ -29,7 +29,7 @@ setup(name='plone.app.discussion',
'plone.app.z3cform',
'plone.indexer',
'plone.registry',
'plone.z3cform<=0.5.7',
'plone.z3cform',
'ZODB3',
'zope.interface',
'zope.component',