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

Commit 8103ad0

Browse files
committed
Updated endpoint to return counts per mode, updated and added tests
1 parent d925f79 commit 8103ad0

9 files changed

Lines changed: 287 additions & 56 deletions

File tree

analytics_data_api/v0/serializers.py

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -509,22 +509,48 @@ def get_engagement_ranges(self, obj):
509509
return engagement_ranges
510510

511511

512-
class CourseMetaSummaryEnrollmentSerializer(ModelSerializerWithCreatedField):
512+
class DynamicFieldsModelSerializer(serializers.ModelSerializer):
513513
"""
514-
Serializer for problems.
514+
A ModelSerializer that takes an additional `fields` argument that controls which
515+
fields should be displayed.
516+
517+
Blatantly taken from http://www.django-rest-framework.org/api-guide/serializers/#dynamically-modifying-fields
518+
"""
519+
520+
def __init__(self, *args, **kwargs):
521+
# Don't pass the 'fields' arg up to the superclass
522+
fields = kwargs.pop('fields', None)
523+
524+
# Instantiate the superclass normally
525+
super(DynamicFieldsModelSerializer, self).__init__(*args, **kwargs)
526+
527+
if fields is not None:
528+
# Drop any fields that are not specified in the `fields` argument.
529+
allowed = set(fields)
530+
existing = set(self.fields.keys())
531+
for field_name in existing - allowed:
532+
self.fields.pop(field_name)
533+
534+
535+
class CourseMetaSummaryEnrollmentSerializer(ModelSerializerWithCreatedField, DynamicFieldsModelSerializer):
536+
"""
537+
Serializer for course and enrollment counts per mode.
515538
"""
516539
course_id = serializers.CharField()
517540
catalog_course_title = serializers.CharField()
518541
catalog_course = serializers.CharField()
519-
start_date = serializers.DateTimeField()
520-
end_date = serializers.DateTimeField()
542+
start_date = serializers.DateTimeField(format=settings.DATETIME_FORMAT)
543+
end_date = serializers.DateTimeField(format=settings.DATETIME_FORMAT)
521544
pacing_type = serializers.CharField()
522545
availability = serializers.CharField()
523-
mode = serializers.CharField()
524546
count = serializers.IntegerField(default=0)
525547
cumulative_count = serializers.IntegerField(default=0)
526-
count_change_7_days = serializers.IntegerField(default=0) # TODO: 0 as default?
548+
count_change_7_days = serializers.IntegerField(default=0)
549+
modes = serializers.SerializerMethodField()
550+
551+
def get_modes(self, obj):
552+
return obj.get('modes', None)
527553

528554
class Meta(object):
529555
model = models.CourseMetaSummaryEnrollment
530-
exclude = ('id',)
556+
exclude = ('id', 'mode')
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import datetime
2+
from urllib import urlencode
3+
4+
import ddt
5+
from django_dynamic_fixture import G
6+
import pytz
7+
8+
from django.conf import settings
9+
10+
from analytics_data_api.constants import enrollment_modes
11+
from analytics_data_api.v0 import models, serializers
12+
from analytics_data_api.v0.tests.views import CourseSamples, VerifyCourseIdMixin
13+
from analyticsdataserver.tests import TestCaseWithAuthentication
14+
15+
16+
@ddt.ddt
17+
class CourseSummariesViewTests(VerifyCourseIdMixin, TestCaseWithAuthentication):
18+
model = models.CourseMetaSummaryEnrollment
19+
serializer = serializers.CourseMetaSummaryEnrollmentSerializer
20+
expected_summaries = []
21+
22+
def setUp(self):
23+
super(CourseSummariesViewTests, self).setUp()
24+
self.now = datetime.datetime.utcnow()
25+
26+
def tearDown(self):
27+
self.model.objects.all().delete()
28+
29+
def path(self, course_ids=None, fields=None):
30+
query_params = {}
31+
for query_arg, data in zip(['course_ids', 'fields'], [course_ids, fields]):
32+
if data:
33+
query_params[query_arg] = ','.join(data)
34+
query_string = '?{}'.format(urlencode(query_params))
35+
return '/api/v0/course_summaries/{}'.format(query_string)
36+
37+
def generate_data(self, course_ids=None, modes=None):
38+
"""Generate course summary data for """
39+
if course_ids is None:
40+
course_ids = CourseSamples.course_ids
41+
42+
if modes is None:
43+
modes = enrollment_modes.ALL
44+
45+
for course_id in course_ids:
46+
for mode in modes:
47+
G(self.model, course_id=course_id, catalog_course_title='Title', catalog_course='Catalog',
48+
start_date=datetime.datetime(2016, 10, 11, tzinfo=pytz.utc),
49+
end_date=datetime.datetime(2016, 12, 18, tzinfo=pytz.utc),
50+
pacing_type='instructor', availability='current', mode=mode,
51+
count=5, cumulative_count=10, count_change_7_days=1, create=self.now,)
52+
53+
def expected_summary(self, course_id, modes=None):
54+
"""Expected summary information for a course and modes to populate with data."""
55+
if modes is None:
56+
modes = enrollment_modes.ALL
57+
58+
num_modes = len(modes)
59+
count_factor = 5
60+
cumulative_count_factor = 10
61+
count_change_factor = 1
62+
summary = {
63+
'course_id': course_id,
64+
'catalog_course_title': 'Title',
65+
'catalog_course': 'Catalog',
66+
'start_date': datetime.datetime(2016, 10, 11, tzinfo=pytz.utc).strftime(settings.DATETIME_FORMAT),
67+
'end_date': datetime.datetime(2016, 12, 18, tzinfo=pytz.utc).strftime(settings.DATETIME_FORMAT),
68+
'pacing_type': 'instructor',
69+
'availability': 'current',
70+
'modes': {},
71+
'count': count_factor * num_modes,
72+
'cumulative_count': cumulative_count_factor * num_modes,
73+
'count_change_7_days': count_change_factor * num_modes,
74+
'created': self.now.strftime(settings.DATETIME_FORMAT),
75+
}
76+
summary['modes'].update({
77+
mode: {
78+
'count': count_factor,
79+
'cumulative_count': cumulative_count_factor,
80+
'count_change_7_days': count_change_factor,
81+
} for mode in modes
82+
})
83+
summary['modes'].update({
84+
mode: {
85+
'count': 0,
86+
'cumulative_count': 0,
87+
'count_change_7_days': 0,
88+
} for mode in set(enrollment_modes.ALL) - set(modes)
89+
})
90+
no_prof = summary['modes'].pop(enrollment_modes.PROFESSIONAL_NO_ID)
91+
prof = summary['modes'].get(enrollment_modes.PROFESSIONAL)
92+
prof.update({
93+
'count': prof['count'] + no_prof['count'],
94+
'cumulative_count': prof['cumulative_count'] + no_prof['cumulative_count'],
95+
'count_change_7_days': prof['count_change_7_days'] + no_prof['count_change_7_days'],
96+
})
97+
return summary
98+
99+
def all_expected_summaries(self, modes=None):
100+
if modes is None:
101+
modes = enrollment_modes.ALL
102+
return [self.expected_summary(course_id, modes) for course_id in CourseSamples.course_ids]
103+
104+
@ddt.data(
105+
None,
106+
CourseSamples.course_ids,
107+
['not/real/course'].extend(CourseSamples.course_ids),
108+
)
109+
def test_all_courses(self, course_ids):
110+
self.generate_data()
111+
response = self.authenticated_get(self.path(course_ids=course_ids))
112+
self.assertEquals(response.status_code, 200)
113+
self.assertItemsEqual(response.data, self.all_expected_summaries())
114+
115+
@ddt.data(*CourseSamples.course_ids)
116+
def test_one_course(self, course_id):
117+
self.generate_data()
118+
response = self.authenticated_get(self.path(course_ids=[course_id]))
119+
self.assertEquals(response.status_code, 200)
120+
self.assertItemsEqual(response.data, [self.expected_summary(course_id)])
121+
122+
@ddt.data(
123+
['availability'],
124+
['modes', 'course_id'],
125+
)
126+
def test_fields(self, fields):
127+
self.generate_data()
128+
response = self.authenticated_get(self.path(fields=fields))
129+
self.assertEquals(response.status_code, 200)
130+
131+
# remove fields not requested from expected results
132+
expected_summaries = self.all_expected_summaries()
133+
for expected_summary in expected_summaries:
134+
for field_to_remove in set(expected_summary.keys()) - set(fields):
135+
expected_summary.pop(field_to_remove)
136+
137+
self.assertItemsEqual(response.data, expected_summaries)
138+
139+
@ddt.data(
140+
[enrollment_modes.VERIFIED],
141+
[enrollment_modes.HONOR, enrollment_modes.PROFESSIONAL],
142+
)
143+
def test_empty_modes(self, modes):
144+
self.generate_data(modes=modes)
145+
response = self.authenticated_get(self.path())
146+
self.assertEquals(response.status_code, 200)
147+
self.assertItemsEqual(response.data, self.all_expected_summaries(modes))
148+
149+
def test_no_summaries(self):
150+
response = self.authenticated_get(self.path())
151+
self.assertEquals(response.status_code, 404)
152+
153+
def test_no_matching_courses(self):
154+
self.generate_data()
155+
response = self.authenticated_get(self.path(course_ids=['no/course/found']))
156+
self.assertEquals(response.status_code, 404)
157+
158+
@ddt.data(
159+
['malformed-course-id'],
160+
[CourseSamples.course_ids[0], 'malformed-course-id'],
161+
)
162+
def test_bad_course_id(self, course_ids):
163+
response = self.authenticated_get(self.path(course_ids=course_ids))
164+
self.verify_bad_course_id(response)

analytics_data_api/v0/tests/views/test_courses.py

Lines changed: 1 addition & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616

1717
from analytics_data_api.constants import country, enrollment_modes, genders
1818
from analytics_data_api.constants.country import get_country
19-
from analytics_data_api.v0 import models, serializers
19+
from analytics_data_api.v0 import models
2020
from analytics_data_api.v0.tests.views import CourseSamples, VerifyCsvResponseMixin
2121
from analytics_data_api.utils import get_filename_safe_course_id
2222
from analyticsdataserver.tests import TestCaseWithAuthentication
@@ -889,31 +889,3 @@ def test_make_working_link_with_missing_last_modified_date(self, course_id):
889889
'expiration_date': datetime.datetime(2014, 1, 1, tzinfo=pytz.utc).strftime(settings.DATETIME_FORMAT)
890890
}
891891
self.assertEqual(response.data, expected)
892-
893-
894-
class CourseSummariesViewTests(TestCaseWithAuthentication):
895-
model = models.CourseMetaSummaryEnrollment
896-
serializer = serializers.CourseMetaSummaryEnrollmentSerializer
897-
path = '/course_summaries'
898-
expected_summaries = []
899-
fake_course_ids = ['edX/DemoX/Demo_Course', 'edX/DemoX/2', 'edX/DemoX/3', 'edX/DemoX/4']
900-
# csv_filename_slug = u'course_summaries'
901-
902-
def setUp(self):
903-
super(CourseSummariesViewTests, self).setUp()
904-
self.generate_data()
905-
906-
def generate_data(self):
907-
for course_id in self.fake_course_ids:
908-
self.expected_summaries.append(self.serializer(
909-
G(self.model, course_id=course_id, count=10, cumulative_count=15)).data)
910-
911-
def test_get(self):
912-
response = self.authenticated_get(u'/api/v0/course_summaries/?course_ids=%s' % ','.join(self.fake_course_ids))
913-
self.assertEquals(response.status_code, 200)
914-
self.assertItemsEqual(response.data, self.expected_summaries)
915-
916-
def test_no_summaries(self):
917-
self.model.objects.all().delete()
918-
response = self.authenticated_get(u'/api/v0/course_summaries/?course_ids=%s' % ','.join(self.fake_course_ids))
919-
self.assertEquals(response.status_code, 404)

analytics_data_api/v0/tests/views/test_utils.py

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from django.test import TestCase
66

77
from analytics_data_api.v0.exceptions import CourseKeyMalformedError
8+
from analytics_data_api.v0.tests.views import CourseSamples
89
import analytics_data_api.v0.views.utils as utils
910

1011

@@ -19,11 +20,7 @@ def test_invalid_course_id(self, course_id):
1920
with self.assertRaises(CourseKeyMalformedError):
2021
utils.validate_course_id(course_id)
2122

22-
# TODO: DDT w/ the refactored CourseSamples once https://github.com/edx/edx-analytics-data-api/pull/143 merges
23-
@ddt.data(
24-
'edX/DemoX/Demo_Course',
25-
'course-v1:edX+DemoX+Demo_2014',
26-
)
23+
@ddt.data(*CourseSamples.course_ids)
2724
def test_valid_course_id(self, course_id):
2825
try:
2926
utils.validate_course_id(course_id)
@@ -38,8 +35,8 @@ def test_split_query_argument_none(self):
3835
('one,two', ['one', 'two']),
3936
)
4037
@ddt.unpack
41-
def test_split_query_argument(self, input, expected):
42-
self.assertListEqual(utils.split_query_argument(input), expected)
38+
def test_split_query_argument(self, query_args, expected):
39+
self.assertListEqual(utils.split_query_argument(query_args), expected)
4340

4441
def test_raise_404_if_none_raises_error(self):
4542
decorated_func = utils.raise_404_if_none(Mock(return_value=None))

analytics_data_api/v0/urls/course_summaries.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,6 @@
22

33
from analytics_data_api.v0.views import course_summaries as views
44

5-
USERNAME_PATTERN = r'(?P<username>[\w.+-]+)'
6-
75
urlpatterns = [
86
url(r'^course_summaries/$', views.CourseSummariesView.as_view(), name='course_summaries'),
97
]

analytics_data_api/v0/views/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from analytics_data_api.v0.exceptions import CourseNotSpecifiedError
55
import analytics_data_api.utils as utils
66

7+
78
class CourseViewMixin(object):
89
"""
910
Captures the course_id from the url and validates it.

0 commit comments

Comments
 (0)