Commit c8c8e25f by Daniel Friedman

Fill in missing dates for engagement timeline

AN-6960
parent 24ce9709
...@@ -9,6 +9,15 @@ class EngagementType(object): ...@@ -9,6 +9,15 @@ class EngagementType(object):
- The internal question of whether the metric should be counted in terms - The internal question of whether the metric should be counted in terms
of the entity type or the raw number of events. of the entity type or the raw number of events.
""" """
# Defines the current canonical set of engagement types used in the Learner
# Analytics API.
ALL_TYPES = (
'problems_attempted',
'problems_completed',
'videos_viewed',
'discussion_contributions',
)
def __init__(self, entity_type, event_type): def __init__(self, entity_type, event_type):
""" """
Initializes an EngagementType for a particular entity and event type. Initializes an EngagementType for a particular entity and event type.
......
import datetime
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.core.management import call_command, CommandError from django.core.management import call_command, CommandError
from django.test import TestCase from django.test import TestCase
...@@ -6,7 +8,7 @@ from rest_framework.authtoken.models import Token ...@@ -6,7 +8,7 @@ from rest_framework.authtoken.models import Token
from analytics_data_api.constants.country import get_country, UNKNOWN_COUNTRY from analytics_data_api.constants.country import get_country, UNKNOWN_COUNTRY
from analytics_data_api.utils import delete_user_auth_token, set_user_auth_token from analytics_data_api.utils import date_range, delete_user_auth_token, set_user_auth_token
class UtilsTests(TestCase): class UtilsTests(TestCase):
...@@ -91,3 +93,30 @@ class CountryTests(TestCase): ...@@ -91,3 +93,30 @@ class CountryTests(TestCase):
# Return unknown country if code is invalid # Return unknown country if code is invalid
self.assertEqual(get_country('A1'), UNKNOWN_COUNTRY) self.assertEqual(get_country('A1'), UNKNOWN_COUNTRY)
self.assertEqual(get_country(None), UNKNOWN_COUNTRY) self.assertEqual(get_country(None), UNKNOWN_COUNTRY)
class DateRangeTests(TestCase):
def test_empty_range(self):
date = datetime.datetime(2016, 1, 1)
self.assertEqual([date for date in date_range(date, date)], [])
def test_range_exclusive(self):
start_date = datetime.datetime(2016, 1, 1)
end_date = datetime.datetime(2016, 1, 2)
self.assertEqual([date for date in date_range(start_date, end_date)], [start_date])
def test_delta_goes_past_end_date(self):
start_date = datetime.datetime(2016, 1, 1)
end_date = datetime.datetime(2016, 1, 3)
time_delta = datetime.timedelta(days=5)
self.assertEqual([date for date in date_range(start_date, end_date, time_delta)], [start_date])
def test_general_range(self):
start_date = datetime.datetime(2016, 1, 1)
end_date = datetime.datetime(2016, 1, 5)
self.assertEqual([date for date in date_range(start_date, end_date)], [
datetime.datetime(2016, 1, 1),
datetime.datetime(2016, 1, 2),
datetime.datetime(2016, 1, 3),
datetime.datetime(2016, 1, 4),
])
import datetime
from importlib import import_module from importlib import import_module
from django.db.models import Q from django.db.models import Q
...@@ -60,3 +61,26 @@ def load_fully_qualified_definition(definition): ...@@ -60,3 +61,26 @@ def load_fully_qualified_definition(definition):
module_name, class_name = definition.rsplit('.', 1) module_name, class_name = definition.rsplit('.', 1)
module = import_module(module_name) module = import_module(module_name)
return getattr(module, class_name) return getattr(module, class_name)
def date_range(start_date, end_date, delta=datetime.timedelta(days=1)):
"""
Returns a generator that iterates over the date range [start_date, end_date)
(start_date inclusive, end_date exclusive). Each date in the range is
offset from the previous date by a change of `delta`, which defaults
to one day.
Arguments:
start_date (datetime.datetime): The start date of the range, inclusive
end_date (datetime.datetime): The end date of the range, exclusive
delta (datetime.timedelta): The change in time between dates in the
range.
Returns:
Generator: A generator which iterates over all dates in the specified
range.
"""
cur_date = start_date
while cur_date < end_date:
yield cur_date
cur_date += delta
import datetime
from itertools import groupby from itertools import groupby
from django.conf import settings from django.conf import settings
...@@ -8,6 +9,7 @@ from elasticsearch_dsl import Date, DocType, Float, Integer, Q, String # pylint ...@@ -8,6 +9,7 @@ from elasticsearch_dsl import Date, DocType, Float, Integer, Q, String # pylint
from analytics_data_api.constants import country, genders, learner from analytics_data_api.constants import country, genders, learner
from analytics_data_api.constants.engagement_types import EngagementType from analytics_data_api.constants.engagement_types import EngagementType
from analytics_data_api.utils import date_range
class CourseActivityWeekly(models.Model): class CourseActivityWeekly(models.Model):
...@@ -392,7 +394,7 @@ class ModuleEngagementTimelineManager(models.Manager): ...@@ -392,7 +394,7 @@ class ModuleEngagementTimelineManager(models.Manager):
Modifies the ModuleEngagement queryset to aggregate engagement data for Modifies the ModuleEngagement queryset to aggregate engagement data for
the learner engagement timeline. the learner engagement timeline.
""" """
def get_timelines(self, course_id, username): def get_timeline(self, course_id, username):
queryset = ModuleEngagement.objects.all().filter(course_id=course_id, username=username) \ queryset = ModuleEngagement.objects.all().filter(course_id=course_id, username=username) \
.values('date', 'entity_type', 'event') \ .values('date', 'entity_type', 'event') \
.annotate(total_count=Sum('count')) \ .annotate(total_count=Sum('count')) \
...@@ -418,7 +420,24 @@ class ModuleEngagementTimelineManager(models.Manager): ...@@ -418,7 +420,24 @@ class ModuleEngagementTimelineManager(models.Manager):
day[engagement_type.name] = day.get(engagement_type.name, 0) + count_delta day[engagement_type.name] = day.get(engagement_type.name, 0) + count_delta
timelines.append(day) timelines.append(day)
return timelines # Fill in dates that may be missing, since the result store doesn't
# store empty engagement entries.
full_timeline = []
default_timeline_entry = {engagement_type: 0 for engagement_type in EngagementType.ALL_TYPES}
for index, current_date in enumerate(timelines):
full_timeline.append(current_date)
try:
next_date = timelines[index + 1]
except IndexError:
continue
one_day = datetime.timedelta(days=1)
if next_date['date'] > current_date['date'] + one_day:
full_timeline += [
dict(date=date, **default_timeline_entry)
for date in date_range(current_date['date'] + one_day, next_date['date'])
]
return full_timeline
class ModuleEngagement(models.Model): class ModuleEngagement(models.Model):
......
...@@ -126,6 +126,13 @@ class EngagementTimelineTests(DemoCourseMixin, VerifyCourseIdMixin, TestCaseWith ...@@ -126,6 +126,13 @@ class EngagementTimelineTests(DemoCourseMixin, VerifyCourseIdMixin, TestCaseWith
'videos_viewed': 1 'videos_viewed': 1
}, },
{ {
'date': '2015-05-27',
'discussion_contributions': 0,
'problems_attempted': 0,
'problems_completed': 0,
'videos_viewed': 0
},
{
'date': '2015-05-28', 'date': '2015-05-28',
'discussion_contributions': 0, 'discussion_contributions': 0,
'problems_attempted': 1, 'problems_attempted': 1,
......
...@@ -317,7 +317,7 @@ class EngagementTimelineView(CourseViewMixin, generics.ListAPIView): ...@@ -317,7 +317,7 @@ class EngagementTimelineView(CourseViewMixin, generics.ListAPIView):
return super(EngagementTimelineView, self).get(request, *args, **kwargs) return super(EngagementTimelineView, self).get(request, *args, **kwargs)
def get_queryset(self): def get_queryset(self):
queryset = ModuleEngagement.objects.get_timelines(self.course_id, self.username) queryset = ModuleEngagement.objects.get_timeline(self.course_id, self.username)
if len(queryset) == 0: if len(queryset) == 0:
raise LearnerEngagementTimelineNotFoundError(username=self.username, course_id=self.course_id) raise LearnerEngagementTimelineNotFoundError(username=self.username, course_id=self.course_id)
return queryset return queryset
......
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