3
0
Fork 0

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
This commit is contained in:
Lance Edgar 2025-12-29 17:51:39 -06:00
parent 0619f070c7
commit 484e1f810a
8 changed files with 225 additions and 16 deletions

View file

@ -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

View file

@ -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)

View file

@ -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"
)

View file

@ -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

View file

@ -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

View file

@ -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

61
tests/test_conf.py Normal file
View file

@ -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])

View file

@ -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()