Convert User pages to use master view.
And of course make some more tweaks to new grids etc.
This commit is contained in:
parent
9cfbc918e7
commit
af07f477dc
|
@ -29,6 +29,8 @@ from __future__ import unicode_literals
|
||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
import edbob
|
import edbob
|
||||||
from edbob.pyramid.forms.formalchemy import TemplateEngine
|
from edbob.pyramid.forms.formalchemy import TemplateEngine
|
||||||
|
|
||||||
|
@ -131,6 +133,7 @@ def make_pyramid_config(settings):
|
||||||
|
|
||||||
# Configure FormAlchemy.
|
# Configure FormAlchemy.
|
||||||
formalchemy.config.engine = TemplateEngine()
|
formalchemy.config.engine = TemplateEngine()
|
||||||
|
formalchemy.FieldSet.default_renderers[sa.Boolean] = renderers.YesNoFieldRenderer
|
||||||
formalchemy.FieldSet.default_renderers[GPCType] = renderers.GPCFieldRenderer
|
formalchemy.FieldSet.default_renderers[GPCType] = renderers.GPCFieldRenderer
|
||||||
|
|
||||||
return config
|
return config
|
||||||
|
|
|
@ -78,26 +78,22 @@ class AlchemyGrid(Grid):
|
||||||
filtrs = filters.GridFilterSet()
|
filtrs = filters.GridFilterSet()
|
||||||
mapper = orm.class_mapper(self.model_class)
|
mapper = orm.class_mapper(self.model_class)
|
||||||
for prop in mapper.iterate_properties:
|
for prop in mapper.iterate_properties:
|
||||||
if isinstance(prop, orm.ColumnProperty) and prop.key != 'uuid':
|
if isinstance(prop, orm.ColumnProperty) and not prop.key.endswith('uuid'):
|
||||||
filtrs[prop.key] = self.make_filter(prop)
|
filtrs[prop.key] = self.make_filter(prop.key, prop.columns[0])
|
||||||
return filtrs
|
return filtrs
|
||||||
|
|
||||||
def make_filter(self, model_property, **kwargs):
|
def make_filter(self, key, column, **kwargs):
|
||||||
"""
|
"""
|
||||||
Make a filter suitable for use with the given model property.
|
Make a filter suitable for use with the given column.
|
||||||
"""
|
"""
|
||||||
if len(model_property.columns) > 1:
|
|
||||||
log.debug("ignoring secondary columns for sake of type detection")
|
|
||||||
coltype = model_property.columns[0].type
|
|
||||||
|
|
||||||
factory = filters.AlchemyGridFilter
|
factory = filters.AlchemyGridFilter
|
||||||
if isinstance(coltype, sa.String):
|
if isinstance(column.type, sa.String):
|
||||||
factory = filters.AlchemyStringFilter
|
factory = filters.AlchemyStringFilter
|
||||||
elif isinstance(coltype, sa.Numeric):
|
elif isinstance(column.type, sa.Numeric):
|
||||||
factory = filters.AlchemyNumericFilter
|
factory = filters.AlchemyNumericFilter
|
||||||
|
elif isinstance(column.type, sa.Boolean):
|
||||||
kwargs['model_property'] = model_property
|
factory = filters.AlchemyBooleanFilter
|
||||||
return factory(model_property.key, **kwargs)
|
return factory(key, column=column, **kwargs)
|
||||||
|
|
||||||
def iter_filters(self):
|
def iter_filters(self):
|
||||||
"""
|
"""
|
||||||
|
@ -112,19 +108,20 @@ class AlchemyGrid(Grid):
|
||||||
"""
|
"""
|
||||||
sorters, updates = {}, sorters
|
sorters, updates = {}, sorters
|
||||||
mapper = orm.class_mapper(self.model_class)
|
mapper = orm.class_mapper(self.model_class)
|
||||||
for key, column in mapper.columns.items():
|
for prop in mapper.iterate_properties:
|
||||||
if key != 'uuid':
|
if isinstance(prop, orm.ColumnProperty) and not prop.key.endswith('uuid'):
|
||||||
sorters[key] = self.make_sorter(column)
|
sorters[prop.key] = self.make_sorter(prop)
|
||||||
if updates:
|
if updates:
|
||||||
sorters.update(updates)
|
sorters.update(updates)
|
||||||
return sorters
|
return sorters
|
||||||
|
|
||||||
def make_sorter(self, field):
|
def make_sorter(self, model_property):
|
||||||
"""
|
"""
|
||||||
Returns a function suitable for a sort map callable, with typical logic
|
Returns a function suitable for a sort map callable, with typical logic
|
||||||
built in for sorting applied to ``field``.
|
built in for sorting applied to ``field``.
|
||||||
"""
|
"""
|
||||||
return lambda q, d: q.order_by(getattr(field, d)())
|
column = getattr(self.model_class, model_property.key)
|
||||||
|
return lambda q, d: q.order_by(getattr(column, d)())
|
||||||
|
|
||||||
def load_settings(self):
|
def load_settings(self):
|
||||||
"""
|
"""
|
||||||
|
@ -136,23 +133,6 @@ class AlchemyGrid(Grid):
|
||||||
self._fa_grid.rebind(self.make_visible_data(), session=Session(),
|
self._fa_grid.rebind(self.make_visible_data(), session=Session(),
|
||||||
request=self.request)
|
request=self.request)
|
||||||
|
|
||||||
def sort_data(self, query):
|
|
||||||
"""
|
|
||||||
Sort the given query according to current settings, and return the result.
|
|
||||||
"""
|
|
||||||
# Cannot sort unless we know which column to sort by.
|
|
||||||
if not self.sortkey:
|
|
||||||
return query
|
|
||||||
|
|
||||||
# Cannot sort unless we have a sort function.
|
|
||||||
sortfunc = self.sorters.get(self.sortkey)
|
|
||||||
if not sortfunc:
|
|
||||||
return query
|
|
||||||
|
|
||||||
# We can provide a default sort direction though.
|
|
||||||
sortdir = getattr(self, 'sortdir', 'asc')
|
|
||||||
return sortfunc(query, sortdir)
|
|
||||||
|
|
||||||
def paginate_data(self, query):
|
def paginate_data(self, query):
|
||||||
"""
|
"""
|
||||||
Paginate the given data set according to current settings, and return
|
Paginate the given data set according to current settings, and return
|
||||||
|
@ -170,7 +150,7 @@ class AlchemyGrid(Grid):
|
||||||
for field in self._fa_grid.render_fields.itervalues():
|
for field in self._fa_grid.render_fields.itervalues():
|
||||||
column = GridColumn()
|
column = GridColumn()
|
||||||
column.field = field
|
column.field = field
|
||||||
column.key = field.name
|
column.key = field.key
|
||||||
column.label = field.label()
|
column.label = field.label()
|
||||||
yield column
|
yield column
|
||||||
|
|
||||||
|
|
|
@ -39,7 +39,7 @@ class Grid(object):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, key, request, columns=[], data=[], main_actions=[], more_actions=[],
|
def __init__(self, key, request, columns=[], data=[], main_actions=[], more_actions=[],
|
||||||
filterable=False, filters={},
|
joiners={}, filterable=False, filters={},
|
||||||
sortable=False, sorters={}, default_sortkey=None, default_sortdir='asc',
|
sortable=False, sorters={}, default_sortkey=None, default_sortdir='asc',
|
||||||
pageable=False, default_pagesize=20, default_page=1,
|
pageable=False, default_pagesize=20, default_page=1,
|
||||||
width='auto', checkboxes=False, **kwargs):
|
width='auto', checkboxes=False, **kwargs):
|
||||||
|
@ -49,6 +49,7 @@ class Grid(object):
|
||||||
self.data = data
|
self.data = data
|
||||||
self.main_actions = main_actions
|
self.main_actions = main_actions
|
||||||
self.more_actions = more_actions
|
self.more_actions = more_actions
|
||||||
|
self.joiners = joiners
|
||||||
|
|
||||||
# Set extra attributes first, in case other init logic depends on any
|
# Set extra attributes first, in case other init logic depends on any
|
||||||
# of them (i.e. in subclasses).
|
# of them (i.e. in subclasses).
|
||||||
|
@ -378,16 +379,32 @@ class Grid(object):
|
||||||
Filter and return the given data set, according to current settings.
|
Filter and return the given data set, according to current settings.
|
||||||
"""
|
"""
|
||||||
for filtr in self.iter_active_filters():
|
for filtr in self.iter_active_filters():
|
||||||
|
if filtr.key in self.joiners and filtr.key not in self.joined:
|
||||||
|
data = self.joiners[filtr.key](data)
|
||||||
|
self.joined.add(filtr.key)
|
||||||
data = filtr.filter(data)
|
data = filtr.filter(data)
|
||||||
return data
|
return data
|
||||||
|
|
||||||
def sort_data(self, data):
|
def sort_data(self, data):
|
||||||
"""
|
"""
|
||||||
Sort the given data set according to current settings, and return the
|
Sort the given query according to current settings, and return the result.
|
||||||
result. Note that the default implementation does nothing.
|
|
||||||
"""
|
"""
|
||||||
|
# Cannot sort unless we know which column to sort by.
|
||||||
|
if not self.sortkey:
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
# Cannot sort unless we have a sort function.
|
||||||
|
sortfunc = self.sorters.get(self.sortkey)
|
||||||
|
if not sortfunc:
|
||||||
|
return data
|
||||||
|
|
||||||
|
# We can provide a default sort direction though.
|
||||||
|
sortdir = getattr(self, 'sortdir', 'asc')
|
||||||
|
if self.sortkey in self.joiners and self.sortkey not in self.joined:
|
||||||
|
data = self.joiners[self.sortkey](data)
|
||||||
|
self.joined.add(self.sortkey)
|
||||||
|
return sortfunc(data, sortdir)
|
||||||
|
|
||||||
def paginate_data(self, data):
|
def paginate_data(self, data):
|
||||||
"""
|
"""
|
||||||
Paginate the given data set according to current settings, and return
|
Paginate the given data set according to current settings, and return
|
||||||
|
@ -401,6 +418,7 @@ class Grid(object):
|
||||||
set. This will page / sort / filter as necessary, according to the
|
set. This will page / sort / filter as necessary, according to the
|
||||||
grid's defaults and the current request etc.
|
grid's defaults and the current request etc.
|
||||||
"""
|
"""
|
||||||
|
self.joined = set()
|
||||||
data = self.data
|
data = self.data
|
||||||
if self.filterable:
|
if self.filterable:
|
||||||
data = self.filter_data(data)
|
data = self.filter_data(data)
|
||||||
|
|
|
@ -70,6 +70,7 @@ class GridFilter(object):
|
||||||
'filters' section when rendering the index page template.
|
'filters' section when rendering the index page template.
|
||||||
"""
|
"""
|
||||||
verbmap = {
|
verbmap = {
|
||||||
|
'is_any': "is any",
|
||||||
'equal': "equal to",
|
'equal': "equal to",
|
||||||
'not_equal': "not equal to",
|
'not_equal': "not equal to",
|
||||||
'greater_than': "greater than",
|
'greater_than': "greater than",
|
||||||
|
@ -78,6 +79,8 @@ class GridFilter(object):
|
||||||
'less_equal': "less than or equal to",
|
'less_equal': "less than or equal to",
|
||||||
'is_null': "is null",
|
'is_null': "is null",
|
||||||
'is_not_null': "is not null",
|
'is_not_null': "is not null",
|
||||||
|
'is_true': "is true",
|
||||||
|
'is_false': "is false",
|
||||||
'contains': "contains",
|
'contains': "contains",
|
||||||
'does_not_contain': "does not contain",
|
'does_not_contain': "does not contain",
|
||||||
}
|
}
|
||||||
|
@ -86,7 +89,7 @@ class GridFilter(object):
|
||||||
default_active=False, default_verb=None, default_value=None):
|
default_active=False, default_verb=None, default_value=None):
|
||||||
self.key = key
|
self.key = key
|
||||||
self.label = label or prettify(key)
|
self.label = label or prettify(key)
|
||||||
self.verbs = verbs or self.default_verbs()
|
self.verbs = verbs or self.get_default_verbs()
|
||||||
self.renderer = renderer or DefaultRenderer()
|
self.renderer = renderer or DefaultRenderer()
|
||||||
self.renderer.filter = self
|
self.renderer.filter = self
|
||||||
self.default_active = default_active
|
self.default_active = default_active
|
||||||
|
@ -96,11 +99,16 @@ class GridFilter(object):
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return "GridFilter({0})".format(repr(self.key))
|
return "GridFilter({0})".format(repr(self.key))
|
||||||
|
|
||||||
def default_verbs(self):
|
def get_default_verbs(self):
|
||||||
"""
|
"""
|
||||||
Returns the set of verbs which will be used by default, i.e. unless
|
Returns the set of verbs which will be used by default, i.e. unless
|
||||||
overridden by constructor args etc.
|
overridden by constructor args etc.
|
||||||
"""
|
"""
|
||||||
|
verbs = getattr(self, 'default_verbs', None)
|
||||||
|
if verbs:
|
||||||
|
if callable(verbs):
|
||||||
|
return verbs()
|
||||||
|
return verbs
|
||||||
return ['equal', 'not_equal', 'is_null', 'is_not_null']
|
return ['equal', 'not_equal', 'is_null', 'is_not_null']
|
||||||
|
|
||||||
def filter(self, data, verb=None, value=UNSPECIFIED):
|
def filter(self, data, verb=None, value=UNSPECIFIED):
|
||||||
|
@ -116,6 +124,14 @@ class GridFilter(object):
|
||||||
raise ValueError("Unknown filter verb: {0}".format(repr(verb)))
|
raise ValueError("Unknown filter verb: {0}".format(repr(verb)))
|
||||||
return filtr(data, value)
|
return filtr(data, value)
|
||||||
|
|
||||||
|
def filter_is_any(self, data, value):
|
||||||
|
"""
|
||||||
|
Special no-op filter which does no actual filtering. Useful in some
|
||||||
|
cases to add an "ineffective" option to the verb list for a given grid
|
||||||
|
filter.
|
||||||
|
"""
|
||||||
|
return data
|
||||||
|
|
||||||
def render(self, **kwargs):
|
def render(self, **kwargs):
|
||||||
kwargs['filter'] = self
|
kwargs['filter'] = self
|
||||||
return self.renderer.render(**kwargs)
|
return self.renderer.render(**kwargs)
|
||||||
|
@ -127,9 +143,7 @@ class AlchemyGridFilter(GridFilter):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
self.model_property = kwargs.pop('model_property')
|
self.column = kwargs.pop('column')
|
||||||
self.model_class = self.model_property.parent.class_
|
|
||||||
self.model_column = getattr(self.model_class, self.model_property.key)
|
|
||||||
super(AlchemyGridFilter, self).__init__(*args, **kwargs)
|
super(AlchemyGridFilter, self).__init__(*args, **kwargs)
|
||||||
|
|
||||||
def filter_equal(self, query, value):
|
def filter_equal(self, query, value):
|
||||||
|
@ -138,7 +152,7 @@ class AlchemyGridFilter(GridFilter):
|
||||||
"""
|
"""
|
||||||
if value is None or value == '':
|
if value is None or value == '':
|
||||||
return query
|
return query
|
||||||
return query.filter(self.model_column == value)
|
return query.filter(self.column == value)
|
||||||
|
|
||||||
def filter_not_equal(self, query, value):
|
def filter_not_equal(self, query, value):
|
||||||
"""
|
"""
|
||||||
|
@ -150,8 +164,8 @@ class AlchemyGridFilter(GridFilter):
|
||||||
# When saying something is 'not equal' to something else, we must also
|
# When saying something is 'not equal' to something else, we must also
|
||||||
# include things which are nothing at all, in our result set.
|
# include things which are nothing at all, in our result set.
|
||||||
return query.filter(sa.or_(
|
return query.filter(sa.or_(
|
||||||
self.model_column == None,
|
self.column == None,
|
||||||
self.model_column != value,
|
self.column != value,
|
||||||
))
|
))
|
||||||
|
|
||||||
def filter_is_null(self, query, value):
|
def filter_is_null(self, query, value):
|
||||||
|
@ -159,14 +173,14 @@ class AlchemyGridFilter(GridFilter):
|
||||||
Filter data with an 'IS NULL' query. Note that this filter does not
|
Filter data with an 'IS NULL' query. Note that this filter does not
|
||||||
use the value for anything.
|
use the value for anything.
|
||||||
"""
|
"""
|
||||||
return query.filter(self.model_column == None)
|
return query.filter(self.column == None)
|
||||||
|
|
||||||
def filter_is_not_null(self, query, value):
|
def filter_is_not_null(self, query, value):
|
||||||
"""
|
"""
|
||||||
Filter data with an 'IS NOT NULL' query. Note that this filter does
|
Filter data with an 'IS NOT NULL' query. Note that this filter does
|
||||||
not use the value for anything.
|
not use the value for anything.
|
||||||
"""
|
"""
|
||||||
return query.filter(self.model_column != None)
|
return query.filter(self.column != None)
|
||||||
|
|
||||||
def filter_greater_than(self, query, value):
|
def filter_greater_than(self, query, value):
|
||||||
"""
|
"""
|
||||||
|
@ -174,7 +188,7 @@ class AlchemyGridFilter(GridFilter):
|
||||||
"""
|
"""
|
||||||
if value is None or value == '':
|
if value is None or value == '':
|
||||||
return query
|
return query
|
||||||
return query.filter(self.model_column > value)
|
return query.filter(self.column > value)
|
||||||
|
|
||||||
def filter_greater_equal(self, query, value):
|
def filter_greater_equal(self, query, value):
|
||||||
"""
|
"""
|
||||||
|
@ -182,7 +196,7 @@ class AlchemyGridFilter(GridFilter):
|
||||||
"""
|
"""
|
||||||
if value is None or value == '':
|
if value is None or value == '':
|
||||||
return query
|
return query
|
||||||
return query.filter(self.model_column >= value)
|
return query.filter(self.column >= value)
|
||||||
|
|
||||||
def filter_less_than(self, query, value):
|
def filter_less_than(self, query, value):
|
||||||
"""
|
"""
|
||||||
|
@ -190,7 +204,7 @@ class AlchemyGridFilter(GridFilter):
|
||||||
"""
|
"""
|
||||||
if value is None or value == '':
|
if value is None or value == '':
|
||||||
return query
|
return query
|
||||||
return query.filter(self.model_column < value)
|
return query.filter(self.column < value)
|
||||||
|
|
||||||
def filter_less_equal(self, query, value):
|
def filter_less_equal(self, query, value):
|
||||||
"""
|
"""
|
||||||
|
@ -198,7 +212,7 @@ class AlchemyGridFilter(GridFilter):
|
||||||
"""
|
"""
|
||||||
if value is None or value == '':
|
if value is None or value == '':
|
||||||
return query
|
return query
|
||||||
return query.filter(self.model_column <= value)
|
return query.filter(self.column <= value)
|
||||||
|
|
||||||
|
|
||||||
class AlchemyStringFilter(AlchemyGridFilter):
|
class AlchemyStringFilter(AlchemyGridFilter):
|
||||||
|
@ -219,7 +233,7 @@ class AlchemyStringFilter(AlchemyGridFilter):
|
||||||
"""
|
"""
|
||||||
if value is None or value == '':
|
if value is None or value == '':
|
||||||
return query
|
return query
|
||||||
return query.filter(self.model_column.ilike('%{0}%'.format(value)))
|
return query.filter(self.column.ilike('%{0}%'.format(value)))
|
||||||
|
|
||||||
def filter_does_not_contain(self, query, value):
|
def filter_does_not_contain(self, query, value):
|
||||||
"""
|
"""
|
||||||
|
@ -231,8 +245,8 @@ class AlchemyStringFilter(AlchemyGridFilter):
|
||||||
# When saying something is 'not like' something else, we must also
|
# When saying something is 'not like' something else, we must also
|
||||||
# include things which are nothing at all, in our result set.
|
# include things which are nothing at all, in our result set.
|
||||||
return query.filter(sa.or_(
|
return query.filter(sa.or_(
|
||||||
self.model_column == None,
|
self.column == None,
|
||||||
~self.model_column.ilike('%{0}%'.format(value)),
|
~self.column.ilike('%{0}%'.format(value)),
|
||||||
))
|
))
|
||||||
|
|
||||||
|
|
||||||
|
@ -249,6 +263,27 @@ class AlchemyNumericFilter(AlchemyGridFilter):
|
||||||
'less_than', 'less_equal', 'is_null', 'is_not_null']
|
'less_than', 'less_equal', 'is_null', 'is_not_null']
|
||||||
|
|
||||||
|
|
||||||
|
class AlchemyBooleanFilter(AlchemyGridFilter):
|
||||||
|
"""
|
||||||
|
Boolean filter for SQLAlchemy.
|
||||||
|
"""
|
||||||
|
default_verbs = ['is_true', 'is_false', 'is_null', 'is_not_null', 'is_any']
|
||||||
|
|
||||||
|
def filter_is_true(self, query, value):
|
||||||
|
"""
|
||||||
|
Filter data with an "is true" query. Note that this filter does not
|
||||||
|
use the value for anything.
|
||||||
|
"""
|
||||||
|
return query.filter(self.column == True)
|
||||||
|
|
||||||
|
def filter_is_false(self, query, value):
|
||||||
|
"""
|
||||||
|
Filter data with an "is false" query. Note that this filter does not
|
||||||
|
use the value for anything.
|
||||||
|
"""
|
||||||
|
return query.filter(self.column == False)
|
||||||
|
|
||||||
|
|
||||||
class GridFilterSet(OrderedDict):
|
class GridFilterSet(OrderedDict):
|
||||||
"""
|
"""
|
||||||
Collection class for :class:`GridFilter` instances.
|
Collection class for :class:`GridFilter` instances.
|
||||||
|
|
|
@ -1,16 +0,0 @@
|
||||||
## -*- coding: utf-8 -*-
|
|
||||||
<%inherit file="/crud.mako" />
|
|
||||||
|
|
||||||
<%def name="context_menu_items()">
|
|
||||||
<li>${h.link_to("Back to Users", url('users'))}</li>
|
|
||||||
% if form.readonly:
|
|
||||||
<li>${h.link_to("Edit this User", url('user.update', uuid=form.fieldset.model.uuid))}</li>
|
|
||||||
% elif form.updating:
|
|
||||||
<li>${h.link_to("View this User", url('user.read', uuid=form.fieldset.model.uuid))}</li>
|
|
||||||
% endif
|
|
||||||
% if version_count is not Undefined and request.has_perm('user.versions.view'):
|
|
||||||
<li>${h.link_to("View Change History ({0})".format(version_count), url('user.versions', uuid=form.fieldset.model.uuid))}</li>
|
|
||||||
% endif
|
|
||||||
</%def>
|
|
||||||
|
|
||||||
${parent.body()}
|
|
11
tailbone/templates/users/edit.mako
Normal file
11
tailbone/templates/users/edit.mako
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
## -*- coding: utf-8 -*-
|
||||||
|
<%inherit file="/master/edit.mako" />
|
||||||
|
|
||||||
|
<%def name="context_menu_items()">
|
||||||
|
${parent.context_menu_items()}
|
||||||
|
% if version_count is not Undefined and request.has_perm('user.versions.view'):
|
||||||
|
<li>${h.link_to("View Change History ({0})".format(version_count), url('user.versions', uuid=instance.uuid))}</li>
|
||||||
|
% endif
|
||||||
|
</%def>
|
||||||
|
|
||||||
|
${parent.body()}
|
|
@ -1,12 +0,0 @@
|
||||||
## -*- coding: utf-8 -*-
|
|
||||||
<%inherit file="/grid.mako" />
|
|
||||||
|
|
||||||
<%def name="title()">Users</%def>
|
|
||||||
|
|
||||||
<%def name="context_menu_items()">
|
|
||||||
% if request.has_perm('users.create'):
|
|
||||||
<li>${h.link_to("Create a new User", url('user.create'))}</li>
|
|
||||||
% endif
|
|
||||||
</%def>
|
|
||||||
|
|
||||||
${parent.body()}
|
|
11
tailbone/templates/users/view.mako
Normal file
11
tailbone/templates/users/view.mako
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
## -*- coding: utf-8 -*-
|
||||||
|
<%inherit file="/master/view.mako" />
|
||||||
|
|
||||||
|
<%def name="context_menu_items()">
|
||||||
|
${parent.context_menu_items()}
|
||||||
|
% if version_count is not Undefined and request.has_perm('user.versions.view'):
|
||||||
|
<li>${h.link_to("View Change History ({0})".format(version_count), url('user.versions', uuid=instance.uuid))}</li>
|
||||||
|
% endif
|
||||||
|
</%def>
|
||||||
|
|
||||||
|
${parent.body()}
|
|
@ -45,6 +45,10 @@ class MasterView(View):
|
||||||
"""
|
"""
|
||||||
Base "master" view class. All model master views should derive from this.
|
Base "master" view class. All model master views should derive from this.
|
||||||
"""
|
"""
|
||||||
|
creating = False
|
||||||
|
viewing = False
|
||||||
|
editing = False
|
||||||
|
deleting = False
|
||||||
|
|
||||||
##############################
|
##############################
|
||||||
# Available Views
|
# Available Views
|
||||||
|
@ -69,7 +73,8 @@ class MasterView(View):
|
||||||
"""
|
"""
|
||||||
View for creating a new model record.
|
View for creating a new model record.
|
||||||
"""
|
"""
|
||||||
form = self.make_form(self.model_class, creating=True)
|
self.creating = True
|
||||||
|
form = self.make_form(self.model_class)
|
||||||
if self.request.method == 'POST':
|
if self.request.method == 'POST':
|
||||||
if form.validate():
|
if form.validate():
|
||||||
form.save()
|
form.save()
|
||||||
|
@ -83,11 +88,9 @@ class MasterView(View):
|
||||||
"""
|
"""
|
||||||
View for viewing details of an existing model record.
|
View for viewing details of an existing model record.
|
||||||
"""
|
"""
|
||||||
key = self.request.matchdict[self.get_model_key()]
|
self.viewing = True
|
||||||
instance = Session.query(self.model_class).get(key)
|
instance = self.get_instance()
|
||||||
if not instance:
|
form = self.make_form(instance)
|
||||||
return HTTPNotFound()
|
|
||||||
form = self.make_form(instance, readonly=True)
|
|
||||||
return self.render_to_response('view', {
|
return self.render_to_response('view', {
|
||||||
'instance': instance, 'form': form})
|
'instance': instance, 'form': form})
|
||||||
|
|
||||||
|
@ -95,11 +98,9 @@ class MasterView(View):
|
||||||
"""
|
"""
|
||||||
View for editing an existing model record.
|
View for editing an existing model record.
|
||||||
"""
|
"""
|
||||||
key = self.request.matchdict[self.get_model_key()]
|
self.editing = True
|
||||||
instance = Session.query(self.model_class).get(key)
|
instance = self.get_instance()
|
||||||
if not instance:
|
form = self.make_form(instance)
|
||||||
return HTTPNotFound()
|
|
||||||
form = self.make_form(instance, editing=True)
|
|
||||||
if self.request.method == 'POST':
|
if self.request.method == 'POST':
|
||||||
if form.validate():
|
if form.validate():
|
||||||
form.save()
|
form.save()
|
||||||
|
@ -112,10 +113,8 @@ class MasterView(View):
|
||||||
"""
|
"""
|
||||||
View for deleting an existing model record.
|
View for deleting an existing model record.
|
||||||
"""
|
"""
|
||||||
key = self.request.matchdict[self.get_model_key()]
|
self.deleting = True
|
||||||
instance = Session.query(self.model_class).get(key)
|
instance = self.get_instance()
|
||||||
if not instance:
|
|
||||||
return HTTPNotFound()
|
|
||||||
|
|
||||||
# Let derived classes prep for (or cancel) deletion.
|
# Let derived classes prep for (or cancel) deletion.
|
||||||
result = self.before_delete(instance)
|
result = self.before_delete(instance)
|
||||||
|
@ -253,6 +252,12 @@ class MasterView(View):
|
||||||
return render_to_response('/master/{0}.mako'.format(template),
|
return render_to_response('/master/{0}.mako'.format(template),
|
||||||
data, request=self.request)
|
data, request=self.request)
|
||||||
|
|
||||||
|
def template_kwargs(self, **kwargs):
|
||||||
|
"""
|
||||||
|
Supplement the template context, for all views.
|
||||||
|
"""
|
||||||
|
return kwargs
|
||||||
|
|
||||||
def redirect(self, url):
|
def redirect(self, url):
|
||||||
"""
|
"""
|
||||||
Convenience method to return a HTTP 302 response.
|
Convenience method to return a HTTP 302 response.
|
||||||
|
@ -362,29 +367,40 @@ class MasterView(View):
|
||||||
|
|
||||||
def make_query(self, session=None):
|
def make_query(self, session=None):
|
||||||
"""
|
"""
|
||||||
Make the base query to be used for the grid. Note that this query will
|
Make the base query to be used for the grid. Subclasses should not
|
||||||
have been prefiltered but otherwise will be "pure". The user's filter
|
override this method; override :meth:`query()` instead.
|
||||||
selections etc. are later applied to this query.
|
|
||||||
"""
|
"""
|
||||||
if session is None:
|
if session is None:
|
||||||
session = Session()
|
session = Session()
|
||||||
query = session.query(self.model_class)
|
return self.query(session)
|
||||||
return self.prefilter_query(query)
|
|
||||||
|
|
||||||
def prefilter_query(self, query):
|
def query(self, session):
|
||||||
"""
|
"""
|
||||||
Apply any sort of pre-filtering to the grid query, as necessary. This
|
Produce the initial/base query for the master grid. By default this is
|
||||||
is useful if say, you don't ever want to show records of a certain type
|
simply a query against the model class, but you may override as
|
||||||
to non-admin users. You would use a "prefilter" to hide what you
|
necessary to apply any sort of pre-filtering etc. This is useful if
|
||||||
wanted, regardless of the user's filter selections.
|
say, you don't ever want to show records of a certain type to non-admin
|
||||||
|
users. You would modify the base query to hide what you wanted,
|
||||||
|
regardless of the user's filter selections.
|
||||||
"""
|
"""
|
||||||
return query
|
return session.query(self.model_class)
|
||||||
|
|
||||||
|
|
||||||
##############################
|
##############################
|
||||||
# CRUD Stuff
|
# CRUD Stuff
|
||||||
##############################
|
##############################
|
||||||
|
|
||||||
|
def get_instance(self):
|
||||||
|
"""
|
||||||
|
Fetch the current model instance by inspecting the route kwargs and
|
||||||
|
doing a database lookup. If the instance cannot be found, raises 404.
|
||||||
|
"""
|
||||||
|
key = self.request.matchdict[self.get_model_key()]
|
||||||
|
instance = Session.query(self.model_class).get(key)
|
||||||
|
if not instance:
|
||||||
|
raise HTTPNotFound()
|
||||||
|
return instance
|
||||||
|
|
||||||
def make_form(self, instance, **kwargs):
|
def make_form(self, instance, **kwargs):
|
||||||
"""
|
"""
|
||||||
Make a FormAlchemy-based form for use with CRUD views.
|
Make a FormAlchemy-based form for use with CRUD views.
|
||||||
|
@ -392,13 +408,8 @@ class MasterView(View):
|
||||||
# TODO: Some hacky stuff here, to accommodate old form cruft. Probably
|
# TODO: Some hacky stuff here, to accommodate old form cruft. Probably
|
||||||
# should refactor forms soon too, but trying to avoid it for the moment.
|
# should refactor forms soon too, but trying to avoid it for the moment.
|
||||||
|
|
||||||
readonly = kwargs.pop('readonly', False)
|
kwargs.setdefault('creating', self.creating)
|
||||||
kwargs.setdefault('creating', False)
|
kwargs.setdefault('editing', self.editing)
|
||||||
kwargs.setdefault('editing', False)
|
|
||||||
|
|
||||||
# Ugh, these attributes must be present on the view..?
|
|
||||||
self.creating = kwargs['creating']
|
|
||||||
self.editing = kwargs['editing']
|
|
||||||
|
|
||||||
fieldset = self.make_fieldset(instance)
|
fieldset = self.make_fieldset(instance)
|
||||||
self.configure_fieldset(fieldset)
|
self.configure_fieldset(fieldset)
|
||||||
|
@ -409,7 +420,7 @@ class MasterView(View):
|
||||||
else:
|
else:
|
||||||
kwargs.setdefault('cancel_url', self.get_action_url('view', instance))
|
kwargs.setdefault('cancel_url', self.get_action_url('view', instance))
|
||||||
form = AlchemyForm(self.request, fieldset, **kwargs)
|
form = AlchemyForm(self.request, fieldset, **kwargs)
|
||||||
form.readonly = readonly
|
form.readonly = self.viewing
|
||||||
return form
|
return form
|
||||||
|
|
||||||
def make_fieldset(self, instance, **kwargs):
|
def make_fieldset(self, instance, **kwargs):
|
||||||
|
@ -422,11 +433,10 @@ class MasterView(View):
|
||||||
fieldset.prettify = prettify
|
fieldset.prettify = prettify
|
||||||
return fieldset
|
return fieldset
|
||||||
|
|
||||||
def template_kwargs(self, **kwargs):
|
def before_delete(self, instance):
|
||||||
"""
|
"""
|
||||||
Supplement the template context, for all views.
|
Event hook which is called just before deletion is attempted.
|
||||||
"""
|
"""
|
||||||
return kwargs
|
|
||||||
|
|
||||||
##############################
|
##############################
|
||||||
# Config Stuff
|
# Config Stuff
|
||||||
|
|
|
@ -134,7 +134,7 @@ class RolesView(MasterView):
|
||||||
users = AlchemyGrid('roles.users', self.request, data=users, model_class=model.User,
|
users = AlchemyGrid('roles.users', self.request, data=users, model_class=model.User,
|
||||||
main_actions=[
|
main_actions=[
|
||||||
GridAction('view', icon='zoomin',
|
GridAction('view', icon='zoomin',
|
||||||
url=lambda u: self.request.route_url('user.read', uuid=u.uuid)),
|
url=lambda u: self.request.route_url('users.view', uuid=u.uuid)),
|
||||||
])
|
])
|
||||||
users.configure(include=[users.username], readonly=True)
|
users.configure(include=[users.username], readonly=True)
|
||||||
kwargs['users'] = users
|
kwargs['users'] = users
|
||||||
|
|
|
@ -26,125 +26,28 @@ User Views
|
||||||
|
|
||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
from sqlalchemy import orm
|
||||||
|
|
||||||
from rattail.db import model
|
from rattail.db import model
|
||||||
from rattail.db.model import User, Person, Role
|
|
||||||
from rattail.db.auth import guest_role, set_user_password
|
from rattail.db.auth import guest_role, set_user_password
|
||||||
|
|
||||||
import formalchemy
|
import formalchemy
|
||||||
from formalchemy import Field, ValidationError
|
|
||||||
from formalchemy.fields import SelectFieldRenderer
|
from formalchemy.fields import SelectFieldRenderer
|
||||||
from webhelpers.html import tags
|
from webhelpers.html import HTML, tags
|
||||||
from webhelpers.html import HTML
|
|
||||||
|
|
||||||
from . import SearchableAlchemyGridView, CrudView
|
from tailbone.db import Session
|
||||||
from ..forms import PersonFieldLinkRenderer
|
from tailbone.views import MasterView
|
||||||
from ..db import Session
|
from tailbone.views.continuum import VersionView, version_defaults
|
||||||
from tailbone.grids.search import BooleanSearchFilter
|
from tailbone.forms import renderers
|
||||||
|
|
||||||
from .continuum import VersionView, version_defaults
|
|
||||||
|
|
||||||
|
|
||||||
class UsersGrid(SearchableAlchemyGridView):
|
def unique_username(value, field):
|
||||||
|
user = field.parent.model
|
||||||
mapped_class = User
|
query = Session.query(model.User).filter(model.User.username == value)
|
||||||
config_prefix = 'users'
|
if user.uuid:
|
||||||
sort = 'username'
|
query = query.filter(model.User.uuid != user.uuid)
|
||||||
|
if query.count():
|
||||||
def join_map(self):
|
raise formalchemy.ValidationError("Username must be unique.")
|
||||||
return {
|
|
||||||
'person':
|
|
||||||
lambda q: q.outerjoin(Person),
|
|
||||||
}
|
|
||||||
|
|
||||||
def filter_map(self):
|
|
||||||
return self.make_filter_map(
|
|
||||||
ilike=['username'],
|
|
||||||
exact=['active'],
|
|
||||||
person=self.filter_ilike(Person.display_name))
|
|
||||||
|
|
||||||
def filter_config(self):
|
|
||||||
return self.make_filter_config(
|
|
||||||
include_filter_username=True,
|
|
||||||
filter_type_username='lk',
|
|
||||||
include_filter_person=True,
|
|
||||||
filter_type_person='lk',
|
|
||||||
filter_factory_active=BooleanSearchFilter,
|
|
||||||
include_filter_active=True,
|
|
||||||
filter_type_active='is',
|
|
||||||
active='True')
|
|
||||||
|
|
||||||
def sort_map(self):
|
|
||||||
return self.make_sort_map(
|
|
||||||
'username',
|
|
||||||
person=self.sorter(Person.display_name))
|
|
||||||
|
|
||||||
def grid(self):
|
|
||||||
g = self.make_grid()
|
|
||||||
g.configure(
|
|
||||||
include=[
|
|
||||||
g.username,
|
|
||||||
g.person,
|
|
||||||
],
|
|
||||||
readonly=True)
|
|
||||||
if self.request.has_perm('users.read'):
|
|
||||||
g.viewable = True
|
|
||||||
g.view_route_name = 'user.read'
|
|
||||||
if self.request.has_perm('users.update'):
|
|
||||||
g.editable = True
|
|
||||||
g.edit_route_name = 'user.update'
|
|
||||||
if self.request.has_perm('users.delete'):
|
|
||||||
g.deletable = True
|
|
||||||
g.delete_route_name = 'user.delete'
|
|
||||||
return g
|
|
||||||
|
|
||||||
|
|
||||||
class RolesField(Field):
|
|
||||||
|
|
||||||
def __init__(self, name, **kwargs):
|
|
||||||
kwargs.setdefault('value', self.get_value)
|
|
||||||
kwargs.setdefault('options', self.get_options())
|
|
||||||
kwargs.setdefault('multiple', True)
|
|
||||||
super(RolesField, self).__init__(name, **kwargs)
|
|
||||||
|
|
||||||
def get_value(self, user):
|
|
||||||
return [x.uuid for x in user.roles]
|
|
||||||
|
|
||||||
def get_options(self):
|
|
||||||
return Session.query(Role.name, Role.uuid)\
|
|
||||||
.filter(Role.uuid != guest_role(Session()).uuid)\
|
|
||||||
.order_by(Role.name)\
|
|
||||||
.all()
|
|
||||||
|
|
||||||
def sync(self):
|
|
||||||
if not self.is_readonly():
|
|
||||||
user = self.model
|
|
||||||
roles = Session.query(Role)
|
|
||||||
data = self.renderer.deserialize()
|
|
||||||
user.roles = [roles.get(x) for x in data]
|
|
||||||
|
|
||||||
|
|
||||||
def RolesFieldRenderer(request):
|
|
||||||
|
|
||||||
class RolesFieldRenderer(SelectFieldRenderer):
|
|
||||||
|
|
||||||
def render_readonly(self, **kwargs):
|
|
||||||
roles = Session.query(Role)
|
|
||||||
html = ''
|
|
||||||
for uuid in self.value:
|
|
||||||
role = roles.get(uuid)
|
|
||||||
link = tags.link_to(
|
|
||||||
role.name, request.route_url('roles.view', uuid=role.uuid))
|
|
||||||
html += HTML.tag('li', c=link)
|
|
||||||
html = HTML.tag('ul', c=html)
|
|
||||||
return html
|
|
||||||
|
|
||||||
return RolesFieldRenderer
|
|
||||||
|
|
||||||
|
|
||||||
class PasswordFieldRenderer(formalchemy.PasswordFieldRenderer):
|
|
||||||
|
|
||||||
def render(self, **kwargs):
|
|
||||||
return tags.password(self.name, value='', maxlength=self.length, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
def passwords_match(value, field):
|
def passwords_match(value, field):
|
||||||
|
@ -153,6 +56,12 @@ def passwords_match(value, field):
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordFieldRenderer(formalchemy.PasswordFieldRenderer):
|
||||||
|
|
||||||
|
def render(self, **kwargs):
|
||||||
|
return tags.password(self.name, value='', maxlength=self.length, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class PasswordField(formalchemy.Field):
|
class PasswordField(formalchemy.Field):
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
|
@ -168,45 +77,103 @@ class PasswordField(formalchemy.Field):
|
||||||
set_user_password(self.model, password)
|
set_user_password(self.model, password)
|
||||||
|
|
||||||
|
|
||||||
class UserCrud(CrudView):
|
def RolesFieldRenderer(request):
|
||||||
|
|
||||||
mapped_class = User
|
class RolesFieldRenderer(SelectFieldRenderer):
|
||||||
home_route = 'users'
|
|
||||||
|
|
||||||
def fieldset(self, user):
|
def render_readonly(self, **kwargs):
|
||||||
fs = self.make_fieldset(user)
|
roles = Session.query(model.Role)
|
||||||
|
html = ''
|
||||||
|
for uuid in self.value:
|
||||||
|
role = roles.get(uuid)
|
||||||
|
link = tags.link_to(
|
||||||
|
role.name, request.route_url('roles.view', uuid=role.uuid))
|
||||||
|
html += HTML.tag('li', c=link)
|
||||||
|
html = HTML.tag('ul', c=html)
|
||||||
|
return html
|
||||||
|
|
||||||
# Must set Person options to empty set to avoid unwanted magic.
|
return RolesFieldRenderer
|
||||||
|
|
||||||
|
|
||||||
|
class RolesField(formalchemy.Field):
|
||||||
|
|
||||||
|
def __init__(self, name, **kwargs):
|
||||||
|
kwargs.setdefault('value', self.get_value)
|
||||||
|
kwargs.setdefault('options', self.get_options())
|
||||||
|
kwargs.setdefault('multiple', True)
|
||||||
|
super(RolesField, self).__init__(name, **kwargs)
|
||||||
|
|
||||||
|
def get_value(self, user):
|
||||||
|
return [x.uuid for x in user.roles]
|
||||||
|
|
||||||
|
def get_options(self):
|
||||||
|
return Session.query(model.Role.name, model.Role.uuid)\
|
||||||
|
.filter(model.Role.uuid != guest_role(Session()).uuid)\
|
||||||
|
.order_by(model.Role.name)\
|
||||||
|
.all()
|
||||||
|
|
||||||
|
def sync(self):
|
||||||
|
if not self.is_readonly():
|
||||||
|
user = self.model
|
||||||
|
roles = Session.query(model.Role)
|
||||||
|
data = self.renderer.deserialize()
|
||||||
|
user.roles = [roles.get(x) for x in data]
|
||||||
|
|
||||||
|
|
||||||
|
class UsersView(MasterView):
|
||||||
|
"""
|
||||||
|
Master view for the User model.
|
||||||
|
"""
|
||||||
|
model_class = model.User
|
||||||
|
|
||||||
|
def query(self, session):
|
||||||
|
return session.query(model.User)\
|
||||||
|
.options(orm.joinedload(model.User.person))
|
||||||
|
|
||||||
|
def configure_grid(self, g):
|
||||||
|
g.joiners['person'] = lambda q: q.outerjoin(model.Person)
|
||||||
|
|
||||||
|
del g.filters['password']
|
||||||
|
del g.filters['salt']
|
||||||
|
g.filters['username'].default_active = True
|
||||||
|
g.filters['username'].default_verb = 'contains'
|
||||||
|
g.filters['active'].verbs = ['is_true', 'is_false', 'is_any']
|
||||||
|
g.filters['active'].default_active = True
|
||||||
|
g.filters['active'].default_verb = 'is_true'
|
||||||
|
g.filters['person'] = g.make_filter('person', model.Person.display_name, label="Person's Name",
|
||||||
|
default_active=True, default_verb='contains')
|
||||||
|
|
||||||
|
g.sorters['person'] = lambda q, d: q.order_by(getattr(model.Person.display_name, d)())
|
||||||
|
g.default_sortkey = 'username'
|
||||||
|
|
||||||
|
g.person.set(label="Person's Name")
|
||||||
|
g.configure(
|
||||||
|
include=[
|
||||||
|
g.username,
|
||||||
|
g.person,
|
||||||
|
],
|
||||||
|
readonly=True)
|
||||||
|
|
||||||
|
def configure_fieldset(self, fs):
|
||||||
|
fs.username.set(validate=unique_username)
|
||||||
fs.person.set(options=[])
|
fs.person.set(options=[])
|
||||||
|
fs.person.set(renderer=renderers.PersonFieldLinkRenderer)
|
||||||
fs.append(PasswordField('password'))
|
fs.append(PasswordField('password', label="Set Password"))
|
||||||
fs.append(Field('confirm_password',
|
fs.append(formalchemy.Field('confirm_password', renderer=PasswordFieldRenderer))
|
||||||
renderer=PasswordFieldRenderer))
|
fs.append(RolesField('roles', renderer=RolesFieldRenderer(self.request)))
|
||||||
fs.append(RolesField(
|
|
||||||
'roles', renderer=RolesFieldRenderer(self.request)))
|
|
||||||
|
|
||||||
fs.configure(
|
fs.configure(
|
||||||
include=[
|
include=[
|
||||||
fs.username,
|
fs.username,
|
||||||
fs.person.with_renderer(PersonFieldLinkRenderer),
|
fs.person,
|
||||||
fs.password.label("Set Password"),
|
fs.active,
|
||||||
|
fs.password,
|
||||||
fs.confirm_password,
|
fs.confirm_password,
|
||||||
fs.roles,
|
fs.roles,
|
||||||
fs.active,
|
|
||||||
])
|
])
|
||||||
|
if self.viewing:
|
||||||
if self.creating:
|
|
||||||
def unique_username(value, field):
|
|
||||||
if Session.query(User).filter_by(username=value).count():
|
|
||||||
raise ValidationError("Username must be unique.")
|
|
||||||
fs.username.set(validate=unique_username)
|
|
||||||
|
|
||||||
if self.readonly:
|
|
||||||
del fs.password
|
del fs.password
|
||||||
del fs.confirm_password
|
del fs.confirm_password
|
||||||
|
|
||||||
return fs
|
|
||||||
|
|
||||||
|
|
||||||
class UserVersionView(VersionView):
|
class UserVersionView(VersionView):
|
||||||
"""
|
"""
|
||||||
|
@ -216,33 +183,6 @@ class UserVersionView(VersionView):
|
||||||
route_model_view = 'user.read'
|
route_model_view = 'user.read'
|
||||||
|
|
||||||
|
|
||||||
def add_routes(config):
|
|
||||||
config.add_route(u'users', u'/users')
|
|
||||||
config.add_route(u'user.create', u'/users/new')
|
|
||||||
config.add_route(u'user.read', u'/users/{uuid}')
|
|
||||||
config.add_route(u'user.update', u'/users/{uuid}/edit')
|
|
||||||
config.add_route(u'user.delete', u'/users/{uuid}/delete')
|
|
||||||
|
|
||||||
|
|
||||||
def includeme(config):
|
def includeme(config):
|
||||||
add_routes(config)
|
UsersView.defaults(config)
|
||||||
|
|
||||||
# List
|
|
||||||
config.add_view(UsersGrid, route_name='users',
|
|
||||||
renderer='/users/index.mako',
|
|
||||||
permission='users.list')
|
|
||||||
|
|
||||||
# CRUD
|
|
||||||
config.add_view(UserCrud, attr='create', route_name='user.create',
|
|
||||||
renderer='/users/crud.mako',
|
|
||||||
permission='users.create')
|
|
||||||
config.add_view(UserCrud, attr='read', route_name='user.read',
|
|
||||||
renderer='/users/crud.mako',
|
|
||||||
permission='users.read')
|
|
||||||
config.add_view(UserCrud, attr='update', route_name='user.update',
|
|
||||||
renderer='/users/crud.mako',
|
|
||||||
permission='users.update')
|
|
||||||
config.add_view(UserCrud, attr='delete', route_name='user.delete',
|
|
||||||
permission='users.delete')
|
|
||||||
|
|
||||||
version_defaults(config, UserVersionView, 'user')
|
version_defaults(config, UserVersionView, 'user')
|
||||||
|
|
Loading…
Reference in a new issue