Repository: hasgeek/coaster

Issue:
ModelView discovery from models
`UrlForMixin` combined with `UrlForView` allows a model to be queried for the URL to a view. However, we often need more metadata:

1. Whether the view is available to the current user. At this time the logic for this is embedded into the template, making it difficult to maintain.

2. A title, icon and category when displaying link buttons (once again hardcoded into the template at present).

3. Enumeration of available views, or available _related_ views.

Similar to `model.url_for(action)`, we could have a `model.view_for(action)` that returns the `ViewHandler` from a `ModelView` affiliated with the model. View handlers have a `data` attribute that could be used by the template: `{{ model.view_for('edit').data['title'] }}`. 

If #179 (`requires_roles`) is implemented using the `ViewDecorator` class, it can also offer an `is_available` property similar to that offered by `StateTransition`:

https://github.com/hasgeek/coaster/blob/672096556e58e1bb3653a9f429e4d568f265ca04/coaster/sqlalchemy/statemanager.py#L554-L559

A limited form of view discovery is available from the `current_view` proxy, which returns the current view class and allows examining its contents. There is a justifiable need for discovering `ModelView` classes that are _not_ currently active.

Discussion:

Patch:
diff --git a/CHANGES.rst b/CHANGES.rst
index c8cd133..b4ff40c 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -29,6 +29,8 @@
   property
 * Changed: ``coaster.sqlalchemy.UrlForMixin`` now recognises that the project
   may have multiple apps with distinct URLs for the same content
+* New: ``coaster.sqlalchemy.registry`` provides two registries for forms and
+  views associated with models
 
 
 0.6.0
diff --git a/coaster/sqlalchemy/__init__.py b/coaster/sqlalchemy/__init__.py
index 039dbfb..31da757 100644
--- a/coaster/sqlalchemy/__init__.py
+++ b/coaster/sqlalchemy/__init__.py
@@ -18,6 +18,7 @@ from .functions import *  # NOQA
 from .roles import *  # NOQA
 from .annotations import *  # NOQA
 from .immutable_annotation import *  # NOQA
+from .registry import *  # NOQA
 from .mixins import *  # NOQA
 from .columns import *  # NOQA
 from .statemanager import *  # NOQA
diff --git a/coaster/sqlalchemy/mixins.py b/coaster/sqlalchemy/mixins.py
index df9a7a2..682edc7 100644
--- a/coaster/sqlalchemy/mixins.py
+++ b/coaster/sqlalchemy/mixins.py
@@ -37,6 +37,7 @@ from ..utils.misc import _punctuation_re
 from ..auth import current_auth
 from .immutable_annotation import immutable
 from .roles import RoleMixin, with_roles
+from .registry import RegistryMixin
 from .comparators import Query, SqlSplitIdComparator, SqlHexUuidComparator, SqlBuidComparator, SqlSuuidComparator
 from .functions import auto_init_default, failsafe_add
 
@@ -44,7 +45,7 @@ from .functions import auto_init_default, failsafe_add
 __all__ = ['IdMixin', 'TimestampMixin', 'PermissionMixin', 'UrlForMixin',
     'NoIdMixin', 'BaseMixin', 'BaseNameMixin', 'BaseScopedNameMixin', 'BaseIdNameMixin',
     'BaseScopedIdMixin', 'BaseScopedIdNameMixin', 'CoordinatesMixin',
-    'UuidMixin', 'RoleMixin']
+    'UuidMixin', 'RoleMixin', 'RegistryMixin']
 
 
 class IdMixin(object):
@@ -268,11 +269,11 @@ class UrlForMixin(object):
         return decorator
 
 
-class NoIdMixin(TimestampMixin, PermissionMixin, RoleMixin, UrlForMixin):
+class NoIdMixin(TimestampMixin, PermissionMixin, RoleMixin, RegistryMixin, UrlForMixin):
     """
-    Mixin that combines :class:`TimestampMixin`, :class:`PermissionMixin`,
-    :class:`RoleMixin` and :class:`UrlForMixin`, for use anywhere where the
-    timestamp columns and helper methods are required, but an id column is not.
+    Mixin that combines all mixin classes except :class:`IdMixin`, for use
+    anywhere the timestamp columns and helper methods are required, but an
+    id column is not.
     """
     def _set_fields(self, fields):
         """Helper method for :meth:`upsert` in the various subclasses"""
diff --git a/coaster/sqlalchemy/registry.py b/coaster/sqlalchemy/registry.py
new file mode 100644
index 0000000..e5a0f86
--- /dev/null
+++ b/coaster/sqlalchemy/registry.py
@@ -0,0 +1,80 @@
+# -*- coding: utf-8 -*-
+
+"""
+Model helper registry
+---------------------
+
+Provides a :class:`Registry` type and a :class:`RegistryMixin` base class
+with two registries, used by other mixin classes.
+
+Helper classes such as forms and views can be registered to the model and
+later accessed from an instance::
+
+    class MyModel(BaseMixin, db.Model):
+        ...
+
+    class MyForm(Form):
+        ...
+
+    class MyView(ModelView):
+        ...
+
+    MyModel.forms.main = MyForm
+    MyModel.views.main = MyView
+
+When accessed from an instance, the registered form or view will receive the
+instance as an ``obj`` parameter::
+
+    doc = MyModel()
+    doc.forms.main() == MyForm(obj=doc)
+    doc.views.main() == MyView(obj=doc)
+
+The name ``main`` is a recommended default, but an app that has separate forms
+for ``new`` and ``edit`` actions could use those names instead.
+"""
+
+from functools import partial
+from sqlalchemy.ext.declarative import declared_attr
+
+
+__all__ = ['Registry', 'InstanceRegistry', 'RegistryMixin']
+
+
+class Registry(object):
+    """
+    Container for items registered to a model.
+    """
+    def __get__(self, obj, cls=None):
+        if obj is None:
+            return self
+        else:
+            return InstanceRegistry(self, obj)
+
+
+class InstanceRegistry(object):
+    """
+    Container for accessing registered items from an instance of the model.
+    Used internally by :class:`Registry`. Returns a partial that will pass
+    in an ``obj`` parameter when called.
+    """
+    def __init__(self, registry, obj):
+        self.__registry = registry
+        self.__obj = obj
+
+    def __getattr__(self, attr):
+        return partial(getattr(self.__registry, attr), obj=self.__obj)
+
+
+class RegistryMixin(object):
+    """
+    Provides the :attr:`forms` and :attr:`views` registries using
+    :class:`Registry`. Additional registries, if needed, should be
+    added directly to the model class.
+    """
+    @declared_attr
+    def forms(self):
+        return Registry()
+
+    @declared_attr
+    def views(self):
+        return Registry()
diff --git a/coaster/views/classview.py b/coaster/views/classview.py
index 8fd5d89..48e6988 100644
--- a/coaster/views/classview.py
+++ b/coaster/views/classview.py
@@ -450,6 +450,7 @@ class ModelView(ClassView):
             def view(self):
                 return self.obj.current_access()
 
+        Document.views.main = DocumentView
         DocumentView.init_app(app)
 
     Views will not receive view arguments, unlike in :class:`ClassView`. If
@@ -474,6 +475,10 @@ class ModelView(ClassView):
     #: SQLAlchemy attribute references to load the instance object.
     route_model_map = {}
 
+    def __init__(self, obj=None):
+        super(ModelView, self).__init__()
+        self.obj = obj
+
     def dispatch_request(self, view, view_args):
         """
         View dispatcher that calls :meth:`before_request`, :meth:`loader`,
diff --git a/docs/sqlalchemy/index.rst b/docs/sqlalchemy/index.rst
index 87b32a7..c0f1054 100644
--- a/docs/sqlalchemy/index.rst
+++ b/docs/sqlalchemy/index.rst
@@ -9,5 +9,6 @@
    roles
    annotations
    immutable_annotation
+   registry
    comparators
    statemanager
diff --git a/docs/sqlalchemy/registry.rst b/docs/sqlalchemy/registry.rst
new file mode 100644
index 0000000..4537704
--- /dev/null
+++ b/docs/sqlalchemy/registry.rst
@@ -0,0 +1,2 @@
+.. automodule:: coaster.sqlalchemy.registry
+   :members:


Test patch:
diff --git a/tests/test_sqlalchemy_registry.py b/tests/test_sqlalchemy_registry.py
new file mode 100644
index 0000000..cc74b9a
--- /dev/null
+++ b/tests/test_sqlalchemy_registry.py
@@ -0,0 +1,70 @@
+# -*- coding: utf-8 -*-
+
+import unittest
+
+from coaster.sqlalchemy import BaseMixin
+from coaster.db import db
+
+# We have two sample models and two registered items to test that
+# the registry is unique to each model and is not a global registry
+# in the base class.
+
+# Sample model 1
+class RegistryTest1(BaseMixin, db.Model):
+    __tablename__ = 'registry_test1'
+
+
+# Sample model 2
+class RegistryTest2(BaseMixin, db.Model):
+    __tablename__ = 'registry_test2'
+
+
+# Sample registered item (form or view) 1
+class RegisteredItem1(object):
+    def __init__(self, obj=None):
+        self.obj = obj
+
+
+# Sample registered item 2
+class RegisteredItem2(object):
+    def __init__(self, obj=None):
+        self.obj = obj
+
+RegistryTest1.views.test = RegisteredItem1
+RegistryTest2.views.test = RegisteredItem2
+
+
+class TestRegistry(unittest.TestCase):
+    def test_access_item_from_class(self):
+        """Registered items are available from the model class"""
+        assert RegistryTest1.views.test is RegisteredItem1
+        assert RegistryTest2.views.test is RegisteredItem2
+        assert RegistryTest1.views.test is not RegisteredItem2
+        assert RegistryTest2.views.test is not RegisteredItem1
+
+    def test_access_item_class_from_instance(self):
+        """Registered items are available from the model instance"""
+        r1 = RegistryTest1()
+        r2 = RegistryTest2()
+        # When accessed from the instance, we get a partial that resembles
+        # the wrapped item, but is not the item itself.
+        assert r1.views.test is not RegisteredItem1
+        assert r1.views.test.func is RegisteredItem1
+        assert r2.views.test is not RegisteredItem2
+        assert r2.views.test.func is RegisteredItem2
+
+    def test_access_item_instance_from_instance(self):
+        """Registered items can be instantiated from the model instance"""
+        r1 = RegistryTest1()
+        r2 = RegistryTest2()
+        i1 = r1.views.test()
+        i2 = r2.views.test()
+
+        assert isinstance(i1, RegisteredItem1)
+        assert isinstance(i2, RegisteredItem2)
+        assert not isinstance(i1, RegisteredItem2)
+        assert not isinstance(i2, RegisteredItem1)
+        assert i1.obj is r1
+        assert i2.obj is r2
+        assert i1.obj is not r2
+        assert i2.obj is not r1
diff --git a/tests/test_views_classview.py b/tests/test_views_classview.py
index 764fb20..026ba93 100644
--- a/tests/test_views_classview.py
+++ b/tests/test_views_classview.py
@@ -209,6 +209,7 @@ class ModelDocumentView(UrlForView, InstanceLoader, ModelView):
         return 'edit-called'
 
 
+ViewDocument.views.main = ModelDocumentView
 ModelDocumentView.init_app(app)
 
 
@@ -221,6 +222,7 @@ class ScopedDocumentView(ModelDocumentView):
         }
 
 
+ScopedViewDocument.views.main = ScopedDocumentView
 ScopedDocumentView.init_app(app)
 
 
@@ -237,6 +239,7 @@ class RenameableDocumentView(UrlChangeCheck, InstanceLoader, ModelView):
         return self.obj.current_access()
 
 
+RenameableDocument.views.main = RenameableDocumentView
 RenameableDocumentView.init_app(app)
 
 
@@ -530,3 +533,18 @@ class TestClassView(unittest.TestCase):
         rv = self.client.get('/multi/test1/1-test2')
         assert rv.status_code == 200
         assert rv.data == b'/multi/test1/1-test2'
+
+    def test_registered_views(self):
+        doc1 = ViewDocument(name='test1', title="Test 1")
+        doc2 = ScopedViewDocument(name='test2', title="Test 2", parent=doc1)
+        doc3 = RenameableDocument(name='test3', title="Test 3")
+        self.session.add_all([doc1, doc2, doc3])
+        self.session.commit()
+
+        assert ViewDocument.views.main is ModelDocumentView
+        assert ScopedViewDocument.views.main is ScopedDocumentView
+        assert RenameableDocument.views.main is RenameableDocumentView
+
+        assert isinstance(doc1.views.main(), ModelDocumentView)
+        assert isinstance(doc2.views.main(), ScopedDocumentView)
+        assert isinstance(doc3.views.main(), RenameableDocumentView)