Commit 26bb7798 authored by Rad Cirskis's avatar Rad Cirskis

Merge remote-tracking branch 'upstream/master' into missing-extra-args-_get_list_extra_args

parents 9b987b8f 7fa26ab2
......@@ -2,10 +2,6 @@ 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
......@@ -14,10 +10,6 @@ matrix:
env: TOX_ENV=flake8
- python: 2.7
env: TOX_ENV=docs-html
- python: 3.3
env: TOX_ENV=py33-WTForms1
- python: 3.3
env: TOX_ENV=py33-WTForms2
- python: 3.4
env: TOX_ENV=py34-WTForms1
- python: 3.4
......
......@@ -93,6 +93,7 @@ For all the tests to pass successfully, you'll need Postgres & MongoDB to be run
CREATE DATABASE flask_admin_test;
CREATE EXTENSION postgis;
CREATE EXTENSION hstore;
You can also run the tests on multiple environments using *tox*.
......
......@@ -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
......
......@@ -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)
......
......@@ -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:
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:
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)
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, JOIN.LEFT_OUTER)
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())
......
......@@ -69,7 +69,7 @@ class QueryAjaxModelLoader(AjaxModelLoader):
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:
......
......@@ -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]
......
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
......@@ -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)
......
......@@ -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
......@@ -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)
......
......@@ -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):
"""
......@@ -1808,6 +1839,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.
......@@ -2108,7 +2159,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',))
......
......@@ -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;
}
......
......@@ -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,
......
......@@ -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 %}
......
......@@ -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">
......
......@@ -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)
......
......@@ -36,9 +36,6 @@ install_requires = [
'wtforms'
]
if sys.version_info[:2] < (2, 7):
install_requires.append('ordereddict')
setup(
name='Flask-Admin',
version=grep('__version__'),
......@@ -76,10 +73,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}
py{27,34,35,36}-WTForms{1,2}
flake8
docs-html
skipsdist = true
......
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