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
a4fd62a6
Commit
a4fd62a6
authored
Dec 02, 2016
by
Dennis Jen
Committed by
GitHub
Dec 02, 2016
Browse files
Options
Browse Files
Download
Plain Diff
Merge pull request #145 from edx/dsjen/course-list-metadata
Adds the course metadata enrollment summary endpoint.
parents
d2fcb7b3
8103ad0e
Hide whitespace changes
Inline
Side-by-side
Showing
11 changed files
with
487 additions
and
42 deletions
+487
-42
analytics_data_api/management/commands/generate_fake_course_data.py
+27
-4
analytics_data_api/v0/models.py
+42
-30
analytics_data_api/v0/serializers.py
+47
-0
analytics_data_api/v0/tests/views/test_course_summaries.py
+164
-0
analytics_data_api/v0/tests/views/test_utils.py
+48
-0
analytics_data_api/v0/urls/__init__.py
+1
-0
analytics_data_api/v0/urls/course_summaries.py
+7
-0
analytics_data_api/v0/views/__init__.py
+3
-7
analytics_data_api/v0/views/course_summaries.py
+133
-0
analytics_data_api/v0/views/utils.py
+14
-1
requirements/base.txt
+1
-0
No files found.
analytics_data_api/management/commands/generate_fake_course_data.py
View file @
a4fd62a6
...
...
@@ -2,8 +2,10 @@
import
datetime
import
logging
from
optparse
import
make_option
import
math
import
random
from
optparse
import
make_option
from
tqdm
import
tqdm
from
django.core.management.base
import
BaseCommand
from
django.utils
import
timezone
...
...
@@ -87,7 +89,8 @@ class Command(BaseCommand):
models
.
CourseEnrollmentByGender
,
models
.
CourseEnrollmentByEducation
,
models
.
CourseEnrollmentByBirthYear
,
models
.
CourseEnrollmentByCountry
]:
models
.
CourseEnrollmentByCountry
,
models
.
CourseMetaSummaryEnrollment
]:
model
.
objects
.
all
()
.
delete
()
logger
.
info
(
"Deleted all daily course enrollment data."
)
...
...
@@ -98,6 +101,7 @@ class Command(BaseCommand):
date
=
start_date
cumulative_count
=
0
progress
=
tqdm
(
total
=
(
end_date
-
date
)
.
days
+
2
)
while
date
<=
end_date
:
daily_total
=
get_count
(
daily_total
)
models
.
CourseEnrollmentDaily
.
objects
.
create
(
course_id
=
course_id
,
date
=
date
,
count
=
daily_total
)
...
...
@@ -128,8 +132,21 @@ class Command(BaseCommand):
models
.
CourseEnrollmentByBirthYear
.
objects
.
create
(
course_id
=
course_id
,
date
=
date
,
count
=
count
,
birth_year
=
birth_year
)
progress
.
update
(
1
)
date
=
date
+
datetime
.
timedelta
(
days
=
1
)
for
mode
,
ratio
in
enrollment_mode_ratios
.
iteritems
():
count
=
int
(
ratio
*
daily_total
)
cumulative_count
=
count
+
random
.
randint
(
0
,
100
)
models
.
CourseMetaSummaryEnrollment
.
objects
.
create
(
course_id
=
course_id
,
catalog_course_title
=
'Demo Course'
,
catalog_course
=
'Demo_Course'
,
start_date
=
timezone
.
now
()
-
datetime
.
timedelta
(
weeks
=
6
),
end_date
=
timezone
.
now
()
+
datetime
.
timedelta
(
weeks
=
10
),
pacing_type
=
'self_paced'
,
availability
=
'Current'
,
mode
=
mode
,
count
=
count
,
cumulative_count
=
cumulative_count
,
count_change_7_days
=
random
.
randint
(
-
50
,
50
))
progress
.
update
(
1
)
progress
.
close
()
logger
.
info
(
"Done!"
)
def
generate_weekly_data
(
self
,
course_id
,
start_date
,
end_date
):
...
...
@@ -144,6 +161,7 @@ class Command(BaseCommand):
logger
.
info
(
"Generating new weekly course activity data..."
)
progress
=
tqdm
(
total
=
math
.
ceil
((
end_date
-
start
)
.
days
/
7.0
)
+
1
)
while
start
<
end_date
:
active_students
=
random
.
randint
(
100
,
4000
)
# End date should occur on Saturday at 23:59:59
...
...
@@ -159,8 +177,10 @@ class Command(BaseCommand):
count
=
active_students
,
interval_start
=
start
,
interval_end
=
end
)
progress
.
update
(
1
)
start
=
end
progress
.
close
()
logger
.
info
(
"Done!"
)
def
generate_video_timeline_data
(
self
,
video_id
):
...
...
@@ -193,6 +213,7 @@ class Command(BaseCommand):
logger
.
info
(
"Generating learner engagement module data..."
)
current
=
start_date
progress
=
tqdm
(
total
=
(
end_date
-
start_date
)
.
days
+
1
)
while
current
<
end_date
:
current
=
current
+
datetime
.
timedelta
(
days
=
1
)
for
metric
in
engagement_events
.
INDIVIDUAL_EVENTS
:
...
...
@@ -206,7 +227,9 @@ class Command(BaseCommand):
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!"
)
progress
.
update
(
1
)
progress
.
close
()
logger
.
info
(
"Done!"
)
def
generate_learner_engagement_range_data
(
self
,
course_id
,
start_date
,
end_date
,
max_value
=
100
):
logger
.
info
(
"Deleting engagement range data..."
)
...
...
@@ -256,7 +279,7 @@ class Command(BaseCommand):
username
=
options
[
'username'
]
video_id
=
'0fac49ba'
video_module_id
=
'i4x-edX-DemoX-video-5c90cffecd9b48b188cbfea176bf7fe9'
start_date
=
datetime
.
datetime
(
year
=
2016
,
month
=
1
,
day
=
1
,
tzinfo
=
timezone
.
utc
)
start_date
=
timezone
.
now
()
-
datetime
.
timedelta
(
weeks
=
10
)
num_weeks
=
options
[
'num_weeks'
]
if
num_weeks
:
...
...
analytics_data_api/v0/models.py
View file @
a4fd62a6
...
...
@@ -12,21 +12,27 @@ from analytics_data_api.constants.engagement_types import EngagementType
from
analytics_data_api.utils
import
date_range
class
CourseActivityWeekly
(
models
.
Model
):
"""A count of unique users who performed a particular action during a week."""
class
BaseCourseModel
(
models
.
Model
):
course_id
=
models
.
CharField
(
db_index
=
True
,
max_length
=
255
)
created
=
models
.
DateTimeField
(
auto_now_add
=
True
)
class
Meta
(
object
):
abstract
=
True
class
CourseActivityWeekly
(
BaseCourseModel
):
"""A count of unique users who performed a particular action during a week."""
class
Meta
(
BaseCourseModel
.
Meta
):
db_table
=
'course_activity'
index_together
=
[[
'course_id'
,
'activity_type'
]]
ordering
=
(
'interval_end'
,
'interval_start'
,
'course_id'
)
get_latest_by
=
'interval_end'
course_id
=
models
.
CharField
(
db_index
=
True
,
max_length
=
255
)
interval_start
=
models
.
DateTimeField
()
interval_end
=
models
.
DateTimeField
(
db_index
=
True
)
activity_type
=
models
.
CharField
(
db_index
=
True
,
max_length
=
255
,
db_column
=
'label'
)
count
=
models
.
IntegerField
()
created
=
models
.
DateTimeField
(
auto_now_add
=
True
)
@classmethod
def
get_most_recent
(
cls
,
course_id
,
activity_type
):
...
...
@@ -34,13 +40,11 @@ class CourseActivityWeekly(models.Model):
return
cls
.
objects
.
filter
(
course_id
=
course_id
,
activity_type
=
activity_type
)
.
latest
(
'interval_end'
)
class
BaseCourseEnrollment
(
models
.
Model
):
course_id
=
models
.
CharField
(
max_length
=
255
)
class
BaseCourseEnrollment
(
BaseCourseModel
):
date
=
models
.
DateField
(
null
=
False
,
db_index
=
True
)
count
=
models
.
IntegerField
(
null
=
False
)
created
=
models
.
DateTimeField
(
auto_now_add
=
True
)
class
Meta
(
object
):
class
Meta
(
BaseCourseModel
.
Meta
):
abstract
=
True
get_latest_by
=
'date'
index_together
=
[(
'course_id'
,
'date'
,)]
...
...
@@ -63,6 +67,24 @@ class CourseEnrollmentModeDaily(BaseCourseEnrollment):
unique_together
=
[(
'course_id'
,
'date'
,
'mode'
)]
class
CourseMetaSummaryEnrollment
(
BaseCourseModel
):
catalog_course_title
=
models
.
CharField
(
db_index
=
True
,
max_length
=
255
)
catalog_course
=
models
.
CharField
(
db_index
=
True
,
max_length
=
255
)
start_date
=
models
.
DateTimeField
()
end_date
=
models
.
DateTimeField
()
pacing_type
=
models
.
CharField
(
db_index
=
True
,
max_length
=
255
)
availability
=
models
.
CharField
(
db_index
=
True
,
max_length
=
255
)
mode
=
models
.
CharField
(
max_length
=
255
)
count
=
models
.
IntegerField
(
null
=
False
)
cumulative_count
=
models
.
IntegerField
(
null
=
False
)
count_change_7_days
=
models
.
IntegerField
(
default
=
0
)
class
Meta
(
BaseCourseModel
.
Meta
):
db_table
=
'course_meta_summary_enrollment'
ordering
=
(
'course_id'
,)
unique_together
=
[(
'course_id'
,
'mode'
,)]
class
CourseEnrollmentByBirthYear
(
BaseCourseEnrollment
):
birth_year
=
models
.
IntegerField
(
null
=
False
)
...
...
@@ -103,14 +125,13 @@ class CourseEnrollmentByGender(BaseCourseEnrollment):
unique_together
=
[(
'course_id'
,
'date'
,
'gender'
)]
class
BaseProblemResponseAnswerDistribution
(
models
.
Model
):
class
BaseProblemResponseAnswerDistribution
(
BaseCourse
Model
):
""" Base model for the answer_distribution table. """
class
Meta
(
object
):
class
Meta
(
BaseCourseModel
.
Meta
):
db_table
=
'answer_distribution'
abstract
=
True
course_id
=
models
.
CharField
(
db_index
=
True
,
max_length
=
255
)
module_id
=
models
.
CharField
(
db_index
=
True
,
max_length
=
255
)
part_id
=
models
.
CharField
(
db_index
=
True
,
max_length
=
255
)
correct
=
models
.
NullBooleanField
()
...
...
@@ -119,7 +140,6 @@ class BaseProblemResponseAnswerDistribution(models.Model):
variant
=
models
.
IntegerField
(
null
=
True
)
problem_display_name
=
models
.
TextField
(
null
=
True
)
question_text
=
models
.
TextField
(
null
=
True
)
created
=
models
.
DateTimeField
(
auto_now_add
=
True
)
class
ProblemResponseAnswerDistribution
(
BaseProblemResponseAnswerDistribution
):
...
...
@@ -131,19 +151,17 @@ class ProblemResponseAnswerDistribution(BaseProblemResponseAnswerDistribution):
count
=
models
.
IntegerField
()
class
ProblemsAndTags
(
models
.
Model
):
class
ProblemsAndTags
(
BaseCourse
Model
):
""" Model for the tags_distribution table """
class
Meta
(
object
):
class
Meta
(
BaseCourseModel
.
Meta
):
db_table
=
'tags_distribution'
course_id
=
models
.
CharField
(
db_index
=
True
,
max_length
=
255
)
module_id
=
models
.
CharField
(
db_index
=
True
,
max_length
=
255
)
tag_name
=
models
.
CharField
(
max_length
=
255
)
tag_value
=
models
.
CharField
(
max_length
=
255
)
total_submissions
=
models
.
IntegerField
(
default
=
0
)
correct_submissions
=
models
.
IntegerField
(
default
=
0
)
created
=
models
.
DateTimeField
(
auto_now_add
=
True
)
class
ProblemFirstLastResponseAnswerDistribution
(
BaseProblemResponseAnswerDistribution
):
...
...
@@ -172,30 +190,26 @@ class CourseEnrollmentByCountry(BaseCourseEnrollment):
unique_together
=
[(
'course_id'
,
'date'
,
'country_code'
)]
class
GradeDistribution
(
models
.
Model
):
class
GradeDistribution
(
BaseCourse
Model
):
""" Each row stores the count of a particular grade on a module for a given course. """
class
Meta
(
object
):
class
Meta
(
BaseCourseModel
.
Meta
):
db_table
=
'grade_distribution'
module_id
=
models
.
CharField
(
db_index
=
True
,
max_length
=
255
)
course_id
=
models
.
CharField
(
db_index
=
True
,
max_length
=
255
)
grade
=
models
.
IntegerField
()
max_grade
=
models
.
IntegerField
()
count
=
models
.
IntegerField
()
created
=
models
.
DateTimeField
(
auto_now_add
=
True
)
class
SequentialOpenDistribution
(
models
.
Model
):
class
SequentialOpenDistribution
(
BaseCourse
Model
):
""" Each row stores the count of views a particular module has had in a given course. """
class
Meta
(
object
):
class
Meta
(
BaseCourseModel
.
Meta
):
db_table
=
'sequential_open_distribution'
module_id
=
models
.
CharField
(
db_index
=
True
,
max_length
=
255
)
course_id
=
models
.
CharField
(
db_index
=
True
,
max_length
=
255
)
count
=
models
.
IntegerField
()
created
=
models
.
DateTimeField
(
auto_now_add
=
True
)
class
BaseVideo
(
models
.
Model
):
...
...
@@ -465,10 +479,9 @@ class ModuleEngagementTimelineManager(models.Manager):
return
full_timeline
class
ModuleEngagement
(
models
.
Model
):
class
ModuleEngagement
(
BaseCourse
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
.
DateField
()
# This will be one of "problem", "video" or "discussion"
...
...
@@ -483,18 +496,17 @@ class ModuleEngagement(models.Model):
objects
=
ModuleEngagementTimelineManager
()
class
Meta
(
object
):
class
Meta
(
BaseCourseModel
.
Meta
):
db_table
=
'module_engagement'
class
ModuleEngagementMetricRanges
(
models
.
Model
):
class
ModuleEngagementMetricRanges
(
BaseCourse
Model
):
"""
Represents the low and high values for a module engagement entity and event
pair, known as the metric. The range_type will either be low, normal, or
high, bounded by low_value and high_value.
"""
course_id
=
models
.
CharField
(
db_index
=
True
,
max_length
=
255
)
start_date
=
models
.
DateField
()
# This is a left-closed interval. No data from the end_date is included in the analysis.
end_date
=
models
.
DateField
()
...
...
@@ -505,5 +517,5 @@ class ModuleEngagementMetricRanges(models.Model):
high_value
=
models
.
FloatField
()
low_value
=
models
.
FloatField
()
class
Meta
(
object
):
class
Meta
(
BaseCourseModel
.
Meta
):
db_table
=
'module_engagement_metric_ranges'
analytics_data_api/v0/serializers.py
View file @
a4fd62a6
...
...
@@ -507,3 +507,50 @@ class CourseLearnerMetadataSerializer(serializers.Serializer):
})
return
engagement_ranges
class
DynamicFieldsModelSerializer
(
serializers
.
ModelSerializer
):
"""
A ModelSerializer that takes an additional `fields` argument that controls which
fields should be displayed.
Blatantly taken from http://www.django-rest-framework.org/api-guide/serializers/#dynamically-modifying-fields
"""
def
__init__
(
self
,
*
args
,
**
kwargs
):
# Don't pass the 'fields' arg up to the superclass
fields
=
kwargs
.
pop
(
'fields'
,
None
)
# Instantiate the superclass normally
super
(
DynamicFieldsModelSerializer
,
self
)
.
__init__
(
*
args
,
**
kwargs
)
if
fields
is
not
None
:
# Drop any fields that are not specified in the `fields` argument.
allowed
=
set
(
fields
)
existing
=
set
(
self
.
fields
.
keys
())
for
field_name
in
existing
-
allowed
:
self
.
fields
.
pop
(
field_name
)
class
CourseMetaSummaryEnrollmentSerializer
(
ModelSerializerWithCreatedField
,
DynamicFieldsModelSerializer
):
"""
Serializer for course and enrollment counts per mode.
"""
course_id
=
serializers
.
CharField
()
catalog_course_title
=
serializers
.
CharField
()
catalog_course
=
serializers
.
CharField
()
start_date
=
serializers
.
DateTimeField
(
format
=
settings
.
DATETIME_FORMAT
)
end_date
=
serializers
.
DateTimeField
(
format
=
settings
.
DATETIME_FORMAT
)
pacing_type
=
serializers
.
CharField
()
availability
=
serializers
.
CharField
()
count
=
serializers
.
IntegerField
(
default
=
0
)
cumulative_count
=
serializers
.
IntegerField
(
default
=
0
)
count_change_7_days
=
serializers
.
IntegerField
(
default
=
0
)
modes
=
serializers
.
SerializerMethodField
()
def
get_modes
(
self
,
obj
):
return
obj
.
get
(
'modes'
,
None
)
class
Meta
(
object
):
model
=
models
.
CourseMetaSummaryEnrollment
exclude
=
(
'id'
,
'mode'
)
analytics_data_api/v0/tests/views/test_course_summaries.py
0 → 100644
View file @
a4fd62a6
import
datetime
from
urllib
import
urlencode
import
ddt
from
django_dynamic_fixture
import
G
import
pytz
from
django.conf
import
settings
from
analytics_data_api.constants
import
enrollment_modes
from
analytics_data_api.v0
import
models
,
serializers
from
analytics_data_api.v0.tests.views
import
CourseSamples
,
VerifyCourseIdMixin
from
analyticsdataserver.tests
import
TestCaseWithAuthentication
@ddt.ddt
class
CourseSummariesViewTests
(
VerifyCourseIdMixin
,
TestCaseWithAuthentication
):
model
=
models
.
CourseMetaSummaryEnrollment
serializer
=
serializers
.
CourseMetaSummaryEnrollmentSerializer
expected_summaries
=
[]
def
setUp
(
self
):
super
(
CourseSummariesViewTests
,
self
)
.
setUp
()
self
.
now
=
datetime
.
datetime
.
utcnow
()
def
tearDown
(
self
):
self
.
model
.
objects
.
all
()
.
delete
()
def
path
(
self
,
course_ids
=
None
,
fields
=
None
):
query_params
=
{}
for
query_arg
,
data
in
zip
([
'course_ids'
,
'fields'
],
[
course_ids
,
fields
]):
if
data
:
query_params
[
query_arg
]
=
','
.
join
(
data
)
query_string
=
'?{}'
.
format
(
urlencode
(
query_params
))
return
'/api/v0/course_summaries/{}'
.
format
(
query_string
)
def
generate_data
(
self
,
course_ids
=
None
,
modes
=
None
):
"""Generate course summary data for """
if
course_ids
is
None
:
course_ids
=
CourseSamples
.
course_ids
if
modes
is
None
:
modes
=
enrollment_modes
.
ALL
for
course_id
in
course_ids
:
for
mode
in
modes
:
G
(
self
.
model
,
course_id
=
course_id
,
catalog_course_title
=
'Title'
,
catalog_course
=
'Catalog'
,
start_date
=
datetime
.
datetime
(
2016
,
10
,
11
,
tzinfo
=
pytz
.
utc
),
end_date
=
datetime
.
datetime
(
2016
,
12
,
18
,
tzinfo
=
pytz
.
utc
),
pacing_type
=
'instructor'
,
availability
=
'current'
,
mode
=
mode
,
count
=
5
,
cumulative_count
=
10
,
count_change_7_days
=
1
,
create
=
self
.
now
,)
def
expected_summary
(
self
,
course_id
,
modes
=
None
):
"""Expected summary information for a course and modes to populate with data."""
if
modes
is
None
:
modes
=
enrollment_modes
.
ALL
num_modes
=
len
(
modes
)
count_factor
=
5
cumulative_count_factor
=
10
count_change_factor
=
1
summary
=
{
'course_id'
:
course_id
,
'catalog_course_title'
:
'Title'
,
'catalog_course'
:
'Catalog'
,
'start_date'
:
datetime
.
datetime
(
2016
,
10
,
11
,
tzinfo
=
pytz
.
utc
)
.
strftime
(
settings
.
DATETIME_FORMAT
),
'end_date'
:
datetime
.
datetime
(
2016
,
12
,
18
,
tzinfo
=
pytz
.
utc
)
.
strftime
(
settings
.
DATETIME_FORMAT
),
'pacing_type'
:
'instructor'
,
'availability'
:
'current'
,
'modes'
:
{},
'count'
:
count_factor
*
num_modes
,
'cumulative_count'
:
cumulative_count_factor
*
num_modes
,
'count_change_7_days'
:
count_change_factor
*
num_modes
,
'created'
:
self
.
now
.
strftime
(
settings
.
DATETIME_FORMAT
),
}
summary
[
'modes'
]
.
update
({
mode
:
{
'count'
:
count_factor
,
'cumulative_count'
:
cumulative_count_factor
,
'count_change_7_days'
:
count_change_factor
,
}
for
mode
in
modes
})
summary
[
'modes'
]
.
update
({
mode
:
{
'count'
:
0
,
'cumulative_count'
:
0
,
'count_change_7_days'
:
0
,
}
for
mode
in
set
(
enrollment_modes
.
ALL
)
-
set
(
modes
)
})
no_prof
=
summary
[
'modes'
]
.
pop
(
enrollment_modes
.
PROFESSIONAL_NO_ID
)
prof
=
summary
[
'modes'
]
.
get
(
enrollment_modes
.
PROFESSIONAL
)
prof
.
update
({
'count'
:
prof
[
'count'
]
+
no_prof
[
'count'
],
'cumulative_count'
:
prof
[
'cumulative_count'
]
+
no_prof
[
'cumulative_count'
],
'count_change_7_days'
:
prof
[
'count_change_7_days'
]
+
no_prof
[
'count_change_7_days'
],
})
return
summary
def
all_expected_summaries
(
self
,
modes
=
None
):
if
modes
is
None
:
modes
=
enrollment_modes
.
ALL
return
[
self
.
expected_summary
(
course_id
,
modes
)
for
course_id
in
CourseSamples
.
course_ids
]
@ddt.data
(
None
,
CourseSamples
.
course_ids
,
[
'not/real/course'
]
.
extend
(
CourseSamples
.
course_ids
),
)
def
test_all_courses
(
self
,
course_ids
):
self
.
generate_data
()
response
=
self
.
authenticated_get
(
self
.
path
(
course_ids
=
course_ids
))
self
.
assertEquals
(
response
.
status_code
,
200
)
self
.
assertItemsEqual
(
response
.
data
,
self
.
all_expected_summaries
())
@ddt.data
(
*
CourseSamples
.
course_ids
)
def
test_one_course
(
self
,
course_id
):
self
.
generate_data
()
response
=
self
.
authenticated_get
(
self
.
path
(
course_ids
=
[
course_id
]))
self
.
assertEquals
(
response
.
status_code
,
200
)
self
.
assertItemsEqual
(
response
.
data
,
[
self
.
expected_summary
(
course_id
)])
@ddt.data
(
[
'availability'
],
[
'modes'
,
'course_id'
],
)
def
test_fields
(
self
,
fields
):
self
.
generate_data
()
response
=
self
.
authenticated_get
(
self
.
path
(
fields
=
fields
))
self
.
assertEquals
(
response
.
status_code
,
200
)
# remove fields not requested from expected results
expected_summaries
=
self
.
all_expected_summaries
()
for
expected_summary
in
expected_summaries
:
for
field_to_remove
in
set
(
expected_summary
.
keys
())
-
set
(
fields
):
expected_summary
.
pop
(
field_to_remove
)
self
.
assertItemsEqual
(
response
.
data
,
expected_summaries
)
@ddt.data
(
[
enrollment_modes
.
VERIFIED
],
[
enrollment_modes
.
HONOR
,
enrollment_modes
.
PROFESSIONAL
],
)
def
test_empty_modes
(
self
,
modes
):
self
.
generate_data
(
modes
=
modes
)
response
=
self
.
authenticated_get
(
self
.
path
())
self
.
assertEquals
(
response
.
status_code
,
200
)
self
.
assertItemsEqual
(
response
.
data
,
self
.
all_expected_summaries
(
modes
))
def
test_no_summaries
(
self
):
response
=
self
.
authenticated_get
(
self
.
path
())
self
.
assertEquals
(
response
.
status_code
,
404
)
def
test_no_matching_courses
(
self
):
self
.
generate_data
()
response
=
self
.
authenticated_get
(
self
.
path
(
course_ids
=
[
'no/course/found'
]))
self
.
assertEquals
(
response
.
status_code
,
404
)
@ddt.data
(
[
'malformed-course-id'
],
[
CourseSamples
.
course_ids
[
0
],
'malformed-course-id'
],
)
def
test_bad_course_id
(
self
,
course_ids
):
response
=
self
.
authenticated_get
(
self
.
path
(
course_ids
=
course_ids
))
self
.
verify_bad_course_id
(
response
)
analytics_data_api/v0/tests/views/test_utils.py
0 → 100644
View file @
a4fd62a6
import
ddt
from
mock
import
Mock
from
django.http
import
Http404
from
django.test
import
TestCase
from
analytics_data_api.v0.exceptions
import
CourseKeyMalformedError
from
analytics_data_api.v0.tests.views
import
CourseSamples
import
analytics_data_api.v0.views.utils
as
utils
@ddt.ddt
class
UtilsTest
(
TestCase
):
@ddt.data
(
None
,
'not-a-key'
,
)
def
test_invalid_course_id
(
self
,
course_id
):
with
self
.
assertRaises
(
CourseKeyMalformedError
):
utils
.
validate_course_id
(
course_id
)
@ddt.data
(
*
CourseSamples
.
course_ids
)
def
test_valid_course_id
(
self
,
course_id
):
try
:
utils
.
validate_course_id
(
course_id
)
except
CourseKeyMalformedError
:
self
.
fail
(
'Unexpected CourseKeyMalformedError!'
)
def
test_split_query_argument_none
(
self
):
self
.
assertIsNone
(
utils
.
split_query_argument
(
None
))
@ddt.data
(
(
'one'
,
[
'one'
]),
(
'one,two'
,
[
'one'
,
'two'
]),
)
@ddt.unpack
def
test_split_query_argument
(
self
,
query_args
,
expected
):
self
.
assertListEqual
(
utils
.
split_query_argument
(
query_args
),
expected
)
def
test_raise_404_if_none_raises_error
(
self
):
decorated_func
=
utils
.
raise_404_if_none
(
Mock
(
return_value
=
None
))
with
self
.
assertRaises
(
Http404
):
decorated_func
(
self
)
def
test_raise_404_if_none_passes_through
(
self
):
decorated_func
=
utils
.
raise_404_if_none
(
Mock
(
return_value
=
'Not a 404'
))
self
.
assertEqual
(
decorated_func
(
self
),
'Not a 404'
)
analytics_data_api/v0/urls/__init__.py
View file @
a4fd62a6
...
...
@@ -9,6 +9,7 @@ urlpatterns = [
url
(
r'^problems/'
,
include
(
'analytics_data_api.v0.urls.problems'
,
'problems'
)),
url
(
r'^videos/'
,
include
(
'analytics_data_api.v0.urls.videos'
,
'videos'
)),
url
(
'^'
,
include
(
'analytics_data_api.v0.urls.learners'
,
'learners'
)),
url
(
'^'
,
include
(
'analytics_data_api.v0.urls.course_summaries'
,
'course_summaries'
)),
# pylint: disable=no-value-for-parameter
url
(
r'^authenticated/$'
,
RedirectView
.
as_view
(
url
=
reverse_lazy
(
'authenticated'
)),
name
=
'authenticated'
),
...
...
analytics_data_api/v0/urls/course_summaries.py
0 → 100644
View file @
a4fd62a6
from
django.conf.urls
import
url
from
analytics_data_api.v0.views
import
course_summaries
as
views
urlpatterns
=
[
url
(
r'^course_summaries/$'
,
views
.
CourseSummariesView
.
as_view
(),
name
=
'course_summaries'
),
]
analytics_data_api/v0/views/__init__.py
View file @
a4fd62a6
from
opaque_keys
import
InvalidKeyError
from
opaque_keys.edx.keys
import
CourseKey
from
django.utils
import
timezone
from
rest_framework.response
import
Response
from
analytics_data_api.v0.exceptions
import
(
CourseNotSpecifiedError
,
CourseKeyMalformedError
)
from
analytics_data_api.v0.exceptions
import
CourseNotSpecifiedError
import
analytics_data_api.utils
as
utils
class
CourseViewMixin
(
object
):
...
...
@@ -18,10 +17,7 @@ class CourseViewMixin(object):
if
not
self
.
course_id
:
raise
CourseNotSpecifiedError
()
try
:
CourseKey
.
from_string
(
self
.
course_id
)
except
InvalidKeyError
:
raise
CourseKeyMalformedError
(
course_id
=
self
.
course_id
)
utils
.
validate_course_id
(
self
.
course_id
)
return
super
(
CourseViewMixin
,
self
)
.
get
(
request
,
*
args
,
**
kwargs
)
...
...
analytics_data_api/v0/views/course_summaries.py
0 → 100644
View file @
a4fd62a6
from
itertools
import
groupby
from
django.db.models
import
Q
from
rest_framework
import
generics
from
analytics_data_api.constants
import
enrollment_modes
from
analytics_data_api.v0
import
models
,
serializers
from
analytics_data_api.v0.views.utils
import
(
raise_404_if_none
,
split_query_argument
,
validate_course_id
,
)
class
CourseSummariesView
(
generics
.
ListAPIView
):
"""
Returns summary information for courses.
**Example Request**
GET /api/v0/course_summaries/?course_ids={course_id},{course_id}
**Response Values**
Returns the count of each gender specified by users:
* course_id: The ID of the course for which data is returned.
* catalog_course_title: The name of the course.
* catalog_course: Course identifier without run.
* start_date: The date and time that the course begins
* end_date: The date and time that the course ends
* pacing_type: The type of pacing for this course
* availability: Availability status of the course
* count: The total count of currently enrolled learners across modes.
* cumulative_count: The total cumulative total of all users ever enrolled across modes.
* count_change_7_days: Total difference in enrollment counts over the past 7 days across modes.
* modes: For each enrollment mode, the count, cumulative_count, and count_change_7_days.
* created: The date the counts were computed.
**Parameters**
Results can be filed to the course IDs specified or limited to the fields.
course_ids -- The comma-separated course identifiers for which summaries are requested.
For example, 'edX/DemoX/Demo_Course,course-v1:edX+DemoX+Demo_2016'. Default is to
return call courses.
fields -- The comma-separated fields to return in the response.
For example, 'course_id,created_mode'. Default is to return all fields.
fields -- Fields to include in response. Default is all.
"""
course_ids
=
None
fields
=
None
serializer_class
=
serializers
.
CourseMetaSummaryEnrollmentSerializer
model
=
models
.
CourseMetaSummaryEnrollment
def
get_serializer
(
self
,
*
args
,
**
kwargs
):
kwargs
.
update
({
'context'
:
self
.
get_serializer_context
(),
'fields'
:
self
.
fields
,
})
return
self
.
get_serializer_class
()(
*
args
,
**
kwargs
)
def
get
(
self
,
request
,
*
args
,
**
kwargs
):
query_params
=
self
.
request
.
query_params
self
.
fields
=
split_query_argument
(
query_params
.
get
(
'fields'
))
self
.
course_ids
=
split_query_argument
(
query_params
.
get
(
'course_ids'
))
if
self
.
course_ids
is
not
None
:
for
course_id
in
self
.
course_ids
:
validate_course_id
(
course_id
)
return
super
(
CourseSummariesView
,
self
)
.
get
(
request
,
*
args
,
**
kwargs
)
def
default_summary
(
self
,
course_id
,
count_fields
):
"""Default summary with fields populated to default levels."""
summary
=
{
'course_id'
:
course_id
,
'created'
:
None
,
'modes'
:
{},
}
summary
.
update
({
field
:
0
for
field
in
count_fields
})
summary
[
'modes'
]
.
update
({
mode
:
{
count_field
:
0
for
count_field
in
count_fields
}
for
mode
in
enrollment_modes
.
ALL
})
return
summary
def
group_by_mode
(
self
,
queryset
):
"""Return enrollment counts for nested in each mode and top-level enrollment counts."""
formatted_data
=
[]
for
course_id
,
summaries
in
groupby
(
queryset
,
lambda
x
:
(
x
.
course_id
)):
count_fields
=
[
'count'
,
'count_change_7_days'
,
'cumulative_count'
]
item
=
self
.
default_summary
(
course_id
,
count_fields
)
# aggregate the enrollment counts for each mode
for
summary
in
summaries
:
summary_meta_fields
=
[
'catalog_course_title'
,
'catalog_course'
,
'start_date'
,
'end_date'
,
'pacing_type'
,
'availability'
]
item
.
update
({
field
:
getattr
(
summary
,
field
)
for
field
in
summary_meta_fields
})
item
[
'modes'
]
.
update
({
summary
.
mode
:
{
field
:
getattr
(
summary
,
field
)
for
field
in
count_fields
}
})
# treat the most recent as the authoritative created date -- should be all the same
item
[
'created'
]
=
max
(
summary
.
created
,
item
[
'created'
])
if
item
[
'created'
]
else
summary
.
created
# update totals for all counts
item
.
update
({
field
:
item
[
field
]
+
getattr
(
summary
,
field
)
for
field
in
count_fields
})
# Merge professional with non verified professional
modes
=
item
[
'modes'
]
prof_no_id_mode
=
modes
.
pop
(
enrollment_modes
.
PROFESSIONAL_NO_ID
,
{})
prof_mode
=
modes
[
enrollment_modes
.
PROFESSIONAL
]
for
count_key
in
count_fields
:
prof_mode
[
count_key
]
=
prof_mode
.
get
(
count_key
,
0
)
+
prof_no_id_mode
.
pop
(
count_key
,
0
)
formatted_data
.
append
(
item
)
return
formatted_data
@raise_404_if_none
def
get_queryset
(
self
):
if
self
.
course_ids
:
# create an OR query for course IDs that match
query
=
reduce
(
lambda
q
,
course_id
:
q
|
Q
(
course_id
=
course_id
),
self
.
course_ids
,
Q
())
queryset
=
self
.
model
.
objects
.
filter
(
query
)
else
:
queryset
=
self
.
model
.
objects
.
all
()
return
self
.
group_by_mode
(
queryset
)
analytics_data_api/v0/views/utils.py
View file @
a4fd62a6
"""Utilities for view-level API logic."""
from
django.http
import
Http404
from
opaque_keys
import
InvalidKeyError
from
opaque_keys.edx.keys
import
CourseKey
from
analytics_data_api.v0.exceptions
import
CourseKeyMalformedError
def
split_query_argument
(
argument
):
"""
...
...
@@ -15,7 +20,7 @@ def split_query_argument(argument):
def
raise_404_if_none
(
func
):
"""
Decorator for rais
eing Http404 if function evaul
ation is falsey (e.g. empty queryset).
Decorator for rais
ing Http404 if function evalu
ation is falsey (e.g. empty queryset).
"""
def
func_wrapper
(
self
):
queryset
=
func
(
self
)
...
...
@@ -24,3 +29,11 @@ def raise_404_if_none(func):
else
:
raise
Http404
return
func_wrapper
def
validate_course_id
(
course_id
):
"""Raises CourseKeyMalformedError if course ID is invalid."""
try
:
CourseKey
.
from_string
(
course_id
)
except
InvalidKeyError
:
raise
CourseKeyMalformedError
(
course_id
=
course_id
)
requirements/base.txt
View file @
a4fd62a6
...
...
@@ -8,6 +8,7 @@ djangorestframework-csv==1.4.1 # BSD
django-storages==1.4.1 # BSD
elasticsearch-dsl==0.0.11 # Apache 2.0
ordered-set==2.0.1 # MIT
tqdm==4.10.0 # MIT
# markdown is used by swagger for rendering the api docs
Markdown==2.6.6 # BSD
...
...
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