Commit d851c420 authored by Serge S. Koval's avatar Serge S. Koval

Fixed #408 and #409. Refactored internal filter logic. Can customize URL with filters

parent 9b19fff4
...@@ -72,3 +72,22 @@ def with_metaclass(meta, *bases): ...@@ -72,3 +72,22 @@ def with_metaclass(meta, *bases):
return type.__new__(cls, name, (), d) return type.__new__(cls, name, (), d)
return meta(name, bases, d) return meta(name, bases, d)
return metaclass('temporary_class', None, {}) return metaclass('temporary_class', None, {})
try:
from collections import OrderedDict
except ImportError:
# Bare-bones OrderedDict implementation for Python2.6 compatibility
class OrderedDict(dict):
def __init__(self, *args, **kwargs):
dict.__init__(self, *args, **kwargs)
self.ordered_keys = []
def __setitem__(self, key, value):
self.ordered_keys.append(key)
dict.__setitem__(self, key, value)
def __iter__(self):
return (k for k in self.ordered_keys)
def iteritems(self):
return ((k, self[k]) for k in self.ordered_keys)
def items(self):
return list(self.iteritems())
import warnings import warnings
import re
from flask import request, url_for, redirect, flash, abort, json, Response from flask import request, url_for, redirect, flash, abort, json, Response
...@@ -13,11 +14,16 @@ from flask.ext.admin.actions import ActionsMixin ...@@ -13,11 +14,16 @@ from flask.ext.admin.actions import ActionsMixin
from flask.ext.admin.helpers import get_form_data, validate_form_on_submit, get_redirect_target from flask.ext.admin.helpers import get_form_data, validate_form_on_submit, get_redirect_target
from flask.ext.admin.tools import rec_getattr from flask.ext.admin.tools import rec_getattr
from flask.ext.admin._backwards import ObsoleteAttr from flask.ext.admin._backwards import ObsoleteAttr
from flask.ext.admin._compat import iteritems, as_unicode from flask.ext.admin._compat import iteritems, OrderedDict
from .helpers import prettify_name, get_mdict_item_or_list from .helpers import prettify_name, get_mdict_item_or_list
from .ajax import AjaxModelLoader from .ajax import AjaxModelLoader
# Used to generate filter query string name
filter_char_re = re.compile('[^a-z0-9 ]')
filter_compact_re = re.compile(' +')
class BaseModelView(BaseView, ActionsMixin): class BaseModelView(BaseView, ActionsMixin):
""" """
Base model view. Base model view.
...@@ -252,6 +258,15 @@ class BaseModelView(BaseView, ActionsMixin): ...@@ -252,6 +258,15 @@ class BaseModelView(BaseView, ActionsMixin):
column_filters = ('user', 'email') column_filters = ('user', 'email')
""" """
named_filter_urls = False
"""
Set to True to use human-readable names for filters in URL parameters.
False by default so as to be robust across translations.
Changing this parameter will break any existing URLs that have filters.
"""
column_display_pk = ObsoleteAttr('column_display_pk', column_display_pk = ObsoleteAttr('column_display_pk',
'list_display_pk', 'list_display_pk',
False) False)
...@@ -543,26 +558,23 @@ class BaseModelView(BaseView, ActionsMixin): ...@@ -543,26 +558,23 @@ class BaseModelView(BaseView, ActionsMixin):
if self.column_descriptions is None: if self.column_descriptions is None:
self.column_descriptions = dict() self.column_descriptions = dict()
# Group filters by field name
if self._filters: if self._filters:
self._filter_groups = [] self._filter_groups = OrderedDict()
self._filter_dict = dict() self._filter_args = {}
for i, n in enumerate(self._filters): for i, flt in enumerate(self._filters):
if n.name not in self._filter_dict: if flt.name not in self._filter_groups:
group = [] self._filter_groups[flt.name] = []
self._filter_dict[n.name] = group
self._filter_groups.append((n.name, group))
else:
group = self._filter_dict[n.name]
group.append((i, n.operation())) self._filter_groups[flt.name].append((i, flt))
self._filter_args[self.get_filter_arg(i, flt)] = (i, flt)
self._filter_types = dict((i, f.data_type) self._filter_data = self._serialize_filter_args()
for i, f in enumerate(self._filters)
if f.data_type)
else: else:
self._filter_groups = None self._filter_groups = None
self._filter_types = None self._filter_data = None
self._filter_args = None
# Form rendering rules # Form rendering rules
if self.form_create_rules: if self.form_create_rules:
...@@ -671,6 +683,7 @@ class BaseModelView(BaseView, ActionsMixin): ...@@ -671,6 +683,7 @@ class BaseModelView(BaseView, ActionsMixin):
""" """
return False return False
# Filter helpers
def scaffold_filters(self, name): def scaffold_filters(self, name):
""" """
Generate filter object for the given name Generate filter object for the given name
...@@ -715,6 +728,52 @@ class BaseModelView(BaseView, ActionsMixin): ...@@ -715,6 +728,52 @@ class BaseModelView(BaseView, ActionsMixin):
else: else:
return None return None
def get_filter_arg(self, index, flt):
"""
Given a filter `flt`, return a unique name for that filter in
this view.
Does not include the `flt[n]_` portion of the filter name.
:param index:
Filter index in _filters array
:param flt:
Filter instance
"""
if self.named_filter_urls:
name = ('%s %s' % (flt.name, flt.operation())).lower()
name = filter_char_re.sub('', name)
name = filter_compact_re.sub('_', name)
return name
else:
return str(index)
def _serialize_filter_args(self):
"""
Convert filter information into JSON-serializable object.
"""
if self._filters:
result = {}
for name, group in iteritems(self._filter_groups):
data = []
for idx, flt in group:
data.append({
'index': idx,
'arg': self.get_filter_arg(idx, flt),
'operation': flt.operation(),
'options': flt.get_options(self) or None,
'type': flt.data_type
})
result[name] = data
return result
return self._filters
# Form helpers
def scaffold_form(self): def scaffold_form(self):
""" """
Create `form.BaseForm` inherited class from the model. Must be Create `form.BaseForm` inherited class from the model. Must be
...@@ -948,43 +1007,42 @@ class BaseModelView(BaseView, ActionsMixin): ...@@ -948,43 +1007,42 @@ class BaseModelView(BaseView, ActionsMixin):
def get_empty_list_message(self): def get_empty_list_message(self):
return gettext('There are no items in the table.') return gettext('There are no items in the table.')
# URL generation helper # URL generation helpers
def _get_extra_args(self): def _get_list_filter_args(self):
"""
Return arguments from query string.
"""
page = request.args.get('page', 0, type=int)
sort = request.args.get('sort', None, type=int)
sort_desc = request.args.get('desc', None, type=int)
search = request.args.get('search', None)
# Gather filters
if self._filters: if self._filters:
sfilters = [] filters = []
for n in request.args: for n in request.args:
if n.startswith('flt'): if not n.startswith('flt'):
ofs = n.find('_') continue
if ofs == -1:
continue
try: if '_' not in n:
pos = int(n[3:ofs]) continue
idx = int(n[ofs + 1:])
except ValueError:
continue
if idx >= 0 and idx < len(self._filters): pos, key = n[3:].split('_', 1)
flt = self._filters[idx]
value = request.args[n] if key in self._filter_args:
idx, flt = self._filter_args[key]
if flt.validate(value): value = request.args[n]
sfilters.append((pos, (idx, flt.clean(value))))
filters = [v[1] for v in sorted(sfilters, key=lambda n: n[0])] if flt.validate(value):
else: filters.append((pos, (idx, flt.clean(value))))
filters = None
# Sort filters
return [v[1] for v in sorted(filters, key=lambda n: n[0])]
return None
def _get_list_extra_args(self):
"""
Return arguments from query string.
"""
page = request.args.get('page', 0, type=int)
sort = request.args.get('sort', None, type=int)
sort_desc = request.args.get('desc', None, type=int)
search = request.args.get('search', None)
filters = self._get_list_filter_args()
return page, sort, sort_desc, search, filters return page, sort, sort_desc, search, filters
...@@ -1016,9 +1074,11 @@ class BaseModelView(BaseView, ActionsMixin): ...@@ -1016,9 +1074,11 @@ class BaseModelView(BaseView, ActionsMixin):
kwargs = dict(page=page, sort=sort, desc=sort_desc, search=search) kwargs = dict(page=page, sort=sort, desc=sort_desc, search=search)
if filters: if filters:
for i, flt in enumerate(filters): for i, pair in enumerate(filters):
key = 'flt%d_%d' % (i, flt[0]) idx, value = pair
kwargs[key] = flt[1]
key = 'flt%d_%s' % (i, self.get_filter_arg(idx, self._filters[idx]))
kwargs[key] = value
return url_for(view, **kwargs) return url_for(view, **kwargs)
...@@ -1038,12 +1098,6 @@ class BaseModelView(BaseView, ActionsMixin): ...@@ -1038,12 +1098,6 @@ class BaseModelView(BaseView, ActionsMixin):
""" """
return rec_getattr(model, name) return rec_getattr(model, name)
def _get_filter_dict(self):
"""
Return flattened filter dictionary which can be JSON-serialized.
"""
return dict((as_unicode(k), v) for k, v in iteritems(self._filter_dict))
@contextfunction @contextfunction
def get_list_value(self, context, model, name): def get_list_value(self, context, model, name):
""" """
...@@ -1104,7 +1158,7 @@ class BaseModelView(BaseView, ActionsMixin): ...@@ -1104,7 +1158,7 @@ class BaseModelView(BaseView, ActionsMixin):
List view List view
""" """
# Grab parameters from URL # Grab parameters from URL
page, sort_idx, sort_desc, search, filters = self._get_extra_args() page, sort_idx, sort_desc, search, filters = self._get_list_extra_args()
# Map column index to column name # Map column index to column name
sort_column = self._get_column_by_idx(sort_idx) sort_column = self._get_column_by_idx(sort_idx)
...@@ -1120,18 +1174,6 @@ class BaseModelView(BaseView, ActionsMixin): ...@@ -1120,18 +1174,6 @@ class BaseModelView(BaseView, ActionsMixin):
if count % self.page_size != 0: if count % self.page_size != 0:
num_pages += 1 num_pages += 1
# Pregenerate filters
if self._filters:
filters_data = dict()
for idx, f in enumerate(self._filters):
flt_data = f.get_options(self)
if flt_data:
filters_data[idx] = flt_data
else:
filters_data = None
# Various URL generation helpers # Various URL generation helpers
def pager_url(p): def pager_url(p):
# Do not add page number if it is first page # Do not add page number if it is first page
...@@ -1186,9 +1228,7 @@ class BaseModelView(BaseView, ActionsMixin): ...@@ -1186,9 +1228,7 @@ class BaseModelView(BaseView, ActionsMixin):
search=search, search=search,
# Filters # Filters
filters=self._filters, filters=self._filters,
filter_groups=self._filter_groups, filter_groups=self._filter_data,
filter_types=self._filter_types,
filter_data=filters_data,
active_filters=filters, active_filters=filters,
# Actions # Actions
......
var AdminFilters = function(element, filters_element, operations, options, types) { var AdminFilters = function(element, filtersElement, filterGroups) {
var $root = $(element); var $root = $(element);
var $container = $('.filters', $root); var $container = $('.filters', $root);
var lastCount = 0; var lastCount = 0;
function getCount(name) { function getCount(name) {
var idx = name.indexOf('_'); var idx = name.indexOf('_');
return parseInt(name.substr(3, idx - 3));
if (idx === -1) {
return 0;
}
return parseInt(name.substr(3, idx - 3), 10);
}
function makeName(name) {
var result = 'flt' + lastCount + '_' + name;
lastCount += 1;
return result;
} }
function changeOperation() { function changeOperation() {
...@@ -23,44 +34,44 @@ var AdminFilters = function(element, filters_element, operations, options, types ...@@ -23,44 +34,44 @@ var AdminFilters = function(element, filters_element, operations, options, types
return false; return false;
} }
function addFilter(name, op) { function addFilter(name, subfilters) {
var $el = $('<tr />').appendTo($container); var $el = $('<tr />').appendTo($container);
// Filter list // Filter list
$el.append( $el.append(
$('<td/>').append( $('<td/>').append(
$('<a href="#" class="btn remove-filter" />') $('<a href="#" class="btn remove-filter" />')
.append($('<span class="close-icon">&times;</span>')) .append($('<span class="close-icon">&times;</span>'))
.append('&nbsp;') .append('&nbsp;')
.append(name) .append(name)
.click(removeFilter) .click(removeFilter)
) )
); );
// Filter type // Filter type
var $select = $('<select class="filter-op" />') var $select = $('<select class="filter-op" />')
.change(changeOperation); .change(changeOperation);
$(op).each(function() { $(subfilters).each(function() {
$select.append($('<option/>').attr('value', this[0]).text(this[1])); $select.append($('<option/>').attr('value', this.arg).text(this.operation));
}); });
$el.append( $el.append(
$('<td/>').append($select) $('<td/>').append($select)
); );
$select.select2({width: 'resolve'}); $select.select2({width: 'resolve'});
// Input // Input
var optId = op[0][0]; var filter = subfilters[0];
var $field; var $field;
if (optId in options) { if (filter.options) {
$field = $('<select class="filter-val" />') $field = $('<select class="filter-val" />')
.attr('name', 'flt' + lastCount + '_' + optId); .attr('name', makeName(filter.arg));
$(options[optId]).each(function() { $(filter.options).each(function() {
$field.append($('<option/>') $field.append($('<option/>')
.val(this[0]).text(this[1])); .val(this[0]).text(this[1]));
}); });
...@@ -70,22 +81,20 @@ var AdminFilters = function(element, filters_element, operations, options, types ...@@ -70,22 +81,20 @@ var AdminFilters = function(element, filters_element, operations, options, types
} else } else
{ {
$field = $('<input type="text" class="filter-val" />') $field = $('<input type="text" class="filter-val" />')
.attr('name', 'flt' + lastCount + '_' + optId); .attr('name', makeName(filter.arg));
$el.append($('<td/>').append($field)); $el.append($('<td/>').append($field));
} }
if (optId in types) { if (filter.type) {
$field.attr('data-role', types[optId]); $field.attr('data-role', filter.type);
faForm.applyStyle($field, types[optId]); faForm.applyStyle($field, filter.type);
} }
lastCount += 1;
} }
$('a.filter', filters_element).click(function() { $('a.filter', filtersElement).click(function() {
var name = $(this).text().trim(); var name = $(this).text().trim();
addFilter(name, operations[name]); addFilter(name, filterGroups[name]);
$('button', $root).show(); $('button', $root).show();
......
...@@ -5,7 +5,7 @@ ...@@ -5,7 +5,7 @@
<ul class="dropdown-menu field-filters"> <ul class="dropdown-menu field-filters">
{% for k in filter_groups %} {% for k in filter_groups %}
<li> <li>
<a href="javascript:void(0)" class="filter" onclick="return false;">{{ k[0] }}</a> <a href="javascript:void(0)" class="filter" onclick="return false;">{{ k }}</a>
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>
...@@ -21,31 +21,32 @@ ...@@ -21,31 +21,32 @@
</div> </div>
<table class="filters"> <table class="filters">
{%- for i, flt in enumerate(active_filters) -%} {%- for n, values in enumerate(active_filters) -%}
<tr> <tr>
{% set filter = admin_view._filters[flt[0]] %} {% set idx, value = values %}
{% set filter = filters[idx] %}
{% set filter_arg = admin_view.get_filter_arg(idx, filter) %}
<td> <td>
<a href="javascript:void(0)" class="btn remove-filter" title="{{ _gettext('Remove Filter') }}"> <a href="javascript:void(0)" class="btn remove-filter" title="{{ _gettext('Remove Filter') }}">
<span class="close-icon">&times;</span>&nbsp;{{ filters[flt[0]] }} <span class="close-icon">&times;</span>&nbsp;{{ filter.name }}
</a> </a>
</td> </td>
<td> <td>
<select class="filter-op" data-role="select2"> <select class="filter-op" data-role="select2">
{% for op in admin_view._filter_dict[filter.name] %} {% for op in filter_groups[filter.name] %}
<option value="{{ op[0] }}"{% if flt[0] == op[0] %} selected="selected"{% endif %}>{{ op[1] }}</option> <option value="{{ op['arg'] }}"{% if idx == op['index'] %} selected="selected"{% endif %}>{{ op['operation'] }}</option>
{% endfor %} {% endfor %}
</select> </select>
</td> </td>
<td> <td>
{%- set data = filter_data.get(flt[0]) -%} {%- if filter.options -%}
{%- if data -%} <select name="flt{{n}}_{{ filter_arg }}" class="filter-val" data-role="select2">
<select name="flt{{ i }}_{{ flt[0] }}" class="filter-val" data-role="select2"> {%- for d in filter.options %}
{%- for d in data %} <option value="{{ d[0] }}"{% if value == d[0] %} selected{% endif %}>{{ d[1] }}</option>
<option value="{{ d[0] }}"{% if flt[1] == d[0] %} selected{% endif %}>{{ d[1] }}</option>
{%- endfor %} {%- endfor %}
</select> </select>
{%- else -%} {%- else -%}
<input name="flt{{ i }}_{{ flt[0] }}" type="text" value="{{ flt[1] or '' }}" class="filter-val"{% if flt[0] in filter_types %} data-role="{{ filter_types[flt[0]] }}"{% endif %}></input> <input name="flt{{n}}_{{ filter_arg }}" type="text" value="{{ value or '' }}" class="filter-val"{% if filter.data_type %} data-role="{{ filter.data_type }}"{% endif %}></input>
{%- endif -%} {%- endif -%}
</td> </td>
</tr> </tr>
......
...@@ -22,7 +22,7 @@ ...@@ -22,7 +22,7 @@
</li> </li>
{% endif %} {% endif %}
{% if filter_groups %} {% if filters %}
<li class="dropdown"> <li class="dropdown">
{{ model_layout.filter_options() }} {{ model_layout.filter_options() }}
</li> </li>
...@@ -42,7 +42,7 @@ ...@@ -42,7 +42,7 @@
</ul> </ul>
{% endblock %} {% endblock %}
{% if filter_groups %} {% if filters %}
{{ model_layout.filter_form() }} {{ model_layout.filter_form() }}
<div class="clearfix"></div> <div class="clearfix"></div>
{% endif %} {% endif %}
...@@ -159,12 +159,10 @@ ...@@ -159,12 +159,10 @@
html: true, html: true,
placement: 'bottom' placement: 'bottom'
}); });
{% if filter_groups is not none and filter_data is not none %} {% if filter_groups %}
var filter = new AdminFilters( var filter = new AdminFilters(
'#filter_form', '.field-filters', '#filter_form', '.field-filters',
{{ admin_view._get_filter_dict()|tojson|safe }}, {{ filter_groups|tojson|safe }}
{{ filter_data|tojson|safe }},
{{ filter_types|tojson|safe }}
); );
{% endif %} {% endif %}
})(jQuery); })(jQuery);
......
...@@ -233,61 +233,71 @@ def test_column_filters(): ...@@ -233,61 +233,71 @@ def test_column_filters():
eq_(len(view._filters), 4) eq_(len(view._filters), 4)
eq_(view._filter_dict, { eq_([(f['index'], f['operation']) for f in view._filter_data[u'Test1']],
u'Test1': [ [
(0, u'equals'), (0, u'equals'),
(1, u'not equal'), (1, u'not equal'),
(2, u'contains'), (2, u'contains'),
(3, u'not contains') (3, u'not contains')
]}) ])
# Test filter that references property # Test filter that references property
view = CustomModelView(Model2, db.session, view = CustomModelView(Model2, db.session,
column_filters=['model1']) column_filters=['model1'])
eq_(view._filter_dict, { eq_([(f['index'], f['operation']) for f in view._filter_data[u'Model1 / Test1']],
u'Model1 / Test1': [ [
(0, u'equals'), (0, u'equals'),
(1, u'not equal'), (1, u'not equal'),
(2, u'contains'), (2, u'contains'),
(3, u'not contains') (3, u'not contains')
], ])
'Model1 / Test2': [
eq_([(f['index'], f['operation']) for f in view._filter_data[u'Model1 / Test2']],
[
(4, 'equals'), (4, 'equals'),
(5, 'not equal'), (5, 'not equal'),
(6, 'contains'), (6, 'contains'),
(7, 'not contains') (7, 'not contains')
], ])
u'Model1 / Test3': [
eq_([(f['index'], f['operation']) for f in view._filter_data[u'Model1 / Test3']],
[
(8, u'equals'), (8, u'equals'),
(9, u'not equal'), (9, u'not equal'),
(10, u'contains'), (10, u'contains'),
(11, u'not contains') (11, u'not contains')
], ])
u'Model1 / Test4': [
eq_([(f['index'], f['operation']) for f in view._filter_data[u'Model1 / Test4']],
[
(12, u'equals'), (12, u'equals'),
(13, u'not equal'), (13, u'not equal'),
(14, u'contains'), (14, u'contains'),
(15, u'not contains') (15, u'not contains')
], ])
u'Model1 / Bool Field': [
eq_([(f['index'], f['operation']) for f in view._filter_data[u'Model1 / Bool Field']],
[
(16, u'equals'), (16, u'equals'),
(17, u'not equal'), (17, u'not equal'),
], ])
u'Model1 / Enum Field': [
eq_([(f['index'], f['operation']) for f in view._filter_data[u'Model1 / Enum Field']],
[
(18, u'equals'), (18, u'equals'),
(19, u'not equal'), (19, u'not equal'),
]}) ])
# Test filter with a dot # Test filter with a dot
view = CustomModelView(Model2, db.session, view = CustomModelView(Model2, db.session,
column_filters=['model1.bool_field']) column_filters=['model1.bool_field'])
eq_(view._filter_dict, { eq_([(f['index'], f['operation']) for f in view._filter_data[u'Model1 / Bool Field']],
'Model1 / Bool Field': [ [
(0, 'equals'), (0, 'equals'),
(1, 'not equal'), (1, 'not equal'),
]}) ])
# Fill DB # Fill DB
model1_obj1 = Model1('model1_obj1', bool_field=True) model1_obj1 = Model1('model1_obj1', bool_field=True)
...@@ -324,11 +334,15 @@ def test_column_filters(): ...@@ -324,11 +334,15 @@ def test_column_filters():
column_filters=['int_field']) column_filters=['int_field'])
admin.add_view(view) admin.add_view(view)
eq_(view._filter_dict, {'Int Field': [(0, 'equals'), (1, 'not equal'), eq_([(f['index'], f['operation']) for f in view._filter_data[u'Int Field']],
(2, 'greater than'), (3, 'smaller than')]}) [
(0, 'equals'),
(1, 'not equal'),
(2, 'greater than'),
(3, 'smaller than')
])
#Test filters to joined table field # Test filters to joined table field
view = CustomModelView( view = CustomModelView(
Model2, db.session, Model2, db.session,
endpoint='_model2', endpoint='_model2',
...@@ -349,6 +363,21 @@ def test_column_filters(): ...@@ -349,6 +363,21 @@ def test_column_filters():
ok_('model2_obj3' not in data) ok_('model2_obj3' not in data)
ok_('model2_obj4' not in data) ok_('model2_obj4' not in data)
# Test human readable URLs
view = CustomModelView(
Model1, db.session,
column_filters=['test1'],
endpoint='_model3',
named_filter_urls=True
)
admin.add_view(view)
rv = client.get('/admin/_model3/?flt1_test1_equals=model1_obj1')
eq_(rv.status_code, 200)
data = rv.data.decode('utf-8')
ok_('model1_obj1' in data)
ok_('model1_obj2' not in data)
def test_url_args(): def test_url_args():
app, db, admin = setup() app, db, admin = setup()
......
...@@ -302,8 +302,8 @@ def test_column_filters(): ...@@ -302,8 +302,8 @@ def test_column_filters():
eq_(view._filters[0].name, 'col1') eq_(view._filters[0].name, 'col1')
eq_(view._filters[1].name, 'col2') eq_(view._filters[1].name, 'col2')
eq_(view._filter_dict, {'col1': [(0, 'test')], eq_([(f['index'], f['operation']) for f in view._filter_data[u'col1']], [(0, 'test')])
'col2': [(1, 'test')]}) eq_([(f['index'], f['operation']) for f in view._filter_data[u'col2']], [(1, 'test')])
# TODO: Make calls with filters # TODO: Make calls with filters
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment