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
e9c0c069
Commit
e9c0c069
authored
Dec 14, 2015
by
Dennis Jen
Committed by
Daniel Friedman
Apr 11, 2016
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
Added fields for learner endpoints.
parent
9fb50c73
Hide whitespace changes
Inline
Side-by-side
Showing
4 changed files
with
177 additions
and
78 deletions
+177
-78
analytics_data_api/v0/models.py
+31
-10
analytics_data_api/v0/serializers.py
+18
-15
analytics_data_api/v0/tests/views/test_learners.py
+117
-50
analytics_data_api/v0/views/learners.py
+11
-3
No files found.
analytics_data_api/v0/models.py
View file @
e9c0c069
...
@@ -3,7 +3,9 @@ from itertools import groupby
...
@@ -3,7 +3,9 @@ from itertools import groupby
from
django.conf
import
settings
from
django.conf
import
settings
from
django.db
import
models
from
django.db
import
models
from
django.db.models
import
Sum
from
django.db.models
import
Sum
from
elasticsearch_dsl
import
DocType
,
Q
# some fields (e.g. Float, Integer) are dynamic and your IDE may highlight them as unavailable
from
elasticsearch_dsl
import
Date
,
DocType
,
Float
,
Integer
,
Q
,
String
from
analytics_data_api.constants
import
country
,
engagement_entity_types
,
genders
,
learner
from
analytics_data_api.constants
import
country
,
engagement_entity_types
,
genders
,
learner
...
@@ -214,6 +216,27 @@ class Video(BaseVideo):
...
@@ -214,6 +216,27 @@ class Video(BaseVideo):
class
RosterEntry
(
DocType
):
class
RosterEntry
(
DocType
):
course_id
=
String
()
username
=
String
()
name
=
String
()
email
=
String
()
enrollment_mode
=
String
()
cohort
=
String
()
segments
=
String
(
fields
=
{
'raw'
:
String
()})
problems_attempted
=
Integer
()
problems_completed
=
Integer
()
problem_attempts_per_completed
=
Float
()
# Useful for ordering problem_attempts_per_completed (because results can include null, which is
# different from zero). attempt_ratio_order is equal to the number of problem attempts if
# problem_attempts_per_completed is > 1 and set to -problem_attempts if
# problem_attempts_per_completed = 1.
attempt_ratio_order
=
Integer
()
discussions_contributed
=
Integer
()
videos_watched
=
Integer
()
enrollment_date
=
Date
()
last_updated
=
Date
()
# pylint: disable=old-style-class
# pylint: disable=old-style-class
class
Meta
:
class
Meta
:
index
=
settings
.
ELASTICSEARCH_LEARNERS_INDEX
index
=
settings
.
ELASTICSEARCH_LEARNERS_INDEX
...
@@ -230,8 +253,7 @@ class RosterEntry(DocType):
...
@@ -230,8 +253,7 @@ class RosterEntry(DocType):
course_id
,
course_id
,
segments
=
None
,
segments
=
None
,
ignore_segments
=
None
,
ignore_segments
=
None
,
# TODO: enable during https://openedx.atlassian.net/browse/AN-6319
cohort
=
None
,
# cohort=None,
enrollment_mode
=
None
,
enrollment_mode
=
None
,
text_search
=
None
,
text_search
=
None
,
order_by
=
'username'
,
order_by
=
'username'
,
...
@@ -251,13 +273,14 @@ class RosterEntry(DocType):
...
@@ -251,13 +273,14 @@ class RosterEntry(DocType):
segment
=
segment
,
segments
=
', '
.
join
(
learner
.
SEGMENTS
)
segment
=
segment
,
segments
=
', '
.
join
(
learner
.
SEGMENTS
)
))
))
order_by_options
=
(
order_by_options
=
(
'username'
,
'email'
,
'discussions_contributed'
,
'problems_attempted'
,
'problems_completed'
,
'videos_viewed'
'username'
,
'email'
,
'discussions_contributed'
,
'problems_attempted'
,
'problems_completed'
,
'attempt_ratio_order'
,
'videos_viewed'
)
)
sort_order_options
=
(
'asc'
,
'desc'
)
if
order_by
not
in
order_by_options
:
if
order_by
not
in
order_by_options
:
raise
ValueError
(
"order_by value '{order_by}' must be one of: ({order_by_options})"
.
format
(
raise
ValueError
(
"order_by value '{order_by}' must be one of: ({order_by_options})"
.
format
(
order_by
=
order_by
,
order_by_options
=
', '
.
join
(
order_by_options
)
order_by
=
order_by
,
order_by_options
=
', '
.
join
(
order_by_options
)
))
))
sort_order_options
=
(
'asc'
,
'desc'
)
if
sort_order
not
in
sort_order_options
:
if
sort_order
not
in
sort_order_options
:
raise
ValueError
(
"sort_order value '{sort_order}' must be one of: ({sort_order_options})"
.
format
(
raise
ValueError
(
"sort_order value '{sort_order}' must be one of: ({sort_order_options})"
.
format
(
sort_order
=
sort_order
,
sort_order_options
=
', '
.
join
(
sort_order_options
)
sort_order
=
sort_order
,
sort_order_options
=
', '
.
join
(
sort_order_options
)
...
@@ -272,9 +295,8 @@ class RosterEntry(DocType):
...
@@ -272,9 +295,8 @@ class RosterEntry(DocType):
elif
ignore_segments
:
elif
ignore_segments
:
for
segment
in
ignore_segments
:
for
segment
in
ignore_segments
:
search
=
search
.
query
(
~
Q
(
'term'
,
segments
=
segment
))
search
=
search
.
query
(
~
Q
(
'term'
,
segments
=
segment
))
# TODO: enable during https://openedx.atlassian.net/browse/AN-6319
if
cohort
:
# if cohort:
search
=
search
.
query
(
'term'
,
cohort
=
cohort
)
# search = search.query('term', cohort=cohort)
if
enrollment_mode
:
if
enrollment_mode
:
search
=
search
.
query
(
'term'
,
enrollment_mode
=
enrollment_mode
)
search
=
search
.
query
(
'term'
,
enrollment_mode
=
enrollment_mode
)
if
text_search
:
if
text_search
:
...
@@ -309,8 +331,7 @@ class RosterEntry(DocType):
...
@@ -309,8 +331,7 @@ class RosterEntry(DocType):
search
.
query
=
Q
(
'bool'
,
must
=
[
Q
(
'term'
,
course_id
=
course_id
)])
search
.
query
=
Q
(
'bool'
,
must
=
[
Q
(
'term'
,
course_id
=
course_id
)])
search
.
aggs
.
bucket
(
'enrollment_modes'
,
'terms'
,
field
=
'enrollment_mode'
)
search
.
aggs
.
bucket
(
'enrollment_modes'
,
'terms'
,
field
=
'enrollment_mode'
)
search
.
aggs
.
bucket
(
'segments'
,
'terms'
,
field
=
'segments'
)
search
.
aggs
.
bucket
(
'segments'
,
'terms'
,
field
=
'segments'
)
# TODO: enable during https://openedx.atlassian.net/browse/AN-6319
search
.
aggs
.
bucket
(
'cohorts'
,
'terms'
,
field
=
'cohort'
)
# search.aggs.bucket('group_by_cohorts', 'terms', field='cohort')
response
=
search
.
execute
()
response
=
search
.
execute
()
# Build up the map of aggregation name to count
# Build up the map of aggregation name to count
aggregations
=
{
aggregations
=
{
...
...
analytics_data_api/v0/serializers.py
View file @
e9c0c069
...
@@ -317,7 +317,7 @@ class VideoTimelineSerializer(ModelSerializerWithCreatedField):
...
@@ -317,7 +317,7 @@ class VideoTimelineSerializer(ModelSerializerWithCreatedField):
)
)
class
LearnerSerializer
(
serializers
.
Serializer
):
class
LearnerSerializer
(
serializers
.
Serializer
,
DefaultIfNoneMixin
):
username
=
serializers
.
CharField
()
username
=
serializers
.
CharField
()
enrollment_mode
=
serializers
.
CharField
()
enrollment_mode
=
serializers
.
CharField
()
name
=
serializers
.
CharField
()
name
=
serializers
.
CharField
()
...
@@ -325,11 +325,9 @@ class LearnerSerializer(serializers.Serializer):
...
@@ -325,11 +325,9 @@ class LearnerSerializer(serializers.Serializer):
email
=
serializers
.
CharField
()
email
=
serializers
.
CharField
()
segments
=
serializers
.
Field
(
source
=
'segments'
)
segments
=
serializers
.
Field
(
source
=
'segments'
)
engagements
=
serializers
.
SerializerMethodField
(
'get_engagements'
)
engagements
=
serializers
.
SerializerMethodField
(
'get_engagements'
)
enrollment_date
=
serializers
.
DateField
(
format
=
settings
.
DATE_FORMAT
)
# TODO: add these back in when the index returns them
last_updated
=
serializers
.
DateField
(
format
=
settings
.
DATE_FORMAT
)
# enrollment_date = serializers.DateField(format=settings.DATE_FORMAT, allow_empty=True)
cohort
=
serializers
.
CharField
()
# last_updated = serializers.DateField(format=settings.DATE_FORMAT)
# cohort = serializers.CharField(allow_none=True)
def
get_account_url
(
self
,
obj
):
def
get_account_url
(
self
,
obj
):
if
settings
.
LMS_USER_ACCOUNT_BASE_URL
:
if
settings
.
LMS_USER_ACCOUNT_BASE_URL
:
...
@@ -342,10 +340,17 @@ class LearnerSerializer(serializers.Serializer):
...
@@ -342,10 +340,17 @@ class LearnerSerializer(serializers.Serializer):
Add the engagement totals.
Add the engagement totals.
"""
"""
engagements
=
{}
engagements
=
{}
for
entity_type
in
engagement_entity_types
.
AGGREGATE_TYPES
:
for
event
in
engagement_events
.
EVENTS
[
entity_type
]:
# fill in these fields will 0 if values not returned/found
metric
=
'{0}_{1}'
.
format
(
entity_type
,
event
)
default_if_none_fields
=
[
'discussions_contributed'
,
'problems_attempted'
,
engagements
[
metric
]
=
getattr
(
obj
,
metric
,
0
)
'problems_completed'
,
'videos_viewed'
]
for
field
in
default_if_none_fields
:
engagements
[
field
]
=
self
.
default_if_none
(
getattr
(
obj
,
field
,
None
),
0
)
# preserve null values for problem attempts per completed
no_default_field
=
'problem_attempts_per_completed'
engagements
[
no_default_field
]
=
getattr
(
obj
,
no_default_field
,
None
)
return
engagements
return
engagements
...
@@ -424,8 +429,7 @@ class EnagementRangeMetricSerializer(serializers.Serializer):
...
@@ -424,8 +429,7 @@ class EnagementRangeMetricSerializer(serializers.Serializer):
class
CourseLearnerMetadataSerializer
(
serializers
.
Serializer
):
class
CourseLearnerMetadataSerializer
(
serializers
.
Serializer
):
enrollment_modes
=
serializers
.
SerializerMethodField
(
'get_enrollment_modes'
)
enrollment_modes
=
serializers
.
SerializerMethodField
(
'get_enrollment_modes'
)
segments
=
serializers
.
SerializerMethodField
(
'get_segments'
)
segments
=
serializers
.
SerializerMethodField
(
'get_segments'
)
# TODO: enable during https://openedx.atlassian.net/browse/AN-6319
cohorts
=
serializers
.
SerializerMethodField
(
'get_cohorts'
)
# cohorts = serializers.SerializerMethodField('get_cohorts')
engagement_ranges
=
serializers
.
SerializerMethodField
(
'get_engagement_ranges'
)
engagement_ranges
=
serializers
.
SerializerMethodField
(
'get_engagement_ranges'
)
def
get_enrollment_modes
(
self
,
obj
):
def
get_enrollment_modes
(
self
,
obj
):
...
@@ -434,9 +438,8 @@ class CourseLearnerMetadataSerializer(serializers.Serializer):
...
@@ -434,9 +438,8 @@ class CourseLearnerMetadataSerializer(serializers.Serializer):
def
get_segments
(
self
,
obj
):
def
get_segments
(
self
,
obj
):
return
obj
[
'es_data'
][
'segments'
]
return
obj
[
'es_data'
][
'segments'
]
# TODO: enable during https://openedx.atlassian.net/browse/AN-6319
def
get_cohorts
(
self
,
obj
):
# def get_cohorts(self, obj):
return
obj
[
'es_data'
][
'cohorts'
]
# return obj['es_data']['cohorts']
def
get_engagement_ranges
(
self
,
obj
):
def
get_engagement_ranges
(
self
,
obj
):
query_set
=
obj
[
'engagement_ranges'
]
query_set
=
obj
[
'engagement_ranges'
]
...
...
analytics_data_api/v0/tests/views/test_learners.py
View file @
e9c0c069
...
@@ -25,6 +25,9 @@ class LearnerAPITestMixin(object):
...
@@ -25,6 +25,9 @@ class LearnerAPITestMixin(object):
"""Creates the index and defines a mapping."""
"""Creates the index and defines a mapping."""
super
(
LearnerAPITestMixin
,
self
)
.
setUp
()
super
(
LearnerAPITestMixin
,
self
)
.
setUp
()
self
.
_es
=
Elasticsearch
([
settings
.
ELASTICSEARCH_LEARNERS_HOST
])
self
.
_es
=
Elasticsearch
([
settings
.
ELASTICSEARCH_LEARNERS_HOST
])
# delete index if for some reason the index wasn't deleted in tearDown
if
self
.
_es
.
indices
.
exists
(
index
=
settings
.
ELASTICSEARCH_LEARNERS_INDEX
):
self
.
_es
.
indices
.
delete
(
index
=
settings
.
ELASTICSEARCH_LEARNERS_INDEX
)
self
.
_es
.
indices
.
create
(
index
=
settings
.
ELASTICSEARCH_LEARNERS_INDEX
)
self
.
_es
.
indices
.
create
(
index
=
settings
.
ELASTICSEARCH_LEARNERS_INDEX
)
self
.
_es
.
indices
.
put_mapping
(
self
.
_es
.
indices
.
put_mapping
(
index
=
settings
.
ELASTICSEARCH_LEARNERS_INDEX
,
index
=
settings
.
ELASTICSEARCH_LEARNERS_INDEX
,
...
@@ -61,12 +64,21 @@ class LearnerAPITestMixin(object):
...
@@ -61,12 +64,21 @@ class LearnerAPITestMixin(object):
'problems_completed'
:
{
'problems_completed'
:
{
'type'
:
'integer'
,
'doc_values'
:
True
'type'
:
'integer'
,
'doc_values'
:
True
},
},
'
attempts_per_problem
_completed'
:
{
'
problem_attempts_per
_completed'
:
{
'type'
:
'float'
,
'doc_values'
:
True
'type'
:
'float'
,
'doc_values'
:
True
},
},
'attempt_ratio_order'
:
{
'type'
:
'integer'
,
'doc_values'
:
True
},
'videos_viewed'
:
{
'videos_viewed'
:
{
'type'
:
'integer'
,
'doc_values'
:
True
'type'
:
'integer'
,
'doc_values'
:
True
}
},
'enrollment_date'
:
{
'type'
:
'date'
,
'doc_values'
:
True
},
'last_updated'
:
{
'type'
:
'date'
,
'doc_values'
:
True
},
}
}
}
}
)
)
...
@@ -84,12 +96,15 @@ class LearnerAPITestMixin(object):
...
@@ -84,12 +96,15 @@ class LearnerAPITestMixin(object):
email
=
None
,
email
=
None
,
enrollment_mode
=
'honor'
,
enrollment_mode
=
'honor'
,
segments
=
None
,
segments
=
None
,
cohort
=
''
,
cohort
=
'
Team edX
'
,
discussions_contributed
=
0
,
discussions_contributed
=
0
,
problems_attempted
=
0
,
problems_attempted
=
0
,
problems_completed
=
0
,
problems_completed
=
0
,
attempts_per_problem_completed
=
0
,
problem_attempts_per_completed
=
None
,
videos_viewed
=
0
attempt_ratio_order
=
0
,
videos_viewed
=
0
,
enrollment_date
=
'2015-01-28'
,
last_updated
=
'2015-01-28'
):
):
"""Create a single learner roster entry in the elasticsearch index."""
"""Create a single learner roster entry in the elasticsearch index."""
self
.
_es
.
create
(
self
.
_es
.
create
(
...
@@ -106,8 +121,11 @@ class LearnerAPITestMixin(object):
...
@@ -106,8 +121,11 @@ class LearnerAPITestMixin(object):
'discussions_contributed'
:
discussions_contributed
,
'discussions_contributed'
:
discussions_contributed
,
'problems_attempted'
:
problems_attempted
,
'problems_attempted'
:
problems_attempted
,
'problems_completed'
:
problems_completed
,
'problems_completed'
:
problems_completed
,
'attempts_per_problem_completed'
:
attempts_per_problem_completed
,
'problem_attempts_per_completed'
:
problem_attempts_per_completed
,
'videos_viewed'
:
videos_viewed
'attempt_ratio_order'
:
attempt_ratio_order
,
'videos_viewed'
:
videos_viewed
,
'enrollment_date'
:
enrollment_date
,
'last_updated'
:
last_updated
}
}
)
)
...
@@ -124,42 +142,59 @@ class LearnerAPITestMixin(object):
...
@@ -124,42 +142,59 @@ class LearnerAPITestMixin(object):
self
.
_es
.
indices
.
refresh
(
index
=
settings
.
ELASTICSEARCH_LEARNERS_INDEX
)
self
.
_es
.
indices
.
refresh
(
index
=
settings
.
ELASTICSEARCH_LEARNERS_INDEX
)
@ddt.ddt
class
LearnerTests
(
VerifyCourseIdMixin
,
LearnerAPITestMixin
,
TestCaseWithAuthentication
):
class
LearnerTests
(
VerifyCourseIdMixin
,
LearnerAPITestMixin
,
TestCaseWithAuthentication
):
"""Tests for the single learner endpoint."""
"""Tests for the single learner endpoint."""
path_template
=
'/api/v0/learners/{}/?course_id={}'
path_template
=
'/api/v0/learners/{}/?course_id={}'
def
setUp
(
self
):
@ddt.data
(
super
(
LearnerTests
,
self
)
.
setUp
()
(
'ed_xavier'
,
'Edward Xavier'
,
'edX/DemoX/Demo_Course'
,
'honor'
,
[
'has_potential'
],
'Team edX'
,
43
,
3
,
6
,
0
,
8.4
,
2
,
'2015-04-24'
,
'2015-08-05'
),
(
'ed_xavier'
,
'Edward Xavier'
,
'edX/DemoX/Demo_Course'
,
'verified'
,
None
,
None
,
None
,
None
,
None
,
None
,
None
,
None
,
None
,
None
),
)
@ddt.unpack
def
test_get_user
(
self
,
username
,
name
,
course_id
,
enrollment_mode
,
segments
,
cohort
,
problems_attempted
,
problems_completed
,
videos_viewed
,
discussions_contributed
,
problem_attempts_per_completed
,
attempt_ratio_order
,
enrollment_date
,
last_updated
):
self
.
create_learners
([{
self
.
create_learners
([{
"username"
:
"ed_xavier"
,
"username"
:
username
,
"name"
:
"Edward Xavier"
,
"name"
:
name
,
"course_id"
:
"edX/DemoX/Demo_Course"
,
"course_id"
:
course_id
,
"segments"
:
[
"has_potential"
],
"enrollment_mode"
:
enrollment_mode
,
"problems_attempted"
:
43
,
"segments"
:
segments
,
"problems_completed"
:
3
,
"cohort"
:
cohort
,
"videos_viewed"
:
6
,
"problems_attempted"
:
problems_attempted
,
"discussions_contributed"
:
0
"problems_completed"
:
problems_completed
,
"videos_viewed"
:
videos_viewed
,
"discussions_contributed"
:
discussions_contributed
,
"problem_attempts_per_completed"
:
problem_attempts_per_completed
,
"attempt_ratio_order"
:
attempt_ratio_order
,
"enrollment_date"
:
enrollment_date
,
"last_updated"
:
last_updated
,
}])
}])
def
test_get_user
(
self
):
response
=
self
.
authenticated_get
(
self
.
path_template
.
format
(
username
,
course_id
))
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
)
self
.
assertEquals
(
response
.
status_code
,
200
)
expected
=
{
expected
=
{
"username"
:
"ed_xavier"
,
"username"
:
username
,
"enrollment_mode"
:
"honor"
,
"enrollment_mode"
:
enrollment_mode
,
"name"
:
"Edward Xavier"
,
"name"
:
name
,
"email"
:
"ed_xavier@example.com"
,
"email"
:
"{}@example.com"
.
format
(
username
),
"account_url"
:
"http://lms-host/ed_xavier"
,
"account_url"
:
"http://lms-host/{}"
.
format
(
username
),
"segments"
:
[
"has_potential"
],
"segments"
:
segments
or
[],
"cohort"
:
cohort
,
"engagements"
:
{
"engagements"
:
{
"problems_attempted"
:
43
,
"problems_attempted"
:
problems_attempted
or
0
,
"problems_completed"
:
3
,
"problems_completed"
:
problems_completed
or
0
,
"videos_viewed"
:
6
,
"videos_viewed"
:
videos_viewed
or
0
,
"discussions_contributed"
:
0
"discussions_contributed"
:
discussions_contributed
or
0
,
"problem_attempts_per_completed"
:
problem_attempts_per_completed
,
},
},
"enrollment_date"
:
enrollment_date
,
"last_updated"
:
last_updated
,
}
}
self
.
assertDictEqual
(
expected
,
response
.
data
)
self
.
assertDictEqual
(
expected
,
response
.
data
)
...
@@ -237,25 +272,25 @@ class LearnerListTests(LearnerAPITestMixin, VerifyCourseIdMixin, TestCaseWithAut
...
@@ -237,25 +272,25 @@ class LearnerListTests(LearnerAPITestMixin, VerifyCourseIdMixin, TestCaseWithAut
'course_id'
:
self
.
course_id
,
'course_id'
:
self
.
course_id
,
'enrollment_mode'
:
'honor'
,
'enrollment_mode'
:
'honor'
,
'segments'
:
[
'a'
,
'b'
],
'segments'
:
[
'a'
,
'b'
],
# TODO: enable during https://openedx.atlassian.net/browse/AN-6319
'cohort'
:
'alpha'
,
# 'cohort': 'alpha',
"problems_attempted"
:
43
,
"problems_attempted"
:
43
,
"problems_completed"
:
3
,
"problems_completed"
:
3
,
"videos_viewed"
:
6
,
"videos_viewed"
:
6
,
"discussions_contributed"
:
0
"discussions_contributed"
:
0
,
"problem_attempts_per_completed"
:
23.14
,
}])
}])
response
=
self
.
_get
(
self
.
course_id
)
response
=
self
.
_get
(
self
.
course_id
)
self
.
assert_learners_returned
(
response
,
[{
self
.
assert_learners_returned
(
response
,
[{
'username'
:
'user_1'
,
'username'
:
'user_1'
,
'enrollment_mode'
:
'honor'
,
'enrollment_mode'
:
'honor'
,
'segments'
:
[
'a'
,
'b'
],
'segments'
:
[
'a'
,
'b'
],
# TODO: enable during https://openedx.atlassian.net/browse/AN-6319
'cohort'
:
'alpha'
,
# 'cohort': 'alpha',
"engagements"
:
{
"engagements"
:
{
"problems_attempted"
:
43
,
"problems_attempted"
:
43
,
"problems_completed"
:
3
,
"problems_completed"
:
3
,
"videos_viewed"
:
6
,
"videos_viewed"
:
6
,
"discussions_contributed"
:
0
"discussions_contributed"
:
0
,
"problem_attempts_per_completed"
:
23.14
,
}
}
}])
}])
...
@@ -272,10 +307,9 @@ class LearnerListTests(LearnerAPITestMixin, VerifyCourseIdMixin, TestCaseWithAut
...
@@ -272,10 +307,9 @@ class LearnerListTests(LearnerAPITestMixin, VerifyCourseIdMixin, TestCaseWithAut
(
'segments'
,
[
'highly_engaged'
,
'struggling'
],
'ignore_segments'
,
'highly_engaged,struggling'
,
False
),
(
'segments'
,
[
'highly_engaged'
,
'struggling'
],
'ignore_segments'
,
'highly_engaged,struggling'
,
False
),
(
'segments'
,
[
'highly_engaged'
,
'struggling'
],
'ignore_segments'
,
''
,
True
),
(
'segments'
,
[
'highly_engaged'
,
'struggling'
],
'ignore_segments'
,
''
,
True
),
(
'segments'
,
[
'highly_engaged'
,
'struggling'
],
'ignore_segments'
,
'disengaging'
,
True
),
(
'segments'
,
[
'highly_engaged'
,
'struggling'
],
'ignore_segments'
,
'disengaging'
,
True
),
# TODO: enable during https://openedx.atlassian.net/browse/AN-6319
(
'cohort'
,
'a'
,
'cohort'
,
'a'
,
True
),
# ('cohort', 'a', 'cohort', 'a', True),
(
'cohort'
,
'a'
,
'cohort'
,
''
,
True
),
# ('cohort', 'a', 'cohort', '', True),
(
'cohort'
,
'a'
,
'cohort'
,
'b'
,
False
),
# ('cohort', 'a', 'cohort', 'b', False),
(
'enrollment_mode'
,
'a'
,
'enrollment_mode'
,
'a'
,
True
),
(
'enrollment_mode'
,
'a'
,
'enrollment_mode'
,
'a'
,
True
),
(
'enrollment_mode'
,
'a'
,
'enrollment_mode'
,
''
,
True
),
(
'enrollment_mode'
,
'a'
,
'enrollment_mode'
,
''
,
True
),
(
'enrollment_mode'
,
'a'
,
'enrollment_mode'
,
'b'
,
False
),
(
'enrollment_mode'
,
'a'
,
'enrollment_mode'
,
'b'
,
False
),
...
@@ -347,6 +381,18 @@ class LearnerListTests(LearnerAPITestMixin, VerifyCourseIdMixin, TestCaseWithAut
...
@@ -347,6 +381,18 @@ class LearnerListTests(LearnerAPITestMixin, VerifyCourseIdMixin, TestCaseWithAut
[{
'username'
:
'a'
,
'videos_viewed'
:
0
},
{
'username'
:
'b'
,
'videos_viewed'
:
1
}],
[{
'username'
:
'a'
,
'videos_viewed'
:
0
},
{
'username'
:
'b'
,
'videos_viewed'
:
1
}],
'videos_viewed'
,
'desc'
,
[{
'username'
:
'b'
},
{
'username'
:
'a'
}]
'videos_viewed'
,
'desc'
,
[{
'username'
:
'b'
},
{
'username'
:
'a'
}]
),
),
(
[{
'username'
:
'a'
,
'problem_attempts_per_completed'
:
None
,
'attempt_ratio_order'
:
-
1
},
{
'username'
:
'b'
,
'problem_attempts_per_completed'
:
None
,
'attempt_ratio_order'
:
0
},
{
'username'
:
'c'
,
'problem_attempts_per_completed'
:
None
,
'attempt_ratio_order'
:
1
}],
'problem_attempts_per_completed'
,
'asc'
,
[{
'username'
:
'a'
},
{
'username'
:
'b'
},
{
'username'
:
'c'
}]
),
(
[{
'username'
:
'a'
,
'problem_attempts_per_completed'
:
None
,
'attempt_ratio_order'
:
-
1
},
{
'username'
:
'b'
,
'problem_attempts_per_completed'
:
0.0
,
'attempt_ratio_order'
:
0
},
{
'username'
:
'c'
,
'problem_attempts_per_completed'
:
123.64
,
'attempt_ratio_order'
:
1
}],
'problem_attempts_per_completed'
,
'desc'
,
[{
'username'
:
'c'
},
{
'username'
:
'b'
},
{
'username'
:
'a'
}]
),
)
)
@ddt.unpack
@ddt.unpack
def
test_sort
(
self
,
learners
,
order_by
,
sort_order
,
expected_users
):
def
test_sort
(
self
,
learners
,
order_by
,
sort_order
,
expected_users
):
...
@@ -398,7 +444,6 @@ class LearnerListTests(LearnerAPITestMixin, VerifyCourseIdMixin, TestCaseWithAut
...
@@ -398,7 +444,6 @@ class LearnerListTests(LearnerAPITestMixin, VerifyCourseIdMixin, TestCaseWithAut
self
.
assert_learners_returned
(
response
,
[{
'username'
:
'e'
}])
self
.
assert_learners_returned
(
response
,
[{
'username'
:
'e'
}])
# Error cases
# Error cases
@ddt.data
(
@ddt.data
(
({},
'course_not_specified'
),
({},
'course_not_specified'
),
({
'course_id'
:
''
},
'course_not_specified'
),
({
'course_id'
:
''
},
'course_not_specified'
),
...
@@ -433,10 +478,11 @@ class CourseLearnerMetadataTests(DemoCourseMixin, VerifyCourseIdMixin,
...
@@ -433,10 +478,11 @@ class CourseLearnerMetadataTests(DemoCourseMixin, VerifyCourseIdMixin,
"""Helper to send a GET request to the API."""
"""Helper to send a GET request to the API."""
return
self
.
authenticated_get
(
'/api/v0/course_learner_metadata/{}/'
.
format
(
course_id
))
return
self
.
authenticated_get
(
'/api/v0/course_learner_metadata/{}/'
.
format
(
course_id
))
def
get_expected_json
(
self
,
segments
,
enrollment_modes
):
def
get_expected_json
(
self
,
segments
,
enrollment_modes
,
cohorts
):
expected_json
=
self
.
_get_full_engagement_ranges
()
expected_json
=
self
.
_get_full_engagement_ranges
()
expected_json
[
'segments'
]
=
segments
expected_json
[
'segments'
]
=
segments
expected_json
[
'enrollment_modes'
]
=
enrollment_modes
expected_json
[
'enrollment_modes'
]
=
enrollment_modes
expected_json
[
'cohorts'
]
=
cohorts
return
expected_json
return
expected_json
def
assert_response_matches
(
self
,
response
,
expected_status_code
,
expected_data
):
def
assert_response_matches
(
self
,
response
,
expected_status_code
,
expected_data
):
...
@@ -471,7 +517,9 @@ class CourseLearnerMetadataTests(DemoCourseMixin, VerifyCourseIdMixin,
...
@@ -471,7 +517,9 @@ class CourseLearnerMetadataTests(DemoCourseMixin, VerifyCourseIdMixin,
expected_segments
=
{
"highly_engaged"
:
0
,
"disengaging"
:
0
,
"struggling"
:
0
,
"inactive"
:
0
,
"unenrolled"
:
0
}
expected_segments
=
{
"highly_engaged"
:
0
,
"disengaging"
:
0
,
"struggling"
:
0
,
"inactive"
:
0
,
"unenrolled"
:
0
}
expected_segments
.
update
(
segments
)
expected_segments
.
update
(
segments
)
expected
=
self
.
get_expected_json
(
expected
=
self
.
get_expected_json
(
segments
=
expected_segments
,
enrollment_modes
=
{
'honor'
:
len
(
learners
)}
if
learners
else
{}
segments
=
expected_segments
,
enrollment_modes
=
{
'honor'
:
len
(
learners
)}
if
learners
else
{},
cohorts
=
{
'Team edX'
:
len
(
learners
)}
if
learners
else
{},
)
)
self
.
assert_response_matches
(
self
.
_get
(
self
.
course_id
),
200
,
expected
)
self
.
assert_response_matches
(
self
.
_get
(
self
.
course_id
),
200
,
expected
)
...
@@ -485,7 +533,8 @@ class CourseLearnerMetadataTests(DemoCourseMixin, VerifyCourseIdMixin,
...
@@ -485,7 +533,8 @@ class CourseLearnerMetadataTests(DemoCourseMixin, VerifyCourseIdMixin,
])
])
expected
=
self
.
get_expected_json
(
expected
=
self
.
get_expected_json
(
segments
=
{
'disengaging'
:
2
,
'struggling'
:
1
,
'highly_engaged'
:
0
,
'inactive'
:
0
,
'unenrolled'
:
0
},
segments
=
{
'disengaging'
:
2
,
'struggling'
:
1
,
'highly_engaged'
:
0
,
'inactive'
:
0
,
'unenrolled'
:
0
},
enrollment_modes
=
{
'honor'
:
2
}
enrollment_modes
=
{
'honor'
:
2
},
cohorts
=
{
'Team edX'
:
2
},
)
)
self
.
assert_response_matches
(
self
.
_get
(
self
.
course_id
),
200
,
expected
)
self
.
assert_response_matches
(
self
.
_get
(
self
.
course_id
),
200
,
expected
)
...
@@ -510,7 +559,29 @@ class CourseLearnerMetadataTests(DemoCourseMixin, VerifyCourseIdMixin,
...
@@ -510,7 +559,29 @@ class CourseLearnerMetadataTests(DemoCourseMixin, VerifyCourseIdMixin,
expected_enrollment_modes
[
enrollment_mode
]
=
count
expected_enrollment_modes
[
enrollment_mode
]
=
count
expected
=
self
.
get_expected_json
(
expected
=
self
.
get_expected_json
(
segments
=
{
'disengaging'
:
0
,
'struggling'
:
0
,
'highly_engaged'
:
0
,
'inactive'
:
0
,
'unenrolled'
:
0
},
segments
=
{
'disengaging'
:
0
,
'struggling'
:
0
,
'highly_engaged'
:
0
,
'inactive'
:
0
,
'unenrolled'
:
0
},
enrollment_modes
=
expected_enrollment_modes
enrollment_modes
=
expected_enrollment_modes
,
cohorts
=
{
'Team edX'
:
len
(
enrollment_modes
)}
if
enrollment_modes
else
{},
)
self
.
assert_response_matches
(
self
.
_get
(
self
.
course_id
),
200
,
expected
)
@ddt.data
(
[],
[
'Yellow'
],
[
'Blue'
],
[
'Red'
,
'Red'
,
'yellow team'
,
'yellow team'
,
'green'
],
)
def
test_cohorts
(
self
,
cohorts
):
self
.
create_learners
([
{
'username'
:
'user_{}'
.
format
(
i
),
'course_id'
:
self
.
course_id
,
'cohort'
:
cohort
}
for
i
,
cohort
in
enumerate
(
cohorts
)
])
expected_cohorts
=
{
cohort
:
len
([
mode
for
mode
in
group
])
for
cohort
,
group
in
groupby
(
cohorts
)
}
expected
=
self
.
get_expected_json
(
segments
=
{
'disengaging'
:
0
,
'struggling'
:
0
,
'highly_engaged'
:
0
,
'inactive'
:
0
,
'unenrolled'
:
0
},
enrollment_modes
=
{
'honor'
:
len
(
cohorts
)}
if
cohorts
else
{},
cohorts
=
expected_cohorts
,
)
)
self
.
assert_response_matches
(
self
.
_get
(
self
.
course_id
),
200
,
expected
)
self
.
assert_response_matches
(
self
.
_get
(
self
.
course_id
),
200
,
expected
)
...
@@ -541,10 +612,6 @@ class CourseLearnerMetadataTests(DemoCourseMixin, VerifyCourseIdMixin,
...
@@ -541,10 +612,6 @@ class CourseLearnerMetadataTests(DemoCourseMixin, VerifyCourseIdMixin,
metrics
.
append
(
'{0}_{1}'
.
format
(
entity_type
,
event
))
metrics
.
append
(
'{0}_{1}'
.
format
(
entity_type
,
event
))
return
metrics
return
metrics
# TODO: enable during https://openedx.atlassian.net/browse/AN-6319
# def test_cohorts(self):
# pass
def
test_no_engagement_ranges
(
self
):
def
test_no_engagement_ranges
(
self
):
response
=
self
.
_get
(
self
.
course_id
)
response
=
self
.
_get
(
self
.
course_id
)
self
.
assertEqual
(
response
.
status_code
,
200
)
self
.
assertEqual
(
response
.
status_code
,
200
)
...
...
analytics_data_api/v0/views/learners.py
View file @
e9c0c069
...
@@ -174,14 +174,21 @@ class LearnerListView(CourseViewMixin, generics.ListAPIView):
...
@@ -174,14 +174,21 @@ class LearnerListView(CourseViewMixin, generics.ListAPIView):
"""
"""
self
.
_validate_query_params
()
self
.
_validate_query_params
()
query_params
=
self
.
request
.
QUERY_PARAMS
query_params
=
self
.
request
.
QUERY_PARAMS
# Ordering by problem_attempts_per_completed can be ambiguous because
# it's a ratio values could be infinite (e.g. divide by zero) if no problems
# were completed. Instead, sorting by attempt_ratio_order will produce
# a sensible ordering
order_by
=
query_params
.
get
(
'order_by'
)
order_by
=
'attempt_ratio_order'
if
order_by
==
'problem_attempts_per_completed'
else
order_by
params
=
{
params
=
{
'segments'
:
split_query_argument
(
query_params
.
get
(
'segments'
)),
'segments'
:
split_query_argument
(
query_params
.
get
(
'segments'
)),
'ignore_segments'
:
split_query_argument
(
query_params
.
get
(
'ignore_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'
),
# 'cohort': query_params.get('cohort'),
'enrollment_mode'
:
query_params
.
get
(
'enrollment_mode'
),
'enrollment_mode'
:
query_params
.
get
(
'enrollment_mode'
),
'text_search'
:
query_params
.
get
(
'text_search'
),
'text_search'
:
query_params
.
get
(
'text_search'
),
'order_by'
:
query_params
.
get
(
'order_by'
)
,
'order_by'
:
order_by
,
'sort_order'
:
query_params
.
get
(
'sort_order'
)
'sort_order'
:
query_params
.
get
(
'sort_order'
)
}
}
# Remove None values from `params` so that we don't overwrite default
# Remove None values from `params` so that we don't overwrite default
...
@@ -211,6 +218,7 @@ class EngagementTimelineView(CourseViewMixin, generics.ListAPIView):
...
@@ -211,6 +218,7 @@ class EngagementTimelineView(CourseViewMixin, generics.ListAPIView):
* problems_completed: Unique number of problems completed.
* problems_completed: Unique number of problems completed.
* discussions_contributed: Number of discussions participated in (e.g. forum posts)
* discussions_contributed: Number of discussions participated in (e.g. forum posts)
* videos_viewed: Number of videos watched.
* videos_viewed: Number of videos watched.
* problem_attempts_per_completed: TBD
**Parameters**
**Parameters**
...
...
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