From e6172a219e6b3921eef74796a9ee89a97aa1cf61 Mon Sep 17 00:00:00 2001 From: "Guido A.J. Stevens" Date: Tue, 17 Sep 2013 12:03:46 +0000 Subject: [PATCH 01/16] Make comments editable --- CHANGES.rst | 4 ++ plone/app/discussion/browser/comment.py | 69 ++++++++++++++++++ plone/app/discussion/browser/comments.pt | 25 +++++-- plone/app/discussion/browser/comments.py | 15 +++- plone/app/discussion/browser/configure.zcml | 9 +++ plone/app/discussion/browser/controlpanel.py | 14 +++- .../browser/javascripts/comments.js | 9 +++ .../browser/javascripts/controlpanel.js | 2 + .../browser/stylesheets/discussion.css | 6 ++ plone/app/discussion/comment.py | 11 ++- plone/app/discussion/configure.zcml | 11 +++ plone/app/discussion/interfaces.py | 11 +++ plone/app/discussion/permissions.zcml | 6 ++ .../discussion/profiles/default/metadata.xml | 2 +- .../discussion/profiles/default/registry.xml | 6 +- .../discussion/profiles/default/rolemap.xml | 6 ++ .../tests/functional_test_comments.txt | 71 +++++++++++++++++++ plone/app/discussion/tests/test_comment.py | 10 +++ .../discussion/tests/test_comments_viewlet.py | 69 +++++++++++++++++- .../app/discussion/tests/test_controlpanel.py | 16 +++++ 20 files changed, 359 insertions(+), 13 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 5d9f16f..fa12098 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,9 +1,13 @@ Changelog ========= + 2.2.9 (unreleased) ------------------ +- Make comments editable. + [pjstevns, gyst] + - Rename CHANGES.txt to CHANGES.rst. [timo] diff --git a/plone/app/discussion/browser/comment.py b/plone/app/discussion/browser/comment.py index 97eec23..03e0732 100644 --- a/plone/app/discussion/browser/comment.py +++ b/plone/app/discussion/browser/comment.py @@ -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 diff --git a/plone/app/discussion/browser/comments.pt b/plone/app/discussion/browser/comments.pt index c192e7a..203e813 100644 --- a/plone/app/discussion/browser/comments.pt +++ b/plone/app/discussion/browser/comments.pt @@ -1,6 +1,7 @@ @@ -42,14 +44,14 @@
- - +
+ +
+ +
+ + + *' + }) /********************************************************************** * Delete a comment and its answers. diff --git a/plone/app/discussion/browser/javascripts/controlpanel.js b/plone/app/discussion/browser/javascripts/controlpanel.js index ba32492..cc810fa 100644 --- a/plone/app/discussion/browser/javascripts/controlpanel.js +++ b/plone/app/discussion/browser/javascripts/controlpanel.js @@ -40,6 +40,7 @@ $.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'), diff --git a/plone/app/discussion/browser/stylesheets/discussion.css b/plone/app/discussion/browser/stylesheets/discussion.css index a8ca7a5..3857836 100644 --- a/plone/app/discussion/browser/stylesheets/discussion.css +++ b/plone/app/discussion/browser/stylesheets/discussion.css @@ -223,3 +223,9 @@ .row .discussion label { font-weight:bold; } + +/* editing comments */ + +.overlay-edit-comment textarea { + height: 10em; +} \ No newline at end of file diff --git a/plone/app/discussion/comment.py b/plone/app/discussion/comment.py index df81bf7..b4bbc35 100644 --- a/plone/app/discussion/comment.py +++ b/plone/app/discussion/comment.py @@ -42,6 +42,7 @@ from Products.CMFCore.CMFCatalogAware import WorkflowAware from OFS.role import RoleManager from AccessControl import ClassSecurityInfo +from AccessControl.SecurityManagement import getSecurityManager from Products.CMFCore import permissions @@ -115,6 +116,14 @@ class Comment(CatalogAware, WorkflowAware, DynamicType, Traversable, self.creation_date = self.modification_date = datetime.utcnow() self.mime_type = 'text/plain' + user = getSecurityManager().getUser() + if user and user.getId(): + aclpath = [x for x in user.getPhysicalPath() if x] + self._owner = (aclpath, user.getId(),) + self.__ac_local_roles__ = { + user.getId(): ['Owner'] + } + @property def __name__(self): return self.comment_id and unicode(self.comment_id) or None @@ -193,7 +202,7 @@ class Comment(CatalogAware, WorkflowAware, DynamicType, Traversable, def Creator(self): """The name of the person who wrote the comment. """ - return self.creator + return self.creator or self.author_name security.declareProtected(permissions.View, 'Type') diff --git a/plone/app/discussion/configure.zcml b/plone/app/discussion/configure.zcml index ed82463..ee486c3 100644 --- a/plone/app/discussion/configure.zcml +++ b/plone/app/discussion/configure.zcml @@ -43,6 +43,17 @@ for="Products.CMFPlone.interfaces.IPloneSiteRoot" /> + + + diff --git a/plone/app/discussion/interfaces.py b/plone/app/discussion/interfaces.py index 46d430d..cdf662b 100644 --- a/plone/app/discussion/interfaces.py +++ b/plone/app/discussion/interfaces.py @@ -274,6 +274,17 @@ class IDiscussionSettings(Interface): default=False, ) + edit_comment_enabled = schema.Bool( + title=_(u"label_edit_comment_enabled", + default="Enable editing of comments"), + description=_(u"help_edit_comment_enabled", + default=u"If selected, supports editing and deletion " + "of comments for users with the 'Edit comments' " + "permission."), + required=False, + default=False, + ) + text_transform = schema.Choice( title=_(u"label_text_transform", default="Comment text transform"), diff --git a/plone/app/discussion/permissions.zcml b/plone/app/discussion/permissions.zcml index c5b090c..dd71e63 100644 --- a/plone/app/discussion/permissions.zcml +++ b/plone/app/discussion/permissions.zcml @@ -9,4 +9,10 @@ title="Review comments" /> + + + diff --git a/plone/app/discussion/profiles/default/metadata.xml b/plone/app/discussion/profiles/default/metadata.xml index ffedb11..7a20473 100644 --- a/plone/app/discussion/profiles/default/metadata.xml +++ b/plone/app/discussion/profiles/default/metadata.xml @@ -1,5 +1,5 @@ - 100 + 101 profile-plone.app.registry:default diff --git a/plone/app/discussion/profiles/default/registry.xml b/plone/app/discussion/profiles/default/registry.xml index fd2a894..2518c9a 100644 --- a/plone/app/discussion/profiles/default/registry.xml +++ b/plone/app/discussion/profiles/default/registry.xml @@ -1,4 +1,6 @@ - - \ No newline at end of file + + False + + diff --git a/plone/app/discussion/profiles/default/rolemap.xml b/plone/app/discussion/profiles/default/rolemap.xml index 83318d8..8e8b019 100644 --- a/plone/app/discussion/profiles/default/rolemap.xml +++ b/plone/app/discussion/profiles/default/rolemap.xml @@ -6,6 +6,12 @@ + + + + + + diff --git a/plone/app/discussion/tests/functional_test_comments.txt b/plone/app/discussion/tests/functional_test_comments.txt index 736da64..0ab4a61 100644 --- a/plone/app/discussion/tests/functional_test_comments.txt +++ b/plone/app/discussion/tests/functional_test_comments.txt @@ -250,6 +250,77 @@ Check that the reply has been posted properly. True +Edit an existing comment +------------------------ + +Log in as admin + + >>> browser.getLink('Log out').click() + >>> browser.open(portal_url + '/login_form') + >>> browser.getControl('Login Name').value = 'admin' + >>> browser.getControl('Password').value = 'secret' + >>> browser.getControl('Log in').click() + +Use the Plone control panel to enable comment editing. + + >>> browser.open(portal_url + '/plone_control_panel') + >>> browser.getLink('Discussion').click() + >>> browser.getControl('Enable editing of comments').selected = True + >>> browser.getControl(name='form.buttons.save').click() + +Extract the edit comment url from the first "edit comment" button + + >>> browser.open(urldoc1) + >>> form = browser.getForm(name='edit', index=0) + >>> '@@edit-comment' in form.action + True + +Open the edit comment view + + >>> browser.open(form.action) + >>> ctrl = browser.getControl('Comment') + >>> ctrl.value + 'Comment from admin' + +Change and save the comment + + >>> ctrl.value = 'Comment from admin / was edited' + >>> browser.getControl('Edit comment').click() + +This used to trigger permissions problems in some portlet configurations. +Check it ain't so. + + >>> 'require_login' in browser.url + False + >>> browser.url.startswith('http://nohost/plone/doc1') + True + >>> 'Comment from admin / was edited' in browser.contents + True + +Opening the edit comment view, then cancel, does nothing. + + >>> form = browser.getForm(name='edit', index=0) + >>> '@@edit-comment' in form.action + True + >>> browser.open(form.action) + >>> browser.getControl('Cancel').click() + >>> browser.url.startswith('http://nohost/plone/doc1') + True + + +Anon cannot edit comments. + + >>> unprivileged_browser.open(urldoc1) + >>> '@@edit-comments' in browser.contents + False + +But Anon can see the edited comment. + + >>> 'Comment from admin / was edited' in unprivileged_browser.contents + True + + + Post a comment with comment review workflow enabled --------------------------------------------------- diff --git a/plone/app/discussion/tests/test_comment.py b/plone/app/discussion/tests/test_comment.py index 1f39098..09bdea2 100644 --- a/plone/app/discussion/tests/test_comment.py +++ b/plone/app/discussion/tests/test_comment.py @@ -130,6 +130,16 @@ class CommentTest(unittest.TestCase): comment1.creator = "jim" self.assertEqual("jim", comment1.Creator()) + def test_creator_author_name(self): + comment1 = createObject('plone.Comment') + comment1.author_name = "joey" + self.assertEqual("joey", comment1.Creator()) + + def test_owner(self): + comment1 = createObject('plone.Comment') + self.assertEqual((['plone', 'acl_users'], TEST_USER_ID), + comment1.getOwnerTuple()) + def test_type(self): comment1 = createObject('plone.Comment') self.assertEqual(comment1.Type(), 'Comment') diff --git a/plone/app/discussion/tests/test_comments_viewlet.py b/plone/app/discussion/tests/test_comments_viewlet.py index 9c3fb22..7ed9da4 100644 --- a/plone/app/discussion/tests/test_comments_viewlet.py +++ b/plone/app/discussion/tests/test_comments_viewlet.py @@ -33,12 +33,14 @@ from plone.app.testing import login from plone.app.discussion.browser.comments import CommentsViewlet from plone.app.discussion.browser.comments import CommentForm +from plone.app.discussion.browser.comment import EditCommentForm from plone.app.discussion import interfaces from plone.app.discussion.interfaces import IConversation from plone.app.discussion.testing import ( PLONE_APP_DISCUSSION_INTEGRATION_TESTING ) from plone.app.discussion.interfaces import IDiscussionSettings +from plone.app.discussion.interfaces import IConversation class TestCommentForm(unittest.TestCase): @@ -125,6 +127,65 @@ class TestCommentForm(unittest.TestCase): self.assertEqual(len(errors), 0) self.assertFalse(commentForm.handleComment(commentForm, "foo")) + def test_edit_comment(self): + """Edit a comment as logged-in user. + """ + + # Allow discussion + self.discussionTool.overrideDiscussionFor(self.portal.doc1, True) + self.viewlet = CommentsViewlet(self.context, self.request, None, None) + + def make_request(form={}): + request = TestRequest() + request.form.update(form) + alsoProvides(request, IFormLayer) + alsoProvides(request, IAttributeAnnotatable) + return request + + provideAdapter( + adapts=(Interface, IBrowserRequest), + provides=Interface, + factory=CommentForm, + name=u"comment-form" + ) + + provideAdapter( + adapts=(Interface, IBrowserRequest), + provides=Interface, + factory=EditCommentForm, + name=u"edit-comment-form" + ) + + # The form is submitted successfully, if the required text field is + # filled out + request = make_request(form={'form.widgets.text': u'bar'}) + + commentForm = getMultiAdapter( + (self.context, request), + name=u"comment-form" + ) + commentForm.update() + data, errors = commentForm.extractData() # pylint: disable-msg=W0612 + + self.assertEqual(len(errors), 0) + self.assertFalse(commentForm.handleComment(commentForm, "foo")) + + # Edit the last comment + conversation = IConversation(self.context) + comment = [x for x in conversation.getComments()][-1] + request = make_request(form={'form.widgets.text': u'foobar'}) + editForm = getMultiAdapter( + (comment, request), + name=u"edit-comment-form" + ) + editForm.update() + data, errors = editForm.extractData() # pylint: disable-msg=W0612 + + self.assertEqual(len(errors), 0) + self.assertFalse(editForm.handleComment(editForm, "foo")) + comment = [x for x in conversation.getComments()][-1] + self.assertEquals(comment.text, u"foobar") + def test_add_anonymous_comment(self): self.discussionTool.overrideDiscussionFor(self.portal.doc1, True) @@ -459,9 +520,11 @@ class TestCommentsViewlet(unittest.TestCase): ) def test_get_commenter_portrait_is_none(self): - self.assertEqual( - self.viewlet.get_commenter_portrait(), - 'defaultUser.gif' + self.assertTrue( + self.viewlet.get_commenter_portrait() in ( + 'defaultUser.png', + 'defaultUser.gif', + ) ) def test_get_commenter_portrait_without_userimage(self): diff --git a/plone/app/discussion/tests/test_controlpanel.py b/plone/app/discussion/tests/test_controlpanel.py index a184bb7..ca56c0b 100644 --- a/plone/app/discussion/tests/test_controlpanel.py +++ b/plone/app/discussion/tests/test_controlpanel.py @@ -81,6 +81,22 @@ class RegistryTest(unittest.TestCase): False ) + def test_edit_comment_enabled(self): + # Check edit_comment_enabled record + self.assertTrue('edit_comment_enabled' in IDiscussionSettings) + self.assertEqual( + self.registry['plone.app.discussion.interfaces.' + + 'IDiscussionSettings.edit_comment_enabled'], + False) + + def test_edit_comment_enabled(self): + # Check edit_comment_enabled record + self.assertTrue('edit_comment_enabled' in IDiscussionSettings) + self.assertEqual( + self.registry['plone.app.discussion.interfaces.' + + 'IDiscussionSettings.edit_comment_enabled'], + False) + def test_text_transform(self): self.assertTrue('text_transform' in IDiscussionSettings) self.assertEqual( From 82a473c138c538c2b93f6f008ea25adc96c902df Mon Sep 17 00:00:00 2001 From: "Guido A.J. Stevens" Date: Thu, 19 Sep 2013 08:39:52 +0000 Subject: [PATCH 02/16] provide "delete own comments" as a configurable option --- CHANGES.rst | 3 + plone/app/discussion/browser/comments.pt | 5 +- plone/app/discussion/browser/comments.py | 18 ++- plone/app/discussion/browser/configure.zcml | 6 +- plone/app/discussion/browser/controlpanel.py | 2 + plone/app/discussion/browser/moderation.py | 34 ++++- plone/app/discussion/configure.zcml | 10 ++ plone/app/discussion/interfaces.py | 11 ++ .../discussion/profiles/default/metadata.xml | 2 +- .../tests/functional_test_comments.txt | 140 ++++++++++++++++++ 10 files changed, 220 insertions(+), 11 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index fa12098..bda1c9b 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,6 +5,9 @@ Changelog 2.2.9 (unreleased) ------------------ +- Provide 'delete own comments' as a configurable option + [gyst] + - Make comments editable. [pjstevns, gyst] diff --git a/plone/app/discussion/browser/comments.pt b/plone/app/discussion/browser/comments.pt index 203e813..0348f33 100644 --- a/plone/app/discussion/browser/comments.pt +++ b/plone/app/discussion/browser/comments.pt @@ -36,7 +36,8 @@ 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'); - canEdit python:view.can_edit(reply)" + canEdit python:view.can_edit(reply); + canDelete python:view.can_delete(reply)" tal:attributes="class python:'comment replyTreeLevel'+str(depth)+' state-'+str(review_state); id string:${reply/getId}" tal:condition="python:canReview or review_state == 'published'"> @@ -89,7 +90,7 @@ action="" method="post" class="commentactionsform" - tal:condition="python:canReview" + tal:condition="python:canDelete" tal:attributes="action string:${reply/absolute_url}/@@moderate-delete-comment"> - + diff --git a/plone/app/discussion/browser/controlpanel.py b/plone/app/discussion/browser/controlpanel.py index 822ca2b..84ded37 100644 --- a/plone/app/discussion/browser/controlpanel.py +++ b/plone/app/discussion/browser/controlpanel.py @@ -54,6 +54,8 @@ class DiscussionSettingsEditForm(controlpanel.RegistryEditForm): SingleCheckBoxFieldWidget self.fields['edit_comment_enabled'].widgetFactory = \ SingleCheckBoxFieldWidget + self.fields['delete_own_comment_enabled'].widgetFactory = \ + SingleCheckBoxFieldWidget self.fields['anonymous_comments'].widgetFactory = \ SingleCheckBoxFieldWidget self.fields['show_commenter_image'].widgetFactory = \ diff --git a/plone/app/discussion/browser/moderation.py b/plone/app/discussion/browser/moderation.py index aa7bb91..0324a43 100644 --- a/plone/app/discussion/browser/moderation.py +++ b/plone/app/discussion/browser/moderation.py @@ -1,5 +1,7 @@ # -*- coding: utf-8 -*- from Acquisition import aq_inner, aq_parent +from AccessControl import getSecurityManager +from zope.component import queryUtility from Products.Five.browser import BrowserView from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile @@ -8,6 +10,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 @@ -94,17 +98,37 @@ class DeleteComment(BrowserView): comment = aq_inner(self.context) conversation = aq_parent(comment) content_object = aq_parent(conversation) - del conversation[comment.id] - content_object.reindexObject() - 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 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'. + """ + if self.is_delete_own_comment_allowed(): + permission = 'Edit comments' + else: + permission = 'Review comments' + return getSecurityManager().checkPermission(permission, + aq_inner(reply)) + + def is_delete_own_comment_allowed(self): + registry = queryUtility(IRegistry) + settings = registry.forInterface(IDiscussionSettings, check=False) + return settings.delete_own_comment_enabled + class PublishComment(BrowserView): """Publish a comment. diff --git a/plone/app/discussion/configure.zcml b/plone/app/discussion/configure.zcml index ee486c3..536994e 100644 --- a/plone/app/discussion/configure.zcml +++ b/plone/app/discussion/configure.zcml @@ -53,6 +53,16 @@ profile="plone.app.discussion:default" /> + + diff --git a/plone/app/discussion/interfaces.py b/plone/app/discussion/interfaces.py index cdf662b..8bf188a 100644 --- a/plone/app/discussion/interfaces.py +++ b/plone/app/discussion/interfaces.py @@ -285,6 +285,17 @@ class IDiscussionSettings(Interface): default=False, ) + delete_own_comment_enabled = schema.Bool( + title=_(u"label_delete_own_comment_enabled", + default="Allow users to delete their own comment threads"), + description=_(u"help_edit_comment_enabled", + default=u"If selected, users may delete their own " + "comments -> AND the whole reply thread below that " + "comment!"), + required=False, + default=False, + ) + text_transform = schema.Choice( title=_(u"label_text_transform", default="Comment text transform"), diff --git a/plone/app/discussion/profiles/default/metadata.xml b/plone/app/discussion/profiles/default/metadata.xml index 7a20473..ce1f445 100644 --- a/plone/app/discussion/profiles/default/metadata.xml +++ b/plone/app/discussion/profiles/default/metadata.xml @@ -1,5 +1,5 @@ - 101 + 102 profile-plone.app.registry:default diff --git a/plone/app/discussion/tests/functional_test_comments.txt b/plone/app/discussion/tests/functional_test_comments.txt index 0ab4a61..a31ed48 100644 --- a/plone/app/discussion/tests/functional_test_comments.txt +++ b/plone/app/discussion/tests/functional_test_comments.txt @@ -320,6 +320,146 @@ But Anon can see the edited comment. True +Deleting existing comments | 'delete own comments' disabled +----------------------------------------------------------- + +Anonymous cannot delete comments + + >>> unprivileged_browser.open(urldoc1) + >>> 'form.button.Delete' in unprivileged_browser.contents + False + +A member cannot delete his own comments, unless this is explicitly enabled (see later) + + >>> browser_member.open(urldoc1) + >>> 'form.button.Delete' in browser_member.contents + False + +Admin can delete comments + + >>> browser.open(urldoc1) + >>> 'form.button.Delete' in browser.contents + True + +Extract the delete comment url from the first "delete comment" button + + >>> browser.open(urldoc1) + >>> form = browser.getForm(name='delete', index=0) + >>> delete_url = form.action + >>> '@@moderate-delete-comment' in delete_url + True + >>> comment_id = delete_url.split('/')[-2] + +Anonymous cannot delete a comment by hitting the delete url directly. + + >>> unprivileged_browser.open(delete_url) + +The comment is still there + + >>> unprivileged_browser.open(urldoc1) + >>> comment_id in unprivileged_browser.contents + True + + +A Member cannot delete even his own comment by hitting the delete url directly. + +Extract the member comment id from the admin browser + + >>> form = browser.getForm(name='delete', index=2) + >>> delete_url = form.action + >>> '@@moderate-delete-comment' in delete_url + True + >>> comment_id = delete_url.split('/')[-2] + +Now try to hit that url as the member owning that comment. +Work around some possible testbrowser breakage and check the result later. + + >>> try: + ... browser_member.open(delete_url) + ... except: + ... pass + +The comment is still there + + >>> browser_member.open(urldoc1) + >>> comment_id in browser_member.contents + True + >>> 'Comment from Jim' in browser_member.contents + True + +Admin, who hase 'review comments' permission, can delete comments + + >>> browser.open(urldoc1) + >>> form = browser.getForm(name='delete', index=0) + >>> '@@moderate-delete-comment' in form.action + True + + >>> comment_id = form.action.split('/')[-2] + +Submitting the form runs into a testbrowser notFoundException. +We'll just catch that and check the result later. + + >>> try: + ... form.submit() + ... except: + ... pass + +Returning to the document we find the deleted comment is indeed gone + + >>> browser.open(urldoc1) + >>> comment_id in browser.contents + False + + +Deleting existing comments | 'delete own comments' ENABLED +---------------------------------------------------------- + +Enable deletion of own comments + + >>> from zope.component import queryUtility + >>> from plone.registry.interfaces import IRegistry + >>> from plone.app.discussion.interfaces import IDiscussionSettings + >>> registry = queryUtility(IRegistry) + >>> settings = registry.forInterface(IDiscussionSettings) + >>> settings.delete_own_comment_enabled = True + + >>> import transaction + >>> transaction.commit() + +Anonymous still cannot delete comments + + >>> unprivileged_browser.open(urldoc1) + >>> 'form.button.Delete' in unprivileged_browser.contents + False + +A member can now delete his own comments + + >>> browser_member.open(urldoc1) + >>> 'form.button.Delete' in browser_member.contents + True + + >>> form = browser_member.getForm(name='delete', index=0) + >>> '@@moderate-delete-comment' in form.action + True + + >>> comment_id = form.action.split('/')[-2] + +Submitting the form runs into a testbrowser notFoundException. +We'll just catch that and check the result later. + + >>> try: + ... form.submit() + ... except: + ... pass + +Returning to the document we find the deleted comment is indeed gone + + >>> browser_member.open(urldoc1) + >>> comment_id in browser_member.contents + False + >>> 'Comment from Jim' in browser_member.contents + False + Post a comment with comment review workflow enabled --------------------------------------------------- From f2d11021a3f8b79f5234bf1918dd3fecb58382f1 Mon Sep 17 00:00:00 2001 From: Andrea Cecchi Date: Tue, 4 Feb 2014 11:35:20 +0100 Subject: [PATCH 03/16] rebased branch from master --- CHANGES.rst | 29 +- buildout.cfg | 7 + plone/app/discussion/bbb/__init__.py | 0 plone/app/discussion/bbb/discussiontool.py | 0 plone/app/discussion/bbb/migration.py | 0 plone/app/discussion/bbb/talkback.py | 0 plone/app/discussion/browser/comments.py | 3 + plone/app/discussion/interfaces.py | 40 +- .../da/LC_MESSAGES/plone.app.discussion.po | 28 +- .../eu/LC_MESSAGES/plone.app.discussion.po | 115 +- .../uk/LC_MESSAGES/plone.app.discussion.po | 98 +- plone/app/discussion/patches.py | 9 +- .../profiles/default/componentregistry.xml | 9 - .../discussion/profiles/default/toolset.xml | 5 - plone/app/discussion/subscribers.py | 15 + plone/app/discussion/subscribers.zcml | 4 +- .../discussion/tests/javascripts/README.txt | 41 - .../discussion/tests/javascripts/jquery.js | 154 --- .../tests/javascripts/test_comments.html | 42 - .../tests/javascripts/test_comments.js | 146 --- .../javascripts/test_moderation.html.txt | 40 - .../tests/javascripts/test_moderation.js.txt | 72 -- plone/app/discussion/tests/jsTestDriver.conf | 18 - plone/app/discussion/tests/jsTestDriver.txt | 5 - .../discussion/tests/qunit/QUnitAdapter.js | 85 -- plone/app/discussion/tests/qunit/equiv.js | 185 ---- plone/app/discussion/tests/qunit/qunit.css | 17 - plone/app/discussion/tests/qunit/qunit.js | 997 ------------------ plone/app/discussion/tests/test_catalog.py | 35 +- plone/app/discussion/tests/test_comment.py | 3 - .../discussion/tests/test_comments_viewlet.py | 63 +- .../app/discussion/tests/test_controlpanel.py | 3 - .../app/discussion/tests/test_conversation.py | 19 +- plone/app/discussion/tests/test_indexers.py | 3 - plone/app/discussion/tests/test_migration.py | 328 ------ .../discussion/tests/test_moderation_view.py | 28 - .../discussion/tests/test_notifications.py | 5 - plone/app/discussion/tests/test_tool.py | 56 - plone/app/discussion/tests/test_workflow.py | 5 - 39 files changed, 235 insertions(+), 2477 deletions(-) delete mode 100644 plone/app/discussion/bbb/__init__.py delete mode 100644 plone/app/discussion/bbb/discussiontool.py delete mode 100644 plone/app/discussion/bbb/migration.py delete mode 100644 plone/app/discussion/bbb/talkback.py delete mode 100644 plone/app/discussion/profiles/default/componentregistry.xml delete mode 100644 plone/app/discussion/profiles/default/toolset.xml create mode 100644 plone/app/discussion/subscribers.py delete mode 100644 plone/app/discussion/tests/javascripts/README.txt delete mode 100644 plone/app/discussion/tests/javascripts/jquery.js delete mode 100644 plone/app/discussion/tests/javascripts/test_comments.html delete mode 100644 plone/app/discussion/tests/javascripts/test_comments.js delete mode 100644 plone/app/discussion/tests/javascripts/test_moderation.html.txt delete mode 100644 plone/app/discussion/tests/javascripts/test_moderation.js.txt delete mode 100644 plone/app/discussion/tests/jsTestDriver.conf delete mode 100644 plone/app/discussion/tests/jsTestDriver.txt delete mode 100644 plone/app/discussion/tests/qunit/QUnitAdapter.js delete mode 100644 plone/app/discussion/tests/qunit/equiv.js delete mode 100644 plone/app/discussion/tests/qunit/qunit.css delete mode 100644 plone/app/discussion/tests/qunit/qunit.js delete mode 100644 plone/app/discussion/tests/test_migration.py delete mode 100644 plone/app/discussion/tests/test_tool.py diff --git a/CHANGES.rst b/CHANGES.rst index 17efe2d..9cdfec9 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -4,20 +4,39 @@ Changelog 2.3.0 (unreleased) ------------------ +- Corrections and additions to the Danish translation + [aputtu] + +- Put defaultUser.png instead of old defaultUser.gif + [bsuttor] + +- Remove bbb directory. bbb was never really implemented. + [timo] + +- Replace deprecated test assert statements. + [timo] + +- Remove portal_discussion tool. + [timo] + +- Refactor tests to use the PLONE_APP_CONTENTTYPES_FIXTURE instead of + PLONE_FIXTURE. + [timo] + +- Fix ownership of comments. [toutpt] + - Provide 'delete own comments' as a configurable option [gyst] - Make comments editable. [pjstevns, gyst] -- Refactor tests to use the PLONE_APP_CONTENTTYPES_FIXTURE instead of the PLONE_FIXTURE. - [timo] - - 2.2.10 (2013-09-24) ------------------- -- Revert "Refactor tests to use the PLONE_APP_CONTENTTYPES_FIXTURE instead of the PLONE_FIXTURE." that has been accidentially introduced into the 2.2.9 release. +- Revert "Refactor tests to use the PLONE_APP_CONTENTTYPES_FIXTURE instead of + the PLONE_FIXTURE." that has been accidentially introduced into the 2.2.9 + release. [timo] diff --git a/buildout.cfg b/buildout.cfg index 0b66ca6..d8d5f93 100644 --- a/buildout.cfg +++ b/buildout.cfg @@ -5,6 +5,7 @@ package-extras = [test] parts += mkrelease pocompile + code-analysis [mkrelease] recipe = zc.recipe.egg @@ -14,7 +15,13 @@ eggs = jarn.mkrelease recipe = zc.recipe.egg eggs = zest.pocompile +[code-analysis] +recipe = plone.recipe.codeanalysis +directory = ${buildout:directory}/plone/app/discussion +flake8-max-complexity = 50 + [versions] plone.app.discussion = zope.interface = 4.0.5 +plone.app.portlets = 2.5a1 diff --git a/plone/app/discussion/bbb/__init__.py b/plone/app/discussion/bbb/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/plone/app/discussion/bbb/discussiontool.py b/plone/app/discussion/bbb/discussiontool.py deleted file mode 100644 index e69de29..0000000 diff --git a/plone/app/discussion/bbb/migration.py b/plone/app/discussion/bbb/migration.py deleted file mode 100644 index e69de29..0000000 diff --git a/plone/app/discussion/bbb/talkback.py b/plone/app/discussion/bbb/talkback.py deleted file mode 100644 index e69de29..0000000 diff --git a/plone/app/discussion/browser/comments.py b/plone/app/discussion/browser/comments.py index 7f9b59d..d58dfb7 100644 --- a/plone/app/discussion/browser/comments.py +++ b/plone/app/discussion/browser/comments.py @@ -196,6 +196,7 @@ class CommentForm(extensible.ExtensibleForm, form.Form): # Member member = portal_membership.getAuthenticatedMember() username = member.getUserName() + user = member.getUser() email = member.getProperty('email') fullname = member.getProperty('fullname') if not fullname or fullname == '': @@ -205,6 +206,8 @@ class CommentForm(extensible.ExtensibleForm, form.Form): fullname = unicode(fullname, 'utf-8') if email and isinstance(email, str): email = unicode(email, 'utf-8') + comment.changeOwnership(user, recursive=False) + comment.manage_setLocalRoles(username, ["Owner"]) comment.creator = username comment.author_username = username comment.author_name = fullname diff --git a/plone/app/discussion/interfaces.py b/plone/app/discussion/interfaces.py index 8bf188a..70b9f4b 100644 --- a/plone/app/discussion/interfaces.py +++ b/plone/app/discussion/interfaces.py @@ -184,38 +184,6 @@ class ICaptcha(Interface): required=False) -class ICommentingTool(Interface): - """A tool that indexes all comments for usage by the management interface. - - This means the management interface can still work even though we don't - index the comments in portal_catalog. - - The default implementation of this interface simply defers to - portal_catalog, but a custom version of the tool can be used to provide - an alternate indexing mechanism. - """ - - def indexObject(comment): - """Indexes a comment - """ - - def reindexObject(comment): - """Reindex a comment - """ - - def unindexObject(comment): - """Removes a comment from the indexes - """ - - def uniqueValuesFor(name): - """Get unique values for FieldIndex name - """ - - def searchResults(REQUEST=None, **kw): - """Perform a search over all indexed comments. - """ - - class IDiscussionSettings(Interface): """Global discussion settings. This describes records stored in the configuration registry and obtainable via plone.registry. @@ -392,3 +360,11 @@ class IDiscussionSettings(Interface): class IDiscussionLayer(Interface): """Request marker installed via browserlayer.xml. """ + + +class ICommentingTool(Interface): + """For backwards-compatibility. + + This can be removed once we no longer support upgrading from versions + of Plone that had a portal_discussion tool. + """ diff --git a/plone/app/discussion/locales/da/LC_MESSAGES/plone.app.discussion.po b/plone/app/discussion/locales/da/LC_MESSAGES/plone.app.discussion.po index ec13feb..1fba6a5 100644 --- a/plone/app/discussion/locales/da/LC_MESSAGES/plone.app.discussion.po +++ b/plone/app/discussion/locales/da/LC_MESSAGES/plone.app.discussion.po @@ -16,11 +16,11 @@ msgstr "" #: ../comment.py:311 msgid "A comment has been posted." -msgstr "En kommentar er gem." +msgstr "Der er oprettet en kommentar." #: ../interfaces.py:141 msgid "A comment id unique to this conversation" -msgstr "En kommentar-id som er unik for denne dialog" +msgstr "Et kommentar-id som er unikt for denne dialog" #: ../browser/comments.py:72 msgid "Add a comment" @@ -64,7 +64,7 @@ msgstr "Oprettelses-dato" #: ../interfaces.py:40 msgid "Date of the most recent public comment" -msgstr "" +msgstr "Dato for den seneste, offentlige kommentar" #: ../vocabularies.py:44 msgid "Disabled" @@ -80,7 +80,7 @@ msgstr "Redigering blev afbrudt" #: ../interfaces.py:153 msgid "Email" -msgstr "Email" +msgstr "E-mail" #: ../browser/controlpanel.py:63 msgid "Enable Comments" @@ -88,7 +88,7 @@ msgstr "Slå kommentarer til" #: ../interfaces.py:144 msgid "Id of comment this comment is in reply to" -msgstr "Id'en på den kommentar, denne kommentar er et svar til" +msgstr "Id'et på den kommentar, denne kommentar er et svar til" #: ../interfaces.py:158 msgid "MIME type" @@ -108,7 +108,7 @@ msgstr "Navn" #: ../interfaces.py:162 msgid "Notify me of new comments via email." -msgstr "Send besked om nye kommentarer pr. email." +msgstr "Send besked om nye kommentarer per e-mail." #: ./plone.app.discussion/plone/app/discussion/configure.zcml msgid "Plone Discussions" @@ -116,7 +116,7 @@ msgstr "Plone diskussioner" #: ../interfaces.py:131 msgid "Portal type" -msgstr "Portal type" +msgstr "Portaltype" #: ../browser/controlpanel.py:71 msgid "Save" @@ -128,27 +128,27 @@ msgstr "Liste over kommentatorer (brugernavne)" #: ../interfaces.py:50 msgid "The set of unique commentators (usernames) of published_comments" -msgstr "" +msgstr "Gruppen af unikke kommentatorer (brugernavne) fra published_comments" #: ../interfaces.py:34 msgid "Total number of public comments on this item" -msgstr "" +msgstr "Det samlede antal offentlige kommentarer til dette element" #: ../comment.py:158 msgid "Transform '%s' => '%s' not available. Failed to transform comment '%s'." -msgstr "" +msgstr "Transformation '%s' => '%s' er ikke muligt. Mislykkedes med at transformere kommentaren '%s'." #: ../browser/controlpanel.py:69 msgid "User Email Notification" -msgstr "Email-notificering af brugere" +msgstr "E-mail-notificering af brugere" #: ../interfaces.py:166 msgid "Username of the commenter" -msgstr "" +msgstr "Kommentatorens brugernavn" #: ../browser/comments.py:244 msgid "Your comment awaits moderator approval." -msgstr "Din kommentar venter på godkendelse." +msgstr "Din kommentar venter på godkendelse." #. Default: "Comment" #: ../browser/comments.py:131 @@ -168,7 +168,7 @@ msgstr "Godkend" #. Default: "You can add a comment by filling out the form below. Plain text formatting. Web and email addresses are transformed into clickable links." #: ../browser/comments.py:57 msgid "comment_description_intelligent_text" -msgstr "Du kan tilføje en kommentar ved at udfylde formularen nedenfor. Ren tekst-formattering. Web- og emailadresser bliver automatisk lavet om til klikbare links." +msgstr "Du kan tilføje en kommentar ved at udfylde formularen nedenfor. Ren tekst-formattering. Web- og e-mailadresser bliver automatisk lavet om til klikbare links." #. Default: "You can add a comment by filling out the form below. Plain text formatting. You can use the Markdown syntax for links and images." #: ../browser/comments.py:51 diff --git a/plone/app/discussion/locales/eu/LC_MESSAGES/plone.app.discussion.po b/plone/app/discussion/locales/eu/LC_MESSAGES/plone.app.discussion.po index df9ac4a..a699fbc 100644 --- a/plone/app/discussion/locales/eu/LC_MESSAGES/plone.app.discussion.po +++ b/plone/app/discussion/locales/eu/LC_MESSAGES/plone.app.discussion.po @@ -2,7 +2,7 @@ msgid "" msgstr "" "Project-Id-Version: plone.app.discussion\n" "POT-Creation-Date: YEAR-MO-DA HO:MI +ZONE\n" -"PO-Revision-Date: 2012-02-08 09:14+0100\n" +"PO-Revision-Date: 2013-11-14 09:09+0100\n" "Last-Translator: Mikel Larreategi \n" "Language-Team: eu \n" "MIME-Version: 1.0\n" @@ -15,11 +15,11 @@ msgstr "" "Domain: DOMAIN\n" "X-Poedit-Language: Basque\n" -#: ../comment.py:311 +#: ../comment.py:326 msgid "A comment has been posted." msgstr "Erantzun bat argitaratu da." -#: ../interfaces.py:141 +#: ../interfaces.py:143 msgid "A comment id unique to this conversation" msgstr "Eztabaida honetarako bakarra den erantzunaren id-a." @@ -27,16 +27,16 @@ msgstr "Eztabaida honetarako bakarra den erantzunaren id-a." msgid "Add a comment" msgstr "Erantzuna gehitu" -#: ../browser/controlpanel.py:64 +#: ../browser/controlpanel.py:66 msgid "Anonymous Comments" msgstr "Erantzun anonimoak" -#: ../browser/comments.py:251 -#: ../browser/controlpanel.py:82 +#: ../browser/comments.py:258 +#: ../browser/controlpanel.py:84 msgid "Cancel" msgstr "Utzi" -#: ../browser/controlpanel.py:78 +#: ../browser/controlpanel.py:80 msgid "Changes saved" msgstr "Aldaketak gordeta" @@ -48,24 +48,24 @@ msgstr "Erantzuna onartuta." msgid "Comment deleted." msgstr "Erantzuna ezabatuta." -#: ../browser/controlpanel.py:65 +#: ../browser/controlpanel.py:67 msgid "Commenter Image" msgstr "Erantzuna eman duenaren irudia." msgid "Commenting infrastructure for Plone" msgstr "Ploneren Erantzunen Azpiegitura" -#: ../interfaces.py:136 +#: ../interfaces.py:138 msgid "Conversation" msgstr "Eztabaida" -#: ../interfaces.py:167 +#: ../interfaces.py:176 msgid "Creation date" msgstr "Sorrera data" #: ../interfaces.py:40 msgid "Date of the most recent public comment" -msgstr "" +msgstr "Azken erantzunaren data" #: ../vocabularies.py:44 msgid "Disabled" @@ -75,39 +75,39 @@ msgstr "Desaktibatuta" msgid "Discussion settings" msgstr "Eztabaidaren ezarpenak" -#: ../browser/controlpanel.py:84 +#: ../browser/controlpanel.py:86 msgid "Edit cancelled" msgstr "Edizioa utzita" -#: ../interfaces.py:153 +#: ../interfaces.py:155 msgid "Email" msgstr "E-posta" -#: ../browser/controlpanel.py:63 +#: ../browser/controlpanel.py:65 msgid "Enable Comments" msgstr "Erantzunak aktibatu" -#: ../interfaces.py:144 +#: ../interfaces.py:146 msgid "Id of comment this comment is in reply to" msgstr "Erantzun honek erreferentzia egiten dion erantzunaren id-a" -#: ../interfaces.py:158 +#: ../interfaces.py:160 msgid "MIME type" msgstr "MIME mota" -#: ../browser/controlpanel.py:67 +#: ../browser/controlpanel.py:69 msgid "Moderator Email Notification" msgstr "Moderatzailea e-postaz abisatu" -#: ../interfaces.py:168 +#: ../interfaces.py:177 msgid "Modification date" msgstr "Aldaketa data" -#: ../interfaces.py:138 +#: ../interfaces.py:140 msgid "Name" msgstr "Izena" -#: ../interfaces.py:162 +#: ../interfaces.py:169 msgid "Notify me of new comments via email." msgstr "Erantzun berriak e-postaz bidali" @@ -115,11 +115,11 @@ msgstr "Erantzun berriak e-postaz bidali" msgid "Plone Discussions" msgstr "Plone Eztabaidak" -#: ../interfaces.py:131 +#: ../interfaces.py:133 msgid "Portal type" msgstr "Elementu mota" -#: ../browser/controlpanel.py:71 +#: ../browser/controlpanel.py:73 msgid "Save" msgstr "Gorde" @@ -129,30 +129,30 @@ msgstr "Erantzunak eman dituzten erabiltzaileak (erabiltzaile-izenak)" #: ../interfaces.py:50 msgid "The set of unique commentators (usernames) of published_comments" -msgstr "" +msgstr "Erantzun-emaleen erabiltzaile izenak" #: ../interfaces.py:34 msgid "Total number of public comments on this item" -msgstr "" +msgstr "Elementu honen erantzun publiko kopurua" #: ../comment.py:158 -msgid "Transform '%s' => '%s' not available. Failed to transform comment '%s'." -msgstr "" +msgid "Transform '%s' => '%s' not available." +msgstr "'%s' => '%s' eraldaketa ez dago erabilgarri." -#: ../browser/controlpanel.py:69 +#: ../browser/controlpanel.py:71 msgid "User Email Notification" msgstr "E-posta abisuak" -#: ../interfaces.py:166 +#: ../interfaces.py:175 msgid "Username of the commenter" -msgstr "" +msgstr "Erantzuna utzi duenaren erabiltzaile-izena" -#: ../browser/comments.py:244 +#: ../browser/comments.py:251 msgid "Your comment awaits moderator approval." msgstr "Zure erantzuna moderazio kolan dago." #. Default: "Comment" -#: ../browser/comments.py:131 +#: ../browser/comments.py:132 msgid "add_comment_button" msgstr "Eman erantzuna" @@ -188,9 +188,8 @@ msgstr "Erantzuna formulario hau betez utzi dezakezu. Formatua testu arruntarena #. Default: "${author_name} on ${content}" #: ../comment.py:48 -#, fuzzy msgid "comment_title" -msgstr "${creator} ${content} buruz" +msgstr "${content} - ${creator}" #. Default: "Action" #: ../browser/moderation.pt:85 @@ -223,17 +222,17 @@ msgid "heading_moderate_comments" msgstr "Erantzunak moderatu" #. Default: "If selected, anonymous users are able to post comments without loggin in. It is highly recommended to use a captcha solution to prevent spam if this setting is enabled." -#: ../interfaces.py:236 +#: ../interfaces.py:215 msgid "help_anonymous_comments" msgstr "Aukeratuta badago, erabiltzaile anonimoek erantzunak gehitu ditzakete login egin gabe. Berariaz gomendatzen dizugu Captcha kontrolen bat aktibatzea anonimoen erantzunak baimentzen badituzu." #. Default: "If selected, anonymous user will have to give their email." -#: ../interfaces.py:334 +#: ../interfaces.py:330 msgid "help_anonymous_email_enabled" -msgstr "" +msgstr "Aukeratuta badago, erabiltzaile anonimoek eposta helbidea idatzi beharko dute" #. Default: "Use this setting to enable or disable Captcha validation for comments. Install plone.formwidget.captcha, plone.formwidget.recaptcha, collective.akismet, or collective.z3cform.norobots if there are no options available." -#: ../interfaces.py:279 +#: ../interfaces.py:265 msgid "help_captcha" msgstr "Erabili aukera hau Captcha aktibatu edo desaktibatzeko. Instalatu plone.formwidget.captcha, plone.formwidget.recaptcha, collective.akismet edo collective.z3cform.norobots aukerarik ez baldin badago." @@ -246,55 +245,55 @@ msgstr "" "Erantzunen Moderazio Workflowa aktibatzeko, joan elementu-moten kontrol panelera, aukeratu 'Erantzuna' eta ezarri 'Erantzunen Moderazio Workflowa'." #. Default: "If selected, users are able to post comments on the site. Though, you have to enable comments for specific content types, folders or content objects before users will be able to post comments." -#: ../interfaces.py:224 +#: ../interfaces.py:201 msgid "help_globally_enabled" msgstr "Aukeratuta badago, atarian erantzunak gehitu daitezke. Edonola ere, elementu-mota bakoitzarentzat erantzunak baimendu beharko dituzu erabiltzaileak erantzunak ematen hasi aurretik." #. Default: "If selected, comments will enter a 'Pending' state in which they are invisible to the public. A user with the 'Review comments' permission ('Reviewer' or 'Manager') can approve comments to make them visible to the public. If you want to enable a custom comment workflow, you have to go to the types control panel." -#: ../interfaces.py:248 +#: ../interfaces.py:231 msgid "help_moderation_enabled" msgstr "Aukeratuta badago, erantzuna 'Zain' izeneko egoeran geldituko da eta ez da argitaratuko. 'Erantzunak errebisatu' baimena duten erabiltzaileek ('Zuzentzailea' edo 'Kudeatzailea') argitaratu ditzakete albisteak. Erantzunen worfklowa pertsonalizatu nahi baduzu, elementu-moten kontrol panelera joan zaitez." #. Default: "Address to which moderator notifications will be sent." -#: ../interfaces.py:316 +#: ../interfaces.py:307 msgid "help_moderator_email" msgstr "Moderatzailearen notifikazioak bidali behar diren helbidea." #. Default: "If selected, the moderator is notified if a comment needs attention. The moderator email address can be found in the 'Mail settings' control panel (Site 'From' address)" -#: ../interfaces.py:304 +#: ../interfaces.py:292 msgid "help_moderator_notification_enabled" msgstr "Aukeratuta badago, moderatzaileari e-posta abisua helduko zaio erantzun bat gehitzean. Moderatzailearen e-posta atariaren E-postaren konfigurazioan dago (Atariaren 'Nork' helbidea)" #. Default: "If selected, an image of the user is shown next to the comment." -#: ../interfaces.py:294 +#: ../interfaces.py:281 msgid "help_show_commenter_image" msgstr "Aukeratuta badago, erantzuna eman duenaren irudi bat agertuko da testuaren ondoan." #. Default: "Use this setting to choose if the comment text should be transformed in any way. You can choose between 'Plain text' and 'Intelligent text'. 'Intelligent text' converts plain text into HTML where line breaks and indentation is preserved, and web and email addresses are made into clickable links." -#: ../interfaces.py:263 +#: ../interfaces.py:248 msgid "help_text_transform" msgstr "Erabili aukera hau erantzunaren testua nolabait eraldatu behar bada. 'Testu arrunta' edo 'Testu argia'ren artean aukeratu dezakezu. 'Testu argia'-k testu arrunta HTML bihurtzen du lerro saltoak eta espazioak mantenduz, eta web helbideak eta e-postak klikagarri eginez." #. Default: "If selected, users can choose to be notified of new comments by email." -#: ../interfaces.py:325 +#: ../interfaces.py:319 msgid "help_user_notification_enabled" msgstr "Aukeratuta badago, erabiltzaileek euren erantzunen erantzunak e-postaz jasotzea aktibatu dezakete." #. Default: "Anonymous" #: ../browser/comments.pt:71 -#: ../comment.py:173 +#: ../comment.py:176 msgid "label_anonymous" msgstr "Anonimoak" #. Default: "Enable anonymous comments" -#: ../interfaces.py:234 +#: ../interfaces.py:213 msgid "label_anonymous_comments" msgstr "Aktibatu erabiltzaile anonimoen erantzunak" #. Default: "Enable anonymous email field" -#: ../interfaces.py:332 +#: ../interfaces.py:328 msgid "label_anonymous_email_enabled" -msgstr "" +msgstr "Aktibatu anonimoentzat eposta eremua" #. Default: "Apply" #: ../browser/moderation.pt:71 @@ -302,12 +301,12 @@ msgid "label_apply" msgstr "Aplikatu" #. Default: "Captcha" -#: ../interfaces.py:277 +#: ../interfaces.py:263 msgid "label_captcha" msgstr "Captcha" #. Default: "Comment" -#: ../interfaces.py:159 +#: ../interfaces.py:162 msgid "label_comment" msgstr "Erantzuna" @@ -322,22 +321,22 @@ msgid "label_delete" msgstr "Ezabatu" #. Default: "Globally enable comments" -#: ../interfaces.py:222 +#: ../interfaces.py:199 msgid "label_globally_enabled" msgstr "Erantzunak globalki aktibatu" #. Default: "Enable comment moderation" -#: ../interfaces.py:246 +#: ../interfaces.py:227 msgid "label_moderation_enabled" msgstr "Erantzunen moderazioa aktibatu." #. Default: "Moderator Email Address" -#: ../interfaces.py:314 +#: ../interfaces.py:303 msgid "label_moderator_email" msgstr "Moderatzailearen e-posta helbidea" #. Default: "Enable moderator email notification" -#: ../interfaces.py:302 +#: ../interfaces.py:290 msgid "label_moderator_notification_enabled" msgstr "Aktibatu moderatzaileari e-postaz abisatzea" @@ -352,7 +351,7 @@ msgid "label_says" msgstr "dio:" #. Default: "Show commenter image" -#: ../interfaces.py:292 +#: ../interfaces.py:279 msgid "label_show_commenter_image" msgstr "Erantzuna eman duenaren irudia erakutsi" @@ -362,17 +361,17 @@ msgid "label_show_full_comment_text" msgstr "Erakutsi testu osoa" #. Default: "Subject" -#: ../interfaces.py:155 +#: ../interfaces.py:157 msgid "label_subject" msgstr "Gaia" #. Default: "Comment text transform" -#: ../interfaces.py:261 +#: ../interfaces.py:246 msgid "label_text_transform" msgstr "Erantzunari aplikatu beharreko transformazioa" #. Default: "Enable user email notification" -#: ../interfaces.py:323 +#: ../interfaces.py:315 msgid "label_user_notification_enabled" msgstr "Aktibatu erabiltzaileek e-postaz jakinaraztea" diff --git a/plone/app/discussion/locales/uk/LC_MESSAGES/plone.app.discussion.po b/plone/app/discussion/locales/uk/LC_MESSAGES/plone.app.discussion.po index 615f639..c9a1694 100644 --- a/plone/app/discussion/locales/uk/LC_MESSAGES/plone.app.discussion.po +++ b/plone/app/discussion/locales/uk/LC_MESSAGES/plone.app.discussion.po @@ -14,11 +14,11 @@ msgstr "" "Preferred-Encodings: utf-8 latin1\n" "Domain: DOMAIN\n" -#: ../comment.py:311 +#: ../comment.py:326 msgid "A comment has been posted." msgstr "Коментар додано." -#: ../interfaces.py:141 +#: ../interfaces.py:143 msgid "A comment id unique to this conversation" msgstr "Унікальний ідентифікатор коментаря для цієї розмови" @@ -26,16 +26,16 @@ msgstr "Унікальний ідентифікатор коментаря дл msgid "Add a comment" msgstr "Додати коментар" -#: ../browser/controlpanel.py:64 +#: ../browser/controlpanel.py:66 msgid "Anonymous Comments" msgstr "Анонімне коментування" -#: ../browser/comments.py:251 -#: ../browser/controlpanel.py:82 +#: ../browser/comments.py:258 +#: ../browser/controlpanel.py:84 msgid "Cancel" msgstr "Скасувати" -#: ../browser/controlpanel.py:78 +#: ../browser/controlpanel.py:80 msgid "Changes saved" msgstr "Зміни збережено" @@ -47,18 +47,18 @@ msgstr "Коментар опубліковано." msgid "Comment deleted." msgstr "Коментар знищено." -#: ../browser/controlpanel.py:65 +#: ../browser/controlpanel.py:67 msgid "Commenter Image" msgstr "Зображення коментатора" msgid "Commenting infrastructure for Plone" msgstr "Інфраструктура коментування в Plone" -#: ../interfaces.py:136 +#: ../interfaces.py:138 msgid "Conversation" msgstr "Розмова" -#: ../interfaces.py:167 +#: ../interfaces.py:176 msgid "Creation date" msgstr "Дата створення" @@ -74,39 +74,39 @@ msgstr "Вимкнено" msgid "Discussion settings" msgstr "Налаштуванняя коментування" -#: ../browser/controlpanel.py:84 +#: ../browser/controlpanel.py:86 msgid "Edit cancelled" msgstr "Редагування скасовано" -#: ../interfaces.py:153 +#: ../interfaces.py:155 msgid "Email" msgstr "Електронна адреса" -#: ../browser/controlpanel.py:63 +#: ../browser/controlpanel.py:65 msgid "Enable Comments" msgstr "Увімкнути можливість додавати коментарі" -#: ../interfaces.py:144 +#: ../interfaces.py:146 msgid "Id of comment this comment is in reply to" msgstr "Id коментаря, відповіддю на який - є цей коментар" -#: ../interfaces.py:158 +#: ../interfaces.py:160 msgid "MIME type" msgstr "MIME-тип" -#: ../browser/controlpanel.py:67 +#: ../browser/controlpanel.py:69 msgid "Moderator Email Notification" msgstr "Сповіщення модератора електронною поштою" -#: ../interfaces.py:168 +#: ../interfaces.py:177 msgid "Modification date" msgstr "Дата зміни" -#: ../interfaces.py:138 +#: ../interfaces.py:140 msgid "Name" msgstr "Ім'я" -#: ../interfaces.py:162 +#: ../interfaces.py:169 msgid "Notify me of new comments via email." msgstr "Повідомляти про нові коментарі поштою." @@ -114,11 +114,11 @@ msgstr "Повідомляти про нові коментарі поштою." msgid "Plone Discussions" msgstr "Коментування в Plone" -#: ../interfaces.py:131 +#: ../interfaces.py:133 msgid "Portal type" msgstr "Портал тип" -#: ../browser/controlpanel.py:71 +#: ../browser/controlpanel.py:73 msgid "Save" msgstr "Зберегти" @@ -135,23 +135,23 @@ msgid "Total number of public comments on this item" msgstr "Загальна кількість публічних коментарів для даного елемента" #: ../comment.py:158 -msgid "Transform '%s' => '%s' not available. Failed to transform comment '%s'." -msgstr "Не доступне перетворення '%s' => '%s'. Не вдалося перетворити коментар '%s'." +msgid "Transform '%s' => '%s' not available." +msgstr "Не доступне перетворення '%s' => '%s'." -#: ../browser/controlpanel.py:69 +#: ../browser/controlpanel.py:71 msgid "User Email Notification" msgstr "Сповіщення користувача електронною поштою" -#: ../interfaces.py:166 +#: ../interfaces.py:175 msgid "Username of the commenter" msgstr "Ім'я автора коментаря" -#: ../browser/comments.py:244 +#: ../browser/comments.py:251 msgid "Your comment awaits moderator approval." msgstr "Ваш коментар очікує затвердження модератором." #. Default: "Comment" -#: ../browser/comments.py:131 +#: ../browser/comments.py:132 msgid "add_comment_button" msgstr "Коментар" @@ -221,17 +221,17 @@ msgid "heading_moderate_comments" msgstr "Модерування коментарів" #. Default: "If selected, anonymous users are able to post comments without loggin in. It is highly recommended to use a captcha solution to prevent spam if this setting is enabled." -#: ../interfaces.py:236 +#: ../interfaces.py:215 msgid "help_anonymous_comments" msgstr "Якщо вибрано - то анонімні користувачі зможуть додавати коментарі без входу в систему. Для таких випадків рекомендуєтсья використовувати капчу, щоб запобігти надсиланню спаму." #. Default: "If selected, anonymous user will have to give their email." -#: ../interfaces.py:334 +#: ../interfaces.py:330 msgid "help_anonymous_email_enabled" msgstr "Якщо вибрано, анонімний користувач повинен буде вказати свою електронну пошту." #. Default: "Use this setting to enable or disable Captcha validation for comments. Install plone.formwidget.captcha, plone.formwidget.recaptcha, collective.akismet, or collective.z3cform.norobots if there are no options available." -#: ../interfaces.py:279 +#: ../interfaces.py:265 msgid "help_captcha" msgstr "Використовуйте цей параметр, щоб увімкнути або вимкнути капчу для коментарів. Для цього спершу встановіть plone.formwidget.captcha, plone.formwidget.recaptcha, collective.akismet або collective.z3cform.norobots." @@ -244,53 +244,53 @@ msgstr "" "To enable the moderation workflow for comments, go to the Types Control Panel, choose \"Comment\" and set workflow to \"Comment Review Workflow\".\"" #. Default: "If selected, users are able to post comments on the site. Though, you have to enable comments for specific content types, folders or content objects before users will be able to post comments." -#: ../interfaces.py:224 +#: ../interfaces.py:201 msgid "help_globally_enabled" msgstr "Якщо вибрано, користувачі зможуть додавати коментарі на сайт. Але спочатку необхідно увімкнути можливість коментування для певних типів вмісту, тек, об'єктів." #. Default: "If selected, comments will enter a 'Pending' state in which they are invisible to the public. A user with the 'Review comments' permission ('Reviewer' or 'Manager') can approve comments to make them visible to the public. If you want to enable a custom comment workflow, you have to go to the types control panel." -#: ../interfaces.py:248 +#: ../interfaces.py:231 msgid "help_moderation_enabled" msgstr "Якщо вибрано, коментарі увійде в стан 'В очікуванні', у якому вони невидимі для громадськості. Користувач з правом 'Огляд коментарів' ('Рецензент' або 'Менеджер') може схвалити коментар, щоб зробити їх видимими для громадськості. Якщо ви хочете налаштувати робочий процес коментарів, ви повинні піти в панель керування типів." #. Default: "Address to which moderator notifications will be sent." -#: ../interfaces.py:316 +#: ../interfaces.py:307 msgid "help_moderator_email" msgstr "Адреса, за якою модератору будуть надсилатися повідомлення." #. Default: "If selected, the moderator is notified if a comment needs attention. The moderator email address can be found in the 'Mail settings' control panel (Site 'From' address)" -#: ../interfaces.py:304 +#: ../interfaces.py:292 msgid "help_moderator_notification_enabled" msgstr "Якщо вибрано, модератор отримує повідомлення, якщо коментар вимагає уваги. Адресу електронної пошти модератора можна знайти в 'Пошта' панелі керування (Адреса 'Від')" #. Default: "If selected, an image of the user is shown next to the comment." -#: ../interfaces.py:294 +#: ../interfaces.py:281 msgid "help_show_commenter_image" msgstr "Якщо вибрано, зображення коментатора буде відображатись поруч з коментарем." #. Default: "Use this setting to choose if the comment text should be transformed in any way. You can choose between 'Plain text' and 'Intelligent text'. 'Intelligent text' converts plain text into HTML where line breaks and indentation is preserved, and web and email addresses are made into clickable links." -#: ../interfaces.py:263 +#: ../interfaces.py:248 msgid "help_text_transform" msgstr "Виберіть як повинен бути перетворений текст коментаря. Ви можете вибрати між 'Звичайний текст' і 'Інтелектуальні тексту'. 'Інтелектуальний текст' перетворює текст в HTML, де рядки і відступи зберігаються, інтернет адреси та адреси електронної пошти перетворяться в активні посилання." #. Default: "If selected, users can choose to be notified of new comments by email." -#: ../interfaces.py:325 +#: ../interfaces.py:319 msgid "help_user_notification_enabled" msgstr "Якщо вибрано, користувачі зможуть обрати можливість отримувати нотифікації про нові коментарі поштою." #. Default: "Anonymous" #: ../browser/comments.pt:71 -#: ../comment.py:173 +#: ../comment.py:176 msgid "label_anonymous" msgstr "Анонім" #. Default: "Enable anonymous comments" -#: ../interfaces.py:234 +#: ../interfaces.py:213 msgid "label_anonymous_comments" msgstr "Увімкнути можливість анонімного коментування" #. Default: "Enable anonymous email field" -#: ../interfaces.py:332 +#: ../interfaces.py:328 msgid "label_anonymous_email_enabled" msgstr "Увімкнути поле електронної адреси для аноніма" @@ -300,12 +300,12 @@ msgid "label_apply" msgstr "Застосувати" #. Default: "Captcha" -#: ../interfaces.py:277 +#: ../interfaces.py:263 msgid "label_captcha" msgstr "Капча" #. Default: "Comment" -#: ../interfaces.py:159 +#: ../interfaces.py:162 msgid "label_comment" msgstr "Коментар" @@ -320,22 +320,22 @@ msgid "label_delete" msgstr "Знищити" #. Default: "Globally enable comments" -#: ../interfaces.py:222 +#: ../interfaces.py:199 msgid "label_globally_enabled" msgstr "Увімкнути коментування для цілого сайту" #. Default: "Enable comment moderation" -#: ../interfaces.py:246 +#: ../interfaces.py:227 msgid "label_moderation_enabled" msgstr "Увімкнути модерування коментарів" #. Default: "Moderator Email Address" -#: ../interfaces.py:314 +#: ../interfaces.py:303 msgid "label_moderator_email" msgstr "Електронна адреса модератора" #. Default: "Enable moderator email notification" -#: ../interfaces.py:302 +#: ../interfaces.py:290 msgid "label_moderator_notification_enabled" msgstr "Увімкнути сповіщення модератора" @@ -350,7 +350,7 @@ msgid "label_says" msgstr "каже:" #. Default: "Show commenter image" -#: ../interfaces.py:292 +#: ../interfaces.py:279 msgid "label_show_commenter_image" msgstr "Показати зображення коментатора" @@ -360,17 +360,17 @@ msgid "label_show_full_comment_text" msgstr "показати повний текст коментаря" #. Default: "Subject" -#: ../interfaces.py:155 +#: ../interfaces.py:157 msgid "label_subject" msgstr "Тема" #. Default: "Comment text transform" -#: ../interfaces.py:261 +#: ../interfaces.py:246 msgid "label_text_transform" msgstr "Перетворення тексту коментаря" #. Default: "Enable user email notification" -#: ../interfaces.py:323 +#: ../interfaces.py:315 msgid "label_user_notification_enabled" msgstr "Увімкнути надcилання нотифікації користувачу через електронну адресу" diff --git a/plone/app/discussion/patches.py b/plone/app/discussion/patches.py index 47ec27b..9491702 100644 --- a/plone/app/discussion/patches.py +++ b/plone/app/discussion/patches.py @@ -1,4 +1,4 @@ -from zope.component import queryUtility +from Products.CMFCore.utils import getToolByName from Acquisition import aq_inner, aq_parent @@ -8,7 +8,6 @@ from Products.CMFPlone.utils import base_hasattr from Products.CMFPlone.utils import safe_callable from plone.app.discussion.conversation import ANNOTATION_KEY -from plone.app.discussion.interfaces import ICommentingTool def patchedClearFindAndRebuild(self): @@ -26,14 +25,14 @@ def patchedClearFindAndRebuild(self): obj.indexObject() annotions = IAnnotations(obj) - ctool = queryUtility(ICommentingTool) + catalog = getToolByName(obj, "portal_catalog") if ANNOTATION_KEY in annotions: conversation = annotions[ANNOTATION_KEY] conversation = conversation.__of__(obj) for comment in conversation.getComments(): try: - if ctool: - ctool.indexObject(comment) + if catalog: + catalog.indexObject(comment) except StopIteration: # pragma: no cover pass diff --git a/plone/app/discussion/profiles/default/componentregistry.xml b/plone/app/discussion/profiles/default/componentregistry.xml deleted file mode 100644 index 635cae9..0000000 --- a/plone/app/discussion/profiles/default/componentregistry.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - diff --git a/plone/app/discussion/profiles/default/toolset.xml b/plone/app/discussion/profiles/default/toolset.xml deleted file mode 100644 index 0823a74..0000000 --- a/plone/app/discussion/profiles/default/toolset.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - \ No newline at end of file diff --git a/plone/app/discussion/subscribers.py b/plone/app/discussion/subscribers.py new file mode 100644 index 0000000..07e0bb5 --- /dev/null +++ b/plone/app/discussion/subscribers.py @@ -0,0 +1,15 @@ +from Products.CMFCore.utils import getToolByName + + +def index_object(obj, event): + """Index the object when it is added to the conversation. + """ + catalog = getToolByName(obj, 'portal_catalog') + return catalog.reindexObject(obj) + + +def unindex_object(obj, event): + """Unindex the object when it is removed from the conversation. + """ + catalog = getToolByName(obj, 'portal_catalog') + return catalog.unindexObject(obj) diff --git a/plone/app/discussion/subscribers.zcml b/plone/app/discussion/subscribers.zcml index d0d848d..e563f80 100644 --- a/plone/app/discussion/subscribers.zcml +++ b/plone/app/discussion/subscribers.zcml @@ -24,13 +24,13 @@ `__, and -the test suite will run instrumented in an iframe. Select the Summary tab to see -the results. - -The command-line options ensure that only our tests and the modules being -tested are instrumented for coverage, not the testing framework nor jQuery. - -Note that JSCoverage adds instrumentation statements to the code, so don't try -to debug your tests when running via the jscoverage server. - -.. _QUnit: http://docs.jquery.com/QUnit -.. _JSCoverage: http://siliconforks.com/jscoverage/ diff --git a/plone/app/discussion/tests/javascripts/jquery.js b/plone/app/discussion/tests/javascripts/jquery.js deleted file mode 100644 index 7c24308..0000000 --- a/plone/app/discussion/tests/javascripts/jquery.js +++ /dev/null @@ -1,154 +0,0 @@ -/*! - * jQuery JavaScript Library v1.4.2 - * http://jquery.com/ - * - * Copyright 2010, John Resig - * Dual licensed under the MIT or GPL Version 2 licenses. - * http://jquery.org/license - * - * Includes Sizzle.js - * http://sizzlejs.com/ - * Copyright 2010, The Dojo Foundation - * Released under the MIT, BSD, and GPL Licenses. - * - * Date: Sat Feb 13 22:33:48 2010 -0500 - */ -(function(A,w){function ma(){if(!c.isReady){try{s.documentElement.doScroll("left")}catch(a){setTimeout(ma,1);return}c.ready()}}function Qa(a,b){b.src?c.ajax({url:b.src,async:false,dataType:"script"}):c.globalEval(b.text||b.textContent||b.innerHTML||"");b.parentNode&&b.parentNode.removeChild(b)}function X(a,b,d,f,e,j){var i=a.length;if(typeof b==="object"){for(var o in b)X(a,o,b[o],f,e,d);return a}if(d!==w){f=!j&&f&&c.isFunction(d);for(o=0;o)[^>]*$|^#([\w-]+)$/,Ua=/^.[^:#\[\.,]*$/,Va=/\S/, -Wa=/^(\s|\u00A0)+|(\s|\u00A0)+$/g,Xa=/^<(\w+)\s*\/?>(?:<\/\1>)?$/,P=navigator.userAgent,xa=false,Q=[],L,$=Object.prototype.toString,aa=Object.prototype.hasOwnProperty,ba=Array.prototype.push,R=Array.prototype.slice,ya=Array.prototype.indexOf;c.fn=c.prototype={init:function(a,b){var d,f;if(!a)return this;if(a.nodeType){this.context=this[0]=a;this.length=1;return this}if(a==="body"&&!b){this.context=s;this[0]=s.body;this.selector="body";this.length=1;return this}if(typeof a==="string")if((d=Ta.exec(a))&& -(d[1]||!b))if(d[1]){f=b?b.ownerDocument||b:s;if(a=Xa.exec(a))if(c.isPlainObject(b)){a=[s.createElement(a[1])];c.fn.attr.call(a,b,true)}else a=[f.createElement(a[1])];else{a=sa([d[1]],[f]);a=(a.cacheable?a.fragment.cloneNode(true):a.fragment).childNodes}return c.merge(this,a)}else{if(b=s.getElementById(d[2])){if(b.id!==d[2])return T.find(a);this.length=1;this[0]=b}this.context=s;this.selector=a;return this}else if(!b&&/^\w+$/.test(a)){this.selector=a;this.context=s;a=s.getElementsByTagName(a);return c.merge(this, -a)}else return!b||b.jquery?(b||T).find(a):c(b).find(a);else if(c.isFunction(a))return T.ready(a);if(a.selector!==w){this.selector=a.selector;this.context=a.context}return c.makeArray(a,this)},selector:"",jquery:"1.4.2",length:0,size:function(){return this.length},toArray:function(){return R.call(this,0)},get:function(a){return a==null?this.toArray():a<0?this.slice(a)[0]:this[a]},pushStack:function(a,b,d){var f=c();c.isArray(a)?ba.apply(f,a):c.merge(f,a);f.prevObject=this;f.context=this.context;if(b=== -"find")f.selector=this.selector+(this.selector?" ":"")+d;else if(b)f.selector=this.selector+"."+b+"("+d+")";return f},each:function(a,b){return c.each(this,a,b)},ready:function(a){c.bindReady();if(c.isReady)a.call(s,c);else Q&&Q.push(a);return this},eq:function(a){return a===-1?this.slice(a):this.slice(a,+a+1)},first:function(){return this.eq(0)},last:function(){return this.eq(-1)},slice:function(){return this.pushStack(R.apply(this,arguments),"slice",R.call(arguments).join(","))},map:function(a){return this.pushStack(c.map(this, -function(b,d){return a.call(b,d,b)}))},end:function(){return this.prevObject||c(null)},push:ba,sort:[].sort,splice:[].splice};c.fn.init.prototype=c.fn;c.extend=c.fn.extend=function(){var a=arguments[0]||{},b=1,d=arguments.length,f=false,e,j,i,o;if(typeof a==="boolean"){f=a;a=arguments[1]||{};b=2}if(typeof a!=="object"&&!c.isFunction(a))a={};if(d===b){a=this;--b}for(;b
a"; -var e=d.getElementsByTagName("*"),j=d.getElementsByTagName("a")[0];if(!(!e||!e.length||!j)){c.support={leadingWhitespace:d.firstChild.nodeType===3,tbody:!d.getElementsByTagName("tbody").length,htmlSerialize:!!d.getElementsByTagName("link").length,style:/red/.test(j.getAttribute("style")),hrefNormalized:j.getAttribute("href")==="/a",opacity:/^0.55$/.test(j.style.opacity),cssFloat:!!j.style.cssFloat,checkOn:d.getElementsByTagName("input")[0].value==="on",optSelected:s.createElement("select").appendChild(s.createElement("option")).selected, -parentNode:d.removeChild(d.appendChild(s.createElement("div"))).parentNode===null,deleteExpando:true,checkClone:false,scriptEval:false,noCloneEvent:true,boxModel:null};b.type="text/javascript";try{b.appendChild(s.createTextNode("window."+f+"=1;"))}catch(i){}a.insertBefore(b,a.firstChild);if(A[f]){c.support.scriptEval=true;delete A[f]}try{delete b.test}catch(o){c.support.deleteExpando=false}a.removeChild(b);if(d.attachEvent&&d.fireEvent){d.attachEvent("onclick",function k(){c.support.noCloneEvent= -false;d.detachEvent("onclick",k)});d.cloneNode(true).fireEvent("onclick")}d=s.createElement("div");d.innerHTML="";a=s.createDocumentFragment();a.appendChild(d.firstChild);c.support.checkClone=a.cloneNode(true).cloneNode(true).lastChild.checked;c(function(){var k=s.createElement("div");k.style.width=k.style.paddingLeft="1px";s.body.appendChild(k);c.boxModel=c.support.boxModel=k.offsetWidth===2;s.body.removeChild(k).style.display="none"});a=function(k){var n= -s.createElement("div");k="on"+k;var r=k in n;if(!r){n.setAttribute(k,"return;");r=typeof n[k]==="function"}return r};c.support.submitBubbles=a("submit");c.support.changeBubbles=a("change");a=b=d=e=j=null}})();c.props={"for":"htmlFor","class":"className",readonly:"readOnly",maxlength:"maxLength",cellspacing:"cellSpacing",rowspan:"rowSpan",colspan:"colSpan",tabindex:"tabIndex",usemap:"useMap",frameborder:"frameBorder"};var G="jQuery"+J(),Ya=0,za={};c.extend({cache:{},expando:G,noData:{embed:true,object:true, -applet:true},data:function(a,b,d){if(!(a.nodeName&&c.noData[a.nodeName.toLowerCase()])){a=a==A?za:a;var f=a[G],e=c.cache;if(!f&&typeof b==="string"&&d===w)return null;f||(f=++Ya);if(typeof b==="object"){a[G]=f;e[f]=c.extend(true,{},b)}else if(!e[f]){a[G]=f;e[f]={}}a=e[f];if(d!==w)a[b]=d;return typeof b==="string"?a[b]:a}},removeData:function(a,b){if(!(a.nodeName&&c.noData[a.nodeName.toLowerCase()])){a=a==A?za:a;var d=a[G],f=c.cache,e=f[d];if(b){if(e){delete e[b];c.isEmptyObject(e)&&c.removeData(a)}}else{if(c.support.deleteExpando)delete a[c.expando]; -else a.removeAttribute&&a.removeAttribute(c.expando);delete f[d]}}}});c.fn.extend({data:function(a,b){if(typeof a==="undefined"&&this.length)return c.data(this[0]);else if(typeof a==="object")return this.each(function(){c.data(this,a)});var d=a.split(".");d[1]=d[1]?"."+d[1]:"";if(b===w){var f=this.triggerHandler("getData"+d[1]+"!",[d[0]]);if(f===w&&this.length)f=c.data(this[0],a);return f===w&&d[1]?this.data(d[0]):f}else return this.trigger("setData"+d[1]+"!",[d[0],b]).each(function(){c.data(this, -a,b)})},removeData:function(a){return this.each(function(){c.removeData(this,a)})}});c.extend({queue:function(a,b,d){if(a){b=(b||"fx")+"queue";var f=c.data(a,b);if(!d)return f||[];if(!f||c.isArray(d))f=c.data(a,b,c.makeArray(d));else f.push(d);return f}},dequeue:function(a,b){b=b||"fx";var d=c.queue(a,b),f=d.shift();if(f==="inprogress")f=d.shift();if(f){b==="fx"&&d.unshift("inprogress");f.call(a,function(){c.dequeue(a,b)})}}});c.fn.extend({queue:function(a,b){if(typeof a!=="string"){b=a;a="fx"}if(b=== -w)return c.queue(this[0],a);return this.each(function(){var d=c.queue(this,a,b);a==="fx"&&d[0]!=="inprogress"&&c.dequeue(this,a)})},dequeue:function(a){return this.each(function(){c.dequeue(this,a)})},delay:function(a,b){a=c.fx?c.fx.speeds[a]||a:a;b=b||"fx";return this.queue(b,function(){var d=this;setTimeout(function(){c.dequeue(d,b)},a)})},clearQueue:function(a){return this.queue(a||"fx",[])}});var Aa=/[\n\t]/g,ca=/\s+/,Za=/\r/g,$a=/href|src|style/,ab=/(button|input)/i,bb=/(button|input|object|select|textarea)/i, -cb=/^(a|area)$/i,Ba=/radio|checkbox/;c.fn.extend({attr:function(a,b){return X(this,a,b,true,c.attr)},removeAttr:function(a){return this.each(function(){c.attr(this,a,"");this.nodeType===1&&this.removeAttribute(a)})},addClass:function(a){if(c.isFunction(a))return this.each(function(n){var r=c(this);r.addClass(a.call(this,n,r.attr("class")))});if(a&&typeof a==="string")for(var b=(a||"").split(ca),d=0,f=this.length;d-1)return true;return false},val:function(a){if(a===w){var b=this[0];if(b){if(c.nodeName(b,"option"))return(b.attributes.value||{}).specified?b.value:b.text;if(c.nodeName(b,"select")){var d=b.selectedIndex,f=[],e=b.options;b=b.type==="select-one";if(d<0)return null;var j=b?d:0;for(d=b?d+1:e.length;j=0;else if(c.nodeName(this,"select")){var u=c.makeArray(r);c("option",this).each(function(){this.selected= -c.inArray(c(this).val(),u)>=0});if(!u.length)this.selectedIndex=-1}else this.value=r}})}});c.extend({attrFn:{val:true,css:true,html:true,text:true,data:true,width:true,height:true,offset:true},attr:function(a,b,d,f){if(!a||a.nodeType===3||a.nodeType===8)return w;if(f&&b in c.attrFn)return c(a)[b](d);f=a.nodeType!==1||!c.isXMLDoc(a);var e=d!==w;b=f&&c.props[b]||b;if(a.nodeType===1){var j=$a.test(b);if(b in a&&f&&!j){if(e){b==="type"&&ab.test(a.nodeName)&&a.parentNode&&c.error("type property can't be changed"); -a[b]=d}if(c.nodeName(a,"form")&&a.getAttributeNode(b))return a.getAttributeNode(b).nodeValue;if(b==="tabIndex")return(b=a.getAttributeNode("tabIndex"))&&b.specified?b.value:bb.test(a.nodeName)||cb.test(a.nodeName)&&a.href?0:w;return a[b]}if(!c.support.style&&f&&b==="style"){if(e)a.style.cssText=""+d;return a.style.cssText}e&&a.setAttribute(b,""+d);a=!c.support.hrefNormalized&&f&&j?a.getAttribute(b,2):a.getAttribute(b);return a===null?w:a}return c.style(a,b,d)}});var O=/\.(.*)$/,db=function(a){return a.replace(/[^\w\s\.\|`]/g, -function(b){return"\\"+b})};c.event={add:function(a,b,d,f){if(!(a.nodeType===3||a.nodeType===8)){if(a.setInterval&&a!==A&&!a.frameElement)a=A;var e,j;if(d.handler){e=d;d=e.handler}if(!d.guid)d.guid=c.guid++;if(j=c.data(a)){var i=j.events=j.events||{},o=j.handle;if(!o)j.handle=o=function(){return typeof c!=="undefined"&&!c.event.triggered?c.event.handle.apply(o.elem,arguments):w};o.elem=a;b=b.split(" ");for(var k,n=0,r;k=b[n++];){j=e?c.extend({},e):{handler:d,data:f};if(k.indexOf(".")>-1){r=k.split("."); -k=r.shift();j.namespace=r.slice(0).sort().join(".")}else{r=[];j.namespace=""}j.type=k;j.guid=d.guid;var u=i[k],z=c.event.special[k]||{};if(!u){u=i[k]=[];if(!z.setup||z.setup.call(a,f,r,o)===false)if(a.addEventListener)a.addEventListener(k,o,false);else a.attachEvent&&a.attachEvent("on"+k,o)}if(z.add){z.add.call(a,j);if(!j.handler.guid)j.handler.guid=d.guid}u.push(j);c.event.global[k]=true}a=null}}},global:{},remove:function(a,b,d,f){if(!(a.nodeType===3||a.nodeType===8)){var e,j=0,i,o,k,n,r,u,z=c.data(a), -C=z&&z.events;if(z&&C){if(b&&b.type){d=b.handler;b=b.type}if(!b||typeof b==="string"&&b.charAt(0)==="."){b=b||"";for(e in C)c.event.remove(a,e+b)}else{for(b=b.split(" ");e=b[j++];){n=e;i=e.indexOf(".")<0;o=[];if(!i){o=e.split(".");e=o.shift();k=new RegExp("(^|\\.)"+c.map(o.slice(0).sort(),db).join("\\.(?:.*\\.)?")+"(\\.|$)")}if(r=C[e])if(d){n=c.event.special[e]||{};for(B=f||0;B=0){a.type= -e=e.slice(0,-1);a.exclusive=true}if(!d){a.stopPropagation();c.event.global[e]&&c.each(c.cache,function(){this.events&&this.events[e]&&c.event.trigger(a,b,this.handle.elem)})}if(!d||d.nodeType===3||d.nodeType===8)return w;a.result=w;a.target=d;b=c.makeArray(b);b.unshift(a)}a.currentTarget=d;(f=c.data(d,"handle"))&&f.apply(d,b);f=d.parentNode||d.ownerDocument;try{if(!(d&&d.nodeName&&c.noData[d.nodeName.toLowerCase()]))if(d["on"+e]&&d["on"+e].apply(d,b)===false)a.result=false}catch(j){}if(!a.isPropagationStopped()&& -f)c.event.trigger(a,b,f,true);else if(!a.isDefaultPrevented()){f=a.target;var i,o=c.nodeName(f,"a")&&e==="click",k=c.event.special[e]||{};if((!k._default||k._default.call(d,a)===false)&&!o&&!(f&&f.nodeName&&c.noData[f.nodeName.toLowerCase()])){try{if(f[e]){if(i=f["on"+e])f["on"+e]=null;c.event.triggered=true;f[e]()}}catch(n){}if(i)f["on"+e]=i;c.event.triggered=false}}},handle:function(a){var b,d,f,e;a=arguments[0]=c.event.fix(a||A.event);a.currentTarget=this;b=a.type.indexOf(".")<0&&!a.exclusive; -if(!b){d=a.type.split(".");a.type=d.shift();f=new RegExp("(^|\\.)"+d.slice(0).sort().join("\\.(?:.*\\.)?")+"(\\.|$)")}e=c.data(this,"events");d=e[a.type];if(e&&d){d=d.slice(0);e=0;for(var j=d.length;e-1?c.map(a.options,function(f){return f.selected}).join("-"):"";else if(a.nodeName.toLowerCase()==="select")d=a.selectedIndex;return d},fa=function(a,b){var d=a.target,f,e;if(!(!da.test(d.nodeName)||d.readOnly)){f=c.data(d,"_change_data");e=Fa(d);if(a.type!=="focusout"||d.type!=="radio")c.data(d,"_change_data", -e);if(!(f===w||e===f))if(f!=null||e){a.type="change";return c.event.trigger(a,b,d)}}};c.event.special.change={filters:{focusout:fa,click:function(a){var b=a.target,d=b.type;if(d==="radio"||d==="checkbox"||b.nodeName.toLowerCase()==="select")return fa.call(this,a)},keydown:function(a){var b=a.target,d=b.type;if(a.keyCode===13&&b.nodeName.toLowerCase()!=="textarea"||a.keyCode===32&&(d==="checkbox"||d==="radio")||d==="select-multiple")return fa.call(this,a)},beforeactivate:function(a){a=a.target;c.data(a, -"_change_data",Fa(a))}},setup:function(){if(this.type==="file")return false;for(var a in ea)c.event.add(this,a+".specialChange",ea[a]);return da.test(this.nodeName)},teardown:function(){c.event.remove(this,".specialChange");return da.test(this.nodeName)}};ea=c.event.special.change.filters}s.addEventListener&&c.each({focus:"focusin",blur:"focusout"},function(a,b){function d(f){f=c.event.fix(f);f.type=b;return c.event.handle.call(this,f)}c.event.special[b]={setup:function(){this.addEventListener(a, -d,true)},teardown:function(){this.removeEventListener(a,d,true)}}});c.each(["bind","one"],function(a,b){c.fn[b]=function(d,f,e){if(typeof d==="object"){for(var j in d)this[b](j,f,d[j],e);return this}if(c.isFunction(f)){e=f;f=w}var i=b==="one"?c.proxy(e,function(k){c(this).unbind(k,i);return e.apply(this,arguments)}):e;if(d==="unload"&&b!=="one")this.one(d,f,e);else{j=0;for(var o=this.length;j0){y=t;break}}t=t[g]}m[q]=y}}}var f=/((?:\((?:\([^()]+\)|[^()]+)+\)|\[(?:\[[^[\]]*\]|['"][^'"]*['"]|[^[\]'"]+)+\]|\\.|[^ >+~,(\[\\]+)+|[>+~])(\s*,\s*)?((?:.|\r|\n)*)/g, -e=0,j=Object.prototype.toString,i=false,o=true;[0,0].sort(function(){o=false;return 0});var k=function(g,h,l,m){l=l||[];var q=h=h||s;if(h.nodeType!==1&&h.nodeType!==9)return[];if(!g||typeof g!=="string")return l;for(var p=[],v,t,y,S,H=true,M=x(h),I=g;(f.exec(""),v=f.exec(I))!==null;){I=v[3];p.push(v[1]);if(v[2]){S=v[3];break}}if(p.length>1&&r.exec(g))if(p.length===2&&n.relative[p[0]])t=ga(p[0]+p[1],h);else for(t=n.relative[p[0]]?[h]:k(p.shift(),h);p.length;){g=p.shift();if(n.relative[g])g+=p.shift(); -t=ga(g,t)}else{if(!m&&p.length>1&&h.nodeType===9&&!M&&n.match.ID.test(p[0])&&!n.match.ID.test(p[p.length-1])){v=k.find(p.shift(),h,M);h=v.expr?k.filter(v.expr,v.set)[0]:v.set[0]}if(h){v=m?{expr:p.pop(),set:z(m)}:k.find(p.pop(),p.length===1&&(p[0]==="~"||p[0]==="+")&&h.parentNode?h.parentNode:h,M);t=v.expr?k.filter(v.expr,v.set):v.set;if(p.length>0)y=z(t);else H=false;for(;p.length;){var D=p.pop();v=D;if(n.relative[D])v=p.pop();else D="";if(v==null)v=h;n.relative[D](y,v,M)}}else y=[]}y||(y=t);y||k.error(D|| -g);if(j.call(y)==="[object Array]")if(H)if(h&&h.nodeType===1)for(g=0;y[g]!=null;g++){if(y[g]&&(y[g]===true||y[g].nodeType===1&&E(h,y[g])))l.push(t[g])}else for(g=0;y[g]!=null;g++)y[g]&&y[g].nodeType===1&&l.push(t[g]);else l.push.apply(l,y);else z(y,l);if(S){k(S,q,l,m);k.uniqueSort(l)}return l};k.uniqueSort=function(g){if(B){i=o;g.sort(B);if(i)for(var h=1;h":function(g,h){var l=typeof h==="string";if(l&&!/\W/.test(h)){h=h.toLowerCase();for(var m=0,q=g.length;m=0))l||m.push(v);else if(l)h[p]=false;return false},ID:function(g){return g[1].replace(/\\/g,"")},TAG:function(g){return g[1].toLowerCase()}, -CHILD:function(g){if(g[1]==="nth"){var h=/(-?)(\d*)n((?:\+|-)?\d*)/.exec(g[2]==="even"&&"2n"||g[2]==="odd"&&"2n+1"||!/\D/.test(g[2])&&"0n+"+g[2]||g[2]);g[2]=h[1]+(h[2]||1)-0;g[3]=h[3]-0}g[0]=e++;return g},ATTR:function(g,h,l,m,q,p){h=g[1].replace(/\\/g,"");if(!p&&n.attrMap[h])g[1]=n.attrMap[h];if(g[2]==="~=")g[4]=" "+g[4]+" ";return g},PSEUDO:function(g,h,l,m,q){if(g[1]==="not")if((f.exec(g[3])||"").length>1||/^\w/.test(g[3]))g[3]=k(g[3],null,null,h);else{g=k.filter(g[3],h,l,true^q);l||m.push.apply(m, -g);return false}else if(n.match.POS.test(g[0])||n.match.CHILD.test(g[0]))return true;return g},POS:function(g){g.unshift(true);return g}},filters:{enabled:function(g){return g.disabled===false&&g.type!=="hidden"},disabled:function(g){return g.disabled===true},checked:function(g){return g.checked===true},selected:function(g){return g.selected===true},parent:function(g){return!!g.firstChild},empty:function(g){return!g.firstChild},has:function(g,h,l){return!!k(l[3],g).length},header:function(g){return/h\d/i.test(g.nodeName)}, -text:function(g){return"text"===g.type},radio:function(g){return"radio"===g.type},checkbox:function(g){return"checkbox"===g.type},file:function(g){return"file"===g.type},password:function(g){return"password"===g.type},submit:function(g){return"submit"===g.type},image:function(g){return"image"===g.type},reset:function(g){return"reset"===g.type},button:function(g){return"button"===g.type||g.nodeName.toLowerCase()==="button"},input:function(g){return/input|select|textarea|button/i.test(g.nodeName)}}, -setFilters:{first:function(g,h){return h===0},last:function(g,h,l,m){return h===m.length-1},even:function(g,h){return h%2===0},odd:function(g,h){return h%2===1},lt:function(g,h,l){return hl[3]-0},nth:function(g,h,l){return l[3]-0===h},eq:function(g,h,l){return l[3]-0===h}},filter:{PSEUDO:function(g,h,l,m){var q=h[1],p=n.filters[q];if(p)return p(g,l,h,m);else if(q==="contains")return(g.textContent||g.innerText||a([g])||"").indexOf(h[3])>=0;else if(q==="not"){h= -h[3];l=0;for(m=h.length;l=0}},ID:function(g,h){return g.nodeType===1&&g.getAttribute("id")===h},TAG:function(g,h){return h==="*"&&g.nodeType===1||g.nodeName.toLowerCase()===h},CLASS:function(g,h){return(" "+(g.className||g.getAttribute("class"))+" ").indexOf(h)>-1},ATTR:function(g,h){var l=h[1];g=n.attrHandle[l]?n.attrHandle[l](g):g[l]!=null?g[l]:g.getAttribute(l);l=g+"";var m=h[2];h=h[4];return g==null?m==="!=":m=== -"="?l===h:m==="*="?l.indexOf(h)>=0:m==="~="?(" "+l+" ").indexOf(h)>=0:!h?l&&g!==false:m==="!="?l!==h:m==="^="?l.indexOf(h)===0:m==="$="?l.substr(l.length-h.length)===h:m==="|="?l===h||l.substr(0,h.length+1)===h+"-":false},POS:function(g,h,l,m){var q=n.setFilters[h[2]];if(q)return q(g,l,h,m)}}},r=n.match.POS;for(var u in n.match){n.match[u]=new RegExp(n.match[u].source+/(?![^\[]*\])(?![^\(]*\))/.source);n.leftMatch[u]=new RegExp(/(^(?:.|\r|\n)*?)/.source+n.match[u].source.replace(/\\(\d+)/g,function(g, -h){return"\\"+(h-0+1)}))}var z=function(g,h){g=Array.prototype.slice.call(g,0);if(h){h.push.apply(h,g);return h}return g};try{Array.prototype.slice.call(s.documentElement.childNodes,0)}catch(C){z=function(g,h){h=h||[];if(j.call(g)==="[object Array]")Array.prototype.push.apply(h,g);else if(typeof g.length==="number")for(var l=0,m=g.length;l";var l=s.documentElement;l.insertBefore(g,l.firstChild);if(s.getElementById(h)){n.find.ID=function(m,q,p){if(typeof q.getElementById!=="undefined"&&!p)return(q=q.getElementById(m[1]))?q.id===m[1]||typeof q.getAttributeNode!=="undefined"&& -q.getAttributeNode("id").nodeValue===m[1]?[q]:w:[]};n.filter.ID=function(m,q){var p=typeof m.getAttributeNode!=="undefined"&&m.getAttributeNode("id");return m.nodeType===1&&p&&p.nodeValue===q}}l.removeChild(g);l=g=null})();(function(){var g=s.createElement("div");g.appendChild(s.createComment(""));if(g.getElementsByTagName("*").length>0)n.find.TAG=function(h,l){l=l.getElementsByTagName(h[1]);if(h[1]==="*"){h=[];for(var m=0;l[m];m++)l[m].nodeType===1&&h.push(l[m]);l=h}return l};g.innerHTML=""; -if(g.firstChild&&typeof g.firstChild.getAttribute!=="undefined"&&g.firstChild.getAttribute("href")!=="#")n.attrHandle.href=function(h){return h.getAttribute("href",2)};g=null})();s.querySelectorAll&&function(){var g=k,h=s.createElement("div");h.innerHTML="

";if(!(h.querySelectorAll&&h.querySelectorAll(".TEST").length===0)){k=function(m,q,p,v){q=q||s;if(!v&&q.nodeType===9&&!x(q))try{return z(q.querySelectorAll(m),p)}catch(t){}return g(m,q,p,v)};for(var l in g)k[l]=g[l];h=null}}(); -(function(){var g=s.createElement("div");g.innerHTML="
";if(!(!g.getElementsByClassName||g.getElementsByClassName("e").length===0)){g.lastChild.className="e";if(g.getElementsByClassName("e").length!==1){n.order.splice(1,0,"CLASS");n.find.CLASS=function(h,l,m){if(typeof l.getElementsByClassName!=="undefined"&&!m)return l.getElementsByClassName(h[1])};g=null}}})();var E=s.compareDocumentPosition?function(g,h){return!!(g.compareDocumentPosition(h)&16)}: -function(g,h){return g!==h&&(g.contains?g.contains(h):true)},x=function(g){return(g=(g?g.ownerDocument||g:0).documentElement)?g.nodeName!=="HTML":false},ga=function(g,h){var l=[],m="",q;for(h=h.nodeType?[h]:h;q=n.match.PSEUDO.exec(g);){m+=q[0];g=g.replace(n.match.PSEUDO,"")}g=n.relative[g]?g+"*":g;q=0;for(var p=h.length;q=0===d})};c.fn.extend({find:function(a){for(var b=this.pushStack("","find",a),d=0,f=0,e=this.length;f0)for(var j=d;j0},closest:function(a,b){if(c.isArray(a)){var d=[],f=this[0],e,j= -{},i;if(f&&a.length){e=0;for(var o=a.length;e-1:c(f).is(e)){d.push({selector:i,elem:f});delete j[i]}}f=f.parentNode}}return d}var k=c.expr.match.POS.test(a)?c(a,b||this.context):null;return this.map(function(n,r){for(;r&&r.ownerDocument&&r!==b;){if(k?k.index(r)>-1:c(r).is(a))return r;r=r.parentNode}return null})},index:function(a){if(!a||typeof a=== -"string")return c.inArray(this[0],a?c(a):this.parent().children());return c.inArray(a.jquery?a[0]:a,this)},add:function(a,b){a=typeof a==="string"?c(a,b||this.context):c.makeArray(a);b=c.merge(this.get(),a);return this.pushStack(qa(a[0])||qa(b[0])?b:c.unique(b))},andSelf:function(){return this.add(this.prevObject)}});c.each({parent:function(a){return(a=a.parentNode)&&a.nodeType!==11?a:null},parents:function(a){return c.dir(a,"parentNode")},parentsUntil:function(a,b,d){return c.dir(a,"parentNode", -d)},next:function(a){return c.nth(a,2,"nextSibling")},prev:function(a){return c.nth(a,2,"previousSibling")},nextAll:function(a){return c.dir(a,"nextSibling")},prevAll:function(a){return c.dir(a,"previousSibling")},nextUntil:function(a,b,d){return c.dir(a,"nextSibling",d)},prevUntil:function(a,b,d){return c.dir(a,"previousSibling",d)},siblings:function(a){return c.sibling(a.parentNode.firstChild,a)},children:function(a){return c.sibling(a.firstChild)},contents:function(a){return c.nodeName(a,"iframe")? -a.contentDocument||a.contentWindow.document:c.makeArray(a.childNodes)}},function(a,b){c.fn[a]=function(d,f){var e=c.map(this,b,d);eb.test(a)||(f=d);if(f&&typeof f==="string")e=c.filter(f,e);e=this.length>1?c.unique(e):e;if((this.length>1||gb.test(f))&&fb.test(a))e=e.reverse();return this.pushStack(e,a,R.call(arguments).join(","))}});c.extend({filter:function(a,b,d){if(d)a=":not("+a+")";return c.find.matches(a,b)},dir:function(a,b,d){var f=[];for(a=a[b];a&&a.nodeType!==9&&(d===w||a.nodeType!==1||!c(a).is(d));){a.nodeType=== -1&&f.push(a);a=a[b]}return f},nth:function(a,b,d){b=b||1;for(var f=0;a;a=a[d])if(a.nodeType===1&&++f===b)break;return a},sibling:function(a,b){for(var d=[];a;a=a.nextSibling)a.nodeType===1&&a!==b&&d.push(a);return d}});var Ja=/ jQuery\d+="(?:\d+|null)"/g,V=/^\s+/,Ka=/(<([\w:]+)[^>]*?)\/>/g,hb=/^(?:area|br|col|embed|hr|img|input|link|meta|param)$/i,La=/<([\w:]+)/,ib=/"},F={option:[1,""],legend:[1,"
","
"],thead:[1,"","
"],tr:[2,"","
"],td:[3,"","
"],col:[2,"","
"],area:[1,"",""],_default:[0,"",""]};F.optgroup=F.option;F.tbody=F.tfoot=F.colgroup=F.caption=F.thead;F.th=F.td;if(!c.support.htmlSerialize)F._default=[1,"div
","
"];c.fn.extend({text:function(a){if(c.isFunction(a))return this.each(function(b){var d= -c(this);d.text(a.call(this,b,d.text()))});if(typeof a!=="object"&&a!==w)return this.empty().append((this[0]&&this[0].ownerDocument||s).createTextNode(a));return c.text(this)},wrapAll:function(a){if(c.isFunction(a))return this.each(function(d){c(this).wrapAll(a.call(this,d))});if(this[0]){var b=c(a,this[0].ownerDocument).eq(0).clone(true);this[0].parentNode&&b.insertBefore(this[0]);b.map(function(){for(var d=this;d.firstChild&&d.firstChild.nodeType===1;)d=d.firstChild;return d}).append(this)}return this}, -wrapInner:function(a){if(c.isFunction(a))return this.each(function(b){c(this).wrapInner(a.call(this,b))});return this.each(function(){var b=c(this),d=b.contents();d.length?d.wrapAll(a):b.append(a)})},wrap:function(a){return this.each(function(){c(this).wrapAll(a)})},unwrap:function(){return this.parent().each(function(){c.nodeName(this,"body")||c(this).replaceWith(this.childNodes)}).end()},append:function(){return this.domManip(arguments,true,function(a){this.nodeType===1&&this.appendChild(a)})}, -prepend:function(){return this.domManip(arguments,true,function(a){this.nodeType===1&&this.insertBefore(a,this.firstChild)})},before:function(){if(this[0]&&this[0].parentNode)return this.domManip(arguments,false,function(b){this.parentNode.insertBefore(b,this)});else if(arguments.length){var a=c(arguments[0]);a.push.apply(a,this.toArray());return this.pushStack(a,"before",arguments)}},after:function(){if(this[0]&&this[0].parentNode)return this.domManip(arguments,false,function(b){this.parentNode.insertBefore(b, -this.nextSibling)});else if(arguments.length){var a=this.pushStack(this,"after",arguments);a.push.apply(a,c(arguments[0]).toArray());return a}},remove:function(a,b){for(var d=0,f;(f=this[d])!=null;d++)if(!a||c.filter(a,[f]).length){if(!b&&f.nodeType===1){c.cleanData(f.getElementsByTagName("*"));c.cleanData([f])}f.parentNode&&f.parentNode.removeChild(f)}return this},empty:function(){for(var a=0,b;(b=this[a])!=null;a++)for(b.nodeType===1&&c.cleanData(b.getElementsByTagName("*"));b.firstChild;)b.removeChild(b.firstChild); -return this},clone:function(a){var b=this.map(function(){if(!c.support.noCloneEvent&&!c.isXMLDoc(this)){var d=this.outerHTML,f=this.ownerDocument;if(!d){d=f.createElement("div");d.appendChild(this.cloneNode(true));d=d.innerHTML}return c.clean([d.replace(Ja,"").replace(/=([^="'>\s]+\/)>/g,'="$1">').replace(V,"")],f)[0]}else return this.cloneNode(true)});if(a===true){ra(this,b);ra(this.find("*"),b.find("*"))}return b},html:function(a){if(a===w)return this[0]&&this[0].nodeType===1?this[0].innerHTML.replace(Ja, -""):null;else if(typeof a==="string"&&!ta.test(a)&&(c.support.leadingWhitespace||!V.test(a))&&!F[(La.exec(a)||["",""])[1].toLowerCase()]){a=a.replace(Ka,Ma);try{for(var b=0,d=this.length;b0||e.cacheable||this.length>1?k.cloneNode(true):k)}o.length&&c.each(o,Qa)}return this}});c.fragments={};c.each({appendTo:"append",prependTo:"prepend",insertBefore:"before",insertAfter:"after",replaceAll:"replaceWith"},function(a,b){c.fn[a]=function(d){var f=[];d=c(d);var e=this.length===1&&this[0].parentNode;if(e&&e.nodeType===11&&e.childNodes.length===1&&d.length===1){d[b](this[0]); -return this}else{e=0;for(var j=d.length;e0?this.clone(true):this).get();c.fn[b].apply(c(d[e]),i);f=f.concat(i)}return this.pushStack(f,a,d.selector)}}});c.extend({clean:function(a,b,d,f){b=b||s;if(typeof b.createElement==="undefined")b=b.ownerDocument||b[0]&&b[0].ownerDocument||s;for(var e=[],j=0,i;(i=a[j])!=null;j++){if(typeof i==="number")i+="";if(i){if(typeof i==="string"&&!jb.test(i))i=b.createTextNode(i);else if(typeof i==="string"){i=i.replace(Ka,Ma);var o=(La.exec(i)||["", -""])[1].toLowerCase(),k=F[o]||F._default,n=k[0],r=b.createElement("div");for(r.innerHTML=k[1]+i+k[2];n--;)r=r.lastChild;if(!c.support.tbody){n=ib.test(i);o=o==="table"&&!n?r.firstChild&&r.firstChild.childNodes:k[1]===""&&!n?r.childNodes:[];for(k=o.length-1;k>=0;--k)c.nodeName(o[k],"tbody")&&!o[k].childNodes.length&&o[k].parentNode.removeChild(o[k])}!c.support.leadingWhitespace&&V.test(i)&&r.insertBefore(b.createTextNode(V.exec(i)[0]),r.firstChild);i=r.childNodes}if(i.nodeType)e.push(i);else e= -c.merge(e,i)}}if(d)for(j=0;e[j];j++)if(f&&c.nodeName(e[j],"script")&&(!e[j].type||e[j].type.toLowerCase()==="text/javascript"))f.push(e[j].parentNode?e[j].parentNode.removeChild(e[j]):e[j]);else{e[j].nodeType===1&&e.splice.apply(e,[j+1,0].concat(c.makeArray(e[j].getElementsByTagName("script"))));d.appendChild(e[j])}return e},cleanData:function(a){for(var b,d,f=c.cache,e=c.event.special,j=c.support.deleteExpando,i=0,o;(o=a[i])!=null;i++)if(d=o[c.expando]){b=f[d];if(b.events)for(var k in b.events)e[k]? -c.event.remove(o,k):Ca(o,k,b.handle);if(j)delete o[c.expando];else o.removeAttribute&&o.removeAttribute(c.expando);delete f[d]}}});var kb=/z-?index|font-?weight|opacity|zoom|line-?height/i,Na=/alpha\([^)]*\)/,Oa=/opacity=([^)]*)/,ha=/float/i,ia=/-([a-z])/ig,lb=/([A-Z])/g,mb=/^-?\d+(?:px)?$/i,nb=/^-?\d/,ob={position:"absolute",visibility:"hidden",display:"block"},pb=["Left","Right"],qb=["Top","Bottom"],rb=s.defaultView&&s.defaultView.getComputedStyle,Pa=c.support.cssFloat?"cssFloat":"styleFloat",ja= -function(a,b){return b.toUpperCase()};c.fn.css=function(a,b){return X(this,a,b,true,function(d,f,e){if(e===w)return c.curCSS(d,f);if(typeof e==="number"&&!kb.test(f))e+="px";c.style(d,f,e)})};c.extend({style:function(a,b,d){if(!a||a.nodeType===3||a.nodeType===8)return w;if((b==="width"||b==="height")&&parseFloat(d)<0)d=w;var f=a.style||a,e=d!==w;if(!c.support.opacity&&b==="opacity"){if(e){f.zoom=1;b=parseInt(d,10)+""==="NaN"?"":"alpha(opacity="+d*100+")";a=f.filter||c.curCSS(a,"filter")||"";f.filter= -Na.test(a)?a.replace(Na,b):b}return f.filter&&f.filter.indexOf("opacity=")>=0?parseFloat(Oa.exec(f.filter)[1])/100+"":""}if(ha.test(b))b=Pa;b=b.replace(ia,ja);if(e)f[b]=d;return f[b]},css:function(a,b,d,f){if(b==="width"||b==="height"){var e,j=b==="width"?pb:qb;function i(){e=b==="width"?a.offsetWidth:a.offsetHeight;f!=="border"&&c.each(j,function(){f||(e-=parseFloat(c.curCSS(a,"padding"+this,true))||0);if(f==="margin")e+=parseFloat(c.curCSS(a,"margin"+this,true))||0;else e-=parseFloat(c.curCSS(a, -"border"+this+"Width",true))||0})}a.offsetWidth!==0?i():c.swap(a,ob,i);return Math.max(0,Math.round(e))}return c.curCSS(a,b,d)},curCSS:function(a,b,d){var f,e=a.style;if(!c.support.opacity&&b==="opacity"&&a.currentStyle){f=Oa.test(a.currentStyle.filter||"")?parseFloat(RegExp.$1)/100+"":"";return f===""?"1":f}if(ha.test(b))b=Pa;if(!d&&e&&e[b])f=e[b];else if(rb){if(ha.test(b))b="float";b=b.replace(lb,"-$1").toLowerCase();e=a.ownerDocument.defaultView;if(!e)return null;if(a=e.getComputedStyle(a,null))f= -a.getPropertyValue(b);if(b==="opacity"&&f==="")f="1"}else if(a.currentStyle){d=b.replace(ia,ja);f=a.currentStyle[b]||a.currentStyle[d];if(!mb.test(f)&&nb.test(f)){b=e.left;var j=a.runtimeStyle.left;a.runtimeStyle.left=a.currentStyle.left;e.left=d==="fontSize"?"1em":f||0;f=e.pixelLeft+"px";e.left=b;a.runtimeStyle.left=j}}return f},swap:function(a,b,d){var f={};for(var e in b){f[e]=a.style[e];a.style[e]=b[e]}d.call(a);for(e in b)a.style[e]=f[e]}});if(c.expr&&c.expr.filters){c.expr.filters.hidden=function(a){var b= -a.offsetWidth,d=a.offsetHeight,f=a.nodeName.toLowerCase()==="tr";return b===0&&d===0&&!f?true:b>0&&d>0&&!f?false:c.curCSS(a,"display")==="none"};c.expr.filters.visible=function(a){return!c.expr.filters.hidden(a)}}var sb=J(),tb=//gi,ub=/select|textarea/i,vb=/color|date|datetime|email|hidden|month|number|password|range|search|tel|text|time|url|week/i,N=/=\?(&|$)/,ka=/\?/,wb=/(\?|&)_=.*?(&|$)/,xb=/^(\w+:)?\/\/([^\/?#]+)/,yb=/%20/g,zb=c.fn.load;c.fn.extend({load:function(a,b,d){if(typeof a!== -"string")return zb.call(this,a);else if(!this.length)return this;var f=a.indexOf(" ");if(f>=0){var e=a.slice(f,a.length);a=a.slice(0,f)}f="GET";if(b)if(c.isFunction(b)){d=b;b=null}else if(typeof b==="object"){b=c.param(b,c.ajaxSettings.traditional);f="POST"}var j=this;c.ajax({url:a,type:f,dataType:"html",data:b,complete:function(i,o){if(o==="success"||o==="notmodified")j.html(e?c("
").append(i.responseText.replace(tb,"")).find(e):i.responseText);d&&j.each(d,[i.responseText,o,i])}});return this}, -serialize:function(){return c.param(this.serializeArray())},serializeArray:function(){return this.map(function(){return this.elements?c.makeArray(this.elements):this}).filter(function(){return this.name&&!this.disabled&&(this.checked||ub.test(this.nodeName)||vb.test(this.type))}).map(function(a,b){a=c(this).val();return a==null?null:c.isArray(a)?c.map(a,function(d){return{name:b.name,value:d}}):{name:b.name,value:a}}).get()}});c.each("ajaxStart ajaxStop ajaxComplete ajaxError ajaxSuccess ajaxSend".split(" "), -function(a,b){c.fn[b]=function(d){return this.bind(b,d)}});c.extend({get:function(a,b,d,f){if(c.isFunction(b)){f=f||d;d=b;b=null}return c.ajax({type:"GET",url:a,data:b,success:d,dataType:f})},getScript:function(a,b){return c.get(a,null,b,"script")},getJSON:function(a,b,d){return c.get(a,b,d,"json")},post:function(a,b,d,f){if(c.isFunction(b)){f=f||d;d=b;b={}}return c.ajax({type:"POST",url:a,data:b,success:d,dataType:f})},ajaxSetup:function(a){c.extend(c.ajaxSettings,a)},ajaxSettings:{url:location.href, -global:true,type:"GET",contentType:"application/x-www-form-urlencoded",processData:true,async:true,xhr:A.XMLHttpRequest&&(A.location.protocol!=="file:"||!A.ActiveXObject)?function(){return new A.XMLHttpRequest}:function(){try{return new A.ActiveXObject("Microsoft.XMLHTTP")}catch(a){}},accepts:{xml:"application/xml, text/xml",html:"text/html",script:"text/javascript, application/javascript",json:"application/json, text/javascript",text:"text/plain",_default:"*/*"}},lastModified:{},etag:{},ajax:function(a){function b(){e.success&& -e.success.call(k,o,i,x);e.global&&f("ajaxSuccess",[x,e])}function d(){e.complete&&e.complete.call(k,x,i);e.global&&f("ajaxComplete",[x,e]);e.global&&!--c.active&&c.event.trigger("ajaxStop")}function f(q,p){(e.context?c(e.context):c.event).trigger(q,p)}var e=c.extend(true,{},c.ajaxSettings,a),j,i,o,k=a&&a.context||e,n=e.type.toUpperCase();if(e.data&&e.processData&&typeof e.data!=="string")e.data=c.param(e.data,e.traditional);if(e.dataType==="jsonp"){if(n==="GET")N.test(e.url)||(e.url+=(ka.test(e.url)? -"&":"?")+(e.jsonp||"callback")+"=?");else if(!e.data||!N.test(e.data))e.data=(e.data?e.data+"&":"")+(e.jsonp||"callback")+"=?";e.dataType="json"}if(e.dataType==="json"&&(e.data&&N.test(e.data)||N.test(e.url))){j=e.jsonpCallback||"jsonp"+sb++;if(e.data)e.data=(e.data+"").replace(N,"="+j+"$1");e.url=e.url.replace(N,"="+j+"$1");e.dataType="script";A[j]=A[j]||function(q){o=q;b();d();A[j]=w;try{delete A[j]}catch(p){}z&&z.removeChild(C)}}if(e.dataType==="script"&&e.cache===null)e.cache=false;if(e.cache=== -false&&n==="GET"){var r=J(),u=e.url.replace(wb,"$1_="+r+"$2");e.url=u+(u===e.url?(ka.test(e.url)?"&":"?")+"_="+r:"")}if(e.data&&n==="GET")e.url+=(ka.test(e.url)?"&":"?")+e.data;e.global&&!c.active++&&c.event.trigger("ajaxStart");r=(r=xb.exec(e.url))&&(r[1]&&r[1]!==location.protocol||r[2]!==location.host);if(e.dataType==="script"&&n==="GET"&&r){var z=s.getElementsByTagName("head")[0]||s.documentElement,C=s.createElement("script");C.src=e.url;if(e.scriptCharset)C.charset=e.scriptCharset;if(!j){var B= -false;C.onload=C.onreadystatechange=function(){if(!B&&(!this.readyState||this.readyState==="loaded"||this.readyState==="complete")){B=true;b();d();C.onload=C.onreadystatechange=null;z&&C.parentNode&&z.removeChild(C)}}}z.insertBefore(C,z.firstChild);return w}var E=false,x=e.xhr();if(x){e.username?x.open(n,e.url,e.async,e.username,e.password):x.open(n,e.url,e.async);try{if(e.data||a&&a.contentType)x.setRequestHeader("Content-Type",e.contentType);if(e.ifModified){c.lastModified[e.url]&&x.setRequestHeader("If-Modified-Since", -c.lastModified[e.url]);c.etag[e.url]&&x.setRequestHeader("If-None-Match",c.etag[e.url])}r||x.setRequestHeader("X-Requested-With","XMLHttpRequest");x.setRequestHeader("Accept",e.dataType&&e.accepts[e.dataType]?e.accepts[e.dataType]+", */*":e.accepts._default)}catch(ga){}if(e.beforeSend&&e.beforeSend.call(k,x,e)===false){e.global&&!--c.active&&c.event.trigger("ajaxStop");x.abort();return false}e.global&&f("ajaxSend",[x,e]);var g=x.onreadystatechange=function(q){if(!x||x.readyState===0||q==="abort"){E|| -d();E=true;if(x)x.onreadystatechange=c.noop}else if(!E&&x&&(x.readyState===4||q==="timeout")){E=true;x.onreadystatechange=c.noop;i=q==="timeout"?"timeout":!c.httpSuccess(x)?"error":e.ifModified&&c.httpNotModified(x,e.url)?"notmodified":"success";var p;if(i==="success")try{o=c.httpData(x,e.dataType,e)}catch(v){i="parsererror";p=v}if(i==="success"||i==="notmodified")j||b();else c.handleError(e,x,i,p);d();q==="timeout"&&x.abort();if(e.async)x=null}};try{var h=x.abort;x.abort=function(){x&&h.call(x); -g("abort")}}catch(l){}e.async&&e.timeout>0&&setTimeout(function(){x&&!E&&g("timeout")},e.timeout);try{x.send(n==="POST"||n==="PUT"||n==="DELETE"?e.data:null)}catch(m){c.handleError(e,x,null,m);d()}e.async||g();return x}},handleError:function(a,b,d,f){if(a.error)a.error.call(a.context||a,b,d,f);if(a.global)(a.context?c(a.context):c.event).trigger("ajaxError",[b,a,f])},active:0,httpSuccess:function(a){try{return!a.status&&location.protocol==="file:"||a.status>=200&&a.status<300||a.status===304||a.status=== -1223||a.status===0}catch(b){}return false},httpNotModified:function(a,b){var d=a.getResponseHeader("Last-Modified"),f=a.getResponseHeader("Etag");if(d)c.lastModified[b]=d;if(f)c.etag[b]=f;return a.status===304||a.status===0},httpData:function(a,b,d){var f=a.getResponseHeader("content-type")||"",e=b==="xml"||!b&&f.indexOf("xml")>=0;a=e?a.responseXML:a.responseText;e&&a.documentElement.nodeName==="parsererror"&&c.error("parsererror");if(d&&d.dataFilter)a=d.dataFilter(a,b);if(typeof a==="string")if(b=== -"json"||!b&&f.indexOf("json")>=0)a=c.parseJSON(a);else if(b==="script"||!b&&f.indexOf("javascript")>=0)c.globalEval(a);return a},param:function(a,b){function d(i,o){if(c.isArray(o))c.each(o,function(k,n){b||/\[\]$/.test(i)?f(i,n):d(i+"["+(typeof n==="object"||c.isArray(n)?k:"")+"]",n)});else!b&&o!=null&&typeof o==="object"?c.each(o,function(k,n){d(i+"["+k+"]",n)}):f(i,o)}function f(i,o){o=c.isFunction(o)?o():o;e[e.length]=encodeURIComponent(i)+"="+encodeURIComponent(o)}var e=[];if(b===w)b=c.ajaxSettings.traditional; -if(c.isArray(a)||a.jquery)c.each(a,function(){f(this.name,this.value)});else for(var j in a)d(j,a[j]);return e.join("&").replace(yb,"+")}});var la={},Ab=/toggle|show|hide/,Bb=/^([+-]=)?([\d+-.]+)(.*)$/,W,va=[["height","marginTop","marginBottom","paddingTop","paddingBottom"],["width","marginLeft","marginRight","paddingLeft","paddingRight"],["opacity"]];c.fn.extend({show:function(a,b){if(a||a===0)return this.animate(K("show",3),a,b);else{a=0;for(b=this.length;a").appendTo("body");f=e.css("display");if(f==="none")f="block";e.remove();la[d]=f}c.data(this[a],"olddisplay",f)}}a=0;for(b=this.length;a=0;f--)if(d[f].elem===this){b&&d[f](true);d.splice(f,1)}});b||this.dequeue();return this}});c.each({slideDown:K("show",1),slideUp:K("hide",1),slideToggle:K("toggle",1),fadeIn:{opacity:"show"},fadeOut:{opacity:"hide"}},function(a,b){c.fn[a]=function(d,f){return this.animate(b,d,f)}});c.extend({speed:function(a,b,d){var f=a&&typeof a==="object"?a:{complete:d||!d&&b||c.isFunction(a)&&a,duration:a,easing:d&&b||b&&!c.isFunction(b)&&b};f.duration=c.fx.off?0:typeof f.duration=== -"number"?f.duration:c.fx.speeds[f.duration]||c.fx.speeds._default;f.old=f.complete;f.complete=function(){f.queue!==false&&c(this).dequeue();c.isFunction(f.old)&&f.old.call(this)};return f},easing:{linear:function(a,b,d,f){return d+f*a},swing:function(a,b,d,f){return(-Math.cos(a*Math.PI)/2+0.5)*f+d}},timers:[],fx:function(a,b,d){this.options=b;this.elem=a;this.prop=d;if(!b.orig)b.orig={}}});c.fx.prototype={update:function(){this.options.step&&this.options.step.call(this.elem,this.now,this);(c.fx.step[this.prop]|| -c.fx.step._default)(this);if((this.prop==="height"||this.prop==="width")&&this.elem.style)this.elem.style.display="block"},cur:function(a){if(this.elem[this.prop]!=null&&(!this.elem.style||this.elem.style[this.prop]==null))return this.elem[this.prop];return(a=parseFloat(c.css(this.elem,this.prop,a)))&&a>-10000?a:parseFloat(c.curCSS(this.elem,this.prop))||0},custom:function(a,b,d){function f(j){return e.step(j)}this.startTime=J();this.start=a;this.end=b;this.unit=d||this.unit||"px";this.now=this.start; -this.pos=this.state=0;var e=this;f.elem=this.elem;if(f()&&c.timers.push(f)&&!W)W=setInterval(c.fx.tick,13)},show:function(){this.options.orig[this.prop]=c.style(this.elem,this.prop);this.options.show=true;this.custom(this.prop==="width"||this.prop==="height"?1:0,this.cur());c(this.elem).show()},hide:function(){this.options.orig[this.prop]=c.style(this.elem,this.prop);this.options.hide=true;this.custom(this.cur(),0)},step:function(a){var b=J(),d=true;if(a||b>=this.options.duration+this.startTime){this.now= -this.end;this.pos=this.state=1;this.update();this.options.curAnim[this.prop]=true;for(var f in this.options.curAnim)if(this.options.curAnim[f]!==true)d=false;if(d){if(this.options.display!=null){this.elem.style.overflow=this.options.overflow;a=c.data(this.elem,"olddisplay");this.elem.style.display=a?a:this.options.display;if(c.css(this.elem,"display")==="none")this.elem.style.display="block"}this.options.hide&&c(this.elem).hide();if(this.options.hide||this.options.show)for(var e in this.options.curAnim)c.style(this.elem, -e,this.options.orig[e]);this.options.complete.call(this.elem)}return false}else{e=b-this.startTime;this.state=e/this.options.duration;a=this.options.easing||(c.easing.swing?"swing":"linear");this.pos=c.easing[this.options.specialEasing&&this.options.specialEasing[this.prop]||a](this.state,e,0,1,this.options.duration);this.now=this.start+(this.end-this.start)*this.pos;this.update()}return true}};c.extend(c.fx,{tick:function(){for(var a=c.timers,b=0;b
"; -a.insertBefore(b,a.firstChild);d=b.firstChild;f=d.firstChild;e=d.nextSibling.firstChild.firstChild;this.doesNotAddBorder=f.offsetTop!==5;this.doesAddBorderForTableAndCells=e.offsetTop===5;f.style.position="fixed";f.style.top="20px";this.supportsFixedPosition=f.offsetTop===20||f.offsetTop===15;f.style.position=f.style.top="";d.style.overflow="hidden";d.style.position="relative";this.subtractsBorderForOverflowNotVisible=f.offsetTop===-5;this.doesNotIncludeMarginInBodyOffset=a.offsetTop!==j;a.removeChild(b); -c.offset.initialize=c.noop},bodyOffset:function(a){var b=a.offsetTop,d=a.offsetLeft;c.offset.initialize();if(c.offset.doesNotIncludeMarginInBodyOffset){b+=parseFloat(c.curCSS(a,"marginTop",true))||0;d+=parseFloat(c.curCSS(a,"marginLeft",true))||0}return{top:b,left:d}},setOffset:function(a,b,d){if(/static/.test(c.curCSS(a,"position")))a.style.position="relative";var f=c(a),e=f.offset(),j=parseInt(c.curCSS(a,"top",true),10)||0,i=parseInt(c.curCSS(a,"left",true),10)||0;if(c.isFunction(b))b=b.call(a, -d,e);d={top:b.top-e.top+j,left:b.left-e.left+i};"using"in b?b.using.call(a,d):f.css(d)}};c.fn.extend({position:function(){if(!this[0])return null;var a=this[0],b=this.offsetParent(),d=this.offset(),f=/^body|html$/i.test(b[0].nodeName)?{top:0,left:0}:b.offset();d.top-=parseFloat(c.curCSS(a,"marginTop",true))||0;d.left-=parseFloat(c.curCSS(a,"marginLeft",true))||0;f.top+=parseFloat(c.curCSS(b[0],"borderTopWidth",true))||0;f.left+=parseFloat(c.curCSS(b[0],"borderLeftWidth",true))||0;return{top:d.top- -f.top,left:d.left-f.left}},offsetParent:function(){return this.map(function(){for(var a=this.offsetParent||s.body;a&&!/^body|html$/i.test(a.nodeName)&&c.css(a,"position")==="static";)a=a.offsetParent;return a})}});c.each(["Left","Top"],function(a,b){var d="scroll"+b;c.fn[d]=function(f){var e=this[0],j;if(!e)return null;if(f!==w)return this.each(function(){if(j=wa(this))j.scrollTo(!a?f:c(j).scrollLeft(),a?f:c(j).scrollTop());else this[d]=f});else return(j=wa(e))?"pageXOffset"in j?j[a?"pageYOffset": -"pageXOffset"]:c.support.boxModel&&j.document.documentElement[d]||j.document.body[d]:e[d]}});c.each(["Height","Width"],function(a,b){var d=b.toLowerCase();c.fn["inner"+b]=function(){return this[0]?c.css(this[0],d,false,"padding"):null};c.fn["outer"+b]=function(f){return this[0]?c.css(this[0],d,false,f?"margin":"border"):null};c.fn[d]=function(f){var e=this[0];if(!e)return f==null?null:this;if(c.isFunction(f))return this.each(function(j){var i=c(this);i[d](f.call(this,j,i[d]()))});return"scrollTo"in -e&&e.document?e.document.compatMode==="CSS1Compat"&&e.document.documentElement["client"+b]||e.document.body["client"+b]:e.nodeType===9?Math.max(e.documentElement["client"+b],e.body["scroll"+b],e.documentElement["scroll"+b],e.body["offset"+b],e.documentElement["offset"+b]):f===w?c.css(e,d):this.css(d,typeof f==="string"?f:f+"px")}});A.jQuery=A.$=c})(window); diff --git a/plone/app/discussion/tests/javascripts/test_comments.html b/plone/app/discussion/tests/javascripts/test_comments.html deleted file mode 100644 index be846e0..0000000 --- a/plone/app/discussion/tests/javascripts/test_comments.html +++ /dev/null @@ -1,42 +0,0 @@ - - - - - plone.app.discussion comments Test Suite - - - - - - - - - - - - - - - - - -

plone.app.discussion comments Test - Suite

- -

- -
-
- -

- -
    -
- - - - diff --git a/plone/app/discussion/tests/javascripts/test_comments.js b/plone/app/discussion/tests/javascripts/test_comments.js deleted file mode 100644 index 8bca11f..0000000 --- a/plone/app/discussion/tests/javascripts/test_comments.js +++ /dev/null @@ -1,146 +0,0 @@ - -$(document).ready(function () { - - /* TEST SETUP */ - - module("comments", { - setup: function () { - - // Create a comments section with one comment inside - // - //
- //
- //
- //

Lorem ipsum.

- //
- //
- // - //
- //
- //
- - var comments = $(document.createElement("div")) - .addClass("discussion") - .append($(document.createElement("div")) - .addClass("comment") - .attr("id", "1282720906349675") - .append($(document.createElement("div")) - .addClass("commentBody") - .append($(document.createElement("p")) - .text("Lorem ipsum.") - ) - ) - .append($(document.createElement("div")) - .addClass("commentActions") - .append($(document.createElement("button")) - .addClass("reply-to-comment-button") - )) - ); - $(document.body).append(comments); - - // Create a basic commenting form - // - // - //
- // - //
- //
- // - //
- //
- // - //
- //
- // - // - //
- // - - var commentform = $(document.createElement("div")) - .append($(document.createElement("form")) - .addClass("form") - .append($(document.createElement("div")) - .addClass("formfield-form-widgets-in_reply_to") - .append($(document.createElement("input")) - .attr("name", "form.widgets.in_reply_to") - .val("") - ) - ) - .append($(document.createElement("div")) - .addClass("formfield-form-widgets-author_name") - .append($(document.createElement("input")) - .attr("name", "form.widgets.author") - .attr("type", "text") - ) - ) - .append($(document.createElement("div")) - .addClass("formfield-form-widgets-text") - .append($(document.createElement("textarea")) - .attr("name", "form.widgets.text") - ) - ) - .append($(document.createElement("div")) - .addClass("formControls") - .append($(document.createElement("input")) - .attr("name", "form.buttons.comment")) - .append($(document.createElement("input")) - .attr("name", "form.buttons.cancel")) - ) - ) - .addClass("reply") - .attr("id", "commenting"); - $(document.body).append(commentform); - }, - teardown: function () { - $("#commenting").remove(); - $(".discussion").remove(); - } - }); - - - /* TESTS */ - - test("Hide the reply and the cancel button for the comment form", function(){ - expect(1); - $(".reply").find("input[name='form.buttons.cancel']").css("display", "none"); - equals($("input[name='form.buttons.cancel']").css("display"), "none", "The cancel button should be hidden"); - }); - - test("Show the reply button only when Javascript is enabled", function(){ - expect(1); - $(".reply-to-comment-button").css("display", "inline"); - equals($("button[class='reply-to-comment-button']").attr("style"), "display: inline;", "The reply button should show up when Javascript is enabled"); - }); - - test("Create a comment reply form.", function() { - expect(2); - var comment_div = $("#1282720906349675"); - var reply_button = comment_div.children(".reply-to-comment-button"); - $.createReplyForm(comment_div); - var reply_form = comment_div.children(".reply"); - ok(reply_form.find("input[name='form.widgets.in_reply_to']"), "Reply form has been copied"); - same(reply_form.find("input[name='form.widgets.in_reply_to']").val(), "1282720906349675", "The reply for should have the id of the comment in the in_reply_to field"); - }); - - test("Clear all form values from a form.", function() { - // Create a reply form with some values - var comment_div = $("#1282720906349675"); - $.createReplyForm(comment_div); - var reply_form = comment_div.find(".reply"); - var author = reply_form.find("input[name='form.widgets.author']"); - var text = comment_div.find("input[name='form.widgets.text']"); - author.val("my author"); - text.val("my text"); - // Call the clearForm function to clear the form - $.clearForm(comment_div); - // Check if all form fields have been cleared - var author = comment_div.find("input[name='form.widgets.author']"); - var text = comment_div.find("input[name='form.widgets.text']"); - equals(author.val(), "", "The author form value should be empty"); - equals(text.text(), "", "The text form value should be empty"); - - }); - -}); - - diff --git a/plone/app/discussion/tests/javascripts/test_moderation.html.txt b/plone/app/discussion/tests/javascripts/test_moderation.html.txt deleted file mode 100644 index 36afc81..0000000 --- a/plone/app/discussion/tests/javascripts/test_moderation.html.txt +++ /dev/null @@ -1,40 +0,0 @@ - - - - - plone.app.discussion moderation Test Suite - - - - - - - - - - - - - - - - - - -

plone.app.discussion moderation Test Suite

- -

- -
- -

- -
    - - - - diff --git a/plone/app/discussion/tests/javascripts/test_moderation.js.txt b/plone/app/discussion/tests/javascripts/test_moderation.js.txt deleted file mode 100644 index 49755d0..0000000 --- a/plone/app/discussion/tests/javascripts/test_moderation.js.txt +++ /dev/null @@ -1,72 +0,0 @@ -/* TEST SETUP */ - -module("comments", { - setup: function () { - //
    - // - // - // - // - // - // - // - //
    - // My comment - // - // - // - //
    - //
    - var review_table = $(document.createElement("form")) - .append($(document.createElement("table")) - .attr("id", "review-comments") - .append($(document.createElement("tbody")) - .append($(document.createElement("tr")) - .append($(document.createElement("td")) - .append($(document.createElement("a")) - .text("My comment.") - .attr("href", "http://localhost:8080/Plone/front-page/++conversation++default/1285339036601284") - ) - ) - .append($(document.createElement("td")) - .append($(document.createElement("input")) - .attr("id", "1285339036601284") - .attr("value", "Publish") - .attr("name", "form.button.Publish") - ) - .append($(document.createElement("input")) - .attr("id", "1285339036601284") - .attr("value", "Delete") - .attr("name", "form.button.Delete") - ) - .addClass("actions") - ) - ) - ) - ); - $(document.body).append(review_table); - }, - teardown: function () { - $("form").remove(); - } -}); - - -/* TESTS */ - -test("Delete a single comment", function(){ - expect(1); - stop(); - var delete_button = $(".actions").children("input[name='form.button.Delete']"); - delete_button.trigger('click'); - start(); - equals($("#1285339036601284").attr("name", "form.button.Delete").length, 0, "The comment row should have been deleted."); -}); - - -test("Publish a single comment", function(){ - expect(1); - var publish_button = $(".actions").children("input[name='form.button.Publish']"); - publish_button.trigger('click'); - equals($("#1285339036601284").attr("name", "form.button.Publish").length, 0, "The comment row should have been removed since the comment has been published."); -}); diff --git a/plone/app/discussion/tests/jsTestDriver.conf b/plone/app/discussion/tests/jsTestDriver.conf deleted file mode 100644 index c5335fb..0000000 --- a/plone/app/discussion/tests/jsTestDriver.conf +++ /dev/null @@ -1,18 +0,0 @@ -server: http://localhost:9876 - -load: - # Add these lines to load the equiv function and adapter in order, before the - # tests (assuming they are saved to tests/qunit/) - - qunit/equiv.js - - qunit/QUnitAdapter.js - - # This is where we load the qunit tests - - javascripts/*.js - - # And this loads the source files we are testing - - ../browser/javascripts/*.js - -plugin: - - name: "coverage" - jar: "../../../../parts/jstestdriver/coverage.jar" - module: "com.google.jstestdriver.coverage.CoverageModule" diff --git a/plone/app/discussion/tests/jsTestDriver.txt b/plone/app/discussion/tests/jsTestDriver.txt deleted file mode 100644 index 7751bb8..0000000 --- a/plone/app/discussion/tests/jsTestDriver.txt +++ /dev/null @@ -1,5 +0,0 @@ -============== -JS TEST DRIVER -============== - -java -jar JsTestDriver.jar --port 9876 --config jsTestDriver.conf --browser /usr/bin/firefox --tests all \ No newline at end of file diff --git a/plone/app/discussion/tests/qunit/QUnitAdapter.js b/plone/app/discussion/tests/qunit/QUnitAdapter.js deleted file mode 100644 index a3fc18c..0000000 --- a/plone/app/discussion/tests/qunit/QUnitAdapter.js +++ /dev/null @@ -1,85 +0,0 @@ -/* -QUnitAdapter -Version: 1.1.0 - -Run qunit tests using JS Test Driver - -This provides almost the same api as qunit. - -Tests must run sychronously, which means no use of stop and start methods. -You can use jsUnit Clock object to deal with timeouts and intervals: -http://googletesting.blogspot.com/2007/03/javascript-simulating-time-in-jsunit.html - -The qunit #main DOM element is not included. If you need to do any DOM manipulation -you need to set it up and tear it down in each test. - -*/ -(function() { - - if(!(window.equiv)) { - throw new Error("QUnitAdapter.js - Unable to find equiv function. Ensure you have added equiv.js to the load section of your jsTestDriver.conf"); - } - - var QUnitTestCase; - - window.module = function(name, lifecycle) { - QUnitTestCase = TestCase(name); - QUnitTestCase.prototype.lifecycle = lifecycle || {}; - }; - - window.test = function(name, expected, test) { - QUnitTestCase.prototype['test ' + name] = function() { - if(this.lifecycle.setup) { - this.lifecycle.setup(); - } - if(expected.constructor === Number) { - expectAsserts(expected); - } else { - test = expected; - } - test.call(this.lifecycle); - - if(this.lifecycle.teardown) { - this.lifecycle.teardown(); - } - }; - }; - - window.expect = function(count) { - expectAsserts(count); - }; - - window.ok = function(actual, msg) { - assertTrue(msg ? msg : '', !!actual); - }; - - window.equals = function(a, b, msg) { - assertEqual(msg ? msg : '', b, a); - }; - - window.start = window.stop = function() { - fail('start and stop methods are not available when using JS Test Driver.\n' + - 'Use jsUnit Clock object to deal with timeouts and intervals:\n' + - 'http://googletesting.blogspot.com/2007/03/javascript-simulating-time-in-jsunit.html.'); - }; - - window.same = function(a, b, msg) { - assertTrue(msg ? msg : '', window.equiv(b, a)); - }; - - window.reset = function() { - fail('reset method is not available when using JS Test Driver'); - }; - - window.isLocal = function() { - return false; - }; - - window.QUnit = { - equiv: window.equiv, - ok: window.ok - }; - - module('Default Module'); - -})(); diff --git a/plone/app/discussion/tests/qunit/equiv.js b/plone/app/discussion/tests/qunit/equiv.js deleted file mode 100644 index 2f5b9f3..0000000 --- a/plone/app/discussion/tests/qunit/equiv.js +++ /dev/null @@ -1,185 +0,0 @@ - -// Tests for equality any JavaScript type and structure without unexpected results. -// Discussions and reference: http://philrathe.com/articles/equiv -// Test suites: http://philrathe.com/tests/equiv -// Author: Philippe Rath -window.equiv = function () { - - var innerEquiv; // the real equiv function - var callers = []; // stack to decide between skip/abort functions - - // Determine what is o. - function hoozit(o) { - if (typeof o === "string") { - return "string"; - - } else if (typeof o === "boolean") { - return "boolean"; - - } else if (typeof o === "number") { - - if (isNaN(o)) { - return "nan"; - } else { - return "number"; - } - - } else if (typeof o === "undefined") { - return "undefined"; - - // consider: typeof null === object - } else if (o === null) { - return "null"; - - // consider: typeof [] === object - } else if (o instanceof Array) { - return "array"; - - // consider: typeof new Date() === object - } else if (o instanceof Date) { - return "date"; - - // consider: /./ instanceof Object; - // /./ instanceof RegExp; - // typeof /./ === "function"; // => false in IE and Opera, - // true in FF and Safari - } else if (o instanceof RegExp) { - return "regexp"; - - } else if (typeof o === "object") { - return "object"; - - } else if (o instanceof Function) { - return "function"; - } - } - - // Call the o related callback with the given arguments. - function bindCallbacks(o, callbacks, args) { - var prop = hoozit(o); - if (prop) { - if (hoozit(callbacks[prop]) === "function") { - return callbacks[prop].apply(callbacks, args); - } else { - return callbacks[prop]; // or undefined - } - } - } - - var callbacks = function () { - - // for string, boolean, number and null - function useStrictEquality(b, a) { - return a === b; - } - - return { - "string": useStrictEquality, - "boolean": useStrictEquality, - "number": useStrictEquality, - "null": useStrictEquality, - "undefined": useStrictEquality, - - "nan": function (b) { - return isNaN(b); - }, - - "date": function (b, a) { - return hoozit(b) === "date" && a.valueOf() === b.valueOf(); - }, - - "regexp": function (b, a) { - return hoozit(b) === "regexp" && - a.source === b.source && // the regex itself - a.global === b.global && // and its modifers (gmi) ... - a.ignoreCase === b.ignoreCase && - a.multiline === b.multiline; - }, - - // - skip when the property is a method of an instance (OOP) - // - abort otherwise, - // initial === would have catch identical references anyway - "function": function () { - var caller = callers[callers.length - 1]; - return caller !== Object && - typeof caller !== "undefined"; - }, - - "array": function (b, a) { - var i; - var len; - - // b could be an object literal here - if ( ! (hoozit(b) === "array")) { - return false; - } - - len = a.length; - if (len !== b.length) { // safe and faster - return false; - } - for (i = 0; i < len; i++) { - if( ! innerEquiv(a[i], b[i])) { - return false; - } - } - return true; - }, - - "object": function (b, a) { - var i; - var eq = true; // unless we can proove it - var aProperties = [], bProperties = []; // collection of strings - - // comparing constructors is more strict than using instanceof - if ( a.constructor !== b.constructor) { - return false; - } - - // stack constructor before traversing properties - callers.push(a.constructor); - - for (i in a) { // be strict: don't ensures hasOwnProperty and go deep - - aProperties.push(i); // collect a's properties - - if ( ! innerEquiv(a[i], b[i])) { - eq = false; - } - } - - callers.pop(); // unstack, we are done - - for (i in b) { - bProperties.push(i); // collect b's properties - } - - // Ensures identical properties name - return eq && innerEquiv(aProperties.sort(), bProperties.sort()); - } - }; - }(); - - innerEquiv = function () { // can take multiple arguments - var args = Array.prototype.slice.apply(arguments); - if (args.length < 2) { - return true; // end transition - } - - return (function (a, b) { - if (a === b) { - return true; // catch the most you can - - } else if (typeof a !== typeof b || a === null || b === null || typeof a === "undefined" || typeof b === "undefined") { - return false; // don't lose time with error prone cases - - } else { - return bindCallbacks(a, callbacks, [b, a]); - } - - // apply transition with (1..n) arguments - })(args[0], args[1]) && arguments.callee.apply(this, args.splice(1, args.length -1)); - }; - - return innerEquiv; -}(); // equiv diff --git a/plone/app/discussion/tests/qunit/qunit.css b/plone/app/discussion/tests/qunit/qunit.css deleted file mode 100644 index 4542933..0000000 --- a/plone/app/discussion/tests/qunit/qunit.css +++ /dev/null @@ -1,17 +0,0 @@ -h1#qunit-header { padding: 15px; font-size: large; background-color: #06b; color: white; font-family: 'trebuchet ms', verdana, arial; margin: 0; } -h1#qunit-header a { color: white; } - -h2#qunit-banner { height: 2em; border-bottom: 1px solid white; background-color: #eee; margin: 0; font-family: 'trebuchet ms', verdana, arial; } -h2#qunit-banner.pass { background-color: green; } -h2#qunit-banner.fail { background-color: red; } - -h2#qunit-userAgent { padding: 10px; background-color: #eee; color: black; margin: 0; font-size: small; font-weight: normal; font-family: 'trebuchet ms', verdana, arial; font-size: 10pt; } - -div#qunit-testrunner-toolbar { background: #eee; border-top: 1px solid black; padding: 10px; font-family: 'trebuchet ms', verdana, arial; margin: 0; font-size: 10pt; } - -ol#qunit-tests { font-family: 'trebuchet ms', verdana, arial; font-size: 10pt; } -ol#qunit-tests li strong { cursor:pointer; } -ol#qunit-tests .pass { color: green; } -ol#qunit-tests .fail { color: red; } - -p#qunit-testresult { margin-left: 1em; font-size: 10pt; font-family: 'trebuchet ms', verdana, arial; } diff --git a/plone/app/discussion/tests/qunit/qunit.js b/plone/app/discussion/tests/qunit/qunit.js deleted file mode 100644 index 51ec655..0000000 --- a/plone/app/discussion/tests/qunit/qunit.js +++ /dev/null @@ -1,997 +0,0 @@ -/* - * QUnit - A JavaScript Unit Testing Framework - * - * http://docs.jquery.com/QUnit - * - * Copyright (c) 2009 John Resig, Jörn Zaefferer - * Dual licensed under the MIT (MIT-LICENSE.txt) - * and GPL (GPL-LICENSE.txt) licenses. - */ - -(function(window) { - -var QUnit = { - - // Initialize the configuration options - init: function init() { - config = { - stats: { all: 0, bad: 0 }, - moduleStats: { all: 0, bad: 0 }, - started: +new Date, - blocking: false, - autorun: false, - assertions: [], - filters: [], - queue: [] - }; - - var tests = id("qunit-tests"), - banner = id("qunit-banner"), - result = id("qunit-testresult"); - - if ( tests ) { - tests.innerHTML = ""; - } - - if ( banner ) { - banner.className = ""; - } - - if ( result ) { - result.parentNode.removeChild( result ); - } - }, - - // call on start of module test to prepend name to all tests - module: function module(name, testEnvironment) { - - synchronize(function() { - if ( config.currentModule ) { - QUnit.moduleDone( config.currentModule, config.moduleStats.bad, config.moduleStats.all ); - } - - config.currentModule = name; - config.moduleTestEnvironment = testEnvironment; - config.moduleStats = { all: 0, bad: 0 }; - - QUnit.moduleStart( name, testEnvironment ); - }); - }, - - asyncTest: function asyncTest(testName, expected, callback) { - if ( arguments.length === 2 ) { - callback = expected; - expected = 0; - } - - QUnit.test(testName, expected, callback, true); - }, - - test: function test(testName, expected, callback, async) { - var name = testName, testEnvironment = {}; - - if ( arguments.length === 2 ) { - callback = expected; - expected = null; - } - - if ( config.currentModule ) { - name = config.currentModule + " module: " + name; - } - - if ( !validTest(name) ) { - return; - } - - synchronize(function() { - QUnit.testStart( testName ); - - testEnvironment = extend({ - setup: function() {}, - teardown: function() {} - }, config.moduleTestEnvironment); - - config.assertions = []; - config.expected = null; - - if ( arguments.length >= 3 ) { - config.expected = callback; - callback = arguments[2]; - } - - try { - if ( !config.pollution ) { - saveGlobal(); - } - - testEnvironment.setup.call(testEnvironment); - } catch(e) { - QUnit.ok( false, "Setup failed on " + name + ": " + e.message ); - } - - if ( async ) { - QUnit.stop(); - } - - try { - callback.call(testEnvironment); - } catch(e) { - fail("Test " + name + " died, exception and test follows", e, callback); - QUnit.ok( false, "Died on test #" + (config.assertions.length + 1) + ": " + e.message ); - // else next test will carry the responsibility - saveGlobal(); - - // Restart the tests if they're blocking - if ( config.blocking ) { - start(); - } - } - }); - - synchronize(function() { - try { - checkPollution(); - testEnvironment.teardown.call(testEnvironment); - } catch(e) { - QUnit.ok( false, "Teardown failed on " + name + ": " + e.message ); - } - - try { - QUnit.reset(); - } catch(e) { - fail("reset() failed, following Test " + name + ", exception and reset fn follows", e, reset); - } - - if ( config.expected && config.expected != config.assertions.length ) { - QUnit.ok( false, "Expected " + config.expected + " assertions, but " + config.assertions.length + " were run" ); - } - - var good = 0, bad = 0, - tests = id("qunit-tests"); - - config.stats.all += config.assertions.length; - config.moduleStats.all += config.assertions.length; - - if ( tests ) { - var ol = document.createElement("ol"); - ol.style.display = "none"; - - for ( var i = 0; i < config.assertions.length; i++ ) { - var assertion = config.assertions[i]; - - var li = document.createElement("li"); - li.className = assertion.result ? "pass" : "fail"; - li.innerHTML = assertion.message || "(no message)"; - ol.appendChild( li ); - - if ( assertion.result ) { - good++; - } else { - bad++; - config.stats.bad++; - config.moduleStats.bad++; - } - } - - var b = document.createElement("strong"); - b.innerHTML = name + " (" + bad + ", " + good + ", " + config.assertions.length + ")"; - - addEvent(b, "click", function() { - var next = b.nextSibling, display = next.style.display; - next.style.display = display === "none" ? "block" : "none"; - }); - - addEvent(b, "dblclick", function(e) { - var target = (e || window.event).target; - if ( target.nodeName.toLowerCase() === "strong" ) { - var text = "", node = target.firstChild; - - while ( node.nodeType === 3 ) { - text += node.nodeValue; - node = node.nextSibling; - } - - text = text.replace(/(^\s*|\s*$)/g, ""); - - if ( window.location ) { - window.location.href = window.location.href.match(/^(.+?)(\?.*)?$/)[1] + "?" + encodeURIComponent(text); - } - } - }); - - var li = document.createElement("li"); - li.className = bad ? "fail" : "pass"; - li.appendChild( b ); - li.appendChild( ol ); - tests.appendChild( li ); - - if ( bad ) { - var toolbar = id("qunit-testrunner-toolbar"); - if ( toolbar ) { - toolbar.style.display = "block"; - id("qunit-filter-pass").disabled = null; - id("qunit-filter-missing").disabled = null; - } - } - - } else { - for ( var i = 0; i < config.assertions.length; i++ ) { - if ( !config.assertions[i].result ) { - bad++; - config.stats.bad++; - config.moduleStats.bad++; - } - } - } - - QUnit.testDone( testName, bad, config.assertions.length ); - - if ( !window.setTimeout && !config.queue.length ) { - done(); - } - }); - - if ( window.setTimeout && !config.doneTimer ) { - config.doneTimer = window.setTimeout(function(){ - if ( !config.queue.length ) { - done(); - } else { - synchronize( done ); - } - }, 13); - } - }, - - /** - * Specify the number of expected assertions to gurantee that failed test (no assertions are run at all) don't slip through. - */ - expect: function expect(asserts) { - config.expected = asserts; - }, - - /** - * Asserts true. - * @example ok( "asdfasdf".length > 5, "There must be at least 5 chars" ); - */ - ok: function ok(a, msg) { - QUnit.log(a, msg); - - config.assertions.push({ - result: !!a, - message: msg - }); - }, - - /** - * Checks that the first two arguments are equal, with an optional message. - * Prints out both actual and expected values. - * - * Prefered to ok( actual == expected, message ) - * - * @example equals( format("Received {0} bytes.", 2), "Received 2 bytes." ); - * - * @param Object actual - * @param Object expected - * @param String message (optional) - */ - equals: function equals(actual, expected, message) { - push(expected == actual, actual, expected, message); - }, - - same: function(a, b, message) { - push(QUnit.equiv(a, b), a, b, message); - }, - - start: function start() { - // A slight delay, to avoid any current callbacks - if ( window.setTimeout ) { - window.setTimeout(function() { - if ( config.timeout ) { - clearTimeout(config.timeout); - } - - config.blocking = false; - process(); - }, 13); - } else { - config.blocking = false; - process(); - } - }, - - stop: function stop(timeout) { - config.blocking = true; - - if ( timeout && window.setTimeout ) { - config.timeout = window.setTimeout(function() { - QUnit.ok( false, "Test timed out" ); - QUnit.start(); - }, timeout); - } - }, - - /** - * Resets the test setup. Useful for tests that modify the DOM. - */ - reset: function reset() { - if ( window.jQuery ) { - jQuery("#main").html( config.fixture ); - jQuery.event.global = {}; - jQuery.ajaxSettings = extend({}, config.ajaxSettings); - } - }, - - /** - * Trigger an event on an element. - * - * @example triggerEvent( document.body, "click" ); - * - * @param DOMElement elem - * @param String type - */ - triggerEvent: function triggerEvent( elem, type, event ) { - if ( document.createEvent ) { - event = document.createEvent("MouseEvents"); - event.initMouseEvent(type, true, true, elem.ownerDocument.defaultView, - 0, 0, 0, 0, 0, false, false, false, false, 0, null); - elem.dispatchEvent( event ); - - } else if ( elem.fireEvent ) { - elem.fireEvent("on"+type); - } - }, - - // Logging callbacks - done: function done(failures, total) {}, - log: function log(result, message) {}, - testStart: function testStart(name) {}, - testDone: function testDone(name, failures, total) {}, - moduleStart: function moduleStart(name, testEnvironment) {}, - moduleDone: function moduleDone(name, failures, total) {} -}; - -// Maintain internal state -var config = { - // The queue of tests to run - queue: [], - - // block until document ready - blocking: true -}; - -// Load paramaters -(function() { - var location = window.location || { search: "", protocol: "file:" }, - GETParams = location.search.slice(1).split('&'); - - for ( var i = 0; i < GETParams.length; i++ ) { - GETParams[i] = decodeURIComponent( GETParams[i] ); - if ( GETParams[i] === "noglobals" ) { - GETParams.splice( i, 1 ); - i--; - config.noglobals = true; - } - } - - // restrict modules/tests by get parameters - config.filters = GETParams; - - // Figure out if we're running the tests from a server or not - QUnit.isLocal = !!(location.protocol === 'file:'); -})(); - -// Expose the API as global variables, unless an 'exports' -// object exists, in that case we assume we're in CommonJS -if ( typeof exports === "undefined" || typeof require === "undefined" ) { - extend(window, QUnit); - window.QUnit = QUnit; -} else { - extend(exports, QUnit); - exports.QUnit = QUnit; -} - -if ( typeof document === "undefined" || document.readyState === "complete" ) { - config.autorun = true; -} - -addEvent(window, "load", function() { - // Initialize the config, saving the execution queue - var oldconfig = extend({}, config); - QUnit.init(); - extend(config, oldconfig); - - config.blocking = false; - - var userAgent = id("qunit-userAgent"); - if ( userAgent ) { - userAgent.innerHTML = navigator.userAgent; - } - - var toolbar = id("qunit-testrunner-toolbar"); - if ( toolbar ) { - toolbar.style.display = "none"; - - var filter = document.createElement("input"); - filter.type = "checkbox"; - filter.id = "qunit-filter-pass"; - filter.disabled = true; - addEvent( filter, "click", function() { - var li = document.getElementsByTagName("li"); - for ( var i = 0; i < li.length; i++ ) { - if ( li[i].className.indexOf("pass") > -1 ) { - li[i].style.display = filter.checked ? "none" : "block"; - } - } - }); - toolbar.appendChild( filter ); - - var label = document.createElement("label"); - label.setAttribute("for", "filter-pass"); - label.innerHTML = "Hide passed tests"; - toolbar.appendChild( label ); - - var missing = document.createElement("input"); - missing.type = "checkbox"; - missing.id = "qunit-filter-missing"; - missing.disabled = true; - addEvent( missing, "click", function() { - var li = document.getElementsByTagName("li"); - for ( var i = 0; i < li.length; i++ ) { - if ( li[i].className.indexOf("fail") > -1 && li[i].innerHTML.indexOf('missing test - untested code is broken code') > - 1 ) { - li[i].parentNode.parentNode.style.display = missing.checked ? "none" : "block"; - } - } - }); - toolbar.appendChild( missing ); - - label = document.createElement("label"); - label.setAttribute("for", "filter-missing"); - label.innerHTML = "Hide missing tests (untested code is broken code)"; - toolbar.appendChild( label ); - } - - var main = id('main'); - if ( main ) { - config.fixture = main.innerHTML; - } - - if ( window.jQuery ) { - config.ajaxSettings = window.jQuery.ajaxSettings; - } - - QUnit.start(); -}); - -function done() { - if ( config.doneTimer && window.clearTimeout ) { - window.clearTimeout( config.doneTimer ); - config.doneTimer = null; - } - - if ( config.queue.length ) { - config.doneTimer = window.setTimeout(function(){ - if ( !config.queue.length ) { - done(); - } else { - synchronize( done ); - } - }, 13); - - return; - } - - config.autorun = true; - - // Log the last module results - if ( config.currentModule ) { - QUnit.moduleDone( config.currentModule, config.moduleStats.bad, config.moduleStats.all ); - } - - var banner = id("qunit-banner"), - tests = id("qunit-tests"), - html = ['Tests completed in ', - +new Date - config.started, ' milliseconds.
    ', - '', config.stats.all - config.stats.bad, ' tests of ', config.stats.all, ' passed, ', config.stats.bad,' failed.'].join(''); - - if ( banner ) { - banner.className += " " + (config.stats.bad ? "fail" : "pass"); - } - - if ( tests ) { - var result = id("qunit-testresult"); - - if ( !result ) { - result = document.createElement("p"); - result.id = "qunit-testresult"; - result.className = "result"; - tests.parentNode.insertBefore( result, tests.nextSibling ); - } - - result.innerHTML = html; - } - - QUnit.done( config.stats.bad, config.stats.all ); -} - -function validTest( name ) { - var i = config.filters.length, - run = false; - - if ( !i ) { - return true; - } - - while ( i-- ) { - var filter = config.filters[i], - not = filter.charAt(0) == '!'; - - if ( not ) { - filter = filter.slice(1); - } - - if ( name.indexOf(filter) !== -1 ) { - return !not; - } - - if ( not ) { - run = true; - } - } - - return run; -} - -function push(result, actual, expected, message) { - message = message || (result ? "okay" : "failed"); - QUnit.ok( result, result ? message + ": " + expected : message + ", expected: " + QUnit.jsDump.parse(expected) + " result: " + QUnit.jsDump.parse(actual) ); -} - -function synchronize( callback ) { - config.queue.push( callback ); - - if ( config.autorun && !config.blocking ) { - process(); - } -} - -function process() { - while ( config.queue.length && !config.blocking ) { - config.queue.shift()(); - } -} - -function saveGlobal() { - config.pollution = []; - - if ( config.noglobals ) { - for ( var key in window ) { - config.pollution.push( key ); - } - } -} - -function checkPollution( name ) { - var old = config.pollution; - saveGlobal(); - - var newGlobals = diff( old, config.pollution ); - if ( newGlobals.length > 0 ) { - ok( false, "Introduced global variable(s): " + newGlobals.join(", ") ); - config.expected++; - } - - var deletedGlobals = diff( config.pollution, old ); - if ( deletedGlobals.length > 0 ) { - ok( false, "Deleted global variable(s): " + deletedGlobals.join(", ") ); - config.expected++; - } -} - -// returns a new Array with the elements that are in a but not in b -function diff( a, b ) { - var result = a.slice(); - for ( var i = 0; i < result.length; i++ ) { - for ( var j = 0; j < b.length; j++ ) { - if ( result[i] === b[j] ) { - result.splice(i, 1); - i--; - break; - } - } - } - return result; -} - -function fail(message, exception, callback) { - if ( typeof console !== "undefined" && console.error && console.warn ) { - console.error(message); - console.error(exception); - console.warn(callback.toString()); - - } else if ( window.opera && opera.postError ) { - opera.postError(message, exception, callback.toString); - } -} - -function extend(a, b) { - for ( var prop in b ) { - a[prop] = b[prop]; - } - - return a; -} - -function addEvent(elem, type, fn) { - if ( elem.addEventListener ) { - elem.addEventListener( type, fn, false ); - } else if ( elem.attachEvent ) { - elem.attachEvent( "on" + type, fn ); - } else { - fn(); - } -} - -function id(name) { - return !!(typeof document !== "undefined" && document && document.getElementById) && - document.getElementById( name ); -} - -// Test for equality any JavaScript type. -// Discussions and reference: http://philrathe.com/articles/equiv -// Test suites: http://philrathe.com/tests/equiv -// Author: Philippe Rathé -QUnit.equiv = function () { - - var innerEquiv; // the real equiv function - var callers = []; // stack to decide between skip/abort functions - - - // Determine what is o. - function hoozit(o) { - if (o.constructor === String) { - return "string"; - - } else if (o.constructor === Boolean) { - return "boolean"; - - } else if (o.constructor === Number) { - - if (isNaN(o)) { - return "nan"; - } else { - return "number"; - } - - } else if (typeof o === "undefined") { - return "undefined"; - - // consider: typeof null === object - } else if (o === null) { - return "null"; - - // consider: typeof [] === object - } else if (o instanceof Array) { - return "array"; - - // consider: typeof new Date() === object - } else if (o instanceof Date) { - return "date"; - - // consider: /./ instanceof Object; - // /./ instanceof RegExp; - // typeof /./ === "function"; // => false in IE and Opera, - // true in FF and Safari - } else if (o instanceof RegExp) { - return "regexp"; - - } else if (typeof o === "object") { - return "object"; - - } else if (o instanceof Function) { - return "function"; - } else { - return undefined; - } - } - - // Call the o related callback with the given arguments. - function bindCallbacks(o, callbacks, args) { - var prop = hoozit(o); - if (prop) { - if (hoozit(callbacks[prop]) === "function") { - return callbacks[prop].apply(callbacks, args); - } else { - return callbacks[prop]; // or undefined - } - } - } - - var callbacks = function () { - - // for string, boolean, number and null - function useStrictEquality(b, a) { - if (b instanceof a.constructor || a instanceof b.constructor) { - // to catch short annotaion VS 'new' annotation of a declaration - // e.g. var i = 1; - // var j = new Number(1); - return a == b; - } else { - return a === b; - } - } - - return { - "string": useStrictEquality, - "boolean": useStrictEquality, - "number": useStrictEquality, - "null": useStrictEquality, - "undefined": useStrictEquality, - - "nan": function (b) { - return isNaN(b); - }, - - "date": function (b, a) { - return hoozit(b) === "date" && a.valueOf() === b.valueOf(); - }, - - "regexp": function (b, a) { - return hoozit(b) === "regexp" && - a.source === b.source && // the regex itself - a.global === b.global && // and its modifers (gmi) ... - a.ignoreCase === b.ignoreCase && - a.multiline === b.multiline; - }, - - // - skip when the property is a method of an instance (OOP) - // - abort otherwise, - // initial === would have catch identical references anyway - "function": function () { - var caller = callers[callers.length - 1]; - return caller !== Object && - typeof caller !== "undefined"; - }, - - "array": function (b, a) { - var i; - var len; - - // b could be an object literal here - if ( ! (hoozit(b) === "array")) { - return false; - } - - len = a.length; - if (len !== b.length) { // safe and faster - return false; - } - for (i = 0; i < len; i++) { - if ( ! innerEquiv(a[i], b[i])) { - return false; - } - } - return true; - }, - - "object": function (b, a) { - var i; - var eq = true; // unless we can proove it - var aProperties = [], bProperties = []; // collection of strings - - // comparing constructors is more strict than using instanceof - if ( a.constructor !== b.constructor) { - return false; - } - - // stack constructor before traversing properties - callers.push(a.constructor); - - for (i in a) { // be strict: don't ensures hasOwnProperty and go deep - - aProperties.push(i); // collect a's properties - - if ( ! innerEquiv(a[i], b[i])) { - eq = false; - } - } - - callers.pop(); // unstack, we are done - - for (i in b) { - bProperties.push(i); // collect b's properties - } - - // Ensures identical properties name - return eq && innerEquiv(aProperties.sort(), bProperties.sort()); - } - }; - }(); - - innerEquiv = function () { // can take multiple arguments - var args = Array.prototype.slice.apply(arguments); - if (args.length < 2) { - return true; // end transition - } - - return (function (a, b) { - if (a === b) { - return true; // catch the most you can - } else if (a === null || b === null || typeof a === "undefined" || typeof b === "undefined" || hoozit(a) !== hoozit(b)) { - return false; // don't lose time with error prone cases - } else { - return bindCallbacks(a, callbacks, [b, a]); - } - - // apply transition with (1..n) arguments - })(args[0], args[1]) && arguments.callee.apply(this, args.splice(1, args.length -1)); - }; - - return innerEquiv; - -}(); - -/** - * jsDump - * Copyright (c) 2008 Ariel Flesler - aflesler(at)gmail(dot)com | http://flesler.blogspot.com - * Licensed under BSD (http://www.opensource.org/licenses/bsd-license.php) - * Date: 5/15/2008 - * @projectDescription Advanced and extensible data dumping for Javascript. - * @version 1.0.0 - * @author Ariel Flesler - * @link {http://flesler.blogspot.com/2008/05/jsdump-pretty-dump-of-any-javascript.html} - */ -QUnit.jsDump = (function() { - function quote( str ) { - return '"' + str.toString().replace(/"/g, '\\"') + '"'; - }; - function literal( o ) { - return o + ''; - }; - function join( pre, arr, post ) { - var s = jsDump.separator(), - base = jsDump.indent(), - inner = jsDump.indent(1); - if ( arr.join ) - arr = arr.join( ',' + s + inner ); - if ( !arr ) - return pre + post; - return [ pre, inner + arr, base + post ].join(s); - }; - function array( arr ) { - var i = arr.length, ret = Array(i); - this.up(); - while ( i-- ) - ret[i] = this.parse( arr[i] ); - this.down(); - return join( '[', ret, ']' ); - }; - - var reName = /^function (\w+)/; - - var jsDump = { - parse:function( obj, type ) { //type is used mostly internally, you can fix a (custom)type in advance - var parser = this.parsers[ type || this.typeOf(obj) ]; - type = typeof parser; - - return type == 'function' ? parser.call( this, obj ) : - type == 'string' ? parser : - this.parsers.error; - }, - typeOf:function( obj ) { - var type = typeof obj, - f = 'function';//we'll use it 3 times, save it - return type != 'object' && type != f ? type : - !obj ? 'null' : - obj.exec ? 'regexp' :// some browsers (FF) consider regexps functions - obj.getHours ? 'date' : - obj.scrollBy ? 'window' : - obj.nodeName == '#document' ? 'document' : - obj.nodeName ? 'node' : - obj.item ? 'nodelist' : // Safari reports nodelists as functions - obj.callee ? 'arguments' : - obj.call || obj.constructor != Array && //an array would also fall on this hack - (obj+'').indexOf(f) != -1 ? f : //IE reports functions like alert, as objects - 'length' in obj ? 'array' : - type; - }, - separator:function() { - return this.multiline ? this.HTML ? '
    ' : '\n' : this.HTML ? ' ' : ' '; - }, - indent:function( extra ) {// extra can be a number, shortcut for increasing-calling-decreasing - if ( !this.multiline ) - return ''; - var chr = this.indentChar; - if ( this.HTML ) - chr = chr.replace(/\t/g,' ').replace(/ /g,' '); - return Array( this._depth_ + (extra||0) ).join(chr); - }, - up:function( a ) { - this._depth_ += a || 1; - }, - down:function( a ) { - this._depth_ -= a || 1; - }, - setParser:function( name, parser ) { - this.parsers[name] = parser; - }, - // The next 3 are exposed so you can use them - quote:quote, - literal:literal, - join:join, - // - _depth_: 1, - // This is the list of parsers, to modify them, use jsDump.setParser - parsers:{ - window: '[Window]', - document: '[Document]', - error:'[ERROR]', //when no parser is found, shouldn't happen - unknown: '[Unknown]', - 'null':'null', - undefined:'undefined', - 'function':function( fn ) { - var ret = 'function', - name = 'name' in fn ? fn.name : (reName.exec(fn)||[])[1];//functions never have name in IE - if ( name ) - ret += ' ' + name; - ret += '('; - - ret = [ ret, this.parse( fn, 'functionArgs' ), '){'].join(''); - return join( ret, this.parse(fn,'functionCode'), '}' ); - }, - array: array, - nodelist: array, - arguments: array, - object:function( map ) { - var ret = [ ]; - this.up(); - for ( var key in map ) - ret.push( this.parse(key,'key') + ': ' + this.parse(map[key]) ); - this.down(); - return join( '{', ret, '}' ); - }, - node:function( node ) { - var open = this.HTML ? '<' : '<', - close = this.HTML ? '>' : '>'; - - var tag = node.nodeName.toLowerCase(), - ret = open + tag; - - for ( var a in this.DOMAttrs ) { - var val = node[this.DOMAttrs[a]]; - if ( val ) - ret += ' ' + a + '=' + this.parse( val, 'attribute' ); - } - return ret + close + open + '/' + tag + close; - }, - functionArgs:function( fn ) {//function calls it internally, it's the arguments part of the function - var l = fn.length; - if ( !l ) return ''; - - var args = Array(l); - while ( l-- ) - args[l] = String.fromCharCode(97+l);//97 is 'a' - return ' ' + args.join(', ') + ' '; - }, - key:quote, //object calls it internally, the key part of an item in a map - functionCode:'[code]', //function calls it internally, it's the content of the function - attribute:quote, //node calls it internally, it's an html attribute value - string:quote, - date:quote, - regexp:literal, //regex - number:literal, - 'boolean':literal - }, - DOMAttrs:{//attributes to dump from nodes, name=>realName - id:'id', - name:'name', - 'class':'className' - }, - HTML:true,//if true, entities are escaped ( <, >, \t, space and \n ) - indentChar:' ',//indentation unit - multiline:true //if true, items in a collection, are separated by a \n, else just a space. - }; - - return jsDump; -})(); - -})(this); diff --git a/plone/app/discussion/tests/test_catalog.py b/plone/app/discussion/tests/test_catalog.py index 5cb26a7..cbff0cd 100644 --- a/plone/app/discussion/tests/test_catalog.py +++ b/plone/app/discussion/tests/test_catalog.py @@ -19,8 +19,6 @@ from plone.app.discussion.testing import ( from plone.app.discussion.interfaces import IConversation -from plone.app.discussion.testing import COLLECTION_TYPE - class CatalogSetupTest(unittest.TestCase): @@ -48,6 +46,8 @@ class CatalogSetupTest(unittest.TestCase): ) def test_collection_criteria_installed(self): + if 'portal_atct' not in self.portal: + return try: self.portal.portal_atct.getIndex('commentators') self.portal.portal_atct.getIndex('total_comments') @@ -459,6 +459,9 @@ class CommentCatalogTest(unittest.TestCase): ) def test_clear_and_rebuild_catalog(self): + brains = self.catalog.searchResults({'portal_type': 'Discussion Item'}) + self.assertTrue(brains) + # Clear and rebuild catalog self.catalog.clearFindAndRebuild() @@ -529,31 +532,6 @@ class CommentCatalogTest(unittest.TestCase): self.assertTrue(brains) self.assertEqual(len(brains), 6) - def test_collection(self): - if COLLECTION_TYPE == "Topic": - self.portal.invokeFactory('Topic', id='topic') - topic = self.portal.topic - crit = topic.addCriterion('Type', 'ATSimpleStringCriterion') - crit.setValue('Comment') - query = topic.buildQuery() - - self.assertEqual(len(query), 1) - self.assertEqual(query['Type'], 'Comment') - self.assertEqual(len(topic.queryCatalog()), 1) - else: - self.portal.invokeFactory('Collection', id='collection') - collection = self.portal.collection - collection.query = [{ - 'i': 'Type', - 'o': 'plone.app.querystring.operation.string.is', - 'v': 'Comment', - }] - - self.assertEqual(collection.results().length, 1) - self.assertEqual(collection.results()[0].text, 'Comment text') - self.assertEqual(collection.results()[0].creator, 'jim') - self.assertEqual(collection.results()[0].author_name, 'Jim') - class NoConversationCatalogTest(unittest.TestCase): @@ -591,6 +569,3 @@ class NoConversationCatalogTest(unittest.TestCase): IAnnotations(self.portal.doc1) ) - -def test_suite(): - return unittest.defaultTestLoader.loadTestsFromName(__name__) diff --git a/plone/app/discussion/tests/test_comment.py b/plone/app/discussion/tests/test_comment.py index 09bdea2..88abff3 100644 --- a/plone/app/discussion/tests/test_comment.py +++ b/plone/app/discussion/tests/test_comment.py @@ -509,6 +509,3 @@ class RepliesTest(unittest.TestCase): re_re_re_comment.absolute_url() ) - -def test_suite(): - return unittest.defaultTestLoader.loadTestsFromName(__name__) diff --git a/plone/app/discussion/tests/test_comments_viewlet.py b/plone/app/discussion/tests/test_comments_viewlet.py index 7ed9da4..a12d909 100644 --- a/plone/app/discussion/tests/test_comments_viewlet.py +++ b/plone/app/discussion/tests/test_comments_viewlet.py @@ -63,12 +63,7 @@ class TestCommentForm(unittest.TestCase): typetool.constructContent('Document', self.portal, 'doc1') wftool = getToolByName(self.portal, "portal_workflow") wftool.doActionFor(self.portal.doc1, action='publish') - self.discussionTool = getToolByName( - self.portal, - 'portal_discussion', - None - ) - self.discussionTool.overrideDiscussionFor(self.portal.doc1, False) + self.portal.doc1.allow_discussion = True self.membershipTool = getToolByName(self.folder, 'portal_membership') self.memberdata = self.portal.portal_memberdata self.context = getattr(self.portal, 'doc1') @@ -83,7 +78,7 @@ class TestCommentForm(unittest.TestCase): """ # Allow discussion - self.discussionTool.overrideDiscussionFor(self.portal.doc1, True) + self.portal.doc1.allow_discussion = True self.viewlet = CommentsViewlet(self.context, self.request, None, None) def make_request(form={}): @@ -186,8 +181,23 @@ class TestCommentForm(unittest.TestCase): comment = [x for x in conversation.getComments()][-1] self.assertEquals(comment.text, u"foobar") + comments = IConversation(commentForm.context).getComments() + comments = [comment for comment in comments] # consume itertor + self.assertEqual(len(comments), 1) + + for comment in comments: + self.assertEqual(comment.text, u"bar") + self.assertEqual(comment.creator, "test-user") + self.assertEqual(comment.getOwner().getUserName(), "test-user") + local_roles = comment.get_local_roles() + self.assertEqual(len(local_roles), 1) + userid, roles = local_roles[0] + self.assertEqual(userid, 'test-user') + self.assertEqual(len(roles), 1) + self.assertEqual(roles[0], 'Owner') + def test_add_anonymous_comment(self): - self.discussionTool.overrideDiscussionFor(self.portal.doc1, True) + self.portal.doc1.allow_discussion = True self.viewlet = CommentsViewlet(self.context, self.request, None, None) @@ -226,11 +236,24 @@ class TestCommentForm(unittest.TestCase): self.assertEqual(len(errors), 0) self.assertFalse(commentForm.handleComment(commentForm, "action")) + comments = IConversation(commentForm.context).getComments() + comments = [comment for comment in comments] # consume itertor + self.assertEqual(len(comments), 1) + + for comment in IConversation(commentForm.context).getComments(): + self.assertEqual(comment.text, u"bar") + self.assertIsNone(comment.creator) + roles = comment.get_local_roles() + self.assertEqual(len(roles), 0) + def test_can_not_add_comments_if_discussion_is_not_allowed(self): """Make sure that comments can't be posted if discussion is disabled. """ - # Discussion is disabled by default + # Disable discussion + registry = queryUtility(IRegistry) + settings = registry.forInterface(IDiscussionSettings) + settings.globally_enabled = False def make_request(form={}): request = TestRequest() @@ -256,6 +279,7 @@ class TestCommentForm(unittest.TestCase): # No form errors, but raise unauthorized because discussion is not # allowed self.assertEqual(len(errors), 0) + self.assertRaises(Unauthorized, commentForm.handleComment, commentForm, @@ -318,11 +342,6 @@ class TestCommentsViewlet(unittest.TestCase): typetool = self.portal.portal_types typetool.constructContent('Document', self.portal, 'doc1') - self.portal_discussion = getToolByName( - self.portal, - 'portal_discussion', - None - ) self.membershipTool = getToolByName(self.folder, 'portal_membership') self.memberdata = self.portal.portal_memberdata context = getattr(self.portal, 'doc1') @@ -372,8 +391,7 @@ class TestCommentsViewlet(unittest.TestCase): # By default, discussion is disabled self.assertFalse(self.viewlet.is_discussion_allowed()) # Enable discussion - portal_discussion = getToolByName(self.portal, 'portal_discussion') - portal_discussion.overrideDiscussionFor(self.portal.doc1, True) + self.portal.doc1.allow_discussion = True # Test if discussion has been enabled self.assertTrue(self.viewlet.is_discussion_allowed()) @@ -520,11 +538,11 @@ class TestCommentsViewlet(unittest.TestCase): ) def test_get_commenter_portrait_is_none(self): - self.assertTrue( - self.viewlet.get_commenter_portrait() in ( - 'defaultUser.png', - 'defaultUser.gif', - ) + + self.assertEqual( + self.viewlet.get_commenter_portrait(), + 'defaultUser.png' + ) def test_get_commenter_portrait_without_userimage(self): @@ -604,6 +622,3 @@ class TestCommentsViewlet(unittest.TestCase): self.assertTrue( localized_time in ['Feb 01, 2009 11:32 PM', '2009-02-01 23:32']) - -def test_suite(): - return unittest.defaultTestLoader.loadTestsFromName(__name__) diff --git a/plone/app/discussion/tests/test_controlpanel.py b/plone/app/discussion/tests/test_controlpanel.py index ca56c0b..1fe8115 100644 --- a/plone/app/discussion/tests/test_controlpanel.py +++ b/plone/app/discussion/tests/test_controlpanel.py @@ -230,6 +230,3 @@ class ConfigurationChangedSubscriberTest(unittest.TestCase): # setting itself remains unchanged. self.settings.moderation_enabled = True - -def test_suite(): - return unittest.defaultTestLoader.loadTestsFromName(__name__) diff --git a/plone/app/discussion/tests/test_conversation.py b/plone/app/discussion/tests/test_conversation.py index 59bca9d..29976e8 100644 --- a/plone/app/discussion/tests/test_conversation.py +++ b/plone/app/discussion/tests/test_conversation.py @@ -91,10 +91,10 @@ class ConversationTest(unittest.TestCase): comment.author_username = "nobody" conversation.addComment(comment) comment.manage_permission("View", roles=tuple()) - self.assertEquals(0, conversation.total_comments) - self.assertEquals(None, conversation.last_comment_date) - self.assertEquals(["nobody"], list(conversation.commentators)) - self.assertEquals([], list(conversation.public_commentators)) + self.assertEqual(0, conversation.total_comments) + self.assertEqual(None, conversation.last_comment_date) + self.assertEqual(["nobody"], list(conversation.commentators)) + self.assertEqual([], list(conversation.public_commentators)) def test_delete_comment(self): # Create a conversation. In this case we doesn't assign it to an @@ -308,12 +308,12 @@ class ConversationTest(unittest.TestCase): # Create a folder self.typetool.constructContent('Folder', self.portal, 'f1') - f1 = self.portal.f1 + # Usually we don't create a conversation on a folder conversation = self.portal.f1.restrictedTraverse('@@conversation_view') # Allow discussion for the folder - self.portal_discussion.overrideDiscussionFor(f1, True) + self.portal.f1.allow_discussion = True # Allow discussion on Folder content type portal_types = getToolByName(self.portal, 'portal_types') @@ -334,12 +334,12 @@ class ConversationTest(unittest.TestCase): self.assertEqual(conversation.enabled(), False) # Allow discussion on content object - self.portal_discussion.overrideDiscussionFor(self.portal.doc1, True) + self.portal.doc1.allow_discussion = True # Check if discussion is now allowed on the content object self.assertEqual(conversation.enabled(), True) - self.portal_discussion.overrideDiscussionFor(self.portal.doc1, False) + self.portal.doc1.allow_discussion = False self.assertEqual(conversation.enabled(), False) def test_dict_operations(self): @@ -874,6 +874,3 @@ class RepliesTest(unittest.TestCase): self.assertEqual(len(replies_to_comment1_1), 1) self.assertEqual(len(replies_to_comment2), 1) - -def test_suite(): - return unittest.defaultTestLoader.loadTestsFromName(__name__) diff --git a/plone/app/discussion/tests/test_indexers.py b/plone/app/discussion/tests/test_indexers.py index 0701e27..2d4e6da 100644 --- a/plone/app/discussion/tests/test_indexers.py +++ b/plone/app/discussion/tests/test_indexers.py @@ -204,6 +204,3 @@ class CommentIndexersTest(unittest.TestCase): # object the comment was added to self.assertEqual(catalog.in_response_to(self.comment)(), 'Document 1') - -def test_suite(): - return unittest.defaultTestLoader.loadTestsFromName(__name__) diff --git a/plone/app/discussion/tests/test_migration.py b/plone/app/discussion/tests/test_migration.py deleted file mode 100644 index eec1215..0000000 --- a/plone/app/discussion/tests/test_migration.py +++ /dev/null @@ -1,328 +0,0 @@ -from datetime import datetime -from DateTime import DateTime - -import unittest2 as unittest - -from zope.annotation.interfaces import IAnnotations - -from Products.CMFCore.utils import getToolByName - -from plone.app.testing import TEST_USER_ID, setRoles - -from plone.app.discussion.testing import ( - PLONE_APP_DISCUSSION_INTEGRATION_TESTING -) - -from plone.app.discussion.browser.migration import View - -from plone.app.discussion.interfaces import IConversation, IComment - - -class MigrationTest(unittest.TestCase): - - layer = PLONE_APP_DISCUSSION_INTEGRATION_TESTING - - def _publish(self, reply): - # publish the reply - status = self.portal.portal_workflow.getStatusOf( - 'comment_review_workflow', reply - ).copy() - status['review_state'] = 'published' - self.portal.portal_workflow.setStatusOf( - 'comment_review_workflow', - reply, - status, - ) - - def setUp(self): - self.portal = self.layer['portal'] - self.request = self.layer['request'] - setRoles(self.portal, TEST_USER_ID, ['Manager']) - - self.portal.invokeFactory( - id='doc', - title='Document 1', - type_name='Document' - ) - # Create a document - self.discussion = getToolByName(self.portal, 'portal_discussion', None) - self.discussion.overrideDiscussionFor(self.portal.doc, 1) - # Publish it - self.workflowTool = getToolByName(self.portal, 'portal_workflow') - self.workflowTool.setDefaultChain('simple_publication_workflow') - self.workflowTool.doActionFor(self.portal.doc, 'publish') - - self.request.set("test", True) - self.view = View(self.portal, self.request) - self.workflowTool.setChainForPortalTypes( - ('Discussion Item',), - 'comment_review_workflow' - ) - - # Create a user Jimmy Jones so comments creator migration can work? - acl_users = getToolByName(self.portal, 'acl_users') - acl_users.userFolderAddUser('Jim', 'secret', ['Member'], []) - mt = getToolByName(self.portal, 'portal_membership') - member = mt.getMemberById('Jim') - member.fullname = 'Jimmy Jones' - - self.doc = self.portal.doc - - def test_migrate_comment(self): - - # Create a comment - talkback = self.discussion.getDiscussionFor(self.doc) - self.doc.talkback.createReply('My Title', 'My Text', Creator='Jim') - reply = talkback.getReplies()[0] - reply.setReplyTo(self.doc) - reply.creation_date = DateTime(2003, 3, 11, 9, 28, 6, 'GMT') - reply.modification_date = DateTime(2009, 7, 12, 19, 38, 7, 'GMT') - - self._publish(reply) - self.assertEqual(reply.Title(), 'My Title') - self.assertEqual(reply.EditableBody(), 'My Text') - self.assertTrue('Jim' in reply.listCreators()) - self.assertEqual(talkback.replyCount(self.doc), 1) - self.assertEqual(reply.inReplyTo(), self.doc) - - # Call migration script - self.view() - - # Make sure a conversation has been created - self.assertTrue( - 'plone.app.discussion:conversation' in IAnnotations(self.doc) - ) - conversation = IConversation(self.doc) - - # Check migration - self.assertEqual(conversation.total_comments, 1) - self.assertTrue(conversation.getComments().next()) - comment1 = conversation.values()[0] - self.assertTrue(IComment.providedBy(comment1)) - self.assertEqual(comment1.Title(), 'My Title') - self.assertEqual(comment1.text, '

    My Text

    \n') - self.assertEqual(comment1.mime_type, 'text/html') - self.assertEqual(comment1.Creator(), 'Jim') - self.assertEqual( - comment1.creation_date, - datetime(2003, 3, 11, 9, 28, 6) - ) - self.assertEqual( - comment1.modification_date, - datetime(2009, 7, 12, 19, 38, 7) - ) - self.assertEqual([ - {'comment': comment1, 'depth': 0, 'id': long(comment1.id)} - ], list(conversation.getThreads())) - self.assertFalse(self.doc.talkback) - - def test_migrate_comment_with_creator(self): - # Create a comment - talkback = self.discussion.getDiscussionFor(self.doc) - self.doc.talkback.createReply('My Title', 'My Text', Creator='Jim') - reply = talkback.getReplies()[0] - reply.setReplyTo(self.doc) - reply.creation_date = DateTime(2003, 3, 11, 9, 28, 6, 'GMT') - reply.modification_date = DateTime(2009, 7, 12, 19, 38, 7, 'GMT') - reply.author_username = 'Jim' - reply.email = 'jimmy@jones.xyz' - - self._publish(reply) - self.assertEqual(reply.Title(), 'My Title') - self.assertEqual(reply.EditableBody(), 'My Text') - self.assertTrue('Jim' in reply.listCreators()) - self.assertEqual(talkback.replyCount(self.doc), 1) - self.assertEqual(reply.inReplyTo(), self.doc) - self.assertEqual(reply.author_username, 'Jim') - self.assertEqual(reply.email, 'jimmy@jones.xyz') - - # Call migration script - self.view() - - # Make sure a conversation has been created - self.assertTrue( - 'plone.app.discussion:conversation' in IAnnotations(self.doc) - ) - conversation = IConversation(self.doc) - - # Check migration - self.assertEqual(conversation.total_comments, 1) - self.assertTrue(conversation.getComments().next()) - comment1 = conversation.values()[0] - self.assertTrue(IComment.providedBy(comment1)) - self.assertEqual(comment1.Title(), 'My Title') - self.assertEqual(comment1.text, '

    My Text

    \n') - self.assertEqual(comment1.mime_type, 'text/html') - self.assertEqual(comment1.Creator(), 'Jim') - self.assertEqual( - comment1.creation_date, - datetime(2003, 3, 11, 9, 28, 6) - ) - self.assertEqual( - comment1.modification_date, - datetime(2009, 7, 12, 19, 38, 7) - ) - self.assertEqual([ - {'comment': comment1, 'depth': 0, 'id': long(comment1.id)} - ], list(conversation.getThreads())) - self.assertFalse(self.doc.talkback) - - # Though this should be Jimmy, but looks like getProperty won't pick - # up 'author_username' (reply.author_username is not None), so it's - # propagating Creator()..? - self.assertEqual(comment1.author_username, 'Jim') - - self.assertEqual(comment1.author_name, 'Jimmy Jones') - self.assertEqual(comment1.author_email, 'jimmy@jones.xyz') - - def test_migrate_nested_comments(self): - # Create some nested comments and migrate them - # - # self.doc - # +- First comment - # +- Re: First comment - # + Re: Re: First comment - # + Re: Re: Re: First comment - # +- Re: First comment (2) - # +- Re: First comment (3) - # +- Re: First comment (4) - # +- Second comment - - talkback = self.discussion.getDiscussionFor(self.doc) - - # First comment - talkback.createReply(title='First comment', - text='This is my first comment.') - comment1 = talkback.getReplies()[0] - self._publish(comment1) - - talkback_comment1 = self.discussion.getDiscussionFor(comment1) - - # Re: First comment - talkback_comment1.createReply(title='Re: First comment', - text='This is my first reply.') - comment1_1 = talkback_comment1.getReplies()[0] - self._publish(comment1_1) - - talkback_comment1_1 = self.discussion.getDiscussionFor(comment1_1) - - self.assertEqual(len(talkback.getReplies()), 1) - self.assertEqual(len(talkback_comment1.getReplies()), 1) - self.assertEqual(len(talkback_comment1_1.getReplies()), 0) - - #Re: Re: First comment - talkback_comment1_1.createReply(title='Re: Re: First comment', - text='This is my first re-reply.') - comment1_1_1 = talkback_comment1_1.getReplies()[0] - self._publish(comment1_1_1) - - talkback_comment1_1_1 = self.discussion.getDiscussionFor(comment1_1_1) - - # Re: Re: Re: First comment - talkback_comment1_1_1.createReply(title='Re: Re: Re: First comment', - text='This is my first re-re-reply.') - self._publish(talkback_comment1_1_1.getReplies()[0]) - - # Re: First comment (2) - talkback_comment1.createReply(title='Re: First comment (2)', - text='This is my first reply (2).') - self._publish(talkback_comment1.getReplies()[1]) - - # Re: First comment (3) - talkback_comment1.createReply(title='Re: First comment (3)', - text='This is my first reply (3).') - self._publish(talkback_comment1.getReplies()[2]) - - # Re: First comment (4) - talkback_comment1.createReply(title='Re: First comment (4)', - text='This is my first reply (4).') - self._publish(talkback_comment1.getReplies()[3]) - - # Second comment - talkback.createReply(title='Second comment', - text='This is my second comment.') - self._publish(talkback.getReplies()[1]) - - # Call migration script - self.view() - - # Check migration - conversation = IConversation(self.doc) - self.assertEqual(conversation.total_comments, 8) - - comment1 = conversation.values()[0] - comment1_1 = conversation.values()[1] - comment1_1_1 = conversation.values()[2] - comment1_1_1_1 = conversation.values()[3] - comment1_2 = conversation.values()[4] - comment1_3 = conversation.values()[5] - comment1_4 = conversation.values()[6] - comment2 = conversation.values()[7] - - self.assertEqual([ - {'comment': comment1, 'depth': 0, 'id': long(comment1.id)}, - {'comment': comment1_1, 'depth': 1, 'id': long(comment1_1.id)}, - {'comment': comment1_1_1, 'depth': 2, 'id': long(comment1_1_1.id)}, - {'comment': comment1_1_1_1, 'depth': 3, - 'id': long(comment1_1_1_1.id)}, - {'comment': comment1_2, 'depth': 1, 'id': long(comment1_2.id)}, - {'comment': comment1_3, 'depth': 1, 'id': long(comment1_3.id)}, - {'comment': comment1_4, 'depth': 1, 'id': long(comment1_4.id)}, - {'comment': comment2, 'depth': 0, 'id': long(comment2.id)}, - ], list(conversation.getThreads())) - - talkback = self.discussion.getDiscussionFor(self.doc) - self.assertEqual(len(talkback.getReplies()), 0) - - def test_migrate_nested_comments_with_filter(self): - # Create some nested comments and migrate them. - # But use a filter that filters the top-level comment. - # All the comments should be removed, but not migrated. - # - # self.doc - # +- First comment - # +- Re: First comment - - talkback = self.discussion.getDiscussionFor(self.doc) - - # First comment - talkback.createReply(title='First comment', - text='This is my first comment.') - comment1 = talkback.getReplies()[0] - talkback_comment1 = self.discussion.getDiscussionFor(comment1) - - # Re: First comment - talkback_comment1.createReply(title='Re: First comment', - text='This is my first reply.') - comment1_1 = talkback_comment1.getReplies()[0] - talkback_comment1_1 = self.discussion.getDiscussionFor(comment1_1) - - self.assertEqual(len(talkback.getReplies()), 1) - self.assertEqual(len(talkback_comment1.getReplies()), 1) - self.assertEqual(len(talkback_comment1_1.getReplies()), 0) - - def deny_comments(reply): - return False - - # Call migration script - self.view(filter_callback=deny_comments) - - # Check migration - conversation = IConversation(self.doc) - self.assertEqual(conversation.total_comments, 0) - talkback = self.discussion.getDiscussionFor(self.doc) - self.assertEqual(len(talkback.getReplies()), 0) - - def test_migrate_no_comment(self): - - # Call migration script - self.view() - - # Make sure no conversation has been created - self.assertTrue( - 'plone.app.discussion:conversation' not in IAnnotations(self.doc) - ) - - -def test_suite(): - return unittest.defaultTestLoader.loadTestsFromName(__name__) diff --git a/plone/app/discussion/tests/test_moderation_view.py b/plone/app/discussion/tests/test_moderation_view.py index ac0a7bd..1c9cf18 100644 --- a/plone/app/discussion/tests/test_moderation_view.py +++ b/plone/app/discussion/tests/test_moderation_view.py @@ -1,8 +1,6 @@ # -*- coding: utf-8 -*- import unittest -from DateTime import DateTime - from zope.component import createObject from Products.CMFCore.utils import getToolByName @@ -58,29 +56,6 @@ class ModerationViewTest(unittest.TestCase): ('comment_review_workflow,')) self.assertEqual(self.view.moderation_enabled(), True) - def test_old_comments_not_shown_in_moderation_view(self): - # Create old comment - discussion = getToolByName(self.portal, 'portal_discussion', None) - discussion.overrideDiscussionFor(self.portal.doc1, 1) - talkback = discussion.getDiscussionFor(self.portal.doc1) - self.portal.doc1.talkback.createReply('My Title', - 'My Text', - Creator='Jim') - reply = talkback.getReplies()[0] - reply.setReplyTo(self.portal.doc1) - reply.creation_date = DateTime(2003, 3, 11, 9, 28, 6) - reply.modification_date = DateTime(2009, 7, 12, 19, 38, 7) - self.assertEqual(reply.Title(), 'My Title') - self.assertEqual(reply.EditableBody(), 'My Text') - self.assertTrue('Jim' in reply.listCreators()) - self.assertEqual(talkback.replyCount(self.portal.doc1), 1) - self.assertEqual(reply.inReplyTo(), self.portal.doc1) - - view = self.view() - - self.assertTrue('No comments to moderate' in view) - self.assertEqual(len(self.view.comments), 0) - class ModerationBulkActionsViewTest(unittest.TestCase): @@ -189,6 +164,3 @@ class ModerationBulkActionsViewTest(unittest.TestCase): self.assertTrue(comment) self.assertEqual(comment, self.comment2) - -def test_suite(): - return unittest.defaultTestLoader.loadTestsFromName(__name__) diff --git a/plone/app/discussion/tests/test_notifications.py b/plone/app/discussion/tests/test_notifications.py index 1c397c1..548681c 100644 --- a/plone/app/discussion/tests/test_notifications.py +++ b/plone/app/discussion/tests/test_notifications.py @@ -42,7 +42,6 @@ class TestUserNotificationUnit(unittest.TestCase): '.user_notification_enabled'] = True # Create test content self.portal.invokeFactory('Document', 'doc1') - self.portal_discussion = self.portal.portal_discussion # Archetypes content types store data as utf-8 encoded strings # The missing u in front of a string is therefor not missing self.portal.doc1.title = 'Kölle Alaaf' # What is "Fasching"? @@ -189,7 +188,6 @@ class TestModeratorNotificationUnit(unittest.TestCase): ] = True # Create test content self.portal.invokeFactory('Document', 'doc1') - self.portal_discussion = self.portal.portal_discussion # Archetypes content types store data as utf-8 encoded strings # The missing u in front of a string is therefor not missing self.portal.doc1.title = 'Kölle Alaaf' # What is "Fasching"? @@ -278,6 +276,3 @@ class TestModeratorNotificationUnit(unittest.TestCase): self.assertEqual(len(self.mailhost.messages), 0) - -def test_suite(): - return unittest.defaultTestLoader.loadTestsFromName(__name__) diff --git a/plone/app/discussion/tests/test_tool.py b/plone/app/discussion/tests/test_tool.py deleted file mode 100644 index 1386cff..0000000 --- a/plone/app/discussion/tests/test_tool.py +++ /dev/null @@ -1,56 +0,0 @@ -import unittest2 as unittest - -from zope.component import queryUtility, createObject - -from plone.app.testing import TEST_USER_ID, setRoles - -from plone.app.discussion.testing import \ - PLONE_APP_DISCUSSION_INTEGRATION_TESTING - -from plone.app.discussion.interfaces import ICommentingTool, IConversation - - -class ToolTest(unittest.TestCase): - - layer = PLONE_APP_DISCUSSION_INTEGRATION_TESTING - - def setUp(self): - self.portal = self.layer['portal'] - setRoles(self.portal, TEST_USER_ID, ['Manager']) - self.portal.invokeFactory(id='doc1', - title='Document 1', - type_name='Document') - - def test_tool_indexing(self): - # Create a conversation. In this case we doesn't assign it to an - # object, as we just want to check the Conversation object API. - conversation = IConversation(self.portal.doc1) - - # Add a comment. - comment = createObject('plone.Comment') - comment.creator = 'jim' - comment.author_name = "Jim" - comment.text = 'Comment text' - - conversation.addComment(comment) - - # Check that the comment got indexed in the tool: - tool = queryUtility(ICommentingTool) - comment = list(tool.searchResults()) - self.assertTrue( - len(comment) == 1, - "There is only one comment, but we got" - " %s results in the search" % len(comment) - ) - self.assertEqual(comment[0].Title, 'Jim on Document 1') - - def test_unindexing(self): - pass - - def test_search(self): - # search returns only comments - pass - - -def test_suite(): - return unittest.defaultTestLoader.loadTestsFromName(__name__) diff --git a/plone/app/discussion/tests/test_workflow.py b/plone/app/discussion/tests/test_workflow.py index 8ea9e30..65361aa 100644 --- a/plone/app/discussion/tests/test_workflow.py +++ b/plone/app/discussion/tests/test_workflow.py @@ -32,7 +32,6 @@ class WorkflowSetupTest(unittest.TestCase): self.portal.invokeFactory('Folder', 'test-folder') self.folder = self.portal['test-folder'] self.portal.portal_types['Document'].allow_discussion = True - self.portal_discussion = self.portal.portal_discussion self.folder.invokeFactory('Document', 'doc1') self.doc = self.folder.doc1 @@ -190,7 +189,6 @@ class CommentReviewWorkflowTest(unittest.TestCase): # Create a Document self.portal.invokeFactory('Document', 'doc1') - self.portal_discussion = self.portal.portal_discussion # Create a conversation for this Document conversation = IConversation(self.portal.doc1) @@ -277,6 +275,3 @@ class CommentReviewWorkflowTest(unittest.TestCase): ) ) - -def test_suite(): - return unittest.defaultTestLoader.loadTestsFromName(__name__) From 6ee480a229d6e8eda3bad1bae8a797c93beb7bcf Mon Sep 17 00:00:00 2001 From: Andrea Cecchi Date: Mon, 3 Mar 2014 15:09:30 +0100 Subject: [PATCH 04/16] fixed edit test --- plone/app/discussion/tests/test_comments_viewlet.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plone/app/discussion/tests/test_comments_viewlet.py b/plone/app/discussion/tests/test_comments_viewlet.py index a12d909..275f3b9 100644 --- a/plone/app/discussion/tests/test_comments_viewlet.py +++ b/plone/app/discussion/tests/test_comments_viewlet.py @@ -127,7 +127,7 @@ class TestCommentForm(unittest.TestCase): """ # Allow discussion - self.discussionTool.overrideDiscussionFor(self.portal.doc1, True) + self.portal.doc1.allow_discussion = True self.viewlet = CommentsViewlet(self.context, self.request, None, None) def make_request(form={}): @@ -186,11 +186,11 @@ class TestCommentForm(unittest.TestCase): self.assertEqual(len(comments), 1) for comment in comments: - self.assertEqual(comment.text, u"bar") + self.assertEqual(comment.text, u"foobar") self.assertEqual(comment.creator, "test-user") self.assertEqual(comment.getOwner().getUserName(), "test-user") local_roles = comment.get_local_roles() - self.assertEqual(len(local_roles), 1) + self.assertEqual(len(local_roles), 2) userid, roles = local_roles[0] self.assertEqual(userid, 'test-user') self.assertEqual(len(roles), 1) From 920392d1244f91919ce5e4a838fbb6d57ad91d31 Mon Sep 17 00:00:00 2001 From: Andrea Cecchi Date: Mon, 3 Mar 2014 15:12:16 +0100 Subject: [PATCH 05/16] added Delete comments permission to manage comments deletion --- CHANGES.rst | 6 +- plone/app/discussion/browser/comments.py | 11 +-- plone/app/discussion/browser/configure.zcml | 2 +- plone/app/discussion/browser/controlpanel.py | 2 - plone/app/discussion/browser/moderation.py | 11 +-- plone/app/discussion/interfaces.py | 11 --- plone/app/discussion/permissions.zcml | 4 + .../discussion/profiles/default/rolemap.xml | 9 ++- plone/app/discussion/testing.py | 10 +++ .../tests/functional_test_comments.txt | 76 ++++--------------- 10 files changed, 43 insertions(+), 99 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 9cdfec9..9cfe383 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -25,12 +25,12 @@ Changelog - Fix ownership of comments. [toutpt] -- Provide 'delete own comments' as a configurable option - [gyst] - - Make comments editable. [pjstevns, gyst] +- Provide 'Delete comments' permission to handle comments deletion + [cekk] + 2.2.10 (2013-09-24) ------------------- diff --git a/plone/app/discussion/browser/comments.py b/plone/app/discussion/browser/comments.py index d58dfb7..0661654 100644 --- a/plone/app/discussion/browser/comments.py +++ b/plone/app/discussion/browser/comments.py @@ -312,18 +312,9 @@ class CommentsViewlet(ViewletBase): """By default requires 'Review comments'. If 'delete own comments' is enabled, requires 'Edit comments'. """ - if self.is_delete_own_comment_allowed(): - permission = 'Edit comments' - else: - permission = 'Review comments' - return getSecurityManager().checkPermission(permission, + return getSecurityManager().checkPermission('Delete comments', aq_inner(reply)) - def is_delete_own_comment_allowed(self): - registry = queryUtility(IRegistry) - settings = registry.forInterface(IDiscussionSettings, check=False) - return settings.delete_own_comment_enabled - def is_discussion_allowed(self): context = aq_inner(self.context) return context.restrictedTraverse('@@conversation_view').enabled() diff --git a/plone/app/discussion/browser/configure.zcml b/plone/app/discussion/browser/configure.zcml index facb643..427395d 100644 --- a/plone/app/discussion/browser/configure.zcml +++ b/plone/app/discussion/browser/configure.zcml @@ -88,7 +88,7 @@ name="moderate-delete-comment" layer="..interfaces.IDiscussionLayer" class=".moderation.DeleteComment" - permission="zope2.DeleteObjects" + permission="plone.app.discussion.DeleteComments" /> diff --git a/plone/app/discussion/browser/controlpanel.py b/plone/app/discussion/browser/controlpanel.py index 84ded37..822ca2b 100644 --- a/plone/app/discussion/browser/controlpanel.py +++ b/plone/app/discussion/browser/controlpanel.py @@ -54,8 +54,6 @@ class DiscussionSettingsEditForm(controlpanel.RegistryEditForm): SingleCheckBoxFieldWidget self.fields['edit_comment_enabled'].widgetFactory = \ SingleCheckBoxFieldWidget - self.fields['delete_own_comment_enabled'].widgetFactory = \ - SingleCheckBoxFieldWidget self.fields['anonymous_comments'].widgetFactory = \ SingleCheckBoxFieldWidget self.fields['show_commenter_image'].widgetFactory = \ diff --git a/plone/app/discussion/browser/moderation.py b/plone/app/discussion/browser/moderation.py index 0324a43..66f235b 100644 --- a/plone/app/discussion/browser/moderation.py +++ b/plone/app/discussion/browser/moderation.py @@ -117,18 +117,9 @@ class DeleteComment(BrowserView): """By default requires 'Review comments'. If 'delete own comments' is enabled, requires 'Edit comments'. """ - if self.is_delete_own_comment_allowed(): - permission = 'Edit comments' - else: - permission = 'Review comments' - return getSecurityManager().checkPermission(permission, + return getSecurityManager().checkPermission('Delete comments', aq_inner(reply)) - def is_delete_own_comment_allowed(self): - registry = queryUtility(IRegistry) - settings = registry.forInterface(IDiscussionSettings, check=False) - return settings.delete_own_comment_enabled - class PublishComment(BrowserView): """Publish a comment. diff --git a/plone/app/discussion/interfaces.py b/plone/app/discussion/interfaces.py index 70b9f4b..e8ff537 100644 --- a/plone/app/discussion/interfaces.py +++ b/plone/app/discussion/interfaces.py @@ -253,17 +253,6 @@ class IDiscussionSettings(Interface): default=False, ) - delete_own_comment_enabled = schema.Bool( - title=_(u"label_delete_own_comment_enabled", - default="Allow users to delete their own comment threads"), - description=_(u"help_edit_comment_enabled", - default=u"If selected, users may delete their own " - "comments -> AND the whole reply thread below that " - "comment!"), - required=False, - default=False, - ) - text_transform = schema.Choice( title=_(u"label_text_transform", default="Comment text transform"), diff --git a/plone/app/discussion/permissions.zcml b/plone/app/discussion/permissions.zcml index dd71e63..97c6782 100644 --- a/plone/app/discussion/permissions.zcml +++ b/plone/app/discussion/permissions.zcml @@ -14,5 +14,9 @@ title="Edit comments" /> + diff --git a/plone/app/discussion/profiles/default/rolemap.xml b/plone/app/discussion/profiles/default/rolemap.xml index 8e8b019..83a67fc 100644 --- a/plone/app/discussion/profiles/default/rolemap.xml +++ b/plone/app/discussion/profiles/default/rolemap.xml @@ -3,15 +3,20 @@ - + - + + + + + + diff --git a/plone/app/discussion/testing.py b/plone/app/discussion/testing.py index cf1066c..6a52a9b 100644 --- a/plone/app/discussion/testing.py +++ b/plone/app/discussion/testing.py @@ -28,6 +28,8 @@ class PloneAppDiscussion(PloneSandboxLayer): USER_WITH_FULLNAME_PASSWORD = 'secret' MANAGER_USER_NAME = 'manager' MANAGER_USER_PASSWORD = 'secret' + REVIEWER_NAME = 'reviewer' + REVIEWER_PASSWORD = 'secret' def setUpZope(self, app, configurationContext): # Load ZCML @@ -60,7 +62,15 @@ class PloneAppDiscussion(PloneSandboxLayer): ['Member'], [], ) + acl_users.userFolderAddUser( + self.REVIEWER_NAME, + self.REVIEWER_PASSWORD, + ['Member'], + [], + ) mtool = getToolByName(portal, 'portal_membership', None) + gtool = getToolByName(portal, 'portal_groups', None) + gtool.addPrincipalToGroup(self.REVIEWER_NAME, 'Reviewers') mtool.addMember('jim', 'Jim', ['Member'], []) mtool.getMemberById('jim').setMemberProperties( {"fullname": 'Jim Fult\xc3\xb8rn'}) diff --git a/plone/app/discussion/tests/functional_test_comments.txt b/plone/app/discussion/tests/functional_test_comments.txt index a31ed48..180b2b5 100644 --- a/plone/app/discussion/tests/functional_test_comments.txt +++ b/plone/app/discussion/tests/functional_test_comments.txt @@ -32,6 +32,7 @@ you're not logged in:: >>> unprivileged_browser = Browser(app) >>> browser_member = Browser(app) >>> browser_user = Browser(app) + >>> browser_reviewer = Browser(app) Make sure we have a test user from the layer and it uses fancy characters: @@ -143,7 +144,6 @@ Post a comment as member ------------------------ Login as user 'jim'. - >>> browser_member.open(portal_url + '/login_form') >>> browser_member.getControl(name='__ac_name').value = 'jim' >>> browser_member.getControl(name='__ac_password').value = 'secret' @@ -320,8 +320,8 @@ But Anon can see the edited comment. True -Deleting existing comments | 'delete own comments' disabled ------------------------------------------------------------ +Deleting existing comments | 'Delete comments' permission +---------------------------------------------------------- Anonymous cannot delete comments @@ -329,7 +329,7 @@ Anonymous cannot delete comments >>> 'form.button.Delete' in unprivileged_browser.contents False -A member cannot delete his own comments, unless this is explicitly enabled (see later) +A member cannot delete his own comments if he can't review or he isn't a Site Administrator >>> browser_member.open(urldoc1) >>> 'form.button.Delete' in browser_member.contents @@ -360,7 +360,6 @@ The comment is still there >>> comment_id in unprivileged_browser.contents True - A Member cannot delete even his own comment by hitting the delete url directly. Extract the member comment id from the admin browser @@ -387,10 +386,17 @@ The comment is still there >>> 'Comment from Jim' in browser_member.contents True -Admin, who hase 'review comments' permission, can delete comments +Now login as user 'reviewer' - >>> browser.open(urldoc1) - >>> form = browser.getForm(name='delete', index=0) + >>> browser_reviewer.open(portal_url + '/login_form') + >>> browser_reviewer.getControl(name='__ac_name').value = 'reviewer' + >>> browser_reviewer.getControl(name='__ac_password').value = 'secret' + >>> browser_reviewer.getControl(name='submit').click() + +Admin and who have 'Delete comments' permission (reviewers for example), can delete comments + + >>> browser_reviewer.open(urldoc1) + >>> form = browser_reviewer.getForm(name='delete', index=0) >>> '@@moderate-delete-comment' in form.action True @@ -406,58 +412,8 @@ We'll just catch that and check the result later. Returning to the document we find the deleted comment is indeed gone - >>> browser.open(urldoc1) - >>> comment_id in browser.contents - False - - -Deleting existing comments | 'delete own comments' ENABLED ----------------------------------------------------------- - -Enable deletion of own comments - - >>> from zope.component import queryUtility - >>> from plone.registry.interfaces import IRegistry - >>> from plone.app.discussion.interfaces import IDiscussionSettings - >>> registry = queryUtility(IRegistry) - >>> settings = registry.forInterface(IDiscussionSettings) - >>> settings.delete_own_comment_enabled = True - - >>> import transaction - >>> transaction.commit() - -Anonymous still cannot delete comments - - >>> unprivileged_browser.open(urldoc1) - >>> 'form.button.Delete' in unprivileged_browser.contents - False - -A member can now delete his own comments - - >>> browser_member.open(urldoc1) - >>> 'form.button.Delete' in browser_member.contents - True - - >>> form = browser_member.getForm(name='delete', index=0) - >>> '@@moderate-delete-comment' in form.action - True - - >>> comment_id = form.action.split('/')[-2] - -Submitting the form runs into a testbrowser notFoundException. -We'll just catch that and check the result later. - - >>> try: - ... form.submit() - ... except: - ... pass - -Returning to the document we find the deleted comment is indeed gone - - >>> browser_member.open(urldoc1) - >>> comment_id in browser_member.contents - False - >>> 'Comment from Jim' in browser_member.contents + >>> browser_reviewer.open(urldoc1) + >>> comment_id in browser_reviewer.contents False From 770651cf72aabbf91aef53a44a7974aa9b84a944 Mon Sep 17 00:00:00 2001 From: Andrea Cecchi Date: Mon, 3 Mar 2014 16:15:12 +0100 Subject: [PATCH 06/16] fixed upgrade step --- plone/app/discussion/configure.zcml | 6 +++--- plone/app/discussion/upgrades.py | 6 ++++++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/plone/app/discussion/configure.zcml b/plone/app/discussion/configure.zcml index 536994e..bbe8b7c 100644 --- a/plone/app/discussion/configure.zcml +++ b/plone/app/discussion/configure.zcml @@ -54,11 +54,11 @@ /> diff --git a/plone/app/discussion/upgrades.py b/plone/app/discussion/upgrades.py index bf9df71..652e060 100644 --- a/plone/app/discussion/upgrades.py +++ b/plone/app/discussion/upgrades.py @@ -2,7 +2,13 @@ from zope.component import getUtility from plone.registry.interfaces import IRegistry from plone.app.discussion.interfaces import IDiscussionSettings +default_profile = 'profile-plone.app.discussion:default' + def update_registry(context): registry = getUtility(IRegistry) registry.registerInterface(IDiscussionSettings) + + +def update_rolemap(context): + context.runImportStepFromProfile(default_profile, 'rolemap') From 22cc340ff6bc7c72a0f95403d76c1d9ef9fc8b7f Mon Sep 17 00:00:00 2001 From: Andrea Cecchi Date: Wed, 26 Mar 2014 18:07:28 +0100 Subject: [PATCH 07/16] fixed HISTORY and add delete test to test_comments_viewlet --- CHANGES.rst | 57 ++++++++++++++++-- .../discussion/tests/test_comments_viewlet.py | 60 ++++++++++++++++++- 2 files changed, 108 insertions(+), 9 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 9cfe383..fc099ad 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,9 +1,59 @@ Changelog ========= -2.3.0 (unreleased) +2.3.2 (unreleased) ------------------ +- bugfix: according to IDiscussionSettings.anonymous_email_enabled (cite): + "If selected, anonymous user will have to give their email." - But field + was not required. Now it is. + [jensens] + +- bugfix: anonymous email field was never saved. + [jensens] + +- updated german translations: added some missing msgstr. + [jensens] + +- added i18ndude and a script ``update_translations`` to buildout in order + to make translation updates simpler. + [jensens] + +- Fix reindexObject for content_object in moderation views. + Now reindex only "total_comments" index and not all the indexes + [cekk] + +- Fix comments Title if utf-8 characters in author_name + [huub_bouma] + +- use member.getId as author_username, so membrane users having different id + then username still have there picture shown and author path is correct. + [maartenkling] + +- Make comments editable. + [pjstevns, gyst] + +- Provide 'Delete comments' permission to handle comments deletion + [cekk] + +- Fixed Italian translations + [cekk] + + +2.3.1 (2014-02-22) +------------------ + +- 2.3.0 was a brown bag release. + [timo] + + +2.3.0 (2014-02-22) +------------------ + +- Execute the proper workflow change when using the moderation buttons instead + of hardcoding the workflow action to always publish + [omiron] + - Corrections and additions to the Danish translation [aputtu] @@ -25,11 +75,6 @@ Changelog - Fix ownership of comments. [toutpt] -- Make comments editable. - [pjstevns, gyst] - -- Provide 'Delete comments' permission to handle comments deletion - [cekk] 2.2.10 (2013-09-24) ------------------- diff --git a/plone/app/discussion/tests/test_comments_viewlet.py b/plone/app/discussion/tests/test_comments_viewlet.py index 275f3b9..efcbdf0 100644 --- a/plone/app/discussion/tests/test_comments_viewlet.py +++ b/plone/app/discussion/tests/test_comments_viewlet.py @@ -187,15 +187,69 @@ class TestCommentForm(unittest.TestCase): for comment in comments: self.assertEqual(comment.text, u"foobar") - self.assertEqual(comment.creator, "test-user") + self.assertEqual(comment.creator, "test_user_1_") self.assertEqual(comment.getOwner().getUserName(), "test-user") local_roles = comment.get_local_roles() - self.assertEqual(len(local_roles), 2) + self.assertEqual(len(local_roles), 1) userid, roles = local_roles[0] - self.assertEqual(userid, 'test-user') + self.assertEqual(userid, 'test_user_1_') self.assertEqual(len(roles), 1) self.assertEqual(roles[0], 'Owner') + def test_delete_comment(self): + """Delete a comment as logged-in user. + """ + + # Allow discussion + self.portal.doc1.allow_discussion = True + self.viewlet = CommentsViewlet(self.context, self.request, None, None) + + def make_request(form={}): + request = TestRequest() + request.form.update(form) + alsoProvides(request, IFormLayer) + alsoProvides(request, IAttributeAnnotatable) + return request + + provideAdapter( + adapts=(Interface, IBrowserRequest), + provides=Interface, + factory=CommentForm, + name=u"comment-form" + ) + + # The form is submitted successfully, if the required text field is + # filled out + form_request = make_request(form={'form.widgets.text': u'bar'}) + + commentForm = getMultiAdapter( + (self.context, form_request), + name=u"comment-form" + ) + + commentForm.update() + data, errors = commentForm.extractData() # pylint: disable-msg=W0612 + self.assertEqual(len(errors), 0) + self.assertFalse(commentForm.handleComment(commentForm, "foo")) + + # Delete the last comment + conversation = IConversation(self.context) + comment = [x for x in conversation.getComments()][-1] + deleteView = getMultiAdapter( + (comment, self.request), + name=u"moderate-delete-comment" + ) + # try to delete last comment without "Delete comments" permission + setRoles(self.portal, TEST_USER_ID, ['Member']) + self.assertRaises(Unauthorized, comment.restrictedTraverse, "@@moderate-delete-comment") + deleteView() + self.assertEqual(1, len([x for x in conversation.getComments()])) + # try to delete last comment with "Delete comments" permission + setRoles(self.portal, TEST_USER_ID, ['Reviewer']) + deleteView() + self.assertEqual(0, len([x for x in conversation.getComments()])) + setRoles(self.portal, TEST_USER_ID, ['Manager']) + def test_add_anonymous_comment(self): self.portal.doc1.allow_discussion = True From dec8bd63dd105780a25dd91ba9da99d825fde821 Mon Sep 17 00:00:00 2001 From: Andrea Cecchi Date: Wed, 26 Mar 2014 18:22:59 +0100 Subject: [PATCH 08/16] rebased CHANGES and tests --- CHANGES.rst | 4 ++-- plone/app/discussion/tests/test_comments_viewlet.py | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index fc099ad..3c504a3 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -73,8 +73,8 @@ Changelog PLONE_FIXTURE. [timo] -- Fix ownership of comments. [toutpt] - +- Fix ownership of comments. + [toutpt] 2.2.10 (2013-09-24) ------------------- diff --git a/plone/app/discussion/tests/test_comments_viewlet.py b/plone/app/discussion/tests/test_comments_viewlet.py index efcbdf0..fd81b7a 100644 --- a/plone/app/discussion/tests/test_comments_viewlet.py +++ b/plone/app/discussion/tests/test_comments_viewlet.py @@ -188,6 +188,7 @@ class TestCommentForm(unittest.TestCase): for comment in comments: self.assertEqual(comment.text, u"foobar") self.assertEqual(comment.creator, "test_user_1_") + self.assertEqual(comment.getOwner().getUserName(), "test-user") local_roles = comment.get_local_roles() self.assertEqual(len(local_roles), 1) From a82352a36c197e17612000c17b40b67e663e6a3c Mon Sep 17 00:00:00 2001 From: "Guido A.J. Stevens" Date: Tue, 17 Sep 2013 12:03:46 +0000 Subject: [PATCH 09/16] Make comments editable --- CHANGES.rst | 5 ++ plone/app/discussion/browser/comment.py | 69 ++++++++++++++++++ plone/app/discussion/browser/comments.pt | 21 +++++- plone/app/discussion/browser/comments.py | 13 ++++ plone/app/discussion/browser/configure.zcml | 9 +++ plone/app/discussion/browser/controlpanel.py | 14 +++- .../browser/javascripts/comments.js | 9 +++ .../browser/javascripts/controlpanel.js | 2 + .../browser/stylesheets/discussion.css | 6 ++ plone/app/discussion/comment.py | 11 ++- plone/app/discussion/configure.zcml | 11 +++ plone/app/discussion/interfaces.py | 11 +++ plone/app/discussion/permissions.zcml | 6 ++ .../discussion/profiles/default/metadata.xml | 2 +- .../discussion/profiles/default/registry.xml | 6 +- .../discussion/profiles/default/rolemap.xml | 6 ++ .../tests/functional_test_comments.txt | 71 +++++++++++++++++++ plone/app/discussion/tests/test_comment.py | 10 +++ .../discussion/tests/test_comments_viewlet.py | 71 ++++++++++++++++++- .../app/discussion/tests/test_controlpanel.py | 16 +++++ 20 files changed, 359 insertions(+), 10 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 6790b6e..27b7716 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,6 +1,7 @@ Changelog ========= + 2.3.3 (unreleased) ------------------ @@ -17,6 +18,10 @@ Changelog - Update Traditional Chinese translations. [marr] +- Make comments editable. + [pjstevns, gyst] + + 2.3.2 (2014-04-05) ------------------ diff --git a/plone/app/discussion/browser/comment.py b/plone/app/discussion/browser/comment.py index 97eec23..03e0732 100644 --- a/plone/app/discussion/browser/comment.py +++ b/plone/app/discussion/browser/comment.py @@ -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 diff --git a/plone/app/discussion/browser/comments.pt b/plone/app/discussion/browser/comments.pt index 4e32e99..203e813 100644 --- a/plone/app/discussion/browser/comments.pt +++ b/plone/app/discussion/browser/comments.pt @@ -1,6 +1,7 @@ @@ -87,7 +89,7 @@ action="" method="post" class="commentactionsform" - tal:condition="canReview" + tal:condition="python:canReview" tal:attributes="action string:${reply/absolute_url}/@@moderate-delete-comment"> +
    + +
    + +
    + + + *' + }) /********************************************************************** * Delete a comment and its answers. diff --git a/plone/app/discussion/browser/javascripts/controlpanel.js b/plone/app/discussion/browser/javascripts/controlpanel.js index f821bf9..e379f6f 100644 --- a/plone/app/discussion/browser/javascripts/controlpanel.js +++ b/plone/app/discussion/browser/javascripts/controlpanel.js @@ -40,6 +40,7 @@ $.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'), diff --git a/plone/app/discussion/browser/stylesheets/discussion.css b/plone/app/discussion/browser/stylesheets/discussion.css index a8ca7a5..3857836 100644 --- a/plone/app/discussion/browser/stylesheets/discussion.css +++ b/plone/app/discussion/browser/stylesheets/discussion.css @@ -223,3 +223,9 @@ .row .discussion label { font-weight:bold; } + +/* editing comments */ + +.overlay-edit-comment textarea { + height: 10em; +} \ No newline at end of file diff --git a/plone/app/discussion/comment.py b/plone/app/discussion/comment.py index bf41967..193f30e 100644 --- a/plone/app/discussion/comment.py +++ b/plone/app/discussion/comment.py @@ -48,6 +48,7 @@ from Products.CMFCore.CMFCatalogAware import WorkflowAware from OFS.role import RoleManager from AccessControl import ClassSecurityInfo +from AccessControl.SecurityManagement import getSecurityManager from Products.CMFCore import permissions @@ -121,6 +122,14 @@ class Comment(CatalogAware, WorkflowAware, DynamicType, Traversable, self.creation_date = self.modification_date = datetime.utcnow() self.mime_type = 'text/plain' + user = getSecurityManager().getUser() + if user and user.getId(): + aclpath = [x for x in user.getPhysicalPath() if x] + self._owner = (aclpath, user.getId(),) + self.__ac_local_roles__ = { + user.getId(): ['Owner'] + } + @property def __name__(self): return self.comment_id and unicode(self.comment_id) or None @@ -199,7 +208,7 @@ class Comment(CatalogAware, WorkflowAware, DynamicType, Traversable, def Creator(self): """The name of the person who wrote the comment. """ - return self.creator + return self.creator or self.author_name security.declareProtected(permissions.View, 'Type') diff --git a/plone/app/discussion/configure.zcml b/plone/app/discussion/configure.zcml index c3345f7..0a689b1 100644 --- a/plone/app/discussion/configure.zcml +++ b/plone/app/discussion/configure.zcml @@ -44,6 +44,17 @@ for="Products.CMFPlone.interfaces.IPloneSiteRoot" /> + + + diff --git a/plone/app/discussion/interfaces.py b/plone/app/discussion/interfaces.py index c39b8c5..a807580 100644 --- a/plone/app/discussion/interfaces.py +++ b/plone/app/discussion/interfaces.py @@ -243,6 +243,17 @@ class IDiscussionSettings(Interface): default=False, ) + edit_comment_enabled = schema.Bool( + title=_(u"label_edit_comment_enabled", + default="Enable editing of comments"), + description=_(u"help_edit_comment_enabled", + default=u"If selected, supports editing and deletion " + "of comments for users with the 'Edit comments' " + "permission."), + required=False, + default=False, + ) + text_transform = schema.Choice( title=_(u"label_text_transform", default="Comment text transform"), diff --git a/plone/app/discussion/permissions.zcml b/plone/app/discussion/permissions.zcml index c5b090c..dd71e63 100644 --- a/plone/app/discussion/permissions.zcml +++ b/plone/app/discussion/permissions.zcml @@ -9,4 +9,10 @@ title="Review comments" /> + + + diff --git a/plone/app/discussion/profiles/default/metadata.xml b/plone/app/discussion/profiles/default/metadata.xml index ffedb11..7a20473 100644 --- a/plone/app/discussion/profiles/default/metadata.xml +++ b/plone/app/discussion/profiles/default/metadata.xml @@ -1,5 +1,5 @@ - 100 + 101 profile-plone.app.registry:default diff --git a/plone/app/discussion/profiles/default/registry.xml b/plone/app/discussion/profiles/default/registry.xml index fd2a894..2518c9a 100644 --- a/plone/app/discussion/profiles/default/registry.xml +++ b/plone/app/discussion/profiles/default/registry.xml @@ -1,4 +1,6 @@ - - \ No newline at end of file + + False + + diff --git a/plone/app/discussion/profiles/default/rolemap.xml b/plone/app/discussion/profiles/default/rolemap.xml index 83318d8..8e8b019 100644 --- a/plone/app/discussion/profiles/default/rolemap.xml +++ b/plone/app/discussion/profiles/default/rolemap.xml @@ -6,6 +6,12 @@ + + + + + + diff --git a/plone/app/discussion/tests/functional_test_comments.txt b/plone/app/discussion/tests/functional_test_comments.txt index 55a489d..8a15209 100644 --- a/plone/app/discussion/tests/functional_test_comments.txt +++ b/plone/app/discussion/tests/functional_test_comments.txt @@ -250,6 +250,77 @@ Check that the reply has been posted properly. True +Edit an existing comment +------------------------ + +Log in as admin + + >>> browser.getLink('Log out').click() + >>> browser.open(portal_url + '/login_form') + >>> browser.getControl('Login Name').value = 'admin' + >>> browser.getControl('Password').value = 'secret' + >>> browser.getControl('Log in').click() + +Use the Plone control panel to enable comment editing. + + >>> browser.open(portal_url + '/plone_control_panel') + >>> browser.getLink('Discussion').click() + >>> browser.getControl('Enable editing of comments').selected = True + >>> browser.getControl(name='form.buttons.save').click() + +Extract the edit comment url from the first "edit comment" button + + >>> browser.open(urldoc1) + >>> form = browser.getForm(name='edit', index=0) + >>> '@@edit-comment' in form.action + True + +Open the edit comment view + + >>> browser.open(form.action) + >>> ctrl = browser.getControl('Comment') + >>> ctrl.value + 'Comment from admin' + +Change and save the comment + + >>> ctrl.value = 'Comment from admin / was edited' + >>> browser.getControl('Edit comment').click() + +This used to trigger permissions problems in some portlet configurations. +Check it ain't so. + + >>> 'require_login' in browser.url + False + >>> browser.url.startswith('http://nohost/plone/doc1') + True + >>> 'Comment from admin / was edited' in browser.contents + True + +Opening the edit comment view, then cancel, does nothing. + + >>> form = browser.getForm(name='edit', index=0) + >>> '@@edit-comment' in form.action + True + >>> browser.open(form.action) + >>> browser.getControl('Cancel').click() + >>> browser.url.startswith('http://nohost/plone/doc1') + True + + +Anon cannot edit comments. + + >>> unprivileged_browser.open(urldoc1) + >>> '@@edit-comments' in browser.contents + False + +But Anon can see the edited comment. + + >>> 'Comment from admin / was edited' in unprivileged_browser.contents + True + + + Post a comment with comment review workflow enabled --------------------------------------------------- diff --git a/plone/app/discussion/tests/test_comment.py b/plone/app/discussion/tests/test_comment.py index d3c25e0..5277f5f 100644 --- a/plone/app/discussion/tests/test_comment.py +++ b/plone/app/discussion/tests/test_comment.py @@ -142,6 +142,16 @@ class CommentTest(unittest.TestCase): comment1.creator = "jim" self.assertEqual("jim", comment1.Creator()) + def test_creator_author_name(self): + comment1 = createObject('plone.Comment') + comment1.author_name = "joey" + self.assertEqual("joey", comment1.Creator()) + + def test_owner(self): + comment1 = createObject('plone.Comment') + self.assertEqual((['plone', 'acl_users'], TEST_USER_ID), + comment1.getOwnerTuple()) + def test_type(self): comment1 = createObject('plone.Comment') self.assertEqual(comment1.Type(), 'Comment') diff --git a/plone/app/discussion/tests/test_comments_viewlet.py b/plone/app/discussion/tests/test_comments_viewlet.py index c0d45b2..e469b91 100644 --- a/plone/app/discussion/tests/test_comments_viewlet.py +++ b/plone/app/discussion/tests/test_comments_viewlet.py @@ -33,12 +33,14 @@ from plone.app.testing import login from plone.app.discussion.browser.comments import CommentsViewlet from plone.app.discussion.browser.comments import CommentForm +from plone.app.discussion.browser.comment import EditCommentForm from plone.app.discussion import interfaces from plone.app.discussion.interfaces import IConversation from plone.app.discussion.testing import ( PLONE_APP_DISCUSSION_INTEGRATION_TESTING ) from plone.app.discussion.interfaces import IDiscussionSettings +from plone.app.discussion.interfaces import IConversation class TestCommentForm(unittest.TestCase): @@ -135,6 +137,65 @@ class TestCommentForm(unittest.TestCase): self.assertEqual(len(roles), 1) self.assertEqual(roles[0], 'Owner') + def test_edit_comment(self): + """Edit a comment as logged-in user. + """ + + # Allow discussion + self.discussionTool.overrideDiscussionFor(self.portal.doc1, True) + self.viewlet = CommentsViewlet(self.context, self.request, None, None) + + def make_request(form={}): + request = TestRequest() + request.form.update(form) + alsoProvides(request, IFormLayer) + alsoProvides(request, IAttributeAnnotatable) + return request + + provideAdapter( + adapts=(Interface, IBrowserRequest), + provides=Interface, + factory=CommentForm, + name=u"comment-form" + ) + + provideAdapter( + adapts=(Interface, IBrowserRequest), + provides=Interface, + factory=EditCommentForm, + name=u"edit-comment-form" + ) + + # The form is submitted successfully, if the required text field is + # filled out + request = make_request(form={'form.widgets.text': u'bar'}) + + commentForm = getMultiAdapter( + (self.context, request), + name=u"comment-form" + ) + commentForm.update() + data, errors = commentForm.extractData() # pylint: disable-msg=W0612 + + self.assertEqual(len(errors), 0) + self.assertFalse(commentForm.handleComment(commentForm, "foo")) + + # Edit the last comment + conversation = IConversation(self.context) + comment = [x for x in conversation.getComments()][-1] + request = make_request(form={'form.widgets.text': u'foobar'}) + editForm = getMultiAdapter( + (comment, request), + name=u"edit-comment-form" + ) + editForm.update() + data, errors = editForm.extractData() # pylint: disable-msg=W0612 + + self.assertEqual(len(errors), 0) + self.assertFalse(editForm.handleComment(editForm, "foo")) + comment = [x for x in conversation.getComments()][-1] + self.assertEquals(comment.text, u"foobar") + def test_add_anonymous_comment(self): self.portal.doc1.allow_discussion = True @@ -477,9 +538,13 @@ class TestCommentsViewlet(unittest.TestCase): ) def test_get_commenter_portrait_is_none(self): - self.assertEqual( - self.viewlet.get_commenter_portrait(), - 'defaultUser.png' + + self.assertTrue( + self.viewlet.get_commenter_portrait() in ( + 'defaultUser.png', + 'defaultUser.gif', + ) + ) def test_get_commenter_portrait_without_userimage(self): diff --git a/plone/app/discussion/tests/test_controlpanel.py b/plone/app/discussion/tests/test_controlpanel.py index 40d4540..8a2ba47 100644 --- a/plone/app/discussion/tests/test_controlpanel.py +++ b/plone/app/discussion/tests/test_controlpanel.py @@ -81,6 +81,22 @@ class RegistryTest(unittest.TestCase): False ) + def test_edit_comment_enabled(self): + # Check edit_comment_enabled record + self.assertTrue('edit_comment_enabled' in IDiscussionSettings) + self.assertEqual( + self.registry['plone.app.discussion.interfaces.' + + 'IDiscussionSettings.edit_comment_enabled'], + False) + + def test_edit_comment_enabled(self): + # Check edit_comment_enabled record + self.assertTrue('edit_comment_enabled' in IDiscussionSettings) + self.assertEqual( + self.registry['plone.app.discussion.interfaces.' + + 'IDiscussionSettings.edit_comment_enabled'], + False) + def test_text_transform(self): self.assertTrue('text_transform' in IDiscussionSettings) self.assertEqual( From 520b1e83f461103faa13d35516435acf42be4ab8 Mon Sep 17 00:00:00 2001 From: "Guido A.J. Stevens" Date: Thu, 19 Sep 2013 08:39:52 +0000 Subject: [PATCH 10/16] provide "delete own comments" as a configurable option --- plone/app/discussion/browser/comments.pt | 5 +- plone/app/discussion/browser/comments.py | 18 ++- plone/app/discussion/browser/configure.zcml | 6 +- plone/app/discussion/browser/controlpanel.py | 2 + plone/app/discussion/browser/moderation.py | 34 ++++- plone/app/discussion/configure.zcml | 10 ++ plone/app/discussion/interfaces.py | 11 ++ .../discussion/profiles/default/metadata.xml | 2 +- .../tests/functional_test_comments.txt | 140 ++++++++++++++++++ 9 files changed, 217 insertions(+), 11 deletions(-) diff --git a/plone/app/discussion/browser/comments.pt b/plone/app/discussion/browser/comments.pt index 203e813..0348f33 100644 --- a/plone/app/discussion/browser/comments.pt +++ b/plone/app/discussion/browser/comments.pt @@ -36,7 +36,8 @@ 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'); - canEdit python:view.can_edit(reply)" + canEdit python:view.can_edit(reply); + canDelete python:view.can_delete(reply)" tal:attributes="class python:'comment replyTreeLevel'+str(depth)+' state-'+str(review_state); id string:${reply/getId}" tal:condition="python:canReview or review_state == 'published'"> @@ -89,7 +90,7 @@ action="" method="post" class="commentactionsform" - tal:condition="python:canReview" + tal:condition="python:canDelete" tal:attributes="action string:${reply/absolute_url}/@@moderate-delete-comment"> - + diff --git a/plone/app/discussion/browser/controlpanel.py b/plone/app/discussion/browser/controlpanel.py index 822ca2b..84ded37 100644 --- a/plone/app/discussion/browser/controlpanel.py +++ b/plone/app/discussion/browser/controlpanel.py @@ -54,6 +54,8 @@ class DiscussionSettingsEditForm(controlpanel.RegistryEditForm): SingleCheckBoxFieldWidget self.fields['edit_comment_enabled'].widgetFactory = \ SingleCheckBoxFieldWidget + self.fields['delete_own_comment_enabled'].widgetFactory = \ + SingleCheckBoxFieldWidget self.fields['anonymous_comments'].widgetFactory = \ SingleCheckBoxFieldWidget self.fields['show_commenter_image'].widgetFactory = \ diff --git a/plone/app/discussion/browser/moderation.py b/plone/app/discussion/browser/moderation.py index 55110e8..ea92515 100644 --- a/plone/app/discussion/browser/moderation.py +++ b/plone/app/discussion/browser/moderation.py @@ -1,5 +1,7 @@ # -*- coding: utf-8 -*- from Acquisition import aq_inner, aq_parent +from AccessControl import getSecurityManager +from zope.component import queryUtility from Products.Five.browser import BrowserView from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile @@ -8,6 +10,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 @@ -94,17 +98,37 @@ class DeleteComment(BrowserView): comment = aq_inner(self.context) conversation = aq_parent(comment) content_object = aq_parent(conversation) - del conversation[comment.id] - content_object.reindexObject(idxs=['total_comments']) - 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 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'. + """ + if self.is_delete_own_comment_allowed(): + permission = 'Edit comments' + else: + permission = 'Review comments' + return getSecurityManager().checkPermission(permission, + aq_inner(reply)) + + def is_delete_own_comment_allowed(self): + registry = queryUtility(IRegistry) + settings = registry.forInterface(IDiscussionSettings, check=False) + return settings.delete_own_comment_enabled + class PublishComment(BrowserView): """Publish a comment. diff --git a/plone/app/discussion/configure.zcml b/plone/app/discussion/configure.zcml index 0a689b1..d1d2675 100644 --- a/plone/app/discussion/configure.zcml +++ b/plone/app/discussion/configure.zcml @@ -54,6 +54,16 @@ profile="plone.app.discussion:default" /> + + diff --git a/plone/app/discussion/interfaces.py b/plone/app/discussion/interfaces.py index a807580..5a6cc8d 100644 --- a/plone/app/discussion/interfaces.py +++ b/plone/app/discussion/interfaces.py @@ -254,6 +254,17 @@ class IDiscussionSettings(Interface): default=False, ) + delete_own_comment_enabled = schema.Bool( + title=_(u"label_delete_own_comment_enabled", + default="Allow users to delete their own comment threads"), + description=_(u"help_edit_comment_enabled", + default=u"If selected, users may delete their own " + "comments -> AND the whole reply thread below that " + "comment!"), + required=False, + default=False, + ) + text_transform = schema.Choice( title=_(u"label_text_transform", default="Comment text transform"), diff --git a/plone/app/discussion/profiles/default/metadata.xml b/plone/app/discussion/profiles/default/metadata.xml index 7a20473..ce1f445 100644 --- a/plone/app/discussion/profiles/default/metadata.xml +++ b/plone/app/discussion/profiles/default/metadata.xml @@ -1,5 +1,5 @@ - 101 + 102 profile-plone.app.registry:default diff --git a/plone/app/discussion/tests/functional_test_comments.txt b/plone/app/discussion/tests/functional_test_comments.txt index 8a15209..9e7350c 100644 --- a/plone/app/discussion/tests/functional_test_comments.txt +++ b/plone/app/discussion/tests/functional_test_comments.txt @@ -320,6 +320,146 @@ But Anon can see the edited comment. True +Deleting existing comments | 'delete own comments' disabled +----------------------------------------------------------- + +Anonymous cannot delete comments + + >>> unprivileged_browser.open(urldoc1) + >>> 'form.button.Delete' in unprivileged_browser.contents + False + +A member cannot delete his own comments, unless this is explicitly enabled (see later) + + >>> browser_member.open(urldoc1) + >>> 'form.button.Delete' in browser_member.contents + False + +Admin can delete comments + + >>> browser.open(urldoc1) + >>> 'form.button.Delete' in browser.contents + True + +Extract the delete comment url from the first "delete comment" button + + >>> browser.open(urldoc1) + >>> form = browser.getForm(name='delete', index=0) + >>> delete_url = form.action + >>> '@@moderate-delete-comment' in delete_url + True + >>> comment_id = delete_url.split('/')[-2] + +Anonymous cannot delete a comment by hitting the delete url directly. + + >>> unprivileged_browser.open(delete_url) + +The comment is still there + + >>> unprivileged_browser.open(urldoc1) + >>> comment_id in unprivileged_browser.contents + True + + +A Member cannot delete even his own comment by hitting the delete url directly. + +Extract the member comment id from the admin browser + + >>> form = browser.getForm(name='delete', index=2) + >>> delete_url = form.action + >>> '@@moderate-delete-comment' in delete_url + True + >>> comment_id = delete_url.split('/')[-2] + +Now try to hit that url as the member owning that comment. +Work around some possible testbrowser breakage and check the result later. + + >>> try: + ... browser_member.open(delete_url) + ... except: + ... pass + +The comment is still there + + >>> browser_member.open(urldoc1) + >>> comment_id in browser_member.contents + True + >>> 'Comment from Jim' in browser_member.contents + True + +Admin, who hase 'review comments' permission, can delete comments + + >>> browser.open(urldoc1) + >>> form = browser.getForm(name='delete', index=0) + >>> '@@moderate-delete-comment' in form.action + True + + >>> comment_id = form.action.split('/')[-2] + +Submitting the form runs into a testbrowser notFoundException. +We'll just catch that and check the result later. + + >>> try: + ... form.submit() + ... except: + ... pass + +Returning to the document we find the deleted comment is indeed gone + + >>> browser.open(urldoc1) + >>> comment_id in browser.contents + False + + +Deleting existing comments | 'delete own comments' ENABLED +---------------------------------------------------------- + +Enable deletion of own comments + + >>> from zope.component import queryUtility + >>> from plone.registry.interfaces import IRegistry + >>> from plone.app.discussion.interfaces import IDiscussionSettings + >>> registry = queryUtility(IRegistry) + >>> settings = registry.forInterface(IDiscussionSettings) + >>> settings.delete_own_comment_enabled = True + + >>> import transaction + >>> transaction.commit() + +Anonymous still cannot delete comments + + >>> unprivileged_browser.open(urldoc1) + >>> 'form.button.Delete' in unprivileged_browser.contents + False + +A member can now delete his own comments + + >>> browser_member.open(urldoc1) + >>> 'form.button.Delete' in browser_member.contents + True + + >>> form = browser_member.getForm(name='delete', index=0) + >>> '@@moderate-delete-comment' in form.action + True + + >>> comment_id = form.action.split('/')[-2] + +Submitting the form runs into a testbrowser notFoundException. +We'll just catch that and check the result later. + + >>> try: + ... form.submit() + ... except: + ... pass + +Returning to the document we find the deleted comment is indeed gone + + >>> browser_member.open(urldoc1) + >>> comment_id in browser_member.contents + False + >>> 'Comment from Jim' in browser_member.contents + False + Post a comment with comment review workflow enabled --------------------------------------------------- From 3711d2fb5fb9867344f232f4b7dec0175601540f Mon Sep 17 00:00:00 2001 From: Andrea Cecchi Date: Tue, 4 Feb 2014 11:35:20 +0100 Subject: [PATCH 11/16] rebased branch from master --- CHANGES.rst | 2 ++ .../app/discussion/tests/test_comments_viewlet.py | 15 +++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/CHANGES.rst b/CHANGES.rst index 27b7716..4ac4976 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -100,6 +100,8 @@ Changelog [toutpt] +======= +>>>>>>> rebased branch from master 2.2.10 (2013-09-24) ------------------- diff --git a/plone/app/discussion/tests/test_comments_viewlet.py b/plone/app/discussion/tests/test_comments_viewlet.py index e469b91..dcf4246 100644 --- a/plone/app/discussion/tests/test_comments_viewlet.py +++ b/plone/app/discussion/tests/test_comments_viewlet.py @@ -196,6 +196,21 @@ class TestCommentForm(unittest.TestCase): comment = [x for x in conversation.getComments()][-1] self.assertEquals(comment.text, u"foobar") + comments = IConversation(commentForm.context).getComments() + comments = [comment for comment in comments] # consume itertor + self.assertEqual(len(comments), 1) + + for comment in comments: + self.assertEqual(comment.text, u"bar") + self.assertEqual(comment.creator, "test-user") + self.assertEqual(comment.getOwner().getUserName(), "test-user") + local_roles = comment.get_local_roles() + self.assertEqual(len(local_roles), 1) + userid, roles = local_roles[0] + self.assertEqual(userid, 'test-user') + self.assertEqual(len(roles), 1) + self.assertEqual(roles[0], 'Owner') + def test_add_anonymous_comment(self): self.portal.doc1.allow_discussion = True From fd16144b9b3dd8e7881e141f750d741b5e2073f5 Mon Sep 17 00:00:00 2001 From: Andrea Cecchi Date: Mon, 3 Mar 2014 15:09:30 +0100 Subject: [PATCH 12/16] fixed edit test --- plone/app/discussion/tests/test_comments_viewlet.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plone/app/discussion/tests/test_comments_viewlet.py b/plone/app/discussion/tests/test_comments_viewlet.py index dcf4246..449efee 100644 --- a/plone/app/discussion/tests/test_comments_viewlet.py +++ b/plone/app/discussion/tests/test_comments_viewlet.py @@ -142,7 +142,7 @@ class TestCommentForm(unittest.TestCase): """ # Allow discussion - self.discussionTool.overrideDiscussionFor(self.portal.doc1, True) + self.portal.doc1.allow_discussion = True self.viewlet = CommentsViewlet(self.context, self.request, None, None) def make_request(form={}): @@ -201,11 +201,11 @@ class TestCommentForm(unittest.TestCase): self.assertEqual(len(comments), 1) for comment in comments: - self.assertEqual(comment.text, u"bar") + self.assertEqual(comment.text, u"foobar") self.assertEqual(comment.creator, "test-user") self.assertEqual(comment.getOwner().getUserName(), "test-user") local_roles = comment.get_local_roles() - self.assertEqual(len(local_roles), 1) + self.assertEqual(len(local_roles), 2) userid, roles = local_roles[0] self.assertEqual(userid, 'test-user') self.assertEqual(len(roles), 1) From 9a4e3718fc8ae7e6f8f229058a27092b0d121a84 Mon Sep 17 00:00:00 2001 From: Andrea Cecchi Date: Mon, 3 Mar 2014 15:12:16 +0100 Subject: [PATCH 13/16] added Delete comments permission to manage comments deletion --- CHANGES.rst | 5 +- plone/app/discussion/browser/comments.py | 11 +-- plone/app/discussion/browser/configure.zcml | 2 +- plone/app/discussion/browser/controlpanel.py | 2 - plone/app/discussion/browser/moderation.py | 11 +-- plone/app/discussion/interfaces.py | 11 --- plone/app/discussion/permissions.zcml | 4 + .../discussion/profiles/default/rolemap.xml | 9 ++- plone/app/discussion/testing.py | 10 +++ .../tests/functional_test_comments.txt | 76 ++++--------------- 10 files changed, 43 insertions(+), 98 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 4ac4976..181c1cf 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -21,6 +21,9 @@ Changelog - Make comments editable. [pjstevns, gyst] +- Provide 'Delete comments' permission to handle comments deletion + [cekk] + 2.3.2 (2014-04-05) ------------------ @@ -100,8 +103,6 @@ Changelog [toutpt] -======= ->>>>>>> rebased branch from master 2.2.10 (2013-09-24) ------------------- diff --git a/plone/app/discussion/browser/comments.py b/plone/app/discussion/browser/comments.py index 8993303..5376875 100644 --- a/plone/app/discussion/browser/comments.py +++ b/plone/app/discussion/browser/comments.py @@ -328,18 +328,9 @@ class CommentsViewlet(ViewletBase): """By default requires 'Review comments'. If 'delete own comments' is enabled, requires 'Edit comments'. """ - if self.is_delete_own_comment_allowed(): - permission = 'Edit comments' - else: - permission = 'Review comments' - return getSecurityManager().checkPermission(permission, + return getSecurityManager().checkPermission('Delete comments', aq_inner(reply)) - def is_delete_own_comment_allowed(self): - registry = queryUtility(IRegistry) - settings = registry.forInterface(IDiscussionSettings, check=False) - return settings.delete_own_comment_enabled - def is_discussion_allowed(self): context = aq_inner(self.context) return context.restrictedTraverse('@@conversation_view').enabled() diff --git a/plone/app/discussion/browser/configure.zcml b/plone/app/discussion/browser/configure.zcml index facb643..427395d 100644 --- a/plone/app/discussion/browser/configure.zcml +++ b/plone/app/discussion/browser/configure.zcml @@ -88,7 +88,7 @@ name="moderate-delete-comment" layer="..interfaces.IDiscussionLayer" class=".moderation.DeleteComment" - permission="zope2.DeleteObjects" + permission="plone.app.discussion.DeleteComments" /> diff --git a/plone/app/discussion/browser/controlpanel.py b/plone/app/discussion/browser/controlpanel.py index 84ded37..822ca2b 100644 --- a/plone/app/discussion/browser/controlpanel.py +++ b/plone/app/discussion/browser/controlpanel.py @@ -54,8 +54,6 @@ class DiscussionSettingsEditForm(controlpanel.RegistryEditForm): SingleCheckBoxFieldWidget self.fields['edit_comment_enabled'].widgetFactory = \ SingleCheckBoxFieldWidget - self.fields['delete_own_comment_enabled'].widgetFactory = \ - SingleCheckBoxFieldWidget self.fields['anonymous_comments'].widgetFactory = \ SingleCheckBoxFieldWidget self.fields['show_commenter_image'].widgetFactory = \ diff --git a/plone/app/discussion/browser/moderation.py b/plone/app/discussion/browser/moderation.py index ea92515..84748f7 100644 --- a/plone/app/discussion/browser/moderation.py +++ b/plone/app/discussion/browser/moderation.py @@ -117,18 +117,9 @@ class DeleteComment(BrowserView): """By default requires 'Review comments'. If 'delete own comments' is enabled, requires 'Edit comments'. """ - if self.is_delete_own_comment_allowed(): - permission = 'Edit comments' - else: - permission = 'Review comments' - return getSecurityManager().checkPermission(permission, + return getSecurityManager().checkPermission('Delete comments', aq_inner(reply)) - def is_delete_own_comment_allowed(self): - registry = queryUtility(IRegistry) - settings = registry.forInterface(IDiscussionSettings, check=False) - return settings.delete_own_comment_enabled - class PublishComment(BrowserView): """Publish a comment. diff --git a/plone/app/discussion/interfaces.py b/plone/app/discussion/interfaces.py index 5a6cc8d..a807580 100644 --- a/plone/app/discussion/interfaces.py +++ b/plone/app/discussion/interfaces.py @@ -254,17 +254,6 @@ class IDiscussionSettings(Interface): default=False, ) - delete_own_comment_enabled = schema.Bool( - title=_(u"label_delete_own_comment_enabled", - default="Allow users to delete their own comment threads"), - description=_(u"help_edit_comment_enabled", - default=u"If selected, users may delete their own " - "comments -> AND the whole reply thread below that " - "comment!"), - required=False, - default=False, - ) - text_transform = schema.Choice( title=_(u"label_text_transform", default="Comment text transform"), diff --git a/plone/app/discussion/permissions.zcml b/plone/app/discussion/permissions.zcml index dd71e63..97c6782 100644 --- a/plone/app/discussion/permissions.zcml +++ b/plone/app/discussion/permissions.zcml @@ -14,5 +14,9 @@ title="Edit comments" /> + diff --git a/plone/app/discussion/profiles/default/rolemap.xml b/plone/app/discussion/profiles/default/rolemap.xml index 8e8b019..83a67fc 100644 --- a/plone/app/discussion/profiles/default/rolemap.xml +++ b/plone/app/discussion/profiles/default/rolemap.xml @@ -3,15 +3,20 @@ - + - + + + + + + diff --git a/plone/app/discussion/testing.py b/plone/app/discussion/testing.py index e7a397d..90f08f9 100644 --- a/plone/app/discussion/testing.py +++ b/plone/app/discussion/testing.py @@ -34,6 +34,8 @@ class PloneAppDiscussion(PloneSandboxLayer): USER_WITH_FULLNAME_PASSWORD = 'secret' MANAGER_USER_NAME = 'manager' MANAGER_USER_PASSWORD = 'secret' + REVIEWER_NAME = 'reviewer' + REVIEWER_PASSWORD = 'secret' def setUpZope(self, app, configurationContext): # Load ZCML @@ -66,7 +68,15 @@ class PloneAppDiscussion(PloneSandboxLayer): ['Member'], [], ) + acl_users.userFolderAddUser( + self.REVIEWER_NAME, + self.REVIEWER_PASSWORD, + ['Member'], + [], + ) mtool = getToolByName(portal, 'portal_membership', None) + gtool = getToolByName(portal, 'portal_groups', None) + gtool.addPrincipalToGroup(self.REVIEWER_NAME, 'Reviewers') mtool.addMember('jim', 'Jim', ['Member'], []) mtool.getMemberById('jim').setMemberProperties( {"fullname": 'Jim Fult\xc3\xb8rn'}) diff --git a/plone/app/discussion/tests/functional_test_comments.txt b/plone/app/discussion/tests/functional_test_comments.txt index 9e7350c..c0d1159 100644 --- a/plone/app/discussion/tests/functional_test_comments.txt +++ b/plone/app/discussion/tests/functional_test_comments.txt @@ -32,6 +32,7 @@ you're not logged in:: >>> unprivileged_browser = Browser(app) >>> browser_member = Browser(app) >>> browser_user = Browser(app) + >>> browser_reviewer = Browser(app) Make sure we have a test user from the layer and it uses fancy characters: @@ -143,7 +144,6 @@ Post a comment as member ------------------------ Login as user 'jim'. - >>> browser_member.open(portal_url + '/login_form') >>> browser_member.getControl(name='__ac_name').value = 'jim' >>> browser_member.getControl(name='__ac_password').value = 'secret' @@ -320,8 +320,8 @@ But Anon can see the edited comment. True -Deleting existing comments | 'delete own comments' disabled ------------------------------------------------------------ +Deleting existing comments | 'Delete comments' permission +---------------------------------------------------------- Anonymous cannot delete comments @@ -329,7 +329,7 @@ Anonymous cannot delete comments >>> 'form.button.Delete' in unprivileged_browser.contents False -A member cannot delete his own comments, unless this is explicitly enabled (see later) +A member cannot delete his own comments if he can't review or he isn't a Site Administrator >>> browser_member.open(urldoc1) >>> 'form.button.Delete' in browser_member.contents @@ -360,7 +360,6 @@ The comment is still there >>> comment_id in unprivileged_browser.contents True - A Member cannot delete even his own comment by hitting the delete url directly. Extract the member comment id from the admin browser @@ -387,10 +386,17 @@ The comment is still there >>> 'Comment from Jim' in browser_member.contents True -Admin, who hase 'review comments' permission, can delete comments +Now login as user 'reviewer' - >>> browser.open(urldoc1) - >>> form = browser.getForm(name='delete', index=0) + >>> browser_reviewer.open(portal_url + '/login_form') + >>> browser_reviewer.getControl(name='__ac_name').value = 'reviewer' + >>> browser_reviewer.getControl(name='__ac_password').value = 'secret' + >>> browser_reviewer.getControl(name='submit').click() + +Admin and who have 'Delete comments' permission (reviewers for example), can delete comments + + >>> browser_reviewer.open(urldoc1) + >>> form = browser_reviewer.getForm(name='delete', index=0) >>> '@@moderate-delete-comment' in form.action True @@ -406,58 +412,8 @@ We'll just catch that and check the result later. Returning to the document we find the deleted comment is indeed gone - >>> browser.open(urldoc1) - >>> comment_id in browser.contents - False - - -Deleting existing comments | 'delete own comments' ENABLED ----------------------------------------------------------- - -Enable deletion of own comments - - >>> from zope.component import queryUtility - >>> from plone.registry.interfaces import IRegistry - >>> from plone.app.discussion.interfaces import IDiscussionSettings - >>> registry = queryUtility(IRegistry) - >>> settings = registry.forInterface(IDiscussionSettings) - >>> settings.delete_own_comment_enabled = True - - >>> import transaction - >>> transaction.commit() - -Anonymous still cannot delete comments - - >>> unprivileged_browser.open(urldoc1) - >>> 'form.button.Delete' in unprivileged_browser.contents - False - -A member can now delete his own comments - - >>> browser_member.open(urldoc1) - >>> 'form.button.Delete' in browser_member.contents - True - - >>> form = browser_member.getForm(name='delete', index=0) - >>> '@@moderate-delete-comment' in form.action - True - - >>> comment_id = form.action.split('/')[-2] - -Submitting the form runs into a testbrowser notFoundException. -We'll just catch that and check the result later. - - >>> try: - ... form.submit() - ... except: - ... pass - -Returning to the document we find the deleted comment is indeed gone - - >>> browser_member.open(urldoc1) - >>> comment_id in browser_member.contents - False - >>> 'Comment from Jim' in browser_member.contents + >>> browser_reviewer.open(urldoc1) + >>> comment_id in browser_reviewer.contents False From f67f12bd05cba8863af4cb8eb381bc53eaf97d05 Mon Sep 17 00:00:00 2001 From: Andrea Cecchi Date: Mon, 3 Mar 2014 16:15:12 +0100 Subject: [PATCH 14/16] fixed upgrade step --- plone/app/discussion/configure.zcml | 6 +++--- plone/app/discussion/upgrades.py | 6 ++++++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/plone/app/discussion/configure.zcml b/plone/app/discussion/configure.zcml index d1d2675..66c5296 100644 --- a/plone/app/discussion/configure.zcml +++ b/plone/app/discussion/configure.zcml @@ -55,11 +55,11 @@ /> diff --git a/plone/app/discussion/upgrades.py b/plone/app/discussion/upgrades.py index bf9df71..652e060 100644 --- a/plone/app/discussion/upgrades.py +++ b/plone/app/discussion/upgrades.py @@ -2,7 +2,13 @@ from zope.component import getUtility from plone.registry.interfaces import IRegistry from plone.app.discussion.interfaces import IDiscussionSettings +default_profile = 'profile-plone.app.discussion:default' + def update_registry(context): registry = getUtility(IRegistry) registry.registerInterface(IDiscussionSettings) + + +def update_rolemap(context): + context.runImportStepFromProfile(default_profile, 'rolemap') From e453e0050b3502c9639bfeb0e2d722d82e933f26 Mon Sep 17 00:00:00 2001 From: Andrea Cecchi Date: Wed, 26 Mar 2014 18:07:28 +0100 Subject: [PATCH 15/16] fixed HISTORY and add delete test to test_comments_viewlet --- CHANGES.rst | 1 - .../discussion/tests/test_comments_viewlet.py | 60 ++++++++++++++++++- 2 files changed, 57 insertions(+), 4 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 181c1cf..55ef5bf 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,7 +1,6 @@ Changelog ========= - 2.3.3 (unreleased) ------------------ diff --git a/plone/app/discussion/tests/test_comments_viewlet.py b/plone/app/discussion/tests/test_comments_viewlet.py index 449efee..d020cd6 100644 --- a/plone/app/discussion/tests/test_comments_viewlet.py +++ b/plone/app/discussion/tests/test_comments_viewlet.py @@ -202,15 +202,69 @@ class TestCommentForm(unittest.TestCase): for comment in comments: self.assertEqual(comment.text, u"foobar") - self.assertEqual(comment.creator, "test-user") + self.assertEqual(comment.creator, "test_user_1_") self.assertEqual(comment.getOwner().getUserName(), "test-user") local_roles = comment.get_local_roles() - self.assertEqual(len(local_roles), 2) + self.assertEqual(len(local_roles), 1) userid, roles = local_roles[0] - self.assertEqual(userid, 'test-user') + self.assertEqual(userid, 'test_user_1_') self.assertEqual(len(roles), 1) self.assertEqual(roles[0], 'Owner') + def test_delete_comment(self): + """Delete a comment as logged-in user. + """ + + # Allow discussion + self.portal.doc1.allow_discussion = True + self.viewlet = CommentsViewlet(self.context, self.request, None, None) + + def make_request(form={}): + request = TestRequest() + request.form.update(form) + alsoProvides(request, IFormLayer) + alsoProvides(request, IAttributeAnnotatable) + return request + + provideAdapter( + adapts=(Interface, IBrowserRequest), + provides=Interface, + factory=CommentForm, + name=u"comment-form" + ) + + # The form is submitted successfully, if the required text field is + # filled out + form_request = make_request(form={'form.widgets.text': u'bar'}) + + commentForm = getMultiAdapter( + (self.context, form_request), + name=u"comment-form" + ) + + commentForm.update() + data, errors = commentForm.extractData() # pylint: disable-msg=W0612 + self.assertEqual(len(errors), 0) + self.assertFalse(commentForm.handleComment(commentForm, "foo")) + + # Delete the last comment + conversation = IConversation(self.context) + comment = [x for x in conversation.getComments()][-1] + deleteView = getMultiAdapter( + (comment, self.request), + name=u"moderate-delete-comment" + ) + # try to delete last comment without "Delete comments" permission + setRoles(self.portal, TEST_USER_ID, ['Member']) + self.assertRaises(Unauthorized, comment.restrictedTraverse, "@@moderate-delete-comment") + deleteView() + self.assertEqual(1, len([x for x in conversation.getComments()])) + # try to delete last comment with "Delete comments" permission + setRoles(self.portal, TEST_USER_ID, ['Reviewer']) + deleteView() + self.assertEqual(0, len([x for x in conversation.getComments()])) + setRoles(self.portal, TEST_USER_ID, ['Manager']) + def test_add_anonymous_comment(self): self.portal.doc1.allow_discussion = True From 4f255a355d97583513565da602561a2360e4c861 Mon Sep 17 00:00:00 2001 From: Andrea Cecchi Date: Wed, 26 Mar 2014 18:22:59 +0100 Subject: [PATCH 16/16] rebased CHANGES and tests --- plone/app/discussion/tests/test_comments_viewlet.py | 1 + 1 file changed, 1 insertion(+) diff --git a/plone/app/discussion/tests/test_comments_viewlet.py b/plone/app/discussion/tests/test_comments_viewlet.py index d020cd6..0aff711 100644 --- a/plone/app/discussion/tests/test_comments_viewlet.py +++ b/plone/app/discussion/tests/test_comments_viewlet.py @@ -203,6 +203,7 @@ class TestCommentForm(unittest.TestCase): for comment in comments: self.assertEqual(comment.text, u"foobar") self.assertEqual(comment.creator, "test_user_1_") + self.assertEqual(comment.getOwner().getUserName(), "test-user") local_roles = comment.get_local_roles() self.assertEqual(len(local_roles), 1)