Skip to content

Commit

Permalink
Added learner engagement timeline.
Browse files Browse the repository at this point in the history
  • Loading branch information
dsjen committed Dec 8, 2015
1 parent c938210 commit c3975bc
Show file tree
Hide file tree
Showing 17 changed files with 439 additions and 47 deletions.
16 changes: 14 additions & 2 deletions analytics_data_api/constants/engagement_entity_types.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,16 @@
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,
}
3 changes: 3 additions & 0 deletions analytics_data_api/constants/engagement_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@

# 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],
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
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__)
Expand Down Expand Up @@ -182,6 +182,24 @@ def generate_video_data(self, course_id, video_id, module_id):
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'
Expand All @@ -199,3 +217,4 @@ def handle(self, *args, **options):
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)
15 changes: 15 additions & 0 deletions analytics_data_api/v0/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,21 @@ def message_template(self):
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.
Expand Down
19 changes: 19 additions & 0 deletions analytics_data_api/v0/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from rest_framework import status

from analytics_data_api.v0.exceptions import (
LearnerEngagementTimelineNotFoundError,
LearnerNotFoundError,
CourseNotSpecifiedError,
CourseKeyMalformedError
Expand Down Expand Up @@ -57,6 +58,24 @@ def status_code(self):
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.
Expand Down
66 changes: 65 additions & 1 deletion analytics_data_api/v0/models.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
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

from analytics_data_api.constants import country, genders
from analytics_data_api.constants import country, engagement_entity_types, genders


class CourseActivityWeekly(models.Model):
Expand Down Expand Up @@ -220,3 +223,64 @@ class Meta:
def get_course_user(cls, course_id, username):
return cls.search().query('term', course_id=course_id).query(
'term', username=username).execute()


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.
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()

class Meta(object):
db_table = 'module_engagement'


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 ModuleEngagementTimeline(models.Model):
"""
Learner engagement timeline data.
"""
date = models.DateField()
problems_attempted = models.IntegerField()
problems_completed = models.IntegerField()
discussions_contributed = models.IntegerField()
videos_viewed = models.IntegerField()
objects = ModuleEngagementTimelineManager()
62 changes: 59 additions & 3 deletions analytics_data_api/v0/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,13 +174,16 @@ class Meta(object):
)


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. """

Expand Down Expand Up @@ -338,8 +341,61 @@ def get_engagements(self, obj):
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)
return engagements


class DateRangeSerializer(serializers.Serializer):
start = serializers.DateField(format=settings.DATE_FORMAT)
end = serializers.DateField(format=settings.DATE_FORMAT)


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)

class Meta(object):
model = models.ModuleEngagementTimeline


class EngagementTimelineSerializer(serializers.Serializer):
"""
Serializes the learner timeline into a date range with start and end dates
and the daily engagement timeline.
"""
date_range = serializers.SerializerMethodField('get_date_range')
days = serializers.SerializerMethodField('get_days')

def get_date_range(self, obj):
queryset = obj['data']
first = queryset[0]
last = queryset[-1]
serializer_context = {
'start': first['date'],
'end': last['date']
}
serializer = DateRangeSerializer(serializer_context)
return serializer.data

def get_days(self, obj):
data = obj['data']
serializer = EngagementDaySerializer(data, many=True)
return serializer.data
27 changes: 27 additions & 0 deletions analytics_data_api/v0/tests/views/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import json

from opaque_keys.edx.keys import CourseKey
from rest_framework import status

DEMO_COURSE_ID = u'course-v1:edX+DemoX+Demo_2014'

Expand All @@ -12,3 +15,27 @@ def setUpClass(cls):
cls.course_id = DEMO_COURSE_ID
cls.course_key = CourseKey.from_string(cls.course_id)
super(DemoCourseMixin, cls).setUpClass()


class VerifyNoCourseIdMixin(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)


class VerifyBadCourseIdMixin(object):

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)
Loading

0 comments on commit c3975bc

Please sign in to comment.