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
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