Commit 564944e6 authored by PJ Janse van Rensburg's avatar PJ Janse van Rensburg

Merge branch 'master' into upgrade-leaflet

parents 30f15ba5 08bdcdea
...@@ -2,20 +2,14 @@ sudo: false ...@@ -2,20 +2,14 @@ sudo: false
language: python language: python
matrix: matrix:
include: include:
- python: 2.6
env: TOX_ENV=py26-WTForms1
- python: 2.6
env: TOX_ENV=py26-WTForms2
- python: 2.7 - python: 2.7
env: TOX_ENV=py27-WTForms1 env: TOX_ENV=py27-WTForms1
- python: 2.7 - python: 2.7
env: TOX_ENV=py27-WTForms2 env: TOX_ENV=py27-WTForms2
- python: 2.7 - python: 2.7
env: TOX_ENV=flake8 env: TOX_ENV=flake8
- python: 3.3 - python: 2.7
env: TOX_ENV=py33-WTForms1 env: TOX_ENV=docs-html
- python: 3.3
env: TOX_ENV=py33-WTForms2
- python: 3.4 - python: 3.4
env: TOX_ENV=py34-WTForms1 env: TOX_ENV=py34-WTForms1
- python: 3.4 - python: 3.4
...@@ -31,15 +25,21 @@ matrix: ...@@ -31,15 +25,21 @@ matrix:
addons: addons:
postgresql: "9.4" postgresql: "9.4"
apt:
packages:
- postgresql-9.4-postgis-2.4
- postgresql-9.4-postgis-2.4-scripts
services: services:
- postgresql - postgresql
- mongodb - mongodb
- docker
before_script: before_script:
- psql -U postgres -c 'CREATE DATABASE flask_admin_test;' - psql -U postgres -c 'CREATE DATABASE flask_admin_test;'
- psql -U postgres -c 'CREATE EXTENSION postgis;' flask_admin_test - psql -U postgres -c 'CREATE EXTENSION postgis;' flask_admin_test
- psql -U postgres -c 'CREATE EXTENSION hstore;' flask_admin_test - psql -U postgres -c 'CREATE EXTENSION hstore;' flask_admin_test
- docker run --restart always -d -e executable=blob -p 10000:10000 --tmpfs /opt/azurite/folder arafato/azurite:2.6.5
install: install:
- pip install tox - pip install tox
......
...@@ -56,8 +56,7 @@ if you think of anything else that should be included, then please make the chan ...@@ -56,8 +56,7 @@ if you think of anything else that should be included, then please make the chan
To build the docs in your local environment, from the project directory:: To build the docs in your local environment, from the project directory::
pip install -r requirements-dev.txt tox -e docs-html
sudo make html
And if you want to preview any *.rst* snippets that you may want to contribute, go to `http://rst.ninjs.org/ <http://rst.ninjs.org/>`_. And if you want to preview any *.rst* snippets that you may want to contribute, go to `http://rst.ninjs.org/ <http://rst.ninjs.org/>`_.
...@@ -99,7 +98,6 @@ For all the tests to pass successfully, you'll need Postgres & MongoDB to be run ...@@ -99,7 +98,6 @@ For all the tests to pass successfully, you'll need Postgres & MongoDB to be run
> psql flask_admin_test > psql flask_admin_test
CREATE EXTENSION postgis; CREATE EXTENSION postgis;
CREATE EXTENSION hstore; CREATE EXTENSION hstore;
\q
You can also run the tests on multiple environments using *tox*. You can also run the tests on multiple environments using *tox*.
......
...@@ -34,7 +34,7 @@ Enabling localization is simple: ...@@ -34,7 +34,7 @@ Enabling localization is simple:
#. Initialize Flask-BabelEx by creating instance of `Babel` class:: #. Initialize Flask-BabelEx by creating instance of `Babel` class::
from flask import app from flask import Flask
from flask_babelex import Babel from flask_babelex import Babel
app = Flask(__name__) app = Flask(__name__)
......
Changelog Changelog
========= =========
1.5.2
-----
* Fixed XSS vulnerability
* Fixed Peewee support
* Added detail view column formatters
* Updated Flask-Login example to work with the newer version of the library
* Various SQLAlchemy-related fixes
* Various Windows related fixes for the file admin
1.5.1
-----
* Dropped Python 2.6 support
* Fixed SQLAlchemy >= 1.2 compatibility
* Fixed Pewee 3.0 compatibility
* Fixed max year for a combo date inline editor
* Lots of small bug fixes
1.5.0 1.5.0
----- -----
......
...@@ -41,7 +41,7 @@ Support ...@@ -41,7 +41,7 @@ Support
**** ****
Python 2.6 - 2.7 and 3.3 - 3.4. Python 2.7 and 3.3 or higher.
Indices And Tables Indices And Tables
------------------ ------------------
......
...@@ -80,6 +80,9 @@ are a few different ways of approaching this. ...@@ -80,6 +80,9 @@ are a few different ways of approaching this.
HTTP Basic Auth HTTP Basic Auth
--------------- ---------------
Unfortunately, there is no easy way of applying HTTP Basic Auth just to your admin
interface.
The simplest form of authentication is HTTP Basic Auth. It doesn't interfere The simplest form of authentication is HTTP Basic Auth. It doesn't interfere
with your database models, and it doesn't require you to write any new view logic or with your database models, and it doesn't require you to write any new view logic or
template code. So it's great for when you're deploying something that's still template code. So it's great for when you're deploying something that's still
...@@ -88,9 +91,6 @@ under development, before you want the whole world to see it. ...@@ -88,9 +91,6 @@ under development, before you want the whole world to see it.
Have a look at `Flask-BasicAuth <https://flask-basicauth.readthedocs.io/>`_ to see just how Have a look at `Flask-BasicAuth <https://flask-basicauth.readthedocs.io/>`_ to see just how
easy it is to put your whole application behind HTTP Basic Auth. easy it is to put your whole application behind HTTP Basic Auth.
Unfortunately, there is no easy way of applying HTTP Basic Auth just to your admin
interface.
Rolling Your Own Rolling Your Own
---------------- ----------------
For a more flexible solution, Flask-Admin lets you define access control rules For a more flexible solution, Flask-Admin lets you define access control rules
......
...@@ -32,12 +32,17 @@ class User(db.Model): ...@@ -32,12 +32,17 @@ class User(db.Model):
password = db.Column(db.String(64)) password = db.Column(db.String(64))
# Flask-Login integration # Flask-Login integration
# NOTE: is_authenticated, is_active, and is_anonymous
# are methods in Flask-Login < 0.3.0
@property
def is_authenticated(self): def is_authenticated(self):
return True return True
@property
def is_active(self): def is_active(self):
return True return True
@property
def is_anonymous(self): def is_anonymous(self):
return False return False
......
...@@ -27,12 +27,17 @@ class User(db.Document): ...@@ -27,12 +27,17 @@ class User(db.Document):
password = db.StringField(max_length=64) password = db.StringField(max_length=64)
# Flask-Login integration # Flask-Login integration
# NOTE: is_authenticated, is_active, and is_anonymous
# are methods in Flask-Login < 0.3.0
@property
def is_authenticated(self): def is_authenticated(self):
return True return True
@property
def is_active(self): def is_active(self):
return True return True
@property
def is_anonymous(self): def is_anonymous(self):
return False return False
......
...@@ -32,7 +32,7 @@ To run this example: ...@@ -32,7 +32,7 @@ To run this example:
5. Run the application:: 5. Run the application::
python examples/sqla/app.py python examples/geo_alchemy/app.py
6. You will notice that the maps are not rendered. To see them, you will have 6. You will notice that the maps are not rendered. To see them, you will have
to register for a free account at `Mapbox <https://www.mapbox.com/>`_ and set to register for a free account at `Mapbox <https://www.mapbox.com/>`_ and set
......
{% extends 'admin/model/list.html' %} {% extends 'admin/model/list.html' %}
{% import 'admin/model/layout.html' as model_layout with context %} {% import 'admin/model/layout.html' as model_layout with context %}
{% block brand %} {% block model_menu_bar %}
<h2 id="brand">{{ admin_view.name|capitalize }} list</h2> <h2 id="brand">{{ admin_view.name|capitalize }} list</h2>
{% if admin_view.can_create %} {% if admin_view.can_create %}
<div class="btn-menu"> <div class="btn-menu">
...@@ -29,6 +29,3 @@ ...@@ -29,6 +29,3 @@
<div class="clearfix"></div> <div class="clearfix"></div>
<hr> <hr>
{% endblock %} {% endblock %}
{% block model_menu_bar %}
{% endblock %}
\ No newline at end of file
from flask import Flask, redirect, url_for from flask import Flask, redirect, url_for
from flask.ext import login from flask_login import current_user, UserMixin, login_user, logout_user, LoginManager
from flask_login import current_user, UserMixin
from flask_admin.base import MenuLink, Admin, BaseView, expose from flask_admin.base import MenuLink, Admin, BaseView, expose
...@@ -52,17 +51,17 @@ def index(): ...@@ -52,17 +51,17 @@ def index():
@app.route('/login/') @app.route('/login/')
def login_view(): def login_view():
login.login_user(User()) login_user(User())
return redirect(url_for('admin.index')) return redirect(url_for('admin.index'))
@app.route('/logout/') @app.route('/logout/')
def logout_view(): def logout_view():
login.logout_user() logout_user()
return redirect(url_for('admin.index')) return redirect(url_for('admin.index'))
login_manager = login.LoginManager() login_manager = LoginManager()
login_manager.init_app(app) login_manager.init_app(app)
......
TinyMongo model backend integration example.
TinyMongo is the Pymongo for TinyDB and it stores data in JSON files.
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/tinymongo/requirements.txt'
4. Run the application::
python examples/tinymongo/app.py
"""
Example of Flask-Admin using TinyDB with TinyMongo
refer to README.txt for instructions
Author: Bruno Rocha <@rochacbruno>
Based in PyMongo Example and TinyMongo
"""
import flask_admin as admin
from flask import Flask
from flask_admin.contrib.pymongo import ModelView, filters
from flask_admin.form import Select2Widget
from flask_admin.model.fields import InlineFieldList, InlineFormField
from wtforms import fields, form
from tinymongo import TinyMongoClient
# Create application
app = Flask(__name__)
# Create dummy secrey key so we can use sessions
app.config['SECRET_KEY'] = '123456790'
# Create models in a JSON file localted at
DATAFOLDER = '/tmp/flask_admin_test'
conn = TinyMongoClient(DATAFOLDER)
db = conn.test
# create some users for testing
# for i in range(30):
# db.user.insert({'name': 'Mike %s' % i})
# User admin
class InnerForm(form.Form):
name = fields.StringField('Name')
test = fields.StringField('Test')
class UserForm(form.Form):
foo = fields.StringField('foo')
name = fields.StringField('Name')
email = fields.StringField('Email')
password = fields.StringField('Password')
# Inner form
inner = InlineFormField(InnerForm)
# Form list
form_list = InlineFieldList(InlineFormField(InnerForm))
class UserView(ModelView):
column_list = ('name', 'email', 'password', 'foo')
column_sortable_list = ('name', 'email', 'password')
form = UserForm
page_size = 20
can_set_page_size = True
# Tweet view
class TweetForm(form.Form):
name = fields.StringField('Name')
user_id = fields.SelectField('User', widget=Select2Widget())
text = fields.StringField('Text')
testie = fields.BooleanField('Test')
class TweetView(ModelView):
column_list = ('name', 'user_name', 'text')
column_sortable_list = ('name', 'text')
column_filters = (filters.FilterEqual('name', 'Name'),
filters.FilterNotEqual('name', 'Name'),
filters.FilterLike('name', 'Name'),
filters.FilterNotLike('name', 'Name'),
filters.BooleanEqualFilter('testie', 'Testie'))
# column_searchable_list = ('name', 'text')
form = TweetForm
def get_list(self, *args, **kwargs):
count, data = super(TweetView, self).get_list(*args, **kwargs)
# Contribute user_name to the models
for item in data:
item['user_name'] = db.user.find_one(
{'_id': item['user_id']}
)['name']
return count, data
# Contribute list of user choices to the forms
def _feed_user_choices(self, form):
users = db.user.find(fields=('name',))
form.user_id.choices = [(str(x['_id']), x['name']) for x in users]
return form
def create_form(self):
form = super(TweetView, self).create_form()
return self._feed_user_choices(form)
def edit_form(self, obj):
form = super(TweetView, self).edit_form(obj)
return self._feed_user_choices(form)
# Correct user_id reference before saving
def on_model_change(self, form, model):
user_id = model.get('user_id')
model['user_id'] = user_id
return model
# 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: TinyMongo - TinyDB')
# Add views
admin.add_view(UserView(db.user, 'User'))
admin.add_view(TweetView(db.tweet, 'Tweets'))
# Start app
app.run(debug=True)
Flask
Flask-Admin
pymongo==2.4.1
git+https://github.com/schapman1974/tinymongo.git#egg=tinymongo
__version__ = '1.5.0' __version__ = '1.5.2'
__author__ = 'Flask-Admin team' __author__ = 'Flask-Admin team'
__email__ = 'serge.koval+github@gmail.com' __email__ = 'serge.koval+github@gmail.com'
......
...@@ -107,6 +107,20 @@ class LocalFileStorage(object): ...@@ -107,6 +107,20 @@ class LocalFileStorage(object):
""" """
return send_file(file_path) return send_file(file_path)
def read_file(self, path):
"""
Reads the content of the file located at `file_path`.
"""
with open(path, 'rb') as f:
return f.read()
def write_file(self, path, content):
"""
Writes `content` to the file located at `file_path`.
"""
with open(path, 'w') as f:
return f.write(content)
def save_file(self, path, file_data): def save_file(self, path, file_data):
""" """
Save uploaded file to the disk Save uploaded file to the disk
...@@ -821,13 +835,19 @@ class BaseFileAdmin(BaseView, ActionsMixin): ...@@ -821,13 +835,19 @@ class BaseFileAdmin(BaseView, ActionsMixin):
sort_column = request.args.get('sort', None, type=str) sort_column = request.args.get('sort', None, type=str)
sort_desc = request.args.get('desc', 0, type=int) sort_desc = request.args.get('desc', 0, type=int)
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
if sort_column is None: if sort_column is None:
# Sort by name # Sort by name
items.sort(key=itemgetter(0)) items.sort(key=itemgetter(0))
# Sort by type # Sort by type
items.sort(key=itemgetter(2), reverse=True) items.sort(key=itemgetter(2), reverse=True)
# Sort by modified date # 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: else:
column_index = self.possible_columns.index(sort_column) column_index = self.possible_columns.index(sort_column)
items.sort(key=itemgetter(column_index), reverse=sort_desc) items.sort(key=itemgetter(column_index), reverse=sort_desc)
...@@ -842,13 +862,16 @@ class BaseFileAdmin(BaseView, ActionsMixin): ...@@ -842,13 +862,16 @@ class BaseFileAdmin(BaseView, ActionsMixin):
else: else:
action_form = None action_form = None
def sort_url(column, invert=False): def sort_url(column, path, invert=False):
desc = None desc = None
if not path:
path = None
if invert and not sort_desc: if invert and not sort_desc:
desc = 1 desc = 1
return self.get_url('.index_view', sort=column, desc=desc) return self.get_url('.index_view', path=path, sort=column, desc=desc)
return self.render(self.list_template, return self.render(self.list_template,
dir_path=path, dir_path=path,
...@@ -1109,8 +1132,7 @@ class BaseFileAdmin(BaseView, ActionsMixin): ...@@ -1109,8 +1132,7 @@ class BaseFileAdmin(BaseView, ActionsMixin):
form.process(request.form, content='') form.process(request.form, content='')
if form.validate(): if form.validate():
try: try:
with open(full_path, 'w') as f: self.storage.write_file(full_path, request.form['content'])
f.write(request.form['content'])
except IOError: except IOError:
flash(gettext("Error saving changes to %(name)s.", name=path), 'error') flash(gettext("Error saving changes to %(name)s.", name=path), 'error')
error = True error = True
...@@ -1122,8 +1144,7 @@ class BaseFileAdmin(BaseView, ActionsMixin): ...@@ -1122,8 +1144,7 @@ class BaseFileAdmin(BaseView, ActionsMixin):
helpers.flash_errors(form, message='Failed to edit file. %(error)s') helpers.flash_errors(form, message='Failed to edit file. %(error)s')
try: try:
with open(full_path, 'rb') as f: content = self.storage.read_file(full_path)
content = f.read()
except IOError: except IOError:
flash(gettext("Error reading %(name)s.", name=path), 'error') flash(gettext("Error reading %(name)s.", name=path), 'error')
error = True error = True
......
from __future__ import absolute_import
from datetime import datetime
from datetime import timedelta
from time import sleep
import os.path as op
try:
from azure.storage.blob import BlobPermissions
from azure.storage.blob import BlockBlobService
except ImportError:
BlobPermissions = BlockBlobService = None
from flask import redirect
from . import BaseFileAdmin
class AzureStorage(object):
"""
Storage object representing files on an Azure Storage container.
Usage::
from flask_admin.contrib.fileadmin import BaseFileAdmin
from flask_admin.contrib.fileadmin.azure import AzureStorage
class MyAzureAdmin(BaseFileAdmin):
# Configure your class however you like
pass
fileadmin_view = MyAzureAdmin(storage=AzureStorage(...))
"""
_fakedir = '.dir'
_copy_poll_interval_seconds = 1
_send_file_lookback = timedelta(minutes=15)
_send_file_validity = timedelta(hours=1)
separator = '/'
def __init__(self, container_name, connection_string):
"""
Constructor
:param container_name:
Name of the container that the files are on.
:param connection_string:
Azure Blob Storage Connection String
"""
if not BlockBlobService:
raise ValueError('Could not import Azure Blob Storage SDK. '
'You can install the SDK using '
'pip install azure-storage-blob')
self._container_name = container_name
self._connection_string = connection_string
self.__client = None
@property
def _client(self):
if not self.__client:
self.__client = BlockBlobService(
connection_string=self._connection_string)
self.__client.create_container(
self._container_name, fail_on_exist=False)
return self.__client
@classmethod
def _get_blob_last_modified(cls, blob):
last_modified = blob.properties.last_modified
tzinfo = last_modified.tzinfo
epoch = last_modified - datetime(1970, 1, 1, tzinfo=tzinfo)
return epoch.total_seconds()
@classmethod
def _ensure_blob_path(cls, path):
if path is None:
return None
path_parts = path.split(op.sep)
return cls.separator.join(path_parts).lstrip(cls.separator)
def get_files(self, path, directory):
if directory and path != directory:
path = op.join(path, directory)
path = self._ensure_blob_path(path)
directory = self._ensure_blob_path(directory)
path_parts = path.split(self.separator) if path else []
num_path_parts = len(path_parts)
folders = set()
files = []
for blob in self._client.list_blobs(self._container_name, path):
blob_path_parts = blob.name.split(self.separator)
name = blob_path_parts.pop()
blob_is_file_at_current_level = blob_path_parts == path_parts
blob_is_directory_file = name == self._fakedir
if blob_is_file_at_current_level and not blob_is_directory_file:
rel_path = blob.name
is_dir = False
size = blob.properties.content_length
last_modified = self._get_blob_last_modified(blob)
files.append((name, rel_path, is_dir, size, last_modified))
else:
next_level_folder = blob_path_parts[:num_path_parts + 1]
folder_name = self.separator.join(next_level_folder)
folders.add(folder_name)
folders.discard(directory)
for folder in folders:
name = folder.split(self.separator)[-1]
rel_path = folder
is_dir = True
size = 0
last_modified = 0
files.append((name, rel_path, is_dir, size, last_modified))
return files
def is_dir(self, path):
path = self._ensure_blob_path(path)
num_blobs = 0
for blob in self._client.list_blobs(self._container_name, path):
blob_path_parts = blob.name.split(self.separator)
is_explicit_directory = blob_path_parts[-1] == self._fakedir
if is_explicit_directory:
return True
num_blobs += 1
path_cannot_be_leaf = num_blobs >= 2
if path_cannot_be_leaf:
return True
return False
def path_exists(self, path):
path = self._ensure_blob_path(path)
if path == self.get_base_path():
return True
try:
next(iter(self._client.list_blobs(self._container_name, path)))
except StopIteration:
return False
else:
return True
def get_base_path(self):
return ''
def get_breadcrumbs(self, path):
path = self._ensure_blob_path(path)
accumulator = []
breadcrumbs = []
for folder in path.split(self.separator):
accumulator.append(folder)
breadcrumbs.append((folder, self.separator.join(accumulator)))
return breadcrumbs
def send_file(self, file_path):
file_path = self._ensure_blob_path(file_path)
if not self._client.exists(self._container_name, file_path):
raise ValueError()
now = datetime.utcnow()
url = self._client.make_blob_url(self._container_name, file_path)
sas = self._client.generate_blob_shared_access_signature(
self._container_name, file_path,
BlobPermissions.READ,
expiry=now + self._send_file_validity,
start=now - self._send_file_lookback)
return redirect('%s?%s' % (url, sas))
def read_file(self, path):
path = self._ensure_blob_path(path)
blob = self._client.get_blob_to_bytes(self._container_name, path)
return blob.content
def write_file(self, path, content):
path = self._ensure_blob_path(path)
self._client.create_blob_from_text(self._container_name, path, content)
def save_file(self, path, file_data):
path = self._ensure_blob_path(path)
self._client.create_blob_from_stream(self._container_name, path,
file_data.stream)
def delete_tree(self, directory):
directory = self._ensure_blob_path(directory)
for blob in self._client.list_blobs(self._container_name, directory):
self._client.delete_blob(self._container_name, blob.name)
def delete_file(self, file_path):
file_path = self._ensure_blob_path(file_path)
self._client.delete_blob(self._container_name, file_path)
def make_dir(self, path, directory):
path = self._ensure_blob_path(path)
directory = self._ensure_blob_path(directory)
blob = self.separator.join([path, directory, self._fakedir])
blob = blob.lstrip(self.separator)
self._client.create_blob_from_text(self._container_name, blob, '')
def _copy_blob(self, src, dst):
src_url = self._client.make_blob_url(self._container_name, src)
copy = self._client.copy_blob(self._container_name, dst, src_url)
while copy.status != 'success':
sleep(self._copy_poll_interval_seconds)
copy = self._client.get_blob_properties(
self._container_name, dst).properties.copy
def _rename_file(self, src, dst):
self._copy_blob(src, dst)
self.delete_file(src)
def _rename_directory(self, src, dst):
for blob in self._client.list_blobs(self._container_name, src):
self._rename_file(blob.name, blob.name.replace(src, dst, 1))
def rename_path(self, src, dst):
src = self._ensure_blob_path(src)
dst = self._ensure_blob_path(dst)
if self.is_dir(src):
self._rename_directory(src, dst)
else:
self._rename_file(src, dst)
class AzureFileAdmin(BaseFileAdmin):
"""
Simple Azure Blob Storage file-management interface.
:param container_name:
Name of the container that the files are on.
:param connection_string:
Azure Blob Storage Connection String
Sample usage::
from flask_admin import Admin
from flask_admin.contrib.fileadmin.azure import AzureFileAdmin
admin = Admin()
admin.add_view(AzureFileAdmin('files_container', 'my-connection-string')
"""
def __init__(self, container_name, connection_string, *args, **kwargs):
storage = AzureStorage(container_name, connection_string)
super(AzureFileAdmin, self).__init__(*args, storage=storage, **kwargs)
...@@ -166,6 +166,14 @@ class S3Storage(object): ...@@ -166,6 +166,14 @@ class S3Storage(object):
keys = self._get_path_keys(path + self.separator) keys = self._get_path_keys(path + self.separator)
return len(keys) == 1 return len(keys) == 1
def read_file(self, path):
key = Key(self.bucket, path)
return key.get_contents_as_string()
def write_file(self, path, content):
key = Key(self.bucket, path)
key.set_contents_from_file(content)
class S3FileAdmin(BaseFileAdmin): class S3FileAdmin(BaseFileAdmin):
""" """
......
...@@ -8,10 +8,14 @@ from .widgets import LeafletWidget ...@@ -8,10 +8,14 @@ from .widgets import LeafletWidget
class GeoJSONField(JSONField): class GeoJSONField(JSONField):
widget = LeafletWidget()
def __init__(self, label=None, validators=None, geometry_type="GEOMETRY", def __init__(self, label=None, validators=None, geometry_type="GEOMETRY",
srid='-1', session=None, **kwargs): srid='-1', session=None, tile_layer_url=None,
tile_layer_attribution=None, **kwargs):
self.widget = LeafletWidget(
tile_layer_url=tile_layer_url,
tile_layer_attribution=tile_layer_attribution
)
super(GeoJSONField, self).__init__(label, validators, **kwargs) super(GeoJSONField, self).__init__(label, validators, **kwargs)
self.web_srid = 4326 self.web_srid = 4326
self.srid = srid self.srid = srid
......
...@@ -9,4 +9,6 @@ class AdminModelConverter(SQLAAdminConverter): ...@@ -9,4 +9,6 @@ class AdminModelConverter(SQLAAdminConverter):
field_args['geometry_type'] = column.type.geometry_type field_args['geometry_type'] = column.type.geometry_type
field_args['srid'] = column.type.srid field_args['srid'] = column.type.srid
field_args['session'] = self.session field_args['session'] = self.session
field_args['tile_layer_url'] = self.view.tile_layer_url
field_args['tile_layer_attribution'] = self.view.tile_layer_attribution
return GeoJSONField(**field_args) return GeoJSONField(**field_args)
...@@ -14,6 +14,8 @@ def geom_formatter(view, value): ...@@ -14,6 +14,8 @@ def geom_formatter(view, value):
"data-height": 70, "data-height": 70,
"data-geometry-type": to_shape(value).geom_type, "data-geometry-type": to_shape(value).geom_type,
"data-zoom": 15, "data-zoom": 15,
"data-tile-layer-url": view.tile_layer_url,
"data-tile-layer-attribution": view.tile_layer_attribution
}) })
if value.srid is -1: if value.srid is -1:
value.srid = 4326 value.srid = 4326
......
...@@ -5,3 +5,5 @@ from flask_admin.contrib.geoa import form, typefmt ...@@ -5,3 +5,5 @@ from flask_admin.contrib.geoa import form, typefmt
class ModelView(SQLAModelView): class ModelView(SQLAModelView):
model_form_converter = form.AdminModelConverter model_form_converter = form.AdminModelConverter
column_type_formatters = typefmt.DEFAULT_FORMATTERS column_type_formatters = typefmt.DEFAULT_FORMATTERS
tile_layer_url = None
tile_layer_attribution = None
...@@ -23,7 +23,8 @@ class LeafletWidget(TextArea): ...@@ -23,7 +23,8 @@ class LeafletWidget(TextArea):
""" """
def __init__( def __init__(
self, width='auto', height=350, center=None, self, width='auto', height=350, center=None,
zoom=None, min_zoom=None, max_zoom=None, max_bounds=None): zoom=None, min_zoom=None, max_zoom=None, max_bounds=None,
tile_layer_url=None, tile_layer_attribution=None):
self.width = width self.width = width
self.height = height self.height = height
self.center = center self.center = center
...@@ -31,6 +32,8 @@ class LeafletWidget(TextArea): ...@@ -31,6 +32,8 @@ class LeafletWidget(TextArea):
self.min_zoom = min_zoom self.min_zoom = min_zoom
self.max_zoom = max_zoom self.max_zoom = max_zoom
self.max_bounds = max_bounds self.max_bounds = max_bounds
self.tile_layer_url = tile_layer_url
self.tile_layer_attribution = tile_layer_attribution
def __call__(self, field, **kwargs): def __call__(self, field, **kwargs):
kwargs.setdefault('data-role', self.data_role) kwargs.setdefault('data-role', self.data_role)
...@@ -38,6 +41,10 @@ class LeafletWidget(TextArea): ...@@ -38,6 +41,10 @@ class LeafletWidget(TextArea):
kwargs.setdefault('data-geometry-type', gtype) kwargs.setdefault('data-geometry-type', gtype)
# set optional values from constructor # set optional values from constructor
if self.tile_layer_url:
kwargs['data-tile-layer-url'] = self.tile_layer_url
if self.tile_layer_attribution:
kwargs['data-tile-layer-attribution'] = self.tile_layer_attribution
if "data-width" not in kwargs: if "data-width" not in kwargs:
kwargs["data-width"] = self.width kwargs["data-width"] = self.width
if "data-height" not in kwargs: if "data-height" not in kwargs:
......
from wtforms import fields from wtforms import fields
from peewee import (CharField, DateTimeField, DateField, TimeField, from peewee import (CharField, DateTimeField, DateField, TimeField,
PrimaryKeyField, ForeignKeyField, BaseModel) PrimaryKeyField, ForeignKeyField)
try:
from peewee import BaseModel
except ImportError:
from peewee import ModelBase as BaseModel
from wtfpeewee.orm import ModelConverter, model_form from wtfpeewee.orm import ModelConverter, model_form
...@@ -265,7 +270,10 @@ class InlineModelConverter(InlineModelConverterBase): ...@@ -265,7 +270,10 @@ class InlineModelConverter(InlineModelConverterBase):
allow_pk=True, allow_pk=True,
converter=converter) converter=converter)
prop_name = reverse_field.related_name try:
prop_name = reverse_field.related_name
except AttributeError:
prop_name = reverse_field.backref
label = self.get_label(info, prop_name) label = self.get_label(info, prop_name)
......
...@@ -14,8 +14,8 @@ def parse_like_term(term): ...@@ -14,8 +14,8 @@ def parse_like_term(term):
def get_meta_fields(model): def get_meta_fields(model):
try: if hasattr(model._meta, 'sorted_fields'):
fields = model._meta.sorted_fields fields = model._meta.sorted_fields
except AttributeError: else:
fields = model._meta.get_fields() fields = model._meta.get_fields()
return fields return fields
...@@ -2,18 +2,18 @@ import logging ...@@ -2,18 +2,18 @@ import logging
from flask import flash from flask import flash
from flask_admin._compat import string_types, iteritems from flask_admin._compat import string_types
from flask_admin.babel import gettext, ngettext, lazy_gettext from flask_admin.babel import gettext, ngettext, lazy_gettext
from flask_admin.model import BaseModelView from flask_admin.model import BaseModelView
from flask_admin.model.form import create_editable_list_form from flask_admin.model.form import create_editable_list_form
from peewee import PrimaryKeyField, ForeignKeyField, Field, CharField, TextField from peewee import JOIN, PrimaryKeyField, ForeignKeyField, Field, CharField, TextField
from flask_admin.actions import action from flask_admin.actions import action
from flask_admin.contrib.peewee import filters from flask_admin.contrib.peewee import filters
from .form import get_form, CustomModelConverter, InlineModelConverter, save_inline from .form import get_form, CustomModelConverter, InlineModelConverter, save_inline
from .tools import get_primary_key, parse_like_term from .tools import get_meta_fields, get_primary_key, parse_like_term
from .ajax import create_ajax_loader from .ajax import create_ajax_loader
# Set up logger # Set up logger
...@@ -176,7 +176,9 @@ class ModelView(BaseModelView): ...@@ -176,7 +176,9 @@ class ModelView(BaseModelView):
if model is None: if model is None:
model = self.model model = self.model
return iteritems(model._meta.fields) return (
(field.name, field)
for field in get_meta_fields(model))
def scaffold_pk(self): def scaffold_pk(self):
return get_primary_key(self.model) return get_primary_key(self.model)
...@@ -213,10 +215,8 @@ class ModelView(BaseModelView): ...@@ -213,10 +215,8 @@ class ModelView(BaseModelView):
if isinstance(p, string_types): if isinstance(p, string_types):
p = getattr(self.model, p) p = getattr(self.model, p)
field_type = type(p)
# Check type # Check type
if (field_type != CharField and field_type != TextField): if not isinstance(p, (CharField, TextField)):
raise Exception('Can only search on text columns. ' + raise Exception('Can only search on text columns. ' +
'Failed to setup search for "%s"' % p) 'Failed to setup search for "%s"' % p)
...@@ -234,14 +234,24 @@ class ModelView(BaseModelView): ...@@ -234,14 +234,24 @@ class ModelView(BaseModelView):
raise Exception('Failed to find field for filter: %s' % name) raise Exception('Failed to find field for filter: %s' % name)
# Check if field is in different model # Check if field is in different model
if attr.model_class != self.model: try:
visible_name = '%s / %s' % (self.get_column_name(attr.model_class.__name__), if attr.model_class != self.model:
self.get_column_name(attr.name)) visible_name = '%s / %s' % (self.get_column_name(attr.model_class.__name__),
else: self.get_column_name(attr.name))
if not isinstance(name, string_types):
visible_name = self.get_column_name(attr.name)
else: else:
visible_name = self.get_column_name(name) if not isinstance(name, string_types):
visible_name = self.get_column_name(attr.name)
else:
visible_name = self.get_column_name(name)
except AttributeError:
if attr.model != self.model:
visible_name = '%s / %s' % (self.get_column_name(attr.model.__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)
type_name = type(attr).__name__ type_name = type(attr).__name__
flt = self.filter_converter.convert(type_name, flt = self.filter_converter.convert(type_name,
...@@ -307,12 +317,20 @@ class ModelView(BaseModelView): ...@@ -307,12 +317,20 @@ class ModelView(BaseModelView):
return create_ajax_loader(self.model, name, name, options) return create_ajax_loader(self.model, name, name, options)
def _handle_join(self, query, field, joins): def _handle_join(self, query, field, joins):
if field.model_class != self.model: try:
model_name = field.model_class.__name__ 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)
except AttributeError:
if field.model != self.model:
model_name = field.model.__name__
if model_name not in joins: if model_name not in joins:
query = query.join(field.model_class) query = query.join(field.model, JOIN.LEFT_OUTER)
joins.add(model_name) joins.add(model_name)
return query return query
...@@ -321,8 +339,12 @@ class ModelView(BaseModelView): ...@@ -321,8 +339,12 @@ class ModelView(BaseModelView):
field = getattr(self.model, sort_field) field = getattr(self.model, sort_field)
query = query.order_by(field.desc() if sort_desc else field.asc()) query = query.order_by(field.desc() if sort_desc else field.asc())
elif isinstance(sort_field, Field): elif isinstance(sort_field, Field):
if sort_field.model_class != self.model: try:
query = self._handle_join(query, sort_field, joins) if sort_field.model_class != self.model:
query = self._handle_join(query, sort_field, joins)
except AttributeError:
if sort_field.model != self.model:
query = self._handle_join(query, sort_field, joins)
query = query.order_by(sort_field.desc() if sort_desc else sort_field.asc()) query = query.order_by(sort_field.desc() if sort_desc else sort_field.asc())
......
...@@ -238,7 +238,7 @@ class ModelView(BaseModelView): ...@@ -238,7 +238,7 @@ class ModelView(BaseModelView):
for flt, flt_name, value in filters: for flt, flt_name, value in filters:
f = self._filters[flt] f = self._filters[flt]
data = f.apply(data, value) data = f.apply(data, f.clean(value))
if data: if data:
if len(data) == 1: if len(data) == 1:
......
from sqlalchemy import or_, and_ from sqlalchemy import or_, and_, cast
from sqlalchemy.types import String
from flask_admin._compat import as_unicode, string_types from flask_admin._compat import as_unicode, string_types
from flask_admin.model.ajax import AjaxModelLoader, DEFAULT_PAGE_SIZE from flask_admin.model.ajax import AjaxModelLoader, DEFAULT_PAGE_SIZE
...@@ -55,7 +56,7 @@ class QueryAjaxModelLoader(AjaxModelLoader): ...@@ -55,7 +56,7 @@ class QueryAjaxModelLoader(AjaxModelLoader):
if not model: if not model:
return None return None
return (getattr(model, self.pk), as_unicode(model)) return getattr(model, self.pk), as_unicode(model)
def get_one(self, pk): def get_one(self, pk):
# prevent autoflush from occuring during populate_obj # prevent autoflush from occuring during populate_obj
...@@ -65,11 +66,11 @@ class QueryAjaxModelLoader(AjaxModelLoader): ...@@ -65,11 +66,11 @@ class QueryAjaxModelLoader(AjaxModelLoader):
def get_list(self, term, offset=0, limit=DEFAULT_PAGE_SIZE): def get_list(self, term, offset=0, limit=DEFAULT_PAGE_SIZE):
query = self.session.query(self.model) query = self.session.query(self.model)
filters = (field.ilike(u'%%%s%%' % term) for field in self._cached_fields) filters = (cast(field, String).ilike(u'%%%s%%' % term) for field in self._cached_fields)
query = query.filter(or_(*filters)) query = query.filter(or_(*filters))
if self.filters: if self.filters:
filters = ["%s.%s" % (self.model.__name__.lower(), value) for value in self.filters] filters = ["%s.%s" % (self.model.__tablename__.lower(), value) for value in self.filters]
query = query.filter(and_(*filters)) query = query.filter(and_(*filters))
if self.order_by: if self.order_by:
......
...@@ -276,7 +276,7 @@ class InlineModelFormList(InlineFieldList): ...@@ -276,7 +276,7 @@ class InlineModelFormList(InlineFieldList):
# Handle request data # Handle request data
for field in self.entries: for field in self.entries:
field_id = field.get_pk() field_id = str(field.get_pk())
is_created = field_id not in pk_map is_created = field_id not in pk_map
if not is_created: if not is_created:
...@@ -296,5 +296,5 @@ class InlineModelFormList(InlineFieldList): ...@@ -296,5 +296,5 @@ class InlineModelFormList(InlineFieldList):
def get_pk_from_identity(obj): def get_pk_from_identity(obj):
# TODO: Remove me # TODO: Remove me
cls, key = identity_key(instance=obj) key = identity_key(instance=obj)[1]
return u':'.join(text_type(x) for x in key) return u':'.join(text_type(x) for x in key)
...@@ -373,7 +373,7 @@ class FilterConverter(filters.BaseFilterConverter): ...@@ -373,7 +373,7 @@ class FilterConverter(filters.BaseFilterConverter):
@filters.convert('string', 'char', 'unicode', 'varchar', 'tinytext', @filters.convert('string', 'char', 'unicode', 'varchar', 'tinytext',
'text', 'mediumtext', 'longtext', 'unicodetext', 'text', 'mediumtext', 'longtext', 'unicodetext',
'nchar', 'nvarchar', 'ntext') 'nchar', 'nvarchar', 'ntext', 'citext')
def conv_string(self, column, name, **kwargs): def conv_string(self, column, name, **kwargs):
return [f(column, name, **kwargs) for f in self.strings] return [f(column, name, **kwargs) for f in self.strings]
...@@ -381,12 +381,12 @@ class FilterConverter(filters.BaseFilterConverter): ...@@ -381,12 +381,12 @@ class FilterConverter(filters.BaseFilterConverter):
def conv_bool(self, column, name, **kwargs): def conv_bool(self, column, name, **kwargs):
return [f(column, name, **kwargs) for f in self.bool_filters] return [f(column, name, **kwargs) for f in self.bool_filters]
@filters.convert('int', 'integer', 'smallinteger', 'smallint', 'numeric', @filters.convert('int', 'integer', 'smallinteger', 'smallint',
'biginteger', 'bigint', 'mediumint') 'biginteger', 'bigint', 'mediumint')
def conv_int(self, column, name, **kwargs): def conv_int(self, column, name, **kwargs):
return [f(column, name, **kwargs) for f in self.int_filters] return [f(column, name, **kwargs) for f in self.int_filters]
@filters.convert('float', 'real', 'decimal', 'double_precision', 'double') @filters.convert('float', 'real', 'decimal', 'numeric', 'double_precision', 'double')
def conv_float(self, column, name, **kwargs): def conv_float(self, column, name, **kwargs):
return [f(column, name, **kwargs) for f in self.float_filters] return [f(column, name, **kwargs) for f in self.float_filters]
......
import warnings import warnings
from enum import Enum
from wtforms import fields, validators from wtforms import fields, validators
from sqlalchemy import Boolean, Column from sqlalchemy import Boolean, Column
...@@ -9,7 +10,7 @@ from flask_admin.model.form import (converts, ModelConverterBase, ...@@ -9,7 +10,7 @@ from flask_admin.model.form import (converts, ModelConverterBase,
from flask_admin.model.fields import AjaxSelectField, AjaxSelectMultipleField from flask_admin.model.fields import AjaxSelectField, AjaxSelectMultipleField
from flask_admin.model.helpers import prettify_name from flask_admin.model.helpers import prettify_name
from flask_admin._backwards import get_property from flask_admin._backwards import get_property
from flask_admin._compat import iteritems from flask_admin._compat import iteritems, text_type
from .validators import Unique from .validators import Unique
from .fields import (QuerySelectField, QuerySelectMultipleField, from .fields import (QuerySelectField, QuerySelectMultipleField,
...@@ -154,7 +155,9 @@ class AdminModelConverter(ModelConverterBase): ...@@ -154,7 +155,9 @@ class AdminModelConverter(ModelConverterBase):
if len(prop.columns) > 1: if len(prop.columns) > 1:
columns = filter_foreign_columns(model.__table__, prop.columns) columns = filter_foreign_columns(model.__table__, prop.columns)
if len(columns) > 1: if len(columns) == 0:
return None
elif len(columns) > 1:
warnings.warn('Can not convert multiple-column properties (%s.%s)' % (model, prop.key)) warnings.warn('Can not convert multiple-column properties (%s.%s)' % (model, prop.key))
return None return None
...@@ -264,7 +267,7 @@ class AdminModelConverter(ModelConverterBase): ...@@ -264,7 +267,7 @@ class AdminModelConverter(ModelConverterBase):
@classmethod @classmethod
def _string_common(cls, column, field_args, **extra): def _string_common(cls, column, field_args, **extra):
if isinstance(column.type.length, int) and column.type.length: if hasattr(column.type, 'length') and isinstance(column.type.length, int) and column.type.length:
field_args['validators'].append(validators.Length(max=column.type.length)) field_args['validators'].append(validators.Length(max=column.type.length))
@converts('String') # includes VARCHAR, CHAR, and Unicode @converts('String') # includes VARCHAR, CHAR, and Unicode
...@@ -279,6 +282,7 @@ class AdminModelConverter(ModelConverterBase): ...@@ -279,6 +282,7 @@ class AdminModelConverter(ModelConverterBase):
accepted_values.append(None) accepted_values.append(None)
field_args['validators'].append(validators.AnyOf(accepted_values)) field_args['validators'].append(validators.AnyOf(accepted_values))
field_args['coerce'] = lambda v: v.name if isinstance(v, Enum) else text_type(v)
return form.Select2Field(**field_args) return form.Select2Field(**field_args)
...@@ -290,7 +294,7 @@ class AdminModelConverter(ModelConverterBase): ...@@ -290,7 +294,7 @@ class AdminModelConverter(ModelConverterBase):
self._string_common(column=column, field_args=field_args, **extra) self._string_common(column=column, field_args=field_args, **extra)
return fields.StringField(**field_args) return fields.StringField(**field_args)
@converts('Text', 'LargeBinary', 'Binary') # includes UnicodeText @converts('Text', 'LargeBinary', 'Binary', 'CIText') # includes UnicodeText
def conv_Text(self, field_args, **extra): def conv_Text(self, field_args, **extra):
self._string_common(field_args=field_args, **extra) self._string_common(field_args=field_args, **extra)
return fields.TextAreaField(**field_args) return fields.TextAreaField(**field_args)
......
...@@ -75,14 +75,14 @@ def tuple_operator_in(model_pk, ids): ...@@ -75,14 +75,14 @@ def tuple_operator_in(model_pk, ids):
The returning operator can be used within a filter(), as it is just an or_ operator The returning operator can be used within a filter(), as it is just an or_ operator
""" """
l = [] ands = []
for id in ids: for id in ids:
k = [] k = []
for i in range(len(model_pk)): for i in range(len(model_pk)):
k.append(eq(model_pk[i], id[i])) k.append(eq(model_pk[i], id[i]))
l.append(and_(*k)) ands.append(and_(*k))
if len(l) >= 1: if len(ands) >= 1:
return or_(*l) return or_(*ands)
else: else:
return None return None
......
...@@ -420,7 +420,9 @@ class ModelView(BaseModelView): ...@@ -420,7 +420,9 @@ class ModelView(BaseModelView):
if len(p.columns) > 1: if len(p.columns) > 1:
filtered = tools.filter_foreign_columns(self.model.__table__, p.columns) filtered = tools.filter_foreign_columns(self.model.__table__, p.columns)
if len(filtered) > 1: if len(filtered) == 0:
continue
elif len(filtered) > 1:
warnings.warn('Can not convert multiple-column properties (%s.%s)' % (self.model, p.key)) warnings.warn('Can not convert multiple-column properties (%s.%s)' % (self.model, p.key))
continue continue
...@@ -594,7 +596,7 @@ class ModelView(BaseModelView): ...@@ -594,7 +596,7 @@ class ModelView(BaseModelView):
if column.foreign_keys or column.primary_key: if column.foreign_keys or column.primary_key:
continue continue
visible_name = '%s / %s' % (self.get_column_name(attr.prop.table.name), visible_name = '%s / %s' % (self.get_column_name(attr.prop.target.name),
self.get_column_name(p.key)) self.get_column_name(p.key))
type_name = type(column.type).__name__ type_name = type(column.type).__name__
...@@ -763,6 +765,12 @@ class ModelView(BaseModelView): ...@@ -763,6 +765,12 @@ class ModelView(BaseModelView):
if p.mapper.class_ == self.model: if p.mapper.class_ == self.model:
continue continue
# Check if it is pointing to a differnet bind
source_bind = getattr(self.model, '__bind_key__', None)
target_bind = getattr(p.mapper.class_, '__bind_key__', None)
if source_bind != target_bind:
continue
if p.direction.name in ['MANYTOONE', 'MANYTOMANY']: if p.direction.name in ['MANYTOONE', 'MANYTOMANY']:
relations.add(p.key) relations.add(p.key)
......
...@@ -8,6 +8,9 @@ from flask_admin._compat import urljoin, urlparse, iteritems ...@@ -8,6 +8,9 @@ from flask_admin._compat import urljoin, urlparse, iteritems
from ._compat import string_types from ._compat import string_types
VALID_SCHEMES = ['http', 'https']
def set_current_view(view): def set_current_view(view):
g._admin_view = view g._admin_view = view
...@@ -128,10 +131,16 @@ def prettify_class_name(name): ...@@ -128,10 +131,16 @@ def prettify_class_name(name):
def is_safe_url(target): def is_safe_url(target):
# prevent urls starting with "javascript:"
target = target.strip()
target_info = urlparse(target)
target_scheme = target_info.scheme
if target_scheme and target_scheme not in VALID_SCHEMES:
return False
ref_url = urlparse(request.host_url) ref_url = urlparse(request.host_url)
test_url = urlparse(urljoin(request.host_url, target)) test_url = urlparse(urljoin(request.host_url, target))
return (test_url.scheme in ('http', 'https') and return ref_url.netloc == test_url.netloc
ref_url.netloc == test_url.netloc)
def get_redirect_target(param_name='url'): def get_redirect_target(param_name='url'):
......
...@@ -263,6 +263,16 @@ class BaseModelView(BaseView, ActionsMixin): ...@@ -263,6 +263,16 @@ class BaseModelView(BaseView, ActionsMixin):
that macros are not supported. that macros are not supported.
""" """
column_formatters_detail = None
"""
Dictionary of list view column formatters to be used for the detail view.
Defaults to column_formatters when set to None.
Functions the same way as column_formatters except
that macros are not supported.
"""
column_type_formatters = ObsoleteAttr('column_type_formatters', 'list_type_formatters', None) column_type_formatters = ObsoleteAttr('column_type_formatters', 'list_type_formatters', None)
""" """
Dictionary of value type formatters to be used in the list view. Dictionary of value type formatters to be used in the list view.
...@@ -319,6 +329,18 @@ class BaseModelView(BaseView, ActionsMixin): ...@@ -319,6 +329,18 @@ class BaseModelView(BaseView, ActionsMixin):
Functions the same way as column_type_formatters. Functions the same way as column_type_formatters.
""" """
column_type_formatters_detail = None
"""
Dictionary of value type formatters to be used in the detail view.
By default, two types are formatted:
1. ``None`` will be displayed as an empty string
2. ``list`` will be joined using ', '
Functions the same way as column_type_formatters.
"""
column_labels = ObsoleteAttr('column_labels', 'rename_columns', None) column_labels = ObsoleteAttr('column_labels', 'rename_columns', None)
""" """
Dictionary where key is column name and value is string to display. Dictionary where key is column name and value is string to display.
...@@ -889,6 +911,9 @@ class BaseModelView(BaseView, ActionsMixin): ...@@ -889,6 +911,9 @@ class BaseModelView(BaseView, ActionsMixin):
if self.column_formatters_export is None: if self.column_formatters_export is None:
self.column_formatters_export = self.column_formatters self.column_formatters_export = self.column_formatters
if self.column_formatters_detail is None:
self.column_formatters_detail = self.column_formatters
# Type formatters # Type formatters
if self.column_type_formatters is None: if self.column_type_formatters is None:
self.column_type_formatters = dict(typefmt.BASE_FORMATTERS) self.column_type_formatters = dict(typefmt.BASE_FORMATTERS)
...@@ -896,6 +921,9 @@ class BaseModelView(BaseView, ActionsMixin): ...@@ -896,6 +921,9 @@ class BaseModelView(BaseView, ActionsMixin):
if self.column_type_formatters_export is None: if self.column_type_formatters_export is None:
self.column_type_formatters_export = dict(typefmt.EXPORT_FORMATTERS) self.column_type_formatters_export = dict(typefmt.EXPORT_FORMATTERS)
if self.column_type_formatters_detail is None:
self.column_type_formatters_detail = dict(typefmt.DETAIL_FORMATTERS)
if self.column_descriptions is None: if self.column_descriptions is None:
self.column_descriptions = dict() self.column_descriptions = dict()
...@@ -1518,12 +1546,15 @@ class BaseModelView(BaseView, ActionsMixin): ...@@ -1518,12 +1546,15 @@ class BaseModelView(BaseView, ActionsMixin):
""" """
try: try:
self.on_model_change(form, model, is_created) self.on_model_change(form, model, is_created)
except TypeError: except TypeError as e:
msg = ('%s.on_model_change() now accepts third ' + if re.match(r'on_model_change\(\) takes .* 3 .* arguments .* 4 .* given .*', e.message):
'parameter is_created. Please update your code') % self.model msg = ('%s.on_model_change() now accepts third ' +
warnings.warn(msg) 'parameter is_created. Please update your code') % self.model
warnings.warn(msg)
self.on_model_change(form, model) self.on_model_change(form, model)
else:
raise
def after_model_change(self, form, model, is_created): def after_model_change(self, form, model, is_created):
""" """
...@@ -1803,6 +1834,26 @@ class BaseModelView(BaseView, ActionsMixin): ...@@ -1803,6 +1834,26 @@ class BaseModelView(BaseView, ActionsMixin):
self.column_type_formatters, self.column_type_formatters,
) )
@contextfunction
def get_detail_value(self, context, model, name):
"""
Returns the value to be displayed in the detail view
:param context:
:py:class:`jinja2.runtime.Context`
:param model:
Model instance
:param name:
Field name
"""
return self._get_list_value(
context,
model,
name,
self.column_formatters_detail,
self.column_type_formatters_detail,
)
def get_export_value(self, model, name): def get_export_value(self, model, name):
""" """
Returns the value to be displayed in export. Returns the value to be displayed in export.
...@@ -2103,7 +2154,7 @@ class BaseModelView(BaseView, ActionsMixin): ...@@ -2103,7 +2154,7 @@ class BaseModelView(BaseView, ActionsMixin):
return self.render(template, return self.render(template,
model=model, model=model,
details_columns=self._details_columns, details_columns=self._details_columns,
get_value=self.get_list_value, get_value=self.get_detail_value,
return_url=return_url) return_url=return_url)
@expose('/delete/', methods=('POST',)) @expose('/delete/', methods=('POST',))
......
...@@ -106,7 +106,7 @@ class DeleteRowAction(TemplateLinkRowAction): ...@@ -106,7 +106,7 @@ class DeleteRowAction(TemplateLinkRowAction):
def __init__(self): def __init__(self):
super(DeleteRowAction, self).__init__( super(DeleteRowAction, self).__init__(
'row_actions.delete_row', 'row_actions.delete_row',
gettext('Edit Record')) gettext('Delete Record'))
# Macro helper # Macro helper
......
...@@ -84,6 +84,13 @@ EXPORT_FORMATTERS = { ...@@ -84,6 +84,13 @@ EXPORT_FORMATTERS = {
dict: dict_formatter, dict: dict_formatter,
} }
DETAIL_FORMATTERS = {
type(None): empty_formatter,
list: list_formatter,
dict: dict_formatter,
}
if Enum is not None: if Enum is not None:
BASE_FORMATTERS[Enum] = enum_formatter BASE_FORMATTERS[Enum] = enum_formatter
EXPORT_FORMATTERS[Enum] = enum_formatter EXPORT_FORMATTERS[Enum] = enum_formatter
DETAIL_FORMATTERS[Enum] = enum_formatter
...@@ -72,7 +72,8 @@ class XEditableWidget(object): ...@@ -72,7 +72,8 @@ class XEditableWidget(object):
field inside of the FieldList (StringField, IntegerField, etc). field inside of the FieldList (StringField, IntegerField, etc).
""" """
def __call__(self, field, **kwargs): def __call__(self, field, **kwargs):
kwargs.setdefault('data-value', kwargs.pop('display_value', '')) display_value = kwargs.pop('display_value', '')
kwargs.setdefault('data-value', display_value)
kwargs.setdefault('data-role', 'x-editable') kwargs.setdefault('data-role', 'x-editable')
kwargs.setdefault('data-url', './ajax/update/') kwargs.setdefault('data-url', './ajax/update/')
...@@ -91,7 +92,7 @@ class XEditableWidget(object): ...@@ -91,7 +92,7 @@ class XEditableWidget(object):
return HTMLString( return HTMLString(
'<a %s>%s</a>' % (html_params(**kwargs), '<a %s>%s</a>' % (html_params(**kwargs),
escape(kwargs['data-value'])) escape(display_value))
) )
def get_kwargs(self, field, kwargs): def get_kwargs(self, field, kwargs):
...@@ -104,7 +105,7 @@ class XEditableWidget(object): ...@@ -104,7 +105,7 @@ class XEditableWidget(object):
kwargs['data-type'] = 'textarea' kwargs['data-type'] = 'textarea'
kwargs['data-rows'] = '5' kwargs['data-rows'] = '5'
elif field.type == 'BooleanField': elif field.type == 'BooleanField':
kwargs['data-type'] = 'select' kwargs['data-type'] = 'select2'
# data-source = dropdown options # data-source = dropdown options
kwargs['data-source'] = json.dumps([ kwargs['data-source'] = json.dumps([
{'value': '', 'text': gettext('No')}, {'value': '', 'text': gettext('No')},
...@@ -112,7 +113,7 @@ class XEditableWidget(object): ...@@ -112,7 +113,7 @@ class XEditableWidget(object):
]) ])
kwargs['data-role'] = 'x-editable-boolean' kwargs['data-role'] = 'x-editable-boolean'
elif field.type in ['Select2Field', 'SelectField']: elif field.type in ['Select2Field', 'SelectField']:
kwargs['data-type'] = 'select' kwargs['data-type'] = 'select2'
choices = [{'value': x, 'text': y} for x, y in field.choices] choices = [{'value': x, 'text': y} for x, y in field.choices]
# prepend a blank field to choices if allow_blank = True # prepend a blank field to choices if allow_blank = True
...@@ -144,7 +145,7 @@ class XEditableWidget(object): ...@@ -144,7 +145,7 @@ class XEditableWidget(object):
elif field.type in ['QuerySelectField', 'ModelSelectField', elif field.type in ['QuerySelectField', 'ModelSelectField',
'QuerySelectMultipleField', 'KeyPropertyField']: 'QuerySelectMultipleField', 'KeyPropertyField']:
# QuerySelectField and ModelSelectField are for relations # QuerySelectField and ModelSelectField are for relations
kwargs['data-type'] = 'select' kwargs['data-type'] = 'select2'
choices = [] choices = []
selected_ids = [] selected_ids = []
...@@ -162,12 +163,13 @@ class XEditableWidget(object): ...@@ -162,12 +163,13 @@ class XEditableWidget(object):
kwargs['data-source'] = json.dumps(choices) kwargs['data-source'] = json.dumps(choices)
if field.type == 'QuerySelectMultipleField': if field.type == 'QuerySelectMultipleField':
kwargs['data-type'] = 'select2'
kwargs['data-role'] = 'x-editable-select2-multiple' kwargs['data-role'] = 'x-editable-select2-multiple'
# must use id instead of text or prefilled values won't work # must use id instead of text or prefilled values won't work
separator = getattr(field, 'separator', ',') separator = getattr(field, 'separator', ',')
kwargs['data-value'] = separator.join(selected_ids) kwargs['data-value'] = separator.join(selected_ids)
else:
kwargs['data-value'] = text_type(selected_ids[0])
else: else:
raise Exception('Unsupported field type: %s' % (type(field),)) raise Exception('Unsupported field type: %s' % (type(field),))
......
...@@ -28,7 +28,7 @@ ...@@ -28,7 +28,7 @@
/* List View - fix gap between actions and table */ /* List View - fix gap between actions and table */
.model-list { .model-list {
position: relative; position: static;
margin-top: -1px; margin-top: -1px;
z-index: 999; z-index: 999;
} }
...@@ -139,3 +139,7 @@ table.filters tr td { ...@@ -139,3 +139,7 @@ table.filters tr td {
*/ */
#no-more-tables td:before { content: attr(data-title); } #no-more-tables td:before { content: attr(data-title); }
} }
.editable-input .select2-container {
min-width: 220px;
}
...@@ -28,7 +28,7 @@ ...@@ -28,7 +28,7 @@
/* List View - fix overlapping border between actions and table */ /* List View - fix overlapping border between actions and table */
.model-list { .model-list {
position: relative; position: static;
margin-top: -1px; margin-top: -1px;
z-index: 999; z-index: 999;
} }
......
var AdminModelActions = function(actionErrorMessage, actionConfirmations) { var AdminModelActions = function(actionErrorMessage, actionConfirmations) {
// Actions helpers. TODO: Move to separate file // Actions helpers. TODO: Move to separate file
this.execute = function(name) { this.execute = function(name) {
var selected = $('input.action-checkbox:checked').size(); var selected = $('input.action-checkbox:checked').length;
if (selected === 0) { if (selected === 0) {
alert(actionErrorMessage); alert(actionErrorMessage);
...@@ -48,3 +48,6 @@ var AdminModelActions = function(actionErrorMessage, actionConfirmations) { ...@@ -48,3 +48,6 @@ var AdminModelActions = function(actionErrorMessage, actionConfirmations) {
}); });
}); });
}; };
if ($('#actions_confirmation').length == 1) {
var modelActions = new AdminModelActions(JSON.parse($('#message-data').text()), JSON.parse($('#actions-confirmation-data').text()));
}
...@@ -2,3 +2,8 @@ ...@@ -2,3 +2,8 @@
$('.modal').on('hidden', function() { $('.modal').on('hidden', function() {
$(this).removeData('modal'); $(this).removeData('modal');
}); });
$(function() {
// Apply flask-admin form styles after the modal is loaded
window.faForm.applyGlobalStyles(document);
});
...@@ -2,3 +2,8 @@ ...@@ -2,3 +2,8 @@
$('body').on('hidden.bs.modal', '.modal', function () { $('body').on('hidden.bs.modal', '.modal', function () {
$(this).removeData('bs.modal').find(".modal-content").empty(); $(this).removeData('bs.modal').find(".modal-content").empty();
}); });
$(function() {
// Apply flask-admin form styles after the modal is loaded
window.faForm.applyGlobalStyles(document);
});
...@@ -2,23 +2,23 @@ var AdminFilters = function(element, filtersElement, filterGroups, activeFilters ...@@ -2,23 +2,23 @@ var AdminFilters = function(element, filtersElement, filterGroups, activeFilters
var $root = $(element); var $root = $(element);
var $container = $('.filters', $root); var $container = $('.filters', $root);
var lastCount = 0; var lastCount = 0;
function getCount(name) { function getCount(name) {
var idx = name.indexOf('_'); var idx = name.indexOf('_');
if (idx === -1) { if (idx === -1) {
return 0; return 0;
} }
return parseInt(name.substr(3, idx - 3), 10); return parseInt(name.substr(3, idx - 3), 10);
} }
function makeName(name) { function makeName(name) {
var result = 'flt' + lastCount + '_' + name; var result = 'flt' + lastCount + '_' + name;
lastCount += 1; lastCount += 1;
return result; return result;
} }
function removeFilter() { function removeFilter() {
$(this).closest('tr').remove(); $(this).closest('tr').remove();
if($('.filters tr').length == 0) { if($('.filters tr').length == 0) {
...@@ -28,23 +28,23 @@ var AdminFilters = function(element, filtersElement, filterGroups, activeFilters ...@@ -28,23 +28,23 @@ var AdminFilters = function(element, filtersElement, filterGroups, activeFilters
} else { } else {
$('button', $root).show(); $('button', $root).show();
} }
return false; return false;
} }
// triggered when the filter operation (equals, not equals, etc) is changed // triggered when the filter operation (equals, not equals, etc) is changed
function changeOperation(subfilters, $el, filter, $select) { function changeOperation(subfilters, $el, filter, $select) {
// get the filter_group subfilter based on the index of the selected option // get the filter_group subfilter based on the index of the selected option
var selectedFilter = subfilters[$select.select2('data').element[0].index]; var selectedFilter = subfilters[$select.select2('data').element[0].index];
var $inputContainer = $el.find('td').last(); var $inputContainer = $el.find('td').last();
// recreate and style the input field (turn into date range or select2 if necessary) // recreate and style the input field (turn into date range or select2 if necessary)
var $field = createFilterInput($inputContainer, null, selectedFilter); var $field = createFilterInput($inputContainer, null, selectedFilter);
styleFilterInput(selectedFilter, $field); styleFilterInput(selectedFilter, $field);
$('button', $root).show(); $('button', $root).show();
} }
// generate HTML for filter input - allows changing filter input type to one with options or tags // generate HTML for filter input - allows changing filter input type to one with options or tags
function createFilterInput(inputContainer, filterValue, filter) { function createFilterInput(inputContainer, filterValue, filter) {
if (filter.type == "select2-tags") { if (filter.type == "select2-tags") {
...@@ -52,7 +52,7 @@ var AdminFilters = function(element, filtersElement, filterGroups, activeFilters ...@@ -52,7 +52,7 @@ var AdminFilters = function(element, filtersElement, filterGroups, activeFilters
$field.val(filterValue); $field.val(filterValue);
} else if (filter.options) { } else if (filter.options) {
var $field = $('<select class="filter-val" />').attr('name', makeName(filter.arg)); var $field = $('<select class="filter-val" />').attr('name', makeName(filter.arg));
$(filter.options).each(function() { $(filter.options).each(function() {
// for active filter inputs with options, add "selected" if there is a matching active filter // for active filter inputs with options, add "selected" if there is a matching active filter
if (filterValue && (filterValue == this[0])) { if (filterValue && (filterValue == this[0])) {
...@@ -68,10 +68,10 @@ var AdminFilters = function(element, filtersElement, filterGroups, activeFilters ...@@ -68,10 +68,10 @@ var AdminFilters = function(element, filtersElement, filterGroups, activeFilters
$field.val(filterValue); $field.val(filterValue);
} }
inputContainer.replaceWith($('<td/>').append($field)); inputContainer.replaceWith($('<td/>').append($field));
return $field; return $field;
} }
// add styling to input field, accommodates filters that change the input field's HTML // add styling to input field, accommodates filters that change the input field's HTML
function styleFilterInput(filter, field) { function styleFilterInput(filter, field) {
if (filter.type) { if (filter.type) {
...@@ -90,19 +90,19 @@ var AdminFilters = function(element, filtersElement, filterGroups, activeFilters ...@@ -90,19 +90,19 @@ var AdminFilters = function(element, filtersElement, filterGroups, activeFilters
// save tag options as json on data attribute // save tag options as json on data attribute
field.attr('data-tags', JSON.stringify(options)); field.attr('data-tags', JSON.stringify(options));
} }
} }
faForm.applyStyle(field, filter.type); faForm.applyStyle(field, filter.type);
} else if (filter.options) { } else if (filter.options) {
filter.type = "select2"; filter.type = "select2";
faForm.applyStyle(field, filter.type); faForm.applyStyle(field, filter.type);
} }
return field; return field;
} }
function addFilter(name, subfilters, selectedIndex, filterValue) { function addFilter(name, subfilters, selectedIndex, filterValue) {
var $el = $('<tr class="form-horizontal" />').appendTo($container); var $el = $('<tr class="form-horizontal" />').appendTo($container);
// Filter list // Filter list
$el.append( $el.append(
$('<td/>').append( $('<td/>').append(
...@@ -113,10 +113,10 @@ var AdminFilters = function(element, filtersElement, filterGroups, activeFilters ...@@ -113,10 +113,10 @@ var AdminFilters = function(element, filtersElement, filterGroups, activeFilters
.click(removeFilter) .click(removeFilter)
) )
); );
// Filter operation <select> (equal, not equal, etc) // Filter operation <select> (equal, not equal, etc)
var $select = $('<select class="filter-op" />'); var $select = $('<select class="filter-op" />');
// if one of the subfilters are selected, use that subfilter to create the input field // if one of the subfilters are selected, use that subfilter to create the input field
var filterSelection = 0; var filterSelection = 0;
$.each(subfilters, function( subfilterIndex, subfilter ) { $.each(subfilters, function( subfilterIndex, subfilter ) {
...@@ -127,55 +127,69 @@ var AdminFilters = function(element, filtersElement, filterGroups, activeFilters ...@@ -127,55 +127,69 @@ var AdminFilters = function(element, filtersElement, filterGroups, activeFilters
$select.append($('<option/>').attr('value', subfilter.arg).text(subfilter.operation)); $select.append($('<option/>').attr('value', subfilter.arg).text(subfilter.operation));
} }
}); });
$el.append( $el.append(
$('<td/>').append($select) $('<td/>').append($select)
); );
// select2 for filter-op (equal, not equal, etc) // select2 for filter-op (equal, not equal, etc)
$select.select2({width: 'resolve'}).on("change", function(e) { $select.select2({width: 'resolve'}).on("change", function(e) {
changeOperation(subfilters, $el, filter, $select); changeOperation(subfilters, $el, filter, $select);
}); });
// get filter option from filter_group, only for new filter creation // get filter option from filter_group, only for new filter creation
var filter = subfilters[filterSelection]; var filter = subfilters[filterSelection];
var $inputContainer = $('<td/>').appendTo($el); var $inputContainer = $('<td/>').appendTo($el);
var $newFilterField = createFilterInput($inputContainer, filterValue, filter).focus(); var $newFilterField = createFilterInput($inputContainer, filterValue, filter).focus();
var $styledFilterField = styleFilterInput(filter, $newFilterField); var $styledFilterField = styleFilterInput(filter, $newFilterField);
return $styledFilterField; return $styledFilterField;
} }
// Add Filter Button, new filter // Add Filter Button, new filter
$('a.filter', filtersElement).click(function() { $('a.filter', filtersElement).click(function() {
var name = ($(this).text().trim !== undefined ? $(this).text().trim() : $(this).text().replace(/^\s+|\s+$/g,'')); var name = ($(this).text().trim !== undefined ? $(this).text().trim() : $(this).text().replace(/^\s+|\s+$/g,''));
addFilter(name, filterGroups[name], false, null); addFilter(name, filterGroups[name], false, null);
$('button', $root).show(); $('button', $root).show();
}); });
// on page load - add active filters // on page load - add active filters
$.each(activeFilters, function( activeIndex, activeFilter ) { $.each(activeFilters, function( activeIndex, activeFilter ) {
var idx = activeFilter[0], var idx = activeFilter[0],
name = activeFilter[1], name = activeFilter[1],
filterValue = activeFilter[2]; filterValue = activeFilter[2];
var $activeField = addFilter(name, filterGroups[name], idx, filterValue); var $activeField = addFilter(name, filterGroups[name], idx, filterValue);
}); });
// show "Apply Filter" button when filter input is changed // show "Apply Filter" button when filter input is changed
$('.filter-val', $root).on('input change', function() { $('.filter-val', $root).on('input change', function() {
$('button', $root).show(); $('button', $root).show();
}); });
$('.remove-filter', $root).click(removeFilter); $('.remove-filter', $root).click(removeFilter);
$('.filter-val', $root).not('.select2-container').each(function() { $('.filter-val', $root).not('.select2-container').each(function() {
var count = getCount($(this).attr('name')); var count = getCount($(this).attr('name'));
if (count > lastCount) if (count > lastCount)
lastCount = count; lastCount = count;
}); });
lastCount += 1; lastCount += 1;
}; };
(function($) {
$('[data-role=tooltip]').tooltip({
html: true,
placement: 'bottom'
});
if ($('#filter-groups-data').length == 1) {
var filter = new AdminFilters(
'#filter_form', '.field-filters',
JSON.parse($('#filter-groups-data').text()),
JSON.parse($('#active-filters-data').text())
);
}
})(jQuery);
...@@ -157,11 +157,19 @@ ...@@ -157,11 +157,19 @@
} }
// set up tiles // set up tiles
var mapboxVersion = window.MAPBOX_ACCESS_TOKEN ? 4 : 3; if($el.data('tile-layer-url')){
L.tileLayer('//{s}.tiles.mapbox.com/v'+mapboxVersion+'/'+MAPBOX_MAP_ID+'/{z}/{x}/{y}.png?access_token='+window.MAPBOX_ACCESS_TOKEN, { var attribution = $el.data('tile-layer-attribution') || ''
attribution: 'Map data &copy; <a href="//openstreetmap.org">OpenStreetMap</a> contributors, <a href="//creativecommons.org/licenses/by-sa/2.0/">CC-BY-SA</a>, Imagery © <a href="//mapbox.com">Mapbox</a>', L.tileLayer('//'+$el.data('tile-layer-url'), {
maxZoom: 18 attribution: attribution,
}).addTo(map); maxZoom: 18
}).addTo(map)
} else {
var mapboxVersion = window.MAPBOX_ACCESS_TOKEN ? 4 : 3;
L.tileLayer('//{s}.tiles.mapbox.com/v'+mapboxVersion+'/'+MAPBOX_MAP_ID+'/{z}/{x}/{y}.png?access_token='+window.MAPBOX_ACCESS_TOKEN, {
attribution: 'Map data &copy; <a href="//openstreetmap.org">OpenStreetMap</a> contributors, <a href="//creativecommons.org/licenses/by-sa/2.0/">CC-BY-SA</a>, Imagery © <a href="//mapbox.com">Mapbox</a>',
maxZoom: 18
}).addTo(map);
}
// everything below here is to set up editing, so if we're not editable, // everything below here is to set up editing, so if we're not editable,
...@@ -451,7 +459,8 @@ ...@@ -451,7 +459,8 @@
params: overrideXeditableParams, params: overrideXeditableParams,
combodate: { combodate: {
// prevent minutes from showing in 5 minute increments // prevent minutes from showing in 5 minute increments
minuteStep: 1 minuteStep: 1,
maxYear: 2030,
} }
}); });
return true; return true;
...@@ -595,9 +604,11 @@ ...@@ -595,9 +604,11 @@
// Add on event handler // Add on event handler
$('body').on('click', '.inline-remove-field' , function(e) { $('body').on('click', '.inline-remove-field' , function(e) {
e.preventDefault(); e.preventDefault();
var r = confirm($('.inline-remove-field').attr('value'));
var form = $(this).closest('.inline-field'); var form = $(this).closest('.inline-field');
if ( r == true ){
form.remove(); form.remove();
}
}); });
// Expose faForm globally // Expose faForm globally
......
...@@ -79,6 +79,7 @@ var RedisCli = function(postUrl) { ...@@ -79,6 +79,7 @@ var RedisCli = function(postUrl) {
sendCommand(val); sendCommand(val);
$input.val(''); $input.val('');
return false;
} }
function onKeyPress(e) { function onKeyPress(e) {
...@@ -114,3 +115,7 @@ var RedisCli = function(postUrl) { ...@@ -114,3 +115,7 @@ var RedisCli = function(postUrl) {
sendCommand('ping'); sendCommand('ping');
}; };
$(function() {
var redisCli = new RedisCli(JSON.parse($('#execute-view-data').text()));
});
...@@ -27,9 +27,8 @@ ...@@ -27,9 +27,8 @@
{% macro script(message, actions, actions_confirmation) %} {% macro script(message, actions, actions_confirmation) %}
{% if actions %} {% if actions %}
<script src="{{ admin_static.url(filename='admin/js/actions.js', v='1.0.0') }}"></script> <div id="actions-confirmation-data" style="display:none;">{{ actions_confirmation|tojson|safe }}</div>
<script language="javascript"> <div id="message-data" style="display:none;">{{ message|tojson|safe }}</div>
var modelActions = new AdminModelActions({{ message|tojson|safe }}, {{ actions_confirmation|tojson|safe }}); <script src="{{ admin_static.url(filename='admin/js/actions.js', v='1.0.0') }}"></script>
</script>
{% endif %} {% endif %}
{% endmacro %} {% endmacro %}
...@@ -33,7 +33,7 @@ ...@@ -33,7 +33,7 @@
</head> </head>
<body> <body>
{% block page_body %} {% block page_body %}
<div class="container"> <div class="container{%if config.get('FLASK_ADMIN_FLUID_LAYOUT', False) %}-fluid{% endif %}">
<div class="navbar"> <div class="navbar">
<div class="navbar-inner"> <div class="navbar-inner">
{% block brand %} {% block brand %}
......
...@@ -37,7 +37,7 @@ ...@@ -37,7 +37,7 @@
<th> <th>
{% if admin_view.is_column_sortable(column) %} {% if admin_view.is_column_sortable(column) %}
{% if sort_column == column %} {% if sort_column == column %}
<a href="{{ sort_url(column, True) }}" title="{{ _gettext('Sort by %(name)s', name=column) }}"> <a href="{{ sort_url(column, dir_path, True) }}" title="{{ _gettext('Sort by %(name)s', name=column) }}">
{{ admin_view.column_label(column) }} {{ admin_view.column_label(column) }}
{% if sort_desc %} {% if sort_desc %}
<i class="fa fa-chevron-up icon-chevron-up"></i> <i class="fa fa-chevron-up icon-chevron-up"></i>
...@@ -46,7 +46,7 @@ ...@@ -46,7 +46,7 @@
{% endif %} {% endif %}
</a> </a>
{% else %} {% else %}
<a href="{{ sort_url(column) }}" title="{{ _gettext('Sort by %(name)s', name=column) }}">{{ admin_view.column_label(column) }}</a> <a href="{{ sort_url(column, dir_path) }}" title="{{ _gettext('Sort by %(name)s', name=column) }}">{{ admin_view.column_label(column) }}</a>
{% endif %} {% endif %}
{% else %} {% else %}
{{ _gettext(admin_view.column_label(column)) }} {{ _gettext(admin_view.column_label(column)) }}
......
...@@ -12,7 +12,7 @@ ...@@ -12,7 +12,7 @@
<input type="checkbox" name="del-{{ subfield.id }}" id="del-{{ subfield.id }}" /> <input type="checkbox" name="del-{{ subfield.id }}" id="del-{{ subfield.id }}" />
<label for="del-{{ subfield.id }}" style="display: inline">{{ _gettext('Delete?') }}</label> <label for="del-{{ subfield.id }}" style="display: inline">{{ _gettext('Delete?') }}</label>
{% else %} {% else %}
<a href="javascript:void(0)" class="inline-remove-field"><i class="fa fa-times icon-remove"></i></a> <a href="javascript:void(0)" value="{{ _gettext('Are you sure you want to delete this record?') }}" class="inline-remove-field"><i class="fa fa-times icon-remove"></i></a>
{% endif %} {% endif %}
</div> </div>
</legend> </legend>
...@@ -29,7 +29,7 @@ ...@@ -29,7 +29,7 @@
<legend> <legend>
{{ _gettext('New') }} {{ field.label.text }} {{ _gettext('New') }} {{ field.label.text }}
<div class="pull-right"> <div class="pull-right">
<a href="javascript:void(0)" class="inline-remove-field"><i class="fa fa-times icon-remove"></i></a> <a href="javascript:void(0)" value="{{ _gettext('Are you sure you want to delete this record?') }}" class="inline-remove-field"><i class="fa fa-times icon-remove"></i></a>
</div> </div>
</legend> </legend>
{{ render(template) }} {{ render(template) }}
......
...@@ -179,26 +179,16 @@ ...@@ -179,26 +179,16 @@
{% block tail %} {% block tail %}
{{ super() }} {{ super() }}
{{ lib.form_js() }}
{% 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 %}
<script src="{{ admin_static.url(filename='admin/js/filters.js', v='1.0.0') }}"></script> <script src="{{ admin_static.url(filename='admin/js/filters.js', v='1.0.0') }}"></script>
{{ lib.form_js() }}
{{ actionlib.script(_gettext('Please select at least one record.'), {{ actionlib.script(_gettext('Please select at least one record.'),
actions, actions,
actions_confirmation) }} actions_confirmation) }}
<script language="javascript">
(function($) {
$('[data-role=tooltip]').tooltip({
html: true,
placement: 'bottom'
});
{% if filter_groups %}
var filter = new AdminFilters(
'#filter_form', '.field-filters',
{{ filter_groups|tojson|safe }},
{{ active_filters|tojson|safe }}
);
{% endif %}
})(jQuery);
</script>
{% endblock %} {% endblock %}
...@@ -21,10 +21,5 @@ ...@@ -21,10 +21,5 @@
$('.modal-header h3').html('{% block header_text -%} $('.modal-header h3').html('{% block header_text -%}
<h3>{{ _gettext('Create New Record') }}</h3> <h3>{{ _gettext('Create New Record') }}</h3>
{%- endblock %}'); {%- endblock %}');
$(function() {
// Apply flask-admin form styles after the modal is loaded
window.faForm.applyGlobalStyles(document);
});
</script> </script>
{% endblock %} {% endblock %}
...@@ -21,10 +21,5 @@ ...@@ -21,10 +21,5 @@
$('.modal-header h3').html('{% block header_text -%} $('.modal-header h3').html('{% block header_text -%}
{{ _gettext('Edit Record') + ' #' + request.args.get('id') }} {{ _gettext('Edit Record') + ' #' + request.args.get('id') }}
{%- endblock %}'); {%- endblock %}');
$(function() {
// Apply flask-admin form styles after the modal is loaded
window.faForm.applyGlobalStyles(document);
});
</script> </script>
{% endblock %} {% endblock %}
...@@ -21,10 +21,7 @@ ...@@ -21,10 +21,7 @@
{% block tail %} {% block tail %}
{{ super() }} {{ super() }}
<div id="execute-view-data" style="display:none;">{{ admin_view.get_url('.execute_view')|tojson|safe }}</div>
<script src="{{ admin_static.url(filename='admin/js/rediscli.js', v='1.0.0') }}"></script> <script src="{{ admin_static.url(filename='admin/js/rediscli.js', v='1.0.0') }}"></script>
<script language="javascript">
$(function() {
var redisCli = new RedisCli({{ get_url('.execute_view')|tojson }});
});
</script>
{% endblock %} {% endblock %}
...@@ -27,9 +27,8 @@ ...@@ -27,9 +27,8 @@
{% macro script(message, actions, actions_confirmation) %} {% macro script(message, actions, actions_confirmation) %}
{% if actions %} {% if actions %}
<script src="{{ admin_static.url(filename='admin/js/actions.js', v='1.0.0') }}"></script> <div id="actions-confirmation-data" style="display:none;">{{ actions_confirmation|tojson|safe }}</div>
<script language="javascript"> <div id="message-data" style="display:none;">{{ message|tojson|safe }}</div>
var modelActions = new AdminModelActions({{ message|tojson|safe }}, {{ actions_confirmation|tojson|safe }}); <script src="{{ admin_static.url(filename='admin/js/actions.js', v='1.0.0') }}"></script>
</script>
{% endif %} {% endif %}
{% endmacro %} {% endmacro %}
...@@ -35,7 +35,7 @@ ...@@ -35,7 +35,7 @@
</head> </head>
<body> <body>
{% block page_body %} {% block page_body %}
<div class="container"> <div class="container{%if config.get('FLASK_ADMIN_FLUID_LAYOUT', False) %}-fluid{% endif %}">
<nav class="navbar navbar-default" role="navigation"> <nav class="navbar navbar-default" role="navigation">
<!-- Brand and toggle get grouped for better mobile display --> <!-- Brand and toggle get grouped for better mobile display -->
<div class="navbar-header"> <div class="navbar-header">
......
...@@ -37,7 +37,7 @@ ...@@ -37,7 +37,7 @@
<th> <th>
{% if admin_view.is_column_sortable(column) %} {% if admin_view.is_column_sortable(column) %}
{% if sort_column == column %} {% if sort_column == column %}
<a href="{{ sort_url(column, True) }}" title="{{ _gettext('Sort by %(name)s', name=column) }}"> <a href="{{ sort_url(column, dir_path, True) }}" title="{{ _gettext('Sort by %(name)s', name=column) }}">
{{ admin_view.column_label(column) }} {{ admin_view.column_label(column) }}
{% if sort_desc %} {% if sort_desc %}
<span class="fa fa-chevron-up glyphicon glyphicon-chevron-up"></span> <span class="fa fa-chevron-up glyphicon glyphicon-chevron-up"></span>
...@@ -46,7 +46,7 @@ ...@@ -46,7 +46,7 @@
{% endif %} {% endif %}
</a> </a>
{% else %} {% else %}
<a href="{{ sort_url(column) }}" title="{{ _gettext('Sort by %(name)s', name=column) }}">{{ admin_view.column_label(column) }}</a> <a href="{{ sort_url(column, dir_path) }}" title="{{ _gettext('Sort by %(name)s', name=column) }}">{{ admin_view.column_label(column) }}</a>
{% endif %} {% endif %}
{% else %} {% else %}
{{ _gettext(admin_view.column_label(column)) }} {{ _gettext(admin_view.column_label(column)) }}
...@@ -126,12 +126,12 @@ ...@@ -126,12 +126,12 @@
{{ size|filesizeformat }} {{ size|filesizeformat }}
</td> </td>
{% endif %} {% endif %}
{% endif %}
{% if admin_view.is_column_visible('date') %} {% if admin_view.is_column_visible('date') %}
<td> <td>
{{ timestamp_format(date) }} {{ timestamp_format(date) }}
</td> </td>
{% endif %} {% endif %}
{% endif %}
{% endblock %} {% endblock %}
</tr> </tr>
{% endfor %} {% endfor %}
......
...@@ -13,7 +13,7 @@ ...@@ -13,7 +13,7 @@
<input type="checkbox" name="del-{{ subfield.id }}" id="del-{{ subfield.id }}" /> <input type="checkbox" name="del-{{ subfield.id }}" id="del-{{ subfield.id }}" />
<label for="del-{{ subfield.id }}" style="display: inline">{{ _gettext('Delete?') }}</label> <label for="del-{{ subfield.id }}" style="display: inline">{{ _gettext('Delete?') }}</label>
{% else %} {% else %}
<a href="javascript:void(0)" class="inline-remove-field"><i class="fa fa-times glyphicon glyphicon-remove"></i></a> <a href="javascript:void(0)" value="{{ _gettext('Are you sure you want to delete this record?') }}" class="inline-remove-field"><i class="fa fa-times glyphicon glyphicon-remove"></i></a>
{% endif %} {% endif %}
</div> </div>
</small> </small>
...@@ -32,7 +32,7 @@ ...@@ -32,7 +32,7 @@
<legend> <legend>
<small>{{ _gettext('New') }} {{ field.label.text }}</small> <small>{{ _gettext('New') }} {{ field.label.text }}</small>
<div class="pull-right"> <div class="pull-right">
<a href="javascript:void(0)" class="inline-remove-field"><span class="fa fa-times glyphicon glyphicon-remove"></span></a> <a href="javascript:void(0)" value="{{ _gettext('Are you sure you want to delete this record?') }}" class="inline-remove-field"><span class="fa fa-times glyphicon glyphicon-remove"></span></a>
</div> </div>
</legend> </legend>
<div class='clearfix'></div> <div class='clearfix'></div>
......
...@@ -180,43 +180,16 @@ ...@@ -180,43 +180,16 @@
{% block tail %} {% block tail %}
{{ super() }} {{ super() }}
{% if filter_groups %}
<div id="filter-groups-data" style="display:none;">{{ filter_groups|tojson|safe }}</div>
<div id="active-filters-data" style="display:none;">{{ active_filters|tojson|safe }}</div>
{% endif %}
<script src="{{ admin_static.url(filename='admin/js/filters.js', v='1.0.0') }}"></script> <script src="{{ admin_static.url(filename='admin/js/filters.js', v='1.0.0') }}"></script>
{{ lib.form_js() }} {{ lib.form_js() }}
{{ actionlib.script(_gettext('Please select at least one record.'), {{ actionlib.script(_gettext('Please select at least one record.'),
actions, actions,
actions_confirmation) }} actions_confirmation) }}
<script language="javascript">
(function($) {
$('[data-role=tooltip]').tooltip({
html: true,
placement: 'bottom'
});
{% if filter_groups %}
var filter = new AdminFilters(
'#filter_form', '.field-filters',
{{ filter_groups|tojson|safe }},
{{ active_filters|tojson|safe }}
);
{% endif %}
})(jQuery);
// Catch exception when closing dialog with <esc> key
// and prevent accidental deletions.
function safeConfirm(msg) {
try {
var isconfirmed = confirm(msg);
if (isconfirmed == true) {
return true;
}
else {
return false;
}
}
catch(err) {
return false;
}
}
</script>
{% endblock %} {% endblock %}
...@@ -21,11 +21,4 @@ ...@@ -21,11 +21,4 @@
{% block tail %} {% block tail %}
<script src="{{ admin_static.url(filename='admin/js/bs3_modal.js', v='1.0.0') }}"></script> <script src="{{ admin_static.url(filename='admin/js/bs3_modal.js', v='1.0.0') }}"></script>
<script>
$(function() {
// Apply flask-admin form styles after the modal is loaded
window.faForm.applyGlobalStyles(document);
});
</script>
{% endblock %} {% endblock %}
...@@ -23,11 +23,4 @@ ...@@ -23,11 +23,4 @@
{% block tail %} {% block tail %}
<script src="{{ admin_static.url(filename='admin/js/bs3_modal.js', v='1.0.0') }}"></script> <script src="{{ admin_static.url(filename='admin/js/bs3_modal.js', v='1.0.0') }}"></script>
<script>
$(function() {
// Apply flask-admin form styles after the modal is loaded
window.faForm.applyGlobalStyles(document);
});
</script>
{% endblock %} {% endblock %}
...@@ -21,10 +21,7 @@ ...@@ -21,10 +21,7 @@
{% block tail %} {% block tail %}
{{ super() }} {{ super() }}
<div id="execute-view-data" style="display:none;">{{ admin_view.get_url('.execute_view')|tojson|safe }}</div>
<script src="{{ admin_static.url(filename='admin/js/rediscli.js', v='1.0.0') }}"></script> <script src="{{ admin_static.url(filename='admin/js/rediscli.js', v='1.0.0') }}"></script>
<script language="javascript">
$(function() {
var redisCli = new RedisCli({{ admin_view.get_url('.execute_view')|tojson }});
});
</script>
{% endblock %} {% endblock %}
import os.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), {}
...@@ -2218,6 +2218,34 @@ def test_multipath_joins(): ...@@ -2218,6 +2218,34 @@ def test_multipath_joins():
eq_(rv.status_code, 200) eq_(rv.status_code, 200)
def test_different_bind_joins():
app, db, admin = setup()
app.config['SQLALCHEMY_BINDS'] = {
'other': 'sqlite:///'
}
class Model1(db.Model):
id = db.Column(db.Integer, primary_key=True)
val1 = db.Column(db.String(20))
class Model2(db.Model):
__bind_key__ = 'other'
id = db.Column(db.Integer, primary_key=True)
val1 = db.Column(db.String(20))
first_id = db.Column(db.Integer, db.ForeignKey(Model1.id))
first = db.relationship(Model1)
db.create_all()
view = CustomModelView(Model2, db.session)
admin.add_view(view)
client = app.test_client()
rv = client.get('/admin/model2/')
eq_(rv.status_code, 200)
def test_model_default(): def test_model_default():
app, db, admin = setup() app, db, admin = setup()
_, Model2 = create_models(db) _, Model2 = create_models(db)
......
...@@ -4,6 +4,7 @@ from . import setup_postgres ...@@ -4,6 +4,7 @@ from . import setup_postgres
from .test_basic import CustomModelView from .test_basic import CustomModelView
from sqlalchemy.dialects.postgresql import HSTORE, JSON from sqlalchemy.dialects.postgresql import HSTORE, JSON
from citext import CIText
def test_hstore(): def test_hstore():
...@@ -75,3 +76,39 @@ def test_json(): ...@@ -75,3 +76,39 @@ def test_json():
data = rv.data.decode('utf-8') data = rv.data.decode('utf-8')
ok_('json_test' in data) ok_('json_test' in data)
ok_('>{"test_key1": "test_value1"}<' in data) ok_('>{"test_key1": "test_value1"}<' in data)
def test_citext():
app, db, admin = setup_postgres()
class CITextModel(db.Model):
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
citext_test = db.Column(CIText)
db.engine.execute('CREATE EXTENSION IF NOT EXISTS citext')
db.create_all()
view = CustomModelView(CITextModel, db.session)
admin.add_view(view)
client = app.test_client()
rv = client.get('/admin/citextmodel/')
eq_(rv.status_code, 200)
rv = client.post('/admin/citextmodel/new/', data={
'citext_test': 'Foo',
})
eq_(rv.status_code, 302)
rv = client.get('/admin/citextmodel/')
eq_(rv.status_code, 200)
data = rv.data.decode('utf-8')
ok_('citext_test' in data)
ok_('Foo' in data)
rv = client.get('/admin/citextmodel/edit/?id=1')
eq_(rv.status_code, 200)
data = rv.data.decode('utf-8')
ok_('name="citext_test"' in data)
ok_('>Foo<' in data)
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 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)')
...@@ -12,7 +12,7 @@ shapely==1.5.9 ...@@ -12,7 +12,7 @@ shapely==1.5.9
geoalchemy2 geoalchemy2
psycopg2 psycopg2
nose nose
sphinx
sphinx-intl
coveralls coveralls
pylint pylint
sqlalchemy-citext
azure-storage-blob
...@@ -31,14 +31,17 @@ def grep(attrname): ...@@ -31,14 +31,17 @@ def grep(attrname):
return strval return strval
extras_require = {
'aws': ['boto'],
'azure': ['azure-storage-blob']
}
install_requires = [ install_requires = [
'Flask>=0.7', 'Flask>=0.7',
'wtforms' 'wtforms'
] ]
if sys.version_info[:2] < (2, 7):
install_requires.append('ordereddict')
setup( setup(
name='Flask-Admin', name='Flask-Admin',
version=grep('__version__'), version=grep('__version__'),
...@@ -52,6 +55,7 @@ setup( ...@@ -52,6 +55,7 @@ setup(
include_package_data=True, include_package_data=True,
zip_safe=False, zip_safe=False,
platforms='any', platforms='any',
extras_require=extras_require,
install_requires=install_requires, install_requires=install_requires,
tests_require=[ tests_require=[
'nose>=1.0', 'nose>=1.0',
...@@ -76,10 +80,10 @@ setup( ...@@ -76,10 +80,10 @@ setup(
'Programming Language :: Python', 'Programming Language :: Python',
'Topic :: Software Development :: Libraries :: Python Modules', 'Topic :: Software Development :: Libraries :: Python Modules',
'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 2.7',
'Programming Language :: Python :: 2.6',
'Programming Language :: Python :: 3.3', 'Programming Language :: Python :: 3.3',
'Programming Language :: Python :: 3.4', 'Programming Language :: Python :: 3.4',
'Programming Language :: Python :: 3.5', 'Programming Language :: Python :: 3.5',
'Programming Language :: Python :: 3.6',
], ],
test_suite='nose.collector' test_suite='nose.collector'
) )
[tox] [tox]
envlist = py{26,27,33,34,35,36}-WTForms{1,2},flake8 envlist =
py{27,34,35,36}-WTForms{1,2}
flake8
docs-html
skipsdist = true skipsdist = true
skip_missing_interpreters = true skip_missing_interpreters = true
[flake8] [flake8]
max_line_length = 120 max_line_length = 120
ignore = E402 ignore = E402,E722
[testenv] [testenv]
setenv =
AZURE_STORAGE_CONNECTION_STRING = DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://127.0.0.1:10000/devstoreaccount1;
usedevelop = true usedevelop = true
deps = deps =
WTForms1: WTForms==1.0.5 WTForms1: WTForms==1.0.5
...@@ -19,3 +24,9 @@ commands = ...@@ -19,3 +24,9 @@ commands =
[testenv:flake8] [testenv:flake8]
deps = flake8 deps = flake8
commands = flake8 flask_admin commands = flake8 flask_admin
[testenv:docs-html]
deps =
sphinx
sphinx-intl
commands = sphinx-build -b html -d build/doctrees doc build/html
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