Skip to content
Projects
Groups
Snippets
Help
This project
Loading...
Sign in / Register
Toggle navigation
E
edx-analytics-data-api
Overview
Overview
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
edx
edx-analytics-data-api
Commits
98081a29
Commit
98081a29
authored
Dec 01, 2015
by
Daniel Friedman
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
Implement Learner List endpoint
AN-6158
parent
0123b1d1
Hide whitespace changes
Inline
Side-by-side
Showing
12 changed files
with
499 additions
and
34 deletions
+499
-34
analytics_data_api/constants/learner.py
+1
-0
analytics_data_api/v0/exceptions.py
+7
-0
analytics_data_api/v0/middleware.py
+21
-2
analytics_data_api/v0/models.py
+53
-1
analytics_data_api/v0/serializers.py
+24
-2
analytics_data_api/v0/tests/views/test_learners.py
+240
-3
analytics_data_api/v0/urls/__init__.py
+1
-1
analytics_data_api/v0/urls/learners.py
+6
-8
analytics_data_api/v0/views/learners.py
+132
-16
analytics_data_api/v0/views/utils.py
+12
-0
analyticsdataserver/settings/base.py
+1
-1
requirements/test.txt
+1
-0
No files found.
analytics_data_api/constants/learner.py
0 → 100644
View file @
98081a29
LEARNER_API_DEFAULT_LIST_PAGE_SIZE
=
25
analytics_data_api/v0/exceptions.py
View file @
98081a29
...
@@ -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
analytics_data_api/v0/middleware.py
View file @
98081a29
...
@@ -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
(
LearnerNotFoun
dError
,
CourseKeyMalforme
dError
,
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
analytics_data_api/v0/models.py
View file @
98081a29
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
analytics_data_api/v0/serializers.py
View file @
98081a29
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
)
analytics_data_api/v0/tests/views/test_learners.py
View file @
98081a29
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
)
analytics_data_api/v0/urls/__init__.py
View file @
98081a29
...
@@ -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'
),
...
...
analytics_data_api/v0/urls/learners.py
View file @
98081a29
...
@@ -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'
),
)
analytics_data_api/v0/views/learners.py
View file @
98081a29
...
@@ -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
)
analytics_data_api/v0/views/utils.py
0 → 100644
View file @
98081a29
"""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
analyticsdataserver/settings/base.py
View file @
98081a29
...
@@ -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
...
...
requirements/test.txt
View file @
98081a29
# 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
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment