From 1a8fc8dd44afcdc6049cf54beead96322f997c0b Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 10 Aug 2024 21:07:38 -0500 Subject: [PATCH] feat: add basic Edit support for CRUD master view --- src/wuttaweb/forms/base.py | 156 ++++++++++++- src/wuttaweb/subscribers.py | 2 +- src/wuttaweb/templates/base.mako | 18 +- .../templates/forms/vue_template.mako | 10 +- src/wuttaweb/templates/master/edit.mako | 9 + src/wuttaweb/templates/wutta-components.mako | 71 ++++++ src/wuttaweb/views/auth.py | 5 +- src/wuttaweb/views/master.py | 206 ++++++++++++++++-- src/wuttaweb/views/settings.py | 30 +++ tests/forms/test_base.py | 73 ++++++- tests/views/test_master.py | 84 +++++-- tests/views/test_settings.py | 27 +++ 12 files changed, 640 insertions(+), 51 deletions(-) create mode 100644 src/wuttaweb/templates/master/edit.mako create mode 100644 src/wuttaweb/templates/wutta-components.mako diff --git a/src/wuttaweb/forms/base.py b/src/wuttaweb/forms/base.py index 89664e3..a47f6bd 100644 --- a/src/wuttaweb/forms/base.py +++ b/src/wuttaweb/forms/base.py @@ -149,10 +149,35 @@ class Form: Default for this is ``False`` in which case the ``
`` tag will exist and submit is allowed. + .. attribute:: readonly_fields + + Set of fields 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()`. + .. attribute:: action_url String URL to which the form should be submitted, if applicable. + .. attribute:: cancel_url + + String URL to which the Cancel button should "always" redirect, + if applicable. + + Code should not access this directly, but instead call + :meth:`get_cancel_url()`. + + .. attribute:: cancel_url_fallback + + String URL to which the Cancel button should redirect, if + referrer cannot be determined from request. + + Code should not access this directly, but instead call + :meth:`get_cancel_url()`. + .. attribute:: vue_tagname String name for Vue component tag. By default this is @@ -180,6 +205,23 @@ class Form: .. attribute:: show_button_reset Flag indicating whether a Reset button should be shown. + Default is ``False``. + + .. attribute:: show_button_cancel + + Flag indicating whether a Cancel button should be shown. + Default is ``True``. + + .. attribute:: button_label_cancel + + String label for the form cancel button. Default is + ``"Cancel"``. + + .. attribute:: auto_disable_cancel + + Flag indicating whether the cancel button should be + auto-disabled, whenever the button is clicked. Default is + ``True``. .. attribute:: validated @@ -197,26 +239,38 @@ class Form: model_class=None, model_instance=None, readonly=False, + readonly_fields=[], labels={}, action_url=None, + cancel_url=None, + cancel_url_fallback=None, vue_tagname='wutta-form', align_buttons_right=False, auto_disable_submit=True, button_label_submit="Save", button_icon_submit='save', show_button_reset=False, + show_button_cancel=True, + button_label_cancel="Cancel", + auto_disable_cancel=True, ): self.request = request self.schema = schema self.readonly = readonly + self.readonly_fields = set(readonly_fields or []) self.labels = labels or {} self.action_url = action_url + self.cancel_url = cancel_url + self.cancel_url_fallback = cancel_url_fallback self.vue_tagname = vue_tagname self.align_buttons_right = align_buttons_right self.auto_disable_submit = auto_disable_submit self.button_label_submit = button_label_submit self.button_icon_submit = button_icon_submit self.show_button_reset = show_button_reset + self.show_button_cancel = show_button_cancel + self.button_label_cancel = button_label_cancel + self.auto_disable_cancel = auto_disable_cancel self.config = self.request.wutta_config self.app = self.config.get_app() @@ -262,6 +316,39 @@ class Form: words = self.vue_tagname.split('-') return ''.join([word.capitalize() for word in words]) + def get_cancel_url(self): + """ + Returns the URL for the Cancel button. + + If :attr:`cancel_url` is set, its value is returned. + + Or, if the referrer can be deduced from the request, that is + returned. + + Or, if :attr:`cancel_url_fallback` is set, that value is + returned. + + As a last resort the "default" URL from + :func:`~wuttaweb.subscribers.request.get_referrer()` is + returned. + """ + # use "permanent" URL if set + if self.cancel_url: + return self.cancel_url + + # nb. use fake default to avoid normal default logic; + # that way if we get something it's a real referrer + url = self.request.get_referrer(default='NOPE') + if url and url != 'NOPE': + return url + + # use fallback URL if set + if self.cancel_url_fallback: + return self.cancel_url_fallback + + # okay, home page then (or whatever is the default URL) + return self.request.get_referrer() + def set_fields(self, fields): """ Explicitly set the list of form fields. @@ -273,6 +360,41 @@ class Form: """ self.fields = FieldList(fields) + def set_readonly(self, key, readonly=True): + """ + Enable or disable the "readonly" flag for a given field. + + When a field is marked readonly, it will be shown in the form + but there will be no editable widget. The field is skipped + over (not saved) when form is submitted. + + See also :meth:`is_readonly()`; this is tracked via + :attr:`readonly_fields`. + + :param key: String key (fieldname) for the field. + + :param readonly: New readonly flag for the field. + """ + if readonly: + self.readonly_fields.add(key) + else: + if key in self.readonly_fields: + self.readonly_fields.remove(key) + + def is_readonly(self, key): + """ + Returns boolean indicating if the given field is marked as + readonly. + + See also :meth:`set_readonly()`. + + :param key: Field key/name as string. + """ + if self.readonly_fields: + if key in self.readonly_fields: + return True + return False + def set_label(self, key, label): """ Set the label for given field name. @@ -381,6 +503,7 @@ class Form: the output. """ context['form'] = self + context['dform'] = self.get_deform() context.setdefault('form_attrs', {}) context.setdefault('request', self.request) @@ -408,17 +531,34 @@ class Form: """ - + # readonly comes from: caller, field flag, or form flag if readonly is None: - readonly = self.readonly + readonly = self.is_readonly(fieldname) + if not readonly: + readonly = self.readonly + + # but also, fields not in deform/schema must be readonly + dform = self.get_deform() + if not readonly and fieldname not in dform: + readonly = True # render the field widget or whatever - dform = self.get_deform() - field = dform[fieldname] - kw = {} - if readonly: - kw['readonly'] = True - html = field.serialize(**kw) + if fieldname in dform: + + # render proper widget if field is in deform/schema + field = dform[fieldname] + kw = {} + if readonly: + kw['readonly'] = True + html = field.serialize(**kw) + + else: + # render static text if field not in deform/schema + # TODO: need to abstract this somehow + if self.model_instance: + html = str(self.model_instance[fieldname]) + else: + html = '' # mark all that as safe html = HTML.literal(html) diff --git a/src/wuttaweb/subscribers.py b/src/wuttaweb/subscribers.py index 92d40d9..2202059 100644 --- a/src/wuttaweb/subscribers.py +++ b/src/wuttaweb/subscribers.py @@ -64,7 +64,7 @@ def new_request(event): Reference to the app :term:`config object`. - .. method:: request.get_referrer(default=None) + .. function:: request.get_referrer(default=None) Request method to get the "canonical" HTTP referrer value. This has logic to check for referrer in the request params, diff --git a/src/wuttaweb/templates/base.mako b/src/wuttaweb/templates/base.mako index 6b5dfd9..7f1b3b0 100644 --- a/src/wuttaweb/templates/base.mako +++ b/src/wuttaweb/templates/base.mako @@ -1,5 +1,6 @@ ## -*- coding: utf-8; -*- <%namespace name="base_meta" file="/base_meta.mako" /> +<%namespace file="/wutta-components.mako" import="make_wutta_components" /> @@ -366,7 +367,21 @@ ${self.render_prevnext_header_buttons()} -<%def name="render_crud_header_buttons()"> +<%def name="render_crud_header_buttons()"> + % if master: + % if master.viewing: + + % elif master.editing: + + % endif + % endif + <%def name="render_prevnext_header_buttons()"> @@ -432,6 +447,7 @@ <%def name="finalize_whole_page_vars()"> <%def name="make_whole_page_component()"> + ${make_wutta_components()} ${self.render_whole_page_template()} ${self.declare_whole_page_vars()} ${self.modify_whole_page_vars()} diff --git a/src/wuttaweb/templates/forms/vue_template.mako b/src/wuttaweb/templates/forms/vue_template.mako index a4e21ca..dd7bfa1 100644 --- a/src/wuttaweb/templates/forms/vue_template.mako +++ b/src/wuttaweb/templates/forms/vue_template.mako @@ -13,6 +13,12 @@ % if not form.readonly:
+ % if form.show_button_cancel: + + % endif + % if form.show_button_reset: Reset @@ -50,7 +56,9 @@ ## field model values % for key in form: - model_${key}: ${form.get_vue_field_value(key)|n}, + % if key in dform: + model_${key}: ${form.get_vue_field_value(key)|n}, + % endif % endfor % if form.auto_disable_submit: diff --git a/src/wuttaweb/templates/master/edit.mako b/src/wuttaweb/templates/master/edit.mako new file mode 100644 index 0000000..8e21fa7 --- /dev/null +++ b/src/wuttaweb/templates/master/edit.mako @@ -0,0 +1,9 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/form.mako" /> + +<%def name="title()">${index_title} » ${instance_title} » Edit + +<%def name="content_title()">Edit: ${instance_title} + + +${parent.body()} diff --git a/src/wuttaweb/templates/wutta-components.mako b/src/wuttaweb/templates/wutta-components.mako new file mode 100644 index 0000000..6619eed --- /dev/null +++ b/src/wuttaweb/templates/wutta-components.mako @@ -0,0 +1,71 @@ + +<%def name="make_wutta_components()"> + ${self.make_wutta_button_component()} + + +<%def name="make_wutta_button_component()"> + + + diff --git a/src/wuttaweb/views/auth.py b/src/wuttaweb/views/auth.py index 389271b..894752a 100644 --- a/src/wuttaweb/views/auth.py +++ b/src/wuttaweb/views/auth.py @@ -59,13 +59,11 @@ class AuthView(View): form = self.make_form(schema=self.login_make_schema(), align_buttons_right=True, + show_button_cancel=False, show_button_reset=True, button_label_submit="Login", button_icon_submit='user') - # TODO - # form.show_cancel = False - # validate basic form data (sanity check) data = form.validate() if data: @@ -155,6 +153,7 @@ class AuthView(View): return self.redirect(self.request.route_url('home')) form = self.make_form(schema=self.change_password_make_schema(), + show_button_cancel=False, show_button_reset=True) data = form.validate() diff --git a/src/wuttaweb/views/master.py b/src/wuttaweb/views/master.py index 644048b..a2a97fc 100644 --- a/src/wuttaweb/views/master.py +++ b/src/wuttaweb/views/master.py @@ -174,6 +174,12 @@ class MasterView(View): i.e. it should have a :meth:`view()` view. Default value is ``True``. + .. attribute:: editable + + Boolean indicating whether the view model supports "editing" - + i.e. it should have an :meth:`edit()` view. Default value is + ``True``. + .. attribute:: form_fields List of columns for the model form. @@ -195,11 +201,13 @@ class MasterView(View): listable = True has_grid = True viewable = True + editable = True configurable = False # current action listing = False viewing = False + editing = False configuring = False ############################## @@ -260,10 +268,15 @@ class MasterView(View): actions = [] # TODO: should split this off into index_get_grid_actions() ? + if self.viewable: actions.append(self.make_grid_action('view', icon='eye', url=self.get_action_url_view)) + if self.editable: + actions.append(self.make_grid_action('edit', icon='edit', + url=self.get_action_url_edit)) + kwargs['actions'] = actions grid = self.make_grid(**kwargs) @@ -309,21 +322,6 @@ class MasterView(View): """ return [] - def get_action_url_view(self, obj, i): - """ - Returns the "view" grid action URL for the given object. - - Most typically this is like ``/widgets/XXX`` where ``XXX`` - represents the object's key/ID. - """ - route_prefix = self.get_route_prefix() - - kw = {} - for key in self.get_model_key(): - kw[key] = obj[key] - - return self.request.route_url(f'{route_prefix}.view', **kw) - ############################## # view methods ############################## @@ -341,9 +339,12 @@ class MasterView(View): The default view logic will show a read-only form with field values displayed. - See also related methods, which are called by this one: + 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()` """ self.viewing = True instance = self.get_instance() @@ -356,6 +357,71 @@ class MasterView(View): } return self.render_to_response('view', context) + ############################## + # edit methods + ############################## + + def edit(self): + """ + View to "edit" details of an existing model record. + + This usually corresponds to a URL like ``/widgets/XXX/edit`` + where ``XXX`` represents the key/ID for the record. + + By default, this view is included only if :attr:`editable` is + true. + + The default "edit" view logic will show a form with field + widgets, allowing user to modify and submit new values which + are then persisted to the DB (assuming typical SQLAlchemy + model). + + 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:`edit_save_form()` + """ + self.editing = True + instance = self.get_instance() + instance_title = self.get_instance_title(instance) + + form = self.make_model_form(instance, + cancel_url_fallback=self.get_index_url()) + + if self.request.method == 'POST': + if form.validate(): + self.edit_save_form(form) + return self.redirect(self.get_action_url('view', instance)) + + context = { + 'instance': instance, + 'instance_title': instance_title, + 'form': form, + } + return self.render_to_response('edit', context) + + def edit_save_form(self, form): + """ + This method is responsible for "converting" the validated form + data to a model instance, and then "saving" the result, + e.g. to DB. + + Subclass may override this, or any of the related methods + called by this one: + + * :meth:`objectify()` + * :meth:`persist()` + + :returns: This should return the resulting model instance, + which was produced by :meth:`objectify()`. + """ + obj = self.objectify(form) + self.persist(obj) + return obj + ############################## # configure methods ############################## @@ -785,6 +851,49 @@ class MasterView(View): """ return str(instance) + def get_action_url(self, action, obj, **kwargs): + """ + Generate an "action" URL for the given model instance. + + This is a shortcut which generates a route name based on + :meth:`get_route_prefix()` and the ``action`` param. + + It returns the URL based on generated route name and object's + model key values. + + :param action: String name for the action, which corresponds + to part of some named route, e.g. ``'view'`` or ``'edit'``. + + :param obj: Model instance object. + """ + route_prefix = self.get_route_prefix() + kw = dict([(key, obj[key]) + for key in self.get_model_key()]) + kw.update(kwargs) + return self.request.route_url(f'{route_prefix}.{action}', **kw) + + def get_action_url_view(self, obj, i): + """ + Returns the "view" grid action URL for the given object. + + Most typically this is like ``/widgets/XXX`` where ``XXX`` + represents the object's key/ID. + + Calls :meth:`get_action_url()` under the hood. + """ + return self.get_action_url('view', obj) + + def get_action_url_edit(self, obj, i): + """ + Returns the "edit" grid action URL for the given object. + + Most typically this is like ``/widgets/XXX/edit`` where + ``XXX`` represents the object's key/ID. + + Calls :meth:`get_action_url()` under the hood. + """ + return self.get_action_url('edit', obj) + def make_model_form(self, model_instance=None, **kwargs): """ Create and return a :class:`~wuttaweb.forms.base.Form` @@ -794,6 +903,7 @@ class MasterView(View): e.g.: * :meth:`view()` + * :meth:`edit()` See also related methods, which are called by this one: @@ -837,12 +947,58 @@ class MasterView(View): Configure the given model form, as needed. This is called by :meth:`make_model_form()` - for multiple - CRUD views. + CRUD views (create, view, edit, delete, possibly others). - There is no default logic here; subclass should override if - needed. The ``form`` param will already be "complete" and - ready to use as-is, but this method can further modify it - based on request details etc. + The default logic here does just one thing: when "editing" + (i.e. in :meth:`edit()` view) then all fields which are part + of the :attr:`model_key` will be marked via + :meth:`set_readonly()` so the user cannot change primary key + values for a record. + + Subclass may override as needed. The ``form`` param will + already be "complete" and ready to use as-is, but this method + can further modify it based on request details etc. + """ + if self.editing: + for key in self.get_model_key(): + form.set_readonly(key) + + def objectify(self, form): + """ + Must return a "model instance" object which reflects the + validated form data. + + In simple cases this may just return the + :attr:`~wuttaweb.forms.base.Form.validated` data dict. + + When dealing with SQLAlchemy models it would return a proper + mapped instance, creating it if necessary. + + :param form: Reference to the *already validated* + :class:`~wuttaweb.forms.base.Form` object. See the form's + :attr:`~wuttaweb.forms.base.Form.validated` attribute for + the data. + + See also :meth:`edit_save_form()` which calls this method. + """ + return form.validated + + def persist(self, obj): + """ + If applicable, this method should persist ("save") the given + object's data (e.g. to DB), creating or updating it as needed. + + This is part of the "submit form" workflow; ``obj`` should be + a model instance which already reflects the validated form + data. + + Note that there is no default logic here, subclass must + override if needed. + + :param obj: Model instance object as produced by + :meth:`objectify()`. + + See also :meth:`edit_save_form()` which calls this method. """ ############################## @@ -1145,6 +1301,14 @@ class MasterView(View): config.add_view(cls, attr='view', route_name=f'{route_prefix}.view') + # edit + if cls.editable: + instance_url_prefix = cls.get_instance_url_prefix() + config.add_route(f'{route_prefix}.edit', + f'{instance_url_prefix}/edit') + config.add_view(cls, attr='edit', + route_name=f'{route_prefix}.edit') + # 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 17f3c42..b5f51ff 100644 --- a/src/wuttaweb/views/settings.py +++ b/src/wuttaweb/views/settings.py @@ -197,6 +197,36 @@ 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') + + 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']) + def defaults(config, **kwargs): base = globals() diff --git a/tests/forms/test_base.py b/tests/forms/test_base.py index 5270f0f..a313937 100644 --- a/tests/forms/test_base.py +++ b/tests/forms/test_base.py @@ -1,7 +1,7 @@ # -*- coding: utf-8; -*- from unittest import TestCase -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch import colander import deform @@ -151,6 +151,34 @@ class TestForm(TestCase): dform = form.get_deform() self.assertEqual(dform.cstruct, myobj) + def test_get_cancel_url(self): + + # is referrer by default + form = self.make_form() + self.request.get_referrer = MagicMock(return_value='/cancel-default') + self.assertEqual(form.get_cancel_url(), '/cancel-default') + del self.request.get_referrer + + # or can be static URL + form = self.make_form(cancel_url='/cancel-static') + self.assertEqual(form.get_cancel_url(), '/cancel-static') + + # or can be fallback URL (nb. 'NOPE' indicates no referrer) + form = self.make_form(cancel_url_fallback='/cancel-fallback') + self.request.get_referrer = MagicMock(return_value='NOPE') + self.assertEqual(form.get_cancel_url(), '/cancel-fallback') + del self.request.get_referrer + + # or can be referrer fallback, i.e. home page + form = self.make_form() + def get_referrer(default=None): + if default == 'NOPE': + return 'NOPE' + return '/home-page' + self.request.get_referrer = get_referrer + self.assertEqual(form.get_cancel_url(), '/home-page') + del self.request.get_referrer + def test_get_label(self): form = self.make_form(fields=['foo', 'bar']) self.assertEqual(form.get_label('foo'), "Foo") @@ -170,6 +198,26 @@ class TestForm(TestCase): self.assertEqual(form.get_label('foo'), "Woohoo") self.assertEqual(schema['foo'].title, "Woohoo") + def test_readonly_fields(self): + form = self.make_form(fields=['foo', 'bar']) + self.assertEqual(form.readonly_fields, set()) + self.assertFalse(form.is_readonly('foo')) + + form.set_readonly('foo') + self.assertEqual(form.readonly_fields, {'foo'}) + self.assertTrue(form.is_readonly('foo')) + self.assertFalse(form.is_readonly('bar')) + + form.set_readonly('bar') + self.assertEqual(form.readonly_fields, {'foo', 'bar'}) + self.assertTrue(form.is_readonly('foo')) + self.assertTrue(form.is_readonly('bar')) + + form.set_readonly('foo', False) + self.assertEqual(form.readonly_fields, {'bar'}) + self.assertFalse(form.is_readonly('foo')) + self.assertTrue(form.is_readonly('bar')) + def test_render_vue_tag(self): schema = self.make_schema() form = self.make_form(schema=schema) @@ -183,13 +231,13 @@ class TestForm(TestCase): # form button is disabled on @submit by default schema = self.make_schema() - form = self.make_form(schema=schema) + form = self.make_form(schema=schema, cancel_url='/') html = form.render_vue_template() self.assertIn('