Repository: hasgeek/coaster

Issue:
Feature flag framework
To check for the availability of a feature in UI, we do this:

```jinja2
{% if obj.view_for('action').is_available() %}
```

These work when the block contains a link to the feature page, but sometimes the entire feature is contained in the template, and the tests get too complex. Sample from Funnel:

```jinja2
{%- if (project.boxoffice_data and project.boxoffice_data.item_collection_id) or project.schedule_state.PUBLISHED -%}
```

As these tend to repeat throughout the template, they should be defined in a central place. The model is a tempting choice as the instance is available and we already have a framework like state manager, but this is a view concern and should be defined in views.

## Option A: Re-use ModelView and RegistryMixin

ModelView registration is already supported via [RegistryMixin](https://github.com/hasgeek/coaster/blob/5952d8eb7ed51b8405ace6d82ab71843abe971e2/coaster/sqlalchemy/registry.py). It can be used to add feature detection methods:

```python
class MyModelView(ModelView):
    model = Model
    def has_feature(self):
        if self.view_name.is_available():
            return True
        # Or...
        if self.obj.state.PUBLISHED and (other_extended_criteria):
            return True

Model.views.main = ModelView
```
```jinja2
{% if obj.views.main.has_feature() %}
```

## Option B: Add a new registry

The existing `forms` and `views` registries can be complemented with a `features` registry. Registering functions on it will reduce the expense of class instantiation:

```python
Model.features.has_feature_name = lambda obj: obj.state.PUBLISHED
```

The Registry class can be amended to serve as a decorator, making it easier to add complex feature tests. Note that Registry passes an explicit `obj` parameter, so the test function must accept `obj`:

```python
@Model.features()
def has_feature_name(obj):
    …

@Model.features('other_feature')
def other_feature_test(obj):
    …
```

The decorator will use the function's name if no explicit name is provided. Adding a decorator will also make view registration straightforward.

```python
@Model.views('main')
@route('/<model>')
class MyModelView(ModelView):
    model = Model
    …
```

An advanced use case is feature versioning, to perform A/B tests. This can be simplistically achieved with:

```jinja2
{% if obj.features.has_feature() %}
  {% if obj.features.has_feature_v1() %}
    …
  {% endif %}
  {% if obj.features.has_feature_v2() %}
    …
  {% endif %}
{% endif %}
```

Where `my_feature()` tests for feature availability, and the distinct `_v1` and `_v2` functions test for the bucket the user has been assigned to. To avoid overhead, they can skip testing for feature availability itself, leaving that to the parent test.

Note: while the feature test functions are lighter than class instantiation, they may still need the class view when testing for roles:

```python
@Model.features()
def can_edit(obj):
    return MyModelView(obj).edit.is_available()
```

Discussion:

Patch:
diff --git a/coaster/sqlalchemy/registry.py b/coaster/sqlalchemy/registry.py
index 8fb5441..75b700a 100644
--- a/coaster/sqlalchemy/registry.py
+++ b/coaster/sqlalchemy/registry.py
@@ -51,6 +51,18 @@ class Registry(object):
         else:
             return InstanceRegistry(self, obj)
 
+    def __call__(self, name=None):
+        """Decorator to aid class or function registration"""
+
+        def decorator(f):
+            use_name = name or f.__name__
+            if hasattr(self, use_name):
+                raise ValueError("%s is already registered" % use_name)
+            setattr(self, name or f.__name__, f)
+            return f
+
+        return decorator
+
 
 class InstanceRegistry(object):
     """
@@ -81,3 +93,7 @@ class RegistryMixin(object):
     @declared_attr
     def views(self):
         return Registry()
+
+    @declared_attr
+    def features(self):
+        return Registry()


Test patch:
diff --git a/tests/test_sqlalchemy_registry.py b/tests/test_sqlalchemy_registry.py
index 6f956c5..fc1d2fc 100644
--- a/tests/test_sqlalchemy_registry.py
+++ b/tests/test_sqlalchemy_registry.py
@@ -27,13 +27,20 @@ class RegisteredItem1(object):
 
 
 # Sample registered item 2
+@RegistryTest2.views('test')
 class RegisteredItem2(object):
     def __init__(self, obj=None):
         self.obj = obj
 
 
+# Sample registered item 3
+@RegistryTest1.features('is1')
+@RegistryTest2.features()
+def is1(obj):
+    return isinstance(obj, RegistryTest1)
+
+
 RegistryTest1.views.test = RegisteredItem1
-RegistryTest2.views.test = RegisteredItem2
 
 
 class TestRegistry(unittest.TestCase):
@@ -43,6 +50,8 @@ class TestRegistry(unittest.TestCase):
         assert RegistryTest2.views.test is RegisteredItem2
         assert RegistryTest1.views.test is not RegisteredItem2
         assert RegistryTest2.views.test is not RegisteredItem1
+        assert RegistryTest1.features.is1 is is1
+        assert RegistryTest2.features.is1 is is1
 
     def test_access_item_class_from_instance(self):
         """Registered items are available from the model instance"""
@@ -54,6 +63,10 @@ class TestRegistry(unittest.TestCase):
         assert r1.views.test.func is RegisteredItem1
         assert r2.views.test is not RegisteredItem2
         assert r2.views.test.func is RegisteredItem2
+        assert r1.features.is1 is not is1
+        assert r1.features.is1.func is is1
+        assert r2.features.is1 is not is1
+        assert r2.features.is1.func is is1
 
     def test_access_item_instance_from_instance(self):
         """Registered items can be instantiated from the model instance"""
@@ -70,3 +83,11 @@ class TestRegistry(unittest.TestCase):
         assert i2.obj is r2
         assert i1.obj is not r2
         assert i2.obj is not r1
+
+    def test_features(self):
+        """The features registry can be used for feature tests"""
+        r1 = RegistryTest1()
+        r2 = RegistryTest2()
+
+        assert r1.features.is1() is True
+        assert r2.features.is1() is False