Commit 88281077 authored by makudesu's avatar makudesu

Merge branch 'master' of github.com:flask-admin/flask-admin

parents bbc00d27 8563ecdd
......@@ -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.
SQLA backend example showing how to filter select dropdown options in forms.
To run this example:
1. Clone the repository::
git clone https://github.com/flask-admin/flask-admin.git
cd flask-admin
2. Create and activate a virtual environment::
virtualenv env
source env/bin/activate
3. Install requirements::
pip install -r 'examples/sqla-filter-selectable/requirements.txt'
4. Run the application::
python examples/sqla-filter-selectable/app.py
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
import flask_admin as admin
from flask_admin.contrib import sqla
# Create application
app = Flask(__name__)
# Create dummy secrey key so we can use sessions
app.config['SECRET_KEY'] = '123456790'
# Create in-memory database
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///sample_db_3.sqlite'
app.config['SQLALCHEMY_ECHO'] = True
db = SQLAlchemy(app)
# Flask views
@app.route('/')
def index():
return '<a href="/admin/">Click me to get to Admin!</a>'
class Person(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(50))
pets = db.relationship('Pet', backref='person')
def __unicode__(self):
return self.name
class Pet(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(50))
person_id = db.Column(db.Integer, db.ForeignKey('person.id'))
available = db.Column(db.Boolean)
def __unicode__(self):
return self.name
class PersonAdmin(sqla.ModelView):
""" Override ModelView to filter options available in forms. """
def create_form(self):
return self._use_filtered_parent(
super(PersonAdmin, self).create_form()
)
def edit_form(self, obj):
return self._use_filtered_parent(
super(PersonAdmin, self).edit_form(obj)
)
def _use_filtered_parent(self, form):
form.pets.query_factory = self._get_parent_list
return form
def _get_parent_list(self):
# only show available pets in the form
return Pet.query.filter_by(available=True).all()
def __unicode__(self):
return self.name
# Create admin
admin = admin.Admin(app, name='Example: SQLAlchemy - Filtered Form Selectable',
template_mode='bootstrap3')
admin.add_view(PersonAdmin(Person, db.session))
admin.add_view(sqla.ModelView(Pet, db.session))
if __name__ == '__main__':
# Recreate DB
db.drop_all()
db.create_all()
person = Person(name='Bill')
pet1 = Pet(name='Dog', available=True)
pet2 = Pet(name='Fish', available=True)
pet3 = Pet(name='Ocelot', available=False)
db.session.add_all([person, pet1, pet2, pet3])
db.session.commit()
# Start app
app.run(debug=True)
Flask
Flask-Admin
Flask-SQLAlchemy
......@@ -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
import warnings
import geoalchemy2
from flask import current_app
from shapely.geometry import shape
from sqlalchemy import func
from wtforms.fields import TextAreaField
from .widgets import LeafletWidget
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'))
from flask_admin.form import JSONField
def to_json(self, obj):
return json.dumps(obj)
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
......
......@@ -76,7 +76,7 @@ def create_ajax_loader(model, name, field_name, opts):
ftype = type(prop).__name__
if ftype == 'ListField':
if ftype == 'ListField' or ftype == 'SortedListField':
prop = prop.field
ftype = type(prop).__name__
......@@ -97,7 +97,7 @@ def process_ajax_references(references, view):
def handle_field(field, subdoc, base):
ftype = type(field).__name__
if ftype == 'ListField':
if ftype == 'ListField' or ftype == 'SortedListField':
child_doc = getattr(subdoc, '_form_subdocuments', {}).get(None)
if child_doc:
......
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]
from mongoengine import ValidationError
from wtforms.validators import ValidationError as wtfValidationError
from flask_admin._compat import itervalues, as_unicode
......@@ -31,6 +32,9 @@ def make_thumb_args(value):
def format_error(error):
if isinstance(error, ValidationError):
return as_unicode(error)
if isinstance(error, wtfValidationError):
return '. '.join(itervalues(error.to_dict()))
return as_unicode(error)
......@@ -350,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):
"""
......
......@@ -494,43 +494,42 @@ class ModelView(BaseModelView):
return result
def get_list_columns(self):
def get_column_names(self, only_columns, excluded_columns):
"""
Returns a list of tuples with the model field name and formatted
field name. If `column_list` was set, returns it. Otherwise calls
`scaffold_list_columns` to generate the list from the model.
"""
if self.column_list is None:
columns = self.scaffold_list_columns()
field name.
# Filter excluded columns
if self.column_exclude_list:
columns = [c for c in columns
if c not in self.column_exclude_list]
Overridden to handle special columns like InstrumentedAttribute.
return [(c, self.get_column_name(c)) for c in columns]
else:
columns = []
:param only_columns:
List of columns to include in the results. If not set,
`scaffold_list_columns` will generate the list from the model.
:param excluded_columns:
List of columns to exclude from the results.
"""
if excluded_columns:
only_columns = [c for c in only_columns if c not in excluded_columns]
for c in self.column_list:
column, path = tools.get_field_with_path(self.model, c)
formatted_columns = []
for c in only_columns:
column, path = tools.get_field_with_path(self.model, c)
if path:
# column is in another table, use full path
column_name = text_type(c)
if path:
# column is a relation (InstrumentedAttribute), use full path
column_name = text_type(c)
else:
# column is in same table, use only model attribute name
if getattr(column, 'key', None) is not None:
column_name = column.key
else:
# column is in same table, use only model attribute name
if getattr(column, 'key', None) is not None:
column_name = column.key
else:
column_name = text_type(c)
column_name = text_type(c)
visible_name = self.get_column_name(column_name)
visible_name = self.get_column_name(column_name)
# column_name must match column_name in `get_sortable_columns`
columns.append((column_name, visible_name))
# column_name must match column_name in `get_sortable_columns`
formatted_columns.append((column_name, visible_name))
return columns
return formatted_columns
def init_search(self):
"""
......@@ -564,11 +563,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_):
......@@ -599,14 +595,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),
......@@ -629,7 +630,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'))
......@@ -3,6 +3,7 @@ import re
import csv
import mimetypes
import time
from math import ceil
from werkzeug import secure_filename
......@@ -21,7 +22,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 +460,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 +502,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::
......@@ -564,6 +583,9 @@ class BaseModelView(BaseView, ActionsMixin):
'description': {
'rows': 10,
'style': 'color: black'
},
'other_field': {
'disabled': True
}
}
......@@ -826,12 +848,10 @@ class BaseModelView(BaseView, ActionsMixin):
self._sortable_columns = self.get_sortable_columns()
# Details view
if self.can_view_details:
self._details_columns = self.get_details_columns()
self._details_columns = self.get_details_columns()
# Export view
if self.can_export:
self._export_columns = self.get_export_columns()
self._export_columns = self.get_export_columns()
# Labels
if self.column_labels is None:
......@@ -908,62 +928,89 @@ class BaseModelView(BaseView, ActionsMixin):
else:
return self._prettify_name(field)
def get_list_columns(self):
def get_list_row_actions(self):
"""
Returns a list of tuples with the model field name and formatted
field name. If `column_list` was set, returns it. Otherwise calls
`scaffold_list_columns` to generate the list from the model.
Return list of row action objects, each is instance of :class:`~flask_admin.model.template.BaseListRowAction`
"""
columns = self.column_list
actions = []
if columns is None:
columns = self.scaffold_list_columns()
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())
# Filter excluded columns
if self.column_exclude_list:
columns = [c for c in columns if c not in self.column_exclude_list]
if self.can_delete:
actions.append(template.DeleteRowAction())
return [(c, self.get_column_name(c)) for c in columns]
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
`column_details_list` was set, returns it. Otherwise calls
`scaffold_list_columns` to generate the list from the model.
def get_column_names(self, only_columns, excluded_columns):
"""
columns = self.column_details_list
if columns is None:
columns = self.scaffold_list_columns()
Returns a list of tuples with the model field name and formatted
field name.
# Filter excluded columns
if self.column_details_exclude_list:
columns = [c for c in columns
if c not in self.column_details_exclude_list]
:param only_columns:
List of columns to include in the results. If not set,
`scaffold_list_columns` will generate the list from the model.
:param excluded_columns:
List of columns to exclude from the results if `only_columns`
is not set.
"""
if excluded_columns:
only_columns = [c for c in only_columns if c not in excluded_columns]
return [(c, self.get_column_name(c)) for c in columns]
return [(c, self.get_column_name(c)) for c in only_columns]
def get_export_columns(self):
def get_list_columns(self):
"""
Returns a list of the model field names in the export view. If
`column_export_list` was set, returns it. Otherwise, if
`column_list` was set, returns it. Otherwise calls
`scaffold_list_columns` to generate the list from the model.
Uses `get_column_names` to get a list of tuples with the model
field name and formatted name for the columns in `column_list`
and not in `column_exclude_list`. If `column_list` is not set,
the columns from `scaffold_list_columns` will be used.
"""
columns = self.column_export_list
return self.get_column_names(
only_columns=self.column_list or self.scaffold_list_columns(),
excluded_columns=self.column_exclude_list,
)
if columns is None:
columns = self.column_list
def get_details_columns(self):
"""
Uses `get_column_names` to get a list of tuples with the model
field name and formatted name for the columns in `column_details_list`
and not in `column_details_exclude_list`. If `column_details_list`
is not set, it will attempt to use the columns from `column_list`
or finally the columns from `scaffold_list_columns` will be used.
"""
only_columns = (self.column_details_list or self.column_list or
self.scaffold_list_columns())
if columns is None:
columns = self.scaffold_list_columns()
return self.get_column_names(
only_columns=only_columns,
excluded_columns=self.column_details_exclude_list,
)
# Filter excluded columns
if self.column_export_exclude_list:
columns = [c for c in columns
if c not in self.column_export_exclude_list]
def get_export_columns(self):
"""
Uses `get_column_names` to get a list of tuples with the model
field name and formatted name for the columns in `column_export_list`
and not in `column_export_exclude_list`. If `column_export_list` is
not set, it will attempt to use the columns from `column_list`
or finally the columns from `scaffold_list_columns` will be used.
"""
only_columns = (self.column_export_list or self.column_list or
self.scaffold_list_columns())
return [(c, self.get_column_name(c)) for c in columns]
return self.get_column_names(
only_columns=only_columns,
excluded_columns=self.column_export_exclude_list,
)
def scaffold_sortable_columns(self):
"""
......@@ -1773,12 +1820,12 @@ class BaseModelView(BaseView, ActionsMixin):
list_forms[self.get_pk_value(row)] = self.list_form(obj=row)
# Calculate number of pages
if count is not None:
num_pages = count // self.page_size
if count % self.page_size != 0:
num_pages += 1
if count is not None and self.page_size:
num_pages = int(ceil(count / float(self.page_size)))
elif not self.page_size:
num_pages = 0 # hide pager for unlimited page_size
else:
num_pages = None
num_pages = None # use simple pager
# Various URL generation helpers
def pager_url(p):
......@@ -1815,6 +1862,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,
......
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 %}
......@@ -908,6 +908,63 @@ def test_nested_list_subdocument():
ok_('value' not in dir(inline_form))
def test_nested_sortedlist_subdocument():
app, db, admin = setup()
class Comment(db.EmbeddedDocument):
name = db.StringField(max_length=20, required=True)
value = db.StringField(max_length=20)
class Model1(db.Document):
test1 = db.StringField(max_length=20)
subdoc = db.SortedListField(db.EmbeddedDocumentField(Comment))
# Check only
view1 = CustomModelView(
Model1,
form_subdocuments = {
'subdoc': {
'form_subdocuments': {
None: {
'form_columns': ('name',)
}
}
}
}
)
form = view1.create_form()
inline_form = form.subdoc.unbound_field.args[2]
ok_('name' in dir(inline_form))
ok_('value' not in dir(inline_form))
def test_sortedlist_subdocument_validation():
app, db, admin = setup()
class Comment(db.EmbeddedDocument):
name = db.StringField(max_length=20, required=True)
value = db.StringField(max_length=20)
class Model1(db.Document):
test1 = db.StringField(max_length=20)
subdoc = db.SortedListField(db.EmbeddedDocumentField(Comment))
view = CustomModelView(Model1)
admin.add_view(view)
client = app.test_client()
rv = client.post('/admin/model1/new/',
data={'test1': 'test1large', 'subdoc-0-name': 'comment', 'subdoc-0-value': 'test'})
eq_(rv.status_code, 302)
rv = client.post('/admin/model1/new/',
data={'test1': 'test1large', 'subdoc-0-name': '', 'subdoc-0-value': 'test'})
eq_(rv.status_code, 200)
ok_('This field is required' in rv.data)
def test_list_subdocument_validation():
app, db, admin = setup()
......
......@@ -2027,6 +2027,28 @@ def test_simple_list_pager():
assert_true(count is None)
def test_unlimited_page_size():
app, db, admin = setup()
M1, _ = create_models(db)
db.session.add_all([M1('1'), M1('2'), M1('3'), M1('4'), M1('5'), M1('6'),
M1('7'), M1('8'), M1('9'), M1('10'), M1('11'),
M1('12'), M1('13'), M1('14'), M1('15'), M1('16'),
M1('17'), M1('18'), M1('19'), M1('20'), M1('21')])
view = CustomModelView(M1, db.session)
# test 0 as page_size
_, data = view.get_list(0, None, None, None, None, execute=True,
page_size=0)
eq_(len(data), 21)
# test False as page_size
_, data = view.get_list(0, None, None, None, None, execute=True,
page_size=False)
eq_(len(data), 21)
def test_advanced_joins():
app, db, admin = setup()
......
......@@ -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)
......@@ -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