Refactor user and role views to use master3

This commit is contained in:
Lance Edgar 2017-12-06 12:40:27 -06:00
parent 789bdef190
commit 86cfc59d33
8 changed files with 304 additions and 209 deletions

View file

@ -81,12 +81,6 @@ div.error-messages div.ui-state-error {
margin: 0.5em 0 0 0;
}
div.error {
color: #dd6666;
font-weight: bold;
margin-bottom: 10px;
}
ul.error {
color: #dd6666;
font-weight: bold;

View file

@ -45,47 +45,54 @@ div.fieldset {
* Fieldsets
******************************/
div.field-wrapper {
.field-wrapper {
clear: both;
min-height: 30px;
overflow: auto;
padding: 5px;
margin: 15px;
}
div.field-wrapper.error {
.field-wrapper.error {
background-color: #ddcccc;
border: 2px solid #dd6666;
padding-bottom: 1em;
}
div.field-wrapper label {
display: block;
float: left;
.field-wrapper .field-row {
display: table-row;
}
.field-wrapper label {
display: table-cell;
vertical-align: top;
width: 15em;
font-weight: bold;
margin-top: 2px;
padding-top: 2px;
white-space: nowrap;
}
.field-wrapper.error label {
color: Black;
padding-left: 1em;
}
div.field-wrapper div.field-error {
.field-wrapper .field-error {
padding: 1em 0 0.5em 1em;
}
.field-wrapper .field-error .error-msg {
color: #dd6666;
font-weight: bold;
}
div.field-wrapper div.field {
display: block;
float: left;
margin-bottom: 5px;
.field-wrapper .field {
display: table-cell;
line-height: 25px;
}
div.field-wrapper div.field input[type=text],
div.field-wrapper div.field input[type=password],
div.field-wrapper div.field select,
div.field-wrapper div.field textarea {
.field-wrapper .field input[type=text],
.field-wrapper .field input[type=password],
.field-wrapper .field select,
.field-wrapper .field textarea {
width: 320px;
}
@ -94,7 +101,7 @@ label input[type="radio"] {
margin-right: 0.5em;
}
div.field ul {
.field ul {
margin: 0px;
padding-left: 15px;
}

View file

@ -3,34 +3,30 @@
* Permission Lists
******************************/
div.field-wrapper.permissions {
overflow: visible;
}
div.field-wrapper.permissions div.field div.group {
.field-wrapper.permissions .field .group {
margin-bottom: 10px;
}
div.field-wrapper.permissions div.field div.group p {
.field-wrapper.permissions .field .group p {
font-weight: bold;
}
div.field-wrapper.permissions div.field label {
float: none;
.field-wrapper.permissions .field label {
display: inline;
font-weight: normal;
}
div.field-wrapper.permissions div.field label input {
.field-wrapper.permissions .field label input {
margin-left: 15px;
margin-right: 10px;
}
div.field-wrapper.permissions div.field div.group p.perm {
.field-wrapper.permissions .field .group p.perm {
font-weight: normal;
margin-left: 15px;
}
div.field-wrapper.permissions div.field div.group p.perm span {
.field-wrapper.permissions .field .group p.perm span {
font-family: monospace;
margin-right: 10px;
}

View file

@ -0,0 +1,28 @@
<div tal:define="oid oid|field.oid;
true_val true_val|field.widget.true_val;"
tal:omit-tag="">
${field.start_mapping()}
<tal:loop tal:repeat="groupkey sorted(permissions, key=lambda k: permissions[k]['label'].lower())">
<div tal:define="perms permissions[groupkey]['perms'];"
class="group">
<p class="group">${permissions[groupkey]['label']}</p>
<tal:loop tal:repeat="key sorted(perms, key=lambda p: perms[p]['label'].lower())">
<div class="perm">
<label>
<input type="checkbox"
name="${key}"
id="${oid}-${key}"
value="${true_val}"
tal:attributes="checked python:field.widget.get_checked_value(cstruct, key);" />
${perms[key]['label']}
</label>
</div>
</tal:loop>
</div>
</tal:loop>
${field.end_mapping()}
</div>

View file

@ -26,12 +26,14 @@ ${h.csrf_token(request)}
## <div class="field-error">${error}</div>
## % endfor
% if field.error:
<div class="field-error">${field.error.msg}</div>
<div class="field-error"><span class="error-msg">${field.error.msg}</span></div>
% endif
<div class="field-row">
<label for="${field.oid}">${field.title}</label>
<div class="field">
${field.serialize()|n}
</div>
</div>
% if form.has_helptext(field.name):
<span class="instructions">${form.render_helptext(field.name)}</span>
% endif

View file

@ -28,9 +28,14 @@ from __future__ import unicode_literals, absolute_import
import copy
import wtforms
from rattail.db.auth import has_permission
from rattail.core import Object
from tailbone.views import MasterView2 as MasterView
import wtforms
from webhelpers2.html import HTML
from tailbone.db import Session
from tailbone.views import MasterView3 as MasterView
class PrincipalMasterView(MasterView):
@ -95,3 +100,33 @@ class PrincipalMasterView(MasterView):
permission='{}.find_by_perm'.format(permission_prefix))
config.add_tailbone_permission(permission_prefix, '{}.find_by_perm'.format(permission_prefix),
"Find all {} with permission X".format(model_title_plural))
class PermissionsRenderer(Object):
permissions = None
include_guest = False
include_authenticated = False
def __call__(self, principal, field):
self.principal = principal
return self.render()
def render(self):
principal = self.principal
html = ''
for groupkey in sorted(self.permissions, key=lambda k: self.permissions[k]['label'].lower()):
inner = HTML.tag('p', c=self.permissions[groupkey]['label'])
perms = self.permissions[groupkey]['perms']
rendered = False
for key in sorted(perms, key=lambda p: perms[p]['label'].lower()):
checked = has_permission(Session(), principal, key,
include_guest=self.include_guest,
include_authenticated=self.include_authenticated)
if checked:
label = perms[key]['label']
span = HTML.tag('span', c="[X]" if checked else "[ ]")
inner += HTML.tag('p', class_='perm', c=span + ' ' + label)
rendered = True
if rendered:
html += HTML.tag('div', class_='group', c=inner)
return html or "(none granted)"

View file

@ -26,17 +26,18 @@ Role Views
from __future__ import unicode_literals, absolute_import
import six
from sqlalchemy import orm
from rattail.db import model
from rattail.db.auth import has_permission, administrator_role, guest_role, authenticated_role
import formalchemy as fa
from formalchemy.fields import IntegerFieldRenderer
import colander
from deform import widget as dfwidget
from tailbone import forms, grids
from tailbone import grids
from tailbone.db import Session
from tailbone.views.principal import PrincipalMasterView
from tailbone.views.principal import PrincipalMasterView, PermissionsRenderer
class RolesView(PrincipalMasterView):
@ -51,6 +52,12 @@ class RolesView(PrincipalMasterView):
'session_timeout',
]
form_fields = [
'name',
'session_timeout',
'permissions',
]
def configure_grid(self, g):
super(RolesView, self).configure_grid(g)
g.filters['name'].default_active = True
@ -58,21 +65,39 @@ class RolesView(PrincipalMasterView):
g.set_sort_defaults('name')
g.set_link('name')
def _preconfigure_fieldset(self, fs):
fs.append(PermissionsField('permissions'))
permissions = self.request.registry.settings.get('tailbone_permissions', {})
fs.permissions.set(renderer=forms.renderers.PermissionsFieldRenderer(permissions))
fs.session_timeout.set(renderer=SessionTimeoutRenderer)
if (self.viewing or self.editing) and fs.model is guest_role(self.Session()):
fs.session_timeout.set(readonly=True, attrs={'applicable': False})
def configure_form(self, f):
super(RolesView, self).configure_form(f)
role = f.model_instance
def configure_fieldset(self, fs):
fs.configure(
include=[
fs.name,
fs.session_timeout,
fs.permissions,
])
# permissions
self.tailbone_permissions = self.request.registry.settings.get('tailbone_permissions', {})
f.set_renderer('permissions', PermissionsRenderer(permissions=self.tailbone_permissions))
f.set_node('permissions', colander.Set())
f.set_widget('permissions', PermissionsWidget(permissions=self.tailbone_permissions))
if self.editing:
granted = []
for groupkey in self.tailbone_permissions:
for key in self.tailbone_permissions[groupkey]['perms']:
if has_permission(self.Session(), role, key, include_guest=False, include_authenticated=False):
granted.append(key)
f.set_default('permissions', granted)
# session_timeout
f.set_renderer('session_timeout', self.render_session_timeout)
if self.editing and role is guest_role(self.Session()):
f.set_readonly('session_timeout')
def render_session_timeout(self, role, field):
if role is guest_role(self.Session()):
return "(not applicable)"
if role.session_timeout is None:
return ""
return six.text_type(role.session_timeout)
def objectify(self, form, data):
role = super(RolesView, self).objectify(form, data)
role.permissions = data['permissions']
return role
def template_kwargs_view(self, **kwargs):
role = kwargs['instance']
@ -86,14 +111,14 @@ class RolesView(PrincipalMasterView):
main_actions=actions)
else:
kwargs['users'] = None
kwargs['guest_role'] = guest_role(Session())
kwargs['authenticated_role'] = authenticated_role(Session())
kwargs['guest_role'] = guest_role(self.Session())
kwargs['authenticated_role'] = authenticated_role(self.Session())
return kwargs
def before_delete(self, role):
admin = administrator_role(Session())
guest = guest_role(Session())
authenticated = authenticated_role(Session())
admin = administrator_role(self.Session())
guest = guest_role(self.Session())
authenticated = authenticated_role(self.Session())
if role in (admin, guest, authenticated):
self.request.session.flash("You may not delete the {} role.".format(role.name), 'error')
return self.redirect(self.request.get_referrer(default=self.request.route_url('roles')))
@ -110,23 +135,25 @@ class RolesView(PrincipalMasterView):
return roles
class SessionTimeoutRenderer(IntegerFieldRenderer):
class PermissionsWidget(dfwidget.Widget):
template = 'permissions'
permissions = None
true_val = 'true'
def render_readonly(self, **kwargs):
if not kwargs.pop('applicable', True):
return "(not applicable)"
return super(SessionTimeoutRenderer, self).render_readonly(**kwargs)
def deserialize(self, field, pstruct):
return [key for key, val in pstruct.items()
if val == self.true_val]
def get_checked_value(self, cstruct, value):
if cstruct is colander.null:
return False
return value in cstruct
class PermissionsField(fa.Field):
"""
Custom field for role permissions.
"""
def sync(self):
if not self.is_readonly():
role = self.model
role.permissions = self.renderer.deserialize()
def serialize(self, field, cstruct, **kw):
kw.setdefault('permissions', self.permissions)
template = self.template
values = self.get_template_values(field, cstruct, kw)
return field.renderer(template, **values)
def includeme(config):

View file

@ -28,100 +28,20 @@ from __future__ import unicode_literals, absolute_import
import copy
import six
from sqlalchemy import orm
from rattail.db import model
from rattail.db.auth import guest_role, authenticated_role, set_user_password, has_permission
import wtforms
import formalchemy
from formalchemy.fields import SelectFieldRenderer
import colander
from deform import widget as dfwidget
from webhelpers2.html import HTML, tags
from tailbone import forms
from tailbone import forms2 as forms
from tailbone.db import Session
from tailbone.views import MasterView2 as MasterView
from tailbone.views.principal import PrincipalMasterView
def unique_username(value, field):
user = field.parent.model
query = Session.query(model.User).filter(model.User.username == value)
if user.uuid:
query = query.filter(model.User.uuid != user.uuid)
if query.count():
raise formalchemy.ValidationError("Username must be unique.")
def passwords_match(value, field):
if field.parent.confirm_password.value != value:
raise formalchemy.ValidationError("Passwords do not match")
return value
class PasswordFieldRenderer(formalchemy.PasswordFieldRenderer):
def render(self, **kwargs):
return tags.password(self.name, value='', maxlength=self.length, **kwargs)
class PasswordField(formalchemy.Field):
def __init__(self, *args, **kwargs):
kwargs.setdefault('value', lambda x: x.password)
kwargs.setdefault('renderer', PasswordFieldRenderer)
kwargs.setdefault('validate', passwords_match)
super(PasswordField, self).__init__(*args, **kwargs)
def sync(self):
if not self.is_readonly():
password = self.renderer.deserialize()
if password:
set_user_password(self.model, password)
def RolesFieldRenderer(request):
class RolesFieldRenderer(SelectFieldRenderer):
def render_readonly(self, **kwargs):
roles = Session.query(model.Role)
html = ''
for uuid in self.value:
role = roles.get(uuid)
link = tags.link_to(
role.name, request.route_url('roles.view', uuid=role.uuid))
html += HTML.tag('li', c=link)
html = HTML.tag('ul', c=html)
return html
return RolesFieldRenderer
class RolesField(formalchemy.Field):
def __init__(self, name, **kwargs):
kwargs.setdefault('value', self.get_value)
kwargs.setdefault('options', self.get_options())
kwargs.setdefault('multiple', True)
super(RolesField, self).__init__(name, **kwargs)
def get_value(self, user):
return [x.uuid for x in user.roles]
def get_options(self):
return Session.query(model.Role.name, model.Role.uuid)\
.filter(model.Role.uuid != guest_role(Session()).uuid)\
.filter(model.Role.uuid != authenticated_role(Session()).uuid)\
.order_by(model.Role.name)\
.all()
def sync(self):
if not self.is_readonly():
user = self.model
roles = Session.query(model.Role)
data = self.renderer.deserialize()
user.roles = [roles.get(x) for x in data]
from tailbone.views import MasterView3 as MasterView
from tailbone.views.principal import PrincipalMasterView, PermissionsRenderer
class UsersView(PrincipalMasterView):
@ -133,6 +53,29 @@ class UsersView(PrincipalMasterView):
model_row_class = model.UserEvent
has_versions = True
grid_columns = [
'username',
'person',
'active',
]
form_fields = [
'username',
'person',
'first_name',
'last_name',
'display_name',
'active',
'active_sticky',
'password',
'roles',
]
row_grid_columns = [
'type_code',
'occurred',
]
mergeable = True
merge_additive_fields = [
'sent_message_count',
@ -147,17 +90,6 @@ class UsersView(PrincipalMasterView):
'active',
]
grid_columns = [
'username',
'person',
'active',
]
row_grid_columns = [
'type_code',
'occurred',
]
def query(self, session):
return session.query(model.User)\
.outerjoin(model.Person)\
@ -191,45 +123,119 @@ class UsersView(PrincipalMasterView):
g.set_link('last_name')
g.set_link('display_name')
def _preconfigure_fieldset(self, fs):
fs.username.set(renderer=forms.renderers.StrippedTextFieldRenderer, validate=unique_username)
fs.person.set(renderer=forms.renderers.PersonFieldRenderer, options=[])
fs.append(PasswordField('password', label="Set Password"))
fs.append(formalchemy.Field('confirm_password', renderer=PasswordFieldRenderer))
fs.append(RolesField('roles', renderer=RolesFieldRenderer(self.request), size=10))
fs.append(forms.AssociationProxyField('first_name'))
fs.append(forms.AssociationProxyField('last_name'))
fs.append(forms.AssociationProxyField('display_name', label="Full Name"))
def unique_username(self, node, value):
query = self.Session.query(model.User)\
.filter(model.User.username == value)
if self.editing:
user = self.get_instance()
query = query.filter(model.User.uuid != user.uuid)
if query.count():
raise colander.Invalid(node, "Username must be unique")
# hm this should work according to MDN but doesn't seem to...
# https://developer.mozilla.org/en-US/docs/Web/Security/Securing_your_site/Turning_off_form_autocompletion
fs.username.attrs(autocomplete='new-password')
fs.password.attrs(autocomplete='new-password')
fs.confirm_password.attrs(autocomplete='new-password')
def configure_form(self, f):
super(UsersView, self).configure_form(f)
user = f.model_instance
# username
f.set_validator('username', self.unique_username)
# person
f.set_renderer('person', self.render_person)
if self.creating or self.editing:
f.replace('person', 'person_uuid')
f.set_node('person_uuid', colander.String(), missing=colander.null)
person_display = ""
if self.request.method == 'POST':
if self.request.POST.get('person_uuid'):
person = self.Session.query(model.Person).get(self.request.POST['person_uuid'])
if person:
person_display = six.text_type(person)
elif self.editing:
person_display = six.text_type(user.person or '')
people_url = self.request.route_url('people.autocomplete')
f.set_widget('person_uuid', forms.widgets.JQueryAutocompleteWidget(
field_display=person_display, service_url=people_url))
f.set_label('person_uuid', "Person")
# password
f.set_widget('password', dfwidget.CheckedPasswordWidget())
f.set_label('password', "Set Password")
# if self.creating:
# f.set_required('password')
# roles
f.set_renderer('roles', self.render_roles)
if self.creating or self.editing:
roles = self.get_possible_roles().all()
role_values = [(s.uuid, six.text_type(s)) for s in roles]
f.set_node('roles', colander.Set())
f.set_widget('roles', dfwidget.SelectWidget(multiple=True,
size=len(roles),
values=role_values))
if self.editing:
f.set_default('roles', [r.uuid for r in user.roles])
f.set_label('display_name', "Full Name")
# # hm this should work according to MDN but doesn't seem to...
# # https://developer.mozilla.org/en-US/docs/Web/Security/Securing_your_site/Turning_off_form_autocompletion
# fs.username.attrs(autocomplete='new-password')
# fs.password.attrs(autocomplete='new-password')
# fs.confirm_password.attrs(autocomplete='new-password')
def configure_fieldset(self, fs):
fs.configure(
include=[
fs.username,
fs.person,
fs.first_name,
fs.last_name,
fs.display_name,
fs.active,
fs.active_sticky,
fs.password,
fs.confirm_password,
fs.roles,
])
if self.viewing:
permissions = self.request.registry.settings.get('tailbone_permissions', {})
renderer = forms.renderers.PermissionsFieldRenderer(permissions,
f.append('permissions')
f.set_renderer('permissions', PermissionsRenderer(permissions=permissions,
include_guest=True,
include_authenticated=True)
fs.append(formalchemy.Field('permissions', renderer=renderer))
include_authenticated=True))
if self.viewing or self.deleting:
del fs.password
del fs.confirm_password
f.remove('password')
def get_possible_roles(self):
excluded = [
guest_role(self.Session()).uuid,
authenticated_role(self.Session()).uuid,
]
return self.Session.query(model.Role)\
.filter(~model.Role.uuid.in_(excluded))\
.order_by(model.Role.name)
def objectify(self, form, data):
user = super(UsersView, self).objectify(form, data)
if data['password']:
set_user_password(user, data['password'])
self.update_roles(user, data)
return user
def update_roles(self, user, data):
old_roles = set([r.uuid for r in user.roles])
new_roles = data['roles']
for uuid in new_roles:
if uuid not in old_roles:
user._roles.append(model.UserRole(role_uuid=uuid))
for uuid in old_roles:
if uuid not in new_roles:
role = self.Session.query(model.Role).get(uuid)
user.roles.remove(role)
def render_person(self, user, field):
person = user.person
if not person:
return ""
text = six.text_type(person)
url = self.request.route_url('people.view', uuid=person.uuid)
return tags.link_to(person, url)
def render_roles(self, user, field):
roles = user.roles
items = []
for role in roles:
text = role.name
url = self.request.route_url('roles.view', uuid=role.uuid)
items.append(HTML.tag('li', c=tags.link_to(text, url)))
return HTML.tag('ul', c=items)
def editable_instance(self, user):
if self.rattail_config.demo():