2
0
Fork 0

feat: expose User "roles" for editing

This commit is contained in:
Lance Edgar 2024-08-13 21:43:56 -05:00
parent bdfa0197b2
commit 97e914c2e0
13 changed files with 492 additions and 39 deletions

View file

@ -120,6 +120,13 @@ class Form:
See also :meth:`set_validator()`. 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 .. attribute:: readonly
Boolean indicating the form does not allow submit. In practice Boolean indicating the form does not allow submit. In practice
@ -248,6 +255,7 @@ class Form:
nodes={}, nodes={},
widgets={}, widgets={},
validators={}, validators={},
defaults={},
readonly=False, readonly=False,
readonly_fields=[], readonly_fields=[],
required_fields={}, required_fields={},
@ -271,6 +279,7 @@ class Form:
self.nodes = nodes or {} self.nodes = nodes or {}
self.widgets = widgets or {} self.widgets = widgets or {}
self.validators = validators or {} self.validators = validators or {}
self.defaults = defaults or {}
self.readonly = readonly self.readonly = readonly
self.readonly_fields = set(readonly_fields or []) self.readonly_fields = set(readonly_fields or [])
self.required_fields = required_fields or {} self.required_fields = required_fields or {}
@ -375,6 +384,23 @@ class Form:
""" """
self.fields = FieldList(fields) 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): def remove(self, *keys):
""" """
Remove some fields(s) from the form. Remove some fields(s) from the form.
@ -471,6 +497,18 @@ class Form:
if self.schema and key in self.schema: if self.schema and key in self.schema:
self.schema[key].validator = validator 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): def set_readonly(self, key, readonly=True):
""" """
Enable or disable the "readonly" flag for a given field. Enable or disable the "readonly" flag for a given field.
@ -624,30 +662,17 @@ class Form:
if self.model_class: if self.model_class:
# first define full list of 'includes' - final schema # collect list of field names and/or nodes
# should contain all of these fields includes = []
includes = list(fields) for key in fields:
# determine which we want ColanderAlchemy to handle
auto_includes = []
for key in includes:
# skip if we already have a node defined
if key in self.nodes: if key in self.nodes:
continue includes.append(self.nodes[key])
else:
# we want the magic for this field includes.append(key)
auto_includes.append(key)
# make initial schema with ColanderAlchemy magic # make initial schema with ColanderAlchemy magic
schema = SQLAlchemySchemaNode(self.model_class, schema = SQLAlchemySchemaNode(self.model_class,
includes=auto_includes) includes=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)
else: else:
@ -685,6 +710,11 @@ class Form:
elif key in schema: # field-level elif key in schema: # field-level
schema[key].validator = validator 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 # apply required flags
for key, required in self.required_fields.items(): for key, required in self.required_fields.items():
if key in schema: if key in schema:

View file

@ -257,3 +257,44 @@ class PersonRef(ObjectRef):
def sort_query(self, query): def sort_query(self, query):
""" """ """ """
return query.order_by(self.model_class.full_name) 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)

View file

@ -32,11 +32,16 @@ in the namespace:
* :class:`deform:deform.widget.TextInputWidget` * :class:`deform:deform.widget.TextInputWidget`
* :class:`deform:deform.widget.TextAreaWidget` * :class:`deform:deform.widget.TextAreaWidget`
* :class:`deform:deform.widget.SelectWidget` * :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 webhelpers2.html import HTML
from wuttaweb.db import Session
class ObjectRefWidget(SelectWidget): class ObjectRefWidget(SelectWidget):
""" """
@ -96,3 +101,45 @@ class NotesWidget(TextAreaWidget):
* ``readonly/notes`` * ``readonly/notes``
""" """
readonly_template = '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)

View file

@ -186,6 +186,23 @@ class Grid:
""" """
self.columns = FieldList(columns) 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): def remove(self, *keys):
""" """
Remove some column(s) from the grid. Remove some column(s) from the grid.

View file

@ -0,0 +1,18 @@
<div tal:define="css_class css_class|field.widget.css_class;
style style|field.widget.style;
oid oid|field.oid;">
${field.start_sequence()}
<div style="display: flex; flex-direction: column; gap: 0.5rem;">
<div tal:repeat="choice values | field.widget.values"
tal:omit-tag="">
<b-checkbox tal:define="(value, title) choice"
name="checkbox"
native-value="${value}"
tal:attributes=":value 'true' if value in cstruct else 'false';
attributes|field.widget.attributes|{};">
${title}
</b-checkbox>
</div>
</div>
${field.end_sequence()}
</div>

View file

@ -28,6 +28,8 @@ import importlib
import json import json
import logging import logging
import sqlalchemy as sa
import colander import colander
from webhelpers2.html import HTML, tags 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 that to determine the field listing if applicable. Otherwise this
returns ``None``. returns ``None``.
""" """
if model_class: if not model_class:
import sqlalchemy as sa return
app = config.get_app()
model = app.model app = config.get_app()
if model_class and issubclass(model_class, model.Base): model = app.model
mapper = sa.inspect(model_class) if not issubclass(model_class, model.Base):
fields = list([prop.key for prop in mapper.iterate_properties]) return
return fields
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): def make_json_safe(value, key=None, warn=True):

View file

@ -28,7 +28,7 @@ import colander
from wuttjamaican.db.model import User from wuttjamaican.db.model import User
from wuttaweb.views import MasterView from wuttaweb.views import MasterView
from wuttaweb.forms.schema import PersonRef from wuttaweb.forms.schema import PersonRef, RoleRefs
from wuttaweb.db import Session from wuttaweb.db import Session
@ -77,10 +77,10 @@ class UserView(MasterView):
def configure_form(self, f): def configure_form(self, f):
""" """ """ """
super().configure_form(f) super().configure_form(f)
user = f.model_instance
# never show these # never show these
f.remove('person_uuid', f.remove('person_uuid',
'password',
'role_refs') 'role_refs')
# person # person
@ -90,6 +90,18 @@ class UserView(MasterView):
# username # username
f.set_validator('username', self.unique_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): def unique_username(self, node, value):
""" """ """ """
model = self.app.model model = self.app.model
@ -105,6 +117,62 @@ class UserView(MasterView):
if query.count(): if query.count():
node.raise_invalid("Username must be unique") 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): def defaults(config, **kwargs):
base = globals() base = globals()

View file

@ -84,6 +84,12 @@ class TestForm(TestCase):
form.set_fields(['baz']) form.set_fields(['baz'])
self.assertEqual(form.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): def test_remove(self):
form = self.make_form(fields=['one', 'two', 'three', 'four']) form = self.make_form(fields=['one', 'two', 'three', 'four'])
self.assertEqual(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(form.validators['foo'], validate2)
self.assertIs(schema['foo'].validator, 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): def test_get_schema(self):
model = self.app.model model = self.app.model
form = self.make_form() form = self.make_form()
@ -233,6 +247,12 @@ class TestForm(TestCase):
schema = form.get_schema() schema = form.get_schema()
self.assertIs(schema.validator, validate) 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): def test_get_deform(self):
model = self.app.model model = self.app.model
schema = self.make_schema() schema = self.make_schema()
@ -422,6 +442,19 @@ class TestForm(TestCase):
# nb. no error message # nb. no error message
self.assertNotIn('message', html) 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): def test_get_field_errors(self):
schema = self.make_schema() schema = self.make_schema()
form = self.make_form(schema=schema) form = self.make_form(schema=schema)

View file

@ -197,3 +197,27 @@ class TestPersonRef(DataTestCase):
sorted_query = typ.sort_query(query) sorted_query = typ.sort_query(query)
self.assertIsInstance(sorted_query, orm.Query) self.assertIsInstance(sorted_query, orm.Query)
self.assertIsNot(sorted_query, 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")

View file

@ -4,8 +4,8 @@ import colander
import deform import deform
from pyramid import testing from pyramid import testing
from wuttaweb.forms import widgets from wuttaweb.forms import widgets as mod
from wuttaweb.forms.schema import PersonRef from wuttaweb.forms.schema import PersonRef, RoleRefs
from tests.util import WebTestCase from tests.util import WebTestCase
@ -25,7 +25,7 @@ class TestObjectRefWidget(WebTestCase):
# standard (editable) # standard (editable)
node = colander.SchemaNode(PersonRef(self.request, session=self.session)) node = colander.SchemaNode(PersonRef(self.request, session=self.session))
widget = widgets.ObjectRefWidget(self.request) widget = mod.ObjectRefWidget(self.request)
field = self.make_field(node) field = self.make_field(node)
html = widget.serialize(field, person.uuid) html = widget.serialize(field, person.uuid)
self.assertIn('<b-select ', html) self.assertIn('<b-select ', html)
@ -33,7 +33,45 @@ class TestObjectRefWidget(WebTestCase):
# readonly # readonly
node = colander.SchemaNode(PersonRef(self.request, session=self.session)) node = colander.SchemaNode(PersonRef(self.request, session=self.session))
node.model_instance = person node.model_instance = person
widget = widgets.ObjectRefWidget(self.request) widget = mod.ObjectRefWidget(self.request)
field = self.make_field(node) field = self.make_field(node)
html = widget.serialize(field, person.uuid, readonly=True) html = widget.serialize(field, person.uuid, readonly=True)
self.assertEqual(html.strip(), '<span>Betty Boop</span>') self.assertEqual(html.strip(), '<span>Betty Boop</span>')
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)

View file

@ -69,6 +69,12 @@ class TestGrid(TestCase):
self.assertEqual(grid.columns, ['name', 'value']) self.assertEqual(grid.columns, ['name', 'value'])
self.assertEqual(grid.get_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): def test_remove(self):
grid = self.make_grid(columns=['one', 'two', 'three', 'four']) grid = self.make_grid(columns=['one', 'two', 'three', 'four'])
self.assertEqual(grid.columns, ['one', 'two', 'three', 'four']) self.assertEqual(grid.columns, ['one', 'two', 'three', 'four'])

View file

@ -442,6 +442,14 @@ class TestGetModelFields(TestCase):
self.config = WuttaConfig() self.config = WuttaConfig()
self.app = self.config.get_app() 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): def test_basic(self):
model = self.app.model model = self.app.model
fields = util.get_model_fields(self.config, model.Setting) fields = util.get_model_fields(self.config, model.Setting)

View file

@ -30,11 +30,29 @@ class TestUserView(WebTestCase):
def test_configure_form(self): def test_configure_form(self):
model = self.app.model model = self.app.model
barney = model.User(username='barney')
self.session.add(barney)
self.session.commit()
view = self.make_view() view = self.make_view()
form = view.make_form(model_class=model.Person)
self.assertIsNone(form.is_required('person')) # person is *not* required
view.configure_form(form) with patch.object(view, 'creating', new=True):
self.assertFalse(form.is_required('person')) 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): def test_unique_username(self):
model = self.app.model model = self.app.model
@ -55,3 +73,103 @@ class TestUserView(WebTestCase):
self.request.matchdict = {'uuid': user.uuid} self.request.matchdict = {'uuid': user.uuid}
node = colander.SchemaNode(colander.String(), name='username') node = colander.SchemaNode(colander.String(), name='username')
self.assertIsNone(view.unique_username(node, 'foo')) 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)