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
=========
Next release
-----
* Fix display of inline x-editable boolean fields on list view
* Add support for several SQLAlchemy-Utils data types
1.5.3
-----
......@@ -9,6 +15,7 @@ Changelog
* SQLAlchemy
* sort on multiple columns with `column_default_sort`
* 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
* support for using mapped `column_property`
* Upgrade Leaflet and Leaflet.draw plugins, used for geoalchemy integration
......
......@@ -43,7 +43,7 @@ master_doc = 'index'
# General information about the project.
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
# |version| and |release|, also used in various other places throughout the
......@@ -256,13 +256,13 @@ intersphinx_mapping = {'http://docs.python.org/': None}
# fall back if theme is not there
try:
__import__('flask_theme_support')
except ImportError, e:
print '-' * 74
print 'Warning: Flask themes unavailable. Building with default theme'
print 'If you want the Flask themes, run this command and build again:'
print
print ' git submodule update --init'
print '-' * 74
except ImportError as e:
print('-' * 74)
print('Warning: Flask themes unavailable. Building with default theme')
print('If you want the Flask themes, run this command and build again:')
print()
print(' git submodule update --init')
print('-' * 74)
pygments_style = 'tango'
html_theme = 'default'
......
......@@ -54,15 +54,11 @@ security = Security(app, user_datastore)
# Create customized model view class
class MyModelView(sqla.ModelView):
def is_accessible(self):
if not current_user.is_active or not current_user.is_authenticated:
return False
if current_user.has_role('superuser'):
return True
return False
return (current_user.is_active and
current_user.is_authenticated and
current_user.has_role('superuser')
)
def _handle_view(self, name, **kwargs):
"""
......
......@@ -2,3 +2,4 @@ Flask
Flask-Admin
Flask-MongoEngine
Flask-Login>=0.3.0
Pillow
\ No newline at end of file
This diff is collapsed.
Flask
Flask-Admin
Flask-BabelEx
Flask-SQLAlchemy
tablib
enum34; python_version < '3.0'
sqlalchemy_utils
arrow
colour
......@@ -19,7 +19,7 @@ class GeoJSONField(JSONField):
super(GeoJSONField, self).__init__(label, validators, **kwargs)
self.web_srid = 4326
self.srid = srid
if self.srid is -1:
if self.srid == -1:
self.transform_srid = self.web_srid
else:
self.transform_srid = self.srid
......@@ -30,11 +30,11 @@ class GeoJSONField(JSONField):
if self.raw_data:
return self.raw_data[0]
if type(self.data) is geoalchemy2.elements.WKBElement:
if self.srid is -1:
return self.session.scalar(func.ST_AsGeoJson(self.data))
if self.srid == -1:
return self.session.scalar(func.ST_AsGeoJSON(self.data))
else:
return self.session.scalar(
func.ST_AsGeoJson(
func.ST_AsGeoJSON(
func.ST_Transform(self.data, self.web_srid)
)
)
......@@ -43,7 +43,7 @@ class GeoJSONField(JSONField):
def process_formdata(self, valuelist):
super(GeoJSONField, self).process_formdata(valuelist)
if str(self.data) is '':
if str(self.data) == '':
self.data = None
if self.data is not None:
web_shape = self.session.scalar(
......
......@@ -17,8 +17,10 @@ def geom_formatter(view, value):
"data-tile-layer-url": view.tile_layer_url,
"data-tile-layer-attribution": view.tile_layer_attribution
})
if value.srid is -1:
if value.srid == -1:
value.srid = 4326
geojson = view.session.query(view.model).with_entities(func.ST_AsGeoJSON(value)).scalar()
return Markup('<textarea %s>%s</textarea>' % (params, geojson))
......
......@@ -7,6 +7,7 @@ from flask_mongoengine.wtf import orm, fields as mongo_fields
from flask_admin import form
from flask_admin.model.form import FieldPlaceholder
from flask_admin.model.fields import InlineFieldList, AjaxSelectField, AjaxSelectMultipleField
from flask_admin.form.validators import FieldListInputRequired
from flask_admin._compat import iteritems
from .fields import ModelFormField, MongoFileField, MongoImageField
......@@ -74,7 +75,10 @@ class CustomModelConverter(orm.ModelConverter):
kwargs['validators'] = list(kwargs['validators'])
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):
kwargs['validators'].append(validators.Optional())
......
......@@ -140,7 +140,8 @@ class ModelView(BaseModelView):
allowed_search_types = (mongoengine.StringField,
mongoengine.URLField,
mongoengine.EmailField)
mongoengine.EmailField,
mongoengine.ReferenceField)
"""
List of allowed search field types.
"""
......@@ -363,8 +364,8 @@ class ModelView(BaseModelView):
# Check type
if (field_type not in self.allowed_search_types):
raise Exception('Can only search on text columns. ' +
'Failed to setup search for "%s"' % p)
raise Exception('Can only search on text columns. ' +
'Failed to setup search for "%s"' % p)
self._search_fields.append(p)
......@@ -466,7 +467,12 @@ class ModelView(BaseModelView):
criteria = None
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)
if criteria is None:
......
......@@ -47,10 +47,8 @@ class MongoImageInput(object):
' <input type="checkbox" name="%(marker)s">Delete</input>'
'</div>')
def __call__(self, field, **kwargs):
def __call__(self, field, **kwargs):
kwargs.setdefault('id', field.id)
placeholder = ''
if field.data and isinstance(field.data, ImageGridFsProxy):
args = helpers.make_thumb_args(field.data)
......
......@@ -221,8 +221,8 @@ class ModelView(BaseModelView):
# Check type
if not isinstance(p, (CharField, TextField)):
raise Exception('Can only search on text columns. ' +
'Failed to setup search for "%s"' % p)
raise Exception('Can only search on text columns. ' +
'Failed to setup search for "%s"' % p)
self._search_fields.append(p)
......
......@@ -2,6 +2,7 @@ from flask_admin.babel import lazy_gettext
from flask_admin.model import filters
from flask_admin.contrib.sqla import tools
from sqlalchemy.sql import not_, or_
import enum
class BaseSQLAFilter(filters.BaseFilter):
......@@ -339,10 +340,123 @@ class EnumFilterNotInList(FilterNotInList):
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
class FilterConverter(filters.BaseFilterConverter):
strings = (FilterLike, FilterNotLike, FilterEqual, FilterNotEqual,
FilterEmpty, FilterInList, FilterNotInList)
string_key_filters = (FilterEqual, FilterNotEqual, FilterEmpty, FilterInList, FilterNotInList)
int_filters = (IntEqualFilter, IntNotEqualFilter, IntGreaterFilter,
IntSmallerFilter, FilterEmpty, IntInListFilter,
IntNotInListFilter)
......@@ -362,6 +476,11 @@ class FilterConverter(filters.BaseFilterConverter):
time_filters = (TimeEqualFilter, TimeNotEqualFilter, TimeGreaterFilter,
TimeSmallerFilter, TimeBetweenFilter, TimeNotBetweenFilter,
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):
filter_name = type_name.lower()
......@@ -373,10 +492,15 @@ class FilterConverter(filters.BaseFilterConverter):
@filters.convert('string', 'char', 'unicode', 'varchar', 'tinytext',
'text', 'mediumtext', 'longtext', 'unicodetext',
'nchar', 'nvarchar', 'ntext', 'citext')
'nchar', 'nvarchar', 'ntext', 'citext', 'emailtype',
'URLType', 'IPAddressType')
def conv_string(self, column, name, **kwargs):
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')
def conv_bool(self, column, name, **kwargs):
return [f(column, name, **kwargs) for f in self.bool_filters]
......@@ -402,6 +526,14 @@ class FilterConverter(filters.BaseFilterConverter):
def conv_time(self, column, name, **kwargs):
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')
def conv_enum(self, column, name, options=None, **kwargs):
if not options:
......@@ -418,3 +550,7 @@ class FilterConverter(filters.BaseFilterConverter):
kwargs['enum_class'] = column.type._enum_class
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
from enum import Enum
from enum import Enum, EnumMeta
from wtforms import fields, validators
from sqlalchemy import Boolean, Column
......@@ -13,7 +13,7 @@ from flask_admin.model.helpers import prettify_name
from flask_admin._backwards import get_property
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,
InlineModelFormList, InlineHstoreList, HstoreForm)
from flask_admin.model.fields import InlineFormField
......@@ -243,9 +243,8 @@ class AdminModelConverter(ModelConverterBase):
if override:
return override(**kwargs)
# Check choices
# Check if a list of 'form_choices' are specified
form_choices = getattr(self.view, 'form_choices', None)
if mapper.class_ == self.view.model and form_choices:
choices = form_choices.get(prop.key)
if choices:
......@@ -263,7 +262,6 @@ class AdminModelConverter(ModelConverterBase):
return converter(model=model, mapper=mapper, prop=prop,
column=column, field_args=kwargs)
return None
@classmethod
......@@ -273,27 +271,52 @@ class AdminModelConverter(ModelConverterBase):
@converts('String') # includes VARCHAR, CHAR, and Unicode
def conv_String(self, column, field_args, **extra):
if hasattr(column.type, 'enums'):
accepted_values = list(column.type.enums)
if column.nullable:
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:
field_args['allow_blank'] = column.nullable
accepted_values.append(None)
@converts('sqlalchemy.sql.sqltypes.Enum')
def convert_enum(self, column, field_args, **extra):
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))
field_args['coerce'] = lambda v: v.name if isinstance(v, Enum) else text_type(v)
if column.nullable:
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:
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
self._string_common(column=column, field_args=field_args, **extra)
return fields.StringField(**field_args)
field_args['choices'] = available_choices
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
def conv_Text(self, field_args, **extra):
......@@ -317,6 +340,44 @@ class AdminModelConverter(ModelConverterBase):
def convert_time(self, field_args, **extra):
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
def handle_integer_types(self, column, field_args, **extra):
unsigned = getattr(column.type, 'unsigned', False)
......@@ -342,10 +403,12 @@ class AdminModelConverter(ModelConverterBase):
field_args['validators'].append(validators.MacAddress())
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):
field_args.setdefault('label', u'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)
@converts('sqlalchemy.dialects.postgresql.base.ARRAY',
......@@ -363,6 +426,41 @@ class AdminModelConverter(ModelConverterBase):
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):
"""
Resolve proxied property
......
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
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()
EXPORT_FORMATTERS = EXPORT_FORMATTERS.copy()
DEFAULT_FORMATTERS.update({
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):
message = self.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
import inspect
from sqlalchemy.orm.attributes import InstrumentedAttribute
from sqlalchemy.orm.base import manager_of_class, instance_state
from sqlalchemy.orm import joinedload, aliased
from sqlalchemy.sql.expression import desc
from sqlalchemy import Boolean, Table, func, or_
......@@ -328,6 +329,8 @@ class ModelView(BaseModelView):
menu_icon_type=menu_icon_type,
menu_icon_value=menu_icon_value)
self._manager = manager_of_class(self.model)
# Primary key
self._primary_key = self.scaffold_pk()
......@@ -590,10 +593,10 @@ class ModelView(BaseModelView):
column_labels = dict(name='Name', last_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:
return 'Search'
return None
placeholders = []
......@@ -605,7 +608,7 @@ class ModelView(BaseModelView):
placeholders.append(
self.column_labels.get(searchable, searchable))
return 'Search: %s' % u', '.join(placeholders)
return u', '.join(placeholders)
def scaffold_filters(self, name):
"""
......@@ -1111,7 +1114,12 @@ class ModelView(BaseModelView):
Form instance
"""
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)
self.session.add(model)
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):
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:
WTForms field to check
"""
from flask_admin.form.validators import FieldListInputRequired
for validator in field.validators:
if isinstance(validator, (DataRequired, InputRequired)):
if isinstance(validator, (DataRequired, InputRequired, FieldListInputRequired)):
return True
return False
......
......@@ -1109,9 +1109,9 @@ class BaseModelView(BaseView, ActionsMixin):
def search_placeholder(self):
"""
Return search placeholder.
Return search placeholder text.
"""
return 'Search'
return None
# Filter helpers
def scaffold_filters(self, name):
......
import time
import datetime
import uuid
from flask_admin.babel import lazy_gettext
......@@ -269,6 +270,29 @@ class BaseTimeBetweenFilter(BaseFilter):
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):
"""
Decorator for field to filter conversion routine.
......
......@@ -110,6 +110,7 @@ class XEditableWidget(object):
kwargs['data-rows'] = '5'
elif field.type == 'BooleanField':
kwargs['data-type'] = 'select2'
kwargs['data-value'] = '1' if field.data else ''
# data-source = dropdown options
kwargs['data-source'] = json.dumps([
{'value': '', 'text': gettext('No')},
......
......@@ -143,3 +143,23 @@ table.filters tr td {
.editable-input .select2-container {
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 {
{
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 @@
case 'x-editable-boolean':
$el.editable({
params: overrideXeditableParams,
display: function(value, sourceData, response) {
// display new boolean value as an icon
if(response) {
if(value == '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>');
}
display: function(value, response) {
// display boolean value as an icon
if(value == '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>');
}
},
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 @@
<div class="clearfix"></div>
{% endmacro %}
{% macro search_form(input_class="span2") %}
{% macro search_form(input_class=None) %}
<form method="GET" action="{{ return_url }}" class="search-form">
{% for flt_name, flt_value in filter_args.items() %}
<input type="hidden" name="{{ flt_name }}" value="{{ flt_value }}">
......@@ -72,17 +72,17 @@
{% if sort_desc %}
<input type="hidden" name="desc" value="{{ sort_desc }}">
{% endif %}
{%- set full_search_placeholder = _gettext('Search') %}
{%- if search_placeholder %}{% set full_search_placeholder = [full_search_placeholder, search_placeholder] | join(": ") %}{% endif %}
{% if search %}
<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">
<i class="fa fa-times icon-remove"></i>
</a>
</div>
{% else %}
<div>
<input type="text" name="search" value="" class="{{ input_class }}" placeholder="{{ _gettext('%(placeholder)s', placeholder=search_placeholder) }}">
</div>
<input type="search" name="search" class="input-xlarge{% if input_class %} {{ input_class }}{% endif %}" value="" placeholder="{{ full_search_placeholder }}">
{% endif %}
</form>
{% endmacro %}
......
......@@ -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='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='admin/js/helpers.js', v='1.0.0') }}" type="text/javascript"></script>
{% if admin_view.extra_js %}
{% for js_url in admin_view.extra_js %}
<script src="{{ js_url }}" type="text/javascript"></script>
......
......@@ -58,7 +58,7 @@
<div class="clearfix"></div>
{% 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">
{% for flt_name, flt_value in filter_args.items() %}
<input type="hidden" name="{{ flt_name }}" value="{{ flt_value }}">
......@@ -72,14 +72,18 @@
{% if sort_desc %}
<input type="hidden" name="desc" value="{{ sort_desc }}">
{% 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 %}
<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>
</div>
{% else %}
<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>
{% endif %}
</form>
......
......@@ -31,7 +31,7 @@
{% elif csrf_token %}
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
{% 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>
</button>
</form>
......
This diff is collapsed.
......@@ -16,4 +16,7 @@ nose
coveralls
pylint
sqlalchemy-citext
sqlalchemy_utils
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