Compare commits
12 commits
b4b72d92aa
...
a8514da107
Author | SHA1 | Date | |
---|---|---|---|
a8514da107 | |||
9805f808da | |||
17b8af27a7 | |||
be8a45e543 | |||
058632ebeb | |||
e3942ce65e | |||
675b51cac2 | |||
bc49392140 | |||
330ee324ba | |||
230e2fd1ab | |||
97e914c2e0 | |||
bdfa0197b2 |
19
CHANGELOG.md
19
CHANGELOG.md
|
@ -5,6 +5,25 @@ All notable changes to wuttaweb will be documented in this file.
|
||||||
The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
|
The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
|
||||||
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## v0.7.0 (2024-08-15)
|
||||||
|
|
||||||
|
### Feat
|
||||||
|
|
||||||
|
- add sane views for 403 Forbidden and 404 Not Found
|
||||||
|
- add permission checks for menus, view routes
|
||||||
|
- add first-time setup page to create admin user
|
||||||
|
- expose User password for editing in master views
|
||||||
|
- expose Role permissions for editing
|
||||||
|
- expose User "roles" for editing
|
||||||
|
- improve widget, rendering for Role notes
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- add stub for `PersonView.make_user()`
|
||||||
|
- allow arbitrary kwargs for `Form.render_vue_field()`
|
||||||
|
- make some tweaks for better tailbone compatibility
|
||||||
|
- prevent delete for built-in roles
|
||||||
|
|
||||||
## v0.6.0 (2024-08-13)
|
## v0.6.0 (2024-08-13)
|
||||||
|
|
||||||
### Feat
|
### Feat
|
||||||
|
|
|
@ -6,7 +6,7 @@ build-backend = "hatchling.build"
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "WuttaWeb"
|
name = "WuttaWeb"
|
||||||
version = "0.6.0"
|
version = "0.7.0"
|
||||||
description = "Web App for Wutta Framework"
|
description = "Web App for Wutta Framework"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
authors = [{name = "Lance Edgar", email = "lance@edbob.org"}]
|
authors = [{name = "Lance Edgar", email = "lance@edbob.org"}]
|
||||||
|
@ -39,7 +39,7 @@ dependencies = [
|
||||||
"pyramid_tm",
|
"pyramid_tm",
|
||||||
"waitress",
|
"waitress",
|
||||||
"WebHelpers2",
|
"WebHelpers2",
|
||||||
"WuttJamaican[db]>=0.11.0",
|
"WuttJamaican[db]>=0.11.1",
|
||||||
"zope.sqlalchemy>=1.5",
|
"zope.sqlalchemy>=1.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
@ -135,6 +135,12 @@ def make_pyramid_config(settings):
|
||||||
pyramid_config.include('pyramid_mako')
|
pyramid_config.include('pyramid_mako')
|
||||||
pyramid_config.include('pyramid_tm')
|
pyramid_config.include('pyramid_tm')
|
||||||
|
|
||||||
|
# add some permissions magic
|
||||||
|
pyramid_config.add_directive('add_wutta_permission_group',
|
||||||
|
'wuttaweb.auth.add_permission_group')
|
||||||
|
pyramid_config.add_directive('add_wutta_permission',
|
||||||
|
'wuttaweb.auth.add_permission')
|
||||||
|
|
||||||
return pyramid_config
|
return pyramid_config
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -148,3 +148,93 @@ class WuttaSecurityPolicy:
|
||||||
auth = app.get_auth_handler()
|
auth = app.get_auth_handler()
|
||||||
user = self.identity(request)
|
user = self.identity(request)
|
||||||
return auth.has_permission(self.db_session, user, permission)
|
return auth.has_permission(self.db_session, user, permission)
|
||||||
|
|
||||||
|
|
||||||
|
def add_permission_group(pyramid_config, key, label=None, overwrite=True):
|
||||||
|
"""
|
||||||
|
Pyramid directive to add a "permission group" to the app's
|
||||||
|
awareness.
|
||||||
|
|
||||||
|
The app must be made aware of all permissions, so they are exposed
|
||||||
|
when editing a
|
||||||
|
:class:`~wuttjamaican:wuttjamaican.db.model.auth.Role`. The logic
|
||||||
|
for discovering permissions is in
|
||||||
|
:meth:`~wuttaweb.views.roles.RoleView.get_available_permissions()`.
|
||||||
|
|
||||||
|
This is usually called from within a master view's
|
||||||
|
:meth:`~wuttaweb.views.master.MasterView.defaults()` to establish
|
||||||
|
the permission group which applies to the view model.
|
||||||
|
|
||||||
|
A simple example of usage::
|
||||||
|
|
||||||
|
pyramid_config.add_permission_group('widgets', label="Widgets")
|
||||||
|
|
||||||
|
:param key: Unique key for the permission group. In the context
|
||||||
|
of a master view, this will be the same as
|
||||||
|
:attr:`~wuttaweb.views.master.MasterView.permission_prefix`.
|
||||||
|
|
||||||
|
:param label: Optional label for the permission group. If not
|
||||||
|
specified, it is derived from ``key``.
|
||||||
|
|
||||||
|
:param overwrite: If the permission group was already established,
|
||||||
|
this flag controls whether the group's label should be
|
||||||
|
overwritten (with ``label``).
|
||||||
|
|
||||||
|
See also :func:`add_permission()`.
|
||||||
|
"""
|
||||||
|
config = pyramid_config.get_settings()['wutta_config']
|
||||||
|
app = config.get_app()
|
||||||
|
def action():
|
||||||
|
perms = pyramid_config.get_settings().get('wutta_permissions', {})
|
||||||
|
if overwrite or key not in perms:
|
||||||
|
group = perms.setdefault(key, {'key': key})
|
||||||
|
group['label'] = label or app.make_title(key)
|
||||||
|
pyramid_config.add_settings({'wutta_permissions': perms})
|
||||||
|
pyramid_config.action(None, action)
|
||||||
|
|
||||||
|
|
||||||
|
def add_permission(pyramid_config, groupkey, key, label=None):
|
||||||
|
"""
|
||||||
|
Pyramid directive to add a single "permission" to the app's
|
||||||
|
awareness.
|
||||||
|
|
||||||
|
The app must be made aware of all permissions, so they are exposed
|
||||||
|
when editing a
|
||||||
|
:class:`~wuttjamaican:wuttjamaican.db.model.auth.Role`. The logic
|
||||||
|
for discovering permissions is in
|
||||||
|
:meth:`~wuttaweb.views.roles.RoleView.get_available_permissions()`.
|
||||||
|
|
||||||
|
This is usually called from within a master view's
|
||||||
|
:meth:`~wuttaweb.views.master.MasterView.defaults()` to establish
|
||||||
|
"known" permissions based on master view feature flags
|
||||||
|
(:attr:`~wuttaweb.views.master.MasterView.viewable`,
|
||||||
|
:attr:`~wuttaweb.views.master.MasterView.editable`, etc.).
|
||||||
|
|
||||||
|
A simple example of usage::
|
||||||
|
|
||||||
|
pyramid_config.add_permission('widgets', 'widgets.polish',
|
||||||
|
label="Polish all the widgets")
|
||||||
|
|
||||||
|
:param key: Unique key for the permission group. In the context
|
||||||
|
of a master view, this will be the same as
|
||||||
|
:attr:`~wuttaweb.views.master.MasterView.permission_prefix`.
|
||||||
|
|
||||||
|
:param key: Unique key for the permission. This should be the
|
||||||
|
"complete" permission name which includes the permission
|
||||||
|
prefix.
|
||||||
|
|
||||||
|
:param label: Optional label for the permission. If not
|
||||||
|
specified, it is derived from ``key``.
|
||||||
|
|
||||||
|
See also :func:`add_permission_group()`.
|
||||||
|
"""
|
||||||
|
def action():
|
||||||
|
config = pyramid_config.get_settings()['wutta_config']
|
||||||
|
app = config.get_app()
|
||||||
|
perms = pyramid_config.get_settings().get('wutta_permissions', {})
|
||||||
|
group = perms.setdefault(groupkey, {'key': groupkey})
|
||||||
|
group.setdefault('label', app.make_title(groupkey))
|
||||||
|
perm = group.setdefault('perms', {}).setdefault(key, {'key': key})
|
||||||
|
perm['label'] = label or app.make_title(key)
|
||||||
|
pyramid_config.add_settings({'wutta_permissions': perms})
|
||||||
|
pyramid_config.action(None, action)
|
||||||
|
|
|
@ -120,6 +120,13 @@ class Form:
|
||||||
|
|
||||||
See also :meth:`set_validator()`.
|
See also :meth:`set_validator()`.
|
||||||
|
|
||||||
|
.. attribute:: defaults
|
||||||
|
|
||||||
|
Dict of default field values, used to construct the form in
|
||||||
|
:meth:`get_schema()`.
|
||||||
|
|
||||||
|
See also :meth:`set_default()`.
|
||||||
|
|
||||||
.. attribute:: readonly
|
.. attribute:: readonly
|
||||||
|
|
||||||
Boolean indicating the form does not allow submit. In practice
|
Boolean indicating the form does not allow submit. In practice
|
||||||
|
@ -248,6 +255,7 @@ class Form:
|
||||||
nodes={},
|
nodes={},
|
||||||
widgets={},
|
widgets={},
|
||||||
validators={},
|
validators={},
|
||||||
|
defaults={},
|
||||||
readonly=False,
|
readonly=False,
|
||||||
readonly_fields=[],
|
readonly_fields=[],
|
||||||
required_fields={},
|
required_fields={},
|
||||||
|
@ -271,6 +279,7 @@ class Form:
|
||||||
self.nodes = nodes or {}
|
self.nodes = nodes or {}
|
||||||
self.widgets = widgets or {}
|
self.widgets = widgets or {}
|
||||||
self.validators = validators or {}
|
self.validators = validators or {}
|
||||||
|
self.defaults = defaults or {}
|
||||||
self.readonly = readonly
|
self.readonly = readonly
|
||||||
self.readonly_fields = set(readonly_fields or [])
|
self.readonly_fields = set(readonly_fields or [])
|
||||||
self.required_fields = required_fields or {}
|
self.required_fields = required_fields or {}
|
||||||
|
@ -375,6 +384,23 @@ class Form:
|
||||||
"""
|
"""
|
||||||
self.fields = FieldList(fields)
|
self.fields = FieldList(fields)
|
||||||
|
|
||||||
|
def append(self, *keys):
|
||||||
|
"""
|
||||||
|
Add some fields(s) to the form.
|
||||||
|
|
||||||
|
This is a convenience to allow adding multiple fields at
|
||||||
|
once::
|
||||||
|
|
||||||
|
form.append('first_field',
|
||||||
|
'second_field',
|
||||||
|
'third_field')
|
||||||
|
|
||||||
|
It will add each field to :attr:`fields`.
|
||||||
|
"""
|
||||||
|
for key in keys:
|
||||||
|
if key not in self.fields:
|
||||||
|
self.fields.append(key)
|
||||||
|
|
||||||
def remove(self, *keys):
|
def remove(self, *keys):
|
||||||
"""
|
"""
|
||||||
Remove some fields(s) from the form.
|
Remove some fields(s) from the form.
|
||||||
|
@ -471,6 +497,18 @@ class Form:
|
||||||
if self.schema and key in self.schema:
|
if self.schema and key in self.schema:
|
||||||
self.schema[key].validator = validator
|
self.schema[key].validator = validator
|
||||||
|
|
||||||
|
def set_default(self, key, value):
|
||||||
|
"""
|
||||||
|
Set/override the default value for a field.
|
||||||
|
|
||||||
|
:param key: Name of field.
|
||||||
|
|
||||||
|
:param validator: Default value for the field.
|
||||||
|
|
||||||
|
Default value overrides are tracked via :attr:`defaults`.
|
||||||
|
"""
|
||||||
|
self.defaults[key] = value
|
||||||
|
|
||||||
def set_readonly(self, key, readonly=True):
|
def set_readonly(self, key, readonly=True):
|
||||||
"""
|
"""
|
||||||
Enable or disable the "readonly" flag for a given field.
|
Enable or disable the "readonly" flag for a given field.
|
||||||
|
@ -624,29 +662,22 @@ class Form:
|
||||||
|
|
||||||
if self.model_class:
|
if self.model_class:
|
||||||
|
|
||||||
# first define full list of 'includes' - final schema
|
# collect list of field names and/or nodes
|
||||||
# should contain all of these fields
|
includes = []
|
||||||
includes = list(fields)
|
for key in fields:
|
||||||
|
|
||||||
# determine which we want ColanderAlchemy to handle
|
|
||||||
auto_includes = []
|
|
||||||
for key in includes:
|
|
||||||
|
|
||||||
# skip if we already have a node defined
|
|
||||||
if key in self.nodes:
|
if key in self.nodes:
|
||||||
continue
|
includes.append(self.nodes[key])
|
||||||
|
else:
|
||||||
# we want the magic for this field
|
includes.append(key)
|
||||||
auto_includes.append(key)
|
|
||||||
|
|
||||||
# make initial schema with ColanderAlchemy magic
|
# make initial schema with ColanderAlchemy magic
|
||||||
schema = SQLAlchemySchemaNode(self.model_class,
|
schema = SQLAlchemySchemaNode(self.model_class,
|
||||||
includes=auto_includes)
|
includes=includes)
|
||||||
|
|
||||||
# now fill in the blanks for non-magic fields
|
# fill in the blanks if anything got missed
|
||||||
for key in includes:
|
for key in fields:
|
||||||
if key not in auto_includes:
|
if key not in schema:
|
||||||
node = self.nodes[key]
|
node = colander.SchemaNode(colander.String(), name=key)
|
||||||
schema.add(node)
|
schema.add(node)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
|
@ -685,6 +716,11 @@ class Form:
|
||||||
elif key in schema: # field-level
|
elif key in schema: # field-level
|
||||||
schema[key].validator = validator
|
schema[key].validator = validator
|
||||||
|
|
||||||
|
# apply default value overrides
|
||||||
|
for key, value in self.defaults.items():
|
||||||
|
if key in schema:
|
||||||
|
schema[key].default = value
|
||||||
|
|
||||||
# apply required flags
|
# apply required flags
|
||||||
for key, required in self.required_fields.items():
|
for key, required in self.required_fields.items():
|
||||||
if key in schema:
|
if key in schema:
|
||||||
|
@ -775,7 +811,12 @@ class Form:
|
||||||
output = render(template, context)
|
output = render(template, context)
|
||||||
return HTML.literal(output)
|
return HTML.literal(output)
|
||||||
|
|
||||||
def render_vue_field(self, fieldname, readonly=None):
|
def render_vue_field(
|
||||||
|
self,
|
||||||
|
fieldname,
|
||||||
|
readonly=None,
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Render the given field completely, i.e. ``<b-field>`` wrapper
|
Render the given field completely, i.e. ``<b-field>`` wrapper
|
||||||
with label and containing a widget.
|
with label and containing a widget.
|
||||||
|
@ -791,6 +832,12 @@ class Form:
|
||||||
message="something went wrong!">
|
message="something went wrong!">
|
||||||
<!-- widget element(s) -->
|
<!-- widget element(s) -->
|
||||||
</b-field>
|
</b-field>
|
||||||
|
|
||||||
|
.. warning::
|
||||||
|
|
||||||
|
Any ``**kwargs`` received from caller are ignored by this
|
||||||
|
method. For now they are allowed, for sake of backwawrd
|
||||||
|
compatibility. This may change in the future.
|
||||||
"""
|
"""
|
||||||
# readonly comes from: caller, field flag, or form flag
|
# readonly comes from: caller, field flag, or form flag
|
||||||
if readonly is None:
|
if readonly is None:
|
||||||
|
@ -903,6 +950,20 @@ class Form:
|
||||||
|
|
||||||
return model_data
|
return model_data
|
||||||
|
|
||||||
|
# TODO: for tailbone compat, should document?
|
||||||
|
# (ideally should remove this and find a better way)
|
||||||
|
def get_vue_field_value(self, key):
|
||||||
|
""" """
|
||||||
|
if key not in self.fields:
|
||||||
|
return
|
||||||
|
|
||||||
|
dform = self.get_deform()
|
||||||
|
if key not in dform:
|
||||||
|
return
|
||||||
|
|
||||||
|
field = dform[key]
|
||||||
|
return make_json_safe(field.cstruct)
|
||||||
|
|
||||||
def validate(self):
|
def validate(self):
|
||||||
"""
|
"""
|
||||||
Try to validate the form, using data from the :attr:`request`.
|
Try to validate the form, using data from the :attr:`request`.
|
||||||
|
|
|
@ -257,3 +257,101 @@ class PersonRef(ObjectRef):
|
||||||
def sort_query(self, query):
|
def sort_query(self, query):
|
||||||
""" """
|
""" """
|
||||||
return query.order_by(self.model_class.full_name)
|
return query.order_by(self.model_class.full_name)
|
||||||
|
|
||||||
|
|
||||||
|
class WuttaSet(colander.Set):
|
||||||
|
"""
|
||||||
|
Custom schema type for :class:`python:set` fields.
|
||||||
|
|
||||||
|
This is a subclass of :class:`colander.Set`, but adds
|
||||||
|
Wutta-related params to the constructor.
|
||||||
|
|
||||||
|
:param request: Current :term:`request` object.
|
||||||
|
|
||||||
|
:param session: Optional :term:`db session` to use instead of
|
||||||
|
:class:`wuttaweb.db.Session`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, request, session=None):
|
||||||
|
super().__init__()
|
||||||
|
self.request = request
|
||||||
|
self.config = self.request.wutta_config
|
||||||
|
self.app = self.config.get_app()
|
||||||
|
self.session = session or Session()
|
||||||
|
|
||||||
|
|
||||||
|
class RoleRefs(WuttaSet):
|
||||||
|
"""
|
||||||
|
Form schema type for the User
|
||||||
|
:attr:`~wuttjamaican:wuttjamaican.db.model.auth.User.roles`
|
||||||
|
association proxy field.
|
||||||
|
|
||||||
|
This is a subclass of :class:`WuttaSet`. It uses a ``set`` of
|
||||||
|
:class:`~wuttjamaican:wuttjamaican.db.model.auth.Role` ``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.RoleRefsWidget`.
|
||||||
|
"""
|
||||||
|
kwargs.setdefault('session', self.session)
|
||||||
|
|
||||||
|
if 'values' not in kwargs:
|
||||||
|
model = self.app.model
|
||||||
|
auth = self.app.get_auth_handler()
|
||||||
|
avoid = {
|
||||||
|
auth.get_role_authenticated(self.session),
|
||||||
|
auth.get_role_anonymous(self.session),
|
||||||
|
}
|
||||||
|
avoid = set([role.uuid for role in avoid])
|
||||||
|
roles = self.session.query(model.Role)\
|
||||||
|
.filter(~model.Role.uuid.in_(avoid))\
|
||||||
|
.order_by(model.Role.name)\
|
||||||
|
.all()
|
||||||
|
values = [(role.uuid, role.name) for role in roles]
|
||||||
|
kwargs['values'] = values
|
||||||
|
|
||||||
|
return widgets.RoleRefsWidget(self.request, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class Permissions(WuttaSet):
|
||||||
|
"""
|
||||||
|
Form schema type for the Role
|
||||||
|
:attr:`~wuttjamaican:wuttjamaican.db.model.auth.Role.permissions`
|
||||||
|
association proxy field.
|
||||||
|
|
||||||
|
This is a subclass of :class:`WuttaSet`. It uses a ``set`` of
|
||||||
|
:attr:`~wuttjamaican:wuttjamaican.db.model.auth.Permission.permission`
|
||||||
|
values for underlying data format.
|
||||||
|
|
||||||
|
:param permissions: Dict with all possible permissions. Should be
|
||||||
|
in the same format as returned by
|
||||||
|
:meth:`~wuttaweb.views.roles.RoleView.get_available_permissions()`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, request, permissions, *args, **kwargs):
|
||||||
|
super().__init__(request, *args, **kwargs)
|
||||||
|
self.permissions = permissions
|
||||||
|
|
||||||
|
def widget_maker(self, **kwargs):
|
||||||
|
"""
|
||||||
|
Constructs a default widget for the field.
|
||||||
|
|
||||||
|
:returns: Instance of
|
||||||
|
:class:`~wuttaweb.forms.widgets.PermissionsWidget`.
|
||||||
|
"""
|
||||||
|
kwargs.setdefault('session', self.session)
|
||||||
|
kwargs.setdefault('permissions', self.permissions)
|
||||||
|
|
||||||
|
if 'values' not in kwargs:
|
||||||
|
values = []
|
||||||
|
for gkey, group in self.permissions.items():
|
||||||
|
for pkey, perm in group['perms'].items():
|
||||||
|
values.append((pkey, perm['label']))
|
||||||
|
kwargs['values'] = values
|
||||||
|
|
||||||
|
return widgets.PermissionsWidget(self.request, **kwargs)
|
||||||
|
|
|
@ -30,12 +30,21 @@ in the namespace:
|
||||||
|
|
||||||
* :class:`deform:deform.widget.Widget` (base class)
|
* :class:`deform:deform.widget.Widget` (base class)
|
||||||
* :class:`deform:deform.widget.TextInputWidget`
|
* :class:`deform:deform.widget.TextInputWidget`
|
||||||
|
* :class:`deform:deform.widget.TextAreaWidget`
|
||||||
|
* :class:`deform:deform.widget.PasswordWidget`
|
||||||
|
* :class:`deform:deform.widget.CheckedPasswordWidget`
|
||||||
* :class:`deform:deform.widget.SelectWidget`
|
* :class:`deform:deform.widget.SelectWidget`
|
||||||
|
* :class:`deform:deform.widget.CheckboxChoiceWidget`
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from deform.widget import Widget, TextInputWidget, SelectWidget
|
import colander
|
||||||
|
from deform.widget import (Widget, TextInputWidget, TextAreaWidget,
|
||||||
|
PasswordWidget, CheckedPasswordWidget,
|
||||||
|
SelectWidget, CheckboxChoiceWidget)
|
||||||
from webhelpers2.html import HTML
|
from webhelpers2.html import HTML
|
||||||
|
|
||||||
|
from wuttaweb.db import Session
|
||||||
|
|
||||||
|
|
||||||
class ObjectRefWidget(SelectWidget):
|
class ObjectRefWidget(SelectWidget):
|
||||||
"""
|
"""
|
||||||
|
@ -48,6 +57,18 @@ class ObjectRefWidget(SelectWidget):
|
||||||
the form schema; via
|
the form schema; via
|
||||||
:meth:`~wuttaweb.forms.schema.ObjectRef.widget_maker()`.
|
:meth:`~wuttaweb.forms.schema.ObjectRef.widget_maker()`.
|
||||||
|
|
||||||
|
In readonly mode, this renders a ``<span>`` tag around the
|
||||||
|
:attr:`model_instance` (converted to string).
|
||||||
|
|
||||||
|
Otherwise it renders a select (dropdown) element allowing user to
|
||||||
|
choose from available records.
|
||||||
|
|
||||||
|
This is a subclass of :class:`deform:deform.widget.SelectWidget`
|
||||||
|
and uses these Deform templates:
|
||||||
|
|
||||||
|
* ``select``
|
||||||
|
* ``readonly/objectref``
|
||||||
|
|
||||||
.. attribute:: model_instance
|
.. attribute:: model_instance
|
||||||
|
|
||||||
Reference to the model record instance, i.e. the "far side" of
|
Reference to the model record instance, i.e. the "far side" of
|
||||||
|
@ -60,23 +81,112 @@ class ObjectRefWidget(SelectWidget):
|
||||||
when the :class:`~wuttaweb.forms.schema.ObjectRef` type
|
when the :class:`~wuttaweb.forms.schema.ObjectRef` type
|
||||||
instance (associated with the node) is serialized.
|
instance (associated with the node) is serialized.
|
||||||
"""
|
"""
|
||||||
|
readonly_template = 'readonly/objectref'
|
||||||
|
|
||||||
def __init__(self, request, *args, **kwargs):
|
def __init__(self, request, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
self.request = request
|
self.request = request
|
||||||
|
|
||||||
|
|
||||||
|
class NotesWidget(TextAreaWidget):
|
||||||
|
"""
|
||||||
|
Widget for use with "notes" fields.
|
||||||
|
|
||||||
|
In readonly mode, this shows the notes with a background to make
|
||||||
|
them stand out a bit more.
|
||||||
|
|
||||||
|
Otherwise it effectively shows a ``<textarea>`` input element.
|
||||||
|
|
||||||
|
This is a subclass of :class:`deform:deform.widget.TextAreaWidget`
|
||||||
|
and uses these Deform templates:
|
||||||
|
|
||||||
|
* ``textarea``
|
||||||
|
* ``readonly/notes``
|
||||||
|
"""
|
||||||
|
readonly_template = 'readonly/notes'
|
||||||
|
|
||||||
|
|
||||||
|
class WuttaCheckboxChoiceWidget(CheckboxChoiceWidget):
|
||||||
|
"""
|
||||||
|
Custom widget for :class:`python:set` fields.
|
||||||
|
|
||||||
|
This is a subclass of
|
||||||
|
:class:`deform:deform.widget.CheckboxChoiceWidget`, but adds
|
||||||
|
Wutta-related params to the constructor.
|
||||||
|
|
||||||
|
:param request: Current :term:`request` object.
|
||||||
|
|
||||||
|
:param session: Optional :term:`db session` to use instead of
|
||||||
|
:class:`wuttaweb.db.Session`.
|
||||||
|
|
||||||
|
It uses these Deform templates:
|
||||||
|
|
||||||
|
* ``checkbox_choice``
|
||||||
|
* ``readonly/checkbox_choice``
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, request, session=None, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.request = request
|
||||||
|
self.config = self.request.wutta_config
|
||||||
|
self.app = self.config.get_app()
|
||||||
|
self.session = session or Session()
|
||||||
|
|
||||||
|
|
||||||
|
class RoleRefsWidget(WuttaCheckboxChoiceWidget):
|
||||||
|
"""
|
||||||
|
Widget for use with User
|
||||||
|
:attr:`~wuttjamaican:wuttjamaican.db.model.auth.User.roles` field.
|
||||||
|
|
||||||
|
This is a subclass of :class:`WuttaCheckboxChoiceWidget`.
|
||||||
|
"""
|
||||||
|
|
||||||
def serialize(self, field, cstruct, **kw):
|
def serialize(self, field, cstruct, **kw):
|
||||||
"""
|
""" """
|
||||||
Serialize the widget.
|
# special logic when field is editable
|
||||||
|
|
||||||
In readonly mode, returns a ``<span>`` tag around the
|
|
||||||
:attr:`model_instance` rendered as string.
|
|
||||||
|
|
||||||
Otherwise renders via the ``deform/select`` template.
|
|
||||||
"""
|
|
||||||
readonly = kw.get('readonly', self.readonly)
|
readonly = kw.get('readonly', self.readonly)
|
||||||
if readonly:
|
if not readonly:
|
||||||
obj = field.schema.model_instance
|
|
||||||
return HTML.tag('span', c=str(obj or ''))
|
# but does not apply if current user is root
|
||||||
|
if not self.request.is_root:
|
||||||
|
auth = self.app.get_auth_handler()
|
||||||
|
admin = auth.get_role_administrator(self.session)
|
||||||
|
|
||||||
|
# prune admin role from values list; it should not be
|
||||||
|
# one of the options since current user is not admin
|
||||||
|
values = kw.get('values', self.values)
|
||||||
|
values = [val for val in values
|
||||||
|
if val[0] != admin.uuid]
|
||||||
|
kw['values'] = values
|
||||||
|
|
||||||
|
# default logic from here
|
||||||
|
return super().serialize(field, cstruct, **kw)
|
||||||
|
|
||||||
|
|
||||||
|
class PermissionsWidget(WuttaCheckboxChoiceWidget):
|
||||||
|
"""
|
||||||
|
Widget for use with Role
|
||||||
|
:attr:`~wuttjamaican:wuttjamaican.db.model.auth.Role.permissions`
|
||||||
|
field.
|
||||||
|
|
||||||
|
This is a subclass of :class:`WuttaCheckboxChoiceWidget`. It uses
|
||||||
|
these Deform templates:
|
||||||
|
|
||||||
|
* ``permissions``
|
||||||
|
* ``readonly/permissions``
|
||||||
|
"""
|
||||||
|
template = 'permissions'
|
||||||
|
readonly_template = 'readonly/permissions'
|
||||||
|
|
||||||
|
def serialize(self, field, cstruct, **kw):
|
||||||
|
""" """
|
||||||
|
kw.setdefault('permissions', self.permissions)
|
||||||
|
|
||||||
|
if 'values' not in kw:
|
||||||
|
values = []
|
||||||
|
for gkey, group in self.permissions.items():
|
||||||
|
for pkey, perm in group['perms'].items():
|
||||||
|
values.append((pkey, perm['label']))
|
||||||
|
kw['values'] = values
|
||||||
|
|
||||||
return super().serialize(field, cstruct, **kw)
|
return super().serialize(field, cstruct, **kw)
|
||||||
|
|
|
@ -24,6 +24,7 @@
|
||||||
Base grid classes
|
Base grid classes
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import functools
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
@ -81,6 +82,12 @@ class Grid:
|
||||||
model records) or else an object capable of producing such a
|
model records) or else an object capable of producing such a
|
||||||
list, e.g. SQLAlchemy query.
|
list, e.g. SQLAlchemy query.
|
||||||
|
|
||||||
|
.. attribute:: renderers
|
||||||
|
|
||||||
|
Dict of column (cell) value renderer overrides.
|
||||||
|
|
||||||
|
See also :meth:`set_renderer()`.
|
||||||
|
|
||||||
.. attribute:: actions
|
.. attribute:: actions
|
||||||
|
|
||||||
List of :class:`GridAction` instances represenging action links
|
List of :class:`GridAction` instances represenging action links
|
||||||
|
@ -106,6 +113,7 @@ class Grid:
|
||||||
key=None,
|
key=None,
|
||||||
columns=None,
|
columns=None,
|
||||||
data=None,
|
data=None,
|
||||||
|
renderers={},
|
||||||
actions=[],
|
actions=[],
|
||||||
linked_columns=[],
|
linked_columns=[],
|
||||||
vue_tagname='wutta-grid',
|
vue_tagname='wutta-grid',
|
||||||
|
@ -114,6 +122,7 @@ class Grid:
|
||||||
self.model_class = model_class
|
self.model_class = model_class
|
||||||
self.key = key
|
self.key = key
|
||||||
self.data = data
|
self.data = data
|
||||||
|
self.renderers = renderers or {}
|
||||||
self.actions = actions or []
|
self.actions = actions or []
|
||||||
self.linked_columns = linked_columns or []
|
self.linked_columns = linked_columns or []
|
||||||
self.vue_tagname = vue_tagname
|
self.vue_tagname = vue_tagname
|
||||||
|
@ -177,6 +186,23 @@ class Grid:
|
||||||
"""
|
"""
|
||||||
self.columns = FieldList(columns)
|
self.columns = FieldList(columns)
|
||||||
|
|
||||||
|
def append(self, *keys):
|
||||||
|
"""
|
||||||
|
Add some columns(s) to the grid.
|
||||||
|
|
||||||
|
This is a convenience to allow adding multiple columns at
|
||||||
|
once::
|
||||||
|
|
||||||
|
grid.append('first_field',
|
||||||
|
'second_field',
|
||||||
|
'third_field')
|
||||||
|
|
||||||
|
It will add each column to :attr:`columns`.
|
||||||
|
"""
|
||||||
|
for key in keys:
|
||||||
|
if key not in self.columns:
|
||||||
|
self.columns.append(key)
|
||||||
|
|
||||||
def remove(self, *keys):
|
def remove(self, *keys):
|
||||||
"""
|
"""
|
||||||
Remove some column(s) from the grid.
|
Remove some column(s) from the grid.
|
||||||
|
@ -194,6 +220,47 @@ class Grid:
|
||||||
if key in self.columns:
|
if key in self.columns:
|
||||||
self.columns.remove(key)
|
self.columns.remove(key)
|
||||||
|
|
||||||
|
def set_renderer(self, key, renderer, **kwargs):
|
||||||
|
"""
|
||||||
|
Set/override the value renderer for a column.
|
||||||
|
|
||||||
|
:param key: Name of column.
|
||||||
|
|
||||||
|
:param renderer: Callable as described below.
|
||||||
|
|
||||||
|
Depending on the nature of grid data, sometimes a cell's
|
||||||
|
"as-is" value will be undesirable for display purposes.
|
||||||
|
|
||||||
|
The logic in :meth:`get_vue_data()` will first "convert" all
|
||||||
|
grid data as necessary so that it is at least JSON-compatible.
|
||||||
|
|
||||||
|
But then it also will invoke a renderer override (if defined)
|
||||||
|
to obtain the "final" cell value.
|
||||||
|
|
||||||
|
A renderer must be a callable which accepts 3 args ``(record,
|
||||||
|
key, value)``:
|
||||||
|
|
||||||
|
* ``record`` is the "original" record from :attr:`data`
|
||||||
|
* ``key`` is the column name
|
||||||
|
* ``value`` is the JSON-safe cell value
|
||||||
|
|
||||||
|
Whatever the renderer returns, is then used as final cell
|
||||||
|
value. For instance::
|
||||||
|
|
||||||
|
from webhelpers2.html import HTML
|
||||||
|
|
||||||
|
def render_foo(record, key, value):
|
||||||
|
return HTML.literal("<p>this is the final cell value</p>")
|
||||||
|
|
||||||
|
grid = Grid(columns=['foo', 'bar'])
|
||||||
|
grid.set_renderer('foo', render_foo)
|
||||||
|
|
||||||
|
Renderer overrides are tracked via :attr:`renderers`.
|
||||||
|
"""
|
||||||
|
if kwargs:
|
||||||
|
renderer = functools.partial(renderer, **kwargs)
|
||||||
|
self.renderers[key] = renderer
|
||||||
|
|
||||||
def set_link(self, key, link=True):
|
def set_link(self, key, link=True):
|
||||||
"""
|
"""
|
||||||
Explicitly enable or disable auto-link behavior for a given
|
Explicitly enable or disable auto-link behavior for a given
|
||||||
|
@ -352,16 +419,26 @@ class Grid:
|
||||||
# we have action(s), so add URL(s) for each record in data
|
# we have action(s), so add URL(s) for each record in data
|
||||||
data = []
|
data = []
|
||||||
for i, record in enumerate(original_data):
|
for i, record in enumerate(original_data):
|
||||||
|
original_record = record
|
||||||
|
|
||||||
|
record = dict(record)
|
||||||
|
|
||||||
# convert data if needed, for json compat
|
# convert data if needed, for json compat
|
||||||
record = make_json_safe(record,
|
record = make_json_safe(record,
|
||||||
# TODO: is this a good idea?
|
# TODO: is this a good idea?
|
||||||
warn=False)
|
warn=False)
|
||||||
|
|
||||||
|
# customize value rendering where applicable
|
||||||
|
for key in self.renderers:
|
||||||
|
value = record[key]
|
||||||
|
record[key] = self.renderers[key](original_record, key, value)
|
||||||
|
|
||||||
# add action urls to each record
|
# add action urls to each record
|
||||||
for action in self.actions:
|
for action in self.actions:
|
||||||
url = action.get_url(record, i)
|
|
||||||
key = f'_action_url_{action.key}'
|
key = f'_action_url_{action.key}'
|
||||||
|
if key not in record:
|
||||||
|
url = action.get_url(original_record, i)
|
||||||
|
if url:
|
||||||
record[key] = url
|
record[key] = url
|
||||||
|
|
||||||
data.append(record)
|
data.append(record)
|
||||||
|
@ -475,7 +552,7 @@ class GridAction:
|
||||||
See also :meth:`render_icon_and_label()`.
|
See also :meth:`render_icon_and_label()`.
|
||||||
"""
|
"""
|
||||||
if self.request.use_oruga:
|
if self.request.use_oruga:
|
||||||
raise NotImplementedError
|
return HTML.tag('o-icon', icon=self.icon)
|
||||||
|
|
||||||
return HTML.tag('i', class_=f'fas fa-{self.icon}')
|
return HTML.tag('i', class_=f'fas fa-{self.icon}')
|
||||||
|
|
||||||
|
|
|
@ -272,9 +272,8 @@ class MenuHandler(GenericHandler):
|
||||||
current user.
|
current user.
|
||||||
"""
|
"""
|
||||||
perm = item.get('perm')
|
perm = item.get('perm')
|
||||||
# TODO
|
if perm:
|
||||||
# if perm:
|
return request.has_perm(perm)
|
||||||
# return request.has_perm(perm)
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _mark_allowed(self, request, menus):
|
def _mark_allowed(self, request, menus):
|
||||||
|
|
|
@ -139,6 +139,16 @@ def new_request_set_user(
|
||||||
pyramid_config.add_subscriber('wuttaweb.subscribers.new_request_set_user',
|
pyramid_config.add_subscriber('wuttaweb.subscribers.new_request_set_user',
|
||||||
'pyramid.events.NewRequest')
|
'pyramid.events.NewRequest')
|
||||||
|
|
||||||
|
You may wish to "supplement" this hook by registering your own
|
||||||
|
custom hook and then invoking this one as needed. You can then
|
||||||
|
pass certain params to override only parts of the logic:
|
||||||
|
|
||||||
|
:param user_getter: Optional getter function to retrieve the user
|
||||||
|
from database, instead of :func:`default_user_getter()`.
|
||||||
|
|
||||||
|
:param db_session: Optional :term:`db session` to use,
|
||||||
|
instead of :class:`wuttaweb.db.Session`.
|
||||||
|
|
||||||
This will add to the request object:
|
This will add to the request object:
|
||||||
|
|
||||||
.. attribute:: request.user
|
.. attribute:: request.user
|
||||||
|
@ -158,19 +168,36 @@ def new_request_set_user(
|
||||||
privileges. This is only possible if :attr:`request.is_admin`
|
privileges. This is only possible if :attr:`request.is_admin`
|
||||||
is also true.
|
is also true.
|
||||||
|
|
||||||
You may wish to "supplement" this hook by registering your own
|
.. attribute:: request.user_permissions
|
||||||
custom hook and then invoking this one as needed. You can then
|
|
||||||
pass certain params to override only parts of the logic:
|
|
||||||
|
|
||||||
:param user_getter: Optional getter function to retrieve the user
|
The ``set`` of permission names which are granted to the
|
||||||
from database, instead of :func:`default_user_getter()`.
|
current user.
|
||||||
|
|
||||||
|
This set is obtained by calling
|
||||||
|
:meth:`~wuttjamaican:wuttjamaican.auth.AuthHandler.get_permissions()`.
|
||||||
|
|
||||||
|
.. function:: request.has_perm(name)
|
||||||
|
|
||||||
|
Shortcut to check if current user has the given permission::
|
||||||
|
|
||||||
|
if not request.has_perm('users.edit'):
|
||||||
|
raise self.forbidden()
|
||||||
|
|
||||||
|
.. function:: request.has_any_perm(*names)
|
||||||
|
|
||||||
|
Shortcut to check if current user has any of the given
|
||||||
|
permissions::
|
||||||
|
|
||||||
|
if request.has_any_perm('users.list', 'users.view'):
|
||||||
|
return "can either list or view"
|
||||||
|
else:
|
||||||
|
raise self.forbidden()
|
||||||
|
|
||||||
:param db_session: Optional :term:`db session` to use,
|
|
||||||
instead of :class:`wuttaweb.db.Session`.
|
|
||||||
"""
|
"""
|
||||||
request = event.request
|
request = event.request
|
||||||
config = request.registry.settings['wutta_config']
|
config = request.registry.settings['wutta_config']
|
||||||
app = config.get_app()
|
app = config.get_app()
|
||||||
|
auth = app.get_auth_handler()
|
||||||
|
|
||||||
# request.user
|
# request.user
|
||||||
if db_session:
|
if db_session:
|
||||||
|
@ -179,7 +206,6 @@ def new_request_set_user(
|
||||||
|
|
||||||
# request.is_admin
|
# request.is_admin
|
||||||
def is_admin(request):
|
def is_admin(request):
|
||||||
auth = app.get_auth_handler()
|
|
||||||
return auth.user_is_admin(request.user)
|
return auth.user_is_admin(request.user)
|
||||||
request.set_property(is_admin, reify=True)
|
request.set_property(is_admin, reify=True)
|
||||||
|
|
||||||
|
@ -191,6 +217,29 @@ def new_request_set_user(
|
||||||
return False
|
return False
|
||||||
request.set_property(is_root, reify=True)
|
request.set_property(is_root, reify=True)
|
||||||
|
|
||||||
|
# request.user_permissions
|
||||||
|
def user_permissions(request):
|
||||||
|
session = db_session or Session()
|
||||||
|
return auth.get_permissions(session, request.user)
|
||||||
|
request.set_property(user_permissions, reify=True)
|
||||||
|
|
||||||
|
# request.has_perm()
|
||||||
|
def has_perm(name):
|
||||||
|
if request.is_root:
|
||||||
|
return True
|
||||||
|
if name in request.user_permissions:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
request.has_perm = has_perm
|
||||||
|
|
||||||
|
# request.has_any_perm()
|
||||||
|
def has_any_perm(*names):
|
||||||
|
for name in names:
|
||||||
|
if request.has_perm(name):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
request.has_any_perm = has_any_perm
|
||||||
|
|
||||||
|
|
||||||
def before_render(event):
|
def before_render(event):
|
||||||
"""
|
"""
|
||||||
|
|
|
@ -222,7 +222,7 @@
|
||||||
% else:
|
% else:
|
||||||
<h1 class="title">${index_title}</h1>
|
<h1 class="title">${index_title}</h1>
|
||||||
% endif
|
% endif
|
||||||
% if master and master.creatable and not master.creating:
|
% if master and master.creatable and not master.creating and master.has_perm('create'):
|
||||||
<wutta-button once type="is-primary"
|
<wutta-button once type="is-primary"
|
||||||
tag="a" href="${url(f'{route_prefix}.create')}"
|
tag="a" href="${url(f'{route_prefix}.create')}"
|
||||||
icon-left="plus"
|
icon-left="plus"
|
||||||
|
@ -235,8 +235,7 @@
|
||||||
|
|
||||||
<div class="level-right">
|
<div class="level-right">
|
||||||
|
|
||||||
## TODO
|
% if master and master.configurable and not master.configuring and master.has_perm('configure'):
|
||||||
% if master and master.configurable and not master.configuring:
|
|
||||||
<div class="level-item">
|
<div class="level-item">
|
||||||
<wutta-button once type="is-primary"
|
<wutta-button once type="is-primary"
|
||||||
tag="a" href="${url(f'{route_prefix}.configure')}"
|
tag="a" href="${url(f'{route_prefix}.configure')}"
|
||||||
|
@ -348,7 +347,7 @@
|
||||||
% elif request.is_admin:
|
% elif request.is_admin:
|
||||||
${h.form(url('become_root'), ref='startBeingRootForm')}
|
${h.form(url('become_root'), ref='startBeingRootForm')}
|
||||||
${h.csrf_token(request)}
|
${h.csrf_token(request)}
|
||||||
<input type="hidden" name="referrer" value="${request.current_route_url()}" />
|
<input type="hidden" name="referrer" value="${request.url}" />
|
||||||
<a @click="startBeingRoot()"
|
<a @click="startBeingRoot()"
|
||||||
class="navbar-item has-background-danger has-text-white">
|
class="navbar-item has-background-danger has-text-white">
|
||||||
Become root
|
Become root
|
||||||
|
@ -378,19 +377,23 @@
|
||||||
tag="a" href="${master.get_action_url('edit', instance)}"
|
tag="a" href="${master.get_action_url('edit', instance)}"
|
||||||
icon-left="edit"
|
icon-left="edit"
|
||||||
label="Edit This" />
|
label="Edit This" />
|
||||||
|
% if instance_deletable:
|
||||||
<wutta-button once type="is-danger"
|
<wutta-button once type="is-danger"
|
||||||
tag="a" href="${master.get_action_url('delete', instance)}"
|
tag="a" href="${master.get_action_url('delete', instance)}"
|
||||||
icon-left="trash"
|
icon-left="trash"
|
||||||
label="Delete This" />
|
label="Delete This" />
|
||||||
|
% endif
|
||||||
% elif master.editing:
|
% elif master.editing:
|
||||||
<wutta-button once
|
<wutta-button once
|
||||||
tag="a" href="${master.get_action_url('view', instance)}"
|
tag="a" href="${master.get_action_url('view', instance)}"
|
||||||
icon-left="eye"
|
icon-left="eye"
|
||||||
label="View This" />
|
label="View This" />
|
||||||
|
% if instance_deletable:
|
||||||
<wutta-button once type="is-danger"
|
<wutta-button once type="is-danger"
|
||||||
tag="a" href="${master.get_action_url('delete', instance)}"
|
tag="a" href="${master.get_action_url('delete', instance)}"
|
||||||
icon-left="trash"
|
icon-left="trash"
|
||||||
label="Delete This" />
|
label="Delete This" />
|
||||||
|
% endif
|
||||||
% elif master.deleting:
|
% elif master.deleting:
|
||||||
<wutta-button once
|
<wutta-button once
|
||||||
tag="a" href="${master.get_action_url('view', instance)}"
|
tag="a" href="${master.get_action_url('view', instance)}"
|
||||||
|
|
18
src/wuttaweb/templates/deform/checkbox_choice.pt
Normal file
18
src/wuttaweb/templates/deform/checkbox_choice.pt
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
<div tal:define="css_class css_class|field.widget.css_class;
|
||||||
|
style style|field.widget.style;
|
||||||
|
oid oid|field.oid;">
|
||||||
|
${field.start_sequence()}
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 0.5rem;">
|
||||||
|
<div tal:repeat="choice values | field.widget.values"
|
||||||
|
tal:omit-tag="">
|
||||||
|
<b-checkbox tal:define="(value, title) choice"
|
||||||
|
name="checkbox"
|
||||||
|
native-value="${value}"
|
||||||
|
tal:attributes=":value 'true' if value in cstruct else 'false';
|
||||||
|
attributes|field.widget.attributes|{};">
|
||||||
|
${title}
|
||||||
|
</b-checkbox>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${field.end_sequence()}
|
||||||
|
</div>
|
23
src/wuttaweb/templates/deform/permissions.pt
Normal file
23
src/wuttaweb/templates/deform/permissions.pt
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
<div>
|
||||||
|
${field.start_sequence()}
|
||||||
|
<tal:loop tal:repeat="groupkey sorted(permissions, key=lambda k: permissions[k]['label'].lower())">
|
||||||
|
<div class="card block"
|
||||||
|
tal:define="perms permissions[groupkey]['perms'];">
|
||||||
|
<header class="card-header">
|
||||||
|
<p class="card-header-title">${permissions[groupkey]['label']}</p>
|
||||||
|
</header>
|
||||||
|
<div class="card-content">
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 0.5rem;">
|
||||||
|
<tal:loop tal:repeat="key sorted(perms, key=lambda p: perms[p]['label'].lower())">
|
||||||
|
<b-checkbox name="checkbox"
|
||||||
|
native-value="${key}"
|
||||||
|
tal:attributes=":value 'true' if key in cstruct else 'false';">
|
||||||
|
${perms[key]['label']}
|
||||||
|
</b-checkbox>
|
||||||
|
</tal:loop>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</tal:loop>
|
||||||
|
${field.end_sequence()}
|
||||||
|
</div>
|
7
src/wuttaweb/templates/deform/readonly/notes.pt
Normal file
7
src/wuttaweb/templates/deform/readonly/notes.pt
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
<div tal:omit-tag="">
|
||||||
|
<span tal:condition="not cstruct"></span>
|
||||||
|
<pre tal:condition="cstruct"
|
||||||
|
class="is-family-sans-serif"
|
||||||
|
style="white-space: pre-wrap;"
|
||||||
|
>${cstruct}</pre>
|
||||||
|
</div>
|
1
src/wuttaweb/templates/deform/readonly/objectref.pt
Normal file
1
src/wuttaweb/templates/deform/readonly/objectref.pt
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<span>${str(field.schema.model_instance or '')}</span>
|
18
src/wuttaweb/templates/deform/readonly/permissions.pt
Normal file
18
src/wuttaweb/templates/deform/readonly/permissions.pt
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
<div>
|
||||||
|
<tal:loop tal:repeat="groupkey sorted(permissions, key=lambda k: permissions[k]['label'].lower())">
|
||||||
|
<div class="card block"
|
||||||
|
tal:condition="any([key in cstruct for key in permissions[groupkey]['perms']])"
|
||||||
|
tal:define="perms permissions[groupkey]['perms'];">
|
||||||
|
<header class="card-header">
|
||||||
|
<p class="card-header-title">${permissions[groupkey]['label']}</p>
|
||||||
|
</header>
|
||||||
|
<div class="card-content">
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 0.5rem;">
|
||||||
|
<tal:loop tal:repeat="key sorted(perms, key=lambda p: perms[p]['label'].lower())">
|
||||||
|
<span tal:condition="key in cstruct">${perms[key]['label']}</span>
|
||||||
|
</tal:loop>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</tal:loop>
|
||||||
|
</div>
|
11
src/wuttaweb/templates/deform/textarea.pt
Normal file
11
src/wuttaweb/templates/deform/textarea.pt
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
<div tal:define="name name|field.name;
|
||||||
|
oid oid|field.oid;
|
||||||
|
vmodel vmodel|'modelData.'+oid;
|
||||||
|
rows rows|field.widget.rows;
|
||||||
|
cols cols|field.widget.cols;"
|
||||||
|
tal:omit-tag="">
|
||||||
|
<b-input name="${name}"
|
||||||
|
v-model="${vmodel}"
|
||||||
|
type="textarea"
|
||||||
|
tal:attributes="attributes|field.widget.attributes|{};" />
|
||||||
|
</div>
|
26
src/wuttaweb/templates/forbidden.mako
Normal file
26
src/wuttaweb/templates/forbidden.mako
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
## -*- coding: utf-8; -*-
|
||||||
|
<%inherit file="/page.mako" />
|
||||||
|
|
||||||
|
<%def name="title()">Access Denied</%def>
|
||||||
|
|
||||||
|
<%def name="page_content()">
|
||||||
|
<div style="padding: 4rem;">
|
||||||
|
<p class="block is-size-5">
|
||||||
|
You are trying to access something for which you do not have permission.
|
||||||
|
</p>
|
||||||
|
<p class="block is-size-5">
|
||||||
|
If you feel this is an error, please ask a site admin to give you access.
|
||||||
|
</p>
|
||||||
|
% if not request.user:
|
||||||
|
<p class="block is-size-5">
|
||||||
|
Or probably, you should just ${h.link_to("Login", url('login'))}.
|
||||||
|
</p>
|
||||||
|
% endif
|
||||||
|
<b-field label="Current URL">
|
||||||
|
${request.url}
|
||||||
|
</b-field>
|
||||||
|
</div>
|
||||||
|
</%def>
|
||||||
|
|
||||||
|
|
||||||
|
${parent.body()}
|
|
@ -9,15 +9,19 @@
|
||||||
|
|
||||||
<%def name="render_this_page_template()">
|
<%def name="render_this_page_template()">
|
||||||
${parent.render_this_page_template()}
|
${parent.render_this_page_template()}
|
||||||
|
% if form is not Undefined:
|
||||||
${form.render_vue_template()}
|
${form.render_vue_template()}
|
||||||
|
% endif
|
||||||
</%def>
|
</%def>
|
||||||
|
|
||||||
<%def name="finalize_this_page_vars()">
|
<%def name="finalize_this_page_vars()">
|
||||||
${parent.finalize_this_page_vars()}
|
${parent.finalize_this_page_vars()}
|
||||||
|
% if form is not Undefined:
|
||||||
<script>
|
<script>
|
||||||
${form.vue_component}.data = function() { return ${form.vue_component}Data }
|
${form.vue_component}.data = function() { return ${form.vue_component}Data }
|
||||||
Vue.component('${form.vue_tagname}', ${form.vue_component})
|
Vue.component('${form.vue_tagname}', ${form.vue_component})
|
||||||
</script>
|
</script>
|
||||||
|
% endif
|
||||||
</%def>
|
</%def>
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -24,7 +24,8 @@
|
||||||
label="Actions"
|
label="Actions"
|
||||||
v-slot="props">
|
v-slot="props">
|
||||||
% for action in grid.actions:
|
% for action in grid.actions:
|
||||||
<a :href="props.row._action_url_${action.key}"
|
<a v-if="props.row._action_url_${action.key}"
|
||||||
|
:href="props.row._action_url_${action.key}"
|
||||||
class="${action.link_class}">
|
class="${action.link_class}">
|
||||||
${action.render_icon_and_label()}
|
${action.render_icon_and_label()}
|
||||||
</a>
|
</a>
|
||||||
|
|
23
src/wuttaweb/templates/notfound.mako
Normal file
23
src/wuttaweb/templates/notfound.mako
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
## -*- coding: utf-8; -*-
|
||||||
|
<%inherit file="/page.mako" />
|
||||||
|
|
||||||
|
<%def name="title()">Not Found</%def>
|
||||||
|
|
||||||
|
<%def name="page_content()">
|
||||||
|
<div style="padding: 4rem;">
|
||||||
|
<p class="block is-size-5">
|
||||||
|
Not saying <span class="has-text-weight-bold">you</span> don't
|
||||||
|
know what you're talking about..
|
||||||
|
</p>
|
||||||
|
<p class="block is-size-5">
|
||||||
|
..but <span class="has-text-weight-bold">*I*</span> don't know
|
||||||
|
what you're talking about.
|
||||||
|
</p>
|
||||||
|
<b-field label="Current URL">
|
||||||
|
${request.url}
|
||||||
|
</b-field>
|
||||||
|
</div>
|
||||||
|
</%def>
|
||||||
|
|
||||||
|
|
||||||
|
${parent.body()}
|
12
src/wuttaweb/templates/people/view_profile.mako
Normal file
12
src/wuttaweb/templates/people/view_profile.mako
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
## -*- coding: utf-8; -*-
|
||||||
|
<%inherit file="/master/view.mako" />
|
||||||
|
|
||||||
|
<%def name="page_content()">
|
||||||
|
<p class="block is-size-5">
|
||||||
|
TODO: view profile page content
|
||||||
|
</p>
|
||||||
|
</%def>
|
||||||
|
|
||||||
|
<%def name="title()">TODO: title</%def>
|
||||||
|
|
||||||
|
${parent.body()}
|
20
src/wuttaweb/templates/setup.mako
Normal file
20
src/wuttaweb/templates/setup.mako
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
## -*- coding: utf-8; -*-
|
||||||
|
<%inherit file="/form.mako" />
|
||||||
|
|
||||||
|
<%def name="title()">First-Time Setup</%def>
|
||||||
|
|
||||||
|
<%def name="page_content()">
|
||||||
|
<b-notification type="is-success">
|
||||||
|
<p class="block">
|
||||||
|
The app is running okay!
|
||||||
|
</p>
|
||||||
|
<p class="block">
|
||||||
|
Please setup the first Administrator account below.
|
||||||
|
</p>
|
||||||
|
</b-notification>
|
||||||
|
|
||||||
|
${parent.page_content()}
|
||||||
|
</%def>
|
||||||
|
|
||||||
|
|
||||||
|
${parent.body()}
|
|
@ -28,6 +28,8 @@ import importlib
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
import colander
|
import colander
|
||||||
from webhelpers2.html import HTML, tags
|
from webhelpers2.html import HTML, tags
|
||||||
|
|
||||||
|
@ -420,13 +422,16 @@ def get_model_fields(config, model_class=None):
|
||||||
that to determine the field listing if applicable. Otherwise this
|
that to determine the field listing if applicable. Otherwise this
|
||||||
returns ``None``.
|
returns ``None``.
|
||||||
"""
|
"""
|
||||||
if model_class:
|
if not model_class:
|
||||||
import sqlalchemy as sa
|
return
|
||||||
|
|
||||||
app = config.get_app()
|
app = config.get_app()
|
||||||
model = app.model
|
model = app.model
|
||||||
if model_class and issubclass(model_class, model.Base):
|
if not issubclass(model_class, model.Base):
|
||||||
|
return
|
||||||
|
|
||||||
mapper = sa.inspect(model_class)
|
mapper = sa.inspect(model_class)
|
||||||
fields = list([prop.key for prop in mapper.iterate_properties])
|
fields = [prop.key for prop in mapper.iterate_properties]
|
||||||
return fields
|
return fields
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -25,11 +25,11 @@ Auth Views
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import colander
|
import colander
|
||||||
from deform.widget import TextInputWidget, PasswordWidget, CheckedPasswordWidget
|
|
||||||
|
|
||||||
from wuttaweb.views import View
|
from wuttaweb.views import View
|
||||||
from wuttaweb.db import Session
|
from wuttaweb.db import Session
|
||||||
from wuttaweb.auth import login_user, logout_user
|
from wuttaweb.auth import login_user, logout_user
|
||||||
|
from wuttaweb.forms import widgets
|
||||||
|
|
||||||
|
|
||||||
class AuthView(View):
|
class AuthView(View):
|
||||||
|
@ -47,10 +47,16 @@ class AuthView(View):
|
||||||
* route: ``login``
|
* route: ``login``
|
||||||
* template: ``/auth/login.mako``
|
* template: ``/auth/login.mako``
|
||||||
"""
|
"""
|
||||||
|
model = self.app.model
|
||||||
|
session = session or Session()
|
||||||
auth = self.app.get_auth_handler()
|
auth = self.app.get_auth_handler()
|
||||||
|
|
||||||
# TODO: should call request.get_referrer()
|
# nb. redirect to /setup if no users exist
|
||||||
referrer = self.request.route_url('home')
|
user = session.query(model.User).first()
|
||||||
|
if not user:
|
||||||
|
return self.redirect(self.request.route_url('setup'))
|
||||||
|
|
||||||
|
referrer = self.request.get_referrer()
|
||||||
|
|
||||||
# redirect if already logged in
|
# redirect if already logged in
|
||||||
if self.request.user:
|
if self.request.user:
|
||||||
|
@ -69,7 +75,6 @@ class AuthView(View):
|
||||||
if data:
|
if data:
|
||||||
|
|
||||||
# truly validate user credentials
|
# truly validate user credentials
|
||||||
session = session or Session()
|
|
||||||
user = auth.authenticate_user(session, data['username'], data['password'])
|
user = auth.authenticate_user(session, data['username'], data['password'])
|
||||||
if user:
|
if user:
|
||||||
|
|
||||||
|
@ -97,14 +102,14 @@ class AuthView(View):
|
||||||
schema.add(colander.SchemaNode(
|
schema.add(colander.SchemaNode(
|
||||||
colander.String(),
|
colander.String(),
|
||||||
name='username',
|
name='username',
|
||||||
widget=TextInputWidget(attributes={
|
widget=widgets.TextInputWidget(attributes={
|
||||||
'ref': 'username',
|
'ref': 'username',
|
||||||
})))
|
})))
|
||||||
|
|
||||||
schema.add(colander.SchemaNode(
|
schema.add(colander.SchemaNode(
|
||||||
colander.String(),
|
colander.String(),
|
||||||
name='password',
|
name='password',
|
||||||
widget=PasswordWidget(attributes={
|
widget=widgets.PasswordWidget(attributes={
|
||||||
'ref': 'password',
|
'ref': 'password',
|
||||||
})))
|
})))
|
||||||
|
|
||||||
|
@ -174,13 +179,13 @@ class AuthView(View):
|
||||||
schema.add(colander.SchemaNode(
|
schema.add(colander.SchemaNode(
|
||||||
colander.String(),
|
colander.String(),
|
||||||
name='current_password',
|
name='current_password',
|
||||||
widget=PasswordWidget(),
|
widget=widgets.PasswordWidget(),
|
||||||
validator=self.change_password_validate_current_password))
|
validator=self.change_password_validate_current_password))
|
||||||
|
|
||||||
schema.add(colander.SchemaNode(
|
schema.add(colander.SchemaNode(
|
||||||
colander.String(),
|
colander.String(),
|
||||||
name='new_password',
|
name='new_password',
|
||||||
widget=CheckedPasswordWidget(),
|
widget=widgets.CheckedPasswordWidget(),
|
||||||
validator=self.change_password_validate_new_password))
|
validator=self.change_password_validate_new_password))
|
||||||
|
|
||||||
return schema
|
return schema
|
||||||
|
|
|
@ -24,7 +24,11 @@
|
||||||
Common Views
|
Common Views
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import colander
|
||||||
|
|
||||||
from wuttaweb.views import View
|
from wuttaweb.views import View
|
||||||
|
from wuttaweb.forms import widgets
|
||||||
|
from wuttaweb.db import Session
|
||||||
|
|
||||||
|
|
||||||
class CommonView(View):
|
class CommonView(View):
|
||||||
|
@ -32,7 +36,7 @@ class CommonView(View):
|
||||||
Common views shared by all apps.
|
Common views shared by all apps.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def home(self):
|
def home(self, session=None):
|
||||||
"""
|
"""
|
||||||
Home page view.
|
Home page view.
|
||||||
|
|
||||||
|
@ -40,12 +44,144 @@ class CommonView(View):
|
||||||
|
|
||||||
This is normally the view shown when a user navigates to the
|
This is normally the view shown when a user navigates to the
|
||||||
root URL for the web app.
|
root URL for the web app.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
model = self.app.model
|
||||||
|
session = session or Session()
|
||||||
|
|
||||||
|
# nb. redirect to /setup if no users exist
|
||||||
|
user = session.query(model.User).first()
|
||||||
|
if not user:
|
||||||
|
return self.redirect(self.request.route_url('setup'))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'index_title': self.app.get_title(),
|
'index_title': self.app.get_title(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def forbidden_view(self):
|
||||||
|
"""
|
||||||
|
This view is shown when a request triggers a 403 Forbidden error.
|
||||||
|
|
||||||
|
Template: ``/forbidden.mako``
|
||||||
|
"""
|
||||||
|
return {'index_title': self.app.get_title()}
|
||||||
|
|
||||||
|
def notfound_view(self):
|
||||||
|
"""
|
||||||
|
This view is shown when a request triggers a 404 Not Found error.
|
||||||
|
|
||||||
|
Template: ``/notfound.mako``
|
||||||
|
"""
|
||||||
|
return {'index_title': self.app.get_title()}
|
||||||
|
|
||||||
|
def setup(self, session=None):
|
||||||
|
"""
|
||||||
|
View for first-time app setup, to create admin user.
|
||||||
|
|
||||||
|
Template: ``/setup.mako``
|
||||||
|
|
||||||
|
This page is only meant for one-time use. As such, if the app
|
||||||
|
DB contains any users, this page will always redirect to the
|
||||||
|
home page.
|
||||||
|
|
||||||
|
However if no users exist yet, this will show a form which may
|
||||||
|
be used to create the first admin user. When finished, user
|
||||||
|
will be redirected to the login page.
|
||||||
|
|
||||||
|
.. note::
|
||||||
|
|
||||||
|
As long as there are no users in the DB, both the home and
|
||||||
|
login pages will automatically redirect to this one.
|
||||||
|
"""
|
||||||
|
model = self.app.model
|
||||||
|
session = session or Session()
|
||||||
|
|
||||||
|
# nb. this view only available until first user is created
|
||||||
|
user = session.query(model.User).first()
|
||||||
|
if user:
|
||||||
|
return self.redirect(self.request.route_url('home'))
|
||||||
|
|
||||||
|
form = self.make_form(fields=['username', 'password', 'first_name', 'last_name'],
|
||||||
|
show_button_cancel=False,
|
||||||
|
show_button_reset=True)
|
||||||
|
form.set_widget('password', widgets.CheckedPasswordWidget())
|
||||||
|
form.set_required('first_name', False)
|
||||||
|
form.set_required('last_name', False)
|
||||||
|
|
||||||
|
if form.validate():
|
||||||
|
auth = self.app.get_auth_handler()
|
||||||
|
data = form.validated
|
||||||
|
|
||||||
|
# make user
|
||||||
|
user = auth.make_user(session=session, username=data['username'])
|
||||||
|
auth.set_user_password(user, data['password'])
|
||||||
|
|
||||||
|
# assign admin role
|
||||||
|
admin = auth.get_role_administrator(session)
|
||||||
|
user.roles.append(admin)
|
||||||
|
admin.notes = ("users in this role may \"become root\".\n\n"
|
||||||
|
"it's recommended not to grant other perms to this role.")
|
||||||
|
|
||||||
|
# initialize built-in roles
|
||||||
|
authed = auth.get_role_authenticated(session)
|
||||||
|
authed.notes = ("this role represents any user who *is* logged in.\n\n"
|
||||||
|
"you may grant any perms you like to it.")
|
||||||
|
anon = auth.get_role_anonymous(session)
|
||||||
|
anon.notes = ("this role represents any user who is *not* logged in.\n\n"
|
||||||
|
"you may grant any perms you like to it.")
|
||||||
|
|
||||||
|
# also make "Site Admin" role
|
||||||
|
site_admin_perms = [
|
||||||
|
'appinfo.list',
|
||||||
|
'appinfo.configure',
|
||||||
|
'people.list',
|
||||||
|
'people.create',
|
||||||
|
'people.view',
|
||||||
|
'people.edit',
|
||||||
|
'people.delete',
|
||||||
|
'roles.list',
|
||||||
|
'roles.create',
|
||||||
|
'roles.view',
|
||||||
|
'roles.edit',
|
||||||
|
'roles.edit_builtin',
|
||||||
|
'roles.delete',
|
||||||
|
'settings.list',
|
||||||
|
'settings.create',
|
||||||
|
'settings.view',
|
||||||
|
'settings.edit',
|
||||||
|
'settings.delete',
|
||||||
|
'users.list',
|
||||||
|
'users.create',
|
||||||
|
'users.view',
|
||||||
|
'users.edit',
|
||||||
|
'users.delete',
|
||||||
|
]
|
||||||
|
admin2 = model.Role(name="Site Admin")
|
||||||
|
admin2.notes = ("this is the \"daily driver\" admin role.\n\n"
|
||||||
|
"you may grant any perms you like to it.")
|
||||||
|
session.add(admin2)
|
||||||
|
user.roles.append(admin2)
|
||||||
|
for perm in site_admin_perms:
|
||||||
|
auth.grant_permission(admin2, perm)
|
||||||
|
|
||||||
|
# maybe make person
|
||||||
|
if data['first_name'] or data['last_name']:
|
||||||
|
first = data['first_name']
|
||||||
|
last = data['last_name']
|
||||||
|
person = model.Person(first_name=first,
|
||||||
|
last_name=last,
|
||||||
|
full_name=(f"{first} {last}").strip())
|
||||||
|
session.add(person)
|
||||||
|
user.person = person
|
||||||
|
|
||||||
|
# send user to /login
|
||||||
|
self.request.session.flash("Account created! Please login below.")
|
||||||
|
return self.redirect(self.request.route_url('login'))
|
||||||
|
|
||||||
|
return {
|
||||||
|
'index_title': self.app.get_title(),
|
||||||
|
'form': form,
|
||||||
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def defaults(cls, config):
|
def defaults(cls, config):
|
||||||
cls._defaults(config)
|
cls._defaults(config)
|
||||||
|
@ -53,15 +189,28 @@ class CommonView(View):
|
||||||
@classmethod
|
@classmethod
|
||||||
def _defaults(cls, config):
|
def _defaults(cls, config):
|
||||||
|
|
||||||
# auto-correct URLs which require trailing slash
|
|
||||||
config.add_notfound_view(cls, attr='notfound', append_slash=True)
|
|
||||||
|
|
||||||
# home page
|
# home page
|
||||||
config.add_route('home', '/')
|
config.add_route('home', '/')
|
||||||
config.add_view(cls, attr='home',
|
config.add_view(cls, attr='home',
|
||||||
route_name='home',
|
route_name='home',
|
||||||
renderer='/home.mako')
|
renderer='/home.mako')
|
||||||
|
|
||||||
|
# forbidden
|
||||||
|
config.add_forbidden_view(cls, attr='forbidden_view',
|
||||||
|
renderer='/forbidden.mako')
|
||||||
|
|
||||||
|
# notfound
|
||||||
|
# nb. also, auto-correct URLs which require trailing slash
|
||||||
|
config.add_notfound_view(cls, attr='notfound_view',
|
||||||
|
append_slash=True,
|
||||||
|
renderer='/notfound.mako')
|
||||||
|
|
||||||
|
# setup
|
||||||
|
config.add_route('setup', '/setup')
|
||||||
|
config.add_view(cls, attr='setup',
|
||||||
|
route_name='setup',
|
||||||
|
renderer='/setup.mako')
|
||||||
|
|
||||||
|
|
||||||
def defaults(config, **kwargs):
|
def defaults(config, **kwargs):
|
||||||
base = globals()
|
base = globals()
|
||||||
|
|
|
@ -28,6 +28,7 @@ import sqlalchemy as sa
|
||||||
from sqlalchemy import orm
|
from sqlalchemy import orm
|
||||||
|
|
||||||
from pyramid.renderers import render_to_response
|
from pyramid.renderers import render_to_response
|
||||||
|
from webhelpers2.html import HTML
|
||||||
|
|
||||||
from wuttaweb.views import View
|
from wuttaweb.views import View
|
||||||
from wuttaweb.util import get_form_data, get_model_fields
|
from wuttaweb.util import get_form_data, get_model_fields
|
||||||
|
@ -138,6 +139,14 @@ class MasterView(View):
|
||||||
Code should not access this directly but instead call
|
Code should not access this directly but instead call
|
||||||
:meth:`get_route_prefix()`.
|
:meth:`get_route_prefix()`.
|
||||||
|
|
||||||
|
.. attribute:: permission_prefix
|
||||||
|
|
||||||
|
Optional override for the view's permission prefix,
|
||||||
|
e.g. ``'wutta_widgets'``.
|
||||||
|
|
||||||
|
Code should not access this directly but instead call
|
||||||
|
:meth:`get_permission_prefix()`.
|
||||||
|
|
||||||
.. attribute:: url_prefix
|
.. attribute:: url_prefix
|
||||||
|
|
||||||
Optional override for the view's URL prefix,
|
Optional override for the view's URL prefix,
|
||||||
|
@ -189,12 +198,16 @@ class MasterView(View):
|
||||||
i.e. it should have an :meth:`edit()` view. Default value is
|
i.e. it should have an :meth:`edit()` view. Default value is
|
||||||
``True``.
|
``True``.
|
||||||
|
|
||||||
|
See also :meth:`is_editable()`.
|
||||||
|
|
||||||
.. attribute:: deletable
|
.. attribute:: deletable
|
||||||
|
|
||||||
Boolean indicating whether the view model supports "deleting" -
|
Boolean indicating whether the view model supports "deleting" -
|
||||||
i.e. it should have a :meth:`delete()` view. Default value is
|
i.e. it should have a :meth:`delete()` view. Default value is
|
||||||
``True``.
|
``True``.
|
||||||
|
|
||||||
|
See also :meth:`is_deletable()`.
|
||||||
|
|
||||||
.. attribute:: form_fields
|
.. attribute:: form_fields
|
||||||
|
|
||||||
List of columns for the model form.
|
List of columns for the model form.
|
||||||
|
@ -229,6 +242,9 @@ class MasterView(View):
|
||||||
deleting = False
|
deleting = False
|
||||||
configuring = False
|
configuring = False
|
||||||
|
|
||||||
|
# default DB session
|
||||||
|
Session = Session
|
||||||
|
|
||||||
##############################
|
##############################
|
||||||
# index methods
|
# index methods
|
||||||
##############################
|
##############################
|
||||||
|
@ -292,7 +308,7 @@ class MasterView(View):
|
||||||
|
|
||||||
if form.validate():
|
if form.validate():
|
||||||
obj = self.create_save_form(form)
|
obj = self.create_save_form(form)
|
||||||
Session.flush()
|
self.Session.flush()
|
||||||
return self.redirect(self.get_action_url('view', obj))
|
return self.redirect(self.get_action_url('view', obj))
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
|
@ -349,7 +365,6 @@ class MasterView(View):
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
'instance': instance,
|
'instance': instance,
|
||||||
'instance_title': self.get_instance_title(instance),
|
|
||||||
'form': form,
|
'form': form,
|
||||||
}
|
}
|
||||||
return self.render_to_response('view', context)
|
return self.render_to_response('view', context)
|
||||||
|
@ -383,7 +398,6 @@ class MasterView(View):
|
||||||
"""
|
"""
|
||||||
self.editing = True
|
self.editing = True
|
||||||
instance = self.get_instance()
|
instance = self.get_instance()
|
||||||
instance_title = self.get_instance_title(instance)
|
|
||||||
|
|
||||||
form = self.make_model_form(instance,
|
form = self.make_model_form(instance,
|
||||||
cancel_url_fallback=self.get_action_url('view', instance))
|
cancel_url_fallback=self.get_action_url('view', instance))
|
||||||
|
@ -394,7 +408,6 @@ class MasterView(View):
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
'instance': instance,
|
'instance': instance,
|
||||||
'instance_title': instance_title,
|
|
||||||
'form': form,
|
'form': form,
|
||||||
}
|
}
|
||||||
return self.render_to_response('edit', context)
|
return self.render_to_response('edit', context)
|
||||||
|
@ -447,7 +460,9 @@ class MasterView(View):
|
||||||
"""
|
"""
|
||||||
self.deleting = True
|
self.deleting = True
|
||||||
instance = self.get_instance()
|
instance = self.get_instance()
|
||||||
instance_title = self.get_instance_title(instance)
|
|
||||||
|
if not self.is_deletable(instance):
|
||||||
|
return self.redirect(self.get_action_url('view', instance))
|
||||||
|
|
||||||
# nb. this form proper is not readonly..
|
# nb. this form proper is not readonly..
|
||||||
form = self.make_model_form(instance,
|
form = self.make_model_form(instance,
|
||||||
|
@ -465,7 +480,6 @@ class MasterView(View):
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
'instance': instance,
|
'instance': instance,
|
||||||
'instance_title': instance_title,
|
|
||||||
'form': form,
|
'form': form,
|
||||||
}
|
}
|
||||||
return self.render_to_response('delete', context)
|
return self.render_to_response('delete', context)
|
||||||
|
@ -500,7 +514,7 @@ class MasterView(View):
|
||||||
# configure methods
|
# configure methods
|
||||||
##############################
|
##############################
|
||||||
|
|
||||||
def configure(self):
|
def configure(self, session=None):
|
||||||
"""
|
"""
|
||||||
View for configuring aspects of the app which are pertinent to
|
View for configuring aspects of the app which are pertinent to
|
||||||
this master view and/or model.
|
this master view and/or model.
|
||||||
|
@ -546,7 +560,7 @@ class MasterView(View):
|
||||||
|
|
||||||
# maybe just remove settings
|
# maybe just remove settings
|
||||||
if self.request.POST.get('remove_settings'):
|
if self.request.POST.get('remove_settings'):
|
||||||
self.configure_remove_settings()
|
self.configure_remove_settings(session=session)
|
||||||
self.request.session.flash(f"All settings for {config_title} have been removed.",
|
self.request.session.flash(f"All settings for {config_title} have been removed.",
|
||||||
'warning')
|
'warning')
|
||||||
|
|
||||||
|
@ -556,8 +570,8 @@ class MasterView(View):
|
||||||
# gather/save settings
|
# gather/save settings
|
||||||
data = get_form_data(self.request)
|
data = get_form_data(self.request)
|
||||||
settings = self.configure_gather_settings(data)
|
settings = self.configure_gather_settings(data)
|
||||||
self.configure_remove_settings()
|
self.configure_remove_settings(session=session)
|
||||||
self.configure_save_settings(settings)
|
self.configure_save_settings(settings, session=session)
|
||||||
self.request.session.flash("Settings have been saved.")
|
self.request.session.flash("Settings have been saved.")
|
||||||
|
|
||||||
# reload configure page
|
# reload configure page
|
||||||
|
@ -735,6 +749,7 @@ class MasterView(View):
|
||||||
def configure_remove_settings(
|
def configure_remove_settings(
|
||||||
self,
|
self,
|
||||||
simple_settings=None,
|
simple_settings=None,
|
||||||
|
session=None,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Remove all "known" settings from the DB; this is called by
|
Remove all "known" settings from the DB; this is called by
|
||||||
|
@ -762,11 +777,11 @@ class MasterView(View):
|
||||||
if names:
|
if names:
|
||||||
# nb. must avoid self.Session here in case that does not
|
# nb. must avoid self.Session here in case that does not
|
||||||
# point to our primary app DB
|
# point to our primary app DB
|
||||||
session = Session()
|
session = session or self.Session()
|
||||||
for name in names:
|
for name in names:
|
||||||
self.app.delete_setting(session, name)
|
self.app.delete_setting(session, name)
|
||||||
|
|
||||||
def configure_save_settings(self, settings):
|
def configure_save_settings(self, settings, session=None):
|
||||||
"""
|
"""
|
||||||
Save the given settings to the DB; this is called by
|
Save the given settings to the DB; this is called by
|
||||||
:meth:`configure()`.
|
:meth:`configure()`.
|
||||||
|
@ -779,7 +794,7 @@ class MasterView(View):
|
||||||
"""
|
"""
|
||||||
# nb. must avoid self.Session here in case that does not point
|
# nb. must avoid self.Session here in case that does not point
|
||||||
# to our primary app DB
|
# to our primary app DB
|
||||||
session = Session()
|
session = session or self.Session()
|
||||||
for setting in settings:
|
for setting in settings:
|
||||||
self.app.save_setting(session, setting['name'], setting['value'],
|
self.app.save_setting(session, setting['name'], setting['value'],
|
||||||
force_create=True)
|
force_create=True)
|
||||||
|
@ -788,6 +803,43 @@ class MasterView(View):
|
||||||
# support methods
|
# support methods
|
||||||
##############################
|
##############################
|
||||||
|
|
||||||
|
def has_perm(self, name):
|
||||||
|
"""
|
||||||
|
Shortcut to check if current user has the given permission.
|
||||||
|
|
||||||
|
This will automatically add the :attr:`permission_prefix` to
|
||||||
|
``name`` before passing it on to
|
||||||
|
:func:`~wuttaweb.subscribers.request.has_perm()`.
|
||||||
|
|
||||||
|
For instance within the
|
||||||
|
:class:`~wuttaweb.views.users.UserView` these give the same
|
||||||
|
result::
|
||||||
|
|
||||||
|
self.request.has_perm('users.edit')
|
||||||
|
|
||||||
|
self.has_perm('edit')
|
||||||
|
|
||||||
|
So this shortcut only applies to permissions defined for the
|
||||||
|
current master view. The first example above must still be
|
||||||
|
used to check for "foreign" permissions (i.e. any needing a
|
||||||
|
different prefix).
|
||||||
|
"""
|
||||||
|
permission_prefix = self.get_permission_prefix()
|
||||||
|
return self.request.has_perm(f'{permission_prefix}.{name}')
|
||||||
|
|
||||||
|
def has_any_perm(self, *names):
|
||||||
|
"""
|
||||||
|
Shortcut to check if current user has any of the given
|
||||||
|
permissions.
|
||||||
|
|
||||||
|
This calls :meth:`has_perm()` until one returns ``True``. If
|
||||||
|
none do, returns ``False``.
|
||||||
|
"""
|
||||||
|
for name in names:
|
||||||
|
if self.has_perm(name):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
def render_to_response(self, template, context):
|
def render_to_response(self, template, context):
|
||||||
"""
|
"""
|
||||||
Locate and render an appropriate template, with the given
|
Locate and render an appropriate template, with the given
|
||||||
|
@ -830,6 +882,16 @@ class MasterView(View):
|
||||||
defaults.update(context)
|
defaults.update(context)
|
||||||
context = defaults
|
context = defaults
|
||||||
|
|
||||||
|
# add crud flags if we have an instance
|
||||||
|
if 'instance' in context:
|
||||||
|
instance = context['instance']
|
||||||
|
if 'instance_title' not in context:
|
||||||
|
context['instance_title'] = self.get_instance_title(instance)
|
||||||
|
if 'instance_editable' not in context:
|
||||||
|
context['instance_editable'] = self.is_editable(instance)
|
||||||
|
if 'instance_deletable' not in context:
|
||||||
|
context['instance_deletable'] = self.is_deletable(instance)
|
||||||
|
|
||||||
# first try the template path most specific to this view
|
# first try the template path most specific to this view
|
||||||
template_prefix = self.get_template_prefix()
|
template_prefix = self.get_template_prefix()
|
||||||
mako_path = f'{template_prefix}/{template}.mako'
|
mako_path = f'{template_prefix}/{template}.mako'
|
||||||
|
@ -919,15 +981,15 @@ class MasterView(View):
|
||||||
|
|
||||||
# TODO: should split this off into index_get_grid_actions() ?
|
# TODO: should split this off into index_get_grid_actions() ?
|
||||||
|
|
||||||
if self.viewable:
|
if self.viewable and self.has_perm('view'):
|
||||||
actions.append(self.make_grid_action('view', icon='eye',
|
actions.append(self.make_grid_action('view', icon='eye',
|
||||||
url=self.get_action_url_view))
|
url=self.get_action_url_view))
|
||||||
|
|
||||||
if self.editable:
|
if self.editable and self.has_perm('edit'):
|
||||||
actions.append(self.make_grid_action('edit', icon='edit',
|
actions.append(self.make_grid_action('edit', icon='edit',
|
||||||
url=self.get_action_url_edit))
|
url=self.get_action_url_edit))
|
||||||
|
|
||||||
if self.deletable:
|
if self.deletable and self.has_perm('delete'):
|
||||||
actions.append(self.make_grid_action('delete', icon='trash',
|
actions.append(self.make_grid_action('delete', icon='trash',
|
||||||
url=self.get_action_url_delete,
|
url=self.get_action_url_delete,
|
||||||
link_class='has-text-danger'))
|
link_class='has-text-danger'))
|
||||||
|
@ -977,26 +1039,7 @@ class MasterView(View):
|
||||||
"""
|
"""
|
||||||
query = self.get_query(session=session)
|
query = self.get_query(session=session)
|
||||||
if query:
|
if query:
|
||||||
data = query.all()
|
return query.all()
|
||||||
|
|
||||||
# determine which columns are relevant for data set
|
|
||||||
if not columns:
|
|
||||||
columns = self.get_grid_columns()
|
|
||||||
if not columns:
|
|
||||||
model_class = self.get_model_class()
|
|
||||||
if model_class:
|
|
||||||
columns = get_model_fields(self.config, model_class)
|
|
||||||
if not columns:
|
|
||||||
raise ValueError("cannot determine columns for the grid")
|
|
||||||
columns = set(columns)
|
|
||||||
columns.update(self.get_model_key())
|
|
||||||
|
|
||||||
# prune data fields for which no column is defined
|
|
||||||
for i, record in enumerate(data):
|
|
||||||
data[i]= dict([(key, record[key])
|
|
||||||
for key in columns])
|
|
||||||
|
|
||||||
return data
|
|
||||||
|
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
@ -1012,7 +1055,7 @@ class MasterView(View):
|
||||||
model = self.app.model
|
model = self.app.model
|
||||||
model_class = self.get_model_class()
|
model_class = self.get_model_class()
|
||||||
if model_class and issubclass(model_class, model.Base):
|
if model_class and issubclass(model_class, model.Base):
|
||||||
session = session or Session()
|
session = session or self.Session()
|
||||||
return session.query(model_class)
|
return session.query(model_class)
|
||||||
|
|
||||||
def configure_grid(self, grid):
|
def configure_grid(self, grid):
|
||||||
|
@ -1032,6 +1075,33 @@ class MasterView(View):
|
||||||
for key in self.get_model_key():
|
for key in self.get_model_key():
|
||||||
grid.set_link(key)
|
grid.set_link(key)
|
||||||
|
|
||||||
|
def grid_render_notes(self, record, key, value, maxlen=100):
|
||||||
|
"""
|
||||||
|
Custom grid renderer callable for "notes" fields.
|
||||||
|
|
||||||
|
If the given text ``value`` is shorter than ``maxlen``
|
||||||
|
characters, it is returned as-is.
|
||||||
|
|
||||||
|
But if it is longer, then it is truncated and an ellispsis is
|
||||||
|
added. The resulting ``<span>`` tag is also given a ``title``
|
||||||
|
attribute with the original (full) text, so that appears on
|
||||||
|
mouse hover.
|
||||||
|
|
||||||
|
To use this feature for your grid::
|
||||||
|
|
||||||
|
grid.set_renderer('my_notes_field', self.grid_render_notes)
|
||||||
|
|
||||||
|
# you can also override maxlen
|
||||||
|
grid.set_renderer('my_notes_field', self.grid_render_notes, maxlen=50)
|
||||||
|
"""
|
||||||
|
if value is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
if len(value) < maxlen:
|
||||||
|
return value
|
||||||
|
|
||||||
|
return HTML.tag('span', title=value, c=f"{value[:maxlen]}...")
|
||||||
|
|
||||||
def get_instance(self, session=None):
|
def get_instance(self, session=None):
|
||||||
"""
|
"""
|
||||||
This should return the "current" model instance based on the
|
This should return the "current" model instance based on the
|
||||||
|
@ -1045,7 +1115,7 @@ class MasterView(View):
|
||||||
"""
|
"""
|
||||||
model_class = self.get_model_class()
|
model_class = self.get_model_class()
|
||||||
if model_class:
|
if model_class:
|
||||||
session = session or Session()
|
session = session or self.Session()
|
||||||
|
|
||||||
def filtr(query, model_key):
|
def filtr(query, model_key):
|
||||||
key = self.request.matchdict[model_key]
|
key = self.request.matchdict[model_key]
|
||||||
|
@ -1111,26 +1181,62 @@ class MasterView(View):
|
||||||
|
|
||||||
def get_action_url_edit(self, obj, i):
|
def get_action_url_edit(self, obj, i):
|
||||||
"""
|
"""
|
||||||
Returns the "edit" grid action URL for the given object.
|
Returns the "edit" grid action URL for the given object, if
|
||||||
|
applicable.
|
||||||
|
|
||||||
Most typically this is like ``/widgets/XXX/edit`` where
|
Most typically this is like ``/widgets/XXX/edit`` where
|
||||||
``XXX`` represents the object's key/ID.
|
``XXX`` represents the object's key/ID.
|
||||||
|
|
||||||
Calls :meth:`get_action_url()` under the hood.
|
This first calls :meth:`is_editable()` and if that is false,
|
||||||
|
this method will return ``None``.
|
||||||
|
|
||||||
|
Calls :meth:`get_action_url()` to generate the true URL.
|
||||||
"""
|
"""
|
||||||
|
if self.is_editable(obj):
|
||||||
return self.get_action_url('edit', obj)
|
return self.get_action_url('edit', obj)
|
||||||
|
|
||||||
def get_action_url_delete(self, obj, i):
|
def get_action_url_delete(self, obj, i):
|
||||||
"""
|
"""
|
||||||
Returns the "delete" grid action URL for the given object.
|
Returns the "delete" grid action URL for the given object, if
|
||||||
|
applicable.
|
||||||
|
|
||||||
Most typically this is like ``/widgets/XXX/delete`` where
|
Most typically this is like ``/widgets/XXX/delete`` where
|
||||||
``XXX`` represents the object's key/ID.
|
``XXX`` represents the object's key/ID.
|
||||||
|
|
||||||
Calls :meth:`get_action_url()` under the hood.
|
This first calls :meth:`is_deletable()` and if that is false,
|
||||||
|
this method will return ``None``.
|
||||||
|
|
||||||
|
Calls :meth:`get_action_url()` to generate the true URL.
|
||||||
"""
|
"""
|
||||||
|
if self.is_deletable(obj):
|
||||||
return self.get_action_url('delete', obj)
|
return self.get_action_url('delete', obj)
|
||||||
|
|
||||||
|
def is_editable(self, obj):
|
||||||
|
"""
|
||||||
|
Returns a boolean indicating whether "edit" should be allowed
|
||||||
|
for the given model instance (and for current user).
|
||||||
|
|
||||||
|
By default this always return ``True``; subclass can override
|
||||||
|
if needed.
|
||||||
|
|
||||||
|
Note that the use of this method implies :attr:`editable` is
|
||||||
|
true, so the method does not need to check that flag.
|
||||||
|
"""
|
||||||
|
return True
|
||||||
|
|
||||||
|
def is_deletable(self, obj):
|
||||||
|
"""
|
||||||
|
Returns a boolean indicating whether "delete" should be
|
||||||
|
allowed for the given model instance (and for current user).
|
||||||
|
|
||||||
|
By default this always return ``True``; subclass can override
|
||||||
|
if needed.
|
||||||
|
|
||||||
|
Note that the use of this method implies :attr:`deletable` is
|
||||||
|
true, so the method does not need to check that flag.
|
||||||
|
"""
|
||||||
|
return True
|
||||||
|
|
||||||
def make_model_form(self, model_instance=None, **kwargs):
|
def make_model_form(self, model_instance=None, **kwargs):
|
||||||
"""
|
"""
|
||||||
Create and return a :class:`~wuttaweb.forms.base.Form`
|
Create and return a :class:`~wuttaweb.forms.base.Form`
|
||||||
|
@ -1260,7 +1366,7 @@ class MasterView(View):
|
||||||
if model_class and issubclass(model_class, model.Base):
|
if model_class and issubclass(model_class, model.Base):
|
||||||
|
|
||||||
# add sqlalchemy model to session
|
# add sqlalchemy model to session
|
||||||
session = session or Session()
|
session = session or self.Session()
|
||||||
session.add(obj)
|
session.add(obj)
|
||||||
|
|
||||||
##############################
|
##############################
|
||||||
|
@ -1414,6 +1520,29 @@ class MasterView(View):
|
||||||
model_name = cls.get_model_name_normalized()
|
model_name = cls.get_model_name_normalized()
|
||||||
return f'{model_name}s'
|
return f'{model_name}s'
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_permission_prefix(cls):
|
||||||
|
"""
|
||||||
|
Returns the "permission prefix" for the master view. This
|
||||||
|
prefix is used for all permissions defined by the view class.
|
||||||
|
|
||||||
|
For instance if permission prefix is ``'widgets'`` then a view
|
||||||
|
might have these permissions:
|
||||||
|
|
||||||
|
* ``'widgets.list'``
|
||||||
|
* ``'widgets.create'``
|
||||||
|
* ``'widgets.edit'``
|
||||||
|
* ``'widgets.delete'``
|
||||||
|
|
||||||
|
The default logic will call :meth:`get_route_prefix()` and use
|
||||||
|
that value as-is. A subclass may override by assigning
|
||||||
|
:attr:`permission_prefix`.
|
||||||
|
"""
|
||||||
|
if hasattr(cls, 'permission_prefix'):
|
||||||
|
return cls.permission_prefix
|
||||||
|
|
||||||
|
return cls.get_route_prefix()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_url_prefix(cls):
|
def get_url_prefix(cls):
|
||||||
"""
|
"""
|
||||||
|
@ -1553,27 +1682,47 @@ class MasterView(View):
|
||||||
@classmethod
|
@classmethod
|
||||||
def _defaults(cls, config):
|
def _defaults(cls, config):
|
||||||
route_prefix = cls.get_route_prefix()
|
route_prefix = cls.get_route_prefix()
|
||||||
|
permission_prefix = cls.get_permission_prefix()
|
||||||
url_prefix = cls.get_url_prefix()
|
url_prefix = cls.get_url_prefix()
|
||||||
|
model_title = cls.get_model_title()
|
||||||
|
model_title_plural = cls.get_model_title_plural()
|
||||||
|
|
||||||
|
# permission group
|
||||||
|
config.add_wutta_permission_group(permission_prefix,
|
||||||
|
model_title_plural,
|
||||||
|
overwrite=False)
|
||||||
|
|
||||||
# index
|
# index
|
||||||
if cls.listable:
|
if cls.listable:
|
||||||
config.add_route(route_prefix, f'{url_prefix}/')
|
config.add_route(route_prefix, f'{url_prefix}/')
|
||||||
config.add_view(cls, attr='index',
|
config.add_view(cls, attr='index',
|
||||||
route_name=route_prefix)
|
route_name=route_prefix,
|
||||||
|
permission=f'{permission_prefix}.list')
|
||||||
|
config.add_wutta_permission(permission_prefix,
|
||||||
|
f'{permission_prefix}.list',
|
||||||
|
f"Browse / search {model_title_plural}")
|
||||||
|
|
||||||
# create
|
# create
|
||||||
if cls.creatable:
|
if cls.creatable:
|
||||||
config.add_route(f'{route_prefix}.create',
|
config.add_route(f'{route_prefix}.create',
|
||||||
f'{url_prefix}/new')
|
f'{url_prefix}/new')
|
||||||
config.add_view(cls, attr='create',
|
config.add_view(cls, attr='create',
|
||||||
route_name=f'{route_prefix}.create')
|
route_name=f'{route_prefix}.create',
|
||||||
|
permission=f'{permission_prefix}.create')
|
||||||
|
config.add_wutta_permission(permission_prefix,
|
||||||
|
f'{permission_prefix}.create',
|
||||||
|
f"Create new {model_title}")
|
||||||
|
|
||||||
# view
|
# view
|
||||||
if cls.viewable:
|
if cls.viewable:
|
||||||
instance_url_prefix = cls.get_instance_url_prefix()
|
instance_url_prefix = cls.get_instance_url_prefix()
|
||||||
config.add_route(f'{route_prefix}.view', instance_url_prefix)
|
config.add_route(f'{route_prefix}.view', instance_url_prefix)
|
||||||
config.add_view(cls, attr='view',
|
config.add_view(cls, attr='view',
|
||||||
route_name=f'{route_prefix}.view')
|
route_name=f'{route_prefix}.view',
|
||||||
|
permission=f'{permission_prefix}.view')
|
||||||
|
config.add_wutta_permission(permission_prefix,
|
||||||
|
f'{permission_prefix}.view',
|
||||||
|
f"View {model_title}")
|
||||||
|
|
||||||
# edit
|
# edit
|
||||||
if cls.editable:
|
if cls.editable:
|
||||||
|
@ -1581,7 +1730,11 @@ class MasterView(View):
|
||||||
config.add_route(f'{route_prefix}.edit',
|
config.add_route(f'{route_prefix}.edit',
|
||||||
f'{instance_url_prefix}/edit')
|
f'{instance_url_prefix}/edit')
|
||||||
config.add_view(cls, attr='edit',
|
config.add_view(cls, attr='edit',
|
||||||
route_name=f'{route_prefix}.edit')
|
route_name=f'{route_prefix}.edit',
|
||||||
|
permission=f'{permission_prefix}.edit')
|
||||||
|
config.add_wutta_permission(permission_prefix,
|
||||||
|
f'{permission_prefix}.edit',
|
||||||
|
f"Edit {model_title}")
|
||||||
|
|
||||||
# delete
|
# delete
|
||||||
if cls.deletable:
|
if cls.deletable:
|
||||||
|
@ -1589,11 +1742,19 @@ class MasterView(View):
|
||||||
config.add_route(f'{route_prefix}.delete',
|
config.add_route(f'{route_prefix}.delete',
|
||||||
f'{instance_url_prefix}/delete')
|
f'{instance_url_prefix}/delete')
|
||||||
config.add_view(cls, attr='delete',
|
config.add_view(cls, attr='delete',
|
||||||
route_name=f'{route_prefix}.delete')
|
route_name=f'{route_prefix}.delete',
|
||||||
|
permission=f'{permission_prefix}.delete')
|
||||||
|
config.add_wutta_permission(permission_prefix,
|
||||||
|
f'{permission_prefix}.delete',
|
||||||
|
f"Delete {model_title}")
|
||||||
|
|
||||||
# configure
|
# configure
|
||||||
if cls.configurable:
|
if cls.configurable:
|
||||||
config.add_route(f'{route_prefix}.configure',
|
config.add_route(f'{route_prefix}.configure',
|
||||||
f'{url_prefix}/configure')
|
f'{url_prefix}/configure')
|
||||||
config.add_view(cls, attr='configure',
|
config.add_view(cls, attr='configure',
|
||||||
route_name=f'{route_prefix}.configure')
|
route_name=f'{route_prefix}.configure',
|
||||||
|
permission=f'{permission_prefix}.configure')
|
||||||
|
config.add_wutta_permission(permission_prefix,
|
||||||
|
f'{permission_prefix}.configure',
|
||||||
|
f"Configure {model_title_plural}")
|
||||||
|
|
|
@ -32,6 +32,8 @@ class PersonView(MasterView):
|
||||||
"""
|
"""
|
||||||
Master view for people.
|
Master view for people.
|
||||||
|
|
||||||
|
Default route prefix is ``people``.
|
||||||
|
|
||||||
Notable URLs provided by this class:
|
Notable URLs provided by this class:
|
||||||
|
|
||||||
* ``/people/``
|
* ``/people/``
|
||||||
|
@ -83,6 +85,47 @@ class PersonView(MasterView):
|
||||||
if 'users' in f:
|
if 'users' in f:
|
||||||
f.fields.remove('users')
|
f.fields.remove('users')
|
||||||
|
|
||||||
|
def view_profile(self, session=None):
|
||||||
|
""" """
|
||||||
|
instance = self.get_instance(session=session)
|
||||||
|
context = {
|
||||||
|
'instance': instance,
|
||||||
|
}
|
||||||
|
return self.render_to_response('view_profile', context)
|
||||||
|
|
||||||
|
def make_user(self):
|
||||||
|
""" """
|
||||||
|
self.request.session.flash("TODO: this feature is not yet supported", 'error')
|
||||||
|
return self.redirect(self.request.get_referrer())
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def defaults(cls, config):
|
||||||
|
cls._defaults(config)
|
||||||
|
cls._people_defaults(config)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _people_defaults(cls, config):
|
||||||
|
route_prefix = cls.get_route_prefix()
|
||||||
|
url_prefix = cls.get_url_prefix()
|
||||||
|
instance_url_prefix = cls.get_instance_url_prefix()
|
||||||
|
permission_prefix = cls.get_permission_prefix()
|
||||||
|
|
||||||
|
# view profile
|
||||||
|
config.add_route(f'{route_prefix}.view_profile',
|
||||||
|
f'{instance_url_prefix}/profile',
|
||||||
|
request_method='GET')
|
||||||
|
config.add_view(cls, attr='view_profile',
|
||||||
|
route_name=f'{route_prefix}.view_profile',
|
||||||
|
permission=f'{permission_prefix}.view_profile')
|
||||||
|
|
||||||
|
# make user for person
|
||||||
|
config.add_route(f'{route_prefix}.make_user',
|
||||||
|
f'{url_prefix}/make-user',
|
||||||
|
request_method='POST')
|
||||||
|
config.add_view(cls, attr='make_user',
|
||||||
|
route_name=f'{route_prefix}.make_user',
|
||||||
|
permission='users.create')
|
||||||
|
|
||||||
|
|
||||||
def defaults(config, **kwargs):
|
def defaults(config, **kwargs):
|
||||||
base = globals()
|
base = globals()
|
||||||
|
|
|
@ -27,12 +27,16 @@ Views for roles
|
||||||
from wuttjamaican.db.model import Role
|
from wuttjamaican.db.model import Role
|
||||||
from wuttaweb.views import MasterView
|
from wuttaweb.views import MasterView
|
||||||
from wuttaweb.db import Session
|
from wuttaweb.db import Session
|
||||||
|
from wuttaweb.forms import widgets
|
||||||
|
from wuttaweb.forms.schema import Permissions
|
||||||
|
|
||||||
|
|
||||||
class RoleView(MasterView):
|
class RoleView(MasterView):
|
||||||
"""
|
"""
|
||||||
Master view for roles.
|
Master view for roles.
|
||||||
|
|
||||||
|
Default route prefix is ``roles``.
|
||||||
|
|
||||||
Notable URLs provided by this class:
|
Notable URLs provided by this class:
|
||||||
|
|
||||||
* ``/roles/``
|
* ``/roles/``
|
||||||
|
@ -62,9 +66,44 @@ class RoleView(MasterView):
|
||||||
# name
|
# name
|
||||||
g.set_link('name')
|
g.set_link('name')
|
||||||
|
|
||||||
|
# notes
|
||||||
|
g.set_renderer('notes', self.grid_render_notes)
|
||||||
|
|
||||||
|
def is_editable(self, role):
|
||||||
|
""" """
|
||||||
|
session = self.app.get_session(role)
|
||||||
|
auth = self.app.get_auth_handler()
|
||||||
|
|
||||||
|
# only "root" can edit admin role
|
||||||
|
if role is auth.get_role_administrator(session):
|
||||||
|
return self.request.is_root
|
||||||
|
|
||||||
|
# other built-in roles require special perm
|
||||||
|
if role in (auth.get_role_authenticated(session),
|
||||||
|
auth.get_role_anonymous(session)):
|
||||||
|
return self.has_perm('edit_builtin')
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def is_deletable(self, role):
|
||||||
|
""" """
|
||||||
|
session = self.app.get_session(role)
|
||||||
|
auth = self.app.get_auth_handler()
|
||||||
|
|
||||||
|
# prevent delete for built-in roles
|
||||||
|
if role is auth.get_role_authenticated(session):
|
||||||
|
return False
|
||||||
|
if role is auth.get_role_anonymous(session):
|
||||||
|
return False
|
||||||
|
if role is auth.get_role_administrator(session):
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
def configure_form(self, f):
|
def configure_form(self, f):
|
||||||
""" """
|
""" """
|
||||||
super().configure_form(f)
|
super().configure_form(f)
|
||||||
|
role = f.model_instance
|
||||||
|
|
||||||
# never show these
|
# never show these
|
||||||
f.remove('permission_refs',
|
f.remove('permission_refs',
|
||||||
|
@ -73,6 +112,16 @@ class RoleView(MasterView):
|
||||||
# name
|
# name
|
||||||
f.set_validator('name', self.unique_name)
|
f.set_validator('name', self.unique_name)
|
||||||
|
|
||||||
|
# notes
|
||||||
|
f.set_widget('notes', widgets.NotesWidget())
|
||||||
|
|
||||||
|
# permissions
|
||||||
|
f.append('permissions')
|
||||||
|
self.wutta_permissions = self.get_available_permissions()
|
||||||
|
f.set_node('permissions', Permissions(self.request, permissions=self.wutta_permissions))
|
||||||
|
if not self.creating:
|
||||||
|
f.set_default('permissions', list(role.permissions))
|
||||||
|
|
||||||
def unique_name(self, node, value):
|
def unique_name(self, node, value):
|
||||||
""" """
|
""" """
|
||||||
model = self.app.model
|
model = self.app.model
|
||||||
|
@ -88,6 +137,128 @@ class RoleView(MasterView):
|
||||||
if query.count():
|
if query.count():
|
||||||
node.raise_invalid("Name must be unique")
|
node.raise_invalid("Name must be unique")
|
||||||
|
|
||||||
|
def get_available_permissions(self):
|
||||||
|
"""
|
||||||
|
Returns all "available" permissions. This is used when
|
||||||
|
viewing or editing a role; the result is passed into the
|
||||||
|
:class:`~wuttaweb.forms.schema.Permissions` field schema.
|
||||||
|
|
||||||
|
The app itself must be made aware of each permission, in order
|
||||||
|
for them to found by this method. This is done via
|
||||||
|
:func:`~wuttaweb.auth.add_permission_group()` and
|
||||||
|
:func:`~wuttaweb.auth.add_permission()`.
|
||||||
|
|
||||||
|
When in "view" (readonly) mode, this method will return the
|
||||||
|
full set of known permissions.
|
||||||
|
|
||||||
|
However in "edit" mode, it will prune the set to remove any
|
||||||
|
permissions which the current user does not also have. The
|
||||||
|
idea here is to allow "many" users to manage roles, but ensure
|
||||||
|
they cannot "break out" of their own role by assigning extra
|
||||||
|
permissions to it.
|
||||||
|
|
||||||
|
The permissions returned will also be grouped, and each single
|
||||||
|
permission is also represented as a simple dict, e.g.::
|
||||||
|
|
||||||
|
{
|
||||||
|
'books': {
|
||||||
|
'key': 'books',
|
||||||
|
'label': "Books",
|
||||||
|
'perms': {
|
||||||
|
'books.list': {
|
||||||
|
'key': 'books.list',
|
||||||
|
'label': "Browse / search Books",
|
||||||
|
},
|
||||||
|
'books.view': {
|
||||||
|
'key': 'books.view',
|
||||||
|
'label': "View Book",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'widgets': {
|
||||||
|
'key': 'widgets',
|
||||||
|
'label': "Widgets",
|
||||||
|
'perms': {
|
||||||
|
'widgets.list': {
|
||||||
|
'key': 'widgets.list',
|
||||||
|
'label': "Browse / search Widgets",
|
||||||
|
},
|
||||||
|
'widgets.view': {
|
||||||
|
'key': 'widgets.view',
|
||||||
|
'label': "View Widget",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
# get all known permissions from settings cache
|
||||||
|
permissions = self.request.registry.settings.get('wutta_permissions', {})
|
||||||
|
|
||||||
|
# when viewing, we allow all permissions to be exposed for all users
|
||||||
|
if self.viewing:
|
||||||
|
return permissions
|
||||||
|
|
||||||
|
# admin user gets to manage all permissions
|
||||||
|
if self.request.is_admin:
|
||||||
|
return permissions
|
||||||
|
|
||||||
|
# non-admin user can only see permissions they're granted
|
||||||
|
available = {}
|
||||||
|
for gkey, group in permissions.items():
|
||||||
|
for pkey, perm in group['perms'].items():
|
||||||
|
if self.request.has_perm(pkey):
|
||||||
|
if gkey not in available:
|
||||||
|
available[gkey] = {
|
||||||
|
'key': gkey,
|
||||||
|
'label': group['label'],
|
||||||
|
'perms': {},
|
||||||
|
}
|
||||||
|
available[gkey]['perms'][pkey] = perm
|
||||||
|
|
||||||
|
return available
|
||||||
|
|
||||||
|
def objectify(self, form):
|
||||||
|
""" """
|
||||||
|
# normal logic first
|
||||||
|
role = super().objectify(form)
|
||||||
|
|
||||||
|
# update permissions for role
|
||||||
|
self.update_permissions(role, form)
|
||||||
|
|
||||||
|
return role
|
||||||
|
|
||||||
|
def update_permissions(self, role, form):
|
||||||
|
""" """
|
||||||
|
if 'permissions' not in form.validated:
|
||||||
|
return
|
||||||
|
|
||||||
|
auth = self.app.get_auth_handler()
|
||||||
|
available = self.wutta_permissions
|
||||||
|
permissions = form.validated['permissions']
|
||||||
|
|
||||||
|
for gkey, group in available.items():
|
||||||
|
for pkey, perm in group['perms'].items():
|
||||||
|
if pkey in permissions:
|
||||||
|
auth.grant_permission(role, pkey)
|
||||||
|
else:
|
||||||
|
auth.revoke_permission(role, pkey)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def defaults(cls, config):
|
||||||
|
cls._defaults(config)
|
||||||
|
cls._role_defaults(config)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _role_defaults(cls, config):
|
||||||
|
permission_prefix = cls.get_permission_prefix()
|
||||||
|
model_title_plural = cls.get_model_title_plural()
|
||||||
|
|
||||||
|
# perm to edit built-in roles
|
||||||
|
config.add_wutta_permission(permission_prefix,
|
||||||
|
f'{permission_prefix}.edit_builtin',
|
||||||
|
f"Edit the Built-in {model_title_plural}")
|
||||||
|
|
||||||
|
|
||||||
def defaults(config, **kwargs):
|
def defaults(config, **kwargs):
|
||||||
base = globals()
|
base = globals()
|
||||||
|
|
|
@ -35,6 +35,8 @@ class AppInfoView(MasterView):
|
||||||
"""
|
"""
|
||||||
Master view for the core app info, to show/edit config etc.
|
Master view for the core app info, to show/edit config etc.
|
||||||
|
|
||||||
|
Default route prefix is ``appinfo``.
|
||||||
|
|
||||||
Notable URLs provided by this class:
|
Notable URLs provided by this class:
|
||||||
|
|
||||||
* ``/appinfo/``
|
* ``/appinfo/``
|
||||||
|
@ -137,6 +139,8 @@ class SettingView(MasterView):
|
||||||
"""
|
"""
|
||||||
Master view for the "raw" settings table.
|
Master view for the "raw" settings table.
|
||||||
|
|
||||||
|
Default route prefix is ``settings``.
|
||||||
|
|
||||||
Notable URLs provided by this class:
|
Notable URLs provided by this class:
|
||||||
|
|
||||||
* ``/settings/``
|
* ``/settings/``
|
||||||
|
|
|
@ -28,7 +28,8 @@ import colander
|
||||||
|
|
||||||
from wuttjamaican.db.model import User
|
from wuttjamaican.db.model import User
|
||||||
from wuttaweb.views import MasterView
|
from wuttaweb.views import MasterView
|
||||||
from wuttaweb.forms.schema import PersonRef
|
from wuttaweb.forms import widgets
|
||||||
|
from wuttaweb.forms.schema import PersonRef, RoleRefs
|
||||||
from wuttaweb.db import Session
|
from wuttaweb.db import Session
|
||||||
|
|
||||||
|
|
||||||
|
@ -36,6 +37,8 @@ class UserView(MasterView):
|
||||||
"""
|
"""
|
||||||
Master view for users.
|
Master view for users.
|
||||||
|
|
||||||
|
Default route prefix is ``users``.
|
||||||
|
|
||||||
Notable URLs provided by this class:
|
Notable URLs provided by this class:
|
||||||
|
|
||||||
* ``/users/``
|
* ``/users/``
|
||||||
|
@ -77,10 +80,10 @@ class UserView(MasterView):
|
||||||
def configure_form(self, f):
|
def configure_form(self, f):
|
||||||
""" """
|
""" """
|
||||||
super().configure_form(f)
|
super().configure_form(f)
|
||||||
|
user = f.model_instance
|
||||||
|
|
||||||
# never show these
|
# never show these
|
||||||
f.remove('person_uuid',
|
f.remove('person_uuid',
|
||||||
'password',
|
|
||||||
'role_refs')
|
'role_refs')
|
||||||
|
|
||||||
# person
|
# person
|
||||||
|
@ -90,6 +93,24 @@ class UserView(MasterView):
|
||||||
# username
|
# username
|
||||||
f.set_validator('username', self.unique_username)
|
f.set_validator('username', self.unique_username)
|
||||||
|
|
||||||
|
# password
|
||||||
|
# nb. we must avoid 'password' as field name since
|
||||||
|
# ColanderAlchemy wants to handle the raw/hashed value
|
||||||
|
f.remove('password')
|
||||||
|
# nb. no need for password field if readonly
|
||||||
|
if self.creating or self.editing:
|
||||||
|
# nb. use 'set_password' as field name
|
||||||
|
f.append('set_password')
|
||||||
|
f.set_required('set_password', False)
|
||||||
|
f.set_widget('set_password', widgets.CheckedPasswordWidget())
|
||||||
|
|
||||||
|
# roles
|
||||||
|
f.append('roles')
|
||||||
|
f.set_node('roles', RoleRefs(self.request))
|
||||||
|
|
||||||
|
if not self.creating:
|
||||||
|
f.set_default('roles', [role.uuid for role in user.roles])
|
||||||
|
|
||||||
def unique_username(self, node, value):
|
def unique_username(self, node, value):
|
||||||
""" """
|
""" """
|
||||||
model = self.app.model
|
model = self.app.model
|
||||||
|
@ -105,6 +126,69 @@ class UserView(MasterView):
|
||||||
if query.count():
|
if query.count():
|
||||||
node.raise_invalid("Username must be unique")
|
node.raise_invalid("Username must be unique")
|
||||||
|
|
||||||
|
def objectify(self, form, session=None):
|
||||||
|
""" """
|
||||||
|
data = form.validated
|
||||||
|
|
||||||
|
# normal logic first
|
||||||
|
user = super().objectify(form)
|
||||||
|
|
||||||
|
# maybe set user password
|
||||||
|
if 'set_password' in form and data.get('set_password'):
|
||||||
|
auth = self.app.get_auth_handler()
|
||||||
|
auth.set_user_password(user, data['set_password'])
|
||||||
|
|
||||||
|
# update roles for user
|
||||||
|
# TODO
|
||||||
|
# if self.has_perm('edit_roles'):
|
||||||
|
self.update_roles(user, form, session=session)
|
||||||
|
|
||||||
|
return user
|
||||||
|
|
||||||
|
def update_roles(self, user, form, session=None):
|
||||||
|
""" """
|
||||||
|
# TODO
|
||||||
|
# if not self.has_perm('edit_roles'):
|
||||||
|
# return
|
||||||
|
data = form.validated
|
||||||
|
if 'roles' not in data:
|
||||||
|
return
|
||||||
|
|
||||||
|
model = self.app.model
|
||||||
|
session = session or Session()
|
||||||
|
auth = self.app.get_auth_handler()
|
||||||
|
|
||||||
|
old_roles = set([role.uuid for role in user.roles])
|
||||||
|
new_roles = data['roles']
|
||||||
|
|
||||||
|
admin = auth.get_role_administrator(session)
|
||||||
|
ignored = {
|
||||||
|
auth.get_role_authenticated(session).uuid,
|
||||||
|
auth.get_role_anonymous(session).uuid,
|
||||||
|
}
|
||||||
|
|
||||||
|
# add any new roles for the user, taking care to avoid certain
|
||||||
|
# unwanted operations for built-in roles
|
||||||
|
for uuid in new_roles:
|
||||||
|
if uuid in ignored:
|
||||||
|
continue
|
||||||
|
if uuid in old_roles:
|
||||||
|
continue
|
||||||
|
if uuid == admin.uuid and not self.request.is_root:
|
||||||
|
continue
|
||||||
|
role = session.get(model.Role, uuid)
|
||||||
|
user.roles.append(role)
|
||||||
|
|
||||||
|
# remove any roles which were *not* specified, taking care to
|
||||||
|
# avoid certain unwanted operations for built-in roles
|
||||||
|
for uuid in old_roles:
|
||||||
|
if uuid in new_roles:
|
||||||
|
continue
|
||||||
|
if uuid == admin.uuid and not self.request.is_root:
|
||||||
|
continue
|
||||||
|
role = session.get(model.Role, uuid)
|
||||||
|
user.roles.remove(role)
|
||||||
|
|
||||||
|
|
||||||
def defaults(config, **kwargs):
|
def defaults(config, **kwargs):
|
||||||
base = globals()
|
base = globals()
|
||||||
|
|
|
@ -84,6 +84,12 @@ class TestForm(TestCase):
|
||||||
form.set_fields(['baz'])
|
form.set_fields(['baz'])
|
||||||
self.assertEqual(form.fields, ['baz'])
|
self.assertEqual(form.fields, ['baz'])
|
||||||
|
|
||||||
|
def test_append(self):
|
||||||
|
form = self.make_form(fields=['one', 'two'])
|
||||||
|
self.assertEqual(form.fields, ['one', 'two'])
|
||||||
|
form.append('one', 'two', 'three')
|
||||||
|
self.assertEqual(form.fields, ['one', 'two', 'three'])
|
||||||
|
|
||||||
def test_remove(self):
|
def test_remove(self):
|
||||||
form = self.make_form(fields=['one', 'two', 'three', 'four'])
|
form = self.make_form(fields=['one', 'two', 'three', 'four'])
|
||||||
self.assertEqual(form.fields, ['one', 'two', 'three', 'four'])
|
self.assertEqual(form.fields, ['one', 'two', 'three', 'four'])
|
||||||
|
@ -157,6 +163,14 @@ class TestForm(TestCase):
|
||||||
self.assertIs(form.validators['foo'], validate2)
|
self.assertIs(form.validators['foo'], validate2)
|
||||||
self.assertIs(schema['foo'].validator, validate2)
|
self.assertIs(schema['foo'].validator, validate2)
|
||||||
|
|
||||||
|
def test_set_default(self):
|
||||||
|
form = self.make_form(fields=['foo', 'bar'])
|
||||||
|
self.assertEqual(form.defaults, {})
|
||||||
|
|
||||||
|
# basic
|
||||||
|
form.set_default('foo', 42)
|
||||||
|
self.assertEqual(form.defaults['foo'], 42)
|
||||||
|
|
||||||
def test_get_schema(self):
|
def test_get_schema(self):
|
||||||
model = self.app.model
|
model = self.app.model
|
||||||
form = self.make_form()
|
form = self.make_form()
|
||||||
|
@ -204,6 +218,18 @@ class TestForm(TestCase):
|
||||||
self.assertIn('name', schema)
|
self.assertIn('name', schema)
|
||||||
self.assertIn('value', schema)
|
self.assertIn('value', schema)
|
||||||
|
|
||||||
|
# ColanderAlchemy schema still has *all* requested fields
|
||||||
|
form = self.make_form(model_instance=model.Setting(name='uhoh'),
|
||||||
|
fields=['name', 'value', 'foo', 'bar'])
|
||||||
|
self.assertEqual(form.fields, ['name', 'value', 'foo', 'bar'])
|
||||||
|
self.assertIsNone(form.schema)
|
||||||
|
schema = form.get_schema()
|
||||||
|
self.assertEqual(len(schema.children), 4)
|
||||||
|
self.assertIn('name', schema)
|
||||||
|
self.assertIn('value', schema)
|
||||||
|
self.assertIn('foo', schema)
|
||||||
|
self.assertIn('bar', schema)
|
||||||
|
|
||||||
# schema nodes are required by default
|
# schema nodes are required by default
|
||||||
form = self.make_form(fields=['foo', 'bar'])
|
form = self.make_form(fields=['foo', 'bar'])
|
||||||
schema = form.get_schema()
|
schema = form.get_schema()
|
||||||
|
@ -233,6 +259,12 @@ class TestForm(TestCase):
|
||||||
schema = form.get_schema()
|
schema = form.get_schema()
|
||||||
self.assertIs(schema.validator, validate)
|
self.assertIs(schema.validator, validate)
|
||||||
|
|
||||||
|
# default value overrides are honored
|
||||||
|
form = self.make_form(model_class=model.Setting)
|
||||||
|
form.set_default('name', 'foo')
|
||||||
|
schema = form.get_schema()
|
||||||
|
self.assertEqual(schema['name'].default, 'foo')
|
||||||
|
|
||||||
def test_get_deform(self):
|
def test_get_deform(self):
|
||||||
model = self.app.model
|
model = self.app.model
|
||||||
schema = self.make_schema()
|
schema = self.make_schema()
|
||||||
|
@ -422,6 +454,41 @@ class TestForm(TestCase):
|
||||||
# nb. no error message
|
# nb. no error message
|
||||||
self.assertNotIn('message', html)
|
self.assertNotIn('message', html)
|
||||||
|
|
||||||
|
def test_get_vue_field_value(self):
|
||||||
|
schema = self.make_schema()
|
||||||
|
form = self.make_form(schema=schema)
|
||||||
|
|
||||||
|
# TODO: yikes what a hack (?)
|
||||||
|
dform = form.get_deform()
|
||||||
|
dform.set_appstruct({'foo': 'one', 'bar': 'two'})
|
||||||
|
|
||||||
|
# null for missing field
|
||||||
|
value = form.get_vue_field_value('doesnotexist')
|
||||||
|
self.assertIsNone(value)
|
||||||
|
|
||||||
|
# normal value is returned
|
||||||
|
value = form.get_vue_field_value('foo')
|
||||||
|
self.assertEqual(value, 'one')
|
||||||
|
|
||||||
|
# but not if we remove field from deform
|
||||||
|
# TODO: what is the use case here again?
|
||||||
|
dform.children.remove(dform['foo'])
|
||||||
|
value = form.get_vue_field_value('foo')
|
||||||
|
self.assertIsNone(value)
|
||||||
|
|
||||||
|
def test_get_vue_model_data(self):
|
||||||
|
schema = self.make_schema()
|
||||||
|
form = self.make_form(schema=schema)
|
||||||
|
|
||||||
|
# 2 fields by default (foo, bar)
|
||||||
|
data = form.get_vue_model_data()
|
||||||
|
self.assertEqual(len(data), 2)
|
||||||
|
|
||||||
|
# still just 2 fields even if we request more
|
||||||
|
form.set_fields(['foo', 'bar', 'baz'])
|
||||||
|
data = form.get_vue_model_data()
|
||||||
|
self.assertEqual(len(data), 2)
|
||||||
|
|
||||||
def test_get_field_errors(self):
|
def test_get_field_errors(self):
|
||||||
schema = self.make_schema()
|
schema = self.make_schema()
|
||||||
form = self.make_form(schema=schema)
|
form = self.make_form(schema=schema)
|
||||||
|
|
|
@ -197,3 +197,58 @@ class TestPersonRef(DataTestCase):
|
||||||
sorted_query = typ.sort_query(query)
|
sorted_query = typ.sort_query(query)
|
||||||
self.assertIsInstance(sorted_query, orm.Query)
|
self.assertIsInstance(sorted_query, orm.Query)
|
||||||
self.assertIsNot(sorted_query, query)
|
self.assertIsNot(sorted_query, query)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRoleRefs(DataTestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.setup_db()
|
||||||
|
self.request = testing.DummyRequest(wutta_config=self.config)
|
||||||
|
|
||||||
|
def test_widget_maker(self):
|
||||||
|
model = self.app.model
|
||||||
|
auth = self.app.get_auth_handler()
|
||||||
|
admin = auth.get_role_administrator(self.session)
|
||||||
|
authed = auth.get_role_authenticated(self.session)
|
||||||
|
anon = auth.get_role_anonymous(self.session)
|
||||||
|
blokes = model.Role(name="Blokes")
|
||||||
|
self.session.add(blokes)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
# default values for widget includes all but: authed, anon
|
||||||
|
typ = mod.RoleRefs(self.request, session=self.session)
|
||||||
|
widget = typ.widget_maker()
|
||||||
|
self.assertEqual(len(widget.values), 2)
|
||||||
|
self.assertEqual(widget.values[0][1], "Administrator")
|
||||||
|
self.assertEqual(widget.values[1][1], "Blokes")
|
||||||
|
|
||||||
|
|
||||||
|
class TestPermissions(DataTestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.setup_db()
|
||||||
|
self.request = testing.DummyRequest(wutta_config=self.config)
|
||||||
|
|
||||||
|
def test_widget_maker(self):
|
||||||
|
|
||||||
|
# no supported permissions
|
||||||
|
permissions = {}
|
||||||
|
typ = mod.Permissions(self.request, permissions)
|
||||||
|
widget = typ.widget_maker()
|
||||||
|
self.assertEqual(len(widget.values), 0)
|
||||||
|
|
||||||
|
# supported permissions are morphed to values
|
||||||
|
permissions = {
|
||||||
|
'widgets': {
|
||||||
|
'label': "Widgets",
|
||||||
|
'perms': {
|
||||||
|
'widgets.polish': {
|
||||||
|
'label': "Polish the widgets",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
typ = mod.Permissions(self.request, permissions)
|
||||||
|
widget = typ.widget_maker()
|
||||||
|
self.assertEqual(len(widget.values), 1)
|
||||||
|
self.assertEqual(widget.values[0], ('widgets.polish', "Polish the widgets"))
|
||||||
|
|
|
@ -4,12 +4,19 @@ import colander
|
||||||
import deform
|
import deform
|
||||||
from pyramid import testing
|
from pyramid import testing
|
||||||
|
|
||||||
from wuttaweb.forms import widgets
|
from wuttaweb.forms import widgets as mod
|
||||||
from wuttaweb.forms.schema import PersonRef
|
from wuttaweb.forms.schema import PersonRef, RoleRefs, Permissions
|
||||||
from tests.util import WebTestCase
|
from tests.util import WebTestCase
|
||||||
|
|
||||||
|
|
||||||
class TestObjectRefWidget(WebTestCase):
|
class TestObjectRefWidget(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):
|
def test_serialize(self):
|
||||||
model = self.app.model
|
model = self.app.model
|
||||||
person = model.Person(full_name="Betty Boop")
|
person = model.Person(full_name="Betty Boop")
|
||||||
|
@ -18,15 +25,91 @@ class TestObjectRefWidget(WebTestCase):
|
||||||
|
|
||||||
# standard (editable)
|
# standard (editable)
|
||||||
node = colander.SchemaNode(PersonRef(self.request, session=self.session))
|
node = colander.SchemaNode(PersonRef(self.request, session=self.session))
|
||||||
widget = widgets.ObjectRefWidget(self.request)
|
widget = mod.ObjectRefWidget(self.request)
|
||||||
field = deform.Field(node)
|
field = self.make_field(node)
|
||||||
html = widget.serialize(field, person.uuid)
|
html = widget.serialize(field, person.uuid)
|
||||||
self.assertIn('<select ', html)
|
self.assertIn('<b-select ', html)
|
||||||
|
|
||||||
# readonly
|
# readonly
|
||||||
node = colander.SchemaNode(PersonRef(self.request, session=self.session))
|
node = colander.SchemaNode(PersonRef(self.request, session=self.session))
|
||||||
node.model_instance = person
|
node.model_instance = person
|
||||||
widget = widgets.ObjectRefWidget(self.request)
|
widget = mod.ObjectRefWidget(self.request)
|
||||||
field = deform.Field(node)
|
field = self.make_field(node)
|
||||||
html = widget.serialize(field, person.uuid, readonly=True)
|
html = widget.serialize(field, person.uuid, readonly=True)
|
||||||
self.assertEqual(html, '<span>Betty Boop</span>')
|
self.assertEqual(html.strip(), '<span>Betty Boop</span>')
|
||||||
|
|
||||||
|
|
||||||
|
class TestRoleRefsWidget(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
|
||||||
|
auth = self.app.get_auth_handler()
|
||||||
|
admin = auth.get_role_administrator(self.session)
|
||||||
|
blokes = model.Role(name="Blokes")
|
||||||
|
self.session.add(blokes)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
# nb. we let the field construct the widget via our type
|
||||||
|
node = colander.SchemaNode(RoleRefs(self.request, session=self.session))
|
||||||
|
field = self.make_field(node)
|
||||||
|
widget = field.widget
|
||||||
|
|
||||||
|
# readonly values list includes admin
|
||||||
|
html = widget.serialize(field, {admin.uuid, blokes.uuid}, readonly=True)
|
||||||
|
self.assertIn(admin.name, html)
|
||||||
|
self.assertIn(blokes.name, html)
|
||||||
|
|
||||||
|
# editable values list *excludes* admin (by default)
|
||||||
|
html = widget.serialize(field, {admin.uuid, blokes.uuid})
|
||||||
|
self.assertNotIn(admin.uuid, html)
|
||||||
|
self.assertIn(blokes.uuid, html)
|
||||||
|
|
||||||
|
# but admin is included for root user
|
||||||
|
self.request.is_root = True
|
||||||
|
html = widget.serialize(field, {admin.uuid, blokes.uuid})
|
||||||
|
self.assertIn(admin.uuid, html)
|
||||||
|
self.assertIn(blokes.uuid, html)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPermissionsWidget(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):
|
||||||
|
permissions = {
|
||||||
|
'widgets': {
|
||||||
|
'label': "Widgets",
|
||||||
|
'perms': {
|
||||||
|
'widgets.polish': {
|
||||||
|
'label': "Polish the widgets",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# nb. we let the field construct the widget via our type
|
||||||
|
node = colander.SchemaNode(Permissions(self.request, permissions, session=self.session))
|
||||||
|
field = self.make_field(node)
|
||||||
|
widget = field.widget
|
||||||
|
|
||||||
|
# readonly output does *not* include the perm by default
|
||||||
|
html = widget.serialize(field, set(), readonly=True)
|
||||||
|
self.assertNotIn("Polish the widgets", html)
|
||||||
|
|
||||||
|
# readonly output includes the perm if set
|
||||||
|
html = widget.serialize(field, {'widgets.polish'}, readonly=True)
|
||||||
|
self.assertIn("Polish the widgets", html)
|
||||||
|
|
||||||
|
# editable output always includes the perm
|
||||||
|
html = widget.serialize(field, set())
|
||||||
|
self.assertIn("Polish the widgets", html)
|
||||||
|
|
|
@ -69,12 +69,37 @@ class TestGrid(TestCase):
|
||||||
self.assertEqual(grid.columns, ['name', 'value'])
|
self.assertEqual(grid.columns, ['name', 'value'])
|
||||||
self.assertEqual(grid.get_columns(), ['name', 'value'])
|
self.assertEqual(grid.get_columns(), ['name', 'value'])
|
||||||
|
|
||||||
|
def test_append(self):
|
||||||
|
grid = self.make_grid(columns=['one', 'two'])
|
||||||
|
self.assertEqual(grid.columns, ['one', 'two'])
|
||||||
|
grid.append('one', 'two', 'three')
|
||||||
|
self.assertEqual(grid.columns, ['one', 'two', 'three'])
|
||||||
|
|
||||||
def test_remove(self):
|
def test_remove(self):
|
||||||
grid = self.make_grid(columns=['one', 'two', 'three', 'four'])
|
grid = self.make_grid(columns=['one', 'two', 'three', 'four'])
|
||||||
self.assertEqual(grid.columns, ['one', 'two', 'three', 'four'])
|
self.assertEqual(grid.columns, ['one', 'two', 'three', 'four'])
|
||||||
grid.remove('two', 'three')
|
grid.remove('two', 'three')
|
||||||
self.assertEqual(grid.columns, ['one', 'four'])
|
self.assertEqual(grid.columns, ['one', 'four'])
|
||||||
|
|
||||||
|
def test_set_renderer(self):
|
||||||
|
grid = self.make_grid(columns=['foo', 'bar'])
|
||||||
|
self.assertEqual(grid.renderers, {})
|
||||||
|
|
||||||
|
def render1(record, key, value):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# basic
|
||||||
|
grid.set_renderer('foo', render1)
|
||||||
|
self.assertIs(grid.renderers['foo'], render1)
|
||||||
|
|
||||||
|
def render2(record, key, value, extra=None):
|
||||||
|
return extra
|
||||||
|
|
||||||
|
# can pass kwargs to get a partial
|
||||||
|
grid.set_renderer('foo', render2, extra=42)
|
||||||
|
self.assertIsNot(grid.renderers['foo'], render2)
|
||||||
|
self.assertEqual(grid.renderers['foo'](None, None, None), 42)
|
||||||
|
|
||||||
def test_linked_columns(self):
|
def test_linked_columns(self):
|
||||||
grid = self.make_grid(columns=['foo', 'bar'])
|
grid = self.make_grid(columns=['foo', 'bar'])
|
||||||
self.assertEqual(grid.linked_columns, [])
|
self.assertEqual(grid.linked_columns, [])
|
||||||
|
@ -143,6 +168,11 @@ class TestGrid(TestCase):
|
||||||
self.assertIsNot(data, mydata)
|
self.assertIsNot(data, mydata)
|
||||||
self.assertEqual(data, [{'foo': 'bar', '_action_url_view': '/blarg'}])
|
self.assertEqual(data, [{'foo': 'bar', '_action_url_view': '/blarg'}])
|
||||||
|
|
||||||
|
# also can override value rendering
|
||||||
|
grid.set_renderer('foo', lambda record, key, value: "blah blah")
|
||||||
|
data = grid.get_vue_data()
|
||||||
|
self.assertEqual(data, [{'foo': 'blah blah', '_action_url_view': '/blarg'}])
|
||||||
|
|
||||||
|
|
||||||
class TestGridAction(TestCase):
|
class TestGridAction(TestCase):
|
||||||
|
|
||||||
|
@ -160,9 +190,10 @@ class TestGridAction(TestCase):
|
||||||
html = action.render_icon()
|
html = action.render_icon()
|
||||||
self.assertIn('<i class="fas fa-blarg">', html)
|
self.assertIn('<i class="fas fa-blarg">', html)
|
||||||
|
|
||||||
# oruga not yet supported
|
# oruga has different output
|
||||||
self.request.use_oruga = True
|
self.request.use_oruga = True
|
||||||
self.assertRaises(NotImplementedError, action.render_icon)
|
html = action.render_icon()
|
||||||
|
self.assertIn('<o-icon icon="blarg">', html)
|
||||||
|
|
||||||
def test_render_label(self):
|
def test_render_label(self):
|
||||||
|
|
||||||
|
|
|
@ -7,6 +7,7 @@ from pyramid import testing
|
||||||
|
|
||||||
from wuttjamaican.conf import WuttaConfig
|
from wuttjamaican.conf import WuttaConfig
|
||||||
from wuttaweb import auth as mod
|
from wuttaweb import auth as mod
|
||||||
|
from tests.util import WebTestCase
|
||||||
|
|
||||||
|
|
||||||
class TestLoginUser(TestCase):
|
class TestLoginUser(TestCase):
|
||||||
|
@ -143,3 +144,26 @@ class TestWuttaSecurityPolicy(TestCase):
|
||||||
self.assertFalse(self.policy.permits(self.request, None, 'some-root-perm'))
|
self.assertFalse(self.policy.permits(self.request, None, 'some-root-perm'))
|
||||||
self.request.is_root = True
|
self.request.is_root = True
|
||||||
self.assertTrue(self.policy.permits(self.request, None, 'some-root-perm'))
|
self.assertTrue(self.policy.permits(self.request, None, 'some-root-perm'))
|
||||||
|
|
||||||
|
|
||||||
|
class TestAddPermissionGroup(WebTestCase):
|
||||||
|
|
||||||
|
def test_basic(self):
|
||||||
|
permissions = self.pyramid_config.get_settings().get('wutta_permissions', {})
|
||||||
|
self.assertNotIn('widgets', permissions)
|
||||||
|
self.pyramid_config.add_wutta_permission_group('widgets')
|
||||||
|
permissions = self.pyramid_config.get_settings().get('wutta_permissions', {})
|
||||||
|
self.assertIn('widgets', permissions)
|
||||||
|
self.assertEqual(permissions['widgets']['label'], "Widgets")
|
||||||
|
|
||||||
|
|
||||||
|
class TestAddPermission(WebTestCase):
|
||||||
|
|
||||||
|
def test_basic(self):
|
||||||
|
permissions = self.pyramid_config.get_settings().get('wutta_permissions', {})
|
||||||
|
self.assertNotIn('widgets', permissions)
|
||||||
|
self.pyramid_config.add_wutta_permission('widgets', 'widgets.polish')
|
||||||
|
permissions = self.pyramid_config.get_settings().get('wutta_permissions', {})
|
||||||
|
self.assertIn('widgets', permissions)
|
||||||
|
self.assertEqual(permissions['widgets']['label'], "Widgets")
|
||||||
|
self.assertIn('widgets.polish', permissions['widgets']['perms'])
|
||||||
|
|
|
@ -3,20 +3,15 @@
|
||||||
from unittest import TestCase
|
from unittest import TestCase
|
||||||
from unittest.mock import patch, MagicMock
|
from unittest.mock import patch, MagicMock
|
||||||
|
|
||||||
from wuttjamaican.conf import WuttaConfig
|
|
||||||
|
|
||||||
from pyramid import testing
|
|
||||||
|
|
||||||
from wuttaweb import menus as mod
|
from wuttaweb import menus as mod
|
||||||
|
from tests.util import WebTestCase
|
||||||
|
|
||||||
|
|
||||||
class TestMenuHandler(TestCase):
|
class TestMenuHandler(WebTestCase):
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.config = WuttaConfig()
|
self.setup_web()
|
||||||
self.app = self.config.get_app()
|
|
||||||
self.handler = mod.MenuHandler(self.config)
|
self.handler = mod.MenuHandler(self.config)
|
||||||
self.request = testing.DummyRequest()
|
|
||||||
|
|
||||||
def test_make_admin_menu(self):
|
def test_make_admin_menu(self):
|
||||||
menus = self.handler.make_admin_menu(self.request)
|
menus = self.handler.make_admin_menu(self.request)
|
||||||
|
@ -27,7 +22,27 @@ class TestMenuHandler(TestCase):
|
||||||
self.assertIsInstance(menus, list)
|
self.assertIsInstance(menus, list)
|
||||||
|
|
||||||
def test_is_allowed(self):
|
def test_is_allowed(self):
|
||||||
# TODO: this should test auth/perm handling
|
model = self.app.model
|
||||||
|
auth = self.app.get_auth_handler()
|
||||||
|
|
||||||
|
# user with perms
|
||||||
|
barney = model.User(username='barney')
|
||||||
|
self.session.add(barney)
|
||||||
|
blokes = model.Role(name="Blokes")
|
||||||
|
self.session.add(blokes)
|
||||||
|
barney.roles.append(blokes)
|
||||||
|
auth.grant_permission(blokes, 'appinfo.list')
|
||||||
|
self.request.user = barney
|
||||||
|
|
||||||
|
# perm not granted to user
|
||||||
|
item = {'perm': 'appinfo.configure'}
|
||||||
|
self.assertFalse(self.handler._is_allowed(self.request, item))
|
||||||
|
|
||||||
|
# perm *is* granted to user
|
||||||
|
item = {'perm': 'appinfo.list'}
|
||||||
|
self.assertTrue(self.handler._is_allowed(self.request, item))
|
||||||
|
|
||||||
|
# perm not required
|
||||||
item = {}
|
item = {}
|
||||||
self.assertTrue(self.handler._is_allowed(self.request, item))
|
self.assertTrue(self.handler._is_allowed(self.request, item))
|
||||||
|
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from unittest import TestCase
|
from unittest import TestCase
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
from wuttjamaican.conf import WuttaConfig
|
from wuttjamaican.conf import WuttaConfig
|
||||||
|
|
||||||
|
@ -210,6 +210,137 @@ class TestNewRequestSetUser(TestCase):
|
||||||
self.assertTrue(self.request.is_admin)
|
self.assertTrue(self.request.is_admin)
|
||||||
self.assertTrue(self.request.is_root)
|
self.assertTrue(self.request.is_root)
|
||||||
|
|
||||||
|
def test_user_permissions(self):
|
||||||
|
model = self.app.model
|
||||||
|
auth = self.app.get_auth_handler()
|
||||||
|
event = MagicMock(request=self.request)
|
||||||
|
|
||||||
|
# anonymous user
|
||||||
|
self.assertFalse(hasattr(self.request, 'user_permissions'))
|
||||||
|
subscribers.new_request_set_user(event, db_session=self.session)
|
||||||
|
self.assertEqual(self.request.user_permissions, set())
|
||||||
|
|
||||||
|
# reset
|
||||||
|
del self.request.user_permissions
|
||||||
|
|
||||||
|
# add user to role with perms
|
||||||
|
blokes = model.Role(name="Blokes")
|
||||||
|
self.session.add(blokes)
|
||||||
|
auth.grant_permission(blokes, 'appinfo.list')
|
||||||
|
self.user.roles.append(blokes)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
# authenticated user, with perms
|
||||||
|
self.request.user = self.user
|
||||||
|
subscribers.new_request_set_user(event, db_session=self.session)
|
||||||
|
self.assertEqual(self.request.user_permissions, {'appinfo.list'})
|
||||||
|
|
||||||
|
def test_has_perm(self):
|
||||||
|
model = self.app.model
|
||||||
|
auth = self.app.get_auth_handler()
|
||||||
|
event = MagicMock(request=self.request)
|
||||||
|
|
||||||
|
# anonymous user
|
||||||
|
self.assertFalse(hasattr(self.request, 'has_perm'))
|
||||||
|
subscribers.new_request_set_user(event, db_session=self.session)
|
||||||
|
self.assertFalse(self.request.has_perm('appinfo.list'))
|
||||||
|
|
||||||
|
# reset
|
||||||
|
del self.request.user_permissions
|
||||||
|
del self.request.has_perm
|
||||||
|
del self.request.has_any_perm
|
||||||
|
|
||||||
|
# add user to role with perms
|
||||||
|
blokes = model.Role(name="Blokes")
|
||||||
|
self.session.add(blokes)
|
||||||
|
auth.grant_permission(blokes, 'appinfo.list')
|
||||||
|
self.user.roles.append(blokes)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
# authenticated user, with perms
|
||||||
|
self.request.user = self.user
|
||||||
|
subscribers.new_request_set_user(event, db_session=self.session)
|
||||||
|
self.assertTrue(self.request.has_perm('appinfo.list'))
|
||||||
|
|
||||||
|
# reset
|
||||||
|
del self.request.user_permissions
|
||||||
|
del self.request.has_perm
|
||||||
|
del self.request.has_any_perm
|
||||||
|
|
||||||
|
# drop user from role, no more perms
|
||||||
|
self.user.roles.remove(blokes)
|
||||||
|
self.session.commit()
|
||||||
|
subscribers.new_request_set_user(event, db_session=self.session)
|
||||||
|
self.assertFalse(self.request.has_perm('appinfo.list'))
|
||||||
|
|
||||||
|
# reset
|
||||||
|
del self.request.user_permissions
|
||||||
|
del self.request.has_perm
|
||||||
|
del self.request.has_any_perm
|
||||||
|
del self.request.is_admin
|
||||||
|
del self.request.is_root
|
||||||
|
|
||||||
|
# root user always has perms
|
||||||
|
admin = auth.get_role_administrator(self.session)
|
||||||
|
self.user.roles.append(admin)
|
||||||
|
self.session.commit()
|
||||||
|
self.request.session['is_root'] = True
|
||||||
|
subscribers.new_request_set_user(event, db_session=self.session)
|
||||||
|
self.assertTrue(self.request.has_perm('appinfo.list'))
|
||||||
|
|
||||||
|
def test_has_any_perm(self):
|
||||||
|
model = self.app.model
|
||||||
|
auth = self.app.get_auth_handler()
|
||||||
|
event = MagicMock(request=self.request)
|
||||||
|
|
||||||
|
# anonymous user
|
||||||
|
self.assertFalse(hasattr(self.request, 'has_any_perm'))
|
||||||
|
subscribers.new_request_set_user(event, db_session=self.session)
|
||||||
|
self.assertFalse(self.request.has_any_perm('appinfo.list'))
|
||||||
|
|
||||||
|
# reset
|
||||||
|
del self.request.user_permissions
|
||||||
|
del self.request.has_perm
|
||||||
|
del self.request.has_any_perm
|
||||||
|
|
||||||
|
# add user to role with perms
|
||||||
|
blokes = model.Role(name="Blokes")
|
||||||
|
self.session.add(blokes)
|
||||||
|
auth.grant_permission(blokes, 'appinfo.list')
|
||||||
|
self.user.roles.append(blokes)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
# authenticated user, with perms
|
||||||
|
self.request.user = self.user
|
||||||
|
subscribers.new_request_set_user(event, db_session=self.session)
|
||||||
|
self.assertTrue(self.request.has_any_perm('appinfo.list', 'appinfo.view'))
|
||||||
|
|
||||||
|
# reset
|
||||||
|
del self.request.user_permissions
|
||||||
|
del self.request.has_perm
|
||||||
|
del self.request.has_any_perm
|
||||||
|
|
||||||
|
# drop user from role, no more perms
|
||||||
|
self.user.roles.remove(blokes)
|
||||||
|
self.session.commit()
|
||||||
|
subscribers.new_request_set_user(event, db_session=self.session)
|
||||||
|
self.assertFalse(self.request.has_any_perm('appinfo.list'))
|
||||||
|
|
||||||
|
# reset
|
||||||
|
del self.request.user_permissions
|
||||||
|
del self.request.has_perm
|
||||||
|
del self.request.has_any_perm
|
||||||
|
del self.request.is_admin
|
||||||
|
del self.request.is_root
|
||||||
|
|
||||||
|
# root user always has perms
|
||||||
|
admin = auth.get_role_administrator(self.session)
|
||||||
|
self.user.roles.append(admin)
|
||||||
|
self.session.commit()
|
||||||
|
self.request.session['is_root'] = True
|
||||||
|
subscribers.new_request_set_user(event, db_session=self.session)
|
||||||
|
self.assertTrue(self.request.has_any_perm('appinfo.list'))
|
||||||
|
|
||||||
|
|
||||||
class TestBeforeRender(TestCase):
|
class TestBeforeRender(TestCase):
|
||||||
|
|
||||||
|
|
|
@ -442,6 +442,14 @@ class TestGetModelFields(TestCase):
|
||||||
self.config = WuttaConfig()
|
self.config = WuttaConfig()
|
||||||
self.app = self.config.get_app()
|
self.app = self.config.get_app()
|
||||||
|
|
||||||
|
def test_empty_model_class(self):
|
||||||
|
fields = util.get_model_fields(self.config)
|
||||||
|
self.assertIsNone(fields)
|
||||||
|
|
||||||
|
def test_unknown_model_class(self):
|
||||||
|
fields = util.get_model_fields(self.config, TestCase)
|
||||||
|
self.assertIsNone(fields)
|
||||||
|
|
||||||
def test_basic(self):
|
def test_basic(self):
|
||||||
model = self.app.model
|
model = self.app.model
|
||||||
fields = util.get_model_fields(self.config, model.Setting)
|
fields = util.get_model_fields(self.config, model.Setting)
|
||||||
|
|
|
@ -46,20 +46,23 @@ class WebTestCase(DataTestCase):
|
||||||
|
|
||||||
def setup_web(self):
|
def setup_web(self):
|
||||||
self.setup_db()
|
self.setup_db()
|
||||||
self.request = testing.DummyRequest()
|
self.request = self.make_request()
|
||||||
self.pyramid_config = testing.setUp(request=self.request, settings={
|
self.pyramid_config = testing.setUp(request=self.request, settings={
|
||||||
'wutta_config': self.config,
|
'wutta_config': self.config,
|
||||||
'mako.directories': ['wuttaweb:templates'],
|
'mako.directories': ['wuttaweb:templates'],
|
||||||
# TODO: have not need this yet, but will?
|
'pyramid_deform.template_search_path': 'wuttaweb:templates/deform',
|
||||||
# 'pyramid_deform.template_search_path': 'wuttaweb:templates/deform',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
# init web
|
# init web
|
||||||
|
self.pyramid_config.include('pyramid_deform')
|
||||||
self.pyramid_config.include('pyramid_mako')
|
self.pyramid_config.include('pyramid_mako')
|
||||||
self.pyramid_config.include('wuttaweb.static')
|
self.pyramid_config.add_directive('add_wutta_permission_group',
|
||||||
self.pyramid_config.include('wuttaweb.views.essential')
|
'wuttaweb.auth.add_permission_group')
|
||||||
|
self.pyramid_config.add_directive('add_wutta_permission',
|
||||||
|
'wuttaweb.auth.add_permission')
|
||||||
self.pyramid_config.add_subscriber('wuttaweb.subscribers.before_render',
|
self.pyramid_config.add_subscriber('wuttaweb.subscribers.before_render',
|
||||||
'pyramid.events.BeforeRender')
|
'pyramid.events.BeforeRender')
|
||||||
|
self.pyramid_config.include('wuttaweb.static')
|
||||||
|
|
||||||
# setup new request w/ anonymous user
|
# setup new request w/ anonymous user
|
||||||
event = MagicMock(request=self.request)
|
event = MagicMock(request=self.request)
|
||||||
|
@ -75,6 +78,9 @@ class WebTestCase(DataTestCase):
|
||||||
testing.tearDown()
|
testing.tearDown()
|
||||||
self.teardown_db()
|
self.teardown_db()
|
||||||
|
|
||||||
|
def make_request(self):
|
||||||
|
return testing.DummyRequest()
|
||||||
|
|
||||||
|
|
||||||
class NullMenuHandler(MenuHandler):
|
class NullMenuHandler(MenuHandler):
|
||||||
"""
|
"""
|
||||||
|
|
|
@ -1,14 +1,10 @@
|
||||||
# -*- coding: utf-8; -*-
|
# -*- coding: utf-8; -*-
|
||||||
|
|
||||||
from unittest import TestCase
|
from tests.util import WebTestCase
|
||||||
|
|
||||||
from pyramid import testing
|
|
||||||
|
|
||||||
|
|
||||||
class TestIncludeMe(TestCase):
|
class TestIncludeMe(WebTestCase):
|
||||||
|
|
||||||
def test_basic(self):
|
def test_basic(self):
|
||||||
with testing.testConfig() as pyramid_config:
|
|
||||||
|
|
||||||
# just ensure no error happens when included..
|
# just ensure no error happens when included..
|
||||||
pyramid_config.include('wuttaweb.views')
|
self.pyramid_config.include('wuttaweb.views')
|
||||||
|
|
|
@ -1,90 +1,88 @@
|
||||||
# -*- coding: utf-8; -*-
|
# -*- coding: utf-8; -*-
|
||||||
|
|
||||||
from unittest import TestCase
|
from unittest.mock import MagicMock, patch
|
||||||
from unittest.mock import MagicMock
|
|
||||||
|
|
||||||
from pyramid import testing
|
|
||||||
from pyramid.httpexceptions import HTTPFound, HTTPForbidden
|
from pyramid.httpexceptions import HTTPFound, HTTPForbidden
|
||||||
|
|
||||||
from wuttjamaican.conf import WuttaConfig
|
|
||||||
from wuttaweb.views import auth as mod
|
from wuttaweb.views import auth as mod
|
||||||
from wuttaweb.auth import WuttaSecurityPolicy
|
from tests.util import WebTestCase
|
||||||
from wuttaweb.subscribers import new_request
|
|
||||||
|
|
||||||
|
|
||||||
class TestAuthView(TestCase):
|
class TestAuthView(WebTestCase):
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.config = WuttaConfig(defaults={
|
self.setup_web()
|
||||||
'wutta.db.default.url': 'sqlite://',
|
|
||||||
})
|
|
||||||
|
|
||||||
self.request = testing.DummyRequest(wutta_config=self.config, user=None)
|
|
||||||
self.pyramid_config = testing.setUp(request=self.request, settings={
|
|
||||||
'wutta_config': self.config,
|
|
||||||
})
|
|
||||||
|
|
||||||
self.app = self.config.get_app()
|
|
||||||
auth = self.app.get_auth_handler()
|
|
||||||
model = self.app.model
|
|
||||||
model.Base.metadata.create_all(bind=self.config.appdb_engine)
|
|
||||||
self.session = self.app.make_session()
|
|
||||||
self.user = model.User(username='barney')
|
|
||||||
self.session.add(self.user)
|
|
||||||
auth.set_user_password(self.user, 'testpass')
|
|
||||||
self.session.commit()
|
|
||||||
|
|
||||||
self.pyramid_config.set_security_policy(WuttaSecurityPolicy(db_session=self.session))
|
|
||||||
self.pyramid_config.include('wuttaweb.views.auth')
|
|
||||||
self.pyramid_config.include('wuttaweb.views.common')
|
self.pyramid_config.include('wuttaweb.views.common')
|
||||||
|
|
||||||
def tearDown(self):
|
def make_view(self):
|
||||||
testing.tearDown()
|
return mod.AuthView(self.request)
|
||||||
|
|
||||||
|
def test_includeme(self):
|
||||||
|
self.pyramid_config.include('wuttaweb.views.auth')
|
||||||
|
|
||||||
def test_login(self):
|
def test_login(self):
|
||||||
view = mod.AuthView(self.request)
|
model = self.app.model
|
||||||
context = view.login()
|
auth = self.app.get_auth_handler()
|
||||||
|
view = self.make_view()
|
||||||
|
|
||||||
|
# until user exists, will redirect
|
||||||
|
self.assertEqual(self.session.query(model.User).count(), 0)
|
||||||
|
response = view.login(session=self.session)
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
|
||||||
|
# make a user
|
||||||
|
barney = model.User(username='barney')
|
||||||
|
auth.set_user_password(barney, 'testpass')
|
||||||
|
self.session.add(barney)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
# now since user exists, form will display
|
||||||
|
context = view.login(session=self.session)
|
||||||
self.assertIn('form', context)
|
self.assertIn('form', context)
|
||||||
|
|
||||||
# redirect if user already logged in
|
# redirect if user already logged in
|
||||||
self.request.user = self.user
|
with patch.object(self.request, 'user', new=barney):
|
||||||
view = mod.AuthView(self.request)
|
view = self.make_view()
|
||||||
redirect = view.login(session=self.session)
|
response = view.login(session=self.session)
|
||||||
self.assertIsInstance(redirect, HTTPFound)
|
self.assertEqual(response.status_code, 302)
|
||||||
|
|
||||||
# login fails w/ wrong password
|
# login fails w/ wrong password
|
||||||
self.request.user = None
|
|
||||||
self.request.method = 'POST'
|
self.request.method = 'POST'
|
||||||
self.request.POST = {'username': 'barney', 'password': 'WRONG'}
|
self.request.POST = {'username': 'barney', 'password': 'WRONG'}
|
||||||
view = mod.AuthView(self.request)
|
view = self.make_view()
|
||||||
context = view.login(session=self.session)
|
context = view.login(session=self.session)
|
||||||
self.assertIn('form', context)
|
self.assertIn('form', context)
|
||||||
|
|
||||||
# redirect if login succeeds
|
# redirect if login succeeds
|
||||||
self.request.method = 'POST'
|
self.request.method = 'POST'
|
||||||
self.request.POST = {'username': 'barney', 'password': 'testpass'}
|
self.request.POST = {'username': 'barney', 'password': 'testpass'}
|
||||||
view = mod.AuthView(self.request)
|
view = self.make_view()
|
||||||
redirect = view.login(session=self.session)
|
response = view.login(session=self.session)
|
||||||
self.assertIsInstance(redirect, HTTPFound)
|
self.assertEqual(response.status_code, 302)
|
||||||
|
|
||||||
def test_logout(self):
|
def test_logout(self):
|
||||||
view = mod.AuthView(self.request)
|
self.pyramid_config.add_route('login', '/login')
|
||||||
|
view = self.make_view()
|
||||||
self.request.session.delete = MagicMock()
|
self.request.session.delete = MagicMock()
|
||||||
redirect = view.logout()
|
response = view.logout()
|
||||||
self.request.session.delete.assert_called_once_with()
|
self.request.session.delete.assert_called_once_with()
|
||||||
self.assertIsInstance(redirect, HTTPFound)
|
self.assertEqual(response.status_code, 302)
|
||||||
|
|
||||||
def test_change_password(self):
|
def test_change_password(self):
|
||||||
view = mod.AuthView(self.request)
|
model = self.app.model
|
||||||
auth = self.app.get_auth_handler()
|
auth = self.app.get_auth_handler()
|
||||||
|
barney = model.User(username='barney')
|
||||||
|
self.session.add(barney)
|
||||||
|
self.session.commit()
|
||||||
|
view = self.make_view()
|
||||||
|
|
||||||
# unauthenticated user is redirected
|
# unauthenticated user is redirected
|
||||||
redirect = view.change_password()
|
redirect = view.change_password()
|
||||||
self.assertIsInstance(redirect, HTTPFound)
|
self.assertIsInstance(redirect, HTTPFound)
|
||||||
|
|
||||||
# now "login" the user, and set initial password
|
# now "login" the user, and set initial password
|
||||||
self.request.user = self.user
|
self.request.user = barney
|
||||||
auth.set_user_password(self.user, 'foo')
|
auth.set_user_password(barney, 'foo')
|
||||||
self.session.commit()
|
self.session.commit()
|
||||||
|
|
||||||
# view should now return context w/ form
|
# view should now return context w/ form
|
||||||
|
@ -105,9 +103,8 @@ class TestAuthView(TestCase):
|
||||||
redirect = view.change_password()
|
redirect = view.change_password()
|
||||||
self.assertIsInstance(redirect, HTTPFound)
|
self.assertIsInstance(redirect, HTTPFound)
|
||||||
self.session.commit()
|
self.session.commit()
|
||||||
self.session.refresh(self.user)
|
self.assertFalse(auth.check_user_password(barney, 'foo'))
|
||||||
self.assertFalse(auth.check_user_password(self.user, 'foo'))
|
self.assertTrue(auth.check_user_password(barney, 'bar'))
|
||||||
self.assertTrue(auth.check_user_password(self.user, 'bar'))
|
|
||||||
|
|
||||||
# at this point 'foo' is the password, now let's submit some
|
# at this point 'foo' is the password, now let's submit some
|
||||||
# invalid forms and make sure we get back a context w/ form
|
# invalid forms and make sure we get back a context w/ form
|
||||||
|
@ -147,8 +144,6 @@ class TestAuthView(TestCase):
|
||||||
self.assertEqual(dform['new_password'].errormsg, "New password must be different from old password.")
|
self.assertEqual(dform['new_password'].errormsg, "New password must be different from old password.")
|
||||||
|
|
||||||
def test_become_root(self):
|
def test_become_root(self):
|
||||||
event = MagicMock(request=self.request)
|
|
||||||
new_request(event) # add request.get_referrer()
|
|
||||||
view = mod.AuthView(self.request)
|
view = mod.AuthView(self.request)
|
||||||
|
|
||||||
# GET not allowed
|
# GET not allowed
|
||||||
|
@ -168,8 +163,6 @@ class TestAuthView(TestCase):
|
||||||
self.assertTrue(self.request.session['is_root'])
|
self.assertTrue(self.request.session['is_root'])
|
||||||
|
|
||||||
def test_stop_root(self):
|
def test_stop_root(self):
|
||||||
event = MagicMock(request=self.request)
|
|
||||||
new_request(event) # add request.get_referrer()
|
|
||||||
view = mod.AuthView(self.request)
|
view = mod.AuthView(self.request)
|
||||||
|
|
||||||
# GET not allowed
|
# GET not allowed
|
||||||
|
|
|
@ -1,27 +1,95 @@
|
||||||
# -*- coding: utf-8; -*-
|
# -*- coding: utf-8; -*-
|
||||||
|
|
||||||
from unittest import TestCase
|
from wuttaweb.views import common as mod
|
||||||
|
from tests.util import WebTestCase
|
||||||
from pyramid import testing
|
|
||||||
|
|
||||||
from wuttjamaican.conf import WuttaConfig
|
|
||||||
from wuttaweb.views import common
|
|
||||||
|
|
||||||
|
|
||||||
class TestCommonView(TestCase):
|
class TestCommonView(WebTestCase):
|
||||||
|
|
||||||
def setUp(self):
|
def make_view(self):
|
||||||
self.config = WuttaConfig()
|
return mod.CommonView(self.request)
|
||||||
self.app = self.config.get_app()
|
|
||||||
self.request = testing.DummyRequest()
|
def test_includeme(self):
|
||||||
self.request.wutta_config = self.config
|
|
||||||
self.pyramid_config = testing.setUp(request=self.request)
|
|
||||||
self.pyramid_config.include('wuttaweb.views.common')
|
self.pyramid_config.include('wuttaweb.views.common')
|
||||||
|
|
||||||
def tearDown(self):
|
def test_forbidden_view(self):
|
||||||
testing.tearDown()
|
view = self.make_view()
|
||||||
|
context = view.forbidden_view()
|
||||||
|
self.assertEqual(context['index_title'], self.app.get_title())
|
||||||
|
|
||||||
|
def test_notfound_view(self):
|
||||||
|
view = self.make_view()
|
||||||
|
context = view.notfound_view()
|
||||||
|
self.assertEqual(context['index_title'], self.app.get_title())
|
||||||
|
|
||||||
def test_home(self):
|
def test_home(self):
|
||||||
view = common.CommonView(self.request)
|
self.pyramid_config.add_route('setup', '/setup')
|
||||||
context = view.home()
|
model = self.app.model
|
||||||
|
view = self.make_view()
|
||||||
|
|
||||||
|
# if no users then home page will redirect
|
||||||
|
response = view.home(session=self.session)
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
|
||||||
|
# so add a user
|
||||||
|
user = model.User(username='foo')
|
||||||
|
self.session.add(user)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
# now we see the home page
|
||||||
|
context = view.home(session=self.session)
|
||||||
self.assertEqual(context['index_title'], self.app.get_title())
|
self.assertEqual(context['index_title'], self.app.get_title())
|
||||||
|
|
||||||
|
def test_setup(self):
|
||||||
|
self.pyramid_config.add_route('home', '/')
|
||||||
|
self.pyramid_config.add_route('login', '/login')
|
||||||
|
model = self.app.model
|
||||||
|
auth = self.app.get_auth_handler()
|
||||||
|
view = self.make_view()
|
||||||
|
|
||||||
|
# at first, can see the setup page
|
||||||
|
self.assertEqual(self.session.query(model.User).count(), 0)
|
||||||
|
context = view.setup(session=self.session)
|
||||||
|
self.assertEqual(context['index_title'], self.app.get_title())
|
||||||
|
|
||||||
|
# so add a user
|
||||||
|
user = model.User(username='foo')
|
||||||
|
self.session.add(user)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
# once user exists it will always redirect
|
||||||
|
response = view.setup(session=self.session)
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
|
||||||
|
# delete that user
|
||||||
|
self.session.delete(user)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
# so we can see the setup page again
|
||||||
|
context = view.setup(session=self.session)
|
||||||
|
self.assertEqual(context['index_title'], self.app.get_title())
|
||||||
|
|
||||||
|
# and finally, post data to create admin user
|
||||||
|
self.request.method = 'POST'
|
||||||
|
self.request.POST = {
|
||||||
|
'username': 'barney',
|
||||||
|
'__start__': 'password:mapping',
|
||||||
|
'password': 'testpass',
|
||||||
|
'password-confirm': 'testpass',
|
||||||
|
'__end__': 'password:mapping',
|
||||||
|
'first_name': "Barney",
|
||||||
|
'last_name': "Rubble",
|
||||||
|
}
|
||||||
|
response = view.setup(session=self.session)
|
||||||
|
# nb. redirects on success
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
barney = self.session.query(model.User).one()
|
||||||
|
self.assertEqual(barney.username, 'barney')
|
||||||
|
self.assertTrue(auth.check_user_password(barney, 'testpass'))
|
||||||
|
admin = auth.get_role_administrator(self.session)
|
||||||
|
self.assertIn(admin, barney.roles)
|
||||||
|
self.assertIsNotNone(barney.person)
|
||||||
|
person = barney.person
|
||||||
|
self.assertEqual(person.first_name, "Barney")
|
||||||
|
self.assertEqual(person.last_name, "Rubble")
|
||||||
|
self.assertEqual(person.full_name, "Barney Rubble")
|
||||||
|
|
|
@ -6,7 +6,7 @@ from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
from pyramid import testing
|
from pyramid import testing
|
||||||
from pyramid.response import Response
|
from pyramid.response import Response
|
||||||
from pyramid.httpexceptions import HTTPFound, HTTPNotFound
|
from pyramid.httpexceptions import HTTPNotFound
|
||||||
|
|
||||||
from wuttjamaican.conf import WuttaConfig
|
from wuttjamaican.conf import WuttaConfig
|
||||||
from wuttaweb.views import master
|
from wuttaweb.views import master
|
||||||
|
@ -16,12 +16,14 @@ from tests.util import WebTestCase
|
||||||
|
|
||||||
class TestMasterView(WebTestCase):
|
class TestMasterView(WebTestCase):
|
||||||
|
|
||||||
|
def make_view(self):
|
||||||
|
return master.MasterView(self.request)
|
||||||
|
|
||||||
def test_defaults(self):
|
def test_defaults(self):
|
||||||
with patch.multiple(master.MasterView, create=True,
|
with patch.multiple(master.MasterView, create=True,
|
||||||
model_name='Widget',
|
model_name='Widget',
|
||||||
viewable=False,
|
model_key='uuid',
|
||||||
editable=False,
|
configurable=True):
|
||||||
deletable=False):
|
|
||||||
master.MasterView.defaults(self.pyramid_config)
|
master.MasterView.defaults(self.pyramid_config)
|
||||||
|
|
||||||
##############################
|
##############################
|
||||||
|
@ -159,6 +161,24 @@ class TestMasterView(WebTestCase):
|
||||||
model_class=MyModel):
|
model_class=MyModel):
|
||||||
self.assertEqual(master.MasterView.get_route_prefix(), 'trucks')
|
self.assertEqual(master.MasterView.get_route_prefix(), 'trucks')
|
||||||
|
|
||||||
|
def test_get_permission_prefix(self):
|
||||||
|
|
||||||
|
# error by default (since no model class)
|
||||||
|
self.assertRaises(AttributeError, master.MasterView.get_permission_prefix)
|
||||||
|
|
||||||
|
# subclass may specify permission prefix
|
||||||
|
with patch.object(master.MasterView, 'permission_prefix', new='widgets', create=True):
|
||||||
|
self.assertEqual(master.MasterView.get_permission_prefix(), 'widgets')
|
||||||
|
|
||||||
|
# subclass may specify route prefix
|
||||||
|
with patch.object(master.MasterView, 'route_prefix', new='widgets', create=True):
|
||||||
|
self.assertEqual(master.MasterView.get_permission_prefix(), 'widgets')
|
||||||
|
|
||||||
|
# or it may specify model class
|
||||||
|
Truck = MagicMock(__name__='Truck')
|
||||||
|
with patch.object(master.MasterView, 'model_class', new=Truck, create=True):
|
||||||
|
self.assertEqual(master.MasterView.get_permission_prefix(), 'trucks')
|
||||||
|
|
||||||
def test_get_url_prefix(self):
|
def test_get_url_prefix(self):
|
||||||
|
|
||||||
# error by default (since no model class)
|
# error by default (since no model class)
|
||||||
|
@ -311,7 +331,68 @@ class TestMasterView(WebTestCase):
|
||||||
# support methods
|
# support methods
|
||||||
##############################
|
##############################
|
||||||
|
|
||||||
|
def test_has_perm(self):
|
||||||
|
model = self.app.model
|
||||||
|
auth = self.app.get_auth_handler()
|
||||||
|
|
||||||
|
with patch.multiple(master.MasterView, create=True,
|
||||||
|
model_name='Setting'):
|
||||||
|
view = self.make_view()
|
||||||
|
|
||||||
|
# anonymous user
|
||||||
|
self.assertFalse(view.has_perm('list'))
|
||||||
|
self.assertFalse(self.request.has_perm('list'))
|
||||||
|
|
||||||
|
# reset
|
||||||
|
del self.request.user_permissions
|
||||||
|
|
||||||
|
# make user with perms
|
||||||
|
barney = model.User(username='barney')
|
||||||
|
self.session.add(barney)
|
||||||
|
blokes = model.Role(name="Blokes")
|
||||||
|
self.session.add(blokes)
|
||||||
|
barney.roles.append(blokes)
|
||||||
|
auth.grant_permission(blokes, 'settings.list')
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
# this user has perms
|
||||||
|
self.request.user = barney
|
||||||
|
self.assertTrue(view.has_perm('list'))
|
||||||
|
self.assertTrue(self.request.has_perm('settings.list'))
|
||||||
|
|
||||||
|
def test_has_any_perm(self):
|
||||||
|
model = self.app.model
|
||||||
|
auth = self.app.get_auth_handler()
|
||||||
|
|
||||||
|
with patch.multiple(master.MasterView, create=True,
|
||||||
|
model_name='Setting'):
|
||||||
|
view = self.make_view()
|
||||||
|
|
||||||
|
# anonymous user
|
||||||
|
self.assertFalse(view.has_any_perm('list', 'view'))
|
||||||
|
self.assertFalse(self.request.has_any_perm('settings.list', 'settings.view'))
|
||||||
|
|
||||||
|
# reset
|
||||||
|
del self.request.user_permissions
|
||||||
|
|
||||||
|
# make user with perms
|
||||||
|
barney = model.User(username='barney')
|
||||||
|
self.session.add(barney)
|
||||||
|
blokes = model.Role(name="Blokes")
|
||||||
|
self.session.add(blokes)
|
||||||
|
barney.roles.append(blokes)
|
||||||
|
auth.grant_permission(blokes, 'settings.view')
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
# this user has perms
|
||||||
|
self.request.user = barney
|
||||||
|
self.assertTrue(view.has_any_perm('list', 'view'))
|
||||||
|
self.assertTrue(self.request.has_any_perm('settings.list', 'settings.view'))
|
||||||
|
|
||||||
def test_render_to_response(self):
|
def test_render_to_response(self):
|
||||||
|
self.pyramid_config.include('wuttaweb.views.common')
|
||||||
|
self.pyramid_config.include('wuttaweb.views.auth')
|
||||||
|
self.pyramid_config.add_route('appinfo', '/appinfo/')
|
||||||
|
|
||||||
def widgets(request): return {}
|
def widgets(request): return {}
|
||||||
self.pyramid_config.add_route('widgets', '/widgets/')
|
self.pyramid_config.add_route('widgets', '/widgets/')
|
||||||
|
@ -364,10 +445,39 @@ class TestMasterView(WebTestCase):
|
||||||
grid = view.make_model_grid(session=self.session)
|
grid = view.make_model_grid(session=self.session)
|
||||||
self.assertIs(grid.model_class, model.Setting)
|
self.assertIs(grid.model_class, model.Setting)
|
||||||
|
|
||||||
|
# no actions by default
|
||||||
|
with patch.multiple(master.MasterView, create=True,
|
||||||
|
model_class=model.Setting):
|
||||||
|
grid = view.make_model_grid(session=self.session)
|
||||||
|
self.assertEqual(grid.actions, [])
|
||||||
|
|
||||||
|
# now let's test some more actions logic
|
||||||
|
with patch.multiple(master.MasterView, create=True,
|
||||||
|
model_class=model.Setting,
|
||||||
|
viewable=True,
|
||||||
|
editable=True,
|
||||||
|
deletable=True):
|
||||||
|
|
||||||
|
# should have 3 actions now, but for lack of perms
|
||||||
|
grid = view.make_model_grid(session=self.session)
|
||||||
|
self.assertEqual(len(grid.actions), 0)
|
||||||
|
|
||||||
|
# but root user has perms, so gets 3 actions
|
||||||
|
with patch.object(self.request, 'is_root', new=True):
|
||||||
|
grid = view.make_model_grid(session=self.session)
|
||||||
|
self.assertEqual(len(grid.actions), 3)
|
||||||
|
|
||||||
def test_get_grid_data(self):
|
def test_get_grid_data(self):
|
||||||
model = self.app.model
|
model = self.app.model
|
||||||
self.app.save_setting(self.session, 'foo', 'bar')
|
self.app.save_setting(self.session, 'foo', 'bar')
|
||||||
self.session.commit()
|
self.session.commit()
|
||||||
|
setting = self.session.query(model.Setting).one()
|
||||||
|
view = self.make_view()
|
||||||
|
|
||||||
|
# empty by default
|
||||||
|
self.assertFalse(hasattr(master.MasterView, 'model_class'))
|
||||||
|
data = view.get_grid_data(session=self.session)
|
||||||
|
self.assertEqual(data, [])
|
||||||
|
|
||||||
# basic logic with Setting model
|
# basic logic with Setting model
|
||||||
with patch.multiple(master.MasterView, create=True,
|
with patch.multiple(master.MasterView, create=True,
|
||||||
|
@ -375,16 +485,7 @@ class TestMasterView(WebTestCase):
|
||||||
view = master.MasterView(self.request)
|
view = master.MasterView(self.request)
|
||||||
data = view.get_grid_data(session=self.session)
|
data = view.get_grid_data(session=self.session)
|
||||||
self.assertEqual(len(data), 1)
|
self.assertEqual(len(data), 1)
|
||||||
self.assertEqual(data[0], {'name': 'foo', 'value': 'bar'})
|
self.assertIs(data[0], setting)
|
||||||
|
|
||||||
# error if model not known
|
|
||||||
view = master.MasterView(self.request)
|
|
||||||
self.assertFalse(hasattr(master.MasterView, 'model_class'))
|
|
||||||
def get_query(session=None):
|
|
||||||
session = session or self.session
|
|
||||||
return session.query(model.Setting)
|
|
||||||
with patch.object(view, 'get_query', new=get_query):
|
|
||||||
self.assertRaises(ValueError, view.get_grid_data, session=self.session)
|
|
||||||
|
|
||||||
def test_configure_grid(self):
|
def test_configure_grid(self):
|
||||||
model = self.app.model
|
model = self.app.model
|
||||||
|
@ -399,6 +500,28 @@ class TestMasterView(WebTestCase):
|
||||||
view.configure_grid(grid)
|
view.configure_grid(grid)
|
||||||
self.assertNotIn('uuid', grid.columns)
|
self.assertNotIn('uuid', grid.columns)
|
||||||
|
|
||||||
|
def test_grid_render_notes(self):
|
||||||
|
model = self.app.model
|
||||||
|
view = self.make_view()
|
||||||
|
|
||||||
|
# null
|
||||||
|
text = None
|
||||||
|
role = model.Role(name="Foo", notes=text)
|
||||||
|
value = view.grid_render_notes(role, 'notes', text)
|
||||||
|
self.assertIsNone(value)
|
||||||
|
|
||||||
|
# short string
|
||||||
|
text = "hello world"
|
||||||
|
role = model.Role(name="Foo", notes=text)
|
||||||
|
value = view.grid_render_notes(role, 'notes', text)
|
||||||
|
self.assertEqual(value, text)
|
||||||
|
|
||||||
|
# long string
|
||||||
|
text = "hello world " * 20
|
||||||
|
role = model.Role(name="Foo", notes=text)
|
||||||
|
value = view.grid_render_notes(role, 'notes', text)
|
||||||
|
self.assertIn('<span ', value)
|
||||||
|
|
||||||
def test_get_instance(self):
|
def test_get_instance(self):
|
||||||
model = self.app.model
|
model = self.app.model
|
||||||
self.app.save_setting(self.session, 'foo', 'bar')
|
self.app.save_setting(self.session, 'foo', 'bar')
|
||||||
|
@ -425,6 +548,57 @@ class TestMasterView(WebTestCase):
|
||||||
self.request.matchdict = {'name': 'blarg'}
|
self.request.matchdict = {'name': 'blarg'}
|
||||||
self.assertRaises(HTTPNotFound, view.get_instance, session=self.session)
|
self.assertRaises(HTTPNotFound, view.get_instance, session=self.session)
|
||||||
|
|
||||||
|
def test_get_action_url_view(self):
|
||||||
|
model = self.app.model
|
||||||
|
setting = model.Setting(name='foo', value='bar')
|
||||||
|
self.session.add(setting)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
with patch.multiple(master.MasterView, create=True,
|
||||||
|
model_class=model.Setting):
|
||||||
|
master.MasterView.defaults(self.pyramid_config)
|
||||||
|
view = self.make_view()
|
||||||
|
url = view.get_action_url_view(setting, 0)
|
||||||
|
self.assertEqual(url, self.request.route_url('settings.view', name='foo'))
|
||||||
|
|
||||||
|
def test_get_action_url_edit(self):
|
||||||
|
model = self.app.model
|
||||||
|
setting = model.Setting(name='foo', value='bar')
|
||||||
|
self.session.add(setting)
|
||||||
|
self.session.commit()
|
||||||
|
with patch.multiple(master.MasterView, create=True,
|
||||||
|
model_class=model.Setting):
|
||||||
|
master.MasterView.defaults(self.pyramid_config)
|
||||||
|
view = self.make_view()
|
||||||
|
|
||||||
|
# typical
|
||||||
|
url = view.get_action_url_edit(setting, 0)
|
||||||
|
self.assertEqual(url, self.request.route_url('settings.edit', name='foo'))
|
||||||
|
|
||||||
|
# but null if instance not editable
|
||||||
|
with patch.object(view, 'is_editable', return_value=False):
|
||||||
|
url = view.get_action_url_edit(setting, 0)
|
||||||
|
self.assertIsNone(url)
|
||||||
|
|
||||||
|
def test_get_action_url_delete(self):
|
||||||
|
model = self.app.model
|
||||||
|
setting = model.Setting(name='foo', value='bar')
|
||||||
|
self.session.add(setting)
|
||||||
|
self.session.commit()
|
||||||
|
with patch.multiple(master.MasterView, create=True,
|
||||||
|
model_class=model.Setting):
|
||||||
|
master.MasterView.defaults(self.pyramid_config)
|
||||||
|
view = self.make_view()
|
||||||
|
|
||||||
|
# typical
|
||||||
|
url = view.get_action_url_delete(setting, 0)
|
||||||
|
self.assertEqual(url, self.request.route_url('settings.delete', name='foo'))
|
||||||
|
|
||||||
|
# but null if instance not deletable
|
||||||
|
with patch.object(view, 'is_deletable', return_value=False):
|
||||||
|
url = view.get_action_url_delete(setting, 0)
|
||||||
|
self.assertIsNone(url)
|
||||||
|
|
||||||
def test_make_model_form(self):
|
def test_make_model_form(self):
|
||||||
model = self.app.model
|
model = self.app.model
|
||||||
|
|
||||||
|
@ -514,28 +688,38 @@ class TestMasterView(WebTestCase):
|
||||||
##############################
|
##############################
|
||||||
|
|
||||||
def test_index(self):
|
def test_index(self):
|
||||||
|
self.pyramid_config.include('wuttaweb.views.common')
|
||||||
|
self.pyramid_config.include('wuttaweb.views.auth')
|
||||||
|
self.pyramid_config.add_route('settings.create', '/settings/new')
|
||||||
|
self.pyramid_config.add_route('settings.view', '/settings/{name}')
|
||||||
|
self.pyramid_config.add_route('settings.edit', '/settings/{name}/edit')
|
||||||
|
self.pyramid_config.add_route('settings.delete', '/settings/{name}/delete')
|
||||||
|
|
||||||
# sanity/coverage check using /settings/
|
# sanity/coverage check using /settings/
|
||||||
master.MasterView.model_name = 'Setting'
|
with patch.multiple(master.MasterView, create=True,
|
||||||
master.MasterView.model_key = 'name'
|
model_name='Setting',
|
||||||
master.MasterView.grid_columns = ['name', 'value']
|
model_key='name',
|
||||||
|
get_index_url=MagicMock(return_value='/settings/'),
|
||||||
|
grid_columns=['name', 'value']):
|
||||||
view = master.MasterView(self.request)
|
view = master.MasterView(self.request)
|
||||||
response = view.index()
|
response = view.index()
|
||||||
|
|
||||||
# then again with data, to include view action url
|
# then again with data, to include view action url
|
||||||
data = [{'name': 'foo', 'value': 'bar'}]
|
data = [{'name': 'foo', 'value': 'bar'}]
|
||||||
with patch.object(view, 'get_grid_data', return_value=data):
|
with patch.object(view, 'get_grid_data', return_value=data):
|
||||||
response = view.index()
|
response = view.index()
|
||||||
del master.MasterView.model_name
|
|
||||||
del master.MasterView.model_key
|
|
||||||
del master.MasterView.grid_columns
|
|
||||||
|
|
||||||
def test_create(self):
|
def test_create(self):
|
||||||
|
self.pyramid_config.include('wuttaweb.views.common')
|
||||||
|
self.pyramid_config.include('wuttaweb.views.auth')
|
||||||
|
self.pyramid_config.add_route('settings.view', '/settings/{name}')
|
||||||
model = self.app.model
|
model = self.app.model
|
||||||
|
|
||||||
# sanity/coverage check using /settings/new
|
# sanity/coverage check using /settings/new
|
||||||
with patch.multiple(master.MasterView, create=True,
|
with patch.multiple(master.MasterView, create=True,
|
||||||
model_name='Setting',
|
model_name='Setting',
|
||||||
model_key='name',
|
model_key='name',
|
||||||
|
get_index_url=MagicMock(return_value='/settings/'),
|
||||||
form_fields=['name', 'value']):
|
form_fields=['name', 'value']):
|
||||||
view = master.MasterView(self.request)
|
view = master.MasterView(self.request)
|
||||||
|
|
||||||
|
@ -579,6 +763,11 @@ class TestMasterView(WebTestCase):
|
||||||
self.assertEqual(self.app.get_setting(self.session, 'foo.bar'), 'fraggle')
|
self.assertEqual(self.app.get_setting(self.session, 'foo.bar'), 'fraggle')
|
||||||
|
|
||||||
def test_view(self):
|
def test_view(self):
|
||||||
|
self.pyramid_config.include('wuttaweb.views.common')
|
||||||
|
self.pyramid_config.include('wuttaweb.views.auth')
|
||||||
|
self.pyramid_config.add_route('settings.create', '/settings/new')
|
||||||
|
self.pyramid_config.add_route('settings.edit', '/settings/{name}/edit')
|
||||||
|
self.pyramid_config.add_route('settings.delete', '/settings/{name}/delete')
|
||||||
|
|
||||||
# sanity/coverage check using /settings/XXX
|
# sanity/coverage check using /settings/XXX
|
||||||
setting = {'name': 'foo.bar', 'value': 'baz'}
|
setting = {'name': 'foo.bar', 'value': 'baz'}
|
||||||
|
@ -586,6 +775,7 @@ class TestMasterView(WebTestCase):
|
||||||
with patch.multiple(master.MasterView, create=True,
|
with patch.multiple(master.MasterView, create=True,
|
||||||
model_name='Setting',
|
model_name='Setting',
|
||||||
model_key='name',
|
model_key='name',
|
||||||
|
get_index_url=MagicMock(return_value='/settings/'),
|
||||||
grid_columns=['name', 'value'],
|
grid_columns=['name', 'value'],
|
||||||
form_fields=['name', 'value']):
|
form_fields=['name', 'value']):
|
||||||
view = master.MasterView(self.request)
|
view = master.MasterView(self.request)
|
||||||
|
@ -593,6 +783,11 @@ class TestMasterView(WebTestCase):
|
||||||
response = view.view()
|
response = view.view()
|
||||||
|
|
||||||
def test_edit(self):
|
def test_edit(self):
|
||||||
|
self.pyramid_config.include('wuttaweb.views.common')
|
||||||
|
self.pyramid_config.include('wuttaweb.views.auth')
|
||||||
|
self.pyramid_config.add_route('settings.create', '/settings/new')
|
||||||
|
self.pyramid_config.add_route('settings.view', '/settings/{name}')
|
||||||
|
self.pyramid_config.add_route('settings.delete', '/settings/{name}/delete')
|
||||||
model = self.app.model
|
model = self.app.model
|
||||||
self.app.save_setting(self.session, 'foo.bar', 'frazzle')
|
self.app.save_setting(self.session, 'foo.bar', 'frazzle')
|
||||||
self.session.commit()
|
self.session.commit()
|
||||||
|
@ -609,6 +804,7 @@ class TestMasterView(WebTestCase):
|
||||||
with patch.multiple(master.MasterView, create=True,
|
with patch.multiple(master.MasterView, create=True,
|
||||||
model_name='Setting',
|
model_name='Setting',
|
||||||
model_key='name',
|
model_key='name',
|
||||||
|
get_index_url=MagicMock(return_value='/settings/'),
|
||||||
form_fields=['name', 'value']):
|
form_fields=['name', 'value']):
|
||||||
view = master.MasterView(self.request)
|
view = master.MasterView(self.request)
|
||||||
with patch.object(view, 'get_instance', new=get_instance):
|
with patch.object(view, 'get_instance', new=get_instance):
|
||||||
|
@ -650,6 +846,11 @@ class TestMasterView(WebTestCase):
|
||||||
self.assertEqual(self.app.get_setting(self.session, 'foo.bar'), 'froogle')
|
self.assertEqual(self.app.get_setting(self.session, 'foo.bar'), 'froogle')
|
||||||
|
|
||||||
def test_delete(self):
|
def test_delete(self):
|
||||||
|
self.pyramid_config.include('wuttaweb.views.common')
|
||||||
|
self.pyramid_config.include('wuttaweb.views.auth')
|
||||||
|
self.pyramid_config.add_route('settings.create', '/settings/new')
|
||||||
|
self.pyramid_config.add_route('settings.view', '/settings/{name}')
|
||||||
|
self.pyramid_config.add_route('settings.edit', '/settings/{name}/edit')
|
||||||
model = self.app.model
|
model = self.app.model
|
||||||
self.app.save_setting(self.session, 'foo.bar', 'frazzle')
|
self.app.save_setting(self.session, 'foo.bar', 'frazzle')
|
||||||
self.session.commit()
|
self.session.commit()
|
||||||
|
@ -667,6 +868,7 @@ class TestMasterView(WebTestCase):
|
||||||
with patch.multiple(master.MasterView, create=True,
|
with patch.multiple(master.MasterView, create=True,
|
||||||
model_name='Setting',
|
model_name='Setting',
|
||||||
model_key='name',
|
model_key='name',
|
||||||
|
get_index_url=MagicMock(return_value='/settings/'),
|
||||||
form_fields=['name', 'value']):
|
form_fields=['name', 'value']):
|
||||||
view = master.MasterView(self.request)
|
view = master.MasterView(self.request)
|
||||||
with patch.object(view, 'get_instance', new=get_instance):
|
with patch.object(view, 'get_instance', new=get_instance):
|
||||||
|
@ -680,10 +882,19 @@ class TestMasterView(WebTestCase):
|
||||||
def delete_instance(setting):
|
def delete_instance(setting):
|
||||||
self.app.delete_setting(self.session, setting['name'])
|
self.app.delete_setting(self.session, setting['name'])
|
||||||
|
|
||||||
# post request to save settings
|
|
||||||
self.request.method = 'POST'
|
self.request.method = 'POST'
|
||||||
self.request.POST = {}
|
self.request.POST = {}
|
||||||
with patch.object(view, 'delete_instance', new=delete_instance):
|
with patch.object(view, 'delete_instance', new=delete_instance):
|
||||||
|
|
||||||
|
# enforces "instance not deletable" rules
|
||||||
|
with patch.object(view, 'is_deletable', return_value=False):
|
||||||
|
response = view.delete()
|
||||||
|
# nb. should get redirect back to view page
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
# setting remains in DB
|
||||||
|
self.assertEqual(self.session.query(model.Setting).count(), 1)
|
||||||
|
|
||||||
|
# post request to delete setting
|
||||||
response = view.delete()
|
response = view.delete()
|
||||||
# nb. should get redirect back to view page
|
# nb. should get redirect back to view page
|
||||||
self.assertEqual(response.status_code, 302)
|
self.assertEqual(response.status_code, 302)
|
||||||
|
@ -705,6 +916,8 @@ class TestMasterView(WebTestCase):
|
||||||
self.assertEqual(self.session.query(model.Setting).count(), 0)
|
self.assertEqual(self.session.query(model.Setting).count(), 0)
|
||||||
|
|
||||||
def test_configure(self):
|
def test_configure(self):
|
||||||
|
self.pyramid_config.include('wuttaweb.views.common')
|
||||||
|
self.pyramid_config.include('wuttaweb.views.auth')
|
||||||
model = self.app.model
|
model = self.app.model
|
||||||
|
|
||||||
# mock settings
|
# mock settings
|
||||||
|
@ -725,10 +938,11 @@ class TestMasterView(WebTestCase):
|
||||||
route_prefix='appinfo',
|
route_prefix='appinfo',
|
||||||
template_prefix='/appinfo',
|
template_prefix='/appinfo',
|
||||||
creatable=False,
|
creatable=False,
|
||||||
|
get_index_url=MagicMock(return_value='/appinfo/'),
|
||||||
configure_get_simple_settings=MagicMock(return_value=settings)):
|
configure_get_simple_settings=MagicMock(return_value=settings)):
|
||||||
|
|
||||||
# get the form page
|
# get the form page
|
||||||
response = view.configure()
|
response = view.configure(session=self.session)
|
||||||
self.assertIsInstance(response, Response)
|
self.assertIsInstance(response, Response)
|
||||||
|
|
||||||
# post request to save settings
|
# post request to save settings
|
||||||
|
@ -738,9 +952,9 @@ class TestMasterView(WebTestCase):
|
||||||
'wutta.foo': 'bar',
|
'wutta.foo': 'bar',
|
||||||
'wutta.flag': 'true',
|
'wutta.flag': 'true',
|
||||||
}
|
}
|
||||||
response = view.configure()
|
response = view.configure(session=self.session)
|
||||||
# nb. should get redirect back to configure page
|
# nb. should get redirect back to configure page
|
||||||
self.assertIsInstance(response, HTTPFound)
|
self.assertEqual(response.status_code, 302)
|
||||||
|
|
||||||
# should now have 5 settings
|
# should now have 5 settings
|
||||||
count = self.session.query(model.Setting).count()
|
count = self.session.query(model.Setting).count()
|
||||||
|
@ -756,9 +970,9 @@ class TestMasterView(WebTestCase):
|
||||||
# post request to remove settings
|
# post request to remove settings
|
||||||
self.request.method = 'POST'
|
self.request.method = 'POST'
|
||||||
self.request.POST = {'remove_settings': '1'}
|
self.request.POST = {'remove_settings': '1'}
|
||||||
response = view.configure()
|
response = view.configure(session=self.session)
|
||||||
# nb. should get redirect back to configure page
|
# nb. should get redirect back to configure page
|
||||||
self.assertIsInstance(response, HTTPFound)
|
self.assertEqual(response.status_code, 302)
|
||||||
|
|
||||||
# should now have 0 settings
|
# should now have 0 settings
|
||||||
count = self.session.query(model.Setting).count()
|
count = self.session.query(model.Setting).count()
|
||||||
|
|
|
@ -15,6 +15,9 @@ class TestPersonView(WebTestCase):
|
||||||
def make_view(self):
|
def make_view(self):
|
||||||
return people.PersonView(self.request)
|
return people.PersonView(self.request)
|
||||||
|
|
||||||
|
def test_includeme(self):
|
||||||
|
self.pyramid_config.include('wuttaweb.views.people')
|
||||||
|
|
||||||
def test_get_query(self):
|
def test_get_query(self):
|
||||||
view = self.make_view()
|
view = self.make_view()
|
||||||
query = view.get_query(session=self.session)
|
query = view.get_query(session=self.session)
|
||||||
|
@ -37,3 +40,34 @@ class TestPersonView(WebTestCase):
|
||||||
view.configure_form(form)
|
view.configure_form(form)
|
||||||
self.assertTrue(form.required_fields)
|
self.assertTrue(form.required_fields)
|
||||||
self.assertFalse(form.required_fields['middle_name'])
|
self.assertFalse(form.required_fields['middle_name'])
|
||||||
|
|
||||||
|
def test_view_profile(self):
|
||||||
|
self.pyramid_config.include('wuttaweb.views.common')
|
||||||
|
self.pyramid_config.include('wuttaweb.views.auth')
|
||||||
|
self.pyramid_config.add_route('people', '/people/')
|
||||||
|
|
||||||
|
model = self.app.model
|
||||||
|
person = model.Person(full_name="Barney Rubble")
|
||||||
|
self.session.add(person)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
# sanity check
|
||||||
|
view = self.make_view()
|
||||||
|
self.request.matchdict = {'uuid': person.uuid}
|
||||||
|
response = view.view_profile(session=self.session)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_make_user(self):
|
||||||
|
self.pyramid_config.include('wuttaweb.views.common')
|
||||||
|
|
||||||
|
model = self.app.model
|
||||||
|
person = model.Person(full_name="Barney Rubble")
|
||||||
|
self.session.add(person)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
# sanity check
|
||||||
|
view = self.make_view()
|
||||||
|
self.request.matchdict = {'uuid': person.uuid}
|
||||||
|
response = view.make_user()
|
||||||
|
# nb. this always redirects for now
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
|
|
@ -15,6 +15,9 @@ class TestRoleView(WebTestCase):
|
||||||
def make_view(self):
|
def make_view(self):
|
||||||
return mod.RoleView(self.request)
|
return mod.RoleView(self.request)
|
||||||
|
|
||||||
|
def test_includeme(self):
|
||||||
|
self.pyramid_config.include('wuttaweb.views.roles')
|
||||||
|
|
||||||
def test_get_query(self):
|
def test_get_query(self):
|
||||||
view = self.make_view()
|
view = self.make_view()
|
||||||
query = view.get_query(session=self.session)
|
query = view.get_query(session=self.session)
|
||||||
|
@ -28,10 +31,63 @@ class TestRoleView(WebTestCase):
|
||||||
view.configure_grid(grid)
|
view.configure_grid(grid)
|
||||||
self.assertTrue(grid.is_linked('name'))
|
self.assertTrue(grid.is_linked('name'))
|
||||||
|
|
||||||
|
def test_is_editable(self):
|
||||||
|
model = self.app.model
|
||||||
|
auth = self.app.get_auth_handler()
|
||||||
|
blokes = model.Role(name="Blokes")
|
||||||
|
self.session.add(blokes)
|
||||||
|
self.session.commit()
|
||||||
|
view = self.make_view()
|
||||||
|
|
||||||
|
admin = auth.get_role_administrator(self.session)
|
||||||
|
authed = auth.get_role_authenticated(self.session)
|
||||||
|
anon = auth.get_role_anonymous(self.session)
|
||||||
|
|
||||||
|
# editable by default
|
||||||
|
self.assertTrue(view.is_editable(blokes))
|
||||||
|
|
||||||
|
# built-in roles not editable by default
|
||||||
|
self.assertFalse(view.is_editable(admin))
|
||||||
|
self.assertFalse(view.is_editable(authed))
|
||||||
|
self.assertFalse(view.is_editable(anon))
|
||||||
|
|
||||||
|
# reset
|
||||||
|
del self.request.user_permissions
|
||||||
|
|
||||||
|
barney = model.User(username='barney')
|
||||||
|
self.session.add(barney)
|
||||||
|
barney.roles.append(blokes)
|
||||||
|
auth.grant_permission(blokes, 'roles.edit_builtin')
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
# user with perms can edit *some* built-in
|
||||||
|
self.request.user = barney
|
||||||
|
self.assertTrue(view.is_editable(authed))
|
||||||
|
self.assertTrue(view.is_editable(anon))
|
||||||
|
# nb. not this one yet
|
||||||
|
self.assertFalse(view.is_editable(admin))
|
||||||
|
|
||||||
|
def test_is_deletable(self):
|
||||||
|
model = self.app.model
|
||||||
|
auth = self.app.get_auth_handler()
|
||||||
|
blokes = model.Role(name="Blokes")
|
||||||
|
self.session.add(blokes)
|
||||||
|
self.session.commit()
|
||||||
|
view = self.make_view()
|
||||||
|
|
||||||
|
# deletable by default
|
||||||
|
self.assertTrue(view.is_deletable(blokes))
|
||||||
|
|
||||||
|
# built-in roles not deletable
|
||||||
|
self.assertFalse(view.is_deletable(auth.get_role_administrator(self.session)))
|
||||||
|
self.assertFalse(view.is_deletable(auth.get_role_authenticated(self.session)))
|
||||||
|
self.assertFalse(view.is_deletable(auth.get_role_anonymous(self.session)))
|
||||||
|
|
||||||
def test_configure_form(self):
|
def test_configure_form(self):
|
||||||
model = self.app.model
|
model = self.app.model
|
||||||
|
role = model.Role(name="Foo")
|
||||||
view = self.make_view()
|
view = self.make_view()
|
||||||
form = view.make_form(model_class=model.Person)
|
form = view.make_form(model_instance=role)
|
||||||
self.assertNotIn('name', form.validators)
|
self.assertNotIn('name', form.validators)
|
||||||
view.configure_form(form)
|
view.configure_form(form)
|
||||||
self.assertIsNotNone(form.validators['name'])
|
self.assertIsNotNone(form.validators['name'])
|
||||||
|
@ -55,3 +111,132 @@ class TestRoleView(WebTestCase):
|
||||||
self.request.matchdict = {'uuid': role.uuid}
|
self.request.matchdict = {'uuid': role.uuid}
|
||||||
node = colander.SchemaNode(colander.String(), name='name')
|
node = colander.SchemaNode(colander.String(), name='name')
|
||||||
self.assertIsNone(view.unique_name(node, 'Foo'))
|
self.assertIsNone(view.unique_name(node, 'Foo'))
|
||||||
|
|
||||||
|
def get_permissions(self):
|
||||||
|
return {
|
||||||
|
'widgets': {
|
||||||
|
'label': "Widgets",
|
||||||
|
'perms': {
|
||||||
|
'widgets.list': {
|
||||||
|
'label': "List widgets",
|
||||||
|
},
|
||||||
|
'widgets.polish': {
|
||||||
|
'label': "Polish the widgets",
|
||||||
|
},
|
||||||
|
'widgets.view': {
|
||||||
|
'label': "View widget",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_get_available_permissions(self):
|
||||||
|
model = self.app.model
|
||||||
|
auth = self.app.get_auth_handler()
|
||||||
|
blokes = model.Role(name="Blokes")
|
||||||
|
auth.grant_permission(blokes, 'widgets.list')
|
||||||
|
self.session.add(blokes)
|
||||||
|
barney = model.User(username='barney')
|
||||||
|
barney.roles.append(blokes)
|
||||||
|
self.session.add(barney)
|
||||||
|
self.session.commit()
|
||||||
|
view = self.make_view()
|
||||||
|
all_perms = self.get_permissions()
|
||||||
|
self.request.registry.settings['wutta_permissions'] = all_perms
|
||||||
|
|
||||||
|
def has_perm(perm):
|
||||||
|
if perm == 'widgets.list':
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
with patch.object(self.request, 'has_perm', new=has_perm, create=True):
|
||||||
|
|
||||||
|
# sanity check; current request has 1 perm
|
||||||
|
self.assertTrue(self.request.has_perm('widgets.list'))
|
||||||
|
self.assertFalse(self.request.has_perm('widgets.polish'))
|
||||||
|
self.assertFalse(self.request.has_perm('widgets.view'))
|
||||||
|
|
||||||
|
# when editing, user sees only the 1 perm
|
||||||
|
with patch.object(view, 'editing', new=True):
|
||||||
|
perms = view.get_available_permissions()
|
||||||
|
self.assertEqual(list(perms), ['widgets'])
|
||||||
|
self.assertEqual(list(perms['widgets']['perms']), ['widgets.list'])
|
||||||
|
|
||||||
|
# but when viewing, same user sees all perms
|
||||||
|
with patch.object(view, 'viewing', new=True):
|
||||||
|
perms = view.get_available_permissions()
|
||||||
|
self.assertEqual(list(perms), ['widgets'])
|
||||||
|
self.assertEqual(list(perms['widgets']['perms']),
|
||||||
|
['widgets.list', 'widgets.polish', 'widgets.view'])
|
||||||
|
|
||||||
|
# also, when admin user is editing, sees all perms
|
||||||
|
self.request.is_admin = True
|
||||||
|
with patch.object(view, 'editing', new=True):
|
||||||
|
perms = view.get_available_permissions()
|
||||||
|
self.assertEqual(list(perms), ['widgets'])
|
||||||
|
self.assertEqual(list(perms['widgets']['perms']),
|
||||||
|
['widgets.list', 'widgets.polish', 'widgets.view'])
|
||||||
|
|
||||||
|
def test_objectify(self):
|
||||||
|
model = self.app.model
|
||||||
|
auth = self.app.get_auth_handler()
|
||||||
|
blokes = model.Role(name="Blokes")
|
||||||
|
self.session.add(blokes)
|
||||||
|
barney = model.User(username='barney')
|
||||||
|
barney.roles.append(blokes)
|
||||||
|
self.session.add(barney)
|
||||||
|
self.session.commit()
|
||||||
|
view = self.make_view()
|
||||||
|
permissions = self.get_permissions()
|
||||||
|
|
||||||
|
# sanity check, role has just 1 perm
|
||||||
|
auth.grant_permission(blokes, 'widgets.list')
|
||||||
|
self.session.commit()
|
||||||
|
self.assertEqual(blokes.permissions, ['widgets.list'])
|
||||||
|
|
||||||
|
# form can update role perms
|
||||||
|
view.editing = True
|
||||||
|
self.request.matchdict = {'uuid': blokes.uuid}
|
||||||
|
with patch.object(view, 'get_available_permissions', return_value=permissions):
|
||||||
|
form = view.make_model_form(model_instance=blokes)
|
||||||
|
form.validated = {'name': 'Blokes',
|
||||||
|
'permissions': {'widgets.list', 'widgets.polish', 'widgets.view'}}
|
||||||
|
role = view.objectify(form)
|
||||||
|
self.session.commit()
|
||||||
|
self.assertIs(role, blokes)
|
||||||
|
self.assertEqual(blokes.permissions, ['widgets.list', 'widgets.polish', 'widgets.view'])
|
||||||
|
|
||||||
|
def test_update_permissions(self):
|
||||||
|
model = self.app.model
|
||||||
|
auth = self.app.get_auth_handler()
|
||||||
|
blokes = model.Role(name="Blokes")
|
||||||
|
auth.grant_permission(blokes, 'widgets.list')
|
||||||
|
self.session.add(blokes)
|
||||||
|
barney = model.User(username='barney')
|
||||||
|
barney.roles.append(blokes)
|
||||||
|
self.session.add(barney)
|
||||||
|
self.session.commit()
|
||||||
|
view = self.make_view()
|
||||||
|
permissions = self.get_permissions()
|
||||||
|
|
||||||
|
with patch.object(view, 'get_available_permissions', return_value=permissions):
|
||||||
|
|
||||||
|
# no error if data is missing perms
|
||||||
|
form = view.make_model_form(model_instance=blokes)
|
||||||
|
form.validated = {'name': 'BloX'}
|
||||||
|
role = view.objectify(form)
|
||||||
|
self.session.commit()
|
||||||
|
self.assertIs(role, blokes)
|
||||||
|
self.assertEqual(blokes.name, 'BloX')
|
||||||
|
|
||||||
|
# sanity check, role has just 1 perm
|
||||||
|
self.assertEqual(blokes.permissions, ['widgets.list'])
|
||||||
|
|
||||||
|
# role perms are updated
|
||||||
|
form = view.make_model_form(model_instance=blokes)
|
||||||
|
form.validated = {'name': 'Blokes',
|
||||||
|
'permissions': {'widgets.polish', 'widgets.view'}}
|
||||||
|
role = view.objectify(form)
|
||||||
|
self.session.commit()
|
||||||
|
self.assertIs(role, blokes)
|
||||||
|
self.assertEqual(blokes.permissions, ['widgets.polish', 'widgets.view'])
|
||||||
|
|
|
@ -10,6 +10,10 @@ from tests.util import WebTestCase
|
||||||
|
|
||||||
class TestAppInfoView(WebTestCase):
|
class TestAppInfoView(WebTestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.setup_web()
|
||||||
|
self.pyramid_config.include('wuttaweb.views.essential')
|
||||||
|
|
||||||
def make_view(self):
|
def make_view(self):
|
||||||
return settings.AppInfoView(self.request)
|
return settings.AppInfoView(self.request)
|
||||||
|
|
||||||
|
|
|
@ -15,6 +15,9 @@ class TestUserView(WebTestCase):
|
||||||
def make_view(self):
|
def make_view(self):
|
||||||
return mod.UserView(self.request)
|
return mod.UserView(self.request)
|
||||||
|
|
||||||
|
def test_includeme(self):
|
||||||
|
self.pyramid_config.include('wuttaweb.views.users')
|
||||||
|
|
||||||
def test_get_query(self):
|
def test_get_query(self):
|
||||||
view = self.make_view()
|
view = self.make_view()
|
||||||
query = view.get_query(session=self.session)
|
query = view.get_query(session=self.session)
|
||||||
|
@ -30,12 +33,30 @@ class TestUserView(WebTestCase):
|
||||||
|
|
||||||
def test_configure_form(self):
|
def test_configure_form(self):
|
||||||
model = self.app.model
|
model = self.app.model
|
||||||
|
barney = model.User(username='barney')
|
||||||
|
self.session.add(barney)
|
||||||
|
self.session.commit()
|
||||||
view = self.make_view()
|
view = self.make_view()
|
||||||
form = view.make_form(model_class=model.Person)
|
|
||||||
|
# person is *not* required
|
||||||
|
with patch.object(view, 'creating', new=True):
|
||||||
|
form = view.make_form(model_class=model.User)
|
||||||
self.assertIsNone(form.is_required('person'))
|
self.assertIsNone(form.is_required('person'))
|
||||||
view.configure_form(form)
|
view.configure_form(form)
|
||||||
self.assertFalse(form.is_required('person'))
|
self.assertFalse(form.is_required('person'))
|
||||||
|
|
||||||
|
# password removed (always, for now)
|
||||||
|
with patch.object(view, 'viewing', new=True):
|
||||||
|
form = view.make_form(model_instance=barney)
|
||||||
|
self.assertIn('password', form)
|
||||||
|
view.configure_form(form)
|
||||||
|
self.assertNotIn('password', form)
|
||||||
|
with patch.object(view, 'editing', new=True):
|
||||||
|
form = view.make_form(model_instance=barney)
|
||||||
|
self.assertIn('password', form)
|
||||||
|
view.configure_form(form)
|
||||||
|
self.assertNotIn('password', form)
|
||||||
|
|
||||||
def test_unique_username(self):
|
def test_unique_username(self):
|
||||||
model = self.app.model
|
model = self.app.model
|
||||||
view = self.make_view()
|
view = self.make_view()
|
||||||
|
@ -55,3 +76,113 @@ class TestUserView(WebTestCase):
|
||||||
self.request.matchdict = {'uuid': user.uuid}
|
self.request.matchdict = {'uuid': user.uuid}
|
||||||
node = colander.SchemaNode(colander.String(), name='username')
|
node = colander.SchemaNode(colander.String(), name='username')
|
||||||
self.assertIsNone(view.unique_username(node, 'foo'))
|
self.assertIsNone(view.unique_username(node, 'foo'))
|
||||||
|
|
||||||
|
def test_objectify(self):
|
||||||
|
model = self.app.model
|
||||||
|
auth = self.app.get_auth_handler()
|
||||||
|
blokes = model.Role(name="Blokes")
|
||||||
|
self.session.add(blokes)
|
||||||
|
others = model.Role(name="Others")
|
||||||
|
self.session.add(others)
|
||||||
|
barney = model.User(username='barney')
|
||||||
|
auth.set_user_password(barney, 'testpass')
|
||||||
|
barney.roles.append(blokes)
|
||||||
|
self.session.add(barney)
|
||||||
|
self.session.commit()
|
||||||
|
view = self.make_view()
|
||||||
|
view.editing = True
|
||||||
|
self.request.matchdict = {'uuid': barney.uuid}
|
||||||
|
|
||||||
|
# sanity check, user is just in 'blokes' role
|
||||||
|
self.session.refresh(barney)
|
||||||
|
self.assertEqual(len(barney.roles), 1)
|
||||||
|
self.assertEqual(barney.roles[0].name, "Blokes")
|
||||||
|
|
||||||
|
# form can update user password
|
||||||
|
self.assertTrue(auth.check_user_password(barney, 'testpass'))
|
||||||
|
form = view.make_model_form(model_instance=barney)
|
||||||
|
form.validated = {'username': 'barney', 'set_password': 'testpass2'}
|
||||||
|
user = view.objectify(form, session=self.session)
|
||||||
|
self.assertIs(user, barney)
|
||||||
|
self.assertTrue(auth.check_user_password(barney, 'testpass2'))
|
||||||
|
|
||||||
|
# form can update user roles
|
||||||
|
form = view.make_model_form(model_instance=barney)
|
||||||
|
form.validated = {'username': 'barney', 'roles': {others.uuid}}
|
||||||
|
user = view.objectify(form, session=self.session)
|
||||||
|
self.assertIs(user, barney)
|
||||||
|
self.assertEqual(len(user.roles), 1)
|
||||||
|
self.assertEqual(user.roles[0].name, "Others")
|
||||||
|
|
||||||
|
def test_update_roles(self):
|
||||||
|
model = self.app.model
|
||||||
|
auth = self.app.get_auth_handler()
|
||||||
|
admin = auth.get_role_administrator(self.session)
|
||||||
|
authed = auth.get_role_authenticated(self.session)
|
||||||
|
anon = auth.get_role_anonymous(self.session)
|
||||||
|
blokes = model.Role(name="Blokes")
|
||||||
|
self.session.add(blokes)
|
||||||
|
others = model.Role(name="Others")
|
||||||
|
self.session.add(others)
|
||||||
|
barney = model.User(username='barney')
|
||||||
|
barney.roles.append(blokes)
|
||||||
|
self.session.add(barney)
|
||||||
|
self.session.commit()
|
||||||
|
view = self.make_view()
|
||||||
|
view.editing = True
|
||||||
|
self.request.matchdict = {'uuid': barney.uuid}
|
||||||
|
|
||||||
|
# no error if data is missing roles
|
||||||
|
form = view.make_model_form(model_instance=barney)
|
||||||
|
form.validated = {'username': 'barneyx'}
|
||||||
|
user = view.objectify(form, session=self.session)
|
||||||
|
self.assertIs(user, barney)
|
||||||
|
self.assertEqual(barney.username, 'barneyx')
|
||||||
|
|
||||||
|
# sanity check, user is just in 'blokes' role
|
||||||
|
self.session.refresh(barney)
|
||||||
|
self.assertEqual(len(barney.roles), 1)
|
||||||
|
self.assertEqual(barney.roles[0].name, "Blokes")
|
||||||
|
|
||||||
|
# let's test a bunch at once to ensure:
|
||||||
|
# - user roles are updated
|
||||||
|
# - authed / anon roles are not added
|
||||||
|
# - admin role not added if current user is not root
|
||||||
|
form = view.make_model_form(model_instance=barney)
|
||||||
|
form.validated = {'username': 'barney',
|
||||||
|
'roles': {admin.uuid, authed.uuid, anon.uuid, others.uuid}}
|
||||||
|
user = view.objectify(form, session=self.session)
|
||||||
|
self.assertIs(user, barney)
|
||||||
|
self.assertEqual(len(user.roles), 1)
|
||||||
|
self.assertEqual(user.roles[0].name, "Others")
|
||||||
|
|
||||||
|
# let's test a bunch at once to ensure:
|
||||||
|
# - user roles are updated
|
||||||
|
# - admin role is added if current user is root
|
||||||
|
self.request.is_root = True
|
||||||
|
form = view.make_model_form(model_instance=barney)
|
||||||
|
form.validated = {'username': 'barney',
|
||||||
|
'roles': {admin.uuid, blokes.uuid, others.uuid}}
|
||||||
|
user = view.objectify(form, session=self.session)
|
||||||
|
self.assertIs(user, barney)
|
||||||
|
self.assertEqual(len(user.roles), 3)
|
||||||
|
role_uuids = set([role.uuid for role in user.roles])
|
||||||
|
self.assertEqual(role_uuids, {admin.uuid, blokes.uuid, others.uuid})
|
||||||
|
|
||||||
|
# admin role not removed if current user is not root
|
||||||
|
self.request.is_root = False
|
||||||
|
form = view.make_model_form(model_instance=barney)
|
||||||
|
form.validated = {'username': 'barney',
|
||||||
|
'roles': {blokes.uuid, others.uuid}}
|
||||||
|
user = view.objectify(form, session=self.session)
|
||||||
|
self.assertIs(user, barney)
|
||||||
|
self.assertEqual(len(user.roles), 3)
|
||||||
|
|
||||||
|
# admin role is removed if current user is root
|
||||||
|
self.request.is_root = True
|
||||||
|
form = view.make_model_form(model_instance=barney)
|
||||||
|
form.validated = {'username': 'barney',
|
||||||
|
'roles': {blokes.uuid, others.uuid}}
|
||||||
|
user = view.objectify(form, session=self.session)
|
||||||
|
self.assertIs(user, barney)
|
||||||
|
self.assertEqual(len(user.roles), 2)
|
||||||
|
|
Loading…
Reference in a new issue