3
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()`.
.. 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:

View file

@ -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)

View file

@ -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)

View file

@ -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.

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 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):

View file

@ -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()