From 484e1f810a64fdc5088e97d2b67087fbf8af7be3 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 29 Dec 2025 17:51:39 -0600 Subject: [PATCH] feat: add MasterView registry/discovery mechanism and show related MasterView link buttons when viewing App Table also show some more info about model class when viewing table --- src/wuttaweb/app.py | 5 ++ src/wuttaweb/conf.py | 45 ++++++++++++++++- src/wuttaweb/testing.py | 3 ++ src/wuttaweb/views/master.py | 3 ++ src/wuttaweb/views/reports.py | 1 + src/wuttaweb/views/tables.py | 94 +++++++++++++++++++++++++++++------ tests/test_conf.py | 61 +++++++++++++++++++++++ tests/views/test_tables.py | 29 +++++++++++ 8 files changed, 225 insertions(+), 16 deletions(-) create mode 100644 tests/test_conf.py diff --git a/src/wuttaweb/app.py b/src/wuttaweb/app.py index 75d00f2..d6749b7 100644 --- a/src/wuttaweb/app.py +++ b/src/wuttaweb/app.py @@ -164,6 +164,11 @@ def make_pyramid_config(settings): ) pyramid_config.add_directive("add_wutta_permission", "wuttaweb.auth.add_permission") + # add some more config magic + pyramid_config.add_directive( + "add_wutta_master_view", "wuttaweb.conf.add_master_view" + ) + return pyramid_config diff --git a/src/wuttaweb/conf.py b/src/wuttaweb/conf.py index 6d33fa6..61c038d 100644 --- a/src/wuttaweb/conf.py +++ b/src/wuttaweb/conf.py @@ -31,7 +31,8 @@ class WuttaWebConfigExtension(WuttaConfigExtension): """ Config extension for WuttaWeb. - This sets the default plugin for SQLAlchemy-Continuum. Which is + This sets the default plugin used for SQLAlchemy-Continuum, to + :class:`~wuttaweb.db.continuum.WuttaWebContinuumPlugin`. Which is only relevant if Wutta-Continuum is installed and enabled. For more info see :doc:`wutta-continuum:index`. """ @@ -44,3 +45,45 @@ class WuttaWebConfigExtension(WuttaConfigExtension): "wutta_continuum.wutta_plugin_spec", "wuttaweb.db.continuum:WuttaWebContinuumPlugin", ) + + +def add_master_view(config, master): + """ + Pyramid directive to add the given ``MasterView`` subclass to the + app's registry. + + This allows the app to dynamically present certain options for + admin features etc. + + This is normally called automatically for all master views, within + the :meth:`~wuttaweb.views.master.MasterView.defaults()` method. + + Should you need to call this yourself, do not call it directly but + instead make a similar call via the Pyramid config object:: + + pyramid_config.add_wutta_master_view(PoserWidgetView) + + :param config: Reference to the Pyramid config object. + + :param master: Reference to a + :class:`~wuttaweb.views.master.MasterView` subclass. + + This function is involved in app startup; once that phase is + complete you can inspect the master views like so:: + + master_views = request.registry.settings["wuttaweb_master_views"] + + # find master views for given model class + user_views = master_views.get(model.User, []) + + # some master views are registered by model name instead (if no class) + email_views = master_views.get("email_setting", []) + """ + key = master.get_model_class() or master.get_model_name() + + def action(): + master_views = config.get_settings().get("wuttaweb_master_views", {}) + master_views.setdefault(key, []).append(master) + config.add_settings({"wuttaweb_master_views": master_views}) + + config.action(None, action) diff --git a/src/wuttaweb/testing.py b/src/wuttaweb/testing.py index b9986aa..2d56066 100644 --- a/src/wuttaweb/testing.py +++ b/src/wuttaweb/testing.py @@ -75,6 +75,9 @@ class WebTestCase(DataTestCase): self.pyramid_config.add_directive( "add_wutta_permission", "wuttaweb.auth.add_permission" ) + self.pyramid_config.add_directive( + "add_wutta_master_view", "wuttaweb.conf.add_master_view" + ) self.pyramid_config.add_subscriber( "wuttaweb.subscribers.before_render", "pyramid.events.BeforeRender" ) diff --git a/src/wuttaweb/views/master.py b/src/wuttaweb/views/master.py index 030bacd..2875f7c 100644 --- a/src/wuttaweb/views/master.py +++ b/src/wuttaweb/views/master.py @@ -3840,6 +3840,9 @@ class MasterView(View): # pylint: disable=too-many-public-methods model_title = cls.get_model_title() model_title_plural = cls.get_model_title_plural() + # add to master view registry + config.add_wutta_master_view(cls) + # permission group config.add_wutta_permission_group( permission_prefix, model_title_plural, overwrite=False diff --git a/src/wuttaweb/views/reports.py b/src/wuttaweb/views/reports.py index 90ff204..1f91012 100644 --- a/src/wuttaweb/views/reports.py +++ b/src/wuttaweb/views/reports.py @@ -48,6 +48,7 @@ class ReportView(MasterView): # pylint: disable=abstract-method * ``/reports/XXX`` """ + model_name = "report" model_title = "Report" model_key = "report_key" filterable = False diff --git a/src/wuttaweb/views/tables.py b/src/wuttaweb/views/tables.py index 611a97a..273079b 100644 --- a/src/wuttaweb/views/tables.py +++ b/src/wuttaweb/views/tables.py @@ -25,6 +25,7 @@ Table Views """ import os +import sys from alembic import command as alembic_command from sqlalchemy_utils import get_mapper @@ -44,11 +45,12 @@ class AppTableView(MasterView): # pylint: disable=abstract-method """ Master view showing all tables in the :term:`app database`. - Default route prefix is ``tables``. + Default route prefix is ``app_tables``. Notable URLs provided by this class: - * ``/tables/`` + * ``/tables/app/`` + * ``/tables/app/XXX`` """ # pylint: disable=duplicate-code @@ -68,6 +70,8 @@ class AppTableView(MasterView): # pylint: disable=abstract-method labels = { "name": "Table Name", + "module_name": "Module", + "module_file": "File", } grid_columns = [ @@ -81,7 +85,11 @@ class AppTableView(MasterView): # pylint: disable=abstract-method form_fields = [ "name", "schema", + "model_name", + "description", # "row_count", + "module_name", + "module_file", ] has_rows = True @@ -101,6 +109,31 @@ class AppTableView(MasterView): # pylint: disable=abstract-method "description", ] + def normalize_table(self, table): # pylint: disable=missing-function-docstring + record = { + "name": table.name, + "schema": table.schema or "", + # "row_count": 42, + } + + try: + cls = get_mapper(table).class_ + except ValueError: + pass + else: + record.update( + { + "model_class": cls, + "model_name": cls.__name__, + "model_name_dotted": f"{cls.__module__}.{cls.__name__}", + "description": (cls.__doc__ or "").strip(), + "module_name": cls.__module__, + "module_file": sys.modules[cls.__module__].__file__, + } + ) + + return record + def get_grid_data( # pylint: disable=empty-docstring self, columns=None, session=None ): @@ -109,13 +142,7 @@ class AppTableView(MasterView): # pylint: disable=abstract-method data = [] for table in model.Base.metadata.tables.values(): - data.append( - { - "name": table.name, - "schema": table.schema or "", - # "row_count": 42, - } - ) + data.append(self.normalize_table(table)) return data @@ -143,12 +170,11 @@ class AppTableView(MasterView): # pylint: disable=abstract-method name = self.request.matchdict["name"] table = model.Base.metadata.tables[name] - data = { - "name": table.name, - "schema": table.schema or "", - # "row_count": 42, - "table": table, - } + + # nb. sometimes need the real table reference later when + # dealing with an instance view + data = self.normalize_table(table) + data["table"] = table self.__dict__["_cached_instance"] = data @@ -158,6 +184,44 @@ class AppTableView(MasterView): # pylint: disable=abstract-method """ """ return instance["name"] + def configure_form(self, form): # pylint: disable=empty-docstring + """ """ + f = form + super().configure_form(f) + + # description + f.set_widget("description", "notes") + + def get_xref_buttons(self, obj): + """ + By default this returns a list of buttons for each + :class:`~wuttaweb.views.master.MasterView` subclass registered + in the app for the current table model. + + See also parent method docs, + :meth:`~wuttaweb.views.master.MasterView.get_xref_buttons()` + """ + table = obj + buttons = [] + + # nb. we do not omit any buttons due to lack of permission + # here. all buttons are shown for anyone seeing this page. + # this is for sake of clarity so admin users are aware of what + # is *possible* within the app etc. + master_views = self.request.registry.settings.get("wuttaweb_master_views", {}) + model_views = master_views.get(table["model_class"], []) + for view in model_views: + buttons.append( + self.make_button( + view.get_model_title_plural(), + primary=True, + url=self.request.route_url(view.get_route_prefix()), + icon_left="eye", + ) + ) + + return buttons + def get_row_grid_data(self, obj): # pylint: disable=empty-docstring """ """ table = obj diff --git a/tests/test_conf.py b/tests/test_conf.py new file mode 100644 index 0000000..49d4f63 --- /dev/null +++ b/tests/test_conf.py @@ -0,0 +1,61 @@ +# -*- coding: utf-8; -*- + +from wuttjamaican.db.model import User +from wuttjamaican.testing import ConfigTestCase + +from wuttaweb import conf as mod +from wuttaweb.testing import WebTestCase +from wuttaweb.views import MasterView + + +class TestWuttaWebConfigExtension(ConfigTestCase): + + def test_basic(self): + + # continuum plugin not set yet (b/c config was not extended) + self.assertIsNone(self.config.get("wutta_continuum.wutta_plugin_spec")) + + # so let's extend it + extension = mod.WuttaWebConfigExtension() + extension.configure(self.config) + self.assertEqual( + self.config.get("wutta_continuum.wutta_plugin_spec"), + "wuttaweb.db.continuum:WuttaWebContinuumPlugin", + ) + + +class MasterWithClass(MasterView): + model_class = User + + +class MasterWithName(MasterView): + model_class = "Widget" + + +class TestAddMasterView(WebTestCase): + + def test_master_with_class(self): + model = self.app.model + + # nb. due to minimal test bootstrapping, no master views are + # registered by default at this point + self.assertNotIn("wuttaweb_master_views", self.request.registry.settings) + + self.pyramid_config.add_wutta_master_view(MasterWithClass) + self.assertIn("wuttaweb_master_views", self.request.registry.settings) + master_views = self.request.registry.settings["wuttaweb_master_views"] + self.assertIn(model.User, master_views) + self.assertEqual(master_views[model.User], [MasterWithClass]) + + def test_master_with_name(self): + model = self.app.model + + # nb. due to minimal test bootstrapping, no master views are + # registered by default at this point + self.assertNotIn("wuttaweb_master_views", self.request.registry.settings) + + self.pyramid_config.add_wutta_master_view(MasterWithName) + self.assertIn("wuttaweb_master_views", self.request.registry.settings) + master_views = self.request.registry.settings["wuttaweb_master_views"] + self.assertIn("Widget", master_views) + self.assertEqual(master_views["Widget"], [MasterWithName]) diff --git a/tests/views/test_tables.py b/tests/views/test_tables.py index d755cdd..2c3507c 100644 --- a/tests/views/test_tables.py +++ b/tests/views/test_tables.py @@ -9,6 +9,7 @@ from wuttjamaican.db.conf import check_alembic_current, make_alembic_config from wuttaweb.testing import WebTestCase from wuttaweb.views import tables as mod +from wuttaweb.views.users import UserView class TestAppTableView(WebTestCase): @@ -75,6 +76,34 @@ version_locations = wuttjamaican.db:alembic/versions table = {"name": "poser_foo"} self.assertEqual(view.get_instance_title(table), "poser_foo") + def test_configure_form(self): + view = self.make_view() + table = {"name": "user", "description": "Represents a user"} + + # no description widget by default + form = view.make_form(model_instance=table, fields=["name", "description"]) + self.assertNotIn("description", form.widgets) + + # but it gets added when configuring + view.configure_form(form) + self.assertIn("description", form.widgets) + + def test_get_xref_buttons(self): + self.pyramid_config.add_route("users", "/users/") + model = self.app.model + view = self.make_view() + + # nb. must add this first + self.pyramid_config.add_wutta_master_view(UserView) + + # now xref button should work + table = {"name": "person", "model_class": model.User} + buttons = view.get_xref_buttons(table) + self.assertEqual(len(buttons), 1) + button = buttons[0] + self.assertIn("Users", button) + self.assertIn("http://example.com/users/", button) + def test_get_row_grid_data(self): model = self.app.model view = self.make_view()