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: ...@@ -11,10 +11,13 @@ env:
- WTFORMS_VERSION=1 - WTFORMS_VERSION=1
- WTFORMS_VERSION=2 - WTFORMS_VERSION=2
addons:
postgresql: "9.4"
services: services:
- postgresql - postgresql
- mongodb - mongodb
before_script: before_script:
- psql -U postgres -c 'CREATE DATABASE flask_admin_test;' - psql -U postgres -c 'CREATE DATABASE flask_admin_test;'
- psql -U postgres -c 'CREATE EXTENSION postgis;' 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): ...@@ -221,7 +221,7 @@ class BaseFileAdmin(BaseView, ActionsMixin):
Base form class. Will be used to create the upload, rename, edit, and delete form. 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 Allows enabling CSRF validation and useful if you want to have custom
contructor or override some fields. constructor or override some fields.
Example:: Example::
......
import json
import warnings import warnings
import geoalchemy2 import geoalchemy2
from flask import current_app from flask import current_app
from shapely.geometry import shape from shapely.geometry import shape
from sqlalchemy import func from sqlalchemy import func
from wtforms.fields import TextAreaField
from .widgets import LeafletWidget from flask_admin.form import JSONField
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): from .widgets import LeafletWidget
return json.dumps(obj)
def from_json(self, data):
return json.loads(data)
class GeoJSONField(JSONField): class GeoJSONField(JSONField):
widget = LeafletWidget() 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) super(GeoJSONField, self).__init__(label, validators, **kwargs)
self.web_srid = 4326 self.web_srid = 4326
self.srid = srid self.srid = srid
......
...@@ -76,7 +76,7 @@ def create_ajax_loader(model, name, field_name, opts): ...@@ -76,7 +76,7 @@ def create_ajax_loader(model, name, field_name, opts):
ftype = type(prop).__name__ ftype = type(prop).__name__
if ftype == 'ListField': if ftype == 'ListField' or ftype == 'SortedListField':
prop = prop.field prop = prop.field
ftype = type(prop).__name__ ftype = type(prop).__name__
...@@ -97,7 +97,7 @@ def process_ajax_references(references, view): ...@@ -97,7 +97,7 @@ def process_ajax_references(references, view):
def handle_field(field, subdoc, base): def handle_field(field, subdoc, base):
ftype = type(field).__name__ ftype = type(field).__name__
if ftype == 'ListField': if ftype == 'ListField' or ftype == 'SortedListField':
child_doc = getattr(subdoc, '_form_subdocuments', {}).get(None) child_doc = getattr(subdoc, '_form_subdocuments', {}).get(None)
if child_doc: if child_doc:
......
import datetime
from flask_admin.babel import lazy_gettext from flask_admin.babel import lazy_gettext
from flask_admin.model import filters from flask_admin.model import filters
from .tools import parse_like_term from .tools import parse_like_term
from mongoengine.queryset import Q from mongoengine.queryset import Q
from bson.errors import InvalidId
from bson.objectid import ObjectId
class BaseMongoEngineFilter(filters.BaseFilter): class BaseMongoEngineFilter(filters.BaseFilter):
""" """
...@@ -221,6 +222,31 @@ class DateTimeNotBetweenFilter(DateTimeBetweenFilter): ...@@ -221,6 +222,31 @@ class DateTimeNotBetweenFilter(DateTimeBetweenFilter):
return lazy_gettext('not between') 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 # Base MongoEngine filter field converter
class FilterConverter(filters.BaseFilterConverter): class FilterConverter(filters.BaseFilterConverter):
strings = (FilterLike, FilterNotLike, FilterEqual, FilterNotEqual, strings = (FilterLike, FilterNotLike, FilterEqual, FilterNotEqual,
...@@ -236,6 +262,7 @@ class FilterConverter(filters.BaseFilterConverter): ...@@ -236,6 +262,7 @@ class FilterConverter(filters.BaseFilterConverter):
DateTimeGreaterFilter, DateTimeSmallerFilter, DateTimeGreaterFilter, DateTimeSmallerFilter,
DateTimeBetweenFilter, DateTimeNotBetweenFilter, DateTimeBetweenFilter, DateTimeNotBetweenFilter,
FilterEmpty) FilterEmpty)
reference_filters = (ReferenceObjectIdFilter,)
def convert(self, type_name, column, name): def convert(self, type_name, column, name):
filter_name = type_name.lower() filter_name = type_name.lower()
...@@ -264,3 +291,7 @@ class FilterConverter(filters.BaseFilterConverter): ...@@ -264,3 +291,7 @@ class FilterConverter(filters.BaseFilterConverter):
@filters.convert('DateTimeField', 'ComplexDateTimeField') @filters.convert('DateTimeField', 'ComplexDateTimeField')
def conv_datetime(self, column, name): def conv_datetime(self, column, name):
return [f(column, name) for f in self.datetime_filters] 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 mongoengine import ValidationError
from wtforms.validators import ValidationError as wtfValidationError
from flask_admin._compat import itervalues, as_unicode from flask_admin._compat import itervalues, as_unicode
...@@ -31,6 +32,9 @@ def make_thumb_args(value): ...@@ -31,6 +32,9 @@ def make_thumb_args(value):
def format_error(error): def format_error(error):
if isinstance(error, ValidationError): if isinstance(error, ValidationError):
return as_unicode(error)
if isinstance(error, wtfValidationError):
return '. '.join(itervalues(error.to_dict())) return '. '.join(itervalues(error.to_dict()))
return as_unicode(error) return as_unicode(error)
...@@ -350,6 +350,10 @@ class AdminModelConverter(ModelConverterBase): ...@@ -350,6 +350,10 @@ class AdminModelConverter(ModelConverterBase):
inner_form = field_args.pop('form', HstoreForm) inner_form = field_args.pop('form', HstoreForm)
return InlineHstoreList(InlineFormField(inner_form), **field_args) 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): def _resolve_prop(prop):
""" """
......
...@@ -494,43 +494,42 @@ class ModelView(BaseModelView): ...@@ -494,43 +494,42 @@ class ModelView(BaseModelView):
return result 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 Returns a list of tuples with the model field name and formatted
field name. If `column_list` was set, returns it. Otherwise calls field name.
`scaffold_list_columns` to generate the list from the model.
"""
if self.column_list is None:
columns = self.scaffold_list_columns()
# Filter excluded columns Overridden to handle special columns like InstrumentedAttribute.
if self.column_exclude_list:
columns = [c for c in columns
if c not in self.column_exclude_list]
return [(c, self.get_column_name(c)) for c in columns] :param only_columns:
else: List of columns to include in the results. If not set,
columns = [] `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: formatted_columns = []
column, path = tools.get_field_with_path(self.model, c) for c in only_columns:
column, path = tools.get_field_with_path(self.model, c)
if path: if path:
# column is in another table, use full path # column is a relation (InstrumentedAttribute), use full path
column_name = text_type(c) 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: else:
# column is in same table, use only model attribute name column_name = text_type(c)
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) visible_name = self.get_column_name(column_name)
# column_name must match column_name in `get_sortable_columns` # column_name must match column_name in `get_sortable_columns`
columns.append((column_name, visible_name)) formatted_columns.append((column_name, visible_name))
return columns return formatted_columns
def init_search(self): def init_search(self):
""" """
...@@ -564,11 +563,8 @@ class ModelView(BaseModelView): ...@@ -564,11 +563,8 @@ class ModelView(BaseModelView):
if attr is None: if attr is None:
raise Exception('Failed to find field for filter: %s' % name) raise Exception('Failed to find field for filter: %s' % name)
# Figure out filters for related column, unless it's a hybrid_property # Figure out filters for related column
if isinstance(attr, ColumnElement): if hasattr(attr, 'property') and hasattr(attr.property, 'direction'):
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'):
filters = [] filters = []
for p in self._get_model_iterator(attr.property.mapper.class_): for p in self._get_model_iterator(attr.property.mapper.class_):
...@@ -599,14 +595,19 @@ class ModelView(BaseModelView): ...@@ -599,14 +595,19 @@ class ModelView(BaseModelView):
return filters return filters
else: 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: if len(columns) > 1:
raise Exception('Can not filter more than on one column for %s' % name) 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): name not in self.column_labels):
visible_name = '%s / %s' % ( visible_name = '%s / %s' % (
self.get_column_name(column.table.name), self.get_column_name(column.table.name),
...@@ -629,7 +630,7 @@ class ModelView(BaseModelView): ...@@ -629,7 +630,7 @@ class ModelView(BaseModelView):
if joins: if joins:
self._filter_joins[column] = 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] self._filter_joins[column] = [column.table]
return flt return flt
......
import time import time
import datetime import datetime
import json
from wtforms import fields, widgets from wtforms import fields
from flask_admin.babel import gettext from flask_admin.babel import gettext
from flask_admin._compat import text_type, as_unicode from flask_admin._compat import text_type, as_unicode
...@@ -11,7 +12,8 @@ from . import widgets as admin_widgets ...@@ -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 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): class DateTimeField(fields.DateTimeField):
...@@ -176,3 +178,28 @@ class Select2TagsField(fields.StringField): ...@@ -176,3 +178,28 @@ class Select2TagsField(fields.StringField):
return as_unicode(self.data) return as_unicode(self.data)
else: else:
return u'' 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 ...@@ -3,6 +3,7 @@ import re
import csv import csv
import mimetypes import mimetypes
import time import time
from math import ceil
from werkzeug import secure_filename from werkzeug import secure_filename
...@@ -21,7 +22,7 @@ from flask_admin.babel import gettext ...@@ -21,7 +22,7 @@ from flask_admin.babel import gettext
from flask_admin.base import BaseView, expose from flask_admin.base import BaseView, expose
from flask_admin.form import BaseForm, FormOpts, rules 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.actions import ActionsMixin
from flask_admin.helpers import (get_form_data, validate_form_on_submit, from flask_admin.helpers import (get_form_data, validate_form_on_submit,
get_redirect_target, flash_errors) get_redirect_target, flash_errors)
...@@ -459,6 +460,24 @@ class BaseModelView(BaseView, ActionsMixin): ...@@ -459,6 +460,24 @@ class BaseModelView(BaseView, ActionsMixin):
actions endpoints are accessible. 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 simple_list_pager = False
""" """
Enable or disable simple list pager. Enable or disable simple list pager.
...@@ -483,7 +502,7 @@ class BaseModelView(BaseView, ActionsMixin): ...@@ -483,7 +502,7 @@ class BaseModelView(BaseView, ActionsMixin):
""" """
Base form class. Will be used by form scaffolding function when creating model form. 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:: Example::
...@@ -564,6 +583,9 @@ class BaseModelView(BaseView, ActionsMixin): ...@@ -564,6 +583,9 @@ class BaseModelView(BaseView, ActionsMixin):
'description': { 'description': {
'rows': 10, 'rows': 10,
'style': 'color: black' 'style': 'color: black'
},
'other_field': {
'disabled': True
} }
} }
...@@ -826,12 +848,10 @@ class BaseModelView(BaseView, ActionsMixin): ...@@ -826,12 +848,10 @@ class BaseModelView(BaseView, ActionsMixin):
self._sortable_columns = self.get_sortable_columns() self._sortable_columns = self.get_sortable_columns()
# Details view # Details view
if self.can_view_details: self._details_columns = self.get_details_columns()
self._details_columns = self.get_details_columns()
# Export view # Export view
if self.can_export: self._export_columns = self.get_export_columns()
self._export_columns = self.get_export_columns()
# Labels # Labels
if self.column_labels is None: if self.column_labels is None:
...@@ -908,62 +928,89 @@ class BaseModelView(BaseView, ActionsMixin): ...@@ -908,62 +928,89 @@ class BaseModelView(BaseView, ActionsMixin):
else: else:
return self._prettify_name(field) 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 Return list of row action objects, each is instance of :class:`~flask_admin.model.template.BaseListRowAction`
field name. If `column_list` was set, returns it. Otherwise calls
`scaffold_list_columns` to generate the list from the model.
""" """
columns = self.column_list actions = []
if columns is None: if self.can_view_details:
columns = self.scaffold_list_columns() 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.can_delete:
if self.column_exclude_list: actions.append(template.DeleteRowAction())
columns = [c for c in columns if c not in self.column_exclude_list]
return [(c, self.get_column_name(c)) for c in columns] return actions + (self.column_extra_row_actions or [])
def get_details_columns(self): def get_column_names(self, only_columns, excluded_columns):
"""
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.
""" """
columns = self.column_details_list Returns a list of tuples with the model field name and formatted
field name.
if columns is None:
columns = self.scaffold_list_columns()
# Filter excluded columns :param only_columns:
if self.column_details_exclude_list: List of columns to include in the results. If not set,
columns = [c for c in columns `scaffold_list_columns` will generate the list from the model.
if c not in self.column_details_exclude_list] :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 Uses `get_column_names` to get a list of tuples with the model
`column_export_list` was set, returns it. Otherwise, if field name and formatted name for the columns in `column_list`
`column_list` was set, returns it. Otherwise calls and not in `column_exclude_list`. If `column_list` is not set,
`scaffold_list_columns` to generate the list from the model. 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: def get_details_columns(self):
columns = self.column_list """
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: return self.get_column_names(
columns = self.scaffold_list_columns() only_columns=only_columns,
excluded_columns=self.column_details_exclude_list,
)
# Filter excluded columns def get_export_columns(self):
if self.column_export_exclude_list: """
columns = [c for c in columns Uses `get_column_names` to get a list of tuples with the model
if c not in self.column_export_exclude_list] 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): def scaffold_sortable_columns(self):
""" """
...@@ -1773,12 +1820,12 @@ class BaseModelView(BaseView, ActionsMixin): ...@@ -1773,12 +1820,12 @@ class BaseModelView(BaseView, ActionsMixin):
list_forms[self.get_pk_value(row)] = self.list_form(obj=row) list_forms[self.get_pk_value(row)] = self.list_form(obj=row)
# Calculate number of pages # Calculate number of pages
if count is not None: if count is not None and self.page_size:
num_pages = count // self.page_size num_pages = int(ceil(count / float(self.page_size)))
if count % self.page_size != 0: elif not self.page_size:
num_pages += 1 num_pages = 0 # hide pager for unlimited page_size
else: else:
num_pages = None num_pages = None # use simple pager
# Various URL generation helpers # Various URL generation helpers
def pager_url(p): def pager_url(p):
...@@ -1815,6 +1862,7 @@ class BaseModelView(BaseView, ActionsMixin): ...@@ -1815,6 +1862,7 @@ class BaseModelView(BaseView, ActionsMixin):
list_columns=self._list_columns, list_columns=self._list_columns,
sortable_columns=self._sortable_columns, sortable_columns=self._sortable_columns,
editable_columns=self.column_editable_list, editable_columns=self.column_editable_list,
list_row_actions=self.get_list_row_actions(),
# Pagination # Pagination
count=count, 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): def macro(name):
''' '''
Jinja2 macro list column formatter. Jinja2 macro list column formatter.
...@@ -14,3 +126,4 @@ def macro(name): ...@@ -14,3 +126,4 @@ def macro(name):
return m(model=model, column=column) return m(model=model, column=column)
return inner return inner
import json
from jinja2 import Markup from jinja2 import Markup
from flask_admin._compat import text_type from flask_admin._compat import text_type
try:
from enum import Enum
except ImportError:
Enum = None
def null_formatter(view, value): def null_formatter(view, value):
...@@ -44,13 +50,40 @@ def list_formatter(view, values): ...@@ -44,13 +50,40 @@ def list_formatter(view, values):
return u', '.join(text_type(v) for v in 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 = { BASE_FORMATTERS = {
type(None): empty_formatter, type(None): empty_formatter,
bool: bool_formatter, bool: bool_formatter,
list: list_formatter, list: list_formatter,
dict: dict_formatter,
} }
EXPORT_FORMATTERS = { EXPORT_FORMATTERS = {
type(None): empty_formatter, type(None): empty_formatter,
list: list_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 @@ ...@@ -3,6 +3,7 @@
{% import 'admin/static.html' as admin_static with context%} {% import 'admin/static.html' as admin_static with context%}
{% import 'admin/model/layout.html' as model_layout with context %} {% import 'admin/model/layout.html' as model_layout with context %}
{% import 'admin/actions.html' as actionlib with context %} {% import 'admin/actions.html' as actionlib with context %}
{% import 'admin/model/row_actions.html' as row_actions with context %}
{% block head %} {% block head %}
{{ super() }} {{ super() }}
...@@ -116,40 +117,11 @@ ...@@ -116,40 +117,11 @@
{% block list_row_actions_column scoped %} {% block list_row_actions_column scoped %}
{% if admin_view.column_display_actions %} {% if admin_view.column_display_actions %}
<td class="list-buttons-column"> <td class="list-buttons-column">
{% block list_row_actions scoped %} {% block list_row_actions scoped %}
{%- if admin_view.can_view_details -%} {% for action in list_row_actions %}
{%- if admin_view.details_modal -%} {{ action.render_ctx(get_pk_value(row), row) }}
{{ 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>') }} {% endfor %}
{% else %} {% endblock %}
<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 %}
</td> </td>
{%- endif -%} {%- endif -%}
{% endblock %} {% 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 @@ ...@@ -3,6 +3,7 @@
{% import 'admin/static.html' as admin_static with context%} {% import 'admin/static.html' as admin_static with context%}
{% import 'admin/model/layout.html' as model_layout with context %} {% import 'admin/model/layout.html' as model_layout with context %}
{% import 'admin/actions.html' as actionlib with context %} {% import 'admin/actions.html' as actionlib with context %}
{% import 'admin/model/row_actions.html' as row_actions with context %}
{% block head %} {% block head %}
{{ super() }} {{ super() }}
...@@ -116,38 +117,9 @@ ...@@ -116,38 +117,9 @@
{% if admin_view.column_display_actions %} {% if admin_view.column_display_actions %}
<td class="list-buttons-column"> <td class="list-buttons-column">
{% block list_row_actions scoped %} {% block list_row_actions scoped %}
{%- if admin_view.can_view_details -%} {% for action in list_row_actions %}
{%- if admin_view.details_modal -%} {{ action.render_ctx(get_pk_value(row), row) }}
{{ 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>') }} {% endfor %}
{% 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 -%}
{% endblock %} {% endblock %}
</td> </td>
{%- endif -%} {%- 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(): ...@@ -908,6 +908,63 @@ def test_nested_list_subdocument():
ok_('value' not in dir(inline_form)) 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(): def test_list_subdocument_validation():
app, db, admin = setup() app, db, admin = setup()
......
...@@ -2027,6 +2027,28 @@ def test_simple_list_pager(): ...@@ -2027,6 +2027,28 @@ def test_simple_list_pager():
assert_true(count is None) 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(): def test_advanced_joins():
app, db, admin = setup() app, db, admin = setup()
......
...@@ -3,7 +3,7 @@ from nose.tools import eq_, ok_ ...@@ -3,7 +3,7 @@ from nose.tools import eq_, ok_
from . import setup_postgres from . import setup_postgres
from .test_basic import CustomModelView from .test_basic import CustomModelView
from sqlalchemy.dialects.postgresql import HSTORE from sqlalchemy.dialects.postgresql import HSTORE, JSON
def test_hstore(): def test_hstore():
...@@ -40,3 +40,38 @@ def test_hstore(): ...@@ -40,3 +40,38 @@ def test_hstore():
data = rv.data.decode('utf-8') data = rv.data.decode('utf-8')
ok_('test_val1' in data) ok_('test_val1' in data)
ok_('test_val2' 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(): ...@@ -726,3 +726,70 @@ def test_export_csv():
rv = client.get('/admin/macro_exception_macro_override/export/csv/') rv = client.get('/admin/macro_exception_macro_override/export/csv/')
data = rv.data.decode('utf-8') data = rv.data.decode('utf-8')
eq_(rv.status_code, 500) 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