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
e7df0f3c
Commit
e7df0f3c
authored
Nov 18, 2015
by
Dennis Jen
Committed by
Daniel Friedman
Apr 11, 2016
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
Added learner engagement timeline.
parent
98081a29
Show whitespace changes
Inline
Side-by-side
Showing
17 changed files
with
410 additions
and
51 deletions
+410
-51
analytics_data_api/constants/engagement_entity_types.py
+14
-2
analytics_data_api/constants/engagement_events.py
+3
-0
analytics_data_api/management/commands/generate_fake_course_data.py
+20
-1
analytics_data_api/v0/exceptions.py
+15
-0
analytics_data_api/v0/middleware.py
+20
-1
analytics_data_api/v0/models.py
+55
-1
analytics_data_api/v0/serializers.py
+26
-3
analytics_data_api/v0/tests/views/__init__.py
+24
-0
analytics_data_api/v0/tests/views/test_engagement_timelines.py
+132
-0
analytics_data_api/v0/tests/views/test_learners.py
+5
-17
analytics_data_api/v0/urls/__init__.py
+4
-0
analytics_data_api/v0/urls/engagement_timelines.py
+10
-0
analytics_data_api/v0/urls/learners.py
+1
-2
analytics_data_api/v0/views/__init__.py
+22
-0
analytics_data_api/v0/views/engagement_timelines.py
+56
-0
analytics_data_api/v0/views/learners.py
+2
-24
analyticsdataserver/settings/base.py
+1
-0
No files found.
analytics_data_api/constants/engagement_entity_types.py
View file @
e7df0f3c
DISCUSSION
=
'discussion'
PROBLEM
=
'problem'
VIDEO
=
'video'
INDIVIDUAL_TYPES
=
[
DISCUSSION
,
PROBLEM
,
VIDEO
]
DISCUSSIONS
=
'discussions'
DISCUSSIONS
=
'discussions'
PROBLEMS
=
'problems'
PROBLEMS
=
'problems'
VIDEO
=
'videos'
VIDEOS
=
'videos'
ALL
=
[
DISCUSSIONS
,
PROBLEMS
,
VIDEO
]
AGGREGATE_TYPES
=
[
DISCUSSIONS
,
PROBLEMS
,
VIDEOS
]
# useful for agregating ModuleEngagement to ModuleEngagementTimeline
SINGULAR_TO_PLURAL
=
{
DISCUSSION
:
DISCUSSIONS
,
PROBLEM
:
PROBLEMS
,
VIDEO
:
VIDEOS
,
}
analytics_data_api/constants/engagement_events.py
View file @
e7df0f3c
...
@@ -7,7 +7,10 @@ VIEWED = 'viewed'
...
@@ -7,7 +7,10 @@ VIEWED = 'viewed'
# map entity types to events
# map entity types to events
EVENTS
=
{
EVENTS
=
{
engagement_entity_types
.
DISCUSSION
:
[
CONTRIBUTED
],
engagement_entity_types
.
DISCUSSIONS
:
[
CONTRIBUTED
],
engagement_entity_types
.
DISCUSSIONS
:
[
CONTRIBUTED
],
engagement_entity_types
.
PROBLEM
:
[
ATTEMPTED
,
COMPLETED
],
engagement_entity_types
.
PROBLEMS
:
[
ATTEMPTED
,
COMPLETED
],
engagement_entity_types
.
PROBLEMS
:
[
ATTEMPTED
,
COMPLETED
],
engagement_entity_types
.
VIDEO
:
[
VIEWED
],
engagement_entity_types
.
VIDEO
:
[
VIEWED
],
engagement_entity_types
.
VIDEOS
:
[
VIEWED
],
}
}
analytics_data_api/management/commands/generate_fake_course_data.py
View file @
e7df0f3c
...
@@ -8,7 +8,7 @@ import random
...
@@ -8,7 +8,7 @@ import random
from
django.core.management.base
import
BaseCommand
from
django.core.management.base
import
BaseCommand
from
django.utils
import
timezone
from
django.utils
import
timezone
from
analytics_data_api.v0
import
models
from
analytics_data_api.v0
import
models
from
analytics_data_api.constants
import
engagement_entity_types
,
engagement_events
logging
.
basicConfig
(
level
=
logging
.
INFO
)
logging
.
basicConfig
(
level
=
logging
.
INFO
)
logger
=
logging
.
getLogger
(
__name__
)
logger
=
logging
.
getLogger
(
__name__
)
...
@@ -182,6 +182,24 @@ class Command(BaseCommand):
...
@@ -182,6 +182,24 @@ class Command(BaseCommand):
users_at_start
=
users_at_start
,
users_at_start
=
users_at_start
,
users_at_end
=
random
.
randint
(
100
,
users_at_start
))
users_at_end
=
random
.
randint
(
100
,
users_at_start
))
def
generate_learner_engagement_data
(
self
,
course_id
,
username
,
start_date
,
end_date
):
logger
.
info
(
"Deleting learner engagement module data..."
)
models
.
ModuleEngagement
.
objects
.
all
()
.
delete
()
logger
.
info
(
"Generating learner engagement module data..."
)
current
=
start_date
while
current
<
end_date
:
current
=
current
+
datetime
.
timedelta
(
days
=
1
)
for
entity_type
in
engagement_entity_types
.
INDIVIDUAL_TYPES
:
for
event
in
engagement_events
.
EVENTS
[
entity_type
]:
count
=
random
.
randint
(
0
,
100
)
if
count
:
entity_id
=
'an-id-{}-{}'
.
format
(
entity_type
,
event
)
models
.
ModuleEngagement
.
objects
.
create
(
course_id
=
course_id
,
username
=
username
,
date
=
current
,
entity_type
=
entity_type
,
entity_id
=
entity_id
,
event
=
event
,
count
=
count
)
logger
.
info
(
"Done!"
)
def
handle
(
self
,
*
args
,
**
options
):
def
handle
(
self
,
*
args
,
**
options
):
course_id
=
'edX/DemoX/Demo_Course'
course_id
=
'edX/DemoX/Demo_Course'
video_id
=
'0fac49ba'
video_id
=
'0fac49ba'
...
@@ -199,3 +217,4 @@ class Command(BaseCommand):
...
@@ -199,3 +217,4 @@ class Command(BaseCommand):
self
.
generate_daily_data
(
course_id
,
start_date
,
end_date
)
self
.
generate_daily_data
(
course_id
,
start_date
,
end_date
)
self
.
generate_video_data
(
course_id
,
video_id
,
video_module_id
)
self
.
generate_video_data
(
course_id
,
video_id
,
video_module_id
)
self
.
generate_video_timeline_data
(
video_id
)
self
.
generate_video_timeline_data
(
video_id
)
self
.
generate_learner_engagement_data
(
course_id
,
'ed_xavier'
,
start_date
,
end_date
)
analytics_data_api/v0/exceptions.py
View file @
e7df0f3c
...
@@ -29,6 +29,21 @@ class LearnerNotFoundError(BaseError):
...
@@ -29,6 +29,21 @@ class LearnerNotFoundError(BaseError):
return
'Learner {username} not found for course {course_id}.'
return
'Learner {username} not found for course {course_id}.'
class
LearnerEngagementTimelineNotFoundError
(
BaseError
):
"""
Raise learner engagement timeline not found for a course.
"""
def
__init__
(
self
,
*
args
,
**
kwargs
):
course_id
=
kwargs
.
pop
(
'course_id'
)
username
=
kwargs
.
pop
(
'username'
)
super
(
LearnerEngagementTimelineNotFoundError
,
self
)
.
__init__
(
*
args
,
**
kwargs
)
self
.
message
=
self
.
message_template
.
format
(
username
=
username
,
course_id
=
course_id
)
@property
def
message_template
(
self
):
return
'Learner {username} engagmeent timeline not found for course {course_id}.'
class
CourseNotSpecifiedError
(
BaseError
):
class
CourseNotSpecifiedError
(
BaseError
):
"""
"""
Raise if course not specified.
Raise if course not specified.
...
...
analytics_data_api/v0/middleware.py
View file @
e7df0f3c
...
@@ -5,8 +5,9 @@ from rest_framework import status
...
@@ -5,8 +5,9 @@ from rest_framework import status
from
analytics_data_api.v0.exceptions
import
(
from
analytics_data_api.v0.exceptions
import
(
CourseKeyMalformedError
,
CourseKeyMalformedError
,
CourseNotSpecifiedError
,
CourseNotSpecifiedError
,
ParameterValue
Error
,
LearnerEngagementTimelineNotFound
Error
,
LearnerNotFoundError
,
LearnerNotFoundError
,
ParameterValueError
,
)
)
...
@@ -58,6 +59,24 @@ class LearnerNotFoundErrorMiddleware(BaseProcessErrorMiddleware):
...
@@ -58,6 +59,24 @@ class LearnerNotFoundErrorMiddleware(BaseProcessErrorMiddleware):
return
status
.
HTTP_404_NOT_FOUND
return
status
.
HTTP_404_NOT_FOUND
class
LearnerEngagementTimelineNotFoundErrorMiddleware
(
BaseProcessErrorMiddleware
):
"""
Raise 404 if learner engagement timeline not found.
"""
@property
def
error
(
self
):
return
LearnerEngagementTimelineNotFoundError
@property
def
error_code
(
self
):
return
'no_learner_engagement_timeline'
@property
def
status_code
(
self
):
return
status
.
HTTP_404_NOT_FOUND
class
CourseNotSpecifiedErrorMiddleware
(
BaseProcessErrorMiddleware
):
class
CourseNotSpecifiedErrorMiddleware
(
BaseProcessErrorMiddleware
):
"""
"""
Raise 400 course not specified.
Raise 400 course not specified.
...
...
analytics_data_api/v0/models.py
View file @
e7df0f3c
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
elasticsearch_dsl
import
DocType
,
Q
from
elasticsearch_dsl
import
DocType
,
Q
from
analytics_data_api.constants
import
country
,
genders
from
analytics_data_api.constants
import
country
,
engagement_entity_types
,
genders
class
CourseActivityWeekly
(
models
.
Model
):
class
CourseActivityWeekly
(
models
.
Model
):
...
@@ -272,3 +275,54 @@ class RosterEntry(DocType):
...
@@ -272,3 +275,54 @@ class RosterEntry(DocType):
search
=
search
.
sort
(
sort_term
)
search
=
search
.
sort
(
sort_term
)
return
search
return
search
class
ModuleEngagementTimelineManager
(
models
.
Manager
):
"""
Modifies the ModuleEngagement queryset to aggregate engagement data for
the learner engagement timeline.
"""
def
get_timelines
(
self
,
course_id
,
username
):
queryset
=
ModuleEngagement
.
objects
.
all
()
.
filter
(
course_id
=
course_id
,
username
=
username
)
\
.
values
(
'date'
,
'entity_type'
,
'event'
)
\
.
annotate
(
count
=
Sum
(
'count'
))
\
.
order_by
(
'date'
)
timelines
=
[]
for
key
,
group
in
groupby
(
queryset
,
lambda
x
:
(
x
[
'date'
])):
# Iterate over groups and create a single item with engagement data
item
=
{
u'date'
:
key
,
}
for
engagement
in
group
:
entity_type
=
engagement_entity_types
.
SINGULAR_TO_PLURAL
[
engagement
[
'entity_type'
]]
engagement_type
=
'{}_{}'
.
format
(
entity_type
,
engagement
[
'event'
])
count
=
item
.
get
(
engagement_type
,
0
)
count
+=
engagement
[
'count'
]
item
[
engagement_type
]
=
count
timelines
.
append
(
item
)
return
timelines
class
ModuleEngagement
(
models
.
Model
):
"""User interactions with entities within the courseware."""
course_id
=
models
.
CharField
(
db_index
=
True
,
max_length
=
255
)
username
=
models
.
CharField
(
max_length
=
255
)
date
=
models
.
DateTimeField
()
# This will be one of "problem", "video" or "forum"
entity_type
=
models
.
CharField
(
max_length
=
255
)
# For problems this will be the usage key, for videos it will be the html encoded module ID,
# for forums it will be the commentable_id
entity_id
=
models
.
CharField
(
max_length
=
255
)
# A description of what interaction occurred, e.g. "contributed" or "viewed"
event
=
models
.
CharField
(
max_length
=
255
)
# The number of times the user interacted with this entity in this way on this day.
count
=
models
.
IntegerField
()
objects
=
ModuleEngagementTimelineManager
()
class
Meta
(
object
):
db_table
=
'module_engagement'
analytics_data_api/v0/serializers.py
View file @
e7df0f3c
...
@@ -175,13 +175,16 @@ class SequentialOpenDistributionSerializer(ModelSerializerWithCreatedField):
...
@@ -175,13 +175,16 @@ class SequentialOpenDistributionSerializer(ModelSerializerWithCreatedField):
)
)
class
BaseCourseEnrollmentModelSerializer
(
ModelSerializerWithCreatedField
):
class
DefaultIfNoneMixin
(
object
):
date
=
serializers
.
DateField
(
format
=
settings
.
DATE_FORMAT
)
def
default_if_none
(
self
,
value
,
default
=
0
):
def
default_if_none
(
self
,
value
,
default
=
0
):
return
value
if
value
is
not
None
else
default
return
value
if
value
is
not
None
else
default
class
BaseCourseEnrollmentModelSerializer
(
DefaultIfNoneMixin
,
ModelSerializerWithCreatedField
):
date
=
serializers
.
DateField
(
format
=
settings
.
DATE_FORMAT
)
class
CourseEnrollmentDailySerializer
(
BaseCourseEnrollmentModelSerializer
):
class
CourseEnrollmentDailySerializer
(
BaseCourseEnrollmentModelSerializer
):
""" Representation of course enrollment for a single day and course. """
""" Representation of course enrollment for a single day and course. """
...
@@ -339,7 +342,7 @@ class LearnerSerializer(serializers.Serializer):
...
@@ -339,7 +342,7 @@ class LearnerSerializer(serializers.Serializer):
Add the engagement totals.
Add the engagement totals.
"""
"""
engagements
=
{}
engagements
=
{}
for
entity_type
in
engagement_entity_types
.
A
LL
:
for
entity_type
in
engagement_entity_types
.
A
GGREGATE_TYPES
:
for
event
in
engagement_events
.
EVENTS
[
entity_type
]:
for
event
in
engagement_events
.
EVENTS
[
entity_type
]:
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
)
...
@@ -365,3 +368,23 @@ class ElasticsearchDSLSearchSerializer(EdxPaginationSerializer):
...
@@ -365,3 +368,23 @@ class ElasticsearchDSLSearchSerializer(EdxPaginationSerializer):
# elasticsearch-dsl search object.
# elasticsearch-dsl search object.
kwargs
[
'instance'
]
.
object_list
=
kwargs
[
'instance'
]
.
object_list
.
execute
()
kwargs
[
'instance'
]
.
object_list
=
kwargs
[
'instance'
]
.
object_list
.
execute
()
super
(
ElasticsearchDSLSearchSerializer
,
self
)
.
__init__
(
*
args
,
**
kwargs
)
super
(
ElasticsearchDSLSearchSerializer
,
self
)
.
__init__
(
*
args
,
**
kwargs
)
class
EngagementDaySerializer
(
DefaultIfNoneMixin
,
serializers
.
Serializer
):
date
=
serializers
.
DateField
(
format
=
settings
.
DATE_FORMAT
)
problems_attempted
=
serializers
.
IntegerField
(
required
=
True
,
default
=
0
)
problems_completed
=
serializers
.
IntegerField
(
required
=
True
,
default
=
0
)
discussions_contributed
=
serializers
.
IntegerField
(
required
=
True
,
default
=
0
)
videos_viewed
=
serializers
.
IntegerField
(
required
=
True
,
default
=
0
)
def
transform_problems_attempted
(
self
,
_obj
,
value
):
return
self
.
default_if_none
(
value
,
0
)
def
transform_problems_completed
(
self
,
_obj
,
value
):
return
self
.
default_if_none
(
value
,
0
)
def
transform_discussions_contributed
(
self
,
_obj
,
value
):
return
self
.
default_if_none
(
value
,
0
)
def
transform_videos_viewed
(
self
,
_obj
,
value
):
return
self
.
default_if_none
(
value
,
0
)
analytics_data_api/v0/tests/views/__init__.py
View file @
e7df0f3c
import
json
from
opaque_keys.edx.keys
import
CourseKey
from
opaque_keys.edx.keys
import
CourseKey
from
rest_framework
import
status
DEMO_COURSE_ID
=
u'course-v1:edX+DemoX+Demo_2014'
DEMO_COURSE_ID
=
u'course-v1:edX+DemoX+Demo_2014'
...
@@ -12,3 +15,24 @@ class DemoCourseMixin(object):
...
@@ -12,3 +15,24 @@ class DemoCourseMixin(object):
cls
.
course_id
=
DEMO_COURSE_ID
cls
.
course_id
=
DEMO_COURSE_ID
cls
.
course_key
=
CourseKey
.
from_string
(
cls
.
course_id
)
cls
.
course_key
=
CourseKey
.
from_string
(
cls
.
course_id
)
super
(
DemoCourseMixin
,
cls
)
.
setUpClass
()
super
(
DemoCourseMixin
,
cls
)
.
setUpClass
()
class
VerifyCourseIdMixin
(
object
):
def
verify_no_course_id
(
self
,
response
):
""" Assert that a course ID must be provided. """
self
.
assertEquals
(
response
.
status_code
,
status
.
HTTP_400_BAD_REQUEST
)
expected
=
{
u"error_code"
:
u"course_not_specified"
,
u"developer_message"
:
u"Course id/key not specified."
}
self
.
assertDictEqual
(
json
.
loads
(
response
.
content
),
expected
)
def
verify_bad_course_id
(
self
,
response
,
course_id
=
'malformed-course-id'
):
""" Assert that a course ID must be valid. """
self
.
assertEquals
(
response
.
status_code
,
status
.
HTTP_400_BAD_REQUEST
)
expected
=
{
u"error_code"
:
u"course_key_malformed"
,
u"developer_message"
:
u"Course id/key {} malformed."
.
format
(
course_id
)
}
self
.
assertDictEqual
(
json
.
loads
(
response
.
content
),
expected
)
analytics_data_api/v0/tests/views/test_engagement_timelines.py
0 → 100644
View file @
e7df0f3c
import
datetime
import
json
from
django.utils.http
import
urlquote
from
django_dynamic_fixture
import
G
import
pytz
from
rest_framework
import
status
from
analyticsdataserver.tests
import
TestCaseWithAuthentication
from
analytics_data_api.constants
import
engagement_entity_types
,
engagement_events
from
analytics_data_api.v0
import
models
from
analytics_data_api.v0.tests.views
import
DemoCourseMixin
,
VerifyCourseIdMixin
class
EngagementTimelineTests
(
DemoCourseMixin
,
VerifyCourseIdMixin
,
TestCaseWithAuthentication
):
DEFAULT_USERNAME
=
'ed_xavier'
path_template
=
'/api/v0/engagement_timelines/{}/?course_id={}'
def
_create_engagement
(
self
):
""" Create module engagement data for testing. """
G
(
models
.
ModuleEngagement
,
course_id
=
self
.
course_id
,
username
=
self
.
DEFAULT_USERNAME
,
date
=
datetime
.
datetime
(
2015
,
1
,
1
,
tzinfo
=
pytz
.
utc
),
entity_type
=
engagement_entity_types
.
PROBLEM
,
entity_id
=
'some-type-of-id'
,
event
=
engagement_events
.
ATTEMPTED
,
count
=
100
)
G
(
models
.
ModuleEngagement
,
course_id
=
self
.
course_id
,
username
=
self
.
DEFAULT_USERNAME
,
date
=
datetime
.
datetime
(
2015
,
1
,
1
,
tzinfo
=
pytz
.
utc
),
entity_type
=
engagement_entity_types
.
PROBLEM
,
entity_id
=
'some-type-of-id'
,
event
=
engagement_events
.
COMPLETED
,
count
=
12
)
G
(
models
.
ModuleEngagement
,
course_id
=
self
.
course_id
,
username
=
self
.
DEFAULT_USERNAME
,
date
=
datetime
.
datetime
(
2015
,
1
,
2
,
tzinfo
=
pytz
.
utc
),
entity_type
=
engagement_entity_types
.
DISCUSSION
,
entity_id
=
'some-type-of-id'
,
event
=
engagement_events
.
CONTRIBUTED
,
count
=
10
)
G
(
models
.
ModuleEngagement
,
course_id
=
self
.
course_id
,
username
=
self
.
DEFAULT_USERNAME
,
date
=
datetime
.
datetime
(
2015
,
1
,
2
,
tzinfo
=
pytz
.
utc
),
entity_type
=
engagement_entity_types
.
VIDEO
,
entity_id
=
'some-type-of-id'
,
event
=
engagement_events
.
VIEWED
,
count
=
44
)
G
(
models
.
ModuleEngagement
,
course_id
=
self
.
course_id
,
username
=
self
.
DEFAULT_USERNAME
,
date
=
datetime
.
datetime
(
2015
,
1
,
2
,
tzinfo
=
pytz
.
utc
),
entity_type
=
engagement_entity_types
.
PROBLEM
,
entity_id
=
'some-type-of-id'
,
event
=
engagement_events
.
ATTEMPTED
,
count
=
8
)
def
test_timeline
(
self
):
path
=
self
.
path_template
.
format
(
self
.
DEFAULT_USERNAME
,
urlquote
(
self
.
course_id
))
self
.
_create_engagement
()
response
=
self
.
authenticated_get
(
path
)
self
.
assertEquals
(
response
.
status_code
,
200
)
expected
=
{
'days'
:
[
{
'date'
:
'2015-01-01'
,
'discussions_contributed'
:
0
,
'problems_attempted'
:
100
,
'problems_completed'
:
12
,
'videos_viewed'
:
0
},
{
'date'
:
'2015-01-02'
,
'discussions_contributed'
:
10
,
'problems_attempted'
:
8
,
'problems_completed'
:
0
,
'videos_viewed'
:
44
},
]
}
self
.
assertEquals
(
response
.
data
,
expected
)
def
test_one
(
self
):
path
=
self
.
path_template
.
format
(
self
.
DEFAULT_USERNAME
,
urlquote
(
self
.
course_id
))
G
(
models
.
ModuleEngagement
,
course_id
=
self
.
course_id
,
username
=
self
.
DEFAULT_USERNAME
,
date
=
datetime
.
datetime
(
2015
,
5
,
28
,
tzinfo
=
pytz
.
utc
),
entity_type
=
engagement_entity_types
.
PROBLEM
,
entity_id
=
'some-type-of-id'
,
event
=
engagement_events
.
ATTEMPTED
,
count
=
6923
)
response
=
self
.
authenticated_get
(
path
)
self
.
assertEquals
(
response
.
status_code
,
200
)
expected
=
{
'days'
:
[
{
'date'
:
'2015-05-28'
,
'discussions_contributed'
:
0
,
'problems_attempted'
:
6923
,
'problems_completed'
:
0
,
'videos_viewed'
:
0
},
]
}
self
.
assertEquals
(
response
.
data
,
expected
)
def
test_day_gap
(
self
):
path
=
self
.
path_template
.
format
(
self
.
DEFAULT_USERNAME
,
urlquote
(
self
.
course_id
))
G
(
models
.
ModuleEngagement
,
course_id
=
self
.
course_id
,
username
=
self
.
DEFAULT_USERNAME
,
date
=
datetime
.
datetime
(
2015
,
5
,
26
,
tzinfo
=
pytz
.
utc
),
entity_type
=
engagement_entity_types
.
VIDEO
,
entity_id
=
'some-type-of-id'
,
event
=
engagement_events
.
VIEWED
,
count
=
1
)
G
(
models
.
ModuleEngagement
,
course_id
=
self
.
course_id
,
username
=
self
.
DEFAULT_USERNAME
,
date
=
datetime
.
datetime
(
2015
,
5
,
28
,
tzinfo
=
pytz
.
utc
),
entity_type
=
engagement_entity_types
.
PROBLEM
,
entity_id
=
'some-type-of-id'
,
event
=
engagement_events
.
ATTEMPTED
,
count
=
6923
)
response
=
self
.
authenticated_get
(
path
)
self
.
assertEquals
(
response
.
status_code
,
200
)
expected
=
{
'days'
:
[
{
'date'
:
'2015-05-26'
,
'discussions_contributed'
:
0
,
'problems_attempted'
:
0
,
'problems_completed'
:
0
,
'videos_viewed'
:
1
},
{
'date'
:
'2015-05-28'
,
'discussions_contributed'
:
0
,
'problems_attempted'
:
6923
,
'problems_completed'
:
0
,
'videos_viewed'
:
0
},
]
}
self
.
assertEquals
(
response
.
data
,
expected
)
def
test_not_found
(
self
):
path
=
self
.
path_template
.
format
(
self
.
DEFAULT_USERNAME
,
urlquote
(
self
.
course_id
))
response
=
self
.
authenticated_get
(
path
)
self
.
assertEquals
(
response
.
status_code
,
status
.
HTTP_404_NOT_FOUND
)
expected
=
{
u"error_code"
:
u"no_learner_engagement_timeline"
,
u"developer_message"
:
u"Learner {} engagmeent timeline not found for course {}."
.
format
(
self
.
DEFAULT_USERNAME
,
self
.
course_id
)
}
self
.
assertDictEqual
(
json
.
loads
(
response
.
content
),
expected
)
def
test_no_course_id
(
self
):
base_path
=
'/api/v0/engagement_timelines/{}'
response
=
self
.
authenticated_get
((
base_path
)
.
format
(
'ed_xavier'
))
self
.
verify_no_course_id
(
response
)
def
test_bad_course_id
(
self
):
path
=
self
.
path_template
.
format
(
self
.
DEFAULT_USERNAME
,
'malformed-course-id'
)
response
=
self
.
authenticated_get
(
path
)
self
.
verify_bad_course_id
(
response
)
analytics_data_api/v0/tests/views/test_learners.py
View file @
e7df0f3c
...
@@ -9,6 +9,7 @@ from rest_framework import status
...
@@ -9,6 +9,7 @@ from rest_framework import status
from
django.conf
import
settings
from
django.conf
import
settings
from
analyticsdataserver.tests
import
TestCaseWithAuthentication
from
analyticsdataserver.tests
import
TestCaseWithAuthentication
from
analytics_data_api.v0.tests.views
import
VerifyCourseIdMixin
class
LearnerAPITestMixin
(
object
):
class
LearnerAPITestMixin
(
object
):
...
@@ -116,9 +117,8 @@ class LearnerAPITestMixin(object):
...
@@ -116,9 +117,8 @@ class LearnerAPITestMixin(object):
self
.
_es
.
indices
.
refresh
(
index
=
settings
.
ELASTICSEARCH_LEARNERS_INDEX
)
self
.
_es
.
indices
.
refresh
(
index
=
settings
.
ELASTICSEARCH_LEARNERS_INDEX
)
class
LearnerTests
(
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
):
def
setUp
(
self
):
...
@@ -170,25 +170,13 @@ class LearnerTests(LearnerAPITestMixin, TestCaseWithAuthentication):
...
@@ -170,25 +170,13 @@ class LearnerTests(LearnerAPITestMixin, TestCaseWithAuthentication):
def
test_no_course_id
(
self
):
def
test_no_course_id
(
self
):
base_path
=
'/api/v0/learners/{}'
base_path
=
'/api/v0/learners/{}'
path
=
(
base_path
)
.
format
(
'ed_xavier'
)
response
=
self
.
authenticated_get
((
base_path
)
.
format
(
'ed_xavier'
))
response
=
self
.
authenticated_get
(
path
)
self
.
verify_no_course_id
(
response
)
self
.
assertEquals
(
response
.
status_code
,
status
.
HTTP_400_BAD_REQUEST
)
expected
=
{
u"error_code"
:
u"course_not_specified"
,
u"developer_message"
:
u"Course id/key not specified."
}
self
.
assertDictEqual
(
json
.
loads
(
response
.
content
),
expected
)
def
test_bad_course_id
(
self
):
def
test_bad_course_id
(
self
):
path
=
self
.
path_template
.
format
(
'ed_xavier'
,
'malformed-course-id'
)
path
=
self
.
path_template
.
format
(
'ed_xavier'
,
'malformed-course-id'
)
response
=
self
.
authenticated_get
(
path
)
response
=
self
.
authenticated_get
(
path
)
self
.
assertEquals
(
response
.
status_code
,
status
.
HTTP_400_BAD_REQUEST
)
self
.
verify_bad_course_id
(
response
)
expected
=
{
u"error_code"
:
u"course_key_malformed"
,
u"developer_message"
:
u"Course id/key malformed-course-id malformed."
}
self
.
assertDictEqual
(
json
.
loads
(
response
.
content
),
expected
)
@ddt.ddt
@ddt.ddt
...
...
analytics_data_api/v0/urls/__init__.py
View file @
e7df0f3c
...
@@ -2,12 +2,16 @@ from django.conf.urls import patterns, url, include
...
@@ -2,12 +2,16 @@ from django.conf.urls import patterns, url, include
from
django.core.urlresolvers
import
reverse_lazy
from
django.core.urlresolvers
import
reverse_lazy
from
django.views.generic
import
RedirectView
from
django.views.generic
import
RedirectView
USERNAME_PATTERN
=
r'(?P<username>.+)'
urlpatterns
=
patterns
(
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
(
'^learners/'
,
include
(
'analytics_data_api.v0.urls.learners'
,
namespace
=
'learners'
)),
url
(
'^learners/'
,
include
(
'analytics_data_api.v0.urls.learners'
,
namespace
=
'learners'
)),
url
(
r'^engagement_timelines/'
,
include
(
'analytics_data_api.v0.urls.engagement_timelines'
,
namespace
=
'engagement_timelines'
)),
# 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/engagement_timelines.py
0 → 100644
View file @
e7df0f3c
from
django.conf.urls
import
patterns
,
url
from
analytics_data_api.v0.views
import
engagement_timelines
as
views
from
analytics_data_api.v0.urls
import
USERNAME_PATTERN
urlpatterns
=
patterns
(
''
,
url
(
r'^{}/$'
.
format
(
USERNAME_PATTERN
),
views
.
EngagementTimelineView
.
as_view
(),
name
=
'engagement_timelines'
),
)
analytics_data_api/v0/urls/learners.py
View file @
e7df0f3c
from
django.conf.urls
import
patterns
,
url
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
from
analytics_data_api.v0.urls
import
USERNAME_PATTERN
USERNAME_PATTERN
=
r'(?P<username>.+)'
urlpatterns
=
patterns
(
urlpatterns
=
patterns
(
''
,
''
,
url
(
r'^$'
,
views
.
LearnerListView
.
as_view
(),
name
=
'learners'
),
url
(
r'^$'
,
views
.
LearnerListView
.
as_view
(),
name
=
'learners'
),
...
...
analytics_data_api/v0/views/__init__.py
View file @
e7df0f3c
from
opaque_keys
import
InvalidKeyError
from
opaque_keys.edx.keys
import
CourseKey
from
analytics_data_api.v0.exceptions
import
(
CourseNotSpecifiedError
,
CourseKeyMalformedError
)
class
CourseViewMixin
(
object
):
"""
Captures the course_id query arg and validates it.
"""
course_id
=
None
def
get
(
self
,
request
,
*
args
,
**
kwargs
):
self
.
course_id
=
request
.
QUERY_PARAMS
.
get
(
'course_id'
,
None
)
if
not
self
.
course_id
:
raise
CourseNotSpecifiedError
()
try
:
CourseKey
.
from_string
(
self
.
course_id
)
except
InvalidKeyError
:
raise
CourseKeyMalformedError
(
course_id
=
self
.
course_id
)
return
super
(
CourseViewMixin
,
self
)
.
get
(
request
,
*
args
,
**
kwargs
)
analytics_data_api/v0/views/engagement_timelines.py
0 → 100644
View file @
e7df0f3c
"""
API methods for module level data.
"""
from
rest_framework
import
generics
,
status
from
analytics_data_api.v0.exceptions
import
LearnerEngagementTimelineNotFoundError
from
analytics_data_api.v0.models
import
ModuleEngagement
from
analytics_data_api.v0.serializers
import
EngagementDaySerializer
from
analytics_data_api.v0.views
import
CourseViewMixin
class
EngagementTimelineView
(
CourseViewMixin
,
generics
.
ListAPIView
):
"""
Get a particular learner's engagement timeline for a particular course. Days
without data will not be returned.
**Example Request**
GET /api/v0/engagement_timeline/{username}/?course_id={course_id}
**Response Values**
Returns the engagement timeline.
* days: Array of the learner's daily engagement timeline.
* problems_attempted: Unique number of unique problems attempted.
* problems_completed: Unique number of problems completed.
* discussions_contributed: Number of discussions participated in (e.g. forum posts)
* videos_viewed: Number of videos watched.
**Parameters**
You can specify course ID for which you want data.
course_id -- The course within which user data is requested.
"""
serializer_class
=
EngagementDaySerializer
username
=
None
lookup_field
=
'username'
def
list
(
self
,
request
,
*
args
,
**
kwargs
):
response
=
super
(
EngagementTimelineView
,
self
)
.
list
(
request
,
*
args
,
**
kwargs
)
if
response
.
status_code
==
status
.
HTTP_200_OK
:
response
.
data
=
{
'days'
:
response
.
data
}
return
response
def
get
(
self
,
request
,
*
args
,
**
kwargs
):
self
.
username
=
self
.
kwargs
.
get
(
'username'
)
return
super
(
EngagementTimelineView
,
self
)
.
get
(
request
,
*
args
,
**
kwargs
)
def
get_queryset
(
self
):
queryset
=
ModuleEngagement
.
objects
.
get_timelines
(
self
.
course_id
,
self
.
username
)
if
len
(
queryset
)
==
0
:
raise
LearnerEngagementTimelineNotFoundError
(
username
=
self
.
username
,
course_id
=
self
.
course_id
)
return
queryset
analytics_data_api/v0/views/learners.py
View file @
e7df0f3c
...
@@ -3,39 +3,17 @@ API methods for module level data.
...
@@ -3,39 +3,17 @@ API methods for module level data.
"""
"""
from
rest_framework
import
generics
from
rest_framework
import
generics
from
opaque_keys
import
InvalidKeyError
from
analytics_data_api.constants
import
learner
from
opaque_keys.edx.keys
import
CourseKey
from
analytics_data_api.v0.exceptions
import
(
from
analytics_data_api.v0.exceptions
import
(
CourseKeyMalformedError
,
CourseNotSpecifiedError
,
LearnerNotFoundError
,
LearnerNotFoundError
,
ParameterValueError
,
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
ElasticsearchDSLSearchSerializer
,
LearnerSerializer
from
analytics_data_api.v0.serializers
import
ElasticsearchDSLSearchSerializer
,
LearnerSerializer
from
analytics_data_api.v0.views
import
CourseViewMixin
from
analytics_data_api.v0.views.utils
import
split_query_argument
from
analytics_data_api.v0.views.utils
import
split_query_argument
class
CourseViewMixin
(
object
):
"""
Captures the course_id query arg and validates it.
"""
course_id
=
None
def
get
(
self
,
request
,
*
args
,
**
kwargs
):
self
.
course_id
=
request
.
QUERY_PARAMS
.
get
(
'course_id'
,
None
)
if
not
self
.
course_id
:
raise
CourseNotSpecifiedError
()
try
:
CourseKey
.
from_string
(
self
.
course_id
)
except
InvalidKeyError
:
raise
CourseKeyMalformedError
(
course_id
=
self
.
course_id
)
return
super
(
CourseViewMixin
,
self
)
.
get
(
request
,
*
args
,
**
kwargs
)
class
LearnerView
(
CourseViewMixin
,
generics
.
RetrieveAPIView
):
class
LearnerView
(
CourseViewMixin
,
generics
.
RetrieveAPIView
):
"""
"""
Get a particular student's data for a particular course.
Get a particular student's data for a particular course.
...
...
analyticsdataserver/settings/base.py
View file @
e7df0f3c
...
@@ -164,6 +164,7 @@ MIDDLEWARE_CLASSES = (
...
@@ -164,6 +164,7 @@ MIDDLEWARE_CLASSES = (
'django.contrib.auth.middleware.AuthenticationMiddleware'
,
'django.contrib.auth.middleware.AuthenticationMiddleware'
,
'django.contrib.messages.middleware.MessageMiddleware'
,
'django.contrib.messages.middleware.MessageMiddleware'
,
'django.middleware.clickjacking.XFrameOptionsMiddleware'
,
'django.middleware.clickjacking.XFrameOptionsMiddleware'
,
'analytics_data_api.v0.middleware.LearnerEngagementTimelineNotFoundErrorMiddleware'
,
'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'
,
...
...
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