Commit 73ed6524 authored by Serge S. Koval's avatar Serge S. Koval

Started working on the filters.

parent 0edfb4a9
......@@ -3,9 +3,9 @@
- Calendar - add validation for time without seconds (automatically add seconds)
- Model Admin
- Ability to sort by fields that are not visible?
- Exclude for list columns
- Exclude for form fields
- List display callables
- Search
- Rename init_search
- Built-in filtering support
- Configurable operations (=, >, <, etc)
- Callable operations
......@@ -20,5 +20,6 @@
- Header title
- Mass-delete functionality
- File size restriction
- Localization
- Unit tests
- Documentation
......@@ -3,6 +3,7 @@ from flaskext.sqlalchemy import SQLAlchemy
from flask.ext import adminex, wtf
from flask.ext.adminex.ext import sqlamodel
from flask.ext.adminex.ext.sqlamodel import filters
# Create application
app = Flask(__name__)
......@@ -61,6 +62,8 @@ class PostAdmin(sqlamodel.ModelView):
searchable_columns = ('title', User.username)
column_filters = (User.username, 'title', 'date', filters.FilterLike(Post.title, 'Fixed Title', options=(('test1', 'Test 1'), ('test2', 'Test 2'))))
# Pass arguments to WTForms. In this case, change label for text field to
# be 'Big Text' and add required() validator.
form_args = dict(
......@@ -84,4 +87,4 @@ if __name__ == '__main__':
# Start app
app.debug = True
app.run()
app.run('0.0.0.0', 8000)
from flask.ext.adminex.model import filters
from flask.ext.adminex.ext.sqlamodel import tools
class BaseSQLAFilter(filters.BaseFilter):
def __init__(self, column, name, options=None, data_type=None):
super(BaseSQLAFilter, self).__init__(name, options, data_type)
self.column = column
# Common filters
class FilterEqual(BaseSQLAFilter):
def apply(self, query, value):
return query.filter(self.column == value)
def __unicode__(self):
return '%s equals' % self.name
class FilterNotEqual(BaseSQLAFilter):
def apply(self, query, value):
return query.filter(self.column != value)
def __unicode__(self):
return '%s not equal' % self.name
class FilterLike(BaseSQLAFilter):
def apply(self, query, value):
stmt = tools.parse_like_term(value)
return query.filter(self.column.ilike(stmt))
def __unicode__(self):
return '%s like' % self.name
class FilterNotLike(BaseSQLAFilter):
def apply(self, query, value):
stmt = tools.parse_like_term(value)
return query.filter(~self.column.ilike(stmt))
def __unicode__(self):
return '%s not like' % self.name
class FilterGreater(BaseSQLAFilter):
def apply(self, query, value):
return query.filter(self.column > value)
def __unicode__(self):
return '%s greater than' % self.name
class FilterSmaller(BaseSQLAFilter):
def apply(self, query, value):
return query.filter(self.column < value)
def __unicode__(self):
return '%s smaller than' % self.name
# Customized type filters
class BooleanEqualFilter(FilterEqual, filters.BaseBooleanFilter):
pass
class BooleanNotEqualFilter(FilterNotEqual, filters.BaseBooleanFilter):
pass
# Base SQLA filter field converter
class FilterConverter(filters.BaseFilterConverter):
strings = (FilterEqual, FilterNotEqual, FilterLike, FilterNotLike)
numeric = (FilterEqual, FilterNotEqual, FilterGreater, FilterSmaller)
def convert(self, type_name, column, name):
if type_name in self.converters:
return self.converters[type_name](column, name)
return None
@filters.convert('String', 'Unicode', 'Text', 'UnicodeText')
def conv_string(self, column, name):
return [f(column, name) for f in self.strings]
@filters.convert('Boolean')
def conv_bool(self, column, name):
return [BooleanEqualFilter(column, name),
BooleanNotEqualFilter(column, name)]
@filters.convert('Integer', 'SmallInteger', 'Numeric', 'Float')
def conv_int(self, column, name):
return [f(column, name) for f in self.numeric]
@filters.convert('Date')
def conv_date(self, column, name):
return [f(column, name, data_type='datepicker') for f in self.numeric]
@filters.convert('DateTime')
def conv_datetime(self, column, name):
return [f(column, name, data_type='datetimepicker') for f in self.numeric]
def parse_like_term(term):
if term.startswith('^'):
stmt = '%s%%' % term[1:]
elif term.startswith('='):
stmt = term[1:]
else:
stmt = '%%%s%%' % term
return stmt
......@@ -12,6 +12,7 @@ from flask import flash
from flask.ext.adminex import form
from flask.ext.adminex.model import BaseModelView
from flask.ext.adminex.ext.sqlamodel import filters, tools
class Unique(object):
......@@ -220,6 +221,11 @@ class ModelView(BaseModelView):
For example, if you entered *=ZZZ*, *ILIKE 'ZZZ'* statement will be used.
"""
filter_converter = filters.FilterConverter()
"""
TBD:
"""
def __init__(self, model, session,
name=None, category=None, endpoint=None, url=None):
"""
......@@ -241,8 +247,9 @@ class ModelView(BaseModelView):
self.session = session
self._search_fields = None
self._search_joins = None
self._search_joins_names = None
self._search_joins_names = set()
self._filter_joins_names = set()
super(ModelView, self).__init__(model, name, category, endpoint, url)
......@@ -312,6 +319,23 @@ class ModelView(BaseModelView):
return columns
def _get_columns_for_field(self, field):
if isinstance(field, basestring):
attr = getattr(self.model, field, None)
if field is None:
raise Exception('Field %s was not found.' % field)
else:
attr = field
if (not attr or
not hasattr(attr, 'property') or
not hasattr(attr.property, 'columns') or
not attr.property.columns):
raise Exception('Invalid field %s: does not contains any columns.' % field)
return attr.property.columns
def init_search(self):
"""
Initialize search. Returns `True` if search is supported for this
......@@ -322,23 +346,10 @@ class ModelView(BaseModelView):
"""
if self.searchable_columns:
self._search_fields = []
self._search_joins = []
self._search_joins_names = set()
for p in self.searchable_columns:
# If item is a stirng, resolve it as an attribute
if isinstance(p, basestring):
attr = getattr(self.model, p, None)
else:
attr = p
# Only column searches are supported
if (not attr or
not hasattr(attr, 'property') or
not hasattr(attr.property, 'columns')):
raise Exception('Invalid searchable column "%s"' % p)
for column in attr.property.columns:
for column in self._get_columns_for_field(p):
column_type = type(column.type).__name__
if not self.is_text_column_type(column_type):
......@@ -349,7 +360,6 @@ class ModelView(BaseModelView):
# If it belongs to different table - add a join
if column.table != self.model.__table__:
self._search_joins.append(column.table)
self._search_joins_names.add(column.table.name)
return bool(self.searchable_columns)
......@@ -363,6 +373,31 @@ class ModelView(BaseModelView):
return (name == 'String' or name == 'Unicode' or
name == 'Text' or name == 'UnicodeText')
def scaffold_filters(self, name):
columns = self._get_columns_for_field(name)
if len(columns) > 1:
raise Exception('Can not filter more than on one column for %s' % name)
column = columns[0]
if not isinstance(name, basestring):
visible_name = self.get_column_name(name.property.key)
else:
visible_name = self.get_column_name(name)
type_name = type(column.type).__name__
flt = self.filter_converter.convert(type_name,
column,
visible_name)
if flt:
# If there's relation to other table, do it
if column.table != self.model.__table__:
self._filter_joins_names.add(column.table.name)
return flt
def scaffold_form(self):
"""
Create form from the model.
......@@ -395,7 +430,7 @@ class ModelView(BaseModelView):
return joined
# Database-related API
def get_list(self, page, sort_column, sort_desc, search, execute=True):
def get_list(self, page, sort_column, sort_desc, search, filters, execute=True):
"""
Return models from the database.
......@@ -409,6 +444,8 @@ class ModelView(BaseModelView):
Search query
`execute`
Execute query immediately? Default is `True`
`filters`
List of filter tuples
"""
# Will contain names of joined tables to avoid duplicate joins
......@@ -416,11 +453,11 @@ class ModelView(BaseModelView):
query = self.session.query(self.model)
# Apply search before counting results
# Apply search criteria
if self._search_supported and search:
# Apply search-related joins
if self._search_joins:
query = query.join(*self._search_joins)
if self._search_joins_names:
query = query.join(*self._search_joins_names)
joins |= self._search_joins_names
# Apply terms
......@@ -430,16 +467,24 @@ class ModelView(BaseModelView):
if not term:
continue
if term.startswith('^'):
stmt = '%s%%' % term[1:]
elif term.startswith('='):
stmt = term[1:]
else:
stmt = '%%%s%%' % term
stmt = tools.parse_like_term(term)
filter_stmt = [c.ilike(stmt) for c in self._search_fields]
query = query.filter(or_(*filter_stmt))
# Apply filters
if self._filters:
# Apply search-related joins
if self._filter_joins_names:
new_joins = self._filter_joins_names - joins
if new_joins:
query = query.join(*new_joins)
joins |= self._search_joins_names
# Apply filters
for flt, value in filters:
query = self._filters[flt].apply(query, value)
# Calculate number of rows
count = query.count()
......
from .base import BaseModelView
from itertools import count
from flask import request, url_for, redirect, flash
from .base import BaseView, expose
from flask.ext.adminex.base import BaseView, expose
from flask.ext.adminex.model import filters
class BaseModelView(BaseView):
......@@ -98,8 +101,8 @@ class BaseModelView(BaseView):
searchable_columns = None
"""
Collection of the searchable columns. It is assumed that only
text-only fields are searchable, but it is up for a model implementation
to make decision.
text-only fields are searchable, but it is up for a model
implementation to make decision.
For example::
......@@ -107,6 +110,13 @@ class BaseModelView(BaseView):
searchable_columns = ('name', 'email')
"""
column_filters = None
"""
Collection of the column filters.
TBD: Doc
"""
form_columns = None
"""
Collection of the model field names for the form. If set to `None` will
......@@ -186,14 +196,29 @@ class BaseModelView(BaseView):
"""
Refresh various cached variables.
"""
# List view
self._list_columns = self.get_list_columns()
self._sortable_columns = self.get_sortable_columns()
# Forms
self._create_form_class = self.get_create_form()
self._edit_form_class = self.get_edit_form()
# Search
self._search_supported = self.init_search()
# Filters
self._filters = self.get_filters()
if self._filters:
self._filter_names = [unicode(n) for n in self._filters]
self._filter_types = dict((i, f.data_type)
for i, f in enumerate(self._filters)
if f.data_type)
else:
self._filter_names = None
self._filter_types = None
# Public API
def scaffold_list_columns(self):
"""
......@@ -207,26 +232,30 @@ class BaseModelView(BaseView):
"""
raise NotImplemented('Please implement scaffold_list_columns method')
def get_column_name(self, field):
"""
Return human-readable column name.
`field`
Model field name.
"""
if self.rename_columns and field in self.rename_columns:
return self.rename_columns[field]
else:
return self.prettify_name(field)
def get_list_columns(self):
"""
Returns list of the model field names. If `list_columns` was
set, returns it. Otherwise calls `scaffold_list_columns`
to generate list from the model.
"""
result = []
if self.list_columns is None:
columns = self.scaffold_list_columns()
else:
columns = self.list_columns
for c in columns:
if self.rename_columns and c in self.rename_columns:
result.append((c, self.rename_columns[c]))
else:
result.append((c, self.prettify_name(c)))
return result
return [(c, self.get_column_name(c)) for c in columns]
def scaffold_sortable_columns(self):
"""
......@@ -266,10 +295,51 @@ class BaseModelView(BaseView):
"""
return False
def scaffold_filter(self, name):
"""
Generate filter object for the given name
`name`
Name of the field
"""
return None
def is_valid_filter(self, filter):
"""
Verify that provided filter object is valid.
`filter`
Filter object to verify.
"""
return isinstance(filter, filters.BaseFilter)
def get_filters(self):
"""
Return list of filter objects.
"""
if self.column_filters:
collection = []
for n in self.column_filters:
if not self.is_valid_filter(n):
flt = self.scaffold_filters(n)
if flt:
collection.extend(flt)
else:
raise Exception('Unsupported filter type %s' % n)
else:
collection.append(n)
print collection
return collection
else:
return None
def scaffold_form(self):
"""
Create `form.BaseForm` inherited class from the model. Must be implemented in
the child class.
Create `form.BaseForm` inherited class from the model. Must be
implemented in the child class.
"""
raise NotImplemented('Please implement scaffold_form method')
......@@ -325,7 +395,7 @@ class BaseModelView(BaseView):
return self._list_columns[idx]
# Database-related API
def get_list(self, page, sort_field, sort_desc, search):
def get_list(self, page, sort_field, sort_desc, search, filters):
"""
Return list of models from the data source with applied pagination
and sorting.
......@@ -340,6 +410,9 @@ class BaseModelView(BaseView):
If set to True, sorting is in descending order.
`search`
Search query
`filters`
List of filter tuples. First value in a tuple is a search
index, second value is a search value.
"""
raise NotImplemented('Please implement get_list method')
......@@ -418,9 +491,28 @@ class BaseModelView(BaseView):
sort_desc = request.args.get('desc', None, type=int)
search = request.args.get('search', None)
return page, sort, sort_desc, search
# Gather filters
if self._filters:
filters = []
for n in count():
param = 'flt%d' % n
if param not in request.args:
break
def _get_url(self, view=None, page=None, sort=None, sort_desc=None, search=None):
idx = request.args.get(param, None, type=int)
value = request.args.get(param + 'v', None)
if idx >= 0 and idx < len(self._filters):
if self._filters[idx].validate(value):
filters.append((idx, value))
else:
filters = None
return page, sort, sort_desc, search, filters
def _get_url(self, view=None, page=None, sort=None, sort_desc=None,
search=None, filters=None):
"""
Generate page URL with current page, sort column and
other parameters.
......@@ -435,6 +527,8 @@ class BaseModelView(BaseView):
Use descending sorting order
`search`
Search query
`filters`
List of active filters
"""
if not search:
search = None
......@@ -442,11 +536,16 @@ class BaseModelView(BaseView):
if not page:
page = None
return url_for(view,
page=page,
sort=sort,
desc=sort_desc,
search=search)
kwargs = dict(page=page, sort=sort, desc=sort_desc, search=search)
if filters:
for i, flt in enumerate(filters):
base = 'flt%d' % i
kwargs[base] = flt[0]
kwargs[base + 'v'] = flt[1]
return url_for(view, **kwargs)
# Views
@expose('/')
......@@ -455,7 +554,7 @@ class BaseModelView(BaseView):
List view
"""
# Grab parameters from URL
page, sort_idx, sort_desc, search = self._get_extra_args()
page, sort_idx, sort_desc, search, filters = self._get_extra_args()
# Map column index to column name
sort_column = self._get_column_by_idx(sort_idx)
......@@ -463,20 +562,34 @@ class BaseModelView(BaseView):
sort_column = sort_column[0]
# Get count and data
count, data = self.get_list(page, sort_column, sort_desc, search)
count, data = self.get_list(page, sort_column, sort_desc,
search, filters)
# Calculate number of pages
num_pages = count / self.page_size
if count % self.page_size != 0:
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
def pager_url(p):
# Do not add page number if it is first page
if p == 0:
p = None
return self._get_url('.index_view', p, sort_idx, sort_desc, search)
return self._get_url('.index_view', p, sort_idx, sort_desc,
search, filters)
def sort_url(column, invert=False):
desc = None
......@@ -484,7 +597,8 @@ class BaseModelView(BaseView):
if invert and not sort_desc:
desc = 1
return self._get_url('.index_view', page, column, desc, search)
return self._get_url('.index_view', page, column, desc,
search, filters)
def get_value(obj, field):
return getattr(obj, field, None)
......@@ -495,12 +609,14 @@ class BaseModelView(BaseView):
list_columns=self._list_columns,
sortable_columns=self._sortable_columns,
# Stuff
enumerate=enumerate,
get_value=get_value,
return_url=self._get_url('.index_view',
page,
sort_idx,
sort_desc,
search),
search,
filters),
# Pagination
pager_url=pager_url,
num_pages=num_pages,
......@@ -514,9 +630,13 @@ class BaseModelView(BaseView):
clear_search_url=self._get_url('.index_view',
None,
sort_idx,
sort_desc,
None),
search=search
sort_desc),
search=search,
# Filters
filter_names=self._filter_names,
filter_types=self._filter_types,
filter_data=filters_data,
active_filters=filters
)
@expose('/new/', methods=('GET', 'POST'))
......
class BaseFilter(object):
def __init__(self, name, options=None, data_type=None):
self.name = name
self.options = options
self.data_type = data_type
def get_options(self, view):
return self.options
def validate(self, value):
return True
def apply(self, query):
raise NotImplemented()
def __unicode__(self):
return self.name
# Customized filters
class BaseBooleanFilter(BaseFilter):
def __init__(self, name, data_type=None):
super(BaseBooleanFilter, self).__init__(name,
(('1', 'Yes'), ('0', 'No')),
data_type)
def validate(self, value):
return value == '0' or value == '1'
class BaseDateFilter(BaseFilter):
def __init__(self, name, options=None):
super(BaseDateFilter, self).__init__(name,
options,
data_type='datepicker')
def validate(self, value):
# TODO: Validation
return True
class BaseDateTimeFilter(BaseFilter):
def __init__(self, name, options=None):
super(BaseDateTimeFilter, self).__init__(name,
options,
data_type='datetimepicker')
def validate(self, value):
# TODO: Validation
return True
def convert(*args):
def _inner(func):
print args
func._converter_for = args
return func
return _inner
class BaseFilterConverter(object):
def __init__(self):
self.converters = dict()
for p in dir(self):
attr = getattr(self, p)
if hasattr(attr, '_converter_for'):
for p in attr._converter_for:
self.converters[p] = attr
/* Body */
/* Global styles */
body
{
padding-top: 50px;
}
/* Form customizations */
form.icon {
display: inline;
}
......@@ -19,3 +20,18 @@ form.icon button {
a.icon {
text-decoration: none;
}
/* Filters */
.filter-row {
margin: 4px;
}
.filter-row a, .filter-row select {
margin-right: 4px;
}
.filter-row input
{
margin-bottom: 0px;
width: 208px;
}
\ No newline at end of file
var Filters = function(element, operations, options, types) {
var $root = $(element)
var $container = $('#filters');
var count = $('#filters>div', $root).length;
function appendValueControl(element, id, optionId) {
var field;
// Conditionally generate select or textbox
if (optionId in options) {
field = $('<select class="filter-val" />').attr('name', 'flt' + id + 'v');
$(options[optionId]).each(function() {
field.append($('<option/>').val(this[0]).text(this[1]));
});
} else
{
field = $('<input type="text" class="filter-val" />').attr('name', 'flt' + id + 'v');
}
$(element).append(field);
if (optionId in options)
field.chosen();
if (optionId in types) {
field.attr('data-role', types[optionId]);
adminForm.applyStyle(field, types[optionId]);
}
}
function addFilter() {
var node = $('<div class="filter-row" />').attr('id', 'fltdiv' + count).appendTo($container);
$('<a href="#" class="remove-filter" />')
.append('<i class="icon-remove"/>')
.click(removeFilter)
.appendTo(node);
var operation = $('<select class="filter-op" />')
.attr('name', 'flt' + count)
.change(changeOperation)
.appendTo(node);
var index = 0;
$(operations).each(function() {
operation.append($('<option/>').val(index).text(this.toString()));
index++;
});
operation.chosen();
appendValueControl(node, count, 0);
count += 1;
$('button', $root).show();
return false;
}
function removeFilter() {
var row = $(this).parent();
var idx = parseInt(row.attr('id').substr(6));
// Remove row
row.remove();
// Renumber any rows that are after
for (var i = idx + 1; i < count; ++i) {
row = $('#fltdiv' + i);
row.attr('id', 'fltdiv' + (i - 1));
$('.filter-op', row).attr('name', 'flt' + (i - 1));
$('.filter-val', row).attr('name', 'flt' + (i - 1) + 'v');
}
count -= 1;
$('button', $root).show();
return false;
}
function changeOperation() {
var row = $(this).parent();
var rowIdx = parseInt(row.attr('id').substr(6));
// Get old value field
var oldValue = $('.filter-val', row);
var oldValueId = oldValue.attr('id');
// Delete old value
oldValue.remove();
if (oldValueId != null)
$('div#' + oldValueId + '_chzn', row).remove();
var optId = $(this).val();
appendValueControl(row, rowIdx, optId);
$('button', $root).show();
};
$('#add_filter', $root).click(addFilter);
$('.remove-filter', $root).click(removeFilter);
$('.filter-op').change(changeOperation);
$('.filter-val').change(function() {
$('button', $root).show();
});
};
$(function() {
var adminForm = new function() {
this.applyStyle = function(el, name) {
switch (name) {
case 'chosen':
$(el).chosen();
break;
case 'chosenblank':
$(el).chosen({allow_single_deselect: true});
break;
case 'datepicker':
$(el).datepicker();
break;
case 'datetimepicker':
$(el).datepicker({displayTime: true});
break;
};
}
// Apply automatic styles
$('[data-role=chosen]').chosen();
$('[data-role=chosenblank]').chosen({allow_single_deselect: true});
$('[data-role=datepicker]').datepicker();
$('[data-role=datetimepicker]').datepicker({displayTime: true});
});
}
{% extends 'admin/master.html' %}
{% import 'admin/lib.html' as lib %}
{% block head %}
<link href="{{ url_for('admin.static', filename='chosen/chosen.css') }}" rel="stylesheet">
<link href="{{ url_for('admin.static', filename='css/datepicker.css') }}" rel="stylesheet">
{% endblock %}
{% block body %}
{% if search_supported %}
<form method="GET" action="{{ return_url }}" class="well form-search">
......@@ -20,6 +25,38 @@
</form>
{% endif %}
{% if filter_names %}
<form id="filter_form" method="GET" action="{{ return_url }}" class="well">
<div id="filters">
{%- for idx, flt in enumerate(active_filters) -%}
<div id="fltdiv{{ idx }}" class="filter-row">
<a href="#" class="remove-filter"><i class="icon-remove"></i></a><select name="flt{{ idx }}" class="filter-op" data-role="chosen">
{% for optidx, opt in enumerate(filter_names) -%}
<option value="{{ optidx }}"{% if flt[0] == optidx %} selected="selected"{% endif %}>{{ opt }}</option>
{%- endfor %}
</select>
{%- set data = filter_data.get(flt[0]) -%}
{%- if data -%}
<select name="flt{{ idx }}v" class="filter-val" data-role="chosen">
{%- for opt in data %}
<option value="{{ opt[0] }}"{% if flt[1] == opt[0] %} selected{% endif %}>{{ opt[1] }}</option>
{%- endfor %}
</select>
{%- else -%}
<input name="flt{{ idx }}v" type="text" value="{{ flt[1] or '' }}" class="filter-val"{% if flt[0] in filter_types %} data-role="{{ filter_types[flt[0]] }}"{% endif %}></input>
{%- endif -%}
</div>
{%- endfor %}
</div>
{% if active_filters %}
<a href="{{ clear_search_url }}" class="btn">Reset Filters</a>
{% endif %}
<a id="add_filter" href="#" class="btn">Add Filter</a>
<button type="submit" class="btn" style="display: none">Apply</button>
</form>
{% endif %}
<table class="table table-striped table-bordered model-list">
<thead>
<tr>
......@@ -75,3 +112,17 @@
<a class="btn btn-primary btn-large" href="{{ url_for('.create_view', url=return_url) }}">Create New</a>
{% endif %}
{% endblock %}
{% block tail %}
<script src="{{ url_for('admin.static', filename='js/bootstrap-datepicker.js') }}"></script>
<script src="{{ url_for('admin.static', filename='js/form.js') }}"></script>
<script src="{{ url_for('admin.static', filename='js/filters.js') }}"></script>
{% if filter_names is not none and filter_data is not none %}
<script language="javascript">
var filter = new Filters('#filter_form',
{{ filter_names|tojson|safe }},
{{ filter_data|tojson|safe }},
{{ filter_types|tojson|safe }});
</script>
{% endif %}
{% endblock %}
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