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

Commit c436129

Browse files
authored
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
1 parent 6e81e13 commit c436129

12 files changed

Lines changed: 560 additions & 168 deletions

File tree

analytics_data_api/management/commands/generate_fake_course_data.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,8 @@ def generate_daily_data(self, course_id, start_date, end_date):
115115
models.CourseEnrollmentByEducation,
116116
models.CourseEnrollmentByBirthYear,
117117
models.CourseEnrollmentByCountry,
118-
models.CourseMetaSummaryEnrollment]:
118+
models.CourseMetaSummaryEnrollment,
119+
models.CourseProgramMetadata]:
119120
model.objects.all().delete()
120121

121122
logger.info("Deleted all daily course enrollment data.")
@@ -170,6 +171,9 @@ def generate_daily_data(self, course_id, start_date, end_date):
170171
pacing_type='self_paced', availability='Starting Soon', enrollment_mode=mode, count=count,
171172
cumulative_count=cumulative_count, count_change_7_days=random.randint(-50, 50))
172173

174+
models.CourseProgramMetadata.objects.create(course_id=course_id, program_id='Demo_Program',
175+
program_type='Demo', program_title='Demo Program')
176+
173177
progress.update(1)
174178
progress.close()
175179
logger.info("Done!")

analytics_data_api/v0/models.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,17 @@ class Meta(BaseCourseModel.Meta):
8585
unique_together = [('course_id', 'enrollment_mode',)]
8686

8787

88+
class CourseProgramMetadata(BaseCourseModel):
89+
program_id = models.CharField(db_index=True, max_length=255)
90+
program_type = models.CharField(db_index=True, max_length=255)
91+
program_title = models.CharField(max_length=255)
92+
93+
class Meta(BaseCourseModel.Meta):
94+
db_table = 'course_program_metadata'
95+
ordering = ('course_id',)
96+
unique_together = [('course_id', 'program_id',)]
97+
98+
8899
class CourseEnrollmentByBirthYear(BaseCourseEnrollment):
89100
birth_year = models.IntegerField(null=False)
90101

analytics_data_api/v0/serializers.py

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -511,15 +511,23 @@ def get_engagement_ranges(self, obj):
511511

512512
class DynamicFieldsModelSerializer(serializers.ModelSerializer):
513513
"""
514-
A ModelSerializer that takes an additional `fields` argument that controls which
514+
A ModelSerializer that takes additional `fields` and/or `exclude` keyword arguments that control which
515515
fields should be displayed.
516516
517517
Blatantly taken from http://www.django-rest-framework.org/api-guide/serializers/#dynamically-modifying-fields
518+
519+
If a field name is specified in both `fields` and `exclude`, then the exclude option takes precedence and the field
520+
will not be included in the serialized result.
521+
522+
Keyword Arguments:
523+
fields -- list of field names on the model to include in the serialized result
524+
exclude -- list of field names on the model to exclude in the serialized result
518525
"""
519526

520527
def __init__(self, *args, **kwargs):
521528
# Don't pass the 'fields' arg up to the superclass
522529
fields = kwargs.pop('fields', None)
530+
exclude = kwargs.pop('exclude', None)
523531

524532
# Instantiate the superclass normally
525533
super(DynamicFieldsModelSerializer, self).__init__(*args, **kwargs)
@@ -531,6 +539,13 @@ def __init__(self, *args, **kwargs):
531539
for field_name in existing - allowed:
532540
self.fields.pop(field_name)
533541

542+
if exclude is not None:
543+
# Drop any fields that are specified in the `exclude` argument.
544+
disallowed = set(exclude)
545+
existing = set(self.fields.keys())
546+
for field_name in existing & disallowed: # intersection
547+
self.fields.pop(field_name)
548+
534549

535550
class CourseMetaSummaryEnrollmentSerializer(ModelSerializerWithCreatedField, DynamicFieldsModelSerializer):
536551
"""
@@ -547,11 +562,34 @@ class CourseMetaSummaryEnrollmentSerializer(ModelSerializerWithCreatedField, Dyn
547562
cumulative_count = serializers.IntegerField(default=0)
548563
count_change_7_days = serializers.IntegerField(default=0)
549564
enrollment_modes = serializers.SerializerMethodField()
565+
programs = serializers.SerializerMethodField()
550566

551567
def get_enrollment_modes(self, obj):
552568
return obj.get('enrollment_modes', None)
553569

570+
def get_programs(self, obj):
571+
return obj.get('programs', None)
572+
554573
class Meta(object):
555574
model = models.CourseMetaSummaryEnrollment
556575
# start_date and end_date used instead of start_time and end_time
557576
exclude = ('id', 'start_time', 'end_time', 'enrollment_mode')
577+
578+
579+
class CourseProgramMetadataSerializer(ModelSerializerWithCreatedField, DynamicFieldsModelSerializer):
580+
"""
581+
Serializer for course and the programs it is under.
582+
"""
583+
program_id = serializers.CharField()
584+
program_type = serializers.CharField()
585+
program_title = serializers.CharField()
586+
course_ids = serializers.SerializerMethodField()
587+
588+
def get_course_ids(self, obj):
589+
return obj.get('course_ids', None)
590+
591+
class Meta(object):
592+
model = models.CourseProgramMetadata
593+
# excluding course-related fields because the serialized output will be embedded in a course object
594+
# with those fields already defined
595+
exclude = ('id', 'created', 'course_id')
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
from datetime import date
2+
from django.test import TestCase
3+
from django_dynamic_fixture import G
4+
5+
from analytics_data_api.v0 import models as api_models, serializers as api_serializers
6+
7+
8+
class TestSerializer(api_serializers.CourseEnrollmentDailySerializer, api_serializers.DynamicFieldsModelSerializer):
9+
pass
10+
11+
12+
class DynamicFieldsModelSerializerTests(TestCase):
13+
def test_fields(self):
14+
now = date.today()
15+
instance = G(api_models.CourseEnrollmentDaily, course_id='1', count=1, date=now)
16+
serialized = TestSerializer(instance)
17+
self.assertListEqual(serialized.data.keys(), ['course_id', 'date', 'count', 'created'])
18+
19+
instance = G(api_models.CourseEnrollmentDaily, course_id='2', count=1, date=now)
20+
serialized = TestSerializer(instance, fields=('course_id',))
21+
self.assertListEqual(serialized.data.keys(), ['course_id'])
22+
23+
def test_exclude(self):
24+
now = date.today()
25+
instance = G(api_models.CourseEnrollmentDaily, course_id='3', count=1, date=now)
26+
serialized = TestSerializer(instance, exclude=('course_id',))
27+
self.assertListEqual(serialized.data.keys(), ['date', 'count', 'created'])

analytics_data_api/v0/tests/views/__init__.py

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1+
import csv
12
import json
23
import StringIO
3-
import csv
4+
from collections import OrderedDict
5+
from urllib import urlencode
46

7+
from django_dynamic_fixture import G
58
from rest_framework import status
69

710
from analytics_data_api.v0.tests.utils import flatten
@@ -15,6 +18,12 @@ class CourseSamples(object):
1518
'ccx-v1:edx+1.005x-CCX+rerun+ccx@15'
1619
]
1720

21+
program_ids = [
22+
'482dee71-e4b9-4b42-a47b-3e16bb69e8f2',
23+
'71c14f59-35d5-41f2-a017-e108d2d9f127',
24+
'cfc6b5ee-6aa1-4c82-8421-20418c492618'
25+
]
26+
1827

1928
class VerifyCourseIdMixin(object):
2029

@@ -80,3 +89,78 @@ def assertResponseFields(self, response, fields):
8089
# Just check the header row
8190
self.assertGreater(len(rows), 1)
8291
self.assertEqual(rows[0], fields)
92+
93+
94+
class APIListViewTestMixin(object):
95+
model = None
96+
model_id = 'id'
97+
serializer = None
98+
expected_results = []
99+
list_name = 'list'
100+
default_ids = []
101+
always_exclude = ['created']
102+
103+
def path(self, ids=None, fields=None, exclude=None, **kwargs):
104+
query_params = {}
105+
for query_arg, data in zip(['ids', 'fields', 'exclude'], [ids, fields, exclude]) + kwargs.items():
106+
if data:
107+
query_params[query_arg] = ','.join(data)
108+
query_string = '?{}'.format(urlencode(query_params))
109+
return '/api/v0/{}/{}'.format(self.list_name, query_string)
110+
111+
def create_model(self, model_id, **kwargs):
112+
pass # implement in subclass
113+
114+
def generate_data(self, ids=None, **kwargs):
115+
"""Generate list data"""
116+
if ids is None:
117+
ids = self.default_ids
118+
119+
for item_id in ids:
120+
self.create_model(item_id, **kwargs)
121+
122+
def expected_result(self, item_id):
123+
result = OrderedDict([
124+
(self.model_id, item_id),
125+
])
126+
return result
127+
128+
def all_expected_results(self, ids=None, **kwargs):
129+
if ids is None:
130+
ids = self.default_ids
131+
132+
return [self.expected_result(item_id, **kwargs) for item_id in ids]
133+
134+
def _test_all_items(self, ids):
135+
self.generate_data()
136+
response = self.authenticated_get(self.path(ids=ids, exclude=self.always_exclude))
137+
self.assertEquals(response.status_code, 200)
138+
self.assertItemsEqual(response.data, self.all_expected_results(ids=ids))
139+
140+
def _test_one_item(self, item_id):
141+
self.generate_data()
142+
response = self.authenticated_get(self.path(ids=[item_id], exclude=self.always_exclude))
143+
self.assertEquals(response.status_code, 200)
144+
self.assertItemsEqual(response.data, [self.expected_result(item_id)])
145+
146+
def _test_fields(self, fields):
147+
self.generate_data()
148+
response = self.authenticated_get(self.path(fields=fields))
149+
self.assertEquals(response.status_code, 200)
150+
151+
# remove fields not requested from expected results
152+
expected_results = self.all_expected_results()
153+
for expected_result in expected_results:
154+
for field_to_remove in set(expected_result.keys()) - set(fields):
155+
expected_result.pop(field_to_remove)
156+
157+
self.assertItemsEqual(response.data, expected_results)
158+
159+
def test_no_items(self):
160+
response = self.authenticated_get(self.path())
161+
self.assertEquals(response.status_code, 404)
162+
163+
def test_no_matching_items(self):
164+
self.generate_data()
165+
response = self.authenticated_get(self.path(ids=['no/items/found']))
166+
self.assertEquals(response.status_code, 404)

0 commit comments

Comments
 (0)