Commit d3dddbf6 authored by Sergey Markelov's avatar Sergey Markelov

Merge remote-tracking branch 'remotes/upstream/master'

parents 18267649 78a66cda
......@@ -18,6 +18,7 @@ venv
__pycache__
examples/sqla-inline/static
examples/file/files
examples/forms/files
.DS_Store
.idea/
*.sqlite
......@@ -274,7 +274,7 @@ administrative views.
File Admin
----------
Flask-Admin comes with another handy battery - file admin. It gives you ability to manage files on your server
Flask-Admin comes with another handy battery - file admin. It gives you the ability to manage files on your server
(upload, delete, rename, etc).
Here is simple example::
......
......@@ -51,6 +51,10 @@ def expose_plugview(url='/'):
# Base views
def _wrap_view(f):
# Avoid wrapping view method twice
if hasattr(f, '_wrapped'):
return f
@wraps(f)
def inner(self, *args, **kwargs):
# Store current admin view
......@@ -63,6 +67,8 @@ def _wrap_view(f):
return f(self, *args, **kwargs)
inner._wrapped = True
return inner
......
......@@ -134,7 +134,7 @@ def process_ajax_references(references, view):
field = getattr(model, name, None)
if not field:
raise ValueError('Invalid subdocument field %s.%s')
raise ValueError('Invalid subdocument field %s.%s' % (model, name))
handle_field(field, doc, make_name(base, name))
......
import re
def parse_like_term(term):
"""
Parse search term into (operation, term) tuple
......@@ -6,8 +8,8 @@ def parse_like_term(term):
Search term
"""
if term.startswith('^'):
return '^%s' % term[1]
return '^{}'.format(re.escape(term[1:]))
elif term.startswith('='):
return '^%s$' % term[1:]
return '^{}$'.format(re.escape(term[1:]))
return '%s' % term
return re.escape(term)
......@@ -100,7 +100,7 @@ class FilterConverter(filters.BaseFilterConverter):
return None
@filters.convert('string', 'unicode', 'text', 'unicodetext')
@filters.convert('string', 'unicode', 'text', 'unicodetext', 'varchar')
def conv_string(self, column, name, **kwargs):
return [f(column, name, **kwargs) for f in self.strings]
......
......@@ -262,10 +262,12 @@ class ModelView(BaseModelView):
self.session = session
self._search_fields = None
self._search_joins = dict()
self._search_joins = []
self._filter_joins = dict()
self._sortable_joins = dict()
if self.form_choices is None:
self.form_choices = {}
......@@ -293,6 +295,41 @@ class ModelView(BaseModelView):
return model._sa_class_manager.mapper.iterate_properties
def _get_columns_for_field(self, field):
if (not field or
not hasattr(field, 'property') or
not hasattr(field.property, 'columns') or
not field.property.columns):
raise Exception('Invalid field %s: does not contains any columns.' % field)
return field.property.columns
def _get_field_with_path(self, name):
join_tables = []
if isinstance(name, string_types):
model = self.model
for attribute in name.split('.'):
value = getattr(model, attribute)
if (hasattr(value, 'property') and
hasattr(value.property, 'direction')):
model = value.property.mapper.class_
table = model.__table__
if self._need_join(table):
join_tables.append(table)
attr = value
else:
attr = name
return join_tables, attr
def _need_join(self, table):
return table not in self.model._sa_class_manager.mapper.tables
# Scaffolding
def scaffold_pk(self):
"""
......@@ -370,25 +407,35 @@ class ModelView(BaseModelView):
return columns
def _get_columns_for_field(self, field):
if isinstance(field, string_types):
attr = getattr(self.model, field, None)
def get_sortable_columns(self):
"""
Returns a dictionary of the sortable columns. Key is a model
field name and value is sort column (for example - attribute).
if field is None:
raise Exception('Field %s was not found.' % field)
If `column_sortable_list` is set, will use it. Otherwise, will call
`scaffold_sortable_columns` to get them from the model.
"""
self._sortable_joins = dict()
if self.column_sortable_list is None:
return self.scaffold_sortable_columns()
else:
attr = field
result = dict()
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)
for c in self.column_sortable_list:
if isinstance(c, tuple):
join_tables, column = self._get_field_with_path(c[1])
return attr.property.columns
result[c[0]] = column
def _need_join(self, table):
return table not in self.model._sa_class_manager.mapper.tables
if join_tables:
self._sortable_joins[c[0]] = join_tables
else:
join_tables, column = self._get_field_with_path(c)
result[c] = column
return result
def init_search(self):
"""
......@@ -400,10 +447,17 @@ class ModelView(BaseModelView):
"""
if self.column_searchable_list:
self._search_fields = []
self._search_joins = dict()
self._search_joins = []
joins = set()
for p in self.column_searchable_list:
for column in self._get_columns_for_field(p):
join_tables, attr = self._get_field_with_path(p)
if not attr:
raise Exception('Failed to find field for search field: %s' % p)
for column in self._get_columns_for_field(attr):
column_type = type(column.type).__name__
if not self.is_text_column_type(column_type):
......@@ -412,9 +466,11 @@ class ModelView(BaseModelView):
self._search_fields.append(column)
# If it belongs to different table - add a join
if self._need_join(column.table):
self._search_joins[column.table.name] = column.table
# Store joins, avoid duplicates
for table in join_tables:
if table.name not in joins:
self._search_joins.append(table)
joins.add(table.name)
return bool(self.column_searchable_list)
......@@ -435,23 +491,7 @@ class ModelView(BaseModelView):
Return list of enabled filters
"""
join_tables = []
if isinstance(name, string_types):
model = self.model
for attribute in name.split('.'):
value = getattr(model, attribute)
if (hasattr(value, 'property') and
hasattr(value.property, 'direction')):
model = value.property.mapper.class_
table = model.__table__
if self._need_join(table):
join_tables.append(table)
attr = value
else:
attr = name
join_tables, attr = self._get_field_with_path(name)
if attr is None:
raise Exception('Failed to find field for filter: %s' % name)
......@@ -616,7 +656,7 @@ class ModelView(BaseModelView):
"""
return self.session.query(func.count('*')).select_from(self.model)
def _order_by(self, query, joins, sort_field, sort_desc):
def _order_by(self, query, joins, sort_joins, sort_field, sort_desc):
"""
Apply order_by to the query
......@@ -630,33 +670,13 @@ class ModelView(BaseModelView):
Ascending or descending
"""
# TODO: Preprocessing for joins
# Try to handle it as a string
if isinstance(sort_field, string_types):
# Create automatic join against a table if column name
# contains dot.
if '.' in sort_field:
parts = sort_field.split('.', 1)
if parts[0] not in joins:
query = query.join(parts[0])
joins.add(parts[0])
elif isinstance(sort_field, InstrumentedAttribute):
# SQLAlchemy 0.8+ uses 'parent' as a name
mapper = getattr(sort_field, 'parent', None)
if mapper is None:
# SQLAlchemy 0.7.x uses parententity
mapper = getattr(sort_field, 'parententity', None)
if mapper is not None:
table = mapper.tables[0]
if self._need_join(table) and table.name not in joins:
# Handle joins
if sort_joins:
for table in sort_joins:
if table.name not in joins:
query = query.outerjoin(table)
joins.add(table.name)
elif isinstance(sort_field, Column):
pass
else:
raise TypeError('Wrong argument type')
if sort_field is not None:
if sort_desc:
......@@ -672,10 +692,9 @@ class ModelView(BaseModelView):
if order is not None:
field, direction = order
if isinstance(field, string_types):
field = getattr(self.model, field)
join_tables, attr = self._get_field_with_path(field)
return field, direction
return join_tables, field, direction
return None
......@@ -707,11 +726,11 @@ class ModelView(BaseModelView):
if self._search_supported and search:
# Apply search-related joins
if self._search_joins:
for jn in self._search_joins.values():
query = query.join(jn)
count_query = count_query.join(jn)
for table in self._search_joins:
query = query.join(table)
count_query = count_query.join(table)
joins = set(self._search_joins.keys())
joins.add(table.name)
# Apply terms
terms = search.split(' ')
......@@ -756,13 +775,16 @@ class ModelView(BaseModelView):
if sort_column is not None:
if sort_column in self._sortable_columns:
sort_field = self._sortable_columns[sort_column]
sort_joins = self._sortable_joins.get(sort_column)
query, joins = self._order_by(query, joins, sort_field, sort_desc)
query, joins = self._order_by(query, joins, sort_joins, sort_field, sort_desc)
else:
order = self._get_default_order()
if order:
query, joins = self._order_by(query, joins, order[0], order[1])
sort_joins, sort_field, sort_desc = order
query, joins = self._order_by(query, joins, sort_joins, sort_field, sort_desc)
# Pagination
if page is not None:
......
......@@ -30,7 +30,7 @@ var AdminModelActions = function(actionErrorMessage, actionConfirmations) {
$(function() {
$('.action-rowtoggle').change(function() {
$('input.action-checkbox').attr('checked', this.checked);
$('input.action-checkbox').prop('checked', this.checked);
});
});
};
......@@ -29,8 +29,13 @@ var AdminFilters = function(element, filtersElement, filterGroups) {
function removeFilter() {
$(this).closest('tr').remove();
$('button', $root).show();
if($('.filters tr').length == 0) {
$('button', $root).hide();
$('a[class=btn]', $root).hide();
} else {
$('button', $root).show();
}
return false;
}
......
......@@ -36,7 +36,7 @@
{% endblock %}
</div>
<!-- navbar content -->
<div class="collapse navbar-collapse" id="#admin-navbar-collapse">
<div class="collapse navbar-collapse" id="admin-navbar-collapse">
{% block main_menu %}
<ul class="nav navbar-nav">
{{ layout.menu() }}
......
......@@ -83,7 +83,7 @@
&nbsp;
{%- endif %}
</label>
<div class="col-md-4">
<div class="{{ kwargs.get('column_class', 'col-md-4') }}">
{% set _dummy = kwargs.setdefault('class', 'form-control') %}
{{ field(**kwargs)|safe }}
</div>
......
......@@ -13,6 +13,7 @@
{% endblock %}
{% block body %}
{% block navlinks %}
<ul class="nav nav-tabs">
<li>
<a href="{{ return_url }}">{{ _gettext('List') }}</a>
......@@ -21,6 +22,7 @@
<a href="javascript:void(0)">{{ _gettext('Create') }}</a>
</li>
</ul>
{% endblock %}
{% call lib.form_tag(form) %}
{{ lib.render_form_fields(form, form_opts=form_opts) }}
......
......@@ -38,6 +38,9 @@ def create_models(db):
bool_field = db.Column(db.Boolean)
enum_field = db.Column(db.Enum('model1_v1', 'model1_v1'), nullable=True)
def __unicode__(self):
return self.test1
def __str__(self):
return self.test1
......@@ -220,6 +223,29 @@ def test_column_searchable_list():
ok_('model2' not in data)
def test_complex_searchable_list():
app, db, admin = setup()
Model1, Model2 = create_models(db)
view = CustomModelView(Model2, db.session,
column_searchable_list=['model1.test1'])
admin.add_view(view)
m1 = Model1('model1')
db.session.add(m1)
db.session.add(Model2('model2', model1=m1))
db.session.add(Model2('model3'))
db.session.commit()
client = app.test_client()
rv = client.get('/admin/model2/?search=model1')
data = rv.data.decode('utf-8')
ok_('model1' in data)
ok_('model3' not in data)
def test_column_filters():
app, db, admin = setup()
......@@ -634,6 +660,29 @@ def test_default_sort():
eq_(data[2].test1, 'c')
def test_default_complex_sort():
app, db, admin = setup()
M1, M2 = create_models(db)
m1 = M1('b')
db.session.add(m1)
db.session.add(M2('c', model1=m1))
m2 = M1('a')
db.session.add(m2)
db.session.add(M2('c', model1=m2))
db.session.commit()
view = CustomModelView(M2, db.session, column_default_sort='model1.test1')
admin.add_view(view)
_, data = view.get_list(0, None, None, None, None)
eq_(len(data), 2)
eq_(data[0].model1.test1, 'a')
eq_(data[1].model1.test1, 'b')
def test_extra_fields():
app, db, admin = setup()
......
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