Merge lp:~jcsackett/launchpad/spam-button-ui into lp:launchpad

Proposed by j.c.sackett
Status: Merged
Approved by: j.c.sackett
Approved revision: no longer in the source branch.
Merged at revision: 13004
Proposed branch: lp:~jcsackett/launchpad/spam-button-ui
Merge into: lp:launchpad
Diff against target: 498 lines (+337/-24)
8 files modified
lib/lp/answers/browser/question.py (+4/-0)
lib/lp/answers/browser/tests/test_questionmessages.py (+49/-20)
lib/lp/answers/javascript/question_spam.js (+73/-0)
lib/lp/answers/javascript/tests/test_question_spam.html (+80/-0)
lib/lp/answers/javascript/tests/test_question_spam.js (+93/-0)
lib/lp/answers/templates/question-index.pt (+11/-3)
lib/lp/answers/templates/questionmessage-display.pt (+17/-1)
lib/lp/testing/factory.py (+10/-0)
To merge this branch: bzr merge lp:~jcsackett/launchpad/spam-button-ui
Reviewer Review Type Date Requested Status
Gavin Panella (community) Approve
Review via email: mp+60072@code.launchpad.net

Commit message

[r=allenap][bug=220535] Adds ui to questions allowing the removal of spam.

Description of the change

Summary
=======
Wouldn't it be nice, when on maintenance rotation, if you could kill spam comments from the UI?

Yeah. This does that for questions.

Less glibly: Previous work added API methods to hide comments on bugs and questions. These could only be accessed via scripts or other means of posting against the API. This branch adds a control (visible to admins and registry experts only) to the footer of messages on questions that allows you to mark the given message as spam via AJAX.

A subsequent branch will add this capability to bugs; that branch, or yet another, will also clean up any duplication that may occur in so doing.

Preimplementation
=================
Curtis Hovey: Implementation and troubleshooting.
Aaron Bentley: Issues with setting up javascript tests.

Implementation
==============
lib/lp/testing/factory.py
-------------------------
Created "makeAdmin" function so you can grab an admin without using sampledata (you routinely end up with ~foo.bar or ~mark via sampledata, and they have super privs.

lib/lp/answers/browser/question.py
-----------------------------------------------------
Created view check to determine whether or not to show the spam controls.

lib/lp/answers/templates/question-index.pt
lib/lp/answers/templates/questionmessage-display.pt
---------------------------------------------------
Added spam controls and javascript hookup.

lib/lp/answers/javascript/question_spam.js
------------------------------------------
Added javascript function which toggles the control between "Mark as spam" and "Mark as not spam", as well as toggles the display as a hidden or not hidden comment and fires off the postback to the API to set the message to hidden.

lib/lp/answers/browser/tests/test_questionmessages.py
-----------------------------------------------------
Tests for view changes.

lib/lp/answers/javascript/tests/test_question_spam.html
lib/lp/answers/javascript/tests/test_question_spam.js
-----------------------------------------------------
Tests for javascript.

Tests
=====
bin/test -vvct test_questionmessages
firefox lib/lp/answers/javascript/tests/test_question_spam.html

QA
==
Open a question with comments while logged in as an admin or registry expert.
Mark a message as spam via the control.
The message will switch to the "hidden" display and the control will change to "Mark as not spam."
Reload the page; the message will still be thusly marked.
Open the same question as anonymous or a user without priveleges (easiest to do in another browser).
The message marked as spam will not display.

Open the question again logged in as admin or registry expert.
Mark the spam message as not spam.
The message will switch to the not hidden display and the control will change to "Mark as spam."
Reload the page; the message will still be marked as not spam.
Open the same question as anon or no privs user; the message will be displayed.

Screenshots
===========
http://people.canonical.com/~jc/images/dealing-with-spam.png

Lint
====
= Launchpad lint =

Checking for conflicts and issues in changed files.

Linting changed files:
  lib/lp/answers/javascript/
  lib/lp/answers/browser/question.py
  lib/lp/answers/browser/tests/test_questionmessages.py
  lib/lp/answers/javascript/question_spam.js
  lib/lp/answers/javascript/tests/
  lib/lp/answers/javascript/tests/test_question_spam.html
  lib/lp/answers/javascript/tests/test_question_spam.js
  lib/lp/answers/templates/question-index.pt
  lib/lp/answers/templates/questionmessage-display.pt
  lib/lp/testing/factory.py

To post a comment you must log in.
Revision history for this message
Gavin Panella (allenap) wrote :
Download full text (4.7 KiB)

This is an awesome feature, but I think the implementation needs a
little more work.

[1]

+ def canSeeSpamControls(self):
+ return check_permission('launchpad.Moderate', self.context.question)

This is going to be checked for every comment so consider making this
a cached property, or use an instance variable set in initialize()
instead.

[2]

+ <a tal:attributes="id string:mark-spam-${index};"
+ class="js-action sprite edit mark-spam" href="#">
+ <tal:not-spam condition="not: context/visible">
+ Mark as not spam
+ </tal:not-spam>
+ <tal:spam condition="context/visible">
+ Mark as spam
+ </tal:spam>
+ </a>

The text here is repeated in the Javascript:

+var spam_text = "Mark as spam";
+var not_spam_text = "Mark as not spam";

I don't mind if you leave it, but another pattern for doing this could
be:

    <a tal:attributes="id string:mark-spam-${index};"
       class="js-action sprite edit mark-spam" href="#">
         <tal:not-spam condition="not: context/visible">
           <span>Mark as not spam</span>
           <span class="unseen">Mark as spam</span>
         </tal:not-spam>
         <tal:spam condition="context/visible">
           <span class="unseen">Mark as not spam</span>
           <span>Mark as spam</span>
         </tal:spam>
    </a>

Then instead of changing the text you could change which span has the
unseen class:

    function update_text(link) {
        link.all("span").toggleClass("unseen");
    }

[3]

+ var config = {
+ on: {
+ success: function () {},
+ failure: function () {}
+ },

Fishy!

+function toggle_spam_setting(link) {
+ var comment = link.get('parentNode').get('parentNode');
+ var visible = comment.hasClass('adminHiddenComment');
+ var comment_number = parseInt(link.get('id').replace('mark-spam-', ''));
+ comment_number = comment_number - 1;
+ namespace._update_text(link);
+ comment.toggleClass(hidden_class);
+ namespace._set_visibility(comment_number, visible);
+}

The call to _update_text() and comment.toggleClass() should be done in
the success handler passed to named_post(). Also, they should not
toggle but set the UI according to the actual state in case the link
is clicked more than once in quick succession (so my suggestion in [2]
is not quite suitable).

Additionally there should probably be a Y.lazr.anim.red_flash() on
failure (as this is an admin tool it's probably safe to not worry
about a more sophisticated error handler for now), and a green_flash()
on success.

[4]

+namespace._update_text = update_text
...
+namespace._set_visibility = set_visibility

These are private functions and are not called outside of the module,
even for testing. You're probably better off not adding them to the
namespace.

[5]

+}, "0.1", {"requires": ["dom", "event-custom", "node", "lazr.effects",
+ "lp.client"]});

Are event-custom or lazr.effects used?

[6]

+function setup_spam_links() {
+ Y.on('click', function(e) {
+ e.halt()
+ var that = this;
+ namespace.toggle_spam_setting(that);
+ }, '.mark-spam');
+}

I couldn't find docs...

Read more...

review: Needs Fixing
Revision history for this message
j.c.sackett (jcsackett) wrote :
Download full text (5.1 KiB)

> This is an awesome feature, but I think the implementation needs a
> little more work.
>
>
> [1]
>
> + def canSeeSpamControls(self):
> + return check_permission('launchpad.Moderate', self.context.question)
>
> This is going to be checked for every comment so consider making this
> a cached property, or use an instance variable set in initialize()
> instead.

I've gone ahead and made it a cachedProperty. My understanding was that check_permission did some caching already, but I guess I was mistaken.

> [3]
>
> + var config = {
> + on: {
> + success: function () {},
> + failure: function () {}
> + },
>
> Fishy!

Agreed! That was a quick hack while I was exploring the problem. As you mention below, I meant to create the success handler to call the other functions.

> +function toggle_spam_setting(link) {
> + var comment = link.get('parentNode').get('parentNode');
> + var visible = comment.hasClass('adminHiddenComment');
> + var comment_number = parseInt(link.get('id').replace('mark-spam-', ''));
> + comment_number = comment_number - 1;
> + namespace._update_text(link);
> + comment.toggleClass(hidden_class);
> + namespace._set_visibility(comment_number, visible);
> +}
>
> The call to _update_text() and comment.toggleClass() should be done in
> the success handler passed to named_post(). Also, they should not
> toggle but set the UI according to the actual state in case the link
> is clicked more than once in quick succession (so my suggestion in [2]
> is not quite suitable).
>
> Additionally there should probably be a Y.lazr.anim.red_flash() on
> failure (as this is an admin tool it's probably safe to not worry
> about a more sophisticated error handler for now), and a green_flash()
> on success.

I agree on adding redflash to failure. Green flash on success has two problems, so I've elected not to add it. 1) There's already a visual indicator of success (the bg color of the comment changes), so I don't think further animation is needed. 2) b/c of the way the flash animation occurs, it interferes with the bg color change -- I've looked at the lazr.anim code, and I don't see a way to tell it to green flash to any color but #FFFFFF.

> [4]
>
> +namespace._update_text = update_text
> ...
> +namespace._set_visibility = set_visibility
>
> These are private functions and are not called outside of the module,
> even for testing. You're probably better off not adding them to the
> namespace.

Thanks! I wasn't sure what was appropriate here. I'll remove them from the namespace.

> [5]
>
> +}, "0.1", {"requires": ["dom", "event-custom", "node", "lazr.effects",
> + "lp.client"]});
>
> Are event-custom or lazr.effects used?

Nope, they've been removed. The perils of cargo culting.

> [6]
>
> +function setup_spam_links() {
> + Y.on('click', function(e) {
> + e.halt()
> + var that = this;
> + namespace.toggle_spam_setting(that);
> + }, '.mark-spam');
> +}
>
> I couldn't find docs for Y.on(). It's a little confusing because the
> selector at the end is easily overlooked. Consider the following:
>
> function setup_spam...

Read more...

Revision history for this message
Gavin Panella (allenap) wrote :

Looks great. Once more small thing, then land it :)

[9]

+ @with_celebrity_logged_in('admin')
+ def makeAdministrator(self, name=None, email=None, password=None):
+ from lp.testing.sampledata import ADMIN_EMAIL
+ if name = None:
+ name = self.getUniqueString()
+ if email = None:
+ email = '%s@%s.com' % (
+ self.getUniqueString(), self.getUniqueString())
+ if password = None:
+ password = self.getUniqueString()

I suspect you're doing more than you need to; makePerson() already
does this.

+ login(ADMIN_EMAIL)

This shouldn't be needed any more.

+ user = self.makePerson(name=name,
+ email=email,
+ password=password)
+ administrators = getUtility(ILaunchpadCelebrities).admin
+ administrators.addMember(user, administrators.teamowner)
+ return user

I /think/ the following should work:

    @with_celebrity_logged_in('admin')
    def makeAdministrator(self, name=None, email=None, password=None):
        user = self.makePerson(name=name,
                               email=email,
                               password=password)
        administrators = getUtility(ILaunchpadCelebrities).admin
        administrators.addMember(user, administrators.teamowner)
        return user

review: Approve
Revision history for this message
Robert Collins (lifeless) wrote :

I have only one thing to say.

\ /
 \ /
  \ /
   \ /
    o

(Yes, thats super-raised-hands)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/answers/browser/question.py'
2--- lib/lp/answers/browser/question.py 2011-04-28 15:09:58 +0000
3+++ lib/lp/answers/browser/question.py 2011-05-09 14:53:12 +0000
4@@ -1113,6 +1113,10 @@
5 else:
6 return "boardCommentBody"
7
8+ @cachedproperty
9+ def canSeeSpamControls(self):
10+ return check_permission('launchpad.Moderate', self.context.question)
11+
12 def getBoardCommentCSSClass(self):
13 css_classes = ["boardComment"]
14 if not self.context.visible:
15
16=== renamed file 'lib/lp/answers/browser/tests/test_questioncomment_visibility.py' => 'lib/lp/answers/browser/tests/test_questionmessages.py'
17--- lib/lp/answers/browser/tests/test_questioncomment_visibility.py 2011-04-26 15:44:42 +0000
18+++ lib/lp/answers/browser/tests/test_questionmessages.py 2011-05-09 14:53:12 +0000
19@@ -9,6 +9,7 @@
20 from zope.security.proxy import removeSecurityProxy
21
22 from canonical.launchpad.interfaces.launchpad import ILaunchpadCelebrities
23+from canonical.launchpad.testing.pages import find_tag_by_id
24 from canonical.testing.layers import DatabaseFunctionalLayer
25 from lp.testing import (
26 BrowserTestCase,
27@@ -20,7 +21,7 @@
28
29 layer = DatabaseFunctionalLayer
30
31- def _makeQuestionWithHiddenComment(self, questionbody=None):
32+ def makeQuestionWithHiddenComment(self, questionbody=None):
33 administrator = getUtility(ILaunchpadCelebrities).admin.teamowner
34 with person_logged_in(administrator):
35 question = self.factory.makeQuestion()
36@@ -28,38 +29,27 @@
37 removeSecurityProxy(comment).message.visible = False
38 return question
39
40- def _getUserForTest(self, team=None):
41- person = self.factory.makePerson()
42- if team is not None:
43- with person_logged_in(team.teamowner):
44- team.addMember(person, team.teamowner)
45- return person
46-
47 def test_admin_can_see_comments(self):
48 comment_text = "You can't see me."
49- question = self._makeQuestionWithHiddenComment(comment_text)
50- admin_team = getUtility(ILaunchpadCelebrities).admin
51- administrator = self._getUserForTest(admin_team)
52- view = self.getViewBrowser(
53- context=question, user=administrator)
54+ question = self.makeQuestionWithHiddenComment(comment_text)
55+ administrator = self.factory.makeAdministrator()
56+ view = self.getViewBrowser(context=question, user=administrator)
57 self.assertTrue(
58 comment_text in view.contents,
59 "Administrator cannot see the hidden comment.")
60
61 def test_registry_can_see_comments(self):
62 comment_text = "You can't see me."
63- question = self._makeQuestionWithHiddenComment(comment_text)
64- registry_team = getUtility(ILaunchpadCelebrities).registry_experts
65- registry_expert = self._getUserForTest(registry_team)
66- view = self.getViewBrowser(
67- context=question, user=registry_expert)
68+ question = self.makeQuestionWithHiddenComment(comment_text)
69+ registry_expert = self.factory.makeRegistryExpert()
70+ view = self.getViewBrowser(context=question, user=registry_expert)
71 self.assertTrue(
72 comment_text in view.contents,
73 "Registy member cannot see the hidden comment.")
74
75 def test_anon_cannot_see_comments(self):
76 comment_text = "You can't see me."
77- question = self._makeQuestionWithHiddenComment(comment_text)
78+ question = self.makeQuestionWithHiddenComment(comment_text)
79 view = self.getViewBrowser(context=question, no_login=True)
80 self.assertFalse(
81 comment_text in view.contents,
82@@ -67,8 +57,47 @@
83
84 def test_random_cannot_see_comments(self):
85 comment_text = "You can't see me."
86- question = self._makeQuestionWithHiddenComment(comment_text)
87+ question = self.makeQuestionWithHiddenComment(comment_text)
88 view = self.getViewBrowser(context=question)
89 self.assertFalse(
90 comment_text in view.contents,
91 "Random user can see the hidden comment.")
92+
93+
94+class TestQuestionSpamControls(BrowserTestCase):
95+
96+ layer = DatabaseFunctionalLayer
97+
98+ def makeQuestionWithMessage(self):
99+ administrator = getUtility(ILaunchpadCelebrities).admin.teamowner
100+ question = self.factory.makeQuestion()
101+ body = self.factory.getUniqueString()
102+ with person_logged_in(administrator):
103+ question.addComment(administrator, body)
104+ return question
105+
106+ def test_admin_sees_spam_control(self):
107+ question = self.makeQuestionWithMessage()
108+ administrator = self.factory.makeAdministrator()
109+ view = self.getViewBrowser(context=question, user=administrator)
110+ spam_link = find_tag_by_id(view.contents, 'mark-spam-1')
111+ self.assertIsNot(None, spam_link)
112+
113+ def test_registry_sees_spam_control(self):
114+ question = self.makeQuestionWithMessage()
115+ registry_expert = self.factory.makeRegistryExpert()
116+ view = self.getViewBrowser(context=question, user=registry_expert)
117+ spam_link = find_tag_by_id(view.contents, 'mark-spam-1')
118+ self.assertIsNot(None, spam_link)
119+
120+ def test_anon_doesnt_see_spam_control(self):
121+ question = self.makeQuestionWithMessage()
122+ view = self.getViewBrowser(context=question, no_login=True)
123+ spam_link = find_tag_by_id(view.contents, 'mark-spam-1')
124+ self.assertIs(None, spam_link)
125+
126+ def test_random_doesnt_see_spam_control(self):
127+ question = self.makeQuestionWithMessage()
128+ view = self.getViewBrowser(context=question)
129+ spam_link = find_tag_by_id(view.contents, 'mark-spam-1')
130+ self.assertIs(None, spam_link)
131
132=== added directory 'lib/lp/answers/javascript'
133=== added file 'lib/lp/answers/javascript/question_spam.js'
134--- lib/lp/answers/javascript/question_spam.js 1970-01-01 00:00:00 +0000
135+++ lib/lp/answers/javascript/question_spam.js 2011-05-09 14:53:12 +0000
136@@ -0,0 +1,73 @@
137+/* Copyright 2011 Canonical Ltd. This software is licensed under the
138+ * GNU Affero General Public License version 3 (see the file LICENSE).
139+ *
140+ * Animation for IBugTask:+subscribe LaunchpadForm.
141+ * Also used in "Edit subscription" advanced overlay.
142+ *
143+ * @namespace Y.lp.answers.question_spam
144+ * @requires dom, node, lazr.effects
145+ */
146+YUI.add('lp.answers.question_spam', function(Y) {
147+var namespace = Y.namespace('lp.answers.question_spam');
148+
149+var hidden_class = "adminHiddenComment";
150+var spam_text = "Mark as spam";
151+var not_spam_text = "Mark as not spam";
152+
153+function update_comment(link, comment) {
154+ var text = link.get('text').trim();
155+ if (text == spam_text) {
156+ comment.removeClass(hidden_class);
157+ link.set('text', not_spam_text);
158+ } else {
159+ comment.addClass(hidden_class);
160+ link.set('text', spam_text);
161+ }
162+}
163+
164+function set_visibility(parameters, callbacks) {
165+ var question = LP.cache.context
166+ var lp_client = new Y.lp.client.Launchpad()
167+ var config = {
168+ on: {
169+ success: callbacks.success,
170+ failure: callbacks.failure
171+ },
172+ parameters: parameters
173+ }
174+ lp_client.named_post(
175+ question.self_link, 'setCommentVisibility', config)
176+}
177+
178+function toggle_spam_setting(link) {
179+ var comment = link.get('parentNode').get('parentNode');
180+ var visible = comment.hasClass('adminHiddenComment');
181+ var comment_number = parseInt(link.get('id').replace('mark-spam-', ''));
182+ comment_number = comment_number - 1;
183+ parameters = {
184+ visible: visible,
185+ comment_number: comment_number
186+ };
187+ set_visibility(parameters, {
188+ // We use red flash on failure so admins no it didn't work.
189+ // There's no green flash on success, b/c the change in bg
190+ // color provides an immediate visual cue.
191+ success: function () {
192+ update_comment(link, comment);
193+ comment.toggleClass(hidden_class);
194+ },
195+ failure: function () {
196+ Y.lazr.anim.red_flash({node:comment});
197+ }
198+ });
199+}
200+namespace.toggle_spam_setting = toggle_spam_setting;
201+
202+function setup_spam_links() {
203+ Y.on('click', function(e) {
204+ e.halt();
205+ namespace.toggle_spam_setting(this);
206+ }, '.mark-spam');
207+}
208+namespace.setup_spam_links = setup_spam_links
209+}, "0.1", {"requires": ["dom", "node", "lazr.anim", "lp.client"]});
210
211=== added directory 'lib/lp/answers/javascript/tests'
212=== added file 'lib/lp/answers/javascript/tests/test_question_spam.html'
213--- lib/lp/answers/javascript/tests/test_question_spam.html 1970-01-01 00:00:00 +0000
214+++ lib/lp/answers/javascript/tests/test_question_spam.html 2011-05-09 14:53:12 +0000
215@@ -0,0 +1,80 @@
216+<html>
217+ <head>
218+ <title>Launchpad Question Spam Controls</title>
219+ <!-- YUI 3.0 Setup -->
220+ <script type="text/javascript" src="../../../../canonical/launchpad/icing/yui/yui/yui.js"></script>
221+ <script type="text/javascript"
222+ src="../../../../canonical/launchpad/icing/lazr/build/lazr.js"></script>
223+ <link rel="stylesheet"
224+ href="../../../../canonical/launchpad/icing/yui/cssreset/reset.css"/>
225+ <link rel="stylesheet"
226+ href="../../../../canonical/launchpad/icing/yui/cssfonts/fonts.css"/>
227+ <link rel="stylesheet"
228+ href="../../../../canonical/launchpad/icing/yui/cssbase/base.css"/>
229+ <link rel="stylesheet"
230+ href="../../../../canonical/launchpad/javascript/test.css" />
231+ <script type="text/javascript" src="../../../app/javascript/client.js"></script>
232+ <script type="text/javascript" src="../../../app/javascript/lp.js"></script>
233+
234+ <!-- Additional module dependency setup -->
235+ <script type="text/javascript">
236+ var LP = {
237+ cache: {
238+ context: {
239+ self_link: 'http:example.com'
240+ }
241+ }
242+ };
243+ </script>
244+
245+ <!-- The module under test -->
246+ <script type="text/javascript" src="../question_spam.js"></script>
247+
248+ <!-- The test suite -->
249+ <script type="text/javascript" src="test_question_spam.js"></script>
250+ </head>
251+ <body class="yui-skin-sam">
252+
253+ <!-- The example markup required by the script to run -->
254+ <div id="expected-id">
255+ <div class="boardComment">
256+ <div class="boardCommentDetails">
257+ <a href="#" class="sprite person">TestyMcTest</a>
258+ said
259+ <span title="">2 hours ago</span>:
260+ <span style="float:right;"> #1</span>
261+ </div>
262+
263+ <div class="boardCommentBody"><p>Foo bar baz.</p></div>
264+
265+ <div class="boardCommentFooter">
266+ <a id="mark-spam-1" class="js-action sprite edit" href="#">
267+ Mark as spam
268+ </a>
269+
270+ </div>
271+ </div>
272+
273+ <div id="hidden-comment" class="boardComment adminHiddenComment">
274+ <div class="boardCommentDetails">
275+ <a href="#" class="sprite person">TestyMcTest</a>
276+ said
277+ <span title="">1 hours ago</span>:
278+ <span style="float:right;"> #2</span>
279+ </div>
280+
281+ <div class="boardCommentBody"><p>Click here for a diploma!</p></div>
282+
283+ <div class="boardCommentFooter">
284+ <a id="mark-spam-2" class="js-action sprite edit" href="#">
285+ Mark as not spam
286+ </a>
287+
288+ </div>
289+ </div>
290+ </div>
291+
292+ <!-- The test output -->
293+ <div id="log"></div>
294+ </body>
295+</html>
296
297=== added file 'lib/lp/answers/javascript/tests/test_question_spam.js'
298--- lib/lp/answers/javascript/tests/test_question_spam.js 1970-01-01 00:00:00 +0000
299+++ lib/lp/answers/javascript/tests/test_question_spam.js 2011-05-09 14:53:12 +0000
300@@ -0,0 +1,93 @@
301+/* Copyright 2011 Canonical Ltd. This software is licensed under the
302+ * GNU Affero General Public License version 3 (see the file LICENSE).
303+ */
304+
305+YUI({
306+ base: '../../../../canonical/launchpad/icing/yui/',
307+ filter: 'raw', combine: false,
308+ fetchCSS: false,
309+ }).use('test', 'console', 'node', 'node-event-simulate',
310+ 'lp.answers.question_spam', function(Y) {
311+
312+ var suite = new Y.Test.Suite("lp.answers.question_spam Tests");
313+
314+ suite.add(new Y.Test.Case({
315+ name: 'question_spam',
316+
317+ setUp: function() {
318+ // Monkeypatch LP to avoid network traffic and to allow
319+ // insertion of test data.
320+ window.LP = {
321+ links: {},
322+ cache: {}
323+ };
324+ Y.lp.client.Launchpad = function() {};
325+ Y.lp.client.Launchpad.prototype.named_post =
326+ function(url, func, config) {
327+ LP.cache.call_data = {
328+ called_url: url,
329+ called_func: func,
330+ called_config: config
331+ }
332+ // our setup assumes success, so we just do the
333+ // success callback.
334+ config.on.success();
335+ };
336+ LP.cache.context = {
337+ self_link: 'https://launchpad.dev/api/devel/questions/fake'
338+ };
339+ },
340+
341+ test_mark_as_spam: function () {
342+ link = Y.one('#mark-spam-1');
343+ comment = Y.one('.boardComment');
344+ Y.lp.answers.question_spam.toggle_spam_setting(link);
345+ Y.Assert.isTrue(comment.hasClass('adminHiddenComment'));
346+ Y.Assert.areEqual('Mark as not spam', link.get('text'),
347+ 'Link text should be \'Mark as not spam\'');
348+ Y.Assert.areEqual(
349+ 'https://launchpad.dev/api/devel/questions/fake',
350+ LP.cache.call_data.called_url, 'Call with wrong url.');
351+ Y.Assert.areEqual(
352+ 'setCommentVisibility', LP.cache.call_data.called_func,
353+ 'Call with wrong func.');
354+ Y.Assert.isFalse(
355+ LP.cache.call_data.called_config.parameters.visible);
356+ Y.Assert.areEqual(
357+ 0, LP.cache.call_data.called_config.parameters.comment_number,
358+ 'Called with wrong wrong comment number.')
359+ },
360+
361+ test_mark_as_not_spam: function () {
362+ link = Y.one('#mark-spam-2');
363+ comment = Y.one('#hidden-comment');
364+ Y.lp.answers.question_spam.toggle_spam_setting(link);
365+ Y.Assert.isFalse(comment.hasClass('adminHiddenComment'));
366+ Y.Assert.areEqual('Mark as spam', link.get('text'),
367+ 'Link text should be \'Mark as spam\'')
368+ Y.Assert.areEqual(
369+ 'https://launchpad.dev/api/devel/questions/fake',
370+ LP.cache.call_data.called_url, 'Call with wrong url.');
371+ Y.Assert.areEqual(
372+ 'setCommentVisibility', LP.cache.call_data.called_func,
373+ 'Call with wrong func.');
374+ Y.Assert.isTrue(
375+ LP.cache.call_data.called_config.parameters.visible);
376+ Y.Assert.areEqual(
377+ 1, LP.cache.call_data.called_config.parameters.comment_number,
378+ 'Called with wrong wrong comment number.')
379+ },
380+
381+ }));
382+
383+ // Lock, stock, and two smoking barrels.
384+ Y.Test.Runner.add(suite);
385+
386+ var console = new Y.Console({newestOnTop: false});
387+ console.render('#log');
388+
389+ Y.on('domready', function() {
390+ Y.Test.Runner.run();
391+ });
392+});
393+
394
395=== modified file 'lib/lp/answers/templates/question-index.pt'
396--- lib/lp/answers/templates/question-index.pt 2011-04-25 22:12:39 +0000
397+++ lib/lp/answers/templates/question-index.pt 2011-05-09 14:53:12 +0000
398@@ -6,8 +6,8 @@
399 i18n:domain="launchpad"
400 >
401 <head>
402- <style metal:fill-slot="head_epilogue"
403- type="text/css" media="screen">
404+ <metal:block fill-slot="head_epilogue">
405+ <style type="text/css" media="screen">
406 div.confirmBox {
407 margin: 0;
408 padding-right: 0.5em;
409@@ -16,6 +16,15 @@
410 font-size: smaller;
411 }
412 </style>
413+ <script type="text/javascript">
414+ LPS.use('base', 'node', 'event',
415+ 'lp.answers.question_spam', function(Y) {
416+ Y.on('domready', function() {
417+ Y.lp.answers.question_spam.setup_spam_links();
418+ });
419+ });
420+ </script>
421+ </metal:block>
422 </head>
423 <body>
424 <metal:registering fill-slot="registering">
425@@ -90,7 +99,6 @@
426 </div>
427 </div>
428
429-
430 <tal:message repeat="message view/visible_messages">
431 <div tal:replace="structure message/@@+display" />
432 </tal:message>
433
434=== modified file 'lib/lp/answers/templates/questionmessage-display.pt'
435--- lib/lp/answers/templates/questionmessage-display.pt 2011-04-27 18:57:39 +0000
436+++ lib/lp/answers/templates/questionmessage-display.pt 2011-05-09 14:53:12 +0000
437@@ -1,10 +1,12 @@
438 <tal:root
439 xmlns:tal="http://xml.zope.org/namespaces/tal"
440 xmlns:metal="http://xml.zope.org/namespaces/metal"
441+ define="index context/index"
442 omit-tag="">
443 <div
444 tal:define="css_classes view/getBoardCommentCSSClass"
445- tal:attributes="class string:${css_classes}">
446+ tal:attributes="class string:${css_classes};
447+ id string:comment-${index}">
448 <div class="boardCommentDetails">
449 <tal:bestanswer condition="view/isBestAnswer">
450 <img src="/@@/favourite-yes" style="float:right;" alt="Best"
451@@ -41,5 +43,19 @@
452 value="This Solved My Problem" />
453 </form>
454 </div>
455+
456+ <div
457+ tal:condition="view/canSeeSpamControls"
458+ class="boardCommentFooter">
459+ <a tal:attributes="id string:mark-spam-${index};"
460+ class="js-action sprite edit mark-spam" href="#">
461+ <tal:not-spam condition="not: context/visible">
462+ Mark as not spam
463+ </tal:not-spam>
464+ <tal:spam condition="context/visible">
465+ Mark as spam
466+ </tal:spam>
467+ </a>
468+ </div>
469 </div>
470 </tal:root>
471
472=== modified file 'lib/lp/testing/factory.py'
473--- lib/lp/testing/factory.py 2011-05-03 04:39:43 +0000
474+++ lib/lp/testing/factory.py 2011-05-09 14:53:12 +0000
475@@ -285,6 +285,7 @@
476 run_with_login,
477 temp_dir,
478 time_counter,
479+ with_celebrity_logged_in,
480 )
481 from lp.translations.enums import RosettaImportStatus
482 from lp.translations.interfaces.potemplate import IPOTemplateSet
483@@ -486,6 +487,15 @@
484 login_as(person)
485 return person
486
487+ @with_celebrity_logged_in('admin')
488+ def makeAdministrator(self, name=None, email=None, password=None):
489+ user = self.makePerson(name=name,
490+ email=email,
491+ password=password)
492+ administrators = getUtility(ILaunchpadCelebrities).admin
493+ administrators.addMember(user, administrators.teamowner)
494+ return user
495+
496 def makeRegistryExpert(self, name=None, email='expert@example.com',
497 password='test'):
498 from lp.testing.sampledata import ADMIN_EMAIL