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'
PROBLEMS = 'problems'
VIDEO = 'videos'
ALL = [DISCUSSIONS, PROBLEMS, VIDEO]
VIDEOS = 'videos'
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'
# map entity types to events
EVENTS = {
engagement_entity_types.DISCUSSION: [CONTRIBUTED],
engagement_entity_types.DISCUSSIONS: [CONTRIBUTED],
engagement_entity_types.PROBLEM: [ATTEMPTED, COMPLETED],
engagement_entity_types.PROBLEMS: [ATTEMPTED, COMPLETED],
engagement_entity_types.VIDEO: [VIEWED],
engagement_entity_types.VIDEOS: [VIEWED],
}
......@@ -8,7 +8,7 @@ import random
from django.core.management.base import BaseCommand
from django.utils import timezone
from analytics_data_api.v0 import models
from analytics_data_api.constants import engagement_entity_types, engagement_events
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
......@@ -182,6 +182,24 @@ class Command(BaseCommand):
users_at_start=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):
course_id = 'edX/DemoX/Demo_Course'
video_id = '0fac49ba'
......@@ -199,3 +217,4 @@ class Command(BaseCommand):
self.generate_daily_data(course_id, start_date, end_date)
self.generate_video_data(course_id, video_id, video_module_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):
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):
"""
Raise if course not specified.
......
......@@ -5,8 +5,9 @@ from rest_framework import status
from analytics_data_api.v0.exceptions import (
CourseKeyMalformedError,
CourseNotSpecifiedError,
ParameterValueError,
LearnerEngagementTimelineNotFoundError,
LearnerNotFoundError,
ParameterValueError,
)
......@@ -58,6 +59,24 @@ class LearnerNotFoundErrorMiddleware(BaseProcessErrorMiddleware):
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):
"""
Raise 400 course not specified.
......
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
from analytics_data_api.constants import country, genders
from analytics_data_api.constants import country, engagement_entity_types, genders
class CourseActivityWeekly(models.Model):
......@@ -272,3 +275,54 @@ class RosterEntry(DocType):
search = search.sort(sort_term)
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):
)
class BaseCourseEnrollmentModelSerializer(ModelSerializerWithCreatedField):
date = serializers.DateField(format=settings.DATE_FORMAT)
class DefaultIfNoneMixin(object):
def default_if_none(self, value, default=0):
return value if value is not None else default
class BaseCourseEnrollmentModelSerializer(DefaultIfNoneMixin, ModelSerializerWithCreatedField):
date = serializers.DateField(format=settings.DATE_FORMAT)
class CourseEnrollmentDailySerializer(BaseCourseEnrollmentModelSerializer):
""" Representation of course enrollment for a single day and course. """
......@@ -339,7 +342,7 @@ class LearnerSerializer(serializers.Serializer):
Add the engagement totals.
"""
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]:
metric = '{0}_{1}'.format(entity_type, event)
engagements[metric] = getattr(obj, metric, 0)
......@@ -365,3 +368,23 @@ class ElasticsearchDSLSearchSerializer(EdxPaginationSerializer):
# elasticsearch-dsl search object.
kwargs['instance'].object_list = kwargs['instance'].object_list.execute()
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 rest_framework import status
DEMO_COURSE_ID = u'course-v1:edX+DemoX+Demo_2014'
......@@ -12,3 +15,24 @@ class DemoCourseMixin(object):
cls.course_id = DEMO_COURSE_ID
cls.course_key = CourseKey.from_string(cls.course_id)
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
from django.conf import settings
from analyticsdataserver.tests import TestCaseWithAuthentication
from analytics_data_api.v0.tests.views import VerifyCourseIdMixin
class LearnerAPITestMixin(object):
......@@ -116,9 +117,8 @@ class LearnerAPITestMixin(object):
self._es.indices.refresh(index=settings.ELASTICSEARCH_LEARNERS_INDEX)
class LearnerTests(LearnerAPITestMixin, TestCaseWithAuthentication):
class LearnerTests(VerifyCourseIdMixin, LearnerAPITestMixin, TestCaseWithAuthentication):
"""Tests for the single learner endpoint."""
path_template = '/api/v0/learners/{}/?course_id={}'
def setUp(self):
......@@ -170,25 +170,13 @@ class LearnerTests(LearnerAPITestMixin, TestCaseWithAuthentication):
def test_no_course_id(self):
base_path = '/api/v0/learners/{}'
path = (base_path).format('ed_xavier')
response = self.authenticated_get(path)
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)
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('ed_xavier', 'malformed-course-id')
response = self.authenticated_get(path)
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-course-id malformed."
}
self.assertDictEqual(json.loads(response.content), expected)
self.verify_bad_course_id(response)
@ddt.ddt
......
......@@ -2,12 +2,16 @@ from django.conf.urls import patterns, url, include
from django.core.urlresolvers import reverse_lazy
from django.views.generic import RedirectView
USERNAME_PATTERN = r'(?P<username>.+)'
urlpatterns = patterns(
'',
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'^videos/', include('analytics_data_api.v0.urls.videos', namespace='videos')),
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
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 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(
'',
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.
"""
from rest_framework import generics
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey
from analytics_data_api.constants import learner
from analytics_data_api.v0.exceptions import (
CourseKeyMalformedError,
CourseNotSpecifiedError,
LearnerNotFoundError,
ParameterValueError,
)
from analytics_data_api.constants import learner
from analytics_data_api.v0.models import RosterEntry
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
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):
"""
Get a particular student's data for a particular course.
......
......@@ -164,6 +164,7 @@ MIDDLEWARE_CLASSES = (
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
'analytics_data_api.v0.middleware.LearnerEngagementTimelineNotFoundErrorMiddleware',
'analytics_data_api.v0.middleware.LearnerNotFoundErrorMiddleware',
'analytics_data_api.v0.middleware.CourseNotSpecifiedErrorMiddleware',
'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