← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~mbp/launchpad/flags-gui into lp:launchpad/devel

 

You have been requested to review the proposed merge of lp:~mbp/launchpad/flags-gui into lp:launchpad/devel.

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.


-- 
https://code.launchpad.net/~mbp/launchpad/flags-gui/+merge/36415
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~mbp/launchpad/flags-gui into lp:launchpad/devel.
=== added directory 'lib/lp/services/features/browser'
=== added file 'lib/lp/services/features/browser/__init__.py'
--- lib/lp/services/features/browser/__init__.py	1970-01-01 00:00:00 +0000
+++ lib/lp/services/features/browser/__init__.py	2010-09-27 08:17:44 +0000
@@ -0,0 +1,45 @@
+# Copyright 2010 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Feature control view"""
+
+
+import logging 
+
+
+from zope.interface import Interface
+from zope.schema import (
+    Text,
+    )
+
+from canonical.launchpad.webapp import (
+    action,
+    LaunchpadFormView,
+    )
+
+
+class IFeatureControlForm(Interface):
+    """Interface specifically for editing a text form of feature rules"""
+
+    def __init__(self, context):
+        self.context = context
+
+    feature_rules = Text(title=u"Feature rules")
+
+
+class FeatureControlView(LaunchpadFormView):
+
+    schema = IFeatureControlForm
+    page_title = label = 'Feature control'
+
+    @action(u"Change", name="change")
+    def change_action(self, action, data):
+        rules_text = self.request.get('feature_rules')
+        logger = logging.getLogger('lp.services.features')
+        logger.warning("Change feature rules to: %s" % (rules_text,))
+        return self.request.features.rule_source.setAllRulesFromText(
+            rules_text)
+
+    def rules_text(self):
+        """Return all rules in an editable text form"""
+        return self.request.features.rule_source.getAllRulesAsText()

=== 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-27 08:17:44 +0000
@@ -0,0 +1,32 @@
+<!-- Copyright 2010 Canonical Ltd.  This software is licensed under the
+     GNU Affero General Public License version 3 (see the file LICENSE).
+-->
+
+<configure
+    xmlns="http://namespaces.zope.org/zope";
+    xmlns:browser="http://namespaces.zope.org/browser";
+    xmlns:i18n="http://namespaces.zope.org/i18n";
+    xmlns:xmlrpc="http://namespaces.zope.org/xmlrpc";
+    i18n_domain="launchpad">
+
+    <!-- View or edit all feature rules.
+
+	 Readonly access is guarded by launchpad.Edit on ILaunchpadRoot, which
+	 limits it to ~admins + ~registry, which are all trusted users.  Write access
+	 is for admins only.
+    -->
+    <browser:page
+        for="canonical.launchpad.interfaces.ILaunchpadRoot"
+        class="lp.services.features.browser.FeatureControlView"
+        name="+feature-rules"
+        permission="launchpad.Edit"
+        template="../templates/feature-rules.pt"/>
+
+    <browser:page
+        for="canonical.launchpad.interfaces.ILaunchpadRoot"
+        class="lp.services.features.browser.FeatureControlView"
+        name="+feature-rules-edit"
+        permission="launchpad.Admin"
+        template="../templates/feature-rules-edit.pt"/>
+
+</configure>

=== 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-27 08:17:44 +0000
@@ -0,0 +1,111 @@
+# Copyright 2010 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Tests for feature rule editor"""
+
+__metaclass__ = type
+
+
+from testtools.matchers import (
+    Equals,
+    )
+
+from zope.component import getUtility
+from zope.security.interfaces import Unauthorized
+
+from canonical.launchpad.interfaces import (
+    ILaunchpadCelebrities,
+    ILaunchpadRoot,
+    )
+from canonical.launchpad.webapp import canonical_url
+from canonical.testing.layers import DatabaseFunctionalLayer
+from lp.testing import (
+    BrowserTestCase,
+    TestCase,
+    TestCaseWithFactory,
+    celebrity_logged_in,
+    login_person,
+    person_logged_in,
+    time_counter,
+    )
+
+from lp.services.features.browser import FeatureControlView
+from lp.services.features.model import addFeatureFlagRules
+
+
+class TestFeatureControlPage(BrowserTestCase):
+
+    layer = DatabaseFunctionalLayer
+
+    def getUserBrowserAsTeamMember(self, url, teams):
+        """Make a TestBrowser authenticated as a team member.
+        
+        :param teams: List of teams to add the new user to.
+        """
+        # XXX bug=646563: To make a UserBrowser, you must know the password.  This
+        # should be separated out into test infrastructure.  -- mbp 20100923
+        user = self.factory.makePerson(password='test')
+        for team in teams:
+            with person_logged_in(team.teamowner):
+                team.addMember(user, reviewer=team.teamowner)
+        return self.getUserBrowser(url, user=user, password='test')
+
+    def getFeatureRulesURL(self):
+        root = getUtility(ILaunchpadRoot)
+        url = canonical_url(root, view_name='+feature-rules')
+        return url
+
+    def getFeaturePageBrowserAsAdmin(self):
+        url = self.getFeatureRulesURL()
+        admin_team = getUtility(ILaunchpadCelebrities).admin
+        return self.getUserBrowserAsTeamMember(url, [admin_team])
+
+    def test_feature_page_default_value(self):
+        """No rules in the sampledata gives no content in the page"""
+        browser = self.getFeaturePageBrowserAsAdmin()
+        textarea = browser.getControl(name="feature_rules")
+        # and by default, since there are no rules in the sample data, it's
+        # empty
+        self.assertThat(textarea.value, Equals(''))
+
+    def test_feature_page_from_database(self):
+        addFeatureFlagRules([
+            ('default', 'ui.icing', u'3.0', 100),
+            ('beta_user', 'ui.icing', u'4.0', 300),
+            ])
+        browser = self.getFeaturePageBrowserAsAdmin()
+        textarea = browser.getControl(name="feature_rules")
+        self.assertThat(textarea.value.replace('\r', ''), Equals("""\
+ui.icing\tbeta_user\t300\t4.0
+ui.icing\tdefault\t100\t3.0
+"""))
+
+    def test_feature_rules_anonymous_unauthorized(self):
+        self.assertRaises(Unauthorized,
+            self.getUserBrowser,
+            self.getFeatureRulesURL())
+
+    def test_feature_rules_peon_unauthorized(self):
+        """Logged in, but not a member of any interesting teams."""
+        self.assertRaises(Unauthorized,
+            self.getUserBrowserAsTeamMember,
+            self.getFeatureRulesURL(),
+            [])
+
+    def test_feature_page_submit_changes(self):
+        # XXX: read/write mode not supported yet
+        return
+        ## new_value = 'beta_user 10 some_key some value with spaces'
+        ## browser = self.getFeaturePageBrowser()
+        ## textarea = browser.getControl(name="feature_rules")
+        ## textarea.value = new_value
+        ## browser.getControl(name="submit").click()
+        ## self.assertThat(textarea.value.replace('\t', ' '),
+        ##     Equals(new_value))
+
+
+    # TODO: test that for unauthorized or anonymous users, the page works
+    # (including showing the active rules) but the textarea is readonly and
+    # there is no submit button.
+    #
+    # TODO: test that you can submit it and it changes the database.

=== 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-27 08:17:44 +0000
@@ -6,6 +6,9 @@
     xmlns="http://namespaces.zope.org/zope";
     xmlns:browser="http://namespaces.zope.org/browser";>
 
+    <include
+        package=".browser"/>
+
     <subscriber
         for="canonical.launchpad.webapp.interfaces.IStartRequestEvent"
         handler="lp.services.features.webapp.start_request"

=== 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-27 08:17:44 +0000
@@ -7,17 +7,14 @@
     ]
 
 
+from lp.services.features.rulesource import (
+    StormFeatureRuleSource,
+    )
+
+
 __metaclass__ = type
 
 
-from storm.locals import Desc
-
-from lp.services.features.model import (
-    FeatureFlag,
-    getFeatureStore,
-    )
-
-
 class Memoize(object):
 
     def __init__(self, calc):
@@ -65,11 +62,11 @@
     of code that has consistent configuration values.  For instance there will
     be one per web app request.
 
-    Intended performance: when this object is first constructed, it will read
-    the whole feature flag table from the database.  It is expected to be
-    reasonably small.  The scopes may be expensive to compute (eg checking
-    team membership) so they are checked at most once when they are first
-    needed.
+    Intended performance: when this object is first asked about a flag, it
+    will read the whole feature flag table from the database.  It is expected
+    to be reasonably small.  The scopes may be expensive to compute (eg
+    checking team membership) so they are checked at most once when
+    they are first needed.
 
     The controller is then supposed to be held in a thread-local and reused
     for the duration of the request.
@@ -77,17 +74,22 @@
     @see: U{https://dev.launchpad.net/LEP/FeatureFlags}
     """
 
-    def __init__(self, scope_check_callback):
+    def __init__(self, scope_check_callback, rule_source=None):
         """Construct a new view of the features for a set of scopes.
 
         :param scope_check_callback: Given a scope name, says whether
-        it's active or not.
+            it's active or not.
+
+        :param rule_source: Instance of StormFeatureRuleSource or similar.
         """
         self._known_scopes = Memoize(scope_check_callback)
         self._known_flags = Memoize(self._checkFlag)
         # rules are read from the database the first time they're needed
         self._rules = None
         self.scopes = ScopeDict(self)
+        if rule_source is None:
+            rule_source = StormFeatureRuleSource()
+        self.rule_source = rule_source
 
     def getFlag(self, flag):
         """Get the value of a specific flag.
@@ -102,7 +104,7 @@
     def _checkFlag(self, flag):
         self._needRules()
         if flag in self._rules:
-            for scope, value in self._rules[flag]:
+            for scope, priority, value in self._rules[flag]:
                 if self._known_scopes.lookup(scope):
                     return value
 
@@ -132,21 +134,9 @@
         self._needRules()
         return dict((f, self.getFlag(f)) for f in self._rules)
 
-    def _loadRules(self):
-        store = getFeatureStore()
-        d = {}
-        rs = (store
-                .find(FeatureFlag)
-                .order_by(Desc(FeatureFlag.priority))
-                .values(FeatureFlag.flag, FeatureFlag.scope,
-                    FeatureFlag.value))
-        for flag, scope, value in rs:
-            d.setdefault(str(flag), []).append((str(scope), value))
-        return d
-
     def _needRules(self):
         if self._rules is None:
-            self._rules = self._loadRules()
+            self._rules = self.rule_source.getAllRulesAsDict()
 
     def usedFlags(self):
         """Return dict of flags used in this controller so far."""

=== 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-27 08:17:44 +0000
@@ -2,6 +2,7 @@
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 __all__ = [
+    'addFeatureFlagRules',
     'FeatureFlag',
     'getFeatureStore',
     ]
@@ -10,6 +11,7 @@
 
 from storm.locals import (
     DateTime,
+    Desc,
     Int,
     Storm,
     Unicode,
@@ -48,3 +50,17 @@
         self.priority = priority
         self.flag = flag
         self.value = value
+
+
+def addFeatureFlagRules(rule_list):
+    """Add rules in to the database; intended for use in testing.
+
+    :param rule_list: [(scope, flag, value, priority)...]
+    """
+    store = getFeatureStore()
+    for (scope, flag, value, priority) in rule_list:
+        store.add(FeatureFlag(
+            scope=unicode(scope),
+            flag=unicode(flag),
+            value=value,
+            priority=priority))

=== 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-27 08:17:44 +0000
@@ -0,0 +1,90 @@
+# Copyright 2010 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Returns rules defining which features are active"""
+
+__all__ = [
+    'FeatureRuleSource',
+    'NullFeatureRuleSource',
+    'StormFeatureRuleSource',
+    ]
+
+__metaclass__ = type
+
+import re
+
+from storm.locals import Desc
+
+from lp.services.features.model import (
+    FeatureFlag,
+    getFeatureStore,
+    )
+
+
+class FeatureRuleSource(object):
+    """Access feature rule sources from the database or elsewhere."""
+
+    def getAllRulesAsDict(self):
+        """Return all rule definitions.
+
+        :returns: dict from flag name to an ordered list of 
+            (scope, priority, value) 
+            tuples, where the first matching scope should be used.
+        """
+        d = {}
+        for (flag, scope, priority, value) in self.getAllRulesAsTuples():
+            d.setdefault(str(flag), []).append((str(scope), priority, value))
+        return d
+
+    def getAllRulesAsText(self):
+        tr = []
+        for (flag, scope, priority, value) in self.getAllRulesAsTuples():
+            tr.append('\t'.join((flag, scope, str(priority), value)))
+        tr.append('')
+        return '\n'.join(tr)
+
+    def setAllRulesFromText(self, text_form):
+        self.setAllRules(self.parseRules(text_form))
+
+    def parseRules(self, text_form):
+        for line in text_form.splitlines():
+            if line.strip() == '':
+                continue
+            flag, scope, priority_str, value = re.split('[ \t]+', line, 3)
+            yield (flag, scope, int(priority_str), unicode(value))
+
+
+class StormFeatureRuleSource(FeatureRuleSource):
+    """Access feature rules stored in the database via Storm.
+    """
+
+    def getAllRulesAsTuples(self):
+        store = getFeatureStore()
+        rs = (store
+                .find(FeatureFlag)
+                .order_by(FeatureFlag.flag, Desc(FeatureFlag.priority)))
+        for r in rs:
+            yield str(r.flag), str(r.scope), r.priority, r.value
+
+    def setAllRules(self, new_rules):
+        """Replace all existing rules with a new set.
+
+        :param new_rules: List of (name, scope, priority, value) tuples.
+        """
+        # XXX: would be slightly better to only update rules as necessary so we keep
+        # timestamps, and to avoid the direct sql etc -- mbp 20100924
+        store = getFeatureStore()
+        store.execute('delete from featureflag')
+        for (flag, scope, priority, value) in new_rules:
+            store.add(FeatureFlag(
+                scope=unicode(scope),
+                flag=unicode(flag),
+                value=value,
+                priority=priority))
+
+
+class NullFeatureRuleSource(FeatureRuleSource):
+    """For use in testing: everything is turned off"""
+
+    def getAllRulesAsTuples(self):
+        return []

=== added directory 'lib/lp/services/features/templates'
=== added file 'lib/lp/services/features/templates/feature-rules-edit.pt'
--- lib/lp/services/features/templates/feature-rules-edit.pt	1970-01-01 00:00:00 +0000
+++ lib/lp/services/features/templates/feature-rules-edit.pt	2010-09-27 08:17:44 +0000
@@ -0,0 +1,29 @@
+<html
+  xmlns="http://www.w3.org/1999/xhtml";
+  xmlns:tal="http://xml.zope.org/namespaces/tal";
+  xmlns:metal="http://xml.zope.org/namespaces/metal";
+  xmlns:i18n="http://xml.zope.org/namespaces/i18n";
+  metal:use-macro="view/macro:page/main_only"
+  i18n:domain="launchpad"
+  tal:define="page_title string:features;">
+
+<body>
+
+<div metal:fill-slot="main">
+  <div metal:use-macro="context/@@launchpad_form/form">
+
+    <p><a href="http://dev.launchpad.net/LEP/FeatureFlags";>Feature flag</a>
+      rules currently active on Launchpad:</p>
+
+    <div metal:fill-slot="widgets">
+
+	<textarea style="font-family: monospace;" 
+	  rows="10" columns="80"
+	  tal:content="view/rules_text" name="feature_rules">
+	</textarea>
+
+    </div>
+  </div>
+</div>
+</body>
+</html>

=== 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-27 08:17:44 +0000
@@ -0,0 +1,30 @@
+<html
+  xmlns="http://www.w3.org/1999/xhtml";
+  xmlns:tal="http://xml.zope.org/namespaces/tal";
+  xmlns:metal="http://xml.zope.org/namespaces/metal";
+  xmlns:i18n="http://xml.zope.org/namespaces/i18n";
+  metal:use-macro="view/macro:page/main_only"
+  i18n:domain="launchpad"
+  tal:define="page_title string:features;">
+
+<body>
+
+<div metal:fill-slot="main">
+
+  <p><a href="http://dev.launchpad.net/LEP/FeatureFlags";>Feature flag</a>
+    rules currently active on Launchpad:</p>
+
+  <div metal:use-macro="context/@@launchpad_form/form">
+    <div metal:fill-slot="widgets">
+
+      <textarea style="font-family: monospace;" 
+	rows="10" columns="80"
+	readonly="true"
+	tal:content="view/rules_text" name="feature_rules">
+      </textarea>
+
+    </div>
+  </div>
+</div>
+</body>
+</html>

=== 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-27 08:17:44 +0000
@@ -16,6 +16,7 @@
     per_thread,
     )
 from lp.services.features.flags import FeatureController
+from lp.services.features.rulesource import StormFeatureRuleSource
 from lp.testing import TestCase
 
 
@@ -46,16 +47,11 @@
         def scope_cb(scope):
             call_log.append(scope)
             return scope in scopes
-        return FeatureController(scope_cb), call_log
+        controller = FeatureController(scope_cb, StormFeatureRuleSource())
+        return controller, call_log
 
     def populateStore(self):
-        store = model.getFeatureStore()
-        for (scope, flag, value, priority) in testdata:
-            store.add(model.FeatureFlag(
-                scope=unicode(scope),
-                flag=unicode(flag),
-                value=value,
-                priority=priority))
+        model.addFeatureFlagRules(testdata)
 
     def test_getFlag(self):
         self.populateStore()
@@ -165,3 +161,50 @@
         self.assertEqual(False, f.scopes['alpha_user'])
         self.assertEqual(True, f.scopes['beta_user'])
         self.assertEqual(['beta_user', 'alpha_user'], call_log)
+
+
+test_rules_list = [
+    (notification_name, 'beta_user', 100, notification_value),
+    ('ui.icing', 'beta_user', 300, u'4.0'),
+    ('ui.icing', 'default', 100, u'3.0'),
+    ]
+
+
+class TestStormFeatureRuleSource(TestCase):
+
+    layer = layers.DatabaseFunctionalLayer
+
+    def setUp(self):
+        super(TestStormFeatureRuleSource, self).setUp()
+        if os.environ.get("STORM_TRACE", None):
+            from storm.tracer import debug
+            debug(True)
+        self.source = StormFeatureRuleSource()
+        self.source.setAllRules(test_rules_list)
+
+    def test_getAllRulesAsTuples(self):
+        self.assertEquals(list(self.source.getAllRulesAsTuples()),
+            test_rules_list)
+
+    def test_getAllRulesAsText(self):
+        self.assertEquals(self.source.getAllRulesAsText(),
+            """\
+%s\tbeta_user\t100\t%s
+ui.icing\tbeta_user\t300\t4.0
+ui.icing\tdefault\t100\t3.0
+""" % (notification_name, notification_value))
+
+    def test_setAllRulesFromText(self):
+        self.source.setAllRulesFromText("""
+
+flag1   beta_user   200   alpha
+flag1   default     100   gamma with spaces
+flag2   default     0\ton
+""")
+        self.assertEquals(self.source.getAllRulesAsDict(), {
+            'flag1': [
+                ('beta_user', 200, 'alpha'),
+                ('default', 100, 'gamma with spaces'), ],
+            'flag2': [
+                ('default', 0, 'on'),]})
+                

=== 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-27 08:17:44 +0000
@@ -10,6 +10,7 @@
 import canonical.config
 from lp.services.features import per_thread
 from lp.services.features.flags import FeatureController
+from lp.services.features.rulesource import StormFeatureRuleSource
 from lp.services.propertycache import cachedproperty
 
 
@@ -80,7 +81,8 @@
 def start_request(event):
     """Register FeatureController."""
     event.request.features = per_thread.features = FeatureController(
-        ScopesFromRequest(event.request).lookup)
+        ScopesFromRequest(event.request).lookup,
+        StormFeatureRuleSource())
 
 
 def end_request(event):


References