From 97e914c2e047a9d4401ebe2fc807899a76aab8de Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Tue, 13 Aug 2024 21:43:56 -0500 Subject: [PATCH] feat: expose User "roles" for editing --- src/wuttaweb/forms/base.py | 70 +++++++--- src/wuttaweb/forms/schema.py | 41 ++++++ src/wuttaweb/forms/widgets.py | 49 ++++++- src/wuttaweb/grids/base.py | 17 +++ .../templates/deform/checkbox_choice.pt | 18 +++ src/wuttaweb/util.py | 21 +-- src/wuttaweb/views/users.py | 72 +++++++++- tests/forms/test_base.py | 33 +++++ tests/forms/test_schema.py | 24 ++++ tests/forms/test_widgets.py | 46 ++++++- tests/grids/test_base.py | 6 + tests/test_util.py | 8 ++ tests/views/test_users.py | 126 +++++++++++++++++- 13 files changed, 492 insertions(+), 39 deletions(-) create mode 100644 src/wuttaweb/templates/deform/checkbox_choice.pt diff --git a/src/wuttaweb/forms/base.py b/src/wuttaweb/forms/base.py index 7ee9b01..c667468 100644 --- a/src/wuttaweb/forms/base.py +++ b/src/wuttaweb/forms/base.py @@ -120,6 +120,13 @@ class Form: See also :meth:`set_validator()`. + .. attribute:: defaults + + Dict of default field values, used to construct the form in + :meth:`get_schema()`. + + See also :meth:`set_default()`. + .. attribute:: readonly Boolean indicating the form does not allow submit. In practice @@ -248,6 +255,7 @@ class Form: nodes={}, widgets={}, validators={}, + defaults={}, readonly=False, readonly_fields=[], required_fields={}, @@ -271,6 +279,7 @@ class Form: self.nodes = nodes or {} self.widgets = widgets or {} self.validators = validators or {} + self.defaults = defaults or {} self.readonly = readonly self.readonly_fields = set(readonly_fields or []) self.required_fields = required_fields or {} @@ -375,6 +384,23 @@ class Form: """ self.fields = FieldList(fields) + def append(self, *keys): + """ + Add some fields(s) to the form. + + This is a convenience to allow adding multiple fields at + once:: + + form.append('first_field', + 'second_field', + 'third_field') + + It will add each field to :attr:`fields`. + """ + for key in keys: + if key not in self.fields: + self.fields.append(key) + def remove(self, *keys): """ Remove some fields(s) from the form. @@ -471,6 +497,18 @@ class Form: if self.schema and key in self.schema: self.schema[key].validator = validator + def set_default(self, key, value): + """ + Set/override the default value for a field. + + :param key: Name of field. + + :param validator: Default value for the field. + + Default value overrides are tracked via :attr:`defaults`. + """ + self.defaults[key] = value + def set_readonly(self, key, readonly=True): """ Enable or disable the "readonly" flag for a given field. @@ -624,30 +662,17 @@ class Form: if self.model_class: - # first define full list of 'includes' - final schema - # should contain all of these fields - includes = list(fields) - - # determine which we want ColanderAlchemy to handle - auto_includes = [] - for key in includes: - - # skip if we already have a node defined + # collect list of field names and/or nodes + includes = [] + for key in fields: if key in self.nodes: - continue - - # we want the magic for this field - auto_includes.append(key) + includes.append(self.nodes[key]) + else: + includes.append(key) # make initial schema with ColanderAlchemy magic schema = SQLAlchemySchemaNode(self.model_class, - includes=auto_includes) - - # now fill in the blanks for non-magic fields - for key in includes: - if key not in auto_includes: - node = self.nodes[key] - schema.add(node) + includes=includes) else: @@ -685,6 +710,11 @@ class Form: elif key in schema: # field-level schema[key].validator = validator + # apply default value overrides + for key, value in self.defaults.items(): + if key in schema: + schema[key].default = value + # apply required flags for key, required in self.required_fields.items(): if key in schema: diff --git a/src/wuttaweb/forms/schema.py b/src/wuttaweb/forms/schema.py index ccb357f..dd46fb3 100644 --- a/src/wuttaweb/forms/schema.py +++ b/src/wuttaweb/forms/schema.py @@ -257,3 +257,44 @@ class PersonRef(ObjectRef): def sort_query(self, query): """ """ return query.order_by(self.model_class.full_name) + + +class RoleRefs(colander.Set): + """ + Form schema type for the User + :attr:`~wuttjamaican:wuttjamaican.db.model.auth.User.roles` + association proxy field. + """ + + def __init__(self, request, session=None, *args, **kwargs): + super().__init__(*args, **kwargs) + self.request = request + self.config = self.request.wutta_config + self.app = self.config.get_app() + self.session = session or Session() + + def widget_maker(self, **kwargs): + """ + Constructs a default widget for the field. + + :returns: Instance of + :class:`~wuttaweb.forms.widgets.RoleRefsWidget`. + """ + kwargs.setdefault('session', self.session) + + if 'values' not in kwargs: + model = self.app.model + auth = self.app.get_auth_handler() + avoid = { + auth.get_role_authenticated(self.session), + auth.get_role_anonymous(self.session), + } + avoid = set([role.uuid for role in avoid]) + roles = self.session.query(model.Role)\ + .filter(~model.Role.uuid.in_(avoid))\ + .order_by(model.Role.name)\ + .all() + values = [(role.uuid, role.name) for role in roles] + kwargs['values'] = values + + return widgets.RoleRefsWidget(self.request, **kwargs) diff --git a/src/wuttaweb/forms/widgets.py b/src/wuttaweb/forms/widgets.py index bdd7666..f5c1523 100644 --- a/src/wuttaweb/forms/widgets.py +++ b/src/wuttaweb/forms/widgets.py @@ -32,11 +32,16 @@ in the namespace: * :class:`deform:deform.widget.TextInputWidget` * :class:`deform:deform.widget.TextAreaWidget` * :class:`deform:deform.widget.SelectWidget` +* :class:`deform:deform.widget.CheckboxChoiceWidget` """ -from deform.widget import Widget, TextInputWidget, TextAreaWidget, SelectWidget +import colander +from deform.widget import (Widget, TextInputWidget, TextAreaWidget, + SelectWidget, CheckboxChoiceWidget) from webhelpers2.html import HTML +from wuttaweb.db import Session + class ObjectRefWidget(SelectWidget): """ @@ -96,3 +101,45 @@ class NotesWidget(TextAreaWidget): * ``readonly/notes`` """ readonly_template = 'readonly/notes' + + +class RoleRefsWidget(CheckboxChoiceWidget): + """ + Widget for use with User + :attr:`~wuttjamaican:wuttjamaican.db.model.auth.User.roles` field. + + This is a subclass of + :class:`deform:deform.widget.CheckboxChoiceWidget` and uses these + Deform templates: + + * ``checkbox_choice`` + * ``readonly/checkbox_choice`` + """ + + def __init__(self, request, session=None, *args, **kwargs): + super().__init__(*args, **kwargs) + self.request = request + self.config = self.request.wutta_config + self.app = self.config.get_app() + self.session = session or Session() + + def serialize(self, field, cstruct, **kw): + """ """ + # special logic when field is editable + readonly = kw.get('readonly', self.readonly) + if not readonly: + + # but does not apply if current user is root + if not self.request.is_root: + auth = self.app.get_auth_handler() + admin = auth.get_role_administrator(self.session) + + # prune admin role from values list; it should not be + # one of the options since current user is not admin + values = kw.get('values', self.values) + values = [val for val in values + if val[0] != admin.uuid] + kw['values'] = values + + # default logic from here + return super().serialize(field, cstruct, **kw) diff --git a/src/wuttaweb/grids/base.py b/src/wuttaweb/grids/base.py index c0738f7..5d3f94b 100644 --- a/src/wuttaweb/grids/base.py +++ b/src/wuttaweb/grids/base.py @@ -186,6 +186,23 @@ class Grid: """ self.columns = FieldList(columns) + def append(self, *keys): + """ + Add some columns(s) to the grid. + + This is a convenience to allow adding multiple columns at + once:: + + grid.append('first_field', + 'second_field', + 'third_field') + + It will add each column to :attr:`columns`. + """ + for key in keys: + if key not in self.columns: + self.columns.append(key) + def remove(self, *keys): """ Remove some column(s) from the grid. diff --git a/src/wuttaweb/templates/deform/checkbox_choice.pt b/src/wuttaweb/templates/deform/checkbox_choice.pt new file mode 100644 index 0000000..f09b373 --- /dev/null +++ b/src/wuttaweb/templates/deform/checkbox_choice.pt @@ -0,0 +1,18 @@ +
+ ${field.start_sequence()} +
+
+ + ${title} + +
+
+ ${field.end_sequence()} +
diff --git a/src/wuttaweb/util.py b/src/wuttaweb/util.py index 36942c3..02b5bcd 100644 --- a/src/wuttaweb/util.py +++ b/src/wuttaweb/util.py @@ -28,6 +28,8 @@ import importlib import json import logging +import sqlalchemy as sa + import colander from webhelpers2.html import HTML, tags @@ -420,14 +422,17 @@ def get_model_fields(config, model_class=None): that to determine the field listing if applicable. Otherwise this returns ``None``. """ - if model_class: - import sqlalchemy as sa - app = config.get_app() - model = app.model - if model_class and issubclass(model_class, model.Base): - mapper = sa.inspect(model_class) - fields = list([prop.key for prop in mapper.iterate_properties]) - return fields + if not model_class: + return + + app = config.get_app() + model = app.model + if not issubclass(model_class, model.Base): + return + + mapper = sa.inspect(model_class) + fields = [prop.key for prop in mapper.iterate_properties] + return fields def make_json_safe(value, key=None, warn=True): diff --git a/src/wuttaweb/views/users.py b/src/wuttaweb/views/users.py index 124aa59..6dd1a8f 100644 --- a/src/wuttaweb/views/users.py +++ b/src/wuttaweb/views/users.py @@ -28,7 +28,7 @@ import colander from wuttjamaican.db.model import User from wuttaweb.views import MasterView -from wuttaweb.forms.schema import PersonRef +from wuttaweb.forms.schema import PersonRef, RoleRefs from wuttaweb.db import Session @@ -77,10 +77,10 @@ class UserView(MasterView): def configure_form(self, f): """ """ super().configure_form(f) + user = f.model_instance # never show these f.remove('person_uuid', - 'password', 'role_refs') # person @@ -90,6 +90,18 @@ class UserView(MasterView): # username f.set_validator('username', self.unique_username) + # password + # if not (self.creating or self.editing): + # f.remove('password') + f.remove('password') + + # roles + f.append('roles') + f.set_node('roles', RoleRefs(self.request)) + + if not self.creating: + f.set_default('roles', [role.uuid for role in user.roles]) + def unique_username(self, node, value): """ """ model = self.app.model @@ -105,6 +117,62 @@ class UserView(MasterView): if query.count(): node.raise_invalid("Username must be unique") + def objectify(self, form, session=None): + """ """ + # normal logic first + user = super().objectify(form) + + # update roles for user + # TODO + # if self.has_perm('edit_roles'): + self.update_roles(user, form, session=session) + + return user + + def update_roles(self, user, form, session=None): + """ """ + # TODO + # if not self.has_perm('edit_roles'): + # return + data = form.validated + if 'roles' not in data: + return + + model = self.app.model + session = session or Session() + auth = self.app.get_auth_handler() + + old_roles = set([role.uuid for role in user.roles]) + new_roles = data['roles'] + + admin = auth.get_role_administrator(session) + ignored = { + auth.get_role_authenticated(session).uuid, + auth.get_role_anonymous(session).uuid, + } + + # add any new roles for the user, taking care to avoid certain + # unwanted operations for built-in roles + for uuid in new_roles: + if uuid in ignored: + continue + if uuid in old_roles: + continue + if uuid == admin.uuid and not self.request.is_root: + continue + role = session.get(model.Role, uuid) + user.roles.append(role) + + # remove any roles which were *not* specified, taking care to + # avoid certain unwanted operations for built-in roles + for uuid in old_roles: + if uuid in new_roles: + continue + if uuid == admin.uuid and not self.request.is_root: + continue + role = session.get(model.Role, uuid) + user.roles.remove(role) + def defaults(config, **kwargs): base = globals() diff --git a/tests/forms/test_base.py b/tests/forms/test_base.py index b3aade2..86b411a 100644 --- a/tests/forms/test_base.py +++ b/tests/forms/test_base.py @@ -84,6 +84,12 @@ class TestForm(TestCase): form.set_fields(['baz']) self.assertEqual(form.fields, ['baz']) + def test_append(self): + form = self.make_form(fields=['one', 'two']) + self.assertEqual(form.fields, ['one', 'two']) + form.append('one', 'two', 'three') + self.assertEqual(form.fields, ['one', 'two', 'three']) + def test_remove(self): form = self.make_form(fields=['one', 'two', 'three', 'four']) self.assertEqual(form.fields, ['one', 'two', 'three', 'four']) @@ -157,6 +163,14 @@ class TestForm(TestCase): self.assertIs(form.validators['foo'], validate2) self.assertIs(schema['foo'].validator, validate2) + def test_set_default(self): + form = self.make_form(fields=['foo', 'bar']) + self.assertEqual(form.defaults, {}) + + # basic + form.set_default('foo', 42) + self.assertEqual(form.defaults['foo'], 42) + def test_get_schema(self): model = self.app.model form = self.make_form() @@ -233,6 +247,12 @@ class TestForm(TestCase): schema = form.get_schema() self.assertIs(schema.validator, validate) + # default value overrides are honored + form = self.make_form(model_class=model.Setting) + form.set_default('name', 'foo') + schema = form.get_schema() + self.assertEqual(schema['name'].default, 'foo') + def test_get_deform(self): model = self.app.model schema = self.make_schema() @@ -422,6 +442,19 @@ class TestForm(TestCase): # nb. no error message self.assertNotIn('message', html) + def test_get_vue_model_data(self): + schema = self.make_schema() + form = self.make_form(schema=schema) + + # 2 fields by default (foo, bar) + data = form.get_vue_model_data() + self.assertEqual(len(data), 2) + + # still just 2 fields even if we request more + form.set_fields(['foo', 'bar', 'baz']) + data = form.get_vue_model_data() + self.assertEqual(len(data), 2) + def test_get_field_errors(self): schema = self.make_schema() form = self.make_form(schema=schema) diff --git a/tests/forms/test_schema.py b/tests/forms/test_schema.py index 3d2492b..02d69ce 100644 --- a/tests/forms/test_schema.py +++ b/tests/forms/test_schema.py @@ -197,3 +197,27 @@ class TestPersonRef(DataTestCase): sorted_query = typ.sort_query(query) self.assertIsInstance(sorted_query, orm.Query) self.assertIsNot(sorted_query, query) + + +class TestRoleRefs(DataTestCase): + + def setUp(self): + self.setup_db() + self.request = testing.DummyRequest(wutta_config=self.config) + + def test_widget_maker(self): + model = self.app.model + auth = self.app.get_auth_handler() + admin = auth.get_role_administrator(self.session) + authed = auth.get_role_authenticated(self.session) + anon = auth.get_role_anonymous(self.session) + blokes = model.Role(name="Blokes") + self.session.add(blokes) + self.session.commit() + + # default values for widget includes all but: authed, anon + typ = mod.RoleRefs(self.request, session=self.session) + widget = typ.widget_maker() + self.assertEqual(len(widget.values), 2) + self.assertEqual(widget.values[0][1], "Administrator") + self.assertEqual(widget.values[1][1], "Blokes") diff --git a/tests/forms/test_widgets.py b/tests/forms/test_widgets.py index d359030..e2ed0e7 100644 --- a/tests/forms/test_widgets.py +++ b/tests/forms/test_widgets.py @@ -4,8 +4,8 @@ import colander import deform from pyramid import testing -from wuttaweb.forms import widgets -from wuttaweb.forms.schema import PersonRef +from wuttaweb.forms import widgets as mod +from wuttaweb.forms.schema import PersonRef, RoleRefs from tests.util import WebTestCase @@ -25,7 +25,7 @@ class TestObjectRefWidget(WebTestCase): # standard (editable) node = colander.SchemaNode(PersonRef(self.request, session=self.session)) - widget = widgets.ObjectRefWidget(self.request) + widget = mod.ObjectRefWidget(self.request) field = self.make_field(node) html = widget.serialize(field, person.uuid) self.assertIn('Betty Boop') + + +class TestRoleRefsWidget(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 + auth = self.app.get_auth_handler() + admin = auth.get_role_administrator(self.session) + blokes = model.Role(name="Blokes") + self.session.add(blokes) + self.session.commit() + + # nb. we let the field construct the widget via our type + node = colander.SchemaNode(RoleRefs(self.request, session=self.session)) + field = self.make_field(node) + widget = field.widget + + # readonly values list includes admin + html = widget.serialize(field, {admin.uuid, blokes.uuid}, readonly=True) + self.assertIn(admin.name, html) + self.assertIn(blokes.name, html) + + # editable values list *excludes* admin (by default) + html = widget.serialize(field, {admin.uuid, blokes.uuid}) + self.assertNotIn(admin.uuid, html) + self.assertIn(blokes.uuid, html) + + # but admin is included for root user + self.request.is_root = True + html = widget.serialize(field, {admin.uuid, blokes.uuid}) + self.assertIn(admin.uuid, html) + self.assertIn(blokes.uuid, html) diff --git a/tests/grids/test_base.py b/tests/grids/test_base.py index 89c5ab0..79f37ae 100644 --- a/tests/grids/test_base.py +++ b/tests/grids/test_base.py @@ -69,6 +69,12 @@ class TestGrid(TestCase): self.assertEqual(grid.columns, ['name', 'value']) self.assertEqual(grid.get_columns(), ['name', 'value']) + def test_append(self): + grid = self.make_grid(columns=['one', 'two']) + self.assertEqual(grid.columns, ['one', 'two']) + grid.append('one', 'two', 'three') + self.assertEqual(grid.columns, ['one', 'two', 'three']) + def test_remove(self): grid = self.make_grid(columns=['one', 'two', 'three', 'four']) self.assertEqual(grid.columns, ['one', 'two', 'three', 'four']) diff --git a/tests/test_util.py b/tests/test_util.py index 6fd94b7..f0f04af 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -442,6 +442,14 @@ class TestGetModelFields(TestCase): self.config = WuttaConfig() self.app = self.config.get_app() + def test_empty_model_class(self): + fields = util.get_model_fields(self.config) + self.assertIsNone(fields) + + def test_unknown_model_class(self): + fields = util.get_model_fields(self.config, TestCase) + self.assertIsNone(fields) + def test_basic(self): model = self.app.model fields = util.get_model_fields(self.config, model.Setting) diff --git a/tests/views/test_users.py b/tests/views/test_users.py index ea67544..292297b 100644 --- a/tests/views/test_users.py +++ b/tests/views/test_users.py @@ -30,11 +30,29 @@ class TestUserView(WebTestCase): def test_configure_form(self): model = self.app.model + barney = model.User(username='barney') + self.session.add(barney) + self.session.commit() view = self.make_view() - form = view.make_form(model_class=model.Person) - self.assertIsNone(form.is_required('person')) - view.configure_form(form) - self.assertFalse(form.is_required('person')) + + # person is *not* required + with patch.object(view, 'creating', new=True): + form = view.make_form(model_class=model.User) + self.assertIsNone(form.is_required('person')) + view.configure_form(form) + self.assertFalse(form.is_required('person')) + + # password removed (always, for now) + with patch.object(view, 'viewing', new=True): + form = view.make_form(model_instance=barney) + self.assertIn('password', form) + view.configure_form(form) + self.assertNotIn('password', form) + with patch.object(view, 'editing', new=True): + form = view.make_form(model_instance=barney) + self.assertIn('password', form) + view.configure_form(form) + self.assertNotIn('password', form) def test_unique_username(self): model = self.app.model @@ -55,3 +73,103 @@ class TestUserView(WebTestCase): self.request.matchdict = {'uuid': user.uuid} node = colander.SchemaNode(colander.String(), name='username') self.assertIsNone(view.unique_username(node, 'foo')) + + def test_objectify(self): + model = self.app.model + blokes = model.Role(name="Blokes") + self.session.add(blokes) + others = model.Role(name="Others") + self.session.add(others) + barney = model.User(username='barney') + barney.roles.append(blokes) + self.session.add(barney) + self.session.commit() + view = self.make_view() + view.editing = True + self.request.matchdict = {'uuid': barney.uuid} + + # sanity check, user is just in 'blokes' role + self.session.refresh(barney) + self.assertEqual(len(barney.roles), 1) + self.assertEqual(barney.roles[0].name, "Blokes") + + # form can update user roles + form = view.make_model_form(model_instance=barney) + form.validated = {'username': 'barney', 'roles': {others.uuid}} + user = view.objectify(form, session=self.session) + self.assertIs(user, barney) + self.assertEqual(len(user.roles), 1) + self.assertEqual(user.roles[0].name, "Others") + + def test_update_roles(self): + model = self.app.model + auth = self.app.get_auth_handler() + admin = auth.get_role_administrator(self.session) + authed = auth.get_role_authenticated(self.session) + anon = auth.get_role_anonymous(self.session) + blokes = model.Role(name="Blokes") + self.session.add(blokes) + others = model.Role(name="Others") + self.session.add(others) + barney = model.User(username='barney') + barney.roles.append(blokes) + self.session.add(barney) + self.session.commit() + view = self.make_view() + view.editing = True + self.request.matchdict = {'uuid': barney.uuid} + + # no error if data is missing roles + form = view.make_model_form(model_instance=barney) + form.validated = {'username': 'barneyx'} + user = view.objectify(form, session=self.session) + self.assertIs(user, barney) + self.assertEqual(barney.username, 'barneyx') + + # sanity check, user is just in 'blokes' role + self.session.refresh(barney) + self.assertEqual(len(barney.roles), 1) + self.assertEqual(barney.roles[0].name, "Blokes") + + # let's test a bunch at once to ensure: + # - user roles are updated + # - authed / anon roles are not added + # - admin role not added if current user is not root + form = view.make_model_form(model_instance=barney) + form.validated = {'username': 'barney', + 'roles': {admin.uuid, authed.uuid, anon.uuid, others.uuid}} + user = view.objectify(form, session=self.session) + self.assertIs(user, barney) + self.assertEqual(len(user.roles), 1) + self.assertEqual(user.roles[0].name, "Others") + + # let's test a bunch at once to ensure: + # - user roles are updated + # - admin role is added if current user is root + self.request.is_root = True + form = view.make_model_form(model_instance=barney) + form.validated = {'username': 'barney', + 'roles': {admin.uuid, blokes.uuid, others.uuid}} + user = view.objectify(form, session=self.session) + self.assertIs(user, barney) + self.assertEqual(len(user.roles), 3) + role_uuids = set([role.uuid for role in user.roles]) + self.assertEqual(role_uuids, {admin.uuid, blokes.uuid, others.uuid}) + + # admin role not removed if current user is not root + self.request.is_root = False + form = view.make_model_form(model_instance=barney) + form.validated = {'username': 'barney', + 'roles': {blokes.uuid, others.uuid}} + user = view.objectify(form, session=self.session) + self.assertIs(user, barney) + self.assertEqual(len(user.roles), 3) + + # admin role is removed if current user is root + self.request.is_root = True + form = view.make_model_form(model_instance=barney) + form.validated = {'username': 'barney', + 'roles': {blokes.uuid, others.uuid}} + user = view.objectify(form, session=self.session) + self.assertIs(user, barney) + self.assertEqual(len(user.roles), 2)