Commit 90aea66a authored by Rad Cirskis's avatar Rad Cirskis

Merge remote-tracking branch 'upstream/master' into composite-keys

parents 9d2f4d31 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
......@@ -33,6 +25,10 @@ matrix:
addons:
postgresql: "9.4"
apt:
packages:
- postgresql-9.4-postgis-2.4
- postgresql-9.4-postgis-2.4-scripts
services:
- postgresql
......
......@@ -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)
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)
......@@ -217,10 +219,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)
......@@ -238,6 +238,7 @@ class ModelView(BaseModelView):
raise Exception('Failed to find field for filter: %s' % name)
# Check if field is in different model
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))
......@@ -246,6 +247,15 @@ class ModelView(BaseModelView):
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,
......@@ -311,11 +321,19 @@ class ModelView(BaseModelView):
return create_ajax_loader(self.model, name, name, options)
def _handle_join(self, query, field, joins):
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)
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, JOIN.LEFT_OUTER)
joins.add(model_name)
return query
......@@ -325,8 +343,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):
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:
......
......@@ -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]
......
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
......@@ -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:
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)
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',))
......
......@@ -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
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;
......
......@@ -79,6 +79,7 @@ var RedisCli = function(postUrl) {
sendCommand(val);
$input.val('');
return false;
}
function onKeyPress(e) {
......
......@@ -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)
......
......@@ -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)
......@@ -14,3 +14,4 @@ psycopg2
nose
coveralls
pylint
sqlalchemy-citext
......@@ -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
......@@ -8,7 +8,7 @@ skip_missing_interpreters = true
[flake8]
max_line_length = 120
ignore = E402
ignore = E402,E722
[testenv]
usedevelop = 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