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.start_mapping()} + + +
+

${permissions[groupkey]['label']}

+ + +
+ +
+
+ +
+
+ + ${field.end_mapping()} +
diff --git a/tailbone/templates/forms2/deform.mako b/tailbone/templates/forms2/deform.mako index ac8f7a37..a8000189 100644 --- a/tailbone/templates/forms2/deform.mako +++ b/tailbone/templates/forms2/deform.mako @@ -26,11 +26,13 @@ ${h.csrf_token(request)} ##
${error}
## % endfor % if field.error: -
${field.error.msg}
+
${field.error.msg}
% endif - -
- ${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():