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):
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')
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
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
......@@ -889,31 +889,3 @@ class CourseReportDownloadViewTests(TestCaseWithAuthentication):
'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)
......@@ -5,6 +5,7 @@ from django.http import Http404
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
......@@ -19,11 +20,7 @@ class UtilsTest(TestCase):
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)
......@@ -38,8 +35,8 @@ class UtilsTest(TestCase):
('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))
......
......@@ -2,8 +2,6 @@ from django.conf.urls import url
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'),
]
......@@ -4,6 +4,7 @@ from django.utils import timezone
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.
......
"""
TODO: TBD
"""
from itertools import groupby
from django.db.models import Q
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.views.utils import (
raise_404_if_none,
......@@ -16,7 +15,7 @@ from analytics_data_api.v0.views.utils import (
class CourseSummariesView(generics.ListAPIView):
"""
TBD.
Returns summary information for courses.
**Example Request**
......@@ -24,14 +23,32 @@ class CourseSummariesView(generics.ListAPIView):
**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**
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.
For example, edX/DemoX/Demo_Course,course-v1:edX+DemoX+Demo_2016
course_ids -- The comma-separated course identifiers for which summaries are requested.
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
......@@ -39,6 +56,13 @@ class CourseSummariesView(generics.ListAPIView):
serializer_class = serializers.CourseMetaSummaryEnrollmentSerializer
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):
query_params = self.request.query_params
self.fields = split_query_argument(query_params.get('fields'))
......@@ -49,12 +73,61 @@ class CourseSummariesView(generics.ListAPIView):
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
def get_queryset(self):
if self.course_ids:
# 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)
else:
queryset = self.model.objects.all()
return queryset
return self.group_by_mode(queryset)
......@@ -8,6 +8,7 @@ djangorestframework-csv==1.4.1 # BSD
django-storages==1.4.1 # BSD
elasticsearch-dsl==0.0.11 # Apache 2.0
ordered-set==2.0.1 # MIT
tqdm==4.10.0 # MIT
# markdown is used by swagger for rendering the api docs
Markdown==2.6.6 # BSD
......
# Local development dependencies go here
-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