Merge lp:~wallyworld/launchpad/admins-can-unsubscribe-bugs-2 into lp:launchpad

Proposed by Ian Booth
Status: Merged
Approved by: Данило Шеган
Approved revision: no longer in the source branch.
Merged at revision: 13344
Proposed branch: lp:~wallyworld/launchpad/admins-can-unsubscribe-bugs-2
Merge into: lp:launchpad
Diff against target: 1175 lines (+107/-914)
9 files modified
lib/canonical/launchpad/windmill/jstests/initialize.js (+0/-164)
lib/canonical/launchpad/windmill/jstests/launchpad_ajax.js (+0/-652)
lib/canonical/launchpad/windmill/jstests/login.js (+0/-32)
lib/lp/bugs/browser/bugsubscription.py (+1/-1)
lib/lp/bugs/browser/tests/test_bugsubscription_views.py (+64/-0)
lib/lp/bugs/javascript/subscribers_list.js (+2/-39)
lib/lp/bugs/javascript/tests/test_subscribers_list.js (+28/-14)
lib/lp/bugs/stories/bug-privacy/05-set-bug-private-as-admin.txt (+3/-3)
lib/lp/bugs/stories/bugs/bug-add-subscriber.txt (+9/-9)
To merge this branch: bzr merge lp:~wallyworld/launchpad/admins-can-unsubscribe-bugs-2
Reviewer Review Type Date Requested Status
Данило Шеган (community) Approve
Review via email: mp+66309@code.launchpad.net

Commit message

[r=danilo][bug=633,134577] Allow a person to unsubscribe from a bug those people/teams who they have subscribed.

Description of the change

This branch provides the client side code to allow someone to unsubscribe from a bug a person/team they have subscribed. A previous branch implemented the functionality on the model. This branch uses that to provide the unsubscribe link when appropriate.

== Implementation ==

The BugPortletSubscribersWithDetails view was changed to call BugSubscription.canBeUnsubscribedByUser() to see if the logged in user can unsubscribe someone.
The subscribers_list javascript was changed to always add the unsubscribe link when a subscription request is completed successfully.

== Demo and QA ==

Subscribe someone to a bug - the unsubscribe icon should be rendered in the subscription list to allow you to unsubscribe them.

== Tests ==

Added tests to BugPortletSubscribersWithDetailsTests:
test_data_subscription_lp_admin
test_data_person_subscription_subscriber

Modify the test_subscribeSomeoneElse_success test in test_subscribers_list.js test

== Lint ==

Checking for conflicts and issues in changed files.

Linting changed files:
  lib/lp/bugs/browser/bugsubscription.py
  lib/lp/bugs/browser/tests/test_bugsubscription_views.py
  lib/lp/bugs/javascript/subscribers_list.js
  lib/lp/bugs/javascript/tests/test_subscribers_list.js

To post a comment you must log in.
Revision history for this message
Данило Шеган (danilo) wrote :

Looks great, Ian, thanks for fixing this!

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== removed directory 'lib/canonical/launchpad/windmill'
=== removed directory 'lib/canonical/launchpad/windmill/jstests'
=== removed file 'lib/canonical/launchpad/windmill/jstests/initialize.js'
--- lib/canonical/launchpad/windmill/jstests/initialize.js 2010-11-16 18:54:59 +0000
+++ lib/canonical/launchpad/windmill/jstests/initialize.js 1970-01-01 00:00:00 +0000
@@ -1,164 +0,0 @@
1/*
2Copyright 2009 Canonical Ltd. This software is licensed under the
3GNU Affero General Public License version 3 (see the file LICENSE).
4
5Contains the JS fixtures we use to test AJAX call in windmill.
6
7Since AJAX calls will complete asynchronously, we need to use a
8synchronisation mechanism.
9
10 */
11
12
13/* A synchronized test.
14 * @param name The name of the test, the DOM node used for synchronization
15 * will use that name.
16 *
17 * @param body This is a list of functions or strings or windmill action
18 * object. That makes this test body.
19 *
20 * @param debug (Optional) If this is set to true, tracing information
21 * will be logged on the Firebug console.
22 */
23function SynchronizedTest(name, body, debug) {
24 this.name = name;
25 this.debug = !!debug;
26 this.synced = false;
27
28 /* Create the test body. */
29 this.test_body = [];
30 this.add_windmill_actions(this.test_body, body);
31
32 /*
33 * The tear down is also as an array, so that many cleanup
34 * actions can be tacked to it using add_cleanups()
35 */
36 // Windmill doesn't like empty array teardown. So we define it as an
37 // empty object, which gets replaced by an array when add_cleanups is
38 // called.
39 //this.teardown = [];
40 this.teardown = function () {};
41}
42
43
44/* The YUI instance used by SynchronizedTest. */
45SynchronizedTest.prototype.Y = YUI({
46 bootstrap: false,
47 fetchCSS: false,
48 combine: false,
49 timeout: 50
50 }).use('dump');
51
52/* Create the synchronization node, that should make the wait() caller
53 * return.
54 *
55 * @param result This object will be available from the result attribute.
56 * This can be used to provide information passed to the callback
57 * and that we want to make assertion on.
58 */
59SynchronizedTest.prototype.sync = function (result) {
60 this.log('sync() called with ' + this.Y.dump(result));
61 this.result = result;
62 this.synced = true;
63};
64
65
66/* Convert a sequence of test items and adds them to a windmill array
67 * test specification.
68 *
69 * That method is used by the constructor and add_cleanups method.
70 */
71SynchronizedTest.prototype.add_windmill_actions = function (list, items) {
72 var test = this;
73
74 /* Windmill invokes all test functions without setting the this parameter.
75 * So we create wrapper that will pass it to our functions.
76 */
77 function create_test_wrapper (func) {
78 return function () { func(test); };
79 }
80
81 for (var i=0; i< items.length; i++) {
82 var test_item = items[i];
83 var yui_lang = this.Y.Lang;
84 if (yui_lang.isFunction(test_item)) {
85 //Create a wrapper that passes the test as first parameter.
86 list.push(create_test_wrapper(test_item));
87 } else if (yui_lang.isString(test_item)) {
88 //This calls a method on the test. And sticks the result
89 //in the test body. Common use case is to use 'wait_action' to
90 //add a windmill wait action for the synchronizing condition.
91 var action = test[test_item].call(test);
92 list.push(action);
93 } else if (yui_lang.isObject(test_item)) {
94 //We expect this to be a Windmill action.
95 list.push(test_item);
96 } else {
97 throw new Error(
98 'Unknown test predicate: ' + this.Y.dump(test_item));
99 }
100 }
101};
102
103/* Add functions/actions to the "teardown" test.
104 *
105 * @param cleanups An array of functions, or strings or objects representing
106 * windmill actions. (Like in the test body parameter.)
107 */
108SynchronizedTest.prototype.add_cleanups = function (cleanups) {
109 if (this.Y.Lang.isFunction(this.teardown)) {
110 this.teardown = [];
111 }
112 this.add_windmill_actions(this.teardown, cleanups);
113};
114
115/* Return a windmill action that can be used to wait for
116 * the synchronization to happen.
117 */
118SynchronizedTest.prototype.wait_action = function () {
119 var test = this;
120 return {
121 method: "waits.forJS",
122 params: {
123 //The function waits for the synced attribute to be set to true;
124 //and then resets it so that next synchronization action work.
125 js: function () {
126 if (test.synced) {
127 test.synced = false;
128 return true;
129 } else {
130 return false;
131 }
132 },
133 timeout: 8000
134 }
135 };
136};
137
138
139/* Output a log message when debug is turned on.
140 */
141SynchronizedTest.prototype.log = function (message) {
142 if (this.debug && console) {
143 console.log(this.name + ': ' + message);
144 }
145};
146
147
148/* Return a configuration object that can be used as a on
149 * specification to YUI.io.
150 *
151 * It basically will call the test sync method, and save the
152 * name of the handler called, and the arguments list.
153 */
154SynchronizedTest.prototype.create_yui_sync_on = function () {
155 var test = this;
156 return {
157 success: function () {
158 test.sync({callback: 'success', args: test.Y.Array(arguments)});
159 },
160 failure: function () {
161 test.sync({callback: 'failure', args: test.Y.Array(arguments)});
162 }
163 };
164};
1650
=== removed file 'lib/canonical/launchpad/windmill/jstests/launchpad_ajax.js'
--- lib/canonical/launchpad/windmill/jstests/launchpad_ajax.js 2011-02-24 22:43:20 +0000
+++ lib/canonical/launchpad/windmill/jstests/launchpad_ajax.js 1970-01-01 00:00:00 +0000
@@ -1,652 +0,0 @@
1// Unit testing framework for the Launchpad AJAX test suite.
2var client = new LP.client.Launchpad();
3
4windmill.jsTest.require('login.js');
5
6
7// Test access to prepopulated data.
8test_prepopulated_data_anonymous = new SynchronizedTest(
9 'test_prepopulated_data_anonymous', [
10 {"params": {"url": "/firefox/+bug/1"}, "method": "open"},
11 {"params": {}, "method": "waits.forPageLoad"},
12 // Wait until one of the last element on the page is available.
13 {"params": {"xpath": "//div[@id='globalfooter']"},
14 "method": "waits.forElement"},
15 function (test) {
16 jum.assertUndefined(LP.links.me);
17 jum.assertUndefined(LP.cache.context);
18 }
19 ]);
20
21test_logged_in_as_foo_bar.test_prepopulated_data = new SynchronizedTest(
22 'test_prepopulated_data', [
23 {"params": {"url": "/firefox/+bug/1"}, "method": "open"},
24 {"params": {}, "method": "waits.forPageLoad"},
25 // Wait until one of the last element on the page is available.
26 {"params": {"xpath": "//div[@id='globalfooter']"},
27 "method": "waits.forElement"},
28 function (test) {
29 // If the user is logged in, the link to their user account
30 // will be in the link cache.
31 jum.assertEquals(LP.links.me, '/~name16');
32
33 // If the view has a context, the context will be in the object
34 // cache.
35 jum.assertNotUndefined(LP.cache.context);
36 var context = LP.cache.context;
37 jum.assertNotEquals(context.self_link.indexOf(
38 "/api/devel/firefox/+bug/1"), -1);
39 jum.assertNotEquals(context.resource_type_link.indexOf(
40 "/api/devel/#bug_task"), -1);
41 jum.assertNotEquals(context.owner_link.indexOf(
42 "/api/devel/~name12"), -1);
43 jum.assertNotEquals(context.related_tasks_collection_link.indexOf(
44 "/api/devel/firefox/+bug/1/related_tasks"), -1);
45
46 // Specific views may add additional objects to the object cache
47 // or links to the link cache.
48 var bug = LP.cache.bug;
49 jum.assertNotUndefined(LP.cache.bug);
50 jum.assertNotEquals(bug.self_link.indexOf("/api/devel/bugs/1"), -1);
51 }
52 ]);
53
54// Test that making a web service call doesn't modify the callback
55// methods you pass in.
56var test_callback_safety = function() {
57 var success_callback = function() {};
58 var config = {on: {success: success_callback}};
59
60 // Test a GET.
61 client.get("/people", config);
62 jum.assertTrue(success_callback === config.on.success);
63
64 // Test a named POST.
65 name = "callback-safety-test" + new Date().getTime();
66 config.parameters = {display_name: 'My new team', name: name};
67 client.named_post("/people", "newTeam", config);
68 jum.assertTrue(success_callback === config.on.success);
69};
70
71
72var test_normalize_uri = function() {
73 var normalize = LP.client.normalize_uri;
74 jum.assertEquals(normalize("http://www.example.com/api/devel/foo"),
75 "/api/devel/foo");
76 jum.assertEquals(normalize("http://www.example.com/foo/bar"), "/foo/bar");
77 jum.assertEquals(normalize("/foo/bar"), "/api/devel/foo/bar");
78 jum.assertEquals(normalize("/api/devel/foo/bar"), "/api/devel/foo/bar");
79 jum.assertEquals(normalize("foo/bar"), "/api/devel/foo/bar");
80 jum.assertEquals(normalize("api/devel/foo/bar"), "/api/devel/foo/bar");
81};
82
83var test_append_qs = function() {
84 var qs = "";
85 qs = LP.client.append_qs(qs, "Pöllä", "Perelló");
86 jum.assertEquals("P%C3%B6ll%C3%A4=Perell%C3%B3", qs);
87};
88
89var test_field_uri = function() {
90 jum.assertEquals(LP.client.get_field_uri("http://www.example.com/api/devel/foo", "field"),
91 "/api/devel/foo/field");
92 jum.assertEquals(LP.client.get_field_uri("/no/slash", "field"),
93 "/api/devel/no/slash/field");
94 jum.assertEquals(LP.client.get_field_uri("/has/slash/", "field"),
95 "/api/devel/has/slash/field");
96};
97
98// Test that retrieving a non-existent resource uses the failure handler.
99var test_no_such_url = new SynchronizedTest('test_no_such_url', [
100 function (test) {
101 client.get("no-such-url", {on: test.create_yui_sync_on()});
102 },
103 'wait_action',
104 function (test) {
105 jum.assertEquals('failure', test.result.callback);
106 }
107 ]);
108
109
110//Check that retrieving the root by relative URL returns the root entry.
111var test_relative_url = new SynchronizedTest('test_relative_url', [
112 function (test) {
113 client.get("", {on: test.create_yui_sync_on()});
114 },
115 'wait_action',
116 function (test) {
117 jum.assertEquals('success', test.result.callback);
118 var launchpad = test.result.args[0];
119 jum.assertTrue(launchpad instanceof LP.client.Root);
120 jum.assertNotNull(
121 launchpad.people_collection_link.match(/people$/));
122 }
123 ]);
124
125
126//Check that retrieving the root by absolute URL returns the root entry.
127var test_absolute_url = new SynchronizedTest('test_absolute_url', [
128 function (test) {
129 client.get("", {on: test.create_yui_sync_on()});
130 },
131 'wait_action',
132 function (test) {
133 jum.assertEquals('success', test.result.callback);
134 var launchpad = test.result.args[0];
135 jum.assertTrue(launchpad instanceof LP.client.Root);
136 jum.assertNotNull(
137 launchpad.people_collection_link.match(/people$/));
138 }
139 ]);
140
141
142// Check that collection resources are paginated.
143var test_pagination_collection = new SynchronizedTest(
144 'test_pagination_collection', [
145 function (test) {
146 client.get("/people", {on: test.create_yui_sync_on(),
147 start: 2, size: 1});
148 },
149 'wait_action',
150 function (test) {
151 jum.assertEquals('success', test.result.callback);
152 var people = test.result.args[0];
153 jum.assertEquals(2, people.start);
154 jum.assertEquals(1, people.entries.length);
155 }
156 ]);
157
158
159// Check invoking a read-only name operation on a resource URI.
160// Make sure it's possible to invoke named operations on URIs.
161var test_named_get = new SynchronizedTest('test_named_get', [
162 function (test) {
163 client.named_get(
164 "people/", "find", {on: test.create_yui_sync_on(),
165 parameters: {text:"salgado"}});
166 },
167 'wait_action',
168 function (test) {
169 jum.assertEquals('success', test.result.callback);
170 var collection = test.result.args[0];
171 jum.assertTrue(collection instanceof LP.client.Collection);
172 jum.assertEquals(1, collection.total_size);
173 }
174 ]);
175
176
177
178var test_named_get_returning_json = new SynchronizedTest(
179 'test_named_get_returning_json', [
180 // Make sure a named GET that returns a JSON data structure works.
181 function (test) {
182 client.named_get("ubuntu/+archive/primary", "getBuildCounters",
183 {on: test.create_yui_sync_on()});
184 },
185 'wait_action',
186 function (test) {
187 jum.assertEquals('success', test.result.callback);
188 var structure = test.result.args[0];
189 jum.assertEquals(structure.failed, 5);
190 }
191 ]);
192
193
194// Check the invocation of a named POST operation.
195test_logged_in_as_foo_bar.test_named_post = new SynchronizedTest(
196 'test_named_post', [
197 function (test) {
198 test.bugtask_uri = '/redfish/+bug/15';
199 client.named_post(
200 test.bugtask_uri, 'transitionToStatus',
201 {on: test.create_yui_sync_on(),
202 parameters: {status: 'Confirmed'}});
203 },
204 'wait_action',
205 function (test) {
206 jum.assertEquals('success', test.result.callback);
207 test.add_cleanups([
208 function (test) {
209 client.named_post(
210 test.bugtask_uri, 'transitionToStatus',
211 {on: test.create_yui_sync_on(),
212 parameters: {'status': 'New'}});
213 },
214 'wait_action']);
215 // Get the bugtask and make sure its status has changed.
216 client.get(test.bugtask_uri, {on: test.create_yui_sync_on()});
217 },
218 'wait_action',
219 function(test) {
220 jum.assertEquals('success', test.result.callback);
221 var bugtask = test.result.args[0];
222 jum.assertEquals(bugtask.get('status'), 'Confirmed');
223 }
224 ]);
225
226//Test that follow_link return the resource at the end of the link.
227// Retrieve the launchpad root and check the people link is a collection.
228var test_follow_link = new SynchronizedTest('test_follow_link', [
229 function (test) {
230 client.get("", {on: test.create_yui_sync_on()});
231 },
232 'wait_action',
233 function (test) {
234 jum.assertEquals('success', test.result.callback);
235 var root_object = test.result.args[0];
236 root_object.follow_link('people', {on: test.create_yui_sync_on()});
237 },
238 'wait_action',
239 function (test) {
240 jum.assertEquals('success', test.result.callback);
241 var people = test.result.args[0];
242 jum.assertTrue(people instanceof LP.client.Collection);
243 jum.assertEquals(4, people.total_size);
244 }
245 ]);
246
247//Test that follow_link follows through redirect.
248// Retrieve the launchpad root and check the people
249test_logged_in_as_foo_bar.test_follow_link_through_redirect =
250 new SynchronizedTest(
251 'test_follow_link_through_redirect', [
252 function (test) {
253 client.get("", {on: test.create_yui_sync_on()});
254 },
255 'wait_action',
256 function (test) {
257 jum.assertEquals('success', test.result.callback);
258 var root_object = test.result.args[0];
259 root_object.follow_link('me', {on: test.create_yui_sync_on()});
260 },
261 'wait_action',
262 function (test) {
263 jum.assertEquals('success', test.result.callback);
264 var me = test.result.args[0];
265 jum.assertTrue(me instanceof LP.client.Entry);
266 jum.assertEquals('name16', me.get('name'));
267 }
268 ]);
269
270
271//Test that retrieving an entry resource yield an Entry object.
272var test_entry_get = new SynchronizedTest('test_entry_get', [
273 function (test) {
274 client.get('~salgado', {on: test.create_yui_sync_on()});
275 },
276 'wait_action',
277 function (test) {
278 jum.assertEquals('success', test.result.callback);
279 var salgado = test.result.args[0];
280 jum.assertTrue(salgado instanceof LP.client.Entry);
281 jum.assertEquals("salgado", salgado.get('name'));
282 }
283 ]);
284
285// Test that retrieving an HTML representation of an entry yields an
286// HTML snippet.
287var test_entry_html_get = new SynchronizedTest('test_entry_html_get', [
288 function (test) {
289 client.get('~salgado', {on: test.create_yui_sync_on(),
290 accept: LP.client.XHTML});
291 },
292 'wait_action',
293 function (test) {
294 jum.assertEquals('success', test.result.callback);
295 var salgado_html = test.result.args[0];
296 jum.assertNotEquals(salgado_html.indexOf("<dl"), -1);
297 }
298 ]);
299
300
301// Test that it's possible to request an HTML representation of
302// an object when updating it.
303test_logged_in_as_foo_bar.test_html_entry_lp_save = new SynchronizedTest(
304 'test_entry_lp_save', [
305 function (test) {
306 client.get('~salgado', {on: test.create_yui_sync_on()});
307 },
308 'wait_action',
309 function (test) {
310 jum.assertEquals('success', test.result.callback);
311 var salgado = test.result.args[0];
312 salgado.lp_save({on: test.create_yui_sync_on(),
313 accept: LP.client.XHTML});
314 },
315 'wait_action',
316 function (test) {
317 jum.assertEquals('success', test.result.callback);
318 var salgado_html = test.result.args[0];
319 jum.assertNotEquals(salgado_html.indexOf("<dl"), -1);
320
321 // Now test the patch() method directly.
322 client.patch('~salgado', {},
323 {on: test.create_yui_sync_on(),
324 accept: LP.client.XHTML});
325 },
326 'wait_action',
327 function (test) {
328 jum.assertEquals('success', test.result.callback);
329 var salgado_html = test.result.args[0];
330 jum.assertNotEquals(salgado_html.indexOf("<dl"), -1);
331
332 // Now test the patch() method on a field resource.
333 var field_uri = LP.client.get_field_uri('~salgado', 'display_name');
334 client.patch(field_uri, 'Guilherme Salgado 2',
335 {on: test.create_yui_sync_on(),
336 accept: LP.client.XHTML});
337 },
338 'wait_action',
339 function (test) {
340 var field_uri = LP.client.get_field_uri('~salgado', 'display_name');
341 jum.assertEquals('success', test.result.callback);
342 var salgado_name_html = test.result.args[0];
343 jum.assertEquals(salgado_name_html, "Guilherme Salgado 2");
344
345 // Now make sure patch() on a field resource works when we
346 // request a JSON representation in return.
347 field_uri = LP.client.get_field_uri('~salgado', 'display_name');
348 client.patch(field_uri, 'Guilherme Salgado',
349 {on: test.create_yui_sync_on()});
350 },
351 'wait_action',
352 function (test) {
353 var field_uri = LP.client.get_field_uri('~salgado', 'display_name');
354 jum.assertEquals('success', test.result.callback);
355 var salgado_name_html = test.result.args[0];
356 jum.assertEquals(salgado_name_html, "Guilherme Salgado");
357 }
358]);
359
360//Test that modifying an entry and then calling lp_save() saves the
361//entry on the server.
362test_logged_in_as_foo_bar.test_entry_lp_save = new SynchronizedTest(
363 'test_entry_lp_save', [
364 function (test) {
365 client.get('~salgado', {on: test.create_yui_sync_on()});
366 },
367 'wait_action',
368 function (test) {
369 jum.assertEquals('success', test.result.callback);
370 var salgado = test.result.args[0];
371 test.original_display_name = salgado.get('display_name');
372 salgado.set('display_name', '<b>A new display name</b>');
373 salgado.lp_save({on: test.create_yui_sync_on()});
374 },
375 'wait_action',
376 function (test) {
377 jum.assertEquals('success', test.result.callback);
378 // Make sure that the save operation returned a new version of
379 // the object.
380 var new_salgado = test.result.args[0];
381 jum.assertEquals(new_salgado.get('display_name'),
382 '<b>A new display name</b>');
383
384 test.add_cleanups([
385 function (test) {
386 client.get('~salgado', {on: test.create_yui_sync_on()});
387 },
388 'wait_action',
389 function (test) {
390 jum.assertEquals('success', test.result.callback);
391 var salgado = test.result.args[0];
392 salgado.set('display_name', test.original_display_name);
393 salgado.lp_save({on: test.create_yui_sync_on()});
394 jum.assertEquals(salgado.dirty_attributes.length, 0);
395 },
396 'wait_action']);
397 client.get('~salgado', {on: test.create_yui_sync_on()});
398 },
399 'wait_action',
400 function (test) {
401 jum.assertEquals('success', test.result.callback);
402 var salgado = test.result.args[0];
403 jum.assertEquals('<b>A new display name</b>',
404 salgado.get('display_name'));
405
406 // As long as we've got bad HTML in the display name, let's
407 // get an HTML representation and see whether the bad HTML was
408 // escaped.
409 client.get('~salgado', {on: test.create_yui_sync_on(),
410 accept: LP.client.XHTML});
411 },
412 'wait_action',
413 function (test) {
414 jum.assertEquals('success', test.result.callback);
415
416 var salgado_html = test.result.args[0];
417 jum.assertNotEquals(salgado_html.indexOf("<dl"), -1);
418 jum.assertNotEquals(salgado_html.indexOf("&lt;b&gt;A new"), -1);
419
420 // Now test the patch() method directly.
421 client.patch('~salgado', {'display_name': 'A patched display name'},
422 {on: test.create_yui_sync_on()});
423 },
424 'wait_action',
425 function (test) {
426 jum.assertEquals('success', test.result.callback);
427 client.get('~salgado', {on: test.create_yui_sync_on()});
428 },
429 'wait_action',
430 function (test) {
431 jum.assertEquals('success', test.result.callback);
432 var salgado = test.result.args[0];
433 jum.assertEquals('A patched display name', salgado.get('display_name'));
434
435 // Test that a mismatched ETag results in a failed save.
436 salgado.set('http_etag', "Non-matching ETag.");
437 salgado.set('display_name', "This display name will not be set.");
438 salgado.lp_save({on: test.create_yui_sync_on()});
439 },
440 'wait_action',
441 function (test) {
442 jum.assertEquals('failure', test.result.callback);
443 var xhr = test.result.args[1];
444 jum.assertEquals(xhr.status, 412);
445 }
446 ]);
447
448
449//Test retrieving a collection object.
450var test_collection = new SynchronizedTest('test_collection', [
451 function (test) {
452 client.get("people", {on: test.create_yui_sync_on()});
453 },
454 'wait_action',
455 function (test) {
456 jum.assertEquals('success', test.result.callback);
457 var collection = test.result.args[0];
458 jum.assertTrue(collection instanceof LP.client.Collection);
459 jum.assertEquals(4, collection.total_size);
460 },
461 'wait_action',
462 function (test) {
463 jum.assertEquals('success', test.result.callback);
464 var entries = test.result.args[0].entries,
465 length = test.result.args[0].total_size,
466 index;
467 for (index = 0 ; index < length ; index++) {
468 jum.assertTrue(entries[index] instanceof LP.client.Entry);
469 }
470 },
471 'wait_action',
472 function (test) {
473 jum.assertEquals('success', test.result.callback);
474 var entry = test.result.args[0].entries[0];
475 jum.assertEquals('test', entry.display_name);
476 entry.set('display_name', "Set Display Name");
477 entry.lp_save({on: test.create_yui_sync_on()});
478 },
479 'wait_action',
480 function (test) {
481 jum.assertEquals('success', test.result.callback);
482 client.get('people', { on: test.create_yui_sync_on()});
483 },
484 'wait_action',
485 function (test) {
486 jum.assertEquals('success', test.result.callback);
487 var entry = test.result.args[0].entries[0];
488 jum.assertEquals('Set Display Name', entry.display_name);
489 }
490 ]);
491
492
493//Test the lp_slice() method on a collection.
494var test_collection_lp_slice = new SynchronizedTest(
495 'test_collection_lp_slice', [
496 function (test) {
497 client.get("people", {on: test.create_yui_sync_on()});
498 },
499 'wait_action',
500 function (test) {
501 jum.assertEquals('success', test.result.callback);
502 var collection = test.result.args[0];
503 collection.lp_slice(test.create_yui_sync_on(), 2, 1);
504 },
505 'wait_action',
506 function (test) {
507 jum.assertEquals('success', test.result.callback);
508 var slice = test.result.args[0];
509 jum.assertEquals(2, slice.start);
510 jum.assertEquals(1, slice.entries.length);
511 }
512 ]);
513
514
515//Test invoking a named GET on a collection.
516var test_collection_named_get = new SynchronizedTest(
517 'test_collection_named_get', [
518 function (test) {
519 client.get("people", {on: test.create_yui_sync_on() });
520 },
521 'wait_action',
522 function (test) {
523 jum.assertEquals('success', test.result.callback);
524 var collection = test.result.args[0];
525 collection.named_get(
526 'find', {on: test.create_yui_sync_on(),
527 parameters: {text: 'salgado'}});
528 },
529 'wait_action',
530 function (test) {
531 jum.assertEquals('success', test.result.callback);
532 var collection = test.result.args[0];
533 jum.assertTrue(collection instanceof LP.client.Collection);
534 jum.assertEquals(1, collection.total_size);
535 }
536 ]);
537
538
539//Test named POST on a collection, and object creation.
540test_logged_in_as_foo_bar.test_collection_named_post = new SynchronizedTest(
541 'test_collection_named_post', [
542 function(test) {
543 client.get("/people/", {on: test.create_yui_sync_on()});
544 },
545 'wait_action',
546 function(test) {
547 // Generate a unique team name so that the team-creation test
548 // can be run multiple times without resetting the dataset.
549 name = "newteam" + new Date().getTime();
550 var collection = test.result.args[0];
551 collection.named_post('newTeam',
552 {on: test.create_yui_sync_on(),
553 parameters: {display_name: 'My new team',
554 name: name}});
555 },
556 'wait_action',
557 function(test) {
558 var new_entry = test.result.args[0];
559 jum.assertEquals("success", test.result.callback);
560 jum.assertTrue(new_entry instanceof LP.client.Entry);
561 jum.assertEquals(new_entry.get("display_name"), "My new team");
562 jum.assertNotEquals(new_entry.lp_original_uri.indexOf("/~newteam"),
563 -1);
564 }
565 ]);
566
567//Test paging on a named collection.
568var test_collection_paged_named_get = new SynchronizedTest(
569 'test_collection_paged_named_get', [
570 function (test) {
571 client.get("people", {on: test.create_yui_sync_on() });
572 },
573 'wait_action',
574 function (test) {
575 jum.assertEquals('success', test.result.callback);
576 var collection = test.result.args[0];
577 collection.named_get(
578 'find', {on: test.create_yui_sync_on(),
579 parameters: {text: 'salgado'},
580 start: 10});
581 },
582 'wait_action',
583 function (test) {
584 jum.assertEquals('success', test.result.callback);
585 var collection = test.result.args[0];
586 jum.assertTrue(collection instanceof LP.client.Collection);
587 jum.assertEquals(1, collection.total_size);
588 }
589 ]);
590
591// Test hosted file objects.
592
593// To test PUT to a hosted file we need to create a brand new
594// file. Several problems combine to make this necessary. The
595// first is that you can't send a binary file through XHR: it gets
596// truncated at the first null character. So we can't just PUT to
597// a mugshot or icon. There are some product release files in the
598// preexisting dataset, but they don't have anything backing them
599// in the librarian, so we can't get a proper handle on them. So
600// we need to create a brand new file and then test PUT on it.
601
602// Unfortunately, currently there are no files you can create with
603// PUT, so we can't test this.
604
605test_logged_in_as_foo_bar.test_hosted_files = new SynchronizedTest(
606
607 'test_hosted_files', [
608 function(test) {
609 var bug_uri = '/bugs/15';
610 client.named_post(bug_uri, 'addAttachment',
611 {on: test.create_yui_sync_on(),
612 parameters: {comment: 'A new attachment',
613 content_type: 'text/plain',
614 data: 'Some data.',
615 filename: 'foo.txt'}});
616 },
617 'wait_action',
618 function(test) {
619 jum.assertEquals('success', test.result.callback);
620 var attachment = test.result.args[0];
621 attachment.follow_link('data',
622 {on: test.create_yui_sync_on()});
623 },
624 'wait_action',
625 function(test) {
626 jum.assertEquals('success', test.result.callback);
627 var hosted_file = test.result.args[0];
628
629// Unfortunately, there's no hosted file that can be edited through
630// the web service, so we can't test PUT.
631// hosted_file.contents = "['Unit tester was here.']";
632// hosted_file.filename = "unittest.json";
633// hosted_file.content_type = "application/json";
634// hosted_file.lp_save({on: test.create_yui_sync_on()});
635// },
636// 'wait_action',
637// function(test) {
638// jum.assertEquals('success', test.result.callback);
639// var hosted_file = test.result.args[2];
640 hosted_file.lp_delete({on: test.create_yui_sync_on()});
641 },
642 'wait_action',
643 function(test) {
644 jum.assertEquals('failure', test.result.callback);
645 // XXX flacoste 2008/12/12 bug=307539
646 // This code works right now, but when testing a hosted file
647 // that can be edited through the web service, it will fail.
648 var request = test.result.args[1];
649 jum.assertEquals(405, request.status);
650 }
651 ]
652 );
6530
=== removed file 'lib/canonical/launchpad/windmill/jstests/login.js'
--- lib/canonical/launchpad/windmill/jstests/login.js 2009-06-30 21:06:27 +0000
+++ lib/canonical/launchpad/windmill/jstests/login.js 1970-01-01 00:00:00 +0000
@@ -1,32 +0,0 @@
1/*
2Copyright 2009 Canonical Ltd. This software is licensed under the
3GNU Affero General Public License version 3 (see the file LICENSE).
4
5Namespaces for tests requiring to be logged in.
6*/
7
8
9/* Logged in as Foo Bar. */
10var test_logged_in_as_foo_bar = {};
11test_logged_in_as_foo_bar.setup = [
12 {"params": {"link": "Log in \/ Register"},
13 "method": "asserts.assertNode"},
14 {"params": {"link": "Log in \/ Register"}, "method": "click"},
15 {"params": {}, "method": "waits.forPageLoad"},
16 {"params": {"id": "email"}, "method": "waits.forElement"},
17 {"params": {"text": "foo.bar@canonical.com", "id": "email"},
18 "method": "type"},
19 {"params": {"text": "test", "id": "password"}, "method": "type"},
20 {"params": {"name": "loginpage_submit_login"}, "method": "click"},
21 {"params": {}, "method": "waits.forPageLoad"},
22 {"params": {"link": "Foo Bar"}, "method": "waits.forElement"}
23 ];
24
25test_logged_in_as_foo_bar.teardown = [
26 {"params": {"name": "logout"}, "method": "click"},
27 // We need the waits.forPageLoad here because it's likely that the
28 // xpath expression might match on the previous page.
29 {"params": {}, "method": "waits.forPageLoad"},
30 {"params": {"xpath": "\/html\/body[@id='document']\/div[@id='mainarea']\/div[@id='container']\/div"}, "method": "waits.forElement"},
31 {"params": {"xpath": "\/html\/body[@id='document']\/div[@id='mainarea']\/div[@id='container']\/div", "validator": "You have been logged out"}, "method": "asserts.assertText"}
32 ];
330
=== modified file 'lib/lp/bugs/browser/bugsubscription.py'
--- lib/lp/bugs/browser/bugsubscription.py 2011-06-17 10:44:18 +0000
+++ lib/lp/bugs/browser/bugsubscription.py 2011-06-30 22:44:42 +0000
@@ -540,7 +540,7 @@
540 details = list(bug.getDirectSubscribersWithDetails())540 details = list(bug.getDirectSubscribersWithDetails())
541 api_request = IWebServiceClientRequest(self.request)541 api_request = IWebServiceClientRequest(self.request)
542 for person, subscription in details:542 for person, subscription in details:
543 can_edit = self.user is not None and self.user.inTeam(person)543 can_edit = subscription.canBeUnsubscribedByUser(self.user)
544 if person == self.user or (person.private and not can_edit):544 if person == self.user or (person.private and not can_edit):
545 # Skip the current user viewing the page,545 # Skip the current user viewing the page,
546 # and private teams user is not a member of.546 # and private teams user is not a member of.
547547
=== modified file 'lib/lp/bugs/browser/tests/test_bugsubscription_views.py'
--- lib/lp/bugs/browser/tests/test_bugsubscription_views.py 2011-06-17 10:31:55 +0000
+++ lib/lp/bugs/browser/tests/test_bugsubscription_views.py 2011-06-30 22:44:42 +0000
@@ -7,6 +7,7 @@
77
8from simplejson import dumps8from simplejson import dumps
99
10from zope.component import getUtility
10from zope.traversing.browser import absoluteURL11from zope.traversing.browser import absoluteURL
1112
12from canonical.launchpad.ftests import LaunchpadFormHarness13from canonical.launchpad.ftests import LaunchpadFormHarness
@@ -19,10 +20,12 @@
19 BugSubscriptionSubscribeSelfView,20 BugSubscriptionSubscribeSelfView,
20 )21 )
21from lp.bugs.enum import BugNotificationLevel22from lp.bugs.enum import BugNotificationLevel
23from lp.registry.interfaces.person import IPersonSet
22from lp.testing import (24from lp.testing import (
23 person_logged_in,25 person_logged_in,
24 TestCaseWithFactory,26 TestCaseWithFactory,
25 )27 )
28from lp.testing.sampledata import ADMIN_EMAIL
26from lp.testing.views import create_initialized_view29from lp.testing.views import create_initialized_view
2730
2831
@@ -573,6 +576,67 @@
573 self.assertEqual(576 self.assertEqual(
574 dumps([expected_result]), harness.view.subscriber_data_js)577 dumps([expected_result]), harness.view.subscriber_data_js)
575578
579 def test_data_subscription_lp_admin(self):
580 # For a subscription, subscriber_data_js has can_edit
581 # set to true for a Launchpad admin.
582 bug = self._makeBugWithNoSubscribers()
583 member = self.factory.makePerson()
584 subscriber = self.factory.makePerson(
585 name='user', displayname='Subscriber Name')
586 with person_logged_in(member):
587 bug.subscribe(subscriber, subscriber,
588 level=BugNotificationLevel.LIFECYCLE)
589 harness = LaunchpadFormHarness(
590 bug, BugPortletSubscribersWithDetails)
591 api_request = IWebServiceClientRequest(harness.request)
592
593 expected_result = {
594 'subscriber': {
595 'name': 'user',
596 'display_name': 'Subscriber Name',
597 'is_team': False,
598 'can_edit': True,
599 'web_link': canonical_url(subscriber),
600 'self_link': absoluteURL(subscriber, api_request),
601 },
602 'subscription_level': "Lifecycle",
603 }
604
605 # Login as admin
606 admin = getUtility(IPersonSet).find(ADMIN_EMAIL).any()
607 with person_logged_in(admin):
608 self.assertEqual(
609 dumps([expected_result]), harness.view.subscriber_data_js)
610
611 def test_data_person_subscription_subscriber(self):
612 # For a subscription, subscriber_data_js has can_edit
613 # set to true for the subscriber.
614 bug = self._makeBugWithNoSubscribers()
615 subscriber = self.factory.makePerson(
616 name='user', displayname='Subscriber Name')
617 subscribed_by = self.factory.makePerson(
618 name='someone', displayname='Subscribed By Name')
619 with person_logged_in(subscriber):
620 bug.subscribe(subscriber, subscribed_by,
621 level=BugNotificationLevel.LIFECYCLE)
622 harness = LaunchpadFormHarness(bug, BugPortletSubscribersWithDetails)
623 api_request = IWebServiceClientRequest(harness.request)
624
625 expected_result = {
626 'subscriber': {
627 'name': 'user',
628 'display_name': 'Subscriber Name',
629 'is_team': False,
630 'can_edit': True,
631 'web_link': canonical_url(subscriber),
632 'self_link': absoluteURL(subscriber, api_request),
633 },
634 'subscription_level': "Lifecycle",
635 }
636 with person_logged_in(subscribed_by):
637 self.assertEqual(
638 dumps([expected_result]), harness.view.subscriber_data_js)
639
576 def test_data_person_subscription_user_excluded(self):640 def test_data_person_subscription_user_excluded(self):
577 # With the subscriber logged in, he is not included in the results.641 # With the subscriber logged in, he is not included in the results.
578 bug = self._makeBugWithNoSubscribers()642 bug = self._makeBugWithNoSubscribers()
579643
=== modified file 'lib/lp/bugs/javascript/subscribers_list.js'
--- lib/lp/bugs/javascript/subscribers_list.js 2011-06-27 14:23:58 +0000
+++ lib/lp/bugs/javascript/subscribers_list.js 2011-06-30 22:44:42 +0000
@@ -325,7 +325,8 @@
325325
326 function on_success() {326 function on_success() {
327 loader.subscribers_list.stopSubscriberActivity(subscriber, true);327 loader.subscribers_list.stopSubscriberActivity(subscriber, true);
328 loader._addUnsubscribeLinkIfTeamMember(subscriber);328 loader.subscribers_list.addUnsubscribeAction(
329 subscriber, loader._getUnsubscribeCallback());
329 }330 }
330 function on_failure(t_id, response) {331 function on_failure(t_id, response) {
331 loader.subscribers_list.stopSubscriberActivity(332 loader.subscribers_list.stopSubscriberActivity(
@@ -347,44 +348,6 @@
347};348};
348349
349/**350/**
350 * Add unsubscribe link for a team if the currently logged in user
351 * is member of the team.
352 *
353 * @method _addUnsubscribeLinkIfTeamMember
354 * @param team {Object} A person object as returned via API.
355 */
356BugSubscribersLoader.prototype
357._addUnsubscribeLinkIfTeamMember = function(team) {
358 var loader = this;
359 function on_success(members) {
360 var team_member = false;
361 var i;
362 for (i=0; i<members.entries.length; i++) {
363 if (members.entries[i].get('member_link') ===
364 Y.lp.client.get_absolute_uri(LP.links.me)) {
365 team_member = true;
366 break;
367 }
368 }
369 if (team_member === true) {
370 // Add unsubscribe action for the team member.
371 loader.subscribers_list.addUnsubscribeAction(
372 team, loader._getUnsubscribeCallback());
373 }
374 }
375
376 if (Y.Lang.isString(LP.links.me) && team.is_team) {
377 var config = {
378 on: { success: on_success }
379 };
380
381 var members_link = team.members_details_collection_link;
382 this.lp_client.get(members_link, config);
383 }
384};
385
386
387/**
388 * Manages entire subscribers' list for a single bug.351 * Manages entire subscribers' list for a single bug.
389 *352 *
390 * If the passed in container_box is not present, or if there are multiple353 * If the passed in container_box is not present, or if there are multiple
391354
=== modified file 'lib/lp/bugs/javascript/tests/test_subscribers_list.js'
--- lib/lp/bugs/javascript/tests/test_subscribers_list.js 2011-06-28 16:26:34 +0000
+++ lib/lp/bugs/javascript/tests/test_subscribers_list.js 2011-06-30 22:44:42 +0000
@@ -2002,8 +2002,8 @@
20022002
2003 test_subscribeSomeoneElse_success: function() {2003 test_subscribeSomeoneElse_success: function() {
2004 // When subscribing someone else succeeds, stopSubscriberActivity2004 // When subscribing someone else succeeds, stopSubscriberActivity
2005 // is called indicating success, and _addUnsubscribeLinkIfTeamMember2005 // is called indicating success, and addUnsubscribeAction
2006 // is called to add unsubscribe-link when needed.2006 // is called with the correct parameters.
20072007
2008 var subscriber = { name: "user", self_link: "/~user" };2008 var subscriber = { name: "user", self_link: "/~user" };
20092009
@@ -2011,17 +2011,29 @@
2011 var loader = setUpLoader(this.root);2011 var loader = setUpLoader(this.root);
2012 loader.subscribers_list.addSubscriber(subscriber, "Maybe");2012 loader.subscribers_list.addSubscriber(subscriber, "Maybe");
20132013
2014 // Mock-up addUnsubscribeLinkIfTeamMember method to2014 // Mock-up addUnsubscribeAction method to
2015 // ensure it's called with the right parameters.2015 // ensure it's called with the right parameters.
2016 var subscriber_link_added = false;2016 // We need to stub the _getUnsubscribeCallback result so we can check
2017 var old_addLink =2017 // the unsubscribe_callback.
2018 module.BugSubscribersLoader2018 var unsubscribe_callback = function() {};
2019 .prototype._addUnsubscribeLinkIfTeamMember;2019 // Save old methods for restoring later.
2020 module.BugSubscribersLoader.prototype2020 var old_getUnsub = module.BugSubscribersLoader.prototype
2021 ._addUnsubscribeLinkIfTeamMember =2021 ._getUnsubscribeCallback;
2022 function(my_subscriber) {2022
2023 // Make _getUnsubscribeCallback return the new callback.
2024 module.BugSubscribersLoader.prototype._getUnsubscribeCallback =
2025 function() {
2026 return unsubscribe_callback;
2027 };
2028
2029 var unsubscribe_link_added = false;
2030 var old_unsubscribe_action =
2031 module.SubscribersList.prototype.addUnsubscribeAction;
2032 module.SubscribersList.prototype.addUnsubscribeAction =
2033 function(my_subscriber, callback) {
2023 Y.Assert.areSame(subscriber, my_subscriber);2034 Y.Assert.areSame(subscriber, my_subscriber);
2024 subscriber_link_added = true;2035 Y.Assert.areEqual(unsubscribe_callback, callback);
2036 unsubscribe_link_added = true;
2025 };2037 };
20262038
2027 // Mock-up stopSubscriberActivity to ensure it's called.2039 // Mock-up stopSubscriberActivity to ensure it's called.
@@ -2051,12 +2063,14 @@
20512063
2052 loader._subscribeSomeoneElse(person);2064 loader._subscribeSomeoneElse(person);
20532065
2054 Y.Assert.isTrue(subscriber_link_added);2066 Y.Assert.isTrue(unsubscribe_link_added);
2055 Y.Assert.isFalse(activity_on);2067 Y.Assert.isFalse(activity_on);
20562068
2057 // Restore original methods.2069 // Restore original methods.
2058 module.BugSubscribersLoader.prototype.addUnsubscribeLinkIfTeamMember =2070 module.SubscribersList.prototype.addUnsubscribeAction =
2059 old_addLink;2071 old_unsubscribe_action;
2072 module.BugSubscribersLoader.prototype._getUnsubscribeCallback =
2073 old_getUnsub;
2060 module.SubscribersList.prototype.stopSubscriberActivity =2074 module.SubscribersList.prototype.stopSubscriberActivity =
2061 old_indicate;2075 old_indicate;
20622076
20632077
=== modified file 'lib/lp/bugs/stories/bug-privacy/05-set-bug-private-as-admin.txt'
--- lib/lp/bugs/stories/bug-privacy/05-set-bug-private-as-admin.txt 2011-06-16 13:50:58 +0000
+++ lib/lp/bugs/stories/bug-privacy/05-set-bug-private-as-admin.txt 2011-06-30 22:44:42 +0000
@@ -15,7 +15,7 @@
15 ... "http://launchpad.dev/bugs/2/+bug-portlet-subscribers-details")15 ... "http://launchpad.dev/bugs/2/+bug-portlet-subscribers-details")
1616
17 >>> print_direct_subscribers(browser.contents)17 >>> print_direct_subscribers(browser.contents)
18 Steve Alexander18 Steve Alexander (Unsubscribe)
19 >>> print_also_notified(browser.contents)19 >>> print_also_notified(browser.contents)
20 Also notified:20 Also notified:
21 Sample Person21 Sample Person
@@ -38,8 +38,8 @@
38 >>> browser.open(38 >>> browser.open(
39 ... "http://launchpad.dev/bugs/2/+bug-portlet-subscribers-details")39 ... "http://launchpad.dev/bugs/2/+bug-portlet-subscribers-details")
40 >>> print_direct_subscribers(browser.contents)40 >>> print_direct_subscribers(browser.contents)
41 Sample Person41 Sample Person (Unsubscribe)
42 Steve Alexander42 Steve Alexander (Unsubscribe)
43 Ubuntu Team (Unsubscribe)43 Ubuntu Team (Unsubscribe)
4444
45 >>> print_also_notified(browser.contents)45 >>> print_also_notified(browser.contents)
4646
=== modified file 'lib/lp/bugs/stories/bugs/bug-add-subscriber.txt'
--- lib/lp/bugs/stories/bugs/bug-add-subscriber.txt 2011-06-16 13:50:58 +0000
+++ lib/lp/bugs/stories/bugs/bug-add-subscriber.txt 2011-06-30 22:44:42 +0000
@@ -67,7 +67,7 @@
67 ... 'http://bugs.launchpad.dev/bugs/1/'67 ... 'http://bugs.launchpad.dev/bugs/1/'
68 ... '+bug-portlet-subscribers-details')68 ... '+bug-portlet-subscribers-details')
69 >>> print_direct_subscribers(user_browser.contents)69 >>> print_direct_subscribers(user_browser.contents)
70 David Allouche70 David Allouche (Unsubscribe)
71 Sample Person71 Sample Person
72 Steve Alexander72 Steve Alexander
7373
@@ -110,8 +110,8 @@
110 ... 'http://bugs.launchpad.dev/bugs/1/'110 ... 'http://bugs.launchpad.dev/bugs/1/'
111 ... '+bug-portlet-subscribers-details')111 ... '+bug-portlet-subscribers-details')
112 >>> print_direct_subscribers(user_browser.contents)112 >>> print_direct_subscribers(user_browser.contents)
113 David Allouche113 David Allouche (Unsubscribe)
114 Landscape Developers114 Landscape Developers (Unsubscribe)
115 Sample Person115 Sample Person
116 Steve Alexander116 Steve Alexander
117117
@@ -144,11 +144,11 @@
144 ... 'http://bugs.launchpad.dev/bugs/1/'144 ... 'http://bugs.launchpad.dev/bugs/1/'
145 ... '+bug-portlet-subscribers-details')145 ... '+bug-portlet-subscribers-details')
146 >>> print_direct_subscribers(foobar_browser.contents)146 >>> print_direct_subscribers(foobar_browser.contents)
147 David Allouche147 David Allouche (Unsubscribe)
148 Landscape Developers148 Landscape Developers (Unsubscribe)
149 Private Team (Unsubscribe)149 Private Team (Unsubscribe)
150 Sample Person150 Sample Person (Unsubscribe)
151 Steve Alexander151 Steve Alexander (Unsubscribe)
152152
153Someone not in the team will not see the private team in the153Someone not in the team will not see the private team in the
154subscribers list.154subscribers list.
@@ -157,8 +157,8 @@
157 ... 'http://bugs.launchpad.dev/bugs/1/'157 ... 'http://bugs.launchpad.dev/bugs/1/'
158 ... '+bug-portlet-subscribers-details')158 ... '+bug-portlet-subscribers-details')
159 >>> print_direct_subscribers(user_browser.contents)159 >>> print_direct_subscribers(user_browser.contents)
160 David Allouche160 David Allouche (Unsubscribe)
161 Landscape Developers161 Landscape Developers (Unsubscribe)
162 Sample Person162 Sample Person
163 Steve Alexander163 Steve Alexander
164164