Skip to content

Commit

Permalink
Merge pull request pallets-eco#1038 from tandreas/csv_export
Browse files Browse the repository at this point in the history
Implement CSV export for BaseModelView.
  • Loading branch information
mrjoes committed Sep 1, 2015
2 parents 9db8c9e + 51113b9 commit e8b1b6b
Show file tree
Hide file tree
Showing 7 changed files with 333 additions and 18 deletions.
6 changes: 6 additions & 0 deletions doc/introduction.rst
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,12 @@ To **manage related models inline**::
These inline forms can be customised. Have a look at the API documentation for
: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
=====================

Expand Down
8 changes: 8 additions & 0 deletions flask_admin/_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ def as_unicode(s):

return str(s)

def csv_encode(s):
''' Returns unicode string expected by Python 3's csv module '''
return as_unicode(s)

# Various tools
from functools import reduce
from urllib.parse import urljoin, urlparse
Expand All @@ -50,6 +54,10 @@ def as_unicode(s):

return unicode(s)

def csv_encode(s):
''' Returns byte string expected by Python 2's csv module '''
return as_unicode(s).encode('utf-8')

# Helpers
reduce = __builtins__['reduce'] if isinstance(__builtins__, dict) else __builtins__.reduce
from urlparse import urljoin, urlparse
Expand Down
181 changes: 170 additions & 11 deletions flask_admin/model/base.py
100644 → 100755
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import warnings
import re
import csv
import time

from werkzeug import secure_filename

from flask import (request, redirect, flash, abort, json, Response,
get_flashed_messages)
get_flashed_messages, stream_with_context)
from jinja2 import contextfunction
from wtforms.fields import HiddenField
from wtforms.fields.core import UnboundField
Expand All @@ -18,12 +22,11 @@
get_redirect_target, flash_errors)
from flask_admin.tools import rec_getattr
from flask_admin._backwards import ObsoleteAttr
from flask_admin._compat import iteritems, OrderedDict, as_unicode
from flask_admin._compat import iteritems, OrderedDict, as_unicode, csv_encode
from .helpers import prettify_name, get_mdict_item_or_list
from .ajax import AjaxModelLoader
from .fields import ListEditableFieldList


# Used to generate filter query string name
filter_char_re = re.compile('[^a-z0-9 ]')
filter_compact_re = re.compile(' +')
Expand Down Expand Up @@ -95,6 +98,9 @@ class BaseModelView(BaseView, ActionsMixin):
when there are too many columns to display in the list_view.
"""

can_export = False
"""Is model list export allowed"""

# Templates
list_template = 'admin/model/list.html'
"""Default list view template"""
Expand Down Expand Up @@ -194,14 +200,25 @@ def formatter(view, context, model, name):
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)
"""
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
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
applied, just override this property with an empty dictionary::
Expand Down Expand Up @@ -237,6 +254,18 @@ def type_formatter(view, value):
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)
"""
Dictionary where key is column name and value is string to display.
Expand Down Expand Up @@ -579,6 +608,12 @@ class MyModelView(BaseModelView):
action_disallowed_list = ['delete']
"""

# Export settings
export_max_rows = None
"""
Maximum number of rows allowed for export.
"""

# Various settings
page_size = 20
"""
Expand Down Expand Up @@ -732,10 +767,17 @@ def _refresh_cache(self):
else:
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
if self.column_type_formatters is None:
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:
self.column_descriptions = dict()

Expand Down Expand Up @@ -1214,7 +1256,8 @@ def _get_default_order(self):
return None

# 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.
Expand All @@ -1231,6 +1274,10 @@ def get_list(self, page, sort_field, sort_desc, search, filters):
:param filters:
List of filter tuples. First value in a tuple is a search
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')

Expand Down Expand Up @@ -1493,19 +1540,23 @@ def _get_field_value(self, model, name):
"""
return rec_getattr(model, name)

@contextfunction
def get_list_value(self, context, model, name):
def _get_list_value(self, context, model, name, column_formatters,
column_type_formatters):
"""
Returns the value to be displayed in the list view
Returns the value to be displayed.
:param context:
:py:class:`jinja2.runtime.Context`
:py:class:`jinja2.runtime.Context` if available
:param model:
Model instance
:param 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:
value = column_fmt(self, context, model, name)
else:
Expand All @@ -1516,7 +1567,7 @@ def get_list_value(self, context, model, name):
return choices_map.get(value) or value

type_fmt = None
for typeobj, formatter in self.column_type_formatters.items():
for typeobj, formatter in column_type_formatters.items():
if isinstance(value, typeobj):
type_fmt = formatter
break
Expand All @@ -1525,6 +1576,44 @@ def get_list_value(self, context, model, name):

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
def _process_ajax_references(self):
"""
Expand Down Expand Up @@ -1823,6 +1912,76 @@ def action_view(self):
"""
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():
# Append the column titles at the beginning
titles = [csv_encode(c[1]) for c in self._list_columns]
yield writer.writerow(titles)

for row in data:
vals = [csv_encode(self.get_export_value(row, c[0]))
for c in self._list_columns]
yield writer.writerow(vals)

filename = '%s_%s.csv' % (self.name,
time.strftime("%Y-%m-%d_%H-%M-%S"))

disposition = 'attachment;filename=%s' % (secure_filename(filename),)

return Response(
stream_with_context(generate()),
headers={'Content-Disposition': disposition},
mimetype='text/csv'
)

@expose('/ajax/lookup/')
def ajax_lookup(self):
name = request.args.get('name')
Expand Down
5 changes: 5 additions & 0 deletions flask_admin/model/typefmt.py
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,8 @@ def list_formatter(view, values):
bool: bool_formatter,
list: list_formatter,
}

EXPORT_FORMATTERS = {
type(None): empty_formatter,
list: list_formatter,
}
6 changes: 6 additions & 0 deletions flask_admin/templates/bootstrap2/admin/model/list.html
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@
</li>
{% 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 %}
<li class="dropdown">
{{ model_layout.filter_options() }}
Expand Down
6 changes: 6 additions & 0 deletions flask_admin/templates/bootstrap3/admin/model/list.html
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@
</li>
{% 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 %}
<li class="dropdown">
{{ model_layout.filter_options() }}
Expand Down
Loading

0 comments on commit e8b1b6b

Please sign in to comment.