diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e4412d..927cbdc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,16 +5,6 @@ All notable changes to wuttaweb will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). -## v0.21.0 (2025-02-01) - -### Feat - -- overhaul some User/Person form fields etc. - -### Fix - -- do not auto-create grid filters for uuid columns - ## v0.20.6 (2025-01-26) ### Fix diff --git a/pyproject.toml b/pyproject.toml index 8d9a55e..5602b7b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "WuttaWeb" -version = "0.21.0" +version = "0.20.6" description = "Web App for Wutta Framework" readme = "README.md" authors = [{name = "Lance Edgar", email = "lance@wuttaproject.org"}] @@ -44,7 +44,7 @@ dependencies = [ "pyramid_tm", "waitress", "WebHelpers2", - "WuttJamaican[db]>=0.20.4", + "WuttJamaican[db]>=0.20.2", "zope.sqlalchemy>=1.5", ] diff --git a/src/wuttaweb/forms/schema.py b/src/wuttaweb/forms/schema.py index 605b2c5..77e443e 100644 --- a/src/wuttaweb/forms/schema.py +++ b/src/wuttaweb/forms/schema.py @@ -572,6 +572,28 @@ class RoleRefs(WuttaSet): return widgets.RoleRefsWidget(self.request, **kwargs) +class UserRefs(WuttaSet): + """ + Form schema type for the Role + :attr:`~wuttjamaican:wuttjamaican.db.model.auth.Role.users` + association proxy field. + + This is a subclass of :class:`WuttaSet`. It uses a ``set`` of + :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` ``uuid`` + values for underlying data format. + """ + + def widget_maker(self, **kwargs): + """ + Constructs a default widget for the field. + + :returns: Instance of + :class:`~wuttaweb.forms.widgets.UserRefsWidget`. + """ + kwargs.setdefault('session', Session()) + return widgets.UserRefsWidget(self.request, **kwargs) + + class Permissions(WuttaSet): """ Form schema type for the Role diff --git a/src/wuttaweb/forms/widgets.py b/src/wuttaweb/forms/widgets.py index 03fe717..f87e8b1 100644 --- a/src/wuttaweb/forms/widgets.py +++ b/src/wuttaweb/forms/widgets.py @@ -56,6 +56,7 @@ from webhelpers2.html import HTML from wuttjamaican.conf import parse_list from wuttaweb.db import Session +from wuttaweb.grids import Grid class ObjectRefWidget(SelectWidget): @@ -413,6 +414,63 @@ class RoleRefsWidget(WuttaCheckboxChoiceWidget): return super().serialize(field, cstruct, **kw) +class UserRefsWidget(WuttaCheckboxChoiceWidget): + """ + Widget for use with Role + :attr:`~wuttjamaican:wuttjamaican.db.model.auth.Role.users` field. + This is the default widget for the + :class:`~wuttaweb.forms.schema.UserRefs` type. + + This is a subclass of :class:`WuttaCheckboxChoiceWidget`; however + it only supports readonly mode and does not use a template. + Rather, it generates and renders a + :class:`~wuttaweb.grids.base.Grid` showing the users list. + """ + + def serialize(self, field, cstruct, **kw): + """ """ + readonly = kw.get('readonly', self.readonly) + if not readonly: + raise NotImplementedError("edit not allowed for this widget") + + model = self.app.model + columns = ['username', 'active'] + + # generate data set for users + users = [] + if cstruct: + for uuid in cstruct: + user = self.session.get(model.User, uuid) + if user: + users.append(dict([(key, getattr(user, key)) + for key in columns + ['uuid']])) + + # do not render if no data + if not users: + return HTML.tag('span') + + # grid + grid = Grid(self.request, key='roles.view.users', + columns=columns, data=users) + + # view action + if self.request.has_perm('users.view'): + url = lambda user, i: self.request.route_url('users.view', uuid=user['uuid']) + grid.add_action('view', icon='eye', url=url) + grid.set_link('person') + grid.set_link('username') + + # edit action + if self.request.has_perm('users.edit'): + url = lambda user, i: self.request.route_url('users.edit', uuid=user['uuid']) + grid.add_action('edit', url=url) + + # render as simple + # nb. must indicate we are a part of this form + form = getattr(field.parent, 'wutta_form', None) + return grid.render_table_element(form) + + class PermissionsWidget(WuttaCheckboxChoiceWidget): """ Widget for use with Role diff --git a/src/wuttaweb/grids/base.py b/src/wuttaweb/grids/base.py index 44c2170..e69d876 100644 --- a/src/wuttaweb/grids/base.py +++ b/src/wuttaweb/grids/base.py @@ -41,7 +41,6 @@ from webhelpers2.html import HTML from wuttaweb.db import Session from wuttaweb.util import FieldList, get_model_fields, make_json_safe from wuttjamaican.util import UNSPECIFIED -from wuttjamaican.db.util import UUID from wuttaweb.grids.filters import default_sqlalchemy_filters, VerbNotSupported @@ -1137,15 +1136,14 @@ class Grid: def make_backend_filters(self, filters=None): """ - Make "automatic" backend filters for the grid. + Make backend filters for all columns in the grid. This is called by the constructor, if :attr:`filterable` is true. - For each "column" in the model class, this will call - :meth:`make_filter()` to add an automatic filter. However it - first checks the provided ``filters`` and will not override - any of those. + For each column in the grid, this checks the provided + ``filters`` and if the column is not yet in there, will call + :meth:`make_filter()` to add it. .. note:: @@ -1183,18 +1181,9 @@ class Grid: inspector = sa.inspect(self.model_class) for prop in inspector.column_attrs: - - # do not overwrite existing filters - if prop.key in filters: - continue - - # do not create filter for UUID field - if (len(prop.columns) == 1 - and isinstance(prop.columns[0].type, UUID)): - continue - - attr = getattr(self.model_class, prop.key) - filters[prop.key] = self.make_filter(attr) + if prop.key not in filters: + attr = getattr(self.model_class, prop.key) + filters[prop.key] = self.make_filter(attr) return filters diff --git a/src/wuttaweb/views/people.py b/src/wuttaweb/views/people.py index c8ce87c..78cf931 100644 --- a/src/wuttaweb/views/people.py +++ b/src/wuttaweb/views/people.py @@ -28,6 +28,7 @@ import sqlalchemy as sa from wuttjamaican.db.model import Person from wuttaweb.views import MasterView +from wuttaweb.forms.schema import UserRefs class PersonView(MasterView): @@ -61,14 +62,6 @@ class PersonView(MasterView): 'full_name': {'active': True}, } - form_fields = [ - 'full_name', - 'first_name', - 'middle_name', - 'last_name', - 'users', - ] - def configure_grid(self, g): """ """ super().configure_grid(g) @@ -87,54 +80,20 @@ class PersonView(MasterView): super().configure_form(f) person = f.model_instance - # full_name - if self.creating or self.editing: - f.remove('full_name') + # TODO: master should handle these? (nullable column) + f.set_required('first_name', False) + f.set_required('middle_name', False) + f.set_required('last_name', False) # users - if self.viewing: - f.set_grid('users', self.make_users_grid(person)) - - def make_users_grid(self, person): - """ - Make and return the grid for the Users field. - - This grid is shown for the Users field when viewing a Person. - - :returns: Fully configured :class:`~wuttaweb.grids.base.Grid` - instance. - """ - model = self.app.model - route_prefix = self.get_route_prefix() - - grid = self.make_grid(key=f'{route_prefix}.view.users', - model_class=model.User, - data=person.users, - columns=[ - 'username', - 'active', - ]) - - if self.request.has_perm('users.view'): - url = lambda user, i: self.request.route_url('users.view', uuid=user.uuid) - grid.add_action('view', icon='eye', url=url) - grid.set_link('username') - - if self.request.has_perm('users.edit'): - url = lambda user, i: self.request.route_url('users.edit', uuid=user.uuid) - grid.add_action('edit', url=url) - - return grid - - def objectify(self, form): - """ """ - person = super().objectify(form) - - # full_name - person.full_name = self.app.make_full_name(person.first_name, - person.last_name) - - return person + # nb. colanderalchemy wants to do some magic for the true + # 'users' relationship, so we use a different field name + f.remove('users') + if not (self.creating or self.editing): + f.append('_users') + f.set_readonly('_users') + f.set_node('_users', UserRefs(self.request)) + f.set_default('_users', [u.uuid for u in person.users]) def autocomplete_query(self, term): """ """ diff --git a/src/wuttaweb/views/users.py b/src/wuttaweb/views/users.py index 760152d..5eac845 100644 --- a/src/wuttaweb/views/users.py +++ b/src/wuttaweb/views/users.py @@ -2,7 +2,7 @@ ################################################################################ # # wuttaweb -- Web App for Wutta Framework -# Copyright © 2024-2025 Lance Edgar +# Copyright © 2024 Lance Edgar # # This file is part of Wutta Framework. # @@ -30,6 +30,7 @@ from wuttjamaican.db.model import User from wuttaweb.views import MasterView from wuttaweb.forms import widgets from wuttaweb.forms.schema import PersonRef, RoleRefs +from wuttaweb.db import Session class UserView(MasterView): @@ -60,14 +61,6 @@ class UserView(MasterView): } sort_defaults = 'username' - form_fields = [ - 'username', - 'person', - 'active', - 'prevent_edit', - 'roles', - ] - def get_query(self, session=None): """ """ query = super().get_query(session=session) @@ -116,23 +109,16 @@ class UserView(MasterView): super().configure_form(f) user = f.model_instance - # username - f.set_validator('username', self.unique_username) + # never show these + f.remove('person_uuid', + 'role_refs') # person - if self.creating or self.editing: - f.fields.insert_after('person', 'first_name') - f.set_required('first_name', False) - f.fields.insert_after('first_name', 'last_name') - f.set_required('last_name', False) - f.remove('person') - if self.editing: - person = user.person - if person: - f.set_default('first_name', person.first_name) - f.set_default('last_name', person.last_name) - else: - f.set_node('person', PersonRef(self.request)) + f.set_node('person', PersonRef(self.request, empty_option=True)) + f.set_required('person', False) + + # username + f.set_validator('username', self.unique_username) # password # nb. we must avoid 'password' as field name since @@ -154,7 +140,7 @@ class UserView(MasterView): def unique_username(self, node, value): """ """ model = self.app.model - session = self.Session() + session = Session() query = session.query(model.User)\ .filter(model.User.username == value) @@ -166,48 +152,26 @@ class UserView(MasterView): if query.count(): node.raise_invalid("Username must be unique") - def objectify(self, form): + def objectify(self, form, session=None): """ """ - model = self.app.model - auth = self.app.get_auth_handler() data = form.validated # normal logic first user = super().objectify(form) - # maybe update person name - if 'first_name' in form or 'last_name' in form: - first_name = data.get('first_name') - last_name = data.get('last_name') - if self.creating and (first_name or last_name): - user.person = auth.make_person(first_name=first_name, last_name=last_name) - elif self.editing: - if first_name or last_name: - if user.person: - person = user.person - if 'first_name' in form: - person.first_name = first_name - if 'last_name' in form: - person.last_name = last_name - person.full_name = self.app.make_full_name(person.first_name, - person.last_name) - else: - user.person = auth.make_person(first_name=first_name, last_name=last_name) - elif user.person: - user.person = None - # maybe set user password if 'set_password' in form and data.get('set_password'): + auth = self.app.get_auth_handler() auth.set_user_password(user, data['set_password']) # update roles for user # TODO # if self.has_perm('edit_roles'): - self.update_roles(user, form) + self.update_roles(user, form, session=session) return user - def update_roles(self, user, form): + def update_roles(self, user, form, session=None): """ """ # TODO # if not self.has_perm('edit_roles'): @@ -217,7 +181,7 @@ class UserView(MasterView): return model = self.app.model - session = self.Session() + session = session or Session() auth = self.app.get_auth_handler() old_roles = set([role.uuid for role in user.roles]) diff --git a/tests/forms/test_schema.py b/tests/forms/test_schema.py index 6914f61..3187161 100644 --- a/tests/forms/test_schema.py +++ b/tests/forms/test_schema.py @@ -377,6 +377,20 @@ class TestUserRef(WebTestCase): self.assertIn(f'/users/{user.uuid}', url) +class TestUserRefs(DataTestCase): + + def setUp(self): + self.setup_db() + self.request = testing.DummyRequest(wutta_config=self.config) + + def test_widget_maker(self): + model = self.app.model + with patch.object(mod, 'Session', return_value=self.session): + typ = mod.UserRefs(self.request) + widget = typ.widget_maker() + self.assertIsInstance(widget, widgets.UserRefsWidget) + + class TestRoleRefs(DataTestCase): def setUp(self): diff --git a/tests/forms/test_widgets.py b/tests/forms/test_widgets.py index cb42446..e571b88 100644 --- a/tests/forms/test_widgets.py +++ b/tests/forms/test_widgets.py @@ -11,7 +11,7 @@ from pyramid import testing from wuttaweb import grids from wuttaweb.forms import widgets as mod from wuttaweb.forms import schema -from wuttaweb.forms.schema import (FileDownload, PersonRef, RoleRefs, Permissions, +from wuttaweb.forms.schema import (FileDownload, PersonRef, RoleRefs, UserRefs, Permissions, WuttaDateTime, EmailRecipients) from wuttaweb.testing import WebTestCase @@ -300,6 +300,51 @@ class TestRoleRefsWidget(WebTestCase): self.assertIn(str(blokes.uuid.hex), html) +class TestUserRefsWidget(WebTestCase): + + def make_field(self, node, **kwargs): + # TODO: not sure why default renderer is in use even though + # pyramid_deform was included in setup? but this works.. + kwargs.setdefault('renderer', deform.Form.default_renderer) + return deform.Field(node, **kwargs) + + def test_serialize(self): + model = self.app.model + + # nb. we let the field construct the widget via our type + # node = colander.SchemaNode(UserRefs(self.request, session=self.session)) + with patch.object(schema, 'Session', return_value=self.session): + node = colander.SchemaNode(UserRefs(self.request)) + field = self.make_field(node) + widget = field.widget + + # readonly is required + self.assertRaises(NotImplementedError, widget.serialize, field, set()) + self.assertRaises(NotImplementedError, widget.serialize, field, set(), readonly=False) + + # empty + html = widget.serialize(field, set(), readonly=True) + self.assertEqual(html, '') + + # with data, no actions + user = model.User(username='barney') + self.session.add(user) + self.session.commit() + html = widget.serialize(field, {user.uuid}, readonly=True) + self.assertIn('