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']
......
...@@ -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