Commit a4fd62a6 by Dennis Jen Committed by GitHub

Merge pull request #145 from edx/dsjen/course-list-metadata

Adds the course metadata enrollment summary endpoint.
parents d2fcb7b3 8103ad0e
......@@ -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:
......
......@@ -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(BaseCourseModel):
""" 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(BaseCourseModel):
""" 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(BaseCourseModel):
""" 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(BaseCourseModel):
""" 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(BaseCourseModel):
"""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(BaseCourseModel):
"""
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'
......@@ -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')
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)
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')
......@@ -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'),
......
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'),
]
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)
......
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)
"""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 raiseing Http404 if function evaulation is falsey (e.g. empty queryset).
Decorator for raising Http404 if function evaluation 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)
......@@ -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
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment