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): ...@@ -115,7 +115,8 @@ class Command(BaseCommand):
models.CourseEnrollmentByEducation, models.CourseEnrollmentByEducation,
models.CourseEnrollmentByBirthYear, models.CourseEnrollmentByBirthYear,
models.CourseEnrollmentByCountry, models.CourseEnrollmentByCountry,
models.CourseMetaSummaryEnrollment]: models.CourseMetaSummaryEnrollment,
models.CourseProgramMetadata]:
model.objects.all().delete() model.objects.all().delete()
logger.info("Deleted all daily course enrollment data.") logger.info("Deleted all daily course enrollment data.")
...@@ -170,6 +171,9 @@ class Command(BaseCommand): ...@@ -170,6 +171,9 @@ class Command(BaseCommand):
pacing_type='self_paced', availability='Starting Soon', enrollment_mode=mode, count=count, pacing_type='self_paced', availability='Starting Soon', enrollment_mode=mode, count=count,
cumulative_count=cumulative_count, count_change_7_days=random.randint(-50, 50)) 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.update(1)
progress.close() progress.close()
logger.info("Done!") logger.info("Done!")
......
...@@ -85,6 +85,17 @@ class CourseMetaSummaryEnrollment(BaseCourseModel): ...@@ -85,6 +85,17 @@ class CourseMetaSummaryEnrollment(BaseCourseModel):
unique_together = [('course_id', 'enrollment_mode',)] 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): class CourseEnrollmentByBirthYear(BaseCourseEnrollment):
birth_year = models.IntegerField(null=False) birth_year = models.IntegerField(null=False)
......
...@@ -511,15 +511,23 @@ class CourseLearnerMetadataSerializer(serializers.Serializer): ...@@ -511,15 +511,23 @@ class CourseLearnerMetadataSerializer(serializers.Serializer):
class DynamicFieldsModelSerializer(serializers.ModelSerializer): 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. fields should be displayed.
Blatantly taken from http://www.django-rest-framework.org/api-guide/serializers/#dynamically-modifying-fields 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): def __init__(self, *args, **kwargs):
# Don't pass the 'fields' arg up to the superclass # Don't pass the 'fields' arg up to the superclass
fields = kwargs.pop('fields', None) fields = kwargs.pop('fields', None)
exclude = kwargs.pop('exclude', None)
# Instantiate the superclass normally # Instantiate the superclass normally
super(DynamicFieldsModelSerializer, self).__init__(*args, **kwargs) super(DynamicFieldsModelSerializer, self).__init__(*args, **kwargs)
...@@ -531,6 +539,13 @@ class DynamicFieldsModelSerializer(serializers.ModelSerializer): ...@@ -531,6 +539,13 @@ class DynamicFieldsModelSerializer(serializers.ModelSerializer):
for field_name in existing - allowed: for field_name in existing - allowed:
self.fields.pop(field_name) 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): class CourseMetaSummaryEnrollmentSerializer(ModelSerializerWithCreatedField, DynamicFieldsModelSerializer):
""" """
...@@ -547,11 +562,34 @@ class CourseMetaSummaryEnrollmentSerializer(ModelSerializerWithCreatedField, Dyn ...@@ -547,11 +562,34 @@ class CourseMetaSummaryEnrollmentSerializer(ModelSerializerWithCreatedField, Dyn
cumulative_count = serializers.IntegerField(default=0) cumulative_count = serializers.IntegerField(default=0)
count_change_7_days = serializers.IntegerField(default=0) count_change_7_days = serializers.IntegerField(default=0)
enrollment_modes = serializers.SerializerMethodField() enrollment_modes = serializers.SerializerMethodField()
programs = serializers.SerializerMethodField()
def get_enrollment_modes(self, obj): def get_enrollment_modes(self, obj):
return obj.get('enrollment_modes', None) return obj.get('enrollment_modes', None)
def get_programs(self, obj):
return obj.get('programs', None)
class Meta(object): class Meta(object):
model = models.CourseMetaSummaryEnrollment model = models.CourseMetaSummaryEnrollment
# start_date and end_date used instead of start_time and end_time # start_date and end_date used instead of start_time and end_time
exclude = ('id', 'start_time', 'end_time', 'enrollment_mode') 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 json
import StringIO import StringIO
import csv from collections import OrderedDict
from urllib import urlencode
from django_dynamic_fixture import G
from rest_framework import status from rest_framework import status
from analytics_data_api.v0.tests.utils import flatten from analytics_data_api.v0.tests.utils import flatten
...@@ -15,6 +18,12 @@ class CourseSamples(object): ...@@ -15,6 +18,12 @@ class CourseSamples(object):
'ccx-v1:edx+1.005x-CCX+rerun+ccx@15' '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): class VerifyCourseIdMixin(object):
...@@ -80,3 +89,78 @@ class VerifyCsvResponseMixin(object): ...@@ -80,3 +89,78 @@ class VerifyCsvResponseMixin(object):
# Just check the header row # Just check the header row
self.assertGreater(len(rows), 1) self.assertGreater(len(rows), 1)
self.assertEqual(rows[0], fields) 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
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 = [ ...@@ -10,6 +10,7 @@ urlpatterns = [
url(r'^videos/', include('analytics_data_api.v0.urls.videos', 'videos')), 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.learners', 'learners')),
url('^', include('analytics_data_api.v0.urls.course_summaries', 'course_summaries')), 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 # pylint: disable=no-value-for-parameter
url(r'^authenticated/$', RedirectView.as_view(url=reverse_lazy('authenticated')), name='authenticated'), url(r'^authenticated/$', RedirectView.as_view(url=reverse_lazy('authenticated')), name='authenticated'),
......
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 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 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): class CourseViewMixin(object):
...@@ -17,7 +28,7 @@ class CourseViewMixin(object): ...@@ -17,7 +28,7 @@ class CourseViewMixin(object):
if not self.course_id: if not self.course_id:
raise CourseNotSpecifiedError() raise CourseNotSpecifiedError()
utils.validate_course_id(self.course_id) validate_course_id(self.course_id)
return super(CourseViewMixin, self).get(request, *args, **kwargs) return super(CourseViewMixin, self).get(request, *args, **kwargs)
...@@ -97,3 +108,114 @@ class CsvViewMixin(object): ...@@ -97,3 +108,114 @@ class CsvViewMixin(object):
if request.META.get('HTTP_ACCEPT') == u'text/csv': if request.META.get('HTTP_ACCEPT') == u'text/csv':
response['Content-Disposition'] = u'attachment; filename={}'.format(self.get_csv_filename()) response['Content-Disposition'] = u'attachment; filename={}'.format(self.get_csv_filename())
return super(CsvViewMixin, self).finalize_response(request, response, *args, **kwargs) 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 django.db.models import Q
from rest_framework import generics
from analytics_data_api.constants import enrollment_modes from analytics_data_api.constants import enrollment_modes
from analytics_data_api.v0 import models, serializers from analytics_data_api.v0 import models, serializers
from analytics_data_api.v0.views import APIListView
from analytics_data_api.v0.views.utils import ( from analytics_data_api.v0.views.utils import (
raise_404_if_none,
split_query_argument, split_query_argument,
validate_course_id, validate_course_id,
) )
class CourseSummariesView(generics.ListAPIView): class CourseSummariesView(APIListView):
""" """
Returns summary information for courses. Returns summary information for courses.
**Example Request** **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** **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. * course_id: The ID of the course for which data is returned.
* catalog_course_title: The name of the course. * catalog_course_title: The name of the course.
...@@ -37,6 +33,7 @@ class CourseSummariesView(generics.ListAPIView): ...@@ -37,6 +33,7 @@ class CourseSummariesView(generics.ListAPIView):
* count_change_7_days: Total difference in enrollment counts over the past 7 days across modes. * 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. * enrollment_modes: For each enrollment mode, the count, cumulative_count, and count_change_7_days.
* created: The date the counts were computed. * created: The date the counts were computed.
* programs: List of program IDs that this course is a part of.
**Parameters** **Parameters**
...@@ -44,94 +41,95 @@ class CourseSummariesView(generics.ListAPIView): ...@@ -44,94 +41,95 @@ class CourseSummariesView(generics.ListAPIView):
course_ids -- The comma-separated course identifiers for which summaries are requested. 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 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. fields -- The comma-separated fields to return in the response.
For example, 'course_id,created_mode'. Default is to return all fields. For example, 'course_id,created'. Default is to return all fields.
exclude -- The comma-separated fields to exclude in the response.
fields -- Fields to include in response. Default is all. 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 serializer_class = serializers.CourseMetaSummaryEnrollmentSerializer
programs_serializer_class = serializers.CourseProgramMetadataSerializer
model = models.CourseMetaSummaryEnrollment model = models.CourseMetaSummaryEnrollment
model_id_field = 'course_id'
def get_serializer(self, *args, **kwargs): programs_model = models.CourseProgramMetadata
kwargs.update({ count_fields = ('count', 'cumulative_count', 'count_change_7_days') # are initialized to 0 by default
'context': self.get_serializer_context(), summary_meta_fields = ['catalog_course_title', 'catalog_course', 'start_time', 'end_time',
'fields': self.fields, 'pacing_type', 'availability'] # fields to extract from summary model
})
return self.get_serializer_class()(*args, **kwargs)
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
query_params = self.request.query_params query_params = self.request.query_params
self.fields = split_query_argument(query_params.get('fields')) programs = split_query_argument(query_params.get('programs'))
self.course_ids = split_query_argument(query_params.get('course_ids')) if not programs:
if self.course_ids is not None: self.always_exclude = self.always_exclude + ['programs']
for course_id in self.course_ids: self.ids = split_query_argument(query_params.get('ids'))
validate_course_id(course_id) self.verify_ids()
response = super(CourseSummariesView, self).get(request, *args, **kwargs)
return super(CourseSummariesView, self).get(request, *args, **kwargs) return response
def default_summary(self, course_id, count_fields): 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.""" """Default summary with fields populated to default levels."""
summary = { summary = super(CourseSummariesView, self).base_field_dict(course_id)
'course_id': course_id, summary.update({
'created': None, 'created': None,
'enrollment_modes': {}, '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({ summary['enrollment_modes'].update({
mode: { 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 } for mode in enrollment_modes.ALL
}) })
return summary return summary
def group_by_mode(self, queryset): def update_field_dict_from_model(self, model, base_field_dict=None, field_list=None):
"""Return enrollment counts for nested in each mode and top-level enrollment counts.""" field_dict = super(CourseSummariesView, self).update_field_dict_from_model(model,
formatted_data = [] base_field_dict=base_field_dict,
for course_id, summaries in groupby(queryset, lambda x: (x.course_id)): field_list=self.summary_meta_fields)
count_fields = ['count', 'count_change_7_days', 'cumulative_count'] field_dict['enrollment_modes'].update({
item = self.default_summary(course_id, count_fields) model.enrollment_mode: {field: getattr(model, field) for field in self.count_fields}
})
# aggregate the enrollment counts for each mode
for summary in summaries: # treat the most recent as the authoritative created date -- should be all the same
summary_meta_fields = ['catalog_course_title', 'catalog_course', 'start_time', 'end_time', field_dict['created'] = max(model.created, field_dict['created']) if field_dict['created'] else model.created
'pacing_type', 'availability']
item.update({field: getattr(summary, field) for field in summary_meta_fields}) # update totals for all counts
item['enrollment_modes'].update({ field_dict.update({field: field_dict[field] + getattr(model, field) for field in self.count_fields})
summary.enrollment_mode: {field: getattr(summary, field) for field in count_fields}
}) return field_dict
# treat the most recent as the authoritative created date -- should be all the same def postprocess_field_dict(self, field_dict):
item['created'] = max(summary.created, item['created']) if item['created'] else summary.created # Merge professional with non verified professional
modes = field_dict['enrollment_modes']
# update totals for all counts prof_no_id_mode = modes.pop(enrollment_modes.PROFESSIONAL_NO_ID, {})
item.update({field: item[field] + getattr(summary, field) for field in count_fields}) prof_mode = modes[enrollment_modes.PROFESSIONAL]
for count_key in self.count_fields:
# Merge professional with non verified professional prof_mode[count_key] = prof_mode.get(count_key, 0) + prof_no_id_mode.pop(count_key, 0)
modes = item['enrollment_modes']
prof_no_id_mode = modes.pop(enrollment_modes.PROFESSIONAL_NO_ID, {}) # AN-8236 replace "Starting Soon" to "Upcoming" availability to collapse the two into one value
prof_mode = modes[enrollment_modes.PROFESSIONAL] if field_dict['availability'] == 'Starting Soon':
for count_key in count_fields: field_dict['availability'] = 'Upcoming'
prof_mode[count_key] = prof_mode.get(count_key, 0) + prof_no_id_mode.pop(count_key, 0)
if self.exclude == [] or (self.exclude and 'programs' not in self.exclude):
# AN-8236 replace "Starting Soon" to "Upcoming" availability to collapse the two into one value # don't do expensive looping for programs if we are just going to throw it away
if item['availability'] == 'Starting Soon': field_dict = self.add_programs(field_dict)
item['availability'] = 'Upcoming'
return field_dict
formatted_data.append(item)
def add_programs(self, field_dict):
return formatted_data """Query for programs attached to a course and include them (just the IDs) in the course summary dict"""
field_dict['programs'] = []
@raise_404_if_none queryset = self.programs_model.objects.filter(course_id=field_dict['course_id'])
def get_queryset(self): for program in queryset:
if self.course_ids: program = self.programs_serializer_class(program.__dict__)
# create an OR query for course IDs that match field_dict['programs'].append(program.data['program_id'])
query = reduce(lambda q, course_id: q | Q(course_id=course_id), self.course_ids, Q()) return field_dict
queryset = self.model.objects.filter(query)
else: def get_query(self):
queryset = self.model.objects.all() return reduce(lambda q, item_id: q | Q(course_id=item_id), self.ids, Q())
return self.group_by_mode(queryset)
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