2
0
Fork 0

feat: improve page linkage between role/user/person

- show Users grid when viewing a Role
- add hyperlinks between things
This commit is contained in:
Lance Edgar 2024-08-21 14:38:34 -05:00
parent 9d261de45a
commit 770c4612d5
16 changed files with 440 additions and 21 deletions

View file

@ -25,6 +25,7 @@ Base form classes
"""
import logging
from collections import OrderedDict
import colander
import deform
@ -311,6 +312,9 @@ class Form:
self.set_fields(fields or self.get_fields())
# nb. this tracks grid JSON data for inclusion in page template
self.grid_vue_data = OrderedDict()
def __contains__(self, name):
"""
Custom logic for the ``in`` operator, to allow easily checking
@ -750,6 +754,10 @@ class Form:
kwargs['appstruct'] = self.model_instance
form = deform.Form(schema, **kwargs)
# nb. must give a reference back to wutta form; this is
# for sake of field schema nodes and widgets, e.g. to
# access the main model instance
form.wutta_form = self
self.deform_form = form
return self.deform_form
@ -818,6 +826,17 @@ class Form:
output = render(template, context)
return HTML.literal(output)
def add_grid_vue_data(self, grid):
""" """
if not grid.key:
raise ValueError("grid must have a key!")
if grid.key in self.grid_vue_data:
log.warning("grid data with key '%s' already registered, "
"but will be replaced", grid.key)
self.grid_vue_data[grid.key] = grid.get_vue_data()
def render_vue_field(
self,
fieldname,

View file

@ -246,6 +246,9 @@ class ObjectRef(colander.SchemaType):
values.insert(0, self.empty_option)
kwargs['values'] = values
if 'url' not in kwargs:
kwargs['url'] = lambda person: self.request.route_url('people.view', uuid=person.uuid)
return widgets.ObjectRefWidget(self.request, **kwargs)
@ -321,6 +324,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', self.session)
return widgets.UserRefsWidget(self.request, **kwargs)
class Permissions(WuttaSet):
"""
Form schema type for the Role

View file

@ -44,6 +44,7 @@ from deform.widget import (Widget, TextInputWidget, TextAreaWidget,
from webhelpers2.html import HTML
from wuttaweb.db import Session
from wuttaweb.grids import Grid
class ObjectRefWidget(SelectWidget):
@ -83,9 +84,19 @@ class ObjectRefWidget(SelectWidget):
"""
readonly_template = 'readonly/objectref'
def __init__(self, request, *args, **kwargs):
def __init__(self, request, url=None, *args, **kwargs):
super().__init__(*args, **kwargs)
self.request = request
self.url = url
def get_template_values(self, field, cstruct, kw):
""" """
values = super().get_template_values(field, cstruct, kw)
if 'url' not in values and self.url and field.schema.model_instance:
values['url'] = self.url(field.schema.model_instance)
return values
class NotesWidget(TextAreaWidget):
@ -137,12 +148,17 @@ class RoleRefsWidget(WuttaCheckboxChoiceWidget):
"""
Widget for use with User
:attr:`~wuttjamaican:wuttjamaican.db.model.auth.User.roles` field.
This is the default widget for the
:class:`~wuttaweb.forms.schema.RoleRefs` type.
This is a subclass of :class:`WuttaCheckboxChoiceWidget`.
"""
readonly_template = 'readonly/rolerefs'
def serialize(self, field, cstruct, **kw):
""" """
model = self.app.model
# special logic when field is editable
readonly = kw.get('readonly', self.readonly)
if not readonly:
@ -159,10 +175,78 @@ class RoleRefsWidget(WuttaCheckboxChoiceWidget):
if val[0] != admin.uuid]
kw['values'] = values
else: # readonly
# roles
roles = []
if cstruct:
for uuid in cstruct:
role = self.session.query(model.Role).get(uuid)
if role:
roles.append(role)
kw['roles'] = roles
# url
url = lambda role: self.request.route_url('roles.view', uuid=role.uuid)
kw['url'] = url
# default logic from here
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 = ['person', 'username', 'active']
# generate data set for users
users = []
if cstruct:
for uuid in cstruct:
user = self.session.query(model.User).get(uuid)
if user:
users.append(dict([(key, getattr(user, key))
for key in columns + ['uuid']]))
# 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 <b-table>
# 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

View file

@ -543,6 +543,13 @@ class Grid:
return True
return False
def add_action(self, key, **kwargs):
"""
Convenience to add a new :class:`GridAction` instance to the
grid's :attr:`actions` list.
"""
self.actions.append(GridAction(self.request, key, **kwargs))
##############################
# sorting methods
##############################
@ -1251,6 +1258,9 @@ class Grid:
"""
Render the Vue template block for the grid.
This is what you want for a "full-featured" grid which will
exist as its own unique Vue component on the frontend.
This returns something like:
.. code-block:: none
@ -1261,12 +1271,21 @@ class Grid:
</b-table>
</script>
<script>
WuttaGridData = {}
WuttaGrid = {
template: 'wutta-grid-template',
}
</script>
.. todo::
Why can't Sphinx render the above code block as 'html' ?
It acts like it can't handle a ``<script>`` tag at all?
See :meth:`render_table_element()` for a simpler variant.
Actual output will of course depend on grid attributes,
:attr:`vue_tagname` and :attr:`columns` etc.
@ -1278,6 +1297,58 @@ class Grid:
output = render(template, context)
return HTML.literal(output)
def render_table_element(
self,
form=None,
template='/grids/element.mako',
**context):
"""
Render a simple Vue table element for the grid.
This is what you want for a "simple" grid which does require a
unique Vue component, but can instead use the standard table
component.
This returns something like:
.. code-block:: html
<b-table :data="gridData['mykey']">
<!-- columns etc. -->
</b-table>
See :meth:`render_vue_template()` for a more complete variant.
Actual output will of course depend on grid attributes,
:attr:`key`, :attr:`columns` etc.
:param form: Reference to the
:class:`~wuttaweb.forms.base.Form` instance which
"contains" this grid. This is needed in order to ensure
the grid data is available to the form Vue component.
:param template: Path to Mako template which is used to render
the output.
.. note::
The above example shows ``gridData['mykey']`` as the Vue
data reference. This should "just work" if you provide the
correct ``form`` arg and the grid is contained directly by
that form's Vue component.
However, this may not account for all use cases. For now
we wait and see what comes up, but know the dust may not
yet be settled here.
"""
# nb. must register data for inclusion on page template
if form:
form.add_grid_vue_data(self)
# otherwise logic is the same, just different template
return self.render_vue_template(template=template, **context)
def render_vue_finalize(self):
"""
Render the Vue "finalize" script for the grid.

View file

@ -501,7 +501,7 @@
label="Delete This" />
% endif
% elif master.editing:
% if instance_viewable and master.has_perm('view'):
% if master.has_perm('view'):
<wutta-button once
tag="a" href="${master.get_action_url('view', instance)}"
icon-left="eye"
@ -514,7 +514,7 @@
label="Delete This" />
% endif
% elif master.deleting:
% if instance_viewable and master.has_perm('view'):
% if master.has_perm('view'):
<wutta-button once
tag="a" href="${master.get_action_url('view', instance)}"
icon-left="eye"

View file

@ -1 +1,9 @@
<span>${str(field.schema.model_instance or '')}</span>
<tal:omit tal:define="url url|None;">
<a tal:condition="url"
href="${url}">
${str(field.schema.model_instance or '')}
</a>
<span tal:condition="not url">
${str(field.schema.model_instance or '')}
</span>
</tal:omit>

View file

@ -0,0 +1,7 @@
<ul class="list-group">
<tal:loop tal:repeat="role roles">
<li class="list-group-item">
<a href="${url(role)}">${role}</a>
</li>
</tal:loop>
</ul>

View file

@ -68,6 +68,14 @@
% endif
% endif
% if form.grid_vue_data:
gridData: {
% for key, data in form.grid_vue_data.items():
'${key}': ${json.dumps(data)|n},
% endfor
},
% endif
}
</script>

View file

@ -0,0 +1,49 @@
## -*- coding: utf-8; -*-
<${b}-table :data="gridData['${grid.key}']">
% for column in grid.get_vue_columns():
<${b}-table-column field="${column['field']}"
label="${column['label']}"
v-slot="props"
:sortable="${json.dumps(column.get('sortable', False))|n}"
cell-class="c_${column['field']}">
% if grid.is_linked(column['field']):
<a :href="props.row._action_url_view"
v-html="props.row.${column['field']}" />
% else:
<span v-html="props.row.${column['field']}"></span>
% endif
</${b}-table-column>
% endfor
% if grid.actions:
<${b}-table-column field="actions"
label="Actions"
v-slot="props">
% for action in grid.actions:
<a v-if="props.row._action_url_${action.key}"
:href="props.row._action_url_${action.key}"
class="${action.link_class}">
${action.render_icon_and_label()}
</a>
&nbsp;
% endfor
</${b}-table-column>
% endif
<template #empty>
<section class="section">
<div class="content has-text-grey has-text-centered">
<p>
<b-icon
pack="fas"
icon="sad-tear"
size="is-large">
</b-icon>
</p>
<p>Nothing here.</p>
</div>
</section>
</template>
</${b}-table>

View file

@ -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):
@ -70,23 +71,25 @@ class PersonView(MasterView):
# last_name
g.set_link('last_name')
# TODO: master should handle this?
def configure_form(self, f):
""" """
super().configure_form(f)
person = f.model_instance
# first_name
# TODO: master should handle these? (nullable column)
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')
# 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):
""" """

View file

@ -28,7 +28,7 @@ from wuttjamaican.db.model import Role
from wuttaweb.views import MasterView
from wuttaweb.db import Session
from wuttaweb.forms import widgets
from wuttaweb.forms.schema import Permissions
from wuttaweb.forms.schema import UserRefs, Permissions
class RoleView(MasterView):
@ -115,6 +115,13 @@ class RoleView(MasterView):
# notes
f.set_widget('notes', widgets.NotesWidget())
# 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 role.users])
# permissions
f.append('permissions')
self.wutta_permissions = self.get_available_permissions()

View file

@ -10,6 +10,7 @@ from pyramid import testing
from wuttjamaican.conf import WuttaConfig
from wuttaweb.forms import base, widgets
from wuttaweb import helpers
from wuttaweb.grids import Grid
class TestForm(TestCase):
@ -405,6 +406,29 @@ class TestForm(TestCase):
self.assertIn('<script type="text/x-template" id="wutta-form-template">', html)
self.assertNotIn('@submit', html)
def test_add_grid_vue_data(self):
form = self.make_form()
# grid must have key
grid = Grid(self.request)
self.assertRaises(ValueError, form.add_grid_vue_data, grid)
# otherwise it works
grid = Grid(self.request, key='foo')
self.assertEqual(len(form.grid_vue_data), 0)
form.add_grid_vue_data(grid)
self.assertEqual(len(form.grid_vue_data), 1)
self.assertIn('foo', form.grid_vue_data)
self.assertEqual(form.grid_vue_data['foo'], [])
# calling again with same key will replace data
records = [{'foo': 1}, {'foo': 2}]
grid = Grid(self.request, key='foo', columns=['foo'], data=records)
form.add_grid_vue_data(grid)
self.assertEqual(len(form.grid_vue_data), 1)
self.assertIn('foo', form.grid_vue_data)
self.assertEqual(form.grid_vue_data['foo'], records)
def test_render_vue_finalize(self):
form = self.make_form()
html = form.render_vue_finalize()

View file

@ -9,6 +9,7 @@ from sqlalchemy import orm
from wuttjamaican.conf import WuttaConfig
from wuttaweb.forms import schema as mod
from wuttaweb.forms import widgets
from tests.util import DataTestCase
@ -200,6 +201,19 @@ class TestPersonRef(DataTestCase):
self.assertIsNot(sorted_query, query)
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
typ = mod.UserRefs(self.request, session=self.session)
widget = typ.widget_maker()
self.assertIsInstance(widget, widgets.UserRefsWidget)
class TestRoleRefs(DataTestCase):
def setUp(self):

View file

@ -1,11 +1,13 @@
# -*- coding: utf-8; -*-
from unittest.mock import patch
import colander
import deform
from pyramid import testing
from wuttaweb.forms import widgets as mod
from wuttaweb.forms.schema import PersonRef, RoleRefs, Permissions
from wuttaweb.forms.schema import PersonRef, RoleRefs, UserRefs, Permissions
from tests.util import WebTestCase
@ -36,7 +38,18 @@ class TestObjectRefWidget(WebTestCase):
widget = mod.ObjectRefWidget(self.request)
field = self.make_field(node)
html = widget.serialize(field, person.uuid, readonly=True)
self.assertEqual(html.strip(), '<span>Betty Boop</span>')
self.assertIn('Betty Boop', html)
self.assertNotIn('<a', html)
# with hyperlink
node = colander.SchemaNode(PersonRef(self.request, session=self.session))
node.model_instance = person
widget = mod.ObjectRefWidget(self.request, url=lambda p: '/foo')
field = self.make_field(node)
html = widget.serialize(field, person.uuid, readonly=True)
self.assertIn('Betty Boop', html)
self.assertIn('<a', html)
self.assertIn('href="/foo"', html)
class TestRoleRefsWidget(WebTestCase):
@ -48,6 +61,7 @@ class TestRoleRefsWidget(WebTestCase):
return deform.Field(node, **kwargs)
def test_serialize(self):
self.pyramid_config.add_route('roles.view', '/roles/{uuid}')
model = self.app.model
auth = self.app.get_auth_handler()
admin = auth.get_role_administrator(self.session)
@ -77,6 +91,49 @@ class TestRoleRefsWidget(WebTestCase):
self.assertIn(blokes.uuid, 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))
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.assertIn('<b-table ', 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('<b-table ', html)
self.assertNotIn('Actions', html)
self.assertNotIn('View', html)
self.assertNotIn('Edit', html)
# with view/edit actions
with patch.object(self.request, 'is_root', new=True):
html = widget.serialize(field, {user.uuid}, readonly=True)
self.assertIn('<b-table ', html)
self.assertIn('Actions', html)
self.assertIn('View', html)
self.assertIn('Edit', html)
class TestPermissionsWidget(WebTestCase):
def make_field(self, node, **kwargs):

View file

@ -10,7 +10,8 @@ from pyramid import testing
from wuttjamaican.conf import WuttaConfig
from wuttaweb.grids import base as mod
from wuttaweb.forms import FieldList
from wuttaweb.util import FieldList
from wuttaweb.forms import Form
from tests.util import WebTestCase
@ -186,6 +187,14 @@ class TestGrid(WebTestCase):
self.assertFalse(grid.is_linked('foo'))
self.assertTrue(grid.is_linked('bar'))
def test_add_action(self):
grid = self.make_grid()
self.assertEqual(len(grid.actions), 0)
grid.add_action('view')
self.assertEqual(len(grid.actions), 1)
self.assertIsInstance(grid.actions[0], mod.GridAction)
def test_get_pagesize_options(self):
grid = self.make_grid()
@ -855,6 +864,25 @@ class TestGrid(WebTestCase):
html = grid.render_vue_template()
self.assertIn('<script type="text/x-template" id="wutta-grid-template">', html)
def test_render_table_element(self):
self.pyramid_config.include('pyramid_mako')
self.pyramid_config.add_subscriber('wuttaweb.subscribers.before_render',
'pyramid.events.BeforeRender')
grid = self.make_grid(key='foobar', columns=['foo', 'bar'])
# form not required
html = grid.render_table_element()
self.assertNotIn('<script ', html)
self.assertIn('<b-table ', html)
# form will register grid data
form = Form(self.request)
self.assertEqual(len(form.grid_vue_data), 0)
html = grid.render_table_element(form)
self.assertEqual(len(form.grid_vue_data), 1)
self.assertIn('foobar', form.grid_vue_data)
def test_render_vue_finalize(self):
grid = self.make_grid()
html = grid.render_vue_finalize()

View file

@ -35,12 +35,27 @@ class TestPersonView(WebTestCase):
model = self.app.model
view = self.make_view()
form = view.make_form(model_class=model.Person)
# required fields
with patch.object(view, 'creating', new=True):
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'])
person = model.Person(full_name="Barney Rubble")
user = model.User(username='barney', person=person)
self.session.add(user)
self.session.commit()
# users field
with patch.object(view, 'viewing', new=True):
form = view.make_form(model_instance=person)
self.assertEqual(form.defaults, {})
view.configure_form(form)
self.assertIn('_users', form.defaults)
def test_autocomplete_query(self):
model = self.app.model