Skip to content

Commit

Permalink
Add admin interface for feature flags
Browse files Browse the repository at this point in the history
Currently, the only way to change the values of feature flags is through
the database. This is non-ideal.

This commit adds a very basic web interface for modifying feature flag
configuration.
  • Loading branch information
nickstenning committed Aug 7, 2015
1 parent 229f224 commit e112bb0
Show file tree
Hide file tree
Showing 5 changed files with 183 additions and 0 deletions.
27 changes: 27 additions & 0 deletions h/admin.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from pyramid import session
from pyramid import view
from pyramid import httpexceptions

Expand All @@ -16,6 +17,31 @@ def index(_):
return {}


@view.view_config(route_name='admin_features',
request_method='GET',
renderer='h:templates/admin/features.html.jinja2',
permission='admin')
def features_index(_):
return {"features": models.Feature.all()}


@view.view_config(route_name='admin_features',
request_method='POST',
permission='admin')
def features_save(request):
session.check_csrf_token(request)
for feat in models.Feature.all():
for attr in ['everyone', 'admins', 'staff']:
val = request.POST.get('{0}[{1}]'.format(feat.name, attr))
if val == 'on':
setattr(feat, attr, True)
else:
setattr(feat, attr, False)
request.session.flash(_("Changes saved."), "success")
return httpexceptions.HTTPSeeOther(
location=request.route_url('admin_features'))


@view.view_config(route_name='admin_nipsa',
request_method='GET',
renderer='h:templates/admin/nipsa.html.jinja2',
Expand Down Expand Up @@ -146,6 +172,7 @@ def staff_remove(request):

def includeme(config):
config.add_route('admin_index', '/admin')
config.add_route('admin_features', '/admin/features')
config.add_route('admin_nipsa', '/admin/nipsa')
config.add_route('admin_nipsa_remove', '/admin/nipsa/remove')
config.add_route('admin_admins', '/admin/admins')
Expand Down
16 changes: 16 additions & 0 deletions h/features.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,22 @@ class Feature(Base):
default=False,
server_default=sa.sql.expression.false())

@property
def description(self):
return FEATURES[self.name]

@classmethod
def all(cls):
"""Fetch (or, if necessary, create) rows for all defined features."""
results = []
for name in FEATURES:
feat = cls.get_by_name(name)
if feat is None:
feat = cls(name=name)
cls.query.session.add(feat)
results.append(feat)
return results

@classmethod
def get_by_name(cls, name):
"""Fetch a flag by name."""
Expand Down
63 changes: 63 additions & 0 deletions h/templates/admin/features.html.jinja2
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
{% extends "h:templates/layouts/admin.html.jinja2" %}

{% set page_id = 'features' %}
{% set page_title = 'Feature flags' %}

{% block content %}
<p>
This page allows you to configure various feature flags that change the
behaviour of the application.
</p>
<p>
<strong>N.B.</strong> your changes will take effect immediately when you
save.
</p>
<div class="table-responsive">
<form method="POST">
<input
type="hidden"
name="csrf_token"
value="{{ request.session.get_csrf_token() }}">
<table class="table table-striped">
<thead>
<tr>
<th></th>
<th>Everyone</th>
<th>Admins</th>
<th>Staff</th>
<th></th>
</tr>
</thead>
<tbody>
{% for feat in features %}
<tr>
<td>{{ feat.name }}</td>
<td>
<input
type="checkbox"
name="{{ feat.name }}[everyone]"
{% if feat.everyone %}checked{% endif %}>
</td>
<td>
<input
type="checkbox"
name="{{ feat.name }}[admins]"
{% if feat.admins %}checked{% endif %}>
</td>
<td>
<input
type="checkbox"
name="{{ feat.name }}[staff]"
{% if feat.staff %}checked{% endif %}>
</td>
<td>
{{ feat.description }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
<input class="btn btn-primary" type="submit" value="Save changes">
</form>
</div>
{% endblock %}
1 change: 1 addition & 0 deletions h/templates/layouts/admin.html.jinja2
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{%- set nav_pages = [
('index', 'Home', request.route_url('admin_index')),
('features', 'Feature flags', request.route_url('admin_features')),
('nipsa', 'NIPSA', request.route_url('admin_nipsa')),
('admins', 'Administrators', request.route_url('admin_admins')),
('staff', 'Staff', request.route_url('admin_staff')),
Expand Down
76 changes: 76 additions & 0 deletions h/test/admin_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,68 @@
from h import admin


class DummyFeature(object):
def __init__(self, name):
self.name = name
self.everyone = False
self.admins = False
self.staff = False


features_save_fixtures = pytest.mark.usefixtures('Feature',
'check_csrf_token',
'routes_mapper')


@features_save_fixtures
def test_features_save_sets_attributes_when_checkboxes_on(Feature):
foo = DummyFeature(name='foo')
bar = DummyFeature(name='bar')
Feature.all.return_value = [foo, bar]
request = DummyRequest(post={'foo[everyone]': 'on',
'foo[staff]': 'on',
'bar[admins]': 'on'})

admin.features_save(request)

assert foo.everyone == foo.staff == bar.admins == True


@features_save_fixtures
def test_features_save_sets_attributes_when_checkboxes_off(Feature):
foo = DummyFeature(name='foo')
foo.everyone = True
foo.staff = True
Feature.all.return_value = [foo]
request = DummyRequest(post={})

admin.features_save(request)

assert foo.everyone == foo.staff == False


@features_save_fixtures
def test_features_save_ignores_unknown_fields(Feature):
foo = DummyFeature(name='foo')
Feature.all.return_value = [foo]
request = DummyRequest(post={'foo[wibble]': 'on',
'foo[admins]': 'ignoreme'})

admin.features_save(request)

assert foo.admins == False


@features_save_fixtures
def test_features_save_checks_csrf_token(Feature, check_csrf_token):
Feature.all.return_value = []
request = DummyRequest(post={})

admin.features_save(request)

check_csrf_token.assert_called_with(request)


# The fixtures required to mock all of nipsa_index()'s dependencies.
nipsa_index_fixtures = pytest.mark.usefixtures('nipsa')

Expand Down Expand Up @@ -391,6 +453,20 @@ def test_staff_remove_404s_if_no_remove_param():
admin.staff_remove(DummyRequest())


@pytest.fixture
def Feature(request):
patcher = patch('h.models.Feature', autospec=True)
request.addfinalizer(patcher.stop)
return patcher.start()


@pytest.fixture
def check_csrf_token(request):
patcher = patch('pyramid.session.check_csrf_token', autospec=True)
request.addfinalizer(patcher.stop)
return patcher.start()


@pytest.fixture
def nipsa(config, request): # pylint:disable=unused-argument
patcher = patch('h.admin.nipsa', autospec=True)
Expand Down

0 comments on commit e112bb0

Please sign in to comment.