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()

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()
```
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

pip install -e .[dev]
parse_log_pytest
requirements.txt
3.6
test_requirements.txt dev_requirements.txt
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
alembic==1.7.7
aniso8601==10.0.0
attrs==22.2.0
base58==2.1.1
bcrypt==4.0.1
bleach==4.1.0
blinker==1.5
cached-property==1.5.2
certifi==2021.5.30
cfgv==3.3.1
charset-normalizer==2.0.12
click==8.0.4
-e git+https://github.com/hasgeek/coaster.git@ec7ddbfe23f3e915a2feecdbf720d8800b58c817#egg=coaster
coverage==6.2
coveralls==3.3.1
dataclasses==0.8
distlib==0.3.9
docflow==0.3.3
docopt==0.6.2
filelock==3.4.1
flake8==5.0.4
flake8-assertive==1.3.0
flake8-blind-except==0.2.1
flake8-builtins==2.0.0
flake8-coding==1.3.2
flake8-comprehensions==2.3.0
flake8-isort==4.2.0
flake8-logging-format==2024.24.12
flake8-mutable==1.2.0
Flask==2.0.3
Flask-Assets==2.1.0
Flask-Migrate==4.0.1
Flask-Script==2.0.6
Flask-SQLAlchemy==2.5.1
furl==2.1.4
greenlet==2.0.2
html2text==2020.1.16
html5lib==1.1
identify==2.4.4
idna==3.10
importlib-metadata==4.8.3
importlib-resources==5.2.3
iniconfig==1.1.1
isort==5.10.1
isoweek==1.3.3
itsdangerous==2.0.1
Jinja2==3.0.3
joblib==1.1.1
Mako==1.1.6
Markdown==3.3.7
MarkupSafe==2.0.1
mccabe==0.7.0
nltk==3.6.7
nodeenv==1.6.0
orderedmultidict==1.0.1
packaging==21.3
pep8-naming==0.13.1
platformdirs==2.4.0
pluggy==1.0.0
pre-commit==2.17.0
psycopg2==2.7.7
py==1.11.0
pycodestyle==2.9.1
PyExecJS==1.5.1
pyflakes==2.5.0
Pygments==2.14.0
pymdown-extensions==9.1
pyparsing==3.1.4
pytest==7.0.1
pytz==2025.2
PyYAML==6.0.1
regex==2023.8.8
requests==2.27.1
requests-file==2.1.0
semantic-version==2.10.0
shortuuid==0.5.0
simplejson==3.20.1
six==1.17.0
SQLAlchemy==1.4.54
SQLAlchemy-Utils==0.41.1
tldextract==3.1.2
toml==0.10.2
tomli==1.2.3
tqdm==4.64.1
typing_extensions==4.1.1
UgliPyJS==0.2.5
Unidecode==1.3.8
urllib3==1.26.20
virtualenv==20.16.2
webassets==2.0
webencodings==0.5.1
Werkzeug==2.0.3
zipp==3.6.0

name: coaster
channels:
  - defaults
  - https://repo.anaconda.com/pkgs/main
  - https://repo.anaconda.com/pkgs/r
  - conda-forge
dependencies:
  - _libgcc_mutex=0.1=main
  - _openmp_mutex=5.1=1_gnu
  - ca-certificates=2025.2.25=h06a4308_0
  - certifi=2021.5.30=py36h06a4308_0
  - ld_impl_linux-64=2.40=h12ee557_0
  - libffi=3.3=he6710b0_2
  - libgcc-ng=11.2.0=h1234567_1
  - libgomp=11.2.0=h1234567_1
  - libstdcxx-ng=11.2.0=h1234567_1
  - ncurses=6.4=h6a678d5_0
  - openssl=1.1.1w=h7f8727e_0
  - pip=21.2.2=py36h06a4308_0
  - python=3.6.13=h12debd9_1
  - readline=8.2=h5eee18b_0
  - setuptools=58.0.4=py36h06a4308_0
  - sqlite=3.45.3=h5eee18b_0
  - tk=8.6.14=h39e8969_0
  - wheel=0.37.1=pyhd3eb1b0_0
  - xz=5.6.4=h5eee18b_1
  - zlib=1.2.13=h5eee18b_1
  - pip:
      - alembic==1.7.7
      - aniso8601==10.0.0
      - attrs==22.2.0
      - base58==2.1.1
      - bcrypt==4.0.1
      - bleach==4.1.0
      - blinker==1.5
      - cached-property==1.5.2
      - cfgv==3.3.1
      - charset-normalizer==2.0.12
      - click==8.0.4
      - coverage==6.2
      - coveralls==3.3.1
      - dataclasses==0.8
      - distlib==0.3.9
      - docflow==0.3.3
      - docopt==0.6.2
      - filelock==3.4.1
      - flake8==5.0.4
      - flake8-assertive==1.3.0
      - flake8-blind-except==0.2.1
      - flake8-builtins==2.0.0
      - flake8-coding==1.3.2
      - flake8-comprehensions==2.3.0
      - flake8-isort==4.2.0
      - flake8-logging-format==2024.24.12
      - flake8-mutable==1.2.0
      - flask==2.0.3
      - flask-assets==2.1.0
      - flask-migrate==4.0.1
      - flask-script==2.0.6
      - flask-sqlalchemy==2.5.1
      - furl==2.1.4
      - greenlet==2.0.2
      - html2text==2020.1.16
      - html5lib==1.1
      - identify==2.4.4
      - idna==3.10
      - importlib-metadata==4.8.3
      - importlib-resources==5.2.3
      - iniconfig==1.1.1
      - isort==5.10.1
      - isoweek==1.3.3
      - itsdangerous==2.0.1
      - jinja2==3.0.3
      - joblib==1.1.1
      - mako==1.1.6
      - markdown==3.3.7
      - markupsafe==2.0.1
      - mccabe==0.7.0
      - nltk==3.6.7
      - nodeenv==1.6.0
      - orderedmultidict==1.0.1
      - packaging==21.3
      - pep8-naming==0.13.1
      - platformdirs==2.4.0
      - pluggy==1.0.0
      - pre-commit==2.17.0
      - psycopg2==2.7.7
      - py==1.11.0
      - pycodestyle==2.9.1
      - pyexecjs==1.5.1
      - pyflakes==2.5.0
      - pygments==2.14.0
      - pymdown-extensions==9.1
      - pyparsing==3.1.4
      - pytest==7.0.1
      - pytz==2025.2
      - pyyaml==6.0.1
      - regex==2023.8.8
      - requests==2.27.1
      - requests-file==2.1.0
      - semantic-version==2.10.0
      - shortuuid==0.5.0
      - simplejson==3.20.1
      - six==1.17.0
      - sqlalchemy==1.4.54
      - sqlalchemy-utils==0.41.1
      - tldextract==3.1.2
      - toml==0.10.2
      - tomli==1.2.3
      - tqdm==4.64.1
      - typing-extensions==4.1.1
      - uglipyjs==0.2.5
      - unidecode==1.3.8
      - urllib3==1.26.20
      - virtualenv==20.16.2
      - webassets==2.0
      - webencodings==0.5.1
      - werkzeug==2.0.3
      - zipp==3.6.0
prefix: /opt/conda/envs/coaster

tests/test_sqlalchemy_registry.py::TestRegistry::test_access_item_class_from_instance tests/test_sqlalchemy_registry.py::TestRegistry::test_access_item_from_class tests/test_sqlalchemy_registry.py::TestRegistry::test_access_item_instance_from_instance tests/test_sqlalchemy_registry.py::TestRegistry::test_features
coaster/sqlalchemy/registry.py
coaster/sqlalchemy/registry.py