Unverified Commit 734dcf44 authored by Petrus Janse van Rensburg's avatar Petrus Janse van Rensburg Committed by GitHub

Merge branch 'master' into patch-1

parents 6824eaff 3e558465
Changelog Changelog
========= =========
Next release
-----
* Fix display of inline x-editable boolean fields on list view
* Add support for several SQLAlchemy-Utils data types
1.5.3 1.5.3
----- -----
...@@ -9,6 +15,7 @@ Changelog ...@@ -9,6 +15,7 @@ Changelog
* SQLAlchemy * SQLAlchemy
* sort on multiple columns with `column_default_sort` * sort on multiple columns with `column_default_sort`
* sort on related models in `column_sortable_list` * sort on related models in `column_sortable_list`
* show searchable fields in search input's placeholder text
* fix: inline model forms can now also be used for models with multiple primary keys * fix: inline model forms can now also be used for models with multiple primary keys
* support for using mapped `column_property` * support for using mapped `column_property`
* Upgrade Leaflet and Leaflet.draw plugins, used for geoalchemy integration * Upgrade Leaflet and Leaflet.draw plugins, used for geoalchemy integration
......
...@@ -43,7 +43,7 @@ master_doc = 'index' ...@@ -43,7 +43,7 @@ master_doc = 'index'
# General information about the project. # General information about the project.
project = u'flask-admin' project = u'flask-admin'
copyright = u'2012-2015, Serge S. Koval' copyright = u'2012-2019, Flask-Admin Team'
# The version info for the project you're documenting, acts as replacement for # The version info for the project you're documenting, acts as replacement for
# |version| and |release|, also used in various other places throughout the # |version| and |release|, also used in various other places throughout the
...@@ -256,13 +256,13 @@ intersphinx_mapping = {'http://docs.python.org/': None} ...@@ -256,13 +256,13 @@ intersphinx_mapping = {'http://docs.python.org/': None}
# fall back if theme is not there # fall back if theme is not there
try: try:
__import__('flask_theme_support') __import__('flask_theme_support')
except ImportError, e: except ImportError as e:
print '-' * 74 print('-' * 74)
print 'Warning: Flask themes unavailable. Building with default theme' print('Warning: Flask themes unavailable. Building with default theme')
print 'If you want the Flask themes, run this command and build again:' print('If you want the Flask themes, run this command and build again:')
print print()
print ' git submodule update --init' print(' git submodule update --init')
print '-' * 74 print('-' * 74)
pygments_style = 'tango' pygments_style = 'tango'
html_theme = 'default' html_theme = 'default'
......
...@@ -54,15 +54,11 @@ security = Security(app, user_datastore) ...@@ -54,15 +54,11 @@ security = Security(app, user_datastore)
# Create customized model view class # Create customized model view class
class MyModelView(sqla.ModelView): class MyModelView(sqla.ModelView):
def is_accessible(self): def is_accessible(self):
if not current_user.is_active or not current_user.is_authenticated: return (current_user.is_active and
return False current_user.is_authenticated and
current_user.has_role('superuser')
if current_user.has_role('superuser'): )
return True
return False
def _handle_view(self, name, **kwargs): def _handle_view(self, name, **kwargs):
""" """
......
...@@ -2,3 +2,4 @@ Flask ...@@ -2,3 +2,4 @@ Flask
Flask-Admin Flask-Admin
Flask-MongoEngine Flask-MongoEngine
Flask-Login>=0.3.0 Flask-Login>=0.3.0
Pillow
\ No newline at end of file
This diff is collapsed.
Flask Flask
Flask-Admin Flask-Admin
Flask-BabelEx
Flask-SQLAlchemy Flask-SQLAlchemy
tablib tablib
enum34; python_version < '3.0' enum34; python_version < '3.0'
sqlalchemy_utils
arrow
colour
...@@ -19,7 +19,7 @@ class GeoJSONField(JSONField): ...@@ -19,7 +19,7 @@ class GeoJSONField(JSONField):
super(GeoJSONField, self).__init__(label, validators, **kwargs) super(GeoJSONField, self).__init__(label, validators, **kwargs)
self.web_srid = 4326 self.web_srid = 4326
self.srid = srid self.srid = srid
if self.srid is -1: if self.srid == -1:
self.transform_srid = self.web_srid self.transform_srid = self.web_srid
else: else:
self.transform_srid = self.srid self.transform_srid = self.srid
...@@ -30,11 +30,11 @@ class GeoJSONField(JSONField): ...@@ -30,11 +30,11 @@ class GeoJSONField(JSONField):
if self.raw_data: if self.raw_data:
return self.raw_data[0] return self.raw_data[0]
if type(self.data) is geoalchemy2.elements.WKBElement: if type(self.data) is geoalchemy2.elements.WKBElement:
if self.srid is -1: if self.srid == -1:
return self.session.scalar(func.ST_AsGeoJson(self.data)) return self.session.scalar(func.ST_AsGeoJSON(self.data))
else: else:
return self.session.scalar( return self.session.scalar(
func.ST_AsGeoJson( func.ST_AsGeoJSON(
func.ST_Transform(self.data, self.web_srid) func.ST_Transform(self.data, self.web_srid)
) )
) )
...@@ -43,7 +43,7 @@ class GeoJSONField(JSONField): ...@@ -43,7 +43,7 @@ class GeoJSONField(JSONField):
def process_formdata(self, valuelist): def process_formdata(self, valuelist):
super(GeoJSONField, self).process_formdata(valuelist) super(GeoJSONField, self).process_formdata(valuelist)
if str(self.data) is '': if str(self.data) == '':
self.data = None self.data = None
if self.data is not None: if self.data is not None:
web_shape = self.session.scalar( web_shape = self.session.scalar(
......
...@@ -17,8 +17,10 @@ def geom_formatter(view, value): ...@@ -17,8 +17,10 @@ def geom_formatter(view, value):
"data-tile-layer-url": view.tile_layer_url, "data-tile-layer-url": view.tile_layer_url,
"data-tile-layer-attribution": view.tile_layer_attribution "data-tile-layer-attribution": view.tile_layer_attribution
}) })
if value.srid is -1:
if value.srid == -1:
value.srid = 4326 value.srid = 4326
geojson = view.session.query(view.model).with_entities(func.ST_AsGeoJSON(value)).scalar() geojson = view.session.query(view.model).with_entities(func.ST_AsGeoJSON(value)).scalar()
return Markup('<textarea %s>%s</textarea>' % (params, geojson)) return Markup('<textarea %s>%s</textarea>' % (params, geojson))
......
...@@ -7,6 +7,7 @@ from flask_mongoengine.wtf import orm, fields as mongo_fields ...@@ -7,6 +7,7 @@ from flask_mongoengine.wtf import orm, fields as mongo_fields
from flask_admin import form from flask_admin import form
from flask_admin.model.form import FieldPlaceholder from flask_admin.model.form import FieldPlaceholder
from flask_admin.model.fields import InlineFieldList, AjaxSelectField, AjaxSelectMultipleField from flask_admin.model.fields import InlineFieldList, AjaxSelectField, AjaxSelectMultipleField
from flask_admin.form.validators import FieldListInputRequired
from flask_admin._compat import iteritems from flask_admin._compat import iteritems
from .fields import ModelFormField, MongoFileField, MongoImageField from .fields import ModelFormField, MongoFileField, MongoImageField
...@@ -74,7 +75,10 @@ class CustomModelConverter(orm.ModelConverter): ...@@ -74,7 +75,10 @@ class CustomModelConverter(orm.ModelConverter):
kwargs['validators'] = list(kwargs['validators']) kwargs['validators'] = list(kwargs['validators'])
if field.required: if field.required:
kwargs['validators'].append(validators.InputRequired()) if isinstance(field, ListField):
kwargs['validators'].append(FieldListInputRequired())
else:
kwargs['validators'].append(validators.InputRequired())
elif not isinstance(field, ListField): elif not isinstance(field, ListField):
kwargs['validators'].append(validators.Optional()) kwargs['validators'].append(validators.Optional())
......
...@@ -140,7 +140,8 @@ class ModelView(BaseModelView): ...@@ -140,7 +140,8 @@ class ModelView(BaseModelView):
allowed_search_types = (mongoengine.StringField, allowed_search_types = (mongoengine.StringField,
mongoengine.URLField, mongoengine.URLField,
mongoengine.EmailField) mongoengine.EmailField,
mongoengine.ReferenceField)
""" """
List of allowed search field types. List of allowed search field types.
""" """
...@@ -363,8 +364,8 @@ class ModelView(BaseModelView): ...@@ -363,8 +364,8 @@ class ModelView(BaseModelView):
# Check type # Check type
if (field_type not in self.allowed_search_types): if (field_type not in self.allowed_search_types):
raise Exception('Can only search on text columns. ' + raise Exception('Can only search on text columns. ' +
'Failed to setup search for "%s"' % p) 'Failed to setup search for "%s"' % p)
self._search_fields.append(p) self._search_fields.append(p)
...@@ -466,7 +467,12 @@ class ModelView(BaseModelView): ...@@ -466,7 +467,12 @@ class ModelView(BaseModelView):
criteria = None criteria = None
for field in self._search_fields: for field in self._search_fields:
flt = {'%s__%s' % (field.name, op): term} if type(field) == mongoengine.ReferenceField:
import re
regex = re.compile('.*%s.*' % term)
else:
regex = term
flt = {'%s__%s' % (field.name, op): regex}
q = mongoengine.Q(**flt) q = mongoengine.Q(**flt)
if criteria is None: if criteria is None:
......
...@@ -47,10 +47,8 @@ class MongoImageInput(object): ...@@ -47,10 +47,8 @@ class MongoImageInput(object):
' <input type="checkbox" name="%(marker)s">Delete</input>' ' <input type="checkbox" name="%(marker)s">Delete</input>'
'</div>') '</div>')
def __call__(self, field, **kwargs):
def __call__(self, field, **kwargs):
kwargs.setdefault('id', field.id) kwargs.setdefault('id', field.id)
placeholder = '' placeholder = ''
if field.data and isinstance(field.data, ImageGridFsProxy): if field.data and isinstance(field.data, ImageGridFsProxy):
args = helpers.make_thumb_args(field.data) args = helpers.make_thumb_args(field.data)
......
...@@ -221,8 +221,8 @@ class ModelView(BaseModelView): ...@@ -221,8 +221,8 @@ class ModelView(BaseModelView):
# Check type # Check type
if not isinstance(p, (CharField, TextField)): if not isinstance(p, (CharField, TextField)):
raise Exception('Can only search on text columns. ' + raise Exception('Can only search on text columns. ' +
'Failed to setup search for "%s"' % p) 'Failed to setup search for "%s"' % p)
self._search_fields.append(p) self._search_fields.append(p)
......
...@@ -2,6 +2,7 @@ from flask_admin.babel import lazy_gettext ...@@ -2,6 +2,7 @@ from flask_admin.babel import lazy_gettext
from flask_admin.model import filters from flask_admin.model import filters
from flask_admin.contrib.sqla import tools from flask_admin.contrib.sqla import tools
from sqlalchemy.sql import not_, or_ from sqlalchemy.sql import not_, or_
import enum
class BaseSQLAFilter(filters.BaseFilter): class BaseSQLAFilter(filters.BaseFilter):
...@@ -339,10 +340,123 @@ class EnumFilterNotInList(FilterNotInList): ...@@ -339,10 +340,123 @@ class EnumFilterNotInList(FilterNotInList):
return values return values
class ChoiceTypeEqualFilter(FilterEqual):
def __init__(self, column, name, options=None, **kwargs):
super(ChoiceTypeEqualFilter, self).__init__(column, name, options, **kwargs)
def apply(self, query, user_query, alias=None):
column = self.get_column(alias)
choice_type = None
# loop through choice 'values' to try and find an exact match
if isinstance(column.type.choices, enum.EnumMeta):
for choice in column.type.choices:
if choice.name == user_query:
choice_type = choice.value
break
else:
for type, value in column.type.choices:
if value == user_query:
choice_type = type
break
if choice_type:
return query.filter(column == choice_type)
else:
return query.filter(column.in_([]))
class ChoiceTypeNotEqualFilter(FilterNotEqual):
def __init__(self, column, name, options=None, **kwargs):
super(ChoiceTypeNotEqualFilter, self).__init__(column, name, options, **kwargs)
def apply(self, query, user_query, alias=None):
column = self.get_column(alias)
choice_type = None
# loop through choice 'values' to try and find an exact match
if isinstance(column.type.choices, enum.EnumMeta):
for choice in column.type.choices:
if choice.name == user_query:
choice_type = choice.value
break
else:
for type, value in column.type.choices:
if value == user_query:
choice_type = type
break
if choice_type:
# != can exclude NULL values, so "or_ == None" needed to be added
return query.filter(or_(column != choice_type, column == None)) # noqa: E711
else:
return query
class ChoiceTypeLikeFilter(FilterLike):
def __init__(self, column, name, options=None, **kwargs):
super(ChoiceTypeLikeFilter, self).__init__(column, name, options, **kwargs)
def apply(self, query, user_query, alias=None):
column = self.get_column(alias)
choice_types = []
if user_query:
# loop through choice 'values' looking for matches
if isinstance(column.type.choices, enum.EnumMeta):
for choice in column.type.choices:
if user_query.lower() in choice.name.lower():
choice_types.append(choice.value)
else:
for type, value in column.type.choices:
if user_query.lower() in value.lower():
choice_types.append(type)
if choice_types:
return query.filter(column.in_(choice_types))
else:
return query
class ChoiceTypeNotLikeFilter(FilterNotLike):
def __init__(self, column, name, options=None, **kwargs):
super(ChoiceTypeNotLikeFilter, self).__init__(column, name, options, **kwargs)
def apply(self, query, user_query, alias=None):
column = self.get_column(alias)
choice_types = []
if user_query:
# loop through choice 'values' looking for matches
if isinstance(column.type.choices, enum.EnumMeta):
for choice in column.type.choices:
if user_query.lower() in choice.name.lower():
choice_types.append(choice.value)
else:
for type, value in column.type.choices:
if user_query.lower() in value.lower():
choice_types.append(type)
if choice_types:
# != can exclude NULL values, so "or_ == None" needed to be added
return query.filter(or_(column.notin_(choice_types), column == None)) # noqa: E711
else:
return query
class UuidFilterEqual(FilterEqual, filters.BaseUuidFilter):
pass
class UuidFilterNotEqual(FilterNotEqual, filters.BaseUuidFilter):
pass
class UuidFilterInList(filters.BaseUuidListFilter, FilterInList):
pass
class UuidFilterNotInList(filters.BaseUuidListFilter, FilterNotInList):
pass
# Base SQLA filter field converter # Base SQLA filter field converter
class FilterConverter(filters.BaseFilterConverter): class FilterConverter(filters.BaseFilterConverter):
strings = (FilterLike, FilterNotLike, FilterEqual, FilterNotEqual, strings = (FilterLike, FilterNotLike, FilterEqual, FilterNotEqual,
FilterEmpty, FilterInList, FilterNotInList) FilterEmpty, FilterInList, FilterNotInList)
string_key_filters = (FilterEqual, FilterNotEqual, FilterEmpty, FilterInList, FilterNotInList)
int_filters = (IntEqualFilter, IntNotEqualFilter, IntGreaterFilter, int_filters = (IntEqualFilter, IntNotEqualFilter, IntGreaterFilter,
IntSmallerFilter, FilterEmpty, IntInListFilter, IntSmallerFilter, FilterEmpty, IntInListFilter,
IntNotInListFilter) IntNotInListFilter)
...@@ -362,6 +476,11 @@ class FilterConverter(filters.BaseFilterConverter): ...@@ -362,6 +476,11 @@ class FilterConverter(filters.BaseFilterConverter):
time_filters = (TimeEqualFilter, TimeNotEqualFilter, TimeGreaterFilter, time_filters = (TimeEqualFilter, TimeNotEqualFilter, TimeGreaterFilter,
TimeSmallerFilter, TimeBetweenFilter, TimeNotBetweenFilter, TimeSmallerFilter, TimeBetweenFilter, TimeNotBetweenFilter,
FilterEmpty) FilterEmpty)
choice_type_filters = (ChoiceTypeEqualFilter, ChoiceTypeNotEqualFilter,
ChoiceTypeLikeFilter, ChoiceTypeNotLikeFilter, FilterEmpty)
uuid_filters = (UuidFilterEqual, UuidFilterNotEqual, FilterEmpty,
UuidFilterInList, UuidFilterNotInList)
arrow_type_filters = (DateTimeGreaterFilter, DateTimeSmallerFilter, FilterEmpty)
def convert(self, type_name, column, name, **kwargs): def convert(self, type_name, column, name, **kwargs):
filter_name = type_name.lower() filter_name = type_name.lower()
...@@ -373,10 +492,15 @@ class FilterConverter(filters.BaseFilterConverter): ...@@ -373,10 +492,15 @@ class FilterConverter(filters.BaseFilterConverter):
@filters.convert('string', 'char', 'unicode', 'varchar', 'tinytext', @filters.convert('string', 'char', 'unicode', 'varchar', 'tinytext',
'text', 'mediumtext', 'longtext', 'unicodetext', 'text', 'mediumtext', 'longtext', 'unicodetext',
'nchar', 'nvarchar', 'ntext', 'citext') 'nchar', 'nvarchar', 'ntext', 'citext', 'emailtype',
'URLType', 'IPAddressType')
def conv_string(self, column, name, **kwargs): def conv_string(self, column, name, **kwargs):
return [f(column, name, **kwargs) for f in self.strings] return [f(column, name, **kwargs) for f in self.strings]
@filters.convert('UUIDType', 'ColorType', 'TimezoneType', 'CurrencyType')
def conv_string_keys(self, column, name, **kwargs):
return [f(column, name, **kwargs) for f in self.string_key_filters]
@filters.convert('boolean', 'tinyint') @filters.convert('boolean', 'tinyint')
def conv_bool(self, column, name, **kwargs): def conv_bool(self, column, name, **kwargs):
return [f(column, name, **kwargs) for f in self.bool_filters] return [f(column, name, **kwargs) for f in self.bool_filters]
...@@ -402,6 +526,14 @@ class FilterConverter(filters.BaseFilterConverter): ...@@ -402,6 +526,14 @@ class FilterConverter(filters.BaseFilterConverter):
def conv_time(self, column, name, **kwargs): def conv_time(self, column, name, **kwargs):
return [f(column, name, **kwargs) for f in self.time_filters] return [f(column, name, **kwargs) for f in self.time_filters]
@filters.convert('ChoiceType')
def conv_sqla_utils_choice(self, column, name, **kwargs):
return [f(column, name, **kwargs) for f in self.choice_type_filters]
@filters.convert('ArrowType')
def conv_sqla_utils_arrow(self, column, name, **kwargs):
return [f(column, name, **kwargs) for f in self.arrow_type_filters]
@filters.convert('enum') @filters.convert('enum')
def conv_enum(self, column, name, options=None, **kwargs): def conv_enum(self, column, name, options=None, **kwargs):
if not options: if not options:
...@@ -418,3 +550,7 @@ class FilterConverter(filters.BaseFilterConverter): ...@@ -418,3 +550,7 @@ class FilterConverter(filters.BaseFilterConverter):
kwargs['enum_class'] = column.type._enum_class kwargs['enum_class'] = column.type._enum_class
return [f(column, name, options, **kwargs) for f in self.enum] return [f(column, name, options, **kwargs) for f in self.enum]
@filters.convert('uuid')
def conv_uuid(self, column, name, **kwargs):
return [f(column, name, **kwargs) for f in self.uuid_filters]
import warnings import warnings
from enum import Enum from enum import Enum, EnumMeta
from wtforms import fields, validators from wtforms import fields, validators
from sqlalchemy import Boolean, Column from sqlalchemy import Boolean, Column
...@@ -13,7 +13,7 @@ from flask_admin.model.helpers import prettify_name ...@@ -13,7 +13,7 @@ from flask_admin.model.helpers import prettify_name
from flask_admin._backwards import get_property from flask_admin._backwards import get_property
from flask_admin._compat import iteritems, text_type from flask_admin._compat import iteritems, text_type
from .validators import Unique from .validators import Unique, valid_currency, valid_color, TimeZoneValidator
from .fields import (QuerySelectField, QuerySelectMultipleField, from .fields import (QuerySelectField, QuerySelectMultipleField,
InlineModelFormList, InlineHstoreList, HstoreForm) InlineModelFormList, InlineHstoreList, HstoreForm)
from flask_admin.model.fields import InlineFormField from flask_admin.model.fields import InlineFormField
...@@ -243,9 +243,8 @@ class AdminModelConverter(ModelConverterBase): ...@@ -243,9 +243,8 @@ class AdminModelConverter(ModelConverterBase):
if override: if override:
return override(**kwargs) return override(**kwargs)
# Check choices # Check if a list of 'form_choices' are specified
form_choices = getattr(self.view, 'form_choices', None) form_choices = getattr(self.view, 'form_choices', None)
if mapper.class_ == self.view.model and form_choices: if mapper.class_ == self.view.model and form_choices:
choices = form_choices.get(prop.key) choices = form_choices.get(prop.key)
if choices: if choices:
...@@ -263,7 +262,6 @@ class AdminModelConverter(ModelConverterBase): ...@@ -263,7 +262,6 @@ class AdminModelConverter(ModelConverterBase):
return converter(model=model, mapper=mapper, prop=prop, return converter(model=model, mapper=mapper, prop=prop,
column=column, field_args=kwargs) column=column, field_args=kwargs)
return None return None
@classmethod @classmethod
...@@ -273,27 +271,52 @@ class AdminModelConverter(ModelConverterBase): ...@@ -273,27 +271,52 @@ class AdminModelConverter(ModelConverterBase):
@converts('String') # includes VARCHAR, CHAR, and Unicode @converts('String') # includes VARCHAR, CHAR, and Unicode
def conv_String(self, column, field_args, **extra): def conv_String(self, column, field_args, **extra):
if hasattr(column.type, 'enums'): if column.nullable:
accepted_values = list(column.type.enums) filters = field_args.get('filters', [])
filters.append(lambda x: x or None)
field_args['filters'] = filters
field_args['choices'] = [(f, f) for f in column.type.enums] self._string_common(column=column, field_args=field_args, **extra)
return fields.StringField(**field_args)
if column.nullable: @converts('sqlalchemy.sql.sqltypes.Enum')
field_args['allow_blank'] = column.nullable def convert_enum(self, column, field_args, **extra):
accepted_values.append(None) available_choices = [(f, f) for f in column.type.enums]
accepted_values = [key for key, val in available_choices]
field_args['validators'].append(validators.AnyOf(accepted_values)) if column.nullable:
field_args['coerce'] = lambda v: v.name if isinstance(v, Enum) else text_type(v) field_args['allow_blank'] = column.nullable
accepted_values.append(None)
filters = field_args.get('filters', [])
filters.append(lambda x: x or None)
field_args['filters'] = filters
return form.Select2Field(**field_args) field_args['choices'] = available_choices
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)
@converts('sqlalchemy_utils.types.choice.ChoiceType')
def convert_choice_type(self, column, field_args, **extra):
available_choices = []
# choices can either be specified as an enum, or as a list of tuples
if isinstance(column.type.choices, EnumMeta):
available_choices = [(f.value, f.name) for f in column.type.choices]
else:
available_choices = column.type.choices
accepted_values = [key for key, val in available_choices]
if column.nullable: if column.nullable:
field_args['allow_blank'] = column.nullable
accepted_values.append(None)
filters = field_args.get('filters', []) filters = field_args.get('filters', [])
filters.append(lambda x: x or None) filters.append(lambda x: x or None)
field_args['filters'] = filters field_args['filters'] = filters
self._string_common(column=column, field_args=field_args, **extra) field_args['choices'] = available_choices
return fields.StringField(**field_args) field_args['validators'].append(validators.AnyOf(accepted_values))
field_args['coerce'] = choice_type_coerce_factory(column.type)
return form.Select2Field(**field_args)
@converts('Text', 'LargeBinary', 'Binary', 'CIText') # includes UnicodeText @converts('Text', 'LargeBinary', 'Binary', 'CIText') # includes UnicodeText
def conv_Text(self, field_args, **extra): def conv_Text(self, field_args, **extra):
...@@ -317,6 +340,44 @@ class AdminModelConverter(ModelConverterBase): ...@@ -317,6 +340,44 @@ class AdminModelConverter(ModelConverterBase):
def convert_time(self, field_args, **extra): def convert_time(self, field_args, **extra):
return form.TimeField(**field_args) return form.TimeField(**field_args)
@converts('sqlalchemy_utils.types.arrow.ArrowType')
def convert_arrow_time(self, field_args, **extra):
return form.DateTimeField(**field_args)
@converts('sqlalchemy_utils.types.email.EmailType')
def convert_email(self, field_args, **extra):
field_args['validators'].append(validators.Email())
return fields.StringField(**field_args)
@converts('sqlalchemy_utils.types.url.URLType')
def convert_url(self, field_args, **extra):
field_args['validators'].append(validators.URL())
field_args['filters'] = [avoid_empty_strings] # don't accept empty strings, or whitespace
return fields.StringField(**field_args)
@converts('sqlalchemy_utils.types.ip_address.IPAddressType')
def convert_ip_address(self, field_args, **extra):
field_args['validators'].append(validators.IPAddress())
return fields.StringField(**field_args)
@converts('sqlalchemy_utils.types.color.ColorType')
def convert_color(self, field_args, **extra):
field_args['validators'].append(valid_color)
field_args['filters'] = [avoid_empty_strings] # don't accept empty strings, or whitespace
return fields.StringField(**field_args)
@converts('sqlalchemy_utils.types.currency.CurrencyType')
def convert_currency(self, field_args, **extra):
field_args['validators'].append(valid_currency)
field_args['filters'] = [avoid_empty_strings] # don't accept empty strings, or whitespace
return fields.StringField(**field_args)
@converts('sqlalchemy_utils.types.timezone.TimezoneType')
def convert_timezone(self, column, field_args, **extra):
field_args['validators'].append(TimeZoneValidator(coerce_function=column.type._coerce))
return fields.StringField(**field_args)
@converts('Integer') # includes BigInteger and SmallInteger @converts('Integer') # includes BigInteger and SmallInteger
def handle_integer_types(self, column, field_args, **extra): def handle_integer_types(self, column, field_args, **extra):
unsigned = getattr(column.type, 'unsigned', False) unsigned = getattr(column.type, 'unsigned', False)
...@@ -342,10 +403,12 @@ class AdminModelConverter(ModelConverterBase): ...@@ -342,10 +403,12 @@ class AdminModelConverter(ModelConverterBase):
field_args['validators'].append(validators.MacAddress()) field_args['validators'].append(validators.MacAddress())
return fields.StringField(**field_args) return fields.StringField(**field_args)
@converts('sqlalchemy.dialects.postgresql.base.UUID') @converts('sqlalchemy.dialects.postgresql.base.UUID',
'sqlalchemy_utils.types.uuid.UUIDType')
def conv_PGUuid(self, field_args, **extra): def conv_PGUuid(self, field_args, **extra):
field_args.setdefault('label', u'UUID') field_args.setdefault('label', u'UUID')
field_args['validators'].append(validators.UUID()) field_args['validators'].append(validators.UUID())
field_args['filters'] = [avoid_empty_strings] # don't accept empty strings, or whitespace
return fields.StringField(**field_args) return fields.StringField(**field_args)
@converts('sqlalchemy.dialects.postgresql.base.ARRAY', @converts('sqlalchemy.dialects.postgresql.base.ARRAY',
...@@ -363,6 +426,41 @@ class AdminModelConverter(ModelConverterBase): ...@@ -363,6 +426,41 @@ class AdminModelConverter(ModelConverterBase):
return form.JSONField(**field_args) return form.JSONField(**field_args)
def avoid_empty_strings(value):
"""
Return None if the incoming value is an empty string or whitespace.
"""
if value:
try:
value = value.strip()
except AttributeError:
# values are not always strings
pass
return value if value else None
def choice_type_coerce_factory(type_):
"""
Return a function to coerce a ChoiceType column, for use by Select2Field.
:param type_: ChoiceType object
"""
from sqlalchemy_utils import Choice
choices = type_.choices
if isinstance(choices, type) and issubclass(choices, Enum):
key, choice_cls = 'value', choices
else:
key, choice_cls = 'code', Choice
def choice_coerce(value):
if value is None:
return None
if isinstance(value, choice_cls):
return getattr(value, key)
return type_.python_type(value)
return choice_coerce
def _resolve_prop(prop): def _resolve_prop(prop):
""" """
Resolve proxied property Resolve proxied property
......
from sqlalchemy.ext.associationproxy import _AssociationList from sqlalchemy.ext.associationproxy import _AssociationList
from flask_admin.model.typefmt import BASE_FORMATTERS, list_formatter from flask_admin.model.typefmt import BASE_FORMATTERS, EXPORT_FORMATTERS, \
list_formatter
from sqlalchemy.orm.collections import InstrumentedList from sqlalchemy.orm.collections import InstrumentedList
def choice_formatter(view, choice):
"""
Return label of selected choice
see https://sqlalchemy-utils.readthedocs.io/
:param choice:
sqlalchemy_utils Choice, which has a `code` and a `value`
"""
return choice.value
def arrow_formatter(view, arrow_time):
"""
Return human-friendly string of the time relative to now.
see https://arrow.readthedocs.io/
:param arrow_time:
Arrow object for handling datetimes
"""
return arrow_time.humanize()
def arrow_export_formatter(view, arrow_time):
"""
Return string representation of Arrow object
see https://arrow.readthedocs.io/
:param arrow_time:
Arrow object for handling datetimes
"""
return arrow_time.format()
DEFAULT_FORMATTERS = BASE_FORMATTERS.copy() DEFAULT_FORMATTERS = BASE_FORMATTERS.copy()
EXPORT_FORMATTERS = EXPORT_FORMATTERS.copy()
DEFAULT_FORMATTERS.update({ DEFAULT_FORMATTERS.update({
InstrumentedList: list_formatter, InstrumentedList: list_formatter,
_AssociationList: list_formatter _AssociationList: list_formatter,
}) })
try:
from sqlalchemy_utils import Choice
DEFAULT_FORMATTERS[Choice] = choice_formatter
except ImportError:
pass
try:
from arrow import Arrow
DEFAULT_FORMATTERS[Arrow] = arrow_formatter
EXPORT_FORMATTERS[Arrow] = arrow_export_formatter
except ImportError:
pass
...@@ -66,3 +66,34 @@ class ItemsRequired(InputRequired): ...@@ -66,3 +66,34 @@ class ItemsRequired(InputRequired):
message = self.message message = self.message
raise ValidationError(message) raise ValidationError(message)
def valid_currency(form, field):
from sqlalchemy_utils import Currency
try:
Currency(field.data)
except (TypeError, ValueError):
raise ValidationError(field.gettext(u'Not a valid ISO currency code (e.g. USD, EUR, CNY).'))
def valid_color(form, field):
from colour import Color
try:
Color(field.data)
except (ValueError):
raise ValidationError(field.gettext(u'Not a valid color (e.g. "red", "#f00", "#ff0000").'))
class TimeZoneValidator(object):
"""
Tries to coerce a TimZone object from input data
"""
def __init__(self, coerce_function):
self.coerce_function = coerce_function
def __call__(self, form, field):
try:
self.coerce_function(str(field.data))
except Exception:
msg = u'Not a valid timezone (e.g. "America/New_York", "Africa/Johannesburg", "Asia/Singapore").'
raise ValidationError(field.gettext(msg))
...@@ -3,6 +3,7 @@ import warnings ...@@ -3,6 +3,7 @@ import warnings
import inspect import inspect
from sqlalchemy.orm.attributes import InstrumentedAttribute from sqlalchemy.orm.attributes import InstrumentedAttribute
from sqlalchemy.orm.base import manager_of_class, instance_state
from sqlalchemy.orm import joinedload, aliased from sqlalchemy.orm import joinedload, aliased
from sqlalchemy.sql.expression import desc from sqlalchemy.sql.expression import desc
from sqlalchemy import Boolean, Table, func, or_ from sqlalchemy import Boolean, Table, func, or_
...@@ -328,6 +329,8 @@ class ModelView(BaseModelView): ...@@ -328,6 +329,8 @@ class ModelView(BaseModelView):
menu_icon_type=menu_icon_type, menu_icon_type=menu_icon_type,
menu_icon_value=menu_icon_value) menu_icon_value=menu_icon_value)
self._manager = manager_of_class(self.model)
# Primary key # Primary key
self._primary_key = self.scaffold_pk() self._primary_key = self.scaffold_pk()
...@@ -590,10 +593,10 @@ class ModelView(BaseModelView): ...@@ -590,10 +593,10 @@ class ModelView(BaseModelView):
column_labels = dict(name='Name', last_name='Last Name') column_labels = dict(name='Name', last_name='Last Name')
column_searchable_list = ('name', 'last_name') column_searchable_list = ('name', 'last_name')
placeholder is: "Search: Name, Last Name" placeholder is: "Name, Last Name"
""" """
if not self.column_searchable_list: if not self.column_searchable_list:
return 'Search' return None
placeholders = [] placeholders = []
...@@ -605,7 +608,7 @@ class ModelView(BaseModelView): ...@@ -605,7 +608,7 @@ class ModelView(BaseModelView):
placeholders.append( placeholders.append(
self.column_labels.get(searchable, searchable)) self.column_labels.get(searchable, searchable))
return 'Search: %s' % u', '.join(placeholders) return u', '.join(placeholders)
def scaffold_filters(self, name): def scaffold_filters(self, name):
""" """
...@@ -1111,7 +1114,12 @@ class ModelView(BaseModelView): ...@@ -1111,7 +1114,12 @@ class ModelView(BaseModelView):
Form instance Form instance
""" """
try: try:
model = self.model() model = self._manager.new_instance()
# TODO: We need a better way to create model instances and stay compatible with
# SQLAlchemy __init__() behavior
state = instance_state(model)
self._manager.dispatch.init(state, [], {})
form.populate_obj(model) form.populate_obj(model)
self.session.add(model) self.session.add(model)
self._on_model_change(form, model, True) self._on_model_change(form, model, True)
......
from flask_admin.babel import gettext
from wtforms.validators import StopValidation
class FieldListInputRequired(object):
"""
Validates that at least one item was provided for a FieldList
"""
field_flags = ('required',)
def __call__(self, form, field):
if len(field.entries) == 0:
field.errors[:] = []
raise StopValidation(gettext('This field requires at least one item.'))
...@@ -45,13 +45,15 @@ def get_url(endpoint, **kwargs): ...@@ -45,13 +45,15 @@ def get_url(endpoint, **kwargs):
def is_required_form_field(field): def is_required_form_field(field):
""" """
Check if form field has `DataRequired` or `InputRequired` validators. Check if form field has `DataRequired`, `InputRequired`, or
`FieldListInputRequired` validators.
:param field: :param field:
WTForms field to check WTForms field to check
""" """
from flask_admin.form.validators import FieldListInputRequired
for validator in field.validators: for validator in field.validators:
if isinstance(validator, (DataRequired, InputRequired)): if isinstance(validator, (DataRequired, InputRequired, FieldListInputRequired)):
return True return True
return False return False
......
...@@ -1109,9 +1109,9 @@ class BaseModelView(BaseView, ActionsMixin): ...@@ -1109,9 +1109,9 @@ class BaseModelView(BaseView, ActionsMixin):
def search_placeholder(self): def search_placeholder(self):
""" """
Return search placeholder. Return search placeholder text.
""" """
return 'Search' return None
# Filter helpers # Filter helpers
def scaffold_filters(self, name): def scaffold_filters(self, name):
......
import time import time
import datetime import datetime
import uuid
from flask_admin.babel import lazy_gettext from flask_admin.babel import lazy_gettext
...@@ -269,6 +270,29 @@ class BaseTimeBetweenFilter(BaseFilter): ...@@ -269,6 +270,29 @@ class BaseTimeBetweenFilter(BaseFilter):
return False return False
class BaseUuidFilter(BaseFilter):
"""
Base uuid filter
"""
def __init__(self, name, options=None, data_type=None):
super(BaseUuidFilter, self).__init__(name,
options,
data_type='uuid')
def clean(self, value):
value = uuid.UUID(value)
return str(value)
class BaseUuidListFilter(BaseFilter):
"""
Base uuid list filter
"""
def clean(self, value):
return [str(uuid.UUID(v.strip())) for v in value.split(',') if v.strip()]
def convert(*args): def convert(*args):
""" """
Decorator for field to filter conversion routine. Decorator for field to filter conversion routine.
......
...@@ -110,6 +110,7 @@ class XEditableWidget(object): ...@@ -110,6 +110,7 @@ class XEditableWidget(object):
kwargs['data-rows'] = '5' kwargs['data-rows'] = '5'
elif field.type == 'BooleanField': elif field.type == 'BooleanField':
kwargs['data-type'] = 'select2' kwargs['data-type'] = 'select2'
kwargs['data-value'] = '1' if field.data else ''
# data-source = dropdown options # data-source = dropdown options
kwargs['data-source'] = json.dumps([ kwargs['data-source'] = json.dumps([
{'value': '', 'text': gettext('No')}, {'value': '', 'text': gettext('No')},
......
...@@ -143,3 +143,23 @@ table.filters tr td { ...@@ -143,3 +143,23 @@ table.filters tr td {
.editable-input .select2-container { .editable-input .select2-container {
min-width: 220px; min-width: 220px;
} }
[placeholder]{
text-overflow:ellipsis;
}
::-webkit-input-placeholder { /* Chrome/Opera/Safari */
text-overflow:ellipsis;
}
::-moz-placeholder { /* Firefox 19+ */
text-overflow:ellipsis;
}
:-ms-input-placeholder { /* IE 10+ */
text-overflow:ellipsis;
}
:-moz-placeholder { /* Firefox 18- */
text-overflow:ellipsis;
}
...@@ -108,3 +108,23 @@ body.modal-open { ...@@ -108,3 +108,23 @@ body.modal-open {
{ {
overflow-x: auto; overflow-x: auto;
} }
[placeholder]{
text-overflow:ellipsis;
}
::-webkit-input-placeholder { /* Chrome/Opera/Safari */
text-overflow:ellipsis;
}
::-moz-placeholder { /* Firefox 19+ */
text-overflow:ellipsis;
}
:-ms-input-placeholder { /* IE 10+ */
text-overflow:ellipsis;
}
:-moz-placeholder { /* Firefox 18- */
text-overflow:ellipsis;
}
...@@ -494,15 +494,21 @@ ...@@ -494,15 +494,21 @@
case 'x-editable-boolean': case 'x-editable-boolean':
$el.editable({ $el.editable({
params: overrideXeditableParams, params: overrideXeditableParams,
display: function(value, sourceData, response) { display: function(value, response) {
// display new boolean value as an icon // display boolean value as an icon
if(response) { if(value == '1') {
if(value == '1') { $(this).html('<span class="fa fa-check-circle glyphicon glyphicon-ok-circle icon-ok-circle"></span>');
$(this).html('<span class="fa fa-check-circle glyphicon glyphicon-ok-circle icon-ok-circle"></span>'); } else {
} else { $(this).html('<span class="fa fa-minus-circle glyphicon glyphicon-minus-sign icon-minus-sign"></span>');
$(this).html('<span class="fa fa-minus-circle glyphicon glyphicon-minus-sign icon-minus-sign"></span>');
}
} }
},
success: function(response, newValue) {
// update display
if(newValue == '1') {
$(this).html('<span class="fa fa-check-circle glyphicon glyphicon-ok-circle icon-ok-circle"></span>');
} else {
$(this).html('<span class="fa fa-minus-circle glyphicon glyphicon-minus-sign icon-minus-sign"></span>');
}
} }
}); });
} }
......
(function() {
window.faHelpers = {
// A simple confirm() wrapper
safeConfirm: function(msg) {
try {
return confirm(msg) ? true : false;
} catch (e) {
return false;
}
}
};
})();
...@@ -58,7 +58,7 @@ ...@@ -58,7 +58,7 @@
<div class="clearfix"></div> <div class="clearfix"></div>
{% endmacro %} {% endmacro %}
{% macro search_form(input_class="span2") %} {% macro search_form(input_class=None) %}
<form method="GET" action="{{ return_url }}" class="search-form"> <form method="GET" action="{{ return_url }}" class="search-form">
{% for flt_name, flt_value in filter_args.items() %} {% for flt_name, flt_value in filter_args.items() %}
<input type="hidden" name="{{ flt_name }}" value="{{ flt_value }}"> <input type="hidden" name="{{ flt_name }}" value="{{ flt_value }}">
...@@ -72,17 +72,17 @@ ...@@ -72,17 +72,17 @@
{% if sort_desc %} {% if sort_desc %}
<input type="hidden" name="desc" value="{{ sort_desc }}"> <input type="hidden" name="desc" value="{{ sort_desc }}">
{% endif %} {% endif %}
{%- set full_search_placeholder = _gettext('Search') %}
{%- if search_placeholder %}{% set full_search_placeholder = [full_search_placeholder, search_placeholder] | join(": ") %}{% endif %}
{% if search %} {% if search %}
<div class="input-append"> <div class="input-append">
<input type="text" name="search" value="{{ search }}" class="{{ input_class }}" placeholder="{{ _gettext('%(placeholder)s', placeholder=search_placeholder) }}"> <input type="search" name="search" class="input-xlarge{% if input_class %} {{ input_class }}{% endif %}" value="{{ search }}" placeholder="{{ full_search_placeholder }}">
<a href="{{ clear_search_url }}" class="clear add-on"> <a href="{{ clear_search_url }}" class="clear add-on">
<i class="fa fa-times icon-remove"></i> <i class="fa fa-times icon-remove"></i>
</a> </a>
</div> </div>
{% else %} {% else %}
<div> <input type="search" name="search" class="input-xlarge{% if input_class %} {{ input_class }}{% endif %}" value="" placeholder="{{ full_search_placeholder }}">
<input type="text" name="search" value="" class="{{ input_class }}" placeholder="{{ _gettext('%(placeholder)s', placeholder=search_placeholder) }}">
</div>
{% endif %} {% endif %}
</form> </form>
{% endmacro %} {% endmacro %}
......
...@@ -84,6 +84,7 @@ ...@@ -84,6 +84,7 @@
<script src="{{ admin_static.url(filename='bootstrap/bootstrap3/js/bootstrap.min.js', v='3.3.5') }}" type="text/javascript"></script> <script src="{{ admin_static.url(filename='bootstrap/bootstrap3/js/bootstrap.min.js', v='3.3.5') }}" type="text/javascript"></script>
<script src="{{ admin_static.url(filename='vendor/moment.min.js', v='2.9.0') }}" type="text/javascript"></script> <script src="{{ admin_static.url(filename='vendor/moment.min.js', v='2.9.0') }}" type="text/javascript"></script>
<script src="{{ admin_static.url(filename='vendor/select2/select2.min.js', v='3.5.2') }}" type="text/javascript"></script> <script src="{{ admin_static.url(filename='vendor/select2/select2.min.js', v='3.5.2') }}" type="text/javascript"></script>
<script src="{{ admin_static.url(filename='admin/js/helpers.js', v='1.0.0') }}" type="text/javascript"></script>
{% if admin_view.extra_js %} {% if admin_view.extra_js %}
{% for js_url in admin_view.extra_js %} {% for js_url in admin_view.extra_js %}
<script src="{{ js_url }}" type="text/javascript"></script> <script src="{{ js_url }}" type="text/javascript"></script>
......
...@@ -58,7 +58,7 @@ ...@@ -58,7 +58,7 @@
<div class="clearfix"></div> <div class="clearfix"></div>
{% endmacro %} {% endmacro %}
{% macro search_form(input_class="col-md-2") %} {% macro search_form(input_class=None) %}
<form method="GET" action="{{ return_url }}" class="navbar-form navbar-left" role="search"> <form method="GET" action="{{ return_url }}" class="navbar-form navbar-left" role="search">
{% for flt_name, flt_value in filter_args.items() %} {% for flt_name, flt_value in filter_args.items() %}
<input type="hidden" name="{{ flt_name }}" value="{{ flt_value }}"> <input type="hidden" name="{{ flt_name }}" value="{{ flt_value }}">
...@@ -72,14 +72,18 @@ ...@@ -72,14 +72,18 @@
{% if sort_desc %} {% if sort_desc %}
<input type="hidden" name="desc" value="{{ sort_desc }}"> <input type="hidden" name="desc" value="{{ sort_desc }}">
{% endif %} {% endif %}
{%- set full_search_placeholder = _gettext('Search') %}
{%- set max_size = config.get('FLASK_ADMIN_SEARCH_SIZE_MAX', 100) %}
{%- if search_placeholder %}{% set full_search_placeholder = [full_search_placeholder, search_placeholder] | join(": ") %}{% endif %}
{%- set input_size = [[full_search_placeholder | length, 30] | max, max_size] | min %}
{% if search %} {% if search %}
<div class="input-group"> <div class="input-group">
<input type="text" name="search" value="{{ search }}" class="{{ input_class }} form-control" placeholder="{{ _gettext('%(placeholder)s', placeholder=search_placeholder) }}"> <input type="search" name="search" value="{{ search }}" class="form-control{% if input_class %} {{ input_class }}{% endif %}" size="{{ input_size }}" placeholder="{{ full_search_placeholder }}">
<a href="{{ clear_search_url }}" class="input-group-addon clear"><span class="fa fa-times glyphicon glyphicon-remove"></span></a> <a href="{{ clear_search_url }}" class="input-group-addon clear"><span class="fa fa-times glyphicon glyphicon-remove"></span></a>
</div> </div>
{% else %} {% else %}
<div class="form-group"> <div class="form-group">
<input type="text" name="search" value="" class="{{ input_class }} form-control" placeholder="{{ _gettext('%(placeholder)s', placeholder=search_placeholder) }}"> <input type="search" name="search" value="" class="form-control{% if input_class %} {{ input_class }}{% endif %}" size="{{ input_size }}" placeholder="{{ full_search_placeholder }}">
</div> </div>
{% endif %} {% endif %}
</form> </form>
......
...@@ -31,7 +31,7 @@ ...@@ -31,7 +31,7 @@
{% elif csrf_token %} {% elif csrf_token %}
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
{% endif %} {% endif %}
<button onclick="return safeConfirm('{{ _gettext('Are you sure you want to delete this record?') }}');" title="{{ _gettext('Delete record') }}"> <button onclick="return faHelpers.safeConfirm('{{ _gettext('Are you sure you want to delete this record?') }}');" title="{{ _gettext('Delete record') }}">
<span class="fa fa-trash glyphicon glyphicon-trash"></span> <span class="fa fa-trash glyphicon glyphicon-trash"></span>
</button> </button>
</form> </form>
......
This diff is collapsed.
...@@ -16,4 +16,7 @@ nose ...@@ -16,4 +16,7 @@ nose
coveralls coveralls
pylint pylint
sqlalchemy-citext sqlalchemy-citext
sqlalchemy_utils
azure-storage-blob azure-storage-blob
arrow<0.14.0
colour
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