Commit 9f9fee13 authored by Alex Kerney's avatar Alex Kerney

Merge pull request #2 from mrjoes/master

Up to date
parents 94a10946 08a4de57
......@@ -8,6 +8,8 @@ Development Lead
Patches and Suggestions
```````````````````````
- Paul Brown <paul90brown@gmail.com>
- Petrus Janse van Rensburg <petrus.jvrensburg@gmail.com>
- Priit Laes <plaes@plaes.org>
- Sean Lynch
- Andy Wilson <wilson.andrew.j+github@gmail.com>
......
This diff is collapsed.
#!/bin/sh
pybabel extract -F babel.ini -k _gettext -k _ngettext -k lazy_gettext -o admin.pot --project Flask-Admin ../flask_admin
pybabel compile -D admin -d ../flask_admin/translations/
pybabel compile -f -D admin -d ../flask_admin/translations/
Changelog
=========
1.1 (dev)
---------
1.1.0
-----
Mostly bug fix release. Highlights:
* Inline model editing on the list page
* FileAdmin refactoring and fixes
* FileUploadField and ImageUploadField will work with Required() validator
* Bug fixes
1.0.9
-----
Highlights:
* Added the ``geoa`` contrib module, for working with `geoalchemy2`_
* Bootstrap 3 support
* WTForms 2.x support
* Updated DateTime picker
* SQLAlchemy backend: support for complex sortables, ability to search for related models, model inheritance support
* Customizable URL generation logic for all views
* New generic filter types: in list, empty, date range
* Added the ``geoa`` contrib module, for working with `geoalchemy2 <http://geoalchemy-2.readthedocs.org/>`_
* Portugese translation
* Lots of bug fixes
.. _geoalchemy2: http://geoalchemy-2.readthedocs.org/
1.0.8
-----
......@@ -43,17 +61,3 @@ Highlights:
* Redis cli
* SQLAlchemy backend can handle inherited models with multiple PKs
* Lots of bug fixes
1.0.6
-----
* Model views now support default sorting order
* Model type/column formatters now accept additional `view` parameter
* `is_visible` for administrative views
* Model views have `after_model_change` method that can be overridden
* In model views, `get_query` was split into `get_count_query` and `get_query`
* Bootstrap 2.3.1
* Bulk deletes go through `delete_model`
* Flask-Admin no longer uses floating navigation bar
* Translations: French, Persian (Farsi), Chinese (Simplified/Traditional), Czech
* Bug fixes
......@@ -37,14 +37,11 @@ Creating simple model
---------------------
GeoAlchemy comes with a `Geometry`_ field that is carefully divorced from the
`Shapely`_ library. Flask-Admin takes the approach that if you're using spatial
objects in your database, and you want an admin interface to edit those objects,
you're probably already using Shapely, so we provide a Geometry field that is
integrated with Shapely objects. To make your admin interface works, be sure to
use this field rather that the one that ships with GeoAlchemy when defining your
models::
from flask.ext.admin.contrib.geoa.sqltypes import Geometry
`Shapely`_ library. Flask-Admin will use this field so that there are no
changes necessary to other code. ``ModelView`` should be imported from
``geoa`` rather than the one imported from ``sqla``::
from geoalchemy2 import Geometry
from flask.ext.admin.contrib.geoa import ModelView
# .. flask initialization
......@@ -62,9 +59,6 @@ models::
db.create_all()
app.run('0.0.0.0', 8000)
Note that you also have to use the ``ModelView`` class imported from ``geoa``,
rather than the one imported from ``sqla``.
Limitations
-----------
......
......@@ -21,8 +21,8 @@ Getting started
To start using the form rendering rules, put a list of form field names into the `form_create_rules`
property one of your admin views::
class RuleView(sqla.ModelView):
form_create_rules = ('email', 'first_name', 'last_name')
class RuleView(sqla.ModelView):
form_create_rules = ('email', 'first_name', 'last_name')
In this example, only three fields will be rendered and `email` field will be above other two fields.
......@@ -32,10 +32,10 @@ form field reference and creates a :class:`flask.ext.admin.form.rules.Field` cla
Lets say we want to display some text between the `email` and `first_name` fields. This can be accomplished by
using the :class:`flask.ext.admin.form.rules.Text` class::
from flask.ext.admin.form import rules
from flask.ext.admin.form import rules
class RuleView(sqla.ModelView):
form_create_rules = ('email', rules.Text('Foobar'), 'first_name', 'last_name')
class RuleView(sqla.ModelView):
form_create_rules = ('email', rules.Text('Foobar'), 'first_name', 'last_name')
Built-in rules
--------------
......@@ -58,44 +58,66 @@ Form Rendering Rule Description
Enabling CSRF Validation
---------------
Flask-Admin does not use Flask-WTF Form class - it uses the wtforms Form class, which does not have CSRF validation.
Adding CSRF validation will require importing flask_wtf and overriding the :class:`flask.ext.admin.form.BaseForm` by using :attr:`flask.ext.admin.model.BaseModelView.form_base_class`::
import os
import flask
**import flask_wtf**
import flask_admin
import flask_sqlalchemy
from flask_admin.contrib.sqla import ModelView
DBFILE = 'app.db'
app = flask.Flask(__name__)
app.config['SECRET_KEY'] = 'Dnit7qz7mfcP0YuelDrF8vLFvk0snhwP'
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///' + DBFILE
**app.config['CSRF_ENABLED'] = True**
**flask_wtf.CsrfProtect(app)**
db = flask_sqlalchemy.SQLAlchemy(app)
admin = flask_admin.Admin(app, name='Admin')
## Here is the fix:
class MyModelView(ModelView):
**form_base_class = flask_wtf.Form**
class User(db.Model):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String)
password = db.Column(db.String)
if not os.path.exists(DBFILE):
db.create_all()
## The subclass is used here:
admin.add_view( MyModelView(User, db.session, name='User') )
app.run(debug=True)
Adding CSRF validation will require overriding the :class:`flask.ext.admin.form.BaseForm` by using :attr:`flask.ext.admin.model.BaseModelView.form_base_class`.
WTForms >=2::
from wtforms.csrf.session import SessionCSRF
from wtforms.meta import DefaultMeta
from flask import session
from datetime import timedelta
from flask.ext.admin import form
from flask.ext.admin.contrib import sqla
class SecureForm(form.BaseForm):
class Meta(DefaultMeta):
csrf = True
csrf_class = SessionCSRF
csrf_secret = b'EPj00jpfj8Gx1SjnyLxwBBSQfnQ9DJYe0Ym'
csrf_time_limit = timedelta(minutes=20)
@property
def csrf_context(self):
return session
class ModelAdmin(sqla.ModelView):
form_base_class = SecureForm
For WTForms 1, you can use use Flask-WTF's Form class::
import os
import flask
import flask_wtf
import flask_admin
import flask_sqlalchemy
from flask_admin.contrib.sqla import ModelView
DBFILE = 'app.db'
app = flask.Flask(__name__)
app.config['SECRET_KEY'] = 'Dnit7qz7mfcP0YuelDrF8vLFvk0snhwP'
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///' + DBFILE
app.config['CSRF_ENABLED'] = True
flask_wtf.CsrfProtect(app)
db = flask_sqlalchemy.SQLAlchemy(app)
admin = flask_admin.Admin(app, name='Admin')
class MyModelView(ModelView):
# Here is the fix:
form_base_class = flask_wtf.Form
class User(db.Model):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String)
password = db.Column(db.String)
if not os.path.exists(DBFILE):
db.create_all()
admin.add_view( MyModelView(User, db.session, name='User') )
app.run(debug=True)
Further reading
---------------
......
......@@ -165,45 +165,18 @@ have access to the view in question::
def is_accessible(self):
return login.current_user.is_authenticated()
You can also implement policy-based security, conditionally allowing or disallowing access to parts of the
administrative interface. If a user does not have access to a particular view, the menu item won't be visible.
Generating URLs
---------------
Internally, view classes work on top of Flask blueprints, so you can use *url_for* with a dot
prefix to get the URL for a local view::
from flask import url_for
To redirect the user to another page if authentication fails, you will need to specify an *_handle_view* method::
class MyView(BaseView):
@expose('/')
def index(self)
# Get URL for the test view method
url = url_for('.test')
return self.render('index.html', url=url)
@expose('/test/')
def test(self):
return self.render('test.html')
If you want to generate a URL for a particular view method from outside, the following rules apply:
1. You can override the endpoint name by passing *endpoint* parameter to the view class constructor::
admin = Admin(app)
admin.add_view(MyView(endpoint='testadmin'))
In this case, you can generate links by concatenating the view method name with an endpoint::
url_for('testadmin.index')
2. If you don't override the endpoint name, the lower-case class name can be used for generating URLs, like in::
url_for('myview.index')
3. For model-based views the rules differ - the model class name should be used if an endpoint name is not provided. Model-based views will be explained in the next section.
def is_accessible(self):
return login.current_user.is_authenticated()
def _handle_view(self, name, **kwargs):
if not self.is_accessible():
return redirect(url_for('login', next=request.url))
You can also implement policy-based security, conditionally allowing or disallowing access to parts of the
administrative interface. If a user does not have access to a particular view, the menu item won't be visible.
Model Views
-----------
......@@ -299,6 +272,51 @@ Sample screenshot:
You can disable uploads, disable file or directory deletion, restrict file uploads to certain types and so on.
Check :mod:`flask.ext.admin.contrib.fileadmin` documentation on how to do it.
Generating URLs
---------------
Internally, view classes work on top of Flask blueprints, so you can use *url_for* with a dot
prefix to get the URL for a local view::
from flask import url_for
class MyView(BaseView):
@expose('/')
def index(self)
# Get URL for the test view method
url = url_for('.test')
return self.render('index.html', url=url)
@expose('/test/')
def test(self):
return self.render('test.html')
If you want to generate a URL for a particular view method from outside, the following rules apply:
1. You can override the endpoint name by passing *endpoint* parameter to the view class constructor::
admin = Admin(app)
admin.add_view(MyView(endpoint='testadmin'))
In this case, you can generate links by concatenating the view method name with an endpoint::
url_for('testadmin.index')
2. If you don't override the endpoint name, the lower-case class name can be used for generating URLs, like in::
url_for('myview.index')
3. For model-based views the rules differ - the model class name should be used if an endpoint name is not provided. The ModelView also has these endpoints by default: *.index_view*, *.create_view*, and *.edit_view*. So, the following urls can be generated for a model named "User"::
# List View
url_for('user.index_view')
# Create View (redirect back to index_view)
url_for('user.create_view', url=url_for('user.index_view'))
# Edit View for record #1 (redirect back to index_view)
url_for('user.edit_view', id=1, url=url_for('user.index_view'))
Examples
--------
......
......@@ -4,20 +4,20 @@ To run this example:
1. Clone the repository::
git clone https://github.com/mrjoes/flask-admin.git
cd flask-admin
git clone https://github.com/mrjoes/flask-admin.git
cd flask-admin
2. Create and activate a virtual environment::
virtualenv env
source env/bin/activate
virtualenv env
source env/bin/activate
3. Install requirements::
pip install -r 'examples/auth-mongoengine/requirements.txt'
pip install -r 'examples/auth-mongoengine/requirements.txt'
4. Run the application::
python examples/auth-mongoengine/app.py
python examples/auth-mongoengine/app.py
......@@ -4,24 +4,24 @@ To run this example:
1. Clone the repository::
git clone https://github.com/mrjoes/flask-admin.git
cd flask-admin
git clone https://github.com/mrjoes/flask-admin.git
cd flask-admin
2. Create and activate a virtual environment::
virtualenv env
source env/bin/activate
virtualenv env
source env/bin/activate
3. Install requirements::
pip install -r 'examples/auth/requirements.txt'
pip install -r 'examples/auth/requirements.txt'
4. Run the application::
python examples/auth/app.py
python examples/auth/app.py
The first time you run this example, a sample sqlite database gets populated automatically. To suppress this behaviour,
comment the following lines in app.py:::
if not os.path.exists(database_path):
build_sample_db()
if not os.path.exists(database_path):
build_sample_db()
......@@ -4,20 +4,20 @@ To run this example:
1. Clone the repository::
git clone https://github.com/mrjoes/flask-admin.git
cd flask-admin
git clone https://github.com/mrjoes/flask-admin.git
cd flask-admin
2. Create and activate a virtual environment::
virtualenv env
source env/bin/activate
virtualenv env
source env/bin/activate
3. Install requirements::
pip install -r 'examples/babel/requirements.txt'
pip install -r 'examples/babel/requirements.txt'
4. Run the application::
python examples/babel/app.py
python examples/babel/app.py
......@@ -4,20 +4,20 @@ To run this example:
1. Clone the repository::
git clone https://github.com/mrjoes/flask-admin.git
cd flask-admin
git clone https://github.com/mrjoes/flask-admin.git
cd flask-admin
2. Create and activate a virtual environment::
virtualenv env
source env/bin/activate
virtualenv env
source env/bin/activate
3. Install requirements::
pip install -r 'examples/file/requirements.txt'
pip install -r 'examples/file/requirements.txt'
4. Run the application::
python examples/file/app.py
python examples/file/app.py
......@@ -5,24 +5,24 @@ To run this example:
1. Clone the repository::
git clone https://github.com/mrjoes/flask-admin.git
cd flask-admin
git clone https://github.com/mrjoes/flask-admin.git
cd flask-admin
2. Create and activate a virtual environment::
virtualenv env
source env/bin/activate
virtualenv env
source env/bin/activate
3. Install requirements::
pip install -r 'examples/forms/requirements.txt'
pip install -r 'examples/forms/requirements.txt'
4. Run the application::
python examples/forms/app.py
python examples/forms/app.py
The first time you run this example, a sample sqlite database gets populated automatically. To suppress this behaviour,
comment the following lines in app.py:::
if not os.path.exists(database_path):
build_sample_db()
if not os.path.exists(database_path):
build_sample_db()
......@@ -4,24 +4,24 @@ To run this example:
1. Clone the repository::
git clone https://github.com/mrjoes/flask-admin.git
cd flask-admin
git clone https://github.com/mrjoes/flask-admin.git
cd flask-admin
2. Create and activate a virtual environment::
virtualenv env
source env/bin/activate
virtualenv env
source env/bin/activate
3. Install requirements::
pip install -r 'examples/layout-bootstrap3/requirements.txt'
pip install -r 'examples/layout-bootstrap3/requirements.txt'
4. Run the application::
python examples/layout-bootstrap3/app.py
python examples/layout-bootstrap3/app.py
The first time you run this example, a sample sqlite database gets populated automatically. To suppress this behaviour,
comment the following lines in app.py:::
if not os.path.exists(database_path):
build_sample_db()
if not os.path.exists(database_path):
build_sample_db()
......@@ -4,24 +4,24 @@ To run this example:
1. Clone the repository::
git clone https://github.com/mrjoes/flask-admin.git
cd flask-admin
git clone https://github.com/mrjoes/flask-admin.git
cd flask-admin
2. Create and activate a virtual environment::
virtualenv env
source env/bin/activate
virtualenv env
source env/bin/activate
3. Install requirements::
pip install -r 'examples/layout/requirements.txt'
pip install -r 'examples/layout/requirements.txt'
4. Run the application::
python examples/layout/app.py
python examples/layout/app.py
The first time you run this example, a sample sqlite database gets populated automatically. To suppress this behaviour,
comment the following lines in app.py:::
if not os.path.exists(database_path):
build_sample_db()
if not os.path.exists(database_path):
build_sample_db()
......@@ -4,20 +4,20 @@ To run this example:
1. Clone the repository::
git clone https://github.com/mrjoes/flask-admin.git
cd flask-admin
git clone https://github.com/mrjoes/flask-admin.git
cd flask-admin
2. Create and activate a virtual environment::
virtualenv env
source env/bin/activate
virtualenv env
source env/bin/activate
3. Install requirements::
pip install -r 'examples/menu-external-links/requirements.txt'
pip install -r 'examples/menu-external-links/requirements.txt'
4. Run the application::
python examples/menu-external-links/app.py
python examples/menu-external-links/app.py
......@@ -4,18 +4,18 @@ To run this example:
1. Clone the repository::
git clone https://github.com/mrjoes/flask-admin.git
cd flask-admin
git clone https://github.com/mrjoes/flask-admin.git
cd flask-admin
2. Create and activate a virtual environment::
virtualenv env
source env/bin/activate
virtualenv env
source env/bin/activate
3. Install requirements::
pip install -r 'examples/methodview/requirements.txt'
pip install -r 'examples/methodview/requirements.txt'
4. Run the application::
python examples/methodview/app.py
python examples/methodview/app.py
......@@ -4,19 +4,19 @@ To run this example:
1. Clone the repository::
git clone https://github.com/mrjoes/flask-admin.git
cd flask-admin
git clone https://github.com/mrjoes/flask-admin.git
cd flask-admin
2. Create and activate a virtual environment::
virtualenv env
source env/bin/activate
virtualenv env
source env/bin/activate
3. Install requirements::
pip install -r 'examples/mongoengine/requirements.txt'
pip install -r 'examples/mongoengine/requirements.txt'
4. Run the application::
python examples/mongoengine/app.py
python examples/mongoengine/app.py
......@@ -4,19 +4,19 @@ To run this example:
1. Clone the repository::
git clone https://github.com/mrjoes/flask-admin.git
cd flask-admin
git clone https://github.com/mrjoes/flask-admin.git
cd flask-admin
2. Create and activate a virtual environment::
virtualenv env
source env/bin/activate
virtualenv env
source env/bin/activate
3. Install requirements::
pip install -r 'examples/multi/requirements.txt'
pip install -r 'examples/multi/requirements.txt'
4. Run the application::
python examples/multi/app.py
python examples/multi/app.py
......@@ -4,19 +4,19 @@ To run this example:
1. Clone the repository::
git clone https://github.com/mrjoes/flask-admin.git
cd flask-admin
git clone https://github.com/mrjoes/flask-admin.git
cd flask-admin
2. Create and activate a virtual environment::
virtualenv env
source env/bin/activate
virtualenv env
source env/bin/activate
3. Install requirements::
pip install -r 'examples/peewee/requirements.txt'
pip install -r 'examples/peewee/requirements.txt'
4. Run the application::
python examples/peewee/app.py
python examples/peewee/app.py
......@@ -4,19 +4,19 @@ To run this example:
1. Clone the repository::
git clone https://github.com/mrjoes/flask-admin.git
cd flask-admin
git clone https://github.com/mrjoes/flask-admin.git
cd flask-admin
2. Create and activate a virtual environment::
virtualenv env
source env/bin/activate
virtualenv env
source env/bin/activate
3. Install requirements::
pip install -r 'examples/pymongo/requirements.txt'
pip install -r 'examples/pymongo/requirements.txt'
4. Run the application::
python examples/pymongo/app.py
python examples/pymongo/app.py
......@@ -4,21 +4,21 @@ To run this example:
1. Clone the repository::
git clone https://github.com/mrjoes/flask-admin.git
cd flask-admin
git clone https://github.com/mrjoes/flask-admin.git
cd flask-admin
2. Create and activate a virtual environment::
virtualenv env
source env/bin/activate
virtualenv env
source env/bin/activate
3. Install requirements::
pip install -r 'examples/quickstart/requirements.txt'
pip install -r 'examples/quickstart/requirements.txt'
4. Run the application with any of the following::
python examples/quickstart/app.py
python examples/quickstart/app2.py
python examples/quickstart/app3.py
python examples/quickstart/app.py
python examples/quickstart/app2.py
python examples/quickstart/app3.py
......@@ -4,20 +4,20 @@ To run this example:
1. Clone the repository::
git clone https://github.com/mrjoes/flask-admin.git
cd flask-admin
git clone https://github.com/mrjoes/flask-admin.git
cd flask-admin
2. Create and activate a virtual environment::
virtualenv env
source env/bin/activate
virtualenv env
source env/bin/activate
3. Install requirements::
pip install -r 'examples/rediscli/requirements.txt'
pip install -r 'examples/rediscli/requirements.txt'
4. Run the application::
python examples/rediscli/app.py
python examples/rediscli/app.py
You should now be able to access a Redis instance on your machine (if it is running) through the admin interface.
\ No newline at end of file
You should now be able to access a Redis instance on your machine (if it is running) through the admin interface.
......@@ -5,18 +5,18 @@ To run this example:
1. Clone the repository::
git clone https://github.com/mrjoes/flask-admin.git
cd flask-admin
git clone https://github.com/mrjoes/flask-admin.git
cd flask-admin
2. Create and activate a virtual environment::
virtualenv env
source env/bin/activate
virtualenv env
source env/bin/activate
3. Install requirements::
pip install -r 'examples/simple/requirements.txt'
pip install -r 'examples/simple/requirements.txt'
4. Run the application::
python examples/simple/app.py
python examples/simple/app.py
......@@ -4,24 +4,24 @@ To run this example:
1. Clone the repository::
git clone https://github.com/mrjoes/flask-admin.git
cd flask-admin
git clone https://github.com/mrjoes/flask-admin.git
cd flask-admin
2. Create and activate a virtual environment::
virtualenv env
source env/bin/activate
virtualenv env
source env/bin/activate
3. Install requirements::
pip install -r 'examples/sqla-custom-filter/requirements.txt'
pip install -r 'examples/sqla-custom-filter/requirements.txt'
4. Run the application::
python examples/sqla-custom-filter/app.py
python examples/sqla-custom-filter/app.py
The first time you run this example, a sample sqlite database gets populated automatically. To suppress this behaviour,
comment the following lines in app.py:::
if not os.path.exists(database_path):
build_sample_db()
if not os.path.exists(database_path):
build_sample_db()
......@@ -4,20 +4,20 @@ To run this example:
1. Clone the repository::
git clone https://github.com/mrjoes/flask-admin.git
cd flask-admin
git clone https://github.com/mrjoes/flask-admin.git
cd flask-admin
2. Create and activate a virtual environment::
virtualenv env
source env/bin/activate
virtualenv env
source env/bin/activate
3. Install requirements::
pip install -r 'examples/sqla-inline/requirements.txt'
pip install -r 'examples/sqla-inline/requirements.txt'
4. Run the application::
python examples/sqla-inline/app.py
python examples/sqla-inline/app.py
......@@ -4,25 +4,25 @@ To run this example:
1. Clone the repository::
git clone https://github.com/mrjoes/flask-admin.git
cd flask-admin
git clone https://github.com/mrjoes/flask-admin.git
cd flask-admin
2. Create and activate a virtual environment::
virtualenv env
source env/bin/activate
virtualenv env
source env/bin/activate
3. Install requirements::
pip install -r 'examples/sqla/requirements.txt'
pip install -r 'examples/sqla/requirements.txt'
4. Run either of these applications::
python examples/sqla/app.py
python examples/sqla/app2.py
python examples/sqla/app.py
python examples/sqla/app2.py
The first time you run this example, a sample sqlite database gets populated automatically. To suppress this behaviour,
comment the following lines in app.py:::
if not os.path.exists(database_path):
build_sample_db()
if not os.path.exists(database_path):
build_sample_db()
......@@ -4,19 +4,19 @@ To run this example:
1. Clone the repository::
git clone https://github.com/mrjoes/flask-admin.git
cd flask-admin
git clone https://github.com/mrjoes/flask-admin.git
cd flask-admin
2. Create and activate a virtual environment::
virtualenv env
source env/bin/activate
virtualenv env
source env/bin/activate
3. Install requirements::
pip install -r 'examples/wysiwyg/requirements.txt'
pip install -r 'examples/wysiwyg/requirements.txt'
4. Run the application::
python examples/wysiwyg/app.py
python examples/wysiwyg/app.py
__version__ = '1.0.9.dev0'
__version__ = '1.1.0'
__author__ = 'Serge S. Koval'
__email__ = 'serge.koval+github@gmail.com'
......
......@@ -116,6 +116,10 @@ class BaseView(with_metaclass(AdminViewMeta, BaseViewClass)):
@expose('/')
def index(self):
return 'Hello World!'
Icons can be added to the menu by using `menu_icon_type` and `menu_icon_value`. For example::
admin.add_view(MyView(name='My View', menu_icon_type='glyph', menu_icon_value='glyphicon-home'))
"""
@property
def _template_args(self):
......@@ -229,10 +233,16 @@ class BaseView(with_metaclass(AdminViewMeta, BaseViewClass)):
if not self.url.startswith('/'):
self.url = '%s/%s' % (self.admin.url, self.url)
# If we're working from the root of the site, set prefix to None
if self.url == '/':
self.url = None
# prevent admin static files from conflicting with flask static files
if not self.static_url_path:
self.static_folder='static'
self.static_url_path='/static/admin'
# If name is not povided, use capitalized endpoint name
if self.name is None:
self.name = self._prettify_class_name(self.__class__.__name__)
......@@ -383,9 +393,21 @@ class AdminIndexView(BaseView):
@expose('/')
def index(self):
arg1 = 'Hello'
return render_template('adminhome.html', arg1=arg1)
return self.render('admin/myhome.html', arg1=arg1)
admin = Admin(index_view=MyHomeView())
Also, you can change the root url from /admin to / with the following::
admin = Admin(
app,
index_view=AdminIndexView(
name='Home',
template='admin/myhome.html',
url='/'
)
)
Default values for the index page are:
......@@ -397,12 +419,18 @@ class AdminIndexView(BaseView):
"""
def __init__(self, name=None, category=None,
endpoint=None, url=None,
template='admin/index.html'):
template='admin/index.html',
menu_class_name=None,
menu_icon_type=None,
menu_icon_value=None):
super(AdminIndexView, self).__init__(name or babel.lazy_gettext('Home'),
category,
endpoint or 'admin',
url or '/admin',
'static')
'static',
menu_class_name=menu_class_name,
menu_icon_type=menu_icon_type,
menu_icon_value=menu_icon_value)
self._template = template
@expose()
......
try:
import wtforms_appengine
except ImportError:
raise Exception('Please install wtforms_appengine in order to use appengine backend')
from .view import ModelView
import logging
from flask.ext.admin.model import BaseModelView
from wtforms_appengine import db as wt_db
from wtforms_appengine import ndb as wt_ndb
from google.appengine.ext import db
from google.appengine.ext import ndb
class NdbModelView(BaseModelView):
"""
AppEngine NDB model scaffolding.
"""
def get_pk_value(self, model):
return model.key.urlsafe()
def scaffold_list_columns(self):
return sorted([k for (k, v) in self.model.__dict__.iteritems() if isinstance(v, ndb.Property)])
def scaffold_sortable_columns(self):
return [k for (k, v) in self.model.__dict__.iteritems() if isinstance(v, ndb.Property) and v._indexed]
def init_search(self):
return None
def is_valid_filter(self):
pass
def scaffold_filters(self):
#TODO: implement
pass
def scaffold_form(self):
return wt_ndb.model_form(self.model())
def get_list(self, page, sort_field, sort_desc, search, filters):
#TODO: implement filters (don't think search can work here)
q = self.model.query()
if sort_field:
order_field = getattr(self.model, sort_field)
if sort_desc:
order_field = -order_field
q = q.order(order_field)
results = q.fetch(self.page_size, offset=page*self.page_size)
return q.count(), results
def get_one(self, urlsafe_key):
return ndb.Key(urlsafe=urlsafe_key).get()
def create_model(self, form):
try:
model = self.model()
form.populate_obj(model)
model.put()
return True
except Exception as ex:
if not self.handle_view_exception(ex):
#flash(gettext('Failed to create record. %(error)s',
# error=ex), 'error')
logging.exception('Failed to create record.')
return False
def update_model(self, form, model):
try:
form.populate_obj(model)
model.put()
return True
except Exception as ex:
if not self.handle_view_exception(ex):
#flash(gettext('Failed to update record. %(error)s',
# error=ex), 'error')
logging.exception('Failed to update record.')
return False
def delete_model(self, model):
try:
model.key.delete()
return True
except Exception as ex:
if not self.handle_view_exception(ex):
#flash(gettext('Failed to delete record. %(error)s',
# error=ex),
# 'error')
logging.exception('Failed to delete record.')
return False
class DbModelView(BaseModelView):
"""
AppEngine DB model scaffolding.
"""
def get_pk_value(self, model):
return str(model.key())
def scaffold_list_columns(self):
return sorted([k for (k, v) in self.model.__dict__.iteritems() if isinstance(v, db.Property)])
def scaffold_sortable_columns(self):
return [k for (k, v) in self.model.__dict__.iteritems() if isinstance(v, db.Property) and v._indexed]
def init_search(self):
return None
def is_valid_filter(self):
pass
def scaffold_filters(self):
#TODO: implement
pass
def scaffold_form(self):
return wt_db.model_form(self.model())
def get_list(self, page, sort_field, sort_desc, search, filters):
#TODO: implement filters (don't think search can work here)
q = self.model.all()
if sort_field:
if sort_desc:
sort_field = "-" + sort_field
q.order(sort_field)
results = q.fetch(self.page_size, offset=page*self.page_size)
return q.count(), results
def get_one(self, encoded_key):
return db.get(db.Key(encoded=encoded_key))
def create_model(self, form):
try:
model = self.model()
form.populate_obj(model)
model.put()
return True
except Exception as ex:
if not self.handle_view_exception(ex):
#flash(gettext('Failed to create record. %(error)s',
# error=ex), 'error')
logging.exception('Failed to create record.')
return False
def update_model(self, form, model):
try:
form.populate_obj(model)
model.put()
return True
except Exception as ex:
if not self.handle_view_exception(ex):
#flash(gettext('Failed to update record. %(error)s',
# error=ex), 'error')
logging.exception('Failed to update record.')
return False
def delete_model(self, model):
try:
model.delete()
return True
except Exception as ex:
if not self.handle_view_exception(ex):
#flash(gettext('Failed to delete record. %(error)s',
# error=ex),
# 'error')
logging.exception('Failed to delete record.')
return False
def ModelView(model):
if issubclass(model, ndb.Model):
return NdbModelView(model)
elif issubclass(model, db.Model):
return DbModelView(model)
else:
raise ValueError("Unsupported model: %s" % model)
This diff is collapsed.
......@@ -2,6 +2,10 @@ import json
from wtforms.fields import TextAreaField
from shapely.geometry import shape, mapping
from .widgets import LeafletWidget
from sqlalchemy import func
import geoalchemy2
#from types import NoneType
#from .. import db how do you get db.session in a Field?
class JSONField(TextAreaField):
......@@ -9,7 +13,7 @@ class JSONField(TextAreaField):
if self.raw_data:
return self.raw_data[0]
if self.data:
return self.to_json(self.data)
return self.data
return ""
def process_formdata(self, valuelist):
......@@ -33,19 +37,32 @@ class JSONField(TextAreaField):
class GeoJSONField(JSONField):
widget = LeafletWidget()
def __init__(self, label=None, validators=None, geometry_type="GEOMETRY", **kwargs):
def __init__(self, label=None, validators=None, geometry_type="GEOMETRY", srid='-1', session=None, **kwargs):
super(GeoJSONField, self).__init__(label, validators, **kwargs)
self.web_srid = 4326
self.srid = srid
if self.srid is -1:
self.transform_srid = self.web_srid
else:
self.transform_srid = self.srid
self.geometry_type = geometry_type.upper()
self.session = session
def _value(self):
if self.raw_data:
return self.raw_data[0]
if self.data:
self.data = mapping(self.data)
if type(self.data) is geoalchemy2.elements.WKBElement:
if self.srid is -1:
self.data = self.session.scalar(func.ST_AsGeoJson(self.data))
else:
self.data = self.session.scalar(func.ST_AsGeoJson(func.ST_Transform(self.data, self.web_srid)))
return super(GeoJSONField, self)._value()
def process_formdata(self, valuelist):
super(GeoJSONField, self).process_formdata(valuelist)
if self.data:
self.data = shape(self.data)
if str(self.data) is '':
self.data = None
if self.data is not None:
web_shape = self.session.scalar(func.ST_AsText(func.ST_Transform(func.ST_GeomFromText(shape(self.data).wkt, self.web_srid), self.transform_srid)))
self.data = 'SRID='+str(self.srid)+';'+str(web_shape)
......@@ -4,7 +4,9 @@ from .fields import GeoJSONField
class AdminModelConverter(SQLAAdminConverter):
@converts('Geometry')
@converts('Geography', 'Geometry')
def convert_geom(self, column, field_args, **extra):
field_args['geometry_type'] = column.type.geometry_type
field_args['srid'] = column.type.srid
field_args['session'] = self.session
return GeoJSONField(**field_args)
from geoalchemy2 import Geometry as BaseGeometry
from geoalchemy2.shape import to_shape
class Geometry(BaseGeometry):
"""
PostGIS datatype that can convert directly to/from Shapely objects,
without worrying about WKTElements or WKBElements.
"""
def result_processor(self, dialect, coltype):
to_wkbelement = super(Geometry, self).result_processor(dialect, coltype)
def process(value):
if value:
return to_shape(to_wkbelement(value))
else:
return None
return process
def bind_processor(self, dialect):
from_wktelement = super(Geometry, self).bind_processor(dialect)
def process(value):
if value:
return from_wktelement(value.wkt)
else:
return None
return process
......@@ -2,8 +2,10 @@ from flask.ext.admin.contrib.sqla.typefmt import DEFAULT_FORMATTERS as BASE_FORM
import json
from jinja2 import Markup
from wtforms.widgets import html_params
from shapely.geometry import mapping
from shapely.geometry.base import BaseGeometry
from geoalchemy2.shape import to_shape
from geoalchemy2.elements import WKBElement
from sqlalchemy import func
from flask import current_app
def geom_formatter(view, value):
......@@ -12,12 +14,15 @@ def geom_formatter(view, value):
"disabled": "disabled",
"data-width": 100,
"data-height": 70,
"data-geometry-type": value.geom_type,
"data-geometry-type": to_shape(value).geom_type,
"data-zoom": 15,
})
geojson = json.dumps(mapping(value))
if value.srid is -1:
geojson = current_app.extensions['sqlalchemy'].db.session.scalar(func.ST_AsGeoJson(value))
else:
geojson = current_app.extensions['sqlalchemy'].db.session.scalar(func.ST_AsGeoJson(value.ST_Transform( 4326)))
return Markup('<textarea %s>%s</textarea>' % (params, geojson))
DEFAULT_FORMATTERS = BASE_FORMATTERS.copy()
DEFAULT_FORMATTERS[BaseGeometry] = geom_formatter
DEFAULT_FORMATTERS[WKBElement] = geom_formatter
......@@ -10,6 +10,8 @@ def lng(pt):
class LeafletWidget(TextArea):
data_role = 'leaflet'
"""
`Leaflet <http://leafletjs.com/>`_ styled map widget. Inherits from
`TextArea` so that geographic data can be stored via the <textarea>
......@@ -31,14 +33,14 @@ class LeafletWidget(TextArea):
self.max_bounds = max_bounds
def __call__(self, field, **kwargs):
kwargs.setdefault('data-role', 'leaflet')
kwargs.setdefault('data-role', self.data_role)
gtype = getattr(field, "geometry_type", "GEOMETRY")
kwargs.setdefault('data-geometry-type', gtype)
# set optional values from constructor
if self.width:
if not "data-width" in kwargs:
kwargs["data-width"] = self.width
if self.height:
if not "data-height" in kwargs:
kwargs["data-height"] = self.height
if self.center:
kwargs["data-lat"] = lat(self.center)
......
......@@ -92,10 +92,10 @@ class FilterEmpty(BaseMongoEngineFilter, filters.BaseBooleanFilter):
else:
flt = {'%s__ne' % self.column.name: None}
return query.filter(**flt)
def operation(self):
return lazy_gettext('empty')
class FilterInList(BaseMongoEngineFilter):
def __init__(self, column, name, options=None, data_type=None):
......@@ -103,23 +103,23 @@ class FilterInList(BaseMongoEngineFilter):
def clean(self, value):
return [v.strip() for v in value.split(',') if v.strip()]
def apply(self, query, value):
flt = {'%s__in' % self.column.name: value}
return query.filter(**flt)
def operation(self):
return lazy_gettext('in list')
class FilterNotInList(FilterInList):
def apply(self, query, value):
flt = {'%s__nin' % self.column.name: value}
return query.filter(**flt)
def operation(self):
return lazy_gettext('not in list')
# Customized type filters
class BooleanEqualFilter(FilterEqual, filters.BaseBooleanFilter):
......@@ -132,95 +132,95 @@ class BooleanNotEqualFilter(FilterNotEqual, filters.BaseBooleanFilter):
def apply(self, query, value):
flt = {'%s' % self.column.name: value != '1'}
return query.filter(**flt)
class IntEqualFilter(FilterEqual, filters.BaseIntFilter):
pass
class IntNotEqualFilter(FilterNotEqual, filters.BaseIntFilter):
pass
class IntGreaterFilter(FilterGreater, filters.BaseIntFilter):
pass
class IntSmallerFilter(FilterSmaller, filters.BaseIntFilter):
pass
class IntInListFilter(filters.BaseIntListFilter, FilterInList):
pass
class IntNotInListFilter(filters.BaseIntListFilter, FilterNotInList):
pass
class FloatEqualFilter(FilterEqual, filters.BaseFloatFilter):
pass
class FloatNotEqualFilter(FilterNotEqual, filters.BaseFloatFilter):
pass
class FloatGreaterFilter(FilterGreater, filters.BaseFloatFilter):
pass
class FloatSmallerFilter(FilterSmaller, filters.BaseFloatFilter):
pass
class FloatInListFilter(filters.BaseFloatListFilter, FilterInList):
pass
class FloatNotInListFilter(filters.BaseFloatListFilter, FilterNotInList):
pass
class DateTimeEqualFilter(FilterEqual, filters.BaseDateTimeFilter):
pass
class DateTimeNotEqualFilter(FilterNotEqual, filters.BaseDateTimeFilter):
pass
class DateTimeGreaterFilter(FilterGreater, filters.BaseDateTimeFilter):
pass
class DateTimeSmallerFilter(FilterSmaller, filters.BaseDateTimeFilter):
pass
class DateTimeBetweenFilter(BaseMongoEngineFilter, filters.BaseDateTimeBetweenFilter):
def __init__(self, column, name, options=None, data_type=None):
super(DateTimeBetweenFilter, self).__init__(column,
name,
name,
options,
data_type='datetimerangepicker')
def apply(self, query, value):
start, end = value
flt = {'%s__gte' % self.column.name: start, '%s__lte' % self.column.name: end}
return query.filter(**flt)
class DateTimeNotBetweenFilter(DateTimeBetweenFilter):
def apply(self, query, value):
start, end = value
return query.filter(Q(**{'%s__not__gte' % self.column.name: start}) |
return query.filter(Q(**{'%s__not__gte' % self.column.name: start}) |
Q(**{'%s__not__lte' % self.column.name: end}))
def operation(self):
return lazy_gettext('not between')
# Base peewee filter field converter
class FilterConverter(filters.BaseFilterConverter):
strings = (FilterEqual, FilterNotEqual, FilterLike, FilterNotLike,
......@@ -229,17 +229,19 @@ class FilterConverter(filters.BaseFilterConverter):
IntSmallerFilter, FilterEmpty, IntInListFilter,
IntNotInListFilter)
float_filters = (FloatEqualFilter, FloatNotEqualFilter, FloatGreaterFilter,
FloatSmallerFilter, FilterEmpty, FloatInListFilter,
FloatSmallerFilter, FilterEmpty, FloatInListFilter,
FloatNotInListFilter)
bool_filters = (BooleanEqualFilter, BooleanNotEqualFilter)
datetime_filters = (DateTimeEqualFilter, DateTimeNotEqualFilter,
datetime_filters = (DateTimeEqualFilter, DateTimeNotEqualFilter,
DateTimeGreaterFilter, DateTimeSmallerFilter,
DateTimeBetweenFilter, DateTimeNotBetweenFilter,
DateTimeBetweenFilter, DateTimeNotBetweenFilter,
FilterEmpty)
def convert(self, type_name, column, name):
if type_name in self.converters:
return self.converters[type_name](column, name)
filter_name = type_name.lower()
if filter_name in self.converters:
return self.converters[filter_name](column, name)
return None
......@@ -254,11 +256,11 @@ class FilterConverter(filters.BaseFilterConverter):
@filters.convert('IntField', 'LongField')
def conv_int(self, column, name):
return [f(column, name) for f in self.int_filters]
@filters.convert('DecimalField', 'FloatField')
def conv_float(self, column, name):
return [f(column, name) for f in self.float_filters]
@filters.convert('DateTimeField', 'ComplexDateTimeField')
def conv_datetime(self, column, name):
return [f(column, name) for f in self.datetime_filters]
......@@ -5,6 +5,8 @@ from flask import request, flash, abort, Response
from flask.ext.admin import expose
from flask.ext.admin.babel import gettext, ngettext, lazy_gettext
from flask.ext.admin.model import BaseModelView
from flask.ext.admin.model.form import wrap_fields_in_fieldlist
from flask.ext.admin.model.fields import ListEditableFieldList
from flask.ext.admin._compat import iteritems, string_types
import mongoengine
......@@ -21,7 +23,6 @@ from .helpers import format_error
from .ajax import process_ajax_references, create_ajax_loader
from .subdoc import convert_subdocuments
# Set up logger
log = logging.getLogger("flask-admin.mongo")
......@@ -398,6 +399,28 @@ class ModelView(BaseModelView):
return form_class
def scaffold_list_form(self, custom_fieldlist=ListEditableFieldList,
validators=None):
"""
Create form for the `index_view` using only the columns from
`self.column_editable_list`.
:param validators:
`form_args` dict with only validators
{'name': {'validators': [required()]}}
:param custom_fieldlist:
A WTForm FieldList class. By default, `ListEditableFieldList`.
"""
form_class = get_form(self.model,
self.model_form_converter(self),
base_class=self.form_base_class,
only=self.column_editable_list,
field_args=validators)
return wrap_fields_in_fieldlist(self.form_base_class,
form_class,
custom_fieldlist)
# AJAX foreignkey support
def _create_ajax_loader(self, name, opts):
return create_ajax_loader(self.model, name, name, opts)
......@@ -409,6 +432,26 @@ class ModelView(BaseModelView):
"""
return self.model.objects
def _search(self, query, search_term):
# TODO: Unfortunately, MongoEngine contains bug which
# prevents running complex Q queries and, as a result,
# Flask-Admin does not support per-word searching like
# in other backends
op, term = parse_like_term(search_term)
criteria = None
for field in self._search_fields:
flt = {'%s__%s' % (field.name, op): term}
q = mongoengine.Q(**flt)
if criteria is None:
criteria = q
else:
criteria |= q
return query.filter(criteria)
def get_list(self, page, sort_column, sort_desc, search, filters,
execute=True):
"""
......@@ -437,24 +480,7 @@ class ModelView(BaseModelView):
# Search
if self._search_supported and search:
# TODO: Unfortunately, MongoEngine contains bug which
# prevents running complex Q queries and, as a result,
# Flask-Admin does not support per-word searching like
# in other backends
op, term = parse_like_term(search)
criteria = None
for field in self._search_fields:
flt = {'%s__%s' % (field.name, op): term}
q = mongoengine.Q(**flt)
if criteria is None:
criteria = q
else:
criteria |= q
query = query.filter(criteria)
query = self._search(query, search)
# Get count
count = query.count()
......
......@@ -89,7 +89,7 @@ class FilterEmpty(BasePeeweeFilter, filters.BaseBooleanFilter):
def operation(self):
return lazy_gettext('empty')
class FilterInList(BasePeeweeFilter):
def __init__(self, column, name, options=None, data_type=None):
......@@ -97,144 +97,144 @@ class FilterInList(BasePeeweeFilter):
def clean(self, value):
return [v.strip() for v in value.split(',') if v.strip()]
def apply(self, query, value):
return query.filter(self.column << value)
def operation(self):
return lazy_gettext('in list')
class FilterNotInList(FilterInList):
def apply(self, query, value):
# NOT IN can exclude NULL values, so "or_ == None" needed to be added
return query.filter(~(self.column << value) | (self.column >> None))
def operation(self):
return lazy_gettext('not in list')
# Customized type filters
class BooleanEqualFilter(FilterEqual, filters.BaseBooleanFilter):
pass
class BooleanNotEqualFilter(FilterNotEqual, filters.BaseBooleanFilter):
pass
class IntEqualFilter(FilterEqual, filters.BaseIntFilter):
pass
class IntNotEqualFilter(FilterNotEqual, filters.BaseIntFilter):
pass
class IntGreaterFilter(FilterGreater, filters.BaseIntFilter):
pass
class IntSmallerFilter(FilterSmaller, filters.BaseIntFilter):
pass
class IntInListFilter(filters.BaseIntListFilter, FilterInList):
pass
class IntNotInListFilter(filters.BaseIntListFilter, FilterNotInList):
pass
class FloatEqualFilter(FilterEqual, filters.BaseFloatFilter):
pass
class FloatNotEqualFilter(FilterNotEqual, filters.BaseFloatFilter):
pass
class FloatGreaterFilter(FilterGreater, filters.BaseFloatFilter):
pass
class FloatSmallerFilter(FilterSmaller, filters.BaseFloatFilter):
pass
class FloatInListFilter(filters.BaseFloatListFilter, FilterInList):
pass
class FloatNotInListFilter(filters.BaseFloatListFilter, FilterNotInList):
pass
class DateEqualFilter(FilterEqual, filters.BaseDateFilter):
pass
class DateNotEqualFilter(FilterNotEqual, filters.BaseDateFilter):
pass
class DateGreaterFilter(FilterGreater, filters.BaseDateFilter):
pass
class DateSmallerFilter(FilterSmaller, filters.BaseDateFilter):
pass
class DateBetweenFilter(BasePeeweeFilter, filters.BaseDateBetweenFilter):
def __init__(self, column, name, options=None, data_type=None):
super(DateBetweenFilter, self).__init__(column,
name,
name,
options,
data_type='daterangepicker')
def apply(self, query, value):
start, end = value
return query.filter(self.column.between(start, end))
return query.filter(self.column.between(start, end))
class DateNotBetweenFilter(DateBetweenFilter):
def apply(self, query, value):
start, end = value
return query.filter(~(self.column.between(start, end)))
def operation(self):
return lazy_gettext('not between')
class DateTimeEqualFilter(FilterEqual, filters.BaseDateTimeFilter):
pass
class DateTimeNotEqualFilter(FilterNotEqual, filters.BaseDateTimeFilter):
pass
class DateTimeGreaterFilter(FilterGreater, filters.BaseDateTimeFilter):
pass
class DateTimeSmallerFilter(FilterSmaller, filters.BaseDateTimeFilter):
pass
class DateTimeBetweenFilter(BasePeeweeFilter, filters.BaseDateTimeBetweenFilter):
def __init__(self, column, name, options=None, data_type=None):
super(DateTimeBetweenFilter, self).__init__(column,
name,
name,
options,
data_type='datetimerangepicker')
def apply(self, query, value):
start, end = value
return query.filter(self.column.between(start, end))
class DateTimeNotBetweenFilter(DateTimeBetweenFilter):
def apply(self, query, value):
......@@ -243,45 +243,45 @@ class DateTimeNotBetweenFilter(DateTimeBetweenFilter):
def operation(self):
return lazy_gettext('not between')
class TimeEqualFilter(FilterEqual, filters.BaseTimeFilter):
pass
class TimeNotEqualFilter(FilterNotEqual, filters.BaseTimeFilter):
pass
class TimeGreaterFilter(FilterGreater, filters.BaseTimeFilter):
pass
class TimeSmallerFilter(FilterSmaller, filters.BaseTimeFilter):
pass
class TimeBetweenFilter(BasePeeweeFilter, filters.BaseTimeBetweenFilter):
def __init__(self, column, name, options=None, data_type=None):
super(TimeBetweenFilter, self).__init__(column,
name,
name,
options,
data_type='timerangepicker')
def apply(self, query, value):
start, end = value
return query.filter(self.column.between(start, end))
class TimeNotBetweenFilter(TimeBetweenFilter):
def apply(self, query, value):
start, end = value
return query.filter(~(self.column.between(start, end)))
def operation(self):
return lazy_gettext('not between')
# Base peewee filter field converter
class FilterConverter(filters.BaseFilterConverter):
strings = (FilterEqual, FilterNotEqual, FilterLike, FilterNotLike,
......@@ -290,23 +290,25 @@ class FilterConverter(filters.BaseFilterConverter):
IntSmallerFilter, FilterEmpty, IntInListFilter,
IntNotInListFilter)
float_filters = (FloatEqualFilter, FloatNotEqualFilter, FloatGreaterFilter,
FloatSmallerFilter, FilterEmpty, FloatInListFilter,
FloatSmallerFilter, FilterEmpty, FloatInListFilter,
FloatNotInListFilter)
bool_filters = (BooleanEqualFilter, BooleanNotEqualFilter)
date_filters = (DateEqualFilter, DateNotEqualFilter, DateGreaterFilter,
DateSmallerFilter, DateBetweenFilter, DateNotBetweenFilter,
FilterEmpty)
datetime_filters = (DateTimeEqualFilter, DateTimeNotEqualFilter,
DateTimeGreaterFilter, DateTimeSmallerFilter,
DateTimeBetweenFilter, DateTimeNotBetweenFilter,
DateTimeGreaterFilter, DateTimeSmallerFilter,
DateTimeBetweenFilter, DateTimeNotBetweenFilter,
FilterEmpty)
time_filters = (TimeEqualFilter, TimeNotEqualFilter, TimeGreaterFilter,
TimeSmallerFilter, TimeBetweenFilter, TimeNotBetweenFilter,
FilterEmpty)
def convert(self, type_name, column, name):
if type_name in self.converters:
return self.converters[type_name](column, name)
filter_name = type_name.lower()
if filter_name in self.converters:
return self.converters[filter_name](column, name)
return None
......@@ -321,11 +323,11 @@ class FilterConverter(filters.BaseFilterConverter):
@filters.convert('IntegerField', 'BigIntegerField')
def conv_int(self, column, name):
return [f(column, name) for f in self.int_filters]
@filters.convert('DecimalField', 'FloatField', 'DoubleField')
def conv_float(self, column, name):
return [f(column, name) for f in self.float_filters]
@filters.convert('DateField')
def conv_date(self, column, name):
return [f(column, name) for f in self.date_filters]
......@@ -333,7 +335,7 @@ class FilterConverter(filters.BaseFilterConverter):
@filters.convert('DateTimeField')
def conv_datetime(self, column, name):
return [f(column, name) for f in self.datetime_filters]
@filters.convert('TimeField')
def conv_time(self, column, name):
return [f(column, name) for f in self.time_filters]
\ No newline at end of file
return [f(column, name) for f in self.time_filters]
......@@ -5,6 +5,8 @@ from flask import flash
from flask.ext.admin._compat import string_types
from flask.ext.admin.babel import gettext, ngettext, lazy_gettext
from flask.ext.admin.model import BaseModelView
from flask.ext.admin.model.form import wrap_fields_in_fieldlist
from flask.ext.admin.model.fields import ListEditableFieldList
from peewee import PrimaryKeyField, ForeignKeyField, Field, CharField, TextField
......@@ -237,6 +239,27 @@ class ModelView(BaseModelView):
return form_class
def scaffold_list_form(self, custom_fieldlist=ListEditableFieldList,
validators=None):
"""
Create form for the `index_view` using only the columns from
`self.column_editable_list`.
:param validators:
`form_args` dict with only validators
{'name': {'validators': [required()]}}
:param custom_fieldlist:
A WTForm FieldList class. By default, `ListEditableFieldList`.
"""
form_class = get_form(self.model, self.model_form_converter(self),
base_class=self.form_base_class,
only=self.column_editable_list,
field_args=validators)
return wrap_fields_in_fieldlist(self.form_base_class,
form_class,
custom_fieldlist)
def scaffold_inline_form_models(self, form_class):
converter = self.model_form_converter(self)
inline_converter = self.inline_model_form_converter(self)
......
......@@ -146,6 +146,42 @@ class ModelView(BaseModelView):
"""
return model.get(name)
def _search(self, query, search_term):
values = search_term.split(' ')
queries = []
# Construct inner querie
for value in values:
if not value:
continue
regex = parse_like_term(value)
stmt = []
for field in self._search_fields:
stmt.append({field: {'$regex': regex}})
if stmt:
if len(stmt) == 1:
queries.append(stmt[0])
else:
queries.append({'$or': stmt})
# Construct final query
if queries:
if len(queries) == 1:
final = queries[0]
else:
final = {'$and': queries}
if query:
query = {'$and': [query, final]}
else:
query = final
return query
def get_list(self, page, sort_column, sort_desc, search, filters,
execute=True):
"""
......@@ -182,38 +218,7 @@ class ModelView(BaseModelView):
# Search
if self._search_supported and search:
values = search.split(' ')
queries = []
# Construct inner querie
for value in values:
if not value:
continue
regex = parse_like_term(value)
stmt = []
for field in self._search_fields:
stmt.append({field: {'$regex': regex}})
if stmt:
if len(stmt) == 1:
queries.append(stmt[0])
else:
queries.append({'$or': stmt})
# Construct final query
if queries:
if len(queries) == 1:
final = queries[0]
else:
final = {'$and': queries}
if query:
query = {'$and': [query, final]}
else:
query = final
query = self._search(query, search)
# Get count
count = self.coll.find(query).count()
......
......@@ -58,7 +58,7 @@ class QueryAjaxModelLoader(AjaxModelLoader):
def get_list(self, term, offset=0, limit=DEFAULT_PAGE_SIZE):
query = self.session.query(self.model)
filters = (field.like(u'%%%s%%' % term) for field in self._cached_fields)
filters = (field.ilike(u'%%%s%%' % term) for field in self._cached_fields)
query = query.filter(or_(*filters))
return query.offset(offset).limit(limit).all()
......
......@@ -89,7 +89,7 @@ class FilterEmpty(BaseSQLAFilter, filters.BaseBooleanFilter):
def operation(self):
return lazy_gettext('empty')
class FilterInList(BaseSQLAFilter):
def __init__(self, column, name, options=None, data_type=None):
......@@ -97,145 +97,145 @@ class FilterInList(BaseSQLAFilter):
def clean(self, value):
return [v.strip() for v in value.split(',') if v.strip()]
def apply(self, query, value):
return query.filter(self.column.in_(value))
def operation(self):
return lazy_gettext('in list')
class FilterNotInList(FilterInList):
def apply(self, query, value):
# NOT IN can exclude NULL values, so "or_ == None" needed to be added
return query.filter(or_(~self.column.in_(value), self.column == None))
def operation(self):
return lazy_gettext('not in list')
# Customized type filters
class BooleanEqualFilter(FilterEqual, filters.BaseBooleanFilter):
pass
class BooleanNotEqualFilter(FilterNotEqual, filters.BaseBooleanFilter):
pass
class IntEqualFilter(FilterEqual, filters.BaseIntFilter):
pass
class IntNotEqualFilter(FilterNotEqual, filters.BaseIntFilter):
pass
class IntGreaterFilter(FilterGreater, filters.BaseIntFilter):
pass
class IntSmallerFilter(FilterSmaller, filters.BaseIntFilter):
pass
class IntInListFilter(filters.BaseIntListFilter, FilterInList):
pass
class IntNotInListFilter(filters.BaseIntListFilter, FilterNotInList):
pass
class FloatEqualFilter(FilterEqual, filters.BaseFloatFilter):
pass
class FloatNotEqualFilter(FilterNotEqual, filters.BaseFloatFilter):
pass
class FloatGreaterFilter(FilterGreater, filters.BaseFloatFilter):
pass
class FloatSmallerFilter(FilterSmaller, filters.BaseFloatFilter):
pass
class FloatInListFilter(filters.BaseFloatListFilter, FilterInList):
pass
class FloatNotInListFilter(filters.BaseFloatListFilter, FilterNotInList):
pass
class DateEqualFilter(FilterEqual, filters.BaseDateFilter):
pass
class DateNotEqualFilter(FilterNotEqual, filters.BaseDateFilter):
pass
class DateGreaterFilter(FilterGreater, filters.BaseDateFilter):
pass
class DateSmallerFilter(FilterSmaller, filters.BaseDateFilter):
pass
class DateBetweenFilter(BaseSQLAFilter, filters.BaseDateBetweenFilter):
def __init__(self, column, name, options=None, data_type=None):
super(DateBetweenFilter, self).__init__(column,
name,
name,
options,
data_type='daterangepicker')
def apply(self, query, value):
start, end = value
return query.filter(self.column.between(start, end))
class DateNotBetweenFilter(DateBetweenFilter):
def apply(self, query, value):
start, end = value
# ~between() isn't possible until sqlalchemy 1.0.0
return query.filter(not_(self.column.between(start, end)))
def operation(self):
return lazy_gettext('not between')
class DateTimeEqualFilter(FilterEqual, filters.BaseDateTimeFilter):
pass
class DateTimeNotEqualFilter(FilterNotEqual, filters.BaseDateTimeFilter):
pass
class DateTimeGreaterFilter(FilterGreater, filters.BaseDateTimeFilter):
pass
class DateTimeSmallerFilter(FilterSmaller, filters.BaseDateTimeFilter):
pass
class DateTimeBetweenFilter(BaseSQLAFilter, filters.BaseDateTimeBetweenFilter):
def __init__(self, column, name, options=None, data_type=None):
super(DateTimeBetweenFilter, self).__init__(column,
name,
name,
options,
data_type='datetimerangepicker')
def apply(self, query, value):
start, end = value
return query.filter(self.column.between(start, end))
class DateTimeNotBetweenFilter(DateTimeBetweenFilter):
def apply(self, query, value):
......@@ -244,45 +244,45 @@ class DateTimeNotBetweenFilter(DateTimeBetweenFilter):
def operation(self):
return lazy_gettext('not between')
class TimeEqualFilter(FilterEqual, filters.BaseTimeFilter):
pass
class TimeNotEqualFilter(FilterNotEqual, filters.BaseTimeFilter):
pass
class TimeGreaterFilter(FilterGreater, filters.BaseTimeFilter):
pass
class TimeSmallerFilter(FilterSmaller, filters.BaseTimeFilter):
pass
class TimeBetweenFilter(BaseSQLAFilter, filters.BaseTimeBetweenFilter):
def __init__(self, column, name, options=None, data_type=None):
super(TimeBetweenFilter, self).__init__(column,
name,
name,
options,
data_type='timerangepicker')
def apply(self, query, value):
start, end = value
return query.filter(self.column.between(start, end))
class TimeNotBetweenFilter(TimeBetweenFilter):
def apply(self, query, value):
start, end = value
return query.filter(not_(self.column.between(start, end)))
def operation(self):
return lazy_gettext('not between')
# Base SQLA filter field converter
class FilterConverter(filters.BaseFilterConverter):
strings = (FilterEqual, FilterNotEqual, FilterLike, FilterNotLike,
......@@ -291,7 +291,7 @@ class FilterConverter(filters.BaseFilterConverter):
IntSmallerFilter, FilterEmpty, IntInListFilter,
IntNotInListFilter)
float_filters = (FloatEqualFilter, FloatNotEqualFilter, FloatGreaterFilter,
FloatSmallerFilter, FilterEmpty, FloatInListFilter,
FloatSmallerFilter, FilterEmpty, FloatInListFilter,
FloatNotInListFilter)
bool_filters = (BooleanEqualFilter, BooleanNotEqualFilter)
enum = (FilterEqual, FilterNotEqual, FilterEmpty, FilterInList,
......@@ -306,43 +306,46 @@ class FilterConverter(filters.BaseFilterConverter):
time_filters = (TimeEqualFilter, TimeNotEqualFilter, TimeGreaterFilter,
TimeSmallerFilter, TimeBetweenFilter, TimeNotBetweenFilter,
FilterEmpty)
def convert(self, type_name, column, name, **kwargs):
if type_name.lower() in self.converters:
return self.converters[type_name.lower()](column, name, **kwargs)
filter_name = type_name.lower()
if filter_name in self.converters:
return self.converters[filter_name](column, name, **kwargs)
return None
@filters.convert('string', 'char', 'unicode', 'varchar', 'tinytext',
'text', 'mediumtext', 'longtext', 'unicodetext',
'nchar', 'nvarchar', 'ntext')
def conv_string(self, column, name, **kwargs):
return [f(column, name, **kwargs) for f in self.strings]
@filters.convert('boolean', 'tinyint')
def conv_bool(self, column, name, **kwargs):
return [f(column, name, **kwargs) for f in self.bool_filters]
@filters.convert('int', 'integer', 'smallinteger', 'smallint', 'numeric',
'biginteger', 'bigint', 'mediumint')
def conv_int(self, column, name, **kwargs):
return [f(column, name, **kwargs) for f in self.int_filters]
@filters.convert('float', 'real', 'decimal', 'double_precision', 'double')
def conv_float(self, column, name, **kwargs):
return [f(column, name, **kwargs) for f in self.float_filters]
@filters.convert('date')
def conv_date(self, column, name, **kwargs):
return [f(column, name, **kwargs) for f in self.date_filters]
@filters.convert('datetime', 'datetime2', 'timestamp', 'smalldatetime')
def conv_datetime(self, column, name, **kwargs):
return [f(column, name, **kwargs) for f in self.datetime_filters]
@filters.convert('time')
def conv_time(self, column, name, **kwargs):
return [f(column, name, **kwargs) for f in self.time_filters]
@filters.convert('enum')
def conv_enum(self, column, name, options=None, **kwargs):
if not options:
......
......@@ -607,9 +607,8 @@ class InlineModelConverter(InlineModelConverterBase):
if label:
kwargs['label'] = label
view_info = self.get_info(self.view)
if view_info.form_args:
field_args = view_info.form_args.get(forward_prop.key, {})
if self.view.form_args:
field_args = self.view.form_args.get(forward_prop.key, {})
kwargs.update(**field_args)
# Contribute field
......
......@@ -4,7 +4,7 @@ from sqlalchemy.exc import DBAPIError
from ast import literal_eval
from flask.ext.admin._compat import filter_list
from flask.ext.admin.tools import iterencode, iterdecode
from flask.ext.admin.tools import iterencode, iterdecode, escape
def parse_like_term(term):
......
......@@ -11,6 +11,9 @@ from flask import flash
from flask.ext.admin._compat import string_types
from flask.ext.admin.babel import gettext, ngettext, lazy_gettext
from flask.ext.admin.model import BaseModelView
from flask.ext.admin.model.form import wrap_fields_in_fieldlist
from flask.ext.admin.model.fields import ListEditableFieldList
from flask.ext.admin.actions import action
from flask.ext.admin._backwards import ObsoleteAttr
......@@ -19,7 +22,6 @@ from .typefmt import DEFAULT_FORMATTERS
from .tools import get_query_for_ids
from .ajax import create_ajax_loader
# Set up logger
log = logging.getLogger("flask-admin.sqla")
......@@ -78,8 +80,7 @@ class ModelView(BaseModelView):
'searchable_columns',
None)
"""
Collection of the searchable columns. Only text-based columns
are searchable (`String`, `Unicode`, `Text`, `UnicodeText`).
Collection of the searchable columns.
Example::
......@@ -339,6 +340,18 @@ class ModelView(BaseModelView):
else:
attr = name
# determine joins if Table.column (relation object) is given
if isinstance(name, InstrumentedAttribute):
columns = self._get_columns_for_field(name)
if len(columns) > 1:
raise Exception('Can only handle one column for %s' % name)
column = columns[0]
if self._need_join(column.table):
join_tables.append(column.table)
return join_tables, attr
def _need_join(self, table):
......@@ -360,7 +373,7 @@ class ModelView(BaseModelView):
if isinstance(self._primary_key, tuple):
return tools.iterencode(getattr(model, attr) for attr in self._primary_key)
else:
return getattr(model, self._primary_key)
return tools.escape(getattr(model, self._primary_key))
def scaffold_list_columns(self):
"""
......@@ -439,15 +452,18 @@ class ModelView(BaseModelView):
for c in self.column_sortable_list:
if isinstance(c, tuple):
join_tables, column = self._get_field_with_path(c[1])
result[c[0]] = column
if join_tables:
self._sortable_joins[c[0]] = join_tables
column_name = c[0]
elif isinstance(c, InstrumentedAttribute):
join_tables, column = self._get_field_with_path(c)
column_name = str(c)
else:
join_tables, column = self._get_field_with_path(c)
column_name = c
result[c] = column
result[column_name] = column
if join_tables:
self._sortable_joins[column_name] = join_tables
return result
......@@ -474,10 +490,6 @@ class ModelView(BaseModelView):
for column in self._get_columns_for_field(attr):
column_type = type(column.type).__name__
if not self.is_text_column_type(column_type):
raise Exception('Can only search on text columns. ' +
'Failed to setup search for "%s"' % p)
self._search_fields.append(column)
# Store joins, avoid duplicates
......@@ -488,18 +500,6 @@ class ModelView(BaseModelView):
return bool(self.column_searchable_list)
def is_text_column_type(self, name):
"""
Verify if the provided column type is text-based.
:returns:
``True`` for ``String``, ``Unicode``, ``Text``, ``UnicodeText``, ``varchar``
"""
if name:
name = name.lower()
return name in ('string', 'unicode', 'text', 'unicodetext', 'varchar')
def scaffold_filters(self, name):
"""
Return list of enabled filters
......@@ -535,8 +535,8 @@ class ModelView(BaseModelView):
if join_tables:
self._filter_joins[table.name] = join_tables
elif self._need_join(table.name):
self._filter_joins[table.name] = [table.name]
elif self._need_join(table):
self._filter_joins[table.name] = [table]
filters.extend(flt)
return filters
......@@ -611,6 +611,28 @@ class ModelView(BaseModelView):
return form_class
def scaffold_list_form(self, custom_fieldlist=ListEditableFieldList,
validators=None):
"""
Create form for the `index_view` using only the columns from
`self.column_editable_list`.
:param validators:
`form_args` dict with only validators
{'name': {'validators': [required()]}}
:param custom_fieldlist:
A WTForm FieldList class. By default, `ListEditableFieldList`.
"""
converter = self.model_form_converter(self.session, self)
form_class = form.get_form(self.model, converter,
base_class=self.form_base_class,
only=self.column_editable_list,
field_args=validators)
return wrap_fields_in_fieldlist(self.form_base_class,
form_class,
custom_fieldlist)
def scaffold_inline_form_models(self, form_class):
"""
Contribute inline models to the form
......@@ -664,6 +686,14 @@ 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::
class MyView(ModelView):
def get_query(self):
return super(MyView, self).get_query().filter(User.username == current_user.username)
"""
return self.session.query(self.model)
......@@ -716,7 +746,7 @@ class ModelView(BaseModelView):
join_tables, attr = self._get_field_with_path(field)
return join_tables, field, direction
return join_tables, attr, direction
return None
......
......@@ -69,24 +69,29 @@ class TimeField(fields.Field):
def _value(self):
if self.raw_data:
return u' '.join(self.raw_data)
elif self.data is not None:
return self.data.strftime(self.default_format)
else:
return self.data and self.data.strftime(self.default_format) or u''
return u''
def process_formdata(self, valuelist):
if valuelist:
date_str = u' '.join(valuelist)
for format in self.formats:
try:
timetuple = time.strptime(date_str, format)
self.data = datetime.time(timetuple.tm_hour,
timetuple.tm_min,
timetuple.tm_sec)
return
except ValueError:
pass
raise ValueError(gettext('Invalid time format'))
if date_str.strip():
for format in self.formats:
try:
timetuple = time.strptime(date_str, format)
self.data = datetime.time(timetuple.tm_hour,
timetuple.tm_min,
timetuple.tm_sec)
return
except ValueError:
pass
raise ValueError(gettext('Invalid time format'))
else:
self.data = None
class Select2Field(fields.SelectField):
......
......@@ -179,11 +179,13 @@ class FileUploadField(fields.StringField):
filename.rsplit('.', 1)[1].lower() in
map(lambda x: x.lower(), self.allowed_extensions))
def _is_uploaded_file(self, data):
return (data
and isinstance(data, FileStorage)
and data.filename)
def pre_validate(self, form):
if (self.data
and self.data.filename
and isinstance(self.data, FileStorage)
and not self.is_file_allowed(self.data.filename)):
if self._is_uploaded_file(self.data) and not self.is_file_allowed(self.data.filename):
raise ValidationError(gettext('Invalid file extension'))
def process(self, formdata, data=unset_value):
......@@ -194,6 +196,15 @@ class FileUploadField(fields.StringField):
return super(FileUploadField, self).process(formdata, data)
def process_formdata(self, valuelist):
if self._should_delete:
self.data = None
elif valuelist:
data = valuelist[0]
if self._is_uploaded_file(data):
self.data = data
def populate_obj(self, obj, name):
field = getattr(obj, name, None)
if field:
......@@ -203,7 +214,7 @@ class FileUploadField(fields.StringField):
setattr(obj, name, None)
return
if self.data and self.data.filename and isinstance(self.data, FileStorage):
if self._is_uploaded_file(self.data):
if field:
self._delete_file(field)
......@@ -299,7 +310,7 @@ class ImageUploadField(FileUploadField):
upload = FileUploadField('File', namegen=prefix_name)
:param allowed_extensions:
List of allowed extensions. If not provided, will allow any file.
List of allowed extensions. If not provided, then gif, jpg, jpeg, png and tiff will be allowed.
:param max_size:
Tuple of (width, height, force) or None. If provided, Flask-Admin will
resize image to the desired size.
......@@ -357,9 +368,7 @@ class ImageUploadField(FileUploadField):
def pre_validate(self, form):
super(ImageUploadField, self).pre_validate(form)
if (self.data and
isinstance(self.data, FileStorage) and
self.data.filename):
if self._is_uploaded_file(self.data):
try:
self.image = Image.open(self.data)
except Exception as e:
......@@ -396,7 +405,7 @@ class ImageUploadField(FileUploadField):
self._save_image(image, self._get_path(filename), format)
else:
data.seek(0)
data.save( self._get_path(filename) )
data.save(self._get_path(filename))
self._save_thumbnail(data, filename, format)
......
from re import sub
from jinja2 import contextfunction
from flask import g, request, url_for
from flask import g, request, url_for, flash
from wtforms.validators import DataRequired, InputRequired
from flask.ext.admin._compat import urljoin, urlparse
from flask.ext.admin._compat import urljoin, urlparse, iteritems
from ._compat import string_types
......@@ -56,7 +55,7 @@ def is_form_submitted():
"""
Check if current method is PUT or POST
"""
return request and request.method in ("PUT", "POST")
return request and request.method in ('PUT', 'POST')
def validate_form_on_submit(form):
......@@ -93,7 +92,13 @@ def is_field_error(errors):
return True
return False
def flash_errors(form, message):
from flask.ext.admin.babel import gettext
for field_name, errors in iteritems(form.errors):
errors = form[field_name].label.text + u": " + u", ".join(errors)
flash(gettext(message, error=str(errors)), 'error')
@contextfunction
def resolve_ctx(context):
......
This diff is collapsed.
......@@ -3,8 +3,14 @@ import itertools
from wtforms.validators import ValidationError
from wtforms.fields import FieldList, FormField, SelectFieldBase
try:
from wtforms.fields import _unset_value as unset_value
except ImportError:
from wtforms.utils import unset_value
from flask.ext.admin._compat import iteritems
from .widgets import InlineFieldListWidget, InlineFormWidget, AjaxSelect2Widget
from .widgets import (InlineFieldListWidget, InlineFormWidget,
AjaxSelect2Widget, XEditableWidget)
class InlineFieldList(FieldList):
......@@ -120,6 +126,58 @@ class InlineModelFormField(FormField):
field.populate_obj(obj, name)
class ListEditableFieldList(FieldList):
"""
Modified FieldList to allow for alphanumeric primary keys.
Used in the editable list view.
"""
widget = XEditableWidget()
def __init__(self, *args, **kwargs):
super(ListEditableFieldList, self).__init__(*args, **kwargs)
# min_entries = 1 is required for the widget to determine the type
self.min_entries = 1
def _extract_indices(self, prefix, formdata):
offset = len(prefix) + 1
for k in formdata:
if k.startswith(prefix):
k = k[offset:].split('-', 1)[0]
# removed "if k.isdigit():"
yield k
def _add_entry(self, formdata=None, data=unset_value, index=None):
assert not self.max_entries or len(self.entries) < self.max_entries, \
'You cannot have more than max_entries entries in this FieldList'
if index is None:
index = self.last_index + 1
self.last_index = index
# '%s-%s' instead of '%s-%d' to allow alphanumeric
name = '%s-%s' % (self.short_name, index)
id = '%s-%s' % (self.id, index)
# support both wtforms 1 and 2
meta = getattr(self, 'meta', None)
if meta:
field = self.unbound_field.bind(
form=None, name=name, prefix=self._prefix, id=id, _meta=meta
)
else:
field = self.unbound_field.bind(
form=None, name=name, prefix=self._prefix, id=id
)
field.process(formdata, data)
self.entries.append(field)
return field
def populate_obj(self, obj, name):
# return data from first item, instead of a list of items
setattr(obj, name, self.data.pop())
class AjaxSelectField(SelectFieldBase):
"""
Ajax Model Select Field
......
......@@ -48,7 +48,7 @@ class BaseFilter(object):
Validate value.
If value is valid, returns `True` and `False` otherwise.
:param value:
Value to validate
"""
......@@ -102,7 +102,7 @@ class BaseBooleanFilter(BaseFilter):
def validate(self, value):
return value in ('0', '1')
class BaseIntFilter(BaseFilter):
"""
......@@ -110,7 +110,7 @@ class BaseIntFilter(BaseFilter):
"""
def clean(self, value):
return int(float(value))
class BaseFloatFilter(BaseFilter):
"""
......@@ -118,7 +118,7 @@ class BaseFloatFilter(BaseFilter):
"""
def clean(self, value):
return float(value)
class BaseIntListFilter(BaseFilter):
"""
......@@ -126,7 +126,7 @@ class BaseIntListFilter(BaseFilter):
"""
def clean(self, value):
return [int(float(v.strip())) for v in value.split(',') if v.strip()]
class BaseFloatListFilter(BaseFilter):
"""
......@@ -134,7 +134,7 @@ class BaseFloatListFilter(BaseFilter):
"""
def clean(self, value):
return [float(v.strip()) for v in value.split(',') if v.strip()]
class BaseDateFilter(BaseFilter):
"""
......@@ -144,10 +144,10 @@ class BaseDateFilter(BaseFilter):
super(BaseDateFilter, self).__init__(name,
options,
data_type='datepicker')
def clean(self, value):
return datetime.datetime.strptime(value, '%Y-%m-%d').date()
class BaseDateBetweenFilter(BaseFilter):
"""
......@@ -163,16 +163,16 @@ class BaseDateBetweenFilter(BaseFilter):
def validate(self, value):
try:
value = [datetime.datetime.strptime(range, '%Y-%m-%d')
value = [datetime.datetime.strptime(range, '%Y-%m-%d')
for range in value.split(' to ')]
# if " to " is missing, fail validation
# sqlalchemy's .between() will not work if end date is before start date
if (len(value) == 2) and (value[0] <= value[1]):
return True
else:
return False
return False
except ValueError:
return False
return False
class BaseDateTimeFilter(BaseFilter):
......@@ -183,27 +183,27 @@ class BaseDateTimeFilter(BaseFilter):
super(BaseDateTimeFilter, self).__init__(name,
options,
data_type='datetimepicker')
def clean(self, value):
# datetime filters will not work in SQLite + SQLAlchemy if value not converted to datetime
return datetime.datetime.strptime(value, '%Y-%m-%d %H:%M:%S')
class BaseDateTimeBetweenFilter(BaseFilter):
"""
Base DateTime Between filter. Consolidates logic for validation and clean.
Apply method is different for each back-end.
"""
"""
def clean(self, value):
return [datetime.datetime.strptime(range, '%Y-%m-%d %H:%M:%S')
return [datetime.datetime.strptime(range, '%Y-%m-%d %H:%M:%S')
for range in value.split(' to ')]
def operation(self):
return lazy_gettext('between')
def validate(self, value):
try:
value = [datetime.datetime.strptime(range, '%Y-%m-%d %H:%M:%S')
value = [datetime.datetime.strptime(range, '%Y-%m-%d %H:%M:%S')
for range in value.split(' to ')]
if (len(value) == 2) and (value[0] <= value[1]):
return True
......@@ -211,7 +211,7 @@ class BaseDateTimeBetweenFilter(BaseFilter):
return False
except ValueError:
return False
class BaseTimeFilter(BaseFilter):
"""
......@@ -221,14 +221,14 @@ class BaseTimeFilter(BaseFilter):
super(BaseTimeFilter, self).__init__(name,
options,
data_type='timepicker')
def clean(self, value):
# time filters will not work in SQLite + SQLAlchemy if value not converted to time
timetuple = time.strptime(value, '%H:%M:%S')
return datetime.time(timetuple.tm_hour,
timetuple.tm_min,
timetuple.tm_sec)
class BaseTimeBetweenFilter(BaseFilter):
"""
......@@ -236,7 +236,7 @@ class BaseTimeBetweenFilter(BaseFilter):
Apply method is different for each back-end.
"""
def clean(self, value):
timetuples = [time.strptime(range, '%H:%M:%S')
timetuples = [time.strptime(range, '%H:%M:%S')
for range in value.split(' to ')]
return [datetime.time(timetuple.tm_hour,
timetuple.tm_min,
......@@ -248,7 +248,7 @@ class BaseTimeBetweenFilter(BaseFilter):
def validate(self, value):
try:
timetuples = [time.strptime(range, '%H:%M:%S')
timetuples = [time.strptime(range, '%H:%M:%S')
for range in value.split(' to ')]
if (len(timetuples) == 2) and (timetuples[0] <= timetuples[1]):
return True
......@@ -257,7 +257,7 @@ class BaseTimeBetweenFilter(BaseFilter):
except ValueError:
raise
return False
def convert(*args):
"""
......@@ -266,7 +266,7 @@ def convert(*args):
See :mod:`flask.ext.admin.contrib.sqla.filters` for usage example.
"""
def _inner(func):
func._converter_for = args
func._converter_for = list(map(str.lower, args))
return func
return _inner
......
......@@ -3,6 +3,8 @@ import inspect
from flask.ext.admin.form import BaseForm, rules
from flask.ext.admin._compat import iteritems
from wtforms.fields.core import UnboundField
def converts(*args):
def _inner(func):
......@@ -11,6 +13,35 @@ def converts(*args):
return _inner
def wrap_fields_in_fieldlist(form_base_class, form_class, CustomFieldList):
"""
Create a form class with all the fields wrapped in a FieldList.
Wrapping each field in FieldList allows submitting POST requests
in this format: ('<field_name>-<primary_key>', '<value>')
Used in the editable list view.
:param form_base_class:
WTForms form class, by default `form_base_class` from base.
:param form_class:
WTForms form class generated by `form.get_form`.
:param CustomFieldList:
WTForms FieldList class.
By default, `CustomFieldList` is `ListEditableFieldList`.
"""
class FieldListForm(form_base_class):
pass
# iterate FormMeta to get unbound fields
for name, obj in iteritems(form_class.__dict__):
if isinstance(obj, UnboundField):
# wrap field in a WTForms FieldList
setattr(FieldListForm, name, CustomFieldList(obj))
return FieldListForm
class InlineBaseFormAdmin(object):
"""
Settings for inline form administration.
......
......@@ -25,7 +25,13 @@ def get_mdict_item_or_list(mdict, key):
if hasattr(mdict, 'getlist'):
v = mdict.getlist(key)
if len(v) == 1:
return v[0]
value = v[0]
# Special case for empty strings, treat them as "no-value"
if value == '':
value = None
return value
elif len(v) == 0:
return None
else:
......
......@@ -26,8 +26,8 @@ class AjaxSelect2Widget(object):
self.multiple = multiple
def __call__(self, field, **kwargs):
kwargs['data-role'] = u'select2-ajax'
kwargs['data-url'] = get_url('.ajax_lookup', name=field.loader.name)
kwargs.setdefault('data-role', 'select2-ajax')
kwargs.setdefault('data-url', get_url('.ajax_lookup', name=field.loader.name))
allow_blank = getattr(field, 'allow_blank', False)
if allow_blank and not self.multiple:
......@@ -61,3 +61,88 @@ class AjaxSelect2Widget(object):
kwargs.setdefault('data-placeholder', placeholder)
return HTMLString('<input %s>' % html_params(name=field.name, **kwargs))
class XEditableWidget(object):
"""
WTForms widget that provides in-line editing for the list view.
Determines how to display the x-editable/ajax form based on the
field inside of the FieldList (StringField, IntegerField, etc).
"""
def __call__(self, field, **kwargs):
kwargs.setdefault('data-value', kwargs.pop('value', ''))
kwargs.setdefault('data-role', 'x-editable')
kwargs.setdefault('data-url', './ajax/update/')
kwargs.setdefault('id', field.id)
kwargs.setdefault('name', field.name)
kwargs.setdefault('href', '#')
if not kwargs.get('pk'):
raise Exception('pk required')
kwargs['data-pk'] = str(kwargs.pop("pk"))
kwargs['data-csrf'] = kwargs.pop("csrf", "")
# subfield is the first entry (subfield) from FieldList (field)
subfield = field.entries[0]
kwargs = self.get_kwargs(subfield, kwargs)
return HTMLString(
'<a %s>%s</a>' % (html_params(**kwargs), kwargs['data-value'])
)
def get_kwargs(self, subfield, kwargs):
"""
Return extra kwargs based on the subfield type.
"""
if subfield.type == 'StringField':
kwargs['data-type'] = 'text'
elif subfield.type == 'TextAreaField':
kwargs['data-type'] = 'textarea'
kwargs['data-rows'] = '5'
elif subfield.type == 'BooleanField':
kwargs['data-type'] = 'select'
# data-source = dropdown options
kwargs['data-source'] = {'': 'False', '1': 'True'}
kwargs['data-role'] = 'x-editable-boolean'
elif subfield.type == 'Select2Field':
kwargs['data-type'] = 'select'
kwargs['data-source'] = dict(subfield.choices)
elif subfield.type == 'DateField':
kwargs['data-type'] = 'combodate'
kwargs['data-format'] = 'YYYY-MM-DD'
kwargs['data-template'] = 'YYYY-MM-DD'
elif subfield.type == 'DateTimeField':
kwargs['data-type'] = 'combodate'
kwargs['data-format'] = 'YYYY-MM-DD HH:mm:ss'
kwargs['data-template'] = 'YYYY-MM-DD HH:mm:ss'
# x-editable-combodate uses 1 minute increments
kwargs['data-role'] = 'x-editable-combodate'
elif subfield.type == 'TimeField':
kwargs['data-type'] = 'combodate'
kwargs['data-format'] = 'HH:mm:ss'
kwargs['data-template'] = 'HH:mm:ss'
kwargs['data-role'] = 'x-editable-combodate'
elif subfield.type == 'IntegerField':
kwargs['data-type'] = 'number'
elif subfield.type in ['FloatField', 'DecimalField']:
kwargs['data-type'] = 'number'
kwargs['data-step'] = 'any'
elif subfield.type in ['QuerySelectField', 'ModelSelectField']:
kwargs['data-type'] = 'select'
choices = {}
for choice in subfield:
try:
choices[str(choice._value())] = str(choice.label.text)
except TypeError:
choices[str(choice._value())] = ""
kwargs['data-source'] = choices
else:
raise Exception('Unsupported field type: %s' % (type(subfield),))
return kwargs
/* Global styles */
body
{
padding-top: 4px;
}
/* Form customizations */
form.icon {
/* List View - fix trash icon inside table column */
.model-list form.icon {
display: inline;
}
form.icon button {
.model-list form.icon button {
border: none;
background: transparent;
text-decoration: none;
......@@ -17,76 +11,62 @@ form.icon button {
line-height: normal;
}
a.icon {
/* List View - link icons - prevent underline */
.model-list a.icon {
text-decoration: none;
}
/* Model search form */
form.search-form {
margin: 4px 0 0 0;
/* List View - fix checkbox column width */
.list-checkbox-column {
width: 14px;
}
form.search-form .clear i {
margin: 2px 0 0 0;
/* List View - fix gap between actions and table */
.model-list {
position: relative;
margin-top: -1px;
z-index: 999;
}
form.search-form div input {
margin: 0;
.actions-nav {
margin-bottom: 0;
margin-left: 4px;
margin-right: 4px;
}
/* Filters */
table.filters {
border-collapse: collapse;
border-spacing: 4px;
}
.filters input
{
#filter_form {
margin-bottom: 0;
}
.filters a.remove-filter {
margin-bottom: 0;
display: block;
text-align: left;
/* List View Search Form - fix gap between form and table */
.actions-nav form.search-form {
margin: -1px 0 0 0;
}
.filters .remove-filter
{
vertical-align: middle;
/* Filters */
table.filters {
border-collapse: collapse;
border-spacing: 4px;
}
.filters .remove-filter .close-icon
{
font-size: 16px;
/* prevents gap between table and actions while there are no filters set */
table.filters:not(:empty) {
margin: 12px 0px 20px 0px;
}
.filters .remove-filter .close-icon:hover
{
color: black;
opacity: 0.4;
/* spacing between filter X button, operation, and value field */
/* uses tables instead of form classes for bootstrap2-3 compatibility */
table.filters tr td {
padding-right: 5px;
padding-bottom: 3px;
}
/* match filter operation drop-down height with bootstrap input */
.filters .filter-op > a {
height: 28px;
line-height: 28px;
}
/* Inline forms */
.inline-field {
padding-bottom: 0.5em;
}
.inline-field-control {
float: right;
}
.inline-field .inline-form-field {
border-left: 1px solid #eeeeee;
padding-left: 8px;
margin-bottom: 4px;
}
/* Image thumbnails */
.image-thumbnail img {
max-width: 100px;
......@@ -94,21 +74,13 @@ table.filters {
}
/* Forms */
.form-horizontal .control-label {
.admin-form .control-label {
width: 100px;
text-align: left;
margin-left: 4px;
}
.form-horizontal .controls {
/* add spacing between labels and form fields */
.admin-form .controls {
margin-left: 110px;
}
/* Patch Select2 */
.select2-results li {
min-height: 24px !important;
}
.list-checkbox-column {
width: 14px;
}
}
\ No newline at end of file
/* Global styles */
body
{
padding-top: 4px;
}
/* Form customizations */
form.icon {
/* List View - fix trash icon inside table column */
.model-list form.icon {
display: inline;
}
form.icon button {
.model-list form.icon button {
border: none;
background: transparent;
text-decoration: none;
......@@ -17,28 +11,28 @@ form.icon button {
line-height: normal;
}
a.icon, button span.glyphicon {
/* List View - prevent link icons from differing from trash icon */
.model-list a.icon {
text-decoration: none;
margin-left: 10px;
color: #333;
color: inherit;
}
/* Model search form */
form.navbar-form {
margin: 1px 0 0 0;
}
form.navbar-form a.clear span {
margin-top: 8px;
margin-left: -20px;
/* List View - fix checkbox column width */
.list-checkbox-column {
width: 14px;
}
form.navbar-form div.input-append {
display: inline-flex;
/* List View - fix overlapping border between actions and table */
.model-list {
position: relative;
margin-top: -1px;
z-index: 999;
}
form.search-form div input {
margin: 0;
/* List View Search Form - fix gap between form and table */
.actions-nav form.navbar-form {
margin: 1px 0 0 0;
}
/* Filters */
......@@ -47,43 +41,19 @@ table.filters {
border-spacing: 4px;
}
/* prevents gap between table and actions while there are no filters set */
table.filters:not(:empty) {
margin: 12px 0px 20px 0px;
}
/* spacing between filter X button, operation, and value field */
/* uses tables instead of form classes for bootstrap2-3 compatibility */
table.filters tr td {
padding-right: 5px;
padding-bottom: 3px;
}
table.flters tr td:nth-child(2){
width: 60%;
}
.filters a.remove-filter {
margin-bottom: 0;
display: block;
text-align: left;
text-decoration: none;
}
.filters .remove-filter
{
vertical-align: middle;
}
.filters .remove-filter .close-icon
{
font-size: 16px;
}
.filters .remove-filter .close-icon:hover
{
color: black;
opacity: 0.4;
}
/* filters */
/* Filters - Select2 Boxes */
.filters .filter-op {
width: 130px;
}
......@@ -92,30 +62,6 @@ table.flters tr td:nth-child(2){
width: 220px;
}
/* Inline forms */
.inline-field {
margin-bottom: 5px;
}
.inline-field-control {
float: right;
}
.inline-field .inline-form {
border-left: 1px solid #eeeeee;
padding-left: 8px;
margin-bottom: 4px;
}
.inline-field-control .glyphicon-remove {
margin-left: -30px;
}
.panel {
padding-top: 25px;
padding-left: 30px;
}
/* Image thumbnails */
.image-thumbnail img {
max-width: 100px;
......@@ -123,58 +69,14 @@ table.flters tr td:nth-child(2){
}
/* Forms */
.form-horizontal {
/* required because form-horizontal removes top padding */
.admin-form {
margin-top: 35px;
}
.submit-row {
margin-top: 5px;
}
td>span.glyphicon {
padding-left: 35%;
}
/* link style */
a.btn-cancel {
border-radius: 4px;
border-color: #a08c8c;
color: #333;
background-color: #f0b8b8;
}
a.btn-link {
border-radius: 4px;
border-color: #95bee2;
}
a.btn-filter, a.btn-filter:hover {
border-radius: 4px;
border-color: #adadad;
color: #333;
background-color: #ebebeb;
max-height: 32px;
line-height: 16px;
}
.help-block {
/* Form Field Description - Appears when field has 'description' attribute */
/* Test with: form_args = {'name':{'description': 'test'}} */
/* prevents awkward gap after help-block - This is default for bootstrap2 */
.admin-form .help-block {
margin-bottom: 0px;
}
ul.has-error {
margin-bottom: -8px;
}
.tooltip.top {
font-size: 1.1em;
top: -35px;
}
.checkbox input[type="checkbox"] {
margin-left: 0px;
margin-right: 15px;
}
.list-checkbox-column {
width: 14px;
}
}
\ No newline at end of file
......@@ -101,12 +101,12 @@ var AdminFilters = function(element, filtersElement, filterGroups, activeFilters
}
function addFilter(name, subfilters, selectedIndex, filterValue) {
var $el = $('<tr />').appendTo($container);
var $el = $('<tr class="form-horizontal" />').appendTo($container);
// Filter list
$el.append(
$('<td/>').append(
$('<a href="#" class="btn btn-filter remove-filter" />')
$('<a href="#" class="btn btn-default remove-filter" />')
.append($('<span class="close-icon">&times;</span>'))
.append('&nbsp;')
.append(name)
......
......@@ -241,6 +241,17 @@
return true;
}
// make x-editable's POST act like a normal FieldList field
// for x-editable, x-editable-combodate, and x-editable-boolean cases
var overrideXeditableParams = function(params) {
var newParams = {};
newParams[params.name + '-' + params.pk] = params.value;
if ($(this).data('csrf')) {
newParams['csrf_token'] = $(this).data('csrf');
}
return newParams;
}
switch (name) {
case 'select2':
var opts = {
......@@ -266,7 +277,7 @@
} else {
var tags = [];
}
// default to a comma for separating list items
// allows using spaces as a token separator
if ($el.attr('data-token-separators')) {
......@@ -274,7 +285,7 @@
} else {
var tokenSeparators = [','];
}
var opts = {
width: 'resolve',
tags: tags,
......@@ -283,12 +294,12 @@
return 'Enter comma separated values';
}
};
$el.select2(opts);
// submit on ENTER
$el.parent().find('input.select2-input').on('keyup', function(e) {
if(e.keyCode === 13)
if(e.keyCode === 13)
$(this).closest('form').submit();
});
return true;
......@@ -390,6 +401,32 @@
case 'leaflet':
processLeafletWidget($el, name);
return true;
case 'x-editable':
$el.editable({params: overrideXeditableParams});
return true;
case 'x-editable-combodate':
$el.editable({
params: overrideXeditableParams,
combodate: {
// prevent minutes from showing in 5 minute increments
minuteStep: 1
}
});
return true;
case 'x-editable-boolean':
$el.editable({
params: overrideXeditableParams,
display: function(value, sourceData, response) {
// display new boolean value as an icon
if(response) {
if(value == '1') {
$(this).html('<span class="glyphicon glyphicon-ok-circle icon-ok-circle"></span>');
} else {
$(this).html('<span class="glyphicon glyphicon-minus-sign icon-minus-sign"></span>');
}
}
}
});
}
};
......@@ -408,19 +445,25 @@
var $parentForm = $el.parent().closest('.inline-field');
if ($parentForm.length > 0 && elID.indexOf($parentForm.attr('id')) !== 0) {
if ($parentForm.hasClass('fresh')) {
id = $parentForm.attr('id') + '-' + elID;
}
var $fieldList = $el.find('> .inline-field-list');
var $lastField = $fieldList.children('.inline-field').last();
var maxId = 0;
var prefix = id + '-0';
if ($lastField.length > 0) {
var parts = $lastField.attr('id').split('-');
$fieldList.children('.inline-field').each(function(idx, field) {
var $field = $(field);
var parts = $field.attr('id').split('-');
idx = parseInt(parts[parts.length - 1], 10) + 1;
prefix = id + '-' + idx;
}
if (idx > maxId) {
maxId = idx;
}
});
var prefix = id + '-' + maxId;
// Get template
var $template = $($el.find('> .inline-field-template').text());
......@@ -428,6 +471,9 @@
// Set form ID
$template.attr('id', prefix);
// Mark form that we just created
$template.addClass('fresh');
// Fix form IDs
$('[name]', $template).each(function(e) {
var me = $(this);
......@@ -460,7 +506,7 @@
this.applyGlobalStyles = function(parent) {
var self = this;
$(':input[data-role]', parent).each(function() {
$(':input[data-role], a[data-role]', parent).each(function() {
var $el = $(this);
self.applyStyle($el, $el.attr('data-role'));
});
......@@ -473,7 +519,7 @@
* @param {converter} function($el, name)
*/
this.addFieldConverter = function(converter) {
fieldConverters.push(converter);
fieldConverters.push(converter);
};
};
......
......@@ -13,6 +13,11 @@
<link href="{{ admin_static.url(filename='bootstrap/bootstrap2/css/bootstrap.css') }}" rel="stylesheet">
<link href="{{ admin_static.url(filename='bootstrap/bootstrap2/css/bootstrap-responsive.css') }}" rel="stylesheet">
<link href="{{ admin_static.url(filename='admin/css/bootstrap2/admin.css') }}" rel="stylesheet">
<style>
body {
padding-top: 4px;
}
</style>
{% endblock %}
{% block head %}
{% endblock %}
......
......@@ -15,6 +15,11 @@
<link href="{{ admin_static.url(filename='bootstrap/bootstrap3/css/bootstrap.min.css') }}" rel="stylesheet">
<link href="{{ admin_static.url(filename='bootstrap/bootstrap3/css/bootstrap-theme.min.css') }}" rel="stylesheet">
<link href="{{ admin_static.url(filename='admin/css/bootstrap3/admin.css') }}" rel="stylesheet">
<style>
body {
padding-top: 4px;
}
</style>
{% endblock %}
{% block head %}
{% endblock %}
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
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