From 33589f1cd863aed4dac62fa13018ed10e74b99df Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 11 Aug 2024 18:21:02 -0500 Subject: [PATCH] feat: add People view; improve CRUD master for SQLAlchemy models --- docs/api/wuttaweb/index.rst | 1 + docs/api/wuttaweb/views.people.rst | 6 ++ src/wuttaweb/forms/base.py | 5 +- src/wuttaweb/menus.py | 32 +++++++++- src/wuttaweb/views/essential.py | 3 + src/wuttaweb/views/master.py | 39 +++++++++--- src/wuttaweb/views/people.py | 95 ++++++++++++++++++++++++++++++ src/wuttaweb/views/settings.py | 3 - tests/views/test_master.py | 95 ++++++++++++++++++++---------- tests/views/test_people.py | 39 ++++++++++++ 10 files changed, 275 insertions(+), 43 deletions(-) create mode 100644 docs/api/wuttaweb/views.people.rst create mode 100644 src/wuttaweb/views/people.py create mode 100644 tests/views/test_people.py diff --git a/docs/api/wuttaweb/index.rst b/docs/api/wuttaweb/index.rst index 1b1a61f..93ba626 100644 --- a/docs/api/wuttaweb/index.rst +++ b/docs/api/wuttaweb/index.rst @@ -26,4 +26,5 @@ views.common views.essential views.master + views.people views.settings diff --git a/docs/api/wuttaweb/views.people.rst b/docs/api/wuttaweb/views.people.rst new file mode 100644 index 0000000..89c6883 --- /dev/null +++ b/docs/api/wuttaweb/views.people.rst @@ -0,0 +1,6 @@ + +``wuttaweb.views.people`` +=========================== + +.. automodule:: wuttaweb.views.people + :members: diff --git a/src/wuttaweb/forms/base.py b/src/wuttaweb/forms/base.py index b7ca13c..1ecff26 100644 --- a/src/wuttaweb/forms/base.py +++ b/src/wuttaweb/forms/base.py @@ -815,8 +815,9 @@ class Form: if self.readonly_fields: schema = self.get_schema() for field in self.readonly_fields: - del schema[field] - dform.children.remove(dform[field]) + if field in schema: + del schema[field] + dform.children.remove(dform[field]) # let deform do real validation controls = get_form_data(self.request).items() diff --git a/src/wuttaweb/menus.py b/src/wuttaweb/menus.py index 92e8162..b07c0e4 100644 --- a/src/wuttaweb/menus.py +++ b/src/wuttaweb/menus.py @@ -97,11 +97,41 @@ class MenuHandler(GenericHandler): is expected for most apps to override it. 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 [ + self.make_people_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): """ Generate a typical Admin menu. @@ -111,7 +141,7 @@ class MenuHandler(GenericHandler): 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 above. + dicts as described in :class:`MenuHandler`. """ return { 'title': "Admin", diff --git a/src/wuttaweb/views/essential.py b/src/wuttaweb/views/essential.py index 0d4ec35..f004201 100644 --- a/src/wuttaweb/views/essential.py +++ b/src/wuttaweb/views/essential.py @@ -31,6 +31,8 @@ That will in turn include the following modules: * :mod:`wuttaweb.views.auth` * :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.common')) config.include(mod('wuttaweb.views.settings')) + config.include(mod('wuttaweb.views.people')) def includeme(config): diff --git a/src/wuttaweb/views/master.py b/src/wuttaweb/views/master.py index 8fa667f..a735cdd 100644 --- a/src/wuttaweb/views/master.py +++ b/src/wuttaweb/views/master.py @@ -30,7 +30,7 @@ from sqlalchemy import orm from pyramid.renderers import render_to_response 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 @@ -292,6 +292,7 @@ class MasterView(View): if form.validate(): obj = self.create_save_form(form) + Session.flush() return self.redirect(self.get_action_url('view', obj)) context = { @@ -910,7 +911,8 @@ class MasterView(View): kwargs['columns'] = self.get_grid_columns() 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: actions = [] @@ -961,7 +963,7 @@ class MasterView(View): if hasattr(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. @@ -974,8 +976,27 @@ class MasterView(View): empty list. Subclass should override as needed. """ query = self.get_query(session=session) - if query is not None: - return query.all() + if query: + 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 [] @@ -1131,7 +1152,6 @@ class MasterView(View): kwargs['model_instance'] = model_instance - # if 'fields' not in kwargs: if not kwargs.get('fields'): fields = self.get_form_fields() if fields: @@ -1181,8 +1201,13 @@ class MasterView(View): already be "complete" and ready to use as-is, but this method 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: - for key in self.get_model_key(): + for key in model_keys: form.set_readonly(key) def objectify(self, form): diff --git a/src/wuttaweb/views/people.py b/src/wuttaweb/views/people.py new file mode 100644 index 0000000..cd56c83 --- /dev/null +++ b/src/wuttaweb/views/people.py @@ -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 . +# +################################################################################ +""" +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) diff --git a/src/wuttaweb/views/settings.py b/src/wuttaweb/views/settings.py index 4b72081..a85be38 100644 --- a/src/wuttaweb/views/settings.py +++ b/src/wuttaweb/views/settings.py @@ -26,12 +26,9 @@ Views for app settings from collections import OrderedDict -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 class AppInfoView(MasterView): diff --git a/tests/views/test_master.py b/tests/views/test_master.py index 55a8acb..3e793e7 100644 --- a/tests/views/test_master.py +++ b/tests/views/test_master.py @@ -36,9 +36,9 @@ class TestMasterView(WebTestCase): # subclass may specify MyModel = MagicMock() - master.MasterView.model_class = MyModel - self.assertIs(master.MasterView.get_model_class(), MyModel) - del master.MasterView.model_class + with patch.multiple(master.MasterView, create=True, + model_class=MyModel): + self.assertIs(master.MasterView.get_model_class(), MyModel) def test_get_model_name(self): @@ -52,9 +52,9 @@ class TestMasterView(WebTestCase): # or it may specify model class MyModel = MagicMock(__name__='Blaster') - master.MasterView.model_class = MyModel - self.assertEqual(master.MasterView.get_model_name(), 'Blaster') - del master.MasterView.model_class + with patch.multiple(master.MasterView, create=True, + model_class=MyModel): + self.assertEqual(master.MasterView.get_model_name(), 'Blaster') def test_get_model_name_normalized(self): @@ -73,9 +73,9 @@ class TestMasterView(WebTestCase): # or it may specify model class MyModel = MagicMock(__name__='Dinosaur') - master.MasterView.model_class = MyModel - self.assertEqual(master.MasterView.get_model_name_normalized(), 'dinosaur') - del master.MasterView.model_class + with patch.multiple(master.MasterView, create=True, + model_class=MyModel): + self.assertEqual(master.MasterView.get_model_name_normalized(), 'dinosaur') def test_get_model_title(self): @@ -94,9 +94,9 @@ class TestMasterView(WebTestCase): # or it may specify model class MyModel = MagicMock(__name__='Dinosaur') - master.MasterView.model_class = MyModel - self.assertEqual(master.MasterView.get_model_title(), "Dinosaur") - del master.MasterView.model_class + with patch.multiple(master.MasterView, create=True, + model_class=MyModel): + self.assertEqual(master.MasterView.get_model_title(), "Dinosaur") def test_get_model_title_plural(self): @@ -120,9 +120,9 @@ class TestMasterView(WebTestCase): # or it may specify model class MyModel = MagicMock(__name__='Dinosaur') - master.MasterView.model_class = MyModel - self.assertEqual(master.MasterView.get_model_title_plural(), "Dinosaurs") - del master.MasterView.model_class + with patch.multiple(master.MasterView, create=True, + model_class=MyModel): + self.assertEqual(master.MasterView.get_model_title_plural(), "Dinosaurs") def test_get_model_key(self): @@ -156,9 +156,9 @@ class TestMasterView(WebTestCase): # or it may specify model class MyModel = MagicMock(__name__='Truck') - master.MasterView.model_class = MyModel - self.assertEqual(master.MasterView.get_route_prefix(), 'trucks') - del master.MasterView.model_class + with patch.multiple(master.MasterView, create=True, + model_class=MyModel): + self.assertEqual(master.MasterView.get_route_prefix(), 'trucks') def test_get_url_prefix(self): @@ -187,9 +187,9 @@ class TestMasterView(WebTestCase): # or it may specify model class MyModel = MagicMock(__name__='Machine') - master.MasterView.model_class = MyModel - self.assertEqual(master.MasterView.get_url_prefix(), '/machines') - del master.MasterView.model_class + with patch.multiple(master.MasterView, create=True, + model_class=MyModel): + self.assertEqual(master.MasterView.get_url_prefix(), '/machines') def test_get_instance_url_prefix(self): @@ -242,9 +242,9 @@ class TestMasterView(WebTestCase): # or it may specify model class MyModel = MagicMock(__name__='Machine') - master.MasterView.model_class = MyModel - self.assertEqual(master.MasterView.get_template_prefix(), '/machines') - del master.MasterView.model_class + with patch.multiple(master.MasterView, create=True, + model_class=MyModel): + self.assertEqual(master.MasterView.get_template_prefix(), '/machines') def test_get_grid_key(self): @@ -273,9 +273,9 @@ class TestMasterView(WebTestCase): # or it may specify model class MyModel = MagicMock(__name__='Machine') - master.MasterView.model_class = MyModel - self.assertEqual(master.MasterView.get_grid_key(), 'machines') - del master.MasterView.model_class + with patch.multiple(master.MasterView, create=True, + model_class=MyModel): + self.assertEqual(master.MasterView.get_grid_key(), 'machines') def test_get_config_title(self): @@ -304,9 +304,9 @@ class TestMasterView(WebTestCase): # or it may specify model class MyModel = MagicMock(__name__='Dinosaur') - master.MasterView.model_class = MyModel - self.assertEqual(master.MasterView.get_config_title(), "Dinosaurs") - del master.MasterView.model_class + with patch.multiple(master.MasterView, create=True, + model_class=MyModel): + self.assertEqual(master.MasterView.get_config_title(), "Dinosaurs") ############################## # support methods @@ -365,6 +365,28 @@ class TestMasterView(WebTestCase): grid = view.make_model_grid(session=self.session) 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): model = self.app.model self.app.save_setting(self.session, 'foo', 'bar') @@ -408,6 +430,19 @@ class TestMasterView(WebTestCase): form = view.make_model_form() 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): model = self.app.model self.app.save_setting(self.session, 'foo', 'bar') diff --git a/tests/views/test_people.py b/tests/views/test_people.py new file mode 100644 index 0000000..a6457b7 --- /dev/null +++ b/tests/views/test_people.py @@ -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'])