Commit 81f96186 authored by michael lynch's avatar michael lynch
parents 1766909e 22b5fdf4
......@@ -11,10 +11,13 @@ env:
- WTFORMS_VERSION=1
- WTFORMS_VERSION=2
addons:
postgresql: "9.4"
services:
- postgresql
- mongodb
before_script:
- psql -U postgres -c 'CREATE DATABASE flask_admin_test;'
- psql -U postgres -c 'CREATE EXTENSION postgis;' flask_admin_test
......
This diff is collapsed.
......@@ -221,7 +221,7 @@ class BaseFileAdmin(BaseView, ActionsMixin):
Base form class. Will be used to create the upload, rename, edit, and delete form.
Allows enabling CSRF validation and useful if you want to have custom
contructor or override some fields.
constructor or override some fields.
Example::
......
import json
from wtforms.fields import TextAreaField
from shapely.geometry import shape, mapping
from .widgets import LeafletWidget
from sqlalchemy import func
import geoalchemy2
#from types import NoneType
#from .. import db how do you get db.session in a Field?
import warnings
import geoalchemy2
from flask import current_app
from shapely.geometry import shape
from sqlalchemy import func
class JSONField(TextAreaField):
def _value(self):
if self.raw_data:
return self.raw_data[0]
if self.data:
return self.data
return ""
def process_formdata(self, valuelist):
if valuelist:
value = valuelist[0]
if not value:
self.data = None
return
try:
self.data = self.from_json(value)
except ValueError:
self.data = None
raise ValueError(self.gettext('Invalid JSON'))
def to_json(self, obj):
return json.dumps(obj)
from flask_admin.form import JSONField
def from_json(self, data):
return json.loads(data)
from .widgets import LeafletWidget
class GeoJSONField(JSONField):
widget = LeafletWidget()
def __init__(self, label=None, validators=None, geometry_type="GEOMETRY", srid='-1', session=None, **kwargs):
def __init__(self, label=None, validators=None, geometry_type="GEOMETRY",
srid='-1', session=None, **kwargs):
super(GeoJSONField, self).__init__(label, validators, **kwargs)
self.web_srid = 4326
self.srid = srid
if self.srid is -1:
self.transform_srid = self.web_srid
self.transform_srid = self.web_srid
else:
self.transform_srid = self.srid
self.transform_srid = self.srid
self.geometry_type = geometry_type.upper()
self.session = session
def _flip_coordinates(self, other_func):
if current_app.config.get('MAPBOX_FIX_COORDINATES_ORDER'):
return func.ST_FlipCoordinates(other_func)
else:
warnings.warn(
'Consider setting the Flask config option '
'MAPBOX_FIX_COORDINATES_ORDER as the current implementation '
'passes lng/lat coordinates in the wrong order to '
'Leaflet. Without this setting any coordinates saved will '
'have flipped coordinates in your database. '
'Please note that this will become the standard behavior in '
'the next major version of Flask-Admin.'
)
return other_func
def _value(self):
if self.raw_data:
return self.raw_data[0]
if type(self.data) is geoalchemy2.elements.WKBElement:
if self.srid is -1:
self.data = self.session.scalar(func.ST_AsGeoJson(self.data))
self.data = self.session.scalar(
func.ST_AsGeoJson(
self._flip_coordinates(self.data)
)
)
else:
self.data = self.session.scalar(func.ST_AsGeoJson(func.ST_Transform(self.data, self.web_srid)))
self.data = self.session.scalar(
func.ST_AsGeoJson(
self._flip_coordinates(
func.ST_Transform(self.data, self.web_srid)
)
)
)
return super(GeoJSONField, self)._value()
def process_formdata(self, valuelist):
super(GeoJSONField, self).process_formdata(valuelist)
if str(self.data) is '':
self.data = None
if self.data is not None:
web_shape = self.session.scalar(func.ST_AsText(func.ST_Transform(func.ST_GeomFromText(shape(self.data).wkt, self.web_srid), self.transform_srid)))
self.data = 'SRID='+str(self.srid)+';'+str(web_shape)
web_shape = self.session.scalar(
func.ST_AsText(
self._flip_coordinates(
func.ST_Transform(
func.ST_GeomFromText(
shape(self.data).wkt,
self.web_srid
),
self.transform_srid
)
)
)
)
self.data = 'SRID=' + str(self.srid) + ';' + str(web_shape)
import datetime
from flask_admin.babel import lazy_gettext
from flask_admin.model import filters
from .tools import parse_like_term
from mongoengine.queryset import Q
from bson.errors import InvalidId
from bson.objectid import ObjectId
class BaseMongoEngineFilter(filters.BaseFilter):
"""
......@@ -221,6 +222,31 @@ class DateTimeNotBetweenFilter(DateTimeBetweenFilter):
return lazy_gettext('not between')
class ReferenceObjectIdFilter(BaseMongoEngineFilter):
def validate(self, value):
"""
Validate value.
If value is valid, returns `True` and `False` otherwise.
:param value:
Value to validate
"""
try:
self.clean(value)
return True
except InvalidId:
return False
def clean(self, value):
return ObjectId(value.strip())
def apply(self, query, value):
flt = {'%s' % self.column.name: value}
return query.filter(**flt)
def operation(self):
return lazy_gettext('ObjectId equals')
# Base MongoEngine filter field converter
class FilterConverter(filters.BaseFilterConverter):
strings = (FilterLike, FilterNotLike, FilterEqual, FilterNotEqual,
......@@ -236,6 +262,7 @@ class FilterConverter(filters.BaseFilterConverter):
DateTimeGreaterFilter, DateTimeSmallerFilter,
DateTimeBetweenFilter, DateTimeNotBetweenFilter,
FilterEmpty)
reference_filters = (ReferenceObjectIdFilter,)
def convert(self, type_name, column, name):
filter_name = type_name.lower()
......@@ -264,3 +291,7 @@ class FilterConverter(filters.BaseFilterConverter):
@filters.convert('DateTimeField', 'ComplexDateTimeField')
def conv_datetime(self, column, name):
return [f(column, name) for f in self.datetime_filters]
@filters.convert('ReferenceField')
def conv_reference(self, column, name):
return [f(column, name) for f in self.reference_filters]
......@@ -147,118 +147,116 @@ class AdminModelConverter(ModelConverterBase):
# Check if it is relation or property
if hasattr(prop, 'direction'):
return self._convert_relation(prop, kwargs)
else:
# Ignore pk/fk
if hasattr(prop, 'columns'):
# Check if more than one column mapped to the property
if len(prop.columns) > 1:
columns = filter_foreign_columns(model.__table__, prop.columns)
if len(columns) > 1:
warnings.warn('Can not convert multiple-column properties (%s.%s)' % (model, prop.key))
return None
elif hasattr(prop, 'columns'): # Ignore pk/fk
# Check if more than one column mapped to the property
if len(prop.columns) > 1:
columns = filter_foreign_columns(model.__table__, prop.columns)
if len(columns) > 1:
warnings.warn('Can not convert multiple-column properties (%s.%s)' % (model, prop.key))
return None
column = columns[0]
else:
# Grab column
column = prop.columns[0]
form_columns = getattr(self.view, 'form_columns', None) or ()
# Do not display foreign keys - use relations, except when explicitly instructed
if column.foreign_keys and prop.key not in form_columns:
return None
# Only display "real" columns
if not isinstance(column, Column):
return None
unique = False
column = columns[0]
if column.primary_key:
if hidden_pk:
# If requested to add hidden field, show it
return fields.HiddenField()
else:
# Grab column
column = prop.columns[0]
# By default, don't show primary keys either
# If PK is not explicitly allowed, ignore it
if prop.key not in form_columns:
return None
form_columns = getattr(self.view, 'form_columns', None) or ()
# Current Unique Validator does not work with multicolumns-pks
if not has_multiple_pks(model):
kwargs['validators'].append(Unique(self.session,
model,
column))
unique = True
# If field is unique, validate it
if column.unique and not unique:
kwargs['validators'].append(Unique(self.session,
model,
column))
optional_types = getattr(self.view, 'form_optional_types', (Boolean,))
if (
not column.nullable
and not isinstance(column.type, optional_types)
and not column.default
and not column.server_default
):
kwargs['validators'].append(validators.InputRequired())
# Do not display foreign keys - use relations, except when explicitly instructed
if column.foreign_keys and prop.key not in form_columns:
return None
# Apply label and description if it isn't inline form field
if self.view.model == mapper.class_:
kwargs['label'] = self._get_label(prop.key, kwargs)
kwargs['description'] = self._get_description(prop.key, kwargs)
# Only display "real" columns
if not isinstance(column, Column):
return None
# Figure out default value
default = getattr(column, 'default', None)
value = None
unique = False
if default is not None:
value = getattr(default, 'arg', None)
if column.primary_key:
if hidden_pk:
# If requested to add hidden field, show it
return fields.HiddenField()
if value is not None:
if getattr(default, 'is_callable', False):
value = lambda: default.arg(None)
else:
# By default, don't show primary keys either
# If PK is not explicitly allowed, ignore it
if prop.key not in form_columns:
return None
# Current Unique Validator does not work with multicolumns-pks
if not has_multiple_pks(model):
kwargs['validators'].append(Unique(self.session,
model,
column))
unique = True
# If field is unique, validate it
if column.unique and not unique:
kwargs['validators'].append(Unique(self.session,
model,
column))
optional_types = getattr(self.view, 'form_optional_types', (Boolean,))
if (
not column.nullable
and not isinstance(column.type, optional_types)
and not column.default
and not column.server_default
):
kwargs['validators'].append(validators.InputRequired())
# Apply label and description if it isn't inline form field
if self.view.model == mapper.class_:
kwargs['label'] = self._get_label(prop.key, kwargs)
kwargs['description'] = self._get_description(prop.key, kwargs)
# Figure out default value
default = getattr(column, 'default', None)
value = None
if default is not None:
value = getattr(default, 'arg', None)
if value is not None:
if getattr(default, 'is_callable', False):
value = lambda: default.arg(None)
else:
if not getattr(default, 'is_scalar', True):
value = None
if not getattr(default, 'is_scalar', True):
value = None
if value is not None:
kwargs['default'] = value
if value is not None:
kwargs['default'] = value
# Check nullable
if column.nullable:
kwargs['validators'].append(validators.Optional())
# Check nullable
if column.nullable:
kwargs['validators'].append(validators.Optional())
# Override field type if necessary
override = self._get_field_override(prop.key)
if override:
return override(**kwargs)
# Override field type if necessary
override = self._get_field_override(prop.key)
if override:
return override(**kwargs)
# Check choices
form_choices = getattr(self.view, 'form_choices', None)
# Check choices
form_choices = getattr(self.view, 'form_choices', None)
if mapper.class_ == self.view.model and form_choices:
choices = form_choices.get(column.key)
if choices:
return form.Select2Field(
choices=choices,
allow_blank=column.nullable,
**kwargs
)
if mapper.class_ == self.view.model and form_choices:
choices = form_choices.get(column.key)
if choices:
return form.Select2Field(
choices=choices,
allow_blank=column.nullable,
**kwargs
)
# Run converter
converter = self.get_converter(column)
# Run converter
converter = self.get_converter(column)
if converter is None:
return None
if converter is None:
return None
return converter(model=model, mapper=mapper, prop=prop,
column=column, field_args=kwargs)
return converter(model=model, mapper=mapper, prop=prop,
column=column, field_args=kwargs)
return None
......@@ -352,6 +350,10 @@ class AdminModelConverter(ModelConverterBase):
inner_form = field_args.pop('form', HstoreForm)
return InlineHstoreList(InlineFormField(inner_form), **field_args)
@converts('JSON')
def convert_JSON(self, field_args, **extra):
return form.JSONField(**field_args)
def _resolve_prop(prop):
"""
......
......@@ -484,7 +484,10 @@ class ModelView(BaseModelView):
"Failed on: {0}".format(c))
else:
# column is in same table, use only model attribute name
column_name = column.key
if getattr(column, 'key', None) is not None:
column_name = column.key
else:
column_name = text_type(c)
# column_name must match column_name used in `get_list_columns`
result[column_name] = column
......@@ -517,7 +520,10 @@ class ModelView(BaseModelView):
column_name = text_type(c)
else:
# column is in same table, use only model attribute name
column_name = column.key
if getattr(column, 'key', None) is not None:
column_name = column.key
else:
column_name = text_type(c)
visible_name = self.get_column_name(column_name)
......@@ -558,11 +564,8 @@ class ModelView(BaseModelView):
if attr is None:
raise Exception('Failed to find field for filter: %s' % name)
# Figure out filters for related column, unless it's a hybrid_property
if isinstance(attr, ColumnElement):
warnings.warn(('Unable to scaffold the filter for %s, scaffolding '
'for hybrid_property is not supported yet.') % name)
elif hasattr(attr, 'property') and hasattr(attr.property, 'direction'):
# Figure out filters for related column
if hasattr(attr, 'property') and hasattr(attr.property, 'direction'):
filters = []
for p in self._get_model_iterator(attr.property.mapper.class_):
......@@ -593,14 +596,19 @@ class ModelView(BaseModelView):
return filters
else:
columns = tools.get_columns_for_field(attr)
is_hybrid_property = isinstance(attr, ColumnElement)
if is_hybrid_property:
column = attr
else:
columns = tools.get_columns_for_field(attr)
if len(columns) > 1:
raise Exception('Can not filter more than on one column for %s' % name)
if len(columns) > 1:
raise Exception('Can not filter more than on one column for %s' % name)
column = columns[0]
column = columns[0]
if (tools.need_join(self.model, column.table) and
# Join not needed for hybrid properties
if (not is_hybrid_property and tools.need_join(self.model, column.table) and
name not in self.column_labels):
visible_name = '%s / %s' % (
self.get_column_name(column.table.name),
......@@ -623,7 +631,7 @@ class ModelView(BaseModelView):
if joins:
self._filter_joins[column] = joins
elif tools.need_join(self.model, column.table):
elif not is_hybrid_property and tools.need_join(self.model, column.table):
self._filter_joins[column] = [column.table]
return flt
......
import time
import datetime
import json
from wtforms import fields, widgets
from wtforms import fields
from flask_admin.babel import gettext
from flask_admin._compat import text_type, as_unicode
......@@ -11,7 +12,8 @@ from . import widgets as admin_widgets
An understanding of WTForms's Custom Widgets is helpful for understanding this code: http://wtforms.simplecodes.com/docs/0.6.2/widgets.html#custom-widgets
"""
__all__ = ['DateTimeField', 'TimeField', 'Select2Field', 'Select2TagsField']
__all__ = ['DateTimeField', 'TimeField', 'Select2Field', 'Select2TagsField',
'JSONField']
class DateTimeField(fields.DateTimeField):
......@@ -176,3 +178,28 @@ class Select2TagsField(fields.StringField):
return as_unicode(self.data)
else:
return u''
class JSONField(fields.TextAreaField):
def _value(self):
if self.raw_data:
return self.raw_data[0]
elif self.data:
# prevent utf8 characters from being converted to ascii
return as_unicode(json.dumps(self.data, ensure_ascii=False))
else:
return ''
def process_formdata(self, valuelist):
if valuelist:
value = valuelist[0]
# allow saving blank field as None
if not value:
self.data = None
return
try:
self.data = json.loads(valuelist[0])
except ValueError:
raise ValueError(self.gettext('Invalid JSON'))
......@@ -21,7 +21,7 @@ from flask_admin.babel import gettext
from flask_admin.base import BaseView, expose
from flask_admin.form import BaseForm, FormOpts, rules
from flask_admin.model import filters, typefmt
from flask_admin.model import filters, typefmt, template
from flask_admin.actions import ActionsMixin
from flask_admin.helpers import (get_form_data, validate_form_on_submit,
get_redirect_target, flash_errors)
......@@ -459,6 +459,24 @@ class BaseModelView(BaseView, ActionsMixin):
actions endpoints are accessible.
"""
column_extra_row_actions = None
"""
List of row actions (instances of :class:`~flask_admin.model.template.BaseListRowAction`).
Flask-Admin will generate standard per-row actions (edit, delete, etc)
and will append custom actions from this list right after them.
For example::
from flask_admin.model.template import EndpointLinkRowAction, LinkRowAction
class MyModelView(BaseModelView):
column_extra_row_actions = [
LinkRowAction('glyphicon glyphicon-off', 'http://direct.link/?id={row_id}'),
EndpointLinkRowAction('glyphicon glyphicon-test', 'my_view.index_view')
]
"""
simple_list_pager = False
"""
Enable or disable simple list pager.
......@@ -483,7 +501,7 @@ class BaseModelView(BaseView, ActionsMixin):
"""
Base form class. Will be used by form scaffolding function when creating model form.
Useful if you want to have custom contructor or override some fields.
Useful if you want to have custom constructor or override some fields.
Example::
......@@ -693,7 +711,7 @@ class BaseModelView(BaseView, ActionsMixin):
A list of available export filetypes. `csv` only is default, but any
filetypes supported by tablib can be used.
Check tablib for https://github.com/kennethreitz/tablib/bloab/master/README.rst
Check tablib for https://github.com/kennethreitz/tablib/blob/master/README.rst
for supported types.
"""
......@@ -925,6 +943,29 @@ class BaseModelView(BaseView, ActionsMixin):
return [(c, self.get_column_name(c)) for c in columns]
def get_list_row_actions(self):
"""
Return list of row action objects, each is instance of :class:`~flask_admin.model.template.BaseListRowAction`
"""
actions = []
if self.can_view_details:
if self.details_modal:
actions.append(template.ViewPopupRowAction())
else:
actions.append(template.ViewRowAction())
if self.can_edit:
if self.edit_modal:
actions.append(template.EditPopupRowAction())
else:
actions.append(template.EditRowAction())
if self.can_delete:
actions.append(template.DeleteRowAction())
return actions + (self.column_extra_row_actions or [])
def get_details_columns(self):
"""
Returns a list of the model field names in the details view. If
......@@ -1815,6 +1856,7 @@ class BaseModelView(BaseView, ActionsMixin):
list_columns=self._list_columns,
sortable_columns=self._sortable_columns,
editable_columns=self.column_editable_list,
list_row_actions=self.get_list_row_actions(),
# Pagination
count=count,
......
......@@ -160,7 +160,7 @@ class BaseDateBetweenFilter(BaseFilter):
Apply method is different for each back-end.
"""
def clean(self, value):
return [datetime.datetime.strptime(range, '%Y-%m-%d')
return [datetime.datetime.strptime(range, '%Y-%m-%d').date()
for range in value.split(' to ')]
def operation(self):
......@@ -168,7 +168,7 @@ class BaseDateBetweenFilter(BaseFilter):
def validate(self, value):
try:
value = [datetime.datetime.strptime(range, '%Y-%m-%d')
value = [datetime.datetime.strptime(range, '%Y-%m-%d').date()
for range in value.split(' to ')]
# if " to " is missing, fail validation
# sqlalchemy's .between() will not work if end date is before start date
......
from jinja2 import contextfunction
from flask_admin._compat import string_types, reduce
from flask_admin.babel import gettext
class BaseListRowAction(object):
def __init__(self, title=None):
self.title = title
def render(self, context, row_id, row):
raise NotImplementedError()
@contextfunction
def render_ctx(self, context, row_id, row):
return self.render(context, row_id, row)
def _resolve_symbol(self, context, symbol):
if '.' in symbol:
parts = symbol.split('.')
m = context.resolve(parts[0])
return reduce(getattr, parts[1:], m)
else:
return context.resolve(symbol)
class LinkRowAction(BaseListRowAction):
def __init__(self, icon_class, url, title=None):
super(LinkRowAction, self).__init__(title=title)
self.url = url
self.icon_class = icon_class
def render(self, context, row_id, row):
m = self._resolve_symbol(context, 'row_actions.link')
if isinstance(self.url, string_types):
url = self.url.format(row_id=row_id)
else:
url = self.url(self, row_id, row)
return m(self, url)
class EndpointLinkRowAction(BaseListRowAction):
def __init__(self, icon_class, endpoint, title=None, id_arg='id', url_args=None):
super(EndpointLinkRowAction, self).__init__(title=title)
self.icon_class = icon_class
self.endpoint = endpoint
self.id_arg = id_arg
self.url_args = url_args
def render(self, context, row_id, row):
m = self._resolve_symbol(context, 'row_actions.link')
get_url = self._resolve_symbol(context, 'get_url')
kwargs = dict(self.url_args) if self.url_args else {}
kwargs[self.id_arg] = row_id
url = get_url(self.endpoint, **kwargs)
return m(self, url)
class TemplateLinkRowAction(BaseListRowAction):
def __init__(self, template_name, title=None):
super(TemplateLinkRowAction, self).__init__(title=title)
self.template_name = template_name
def render(self, context, row_id, row):
m = self._resolve_symbol(context, self.template_name)
return m(self, row_id, row)
class ViewRowAction(TemplateLinkRowAction):
def __init__(self):
super(ViewRowAction, self).__init__(
'row_actions.view_row',
gettext('View Record'))
class ViewPopupRowAction(TemplateLinkRowAction):
def __init__(self):
super(ViewPopupRowAction, self).__init__(
'row_actions.view_row_popup',
gettext('View Record'))
class EditRowAction(TemplateLinkRowAction):
def __init__(self):
super(EditRowAction, self).__init__(
'row_actions.edit_row',
gettext('Edit Record'))
class EditPopupRowAction(TemplateLinkRowAction):
def __init__(self):
super(EditPopupRowAction, self).__init__(
'row_actions.edit_row_popup',
gettext('Edit Record'))
class DeleteRowAction(TemplateLinkRowAction):
def __init__(self):
super(DeleteRowAction, self).__init__(
'row_actions.delete_row',
gettext('Edit Record'))
# Macro helper
def macro(name):
'''
Jinja2 macro list column formatter.
......@@ -14,3 +126,4 @@ def macro(name):
return m(model=model, column=column)
return inner
import json
from jinja2 import Markup
from flask_admin._compat import text_type
try:
from enum import Enum
except ImportError:
Enum = None
def null_formatter(view, value):
......@@ -44,13 +50,40 @@ def list_formatter(view, values):
return u', '.join(text_type(v) for v in values)
def enum_formatter(view, value):
"""
Return the name of the enumerated member.
:param value:
Value to check
"""
return value.name
def dict_formatter(view, value):
"""
Removes unicode entities when displaying dict as string. Also unescapes
non-ASCII characters stored in the JSON.
:param value:
Dict to convert to string
"""
return json.dumps(value, ensure_ascii=False)
BASE_FORMATTERS = {
type(None): empty_formatter,
bool: bool_formatter,
list: list_formatter,
dict: dict_formatter,
}
EXPORT_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
......@@ -3,6 +3,7 @@
{% import 'admin/static.html' as admin_static with context%}
{% import 'admin/model/layout.html' as model_layout with context %}
{% import 'admin/actions.html' as actionlib with context %}
{% import 'admin/model/row_actions.html' as row_actions with context %}
{% block head %}
{{ super() }}
......@@ -116,40 +117,11 @@
{% block list_row_actions_column scoped %}
{% if admin_view.column_display_actions %}
<td class="list-buttons-column">
{% block list_row_actions scoped %}
{%- if admin_view.can_view_details -%}
{%- if admin_view.details_modal -%}
{{ lib.add_modal_button(url=get_url('.details_view', id=get_pk_value(row), url=return_url, modal=True), title=_gettext('View Record'), content='<span class="fa fa-eye glyphicon icon-eye-open"></span>') }}
{% else %}
<a class="icon" href="{{ get_url('.details_view', id=get_pk_value(row), url=return_url) }}" title="{{ _gettext('View Record') }}">
<span class="fa fa-eye icon-eye-open"></span>
</a>
{%- endif -%}
{%- endif -%}
{%- if admin_view.can_edit -%}
{%- if admin_view.edit_modal -%}
{{ lib.add_modal_button(url=get_url('.edit_view', id=get_pk_value(row), url=return_url, modal=True), title=_gettext('Edit Record'), content='<i class="fa fa-pencil icon-pencil"></i>') }}
{% else %}
<a class="icon" href="{{ get_url('.edit_view', id=get_pk_value(row), url=return_url) }}" title="{{ _gettext('Edit Record') }}">
<i class="fa fa-pencil icon-pencil"></i>
</a>
{%- endif -%}
{%- endif -%}
{%- if admin_view.can_delete -%}
<form class="icon" method="POST" action="{{ get_url('.delete_view') }}">
{{ delete_form.id(value=get_pk_value(row)) }}
{{ delete_form.url(value=return_url) }}
{% if delete_form.csrf_token %}
{{ delete_form.csrf_token }}
{% elif csrf_token %}
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
{% endif %}
<button onclick="return confirm('{{ _gettext('Are you sure you want to delete this record?') }}');" title="{{ _gettext('Delete record') }}">
<i class="fa fa-trash icon-trash"></i>
</button>
</form>
{%- endif -%}
{% endblock %}
{% block list_row_actions scoped %}
{% for action in list_row_actions %}
{{ action.render_ctx(get_pk_value(row), row) }}
{% endfor %}
{% endblock %}
</td>
{%- endif -%}
{% endblock %}
......
{% import 'admin/lib.html' as lib with context %}
{% macro link(action, url, icon_class=None) %}
<a class="icon" href="{{ url }}" title="{{ action.title or '' }}">
<span class="{{ icon_class or action.icon_class }}"></span>
</a>
{% endmacro %}
{% macro view_row(action, row_id, row) %}
{{ link(action, get_url('.details_view', id=row_id, url=return_url), 'fa fa-eye glyphicon icon-eye-open') }}
{% endmacro %}
{% macro view_row_popup(action, row_id, row) %}
{{ lib.add_modal_button(url=get_url('.details_view', id=row_id, url=return_url, modal=True), title=action.title, content='<span class="fa fa-eye glyphicon icon-eye-open"></span>') }}
{% endmacro %}
{% macro edit_row(action, row_id, row) %}
{{ link(action, get_url('.edit_view', id=row_id, url=return_url), 'fa fa-pencil glyphicon icon-pencil') }}
{% endmacro %}
{% macro edit_row_popup(action, row_id, row) %}
{{ lib.add_modal_button(url=get_url('.edit_view', id=row_id, url=return_url, modal=True), title=action.title, content='<span class="fa fa-pencil glyphicon icon-pencil"></span>') }}
{% endmacro %}
{% macro delete_row(action, row_id, row) %}
<form class="icon" method="POST" action="{{ get_url('.delete_view') }}">
{{ delete_form.id(value=get_pk_value(row)) }}
{{ delete_form.url(value=return_url) }}
{% if delete_form.csrf_token %}
{{ delete_form.csrf_token }}
{% elif csrf_token %}
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
{% endif %}
<button onclick="return confirm('{{ _gettext('Are you sure you want to delete this record?') }}');" title="Delete record">
<span class="fa fa-trash glyphicon icon-trash"></span>
</button>
</form>
{% endmacro %}
......@@ -3,6 +3,7 @@
{% import 'admin/static.html' as admin_static with context%}
{% import 'admin/model/layout.html' as model_layout with context %}
{% import 'admin/actions.html' as actionlib with context %}
{% import 'admin/model/row_actions.html' as row_actions with context %}
{% block head %}
{{ super() }}
......@@ -116,38 +117,9 @@
{% if admin_view.column_display_actions %}
<td class="list-buttons-column">
{% block list_row_actions scoped %}
{%- if admin_view.can_view_details -%}
{%- if admin_view.details_modal -%}
{{ lib.add_modal_button(url=get_url('.details_view', id=get_pk_value(row), url=return_url, modal=True), title=_gettext('View Record'), content='<span class="fa fa-eye glyphicon glyphicon-eye-open"></span>') }}
{% else %}
<a class="icon" href="{{ get_url('.details_view', id=get_pk_value(row), url=return_url) }}" title="{{ _gettext('View Record') }}">
<span class="fa fa-eye glyphicon glyphicon-eye-open"></span>
</a>
{%- endif -%}
{%- endif -%}
{%- if admin_view.can_edit -%}
{%- if admin_view.edit_modal -%}
{{ lib.add_modal_button(url=get_url('.edit_view', id=get_pk_value(row), url=return_url, modal=True), title=_gettext('Edit Record'), content='<span class="fa fa-pencil glyphicon glyphicon-pencil"></span>') }}
{% else %}
<a class="icon" href="{{ get_url('.edit_view', id=get_pk_value(row), url=return_url) }}" title="{{ _gettext('Edit Record') }}">
<span class="fa fa-pencil glyphicon glyphicon-pencil"></span>
</a>
{%- endif -%}
{%- endif -%}
{%- if admin_view.can_delete -%}
<form class="icon" method="POST" action="{{ get_url('.delete_view') }}">
{{ delete_form.id(value=get_pk_value(row)) }}
{{ delete_form.url(value=return_url) }}
{% if delete_form.csrf_token %}
{{ delete_form.csrf_token }}
{% elif csrf_token %}
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
{% endif %}
<button onclick="return confirm('{{ _gettext('Are you sure you want to delete this record?') }}');" title="Delete record">
<span class="fa fa-trash glyphicon glyphicon-trash"></span>
</button>
</form>
{%- endif -%}
{% for action in list_row_actions %}
{{ action.render_ctx(get_pk_value(row), row) }}
{% endfor %}
{% endblock %}
</td>
{%- endif -%}
......
{% import 'admin/lib.html' as lib with context %}
{% macro link(action, url, icon_class=None) %}
<a class="icon" href="{{ url }}" title="{{ action.title or '' }}">
<span class="{{ icon_class or action.icon_class }}"></span>
</a>
{% endmacro %}
{% macro view_row(action, row_id, row) %}
{{ link(action, get_url('.details_view', id=row_id, url=return_url), 'fa fa-eye glyphicon glyphicon-eye-open') }}
{% endmacro %}
{% macro view_row_popup(action, row_id, row) %}
{{ lib.add_modal_button(url=get_url('.details_view', id=row_id, url=return_url, modal=True), title=action.title, content='<span class="fa fa-eye glyphicon glyphicon-eye-open"></span>') }}
{% endmacro %}
{% macro edit_row(action, row_id, row) %}
{{ link(action, get_url('.edit_view', id=row_id, url=return_url), 'fa fa-pencil glyphicon glyphicon-pencil') }}
{% endmacro %}
{% macro edit_row_popup(action, row_id, row) %}
{{ lib.add_modal_button(url=get_url('.edit_view', id=row_id, url=return_url, modal=True), title=action.title, content='<span class="fa fa-pencil glyphicon glyphicon-pencil"></span>') }}
{% endmacro %}
{% macro delete_row(action, row_id, row) %}
<form class="icon" method="POST" action="{{ get_url('.delete_view') }}">
{{ delete_form.id(value=get_pk_value(row)) }}
{{ delete_form.url(value=return_url) }}
{% if delete_form.csrf_token %}
{{ delete_form.csrf_token }}
{% elif csrf_token %}
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
{% endif %}
<button onclick="return confirm('{{ _gettext('Are you sure you want to delete this record?') }}');" title="Delete record">
<span class="fa fa-trash glyphicon glyphicon-trash"></span>
</button>
</form>
{% endmacro %}
from __future__ import unicode_literals
from nose.tools import eq_, ok_
import json
import re
from flask_admin.contrib.geoa import ModelView
from flask_admin.contrib.geoa.fields import GeoJSONField
from geoalchemy2 import Geometry
from geoalchemy2.shape import to_shape
from flask_admin.contrib.geoa.fields import GeoJSONField
from nose.tools import eq_
from . import setup
......@@ -30,6 +32,8 @@ def test_model():
app, db, admin = setup()
GeoModel = create_models(db)
db.create_all()
GeoModel.query.delete()
db.session.commit()
view = ModelView(GeoModel, db.session)
admin.add_view(view)
......@@ -81,37 +85,84 @@ def test_model():
rv = client.get('/admin/geomodel/')
eq_(rv.status_code, 200)
point_opt_1 = '>{"type": "Point", "coordinates": [125.8, 10.0]}</textarea>'
point_opt_2 = '>{"coordinates": [125.8, 10.0], "type": "Point"}</textarea>'
point_opt_3 = '>{"type":"Point","coordinates":[125.8,10]}</textarea>'
html = rv.data.decode('utf-8')
ok_(point_opt_1 in html or point_opt_2 in html or point_opt_3 in html, html)
pattern = r'(.|\n)+({.*"type": ?"Point".*})</textarea>(.|\n)+'
group = re.match(pattern, html).group(2)
p = json.loads(group)
eq_(p['coordinates'][0], 125.8)
eq_(p['coordinates'][1], 10.0)
url = '/admin/geomodel/edit/?id=%s' % model.id
rv = client.get(url)
eq_(rv.status_code, 200)
#rv = client.post(url, data={
# "name": "edited",
# "point": '{"type": "Point", "coordinates": [99.9, 10.5]}',
# "line": '', # set to NULL in the database
#})
#eq_(rv.status_code, 302)
# rv = client.post(url, data={
# "name": "edited",
# "point": '{"type": "Point", "coordinates": [99.9, 10.5]}',
# "line": '', # set to NULL in the database
# })
# eq_(rv.status_code, 302)
#
#model = db.session.query(GeoModel).first()
#eq_(model.name, "edited")
#eq_(to_shape(model.point).geom_type, "Point")
#eq_(list(to_shape(model.point).coords), [(99.9, 10.5)])
#eq_(to_shape(model.line), None)
#eq_(to_shape(model.polygon).geom_type, "Polygon")
#eq_(list(to_shape(model.polygon).exterior.coords),
# [(100.0, 0.0), (101.0, 0.0), (101.0, 1.0), (100.0, 1.0), (100.0, 0.0)])
#eq_(to_shape(model.multi).geom_type, "MultiPoint")
#eq_(len(to_shape(model.multi).geoms), 2)
#eq_(list(to_shape(model.multi).geoms[0].coords), [(100.0, 0.0)])
#eq_(list(to_shape(model.multi).geoms[1].coords), [(101.0, 1.0)])
# model = db.session.query(GeoModel).first()
# eq_(model.name, "edited")
# eq_(to_shape(model.point).geom_type, "Point")
# eq_(list(to_shape(model.point).coords), [(99.9, 10.5)])
# eq_(to_shape(model.line), None)
# eq_(to_shape(model.polygon).geom_type, "Polygon")
# eq_(list(to_shape(model.polygon).exterior.coords),
# [(100.0, 0.0), (101.0, 0.0), (101.0, 1.0), (100.0, 1.0), (100.0, 0.0)])
# eq_(to_shape(model.multi).geom_type, "MultiPoint")
# eq_(len(to_shape(model.multi).geoms), 2)
# eq_(list(to_shape(model.multi).geoms[0].coords), [(100.0, 0.0)])
# eq_(list(to_shape(model.multi).geoms[1].coords), [(101.0, 1.0)])
url = '/admin/geomodel/delete/?id=%s' % model.id
rv = client.post(url)
eq_(rv.status_code, 302)
eq_(db.session.query(GeoModel).count(), 0)
def test_mapbox_fix_point_coordinates():
app, db, admin = setup()
app.config['MAPBOX_FIX_COORDINATES_ORDER'] = True
GeoModel = create_models(db)
db.create_all()
GeoModel.query.delete()
db.session.commit()
view = ModelView(GeoModel, db.session)
admin.add_view(view)
# Make some test clients
client = app.test_client()
rv = client.post('/admin/geomodel/new/', data={
"name": "test1",
"point": '{"type": "Point", "coordinates": [125.8, 10.0]}',
"line": '{"type": "LineString", "coordinates": [[50.2345, 94.2], [50.21, 94.87]]}',
"polygon": '{"type": "Polygon", "coordinates": [[[100.0, 0.0], [101.0, 0.0], [101.0, 1.0], [100.0, 1.0], [100.0, 0.0]]]}',
"multi": '{"type": "MultiPoint", "coordinates": [[100.0, 0.0], [101.0, 1.0]]}',
})
model = db.session.query(GeoModel).first()
# Notice how the coordinates are reversed here, i.e. longitude first which
# is the way it's stored in PostGIS columns.
eq_(list(to_shape(model.point).coords), [(10.0, 125.8)])
eq_(list(to_shape(model.line).coords), [(94.2, 50.2345), (94.87, 50.21)])
eq_(list(to_shape(model.polygon).exterior.coords),
[(0.0, 100.0), (0.0, 101.0), (1.0, 101.0), (1.0, 100.0), (0.0, 100.0)])
eq_(list(to_shape(model.multi).geoms[0].coords), [(0.0, 100.0)])
eq_(list(to_shape(model.multi).geoms[1].coords), [(1.0, 101.0)])
rv = client.get('/admin/geomodel/')
eq_(rv.status_code, 200)
html = rv.data.decode('utf-8')
pattern = r'(.|\n)+({.*"type": ?"Point".*})</textarea>(.|\n)+'
group = re.match(pattern, html).group(2)
p = json.loads(group)
# Reversed order again, so that it's parsed correctly by leaflet
eq_(p['coordinates'][0], 10.0)
eq_(p['coordinates'][1], 125.8)
......@@ -3,7 +3,7 @@ from nose.tools import eq_, ok_
from . import setup_postgres
from .test_basic import CustomModelView
from sqlalchemy.dialects.postgresql import HSTORE
from sqlalchemy.dialects.postgresql import HSTORE, JSON
def test_hstore():
......@@ -40,3 +40,38 @@ def test_hstore():
data = rv.data.decode('utf-8')
ok_('test_val1' in data)
ok_('test_val2' in data)
def test_json():
app, db, admin = setup_postgres()
class JSONModel(db.Model):
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
json_test = db.Column(JSON)
db.create_all()
view = CustomModelView(JSONModel, db.session)
admin.add_view(view)
client = app.test_client()
rv = client.get('/admin/jsonmodel/')
eq_(rv.status_code, 200)
rv = client.post('/admin/jsonmodel/new/', data={
'json_test': '{"test_key1": "test_value1"}',
})
eq_(rv.status_code, 302)
rv = client.get('/admin/jsonmodel/')
eq_(rv.status_code, 200)
data = rv.data.decode('utf-8')
ok_('json_test' in data)
ok_('{&#34;test_key1&#34;: &#34;test_value1&#34;}' in data)
rv = client.get('/admin/jsonmodel/edit/?id=1')
eq_(rv.status_code, 200)
data = rv.data.decode('utf-8')
ok_('json_test' in data)
ok_('>{"test_key1": "test_value1"}<' in data)
import os
from nose.tools import ok_, eq_, raises
from flask import Flask, request, abort, url_for
......@@ -176,7 +178,7 @@ def test_baseview_registration():
# Verify generated blueprint properties
eq_(bp.name, view.endpoint)
eq_(bp.url_prefix, view.url)
eq_(bp.template_folder, 'templates/bootstrap2')
eq_(bp.template_folder, os.path.join('templates', 'bootstrap2'))
eq_(bp.static_folder, view.static_folder)
# Verify customizations
......
......@@ -726,3 +726,70 @@ def test_export_csv():
rv = client.get('/admin/macro_exception_macro_override/export/csv/')
data = rv.data.decode('utf-8')
eq_(rv.status_code, 500)
def test_list_row_actions():
app, admin = setup()
client = app.test_client()
from flask_admin.model import template
# Test default actions
view = MockModelView(Model, endpoint='test')
admin.add_view(view)
actions = view.get_list_row_actions()
ok_(isinstance(actions[0], template.EditRowAction))
ok_(isinstance(actions[1], template.DeleteRowAction))
rv = client.get('/admin/test/')
eq_(rv.status_code, 200)
# Test default actions
view = MockModelView(Model, endpoint='test1', can_edit=False, can_delete=False, can_view_details=True)
admin.add_view(view)
actions = view.get_list_row_actions()
eq_(len(actions), 1)
ok_(isinstance(actions[0], template.ViewRowAction))
rv = client.get('/admin/test1/')
eq_(rv.status_code, 200)
# Test popups
view = MockModelView(Model, endpoint='test2',
can_view_details=True,
details_modal=True,
edit_modal=True)
admin.add_view(view)
actions = view.get_list_row_actions()
ok_(isinstance(actions[0], template.ViewPopupRowAction))
ok_(isinstance(actions[1], template.EditPopupRowAction))
ok_(isinstance(actions[2], template.DeleteRowAction))
rv = client.get('/admin/test2/')
eq_(rv.status_code, 200)
# Test custom views
view = MockModelView(Model, endpoint='test3',
column_extra_row_actions=[
template.LinkRowAction('glyphicon glyphicon-off', 'http://localhost/?id={row_id}'),
template.EndpointLinkRowAction('glyphicon glyphicon-test', 'test1.index_view')
])
admin.add_view(view)
actions = view.get_list_row_actions()
ok_(isinstance(actions[0], template.EditRowAction))
ok_(isinstance(actions[1], template.DeleteRowAction))
ok_(isinstance(actions[2], template.LinkRowAction))
ok_(isinstance(actions[3], template.EndpointLinkRowAction))
rv = client.get('/admin/test3/')
eq_(rv.status_code, 200)
data = rv.data.decode('utf-8')
ok_('glyphicon-off' in data)
ok_('http://localhost/?id=' in data)
ok_('glyphicon-test' in data)
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