Unverified Commit 0e32fc25 authored by ufo911's avatar ufo911 Committed by GitHub

Merge pull request #1 from flask-admin/master

pull master
parents 619699b6 bf17b7ad
...@@ -33,11 +33,13 @@ addons: ...@@ -33,11 +33,13 @@ addons:
services: services:
- postgresql - postgresql
- mongodb - mongodb
- docker
before_script: before_script:
- psql -U postgres -c 'CREATE DATABASE flask_admin_test;' - psql -U postgres -c 'CREATE DATABASE flask_admin_test;'
- psql -U postgres -c 'CREATE EXTENSION postgis;' flask_admin_test - psql -U postgres -c 'CREATE EXTENSION postgis;' flask_admin_test
- psql -U postgres -c 'CREATE EXTENSION hstore;' flask_admin_test - psql -U postgres -c 'CREATE EXTENSION hstore;' flask_admin_test
- docker run --restart always -d -e executable=blob -p 10000:10000 --tmpfs /opt/azurite/folder arafato/azurite:2.6.5
install: install:
- pip install tox - pip install tox
......
...@@ -38,14 +38,28 @@ Flask-Admin is an active project, well-tested and production ready. ...@@ -38,14 +38,28 @@ Flask-Admin is an active project, well-tested and production ready.
Examples Examples
-------- --------
Several usage examples are included in the */examples* folder. Please feel free to add your own examples, or improve Several usage examples are included in the */examples* folder. Please add your own, or improve
on some of the existing ones, and then submit them via GitHub as a *pull-request*. on the existing examples, and submit a *pull-request*.
You can see some of these examples in action at `http://examples.flask-admin.org <http://examples.flask-admin.org/>`_. To run the examples in your local environment::
To run the examples on your local environment, one at a time, do something like::
cd flask-admin 1. Clone the repository::
python examples/simple/app.py
git clone https://github.com/flask-admin/flask-admin.git
cd flask-admin
2. Create and activate a virtual environment::
virtualenv env -p python3
source env/bin/activate
3. Install requirements::
pip install -r 'examples/sqla/requirements.txt'
4. Run the application::
python examples/sqla/app.py
Documentation Documentation
------------- -------------
...@@ -91,7 +105,11 @@ You should see output similar to:: ...@@ -91,7 +105,11 @@ You should see output similar to::
For all the tests to pass successfully, you'll need Postgres & MongoDB to be running locally. For Postgres:: For all the tests to pass successfully, you'll need Postgres & MongoDB to be running locally. For Postgres::
> psql postgres
CREATE DATABASE flask_admin_test; CREATE DATABASE flask_admin_test;
\q
> psql flask_admin_test
CREATE EXTENSION postgis; CREATE EXTENSION postgis;
CREATE EXTENSION hstore; CREATE EXTENSION hstore;
...@@ -100,7 +118,8 @@ You can also run the tests on multiple environments using *tox*. ...@@ -100,7 +118,8 @@ You can also run the tests on multiple environments using *tox*.
3rd Party Stuff 3rd Party Stuff
--------------- ---------------
Flask-Admin is built with the help of `Bootstrap <http://getbootstrap.com/>`_ and `Select2 <https://github.com/ivaynberg/select2>`_. Flask-Admin is built with the help of `Bootstrap <http://getbootstrap.com/>`_, `Select2 <https://github.com/ivaynberg/select2>`_
and `Bootswatch <http://bootswatch.com/>`_.
If you want to localize your application, install the `Flask-BabelEx <https://pypi.python.org/pypi/Flask-BabelEx>`_ package. If you want to localize your application, install the `Flask-BabelEx <https://pypi.python.org/pypi/Flask-BabelEx>`_ package.
......
...@@ -2,7 +2,6 @@ ...@@ -2,7 +2,6 @@
<ul> <ul>
<li><a href="http://flask.pocoo.org/" target="_blank">Flask</a></li> <li><a href="http://flask.pocoo.org/" target="_blank">Flask</a></li>
<li><a href="http://github.com/flask-admin/flask-admin" target="_blank">Flask-Admin @ github</a></li> <li><a href="http://github.com/flask-admin/flask-admin" target="_blank">Flask-Admin @ github</a></li>
<li><a href="http://examples.flask-admin.org/" target="_blank">Flask-Admin Examples</a></li>
</ul> </ul>
<a class="github" href="http://github.com/flask-admin/flask-admin" target="_blank"><img style="position: fixed; top: 0; right: 0; border: 0;" <a class="github" href="http://github.com/flask-admin/flask-admin" target="_blank"><img style="position: fixed; top: 0; right: 0; border: 0;"
......
...@@ -34,7 +34,7 @@ Enabling localization is simple: ...@@ -34,7 +34,7 @@ Enabling localization is simple:
#. Initialize Flask-BabelEx by creating instance of `Babel` class:: #. Initialize Flask-BabelEx by creating instance of `Babel` class::
from flask import app from flask import Flask
from flask_babelex import Babel from flask_babelex import Babel
app = Flask(__name__) app = Flask(__name__)
...@@ -164,7 +164,7 @@ Image handling also requires you to have `Pillow <https://pypi.python.org/pypi/P ...@@ -164,7 +164,7 @@ Image handling also requires you to have `Pillow <https://pypi.python.org/pypi/P
installed if you need to do any processing on the image files. installed if you need to do any processing on the image files.
Have a look at the example at Have a look at the example at
https://github.com/flask-admin/Flask-Admin/tree/master/examples/forms. https://github.com/flask-admin/Flask-Admin/tree/master/examples/forms-files-images.
If you are using the MongoEngine backend, Flask-Admin supports GridFS-backed image and file uploads through WTForms fields. Documentation can be found at :mod:`flask_admin.contrib.mongoengine.fields`. If you are using the MongoEngine backend, Flask-Admin supports GridFS-backed image and file uploads through WTForms fields. Documentation can be found at :mod:`flask_admin.contrib.mongoengine.fields`.
...@@ -544,4 +544,3 @@ While the wrapped function should accept only one parameter - `ids`:: ...@@ -544,4 +544,3 @@ While the wrapped function should accept only one parameter - `ids`::
raise raise
flash(gettext('Failed to approve users. %(error)s', error=str(ex)), 'error') flash(gettext('Failed to approve users. %(error)s', error=str(ex)), 'error')
...@@ -15,6 +15,7 @@ API ...@@ -15,6 +15,7 @@ API
mod_actions mod_actions
mod_contrib_sqla mod_contrib_sqla
mod_contrib_sqla_fields
mod_contrib_mongoengine mod_contrib_mongoengine
mod_contrib_mongoengine_fields mod_contrib_mongoengine_fields
mod_contrib_peewee mod_contrib_peewee
......
``flask_admin.contrib.sqla.fields``
===================================
.. automodule:: flask_admin.contrib.sqla.fields
.. autoclass:: QuerySelectField
:members:
.. autoclass:: QuerySelectMultipleField
:members:
.. autoclass:: CheckboxListField
:members:
Changelog Changelog
========= =========
1.5.3
-----
* Fixed XSS vulnerability
* Support nested categories in the navbar menu
* SQLAlchemy
* sort on multiple columns with `column_default_sort`
* sort on related models in `column_sortable_list`
* fix: inline model forms can now also be used for models with multiple primary keys
* support for using mapped `column_property`
* Upgrade Leaflet and Leaflet.draw plugins, used for geoalchemy integration
* Specify `minimum_input_length` for ajax widget
* Peewee: support composite keys
* MongoEngine: when searching/filtering the input is now regarded as case-insensitive by default
* FileAdmin
* handle special characters in filename
* fix a bug with listing directories on Windows
* avoid raising an exception when unknown sort parameter is encountered
* WTForms 3 support
1.5.2
-----
* Fixed XSS vulnerability
* Fixed Peewee support
* Added detail view column formatters
* Updated Flask-Login example to work with the newer version of the library
* Various SQLAlchemy-related fixes
* Various Windows related fixes for the file admin
1.5.1
-----
* Dropped Python 2.6 support
* Fixed SQLAlchemy >= 1.2 compatibility
* Fixed Pewee 3.0 compatibility
* Fixed max year for a combo date inline editor
* Lots of small bug fixes
1.5.0 1.5.0
----- -----
...@@ -13,7 +52,7 @@ Changelog ...@@ -13,7 +52,7 @@ Changelog
- Added support for association proxies - Added support for association proxies
- Added support for remote hybrid properties filters - Added support for remote hybrid properties filters
- Added support for ARRAY column type - Added support for ARRAY column type
* Localization-related fixes * Localization-related fixes
* MongoEngine backend is now properly formats model labels * MongoEngine backend is now properly formats model labels
* Improved Google App Engine support: * Improved Google App Engine support:
- Added TextProperty, KeyProperty and SelectField support - Added TextProperty, KeyProperty and SelectField support
......
...@@ -23,9 +23,9 @@ because they let you group together all of the usual ...@@ -23,9 +23,9 @@ because they let you group together all of the usual
*Create, Read, Update, Delete* (CRUD) view logic into a single, self-contained *Create, Read, Update, Delete* (CRUD) view logic into a single, self-contained
class for each of your models. class for each of your models.
**What does it look like?** At http://examples.flask-admin.org/ you can see **What does it look like?** Clone the `GitHub repository <https://github.com/flask-admin/flask-admin>`_
some examples of Flask-Admin in action, or browse through the `examples/` and run the provided examples locally to get a feel for Flask-Admin. There are several to choose from
directory in the `GitHub repository <https://github.com/flask-admin/flask-admin>`_. in the `examples` directory.
.. toctree:: .. toctree::
:maxdepth: 2 :maxdepth: 2
......
...@@ -18,6 +18,9 @@ The first step is to initialize an empty admin interface for your Flask app:: ...@@ -18,6 +18,9 @@ The first step is to initialize an empty admin interface for your Flask app::
app = Flask(__name__) app = Flask(__name__)
# set optional bootswatch theme
app.config['FLASK_ADMIN_SWATCH'] = 'cerulean'
admin = Admin(app, name='microblog', template_mode='bootstrap3') admin = Admin(app, name='microblog', template_mode='bootstrap3')
# Add administrative views here # Add administrative views here
...@@ -27,7 +30,8 @@ Here, both the *name* and *template_mode* parameters are optional. Alternatively ...@@ -27,7 +30,8 @@ Here, both the *name* and *template_mode* parameters are optional. Alternatively
you could use the :meth:`~flask_admin.base.Admin.init_app` method. you could use the :meth:`~flask_admin.base.Admin.init_app` method.
If you start this application and navigate to `http://localhost:5000/admin/ <http://localhost:5000/admin/>`_, If you start this application and navigate to `http://localhost:5000/admin/ <http://localhost:5000/admin/>`_,
you should see an empty page with a navigation bar on top. you should see an empty page with a navigation bar on top. Customize the look by
specifying a Bootswatch theme that suits your needs (see http://bootswatch.com/3/ for available swatches).
Adding Model Views Adding Model Views
------------------ ------------------
...@@ -156,12 +160,9 @@ Customizing Built-in Views ...@@ -156,12 +160,9 @@ Customizing Built-in Views
**** ****
The built-in `ModelView` class is great for getting started quickly. But, you'll want When inheriting from `ModelView`, values can be specified for numerous
to configure its functionality to suit your particular models. This is done by setting configuration parameters. Use these to customize the views to suit your
values for the configuration attributes that are made available in the `ModelView` class. particular models::
To specify some global configuration parameters, you can subclass `ModelView` and use that
subclass when adding your models to the interface::
from flask_admin.contrib.sqla import ModelView from flask_admin.contrib.sqla import ModelView
...@@ -287,6 +288,28 @@ To **enable csv export** of the model view:: ...@@ -287,6 +288,28 @@ To **enable csv export** of the model view::
This will add a button to the model view that exports records, truncating at :attr:`~flask_admin.model.BaseModelView.export_max_rows`. This will add a button to the model view that exports records, truncating at :attr:`~flask_admin.model.BaseModelView.export_max_rows`.
Grouping Views
==============
When adding a view, specify a value for the `category` parameter
to group related views together in the menu::
admin.add_view(UserView(User, db.session, category="Team"))
admin.add_view(ModelView(Role, db.session, category="Team"))
admin.add_view(ModelView(Permission, db.session, category="Team"))
This will create a top-level menu item named 'Team', and a drop-down containing
links to the three views.
To nest related views within these drop-downs, use the `add_sub_category` method::
admin.add_sub_category(name="Links", parent_name="Team")
And to add arbitrary hyperlinks to the menu::
admin.add_link(MenuLink(name='Home Page', url='/', category='Links'))
Adding Your Own Views Adding Your Own Views
===================== =====================
...@@ -440,7 +463,7 @@ list_row_actions Row action cell with edit/remove/etc buttons ...@@ -440,7 +463,7 @@ list_row_actions Row action cell with edit/remove/etc buttons
empty_list_message Message that will be displayed if there are no models found empty_list_message Message that will be displayed if there are no models found
======================= ============================================ ======================= ============================================
Have a look at the `layout` example at https://github.com/flask-admin/flask-admin/tree/master/examples/layout Have a look at the `layout` example at https://github.com/flask-admin/flask-admin/tree/master/examples/custom-layout
to see how you can take full stylistic control over the admin interface. to see how you can take full stylistic control over the admin interface.
Environment Variables Environment Variables
......
...@@ -14,11 +14,11 @@ To run this example: ...@@ -14,11 +14,11 @@ To run this example:
3. Install requirements:: 3. Install requirements::
pip install -r 'examples/layout/requirements.txt' pip install -r 'examples/custom-layout/requirements.txt'
4. Run the application:: 4. Run the application::
python examples/layout/app.py python examples/custom-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:::
......
...@@ -7,7 +7,7 @@ ...@@ -7,7 +7,7 @@
{% endblock %} {% endblock %}
{% block page_body %} {% block page_body %}
<div class="container"> <div class="container-fluid">
<div class="row"> <div class="row">
<div class="col-md-2" role="navigation"> <div class="col-md-2" role="navigation">
<ul class="nav nav-pills nav-stacked"> <ul class="nav nav-pills nav-stacked">
......
Simple file management interface example.
To run this example:
1. Clone the repository::
git clone https://github.com/flask-admin/flask-admin.git
cd flask-admin
2. Create and activate a virtual environment::
virtualenv env
source env/bin/activate
3. Install requirements::
pip install -r 'examples/file/requirements.txt'
4. Run the application::
python examples/file/app.py
import os
import os.path as op
from flask import Flask
import flask_admin as admin
from flask_admin.contrib import fileadmin
# Create flask app
app = Flask(__name__, template_folder='templates', static_folder='files')
# Create dummy secrey key so we can use flash
app.config['SECRET_KEY'] = '123456790'
# Flask views
@app.route('/')
def index():
return '<a href="/admin/">Click me to get to Admin!</a>'
if __name__ == '__main__':
# Create directory
path = op.join(op.dirname(__file__), 'files')
try:
os.mkdir(path)
except OSError:
pass
# Create admin interface
admin = admin.Admin(app, 'Example: Files')
admin.add_view(fileadmin.FileAdmin(path, '/files/', name='Files'))
# Start app
app.run(debug=True)
Example of custom filters for the SQLAlchemy backend. This example shows how you can::
* define your own custom forms by using form rendering rules
* handle generic static file uploads
* handle image uploads
* turn a TextArea field into a rich WYSIWYG editor using WTForms and CKEditor
* set up a Flask-Admin view as a Redis terminal
To run this example: To run this example:
...@@ -14,11 +21,11 @@ To run this example: ...@@ -14,11 +21,11 @@ To run this example:
3. Install requirements:: 3. Install requirements::
pip install -r 'examples/sqla-custom-filter/requirements.txt' pip install -r 'examples/forms-files-images/requirements.txt'
4. Run the application:: 4. Run the application::
python examples/sqla-custom-filter/app.py python examples/forms-files-images/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:::
......
...@@ -3,18 +3,24 @@ import os.path as op ...@@ -3,18 +3,24 @@ import os.path as op
from flask import Flask, url_for from flask import Flask, url_for
from flask_sqlalchemy import SQLAlchemy from flask_sqlalchemy import SQLAlchemy
from redis import Redis
from wtforms import fields, widgets
from sqlalchemy.event import listens_for from sqlalchemy.event import listens_for
from jinja2 import Markup from jinja2 import Markup
from flask_admin import Admin, form from flask_admin import Admin, form
from flask_admin.form import rules from flask_admin.form import rules
from flask_admin.contrib import sqla from flask_admin.contrib import sqla, rediscli
# Create application # Create application
app = Flask(__name__, static_folder='files') app = Flask(__name__, static_folder='files')
# set optional bootswatch theme
# see http://bootswatch.com/3/ for available swatches
app.config['FLASK_ADMIN_SWATCH'] = 'cerulean'
# Create dummy secrey key so we can use sessions # Create dummy secrey key so we can use sessions
app.config['SECRET_KEY'] = '123456790' app.config['SECRET_KEY'] = '123456790'
...@@ -62,6 +68,15 @@ class User(db.Model): ...@@ -62,6 +68,15 @@ class User(db.Model):
notes = db.Column(db.UnicodeText) notes = db.Column(db.UnicodeText)
class Page(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.Unicode(64))
text = db.Column(db.UnicodeText)
def __unicode__(self):
return self.name
# Delete hooks for models, delete files if models are getting deleted # Delete hooks for models, delete files if models are getting deleted
@listens_for(File, 'after_delete') @listens_for(File, 'after_delete')
def del_file(mapper, connection, target): def del_file(mapper, connection, target):
...@@ -90,7 +105,28 @@ def del_image(mapper, connection, target): ...@@ -90,7 +105,28 @@ def del_image(mapper, connection, target):
pass pass
# define a custom wtforms widget and field.
# see https://wtforms.readthedocs.io/en/latest/widgets.html#custom-widgets
class CKTextAreaWidget(widgets.TextArea):
def __call__(self, field, **kwargs):
# add WYSIWYG class to existing classes
existing_classes = kwargs.pop('class', '') or kwargs.pop('class_', '')
kwargs['class'] = '{} {}'.format(existing_classes, "ckeditor")
return super(CKTextAreaWidget, self).__call__(field, **kwargs)
class CKTextAreaField(fields.TextAreaField):
widget = CKTextAreaWidget()
# Administrative views # Administrative views
class PageView(sqla.ModelView):
form_overrides = {
'text': CKTextAreaField
}
create_template = 'create_page.html'
edit_template = 'edit_page.html'
class FileView(sqla.ModelView): class FileView(sqla.ModelView):
# Override form field to use Flask-Admin FileUploadField # Override form field to use Flask-Admin FileUploadField
form_overrides = { form_overrides = {
...@@ -140,15 +176,15 @@ class UserView(sqla.ModelView): ...@@ -140,15 +176,15 @@ class UserView(sqla.ModelView):
rules.Field('city'), rules.Field('city'),
# String is resolved to form field, so there's no need to explicitly use `rules.Field` # String is resolved to form field, so there's no need to explicitly use `rules.Field`
'country', 'country',
# Show macro from Flask-Admin lib.html (it is included with 'lib' prefix) # Show macro that's included in the templates
rules.Container('rule_demo.wrap', rules.Field('notes')) rules.Container('rule_demo.wrap', rules.Field('notes'))
] ]
# Use same rule set for edit page # Use same rule set for edit page
form_edit_rules = form_create_rules form_edit_rules = form_create_rules
create_template = 'rule_create.html' create_template = 'create_user.html'
edit_template = 'rule_edit.html' edit_template = 'edit_user.html'
# Flask views # Flask views
...@@ -162,7 +198,9 @@ admin = Admin(app, 'Example: Forms', template_mode='bootstrap3') ...@@ -162,7 +198,9 @@ admin = Admin(app, 'Example: Forms', template_mode='bootstrap3')
# Add views # Add views
admin.add_view(FileView(File, db.session)) admin.add_view(FileView(File, db.session))
admin.add_view(ImageView(Image, db.session)) admin.add_view(ImageView(Image, db.session))
admin.add_view(UserView(User, db.session, name='User')) admin.add_view(UserView(User, db.session))
admin.add_view(PageView(Page, db.session))
admin.add_view(rediscli.RedisCli(Redis()))
def build_sample_db(): def build_sample_db():
...@@ -238,6 +276,10 @@ def build_sample_db(): ...@@ -238,6 +276,10 @@ def build_sample_db():
file.path = "example_" + str(i) + ".pdf" file.path = "example_" + str(i) + ".pdf"
db.session.add(file) db.session.add(file)
sample_text = "<h2>This is a test</h2>" + \
"<p>Create HTML content in a text area field with the help of <i>WTForms</i> and <i>CKEditor</i>.</p>"
db.session.add(Page(name="Test Page", text=sample_text))
db.session.commit() db.session.commit()
return return
......
Flask Flask
Flask-Admin Flask-Admin
Flask-SQLAlchemy Flask-SQLAlchemy
pillow
redis
{% extends 'admin/model/create.html' %}
{% block tail %}
{{ super() }}
<script src="https://cdn.ckeditor.com/ckeditor5/11.1.1/classic/ckeditor.js"></script>
<script>
ClassicEditor
.create(document.querySelector('.ckeditor'))
.catch(error => {
console.error( error );
});
</script>
{% endblock %}
{% extends 'admin/model/create.html' %} {% extends 'admin/model/create.html' %}
{% import 'rule_demo.html' as rule_demo %} {% import 'macros.html' as rule_demo %}
\ No newline at end of file
{% extends 'admin/model/create.html' %}
{% block tail %}
{{ super() }}
<script src="https://cdn.ckeditor.com/ckeditor5/11.1.1/classic/ckeditor.js"></script>
<script>
ClassicEditor
.create(document.querySelector('.ckeditor'))
.catch(error => {
console.error( error );
});
</script>
{% endblock %}
{% extends 'admin/model/edit.html' %} {% extends 'admin/model/edit.html' %}
{% import 'rule_demo.html' as rule_demo %} {% import 'macros.html' as rule_demo %}
This example shows how you can define your own custom forms by using form rendering rules. It also demonstrates general file handling as well as the handling of image files specifically.
To run this example:
1. Clone the repository::
git clone https://github.com/flask-admin/flask-admin.git
cd flask-admin
2. Create and activate a virtual environment::
virtualenv env
source env/bin/activate
3. Install requirements::
pip install -r 'examples/forms/requirements.txt'
4. Run the application::
python examples/forms/app.py
The first time you run this example, a sample sqlite database gets populated automatically. To suppress this behaviour,
comment the following lines in app.py:::
if not os.path.exists(database_path):
build_sample_db()
Flask
Flask-Admin
Flask-SQLAlchemy
pillow==2.9.0
...@@ -32,7 +32,7 @@ To run this example: ...@@ -32,7 +32,7 @@ To run this example:
5. Run the application:: 5. Run the application::
python examples/sqla/app.py python examples/geo_alchemy/app.py
6. You will notice that the maps are not rendered. To see them, you will have 6. You will notice that the maps are not rendered. To see them, you will have
to register for a free account at `Mapbox <https://www.mapbox.com/>`_ and set to register for a free account at `Mapbox <https://www.mapbox.com/>`_ and set
......
...@@ -7,4 +7,8 @@ SQLALCHEMY_ECHO = True ...@@ -7,4 +7,8 @@ SQLALCHEMY_ECHO = True
# credentials for loading map tiles from mapbox # credentials for loading map tiles from mapbox
MAPBOX_MAP_ID = '...' MAPBOX_MAP_ID = '...'
MAPBOX_ACCESS_TOKEN = '...' MAPBOX_ACCESS_TOKEN = '...'
\ No newline at end of file
# when the creating new shapes, use this default map center
DEFAULT_CENTER_LAT = -33.918861
DEFAULT_CENTER_LONG = 18.423300
import os
import os.path as op
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
import flask_admin as admin
from flask_admin.contrib.sqla import ModelView
# Create application
app = Flask(__name__)
# Create dummy secrey key so we can use sessions
app.config['SECRET_KEY'] = '123456790'
# Create in-memory database
app.config['DATABASE_FILE'] = 'sample_db.sqlite'
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///' + app.config['DATABASE_FILE']
app.config['SQLALCHEMY_ECHO'] = True
db = SQLAlchemy(app)
# Models
class User(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.Unicode(64))
email = db.Column(db.Unicode(64))
def __unicode__(self):
return self.name
class Page(db.Model):
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.Unicode(64))
content = db.Column(db.UnicodeText)
def __unicode__(self):
return self.name
# Customized admin interface
class CustomView(ModelView):
list_template = 'list.html'
create_template = 'create.html'
edit_template = 'edit.html'
class UserAdmin(CustomView):
column_searchable_list = ('name',)
column_filters = ('name', 'email')
# Flask views
@app.route('/')
def index():
return '<a href="/admin/">Click me to get to Admin!</a>'
# Create admin with custom base template
admin = admin.Admin(app, 'Example: Layout', base_template='layout.html')
# Add views
admin.add_view(UserAdmin(User, db.session))
admin.add_view(CustomView(Page, db.session))
def build_sample_db():
"""
Populate a small db with some example entries.
"""
db.drop_all()
db.create_all()
first_names = [
'Harry', 'Amelia', 'Oliver', 'Jack', 'Isabella', 'Charlie','Sophie', 'Mia',
'Jacob', 'Thomas', 'Emily', 'Lily', 'Ava', 'Isla', 'Alfie', 'Olivia', 'Jessica',
'Riley', 'William', 'James', 'Geoffrey', 'Lisa', 'Benjamin', 'Stacey', 'Lucy'
]
last_names = [
'Brown', 'Smith', 'Patel', 'Jones', 'Williams', 'Johnson', 'Taylor', 'Thomas',
'Roberts', 'Khan', 'Lewis', 'Jackson', 'Clarke', 'James', 'Phillips', 'Wilson',
'Ali', 'Mason', 'Mitchell', 'Rose', 'Davis', 'Davies', 'Rodriguez', 'Cox', 'Alexander'
]
for i in range(len(first_names)):
user = User()
user.name = first_names[i] + " " + last_names[i]
user.email = first_names[i].lower() + "@example.com"
db.session.add(user)
sample_text = [
{
'title': "de Finibus Bonorum et Malorum - Part I",
'content': "Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor \
incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud \
exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure \
dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. \
Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt \
mollit anim id est laborum."
},
{
'title': "de Finibus Bonorum et Malorum - Part II",
'content': "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque \
laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto \
beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur \
aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi \
nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, \
adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam \
aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam \
corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum \
iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum \
qui dolorem eum fugiat quo voluptas nulla pariatur?"
},
{
'title': "de Finibus Bonorum et Malorum - Part III",
'content': "At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis praesentium \
voluptatum deleniti atque corrupti quos dolores et quas molestias excepturi sint occaecati \
cupiditate non provident, similique sunt in culpa qui officia deserunt mollitia animi, id \
est laborum et dolorum fuga. Et harum quidem rerum facilis est et expedita distinctio. Nam \
libero tempore, cum soluta nobis est eligendi optio cumque nihil impedit quo minus id quod \
maxime placeat facere possimus, omnis voluptas assumenda est, omnis dolor repellendus. \
Temporibus autem quibusdam et aut officiis debitis aut rerum necessitatibus saepe eveniet \
ut et voluptates repudiandae sint et molestiae non recusandae. Itaque earum rerum hic tenetur \
a sapiente delectus, ut aut reiciendis voluptatibus maiores alias consequatur aut perferendis \
doloribus asperiores repellat."
}
]
for entry in sample_text:
page = Page()
page.title = entry['title']
page.content = entry['content']
db.session.add(page)
db.session.commit()
return
if __name__ == '__main__':
# Build a sample db on the fly, if one does not exist yet.
app_dir = op.realpath(os.path.dirname(__file__))
database_path = op.join(app_dir, app.config['DATABASE_FILE'])
if not os.path.exists(database_path):
build_sample_db()
# Start app
app.run(debug=True)
body {
background: #EEE;
}
#content {
background: white;
border: 1px solid #CCC;
padding: 12px;
overflow: scroll;
}
#brand {
float: left;
font-weight: 300;
margin: 0;
}
.search-form {
margin: 0 5px;
}
.search-form form {
margin: 0;
}
.btn-menu {
margin: 4px 5px 0 0;
float: right;
}
.btn-menu a, .btn-menu input {
padding: 7px 16px !important;
border-radius: 0 !important;
}
.btn, textarea, input[type], button, .model-list {
border-radius: 0;
}
.model-list {
border-radius: 0;
}
.nav-pills li > a {
border-radius: 0;
}
.select2-container .select2-choice {
border-radius: 0;
}
\ No newline at end of file
{% extends 'admin/master.html' %}
{% block body %}
{{ super() }}
<div class="row-fluid">
<h1>Flask-Admin example</h1>
<p class="lead">
Customize the layout
</p>
<p>
This example shows how you can customize the look & feel of the admin interface.
</p>
<p>
This is done by overriding some of the built-in templates.
</p>
<a class="btn btn-primary" href="/"><i class="icon-arrow-left icon-white"></i> Back</a>
</div>
{% endblock body %}
\ No newline at end of file
{% import 'admin/layout.html' as layout with context -%}
{% extends 'admin/base.html' %}
{% block head_tail %}
{{ super() }}
<link href="{{ url_for('static', filename='layout.css') }}" rel="stylesheet">
{% endblock %}
{% block page_body %}
<div class="container">
<div class="row">
<div class="span2">
<ul class="nav nav-pills nav-stacked">
{{ layout.menu() }}
{{ layout.menu_links() }}
</ul>
</div>
<div class="span10">
<div id="content">
{% block brand %}
<h2 id="brand">{{ admin_view.name|capitalize }}</h2>
<div class="clearfix"></div>
{% endblock %}
{{ layout.messages() }}
{% block body %}{% endblock %}
</div>
</div>
</div>
</div>
{% endblock %}
{% extends 'admin/model/list.html' %}
{% import 'admin/model/layout.html' as model_layout with context %}
{% block brand %}
<h2 id="brand">{{ admin_view.name|capitalize }} list</h2>
{% if admin_view.can_create %}
<div class="btn-menu">
<a href="{{ url_for('.create_view', url=return_url) }}" class="btn btn-primary pull-right">{{ _gettext('Create') }}</a>
</div>
{% endif %}
{% if filter_groups %}
<div class="btn-group btn-menu">
{{ model_layout.filter_options(btn_class='btn dropdown-toggle btn-title') }}
</div>
{% endif %}
{% if actions %}
<div class="btn-group btn-menu">
{{ actionlib.dropdown(actions, btn_class='btn dropdown-toggle btn-title') }}
</div>
{% endif %}
{% if search_supported %}
<div class="search-form btn-menu">
{{ model_layout.search_form(input_class='span2 btn-title') }}
</div>
{% endif %}
<div class="clearfix"></div>
<hr>
{% endblock %}
{% block model_menu_bar %}
{% endblock %}
\ No newline at end of file
This example shows how you can customize the look & feel of the admin interface. This is done by overriding some of the built-in templates.
To run this example:
1. Clone the repository::
git clone https://github.com/flask-admin/flask-admin.git
cd flask-admin
2. Create and activate a virtual environment::
virtualenv env
source env/bin/activate
3. Install requirements::
pip install -r 'examples/layout_bootstrap3/requirements.txt'
4. Run the application::
python examples/layout_bootstrap3/app.py
The first time you run this example, a sample sqlite database gets populated automatically. To suppress this behaviour,
comment the following lines in app.py:::
if not os.path.exists(database_path):
build_sample_db()
{% extends 'admin/model/create.html' %}
{% block brand %}
<h2 id="brand">Create {{ admin_view.name|capitalize }}</h2>
<div class="clearfix"></div>
<hr>
{% endblock %}
{% block body %}
{% call lib.form_tag(form) %}
{{ lib.render_form_fields(form, form_opts=form_opts) }}
<div class="form-buttons">
{{ lib.render_form_buttons(return_url, extra()) }}
</div>
{% endcall %}
{% endblock %}
{% extends 'admin/model/edit.html' %}
{% block brand %}
<h2 id="brand">Edit {{ admin_view.name|capitalize }}</h2>
<div class="clearfix"></div>
<hr>
{% endblock %}
{% block body %}
{% call lib.form_tag(form) %}
{{ lib.render_form_fields(form, form_opts=form_opts) }}
<div class="form-buttons">
{{ lib.render_form_buttons(return_url) }}
</div>
{% endcall %}
{% endblock %}
This example shows how you can add links to external (non flask-admin) pages to the navbar menu, and how you can hide certain links if a user is not logged-in.
To run this example:
1. Clone the repository::
git clone https://github.com/flask-admin/flask-admin.git
cd flask-admin
2. Create and activate a virtual environment::
virtualenv env
source env/bin/activate
3. Install requirements::
pip install -r 'examples/menu-external-links/requirements.txt'
4. Run the application::
python examples/menu-external-links/app.py
from flask import Flask, redirect, url_for
from flask.ext import login
from flask_login import current_user, UserMixin
from flask_admin.base import MenuLink, Admin, BaseView, expose
# Create fake user class for authentication
class User(UserMixin):
users_id = 0
def __init__(self, id=None):
if not id:
self.users_id += 1
self.id = self.users_id
else:
self.id = id
# Create menu links classes with reloaded accessible
class AuthenticatedMenuLink(MenuLink):
def is_accessible(self):
return current_user.is_authenticated
class NotAuthenticatedMenuLink(MenuLink):
def is_accessible(self):
return not current_user.is_authenticated
# Create custom admin view for authenticated users
class MyAdminView(BaseView):
@expose('/')
def index(self):
return self.render('authenticated-admin.html')
def is_accessible(self):
return current_user.is_authenticated
# Create flask app
app = Flask(__name__, template_folder='templates')
# Create dummy secrey key so we can use sessions
app.config['SECRET_KEY'] = '123456790'
# Flask views
@app.route('/')
def index():
return '<a href="/admin/">Click me to get to Admin!</a>'
@app.route('/login/')
def login_view():
login.login_user(User())
return redirect(url_for('admin.index'))
@app.route('/logout/')
def logout_view():
login.logout_user()
return redirect(url_for('admin.index'))
login_manager = login.LoginManager()
login_manager.init_app(app)
# Create user loader function
@login_manager.user_loader
def load_user(user_id):
return User(user_id)
if __name__ == '__main__':
# Create admin interface
admin = Admin(name='Example: Menu')
admin.add_view(MyAdminView(name='Authenticated'))
# Add home link by url
admin.add_link(MenuLink(name='Back Home', url='/'))
# Add login link by endpoint
admin.add_link(NotAuthenticatedMenuLink(name='Login',
endpoint='login_view'))
# Add links with categories
admin.add_link(MenuLink(name='Google', category='Links', url='http://www.google.com/'))
admin.add_link(MenuLink(name='Mozilla', category='Links', url='http://mozilla.org/'))
# Add logout link by endpoint
admin.add_link(AuthenticatedMenuLink(name='Logout',
endpoint='logout_view'))
admin.init_app(app)
# Start app
app.run(debug=True)
{% extends 'admin/master.html' %}
{% block body %}
Hello World from Authenticated Admin!
{% endblock %}
...@@ -14,9 +14,8 @@ To run this example: ...@@ -14,9 +14,8 @@ To run this example:
3. Install requirements:: 3. Install requirements::
pip install -r 'examples/multi/requirements.txt' pip install -r 'examples/multiple-admin-instances/requirements.txt'
4. Run the application:: 4. Run the application::
python examples/multi/app.py python examples/multiple-admin-instances/app.py
Simple Flask-Admin examples used by the quickstart tutorial.
To run this example:
1. Clone the repository::
git clone https://github.com/flask-admin/flask-admin.git
cd flask-admin
2. Create and activate a virtual environment::
virtualenv env
source env/bin/activate
3. Install requirements::
pip install -r 'examples/quickstart/requirements.txt'
4. Run the application with any of the following::
python examples/quickstart/app.py
python examples/quickstart/app2.py
python examples/quickstart/app3.py
from flask import Flask
from flask_admin import Admin
app = Flask(__name__)
app.debug = True
admin = Admin(app, name="Example: Quickstart")
if __name__ == '__main__':
# Start app
app.run(debug=True)
from flask import Flask
from flask_admin import Admin, BaseView, expose
class MyView(BaseView):
@expose('/')
def index(self):
return self.render('index.html')
app = Flask(__name__)
app.debug = True
admin = Admin(app, name="Example: Quickstart2")
admin.add_view(MyView(name='Hello'))
if __name__ == '__main__':
# Start app
app.run()
from flask import Flask
from flask_admin import Admin, BaseView, expose
class MyView(BaseView):
@expose('/')
def index(self):
return self.render('index.html')
app = Flask(__name__)
app.debug = True
admin = Admin(app, name="Example: Quickstart3")
admin.add_view(MyView(name='Hello 1', endpoint='test1', category='Test'))
admin.add_view(MyView(name='Hello 2', endpoint='test2', category='Test'))
admin.add_view(MyView(name='Hello 3', endpoint='test3', category='Test'))
if __name__ == '__main__':
# Start app
app.run()
{% extends 'admin/master.html' %}
{% block body %}
Hello World from MyView!
{% endblock %}
This example shows how to set up a Flask-Admin view as a Redis terminal.
To run this example:
1. Clone the repository::
git clone https://github.com/flask-admin/flask-admin.git
cd flask-admin
2. Create and activate a virtual environment::
virtualenv env
source env/bin/activate
3. Install requirements::
pip install -r 'examples/rediscli/requirements.txt'
4. Run the application::
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.
from flask import Flask
from redis import Redis
import flask_admin as admin
from flask_admin.contrib import rediscli
# Create flask app
app = Flask(__name__)
# Flask views
@app.route('/')
def index():
return '<a href="/admin/">Click me to get to Admin!</a>'
if __name__ == '__main__':
# Create admin interface
admin = admin.Admin(app, name="Example: Redis")
admin.add_view(rediscli.RedisCli(Redis()))
# Start app
app.run(debug=True)
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from flask_admin.contrib import sqla
from flask_admin import Admin
# required for creating custom filters
from flask_admin.contrib.sqla.filters import BaseSQLAFilter, FilterEqual
# Create application
app = Flask(__name__)
# Create dummy secrey key so we can use sessions
app.config['SECRET_KEY'] = '123456790'
# Create in-memory database
app.config['DATABASE_FILE'] = 'sample_db.sqlite'
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///' + app.config['DATABASE_FILE']
app.config['SQLALCHEMY_ECHO'] = True
db = SQLAlchemy(app)
# Flask views
@app.route('/')
def index():
return '<a href="/admin/">Click me to get to Admin!</a>'
# Create model
class User(db.Model):
def __init__(self, first_name, last_name, username, email):
self.first_name = first_name
self.last_name = last_name
self.username = username
self.email = email
id = db.Column(db.Integer, primary_key=True)
first_name = db.Column(db.String(100))
last_name = db.Column(db.String(100))
username = db.Column(db.String(80), unique=True)
email = db.Column(db.String(120), unique=True)
# Required for admin interface. For python 3 please use __str__ instead.
def __unicode__(self):
return self.username
# Create custom filter class
class FilterLastNameBrown(BaseSQLAFilter):
def apply(self, query, value, alias=None):
if value == '1':
return query.filter(self.column == "Brown")
else:
return query.filter(self.column != "Brown")
def operation(self):
return 'is Brown'
# Add custom filter and standard FilterEqual to ModelView
class UserAdmin(sqla.ModelView):
# each filter in the list is a filter operation (equals, not equals, etc)
# filters with the same name will appear as operations under the same filter
column_filters = [
FilterEqual(column=User.last_name, name='Last Name'),
FilterLastNameBrown(column=User.last_name, name='Last Name',
options=(('1', 'Yes'), ('0', 'No')))
]
admin = Admin(app, template_mode="bootstrap3")
admin.add_view(UserAdmin(User, db.session))
def build_sample_db():
db.drop_all()
db.create_all()
user_obj1 = User("Paul", "Brown", "pbrown", "paul@gmail.com")
user_obj2 = User("Luke", "Brown", "lbrown", "luke@gmail.com")
user_obj3 = User("Serge", "Koval", "skoval", "serge@gmail.com")
db.session.add_all([user_obj1, user_obj2, user_obj3])
db.session.commit()
if __name__ == '__main__':
build_sample_db()
app.run(port=5000, debug=True)
...@@ -14,10 +14,8 @@ To run this example: ...@@ -14,10 +14,8 @@ To run this example:
3. Install requirements:: 3. Install requirements::
pip install -r 'examples/sqla-inline/requirements.txt' pip install -r 'examples/sqla-custom-inline-forms/requirements.txt'
4. Run the application:: 4. Run the application::
python examples/sqla-inline/app.py python examples/sqla-custom-inline-forms/app.py
SQLA backend example showing how to filter select dropdown options in forms.
To run this example:
1. Clone the repository::
git clone https://github.com/flask-admin/flask-admin.git
cd flask-admin
2. Create and activate a virtual environment::
virtualenv env
source env/bin/activate
3. Install requirements::
pip install -r 'examples/sqla-filter-selectable/requirements.txt'
4. Run the application::
python examples/sqla-filter-selectable/app.py
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
import flask_admin as admin
from flask_admin.contrib import sqla
# Create application
app = Flask(__name__)
# Create dummy secrey key so we can use sessions
app.config['SECRET_KEY'] = '123456790'
# Create in-memory database
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///sample_db_3.sqlite'
app.config['SQLALCHEMY_ECHO'] = True
db = SQLAlchemy(app)
# Flask views
@app.route('/')
def index():
return '<a href="/admin/">Click me to get to Admin!</a>'
class Person(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(50))
pets = db.relationship('Pet', backref='person')
def __unicode__(self):
return self.name
class Pet(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(50))
person_id = db.Column(db.Integer, db.ForeignKey('person.id'))
available = db.Column(db.Boolean)
def __unicode__(self):
return self.name
class PersonAdmin(sqla.ModelView):
""" Override ModelView to filter options available in forms. """
def create_form(self):
return self._use_filtered_parent(
super(PersonAdmin, self).create_form()
)
def edit_form(self, obj):
return self._use_filtered_parent(
super(PersonAdmin, self).edit_form(obj)
)
def _use_filtered_parent(self, form):
form.pets.query_factory = self._get_parent_list
return form
def _get_parent_list(self):
# only show available pets in the form
return Pet.query.filter_by(available=True).all()
def __unicode__(self):
return self.name
# Create admin
admin = admin.Admin(app, name='Example: SQLAlchemy - Filtered Form Selectable',
template_mode='bootstrap3')
admin.add_view(PersonAdmin(Person, db.session))
admin.add_view(sqla.ModelView(Pet, db.session))
if __name__ == '__main__':
# Recreate DB
db.drop_all()
db.create_all()
person = Person(name='Bill')
pet1 = Pet(name='Dog', available=True)
pet2 = Pet(name='Fish', available=True)
pet3 = Pet(name='Ocelot', available=False)
db.session.add_all([person, pet1, pet2, pet3])
db.session.commit()
# Start app
app.run(debug=True)
Example of how to use (and filter on) a hybrid_property with the SQLAlchemy backend.
Hybrid properties allow you to treat calculations (for example: first_name + last_name)
like any other database column.
To run this example:
1. Clone the repository::
git clone https://github.com/flask-admin/flask-admin.git
cd flask-admin
2. Create and activate a virtual environment::
virtualenv env
source env/bin/activate
3. Install requirements::
pip install -r 'examples/sqla-hybrid_property/requirements.txt'
4. Run the application::
python examples/sqla-hybrid_property/app.py
The first time you run this example, a sample sqlite database gets populated automatically. To suppress this behaviour,
comment the following lines in app.py:::
if not os.path.exists(database_path):
build_sample_db()
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy.ext.hybrid import hybrid_property
import flask_admin as admin
from flask_admin.contrib import sqla
# Create application
app = Flask(__name__)
# Create dummy secrey key so we can use sessions
app.config['SECRET_KEY'] = '123456790'
# Create in-memory database
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///sample_db_2.sqlite'
app.config['SQLALCHEMY_ECHO'] = True
db = SQLAlchemy(app)
# Flask views
@app.route('/')
def index():
return '<a href="/admin/">Click me to get to Admin!</a>'
class Screen(db.Model):
__tablename__ = 'screen'
id = db.Column(db.Integer, primary_key=True)
width = db.Column(db.Integer, nullable=False)
height = db.Column(db.Integer, nullable=False)
@hybrid_property
def number_of_pixels(self):
return self.width * self.height
class ScreenAdmin(sqla.ModelView):
""" Flask-admin can not automatically find a hybrid_property yet. You will
need to manually define the column in list_view/filters/sorting/etc."""
column_list = ['id', 'width', 'height', 'number_of_pixels']
column_sortable_list = ['id', 'width', 'height', 'number_of_pixels']
# Flask-admin can automatically detect the relevant filters for hybrid properties.
column_filters = ('number_of_pixels', )
# Create admin
admin = admin.Admin(app, name='Example: SQLAlchemy2', template_mode='bootstrap3')
admin.add_view(ScreenAdmin(Screen, db.session))
if __name__ == '__main__':
# Create DB
db.create_all()
# Start app
app.run(debug=True)
...@@ -16,10 +16,9 @@ To run this example: ...@@ -16,10 +16,9 @@ To run this example:
pip install -r 'examples/sqla/requirements.txt' pip install -r 'examples/sqla/requirements.txt'
4. Run either of these applications:: 4. Run the application::
python examples/sqla/app.py python examples/sqla/app.py
python examples/sqla/app2.py
The first time you run this example, a sample sqlite database gets populated automatically. To suppress this behaviour, 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:::
......
...@@ -2,17 +2,26 @@ import os ...@@ -2,17 +2,26 @@ import os
import os.path as op import os.path as op
from flask import Flask from flask import Flask
from flask_sqlalchemy import SQLAlchemy from flask_sqlalchemy import SQLAlchemy
from sqlalchemy.ext.hybrid import hybrid_property
from wtforms import validators from wtforms import validators
import flask_admin as admin import flask_admin as admin
from flask_admin.base import MenuLink
from flask_admin.contrib import sqla from flask_admin.contrib import sqla
from flask_admin.contrib.sqla import filters from flask_admin.contrib.sqla import filters
from flask_admin.contrib.sqla.form import InlineModelConverter
from flask_admin.contrib.sqla.fields import InlineModelFormList
from flask_admin.contrib.sqla.filters import BaseSQLAFilter, FilterEqual
# Create application # Create application
app = Flask(__name__) app = Flask(__name__)
# set optional bootswatch theme
# see http://bootswatch.com/3/ for available swatches
app.config['FLASK_ADMIN_SWATCH'] = 'cerulean'
# Create dummy secrey key so we can use sessions # Create dummy secrey key so we can use sessions
app.config['SECRET_KEY'] = '123456790' app.config['SECRET_KEY'] = '123456790'
...@@ -28,11 +37,24 @@ class User(db.Model): ...@@ -28,11 +37,24 @@ class User(db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
first_name = db.Column(db.String(100)) first_name = db.Column(db.String(100))
last_name = db.Column(db.String(100)) last_name = db.Column(db.String(100))
username = db.Column(db.String(80), unique=True)
email = db.Column(db.String(120), unique=True) email = db.Column(db.String(120), unique=True)
pets = db.relationship('Pet', backref='owner')
def __str__(self): def __str__(self):
return self.username return "{}, {}".format(self.last_name, self.first_name)
def __repr__(self):
return "{}: {}".format(self.id, self.__str__())
class Pet(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(50), nullable=False)
person_id = db.Column(db.Integer, db.ForeignKey('user.id'))
available = db.Column(db.Boolean)
def __str__(self):
return self.name
# Create M2M table # Create M2M table
...@@ -46,7 +68,7 @@ class Post(db.Model): ...@@ -46,7 +68,7 @@ class Post(db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(120)) title = db.Column(db.String(120))
text = db.Column(db.Text, nullable=False) text = db.Column(db.Text, nullable=False)
date = db.Column(db.DateTime) date = db.Column(db.Date)
user_id = db.Column(db.Integer(), db.ForeignKey(User.id)) user_id = db.Column(db.Integer(), db.ForeignKey(User.id))
user = db.relationship(User, backref='posts') user = db.relationship(User, backref='posts')
...@@ -54,7 +76,7 @@ class Post(db.Model): ...@@ -54,7 +76,7 @@ class Post(db.Model):
tags = db.relationship('Tag', secondary=post_tags_table) tags = db.relationship('Tag', secondary=post_tags_table)
def __str__(self): def __str__(self):
return self.title return "{}".format(self.title)
class Tag(db.Model): class Tag(db.Model):
...@@ -62,7 +84,7 @@ class Tag(db.Model): ...@@ -62,7 +84,7 @@ class Tag(db.Model):
name = db.Column(db.Unicode(64)) name = db.Column(db.Unicode(64))
def __str__(self): def __str__(self):
return self.name return "{}".format(self.name)
class UserInfo(db.Model): class UserInfo(db.Model):
...@@ -75,7 +97,7 @@ class UserInfo(db.Model): ...@@ -75,7 +97,7 @@ class UserInfo(db.Model):
user = db.relationship(User, backref='info') user = db.relationship(User, backref='info')
def __str__(self): def __str__(self):
return '%s - %s' % (self.key, self.value) return "{} - {}".format(self.key, self.value)
class Tree(db.Model): class Tree(db.Model):
...@@ -85,7 +107,18 @@ class Tree(db.Model): ...@@ -85,7 +107,18 @@ class Tree(db.Model):
parent = db.relationship('Tree', remote_side=[id], backref='children') parent = db.relationship('Tree', remote_side=[id], backref='children')
def __str__(self): def __str__(self):
return self.name return "{}".format(self.name)
class Screen(db.Model):
__tablename__ = 'screen'
id = db.Column(db.Integer, primary_key=True)
width = db.Column(db.Integer, nullable=False)
height = db.Column(db.Integer, nullable=False)
@hybrid_property
def number_of_pixels(self):
return self.width * self.height
# Flask views # Flask views
...@@ -94,30 +127,95 @@ def index(): ...@@ -94,30 +127,95 @@ def index():
return '<a href="/admin/">Click me to get to Admin!</a>' return '<a href="/admin/">Click me to get to Admin!</a>'
# Custom filter class
class FilterLastNameBrown(BaseSQLAFilter):
def apply(self, query, value, alias=None):
if value == '1':
return query.filter(self.column == "Brown")
else:
return query.filter(self.column != "Brown")
def operation(self):
return 'is Brown'
# Customized User model admin # Customized User model admin
inline_form_options = {
'form_label': "Info item",
'form_columns': ['id', 'key', 'value'],
'form_args': None,
'form_extra_fields': None,
}
class UserAdmin(sqla.ModelView): class UserAdmin(sqla.ModelView):
inline_models = (UserInfo,) action_disallowed_list = ['delete', ]
column_display_pk = True
column_list = [
'id',
'last_name',
'first_name',
'email',
'pets',
]
column_default_sort = [('last_name', False), ('first_name', False)] # sort on multiple columns
# custom filter: each filter in the list is a filter operation (equals, not equals, etc)
# filters with the same name will appear as operations under the same filter
column_filters = [
FilterEqual(column=User.last_name, name='Last Name'),
FilterLastNameBrown(column=User.last_name, name='Last Name',
options=(('1', 'Yes'), ('0', 'No')))
]
inline_models = [(UserInfo, inline_form_options), ]
# setup create & edit forms so that only 'available' pets can be selected
def create_form(self):
return self._use_filtered_parent(
super(UserAdmin, self).create_form()
)
# Customized Post model admin def edit_form(self, obj):
class PostAdmin(sqla.ModelView): return self._use_filtered_parent(
# Visible columns in the list view super(UserAdmin, self).edit_form(obj)
column_exclude_list = ['text'] )
# List of columns that can be sorted. For 'user' column, use User.username as def _use_filtered_parent(self, form):
# a column. form.pets.query_factory = self._get_parent_list
column_sortable_list = ('title', ('user', 'user.username'), 'date') return form
# Rename 'title' columns to 'Post Title' in list view def _get_parent_list(self):
column_labels = dict(title='Post Title') # only show available pets in the form
return Pet.query.filter_by(available=True).all()
column_searchable_list = ('title', User.username, 'tags.name')
column_filters = ('user',
'title', # Customized Post model admin
'date', class PostAdmin(sqla.ModelView):
'tags', column_list = ['id', 'user', 'title', 'date', 'tags']
filters.FilterLike(Post.title, 'Fixed Title', options=(('test1', 'Test 1'), ('test2', 'Test 2')))) column_default_sort = ('date', True)
column_sortable_list = [
'id',
'title',
'date',
('user', ('user.last_name', 'user.first_name')), # sort on multiple columns
]
column_labels = dict(title='Post Title') # Rename 'title' column in list view
column_searchable_list = [
'title',
User.first_name,
User.last_name,
'tags.name',
]
column_filters = [
'user',
'title',
'date',
'tags',
filters.FilterLike(Post.title, 'Fixed Title', options=(('test1', 'Test 1'), ('test2', 'Test 2'))),
]
can_export = True
export_max_rows = 1000
export_types = ['csv', 'xls']
# Pass arguments to WTForms. In this case, change label for text field to # Pass arguments to WTForms. In this case, change label for text field to
# be 'Big Text' and add required() validator. # be 'Big Text' and add required() validator.
...@@ -127,11 +225,14 @@ class PostAdmin(sqla.ModelView): ...@@ -127,11 +225,14 @@ class PostAdmin(sqla.ModelView):
form_ajax_refs = { form_ajax_refs = {
'user': { 'user': {
'fields': (User.username, User.email) 'fields': (User.first_name, User.last_name)
}, },
'tags': { 'tags': {
'fields': (Tag.name,) 'fields': (Tag.name,),
} 'minimum_input_length': 0, # show suggestions, even before any user input
'placeholder': 'Please select',
'page_size': 5,
},
} }
def __init__(self, session): def __init__(self, session):
...@@ -143,6 +244,14 @@ class TreeView(sqla.ModelView): ...@@ -143,6 +244,14 @@ class TreeView(sqla.ModelView):
form_excluded_columns = ['children', ] form_excluded_columns = ['children', ]
class ScreenView(sqla.ModelView):
column_list = ['id', 'width', 'height', 'number_of_pixels'] # not that 'number_of_pixels' is a hybrid property, not a field
column_sortable_list = ['id', 'width', 'height', 'number_of_pixels']
# Flask-admin can automatically detect the relevant filters for hybrid properties.
column_filters = ('number_of_pixels', )
# Create admin # Create admin
admin = admin.Admin(app, name='Example: SQLAlchemy', template_mode='bootstrap3') admin = admin.Admin(app, name='Example: SQLAlchemy', template_mode='bootstrap3')
...@@ -150,7 +259,14 @@ admin = admin.Admin(app, name='Example: SQLAlchemy', template_mode='bootstrap3') ...@@ -150,7 +259,14 @@ admin = admin.Admin(app, name='Example: SQLAlchemy', template_mode='bootstrap3')
admin.add_view(UserAdmin(User, db.session)) admin.add_view(UserAdmin(User, db.session))
admin.add_view(sqla.ModelView(Tag, db.session)) admin.add_view(sqla.ModelView(Tag, db.session))
admin.add_view(PostAdmin(db.session)) admin.add_view(PostAdmin(db.session))
admin.add_view(TreeView(Tree, db.session)) admin.add_view(sqla.ModelView(Pet, db.session, category="Other"))
admin.add_view(sqla.ModelView(UserInfo, db.session, category="Other"))
admin.add_view(TreeView(Tree, db.session, category="Other"))
admin.add_view(ScreenView(Screen, db.session, category="Other"))
admin.add_sub_category(name="Links", parent_name="Other")
admin.add_link(MenuLink(name='Back Home', url='/', category='Links'))
admin.add_link(MenuLink(name='Google', url='http://www.google.com/', category='Links'))
admin.add_link(MenuLink(name='Mozilla', url='http://mozilla.org/', category='Links'))
def build_sample_db(): def build_sample_db():
...@@ -171,8 +287,8 @@ def build_sample_db(): ...@@ -171,8 +287,8 @@ def build_sample_db():
'Riley', 'William', 'James', 'Geoffrey', 'Lisa', 'Benjamin', 'Stacey', 'Lucy' 'Riley', 'William', 'James', 'Geoffrey', 'Lisa', 'Benjamin', 'Stacey', 'Lucy'
] ]
last_names = [ last_names = [
'Brown', 'Smith', 'Patel', 'Jones', 'Williams', 'Johnson', 'Taylor', 'Thomas', 'Brown', 'Brown', 'Patel', 'Jones', 'Williams', 'Johnson', 'Taylor', 'Thomas',
'Roberts', 'Khan', 'Lewis', 'Jackson', 'Clarke', 'James', 'Phillips', 'Wilson', 'Roberts', 'Khan', 'Clarke', 'Clarke', 'Clarke', 'James', 'Phillips', 'Wilson',
'Ali', 'Mason', 'Mitchell', 'Rose', 'Davis', 'Davies', 'Rodriguez', 'Cox', 'Alexander' 'Ali', 'Mason', 'Mitchell', 'Rose', 'Davis', 'Davies', 'Rodriguez', 'Cox', 'Alexander'
] ]
...@@ -180,9 +296,9 @@ def build_sample_db(): ...@@ -180,9 +296,9 @@ def build_sample_db():
for i in range(len(first_names)): for i in range(len(first_names)):
user = User() user = User()
user.first_name = first_names[i] user.first_name = first_names[i]
user.username = first_names[i].lower()
user.last_name = last_names[i] user.last_name = last_names[i]
user.email = user.username + "@example.com" user.email = first_names[i].lower() + "@example.com"
user.info.append(UserInfo(key="foo", value="bar"))
user_list.append(user) user_list.append(user)
db.session.add(user) db.session.add(user)
...@@ -258,6 +374,15 @@ def build_sample_db(): ...@@ -258,6 +374,15 @@ def build_sample_db():
leaf.parent = branch leaf.parent = branch
db.session.add(leaf) db.session.add(leaf)
db.session.add(Pet(name='Dog', available=True))
db.session.add(Pet(name='Fish', available=True))
db.session.add(Pet(name='Cat', available=True))
db.session.add(Pet(name='Parrot', available=True))
db.session.add(Pet(name='Ocelot', available=False))
db.session.add(Screen(width=500, height=2000))
db.session.add(Screen(width=550, height=1900))
db.session.commit() db.session.commit()
return return
......
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
import flask_admin as admin
from flask_admin.contrib import sqla
# Create application
app = Flask(__name__)
# Create dummy secrey key so we can use sessions
app.config['SECRET_KEY'] = '123456790'
# Create in-memory database
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///sample_db_2.sqlite'
app.config['SQLALCHEMY_ECHO'] = True
db = SQLAlchemy(app)
# Flask views
@app.route('/')
def index():
return '<a href="/admin/">Click me to get to Admin!</a>'
class Car(db.Model):
__tablename__ = 'cars'
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
desc = db.Column(db.String(50))
def __str__(self):
return self.desc
class Tyre(db.Model):
__tablename__ = 'tyres'
car_id = db.Column(db.Integer, db.ForeignKey('cars.id'), primary_key=True)
tyre_id = db.Column(db.Integer, primary_key=True)
car = db.relationship('Car', backref='tyres')
desc = db.Column(db.String(50))
class CarAdmin(sqla.ModelView):
column_display_pk = True
form_columns = ['id', 'desc']
class TyreAdmin(sqla.ModelView):
column_display_pk = True
form_columns = ['car', 'tyre_id', 'desc']
# Create admin
admin = admin.Admin(app, name='Example: SQLAlchemy2', template_mode='bootstrap3')
admin.add_view(CarAdmin(Car, db.session))
admin.add_view(TyreAdmin(Tyre, db.session))
if __name__ == '__main__':
# Create DB
db.create_all()
# Start app
app.run(debug=True)
Flask Flask
Flask-Admin Flask-Admin
Flask-SQLAlchemy Flask-SQLAlchemy
tablib
This example shows how you can change bootswatch themes
To run this example:
1. Clone the repository::
git clone https://github.com/flask-admin/flask-admin.git
cd flask-admin
2. Create and activate a virtual environment::
virtualenv env
source env/bin/activate
3. Install requirements::
pip install -r 'examples/forms/requirements.txt'
4. Run the application::
python examples/forms/app.py
The first time you run this example, a sample sqlite database gets populated automatically. To suppress this behaviour,
comment the following lines in app.py:::
if not os.path.exists(database_path):
build_sample_db()
__author__ = 'rochacbruno'
import os
import os.path as op
from flask import Flask, url_for
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy.event import listens_for
from jinja2 import Markup
from flask_admin import Admin, form
from flask_admin.form import rules
from flask_admin.contrib import sqla
# Create application
app = Flask(__name__, static_folder='files')
# set flask admin swatch
app.config['FLASK_ADMIN_SWATCH'] = 'cerulean'
# Create dummy secrey key so we can use sessions
app.config['SECRET_KEY'] = '123456790'
# Create in-memory database
app.config['DATABASE_FILE'] = 'sample_db.sqlite'
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///' + app.config['DATABASE_FILE']
app.config['SQLALCHEMY_ECHO'] = True
db = SQLAlchemy(app)
# Create directory for file fields to use
file_path = op.join(op.dirname(__file__), 'files')
try:
os.mkdir(file_path)
except OSError:
pass
# Create models
class File(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.Unicode(64))
path = db.Column(db.Unicode(128))
def __unicode__(self):
return self.name
class Image(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.Unicode(64))
path = db.Column(db.Unicode(128))
def __unicode__(self):
return self.name
class User(db.Model):
id = db.Column(db.Integer, primary_key=True)
first_name = db.Column(db.Unicode(64))
last_name = db.Column(db.Unicode(64))
email = db.Column(db.Unicode(128))
phone = db.Column(db.Unicode(32))
city = db.Column(db.Unicode(128))
country = db.Column(db.Unicode(128))
notes = db.Column(db.UnicodeText)
# Delete hooks for models, delete files if models are getting deleted
@listens_for(File, 'after_delete')
def del_file(mapper, connection, target):
if target.path:
try:
os.remove(op.join(file_path, target.path))
except OSError:
# Don't care if was not deleted because it does not exist
pass
@listens_for(Image, 'after_delete')
def del_image(mapper, connection, target):
if target.path:
# Delete image
try:
os.remove(op.join(file_path, target.path))
except OSError:
pass
# Delete thumbnail
try:
os.remove(op.join(file_path,
form.thumbgen_filename(target.path)))
except OSError:
pass
# Administrative views
class FileView(sqla.ModelView):
# Override form field to use Flask-Admin FileUploadField
form_overrides = {
'path': form.FileUploadField
}
# Pass additional parameters to 'path' to FileUploadField constructor
form_args = {
'path': {
'label': 'File',
'base_path': file_path,
'allow_overwrite': False
}
}
class ImageView(sqla.ModelView):
def _list_thumbnail(view, context, model, name):
if not model.path:
return ''
return Markup('<img src="%s">' % url_for('static',
filename=form.thumbgen_filename(model.path)))
column_formatters = {
'path': _list_thumbnail
}
# Alternative way to contribute field is to override it completely.
# In this case, Flask-Admin won't attempt to merge various parameters for the field.
form_extra_fields = {
'path': form.ImageUploadField('Image',
base_path=file_path,
thumbnail_size=(100, 100, True))
}
class UserView(sqla.ModelView):
"""
This class demonstrates the use of 'rules' for controlling the rendering of forms.
"""
form_create_rules = [
# Header and four fields. Email field will go above phone field.
rules.FieldSet(('first_name', 'last_name', 'email', 'phone'), 'Personal'),
# Separate header and few fields
rules.Header('Location'),
rules.Field('city'),
# String is resolved to form field, so there's no need to explicitly use `rules.Field`
'country',
# Show macro from Flask-Admin lib.html (it is included with 'lib' prefix)
rules.Container('rule_demo.wrap', rules.Field('notes'))
]
# Use same rule set for edit page
form_edit_rules = form_create_rules
create_template = 'rule_create.html'
edit_template = 'rule_edit.html'
# Flask views
@app.route('/')
def index():
return '<a href="/admin/">Click me to get to Admin!</a>'
# Create admin
admin = Admin(app, 'Example: Bootswatch', template_mode='bootstrap2')
# Add views
admin.add_view(FileView(File, db.session))
admin.add_view(ImageView(Image, db.session))
admin.add_view(UserView(User, db.session, name='User'))
def build_sample_db():
"""
Populate a small db with some example entries.
"""
import random
import string
db.drop_all()
db.create_all()
first_names = [
'Harry', 'Amelia', 'Oliver', 'Jack', 'Isabella', 'Charlie','Sophie', 'Mia',
'Jacob', 'Thomas', 'Emily', 'Lily', 'Ava', 'Isla', 'Alfie', 'Olivia', 'Jessica',
'Riley', 'William', 'James', 'Geoffrey', 'Lisa', 'Benjamin', 'Stacey', 'Lucy'
]
last_names = [
'Brown', 'Smith', 'Patel', 'Jones', 'Williams', 'Johnson', 'Taylor', 'Thomas',
'Roberts', 'Khan', 'Lewis', 'Jackson', 'Clarke', 'James', 'Phillips', 'Wilson',
'Ali', 'Mason', 'Mitchell', 'Rose', 'Davis', 'Davies', 'Rodriguez', 'Cox', 'Alexander'
]
locations = [
("Shanghai", "China"),
("Istanbul", "Turkey"),
("Karachi", "Pakistan"),
("Mumbai", "India"),
("Moscow", "Russia"),
("Sao Paulo", "Brazil"),
("Beijing", "China"),
("Tianjin", "China"),
("Guangzhou", "China"),
("Delhi", "India"),
("Seoul", "South Korea"),
("Shenzhen", "China"),
("Jakarta", "Indonesia"),
("Tokyo", "Japan"),
("Mexico City", "Mexico"),
("Kinshasa", "Democratic Republic of the Congo"),
("Bangalore", "India"),
("New York City", "United States"),
("London", "United Kingdom"),
("Bangkok", "Thailand"),
("Tehran", "Iran"),
("Dongguan", "China"),
("Lagos", "Nigeria"),
("Lima", "Peru"),
("Ho Chi Minh City", "Vietnam"),
]
for i in range(len(first_names)):
user = User()
user.first_name = first_names[i]
user.last_name = last_names[i]
user.email = user.first_name.lower() + "@example.com"
tmp = ''.join(random.choice(string.digits) for i in range(10))
user.phone = "(" + tmp[0:3] + ") " + tmp[3:6] + " " + tmp[6::]
user.city = locations[i][0]
user.country = locations[i][1]
db.session.add(user)
images = ["Buffalo", "Elephant", "Leopard", "Lion", "Rhino"]
for name in images:
image = Image()
image.name = name
image.path = name.lower() + ".jpg"
db.session.add(image)
for i in [1, 2, 3]:
file = File()
file.name = "Example " + str(i)
file.path = "example_" + str(i) + ".pdf"
db.session.add(file)
db.session.commit()
return
if __name__ == '__main__':
# Build a sample db on the fly, if one does not exist yet.
app_dir = op.realpath(os.path.dirname(__file__))
database_path = op.join(app_dir, app.config['DATABASE_FILE'])
if not os.path.exists(database_path):
build_sample_db()
# Start app
app.run(debug=True, use_reloader=True)
Flask
Flask-Admin
Flask-SQLAlchemy
pillow
\ No newline at end of file
{% extends 'admin/master.html' %}
{% block body %}
{{ super() }}
<div class="container">
<div class="row">
<div class="span12">
<h1>Flask-Admin example</h1>
<p class="lead">
Bootswatch
</p>
<p>
This example shows how you can define a bootstrap swatch theme
</p>
<pre># set flask admin swatch
app.config['FLASK_ADMIN_SWATCH'] = 'cerulean'</pre>
<p>
Available swatches in <a href="http://bootswatch.com/2/">http://bootswatch.com/2/</a>
</p>
<a class="btn btn-primary" href="/"><i class="glyphicon glyphicon-chevron-left"></i> Back</a>
</div>
</div>
</div>
{% endblock body %}
{% extends 'admin/model/create.html' %}
{% import 'rule_demo.html' as rule_demo %}
\ No newline at end of file
{% macro wrap() %}
<div style="border: 1px solid gray; background-color: #f0f0f0; padding-top: 8px; margin-bottom: 8px">
{{ caller() }}
</div>
{% endmacro %}
\ No newline at end of file
{% extends 'admin/model/edit.html' %}
{% import 'rule_demo.html' as rule_demo %}
This example shows how you can change bootswatch themes
To run this example:
1. Clone the repository::
git clone https://github.com/flask-admin/flask-admin.git
cd flask-admin
2. Create and activate a virtual environment::
virtualenv env
source env/bin/activate
3. Install requirements::
pip install -r 'examples/forms/requirements.txt'
4. Run the application::
python examples/forms/app.py
The first time you run this example, a sample sqlite database gets populated automatically. To suppress this behaviour,
comment the following lines in app.py:::
if not os.path.exists(database_path):
build_sample_db()
__author__ = 'rochacbruno'
import os
import os.path as op
from flask import Flask, url_for
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy.event import listens_for
from jinja2 import Markup
from flask_admin import Admin, form
from flask_admin.form import rules
from flask_admin.contrib import sqla
# Create application
app = Flask(__name__, static_folder='files')
# set flask admin swatch
app.config['FLASK_ADMIN_SWATCH'] = 'cerulean'
# Create dummy secrey key so we can use sessions
app.config['SECRET_KEY'] = '123456790'
# Create in-memory database
app.config['DATABASE_FILE'] = 'sample_db.sqlite'
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///' + app.config['DATABASE_FILE']
app.config['SQLALCHEMY_ECHO'] = True
db = SQLAlchemy(app)
# Create directory for file fields to use
file_path = op.join(op.dirname(__file__), 'files')
try:
os.mkdir(file_path)
except OSError:
pass
# Create models
class File(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.Unicode(64))
path = db.Column(db.Unicode(128))
def __unicode__(self):
return self.name
class Image(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.Unicode(64))
path = db.Column(db.Unicode(128))
def __unicode__(self):
return self.name
class User(db.Model):
id = db.Column(db.Integer, primary_key=True)
first_name = db.Column(db.Unicode(64))
last_name = db.Column(db.Unicode(64))
email = db.Column(db.Unicode(128))
phone = db.Column(db.Unicode(32))
city = db.Column(db.Unicode(128))
country = db.Column(db.Unicode(128))
notes = db.Column(db.UnicodeText)
# Delete hooks for models, delete files if models are getting deleted
@listens_for(File, 'after_delete')
def del_file(mapper, connection, target):
if target.path:
try:
os.remove(op.join(file_path, target.path))
except OSError:
# Don't care if was not deleted because it does not exist
pass
@listens_for(Image, 'after_delete')
def del_image(mapper, connection, target):
if target.path:
# Delete image
try:
os.remove(op.join(file_path, target.path))
except OSError:
pass
# Delete thumbnail
try:
os.remove(op.join(file_path,
form.thumbgen_filename(target.path)))
except OSError:
pass
# Administrative views
class FileView(sqla.ModelView):
# Override form field to use Flask-Admin FileUploadField
form_overrides = {
'path': form.FileUploadField
}
# Pass additional parameters to 'path' to FileUploadField constructor
form_args = {
'path': {
'label': 'File',
'base_path': file_path,
'allow_overwrite': False
}
}
class ImageView(sqla.ModelView):
def _list_thumbnail(view, context, model, name):
if not model.path:
return ''
return Markup('<img src="%s">' % url_for('static',
filename=form.thumbgen_filename(model.path)))
column_formatters = {
'path': _list_thumbnail
}
# Alternative way to contribute field is to override it completely.
# In this case, Flask-Admin won't attempt to merge various parameters for the field.
form_extra_fields = {
'path': form.ImageUploadField('Image',
base_path=file_path,
thumbnail_size=(100, 100, True))
}
class UserView(sqla.ModelView):
"""
This class demonstrates the use of 'rules' for controlling the rendering of forms.
"""
form_create_rules = [
# Header and four fields. Email field will go above phone field.
rules.FieldSet(('first_name', 'last_name', 'email', 'phone'), 'Personal'),
# Separate header and few fields
rules.Header('Location'),
rules.Field('city'),
# String is resolved to form field, so there's no need to explicitly use `rules.Field`
'country',
# Show macro from Flask-Admin lib.html (it is included with 'lib' prefix)
rules.Container('rule_demo.wrap', rules.Field('notes'))
]
# Use same rule set for edit page
form_edit_rules = form_create_rules
create_template = 'rule_create.html'
edit_template = 'rule_edit.html'
# Flask views
@app.route('/')
def index():
return '<a href="/admin/">Click me to get to Admin!</a>'
# Create admin
admin = Admin(app, 'Example: Bootswatch', template_mode='bootstrap3')
# Add views
admin.add_view(FileView(File, db.session))
admin.add_view(ImageView(Image, db.session))
admin.add_view(UserView(User, db.session, name='User'))
def build_sample_db():
"""
Populate a small db with some example entries.
"""
import random
import string
db.drop_all()
db.create_all()
first_names = [
'Harry', 'Amelia', 'Oliver', 'Jack', 'Isabella', 'Charlie','Sophie', 'Mia',
'Jacob', 'Thomas', 'Emily', 'Lily', 'Ava', 'Isla', 'Alfie', 'Olivia', 'Jessica',
'Riley', 'William', 'James', 'Geoffrey', 'Lisa', 'Benjamin', 'Stacey', 'Lucy'
]
last_names = [
'Brown', 'Smith', 'Patel', 'Jones', 'Williams', 'Johnson', 'Taylor', 'Thomas',
'Roberts', 'Khan', 'Lewis', 'Jackson', 'Clarke', 'James', 'Phillips', 'Wilson',
'Ali', 'Mason', 'Mitchell', 'Rose', 'Davis', 'Davies', 'Rodriguez', 'Cox', 'Alexander'
]
locations = [
("Shanghai", "China"),
("Istanbul", "Turkey"),
("Karachi", "Pakistan"),
("Mumbai", "India"),
("Moscow", "Russia"),
("Sao Paulo", "Brazil"),
("Beijing", "China"),
("Tianjin", "China"),
("Guangzhou", "China"),
("Delhi", "India"),
("Seoul", "South Korea"),
("Shenzhen", "China"),
("Jakarta", "Indonesia"),
("Tokyo", "Japan"),
("Mexico City", "Mexico"),
("Kinshasa", "Democratic Republic of the Congo"),
("Bangalore", "India"),
("New York City", "United States"),
("London", "United Kingdom"),
("Bangkok", "Thailand"),
("Tehran", "Iran"),
("Dongguan", "China"),
("Lagos", "Nigeria"),
("Lima", "Peru"),
("Ho Chi Minh City", "Vietnam"),
]
for i in range(len(first_names)):
user = User()
user.first_name = first_names[i]
user.last_name = last_names[i]
user.email = user.first_name.lower() + "@example.com"
tmp = ''.join(random.choice(string.digits) for i in range(10))
user.phone = "(" + tmp[0:3] + ") " + tmp[3:6] + " " + tmp[6::]
user.city = locations[i][0]
user.country = locations[i][1]
db.session.add(user)
images = ["Buffalo", "Elephant", "Leopard", "Lion", "Rhino"]
for name in images:
image = Image()
image.name = name
image.path = name.lower() + ".jpg"
db.session.add(image)
for i in [1, 2, 3]:
file = File()
file.name = "Example " + str(i)
file.path = "example_" + str(i) + ".pdf"
db.session.add(file)
db.session.commit()
return
if __name__ == '__main__':
# Build a sample db on the fly, if one does not exist yet.
app_dir = op.realpath(os.path.dirname(__file__))
database_path = op.join(app_dir, app.config['DATABASE_FILE'])
if not os.path.exists(database_path):
build_sample_db()
# Start app
app.run(debug=True, use_reloader=True)
Flask
Flask-Admin
Flask-SQLAlchemy
pillow
\ No newline at end of file
{% extends 'admin/master.html' %}
{% block body %}
{{ super() }}
<div class="container">
<div class="row">
<div class="col-sm-10 col-sm-offset-1">
<h1>Flask-Admin example</h1>
<p class="lead">
Bootswatch
</p>
<p>
This example shows how you can define a bootstrap swatch theme
</p>
<pre># set flask admin swatch
app.config['FLASK_ADMIN_SWATCH'] = 'cerulean'</pre>
<p>
Available swatches in <a href="http://bootswatch.com">http://bootswatch.com</a>
</p>
<a class="btn btn-primary" href="/"><i class="glyphicon glyphicon-chevron-left"></i> Back</a>
</div>
</div>
</div>
{% endblock body %}
{% extends 'admin/model/create.html' %}
{% import 'rule_demo.html' as rule_demo %}
\ No newline at end of file
{% macro wrap() %}
<div style="border: 1px solid gray; background-color: #f0f0f0; padding-top: 8px; margin-bottom: 8px">
{{ caller() }}
</div>
{% endmacro %}
\ No newline at end of file
{% extends 'admin/model/edit.html' %}
{% import 'rule_demo.html' as rule_demo %}
This example shows how you can turn a TextArea field into a rich WYSIWYG editor using WTForms and CKEditor.
To run this example:
1. Clone the repository::
git clone https://github.com/flask-admin/flask-admin.git
cd flask-admin
2. Create and activate a virtual environment::
virtualenv env
source env/bin/activate
3. Install requirements::
pip install -r 'examples/wysiwyg/requirements.txt'
4. Run the application::
python examples/wysiwyg/app.py
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from wtforms import fields, widgets
import flask_admin as admin
from flask_admin.contrib import sqla
# Create application
app = Flask(__name__)
# Create dummy secrey key so we can use sessions
app.config['SECRET_KEY'] = '123456790'
# Create in-memory database
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///sample_db.sqlite'
app.config['SQLALCHEMY_ECHO'] = True
db = SQLAlchemy(app)
''' Define a wtforms widget and field.
WTForms documentation on custom widgets:
https://wtforms.readthedocs.io/en/latest/widgets.html#custom-widgets
'''
class CKTextAreaWidget(widgets.TextArea):
def __call__(self, field, **kwargs):
# add WYSIWYG class to existing classes
existing_classes = kwargs.pop('class', '') or kwargs.pop('class_', '')
kwargs['class'] = u'%s %s' % (existing_classes, "ckeditor")
return super(CKTextAreaWidget, self).__call__(field, **kwargs)
class CKTextAreaField(fields.TextAreaField):
widget = CKTextAreaWidget()
# Model
class Page(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.Unicode(64))
text = db.Column(db.UnicodeText)
def __unicode__(self):
return self.name
# Customized admin interface
class PageAdmin(sqla.ModelView):
form_overrides = dict(text=CKTextAreaField)
create_template = 'create.html'
edit_template = 'edit.html'
# Flask views
@app.route('/')
def index():
return '<a href="/admin/">Click me to get to Admin!</a>'
if __name__ == '__main__':
# Create admin
admin = admin.Admin(app, name="Example: WYSIWYG")
# Add views
admin.add_view(PageAdmin(Page, db.session))
# Create DB
db.create_all()
# Start app
app.run(debug=True)
Flask
Flask-Admin
Flask-SQLAlchemy
{% extends 'admin/model/create.html' %}
{% block tail %}
{{ super() }}
<script src="http://cdnjs.cloudflare.com/ajax/libs/ckeditor/4.0.1/ckeditor.js"></script>
{% endblock %}
{% extends 'admin/model/create.html' %}
{% block tail %}
{{ super() }}
<script src="http://cdnjs.cloudflare.com/ajax/libs/ckeditor/4.0.1/ckeditor.js"></script>
{% endblock %}
__version__ = '1.5.0' __version__ = '1.5.3'
__author__ = 'Flask-Admin team' __author__ = 'Flask-Admin team'
__email__ = 'serge.koval+github@gmail.com' __email__ = 'serge.koval+github@gmail.com'
......
...@@ -8,6 +8,11 @@ ...@@ -8,6 +8,11 @@
import sys import sys
import warnings import warnings
try:
from wtforms.widgets import HTMLString as Markup
except ImportError:
from markupsafe import Markup # noqa: F401
def get_property(obj, name, old_name, default=None): def get_property(obj, name, old_name, default=None):
""" """
......
...@@ -38,7 +38,7 @@ if not PY2: ...@@ -38,7 +38,7 @@ if not PY2:
# Various tools # Various tools
from functools import reduce from functools import reduce
from urllib.parse import urljoin, urlparse from urllib.parse import urljoin, urlparse, quote
else: else:
text_type = unicode text_type = unicode
string_types = (str, unicode) string_types = (str, unicode)
...@@ -62,6 +62,7 @@ else: ...@@ -62,6 +62,7 @@ else:
# Helpers # Helpers
reduce = __builtins__['reduce'] if isinstance(__builtins__, dict) else __builtins__.reduce reduce = __builtins__['reduce'] if isinstance(__builtins__, dict) else __builtins__.reduce
from urlparse import urljoin, urlparse from urlparse import urljoin, urlparse
from urllib import quote
def with_metaclass(meta, *bases): def with_metaclass(meta, *bases):
......
...@@ -9,7 +9,7 @@ from flask_admin._compat import with_metaclass, as_unicode ...@@ -9,7 +9,7 @@ from flask_admin._compat import with_metaclass, as_unicode
from flask_admin import helpers as h from flask_admin import helpers as h
# For compatibility reasons import MenuLink # For compatibility reasons import MenuLink
from flask_admin.menu import MenuCategory, MenuView, MenuLink # noqa: F401 from flask_admin.menu import MenuCategory, MenuView, MenuLink, SubMenuCategory # noqa: F401
def expose(url='/', methods=('GET',)): def expose(url='/', methods=('GET',)):
...@@ -581,6 +581,27 @@ class Admin(object): ...@@ -581,6 +581,27 @@ class Admin(object):
for view in args: for view in args:
self.add_view(view) self.add_view(view)
def add_sub_category(self, name, parent_name):
"""
Add a category of a given name underneath
the category with parent_name.
:param name:
The name of the new menu category.
:param parent_name:
The name of a parent_name category
"""
name_text = as_unicode(name)
parent_name_text = as_unicode(parent_name)
category = self.get_category_menu_item(name_text)
parent = self.get_category_menu_item(parent_name_text)
if category is None and parent is not None:
category = SubMenuCategory(name)
self._menu_categories[name_text] = category
parent.add_child(category)
def add_link(self, link): def add_link(self, link):
""" """
Add link to menu links collection. Add link to menu links collection.
......
...@@ -12,7 +12,7 @@ from werkzeug import secure_filename ...@@ -12,7 +12,7 @@ from werkzeug import secure_filename
from wtforms import fields, validators from wtforms import fields, validators
from flask_admin import form, helpers from flask_admin import form, helpers
from flask_admin._compat import urljoin, as_unicode from flask_admin._compat import urljoin, as_unicode, quote
from flask_admin.base import BaseView, expose from flask_admin.base import BaseView, expose
from flask_admin.actions import action, ActionsMixin from flask_admin.actions import action, ActionsMixin
from flask_admin.babel import gettext, lazy_gettext from flask_admin.babel import gettext, lazy_gettext
...@@ -107,6 +107,20 @@ class LocalFileStorage(object): ...@@ -107,6 +107,20 @@ class LocalFileStorage(object):
""" """
return send_file(file_path) return send_file(file_path)
def read_file(self, path):
"""
Reads the content of the file located at `file_path`.
"""
with open(path, 'rb') as f:
return f.read()
def write_file(self, path, content):
"""
Writes `content` to the file located at `file_path`.
"""
with open(path, 'w') as f:
return f.write(content)
def save_file(self, path, file_data): def save_file(self, path, file_data):
""" """
Save uploaded file to the disk Save uploaded file to the disk
...@@ -593,6 +607,9 @@ class BaseFileAdmin(BaseView, ActionsMixin): ...@@ -593,6 +607,9 @@ class BaseFileAdmin(BaseView, ActionsMixin):
:param path: :param path:
Static file path Static file path
""" """
if self._on_windows:
path = path.replace('\\', '/')
if self.is_file_editable(path): if self.is_file_editable(path):
route = '.edit' route = '.edit'
else: else:
...@@ -818,18 +835,29 @@ class BaseFileAdmin(BaseView, ActionsMixin): ...@@ -818,18 +835,29 @@ class BaseFileAdmin(BaseView, ActionsMixin):
if self.is_accessible_path(rel_path): if self.is_accessible_path(rel_path):
items.append(item) items.append(item)
sort_column = request.args.get('sort', None, type=str) sort_column = request.args.get('sort', None, type=str) or self.default_sort_column
sort_desc = request.args.get('desc', 0, type=int) sort_desc = request.args.get('desc', 0, type=int) or self.default_desc
if sort_column is None:
if self.default_sort_column:
sort_column = self.default_sort_column
if self.default_desc:
sort_desc = self.default_desc
try:
column_index = self.possible_columns.index(sort_column)
except ValueError:
sort_column = self.default_sort_column
if sort_column is None: if sort_column is None:
# Sort by name # Sort by name
items.sort(key=itemgetter(0)) items.sort(key=itemgetter(0))
# Sort by type # Sort by type
items.sort(key=itemgetter(2), reverse=True) items.sort(key=itemgetter(2), reverse=True)
# Sort by modified date if not self._on_windows:
items.sort(key=lambda x: (x[0], x[1], x[2], x[3], datetime.fromtimestamp(x[4])), reverse=True) # Sort by modified date
items.sort(key=lambda x: (x[0], x[1], x[2], x[3], datetime.utcfromtimestamp(x[4])), reverse=True)
else: else:
column_index = self.possible_columns.index(sort_column)
items.sort(key=itemgetter(column_index), reverse=sort_desc) items.sort(key=itemgetter(column_index), reverse=sort_desc)
# Generate breadcrumbs # Generate breadcrumbs
...@@ -842,13 +870,16 @@ class BaseFileAdmin(BaseView, ActionsMixin): ...@@ -842,13 +870,16 @@ class BaseFileAdmin(BaseView, ActionsMixin):
else: else:
action_form = None action_form = None
def sort_url(column, invert=False): def sort_url(column, path, invert=False):
desc = None desc = None
if not path:
path = None
if invert and not sort_desc: if invert and not sort_desc:
desc = 1 desc = 1
return self.get_url('.index_view', sort=column, desc=desc) return self.get_url('.index_view', path=path, sort=column, desc=desc)
return self.render(self.list_template, return self.render(self.list_template,
dir_path=path, dir_path=path,
...@@ -921,7 +952,7 @@ class BaseFileAdmin(BaseView, ActionsMixin): ...@@ -921,7 +952,7 @@ class BaseFileAdmin(BaseView, ActionsMixin):
base_url = self.get_base_url() base_url = self.get_base_url()
if base_url: if base_url:
base_url = urljoin(self.get_url('.index_view'), base_url) base_url = urljoin(self.get_url('.index_view'), base_url)
return redirect(urljoin(base_url, path)) return redirect(urljoin(quote(base_url), quote(path)))
return self.storage.send_file(directory) return self.storage.send_file(directory)
...@@ -1109,8 +1140,7 @@ class BaseFileAdmin(BaseView, ActionsMixin): ...@@ -1109,8 +1140,7 @@ class BaseFileAdmin(BaseView, ActionsMixin):
form.process(request.form, content='') form.process(request.form, content='')
if form.validate(): if form.validate():
try: try:
with open(full_path, 'w') as f: self.storage.write_file(full_path, request.form['content'])
f.write(request.form['content'])
except IOError: except IOError:
flash(gettext("Error saving changes to %(name)s.", name=path), 'error') flash(gettext("Error saving changes to %(name)s.", name=path), 'error')
error = True error = True
...@@ -1122,8 +1152,7 @@ class BaseFileAdmin(BaseView, ActionsMixin): ...@@ -1122,8 +1152,7 @@ class BaseFileAdmin(BaseView, ActionsMixin):
helpers.flash_errors(form, message='Failed to edit file. %(error)s') helpers.flash_errors(form, message='Failed to edit file. %(error)s')
try: try:
with open(full_path, 'rb') as f: content = self.storage.read_file(full_path)
content = f.read()
except IOError: except IOError:
flash(gettext("Error reading %(name)s.", name=path), 'error') flash(gettext("Error reading %(name)s.", name=path), 'error')
error = True error = True
......
from __future__ import absolute_import
from datetime import datetime
from datetime import timedelta
from time import sleep
import os.path as op
try:
from azure.storage.blob import BlobPermissions
from azure.storage.blob import BlockBlobService
except ImportError:
BlobPermissions = BlockBlobService = None
from flask import redirect
from . import BaseFileAdmin
class AzureStorage(object):
"""
Storage object representing files on an Azure Storage container.
Usage::
from flask_admin.contrib.fileadmin import BaseFileAdmin
from flask_admin.contrib.fileadmin.azure import AzureStorage
class MyAzureAdmin(BaseFileAdmin):
# Configure your class however you like
pass
fileadmin_view = MyAzureAdmin(storage=AzureStorage(...))
"""
_fakedir = '.dir'
_copy_poll_interval_seconds = 1
_send_file_lookback = timedelta(minutes=15)
_send_file_validity = timedelta(hours=1)
separator = '/'
def __init__(self, container_name, connection_string):
"""
Constructor
:param container_name:
Name of the container that the files are on.
:param connection_string:
Azure Blob Storage Connection String
"""
if not BlockBlobService:
raise ValueError('Could not import Azure Blob Storage SDK. '
'You can install the SDK using '
'pip install azure-storage-blob')
self._container_name = container_name
self._connection_string = connection_string
self.__client = None
@property
def _client(self):
if not self.__client:
self.__client = BlockBlobService(
connection_string=self._connection_string)
self.__client.create_container(
self._container_name, fail_on_exist=False)
return self.__client
@classmethod
def _get_blob_last_modified(cls, blob):
last_modified = blob.properties.last_modified
tzinfo = last_modified.tzinfo
epoch = last_modified - datetime(1970, 1, 1, tzinfo=tzinfo)
return epoch.total_seconds()
@classmethod
def _ensure_blob_path(cls, path):
if path is None:
return None
path_parts = path.split(op.sep)
return cls.separator.join(path_parts).lstrip(cls.separator)
def get_files(self, path, directory):
if directory and path != directory:
path = op.join(path, directory)
path = self._ensure_blob_path(path)
directory = self._ensure_blob_path(directory)
path_parts = path.split(self.separator) if path else []
num_path_parts = len(path_parts)
folders = set()
files = []
for blob in self._client.list_blobs(self._container_name, path):
blob_path_parts = blob.name.split(self.separator)
name = blob_path_parts.pop()
blob_is_file_at_current_level = blob_path_parts == path_parts
blob_is_directory_file = name == self._fakedir
if blob_is_file_at_current_level and not blob_is_directory_file:
rel_path = blob.name
is_dir = False
size = blob.properties.content_length
last_modified = self._get_blob_last_modified(blob)
files.append((name, rel_path, is_dir, size, last_modified))
else:
next_level_folder = blob_path_parts[:num_path_parts + 1]
folder_name = self.separator.join(next_level_folder)
folders.add(folder_name)
folders.discard(directory)
for folder in folders:
name = folder.split(self.separator)[-1]
rel_path = folder
is_dir = True
size = 0
last_modified = 0
files.append((name, rel_path, is_dir, size, last_modified))
return files
def is_dir(self, path):
path = self._ensure_blob_path(path)
num_blobs = 0
for blob in self._client.list_blobs(self._container_name, path):
blob_path_parts = blob.name.split(self.separator)
is_explicit_directory = blob_path_parts[-1] == self._fakedir
if is_explicit_directory:
return True
num_blobs += 1
path_cannot_be_leaf = num_blobs >= 2
if path_cannot_be_leaf:
return True
return False
def path_exists(self, path):
path = self._ensure_blob_path(path)
if path == self.get_base_path():
return True
try:
next(iter(self._client.list_blobs(self._container_name, path)))
except StopIteration:
return False
else:
return True
def get_base_path(self):
return ''
def get_breadcrumbs(self, path):
path = self._ensure_blob_path(path)
accumulator = []
breadcrumbs = []
for folder in path.split(self.separator):
accumulator.append(folder)
breadcrumbs.append((folder, self.separator.join(accumulator)))
return breadcrumbs
def send_file(self, file_path):
file_path = self._ensure_blob_path(file_path)
if not self._client.exists(self._container_name, file_path):
raise ValueError()
now = datetime.utcnow()
url = self._client.make_blob_url(self._container_name, file_path)
sas = self._client.generate_blob_shared_access_signature(
self._container_name, file_path,
BlobPermissions.READ,
expiry=now + self._send_file_validity,
start=now - self._send_file_lookback)
return redirect('%s?%s' % (url, sas))
def read_file(self, path):
path = self._ensure_blob_path(path)
blob = self._client.get_blob_to_bytes(self._container_name, path)
return blob.content
def write_file(self, path, content):
path = self._ensure_blob_path(path)
self._client.create_blob_from_text(self._container_name, path, content)
def save_file(self, path, file_data):
path = self._ensure_blob_path(path)
self._client.create_blob_from_stream(self._container_name, path,
file_data.stream)
def delete_tree(self, directory):
directory = self._ensure_blob_path(directory)
for blob in self._client.list_blobs(self._container_name, directory):
self._client.delete_blob(self._container_name, blob.name)
def delete_file(self, file_path):
file_path = self._ensure_blob_path(file_path)
self._client.delete_blob(self._container_name, file_path)
def make_dir(self, path, directory):
path = self._ensure_blob_path(path)
directory = self._ensure_blob_path(directory)
blob = self.separator.join([path, directory, self._fakedir])
blob = blob.lstrip(self.separator)
self._client.create_blob_from_text(self._container_name, blob, '')
def _copy_blob(self, src, dst):
src_url = self._client.make_blob_url(self._container_name, src)
copy = self._client.copy_blob(self._container_name, dst, src_url)
while copy.status != 'success':
sleep(self._copy_poll_interval_seconds)
copy = self._client.get_blob_properties(
self._container_name, dst).properties.copy
def _rename_file(self, src, dst):
self._copy_blob(src, dst)
self.delete_file(src)
def _rename_directory(self, src, dst):
for blob in self._client.list_blobs(self._container_name, src):
self._rename_file(blob.name, blob.name.replace(src, dst, 1))
def rename_path(self, src, dst):
src = self._ensure_blob_path(src)
dst = self._ensure_blob_path(dst)
if self.is_dir(src):
self._rename_directory(src, dst)
else:
self._rename_file(src, dst)
class AzureFileAdmin(BaseFileAdmin):
"""
Simple Azure Blob Storage file-management interface.
:param container_name:
Name of the container that the files are on.
:param connection_string:
Azure Blob Storage Connection String
Sample usage::
from flask_admin import Admin
from flask_admin.contrib.fileadmin.azure import AzureFileAdmin
admin = Admin()
admin.add_view(AzureFileAdmin('files_container', 'my-connection-string')
"""
def __init__(self, container_name, connection_string, *args, **kwargs):
storage = AzureStorage(container_name, connection_string)
super(AzureFileAdmin, self).__init__(*args, storage=storage, **kwargs)
...@@ -166,6 +166,14 @@ class S3Storage(object): ...@@ -166,6 +166,14 @@ class S3Storage(object):
keys = self._get_path_keys(path + self.separator) keys = self._get_path_keys(path + self.separator)
return len(keys) == 1 return len(keys) == 1
def read_file(self, path):
key = Key(self.bucket, path)
return key.get_contents_as_string()
def write_file(self, path, content):
key = Key(self.bucket, path)
key.set_contents_from_file(content)
class S3FileAdmin(BaseFileAdmin): class S3FileAdmin(BaseFileAdmin):
""" """
......
...@@ -50,17 +50,18 @@ class QueryAjaxModelLoader(AjaxModelLoader): ...@@ -50,17 +50,18 @@ 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.model.objects query = self.model.objects
criteria = None if len(term) > 0:
criteria = None
for field in self._cached_fields: for field in self._cached_fields:
flt = {u'%s__icontains' % field.name: term} flt = {u'%s__icontains' % field.name: term}
if not criteria: if not criteria:
criteria = mongoengine.Q(**flt) criteria = mongoengine.Q(**flt)
else: else:
criteria |= mongoengine.Q(**flt) criteria |= mongoengine.Q(**flt)
query = query.filter(criteria) query = query.filter(criteria)
if offset: if offset:
query = query.skip(offset) query = query.skip(offset)
......
def parse_like_term(term): def parse_like_term(term):
""" """
Parse search term into (operation, term) tuple. Recognizes operators Parse search term into (operation, term) tuple. Recognizes operators
in the beginning of the search term. in the beginning of the search term. Case insensitive is the default.
* = case insensitive (can precede other operators) * = case sensitive (can precede other operators)
^ = starts with ^ = starts with
= = exact = = exact
:param term: :param term:
Search term Search term
""" """
case_insensitive = term.startswith('*') case_sensitive = term.startswith('*')
if case_insensitive: if case_sensitive:
term = term[1:] term = term[1:]
# apply operators # apply operators
if term.startswith('^'): if term.startswith('^'):
...@@ -23,6 +23,6 @@ def parse_like_term(term): ...@@ -23,6 +23,6 @@ def parse_like_term(term):
else: else:
oper = 'contains' oper = 'contains'
# add case insensitive flag # add case insensitive flag
if case_insensitive: if not case_sensitive:
oper = 'i' + oper oper = 'i' + oper
return oper, term return oper, term
...@@ -526,7 +526,9 @@ class ModelView(BaseModelView): ...@@ -526,7 +526,9 @@ class ModelView(BaseModelView):
order = self._get_default_order() order = self._get_default_order()
if order: if order:
query = query.order_by('%s%s' % ('-' if order[1] else '', order[0])) keys = ['%s%s' % ('-' if desc else '', col)
for (col, desc) in order]
query = query.order_by(*keys)
# Pagination # Pagination
if page_size is None: if page_size is None:
......
from wtforms.widgets import HTMLString, html_params from wtforms.widgets import html_params
from jinja2 import escape from jinja2 import escape
from mongoengine.fields import GridFSProxy, ImageGridFsProxy from mongoengine.fields import GridFSProxy, ImageGridFsProxy
from flask_admin._backwards import Markup
from flask_admin.helpers import get_url from flask_admin.helpers import get_url
from . import helpers from . import helpers
...@@ -31,10 +32,10 @@ class MongoFileInput(object): ...@@ -31,10 +32,10 @@ class MongoFileInput(object):
'marker': '_%s-delete' % field.name 'marker': '_%s-delete' % field.name
} }
return HTMLString('%s<input %s>' % (placeholder, return Markup('%s<input %s>' % (placeholder,
html_params(name=field.name, html_params(name=field.name,
type='file', type='file',
**kwargs))) **kwargs)))
class MongoImageInput(object): class MongoImageInput(object):
...@@ -46,7 +47,8 @@ class MongoImageInput(object): ...@@ -46,7 +47,8 @@ class MongoImageInput(object):
' <input type="checkbox" name="%(marker)s">Delete</input>' ' <input type="checkbox" name="%(marker)s">Delete</input>'
'</div>') '</div>')
def __call__(self, field, **kwargs):
def __call__(self, field, **kwargs):
kwargs.setdefault('id', field.id) kwargs.setdefault('id', field.id)
placeholder = '' placeholder = ''
...@@ -57,7 +59,7 @@ class MongoImageInput(object): ...@@ -57,7 +59,7 @@ class MongoImageInput(object):
'marker': '_%s-delete' % field.name 'marker': '_%s-delete' % field.name
} }
return HTMLString('%s<input %s>' % (placeholder, return Markup('%s<input %s>' % (placeholder,
html_params(name=field.name, html_params(name=field.name,
type='file', type='file',
**kwargs))) **kwargs)))
...@@ -52,16 +52,17 @@ class QueryAjaxModelLoader(AjaxModelLoader): ...@@ -52,16 +52,17 @@ 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.model.select() query = self.model.select()
stmt = None if len(term) > 0:
for field in self._cached_fields: stmt = None
q = field ** (u'%%%s%%' % term) for field in self._cached_fields:
q = field ** (u'%%%s%%' % term)
if stmt is None: if stmt is None:
stmt = q stmt = q
else: else:
stmt |= q stmt |= q
query = query.where(stmt) query = query.where(stmt)
if offset: if offset:
query = query.offset(offset) query = query.offset(offset)
......
...@@ -184,6 +184,10 @@ class ModelView(BaseModelView): ...@@ -184,6 +184,10 @@ class ModelView(BaseModelView):
return get_primary_key(self.model) return get_primary_key(self.model)
def get_pk_value(self, model): def get_pk_value(self, model):
if self.model._meta.composite_key:
return tuple([
model._data[field_name]
for field_name in self.model._meta.primary_key.field_names])
return getattr(model, self._primary_key) return getattr(model, self._primary_key)
def scaffold_list_columns(self): def scaffold_list_columns(self):
...@@ -234,24 +238,20 @@ class ModelView(BaseModelView): ...@@ -234,24 +238,20 @@ class ModelView(BaseModelView):
raise Exception('Failed to find field for filter: %s' % name) raise Exception('Failed to find field for filter: %s' % name)
# Check if field is in different model # Check if field is in different model
model_class = None
try: try:
if attr.model_class != self.model: model_class = attr.model_class
visible_name = '%s / %s' % (self.get_column_name(attr.model_class.__name__),
self.get_column_name(attr.name))
else:
if not isinstance(name, string_types):
visible_name = self.get_column_name(attr.name)
else:
visible_name = self.get_column_name(name)
except AttributeError: except AttributeError:
if attr.model != self.model: model_class = attr.model
visible_name = '%s / %s' % (self.get_column_name(attr.model.__name__),
self.get_column_name(attr.name)) if model_class != self.model:
visible_name = '%s / %s' % (self.get_column_name(model_class.__name__),
self.get_column_name(attr.name))
else:
if not isinstance(name, string_types):
visible_name = self.get_column_name(attr.name)
else: else:
if not isinstance(name, string_types): visible_name = self.get_column_name(name)
visible_name = self.get_column_name(attr.name)
else:
visible_name = self.get_column_name(name)
type_name = type(attr).__name__ type_name = type(attr).__name__
flt = self.filter_converter.convert(type_name, flt = self.filter_converter.convert(type_name,
...@@ -317,38 +317,42 @@ class ModelView(BaseModelView): ...@@ -317,38 +317,42 @@ class ModelView(BaseModelView):
return create_ajax_loader(self.model, name, name, options) return create_ajax_loader(self.model, name, name, options)
def _handle_join(self, query, field, joins): def _handle_join(self, query, field, joins):
model_class = None
try: try:
if field.model_class != self.model: model_class = field.model_class
model_name = field.model_class.__name__
if model_name not in joins:
query = query.join(field.model_class, JOIN.LEFT_OUTER)
joins.add(model_name)
except AttributeError: except AttributeError:
if field.model != self.model: model_class = field.model
model_name = field.model.__name__ if model_class != self.model:
model_name = model_class.__name__
if model_name not in joins:
query = query.join(field.model, JOIN.LEFT_OUTER)
joins.add(model_name)
if model_name not in joins:
query = query.join(model_class, JOIN.LEFT_OUTER)
joins.add(model_name)
return query return query
def _order_by(self, query, joins, sort_field, sort_desc): def _order_by(self, query, joins, order):
clauses = []
for sort_field, sort_desc in order:
query, joins, clause = self._sort_clause(
query, joins, sort_field, sort_desc)
clauses.append(clause)
query = query.order_by(*clauses)
return query, joins
def _sort_clause(self, query, joins, sort_field, sort_desc):
if isinstance(sort_field, string_types): if isinstance(sort_field, string_types):
field = getattr(self.model, sort_field) field = getattr(self.model, sort_field)
query = query.order_by(field.desc() if sort_desc else field.asc())
elif isinstance(sort_field, Field): elif isinstance(sort_field, Field):
model_class = None
try: try:
if sort_field.model_class != self.model: model_class = sort_field.model_class
query = self._handle_join(query, sort_field, joins)
except AttributeError: except AttributeError:
if sort_field.model != self.model: model_class = sort_field.model
query = self._handle_join(query, sort_field, joins) if model_class != self.model:
query = self._handle_join(query, sort_field, joins)
query = query.order_by(sort_field.desc() if sort_desc else sort_field.asc()) field = sort_field
clause = field.desc() if sort_desc else field.asc()
return query, joins return query, joins, clause
def get_query(self): def get_query(self):
return self.model.select() return self.model.select()
...@@ -417,13 +421,12 @@ class ModelView(BaseModelView): ...@@ -417,13 +421,12 @@ class ModelView(BaseModelView):
# Apply sorting # Apply sorting
if sort_column is not None: if sort_column is not None:
sort_field = self._sortable_columns[sort_column] sort_field = self._sortable_columns[sort_column]
order = [(sort_field, sort_desc)]
query, joins = self._order_by(query, joins, sort_field, sort_desc) query, joins = self._order_by(query, joins, order)
else: else:
order = self._get_default_order() order = self._get_default_order()
if order: if order:
query, joins = self._order_by(query, joins, order[0], order[1]) query, joins = self._order_by(query, joins, order)
# Pagination # Pagination
if page_size is None: if page_size is None:
...@@ -441,6 +444,8 @@ class ModelView(BaseModelView): ...@@ -441,6 +444,8 @@ class ModelView(BaseModelView):
return count, query return count, query
def get_one(self, id): def get_one(self, id):
if self.model._meta.composite_key:
return self.model.get(**dict(zip(self.model._meta.primary_key.field_names, id)))
return self.model.get(**{self._primary_key: id}) return self.model.get(**{self._primary_key: id})
def create_model(self, form): def create_model(self, form):
......
...@@ -262,7 +262,8 @@ class ModelView(BaseModelView): ...@@ -262,7 +262,8 @@ class ModelView(BaseModelView):
order = self._get_default_order() order = self._get_default_order()
if order: if order:
sort_by = [(order[0], pymongo.DESCENDING if order[1] else pymongo.ASCENDING)] sort_by = [(col, pymongo.DESCENDING if desc else pymongo.ASCENDING)
for (col, desc) in order]
# Pagination # Pagination
if page_size is None: if page_size is None:
......
from sqlalchemy import or_, and_ from sqlalchemy import or_, and_, cast
from sqlalchemy.types import String
from flask_admin._compat import as_unicode, string_types from flask_admin._compat import as_unicode, string_types
from flask_admin.model.ajax import AjaxModelLoader, DEFAULT_PAGE_SIZE from flask_admin.model.ajax import AjaxModelLoader, DEFAULT_PAGE_SIZE
...@@ -55,7 +56,7 @@ class QueryAjaxModelLoader(AjaxModelLoader): ...@@ -55,7 +56,7 @@ class QueryAjaxModelLoader(AjaxModelLoader):
if not model: if not model:
return None return None
return (getattr(model, self.pk), as_unicode(model)) return getattr(model, self.pk), as_unicode(model)
def get_one(self, pk): def get_one(self, pk):
# prevent autoflush from occuring during populate_obj # prevent autoflush from occuring during populate_obj
...@@ -65,7 +66,7 @@ class QueryAjaxModelLoader(AjaxModelLoader): ...@@ -65,7 +66,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.ilike(u'%%%s%%' % term) for field in self._cached_fields) filters = (cast(field, String).ilike(u'%%%s%%' % term) for field in self._cached_fields)
query = query.filter(or_(*filters)) query = query.filter(or_(*filters))
if self.filters: if self.filters:
......
...@@ -13,6 +13,7 @@ except ImportError: ...@@ -13,6 +13,7 @@ except ImportError:
from .tools import get_primary_key from .tools import get_primary_key
from flask_admin._compat import text_type, string_types, iteritems from flask_admin._compat import text_type, string_types, iteritems
from flask_admin.contrib.sqla.widgets import CheckboxListInput
from flask_admin.form import FormOpts, BaseForm, Select2Widget from flask_admin.form import FormOpts, BaseForm, Select2Widget
from flask_admin.model.fields import InlineFieldList, InlineModelFormField from flask_admin.model.fields import InlineFieldList, InlineModelFormField
from flask_admin.babel import lazy_gettext from flask_admin.babel import lazy_gettext
...@@ -181,6 +182,30 @@ class QuerySelectMultipleField(QuerySelectField): ...@@ -181,6 +182,30 @@ class QuerySelectMultipleField(QuerySelectField):
raise ValidationError(self.gettext(u'Not a valid choice')) raise ValidationError(self.gettext(u'Not a valid choice'))
class CheckboxListField(QuerySelectMultipleField):
"""
Alternative field for many-to-many relationships.
Can be used instead of `QuerySelectMultipleField`.
Appears as the list of checkboxes.
Example::
class MyView(ModelView):
form_columns = (
'languages',
)
form_args = {
'languages': {
'query_factory': Language.query,
},
}
form_overrides = {
'languages': CheckboxListField,
}
"""
widget = CheckboxListInput()
class HstoreForm(BaseForm): class HstoreForm(BaseForm):
""" Form used in InlineFormField/InlineHstoreList for HSTORE columns """ """ Form used in InlineFormField/InlineHstoreList for HSTORE columns """
key = StringField(lazy_gettext('Key')) key = StringField(lazy_gettext('Key'))
...@@ -272,11 +297,11 @@ class InlineModelFormList(InlineFieldList): ...@@ -272,11 +297,11 @@ class InlineModelFormList(InlineFieldList):
return return
# Create primary key map # Create primary key map
pk_map = dict((str(getattr(v, self._pk)), v) for v in values) pk_map = dict((get_obj_pk(v, self._pk), v) for v in values)
# Handle request data # Handle request data
for field in self.entries: for field in self.entries:
field_id = str(field.get_pk()) field_id = get_field_id(field)
is_created = field_id not in pk_map is_created = field_id not in pk_map
if not is_created: if not is_created:
...@@ -298,3 +323,27 @@ def get_pk_from_identity(obj): ...@@ -298,3 +323,27 @@ def get_pk_from_identity(obj):
# TODO: Remove me # TODO: Remove me
key = identity_key(instance=obj)[1] key = identity_key(instance=obj)[1]
return u':'.join(text_type(x) for x in key) return u':'.join(text_type(x) for x in key)
def get_obj_pk(obj, pk):
"""
get and format pk from obj
:rtype: text_type
"""
if isinstance(pk, tuple):
return tuple(text_type(getattr(obj, k)) for k in pk)
return text_type(getattr(obj, pk))
def get_field_id(field):
"""
get and format id from field
:rtype: text_type
"""
field_id = field.get_pk()
if isinstance(field_id, tuple):
return tuple(text_type(_) for _ in field_id)
return text_type(field_id)
...@@ -3,6 +3,7 @@ from enum import Enum ...@@ -3,6 +3,7 @@ from enum import Enum
from wtforms import fields, validators from wtforms import fields, validators
from sqlalchemy import Boolean, Column from sqlalchemy import Boolean, Column
from sqlalchemy.orm import ColumnProperty
from flask_admin import form from flask_admin import form
from flask_admin.model.form import (converts, ModelConverterBase, from flask_admin.model.form import (converts, ModelConverterBase,
...@@ -152,7 +153,7 @@ class AdminModelConverter(ModelConverterBase): ...@@ -152,7 +153,7 @@ class AdminModelConverter(ModelConverterBase):
return self._convert_relation(name, prop, property_is_association_proxy, kwargs) return self._convert_relation(name, prop, property_is_association_proxy, kwargs)
elif hasattr(prop, 'columns'): # Ignore pk/fk elif hasattr(prop, 'columns'): # Ignore pk/fk
# Check if more than one column mapped to the property # Check if more than one column mapped to the property
if len(prop.columns) > 1: if len(prop.columns) > 1 and not isinstance(prop, ColumnProperty):
columns = filter_foreign_columns(model.__table__, prop.columns) columns = filter_foreign_columns(model.__table__, prop.columns)
if len(columns) == 0: if len(columns) == 0:
......
...@@ -484,13 +484,21 @@ class ModelView(BaseModelView): ...@@ -484,13 +484,21 @@ 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):
column, path = tools.get_field_with_path(self.model, c[1]) if isinstance(c[1], tuple):
column_name = c[0] column, path = [], []
for item in c[1]:
column_item, path_item = tools.get_field_with_path(self.model, item)
column.append(column_item)
path.append(path_item)
column_name = c[0]
else:
column, path = tools.get_field_with_path(self.model, c[1])
column_name = c[0]
else: else:
column, path = tools.get_field_with_path(self.model, c) column, path = tools.get_field_with_path(self.model, c)
column_name = text_type(c) column_name = text_type(c)
if path and hasattr(path[0], 'property'): if path and (hasattr(path[0], 'property') or isinstance(path[0], list)):
self._sortable_joins[column_name] = path self._sortable_joins[column_name] = path
elif path: elif path:
raise Exception("For sorting columns in a related table, " raise Exception("For sorting columns in a related table, "
...@@ -501,8 +509,6 @@ class ModelView(BaseModelView): ...@@ -501,8 +509,6 @@ class ModelView(BaseModelView):
# column is in same table, use only model attribute name # column is in same table, use only model attribute name
if getattr(column, 'key', None) is not None: if getattr(column, 'key', None) is not None:
column_name = column.key column_name = column.key
else:
column_name = text_type(c)
# column_name must match column_name used in `get_list_columns` # column_name must match column_name used in `get_list_columns`
result[column_name] = column result[column_name] = column
...@@ -574,6 +580,33 @@ class ModelView(BaseModelView): ...@@ -574,6 +580,33 @@ class ModelView(BaseModelView):
return bool(self.column_searchable_list) return bool(self.column_searchable_list)
def search_placeholder(self):
"""
Return search placeholder.
For example, if set column_labels and column_searchable_list:
class MyModelView(BaseModelView):
column_labels = dict(name='Name', last_name='Last Name')
column_searchable_list = ('name', 'last_name')
placeholder is: "Search: Name, Last Name"
"""
if not self.column_searchable_list:
return 'Search'
placeholders = []
for searchable in self.column_searchable_list:
if isinstance(searchable, InstrumentedAttribute):
placeholders.append(
self.column_labels.get(searchable.key, searchable.key))
else:
placeholders.append(
self.column_labels.get(searchable, searchable))
return 'Search: %s' % u', '.join(placeholders)
def scaffold_filters(self, name): def scaffold_filters(self, name):
""" """
Return list of enabled filters Return list of enabled filters
...@@ -791,8 +824,6 @@ class ModelView(BaseModelView): ...@@ -791,8 +824,6 @@ 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.
This method can be used to set a "persistent filter" on an index_view. This method can be used to set a "persistent filter" on an index_view.
Example:: Example::
...@@ -800,6 +831,10 @@ class ModelView(BaseModelView): ...@@ -800,6 +831,10 @@ class ModelView(BaseModelView):
class MyView(ModelView): class MyView(ModelView):
def get_query(self): def get_query(self):
return super(MyView, self).get_query().filter(User.username == current_user.username) return super(MyView, self).get_query().filter(User.username == current_user.username)
If you override this method, don't forget to also override `get_count_query`, for displaying the correct
item count in the list view, and `get_one`, which is used when retrieving records for the edit view.
""" """
return self.session.query(self.model) return self.session.query(self.model)
...@@ -836,29 +871,17 @@ class ModelView(BaseModelView): ...@@ -836,29 +871,17 @@ class ModelView(BaseModelView):
column = sort_field if alias is None else getattr(alias, sort_field.key) column = sort_field if alias is None else getattr(alias, sort_field.key)
if sort_desc: if sort_desc:
if isinstance(column, tuple): query = query.order_by(desc(column))
query = query.order_by(*map(desc, column))
else:
query = query.order_by(desc(column))
else: else:
if isinstance(column, tuple): query = query.order_by(column)
query = query.order_by(*column)
else:
query = query.order_by(column)
return query, joins return query, joins
def _get_default_order(self): def _get_default_order(self):
order = super(ModelView, self)._get_default_order() order = super(ModelView, self)._get_default_order()
for field, direction in (order or []):
if order is not None:
field, direction = order
attr, joins = tools.get_field_with_path(self.model, field) attr, joins = tools.get_field_with_path(self.model, field)
yield attr, joins, direction
return attr, joins, direction
return None
def _apply_sorting(self, query, joins, sort_column, sort_desc): def _apply_sorting(self, query, joins, sort_column, sort_desc):
if sort_column is not None: if sort_column is not None:
...@@ -866,13 +889,14 @@ class ModelView(BaseModelView): ...@@ -866,13 +889,14 @@ class ModelView(BaseModelView):
sort_field = self._sortable_columns[sort_column] sort_field = self._sortable_columns[sort_column]
sort_joins = self._sortable_joins.get(sort_column) sort_joins = self._sortable_joins.get(sort_column)
query, joins = self._order_by(query, joins, sort_joins, sort_field, sort_desc) if isinstance(sort_field, list):
for field_item, join_item in zip(sort_field, sort_joins):
query, joins = self._order_by(query, joins, join_item, field_item, sort_desc)
else:
query, joins = self._order_by(query, joins, sort_joins, sort_field, sort_desc)
else: else:
order = self._get_default_order() order = self._get_default_order()
for sort_field, sort_joins, sort_desc in order:
if order:
sort_field, sort_joins, sort_desc = order
query, joins = self._order_by(query, joins, sort_joins, sort_field, sort_desc) query, joins = self._order_by(query, joins, sort_joins, sort_field, sort_desc)
return query, joins return query, joins
...@@ -1051,6 +1075,14 @@ class ModelView(BaseModelView): ...@@ -1051,6 +1075,14 @@ class ModelView(BaseModelView):
""" """
Return a single model by its id. Return a single model by its id.
Example::
def get_one(self, id):
query = self.get_query()
return query.filter(self.model.id == id).one()
Also see `get_query` for how to filter the list view.
:param id: :param id:
Model id Model id
""" """
...@@ -1059,7 +1091,10 @@ class ModelView(BaseModelView): ...@@ -1059,7 +1091,10 @@ class ModelView(BaseModelView):
# Error handler # Error handler
def handle_view_exception(self, exc): def handle_view_exception(self, exc):
if isinstance(exc, IntegrityError): if isinstance(exc, IntegrityError):
if current_app.config.get('ADMIN_RAISE_ON_VIEW_EXCEPTION'): if current_app.config.get(
'ADMIN_RAISE_ON_INTEGRITY_ERROR',
current_app.config.get('ADMIN_RAISE_ON_VIEW_EXCEPTION')
):
raise raise
else: else:
flash(gettext('Integrity error. %(message)s', message=text_type(exc)), 'error') flash(gettext('Integrity error. %(message)s', message=text_type(exc)), 'error')
......
from wtforms.widgets.core import escape
from flask_admin._backwards import Markup
class CheckboxListInput:
"""
Alternative widget for many-to-many relationships.
Appears as the list of checkboxes.
"""
template = (
'<div class="checkbox">'
' <label>'
' <input id="%(id)s" name="%(name)s" value="%(id)s" '
'type="checkbox"%(selected)s>%(label)s'
' </label>'
'</div>'
)
def __call__(self, field, **kwargs):
items = []
for val, label, selected in field.iter_choices():
args = {
'id': val,
'name': field.name,
'label': escape(label),
'selected': ' checked' if selected else '',
}
items.append(self.template % args)
return Markup(''.join(items))
...@@ -5,7 +5,7 @@ from werkzeug import secure_filename ...@@ -5,7 +5,7 @@ from werkzeug import secure_filename
from werkzeug.datastructures import FileStorage from werkzeug.datastructures import FileStorage
from wtforms import ValidationError, fields from wtforms import ValidationError, fields
from wtforms.widgets import HTMLString, html_params from wtforms.widgets import html_params
try: try:
from wtforms.fields.core import _unset_value as unset_value from wtforms.fields.core import _unset_value as unset_value
...@@ -15,6 +15,7 @@ except ImportError: ...@@ -15,6 +15,7 @@ except ImportError:
from flask_admin.babel import gettext from flask_admin.babel import gettext
from flask_admin.helpers import get_url from flask_admin.helpers import get_url
from flask_admin._backwards import Markup
from flask_admin._compat import string_types, urljoin from flask_admin._compat import string_types, urljoin
...@@ -59,7 +60,7 @@ class FileUploadInput(object): ...@@ -59,7 +60,7 @@ class FileUploadInput(object):
else: else:
value = field.data or '' value = field.data or ''
return HTMLString(template % { return Markup(template % {
'text': html_params(type='text', 'text': html_params(type='text',
readonly='readonly', readonly='readonly',
value=value, value=value,
...@@ -108,7 +109,7 @@ class ImageUploadInput(object): ...@@ -108,7 +109,7 @@ class ImageUploadInput(object):
else: else:
template = self.empty_template template = self.empty_template
return HTMLString(template % args) return Markup(template % args)
def get_url(self, field): def get_url(self, field):
if field.thumbnail_size: if field.thumbnail_size:
......
from re import sub from re import sub, compile
from jinja2 import contextfunction from jinja2 import contextfunction
from flask import g, request, url_for, flash from flask import g, request, url_for, flash
from wtforms.validators import DataRequired, InputRequired from wtforms.validators import DataRequired, InputRequired
...@@ -8,6 +8,11 @@ from flask_admin._compat import urljoin, urlparse, iteritems ...@@ -8,6 +8,11 @@ from flask_admin._compat import urljoin, urlparse, iteritems
from ._compat import string_types from ._compat import string_types
VALID_SCHEMES = ['http', 'https']
_substitute_whitespace = compile(r'[\s\x00-\x08\x0B\x0C\x0E-\x19]+').sub
_fix_multiple_slashes = compile(r'(^([^/]+:)?//)/*').sub
def set_current_view(view): def set_current_view(view):
g._admin_view = view g._admin_view = view
...@@ -128,10 +133,26 @@ def prettify_class_name(name): ...@@ -128,10 +133,26 @@ def prettify_class_name(name):
def is_safe_url(target): def is_safe_url(target):
# prevent urls like "\\www.google.com"
# some browser will change \\ to // (eg: Chrome)
# refs https://stackoverflow.com/questions/10438008
target = target.replace('\\', '/')
# handle cases like "j a v a s c r i p t:"
target = _substitute_whitespace('', target)
# Chrome and FireFox "fix" more than two slashes into two after protocol
target = _fix_multiple_slashes(lambda m: m.group(1), target, 1)
# prevent urls starting with "javascript:"
target_info = urlparse(target)
target_scheme = target_info.scheme
if target_scheme and target_scheme not in VALID_SCHEMES:
return False
ref_url = urlparse(request.host_url) ref_url = urlparse(request.host_url)
test_url = urlparse(urljoin(request.host_url, target)) test_url = urlparse(urljoin(request.host_url, target))
return (test_url.scheme in ('http', 'https') and return ref_url.netloc == test_url.netloc
ref_url.netloc == test_url.netloc)
def get_redirect_target(param_name='url'): def get_redirect_target(param_name='url'):
......
...@@ -7,7 +7,7 @@ class BaseMenu(object): ...@@ -7,7 +7,7 @@ class BaseMenu(object):
""" """
def __init__(self, name, class_name=None, icon_type=None, icon_value=None, target=None): def __init__(self, name, class_name=None, icon_type=None, icon_value=None, target=None):
self.name = name self.name = name
self.class_name = class_name self.class_name = class_name if class_name is not None else ''
self.icon_type = icon_type self.icon_type = icon_type
self.icon_value = icon_value self.icon_value = icon_value
self.target = target self.target = target
...@@ -141,3 +141,9 @@ class MenuLink(BaseMenu): ...@@ -141,3 +141,9 @@ class MenuLink(BaseMenu):
def get_url(self): def get_url(self):
return self.url or url_for(self.endpoint) return self.url or url_for(self.endpoint)
class SubMenuCategory(MenuCategory):
def __init__(self, *args, **kwargs):
super(SubMenuCategory, self).__init__(*args, **kwargs)
self.class_name += ' dropdown-submenu'
...@@ -18,7 +18,7 @@ from wtforms.fields import HiddenField ...@@ -18,7 +18,7 @@ from wtforms.fields import HiddenField
from wtforms.fields.core import UnboundField from wtforms.fields.core import UnboundField
from wtforms.validators import ValidationError, InputRequired from wtforms.validators import ValidationError, InputRequired
from flask_admin.babel import gettext from flask_admin.babel import gettext, ngettext
from flask_admin.base import BaseView, expose from flask_admin.base import BaseView, expose
from flask_admin.form import BaseForm, FormOpts, rules from flask_admin.form import BaseForm, FormOpts, rules
...@@ -382,6 +382,12 @@ class BaseModelView(BaseView, ActionsMixin): ...@@ -382,6 +382,12 @@ class BaseModelView(BaseView, ActionsMixin):
class MyModelView(BaseModelView): class MyModelView(BaseModelView):
column_sortable_list = ('name', ('user', 'user.username')) column_sortable_list = ('name', ('user', 'user.username'))
You can also specify multiple fields to be used while sorting::
class MyModelView(BaseModelView):
column_sortable_list = (
'name', ('user', ('user.first_name', 'user.last_name')))
When using SQLAlchemy models, model attributes can be used instead When using SQLAlchemy models, model attributes can be used instead
of strings:: of strings::
...@@ -403,6 +409,12 @@ class BaseModelView(BaseView, ActionsMixin): ...@@ -403,6 +409,12 @@ class BaseModelView(BaseView, ActionsMixin):
class MyModelView(BaseModelView): class MyModelView(BaseModelView):
column_default_sort = ('user', True) column_default_sort = ('user', True)
If you want to sort by more than one column,
you can pass a list of tuples::
class MyModelView(BaseModelView):
column_default_sort = [('name', True), ('last_name', True)]
""" """
column_searchable_list = ObsoleteAttr('column_searchable_list', column_searchable_list = ObsoleteAttr('column_searchable_list',
...@@ -665,7 +677,9 @@ class BaseModelView(BaseView, ActionsMixin): ...@@ -665,7 +677,9 @@ class BaseModelView(BaseView, ActionsMixin):
form_ajax_refs = { form_ajax_refs = {
'user': { 'user': {
'fields': ('first_name', 'last_name', 'email'), 'fields': ('first_name', 'last_name', 'email'),
'page_size': 10 'placeholder': 'Please select',
'page_size': 10,
'minimum_input_length': 0,
} }
} }
...@@ -769,7 +783,7 @@ class BaseModelView(BaseView, ActionsMixin): ...@@ -769,7 +783,7 @@ class BaseModelView(BaseView, ActionsMixin):
:param name: :param name:
View name. If not provided, will use the model class name View name. If not provided, will use the model class name
:param category: :param category:
View category Optional category name, for grouping views in the menu
:param endpoint: :param endpoint:
Base endpoint. If not provided, will use the model name. Base endpoint. If not provided, will use the model name.
:param url: :param url:
...@@ -1093,6 +1107,12 @@ class BaseModelView(BaseView, ActionsMixin): ...@@ -1093,6 +1107,12 @@ class BaseModelView(BaseView, ActionsMixin):
""" """
return False return False
def search_placeholder(self):
"""
Return search placeholder.
"""
return 'Search'
# Filter helpers # Filter helpers
def scaffold_filters(self, name): def scaffold_filters(self, name):
""" """
...@@ -1463,10 +1483,12 @@ class BaseModelView(BaseView, ActionsMixin): ...@@ -1463,10 +1483,12 @@ class BaseModelView(BaseView, ActionsMixin):
Return default sort order Return default sort order
""" """
if self.column_default_sort: if self.column_default_sort:
if isinstance(self.column_default_sort, tuple): if isinstance(self.column_default_sort, list):
return self.column_default_sort return self.column_default_sort
if isinstance(self.column_default_sort, tuple):
return [self.column_default_sort]
else: else:
return self.column_default_sort, False return [(self.column_default_sort, False)]
return None return None
...@@ -1547,7 +1569,7 @@ class BaseModelView(BaseView, ActionsMixin): ...@@ -1547,7 +1569,7 @@ class BaseModelView(BaseView, ActionsMixin):
try: try:
self.on_model_change(form, model, is_created) self.on_model_change(form, model, is_created)
except TypeError as e: except TypeError as e:
if re.match(r'on_model_change\(\) takes .* 3 .* arguments .* 4 .* given .*', e.message): if re.match(r'on_model_change\(\) takes .* 3 .* arguments .* 4 .* given .*', str(e)):
msg = ('%s.on_model_change() now accepts third ' + msg = ('%s.on_model_change() now accepts third ' +
'parameter is_created. Please update your code') % self.model 'parameter is_created. Please update your code') % self.model
warnings.warn(msg) warnings.warn(msg)
...@@ -1717,7 +1739,12 @@ class BaseModelView(BaseView, ActionsMixin): ...@@ -1717,7 +1739,12 @@ class BaseModelView(BaseView, ActionsMixin):
sort=request.args.get('sort', None, type=int), sort=request.args.get('sort', None, type=int),
sort_desc=request.args.get('desc', None, type=int), sort_desc=request.args.get('desc', None, type=int),
search=request.args.get('search', None), search=request.args.get('search', None),
filters=self._get_list_filter_args()) filters=self._get_list_filter_args(),
extra_args=dict([
(k, v) for k, v in request.args.items()
if k not in ('page', 'page_size', 'sort', 'desc', 'search', ) and
not k.startswith('flt')
]))
def _get_filters(self, filters): def _get_filters(self, filters):
""" """
...@@ -2010,6 +2037,7 @@ class BaseModelView(BaseView, ActionsMixin): ...@@ -2010,6 +2037,7 @@ class BaseModelView(BaseView, ActionsMixin):
search_supported=self._search_supported, search_supported=self._search_supported,
clear_search_url=clear_search_url, clear_search_url=clear_search_url,
search=view_args.search, search=view_args.search,
search_placeholder=self.search_placeholder(),
# Filters # Filters
filters=self._filters, filters=self._filters,
...@@ -2181,7 +2209,11 @@ class BaseModelView(BaseView, ActionsMixin): ...@@ -2181,7 +2209,11 @@ class BaseModelView(BaseView, ActionsMixin):
# message is flashed from within delete_model if it fails # message is flashed from within delete_model if it fails
if self.delete_model(model): if self.delete_model(model):
flash(gettext('Record was successfully deleted.'), 'success') count = 1
flash(
ngettext('Record was successfully deleted.',
'%(count)s records were successfully deleted.',
count, count=count), 'success')
return redirect(return_url) return redirect(return_url)
else: else:
flash_errors(form, message='Failed to delete record. %(error)s') flash_errors(form, message='Failed to delete record. %(error)s')
...@@ -2298,12 +2330,12 @@ class BaseModelView(BaseView, ActionsMixin): ...@@ -2298,12 +2330,12 @@ class BaseModelView(BaseView, ActionsMixin):
if encoding: if encoding:
mimetype = '%s; charset=%s' % (mimetype, encoding) mimetype = '%s; charset=%s' % (mimetype, encoding)
ds = tablib.Dataset(headers=[c[1] for c in self._export_columns]) ds = tablib.Dataset(headers=[csv_encode(c[1]) for c in self._export_columns])
count, data = self._export_data() count, data = self._export_data()
for row in data: for row in data:
vals = [self.get_export_value(row, c[0]) for c in self._export_columns] vals = [csv_encode(self.get_export_value(row, c[0])) for c in self._export_columns]
ds.append(vals) ds.append(vals)
try: try:
......
...@@ -118,6 +118,10 @@ class InlineModelFormField(FormField): ...@@ -118,6 +118,10 @@ class InlineModelFormField(FormField):
self.form_opts = form_opts self.form_opts = form_opts
def get_pk(self): def get_pk(self):
if isinstance(self._pk, (tuple, list)):
return tuple(getattr(self.form, pk).data for pk in self._pk)
return getattr(self.form, self._pk).data return getattr(self.form, self._pk).data
def populate_obj(self, obj, name): def populate_obj(self, obj, name):
......
from flask import json from flask import json
from jinja2 import escape from jinja2 import escape
from wtforms.widgets import HTMLString, html_params from wtforms.widgets import html_params
from flask_admin._backwards import Markup
from flask_admin._compat import as_unicode, text_type from flask_admin._compat import as_unicode, text_type
from flask_admin.babel import gettext from flask_admin.babel import gettext
from flask_admin.helpers import get_url from flask_admin.helpers import get_url
...@@ -61,7 +62,10 @@ class AjaxSelect2Widget(object): ...@@ -61,7 +62,10 @@ class AjaxSelect2Widget(object):
placeholder = field.loader.options.get('placeholder', gettext('Please select model')) placeholder = field.loader.options.get('placeholder', gettext('Please select model'))
kwargs.setdefault('data-placeholder', placeholder) kwargs.setdefault('data-placeholder', placeholder)
return HTMLString('<input %s>' % html_params(name=field.name, **kwargs)) minimum_input_length = int(field.loader.options.get('minimum_input_length', 1))
kwargs.setdefault('data-minimum-input-length', minimum_input_length)
return Markup('<input %s>' % html_params(name=field.name, **kwargs))
class XEditableWidget(object): class XEditableWidget(object):
...@@ -90,7 +94,7 @@ class XEditableWidget(object): ...@@ -90,7 +94,7 @@ class XEditableWidget(object):
kwargs = self.get_kwargs(field, kwargs) kwargs = self.get_kwargs(field, kwargs)
return HTMLString( return Markup(
'<a %s>%s</a>' % (html_params(**kwargs), '<a %s>%s</a>' % (html_params(**kwargs),
escape(display_value)) escape(display_value))
) )
......
.nav li.dropdown ul.dropdown-menu li:hover ul {
display:block;
position:absolute;
left:100%;
-webkit-border-radius: 3px;
-moz-border-radius: 3px;
border-radius: 3px;
}
.nav li.dropdown ul.dropdown-menu ul {
display: none;
float:right;
position: relative;
top: auto;
margin-top: -30px;
}
.nav li.dropdown a.dropdown-toggle .glyphicon {
margin: 0 4px;
}
var AdminModelActions = function(actionErrorMessage, actionConfirmations) { var AdminModelActions = function(actionErrorMessage, actionConfirmations) {
// Actions helpers. TODO: Move to separate file // batch actions helpers
this.execute = function(name) { this.execute = function(name) {
var selected = $('input.action-checkbox:checked').length; var selected = $('input.action-checkbox:checked').length;
...@@ -48,3 +48,4 @@ var AdminModelActions = function(actionErrorMessage, actionConfirmations) { ...@@ -48,3 +48,4 @@ var AdminModelActions = function(actionErrorMessage, actionConfirmations) {
}); });
}); });
}; };
var modelActions = new AdminModelActions(JSON.parse($('#message-data').text()), JSON.parse($('#actions-confirmation-data').text()));
...@@ -2,3 +2,8 @@ ...@@ -2,3 +2,8 @@
$('.modal').on('hidden', function() { $('.modal').on('hidden', function() {
$(this).removeData('modal'); $(this).removeData('modal');
}); });
$(function() {
// Apply flask-admin form styles after the modal is loaded
window.faForm.applyGlobalStyles(document);
});
...@@ -2,3 +2,8 @@ ...@@ -2,3 +2,8 @@
$('body').on('hidden.bs.modal', '.modal', function () { $('body').on('hidden.bs.modal', '.modal', function () {
$(this).removeData('bs.modal').find(".modal-content").empty(); $(this).removeData('bs.modal').find(".modal-content").empty();
}); });
$(function() {
// Apply flask-admin form styles after the modal is loaded
window.faForm.applyGlobalStyles(document);
});
...@@ -2,23 +2,23 @@ var AdminFilters = function(element, filtersElement, filterGroups, activeFilters ...@@ -2,23 +2,23 @@ var AdminFilters = function(element, filtersElement, filterGroups, activeFilters
var $root = $(element); var $root = $(element);
var $container = $('.filters', $root); var $container = $('.filters', $root);
var lastCount = 0; var lastCount = 0;
function getCount(name) { function getCount(name) {
var idx = name.indexOf('_'); var idx = name.indexOf('_');
if (idx === -1) { if (idx === -1) {
return 0; return 0;
} }
return parseInt(name.substr(3, idx - 3), 10); return parseInt(name.substr(3, idx - 3), 10);
} }
function makeName(name) { function makeName(name) {
var result = 'flt' + lastCount + '_' + name; var result = 'flt' + lastCount + '_' + name;
lastCount += 1; lastCount += 1;
return result; return result;
} }
function removeFilter() { function removeFilter() {
$(this).closest('tr').remove(); $(this).closest('tr').remove();
if($('.filters tr').length == 0) { if($('.filters tr').length == 0) {
...@@ -28,23 +28,23 @@ var AdminFilters = function(element, filtersElement, filterGroups, activeFilters ...@@ -28,23 +28,23 @@ var AdminFilters = function(element, filtersElement, filterGroups, activeFilters
} else { } else {
$('button', $root).show(); $('button', $root).show();
} }
return false; return false;
} }
// triggered when the filter operation (equals, not equals, etc) is changed // triggered when the filter operation (equals, not equals, etc) is changed
function changeOperation(subfilters, $el, filter, $select) { function changeOperation(subfilters, $el, filter, $select) {
// get the filter_group subfilter based on the index of the selected option // get the filter_group subfilter based on the index of the selected option
var selectedFilter = subfilters[$select.select2('data').element[0].index]; var selectedFilter = subfilters[$select.select2('data').element[0].index];
var $inputContainer = $el.find('td').last(); var $inputContainer = $el.find('td').last();
// recreate and style the input field (turn into date range or select2 if necessary) // recreate and style the input field (turn into date range or select2 if necessary)
var $field = createFilterInput($inputContainer, null, selectedFilter); var $field = createFilterInput($inputContainer, null, selectedFilter);
styleFilterInput(selectedFilter, $field); styleFilterInput(selectedFilter, $field);
$('button', $root).show(); $('button', $root).show();
} }
// generate HTML for filter input - allows changing filter input type to one with options or tags // generate HTML for filter input - allows changing filter input type to one with options or tags
function createFilterInput(inputContainer, filterValue, filter) { function createFilterInput(inputContainer, filterValue, filter) {
if (filter.type == "select2-tags") { if (filter.type == "select2-tags") {
...@@ -52,7 +52,7 @@ var AdminFilters = function(element, filtersElement, filterGroups, activeFilters ...@@ -52,7 +52,7 @@ var AdminFilters = function(element, filtersElement, filterGroups, activeFilters
$field.val(filterValue); $field.val(filterValue);
} else if (filter.options) { } else if (filter.options) {
var $field = $('<select class="filter-val" />').attr('name', makeName(filter.arg)); var $field = $('<select class="filter-val" />').attr('name', makeName(filter.arg));
$(filter.options).each(function() { $(filter.options).each(function() {
// for active filter inputs with options, add "selected" if there is a matching active filter // for active filter inputs with options, add "selected" if there is a matching active filter
if (filterValue && (filterValue == this[0])) { if (filterValue && (filterValue == this[0])) {
...@@ -68,10 +68,10 @@ var AdminFilters = function(element, filtersElement, filterGroups, activeFilters ...@@ -68,10 +68,10 @@ var AdminFilters = function(element, filtersElement, filterGroups, activeFilters
$field.val(filterValue); $field.val(filterValue);
} }
inputContainer.replaceWith($('<td/>').append($field)); inputContainer.replaceWith($('<td/>').append($field));
return $field; return $field;
} }
// add styling to input field, accommodates filters that change the input field's HTML // add styling to input field, accommodates filters that change the input field's HTML
function styleFilterInput(filter, field) { function styleFilterInput(filter, field) {
if (filter.type) { if (filter.type) {
...@@ -90,19 +90,19 @@ var AdminFilters = function(element, filtersElement, filterGroups, activeFilters ...@@ -90,19 +90,19 @@ var AdminFilters = function(element, filtersElement, filterGroups, activeFilters
// save tag options as json on data attribute // save tag options as json on data attribute
field.attr('data-tags', JSON.stringify(options)); field.attr('data-tags', JSON.stringify(options));
} }
} }
faForm.applyStyle(field, filter.type); faForm.applyStyle(field, filter.type);
} else if (filter.options) { } else if (filter.options) {
filter.type = "select2"; filter.type = "select2";
faForm.applyStyle(field, filter.type); faForm.applyStyle(field, filter.type);
} }
return field; return field;
} }
function addFilter(name, subfilters, selectedIndex, filterValue) { function addFilter(name, subfilters, selectedIndex, filterValue) {
var $el = $('<tr class="form-horizontal" />').appendTo($container); var $el = $('<tr class="form-horizontal" />').appendTo($container);
// Filter list // Filter list
$el.append( $el.append(
$('<td/>').append( $('<td/>').append(
...@@ -113,10 +113,10 @@ var AdminFilters = function(element, filtersElement, filterGroups, activeFilters ...@@ -113,10 +113,10 @@ var AdminFilters = function(element, filtersElement, filterGroups, activeFilters
.click(removeFilter) .click(removeFilter)
) )
); );
// Filter operation <select> (equal, not equal, etc) // Filter operation <select> (equal, not equal, etc)
var $select = $('<select class="filter-op" />'); var $select = $('<select class="filter-op" />');
// if one of the subfilters are selected, use that subfilter to create the input field // if one of the subfilters are selected, use that subfilter to create the input field
var filterSelection = 0; var filterSelection = 0;
$.each(subfilters, function( subfilterIndex, subfilter ) { $.each(subfilters, function( subfilterIndex, subfilter ) {
...@@ -127,55 +127,69 @@ var AdminFilters = function(element, filtersElement, filterGroups, activeFilters ...@@ -127,55 +127,69 @@ var AdminFilters = function(element, filtersElement, filterGroups, activeFilters
$select.append($('<option/>').attr('value', subfilter.arg).text(subfilter.operation)); $select.append($('<option/>').attr('value', subfilter.arg).text(subfilter.operation));
} }
}); });
$el.append( $el.append(
$('<td/>').append($select) $('<td/>').append($select)
); );
// select2 for filter-op (equal, not equal, etc) // select2 for filter-op (equal, not equal, etc)
$select.select2({width: 'resolve'}).on("change", function(e) { $select.select2({width: 'resolve'}).on("change", function(e) {
changeOperation(subfilters, $el, filter, $select); changeOperation(subfilters, $el, filter, $select);
}); });
// get filter option from filter_group, only for new filter creation // get filter option from filter_group, only for new filter creation
var filter = subfilters[filterSelection]; var filter = subfilters[filterSelection];
var $inputContainer = $('<td/>').appendTo($el); var $inputContainer = $('<td/>').appendTo($el);
var $newFilterField = createFilterInput($inputContainer, filterValue, filter).focus(); var $newFilterField = createFilterInput($inputContainer, filterValue, filter).focus();
var $styledFilterField = styleFilterInput(filter, $newFilterField); var $styledFilterField = styleFilterInput(filter, $newFilterField);
return $styledFilterField; return $styledFilterField;
} }
// Add Filter Button, new filter // Add Filter Button, new filter
$('a.filter', filtersElement).click(function() { $('a.filter', filtersElement).click(function() {
var name = ($(this).text().trim !== undefined ? $(this).text().trim() : $(this).text().replace(/^\s+|\s+$/g,'')); var name = ($(this).text().trim !== undefined ? $(this).text().trim() : $(this).text().replace(/^\s+|\s+$/g,''));
addFilter(name, filterGroups[name], false, null); addFilter(name, filterGroups[name], false, null);
$('button', $root).show(); $('button', $root).show();
}); });
// on page load - add active filters // on page load - add active filters
$.each(activeFilters, function( activeIndex, activeFilter ) { $.each(activeFilters, function( activeIndex, activeFilter ) {
var idx = activeFilter[0], var idx = activeFilter[0],
name = activeFilter[1], name = activeFilter[1],
filterValue = activeFilter[2]; filterValue = activeFilter[2];
var $activeField = addFilter(name, filterGroups[name], idx, filterValue); var $activeField = addFilter(name, filterGroups[name], idx, filterValue);
}); });
// show "Apply Filter" button when filter input is changed // show "Apply Filter" button when filter input is changed
$('.filter-val', $root).on('input change', function() { $('.filter-val', $root).on('input change', function() {
$('button', $root).show(); $('button', $root).show();
}); });
$('.remove-filter', $root).click(removeFilter); $('.remove-filter', $root).click(removeFilter);
$('.filter-val', $root).not('.select2-container').each(function() { $('.filter-val', $root).not('.select2-container').each(function() {
var count = getCount($(this).attr('name')); var count = getCount($(this).attr('name'));
if (count > lastCount) if (count > lastCount)
lastCount = count; lastCount = count;
}); });
lastCount += 1; lastCount += 1;
}; };
(function($) {
$('[data-role=tooltip]').tooltip({
html: true,
placement: 'bottom'
});
if ($('#filter-groups-data').length == 1) {
var filter = new AdminFilters(
'#filter_form', '.field-filters',
JSON.parse($('#filter-groups-data').text()),
JSON.parse($('#active-filters-data').text())
);
}
})(jQuery);
...@@ -11,7 +11,7 @@ ...@@ -11,7 +11,7 @@
var opts = { var opts = {
width: 'resolve', width: 'resolve',
minimumInputLength: 1, minimumInputLength: $el.attr('data-minimum-input-length'),
placeholder: 'data-placeholder', placeholder: 'data-placeholder',
ajax: { ajax: {
url: $el.attr('data-url'), url: $el.attr('data-url'),
...@@ -77,6 +77,10 @@ ...@@ -77,6 +77,10 @@
console.error("You must set MAPBOX_MAP_ID in your Flask settings to use the map widget"); console.error("You must set MAPBOX_MAP_ID in your Flask settings to use the map widget");
return false; return false;
} }
if (!window.DEFAULT_CENTER_LAT || !window.DEFAULT_CENTER_LONG) {
console.error("You must set DEFAULT_CENTER_LAT and DEFAULT_CENTER_LONG in your Flask settings to use the map widget");
return false;
}
var geometryType = $el.data("geometry-type") var geometryType = $el.data("geometry-type")
if (geometryType) { if (geometryType) {
...@@ -148,12 +152,8 @@ ...@@ -148,12 +152,8 @@
map.fitBounds(bounds); map.fitBounds(bounds);
} }
} else { } else {
// look up user's location by IP address // use the default map center
$.getJSON("//ip-api.com/json/?callback=?", function(data) { map.setView([window.DEFAULT_CENTER_LAT, window.DEFAULT_CENTER_LONG], 12);
map.setView([data["lat"], data["lon"]], 12);
}).fail(function() {
map.setView([0, 0], 1)
});
} }
// set up tiles // set up tiles
...@@ -182,7 +182,8 @@ ...@@ -182,7 +182,8 @@
var drawOptions = { var drawOptions = {
draw: { draw: {
// circles are not geometries in geojson // circles are not geometries in geojson
circle: false circle: false,
circlemarker: false
}, },
edit: { edit: {
featureGroup: editableLayers featureGroup: editableLayers
...@@ -304,6 +305,8 @@ ...@@ -304,6 +305,8 @@
if ($el.attr('data-allow-blank')) if ($el.attr('data-allow-blank'))
opts['allowClear'] = true; opts['allowClear'] = true;
opts['minimumInputLength'] = $el.attr('data-minimum-input-length');
if ($el.attr('data-tags')) { if ($el.attr('data-tags')) {
$.extend(opts, { $.extend(opts, {
tokenSeparators: [','], tokenSeparators: [','],
......
...@@ -115,3 +115,7 @@ var RedisCli = function(postUrl) { ...@@ -115,3 +115,7 @@ var RedisCli = function(postUrl) {
sendCommand('ping'); sendCommand('ping');
}; };
$(function() {
var redisCli = new RedisCli(JSON.parse($('#execute-view-data').text()));
});
File mode changed from 100644 to 100755
File mode changed from 100644 to 100755
File mode changed from 100644 to 100755
File mode changed from 100644 to 100755
File mode changed from 100644 to 100755
flask_admin/static/vendor/leaflet/images/spritesheet-2x.png

2.03 KB | W: | H:

flask_admin/static/vendor/leaflet/images/spritesheet-2x.png

3.5 KB | W: | H:

flask_admin/static/vendor/leaflet/images/spritesheet-2x.png
flask_admin/static/vendor/leaflet/images/spritesheet-2x.png
flask_admin/static/vendor/leaflet/images/spritesheet-2x.png
flask_admin/static/vendor/leaflet/images/spritesheet-2x.png
  • 2-up
  • Swipe
  • Onion skin
flask_admin/static/vendor/leaflet/images/spritesheet.png

1.03 KB | W: | H:

flask_admin/static/vendor/leaflet/images/spritesheet.png

1.86 KB | W: | H:

flask_admin/static/vendor/leaflet/images/spritesheet.png
flask_admin/static/vendor/leaflet/images/spritesheet.png
flask_admin/static/vendor/leaflet/images/spritesheet.png
flask_admin/static/vendor/leaflet/images/spritesheet.png
  • 2-up
  • Swipe
  • Onion skin
<?xml version="1.0" encoding="UTF-8" standalone="no"?> <?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 540 60" height="60" width="540"> <svg
<g id="enabled" fill="#464646"> xmlns:dc="http://purl.org/dc/elements/1.1/"
<g id="polyline"> xmlns:cc="http://creativecommons.org/ns#"
<path d="M18 36v6h6v-6h-6zm4 4h-2v-2h2v2z"/> xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
<path d="M36 18v6h6v-6h-6zm4 4h-2v-2h2v2z"/> xmlns:svg="http://www.w3.org/2000/svg"
<path d="M23.142 39.145l-2.285-2.29 16-15.998 2.285 2.285z"/> xmlns="http://www.w3.org/2000/svg"
</g> xmlns:xlink="http://www.w3.org/1999/xlink"
<path id="polygon" d="M100 24.565l-2.096 14.83L83.07 42 76 28.773 86.463 18z"/> xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
<path id="rectangle" d="M140 20h20v20h-20z"/> xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
<path id="circle" d="M221 30c0 6.078-4.926 11-11 11s-11-4.922-11-11c0-6.074 4.926-11 11-11s11 4.926 11 11z"/> viewBox="0 0 600 60"
<path id="marker" d="M270,19c-4.971,0-9,4.029-9,9c0,4.971,5.001,12,9,14c4.001-2,9-9.029,9-14C279,23.029,274.971,19,270,19z M270,31.5c-2.484,0-4.5-2.014-4.5-4.5c0-2.484,2.016-4.5,4.5-4.5c2.485,0,4.5,2.016,4.5,4.5C274.5,29.486,272.485,31.5,270,31.5z"/> height="60"
<g id="edit"> width="600"
<path d="M337,30.156v0.407v5.604c0,1.658-1.344,3-3,3h-10c-1.655,0-3-1.342-3-3v-10c0-1.657,1.345-3,3-3h6.345 l3.19-3.17H324c-3.313,0-6,2.687-6,6v10c0,3.313,2.687,6,6,6h10c3.314,0,6-2.687,6-6v-8.809L337,30.156"/> id="svg4225"
<path d="M338.72 24.637l-8.892 8.892H327V30.7l8.89-8.89z"/> version="1.1"
<path d="M338.697 17.826h4v4h-4z" transform="rotate(-134.99 340.703 19.817)"/> inkscape:version="0.91 r13725"
</g> sodipodi:docname="spritesheet.svg"
<g id="remove"> inkscape:export-filename="/home/fpuga/development/upstream/icarto.Leaflet.draw/src/images/spritesheet-2x.png"
<path d="M381 42h18V24h-18v18zm14-16h2v14h-2V26zm-4 0h2v14h-2V26zm-4 0h2v14h-2V26zm-4 0h2v14h-2V26z"/> inkscape:export-xdpi="90"
<path d="M395 20v-4h-10v4h-6v2h22v-2h-6zm-2 0h-6v-2h6v2z"/> inkscape:export-ydpi="90">
</g> <metadata
id="metadata4258">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs4256" />
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1920"
inkscape:window-height="1056"
id="namedview4254"
showgrid="false"
inkscape:zoom="1.3101852"
inkscape:cx="237.56928"
inkscape:cy="7.2419621"
inkscape:window-x="1920"
inkscape:window-y="24"
inkscape:window-maximized="1"
inkscape:current-layer="svg4225" />
<g
id="enabled"
style="fill:#464646;fill-opacity:1">
<g
id="polyline"
style="fill:#464646;fill-opacity:1">
<path
d="m 18,36 0,6 6,0 0,-6 -6,0 z m 4,4 -2,0 0,-2 2,0 0,2 z"
id="path4229"
inkscape:connector-curvature="0"
style="fill:#464646;fill-opacity:1" />
<path
d="m 36,18 0,6 6,0 0,-6 -6,0 z m 4,4 -2,0 0,-2 2,0 0,2 z"
id="path4231"
inkscape:connector-curvature="0"
style="fill:#464646;fill-opacity:1" />
<path
d="m 23.142,39.145 -2.285,-2.29 16,-15.998 2.285,2.285 z"
id="path4233"
inkscape:connector-curvature="0"
style="fill:#464646;fill-opacity:1" />
</g> </g>
<g id="disabled" fill="#bbb" transform="translate(120)"> <path
<use xlink:href="#edit" id="edit-disabled"/> id="polygon"
<use xlink:href="#remove" id="remove-disabled"/> d="M 100,24.565 97.904,39.395 83.07,42 76,28.773 86.463,18 Z"
inkscape:connector-curvature="0"
style="fill:#464646;fill-opacity:1" />
<path
id="rectangle"
d="m 140,20 20,0 0,20 -20,0 z"
inkscape:connector-curvature="0"
style="fill:#464646;fill-opacity:1" />
<path
id="circle"
d="m 221,30 c 0,6.078 -4.926,11 -11,11 -6.074,0 -11,-4.922 -11,-11 0,-6.074 4.926,-11 11,-11 6.074,0 11,4.926 11,11 z"
inkscape:connector-curvature="0"
style="fill:#464646;fill-opacity:1" />
<path
id="marker"
d="m 270,19 c -4.971,0 -9,4.029 -9,9 0,4.971 5.001,12 9,14 4.001,-2 9,-9.029 9,-14 0,-4.971 -4.029,-9 -9,-9 z m 0,12.5 c -2.484,0 -4.5,-2.014 -4.5,-4.5 0,-2.484 2.016,-4.5 4.5,-4.5 2.485,0 4.5,2.016 4.5,4.5 0,2.486 -2.015,4.5 -4.5,4.5 z"
inkscape:connector-curvature="0"
style="fill:#464646;fill-opacity:1" />
<g
id="edit"
style="fill:#464646;fill-opacity:1">
<path
d="m 337,30.156 0,0.407 0,5.604 c 0,1.658 -1.344,3 -3,3 l -10,0 c -1.655,0 -3,-1.342 -3,-3 l 0,-10 c 0,-1.657 1.345,-3 3,-3 l 6.345,0 3.19,-3.17 -9.535,0 c -3.313,0 -6,2.687 -6,6 l 0,10 c 0,3.313 2.687,6 6,6 l 10,0 c 3.314,0 6,-2.687 6,-6 l 0,-8.809 -3,2.968"
id="path4240"
inkscape:connector-curvature="0"
style="fill:#464646;fill-opacity:1" />
<path
d="m 338.72,24.637 -8.892,8.892 -2.828,0 0,-2.829 8.89,-8.89 z"
id="path4242"
inkscape:connector-curvature="0"
style="fill:#464646;fill-opacity:1" />
<path
d="m 338.697,17.826 4,0 0,4 -4,0 z"
transform="matrix(-0.70698336,-0.70723018,0.70723018,-0.70698336,567.55917,274.78273)"
id="path4244"
inkscape:connector-curvature="0"
style="fill:#464646;fill-opacity:1" />
</g> </g>
<g
id="remove"
style="fill:#464646;fill-opacity:1">
<path
d="m 381,42 18,0 0,-18 -18,0 0,18 z m 14,-16 2,0 0,14 -2,0 0,-14 z m -4,0 2,0 0,14 -2,0 0,-14 z m -4,0 2,0 0,14 -2,0 0,-14 z m -4,0 2,0 0,14 -2,0 0,-14 z"
id="path4247"
inkscape:connector-curvature="0"
style="fill:#464646;fill-opacity:1" />
<path
d="m 395,20 0,-4 -10,0 0,4 -6,0 0,2 22,0 0,-2 -6,0 z m -2,0 -6,0 0,-2 6,0 0,2 z"
id="path4249"
inkscape:connector-curvature="0"
style="fill:#464646;fill-opacity:1" />
</g>
</g>
<g
id="disabled"
transform="translate(120,0)"
style="fill:#bbbbbb">
<use
xlink:href="#edit"
id="edit-disabled"
x="0"
y="0"
width="100%"
height="100%" />
<use
xlink:href="#remove"
id="remove-disabled"
x="0"
y="0"
width="100%"
height="100%" />
</g>
<path
style="fill:none;stroke:#464646;stroke-width:2;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="circle-3"
d="m 581.65725,30 c 0,6.078 -4.926,11 -11,11 -6.074,0 -11,-4.922 -11,-11 0,-6.074 4.926,-11 11,-11 6.074,0 11,4.926 11,11 z"
inkscape:connector-curvature="0" />
</svg> </svg>
/* required styles */ /* required styles */
.leaflet-map-pane, .leaflet-pane,
.leaflet-tile, .leaflet-tile,
.leaflet-marker-icon, .leaflet-marker-icon,
.leaflet-marker-shadow, .leaflet-marker-shadow,
.leaflet-tile-pane,
.leaflet-tile-container, .leaflet-tile-container,
.leaflet-overlay-pane, .leaflet-pane > svg,
.leaflet-shadow-pane, .leaflet-pane > canvas,
.leaflet-marker-pane,
.leaflet-popup-pane,
.leaflet-overlay-pane svg,
.leaflet-zoom-box, .leaflet-zoom-box,
.leaflet-image-layer, .leaflet-image-layer,
.leaflet-layer { .leaflet-layer {
...@@ -20,7 +16,6 @@ ...@@ -20,7 +16,6 @@
} }
.leaflet-container { .leaflet-container {
overflow: hidden; overflow: hidden;
-ms-touch-action: none;
} }
.leaflet-tile, .leaflet-tile,
.leaflet-marker-icon, .leaflet-marker-icon,
...@@ -28,20 +23,54 @@ ...@@ -28,20 +23,54 @@
-webkit-user-select: none; -webkit-user-select: none;
-moz-user-select: none; -moz-user-select: none;
user-select: none; user-select: none;
-webkit-user-drag: none; -webkit-user-drag: none;
}
/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
.leaflet-safari .leaflet-tile {
image-rendering: -webkit-optimize-contrast;
}
/* hack that prevents hw layers "stretching" when loading new tiles */
.leaflet-safari .leaflet-tile-container {
width: 1600px;
height: 1600px;
-webkit-transform-origin: 0 0;
} }
.leaflet-marker-icon, .leaflet-marker-icon,
.leaflet-marker-shadow { .leaflet-marker-shadow {
display: block; display: block;
} }
/* map is broken in FF if you have max-width: 100% on tiles */ /* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
.leaflet-container img { /* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
.leaflet-container .leaflet-overlay-pane svg,
.leaflet-container .leaflet-marker-pane img,
.leaflet-container .leaflet-shadow-pane img,
.leaflet-container .leaflet-tile-pane img,
.leaflet-container img.leaflet-image-layer,
.leaflet-container .leaflet-tile {
max-width: none !important; max-width: none !important;
max-height: none !important;
} }
/* stupid Android 2 doesn't understand "max-width: none" properly */
.leaflet-container img.leaflet-image-layer { .leaflet-container.leaflet-touch-zoom {
max-width: 15000px !important; -ms-touch-action: pan-x pan-y;
touch-action: pan-x pan-y;
} }
.leaflet-container.leaflet-touch-drag {
-ms-touch-action: pinch-zoom;
/* Fallback for FF which doesn't support pinch-zoom */
touch-action: none;
touch-action: pinch-zoom;
}
.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
-ms-touch-action: none;
touch-action: none;
}
.leaflet-container {
-webkit-tap-highlight-color: transparent;
}
.leaflet-container a {
-webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
}
.leaflet-tile { .leaflet-tile {
filter: inherit; filter: inherit;
visibility: hidden; visibility: hidden;
...@@ -52,18 +81,26 @@ ...@@ -52,18 +81,26 @@
.leaflet-zoom-box { .leaflet-zoom-box {
width: 0; width: 0;
height: 0; height: 0;
-moz-box-sizing: border-box;
box-sizing: border-box;
z-index: 800;
} }
/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */ /* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
.leaflet-overlay-pane svg { .leaflet-overlay-pane svg {
-moz-user-select: none; -moz-user-select: none;
} }
.leaflet-tile-pane { z-index: 2; } .leaflet-pane { z-index: 400; }
.leaflet-objects-pane { z-index: 3; }
.leaflet-overlay-pane { z-index: 4; } .leaflet-tile-pane { z-index: 200; }
.leaflet-shadow-pane { z-index: 5; } .leaflet-overlay-pane { z-index: 400; }
.leaflet-marker-pane { z-index: 6; } .leaflet-shadow-pane { z-index: 500; }
.leaflet-popup-pane { z-index: 7; } .leaflet-marker-pane { z-index: 600; }
.leaflet-tooltip-pane { z-index: 650; }
.leaflet-popup-pane { z-index: 700; }
.leaflet-map-pane canvas { z-index: 100; }
.leaflet-map-pane svg { z-index: 200; }
.leaflet-vml-shape { .leaflet-vml-shape {
width: 1px; width: 1px;
...@@ -80,7 +117,8 @@ ...@@ -80,7 +117,8 @@
.leaflet-control { .leaflet-control {
position: relative; position: relative;
z-index: 7; z-index: 800;
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto; pointer-events: auto;
} }
.leaflet-top, .leaflet-top,
...@@ -124,31 +162,35 @@ ...@@ -124,31 +162,35 @@
/* zoom and fade animations */ /* zoom and fade animations */
.leaflet-fade-anim .leaflet-tile, .leaflet-fade-anim .leaflet-tile {
will-change: opacity;
}
.leaflet-fade-anim .leaflet-popup { .leaflet-fade-anim .leaflet-popup {
opacity: 0; opacity: 0;
-webkit-transition: opacity 0.2s linear; -webkit-transition: opacity 0.2s linear;
-moz-transition: opacity 0.2s linear; -moz-transition: opacity 0.2s linear;
-o-transition: opacity 0.2s linear;
transition: opacity 0.2s linear; transition: opacity 0.2s linear;
} }
.leaflet-fade-anim .leaflet-tile-loaded,
.leaflet-fade-anim .leaflet-map-pane .leaflet-popup { .leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
opacity: 1; opacity: 1;
} }
.leaflet-zoom-animated {
-webkit-transform-origin: 0 0;
-ms-transform-origin: 0 0;
transform-origin: 0 0;
}
.leaflet-zoom-anim .leaflet-zoom-animated {
will-change: transform;
}
.leaflet-zoom-anim .leaflet-zoom-animated { .leaflet-zoom-anim .leaflet-zoom-animated {
-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1); -webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
-moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1); -moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
-o-transition: -o-transform 0.25s cubic-bezier(0,0,0.25,1);
transition: transform 0.25s cubic-bezier(0,0,0.25,1); transition: transform 0.25s cubic-bezier(0,0,0.25,1);
} }
.leaflet-zoom-anim .leaflet-tile, .leaflet-zoom-anim .leaflet-tile,
.leaflet-pan-anim .leaflet-tile, .leaflet-pan-anim .leaflet-tile {
.leaflet-touching .leaflet-zoom-animated {
-webkit-transition: none; -webkit-transition: none;
-moz-transition: none; -moz-transition: none;
-o-transition: none;
transition: none; transition: none;
} }
...@@ -159,24 +201,46 @@ ...@@ -159,24 +201,46 @@
/* cursors */ /* cursors */
.leaflet-clickable { .leaflet-interactive {
cursor: pointer; cursor: pointer;
} }
.leaflet-container { .leaflet-grab {
cursor: -webkit-grab; cursor: -webkit-grab;
cursor: -moz-grab; cursor: -moz-grab;
cursor: grab;
}
.leaflet-crosshair,
.leaflet-crosshair .leaflet-interactive {
cursor: crosshair;
} }
.leaflet-popup-pane, .leaflet-popup-pane,
.leaflet-control { .leaflet-control {
cursor: auto; cursor: auto;
} }
.leaflet-dragging .leaflet-container, .leaflet-dragging .leaflet-grab,
.leaflet-dragging .leaflet-clickable { .leaflet-dragging .leaflet-grab .leaflet-interactive,
.leaflet-dragging .leaflet-marker-draggable {
cursor: move; cursor: move;
cursor: -webkit-grabbing; cursor: -webkit-grabbing;
cursor: -moz-grabbing; cursor: -moz-grabbing;
cursor: grabbing;
} }
/* marker & overlays interactivity */
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-image-layer,
.leaflet-pane > svg path,
.leaflet-tile-container {
pointer-events: none;
}
.leaflet-marker-icon.leaflet-interactive,
.leaflet-image-layer.leaflet-interactive,
.leaflet-pane > svg path.leaflet-interactive {
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
/* visual tweaks */ /* visual tweaks */
...@@ -249,7 +313,14 @@ ...@@ -249,7 +313,14 @@
height: 30px; height: 30px;
line-height: 30px; line-height: 30px;
} }
.leaflet-touch .leaflet-bar a:first-child {
border-top-left-radius: 2px;
border-top-right-radius: 2px;
}
.leaflet-touch .leaflet-bar a:last-child {
border-bottom-left-radius: 2px;
border-bottom-right-radius: 2px;
}
/* zoom control */ /* zoom control */
...@@ -258,16 +329,10 @@ ...@@ -258,16 +329,10 @@
font: bold 18px 'Lucida Console', Monaco, monospace; font: bold 18px 'Lucida Console', Monaco, monospace;
text-indent: 1px; text-indent: 1px;
} }
.leaflet-control-zoom-out {
font-size: 20px;
}
.leaflet-touch .leaflet-control-zoom-in { .leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {
font-size: 22px; font-size: 22px;
} }
.leaflet-touch .leaflet-control-zoom-out {
font-size: 24px;
}
/* layers control */ /* layers control */
...@@ -303,6 +368,11 @@ ...@@ -303,6 +368,11 @@
color: #333; color: #333;
background: #fff; background: #fff;
} }
.leaflet-control-layers-scrollbar {
overflow-y: scroll;
overflow-x: hidden;
padding-right: 5px;
}
.leaflet-control-layers-selector { .leaflet-control-layers-selector {
margin-top: 2px; margin-top: 2px;
position: relative; position: relative;
...@@ -317,6 +387,11 @@ ...@@ -317,6 +387,11 @@
margin: 5px -10px 5px -6px; margin: 5px -10px 5px -6px;
} }
/* Default icon URLs */
.leaflet-default-icon-path {
background-image: url(images/marker-icon.png);
}
/* attribution and scale controls */ /* attribution and scale controls */
...@@ -354,8 +429,8 @@ ...@@ -354,8 +429,8 @@
font-size: 11px; font-size: 11px;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
-moz-box-sizing: content-box; -moz-box-sizing: border-box;
box-sizing: content-box; box-sizing: border-box;
background: #fff; background: #fff;
background: rgba(255, 255, 255, 0.5); background: rgba(255, 255, 255, 0.5);
...@@ -386,6 +461,7 @@ ...@@ -386,6 +461,7 @@
.leaflet-popup { .leaflet-popup {
position: absolute; position: absolute;
text-align: center; text-align: center;
margin-bottom: 20px;
} }
.leaflet-popup-content-wrapper { .leaflet-popup-content-wrapper {
padding: 1px; padding: 1px;
...@@ -400,11 +476,13 @@ ...@@ -400,11 +476,13 @@
margin: 18px 0; margin: 18px 0;
} }
.leaflet-popup-tip-container { .leaflet-popup-tip-container {
margin: 0 auto;
width: 40px; width: 40px;
height: 20px; height: 20px;
position: relative; position: absolute;
left: 50%;
margin-left: -20px;
overflow: hidden; overflow: hidden;
pointer-events: none;
} }
.leaflet-popup-tip { .leaflet-popup-tip {
width: 17px; width: 17px;
...@@ -416,13 +494,12 @@ ...@@ -416,13 +494,12 @@
-webkit-transform: rotate(45deg); -webkit-transform: rotate(45deg);
-moz-transform: rotate(45deg); -moz-transform: rotate(45deg);
-ms-transform: rotate(45deg); -ms-transform: rotate(45deg);
-o-transform: rotate(45deg);
transform: rotate(45deg); transform: rotate(45deg);
} }
.leaflet-popup-content-wrapper, .leaflet-popup-content-wrapper,
.leaflet-popup-tip { .leaflet-popup-tip {
background: white; background: white;
color: #333;
box-shadow: 0 3px 14px rgba(0,0,0,0.4); box-shadow: 0 3px 14px rgba(0,0,0,0.4);
} }
.leaflet-container a.leaflet-popup-close-button { .leaflet-container a.leaflet-popup-close-button {
...@@ -430,6 +507,7 @@ ...@@ -430,6 +507,7 @@
top: 0; top: 0;
right: 0; right: 0;
padding: 4px 4px 0 0; padding: 4px 4px 0 0;
border: none;
text-align: center; text-align: center;
width: 18px; width: 18px;
height: 14px; height: 14px;
...@@ -476,3 +554,82 @@ ...@@ -476,3 +554,82 @@
background: #fff; background: #fff;
border: 1px solid #666; border: 1px solid #666;
} }
/* Tooltip */
/* Base styles for the element that has a tooltip */
.leaflet-tooltip {
position: absolute;
padding: 6px;
background-color: #fff;
border: 1px solid #fff;
border-radius: 3px;
color: #222;
white-space: nowrap;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
pointer-events: none;
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
}
.leaflet-tooltip.leaflet-clickable {
cursor: pointer;
pointer-events: auto;
}
.leaflet-tooltip-top:before,
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
position: absolute;
pointer-events: none;
border: 6px solid transparent;
background: transparent;
content: "";
}
/* Directions */
.leaflet-tooltip-bottom {
margin-top: 6px;
}
.leaflet-tooltip-top {
margin-top: -6px;
}
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-top:before {
left: 50%;
margin-left: -6px;
}
.leaflet-tooltip-top:before {
bottom: 0;
margin-bottom: -12px;
border-top-color: #fff;
}
.leaflet-tooltip-bottom:before {
top: 0;
margin-top: -12px;
margin-left: -6px;
border-bottom-color: #fff;
}
.leaflet-tooltip-left {
margin-left: -6px;
}
.leaflet-tooltip-right {
margin-left: 6px;
}
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
top: 50%;
margin-top: -6px;
}
.leaflet-tooltip-left:before {
right: 0;
margin-right: -12px;
border-left-color: #fff;
}
.leaflet-tooltip-right:before {
left: 0;
margin-left: -12px;
border-right-color: #fff;
}
/* ================================================================== */ .leaflet-draw-section{position:relative}.leaflet-draw-toolbar{margin-top:12px}.leaflet-draw-toolbar-top{margin-top:0}.leaflet-draw-toolbar-notop a:first-child{border-top-right-radius:0}.leaflet-draw-toolbar-nobottom a:last-child{border-bottom-right-radius:0}.leaflet-draw-toolbar a{background-image:url('images/spritesheet.png');background-image:linear-gradient(transparent,transparent),url('images/spritesheet.svg');background-repeat:no-repeat;background-size:300px 30px;background-clip:padding-box}.leaflet-retina .leaflet-draw-toolbar a{background-image:url('images/spritesheet-2x.png');background-image:linear-gradient(transparent,transparent),url('images/spritesheet.svg')}
/* Toolbars .leaflet-draw a{display:block;text-align:center;text-decoration:none}.leaflet-draw a .sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);border:0}.leaflet-draw-actions{display:none;list-style:none;margin:0;padding:0;position:absolute;left:26px;top:0;white-space:nowrap}.leaflet-touch .leaflet-draw-actions{left:32px}.leaflet-right .leaflet-draw-actions{right:26px;left:auto}.leaflet-touch .leaflet-right .leaflet-draw-actions{right:32px;left:auto}.leaflet-draw-actions li{display:inline-block}
/* ================================================================== */ .leaflet-draw-actions li:first-child a{border-left:0}.leaflet-draw-actions li:last-child a{-webkit-border-radius:0 4px 4px 0;border-radius:0 4px 4px 0}.leaflet-right .leaflet-draw-actions li:last-child a{-webkit-border-radius:0;border-radius:0}.leaflet-right .leaflet-draw-actions li:first-child a{-webkit-border-radius:4px 0 0 4px;border-radius:4px 0 0 4px}.leaflet-draw-actions a{background-color:#919187;border-left:1px solid #AAA;color:#FFF;font:11px/19px "Helvetica Neue",Arial,Helvetica,sans-serif;line-height:28px;text-decoration:none;padding-left:10px;padding-right:10px;height:28px}
.leaflet-touch .leaflet-draw-actions a{font-size:12px;line-height:30px;height:30px}.leaflet-draw-actions-bottom{margin-top:0}.leaflet-draw-actions-top{margin-top:1px}.leaflet-draw-actions-top a,.leaflet-draw-actions-bottom a{height:27px;line-height:27px}.leaflet-draw-actions a:hover{background-color:#a0a098}.leaflet-draw-actions-top.leaflet-draw-actions-bottom a{height:26px;line-height:26px}.leaflet-draw-toolbar .leaflet-draw-draw-polyline{background-position:-2px -2px}.leaflet-touch .leaflet-draw-toolbar .leaflet-draw-draw-polyline{background-position:0 -1px}
.leaflet-draw-section { .leaflet-draw-toolbar .leaflet-draw-draw-polygon{background-position:-31px -2px}.leaflet-touch .leaflet-draw-toolbar .leaflet-draw-draw-polygon{background-position:-29px -1px}.leaflet-draw-toolbar .leaflet-draw-draw-rectangle{background-position:-62px -2px}.leaflet-touch .leaflet-draw-toolbar .leaflet-draw-draw-rectangle{background-position:-60px -1px}.leaflet-draw-toolbar .leaflet-draw-draw-circle{background-position:-92px -2px}.leaflet-touch .leaflet-draw-toolbar .leaflet-draw-draw-circle{background-position:-90px -1px}
position: relative; .leaflet-draw-toolbar .leaflet-draw-draw-marker{background-position:-122px -2px}.leaflet-touch .leaflet-draw-toolbar .leaflet-draw-draw-marker{background-position:-120px -1px}.leaflet-draw-toolbar .leaflet-draw-draw-circlemarker{background-position:-273px -2px}.leaflet-touch .leaflet-draw-toolbar .leaflet-draw-draw-circlemarker{background-position:-271px -1px}.leaflet-draw-toolbar .leaflet-draw-edit-edit{background-position:-152px -2px}.leaflet-touch .leaflet-draw-toolbar .leaflet-draw-edit-edit{background-position:-150px -1px}
} .leaflet-draw-toolbar .leaflet-draw-edit-remove{background-position:-182px -2px}.leaflet-touch .leaflet-draw-toolbar .leaflet-draw-edit-remove{background-position:-180px -1px}.leaflet-draw-toolbar .leaflet-draw-edit-edit.leaflet-disabled{background-position:-212px -2px}.leaflet-touch .leaflet-draw-toolbar .leaflet-draw-edit-edit.leaflet-disabled{background-position:-210px -1px}.leaflet-draw-toolbar .leaflet-draw-edit-remove.leaflet-disabled{background-position:-242px -2px}.leaflet-touch .leaflet-draw-toolbar .leaflet-draw-edit-remove.leaflet-disabled{background-position:-240px -2px}
.leaflet-mouse-marker{background-color:#fff;cursor:crosshair}.leaflet-draw-tooltip{background:#363636;background:rgba(0,0,0,0.5);border:1px solid transparent;-webkit-border-radius:4px;border-radius:4px;color:#fff;font:12px/18px "Helvetica Neue",Arial,Helvetica,sans-serif;margin-left:20px;margin-top:-21px;padding:4px 8px;position:absolute;visibility:hidden;white-space:nowrap;z-index:6}.leaflet-draw-tooltip:before{border-right:6px solid black;border-right-color:rgba(0,0,0,0.5);border-top:6px solid transparent;border-bottom:6px solid transparent;content:"";position:absolute;top:7px;left:-7px}
.leaflet-draw-toolbar { .leaflet-error-draw-tooltip{background-color:#f2dede;border:1px solid #e6b6bd;color:#b94a48}.leaflet-error-draw-tooltip:before{border-right-color:#e6b6bd}.leaflet-draw-tooltip-single{margin-top:-12px}.leaflet-draw-tooltip-subtext{color:#f8d5e4}.leaflet-draw-guide-dash{font-size:1%;opacity:.6;position:absolute;width:5px;height:5px}.leaflet-edit-marker-selected{background-color:rgba(254,87,161,0.1);border:4px dashed rgba(254,87,161,0.6);-webkit-border-radius:4px;border-radius:4px;box-sizing:content-box}
margin-top: 12px; .leaflet-edit-move{cursor:move}.leaflet-edit-resize{cursor:pointer}.leaflet-oldie .leaflet-draw-toolbar{border:1px solid #999}
} \ No newline at end of file
.leaflet-draw-toolbar-top {
margin-top: 0;
}
.leaflet-draw-toolbar-notop a:first-child {
border-top-right-radius: 0;
}
.leaflet-draw-toolbar-nobottom a:last-child {
border-bottom-right-radius: 0;
}
.leaflet-draw-toolbar a {
background-image: url('images/spritesheet.png');
background-repeat: no-repeat;
}
.leaflet-retina .leaflet-draw-toolbar a {
background-image: url('images/spritesheet-2x.png');
background-size: 270px 30px;
}
.leaflet-draw a {
display: block;
text-align: center;
text-decoration: none;
}
/* ================================================================== */
/* Toolbar actions menu
/* ================================================================== */
.leaflet-draw-actions {
display: none;
list-style: none;
margin: 0;
padding: 0;
position: absolute;
left: 26px; /* leaflet-draw-toolbar.left + leaflet-draw-toolbar.width */
top: 0;
white-space: nowrap;
}
.leaflet-right .leaflet-draw-actions {
right:26px;
left:auto;
}
.leaflet-draw-actions li {
display: inline-block;
}
.leaflet-draw-actions li:first-child a {
border-left: none;
}
.leaflet-draw-actions li:last-child a {
-webkit-border-radius: 0 4px 4px 0;
border-radius: 0 4px 4px 0;
}
.leaflet-right .leaflet-draw-actions li:last-child a {
-webkit-border-radius: 0;
border-radius: 0;
}
.leaflet-right .leaflet-draw-actions li:first-child a {
-webkit-border-radius: 4px 0 0 4px;
border-radius: 4px 0 0 4px;
}
.leaflet-draw-actions a {
background-color: #919187;
border-left: 1px solid #AAA;
color: #FFF;
font: 11px/19px "Helvetica Neue", Arial, Helvetica, sans-serif;
line-height: 28px;
text-decoration: none;
padding-left: 10px;
padding-right: 10px;
height: 28px;
}
.leaflet-draw-actions-bottom {
margin-top: 0;
}
.leaflet-draw-actions-top {
margin-top: 1px;
}
.leaflet-draw-actions-top a,
.leaflet-draw-actions-bottom a {
height: 27px;
line-height: 27px;
}
.leaflet-draw-actions a:hover {
background-color: #A0A098;
}
.leaflet-draw-actions-top.leaflet-draw-actions-bottom a {
height: 26px;
line-height: 26px;
}
/* ================================================================== */
/* Draw toolbar
/* ================================================================== */
.leaflet-draw-toolbar .leaflet-draw-draw-polyline {
background-position: -2px -2px;
}
.leaflet-draw-toolbar .leaflet-draw-draw-polygon {
background-position: -31px -2px;
}
.leaflet-draw-toolbar .leaflet-draw-draw-rectangle {
background-position: -62px -2px;
}
.leaflet-draw-toolbar .leaflet-draw-draw-circle {
background-position: -92px -2px;
}
.leaflet-draw-toolbar .leaflet-draw-draw-marker {
background-position: -122px -2px;
}
/* ================================================================== */
/* Edit toolbar
/* ================================================================== */
.leaflet-draw-toolbar .leaflet-draw-edit-edit {
background-position: -152px -2px;
}
.leaflet-draw-toolbar .leaflet-draw-edit-remove {
background-position: -182px -2px;
}
.leaflet-draw-toolbar .leaflet-draw-edit-edit.leaflet-disabled {
background-position: -212px -2px;
}
.leaflet-draw-toolbar .leaflet-draw-edit-remove.leaflet-disabled {
background-position: -242px -2px;
}
/* ================================================================== */
/* Drawing styles
/* ================================================================== */
.leaflet-mouse-marker {
background-color: #fff;
cursor: crosshair;
}
.leaflet-draw-tooltip {
background: rgb(54, 54, 54);
background: rgba(0, 0, 0, 0.5);
border: 1px solid transparent;
-webkit-border-radius: 4px;
border-radius: 4px;
color: #fff;
font: 12px/18px "Helvetica Neue", Arial, Helvetica, sans-serif;
margin-left: 20px;
margin-top: -21px;
padding: 4px 8px;
position: absolute;
visibility: hidden;
white-space: nowrap;
z-index: 6;
}
.leaflet-draw-tooltip:before {
border-right: 6px solid black;
border-right-color: rgba(0, 0, 0, 0.5);
border-top: 6px solid transparent;
border-bottom: 6px solid transparent;
content: "";
position: absolute;
top: 7px;
left: -7px;
}
.leaflet-error-draw-tooltip {
background-color: #F2DEDE;
border: 1px solid #E6B6BD;
color: #B94A48;
}
.leaflet-error-draw-tooltip:before {
border-right-color: #E6B6BD;
}
.leaflet-draw-tooltip-single {
margin-top: -12px
}
.leaflet-draw-tooltip-subtext {
color: #f8d5e4;
}
.leaflet-draw-guide-dash {
font-size: 1%;
opacity: 0.6;
position: absolute;
width: 5px;
height: 5px;
}
/* ================================================================== */
/* Edit styles
/* ================================================================== */
.leaflet-edit-marker-selected {
background: rgba(254, 87, 161, 0.1);
border: 4px dashed rgba(254, 87, 161, 0.6);
-webkit-border-radius: 4px;
border-radius: 4px;
}
.leaflet-edit-move {
cursor: move;
}
.leaflet-edit-resize {
cursor: pointer;
}
/* ================================================================== */
/* Old IE styles
/* ================================================================== */
.leaflet-oldie .leaflet-draw-toolbar {
border: 3px solid #999;
}
.leaflet-oldie .leaflet-draw-toolbar a {
background-color: #eee;
}
.leaflet-oldie .leaflet-draw-toolbar a:hover {
background-color: #fff;
}
.leaflet-oldie .leaflet-draw-actions {
left: 32px;
margin-top: 3px;
}
.leaflet-oldie .leaflet-draw-actions li {
display: inline;
zoom: 1;
}
.leaflet-oldie .leaflet-edit-marker-selected {
border: 4px dashed #fe93c2;
}
.leaflet-oldie .leaflet-draw-actions a {
background-color: #999;
}
.leaflet-oldie .leaflet-draw-actions a:hover {
background-color: #a5a5a5;
}
.leaflet-oldie .leaflet-draw-actions-top a {
margin-top: 1px;
}
.leaflet-oldie .leaflet-draw-actions-bottom a {
height: 28px;
line-height: 28px;
}
.leaflet-oldie .leaflet-draw-actions-top.leaflet-draw-actions-bottom a {
height: 27px;
line-height: 27px;
}
This source diff could not be displayed because it is too large. You can view the blob instead.
This source diff could not be displayed because it is too large. You can view the blob instead.
This source diff could not be displayed because it is too large. You can view the blob instead.
...@@ -27,9 +27,8 @@ ...@@ -27,9 +27,8 @@
{% macro script(message, actions, actions_confirmation) %} {% macro script(message, actions, actions_confirmation) %}
{% if actions %} {% if actions %}
<script src="{{ admin_static.url(filename='admin/js/actions.js', v='1.0.0') }}"></script> <div id="actions-confirmation-data" style="display:none;">{{ actions_confirmation|tojson|safe }}</div>
<script language="javascript"> <div id="message-data" style="display:none;">{{ message|tojson|safe }}</div>
var modelActions = new AdminModelActions({{ message|tojson|safe }}, {{ actions_confirmation|tojson|safe }}); <script src="{{ admin_static.url(filename='admin/js/actions.js', v='1.0.0') }}"></script>
</script>
{% endif %} {% endif %}
{% endmacro %} {% endmacro %}
...@@ -20,25 +20,33 @@ ...@@ -20,25 +20,33 @@
{%- if item.is_category() -%} {%- if item.is_category() -%}
{% set children = item.get_children() %} {% set children = item.get_children() %}
{%- if children %} {%- if children %}
{% set class_name = item.get_class_name() %} {% set class_name = item.get_class_name() or '' %}
{%- if item.is_active(admin_view) %} {%- if item.is_active(admin_view) %}
<li class="active dropdown"> <li class="active dropdown{% if class_name %} {{class_name}}{% endif %}">
{% else -%} {% else -%}
<li class="dropdown"> <li class="dropdown{% if class_name %} {{class_name}}{% endif %}">
{%- endif %} {%- endif %}
<a class="dropdown-toggle" data-toggle="dropdown" href="javascript:void(0)"> <a class="dropdown-toggle" data-toggle="dropdown" href="javascript:void(0)">
{% if item.class_name %}<i class="{{ item.class_name }}"></i> {% endif %}{{ item.name }}<b class="caret"></b> {% if item.class_name %}<i class="{{ item.class_name }}"></i> {% endif %}
{{ menu_icon(item) }}{{ item.name }}
{%- if 'dropdown-submenu' not in class_name -%}<b class="caret"></b>{%- endif -%}
</a> </a>
<ul class="dropdown-menu"> <ul class="dropdown-menu">
{%- for child in children -%} {%- for child in children -%}
{% set class_name = child.get_class_name() %} {%- if child.is_category() -%}
{%- if child.is_active(admin_view) %} {{ menu(menu_root=[child]) }}
<li class="active{% if class_name %} {{class_name}}{% endif %}">
{% else %} {% else %}
<li{% if class_name %} class="{{class_name}}"{% endif %}> {% set class_name = child.get_class_name() %}
{%- if child.is_active(admin_view) %}
<li class="active{% if class_name %} {{class_name}}{% endif %}">
{% else %}
<li{% if class_name %} class="{{class_name}}"{% endif %}>
{%- endif %}
<a href="{{ child.get_url() }}"{% if child.target %}
target="{{ child.target }}"{% endif %}>
{{ menu_icon(child) }}{{ child.name }}</a>
</li>
{%- endif %} {%- endif %}
<a href="{{ child.get_url() }}"{% if child.target %} target="{{ child.target }}"{% endif %}>{{ menu_icon(child) }}{{ child.name }}</a>
</li>
{%- endfor %} {%- endfor %}
</ul> </ul>
</li> </li>
...@@ -61,8 +69,9 @@ ...@@ -61,8 +69,9 @@
{% macro menu_links(links=None) %} {% macro menu_links(links=None) %}
{% if links is none %}{% set links = admin_view.admin.menu_links() %}{% endif %} {% if links is none %}{% set links = admin_view.admin.menu_links() %}{% endif %}
{% for item in links %} {% for item in links %}
{% set class_name = item.get_class_name() %}
{% if item.is_accessible() and item.is_visible() %} {% if item.is_accessible() and item.is_visible() %}
<li> <li{% if class_name %} class="{{ class_name }}"{% endif %}>
<a href="{{ item.get_url() }}">{{ menu_icon(item) }}{{ item.name }}</a> <a href="{{ item.get_url() }}">{{ menu_icon(item) }}{{ item.name }}</a>
</li> </li>
{% endif %} {% endif %}
......
...@@ -219,8 +219,8 @@ ...@@ -219,8 +219,8 @@
<link href="{{ admin_static.url(filename='vendor/select2/select2.css', v='3.5.2') }}" rel="stylesheet"> <link href="{{ admin_static.url(filename='vendor/select2/select2.css', v='3.5.2') }}" rel="stylesheet">
<link href="{{ admin_static.url(filename='vendor/bootstrap-daterangepicker/daterangepicker-bs2.css', v='1.3.22') }}" rel="stylesheet"> <link href="{{ admin_static.url(filename='vendor/bootstrap-daterangepicker/daterangepicker-bs2.css', v='1.3.22') }}" rel="stylesheet">
{% if config.MAPBOX_MAP_ID %} {% if config.MAPBOX_MAP_ID %}
<link href="{{ admin_static.url(filename='vendor/leaflet/leaflet.css', v='1.0.0') }}" rel="stylesheet"> <link href="{{ admin_static.url(filename='vendor/leaflet/leaflet.css', v='1.0.2') }}" rel="stylesheet">
<link href="{{ admin_static.url(filename='vendor/leaflet/leaflet.draw.css', v='0.3.2') }}" rel="stylesheet"> <link href="{{ admin_static.url(filename='vendor/leaflet/leaflet.draw.css', v='0.4.6') }}" rel="stylesheet">
{% endif %} {% endif %}
{% if editable_columns %} {% if editable_columns %}
<link href="{{ admin_static.url(filename='vendor/x-editable/css/bootstrap2-editable.css', v='1.5.1.1') }}" rel="stylesheet"> <link href="{{ admin_static.url(filename='vendor/x-editable/css/bootstrap2-editable.css', v='1.5.1.1') }}" rel="stylesheet">
...@@ -234,9 +234,13 @@ ...@@ -234,9 +234,13 @@
{% if config.MAPBOX_ACCESS_TOKEN %} {% if config.MAPBOX_ACCESS_TOKEN %}
window.MAPBOX_ACCESS_TOKEN = "{{ config.MAPBOX_ACCESS_TOKEN }}"; window.MAPBOX_ACCESS_TOKEN = "{{ config.MAPBOX_ACCESS_TOKEN }}";
{% endif %} {% endif %}
{% if config.DEFAULT_CENTER_LAT and config.DEFAULT_CENTER_LONG %}
window.DEFAULT_CENTER_LAT = "{{ config.DEFAULT_CENTER_LAT }}";
window.DEFAULT_CENTER_LONG = "{{ config.DEFAULT_CENTER_LONG }}";
{% endif %}
</script> </script>
<script src="{{ admin_static.url(filename='vendor/leaflet/leaflet.js', v='0.7.3') }}"></script> <script src="{{ admin_static.url(filename='vendor/leaflet/leaflet.js', v='1.0.2') }}"></script>
<script src="{{ admin_static.url(filename='vendor/leaflet/leaflet.draw.js', v='0.2.3') }}"></script> <script src="{{ admin_static.url(filename='vendor/leaflet/leaflet.draw.js', v='0.4.6') }}"></script>
{% if config.MAPBOX_SEARCH %} {% if config.MAPBOX_SEARCH %}
<script> <script>
window.MAPBOX_SEARCH = "{{ config.MAPBOX_SEARCH }}"; window.MAPBOX_SEARCH = "{{ config.MAPBOX_SEARCH }}";
......
...@@ -74,14 +74,14 @@ ...@@ -74,14 +74,14 @@
{% endif %} {% endif %}
{% if search %} {% if search %}
<div class="input-append"> <div class="input-append">
<input type="text" name="search" value="{{ search }}" class="{{ input_class }}" placeholder="{{ _gettext('Search') }}"> <input type="text" name="search" value="{{ search }}" class="{{ input_class }}" placeholder="{{ _gettext('%(placeholder)s', placeholder=search_placeholder) }}">
<a href="{{ clear_search_url }}" class="clear add-on"> <a href="{{ clear_search_url }}" class="clear add-on">
<i class="fa fa-times icon-remove"></i> <i class="fa fa-times icon-remove"></i>
</a> </a>
</div> </div>
{% else %} {% else %}
<div> <div>
<input type="text" name="search" value="" class="{{ input_class }}" placeholder="{{ _gettext('Search') }}"> <input type="text" name="search" value="" class="{{ input_class }}" placeholder="{{ _gettext('%(placeholder)s', placeholder=search_placeholder) }}">
</div> </div>
{% endif %} {% endif %}
</form> </form>
......
...@@ -179,26 +179,16 @@ ...@@ -179,26 +179,16 @@
{% block tail %} {% block tail %}
{{ super() }} {{ super() }}
{% if filter_groups %}
<div id="filter-groups-data" style="display:none;">{{ filter_groups|tojson|safe }}</div>
<div id="active-filters-data" style="display:none;">{{ active_filters|tojson|safe }}</div>
{% endif %}
{{ lib.form_js() }} {{ lib.form_js() }}
<script src="{{ admin_static.url(filename='admin/js/filters.js', v='1.0.0') }}"></script> <script src="{{ admin_static.url(filename='admin/js/filters.js', v='1.0.0') }}"></script>
{{ actionlib.script(_gettext('Please select at least one record.'), {{ actionlib.script(_gettext('Please select at least one record.'),
actions, actions,
actions_confirmation) }} actions_confirmation) }}
<script language="javascript">
(function($) {
$('[data-role=tooltip]').tooltip({
html: true,
placement: 'bottom'
});
{% if filter_groups %}
var filter = new AdminFilters(
'#filter_form', '.field-filters',
{{ filter_groups|tojson|safe }},
{{ active_filters|tojson|safe }}
);
{% endif %}
})(jQuery);
</script>
{% endblock %} {% endblock %}
...@@ -21,10 +21,5 @@ ...@@ -21,10 +21,5 @@
$('.modal-header h3').html('{% block header_text -%} $('.modal-header h3').html('{% block header_text -%}
<h3>{{ _gettext('Create New Record') }}</h3> <h3>{{ _gettext('Create New Record') }}</h3>
{%- endblock %}'); {%- endblock %}');
$(function() {
// Apply flask-admin form styles after the modal is loaded
window.faForm.applyGlobalStyles(document);
});
</script> </script>
{% endblock %} {% endblock %}
...@@ -21,10 +21,5 @@ ...@@ -21,10 +21,5 @@
$('.modal-header h3').html('{% block header_text -%} $('.modal-header h3').html('{% block header_text -%}
{{ _gettext('Edit Record') + ' #' + request.args.get('id') }} {{ _gettext('Edit Record') + ' #' + request.args.get('id') }}
{%- endblock %}'); {%- endblock %}');
$(function() {
// Apply flask-admin form styles after the modal is loaded
window.faForm.applyGlobalStyles(document);
});
</script> </script>
{% endblock %} {% endblock %}
...@@ -21,10 +21,7 @@ ...@@ -21,10 +21,7 @@
{% block tail %} {% block tail %}
{{ super() }} {{ super() }}
<div id="execute-view-data" style="display:none;">{{ admin_view.get_url('.execute_view')|tojson|safe }}</div>
<script src="{{ admin_static.url(filename='admin/js/rediscli.js', v='1.0.0') }}"></script> <script src="{{ admin_static.url(filename='admin/js/rediscli.js', v='1.0.0') }}"></script>
<script language="javascript">
$(function() {
var redisCli = new RedisCli({{ get_url('.execute_view')|tojson }});
});
</script>
{% endblock %} {% endblock %}
...@@ -27,9 +27,8 @@ ...@@ -27,9 +27,8 @@
{% macro script(message, actions, actions_confirmation) %} {% macro script(message, actions, actions_confirmation) %}
{% if actions %} {% if actions %}
<script src="{{ admin_static.url(filename='admin/js/actions.js', v='1.0.0') }}"></script> <div id="actions-confirmation-data" style="display:none;">{{ actions_confirmation|tojson|safe }}</div>
<script language="javascript"> <div id="message-data" style="display:none;">{{ message|tojson|safe }}</div>
var modelActions = new AdminModelActions({{ message|tojson|safe }}, {{ actions_confirmation|tojson|safe }}); <script src="{{ admin_static.url(filename='admin/js/actions.js', v='1.0.0') }}"></script>
</script>
{% endif %} {% endif %}
{% endmacro %} {% endmacro %}
...@@ -17,6 +17,7 @@ ...@@ -17,6 +17,7 @@
<link href="{{ admin_static.url(filename='bootstrap/bootstrap3/css/bootstrap-theme.min.css', v='3.3.5') }}" rel="stylesheet"> <link href="{{ admin_static.url(filename='bootstrap/bootstrap3/css/bootstrap-theme.min.css', v='3.3.5') }}" rel="stylesheet">
{%endif%} {%endif%}
<link href="{{ admin_static.url(filename='admin/css/bootstrap3/admin.css', v='1.1.1') }}" rel="stylesheet"> <link href="{{ admin_static.url(filename='admin/css/bootstrap3/admin.css', v='1.1.1') }}" rel="stylesheet">
<link href="{{ admin_static.url(filename='admin/css/bootstrap3/submenu.css') }}" rel="stylesheet">
{% if admin_view.extra_css %} {% if admin_view.extra_css %}
{% for css_url in admin_view.extra_css %} {% for css_url in admin_view.extra_css %}
<link href="{{ css_url }}" rel="stylesheet"> <link href="{{ css_url }}" rel="stylesheet">
......
...@@ -20,25 +20,37 @@ ...@@ -20,25 +20,37 @@
{%- if item.is_category() -%} {%- if item.is_category() -%}
{% set children = item.get_children() %} {% set children = item.get_children() %}
{%- if children %} {%- if children %}
{% set class_name = item.get_class_name() %} {% set class_name = item.get_class_name() or '' %}
{%- if item.is_active(admin_view) %} {%- if item.is_active(admin_view) %}
<li class="active dropdown"> <li class="active dropdown{% if class_name %} {{class_name}}{% endif %}">
{% else -%} {% else -%}
<li class="dropdown"> <li class="dropdown{% if class_name %} {{class_name}}{% endif %}">
{%- endif %} {%- endif %}
<a class="dropdown-toggle" data-toggle="dropdown" href="javascript:void(0)"> <a class="dropdown-toggle" data-toggle="dropdown" href="javascript:void(0)">
{% if item.class_name %}<span class="{{ item.class_name }}"></span> {% endif %}{{ item.name }}<b class="caret"></b> {% if item.class_name %}<span class="{{ item.class_name }}"></span> {% endif %}
{{ menu_icon(item) }}{{ item.name }}
{%- if 'dropdown-submenu' in class_name -%}
<i class="glyphicon glyphicon-chevron-right small"></i>
{%- else -%}
<i class="glyphicon glyphicon-chevron-down small"></i>
{%- endif -%}
</a> </a>
<ul class="dropdown-menu"> <ul class="dropdown-menu">
{%- for child in children -%} {%- for child in children -%}
{% set class_name = child.get_class_name() %} {%- if child.is_category() -%}
{%- if child.is_active(admin_view) %} {{ menu(menu_root=[child]) }}
<li class="active{% if class_name %} {{class_name}}{% endif %}">
{% else %} {% else %}
<li{% if class_name %} class="{{class_name}}"{% endif %}> {% set class_name = child.get_class_name() %}
{%- if child.is_active(admin_view) %}
<li class="active{% if class_name %} {{class_name}}{% endif %}">
{% else %}
<li{% if class_name %} class="{{class_name}}"{% endif %}>
{%- endif %}
<a href="{{ child.get_url() }}"{% if child.target %}
target="{{ child.target }}"{% endif %}>
{{ menu_icon(child) }}{{ child.name }}</a>
</li>
{%- endif %} {%- endif %}
<a href="{{ child.get_url() }}"{% if child.target %} target="{{ child.target }}"{% endif %}>{{ menu_icon(child) }}{{ child.name }}</a>
</li>
{%- endfor %} {%- endfor %}
</ul> </ul>
</li> </li>
...@@ -61,8 +73,9 @@ ...@@ -61,8 +73,9 @@
{% macro menu_links(links=None) %} {% macro menu_links(links=None) %}
{% if links is none %}{% set links = admin_view.admin.menu_links() %}{% endif %} {% if links is none %}{% set links = admin_view.admin.menu_links() %}{% endif %}
{% for item in links %} {% for item in links %}
{% set class_name = item.get_class_name() %}
{% if item.is_accessible() and item.is_visible() %} {% if item.is_accessible() and item.is_visible() %}
<li> <li{% if class_name %} class="{{ class_name }}"{% endif %}>
<a href="{{ item.get_url() }}">{{ menu_icon(item) }}{{ item.name }}</a> <a href="{{ item.get_url() }}">{{ menu_icon(item) }}{{ item.name }}</a>
</li> </li>
{% endif %} {% endif %}
......
...@@ -210,8 +210,8 @@ ...@@ -210,8 +210,8 @@
<link href="{{ admin_static.url(filename='vendor/select2/select2-bootstrap3.css', v='1.4.6') }}" rel="stylesheet"> <link href="{{ admin_static.url(filename='vendor/select2/select2-bootstrap3.css', v='1.4.6') }}" rel="stylesheet">
<link href="{{ admin_static.url(filename='vendor/bootstrap-daterangepicker/daterangepicker-bs3.css', v='1.3.22') }}" rel="stylesheet"> <link href="{{ admin_static.url(filename='vendor/bootstrap-daterangepicker/daterangepicker-bs3.css', v='1.3.22') }}" rel="stylesheet">
{% if config.MAPBOX_MAP_ID %} {% if config.MAPBOX_MAP_ID %}
<link href="{{ admin_static.url(filename='vendor/leaflet/leaflet.css', v='1.0.0') }}" rel="stylesheet"> <link href="{{ admin_static.url(filename='vendor/leaflet/leaflet.css', v='1.0.2') }}" rel="stylesheet">
<link href="{{ admin_static.url(filename='vendor/leaflet/leaflet.draw.css', v='0.3.2') }}" rel="stylesheet"> <link href="{{ admin_static.url(filename='vendor/leaflet/leaflet.draw.css', v='0.4.6') }}" rel="stylesheet">
{% endif %} {% endif %}
{% if editable_columns %} {% if editable_columns %}
<link href="{{ admin_static.url(filename='vendor/x-editable/css/bootstrap3-editable.css', v='1.5.1.1') }}" rel="stylesheet"> <link href="{{ admin_static.url(filename='vendor/x-editable/css/bootstrap3-editable.css', v='1.5.1.1') }}" rel="stylesheet">
...@@ -225,9 +225,13 @@ ...@@ -225,9 +225,13 @@
{% if config.MAPBOX_ACCESS_TOKEN %} {% if config.MAPBOX_ACCESS_TOKEN %}
window.MAPBOX_ACCESS_TOKEN = "{{ config.MAPBOX_ACCESS_TOKEN }}"; window.MAPBOX_ACCESS_TOKEN = "{{ config.MAPBOX_ACCESS_TOKEN }}";
{% endif %} {% endif %}
{% if config.DEFAULT_CENTER_LAT and config.DEFAULT_CENTER_LONG %}
window.DEFAULT_CENTER_LAT = "{{ config.DEFAULT_CENTER_LAT }}";
window.DEFAULT_CENTER_LONG = "{{ config.DEFAULT_CENTER_LONG }}";
{% endif %}
</script> </script>
<script src="{{ admin_static.url(filename='vendor/leaflet/leaflet.js', v='1.0.0') }}"></script> <script src="{{ admin_static.url(filename='vendor/leaflet/leaflet.js', v='1.0.2') }}"></script>
<script src="{{ admin_static.url(filename='vendor/leaflet/leaflet.draw.js', v='0.3.2') }}"></script> <script src="{{ admin_static.url(filename='vendor/leaflet/leaflet.draw.js', v='0.4.6') }}"></script>
{% if config.MAPBOX_SEARCH %} {% if config.MAPBOX_SEARCH %}
<script> <script>
window.MAPBOX_SEARCH = "{{ config.MAPBOX_SEARCH }}"; window.MAPBOX_SEARCH = "{{ config.MAPBOX_SEARCH }}";
......
...@@ -74,12 +74,12 @@ ...@@ -74,12 +74,12 @@
{% endif %} {% endif %}
{% if search %} {% if search %}
<div class="input-group"> <div class="input-group">
<input type="text" name="search" value="{{ search }}" class="{{ input_class }} form-control" placeholder="{{ _gettext('Search') }}"> <input type="text" name="search" value="{{ search }}" class="{{ input_class }} form-control" placeholder="{{ _gettext('%(placeholder)s', placeholder=search_placeholder) }}">
<a href="{{ clear_search_url }}" class="input-group-addon clear"><span class="fa fa-times glyphicon glyphicon-remove"></span></a> <a href="{{ clear_search_url }}" class="input-group-addon clear"><span class="fa fa-times glyphicon glyphicon-remove"></span></a>
</div> </div>
{% else %} {% else %}
<div class="form-group"> <div class="form-group">
<input type="text" name="search" value="" class="{{ input_class }} form-control" placeholder="{{ _gettext('Search') }}"> <input type="text" name="search" value="" class="{{ input_class }} form-control" placeholder="{{ _gettext('%(placeholder)s', placeholder=search_placeholder) }}">
</div> </div>
{% endif %} {% endif %}
</form> </form>
......
...@@ -180,43 +180,16 @@ ...@@ -180,43 +180,16 @@
{% block tail %} {% block tail %}
{{ super() }} {{ super() }}
<script src="{{ admin_static.url(filename='admin/js/filters.js', v='1.0.0') }}"></script>
{% if filter_groups %}
<div id="filter-groups-data" style="display:none;">{{ filter_groups|tojson|safe }}</div>
<div id="active-filters-data" style="display:none;">{{ active_filters|tojson|safe }}</div>
{% endif %}
{{ lib.form_js() }} {{ lib.form_js() }}
<script src="{{ admin_static.url(filename='admin/js/filters.js', v='1.0.0') }}"></script>
{{ actionlib.script(_gettext('Please select at least one record.'), {{ actionlib.script(_gettext('Please select at least one record.'),
actions, actions,
actions_confirmation) }} actions_confirmation) }}
<script language="javascript">
(function($) {
$('[data-role=tooltip]').tooltip({
html: true,
placement: 'bottom'
});
{% if filter_groups %}
var filter = new AdminFilters(
'#filter_form', '.field-filters',
{{ filter_groups|tojson|safe }},
{{ active_filters|tojson|safe }}
);
{% endif %}
})(jQuery);
// Catch exception when closing dialog with <esc> key
// and prevent accidental deletions.
function safeConfirm(msg) {
try {
var isconfirmed = confirm(msg);
if (isconfirmed == true) {
return true;
}
else {
return false;
}
}
catch(err) {
return false;
}
}
</script>
{% endblock %} {% endblock %}
...@@ -21,11 +21,4 @@ ...@@ -21,11 +21,4 @@
{% block tail %} {% block tail %}
<script src="{{ admin_static.url(filename='admin/js/bs3_modal.js', v='1.0.0') }}"></script> <script src="{{ admin_static.url(filename='admin/js/bs3_modal.js', v='1.0.0') }}"></script>
<script>
$(function() {
// Apply flask-admin form styles after the modal is loaded
window.faForm.applyGlobalStyles(document);
});
</script>
{% endblock %} {% endblock %}
...@@ -23,11 +23,4 @@ ...@@ -23,11 +23,4 @@
{% block tail %} {% block tail %}
<script src="{{ admin_static.url(filename='admin/js/bs3_modal.js', v='1.0.0') }}"></script> <script src="{{ admin_static.url(filename='admin/js/bs3_modal.js', v='1.0.0') }}"></script>
<script>
$(function() {
// Apply flask-admin form styles after the modal is loaded
window.faForm.applyGlobalStyles(document);
});
</script>
{% endblock %} {% endblock %}
...@@ -31,7 +31,7 @@ ...@@ -31,7 +31,7 @@
{% elif csrf_token %} {% elif csrf_token %}
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
{% endif %} {% endif %}
<button onclick="return safeConfirm('{{ _gettext('Are you sure you want to delete this record?') }}');" title="Delete record"> <button onclick="return safeConfirm('{{ _gettext('Are you sure you want to delete this record?') }}');" title="{{ _gettext('Delete record') }}">
<span class="fa fa-trash glyphicon glyphicon-trash"></span> <span class="fa fa-trash glyphicon glyphicon-trash"></span>
</button> </button>
</form> </form>
......
...@@ -21,10 +21,7 @@ ...@@ -21,10 +21,7 @@
{% block tail %} {% block tail %}
{{ super() }} {{ super() }}
<div id="execute-view-data" style="display:none;">{{ admin_view.get_url('.execute_view')|tojson|safe }}</div>
<script src="{{ admin_static.url(filename='admin/js/rediscli.js', v='1.0.0') }}"></script> <script src="{{ admin_static.url(filename='admin/js/rediscli.js', v='1.0.0') }}"></script>
<script language="javascript">
$(function() {
var redisCli = new RedisCli({{ admin_view.get_url('.execute_view')|tojson }});
});
</script>
{% endblock %} {% endblock %}
import os
import os.path as op import os.path as op
import unittest
from nose.tools import eq_, ok_ from nose.tools import eq_, ok_
...@@ -14,178 +16,230 @@ except ImportError: ...@@ -14,178 +16,230 @@ except ImportError:
from io import StringIO from io import StringIO
def create_view(): class Base:
app, admin = setup() class FileAdminTests(unittest.TestCase):
_test_files_root = op.join(op.dirname(__file__), 'files')
class MyFileAdmin(fileadmin.FileAdmin):
editable_extensions = ('txt',)
path = op.join(op.dirname(__file__), 'files')
view = MyFileAdmin(path, '/files/', name='Files')
admin.add_view(view)
return app, admin, view
def test_file_admin():
app, admin, view = create_view()
client = app.test_client()
# index
rv = client.get('/admin/myfileadmin/')
eq_(rv.status_code, 200)
ok_('path=dummy.txt' in rv.data.decode('utf-8'))
# edit
rv = client.get('/admin/myfileadmin/edit/?path=dummy.txt')
eq_(rv.status_code, 200)
ok_('dummy.txt' in rv.data.decode('utf-8'))
rv = client.post('/admin/myfileadmin/edit/?path=dummy.txt', data=dict(
content='new_string'
))
eq_(rv.status_code, 302)
rv = client.get('/admin/myfileadmin/edit/?path=dummy.txt')
eq_(rv.status_code, 200)
ok_('dummy.txt' in rv.data.decode('utf-8'))
ok_('new_string' in rv.data.decode('utf-8'))
# rename
rv = client.get('/admin/myfileadmin/rename/?path=dummy.txt')
eq_(rv.status_code, 200)
ok_('dummy.txt' in rv.data.decode('utf-8'))
rv = client.post('/admin/myfileadmin/rename/?path=dummy.txt', data=dict( def fileadmin_class(self):
name='dummy_renamed.txt', raise NotImplementedError
path='dummy.txt'
))
eq_(rv.status_code, 302)
rv = client.get('/admin/myfileadmin/') def fileadmin_args(self):
eq_(rv.status_code, 200) raise NotImplementedError
ok_('path=dummy_renamed.txt' in rv.data.decode('utf-8'))
ok_('path=dummy.txt' not in rv.data.decode('utf-8')) def test_file_admin(self):
fileadmin_class = self.fileadmin_class()
# upload fileadmin_args, fileadmin_kwargs = self.fileadmin_args()
rv = client.get('/admin/myfileadmin/upload/')
eq_(rv.status_code, 200) app, admin = setup()
rv = client.post('/admin/myfileadmin/upload/', data=dict( class MyFileAdmin(fileadmin_class):
upload=(StringIO(""), 'dummy.txt'), editable_extensions = ('txt',)
))
eq_(rv.status_code, 302) view_kwargs = dict(fileadmin_kwargs)
view_kwargs.setdefault('name', 'Files')
rv = client.get('/admin/myfileadmin/') view = MyFileAdmin(*fileadmin_args, **view_kwargs)
eq_(rv.status_code, 200)
ok_('path=dummy.txt' in rv.data.decode('utf-8')) admin.add_view(view)
ok_('path=dummy_renamed.txt' in rv.data.decode('utf-8'))
client = app.test_client()
# delete
rv = client.post('/admin/myfileadmin/delete/', data=dict( # index
path='dummy_renamed.txt' rv = client.get('/admin/myfileadmin/')
)) eq_(rv.status_code, 200)
eq_(rv.status_code, 302) ok_('path=dummy.txt' in rv.data.decode('utf-8'))
rv = client.get('/admin/myfileadmin/') # edit
eq_(rv.status_code, 200) rv = client.get('/admin/myfileadmin/edit/?path=dummy.txt')
ok_('path=dummy_renamed.txt' not in rv.data.decode('utf-8')) eq_(rv.status_code, 200)
ok_('path=dummy.txt' in rv.data.decode('utf-8')) ok_('dummy.txt' in rv.data.decode('utf-8'))
# mkdir rv = client.post('/admin/myfileadmin/edit/?path=dummy.txt',
rv = client.get('/admin/myfileadmin/mkdir/') data=dict(content='new_string'))
eq_(rv.status_code, 200) eq_(rv.status_code, 302)
rv = client.post('/admin/myfileadmin/mkdir/', data=dict( rv = client.get('/admin/myfileadmin/edit/?path=dummy.txt')
name='dummy_dir' eq_(rv.status_code, 200)
)) ok_('dummy.txt' in rv.data.decode('utf-8'))
eq_(rv.status_code, 302) ok_('new_string' in rv.data.decode('utf-8'))
rv = client.get('/admin/myfileadmin/') # rename
eq_(rv.status_code, 200) rv = client.get('/admin/myfileadmin/rename/?path=dummy.txt')
ok_('path=dummy.txt' in rv.data.decode('utf-8')) eq_(rv.status_code, 200)
ok_('path=dummy_dir' in rv.data.decode('utf-8')) ok_('dummy.txt' in rv.data.decode('utf-8'))
# rename - directory rv = client.post('/admin/myfileadmin/rename/?path=dummy.txt',
rv = client.get('/admin/myfileadmin/rename/?path=dummy_dir') data=dict(name='dummy_renamed.txt',
eq_(rv.status_code, 200) path='dummy.txt'))
ok_('dummy_dir' in rv.data.decode('utf-8')) eq_(rv.status_code, 302)
rv = client.post('/admin/myfileadmin/rename/?path=dummy_dir', data=dict( rv = client.get('/admin/myfileadmin/')
name='dummy_renamed_dir', eq_(rv.status_code, 200)
path='dummy_dir' ok_('path=dummy_renamed.txt' in rv.data.decode('utf-8'))
)) ok_('path=dummy.txt' not in rv.data.decode('utf-8'))
eq_(rv.status_code, 302)
# upload
rv = client.get('/admin/myfileadmin/') rv = client.get('/admin/myfileadmin/upload/')
eq_(rv.status_code, 200) eq_(rv.status_code, 200)
ok_('path=dummy_renamed_dir' in rv.data.decode('utf-8'))
ok_('path=dummy_dir' not in rv.data.decode('utf-8')) rv = client.post('/admin/myfileadmin/upload/',
data=dict(upload=(StringIO(""), 'dummy.txt')))
# delete - directory eq_(rv.status_code, 302)
rv = client.post('/admin/myfileadmin/delete/', data=dict(
path='dummy_renamed_dir' rv = client.get('/admin/myfileadmin/')
)) eq_(rv.status_code, 200)
eq_(rv.status_code, 302) ok_('path=dummy.txt' in rv.data.decode('utf-8'))
ok_('path=dummy_renamed.txt' in rv.data.decode('utf-8'))
rv = client.get('/admin/myfileadmin/')
eq_(rv.status_code, 200) # delete
ok_('path=dummy_renamed_dir' not in rv.data.decode('utf-8')) rv = client.post('/admin/myfileadmin/delete/',
ok_('path=dummy.txt' in rv.data.decode('utf-8')) data=dict(path='dummy_renamed.txt'))
eq_(rv.status_code, 302)
def test_modal_edit(): rv = client.get('/admin/myfileadmin/')
# bootstrap 2 - test edit_modal eq_(rv.status_code, 200)
app_bs2 = Flask(__name__) ok_('path=dummy_renamed.txt' not in rv.data.decode('utf-8'))
admin_bs2 = Admin(app_bs2, template_mode="bootstrap2") ok_('path=dummy.txt' in rv.data.decode('utf-8'))
class EditModalOn(fileadmin.FileAdmin): # mkdir
edit_modal = True rv = client.get('/admin/myfileadmin/mkdir/')
editable_extensions = ('txt',) eq_(rv.status_code, 200)
class EditModalOff(fileadmin.FileAdmin): rv = client.post('/admin/myfileadmin/mkdir/',
edit_modal = False data=dict(name='dummy_dir'))
editable_extensions = ('txt',) eq_(rv.status_code, 302)
path = op.join(op.dirname(__file__), 'files') rv = client.get('/admin/myfileadmin/')
edit_modal_on = EditModalOn(path, '/files/', endpoint='edit_modal_on') eq_(rv.status_code, 200)
edit_modal_off = EditModalOff(path, '/files/', endpoint='edit_modal_off') ok_('path=dummy.txt' in rv.data.decode('utf-8'))
ok_('path=dummy_dir' in rv.data.decode('utf-8'))
admin_bs2.add_view(edit_modal_on)
admin_bs2.add_view(edit_modal_off) # rename - directory
rv = client.get('/admin/myfileadmin/rename/?path=dummy_dir')
client_bs2 = app_bs2.test_client() eq_(rv.status_code, 200)
ok_('dummy_dir' in rv.data.decode('utf-8'))
# bootstrap 2 - ensure modal window is added when edit_modal is enabled
rv = client_bs2.get('/admin/edit_modal_on/') rv = client.post('/admin/myfileadmin/rename/?path=dummy_dir',
eq_(rv.status_code, 200) data=dict(name='dummy_renamed_dir',
data = rv.data.decode('utf-8') path='dummy_dir'))
ok_('fa_modal_window' in data) eq_(rv.status_code, 302)
# bootstrap 2 - test edit modal disabled rv = client.get('/admin/myfileadmin/')
rv = client_bs2.get('/admin/edit_modal_off/') eq_(rv.status_code, 200)
eq_(rv.status_code, 200) ok_('path=dummy_renamed_dir' in rv.data.decode('utf-8'))
data = rv.data.decode('utf-8') ok_('path=dummy_dir' not in rv.data.decode('utf-8'))
ok_('fa_modal_window' not in data)
# delete - directory
# bootstrap 3 rv = client.post('/admin/myfileadmin/delete/',
app_bs3 = Flask(__name__) data=dict(path='dummy_renamed_dir'))
admin_bs3 = Admin(app_bs3, template_mode="bootstrap3") eq_(rv.status_code, 302)
admin_bs3.add_view(edit_modal_on) rv = client.get('/admin/myfileadmin/')
admin_bs3.add_view(edit_modal_off) eq_(rv.status_code, 200)
ok_('path=dummy_renamed_dir' not in rv.data.decode('utf-8'))
client_bs3 = app_bs3.test_client() ok_('path=dummy.txt' in rv.data.decode('utf-8'))
# bootstrap 3 - ensure modal window is added when edit_modal is enabled def test_modal_edit(self):
rv = client_bs3.get('/admin/edit_modal_on/') # bootstrap 2 - test edit_modal
eq_(rv.status_code, 200) app_bs2 = Flask(__name__)
data = rv.data.decode('utf-8') admin_bs2 = Admin(app_bs2, template_mode="bootstrap2")
ok_('fa_modal_window' in data)
fileadmin_class = self.fileadmin_class()
# bootstrap 3 - test modal disabled fileadmin_args, fileadmin_kwargs = self.fileadmin_args()
rv = client_bs3.get('/admin/edit_modal_off/')
eq_(rv.status_code, 200) class EditModalOn(fileadmin_class):
data = rv.data.decode('utf-8') edit_modal = True
ok_('fa_modal_window' not in data) editable_extensions = ('txt',)
class EditModalOff(fileadmin_class):
edit_modal = False
editable_extensions = ('txt',)
on_view_kwargs = dict(fileadmin_kwargs)
on_view_kwargs.setdefault('endpoint', 'edit_modal_on')
edit_modal_on = EditModalOn(*fileadmin_args, **on_view_kwargs)
off_view_kwargs = dict(fileadmin_kwargs)
off_view_kwargs.setdefault('endpoint', 'edit_modal_off')
edit_modal_off = EditModalOff(*fileadmin_args, **off_view_kwargs)
admin_bs2.add_view(edit_modal_on)
admin_bs2.add_view(edit_modal_off)
client_bs2 = app_bs2.test_client()
# bootstrap 2 - ensure modal window is added when edit_modal is
# enabled
rv = client_bs2.get('/admin/edit_modal_on/')
eq_(rv.status_code, 200)
data = rv.data.decode('utf-8')
ok_('fa_modal_window' in data)
# bootstrap 2 - test edit modal disabled
rv = client_bs2.get('/admin/edit_modal_off/')
eq_(rv.status_code, 200)
data = rv.data.decode('utf-8')
ok_('fa_modal_window' not in data)
# bootstrap 3
app_bs3 = Flask(__name__)
admin_bs3 = Admin(app_bs3, template_mode="bootstrap3")
admin_bs3.add_view(edit_modal_on)
admin_bs3.add_view(edit_modal_off)
client_bs3 = app_bs3.test_client()
# bootstrap 3 - ensure modal window is added when edit_modal is
# enabled
rv = client_bs3.get('/admin/edit_modal_on/')
eq_(rv.status_code, 200)
data = rv.data.decode('utf-8')
ok_('fa_modal_window' in data)
# bootstrap 3 - test modal disabled
rv = client_bs3.get('/admin/edit_modal_off/')
eq_(rv.status_code, 200)
data = rv.data.decode('utf-8')
ok_('fa_modal_window' not in data)
class LocalFileAdminTests(Base.FileAdminTests):
def fileadmin_class(self):
return fileadmin.FileAdmin
def fileadmin_args(self):
return (self._test_files_root, '/files'), {}
def test_fileadmin_sort_bogus_url_param(self):
fileadmin_class = self.fileadmin_class()
fileadmin_args, fileadmin_kwargs = self.fileadmin_args()
app, admin = setup()
class MyFileAdmin(fileadmin_class):
editable_extensions = ('txt',)
view_kwargs = dict(fileadmin_kwargs)
view_kwargs.setdefault('name', 'Files')
view = MyFileAdmin(*fileadmin_args, **view_kwargs)
admin.add_view(view)
client = app.test_client()
with open(op.join(self._test_files_root, 'dummy2.txt'), 'w') as fp:
# make sure that 'files/dummy2.txt' exists, is newest and has bigger size
fp.write('test')
rv = client.get('/admin/myfileadmin/?sort=bogus')
eq_(rv.status_code, 200)
ok_(rv.data.decode('utf-8').find('path=dummy2.txt') <
rv.data.decode('utf-8').find('path=dummy.txt'))
rv = client.get('/admin/myfileadmin/?sort=name')
eq_(rv.status_code, 200)
ok_(rv.data.decode('utf-8').find('path=dummy.txt') <
rv.data.decode('utf-8').find('path=dummy2.txt'))
try:
# clean up
os.remove(op.join(self._test_files_root, 'dummy2.txt'))
except (IOError, OSError):
pass
import os.path as op
from os import getenv
from uuid import uuid4
from nose import SkipTest
from flask_admin.contrib.fileadmin import azure
from .test_fileadmin import Base
class AzureFileAdminTests(Base.FileAdminTests):
_test_storage = getenv('AZURE_STORAGE_CONNECTION_STRING')
def setUp(self):
if not azure.BlockBlobService:
raise SkipTest('AzureFileAdmin dependencies not installed')
self._container_name = 'fileadmin-tests-%s' % uuid4()
if not self._test_storage or not self._container_name:
raise SkipTest('AzureFileAdmin test credentials not set')
client = azure.BlockBlobService(connection_string=self._test_storage)
client.create_container(self._container_name)
dummy = op.join(self._test_files_root, 'dummy.txt')
client.create_blob_from_path(self._container_name, 'dummy.txt', dummy)
def tearDown(self):
client = azure.BlockBlobService(connection_string=self._test_storage)
client.delete_container(self._container_name)
def fileadmin_class(self):
return azure.AzureFileAdmin
def fileadmin_args(self):
return (self._container_name, self._test_storage), {}
...@@ -9,6 +9,7 @@ def setup(): ...@@ -9,6 +9,7 @@ def setup():
app.config['CSRF_ENABLED'] = False app.config['CSRF_ENABLED'] = False
app.config['SQLALCHEMY_DATABASE_URI'] = 'postgresql://localhost/flask_admin_test' app.config['SQLALCHEMY_DATABASE_URI'] = 'postgresql://localhost/flask_admin_test'
app.config['SQLALCHEMY_ECHO'] = True app.config['SQLALCHEMY_ECHO'] = True
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
db = SQLAlchemy(app) db = SQLAlchemy(app)
admin = Admin(app) admin = Admin(app)
......
...@@ -685,9 +685,9 @@ def test_default_sort(): ...@@ -685,9 +685,9 @@ def test_default_sort():
app, db, admin = setup() app, db, admin = setup()
M1, _ = create_models(db) M1, _ = create_models(db)
M1(test1='c').save() M1(test1='c', test2='x').save()
M1(test1='b').save() M1(test1='b', test2='x').save()
M1(test1='a').save() M1(test1='a', test2='y').save()
eq_(M1.objects.count(), 3) eq_(M1.objects.count(), 3)
...@@ -700,6 +700,18 @@ def test_default_sort(): ...@@ -700,6 +700,18 @@ def test_default_sort():
eq_(data[1].test1, 'b') eq_(data[1].test1, 'b')
eq_(data[2].test1, 'c') eq_(data[2].test1, 'c')
# test default sort with multiple columns
order = [('test2', False), ('test1', False)]
view2 = CustomModelView(M1, column_default_sort=order, endpoint='m1_2')
admin.add_view(view2)
_, data = view2.get_list(0, None, None, None, None)
eq_(len(data), 3)
eq_(data[0].test1, 'b')
eq_(data[1].test1, 'c')
eq_(data[2].test1, 'a')
def test_extra_fields(): def test_extra_fields():
app, db, admin = setup() app, db, admin = setup()
......
...@@ -870,8 +870,8 @@ def test_default_sort(): ...@@ -870,8 +870,8 @@ def test_default_sort():
M1, _ = create_models(db) M1, _ = create_models(db)
M1('c', 1).save() M1('c', 1).save()
M1('b', 2).save() M1('b', 1).save()
M1('a', 3).save() M1('a', 2).save()
eq_(M1.select().count(), 3) eq_(M1.select().count(), 3)
...@@ -884,6 +884,18 @@ def test_default_sort(): ...@@ -884,6 +884,18 @@ def test_default_sort():
eq_(data[1].test1, 'b') eq_(data[1].test1, 'b')
eq_(data[2].test1, 'c') eq_(data[2].test1, 'c')
# test default sort with multiple columns
order = [('test2', False), ('test1', False)]
view2 = CustomModelView(M1, column_default_sort=order, endpoint='m1_2')
admin.add_view(view2)
_, data = view2.get_list(0, None, None, None, None)
eq_(len(data), 3)
eq_(data[0].test1, 'b')
eq_(data[1].test1, 'c')
eq_(data[2].test1, 'a')
def test_extra_fields(): def test_extra_fields():
app, db, admin = setup() app, db, admin = setup()
......
...@@ -9,6 +9,7 @@ def setup(): ...@@ -9,6 +9,7 @@ def setup():
app.config['CSRF_ENABLED'] = False app.config['CSRF_ENABLED'] = False
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///' app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///'
app.config['SQLALCHEMY_ECHO'] = True app.config['SQLALCHEMY_ECHO'] = True
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
db = SQLAlchemy(app) db = SQLAlchemy(app)
admin = Admin(app) admin = Admin(app)
...@@ -22,6 +23,7 @@ def setup_postgres(): ...@@ -22,6 +23,7 @@ def setup_postgres():
app.config['CSRF_ENABLED'] = False app.config['CSRF_ENABLED'] = False
app.config['SQLALCHEMY_DATABASE_URI'] = 'postgresql://localhost/flask_admin_test' app.config['SQLALCHEMY_DATABASE_URI'] = 'postgresql://localhost/flask_admin_test'
app.config['SQLALCHEMY_ECHO'] = True app.config['SQLALCHEMY_ECHO'] = True
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
db = SQLAlchemy(app) db = SQLAlchemy(app)
admin = Admin(app) admin = Admin(app)
......
...@@ -1676,7 +1676,7 @@ def test_default_sort(): ...@@ -1676,7 +1676,7 @@ def test_default_sort():
app, db, admin = setup() app, db, admin = setup()
M1, _ = create_models(db) M1, _ = create_models(db)
db.session.add_all([M1('c'), M1('b'), M1('a')]) db.session.add_all([M1('c', 'x'), M1('b', 'x'), M1('a', 'y')])
db.session.commit() db.session.commit()
eq_(M1.query.count(), 3) eq_(M1.query.count(), 3)
...@@ -1715,18 +1715,34 @@ def test_default_sort(): ...@@ -1715,18 +1715,34 @@ def test_default_sort():
eq_(data[1].test1, 'b') eq_(data[1].test1, 'b')
eq_(data[2].test1, 'c') eq_(data[2].test1, 'c')
# test default sort with multiple columns
order = [('test2', False), ('test1', False)]
view4 = CustomModelView(M1, db.session, column_default_sort=order, endpoint='m1_4')
admin.add_view(view4)
_, data = view4.get_list(0, None, None, None, None)
eq_(len(data), 3)
eq_(data[0].test1, 'b')
eq_(data[1].test1, 'c')
eq_(data[2].test1, 'a')
def test_complex_sort(): def test_complex_sort():
app, db, admin = setup() app, db, admin = setup()
M1, M2 = create_models(db) M1, M2 = create_models(db)
m1 = M1('b') m1 = M1(test1='c', test2='x')
db.session.add(m1) db.session.add(m1)
db.session.add(M2('c', model1=m1)) db.session.add(M2('c', model1=m1))
m2 = M1('a') m2 = M1(test1='b', test2='x')
db.session.add(m2) db.session.add(m2)
db.session.add(M2('c', model1=m2)) db.session.add(M2('b', model1=m2))
m3 = M1(test1='a', test2='y')
db.session.add(m3)
db.session.add(M2('a', model1=m3))
db.session.commit() db.session.commit()
...@@ -1738,9 +1754,30 @@ def test_complex_sort(): ...@@ -1738,9 +1754,30 @@ def test_complex_sort():
client = app.test_client() client = app.test_client()
rv = client.get('/admin/model2/?sort=1') rv = client.get('/admin/model2/?sort=0')
eq_(rv.status_code, 200)
_, data = view.get_list(0, 'model1.test1', False, None, None)
eq_(data[0].model1.test1, 'a')
eq_(data[1].model1.test1, 'b')
eq_(data[2].model1.test1, 'c')
# test sorting on multiple columns in related model
view2 = CustomModelView(M2, db.session,
column_list=['string_field', 'model1'],
column_sortable_list=[('model1', ('model1.test2', 'model1.test1'))], endpoint="m1_2")
admin.add_view(view2)
rv = client.get('/admin/m1_2/?sort=0')
eq_(rv.status_code, 200) eq_(rv.status_code, 200)
_, data = view2.get_list(0, 'model1', False, None, None)
eq_(data[0].model1.test1, 'b')
eq_(data[1].model1.test1, 'c')
eq_(data[2].model1.test1, 'a')
@raises(Exception) @raises(Exception)
def test_complex_sort_exception(): def test_complex_sort_exception():
......
import flask
from flask_admin import helpers
def test_is_safe_url():
app = flask.Flask(__name__)
with app.test_request_context('http://127.0.0.1/admin/car/edit/'):
assert helpers.is_safe_url('http://127.0.0.1/admin/car/')
assert helpers.is_safe_url('https://127.0.0.1/admin/car/')
assert helpers.is_safe_url('/admin/car/')
assert helpers.is_safe_url('admin/car/')
assert helpers.is_safe_url('http////www.google.com')
assert not helpers.is_safe_url('http://127.0.0.2/admin/car/')
assert not helpers.is_safe_url(' javascript:alert(document.domain)')
assert not helpers.is_safe_url('javascript:alert(document.domain)')
assert not helpers.is_safe_url('javascrip\nt:alert(document.domain)')
assert not helpers.is_safe_url(r'\\www.google.com')
assert not helpers.is_safe_url(r'\\/www.google.com')
assert not helpers.is_safe_url('/////www.google.com')
assert not helpers.is_safe_url('http:///www.google.com')
assert not helpers.is_safe_url('https:////www.google.com')
...@@ -235,7 +235,7 @@ msgstr "дорівнює" ...@@ -235,7 +235,7 @@ msgstr "дорівнює"
#: ../flask_admin/contrib/pymongo/filters.py:47 #: ../flask_admin/contrib/pymongo/filters.py:47
#: ../flask_admin/contrib/sqla/filters.py:49 #: ../flask_admin/contrib/sqla/filters.py:49
msgid "not equal" msgid "not equal"
msgstr "дорівнює" msgstr "не дорівнює"
#: ../flask_admin/contrib/mongoengine/filters.py:58 #: ../flask_admin/contrib/mongoengine/filters.py:58
#: ../flask_admin/contrib/peewee/filters.py:52 #: ../flask_admin/contrib/peewee/filters.py:52
......
# Chinese (Traditional, Taiwan) translations for Flask-Admin.
# Copyright (C) 2017 ORGANIZATION
# This file is distributed under the same license as the Flask-Admin
# project.
# FIRST AUTHOR <EMAIL@ADDRESS>, 2017.
#
msgid "" msgid ""
msgstr "" msgstr ""
"Project-Id-Version: flask-admin\n" "Project-Id-Version: Flask-Admin VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2017-02-07 00:17-0600\n" "POT-Creation-Date: 2017-02-07 00:19-0600\n"
"PO-Revision-Date: 2017-02-07 01:19-0500\n" "PO-Revision-Date: 2018-11-05 05:43+0800\n"
"Last-Translator: mrjoes <serge.koval@gmail.com>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: Chinese Traditional\n" "Language: zh_Hant_TW\n"
"Language-Team: zh_Hant_TW <LL@li.org>\n"
"Plural-Forms: nplurals=1; plural=0\n"
"MIME-Version: 1.0\n" "MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n" "Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.1.1\n" "Generated-By: Babel 2.6.0\n"
"Plural-Forms: nplurals=1; plural=0;\n"
"X-Generator: crowdin.com\n"
"X-Crowdin-Project: flask-admin\n"
"X-Crowdin-Language: zh-TW\n"
"X-Crowdin-File: admin.pot\n"
"Language: zh_TW\n"
#: ../flask_admin/base.py:440 #: ../flask_admin/base.py:440
msgid "Home" msgid "Home"
...@@ -23,7 +25,7 @@ msgstr "首頁" ...@@ -23,7 +25,7 @@ msgstr "首頁"
#: ../flask_admin/contrib/rediscli.py:127 #: ../flask_admin/contrib/rediscli.py:127
msgid "Cli: Invalid command." msgid "Cli: Invalid command."
msgstr "Cli: 不正確命令。" msgstr "Cli: 錯誤的指令。"
#: ../flask_admin/contrib/fileadmin/__init__.py:352 #: ../flask_admin/contrib/fileadmin/__init__.py:352
msgid "File to upload" msgid "File to upload"
...@@ -31,7 +33,7 @@ msgstr "要上傳的檔案" ...@@ -31,7 +33,7 @@ msgstr "要上傳的檔案"
#: ../flask_admin/contrib/fileadmin/__init__.py:360 #: ../flask_admin/contrib/fileadmin/__init__.py:360
msgid "File required." msgid "File required."
msgstr "檔案是必須上傳的。" msgstr "必要的檔案。"
#: ../flask_admin/contrib/fileadmin/__init__.py:365 #: ../flask_admin/contrib/fileadmin/__init__.py:365
msgid "Invalid file type." msgid "Invalid file type."
...@@ -43,7 +45,7 @@ msgstr "內容" ...@@ -43,7 +45,7 @@ msgstr "內容"
#: ../flask_admin/contrib/fileadmin/__init__.py:390 #: ../flask_admin/contrib/fileadmin/__init__.py:390
msgid "Invalid name" msgid "Invalid name"
msgstr "不正確名稱" msgstr "不正確名稱"
#: ../flask_admin/contrib/fileadmin/__init__.py:398 #: ../flask_admin/contrib/fileadmin/__init__.py:398
#: ../flask_admin/templates/bootstrap2/admin/file/list.html:106 #: ../flask_admin/templates/bootstrap2/admin/file/list.html:106
...@@ -127,7 +129,7 @@ msgstr "已成功刪除目錄\"%(path)s\"。" ...@@ -127,7 +129,7 @@ msgstr "已成功刪除目錄\"%(path)s\"。"
#: ../flask_admin/contrib/fileadmin/__init__.py:1013 #: ../flask_admin/contrib/fileadmin/__init__.py:1013
#, python-format #, python-format
msgid "Failed to delete directory: %(error)s" msgid "Failed to delete directory: %(error)s"
msgstr "在刪除目錄時發生異常:%(error)s" msgstr "刪除檔案的時候發生異常:%(error)s"
#: ../flask_admin/contrib/fileadmin/__init__.py:1019 #: ../flask_admin/contrib/fileadmin/__init__.py:1019
#: ../flask_admin/contrib/fileadmin/__init__.py:1176 #: ../flask_admin/contrib/fileadmin/__init__.py:1176
...@@ -139,7 +141,7 @@ msgstr "檔案 \"%(name)s\" 已被成功刪除。" ...@@ -139,7 +141,7 @@ msgstr "檔案 \"%(name)s\" 已被成功刪除。"
#: ../flask_admin/contrib/fileadmin/__init__.py:1178 #: ../flask_admin/contrib/fileadmin/__init__.py:1178
#, python-format #, python-format
msgid "Failed to delete file: %(name)s" msgid "Failed to delete file: %(name)s"
msgstr "刪除檔案的時候發生異常:%(name)s" msgstr ""
#: ../flask_admin/contrib/fileadmin/__init__.py:1043 #: ../flask_admin/contrib/fileadmin/__init__.py:1043
msgid "Renaming is disabled." msgid "Renaming is disabled."
...@@ -162,7 +164,7 @@ msgstr "重新命名的時候發生異常:%(error)s" ...@@ -162,7 +164,7 @@ msgstr "重新命名的時候發生異常:%(error)s"
#: ../flask_admin/contrib/fileadmin/__init__.py:1078 #: ../flask_admin/contrib/fileadmin/__init__.py:1078
#, python-format #, python-format
msgid "Rename %(name)s" msgid "Rename %(name)s"
msgstr "重命名 %(name)s" msgstr "重命名 %(name)s"
#: ../flask_admin/contrib/fileadmin/__init__.py:1115 #: ../flask_admin/contrib/fileadmin/__init__.py:1115
#, python-format #, python-format
...@@ -177,7 +179,7 @@ msgstr "對 %(name)s 成功保存的更改。" ...@@ -177,7 +179,7 @@ msgstr "對 %(name)s 成功保存的更改。"
#: ../flask_admin/contrib/fileadmin/__init__.py:1128 #: ../flask_admin/contrib/fileadmin/__init__.py:1128
#, python-format #, python-format
msgid "Error reading %(name)s." msgid "Error reading %(name)s."
msgstr "閱讀 %(name)s 錯誤。" msgstr "讀取 %(name)s 錯誤。"
#: ../flask_admin/contrib/fileadmin/__init__.py:1131 #: ../flask_admin/contrib/fileadmin/__init__.py:1131
#: ../flask_admin/contrib/fileadmin/__init__.py:1140 #: ../flask_admin/contrib/fileadmin/__init__.py:1140
...@@ -188,7 +190,7 @@ msgstr "從 %(name)s 中讀取時出現意外的錯誤" ...@@ -188,7 +190,7 @@ msgstr "從 %(name)s 中讀取時出現意外的錯誤"
#: ../flask_admin/contrib/fileadmin/__init__.py:1137 #: ../flask_admin/contrib/fileadmin/__init__.py:1137
#, python-format #, python-format
msgid "Cannot edit %(name)s." msgid "Cannot edit %(name)s."
msgstr "不能編輯 %(name)s。" msgstr "無法編輯 %(name)s。"
#: ../flask_admin/contrib/fileadmin/__init__.py:1155 #: ../flask_admin/contrib/fileadmin/__init__.py:1155
#, python-format #, python-format
...@@ -209,7 +211,7 @@ msgstr "您確定要刪除這些檔案嗎?" ...@@ -209,7 +211,7 @@ msgstr "您確定要刪除這些檔案嗎?"
#: ../flask_admin/contrib/fileadmin/__init__.py:1167 #: ../flask_admin/contrib/fileadmin/__init__.py:1167
msgid "File deletion is disabled." msgid "File deletion is disabled."
msgstr "刪除檔將被禁用。" msgstr "刪除檔案被禁用"
#: ../flask_admin/contrib/fileadmin/__init__.py:1180 #: ../flask_admin/contrib/fileadmin/__init__.py:1180
#: ../flask_admin/templates/bootstrap2/admin/model/details.html:17 #: ../flask_admin/templates/bootstrap2/admin/model/details.html:17
...@@ -300,7 +302,7 @@ msgstr "" ...@@ -300,7 +302,7 @@ msgstr ""
#: ../flask_admin/contrib/mongoengine/view.py:551 #: ../flask_admin/contrib/mongoengine/view.py:551
#, python-format #, python-format
msgid "Failed to get model. %(error)s" msgid "Failed to get model. %(error)s"
msgstr "未能獲取模型。%(error)s" msgstr "未能獲取資料。%(error)s"
#: ../flask_admin/contrib/mongoengine/view.py:570 #: ../flask_admin/contrib/mongoengine/view.py:570
#: ../flask_admin/contrib/peewee/view.py:435 #: ../flask_admin/contrib/peewee/view.py:435
...@@ -308,7 +310,7 @@ msgstr "未能獲取模型。%(error)s" ...@@ -308,7 +310,7 @@ msgstr "未能獲取模型。%(error)s"
#: ../flask_admin/contrib/sqla/view.py:1078 #: ../flask_admin/contrib/sqla/view.py:1078
#, python-format #, python-format
msgid "Failed to create record. %(error)s" msgid "Failed to create record. %(error)s"
msgstr "建立模型的時候發生異常:%(error)s" msgstr "建立紀錄的時候發生異常:%(error)s"
#: ../flask_admin/contrib/mongoengine/view.py:596 #: ../flask_admin/contrib/mongoengine/view.py:596
#: ../flask_admin/contrib/peewee/view.py:454 #: ../flask_admin/contrib/peewee/view.py:454
...@@ -317,7 +319,7 @@ msgstr "建立模型的時候發生異常:%(error)s" ...@@ -317,7 +319,7 @@ msgstr "建立模型的時候發生異常:%(error)s"
#: ../flask_admin/model/base.py:2313 ../flask_admin/model/base.py:2315 #: ../flask_admin/model/base.py:2313 ../flask_admin/model/base.py:2315
#, python-format #, python-format
msgid "Failed to update record. %(error)s" msgid "Failed to update record. %(error)s"
msgstr "更新模型的時候發生異常:%(error)s" msgstr "更新紀錄的時候發生異常:%(error)s"
#: ../flask_admin/contrib/mongoengine/view.py:619 #: ../flask_admin/contrib/mongoengine/view.py:619
#: ../flask_admin/contrib/peewee/view.py:469 #: ../flask_admin/contrib/peewee/view.py:469
...@@ -325,14 +327,14 @@ msgstr "更新模型的時候發生異常:%(error)s" ...@@ -325,14 +327,14 @@ msgstr "更新模型的時候發生異常:%(error)s"
#: ../flask_admin/contrib/sqla/view.py:1129 #: ../flask_admin/contrib/sqla/view.py:1129
#, python-format #, python-format
msgid "Failed to delete record. %(error)s" msgid "Failed to delete record. %(error)s"
msgstr "刪除模型的時候發生異常:%(error)s" msgstr "刪除紀錄的時候發生異常:%(error)s"
#: ../flask_admin/contrib/mongoengine/view.py:659 #: ../flask_admin/contrib/mongoengine/view.py:659
#: ../flask_admin/contrib/peewee/view.py:488 #: ../flask_admin/contrib/peewee/view.py:488
#: ../flask_admin/contrib/pymongo/view.py:385 #: ../flask_admin/contrib/pymongo/view.py:385
#: ../flask_admin/contrib/sqla/view.py:1150 #: ../flask_admin/contrib/sqla/view.py:1150
msgid "Are you sure you want to delete selected records?" msgid "Are you sure you want to delete selected records?"
msgstr "您確定要刪除這些模型嗎?" msgstr "您確定要刪除這些紀錄嗎?"
#: ../flask_admin/contrib/mongoengine/view.py:668 #: ../flask_admin/contrib/mongoengine/view.py:668
#: ../flask_admin/contrib/peewee/view.py:505 #: ../flask_admin/contrib/peewee/view.py:505
...@@ -340,8 +342,7 @@ msgstr "您確定要刪除這些模型嗎?" ...@@ -340,8 +342,7 @@ msgstr "您確定要刪除這些模型嗎?"
#: ../flask_admin/contrib/sqla/view.py:1166 ../flask_admin/model/base.py:2118 #: ../flask_admin/contrib/sqla/view.py:1166 ../flask_admin/model/base.py:2118
#, python-format #, python-format
msgid "Record was successfully deleted." msgid "Record was successfully deleted."
msgid_plural "%(count)s records were successfully deleted." msgstr "紀錄刪除成功。"
msgstr[0] "刪除作業成功完成。"
#: ../flask_admin/contrib/mongoengine/view.py:674 #: ../flask_admin/contrib/mongoengine/view.py:674
#: ../flask_admin/contrib/peewee/view.py:511 #: ../flask_admin/contrib/peewee/view.py:511
...@@ -349,14 +350,14 @@ msgstr[0] "刪除作業成功完成。" ...@@ -349,14 +350,14 @@ msgstr[0] "刪除作業成功完成。"
#: ../flask_admin/contrib/sqla/view.py:1174 #: ../flask_admin/contrib/sqla/view.py:1174
#, python-format #, python-format
msgid "Failed to delete records. %(error)s" msgid "Failed to delete records. %(error)s"
msgstr "刪除模型的時候發生異常:%(error)s" msgstr "刪除紀錄的時候發生異常:%(error)s"
#: ../flask_admin/contrib/sqla/fields.py:126 #: ../flask_admin/contrib/sqla/fields.py:126
#: ../flask_admin/contrib/sqla/fields.py:176 #: ../flask_admin/contrib/sqla/fields.py:176
#: ../flask_admin/contrib/sqla/fields.py:181 ../flask_admin/model/fields.py:173 #: ../flask_admin/contrib/sqla/fields.py:181 ../flask_admin/model/fields.py:173
#: ../flask_admin/model/fields.py:222 #: ../flask_admin/model/fields.py:222
msgid "Not a valid choice" msgid "Not a valid choice"
msgstr "炫則的值不正確。" msgstr "選擇的值不正確"
#: ../flask_admin/contrib/sqla/fields.py:186 #: ../flask_admin/contrib/sqla/fields.py:186
msgid "Key" msgid "Key"
...@@ -374,7 +375,7 @@ msgstr "資料已經存在。" ...@@ -374,7 +375,7 @@ msgstr "資料已經存在。"
#, python-format #, python-format
msgid "At least %(num)d item is required" msgid "At least %(num)d item is required"
msgid_plural "At least %(num)d items are required" msgid_plural "At least %(num)d items are required"
msgstr[0] "" msgstr ""
#: ../flask_admin/contrib/sqla/view.py:1057 #: ../flask_admin/contrib/sqla/view.py:1057
#, python-format #, python-format
...@@ -391,11 +392,11 @@ msgstr "不正確選擇: 不能強迫" ...@@ -391,11 +392,11 @@ msgstr "不正確選擇: 不能強迫"
#: ../flask_admin/form/fields.py:208 #: ../flask_admin/form/fields.py:208
msgid "Invalid JSON" msgid "Invalid JSON"
msgstr "不正確 JSON" msgstr "JSON 不正確"
#: ../flask_admin/form/upload.py:207 #: ../flask_admin/form/upload.py:207
msgid "Invalid file extension" msgid "Invalid file extension"
msgstr "不正確副檔名" msgstr "不正確的檔案副檔名"
#: ../flask_admin/form/upload.py:214 ../flask_admin/form/upload.py:281 #: ../flask_admin/form/upload.py:214 ../flask_admin/form/upload.py:281
#, python-format #, python-format
...@@ -404,7 +405,7 @@ msgstr "檔案 \"%s\" 已經存在。" ...@@ -404,7 +405,7 @@ msgstr "檔案 \"%s\" 已經存在。"
#: ../flask_admin/model/base.py:1649 #: ../flask_admin/model/base.py:1649
msgid "There are no items in the table." msgid "There are no items in the table."
msgstr "在表中沒有專案。" msgstr "在表中沒有項目。"
#: ../flask_admin/model/base.py:1673 #: ../flask_admin/model/base.py:1673
#, python-format #, python-format
...@@ -413,7 +414,7 @@ msgstr "不正確篩選器值: %(value)s" ...@@ -413,7 +414,7 @@ msgstr "不正確篩選器值: %(value)s"
#: ../flask_admin/model/base.py:1984 #: ../flask_admin/model/base.py:1984
msgid "Record was successfully created." msgid "Record was successfully created."
msgstr "模型建立成功。" msgstr "資料新增成功。"
#: ../flask_admin/model/base.py:2028 ../flask_admin/model/base.py:2080 #: ../flask_admin/model/base.py:2028 ../flask_admin/model/base.py:2080
#: ../flask_admin/model/base.py:2113 ../flask_admin/model/base.py:2297 #: ../flask_admin/model/base.py:2113 ../flask_admin/model/base.py:2297
...@@ -457,7 +458,7 @@ msgstr "查看記錄" ...@@ -457,7 +458,7 @@ msgstr "查看記錄"
#: ../flask_admin/templates/bootstrap2/admin/model/modals/edit.html:22 #: ../flask_admin/templates/bootstrap2/admin/model/modals/edit.html:22
#: ../flask_admin/templates/bootstrap3/admin/model/modals/edit.html:11 #: ../flask_admin/templates/bootstrap3/admin/model/modals/edit.html:11
msgid "Edit Record" msgid "Edit Record"
msgstr "編輯錄" msgstr "編輯錄"
#: ../flask_admin/model/widgets.py:61 #: ../flask_admin/model/widgets.py:61
msgid "Please select model" msgid "Please select model"
...@@ -466,7 +467,7 @@ msgstr "" ...@@ -466,7 +467,7 @@ msgstr ""
#: ../flask_admin/templates/bootstrap2/admin/actions.html:4 #: ../flask_admin/templates/bootstrap2/admin/actions.html:4
#: ../flask_admin/templates/bootstrap3/admin/actions.html:4 #: ../flask_admin/templates/bootstrap3/admin/actions.html:4
msgid "With selected" msgid "With selected"
msgstr "選中的" msgstr "選中的 "
#: ../flask_admin/templates/bootstrap2/admin/lib.html:200 #: ../flask_admin/templates/bootstrap2/admin/lib.html:200
#: ../flask_admin/templates/bootstrap3/admin/lib.html:190 #: ../flask_admin/templates/bootstrap3/admin/lib.html:190
...@@ -481,17 +482,17 @@ msgstr "取消" ...@@ -481,17 +482,17 @@ msgstr "取消"
#: ../flask_admin/templates/bootstrap2/admin/lib.html:256 #: ../flask_admin/templates/bootstrap2/admin/lib.html:256
#: ../flask_admin/templates/bootstrap3/admin/lib.html:247 #: ../flask_admin/templates/bootstrap3/admin/lib.html:247
msgid "Save and Add Another" msgid "Save and Add Another"
msgstr "" msgstr "儲存後繼續新增"
#: ../flask_admin/templates/bootstrap2/admin/lib.html:259 #: ../flask_admin/templates/bootstrap2/admin/lib.html:259
#: ../flask_admin/templates/bootstrap3/admin/lib.html:250 #: ../flask_admin/templates/bootstrap3/admin/lib.html:250
msgid "Save and Continue Editing" msgid "Save and Continue Editing"
msgstr "" msgstr "儲存後繼續編輯"
#: ../flask_admin/templates/bootstrap2/admin/file/list.html:9 #: ../flask_admin/templates/bootstrap2/admin/file/list.html:9
#: ../flask_admin/templates/bootstrap3/admin/file/list.html:9 #: ../flask_admin/templates/bootstrap3/admin/file/list.html:9
msgid "Root" msgid "Root"
msgstr "" msgstr "Root"
#: ../flask_admin/templates/bootstrap2/admin/file/list.html:40 #: ../flask_admin/templates/bootstrap2/admin/file/list.html:40
#: ../flask_admin/templates/bootstrap2/admin/file/list.html:49 #: ../flask_admin/templates/bootstrap2/admin/file/list.html:49
...@@ -542,7 +543,7 @@ msgstr "請至少選擇一個檔案。" ...@@ -542,7 +543,7 @@ msgstr "請至少選擇一個檔案。"
#: ../flask_admin/templates/bootstrap3/admin/model/edit.html:14 #: ../flask_admin/templates/bootstrap3/admin/model/edit.html:14
#: ../flask_admin/templates/bootstrap3/admin/model/list.html:17 #: ../flask_admin/templates/bootstrap3/admin/model/list.html:17
msgid "List" msgid "List"
msgstr "列表" msgstr "資料列表"
#: ../flask_admin/templates/bootstrap2/admin/model/create.html:17 #: ../flask_admin/templates/bootstrap2/admin/model/create.html:17
#: ../flask_admin/templates/bootstrap2/admin/model/details.html:12 #: ../flask_admin/templates/bootstrap2/admin/model/details.html:12
...@@ -562,7 +563,7 @@ msgstr "建立" ...@@ -562,7 +563,7 @@ msgstr "建立"
#: ../flask_admin/templates/bootstrap3/admin/model/details.html:21 #: ../flask_admin/templates/bootstrap3/admin/model/details.html:21
#: ../flask_admin/templates/bootstrap3/admin/model/edit.html:26 #: ../flask_admin/templates/bootstrap3/admin/model/edit.html:26
msgid "Details" msgid "Details"
msgstr "" msgstr "詳細資訊"
#: ../flask_admin/templates/bootstrap2/admin/model/details.html:29 #: ../flask_admin/templates/bootstrap2/admin/model/details.html:29
#: ../flask_admin/templates/bootstrap2/admin/model/modals/details.html:8 #: ../flask_admin/templates/bootstrap2/admin/model/modals/details.html:8
...@@ -640,7 +641,7 @@ msgstr "創建新記錄" ...@@ -640,7 +641,7 @@ msgstr "創建新記錄"
#: ../flask_admin/templates/bootstrap2/admin/model/list.html:77 #: ../flask_admin/templates/bootstrap2/admin/model/list.html:77
#: ../flask_admin/templates/bootstrap3/admin/model/list.html:76 #: ../flask_admin/templates/bootstrap3/admin/model/list.html:76
msgid "Select all records" msgid "Select all records"
msgstr "選擇所有錄" msgstr "選擇所有錄"
#: ../flask_admin/templates/bootstrap2/admin/model/list.html:120 #: ../flask_admin/templates/bootstrap2/admin/model/list.html:120
#: ../flask_admin/templates/bootstrap3/admin/model/list.html:119 #: ../flask_admin/templates/bootstrap3/admin/model/list.html:119
...@@ -650,10 +651,10 @@ msgstr "選擇記錄" ...@@ -650,10 +651,10 @@ msgstr "選擇記錄"
#: ../flask_admin/templates/bootstrap2/admin/model/list.html:185 #: ../flask_admin/templates/bootstrap2/admin/model/list.html:185
#: ../flask_admin/templates/bootstrap3/admin/model/list.html:186 #: ../flask_admin/templates/bootstrap3/admin/model/list.html:186
msgid "Please select at least one record." msgid "Please select at least one record."
msgstr "請至少選擇一個模型。" msgstr "請至少選擇一筆資料。"
#: ../flask_admin/templates/bootstrap2/admin/model/row_actions.html:34 #: ../flask_admin/templates/bootstrap2/admin/model/row_actions.html:34
#: ../flask_admin/templates/bootstrap3/admin/model/row_actions.html:34 #: ../flask_admin/templates/bootstrap3/admin/model/row_actions.html:34
msgid "Are you sure you want to delete this record?" msgid "Are you sure you want to delete this record?"
msgstr "您確定要刪除這個東西嗎?" msgstr "您確定要刪除這筆記錄嗎?"
flake8
Flask>=0.7 Flask>=0.7
Flask-SQLAlchemy>=0.15 Flask-SQLAlchemy>=0.15
peewee peewee
...@@ -15,3 +16,4 @@ nose ...@@ -15,3 +16,4 @@ nose
coveralls coveralls
pylint pylint
sqlalchemy-citext sqlalchemy-citext
azure-storage-blob
...@@ -31,6 +31,12 @@ def grep(attrname): ...@@ -31,6 +31,12 @@ def grep(attrname):
return strval return strval
extras_require = {
'aws': ['boto'],
'azure': ['azure-storage-blob']
}
install_requires = [ install_requires = [
'Flask>=0.7', 'Flask>=0.7',
'wtforms' 'wtforms'
...@@ -49,6 +55,7 @@ setup( ...@@ -49,6 +55,7 @@ setup(
include_package_data=True, include_package_data=True,
zip_safe=False, zip_safe=False,
platforms='any', platforms='any',
extras_require=extras_require,
install_requires=install_requires, install_requires=install_requires,
tests_require=[ tests_require=[
'nose>=1.0', 'nose>=1.0',
......
...@@ -8,9 +8,11 @@ skip_missing_interpreters = true ...@@ -8,9 +8,11 @@ skip_missing_interpreters = true
[flake8] [flake8]
max_line_length = 120 max_line_length = 120
ignore = E402,E722 ignore = E402,E722,W504
[testenv] [testenv]
setenv =
AZURE_STORAGE_CONNECTION_STRING = DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://127.0.0.1:10000/devstoreaccount1;
usedevelop = true usedevelop = true
deps = deps =
WTForms1: WTForms==1.0.5 WTForms1: WTForms==1.0.5
......
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