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
=== modified file 'lib/lp/services/features/__init__.py'
--- lib/lp/services/features/__init__.py 2010-09-21 13:16:25 +0000
+++ lib/lp/services/features/__init__.py 2010-09-29 08:38:48 +0000
@@ -137,6 +137,7 @@
137137
138138
139__all__ = [139__all__ = [
140 'get_relevant_feature_controller',
140 'getFeatureFlag',141 'getFeatureFlag',
141 'per_thread',142 'per_thread',
142 ]143 ]
@@ -150,11 +151,20 @@
150"""151"""
151152
152153
154def get_relevant_feature_controller():
155 """Get a FeatureController for this thread."""
156
157 # The noncommittal name "relevant" is because this function may change to
158 # look things up from the current request or some other mechanism in
159 # future.
160 return getattr(per_thread, 'features', None)
161
162
153def getFeatureFlag(flag):163def getFeatureFlag(flag):
154 """Get the value of a flag for this thread's scopes."""164 """Get the value of a flag for this thread's scopes."""
155 # Workaround for bug 631884 - features have two homes, threads and165 # Workaround for bug 631884 - features have two homes, threads and
156 # requests.166 # requests.
157 features = getattr(per_thread, 'features', None)167 features = get_relevant_feature_controller()
158 if features is None:168 if features is None:
159 return None169 return None
160 return features.getFlag(flag)170 return features.getFlag(flag)
161171
=== added directory 'lib/lp/services/features/browser'
=== added file 'lib/lp/services/features/browser/__init__.py'
=== added file 'lib/lp/services/features/browser/configure.zcml'
--- lib/lp/services/features/browser/configure.zcml 1970-01-01 00:00:00 +0000
+++ lib/lp/services/features/browser/configure.zcml 2010-09-29 08:38:48 +0000
@@ -0,0 +1,25 @@
1<!-- Copyright 2010 Canonical Ltd. This software is licensed under the
2 GNU Affero General Public License version 3 (see the file LICENSE).
3-->
4
5<configure
6 xmlns="http://namespaces.zope.org/zope"
7 xmlns:browser="http://namespaces.zope.org/browser"
8 xmlns:i18n="http://namespaces.zope.org/i18n"
9 xmlns:xmlrpc="http://namespaces.zope.org/xmlrpc"
10 i18n_domain="launchpad">
11
12 <!-- View or edit all feature rules.
13
14 Readonly access is guarded by launchpad.Edit on ILaunchpadRoot, which
15 limits it to ~admins + ~registry, which are all trusted users. Write access
16 is for admins only.
17 -->
18 <browser:page
19 for="canonical.launchpad.interfaces.ILaunchpadRoot"
20 class="lp.services.features.browser.edit.FeatureControlView"
21 name="+feature-rules"
22 permission="launchpad.Edit"
23 template="../templates/feature-rules.pt"/>
24
25</configure>
026
=== added file 'lib/lp/services/features/browser/edit.py'
--- lib/lp/services/features/browser/edit.py 1970-01-01 00:00:00 +0000
+++ lib/lp/services/features/browser/edit.py 2010-09-29 08:38:48 +0000
@@ -0,0 +1,84 @@
1# Copyright 2010 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4"""View and edit feature rules."""
5
6__metaclass__ = type
7__all__ = [
8 'FeatureControlView',
9 'IFeatureControlForm',
10 ]
11
12
13import logging
14
15
16from zope.interface import Interface
17from zope.schema import (
18 Text,
19 )
20
21from canonical.launchpad.webapp import (
22 action,
23 enabled_with_permission,
24 LaunchpadFormView,
25 )
26from canonical.launchpad.webapp.authorization import (
27 check_permission,
28 )
29
30
31class IFeatureControlForm(Interface):
32 """Interface specifically for editing a text form of feature rules"""
33
34 def __init__(self, context):
35 self.context = context
36
37 feature_rules = Text(
38 title=u"Feature rules",
39 description=(
40 u"Rules to control feature flags on Launchpad. "
41 u"On each line: (flag, scope, priority, value), "
42 u"whitespace-separated. Numerically higher "
43 u"priorities match first."
44 ),
45 required=False,
46 )
47
48
49class FeatureControlView(LaunchpadFormView):
50 """Text view of feature rules.
51
52 Presents a text area, either read-only or read-write, showing currently
53 active rules.
54 """
55
56 schema = IFeatureControlForm
57 page_title = label = 'Feature control'
58 field_names = ['feature_rules']
59
60 @action(u"Change", name="change")
61 def change_action(self, action, data):
62 if not check_permission('launchpad.Admin', self.context):
63 raise Unauthorized()
64 rules_text = data.get('feature_rules') or ''
65 logger = logging.getLogger('lp.services.features')
66 logger.warning("Change feature rules to: %s" % (rules_text,))
67 self.request.features.rule_source.setAllRulesFromText(
68 rules_text)
69
70 @property
71 def initial_values(self):
72 return dict(
73 feature_rules=self.request.features.rule_source.getAllRulesAsText(),
74 )
75
76 def validate(self, data):
77 # Try parsing the rules so we give a clean error: at the moment the
78 # message is not great, but it's better than an oops.
79 try:
80 # Unfortunately if the field is '', zope leaves it out of data.
81 self.request.features.rule_source.parseRules(
82 data.get('feature_rules') or '')
83 except (IndexError, TypeError, ValueError), e:
84 self.setFieldError('feature_rules', 'Invalid rule syntax: %s' % e)
085
=== added directory 'lib/lp/services/features/browser/tests'
=== added file 'lib/lp/services/features/browser/tests/__init__.py'
=== added file 'lib/lp/services/features/browser/tests/test_feature_editor.py'
--- lib/lp/services/features/browser/tests/test_feature_editor.py 1970-01-01 00:00:00 +0000
+++ lib/lp/services/features/browser/tests/test_feature_editor.py 2010-09-29 08:38:48 +0000
@@ -0,0 +1,138 @@
1# Copyright 2010 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4"""Tests for feature rule editor"""
5
6__metaclass__ = type
7
8
9from testtools.matchers import (
10 Equals,
11 )
12
13from zope.component import getUtility
14from zope.security.interfaces import Unauthorized
15
16from canonical.launchpad.interfaces import (
17 ILaunchpadCelebrities,
18 ILaunchpadRoot,
19 )
20from canonical.launchpad.webapp import canonical_url
21from canonical.testing.layers import DatabaseFunctionalLayer
22from lp.testing import (
23 BrowserTestCase,
24 TestCase,
25 TestCaseWithFactory,
26 celebrity_logged_in,
27 login_person,
28 person_logged_in,
29 time_counter,
30 )
31
32from lp.services.features import (
33 get_relevant_feature_controller,
34 )
35from lp.services.features.browser.edit import (
36 FeatureControlView,
37 )
38from lp.services.features.rulesource import (
39 StormFeatureRuleSource,
40 )
41
42
43class TestFeatureControlPage(BrowserTestCase):
44
45 layer = DatabaseFunctionalLayer
46
47 def getUserBrowserAsTeamMember(self, teams):
48 """Make a TestBrowser authenticated as a team member.
49
50 :param teams: List of teams to add the new user to.
51 """
52 # XXX MartinPool 2010-09-23 bug=646563: To make a UserBrowser, you
53 # must know the password; we can't get the password for an existing
54 # user so we have to make a new one.
55 user = self.factory.makePerson(password='test')
56 for team in teams:
57 with person_logged_in(team.teamowner):
58 team.addMember(user, reviewer=team.teamowner)
59 return self.getUserBrowser(url=None, user=user, password='test')
60
61 def getUserBrowserAsAdmin(self):
62 """Make a new TestBrowser logged in as an admin user."""
63 url = self.getFeatureRulesViewURL()
64 admin_team = getUtility(ILaunchpadCelebrities).admin
65 return self.getUserBrowserAsTeamMember([admin_team])
66
67 def getFeatureRulesViewURL(self):
68 root = getUtility(ILaunchpadRoot)
69 return canonical_url(root, view_name='+feature-rules')
70
71 def getFeatureRulesEditURL(self):
72 root = getUtility(ILaunchpadRoot)
73 return canonical_url(root, view_name='+feature-rules')
74
75 def test_feature_page_default_value(self):
76 """No rules in the sampledata gives no content in the page"""
77 browser = self.getUserBrowserAsAdmin()
78 browser.open(self.getFeatureRulesViewURL())
79 textarea = browser.getControl(name="field.feature_rules")
80 # and by default, since there are no rules in the sample data, it's
81 # empty
82 self.assertThat(textarea.value, Equals(''))
83
84 def test_feature_page_from_database(self):
85 StormFeatureRuleSource().setAllRules([
86 ('ui.icing', 'default', 100, u'3.0'),
87 ('ui.icing', 'beta_user', 300, u'4.0'),
88 ])
89 browser = self.getUserBrowserAsAdmin()
90 browser.open(self.getFeatureRulesViewURL())
91 textarea = browser.getControl(name="field.feature_rules")
92 self.assertThat(
93 textarea.value.replace('\r', ''),
94 Equals(
95 "ui.icing\tbeta_user\t300\t4.0\n"
96 "ui.icing\tdefault\t100\t3.0\n"))
97
98 def test_feature_rules_anonymous_unauthorized(self):
99 browser = self.getUserBrowser()
100 self.assertRaises(Unauthorized,
101 browser.open,
102 self.getFeatureRulesViewURL())
103
104 def test_feature_rules_plebian_unauthorized(self):
105 """Logged in, but not a member of any interesting teams."""
106 browser = self.getUserBrowserAsTeamMember([])
107 self.assertRaises(Unauthorized,
108 browser.open,
109 self.getFeatureRulesViewURL())
110
111 def test_feature_page_submit_changes(self):
112 """Submitted changes show up in the db."""
113 browser = self.getUserBrowserAsAdmin()
114 browser.open(self.getFeatureRulesEditURL())
115 new_value = 'beta_user some_key 10 some value with spaces'
116 textarea = browser.getControl(name="field.feature_rules")
117 textarea.value = new_value
118 browser.getControl(name="field.actions.change").click()
119 self.assertThat(
120 list(StormFeatureRuleSource().getAllRulesAsTuples()),
121 Equals([
122 ('beta_user', 'some_key', 10, 'some value with spaces'),
123 ]))
124
125 def test_feature_page_submit_change_to_empty(self):
126 """Correctly handle submitting an empty value."""
127 # Zope has the quirk of conflating empty with absent; make sure we
128 # handle it properly.
129 browser = self.getUserBrowserAsAdmin()
130 browser.open(self.getFeatureRulesEditURL())
131 new_value = ''
132 textarea = browser.getControl(name="field.feature_rules")
133 textarea.value = new_value
134 browser.getControl(name="field.actions.change").click()
135 self.assertThat(
136 list(StormFeatureRuleSource().getAllRulesAsTuples()),
137 Equals([
138 ]))
0139
=== modified file 'lib/lp/services/features/configure.zcml'
--- lib/lp/services/features/configure.zcml 2010-07-23 14:28:53 +0000
+++ lib/lp/services/features/configure.zcml 2010-09-29 08:38:48 +0000
@@ -6,6 +6,9 @@
6 xmlns="http://namespaces.zope.org/zope"6 xmlns="http://namespaces.zope.org/zope"
7 xmlns:browser="http://namespaces.zope.org/browser">7 xmlns:browser="http://namespaces.zope.org/browser">
88
9 <include
10 package=".browser"/>
11
9 <subscriber12 <subscriber
10 for="canonical.launchpad.webapp.interfaces.IStartRequestEvent"13 for="canonical.launchpad.webapp.interfaces.IStartRequestEvent"
11 handler="lp.services.features.webapp.start_request"14 handler="lp.services.features.webapp.start_request"
1215
=== modified file 'lib/lp/services/features/flags.py'
--- lib/lp/services/features/flags.py 2010-08-18 08:51:26 +0000
+++ lib/lp/services/features/flags.py 2010-09-29 08:38:48 +0000
@@ -7,17 +7,14 @@
7 ]7 ]
88
99
10from lp.services.features.rulesource import (
11 StormFeatureRuleSource,
12 )
13
14
10__metaclass__ = type15__metaclass__ = type
1116
1217
13from storm.locals import Desc
14
15from lp.services.features.model import (
16 FeatureFlag,
17 getFeatureStore,
18 )
19
20
21class Memoize(object):18class Memoize(object):
2219
23 def __init__(self, calc):20 def __init__(self, calc):
@@ -65,11 +62,11 @@
65 of code that has consistent configuration values. For instance there will62 of code that has consistent configuration values. For instance there will
66 be one per web app request.63 be one per web app request.
6764
68 Intended performance: when this object is first constructed, it will read65 Intended performance: when this object is first asked about a flag, it
69 the whole feature flag table from the database. It is expected to be66 will read the whole feature flag table from the database. It is expected
70 reasonably small. The scopes may be expensive to compute (eg checking67 to be reasonably small. The scopes may be expensive to compute (eg
71 team membership) so they are checked at most once when they are first68 checking team membership) so they are checked at most once when
72 needed.69 they are first needed.
7370
74 The controller is then supposed to be held in a thread-local and reused71 The controller is then supposed to be held in a thread-local and reused
75 for the duration of the request.72 for the duration of the request.
@@ -77,17 +74,22 @@
77 @see: U{https://dev.launchpad.net/LEP/FeatureFlags}74 @see: U{https://dev.launchpad.net/LEP/FeatureFlags}
78 """75 """
7976
80 def __init__(self, scope_check_callback):77 def __init__(self, scope_check_callback, rule_source=None):
81 """Construct a new view of the features for a set of scopes.78 """Construct a new view of the features for a set of scopes.
8279
83 :param scope_check_callback: Given a scope name, says whether80 :param scope_check_callback: Given a scope name, says whether
84 it's active or not.81 it's active or not.
82
83 :param rule_source: Instance of StormFeatureRuleSource or similar.
85 """84 """
86 self._known_scopes = Memoize(scope_check_callback)85 self._known_scopes = Memoize(scope_check_callback)
87 self._known_flags = Memoize(self._checkFlag)86 self._known_flags = Memoize(self._checkFlag)
88 # rules are read from the database the first time they're needed87 # rules are read from the database the first time they're needed
89 self._rules = None88 self._rules = None
90 self.scopes = ScopeDict(self)89 self.scopes = ScopeDict(self)
90 if rule_source is None:
91 rule_source = StormFeatureRuleSource()
92 self.rule_source = rule_source
9193
92 def getFlag(self, flag):94 def getFlag(self, flag):
93 """Get the value of a specific flag.95 """Get the value of a specific flag.
@@ -102,7 +104,7 @@
102 def _checkFlag(self, flag):104 def _checkFlag(self, flag):
103 self._needRules()105 self._needRules()
104 if flag in self._rules:106 if flag in self._rules:
105 for scope, value in self._rules[flag]:107 for scope, priority, value in self._rules[flag]:
106 if self._known_scopes.lookup(scope):108 if self._known_scopes.lookup(scope):
107 return value109 return value
108110
@@ -132,21 +134,9 @@
132 self._needRules()134 self._needRules()
133 return dict((f, self.getFlag(f)) for f in self._rules)135 return dict((f, self.getFlag(f)) for f in self._rules)
134136
135 def _loadRules(self):
136 store = getFeatureStore()
137 d = {}
138 rs = (store
139 .find(FeatureFlag)
140 .order_by(Desc(FeatureFlag.priority))
141 .values(FeatureFlag.flag, FeatureFlag.scope,
142 FeatureFlag.value))
143 for flag, scope, value in rs:
144 d.setdefault(str(flag), []).append((str(scope), value))
145 return d
146
147 def _needRules(self):137 def _needRules(self):
148 if self._rules is None:138 if self._rules is None:
149 self._rules = self._loadRules()139 self._rules = self.rule_source.getAllRulesAsDict()
150140
151 def usedFlags(self):141 def usedFlags(self):
152 """Return dict of flags used in this controller so far."""142 """Return dict of flags used in this controller so far."""
153143
=== modified file 'lib/lp/services/features/model.py'
--- lib/lp/services/features/model.py 2010-08-20 20:31:18 +0000
+++ lib/lp/services/features/model.py 2010-09-29 08:38:48 +0000
@@ -10,6 +10,7 @@
1010
11from storm.locals import (11from storm.locals import (
12 DateTime,12 DateTime,
13 Desc,
13 Int,14 Int,
14 Storm,15 Storm,
15 Unicode,16 Unicode,
1617
=== added file 'lib/lp/services/features/rulesource.py'
--- lib/lp/services/features/rulesource.py 1970-01-01 00:00:00 +0000
+++ lib/lp/services/features/rulesource.py 2010-09-29 08:38:48 +0000
@@ -0,0 +1,116 @@
1# Copyright 2010 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4"""Returns rules defining which features are active"""
5
6__all__ = [
7 'FeatureRuleSource',
8 'NullFeatureRuleSource',
9 'StormFeatureRuleSource',
10 ]
11
12__metaclass__ = type
13
14import re
15
16from storm.locals import Desc
17
18from lp.services.features.model import (
19 FeatureFlag,
20 getFeatureStore,
21 )
22
23
24class FeatureRuleSource(object):
25 """Access feature rule sources from the database or elsewhere."""
26
27 def getAllRulesAsDict(self):
28 """Return all rule definitions.
29
30 :returns: dict from flag name to a list of
31 (scope, priority, value)
32 in descending order by priority.
33 """
34 d = {}
35 for (flag, scope, priority, value) in self.getAllRulesAsTuples():
36 d.setdefault(str(flag), []).append((str(scope), priority, value))
37 return d
38
39 def getAllRulesAsTuples(self):
40 """Generate list of (flag, scope, priority, value)"""
41 raise NotImplementedError()
42
43 def getAllRulesAsText(self):
44 """Return a text for of the rules.
45
46 This has one line per rule, with tab-separate
47 (flag, scope, prioirity, value), as used in the flag editor web
48 interface.
49 """
50 tr = []
51 for (flag, scope, priority, value) in self.getAllRulesAsTuples():
52 tr.append('\t'.join((flag, scope, str(priority), value)))
53 tr.append('')
54 return '\n'.join(tr)
55
56 def setAllRulesFromText(self, text_form):
57 """Update all rules from text input.
58
59 The input is similar in form to that generated by getAllRulesAsText:
60 one line per rule, with whitespace-separated (flag, scope,
61 priority, value). Whitespace is allowed in the flag value.
62
63 """
64 self.setAllRules(self.parseRules(text_form))
65
66 def parseRules(self, text_form):
67 """Return a list of tuples for the parsed form of the text input.
68
69 For each non-blank line gives back a tuple of (flag, scope, priority, value).
70
71 Returns a list rather than a generator so that you see any syntax
72 errors immediately.
73 """
74 r = []
75 for line in text_form.splitlines():
76 if line.strip() == '':
77 continue
78 flag, scope, priority_str, value = re.split('[ \t]+', line, 3)
79 r.append((flag, scope, int(priority_str), unicode(value)))
80 return r
81
82
83class StormFeatureRuleSource(FeatureRuleSource):
84 """Access feature rules stored in the database via Storm.
85 """
86
87 def getAllRulesAsTuples(self):
88 store = getFeatureStore()
89 rs = (store
90 .find(FeatureFlag)
91 .order_by(FeatureFlag.flag, Desc(FeatureFlag.priority)))
92 for r in rs:
93 yield str(r.flag), str(r.scope), r.priority, r.value
94
95 def setAllRules(self, new_rules):
96 """Replace all existing rules with a new set.
97
98 :param new_rules: List of (name, scope, priority, value) tuples.
99 """
100 # XXX: would be slightly better to only update rules as necessary so we keep
101 # timestamps, and to avoid the direct sql etc -- mbp 20100924
102 store = getFeatureStore()
103 store.execute('DELETE FROM FeatureFlag')
104 for (flag, scope, priority, value) in new_rules:
105 store.add(FeatureFlag(
106 scope=unicode(scope),
107 flag=unicode(flag),
108 value=value,
109 priority=priority))
110
111
112class NullFeatureRuleSource(FeatureRuleSource):
113 """For use in testing: everything is turned off"""
114
115 def getAllRulesAsTuples(self):
116 return []
0117
=== added directory 'lib/lp/services/features/templates'
=== added file 'lib/lp/services/features/templates/feature-rules.pt'
--- lib/lp/services/features/templates/feature-rules.pt 1970-01-01 00:00:00 +0000
+++ lib/lp/services/features/templates/feature-rules.pt 2010-09-29 08:38:48 +0000
@@ -0,0 +1,18 @@
1<html
2 xmlns="http://www.w3.org/1999/xhtml"
3 xmlns:tal="http://xml.zope.org/namespaces/tal"
4 xmlns:metal="http://xml.zope.org/namespaces/metal"
5 xmlns:i18n="http://xml.zope.org/namespaces/i18n"
6 metal:use-macro="view/macro:page/main_only"
7 i18n:domain="launchpad"
8 tal:define="page_title string:features;">
9
10<body>
11
12<div metal:fill-slot="main">
13
14 <div metal:use-macro="context/@@launchpad_form/form">
15 </div>
16</div>
17</body>
18</html>
019
=== modified file 'lib/lp/services/features/tests/test_flags.py'
--- lib/lp/services/features/tests/test_flags.py 2010-09-12 05:19:38 +0000
+++ lib/lp/services/features/tests/test_flags.py 2010-09-29 08:38:48 +0000
@@ -16,6 +16,7 @@
16 per_thread,16 per_thread,
17 )17 )
18from lp.services.features.flags import FeatureController18from lp.services.features.flags import FeatureController
19from lp.services.features.rulesource import StormFeatureRuleSource
19from lp.testing import TestCase20from lp.testing import TestCase
2021
2122
@@ -24,9 +25,9 @@
2425
2526
26testdata = [27testdata = [
27 ('beta_user', notification_name, notification_value, 100),28 (notification_name, 'beta_user', 100, notification_value),
28 ('default', 'ui.icing', u'3.0', 100),29 ('ui.icing', 'default', 100, u'3.0'),
29 ('beta_user', 'ui.icing', u'4.0', 300),30 ('ui.icing', 'beta_user', 300, u'4.0'),
30 ]31 ]
3132
3233
@@ -46,16 +47,11 @@
46 def scope_cb(scope):47 def scope_cb(scope):
47 call_log.append(scope)48 call_log.append(scope)
48 return scope in scopes49 return scope in scopes
49 return FeatureController(scope_cb), call_log50 controller = FeatureController(scope_cb, StormFeatureRuleSource())
51 return controller, call_log
5052
51 def populateStore(self):53 def populateStore(self):
52 store = model.getFeatureStore()54 StormFeatureRuleSource().setAllRules(testdata)
53 for (scope, flag, value, priority) in testdata:
54 store.add(model.FeatureFlag(
55 scope=unicode(scope),
56 flag=unicode(flag),
57 value=value,
58 priority=priority))
5955
60 def test_getFlag(self):56 def test_getFlag(self):
61 self.populateStore()57 self.populateStore()
@@ -165,3 +161,54 @@
165 self.assertEqual(False, f.scopes['alpha_user'])161 self.assertEqual(False, f.scopes['alpha_user'])
166 self.assertEqual(True, f.scopes['beta_user'])162 self.assertEqual(True, f.scopes['beta_user'])
167 self.assertEqual(['beta_user', 'alpha_user'], call_log)163 self.assertEqual(['beta_user', 'alpha_user'], call_log)
164
165
166test_rules_list = [
167 (notification_name, 'beta_user', 100, notification_value),
168 ('ui.icing', 'beta_user', 300, u'4.0'),
169 ('ui.icing', 'default', 100, u'3.0'),
170 ]
171
172
173class TestStormFeatureRuleSource(TestCase):
174
175 layer = layers.DatabaseFunctionalLayer
176
177 def test_getAllRulesAsTuples(self):
178 source = StormFeatureRuleSource()
179 source.setAllRules(test_rules_list)
180 self.assertEquals(
181 test_rules_list,
182 list(source.getAllRulesAsTuples()))
183
184 def test_getAllRulesAsText(self):
185 source = StormFeatureRuleSource()
186 source.setAllRules(test_rules_list)
187 self.assertEquals(
188 """\
189%s\tbeta_user\t100\t%s
190ui.icing\tbeta_user\t300\t4.0
191ui.icing\tdefault\t100\t3.0
192""" % (notification_name, notification_value),
193 source.getAllRulesAsText())
194
195 def test_setAllRulesFromText(self):
196 # We will overwrite existing data.
197 source = StormFeatureRuleSource()
198 source.setAllRules(test_rules_list)
199 source.setAllRulesFromText("""
200
201flag1 beta_user 200 alpha
202flag1 default 100 gamma with spaces
203flag2 default 0\ton
204""")
205 self.assertEquals({
206 'flag1': [
207 ('beta_user', 200, 'alpha'),
208 ('default', 100, 'gamma with spaces'),
209 ],
210 'flag2': [
211 ('default', 0, 'on'),
212 ],
213 },
214 source.getAllRulesAsDict())
168215
=== modified file 'lib/lp/services/features/webapp.py'
--- lib/lp/services/features/webapp.py 2010-09-12 03:57:21 +0000
+++ lib/lp/services/features/webapp.py 2010-09-29 08:38:48 +0000
@@ -10,6 +10,7 @@
10import canonical.config10import canonical.config
11from lp.services.features import per_thread11from lp.services.features import per_thread
12from lp.services.features.flags import FeatureController12from lp.services.features.flags import FeatureController
13from lp.services.features.rulesource import StormFeatureRuleSource
13from lp.services.propertycache import cachedproperty14from lp.services.propertycache import cachedproperty
1415
1516
@@ -80,7 +81,8 @@
80def start_request(event):81def start_request(event):
81 """Register FeatureController."""82 """Register FeatureController."""
82 event.request.features = per_thread.features = FeatureController(83 event.request.features = per_thread.features = FeatureController(
83 ScopesFromRequest(event.request).lookup)84 ScopesFromRequest(event.request).lookup,
85 StormFeatureRuleSource())
8486
8587
86def end_request(event):88def end_request(event):