plone.app.discussion/plone/app/discussion/browser/comments.py

504 lines
20 KiB
Python
Raw Normal View History

# -*- coding: utf-8 -*-
from AccessControl import getSecurityManager
from AccessControl import Unauthorized
from Acquisition import aq_inner
from DateTime import DateTime
from Products.CMFCore.utils import getToolByName
from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile
from Products.statusmessages.interfaces import IStatusMessage
from datetime import datetime
from plone.app.discussion import _
from plone.app.discussion.browser.validator import CaptchaValidator
from plone.app.discussion.interfaces import ICaptcha
from plone.app.discussion.interfaces import IComment
from plone.app.discussion.interfaces import IConversation
from plone.app.discussion.interfaces import IDiscussionSettings
from plone.app.discussion.interfaces import IReplies
from plone.app.layout.viewlets.common import ViewletBase
from plone.registry.interfaces import IRegistry
from plone.z3cform import z2
from plone.z3cform.fieldsets import extensible
from plone.z3cform.interfaces import IWrappedForm
from urllib import quote as url_quote
from z3c.form import button
from z3c.form import field
from z3c.form import form
from z3c.form import interfaces
from z3c.form.browser.checkbox import SingleCheckBoxFieldWidget
from z3c.form.interfaces import IFormLayer
from zope.component import createObject
from zope.component import queryUtility
from zope.i18n import translate
from zope.i18nmessageid import Message
from zope.interface import alsoProvides
COMMENT_DESCRIPTION_PLAIN_TEXT = _(
u"comment_description_plain_text",
default=u"You can add a comment by filling out the form below. " +
2013-04-18 15:46:28 +02:00
u"Plain text formatting.")
COMMENT_DESCRIPTION_MARKDOWN = _(
u"comment_description_markdown",
default=u"You can add a comment by filling out the form below. " +
2013-04-18 15:46:28 +02:00
u"Plain text formatting. You can use the Markdown syntax for " +
u"links and images.")
COMMENT_DESCRIPTION_INTELLIGENT_TEXT = _(
u"comment_description_intelligent_text",
default=u"You can add a comment by filling out the form below. " +
2013-04-18 15:46:28 +02:00
u"Plain text formatting. Web and email addresses are " +
u"transformed into clickable links.")
COMMENT_DESCRIPTION_MODERATION_ENABLED = _(
u"comment_description_moderation_enabled",
default=u"Comments are moderated.")
class CommentForm(extensible.ExtensibleForm, form.Form):
2012-01-14 07:26:01 +01:00
ignoreContext = True # don't use context to get widget data
id = None
label = _(u"Add a comment")
fields = field.Fields(IComment).omit('portal_type',
'__parent__',
'__name__',
'comment_id',
'mime_type',
'creator',
'creation_date',
'modification_date',
'author_username',
'title')
def updateFields(self):
super(CommentForm, self).updateFields()
self.fields['user_notification'].widgetFactory = \
SingleCheckBoxFieldWidget
def updateWidgets(self):
super(CommentForm, self).updateWidgets()
# Widgets
self.widgets['in_reply_to'].mode = interfaces.HIDDEN_MODE
self.widgets['text'].addClass("autoresize")
self.widgets['user_notification'].label = _(u"")
# Rename the id of the text widgets because there can be css-id
# clashes with the text field of documents when using and overlay
# with TinyMCE.
self.widgets['text'].id = "form-widgets-comment-text"
# Anonymous / Logged-in
mtool = getToolByName(self.context, 'portal_membership')
anon = mtool.isAnonymousUser()
registry = queryUtility(IRegistry)
settings = registry.forInterface(IDiscussionSettings, check=False)
if anon:
if settings.anonymous_email_enabled:
# according to IDiscussionSettings.anonymous_email_enabled:
# "If selected, anonymous user will have to give their email."
self.widgets['author_email'].field.required = True
self.widgets['author_email'].required = True
else:
self.widgets['author_email'].mode = interfaces.HIDDEN_MODE
else:
self.widgets['author_name'].mode = interfaces.HIDDEN_MODE
self.widgets['author_email'].mode = interfaces.HIDDEN_MODE
member = mtool.getAuthenticatedMember()
member_email = member.getProperty('email')
# Hide the user_notification checkbox if user notification is disabled
2012-01-14 07:26:01 +01:00
# or the user is not logged in. Also check if the user has a valid
# email address
2013-04-18 15:46:28 +02:00
member_email_is_empty = member_email == ''
user_notification_disabled = not settings.user_notification_enabled
if member_email_is_empty or user_notification_disabled or anon:
self.widgets['user_notification'].mode = interfaces.HIDDEN_MODE
def updateActions(self):
super(CommentForm, self).updateActions()
self.actions['cancel'].addClass("standalone")
self.actions['cancel'].addClass("hide")
self.actions['comment'].addClass("context")
@button.buttonAndHandler(_(u"add_comment_button", default=u"Comment"),
name='comment')
def handleComment(self, action):
context = aq_inner(self.context)
2012-01-14 07:26:01 +01:00
# Check if conversation is enabled on this content object
if not self.__parent__.restrictedTraverse(
2013-04-18 15:46:28 +02:00
'@@conversation_view'
).enabled():
raise Unauthorized("Discussion is not enabled for this content "
"object.")
2012-01-14 07:26:01 +01:00
# Validation form
data, errors = self.extractData()
if errors:
return
2012-01-14 07:26:01 +01:00
# Validate Captcha
registry = queryUtility(IRegistry)
settings = registry.forInterface(IDiscussionSettings, check=False)
portal_membership = getToolByName(self.context, 'portal_membership')
2013-04-18 15:46:28 +02:00
captcha_enabled = settings.captcha != 'disabled'
anonymous_comments = settings.anonymous_comments
anon = portal_membership.isAnonymousUser()
if captcha_enabled and anonymous_comments and anon:
2015-05-03 08:56:58 +02:00
if 'captcha' not in data:
data['captcha'] = u""
captcha = CaptchaValidator(self.context,
self.request,
None,
ICaptcha['captcha'],
None)
captcha.validate(data['captcha'])
2012-01-14 07:26:01 +01:00
# some attributes are not always set
author_name = u""
2012-01-14 07:26:01 +01:00
# Create comment
comment = createObject('plone.Comment')
# Set comment mime type to current setting in the discussion registry
comment.mime_type = settings.text_transform
# Set comment attributes (including extended comment form attributes)
for attribute in self.fields.keys():
setattr(comment, attribute, data[attribute])
# Make sure author_name/ author_email is properly encoded
if 'author_name' in data:
author_name = data['author_name']
if isinstance(author_name, str):
author_name = unicode(author_name, 'utf-8')
if 'author_email' in data:
author_email = data['author_email']
if isinstance(author_email, str):
author_email = unicode(author_email, 'utf-8')
2012-01-14 07:26:01 +01:00
# Set comment author properties for anonymous users or members
can_reply = getSecurityManager().checkPermission('Reply to item',
context)
portal_membership = getToolByName(self.context, 'portal_membership')
2013-04-18 15:46:28 +02:00
if anon and anonymous_comments:
# Anonymous Users
comment.author_name = author_name
comment.author_email = author_email
comment.user_notification = None
2012-01-14 07:26:01 +01:00
comment.creation_date = datetime.utcnow()
comment.modification_date = datetime.utcnow()
elif not portal_membership.isAnonymousUser() and can_reply:
# Member
member = portal_membership.getAuthenticatedMember()
memberid = member.getId()
2013-11-04 16:30:14 +01:00
user = member.getUser()
email = member.getProperty('email')
fullname = member.getProperty('fullname')
if not fullname or fullname == '':
fullname = member.getUserName()
# memberdata is stored as utf-8 encoded strings
elif isinstance(fullname, str):
fullname = unicode(fullname, 'utf-8')
if email and isinstance(email, str):
email = unicode(email, 'utf-8')
2013-11-04 16:30:14 +01:00
comment.changeOwnership(user, recursive=False)
comment.manage_setLocalRoles(memberid, ["Owner"])
comment.creator = memberid
comment.author_username = memberid
comment.author_name = fullname
# XXX: according to IComment interface author_email must not be
# set for logged in users, cite:
# "for anonymous comments only, set to None for logged in comments"
comment.author_email = email
# /XXX
2012-01-14 07:26:01 +01:00
comment.creation_date = datetime.utcnow()
comment.modification_date = datetime.utcnow()
2012-01-14 07:26:01 +01:00
else: # pragma: no cover
2013-04-18 15:46:28 +02:00
raise Unauthorized(
u"Anonymous user tries to post a comment, but anonymous "
u"commenting is disabled. Or user does not have the "
u"'reply to item' permission."
)
# Add comment to conversation
conversation = IConversation(self.__parent__)
if data['in_reply_to']:
# Add a reply to an existing comment
conversation_to_reply_to = conversation.get(data['in_reply_to'])
replies = IReplies(conversation_to_reply_to)
comment_id = replies.addComment(comment)
else:
# Add a comment to the conversation
comment_id = conversation.addComment(comment)
# Redirect after form submit:
# If a user posts a comment and moderation is enabled, a message is
# shown to the user that his/her comment awaits moderation. If the user
# has 'review comments' permission, he/she is redirected directly
# to the comment.
can_review = getSecurityManager().checkPermission('Review comments',
context)
workflowTool = getToolByName(context, 'portal_workflow')
comment_review_state = workflowTool.getInfoFor(
2013-04-18 15:46:28 +02:00
comment,
'review_state',
None
)
if comment_review_state == 'pending' and not can_review:
# Show info message when comment moderation is enabled
IStatusMessage(self.context.REQUEST).addStatusMessage(
_("Your comment awaits moderator approval."),
type="info")
self.request.response.redirect(self.action)
else:
# Redirect to comment (inside a content object page)
self.request.response.redirect(self.action + '#' + str(comment_id))
@button.buttonAndHandler(_(u"Cancel"))
def handleCancel(self, action):
# This method should never be called, it's only there to show
# a cancel button that is handled by a jQuery method.
2012-01-14 07:26:01 +01:00
pass # pragma: no cover
class CommentsViewlet(ViewletBase):
form = CommentForm
index = ViewPageTemplateFile('comments.pt')
def update(self):
super(CommentsViewlet, self).update()
2013-04-18 15:46:28 +02:00
discussion_allowed = self.is_discussion_allowed()
anonymous_allowed_or_can_reply = (
self.is_anonymous()
and self.anonymous_discussion_allowed()
or self.can_reply()
)
if discussion_allowed and anonymous_allowed_or_can_reply:
z2.switch_on(self, request_layer=IFormLayer)
self.form = self.form(aq_inner(self.context), self.request)
alsoProvides(self.form, IWrappedForm)
self.form.update()
# view methods
def can_reply(self):
"""Returns true if current user has the 'Reply to item' permission.
"""
return getSecurityManager().checkPermission('Reply to item',
aq_inner(self.context))
def can_manage(self):
"""We keep this method for <= 1.0b9 backward compatibility. Since we do
not want any API changes in beta releases.
"""
return self.can_review()
def can_review(self):
"""Returns true if current user has the 'Review comments' permission.
"""
return getSecurityManager().checkPermission('Review comments',
aq_inner(self.context))
def can_delete_own(self, comment):
"""Returns true if the current user can delete the comment. Only
comments without replies can be deleted.
"""
try:
2014-08-17 04:48:44 +02:00
return comment.restrictedTraverse(
'@@delete-own-comment').can_delete()
except Unauthorized:
return False
def could_delete_own(self, comment):
2014-08-17 04:48:44 +02:00
"""Returns true if the current user could delete the comment if it had
no replies. This is used to prepare hidden form buttons for JS.
"""
try:
2014-08-17 04:48:44 +02:00
return comment.restrictedTraverse(
'@@delete-own-comment').could_delete()
except Unauthorized:
return False
2013-09-17 14:03:46 +02:00
def can_edit(self, reply):
"""Returns true if current user has the 'Edit comments'
2013-09-17 14:03:46 +02:00
permission.
"""
return getSecurityManager().checkPermission('Edit comments',
aq_inner(reply))
def can_delete(self, reply):
"""Returns true if current user has the 'Delete comments'
permission.
"""
return getSecurityManager().checkPermission('Delete comments',
aq_inner(reply))
def is_discussion_allowed(self):
context = aq_inner(self.context)
return context.restrictedTraverse('@@conversation_view').enabled()
def comment_transform_message(self):
"""Returns the description that shows up above the comment text,
dependent on the text_transform setting and the comment moderation
workflow in the discussion control panel.
"""
context = aq_inner(self.context)
registry = queryUtility(IRegistry)
settings = registry.forInterface(IDiscussionSettings, check=False)
# text transform setting
if settings.text_transform == "text/x-web-intelligent":
message = translate(Message(COMMENT_DESCRIPTION_INTELLIGENT_TEXT),
context=self.request)
elif settings.text_transform == "text/x-web-markdown":
message = translate(Message(COMMENT_DESCRIPTION_MARKDOWN),
context=self.request)
else:
message = translate(Message(COMMENT_DESCRIPTION_PLAIN_TEXT),
context=self.request)
# comment workflow
wftool = getToolByName(context, "portal_workflow", None)
workflow_chain = wftool.getChainForPortalType('Discussion Item')
if workflow_chain:
comment_workflow = workflow_chain[0]
comment_workflow = wftool[comment_workflow]
# check if the current workflow implements a pending state. If this
# is true comments are moderated
if 'pending' in comment_workflow.states:
message = message + " " + \
translate(Message(COMMENT_DESCRIPTION_MODERATION_ENABLED),
context=self.request)
return message
def has_replies(self, workflow_actions=False):
"""Returns true if there are replies.
"""
if self.get_replies(workflow_actions) is not None:
try:
self.get_replies(workflow_actions).next()
return True
2012-01-14 07:26:01 +01:00
except StopIteration: # pragma: no cover
pass
return False
def get_replies(self, workflow_actions=False):
"""Returns all replies to a content object.
If workflow_actions is false, only published
comments are returned.
If workflow actions is true, comments are
returned with workflow actions.
"""
context = aq_inner(self.context)
conversation = IConversation(context, None)
if conversation is None:
return iter([])
wf = getToolByName(context, 'portal_workflow')
# workflow_actions is only true when user
# has 'Manage portal' permission
def replies_with_workflow_actions():
# Generator that returns replies dict with workflow actions
for r in conversation.getThreads():
comment_obj = r['comment']
# list all possible workflow actions
2013-04-18 15:46:28 +02:00
actions = [
a for a in wf.listActionInfos(object=comment_obj)
if a['category'] == 'workflow' and a['allowed']
]
r = r.copy()
r['actions'] = actions
yield r
def published_replies():
# Generator that returns replies dict with workflow status.
for r in conversation.getThreads():
comment_obj = r['comment']
workflow_status = wf.getInfoFor(comment_obj, 'review_state')
if workflow_status == 'published':
r = r.copy()
r['workflow_status'] = workflow_status
yield r
# Return all direct replies
if len(conversation.objectIds()):
if workflow_actions:
return replies_with_workflow_actions()
else:
return published_replies()
def get_commenter_home_url(self, username=None):
if username is None:
return None
else:
return "%s/author/%s" % (self.context.portal_url(), username)
def get_commenter_portrait(self, username=None):
if username is None:
# return the default user image if no username is given
return 'defaultUser.png'
else:
portal_membership = getToolByName(self.context,
'portal_membership',
None)
2013-04-18 15:46:28 +02:00
return portal_membership\
.getPersonalPortrait(username)\
.absolute_url()
def anonymous_discussion_allowed(self):
# Check if anonymous comments are allowed in the registry
registry = queryUtility(IRegistry)
settings = registry.forInterface(IDiscussionSettings, check=False)
return settings.anonymous_comments
2013-09-17 14:03:46 +02:00
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 delete_own_comment_allowed(self):
# Check if delete own comments is allowed in the registry
registry = queryUtility(IRegistry)
settings = registry.forInterface(IDiscussionSettings, check=False)
return settings.delete_own_comment_enabled
2013-09-17 14:03:46 +02:00
def show_commenter_image(self):
# Check if showing commenter image is enabled in the registry
registry = queryUtility(IRegistry)
settings = registry.forInterface(IDiscussionSettings, check=False)
return settings.show_commenter_image
def is_anonymous(self):
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', '')),)
def format_time(self, time):
# We have to transform Python datetime into Zope DateTime
# before we can call toLocalizedTime.
util = getToolByName(self.context, 'translation_service')
zope_time = DateTime(time.isoformat())
return util.toLocalizedTime(zope_time, long_format=True)