Skip to content
This repository was archived by the owner on May 1, 2024. It is now read-only.

Commit b7fb3b3

Browse files
committed
Adds CSV support to the LearnerListView
* Puts pagination previous/next links in the HTTP header * Allows the returned field list (for CSV only) to be limited by the 'fields' query string parameter. * Flattens CSV list fields into a comma-delimited string, instead of showing list.0,list.1.. * Upgrades djangorestframework-csv to maintain fields/heading sort order in CSV. * Avoids duplicate-code warnings by moving the CSV response validation tests to a new analytics_data_api.v0.tests.views.VerifyCsvResponseMixin
1 parent 761f23d commit b7fb3b3

13 files changed

Lines changed: 552 additions & 93 deletions

File tree

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1 @@
1-
LEARNER_API_DEFAULT_LIST_PAGE_SIZE = 25
2-
31
SEGMENTS = ["highly_engaged", "disengaging", "struggling", "inactive", "unenrolled"]

analytics_data_api/renderers.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
"""
2+
Custom REST framework renderers common to all versions of the API.
3+
"""
4+
from rest_framework_csv.renderers import CSVRenderer
5+
from ordered_set import OrderedSet
6+
7+
8+
class ResultsOnlyRendererMixin(object):
9+
"""
10+
Render data using just the results array.
11+
12+
Use with PaginatedHeadersMixin to preserve the pagination links in the response header.
13+
"""
14+
results_field = 'results'
15+
16+
def render(self, data, *args, **kwargs):
17+
"""
18+
Replace the rendered data with just what is in the results_field.
19+
"""
20+
if not isinstance(data, list):
21+
data = data.get(self.results_field, [])
22+
return super(ResultsOnlyRendererMixin, self).render(data, *args, **kwargs)
23+
24+
25+
class DynamicFieldsCsvRenderer(CSVRenderer):
26+
"""
27+
Allows the `fields` query parameter to determine which fields should be
28+
returned in the response, and in what order.
29+
30+
Note that if no header is provided, and the fields_param query string
31+
parameter is not found in the request, the fields are rendered in
32+
alphabetical order.
33+
"""
34+
# Name of the query string parameter to check for the fields list
35+
# Set to None to ensure that any request fields will not override
36+
fields_param = 'fields'
37+
38+
# Seperator character(s) to split the fields parameter list
39+
fields_sep = ','
40+
41+
# Set to None to flatten lists into one heading per value.
42+
# Otherwise, concatenate lists delimiting with the given string.
43+
concatenate_lists_sep = ', '
44+
45+
def flatten_list(self, l):
46+
if self.concatenate_lists_sep is None:
47+
return super(DynamicFieldsCsvRenderer, self).flatten_list(l)
48+
return {'': self.concatenate_lists_sep.join(l)}
49+
50+
def get_header(self, data, renderer_context):
51+
"""Return the list of header fields, determined by class settings and context."""
52+
53+
# Start with the previously-set list of header fields
54+
header = renderer_context.get('header', self.header)
55+
56+
# If no previous set, then determine the candidates from the data
57+
if header is None:
58+
header = set()
59+
data = self.flatten_data(data)
60+
for item in data:
61+
header.update(list(item.keys()))
62+
63+
# Alphabetize header fields by default, since
64+
# flatten_data() makes field order indeterminate.
65+
header = sorted(header)
66+
67+
# If configured to, examine the query parameters for the requsted header fields
68+
request = renderer_context.get('request')
69+
if request is not None and self.fields_param is not None:
70+
71+
request_fields = request.query_params.get(self.fields_param)
72+
if request_fields is not None:
73+
74+
requested = OrderedSet()
75+
for request_field in request_fields.split(self.fields_sep):
76+
77+
# Only fields in the original candidate header set are valid
78+
if request_field in header:
79+
requested.update((request_field,))
80+
81+
header = requested # pylint: disable=redefined-variable-type
82+
83+
return header
84+
85+
def render(self, data, media_type=None, renderer_context=None, writer_opts=None):
86+
"""Override the default "get headers" behaviour, then render the data."""
87+
renderer_context = renderer_context or {}
88+
self.header = self.get_header(data, renderer_context)
89+
return super(DynamicFieldsCsvRenderer, self).render(data, media_type, renderer_context, writer_opts)
90+
91+
92+
class PaginatedCsvRenderer(ResultsOnlyRendererMixin, DynamicFieldsCsvRenderer):
93+
"""
94+
Render results-only CSV data with dynamically-determined fields.
95+
"""
96+
media_type = 'text/csv'

analytics_data_api/tests/__init__.py

Whitespace-only changes.
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
"""
2+
Tests for the custom REST framework renderers.
3+
"""
4+
from mock import MagicMock, PropertyMock
5+
from django.test import TestCase
6+
7+
from analytics_data_api.renderers import PaginatedCsvRenderer
8+
9+
10+
class PaginatedCsvRendererTests(TestCase):
11+
12+
def setUp(self):
13+
super(PaginatedCsvRendererTests, self).setUp()
14+
self.renderer = PaginatedCsvRenderer()
15+
self.data = {'results': [
16+
{
17+
'string': 'ab,c',
18+
'list': ['a', 'b', 'c'],
19+
'dict': {'a': 1, 'b': 2, 'c': 3},
20+
}, {
21+
'string': 'def',
22+
'string2': 'ghi',
23+
'list': ['d', 'e', 'f', 'g'],
24+
'dict': {'d': 4, 'b': 5, 'c': 6},
25+
},
26+
]}
27+
self.context = {}
28+
29+
def set_request(self, params=None):
30+
request = MagicMock()
31+
mock_params = PropertyMock(return_value=params)
32+
type(request).query_params = mock_params
33+
self.context['request'] = request
34+
35+
def test_csv_media_type(self):
36+
self.assertEqual(self.renderer.media_type, 'text/csv')
37+
38+
def test_render(self):
39+
rendered_data = self.renderer.render(self.data, renderer_context=self.context)
40+
self.assertEquals(rendered_data,
41+
'dict.a,dict.b,dict.c,dict.d,list,string,string2\r\n'
42+
'1,2,3,,"a, b, c","ab,c",\r\n'
43+
',5,6,4,"d, e, f, g",def,ghi\r\n')
44+
45+
def test_render_fields(self):
46+
self.set_request(dict(fields='string2,invalid,dict.b,list,dict.a,string'))
47+
rendered_data = self.renderer.render(self.data, renderer_context=self.context)
48+
self.assertEquals(rendered_data,
49+
'string2,dict.b,list,dict.a,string\r\n'
50+
',2,"a, b, c",1,"ab,c"\r\n'
51+
'ghi,5,"d, e, f, g",,def\r\n')
52+
53+
def test_render_flatten_lists(self):
54+
self.renderer.concatenate_lists_sep = None
55+
rendered_data = self.renderer.render(self.data, renderer_context=self.context)
56+
self.assertEquals(rendered_data,
57+
'dict.a,dict.b,dict.c,dict.d,list.0,list.1,list.2,list.3,string,string2\r\n'
58+
'1,2,3,,a,b,c,,"ab,c",\r\n'
59+
',5,6,4,d,e,f,g,def,ghi\r\n')
60+
61+
def test_render_fields_flatten_lists(self):
62+
self.renderer.concatenate_lists_sep = None
63+
self.set_request(dict(fields='string2,invalid,list.2,dict.a,list.1,string'))
64+
rendered_data = self.renderer.render(self.data, renderer_context=self.context)
65+
self.assertEquals(rendered_data,
66+
'string2,list.2,dict.a,list.1,string\r\n'
67+
',c,1,b,"ab,c"\r\n'
68+
'ghi,f,,e,def\r\n')
69+
70+
def test_render_fields_limit_headers(self):
71+
self.renderer.header = ('string2', 'invalid', 'dict.a')
72+
self.set_request(dict(fields='string2,invalid,dict.b,list,dict.a,string'))
73+
rendered_data = self.renderer.render(self.data, renderer_context=self.context)
74+
self.assertEquals(rendered_data,
75+
'string2,invalid,dict.a\r\n'
76+
',,1\r\n'
77+
'ghi,,\r\n')

analytics_data_api/v0/serializers.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
from analytics_data_api.constants import (
88
engagement_events,
99
enrollment_modes,
10-
learner,
1110
)
1211
from analytics_data_api.v0 import models
1312

@@ -409,8 +408,8 @@ class EdxPaginationSerializer(pagination.PageNumberPagination):
409408
Adds values to the response according to edX REST API Conventions.
410409
"""
411410
page_size_query_param = 'page_size'
412-
page_size = learner.LEARNER_API_DEFAULT_LIST_PAGE_SIZE
413-
max_page_size = 100 # TODO -- tweak during load testing
411+
page_size = getattr(settings, 'DEFAULT_PAGE_SIZE', 25)
412+
max_page_size = getattr(settings, 'MAX_PAGE_SIZE', 100) # TODO -- tweak during load testing
414413

415414
def get_paginated_response(self, data):
416415
# The output is more readable with num_pages included not at the end, but

analytics_data_api/v0/tests/views/__init__.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
import json
2+
import StringIO
3+
import csv
24

35
from opaque_keys.edx.keys import CourseKey
46
from rest_framework import status
57

68
from analytics_data_api.utils import get_filename_safe_course_id
9+
from analytics_data_api.v0.tests.utils import flatten
10+
711

812
DEMO_COURSE_ID = u'course-v1:edX+DemoX+Demo_2014'
913
SANITIZED_DEMO_COURSE_ID = get_filename_safe_course_id(DEMO_COURSE_ID)
@@ -39,3 +43,48 @@ def verify_bad_course_id(self, response, course_id='malformed-course-id'):
3943
u"developer_message": u"Course id/key {} malformed.".format(course_id)
4044
}
4145
self.assertDictEqual(json.loads(response.content), expected)
46+
47+
48+
class VerifyCsvResponseMixin(object):
49+
50+
def assertCsvResponseIsValid(self, response, expected_filename, expected_data=None, expected_headers=None):
51+
52+
# Validate the basic response status, content type, and filename
53+
self.assertEquals(response.status_code, 200)
54+
if expected_data:
55+
self.assertEquals(response['Content-Type'].split(';')[0], 'text/csv')
56+
self.assertEquals(response['Content-Disposition'], u'attachment; filename={}'.format(expected_filename))
57+
58+
# Validate other response headers
59+
if expected_headers:
60+
for header_name, header_content in expected_headers.iteritems():
61+
self.assertEquals(response.get(header_name), header_content)
62+
63+
# Validate the content data
64+
if expected_data:
65+
data = map(flatten, expected_data)
66+
67+
# The CSV renderer sorts the headers alphabetically
68+
fieldnames = sorted(data[0].keys())
69+
70+
# Generate the expected CSV output
71+
expected = StringIO.StringIO()
72+
writer = csv.DictWriter(expected, fieldnames)
73+
writer.writeheader()
74+
writer.writerows(data)
75+
self.assertEqual(response.content, expected.getvalue())
76+
else:
77+
self.assertEqual(response.content, '')
78+
79+
def assertResponseFields(self, response, fields):
80+
content_type = response.get('Content-Type', '').split(';')[0]
81+
self.assertEquals(content_type, 'text/csv')
82+
83+
data = StringIO.StringIO(response.content)
84+
reader = csv.reader(data)
85+
rows = []
86+
for row in reader:
87+
rows.append(row)
88+
# Just check the header row
89+
self.assertGreater(len(rows), 1)
90+
self.assertEqual(rows[0], fields)

analytics_data_api/v0/tests/views/test_courses.py

Lines changed: 8 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,6 @@
33
# change for versions greater than 1.0.0. Tests target a specific version of the API, additional tests should be added
44
# for subsequent versions if there are breaking changes introduced in those versions.
55

6-
import StringIO
7-
import csv
86
import datetime
97
from itertools import groupby
108
import urllib
@@ -18,8 +16,9 @@
1816
from analytics_data_api.v0 import models
1917
from analytics_data_api.constants import country, enrollment_modes, genders
2018
from analytics_data_api.v0.models import CourseActivityWeekly
21-
from analytics_data_api.v0.tests.utils import flatten
22-
from analytics_data_api.v0.tests.views import DemoCourseMixin, DEMO_COURSE_ID, SANITIZED_DEMO_COURSE_ID
19+
from analytics_data_api.v0.tests.views import (
20+
DemoCourseMixin, VerifyCsvResponseMixin, DEMO_COURSE_ID, SANITIZED_DEMO_COURSE_ID,
21+
)
2322
from analyticsdataserver.tests import TestCaseWithAuthentication
2423

2524

@@ -38,7 +37,7 @@ def test_default_fill(self):
3837

3938

4039
# pylint: disable=no-member
41-
class CourseViewTestCaseMixin(DemoCourseMixin):
40+
class CourseViewTestCaseMixin(DemoCourseMixin, VerifyCsvResponseMixin):
4241
model = None
4342
api_root_path = '/api/v0/'
4443
path = None
@@ -66,7 +65,8 @@ def get_latest_data(self, course_id=None):
6665
"""
6766
raise NotImplementedError
6867

69-
def get_csv_filename(self):
68+
@property
69+
def csv_filename(self):
7070
return u'edX-DemoX-Demo_2014--{0}.csv'.format(self.csv_filename_slug)
7171

7272
def test_get_not_found(self):
@@ -93,28 +93,12 @@ def assertCSVIsValid(self, course_id, filename):
9393
csv_content_type = 'text/csv'
9494
response = self.authenticated_get(path, HTTP_ACCEPT=csv_content_type)
9595

96-
# Validate the basic response status, content type, and filename
97-
self.assertEquals(response.status_code, 200)
98-
self.assertEquals(response['Content-Type'].split(';')[0], csv_content_type)
99-
self.assertEquals(response['Content-Disposition'], u'attachment; filename={}'.format(filename))
100-
101-
# Validate the actual data
10296
data = self.format_as_response(*self.get_latest_data(course_id=course_id))
103-
data = map(flatten, data)
104-
105-
# The CSV renderer sorts the headers alphabetically
106-
fieldnames = sorted(data[0].keys())
107-
108-
# Generate the expected CSV output
109-
expected = StringIO.StringIO()
110-
writer = csv.DictWriter(expected, fieldnames)
111-
writer.writeheader()
112-
writer.writerows(data)
113-
self.assertEqual(response.content, expected.getvalue())
97+
self.assertCsvResponseIsValid(response, filename, data)
11498

11599
def test_get_csv(self):
116100
""" Verify the endpoint returns data that has been properly converted to CSV. """
117-
self.assertCSVIsValid(self.course_id, self.get_csv_filename())
101+
self.assertCSVIsValid(self.course_id, self.csv_filename)
118102

119103
def test_get_csv_with_deprecated_key(self):
120104
"""

0 commit comments

Comments
 (0)