Commit c07ed4b5 authored by Paul Brown's avatar Paul Brown

Merge pull request #1 from mrjoes/master

Merge most recent changes
parents f820bd9e 3bf7e239
...@@ -36,8 +36,12 @@ Several usage examples are included in the */examples* folder. Please feel free ...@@ -36,8 +36,12 @@ Several usage examples are included in the */examples* folder. Please feel free
on some of the existing ones, and then submit them via GitHub as a *pull-request*. on some of the existing ones, and then submit them via GitHub as a *pull-request*.
You can see some of these examples in action at `http://examples.flask-admin.org <http://examples.flask-admin.org/>`_. You can see some of these examples in action at `http://examples.flask-admin.org <http://examples.flask-admin.org/>`_.
To run that same page in your local environment, simply::
To run one of the examples in your local environment, simply:: cd flask-admin
python examples/runserver.py
Alternatively, you can run the examples one at a time, with something like::
cd flask-admin cd flask-admin
python examples/simple/simple.py python examples/simple/simple.py
......
This diff is collapsed.
__author__ = 'petrus'
from flask import Flask
from flask import render_template
app = Flask(__name__)
app.debug = True
app.config['MAX_CONTENT_LENGTH'] = 5 * 1024 * 1024 # 5Mb
@app.route('/')
def index():
return render_template('index.html')
if __name__ == '__main__':
app.run()
\ No newline at end of file
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This source diff could not be displayed because it is too large. You can view the blob instead.
This diff is collapsed.
This diff is collapsed.
<!DOCTYPE html>
<html>
<head>
<title>Flask-Admin examples</title>
<meta name="description" content="Live examples of the Flask-Admin package in action. See how you can add your own custom views, change the look & feel of the admin interface, or just add basic CRUD-views for managing your models.">
<link rel="stylesheet" href="static/bootstrap/css/bootstrap.css">
<link href="//netdna.bootstrapcdn.com/font-awesome/4.0.3/css/font-awesome.css" rel="stylesheet">
<style>
.item{
padding-top: 5px;
}
.item hr{
margin-bottom: 0;
}
.item .btn{
margin-bottom: 10px;
}
.item p.lead{
margin-bottom: 5px;
}
.footer p{
margin-top: 25px;
margin-bottom: 45px;
}
</style>
</head>
<body>
<div class="jumbotron">
<h1>Flask-Admin examples</h1>
<p>
These are some of the examples that can be found in the Flask-Admin GitHub repo at
<a href="https://github.com/mrjoes/flask-admin" target="_blank">https://github.com/mrjoes/flask-admin</a>.
Feel free to play around. This site gets refreshed every 10 minutes or so.
</p>
</div>
<div class="container">
<div class="item">
<h2>Simple views</h2>
<p class="lead">Add a few of your own views to the admin interface. You can add links to them in the top navbar,
but you don't have to.</p>
<a class="btn btn-primary" role="button" href="simple/admin/"><i class="fa fa-chevron-right"></i> view example</a>
<hr>
</div>
<div class="item">
<h2>SQLAlchemy models</h2>
<p class="lead">Add some basic CRUD-views for your models.</p>
<a class="btn btn-primary" role="button" href="sqla/simple/admin/"><i class="fa fa-chevron-right"></i> view example</a>
<!--<p class="lead">Define models with multiple primary keys.</p>-->
<!--<a class="btn btn-primary" role="button" href="sqla/multiple_pk/admin/"><i class="fa fa-chevron-right"></i> view example</a>-->
<hr>
</div>
<div class="item">
<h2>Customize the layout</h2>
<p class="lead">Take control of the look & feel of your admin interface.</p>
<a class="btn btn-primary" role="button" href="layout/admin/"><i class="fa fa-chevron-right"></i> view example</a>
<hr>
</div>
<div class="item">
<h2>Files, images & custom forms</h2>
<p class="lead">Define custom forms using form rules, and quickly add file/image management to your application.</p>
<p>Note: a 5Mb limit has been placed on the size of uploaded files & images for this example.</p>
<a class="btn btn-primary" role="button" href="forms/admin/"><i class="fa fa-chevron-right"></i> view example</a>
<hr>
</div>
<div class="item">
<h2>Authentication</h2>
<p class="lead">Use Flask-Login to authenticate users.</p>
<a class="btn btn-primary" role="button" href="auth/admin/"><i class="fa fa-chevron-right"></i> view example</a>
<hr>
</div>
</div>
<div class="container footer">
<p>
</p>
</div>
<!-- Google Analytics tracking -->
<script>
(function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
(i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),
m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
})(window,document,'script','//www.google-analytics.com/analytics.js','ga');
ga('create', 'UA-45533714-1', 'flask-admin.org');
ga('send', 'pageview');
</script>
</body>
</html>
\ No newline at end of file
from werkzeug.wsgi import DispatcherMiddleware
from werkzeug.serving import run_simple
from index.index import app as index
import examples.simple.simple
import examples.sqla.simple
import examples.layout.simple
import examples.forms.simple
import examples.auth.auth
examples.sqla.simple.build_sample_db()
examples.layout.simple.build_sample_db()
examples.forms.simple.build_sample_db()
examples.auth.auth.build_sample_db()
application = DispatcherMiddleware(
index,
{
'/simple': examples.simple.simple.app,
'/sqla/simple': examples.sqla.simple.app,
'/layout': examples.layout.simple.app,
'/forms': examples.forms.simple.app,
'/auth': examples.auth.auth.app,
}
)
if __name__ == '__main__':
run_simple('localhost', 5000, application,
use_reloader=True, use_debugger=True, use_evalex=True)
\ No newline at end of file
...@@ -31,8 +31,8 @@ class User(db.Model): ...@@ -31,8 +31,8 @@ class User(db.Model):
username = db.Column(db.String(80), unique=True) username = db.Column(db.String(80), unique=True)
email = db.Column(db.String(120), unique=True) email = db.Column(db.String(120), unique=True)
# Required for administrative interface # Required for administrative interface. For python 3 please use __str__ instead.
def __str__(self): def __unicode__(self):
return self.username return self.username
...@@ -54,7 +54,7 @@ class Post(db.Model): ...@@ -54,7 +54,7 @@ class Post(db.Model):
tags = db.relationship('Tag', secondary=post_tags_table) tags = db.relationship('Tag', secondary=post_tags_table)
def __str__(self): def __unicode__(self):
return self.title return self.title
...@@ -62,7 +62,7 @@ class Tag(db.Model): ...@@ -62,7 +62,7 @@ class Tag(db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.Unicode(64)) name = db.Column(db.Unicode(64))
def __str__(self): def __unicode__(self):
return self.name return self.name
...@@ -75,7 +75,7 @@ class UserInfo(db.Model): ...@@ -75,7 +75,7 @@ class UserInfo(db.Model):
user_id = db.Column(db.Integer(), db.ForeignKey(User.id)) user_id = db.Column(db.Integer(), db.ForeignKey(User.id))
user = db.relationship(User, backref='info') user = db.relationship(User, backref='info')
def __str__(self): def __unicode__(self):
return '%s - %s' % (self.key, self.value) return '%s - %s' % (self.key, self.value)
...@@ -85,7 +85,7 @@ class Tree(db.Model): ...@@ -85,7 +85,7 @@ class Tree(db.Model):
parent_id = db.Column(db.Integer, db.ForeignKey('tree.id')) parent_id = db.Column(db.Integer, db.ForeignKey('tree.id'))
parent = db.relationship('Tree', remote_side=[id], backref='children') parent = db.relationship('Tree', remote_side=[id], backref='children')
def __str__(self): def __unicode__(self):
return self.name return self.name
......
...@@ -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())
...@@ -155,6 +155,11 @@ class FileAdmin(BaseView, ActionsMixin): ...@@ -155,6 +155,11 @@ class FileAdmin(BaseView, ActionsMixin):
Edit template Edit template
""" """
upload_form = UploadForm
"""
Upload form class
"""
def __init__(self, base_path, base_url=None, def __init__(self, base_path, base_url=None,
name=None, category=None, endpoint=None, url=None, name=None, category=None, endpoint=None, url=None,
verify_path=True): verify_path=True):
...@@ -285,7 +290,7 @@ class FileAdmin(BaseView, ActionsMixin): ...@@ -285,7 +290,7 @@ class FileAdmin(BaseView, ActionsMixin):
""" """
file_data.save(path) file_data.save(path)
def _get_dir_url(self, endpoint, path, **kwargs): def _get_dir_url(self, endpoint, path=None, **kwargs):
""" """
Return prettified URL Return prettified URL
...@@ -410,6 +415,17 @@ class FileAdmin(BaseView, ActionsMixin): ...@@ -410,6 +415,17 @@ class FileAdmin(BaseView, ActionsMixin):
""" """
pass pass
def _save_form_files(self, directory, path, form):
filename = op.join(directory,
secure_filename(form.upload.data.filename))
if op.exists(filename):
flash(gettext('File "%(name)s" already exists.', name=filename),
'error')
else:
self.save_file(filename, form.upload.data)
self.on_file_upload(directory, path, filename)
@expose('/') @expose('/')
@expose('/b/<path:path>') @expose('/b/<path:path>')
def index(self, path=None): def index(self, path=None):
...@@ -423,7 +439,7 @@ class FileAdmin(BaseView, ActionsMixin): ...@@ -423,7 +439,7 @@ class FileAdmin(BaseView, ActionsMixin):
base_path, directory, path = self._normalize_path(path) base_path, directory, path = self._normalize_path(path)
if not self.is_accessible_path(path): if not self.is_accessible_path(path):
flash(gettext(gettext('Permission denied.'))) flash(gettext('Permission denied.'))
return redirect(self._get_dir_url('.index')) return redirect(self._get_dir_url('.index'))
# Get directory listing # Get directory listing
...@@ -486,21 +502,13 @@ class FileAdmin(BaseView, ActionsMixin): ...@@ -486,21 +502,13 @@ class FileAdmin(BaseView, ActionsMixin):
return redirect(self._get_dir_url('.index', path)) return redirect(self._get_dir_url('.index', path))
if not self.is_accessible_path(path): if not self.is_accessible_path(path):
flash(gettext(gettext('Permission denied.'))) flash(gettext('Permission denied.'))
return redirect(self._get_dir_url('.index')) return redirect(self._get_dir_url('.index'))
form = UploadForm(self) form = self.upload_form(self)
if helpers.validate_form_on_submit(form): if helpers.validate_form_on_submit(form):
filename = op.join(directory,
secure_filename(form.upload.data.filename))
if op.exists(filename):
flash(gettext('File "%(name)s" already exists.', name=filename),
'error')
else:
try: try:
self.save_file(filename, form.upload.data) self._save_form_files(directory, path, form)
self.on_file_upload(directory, path, filename)
return redirect(self._get_dir_url('.index', path)) return redirect(self._get_dir_url('.index', path))
except Exception as ex: except Exception as ex:
flash(gettext('Failed to save file: %(error)s', error=ex)) flash(gettext('Failed to save file: %(error)s', error=ex))
...@@ -547,7 +555,7 @@ class FileAdmin(BaseView, ActionsMixin): ...@@ -547,7 +555,7 @@ class FileAdmin(BaseView, ActionsMixin):
return redirect(dir_url) return redirect(dir_url)
if not self.is_accessible_path(path): if not self.is_accessible_path(path):
flash(gettext(gettext('Permission denied.'))) flash(gettext('Permission denied.'))
return redirect(self._get_dir_url('.index')) return redirect(self._get_dir_url('.index'))
form = NameForm(helpers.get_form_data()) form = NameForm(helpers.get_form_data())
...@@ -558,7 +566,7 @@ class FileAdmin(BaseView, ActionsMixin): ...@@ -558,7 +566,7 @@ class FileAdmin(BaseView, ActionsMixin):
self.on_mkdir(directory, form.name.data) self.on_mkdir(directory, form.name.data)
return redirect(dir_url) return redirect(dir_url)
except Exception as ex: except Exception as ex:
flash(gettext('Failed to create directory: %(error)s', ex), 'error') flash(gettext('Failed to create directory: %(error)s', error=ex), 'error')
return self.render(self.mkdir_template, return self.render(self.mkdir_template,
form=form, form=form,
...@@ -584,7 +592,7 @@ class FileAdmin(BaseView, ActionsMixin): ...@@ -584,7 +592,7 @@ class FileAdmin(BaseView, ActionsMixin):
return redirect(return_url) return redirect(return_url)
if not self.is_accessible_path(path): if not self.is_accessible_path(path):
flash(gettext(gettext('Permission denied.'))) flash(gettext('Permission denied.'))
return redirect(self._get_dir_url('.index')) return redirect(self._get_dir_url('.index'))
if op.isdir(full_path): if op.isdir(full_path):
...@@ -627,7 +635,7 @@ class FileAdmin(BaseView, ActionsMixin): ...@@ -627,7 +635,7 @@ class FileAdmin(BaseView, ActionsMixin):
return redirect(return_url) return redirect(return_url)
if not self.is_accessible_path(path): if not self.is_accessible_path(path):
flash(gettext(gettext('Permission denied.'))) flash(gettext('Permission denied.'))
return redirect(self._get_dir_url('.index')) return redirect(self._get_dir_url('.index'))
if not op.exists(full_path): if not op.exists(full_path):
...@@ -672,8 +680,8 @@ class FileAdmin(BaseView, ActionsMixin): ...@@ -672,8 +680,8 @@ class FileAdmin(BaseView, ActionsMixin):
base_path, full_path, path = self._normalize_path(path) base_path, full_path, path = self._normalize_path(path)
if not self.is_accessible_path(path): if not self.is_accessible_path(path) or not self.is_file_editable(path):
flash(gettext(gettext('Permission denied.'))) flash(gettext('Permission denied.'))
return redirect(self._get_dir_url('.index')) return redirect(self._get_dir_url('.index'))
dir_url = self._get_dir_url('.index', os.path.dirname(path)) dir_url = self._get_dir_url('.index', os.path.dirname(path))
......
...@@ -106,9 +106,9 @@ class AdminModelConverter(ModelConverterBase): ...@@ -106,9 +106,9 @@ class AdminModelConverter(ModelConverterBase):
kwargs['label'] = self._get_label(prop.key, kwargs) kwargs['label'] = self._get_label(prop.key, kwargs)
kwargs['description'] = self._get_description(prop.key, kwargs) kwargs['description'] = self._get_description(prop.key, kwargs)
if column.nullable: if column.nullable or prop.direction.name != 'MANYTOONE':
kwargs['validators'].append(validators.Optional()) kwargs['validators'].append(validators.Optional())
elif prop.direction.name != 'MANYTOMANY': else:
kwargs['validators'].append(validators.InputRequired()) kwargs['validators'].append(validators.InputRequired())
# Contribute model-related parameters # Contribute model-related parameters
......
...@@ -32,6 +32,12 @@ def get_primary_key(model): ...@@ -32,6 +32,12 @@ def get_primary_key(model):
pks.append(get_column_for_current_model(p).key) pks.append(get_column_for_current_model(p).key)
else: else:
pks.append(p.key) pks.append(p.key)
else:
if hasattr(p, 'columns'):
for c in p.columns:
if c.primary_key:
pks.append(p.key)
break
if len(pks) == 1: if len(pks) == 1:
return pks[0] return pks[0]
...@@ -52,8 +58,12 @@ def is_inherited_primary_key(prop): ...@@ -52,8 +58,12 @@ def is_inherited_primary_key(prop):
:return: Boolean :return: Boolean
:raises: Exceptions as they occur - no ExceptionHandling here :raises: Exceptions as they occur - no ExceptionHandling here
""" """
if not hasattr(prop, 'expression'):
return False
if prop.expression.primary_key: if prop.expression.primary_key:
return len(prop._orig_columns) == len(prop.columns)-1 return len(prop._orig_columns) == len(prop.columns)-1
return False return False
def get_column_for_current_model(prop): def get_column_for_current_model(prop):
......
...@@ -326,6 +326,7 @@ class ModelView(BaseModelView): ...@@ -326,6 +326,7 @@ class ModelView(BaseModelView):
columns.append(p.key) columns.append(p.key)
elif hasattr(p, 'columns'): elif hasattr(p, 'columns'):
column_inherited_primary_key = False column_inherited_primary_key = False
if len(p.columns) != 1: if len(p.columns) != 1:
if is_inherited_primary_key(p): if is_inherited_primary_key(p):
column = get_column_for_current_model(p) column = get_column_for_current_model(p)
...@@ -428,7 +429,10 @@ class ModelView(BaseModelView): ...@@ -428,7 +429,10 @@ class ModelView(BaseModelView):
:returns: :returns:
``True`` for ``String``, ``Unicode``, ``Text``, ``UnicodeText`` ``True`` for ``String``, ``Unicode``, ``Text``, ``UnicodeText``
""" """
return name in ('String', 'Unicode', 'Text', 'UnicodeText') if name:
name = name.lower()
return name in ('string', 'unicode', 'text', 'unicodetext')
def scaffold_filters(self, name): def scaffold_filters(self, name):
""" """
......
...@@ -177,7 +177,8 @@ class FileUploadField(fields.TextField): ...@@ -177,7 +177,8 @@ class FileUploadField(fields.TextField):
return True return True
return ('.' in filename and return ('.' in filename and
filename.rsplit('.', 1)[1] in self.allowed_extensions) filename.rsplit('.', 1)[1].lower() in
map(str.lower, self.allowed_extensions))
def pre_validate(self, form): def pre_validate(self, form):
if (self.data and if (self.data and
...@@ -208,6 +209,8 @@ class FileUploadField(fields.TextField): ...@@ -208,6 +209,8 @@ class FileUploadField(fields.TextField):
filename = self.generate_name(obj, self.data) filename = self.generate_name(obj, self.data)
filename = self._save_file(self.data, filename) filename = self._save_file(self.data, filename)
# update filename of FileStorage to our validated name
self.data.filename = filename
setattr(obj, name, filename) setattr(obj, name, filename)
...@@ -329,7 +332,7 @@ class ImageUploadField(FileUploadField): ...@@ -329,7 +332,7 @@ class ImageUploadField(FileUploadField):
""" """
# Check if PIL is installed # Check if PIL is installed
if Image is None: if Image is None:
raise Exception('PIL library was not found') raise ImportError('PIL library was not found')
self.max_size = max_size self.max_size = max_size
self.thumbnail_fn = thumbgen or thumbgen_filename self.thumbnail_fn = thumbgen or thumbgen_filename
......
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,27 @@ class BaseModelView(BaseView, ActionsMixin): ...@@ -543,26 +558,27 @@ 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)) self._filter_groups[flt.name].append({
else: 'index': i,
group = self._filter_dict[n.name] 'arg': self.get_filter_arg(i, flt),
'operation': flt.operation(),
group.append((i, n.operation())) 'options': flt.get_options(self) or None,
'type': flt.data_type
})
self._filter_types = dict((i, f.data_type) self._filter_args[self.get_filter_arg(i, flt)] = (i, flt)
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_args = None
# Form rendering rules # Form rendering rules
if self.form_create_rules: if self.form_create_rules:
...@@ -671,6 +687,7 @@ class BaseModelView(BaseView, ActionsMixin): ...@@ -671,6 +687,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 +732,27 @@ class BaseModelView(BaseView, ActionsMixin): ...@@ -715,6 +732,27 @@ 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)
# 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 +986,42 @@ class BaseModelView(BaseView, ActionsMixin): ...@@ -948,43 +986,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('_')
if ofs == -1:
continue continue
try: if '_' not in n:
pos = int(n[3:ofs])
idx = int(n[ofs + 1:])
except ValueError:
continue continue
if idx >= 0 and idx < len(self._filters): pos, key = n[3:].split('_', 1)
flt = self._filters[idx]
if key in self._filter_args:
idx, flt = self._filter_args[key]
value = request.args[n] value = request.args[n]
if flt.validate(value): if flt.validate(value):
sfilters.append((pos, (idx, flt.clean(value)))) filters.append((pos, (idx, flt.clean(value))))
filters = [v[1] for v in sorted(sfilters, key=lambda n: n[0])] # Sort filters
else: return [v[1] for v in sorted(filters, key=lambda n: n[0])]
filters = None
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 +1053,11 @@ class BaseModelView(BaseView, ActionsMixin): ...@@ -1016,9 +1053,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 +1077,6 @@ class BaseModelView(BaseView, ActionsMixin): ...@@ -1038,12 +1077,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):
""" """
...@@ -1058,8 +1091,8 @@ class BaseModelView(BaseView, ActionsMixin): ...@@ -1058,8 +1091,8 @@ class BaseModelView(BaseView, ActionsMixin):
""" """
column_fmt = self.column_formatters.get(name) column_fmt = self.column_formatters.get(name)
if column_fmt is not None: if column_fmt is not None:
return column_fmt(self, context, model, name) value = column_fmt(self, context, model, name)
else:
value = self._get_field_value(model, name) value = self._get_field_value(model, name)
choices_map = self._column_choices_map.get(name, {}) choices_map = self._column_choices_map.get(name, {})
...@@ -1104,7 +1137,7 @@ class BaseModelView(BaseView, ActionsMixin): ...@@ -1104,7 +1137,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 +1153,6 @@ class BaseModelView(BaseView, ActionsMixin): ...@@ -1120,18 +1153,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
...@@ -1187,8 +1208,6 @@ class BaseModelView(BaseView, ActionsMixin): ...@@ -1187,8 +1208,6 @@ class BaseModelView(BaseView, ActionsMixin):
# Filters # Filters
filters=self._filters, filters=self._filters,
filter_groups=self._filter_groups, filter_groups=self._filter_groups,
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,7 +34,7 @@ var AdminFilters = function(element, filters_element, operations, options, types ...@@ -23,7 +34,7 @@ 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
...@@ -41,8 +52,8 @@ var AdminFilters = function(element, filters_element, operations, options, types ...@@ -41,8 +52,8 @@ var AdminFilters = function(element, filters_element, operations, options, types
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(
...@@ -52,15 +63,15 @@ var AdminFilters = function(element, filters_element, operations, options, types ...@@ -52,15 +63,15 @@ var AdminFilters = function(element, filters_element, operations, options, types
$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();
......
...@@ -90,7 +90,7 @@ ...@@ -90,7 +90,7 @@
{% endif %} {% endif %}
</td> </td>
<td> <td>
{{ size }} {{ size|filesizeformat }}
</td> </td>
{% endif %} {% endif %}
{% endblock %} {% endblock %}
......
...@@ -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 %}
...@@ -99,6 +99,7 @@ ...@@ -99,6 +99,7 @@
<input type="checkbox" name="rowid" class="action-checkbox" value="{{ get_pk_value(row) }}" title="{{ _gettext('Select record') }}" /> <input type="checkbox" name="rowid" class="action-checkbox" value="{{ get_pk_value(row) }}" title="{{ _gettext('Select record') }}" />
</td> </td>
{% endif %} {% endif %}
{% block list_row_actions_column scoped %}
<td> <td>
{% block list_row_actions scoped %} {% block list_row_actions scoped %}
{%- if admin_view.can_edit -%} {%- if admin_view.can_edit -%}
...@@ -118,6 +119,7 @@ ...@@ -118,6 +119,7 @@
{%- endif -%} {%- endif -%}
{% endblock %} {% endblock %}
</td> </td>
{% endblock %}
{% for c, name in list_columns %} {% for c, name in list_columns %}
<td>{{ get_value(row, c) }}</td> <td>{{ get_value(row, c) }}</td>
{% endfor %} {% endfor %}
...@@ -157,12 +159,10 @@ ...@@ -157,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_groups[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_groups[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_groups[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_groups[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_groups[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_groups[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_groups[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_groups[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_groups[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()
......
...@@ -95,6 +95,10 @@ def test_image_upload_field(): ...@@ -95,6 +95,10 @@ def test_image_upload_field():
safe_delete(path, 'test2.png') safe_delete(path, 'test2.png')
safe_delete(path, 'test2_thumb.jpg') safe_delete(path, 'test2_thumb.jpg')
safe_delete(path, 'test1.jpg') safe_delete(path, 'test1.jpg')
safe_delete(path, 'test1.jpeg')
safe_delete(path, 'test1.gif')
safe_delete(path, 'test1.png')
safe_delete(path, 'test1.tiff')
class TestForm(form.BaseForm): class TestForm(form.BaseForm):
upload = form.ImageUploadField('Upload', upload = form.ImageUploadField('Upload',
...@@ -204,6 +208,25 @@ def test_image_upload_field(): ...@@ -204,6 +208,25 @@ def test_image_upload_field():
ok_(op.exists(op.join(path, 'test1.jpg'))) ok_(op.exists(op.join(path, 'test1.jpg')))
# check allowed extensions
for extension in ('gif', 'jpg', 'jpeg', 'png', 'tiff'):
filename = 'copyleft.' + extension
filepath = op.join(op.dirname(__file__), 'data', filename)
with open(filepath, 'rb') as fp:
with app.test_request_context(method='POST', data={'upload': (fp, filename)}):
my_form = TestNoResizeForm(helpers.get_form_data())
ok_(my_form.validate())
my_form.populate_obj(dummy)
eq_(dummy.upload, my_form.upload.data.filename)
# check case-sensitivity for extensions
filename = op.join(op.dirname(__file__), 'data', 'copyleft.jpg')
with open(filename, 'rb') as fp:
with app.test_request_context(method='POST', data={'upload': (fp, 'copyleft.JPG')}):
my_form = TestNoResizeForm(helpers.get_form_data())
ok_(my_form.validate())
def test_relative_path(): def test_relative_path():
app = Flask(__name__) app = Flask(__name__)
......
...@@ -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_groups[u'col1']], [(0, 'test')])
'col2': [(1, 'test')]}) eq_([(f['index'], f['operation']) for f in view._filter_groups[u'col2']], [(1, 'test')])
# TODO: Make calls with filters # TODO: Make calls with filters
......
...@@ -254,6 +254,10 @@ msgstr "Создать" ...@@ -254,6 +254,10 @@ msgstr "Создать"
msgid "Save and Add" msgid "Save and Add"
msgstr "Сохранить и Добавить" msgstr "Сохранить и Добавить"
#: ../flask_admin/templates/admin/model/edit.html:6
msgid "Save and Continue"
msgstr "Сохранить и Продолжить"
#: ../flask_admin/templates/admin/model/inline_form_list.html:24 #: ../flask_admin/templates/admin/model/inline_form_list.html:24
msgid "Delete?" msgid "Delete?"
msgstr "Удалить?" msgstr "Удалить?"
......
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