3
0
Fork 0

feat: add basic support for rows grid for master, batch views

This commit is contained in:
Lance Edgar 2024-12-14 10:38:49 -06:00
parent 5006c97b4b
commit e3beb9953d
5 changed files with 573 additions and 11 deletions

View file

@ -5,5 +5,34 @@
<%def name="content_title()">${instance_title}</%def> <%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:
<br />
<h4 class="block is-size-4">${master.get_rows_title() or ''}</h4>
${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>

View file

@ -50,6 +50,10 @@ class BatchMasterView(MasterView):
sort_defaults = ('id', 'desc') sort_defaults = ('id', 'desc')
has_rows = True
rows_title = "Batch Rows"
rows_sort_defaults = 'sequence'
def __init__(self, request, context=None): def __init__(self, request, context=None):
super().__init__(request, context=context) super().__init__(request, context=context)
self.batch_handler = self.get_batch_handler() self.batch_handler = self.get_batch_handler()
@ -253,3 +257,33 @@ class BatchMasterView(MasterView):
finally: finally:
session.close() 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)

View file

@ -73,12 +73,12 @@ class MasterView(View):
.. attribute:: model_class .. attribute:: model_class
Optional reference to a data model class. While not strictly Optional reference to a :term:`data model` class. While not
required, most views will set this to a SQLAlchemy mapped strictly required, most views will set this to a SQLAlchemy
class, mapped class,
e.g. :class:`~wuttjamaican:wuttjamaican.db.model.base.Person`. 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()`. :meth:`get_model_class()`.
.. attribute:: model_name .. attribute:: model_name
@ -340,6 +340,38 @@ class MasterView(View):
Boolean indicating whether the master view supports Boolean indicating whether the master view supports
"configuring" - i.e. it should have a :meth:`configure()` view. "configuring" - i.e. it should have a :meth:`configure()` view.
Default value is ``False``. 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 execute_progress_template = None
configurable = False 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 # current action
listing = False listing = False
creating = False creating = False
@ -525,15 +567,40 @@ class MasterView(View):
* :meth:`make_model_form()` * :meth:`make_model_form()`
* :meth:`configure_form()` * :meth:`configure_form()`
* :meth:`make_row_model_grid()` - if :attr:`has_rows` is true
""" """
self.viewing = True self.viewing = True
instance = self.get_instance() obj = self.get_instance()
form = self.make_model_form(instance, readonly=True) form = self.make_model_form(obj, readonly=True)
context = { context = {
'instance': instance, 'instance': obj,
'form': form, '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) return self.render_to_response('view', context)
############################## ##############################
@ -1907,8 +1974,8 @@ class MasterView(View):
This is called by :meth:`make_model_grid()`. This is called by :meth:`make_model_grid()`.
There is no default logic here; subclass should override as There is minimal default logic here; subclass should override
needed. The ``grid`` param will already be "complete" and as needed. The ``grid`` param will already be "complete" and
ready to use as-is, but this method can further modify it ready to use as-is, but this method can further modify it
based on request details etc. based on request details etc.
""" """
@ -2241,6 +2308,182 @@ class MasterView(View):
session = session or self.Session() session = session or self.Session()
session.add(obj) 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 # class methods
############################## ##############################
@ -2526,6 +2769,18 @@ class MasterView(View):
return cls.get_model_title_plural() 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 # configuration
############################## ##############################

View file

@ -3,6 +3,7 @@
import datetime import datetime
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
from sqlalchemy import orm
from pyramid.httpexceptions import HTTPFound from pyramid.httpexceptions import HTTPFound
from wuttjamaican.db import model from wuttjamaican.db import model
@ -19,12 +20,23 @@ class MockBatchRow(model.BatchRowMixin, model.Base):
__tablename__ = 'testing_batch_mock_row' __tablename__ = 'testing_batch_mock_row'
__batch_class__ = MockBatch __batch_class__ = MockBatch
MockBatch.__row_class__ = MockBatchRow
class MockBatchHandler(BatchHandler): class MockBatchHandler(BatchHandler):
model_class = MockBatch model_class = MockBatch
class TestBatchMasterView(WebTestCase): 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): def test_get_batch_handler(self):
self.assertRaises(NotImplementedError, mod.BatchMasterView, self.request) self.assertRaises(NotImplementedError, mod.BatchMasterView, self.request)
@ -200,3 +212,78 @@ class TestBatchMasterView(WebTestCase):
self.session.commit() self.session.commit()
# nb. should give up waiting after 1 second # nb. should give up waiting after 1 second
self.assertRaises(RuntimeError, view.populate_thread, batch.uuid) 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.")

View file

@ -334,6 +334,16 @@ class TestMasterView(WebTestCase):
model_class=MyModel): model_class=MyModel):
self.assertEqual(mod.MasterView.get_config_title(), "Dinosaurs") 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 # support methods
############################## ##############################
@ -1017,6 +1027,53 @@ class TestMasterView(WebTestCase):
with patch.object(view, 'get_instance', return_value=setting): with patch.object(view, 'get_instance', return_value=setting):
response = view.view() 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): def test_edit(self):
self.pyramid_config.include('wuttaweb.views.common') self.pyramid_config.include('wuttaweb.views.common')
self.pyramid_config.include('wuttaweb.views.auth') self.pyramid_config.include('wuttaweb.views.auth')
@ -1501,3 +1558,103 @@ class TestMasterView(WebTestCase):
# should now have 0 settings # should now have 0 settings
count = self.session.query(model.Setting).count() count = self.session.query(model.Setting).count()
self.assertEqual(count, 0) 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")