diff --git a/tailbone/static/css/base.css b/tailbone/static/css/base.css
index f3cd2ebe..466ca50b 100644
--- a/tailbone/static/css/base.css
+++ b/tailbone/static/css/base.css
@@ -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;
diff --git a/tailbone/static/css/forms.css b/tailbone/static/css/forms.css
index a08bf6d5..fdbace95 100644
--- a/tailbone/static/css/forms.css
+++ b/tailbone/static/css/forms.css
@@ -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;
}
diff --git a/tailbone/static/css/perms.css b/tailbone/static/css/perms.css
index 7db6a2c0..b83ae7d9 100644
--- a/tailbone/static/css/perms.css
+++ b/tailbone/static/css/perms.css
@@ -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;
}
diff --git a/tailbone/templates/deform/permissions.pt b/tailbone/templates/deform/permissions.pt
new file mode 100644
index 00000000..4a9cb0f9
--- /dev/null
+++ b/tailbone/templates/deform/permissions.pt
@@ -0,0 +1,28 @@
+
- ${field.serialize()|n}
+
+
+
+ ${field.serialize()|n}
+
% if form.has_helptext(field.name):
${form.render_helptext(field.name)}
diff --git a/tailbone/views/principal.py b/tailbone/views/principal.py
index 8fbe741c..f68f1036 100644
--- a/tailbone/views/principal.py
+++ b/tailbone/views/principal.py
@@ -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)"
diff --git a/tailbone/views/roles.py b/tailbone/views/roles.py
index 4c79fd37..d43e37ad 100644
--- a/tailbone/views/roles.py
+++ b/tailbone/views/roles.py
@@ -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):
diff --git a/tailbone/views/users.py b/tailbone/views/users.py
index 4990eb3d..b98b485b 100644
--- a/tailbone/views/users.py
+++ b/tailbone/views/users.py
@@ -28,102 +28,22 @@ 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
+from tailbone.views import MasterView3 as MasterView
+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):
"""
Master view for the User model.
@@ -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,
- include_guest=True,
- include_authenticated=True)
- fs.append(formalchemy.Field('permissions', renderer=renderer))
+ f.append('permissions')
+ f.set_renderer('permissions', PermissionsRenderer(permissions=permissions,
+ include_guest=True,
+ 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():