From cd706821b2e8d439ba2cf281708404df32e69102 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 15 Aug 2024 20:51:36 -0500 Subject: [PATCH] feat: add form/grid label auto-overrides for master view --- src/wuttaweb/forms/base.py | 6 ++-- src/wuttaweb/forms/schema.py | 9 +++-- src/wuttaweb/grids/base.py | 38 ++++++++++++++++++-- src/wuttaweb/views/master.py | 69 ++++++++++++++++++++++++++++++++++++ tests/forms/test_schema.py | 5 +-- tests/grids/test_base.py | 24 +++++++++++++ tests/views/test_master.py | 39 ++++++++++++++++++++ 7 files changed, 179 insertions(+), 11 deletions(-) diff --git a/src/wuttaweb/forms/base.py b/src/wuttaweb/forms/base.py index 8ed17f8..59cdc04 100644 --- a/src/wuttaweb/forms/base.py +++ b/src/wuttaweb/forms/base.py @@ -435,16 +435,14 @@ class Form: Node overrides are tracked via :attr:`nodes`. """ + from wuttaweb.forms.schema import ObjectNode + if isinstance(nodeinfo, colander.SchemaNode): # assume nodeinfo is a complete node node = nodeinfo else: # assume nodeinfo is a schema type kwargs.setdefault('name', key) - - from wuttaweb.forms.schema import ObjectNode - - # node = colander.SchemaNode(nodeinfo, **kwargs) node = ObjectNode(nodeinfo, **kwargs) self.nodes[key] = node diff --git a/src/wuttaweb/forms/schema.py b/src/wuttaweb/forms/schema.py index 98ce0f1..8c245f6 100644 --- a/src/wuttaweb/forms/schema.py +++ b/src/wuttaweb/forms/schema.py @@ -59,13 +59,16 @@ class ObjectNode(colander.SchemaNode): :class:`ObjectRef`. If the node's type does not have a ``dictify()`` method, this - will raise ``NotImplementeError``. + will just convert the object to a string and return that. """ if hasattr(self.typ, 'dictify'): return self.typ.dictify(obj) - class_name = self.typ.__class__.__name__ - raise NotImplementedError(f"you must define {class_name}.dictify()") + # TODO: this is better than raising an error, as it previously + # did, but seems like troubleshooting problems may often lead + # one here.. i suspect this needs to do something smarter but + # not sure what that is yet + return str(obj) def objectify(self, value): """ diff --git a/src/wuttaweb/grids/base.py b/src/wuttaweb/grids/base.py index 1e793bd..740607c 100644 --- a/src/wuttaweb/grids/base.py +++ b/src/wuttaweb/grids/base.py @@ -82,6 +82,12 @@ class Grid: model records) or else an object capable of producing such a list, e.g. SQLAlchemy query. + .. attribute:: labels + + Dict of column label overrides. + + See also :meth:`get_label()` and :meth:`set_label()`. + .. attribute:: renderers Dict of column (cell) value renderer overrides. @@ -113,6 +119,7 @@ class Grid: key=None, columns=None, data=None, + labels={}, renderers={}, actions=[], linked_columns=[], @@ -122,6 +129,7 @@ class Grid: self.model_class = model_class self.key = key self.data = data + self.labels = labels or {} self.renderers = renderers or {} self.actions = actions or [] self.linked_columns = linked_columns or [] @@ -220,6 +228,32 @@ class Grid: if key in self.columns: self.columns.remove(key) + def set_label(self, key, label): + """ + Set/override the label for a column. + + :param key: Name of column. + + :param label: New label for the column header. + + See also :meth:`get_label()`. + + Label overrides are tracked via :attr:`labels`. + """ + self.labels[key] = label + + def get_label(self, key): + """ + Returns the label text for a given column. + + If no override is defined, the label is derived from ``key``. + + See also :meth:`set_label()`. + """ + if key in self.labels: + return self.labels[key] + return self.app.make_title(key) + def set_renderer(self, key, renderer, **kwargs): """ Set/override the value renderer for a column. @@ -376,7 +410,7 @@ class Grid: for name in self.columns: columns.append({ 'field': name, - 'label': self.app.make_title(name), + 'label': self.get_label(name), }) return columns @@ -430,7 +464,7 @@ class Grid: # customize value rendering where applicable for key in self.renderers: - value = record[key] + value = record.get(key, None) record[key] = self.renderers[key](original_record, key, value) # add action urls to each record diff --git a/src/wuttaweb/views/master.py b/src/wuttaweb/views/master.py index fe4448f..1c7518d 100644 --- a/src/wuttaweb/views/master.py +++ b/src/wuttaweb/views/master.py @@ -33,6 +33,7 @@ from webhelpers2.html import HTML from wuttaweb.views import View from wuttaweb.util import get_form_data, get_model_fields from wuttaweb.db import Session +from wuttjamaican.util import get_class_hierarchy class MasterView(View): @@ -803,6 +804,16 @@ class MasterView(View): # support methods ############################## + def get_class_hierarchy(self, topfirst=True): + """ + Convenience to return a list of classes from which the current + class inherits. + + This is a wrapper around + :func:`wuttjamaican.util.get_class_hierarchy()`. + """ + return get_class_hierarchy(self.__class__, topfirst=topfirst) + def has_perm(self, name): """ Shortcut to check if current user has the given permission. @@ -949,6 +960,60 @@ class MasterView(View): route_prefix = self.get_route_prefix() return self.request.route_url(route_prefix, **kwargs) + def set_labels(self, obj): + """ + Set label overrides on a form or grid, based on what is + defined by the view class and its parent class(es). + + This is called automatically from :meth:`configure_grid()` and + :meth:`configure_form()`. + + This calls :meth:`collect_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_labels() + for key, label in labels.items(): + obj.set_label(key, label) + + def collect_labels(self): + """ + Collect all labels defined by the view class and/or its parents. + + A master view can declare labels via class-level attribute, + like so:: + + from wuttaweb.views import MasterView + + class WidgetView(MasterView): + + labels = { + 'id': "Widget ID", + 'serial_no': "Serial Number", + } + + All such labels, defined by any class from which the master + view inherits, will be returned. However if the same label + key is defined by multiple classes, the "subclass" always + wins. + + Labels defined in this way will apply to both forms and grids. + See also :meth:`set_labels()`. + + :returns: Dict of all labels found. + """ + labels = {} + hierarchy = self.get_class_hierarchy() + for cls in hierarchy: + if hasattr(cls, 'labels'): + labels.update(cls.labels) + return labels + def make_model_grid(self, session=None, **kwargs): """ Create and return a :class:`~wuttaweb.grids.base.Grid` @@ -1072,6 +1137,8 @@ class MasterView(View): if 'uuid' in grid.columns: grid.columns.remove('uuid') + self.set_labels(grid) + for key in self.get_model_key(): grid.set_link(key) @@ -1311,6 +1378,8 @@ class MasterView(View): """ form.remove('uuid') + self.set_labels(form) + if self.editing: for key in self.get_model_key(): form.set_readonly(key) diff --git a/tests/forms/test_schema.py b/tests/forms/test_schema.py index aef3432..ed68d3b 100644 --- a/tests/forms/test_schema.py +++ b/tests/forms/test_schema.py @@ -22,9 +22,10 @@ class TestObjectNode(DataTestCase): model = self.app.model person = model.Person(full_name="Betty Boop") - # unsupported type raises error + # unsupported type is converted to string node = mod.ObjectNode(colander.String()) - self.assertRaises(NotImplementedError, node.dictify, person) + value = node.dictify(person) + self.assertEqual(value, "Betty Boop") # but supported type can dictify node = mod.ObjectNode(mod.PersonRef(self.request)) diff --git a/tests/grids/test_base.py b/tests/grids/test_base.py index c3e8f7b..2d15689 100644 --- a/tests/grids/test_base.py +++ b/tests/grids/test_base.py @@ -81,6 +81,30 @@ class TestGrid(TestCase): grid.remove('two', 'three') self.assertEqual(grid.columns, ['one', 'four']) + def test_set_label(self): + grid = self.make_grid(columns=['foo', 'bar']) + self.assertEqual(grid.labels, {}) + + # basic + grid.set_label('foo', "Foo Fighters") + self.assertEqual(grid.labels['foo'], "Foo Fighters") + + # can replace label + grid.set_label('foo', "Different") + self.assertEqual(grid.labels['foo'], "Different") + self.assertEqual(grid.get_label('foo'), "Different") + + def test_get_label(self): + grid = self.make_grid(columns=['foo', 'bar']) + self.assertEqual(grid.labels, {}) + + # default derived from key + self.assertEqual(grid.get_label('foo'), "Foo") + + # can override + grid.set_label('foo', "Different") + self.assertEqual(grid.get_label('foo'), "Different") + def test_set_renderer(self): grid = self.make_grid(columns=['foo', 'bar']) self.assertEqual(grid.renderers, {}) diff --git a/tests/views/test_master.py b/tests/views/test_master.py index 1f00d28..8647b2a 100644 --- a/tests/views/test_master.py +++ b/tests/views/test_master.py @@ -10,6 +10,7 @@ from pyramid.httpexceptions import HTTPNotFound from wuttjamaican.conf import WuttaConfig from wuttaweb.views import master +from wuttaweb.views import View from wuttaweb.subscribers import new_request_set_user from tests.util import WebTestCase @@ -331,6 +332,14 @@ class TestMasterView(WebTestCase): # support methods ############################## + def test_get_class_hierarchy(self): + class MyView(master.MasterView): + pass + + view = MyView(self.request) + classes = view.get_class_hierarchy() + self.assertEqual(classes, [View, master.MasterView, MyView]) + def test_has_perm(self): model = self.app.model auth = self.app.get_auth_handler() @@ -428,6 +437,36 @@ class TestMasterView(WebTestCase): self.assertEqual(view.get_index_title(), "Wutta Widgets") del master.MasterView.model_title_plural + def test_collect_labels(self): + + # no labels by default + view = self.make_view() + labels = view.collect_labels() + self.assertEqual(labels, {}) + + # labels come from all classes; subclass wins + with patch.object(View, 'labels', new={'foo': "Foo", 'bar': "Bar"}, create=True): + with patch.object(master.MasterView, 'labels', new={'foo': "FOO FIGHTERS"}, create=True): + view = self.make_view() + labels = view.collect_labels() + self.assertEqual(labels, {'foo': "FOO FIGHTERS", 'bar': "Bar"}) + + def test_set_labels(self): + model = self.app.model + with patch.object(master.MasterView, 'model_class', new=model.Setting, create=True): + + # no labels by default + view = self.make_view() + grid = view.make_model_grid(session=self.session) + view.set_labels(grid) + self.assertEqual(grid.labels, {}) + + # labels come from all classes; subclass wins + with patch.object(master.MasterView, 'labels', new={'name': "SETTING NAME"}, create=True): + view = self.make_view() + view.set_labels(grid) + self.assertEqual(grid.labels, {'name': "SETTING NAME"}) + def test_make_model_grid(self): model = self.app.model