2
0
Fork 0

feat: add basic Delete support for CRUD master view

This commit is contained in:
Lance Edgar 2024-08-11 09:56:47 -05:00
parent 1a8fc8dd44
commit c46b42f76d
12 changed files with 331 additions and 72 deletions

View file

@ -118,9 +118,9 @@ class Form:
.. attribute:: schema .. attribute:: schema
Colander-based schema object for the form. This is optional; :class:`colander:colander.Schema` object for the form. This is
if not specified an attempt will be made to construct one optional; if not specified an attempt will be made to construct
automatically. one automatically.
See also :meth:`get_schema()`. See also :meth:`get_schema()`.
@ -151,12 +151,13 @@ class Form:
.. attribute:: readonly_fields .. attribute:: readonly_fields
Set of fields which should be readonly. Each will still be A :class:`~python:set` of field names which should be readonly.
rendered but with static value text and no widget. Each will still be rendered but with static value text and no
widget.
This is only applicable if :attr:`readonly` is ``False``. 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 .. attribute:: action_url
@ -202,6 +203,21 @@ class Form:
String icon name for the form submit button. Default is ``'save'``. 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
<b-button type="is-primary"
native-type="submit">
Save
</b-button>
See also the `Buefy docs
<https://buefy.org/documentation/button/#api-view>`_.
.. attribute:: show_button_reset .. attribute:: show_button_reset
Flag indicating whether a Reset button should be shown. Flag indicating whether a Reset button should be shown.
@ -249,6 +265,7 @@ class Form:
auto_disable_submit=True, auto_disable_submit=True,
button_label_submit="Save", button_label_submit="Save",
button_icon_submit='save', button_icon_submit='save',
button_type_submit='is-primary',
show_button_reset=False, show_button_reset=False,
show_button_cancel=True, show_button_cancel=True,
button_label_cancel="Cancel", button_label_cancel="Cancel",
@ -267,6 +284,7 @@ class Form:
self.auto_disable_submit = auto_disable_submit self.auto_disable_submit = auto_disable_submit
self.button_label_submit = button_label_submit self.button_label_submit = button_label_submit
self.button_icon_submit = button_icon_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_reset = show_button_reset
self.show_button_cancel = show_button_cancel self.show_button_cancel = show_button_cancel
self.button_label_cancel = button_label_cancel self.button_label_cancel = button_label_cancel
@ -650,14 +668,14 @@ class Form:
def validate(self): 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 Uses :func:`~wuttaweb.util.get_form_data()` to retrieve the
POST data, or as JSON body. form data from POST or JSON body.
If the form data is valid, this method returns the data dict. If the form data is valid, the data dict is returned. This
This data dict is also then available on the form object via data dict is also made available on the form object via the
the :attr:`validated` attribute. :attr:`validated` attribute.
However if the data is not valid, ``False`` is returned, and However if the data is not valid, ``False`` is returned, and
there will be no :attr:`validated` attribute. In that case there will be no :attr:`validated` attribute. In that case
@ -665,6 +683,17 @@ class Form:
wrong for the user's sake. See also wrong for the user's sake. See also
:meth:`get_field_errors()`. :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``. :returns: Data dict, or ``False``.
""" """
if hasattr(self, 'validated'): if hasattr(self, 'validated'):
@ -673,9 +702,16 @@ class Form:
if self.request.method != 'POST': if self.request.method != 'POST':
return False return False
# remove all readonly fields from deform / schema
dform = self.get_deform() 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: try:
self.validated = dform.validate(controls) self.validated = dform.validate(controls)
except deform.ValidationFailure: except deform.ValidationFailure:

View file

@ -331,6 +331,10 @@ class GridAction:
Name of icon to be shown for the action link. Name of icon to be shown for the action link.
See also :meth:`render_icon()`. See also :meth:`render_icon()`.
.. attribute:: link_class
Optional HTML class attribute for the action's ``<a>`` tag.
""" """
def __init__( def __init__(
@ -340,6 +344,7 @@ class GridAction:
label=None, label=None,
url=None, url=None,
icon=None, icon=None,
link_class=None,
): ):
self.request = request self.request = request
self.config = self.request.wutta_config self.config = self.request.wutta_config
@ -348,6 +353,16 @@ class GridAction:
self.url = url self.url = url
self.label = label or self.app.make_title(key) self.label = label or self.app.make_title(key)
self.icon = icon or 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): def render_icon(self):
""" """
@ -359,6 +374,8 @@ class GridAction:
.. code-block:: html .. code-block:: html
<i class="fas fa-trash"></i> <i class="fas fa-trash"></i>
See also :meth:`render_icon_and_label()`.
""" """
if self.request.use_oruga: if self.request.use_oruga:
raise NotImplementedError raise NotImplementedError
@ -370,6 +387,8 @@ class GridAction:
Render the label text for the action link. Render the label text for the action link.
Default behavior is to return :attr:`label` as-is. Default behavior is to return :attr:`label` as-is.
See also :meth:`render_icon_and_label()`.
""" """
return self.label return self.label

View file

@ -231,13 +231,10 @@
## TODO ## TODO
% if master and master.configurable and not master.configuring: % if master and master.configurable and not master.configuring:
<div class="level-item"> <div class="level-item">
<b-button type="is-primary" <wutta-button once type="is-primary"
tag="a" tag="a" href="${url(f'{route_prefix}.configure')}"
href="${url(f'{route_prefix}.configure')}" icon-left="cog"
icon-pack="fas" label="Configure" />
icon-left="cog">
Configure
</b-button>
</div> </div>
% endif % endif
@ -374,11 +371,28 @@
tag="a" href="${master.get_action_url('edit', instance)}" tag="a" href="${master.get_action_url('edit', instance)}"
icon-left="edit" icon-left="edit"
label="Edit This" /> label="Edit This" />
<wutta-button once type="is-danger"
tag="a" href="${master.get_action_url('delete', instance)}"
icon-left="trash"
label="Delete This" />
% elif master.editing: % elif master.editing:
<wutta-button once <wutta-button once
tag="a" href="${master.get_action_url('view', instance)}" tag="a" href="${master.get_action_url('view', instance)}"
icon-left="eye" icon-left="eye"
label="View This" /> label="View This" />
<wutta-button once type="is-danger"
tag="a" href="${master.get_action_url('delete', instance)}"
icon-left="trash"
label="Delete This" />
% elif master.deleting:
<wutta-button once
tag="a" href="${master.get_action_url('view', instance)}"
icon-left="eye"
label="View This" />
<wutta-button once
tag="a" href="${master.get_action_url('edit', instance)}"
icon-left="edit"
label="Edit This" />
% endif % endif
% endif % endif
</%def> </%def>

View file

@ -25,7 +25,7 @@
</b-button> </b-button>
% endif % endif
<b-button type="is-primary" <b-button type="${form.button_type_submit}"
native-type="submit" native-type="submit"
% if form.auto_disable_submit: % if form.auto_disable_submit:
:disabled="formSubmitting" :disabled="formSubmitting"

View file

@ -24,9 +24,9 @@
label="Actions" label="Actions"
v-slot="props"> v-slot="props">
% for action in grid.actions: % for action in grid.actions:
<a :href="props.row._action_url_${action.key}"> <a :href="props.row._action_url_${action.key}"
${action.render_icon()} class="${action.link_class}">
${action.render_label()} ${action.render_icon_and_label()}
</a> </a>
&nbsp; &nbsp;
% endfor % endfor

View file

@ -0,0 +1,18 @@
## -*- coding: utf-8; -*-
<%inherit file="/master/form.mako" />
<%def name="title()">${index_title} &raquo; ${instance_title} &raquo; Delete</%def>
<%def name="content_title()">Delete: ${instance_title}</%def>
<%def name="page_content()">
<br />
<b-notification type="is-danger" :closable="false"
style="width: 50%;">
Really DELETE this ${model_title}?
</b-notification>
${parent.page_content()}
</%def>
${parent.body()}

View file

@ -180,6 +180,12 @@ class MasterView(View):
i.e. it should have an :meth:`edit()` view. Default value is i.e. it should have an :meth:`edit()` view. Default value is
``True``. ``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 .. attribute:: form_fields
List of columns for the model form. List of columns for the model form.
@ -202,12 +208,14 @@ class MasterView(View):
has_grid = True has_grid = True
viewable = True viewable = True
editable = True editable = True
deletable = True
configurable = False configurable = False
# current action # current action
listing = False listing = False
viewing = False viewing = False
editing = False editing = False
deleting = False
configuring = False configuring = False
############################## ##############################
@ -277,6 +285,11 @@ class MasterView(View):
actions.append(self.make_grid_action('edit', icon='edit', actions.append(self.make_grid_action('edit', icon='edit',
url=self.get_action_url_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 kwargs['actions'] = actions
grid = self.make_grid(**kwargs) grid = self.make_grid(**kwargs)
@ -389,12 +402,11 @@ class MasterView(View):
instance_title = self.get_instance_title(instance) instance_title = self.get_instance_title(instance)
form = self.make_model_form(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():
if form.validate(): self.edit_save_form(form)
self.edit_save_form(form) return self.redirect(self.get_action_url('view', instance))
return self.redirect(self.get_action_url('view', instance))
context = { context = {
'instance': instance, 'instance': instance,
@ -422,6 +434,83 @@ class MasterView(View):
self.persist(obj) self.persist(obj)
return 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 # configure methods
############################## ##############################
@ -748,6 +837,7 @@ class MasterView(View):
'route_prefix': self.get_route_prefix(), 'route_prefix': self.get_route_prefix(),
'index_title': self.get_index_title(), 'index_title': self.get_index_title(),
'index_url': self.get_index_url(), 'index_url': self.get_index_url(),
'model_title': self.get_model_title(),
'config_title': self.get_config_title(), 'config_title': self.get_config_title(),
} }
@ -894,6 +984,17 @@ class MasterView(View):
""" """
return self.get_action_url('edit', obj) 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): def make_model_form(self, model_instance=None, **kwargs):
""" """
Create and return a :class:`~wuttaweb.forms.base.Form` Create and return a :class:`~wuttaweb.forms.base.Form`
@ -1309,6 +1410,14 @@ class MasterView(View):
config.add_view(cls, attr='edit', config.add_view(cls, attr='edit',
route_name=f'{route_prefix}.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 # configure
if cls.configurable: if cls.configurable:
config.add_route(f'{route_prefix}.configure', config.add_route(f'{route_prefix}.configure',

View file

@ -197,36 +197,19 @@ class SettingView(MasterView):
""" """ """ """
return setting['name'] return setting['name']
def make_model_form(self, *args, **kwargs): # TODO: master should handle this
""" """
# 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')
def persist(self, setting, session=None): def persist(self, setting, session=None):
""" """ """ """
name = self.get_instance(session=session)['name'] name = self.get_instance(session=session)['name']
session = session or Session() session = session or Session()
self.app.save_setting(session, name, setting['value']) 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): def defaults(config, **kwargs):
base = globals() base = globals()

View file

@ -347,7 +347,7 @@ class TestForm(TestCase):
data = form.validate() data = form.validate()
self.assertEqual(data, {'foo': 'blarg', 'bar': 'baz'}) 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'} self.request.POST = {'foo': 'BLARG', 'bar': 'BAZ'}
data = form.validate() data = form.validate()
self.assertEqual(data, {'foo': 'BLARG', 'bar': 'BAZ'}) self.assertEqual(data, {'foo': 'BLARG', 'bar': 'BAZ'})
@ -359,3 +359,17 @@ class TestForm(TestCase):
dform = form.get_deform() dform = form.get_deform()
self.assertEqual(len(dform.error.children), 2) self.assertEqual(len(dform.error.children), 2)
self.assertEqual(dform['foo'].errormsg, "Pstruct is not a string") 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)

View file

@ -1,6 +1,7 @@
# -*- coding: utf-8; -*- # -*- coding: utf-8; -*-
from unittest import TestCase from unittest import TestCase
from unittest.mock import patch
from pyramid import testing from pyramid import testing
@ -151,6 +152,14 @@ class TestGridAction(TestCase):
label = action.render_label() label = action.render_label()
self.assertEqual(label, "Bar") 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): def test_get_url(self):
obj = {'foo': 'bar'} obj = {'foo': 'bar'}

View file

@ -21,7 +21,8 @@ class TestMasterView(WebTestCase):
with patch.multiple(master.MasterView, create=True, with patch.multiple(master.MasterView, create=True,
model_name='Widget', model_name='Widget',
viewable=False, viewable=False,
editable=False): editable=False,
deletable=False):
master.MasterView.defaults(self.pyramid_config) master.MasterView.defaults(self.pyramid_config)
############################## ##############################
@ -415,7 +416,7 @@ class TestMasterView(WebTestCase):
self.assertNotIn('Required', response.text) self.assertNotIn('Required', response.text)
def persist(setting): 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() self.session.commit()
# post request to save settings # post request to save settings
@ -427,15 +428,13 @@ class TestMasterView(WebTestCase):
with patch.object(view, 'persist', new=persist): with patch.object(view, 'persist', new=persist):
response = view.edit() response = view.edit()
# nb. should get redirect back to view page # nb. should get redirect back to view page
self.assertIsInstance(response, HTTPFound) self.assertEqual(response.status_code, 302)
# setting should be updated in DB # setting should be updated in DB
self.assertEqual(self.app.get_setting(self.session, 'foo.bar'), 'froogle') 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.method = 'POST'
self.request.POST = { self.request.POST = {}
'value': 'gargoyle',
}
with patch.object(view, 'persist', new=persist): with patch.object(view, 'persist', new=persist):
response = view.edit() response = view.edit()
# nb. should get a form with errors # nb. should get a form with errors
@ -444,6 +443,60 @@ class TestMasterView(WebTestCase):
# setting did not change in DB # setting did not change in DB
self.assertEqual(self.app.get_setting(self.session, 'foo.bar'), 'froogle') 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): def test_configure(self):
model = self.app.model model = self.app.model

View file

@ -66,17 +66,6 @@ class TestSettingView(WebTestCase):
title = view.get_instance_title(setting) title = view.get_instance_title(setting)
self.assertEqual(title, 'foo') 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): def test_persist(self):
model = self.app.model model = self.app.model
view = self.make_view() view = self.make_view()
@ -92,3 +81,18 @@ class TestSettingView(WebTestCase):
self.session.commit() self.session.commit()
self.assertEqual(self.session.query(model.Setting).count(), 1) self.assertEqual(self.session.query(model.Setting).count(), 1)
self.assertEqual(self.app.get_setting(self.session, 'foo'), 'frazzle') 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)