feat: add basic Create support for CRUD master view
This commit is contained in:
parent
c46b42f76d
commit
73014964cb
|
@ -159,6 +159,18 @@ class Form:
|
|||
|
||||
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
|
||||
|
||||
String URL to which the form should be submitted, if applicable.
|
||||
|
@ -256,6 +268,7 @@ class Form:
|
|||
model_instance=None,
|
||||
readonly=False,
|
||||
readonly_fields=[],
|
||||
required_fields={},
|
||||
labels={},
|
||||
action_url=None,
|
||||
cancel_url=None,
|
||||
|
@ -275,6 +288,7 @@ class Form:
|
|||
self.schema = schema
|
||||
self.readonly = readonly
|
||||
self.readonly_fields = set(readonly_fields or [])
|
||||
self.required_fields = required_fields or {}
|
||||
self.labels = labels or {}
|
||||
self.action_url = action_url
|
||||
self.cancel_url = cancel_url
|
||||
|
@ -413,6 +427,41 @@ class Form:
|
|||
return True
|
||||
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):
|
||||
"""
|
||||
Set the label for given field name.
|
||||
|
@ -452,6 +501,15 @@ class Form:
|
|||
schema.add(colander.SchemaNode(
|
||||
colander.String(),
|
||||
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
|
||||
|
||||
else: # no fields
|
||||
|
|
|
@ -362,7 +362,11 @@ class GridAction:
|
|||
Default logic returns the output from :meth:`render_icon()`
|
||||
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):
|
||||
"""
|
||||
|
|
|
@ -214,13 +214,20 @@
|
|||
<div class="level-left">
|
||||
|
||||
## 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_url:
|
||||
<h1 class="title">${h.link_to(index_title, index_url)}</h1>
|
||||
% else:
|
||||
<h1 class="title">${index_title}</h1>
|
||||
% 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
|
||||
</div>
|
||||
|
||||
|
|
|
@ -54,15 +54,19 @@
|
|||
|
||||
let ${form.vue_component}Data = {
|
||||
|
||||
## field model values
|
||||
% for key in form:
|
||||
% if key in dform:
|
||||
model_${key}: ${form.get_vue_field_value(key)|n},
|
||||
% endif
|
||||
% endfor
|
||||
% if not form.readonly:
|
||||
|
||||
## field model values
|
||||
% for key in form:
|
||||
% if key in dform:
|
||||
model_${key}: ${form.get_vue_field_value(key)|n},
|
||||
% endif
|
||||
% endfor
|
||||
|
||||
% if form.auto_disable_submit:
|
||||
formSubmitting: false,
|
||||
% endif
|
||||
|
||||
% if form.auto_disable_submit:
|
||||
formSubmitting: false,
|
||||
% endif
|
||||
}
|
||||
|
||||
|
|
7
src/wuttaweb/templates/master/create.mako
Normal file
7
src/wuttaweb/templates/master/create.mako
Normal file
|
@ -0,0 +1,7 @@
|
|||
## -*- coding: utf-8; -*-
|
||||
<%inherit file="/master/form.mako" />
|
||||
|
||||
<%def name="title()">New ${model_title}</%def>
|
||||
|
||||
|
||||
${parent.body()}
|
|
@ -168,6 +168,12 @@ class MasterView(View):
|
|||
|
||||
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
|
||||
|
||||
Boolean indicating whether the view model supports "viewing" -
|
||||
|
@ -206,6 +212,7 @@ class MasterView(View):
|
|||
# features
|
||||
listable = True
|
||||
has_grid = True
|
||||
creatable = True
|
||||
viewable = True
|
||||
editable = True
|
||||
deletable = True
|
||||
|
@ -213,6 +220,7 @@ class MasterView(View):
|
|||
|
||||
# current action
|
||||
listing = False
|
||||
creating = False
|
||||
viewing = False
|
||||
editing = False
|
||||
deleting = False
|
||||
|
@ -335,6 +343,62 @@ class MasterView(View):
|
|||
"""
|
||||
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
|
||||
##############################
|
||||
|
@ -419,7 +483,7 @@ class MasterView(View):
|
|||
"""
|
||||
This method is responsible for "converting" the validated form
|
||||
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
|
||||
called by this one:
|
||||
|
@ -427,8 +491,8 @@ class MasterView(View):
|
|||
* :meth:`objectify()`
|
||||
* :meth:`persist()`
|
||||
|
||||
:returns: This should return the resulting model instance,
|
||||
which was produced by :meth:`objectify()`.
|
||||
:returns: Should return the resulting model instance, e.g. as
|
||||
produced by :meth:`objectify()`.
|
||||
"""
|
||||
obj = self.objectify(form)
|
||||
self.persist(obj)
|
||||
|
@ -1395,6 +1459,13 @@ class MasterView(View):
|
|||
config.add_view(cls, attr='index',
|
||||
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
|
||||
if cls.viewable:
|
||||
instance_url_prefix = cls.get_instance_url_prefix()
|
||||
|
|
|
@ -26,8 +26,9 @@ Views for app settings
|
|||
|
||||
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.util import get_libver, get_liburl
|
||||
from wuttaweb.db import Session
|
||||
|
@ -48,6 +49,7 @@ class AppInfoView(MasterView):
|
|||
model_title_plural = "App Info"
|
||||
route_prefix = 'appinfo'
|
||||
has_grid = False
|
||||
creatable = False
|
||||
viewable = False
|
||||
editable = False
|
||||
deletable = False
|
||||
|
@ -177,6 +179,10 @@ class SettingView(MasterView):
|
|||
""" """
|
||||
return {
|
||||
'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,
|
||||
}
|
||||
|
||||
|
@ -188,7 +194,10 @@ class SettingView(MasterView):
|
|||
name = self.request.matchdict['name']
|
||||
setting = session.query(model.Setting).get(name)
|
||||
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()
|
||||
|
||||
|
@ -197,10 +206,19 @@ class SettingView(MasterView):
|
|||
""" """
|
||||
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
|
||||
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()
|
||||
self.app.save_setting(session, name, setting['value'])
|
||||
|
||||
|
|
|
@ -135,6 +135,19 @@ class TestForm(TestCase):
|
|||
self.assertIsNone(form.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):
|
||||
schema = self.make_schema()
|
||||
|
||||
|
@ -218,6 +231,26 @@ class TestForm(TestCase):
|
|||
self.assertFalse(form.is_readonly('foo'))
|
||||
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):
|
||||
schema = self.make_schema()
|
||||
form = self.make_form(schema=schema)
|
||||
|
|
|
@ -320,22 +320,22 @@ class TestMasterView(WebTestCase):
|
|||
|
||||
# basic sanity check using /master/index.mako
|
||||
# (nb. it skips /widgets/index.mako since that doesn't exist)
|
||||
master.MasterView.model_name = 'Widget'
|
||||
view = master.MasterView(self.request)
|
||||
response = view.render_to_response('index', {})
|
||||
self.assertIsInstance(response, Response)
|
||||
del master.MasterView.model_name
|
||||
with patch.multiple(master.MasterView, create=True,
|
||||
model_name='Widget',
|
||||
creatable=False):
|
||||
view = master.MasterView(self.request)
|
||||
response = view.render_to_response('index', {})
|
||||
self.assertIsInstance(response, Response)
|
||||
|
||||
# basic sanity check using /appinfo/index.mako
|
||||
master.MasterView.model_name = 'AppInfo'
|
||||
master.MasterView.route_prefix = 'appinfo'
|
||||
master.MasterView.url_prefix = '/appinfo'
|
||||
view = master.MasterView(self.request)
|
||||
response = view.render_to_response('index', {})
|
||||
self.assertIsInstance(response, Response)
|
||||
del master.MasterView.model_name
|
||||
del master.MasterView.route_prefix
|
||||
del master.MasterView.url_prefix
|
||||
with patch.multiple(master.MasterView, create=True,
|
||||
model_name='AppInfo',
|
||||
route_prefix='appinfo',
|
||||
url_prefix='/appinfo',
|
||||
creatable=False):
|
||||
view = master.MasterView(self.request)
|
||||
response = view.render_to_response('index', {})
|
||||
self.assertIsInstance(response, Response)
|
||||
|
||||
# bad template name causes error
|
||||
master.MasterView.model_name = 'Widget'
|
||||
|
@ -372,6 +372,55 @@ class TestMasterView(WebTestCase):
|
|||
del master.MasterView.model_key
|
||||
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):
|
||||
|
||||
# sanity/coverage check using /settings/XXX
|
||||
|
@ -500,11 +549,6 @@ class TestMasterView(WebTestCase):
|
|||
def test_configure(self):
|
||||
model = self.app.model
|
||||
|
||||
# setup
|
||||
master.MasterView.model_name = 'AppInfo'
|
||||
master.MasterView.route_prefix = 'appinfo'
|
||||
master.MasterView.template_prefix = '/appinfo'
|
||||
|
||||
# mock settings
|
||||
settings = [
|
||||
{'name': 'wutta.app_title'},
|
||||
|
@ -516,11 +560,14 @@ class TestMasterView(WebTestCase):
|
|||
]
|
||||
|
||||
view = master.MasterView(self.request)
|
||||
with patch.object(self.request, 'current_route_url',
|
||||
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(self.request, 'current_route_url', return_value='/appinfo/configure'):
|
||||
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
|
||||
response = view.configure()
|
||||
|
@ -558,8 +605,3 @@ class TestMasterView(WebTestCase):
|
|||
# should now have 0 settings
|
||||
count = self.session.query(model.Setting).count()
|
||||
self.assertEqual(count, 0)
|
||||
|
||||
# teardown
|
||||
del master.MasterView.model_name
|
||||
del master.MasterView.route_prefix
|
||||
del master.MasterView.template_prefix
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
# -*- coding: utf-8; -*-
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from tests.views.utils import WebTestCase
|
||||
|
||||
from pyramid.httpexceptions import HTTPNotFound
|
||||
|
@ -66,6 +68,14 @@ class TestSettingView(WebTestCase):
|
|||
title = view.get_instance_title(setting)
|
||||
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):
|
||||
model = self.app.model
|
||||
view = self.make_view()
|
||||
|
@ -82,6 +92,14 @@ class TestSettingView(WebTestCase):
|
|||
self.assertEqual(self.session.query(model.Setting).count(), 1)
|
||||
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):
|
||||
model = self.app.model
|
||||
view = self.make_view()
|
||||
|
|
Loading…
Reference in a new issue