Skip to content
This repository has been archived by the owner on May 1, 2024. It is now read-only.

Commit

Permalink
AN-8555 Add programs endpoint (#166)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
thallada authored Apr 18, 2017
1 parent 6e81e13 commit c436129
Show file tree
Hide file tree
Showing 12 changed files with 560 additions and 168 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,8 @@ def generate_daily_data(self, course_id, start_date, end_date):
models.CourseEnrollmentByEducation,
models.CourseEnrollmentByBirthYear,
models.CourseEnrollmentByCountry,
models.CourseMetaSummaryEnrollment]:
models.CourseMetaSummaryEnrollment,
models.CourseProgramMetadata]:
model.objects.all().delete()

logger.info("Deleted all daily course enrollment data.")
Expand Down Expand Up @@ -170,6 +171,9 @@ def generate_daily_data(self, course_id, start_date, end_date):
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!")
Expand Down
11 changes: 11 additions & 0 deletions analytics_data_api/v0/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,17 @@ class Meta(BaseCourseModel.Meta):
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)

Expand Down
40 changes: 39 additions & 1 deletion analytics_data_api/v0/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -511,15 +511,23 @@ def get_engagement_ranges(self, obj):

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)
Expand All @@ -531,6 +539,13 @@ def __init__(self, *args, **kwargs):
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):
"""
Expand All @@ -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')
27 changes: 27 additions & 0 deletions analytics_data_api/v0/tests/test_serializers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
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'])
86 changes: 85 additions & 1 deletion analytics_data_api/v0/tests/views/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
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
Expand All @@ -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):

Expand Down Expand Up @@ -80,3 +89,78 @@ def assertResponseFields(self, response, fields):
# 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)
Loading

0 comments on commit c436129

Please sign in to comment.