Commit e9c0c069 by Dennis Jen Committed by Daniel Friedman

Added fields for learner endpoints.

parent 9fb50c73
......@@ -3,7 +3,9 @@ from itertools import groupby
from django.conf import settings
from django.db import models
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
......@@ -214,6 +216,27 @@ class Video(BaseVideo):
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
class Meta:
index = settings.ELASTICSEARCH_LEARNERS_INDEX
......@@ -230,8 +253,7 @@ class RosterEntry(DocType):
course_id,
segments=None,
ignore_segments=None,
# TODO: enable during https://openedx.atlassian.net/browse/AN-6319
# cohort=None,
cohort=None,
enrollment_mode=None,
text_search=None,
order_by='username',
......@@ -251,13 +273,14 @@ class RosterEntry(DocType):
segment=segment, segments=', '.join(learner.SEGMENTS)
))
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:
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)
))
sort_order_options = ('asc', 'desc')
if sort_order not in sort_order_options:
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)
......@@ -272,9 +295,8 @@ class RosterEntry(DocType):
elif ignore_segments:
for segment in ignore_segments:
search = search.query(~Q('term', segments=segment))
# TODO: enable during https://openedx.atlassian.net/browse/AN-6319
# if cohort:
# search = search.query('term', cohort=cohort)
if cohort:
search = search.query('term', cohort=cohort)
if enrollment_mode:
search = search.query('term', enrollment_mode=enrollment_mode)
if text_search:
......@@ -309,8 +331,7 @@ class RosterEntry(DocType):
search.query = Q('bool', must=[Q('term', course_id=course_id)])
search.aggs.bucket('enrollment_modes', 'terms', field='enrollment_mode')
search.aggs.bucket('segments', 'terms', field='segments')
# TODO: enable during https://openedx.atlassian.net/browse/AN-6319
# search.aggs.bucket('group_by_cohorts', 'terms', field='cohort')
search.aggs.bucket('cohorts', 'terms', field='cohort')
response = search.execute()
# Build up the map of aggregation name to count
aggregations = {
......
......@@ -317,7 +317,7 @@ class VideoTimelineSerializer(ModelSerializerWithCreatedField):
)
class LearnerSerializer(serializers.Serializer):
class LearnerSerializer(serializers.Serializer, DefaultIfNoneMixin):
username = serializers.CharField()
enrollment_mode = serializers.CharField()
name = serializers.CharField()
......@@ -325,11 +325,9 @@ class LearnerSerializer(serializers.Serializer):
email = serializers.CharField()
segments = serializers.Field(source='segments')
engagements = serializers.SerializerMethodField('get_engagements')
# TODO: add these back in when the index returns them
# enrollment_date = serializers.DateField(format=settings.DATE_FORMAT, allow_empty=True)
# last_updated = serializers.DateField(format=settings.DATE_FORMAT)
# cohort = serializers.CharField(allow_none=True)
enrollment_date = serializers.DateField(format=settings.DATE_FORMAT)
last_updated = serializers.DateField(format=settings.DATE_FORMAT)
cohort = serializers.CharField()
def get_account_url(self, obj):
if settings.LMS_USER_ACCOUNT_BASE_URL:
......@@ -342,10 +340,17 @@ class LearnerSerializer(serializers.Serializer):
Add the engagement totals.
"""
engagements = {}
for entity_type in engagement_entity_types.AGGREGATE_TYPES:
for event in engagement_events.EVENTS[entity_type]:
metric = '{0}_{1}'.format(entity_type, event)
engagements[metric] = getattr(obj, metric, 0)
# fill in these fields will 0 if values not returned/found
default_if_none_fields = ['discussions_contributed', 'problems_attempted',
'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
......@@ -424,8 +429,7 @@ class EnagementRangeMetricSerializer(serializers.Serializer):
class CourseLearnerMetadataSerializer(serializers.Serializer):
enrollment_modes = serializers.SerializerMethodField('get_enrollment_modes')
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')
def get_enrollment_modes(self, obj):
......@@ -434,9 +438,8 @@ class CourseLearnerMetadataSerializer(serializers.Serializer):
def get_segments(self, obj):
return obj['es_data']['segments']
# TODO: enable during https://openedx.atlassian.net/browse/AN-6319
# def get_cohorts(self, obj):
# return obj['es_data']['cohorts']
def get_cohorts(self, obj):
return obj['es_data']['cohorts']
def get_engagement_ranges(self, obj):
query_set = obj['engagement_ranges']
......
......@@ -25,6 +25,9 @@ class LearnerAPITestMixin(object):
"""Creates the index and defines a mapping."""
super(LearnerAPITestMixin, self).setUp()
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.put_mapping(
index=settings.ELASTICSEARCH_LEARNERS_INDEX,
......@@ -61,12 +64,21 @@ class LearnerAPITestMixin(object):
'problems_completed': {
'type': 'integer', 'doc_values': True
},
'attempts_per_problem_completed': {
'problem_attempts_per_completed': {
'type': 'float', 'doc_values': True
},
'attempt_ratio_order': {
'type': 'integer', 'doc_values': True
},
'videos_viewed': {
'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):
email=None,
enrollment_mode='honor',
segments=None,
cohort='',
cohort='Team edX',
discussions_contributed=0,
problems_attempted=0,
problems_completed=0,
attempts_per_problem_completed=0,
videos_viewed=0
problem_attempts_per_completed=None,
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."""
self._es.create(
......@@ -106,8 +121,11 @@ class LearnerAPITestMixin(object):
'discussions_contributed': discussions_contributed,
'problems_attempted': problems_attempted,
'problems_completed': problems_completed,
'attempts_per_problem_completed': attempts_per_problem_completed,
'videos_viewed': videos_viewed
'problem_attempts_per_completed': problem_attempts_per_completed,
'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):
self._es.indices.refresh(index=settings.ELASTICSEARCH_LEARNERS_INDEX)
@ddt.ddt
class LearnerTests(VerifyCourseIdMixin, LearnerAPITestMixin, TestCaseWithAuthentication):
"""Tests for the single learner endpoint."""
path_template = '/api/v0/learners/{}/?course_id={}'
def setUp(self):
super(LearnerTests, self).setUp()
@ddt.data(
('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([{
"username": "ed_xavier",
"name": "Edward Xavier",
"course_id": "edX/DemoX/Demo_Course",
"segments": ["has_potential"],
"problems_attempted": 43,
"problems_completed": 3,
"videos_viewed": 6,
"discussions_contributed": 0
"username": username,
"name": name,
"course_id": course_id,
"enrollment_mode": enrollment_mode,
"segments": segments,
"cohort": cohort,
"problems_attempted": problems_attempted,
"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):
user_name = 'ed_xavier'
course_id = 'edX/DemoX/Demo_Course'
response = self.authenticated_get(self.path_template.format(user_name, course_id))
response = self.authenticated_get(self.path_template.format(username, course_id))
self.assertEquals(response.status_code, 200)
expected = {
"username": "ed_xavier",
"enrollment_mode": "honor",
"name": "Edward Xavier",
"email": "ed_xavier@example.com",
"account_url": "http://lms-host/ed_xavier",
"segments": ["has_potential"],
"username": username,
"enrollment_mode": enrollment_mode,
"name": name,
"email": "{}@example.com".format(username),
"account_url": "http://lms-host/{}".format(username),
"segments": segments or [],
"cohort": cohort,
"engagements": {
"problems_attempted": 43,
"problems_completed": 3,
"videos_viewed": 6,
"discussions_contributed": 0
"problems_attempted": problems_attempted or 0,
"problems_completed": problems_completed or 0,
"videos_viewed": videos_viewed or 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)
......@@ -237,25 +272,25 @@ class LearnerListTests(LearnerAPITestMixin, VerifyCourseIdMixin, TestCaseWithAut
'course_id': self.course_id,
'enrollment_mode': 'honor',
'segments': ['a', 'b'],
# TODO: enable during https://openedx.atlassian.net/browse/AN-6319
# 'cohort': 'alpha',
'cohort': 'alpha',
"problems_attempted": 43,
"problems_completed": 3,
"videos_viewed": 6,
"discussions_contributed": 0
"discussions_contributed": 0,
"problem_attempts_per_completed": 23.14,
}])
response = self._get(self.course_id)
self.assert_learners_returned(response, [{
'username': 'user_1',
'enrollment_mode': 'honor',
'segments': ['a', 'b'],
# TODO: enable during https://openedx.atlassian.net/browse/AN-6319
# 'cohort': 'alpha',
'cohort': 'alpha',
"engagements": {
"problems_attempted": 43,
"problems_completed": 3,
"videos_viewed": 6,
"discussions_contributed": 0
"discussions_contributed": 0,
"problem_attempts_per_completed": 23.14,
}
}])
......@@ -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', '', 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', '', True),
# ('cohort', 'a', 'cohort', 'b', False),
('cohort', 'a', 'cohort', 'a', True),
('cohort', 'a', 'cohort', '', True),
('cohort', 'a', 'cohort', 'b', False),
('enrollment_mode', 'a', 'enrollment_mode', 'a', True),
('enrollment_mode', 'a', 'enrollment_mode', '', True),
('enrollment_mode', 'a', 'enrollment_mode', 'b', False),
......@@ -347,6 +381,18 @@ class LearnerListTests(LearnerAPITestMixin, VerifyCourseIdMixin, TestCaseWithAut
[{'username': 'a', 'videos_viewed': 0}, {'username': 'b', 'videos_viewed': 1}],
'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
def test_sort(self, learners, order_by, sort_order, expected_users):
......@@ -398,7 +444,6 @@ class LearnerListTests(LearnerAPITestMixin, VerifyCourseIdMixin, TestCaseWithAut
self.assert_learners_returned(response, [{'username': 'e'}])
# Error cases
@ddt.data(
({}, 'course_not_specified'),
({'course_id': ''}, 'course_not_specified'),
......@@ -433,10 +478,11 @@ class CourseLearnerMetadataTests(DemoCourseMixin, VerifyCourseIdMixin,
"""Helper to send a GET request to the API."""
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['segments'] = segments
expected_json['enrollment_modes'] = enrollment_modes
expected_json['cohorts'] = cohorts
return expected_json
def assert_response_matches(self, response, expected_status_code, expected_data):
......@@ -471,7 +517,9 @@ class CourseLearnerMetadataTests(DemoCourseMixin, VerifyCourseIdMixin,
expected_segments = {"highly_engaged": 0, "disengaging": 0, "struggling": 0, "inactive": 0, "unenrolled": 0}
expected_segments.update(segments)
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)
......@@ -485,7 +533,8 @@ class CourseLearnerMetadataTests(DemoCourseMixin, VerifyCourseIdMixin,
])
expected = self.get_expected_json(
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)
......@@ -510,7 +559,29 @@ class CourseLearnerMetadataTests(DemoCourseMixin, VerifyCourseIdMixin,
expected_enrollment_modes[enrollment_mode] = count
expected = self.get_expected_json(
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)
......@@ -541,10 +612,6 @@ class CourseLearnerMetadataTests(DemoCourseMixin, VerifyCourseIdMixin,
metrics.append('{0}_{1}'.format(entity_type, event))
return metrics
# TODO: enable during https://openedx.atlassian.net/browse/AN-6319
# def test_cohorts(self):
# pass
def test_no_engagement_ranges(self):
response = self._get(self.course_id)
self.assertEqual(response.status_code, 200)
......
......@@ -174,14 +174,21 @@ class LearnerListView(CourseViewMixin, generics.ListAPIView):
"""
self._validate_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 = {
'segments': split_query_argument(query_params.get('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'),
'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')
}
# Remove None values from `params` so that we don't overwrite default
......@@ -211,6 +218,7 @@ class EngagementTimelineView(CourseViewMixin, generics.ListAPIView):
* problems_completed: Unique number of problems completed.
* discussions_contributed: Number of discussions participated in (e.g. forum posts)
* videos_viewed: Number of videos watched.
* problem_attempts_per_completed: TBD
**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