+
% if index_title:
% if index_url:
${h.link_to(index_title, index_url)}
% else:
${index_title}
% endif
+ % if master and master.creatable and not master.creating:
+
+ % endif
% endif
diff --git a/src/wuttaweb/templates/forms/vue_template.mako b/src/wuttaweb/templates/forms/vue_template.mako
index 93d1f7d..47e9fc3 100644
--- a/src/wuttaweb/templates/forms/vue_template.mako
+++ b/src/wuttaweb/templates/forms/vue_template.mako
@@ -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
}
diff --git a/src/wuttaweb/templates/master/create.mako b/src/wuttaweb/templates/master/create.mako
new file mode 100644
index 0000000..5aebc1a
--- /dev/null
+++ b/src/wuttaweb/templates/master/create.mako
@@ -0,0 +1,7 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/master/form.mako" />
+
+<%def name="title()">New ${model_title}%def>
+
+
+${parent.body()}
diff --git a/src/wuttaweb/views/master.py b/src/wuttaweb/views/master.py
index 1ac6a9f..70f47fd 100644
--- a/src/wuttaweb/views/master.py
+++ b/src/wuttaweb/views/master.py
@@ -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()
diff --git a/src/wuttaweb/views/settings.py b/src/wuttaweb/views/settings.py
index 2da0951..7d60ee2 100644
--- a/src/wuttaweb/views/settings.py
+++ b/src/wuttaweb/views/settings.py
@@ -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'])
diff --git a/tests/forms/test_base.py b/tests/forms/test_base.py
index cd0998f..e838e14 100644
--- a/tests/forms/test_base.py
+++ b/tests/forms/test_base.py
@@ -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)
diff --git a/tests/views/test_master.py b/tests/views/test_master.py
index 10724a4..6106ab0 100644
--- a/tests/views/test_master.py
+++ b/tests/views/test_master.py
@@ -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
diff --git a/tests/views/test_settings.py b/tests/views/test_settings.py
index 4712664..499cff0 100644
--- a/tests/views/test_settings.py
+++ b/tests/views/test_settings.py
@@ -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()