Merge lp:~mbp/launchpad/flags-gui into lp:launchpad

Proposed by Martin Pool
Status: Merged
Approved by: Jeroen T. Vermeulen
Approved revision: no longer in the source branch.
Merged at revision: 11657
Proposed branch: lp:~mbp/launchpad/flags-gui
Merge into: lp:launchpad
Diff against target: 693 lines (+476/-42)
11 files modified
lib/lp/services/features/__init__.py (+11/-1)
lib/lp/services/features/browser/configure.zcml (+25/-0)
lib/lp/services/features/browser/edit.py (+84/-0)
lib/lp/services/features/browser/tests/test_feature_editor.py (+138/-0)
lib/lp/services/features/configure.zcml (+3/-0)
lib/lp/services/features/flags.py (+19/-29)
lib/lp/services/features/model.py (+1/-0)
lib/lp/services/features/rulesource.py (+116/-0)
lib/lp/services/features/templates/feature-rules.pt (+18/-0)
lib/lp/services/features/tests/test_flags.py (+58/-11)
lib/lp/services/features/webapp.py (+3/-1)
To merge this branch: bzr merge lp:~mbp/launchpad/flags-gui
Reviewer Review Type Date Requested Status
Jeroen T. Vermeulen (community) code Approve
Launchpad code reviewers Pending
Review via email: mp+36415@code.launchpad.net

Commit message

Feature flags UI.

Description of the change

This adds two pages, https://launchpad.dev/+feature-rules andttps://launchpad.dev/+feature-rules-edit/
through which you can control feature flags.

The former is readonly and for ~launchpad; the latter is only for ~admins.

See screenshot <https://bugs.edge.launchpad.net/launchpad-foundations/+bug/616631/+attachment/1625731/+files/20100923-feature-rules.png>
Very simple one-rule-per-line textarea, which is the simplest thing that could possibly work.

This could do with a couple more tests for the ui, and perhaps some testing/handling of syntax errors, but aside from that should be good to go.

To post a comment you must log in.
Revision history for this message
Jeroen T. Vermeulen (jtv) wrote :
Download full text (18.8 KiB)

Hi Martin,

The UI isn't very nice, but since this is meant for internal use only I can see the value in getting something simple working quickly. However I don't think this branch is quite ready yet.

  1 === added directory 'lib/lp/services/features/browser'
  2 === added file 'lib/lp/services/features/browser/__init__.py'
  3 --- lib/lp/services/features/browser/__init__.py 1970-01-01 00:00:00 +0000
  4 +++ lib/lp/services/features/browser/__init__.py 2010-09-27 08:17:44 +0000
  5 @@ -0,0 +1,45 @@
  6 +# Copyright 2010 Canonical Ltd. This software is licensed under the
  7 +# GNU Affero General Public License version 3 (see the file LICENSE).

I don't much care for putting code in __init__.py. I can see that it keeps the imports shorter, but it's unusual enough that it may be easy to miss.

  8 +
  9 +"""Feature control view"""
 10 +

Where's the __all__?

 11 +
 12 +import logging
 13 +
 14 +
 15 +from zope.interface import Interface
 16 +from zope.schema import (
 17 + Text,
 18 + )
 19 +
 20 +from canonical.launchpad.webapp import (
 21 + action,
 22 + LaunchpadFormView,
 23 + )
 24 +
 25 +
 26 +class IFeatureControlForm(Interface):
 27 + """Interface specifically for editing a text form of feature rules"""
 28 +
 29 + def __init__(self, context):
 30 + self.context = context
 31 +
 32 + feature_rules = Text(title=u"Feature rules")
 33 +
 34 +
 35 +class FeatureControlView(LaunchpadFormView):

Needs a docstring.

 91 === added file 'lib/lp/services/features/browser/tests/test_feature_editor.py'
 92 --- lib/lp/services/features/browser/tests/test_feature_editor.py 1970-01-01 00:00:00 +0000
 93 +++ lib/lp/services/features/browser/tests/test_feature_editor.py 2010-09-27 08:17:44 +0000

130 +class TestFeatureControlPage(BrowserTestCase):
131 +
132 + layer = DatabaseFunctionalLayer
133 +
134 + def getUserBrowserAsTeamMember(self, url, teams):
135 + """Make a TestBrowser authenticated as a team member.
136 +
137 + :param teams: List of teams to add the new user to.
138 + """

This method dithers between being a test setup helper and a test verification helper. Things end up muddled: when you expect getUserBrowserAsTeamMember to raise an authorization error, you're making an assertion about the "open the page" part but the code makes it look as if you're talking about the "add user to teams" part instead.

139 + # XXX bug=646563: To make a UserBrowser, you must know the password. This
140 + # should be separated out into test infrastructure. -- mbp 20100923

The XXX format as I recall it goes:

    # XXX MartinPool 2010-09-23 bug=646563: To make a UserBrowser, you
    # etc.

141 + user = self.factory.makePerson(password='test')
142 + for team in teams:
143 + with person_logged_in(team.teamowner):
144 + team.addMember(user, reviewer=team.teamowner)
145 + return self.getUserBrowser(url, user=user, password='test')

I would suggest not opening "url" here, so that getUserBrowserAsTeamMember can serve as an unmistakable setup helper. That also leaves you free to assume an admin identity just once (or remove...

review: Needs Fixing (code)
Revision history for this message
Martin Pool (mbp) wrote :
Download full text (23.3 KiB)

Hi, thanks for putting so much work into the review. Everything I
haven't commented on I agree with.

> The UI isn't very nice, but since this is meant for internal use only I can see the value in getting something simple working quickly.

Right, getting something better than raw sql is useful, and perhaps
some experience with it will show what people most want to have
changed. If you have ideas about what an ideal UI would be like I'd
be happy to hear them, at least for a later branch.

On 27 September 2010 22:17, Jeroen T. Vermeulen <email address hidden> wrote:
> Review: Needs Fixing code
> Hi Martin,
>
  However I don't think this branch is quite ready yet.
>
>
>  1     === added directory 'lib/lp/services/features/browser'
>  2     === added file 'lib/lp/services/features/browser/__init__.py'
>  3     --- lib/lp/services/features/browser/__init__.py        1970-01-01 00:00:00 +0000
>  4     +++ lib/lp/services/features/browser/__init__.py        2010-09-27 08:17:44 +0000
>  5     @@ -0,0 +1,45 @@
>  6     +# Copyright 2010 Canonical Ltd.  This software is licensed under the
>  7     +# GNU Affero General Public License version 3 (see the file LICENSE).
>
> I don't much care for putting code in __init__.py.  I can see that it keeps the imports shorter, but it's unusual enough that it may be easy to miss.

OK. Can I make it just browser.py, or should I have a submodule with
a named file within it?

>
>
>  8     +
>  9     +"""Feature control view"""
>  10     +
>
> Where's the __all__?
>
>
>  11     +
>  12     +import logging
>  13     +
>  14     +
>  15     +from zope.interface import Interface
>  16     +from zope.schema import (
>  17     +    Text,
>  18     +    )
>  19     +
>  20     +from canonical.launchpad.webapp import (
>  21     +    action,
>  22     +    LaunchpadFormView,
>  23     +    )
>  24     +
>  25     +
>  26     +class IFeatureControlForm(Interface):
>  27     +    """Interface specifically for editing a text form of feature rules"""
>  28     +
>  29     +    def __init__(self, context):
>  30     +        self.context = context
>  31     +
>  32     +    feature_rules = Text(title=u"Feature rules")
>  33     +
>  34     +
>  35     +class FeatureControlView(LaunchpadFormView):
>
> Needs a docstring.
>
>
>  91     === added file 'lib/lp/services/features/browser/tests/test_feature_editor.py'
>  92     --- lib/lp/services/features/browser/tests/test_feature_editor.py       1970-01-01  00:00:00 +0000
>  93     +++ lib/lp/services/features/browser/tests/test_feature_editor.py       2010-09-27 08:17:44 +0000
>
> 130     +class TestFeatureControlPage(BrowserTestCase):
> 131     +
> 132     +    layer = DatabaseFunctionalLayer
> 133     +
> 134     +    def getUserBrowserAsTeamMember(self, url, teams):
> 135     +        """Make a TestBrowser authenticated as a team member.
> 136     +
> 137     +        :param teams: List of teams to add the new user to.
> 138     +        """
>
> This method dithers between being a test setup helper and a test verification helper.  Things end up muddled: when you expect getUserBrowserAsTeamMember to raise an authorization error, you're making an assertion about the "open the page"...

Revision history for this message
Martin Pool (mbp) wrote :

Oh, I'll just mention that one reason i used a textarea even for the readonly view is so that the rules can easily be copied/pasted, mailed, and then re-pasted back in to the writable view. So I will stick with that for now, but add some text explaining how the fields should be interpreted.

Revision history for this message
Martin Pool (mbp) wrote :

> I don't much care for putting code in __init__.py. I can see that it keeps the imports shorter, but it's unusual enough that it may be easy to miss.

I realize you said this an a preference not a rule, but I got the idea this was common because there are dozens of existing modules with nontrivial inits. Perhaps that's no longer prefered. If so, it should go into https://dev.launchpad.net/PythonStyleGuide.

>> 147 + def getFeatureRulesURL(self):
> Needs a docstring.

This is the kind of thing where I question "everything should have a docstring." It's in test code; it's only used in this class; the name is pretty selfexplanatory and it's only two lines of code.

> A pilot error would lead to an oops in the best case or lots of inconvenienced users in the worst case. What if they accidentally enter one field too many, for instance? What of duplicate lines? What of near-duplicate lines with different values? Even if the data is meant to be entered only by careful and usually sober admins, I'd like to see a bit more checking and a lot more testing!

If we look at the various species of pilot error:

0- syntactically invalid, for instance having not enough fields or conflicting priorities: you'll get an oops; it would be much nicer to give you a clean message explaining where you went wrong. however the change won't be committed to the database and it has no lasting impact.

1 - syntactically valid, but not what you meant to say: will be committed to the database and may cause arbitrarily weird things to happen to users.

To me 1 is more important to guard against for obvious reasons.

To reduce the chance of that happening there are various things we can do:
 * show a diff of the changes before they're applied
 * have a smarter editor that knows what options are available and what values they can take
 * allow reverting changes
 * store and show a history of changes

I think fixing 1 will largely obsolete 0 because we'll go away from just having a plaintext editor; so I'm not so interested in fixing it by itself. (This is, I admit, also an argument from impatience.)

I think the risk is tolerable because the people with access to this already can and do run arbitrary shell and sql commands, and the damage they can do here is strictly less than is possible with them. I would like to get this just rolling too, and give them something better than raw sql to use.

Revision history for this message
Martin Pool (mbp) wrote :

The current changes address, I think, everything substantial here. The ui is not ideal, but I think is better than raw sql, and I'm impatient to get something like that up.

I may see about changing it to have just one url, that appears in either readonly or read-write mode as appropriate.

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

On Tue, Sep 28, 2010 at 10:23 PM, Martin Pool <email address hidden> wrote:
>> I don't much care for putting code in __init__.py.  I can see that it keeps the imports shorter, but it's unusual enough that it may be easy to miss.
>
> I realize you said this an a preference not a rule, but I got the idea this was common because there are dozens of existing modules with nontrivial inits.  Perhaps that's no longer prefered.  If so, it should go into https://dev.launchpad.net/PythonStyleGuide.

Putting stuff in __init__ is fine. If its not clear, or the file is
'large', split it out - but thats a normal rule anyhow.

>>> 147     +    def getFeatureRulesURL(self):
>> Needs a docstring.
>
> This is the kind of thing where I question "everything should have a docstring."  It's in test code; it's only used in this class; the name is pretty selfexplanatory and it's only two lines of code.

Agreed; I certainly woudn't put a docstring unless its nonobvious what it is.

> I think the risk is tolerable because the people with access to this already can and do run arbitrary shell and sql commands, and the damage they can do here is strictly less than is possible with them.  I would like to get this just rolling too, and give them something better than raw sql to use.

Also by definition these config values will (at worst) enable a
feature to users that didn't expect it (or conversely disable things
we didn't want disabled [because they needed an explicit-on to work].

Not a huge issue. I like the idea of a diff showing 'this is what
changed', if you care to do it - but I wouldn't block on having it.

-Rob

Revision history for this message
Martin Pool (mbp) wrote :

I'm going to do one more change here, which is to consider unifying
the view and edit pages, which will also fix an issue of a useless
"submit" button appearing on the view page, and perhaps will remove
some unnecessary repetition. Then I think this is ready to land; more
can be done both in UI and other aspects of flags but this is an
incremental improvement.

Revision history for this message
Jeroen T. Vermeulen (jtv) wrote :
Download full text (6.9 KiB)

Hi Martin,

Following up in two parts. This is part one. I, too, will skip the parts I agree with and which seem to require no further action.

> >  1     === added directory 'lib/lp/services/features/browser'
> >  2     === added file 'lib/lp/services/features/browser/__init__.py'
> >  3     --- lib/lp/services/features/browser/__init__.py        1970-01-01
> 00:00:00 +0000
> >  4     +++ lib/lp/services/features/browser/__init__.py        2010-09-27
> 08:17:44 +0000
> >  5     @@ -0,0 +1,45 @@
> >  6     +# Copyright 2010 Canonical Ltd.  This software is licensed under the
> >  7     +# GNU Affero General Public License version 3 (see the file
> LICENSE).
> >
> > I don't much care for putting code in __init__.py.  I can see that it keeps
> the imports shorter, but it's unusual enough that it may be easy to miss.
>
> OK. Can I make it just browser.py, or should I have a submodule with
> a named file within it?

If a nontrivial __init__.py is already a normal thing to write, then there's no reason to worry about it here after all. So it's fine to leave it as it is.

> >  91     === added file
> 'lib/lp/services/features/browser/tests/test_feature_editor.py'
> >  92     --- lib/lp/services/features/browser/tests/test_feature_editor.py
> 1970-01-01  00:00:00 +0000
> >  93     +++ lib/lp/services/features/browser/tests/test_feature_editor.py
> 2010-09-27 08:17:44 +0000
> >
> > 130     +class TestFeatureControlPage(BrowserTestCase):
> > 131     +
> > 132     +    layer = DatabaseFunctionalLayer
> > 133     +
> > 134     +    def getUserBrowserAsTeamMember(self, url, teams):
> > 135     +        """Make a TestBrowser authenticated as a team member.
> > 136     +
> > 137     +        :param teams: List of teams to add the new user to.
> > 138     +        """
> >
> > This method dithers between being a test setup helper and a test
> verification helper.  Things end up muddled: when you expect
> getUserBrowserAsTeamMember to raise an authorization error, you're making an
> assertion about the "open the page" part but the code makes it look as if
> you're talking about the "add user to teams" part instead.
>
> Hm, so perhaps (if it's possible) it would be clearer to write something like:
>
> browser = getUserBrowserAsTeamMember(teams)
> self.assertRaises(Unauthorized, browser.open(url))

Yes, exactly. And AFAICS that should work.

> > 152     +    def getFeaturePageBrowserAsAdmin(self):
> > 153     +        url = self.getFeatureRulesURL()
> > 154     +        admin_team = getUtility(ILaunchpadCelebrities).admin
> > 155     +        return self.getUserBrowserAsTeamMember(url, [admin_team])
> >
> > It looks to me as if BrowserTestCase ought to have a "getAdminBrowser."
> Have you considered creating one?
>
> Right, that's bug 646563, but this branch is getting big enough already.

Very well.

> > 165     +    def test_feature_page_from_database(self):
> > 166     +        addFeatureFlagRules([
> > 167     +            ('default', 'ui.icing', u'3.0', 100),
> > 168     +            ('beta_user', 'ui.icing', u'4.0', 300),
> > 169     +            ])
> > 170     +        browser = self.getFeaturePageBrowserAsAdmin()
> > 171     +        te...

Read more...

Revision history for this message
Jeroen T. Vermeulen (jtv) wrote :

This is part two of my follow-up. I'm close to an Approved vote, but a quick glance at the current diff still shows the irregularly indented test strings. Have you been fixing the formatting problems I pointed out?

> >> 147 + def getFeatureRulesURL(self):
> > Needs a docstring.
>
> This is the kind of thing where I question "everything should have a
> docstring." It's in test code; it's only used in this class; the name is
> pretty selfexplanatory and it's only two lines of code.

It would have been, apart from the uncertainty over the read/edit page dichotomy. Your updated test has methods for both, making this a lot clearer to me. With that I no longer care about the docstring.

> > A pilot error would lead to an oops in the best case or lots of
> inconvenienced users in the worst case. What if they accidentally enter one
> field too many, for instance? What of duplicate lines? What of near-
> duplicate lines with different values? Even if the data is meant to be entered
> only by careful and usually sober admins, I'd like to see a bit more checking
> and a lot more testing!
>
> If we look at the various species of pilot error:
>
> 0- syntactically invalid, for instance having not enough fields or conflicting
> priorities: you'll get an oops; it would be much nicer to give you a clean
> message explaining where you went wrong. however the change won't be
> committed to the database and it has no lasting impact.
>
> 1 - syntactically valid, but not what you meant to say: will be committed to
> the database and may cause arbitrarily weird things to happen to users.
>
> To me 1 is more important to guard against for obvious reasons.
>
> To reduce the chance of that happening there are various things we can do:
> * show a diff of the changes before they're applied
> * have a smarter editor that knows what options are available and what values
> they can take
> * allow reverting changes
> * store and show a history of changes
>
> I think fixing 1 will largely obsolete 0 because we'll go away from just
> having a plaintext editor; so I'm not so interested in fixing it by itself.
> (This is, I admit, also an argument from impatience.)
>
> I think the risk is tolerable because the people with access to this already
> can and do run arbitrary shell and sql commands, and the damage they can do
> here is strictly less than is possible with them. I would like to get this
> just rolling too, and give them something better than raw sql to use.

Still, a separate input validation pass will more or less automatically lead to better error detection and reporting. On IRC you mentioned that you can do that in a validate method, which is the right way to do it. Adding the explanatory text will also help make some mistakes more obvious.

Jeroen

Revision history for this message
Martin Pool (mbp) wrote :

> > OK. Can I make it just browser.py, or should I have a submodule with
> > a named file within it?
>
> If a nontrivial __init__.py is already a normal thing to write, then there's
> no reason to worry about it here after all. So it's fine to leave it as it
> is.

I already moved it, and perhaps it's better that way as it leaves room for growth.

> > Hm, so perhaps (if it's possible) it would be clearer to write something
> like:
> >
> > browser = getUserBrowserAsTeamMember(teams)
> > self.assertRaises(Unauthorized, browser.open(url))
>
> Yes, exactly. And AFAICS that should work.

Yep, and that is better.

> I think ignoring whitespace would be a fine thing to do, assuming that a
> separate test verifies that the whitespace conforms to expectations.
>
> A Matcher would be very nice here.

Done, and reformatted to not be specially indented.

>

> > Actually I think for most testing, we want to just change the
> > featurecontroller, not actually change the database. And perhaps it
> > should be a test fixture not on that monolith class?
>
> I could imagine a need for both "create a rule in the database" and "create a
> rule in whatever rules source I'm currently using." Which you want this
> function for is up to you, and I defer to your judgment.

There's some other existing code that's directly poking things in to the db. I'd like to reconsider the way it sets things up, but separately from this.

Revision history for this message
Martin Pool (mbp) wrote :

> This is part two of my follow-up. I'm close to an Approved vote, but a quick
> glance at the current diff still shows the irregularly indented test strings.
> Have you been fixing the formatting problems I pointed out?

I have, and now that one is done too.

> Still, a separate input validation pass will more or less automatically lead
> to better error detection and reporting. On IRC you mentioned that you can do
> that in a validate method, which is the right way to do it. Adding the
> explanatory text will also help make some mistakes more obvious.

I did that.

Revision history for this message
Jeroen T. Vermeulen (jtv) wrote :

Much better, thanks. Still a bit of un-indented string in there, but believe it or not I'm not here to stop you from getting your branch in. :)

I'll proceed to land it for you, as requested.

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/services/features/__init__.py'
2--- lib/lp/services/features/__init__.py 2010-09-21 13:16:25 +0000
3+++ lib/lp/services/features/__init__.py 2010-09-29 08:38:48 +0000
4@@ -137,6 +137,7 @@
5
6
7 __all__ = [
8+ 'get_relevant_feature_controller',
9 'getFeatureFlag',
10 'per_thread',
11 ]
12@@ -150,11 +151,20 @@
13 """
14
15
16+def get_relevant_feature_controller():
17+ """Get a FeatureController for this thread."""
18+
19+ # The noncommittal name "relevant" is because this function may change to
20+ # look things up from the current request or some other mechanism in
21+ # future.
22+ return getattr(per_thread, 'features', None)
23+
24+
25 def getFeatureFlag(flag):
26 """Get the value of a flag for this thread's scopes."""
27 # Workaround for bug 631884 - features have two homes, threads and
28 # requests.
29- features = getattr(per_thread, 'features', None)
30+ features = get_relevant_feature_controller()
31 if features is None:
32 return None
33 return features.getFlag(flag)
34
35=== added directory 'lib/lp/services/features/browser'
36=== added file 'lib/lp/services/features/browser/__init__.py'
37=== added file 'lib/lp/services/features/browser/configure.zcml'
38--- lib/lp/services/features/browser/configure.zcml 1970-01-01 00:00:00 +0000
39+++ lib/lp/services/features/browser/configure.zcml 2010-09-29 08:38:48 +0000
40@@ -0,0 +1,25 @@
41+<!-- Copyright 2010 Canonical Ltd. This software is licensed under the
42+ GNU Affero General Public License version 3 (see the file LICENSE).
43+-->
44+
45+<configure
46+ xmlns="http://namespaces.zope.org/zope"
47+ xmlns:browser="http://namespaces.zope.org/browser"
48+ xmlns:i18n="http://namespaces.zope.org/i18n"
49+ xmlns:xmlrpc="http://namespaces.zope.org/xmlrpc"
50+ i18n_domain="launchpad">
51+
52+ <!-- View or edit all feature rules.
53+
54+ Readonly access is guarded by launchpad.Edit on ILaunchpadRoot, which
55+ limits it to ~admins + ~registry, which are all trusted users. Write access
56+ is for admins only.
57+ -->
58+ <browser:page
59+ for="canonical.launchpad.interfaces.ILaunchpadRoot"
60+ class="lp.services.features.browser.edit.FeatureControlView"
61+ name="+feature-rules"
62+ permission="launchpad.Edit"
63+ template="../templates/feature-rules.pt"/>
64+
65+</configure>
66
67=== added file 'lib/lp/services/features/browser/edit.py'
68--- lib/lp/services/features/browser/edit.py 1970-01-01 00:00:00 +0000
69+++ lib/lp/services/features/browser/edit.py 2010-09-29 08:38:48 +0000
70@@ -0,0 +1,84 @@
71+# Copyright 2010 Canonical Ltd. This software is licensed under the
72+# GNU Affero General Public License version 3 (see the file LICENSE).
73+
74+"""View and edit feature rules."""
75+
76+__metaclass__ = type
77+__all__ = [
78+ 'FeatureControlView',
79+ 'IFeatureControlForm',
80+ ]
81+
82+
83+import logging
84+
85+
86+from zope.interface import Interface
87+from zope.schema import (
88+ Text,
89+ )
90+
91+from canonical.launchpad.webapp import (
92+ action,
93+ enabled_with_permission,
94+ LaunchpadFormView,
95+ )
96+from canonical.launchpad.webapp.authorization import (
97+ check_permission,
98+ )
99+
100+
101+class IFeatureControlForm(Interface):
102+ """Interface specifically for editing a text form of feature rules"""
103+
104+ def __init__(self, context):
105+ self.context = context
106+
107+ feature_rules = Text(
108+ title=u"Feature rules",
109+ description=(
110+ u"Rules to control feature flags on Launchpad. "
111+ u"On each line: (flag, scope, priority, value), "
112+ u"whitespace-separated. Numerically higher "
113+ u"priorities match first."
114+ ),
115+ required=False,
116+ )
117+
118+
119+class FeatureControlView(LaunchpadFormView):
120+ """Text view of feature rules.
121+
122+ Presents a text area, either read-only or read-write, showing currently
123+ active rules.
124+ """
125+
126+ schema = IFeatureControlForm
127+ page_title = label = 'Feature control'
128+ field_names = ['feature_rules']
129+
130+ @action(u"Change", name="change")
131+ def change_action(self, action, data):
132+ if not check_permission('launchpad.Admin', self.context):
133+ raise Unauthorized()
134+ rules_text = data.get('feature_rules') or ''
135+ logger = logging.getLogger('lp.services.features')
136+ logger.warning("Change feature rules to: %s" % (rules_text,))
137+ self.request.features.rule_source.setAllRulesFromText(
138+ rules_text)
139+
140+ @property
141+ def initial_values(self):
142+ return dict(
143+ feature_rules=self.request.features.rule_source.getAllRulesAsText(),
144+ )
145+
146+ def validate(self, data):
147+ # Try parsing the rules so we give a clean error: at the moment the
148+ # message is not great, but it's better than an oops.
149+ try:
150+ # Unfortunately if the field is '', zope leaves it out of data.
151+ self.request.features.rule_source.parseRules(
152+ data.get('feature_rules') or '')
153+ except (IndexError, TypeError, ValueError), e:
154+ self.setFieldError('feature_rules', 'Invalid rule syntax: %s' % e)
155
156=== added directory 'lib/lp/services/features/browser/tests'
157=== added file 'lib/lp/services/features/browser/tests/__init__.py'
158=== added file 'lib/lp/services/features/browser/tests/test_feature_editor.py'
159--- lib/lp/services/features/browser/tests/test_feature_editor.py 1970-01-01 00:00:00 +0000
160+++ lib/lp/services/features/browser/tests/test_feature_editor.py 2010-09-29 08:38:48 +0000
161@@ -0,0 +1,138 @@
162+# Copyright 2010 Canonical Ltd. This software is licensed under the
163+# GNU Affero General Public License version 3 (see the file LICENSE).
164+
165+"""Tests for feature rule editor"""
166+
167+__metaclass__ = type
168+
169+
170+from testtools.matchers import (
171+ Equals,
172+ )
173+
174+from zope.component import getUtility
175+from zope.security.interfaces import Unauthorized
176+
177+from canonical.launchpad.interfaces import (
178+ ILaunchpadCelebrities,
179+ ILaunchpadRoot,
180+ )
181+from canonical.launchpad.webapp import canonical_url
182+from canonical.testing.layers import DatabaseFunctionalLayer
183+from lp.testing import (
184+ BrowserTestCase,
185+ TestCase,
186+ TestCaseWithFactory,
187+ celebrity_logged_in,
188+ login_person,
189+ person_logged_in,
190+ time_counter,
191+ )
192+
193+from lp.services.features import (
194+ get_relevant_feature_controller,
195+ )
196+from lp.services.features.browser.edit import (
197+ FeatureControlView,
198+ )
199+from lp.services.features.rulesource import (
200+ StormFeatureRuleSource,
201+ )
202+
203+
204+class TestFeatureControlPage(BrowserTestCase):
205+
206+ layer = DatabaseFunctionalLayer
207+
208+ def getUserBrowserAsTeamMember(self, teams):
209+ """Make a TestBrowser authenticated as a team member.
210+
211+ :param teams: List of teams to add the new user to.
212+ """
213+ # XXX MartinPool 2010-09-23 bug=646563: To make a UserBrowser, you
214+ # must know the password; we can't get the password for an existing
215+ # user so we have to make a new one.
216+ user = self.factory.makePerson(password='test')
217+ for team in teams:
218+ with person_logged_in(team.teamowner):
219+ team.addMember(user, reviewer=team.teamowner)
220+ return self.getUserBrowser(url=None, user=user, password='test')
221+
222+ def getUserBrowserAsAdmin(self):
223+ """Make a new TestBrowser logged in as an admin user."""
224+ url = self.getFeatureRulesViewURL()
225+ admin_team = getUtility(ILaunchpadCelebrities).admin
226+ return self.getUserBrowserAsTeamMember([admin_team])
227+
228+ def getFeatureRulesViewURL(self):
229+ root = getUtility(ILaunchpadRoot)
230+ return canonical_url(root, view_name='+feature-rules')
231+
232+ def getFeatureRulesEditURL(self):
233+ root = getUtility(ILaunchpadRoot)
234+ return canonical_url(root, view_name='+feature-rules')
235+
236+ def test_feature_page_default_value(self):
237+ """No rules in the sampledata gives no content in the page"""
238+ browser = self.getUserBrowserAsAdmin()
239+ browser.open(self.getFeatureRulesViewURL())
240+ textarea = browser.getControl(name="field.feature_rules")
241+ # and by default, since there are no rules in the sample data, it's
242+ # empty
243+ self.assertThat(textarea.value, Equals(''))
244+
245+ def test_feature_page_from_database(self):
246+ StormFeatureRuleSource().setAllRules([
247+ ('ui.icing', 'default', 100, u'3.0'),
248+ ('ui.icing', 'beta_user', 300, u'4.0'),
249+ ])
250+ browser = self.getUserBrowserAsAdmin()
251+ browser.open(self.getFeatureRulesViewURL())
252+ textarea = browser.getControl(name="field.feature_rules")
253+ self.assertThat(
254+ textarea.value.replace('\r', ''),
255+ Equals(
256+ "ui.icing\tbeta_user\t300\t4.0\n"
257+ "ui.icing\tdefault\t100\t3.0\n"))
258+
259+ def test_feature_rules_anonymous_unauthorized(self):
260+ browser = self.getUserBrowser()
261+ self.assertRaises(Unauthorized,
262+ browser.open,
263+ self.getFeatureRulesViewURL())
264+
265+ def test_feature_rules_plebian_unauthorized(self):
266+ """Logged in, but not a member of any interesting teams."""
267+ browser = self.getUserBrowserAsTeamMember([])
268+ self.assertRaises(Unauthorized,
269+ browser.open,
270+ self.getFeatureRulesViewURL())
271+
272+ def test_feature_page_submit_changes(self):
273+ """Submitted changes show up in the db."""
274+ browser = self.getUserBrowserAsAdmin()
275+ browser.open(self.getFeatureRulesEditURL())
276+ new_value = 'beta_user some_key 10 some value with spaces'
277+ textarea = browser.getControl(name="field.feature_rules")
278+ textarea.value = new_value
279+ browser.getControl(name="field.actions.change").click()
280+ self.assertThat(
281+ list(StormFeatureRuleSource().getAllRulesAsTuples()),
282+ Equals([
283+ ('beta_user', 'some_key', 10, 'some value with spaces'),
284+ ]))
285+
286+ def test_feature_page_submit_change_to_empty(self):
287+ """Correctly handle submitting an empty value."""
288+ # Zope has the quirk of conflating empty with absent; make sure we
289+ # handle it properly.
290+ browser = self.getUserBrowserAsAdmin()
291+ browser.open(self.getFeatureRulesEditURL())
292+ new_value = ''
293+ textarea = browser.getControl(name="field.feature_rules")
294+ textarea.value = new_value
295+ browser.getControl(name="field.actions.change").click()
296+ self.assertThat(
297+ list(StormFeatureRuleSource().getAllRulesAsTuples()),
298+ Equals([
299+ ]))
300
301=== modified file 'lib/lp/services/features/configure.zcml'
302--- lib/lp/services/features/configure.zcml 2010-07-23 14:28:53 +0000
303+++ lib/lp/services/features/configure.zcml 2010-09-29 08:38:48 +0000
304@@ -6,6 +6,9 @@
305 xmlns="http://namespaces.zope.org/zope"
306 xmlns:browser="http://namespaces.zope.org/browser">
307
308+ <include
309+ package=".browser"/>
310+
311 <subscriber
312 for="canonical.launchpad.webapp.interfaces.IStartRequestEvent"
313 handler="lp.services.features.webapp.start_request"
314
315=== modified file 'lib/lp/services/features/flags.py'
316--- lib/lp/services/features/flags.py 2010-08-18 08:51:26 +0000
317+++ lib/lp/services/features/flags.py 2010-09-29 08:38:48 +0000
318@@ -7,17 +7,14 @@
319 ]
320
321
322+from lp.services.features.rulesource import (
323+ StormFeatureRuleSource,
324+ )
325+
326+
327 __metaclass__ = type
328
329
330-from storm.locals import Desc
331-
332-from lp.services.features.model import (
333- FeatureFlag,
334- getFeatureStore,
335- )
336-
337-
338 class Memoize(object):
339
340 def __init__(self, calc):
341@@ -65,11 +62,11 @@
342 of code that has consistent configuration values. For instance there will
343 be one per web app request.
344
345- Intended performance: when this object is first constructed, it will read
346- the whole feature flag table from the database. It is expected to be
347- reasonably small. The scopes may be expensive to compute (eg checking
348- team membership) so they are checked at most once when they are first
349- needed.
350+ Intended performance: when this object is first asked about a flag, it
351+ will read the whole feature flag table from the database. It is expected
352+ to be reasonably small. The scopes may be expensive to compute (eg
353+ checking team membership) so they are checked at most once when
354+ they are first needed.
355
356 The controller is then supposed to be held in a thread-local and reused
357 for the duration of the request.
358@@ -77,17 +74,22 @@
359 @see: U{https://dev.launchpad.net/LEP/FeatureFlags}
360 """
361
362- def __init__(self, scope_check_callback):
363+ def __init__(self, scope_check_callback, rule_source=None):
364 """Construct a new view of the features for a set of scopes.
365
366 :param scope_check_callback: Given a scope name, says whether
367- it's active or not.
368+ it's active or not.
369+
370+ :param rule_source: Instance of StormFeatureRuleSource or similar.
371 """
372 self._known_scopes = Memoize(scope_check_callback)
373 self._known_flags = Memoize(self._checkFlag)
374 # rules are read from the database the first time they're needed
375 self._rules = None
376 self.scopes = ScopeDict(self)
377+ if rule_source is None:
378+ rule_source = StormFeatureRuleSource()
379+ self.rule_source = rule_source
380
381 def getFlag(self, flag):
382 """Get the value of a specific flag.
383@@ -102,7 +104,7 @@
384 def _checkFlag(self, flag):
385 self._needRules()
386 if flag in self._rules:
387- for scope, value in self._rules[flag]:
388+ for scope, priority, value in self._rules[flag]:
389 if self._known_scopes.lookup(scope):
390 return value
391
392@@ -132,21 +134,9 @@
393 self._needRules()
394 return dict((f, self.getFlag(f)) for f in self._rules)
395
396- def _loadRules(self):
397- store = getFeatureStore()
398- d = {}
399- rs = (store
400- .find(FeatureFlag)
401- .order_by(Desc(FeatureFlag.priority))
402- .values(FeatureFlag.flag, FeatureFlag.scope,
403- FeatureFlag.value))
404- for flag, scope, value in rs:
405- d.setdefault(str(flag), []).append((str(scope), value))
406- return d
407-
408 def _needRules(self):
409 if self._rules is None:
410- self._rules = self._loadRules()
411+ self._rules = self.rule_source.getAllRulesAsDict()
412
413 def usedFlags(self):
414 """Return dict of flags used in this controller so far."""
415
416=== modified file 'lib/lp/services/features/model.py'
417--- lib/lp/services/features/model.py 2010-08-20 20:31:18 +0000
418+++ lib/lp/services/features/model.py 2010-09-29 08:38:48 +0000
419@@ -10,6 +10,7 @@
420
421 from storm.locals import (
422 DateTime,
423+ Desc,
424 Int,
425 Storm,
426 Unicode,
427
428=== added file 'lib/lp/services/features/rulesource.py'
429--- lib/lp/services/features/rulesource.py 1970-01-01 00:00:00 +0000
430+++ lib/lp/services/features/rulesource.py 2010-09-29 08:38:48 +0000
431@@ -0,0 +1,116 @@
432+# Copyright 2010 Canonical Ltd. This software is licensed under the
433+# GNU Affero General Public License version 3 (see the file LICENSE).
434+
435+"""Returns rules defining which features are active"""
436+
437+__all__ = [
438+ 'FeatureRuleSource',
439+ 'NullFeatureRuleSource',
440+ 'StormFeatureRuleSource',
441+ ]
442+
443+__metaclass__ = type
444+
445+import re
446+
447+from storm.locals import Desc
448+
449+from lp.services.features.model import (
450+ FeatureFlag,
451+ getFeatureStore,
452+ )
453+
454+
455+class FeatureRuleSource(object):
456+ """Access feature rule sources from the database or elsewhere."""
457+
458+ def getAllRulesAsDict(self):
459+ """Return all rule definitions.
460+
461+ :returns: dict from flag name to a list of
462+ (scope, priority, value)
463+ in descending order by priority.
464+ """
465+ d = {}
466+ for (flag, scope, priority, value) in self.getAllRulesAsTuples():
467+ d.setdefault(str(flag), []).append((str(scope), priority, value))
468+ return d
469+
470+ def getAllRulesAsTuples(self):
471+ """Generate list of (flag, scope, priority, value)"""
472+ raise NotImplementedError()
473+
474+ def getAllRulesAsText(self):
475+ """Return a text for of the rules.
476+
477+ This has one line per rule, with tab-separate
478+ (flag, scope, prioirity, value), as used in the flag editor web
479+ interface.
480+ """
481+ tr = []
482+ for (flag, scope, priority, value) in self.getAllRulesAsTuples():
483+ tr.append('\t'.join((flag, scope, str(priority), value)))
484+ tr.append('')
485+ return '\n'.join(tr)
486+
487+ def setAllRulesFromText(self, text_form):
488+ """Update all rules from text input.
489+
490+ The input is similar in form to that generated by getAllRulesAsText:
491+ one line per rule, with whitespace-separated (flag, scope,
492+ priority, value). Whitespace is allowed in the flag value.
493+
494+ """
495+ self.setAllRules(self.parseRules(text_form))
496+
497+ def parseRules(self, text_form):
498+ """Return a list of tuples for the parsed form of the text input.
499+
500+ For each non-blank line gives back a tuple of (flag, scope, priority, value).
501+
502+ Returns a list rather than a generator so that you see any syntax
503+ errors immediately.
504+ """
505+ r = []
506+ for line in text_form.splitlines():
507+ if line.strip() == '':
508+ continue
509+ flag, scope, priority_str, value = re.split('[ \t]+', line, 3)
510+ r.append((flag, scope, int(priority_str), unicode(value)))
511+ return r
512+
513+
514+class StormFeatureRuleSource(FeatureRuleSource):
515+ """Access feature rules stored in the database via Storm.
516+ """
517+
518+ def getAllRulesAsTuples(self):
519+ store = getFeatureStore()
520+ rs = (store
521+ .find(FeatureFlag)
522+ .order_by(FeatureFlag.flag, Desc(FeatureFlag.priority)))
523+ for r in rs:
524+ yield str(r.flag), str(r.scope), r.priority, r.value
525+
526+ def setAllRules(self, new_rules):
527+ """Replace all existing rules with a new set.
528+
529+ :param new_rules: List of (name, scope, priority, value) tuples.
530+ """
531+ # XXX: would be slightly better to only update rules as necessary so we keep
532+ # timestamps, and to avoid the direct sql etc -- mbp 20100924
533+ store = getFeatureStore()
534+ store.execute('DELETE FROM FeatureFlag')
535+ for (flag, scope, priority, value) in new_rules:
536+ store.add(FeatureFlag(
537+ scope=unicode(scope),
538+ flag=unicode(flag),
539+ value=value,
540+ priority=priority))
541+
542+
543+class NullFeatureRuleSource(FeatureRuleSource):
544+ """For use in testing: everything is turned off"""
545+
546+ def getAllRulesAsTuples(self):
547+ return []
548
549=== added directory 'lib/lp/services/features/templates'
550=== added file 'lib/lp/services/features/templates/feature-rules.pt'
551--- lib/lp/services/features/templates/feature-rules.pt 1970-01-01 00:00:00 +0000
552+++ lib/lp/services/features/templates/feature-rules.pt 2010-09-29 08:38:48 +0000
553@@ -0,0 +1,18 @@
554+<html
555+ xmlns="http://www.w3.org/1999/xhtml"
556+ xmlns:tal="http://xml.zope.org/namespaces/tal"
557+ xmlns:metal="http://xml.zope.org/namespaces/metal"
558+ xmlns:i18n="http://xml.zope.org/namespaces/i18n"
559+ metal:use-macro="view/macro:page/main_only"
560+ i18n:domain="launchpad"
561+ tal:define="page_title string:features;">
562+
563+<body>
564+
565+<div metal:fill-slot="main">
566+
567+ <div metal:use-macro="context/@@launchpad_form/form">
568+ </div>
569+</div>
570+</body>
571+</html>
572
573=== modified file 'lib/lp/services/features/tests/test_flags.py'
574--- lib/lp/services/features/tests/test_flags.py 2010-09-12 05:19:38 +0000
575+++ lib/lp/services/features/tests/test_flags.py 2010-09-29 08:38:48 +0000
576@@ -16,6 +16,7 @@
577 per_thread,
578 )
579 from lp.services.features.flags import FeatureController
580+from lp.services.features.rulesource import StormFeatureRuleSource
581 from lp.testing import TestCase
582
583
584@@ -24,9 +25,9 @@
585
586
587 testdata = [
588- ('beta_user', notification_name, notification_value, 100),
589- ('default', 'ui.icing', u'3.0', 100),
590- ('beta_user', 'ui.icing', u'4.0', 300),
591+ (notification_name, 'beta_user', 100, notification_value),
592+ ('ui.icing', 'default', 100, u'3.0'),
593+ ('ui.icing', 'beta_user', 300, u'4.0'),
594 ]
595
596
597@@ -46,16 +47,11 @@
598 def scope_cb(scope):
599 call_log.append(scope)
600 return scope in scopes
601- return FeatureController(scope_cb), call_log
602+ controller = FeatureController(scope_cb, StormFeatureRuleSource())
603+ return controller, call_log
604
605 def populateStore(self):
606- store = model.getFeatureStore()
607- for (scope, flag, value, priority) in testdata:
608- store.add(model.FeatureFlag(
609- scope=unicode(scope),
610- flag=unicode(flag),
611- value=value,
612- priority=priority))
613+ StormFeatureRuleSource().setAllRules(testdata)
614
615 def test_getFlag(self):
616 self.populateStore()
617@@ -165,3 +161,54 @@
618 self.assertEqual(False, f.scopes['alpha_user'])
619 self.assertEqual(True, f.scopes['beta_user'])
620 self.assertEqual(['beta_user', 'alpha_user'], call_log)
621+
622+
623+test_rules_list = [
624+ (notification_name, 'beta_user', 100, notification_value),
625+ ('ui.icing', 'beta_user', 300, u'4.0'),
626+ ('ui.icing', 'default', 100, u'3.0'),
627+ ]
628+
629+
630+class TestStormFeatureRuleSource(TestCase):
631+
632+ layer = layers.DatabaseFunctionalLayer
633+
634+ def test_getAllRulesAsTuples(self):
635+ source = StormFeatureRuleSource()
636+ source.setAllRules(test_rules_list)
637+ self.assertEquals(
638+ test_rules_list,
639+ list(source.getAllRulesAsTuples()))
640+
641+ def test_getAllRulesAsText(self):
642+ source = StormFeatureRuleSource()
643+ source.setAllRules(test_rules_list)
644+ self.assertEquals(
645+ """\
646+%s\tbeta_user\t100\t%s
647+ui.icing\tbeta_user\t300\t4.0
648+ui.icing\tdefault\t100\t3.0
649+""" % (notification_name, notification_value),
650+ source.getAllRulesAsText())
651+
652+ def test_setAllRulesFromText(self):
653+ # We will overwrite existing data.
654+ source = StormFeatureRuleSource()
655+ source.setAllRules(test_rules_list)
656+ source.setAllRulesFromText("""
657+
658+flag1 beta_user 200 alpha
659+flag1 default 100 gamma with spaces
660+flag2 default 0\ton
661+""")
662+ self.assertEquals({
663+ 'flag1': [
664+ ('beta_user', 200, 'alpha'),
665+ ('default', 100, 'gamma with spaces'),
666+ ],
667+ 'flag2': [
668+ ('default', 0, 'on'),
669+ ],
670+ },
671+ source.getAllRulesAsDict())
672
673=== modified file 'lib/lp/services/features/webapp.py'
674--- lib/lp/services/features/webapp.py 2010-09-12 03:57:21 +0000
675+++ lib/lp/services/features/webapp.py 2010-09-29 08:38:48 +0000
676@@ -10,6 +10,7 @@
677 import canonical.config
678 from lp.services.features import per_thread
679 from lp.services.features.flags import FeatureController
680+from lp.services.features.rulesource import StormFeatureRuleSource
681 from lp.services.propertycache import cachedproperty
682
683
684@@ -80,7 +81,8 @@
685 def start_request(event):
686 """Register FeatureController."""
687 event.request.features = per_thread.features = FeatureController(
688- ScopesFromRequest(event.request).lookup)
689+ ScopesFromRequest(event.request).lookup,
690+ StormFeatureRuleSource())
691
692
693 def end_request(event):