Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 1 addition & 14 deletions .travis.yml
Original file line number Diff line number Diff line change
@@ -1,26 +1,13 @@
language: python
python:
- 2.6
- 2.7
- 3.5
env:
- DJANGO=1.5
- DJANGO=1.6
- DJANGO=1.7
- DJANGO=1.8
- DJANGO=1.9
- DJANGO=1.10
matrix:
exclude:
- python: 2.6
env: DJANGO=1.6
- python: 2.6
env: DJANGO=1.7
- python: 2.6
env: DJANGO=1.8
- python: 2.6
env: DJANGO=1.9
- python: 3.5
env: DJANGO=1.5
- python: 3.5
env: DJANGO=1.6
- python: 3.5
Expand Down
18 changes: 15 additions & 3 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,13 @@ installation
Run::

pip install django-queryset-csv
Supports Python 2.6 and 2.7, Django >= 1.5.

Supports Python 2.7 and 3.5, Django >= 1.8.

usage
-----
Perform all filtering and field authorization in your view using ``.filter()`` and ``.values()``.
Then, use ``render_to_csv_response`` to turn a queryset into a respone with a CSV attachment.
Then, use ``render_to_csv_response`` to turn a queryset into a response with a CSV attachment.
Pass it a ``QuerySet`` or ``ValuesQuerySet`` instance::

from djqscsv import render_to_csv_response
Expand All @@ -42,6 +42,14 @@ Pass it a ``QuerySet`` or ``ValuesQuerySet`` instance::
qs = Foo.objects.filter(bar=True).values('id', 'bar')
return render_to_csv_response(qs)

If you need to write the CSV to a file you can use ``write_csv`` instead::

from djqscsv import write_csv

qs = Foo.objects.filter(bar=True).values('id', 'bar')
with open('foo.csv', 'w') as csv_file:
write_csv(qs, csv_file)

foreign keys
------------

Expand Down Expand Up @@ -77,6 +85,10 @@ This module exports two functions that write CSVs, ``render_to_csv_response`` an
- ``use_verbose_names`` - (default: ``True``) A boolean determining whether to use the django field's ``verbose_name``, or to use it's regular field name as a column header. Note that if a given field is found in the ``field_header_map``, this value will take precendence.
- ``field_order`` - (default: ``None``) A list of fields to determine the sort order. This list need not be complete: any fields not specified will follow those in the list with the order they would have otherwise used.

In addition to the above arguments, ``render_to_csv_response`` takes the following optional keyword argument:

- ``streaming`` - (default: ``True``) A boolean determining whether to use ``StreamingHttpResponse`` instead of the normal ``HttpResponse``.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think defaulting this to True is fine, provided that we bump the major version number in the next release.


The remaining keyword arguments are *passed through* to the csv writer. For example, you can export a CSV with a different delimiter.

views.py::
Expand Down
63 changes: 39 additions & 24 deletions djqscsv/djqscsv.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,32 @@

from django.core.exceptions import ValidationError
from django.utils.text import slugify
from django.http import HttpResponse
from django.http import HttpResponse, StreamingHttpResponse
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From what I can tell, StreamingHttpResponse is in all of the supported Django versions (even 1.6). We could probably drop support for 1.6 and 1.7 too, given that 1.7 is unsupported as of the end of Q4 2015.

See: https://www.djangoproject.com/download/#supported-versions

Copy link
Contributor Author

@maurizi maurizi Nov 2, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I dropped 1.5 because it was failing on Travis CI when run with Python 2.6 (not sure why, logs were broken last night).

Since 1.6 and 1.7 are no longer supported, I just dropped those as well, which let me get rid of some code too!


from django.utils import six

""" A simple python package for turning django models into csvs """

# Keyword arguments that will be used by this module
# the rest will be passed along to the csv writer
DJQSCSV_KWARGS = {'field_header_map': None,
'field_serializer_map': None,
'use_verbose_names': True,
'field_order': None}
DJQSCSV_KWARGS = {
'field_header_map', 'field_serializer_map', 'use_verbose_names',
'field_order', 'streaming'}


class CSVException(Exception):
pass


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


def render_to_csv_response(queryset, filename=None, append_datestamp=False,
**kwargs):
"""
Expand All @@ -36,19 +44,34 @@ def render_to_csv_response(queryset, filename=None, append_datestamp=False,
filename = generate_filename(queryset,
append_datestamp=append_datestamp)

response = HttpResponse(content_type='text/csv')
response_args = {'content_type': 'text/csv'}

if not kwargs.get('streaming', True):
response = HttpResponse(**response_args)
write_csv(queryset, response, **kwargs)
else:
response = StreamingHttpResponse(
_iter_csv(queryset, _Echo(), **kwargs), **response_args)

response['Content-Disposition'] = 'attachment; filename=%s;' % filename
response['Cache-Control'] = 'no-cache'

write_csv(queryset, response, **kwargs)

return response


def write_csv(queryset, file_obj, **kwargs):
"""
Writes CSV data to a file object based on the contents of the queryset.
"""
# Force iteration over all rows so they all get written to the file
for _ in _iter_csv(queryset, file_obj, **kwargs):
pass


def _iter_csv(queryset, file_obj, **kwargs):
"""
The main worker function. Writes CSV data to a file object based on the
contents of the queryset.
contents of the queryset and yields each row.
"""

# process keyword arguments to pull out the ones used by this function
Expand All @@ -64,7 +87,7 @@ def write_csv(queryset, file_obj, **kwargs):
csv_kwargs[key] = val

# add BOM to support CSVs in MS Excel (for Windows only)
file_obj.write(b'\xef\xbb\xbf')
yield file_obj.write(b'\xef\xbb\xbf')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice minimal change.


# the CSV must always be built from a values queryset
# in order to introspect the necessary fields.
Expand All @@ -86,25 +109,17 @@ def write_csv(queryset, file_obj, **kwargs):
values_qs = queryset.values()

try:
# Django 1.9+
field_names = values_qs.query.values_select
except AttributeError:
try:
field_names = values_qs.field_names
except AttributeError:
# in django1.5, empty querysets trigger
# this exception, but not django 1.6
raise CSVException("Empty queryset provided to exporter.")
# Django 1.8
field_names = values_qs.field_names

extra_columns = list(values_qs.query.extra_select)
if extra_columns:
field_names += extra_columns

try:
aggregate_columns = list(values_qs.query.annotation_select)
except AttributeError:
# this gets a deprecation warning in django 1.9 but is
# required in django<=1.7
aggregate_columns = list(values_qs.query.aggregate_select)
aggregate_columns = list(values_qs.query.annotation_select)

if aggregate_columns:
field_names += aggregate_columns
Expand Down Expand Up @@ -134,11 +149,11 @@ def write_csv(queryset, file_obj, **kwargs):
merged_header_map.update(dict((k, k) for k in extra_columns))
merged_header_map.update(field_header_map)

writer.writerow(merged_header_map)
yield writer.writerow(merged_header_map)

for record in values_qs:
record = _sanitize_record(field_serializer_map, record)
writer.writerow(record)
yield writer.writerow(record)


def generate_filename(queryset, append_datestamp=False):
Expand Down
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,5 @@
"Framework :: Django",
"License :: OSI Approved :: GNU General Public License (GPL)"
],
install_requires=['django>=1.5', 'unicodecsv>=0.14.1'],
install_requires=['django>=1.8', 'unicodecsv>=0.14.1'],
)
27 changes: 20 additions & 7 deletions test_app/djqscsv_tests/tests/test_csv_creation.py
Original file line number Diff line number Diff line change
Expand Up @@ -298,8 +298,9 @@ def test_render_to_csv_response_no_filename(self):
response = djqscsv.render_to_csv_response(self.qs,
use_verbose_names=False)
self.assertEqual(response['Content-Type'], 'text/csv')
self.assertMatchesCsv(response.content.splitlines(),
self.FULL_PERSON_CSV_NO_VERBOSE)
self.assertMatchesCsv(
b''.join(response.streaming_content).splitlines(),
self.FULL_PERSON_CSV_NO_VERBOSE)

self.assertRegexpMatches(response['Content-Disposition'],
r'attachment; filename=person_export.csv;')
Expand All @@ -309,6 +310,16 @@ def test_render_to_csv_response(self):
filename="test_csv",
use_verbose_names=False)
self.assertEqual(response['Content-Type'], 'text/csv')
self.assertMatchesCsv(
b''.join(response.streaming_content).splitlines(),
self.FULL_PERSON_CSV_NO_VERBOSE)

def test_render_to_csv_response_non_streaming(self):
response = djqscsv.render_to_csv_response(self.qs,
filename="test_csv",
use_verbose_names=False,
streaming=False)
self.assertEqual(response['Content-Type'], 'text/csv')
self.assertMatchesCsv(response.content.splitlines(),
self.FULL_PERSON_CSV_NO_VERBOSE)

Expand All @@ -319,9 +330,10 @@ def test_render_to_csv_response_other_delimiter(self):
delimiter='|')

self.assertEqual(response['Content-Type'], 'text/csv')
self.assertMatchesCsv(response.content.splitlines(),
self.FULL_PERSON_CSV_NO_VERBOSE,
delimiter="|")
self.assertMatchesCsv(
b''.join(response.streaming_content).splitlines(),
self.FULL_PERSON_CSV_NO_VERBOSE,
delimiter="|")

def test_render_to_csv_fails_on_delimiter_mismatch(self):
response = djqscsv.render_to_csv_response(self.qs,
Expand All @@ -330,5 +342,6 @@ def test_render_to_csv_fails_on_delimiter_mismatch(self):
delimiter='|')

self.assertEqual(response['Content-Type'], 'text/csv')
self.assertNotMatchesCsv(response.content.splitlines(),
self.FULL_PERSON_CSV_NO_VERBOSE)
self.assertNotMatchesCsv(
b''.join(response.streaming_content).splitlines(),
self.FULL_PERSON_CSV_NO_VERBOSE)