1
0
Fork 0

feat: add basic Create support for CRUD master view

This commit is contained in:
Lance Edgar 2024-08-11 12:41:22 -05:00
parent c46b42f76d
commit 73014964cb
10 changed files with 307 additions and 45 deletions

View file

@ -159,6 +159,18 @@ class Form:
See also :meth:`set_readonly()` and :meth:`is_readonly()`. See also :meth:`set_readonly()` and :meth:`is_readonly()`.
.. attribute:: required_fields
A dict of "required" field flags. Keys are field names, and
values are boolean flags indicating whether the field is
required.
Depending on :attr:`schema`, some fields may be "(not)
required" by default. However ``required_fields`` keeps track
of any "overrides" per field.
See also :meth:`set_required()` and :meth:`is_required()`.
.. attribute:: action_url .. attribute:: action_url
String URL to which the form should be submitted, if applicable. String URL to which the form should be submitted, if applicable.
@ -256,6 +268,7 @@ class Form:
model_instance=None, model_instance=None,
readonly=False, readonly=False,
readonly_fields=[], readonly_fields=[],
required_fields={},
labels={}, labels={},
action_url=None, action_url=None,
cancel_url=None, cancel_url=None,
@ -275,6 +288,7 @@ class Form:
self.schema = schema self.schema = schema
self.readonly = readonly self.readonly = readonly
self.readonly_fields = set(readonly_fields or []) self.readonly_fields = set(readonly_fields or [])
self.required_fields = required_fields or {}
self.labels = labels or {} self.labels = labels or {}
self.action_url = action_url self.action_url = action_url
self.cancel_url = cancel_url self.cancel_url = cancel_url
@ -413,6 +427,41 @@ class Form:
return True return True
return False return False
def set_required(self, key, required=True):
"""
Enable or disable the "required" flag for a given field.
When a field is marked required, a value must be provided
or else it fails validation.
In practice if a field is "not required" then a default
"empty" value is assumed, should the user not provide one.
See also :meth:`is_required()`; this is tracked via
:attr:`required_fields`.
:param key: String key (fieldname) for the field.
:param required: New required flag for the field. Usually a
boolean, but may also be ``None`` to remove any flag and
revert to default behavior for the field.
"""
self.required_fields[key] = required
def is_required(self, key):
"""
Returns boolean indicating if the given field is marked as
required.
See also :meth:`set_required()`.
:param key: Field key/name as string.
:returns: Value for the flag from :attr:`required_fields` if
present; otherwise ``None``.
"""
return self.required_fields.get(key, None)
def set_label(self, key, label): def set_label(self, key, label):
""" """
Set the label for given field name. Set the label for given field name.
@ -452,6 +501,15 @@ class Form:
schema.add(colander.SchemaNode( schema.add(colander.SchemaNode(
colander.String(), colander.String(),
name=name)) name=name))
# apply required flags
for key, required in self.required_fields.items():
if key in schema:
if required is False:
# TODO: (why) should we not use colander.null here?
#schema[key].missing = colander.null
schema[key].missing = None
self.schema = schema self.schema = schema
else: # no fields else: # no fields

View file

@ -362,7 +362,11 @@ class GridAction:
Default logic returns the output from :meth:`render_icon()` Default logic returns the output from :meth:`render_icon()`
and :meth:`render_label()`. and :meth:`render_label()`.
""" """
return self.render_icon() + ' ' + self.render_label() html = [
self.render_icon(),
self.render_label(),
]
return HTML.literal(' ').join(html)
def render_icon(self): def render_icon(self):
""" """

View file

@ -214,13 +214,20 @@
<div class="level-left"> <div class="level-left">
## Current Context ## Current Context
<div id="current-context" class="level-item"> <div id="current-context" class="level-item"
style="display: flex; gap: 1.5rem;">
% if index_title: % if index_title:
% if index_url: % if index_url:
<h1 class="title">${h.link_to(index_title, index_url)}</h1> <h1 class="title">${h.link_to(index_title, index_url)}</h1>
% else: % else:
<h1 class="title">${index_title}</h1> <h1 class="title">${index_title}</h1>
% endif % endif
% if master and master.creatable and not master.creating:
<wutta-button once type="is-primary"
tag="a" href="${url(f'{route_prefix}.create')}"
icon-left="plus"
label="Create New" />
% endif
% endif % endif
</div> </div>

View file

@ -54,6 +54,8 @@
let ${form.vue_component}Data = { let ${form.vue_component}Data = {
% if not form.readonly:
## field model values ## field model values
% for key in form: % for key in form:
% if key in dform: % if key in dform:
@ -64,6 +66,8 @@
% if form.auto_disable_submit: % if form.auto_disable_submit:
formSubmitting: false, formSubmitting: false,
% endif % endif
% endif
} }
</script> </script>

View file

@ -0,0 +1,7 @@
## -*- coding: utf-8; -*-
<%inherit file="/master/form.mako" />
<%def name="title()">New ${model_title}</%def>
${parent.body()}

View file

@ -168,6 +168,12 @@ class MasterView(View):
This is optional; see also :meth:`index_get_grid_columns()`. This is optional; see also :meth:`index_get_grid_columns()`.
.. attribute:: creatable
Boolean indicating whether the view model supports "creating" -
i.e. it should have a :meth:`create()` view. Default value is
``True``.
.. attribute:: viewable .. attribute:: viewable
Boolean indicating whether the view model supports "viewing" - Boolean indicating whether the view model supports "viewing" -
@ -206,6 +212,7 @@ class MasterView(View):
# features # features
listable = True listable = True
has_grid = True has_grid = True
creatable = True
viewable = True viewable = True
editable = True editable = True
deletable = True deletable = True
@ -213,6 +220,7 @@ class MasterView(View):
# current action # current action
listing = False listing = False
creating = False
viewing = False viewing = False
editing = False editing = False
deleting = False deleting = False
@ -335,6 +343,62 @@ class MasterView(View):
""" """
return [] return []
##############################
# create methods
##############################
def create(self):
"""
View to "create" a new model record.
This usually corresponds to a URL like ``/widgets/new``.
By default, this view is included only if :attr:`creatable` is
true.
The default "create" view logic will show a form with field
widgets, allowing user to 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:`create_save_form()`
"""
self.creating = True
form = self.make_model_form(cancel_url_fallback=self.get_index_url())
if form.validate():
obj = self.create_save_form(form)
return self.redirect(self.get_action_url('view', obj))
context = {
'form': form,
}
return self.render_to_response('create', context)
def create_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. It is called by :meth:`create()`.
Subclass may override this, or any of the related methods
called by this one:
* :meth:`objectify()`
* :meth:`persist()`
:returns: Should return the resulting model instance, e.g. as
produced by :meth:`objectify()`.
"""
obj = self.objectify(form)
self.persist(obj)
return obj
############################## ##############################
# view methods # view methods
############################## ##############################
@ -419,7 +483,7 @@ class MasterView(View):
""" """
This method is responsible for "converting" the validated form This method is responsible for "converting" the validated form
data to a model instance, and then "saving" the result, data to a model instance, and then "saving" the result,
e.g. to DB. e.g. to DB. It is called by :meth:`edit()`.
Subclass may override this, or any of the related methods Subclass may override this, or any of the related methods
called by this one: called by this one:
@ -427,8 +491,8 @@ class MasterView(View):
* :meth:`objectify()` * :meth:`objectify()`
* :meth:`persist()` * :meth:`persist()`
:returns: This should return the resulting model instance, :returns: Should return the resulting model instance, e.g. as
which was produced by :meth:`objectify()`. produced by :meth:`objectify()`.
""" """
obj = self.objectify(form) obj = self.objectify(form)
self.persist(obj) self.persist(obj)
@ -1395,6 +1459,13 @@ class MasterView(View):
config.add_view(cls, attr='index', config.add_view(cls, attr='index',
route_name=route_prefix) route_name=route_prefix)
# create
if cls.creatable:
config.add_route(f'{route_prefix}.create',
f'{url_prefix}/new')
config.add_view(cls, attr='create',
route_name=f'{route_prefix}.create')
# view # view
if cls.viewable: if cls.viewable:
instance_url_prefix = cls.get_instance_url_prefix() instance_url_prefix = cls.get_instance_url_prefix()

View file

@ -26,8 +26,9 @@ Views for app settings
from collections import OrderedDict from collections import OrderedDict
from wuttjamaican.db.model import Setting import colander
from wuttjamaican.db.model import Setting
from wuttaweb.views import MasterView from wuttaweb.views import MasterView
from wuttaweb.util import get_libver, get_liburl from wuttaweb.util import get_libver, get_liburl
from wuttaweb.db import Session from wuttaweb.db import Session
@ -48,6 +49,7 @@ class AppInfoView(MasterView):
model_title_plural = "App Info" model_title_plural = "App Info"
route_prefix = 'appinfo' route_prefix = 'appinfo'
has_grid = False has_grid = False
creatable = False
viewable = False viewable = False
editable = False editable = False
deletable = False deletable = False
@ -177,6 +179,10 @@ class SettingView(MasterView):
""" """ """ """
return { return {
'name': setting.name, 'name': setting.name,
# TODO: when viewing the record, 'None' is displayed for null
# field values. not so if we return colander.null here, but
# then that causes other problems..
#'value': setting.value if setting.value is not None else colander.null,
'value': setting.value, 'value': setting.value,
} }
@ -188,7 +194,10 @@ class SettingView(MasterView):
name = self.request.matchdict['name'] name = self.request.matchdict['name']
setting = session.query(model.Setting).get(name) setting = session.query(model.Setting).get(name)
if setting: if setting:
return self.normalize_setting(setting) setting = self.normalize_setting(setting)
if setting['value'] is None:
setting['value'] = colander.null
return setting
return self.notfound() return self.notfound()
@ -197,10 +206,19 @@ class SettingView(MasterView):
""" """ """ """
return setting['name'] return setting['name']
# TODO: master should handle this
def configure_form(self, f):
super().configure_form(f)
f.set_required('value', False)
# TODO: master should handle this # TODO: master should handle this
def persist(self, setting, session=None): def persist(self, setting, session=None):
""" """ """ """
name = self.get_instance(session=session)['name'] if self.creating:
name = setting['name']
else:
name = self.request.matchdict['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'])

View file

@ -135,6 +135,19 @@ class TestForm(TestCase):
self.assertIsNone(form.schema) self.assertIsNone(form.schema)
self.assertRaises(NotImplementedError, form.get_schema) self.assertRaises(NotImplementedError, form.get_schema)
# schema nodes are required by default
form = self.make_form(fields=['foo', 'bar'])
schema = form.get_schema()
self.assertIs(schema['foo'].missing, colander.required)
self.assertIs(schema['bar'].missing, colander.required)
# but fields can be marked *not* required
form = self.make_form(fields=['foo', 'bar'])
form.set_required('bar', False)
schema = form.get_schema()
self.assertIs(schema['foo'].missing, colander.required)
self.assertIsNone(schema['bar'].missing)
def test_get_deform(self): def test_get_deform(self):
schema = self.make_schema() schema = self.make_schema()
@ -218,6 +231,26 @@ class TestForm(TestCase):
self.assertFalse(form.is_readonly('foo')) self.assertFalse(form.is_readonly('foo'))
self.assertTrue(form.is_readonly('bar')) self.assertTrue(form.is_readonly('bar'))
def test_required_fields(self):
form = self.make_form(fields=['foo', 'bar'])
self.assertEqual(form.required_fields, {})
self.assertIsNone(form.is_required('foo'))
form.set_required('foo')
self.assertEqual(form.required_fields, {'foo': True})
self.assertTrue(form.is_required('foo'))
self.assertIsNone(form.is_required('bar'))
form.set_required('bar')
self.assertEqual(form.required_fields, {'foo': True, 'bar': True})
self.assertTrue(form.is_required('foo'))
self.assertTrue(form.is_required('bar'))
form.set_required('foo', False)
self.assertEqual(form.required_fields, {'foo': False, 'bar': True})
self.assertFalse(form.is_required('foo'))
self.assertTrue(form.is_required('bar'))
def test_render_vue_tag(self): def test_render_vue_tag(self):
schema = self.make_schema() schema = self.make_schema()
form = self.make_form(schema=schema) form = self.make_form(schema=schema)

View file

@ -320,22 +320,22 @@ class TestMasterView(WebTestCase):
# basic sanity check using /master/index.mako # basic sanity check using /master/index.mako
# (nb. it skips /widgets/index.mako since that doesn't exist) # (nb. it skips /widgets/index.mako since that doesn't exist)
master.MasterView.model_name = 'Widget' with patch.multiple(master.MasterView, create=True,
model_name='Widget',
creatable=False):
view = master.MasterView(self.request) view = master.MasterView(self.request)
response = view.render_to_response('index', {}) response = view.render_to_response('index', {})
self.assertIsInstance(response, Response) self.assertIsInstance(response, Response)
del master.MasterView.model_name
# basic sanity check using /appinfo/index.mako # basic sanity check using /appinfo/index.mako
master.MasterView.model_name = 'AppInfo' with patch.multiple(master.MasterView, create=True,
master.MasterView.route_prefix = 'appinfo' model_name='AppInfo',
master.MasterView.url_prefix = '/appinfo' route_prefix='appinfo',
url_prefix='/appinfo',
creatable=False):
view = master.MasterView(self.request) view = master.MasterView(self.request)
response = view.render_to_response('index', {}) response = view.render_to_response('index', {})
self.assertIsInstance(response, Response) self.assertIsInstance(response, Response)
del master.MasterView.model_name
del master.MasterView.route_prefix
del master.MasterView.url_prefix
# bad template name causes error # bad template name causes error
master.MasterView.model_name = 'Widget' master.MasterView.model_name = 'Widget'
@ -372,6 +372,55 @@ class TestMasterView(WebTestCase):
del master.MasterView.model_key del master.MasterView.model_key
del master.MasterView.grid_columns del master.MasterView.grid_columns
def test_create(self):
model = self.app.model
# sanity/coverage check using /settings/new
with patch.multiple(master.MasterView, create=True,
model_name='Setting',
model_key='name',
form_fields=['name', 'value']):
view = master.MasterView(self.request)
# no setting yet
self.assertIsNone(self.app.get_setting(self.session, 'foo.bar'))
# get the form page
response = view.create()
self.assertIsInstance(response, Response)
self.assertEqual(response.status_code, 200)
# self.assertIn('frazzle', response.text)
# nb. no error
self.assertNotIn('Required', response.text)
def persist(setting):
self.app.save_setting(self.session, setting['name'], setting['value'])
self.session.commit()
# post request to save setting
self.request.method = 'POST'
self.request.POST = {
'name': 'foo.bar',
'value': 'fraggle',
}
with patch.object(view, 'persist', new=persist):
response = view.create()
# nb. should get redirect back to view page
self.assertEqual(response.status_code, 302)
# setting should now be in DB
self.assertEqual(self.app.get_setting(self.session, 'foo.bar'), 'fraggle')
# try another post with invalid data (value is required)
self.request.method = 'POST'
self.request.POST = {}
with patch.object(view, 'persist', new=persist):
response = view.create()
# nb. should get a form with errors
self.assertEqual(response.status_code, 200)
self.assertIn('Required', response.text)
# setting did not change in DB
self.assertEqual(self.app.get_setting(self.session, 'foo.bar'), 'fraggle')
def test_view(self): def test_view(self):
# sanity/coverage check using /settings/XXX # sanity/coverage check using /settings/XXX
@ -500,11 +549,6 @@ class TestMasterView(WebTestCase):
def test_configure(self): def test_configure(self):
model = self.app.model model = self.app.model
# setup
master.MasterView.model_name = 'AppInfo'
master.MasterView.route_prefix = 'appinfo'
master.MasterView.template_prefix = '/appinfo'
# mock settings # mock settings
settings = [ settings = [
{'name': 'wutta.app_title'}, {'name': 'wutta.app_title'},
@ -516,11 +560,14 @@ class TestMasterView(WebTestCase):
] ]
view = master.MasterView(self.request) view = master.MasterView(self.request)
with patch.object(self.request, 'current_route_url', with patch.object(self.request, 'current_route_url', return_value='/appinfo/configure'):
return_value='/appinfo/configure'):
with patch.object(master.MasterView, 'configure_get_simple_settings',
return_value=settings):
with patch.object(master, 'Session', return_value=self.session): with patch.object(master, 'Session', return_value=self.session):
with patch.multiple(master.MasterView, create=True,
model_name='AppInfo',
route_prefix='appinfo',
template_prefix='/appinfo',
creatable=False,
configure_get_simple_settings=MagicMock(return_value=settings)):
# get the form page # get the form page
response = view.configure() response = view.configure()
@ -558,8 +605,3 @@ class TestMasterView(WebTestCase):
# should now have 0 settings # should now have 0 settings
count = self.session.query(model.Setting).count() count = self.session.query(model.Setting).count()
self.assertEqual(count, 0) self.assertEqual(count, 0)
# teardown
del master.MasterView.model_name
del master.MasterView.route_prefix
del master.MasterView.template_prefix

View file

@ -1,5 +1,7 @@
# -*- coding: utf-8; -*- # -*- coding: utf-8; -*-
from unittest.mock import patch
from tests.views.utils import WebTestCase from tests.views.utils import WebTestCase
from pyramid.httpexceptions import HTTPNotFound from pyramid.httpexceptions import HTTPNotFound
@ -66,6 +68,14 @@ 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_configure_form(self):
view = self.make_view()
form = view.make_form(fields=view.get_form_fields())
self.assertNotIn('value', form.required_fields)
view.configure_form(form)
self.assertIn('value', form.required_fields)
self.assertFalse(form.required_fields['value'])
def test_persist(self): def test_persist(self):
model = self.app.model model = self.app.model
view = self.make_view() view = self.make_view()
@ -82,6 +92,14 @@ class TestSettingView(WebTestCase):
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')
# new setting is created
self.request.matchdict = {}
with patch.object(view, 'creating', new=True):
view.persist({'name': 'foo', 'value': 'frazzle'}, session=self.session)
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): def test_delete_instance(self):
model = self.app.model model = self.app.model
view = self.make_view() view = self.make_view()