From c46b42f76db1fe2ae424f8a377603963e248dfed Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 11 Aug 2024 09:56:47 -0500 Subject: [PATCH] feat: add basic Delete support for CRUD master view --- src/wuttaweb/forms/base.py | 62 +++++++-- src/wuttaweb/grids/base.py | 19 +++ src/wuttaweb/templates/base.mako | 28 +++-- .../templates/forms/vue_template.mako | 2 +- .../templates/grids/vue_template.mako | 6 +- src/wuttaweb/templates/master/delete.mako | 18 +++ src/wuttaweb/views/master.py | 119 +++++++++++++++++- src/wuttaweb/views/settings.py | 31 ++--- tests/forms/test_base.py | 16 ++- tests/grids/test_base.py | 9 ++ tests/views/test_master.py | 67 ++++++++-- tests/views/test_settings.py | 26 ++-- 12 files changed, 331 insertions(+), 72 deletions(-) create mode 100644 src/wuttaweb/templates/master/delete.mako diff --git a/src/wuttaweb/forms/base.py b/src/wuttaweb/forms/base.py index a47f6bd..b3dd1e2 100644 --- a/src/wuttaweb/forms/base.py +++ b/src/wuttaweb/forms/base.py @@ -118,9 +118,9 @@ class Form: .. attribute:: schema - Colander-based schema object for the form. This is optional; - if not specified an attempt will be made to construct one - automatically. + :class:`colander:colander.Schema` object for the form. This is + optional; if not specified an attempt will be made to construct + one automatically. See also :meth:`get_schema()`. @@ -151,12 +151,13 @@ class Form: .. attribute:: readonly_fields - Set of fields which should be readonly. Each will still be - rendered but with static value text and no widget. + A :class:`~python:set` of field names which should be readonly. + Each will still be rendered but with static value text and no + widget. This is only applicable if :attr:`readonly` is ``False``. - See also :meth:`set_readonly()`. + See also :meth:`set_readonly()` and :meth:`is_readonly()`. .. attribute:: action_url @@ -202,6 +203,21 @@ class Form: String icon name for the form submit button. Default is ``'save'``. + .. attribute:: button_type_submit + + Buefy type for the submit button. Default is ``'is-primary'``, + so for example: + + .. code-block:: html + + + Save + + + See also the `Buefy docs + `_. + .. attribute:: show_button_reset Flag indicating whether a Reset button should be shown. @@ -249,6 +265,7 @@ class Form: auto_disable_submit=True, button_label_submit="Save", button_icon_submit='save', + button_type_submit='is-primary', show_button_reset=False, show_button_cancel=True, button_label_cancel="Cancel", @@ -267,6 +284,7 @@ class Form: self.auto_disable_submit = auto_disable_submit self.button_label_submit = button_label_submit self.button_icon_submit = button_icon_submit + self.button_type_submit = button_type_submit self.show_button_reset = show_button_reset self.show_button_cancel = show_button_cancel self.button_label_cancel = button_label_cancel @@ -650,14 +668,14 @@ class Form: def validate(self): """ - Try to validate the form. + Try to validate the form, using data from the :attr:`request`. - This should work whether request data was submitted as classic - POST data, or as JSON body. + Uses :func:`~wuttaweb.util.get_form_data()` to retrieve the + form data from POST or JSON body. - If the form data is valid, this method returns the data dict. - This data dict is also then available on the form object via - the :attr:`validated` attribute. + If the form data is valid, the data dict is returned. This + data dict is also made available on the form object via the + :attr:`validated` attribute. However if the data is not valid, ``False`` is returned, and there will be no :attr:`validated` attribute. In that case @@ -665,6 +683,17 @@ class Form: wrong for the user's sake. See also :meth:`get_field_errors()`. + This uses :meth:`deform:deform.Field.validate()` under the + hood. + + .. warning:: + + Calling ``validate()`` on some forms will cause the + underlying Deform and Colander structures to mutate. In + particular, all :attr:`readonly_fields` will be *removed* + from the :attr:`schema` to ensure they are not involved in + the validation. + :returns: Data dict, or ``False``. """ if hasattr(self, 'validated'): @@ -673,9 +702,16 @@ class Form: if self.request.method != 'POST': return False + # remove all readonly fields from deform / schema dform = self.get_deform() - controls = get_form_data(self.request).items() + if self.readonly_fields: + schema = self.get_schema() + for field in self.readonly_fields: + del schema[field] + dform.children.remove(dform[field]) + # let deform do real validation + controls = get_form_data(self.request).items() try: self.validated = dform.validate(controls) except deform.ValidationFailure: diff --git a/src/wuttaweb/grids/base.py b/src/wuttaweb/grids/base.py index 330b67b..5cb4dab 100644 --- a/src/wuttaweb/grids/base.py +++ b/src/wuttaweb/grids/base.py @@ -331,6 +331,10 @@ class GridAction: Name of icon to be shown for the action link. See also :meth:`render_icon()`. + + .. attribute:: link_class + + Optional HTML class attribute for the action's ```` tag. """ def __init__( @@ -340,6 +344,7 @@ class GridAction: label=None, url=None, icon=None, + link_class=None, ): self.request = request self.config = self.request.wutta_config @@ -348,6 +353,16 @@ class GridAction: self.url = url self.label = label or self.app.make_title(key) self.icon = icon or key + self.link_class = link_class or '' + + def render_icon_and_label(self): + """ + Render the HTML snippet for action link icon and label. + + Default logic returns the output from :meth:`render_icon()` + and :meth:`render_label()`. + """ + return self.render_icon() + ' ' + self.render_label() def render_icon(self): """ @@ -359,6 +374,8 @@ class GridAction: .. code-block:: html + + See also :meth:`render_icon_and_label()`. """ if self.request.use_oruga: raise NotImplementedError @@ -370,6 +387,8 @@ class GridAction: Render the label text for the action link. Default behavior is to return :attr:`label` as-is. + + See also :meth:`render_icon_and_label()`. """ return self.label diff --git a/src/wuttaweb/templates/base.mako b/src/wuttaweb/templates/base.mako index 7f1b3b0..487867c 100644 --- a/src/wuttaweb/templates/base.mako +++ b/src/wuttaweb/templates/base.mako @@ -231,13 +231,10 @@ ## TODO % if master and master.configurable and not master.configuring:
- - Configure - +
% endif @@ -374,11 +371,28 @@ tag="a" href="${master.get_action_url('edit', instance)}" icon-left="edit" label="Edit This" /> + % elif master.editing: + + % elif master.deleting: + + % endif % endif diff --git a/src/wuttaweb/templates/forms/vue_template.mako b/src/wuttaweb/templates/forms/vue_template.mako index dd7bfa1..93d1f7d 100644 --- a/src/wuttaweb/templates/forms/vue_template.mako +++ b/src/wuttaweb/templates/forms/vue_template.mako @@ -25,7 +25,7 @@ % endif - % for action in grid.actions: -
- ${action.render_icon()} - ${action.render_label()} + + ${action.render_icon_and_label()}   % endfor diff --git a/src/wuttaweb/templates/master/delete.mako b/src/wuttaweb/templates/master/delete.mako new file mode 100644 index 0000000..59a6f63 --- /dev/null +++ b/src/wuttaweb/templates/master/delete.mako @@ -0,0 +1,18 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/form.mako" /> + +<%def name="title()">${index_title} » ${instance_title} » Delete + +<%def name="content_title()">Delete: ${instance_title} + +<%def name="page_content()"> +
+ + Really DELETE this ${model_title}? + + ${parent.page_content()} + + + +${parent.body()} diff --git a/src/wuttaweb/views/master.py b/src/wuttaweb/views/master.py index a2a97fc..1ac6a9f 100644 --- a/src/wuttaweb/views/master.py +++ b/src/wuttaweb/views/master.py @@ -180,6 +180,12 @@ class MasterView(View): i.e. it should have an :meth:`edit()` view. Default value is ``True``. + .. attribute:: deletable + + Boolean indicating whether the view model supports "deleting" - + i.e. it should have a :meth:`delete()` view. Default value is + ``True``. + .. attribute:: form_fields List of columns for the model form. @@ -202,12 +208,14 @@ class MasterView(View): has_grid = True viewable = True editable = True + deletable = True configurable = False # current action listing = False viewing = False editing = False + deleting = False configuring = False ############################## @@ -277,6 +285,11 @@ class MasterView(View): actions.append(self.make_grid_action('edit', icon='edit', url=self.get_action_url_edit)) + if self.deletable: + actions.append(self.make_grid_action('delete', icon='trash', + url=self.get_action_url_delete, + link_class='has-text-danger')) + kwargs['actions'] = actions grid = self.make_grid(**kwargs) @@ -389,12 +402,11 @@ class MasterView(View): instance_title = self.get_instance_title(instance) form = self.make_model_form(instance, - cancel_url_fallback=self.get_index_url()) + cancel_url_fallback=self.get_action_url('view', instance)) - if self.request.method == 'POST': - if form.validate(): - self.edit_save_form(form) - return self.redirect(self.get_action_url('view', instance)) + if form.validate(): + self.edit_save_form(form) + return self.redirect(self.get_action_url('view', instance)) context = { 'instance': instance, @@ -422,6 +434,83 @@ class MasterView(View): self.persist(obj) return obj + ############################## + # delete methods + ############################## + + def delete(self): + """ + View to delete an existing model instance. + + This usually corresponds to a URL like ``/widgets/XXX/delete`` + where ``XXX`` represents the key/ID for the record. + + By default, this view is included only if :attr:`deletable` is + true. + + The default "delete" view logic will show a "psuedo-readonly" + form with no fields editable, but with a submit button so user + must confirm, before deletion actually occurs. + + Subclass normally should not override this method, but rather + one of the related methods which are called (in)directly by + this one: + + * :meth:`make_model_form()` + * :meth:`configure_form()` + * :meth:`delete_save_form()` + * :meth:`delete_instance()` + """ + self.deleting = True + instance = self.get_instance() + instance_title = self.get_instance_title(instance) + + # nb. this form proper is not readonly.. + form = self.make_model_form(instance, + cancel_url_fallback=self.get_action_url('view', instance), + button_label_submit="DELETE Forever", + button_icon_submit='trash', + button_type_submit='is-danger') + # ..but *all* fields are readonly + form.readonly_fields = set(form.fields) + + # nb. validate() often returns empty dict here + if form.validate() is not False: + self.delete_save_form(form) + return self.redirect(self.get_index_url()) + + context = { + 'instance': instance, + 'instance_title': instance_title, + 'form': form, + } + return self.render_to_response('delete', context) + + def delete_save_form(self, form): + """ + Perform the delete operation(s) based on the given form data. + + Default logic simply calls :meth:`delete_instance()` on the + form's :attr:`~wuttaweb.forms.base.Form.model_instance`. + + This method is called by :meth:`delete()` after it has + validated the form. + """ + obj = form.model_instance + self.delete_instance(obj) + + def delete_instance(self, obj): + """ + Delete the given model instance. + + As of yet there is no default logic for this method; it will + raise ``NotImplementedError``. Subclass should override if + needed. + + This method is called by :meth:`delete_save_form()`. + """ + raise NotImplementedError + ############################## # configure methods ############################## @@ -748,6 +837,7 @@ class MasterView(View): 'route_prefix': self.get_route_prefix(), 'index_title': self.get_index_title(), 'index_url': self.get_index_url(), + 'model_title': self.get_model_title(), 'config_title': self.get_config_title(), } @@ -894,6 +984,17 @@ class MasterView(View): """ return self.get_action_url('edit', obj) + def get_action_url_delete(self, obj, i): + """ + Returns the "delete" grid action URL for the given object. + + Most typically this is like ``/widgets/XXX/delete`` where + ``XXX`` represents the object's key/ID. + + Calls :meth:`get_action_url()` under the hood. + """ + return self.get_action_url('delete', obj) + def make_model_form(self, model_instance=None, **kwargs): """ Create and return a :class:`~wuttaweb.forms.base.Form` @@ -1309,6 +1410,14 @@ class MasterView(View): config.add_view(cls, attr='edit', route_name=f'{route_prefix}.edit') + # delete + if cls.deletable: + instance_url_prefix = cls.get_instance_url_prefix() + config.add_route(f'{route_prefix}.delete', + f'{instance_url_prefix}/delete') + config.add_view(cls, attr='delete', + route_name=f'{route_prefix}.delete') + # configure if cls.configurable: config.add_route(f'{route_prefix}.configure', diff --git a/src/wuttaweb/views/settings.py b/src/wuttaweb/views/settings.py index b5f51ff..2da0951 100644 --- a/src/wuttaweb/views/settings.py +++ b/src/wuttaweb/views/settings.py @@ -197,36 +197,19 @@ class SettingView(MasterView): """ """ return setting['name'] - def make_model_form(self, *args, **kwargs): - """ """ - # TODO: sheesh what a hack. hopefully not needed for long.. - # here we ensure deform is created before introducing our - # `name` field, to keep it out of submit handling - - if self.editing: - kwargs['fields'] = ['value'] - - form = super().make_model_form(*args, **kwargs) - - if self.editing: - form.get_deform() - form.fields.insert_before('value', 'name') - - return form - - def configure_form(self, f): - """ """ - super().configure_form(f) - - if self.editing: - f.set_readonly('name') - + # TODO: master should handle this def persist(self, setting, session=None): """ """ name = self.get_instance(session=session)['name'] session = session or Session() self.app.save_setting(session, name, setting['value']) + # TODO: master should handle this + def delete_instance(self, obj, session=None): + """ """ + session = session or Session() + self.app.delete_setting(session, obj['name']) + def defaults(config, **kwargs): base = globals() diff --git a/tests/forms/test_base.py b/tests/forms/test_base.py index a313937..cd0998f 100644 --- a/tests/forms/test_base.py +++ b/tests/forms/test_base.py @@ -347,7 +347,7 @@ class TestForm(TestCase): data = form.validate() self.assertEqual(data, {'foo': 'blarg', 'bar': 'baz'}) - # validating a second type updates form.validated + # validating a second time updates form.validated self.request.POST = {'foo': 'BLARG', 'bar': 'BAZ'} data = form.validate() self.assertEqual(data, {'foo': 'BLARG', 'bar': 'BAZ'}) @@ -359,3 +359,17 @@ class TestForm(TestCase): dform = form.get_deform() self.assertEqual(len(dform.error.children), 2) self.assertEqual(dform['foo'].errormsg, "Pstruct is not a string") + + # when a form has readonly fields, validating it will *remove* + # those fields from deform/schema as well as final data dict + schema = self.make_schema() + form = self.make_form(schema=schema) + form.set_readonly('foo') + self.request.POST = {'foo': 'one', 'bar': 'two'} + data = form.validate() + self.assertEqual(data, {'bar': 'two'}) + dform = form.get_deform() + self.assertNotIn('foo', schema) + self.assertNotIn('foo', dform) + self.assertIn('bar', schema) + self.assertIn('bar', dform) diff --git a/tests/grids/test_base.py b/tests/grids/test_base.py index 732c332..7d32c33 100644 --- a/tests/grids/test_base.py +++ b/tests/grids/test_base.py @@ -1,6 +1,7 @@ # -*- coding: utf-8; -*- from unittest import TestCase +from unittest.mock import patch from pyramid import testing @@ -151,6 +152,14 @@ class TestGridAction(TestCase): label = action.render_label() self.assertEqual(label, "Bar") + def test_render_icon_and_label(self): + action = self.make_action('blarg') + with patch.multiple(action, + render_icon=lambda: 'ICON', + render_label=lambda: 'LABEL'): + html = action.render_icon_and_label() + self.assertEqual('ICON LABEL', html) + def test_get_url(self): obj = {'foo': 'bar'} diff --git a/tests/views/test_master.py b/tests/views/test_master.py index 22afa28..10724a4 100644 --- a/tests/views/test_master.py +++ b/tests/views/test_master.py @@ -21,7 +21,8 @@ class TestMasterView(WebTestCase): with patch.multiple(master.MasterView, create=True, model_name='Widget', viewable=False, - editable=False): + editable=False, + deletable=False): master.MasterView.defaults(self.pyramid_config) ############################## @@ -415,7 +416,7 @@ class TestMasterView(WebTestCase): self.assertNotIn('Required', response.text) def persist(setting): - self.app.save_setting(self.session, setting['name'], setting['value']) + self.app.save_setting(self.session, 'foo.bar', setting['value']) self.session.commit() # post request to save settings @@ -427,15 +428,13 @@ class TestMasterView(WebTestCase): with patch.object(view, 'persist', new=persist): response = view.edit() # nb. should get redirect back to view page - self.assertIsInstance(response, HTTPFound) + self.assertEqual(response.status_code, 302) # setting should be updated in DB self.assertEqual(self.app.get_setting(self.session, 'foo.bar'), 'froogle') - # try another post with invalid data (name is required) + # try another post with invalid data (value is required) self.request.method = 'POST' - self.request.POST = { - 'value': 'gargoyle', - } + self.request.POST = {} with patch.object(view, 'persist', new=persist): response = view.edit() # nb. should get a form with errors @@ -444,6 +443,60 @@ class TestMasterView(WebTestCase): # setting did not change in DB self.assertEqual(self.app.get_setting(self.session, 'foo.bar'), 'froogle') + def test_delete(self): + model = self.app.model + self.app.save_setting(self.session, 'foo.bar', 'frazzle') + self.session.commit() + self.assertEqual(self.session.query(model.Setting).count(), 1) + + def get_instance(): + setting = self.session.query(model.Setting).get('foo.bar') + return { + 'name': setting.name, + 'value': setting.value, + } + + # sanity/coverage check using /settings/XXX/delete + self.request.matchdict = {'name': 'foo.bar'} + with patch.multiple(master.MasterView, create=True, + model_name='Setting', + model_key='name', + form_fields=['name', 'value']): + view = master.MasterView(self.request) + with patch.object(view, 'get_instance', new=get_instance): + + # get the form page + response = view.delete() + self.assertIsInstance(response, Response) + self.assertEqual(response.status_code, 200) + self.assertIn('frazzle', response.text) + + def delete_instance(setting): + print(setting) # TODO + self.app.delete_setting(self.session, setting['name']) + + # post request to save settings + self.request.method = 'POST' + self.request.POST = {} + with patch.object(view, 'delete_instance', new=delete_instance): + response = view.delete() + # nb. should get redirect back to view page + self.assertEqual(response.status_code, 302) + # setting should be gone from DB + self.assertEqual(self.session.query(model.Setting).count(), 0) + + def test_delete_instance(self): + model = self.app.model + self.app.save_setting(self.session, 'foo.bar', 'frazzle') + self.session.commit() + setting = self.session.query(model.Setting).one() + + with patch.multiple(master.MasterView, create=True, + model_class=model.Setting, + form_fields=['name', 'value']): + view = master.MasterView(self.request) + self.assertRaises(NotImplementedError, view.delete_instance, setting) + def test_configure(self): model = self.app.model diff --git a/tests/views/test_settings.py b/tests/views/test_settings.py index 2fab0ff..4712664 100644 --- a/tests/views/test_settings.py +++ b/tests/views/test_settings.py @@ -66,17 +66,6 @@ class TestSettingView(WebTestCase): title = view.get_instance_title(setting) self.assertEqual(title, 'foo') - def test_make_model_form(self): - view = self.make_view() - view.editing = True - form = view.make_model_form() - self.assertEqual(form.fields, ['name', 'value']) - self.assertIn('name', form) - self.assertIn('value', form) - dform = form.get_deform() - self.assertNotIn('name', dform) - self.assertIn('value', dform) - def test_persist(self): model = self.app.model view = self.make_view() @@ -92,3 +81,18 @@ class TestSettingView(WebTestCase): self.session.commit() self.assertEqual(self.session.query(model.Setting).count(), 1) self.assertEqual(self.app.get_setting(self.session, 'foo'), 'frazzle') + + def test_delete_instance(self): + model = self.app.model + view = self.make_view() + + # setup + self.app.save_setting(self.session, 'foo', 'bar') + self.session.commit() + self.assertEqual(self.session.query(model.Setting).count(), 1) + + # setting is deleted + self.request.matchdict = {'name': 'foo'} + view.delete_instance({'name': 'foo', 'value': 'frazzle'}, session=self.session) + self.session.commit() + self.assertEqual(self.session.query(model.Setting).count(), 0)