Commit f6198b45 authored by PJ Janse van Rensburg's avatar PJ Janse van Rensburg

Merge branch 'master' into sqlalchemy-utils-types

parents 424108be e4d83a91
......@@ -43,23 +43,23 @@ on the existing examples, and submit a *pull-request*.
To run the examples in your local environment::
1. Clone the repository::
1. Clone the repository::
git clone https://github.com/flask-admin/flask-admin.git
cd flask-admin
git clone https://github.com/flask-admin/flask-admin.git
cd flask-admin
2. Create and activate a virtual environment::
2. Create and activate a virtual environment::
virtualenv env -p python3
source env/bin/activate
virtualenv env -p python3
source env/bin/activate
3. Install requirements::
3. Install requirements::
pip install -r 'examples/sqla/requirements.txt'
pip install -r 'examples/sqla/requirements.txt'
4. Run the application::
4. Run the application::
python examples/sqla/app.py
python examples/sqla/app.py
Documentation
-------------
......
......@@ -15,6 +15,7 @@ API
mod_actions
mod_contrib_sqla
mod_contrib_sqla_fields
mod_contrib_mongoengine
mod_contrib_mongoengine_fields
mod_contrib_peewee
......
``flask_admin.contrib.sqla.fields``
===================================
.. automodule:: flask_admin.contrib.sqla.fields
.. autoclass:: QuerySelectField
:members:
.. autoclass:: QuerySelectMultipleField
:members:
.. autoclass:: CheckboxListField
:members:
Changelog
=========
next release
1.5.3
-----
* Fixed XSS vulnerability
* Support nested categories in the navbar menu
* SQLAlchemy
* sort on multiple columns with `column_default_sort`
* sort on related models in `column_sortable_list`
* show searchable fields in search input's placeholder text
* fix: inline model forms can now also be used for models with multiple primary keys
* support for using mapped `column_property`
* Upgrade Leaflet and Leaflet.draw plugins, used for geoalchemy integration
......@@ -18,6 +20,7 @@ next release
* handle special characters in filename
* fix a bug with listing directories on Windows
* avoid raising an exception when unknown sort parameter is encountered
* WTForms 3 support
1.5.2
-----
......
......@@ -16,11 +16,11 @@ The first step is to initialize an empty admin interface for your Flask app::
from flask import Flask
from flask_admin import Admin
app = Flask(__name__)
# set optional bootswatch theme
app.config['FLASK_ADMIN_SWATCH'] = 'cerulean'
app = Flask(__name__)
admin = Admin(app, name='microblog', template_mode='bootstrap3')
# Add administrative views here
......
......@@ -270,10 +270,16 @@ class PostAdmin(sqla.ModelView):
column_labels = dict(title='Post Title') # Rename 'title' column in list view
column_searchable_list = [
'title',
'tags.name',
'user.first_name',
'user.last_name',
'tags.name',
]
column_labels = {
'title': 'Title',
'tags.name': 'tags',
'user.first_name': 'user\'s first name',
'user.last_name': 'last name',
}
column_filters = [
'background_color',
'created_at',
......
__version__ = '1.5.2'
__version__ = '1.5.3'
__author__ = 'Flask-Admin team'
__email__ = 'serge.koval+github@gmail.com'
......
......@@ -8,6 +8,11 @@
import sys
import warnings
try:
from wtforms.widgets import HTMLString as Markup
except ImportError:
from markupsafe import Markup # noqa: F401
def get_property(obj, name, old_name, default=None):
"""
......
......@@ -140,7 +140,8 @@ class ModelView(BaseModelView):
allowed_search_types = (mongoengine.StringField,
mongoengine.URLField,
mongoengine.EmailField)
mongoengine.EmailField,
mongoengine.ReferenceField)
"""
List of allowed search field types.
"""
......@@ -466,7 +467,12 @@ class ModelView(BaseModelView):
criteria = None
for field in self._search_fields:
flt = {'%s__%s' % (field.name, op): term}
if type(field) == mongoengine.ReferenceField:
import re
regex = re.compile('.*%s.*' % term)
else:
regex = term
flt = {'%s__%s' % (field.name, op): regex}
q = mongoengine.Q(**flt)
if criteria is None:
......
from wtforms.widgets import HTMLString, html_params
from wtforms.widgets import html_params
from jinja2 import escape
from mongoengine.fields import GridFSProxy, ImageGridFsProxy
from flask_admin._backwards import Markup
from flask_admin.helpers import get_url
from . import helpers
......@@ -31,10 +32,10 @@ class MongoFileInput(object):
'marker': '_%s-delete' % field.name
}
return HTMLString('%s<input %s>' % (placeholder,
html_params(name=field.name,
type='file',
**kwargs)))
return Markup('%s<input %s>' % (placeholder,
html_params(name=field.name,
type='file',
**kwargs)))
class MongoImageInput(object):
......@@ -48,7 +49,6 @@ class MongoImageInput(object):
def __call__(self, field, **kwargs):
kwargs.setdefault('id', field.id)
placeholder = ''
if field.data and isinstance(field.data, ImageGridFsProxy):
args = helpers.make_thumb_args(field.data)
......@@ -57,7 +57,7 @@ class MongoImageInput(object):
'marker': '_%s-delete' % field.name
}
return HTMLString('%s<input %s>' % (placeholder,
html_params(name=field.name,
type='file',
**kwargs)))
return Markup('%s<input %s>' % (placeholder,
html_params(name=field.name,
type='file',
**kwargs)))
......@@ -13,6 +13,7 @@ except ImportError:
from .tools import get_primary_key
from flask_admin._compat import text_type, string_types, iteritems
from flask_admin.contrib.sqla.widgets import CheckboxListInput
from flask_admin.form import FormOpts, BaseForm, Select2Widget
from flask_admin.model.fields import InlineFieldList, InlineModelFormField
from flask_admin.babel import lazy_gettext
......@@ -181,6 +182,30 @@ class QuerySelectMultipleField(QuerySelectField):
raise ValidationError(self.gettext(u'Not a valid choice'))
class CheckboxListField(QuerySelectMultipleField):
"""
Alternative field for many-to-many relationships.
Can be used instead of `QuerySelectMultipleField`.
Appears as the list of checkboxes.
Example::
class MyView(ModelView):
form_columns = (
'languages',
)
form_args = {
'languages': {
'query_factory': Language.query,
},
}
form_overrides = {
'languages': CheckboxListField,
}
"""
widget = CheckboxListInput()
class HstoreForm(BaseForm):
""" Form used in InlineFormField/InlineHstoreList for HSTORE columns """
key = StringField(lazy_gettext('Key'))
......
......@@ -590,10 +590,10 @@ class ModelView(BaseModelView):
column_labels = dict(name='Name', last_name='Last Name')
column_searchable_list = ('name', 'last_name')
placeholder is: "Search: Name, Last Name"
placeholder is: "Name, Last Name"
"""
if not self.column_searchable_list:
return 'Search'
return None
placeholders = []
......@@ -605,7 +605,7 @@ class ModelView(BaseModelView):
placeholders.append(
self.column_labels.get(searchable, searchable))
return 'Search: %s' % u', '.join(placeholders)
return u', '.join(placeholders)
def scaffold_filters(self, name):
"""
......@@ -824,8 +824,6 @@ class ModelView(BaseModelView):
"""
Return a query for the model type.
If you override this method, don't forget to override `get_count_query` as well.
This method can be used to set a "persistent filter" on an index_view.
Example::
......@@ -833,6 +831,10 @@ class ModelView(BaseModelView):
class MyView(ModelView):
def get_query(self):
return super(MyView, self).get_query().filter(User.username == current_user.username)
If you override this method, don't forget to also override `get_count_query`, for displaying the correct
item count in the list view, and `get_one`, which is used when retrieving records for the edit view.
"""
return self.session.query(self.model)
......@@ -1073,6 +1075,14 @@ class ModelView(BaseModelView):
"""
Return a single model by its id.
Example::
def get_one(self, id):
query = self.get_query()
return query.filter(self.model.id == id).one()
Also see `get_query` for how to filter the list view.
:param id:
Model id
"""
......
from wtforms.widgets.core import escape
from flask_admin._backwards import Markup
class CheckboxListInput:
"""
Alternative widget for many-to-many relationships.
Appears as the list of checkboxes.
"""
template = (
'<div class="checkbox">'
' <label>'
' <input id="%(id)s" name="%(name)s" value="%(id)s" '
'type="checkbox"%(selected)s>%(label)s'
' </label>'
'</div>'
)
def __call__(self, field, **kwargs):
items = []
for val, label, selected in field.iter_choices():
args = {
'id': val,
'name': field.name,
'label': escape(label),
'selected': ' checked' if selected else '',
}
items.append(self.template % args)
return Markup(''.join(items))
......@@ -5,7 +5,7 @@ from werkzeug import secure_filename
from werkzeug.datastructures import FileStorage
from wtforms import ValidationError, fields
from wtforms.widgets import HTMLString, html_params
from wtforms.widgets import html_params
try:
from wtforms.fields.core import _unset_value as unset_value
......@@ -15,6 +15,7 @@ except ImportError:
from flask_admin.babel import gettext
from flask_admin.helpers import get_url
from flask_admin._backwards import Markup
from flask_admin._compat import string_types, urljoin
......@@ -59,7 +60,7 @@ class FileUploadInput(object):
else:
value = field.data or ''
return HTMLString(template % {
return Markup(template % {
'text': html_params(type='text',
readonly='readonly',
value=value,
......@@ -108,7 +109,7 @@ class ImageUploadInput(object):
else:
template = self.empty_template
return HTMLString(template % args)
return Markup(template % args)
def get_url(self, field):
if field.thumbnail_size:
......
from re import sub
from re import sub, compile
from jinja2 import contextfunction
from flask import g, request, url_for, flash
from wtforms.validators import DataRequired, InputRequired
......@@ -9,6 +9,8 @@ from ._compat import string_types
VALID_SCHEMES = ['http', 'https']
_substitute_whitespace = compile(r'[\s\x00-\x08\x0B\x0C\x0E-\x19]+').sub
_fix_multiple_slashes = compile(r'(^([^/]+:)?//)/*').sub
def set_current_view(view):
......@@ -131,8 +133,18 @@ def prettify_class_name(name):
def is_safe_url(target):
# prevent urls like "\\www.google.com"
# some browser will change \\ to // (eg: Chrome)
# refs https://stackoverflow.com/questions/10438008
target = target.replace('\\', '/')
# handle cases like "j a v a s c r i p t:"
target = _substitute_whitespace('', target)
# Chrome and FireFox "fix" more than two slashes into two after protocol
target = _fix_multiple_slashes(lambda m: m.group(1), target, 1)
# prevent urls starting with "javascript:"
target = target.strip()
target_info = urlparse(target)
target_scheme = target_info.scheme
if target_scheme and target_scheme not in VALID_SCHEMES:
......
......@@ -1109,9 +1109,9 @@ class BaseModelView(BaseView, ActionsMixin):
def search_placeholder(self):
"""
Return search placeholder.
Return search placeholder text.
"""
return 'Search'
return None
# Filter helpers
def scaffold_filters(self, name):
......
from flask import json
from jinja2 import escape
from wtforms.widgets import HTMLString, html_params
from wtforms.widgets import html_params
from flask_admin._backwards import Markup
from flask_admin._compat import as_unicode, text_type
from flask_admin.babel import gettext
from flask_admin.helpers import get_url
......@@ -64,7 +65,7 @@ class AjaxSelect2Widget(object):
minimum_input_length = int(field.loader.options.get('minimum_input_length', 1))
kwargs.setdefault('data-minimum-input-length', minimum_input_length)
return HTMLString('<input %s>' % html_params(name=field.name, **kwargs))
return Markup('<input %s>' % html_params(name=field.name, **kwargs))
class XEditableWidget(object):
......@@ -93,7 +94,7 @@ class XEditableWidget(object):
kwargs = self.get_kwargs(field, kwargs)
return HTMLString(
return Markup(
'<a %s>%s</a>' % (html_params(**kwargs),
escape(display_value))
)
......
......@@ -143,3 +143,23 @@ table.filters tr td {
.editable-input .select2-container {
min-width: 220px;
}
[placeholder]{
text-overflow:ellipsis;
}
::-webkit-input-placeholder { /* Chrome/Opera/Safari */
text-overflow:ellipsis;
}
::-moz-placeholder { /* Firefox 19+ */
text-overflow:ellipsis;
}
:-ms-input-placeholder { /* IE 10+ */
text-overflow:ellipsis;
}
:-moz-placeholder { /* Firefox 18- */
text-overflow:ellipsis;
}
......@@ -108,3 +108,23 @@ body.modal-open {
{
overflow-x: auto;
}
[placeholder]{
text-overflow:ellipsis;
}
::-webkit-input-placeholder { /* Chrome/Opera/Safari */
text-overflow:ellipsis;
}
::-moz-placeholder { /* Firefox 19+ */
text-overflow:ellipsis;
}
:-ms-input-placeholder { /* IE 10+ */
text-overflow:ellipsis;
}
:-moz-placeholder { /* Firefox 18- */
text-overflow:ellipsis;
}
(function() {
window.faHelpers = {
// A simple confirm() wrapper
safeConfirm: function(msg) {
try {
return confirm(msg) ? true : false;
} catch (e) {
return false;
}
}
};
})();
......@@ -84,7 +84,11 @@
{% if name != '..' and admin_view.can_delete_dirs %}
<form class="icon" method="POST" action="{{ get_url('.delete') }}">
{{ delete_form.path(value=path) }}
{{ delete_form.csrf_token }}
{% if delete_form.csrf_token %}
{{ delete_form.csrf_token }}
{% elif csrf_token %}
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
{% endif %}
<button onclick="return confirm('{{ _gettext('Are you sure you want to delete \\\'%(name)s\\\' recursively?', name=name) }}')">
<i class="fa fa-times icon-remove"></i>
</button>
......@@ -93,7 +97,11 @@
{% else %}
<form class="icon" method="POST" action="{{ get_url('.delete') }}">
{{ delete_form.path(value=path) }}
{{ delete_form.csrf_token }}
{% if delete_form.csrf_token %}
{{ delete_form.csrf_token }}
{% elif csrf_token %}
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
{% endif %}
<button onclick="return confirm('{{ _gettext('Are you sure you want to delete \\\'%(name)s\\\'?', name=name) }}')">
<i class="fa fa-times icon-remove"></i>
</button>
......
......@@ -58,7 +58,7 @@
<div class="clearfix"></div>
{% endmacro %}
{% macro search_form(input_class="span2") %}
{% macro search_form(input_class=None) %}
<form method="GET" action="{{ return_url }}" class="search-form">
{% for flt_name, flt_value in filter_args.items() %}
<input type="hidden" name="{{ flt_name }}" value="{{ flt_value }}">
......@@ -72,17 +72,17 @@
{% if sort_desc %}
<input type="hidden" name="desc" value="{{ sort_desc }}">
{% endif %}
{%- set full_search_placeholder = _gettext('Search') %}
{%- if search_placeholder %}{% set full_search_placeholder = [full_search_placeholder, search_placeholder] | join(": ") %}{% endif %}
{% if search %}
<div class="input-append">
<input type="text" name="search" value="{{ search }}" class="{{ input_class }}" placeholder="{{ _gettext('%(placeholder)s', placeholder=search_placeholder) }}">
<input type="search" name="search" class="input-xlarge{% if input_class %} {{ input_class }}{% endif %}" value="{{ search }}" placeholder="{{ full_search_placeholder }}">
<a href="{{ clear_search_url }}" class="clear add-on">
<i class="fa fa-times icon-remove"></i>
</a>
</div>
{% else %}
<div>
<input type="text" name="search" value="" class="{{ input_class }}" placeholder="{{ _gettext('%(placeholder)s', placeholder=search_placeholder) }}">
</div>
<input type="search" name="search" class="input-xlarge{% if input_class %} {{ input_class }}{% endif %}" value="" placeholder="{{ full_search_placeholder }}">
{% endif %}
</form>
{% endmacro %}
......
......@@ -185,8 +185,8 @@
<div id="active-filters-data" style="display:none;">{{ active_filters|tojson|safe }}</div>
{% endif %}
<script src="{{ admin_static.url(filename='admin/js/filters.js', v='1.0.0') }}"></script>
{{ lib.form_js() }}
<script src="{{ admin_static.url(filename='admin/js/filters.js', v='1.0.0') }}"></script>
{{ actionlib.script(_gettext('Please select at least one record.'),
actions,
......
......@@ -84,6 +84,7 @@
<script src="{{ admin_static.url(filename='bootstrap/bootstrap3/js/bootstrap.min.js', v='3.3.5') }}" type="text/javascript"></script>
<script src="{{ admin_static.url(filename='vendor/moment.min.js', v='2.9.0') }}" type="text/javascript"></script>
<script src="{{ admin_static.url(filename='vendor/select2/select2.min.js', v='3.5.2') }}" type="text/javascript"></script>
<script src="{{ admin_static.url(filename='admin/js/helpers.js', v='1.0.0') }}" type="text/javascript"></script>
{% if admin_view.extra_js %}
{% for js_url in admin_view.extra_js %}
<script src="{{ js_url }}" type="text/javascript"></script>
......
......@@ -84,7 +84,11 @@
{% if name != '..' and admin_view.can_delete_dirs %}
<form class="icon" method="POST" action="{{ get_url('.delete') }}">
{{ delete_form.path(value=path) }}
{{ delete_form.csrf_token }}
{% if delete_form.csrf_token %}
{{ delete_form.csrf_token }}
{% elif csrf_token %}
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
{% endif %}
<button onclick="return confirm('{{ _gettext('Are you sure you want to delete \\\'%(name)s\\\' recursively?', name=name) }}')">
<i class="fa fa-times glyphicon glyphicon-remove"></i>
</button>
......@@ -93,7 +97,11 @@
{% else %}
<form class="icon" method="POST" action="{{ get_url('.delete') }}">
{{ delete_form.path(value=path) }}
{{ delete_form.csrf_token }}
{% if delete_form.csrf_token %}
{{ delete_form.csrf_token }}
{% elif csrf_token %}
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
{% endif %}
<button onclick="return confirm('{{ _gettext('Are you sure you want to delete \\\'%(name)s\\\'?', name=name) }}')">
<i class="fa fa-trash glyphicon glyphicon-trash"></i>
</button>
......
......@@ -58,7 +58,7 @@
<div class="clearfix"></div>
{% endmacro %}
{% macro search_form(input_class="col-md-2") %}
{% macro search_form(input_class=None) %}
<form method="GET" action="{{ return_url }}" class="navbar-form navbar-left" role="search">
{% for flt_name, flt_value in filter_args.items() %}
<input type="hidden" name="{{ flt_name }}" value="{{ flt_value }}">
......@@ -72,14 +72,18 @@
{% if sort_desc %}
<input type="hidden" name="desc" value="{{ sort_desc }}">
{% endif %}
{%- set full_search_placeholder = _gettext('Search') %}
{%- set max_size = config.get('FLASK_ADMIN_SEARCH_SIZE_MAX', 100) %}
{%- if search_placeholder %}{% set full_search_placeholder = [full_search_placeholder, search_placeholder] | join(": ") %}{% endif %}
{%- set input_size = [[full_search_placeholder | length, 30] | max, max_size] | min %}
{% if search %}
<div class="input-group">
<input type="text" name="search" value="{{ search }}" class="{{ input_class }} form-control" placeholder="{{ _gettext('%(placeholder)s', placeholder=search_placeholder) }}">
<input type="search" name="search" value="{{ search }}" class="form-control{% if input_class %} {{ input_class }}{% endif %}" size="{{ input_size }}" placeholder="{{ full_search_placeholder }}">
<a href="{{ clear_search_url }}" class="input-group-addon clear"><span class="fa fa-times glyphicon glyphicon-remove"></span></a>
</div>
{% else %}
<div class="form-group">
<input type="text" name="search" value="" class="{{ input_class }} form-control" placeholder="{{ _gettext('%(placeholder)s', placeholder=search_placeholder) }}">
<input type="search" name="search" value="" class="form-control{% if input_class %} {{ input_class }}{% endif %}" size="{{ input_size }}" placeholder="{{ full_search_placeholder }}">
</div>
{% endif %}
</form>
......
......@@ -186,8 +186,8 @@
<div id="active-filters-data" style="display:none;">{{ active_filters|tojson|safe }}</div>
{% endif %}
<script src="{{ admin_static.url(filename='admin/js/filters.js', v='1.0.0') }}"></script>
{{ lib.form_js() }}
<script src="{{ admin_static.url(filename='admin/js/filters.js', v='1.0.0') }}"></script>
{{ actionlib.script(_gettext('Please select at least one record.'),
actions,
......
......@@ -31,7 +31,7 @@
{% elif csrf_token %}
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
{% endif %}
<button onclick="return safeConfirm('{{ _gettext('Are you sure you want to delete this record?') }}');" title="{{ _gettext('Delete record') }}">
<button onclick="return faHelpers.safeConfirm('{{ _gettext('Are you sure you want to delete this record?') }}');" title="{{ _gettext('Delete record') }}">
<span class="fa fa-trash glyphicon glyphicon-trash"></span>
</button>
</form>
......
......@@ -11,7 +11,14 @@ def test_is_safe_url():
assert helpers.is_safe_url('https://127.0.0.1/admin/car/')
assert helpers.is_safe_url('/admin/car/')
assert helpers.is_safe_url('admin/car/')
assert helpers.is_safe_url('http////www.google.com')
assert not helpers.is_safe_url('http://127.0.0.2/admin/car/')
assert not helpers.is_safe_url(' javascript:alert(document.domain)')
assert not helpers.is_safe_url('javascript:alert(document.domain)')
assert not helpers.is_safe_url('javascrip\nt:alert(document.domain)')
assert not helpers.is_safe_url(r'\\www.google.com')
assert not helpers.is_safe_url(r'\\/www.google.com')
assert not helpers.is_safe_url('/////www.google.com')
assert not helpers.is_safe_url('http:///www.google.com')
assert not helpers.is_safe_url('https:////www.google.com')
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