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 ...@@ -8,6 +8,8 @@ Development Lead
Patches and Suggestions Patches and Suggestions
``````````````````````` ```````````````````````
- Paul Brown <paul90brown@gmail.com>
- Petrus Janse van Rensburg <petrus.jvrensburg@gmail.com>
- Priit Laes <plaes@plaes.org> - Priit Laes <plaes@plaes.org>
- Sean Lynch - Sean Lynch
- Andy Wilson <wilson.andrew.j+github@gmail.com> - Andy Wilson <wilson.andrew.j+github@gmail.com>
......
This diff is collapsed.
#!/bin/sh #!/bin/sh
pybabel extract -F babel.ini -k _gettext -k _ngettext -k lazy_gettext -o admin.pot --project Flask-Admin ../flask_admin 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 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: 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 1.0.8
----- -----
...@@ -43,17 +61,3 @@ Highlights: ...@@ -43,17 +61,3 @@ Highlights:
* Redis cli * Redis cli
* SQLAlchemy backend can handle inherited models with multiple PKs * SQLAlchemy backend can handle inherited models with multiple PKs
* Lots of bug fixes * 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 ...@@ -37,14 +37,11 @@ Creating simple model
--------------------- ---------------------
GeoAlchemy comes with a `Geometry`_ field that is carefully divorced from the 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 `Shapely`_ library. Flask-Admin will use this field so that there are no
objects in your database, and you want an admin interface to edit those objects, changes necessary to other code. ``ModelView`` should be imported from
you're probably already using Shapely, so we provide a Geometry field that is ``geoa`` rather than the one imported from ``sqla``::
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 from geoalchemy2 import Geometry
models::
from flask.ext.admin.contrib.geoa.sqltypes import Geometry
from flask.ext.admin.contrib.geoa import ModelView from flask.ext.admin.contrib.geoa import ModelView
# .. flask initialization # .. flask initialization
...@@ -62,9 +59,6 @@ models:: ...@@ -62,9 +59,6 @@ models::
db.create_all() db.create_all()
app.run('0.0.0.0', 8000) 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 Limitations
----------- -----------
......
...@@ -21,8 +21,8 @@ Getting started ...@@ -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` 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:: property one of your admin views::
class RuleView(sqla.ModelView): class RuleView(sqla.ModelView):
form_create_rules = ('email', 'first_name', 'last_name') 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. 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 ...@@ -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 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:: 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): class RuleView(sqla.ModelView):
form_create_rules = ('email', rules.Text('Foobar'), 'first_name', 'last_name') form_create_rules = ('email', rules.Text('Foobar'), 'first_name', 'last_name')
Built-in rules Built-in rules
-------------- --------------
...@@ -58,44 +58,66 @@ Form Rendering Rule Description ...@@ -58,44 +58,66 @@ Form Rendering Rule Description
Enabling CSRF Validation Enabling CSRF Validation
--------------- ---------------
Adding CSRF validation will require overriding the :class:`flask.ext.admin.form.BaseForm` by using :attr:`flask.ext.admin.model.BaseModelView.form_base_class`.
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`:: WTForms >=2::
import os from wtforms.csrf.session import SessionCSRF
import flask from wtforms.meta import DefaultMeta
**import flask_wtf** from flask import session
import flask_admin from datetime import timedelta
import flask_sqlalchemy from flask.ext.admin import form
from flask_admin.contrib.sqla import ModelView from flask.ext.admin.contrib import sqla
DBFILE = 'app.db' class SecureForm(form.BaseForm):
class Meta(DefaultMeta):
app = flask.Flask(__name__) csrf = True
app.config['SECRET_KEY'] = 'Dnit7qz7mfcP0YuelDrF8vLFvk0snhwP' csrf_class = SessionCSRF
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///' + DBFILE csrf_secret = b'EPj00jpfj8Gx1SjnyLxwBBSQfnQ9DJYe0Ym'
**app.config['CSRF_ENABLED'] = True** csrf_time_limit = timedelta(minutes=20)
**flask_wtf.CsrfProtect(app)** @property
db = flask_sqlalchemy.SQLAlchemy(app) def csrf_context(self):
admin = flask_admin.Admin(app, name='Admin') return session
## Here is the fix: class ModelAdmin(sqla.ModelView):
class MyModelView(ModelView): form_base_class = SecureForm
**form_base_class = flask_wtf.Form**
For WTForms 1, you can use use Flask-WTF's Form class::
class User(db.Model):
id = db.Column(db.Integer, primary_key=True) import os
username = db.Column(db.String) import flask
password = db.Column(db.String) import flask_wtf
import flask_admin
if not os.path.exists(DBFILE): import flask_sqlalchemy
db.create_all() from flask_admin.contrib.sqla import ModelView
## The subclass is used here: DBFILE = 'app.db'
admin.add_view( MyModelView(User, db.session, name='User') )
app = flask.Flask(__name__)
app.run(debug=True) 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 Further reading
--------------- ---------------
......
...@@ -165,45 +165,18 @@ have access to the view in question:: ...@@ -165,45 +165,18 @@ have access to the view in question::
def is_accessible(self): def is_accessible(self):
return login.current_user.is_authenticated() return login.current_user.is_authenticated()
You can also implement policy-based security, conditionally allowing or disallowing access to parts of the To redirect the user to another page if authentication fails, you will need to specify an *_handle_view* method::
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
class MyView(BaseView): class MyView(BaseView):
@expose('/') def is_accessible(self):
def index(self) return login.current_user.is_authenticated()
# Get URL for the test view method
url = url_for('.test') def _handle_view(self, name, **kwargs):
return self.render('index.html', url=url) if not self.is_accessible():
return redirect(url_for('login', next=request.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.
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 Model Views
----------- -----------
...@@ -299,6 +272,51 @@ Sample screenshot: ...@@ -299,6 +272,51 @@ Sample screenshot:
You can disable uploads, disable file or directory deletion, restrict file uploads to certain types and so on. 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. 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 Examples
-------- --------
......
...@@ -4,20 +4,20 @@ To run this example: ...@@ -4,20 +4,20 @@ To run this example:
1. Clone the repository:: 1. Clone the repository::
git clone https://github.com/mrjoes/flask-admin.git git clone https://github.com/mrjoes/flask-admin.git
cd flask-admin cd flask-admin
2. Create and activate a virtual environment:: 2. Create and activate a virtual environment::
virtualenv env virtualenv env
source env/bin/activate source env/bin/activate
3. Install requirements:: 3. Install requirements::
pip install -r 'examples/auth-mongoengine/requirements.txt' pip install -r 'examples/auth-mongoengine/requirements.txt'
4. Run the application:: 4. Run the application::
python examples/auth-mongoengine/app.py python examples/auth-mongoengine/app.py
...@@ -4,24 +4,24 @@ To run this example: ...@@ -4,24 +4,24 @@ To run this example:
1. Clone the repository:: 1. Clone the repository::
git clone https://github.com/mrjoes/flask-admin.git git clone https://github.com/mrjoes/flask-admin.git
cd flask-admin cd flask-admin
2. Create and activate a virtual environment:: 2. Create and activate a virtual environment::
virtualenv env virtualenv env
source env/bin/activate source env/bin/activate
3. Install requirements:: 3. Install requirements::
pip install -r 'examples/auth/requirements.txt' pip install -r 'examples/auth/requirements.txt'
4. Run the application:: 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, 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::: comment the following lines in app.py:::
if not os.path.exists(database_path): if not os.path.exists(database_path):
build_sample_db() build_sample_db()
...@@ -4,20 +4,20 @@ To run this example: ...@@ -4,20 +4,20 @@ To run this example:
1. Clone the repository:: 1. Clone the repository::
git clone https://github.com/mrjoes/flask-admin.git git clone https://github.com/mrjoes/flask-admin.git
cd flask-admin cd flask-admin
2. Create and activate a virtual environment:: 2. Create and activate a virtual environment::
virtualenv env virtualenv env
source env/bin/activate source env/bin/activate
3. Install requirements:: 3. Install requirements::
pip install -r 'examples/babel/requirements.txt' pip install -r 'examples/babel/requirements.txt'
4. Run the application:: 4. Run the application::
python examples/babel/app.py python examples/babel/app.py
...@@ -4,20 +4,20 @@ To run this example: ...@@ -4,20 +4,20 @@ To run this example:
1. Clone the repository:: 1. Clone the repository::
git clone https://github.com/mrjoes/flask-admin.git git clone https://github.com/mrjoes/flask-admin.git
cd flask-admin cd flask-admin
2. Create and activate a virtual environment:: 2. Create and activate a virtual environment::
virtualenv env virtualenv env
source env/bin/activate source env/bin/activate
3. Install requirements:: 3. Install requirements::
pip install -r 'examples/file/requirements.txt' pip install -r 'examples/file/requirements.txt'
4. Run the application:: 4. Run the application::
python examples/file/app.py python examples/file/app.py
...@@ -5,24 +5,24 @@ To run this example: ...@@ -5,24 +5,24 @@ To run this example:
1. Clone the repository:: 1. Clone the repository::
git clone https://github.com/mrjoes/flask-admin.git git clone https://github.com/mrjoes/flask-admin.git
cd flask-admin cd flask-admin
2. Create and activate a virtual environment:: 2. Create and activate a virtual environment::
virtualenv env virtualenv env
source env/bin/activate source env/bin/activate
3. Install requirements:: 3. Install requirements::
pip install -r 'examples/forms/requirements.txt' pip install -r 'examples/forms/requirements.txt'
4. Run the application:: 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, 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::: comment the following lines in app.py:::
if not os.path.exists(database_path): if not os.path.exists(database_path):
build_sample_db() build_sample_db()
...@@ -4,24 +4,24 @@ To run this example: ...@@ -4,24 +4,24 @@ To run this example:
1. Clone the repository:: 1. Clone the repository::
git clone https://github.com/mrjoes/flask-admin.git git clone https://github.com/mrjoes/flask-admin.git
cd flask-admin cd flask-admin
2. Create and activate a virtual environment:: 2. Create and activate a virtual environment::
virtualenv env virtualenv env
source env/bin/activate source env/bin/activate
3. Install requirements:: 3. Install requirements::
pip install -r 'examples/layout-bootstrap3/requirements.txt' pip install -r 'examples/layout-bootstrap3/requirements.txt'
4. Run the application:: 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, 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::: comment the following lines in app.py:::
if not os.path.exists(database_path): if not os.path.exists(database_path):
build_sample_db() build_sample_db()
...@@ -4,24 +4,24 @@ To run this example: ...@@ -4,24 +4,24 @@ To run this example:
1. Clone the repository:: 1. Clone the repository::
git clone https://github.com/mrjoes/flask-admin.git git clone https://github.com/mrjoes/flask-admin.git
cd flask-admin cd flask-admin
2. Create and activate a virtual environment:: 2. Create and activate a virtual environment::
virtualenv env virtualenv env
source env/bin/activate source env/bin/activate
3. Install requirements:: 3. Install requirements::
pip install -r 'examples/layout/requirements.txt' pip install -r 'examples/layout/requirements.txt'
4. Run the application:: 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, 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::: comment the following lines in app.py:::
if not os.path.exists(database_path): if not os.path.exists(database_path):
build_sample_db() build_sample_db()
...@@ -4,20 +4,20 @@ To run this example: ...@@ -4,20 +4,20 @@ To run this example:
1. Clone the repository:: 1. Clone the repository::
git clone https://github.com/mrjoes/flask-admin.git git clone https://github.com/mrjoes/flask-admin.git
cd flask-admin cd flask-admin
2. Create and activate a virtual environment:: 2. Create and activate a virtual environment::
virtualenv env virtualenv env
source env/bin/activate source env/bin/activate
3. Install requirements:: 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:: 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: ...@@ -4,18 +4,18 @@ To run this example:
1. Clone the repository:: 1. Clone the repository::
git clone https://github.com/mrjoes/flask-admin.git git clone https://github.com/mrjoes/flask-admin.git
cd flask-admin cd flask-admin
2. Create and activate a virtual environment:: 2. Create and activate a virtual environment::
virtualenv env virtualenv env
source env/bin/activate source env/bin/activate
3. Install requirements:: 3. Install requirements::
pip install -r 'examples/methodview/requirements.txt' pip install -r 'examples/methodview/requirements.txt'
4. Run the application:: 4. Run the application::
python examples/methodview/app.py python examples/methodview/app.py
...@@ -4,19 +4,19 @@ To run this example: ...@@ -4,19 +4,19 @@ To run this example:
1. Clone the repository:: 1. Clone the repository::
git clone https://github.com/mrjoes/flask-admin.git git clone https://github.com/mrjoes/flask-admin.git
cd flask-admin cd flask-admin
2. Create and activate a virtual environment:: 2. Create and activate a virtual environment::
virtualenv env virtualenv env
source env/bin/activate source env/bin/activate
3. Install requirements:: 3. Install requirements::
pip install -r 'examples/mongoengine/requirements.txt' pip install -r 'examples/mongoengine/requirements.txt'
4. Run the application:: 4. Run the application::
python examples/mongoengine/app.py python examples/mongoengine/app.py
...@@ -4,19 +4,19 @@ To run this example: ...@@ -4,19 +4,19 @@ To run this example:
1. Clone the repository:: 1. Clone the repository::
git clone https://github.com/mrjoes/flask-admin.git git clone https://github.com/mrjoes/flask-admin.git
cd flask-admin cd flask-admin
2. Create and activate a virtual environment:: 2. Create and activate a virtual environment::
virtualenv env virtualenv env
source env/bin/activate source env/bin/activate
3. Install requirements:: 3. Install requirements::
pip install -r 'examples/multi/requirements.txt' pip install -r 'examples/multi/requirements.txt'
4. Run the application:: 4. Run the application::
python examples/multi/app.py python examples/multi/app.py
...@@ -4,19 +4,19 @@ To run this example: ...@@ -4,19 +4,19 @@ To run this example:
1. Clone the repository:: 1. Clone the repository::
git clone https://github.com/mrjoes/flask-admin.git git clone https://github.com/mrjoes/flask-admin.git
cd flask-admin cd flask-admin
2. Create and activate a virtual environment:: 2. Create and activate a virtual environment::
virtualenv env virtualenv env
source env/bin/activate source env/bin/activate
3. Install requirements:: 3. Install requirements::
pip install -r 'examples/peewee/requirements.txt' pip install -r 'examples/peewee/requirements.txt'
4. Run the application:: 4. Run the application::
python examples/peewee/app.py python examples/peewee/app.py
...@@ -4,19 +4,19 @@ To run this example: ...@@ -4,19 +4,19 @@ To run this example:
1. Clone the repository:: 1. Clone the repository::
git clone https://github.com/mrjoes/flask-admin.git git clone https://github.com/mrjoes/flask-admin.git
cd flask-admin cd flask-admin
2. Create and activate a virtual environment:: 2. Create and activate a virtual environment::
virtualenv env virtualenv env
source env/bin/activate source env/bin/activate
3. Install requirements:: 3. Install requirements::
pip install -r 'examples/pymongo/requirements.txt' pip install -r 'examples/pymongo/requirements.txt'
4. Run the application:: 4. Run the application::
python examples/pymongo/app.py python examples/pymongo/app.py
...@@ -4,21 +4,21 @@ To run this example: ...@@ -4,21 +4,21 @@ To run this example:
1. Clone the repository:: 1. Clone the repository::
git clone https://github.com/mrjoes/flask-admin.git git clone https://github.com/mrjoes/flask-admin.git
cd flask-admin cd flask-admin
2. Create and activate a virtual environment:: 2. Create and activate a virtual environment::
virtualenv env virtualenv env
source env/bin/activate source env/bin/activate
3. Install requirements:: 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:: 4. Run the application with any of the following::
python examples/quickstart/app.py python examples/quickstart/app.py
python examples/quickstart/app2.py python examples/quickstart/app2.py
python examples/quickstart/app3.py python examples/quickstart/app3.py
...@@ -4,20 +4,20 @@ To run this example: ...@@ -4,20 +4,20 @@ To run this example:
1. Clone the repository:: 1. Clone the repository::
git clone https://github.com/mrjoes/flask-admin.git git clone https://github.com/mrjoes/flask-admin.git
cd flask-admin cd flask-admin
2. Create and activate a virtual environment:: 2. Create and activate a virtual environment::
virtualenv env virtualenv env
source env/bin/activate source env/bin/activate
3. Install requirements:: 3. Install requirements::
pip install -r 'examples/rediscli/requirements.txt' pip install -r 'examples/rediscli/requirements.txt'
4. Run the application:: 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. 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
...@@ -5,18 +5,18 @@ To run this example: ...@@ -5,18 +5,18 @@ To run this example:
1. Clone the repository:: 1. Clone the repository::
git clone https://github.com/mrjoes/flask-admin.git git clone https://github.com/mrjoes/flask-admin.git
cd flask-admin cd flask-admin
2. Create and activate a virtual environment:: 2. Create and activate a virtual environment::
virtualenv env virtualenv env
source env/bin/activate source env/bin/activate
3. Install requirements:: 3. Install requirements::
pip install -r 'examples/simple/requirements.txt' pip install -r 'examples/simple/requirements.txt'
4. Run the application:: 4. Run the application::
python examples/simple/app.py python examples/simple/app.py
...@@ -4,24 +4,24 @@ To run this example: ...@@ -4,24 +4,24 @@ To run this example:
1. Clone the repository:: 1. Clone the repository::
git clone https://github.com/mrjoes/flask-admin.git git clone https://github.com/mrjoes/flask-admin.git
cd flask-admin cd flask-admin
2. Create and activate a virtual environment:: 2. Create and activate a virtual environment::
virtualenv env virtualenv env
source env/bin/activate source env/bin/activate
3. Install requirements:: 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:: 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, 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::: comment the following lines in app.py:::
if not os.path.exists(database_path): if not os.path.exists(database_path):
build_sample_db() build_sample_db()
...@@ -4,20 +4,20 @@ To run this example: ...@@ -4,20 +4,20 @@ To run this example:
1. Clone the repository:: 1. Clone the repository::
git clone https://github.com/mrjoes/flask-admin.git git clone https://github.com/mrjoes/flask-admin.git
cd flask-admin cd flask-admin
2. Create and activate a virtual environment:: 2. Create and activate a virtual environment::
virtualenv env virtualenv env
source env/bin/activate source env/bin/activate
3. Install requirements:: 3. Install requirements::
pip install -r 'examples/sqla-inline/requirements.txt' pip install -r 'examples/sqla-inline/requirements.txt'
4. Run the application:: 4. Run the application::
python examples/sqla-inline/app.py python examples/sqla-inline/app.py
...@@ -4,25 +4,25 @@ To run this example: ...@@ -4,25 +4,25 @@ To run this example:
1. Clone the repository:: 1. Clone the repository::
git clone https://github.com/mrjoes/flask-admin.git git clone https://github.com/mrjoes/flask-admin.git
cd flask-admin cd flask-admin
2. Create and activate a virtual environment:: 2. Create and activate a virtual environment::
virtualenv env virtualenv env
source env/bin/activate source env/bin/activate
3. Install requirements:: 3. Install requirements::
pip install -r 'examples/sqla/requirements.txt' pip install -r 'examples/sqla/requirements.txt'
4. Run either of these applications:: 4. Run either of these applications::
python examples/sqla/app.py python examples/sqla/app.py
python examples/sqla/app2.py python examples/sqla/app2.py
The first time you run this example, a sample sqlite database gets populated automatically. To suppress this behaviour, 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::: comment the following lines in app.py:::
if not os.path.exists(database_path): if not os.path.exists(database_path):
build_sample_db() build_sample_db()
...@@ -4,19 +4,19 @@ To run this example: ...@@ -4,19 +4,19 @@ To run this example:
1. Clone the repository:: 1. Clone the repository::
git clone https://github.com/mrjoes/flask-admin.git git clone https://github.com/mrjoes/flask-admin.git
cd flask-admin cd flask-admin
2. Create and activate a virtual environment:: 2. Create and activate a virtual environment::
virtualenv env virtualenv env
source env/bin/activate source env/bin/activate
3. Install requirements:: 3. Install requirements::
pip install -r 'examples/wysiwyg/requirements.txt' pip install -r 'examples/wysiwyg/requirements.txt'
4. Run the application:: 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' __author__ = 'Serge S. Koval'
__email__ = 'serge.koval+github@gmail.com' __email__ = 'serge.koval+github@gmail.com'
......
...@@ -116,6 +116,10 @@ class BaseView(with_metaclass(AdminViewMeta, BaseViewClass)): ...@@ -116,6 +116,10 @@ class BaseView(with_metaclass(AdminViewMeta, BaseViewClass)):
@expose('/') @expose('/')
def index(self): def index(self):
return 'Hello World!' 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 @property
def _template_args(self): def _template_args(self):
...@@ -229,10 +233,16 @@ class BaseView(with_metaclass(AdminViewMeta, BaseViewClass)): ...@@ -229,10 +233,16 @@ class BaseView(with_metaclass(AdminViewMeta, BaseViewClass)):
if not self.url.startswith('/'): if not self.url.startswith('/'):
self.url = '%s/%s' % (self.admin.url, self.url) self.url = '%s/%s' % (self.admin.url, self.url)
# If we're working from the root of the site, set prefix to None # If we're working from the root of the site, set prefix to None
if self.url == '/': if self.url == '/':
self.url = None 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 name is not povided, use capitalized endpoint name
if self.name is None: if self.name is None:
self.name = self._prettify_class_name(self.__class__.__name__) self.name = self._prettify_class_name(self.__class__.__name__)
...@@ -383,9 +393,21 @@ class AdminIndexView(BaseView): ...@@ -383,9 +393,21 @@ class AdminIndexView(BaseView):
@expose('/') @expose('/')
def index(self): def index(self):
arg1 = 'Hello' arg1 = 'Hello'
return render_template('adminhome.html', arg1=arg1) return self.render('admin/myhome.html', arg1=arg1)
admin = Admin(index_view=MyHomeView()) 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: Default values for the index page are:
...@@ -397,12 +419,18 @@ class AdminIndexView(BaseView): ...@@ -397,12 +419,18 @@ class AdminIndexView(BaseView):
""" """
def __init__(self, name=None, category=None, def __init__(self, name=None, category=None,
endpoint=None, url=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'), super(AdminIndexView, self).__init__(name or babel.lazy_gettext('Home'),
category, category,
endpoint or 'admin', endpoint or 'admin',
url 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 self._template = template
@expose() @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 ...@@ -2,6 +2,10 @@ import json
from wtforms.fields import TextAreaField from wtforms.fields import TextAreaField
from shapely.geometry import shape, mapping from shapely.geometry import shape, mapping
from .widgets import LeafletWidget 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): class JSONField(TextAreaField):
...@@ -9,7 +13,7 @@ class JSONField(TextAreaField): ...@@ -9,7 +13,7 @@ class JSONField(TextAreaField):
if self.raw_data: if self.raw_data:
return self.raw_data[0] return self.raw_data[0]
if self.data: if self.data:
return self.to_json(self.data) return self.data
return "" return ""
def process_formdata(self, valuelist): def process_formdata(self, valuelist):
...@@ -33,19 +37,32 @@ class JSONField(TextAreaField): ...@@ -33,19 +37,32 @@ class JSONField(TextAreaField):
class GeoJSONField(JSONField): class GeoJSONField(JSONField):
widget = LeafletWidget() 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) 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.geometry_type = geometry_type.upper()
self.session = session
def _value(self): def _value(self):
if self.raw_data: if self.raw_data:
return self.raw_data[0] return self.raw_data[0]
if self.data: if type(self.data) is geoalchemy2.elements.WKBElement:
self.data = mapping(self.data) 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() return super(GeoJSONField, self)._value()
def process_formdata(self, valuelist): def process_formdata(self, valuelist):
super(GeoJSONField, self).process_formdata(valuelist) super(GeoJSONField, self).process_formdata(valuelist)
if self.data: if str(self.data) is '':
self.data = shape(self.data) 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 ...@@ -4,7 +4,9 @@ from .fields import GeoJSONField
class AdminModelConverter(SQLAAdminConverter): class AdminModelConverter(SQLAAdminConverter):
@converts('Geometry') @converts('Geography', 'Geometry')
def convert_geom(self, column, field_args, **extra): def convert_geom(self, column, field_args, **extra):
field_args['geometry_type'] = column.type.geometry_type field_args['geometry_type'] = column.type.geometry_type
field_args['srid'] = column.type.srid
field_args['session'] = self.session
return GeoJSONField(**field_args) 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 ...@@ -2,8 +2,10 @@ from flask.ext.admin.contrib.sqla.typefmt import DEFAULT_FORMATTERS as BASE_FORM
import json import json
from jinja2 import Markup from jinja2 import Markup
from wtforms.widgets import html_params from wtforms.widgets import html_params
from shapely.geometry import mapping from geoalchemy2.shape import to_shape
from shapely.geometry.base import BaseGeometry from geoalchemy2.elements import WKBElement
from sqlalchemy import func
from flask import current_app
def geom_formatter(view, value): def geom_formatter(view, value):
...@@ -12,12 +14,15 @@ def geom_formatter(view, value): ...@@ -12,12 +14,15 @@ def geom_formatter(view, value):
"disabled": "disabled", "disabled": "disabled",
"data-width": 100, "data-width": 100,
"data-height": 70, "data-height": 70,
"data-geometry-type": value.geom_type, "data-geometry-type": to_shape(value).geom_type,
"data-zoom": 15, "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)) return Markup('<textarea %s>%s</textarea>' % (params, geojson))
DEFAULT_FORMATTERS = BASE_FORMATTERS.copy() DEFAULT_FORMATTERS = BASE_FORMATTERS.copy()
DEFAULT_FORMATTERS[BaseGeometry] = geom_formatter DEFAULT_FORMATTERS[WKBElement] = geom_formatter
...@@ -10,6 +10,8 @@ def lng(pt): ...@@ -10,6 +10,8 @@ def lng(pt):
class LeafletWidget(TextArea): class LeafletWidget(TextArea):
data_role = 'leaflet'
""" """
`Leaflet <http://leafletjs.com/>`_ styled map widget. Inherits from `Leaflet <http://leafletjs.com/>`_ styled map widget. Inherits from
`TextArea` so that geographic data can be stored via the <textarea> `TextArea` so that geographic data can be stored via the <textarea>
...@@ -31,14 +33,14 @@ class LeafletWidget(TextArea): ...@@ -31,14 +33,14 @@ class LeafletWidget(TextArea):
self.max_bounds = max_bounds self.max_bounds = max_bounds
def __call__(self, field, **kwargs): def __call__(self, field, **kwargs):
kwargs.setdefault('data-role', 'leaflet') kwargs.setdefault('data-role', self.data_role)
gtype = getattr(field, "geometry_type", "GEOMETRY") gtype = getattr(field, "geometry_type", "GEOMETRY")
kwargs.setdefault('data-geometry-type', gtype) kwargs.setdefault('data-geometry-type', gtype)
# set optional values from constructor # set optional values from constructor
if self.width: if not "data-width" in kwargs:
kwargs["data-width"] = self.width kwargs["data-width"] = self.width
if self.height: if not "data-height" in kwargs:
kwargs["data-height"] = self.height kwargs["data-height"] = self.height
if self.center: if self.center:
kwargs["data-lat"] = lat(self.center) kwargs["data-lat"] = lat(self.center)
......
...@@ -92,10 +92,10 @@ class FilterEmpty(BaseMongoEngineFilter, filters.BaseBooleanFilter): ...@@ -92,10 +92,10 @@ class FilterEmpty(BaseMongoEngineFilter, filters.BaseBooleanFilter):
else: else:
flt = {'%s__ne' % self.column.name: None} flt = {'%s__ne' % self.column.name: None}
return query.filter(**flt) return query.filter(**flt)
def operation(self): def operation(self):
return lazy_gettext('empty') return lazy_gettext('empty')
class FilterInList(BaseMongoEngineFilter): class FilterInList(BaseMongoEngineFilter):
def __init__(self, column, name, options=None, data_type=None): def __init__(self, column, name, options=None, data_type=None):
...@@ -103,23 +103,23 @@ class FilterInList(BaseMongoEngineFilter): ...@@ -103,23 +103,23 @@ class FilterInList(BaseMongoEngineFilter):
def clean(self, value): def clean(self, value):
return [v.strip() for v in value.split(',') if v.strip()] return [v.strip() for v in value.split(',') if v.strip()]
def apply(self, query, value): def apply(self, query, value):
flt = {'%s__in' % self.column.name: value} flt = {'%s__in' % self.column.name: value}
return query.filter(**flt) return query.filter(**flt)
def operation(self): def operation(self):
return lazy_gettext('in list') return lazy_gettext('in list')
class FilterNotInList(FilterInList): class FilterNotInList(FilterInList):
def apply(self, query, value): def apply(self, query, value):
flt = {'%s__nin' % self.column.name: value} flt = {'%s__nin' % self.column.name: value}
return query.filter(**flt) return query.filter(**flt)
def operation(self): def operation(self):
return lazy_gettext('not in list') return lazy_gettext('not in list')
# Customized type filters # Customized type filters
class BooleanEqualFilter(FilterEqual, filters.BaseBooleanFilter): class BooleanEqualFilter(FilterEqual, filters.BaseBooleanFilter):
...@@ -132,95 +132,95 @@ class BooleanNotEqualFilter(FilterNotEqual, filters.BaseBooleanFilter): ...@@ -132,95 +132,95 @@ class BooleanNotEqualFilter(FilterNotEqual, filters.BaseBooleanFilter):
def apply(self, query, value): def apply(self, query, value):
flt = {'%s' % self.column.name: value != '1'} flt = {'%s' % self.column.name: value != '1'}
return query.filter(**flt) return query.filter(**flt)
class IntEqualFilter(FilterEqual, filters.BaseIntFilter): class IntEqualFilter(FilterEqual, filters.BaseIntFilter):
pass pass
class IntNotEqualFilter(FilterNotEqual, filters.BaseIntFilter): class IntNotEqualFilter(FilterNotEqual, filters.BaseIntFilter):
pass pass
class IntGreaterFilter(FilterGreater, filters.BaseIntFilter): class IntGreaterFilter(FilterGreater, filters.BaseIntFilter):
pass pass
class IntSmallerFilter(FilterSmaller, filters.BaseIntFilter): class IntSmallerFilter(FilterSmaller, filters.BaseIntFilter):
pass pass
class IntInListFilter(filters.BaseIntListFilter, FilterInList): class IntInListFilter(filters.BaseIntListFilter, FilterInList):
pass pass
class IntNotInListFilter(filters.BaseIntListFilter, FilterNotInList): class IntNotInListFilter(filters.BaseIntListFilter, FilterNotInList):
pass pass
class FloatEqualFilter(FilterEqual, filters.BaseFloatFilter): class FloatEqualFilter(FilterEqual, filters.BaseFloatFilter):
pass pass
class FloatNotEqualFilter(FilterNotEqual, filters.BaseFloatFilter): class FloatNotEqualFilter(FilterNotEqual, filters.BaseFloatFilter):
pass pass
class FloatGreaterFilter(FilterGreater, filters.BaseFloatFilter): class FloatGreaterFilter(FilterGreater, filters.BaseFloatFilter):
pass pass
class FloatSmallerFilter(FilterSmaller, filters.BaseFloatFilter): class FloatSmallerFilter(FilterSmaller, filters.BaseFloatFilter):
pass pass
class FloatInListFilter(filters.BaseFloatListFilter, FilterInList): class FloatInListFilter(filters.BaseFloatListFilter, FilterInList):
pass pass
class FloatNotInListFilter(filters.BaseFloatListFilter, FilterNotInList): class FloatNotInListFilter(filters.BaseFloatListFilter, FilterNotInList):
pass pass
class DateTimeEqualFilter(FilterEqual, filters.BaseDateTimeFilter): class DateTimeEqualFilter(FilterEqual, filters.BaseDateTimeFilter):
pass pass
class DateTimeNotEqualFilter(FilterNotEqual, filters.BaseDateTimeFilter): class DateTimeNotEqualFilter(FilterNotEqual, filters.BaseDateTimeFilter):
pass pass
class DateTimeGreaterFilter(FilterGreater, filters.BaseDateTimeFilter): class DateTimeGreaterFilter(FilterGreater, filters.BaseDateTimeFilter):
pass pass
class DateTimeSmallerFilter(FilterSmaller, filters.BaseDateTimeFilter): class DateTimeSmallerFilter(FilterSmaller, filters.BaseDateTimeFilter):
pass pass
class DateTimeBetweenFilter(BaseMongoEngineFilter, filters.BaseDateTimeBetweenFilter): class DateTimeBetweenFilter(BaseMongoEngineFilter, filters.BaseDateTimeBetweenFilter):
def __init__(self, column, name, options=None, data_type=None): def __init__(self, column, name, options=None, data_type=None):
super(DateTimeBetweenFilter, self).__init__(column, super(DateTimeBetweenFilter, self).__init__(column,
name, name,
options, options,
data_type='datetimerangepicker') data_type='datetimerangepicker')
def apply(self, query, value): def apply(self, query, value):
start, end = value start, end = value
flt = {'%s__gte' % self.column.name: start, '%s__lte' % self.column.name: end} flt = {'%s__gte' % self.column.name: start, '%s__lte' % self.column.name: end}
return query.filter(**flt) return query.filter(**flt)
class DateTimeNotBetweenFilter(DateTimeBetweenFilter): class DateTimeNotBetweenFilter(DateTimeBetweenFilter):
def apply(self, query, value): def apply(self, query, value):
start, end = 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})) Q(**{'%s__not__lte' % self.column.name: end}))
def operation(self): def operation(self):
return lazy_gettext('not between') return lazy_gettext('not between')
# Base peewee filter field converter # Base peewee filter field converter
class FilterConverter(filters.BaseFilterConverter): class FilterConverter(filters.BaseFilterConverter):
strings = (FilterEqual, FilterNotEqual, FilterLike, FilterNotLike, strings = (FilterEqual, FilterNotEqual, FilterLike, FilterNotLike,
...@@ -229,17 +229,19 @@ class FilterConverter(filters.BaseFilterConverter): ...@@ -229,17 +229,19 @@ class FilterConverter(filters.BaseFilterConverter):
IntSmallerFilter, FilterEmpty, IntInListFilter, IntSmallerFilter, FilterEmpty, IntInListFilter,
IntNotInListFilter) IntNotInListFilter)
float_filters = (FloatEqualFilter, FloatNotEqualFilter, FloatGreaterFilter, float_filters = (FloatEqualFilter, FloatNotEqualFilter, FloatGreaterFilter,
FloatSmallerFilter, FilterEmpty, FloatInListFilter, FloatSmallerFilter, FilterEmpty, FloatInListFilter,
FloatNotInListFilter) FloatNotInListFilter)
bool_filters = (BooleanEqualFilter, BooleanNotEqualFilter) bool_filters = (BooleanEqualFilter, BooleanNotEqualFilter)
datetime_filters = (DateTimeEqualFilter, DateTimeNotEqualFilter, datetime_filters = (DateTimeEqualFilter, DateTimeNotEqualFilter,
DateTimeGreaterFilter, DateTimeSmallerFilter, DateTimeGreaterFilter, DateTimeSmallerFilter,
DateTimeBetweenFilter, DateTimeNotBetweenFilter, DateTimeBetweenFilter, DateTimeNotBetweenFilter,
FilterEmpty) FilterEmpty)
def convert(self, type_name, column, name): def convert(self, type_name, column, name):
if type_name in self.converters: filter_name = type_name.lower()
return self.converters[type_name](column, name)
if filter_name in self.converters:
return self.converters[filter_name](column, name)
return None return None
...@@ -254,11 +256,11 @@ class FilterConverter(filters.BaseFilterConverter): ...@@ -254,11 +256,11 @@ class FilterConverter(filters.BaseFilterConverter):
@filters.convert('IntField', 'LongField') @filters.convert('IntField', 'LongField')
def conv_int(self, column, name): def conv_int(self, column, name):
return [f(column, name) for f in self.int_filters] return [f(column, name) for f in self.int_filters]
@filters.convert('DecimalField', 'FloatField') @filters.convert('DecimalField', 'FloatField')
def conv_float(self, column, name): def conv_float(self, column, name):
return [f(column, name) for f in self.float_filters] return [f(column, name) for f in self.float_filters]
@filters.convert('DateTimeField', 'ComplexDateTimeField') @filters.convert('DateTimeField', 'ComplexDateTimeField')
def conv_datetime(self, column, name): def conv_datetime(self, column, name):
return [f(column, name) for f in self.datetime_filters] return [f(column, name) for f in self.datetime_filters]
...@@ -5,6 +5,8 @@ from flask import request, flash, abort, Response ...@@ -5,6 +5,8 @@ from flask import request, flash, abort, Response
from flask.ext.admin import expose from flask.ext.admin import expose
from flask.ext.admin.babel import gettext, ngettext, lazy_gettext from flask.ext.admin.babel import gettext, ngettext, lazy_gettext
from flask.ext.admin.model import BaseModelView 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 from flask.ext.admin._compat import iteritems, string_types
import mongoengine import mongoengine
...@@ -21,7 +23,6 @@ from .helpers import format_error ...@@ -21,7 +23,6 @@ from .helpers import format_error
from .ajax import process_ajax_references, create_ajax_loader from .ajax import process_ajax_references, create_ajax_loader
from .subdoc import convert_subdocuments from .subdoc import convert_subdocuments
# Set up logger # Set up logger
log = logging.getLogger("flask-admin.mongo") log = logging.getLogger("flask-admin.mongo")
...@@ -398,6 +399,28 @@ class ModelView(BaseModelView): ...@@ -398,6 +399,28 @@ class ModelView(BaseModelView):
return form_class 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 # AJAX foreignkey support
def _create_ajax_loader(self, name, opts): def _create_ajax_loader(self, name, opts):
return create_ajax_loader(self.model, name, name, opts) return create_ajax_loader(self.model, name, name, opts)
...@@ -409,6 +432,26 @@ class ModelView(BaseModelView): ...@@ -409,6 +432,26 @@ class ModelView(BaseModelView):
""" """
return self.model.objects 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, def get_list(self, page, sort_column, sort_desc, search, filters,
execute=True): execute=True):
""" """
...@@ -437,24 +480,7 @@ class ModelView(BaseModelView): ...@@ -437,24 +480,7 @@ class ModelView(BaseModelView):
# Search # Search
if self._search_supported and search: if self._search_supported and search:
# TODO: Unfortunately, MongoEngine contains bug which query = self._search(query, search)
# 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)
# Get count # Get count
count = query.count() count = query.count()
......
...@@ -89,7 +89,7 @@ class FilterEmpty(BasePeeweeFilter, filters.BaseBooleanFilter): ...@@ -89,7 +89,7 @@ class FilterEmpty(BasePeeweeFilter, filters.BaseBooleanFilter):
def operation(self): def operation(self):
return lazy_gettext('empty') return lazy_gettext('empty')
class FilterInList(BasePeeweeFilter): class FilterInList(BasePeeweeFilter):
def __init__(self, column, name, options=None, data_type=None): def __init__(self, column, name, options=None, data_type=None):
...@@ -97,144 +97,144 @@ class FilterInList(BasePeeweeFilter): ...@@ -97,144 +97,144 @@ class FilterInList(BasePeeweeFilter):
def clean(self, value): def clean(self, value):
return [v.strip() for v in value.split(',') if v.strip()] return [v.strip() for v in value.split(',') if v.strip()]
def apply(self, query, value): def apply(self, query, value):
return query.filter(self.column << value) return query.filter(self.column << value)
def operation(self): def operation(self):
return lazy_gettext('in list') return lazy_gettext('in list')
class FilterNotInList(FilterInList): class FilterNotInList(FilterInList):
def apply(self, query, value): def apply(self, query, value):
# NOT IN can exclude NULL values, so "or_ == None" needed to be added # NOT IN can exclude NULL values, so "or_ == None" needed to be added
return query.filter(~(self.column << value) | (self.column >> None)) return query.filter(~(self.column << value) | (self.column >> None))
def operation(self): def operation(self):
return lazy_gettext('not in list') return lazy_gettext('not in list')
# Customized type filters # Customized type filters
class BooleanEqualFilter(FilterEqual, filters.BaseBooleanFilter): class BooleanEqualFilter(FilterEqual, filters.BaseBooleanFilter):
pass pass
class BooleanNotEqualFilter(FilterNotEqual, filters.BaseBooleanFilter): class BooleanNotEqualFilter(FilterNotEqual, filters.BaseBooleanFilter):
pass pass
class IntEqualFilter(FilterEqual, filters.BaseIntFilter): class IntEqualFilter(FilterEqual, filters.BaseIntFilter):
pass pass
class IntNotEqualFilter(FilterNotEqual, filters.BaseIntFilter): class IntNotEqualFilter(FilterNotEqual, filters.BaseIntFilter):
pass pass
class IntGreaterFilter(FilterGreater, filters.BaseIntFilter): class IntGreaterFilter(FilterGreater, filters.BaseIntFilter):
pass pass
class IntSmallerFilter(FilterSmaller, filters.BaseIntFilter): class IntSmallerFilter(FilterSmaller, filters.BaseIntFilter):
pass pass
class IntInListFilter(filters.BaseIntListFilter, FilterInList): class IntInListFilter(filters.BaseIntListFilter, FilterInList):
pass pass
class IntNotInListFilter(filters.BaseIntListFilter, FilterNotInList): class IntNotInListFilter(filters.BaseIntListFilter, FilterNotInList):
pass pass
class FloatEqualFilter(FilterEqual, filters.BaseFloatFilter): class FloatEqualFilter(FilterEqual, filters.BaseFloatFilter):
pass pass
class FloatNotEqualFilter(FilterNotEqual, filters.BaseFloatFilter): class FloatNotEqualFilter(FilterNotEqual, filters.BaseFloatFilter):
pass pass
class FloatGreaterFilter(FilterGreater, filters.BaseFloatFilter): class FloatGreaterFilter(FilterGreater, filters.BaseFloatFilter):
pass pass
class FloatSmallerFilter(FilterSmaller, filters.BaseFloatFilter): class FloatSmallerFilter(FilterSmaller, filters.BaseFloatFilter):
pass pass
class FloatInListFilter(filters.BaseFloatListFilter, FilterInList): class FloatInListFilter(filters.BaseFloatListFilter, FilterInList):
pass pass
class FloatNotInListFilter(filters.BaseFloatListFilter, FilterNotInList): class FloatNotInListFilter(filters.BaseFloatListFilter, FilterNotInList):
pass pass
class DateEqualFilter(FilterEqual, filters.BaseDateFilter): class DateEqualFilter(FilterEqual, filters.BaseDateFilter):
pass pass
class DateNotEqualFilter(FilterNotEqual, filters.BaseDateFilter): class DateNotEqualFilter(FilterNotEqual, filters.BaseDateFilter):
pass pass
class DateGreaterFilter(FilterGreater, filters.BaseDateFilter): class DateGreaterFilter(FilterGreater, filters.BaseDateFilter):
pass pass
class DateSmallerFilter(FilterSmaller, filters.BaseDateFilter): class DateSmallerFilter(FilterSmaller, filters.BaseDateFilter):
pass pass
class DateBetweenFilter(BasePeeweeFilter, filters.BaseDateBetweenFilter): class DateBetweenFilter(BasePeeweeFilter, filters.BaseDateBetweenFilter):
def __init__(self, column, name, options=None, data_type=None): def __init__(self, column, name, options=None, data_type=None):
super(DateBetweenFilter, self).__init__(column, super(DateBetweenFilter, self).__init__(column,
name, name,
options, options,
data_type='daterangepicker') data_type='daterangepicker')
def apply(self, query, value): def apply(self, query, value):
start, end = value start, end = value
return query.filter(self.column.between(start, end)) return query.filter(self.column.between(start, end))
class DateNotBetweenFilter(DateBetweenFilter): class DateNotBetweenFilter(DateBetweenFilter):
def apply(self, query, value): def apply(self, query, value):
start, end = value start, end = value
return query.filter(~(self.column.between(start, end))) return query.filter(~(self.column.between(start, end)))
def operation(self): def operation(self):
return lazy_gettext('not between') return lazy_gettext('not between')
class DateTimeEqualFilter(FilterEqual, filters.BaseDateTimeFilter): class DateTimeEqualFilter(FilterEqual, filters.BaseDateTimeFilter):
pass pass
class DateTimeNotEqualFilter(FilterNotEqual, filters.BaseDateTimeFilter): class DateTimeNotEqualFilter(FilterNotEqual, filters.BaseDateTimeFilter):
pass pass
class DateTimeGreaterFilter(FilterGreater, filters.BaseDateTimeFilter): class DateTimeGreaterFilter(FilterGreater, filters.BaseDateTimeFilter):
pass pass
class DateTimeSmallerFilter(FilterSmaller, filters.BaseDateTimeFilter): class DateTimeSmallerFilter(FilterSmaller, filters.BaseDateTimeFilter):
pass pass
class DateTimeBetweenFilter(BasePeeweeFilter, filters.BaseDateTimeBetweenFilter): class DateTimeBetweenFilter(BasePeeweeFilter, filters.BaseDateTimeBetweenFilter):
def __init__(self, column, name, options=None, data_type=None): def __init__(self, column, name, options=None, data_type=None):
super(DateTimeBetweenFilter, self).__init__(column, super(DateTimeBetweenFilter, self).__init__(column,
name, name,
options, options,
data_type='datetimerangepicker') data_type='datetimerangepicker')
def apply(self, query, value): def apply(self, query, value):
start, end = value start, end = value
return query.filter(self.column.between(start, end)) return query.filter(self.column.between(start, end))
class DateTimeNotBetweenFilter(DateTimeBetweenFilter): class DateTimeNotBetweenFilter(DateTimeBetweenFilter):
def apply(self, query, value): def apply(self, query, value):
...@@ -243,45 +243,45 @@ class DateTimeNotBetweenFilter(DateTimeBetweenFilter): ...@@ -243,45 +243,45 @@ class DateTimeNotBetweenFilter(DateTimeBetweenFilter):
def operation(self): def operation(self):
return lazy_gettext('not between') return lazy_gettext('not between')
class TimeEqualFilter(FilterEqual, filters.BaseTimeFilter): class TimeEqualFilter(FilterEqual, filters.BaseTimeFilter):
pass pass
class TimeNotEqualFilter(FilterNotEqual, filters.BaseTimeFilter): class TimeNotEqualFilter(FilterNotEqual, filters.BaseTimeFilter):
pass pass
class TimeGreaterFilter(FilterGreater, filters.BaseTimeFilter): class TimeGreaterFilter(FilterGreater, filters.BaseTimeFilter):
pass pass
class TimeSmallerFilter(FilterSmaller, filters.BaseTimeFilter): class TimeSmallerFilter(FilterSmaller, filters.BaseTimeFilter):
pass pass
class TimeBetweenFilter(BasePeeweeFilter, filters.BaseTimeBetweenFilter): class TimeBetweenFilter(BasePeeweeFilter, filters.BaseTimeBetweenFilter):
def __init__(self, column, name, options=None, data_type=None): def __init__(self, column, name, options=None, data_type=None):
super(TimeBetweenFilter, self).__init__(column, super(TimeBetweenFilter, self).__init__(column,
name, name,
options, options,
data_type='timerangepicker') data_type='timerangepicker')
def apply(self, query, value): def apply(self, query, value):
start, end = value start, end = value
return query.filter(self.column.between(start, end)) return query.filter(self.column.between(start, end))
class TimeNotBetweenFilter(TimeBetweenFilter): class TimeNotBetweenFilter(TimeBetweenFilter):
def apply(self, query, value): def apply(self, query, value):
start, end = value start, end = value
return query.filter(~(self.column.between(start, end))) return query.filter(~(self.column.between(start, end)))
def operation(self): def operation(self):
return lazy_gettext('not between') return lazy_gettext('not between')
# Base peewee filter field converter # Base peewee filter field converter
class FilterConverter(filters.BaseFilterConverter): class FilterConverter(filters.BaseFilterConverter):
strings = (FilterEqual, FilterNotEqual, FilterLike, FilterNotLike, strings = (FilterEqual, FilterNotEqual, FilterLike, FilterNotLike,
...@@ -290,23 +290,25 @@ class FilterConverter(filters.BaseFilterConverter): ...@@ -290,23 +290,25 @@ class FilterConverter(filters.BaseFilterConverter):
IntSmallerFilter, FilterEmpty, IntInListFilter, IntSmallerFilter, FilterEmpty, IntInListFilter,
IntNotInListFilter) IntNotInListFilter)
float_filters = (FloatEqualFilter, FloatNotEqualFilter, FloatGreaterFilter, float_filters = (FloatEqualFilter, FloatNotEqualFilter, FloatGreaterFilter,
FloatSmallerFilter, FilterEmpty, FloatInListFilter, FloatSmallerFilter, FilterEmpty, FloatInListFilter,
FloatNotInListFilter) FloatNotInListFilter)
bool_filters = (BooleanEqualFilter, BooleanNotEqualFilter) bool_filters = (BooleanEqualFilter, BooleanNotEqualFilter)
date_filters = (DateEqualFilter, DateNotEqualFilter, DateGreaterFilter, date_filters = (DateEqualFilter, DateNotEqualFilter, DateGreaterFilter,
DateSmallerFilter, DateBetweenFilter, DateNotBetweenFilter, DateSmallerFilter, DateBetweenFilter, DateNotBetweenFilter,
FilterEmpty) FilterEmpty)
datetime_filters = (DateTimeEqualFilter, DateTimeNotEqualFilter, datetime_filters = (DateTimeEqualFilter, DateTimeNotEqualFilter,
DateTimeGreaterFilter, DateTimeSmallerFilter, DateTimeGreaterFilter, DateTimeSmallerFilter,
DateTimeBetweenFilter, DateTimeNotBetweenFilter, DateTimeBetweenFilter, DateTimeNotBetweenFilter,
FilterEmpty) FilterEmpty)
time_filters = (TimeEqualFilter, TimeNotEqualFilter, TimeGreaterFilter, time_filters = (TimeEqualFilter, TimeNotEqualFilter, TimeGreaterFilter,
TimeSmallerFilter, TimeBetweenFilter, TimeNotBetweenFilter, TimeSmallerFilter, TimeBetweenFilter, TimeNotBetweenFilter,
FilterEmpty) FilterEmpty)
def convert(self, type_name, column, name): def convert(self, type_name, column, name):
if type_name in self.converters: filter_name = type_name.lower()
return self.converters[type_name](column, name)
if filter_name in self.converters:
return self.converters[filter_name](column, name)
return None return None
...@@ -321,11 +323,11 @@ class FilterConverter(filters.BaseFilterConverter): ...@@ -321,11 +323,11 @@ class FilterConverter(filters.BaseFilterConverter):
@filters.convert('IntegerField', 'BigIntegerField') @filters.convert('IntegerField', 'BigIntegerField')
def conv_int(self, column, name): def conv_int(self, column, name):
return [f(column, name) for f in self.int_filters] return [f(column, name) for f in self.int_filters]
@filters.convert('DecimalField', 'FloatField', 'DoubleField') @filters.convert('DecimalField', 'FloatField', 'DoubleField')
def conv_float(self, column, name): def conv_float(self, column, name):
return [f(column, name) for f in self.float_filters] return [f(column, name) for f in self.float_filters]
@filters.convert('DateField') @filters.convert('DateField')
def conv_date(self, column, name): def conv_date(self, column, name):
return [f(column, name) for f in self.date_filters] return [f(column, name) for f in self.date_filters]
...@@ -333,7 +335,7 @@ class FilterConverter(filters.BaseFilterConverter): ...@@ -333,7 +335,7 @@ class FilterConverter(filters.BaseFilterConverter):
@filters.convert('DateTimeField') @filters.convert('DateTimeField')
def conv_datetime(self, column, name): def conv_datetime(self, column, name):
return [f(column, name) for f in self.datetime_filters] return [f(column, name) for f in self.datetime_filters]
@filters.convert('TimeField') @filters.convert('TimeField')
def conv_time(self, column, name): def conv_time(self, column, name):
return [f(column, name) for f in self.time_filters] return [f(column, name) for f in self.time_filters]
\ No newline at end of file
...@@ -5,6 +5,8 @@ from flask import flash ...@@ -5,6 +5,8 @@ from flask import flash
from flask.ext.admin._compat import string_types from flask.ext.admin._compat import string_types
from flask.ext.admin.babel import gettext, ngettext, lazy_gettext from flask.ext.admin.babel import gettext, ngettext, lazy_gettext
from flask.ext.admin.model import BaseModelView 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 from peewee import PrimaryKeyField, ForeignKeyField, Field, CharField, TextField
...@@ -237,6 +239,27 @@ class ModelView(BaseModelView): ...@@ -237,6 +239,27 @@ class ModelView(BaseModelView):
return form_class 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): def scaffold_inline_form_models(self, form_class):
converter = self.model_form_converter(self) converter = self.model_form_converter(self)
inline_converter = self.inline_model_form_converter(self) inline_converter = self.inline_model_form_converter(self)
......
...@@ -146,6 +146,42 @@ class ModelView(BaseModelView): ...@@ -146,6 +146,42 @@ class ModelView(BaseModelView):
""" """
return model.get(name) 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, def get_list(self, page, sort_column, sort_desc, search, filters,
execute=True): execute=True):
""" """
...@@ -182,38 +218,7 @@ class ModelView(BaseModelView): ...@@ -182,38 +218,7 @@ class ModelView(BaseModelView):
# Search # Search
if self._search_supported and search: if self._search_supported and search:
values = search.split(' ') query = self._search(query, search)
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
# Get count # Get count
count = self.coll.find(query).count() count = self.coll.find(query).count()
......
...@@ -58,7 +58,7 @@ class QueryAjaxModelLoader(AjaxModelLoader): ...@@ -58,7 +58,7 @@ class QueryAjaxModelLoader(AjaxModelLoader):
def get_list(self, term, offset=0, limit=DEFAULT_PAGE_SIZE): def get_list(self, term, offset=0, limit=DEFAULT_PAGE_SIZE):
query = self.session.query(self.model) 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)) query = query.filter(or_(*filters))
return query.offset(offset).limit(limit).all() return query.offset(offset).limit(limit).all()
......
...@@ -89,7 +89,7 @@ class FilterEmpty(BaseSQLAFilter, filters.BaseBooleanFilter): ...@@ -89,7 +89,7 @@ class FilterEmpty(BaseSQLAFilter, filters.BaseBooleanFilter):
def operation(self): def operation(self):
return lazy_gettext('empty') return lazy_gettext('empty')
class FilterInList(BaseSQLAFilter): class FilterInList(BaseSQLAFilter):
def __init__(self, column, name, options=None, data_type=None): def __init__(self, column, name, options=None, data_type=None):
...@@ -97,145 +97,145 @@ class FilterInList(BaseSQLAFilter): ...@@ -97,145 +97,145 @@ class FilterInList(BaseSQLAFilter):
def clean(self, value): def clean(self, value):
return [v.strip() for v in value.split(',') if v.strip()] return [v.strip() for v in value.split(',') if v.strip()]
def apply(self, query, value): def apply(self, query, value):
return query.filter(self.column.in_(value)) return query.filter(self.column.in_(value))
def operation(self): def operation(self):
return lazy_gettext('in list') return lazy_gettext('in list')
class FilterNotInList(FilterInList): class FilterNotInList(FilterInList):
def apply(self, query, value): def apply(self, query, value):
# NOT IN can exclude NULL values, so "or_ == None" needed to be added # NOT IN can exclude NULL values, so "or_ == None" needed to be added
return query.filter(or_(~self.column.in_(value), self.column == None)) return query.filter(or_(~self.column.in_(value), self.column == None))
def operation(self): def operation(self):
return lazy_gettext('not in list') return lazy_gettext('not in list')
# Customized type filters # Customized type filters
class BooleanEqualFilter(FilterEqual, filters.BaseBooleanFilter): class BooleanEqualFilter(FilterEqual, filters.BaseBooleanFilter):
pass pass
class BooleanNotEqualFilter(FilterNotEqual, filters.BaseBooleanFilter): class BooleanNotEqualFilter(FilterNotEqual, filters.BaseBooleanFilter):
pass pass
class IntEqualFilter(FilterEqual, filters.BaseIntFilter): class IntEqualFilter(FilterEqual, filters.BaseIntFilter):
pass pass
class IntNotEqualFilter(FilterNotEqual, filters.BaseIntFilter): class IntNotEqualFilter(FilterNotEqual, filters.BaseIntFilter):
pass pass
class IntGreaterFilter(FilterGreater, filters.BaseIntFilter): class IntGreaterFilter(FilterGreater, filters.BaseIntFilter):
pass pass
class IntSmallerFilter(FilterSmaller, filters.BaseIntFilter): class IntSmallerFilter(FilterSmaller, filters.BaseIntFilter):
pass pass
class IntInListFilter(filters.BaseIntListFilter, FilterInList): class IntInListFilter(filters.BaseIntListFilter, FilterInList):
pass pass
class IntNotInListFilter(filters.BaseIntListFilter, FilterNotInList): class IntNotInListFilter(filters.BaseIntListFilter, FilterNotInList):
pass pass
class FloatEqualFilter(FilterEqual, filters.BaseFloatFilter): class FloatEqualFilter(FilterEqual, filters.BaseFloatFilter):
pass pass
class FloatNotEqualFilter(FilterNotEqual, filters.BaseFloatFilter): class FloatNotEqualFilter(FilterNotEqual, filters.BaseFloatFilter):
pass pass
class FloatGreaterFilter(FilterGreater, filters.BaseFloatFilter): class FloatGreaterFilter(FilterGreater, filters.BaseFloatFilter):
pass pass
class FloatSmallerFilter(FilterSmaller, filters.BaseFloatFilter): class FloatSmallerFilter(FilterSmaller, filters.BaseFloatFilter):
pass pass
class FloatInListFilter(filters.BaseFloatListFilter, FilterInList): class FloatInListFilter(filters.BaseFloatListFilter, FilterInList):
pass pass
class FloatNotInListFilter(filters.BaseFloatListFilter, FilterNotInList): class FloatNotInListFilter(filters.BaseFloatListFilter, FilterNotInList):
pass pass
class DateEqualFilter(FilterEqual, filters.BaseDateFilter): class DateEqualFilter(FilterEqual, filters.BaseDateFilter):
pass pass
class DateNotEqualFilter(FilterNotEqual, filters.BaseDateFilter): class DateNotEqualFilter(FilterNotEqual, filters.BaseDateFilter):
pass pass
class DateGreaterFilter(FilterGreater, filters.BaseDateFilter): class DateGreaterFilter(FilterGreater, filters.BaseDateFilter):
pass pass
class DateSmallerFilter(FilterSmaller, filters.BaseDateFilter): class DateSmallerFilter(FilterSmaller, filters.BaseDateFilter):
pass pass
class DateBetweenFilter(BaseSQLAFilter, filters.BaseDateBetweenFilter): class DateBetweenFilter(BaseSQLAFilter, filters.BaseDateBetweenFilter):
def __init__(self, column, name, options=None, data_type=None): def __init__(self, column, name, options=None, data_type=None):
super(DateBetweenFilter, self).__init__(column, super(DateBetweenFilter, self).__init__(column,
name, name,
options, options,
data_type='daterangepicker') data_type='daterangepicker')
def apply(self, query, value): def apply(self, query, value):
start, end = value start, end = value
return query.filter(self.column.between(start, end)) return query.filter(self.column.between(start, end))
class DateNotBetweenFilter(DateBetweenFilter): class DateNotBetweenFilter(DateBetweenFilter):
def apply(self, query, value): def apply(self, query, value):
start, end = value start, end = value
# ~between() isn't possible until sqlalchemy 1.0.0 # ~between() isn't possible until sqlalchemy 1.0.0
return query.filter(not_(self.column.between(start, end))) return query.filter(not_(self.column.between(start, end)))
def operation(self): def operation(self):
return lazy_gettext('not between') return lazy_gettext('not between')
class DateTimeEqualFilter(FilterEqual, filters.BaseDateTimeFilter): class DateTimeEqualFilter(FilterEqual, filters.BaseDateTimeFilter):
pass pass
class DateTimeNotEqualFilter(FilterNotEqual, filters.BaseDateTimeFilter): class DateTimeNotEqualFilter(FilterNotEqual, filters.BaseDateTimeFilter):
pass pass
class DateTimeGreaterFilter(FilterGreater, filters.BaseDateTimeFilter): class DateTimeGreaterFilter(FilterGreater, filters.BaseDateTimeFilter):
pass pass
class DateTimeSmallerFilter(FilterSmaller, filters.BaseDateTimeFilter): class DateTimeSmallerFilter(FilterSmaller, filters.BaseDateTimeFilter):
pass pass
class DateTimeBetweenFilter(BaseSQLAFilter, filters.BaseDateTimeBetweenFilter): class DateTimeBetweenFilter(BaseSQLAFilter, filters.BaseDateTimeBetweenFilter):
def __init__(self, column, name, options=None, data_type=None): def __init__(self, column, name, options=None, data_type=None):
super(DateTimeBetweenFilter, self).__init__(column, super(DateTimeBetweenFilter, self).__init__(column,
name, name,
options, options,
data_type='datetimerangepicker') data_type='datetimerangepicker')
def apply(self, query, value): def apply(self, query, value):
start, end = value start, end = value
return query.filter(self.column.between(start, end)) return query.filter(self.column.between(start, end))
class DateTimeNotBetweenFilter(DateTimeBetweenFilter): class DateTimeNotBetweenFilter(DateTimeBetweenFilter):
def apply(self, query, value): def apply(self, query, value):
...@@ -244,45 +244,45 @@ class DateTimeNotBetweenFilter(DateTimeBetweenFilter): ...@@ -244,45 +244,45 @@ class DateTimeNotBetweenFilter(DateTimeBetweenFilter):
def operation(self): def operation(self):
return lazy_gettext('not between') return lazy_gettext('not between')
class TimeEqualFilter(FilterEqual, filters.BaseTimeFilter): class TimeEqualFilter(FilterEqual, filters.BaseTimeFilter):
pass pass
class TimeNotEqualFilter(FilterNotEqual, filters.BaseTimeFilter): class TimeNotEqualFilter(FilterNotEqual, filters.BaseTimeFilter):
pass pass
class TimeGreaterFilter(FilterGreater, filters.BaseTimeFilter): class TimeGreaterFilter(FilterGreater, filters.BaseTimeFilter):
pass pass
class TimeSmallerFilter(FilterSmaller, filters.BaseTimeFilter): class TimeSmallerFilter(FilterSmaller, filters.BaseTimeFilter):
pass pass
class TimeBetweenFilter(BaseSQLAFilter, filters.BaseTimeBetweenFilter): class TimeBetweenFilter(BaseSQLAFilter, filters.BaseTimeBetweenFilter):
def __init__(self, column, name, options=None, data_type=None): def __init__(self, column, name, options=None, data_type=None):
super(TimeBetweenFilter, self).__init__(column, super(TimeBetweenFilter, self).__init__(column,
name, name,
options, options,
data_type='timerangepicker') data_type='timerangepicker')
def apply(self, query, value): def apply(self, query, value):
start, end = value start, end = value
return query.filter(self.column.between(start, end)) return query.filter(self.column.between(start, end))
class TimeNotBetweenFilter(TimeBetweenFilter): class TimeNotBetweenFilter(TimeBetweenFilter):
def apply(self, query, value): def apply(self, query, value):
start, end = value start, end = value
return query.filter(not_(self.column.between(start, end))) return query.filter(not_(self.column.between(start, end)))
def operation(self): def operation(self):
return lazy_gettext('not between') return lazy_gettext('not between')
# Base SQLA filter field converter # Base SQLA filter field converter
class FilterConverter(filters.BaseFilterConverter): class FilterConverter(filters.BaseFilterConverter):
strings = (FilterEqual, FilterNotEqual, FilterLike, FilterNotLike, strings = (FilterEqual, FilterNotEqual, FilterLike, FilterNotLike,
...@@ -291,7 +291,7 @@ class FilterConverter(filters.BaseFilterConverter): ...@@ -291,7 +291,7 @@ class FilterConverter(filters.BaseFilterConverter):
IntSmallerFilter, FilterEmpty, IntInListFilter, IntSmallerFilter, FilterEmpty, IntInListFilter,
IntNotInListFilter) IntNotInListFilter)
float_filters = (FloatEqualFilter, FloatNotEqualFilter, FloatGreaterFilter, float_filters = (FloatEqualFilter, FloatNotEqualFilter, FloatGreaterFilter,
FloatSmallerFilter, FilterEmpty, FloatInListFilter, FloatSmallerFilter, FilterEmpty, FloatInListFilter,
FloatNotInListFilter) FloatNotInListFilter)
bool_filters = (BooleanEqualFilter, BooleanNotEqualFilter) bool_filters = (BooleanEqualFilter, BooleanNotEqualFilter)
enum = (FilterEqual, FilterNotEqual, FilterEmpty, FilterInList, enum = (FilterEqual, FilterNotEqual, FilterEmpty, FilterInList,
...@@ -306,43 +306,46 @@ class FilterConverter(filters.BaseFilterConverter): ...@@ -306,43 +306,46 @@ class FilterConverter(filters.BaseFilterConverter):
time_filters = (TimeEqualFilter, TimeNotEqualFilter, TimeGreaterFilter, time_filters = (TimeEqualFilter, TimeNotEqualFilter, TimeGreaterFilter,
TimeSmallerFilter, TimeBetweenFilter, TimeNotBetweenFilter, TimeSmallerFilter, TimeBetweenFilter, TimeNotBetweenFilter,
FilterEmpty) FilterEmpty)
def convert(self, type_name, column, name, **kwargs): def convert(self, type_name, column, name, **kwargs):
if type_name.lower() in self.converters: filter_name = type_name.lower()
return self.converters[type_name.lower()](column, name, **kwargs)
if filter_name in self.converters:
return self.converters[filter_name](column, name, **kwargs)
return None return None
@filters.convert('string', 'char', 'unicode', 'varchar', 'tinytext', @filters.convert('string', 'char', 'unicode', 'varchar', 'tinytext',
'text', 'mediumtext', 'longtext', 'unicodetext', 'text', 'mediumtext', 'longtext', 'unicodetext',
'nchar', 'nvarchar', 'ntext') 'nchar', 'nvarchar', 'ntext')
def conv_string(self, column, name, **kwargs): def conv_string(self, column, name, **kwargs):
return [f(column, name, **kwargs) for f in self.strings] return [f(column, name, **kwargs) for f in self.strings]
@filters.convert('boolean', 'tinyint') @filters.convert('boolean', 'tinyint')
def conv_bool(self, column, name, **kwargs): def conv_bool(self, column, name, **kwargs):
return [f(column, name, **kwargs) for f in self.bool_filters] return [f(column, name, **kwargs) for f in self.bool_filters]
@filters.convert('int', 'integer', 'smallinteger', 'smallint', 'numeric', @filters.convert('int', 'integer', 'smallinteger', 'smallint', 'numeric',
'biginteger', 'bigint', 'mediumint') 'biginteger', 'bigint', 'mediumint')
def conv_int(self, column, name, **kwargs): def conv_int(self, column, name, **kwargs):
return [f(column, name, **kwargs) for f in self.int_filters] return [f(column, name, **kwargs) for f in self.int_filters]
@filters.convert('float', 'real', 'decimal', 'double_precision', 'double') @filters.convert('float', 'real', 'decimal', 'double_precision', 'double')
def conv_float(self, column, name, **kwargs): def conv_float(self, column, name, **kwargs):
return [f(column, name, **kwargs) for f in self.float_filters] return [f(column, name, **kwargs) for f in self.float_filters]
@filters.convert('date') @filters.convert('date')
def conv_date(self, column, name, **kwargs): def conv_date(self, column, name, **kwargs):
return [f(column, name, **kwargs) for f in self.date_filters] return [f(column, name, **kwargs) for f in self.date_filters]
@filters.convert('datetime', 'datetime2', 'timestamp', 'smalldatetime') @filters.convert('datetime', 'datetime2', 'timestamp', 'smalldatetime')
def conv_datetime(self, column, name, **kwargs): def conv_datetime(self, column, name, **kwargs):
return [f(column, name, **kwargs) for f in self.datetime_filters] return [f(column, name, **kwargs) for f in self.datetime_filters]
@filters.convert('time') @filters.convert('time')
def conv_time(self, column, name, **kwargs): def conv_time(self, column, name, **kwargs):
return [f(column, name, **kwargs) for f in self.time_filters] return [f(column, name, **kwargs) for f in self.time_filters]
@filters.convert('enum') @filters.convert('enum')
def conv_enum(self, column, name, options=None, **kwargs): def conv_enum(self, column, name, options=None, **kwargs):
if not options: if not options:
......
...@@ -607,9 +607,8 @@ class InlineModelConverter(InlineModelConverterBase): ...@@ -607,9 +607,8 @@ class InlineModelConverter(InlineModelConverterBase):
if label: if label:
kwargs['label'] = label kwargs['label'] = label
view_info = self.get_info(self.view) if self.view.form_args:
if view_info.form_args: field_args = self.view.form_args.get(forward_prop.key, {})
field_args = view_info.form_args.get(forward_prop.key, {})
kwargs.update(**field_args) kwargs.update(**field_args)
# Contribute field # Contribute field
......
...@@ -4,7 +4,7 @@ from sqlalchemy.exc import DBAPIError ...@@ -4,7 +4,7 @@ from sqlalchemy.exc import DBAPIError
from ast import literal_eval from ast import literal_eval
from flask.ext.admin._compat import filter_list 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): def parse_like_term(term):
......
...@@ -11,6 +11,9 @@ from flask import flash ...@@ -11,6 +11,9 @@ from flask import flash
from flask.ext.admin._compat import string_types from flask.ext.admin._compat import string_types
from flask.ext.admin.babel import gettext, ngettext, lazy_gettext from flask.ext.admin.babel import gettext, ngettext, lazy_gettext
from flask.ext.admin.model import BaseModelView 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.actions import action
from flask.ext.admin._backwards import ObsoleteAttr from flask.ext.admin._backwards import ObsoleteAttr
...@@ -19,7 +22,6 @@ from .typefmt import DEFAULT_FORMATTERS ...@@ -19,7 +22,6 @@ from .typefmt import DEFAULT_FORMATTERS
from .tools import get_query_for_ids from .tools import get_query_for_ids
from .ajax import create_ajax_loader from .ajax import create_ajax_loader
# Set up logger # Set up logger
log = logging.getLogger("flask-admin.sqla") log = logging.getLogger("flask-admin.sqla")
...@@ -78,8 +80,7 @@ class ModelView(BaseModelView): ...@@ -78,8 +80,7 @@ class ModelView(BaseModelView):
'searchable_columns', 'searchable_columns',
None) None)
""" """
Collection of the searchable columns. Only text-based columns Collection of the searchable columns.
are searchable (`String`, `Unicode`, `Text`, `UnicodeText`).
Example:: Example::
...@@ -339,6 +340,18 @@ class ModelView(BaseModelView): ...@@ -339,6 +340,18 @@ class ModelView(BaseModelView):
else: else:
attr = name 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 return join_tables, attr
def _need_join(self, table): def _need_join(self, table):
...@@ -360,7 +373,7 @@ class ModelView(BaseModelView): ...@@ -360,7 +373,7 @@ class ModelView(BaseModelView):
if isinstance(self._primary_key, tuple): if isinstance(self._primary_key, tuple):
return tools.iterencode(getattr(model, attr) for attr in self._primary_key) return tools.iterencode(getattr(model, attr) for attr in self._primary_key)
else: else:
return getattr(model, self._primary_key) return tools.escape(getattr(model, self._primary_key))
def scaffold_list_columns(self): def scaffold_list_columns(self):
""" """
...@@ -439,15 +452,18 @@ class ModelView(BaseModelView): ...@@ -439,15 +452,18 @@ class ModelView(BaseModelView):
for c in self.column_sortable_list: for c in self.column_sortable_list:
if isinstance(c, tuple): if isinstance(c, tuple):
join_tables, column = self._get_field_with_path(c[1]) join_tables, column = self._get_field_with_path(c[1])
column_name = c[0]
result[c[0]] = column elif isinstance(c, InstrumentedAttribute):
join_tables, column = self._get_field_with_path(c)
if join_tables: column_name = str(c)
self._sortable_joins[c[0]] = join_tables
else: else:
join_tables, column = self._get_field_with_path(c) 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 return result
...@@ -474,10 +490,6 @@ class ModelView(BaseModelView): ...@@ -474,10 +490,6 @@ class ModelView(BaseModelView):
for column in self._get_columns_for_field(attr): for column in self._get_columns_for_field(attr):
column_type = type(column.type).__name__ 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) self._search_fields.append(column)
# Store joins, avoid duplicates # Store joins, avoid duplicates
...@@ -488,18 +500,6 @@ class ModelView(BaseModelView): ...@@ -488,18 +500,6 @@ class ModelView(BaseModelView):
return bool(self.column_searchable_list) 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): def scaffold_filters(self, name):
""" """
Return list of enabled filters Return list of enabled filters
...@@ -535,8 +535,8 @@ class ModelView(BaseModelView): ...@@ -535,8 +535,8 @@ class ModelView(BaseModelView):
if join_tables: if join_tables:
self._filter_joins[table.name] = join_tables self._filter_joins[table.name] = join_tables
elif self._need_join(table.name): elif self._need_join(table):
self._filter_joins[table.name] = [table.name] self._filter_joins[table.name] = [table]
filters.extend(flt) filters.extend(flt)
return filters return filters
...@@ -611,6 +611,28 @@ class ModelView(BaseModelView): ...@@ -611,6 +611,28 @@ class ModelView(BaseModelView):
return form_class 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): def scaffold_inline_form_models(self, form_class):
""" """
Contribute inline models to the form Contribute inline models to the form
...@@ -664,6 +686,14 @@ class ModelView(BaseModelView): ...@@ -664,6 +686,14 @@ class ModelView(BaseModelView):
Return a query for the model type. Return a query for the model type.
If you override this method, don't forget to override `get_count_query` as well. 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) return self.session.query(self.model)
...@@ -716,7 +746,7 @@ class ModelView(BaseModelView): ...@@ -716,7 +746,7 @@ class ModelView(BaseModelView):
join_tables, attr = self._get_field_with_path(field) join_tables, attr = self._get_field_with_path(field)
return join_tables, field, direction return join_tables, attr, direction
return None return None
......
...@@ -69,24 +69,29 @@ class TimeField(fields.Field): ...@@ -69,24 +69,29 @@ class TimeField(fields.Field):
def _value(self): def _value(self):
if self.raw_data: if self.raw_data:
return u' '.join(self.raw_data) return u' '.join(self.raw_data)
elif self.data is not None:
return self.data.strftime(self.default_format)
else: else:
return self.data and self.data.strftime(self.default_format) or u'' return u''
def process_formdata(self, valuelist): def process_formdata(self, valuelist):
if valuelist: if valuelist:
date_str = u' '.join(valuelist) date_str = u' '.join(valuelist)
for format in self.formats: if date_str.strip():
try: for format in self.formats:
timetuple = time.strptime(date_str, format) try:
self.data = datetime.time(timetuple.tm_hour, timetuple = time.strptime(date_str, format)
timetuple.tm_min, self.data = datetime.time(timetuple.tm_hour,
timetuple.tm_sec) timetuple.tm_min,
return timetuple.tm_sec)
except ValueError: return
pass except ValueError:
pass
raise ValueError(gettext('Invalid time format'))
raise ValueError(gettext('Invalid time format'))
else:
self.data = None
class Select2Field(fields.SelectField): class Select2Field(fields.SelectField):
......
...@@ -179,11 +179,13 @@ class FileUploadField(fields.StringField): ...@@ -179,11 +179,13 @@ class FileUploadField(fields.StringField):
filename.rsplit('.', 1)[1].lower() in filename.rsplit('.', 1)[1].lower() in
map(lambda x: x.lower(), self.allowed_extensions)) 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): def pre_validate(self, form):
if (self.data if self._is_uploaded_file(self.data) and not self.is_file_allowed(self.data.filename):
and self.data.filename
and isinstance(self.data, FileStorage)
and not self.is_file_allowed(self.data.filename)):
raise ValidationError(gettext('Invalid file extension')) raise ValidationError(gettext('Invalid file extension'))
def process(self, formdata, data=unset_value): def process(self, formdata, data=unset_value):
...@@ -194,6 +196,15 @@ class FileUploadField(fields.StringField): ...@@ -194,6 +196,15 @@ class FileUploadField(fields.StringField):
return super(FileUploadField, self).process(formdata, data) 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): def populate_obj(self, obj, name):
field = getattr(obj, name, None) field = getattr(obj, name, None)
if field: if field:
...@@ -203,7 +214,7 @@ class FileUploadField(fields.StringField): ...@@ -203,7 +214,7 @@ class FileUploadField(fields.StringField):
setattr(obj, name, None) setattr(obj, name, None)
return return
if self.data and self.data.filename and isinstance(self.data, FileStorage): if self._is_uploaded_file(self.data):
if field: if field:
self._delete_file(field) self._delete_file(field)
...@@ -299,7 +310,7 @@ class ImageUploadField(FileUploadField): ...@@ -299,7 +310,7 @@ class ImageUploadField(FileUploadField):
upload = FileUploadField('File', namegen=prefix_name) upload = FileUploadField('File', namegen=prefix_name)
:param allowed_extensions: :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: :param max_size:
Tuple of (width, height, force) or None. If provided, Flask-Admin will Tuple of (width, height, force) or None. If provided, Flask-Admin will
resize image to the desired size. resize image to the desired size.
...@@ -357,9 +368,7 @@ class ImageUploadField(FileUploadField): ...@@ -357,9 +368,7 @@ class ImageUploadField(FileUploadField):
def pre_validate(self, form): def pre_validate(self, form):
super(ImageUploadField, self).pre_validate(form) super(ImageUploadField, self).pre_validate(form)
if (self.data and if self._is_uploaded_file(self.data):
isinstance(self.data, FileStorage) and
self.data.filename):
try: try:
self.image = Image.open(self.data) self.image = Image.open(self.data)
except Exception as e: except Exception as e:
...@@ -396,7 +405,7 @@ class ImageUploadField(FileUploadField): ...@@ -396,7 +405,7 @@ class ImageUploadField(FileUploadField):
self._save_image(image, self._get_path(filename), format) self._save_image(image, self._get_path(filename), format)
else: else:
data.seek(0) data.seek(0)
data.save( self._get_path(filename) ) data.save(self._get_path(filename))
self._save_thumbnail(data, filename, format) self._save_thumbnail(data, filename, format)
......
from re import sub from re import sub
from jinja2 import contextfunction 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 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 from ._compat import string_types
...@@ -56,7 +55,7 @@ def is_form_submitted(): ...@@ -56,7 +55,7 @@ def is_form_submitted():
""" """
Check if current method is PUT or POST 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): def validate_form_on_submit(form):
...@@ -93,7 +92,13 @@ def is_field_error(errors): ...@@ -93,7 +92,13 @@ def is_field_error(errors):
return True return True
return False 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 @contextfunction
def resolve_ctx(context): def resolve_ctx(context):
......
This diff is collapsed.
...@@ -3,8 +3,14 @@ import itertools ...@@ -3,8 +3,14 @@ import itertools
from wtforms.validators import ValidationError from wtforms.validators import ValidationError
from wtforms.fields import FieldList, FormField, SelectFieldBase 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 flask.ext.admin._compat import iteritems
from .widgets import InlineFieldListWidget, InlineFormWidget, AjaxSelect2Widget from .widgets import (InlineFieldListWidget, InlineFormWidget,
AjaxSelect2Widget, XEditableWidget)
class InlineFieldList(FieldList): class InlineFieldList(FieldList):
...@@ -120,6 +126,58 @@ class InlineModelFormField(FormField): ...@@ -120,6 +126,58 @@ class InlineModelFormField(FormField):
field.populate_obj(obj, name) 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): class AjaxSelectField(SelectFieldBase):
""" """
Ajax Model Select Field Ajax Model Select Field
......
...@@ -48,7 +48,7 @@ class BaseFilter(object): ...@@ -48,7 +48,7 @@ class BaseFilter(object):
Validate value. Validate value.
If value is valid, returns `True` and `False` otherwise. If value is valid, returns `True` and `False` otherwise.
:param value: :param value:
Value to validate Value to validate
""" """
...@@ -102,7 +102,7 @@ class BaseBooleanFilter(BaseFilter): ...@@ -102,7 +102,7 @@ class BaseBooleanFilter(BaseFilter):
def validate(self, value): def validate(self, value):
return value in ('0', '1') return value in ('0', '1')
class BaseIntFilter(BaseFilter): class BaseIntFilter(BaseFilter):
""" """
...@@ -110,7 +110,7 @@ class BaseIntFilter(BaseFilter): ...@@ -110,7 +110,7 @@ class BaseIntFilter(BaseFilter):
""" """
def clean(self, value): def clean(self, value):
return int(float(value)) return int(float(value))
class BaseFloatFilter(BaseFilter): class BaseFloatFilter(BaseFilter):
""" """
...@@ -118,7 +118,7 @@ class BaseFloatFilter(BaseFilter): ...@@ -118,7 +118,7 @@ class BaseFloatFilter(BaseFilter):
""" """
def clean(self, value): def clean(self, value):
return float(value) return float(value)
class BaseIntListFilter(BaseFilter): class BaseIntListFilter(BaseFilter):
""" """
...@@ -126,7 +126,7 @@ class BaseIntListFilter(BaseFilter): ...@@ -126,7 +126,7 @@ class BaseIntListFilter(BaseFilter):
""" """
def clean(self, value): def clean(self, value):
return [int(float(v.strip())) for v in value.split(',') if v.strip()] return [int(float(v.strip())) for v in value.split(',') if v.strip()]
class BaseFloatListFilter(BaseFilter): class BaseFloatListFilter(BaseFilter):
""" """
...@@ -134,7 +134,7 @@ class BaseFloatListFilter(BaseFilter): ...@@ -134,7 +134,7 @@ class BaseFloatListFilter(BaseFilter):
""" """
def clean(self, value): def clean(self, value):
return [float(v.strip()) for v in value.split(',') if v.strip()] return [float(v.strip()) for v in value.split(',') if v.strip()]
class BaseDateFilter(BaseFilter): class BaseDateFilter(BaseFilter):
""" """
...@@ -144,10 +144,10 @@ class BaseDateFilter(BaseFilter): ...@@ -144,10 +144,10 @@ class BaseDateFilter(BaseFilter):
super(BaseDateFilter, self).__init__(name, super(BaseDateFilter, self).__init__(name,
options, options,
data_type='datepicker') data_type='datepicker')
def clean(self, value): def clean(self, value):
return datetime.datetime.strptime(value, '%Y-%m-%d').date() return datetime.datetime.strptime(value, '%Y-%m-%d').date()
class BaseDateBetweenFilter(BaseFilter): class BaseDateBetweenFilter(BaseFilter):
""" """
...@@ -163,16 +163,16 @@ class BaseDateBetweenFilter(BaseFilter): ...@@ -163,16 +163,16 @@ class BaseDateBetweenFilter(BaseFilter):
def validate(self, value): def validate(self, value):
try: try:
value = [datetime.datetime.strptime(range, '%Y-%m-%d') value = [datetime.datetime.strptime(range, '%Y-%m-%d')
for range in value.split(' to ')] for range in value.split(' to ')]
# if " to " is missing, fail validation # if " to " is missing, fail validation
# sqlalchemy's .between() will not work if end date is before start date # sqlalchemy's .between() will not work if end date is before start date
if (len(value) == 2) and (value[0] <= value[1]): if (len(value) == 2) and (value[0] <= value[1]):
return True return True
else: else:
return False return False
except ValueError: except ValueError:
return False return False
class BaseDateTimeFilter(BaseFilter): class BaseDateTimeFilter(BaseFilter):
...@@ -183,27 +183,27 @@ class BaseDateTimeFilter(BaseFilter): ...@@ -183,27 +183,27 @@ class BaseDateTimeFilter(BaseFilter):
super(BaseDateTimeFilter, self).__init__(name, super(BaseDateTimeFilter, self).__init__(name,
options, options,
data_type='datetimepicker') data_type='datetimepicker')
def clean(self, value): def clean(self, value):
# datetime filters will not work in SQLite + SQLAlchemy if value not converted to datetime # 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') return datetime.datetime.strptime(value, '%Y-%m-%d %H:%M:%S')
class BaseDateTimeBetweenFilter(BaseFilter): class BaseDateTimeBetweenFilter(BaseFilter):
""" """
Base DateTime Between filter. Consolidates logic for validation and clean. Base DateTime Between filter. Consolidates logic for validation and clean.
Apply method is different for each back-end. Apply method is different for each back-end.
""" """
def clean(self, value): 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 ')] for range in value.split(' to ')]
def operation(self): def operation(self):
return lazy_gettext('between') return lazy_gettext('between')
def validate(self, value): def validate(self, value):
try: 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 ')] for range in value.split(' to ')]
if (len(value) == 2) and (value[0] <= value[1]): if (len(value) == 2) and (value[0] <= value[1]):
return True return True
...@@ -211,7 +211,7 @@ class BaseDateTimeBetweenFilter(BaseFilter): ...@@ -211,7 +211,7 @@ class BaseDateTimeBetweenFilter(BaseFilter):
return False return False
except ValueError: except ValueError:
return False return False
class BaseTimeFilter(BaseFilter): class BaseTimeFilter(BaseFilter):
""" """
...@@ -221,14 +221,14 @@ class BaseTimeFilter(BaseFilter): ...@@ -221,14 +221,14 @@ class BaseTimeFilter(BaseFilter):
super(BaseTimeFilter, self).__init__(name, super(BaseTimeFilter, self).__init__(name,
options, options,
data_type='timepicker') data_type='timepicker')
def clean(self, value): def clean(self, value):
# time filters will not work in SQLite + SQLAlchemy if value not converted to time # time filters will not work in SQLite + SQLAlchemy if value not converted to time
timetuple = time.strptime(value, '%H:%M:%S') timetuple = time.strptime(value, '%H:%M:%S')
return datetime.time(timetuple.tm_hour, return datetime.time(timetuple.tm_hour,
timetuple.tm_min, timetuple.tm_min,
timetuple.tm_sec) timetuple.tm_sec)
class BaseTimeBetweenFilter(BaseFilter): class BaseTimeBetweenFilter(BaseFilter):
""" """
...@@ -236,7 +236,7 @@ class BaseTimeBetweenFilter(BaseFilter): ...@@ -236,7 +236,7 @@ class BaseTimeBetweenFilter(BaseFilter):
Apply method is different for each back-end. Apply method is different for each back-end.
""" """
def clean(self, value): def clean(self, value):
timetuples = [time.strptime(range, '%H:%M:%S') timetuples = [time.strptime(range, '%H:%M:%S')
for range in value.split(' to ')] for range in value.split(' to ')]
return [datetime.time(timetuple.tm_hour, return [datetime.time(timetuple.tm_hour,
timetuple.tm_min, timetuple.tm_min,
...@@ -248,7 +248,7 @@ class BaseTimeBetweenFilter(BaseFilter): ...@@ -248,7 +248,7 @@ class BaseTimeBetweenFilter(BaseFilter):
def validate(self, value): def validate(self, value):
try: try:
timetuples = [time.strptime(range, '%H:%M:%S') timetuples = [time.strptime(range, '%H:%M:%S')
for range in value.split(' to ')] for range in value.split(' to ')]
if (len(timetuples) == 2) and (timetuples[0] <= timetuples[1]): if (len(timetuples) == 2) and (timetuples[0] <= timetuples[1]):
return True return True
...@@ -257,7 +257,7 @@ class BaseTimeBetweenFilter(BaseFilter): ...@@ -257,7 +257,7 @@ class BaseTimeBetweenFilter(BaseFilter):
except ValueError: except ValueError:
raise raise
return False return False
def convert(*args): def convert(*args):
""" """
...@@ -266,7 +266,7 @@ def convert(*args): ...@@ -266,7 +266,7 @@ def convert(*args):
See :mod:`flask.ext.admin.contrib.sqla.filters` for usage example. See :mod:`flask.ext.admin.contrib.sqla.filters` for usage example.
""" """
def _inner(func): def _inner(func):
func._converter_for = args func._converter_for = list(map(str.lower, args))
return func return func
return _inner return _inner
......
...@@ -3,6 +3,8 @@ import inspect ...@@ -3,6 +3,8 @@ import inspect
from flask.ext.admin.form import BaseForm, rules from flask.ext.admin.form import BaseForm, rules
from flask.ext.admin._compat import iteritems from flask.ext.admin._compat import iteritems
from wtforms.fields.core import UnboundField
def converts(*args): def converts(*args):
def _inner(func): def _inner(func):
...@@ -11,6 +13,35 @@ def converts(*args): ...@@ -11,6 +13,35 @@ def converts(*args):
return _inner 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): class InlineBaseFormAdmin(object):
""" """
Settings for inline form administration. Settings for inline form administration.
......
...@@ -25,7 +25,13 @@ def get_mdict_item_or_list(mdict, key): ...@@ -25,7 +25,13 @@ def get_mdict_item_or_list(mdict, key):
if hasattr(mdict, 'getlist'): if hasattr(mdict, 'getlist'):
v = mdict.getlist(key) v = mdict.getlist(key)
if len(v) == 1: 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: elif len(v) == 0:
return None return None
else: else:
......
...@@ -26,8 +26,8 @@ class AjaxSelect2Widget(object): ...@@ -26,8 +26,8 @@ class AjaxSelect2Widget(object):
self.multiple = multiple self.multiple = multiple
def __call__(self, field, **kwargs): def __call__(self, field, **kwargs):
kwargs['data-role'] = u'select2-ajax' kwargs.setdefault('data-role', 'select2-ajax')
kwargs['data-url'] = get_url('.ajax_lookup', name=field.loader.name) kwargs.setdefault('data-url', get_url('.ajax_lookup', name=field.loader.name))
allow_blank = getattr(field, 'allow_blank', False) allow_blank = getattr(field, 'allow_blank', False)
if allow_blank and not self.multiple: if allow_blank and not self.multiple:
...@@ -61,3 +61,88 @@ class AjaxSelect2Widget(object): ...@@ -61,3 +61,88 @@ class AjaxSelect2Widget(object):
kwargs.setdefault('data-placeholder', placeholder) kwargs.setdefault('data-placeholder', placeholder)
return HTMLString('<input %s>' % html_params(name=field.name, **kwargs)) 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 */ /* List View - fix trash icon inside table column */
body .model-list form.icon {
{
padding-top: 4px;
}
/* Form customizations */
form.icon {
display: inline; display: inline;
} }
form.icon button { .model-list form.icon button {
border: none; border: none;
background: transparent; background: transparent;
text-decoration: none; text-decoration: none;
...@@ -17,76 +11,62 @@ form.icon button { ...@@ -17,76 +11,62 @@ form.icon button {
line-height: normal; line-height: normal;
} }
a.icon { /* List View - link icons - prevent underline */
.model-list a.icon {
text-decoration: none; text-decoration: none;
} }
/* Model search form */ /* List View - fix checkbox column width */
form.search-form { .list-checkbox-column {
margin: 4px 0 0 0; width: 14px;
} }
form.search-form .clear i { /* List View - fix gap between actions and table */
margin: 2px 0 0 0; .model-list {
position: relative;
margin-top: -1px;
z-index: 999;
} }
form.search-form div input { .actions-nav {
margin: 0; margin-bottom: 0;
margin-left: 4px;
margin-right: 4px;
} }
/* Filters */ #filter_form {
table.filters {
border-collapse: collapse;
border-spacing: 4px;
}
.filters input
{
margin-bottom: 0; margin-bottom: 0;
} }
.filters a.remove-filter { /* List View Search Form - fix gap between form and table */
margin-bottom: 0; .actions-nav form.search-form {
display: block; margin: -1px 0 0 0;
text-align: left;
} }
.filters .remove-filter /* Filters */
{ table.filters {
vertical-align: middle; border-collapse: collapse;
border-spacing: 4px;
} }
.filters .remove-filter .close-icon /* prevents gap between table and actions while there are no filters set */
{ table.filters:not(:empty) {
font-size: 16px; margin: 12px 0px 20px 0px;
} }
.filters .remove-filter .close-icon:hover /* spacing between filter X button, operation, and value field */
{ /* uses tables instead of form classes for bootstrap2-3 compatibility */
color: black; table.filters tr td {
opacity: 0.4; padding-right: 5px;
padding-bottom: 3px;
} }
/* match filter operation drop-down height with bootstrap input */
.filters .filter-op > a { .filters .filter-op > a {
height: 28px; height: 28px;
line-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 thumbnails */
.image-thumbnail img { .image-thumbnail img {
max-width: 100px; max-width: 100px;
...@@ -94,21 +74,13 @@ table.filters { ...@@ -94,21 +74,13 @@ table.filters {
} }
/* Forms */ /* Forms */
.form-horizontal .control-label { .admin-form .control-label {
width: 100px; width: 100px;
text-align: left; text-align: left;
margin-left: 4px; margin-left: 4px;
} }
.form-horizontal .controls { /* add spacing between labels and form fields */
.admin-form .controls {
margin-left: 110px; margin-left: 110px;
} }
\ No newline at end of file
/* Patch Select2 */
.select2-results li {
min-height: 24px !important;
}
.list-checkbox-column {
width: 14px;
}
/* Global styles */ /* List View - fix trash icon inside table column */
body .model-list form.icon {
{
padding-top: 4px;
}
/* Form customizations */
form.icon {
display: inline; display: inline;
} }
form.icon button { .model-list form.icon button {
border: none; border: none;
background: transparent; background: transparent;
text-decoration: none; text-decoration: none;
...@@ -17,28 +11,28 @@ form.icon button { ...@@ -17,28 +11,28 @@ form.icon button {
line-height: normal; 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; text-decoration: none;
margin-left: 10px; margin-left: 10px;
color: #333; color: inherit;
} }
/* Model search form */ /* List View - fix checkbox column width */
form.navbar-form { .list-checkbox-column {
margin: 1px 0 0 0; width: 14px;
}
form.navbar-form a.clear span {
margin-top: 8px;
margin-left: -20px;
} }
form.navbar-form div.input-append { /* List View - fix overlapping border between actions and table */
display: inline-flex; .model-list {
position: relative;
margin-top: -1px;
z-index: 999;
} }
form.search-form div input { /* List View Search Form - fix gap between form and table */
margin: 0; .actions-nav form.navbar-form {
margin: 1px 0 0 0;
} }
/* Filters */ /* Filters */
...@@ -47,43 +41,19 @@ table.filters { ...@@ -47,43 +41,19 @@ table.filters {
border-spacing: 4px; border-spacing: 4px;
} }
/* prevents gap between table and actions while there are no filters set */
table.filters:not(:empty) { table.filters:not(:empty) {
margin: 12px 0px 20px 0px; 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 { table.filters tr td {
padding-right: 5px; padding-right: 5px;
padding-bottom: 3px; padding-bottom: 3px;
} }
table.flters tr td:nth-child(2){ /* Filters - Select2 Boxes */
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 .filter-op { .filters .filter-op {
width: 130px; width: 130px;
} }
...@@ -92,30 +62,6 @@ table.flters tr td:nth-child(2){ ...@@ -92,30 +62,6 @@ table.flters tr td:nth-child(2){
width: 220px; 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 thumbnails */
.image-thumbnail img { .image-thumbnail img {
max-width: 100px; max-width: 100px;
...@@ -123,58 +69,14 @@ table.flters tr td:nth-child(2){ ...@@ -123,58 +69,14 @@ table.flters tr td:nth-child(2){
} }
/* Forms */ /* Forms */
.form-horizontal { /* required because form-horizontal removes top padding */
.admin-form {
margin-top: 35px; margin-top: 35px;
} }
.submit-row { /* Form Field Description - Appears when field has 'description' attribute */
margin-top: 5px; /* Test with: form_args = {'name':{'description': 'test'}} */
} /* prevents awkward gap after help-block - This is default for bootstrap2 */
.admin-form .help-block {
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 {
margin-bottom: 0px; margin-bottom: 0px;
} }
\ No newline at end of file
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;
}
...@@ -101,12 +101,12 @@ var AdminFilters = function(element, filtersElement, filterGroups, activeFilters ...@@ -101,12 +101,12 @@ var AdminFilters = function(element, filtersElement, filterGroups, activeFilters
} }
function addFilter(name, subfilters, selectedIndex, filterValue) { function addFilter(name, subfilters, selectedIndex, filterValue) {
var $el = $('<tr />').appendTo($container); var $el = $('<tr class="form-horizontal" />').appendTo($container);
// Filter list // Filter list
$el.append( $el.append(
$('<td/>').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($('<span class="close-icon">&times;</span>'))
.append('&nbsp;') .append('&nbsp;')
.append(name) .append(name)
......
...@@ -241,6 +241,17 @@ ...@@ -241,6 +241,17 @@
return true; 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) { switch (name) {
case 'select2': case 'select2':
var opts = { var opts = {
...@@ -266,7 +277,7 @@ ...@@ -266,7 +277,7 @@
} else { } else {
var tags = []; var tags = [];
} }
// default to a comma for separating list items // default to a comma for separating list items
// allows using spaces as a token separator // allows using spaces as a token separator
if ($el.attr('data-token-separators')) { if ($el.attr('data-token-separators')) {
...@@ -274,7 +285,7 @@ ...@@ -274,7 +285,7 @@
} else { } else {
var tokenSeparators = [',']; var tokenSeparators = [','];
} }
var opts = { var opts = {
width: 'resolve', width: 'resolve',
tags: tags, tags: tags,
...@@ -283,12 +294,12 @@ ...@@ -283,12 +294,12 @@
return 'Enter comma separated values'; return 'Enter comma separated values';
} }
}; };
$el.select2(opts); $el.select2(opts);
// submit on ENTER // submit on ENTER
$el.parent().find('input.select2-input').on('keyup', function(e) { $el.parent().find('input.select2-input').on('keyup', function(e) {
if(e.keyCode === 13) if(e.keyCode === 13)
$(this).closest('form').submit(); $(this).closest('form').submit();
}); });
return true; return true;
...@@ -390,6 +401,32 @@ ...@@ -390,6 +401,32 @@
case 'leaflet': case 'leaflet':
processLeafletWidget($el, name); processLeafletWidget($el, name);
return true; 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 @@ ...@@ -408,19 +445,25 @@
var $parentForm = $el.parent().closest('.inline-field'); 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; id = $parentForm.attr('id') + '-' + elID;
} }
var $fieldList = $el.find('> .inline-field-list'); var $fieldList = $el.find('> .inline-field-list');
var $lastField = $fieldList.children('.inline-field').last(); var maxId = 0;
var prefix = id + '-0'; $fieldList.children('.inline-field').each(function(idx, field) {
if ($lastField.length > 0) { var $field = $(field);
var parts = $lastField.attr('id').split('-');
var parts = $field.attr('id').split('-');
idx = parseInt(parts[parts.length - 1], 10) + 1; idx = parseInt(parts[parts.length - 1], 10) + 1;
prefix = id + '-' + idx;
} if (idx > maxId) {
maxId = idx;
}
});
var prefix = id + '-' + maxId;
// Get template // Get template
var $template = $($el.find('> .inline-field-template').text()); var $template = $($el.find('> .inline-field-template').text());
...@@ -428,6 +471,9 @@ ...@@ -428,6 +471,9 @@
// Set form ID // Set form ID
$template.attr('id', prefix); $template.attr('id', prefix);
// Mark form that we just created
$template.addClass('fresh');
// Fix form IDs // Fix form IDs
$('[name]', $template).each(function(e) { $('[name]', $template).each(function(e) {
var me = $(this); var me = $(this);
...@@ -460,7 +506,7 @@ ...@@ -460,7 +506,7 @@
this.applyGlobalStyles = function(parent) { this.applyGlobalStyles = function(parent) {
var self = this; var self = this;
$(':input[data-role]', parent).each(function() { $(':input[data-role], a[data-role]', parent).each(function() {
var $el = $(this); var $el = $(this);
self.applyStyle($el, $el.attr('data-role')); self.applyStyle($el, $el.attr('data-role'));
}); });
...@@ -473,7 +519,7 @@ ...@@ -473,7 +519,7 @@
* @param {converter} function($el, name) * @param {converter} function($el, name)
*/ */
this.addFieldConverter = function(converter) { this.addFieldConverter = function(converter) {
fieldConverters.push(converter); fieldConverters.push(converter);
}; };
}; };
......
...@@ -13,6 +13,11 @@ ...@@ -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.css') }}" rel="stylesheet">
<link href="{{ admin_static.url(filename='bootstrap/bootstrap2/css/bootstrap-responsive.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"> <link href="{{ admin_static.url(filename='admin/css/bootstrap2/admin.css') }}" rel="stylesheet">
<style>
body {
padding-top: 4px;
}
</style>
{% endblock %} {% endblock %}
{% block head %} {% block head %}
{% endblock %} {% endblock %}
......
...@@ -15,6 +15,11 @@ ...@@ -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.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='bootstrap/bootstrap3/css/bootstrap-theme.min.css') }}" rel="stylesheet">
<link href="{{ admin_static.url(filename='admin/css/bootstrap3/admin.css') }}" rel="stylesheet"> <link href="{{ admin_static.url(filename='admin/css/bootstrap3/admin.css') }}" rel="stylesheet">
<style>
body {
padding-top: 4px;
}
</style>
{% endblock %} {% endblock %}
{% block head %} {% block head %}
{% endblock %} {% 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