1
0
Fork 0

feat: add People view; improve CRUD master for SQLAlchemy models

This commit is contained in:
Lance Edgar 2024-08-11 18:21:02 -05:00
parent fc01fa283a
commit 33589f1cd8
10 changed files with 275 additions and 43 deletions

View file

@ -26,4 +26,5 @@
views.common views.common
views.essential views.essential
views.master views.master
views.people
views.settings views.settings

View file

@ -0,0 +1,6 @@
``wuttaweb.views.people``
===========================
.. automodule:: wuttaweb.views.people
:members:

View file

@ -815,8 +815,9 @@ class Form:
if self.readonly_fields: if self.readonly_fields:
schema = self.get_schema() schema = self.get_schema()
for field in self.readonly_fields: for field in self.readonly_fields:
del schema[field] if field in schema:
dform.children.remove(dform[field]) del schema[field]
dform.children.remove(dform[field])
# let deform do real validation # let deform do real validation
controls = get_form_data(self.request).items() controls = get_form_data(self.request).items()

View file

@ -97,11 +97,41 @@ class MenuHandler(GenericHandler):
is expected for most apps to override it. is expected for most apps to override it.
The return value should be a list of dicts as described above. The return value should be a list of dicts as described above.
The default logic returns a list of menus obtained from
calling these methods:
* :meth:`make_people_menu()`
* :meth:`make_admin_menu()`
""" """
return [ return [
self.make_people_menu(request),
self.make_admin_menu(request), self.make_admin_menu(request),
] ]
def make_people_menu(self, request, **kwargs):
"""
Generate a typical People menu.
This method provides a semi-sane menu set by default, but it
is expected for most apps to override it.
The return value for this method should be a *single* dict,
which will ultimately be one element of the final list of
dicts as described in :class:`MenuHandler`.
"""
return {
'title': "People",
'type': 'menu',
'items': [
{
'title': "All People",
'route': 'people',
'perm': 'people.list',
},
],
}
def make_admin_menu(self, request, **kwargs): def make_admin_menu(self, request, **kwargs):
""" """
Generate a typical Admin menu. Generate a typical Admin menu.
@ -111,7 +141,7 @@ class MenuHandler(GenericHandler):
The return value for this method should be a *single* dict, The return value for this method should be a *single* dict,
which will ultimately be one element of the final list of which will ultimately be one element of the final list of
dicts as described above. dicts as described in :class:`MenuHandler`.
""" """
return { return {
'title': "Admin", 'title': "Admin",

View file

@ -31,6 +31,8 @@ That will in turn include the following modules:
* :mod:`wuttaweb.views.auth` * :mod:`wuttaweb.views.auth`
* :mod:`wuttaweb.views.common` * :mod:`wuttaweb.views.common`
* :mod:`wuttaweb.views.settings`
* :mod:`wuttaweb.views.people`
""" """
@ -40,6 +42,7 @@ def defaults(config, **kwargs):
config.include(mod('wuttaweb.views.auth')) config.include(mod('wuttaweb.views.auth'))
config.include(mod('wuttaweb.views.common')) config.include(mod('wuttaweb.views.common'))
config.include(mod('wuttaweb.views.settings')) config.include(mod('wuttaweb.views.settings'))
config.include(mod('wuttaweb.views.people'))
def includeme(config): def includeme(config):

View file

@ -30,7 +30,7 @@ from sqlalchemy import orm
from pyramid.renderers import render_to_response from pyramid.renderers import render_to_response
from wuttaweb.views import View from wuttaweb.views import View
from wuttaweb.util import get_form_data from wuttaweb.util import get_form_data, get_model_fields
from wuttaweb.db import Session from wuttaweb.db import Session
@ -292,6 +292,7 @@ class MasterView(View):
if form.validate(): if form.validate():
obj = self.create_save_form(form) obj = self.create_save_form(form)
Session.flush()
return self.redirect(self.get_action_url('view', obj)) return self.redirect(self.get_action_url('view', obj))
context = { context = {
@ -910,7 +911,8 @@ class MasterView(View):
kwargs['columns'] = self.get_grid_columns() kwargs['columns'] = self.get_grid_columns()
if 'data' not in kwargs: if 'data' not in kwargs:
kwargs['data'] = self.get_grid_data(session=session) kwargs['data'] = self.get_grid_data(columns=kwargs['columns'],
session=session)
if 'actions' not in kwargs: if 'actions' not in kwargs:
actions = [] actions = []
@ -961,7 +963,7 @@ class MasterView(View):
if hasattr(self, 'grid_columns'): if hasattr(self, 'grid_columns'):
return self.grid_columns return self.grid_columns
def get_grid_data(self, session=None): def get_grid_data(self, columns=None, session=None):
""" """
Returns the grid data for the :meth:`index()` view. Returns the grid data for the :meth:`index()` view.
@ -974,8 +976,27 @@ class MasterView(View):
empty list. Subclass should override as needed. empty list. Subclass should override as needed.
""" """
query = self.get_query(session=session) query = self.get_query(session=session)
if query is not None: if query:
return query.all() data = query.all()
# determine which columns are relevant for data set
if not columns:
columns = self.get_grid_columns()
if not columns:
model_class = self.get_model_class()
if model_class:
columns = get_model_fields(self.config, model_class)
if not columns:
raise ValueError("cannot determine columns for the grid")
columns = set(columns)
columns.update(self.get_model_key())
# prune data fields for which no column is defined
for i, record in enumerate(data):
data[i]= dict([(key, record[key])
for key in columns])
return data
return [] return []
@ -1131,7 +1152,6 @@ class MasterView(View):
kwargs['model_instance'] = model_instance kwargs['model_instance'] = model_instance
# if 'fields' not in kwargs:
if not kwargs.get('fields'): if not kwargs.get('fields'):
fields = self.get_form_fields() fields = self.get_form_fields()
if fields: if fields:
@ -1181,8 +1201,13 @@ class MasterView(View):
already be "complete" and ready to use as-is, but this method already be "complete" and ready to use as-is, but this method
can further modify it based on request details etc. can further modify it based on request details etc.
""" """
model_keys = self.get_model_key()
if 'uuid' in form:
form.fields.remove('uuid')
if self.editing: if self.editing:
for key in self.get_model_key(): for key in model_keys:
form.set_readonly(key) form.set_readonly(key)
def objectify(self, form): def objectify(self, form):

View file

@ -0,0 +1,95 @@
# -*- coding: utf-8; -*-
################################################################################
#
# wuttaweb -- Web App for Wutta Framework
# Copyright © 2024 Lance Edgar
#
# This file is part of Wutta Framework.
#
# Wutta Framework is free software: you can redistribute it and/or modify it
# under the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option) any
# later version.
#
# Wutta Framework is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# Wutta Framework. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Views for people
"""
from wuttjamaican.db.model import Person
from wuttaweb.views import MasterView
class PersonView(MasterView):
"""
Master view for people.
Notable URLs provided by this class:
* ``/people/``
* ``/people/new``
* ``/people/XXX``
* ``/people/XXX/edit``
* ``/people/XXX/delete``
"""
model_class = Person
model_title_plural = "People"
route_prefix = 'people'
grid_columns = [
'full_name',
'first_name',
'middle_name',
'last_name',
]
# TODO: master should handle this, possibly via configure_form()
def get_query(self, session=None):
""" """
model = self.app.model
query = super().get_query(session=session)
return query.order_by(model.Person.full_name)
def configure_grid(self, g):
""" """
super().configure_grid(g)
# full_name
g.set_link('full_name')
# TODO: master should handle this?
def configure_form(self, f):
""" """
super().configure_form(f)
# first_name
f.set_required('first_name', False)
# middle_name
f.set_required('middle_name', False)
# last_name
f.set_required('last_name', False)
# users
if 'users' in f:
f.fields.remove('users')
def defaults(config, **kwargs):
base = globals()
PersonView = kwargs.get('PersonView', base['PersonView'])
PersonView.defaults(config)
def includeme(config):
defaults(config)

View file

@ -26,12 +26,9 @@ Views for app settings
from collections import OrderedDict from collections import OrderedDict
import colander
from wuttjamaican.db.model import Setting 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
class AppInfoView(MasterView): class AppInfoView(MasterView):

View file

@ -36,9 +36,9 @@ class TestMasterView(WebTestCase):
# subclass may specify # subclass may specify
MyModel = MagicMock() MyModel = MagicMock()
master.MasterView.model_class = MyModel with patch.multiple(master.MasterView, create=True,
self.assertIs(master.MasterView.get_model_class(), MyModel) model_class=MyModel):
del master.MasterView.model_class self.assertIs(master.MasterView.get_model_class(), MyModel)
def test_get_model_name(self): def test_get_model_name(self):
@ -52,9 +52,9 @@ class TestMasterView(WebTestCase):
# or it may specify model class # or it may specify model class
MyModel = MagicMock(__name__='Blaster') MyModel = MagicMock(__name__='Blaster')
master.MasterView.model_class = MyModel with patch.multiple(master.MasterView, create=True,
self.assertEqual(master.MasterView.get_model_name(), 'Blaster') model_class=MyModel):
del master.MasterView.model_class self.assertEqual(master.MasterView.get_model_name(), 'Blaster')
def test_get_model_name_normalized(self): def test_get_model_name_normalized(self):
@ -73,9 +73,9 @@ class TestMasterView(WebTestCase):
# or it may specify model class # or it may specify model class
MyModel = MagicMock(__name__='Dinosaur') MyModel = MagicMock(__name__='Dinosaur')
master.MasterView.model_class = MyModel with patch.multiple(master.MasterView, create=True,
self.assertEqual(master.MasterView.get_model_name_normalized(), 'dinosaur') model_class=MyModel):
del master.MasterView.model_class self.assertEqual(master.MasterView.get_model_name_normalized(), 'dinosaur')
def test_get_model_title(self): def test_get_model_title(self):
@ -94,9 +94,9 @@ class TestMasterView(WebTestCase):
# or it may specify model class # or it may specify model class
MyModel = MagicMock(__name__='Dinosaur') MyModel = MagicMock(__name__='Dinosaur')
master.MasterView.model_class = MyModel with patch.multiple(master.MasterView, create=True,
self.assertEqual(master.MasterView.get_model_title(), "Dinosaur") model_class=MyModel):
del master.MasterView.model_class self.assertEqual(master.MasterView.get_model_title(), "Dinosaur")
def test_get_model_title_plural(self): def test_get_model_title_plural(self):
@ -120,9 +120,9 @@ class TestMasterView(WebTestCase):
# or it may specify model class # or it may specify model class
MyModel = MagicMock(__name__='Dinosaur') MyModel = MagicMock(__name__='Dinosaur')
master.MasterView.model_class = MyModel with patch.multiple(master.MasterView, create=True,
self.assertEqual(master.MasterView.get_model_title_plural(), "Dinosaurs") model_class=MyModel):
del master.MasterView.model_class self.assertEqual(master.MasterView.get_model_title_plural(), "Dinosaurs")
def test_get_model_key(self): def test_get_model_key(self):
@ -156,9 +156,9 @@ class TestMasterView(WebTestCase):
# or it may specify model class # or it may specify model class
MyModel = MagicMock(__name__='Truck') MyModel = MagicMock(__name__='Truck')
master.MasterView.model_class = MyModel with patch.multiple(master.MasterView, create=True,
self.assertEqual(master.MasterView.get_route_prefix(), 'trucks') model_class=MyModel):
del master.MasterView.model_class self.assertEqual(master.MasterView.get_route_prefix(), 'trucks')
def test_get_url_prefix(self): def test_get_url_prefix(self):
@ -187,9 +187,9 @@ class TestMasterView(WebTestCase):
# or it may specify model class # or it may specify model class
MyModel = MagicMock(__name__='Machine') MyModel = MagicMock(__name__='Machine')
master.MasterView.model_class = MyModel with patch.multiple(master.MasterView, create=True,
self.assertEqual(master.MasterView.get_url_prefix(), '/machines') model_class=MyModel):
del master.MasterView.model_class self.assertEqual(master.MasterView.get_url_prefix(), '/machines')
def test_get_instance_url_prefix(self): def test_get_instance_url_prefix(self):
@ -242,9 +242,9 @@ class TestMasterView(WebTestCase):
# or it may specify model class # or it may specify model class
MyModel = MagicMock(__name__='Machine') MyModel = MagicMock(__name__='Machine')
master.MasterView.model_class = MyModel with patch.multiple(master.MasterView, create=True,
self.assertEqual(master.MasterView.get_template_prefix(), '/machines') model_class=MyModel):
del master.MasterView.model_class self.assertEqual(master.MasterView.get_template_prefix(), '/machines')
def test_get_grid_key(self): def test_get_grid_key(self):
@ -273,9 +273,9 @@ class TestMasterView(WebTestCase):
# or it may specify model class # or it may specify model class
MyModel = MagicMock(__name__='Machine') MyModel = MagicMock(__name__='Machine')
master.MasterView.model_class = MyModel with patch.multiple(master.MasterView, create=True,
self.assertEqual(master.MasterView.get_grid_key(), 'machines') model_class=MyModel):
del master.MasterView.model_class self.assertEqual(master.MasterView.get_grid_key(), 'machines')
def test_get_config_title(self): def test_get_config_title(self):
@ -304,9 +304,9 @@ class TestMasterView(WebTestCase):
# or it may specify model class # or it may specify model class
MyModel = MagicMock(__name__='Dinosaur') MyModel = MagicMock(__name__='Dinosaur')
master.MasterView.model_class = MyModel with patch.multiple(master.MasterView, create=True,
self.assertEqual(master.MasterView.get_config_title(), "Dinosaurs") model_class=MyModel):
del master.MasterView.model_class self.assertEqual(master.MasterView.get_config_title(), "Dinosaurs")
############################## ##############################
# support methods # support methods
@ -365,6 +365,28 @@ class TestMasterView(WebTestCase):
grid = view.make_model_grid(session=self.session) grid = view.make_model_grid(session=self.session)
self.assertIs(grid.model_class, model.Setting) self.assertIs(grid.model_class, model.Setting)
def test_get_grid_data(self):
model = self.app.model
self.app.save_setting(self.session, 'foo', 'bar')
self.session.commit()
# basic logic with Setting model
with patch.multiple(master.MasterView, create=True,
model_class=model.Setting):
view = master.MasterView(self.request)
data = view.get_grid_data(session=self.session)
self.assertEqual(len(data), 1)
self.assertEqual(data[0], {'name': 'foo', 'value': 'bar'})
# error if model not known
view = master.MasterView(self.request)
self.assertFalse(hasattr(master.MasterView, 'model_class'))
def get_query(session=None):
session = session or self.session
return session.query(model.Setting)
with patch.object(view, 'get_query', new=get_query):
self.assertRaises(ValueError, view.get_grid_data, session=self.session)
def test_get_instance(self): def test_get_instance(self):
model = self.app.model model = self.app.model
self.app.save_setting(self.session, 'foo', 'bar') self.app.save_setting(self.session, 'foo', 'bar')
@ -408,6 +430,19 @@ class TestMasterView(WebTestCase):
form = view.make_model_form() form = view.make_model_form()
self.assertIs(form.model_class, model.Setting) self.assertIs(form.model_class, model.Setting)
def test_configure_form(self):
model = self.app.model
# uuid field is pruned
with patch.multiple(master.MasterView, create=True,
model_class=model.Setting):
view = master.MasterView(self.request)
form = view.make_form(model_class=model.Setting,
fields=['uuid', 'name', 'value'])
self.assertIn('uuid', form.fields)
view.configure_form(form)
self.assertNotIn('uuid', form.fields)
def test_objectify(self): def test_objectify(self):
model = self.app.model model = self.app.model
self.app.save_setting(self.session, 'foo', 'bar') self.app.save_setting(self.session, 'foo', 'bar')

View file

@ -0,0 +1,39 @@
# -*- coding: utf-8; -*-
from unittest.mock import patch
from sqlalchemy import orm
from pyramid.httpexceptions import HTTPNotFound
from wuttaweb.views import people
from tests.views.utils import WebTestCase
class TestPersonView(WebTestCase):
def make_view(self):
return people.PersonView(self.request)
def test_get_query(self):
view = self.make_view()
query = view.get_query(session=self.session)
self.assertIsInstance(query, orm.Query)
def test_configure_grid(self):
model = self.app.model
view = self.make_view()
grid = view.make_grid(model_class=model.Setting)
self.assertEqual(grid.linked_columns, [])
view.configure_grid(grid)
self.assertIn('full_name', grid.linked_columns)
def test_configure_form(self):
model = self.app.model
view = self.make_view()
form = view.make_form(model_class=model.Person)
form.set_fields(form.get_model_fields())
self.assertEqual(form.required_fields, {})
view.configure_form(form)
self.assertTrue(form.required_fields)
self.assertFalse(form.required_fields['middle_name'])