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; margin: 0.5em 0 0 0;
} }
div.error {
color: #dd6666;
font-weight: bold;
margin-bottom: 10px;
}
ul.error { ul.error {
color: #dd6666; color: #dd6666;
font-weight: bold; font-weight: bold;

View file

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

View file

@ -3,34 +3,30 @@
* Permission Lists * Permission Lists
******************************/ ******************************/
div.field-wrapper.permissions { .field-wrapper.permissions .field .group {
overflow: visible;
}
div.field-wrapper.permissions div.field div.group {
margin-bottom: 10px; margin-bottom: 10px;
} }
div.field-wrapper.permissions div.field div.group p { .field-wrapper.permissions .field .group p {
font-weight: bold; font-weight: bold;
} }
div.field-wrapper.permissions div.field label { .field-wrapper.permissions .field label {
float: none; display: inline;
font-weight: normal; font-weight: normal;
} }
div.field-wrapper.permissions div.field label input { .field-wrapper.permissions .field label input {
margin-left: 15px; margin-left: 15px;
margin-right: 10px; margin-right: 10px;
} }
div.field-wrapper.permissions div.field div.group p.perm { .field-wrapper.permissions .field .group p.perm {
font-weight: normal; font-weight: normal;
margin-left: 15px; 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; font-family: monospace;
margin-right: 10px; 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,11 +26,13 @@ ${h.csrf_token(request)}
## <div class="field-error">${error}</div> ## <div class="field-error">${error}</div>
## % endfor ## % endfor
% if field.error: % 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 % endif
<label for="${field.oid}">${field.title}</label> <div class="field-row">
<div class="field"> <label for="${field.oid}">${field.title}</label>
${field.serialize()|n} <div class="field">
${field.serialize()|n}
</div>
</div> </div>
% if form.has_helptext(field.name): % if form.has_helptext(field.name):
<span class="instructions">${form.render_helptext(field.name)}</span> <span class="instructions">${form.render_helptext(field.name)}</span>

View file

@ -28,9 +28,14 @@ from __future__ import unicode_literals, absolute_import
import copy 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): class PrincipalMasterView(MasterView):
@ -95,3 +100,33 @@ class PrincipalMasterView(MasterView):
permission='{}.find_by_perm'.format(permission_prefix)) permission='{}.find_by_perm'.format(permission_prefix))
config.add_tailbone_permission(permission_prefix, '{}.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)) "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 from __future__ import unicode_literals, absolute_import
import six
from sqlalchemy import orm from sqlalchemy import orm
from rattail.db import model from rattail.db import model
from rattail.db.auth import has_permission, administrator_role, guest_role, authenticated_role from rattail.db.auth import has_permission, administrator_role, guest_role, authenticated_role
import formalchemy as fa import colander
from formalchemy.fields import IntegerFieldRenderer from deform import widget as dfwidget
from tailbone import forms, grids from tailbone import grids
from tailbone.db import Session from tailbone.db import Session
from tailbone.views.principal import PrincipalMasterView from tailbone.views.principal import PrincipalMasterView, PermissionsRenderer
class RolesView(PrincipalMasterView): class RolesView(PrincipalMasterView):
@ -51,6 +52,12 @@ class RolesView(PrincipalMasterView):
'session_timeout', 'session_timeout',
] ]
form_fields = [
'name',
'session_timeout',
'permissions',
]
def configure_grid(self, g): def configure_grid(self, g):
super(RolesView, self).configure_grid(g) super(RolesView, self).configure_grid(g)
g.filters['name'].default_active = True g.filters['name'].default_active = True
@ -58,21 +65,39 @@ class RolesView(PrincipalMasterView):
g.set_sort_defaults('name') g.set_sort_defaults('name')
g.set_link('name') g.set_link('name')
def _preconfigure_fieldset(self, fs): def configure_form(self, f):
fs.append(PermissionsField('permissions')) super(RolesView, self).configure_form(f)
permissions = self.request.registry.settings.get('tailbone_permissions', {}) role = f.model_instance
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_fieldset(self, fs): # permissions
fs.configure( self.tailbone_permissions = self.request.registry.settings.get('tailbone_permissions', {})
include=[ f.set_renderer('permissions', PermissionsRenderer(permissions=self.tailbone_permissions))
fs.name, f.set_node('permissions', colander.Set())
fs.session_timeout, f.set_widget('permissions', PermissionsWidget(permissions=self.tailbone_permissions))
fs.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): def template_kwargs_view(self, **kwargs):
role = kwargs['instance'] role = kwargs['instance']
@ -86,14 +111,14 @@ class RolesView(PrincipalMasterView):
main_actions=actions) main_actions=actions)
else: else:
kwargs['users'] = None kwargs['users'] = None
kwargs['guest_role'] = guest_role(Session()) kwargs['guest_role'] = guest_role(self.Session())
kwargs['authenticated_role'] = authenticated_role(Session()) kwargs['authenticated_role'] = authenticated_role(self.Session())
return kwargs return kwargs
def before_delete(self, role): def before_delete(self, role):
admin = administrator_role(Session()) admin = administrator_role(self.Session())
guest = guest_role(Session()) guest = guest_role(self.Session())
authenticated = authenticated_role(Session()) authenticated = authenticated_role(self.Session())
if role in (admin, guest, authenticated): if role in (admin, guest, authenticated):
self.request.session.flash("You may not delete the {} role.".format(role.name), 'error') 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'))) return self.redirect(self.request.get_referrer(default=self.request.route_url('roles')))
@ -110,23 +135,25 @@ class RolesView(PrincipalMasterView):
return roles return roles
class SessionTimeoutRenderer(IntegerFieldRenderer): class PermissionsWidget(dfwidget.Widget):
template = 'permissions'
permissions = None
true_val = 'true'
def render_readonly(self, **kwargs): def deserialize(self, field, pstruct):
if not kwargs.pop('applicable', True): return [key for key, val in pstruct.items()
return "(not applicable)" if val == self.true_val]
return super(SessionTimeoutRenderer, self).render_readonly(**kwargs)
def get_checked_value(self, cstruct, value):
if cstruct is colander.null:
return False
return value in cstruct
class PermissionsField(fa.Field): def serialize(self, field, cstruct, **kw):
""" kw.setdefault('permissions', self.permissions)
Custom field for role permissions. template = self.template
""" values = self.get_template_values(field, cstruct, kw)
return field.renderer(template, **values)
def sync(self):
if not self.is_readonly():
role = self.model
role.permissions = self.renderer.deserialize()
def includeme(config): def includeme(config):

View file

@ -28,100 +28,20 @@ from __future__ import unicode_literals, absolute_import
import copy import copy
import six
from sqlalchemy import orm from sqlalchemy import orm
from rattail.db import model from rattail.db import model
from rattail.db.auth import guest_role, authenticated_role, set_user_password, has_permission from rattail.db.auth import guest_role, authenticated_role, set_user_password, has_permission
import wtforms import colander
import formalchemy from deform import widget as dfwidget
from formalchemy.fields import SelectFieldRenderer
from webhelpers2.html import HTML, tags from webhelpers2.html import HTML, tags
from tailbone import forms from tailbone import forms2 as forms
from tailbone.db import Session from tailbone.db import Session
from tailbone.views import MasterView2 as MasterView from tailbone.views import MasterView3 as MasterView
from tailbone.views.principal import PrincipalMasterView from tailbone.views.principal import PrincipalMasterView, PermissionsRenderer
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]
class UsersView(PrincipalMasterView): class UsersView(PrincipalMasterView):
@ -133,6 +53,29 @@ class UsersView(PrincipalMasterView):
model_row_class = model.UserEvent model_row_class = model.UserEvent
has_versions = True 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 mergeable = True
merge_additive_fields = [ merge_additive_fields = [
'sent_message_count', 'sent_message_count',
@ -147,17 +90,6 @@ class UsersView(PrincipalMasterView):
'active', 'active',
] ]
grid_columns = [
'username',
'person',
'active',
]
row_grid_columns = [
'type_code',
'occurred',
]
def query(self, session): def query(self, session):
return session.query(model.User)\ return session.query(model.User)\
.outerjoin(model.Person)\ .outerjoin(model.Person)\
@ -191,45 +123,119 @@ class UsersView(PrincipalMasterView):
g.set_link('last_name') g.set_link('last_name')
g.set_link('display_name') g.set_link('display_name')
def _preconfigure_fieldset(self, fs): def unique_username(self, node, value):
fs.username.set(renderer=forms.renderers.StrippedTextFieldRenderer, validate=unique_username) query = self.Session.query(model.User)\
fs.person.set(renderer=forms.renderers.PersonFieldRenderer, options=[]) .filter(model.User.username == value)
fs.append(PasswordField('password', label="Set Password")) if self.editing:
fs.append(formalchemy.Field('confirm_password', renderer=PasswordFieldRenderer)) user = self.get_instance()
fs.append(RolesField('roles', renderer=RolesFieldRenderer(self.request), size=10)) query = query.filter(model.User.uuid != user.uuid)
fs.append(forms.AssociationProxyField('first_name')) if query.count():
fs.append(forms.AssociationProxyField('last_name')) raise colander.Invalid(node, "Username must be unique")
fs.append(forms.AssociationProxyField('display_name', label="Full Name"))
# hm this should work according to MDN but doesn't seem to... def configure_form(self, f):
# https://developer.mozilla.org/en-US/docs/Web/Security/Securing_your_site/Turning_off_form_autocompletion super(UsersView, self).configure_form(f)
fs.username.attrs(autocomplete='new-password') user = f.model_instance
fs.password.attrs(autocomplete='new-password')
fs.confirm_password.attrs(autocomplete='new-password') # 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: if self.viewing:
permissions = self.request.registry.settings.get('tailbone_permissions', {}) permissions = self.request.registry.settings.get('tailbone_permissions', {})
renderer = forms.renderers.PermissionsFieldRenderer(permissions, f.append('permissions')
include_guest=True, f.set_renderer('permissions', PermissionsRenderer(permissions=permissions,
include_authenticated=True) include_guest=True,
fs.append(formalchemy.Field('permissions', renderer=renderer)) include_authenticated=True))
if self.viewing or self.deleting: if self.viewing or self.deleting:
del fs.password f.remove('password')
del fs.confirm_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): def editable_instance(self, user):
if self.rattail_config.demo(): if self.rattail_config.demo():