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 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 name="render_vue_templates()"> + ${parent.render_vue_templates()} + % if master.has_rows: + ${self.render_vue_template_rows_grid()} + % endif + + +<%def name="render_vue_template_rows_grid()"> + ${rows_grid.render_vue_template()} + + +<%def name="make_vue_components()"> + ${parent.make_vue_components()} + % if master.has_rows: + ${rows_grid.render_vue_finalize()} + % endif + 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")