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
language: python
matrix:
include:
- python: 2.6
env: TOX_ENV=py26-WTForms1
- python: 2.6
env: TOX_ENV=py26-WTForms2
- python: 2.7
env: TOX_ENV=py27-WTForms1
- python: 2.7
env: TOX_ENV=py27-WTForms2
- python: 2.7
env: TOX_ENV=flake8
- python: 3.3
env: TOX_ENV=py33-WTForms1
- python: 3.3
env: TOX_ENV=py33-WTForms2
- python: 2.7
env: TOX_ENV=docs-html
- python: 3.4
env: TOX_ENV=py34-WTForms1
- python: 3.4
......@@ -31,15 +25,21 @@ matrix:
addons:
postgresql: "9.4"
apt:
packages:
- postgresql-9.4-postgis-2.4
- postgresql-9.4-postgis-2.4-scripts
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
......
......@@ -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::
pip install -r requirements-dev.txt
sudo make html
tox -e docs-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/>`_.
......@@ -99,7 +98,6 @@ For all the tests to pass successfully, you'll need Postgres & MongoDB to be run
> psql flask_admin_test
CREATE EXTENSION postgis;
CREATE EXTENSION hstore;
\q
You can also run the tests on multiple environments using *tox*.
......
......@@ -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__)
......
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
-----
......
......@@ -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
------------------
......
......@@ -80,6 +80,9 @@ are a few different ways of approaching this.
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
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
......@@ -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
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
----------------
For a more flexible solution, Flask-Admin lets you define access control rules
......
......@@ -32,12 +32,17 @@ class User(db.Model):
password = db.Column(db.String(64))
# Flask-Login integration
# NOTE: is_authenticated, is_active, and is_anonymous
# are methods in Flask-Login < 0.3.0
@property
def is_authenticated(self):
return True
@property
def is_active(self):
return True
@property
def is_anonymous(self):
return False
......
......@@ -27,12 +27,17 @@ class User(db.Document):
password = db.StringField(max_length=64)
# Flask-Login integration
# NOTE: is_authenticated, is_active, and is_anonymous
# are methods in Flask-Login < 0.3.0
@property
def is_authenticated(self):
return True
@property
def is_active(self):
return True
@property
def is_anonymous(self):
return False
......
......@@ -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
......
{% extends 'admin/model/list.html' %}
{% 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>
{% if admin_view.can_create %}
<div class="btn-menu">
......@@ -29,6 +29,3 @@
<div class="clearfix"></div>
<hr>
{% endblock %}
{% block model_menu_bar %}
{% endblock %}
\ No newline at end of file
from flask import Flask, redirect, url_for
from flask.ext import login
from flask_login import current_user, UserMixin
from flask_login import current_user, UserMixin, login_user, logout_user, LoginManager
from flask_admin.base import MenuLink, Admin, BaseView, expose
......@@ -52,17 +51,17 @@ def index():
@app.route('/login/')
def login_view():
login.login_user(User())
login_user(User())
return redirect(url_for('admin.index'))
@app.route('/logout/')
def logout_view():
login.logout_user()
logout_user()
return redirect(url_for('admin.index'))
login_manager = login.LoginManager()
login_manager = LoginManager()
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'
__email__ = 'serge.koval+github@gmail.com'
......
......@@ -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
......@@ -821,13 +835,19 @@ class BaseFileAdmin(BaseView, ActionsMixin):
sort_column = request.args.get('sort', None, type=str)
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:
# Sort by name
items.sort(key=itemgetter(0))
# Sort by type
items.sort(key=itemgetter(2), reverse=True)
# 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)
......@@ -842,13 +862,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,
......@@ -1109,8 +1132,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 +1144,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):
"""
......
......@@ -8,10 +8,14 @@ from .widgets import LeafletWidget
class GeoJSONField(JSONField):
widget = LeafletWidget()
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)
self.web_srid = 4326
self.srid = srid
......
......@@ -9,4 +9,6 @@ class AdminModelConverter(SQLAAdminConverter):
field_args['geometry_type'] = column.type.geometry_type
field_args['srid'] = column.type.srid
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)
......@@ -14,6 +14,8 @@ def geom_formatter(view, value):
"data-height": 70,
"data-geometry-type": to_shape(value).geom_type,
"data-zoom": 15,
"data-tile-layer-url": view.tile_layer_url,
"data-tile-layer-attribution": view.tile_layer_attribution
})
if value.srid is -1:
value.srid = 4326
......
......@@ -5,3 +5,5 @@ from flask_admin.contrib.geoa import form, typefmt
class ModelView(SQLAModelView):
model_form_converter = form.AdminModelConverter
column_type_formatters = typefmt.DEFAULT_FORMATTERS
tile_layer_url = None
tile_layer_attribution = None
......@@ -23,7 +23,8 @@ class LeafletWidget(TextArea):
"""
def __init__(
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.height = height
self.center = center
......@@ -31,6 +32,8 @@ class LeafletWidget(TextArea):
self.min_zoom = min_zoom
self.max_zoom = max_zoom
self.max_bounds = max_bounds
self.tile_layer_url = tile_layer_url
self.tile_layer_attribution = tile_layer_attribution
def __call__(self, field, **kwargs):
kwargs.setdefault('data-role', self.data_role)
......@@ -38,6 +41,10 @@ class LeafletWidget(TextArea):
kwargs.setdefault('data-geometry-type', gtype)
# 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:
kwargs["data-width"] = self.width
if "data-height" not in kwargs:
......
from wtforms import fields
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
......@@ -265,7 +270,10 @@ class InlineModelConverter(InlineModelConverterBase):
allow_pk=True,
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)
......
......@@ -14,8 +14,8 @@ def parse_like_term(term):
def get_meta_fields(model):
try:
if hasattr(model._meta, 'sorted_fields'):
fields = model._meta.sorted_fields
except AttributeError:
else:
fields = model._meta.get_fields()
return fields
......@@ -2,18 +2,18 @@ import logging
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.model import BaseModelView
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.contrib.peewee import filters
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
# Set up logger
......@@ -176,7 +176,9 @@ class ModelView(BaseModelView):
if model is None:
model = self.model
return iteritems(model._meta.fields)
return (
(field.name, field)
for field in get_meta_fields(model))
def scaffold_pk(self):
return get_primary_key(self.model)
......@@ -213,10 +215,8 @@ class ModelView(BaseModelView):
if isinstance(p, string_types):
p = getattr(self.model, p)
field_type = type(p)
# Check type
if (field_type != CharField and field_type != TextField):
if not isinstance(p, (CharField, TextField)):
raise Exception('Can only search on text columns. ' +
'Failed to setup search for "%s"' % p)
......@@ -234,14 +234,24 @@ class ModelView(BaseModelView):
raise Exception('Failed to find field for filter: %s' % name)
# Check if field is in different model
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)
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:
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__
flt = self.filter_converter.convert(type_name,
......@@ -307,12 +317,20 @@ class ModelView(BaseModelView):
return create_ajax_loader(self.model, name, name, options)
def _handle_join(self, query, field, joins):
if field.model_class != self.model:
model_name = field.model_class.__name__
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)
except AttributeError:
if field.model != self.model:
model_name = field.model.__name__
if model_name not in joins:
query = query.join(field.model_class)
joins.add(model_name)
if model_name not in joins:
query = query.join(field.model, JOIN.LEFT_OUTER)
joins.add(model_name)
return query
......@@ -321,8 +339,12 @@ class ModelView(BaseModelView):
field = getattr(self.model, sort_field)
query = query.order_by(field.desc() if sort_desc else field.asc())
elif isinstance(sort_field, Field):
if sort_field.model_class != self.model:
query = self._handle_join(query, sort_field, joins)
try:
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())
......
......@@ -238,7 +238,7 @@ class ModelView(BaseModelView):
for flt, flt_name, value in filters:
f = self._filters[flt]
data = f.apply(data, value)
data = f.apply(data, f.clean(value))
if data:
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.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,11 +66,11 @@ 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:
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))
if self.order_by:
......
......@@ -276,7 +276,7 @@ class InlineModelFormList(InlineFieldList):
# Handle request data
for field in self.entries:
field_id = field.get_pk()
field_id = str(field.get_pk())
is_created = field_id not in pk_map
if not is_created:
......@@ -296,5 +296,5 @@ class InlineModelFormList(InlineFieldList):
def get_pk_from_identity(obj):
# 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)
......@@ -373,7 +373,7 @@ class FilterConverter(filters.BaseFilterConverter):
@filters.convert('string', 'char', 'unicode', 'varchar', 'tinytext',
'text', 'mediumtext', 'longtext', 'unicodetext',
'nchar', 'nvarchar', 'ntext')
'nchar', 'nvarchar', 'ntext', 'citext')
def conv_string(self, column, name, **kwargs):
return [f(column, name, **kwargs) for f in self.strings]
......@@ -381,12 +381,12 @@ class FilterConverter(filters.BaseFilterConverter):
def conv_bool(self, column, name, **kwargs):
return [f(column, name, **kwargs) for f in self.bool_filters]
@filters.convert('int', 'integer', 'smallinteger', 'smallint', 'numeric',
@filters.convert('int', 'integer', 'smallinteger', 'smallint',
'biginteger', 'bigint', 'mediumint')
def conv_int(self, column, name, **kwargs):
return [f(column, name, **kwargs) for f in self.int_filters]
@filters.convert('float', 'real', 'decimal', 'double_precision', 'double')
@filters.convert('float', 'real', 'decimal', 'numeric', 'double_precision', 'double')
def conv_float(self, column, name, **kwargs):
return [f(column, name, **kwargs) for f in self.float_filters]
......
import warnings
from enum import Enum
from wtforms import fields, validators
from sqlalchemy import Boolean, Column
......@@ -9,7 +10,7 @@ from flask_admin.model.form import (converts, ModelConverterBase,
from flask_admin.model.fields import AjaxSelectField, AjaxSelectMultipleField
from flask_admin.model.helpers import prettify_name
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 .fields import (QuerySelectField, QuerySelectMultipleField,
......@@ -154,7 +155,9 @@ class AdminModelConverter(ModelConverterBase):
if len(prop.columns) > 1:
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))
return None
......@@ -264,7 +267,7 @@ class AdminModelConverter(ModelConverterBase):
@classmethod
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))
@converts('String') # includes VARCHAR, CHAR, and Unicode
......@@ -279,6 +282,7 @@ class AdminModelConverter(ModelConverterBase):
accepted_values.append(None)
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)
......@@ -290,7 +294,7 @@ class AdminModelConverter(ModelConverterBase):
self._string_common(column=column, field_args=field_args, **extra)
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):
self._string_common(field_args=field_args, **extra)
return fields.TextAreaField(**field_args)
......
......@@ -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
"""
l = []
ands = []
for id in ids:
k = []
for i in range(len(model_pk)):
k.append(eq(model_pk[i], id[i]))
l.append(and_(*k))
if len(l) >= 1:
return or_(*l)
ands.append(and_(*k))
if len(ands) >= 1:
return or_(*ands)
else:
return None
......
......@@ -420,7 +420,9 @@ class ModelView(BaseModelView):
if len(p.columns) > 1:
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))
continue
......@@ -594,7 +596,7 @@ class ModelView(BaseModelView):
if column.foreign_keys or column.primary_key:
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))
type_name = type(column.type).__name__
......@@ -763,6 +765,12 @@ class ModelView(BaseModelView):
if p.mapper.class_ == self.model:
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']:
relations.add(p.key)
......
......@@ -8,6 +8,9 @@ from flask_admin._compat import urljoin, urlparse, iteritems
from ._compat import string_types
VALID_SCHEMES = ['http', 'https']
def set_current_view(view):
g._admin_view = view
......@@ -128,10 +131,16 @@ def prettify_class_name(name):
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)
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'):
......
......@@ -263,6 +263,16 @@ class BaseModelView(BaseView, ActionsMixin):
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)
"""
Dictionary of value type formatters to be used in the list view.
......@@ -319,6 +329,18 @@ class BaseModelView(BaseView, ActionsMixin):
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)
"""
Dictionary where key is column name and value is string to display.
......@@ -889,6 +911,9 @@ class BaseModelView(BaseView, ActionsMixin):
if self.column_formatters_export is None:
self.column_formatters_export = self.column_formatters
if self.column_formatters_detail is None:
self.column_formatters_detail = self.column_formatters
# Type formatters
if self.column_type_formatters is None:
self.column_type_formatters = dict(typefmt.BASE_FORMATTERS)
......@@ -896,6 +921,9 @@ class BaseModelView(BaseView, ActionsMixin):
if self.column_type_formatters_export is None:
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:
self.column_descriptions = dict()
......@@ -1518,12 +1546,15 @@ class BaseModelView(BaseView, ActionsMixin):
"""
try:
self.on_model_change(form, model, is_created)
except TypeError:
msg = ('%s.on_model_change() now accepts third ' +
'parameter is_created. Please update your code') % self.model
warnings.warn(msg)
except TypeError as e:
if re.match(r'on_model_change\(\) takes .* 3 .* arguments .* 4 .* given .*', e.message):
msg = ('%s.on_model_change() now accepts third ' +
'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):
"""
......@@ -1803,6 +1834,26 @@ class BaseModelView(BaseView, ActionsMixin):
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):
"""
Returns the value to be displayed in export.
......@@ -2103,7 +2154,7 @@ class BaseModelView(BaseView, ActionsMixin):
return self.render(template,
model=model,
details_columns=self._details_columns,
get_value=self.get_list_value,
get_value=self.get_detail_value,
return_url=return_url)
@expose('/delete/', methods=('POST',))
......
......@@ -106,7 +106,7 @@ class DeleteRowAction(TemplateLinkRowAction):
def __init__(self):
super(DeleteRowAction, self).__init__(
'row_actions.delete_row',
gettext('Edit Record'))
gettext('Delete Record'))
# Macro helper
......
......@@ -84,6 +84,13 @@ EXPORT_FORMATTERS = {
dict: dict_formatter,
}
DETAIL_FORMATTERS = {
type(None): empty_formatter,
list: list_formatter,
dict: dict_formatter,
}
if Enum is not None:
BASE_FORMATTERS[Enum] = enum_formatter
EXPORT_FORMATTERS[Enum] = enum_formatter
DETAIL_FORMATTERS[Enum] = enum_formatter
......@@ -72,7 +72,8 @@ class XEditableWidget(object):
field inside of the FieldList (StringField, IntegerField, etc).
"""
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-url', './ajax/update/')
......@@ -91,7 +92,7 @@ class XEditableWidget(object):
return HTMLString(
'<a %s>%s</a>' % (html_params(**kwargs),
escape(kwargs['data-value']))
escape(display_value))
)
def get_kwargs(self, field, kwargs):
......@@ -104,7 +105,7 @@ class XEditableWidget(object):
kwargs['data-type'] = 'textarea'
kwargs['data-rows'] = '5'
elif field.type == 'BooleanField':
kwargs['data-type'] = 'select'
kwargs['data-type'] = 'select2'
# data-source = dropdown options
kwargs['data-source'] = json.dumps([
{'value': '', 'text': gettext('No')},
......@@ -112,7 +113,7 @@ class XEditableWidget(object):
])
kwargs['data-role'] = 'x-editable-boolean'
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]
# prepend a blank field to choices if allow_blank = True
......@@ -144,7 +145,7 @@ class XEditableWidget(object):
elif field.type in ['QuerySelectField', 'ModelSelectField',
'QuerySelectMultipleField', 'KeyPropertyField']:
# QuerySelectField and ModelSelectField are for relations
kwargs['data-type'] = 'select'
kwargs['data-type'] = 'select2'
choices = []
selected_ids = []
......@@ -162,12 +163,13 @@ class XEditableWidget(object):
kwargs['data-source'] = json.dumps(choices)
if field.type == 'QuerySelectMultipleField':
kwargs['data-type'] = 'select2'
kwargs['data-role'] = 'x-editable-select2-multiple'
# must use id instead of text or prefilled values won't work
separator = getattr(field, 'separator', ',')
kwargs['data-value'] = separator.join(selected_ids)
else:
kwargs['data-value'] = text_type(selected_ids[0])
else:
raise Exception('Unsupported field type: %s' % (type(field),))
......
......@@ -28,7 +28,7 @@
/* List View - fix gap between actions and table */
.model-list {
position: relative;
position: static;
margin-top: -1px;
z-index: 999;
}
......@@ -139,3 +139,7 @@ table.filters tr td {
*/
#no-more-tables td:before { content: attr(data-title); }
}
.editable-input .select2-container {
min-width: 220px;
}
......@@ -28,7 +28,7 @@
/* List View - fix overlapping border between actions and table */
.model-list {
position: relative;
position: static;
margin-top: -1px;
z-index: 999;
}
......
var AdminModelActions = function(actionErrorMessage, actionConfirmations) {
// Actions helpers. TODO: Move to separate file
this.execute = function(name) {
var selected = $('input.action-checkbox:checked').size();
var selected = $('input.action-checkbox:checked').length;
if (selected === 0) {
alert(actionErrorMessage);
......@@ -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 @@
$('.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);
});
......@@ -2,23 +2,23 @@ var AdminFilters = function(element, filtersElement, filterGroups, activeFilters
var $root = $(element);
var $container = $('.filters', $root);
var lastCount = 0;
function getCount(name) {
var idx = name.indexOf('_');
if (idx === -1) {
return 0;
}
return parseInt(name.substr(3, idx - 3), 10);
}
function makeName(name) {
var result = 'flt' + lastCount + '_' + name;
lastCount += 1;
return result;
}
function removeFilter() {
$(this).closest('tr').remove();
if($('.filters tr').length == 0) {
......@@ -28,23 +28,23 @@ var AdminFilters = function(element, filtersElement, filterGroups, activeFilters
} else {
$('button', $root).show();
}
return false;
}
// triggered when the filter operation (equals, not equals, etc) is changed
function changeOperation(subfilters, $el, filter, $select) {
// get the filter_group subfilter based on the index of the selected option
var selectedFilter = subfilters[$select.select2('data').element[0].index];
var $inputContainer = $el.find('td').last();
// recreate and style the input field (turn into date range or select2 if necessary)
var $field = createFilterInput($inputContainer, null, selectedFilter);
styleFilterInput(selectedFilter, $field);
$('button', $root).show();
}
// generate HTML for filter input - allows changing filter input type to one with options or tags
function createFilterInput(inputContainer, filterValue, filter) {
if (filter.type == "select2-tags") {
......@@ -52,7 +52,7 @@ var AdminFilters = function(element, filtersElement, filterGroups, activeFilters
$field.val(filterValue);
} else if (filter.options) {
var $field = $('<select class="filter-val" />').attr('name', makeName(filter.arg));
$(filter.options).each(function() {
// for active filter inputs with options, add "selected" if there is a matching active filter
if (filterValue && (filterValue == this[0])) {
......@@ -68,10 +68,10 @@ var AdminFilters = function(element, filtersElement, filterGroups, activeFilters
$field.val(filterValue);
}
inputContainer.replaceWith($('<td/>').append($field));
return $field;
}
// add styling to input field, accommodates filters that change the input field's HTML
function styleFilterInput(filter, field) {
if (filter.type) {
......@@ -90,19 +90,19 @@ var AdminFilters = function(element, filtersElement, filterGroups, activeFilters
// save tag options as json on data attribute
field.attr('data-tags', JSON.stringify(options));
}
}
}
faForm.applyStyle(field, filter.type);
} else if (filter.options) {
filter.type = "select2";
faForm.applyStyle(field, filter.type);
}
return field;
}
function addFilter(name, subfilters, selectedIndex, filterValue) {
var $el = $('<tr class="form-horizontal" />').appendTo($container);
// Filter list
$el.append(
$('<td/>').append(
......@@ -113,10 +113,10 @@ var AdminFilters = function(element, filtersElement, filterGroups, activeFilters
.click(removeFilter)
)
);
// Filter operation <select> (equal, not equal, etc)
var $select = $('<select class="filter-op" />');
// if one of the subfilters are selected, use that subfilter to create the input field
var filterSelection = 0;
$.each(subfilters, function( subfilterIndex, subfilter ) {
......@@ -127,55 +127,69 @@ var AdminFilters = function(element, filtersElement, filterGroups, activeFilters
$select.append($('<option/>').attr('value', subfilter.arg).text(subfilter.operation));
}
});
$el.append(
$('<td/>').append($select)
);
// select2 for filter-op (equal, not equal, etc)
$select.select2({width: 'resolve'}).on("change", function(e) {
changeOperation(subfilters, $el, filter, $select);
});
// get filter option from filter_group, only for new filter creation
var filter = subfilters[filterSelection];
var $inputContainer = $('<td/>').appendTo($el);
var $newFilterField = createFilterInput($inputContainer, filterValue, filter).focus();
var $styledFilterField = styleFilterInput(filter, $newFilterField);
return $styledFilterField;
}
// Add Filter Button, new filter
$('a.filter', filtersElement).click(function() {
var name = ($(this).text().trim !== undefined ? $(this).text().trim() : $(this).text().replace(/^\s+|\s+$/g,''));
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 ) {
var idx = activeFilter[0],
name = activeFilter[1],
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
$('.filter-val', $root).on('input change', function() {
$('button', $root).show();
});
$('.remove-filter', $root).click(removeFilter);
$('.filter-val', $root).not('.select2-container').each(function() {
var count = getCount($(this).attr('name'));
if (count > lastCount)
lastCount = count;
});
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 @@
}
// set up tiles
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);
if($el.data('tile-layer-url')){
var attribution = $el.data('tile-layer-attribution') || ''
L.tileLayer('//'+$el.data('tile-layer-url'), {
attribution: attribution,
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,
......@@ -451,7 +459,8 @@
params: overrideXeditableParams,
combodate: {
// prevent minutes from showing in 5 minute increments
minuteStep: 1
minuteStep: 1,
maxYear: 2030,
}
});
return true;
......@@ -595,9 +604,11 @@
// Add on event handler
$('body').on('click', '.inline-remove-field' , function(e) {
e.preventDefault();
var r = confirm($('.inline-remove-field').attr('value'));
var form = $(this).closest('.inline-field');
if ( r == true ){
form.remove();
}
});
// Expose faForm globally
......
......@@ -79,6 +79,7 @@ var RedisCli = function(postUrl) {
sendCommand(val);
$input.val('');
return false;
}
function onKeyPress(e) {
......@@ -114,3 +115,7 @@ var RedisCli = function(postUrl) {
sendCommand('ping');
};
$(function() {
var redisCli = new RedisCli(JSON.parse($('#execute-view-data').text()));
});
......@@ -27,9 +27,8 @@
{% macro script(message, actions, actions_confirmation) %}
{% if actions %}
<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>
<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>
{% endif %}
{% endmacro %}
......@@ -33,7 +33,7 @@
</head>
<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-inner">
{% block brand %}
......
......@@ -37,7 +37,7 @@
<th>
{% if admin_view.is_column_sortable(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) }}
{% if sort_desc %}
<i class="fa fa-chevron-up icon-chevron-up"></i>
......@@ -46,7 +46,7 @@
{% endif %}
</a>
{% 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 %}
{% else %}
{{ _gettext(admin_view.column_label(column)) }}
......
......@@ -12,7 +12,7 @@
<input type="checkbox" name="del-{{ subfield.id }}" id="del-{{ subfield.id }}" />
<label for="del-{{ subfield.id }}" style="display: inline">{{ _gettext('Delete?') }}</label>
{% 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 %}
</div>
</legend>
......@@ -29,7 +29,7 @@
<legend>
{{ _gettext('New') }} {{ field.label.text }}
<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>
</legend>
{{ render(template) }}
......
......@@ -179,26 +179,16 @@
{% block tail %}
{{ 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>
{{ lib.form_js() }}
{{ 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 %}
<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>
<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>
{% endif %}
{% endmacro %}
......@@ -35,7 +35,7 @@
</head>
<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">
<!-- Brand and toggle get grouped for better mobile display -->
<div class="navbar-header">
......
......@@ -37,7 +37,7 @@
<th>
{% if admin_view.is_column_sortable(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) }}
{% if sort_desc %}
<span class="fa fa-chevron-up glyphicon glyphicon-chevron-up"></span>
......@@ -46,7 +46,7 @@
{% endif %}
</a>
{% 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 %}
{% else %}
{{ _gettext(admin_view.column_label(column)) }}
......@@ -126,12 +126,12 @@
{{ size|filesizeformat }}
</td>
{% endif %}
{% endif %}
{% if admin_view.is_column_visible('date') %}
<td>
{{ timestamp_format(date) }}
</td>
{% endif %}
{% endif %}
{% endblock %}
</tr>
{% endfor %}
......
......@@ -13,7 +13,7 @@
<input type="checkbox" name="del-{{ subfield.id }}" id="del-{{ subfield.id }}" />
<label for="del-{{ subfield.id }}" style="display: inline">{{ _gettext('Delete?') }}</label>
{% 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 %}
</div>
</small>
......@@ -32,7 +32,7 @@
<legend>
<small>{{ _gettext('New') }} {{ field.label.text }}</small>
<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>
</legend>
<div class='clearfix'></div>
......
......@@ -180,43 +180,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 %}
<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.'),
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 %}
......@@ -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.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():
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():
app, db, admin = setup()
_, Model2 = create_models(db)
......
......@@ -4,6 +4,7 @@ from . import setup_postgres
from .test_basic import CustomModelView
from sqlalchemy.dialects.postgresql import HSTORE, JSON
from citext import CIText
def test_hstore():
......@@ -75,3 +76,39 @@ def test_json():
data = rv.data.decode('utf-8')
ok_('json_test' 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
geoalchemy2
psycopg2
nose
sphinx
sphinx-intl
coveralls
pylint
sqlalchemy-citext
azure-storage-blob
......@@ -31,14 +31,17 @@ def grep(attrname):
return strval
extras_require = {
'aws': ['boto'],
'azure': ['azure-storage-blob']
}
install_requires = [
'Flask>=0.7',
'wtforms'
]
if sys.version_info[:2] < (2, 7):
install_requires.append('ordereddict')
setup(
name='Flask-Admin',
version=grep('__version__'),
......@@ -52,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',
......@@ -76,10 +80,10 @@ setup(
'Programming Language :: Python',
'Topic :: Software Development :: Libraries :: Python Modules',
'Programming Language :: Python :: 2.7',
'Programming Language :: Python :: 2.6',
'Programming Language :: Python :: 3.3',
'Programming Language :: Python :: 3.4',
'Programming Language :: Python :: 3.5',
'Programming Language :: Python :: 3.6',
],
test_suite='nose.collector'
)
[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
skip_missing_interpreters = true
[flake8]
max_line_length = 120
ignore = E402
ignore = E402,E722
[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
......@@ -19,3 +24,9 @@ commands =
[testenv:flake8]
deps = flake8
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