diff --git a/src/wuttaweb/templates/master/view.mako b/src/wuttaweb/templates/master/view.mako
index b84ebc1..c7021eb 100644
--- a/src/wuttaweb/templates/master/view.mako
+++ b/src/wuttaweb/templates/master/view.mako
@@ -5,5 +5,34 @@
<%def name="content_title()">${instance_title}%def>
+<%def name="page_content()">
-${parent.body()}
+ ## render main form
+ ${parent.page_content()}
+
+ ## render row grid
+ % if master.has_rows:
+
+
${master.get_rows_title() or ''}
+ ${rows_grid.render_vue_tag()}
+ % endif
+
+%def>
+
+<%def name="render_vue_templates()">
+ ${parent.render_vue_templates()}
+ % if master.has_rows:
+ ${self.render_vue_template_rows_grid()}
+ % endif
+%def>
+
+<%def name="render_vue_template_rows_grid()">
+ ${rows_grid.render_vue_template()}
+%def>
+
+<%def name="make_vue_components()">
+ ${parent.make_vue_components()}
+ % if master.has_rows:
+ ${rows_grid.render_vue_finalize()}
+ % endif
+%def>
diff --git a/src/wuttaweb/views/batch.py b/src/wuttaweb/views/batch.py
index da82503..1383ec9 100644
--- a/src/wuttaweb/views/batch.py
+++ b/src/wuttaweb/views/batch.py
@@ -50,6 +50,10 @@ class BatchMasterView(MasterView):
sort_defaults = ('id', 'desc')
+ has_rows = True
+ rows_title = "Batch Rows"
+ rows_sort_defaults = 'sequence'
+
def __init__(self, request, context=None):
super().__init__(request, context=context)
self.batch_handler = self.get_batch_handler()
@@ -253,3 +257,33 @@ class BatchMasterView(MasterView):
finally:
session.close()
+
+ ##############################
+ # row methods
+ ##############################
+
+ @classmethod
+ def get_row_model_class(cls):
+ """ """
+ if hasattr(cls, 'row_model_class'):
+ return cls.row_model_class
+
+ Batch = cls.get_model_class()
+ return Batch.__row_class__
+
+ def get_row_grid_data(self, batch):
+ """
+ Returns the base query for the batch
+ :attr:`~wuttjamaican:wuttjamaican.db.model.batch.BatchMixin.rows`
+ data.
+ """
+ BatchRow = self.get_row_model_class()
+ query = self.Session.query(BatchRow)\
+ .filter(BatchRow.batch == batch)
+ return query
+
+ def configure_row_grid(self, g):
+ """ """
+ super().configure_row_grid(g)
+
+ g.set_label('sequence', "Seq.", column_only=True)
diff --git a/src/wuttaweb/views/master.py b/src/wuttaweb/views/master.py
index 7f94aba..0030859 100644
--- a/src/wuttaweb/views/master.py
+++ b/src/wuttaweb/views/master.py
@@ -73,12 +73,12 @@ class MasterView(View):
.. attribute:: model_class
- Optional reference to a data model class. While not strictly
- required, most views will set this to a SQLAlchemy mapped
- class,
+ Optional reference to a :term:`data model` class. While not
+ strictly required, most views will set this to a SQLAlchemy
+ mapped class,
e.g. :class:`~wuttjamaican:wuttjamaican.db.model.base.Person`.
- Code should not access this directly but instead call
+ The base logic should not access this directly but instead call
:meth:`get_model_class()`.
.. attribute:: model_name
@@ -340,6 +340,38 @@ class MasterView(View):
Boolean indicating whether the master view supports
"configuring" - i.e. it should have a :meth:`configure()` view.
Default value is ``False``.
+
+ **ROW FEATURES**
+
+ .. attribute:: has_rows
+
+ Whether the model has "rows" which should also be displayed
+ when viewing model records.
+
+ This the "master switch" for all row features; if this is turned
+ on then many other things kick in.
+
+ See also :attr:`row_model_class`.
+
+ .. attribute:: row_model_class
+
+ Reference to a :term:`data model` class for the rows.
+
+ The base logic should not access this directly but instead call
+ :meth:`get_row_model_class()`.
+
+ .. attribute:: rows_title
+
+ Display title for the rows grid.
+
+ The base logic should not access this directly but instead call
+ :meth:`get_rows_title()`.
+
+ .. attribute:: row_grid_columns
+
+ List of columns for the row grid.
+
+ This is optional; see also :meth:`get_row_grid_columns()`.
"""
##############################
@@ -368,6 +400,16 @@ class MasterView(View):
execute_progress_template = None
configurable = False
+ # row features
+ has_rows = False
+ rows_filterable = True
+ rows_filter_defaults = None
+ rows_sortable = True
+ rows_sort_on_backend = True
+ rows_sort_defaults = None
+ rows_paginated = True
+ rows_paginate_on_backend = True
+
# current action
listing = False
creating = False
@@ -525,15 +567,40 @@ class MasterView(View):
* :meth:`make_model_form()`
* :meth:`configure_form()`
+ * :meth:`make_row_model_grid()` - if :attr:`has_rows` is true
"""
self.viewing = True
- instance = self.get_instance()
- form = self.make_model_form(instance, readonly=True)
-
+ obj = self.get_instance()
+ form = self.make_model_form(obj, readonly=True)
context = {
- 'instance': instance,
+ 'instance': obj,
'form': form,
}
+
+ if self.has_rows:
+
+ # always make the grid first. note that it already knows
+ # to "reset" its params when that is requested.
+ grid = self.make_row_model_grid(obj)
+
+ # but if user did request a "reset" then we want to
+ # redirect so the query string gets cleared out
+ if self.request.GET.get('reset-view'):
+
+ # nb. we want to preserve url hash if applicable
+ kw = {'_query': None,
+ '_anchor': self.request.GET.get('hash')}
+ return self.redirect(self.request.current_route_url(**kw))
+
+ # so-called 'partial' requests get just the grid data
+ if self.request.params.get('partial'):
+ context = grid.get_vue_context()
+ if grid.paginated and grid.paginate_on_backend:
+ context['pager_stats'] = grid.get_vue_pager_stats()
+ return self.json_response(context)
+
+ context['rows_grid'] = grid
+
return self.render_to_response('view', context)
##############################
@@ -1907,8 +1974,8 @@ class MasterView(View):
This is called by :meth:`make_model_grid()`.
- There is no default logic here; subclass should override as
- needed. The ``grid`` param will already be "complete" and
+ There is minimal default logic here; subclass should override
+ as needed. The ``grid`` param will already be "complete" and
ready to use as-is, but this method can further modify it
based on request details etc.
"""
@@ -2241,6 +2308,182 @@ class MasterView(View):
session = session or self.Session()
session.add(obj)
+ ##############################
+ # row methods
+ ##############################
+
+ def get_rows_title(self):
+ """
+ Returns the display title for model **rows** grid, if
+ applicable/desired. Only relevant if :attr:`has_rows` is
+ true.
+
+ There is no default here, but subclass may override by
+ assigning :attr:`rows_title`.
+ """
+ if hasattr(self, 'rows_title'):
+ return self.rows_title
+
+ def make_row_model_grid(self, obj, **kwargs):
+ """
+ Create and return a grid for a record's **rows** data, for use
+ in :meth:`view()`. Only applicable if :attr:`has_rows` is
+ true.
+
+ :param obj: Current model instance for which rows data is
+ being displayed.
+
+ :returns: :class:`~wuttaweb.grids.base.Grid` instance for the
+ rows data.
+
+ See also related methods, which are called by this one:
+
+ * :meth:`get_row_grid_key()`
+ * :meth:`get_row_grid_columns()`
+ * :meth:`get_row_grid_data()`
+ * :meth:`configure_row_grid()`
+ """
+ if 'key' not in kwargs:
+ kwargs['key'] = self.get_row_grid_key()
+
+ if 'model_class' not in kwargs:
+ model_class = self.get_row_model_class()
+ if model_class:
+ kwargs['model_class'] = model_class
+
+ if 'columns' not in kwargs:
+ kwargs['columns'] = self.get_row_grid_columns()
+
+ if 'data' not in kwargs:
+ kwargs['data'] = self.get_row_grid_data(obj)
+
+ kwargs.setdefault('filterable', self.rows_filterable)
+ kwargs.setdefault('filter_defaults', self.rows_filter_defaults)
+ kwargs.setdefault('sortable', self.rows_sortable)
+ kwargs.setdefault('sort_multiple', not self.request.use_oruga)
+ kwargs.setdefault('sort_on_backend', self.rows_sort_on_backend)
+ kwargs.setdefault('sort_defaults', self.rows_sort_defaults)
+ kwargs.setdefault('paginated', self.rows_paginated)
+ kwargs.setdefault('paginate_on_backend', self.rows_paginate_on_backend)
+
+ grid = self.make_grid(**kwargs)
+ self.configure_row_grid(grid)
+ grid.load_settings()
+ return grid
+
+ def get_row_grid_key(self):
+ """
+ Returns the (presumably) unique key to be used for the
+ **rows** grid in :meth:`view()`. Only relevant if
+ :attr:`has_rows` is true.
+
+ This is called from :meth:`make_row_model_grid()`; in the
+ resulting grid, this becomes
+ :attr:`~wuttaweb.grids.base.Grid.key`.
+
+ Whereas you can define :attr:`grid_key` for the main grid, the
+ row grid key is always generated dynamically. This
+ incorporates the current record key (whose rows are in the
+ grid) so that the rows grid for each record is unique.
+ """
+ parts = [self.get_grid_key()]
+ for key in self.get_model_key():
+ parts.append(str(self.request.matchdict[key]))
+ return '.'.join(parts)
+
+ def get_row_grid_columns(self):
+ """
+ Returns the default list of column names for the **rows**
+ grid, for use in :meth:`view()`. Only relevant if
+ :attr:`has_rows` is true.
+
+ This is called by :meth:`make_row_model_grid()`; in the
+ resulting grid, this becomes
+ :attr:`~wuttaweb.grids.base.Grid.columns`.
+
+ This method may return ``None``, in which case the grid may
+ (try to) generate its own default list.
+
+ Subclass may define :attr:`row_grid_columns` for simple cases,
+ or can override this method if needed.
+
+ Also note that :meth:`configure_row_grid()` may be used to
+ further modify the final column set, regardless of what this
+ method returns. So a common pattern is to declare all
+ "supported" columns by setting :attr:`row_grid_columns` but
+ then optionally remove or replace some of those within
+ :meth:`configure_row_grid()`.
+ """
+ if hasattr(self, 'row_grid_columns'):
+ return self.row_grid_columns
+
+ def get_row_grid_data(self, obj):
+ """
+ Returns the data for the **rows** grid, for use in
+ :meth:`view()`. Only relevant if :attr:`has_rows` is true.
+
+ This is called by :meth:`make_row_model_grid()`; in the
+ resulting grid, this becomes
+ :attr:`~wuttaweb.grids.base.Grid.data`.
+
+ Default logic not implemented; subclass must define this.
+ """
+ raise NotImplementedError
+
+ def configure_row_grid(self, grid):
+ """
+ Configure the **rows** grid for use in :meth:`view()`. Only
+ relevant if :attr:`has_rows` is true.
+
+ This is called by :meth:`make_row_model_grid()`.
+
+ There is minimal default logic here; subclass should override
+ as needed. The ``grid`` param will already be "complete" and
+ ready to use as-is, but this method can further modify it
+ based on request details etc.
+ """
+ grid.remove('uuid')
+ self.set_row_labels(grid)
+
+ def set_row_labels(self, obj):
+ """
+ Set label overrides on a **row** form or grid, based on what
+ is defined by the view class and its parent class(es).
+
+ This is called automatically from
+ :meth:`configure_row_grid()` and
+ :meth:`configure_row_form()`.
+
+ This calls :meth:`collect_row_labels()` to find everything,
+ then it assigns the labels using one of (based on ``obj``
+ type):
+
+ * :func:`wuttaweb.forms.base.Form.set_label()`
+ * :func:`wuttaweb.grids.base.Grid.set_label()`
+
+ :param obj: Either a :class:`~wuttaweb.grids.base.Grid` or a
+ :class:`~wuttaweb.forms.base.Form` instance.
+ """
+ labels = self.collect_row_labels()
+ for key, label in labels.items():
+ obj.set_label(key, label)
+
+ def collect_row_labels(self):
+ """
+ Collect all **row** labels defined within the view class
+ hierarchy.
+
+ This is called by :meth:`set_row_labels()`.
+
+ :returns: Dict of all labels found.
+ """
+ labels = {}
+ hierarchy = self.get_class_hierarchy()
+ for cls in hierarchy:
+ if hasattr(cls, 'row_labels'):
+ labels.update(cls.row_labels)
+ return labels
+
##############################
# class methods
##############################
@@ -2526,6 +2769,18 @@ class MasterView(View):
return cls.get_model_title_plural()
+ @classmethod
+ def get_row_model_class(cls):
+ """
+ Returns the **row** model class for the view, if defined.
+ Only relevant if :attr:`has_rows` is true.
+
+ There is no default here, but a subclass may override by
+ assigning :attr:`row_model_class`.
+ """
+ if hasattr(cls, 'row_model_class'):
+ return cls.row_model_class
+
##############################
# configuration
##############################
diff --git a/tests/views/test_batch.py b/tests/views/test_batch.py
index 26ea706..fb2f46b 100644
--- a/tests/views/test_batch.py
+++ b/tests/views/test_batch.py
@@ -3,6 +3,7 @@
import datetime
from unittest.mock import patch, MagicMock
+from sqlalchemy import orm
from pyramid.httpexceptions import HTTPFound
from wuttjamaican.db import model
@@ -19,12 +20,23 @@ class MockBatchRow(model.BatchRowMixin, model.Base):
__tablename__ = 'testing_batch_mock_row'
__batch_class__ = MockBatch
+MockBatch.__row_class__ = MockBatchRow
+
class MockBatchHandler(BatchHandler):
model_class = MockBatch
class TestBatchMasterView(WebTestCase):
+ def setUp(self):
+ self.setup_web()
+
+ # nb. create MockBatch, MockBatchRow
+ model.Base.metadata.create_all(bind=self.session.bind)
+
+ def make_view(self):
+ return mod.BatchMasterView(self.request)
+
def test_get_batch_handler(self):
self.assertRaises(NotImplementedError, mod.BatchMasterView, self.request)
@@ -200,3 +212,78 @@ class TestBatchMasterView(WebTestCase):
self.session.commit()
# nb. should give up waiting after 1 second
self.assertRaises(RuntimeError, view.populate_thread, batch.uuid)
+
+ def test_get_row_model_class(self):
+ handler = MockBatchHandler(self.config)
+ with patch.object(mod.BatchMasterView, 'get_batch_handler', return_value=handler):
+ view = self.make_view()
+
+ self.assertRaises(AttributeError, view.get_row_model_class)
+
+ # row class determined from batch class
+ with patch.object(mod.BatchMasterView, 'model_class', new=MockBatch, create=True):
+ cls = view.get_row_model_class()
+ self.assertIs(cls, MockBatchRow)
+
+ self.assertRaises(AttributeError, view.get_row_model_class)
+
+ # view may specify row class
+ with patch.object(mod.BatchMasterView, 'row_model_class', new=MockBatchRow, create=True):
+ cls = view.get_row_model_class()
+ self.assertIs(cls, MockBatchRow)
+
+ def test_get_row_grid_data(self):
+ handler = MockBatchHandler(self.config)
+ model = self.app.model
+
+ user = model.User(username='barney')
+ self.session.add(user)
+
+ batch = handler.make_batch(self.session, created_by=user)
+ self.session.add(batch)
+ row = handler.make_row()
+ handler.add_row(batch, row)
+ self.session.flush()
+
+ with patch.object(mod.BatchMasterView, 'get_batch_handler', return_value=handler):
+
+ view = self.make_view()
+ self.assertRaises(AttributeError, view.get_row_grid_data, batch)
+
+ Session = MagicMock(return_value=self.session)
+ Session.query.side_effect = lambda m: self.session.query(m)
+ with patch.multiple(mod.BatchMasterView, create=True,
+ Session=Session,
+ model_class=MockBatch):
+
+ view = self.make_view()
+ data = view.get_row_grid_data(batch)
+ self.assertIsInstance(data, orm.Query)
+ self.assertEqual(data.count(), 1)
+
+ def test_configure_row_grid(self):
+ handler = MockBatchHandler(self.config)
+ model = self.app.model
+
+ user = model.User(username='barney')
+ self.session.add(user)
+
+ batch = handler.make_batch(self.session, created_by=user)
+ self.session.add(batch)
+ row = handler.make_row()
+ handler.add_row(batch, row)
+ self.session.flush()
+
+ with patch.object(mod.BatchMasterView, 'get_batch_handler', return_value=handler):
+
+ Session = MagicMock(return_value=self.session)
+ Session.query.side_effect = lambda m: self.session.query(m)
+ with patch.multiple(mod.BatchMasterView, create=True,
+ Session=Session,
+ model_class=MockBatch):
+
+ with patch.object(self.request, 'matchdict', new={'uuid': batch.uuid}):
+ view = self.make_view()
+ grid = view.make_row_model_grid(batch)
+ self.assertIn('sequence', grid.labels)
+ self.assertEqual(grid.labels['sequence'], "Seq.")
diff --git a/tests/views/test_master.py b/tests/views/test_master.py
index 8e451ee..06d8ed1 100644
--- a/tests/views/test_master.py
+++ b/tests/views/test_master.py
@@ -334,6 +334,16 @@ class TestMasterView(WebTestCase):
model_class=MyModel):
self.assertEqual(mod.MasterView.get_config_title(), "Dinosaurs")
+ def test_get_row_model_class(self):
+ model = self.app.model
+
+ # no default
+ self.assertIsNone(mod.MasterView.get_row_model_class())
+
+ # class may specify
+ with patch.object(mod.MasterView, 'row_model_class', create=True, new=model.User):
+ self.assertIs(mod.MasterView.get_row_model_class(), model.User)
+
##############################
# support methods
##############################
@@ -1017,6 +1027,53 @@ class TestMasterView(WebTestCase):
with patch.object(view, 'get_instance', return_value=setting):
response = view.view()
+ def test_view_with_rows(self):
+ self.pyramid_config.include('wuttaweb.views.common')
+ self.pyramid_config.include('wuttaweb.views.auth')
+ self.pyramid_config.add_route('people', '/people/')
+ model = self.app.model
+ person = model.Person(full_name="Whitney Houston")
+ self.session.add(person)
+ user = model.User(username='whitney', person=person)
+ self.session.add(user)
+ self.session.commit()
+
+ get_row_grid_data = MagicMock()
+ with patch.multiple(mod.MasterView, create=True,
+ Session=MagicMock(return_value=self.session),
+ model_class=model.Person,
+ route_prefix='people',
+ has_rows=True,
+ row_model_class=model.User,
+ get_row_grid_data=get_row_grid_data):
+ with patch.object(self.request, 'matchdict', new={'uuid': person.uuid}):
+ view = self.make_view()
+
+ # just for coverage
+ get_row_grid_data.return_value = []
+ response = view.view()
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response.content_type, 'text/html')
+
+ # now with data...
+ get_row_grid_data.return_value = [user]
+ response = view.view()
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response.content_type, 'text/html')
+
+ # then once more as 'partial' - aka. data only
+ with patch.dict(self.request.GET, {'partial': 1}):
+ response = view.view()
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response.content_type, 'application/json')
+
+ # redirects when view is reset
+ with patch.dict(self.request.GET, {'reset-view': '1', 'hash': 'foo'}):
+ # nb. mock current route
+ with patch.object(self.request, 'current_route_url'):
+ response = view.view()
+ self.assertEqual(response.status_code, 302)
+
def test_edit(self):
self.pyramid_config.include('wuttaweb.views.common')
self.pyramid_config.include('wuttaweb.views.auth')
@@ -1501,3 +1558,103 @@ class TestMasterView(WebTestCase):
# should now have 0 settings
count = self.session.query(model.Setting).count()
self.assertEqual(count, 0)
+
+ ##############################
+ # row methods
+ ##############################
+
+ def test_collect_row_labels(self):
+
+ # default labels
+ view = self.make_view()
+ labels = view.collect_row_labels()
+ self.assertEqual(labels, {})
+
+ # labels come from all classes; subclass wins
+ with patch.object(View, 'row_labels', create=True, new={'foo': "Foo", 'bar': "Bar"}):
+ with patch.object(mod.MasterView, 'row_labels', create=True, new={'foo': "FOO FIGHTERS"}):
+ view = self.make_view()
+ labels = view.collect_row_labels()
+ self.assertEqual(labels, {'foo': "FOO FIGHTERS", 'bar': "Bar"})
+
+ def test_set_row_labels(self):
+ model = self.app.model
+ person = model.Person(full_name="Fred Flintstone")
+ self.session.add(person)
+
+ with patch.multiple(mod.MasterView, create=True,
+ model_class=model.Person,
+ has_rows=True,
+ row_model_class=model.User):
+
+ # no labels by default
+ view = self.make_view()
+ grid = view.make_row_model_grid(person, key='person.users', data=[])
+ view.set_row_labels(grid)
+ self.assertEqual(grid.labels, {})
+
+ # labels come from all classes; subclass wins
+ with patch.object(View, 'row_labels', create=True, new={'username': "USERNAME"}):
+ with patch.object(mod.MasterView, 'row_labels', create=True, new={'username': "UserName"}):
+ view = self.make_view()
+ grid = view.make_row_model_grid(person, key='person.users', data=[])
+ view.set_row_labels(grid)
+ self.assertEqual(grid.labels, {'username': "UserName"})
+
+ def test_get_row_grid_data(self):
+ model = self.app.model
+ person = model.Person(full_name="Fred Flintstone")
+ self.session.add(person)
+ view = self.make_view()
+ self.assertRaises(NotImplementedError, view.get_row_grid_data, person)
+
+ def test_get_row_grid_columns(self):
+
+ # no default
+ view = self.make_view()
+ self.assertIsNone(view.get_row_grid_columns())
+
+ # class may specify
+ with patch.object(view, 'row_grid_columns', create=True, new=['foo', 'bar']):
+ self.assertEqual(view.get_row_grid_columns(), ['foo', 'bar'])
+
+ def test_get_row_grid_key(self):
+ view = self.make_view()
+ with patch.multiple(mod.MasterView, create=True,
+ model_key='id',
+ grid_key='widgets'):
+
+ self.request.matchdict = {'id': 42}
+ self.assertEqual(view.get_row_grid_key(), 'widgets.42')
+
+ def test_make_row_model_grid(self):
+ model = self.app.model
+ person = model.Person(full_name="Barney Rubble")
+ self.session.add(person)
+ self.session.commit()
+
+ self.request.matchdict = {'uuid': person.uuid}
+ with patch.multiple(mod.MasterView, create=True,
+ model_class=model.Person):
+ view = self.make_view()
+
+ # specify data
+ grid = view.make_row_model_grid(person, data=[])
+ self.assertIsNone(grid.model_class)
+ self.assertEqual(grid.data, [])
+
+ # fetch data
+ with patch.object(view, 'get_row_grid_data', return_value=[]):
+ grid = view.make_row_model_grid(person)
+ self.assertIsNone(grid.model_class)
+ self.assertEqual(grid.data, [])
+
+ def test_get_rows_title(self):
+ view = self.make_view()
+
+ # no default
+ self.assertIsNone(view.get_rows_title())
+
+ # class may specify
+ with patch.object(view, 'rows_title', create=True, new="Mock Rows"):
+ self.assertEqual(view.get_rows_title(), "Mock Rows")