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:
services:
- postgresql
- mongodb
- docker
before_script:
- 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 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:
- pip install tox
......
......@@ -38,14 +38,28 @@ Flask-Admin is an active project, well-tested and production ready.
Examples
--------
Several usage examples are included in the */examples* folder. Please feel free to add your own examples, or improve
on some of the existing ones, and then submit them via GitHub as a *pull-request*.
Several usage examples are included in the */examples* folder. Please add your own, or improve
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 on your local environment, one at a time, do something like::
To run the examples in your local environment::
1. Clone the repository::
git clone https://github.com/flask-admin/flask-admin.git
cd flask-admin
python examples/simple/app.py
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
-------------
......@@ -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::
> psql postgres
CREATE DATABASE flask_admin_test;
\q
> psql flask_admin_test
CREATE EXTENSION postgis;
CREATE EXTENSION hstore;
......@@ -100,7 +118,8 @@ You can also run the tests on multiple environments using *tox*.
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.
......
......@@ -2,7 +2,6 @@
<ul>
<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://examples.flask-admin.org/" target="_blank">Flask-Admin Examples</a></li>
</ul>
<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:
#. Initialize Flask-BabelEx by creating instance of `Babel` class::
from flask import app
from flask import Flask
from flask_babelex import Babel
app = Flask(__name__)
......@@ -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.
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`.
......@@ -544,4 +544,3 @@ While the wrapped function should accept only one parameter - `ids`::
raise
flash(gettext('Failed to approve users. %(error)s', error=str(ex)), 'error')
......@@ -15,6 +15,7 @@ API
mod_actions
mod_contrib_sqla
mod_contrib_sqla_fields
mod_contrib_mongoengine
mod_contrib_mongoengine_fields
mod_contrib_peewee
......
``flask_admin.contrib.sqla.fields``
===================================
.. automodule:: flask_admin.contrib.sqla.fields
.. autoclass:: QuerySelectField
:members:
.. autoclass:: QuerySelectMultipleField
:members:
.. autoclass:: CheckboxListField
:members:
Changelog
=========
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
-----
......
......@@ -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
class for each of your models.
**What does it look like?** At http://examples.flask-admin.org/ you can see
some examples of Flask-Admin in action, or browse through the `examples/`
directory in the `GitHub repository <https://github.com/flask-admin/flask-admin>`_.
**What does it look like?** Clone the `GitHub repository <https://github.com/flask-admin/flask-admin>`_
and run the provided examples locally to get a feel for Flask-Admin. There are several to choose from
in the `examples` directory.
.. toctree::
:maxdepth: 2
......
......@@ -18,6 +18,9 @@ The first step is to initialize an empty admin interface for your Flask app::
app = Flask(__name__)
# set optional bootswatch theme
app.config['FLASK_ADMIN_SWATCH'] = 'cerulean'
admin = Admin(app, name='microblog', template_mode='bootstrap3')
# Add administrative views here
......@@ -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.
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
------------------
......@@ -156,12 +160,9 @@ Customizing Built-in Views
****
The built-in `ModelView` class is great for getting started quickly. But, you'll want
to configure its functionality to suit your particular models. This is done by setting
values for the configuration attributes that are made available in the `ModelView` class.
To specify some global configuration parameters, you can subclass `ModelView` and use that
subclass when adding your models to the interface::
When inheriting from `ModelView`, values can be specified for numerous
configuration parameters. Use these to customize the views to suit your
particular models::
from flask_admin.contrib.sqla import ModelView
......@@ -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`.
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
=====================
......@@ -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
======================= ============================================
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.
Environment Variables
......
......@@ -14,11 +14,11 @@ To run this example:
3. Install requirements::
pip install -r 'examples/layout/requirements.txt'
pip install -r 'examples/custom-layout/requirements.txt'
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,
comment the following lines in app.py:::
......
......@@ -7,7 +7,7 @@
{% endblock %}
{% block page_body %}
<div class="container">
<div class="container-fluid">
<div class="row">
<div class="col-md-2" role="navigation">
<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:
......@@ -14,11 +21,11 @@ To run this example:
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::
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,
comment the following lines in app.py:::
......
......@@ -3,18 +3,24 @@ import os.path as op
from flask import Flask, url_for
from flask_sqlalchemy import SQLAlchemy
from redis import Redis
from wtforms import fields, widgets
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
from flask_admin.contrib import sqla, rediscli
# Create application
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
app.config['SECRET_KEY'] = '123456790'
......@@ -62,6 +68,15 @@ class User(db.Model):
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
@listens_for(File, 'after_delete')
def del_file(mapper, connection, target):
......@@ -90,7 +105,28 @@ def del_image(mapper, connection, target):
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
class PageView(sqla.ModelView):
form_overrides = {
'text': CKTextAreaField
}
create_template = 'create_page.html'
edit_template = 'edit_page.html'
class FileView(sqla.ModelView):
# Override form field to use Flask-Admin FileUploadField
form_overrides = {
......@@ -140,15 +176,15 @@ class UserView(sqla.ModelView):
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)
# Show macro that's included in the templates
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'
create_template = 'create_user.html'
edit_template = 'edit_user.html'
# Flask views
......@@ -162,7 +198,9 @@ admin = Admin(app, 'Example: Forms', 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'))
admin.add_view(UserView(User, db.session))
admin.add_view(PageView(Page, db.session))
admin.add_view(rediscli.RedisCli(Redis()))
def build_sample_db():
......@@ -238,6 +276,10 @@ def build_sample_db():
file.path = "example_" + str(i) + ".pdf"
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()
return
......
{% 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' %}
{% import 'rule_demo.html' as rule_demo %}
\ No newline at end of file
{% import 'macros.html' as rule_demo %}
{% 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' %}
{% 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:
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
to register for a free account at `Mapbox <https://www.mapbox.com/>`_ and set
......
......@@ -8,3 +8,7 @@ SQLALCHEMY_ECHO = True
# credentials for loading map tiles from mapbox
MAPBOX_MAP_ID = '...'
MAPBOX_ACCESS_TOKEN = '...'
# 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:
3. Install requirements::
pip install -r 'examples/multi/requirements.txt'
pip install -r 'examples/multiple-admin-instances/requirements.txt'
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:
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::
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:
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/app2.py
The first time you run this example, a sample sqlite database gets populated automatically. To suppress this behaviour,
comment the following lines in app.py:::
......
......@@ -2,17 +2,26 @@ import os
import os.path as op
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy.ext.hybrid import hybrid_property
from wtforms import validators
import flask_admin as admin
from flask_admin.base import MenuLink
from flask_admin.contrib import sqla
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
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
app.config['SECRET_KEY'] = '123456790'
......@@ -28,11 +37,24 @@ class User(db.Model):
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)
pets = db.relationship('Pet', backref='owner')
def __str__(self):
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.username
return self.name
# Create M2M table
......@@ -46,7 +68,7 @@ class Post(db.Model):
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(120))
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 = db.relationship(User, backref='posts')
......@@ -54,7 +76,7 @@ class Post(db.Model):
tags = db.relationship('Tag', secondary=post_tags_table)
def __str__(self):
return self.title
return "{}".format(self.title)
class Tag(db.Model):
......@@ -62,7 +84,7 @@ class Tag(db.Model):
name = db.Column(db.Unicode(64))
def __str__(self):
return self.name
return "{}".format(self.name)
class UserInfo(db.Model):
......@@ -75,7 +97,7 @@ class UserInfo(db.Model):
user = db.relationship(User, backref='info')
def __str__(self):
return '%s - %s' % (self.key, self.value)
return "{} - {}".format(self.key, self.value)
class Tree(db.Model):
......@@ -85,7 +107,18 @@ class Tree(db.Model):
parent = db.relationship('Tree', remote_side=[id], backref='children')
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
......@@ -94,30 +127,95 @@ def index():
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
inline_form_options = {
'form_label': "Info item",
'form_columns': ['id', 'key', 'value'],
'form_args': None,
'form_extra_fields': None,
}
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
class PostAdmin(sqla.ModelView):
# Visible columns in the list view
column_exclude_list = ['text']
def edit_form(self, obj):
return self._use_filtered_parent(
super(UserAdmin, self).edit_form(obj)
)
def _use_filtered_parent(self, form):
form.pets.query_factory = self._get_parent_list
return form
# List of columns that can be sorted. For 'user' column, use User.username as
# a column.
column_sortable_list = ('title', ('user', 'user.username'), 'date')
def _get_parent_list(self):
# only show available pets in the form
return Pet.query.filter_by(available=True).all()
# Rename 'title' columns to 'Post Title' in list view
column_labels = dict(title='Post Title')
column_searchable_list = ('title', User.username, 'tags.name')
column_filters = ('user',
# Customized Post model admin
class PostAdmin(sqla.ModelView):
column_list = ['id', 'user', 'title', 'date', 'tags']
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'))))
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
# be 'Big Text' and add required() validator.
......@@ -127,11 +225,14 @@ class PostAdmin(sqla.ModelView):
form_ajax_refs = {
'user': {
'fields': (User.username, User.email)
'fields': (User.first_name, User.last_name)
},
'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):
......@@ -143,6 +244,14 @@ class TreeView(sqla.ModelView):
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
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(sqla.ModelView(Tag, 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():
......@@ -171,8 +287,8 @@ def build_sample_db():
'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',
'Brown', 'Brown', 'Patel', 'Jones', 'Williams', 'Johnson', 'Taylor', 'Thomas',
'Roberts', 'Khan', 'Clarke', 'Clarke', 'Clarke', 'James', 'Phillips', 'Wilson',
'Ali', 'Mason', 'Mitchell', 'Rose', 'Davis', 'Davies', 'Rodriguez', 'Cox', 'Alexander'
]
......@@ -180,9 +296,9 @@ def build_sample_db():
for i in range(len(first_names)):
user = User()
user.first_name = first_names[i]
user.username = first_names[i].lower()
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)
db.session.add(user)
......@@ -258,6 +374,15 @@ def build_sample_db():
leaf.parent = branch
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()
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-Admin
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'
__email__ = 'serge.koval+github@gmail.com'
......
......@@ -8,6 +8,11 @@
import sys
import warnings
try:
from wtforms.widgets import HTMLString as Markup
except ImportError:
from markupsafe import Markup # noqa: F401
def get_property(obj, name, old_name, default=None):
"""
......
......@@ -38,7 +38,7 @@ if not PY2:
# Various tools
from functools import reduce
from urllib.parse import urljoin, urlparse
from urllib.parse import urljoin, urlparse, quote
else:
text_type = unicode
string_types = (str, unicode)
......@@ -62,6 +62,7 @@ else:
# Helpers
reduce = __builtins__['reduce'] if isinstance(__builtins__, dict) else __builtins__.reduce
from urlparse import urljoin, urlparse
from urllib import quote
def with_metaclass(meta, *bases):
......
......@@ -9,7 +9,7 @@ from flask_admin._compat import with_metaclass, as_unicode
from flask_admin import helpers as h
# 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',)):
......@@ -581,6 +581,27 @@ class Admin(object):
for view in args:
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):
"""
Add link to menu links collection.
......
......@@ -12,7 +12,7 @@ from werkzeug import secure_filename
from wtforms import fields, validators
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.actions import action, ActionsMixin
from flask_admin.babel import gettext, lazy_gettext
......@@ -107,6 +107,20 @@ class LocalFileStorage(object):
"""
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):
"""
Save uploaded file to the disk
......@@ -593,6 +607,9 @@ class BaseFileAdmin(BaseView, ActionsMixin):
:param path:
Static file path
"""
if self._on_windows:
path = path.replace('\\', '/')
if self.is_file_editable(path):
route = '.edit'
else:
......@@ -818,18 +835,29 @@ class BaseFileAdmin(BaseView, ActionsMixin):
if self.is_accessible_path(rel_path):
items.append(item)
sort_column = request.args.get('sort', None, type=str)
sort_desc = request.args.get('desc', 0, type=int)
sort_column = request.args.get('sort', None, type=str) or self.default_sort_column
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:
# Sort by name
items.sort(key=itemgetter(0))
# Sort by type
items.sort(key=itemgetter(2), reverse=True)
if not self._on_windows:
# Sort by modified date
items.sort(key=lambda x: (x[0], x[1], x[2], x[3], datetime.fromtimestamp(x[4])), reverse=True)
items.sort(key=lambda x: (x[0], x[1], x[2], x[3], datetime.utcfromtimestamp(x[4])), reverse=True)
else:
column_index = self.possible_columns.index(sort_column)
items.sort(key=itemgetter(column_index), reverse=sort_desc)
# Generate breadcrumbs
......@@ -842,13 +870,16 @@ class BaseFileAdmin(BaseView, ActionsMixin):
else:
action_form = None
def sort_url(column, invert=False):
def sort_url(column, path, invert=False):
desc = None
if not path:
path = None
if invert and not sort_desc:
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,
dir_path=path,
......@@ -921,7 +952,7 @@ class BaseFileAdmin(BaseView, ActionsMixin):
base_url = self.get_base_url()
if 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)
......@@ -1109,8 +1140,7 @@ class BaseFileAdmin(BaseView, ActionsMixin):
form.process(request.form, content='')
if form.validate():
try:
with open(full_path, 'w') as f:
f.write(request.form['content'])
self.storage.write_file(full_path, request.form['content'])
except IOError:
flash(gettext("Error saving changes to %(name)s.", name=path), 'error')
error = True
......@@ -1122,8 +1152,7 @@ class BaseFileAdmin(BaseView, ActionsMixin):
helpers.flash_errors(form, message='Failed to edit file. %(error)s')
try:
with open(full_path, 'rb') as f:
content = f.read()
content = self.storage.read_file(full_path)
except IOError:
flash(gettext("Error reading %(name)s.", name=path), 'error')
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):
keys = self._get_path_keys(path + self.separator)
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):
"""
......
......@@ -50,6 +50,7 @@ class QueryAjaxModelLoader(AjaxModelLoader):
def get_list(self, term, offset=0, limit=DEFAULT_PAGE_SIZE):
query = self.model.objects
if len(term) > 0:
criteria = None
for field in self._cached_fields:
......
def parse_like_term(term):
"""
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
= = exact
:param term:
Search term
"""
case_insensitive = term.startswith('*')
if case_insensitive:
case_sensitive = term.startswith('*')
if case_sensitive:
term = term[1:]
# apply operators
if term.startswith('^'):
......@@ -23,6 +23,6 @@ def parse_like_term(term):
else:
oper = 'contains'
# add case insensitive flag
if case_insensitive:
if not case_sensitive:
oper = 'i' + oper
return oper, term
......@@ -526,7 +526,9 @@ class ModelView(BaseModelView):
order = self._get_default_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
if page_size is None:
......
from wtforms.widgets import HTMLString, html_params
from wtforms.widgets import html_params
from jinja2 import escape
from mongoengine.fields import GridFSProxy, ImageGridFsProxy
from flask_admin._backwards import Markup
from flask_admin.helpers import get_url
from . import helpers
......@@ -31,7 +32,7 @@ class MongoFileInput(object):
'marker': '_%s-delete' % field.name
}
return HTMLString('%s<input %s>' % (placeholder,
return Markup('%s<input %s>' % (placeholder,
html_params(name=field.name,
type='file',
**kwargs)))
......@@ -46,7 +47,8 @@ class MongoImageInput(object):
' <input type="checkbox" name="%(marker)s">Delete</input>'
'</div>')
def __call__(self, field, **kwargs):
def __call__(self, field, **kwargs):
kwargs.setdefault('id', field.id)
placeholder = ''
......@@ -57,7 +59,7 @@ class MongoImageInput(object):
'marker': '_%s-delete' % field.name
}
return HTMLString('%s<input %s>' % (placeholder,
return Markup('%s<input %s>' % (placeholder,
html_params(name=field.name,
type='file',
**kwargs)))
......@@ -52,6 +52,7 @@ class QueryAjaxModelLoader(AjaxModelLoader):
def get_list(self, term, offset=0, limit=DEFAULT_PAGE_SIZE):
query = self.model.select()
if len(term) > 0:
stmt = None
for field in self._cached_fields:
q = field ** (u'%%%s%%' % term)
......
......@@ -184,6 +184,10 @@ class ModelView(BaseModelView):
return get_primary_key(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)
def scaffold_list_columns(self):
......@@ -234,18 +238,14 @@ class ModelView(BaseModelView):
raise Exception('Failed to find field for filter: %s' % name)
# Check if field is in different model
model_class = None
try:
if attr.model_class != self.model:
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)
model_class = attr.model_class
except AttributeError:
if attr.model != self.model:
visible_name = '%s / %s' % (self.get_column_name(attr.model.__name__),
model_class = attr.model
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):
......@@ -317,38 +317,42 @@ class ModelView(BaseModelView):
return create_ajax_loader(self.model, name, name, options)
def _handle_join(self, query, field, joins):
model_class = None
try:
if field.model_class != self.model:
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)
model_class = field.model_class
except AttributeError:
if field.model != self.model:
model_name = field.model.__name__
model_class = field.model
if model_class != self.model:
model_name = model_class.__name__
if model_name not in joins:
query = query.join(field.model, JOIN.LEFT_OUTER)
query = query.join(model_class, JOIN.LEFT_OUTER)
joins.add(model_name)
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):
field = getattr(self.model, sort_field)
query = query.order_by(field.desc() if sort_desc else field.asc())
elif isinstance(sort_field, Field):
model_class = None
try:
if sort_field.model_class != self.model:
query = self._handle_join(query, sort_field, joins)
model_class = sort_field.model_class
except AttributeError:
if sort_field.model != self.model:
model_class = sort_field.model
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())
return query, joins
field = sort_field
clause = field.desc() if sort_desc else field.asc()
return query, joins, clause
def get_query(self):
return self.model.select()
......@@ -417,13 +421,12 @@ class ModelView(BaseModelView):
# Apply sorting
if sort_column is not None:
sort_field = self._sortable_columns[sort_column]
query, joins = self._order_by(query, joins, sort_field, sort_desc)
order = [(sort_field, sort_desc)]
query, joins = self._order_by(query, joins, order)
else:
order = self._get_default_order()
if order:
query, joins = self._order_by(query, joins, order[0], order[1])
query, joins = self._order_by(query, joins, order)
# Pagination
if page_size is None:
......@@ -441,6 +444,8 @@ class ModelView(BaseModelView):
return count, query
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})
def create_model(self, form):
......
......@@ -262,7 +262,8 @@ class ModelView(BaseModelView):
order = self._get_default_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
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.model.ajax import AjaxModelLoader, DEFAULT_PAGE_SIZE
......@@ -55,7 +56,7 @@ class QueryAjaxModelLoader(AjaxModelLoader):
if not model:
return None
return (getattr(model, self.pk), as_unicode(model))
return getattr(model, self.pk), as_unicode(model)
def get_one(self, pk):
# prevent autoflush from occuring during populate_obj
......@@ -65,7 +66,7 @@ class QueryAjaxModelLoader(AjaxModelLoader):
def get_list(self, term, offset=0, limit=DEFAULT_PAGE_SIZE):
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))
if self.filters:
......
......@@ -13,6 +13,7 @@ except ImportError:
from .tools import get_primary_key
from flask_admin._compat import text_type, string_types, iteritems
from flask_admin.contrib.sqla.widgets import CheckboxListInput
from flask_admin.form import FormOpts, BaseForm, Select2Widget
from flask_admin.model.fields import InlineFieldList, InlineModelFormField
from flask_admin.babel import lazy_gettext
......@@ -181,6 +182,30 @@ class QuerySelectMultipleField(QuerySelectField):
raise ValidationError(self.gettext(u'Not a valid choice'))
class CheckboxListField(QuerySelectMultipleField):
"""
Alternative field for many-to-many relationships.
Can be used instead of `QuerySelectMultipleField`.
Appears as the list of checkboxes.
Example::
class MyView(ModelView):
form_columns = (
'languages',
)
form_args = {
'languages': {
'query_factory': Language.query,
},
}
form_overrides = {
'languages': CheckboxListField,
}
"""
widget = CheckboxListInput()
class HstoreForm(BaseForm):
""" Form used in InlineFormField/InlineHstoreList for HSTORE columns """
key = StringField(lazy_gettext('Key'))
......@@ -272,11 +297,11 @@ class InlineModelFormList(InlineFieldList):
return
# 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
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
if not is_created:
......@@ -298,3 +323,27 @@ def get_pk_from_identity(obj):
# TODO: Remove me
key = identity_key(instance=obj)[1]
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
from wtforms import fields, validators
from sqlalchemy import Boolean, Column
from sqlalchemy.orm import ColumnProperty
from flask_admin import form
from flask_admin.model.form import (converts, ModelConverterBase,
......@@ -152,7 +153,7 @@ class AdminModelConverter(ModelConverterBase):
return self._convert_relation(name, prop, property_is_association_proxy, kwargs)
elif hasattr(prop, 'columns'): # Ignore pk/fk
# 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)
if len(columns) == 0:
......
......@@ -484,13 +484,21 @@ class ModelView(BaseModelView):
for c in self.column_sortable_list:
if isinstance(c, tuple):
if isinstance(c[1], tuple):
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:
column, path = tools.get_field_with_path(self.model, 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
elif path:
raise Exception("For sorting columns in a related table, "
......@@ -501,8 +509,6 @@ class ModelView(BaseModelView):
# column is in same table, use only model attribute name
if getattr(column, 'key', None) is not None:
column_name = column.key
else:
column_name = text_type(c)
# column_name must match column_name used in `get_list_columns`
result[column_name] = column
......@@ -574,6 +580,33 @@ class ModelView(BaseModelView):
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):
"""
Return list of enabled filters
......@@ -791,8 +824,6 @@ class ModelView(BaseModelView):
"""
Return a query for the model type.
If you override this method, don't forget to override `get_count_query` as well.
This method can be used to set a "persistent filter" on an index_view.
Example::
......@@ -800,6 +831,10 @@ class ModelView(BaseModelView):
class MyView(ModelView):
def get_query(self):
return super(MyView, self).get_query().filter(User.username == current_user.username)
If you override this method, don't forget to also override `get_count_query`, for displaying the correct
item count in the list view, and `get_one`, which is used when retrieving records for the edit view.
"""
return self.session.query(self.model)
......@@ -836,13 +871,7 @@ class ModelView(BaseModelView):
column = sort_field if alias is None else getattr(alias, sort_field.key)
if sort_desc:
if isinstance(column, tuple):
query = query.order_by(*map(desc, column))
else:
query = query.order_by(desc(column))
else:
if isinstance(column, tuple):
query = query.order_by(*column)
else:
query = query.order_by(column)
......@@ -850,15 +879,9 @@ class ModelView(BaseModelView):
def _get_default_order(self):
order = super(ModelView, self)._get_default_order()
if order is not None:
field, direction = order
for field, direction in (order or []):
attr, joins = tools.get_field_with_path(self.model, field)
return attr, joins, direction
return None
yield attr, joins, direction
def _apply_sorting(self, query, joins, sort_column, sort_desc):
if sort_column is not None:
......@@ -866,13 +889,14 @@ class ModelView(BaseModelView):
sort_field = self._sortable_columns[sort_column]
sort_joins = self._sortable_joins.get(sort_column)
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:
order = self._get_default_order()
if order:
sort_field, sort_joins, sort_desc = order
for sort_field, sort_joins, sort_desc in order:
query, joins = self._order_by(query, joins, sort_joins, sort_field, sort_desc)
return query, joins
......@@ -1051,6 +1075,14 @@ class ModelView(BaseModelView):
"""
Return a single model by its id.
Example::
def get_one(self, id):
query = self.get_query()
return query.filter(self.model.id == id).one()
Also see `get_query` for how to filter the list view.
:param id:
Model id
"""
......@@ -1059,7 +1091,10 @@ class ModelView(BaseModelView):
# Error handler
def handle_view_exception(self, exc):
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
else:
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
from werkzeug.datastructures import FileStorage
from wtforms import ValidationError, fields
from wtforms.widgets import HTMLString, html_params
from wtforms.widgets import html_params
try:
from wtforms.fields.core import _unset_value as unset_value
......@@ -15,6 +15,7 @@ except ImportError:
from flask_admin.babel import gettext
from flask_admin.helpers import get_url
from flask_admin._backwards import Markup
from flask_admin._compat import string_types, urljoin
......@@ -59,7 +60,7 @@ class FileUploadInput(object):
else:
value = field.data or ''
return HTMLString(template % {
return Markup(template % {
'text': html_params(type='text',
readonly='readonly',
value=value,
......@@ -108,7 +109,7 @@ class ImageUploadInput(object):
else:
template = self.empty_template
return HTMLString(template % args)
return Markup(template % args)
def get_url(self, field):
if field.thumbnail_size:
......
from re import sub
from re import sub, compile
from jinja2 import contextfunction
from flask import g, request, url_for, flash
from wtforms.validators import DataRequired, InputRequired
......@@ -8,6 +8,11 @@ from flask_admin._compat import urljoin, urlparse, iteritems
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):
g._admin_view = view
......@@ -128,10 +133,26 @@ def prettify_class_name(name):
def is_safe_url(target):
# prevent urls like "\\www.google.com"
# some browser will change \\ to // (eg: Chrome)
# refs https://stackoverflow.com/questions/10438008
target = target.replace('\\', '/')
# handle cases like "j a v a s c r i p t:"
target = _substitute_whitespace('', target)
# Chrome and FireFox "fix" more than two slashes into two after protocol
target = _fix_multiple_slashes(lambda m: m.group(1), target, 1)
# prevent urls starting with "javascript:"
target_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)
test_url = urlparse(urljoin(request.host_url, target))
return (test_url.scheme in ('http', 'https') and
ref_url.netloc == test_url.netloc)
return ref_url.netloc == test_url.netloc
def get_redirect_target(param_name='url'):
......
......@@ -7,7 +7,7 @@ class BaseMenu(object):
"""
def __init__(self, name, class_name=None, icon_type=None, icon_value=None, target=None):
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_value = icon_value
self.target = target
......@@ -141,3 +141,9 @@ class MenuLink(BaseMenu):
def get_url(self):
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
from wtforms.fields.core import UnboundField
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.form import BaseForm, FormOpts, rules
......@@ -382,6 +382,12 @@ class BaseModelView(BaseView, ActionsMixin):
class MyModelView(BaseModelView):
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
of strings::
......@@ -403,6 +409,12 @@ class BaseModelView(BaseView, ActionsMixin):
class MyModelView(BaseModelView):
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',
......@@ -665,7 +677,9 @@ class BaseModelView(BaseView, ActionsMixin):
form_ajax_refs = {
'user': {
'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):
:param name:
View name. If not provided, will use the model class name
:param category:
View category
Optional category name, for grouping views in the menu
:param endpoint:
Base endpoint. If not provided, will use the model name.
:param url:
......@@ -1093,6 +1107,12 @@ class BaseModelView(BaseView, ActionsMixin):
"""
return False
def search_placeholder(self):
"""
Return search placeholder.
"""
return 'Search'
# Filter helpers
def scaffold_filters(self, name):
"""
......@@ -1463,10 +1483,12 @@ class BaseModelView(BaseView, ActionsMixin):
Return default sort order
"""
if self.column_default_sort:
if isinstance(self.column_default_sort, tuple):
if isinstance(self.column_default_sort, list):
return self.column_default_sort
if isinstance(self.column_default_sort, tuple):
return [self.column_default_sort]
else:
return self.column_default_sort, False
return [(self.column_default_sort, False)]
return None
......@@ -1547,7 +1569,7 @@ class BaseModelView(BaseView, ActionsMixin):
try:
self.on_model_change(form, model, is_created)
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 ' +
'parameter is_created. Please update your code') % self.model
warnings.warn(msg)
......@@ -1717,7 +1739,12 @@ class BaseModelView(BaseView, ActionsMixin):
sort=request.args.get('sort', None, type=int),
sort_desc=request.args.get('desc', None, type=int),
search=request.args.get('search', None),
filters=self._get_list_filter_args())
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):
"""
......@@ -2010,6 +2037,7 @@ class BaseModelView(BaseView, ActionsMixin):
search_supported=self._search_supported,
clear_search_url=clear_search_url,
search=view_args.search,
search_placeholder=self.search_placeholder(),
# Filters
filters=self._filters,
......@@ -2181,7 +2209,11 @@ class BaseModelView(BaseView, ActionsMixin):
# message is flashed from within delete_model if it fails
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)
else:
flash_errors(form, message='Failed to delete record. %(error)s')
......@@ -2298,12 +2330,12 @@ class BaseModelView(BaseView, ActionsMixin):
if 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()
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)
try:
......
......@@ -118,6 +118,10 @@ class InlineModelFormField(FormField):
self.form_opts = form_opts
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
def populate_obj(self, obj, name):
......
from flask import json
from jinja2 import escape
from wtforms.widgets import HTMLString, html_params
from wtforms.widgets import html_params
from flask_admin._backwards import Markup
from flask_admin._compat import as_unicode, text_type
from flask_admin.babel import gettext
from flask_admin.helpers import get_url
......@@ -61,7 +62,10 @@ class AjaxSelect2Widget(object):
placeholder = field.loader.options.get('placeholder', gettext('Please select model'))
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):
......@@ -90,7 +94,7 @@ class XEditableWidget(object):
kwargs = self.get_kwargs(field, kwargs)
return HTMLString(
return Markup(
'<a %s>%s</a>' % (html_params(**kwargs),
escape(display_value))
)
......
.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) {
// Actions helpers. TODO: Move to separate file
// batch actions helpers
this.execute = function(name) {
var selected = $('input.action-checkbox:checked').length;
......@@ -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 @@
$('.modal').on('hidden', function() {
$(this).removeData('modal');
});
$(function() {
// Apply flask-admin form styles after the modal is loaded
window.faForm.applyGlobalStyles(document);
});
......@@ -2,3 +2,8 @@
$('body').on('hidden.bs.modal', '.modal', function () {
$(this).removeData('bs.modal').find(".modal-content").empty();
});
$(function() {
// Apply flask-admin form styles after the modal is loaded
window.faForm.applyGlobalStyles(document);
});
......@@ -179,3 +179,17 @@ var AdminFilters = function(element, filtersElement, filterGroups, activeFilters
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 @@
var opts = {
width: 'resolve',
minimumInputLength: 1,
minimumInputLength: $el.attr('data-minimum-input-length'),
placeholder: 'data-placeholder',
ajax: {
url: $el.attr('data-url'),
......@@ -77,6 +77,10 @@
console.error("You must set MAPBOX_MAP_ID in your Flask settings to use the map widget");
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")
if (geometryType) {
......@@ -148,12 +152,8 @@
map.fitBounds(bounds);
}
} else {
// look up user's location by IP address
$.getJSON("//ip-api.com/json/?callback=?", function(data) {
map.setView([data["lat"], data["lon"]], 12);
}).fail(function() {
map.setView([0, 0], 1)
});
// use the default map center
map.setView([window.DEFAULT_CENTER_LAT, window.DEFAULT_CENTER_LONG], 12);
}
// set up tiles
......@@ -182,7 +182,8 @@
var drawOptions = {
draw: {
// circles are not geometries in geojson
circle: false
circle: false,
circlemarker: false
},
edit: {
featureGroup: editableLayers
......@@ -304,6 +305,8 @@
if ($el.attr('data-allow-blank'))
opts['allowClear'] = true;
opts['minimumInputLength'] = $el.attr('data-minimum-input-length');
if ($el.attr('data-tags')) {
$.extend(opts, {
tokenSeparators: [','],
......
......@@ -115,3 +115,7 @@ var RedisCli = function(postUrl) {
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"?>
<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">
<g id="enabled" fill="#464646">
<g id="polyline">
<path d="M18 36v6h6v-6h-6zm4 4h-2v-2h2v2z"/>
<path d="M36 18v6h6v-6h-6zm4 4h-2v-2h2v2z"/>
<path d="M23.142 39.145l-2.285-2.29 16-15.998 2.285 2.285z"/>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
viewBox="0 0 600 60"
height="60"
width="600"
id="svg4225"
version="1.1"
inkscape:version="0.91 r13725"
sodipodi:docname="spritesheet.svg"
inkscape:export-filename="/home/fpuga/development/upstream/icarto.Leaflet.draw/src/images/spritesheet-2x.png"
inkscape:export-xdpi="90"
inkscape:export-ydpi="90">
<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>
<path id="polygon" d="M100 24.565l-2.096 14.83L83.07 42 76 28.773 86.463 18z"/>
<path id="rectangle" d="M140 20h20v20h-20z"/>
<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"/>
<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"/>
<g id="edit">
<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"/>
<path d="M338.72 24.637l-8.892 8.892H327V30.7l8.89-8.89z"/>
<path d="M338.697 17.826h4v4h-4z" transform="rotate(-134.99 340.703 19.817)"/>
<path
id="polygon"
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 id="remove">
<path d="M381 42h18V24h-18v18zm14-16h2v14h-2V26zm-4 0h2v14h-2V26zm-4 0h2v14h-2V26zm-4 0h2v14h-2V26z"/>
<path d="M395 20v-4h-10v4h-6v2h22v-2h-6zm-2 0h-6v-2h6v2z"/>
<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" fill="#bbb" transform="translate(120)">
<use xlink:href="#edit" id="edit-disabled"/>
<use xlink:href="#remove" id="remove-disabled"/>
<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>
/* required styles */
.leaflet-map-pane,
.leaflet-pane,
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-tile-pane,
.leaflet-tile-container,
.leaflet-overlay-pane,
.leaflet-shadow-pane,
.leaflet-marker-pane,
.leaflet-popup-pane,
.leaflet-overlay-pane svg,
.leaflet-pane > svg,
.leaflet-pane > canvas,
.leaflet-zoom-box,
.leaflet-image-layer,
.leaflet-layer {
......@@ -20,7 +16,6 @@
}
.leaflet-container {
overflow: hidden;
-ms-touch-action: none;
}
.leaflet-tile,
.leaflet-marker-icon,
......@@ -30,18 +25,52 @@
user-select: 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-shadow {
display: block;
}
/* map is broken in FF if you have max-width: 100% on tiles */
.leaflet-container img {
/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
/* .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-height: none !important;
}
/* stupid Android 2 doesn't understand "max-width: none" properly */
.leaflet-container img.leaflet-image-layer {
max-width: 15000px !important;
.leaflet-container.leaflet-touch-zoom {
-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 {
filter: inherit;
visibility: hidden;
......@@ -52,18 +81,26 @@
.leaflet-zoom-box {
width: 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 */
.leaflet-overlay-pane svg {
-moz-user-select: none;
}
.leaflet-tile-pane { z-index: 2; }
.leaflet-objects-pane { z-index: 3; }
.leaflet-overlay-pane { z-index: 4; }
.leaflet-shadow-pane { z-index: 5; }
.leaflet-marker-pane { z-index: 6; }
.leaflet-popup-pane { z-index: 7; }
.leaflet-pane { z-index: 400; }
.leaflet-tile-pane { z-index: 200; }
.leaflet-overlay-pane { z-index: 400; }
.leaflet-shadow-pane { z-index: 500; }
.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 {
width: 1px;
......@@ -80,7 +117,8 @@
.leaflet-control {
position: relative;
z-index: 7;
z-index: 800;
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
.leaflet-top,
......@@ -124,31 +162,35 @@
/* zoom and fade animations */
.leaflet-fade-anim .leaflet-tile,
.leaflet-fade-anim .leaflet-tile {
will-change: opacity;
}
.leaflet-fade-anim .leaflet-popup {
opacity: 0;
-webkit-transition: opacity 0.2s linear;
-moz-transition: opacity 0.2s linear;
-o-transition: opacity 0.2s linear;
transition: opacity 0.2s linear;
}
.leaflet-fade-anim .leaflet-tile-loaded,
.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
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 {
-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);
-o-transition: -o-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-pan-anim .leaflet-tile,
.leaflet-touching .leaflet-zoom-animated {
.leaflet-pan-anim .leaflet-tile {
-webkit-transition: none;
-moz-transition: none;
-o-transition: none;
transition: none;
}
......@@ -159,24 +201,46 @@
/* cursors */
.leaflet-clickable {
.leaflet-interactive {
cursor: pointer;
}
.leaflet-container {
.leaflet-grab {
cursor: -webkit-grab;
cursor: -moz-grab;
cursor: grab;
}
.leaflet-crosshair,
.leaflet-crosshair .leaflet-interactive {
cursor: crosshair;
}
.leaflet-popup-pane,
.leaflet-control {
cursor: auto;
}
.leaflet-dragging .leaflet-container,
.leaflet-dragging .leaflet-clickable {
.leaflet-dragging .leaflet-grab,
.leaflet-dragging .leaflet-grab .leaflet-interactive,
.leaflet-dragging .leaflet-marker-draggable {
cursor: move;
cursor: -webkit-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 */
......@@ -249,7 +313,14 @@
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 */
......@@ -258,16 +329,10 @@
font: bold 18px 'Lucida Console', Monaco, monospace;
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;
}
.leaflet-touch .leaflet-control-zoom-out {
font-size: 24px;
}
/* layers control */
......@@ -303,6 +368,11 @@
color: #333;
background: #fff;
}
.leaflet-control-layers-scrollbar {
overflow-y: scroll;
overflow-x: hidden;
padding-right: 5px;
}
.leaflet-control-layers-selector {
margin-top: 2px;
position: relative;
......@@ -317,6 +387,11 @@
margin: 5px -10px 5px -6px;
}
/* Default icon URLs */
.leaflet-default-icon-path {
background-image: url(images/marker-icon.png);
}
/* attribution and scale controls */
......@@ -354,8 +429,8 @@
font-size: 11px;
white-space: nowrap;
overflow: hidden;
-moz-box-sizing: content-box;
box-sizing: content-box;
-moz-box-sizing: border-box;
box-sizing: border-box;
background: #fff;
background: rgba(255, 255, 255, 0.5);
......@@ -386,6 +461,7 @@
.leaflet-popup {
position: absolute;
text-align: center;
margin-bottom: 20px;
}
.leaflet-popup-content-wrapper {
padding: 1px;
......@@ -400,11 +476,13 @@
margin: 18px 0;
}
.leaflet-popup-tip-container {
margin: 0 auto;
width: 40px;
height: 20px;
position: relative;
position: absolute;
left: 50%;
margin-left: -20px;
overflow: hidden;
pointer-events: none;
}
.leaflet-popup-tip {
width: 17px;
......@@ -416,13 +494,12 @@
-webkit-transform: rotate(45deg);
-moz-transform: rotate(45deg);
-ms-transform: rotate(45deg);
-o-transform: rotate(45deg);
transform: rotate(45deg);
}
.leaflet-popup-content-wrapper,
.leaflet-popup-tip {
background: white;
color: #333;
box-shadow: 0 3px 14px rgba(0,0,0,0.4);
}
.leaflet-container a.leaflet-popup-close-button {
......@@ -430,6 +507,7 @@
top: 0;
right: 0;
padding: 4px 4px 0 0;
border: none;
text-align: center;
width: 18px;
height: 14px;
......@@ -476,3 +554,82 @@
background: #fff;
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;
}
/* ================================================================== */
/* Toolbars
/* ================================================================== */
.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-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;
}
.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')}
.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-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}
.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-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}
.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
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 @@
{% macro script(message, actions, actions_confirmation) %}
{% if actions %}
<div id="actions-confirmation-data" style="display:none;">{{ actions_confirmation|tojson|safe }}</div>
<div id="message-data" style="display:none;">{{ message|tojson|safe }}</div>
<script src="{{ admin_static.url(filename='admin/js/actions.js', v='1.0.0') }}"></script>
<script language="javascript">
var modelActions = new AdminModelActions({{ message|tojson|safe }}, {{ actions_confirmation|tojson|safe }});
</script>
{% endif %}
{% endmacro %}
......@@ -20,25 +20,33 @@
{%- if item.is_category() -%}
{% set children = item.get_children() %}
{%- if children %}
{% set class_name = item.get_class_name() %}
{% set class_name = item.get_class_name() or '' %}
{%- if item.is_active(admin_view) %}
<li class="active dropdown">
<li class="active dropdown{% if class_name %} {{class_name}}{% endif %}">
{% else -%}
<li class="dropdown">
<li class="dropdown{% if class_name %} {{class_name}}{% endif %}">
{%- endif %}
<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>
<ul class="dropdown-menu">
{%- for child in children -%}
{%- if child.is_category() -%}
{{ menu(menu_root=[child]) }}
{% else %}
{% 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>
<a href="{{ child.get_url() }}"{% if child.target %}
target="{{ child.target }}"{% endif %}>
{{ menu_icon(child) }}{{ child.name }}</a>
</li>
{%- endif %}
{%- endfor %}
</ul>
</li>
......@@ -61,8 +69,9 @@
{% macro menu_links(links=None) %}
{% if links is none %}{% set links = admin_view.admin.menu_links() %}{% endif %}
{% for item in links %}
{% set class_name = item.get_class_name() %}
{% 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>
</li>
{% endif %}
......
......@@ -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/bootstrap-daterangepicker/daterangepicker-bs2.css', v='1.3.22') }}" rel="stylesheet">
{% 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.draw.css', v='0.3.2') }}" 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.4.6') }}" rel="stylesheet">
{% endif %}
{% if editable_columns %}
<link href="{{ admin_static.url(filename='vendor/x-editable/css/bootstrap2-editable.css', v='1.5.1.1') }}" rel="stylesheet">
......@@ -234,9 +234,13 @@
{% if config.MAPBOX_ACCESS_TOKEN %}
window.MAPBOX_ACCESS_TOKEN = "{{ config.MAPBOX_ACCESS_TOKEN }}";
{% 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 src="{{ admin_static.url(filename='vendor/leaflet/leaflet.js', v='0.7.3') }}"></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.js', v='1.0.2') }}"></script>
<script src="{{ admin_static.url(filename='vendor/leaflet/leaflet.draw.js', v='0.4.6') }}"></script>
{% if config.MAPBOX_SEARCH %}
<script>
window.MAPBOX_SEARCH = "{{ config.MAPBOX_SEARCH }}";
......
......@@ -74,14 +74,14 @@
{% endif %}
{% if search %}
<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">
<i class="fa fa-times icon-remove"></i>
</a>
</div>
{% else %}
<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>
{% endif %}
</form>
......
......@@ -179,26 +179,16 @@
{% block tail %}
{{ 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() }}
<script src="{{ admin_static.url(filename='admin/js/filters.js', v='1.0.0') }}"></script>
{{ actionlib.script(_gettext('Please select at least one record.'),
actions,
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 %}
......@@ -21,10 +21,5 @@
$('.modal-header h3').html('{% block header_text -%}
<h3>{{ _gettext('Create New Record') }}</h3>
{%- endblock %}');
$(function() {
// Apply flask-admin form styles after the modal is loaded
window.faForm.applyGlobalStyles(document);
});
</script>
{% endblock %}
......@@ -21,10 +21,5 @@
$('.modal-header h3').html('{% block header_text -%}
{{ _gettext('Edit Record') + ' #' + request.args.get('id') }}
{%- endblock %}');
$(function() {
// Apply flask-admin form styles after the modal is loaded
window.faForm.applyGlobalStyles(document);
});
</script>
{% endblock %}
......@@ -21,10 +21,7 @@
{% block tail %}
{{ 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 language="javascript">
$(function() {
var redisCli = new RedisCli({{ get_url('.execute_view')|tojson }});
});
</script>
{% endblock %}
......@@ -27,9 +27,8 @@
{% macro script(message, actions, actions_confirmation) %}
{% if actions %}
<div id="actions-confirmation-data" style="display:none;">{{ actions_confirmation|tojson|safe }}</div>
<div id="message-data" style="display:none;">{{ message|tojson|safe }}</div>
<script src="{{ admin_static.url(filename='admin/js/actions.js', v='1.0.0') }}"></script>
<script language="javascript">
var modelActions = new AdminModelActions({{ message|tojson|safe }}, {{ actions_confirmation|tojson|safe }});
</script>
{% endif %}
{% endmacro %}
......@@ -17,6 +17,7 @@
<link href="{{ admin_static.url(filename='bootstrap/bootstrap3/css/bootstrap-theme.min.css', v='3.3.5') }}" rel="stylesheet">
{%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/submenu.css') }}" rel="stylesheet">
{% if admin_view.extra_css %}
{% for css_url in admin_view.extra_css %}
<link href="{{ css_url }}" rel="stylesheet">
......
......@@ -20,25 +20,37 @@
{%- if item.is_category() -%}
{% set children = item.get_children() %}
{%- if children %}
{% set class_name = item.get_class_name() %}
{% set class_name = item.get_class_name() or '' %}
{%- if item.is_active(admin_view) %}
<li class="active dropdown">
<li class="active dropdown{% if class_name %} {{class_name}}{% endif %}">
{% else -%}
<li class="dropdown">
<li class="dropdown{% if class_name %} {{class_name}}{% endif %}">
{%- endif %}
<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>
<ul class="dropdown-menu">
{%- for child in children -%}
{%- if child.is_category() -%}
{{ menu(menu_root=[child]) }}
{% else %}
{% 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>
<a href="{{ child.get_url() }}"{% if child.target %}
target="{{ child.target }}"{% endif %}>
{{ menu_icon(child) }}{{ child.name }}</a>
</li>
{%- endif %}
{%- endfor %}
</ul>
</li>
......@@ -61,8 +73,9 @@
{% macro menu_links(links=None) %}
{% if links is none %}{% set links = admin_view.admin.menu_links() %}{% endif %}
{% for item in links %}
{% set class_name = item.get_class_name() %}
{% 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>
</li>
{% endif %}
......
......@@ -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/bootstrap-daterangepicker/daterangepicker-bs3.css', v='1.3.22') }}" rel="stylesheet">
{% 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.draw.css', v='0.3.2') }}" 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.4.6') }}" rel="stylesheet">
{% endif %}
{% if editable_columns %}
<link href="{{ admin_static.url(filename='vendor/x-editable/css/bootstrap3-editable.css', v='1.5.1.1') }}" rel="stylesheet">
......@@ -225,9 +225,13 @@
{% if config.MAPBOX_ACCESS_TOKEN %}
window.MAPBOX_ACCESS_TOKEN = "{{ config.MAPBOX_ACCESS_TOKEN }}";
{% 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 src="{{ admin_static.url(filename='vendor/leaflet/leaflet.js', v='1.0.0') }}"></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.js', v='1.0.2') }}"></script>
<script src="{{ admin_static.url(filename='vendor/leaflet/leaflet.draw.js', v='0.4.6') }}"></script>
{% if config.MAPBOX_SEARCH %}
<script>
window.MAPBOX_SEARCH = "{{ config.MAPBOX_SEARCH }}";
......
......@@ -74,12 +74,12 @@
{% endif %}
{% if search %}
<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>
</div>
{% else %}
<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>
{% endif %}
</form>
......
......@@ -180,43 +180,16 @@
{% block tail %}
{{ 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() }}
<script src="{{ admin_static.url(filename='admin/js/filters.js', v='1.0.0') }}"></script>
{{ actionlib.script(_gettext('Please select at least one record.'),
actions,
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 %}
......@@ -21,11 +21,4 @@
{% block tail %}
<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 %}
......@@ -23,11 +23,4 @@
{% block tail %}
<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 %}
......@@ -31,7 +31,7 @@
{% elif csrf_token %}
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
{% endif %}
<button onclick="return safeConfirm('{{ _gettext('Are you sure you want to delete this record?') }}');" title="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>
</button>
</form>
......
......@@ -21,10 +21,7 @@
{% block tail %}
{{ 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 language="javascript">
$(function() {
var redisCli = new RedisCli({{ admin_view.get_url('.execute_view')|tojson }});
});
</script>
{% endblock %}
import os
import os.path as op
import unittest
from nose.tools import eq_, ok_
......@@ -14,21 +16,30 @@ except ImportError:
from io import StringIO
def create_view():
app, admin = setup()
class Base:
class FileAdminTests(unittest.TestCase):
_test_files_root = op.join(op.dirname(__file__), 'files')
class MyFileAdmin(fileadmin.FileAdmin):
editable_extensions = ('txt',)
def fileadmin_class(self):
raise NotImplementedError
path = op.join(op.dirname(__file__), 'files')
view = MyFileAdmin(path, '/files/', name='Files')
admin.add_view(view)
def fileadmin_args(self):
raise NotImplementedError
def test_file_admin(self):
fileadmin_class = self.fileadmin_class()
fileadmin_args, fileadmin_kwargs = self.fileadmin_args()
return app, admin, view
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)
def test_file_admin():
app, admin, view = create_view()
admin.add_view(view)
client = app.test_client()
......@@ -42,9 +53,8 @@ def test_file_admin():
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'
))
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')
......@@ -57,10 +67,9 @@ def test_file_admin():
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(
name='dummy_renamed.txt',
path='dummy.txt'
))
rv = client.post('/admin/myfileadmin/rename/?path=dummy.txt',
data=dict(name='dummy_renamed.txt',
path='dummy.txt'))
eq_(rv.status_code, 302)
rv = client.get('/admin/myfileadmin/')
......@@ -72,9 +81,8 @@ def test_file_admin():
rv = client.get('/admin/myfileadmin/upload/')
eq_(rv.status_code, 200)
rv = client.post('/admin/myfileadmin/upload/', data=dict(
upload=(StringIO(""), 'dummy.txt'),
))
rv = client.post('/admin/myfileadmin/upload/',
data=dict(upload=(StringIO(""), 'dummy.txt')))
eq_(rv.status_code, 302)
rv = client.get('/admin/myfileadmin/')
......@@ -83,9 +91,8 @@ def test_file_admin():
ok_('path=dummy_renamed.txt' in rv.data.decode('utf-8'))
# delete
rv = client.post('/admin/myfileadmin/delete/', data=dict(
path='dummy_renamed.txt'
))
rv = client.post('/admin/myfileadmin/delete/',
data=dict(path='dummy_renamed.txt'))
eq_(rv.status_code, 302)
rv = client.get('/admin/myfileadmin/')
......@@ -97,9 +104,8 @@ def test_file_admin():
rv = client.get('/admin/myfileadmin/mkdir/')
eq_(rv.status_code, 200)
rv = client.post('/admin/myfileadmin/mkdir/', data=dict(
name='dummy_dir'
))
rv = client.post('/admin/myfileadmin/mkdir/',
data=dict(name='dummy_dir'))
eq_(rv.status_code, 302)
rv = client.get('/admin/myfileadmin/')
......@@ -112,10 +118,9 @@ def test_file_admin():
eq_(rv.status_code, 200)
ok_('dummy_dir' in rv.data.decode('utf-8'))
rv = client.post('/admin/myfileadmin/rename/?path=dummy_dir', data=dict(
name='dummy_renamed_dir',
path='dummy_dir'
))
rv = client.post('/admin/myfileadmin/rename/?path=dummy_dir',
data=dict(name='dummy_renamed_dir',
path='dummy_dir'))
eq_(rv.status_code, 302)
rv = client.get('/admin/myfileadmin/')
......@@ -124,9 +129,8 @@ def test_file_admin():
ok_('path=dummy_dir' not in rv.data.decode('utf-8'))
# delete - directory
rv = client.post('/admin/myfileadmin/delete/', data=dict(
path='dummy_renamed_dir'
))
rv = client.post('/admin/myfileadmin/delete/',
data=dict(path='dummy_renamed_dir'))
eq_(rv.status_code, 302)
rv = client.get('/admin/myfileadmin/')
......@@ -134,30 +138,37 @@ def test_file_admin():
ok_('path=dummy_renamed_dir' not in rv.data.decode('utf-8'))
ok_('path=dummy.txt' in rv.data.decode('utf-8'))
def test_modal_edit():
def test_modal_edit(self):
# bootstrap 2 - test edit_modal
app_bs2 = Flask(__name__)
admin_bs2 = Admin(app_bs2, template_mode="bootstrap2")
class EditModalOn(fileadmin.FileAdmin):
fileadmin_class = self.fileadmin_class()
fileadmin_args, fileadmin_kwargs = self.fileadmin_args()
class EditModalOn(fileadmin_class):
edit_modal = True
editable_extensions = ('txt',)
class EditModalOff(fileadmin.FileAdmin):
class EditModalOff(fileadmin_class):
edit_modal = False
editable_extensions = ('txt',)
path = op.join(op.dirname(__file__), 'files')
edit_modal_on = EditModalOn(path, '/files/', endpoint='edit_modal_on')
edit_modal_off = EditModalOff(path, '/files/', endpoint='edit_modal_off')
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
# 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')
......@@ -178,7 +189,8 @@ def test_modal_edit():
client_bs3 = app_bs3.test_client()
# bootstrap 3 - ensure modal window is added when edit_modal is enabled
# 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')
......@@ -189,3 +201,45 @@ def test_modal_edit():
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():
app.config['CSRF_ENABLED'] = False
app.config['SQLALCHEMY_DATABASE_URI'] = 'postgresql://localhost/flask_admin_test'
app.config['SQLALCHEMY_ECHO'] = True
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
db = SQLAlchemy(app)
admin = Admin(app)
......
......@@ -685,9 +685,9 @@ def test_default_sort():
app, db, admin = setup()
M1, _ = create_models(db)
M1(test1='c').save()
M1(test1='b').save()
M1(test1='a').save()
M1(test1='c', test2='x').save()
M1(test1='b', test2='x').save()
M1(test1='a', test2='y').save()
eq_(M1.objects.count(), 3)
......@@ -700,6 +700,18 @@ def test_default_sort():
eq_(data[1].test1, 'b')
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():
app, db, admin = setup()
......
......@@ -870,8 +870,8 @@ def test_default_sort():
M1, _ = create_models(db)
M1('c', 1).save()
M1('b', 2).save()
M1('a', 3).save()
M1('b', 1).save()
M1('a', 2).save()
eq_(M1.select().count(), 3)
......@@ -884,6 +884,18 @@ def test_default_sort():
eq_(data[1].test1, 'b')
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():
app, db, admin = setup()
......
......@@ -9,6 +9,7 @@ def setup():
app.config['CSRF_ENABLED'] = False
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///'
app.config['SQLALCHEMY_ECHO'] = True
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
db = SQLAlchemy(app)
admin = Admin(app)
......@@ -22,6 +23,7 @@ def setup_postgres():
app.config['CSRF_ENABLED'] = False
app.config['SQLALCHEMY_DATABASE_URI'] = 'postgresql://localhost/flask_admin_test'
app.config['SQLALCHEMY_ECHO'] = True
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
db = SQLAlchemy(app)
admin = Admin(app)
......
......@@ -1676,7 +1676,7 @@ def test_default_sort():
app, db, admin = setup()
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()
eq_(M1.query.count(), 3)
......@@ -1715,18 +1715,34 @@ def test_default_sort():
eq_(data[1].test1, 'b')
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():
app, db, admin = setup()
M1, M2 = create_models(db)
m1 = M1('b')
m1 = M1(test1='c', test2='x')
db.session.add(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('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()
......@@ -1738,9 +1754,30 @@ def test_complex_sort():
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)
_, 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)
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 "дорівнює"
#: ../flask_admin/contrib/pymongo/filters.py:47
#: ../flask_admin/contrib/sqla/filters.py:49
msgid "not equal"
msgstr "дорівнює"
msgstr "не дорівнює"
#: ../flask_admin/contrib/mongoengine/filters.py:58
#: ../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 ""
msgstr ""
"Project-Id-Version: flask-admin\n"
"Project-Id-Version: Flask-Admin VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2017-02-07 00:17-0600\n"
"PO-Revision-Date: 2017-02-07 01:19-0500\n"
"Last-Translator: mrjoes <serge.koval@gmail.com>\n"
"Language-Team: Chinese Traditional\n"
"POT-Creation-Date: 2017-02-07 00:19-0600\n"
"PO-Revision-Date: 2018-11-05 05:43+0800\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\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"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.1.1\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"
"Generated-By: Babel 2.6.0\n"
#: ../flask_admin/base.py:440
msgid "Home"
......@@ -23,7 +25,7 @@ msgstr "首頁"
#: ../flask_admin/contrib/rediscli.py:127
msgid "Cli: Invalid command."
msgstr "Cli: 不正確命令。"
msgstr "Cli: 錯誤的指令。"
#: ../flask_admin/contrib/fileadmin/__init__.py:352
msgid "File to upload"
......@@ -31,7 +33,7 @@ msgstr "要上傳的檔案"
#: ../flask_admin/contrib/fileadmin/__init__.py:360
msgid "File required."
msgstr "檔案是必須上傳的。"
msgstr "必要的檔案。"
#: ../flask_admin/contrib/fileadmin/__init__.py:365
msgid "Invalid file type."
......@@ -43,7 +45,7 @@ msgstr "內容"
#: ../flask_admin/contrib/fileadmin/__init__.py:390
msgid "Invalid name"
msgstr "不正確名稱"
msgstr "不正確名稱"
#: ../flask_admin/contrib/fileadmin/__init__.py:398
#: ../flask_admin/templates/bootstrap2/admin/file/list.html:106
......@@ -127,7 +129,7 @@ msgstr "已成功刪除目錄\"%(path)s\"。"
#: ../flask_admin/contrib/fileadmin/__init__.py:1013
#, python-format
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:1176
......@@ -139,7 +141,7 @@ msgstr "檔案 \"%(name)s\" 已被成功刪除。"
#: ../flask_admin/contrib/fileadmin/__init__.py:1178
#, python-format
msgid "Failed to delete file: %(name)s"
msgstr "刪除檔案的時候發生異常:%(name)s"
msgstr ""
#: ../flask_admin/contrib/fileadmin/__init__.py:1043
msgid "Renaming is disabled."
......@@ -162,7 +164,7 @@ msgstr "重新命名的時候發生異常:%(error)s"
#: ../flask_admin/contrib/fileadmin/__init__.py:1078
#, python-format
msgid "Rename %(name)s"
msgstr "重命名 %(name)s"
msgstr "重命名 %(name)s"
#: ../flask_admin/contrib/fileadmin/__init__.py:1115
#, python-format
......@@ -177,7 +179,7 @@ msgstr "對 %(name)s 成功保存的更改。"
#: ../flask_admin/contrib/fileadmin/__init__.py:1128
#, python-format
msgid "Error reading %(name)s."
msgstr "閱讀 %(name)s 錯誤。"
msgstr "讀取 %(name)s 錯誤。"
#: ../flask_admin/contrib/fileadmin/__init__.py:1131
#: ../flask_admin/contrib/fileadmin/__init__.py:1140
......@@ -188,7 +190,7 @@ msgstr "從 %(name)s 中讀取時出現意外的錯誤"
#: ../flask_admin/contrib/fileadmin/__init__.py:1137
#, python-format
msgid "Cannot edit %(name)s."
msgstr "不能編輯 %(name)s。"
msgstr "無法編輯 %(name)s。"
#: ../flask_admin/contrib/fileadmin/__init__.py:1155
#, python-format
......@@ -209,7 +211,7 @@ msgstr "您確定要刪除這些檔案嗎?"
#: ../flask_admin/contrib/fileadmin/__init__.py:1167
msgid "File deletion is disabled."
msgstr "刪除檔將被禁用。"
msgstr "刪除檔案被禁用"
#: ../flask_admin/contrib/fileadmin/__init__.py:1180
#: ../flask_admin/templates/bootstrap2/admin/model/details.html:17
......@@ -300,7 +302,7 @@ msgstr ""
#: ../flask_admin/contrib/mongoengine/view.py:551
#, python-format
msgid "Failed to get model. %(error)s"
msgstr "未能獲取模型。%(error)s"
msgstr "未能獲取資料。%(error)s"
#: ../flask_admin/contrib/mongoengine/view.py:570
#: ../flask_admin/contrib/peewee/view.py:435
......@@ -308,7 +310,7 @@ msgstr "未能獲取模型。%(error)s"
#: ../flask_admin/contrib/sqla/view.py:1078
#, python-format
msgid "Failed to create record. %(error)s"
msgstr "建立模型的時候發生異常:%(error)s"
msgstr "建立紀錄的時候發生異常:%(error)s"
#: ../flask_admin/contrib/mongoengine/view.py:596
#: ../flask_admin/contrib/peewee/view.py:454
......@@ -317,7 +319,7 @@ msgstr "建立模型的時候發生異常:%(error)s"
#: ../flask_admin/model/base.py:2313 ../flask_admin/model/base.py:2315
#, python-format
msgid "Failed to update record. %(error)s"
msgstr "更新模型的時候發生異常:%(error)s"
msgstr "更新紀錄的時候發生異常:%(error)s"
#: ../flask_admin/contrib/mongoengine/view.py:619
#: ../flask_admin/contrib/peewee/view.py:469
......@@ -325,14 +327,14 @@ msgstr "更新模型的時候發生異常:%(error)s"
#: ../flask_admin/contrib/sqla/view.py:1129
#, python-format
msgid "Failed to delete record. %(error)s"
msgstr "刪除模型的時候發生異常:%(error)s"
msgstr "刪除紀錄的時候發生異常:%(error)s"
#: ../flask_admin/contrib/mongoengine/view.py:659
#: ../flask_admin/contrib/peewee/view.py:488
#: ../flask_admin/contrib/pymongo/view.py:385
#: ../flask_admin/contrib/sqla/view.py:1150
msgid "Are you sure you want to delete selected records?"
msgstr "您確定要刪除這些模型嗎?"
msgstr "您確定要刪除這些紀錄嗎?"
#: ../flask_admin/contrib/mongoengine/view.py:668
#: ../flask_admin/contrib/peewee/view.py:505
......@@ -340,8 +342,7 @@ msgstr "您確定要刪除這些模型嗎?"
#: ../flask_admin/contrib/sqla/view.py:1166 ../flask_admin/model/base.py:2118
#, python-format
msgid "Record was successfully deleted."
msgid_plural "%(count)s records were successfully deleted."
msgstr[0] "刪除作業成功完成。"
msgstr "紀錄刪除成功。"
#: ../flask_admin/contrib/mongoengine/view.py:674
#: ../flask_admin/contrib/peewee/view.py:511
......@@ -349,14 +350,14 @@ msgstr[0] "刪除作業成功完成。"
#: ../flask_admin/contrib/sqla/view.py:1174
#, python-format
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:176
#: ../flask_admin/contrib/sqla/fields.py:181 ../flask_admin/model/fields.py:173
#: ../flask_admin/model/fields.py:222
msgid "Not a valid choice"
msgstr "炫則的值不正確。"
msgstr "選擇的值不正確"
#: ../flask_admin/contrib/sqla/fields.py:186
msgid "Key"
......@@ -374,7 +375,7 @@ msgstr "資料已經存在。"
#, python-format
msgid "At least %(num)d item is required"
msgid_plural "At least %(num)d items are required"
msgstr[0] ""
msgstr ""
#: ../flask_admin/contrib/sqla/view.py:1057
#, python-format
......@@ -391,11 +392,11 @@ msgstr "不正確選擇: 不能強迫"
#: ../flask_admin/form/fields.py:208
msgid "Invalid JSON"
msgstr "不正確 JSON"
msgstr "JSON 不正確"
#: ../flask_admin/form/upload.py:207
msgid "Invalid file extension"
msgstr "不正確副檔名"
msgstr "不正確的檔案副檔名"
#: ../flask_admin/form/upload.py:214 ../flask_admin/form/upload.py:281
#, python-format
......@@ -404,7 +405,7 @@ msgstr "檔案 \"%s\" 已經存在。"
#: ../flask_admin/model/base.py:1649
msgid "There are no items in the table."
msgstr "在表中沒有專案。"
msgstr "在表中沒有項目。"
#: ../flask_admin/model/base.py:1673
#, python-format
......@@ -413,7 +414,7 @@ msgstr "不正確篩選器值: %(value)s"
#: ../flask_admin/model/base.py:1984
msgid "Record was successfully created."
msgstr "模型建立成功。"
msgstr "資料新增成功。"
#: ../flask_admin/model/base.py:2028 ../flask_admin/model/base.py:2080
#: ../flask_admin/model/base.py:2113 ../flask_admin/model/base.py:2297
......@@ -457,7 +458,7 @@ msgstr "查看記錄"
#: ../flask_admin/templates/bootstrap2/admin/model/modals/edit.html:22
#: ../flask_admin/templates/bootstrap3/admin/model/modals/edit.html:11
msgid "Edit Record"
msgstr "編輯錄"
msgstr "編輯錄"
#: ../flask_admin/model/widgets.py:61
msgid "Please select model"
......@@ -466,7 +467,7 @@ msgstr ""
#: ../flask_admin/templates/bootstrap2/admin/actions.html:4
#: ../flask_admin/templates/bootstrap3/admin/actions.html:4
msgid "With selected"
msgstr "選中的"
msgstr "選中的 "
#: ../flask_admin/templates/bootstrap2/admin/lib.html:200
#: ../flask_admin/templates/bootstrap3/admin/lib.html:190
......@@ -481,17 +482,17 @@ msgstr "取消"
#: ../flask_admin/templates/bootstrap2/admin/lib.html:256
#: ../flask_admin/templates/bootstrap3/admin/lib.html:247
msgid "Save and Add Another"
msgstr ""
msgstr "儲存後繼續新增"
#: ../flask_admin/templates/bootstrap2/admin/lib.html:259
#: ../flask_admin/templates/bootstrap3/admin/lib.html:250
msgid "Save and Continue Editing"
msgstr ""
msgstr "儲存後繼續編輯"
#: ../flask_admin/templates/bootstrap2/admin/file/list.html:9
#: ../flask_admin/templates/bootstrap3/admin/file/list.html:9
msgid "Root"
msgstr ""
msgstr "Root"
#: ../flask_admin/templates/bootstrap2/admin/file/list.html:40
#: ../flask_admin/templates/bootstrap2/admin/file/list.html:49
......@@ -542,7 +543,7 @@ msgstr "請至少選擇一個檔案。"
#: ../flask_admin/templates/bootstrap3/admin/model/edit.html:14
#: ../flask_admin/templates/bootstrap3/admin/model/list.html:17
msgid "List"
msgstr "列表"
msgstr "資料列表"
#: ../flask_admin/templates/bootstrap2/admin/model/create.html:17
#: ../flask_admin/templates/bootstrap2/admin/model/details.html:12
......@@ -562,7 +563,7 @@ msgstr "建立"
#: ../flask_admin/templates/bootstrap3/admin/model/details.html:21
#: ../flask_admin/templates/bootstrap3/admin/model/edit.html:26
msgid "Details"
msgstr ""
msgstr "詳細資訊"
#: ../flask_admin/templates/bootstrap2/admin/model/details.html:29
#: ../flask_admin/templates/bootstrap2/admin/model/modals/details.html:8
......@@ -640,7 +641,7 @@ msgstr "創建新記錄"
#: ../flask_admin/templates/bootstrap2/admin/model/list.html:77
#: ../flask_admin/templates/bootstrap3/admin/model/list.html:76
msgid "Select all records"
msgstr "選擇所有錄"
msgstr "選擇所有錄"
#: ../flask_admin/templates/bootstrap2/admin/model/list.html:120
#: ../flask_admin/templates/bootstrap3/admin/model/list.html:119
......@@ -650,10 +651,10 @@ msgstr "選擇記錄"
#: ../flask_admin/templates/bootstrap2/admin/model/list.html:185
#: ../flask_admin/templates/bootstrap3/admin/model/list.html:186
msgid "Please select at least one record."
msgstr "請至少選擇一個模型。"
msgstr "請至少選擇一筆資料。"
#: ../flask_admin/templates/bootstrap2/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?"
msgstr "您確定要刪除這個東西嗎?"
msgstr "您確定要刪除這筆記錄嗎?"
flake8
Flask>=0.7
Flask-SQLAlchemy>=0.15
peewee
......@@ -15,3 +16,4 @@ nose
coveralls
pylint
sqlalchemy-citext
azure-storage-blob
......@@ -31,6 +31,12 @@ def grep(attrname):
return strval
extras_require = {
'aws': ['boto'],
'azure': ['azure-storage-blob']
}
install_requires = [
'Flask>=0.7',
'wtforms'
......@@ -49,6 +55,7 @@ setup(
include_package_data=True,
zip_safe=False,
platforms='any',
extras_require=extras_require,
install_requires=install_requires,
tests_require=[
'nose>=1.0',
......
......@@ -8,9 +8,11 @@ skip_missing_interpreters = true
[flake8]
max_line_length = 120
ignore = E402,E722
ignore = E402,E722,W504
[testenv]
setenv =
AZURE_STORAGE_CONNECTION_STRING = DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://127.0.0.1:10000/devstoreaccount1;
usedevelop = true
deps =
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