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
%def>
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>
+
+<%def name="content_title()">Delete: ${instance_title}%def>
+
+<%def name="page_content()">
+
+
+ Really DELETE this ${model_title}?
+
+ ${parent.page_content()}
+%def>
+
+
+${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)