Commit 98081a29 by Daniel Friedman

Implement Learner List endpoint

AN-6158
parent 0123b1d1
LEARNER_API_DEFAULT_LIST_PAGE_SIZE = 25
...@@ -50,3 +50,10 @@ class CourseKeyMalformedError(BaseError): ...@@ -50,3 +50,10 @@ class CourseKeyMalformedError(BaseError):
@property @property
def message_template(self): def message_template(self):
return 'Course id/key {course_id} malformed.' return 'Course id/key {course_id} malformed.'
class ParameterValueError(BaseError):
"""Raise if multiple incompatible parameters were provided."""
def __init__(self, message, *args, **kwargs):
super(ParameterValueError, self).__init__(*args, **kwargs)
self.message = message
...@@ -3,9 +3,10 @@ from django.http.response import JsonResponse ...@@ -3,9 +3,10 @@ from django.http.response import JsonResponse
from rest_framework import status from rest_framework import status
from analytics_data_api.v0.exceptions import ( from analytics_data_api.v0.exceptions import (
LearnerNotFoundError, CourseKeyMalformedError,
CourseNotSpecifiedError, CourseNotSpecifiedError,
CourseKeyMalformedError ParameterValueError,
LearnerNotFoundError,
) )
...@@ -91,3 +92,21 @@ class CourseKeyMalformedErrorMiddleware(BaseProcessErrorMiddleware): ...@@ -91,3 +92,21 @@ class CourseKeyMalformedErrorMiddleware(BaseProcessErrorMiddleware):
@property @property
def status_code(self): def status_code(self):
return status.HTTP_400_BAD_REQUEST return status.HTTP_400_BAD_REQUEST
class ParameterValueErrorMiddleware(BaseProcessErrorMiddleware):
"""
Raise 400 if illegal parameter values are provided.
"""
@property
def error(self):
return ParameterValueError
@property
def error_code(self):
return 'illegal_parameter_values'
@property
def status_code(self):
return status.HTTP_400_BAD_REQUEST
from django.conf import settings from django.conf import settings
from django.db import models from django.db import models
from elasticsearch_dsl import DocType from elasticsearch_dsl import DocType, Q
from analytics_data_api.constants import country, genders from analytics_data_api.constants import country, genders
...@@ -220,3 +220,55 @@ class RosterEntry(DocType): ...@@ -220,3 +220,55 @@ class RosterEntry(DocType):
def get_course_user(cls, course_id, username): def get_course_user(cls, course_id, username):
return cls.search().query('term', course_id=course_id).query( return cls.search().query('term', course_id=course_id).query(
'term', username=username).execute() 'term', username=username).execute()
@classmethod
def get_users_in_course(
cls,
course_id,
segments=None,
ignore_segments=None,
# TODO: enable during https://openedx.atlassian.net/browse/AN-6319
# cohort=None,
enrollment_mode=None,
text_search=None,
order_by='username',
sort_order='asc'
):
"""
Construct a search query for all users in `course_id` and return
the Search object. Raises `ValueError` if both `segments` and
`ignore_segments` are provided.
"""
if segments and ignore_segments:
raise ValueError('Cannot combine `segments` and `ignore_segments` parameters.')
search = cls.search()
search.query = Q('bool', must=[Q('term', course_id=course_id)])
# Filtering/Search
if segments:
search.query.must.append(Q('bool', should=[Q('term', segments=segment) for segment in segments]))
elif ignore_segments:
for segment in ignore_segments:
search = search.query(~Q('term', segments=segment))
# TODO: enable during https://openedx.atlassian.net/browse/AN-6319
# if cohort:
# search = search.query('term', cohort=cohort)
if enrollment_mode:
search = search.query('term', enrollment_mode=enrollment_mode)
if text_search:
search.query.must.append(Q('multi_match', query=text_search, fields=['name', 'username', 'email']))
# Sorting
order_by_options = (
'username', 'email', 'discussions_contributed', 'problems_attempted', 'problems_completed', 'videos_viewed'
)
sort_order_options = ('asc', 'desc')
if order_by not in order_by_options:
raise ValueError('order_by value must be one of: {}'.format(', '.join(order_by_options)))
if sort_order not in sort_order_options:
raise ValueError('sort_order value must be one of: {}'.format(', '.join(sort_order_options)))
sort_term = order_by if sort_order == 'asc' else '-{}'.format(order_by)
search = search.sort(sort_term)
return search
from urlparse import urljoin from urlparse import urljoin
from django.conf import settings from django.conf import settings
from rest_framework import serializers from rest_framework import pagination, serializers
from analytics_data_api.constants import ( from analytics_data_api.constants import (
engagement_entity_types, engagement_entity_types,
engagement_events, engagement_events,
enrollment_modes, enrollment_modes,
genders,) genders,
)
from analytics_data_api.v0 import models from analytics_data_api.v0 import models
...@@ -343,3 +344,24 @@ class LearnerSerializer(serializers.Serializer): ...@@ -343,3 +344,24 @@ class LearnerSerializer(serializers.Serializer):
metric = '{0}_{1}'.format(entity_type, event) metric = '{0}_{1}'.format(entity_type, event)
engagements[metric] = getattr(obj, metric, 0) engagements[metric] = getattr(obj, metric, 0)
return engagements return engagements
class EdxPaginationSerializer(pagination.PaginationSerializer):
"""
Adds values to the response according to edX REST API Conventions.
"""
count = serializers.Field(source='paginator.count')
num_pages = serializers.Field(source='paginator.num_pages')
class ElasticsearchDSLSearchSerializer(EdxPaginationSerializer):
def __init__(self, *args, **kwargs):
"""Make sure that the elasticsearch query is executed."""
# Because the elasticsearch-dsl search object has a different
# API from the queryset object that's expected by the django
# Paginator object, we have to manually execute the query.
# Note that the `kwargs['instance']` is the Page object, and
# `kwargs['instance'].object_list` is actually an
# elasticsearch-dsl search object.
kwargs['instance'].object_list = kwargs['instance'].object_list.execute()
super(ElasticsearchDSLSearchSerializer, self).__init__(*args, **kwargs)
import json import json
from urllib import urlencode
import ddt
from elasticsearch import Elasticsearch from elasticsearch import Elasticsearch
from mock import patch, Mock from mock import patch, Mock
from rest_framework import status from rest_framework import status
...@@ -101,7 +103,7 @@ class LearnerAPITestMixin(object): ...@@ -101,7 +103,7 @@ class LearnerAPITestMixin(object):
} }
) )
def create_learners(self, *learners): def create_learners(self, learners):
""" """
Creates multiple learner roster entries. `learners` is a list of Creates multiple learner roster entries. `learners` is a list of
dicts, each representing a learner which must at least contain dicts, each representing a learner which must at least contain
...@@ -115,11 +117,13 @@ class LearnerAPITestMixin(object): ...@@ -115,11 +117,13 @@ class LearnerAPITestMixin(object):
class LearnerTests(LearnerAPITestMixin, TestCaseWithAuthentication): class LearnerTests(LearnerAPITestMixin, TestCaseWithAuthentication):
"""Tests for the single learner endpoint."""
path_template = '/api/v0/learners/{}/?course_id={}' path_template = '/api/v0/learners/{}/?course_id={}'
def setUp(self): def setUp(self):
super(LearnerTests, self).setUp() super(LearnerTests, self).setUp()
self.create_learners({ self.create_learners([{
"username": "ed_xavier", "username": "ed_xavier",
"name": "Edward Xavier", "name": "Edward Xavier",
"course_id": "edX/DemoX/Demo_Course", "course_id": "edX/DemoX/Demo_Course",
...@@ -128,7 +132,7 @@ class LearnerTests(LearnerAPITestMixin, TestCaseWithAuthentication): ...@@ -128,7 +132,7 @@ class LearnerTests(LearnerAPITestMixin, TestCaseWithAuthentication):
"problems_completed": 3, "problems_completed": 3,
"videos_viewed": 6, "videos_viewed": 6,
"discussions_contributed": 0 "discussions_contributed": 0
}) }])
def test_get_user(self): def test_get_user(self):
user_name = 'ed_xavier' user_name = 'ed_xavier'
...@@ -185,3 +189,236 @@ class LearnerTests(LearnerAPITestMixin, TestCaseWithAuthentication): ...@@ -185,3 +189,236 @@ class LearnerTests(LearnerAPITestMixin, TestCaseWithAuthentication):
u"developer_message": u"Course id/key malformed-course-id malformed." u"developer_message": u"Course id/key malformed-course-id malformed."
} }
self.assertDictEqual(json.loads(response.content), expected) self.assertDictEqual(json.loads(response.content), expected)
@ddt.ddt
class LearnerListTests(LearnerAPITestMixin, TestCaseWithAuthentication):
"""Tests for the learner list endpoint."""
def setUp(self):
super(LearnerListTests, self).setUp()
self.course_id = 'edX/DemoX/Demo_Course'
def _get(self, course_id, **query_params):
"""Helper to send a GET request to the API."""
query_params['course_id'] = course_id
return self.authenticated_get('/api/v0/learners/', query_params)
def assert_learners_returned(self, response, expected_learners):
"""
Verify that the learners in the response match the expected
learners, in order. Each learner in `expected_learners` is a
dictionary subset of the expected returned representation. If
`expected_learners` is None, assert that no learners were
returned.
"""
self.assertEqual(response.status_code, 200)
payload = json.loads(response.content)
returned_learners = payload['results']
if expected_learners is None:
self.assertEqual(returned_learners, list())
else:
self.assertEqual(len(expected_learners), len(returned_learners))
for expected_learner, returned_learner in zip(expected_learners, returned_learners):
self.assertDictContainsSubset(expected_learner, returned_learner)
def test_all_learners(self):
usernames = ['dan', 'dennis', 'victor', 'olga', 'gabe', 'brian', 'alison']
self.create_learners([{'username': username, 'course_id': self.course_id} for username in usernames])
response = self._get(self.course_id)
# Default ordering is by username
self.assert_learners_returned(response, [{'username': username} for username in sorted(usernames)])
def test_course_id(self):
self.create_learners([
{'username': 'user_1', 'course_id': self.course_id},
{'username': 'user_2', 'course_id': 'other/course/id'}
])
response = self._get(self.course_id)
self.assert_learners_returned(response, [{'username': 'user_1'}])
def test_data(self):
self.create_learners([{
'username': 'user_1',
'course_id': self.course_id,
'enrollment_mode': 'honor',
'segments': ['a', 'b'],
# TODO: enable during https://openedx.atlassian.net/browse/AN-6319
# 'cohort': 'alpha',
"problems_attempted": 43,
"problems_completed": 3,
"videos_viewed": 6,
"discussions_contributed": 0
}])
response = self._get(self.course_id)
self.assert_learners_returned(response, [{
'username': 'user_1',
'enrollment_mode': 'honor',
'segments': ['a', 'b'],
# TODO: enable during https://openedx.atlassian.net/browse/AN-6319
# 'cohort': 'alpha',
"engagements": {
"problems_attempted": 43,
"problems_completed": 3,
"videos_viewed": 6,
"discussions_contributed": 0
}
}])
@ddt.data(
('segments', ['a'], 'segments', 'a', True),
('segments', ['a', 'b'], 'segments', 'a', True),
('segments', ['a', 'b'], 'segments', 'b', True),
('segments', ['a', 'b'], 'segments', 'a,b', True),
('segments', ['a', 'b'], 'segments', '', True),
('segments', ['a', 'b'], 'segments', 'c', False),
('segments', ['a'], 'ignore_segments', 'a', False),
('segments', ['a', 'b'], 'ignore_segments', 'a', False),
('segments', ['a', 'b'], 'ignore_segments', 'b', False),
('segments', ['a', 'b'], 'ignore_segments', 'a,b', False),
('segments', ['a', 'b'], 'ignore_segments', '', True),
('segments', ['a', 'b'], 'ignore_segments', 'c', True),
# TODO: enable during https://openedx.atlassian.net/browse/AN-6319
# ('cohort', 'a', 'cohort', 'a', True),
# ('cohort', 'a', 'cohort', '', True),
# ('cohort', 'a', 'cohort', 'b', False),
('enrollment_mode', 'a', 'enrollment_mode', 'a', True),
('enrollment_mode', 'a', 'enrollment_mode', '', True),
('enrollment_mode', 'a', 'enrollment_mode', 'b', False),
('name', 'daniel', 'text_search', 'daniel', True),
('username', 'daniel', 'text_search', 'daniel', True),
('email', 'daniel@example.com', 'text_search', 'daniel@example.com', True),
('name', 'daniel', 'text_search', 'dan', False),
('email', 'daniel@example.com', 'text_search', 'alfred', False),
)
@ddt.unpack
def test_filters(
self,
attribute_name,
attribute_value,
filter_key,
filter_value,
expect_learner
):
"""
Tests filtering and searching logic. Sets up a single learner
with a given attribute value, then makes a GET request to the
API with the specified query parameter set to the specified
value. If `expect_learner` is True, we assert that the user was
returned, otherwise we assert that no users were returned.
"""
learner = {'username': 'user', 'course_id': self.course_id}
learner[attribute_name] = attribute_value
self.create_learners([learner])
learner.pop('course_id')
response = self._get(self.course_id, **{filter_key: filter_value})
expected_learners = [learner] if expect_learner else None
self.assert_learners_returned(response, expected_learners)
@ddt.data(
([{'username': 'a'}, {'username': 'b'}], None, None, [{'username': 'a'}, {'username': 'b'}]),
([{'username': 'a'}, {'username': 'b'}], None, 'desc', [{'username': 'b'}, {'username': 'a'}]),
([{'username': 'a'}, {'username': 'b'}], 'username', 'desc', [{'username': 'b'}, {'username': 'a'}]),
([{'username': 'a'}, {'username': 'b'}], 'email', 'asc', [{'username': 'a'}, {'username': 'b'}]),
([{'username': 'a'}, {'username': 'b'}], 'email', 'desc', [{'username': 'b'}, {'username': 'a'}]),
(
[{'username': 'a', 'discussions_contributed': 0}, {'username': 'b', 'discussions_contributed': 1}],
'discussions_contributed', 'asc', [{'username': 'a'}, {'username': 'b'}]
),
(
[{'username': 'a', 'discussions_contributed': 0}, {'username': 'b', 'discussions_contributed': 1}],
'discussions_contributed', 'desc', [{'username': 'b'}, {'username': 'a'}]
),
(
[{'username': 'a', 'problems_attempted': 0}, {'username': 'b', 'problems_attempted': 1}],
'problems_attempted', 'asc', [{'username': 'a'}, {'username': 'b'}]
),
(
[{'username': 'a', 'problems_attempted': 0}, {'username': 'b', 'problems_attempted': 1}],
'problems_attempted', 'desc', [{'username': 'b'}, {'username': 'a'}]
),
(
[{'username': 'a', 'problems_completed': 0}, {'username': 'b', 'problems_completed': 1}],
'problems_completed', 'asc', [{'username': 'a'}, {'username': 'b'}]
),
(
[{'username': 'a', 'problems_completed': 0}, {'username': 'b', 'problems_completed': 1}],
'problems_completed', 'desc', [{'username': 'b'}, {'username': 'a'}]
),
(
[{'username': 'a', 'videos_viewed': 0}, {'username': 'b', 'videos_viewed': 1}],
'videos_viewed', 'asc', [{'username': 'a'}, {'username': 'b'}]
),
(
[{'username': 'a', 'videos_viewed': 0}, {'username': 'b', 'videos_viewed': 1}],
'videos_viewed', 'desc', [{'username': 'b'}, {'username': 'a'}]
),
)
@ddt.unpack
def test_sort(self, learners, order_by, sort_order, expected_users):
for learner in learners:
learner['course_id'] = self.course_id
self.create_learners(learners)
params = dict()
if order_by:
params['order_by'] = order_by
if sort_order:
params['sort_order'] = sort_order
response = self._get(self.course_id, **params)
self.assert_learners_returned(response, expected_users)
def test_pagination(self):
usernames = ['a', 'b', 'c', 'd', 'e']
expected_page_url_template = 'http://testserver/api/v0/learners/?' \
'{course_query}&page={page}&page_size={page_size}'
self.create_learners([{'username': username, 'course_id': self.course_id} for username in usernames])
response = self._get(self.course_id, page_size=2)
payload = json.loads(response.content)
self.assertDictContainsSubset(
{
'count': len(usernames),
'previous': None,
'next': expected_page_url_template.format(
course_query=urlencode({'course_id': self.course_id}), page=2, page_size=2
),
'num_pages': 3
},
payload
)
self.assert_learners_returned(response, [{'username': 'a'}, {'username': 'b'}])
response = self._get(self.course_id, page_size=2, page=3)
payload = json.loads(response.content)
self.assertDictContainsSubset(
{
'count': len(usernames),
'previous': expected_page_url_template.format(
course_query=urlencode({'course_id': self.course_id}), page=2, page_size=2
),
'next': None,
'num_pages': 3
},
payload
)
self.assert_learners_returned(response, [{'username': 'e'}])
# Error cases
@ddt.data(
({}, 'course_not_specified'),
({'course_id': ''}, 'course_not_specified'),
({'course_id': 'bad_course_id'}, 'course_key_malformed'),
({'course_id': 'edX/DemoX/Demo_Course', 'segments': 'a', 'ignore_segments': 'b'}, 'illegal_parameter_values'),
({'course_id': 'edX/DemoX/Demo_Course', 'order_by': 'a_non_existent_field'}, 'illegal_parameter_values'),
({'course_id': 'edX/DemoX/Demo_Course', 'sort_order': 'bad_value'}, 'illegal_parameter_values'),
({'course_id': 'edX/DemoX/Demo_Course', 'page': -1}, 'illegal_parameter_values'),
({'course_id': 'edX/DemoX/Demo_Course', 'page': 0}, 'illegal_parameter_values'),
({'course_id': 'edX/DemoX/Demo_Course', 'page': 'bad_value'}, 'illegal_parameter_values'),
({'course_id': 'edX/DemoX/Demo_Course', 'page_size': 'bad_value'}, 'illegal_parameter_values'),
({'course_id': 'edX/DemoX/Demo_Course', 'page_size': 101}, 'illegal_parameter_values'),
)
@ddt.unpack
def test_bad_request(self, parameters, expected_error_code):
response = self.authenticated_get('/api/v0/learners/', parameters)
self.assertEqual(response.status_code, 400)
self.assertEqual(json.loads(response.content)['error_code'], expected_error_code)
...@@ -7,7 +7,7 @@ urlpatterns = patterns( ...@@ -7,7 +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')), url('^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'),
......
...@@ -2,13 +2,11 @@ from django.conf.urls import patterns, url ...@@ -2,13 +2,11 @@ from django.conf.urls import patterns, url
from analytics_data_api.v0.views import learners as views from analytics_data_api.v0.views import learners as views
USERNAME_PATTERN = r'(?P<username>.+)'
LEARNERS_URLS = [
('', views.LearnerView, 'learner')
]
urlpatterns = [] USERNAME_PATTERN = r'(?P<username>.+)'
for path, view, name in LEARNERS_URLS: urlpatterns = patterns(
regex = r'^{0}/$'.format(USERNAME_PATTERN) '',
urlpatterns += patterns('', url(regex, view.as_view(), name=name)) url(r'^$', views.LearnerListView.as_view(), name='learners'),
url(r'^{}/$'.format(USERNAME_PATTERN), views.LearnerView.as_view(), name='learner'),
)
...@@ -7,9 +7,15 @@ from opaque_keys import InvalidKeyError ...@@ -7,9 +7,15 @@ from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.keys import CourseKey
from analytics_data_api.v0.exceptions import ( from analytics_data_api.v0.exceptions import (
CourseNotSpecifiedError, CourseKeyMalformedError, LearnerNotFoundError) CourseKeyMalformedError,
CourseNotSpecifiedError,
LearnerNotFoundError,
ParameterValueError,
)
from analytics_data_api.constants import learner
from analytics_data_api.v0.models import RosterEntry from analytics_data_api.v0.models import RosterEntry
from analytics_data_api.v0.serializers import LearnerSerializer from analytics_data_api.v0.serializers import ElasticsearchDSLSearchSerializer, LearnerSerializer
from analytics_data_api.v0.views.utils import split_query_argument
class CourseViewMixin(object): class CourseViewMixin(object):
...@@ -40,31 +46,22 @@ class LearnerView(CourseViewMixin, generics.RetrieveAPIView): ...@@ -40,31 +46,22 @@ class LearnerView(CourseViewMixin, generics.RetrieveAPIView):
**Response Values** **Response Values**
Returns viewing data for each segment of a video. For each segment, Returns the learner metadata and engagement data:
the collection contains the following data.
* segment: The order of the segment in the video timeline. * username: User's username.
* 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). * enrollment_mode: Enrollment mode (e.g. "honor).
* name: User name. * name: User's full name.
* email: User email. * email: User's email.
* segments: Classification for this course based on engagement, (e.g. "has_potential"). * segments: Classification for this course based on engagement, (e.g. "has_potential").
* engagements: Summary of engagement events for a time span. * engagements: Summary of engagement events for a time span.
* videos_viewed: Number of times a video was played. * videos_viewed: Number of times a video was played.
* problems_completed: Unique number of problems completed. * problems_completed: Unique number of problems completed.
* problems_attempted: Unique number of problems attempted. * problems_attempted: Unique number of problems attempted.
* problem_attempts: Number of attempts of problems.
* discussions_contributed: Number of discussions (e.g. forum posts). * discussions_contributed: Number of discussions (e.g. forum posts).
**Parameters** **Parameters**
You can specify course ID for which you want data. You can specify the course ID for which you want data.
course_id -- The course within which user data is requested. course_id -- The course within which user data is requested.
...@@ -85,3 +82,122 @@ class LearnerView(CourseViewMixin, generics.RetrieveAPIView): ...@@ -85,3 +82,122 @@ class LearnerView(CourseViewMixin, generics.RetrieveAPIView):
if len(queryset) == 1: if len(queryset) == 1:
return queryset[0] return queryset[0]
raise LearnerNotFoundError(username=self.username, course_id=self.course_id) raise LearnerNotFoundError(username=self.username, course_id=self.course_id)
class LearnerListView(CourseViewMixin, generics.ListAPIView):
"""
Get a paginated list of student data for a particular course.
**Example Request**
GET /api/v0/learners/?course_id={course_id}
**Response Values**
Returns a paginated list of learner metadata and engagement data.
Pagination data is returned in the top-level of the returned JSON
object:
* count: The number of learners matching the query.
* page: The current one-indexed page number.
* next: A hyperlink to the next page if one exists, otherwise null.
* previous: A hyperlink to the previous page if one exists, otherwise null.
The 'results' key in the returned JSON object maps to an array of
learners which contains at most a full page's worth of learners. Each
learner is a JSON object containing the following keys:
* username: User's username.
* enrollment_mode: Enrollment mode (e.g. "honor).
* name: User's full name.
* email: User's 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.
* discussions_contributed: Number of discussions (e.g. forum posts).
**Parameters**
You can filter the list of learners by course ID and other parameters
such as enrollment mode and text search. You can also control the
page size and page number of the response, as well as sort the learners
in the response.
course_id -- The course within which user data is requested.
page -- The page of results which should be returned.
page_size -- The maximum number of results which should be returned per page.
text_search -- A string to search over the name, username, and email of learners.
segments -- A comma-separated string of segments to which
learners should belong. Semgents are "OR"-ed together.
Cannot use in combination with `ignore_segments`
argument.
ignore_segments -- A comma-separated string of segments to
which learners should NOT belong. Semgents are "OR"-ed
together. Cannot use in combination with `segments`
argument.
cohort -- The cohort to which all returned learners must
belong.
enrollment_mode -- The enrollment mode to which all returned
learners must belong.
order_by -- The field for sorting the response. Defaults to 'username'.
sort_order -- The sort direction. One of 'asc' or 'desc'.
Defaults to 'asc'.
"""
serializer_class = LearnerSerializer
pagination_serializer_class = ElasticsearchDSLSearchSerializer
paginate_by_param = 'page_size'
paginate_by = learner.LEARNER_API_DEFAULT_LIST_PAGE_SIZE
max_paginate_by = 100 # TODO -- tweak during load testing
def _validate_query_params(self):
"""Validates various querystring parameters."""
query_params = self.request.QUERY_PARAMS
page = query_params.get('page')
if page:
try:
page = int(page)
except ValueError:
raise ParameterValueError('Page must be an integer')
finally:
if page < 1:
raise ParameterValueError(
'Page numbers are one-indexed, therefore the page value must be greater than 0'
)
page_size = query_params.get('page_size')
if page_size:
try:
page_size = int(page_size)
except ValueError:
raise ParameterValueError('Page size must be an integer')
finally:
if page_size > self.max_paginate_by or page_size < 1:
raise ParameterValueError('Page size must be in the range [1, {}]'.format(self.max_paginate_by))
def get_queryset(self):
"""
Fetches the user list from elasticsearch. Note that an
elasticsearch_dsl `Search` object is returned, not an actual
queryset.
"""
self._validate_query_params()
query_params = self.request.QUERY_PARAMS
params = {
'segments': split_query_argument(query_params.get('segments')),
'ignore_segments': split_query_argument(query_params.get('ignore_segments')),
# TODO: enable during https://openedx.atlassian.net/browse/AN-6319
# 'cohort': query_params.get('cohort'),
'enrollment_mode': query_params.get('enrollment_mode'),
'text_search': query_params.get('text_search'),
'order_by': query_params.get('order_by'),
'sort_order': query_params.get('sort_order')
}
# Remove None values from `params` so that we don't overwrite default
# parameter values in `get_users_in_course`.
params = {key: val for key, val in params.items() if val is not None}
try:
return RosterEntry.get_users_in_course(self.course_id, **params)
except ValueError as e:
raise ParameterValueError(e.message)
"""Utilities for view-level API logic."""
def split_query_argument(argument):
"""
Splits a comma-separated querystring argument into a list.
Returns None if the argument is empty.
"""
if argument:
return argument.split(',')
else:
return None
...@@ -56,7 +56,6 @@ ELASTICSEARCH_LEARNERS_HOST = environ.get('ELASTICSEARCH_LEARNERS_HOST', None) ...@@ -56,7 +56,6 @@ ELASTICSEARCH_LEARNERS_HOST = environ.get('ELASTICSEARCH_LEARNERS_HOST', None)
ELASTICSEARCH_LEARNERS_INDEX = environ.get('ELASTICSEARCH_LEARNERS_INDEX', None) ELASTICSEARCH_LEARNERS_INDEX = environ.get('ELASTICSEARCH_LEARNERS_INDEX', None)
########## END ELASTICSEARCH CONFIGURATION ########## END ELASTICSEARCH CONFIGURATION
########## GENERAL CONFIGURATION ########## GENERAL CONFIGURATION
# See: https://docs.djangoproject.com/en/dev/ref/settings/#time-zone # See: https://docs.djangoproject.com/en/dev/ref/settings/#time-zone
TIME_ZONE = 'UTC' TIME_ZONE = 'UTC'
...@@ -168,6 +167,7 @@ MIDDLEWARE_CLASSES = ( ...@@ -168,6 +167,7 @@ MIDDLEWARE_CLASSES = (
'analytics_data_api.v0.middleware.LearnerNotFoundErrorMiddleware', 'analytics_data_api.v0.middleware.LearnerNotFoundErrorMiddleware',
'analytics_data_api.v0.middleware.CourseNotSpecifiedErrorMiddleware', 'analytics_data_api.v0.middleware.CourseNotSpecifiedErrorMiddleware',
'analytics_data_api.v0.middleware.CourseKeyMalformedErrorMiddleware', 'analytics_data_api.v0.middleware.CourseKeyMalformedErrorMiddleware',
'analytics_data_api.v0.middleware.ParameterValueErrorMiddleware',
) )
########## END MIDDLEWARE CONFIGURATION ########## END MIDDLEWARE CONFIGURATION
......
# Test dependencies go here. # Test dependencies go here.
-r base.txt -r base.txt
coverage==3.7.1 coverage==3.7.1
ddt==1.0.1
diff-cover >= 0.2.1 diff-cover >= 0.2.1
django-dynamic-fixture==1.8.1 django-dynamic-fixture==1.8.1
django-nose==1.4.1 django-nose==1.4.1
......
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