3
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()`.
.. 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

View file

@ -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):
"""

View file

@ -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>

View file

@ -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
}

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()`.
.. 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()

View file

@ -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'])