Skip to content

Commit

Permalink
Updated endpoint to return counts per mode, updated and added tests
Browse files Browse the repository at this point in the history
  • Loading branch information
dsjen committed Dec 2, 2016
1 parent d925f79 commit 8103ad0
Show file tree
Hide file tree
Showing 9 changed files with 287 additions and 56 deletions.
40 changes: 33 additions & 7 deletions analytics_data_api/v0/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -509,22 +509,48 @@ def get_engagement_ranges(self, obj):
return engagement_ranges


class CourseMetaSummaryEnrollmentSerializer(ModelSerializerWithCreatedField):
class DynamicFieldsModelSerializer(serializers.ModelSerializer):
"""
Serializer for problems.
A ModelSerializer that takes an additional `fields` argument that controls which
fields should be displayed.
Blatantly taken from http://www.django-rest-framework.org/api-guide/serializers/#dynamically-modifying-fields
"""

def __init__(self, *args, **kwargs):
# Don't pass the 'fields' arg up to the superclass
fields = kwargs.pop('fields', None)

# Instantiate the superclass normally
super(DynamicFieldsModelSerializer, self).__init__(*args, **kwargs)

if fields is not None:
# Drop any fields that are not specified in the `fields` argument.
allowed = set(fields)
existing = set(self.fields.keys())
for field_name in existing - allowed:
self.fields.pop(field_name)


class CourseMetaSummaryEnrollmentSerializer(ModelSerializerWithCreatedField, DynamicFieldsModelSerializer):
"""
Serializer for course and enrollment counts per mode.
"""
course_id = serializers.CharField()
catalog_course_title = serializers.CharField()
catalog_course = serializers.CharField()
start_date = serializers.DateTimeField()
end_date = serializers.DateTimeField()
start_date = serializers.DateTimeField(format=settings.DATETIME_FORMAT)
end_date = serializers.DateTimeField(format=settings.DATETIME_FORMAT)
pacing_type = serializers.CharField()
availability = serializers.CharField()
mode = serializers.CharField()
count = serializers.IntegerField(default=0)
cumulative_count = serializers.IntegerField(default=0)
count_change_7_days = serializers.IntegerField(default=0) # TODO: 0 as default?
count_change_7_days = serializers.IntegerField(default=0)
modes = serializers.SerializerMethodField()

def get_modes(self, obj):
return obj.get('modes', None)

class Meta(object):
model = models.CourseMetaSummaryEnrollment
exclude = ('id',)
exclude = ('id', 'mode')
164 changes: 164 additions & 0 deletions analytics_data_api/v0/tests/views/test_course_summaries.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import datetime
from urllib import urlencode

import ddt
from django_dynamic_fixture import G
import pytz

from django.conf import settings

from analytics_data_api.constants import enrollment_modes
from analytics_data_api.v0 import models, serializers
from analytics_data_api.v0.tests.views import CourseSamples, VerifyCourseIdMixin
from analyticsdataserver.tests import TestCaseWithAuthentication


@ddt.ddt
class CourseSummariesViewTests(VerifyCourseIdMixin, TestCaseWithAuthentication):
model = models.CourseMetaSummaryEnrollment
serializer = serializers.CourseMetaSummaryEnrollmentSerializer
expected_summaries = []

def setUp(self):
super(CourseSummariesViewTests, self).setUp()
self.now = datetime.datetime.utcnow()

def tearDown(self):
self.model.objects.all().delete()

def path(self, course_ids=None, fields=None):
query_params = {}
for query_arg, data in zip(['course_ids', 'fields'], [course_ids, fields]):
if data:
query_params[query_arg] = ','.join(data)
query_string = '?{}'.format(urlencode(query_params))
return '/api/v0/course_summaries/{}'.format(query_string)

def generate_data(self, course_ids=None, modes=None):
"""Generate course summary data for """
if course_ids is None:
course_ids = CourseSamples.course_ids

if modes is None:
modes = enrollment_modes.ALL

for course_id in course_ids:
for mode in modes:
G(self.model, course_id=course_id, catalog_course_title='Title', catalog_course='Catalog',
start_date=datetime.datetime(2016, 10, 11, tzinfo=pytz.utc),
end_date=datetime.datetime(2016, 12, 18, tzinfo=pytz.utc),
pacing_type='instructor', availability='current', mode=mode,
count=5, cumulative_count=10, count_change_7_days=1, create=self.now,)

def expected_summary(self, course_id, modes=None):
"""Expected summary information for a course and modes to populate with data."""
if modes is None:
modes = enrollment_modes.ALL

num_modes = len(modes)
count_factor = 5
cumulative_count_factor = 10
count_change_factor = 1
summary = {
'course_id': course_id,
'catalog_course_title': 'Title',
'catalog_course': 'Catalog',
'start_date': datetime.datetime(2016, 10, 11, tzinfo=pytz.utc).strftime(settings.DATETIME_FORMAT),
'end_date': datetime.datetime(2016, 12, 18, tzinfo=pytz.utc).strftime(settings.DATETIME_FORMAT),
'pacing_type': 'instructor',
'availability': 'current',
'modes': {},
'count': count_factor * num_modes,
'cumulative_count': cumulative_count_factor * num_modes,
'count_change_7_days': count_change_factor * num_modes,
'created': self.now.strftime(settings.DATETIME_FORMAT),
}
summary['modes'].update({
mode: {
'count': count_factor,
'cumulative_count': cumulative_count_factor,
'count_change_7_days': count_change_factor,
} for mode in modes
})
summary['modes'].update({
mode: {
'count': 0,
'cumulative_count': 0,
'count_change_7_days': 0,
} for mode in set(enrollment_modes.ALL) - set(modes)
})
no_prof = summary['modes'].pop(enrollment_modes.PROFESSIONAL_NO_ID)
prof = summary['modes'].get(enrollment_modes.PROFESSIONAL)
prof.update({
'count': prof['count'] + no_prof['count'],
'cumulative_count': prof['cumulative_count'] + no_prof['cumulative_count'],
'count_change_7_days': prof['count_change_7_days'] + no_prof['count_change_7_days'],
})
return summary

def all_expected_summaries(self, modes=None):
if modes is None:
modes = enrollment_modes.ALL
return [self.expected_summary(course_id, modes) for course_id in CourseSamples.course_ids]

@ddt.data(
None,
CourseSamples.course_ids,
['not/real/course'].extend(CourseSamples.course_ids),
)
def test_all_courses(self, course_ids):
self.generate_data()
response = self.authenticated_get(self.path(course_ids=course_ids))
self.assertEquals(response.status_code, 200)
self.assertItemsEqual(response.data, self.all_expected_summaries())

@ddt.data(*CourseSamples.course_ids)
def test_one_course(self, course_id):
self.generate_data()
response = self.authenticated_get(self.path(course_ids=[course_id]))
self.assertEquals(response.status_code, 200)
self.assertItemsEqual(response.data, [self.expected_summary(course_id)])

@ddt.data(
['availability'],
['modes', 'course_id'],
)
def test_fields(self, fields):
self.generate_data()
response = self.authenticated_get(self.path(fields=fields))
self.assertEquals(response.status_code, 200)

# remove fields not requested from expected results
expected_summaries = self.all_expected_summaries()
for expected_summary in expected_summaries:
for field_to_remove in set(expected_summary.keys()) - set(fields):
expected_summary.pop(field_to_remove)

self.assertItemsEqual(response.data, expected_summaries)

@ddt.data(
[enrollment_modes.VERIFIED],
[enrollment_modes.HONOR, enrollment_modes.PROFESSIONAL],
)
def test_empty_modes(self, modes):
self.generate_data(modes=modes)
response = self.authenticated_get(self.path())
self.assertEquals(response.status_code, 200)
self.assertItemsEqual(response.data, self.all_expected_summaries(modes))

def test_no_summaries(self):
response = self.authenticated_get(self.path())
self.assertEquals(response.status_code, 404)

def test_no_matching_courses(self):
self.generate_data()
response = self.authenticated_get(self.path(course_ids=['no/course/found']))
self.assertEquals(response.status_code, 404)

@ddt.data(
['malformed-course-id'],
[CourseSamples.course_ids[0], 'malformed-course-id'],
)
def test_bad_course_id(self, course_ids):
response = self.authenticated_get(self.path(course_ids=course_ids))
self.verify_bad_course_id(response)
30 changes: 1 addition & 29 deletions analytics_data_api/v0/tests/views/test_courses.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

from analytics_data_api.constants import country, enrollment_modes, genders
from analytics_data_api.constants.country import get_country
from analytics_data_api.v0 import models, serializers
from analytics_data_api.v0 import models
from analytics_data_api.v0.tests.views import CourseSamples, VerifyCsvResponseMixin
from analytics_data_api.utils import get_filename_safe_course_id
from analyticsdataserver.tests import TestCaseWithAuthentication
Expand Down Expand Up @@ -889,31 +889,3 @@ def test_make_working_link_with_missing_last_modified_date(self, course_id):
'expiration_date': datetime.datetime(2014, 1, 1, tzinfo=pytz.utc).strftime(settings.DATETIME_FORMAT)
}
self.assertEqual(response.data, expected)


class CourseSummariesViewTests(TestCaseWithAuthentication):
model = models.CourseMetaSummaryEnrollment
serializer = serializers.CourseMetaSummaryEnrollmentSerializer
path = '/course_summaries'
expected_summaries = []
fake_course_ids = ['edX/DemoX/Demo_Course', 'edX/DemoX/2', 'edX/DemoX/3', 'edX/DemoX/4']
# csv_filename_slug = u'course_summaries'

def setUp(self):
super(CourseSummariesViewTests, self).setUp()
self.generate_data()

def generate_data(self):
for course_id in self.fake_course_ids:
self.expected_summaries.append(self.serializer(
G(self.model, course_id=course_id, count=10, cumulative_count=15)).data)

def test_get(self):
response = self.authenticated_get(u'/api/v0/course_summaries/?course_ids=%s' % ','.join(self.fake_course_ids))
self.assertEquals(response.status_code, 200)
self.assertItemsEqual(response.data, self.expected_summaries)

def test_no_summaries(self):
self.model.objects.all().delete()
response = self.authenticated_get(u'/api/v0/course_summaries/?course_ids=%s' % ','.join(self.fake_course_ids))
self.assertEquals(response.status_code, 404)
11 changes: 4 additions & 7 deletions analytics_data_api/v0/tests/views/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from django.test import TestCase

from analytics_data_api.v0.exceptions import CourseKeyMalformedError
from analytics_data_api.v0.tests.views import CourseSamples
import analytics_data_api.v0.views.utils as utils


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

# TODO: DDT w/ the refactored CourseSamples once https://github.com/edx/edx-analytics-data-api/pull/143 merges
@ddt.data(
'edX/DemoX/Demo_Course',
'course-v1:edX+DemoX+Demo_2014',
)
@ddt.data(*CourseSamples.course_ids)
def test_valid_course_id(self, course_id):
try:
utils.validate_course_id(course_id)
Expand All @@ -38,8 +35,8 @@ def test_split_query_argument_none(self):
('one,two', ['one', 'two']),
)
@ddt.unpack
def test_split_query_argument(self, input, expected):
self.assertListEqual(utils.split_query_argument(input), expected)
def test_split_query_argument(self, query_args, expected):
self.assertListEqual(utils.split_query_argument(query_args), expected)

def test_raise_404_if_none_raises_error(self):
decorated_func = utils.raise_404_if_none(Mock(return_value=None))
Expand Down
2 changes: 0 additions & 2 deletions analytics_data_api/v0/urls/course_summaries.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@

from analytics_data_api.v0.views import course_summaries as views

USERNAME_PATTERN = r'(?P<username>[\w.+-]+)'

urlpatterns = [
url(r'^course_summaries/$', views.CourseSummariesView.as_view(), name='course_summaries'),
]
1 change: 1 addition & 0 deletions analytics_data_api/v0/views/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from analytics_data_api.v0.exceptions import CourseNotSpecifiedError
import analytics_data_api.utils as utils


class CourseViewMixin(object):
"""
Captures the course_id from the url and validates it.
Expand Down
Loading

0 comments on commit 8103ad0

Please sign in to comment.