Commit a232209e by Dennis Jen Committed by Daniel Friedman

Added learner details endpoint.

parent 3b4aa462
DISCUSSIONS = 'discussions'
PROBLEM = 'problem'
PROBLEMS = 'problems'
VIDEO = 'videos'
ALL = [DISCUSSIONS, PROBLEM, PROBLEMS, VIDEO]
from analytics_data_api.constants import engagement_entity_types
ATTEMPTS = 'attempts'
ATTEMPTED = 'attempted'
COMPLETED = 'completed'
CONTRIBUTED = 'contributed'
VIEWED = 'viewed'
# map entity types to events
EVENTS = {
engagement_entity_types.DISCUSSIONS: [CONTRIBUTED],
engagement_entity_types.PROBLEM: [ATTEMPTS],
engagement_entity_types.PROBLEMS: [ATTEMPTED, COMPLETED],
engagement_entity_types.VIDEO: [VIEWED],
}
default_app_config = 'analytics_data_api.v0.apps.ApiAppConfig'
from django.apps import AppConfig
from django.conf import settings
from elasticsearch_dsl import connections
class ApiAppConfig(AppConfig):
name = 'analytics_data_api.v0'
def ready(self):
super(ApiAppConfig, self).ready()
if settings.ELASTICSEARCH_LEARNERS_HOST:
connections.connections.create_connection(hosts=[settings.ELASTICSEARCH_LEARNERS_HOST])
import abc
class BaseError(Exception):
"""
Base error.
"""
__metaclass__ = abc.ABCMeta
message = None
def __str__(self):
return self.message
class LearnerNotFoundError(BaseError):
"""
Raise learner not found for a course.
"""
def __init__(self, *args, **kwargs):
course_id = kwargs.pop('course_id')
username = kwargs.pop('username')
super(LearnerNotFoundError, self).__init__(*args, **kwargs)
self.message = self.message_template.format(username=username, course_id=course_id)
@property
def message_template(self):
return 'Learner {username} not found for course {course_id}.'
class CourseNotSpecifiedError(BaseError):
"""
Raise if course not specified.
"""
def __init__(self, *args, **kwargs):
super(CourseNotSpecifiedError, self).__init__(*args, **kwargs)
self.message = 'Course id/key not specified.'
class CourseKeyMalformedError(BaseError):
"""
Raise if course id/key malformed.
"""
def __init__(self, *args, **kwargs):
course_id = kwargs.pop('course_id')
super(CourseKeyMalformedError, self).__init__(*args, **kwargs)
self.message = self.message_template.format(course_id=course_id)
@property
def message_template(self):
return 'Course id/key {course_id} malformed.'
import abc
from django.http.response import JsonResponse
from rest_framework import status
from analytics_data_api.v0.exceptions import (
LearnerNotFoundError,
CourseNotSpecifiedError,
CourseKeyMalformedError
)
class BaseProcessErrorMiddleware(object):
"""
Base error.
"""
__metaclass__ = abc.ABCMeta
@abc.abstractproperty
def error(self):
""" Error class to catch. """
pass
@abc.abstractproperty
def error_code(self):
""" Error code to return. """
pass
@abc.abstractproperty
def status_code(self):
""" HTTP status code to return. """
pass
def process_exception(self, _request, exception):
if type(exception) is self.error:
return JsonResponse({
"error_code": self.error_code,
"developer_message": str(exception)
}, status=self.status_code)
class LearnerNotFoundErrorMiddleware(BaseProcessErrorMiddleware):
"""
Raise 404 if learner not found.
"""
@property
def error(self):
return LearnerNotFoundError
@property
def error_code(self):
return 'no_learner_for_course'
@property
def status_code(self):
return status.HTTP_404_NOT_FOUND
class CourseNotSpecifiedErrorMiddleware(BaseProcessErrorMiddleware):
"""
Raise 400 course not specified.
"""
@property
def error(self):
return CourseNotSpecifiedError
@property
def error_code(self):
return 'course_not_specified'
@property
def status_code(self):
return status.HTTP_400_BAD_REQUEST
class CourseKeyMalformedErrorMiddleware(BaseProcessErrorMiddleware):
"""
Raise 400 if course key is malformed.
"""
@property
def error(self):
return CourseKeyMalformedError
@property
def error_code(self):
return 'course_key_malformed'
@property
def status_code(self):
return status.HTTP_400_BAD_REQUEST
from django.conf import settings
from django.db import models from django.db import models
from elasticsearch_dsl import DocType
from analytics_data_api.constants import country, genders from analytics_data_api.constants import country, genders
...@@ -206,3 +208,15 @@ class Video(BaseVideo): ...@@ -206,3 +208,15 @@ class Video(BaseVideo):
class Meta(BaseVideo.Meta): class Meta(BaseVideo.Meta):
db_table = 'video' db_table = 'video'
class RosterEntry(DocType):
# pylint: disable=old-style-class
class Meta:
index = settings.ELASTICSEARCH_LEARNERS_INDEX
doc_type = 'roster_entry'
@classmethod
def get_course_user(cls, course_id, username):
return cls.search().query('term', course_id=course_id).query(
'term', username=username).execute()
from urlparse import urljoin
from django.conf import settings from django.conf import settings
from rest_framework import serializers from rest_framework import serializers
from analytics_data_api.constants import enrollment_modes, genders from analytics_data_api.constants import (
engagement_entity_types,
engagement_events,
enrollment_modes,
genders,)
from analytics_data_api.v0 import models from analytics_data_api.v0 import models
...@@ -306,3 +311,35 @@ class VideoTimelineSerializer(ModelSerializerWithCreatedField): ...@@ -306,3 +311,35 @@ class VideoTimelineSerializer(ModelSerializerWithCreatedField):
'num_views', 'num_views',
'created' 'created'
) )
class LearnerSerializer(serializers.Serializer):
username = serializers.CharField()
enrollment_mode = serializers.CharField()
name = serializers.CharField()
account_url = serializers.SerializerMethodField('get_account_url')
email = serializers.CharField()
segments = serializers.Field(source='segments')
engagements = serializers.SerializerMethodField('get_engagements')
# TODO: add these back in when the index returns them
# enrollment_date = serializers.DateField(format=settings.DATE_FORMAT, allow_empty=True)
# last_updated = serializers.DateField(format=settings.DATE_FORMAT)
# cohort = serializers.CharField(allow_none=True)
def get_account_url(self, obj):
if settings.LMS_USER_ACCOUNT_BASE_URL:
return urljoin(settings.LMS_USER_ACCOUNT_BASE_URL, obj.username)
else:
return None
def get_engagements(self, obj):
"""
Add the engagement totals.
"""
engagements = {}
for entity_type in engagement_entity_types.ALL:
for event in engagement_events.EVENTS[entity_type]:
metric = '{0}_{1}'.format(entity_type, event)
engagements[metric] = getattr(obj, metric, 0)
return engagements
import json
from elasticsearch_dsl.connections import connections
from mock import patch, Mock
from rest_framework import status
from analyticsdataserver.tests import TestCaseWithAuthentication
class LearnerTests(TestCaseWithAuthentication):
path_template = '/api/v0/learners/{}/?course_id={}'
@classmethod
def get_fixture(cls):
return {
"took": 11,
"_shards": {
"total": 10,
"successful": 10,
"failed": 0
},
"hits": {
"total": 33701,
"max_score": 7.201823,
"hits": [{
"_index": "roster_1_1",
"_type": "roster_entry",
"_id": "edX/DemoX/Demo_Course|ed_xavier",
"_score": 7.201823,
"_source": {
"username": "ed_xavier",
"enrollment_mode": "honor",
"problems_attempted": 43,
"problems_completed": 3,
"problem_attempts": 0,
"course_id": "edX/DemoX/Demo_Course",
"videos_viewed": 6,
"name": "Edward Xavier",
"segments": ["has_potential"],
"email": "ed_xavier@example.com"
}
}]
}
}
@classmethod
def setUpClass(cls):
# mock the elastic search client
client = Mock()
client.search.return_value = cls.get_fixture()
connections.add_connection('default', client)
def test_get_user(self):
user_name = 'ed_xavier'
course_id = 'edX/DemoX/Demo_Course'
response = self.authenticated_get(self.path_template.format(user_name, course_id))
self.assertEquals(response.status_code, 200)
expected = {
"username": "ed_xavier",
"enrollment_mode": "honor",
"name": "Edward Xavier",
"email": "ed_xavier@example.com",
"account_url": "http://lms-host/ed_xavier",
"segments": ["has_potential"],
"engagements": {
"problem_attempts": 0,
"problems_attempted": 43,
"problems_completed": 3,
"videos_viewed": 6,
"discussions_contributed": 0
},
}
self.assertDictEqual(expected, response.data)
@patch('analytics_data_api.v0.models.RosterEntry.get_course_user', Mock(return_value=[]))
def test_not_found(self):
user_name = 'a_user'
course_id = 'edX/DemoX/Demo_Course'
response = self.authenticated_get(self.path_template.format(user_name, course_id))
self.assertEquals(response.status_code, status.HTTP_404_NOT_FOUND)
expected = {
u"error_code": u"no_learner_for_course",
u"developer_message": u"Learner a_user not found for course edX/DemoX/Demo_Course."
}
self.assertDictEqual(json.loads(response.content), expected)
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)
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)
...@@ -7,6 +7,7 @@ urlpatterns = patterns( ...@@ -7,6 +7,7 @@ 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(r'^learners/', include('analytics_data_api.v0.urls.learners', namespace='learners')),
# 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 learners as views
USERNAME_PATTERN = r'(?P<username>.+)'
LEARNERS_URLS = [
('', views.LearnerView, 'learner')
]
urlpatterns = []
for path, view, name in LEARNERS_URLS:
regex = r'^{0}/$'.format(USERNAME_PATTERN)
urlpatterns += patterns('', url(regex, view.as_view(), name=name))
"""
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.v0.exceptions import (
CourseNotSpecifiedError, CourseKeyMalformedError, LearnerNotFoundError)
from analytics_data_api.v0.models import RosterEntry
from analytics_data_api.v0.serializers import LearnerSerializer
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.
**Example Request**
GET /api/v0/learners/{username}/?course_id={course_id}
**Response Values**
Returns viewing data for each segment of a video. For each segment,
the collection contains the following data.
* segment: The order of the segment in the video timeline.
* num_users: The number of unique users who viewed this segment.
* num_views: The number of views for this segment.
* created: The date the segment data was computed.
Returns the user metadata and engagement data:
* username: User name.
* enrollment_mode: Enrollment mode (e.g. "honor).
* name: User name.
* email: User email.
* segments: Classification for this course based on engagement, (e.g. "has_potential").
* engagements: Summary of engagement events for a time span.
* videos_viewed: Number of times a video was played.
* problems_completed: Unique number of problems completed.
* problems_attempted: Unique number of problems attempted.
* problem_attempts: Number of attempts of problems.
* discussions_contributed: Number of discussions (e.g. forum posts).
**Parameters**
You can specify course ID for which you want data.
course_id -- The course within which user data is requested.
"""
serializer_class = LearnerSerializer
username = None
lookup_field = 'username'
def get(self, request, *args, **kwargs):
self.username = self.kwargs.get('username')
return super(LearnerView, self).get(request, *args, **kwargs)
def get_queryset(self):
return RosterEntry.get_course_user(self.course_id, self.username)
def get_object(self, queryset=None):
queryset = self.get_queryset()
if len(queryset) == 1:
return queryset[0]
raise LearnerNotFoundError(username=self.username, course_id=self.course_id)
...@@ -165,6 +165,9 @@ MIDDLEWARE_CLASSES = ( ...@@ -165,6 +165,9 @@ 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.LearnerNotFoundErrorMiddleware',
'analytics_data_api.v0.middleware.CourseNotSpecifiedErrorMiddleware',
'analytics_data_api.v0.middleware.CourseKeyMalformedErrorMiddleware',
) )
########## END MIDDLEWARE CONFIGURATION ########## END MIDDLEWARE CONFIGURATION
...@@ -271,7 +274,11 @@ DATABASE_ROUTERS = ['analyticsdataserver.router.AnalyticsApiRouter'] ...@@ -271,7 +274,11 @@ DATABASE_ROUTERS = ['analyticsdataserver.router.AnalyticsApiRouter']
ENABLE_ADMIN_SITE = False ENABLE_ADMIN_SITE = False
# base url to generate link to user api
LMS_USER_ACCOUNT_BASE_URL = None
########## END ANALYTICS DATA API CONFIGURATION ########## END ANALYTICS DATA API CONFIGURATION
DATE_FORMAT = '%Y-%m-%d' DATE_FORMAT = '%Y-%m-%d'
DATETIME_FORMAT = '%Y-%m-%dT%H%M%S' DATETIME_FORMAT = '%Y-%m-%dT%H%M%S'
...@@ -19,10 +19,10 @@ DATABASES = { ...@@ -19,10 +19,10 @@ DATABASES = {
}, },
'analytics': { 'analytics': {
'ENGINE': 'django.db.backends.mysql', 'ENGINE': 'django.db.backends.mysql',
'NAME': 'analytics', 'NAME': 'reports_2_0',
'USER': 'root', 'USER': 'readonly001',
'PASSWORD': '', 'PASSWORD': 'meringues unfreehold sisterize morsing',
'HOST': '', 'HOST': 'stage-edx-analytics-report-rds.edx.org',
'PORT': '', 'PORT': '3306',
} }
} }
\ No newline at end of file
...@@ -18,4 +18,6 @@ INSTALLED_APPS += ( ...@@ -18,4 +18,6 @@ INSTALLED_APPS += (
'django_nose', 'django_nose',
) )
LMS_USER_ACCOUNT_BASE_URL = 'http://lms-host'
TEST_RUNNER = 'django_nose.NoseTestSuiteRunner' TEST_RUNNER = 'django_nose.NoseTestSuiteRunner'
...@@ -6,4 +6,5 @@ ipython==2.4.1 # BSD ...@@ -6,4 +6,5 @@ ipython==2.4.1 # BSD
django-rest-swagger==0.2.8 # BSD django-rest-swagger==0.2.8 # BSD
djangorestframework-csv==1.3.3 # BSD djangorestframework-csv==1.3.3 # BSD
django-countries==3.2 # MIT django-countries==3.2 # MIT
elasticsearch-dsl==0.0.9 # Apache 2.0
-e git+https://github.com/edx/opaque-keys.git@d45d0bd8d64c69531be69178b9505b5d38806ce0#egg=opaque-keys -e git+https://github.com/edx/opaque-keys.git@d45d0bd8d64c69531be69178b9505b5d38806ce0#egg=opaque-keys
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