Merge lp:~gmb/launchpad/make-bnl-descriptions-readable-bug-664566 into lp:launchpad

Proposed by Graham Binns on 2010-10-22
Status: Merged
Merged at revision: 11832
Proposed branch: lp:~gmb/launchpad/make-bnl-descriptions-readable-bug-664566
Merge into: lp:launchpad
Prerequisite: lp:~gmb/launchpad/include-bnl-bug-651108
Diff against target: 534 lines (+374/-20)
7 files modified
lib/lp/bugs/browser/bugsubscription.py (+91/-14)
lib/lp/bugs/browser/tests/test_bugsubscription_views.py (+94/-0)
lib/lp/bugs/stories/bugs/xx-bug-personal-subscriptions-advanced-features.txt (+43/-0)
lib/lp/services/features/rulesource.py (+9/-4)
lib/lp/services/features/testing.py (+68/-0)
lib/lp/services/features/tests/test_helpers.py (+67/-0)
lib/lp/services/memcache/doc/tales-cache.txt (+2/-2)
To merge this branch: bzr merge lp:~gmb/launchpad/make-bnl-descriptions-readable-bug-664566
Reviewer Review Type Date Requested Status
Abel Deuring (community) code 2010-10-22 Approve on 2010-10-22
Review via email: mp+39158@code.launchpad.net

Commit Message

BugNotificationLevel now has a more readable set of descriptions for Bug:+subscribe. BugNotificationLevel.NOTHING is no longer accepted when one is attempting to subscribe to a bug through the web UI.

Description of the Change

This branch fixes bug 664566 and bug 664569 by altering the way that BugNotificationLevel is displayed as an option on the bug +subscribe page.

== lib/lp/bugs/browser/bugsubscription.py ==

 - I've added a mapping of BugNotificationLevel values to human-readable descriptions. This is now used to build a custom field for bug_notification_level. Note that I didn't just change the descriptions in BugNotificationLevel directly because different contexts will mean that the descriptions will need to be worded differently (e.g. subscriptions to searches vs. direct sbscriptions vs. structural subscriptions).
 - I've dropped BugNotificationLevel.NOTHING from the set of options displayed.

== lib/lp/bugs/browser/tests/test_bugsubscription_views.py ==

 - I've added a test to ensure that trying to subscribe with BugNotificationLevel.NOTHING raises an error.

== lib/lp/bugs/stories/bugs/xx-bug-personal-subscriptions-advanced-features.txt ==

 - I've updated the existing pagetest to reflect the changes in this branch.

To post a comment you must log in.
Abel Deuring (adeuring) wrote :

nice work! just one nitpick:

(17:58:02) adeuring: gmb: I think @cachedproperty would be better for _bug_notification_level_field. I understand that it is used just once, but just in case it is used later somewhere else, we can/should use the same Choice instance
(17:58:32) gmb: adeuring: Okay, sure.

review: Approve (code)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/bugs/browser/bugsubscription.py'
2--- lib/lp/bugs/browser/bugsubscription.py 2010-10-19 10:29:00 +0000
3+++ lib/lp/bugs/browser/bugsubscription.py 2010-11-01 13:08:34 +0000
4@@ -18,8 +18,6 @@
5 from zope import formlib
6 from zope.app.form import CustomWidgetFactory
7 from zope.app.form.browser.itemswidgets import RadioWidget
8-from zope.app.form.interfaces import IInputWidget
9-from zope.app.form.utility import setUpWidget
10 from zope.schema import Choice
11 from zope.schema.vocabulary import (
12 SimpleTerm,
13@@ -30,7 +28,6 @@
14 from canonical.launchpad.webapp import (
15 action,
16 canonical_url,
17- custom_widget,
18 LaunchpadFormView,
19 LaunchpadView,
20 )
21@@ -85,10 +82,25 @@
22 """A view to handle the +subscribe page for a bug."""
23
24 schema = IBugSubscription
25- # XXX 2010-10-18 gmb bug=651108:
26- # field_names is currently empty because we don't use any of
27- # them. This will be amended in a subsequent branch.
28- field_names = []
29+
30+ # A mapping of BugNotificationLevel values to descriptions to be
31+ # shown on the +subscribe page.
32+ _bug_notification_level_descriptions = {
33+ BugNotificationLevel.LIFECYCLE: (
34+ "The bug is fixed or re-opened."),
35+ BugNotificationLevel.METADATA: (
36+ "Any change is made to this bug, other than a new comment "
37+ "being added."),
38+ BugNotificationLevel.COMMENTS: (
39+ "A change is made to this bug or a new comment is added."),
40+ }
41+
42+ @property
43+ def field_names(self):
44+ if self._use_advanced_features:
45+ return ['bug_notification_level']
46+ else:
47+ return []
48
49 @property
50 def next_url(self):
51@@ -102,7 +114,7 @@
52 # far as referrers are concerned so that we can handle privacy
53 # issues properly.
54 ignored_referrer_urls = (
55- 'localhost', self.request.getURL(), context_url)
56+ 'localhost', self.request.getURL(), context_url)
57 if referer and referer not in ignored_referrer_urls:
58 next_url = referer
59 elif self._redirecting_to_bug_list:
60@@ -112,7 +124,6 @@
61 return next_url
62
63 cancel_url = next_url
64- _redirecting_to_bug_list = False
65
66 @cachedproperty
67 def _subscribers_for_current_user(self):
68@@ -128,10 +139,45 @@
69 self._subscriber_count_for_current_user = person_count
70 return persons_for_user.values()
71
72+ @cachedproperty
73+ def _use_advanced_features(self):
74+ """Return True if advanced subscriptions features are enabled."""
75+ return features.getFeatureFlag(
76+ 'malone.advanced-subscriptions.enabled')
77+
78+ def initialize(self):
79+ """See `LaunchpadFormView`."""
80+ self._subscriber_count_for_current_user = 0
81+ self._redirecting_to_bug_list = False
82+ super(BugSubscriptionSubscribeSelfView, self).initialize()
83+
84+ @cachedproperty
85+ def _bug_notification_level_field(self):
86+ """Return a custom form field for bug_notification_level."""
87+ # We rebuild the items that we show in the field so that the
88+ # labels shown are human readable and specific to the +subscribe
89+ # form. The BugNotificationLevel descriptions are too generic.
90+ bug_notification_level_terms = [
91+ SimpleTerm(
92+ level, level.name,
93+ self._bug_notification_level_descriptions[level])
94+ # We reorder the items so that COMMENTS comes first. We also
95+ # drop the NOTHING option since it just makes the UI
96+ # confusing.
97+ for level in sorted(BugNotificationLevel.items, reverse=True)
98+ if level != BugNotificationLevel.NOTHING
99+ ]
100+ bug_notification_vocabulary = SimpleVocabulary(
101+ bug_notification_level_terms)
102+ bug_notification_level_field = Choice(
103+ __name__='bug_notification_level', title=_("Tell me when"),
104+ vocabulary=bug_notification_vocabulary, required=True,
105+ default=BugNotificationLevel.COMMENTS)
106+ return bug_notification_level_field
107+
108 def setUpFields(self):
109 """See `LaunchpadFormView`."""
110 super(BugSubscriptionSubscribeSelfView, self).setUpFields()
111- bug = self.context.bug
112 if self.user is None:
113 return
114
115@@ -162,10 +208,37 @@
116 __name__='subscription', title=_("Subscription options"),
117 vocabulary=subscription_vocabulary, required=True,
118 default=default_subscription_value)
119- self.form_fields += formlib.form.Fields(subscription_field)
120+
121+ if self._use_advanced_features:
122+ self.form_fields = self.form_fields.omit('bug_notification_level')
123+ self.form_fields += formlib.form.Fields(subscription_field)
124+ self.form_fields += formlib.form.Fields(
125+ self._bug_notification_level_field)
126+ self.form_fields['bug_notification_level'].custom_widget = (
127+ CustomWidgetFactory(RadioWidget))
128+ else:
129+ self.form_fields += formlib.form.Fields(subscription_field)
130 self.form_fields['subscription'].custom_widget = CustomWidgetFactory(
131 RadioWidget)
132
133+ def setUpWidgets(self):
134+ """See `LaunchpadFormView`."""
135+ super(BugSubscriptionSubscribeSelfView, self).setUpWidgets()
136+ if self._use_advanced_features:
137+ if self.user_is_subscribed:
138+ self.widgets['bug_notification_level'].visible = False
139+ else:
140+ if self._subscriber_count_for_current_user == 0:
141+ # We hide the subscription widget if the user isn't
142+ # subscribed, since we know who the subscriber is and we
143+ # don't need to present them with a single radio button.
144+ self.widgets['subscription'].visible = False
145+ else:
146+ # We show the subscription widget when the user is
147+ # subscribed via a team, because they can either
148+ # subscribe theirself or unsubscribe their team.
149+ self.widgets['subscription'].visible = True
150+
151 @cachedproperty
152 def user_is_subscribed(self):
153 """Is the user subscribed to this bug?"""
154@@ -195,14 +268,18 @@
155 subscription_person = self.widgets['subscription'].getInputValue()
156 if (not self.user_is_subscribed and
157 (subscription_person == self.user)):
158- self._handleSubscribe()
159+ if self._use_advanced_features:
160+ bug_notification_level = data['bug_notification_level']
161+ else:
162+ bug_notification_level = None
163+ self._handleSubscribe(bug_notification_level)
164 else:
165 self._handleUnsubscribe(subscription_person)
166 self.request.response.redirect(self.next_url)
167
168- def _handleSubscribe(self):
169+ def _handleSubscribe(self, level=None):
170 """Handle a subscribe request."""
171- self.context.bug.subscribe(self.user, self.user)
172+ self.context.bug.subscribe(self.user, self.user, level=level)
173 self.request.response.addNotification(
174 "You have been subscribed to this bug.")
175
176
177=== added file 'lib/lp/bugs/browser/tests/test_bugsubscription_views.py'
178--- lib/lp/bugs/browser/tests/test_bugsubscription_views.py 1970-01-01 00:00:00 +0000
179+++ lib/lp/bugs/browser/tests/test_bugsubscription_views.py 2010-11-01 13:08:34 +0000
180@@ -0,0 +1,94 @@
181+# Copyright 2010 Canonical Ltd. This software is licensed under the
182+# GNU Affero General Public License version 3 (see the file LICENSE).
183+
184+"""Tests for BugSubscription views."""
185+
186+__metaclass__ = type
187+
188+from storm.store import Store
189+
190+from canonical.launchpad.ftests import LaunchpadFormHarness
191+from canonical.testing.layers import LaunchpadFunctionalLayer
192+
193+from lp.bugs.browser.bugsubscription import BugSubscriptionSubscribeSelfView
194+from lp.bugs.model.bugsubscription import BugSubscription
195+from lp.registry.enum import BugNotificationLevel
196+from lp.testing import (
197+ feature_flags,
198+ person_logged_in,
199+ set_feature_flag,
200+ TestCaseWithFactory,
201+ )
202+
203+
204+class BugSubscriptionAdvancedFeaturesTestCase(TestCaseWithFactory):
205+
206+ layer = LaunchpadFunctionalLayer
207+
208+ def _getBugSubscriptionForUserAndBug(self, user, bug):
209+ """Return the BugSubscription for a given user, bug combination."""
210+ store = Store.of(bug)
211+ return store.find(
212+ BugSubscription,
213+ BugSubscription.person == user,
214+ BugSubscription.bug == bug).one()
215+
216+ def test_subscribe_uses_bug_notification_level(self):
217+ # When a user subscribes to a bug using the advanced features on
218+ # the Bug +subscribe page, the bug notification level they
219+ # choose is taken into account.
220+ bug = self.factory.makeBug()
221+ # We unsubscribe the bug's owner because if we don't there will
222+ # be two COMMENTS-level subscribers.
223+ with person_logged_in(bug.owner):
224+ bug.unsubscribe(bug.owner, bug.owner)
225+
226+ # We don't display BugNotificationLevel.NOTHING as an option.
227+ # This is tested below.
228+ with feature_flags():
229+ set_feature_flag(u'malone.advanced-subscriptions.enabled', u'on')
230+ displayed_levels = [
231+ level for level in BugNotificationLevel.items
232+ if level != BugNotificationLevel.NOTHING]
233+ for level in displayed_levels:
234+ person = self.factory.makePerson()
235+ with person_logged_in(person):
236+ harness = LaunchpadFormHarness(
237+ bug.default_bugtask, BugSubscriptionSubscribeSelfView)
238+ form_data = {
239+ 'field.subscription': person.name,
240+ 'field.bug_notification_level': level.name,
241+ }
242+ harness.submit('continue', form_data)
243+
244+ subscription = self._getBugSubscriptionForUserAndBug(
245+ person, bug)
246+ self.assertEqual(
247+ level, subscription.bug_notification_level,
248+ "Bug notification level of subscription should be %s, is "
249+ "actually %s." % (
250+ level.name, subscription.bug_notification_level.name))
251+
252+ # XXX 2010-11-01 gmb bug=668334:
253+ # This should be in its own test but at the moment the above
254+ # bug causes it to fail because of feature flag
255+ # inconsistencies. It should be moved to its own test when
256+ # the above bug is resolved.
257+ # BugNotificationLevel.NOTHING isn't considered valid when
258+ # someone is trying to subscribe.
259+ with feature_flags():
260+ with person_logged_in(person):
261+ level = BugNotificationLevel.NOTHING
262+ harness = LaunchpadFormHarness(
263+ bug.default_bugtask, BugSubscriptionSubscribeSelfView)
264+ form_data = {
265+ 'field.subscription': person.name,
266+ 'field.bug_notification_level': level.name,
267+ }
268+ harness.submit('continue', form_data)
269+ self.assertTrue(harness.hasErrors())
270+ self.assertEqual(
271+ 'Invalid value',
272+ harness.getFieldError('bug_notification_level'),
273+ "The view should treat BugNotificationLevel.NOTHING as an "
274+ "invalid value.")
275
276=== added file 'lib/lp/bugs/stories/bugs/xx-bug-personal-subscriptions-advanced-features.txt'
277--- lib/lp/bugs/stories/bugs/xx-bug-personal-subscriptions-advanced-features.txt 1970-01-01 00:00:00 +0000
278+++ lib/lp/bugs/stories/bugs/xx-bug-personal-subscriptions-advanced-features.txt 2010-11-01 13:08:34 +0000
279@@ -0,0 +1,43 @@
280+Advanced personal subscriptions
281+-------------------------------
282+
283+There are advanced features for bug subscriptions. These require a
284+feature flag to be turned on for them to be accessible.
285+
286+ >>> from lp.services.features.model import FeatureFlag, getFeatureStore
287+ >>> feature_store = getFeatureStore()
288+ >>> ignore = feature_store.add(FeatureFlag(
289+ ... scope=u'default',
290+ ... flag=u'malone.advanced-subscriptions.enabled',
291+ ... value=u'on', priority=1))
292+ >>> feature_store.flush()
293+
294+Now when a user visits the +subscribe page of a bug they are given the
295+option to subscribe to the bug at a given BugNotificationLevel.
296+
297+ >>> from canonical.launchpad.webapp import canonical_url
298+ >>> from lp.testing.sampledata import USER_EMAIL
299+ >>> login(USER_EMAIL)
300+ >>> bug = factory.makeBug()
301+ >>> logout()
302+ >>> user_browser.open(
303+ ... canonical_url(bug.default_bugtask, view_name='+subscribe'))
304+ >>> bug_notification_level_control = user_browser.getControl(
305+ ... name='field.bug_notification_level')
306+ >>> for control in bug_notification_level_control.controls:
307+ ... print control.optionValue
308+ COMMENTS
309+ METADATA
310+ LIFECYCLE
311+
312+The user can now subscribe to the bug at any of the given notification
313+levels. In this case, they want to subscribe to just metadata updates:
314+
315+ >>> bug_notification_level_control.getControl(
316+ ... 'Any change is made to this bug, other than a new comment '
317+ ... 'being added.').click()
318+ >>> user_browser.getControl('Continue').click()
319+
320+ >>> for message in find_tags_by_class(user_browser.contents, 'message'):
321+ ... print extract_text(message)
322+ You have been subscribed to this bug.
323
324=== modified file 'lib/lp/services/features/rulesource.py'
325--- lib/lp/services/features/rulesource.py 2010-09-29 07:13:11 +0000
326+++ lib/lp/services/features/rulesource.py 2010-11-01 13:08:34 +0000
327@@ -12,6 +12,7 @@
328 __metaclass__ = type
329
330 import re
331+from collections import namedtuple
332
333 from storm.locals import Desc
334
335@@ -21,14 +22,18 @@
336 )
337
338
339+# A convenient mapping for a feature flag rule in the database.
340+Rule = namedtuple("Rule", "flag scope priority value")
341+
342+
343 class FeatureRuleSource(object):
344 """Access feature rule sources from the database or elsewhere."""
345
346 def getAllRulesAsDict(self):
347 """Return all rule definitions.
348
349- :returns: dict from flag name to a list of
350- (scope, priority, value)
351+ :returns: dict from flag name to a list of
352+ (scope, priority, value)
353 in descending order by priority.
354 """
355 d = {}
356@@ -67,7 +72,7 @@
357 """Return a list of tuples for the parsed form of the text input.
358
359 For each non-blank line gives back a tuple of (flag, scope, priority, value).
360-
361+
362 Returns a list rather than a generator so that you see any syntax
363 errors immediately.
364 """
365@@ -90,7 +95,7 @@
366 .find(FeatureFlag)
367 .order_by(FeatureFlag.flag, Desc(FeatureFlag.priority)))
368 for r in rs:
369- yield str(r.flag), str(r.scope), r.priority, r.value
370+ yield Rule(str(r.flag), str(r.scope), r.priority, r.value)
371
372 def setAllRules(self, new_rules):
373 """Replace all existing rules with a new set.
374
375=== added file 'lib/lp/services/features/testing.py'
376--- lib/lp/services/features/testing.py 1970-01-01 00:00:00 +0000
377+++ lib/lp/services/features/testing.py 2010-11-01 13:08:34 +0000
378@@ -0,0 +1,68 @@
379+# Copyright 2010 Canonical Ltd. This software is licensed under the
380+# GNU Affero General Public License version 3 (see the file LICENSE).
381+
382+"""Helpers for writing tests that use feature flags."""
383+
384+__metaclass__ = type
385+__all__ = ['active_features']
386+
387+
388+from fixtures import Fixture
389+from lp.services.features import per_thread
390+from lp.services.features.flags import FeatureController
391+from lp.services.features.rulesource import Rule, StormFeatureRuleSource
392+
393+
394+class FeatureFixture(Fixture):
395+ """A fixture that sets a feature.
396+
397+ The fixture takes a dictonary as its constructor argument. The keys of the
398+ dictionary are features to be set.
399+
400+ Call the fixture's `setUp()' method to install the features with the
401+ desired values. Calling `cleanUp()' will restore the original values.
402+ You can also install this fixture by inheriting from
403+ `fixtures.TestWithFixtures' and then calling the TestCase's
404+ `self.useFixture()' method.
405+
406+ The fixture can also be used as a context manager. The value of the
407+ feature within the context block is set to the dictonary's key's value.
408+ The values are restored when the block exits.
409+ """
410+
411+ def __init__(self, features_dict):
412+ """Constructor.
413+
414+ :param features_dict: A dictionary-like object with keys and values
415+ that are flag names and those flags' settings.
416+ """
417+ self.desired_features = features_dict
418+
419+ def setUp(self):
420+ """Set the feature flags that this fixture is responsible for."""
421+ super(FeatureFixture, self).setUp()
422+
423+ rule_source = StormFeatureRuleSource()
424+ self.addCleanup(
425+ rule_source.setAllRules, rule_source.getAllRulesAsTuples())
426+
427+ # Create a list of the new rules. Note that rules with a None
428+ # value are quietly dropped, since you can't assign None as a
429+ # feature flag value (it would come out as u'None') and setting
430+ # a flag to None essentially means turning it off anyway.
431+ new_rules = [
432+ Rule(
433+ flag=flag_name,
434+ scope='default',
435+ priority=999,
436+ value=unicode(value))
437+ for flag_name, value in self.desired_features.iteritems()
438+ if value is not None]
439+
440+ rule_source.setAllRules(new_rules)
441+
442+
443+ original_controller = getattr(per_thread, 'features', None)
444+ controller = FeatureController(lambda _: True, rule_source)
445+ per_thread.features = controller
446+ self.addCleanup(setattr, per_thread, 'features', original_controller)
447
448=== added file 'lib/lp/services/features/tests/test_helpers.py'
449--- lib/lp/services/features/tests/test_helpers.py 1970-01-01 00:00:00 +0000
450+++ lib/lp/services/features/tests/test_helpers.py 2010-11-01 13:08:34 +0000
451@@ -0,0 +1,67 @@
452+# Copyright 2010 Canonical Ltd. This software is licensed under the
453+# GNU Affero General Public License version 3 (see the file LICENSE).
454+
455+"""Tests for the feature flags test helpers."""
456+
457+from __future__ import with_statement
458+
459+
460+__metaclass__ = type
461+__all__ = []
462+
463+from canonical.testing import layers
464+from lp.testing import TestCase
465+from lp.services.features import getFeatureFlag
466+from lp.services.features.testing import FeatureFixture
467+
468+
469+class TestFeaturesContextManager(TestCase):
470+ """Tests for the feature flags context manager test helper."""
471+
472+ layer = layers.DatabaseFunctionalLayer
473+
474+ def test_setting_one_flag_with_manager(self):
475+ flag = self.getUniqueString()
476+ value_outside_manager = getFeatureFlag(flag)
477+ value_in_manager = None
478+
479+ with FeatureFixture({flag: u'on'}):
480+ value_in_manager = getFeatureFlag(flag)
481+
482+ self.assertEqual(value_in_manager, u'on')
483+ self.assertEqual(value_outside_manager, getFeatureFlag(flag))
484+ self.assertNotEqual(value_outside_manager, value_in_manager)
485+
486+
487+class TestFeaturesFixture(TestCase):
488+ """Tests for the feature flags test fixture."""
489+
490+ layer = layers.DatabaseFunctionalLayer
491+
492+ def test_fixture_sets_one_flag_and_cleans_up_again(self):
493+ flag = self.getUniqueString()
494+ value_before_fixture_setup = getFeatureFlag(flag)
495+ value_after_fixture_setup = None
496+
497+ fixture = FeatureFixture({flag: 'on'})
498+ fixture.setUp()
499+ value_after_fixture_setup = getFeatureFlag(flag)
500+ fixture.cleanUp()
501+
502+ self.assertEqual(value_after_fixture_setup, 'on')
503+ self.assertEqual(value_before_fixture_setup, getFeatureFlag(flag))
504+ self.assertNotEqual(
505+ value_before_fixture_setup, value_after_fixture_setup)
506+
507+ def test_fixture_deletes_existing_values(self):
508+ self.useFixture(FeatureFixture({'one': '1'}))
509+ self.useFixture(FeatureFixture({'two': '2'}))
510+
511+ self.assertEqual(getFeatureFlag('one'), None)
512+ self.assertEqual(getFeatureFlag('two'), u'2')
513+
514+ def test_fixture_overrides_previously_set_flags(self):
515+ self.useFixture(FeatureFixture({'one': '1'}))
516+ self.useFixture(FeatureFixture({'one': '5'}))
517+
518+ self.assertEqual(getFeatureFlag('one'), u'5')
519
520=== modified file 'lib/lp/services/memcache/doc/tales-cache.txt'
521--- lib/lp/services/memcache/doc/tales-cache.txt 2010-10-25 13:16:10 +0000
522+++ lib/lp/services/memcache/doc/tales-cache.txt 2010-11-01 13:08:34 +0000
523@@ -350,9 +350,9 @@
524
525 >>> from canonical.launchpad.webapp.servers import LaunchpadTestRequest
526 >>> from lp.services.features.model import FeatureFlag, getFeatureStore
527+ >>> from lp.services.features.webapp import ScopesFromRequest
528+ >>> from lp.services.features.flags import FeatureController
529 >>> from lp.services.features import per_thread
530- >>> from lp.services.features.flags import FeatureController
531- >>> from lp.services.features.webapp import ScopesFromRequest
532 >>> ignore = getFeatureStore().add(FeatureFlag(
533 ... scope=u'default', flag=u'memcache', value=u'disabled',
534 ... priority=1))