Commit e9c0c069 by Dennis Jen Committed by Daniel Friedman

Added fields for learner endpoints.

parent 9fb50c73
...@@ -3,7 +3,9 @@ from itertools import groupby ...@@ -3,7 +3,9 @@ from itertools import groupby
from django.conf import settings from django.conf import settings
from django.db import models from django.db import models
from django.db.models import Sum from django.db.models import Sum
from elasticsearch_dsl import DocType, Q # some fields (e.g. Float, Integer) are dynamic and your IDE may highlight them as unavailable
from elasticsearch_dsl import Date, DocType, Float, Integer, Q, String
from analytics_data_api.constants import country, engagement_entity_types, genders, learner from analytics_data_api.constants import country, engagement_entity_types, genders, learner
...@@ -214,6 +216,27 @@ class Video(BaseVideo): ...@@ -214,6 +216,27 @@ class Video(BaseVideo):
class RosterEntry(DocType): class RosterEntry(DocType):
course_id = String()
username = String()
name = String()
email = String()
enrollment_mode = String()
cohort = String()
segments = String(fields={'raw': String()})
problems_attempted = Integer()
problems_completed = Integer()
problem_attempts_per_completed = Float()
# Useful for ordering problem_attempts_per_completed (because results can include null, which is
# different from zero). attempt_ratio_order is equal to the number of problem attempts if
# problem_attempts_per_completed is > 1 and set to -problem_attempts if
# problem_attempts_per_completed = 1.
attempt_ratio_order = Integer()
discussions_contributed = Integer()
videos_watched = Integer()
enrollment_date = Date()
last_updated = Date()
# pylint: disable=old-style-class # pylint: disable=old-style-class
class Meta: class Meta:
index = settings.ELASTICSEARCH_LEARNERS_INDEX index = settings.ELASTICSEARCH_LEARNERS_INDEX
...@@ -230,8 +253,7 @@ class RosterEntry(DocType): ...@@ -230,8 +253,7 @@ class RosterEntry(DocType):
course_id, course_id,
segments=None, segments=None,
ignore_segments=None, ignore_segments=None,
# TODO: enable during https://openedx.atlassian.net/browse/AN-6319 cohort=None,
# cohort=None,
enrollment_mode=None, enrollment_mode=None,
text_search=None, text_search=None,
order_by='username', order_by='username',
...@@ -251,13 +273,14 @@ class RosterEntry(DocType): ...@@ -251,13 +273,14 @@ class RosterEntry(DocType):
segment=segment, segments=', '.join(learner.SEGMENTS) segment=segment, segments=', '.join(learner.SEGMENTS)
)) ))
order_by_options = ( order_by_options = (
'username', 'email', 'discussions_contributed', 'problems_attempted', 'problems_completed', 'videos_viewed' 'username', 'email', 'discussions_contributed', 'problems_attempted', 'problems_completed',
'attempt_ratio_order', 'videos_viewed'
) )
sort_order_options = ('asc', 'desc')
if order_by not in order_by_options: if order_by not in order_by_options:
raise ValueError("order_by value '{order_by}' must be one of: ({order_by_options})".format( raise ValueError("order_by value '{order_by}' must be one of: ({order_by_options})".format(
order_by=order_by, order_by_options=', '.join(order_by_options) order_by=order_by, order_by_options=', '.join(order_by_options)
)) ))
sort_order_options = ('asc', 'desc')
if sort_order not in sort_order_options: if sort_order not in sort_order_options:
raise ValueError("sort_order value '{sort_order}' must be one of: ({sort_order_options})".format( raise ValueError("sort_order value '{sort_order}' must be one of: ({sort_order_options})".format(
sort_order=sort_order, sort_order_options=', '.join(sort_order_options) sort_order=sort_order, sort_order_options=', '.join(sort_order_options)
...@@ -272,9 +295,8 @@ class RosterEntry(DocType): ...@@ -272,9 +295,8 @@ class RosterEntry(DocType):
elif ignore_segments: elif ignore_segments:
for segment in ignore_segments: for segment in ignore_segments:
search = search.query(~Q('term', segments=segment)) search = search.query(~Q('term', segments=segment))
# TODO: enable during https://openedx.atlassian.net/browse/AN-6319 if cohort:
# if cohort: search = search.query('term', cohort=cohort)
# search = search.query('term', cohort=cohort)
if enrollment_mode: if enrollment_mode:
search = search.query('term', enrollment_mode=enrollment_mode) search = search.query('term', enrollment_mode=enrollment_mode)
if text_search: if text_search:
...@@ -309,8 +331,7 @@ class RosterEntry(DocType): ...@@ -309,8 +331,7 @@ class RosterEntry(DocType):
search.query = Q('bool', must=[Q('term', course_id=course_id)]) search.query = Q('bool', must=[Q('term', course_id=course_id)])
search.aggs.bucket('enrollment_modes', 'terms', field='enrollment_mode') search.aggs.bucket('enrollment_modes', 'terms', field='enrollment_mode')
search.aggs.bucket('segments', 'terms', field='segments') search.aggs.bucket('segments', 'terms', field='segments')
# TODO: enable during https://openedx.atlassian.net/browse/AN-6319 search.aggs.bucket('cohorts', 'terms', field='cohort')
# search.aggs.bucket('group_by_cohorts', 'terms', field='cohort')
response = search.execute() response = search.execute()
# Build up the map of aggregation name to count # Build up the map of aggregation name to count
aggregations = { aggregations = {
......
...@@ -317,7 +317,7 @@ class VideoTimelineSerializer(ModelSerializerWithCreatedField): ...@@ -317,7 +317,7 @@ class VideoTimelineSerializer(ModelSerializerWithCreatedField):
) )
class LearnerSerializer(serializers.Serializer): class LearnerSerializer(serializers.Serializer, DefaultIfNoneMixin):
username = serializers.CharField() username = serializers.CharField()
enrollment_mode = serializers.CharField() enrollment_mode = serializers.CharField()
name = serializers.CharField() name = serializers.CharField()
...@@ -325,11 +325,9 @@ class LearnerSerializer(serializers.Serializer): ...@@ -325,11 +325,9 @@ class LearnerSerializer(serializers.Serializer):
email = serializers.CharField() email = serializers.CharField()
segments = serializers.Field(source='segments') segments = serializers.Field(source='segments')
engagements = serializers.SerializerMethodField('get_engagements') engagements = serializers.SerializerMethodField('get_engagements')
enrollment_date = serializers.DateField(format=settings.DATE_FORMAT)
# TODO: add these back in when the index returns them last_updated = serializers.DateField(format=settings.DATE_FORMAT)
# enrollment_date = serializers.DateField(format=settings.DATE_FORMAT, allow_empty=True) cohort = serializers.CharField()
# last_updated = serializers.DateField(format=settings.DATE_FORMAT)
# cohort = serializers.CharField(allow_none=True)
def get_account_url(self, obj): def get_account_url(self, obj):
if settings.LMS_USER_ACCOUNT_BASE_URL: if settings.LMS_USER_ACCOUNT_BASE_URL:
...@@ -342,10 +340,17 @@ class LearnerSerializer(serializers.Serializer): ...@@ -342,10 +340,17 @@ class LearnerSerializer(serializers.Serializer):
Add the engagement totals. Add the engagement totals.
""" """
engagements = {} engagements = {}
for entity_type in engagement_entity_types.AGGREGATE_TYPES:
for event in engagement_events.EVENTS[entity_type]: # fill in these fields will 0 if values not returned/found
metric = '{0}_{1}'.format(entity_type, event) default_if_none_fields = ['discussions_contributed', 'problems_attempted',
engagements[metric] = getattr(obj, metric, 0) 'problems_completed', 'videos_viewed']
for field in default_if_none_fields:
engagements[field] = self.default_if_none(getattr(obj, field, None), 0)
# preserve null values for problem attempts per completed
no_default_field = 'problem_attempts_per_completed'
engagements[no_default_field] = getattr(obj, no_default_field, None)
return engagements return engagements
...@@ -424,8 +429,7 @@ class EnagementRangeMetricSerializer(serializers.Serializer): ...@@ -424,8 +429,7 @@ class EnagementRangeMetricSerializer(serializers.Serializer):
class CourseLearnerMetadataSerializer(serializers.Serializer): class CourseLearnerMetadataSerializer(serializers.Serializer):
enrollment_modes = serializers.SerializerMethodField('get_enrollment_modes') enrollment_modes = serializers.SerializerMethodField('get_enrollment_modes')
segments = serializers.SerializerMethodField('get_segments') segments = serializers.SerializerMethodField('get_segments')
# TODO: enable during https://openedx.atlassian.net/browse/AN-6319 cohorts = serializers.SerializerMethodField('get_cohorts')
# cohorts = serializers.SerializerMethodField('get_cohorts')
engagement_ranges = serializers.SerializerMethodField('get_engagement_ranges') engagement_ranges = serializers.SerializerMethodField('get_engagement_ranges')
def get_enrollment_modes(self, obj): def get_enrollment_modes(self, obj):
...@@ -434,9 +438,8 @@ class CourseLearnerMetadataSerializer(serializers.Serializer): ...@@ -434,9 +438,8 @@ class CourseLearnerMetadataSerializer(serializers.Serializer):
def get_segments(self, obj): def get_segments(self, obj):
return obj['es_data']['segments'] return obj['es_data']['segments']
# TODO: enable during https://openedx.atlassian.net/browse/AN-6319 def get_cohorts(self, obj):
# def get_cohorts(self, obj): return obj['es_data']['cohorts']
# return obj['es_data']['cohorts']
def get_engagement_ranges(self, obj): def get_engagement_ranges(self, obj):
query_set = obj['engagement_ranges'] query_set = obj['engagement_ranges']
......
...@@ -25,6 +25,9 @@ class LearnerAPITestMixin(object): ...@@ -25,6 +25,9 @@ class LearnerAPITestMixin(object):
"""Creates the index and defines a mapping.""" """Creates the index and defines a mapping."""
super(LearnerAPITestMixin, self).setUp() super(LearnerAPITestMixin, self).setUp()
self._es = Elasticsearch([settings.ELASTICSEARCH_LEARNERS_HOST]) self._es = Elasticsearch([settings.ELASTICSEARCH_LEARNERS_HOST])
# delete index if for some reason the index wasn't deleted in tearDown
if self._es.indices.exists(index=settings.ELASTICSEARCH_LEARNERS_INDEX):
self._es.indices.delete(index=settings.ELASTICSEARCH_LEARNERS_INDEX)
self._es.indices.create(index=settings.ELASTICSEARCH_LEARNERS_INDEX) self._es.indices.create(index=settings.ELASTICSEARCH_LEARNERS_INDEX)
self._es.indices.put_mapping( self._es.indices.put_mapping(
index=settings.ELASTICSEARCH_LEARNERS_INDEX, index=settings.ELASTICSEARCH_LEARNERS_INDEX,
...@@ -61,12 +64,21 @@ class LearnerAPITestMixin(object): ...@@ -61,12 +64,21 @@ class LearnerAPITestMixin(object):
'problems_completed': { 'problems_completed': {
'type': 'integer', 'doc_values': True 'type': 'integer', 'doc_values': True
}, },
'attempts_per_problem_completed': { 'problem_attempts_per_completed': {
'type': 'float', 'doc_values': True 'type': 'float', 'doc_values': True
}, },
'attempt_ratio_order': {
'type': 'integer', 'doc_values': True
},
'videos_viewed': { 'videos_viewed': {
'type': 'integer', 'doc_values': True 'type': 'integer', 'doc_values': True
} },
'enrollment_date': {
'type': 'date', 'doc_values': True
},
'last_updated': {
'type': 'date', 'doc_values': True
},
} }
} }
) )
...@@ -84,12 +96,15 @@ class LearnerAPITestMixin(object): ...@@ -84,12 +96,15 @@ class LearnerAPITestMixin(object):
email=None, email=None,
enrollment_mode='honor', enrollment_mode='honor',
segments=None, segments=None,
cohort='', cohort='Team edX',
discussions_contributed=0, discussions_contributed=0,
problems_attempted=0, problems_attempted=0,
problems_completed=0, problems_completed=0,
attempts_per_problem_completed=0, problem_attempts_per_completed=None,
videos_viewed=0 attempt_ratio_order=0,
videos_viewed=0,
enrollment_date='2015-01-28',
last_updated='2015-01-28'
): ):
"""Create a single learner roster entry in the elasticsearch index.""" """Create a single learner roster entry in the elasticsearch index."""
self._es.create( self._es.create(
...@@ -106,8 +121,11 @@ class LearnerAPITestMixin(object): ...@@ -106,8 +121,11 @@ class LearnerAPITestMixin(object):
'discussions_contributed': discussions_contributed, 'discussions_contributed': discussions_contributed,
'problems_attempted': problems_attempted, 'problems_attempted': problems_attempted,
'problems_completed': problems_completed, 'problems_completed': problems_completed,
'attempts_per_problem_completed': attempts_per_problem_completed, 'problem_attempts_per_completed': problem_attempts_per_completed,
'videos_viewed': videos_viewed 'attempt_ratio_order': attempt_ratio_order,
'videos_viewed': videos_viewed,
'enrollment_date': enrollment_date,
'last_updated': last_updated
} }
) )
...@@ -124,42 +142,59 @@ class LearnerAPITestMixin(object): ...@@ -124,42 +142,59 @@ class LearnerAPITestMixin(object):
self._es.indices.refresh(index=settings.ELASTICSEARCH_LEARNERS_INDEX) self._es.indices.refresh(index=settings.ELASTICSEARCH_LEARNERS_INDEX)
@ddt.ddt
class LearnerTests(VerifyCourseIdMixin, LearnerAPITestMixin, TestCaseWithAuthentication): class LearnerTests(VerifyCourseIdMixin, LearnerAPITestMixin, TestCaseWithAuthentication):
"""Tests for the single learner endpoint.""" """Tests for the single learner endpoint."""
path_template = '/api/v0/learners/{}/?course_id={}' path_template = '/api/v0/learners/{}/?course_id={}'
def setUp(self): @ddt.data(
super(LearnerTests, self).setUp() ('ed_xavier', 'Edward Xavier', 'edX/DemoX/Demo_Course', 'honor', ['has_potential'], 'Team edX',
43, 3, 6, 0, 8.4, 2, '2015-04-24', '2015-08-05'),
('ed_xavier', 'Edward Xavier', 'edX/DemoX/Demo_Course', 'verified', None, None,
None, None, None, None, None, None, None, None),
)
@ddt.unpack
def test_get_user(self, username, name, course_id, enrollment_mode, segments, cohort, problems_attempted,
problems_completed, videos_viewed, discussions_contributed, problem_attempts_per_completed,
attempt_ratio_order, enrollment_date, last_updated):
self.create_learners([{ self.create_learners([{
"username": "ed_xavier", "username": username,
"name": "Edward Xavier", "name": name,
"course_id": "edX/DemoX/Demo_Course", "course_id": course_id,
"segments": ["has_potential"], "enrollment_mode": enrollment_mode,
"problems_attempted": 43, "segments": segments,
"problems_completed": 3, "cohort": cohort,
"videos_viewed": 6, "problems_attempted": problems_attempted,
"discussions_contributed": 0 "problems_completed": problems_completed,
"videos_viewed": videos_viewed,
"discussions_contributed": discussions_contributed,
"problem_attempts_per_completed": problem_attempts_per_completed,
"attempt_ratio_order": attempt_ratio_order,
"enrollment_date": enrollment_date,
"last_updated": last_updated,
}]) }])
def test_get_user(self): response = self.authenticated_get(self.path_template.format(username, course_id))
user_name = 'ed_xavier'
course_id = 'edX/DemoX/Demo_Course'
response = self.authenticated_get(self.path_template.format(user_name, course_id))
self.assertEquals(response.status_code, 200) self.assertEquals(response.status_code, 200)
expected = { expected = {
"username": "ed_xavier", "username": username,
"enrollment_mode": "honor", "enrollment_mode": enrollment_mode,
"name": "Edward Xavier", "name": name,
"email": "ed_xavier@example.com", "email": "{}@example.com".format(username),
"account_url": "http://lms-host/ed_xavier", "account_url": "http://lms-host/{}".format(username),
"segments": ["has_potential"], "segments": segments or [],
"cohort": cohort,
"engagements": { "engagements": {
"problems_attempted": 43, "problems_attempted": problems_attempted or 0,
"problems_completed": 3, "problems_completed": problems_completed or 0,
"videos_viewed": 6, "videos_viewed": videos_viewed or 0,
"discussions_contributed": 0 "discussions_contributed": discussions_contributed or 0,
"problem_attempts_per_completed": problem_attempts_per_completed,
}, },
"enrollment_date": enrollment_date,
"last_updated": last_updated,
} }
self.assertDictEqual(expected, response.data) self.assertDictEqual(expected, response.data)
...@@ -237,25 +272,25 @@ class LearnerListTests(LearnerAPITestMixin, VerifyCourseIdMixin, TestCaseWithAut ...@@ -237,25 +272,25 @@ class LearnerListTests(LearnerAPITestMixin, VerifyCourseIdMixin, TestCaseWithAut
'course_id': self.course_id, 'course_id': self.course_id,
'enrollment_mode': 'honor', 'enrollment_mode': 'honor',
'segments': ['a', 'b'], 'segments': ['a', 'b'],
# TODO: enable during https://openedx.atlassian.net/browse/AN-6319 'cohort': 'alpha',
# 'cohort': 'alpha',
"problems_attempted": 43, "problems_attempted": 43,
"problems_completed": 3, "problems_completed": 3,
"videos_viewed": 6, "videos_viewed": 6,
"discussions_contributed": 0 "discussions_contributed": 0,
"problem_attempts_per_completed": 23.14,
}]) }])
response = self._get(self.course_id) response = self._get(self.course_id)
self.assert_learners_returned(response, [{ self.assert_learners_returned(response, [{
'username': 'user_1', 'username': 'user_1',
'enrollment_mode': 'honor', 'enrollment_mode': 'honor',
'segments': ['a', 'b'], 'segments': ['a', 'b'],
# TODO: enable during https://openedx.atlassian.net/browse/AN-6319 'cohort': 'alpha',
# 'cohort': 'alpha',
"engagements": { "engagements": {
"problems_attempted": 43, "problems_attempted": 43,
"problems_completed": 3, "problems_completed": 3,
"videos_viewed": 6, "videos_viewed": 6,
"discussions_contributed": 0 "discussions_contributed": 0,
"problem_attempts_per_completed": 23.14,
} }
}]) }])
...@@ -272,10 +307,9 @@ class LearnerListTests(LearnerAPITestMixin, VerifyCourseIdMixin, TestCaseWithAut ...@@ -272,10 +307,9 @@ class LearnerListTests(LearnerAPITestMixin, VerifyCourseIdMixin, TestCaseWithAut
('segments', ['highly_engaged', 'struggling'], 'ignore_segments', 'highly_engaged,struggling', False), ('segments', ['highly_engaged', 'struggling'], 'ignore_segments', 'highly_engaged,struggling', False),
('segments', ['highly_engaged', 'struggling'], 'ignore_segments', '', True), ('segments', ['highly_engaged', 'struggling'], 'ignore_segments', '', True),
('segments', ['highly_engaged', 'struggling'], 'ignore_segments', 'disengaging', True), ('segments', ['highly_engaged', 'struggling'], 'ignore_segments', 'disengaging', True),
# TODO: enable during https://openedx.atlassian.net/browse/AN-6319 ('cohort', 'a', 'cohort', 'a', True),
# ('cohort', 'a', 'cohort', 'a', True), ('cohort', 'a', 'cohort', '', True),
# ('cohort', 'a', 'cohort', '', True), ('cohort', 'a', 'cohort', 'b', False),
# ('cohort', 'a', 'cohort', 'b', False),
('enrollment_mode', 'a', 'enrollment_mode', 'a', True), ('enrollment_mode', 'a', 'enrollment_mode', 'a', True),
('enrollment_mode', 'a', 'enrollment_mode', '', True), ('enrollment_mode', 'a', 'enrollment_mode', '', True),
('enrollment_mode', 'a', 'enrollment_mode', 'b', False), ('enrollment_mode', 'a', 'enrollment_mode', 'b', False),
...@@ -347,6 +381,18 @@ class LearnerListTests(LearnerAPITestMixin, VerifyCourseIdMixin, TestCaseWithAut ...@@ -347,6 +381,18 @@ class LearnerListTests(LearnerAPITestMixin, VerifyCourseIdMixin, TestCaseWithAut
[{'username': 'a', 'videos_viewed': 0}, {'username': 'b', 'videos_viewed': 1}], [{'username': 'a', 'videos_viewed': 0}, {'username': 'b', 'videos_viewed': 1}],
'videos_viewed', 'desc', [{'username': 'b'}, {'username': 'a'}] 'videos_viewed', 'desc', [{'username': 'b'}, {'username': 'a'}]
), ),
(
[{'username': 'a', 'problem_attempts_per_completed': None, 'attempt_ratio_order': -1},
{'username': 'b', 'problem_attempts_per_completed': None, 'attempt_ratio_order': 0},
{'username': 'c', 'problem_attempts_per_completed': None, 'attempt_ratio_order': 1}],
'problem_attempts_per_completed', 'asc', [{'username': 'a'}, {'username': 'b'}, {'username': 'c'}]
),
(
[{'username': 'a', 'problem_attempts_per_completed': None, 'attempt_ratio_order': -1},
{'username': 'b', 'problem_attempts_per_completed': 0.0, 'attempt_ratio_order': 0},
{'username': 'c', 'problem_attempts_per_completed': 123.64, 'attempt_ratio_order': 1}],
'problem_attempts_per_completed', 'desc', [{'username': 'c'}, {'username': 'b'}, {'username': 'a'}]
),
) )
@ddt.unpack @ddt.unpack
def test_sort(self, learners, order_by, sort_order, expected_users): def test_sort(self, learners, order_by, sort_order, expected_users):
...@@ -398,7 +444,6 @@ class LearnerListTests(LearnerAPITestMixin, VerifyCourseIdMixin, TestCaseWithAut ...@@ -398,7 +444,6 @@ class LearnerListTests(LearnerAPITestMixin, VerifyCourseIdMixin, TestCaseWithAut
self.assert_learners_returned(response, [{'username': 'e'}]) self.assert_learners_returned(response, [{'username': 'e'}])
# Error cases # Error cases
@ddt.data( @ddt.data(
({}, 'course_not_specified'), ({}, 'course_not_specified'),
({'course_id': ''}, 'course_not_specified'), ({'course_id': ''}, 'course_not_specified'),
...@@ -433,10 +478,11 @@ class CourseLearnerMetadataTests(DemoCourseMixin, VerifyCourseIdMixin, ...@@ -433,10 +478,11 @@ class CourseLearnerMetadataTests(DemoCourseMixin, VerifyCourseIdMixin,
"""Helper to send a GET request to the API.""" """Helper to send a GET request to the API."""
return self.authenticated_get('/api/v0/course_learner_metadata/{}/'.format(course_id)) return self.authenticated_get('/api/v0/course_learner_metadata/{}/'.format(course_id))
def get_expected_json(self, segments, enrollment_modes): def get_expected_json(self, segments, enrollment_modes, cohorts):
expected_json = self._get_full_engagement_ranges() expected_json = self._get_full_engagement_ranges()
expected_json['segments'] = segments expected_json['segments'] = segments
expected_json['enrollment_modes'] = enrollment_modes expected_json['enrollment_modes'] = enrollment_modes
expected_json['cohorts'] = cohorts
return expected_json return expected_json
def assert_response_matches(self, response, expected_status_code, expected_data): def assert_response_matches(self, response, expected_status_code, expected_data):
...@@ -471,7 +517,9 @@ class CourseLearnerMetadataTests(DemoCourseMixin, VerifyCourseIdMixin, ...@@ -471,7 +517,9 @@ class CourseLearnerMetadataTests(DemoCourseMixin, VerifyCourseIdMixin,
expected_segments = {"highly_engaged": 0, "disengaging": 0, "struggling": 0, "inactive": 0, "unenrolled": 0} expected_segments = {"highly_engaged": 0, "disengaging": 0, "struggling": 0, "inactive": 0, "unenrolled": 0}
expected_segments.update(segments) expected_segments.update(segments)
expected = self.get_expected_json( expected = self.get_expected_json(
segments=expected_segments, enrollment_modes={'honor': len(learners)} if learners else {} segments=expected_segments,
enrollment_modes={'honor': len(learners)} if learners else {},
cohorts={'Team edX': len(learners)} if learners else {},
) )
self.assert_response_matches(self._get(self.course_id), 200, expected) self.assert_response_matches(self._get(self.course_id), 200, expected)
...@@ -485,7 +533,8 @@ class CourseLearnerMetadataTests(DemoCourseMixin, VerifyCourseIdMixin, ...@@ -485,7 +533,8 @@ class CourseLearnerMetadataTests(DemoCourseMixin, VerifyCourseIdMixin,
]) ])
expected = self.get_expected_json( expected = self.get_expected_json(
segments={'disengaging': 2, 'struggling': 1, 'highly_engaged': 0, 'inactive': 0, 'unenrolled': 0}, segments={'disengaging': 2, 'struggling': 1, 'highly_engaged': 0, 'inactive': 0, 'unenrolled': 0},
enrollment_modes={'honor': 2} enrollment_modes={'honor': 2},
cohorts={'Team edX': 2},
) )
self.assert_response_matches(self._get(self.course_id), 200, expected) self.assert_response_matches(self._get(self.course_id), 200, expected)
...@@ -510,7 +559,29 @@ class CourseLearnerMetadataTests(DemoCourseMixin, VerifyCourseIdMixin, ...@@ -510,7 +559,29 @@ class CourseLearnerMetadataTests(DemoCourseMixin, VerifyCourseIdMixin,
expected_enrollment_modes[enrollment_mode] = count expected_enrollment_modes[enrollment_mode] = count
expected = self.get_expected_json( expected = self.get_expected_json(
segments={'disengaging': 0, 'struggling': 0, 'highly_engaged': 0, 'inactive': 0, 'unenrolled': 0}, segments={'disengaging': 0, 'struggling': 0, 'highly_engaged': 0, 'inactive': 0, 'unenrolled': 0},
enrollment_modes=expected_enrollment_modes enrollment_modes=expected_enrollment_modes,
cohorts={'Team edX': len(enrollment_modes)} if enrollment_modes else {},
)
self.assert_response_matches(self._get(self.course_id), 200, expected)
@ddt.data(
[],
['Yellow'],
['Blue'],
['Red', 'Red', 'yellow team', 'yellow team', 'green'],
)
def test_cohorts(self, cohorts):
self.create_learners([
{'username': 'user_{}'.format(i), 'course_id': self.course_id, 'cohort': cohort}
for i, cohort in enumerate(cohorts)
])
expected_cohorts = {
cohort: len([mode for mode in group]) for cohort, group in groupby(cohorts)
}
expected = self.get_expected_json(
segments={'disengaging': 0, 'struggling': 0, 'highly_engaged': 0, 'inactive': 0, 'unenrolled': 0},
enrollment_modes={'honor': len(cohorts)} if cohorts else {},
cohorts=expected_cohorts,
) )
self.assert_response_matches(self._get(self.course_id), 200, expected) self.assert_response_matches(self._get(self.course_id), 200, expected)
...@@ -541,10 +612,6 @@ class CourseLearnerMetadataTests(DemoCourseMixin, VerifyCourseIdMixin, ...@@ -541,10 +612,6 @@ class CourseLearnerMetadataTests(DemoCourseMixin, VerifyCourseIdMixin,
metrics.append('{0}_{1}'.format(entity_type, event)) metrics.append('{0}_{1}'.format(entity_type, event))
return metrics return metrics
# TODO: enable during https://openedx.atlassian.net/browse/AN-6319
# def test_cohorts(self):
# pass
def test_no_engagement_ranges(self): def test_no_engagement_ranges(self):
response = self._get(self.course_id) response = self._get(self.course_id)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
......
...@@ -174,14 +174,21 @@ class LearnerListView(CourseViewMixin, generics.ListAPIView): ...@@ -174,14 +174,21 @@ class LearnerListView(CourseViewMixin, generics.ListAPIView):
""" """
self._validate_query_params() self._validate_query_params()
query_params = self.request.QUERY_PARAMS query_params = self.request.QUERY_PARAMS
# Ordering by problem_attempts_per_completed can be ambiguous because
# it's a ratio values could be infinite (e.g. divide by zero) if no problems
# were completed. Instead, sorting by attempt_ratio_order will produce
# a sensible ordering
order_by = query_params.get('order_by')
order_by = 'attempt_ratio_order' if order_by == 'problem_attempts_per_completed' else order_by
params = { params = {
'segments': split_query_argument(query_params.get('segments')), 'segments': split_query_argument(query_params.get('segments')),
'ignore_segments': split_query_argument(query_params.get('ignore_segments')), 'ignore_segments': split_query_argument(query_params.get('ignore_segments')),
# TODO: enable during https://openedx.atlassian.net/browse/AN-6319 'cohort': query_params.get('cohort'),
# 'cohort': query_params.get('cohort'),
'enrollment_mode': query_params.get('enrollment_mode'), 'enrollment_mode': query_params.get('enrollment_mode'),
'text_search': query_params.get('text_search'), 'text_search': query_params.get('text_search'),
'order_by': query_params.get('order_by'), 'order_by': order_by,
'sort_order': query_params.get('sort_order') 'sort_order': query_params.get('sort_order')
} }
# Remove None values from `params` so that we don't overwrite default # Remove None values from `params` so that we don't overwrite default
...@@ -211,6 +218,7 @@ class EngagementTimelineView(CourseViewMixin, generics.ListAPIView): ...@@ -211,6 +218,7 @@ class EngagementTimelineView(CourseViewMixin, generics.ListAPIView):
* problems_completed: Unique number of problems completed. * problems_completed: Unique number of problems completed.
* discussions_contributed: Number of discussions participated in (e.g. forum posts) * discussions_contributed: Number of discussions participated in (e.g. forum posts)
* videos_viewed: Number of videos watched. * videos_viewed: Number of videos watched.
* problem_attempts_per_completed: TBD
**Parameters** **Parameters**
......
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