From 6085ea78ec2626d82033348e1d619b47ba0a4c67 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Mon, 25 Nov 2024 18:45:36 -0600
Subject: [PATCH 1/3] fix: hide CRUD header buttons if master view does not
 allow

---
 src/wuttaweb/templates/base.mako | 8 ++++----
 1 file changed, 4 insertions(+), 4 deletions(-)

diff --git a/src/wuttaweb/templates/base.mako b/src/wuttaweb/templates/base.mako
index 14d0306..6e5d004 100644
--- a/src/wuttaweb/templates/base.mako
+++ b/src/wuttaweb/templates/base.mako
@@ -682,13 +682,13 @@
 <%def name="render_crud_header_buttons()">
   % if master:
       % if master.viewing:
-          % if instance_editable and master.has_perm('edit'):
+          % if master.editable and instance_editable and master.has_perm('edit'):
               <wutta-button once
                             tag="a" href="${master.get_action_url('edit', instance)}"
                             icon-left="edit"
                             label="Edit This" />
           % endif
-          % if instance_deletable and master.has_perm('delete'):
+          % if master.deletable and instance_deletable and master.has_perm('delete'):
               <wutta-button once type="is-danger"
                             tag="a" href="${master.get_action_url('delete', instance)}"
                             icon-left="trash"
@@ -701,7 +701,7 @@
                             icon-left="eye"
                             label="View This" />
           % endif
-          % if instance_deletable and master.has_perm('delete'):
+          % if master.deletable and instance_deletable and master.has_perm('delete'):
               <wutta-button once type="is-danger"
                             tag="a" href="${master.get_action_url('delete', instance)}"
                             icon-left="trash"
@@ -714,7 +714,7 @@
                             icon-left="eye"
                             label="View This" />
           % endif
-          % if instance_editable and master.has_perm('edit'):
+          % if master.editable and instance_editable and master.has_perm('edit'):
               <wutta-button once
                             tag="a" href="${master.get_action_url('edit', instance)}"
                             icon-left="edit"

From ba9021c990aa214f51a50876889f404ad9ec5d08 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Mon, 25 Nov 2024 19:10:58 -0600
Subject: [PATCH 2/3] feat: add `get_template_context()` method for master view

allow override / supplement context for any view template
---
 src/wuttaweb/views/master.py | 23 +++++++++++++++++++++++
 1 file changed, 23 insertions(+)

diff --git a/src/wuttaweb/views/master.py b/src/wuttaweb/views/master.py
index c5ff5d7..6f3967e 100644
--- a/src/wuttaweb/views/master.py
+++ b/src/wuttaweb/views/master.py
@@ -1629,6 +1629,9 @@ class MasterView(View):
             if 'instance_deletable' not in context:
                 context['instance_deletable'] = self.is_deletable(instance)
 
+        # supplement context further if needed
+        context = self.get_template_context(context)
+
         # first try the template path most specific to this view
         template_prefix = self.get_template_prefix()
         mako_path = f'{template_prefix}/{template}.mako'
@@ -1648,6 +1651,26 @@ class MasterView(View):
             # let that error raise on up
             return render_to_response(mako_path, context, request=self.request)
 
+    def get_template_context(self, context):
+        """
+        This method should return the "complete" context for rendering
+        the current view template.
+
+        Default logic for this method returns the given context
+        unchanged.
+
+        You may wish to override to pass extra context to the view
+        template.  Check :attr:`viewing` and similar, or
+        ``request.current_route_name`` etc. in order to add extra
+        context only for certain view templates.
+
+        :params: context: The context dict we have so far,
+           auto-provided by the master view logic.
+
+        :returns: Final context dict for the template.
+        """
+        return context
+
     def get_fallback_templates(self, template):
         """
         Returns a list of "fallback" template paths which may be

From dcdc0e7daba618f954b562300669b255099826e2 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Mon, 25 Nov 2024 19:11:41 -0600
Subject: [PATCH 3/3] fix: improve support for random objects with grid, master
 view

thus far we expected either dict or "native" ORM object which can
essentially behave like a dict when needed.  but a "non-native" object
may not behave like a dict and this hopefully fixes the logic to allow
for those anyway..
---
 src/wuttaweb/grids/base.py   | 11 ++++++++++-
 src/wuttaweb/views/master.py |  8 ++++++--
 tests/grids/test_base.py     | 19 +++++++++++++++++++
 tests/views/test_master.py   | 35 +++++++++++++++++++++++++++++++++++
 4 files changed, 70 insertions(+), 3 deletions(-)

diff --git a/src/wuttaweb/grids/base.py b/src/wuttaweb/grids/base.py
index 4ff990e..71b6a69 100644
--- a/src/wuttaweb/grids/base.py
+++ b/src/wuttaweb/grids/base.py
@@ -1940,6 +1940,15 @@ class Grid:
             })
         return filters
 
+    def object_to_dict(self, obj):
+        """ """
+        try:
+            dct = dict(obj)
+        except TypeError:
+            dct = dict(obj.__dict__)
+            dct.pop('_sa_instance_state', None)
+        return dct
+
     def get_vue_context(self):
         """
         Returns a dict of context for the grid, for use with the Vue
@@ -1976,7 +1985,7 @@ class Grid:
             original_record = record
 
             # convert record to new dict
-            record = dict(record)
+            record = self.object_to_dict(record)
 
             # make all values safe for json
             record = make_json_safe(record, warn=False)
diff --git a/src/wuttaweb/views/master.py b/src/wuttaweb/views/master.py
index 6f3967e..6e70c4d 100644
--- a/src/wuttaweb/views/master.py
+++ b/src/wuttaweb/views/master.py
@@ -2018,8 +2018,12 @@ class MasterView(View):
         :param obj: Model instance object.
         """
         route_prefix = self.get_route_prefix()
-        kw = dict([(key, obj[key])
-                   for key in self.get_model_key()])
+        try:
+            kw = dict([(key, obj[key])
+                       for key in self.get_model_key()])
+        except TypeError:
+            kw = dict([(key, getattr(obj, key))
+                       for key in self.get_model_key()])
         kw.update(kwargs)
         return self.request.route_url(f'{route_prefix}.{action}', **kw)
 
diff --git a/tests/grids/test_base.py b/tests/grids/test_base.py
index f532ddf..fb939db 100644
--- a/tests/grids/test_base.py
+++ b/tests/grids/test_base.py
@@ -1373,6 +1373,25 @@ class TestGrid(WebTestCase):
         filters = grid.get_vue_filters()
         self.assertEqual(len(filters), 2)
 
+    def test_object_to_dict(self):
+        grid = self.make_grid()
+        setting = {'name': 'foo', 'value': 'bar'}
+
+        # new dict but with same values
+        dct = grid.object_to_dict(setting)
+        self.assertIsInstance(dct, dict)
+        self.assertIsNot(dct, setting)
+        self.assertEqual(dct, setting)
+
+        # random object, not iterable
+        class MockSetting:
+            def __init__(self, **kw):
+                self.__dict__.update(kw)
+        mock = MockSetting(**setting)
+        dct = grid.object_to_dict(mock)
+        self.assertIsInstance(dct, dict)
+        self.assertEqual(dct, setting)
+
     def test_get_vue_context(self):
 
         # empty if no columns defined
diff --git a/tests/views/test_master.py b/tests/views/test_master.py
index 7b75e0a..7e427e9 100644
--- a/tests/views/test_master.py
+++ b/tests/views/test_master.py
@@ -734,6 +734,41 @@ class TestMasterView(WebTestCase):
             self.request.matchdict = {'name': 'blarg'}
             self.assertRaises(HTTPNotFound, view.get_instance, session=self.session)
 
+    def test_get_action_url_for_dict(self):
+        model = self.app.model
+        setting = {'name': 'foo', 'value': 'bar'}
+        with patch.multiple(mod.MasterView, create=True,
+                            model_class=model.Setting):
+            mod.MasterView.defaults(self.pyramid_config)
+            view = self.make_view()
+            url = view.get_action_url_view(setting, 0)
+            self.assertEqual(url, self.request.route_url('settings.view', name='foo'))
+
+    def test_get_action_url_for_orm_object(self):
+        model = self.app.model
+        setting = model.Setting(name='foo', value='bar')
+        self.session.add(setting)
+        self.session.commit()
+        with patch.multiple(mod.MasterView, create=True,
+                            model_class=model.Setting):
+            mod.MasterView.defaults(self.pyramid_config)
+            view = self.make_view()
+            url = view.get_action_url_view(setting, 0)
+            self.assertEqual(url, self.request.route_url('settings.view', name='foo'))
+
+    def test_get_action_url_for_adhoc_object(self):
+        model = self.app.model
+        class MockSetting:
+            def __init__(self, **kw):
+                self.__dict__.update(kw)
+        setting = MockSetting(name='foo', value='bar')
+        with patch.multiple(mod.MasterView, create=True,
+                            model_class=model.Setting):
+            mod.MasterView.defaults(self.pyramid_config)
+            view = self.make_view()
+            url = view.get_action_url_view(setting, 0)
+            self.assertEqual(url, self.request.route_url('settings.view', name='foo'))
+
     def test_get_action_url_view(self):
         model = self.app.model
         setting = model.Setting(name='foo', value='bar')