Commit c4361294 by Tyler Hallada Committed by GitHub

AN-8555 Add programs endpoint (#166)

* Add programs endpoint & programs list to summaries

* Exclude programs array from the course_summaries

* Add/fix tests, program field exclude option

* Refactor common list view code into APIListView

* Fix some pylint errors

* Fix bad import

* Use assertListEqual and sort lists first

* Refactor common test code into mixin

* Increase test coverage (test add_programs)

* Add documentation for programs query arg

* Address PR comments
parent 6e81e131
......@@ -115,7 +115,8 @@ class Command(BaseCommand):
models.CourseEnrollmentByEducation,
models.CourseEnrollmentByBirthYear,
models.CourseEnrollmentByCountry,
models.CourseMetaSummaryEnrollment]:
models.CourseMetaSummaryEnrollment,
models.CourseProgramMetadata]:
model.objects.all().delete()
logger.info("Deleted all daily course enrollment data.")
......@@ -170,6 +171,9 @@ class Command(BaseCommand):
pacing_type='self_paced', availability='Starting Soon', enrollment_mode=mode, count=count,
cumulative_count=cumulative_count, count_change_7_days=random.randint(-50, 50))
models.CourseProgramMetadata.objects.create(course_id=course_id, program_id='Demo_Program',
program_type='Demo', program_title='Demo Program')
progress.update(1)
progress.close()
logger.info("Done!")
......
......@@ -85,6 +85,17 @@ class CourseMetaSummaryEnrollment(BaseCourseModel):
unique_together = [('course_id', 'enrollment_mode',)]
class CourseProgramMetadata(BaseCourseModel):
program_id = models.CharField(db_index=True, max_length=255)
program_type = models.CharField(db_index=True, max_length=255)
program_title = models.CharField(max_length=255)
class Meta(BaseCourseModel.Meta):
db_table = 'course_program_metadata'
ordering = ('course_id',)
unique_together = [('course_id', 'program_id',)]
class CourseEnrollmentByBirthYear(BaseCourseEnrollment):
birth_year = models.IntegerField(null=False)
......
......@@ -511,15 +511,23 @@ class CourseLearnerMetadataSerializer(serializers.Serializer):
class DynamicFieldsModelSerializer(serializers.ModelSerializer):
"""
A ModelSerializer that takes an additional `fields` argument that controls which
A ModelSerializer that takes additional `fields` and/or `exclude` keyword arguments that control which
fields should be displayed.
Blatantly taken from http://www.django-rest-framework.org/api-guide/serializers/#dynamically-modifying-fields
If a field name is specified in both `fields` and `exclude`, then the exclude option takes precedence and the field
will not be included in the serialized result.
Keyword Arguments:
fields -- list of field names on the model to include in the serialized result
exclude -- list of field names on the model to exclude in the serialized result
"""
def __init__(self, *args, **kwargs):
# Don't pass the 'fields' arg up to the superclass
fields = kwargs.pop('fields', None)
exclude = kwargs.pop('exclude', None)
# Instantiate the superclass normally
super(DynamicFieldsModelSerializer, self).__init__(*args, **kwargs)
......@@ -531,6 +539,13 @@ class DynamicFieldsModelSerializer(serializers.ModelSerializer):
for field_name in existing - allowed:
self.fields.pop(field_name)
if exclude is not None:
# Drop any fields that are specified in the `exclude` argument.
disallowed = set(exclude)
existing = set(self.fields.keys())
for field_name in existing & disallowed: # intersection
self.fields.pop(field_name)
class CourseMetaSummaryEnrollmentSerializer(ModelSerializerWithCreatedField, DynamicFieldsModelSerializer):
"""
......@@ -547,11 +562,34 @@ class CourseMetaSummaryEnrollmentSerializer(ModelSerializerWithCreatedField, Dyn
cumulative_count = serializers.IntegerField(default=0)
count_change_7_days = serializers.IntegerField(default=0)
enrollment_modes = serializers.SerializerMethodField()
programs = serializers.SerializerMethodField()
def get_enrollment_modes(self, obj):
return obj.get('enrollment_modes', None)
def get_programs(self, obj):
return obj.get('programs', None)
class Meta(object):
model = models.CourseMetaSummaryEnrollment
# start_date and end_date used instead of start_time and end_time
exclude = ('id', 'start_time', 'end_time', 'enrollment_mode')
class CourseProgramMetadataSerializer(ModelSerializerWithCreatedField, DynamicFieldsModelSerializer):
"""
Serializer for course and the programs it is under.
"""
program_id = serializers.CharField()
program_type = serializers.CharField()
program_title = serializers.CharField()
course_ids = serializers.SerializerMethodField()
def get_course_ids(self, obj):
return obj.get('course_ids', None)
class Meta(object):
model = models.CourseProgramMetadata
# excluding course-related fields because the serialized output will be embedded in a course object
# with those fields already defined
exclude = ('id', 'created', 'course_id')
from datetime import date
from django.test import TestCase
from django_dynamic_fixture import G
from analytics_data_api.v0 import models as api_models, serializers as api_serializers
class TestSerializer(api_serializers.CourseEnrollmentDailySerializer, api_serializers.DynamicFieldsModelSerializer):
pass
class DynamicFieldsModelSerializerTests(TestCase):
def test_fields(self):
now = date.today()
instance = G(api_models.CourseEnrollmentDaily, course_id='1', count=1, date=now)
serialized = TestSerializer(instance)
self.assertListEqual(serialized.data.keys(), ['course_id', 'date', 'count', 'created'])
instance = G(api_models.CourseEnrollmentDaily, course_id='2', count=1, date=now)
serialized = TestSerializer(instance, fields=('course_id',))
self.assertListEqual(serialized.data.keys(), ['course_id'])
def test_exclude(self):
now = date.today()
instance = G(api_models.CourseEnrollmentDaily, course_id='3', count=1, date=now)
serialized = TestSerializer(instance, exclude=('course_id',))
self.assertListEqual(serialized.data.keys(), ['date', 'count', 'created'])
import csv
import json
import StringIO
import csv
from collections import OrderedDict
from urllib import urlencode
from django_dynamic_fixture import G
from rest_framework import status
from analytics_data_api.v0.tests.utils import flatten
......@@ -15,6 +18,12 @@ class CourseSamples(object):
'ccx-v1:edx+1.005x-CCX+rerun+ccx@15'
]
program_ids = [
'482dee71-e4b9-4b42-a47b-3e16bb69e8f2',
'71c14f59-35d5-41f2-a017-e108d2d9f127',
'cfc6b5ee-6aa1-4c82-8421-20418c492618'
]
class VerifyCourseIdMixin(object):
......@@ -80,3 +89,78 @@ class VerifyCsvResponseMixin(object):
# Just check the header row
self.assertGreater(len(rows), 1)
self.assertEqual(rows[0], fields)
class APIListViewTestMixin(object):
model = None
model_id = 'id'
serializer = None
expected_results = []
list_name = 'list'
default_ids = []
always_exclude = ['created']
def path(self, ids=None, fields=None, exclude=None, **kwargs):
query_params = {}
for query_arg, data in zip(['ids', 'fields', 'exclude'], [ids, fields, exclude]) + kwargs.items():
if data:
query_params[query_arg] = ','.join(data)
query_string = '?{}'.format(urlencode(query_params))
return '/api/v0/{}/{}'.format(self.list_name, query_string)
def create_model(self, model_id, **kwargs):
pass # implement in subclass
def generate_data(self, ids=None, **kwargs):
"""Generate list data"""
if ids is None:
ids = self.default_ids
for item_id in ids:
self.create_model(item_id, **kwargs)
def expected_result(self, item_id):
result = OrderedDict([
(self.model_id, item_id),
])
return result
def all_expected_results(self, ids=None, **kwargs):
if ids is None:
ids = self.default_ids
return [self.expected_result(item_id, **kwargs) for item_id in ids]
def _test_all_items(self, ids):
self.generate_data()
response = self.authenticated_get(self.path(ids=ids, exclude=self.always_exclude))
self.assertEquals(response.status_code, 200)
self.assertItemsEqual(response.data, self.all_expected_results(ids=ids))
def _test_one_item(self, item_id):
self.generate_data()
response = self.authenticated_get(self.path(ids=[item_id], exclude=self.always_exclude))
self.assertEquals(response.status_code, 200)
self.assertItemsEqual(response.data, [self.expected_result(item_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_results = self.all_expected_results()
for expected_result in expected_results:
for field_to_remove in set(expected_result.keys()) - set(fields):
expected_result.pop(field_to_remove)
self.assertItemsEqual(response.data, expected_results)
def test_no_items(self):
response = self.authenticated_get(self.path())
self.assertEquals(response.status_code, 404)
def test_no_matching_items(self):
self.generate_data()
response = self.authenticated_get(self.path(ids=['no/items/found']))
self.assertEquals(response.status_code, 404)
import datetime
from urllib import urlencode
import ddt
from django_dynamic_fixture import G
......@@ -9,15 +8,19 @@ 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 analytics_data_api.v0.tests.views import CourseSamples, VerifyCourseIdMixin, APIListViewTestMixin
from analyticsdataserver.tests import TestCaseWithAuthentication
@ddt.ddt
class CourseSummariesViewTests(VerifyCourseIdMixin, TestCaseWithAuthentication):
class CourseSummariesViewTests(VerifyCourseIdMixin, TestCaseWithAuthentication, APIListViewTestMixin):
model = models.CourseMetaSummaryEnrollment
model_id = 'course_id'
serializer = serializers.CourseMetaSummaryEnrollmentSerializer
expected_summaries = []
list_name = 'course_summaries'
default_ids = CourseSamples.course_ids
always_exclude = ['created', 'programs']
def setUp(self):
super(CourseSummariesViewTests, self).setUp()
......@@ -27,32 +30,29 @@ class CourseSummariesViewTests(VerifyCourseIdMixin, TestCaseWithAuthentication):
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, availability='Current'):
"""Generate course summary data for """
if course_ids is None:
course_ids = CourseSamples.course_ids
def create_model(self, model_id, **kwargs):
for mode in kwargs['modes']:
G(self.model, course_id=model_id, catalog_course_title='Title', catalog_course='Catalog',
start_time=datetime.datetime(2016, 10, 11, tzinfo=pytz.utc),
end_time=datetime.datetime(2016, 12, 18, tzinfo=pytz.utc),
pacing_type='instructor', availability=kwargs['availability'], enrollment_mode=mode,
count=5, cumulative_count=10, count_change_7_days=1, create=self.now,)
if 'programs' in kwargs and kwargs['programs']:
# Create a link from this course to a program
G(models.CourseProgramMetadata, course_id=model_id, program_id=CourseSamples.program_ids[0],
program_type='Demo', program_title='Test')
def generate_data(self, ids=None, modes=None, availability='Current', **kwargs):
"""Generate course summary data"""
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_time=datetime.datetime(2016, 10, 11, tzinfo=pytz.utc),
end_time=datetime.datetime(2016, 12, 18, tzinfo=pytz.utc),
pacing_type='instructor', availability=availability, enrollment_mode=mode,
count=5, cumulative_count=10, count_change_7_days=1, create=self.now,)
super(CourseSummariesViewTests, self).generate_data(ids=ids, modes=modes, availability=availability, **kwargs)
def expected_summary(self, course_id, modes=None, availability='Current'):
def expected_result(self, item_id, modes=None, availability='Current', programs=False): # pylint: disable=arguments-differ
"""Expected summary information for a course and modes to populate with data."""
summary = super(CourseSummariesViewTests, self).expected_result(item_id)
if modes is None:
modes = enrollment_modes.ALL
......@@ -60,20 +60,18 @@ class CourseSummariesViewTests(VerifyCourseIdMixin, TestCaseWithAuthentication):
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': availability,
'enrollment_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.update([
('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', availability),
('count', count_factor * num_modes),
('cumulative_count', cumulative_count_factor * num_modes),
('count_change_7_days', count_change_factor * num_modes),
('enrollment_modes', {}),
])
summary['enrollment_modes'].update({
mode: {
'count': count_factor,
......@@ -95,16 +93,17 @@ class CourseSummariesViewTests(VerifyCourseIdMixin, TestCaseWithAuthentication):
'cumulative_count': prof['cumulative_count'] + no_prof['cumulative_count'],
'count_change_7_days': prof['count_change_7_days'] + no_prof['count_change_7_days'],
})
if programs:
summary['programs'] = [CourseSamples.program_ids[0]]
return summary
def all_expected_summaries(self, modes=None, course_ids=None, availability='Current'):
if course_ids is None:
course_ids = CourseSamples.course_ids
def all_expected_results(self, ids=None, modes=None, availability='Current', programs=False): # pylint: disable=arguments-differ
if modes is None:
modes = enrollment_modes.ALL
return [self.expected_summary(course_id, modes, availability) for course_id in course_ids]
return super(CourseSummariesViewTests, self).all_expected_results(ids=ids, modes=modes,
availability=availability,
programs=programs)
@ddt.data(
None,
......@@ -112,34 +111,18 @@ class CourseSummariesViewTests(VerifyCourseIdMixin, TestCaseWithAuthentication):
['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())
self._test_all_items(course_ids)
@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)])
self._test_one_item(course_id)
@ddt.data(
['availability'],
['enrollment_mode', '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)
self._test_fields(fields)
@ddt.data(
[enrollment_modes.VERIFIED],
......@@ -147,35 +130,32 @@ class CourseSummariesViewTests(VerifyCourseIdMixin, TestCaseWithAuthentication):
)
def test_empty_modes(self, modes):
self.generate_data(modes=modes)
response = self.authenticated_get(self.path())
response = self.authenticated_get(self.path(exclude=self.always_exclude))
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)
self.assertItemsEqual(response.data, self.all_expected_results(modes=modes))
@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))
response = self.authenticated_get(self.path(ids=course_ids))
self.verify_bad_course_id(response)
def test_collapse_upcoming(self):
self.generate_data(availability='Starting Soon')
self.generate_data(course_ids=['foo/bar/baz'], availability='Upcoming')
response = self.authenticated_get(self.path())
self.generate_data(ids=['foo/bar/baz'], availability='Upcoming')
response = self.authenticated_get(self.path(exclude=self.always_exclude))
self.assertEquals(response.status_code, 200)
expected_summaries = self.all_expected_summaries(availability='Upcoming')
expected_summaries.extend(self.all_expected_summaries(course_ids=['foo/bar/baz'],
availability='Upcoming'))
expected_summaries = self.all_expected_results(availability='Upcoming')
expected_summaries.extend(self.all_expected_results(ids=['foo/bar/baz'],
availability='Upcoming'))
self.assertItemsEqual(response.data, expected_summaries)
def test_programs(self):
self.generate_data(programs=True)
response = self.authenticated_get(self.path(exclude=self.always_exclude[:1], programs=['True']))
self.assertEquals(response.status_code, 200)
self.assertItemsEqual(response.data, self.all_expected_results(programs=True))
import datetime
import ddt
from django_dynamic_fixture import G
from analytics_data_api.v0 import models, serializers
from analytics_data_api.v0.tests.views import CourseSamples, APIListViewTestMixin
from analyticsdataserver.tests import TestCaseWithAuthentication
@ddt.ddt
class ProgramsViewTests(TestCaseWithAuthentication, APIListViewTestMixin):
model = models.CourseProgramMetadata
model_id = 'program_id'
serializer = serializers.CourseProgramMetadataSerializer
expected_programs = []
list_name = 'programs'
default_ids = CourseSamples.program_ids
def setUp(self):
super(ProgramsViewTests, self).setUp()
self.now = datetime.datetime.utcnow()
self.maxDiff = None
self.course_id = CourseSamples.course_ids[0]
def tearDown(self):
self.model.objects.all().delete()
def create_model(self, model_id):
G(self.model, course_id=self.course_id, program_id=model_id, program_type='Demo', program_title='Test')
def expected_result(self, item_id):
"""Expected program metadata to populate with data."""
program = super(ProgramsViewTests, self).expected_result(item_id)
program.update([
('program_type', 'Demo'),
('program_title', 'Test'),
('course_ids', [self.course_id])
])
return program
@ddt.data(
None,
CourseSamples.program_ids,
['not-real-program'].extend(CourseSamples.program_ids),
)
def test_all_programs(self, program_ids):
self._test_all_items(program_ids)
@ddt.data(*CourseSamples.program_ids)
def test_one_course(self, program_id):
self._test_one_item(program_id)
@ddt.data(
['program_id'],
['program_type', 'program_title'],
)
def test_fields(self, fields):
self._test_fields(fields)
......@@ -10,6 +10,7 @@ urlpatterns = [
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')),
url('^', include('analytics_data_api.v0.urls.programs', 'programs')),
# 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 programs as views
urlpatterns = [
url(r'^programs/$', views.ProgramsView.as_view(), name='programs'),
]
from opaque_keys.edx.keys import CourseKey
from itertools import groupby
from django.db import models
from django.db.models import Q
from django.utils import timezone
from rest_framework import generics, serializers
from opaque_keys.edx.keys import CourseKey
from analytics_data_api.v0.exceptions import CourseNotSpecifiedError
import analytics_data_api.utils as utils
from analytics_data_api.v0.views.utils import (
raise_404_if_none,
split_query_argument,
validate_course_id
)
class CourseViewMixin(object):
......@@ -17,7 +28,7 @@ class CourseViewMixin(object):
if not self.course_id:
raise CourseNotSpecifiedError()
utils.validate_course_id(self.course_id)
validate_course_id(self.course_id)
return super(CourseViewMixin, self).get(request, *args, **kwargs)
......@@ -97,3 +108,114 @@ class CsvViewMixin(object):
if request.META.get('HTTP_ACCEPT') == u'text/csv':
response['Content-Disposition'] = u'attachment; filename={}'.format(self.get_csv_filename())
return super(CsvViewMixin, self).finalize_response(request, response, *args, **kwargs)
class APIListView(generics.ListAPIView):
"""
An abstract view to store common code for views that return a list of data.
**Example Requests**
GET /api/v0/some_endpoint/
Returns full list of serialized models with all default fields.
GET /api/v0/some_endpoint/?ids={id},{id}
Returns list of serialized models with IDs that match an ID in the given
`ids` query parameter with all default fields.
GET /api/v0/some_endpoint/?ids={id},{id}&fields={some_field},{some_field}
Returns list of serialized models with IDs that match an ID in the given
`ids` query parameter with only the fields in the given `fields` query parameter.
GET /api/v0/some_endpoint/?ids={id},{id}&exclude={some_field},{some_field}
Returns list of serialized models with IDs that match an ID in the given
`ids` query parameter with all fields except those in the given `exclude` query
parameter.
**Response Values**
Since this is an abstract class, this view just returns an empty list.
**Parameters**
This view supports filtering the results by a given list of IDs. It also supports
explicitly specifying the fields to include in each result with `fields` as well of
the fields to exclude with `exclude`.
ids -- The comma-separated list of identifiers for which results are filtered to.
For example, 'edX/DemoX/Demo_Course,course-v1:edX+DemoX+Demo_2016'. Default is to
return all courses.
fields -- The comma-separated fields to return in the response.
For example, 'course_id,created'. Default is to return all fields.
exclude -- The comma-separated fields to exclude in the response.
For example, 'course_id,created'. Default is to not exclude any fields.
"""
ids = None
fields = None
exclude = None
always_exclude = []
model_id_field = 'id'
def get_serializer(self, *args, **kwargs):
kwargs.update({
'context': self.get_serializer_context(),
'fields': self.fields,
'exclude': self.exclude
})
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'))
exclude = split_query_argument(query_params.get('exclude'))
self.exclude = self.always_exclude + (exclude if exclude else [])
self.ids = split_query_argument(query_params.get('ids'))
return super(APIListView, self).get(request, *args, **kwargs)
def base_field_dict(self, item_id):
"""Default result with fields pre-populated to default values."""
field_dict = {
self.model_id_field: item_id,
}
return field_dict
def update_field_dict_from_model(self, model, base_field_dict=None, field_list=None):
field_list = (field_list if field_list else
[f.name for f in self.model._meta.get_fields()]) # pylint: disable=protected-access
field_dict = base_field_dict if base_field_dict else {}
field_dict.update({field: getattr(model, field) for field in field_list})
return field_dict
def postprocess_field_dict(self, field_dict):
"""Applies some business logic to final result without access to any data from the original model."""
return field_dict
def group_by_id(self, queryset):
"""Return results aggregated by a distinct ID."""
aggregate_field_dict = []
for item_id, model_group in groupby(queryset, lambda x: (getattr(x, self.model_id_field))):
field_dict = self.base_field_dict(item_id)
for model in model_group:
field_dict = self.update_field_dict_from_model(model, base_field_dict=field_dict)
field_dict = self.postprocess_field_dict(field_dict)
aggregate_field_dict.append(field_dict)
return aggregate_field_dict
def get_query(self):
return reduce(lambda q, item_id: q | Q(id=item_id), self.ids, Q())
@raise_404_if_none
def get_queryset(self):
if self.ids:
queryset = self.model.objects.filter(self.get_query())
else:
queryset = self.model.objects.all()
field_dict = self.group_by_id(queryset)
# Django-rest-framework will serialize this dictionary to a JSON response
return field_dict
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 import APIListView
from analytics_data_api.v0.views.utils import (
raise_404_if_none,
split_query_argument,
validate_course_id,
)
class CourseSummariesView(generics.ListAPIView):
class CourseSummariesView(APIListView):
"""
Returns summary information for courses.
**Example Request**
GET /api/v0/course_summaries/?course_ids={course_id},{course_id}
GET /api/v0/course_summaries/?ids={course_id},{course_id}
**Response Values**
Returns the count of each gender specified by users:
Returns enrollment counts and other metadata for each course:
* course_id: The ID of the course for which data is returned.
* catalog_course_title: The name of the course.
......@@ -37,6 +33,7 @@ class CourseSummariesView(generics.ListAPIView):
* count_change_7_days: Total difference in enrollment counts over the past 7 days across modes.
* enrollment_modes: For each enrollment mode, the count, cumulative_count, and count_change_7_days.
* created: The date the counts were computed.
* programs: List of program IDs that this course is a part of.
**Parameters**
......@@ -44,94 +41,95 @@ class CourseSummariesView(generics.ListAPIView):
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.
return all 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.
For example, 'course_id,created'. Default is to return all fields.
exclude -- The comma-separated fields to exclude in the response.
For example, 'course_id,created'. Default is to exclude the programs array.
programs -- If included in the query parameters, will find each courses' program IDs
and include them in the response.
"""
course_ids = None
fields = None
serializer_class = serializers.CourseMetaSummaryEnrollmentSerializer
programs_serializer_class = serializers.CourseProgramMetadataSerializer
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)
model_id_field = 'course_id'
programs_model = models.CourseProgramMetadata
count_fields = ('count', 'cumulative_count', 'count_change_7_days') # are initialized to 0 by default
summary_meta_fields = ['catalog_course_title', 'catalog_course', 'start_time', 'end_time',
'pacing_type', 'availability'] # fields to extract from summary model
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):
programs = split_query_argument(query_params.get('programs'))
if not programs:
self.always_exclude = self.always_exclude + ['programs']
self.ids = split_query_argument(query_params.get('ids'))
self.verify_ids()
response = super(CourseSummariesView, self).get(request, *args, **kwargs)
return response
def verify_ids(self):
if self.ids is not None:
for item_id in self.ids:
validate_course_id(item_id)
def base_field_dict(self, course_id):
"""Default summary with fields populated to default levels."""
summary = {
'course_id': course_id,
summary = super(CourseSummariesView, self).base_field_dict(course_id)
summary.update({
'created': None,
'enrollment_modes': {},
}
summary.update({field: 0 for field in count_fields})
})
summary.update({field: 0 for field in self.count_fields})
summary['enrollment_modes'].update({
mode: {
count_field: 0 for count_field in count_fields
count_field: 0 for count_field in self.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_time', 'end_time',
'pacing_type', 'availability']
item.update({field: getattr(summary, field) for field in summary_meta_fields})
item['enrollment_modes'].update({
summary.enrollment_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['enrollment_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)
# AN-8236 replace "Starting Soon" to "Upcoming" availability to collapse the two into one value
if item['availability'] == 'Starting Soon':
item['availability'] = 'Upcoming'
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)
def update_field_dict_from_model(self, model, base_field_dict=None, field_list=None):
field_dict = super(CourseSummariesView, self).update_field_dict_from_model(model,
base_field_dict=base_field_dict,
field_list=self.summary_meta_fields)
field_dict['enrollment_modes'].update({
model.enrollment_mode: {field: getattr(model, field) for field in self.count_fields}
})
# treat the most recent as the authoritative created date -- should be all the same
field_dict['created'] = max(model.created, field_dict['created']) if field_dict['created'] else model.created
# update totals for all counts
field_dict.update({field: field_dict[field] + getattr(model, field) for field in self.count_fields})
return field_dict
def postprocess_field_dict(self, field_dict):
# Merge professional with non verified professional
modes = field_dict['enrollment_modes']
prof_no_id_mode = modes.pop(enrollment_modes.PROFESSIONAL_NO_ID, {})
prof_mode = modes[enrollment_modes.PROFESSIONAL]
for count_key in self.count_fields:
prof_mode[count_key] = prof_mode.get(count_key, 0) + prof_no_id_mode.pop(count_key, 0)
# AN-8236 replace "Starting Soon" to "Upcoming" availability to collapse the two into one value
if field_dict['availability'] == 'Starting Soon':
field_dict['availability'] = 'Upcoming'
if self.exclude == [] or (self.exclude and 'programs' not in self.exclude):
# don't do expensive looping for programs if we are just going to throw it away
field_dict = self.add_programs(field_dict)
return field_dict
def add_programs(self, field_dict):
"""Query for programs attached to a course and include them (just the IDs) in the course summary dict"""
field_dict['programs'] = []
queryset = self.programs_model.objects.filter(course_id=field_dict['course_id'])
for program in queryset:
program = self.programs_serializer_class(program.__dict__)
field_dict['programs'].append(program.data['program_id'])
return field_dict
def get_query(self):
return reduce(lambda q, item_id: q | Q(course_id=item_id), self.ids, Q())
from django.db.models import Q
from analytics_data_api.v0 import models, serializers
from analytics_data_api.v0.views import APIListView
class ProgramsView(APIListView):
"""
Returns metadata information for programs.
**Example Request**
GET /api/v0/course_programs/?program_ids={program_id},{program_id}
**Response Values**
Returns metadata for every program:
* program_id: The ID of the program for which data is returned.
* program_type: The type of the program
* program_title: The title of the program
* created: The date the metadata was computed.
**Parameters**
Results can be filtered to the program IDs specified or limited to the fields.
program_ids -- The comma-separated program identifiers for which metadata is requested.
Default is to return all programs.
fields -- The comma-separated fields to return in the response.
For example, 'program_id,created'. Default is to return all fields.
exclude -- The comma-separated fields to exclude in the response.
For example, 'program_id,created'. Default is to not exclude any fields.
"""
serializer_class = serializers.CourseProgramMetadataSerializer
model = models.CourseProgramMetadata
model_id_field = 'program_id'
program_meta_fields = ['program_type', 'program_title']
def base_field_dict(self, program_id):
"""Default program with id, empty metadata, and empty courses array."""
program = super(ProgramsView, self).base_field_dict(program_id)
program.update({
'program_type': '',
'program_title': '',
'created': None,
'course_ids': [],
})
return program
def update_field_dict_from_model(self, model, base_field_dict=None, field_list=None):
field_dict = super(ProgramsView, self).update_field_dict_from_model(model, base_field_dict=base_field_dict,
field_list=self.program_meta_fields)
field_dict['course_ids'].append(model.course_id)
# treat the most recent as the authoritative created date -- should be all the same
field_dict['created'] = max(model.created, field_dict['created']) if field_dict['created'] else model.created
return field_dict
def get_query(self):
return reduce(lambda q, item_id: q | Q(program_id=item_id), self.ids, Q())
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