1
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
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
<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
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:

View file

@ -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 ``<a>`` 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
<i class="fas fa-trash"></i>
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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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