merged master

This commit is contained in:
Jens W. Klein
2014-08-17 04:48:44 +02:00
140 changed files with 8593 additions and 5378 deletions
+69
View File
@@ -1,8 +1,16 @@
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.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):
"""Comment View.
@@ -37,3 +45,64 @@ class View(BrowserView):
url = "%s/view" % url
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
+38 -17
View File
@@ -1,6 +1,7 @@
<tal:block tal:define="userHasReplyPermission view/can_reply;
isDiscussionAllowed view/is_discussion_allowed;
isAnonymousDiscussionAllowed view/anonymous_discussion_allowed;
isEditCommentAllowed view/edit_comment_allowed;
isAnon view/is_anonymous;
canReview view/can_review;
replies python:view.get_replies(canReview);
@@ -14,8 +15,7 @@
<div class="reply"
tal:condition="python:isAnon and not isAnonymousDiscussionAllowed">
<form tal:attributes="action view/login_action">
<input class="standalone"
style="margin-bottom: 1.25em;"
<input class="standalone loginbutton"
type="submit"
value="Log in to add comments"
i18n:attributes="value label_login_to_add_comments;"
@@ -31,45 +31,48 @@
<div class="comment"
tal:define="reply reply_dict/comment;
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);
has_author_link python:author_home_url and not isAnon;
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);
canDelete python:view.can_delete(reply)"
tal:attributes="class python:'comment replyTreeLevel'+str(depth)+' state-'+str(review_state);
style string:margin-left: ${depth}em;
id string:${reply/getId}"
tal:condition="python:canReview or review_state == 'published'">
<div class="commentImage" tal:condition="showCommenterImage">
<a href="" tal:condition="has_author_link"
tal:attributes="href author_home_url">
<img src="defaultUser.gif"
<img src="defaultUser.png"
alt=""
border="0"
class="defaultuserimg"
height="32"
tal:attributes="src portrait_url;
alt reply/Creator" />
alt reply/author_name" />
</a>
<img src="defaultUser.gif"
<img src="defaultUser.png"
alt=""
border="0"
class="defaultuserimg"
height="32"
tal:condition="not: has_author_link"
tal:attributes="src portrait_url;
alt reply/Creator" />
alt reply/author_name" />
</div>
<div class="documentByLine" i18n:domain="plone.app.discussion">
<tal:name>
<a href=""
tal:condition="has_author_link"
tal:content="reply/Creator"
tal:content="reply/author_name"
tal:attributes="href author_home_url">
Poster Name
</a>
<span tal:condition="not: has_author_link"
tal:replace="reply/Creator" />
<span tal:condition="not: reply/Creator">Anonymous</span>
tal:replace="reply/author_name" />
<span tal:condition="not: reply/author_name"
i18n:translate="label_anonymous">Anonymous</span>
</tal:name>
<tal:posted i18n:translate="label_says">says:</tal:posted>
<div class="commentDate"
@@ -86,7 +89,9 @@
<form name="delete"
action=""
method="post"
tal:condition="python: not view.can_review() and view.could_delete_own(reply)"
style="display: inline;"
class="commentactionsform"
tal:condition="python: not canDelete 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'">
<input name="form.button.DeleteComment"
@@ -101,6 +106,8 @@
method="post"
style="display: inline;"
tal:condition="canReview"
class="commentactionsform"
tal:condition="python:canDelete"
tal:attributes="action string:${reply/absolute_url}/@@moderate-delete-comment">
<input name="form.button.DeleteComment"
class="destructive"
@@ -110,11 +117,26 @@
/>
</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') -->
<form name=""
action=""
method="get"
style="display: inline;"
class="commentactionsform"
tal:condition="canReview"
tal:repeat="action reply_dict/actions|nothing"
tal:attributes="action string:${reply/absolute_url}/@@moderate-publish-comment;
@@ -152,8 +174,7 @@
<div class="reply"
tal:condition="python:has_replies and (isAnon and not isAnonymousDiscussionAllowed)">
<form tal:attributes="action view/login_action">
<input class="standalone"
style="margin-bottom: 1.25em;"
<input class="standalone loginbutton"
type="submit"
value="Log in to add comments"
i18n:attributes="value label_login_to_add_comments;"
+113 -55
View File
@@ -38,7 +38,6 @@ from plone.app.discussion.interfaces import ICaptcha
from plone.app.discussion.browser.validator import CaptchaValidator
from plone.z3cform import z2
from plone.z3cform.widget import SingleCheckBoxWidget
from plone.z3cform.fieldsets import extensible
@@ -47,19 +46,19 @@ from plone.z3cform.interfaces import IWrappedForm
COMMENT_DESCRIPTION_PLAIN_TEXT = _(
u"comment_description_plain_text",
default=u"You can add a comment by filling out the form below. " +
"Plain text formatting.")
u"Plain text formatting.")
COMMENT_DESCRIPTION_MARKDOWN = _(
u"comment_description_markdown",
default=u"You can add a comment by filling out the form below. " +
"Plain text formatting. You can use the Markdown syntax for " +
"links and images.")
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. " +
"Plain text formatting. Web and email addresses are " +
"transformed into clickable links.")
u"Plain text formatting. Web and email addresses are " +
u"transformed into clickable links.")
COMMENT_DESCRIPTION_MODERATION_ENABLED = _(
u"comment_description_moderation_enabled",
@@ -95,29 +94,40 @@ class CommentForm(extensible.ExtensibleForm, form.Form):
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')
if not mtool.isAnonymousUser():
self.widgets['author_name'].mode = interfaces.HIDDEN_MODE
self.widgets['author_email'].mode = interfaces.HIDDEN_MODE
# Todo: 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
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
# or the user is not logged in. Also check if the user has a valid
# email address
if member_email == '' or \
not settings.user_notification_enabled or \
mtool.isAnonymousUser():
self.widgets['user_notification'].mode = interfaces.HIDDEN_MODE
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()
@@ -132,7 +142,8 @@ class CommentForm(extensible.ExtensibleForm, form.Form):
# Check if conversation is enabled on this content object
if not self.__parent__.restrictedTraverse(
'@@conversation_view').enabled():
'@@conversation_view'
).enabled():
raise Unauthorized("Discussion is not enabled for this content "
"object.")
@@ -145,9 +156,10 @@ class CommentForm(extensible.ExtensibleForm, form.Form):
registry = queryUtility(IRegistry)
settings = registry.forInterface(IDiscussionSettings, check=False)
portal_membership = getToolByName(self.context, 'portal_membership')
if settings.captcha != 'disabled' and \
settings.anonymous_comments and \
portal_membership.isAnonymousUser():
captcha_enabled = settings.captcha != 'disabled'
anonymous_comments = settings.anonymous_comments
anon = portal_membership.isAnonymousUser()
if captcha_enabled and anonymous_comments and anon:
if not 'captcha' in data:
data['captcha'] = u""
captcha = CaptchaValidator(self.context,
@@ -159,38 +171,42 @@ class CommentForm(extensible.ExtensibleForm, form.Form):
# some attributes are not always set
author_name = u""
author_email = u""
user_notification = None
# 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 is properly encoded
# 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')
# Set comment author properties for anonymous users or members
can_reply = getSecurityManager().checkPermission('Reply to item',
context)
portal_membership = getToolByName(self.context, 'portal_membership')
if portal_membership.isAnonymousUser() and \
settings.anonymous_comments:
if anon and anonymous_comments:
# Anonymous Users
comment.creator = author_name
comment.author_name = author_name
comment.author_email = author_email
comment.user_notification = user_notification
comment.user_notification = None
comment.creation_date = datetime.utcnow()
comment.modification_date = datetime.utcnow()
elif not portal_membership.isAnonymousUser() and can_reply:
# Member
member = portal_membership.getAuthenticatedMember()
username = member.getUserName()
userid = member.getUserId()
memberid = member.getId()
user = member.getUser()
email = member.getProperty('email')
fullname = member.getProperty('fullname')
if not fullname or fullname == '':
@@ -200,21 +216,27 @@ class CommentForm(extensible.ExtensibleForm, form.Form):
fullname = unicode(fullname, 'utf-8')
if email and isinstance(email, str):
email = unicode(email, 'utf-8')
comment.creator = fullname
comment.author_username = username
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
comment.user_notification = user_notification
# /XXX
comment.creation_date = datetime.utcnow()
comment.modification_date = datetime.utcnow()
# add local "Owner" role for current user
comment.manage_setLocalRoles(userid, ['Owner'])
else: # pragma: no cover
raise Unauthorized("Anonymous user tries to post a comment, but "
"anonymous commenting is disabled. Or user does not have the "
"'reply to item' permission.")
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__)
@@ -235,7 +257,11 @@ class CommentForm(extensible.ExtensibleForm, form.Form):
can_review = getSecurityManager().checkPermission('Review comments',
context)
workflowTool = getToolByName(context, 'portal_workflow')
comment_review_state = workflowTool.getInfoFor(comment, 'review_state')
comment_review_state = workflowTool.getInfoFor(
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(
@@ -260,9 +286,13 @@ class CommentsViewlet(ViewletBase):
def update(self):
super(CommentsViewlet, self).update()
if self.is_discussion_allowed() and \
(self.is_anonymous() and self.anonymous_discussion_allowed() \
or self.can_reply()):
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)
@@ -293,19 +323,35 @@ class CommentsViewlet(ViewletBase):
comments without replies can be deleted.
"""
try:
return comment.restrictedTraverse('@@delete-own-comment').can_delete()
return comment.restrictedTraverse(
'@@delete-own-comment').can_delete()
except Unauthorized:
return False
def could_delete_own(self, comment):
"""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.
"""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:
return comment.restrictedTraverse('@@delete-own-comment').could_delete()
return comment.restrictedTraverse(
'@@delete-own-comment').could_delete()
except Unauthorized:
return False
def can_edit(self, reply):
"""Returns true if current user has the 'Edit comments'
permission.
"""
return getSecurityManager().checkPermission('Edit comments',
aq_inner(reply))
def can_delete(self, reply):
"""By default requires 'Review comments'.
If 'delete own comments' is enabled, requires 'Edit comments'.
"""
return getSecurityManager().checkPermission('Delete comments',
aq_inner(reply))
def is_discussion_allowed(self):
context = aq_inner(self.context)
return context.restrictedTraverse('@@conversation_view').enabled()
@@ -366,7 +412,10 @@ class CommentsViewlet(ViewletBase):
returned with workflow actions.
"""
context = aq_inner(self.context)
conversation = IConversation(context)
conversation = IConversation(context, None)
if conversation is None:
return iter([])
wf = getToolByName(context, 'portal_workflow')
# workflow_actions is only true when user
@@ -377,8 +426,10 @@ class CommentsViewlet(ViewletBase):
for r in conversation.getThreads():
comment_obj = r['comment']
# list all possible workflow actions
actions = [a for a in wf.listActionInfos(object=comment_obj)
if a['category'] == 'workflow' and a['allowed']]
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
@@ -394,7 +445,7 @@ class CommentsViewlet(ViewletBase):
yield r
# Return all direct replies
if conversation.total_comments > 0:
if len(conversation.objectIds()):
if workflow_actions:
return replies_with_workflow_actions()
else:
@@ -410,13 +461,14 @@ class CommentsViewlet(ViewletBase):
if username is None:
# return the default user image if no username is given
return 'defaultUser.gif'
return 'defaultUser.png'
else:
portal_membership = getToolByName(self.context,
'portal_membership',
None)
return portal_membership.getPersonalPortrait(username)\
.absolute_url()
return portal_membership\
.getPersonalPortrait(username)\
.absolute_url()
def anonymous_discussion_allowed(self):
# Check if anonymous comments are allowed in the registry
@@ -424,6 +476,12 @@ class CommentsViewlet(ViewletBase):
settings = registry.forInterface(IDiscussionSettings, check=False)
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):
# Check if showing commenter image is enabled in the registry
registry = queryUtility(IRegistry)
+13 -2
View File
@@ -71,13 +71,24 @@
permission="zope2.View"
/>
<!-- Delete comment views -->
<!-- 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 views
has conditional security dependent on controlpanel settings.
-->
<browser:page
for="plone.app.discussion.interfaces.IComment"
name="moderate-delete-comment"
layer="..interfaces.IDiscussionLayer"
class=".moderation.DeleteComment"
permission="plone.app.discussion.ReviewComments"
permission="plone.app.discussion.DeleteComments"
/>
<browser:page
+23 -21
View File
@@ -22,12 +22,12 @@
tal:attributes="src string:${context/portal_url}/++resource++plone.app.discussion.javascripts/controlpanel.js">
</script>
<dl class="portalMessage warning"
<div class="portalMessage warning"
tal:condition="view/mailhost_warning">
<dt i18n:translate="">
<strong i18n:translate="">
Warning
</dt>
<dd i18n:translate="text_no_mailhost_configured">
</strong>
<span tal:omit-tag="" i18n:translate="text_no_mailhost_configured">
You have not configured a mail host or a site 'From'
address, various features including contact forms, email
notification and password reset will not work. Go to the
@@ -38,15 +38,15 @@
>Mail control panel</a>
</tal:link>
to fix this.
</dd>
</dl>
</span>
</div>
<dl class="portalMessage warning"
<div class="portalMessage warning"
tal:condition="view/custom_comment_workflow_warning">
<dt i18n:translate="">
<strong i18n:translate="">
Warning
</dt>
<dd i18n:translate="text_custom_comment_workflow">
</strong>
<span tal:omit-tag="" i18n:translate="text_custom_comment_workflow">
You have configured a custom workflow for the 'Discussion Item'
content type. You can enable/disable the comment moderation
in this control panel only if you use one of the default
@@ -58,15 +58,15 @@
>Types control panel</a>
</tal:link>
to choose a workflow for the 'Discussion Item' type.
</dd>
</dl>
</span>
</div>
<dl class="portalMessage warning"
<div class="portalMessage warning"
tal:condition="view/unmigrated_comments_warning">
<dt i18n:translate="">
<strong i18n:translate="">
Warning
</dt>
<dd i18n:translate="text_unmigrated_comments">
</strong>
<span tal:omit-tag="" i18n:translate="text_unmigrated_comments">
You have comments that have not been migrated to the new
commenting system that has been introduced in Plone 4.1.
Please
@@ -77,8 +77,8 @@
>migrate your comments</a>
</tal:link>
to fix this.
</dd>
</dl>
</span>
</div>
<div metal:use-macro="context/global_statusmessage/macros/portal_message">
Portal status message
@@ -89,12 +89,14 @@
tal:attributes="href string:$portal_url/plone_control_panel"
i18n:translate="">
Site Setup
</a> &rsaquo;
</a>
<h1 class="documentFirstHeading" tal:content="view/label">View Title</h1>
<div id="layout-contents">
<span tal:replace="structure view/contents" />
<div id="content-core">
<div id="layout-contents">
<span tal:replace="structure view/contents" />
</div>
</div>
</div>
+34 -16
View File
@@ -24,6 +24,7 @@ from z3c.form import button
from z3c.form.browser.checkbox import SingleCheckBoxFieldWidget
from plone.app.discussion.interfaces import IDiscussionSettings, _
from plone.app.discussion.upgrades import update_registry
class DiscussionSettingsEditForm(controlpanel.RegistryEditForm):
@@ -32,16 +33,18 @@ class DiscussionSettingsEditForm(controlpanel.RegistryEditForm):
schema = IDiscussionSettings
id = "DiscussionSettingsEditForm"
label = _(u"Discussion settings")
description = _(u"help_discussion_settings_editform",
default=u"Some discussion related settings are not "
"located in the Discussion Control Panel.\n"
"To enable comments for a specific content type, "
"go to the Types Control Panel of this type and "
"choose \"Allow comments\".\n"
"To enable the moderation workflow for comments, "
"go to the Types Control Panel, choose "
"\"Comment\" and set workflow to "
"\"Comment Review Workflow\".")
description = _(
u"help_discussion_settings_editform",
default=u"Some discussion related settings are not "
u"located in the Discussion Control Panel.\n"
u"To enable comments for a specific content type, "
u"go to the Types Control Panel of this type and "
u"choose \"Allow comments\".\n"
u"To enable the moderation workflow for comments, "
u"go to the Types Control Panel, choose "
u"\"Comment\" and set workflow to "
u"\"Comment Review Workflow\"."
)
def updateFields(self):
super(DiscussionSettingsEditForm, self).updateFields()
@@ -49,6 +52,8 @@ class DiscussionSettingsEditForm(controlpanel.RegistryEditForm):
SingleCheckBoxFieldWidget
self.fields['moderation_enabled'].widgetFactory = \
SingleCheckBoxFieldWidget
self.fields['edit_comment_enabled'].widgetFactory = \
SingleCheckBoxFieldWidget
self.fields['anonymous_comments'].widgetFactory = \
SingleCheckBoxFieldWidget
self.fields['show_commenter_image'].widgetFactory = \
@@ -59,7 +64,13 @@ class DiscussionSettingsEditForm(controlpanel.RegistryEditForm):
SingleCheckBoxFieldWidget
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['anonymous_comments'].label = _(u"Anonymous Comments")
self.widgets['show_commenter_image'].label = _(u"Commenter Image")
@@ -108,12 +119,17 @@ class DiscussionSettingsControlPanel(controlpanel.ControlPanelFormWrapper):
output.append("globally_enabled")
# Comment moderation
if 'one_state_workflow' not in workflow_chain and \
'comment_review_workflow' not in workflow_chain:
one_state_worklow_disabled = 'one_state_workflow' not in workflow_chain
comment_review_workflow_disabled = \
'comment_review_workflow' not in workflow_chain
if one_state_worklow_disabled and comment_review_workflow_disabled:
output.append("moderation_custom")
elif settings.moderation_enabled:
output.append("moderation_enabled")
if settings.edit_comment_enabled:
output.append("edit_comment_enabled")
# Anonymous comments
if settings.anonymous_comments:
output.append("anonymous_comments")
@@ -151,8 +167,10 @@ class DiscussionSettingsControlPanel(controlpanel.ControlPanelFormWrapper):
"""
wftool = getToolByName(self.context, "portal_workflow", None)
workflow_chain = wftool.getChainForPortalType('Discussion Item')
if 'one_state_workflow' in workflow_chain \
or 'comment_review_workflow' in workflow_chain:
one_state_workflow_enabled = '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:
return
return True
@@ -176,7 +194,7 @@ def notify_configuration_changed(event):
# Discussion control panel setting changed
if event.record.fieldName == 'moderation_enabled':
# Moderation enabled has changed
if event.record.value == True:
if event.record.value is True:
# Enable moderation workflow
wftool.setChainForPortalTypes(('Discussion Item',),
'comment_review_workflow')
+53 -4
View File
@@ -14,10 +14,22 @@ from Products.CMFPlone.interfaces import INonStructuralFolder
from plone.app.discussion.interfaces import IDiscussionSettings
try:
from plone.dexterity.interfaces import IDexterityContent
DEXTERITY_INSTALLED = True
except:
DEXTERITY_INSTALLED = False
class ConversationView(object):
def enabled(self):
if DEXTERITY_INSTALLED and IDexterityContent.providedBy(self.context):
return self._enabled_for_dexterity_types()
else:
return self._enabled_for_archetypes()
def _enabled_for_archetypes(self):
""" Returns True if discussion is enabled for this conversation.
This method checks five different settings in order to figure out if
@@ -52,8 +64,9 @@ class ConversationView(object):
return False
# Always return False if object is a folder
if (IFolderish.providedBy(context) and
not INonStructuralFolder.providedBy(context)):
context_is_folderish = IFolderish.providedBy(context)
context_is_structural = not INonStructuralFolder.providedBy(context)
if (context_is_folderish and context_is_structural):
return False
def traverse_parents(context):
@@ -61,8 +74,9 @@ class ConversationView(object):
# enabled in a parent folder.
for obj in aq_chain(context):
if not IPloneSiteRoot.providedBy(obj):
if (IFolderish.providedBy(obj) and
not INonStructuralFolder.providedBy(obj)):
obj_is_folderish = IFolderish.providedBy(obj)
obj_is_stuctural = not INonStructuralFolder.providedBy(obj)
if (obj_is_folderish and obj_is_stuctural):
flag = getattr(obj, 'allow_discussion', None)
if flag is not None:
return flag
@@ -94,3 +108,38 @@ class ConversationView(object):
return False
return True
def _enabled_for_dexterity_types(self):
""" Returns True if discussion is enabled for this conversation.
This method checks five different settings in order to figure out if
discussion is enable on a specific content object:
1) Check if discussion is enabled globally in the plone.app.discussion
registry/control panel.
2) Check if the allow_discussion boolean flag on the content object is
set. If it is set to True or False, return the value. If it set to
None, try further.
3) Check if discussion is allowed for the content type.
"""
context = aq_inner(self.context)
# Fetch discussion registry
registry = queryUtility(IRegistry)
settings = registry.forInterface(IDiscussionSettings, check=False)
# Check if discussion is allowed globally
if not settings.globally_enabled:
return False
# Check if discussion is allowed on the content object
if hasattr(context, "allow_discussion"):
if context.allow_discussion is not None:
return context.allow_discussion
# Check if discussion is allowed on the content type
portal_types = getToolByName(self, 'portal_types')
document_fti = getattr(portal_types, context.portal_type)
return document_fti.getProperty('allow_discussion')
@@ -35,8 +35,9 @@
*/
reply_div.appendTo(comment_div).css("display", "none");
/* Remove id="reply" attribute, since we use it to uniquely
/* Remove id="commenting" attribute, since we use it to uniquely define
the main reply form. */
// Still belongs to class="reply"
reply_div.removeAttr("id");
/* Hide the reply button (only hide, because we may want to show it
@@ -47,6 +48,13 @@
/* Fetch the reply form inside the reply div */
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 );
/* Populate the hidden 'in_reply_to' field with the correct comment
id */
reply_form.find("input[name='form.widgets.in_reply_to']")
@@ -127,23 +135,23 @@
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']").live('click', function () {
$("input[name='form.button.PublishComment']").on('click', function () {
var trigger = this;
var form = $(this).parents("form");
var data = $(form).serialize();
@@ -151,7 +159,7 @@
$.ajax({
type: "GET",
url: form_url,
data: "workflow_action=publish",
data: data,
context: trigger,
success: function (msg) {
// remove button (trigger object can't be directly removed)
@@ -165,11 +173,20 @@
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.
**********************************************************************/
$("input[name='form.button.DeleteComment']").live('click', function () {
$("input[name='form.button.DeleteComment']").on('click', function () {
var trigger = this;
var form = $(this).parents("form");
var data = $(form).serialize();
@@ -177,6 +194,7 @@
$.ajax({
type: 'POST',
url: form_url,
data: data,
context: $(trigger).parents(".comment"),
success: function (data) {
var comment = $(this);
@@ -7,39 +7,40 @@
// 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
/* Disable a control panel setting */
$.disableSettings = function (settings) {
$.each(settings, function (intIndex, setting) {
setting.addClass('unclickable');
var setting_field = $(setting).find("input,select");
setting_field.attr('disabled', 'disabled');
});
});
};
/* Enable a control panel setting */
$.enableSettings = function (settings) {
$.each(settings, function (intIndex, setting) {
setting.removeClass('unclickable');
var setting_field = $(setting).find("input,select");
setting_field.removeAttr('disabled');
});
});
};
/* Update settings */
$.updateSettings = function () {
var globally_enabled = $("#content").hasClass("globally_enabled");
var anonymous_comments = $("#content").hasClass("anonymous_comments");
var moderation_enabled = $("#content").hasClass("moderation_enabled");
var moderation_custom = $("#content").hasClass("moderation_custom");
var invalid_mail_setup = $("#content").hasClass("invalid_mail_setup");
/* If commenting is globally disabled, disable all settings. */
if (globally_enabled === true) {
$.enableSettings([
$('#formfield-form-widgets-anonymous_comments'),
$('#formfield-form-widgets-moderation_enabled'),
$('#formfield-form-widgets-edit_comment_enabled'),
$('#formfield-form-widgets-text_transform'),
$('#formfield-form-widgets-captcha'),
$('#formfield-form-widgets-show_commenter_image'),
@@ -52,6 +53,7 @@
$.disableSettings([
$('#formfield-form-widgets-anonymous_comments'),
$('#formfield-form-widgets-moderation_enabled'),
$('#formfield-form-widgets-edit_comment_enabled'),
$('#formfield-form-widgets-text_transform'),
$('#formfield-form-widgets-captcha'),
$('#formfield-form-widgets-show_commenter_image'),
@@ -60,7 +62,7 @@
$('#formfield-form-widgets-user_notification_enabled')
]);
}
/* If the mail setup is invalid, disable the mail settings. */
if (invalid_mail_setup === true) {
$.disableSettings([
@@ -73,14 +75,14 @@
/* Enable mail setup only if discussion is enabled. */
if (globally_enabled === true) {
$.enableSettings([
$('#formfield-form-widgets-moderator_notification_enabled'),
$('#formfield-form-widgets-moderator_email'),
$('#formfield-form-widgets-moderator_notification_enabled'),
$('#formfield-form-widgets-moderator_email'),
$('#formfield-form-widgets-user_notification_enabled')
]);
}
}
/* If a custom workflow for comments is enabled, disable the moderation
/* If a custom workflow for comments is enabled, disable the moderation
switch. */
if (moderation_custom === true) {
$.disableSettings([
@@ -89,21 +91,21 @@
}
};
//#JSCOVERAGE_IF 0
/**************************************************************************
* Window Load Function: Executes when complete page is fully loaded,
* including all frames,
**************************************************************************/
$(window).load(function () {
// Update settings on page load
$.updateSettings();
// Set #content class and update settings afterwards
$("input,select").live("change", function (e) {
$("input,select").on("change", function (e) {
var id = $(this).attr("id");
if (id === "form-widgets-globally_enabled-0") {
if ($(this).attr("checked") === true) {
if (id === "form-widgets-globally_enabled-0") {
if ($(this).attr("checked")) {
$("#content").addClass("globally_enabled");
}
else {
@@ -112,17 +114,16 @@
$.updateSettings();
}
});
/**********************************************************************
* Remove the disabled attribute from all form elements before
* Remove the disabled attribute from all form elements before
* submitting the form. Otherwise the z3c.form will raise errors on
* the required attributes.
**********************************************************************/
$("form#DiscussionSettingsEditForm").bind("submit", function (e) {
$(this).find("input,select").removeAttr('disabled');
$(this).submit();
});
});
//#JSCOVERAGE_ENDIF
@@ -17,7 +17,7 @@ content object. Each comment div has a unique id::
<div class="documentByLine"></div>
<div class="commentBody"> </div>
<div class="commentActions">
<button class="reply-to-comment-button">Reply</button>
<button class="reply-to-comment-button">Reply</button>
</div>
</div>
@@ -34,7 +34,7 @@ The comment form is rendered inside a "commenting" div::
<div id="formfield-form-widgets-in_reply_to">
<input id="form-widgets-in_reply_to"
name="form.widgets.in_reply_to" value=
type="hidden"
type="hidden"
/>
</div>
</form>
@@ -1,22 +1,22 @@
/******************************************************************************
*
*
* jQuery functions for the plone.app.discussion bulk moderation.
*
*
******************************************************************************/
(function ($) {
// This unnamed function allows us to use $ inside of a block of code
// 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
//#JSCOVERAGE_IF 0
/**************************************************************************
* Window Load Function: Executes when complete page is fully loaded,
* Window Load Function: Executes when complete page is fully loaded,
* including all frames,
**************************************************************************/
**************************************************************************/
$(window).load(function () {
/**********************************************************************
* Delete a single comment.
**********************************************************************/
@@ -47,8 +47,8 @@
}
});
});
/**********************************************************************
* Publish a single comment.
**********************************************************************/
@@ -78,8 +78,8 @@
}
});
});
/**********************************************************************
* Bulk actions for comments (delete, publish)
**********************************************************************/
@@ -116,8 +116,8 @@
selectField.find("option[value='-1']").attr('selected', 'selected');
}
});
/**********************************************************************
* Check or uncheck all checkboxes from the batch moderation page.
**********************************************************************/
@@ -134,12 +134,12 @@
$(this).val("0");
}
});
/**********************************************************************
* 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();
@@ -156,9 +156,9 @@
}
});
});
});
//#JSCOVERAGE_ENDIF
}(jQuery));
+85 -18
View File
@@ -14,6 +14,9 @@ from plone.app.discussion.comment import CommentFactory
from plone.app.discussion.interfaces import IConversation, IReplies, IComment
from types import TupleType
from DateTime import DateTime
def DT2dt(DT):
"""Convert a Zope DateTime (with timezone) into a Python datetime (GMT)."""
@@ -64,8 +67,15 @@ class View(BrowserView):
if len(replies) == 0:
return True
for reply in replies:
workflow = context.portal_workflow
oldchain = workflow.getChainForPortalType('Discussion Item')
new_workflow = workflow.comment_review_workflow
mt = getToolByName(self.context, 'portal_membership')
if type(oldchain) == TupleType and len(oldchain) > 0:
oldchain = oldchain[0]
for reply in replies:
# log
indent = " "
for i in range(depth):
@@ -87,9 +97,30 @@ class View(BrowserView):
comment.mime_type = 'text/html'
comment.creator = reply.Creator()
email = reply.getProperty('email', None)
if email:
comment.author_email = email
try:
comment.author_username = reply.author_username
except AttributeError:
comment.author_username = reply.Creator()
member = mt.getMemberById(comment.author_username)
if member:
comment.author_name = member.fullname
if not comment.author_name:
# In migrated site member.fullname = ''
# while member.getProperty('fullname') has the
# correct value
if member:
comment.author_name = member.getProperty(
'fullname'
)
else:
comment.author_name = comment.author_username
try:
comment.author_email = reply.email
except AttributeError:
comment.author_email = None
comment.creation_date = DT2dt(reply.creation_date)
comment.modification_date = DT2dt(reply.modification_date)
@@ -105,6 +136,32 @@ class View(BrowserView):
replies = IReplies(comment_to_reply_to)
new_in_reply_to = replies.addComment(comment)
# migrate the review state
old_status = workflow.getStatusOf(oldchain, reply)
new_status = {
'action': None,
'actor': None,
'comment': 'Migrated workflow state',
'review_state': old_status and old_status.get(
'review_state',
new_workflow.initial_state
) or 'published',
'time': DateTime()
}
workflow.setStatusOf('comment_review_workflow',
comment,
new_status)
auto_transition = new_workflow._findAutomaticTransition(
comment,
new_workflow._getWorkflowStateOf(comment))
if auto_transition is not None:
new_workflow._changeStateOf(comment, auto_transition)
else:
new_workflow.updateRoleMappingsFor(comment)
comment.reindexObject(idxs=['allowedRolesAndUsers',
'review_state'])
self.total_comments_migrated += 1
# migrate all talkbacks of the reply
@@ -133,13 +190,17 @@ class View(BrowserView):
object_provides='Products.CMFCore.interfaces._content.IContentish')
log("Found %s content objects." % len(brains))
count_discussion_items = len(catalog.searchResults(
Type='Discussion Item'))
count_comments_pad = len(catalog.searchResults(
object_provides=IComment.__identifier__))
count_comments_old = len(catalog.searchResults(
object_provides=IDiscussionResponse.\
__identifier__))
count_discussion_items = len(
catalog.searchResults(Type='Discussion Item')
)
count_comments_pad = len(
catalog.searchResults(object_provides=IComment.__identifier__)
)
count_comments_old = len(
catalog.searchResults(
object_provides=IDiscussionResponse.__identifier__
)
)
log("Found %s Discussion Item objects." % count_discussion_items)
log("Found %s old discussion items." % count_comments_old)
@@ -172,10 +233,14 @@ class View(BrowserView):
obj.talkback = None
if self.total_comments_deleted != self.total_comments_migrated:
log("Something went wrong during migration. The number of \
migrated comments (%s) differs from the number of deleted \
comments (%s)." # pragma: no cover
% (self.total_comments_migrated, self.total_comments_deleted))
log(
"Something went wrong during migration. The number of " +
"migrated comments (%s) differs from the number of deleted " +
"comments (%s)." % (
self.total_comments_migrated,
self.total_comments_deleted
)
)
if not test: # pragma: no cover
transaction.abort() # pragma: no cover
log("Abort transaction") # pragma: no cover
@@ -188,9 +253,11 @@ class View(BrowserView):
% (self.total_comments_migrated, count_comments_old))
if self.total_comments_migrated != count_comments_old:
log("%s comments could not be migrated." %
(count_comments_old - \
self.total_comments_migrated)) # pragma: no cover
log(
"%s comments could not be migrated." % (
count_comments_old - self.total_comments_migrated
)
) # pragma: no cover
log("Please make sure your " +
"portal catalog is up-to-date.") # pragma: no cover
+7 -7
View File
@@ -25,18 +25,18 @@
Moderate comments
</h1>
<dl class="portalMessage warning"
<div class="portalMessage warning"
tal:condition="not: view/moderation_enabled">
<dt i18n:domain="plone" i18n:translate="">Warning</dt>
<dd i18n:translate="message_moderation_disabled">
<strong i18n:domain="plone" 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}/@@types-controlpanel?type_id=Discussion Item">
enable the 'Comment Review Workflow' for the Comment content
type</a> before you can moderate comments here.
</dd>
</dl>
</span>
</div>
<form tal:condition="not:items">
<fieldset id="fieldset-moderate-comments" class="formPanel">
@@ -56,7 +56,7 @@
<div metal:use-macro="here/batch_macros/macros/navigation" />
<table id="review-comments" class="listing" style="width: 100%">
<table id="review-comments" class="listing">
<thead>
<tr>
<th id="bulkactions" class="nosort" colspan="7">
@@ -87,7 +87,7 @@
</thead>
<tbody>
<tal:block repeat="item batch">
<tr style="vertical-align: top" class="commentrow"
<tr class="commentrow"
tal:define="even repeat/item/even"
tal:attributes="class python: even and 'odd' or 'even'">
<td class="notDraggable">
+33 -13
View File
@@ -1,5 +1,7 @@
# -*- coding: utf-8 -*-
from Acquisition import aq_inner, aq_parent
from AccessControl import getSecurityManager
from zope.component import queryUtility
from AccessControl import Unauthorized, getSecurityManager
@@ -10,6 +12,8 @@ from Products.CMFCore.utils import getToolByName
from Products.statusmessages.interfaces import IStatusMessage
from plone.registry.interfaces import IRegistry
from plone.app.discussion.interfaces import IDiscussionSettings
from plone.app.discussion.interfaces import _
from plone.app.discussion.interfaces import IComment
from plone.app.discussion.interfaces import IReplies
@@ -97,15 +101,28 @@ class DeleteComment(BrowserView):
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")
# conditional security
# base ZCML condition zope2.deleteObject allows 'delete own object'
# modify this for 'delete_own_comment_allowed' controlpanel setting
if self.can_delete(comment):
del conversation[comment.id]
content_object.reindexObject()
IStatusMessage(self.context.REQUEST).addStatusMessage(
_("Comment deleted."),
type="info")
came_from = self.context.REQUEST.HTTP_REFERER
if len(came_from) == 0:
# 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:
came_from = content_object.absolute_url()
return self.context.REQUEST.RESPONSE.redirect(came_from)
def can_delete(self, reply):
"""By default requires 'Review comments'.
If 'delete own comments' is enabled, requires 'Edit comments'.
"""
return getSecurityManager().checkPermission('Delete comments',
aq_inner(reply))
class DeleteOwnComment(DeleteComment):
"""Delete an own comment if it has no replies. Following conditions have to be true
@@ -160,16 +177,16 @@ class PublishComment(BrowserView):
comment = aq_inner(self.context)
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)
workflow_action = self.request.form.get('workflow_action', 'publish')
workflowTool.doActionFor(comment, workflow_action)
comment.reindexObject()
content_object.reindexObject(idxs=['total_comments'])
IStatusMessage(self.context.REQUEST).addStatusMessage(
_("Comment approved."),
type="info")
came_from = self.context.REQUEST.HTTP_REFERER
if len(came_from) == 0:
# 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:
came_from = content_object.absolute_url()
return self.context.REQUEST.RESPONSE.redirect(came_from)
@@ -230,12 +247,13 @@ class BulkActionsView(BrowserView):
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')
catalog = getToolByName(comment, 'portal_catalog')
catalog.reindexObject(comment)
comment.reindexObject()
content_object.reindexObject(idxs=['total_comments'])
def mark_as_spam(self):
raise NotImplementedError
@@ -252,4 +270,6 @@ class BulkActionsView(BrowserView):
for path in self.paths:
comment = context.restrictedTraverse(path)
conversation = aq_parent(comment)
content_object = aq_parent(conversation)
del conversation[comment.id]
content_object.reindexObject(idxs=['total_comments'])
@@ -11,7 +11,7 @@
.discussion,
#commenting {
/* Clear "float: left" from "manage portlets" div above the
comment viewlet. This will get fixed in the next Plone 4
comment viewlet. This will get fixed in the next Plone 4
release. */
clear: both;
}
@@ -46,6 +46,7 @@
clear: both;
margin: 1em 0;
overflow: auto;
border: 1px solid #ccc;
}
.commentBody p {
@@ -55,7 +56,7 @@
.discussion .documentByLine {
float: left;
margin-left: 0;
margin-bottom: 0.5em;
margin-bottom: 0.5em;
}
.commentActions {
@@ -65,8 +66,52 @@
.discussion .discreet {
color: #666666;
font-size: 85%;
font-size: 85%;
}
.loginbutton {
margin-bottom: 1.25em;
}
.commentactionsform {
display: inline;
}
.replyTreeLevel0 {
margin-left: 0em;
}
.replyTreeLevel1 {
margin-left: 1em;
}
.replyTreeLevel2 {
margin-left: 2em;
}
.replyTreeLevel3 {
margin-left: 3em;
}
.replyTreeLevel4 {
margin-left: 4em;
}
.replyTreeLevel5 {
margin-left: 5em;
}
.replyTreeLevel6 {
margin-left: 6em;
}
.replyTreeLevel7 {
margin-left: 7em;
}
.replyTreeLevel8 {
margin-left: 8em;
}
.replyTreeLevel9 {
margin-left: 9em;
}
.replyTreeLevel10 {
margin-left: 10em;
}
.defaultuserimg {
border: 0;
}
/* Reply Form
-------------------------------------------------------------------------- */
@@ -78,7 +123,7 @@
.reply .text-widget {
/* Make sure the input fields are always below the label. This sometimes
* break when the reply form is copied from the main comment form.
* break when the reply form is copied from the main comment form.
*/
display: block;
}
@@ -120,6 +165,10 @@
#review-comments {
margin: 0;
width: 100%;
}
#review-comments tbody tr {
vertical-align: top
}
#fieldset-moderate-comments {
@@ -137,7 +186,7 @@
/* Plone 4 Styles
-------------------------------------------------------------------------- */
/* These styles are only applied for Plone 4, since Plone 3.x does
/* These styles are only applied for Plone 4, since Plone 3.x does
not have a .row class for portal-column-content. */
.row #dobulkaction {
@@ -174,3 +223,9 @@
.row .discussion label {
font-weight:bold;
}
/* editing comments */
.overlay-edit-comment textarea {
height: 10em;
}
+1 -1
View File
@@ -32,7 +32,7 @@ except ImportError:
pass
try:
from plone.formwidget.recaptcha.validator import WrongCaptchaCode
from plone.formwidget.recaptcha.validator import WrongCaptchaCode # noqa
except ImportError:
pass