Commit dbde3921 by Clinton Blackburn

Added enrollment-location resource

Change-Id: I14c30ad846ae873296ebb6b27bea0e540e1f1e96
parent 3a917a41
......@@ -50,7 +50,7 @@ syncdb:
$(foreach db_name,$(DATABASES),./manage.py syncdb --migrate --noinput --database=$(db_name);)
loaddata: syncdb
python manage.py loaddata courses education_levels single_course_activity course_enrollment_birth_year course_enrollment_education course_enrollment_gender problem_response_answer_distribution course_enrollment_daily --database=analytics
python manage.py loaddata courses education_levels single_course_activity course_enrollment_birth_year course_enrollment_education course_enrollment_gender problem_response_answer_distribution course_enrollment_daily countries course_enrollment_country --database=analytics
demo: clean requirements loaddata
python manage.py set_api_key analytics analytics
[
{
"model": "v0.CourseEnrollmentByCountry",
"fields": {
"course": ["edX/DemoX/Demo_Course"],
"country": "US",
"date": "2014-06-01",
"count": 100
}
},
{
"model": "v0.CourseEnrollmentByCountry",
"fields": {
"course": ["edX/DemoX/Demo_Course"],
"country": "IN",
"date": "2014-06-01",
"count": 240
}
},
{
"model": "v0.CourseEnrollmentByCountry",
"fields": {
"course": ["edX/DemoX/Demo_Course"],
"country": "US",
"date": "2014-06-02",
"count": 106
}
},
{
"model": "v0.CourseEnrollmentByCountry",
"fields": {
"course": ["edX/DemoX/Demo_Course"],
"country": "IN",
"date": "2014-06-02",
"count": 199
}
},
{
"model": "v0.CourseEnrollmentByCountry",
"fields": {
"course": ["edX/DemoX/Demo_Course"],
"country": "US",
"date": "2014-06-03",
"count": 200
}
},
{
"model": "v0.CourseEnrollmentByCountry",
"fields": {
"course": ["edX/DemoX/Demo_Course"],
"country": "IN",
"date": "2014-06-03",
"count": 300
}
},
{
"model": "v0.CourseEnrollmentByCountry",
"fields": {
"course": ["edX/DemoX/Demo_Course"],
"country": "IS",
"date": "2014-06-03",
"count": 6
}
}
]
......@@ -4,3 +4,8 @@ from django.db import models
class CourseManager(models.Manager):
def get_by_natural_key(self, course_id):
return self.get(course_id=course_id)
class CountryManager(models.Manager):
def get_by_natural_key(self, code):
return self.get(code=code)
from django.db import models
from analytics_data_api.v0.managers import CourseManager
from analytics_data_api.v0.managers import CourseManager, CountryManager
class Course(models.Model):
......@@ -36,23 +36,22 @@ class BaseCourseEnrollment(models.Model):
class Meta(object):
abstract = True
unique_together = [('course', 'date',)]
get_latest_by = 'date'
class CourseEnrollmentDaily(BaseCourseEnrollment):
class Meta(object):
class Meta(BaseCourseEnrollment.Meta):
db_table = 'course_enrollment_daily'
ordering = ('course', '-date')
ordering = ('-date', 'course')
unique_together = [('course', 'date',)]
get_latest_by = 'date'
class CourseEnrollmentByBirthYear(BaseCourseEnrollment):
birth_year = models.IntegerField(null=False)
class Meta(object):
class Meta(BaseCourseEnrollment.Meta):
db_table = 'course_enrollment_birth_year'
ordering = ('course', 'birth_year')
ordering = ('-date', 'birth_year', 'course')
unique_together = [('course', 'date', 'birth_year')]
......@@ -63,22 +62,25 @@ class EducationLevel(models.Model):
class Meta(object):
db_table = 'education_levels'
def __unicode__(self):
return "{0} - {1}".format(self.short_name, self.name)
class CourseEnrollmentByEducation(BaseCourseEnrollment):
education_level = models.ForeignKey(EducationLevel)
class Meta(object):
class Meta(BaseCourseEnrollment.Meta):
db_table = 'course_enrollment_education_level'
ordering = ('course', 'education_level')
ordering = ('-date', 'education_level', 'course')
unique_together = [('course', 'date', 'education_level')]
class CourseEnrollmentByGender(BaseCourseEnrollment):
gender = models.CharField(max_length=255, null=False)
class Meta(object):
class Meta(BaseCourseEnrollment.Meta):
db_table = 'course_enrollment_gender'
ordering = ('course', 'gender')
ordering = ('-date', 'gender', 'course')
unique_together = [('course', 'date', 'gender')]
......@@ -98,3 +100,25 @@ class ProblemResponseAnswerDistribution(models.Model):
answer_value_numeric = models.FloatField(db_column='answer_value_numeric', null=True)
variant = models.IntegerField(db_column='variant', null=True)
created = models.DateTimeField(auto_now_add=True, db_column='created')
class Country(models.Model):
code = models.CharField(max_length=2, primary_key=True)
name = models.CharField(max_length=255, unique=True, null=False)
objects = CountryManager() # pylint: disable=no-value-for-parameter
class Meta(object):
db_table = 'countries'
def __unicode__(self):
return "{0} - {1}".format(self.code, self.name)
class CourseEnrollmentByCountry(BaseCourseEnrollment):
country = models.ForeignKey(Country, null=False, db_column='country_code')
class Meta(BaseCourseEnrollment.Meta):
db_table = 'course_enrollment_location'
ordering = ('-date', 'country', 'course')
unique_together = [('course', 'date', 'country')]
from django.conf import settings
from rest_framework import serializers
from analytics_data_api.v0.models import CourseActivityByWeek, ProblemResponseAnswerDistribution, CourseEnrollmentDaily
from analytics_data_api.v0.models import CourseActivityByWeek, ProblemResponseAnswerDistribution, \
CourseEnrollmentDaily, CourseEnrollmentByCountry, Country
class CourseIdMixin(object):
......@@ -7,6 +9,10 @@ class CourseIdMixin(object):
return obj.course.course_id
class RequiredSerializerMethodField(serializers.SerializerMethodField):
required = True
class CourseActivityByWeekSerializer(serializers.ModelSerializer, CourseIdMixin):
"""
Representation of CourseActivityByWeek that excludes the id field.
......@@ -14,7 +20,8 @@ class CourseActivityByWeekSerializer(serializers.ModelSerializer, CourseIdMixin)
This table is managed by the data pipeline, and records can be removed and added at any time. The id for a
particular record is likely to change unexpectedly so we avoid exposing it.
"""
course_id = serializers.SerializerMethodField('get_course_id')
course_id = RequiredSerializerMethodField('get_course_id')
class Meta(object):
model = CourseActivityByWeek
......@@ -50,8 +57,25 @@ class CourseEnrollmentDailySerializer(serializers.ModelSerializer, CourseIdMixin
Representation of course enrollment for a single day and course.
"""
course_id = serializers.SerializerMethodField('get_course_id')
course_id = RequiredSerializerMethodField('get_course_id')
class Meta(object):
model = CourseEnrollmentDaily
fields = ('course_id', 'date', 'count')
# pylint: disable=no-value-for-parameter
class CountrySerializer(serializers.ModelSerializer):
class Meta(object):
model = Country
fields = ('code', 'name')
class CourseEnrollmentByCountrySerializer(serializers.ModelSerializer, CourseIdMixin):
course_id = RequiredSerializerMethodField('get_course_id')
country = CountrySerializer()
date = serializers.DateField(format=settings.DATE_FORMAT)
class Meta(object):
model = CourseEnrollmentByCountry
fields = ('date', 'course_id', 'country', 'count')
from django.core.exceptions import ObjectDoesNotExist
from django.test import TestCase
from django_dynamic_fixture import G
from analytics_data_api.v0.models import Course, Country
class CourseManagerTests(TestCase):
def test_get_by_natural_key(self):
course_id = 'edX/DemoX/Demo_Course'
self.assertRaises(ObjectDoesNotExist, Course.objects.get_by_natural_key, course_id)
course = G(Course, course_id=course_id)
self.assertEqual(course, Course.objects.get_by_natural_key(course_id))
class CountryManagerTests(TestCase):
def test_get_by_natural_key(self):
code = 'US'
self.assertRaises(ObjectDoesNotExist, Country.objects.get_by_natural_key, code)
country = G(Country, code=code)
self.assertEqual(country, Country.objects.get_by_natural_key(code))
from django.test import TestCase
from django_dynamic_fixture import G
from analytics_data_api.v0.models import EducationLevel, Country
class EducationLevelTests(TestCase):
def test_unicode(self):
short_name = 'high_school'
name = 'High School'
education_level = G(EducationLevel, short_name=short_name, name=name)
self.assertEqual(unicode(education_level), "{0} - {1}".format(short_name, name))
class CountryTests(TestCase):
def test_unicode(self):
code = 'US'
name = 'United States of America'
country = G(Country, code=code, name=name)
self.assertEqual(unicode(country), "{0} - {1}".format(code, name))
......@@ -2,16 +2,17 @@
# change for versions greater than 1.0.0. Tests target a specific version of the API, additional tests should be added
# for subsequent versions if there are breaking changes introduced in those versions.
from datetime import datetime, date
import datetime
import random
from django.core.exceptions import ObjectDoesNotExist
from django.test import TestCase
from django.conf import settings
from django_dynamic_fixture import G
import pytz
from analytics_data_api.v0.models import CourseEnrollmentByBirthYear, CourseEnrollmentByEducation, EducationLevel, \
CourseEnrollmentByGender, CourseActivityByWeek, Course, ProblemResponseAnswerDistribution, CourseEnrollmentDaily
CourseEnrollmentByGender, CourseActivityByWeek, Course, ProblemResponseAnswerDistribution, CourseEnrollmentDaily, \
Country, \
CourseEnrollmentByCountry
from analytics_data_api.v0.serializers import ProblemResponseAnswerDistributionSerializer
from analyticsdataserver.tests import TestCaseWithAuthentication
......@@ -41,8 +42,8 @@ class CourseActivityLastWeekTest(TestCaseWithAuthentication):
def get_activity_record(**kwargs):
default = {
'course_id': 'edX/DemoX/Demo_Course',
'interval_start': datetime(2014, 5, 24, 0, 0, tzinfo=pytz.utc),
'interval_end': datetime(2014, 6, 1, 0, 0, tzinfo=pytz.utc),
'interval_start': datetime.datetime(2014, 5, 24, 0, 0, tzinfo=pytz.utc),
'interval_end': datetime.datetime(2014, 6, 1, 0, 0, tzinfo=pytz.utc),
'activity_type': 'any',
'count': 300,
}
......@@ -171,15 +172,6 @@ class CourseEnrollmentByGenderViewTests(TestCaseWithAuthentication, CourseEnroll
self.assertEquals(actual, expected)
class CourseManagerTests(TestCase):
def test_get_by_natural_key(self):
course_id = 'edX/DemoX/Demo_Course'
self.assertRaises(ObjectDoesNotExist, Course.objects.get_by_natural_key, course_id)
course = G(Course, course_id=course_id)
self.assertEqual(course, Course.objects.get_by_natural_key(course_id))
# pylint: disable=no-member,no-value-for-parameter
class AnswerDistributionTests(TestCaseWithAuthentication):
path = '/answer_distribution'
......@@ -218,10 +210,60 @@ class CourseEnrollmentLatestViewTests(TestCaseWithAuthentication, CourseEnrollme
@classmethod
def setUpClass(cls):
cls.course = G(Course)
cls.ce = G(CourseEnrollmentDaily, course=cls.course, date=date(2014, 1, 1), count=203)
cls.ce = G(CourseEnrollmentDaily, course=cls.course, date=datetime.date(2014, 1, 1), count=203)
def test_get(self):
response = self.authenticated_get('/api/v0/courses/%s%s' % (self.course.course_id, self.path,))
self.assertEquals(response.status_code, 200)
expected = {'course_id': self.ce.course.course_id, 'count': self.ce.count, 'date': self.ce.date}
self.assertDictEqual(response.data, expected)
class CourseEnrollmentByLocationViewTests(TestCaseWithAuthentication, CourseEnrollmentViewTestCase):
path = '/enrollment/location/'
model = CourseEnrollmentByCountry
def get_expected_response(self, *args):
return [{'course_id': ce.course.course_id, 'count': ce.count, 'date': ce.date.strftime(settings.DATE_FORMAT),
'country': {'code': ce.country.code, 'name': ce.country.name}} for ce in args]
def test_get(self):
course = G(Course)
date1 = datetime.date(2014, 1, 1)
date2 = datetime.date(2013, 1, 1)
ce1 = G(CourseEnrollmentByCountry, course=course, country=G(Country), count=455, date=date1)
ce2 = G(CourseEnrollmentByCountry, course=course, country=G(Country), count=356, date=date1)
# This should not be returned as the view should return only the latest data when no interval is supplied.
G(CourseEnrollmentByCountry, course=course, country=G(Country), count=12, date=date2)
response = self.authenticated_get('/api/v0/courses/%s%s' % (course.course_id, self.path,))
self.assertEquals(response.status_code, 200)
expected = self.get_expected_response(ce1, ce2)
self.assertListEqual(response.data, expected)
def test_get_with_intervals(self):
course = G(Course)
country1 = G(Country)
country2 = G(Country)
date = datetime.date(2014, 1, 1)
ce1 = G(CourseEnrollmentByCountry, course=course, country=country1, date=date)
ce2 = G(CourseEnrollmentByCountry, course=course, country=country2, date=date)
# If start date is after date of existing data, no data should be returned
response = self.authenticated_get('/api/v0/courses/%s%s?start_date=2014-02-01' % (course.course_id, self.path,))
self.assertEquals(response.status_code, 200)
self.assertListEqual([], response.data)
# If end date is before date of existing data, no data should be returned
response = self.authenticated_get('/api/v0/courses/%s%s?end_date=2013-02-01' % (course.course_id, self.path,))
self.assertEquals(response.status_code, 200)
self.assertListEqual([], response.data)
# If data falls in date range, data should be returned
response = self.authenticated_get(
'/api/v0/courses/%s%s?start_date=2013-02-01&end_date=2014-02-01' % (course.course_id, self.path,))
self.assertEquals(response.status_code, 200)
expected = self.get_expected_response(ce1, ce2)
self.assertListEqual(response.data, expected)
......@@ -2,16 +2,16 @@ import re
from django.conf.urls import patterns, url
from analytics_data_api.v0.views.courses import CourseActivityMostRecentWeekView, CourseEnrollmentByEducationView, \
CourseEnrollmentByBirthYearView, CourseEnrollmentByGenderView, CourseEnrollmentLatestView
from analytics_data_api.v0.views import courses as views
COURSE_URLS = [
('recent_activity', CourseActivityMostRecentWeekView, 'recent_activity'),
('enrollment', CourseEnrollmentLatestView, 'enrollment_latest'),
('enrollment/birth_year', CourseEnrollmentByBirthYearView, 'enrollment_by_birth_year'),
('enrollment/education', CourseEnrollmentByEducationView, 'enrollment_by_education'),
('enrollment/gender', CourseEnrollmentByGenderView, 'enrollment_by_gender'),
('recent_activity', views.CourseActivityMostRecentWeekView, 'recent_activity'),
('enrollment', views.CourseEnrollmentLatestView, 'enrollment_latest'),
('enrollment/birth_year', views.CourseEnrollmentByBirthYearView, 'enrollment_by_birth_year'),
('enrollment/education', views.CourseEnrollmentByEducationView, 'enrollment_by_education'),
('enrollment/gender', views.CourseEnrollmentByGenderView, 'enrollment_by_gender'),
('enrollment/location', views.CourseEnrollmentByLocationView, 'enrollment_by_location'),
]
urlpatterns = []
......
import datetime
from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist
from django.db.models import Max
from django.http import Http404
from rest_framework import generics
from rest_framework.generics import RetrieveAPIView
from rest_framework.generics import RetrieveAPIView, get_object_or_404
from rest_framework.response import Response
from rest_framework.views import APIView
from analytics_data_api.v0.models import CourseActivityByWeek, CourseEnrollmentByBirthYear, \
CourseEnrollmentByEducation, CourseEnrollmentByGender, CourseEnrollmentDaily
from analytics_data_api.v0.serializers import CourseActivityByWeekSerializer, CourseEnrollmentDailySerializer
CourseEnrollmentByEducation, CourseEnrollmentByGender, CourseEnrollmentByCountry, CourseEnrollmentDaily, Course
from analytics_data_api.v0.serializers import CourseActivityByWeekSerializer, CourseEnrollmentByCountrySerializer, \
CourseEnrollmentDailySerializer
class CourseActivityMostRecentWeekView(generics.RetrieveAPIView):
......@@ -61,7 +65,7 @@ class AbstractCourseEnrollmentView(APIView):
"""
raise NotImplementedError('Subclasses must define a render_data method!')
def get(self, request, *args, **kwargs): # pylint: disable=unused-argument
def get(self, request, *args, **kwargs): # pylint: disable=unused-argument
if not self.model:
raise NotImplementedError('Subclasses must specify a model!')
......@@ -133,3 +137,46 @@ class CourseEnrollmentLatestView(RetrieveAPIView):
return CourseEnrollmentDaily.objects.filter(course__course_id=course_id).order_by('-date')[0]
except IndexError:
raise Http404
# pylint: disable=line-too-long
class CourseEnrollmentByLocationView(generics.ListAPIView):
"""
Course enrollment broken down by user location
Returns the enrollment of a course with users binned by their location. Location is calculated based on the user's
IP address. If no start or end dates are passed, the data for the latest date is returned.
Countries are denoted by their <a href="http://www.iso.org/iso/country_codes/country_codes" target="_blank">ISO 3166 country code</a>.
Date format: YYYY-mm-dd (e.g. 2014-01-31)
start_date -- Date after which all data should be returned (inclusive)
end_date -- Date before which all data should be returned (exclusive)
"""
serializer_class = CourseEnrollmentByCountrySerializer
def get_queryset(self):
course = get_object_or_404(Course, course_id=self.kwargs.get('course_id'))
queryset = CourseEnrollmentByCountry.objects.filter(course=course)
if 'start_date' in self.request.QUERY_PARAMS or 'end_date' in self.request.QUERY_PARAMS:
# Filter by start/end date
start_date = self.request.QUERY_PARAMS.get('start_date')
if start_date:
start_date = datetime.datetime.strptime(start_date, settings.DATE_FORMAT)
queryset = queryset.filter(date__gte=start_date)
end_date = self.request.QUERY_PARAMS.get('end_date')
if end_date:
end_date = datetime.datetime.strptime(end_date, settings.DATE_FORMAT)
queryset = queryset.filter(date__lt=end_date)
else:
# No date filter supplied, so only return data for the latest date
latest_date = queryset.aggregate(Max('date'))
if latest_date:
latest_date = latest_date['date__max']
queryset = queryset.filter(date=latest_date)
return queryset
from django.conf import settings
class DatabaseFromSettingRouter(object):
class AnalyticsApiRouter(object):
def db_for_read(self, model, **hints): # pylint: disable=unused-argument
return self._get_database(model)
......@@ -9,9 +9,6 @@ class DatabaseFromSettingRouter(object):
if model._meta.app_label == 'v0': # pylint: disable=protected-access
return getattr(settings, 'ANALYTICS_DATABASE', 'default')
if getattr(model, 'db_from_setting', None):
return getattr(settings, model.db_from_setting, 'default')
return None
def db_for_write(self, model, **hints): # pylint: disable=unused-argument
......
......@@ -257,8 +257,10 @@ REST_FRAMEWORK = {
########## ANALYTICS DATA API CONFIGURATION
ANALYTICS_DATABASE = 'default'
DATABASE_ROUTERS = ['analyticsdataserver.router.DatabaseFromSettingRouter']
DATABASE_ROUTERS = ['analyticsdataserver.router.AnalyticsApiRouter']
ENABLE_ADMIN_SITE = False
########## END ANALYTICS DATA API CONFIGURATION
DATE_FORMAT = '%Y-%m-%d'
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