Merge lp:~mbp/launchpad/flags-gui into lp:launchpad
- flags-gui
- Merge into devel
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 | ||||
Related bugs: |
|
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:/
through which you can control feature flags.
The former is readonly and for ~launchpad; the latter is only for ~admins.
See screenshot <https:/
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.
Martin Pool (mbp) wrote : | # |
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/
> 2 === added file 'lib/lp/
> 3 --- lib/lp/
> 4 +++ lib/lp/
> 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.
> 21 + action,
> 22 + LaunchpadFormView,
> 23 + )
> 24 +
> 25 +
> 26 +class IFeatureControl
> 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=
> 33 +
> 34 +
> 35 +class FeatureControlV
>
> Needs a docstring.
>
>
> 91 === added file 'lib/lp/
> 92 --- lib/lp/
> 93 +++ lib/lp/
>
> 130 +class TestFeatureCont
> 131 +
> 132 + layer = DatabaseFunctio
> 133 +
> 134 + def getUserBrowserA
> 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 getUserBrowserA
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.
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:/
>> 147 + def getFeatureRules
> 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.
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.
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:/
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 getFeatureRules
>> 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
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.
Jeroen T. Vermeulen (jtv) wrote : | # |
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/
> > 2 === added file 'lib/lp/
> > 3 --- lib/lp/
> 00:00:00 +0000
> > 4 +++ lib/lp/
> 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/
> > 92 --- lib/lp/
> 1970-01-01 00:00:00 +0000
> > 93 +++ lib/lp/
> 2010-09-27 08:17:44 +0000
> >
> > 130 +class TestFeatureCont
> > 131 +
> > 132 + layer = DatabaseFunctio
> > 133 +
> > 134 + def getUserBrowserA
> > 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
> getUserBrowserA
> 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 = getUserBrowserA
> self.assertRais
Yes, exactly. And AFAICS that should work.
> > 152 + def getFeaturePageB
> > 153 + url = self.getFeature
> > 154 + admin_team = getUtility(
> > 155 + return self.getUserBro
> >
> > 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_
> > 166 + addFeatureFlagR
> > 167 + ('default', 'ui.icing', u'3.0', 100),
> > 168 + ('beta_user', 'ui.icing', u'4.0', 300),
> > 169 + ])
> > 170 + browser = self.getFeature
> > 171 + te...
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 getFeatureRules
> > 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
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 = getUserBrowserA
> > self.assertRais
>
> 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.
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.
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.
Preview Diff
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): |
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' services/ features/ browser/ __init_ _.py' services/ features/ browser/ __init_ _.py 1970-01-01 00:00:00 +0000 services/ features/ browser/ __init_ _.py 2010-09-27 08:17:44 +0000
2 === added file 'lib/lp/
3 --- lib/lp/
4 +++ lib/lp/
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 + launchpad. webapp import ( Form(Interface) : u"Feature rules") iew(LaunchpadFo rmView) :
12 +import logging
13 +
14 +
15 +from zope.interface import Interface
16 +from zope.schema import (
17 + Text,
18 + )
19 +
20 +from canonical.
21 + action,
22 + LaunchpadFormView,
23 + )
24 +
25 +
26 +class IFeatureControl
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=
33 +
34 +
35 +class FeatureControlV
Needs a docstring.
91 === added file 'lib/lp/ services/ features/ browser/ tests/test_ feature_ editor. py' services/ features/ browser/ tests/test_ feature_ editor. py 1970-01-01 00:00:00 +0000 services/ features/ browser/ tests/test_ feature_ editor. py 2010-09-27 08:17:44 +0000
92 --- lib/lp/
93 +++ lib/lp/
130 +class TestFeatureCont rolPage( BrowserTestCase ): nalLayer sTeamMember( self, url, teams):
131 +
132 + layer = DatabaseFunctio
133 +
134 + def getUserBrowserA
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 getUserBrowserA sTeamMember 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') logged_ in(team. teamowner) : user, reviewer= team.teamowner) wser(url, user=user, password='test')
142 + for team in teams:
143 + with person_
144 + team.addMember(
145 + return self.getUserBro
I would suggest not opening "url" here, so that getUserBrowserA sTeamMember can serve as an unmistakable setup helper. That also leaves you free to assume an admin identity just once (or remove...