Commit 597ca07d authored by Trevor Andreas's avatar Trevor Andreas Committed by Trevor Andreas

Implement CSV export for BaseModelView.

parent 9db8c9e5
...@@ -298,6 +298,12 @@ To **manage related models inline**:: ...@@ -298,6 +298,12 @@ To **manage related models inline**::
These inline forms can be customised. Have a look at the API documentation for These inline forms can be customised. Have a look at the API documentation for
:meth:`~flask_admin.contrib.sqla.ModelView.inline_models`. :meth:`~flask_admin.contrib.sqla.ModelView.inline_models`.
To **enable csv export** of the model view::
can_export = True
This will add a button to the model view that exports records, truncating at :attr:`~flask_admin.model.BaseModelView.max_export_rows`.
Adding Your Own Views Adding Your Own Views
===================== =====================
......
import warnings import warnings
import re import re
import csv
import time
from werkzeug import secure_filename
from flask import (request, redirect, flash, abort, json, Response, from flask import (request, redirect, flash, abort, json, Response,
get_flashed_messages) get_flashed_messages, stream_with_context)
from jinja2 import contextfunction from jinja2 import contextfunction
from wtforms.fields import HiddenField from wtforms.fields import HiddenField
from wtforms.fields.core import UnboundField from wtforms.fields.core import UnboundField
...@@ -23,7 +27,6 @@ from .helpers import prettify_name, get_mdict_item_or_list ...@@ -23,7 +27,6 @@ from .helpers import prettify_name, get_mdict_item_or_list
from .ajax import AjaxModelLoader from .ajax import AjaxModelLoader
from .fields import ListEditableFieldList from .fields import ListEditableFieldList
# Used to generate filter query string name # Used to generate filter query string name
filter_char_re = re.compile('[^a-z0-9 ]') filter_char_re = re.compile('[^a-z0-9 ]')
filter_compact_re = re.compile(' +') filter_compact_re = re.compile(' +')
...@@ -95,6 +98,9 @@ class BaseModelView(BaseView, ActionsMixin): ...@@ -95,6 +98,9 @@ class BaseModelView(BaseView, ActionsMixin):
when there are too many columns to display in the list_view. when there are too many columns to display in the list_view.
""" """
can_export = False
"""Is model list export allowed"""
# Templates # Templates
list_template = 'admin/model/list.html' list_template = 'admin/model/list.html'
"""Default list view template""" """Default list view template"""
...@@ -194,14 +200,25 @@ class BaseModelView(BaseView, ActionsMixin): ...@@ -194,14 +200,25 @@ class BaseModelView(BaseView, ActionsMixin):
pass pass
""" """
column_formatters_export = None
"""
Dictionary of list view column formatters to be used for export.
Defaults to column_formatters when set to None.
Functions the same way as column_formatters except
that macros are not supported.
"""
column_type_formatters = ObsoleteAttr('column_type_formatters', 'list_type_formatters', None) column_type_formatters = ObsoleteAttr('column_type_formatters', 'list_type_formatters', None)
""" """
Dictionary of value type formatters to be used in the list view. Dictionary of value type formatters to be used in the list view.
By default, two types are formatted: By default, three types are formatted:
1. ``None`` will be displayed as an empty string 1. ``None`` will be displayed as an empty string
2. ``bool`` will be displayed as a checkmark if it is ``True`` 2. ``bool`` will be displayed as a checkmark if it is ``True``
3. ``list`` will be joined using ', '
If you don't like the default behavior and don't want any type formatters If you don't like the default behavior and don't want any type formatters
applied, just override this property with an empty dictionary:: applied, just override this property with an empty dictionary::
...@@ -237,6 +254,18 @@ class BaseModelView(BaseView, ActionsMixin): ...@@ -237,6 +254,18 @@ class BaseModelView(BaseView, ActionsMixin):
pass pass
""" """
column_type_formatters_export = None
"""
Dictionary of value type formatters to be used in the export.
By default, two types are formatted:
1. ``None`` will be displayed as an empty string
2. ``list`` will be joined using ', '
Functions the same way as column_type_formatters.
"""
column_labels = ObsoleteAttr('column_labels', 'rename_columns', None) column_labels = ObsoleteAttr('column_labels', 'rename_columns', None)
""" """
Dictionary where key is column name and value is string to display. Dictionary where key is column name and value is string to display.
...@@ -579,6 +608,12 @@ class BaseModelView(BaseView, ActionsMixin): ...@@ -579,6 +608,12 @@ class BaseModelView(BaseView, ActionsMixin):
action_disallowed_list = ['delete'] action_disallowed_list = ['delete']
""" """
# Export settings
export_max_rows = None
"""
Maximum number of rows allowed for export.
"""
# Various settings # Various settings
page_size = 20 page_size = 20
""" """
...@@ -732,10 +767,17 @@ class BaseModelView(BaseView, ActionsMixin): ...@@ -732,10 +767,17 @@ class BaseModelView(BaseView, ActionsMixin):
else: else:
self.column_choices = self._column_choices_map = dict() self.column_choices = self._column_choices_map = dict()
# Column formatters
if self.column_formatters_export is None:
self.column_formatters_export = self.column_formatters
# Type formatters # Type formatters
if self.column_type_formatters is None: if self.column_type_formatters is None:
self.column_type_formatters = dict(typefmt.BASE_FORMATTERS) self.column_type_formatters = dict(typefmt.BASE_FORMATTERS)
if self.column_type_formatters_export is None:
self.column_type_formatters_export = dict(typefmt.EXPORT_FORMATTERS)
if self.column_descriptions is None: if self.column_descriptions is None:
self.column_descriptions = dict() self.column_descriptions = dict()
...@@ -1214,7 +1256,8 @@ class BaseModelView(BaseView, ActionsMixin): ...@@ -1214,7 +1256,8 @@ class BaseModelView(BaseView, ActionsMixin):
return None return None
# Database-related API # Database-related API
def get_list(self, page, sort_field, sort_desc, search, filters): def get_list(self, page, sort_field, sort_desc, search, filters,
page_size=None):
""" """
Return a paginated and sorted list of models from the data source. Return a paginated and sorted list of models from the data source.
...@@ -1231,6 +1274,10 @@ class BaseModelView(BaseView, ActionsMixin): ...@@ -1231,6 +1274,10 @@ class BaseModelView(BaseView, ActionsMixin):
:param filters: :param filters:
List of filter tuples. First value in a tuple is a search List of filter tuples. First value in a tuple is a search
index, second value is a search value. index, second value is a search value.
:param page_size:
Number of results. Defaults to ModelView's page_size. Can be
overriden to change the page_size limit. Removing the page_size
limit requires setting page_size to 0 or False.
""" """
raise NotImplementedError('Please implement get_list method') raise NotImplementedError('Please implement get_list method')
...@@ -1493,19 +1540,23 @@ class BaseModelView(BaseView, ActionsMixin): ...@@ -1493,19 +1540,23 @@ class BaseModelView(BaseView, ActionsMixin):
""" """
return rec_getattr(model, name) return rec_getattr(model, name)
@contextfunction def _get_list_value(self, context, model, name, column_formatters,
def get_list_value(self, context, model, name): column_type_formatters):
""" """
Returns the value to be displayed in the list view Returns the value to be displayed.
:param context: :param context:
:py:class:`jinja2.runtime.Context` :py:class:`jinja2.runtime.Context` if available
:param model: :param model:
Model instance Model instance
:param name: :param name:
Field name Field name
:param column_formatters:
column_formatters to be used.
:param column_type_formatters:
column_type_formatters to be used.
""" """
column_fmt = self.column_formatters.get(name) column_fmt = column_formatters.get(name)
if column_fmt is not None: if column_fmt is not None:
value = column_fmt(self, context, model, name) value = column_fmt(self, context, model, name)
else: else:
...@@ -1516,7 +1567,7 @@ class BaseModelView(BaseView, ActionsMixin): ...@@ -1516,7 +1567,7 @@ class BaseModelView(BaseView, ActionsMixin):
return choices_map.get(value) or value return choices_map.get(value) or value
type_fmt = None type_fmt = None
for typeobj, formatter in self.column_type_formatters.items(): for typeobj, formatter in column_type_formatters.items():
if isinstance(value, typeobj): if isinstance(value, typeobj):
type_fmt = formatter type_fmt = formatter
break break
...@@ -1525,6 +1576,44 @@ class BaseModelView(BaseView, ActionsMixin): ...@@ -1525,6 +1576,44 @@ class BaseModelView(BaseView, ActionsMixin):
return value return value
@contextfunction
def get_list_value(self, context, model, name):
"""
Returns the value to be displayed in the list view
:param context:
:py:class:`jinja2.runtime.Context`
:param model:
Model instance
:param name:
Field name
"""
return self._get_list_value(
context,
model,
name,
self.column_formatters,
self.column_type_formatters,
)
def get_export_value(self, model, name):
"""
Returns the value to be displayed in export.
Allows export to use different (non HTML) formatters.
:param model:
Model instance
:param name:
Field name
"""
return self._get_list_value(
None,
model,
name,
self.column_formatters_export,
self.column_type_formatters_export,
)
# AJAX references # AJAX references
def _process_ajax_references(self): def _process_ajax_references(self):
""" """
...@@ -1823,6 +1912,82 @@ class BaseModelView(BaseView, ActionsMixin): ...@@ -1823,6 +1912,82 @@ class BaseModelView(BaseView, ActionsMixin):
""" """
return self.handle_action() return self.handle_action()
@expose('/export/csv/')
def export_csv(self):
"""
Export a CSV of records.
"""
return_url = get_redirect_target() or self.get_url('.index_view')
if not self.can_export:
flash(gettext('Permission denied.'))
return redirect(return_url)
# Macros in column_formatters are not supported.
# Macros will have a function name 'inner'
# This causes non-macro functions named 'inner' not work.
for col, func in iteritems(self.column_formatters):
if func.__name__ == 'inner':
raise NotImplementedError(
'Macros not implemented. Override with '
'column_formatters_export. Column: %s' % (col,)
)
# Grab parameters from URL
view_args = self._get_list_extra_args()
# Map column index to column name
sort_column = self._get_column_by_idx(view_args.sort)
if sort_column is not None:
sort_column = sort_column[0]
# Get count and data
count, data = self.get_list(0, sort_column, view_args.sort_desc,
view_args.search, view_args.filters,
page_size=self.export_max_rows)
# https://docs.djangoproject.com/en/1.8/howto/outputting-csv/
class Echo(object):
"""
An object that implements just the write method of the file-like
interface.
"""
def write(self, value):
"""
Write the value by returning it, instead of storing
in a buffer.
"""
return value
writer = csv.writer(Echo())
def generate():
# Needed as python 2 csvwriter does not support unicode
def fix_unicode(t):
return as_unicode(t).encode('utf-8')
# Append the column titles at the beginning
titles = [fix_unicode(c[1]) for c in self._list_columns]
yield writer.writerow(titles)
for row in data:
vals = [fix_unicode(self.get_export_value(row, c[0]))
for c in self._list_columns]
yield writer.writerow(vals)
filename = '{}_{}.csv'.format(
self.name,
time.strftime("%Y-%m-%d_%H-%M-%S")
)
disposition = 'attachment;filename={}'.format(secure_filename(filename))
return Response(
stream_with_context(generate()),
headers={'Content-Disposition': disposition},
mimetype='text/csv'
)
@expose('/ajax/lookup/') @expose('/ajax/lookup/')
def ajax_lookup(self): def ajax_lookup(self):
name = request.args.get('name') name = request.args.get('name')
......
...@@ -49,3 +49,8 @@ BASE_FORMATTERS = { ...@@ -49,3 +49,8 @@ BASE_FORMATTERS = {
bool: bool_formatter, bool: bool_formatter,
list: list_formatter, list: list_formatter,
} }
EXPORT_FORMATTERS = {
type(None): empty_formatter,
list: list_formatter,
}
...@@ -26,6 +26,12 @@ ...@@ -26,6 +26,12 @@
</li> </li>
{% endif %} {% endif %}
{% if admin_view.can_export %}
<li>
<a href="{{ get_url('.export_csv', **request.args) }}" title="{{ _gettext('Export') }}">{{ _gettext('Export') }}</a>
</li>
{% endif %}
{% if filters %} {% if filters %}
<li class="dropdown"> <li class="dropdown">
{{ model_layout.filter_options() }} {{ model_layout.filter_options() }}
......
...@@ -26,6 +26,12 @@ ...@@ -26,6 +26,12 @@
</li> </li>
{% endif %} {% endif %}
{% if admin_view.can_export %}
<li>
<a href="{{ get_url('.export_csv', **request.args) }}" title="{{ _gettext('Export') }}">{{ _gettext('Export') }}</a>
</li>
{% endif %}
{% if filters %} {% if filters %}
<li class="dropdown"> <li class="dropdown">
{{ model_layout.filter_options() }} {{ model_layout.filter_options() }}
......
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