Commit e7df0f3c by Dennis Jen Committed by Daniel Friedman

Added learner engagement timeline.

parent 98081a29
DISCUSSION = 'discussion'
PROBLEM = 'problem'
VIDEO = 'video'
INDIVIDUAL_TYPES = [DISCUSSION, PROBLEM, VIDEO]
DISCUSSIONS = 'discussions' DISCUSSIONS = 'discussions'
PROBLEMS = 'problems' PROBLEMS = 'problems'
VIDEO = 'videos' VIDEOS = 'videos'
ALL = [DISCUSSIONS, PROBLEMS, VIDEO] AGGREGATE_TYPES = [DISCUSSIONS, PROBLEMS, VIDEOS]
# useful for agregating ModuleEngagement to ModuleEngagementTimeline
SINGULAR_TO_PLURAL = {
DISCUSSION: DISCUSSIONS,
PROBLEM: PROBLEMS,
VIDEO: VIDEOS,
}
...@@ -7,7 +7,10 @@ VIEWED = 'viewed' ...@@ -7,7 +7,10 @@ VIEWED = 'viewed'
# map entity types to events # map entity types to events
EVENTS = { EVENTS = {
engagement_entity_types.DISCUSSION: [CONTRIBUTED],
engagement_entity_types.DISCUSSIONS: [CONTRIBUTED], engagement_entity_types.DISCUSSIONS: [CONTRIBUTED],
engagement_entity_types.PROBLEM: [ATTEMPTED, COMPLETED],
engagement_entity_types.PROBLEMS: [ATTEMPTED, COMPLETED], engagement_entity_types.PROBLEMS: [ATTEMPTED, COMPLETED],
engagement_entity_types.VIDEO: [VIEWED], engagement_entity_types.VIDEO: [VIEWED],
engagement_entity_types.VIDEOS: [VIEWED],
} }
...@@ -8,7 +8,7 @@ import random ...@@ -8,7 +8,7 @@ import random
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.utils import timezone from django.utils import timezone
from analytics_data_api.v0 import models from analytics_data_api.v0 import models
from analytics_data_api.constants import engagement_entity_types, engagement_events
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
...@@ -182,6 +182,24 @@ class Command(BaseCommand): ...@@ -182,6 +182,24 @@ class Command(BaseCommand):
users_at_start=users_at_start, users_at_start=users_at_start,
users_at_end=random.randint(100, users_at_start)) users_at_end=random.randint(100, users_at_start))
def generate_learner_engagement_data(self, course_id, username, start_date, end_date):
logger.info("Deleting learner engagement module data...")
models.ModuleEngagement.objects.all().delete()
logger.info("Generating learner engagement module data...")
current = start_date
while current < end_date:
current = current + datetime.timedelta(days=1)
for entity_type in engagement_entity_types.INDIVIDUAL_TYPES:
for event in engagement_events.EVENTS[entity_type]:
count = random.randint(0, 100)
if count:
entity_id = 'an-id-{}-{}'.format(entity_type, event)
models.ModuleEngagement.objects.create(
course_id=course_id, username=username, date=current,
entity_type=entity_type, entity_id=entity_id, event=event, count=count)
logger.info("Done!")
def handle(self, *args, **options): def handle(self, *args, **options):
course_id = 'edX/DemoX/Demo_Course' course_id = 'edX/DemoX/Demo_Course'
video_id = '0fac49ba' video_id = '0fac49ba'
...@@ -199,3 +217,4 @@ class Command(BaseCommand): ...@@ -199,3 +217,4 @@ class Command(BaseCommand):
self.generate_daily_data(course_id, start_date, end_date) self.generate_daily_data(course_id, start_date, end_date)
self.generate_video_data(course_id, video_id, video_module_id) self.generate_video_data(course_id, video_id, video_module_id)
self.generate_video_timeline_data(video_id) self.generate_video_timeline_data(video_id)
self.generate_learner_engagement_data(course_id, 'ed_xavier', start_date, end_date)
...@@ -29,6 +29,21 @@ class LearnerNotFoundError(BaseError): ...@@ -29,6 +29,21 @@ class LearnerNotFoundError(BaseError):
return 'Learner {username} not found for course {course_id}.' return 'Learner {username} not found for course {course_id}.'
class LearnerEngagementTimelineNotFoundError(BaseError):
"""
Raise learner engagement timeline not found for a course.
"""
def __init__(self, *args, **kwargs):
course_id = kwargs.pop('course_id')
username = kwargs.pop('username')
super(LearnerEngagementTimelineNotFoundError, self).__init__(*args, **kwargs)
self.message = self.message_template.format(username=username, course_id=course_id)
@property
def message_template(self):
return 'Learner {username} engagmeent timeline not found for course {course_id}.'
class CourseNotSpecifiedError(BaseError): class CourseNotSpecifiedError(BaseError):
""" """
Raise if course not specified. Raise if course not specified.
......
...@@ -5,8 +5,9 @@ from rest_framework import status ...@@ -5,8 +5,9 @@ from rest_framework import status
from analytics_data_api.v0.exceptions import ( from analytics_data_api.v0.exceptions import (
CourseKeyMalformedError, CourseKeyMalformedError,
CourseNotSpecifiedError, CourseNotSpecifiedError,
ParameterValueError, LearnerEngagementTimelineNotFoundError,
LearnerNotFoundError, LearnerNotFoundError,
ParameterValueError,
) )
...@@ -58,6 +59,24 @@ class LearnerNotFoundErrorMiddleware(BaseProcessErrorMiddleware): ...@@ -58,6 +59,24 @@ class LearnerNotFoundErrorMiddleware(BaseProcessErrorMiddleware):
return status.HTTP_404_NOT_FOUND return status.HTTP_404_NOT_FOUND
class LearnerEngagementTimelineNotFoundErrorMiddleware(BaseProcessErrorMiddleware):
"""
Raise 404 if learner engagement timeline not found.
"""
@property
def error(self):
return LearnerEngagementTimelineNotFoundError
@property
def error_code(self):
return 'no_learner_engagement_timeline'
@property
def status_code(self):
return status.HTTP_404_NOT_FOUND
class CourseNotSpecifiedErrorMiddleware(BaseProcessErrorMiddleware): class CourseNotSpecifiedErrorMiddleware(BaseProcessErrorMiddleware):
""" """
Raise 400 course not specified. Raise 400 course not specified.
......
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 elasticsearch_dsl import DocType, Q from elasticsearch_dsl import DocType, Q
from analytics_data_api.constants import country, genders from analytics_data_api.constants import country, engagement_entity_types, genders
class CourseActivityWeekly(models.Model): class CourseActivityWeekly(models.Model):
...@@ -272,3 +275,54 @@ class RosterEntry(DocType): ...@@ -272,3 +275,54 @@ class RosterEntry(DocType):
search = search.sort(sort_term) search = search.sort(sort_term)
return search return search
class ModuleEngagementTimelineManager(models.Manager):
"""
Modifies the ModuleEngagement queryset to aggregate engagement data for
the learner engagement timeline.
"""
def get_timelines(self, course_id, username):
queryset = ModuleEngagement.objects.all().filter(course_id=course_id, username=username) \
.values('date', 'entity_type', 'event') \
.annotate(count=Sum('count')) \
.order_by('date')
timelines = []
for key, group in groupby(queryset, lambda x: (x['date'])):
# Iterate over groups and create a single item with engagement data
item = {
u'date': key,
}
for engagement in group:
entity_type = engagement_entity_types.SINGULAR_TO_PLURAL[engagement['entity_type']]
engagement_type = '{}_{}'.format(entity_type, engagement['event'])
count = item.get(engagement_type, 0)
count += engagement['count']
item[engagement_type] = count
timelines.append(item)
return timelines
class ModuleEngagement(models.Model):
"""User interactions with entities within the courseware."""
course_id = models.CharField(db_index=True, max_length=255)
username = models.CharField(max_length=255)
date = models.DateTimeField()
# This will be one of "problem", "video" or "forum"
entity_type = models.CharField(max_length=255)
# For problems this will be the usage key, for videos it will be the html encoded module ID,
# for forums it will be the commentable_id
entity_id = models.CharField(max_length=255)
# A description of what interaction occurred, e.g. "contributed" or "viewed"
event = models.CharField(max_length=255)
# The number of times the user interacted with this entity in this way on this day.
count = models.IntegerField()
objects = ModuleEngagementTimelineManager()
class Meta(object):
db_table = 'module_engagement'
...@@ -175,13 +175,16 @@ class SequentialOpenDistributionSerializer(ModelSerializerWithCreatedField): ...@@ -175,13 +175,16 @@ class SequentialOpenDistributionSerializer(ModelSerializerWithCreatedField):
) )
class BaseCourseEnrollmentModelSerializer(ModelSerializerWithCreatedField): class DefaultIfNoneMixin(object):
date = serializers.DateField(format=settings.DATE_FORMAT)
def default_if_none(self, value, default=0): def default_if_none(self, value, default=0):
return value if value is not None else default return value if value is not None else default
class BaseCourseEnrollmentModelSerializer(DefaultIfNoneMixin, ModelSerializerWithCreatedField):
date = serializers.DateField(format=settings.DATE_FORMAT)
class CourseEnrollmentDailySerializer(BaseCourseEnrollmentModelSerializer): class CourseEnrollmentDailySerializer(BaseCourseEnrollmentModelSerializer):
""" Representation of course enrollment for a single day and course. """ """ Representation of course enrollment for a single day and course. """
...@@ -339,7 +342,7 @@ class LearnerSerializer(serializers.Serializer): ...@@ -339,7 +342,7 @@ class LearnerSerializer(serializers.Serializer):
Add the engagement totals. Add the engagement totals.
""" """
engagements = {} engagements = {}
for entity_type in engagement_entity_types.ALL: for entity_type in engagement_entity_types.AGGREGATE_TYPES:
for event in engagement_events.EVENTS[entity_type]: for event in engagement_events.EVENTS[entity_type]:
metric = '{0}_{1}'.format(entity_type, event) metric = '{0}_{1}'.format(entity_type, event)
engagements[metric] = getattr(obj, metric, 0) engagements[metric] = getattr(obj, metric, 0)
...@@ -365,3 +368,23 @@ class ElasticsearchDSLSearchSerializer(EdxPaginationSerializer): ...@@ -365,3 +368,23 @@ class ElasticsearchDSLSearchSerializer(EdxPaginationSerializer):
# elasticsearch-dsl search object. # elasticsearch-dsl search object.
kwargs['instance'].object_list = kwargs['instance'].object_list.execute() kwargs['instance'].object_list = kwargs['instance'].object_list.execute()
super(ElasticsearchDSLSearchSerializer, self).__init__(*args, **kwargs) super(ElasticsearchDSLSearchSerializer, self).__init__(*args, **kwargs)
class EngagementDaySerializer(DefaultIfNoneMixin, serializers.Serializer):
date = serializers.DateField(format=settings.DATE_FORMAT)
problems_attempted = serializers.IntegerField(required=True, default=0)
problems_completed = serializers.IntegerField(required=True, default=0)
discussions_contributed = serializers.IntegerField(required=True, default=0)
videos_viewed = serializers.IntegerField(required=True, default=0)
def transform_problems_attempted(self, _obj, value):
return self.default_if_none(value, 0)
def transform_problems_completed(self, _obj, value):
return self.default_if_none(value, 0)
def transform_discussions_contributed(self, _obj, value):
return self.default_if_none(value, 0)
def transform_videos_viewed(self, _obj, value):
return self.default_if_none(value, 0)
import json
from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.keys import CourseKey
from rest_framework import status
DEMO_COURSE_ID = u'course-v1:edX+DemoX+Demo_2014' DEMO_COURSE_ID = u'course-v1:edX+DemoX+Demo_2014'
...@@ -12,3 +15,24 @@ class DemoCourseMixin(object): ...@@ -12,3 +15,24 @@ class DemoCourseMixin(object):
cls.course_id = DEMO_COURSE_ID cls.course_id = DEMO_COURSE_ID
cls.course_key = CourseKey.from_string(cls.course_id) cls.course_key = CourseKey.from_string(cls.course_id)
super(DemoCourseMixin, cls).setUpClass() super(DemoCourseMixin, cls).setUpClass()
class VerifyCourseIdMixin(object):
def verify_no_course_id(self, response):
""" Assert that a course ID must be provided. """
self.assertEquals(response.status_code, status.HTTP_400_BAD_REQUEST)
expected = {
u"error_code": u"course_not_specified",
u"developer_message": u"Course id/key not specified."
}
self.assertDictEqual(json.loads(response.content), expected)
def verify_bad_course_id(self, response, course_id='malformed-course-id'):
""" Assert that a course ID must be valid. """
self.assertEquals(response.status_code, status.HTTP_400_BAD_REQUEST)
expected = {
u"error_code": u"course_key_malformed",
u"developer_message": u"Course id/key {} malformed.".format(course_id)
}
self.assertDictEqual(json.loads(response.content), expected)
import datetime
import json
from django.utils.http import urlquote
from django_dynamic_fixture import G
import pytz
from rest_framework import status
from analyticsdataserver.tests import TestCaseWithAuthentication
from analytics_data_api.constants import engagement_entity_types, engagement_events
from analytics_data_api.v0 import models
from analytics_data_api.v0.tests.views import DemoCourseMixin, VerifyCourseIdMixin
class EngagementTimelineTests(DemoCourseMixin, VerifyCourseIdMixin, TestCaseWithAuthentication):
DEFAULT_USERNAME = 'ed_xavier'
path_template = '/api/v0/engagement_timelines/{}/?course_id={}'
def _create_engagement(self):
""" Create module engagement data for testing. """
G(models.ModuleEngagement, course_id=self.course_id, username=self.DEFAULT_USERNAME,
date=datetime.datetime(2015, 1, 1, tzinfo=pytz.utc), entity_type=engagement_entity_types.PROBLEM,
entity_id='some-type-of-id', event=engagement_events.ATTEMPTED, count=100)
G(models.ModuleEngagement, course_id=self.course_id, username=self.DEFAULT_USERNAME,
date=datetime.datetime(2015, 1, 1, tzinfo=pytz.utc), entity_type=engagement_entity_types.PROBLEM,
entity_id='some-type-of-id', event=engagement_events.COMPLETED, count=12)
G(models.ModuleEngagement, course_id=self.course_id, username=self.DEFAULT_USERNAME,
date=datetime.datetime(2015, 1, 2, tzinfo=pytz.utc), entity_type=engagement_entity_types.DISCUSSION,
entity_id='some-type-of-id', event=engagement_events.CONTRIBUTED, count=10)
G(models.ModuleEngagement, course_id=self.course_id, username=self.DEFAULT_USERNAME,
date=datetime.datetime(2015, 1, 2, tzinfo=pytz.utc), entity_type=engagement_entity_types.VIDEO,
entity_id='some-type-of-id', event=engagement_events.VIEWED, count=44)
G(models.ModuleEngagement, course_id=self.course_id, username=self.DEFAULT_USERNAME,
date=datetime.datetime(2015, 1, 2, tzinfo=pytz.utc), entity_type=engagement_entity_types.PROBLEM,
entity_id='some-type-of-id', event=engagement_events.ATTEMPTED, count=8)
def test_timeline(self):
path = self.path_template.format(self.DEFAULT_USERNAME, urlquote(self.course_id))
self._create_engagement()
response = self.authenticated_get(path)
self.assertEquals(response.status_code, 200)
expected = {
'days': [
{
'date': '2015-01-01',
'discussions_contributed': 0,
'problems_attempted': 100,
'problems_completed': 12,
'videos_viewed': 0
},
{
'date': '2015-01-02',
'discussions_contributed': 10,
'problems_attempted': 8,
'problems_completed': 0,
'videos_viewed': 44
},
]
}
self.assertEquals(response.data, expected)
def test_one(self):
path = self.path_template.format(self.DEFAULT_USERNAME, urlquote(self.course_id))
G(models.ModuleEngagement, course_id=self.course_id, username=self.DEFAULT_USERNAME,
date=datetime.datetime(2015, 5, 28, tzinfo=pytz.utc), entity_type=engagement_entity_types.PROBLEM,
entity_id='some-type-of-id', event=engagement_events.ATTEMPTED, count=6923)
response = self.authenticated_get(path)
self.assertEquals(response.status_code, 200)
expected = {
'days': [
{
'date': '2015-05-28',
'discussions_contributed': 0,
'problems_attempted': 6923,
'problems_completed': 0,
'videos_viewed': 0
},
]
}
self.assertEquals(response.data, expected)
def test_day_gap(self):
path = self.path_template.format(self.DEFAULT_USERNAME, urlquote(self.course_id))
G(models.ModuleEngagement, course_id=self.course_id, username=self.DEFAULT_USERNAME,
date=datetime.datetime(2015, 5, 26, tzinfo=pytz.utc), entity_type=engagement_entity_types.VIDEO,
entity_id='some-type-of-id', event=engagement_events.VIEWED, count=1)
G(models.ModuleEngagement, course_id=self.course_id, username=self.DEFAULT_USERNAME,
date=datetime.datetime(2015, 5, 28, tzinfo=pytz.utc), entity_type=engagement_entity_types.PROBLEM,
entity_id='some-type-of-id', event=engagement_events.ATTEMPTED, count=6923)
response = self.authenticated_get(path)
self.assertEquals(response.status_code, 200)
expected = {
'days': [
{
'date': '2015-05-26',
'discussions_contributed': 0,
'problems_attempted': 0,
'problems_completed': 0,
'videos_viewed': 1
},
{
'date': '2015-05-28',
'discussions_contributed': 0,
'problems_attempted': 6923,
'problems_completed': 0,
'videos_viewed': 0
},
]
}
self.assertEquals(response.data, expected)
def test_not_found(self):
path = self.path_template.format(self.DEFAULT_USERNAME, urlquote(self.course_id))
response = self.authenticated_get(path)
self.assertEquals(response.status_code, status.HTTP_404_NOT_FOUND)
expected = {
u"error_code": u"no_learner_engagement_timeline",
u"developer_message": u"Learner {} engagmeent timeline not found for course {}.".format(
self.DEFAULT_USERNAME, self.course_id)
}
self.assertDictEqual(json.loads(response.content), expected)
def test_no_course_id(self):
base_path = '/api/v0/engagement_timelines/{}'
response = self.authenticated_get((base_path).format('ed_xavier'))
self.verify_no_course_id(response)
def test_bad_course_id(self):
path = self.path_template.format(self.DEFAULT_USERNAME, 'malformed-course-id')
response = self.authenticated_get(path)
self.verify_bad_course_id(response)
...@@ -9,6 +9,7 @@ from rest_framework import status ...@@ -9,6 +9,7 @@ from rest_framework import status
from django.conf import settings from django.conf import settings
from analyticsdataserver.tests import TestCaseWithAuthentication from analyticsdataserver.tests import TestCaseWithAuthentication
from analytics_data_api.v0.tests.views import VerifyCourseIdMixin
class LearnerAPITestMixin(object): class LearnerAPITestMixin(object):
...@@ -116,9 +117,8 @@ class LearnerAPITestMixin(object): ...@@ -116,9 +117,8 @@ class LearnerAPITestMixin(object):
self._es.indices.refresh(index=settings.ELASTICSEARCH_LEARNERS_INDEX) self._es.indices.refresh(index=settings.ELASTICSEARCH_LEARNERS_INDEX)
class LearnerTests(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): def setUp(self):
...@@ -170,25 +170,13 @@ class LearnerTests(LearnerAPITestMixin, TestCaseWithAuthentication): ...@@ -170,25 +170,13 @@ class LearnerTests(LearnerAPITestMixin, TestCaseWithAuthentication):
def test_no_course_id(self): def test_no_course_id(self):
base_path = '/api/v0/learners/{}' base_path = '/api/v0/learners/{}'
path = (base_path).format('ed_xavier') response = self.authenticated_get((base_path).format('ed_xavier'))
response = self.authenticated_get(path) self.verify_no_course_id(response)
self.assertEquals(response.status_code, status.HTTP_400_BAD_REQUEST)
expected = {
u"error_code": u"course_not_specified",
u"developer_message": u"Course id/key not specified."
}
self.assertDictEqual(json.loads(response.content), expected)
def test_bad_course_id(self): def test_bad_course_id(self):
path = self.path_template.format('ed_xavier', 'malformed-course-id') path = self.path_template.format('ed_xavier', 'malformed-course-id')
response = self.authenticated_get(path) response = self.authenticated_get(path)
self.assertEquals(response.status_code, status.HTTP_400_BAD_REQUEST) self.verify_bad_course_id(response)
expected = {
u"error_code": u"course_key_malformed",
u"developer_message": u"Course id/key malformed-course-id malformed."
}
self.assertDictEqual(json.loads(response.content), expected)
@ddt.ddt @ddt.ddt
......
...@@ -2,12 +2,16 @@ from django.conf.urls import patterns, url, include ...@@ -2,12 +2,16 @@ from django.conf.urls import patterns, url, include
from django.core.urlresolvers import reverse_lazy from django.core.urlresolvers import reverse_lazy
from django.views.generic import RedirectView from django.views.generic import RedirectView
USERNAME_PATTERN = r'(?P<username>.+)'
urlpatterns = patterns( urlpatterns = patterns(
'', '',
url(r'^courses/', include('analytics_data_api.v0.urls.courses', namespace='courses')), url(r'^courses/', include('analytics_data_api.v0.urls.courses', namespace='courses')),
url(r'^problems/', include('analytics_data_api.v0.urls.problems', namespace='problems')), url(r'^problems/', include('analytics_data_api.v0.urls.problems', namespace='problems')),
url(r'^videos/', include('analytics_data_api.v0.urls.videos', namespace='videos')), url(r'^videos/', include('analytics_data_api.v0.urls.videos', namespace='videos')),
url('^learners/', include('analytics_data_api.v0.urls.learners', namespace='learners')), url('^learners/', include('analytics_data_api.v0.urls.learners', namespace='learners')),
url(r'^engagement_timelines/', include('analytics_data_api.v0.urls.engagement_timelines',
namespace='engagement_timelines')),
# pylint: disable=no-value-for-parameter # pylint: disable=no-value-for-parameter
url(r'^authenticated/$', RedirectView.as_view(url=reverse_lazy('authenticated')), name='authenticated'), url(r'^authenticated/$', RedirectView.as_view(url=reverse_lazy('authenticated')), name='authenticated'),
......
from django.conf.urls import patterns, url
from analytics_data_api.v0.views import engagement_timelines as views
from analytics_data_api.v0.urls import USERNAME_PATTERN
urlpatterns = patterns(
'',
url(r'^{}/$'.format(USERNAME_PATTERN), views.EngagementTimelineView.as_view(), name='engagement_timelines'),
)
from django.conf.urls import patterns, url from django.conf.urls import patterns, url
from analytics_data_api.v0.views import learners as views from analytics_data_api.v0.views import learners as views
from analytics_data_api.v0.urls import USERNAME_PATTERN
USERNAME_PATTERN = r'(?P<username>.+)'
urlpatterns = patterns( urlpatterns = patterns(
'', '',
url(r'^$', views.LearnerListView.as_view(), name='learners'), url(r'^$', views.LearnerListView.as_view(), name='learners'),
......
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey
from analytics_data_api.v0.exceptions import (CourseNotSpecifiedError, CourseKeyMalformedError)
class CourseViewMixin(object):
"""
Captures the course_id query arg and validates it.
"""
course_id = None
def get(self, request, *args, **kwargs):
self.course_id = request.QUERY_PARAMS.get('course_id', None)
if not self.course_id:
raise CourseNotSpecifiedError()
try:
CourseKey.from_string(self.course_id)
except InvalidKeyError:
raise CourseKeyMalformedError(course_id=self.course_id)
return super(CourseViewMixin, self).get(request, *args, **kwargs)
"""
API methods for module level data.
"""
from rest_framework import generics, status
from analytics_data_api.v0.exceptions import LearnerEngagementTimelineNotFoundError
from analytics_data_api.v0.models import ModuleEngagement
from analytics_data_api.v0.serializers import EngagementDaySerializer
from analytics_data_api.v0.views import CourseViewMixin
class EngagementTimelineView(CourseViewMixin, generics.ListAPIView):
"""
Get a particular learner's engagement timeline for a particular course. Days
without data will not be returned.
**Example Request**
GET /api/v0/engagement_timeline/{username}/?course_id={course_id}
**Response Values**
Returns the engagement timeline.
* days: Array of the learner's daily engagement timeline.
* problems_attempted: Unique number of unique problems attempted.
* problems_completed: Unique number of problems completed.
* discussions_contributed: Number of discussions participated in (e.g. forum posts)
* videos_viewed: Number of videos watched.
**Parameters**
You can specify course ID for which you want data.
course_id -- The course within which user data is requested.
"""
serializer_class = EngagementDaySerializer
username = None
lookup_field = 'username'
def list(self, request, *args, **kwargs):
response = super(EngagementTimelineView, self).list(request, *args, **kwargs)
if response.status_code == status.HTTP_200_OK:
response.data = {'days': response.data}
return response
def get(self, request, *args, **kwargs):
self.username = self.kwargs.get('username')
return super(EngagementTimelineView, self).get(request, *args, **kwargs)
def get_queryset(self):
queryset = ModuleEngagement.objects.get_timelines(self.course_id, self.username)
if len(queryset) == 0:
raise LearnerEngagementTimelineNotFoundError(username=self.username, course_id=self.course_id)
return queryset
...@@ -3,39 +3,17 @@ API methods for module level data. ...@@ -3,39 +3,17 @@ API methods for module level data.
""" """
from rest_framework import generics from rest_framework import generics
from opaque_keys import InvalidKeyError from analytics_data_api.constants import learner
from opaque_keys.edx.keys import CourseKey
from analytics_data_api.v0.exceptions import ( from analytics_data_api.v0.exceptions import (
CourseKeyMalformedError,
CourseNotSpecifiedError,
LearnerNotFoundError, LearnerNotFoundError,
ParameterValueError, ParameterValueError,
) )
from analytics_data_api.constants import learner
from analytics_data_api.v0.models import RosterEntry from analytics_data_api.v0.models import RosterEntry
from analytics_data_api.v0.serializers import ElasticsearchDSLSearchSerializer, LearnerSerializer from analytics_data_api.v0.serializers import ElasticsearchDSLSearchSerializer, LearnerSerializer
from analytics_data_api.v0.views import CourseViewMixin
from analytics_data_api.v0.views.utils import split_query_argument from analytics_data_api.v0.views.utils import split_query_argument
class CourseViewMixin(object):
"""
Captures the course_id query arg and validates it.
"""
course_id = None
def get(self, request, *args, **kwargs):
self.course_id = request.QUERY_PARAMS.get('course_id', None)
if not self.course_id:
raise CourseNotSpecifiedError()
try:
CourseKey.from_string(self.course_id)
except InvalidKeyError:
raise CourseKeyMalformedError(course_id=self.course_id)
return super(CourseViewMixin, self).get(request, *args, **kwargs)
class LearnerView(CourseViewMixin, generics.RetrieveAPIView): class LearnerView(CourseViewMixin, generics.RetrieveAPIView):
""" """
Get a particular student's data for a particular course. Get a particular student's data for a particular course.
......
...@@ -164,6 +164,7 @@ MIDDLEWARE_CLASSES = ( ...@@ -164,6 +164,7 @@ MIDDLEWARE_CLASSES = (
'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware', 'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware',
'analytics_data_api.v0.middleware.LearnerEngagementTimelineNotFoundErrorMiddleware',
'analytics_data_api.v0.middleware.LearnerNotFoundErrorMiddleware', 'analytics_data_api.v0.middleware.LearnerNotFoundErrorMiddleware',
'analytics_data_api.v0.middleware.CourseNotSpecifiedErrorMiddleware', 'analytics_data_api.v0.middleware.CourseNotSpecifiedErrorMiddleware',
'analytics_data_api.v0.middleware.CourseKeyMalformedErrorMiddleware', 'analytics_data_api.v0.middleware.CourseKeyMalformedErrorMiddleware',
......
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