Commit 8103ad0e by Dennis Jen

Updated endpoint to return counts per mode, updated and added tests

parent d925f797
...@@ -509,22 +509,48 @@ class CourseLearnerMetadataSerializer(serializers.Serializer): ...@@ -509,22 +509,48 @@ class CourseLearnerMetadataSerializer(serializers.Serializer):
return engagement_ranges 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() course_id = serializers.CharField()
catalog_course_title = serializers.CharField() catalog_course_title = serializers.CharField()
catalog_course = serializers.CharField() catalog_course = serializers.CharField()
start_date = serializers.DateTimeField() start_date = serializers.DateTimeField(format=settings.DATETIME_FORMAT)
end_date = serializers.DateTimeField() end_date = serializers.DateTimeField(format=settings.DATETIME_FORMAT)
pacing_type = serializers.CharField() pacing_type = serializers.CharField()
availability = serializers.CharField() availability = serializers.CharField()
mode = serializers.CharField()
count = serializers.IntegerField(default=0) count = serializers.IntegerField(default=0)
cumulative_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): class Meta(object):
model = models.CourseMetaSummaryEnrollment model = models.CourseMetaSummaryEnrollment
exclude = ('id',) exclude = ('id', 'mode')
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)
...@@ -16,7 +16,7 @@ from mock import patch, Mock ...@@ -16,7 +16,7 @@ from mock import patch, Mock
from analytics_data_api.constants import country, enrollment_modes, genders from analytics_data_api.constants import country, enrollment_modes, genders
from analytics_data_api.constants.country import get_country 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.v0.tests.views import CourseSamples, VerifyCsvResponseMixin
from analytics_data_api.utils import get_filename_safe_course_id from analytics_data_api.utils import get_filename_safe_course_id
from analyticsdataserver.tests import TestCaseWithAuthentication from analyticsdataserver.tests import TestCaseWithAuthentication
...@@ -889,31 +889,3 @@ class CourseReportDownloadViewTests(TestCaseWithAuthentication): ...@@ -889,31 +889,3 @@ class CourseReportDownloadViewTests(TestCaseWithAuthentication):
'expiration_date': datetime.datetime(2014, 1, 1, tzinfo=pytz.utc).strftime(settings.DATETIME_FORMAT) 'expiration_date': datetime.datetime(2014, 1, 1, tzinfo=pytz.utc).strftime(settings.DATETIME_FORMAT)
} }
self.assertEqual(response.data, expected) 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)
...@@ -5,6 +5,7 @@ from django.http import Http404 ...@@ -5,6 +5,7 @@ from django.http import Http404
from django.test import TestCase from django.test import TestCase
from analytics_data_api.v0.exceptions import CourseKeyMalformedError 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 import analytics_data_api.v0.views.utils as utils
...@@ -19,11 +20,7 @@ class UtilsTest(TestCase): ...@@ -19,11 +20,7 @@ class UtilsTest(TestCase):
with self.assertRaises(CourseKeyMalformedError): with self.assertRaises(CourseKeyMalformedError):
utils.validate_course_id(course_id) 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(*CourseSamples.course_ids)
@ddt.data(
'edX/DemoX/Demo_Course',
'course-v1:edX+DemoX+Demo_2014',
)
def test_valid_course_id(self, course_id): def test_valid_course_id(self, course_id):
try: try:
utils.validate_course_id(course_id) utils.validate_course_id(course_id)
...@@ -38,8 +35,8 @@ class UtilsTest(TestCase): ...@@ -38,8 +35,8 @@ class UtilsTest(TestCase):
('one,two', ['one', 'two']), ('one,two', ['one', 'two']),
) )
@ddt.unpack @ddt.unpack
def test_split_query_argument(self, input, expected): def test_split_query_argument(self, query_args, expected):
self.assertListEqual(utils.split_query_argument(input), expected) self.assertListEqual(utils.split_query_argument(query_args), expected)
def test_raise_404_if_none_raises_error(self): def test_raise_404_if_none_raises_error(self):
decorated_func = utils.raise_404_if_none(Mock(return_value=None)) decorated_func = utils.raise_404_if_none(Mock(return_value=None))
......
...@@ -2,8 +2,6 @@ from django.conf.urls import url ...@@ -2,8 +2,6 @@ from django.conf.urls import url
from analytics_data_api.v0.views import course_summaries as views from analytics_data_api.v0.views import course_summaries as views
USERNAME_PATTERN = r'(?P<username>[\w.+-]+)'
urlpatterns = [ urlpatterns = [
url(r'^course_summaries/$', views.CourseSummariesView.as_view(), name='course_summaries'), url(r'^course_summaries/$', views.CourseSummariesView.as_view(), name='course_summaries'),
] ]
...@@ -4,6 +4,7 @@ from django.utils import timezone ...@@ -4,6 +4,7 @@ from django.utils import timezone
from analytics_data_api.v0.exceptions import CourseNotSpecifiedError from analytics_data_api.v0.exceptions import CourseNotSpecifiedError
import analytics_data_api.utils as utils import analytics_data_api.utils as utils
class CourseViewMixin(object): class CourseViewMixin(object):
""" """
Captures the course_id from the url and validates it. Captures the course_id from the url and validates it.
......
""" from itertools import groupby
TODO: TBD
"""
from django.db.models import Q from django.db.models import Q
from rest_framework import generics from rest_framework import generics
from analytics_data_api.constants import enrollment_modes
from analytics_data_api.v0 import models, serializers from analytics_data_api.v0 import models, serializers
from analytics_data_api.v0.views.utils import ( from analytics_data_api.v0.views.utils import (
raise_404_if_none, raise_404_if_none,
...@@ -16,7 +15,7 @@ from analytics_data_api.v0.views.utils import ( ...@@ -16,7 +15,7 @@ from analytics_data_api.v0.views.utils import (
class CourseSummariesView(generics.ListAPIView): class CourseSummariesView(generics.ListAPIView):
""" """
TBD. Returns summary information for courses.
**Example Request** **Example Request**
...@@ -24,14 +23,32 @@ class CourseSummariesView(generics.ListAPIView): ...@@ -24,14 +23,32 @@ class CourseSummariesView(generics.ListAPIView):
**Response Values** **Response Values**
TBD Returns the count of each gender specified by users:
* course_id: The ID of the course for which data is returned.
* catalog_course_title: The name of the course.
* catalog_course: Course identifier without run.
* start_date: The date and time that the course begins
* end_date: The date and time that the course ends
* pacing_type: The type of pacing for this course
* availability: Availability status of the course
* count: The total count of currently enrolled learners across modes.
* cumulative_count: The total cumulative total of all users ever enrolled across modes.
* count_change_7_days: Total difference in enrollment counts over the past 7 days across modes.
* modes: For each enrollment mode, the count, cumulative_count, and count_change_7_days.
* created: The date the counts were computed.
**Parameters** **Parameters**
You can specify the course IDs for which you want data. Results can be filed to the course IDs specified or limited to the fields.
course_ids -- The comma-separated course identifiers for which user data is requested. course_ids -- The comma-separated course identifiers for which summaries are requested.
For example, edX/DemoX/Demo_Course,course-v1:edX+DemoX+Demo_2016 For example, 'edX/DemoX/Demo_Course,course-v1:edX+DemoX+Demo_2016'. Default is to
return call courses.
fields -- The comma-separated fields to return in the response.
For example, 'course_id,created_mode'. Default is to return all fields.
fields -- Fields to include in response. Default is all.
""" """
course_ids = None course_ids = None
...@@ -39,6 +56,13 @@ class CourseSummariesView(generics.ListAPIView): ...@@ -39,6 +56,13 @@ class CourseSummariesView(generics.ListAPIView):
serializer_class = serializers.CourseMetaSummaryEnrollmentSerializer serializer_class = serializers.CourseMetaSummaryEnrollmentSerializer
model = models.CourseMetaSummaryEnrollment model = models.CourseMetaSummaryEnrollment
def get_serializer(self, *args, **kwargs):
kwargs.update({
'context': self.get_serializer_context(),
'fields': self.fields,
})
return self.get_serializer_class()(*args, **kwargs)
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
query_params = self.request.query_params query_params = self.request.query_params
self.fields = split_query_argument(query_params.get('fields')) self.fields = split_query_argument(query_params.get('fields'))
...@@ -49,12 +73,61 @@ class CourseSummariesView(generics.ListAPIView): ...@@ -49,12 +73,61 @@ class CourseSummariesView(generics.ListAPIView):
return super(CourseSummariesView, self).get(request, *args, **kwargs) return super(CourseSummariesView, self).get(request, *args, **kwargs)
def default_summary(self, course_id, count_fields):
"""Default summary with fields populated to default levels."""
summary = {
'course_id': course_id,
'created': None,
'modes': {},
}
summary.update({field: 0 for field in count_fields})
summary['modes'].update({
mode: {
count_field: 0 for count_field in count_fields
} for mode in enrollment_modes.ALL
})
return summary
def group_by_mode(self, queryset):
"""Return enrollment counts for nested in each mode and top-level enrollment counts."""
formatted_data = []
for course_id, summaries in groupby(queryset, lambda x: (x.course_id)):
count_fields = ['count', 'count_change_7_days', 'cumulative_count']
item = self.default_summary(course_id, count_fields)
# aggregate the enrollment counts for each mode
for summary in summaries:
summary_meta_fields = ['catalog_course_title', 'catalog_course', 'start_date', 'end_date',
'pacing_type', 'availability']
item.update({field: getattr(summary, field) for field in summary_meta_fields})
item['modes'].update({
summary.mode: {field: getattr(summary, field) for field in count_fields}
})
# treat the most recent as the authoritative created date -- should be all the same
item['created'] = max(summary.created, item['created']) if item['created'] else summary.created
# update totals for all counts
item.update({field: item[field] + getattr(summary, field) for field in count_fields})
# Merge professional with non verified professional
modes = item['modes']
prof_no_id_mode = modes.pop(enrollment_modes.PROFESSIONAL_NO_ID, {})
prof_mode = modes[enrollment_modes.PROFESSIONAL]
for count_key in count_fields:
prof_mode[count_key] = prof_mode.get(count_key, 0) + prof_no_id_mode.pop(count_key, 0)
formatted_data.append(item)
return formatted_data
@raise_404_if_none @raise_404_if_none
def get_queryset(self): def get_queryset(self):
if self.course_ids: if self.course_ids:
# create an OR query for course IDs that match # create an OR query for course IDs that match
query = reduce(lambda q, course_id: q|Q(course_id=course_id), self.course_ids, Q()) query = reduce(lambda q, course_id: q | Q(course_id=course_id), self.course_ids, Q())
queryset = self.model.objects.filter(query) queryset = self.model.objects.filter(query)
else: else:
queryset = self.model.objects.all() queryset = self.model.objects.all()
return queryset
return self.group_by_mode(queryset)
...@@ -8,6 +8,7 @@ djangorestframework-csv==1.4.1 # BSD ...@@ -8,6 +8,7 @@ djangorestframework-csv==1.4.1 # BSD
django-storages==1.4.1 # BSD django-storages==1.4.1 # BSD
elasticsearch-dsl==0.0.11 # Apache 2.0 elasticsearch-dsl==0.0.11 # Apache 2.0
ordered-set==2.0.1 # MIT ordered-set==2.0.1 # MIT
tqdm==4.10.0 # MIT
# markdown is used by swagger for rendering the api docs # markdown is used by swagger for rendering the api docs
Markdown==2.6.6 # BSD Markdown==2.6.6 # BSD
......
# Local development dependencies go here # Local development dependencies go here
-r base.txt -r base.txt
tqdm==4.10.0 # MIT
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment