Commit ff6e1057 by Kyle McCormick Committed by GitHub

Merge pull request #173 from edx/edx/kdmccormick/post-course-summaries

EDUCATOR-464: Add POST method to course_summaries/
parents 5061f385 ff9fb9bb
...@@ -9,3 +9,4 @@ Jason Bau <jbau@stanford.edu> ...@@ -9,3 +9,4 @@ Jason Bau <jbau@stanford.edu>
John Jarvis <jarv@edx.org> John Jarvis <jarv@edx.org>
Dmitry Viskov <dmitry.viskov@webenterprise.ru> Dmitry Viskov <dmitry.viskov@webenterprise.ru>
Eric Fischer <efischer@edx.org> Eric Fischer <efischer@edx.org>
Kyle McCormick <kylemccor@gmail.com>
...@@ -100,15 +100,29 @@ class APIListViewTestMixin(object): ...@@ -100,15 +100,29 @@ class APIListViewTestMixin(object):
list_name = 'list' list_name = 'list'
default_ids = [] default_ids = []
always_exclude = ['created'] always_exclude = ['created']
test_post_method = False
def path(self, ids=None, fields=None, exclude=None, **kwargs): def path(self, query_data=None):
query_params = {} query_data = query_data or {}
for query_arg, data in zip([self.ids_param, 'fields', 'exclude'], [ids, fields, exclude]) + kwargs.items(): concat_query_data = {param: ','.join(arg) for param, arg in query_data.items() if arg}
if data: query_string = '?{}'.format(urlencode(concat_query_data)) if concat_query_data else ''
query_params[query_arg] = ','.join(data)
query_string = '?{}'.format(urlencode(query_params))
return '/api/v0/{}/{}'.format(self.list_name, query_string) return '/api/v0/{}/{}'.format(self.list_name, query_string)
def validated_request(self, ids=None, fields=None, exclude=None, **extra_args):
params = [self.ids_param, 'fields', 'exclude']
args = [ids, fields, exclude]
data = {param: arg for param, arg in zip(params, args) if arg}
data.update(extra_args)
get_response = self.authenticated_get(self.path(data))
if self.test_post_method:
post_response = self.authenticated_post(self.path(), data=data)
self.assertEquals(get_response.status_code, post_response.status_code)
if 200 <= get_response.status_code < 300:
self.assertEquals(get_response.data, post_response.data)
return get_response
def create_model(self, model_id, **kwargs): def create_model(self, model_id, **kwargs):
pass # implement in subclass pass # implement in subclass
...@@ -134,19 +148,19 @@ class APIListViewTestMixin(object): ...@@ -134,19 +148,19 @@ class APIListViewTestMixin(object):
def _test_all_items(self, ids): def _test_all_items(self, ids):
self.generate_data() self.generate_data()
response = self.authenticated_get(self.path(ids=ids, exclude=self.always_exclude)) response = self.validated_request(ids=ids, exclude=self.always_exclude)
self.assertEquals(response.status_code, 200) self.assertEquals(response.status_code, 200)
self.assertItemsEqual(response.data, self.all_expected_results(ids=ids)) self.assertItemsEqual(response.data, self.all_expected_results(ids=ids))
def _test_one_item(self, item_id): def _test_one_item(self, item_id):
self.generate_data() self.generate_data()
response = self.authenticated_get(self.path(ids=[item_id], exclude=self.always_exclude)) response = self.validated_request(ids=[item_id], exclude=self.always_exclude)
self.assertEquals(response.status_code, 200) self.assertEquals(response.status_code, 200)
self.assertItemsEqual(response.data, [self.expected_result(item_id)]) self.assertItemsEqual(response.data, [self.expected_result(item_id)])
def _test_fields(self, fields): def _test_fields(self, fields):
self.generate_data() self.generate_data()
response = self.authenticated_get(self.path(fields=fields)) response = self.validated_request(fields=fields)
self.assertEquals(response.status_code, 200) self.assertEquals(response.status_code, 200)
# remove fields not requested from expected results # remove fields not requested from expected results
...@@ -158,10 +172,10 @@ class APIListViewTestMixin(object): ...@@ -158,10 +172,10 @@ class APIListViewTestMixin(object):
self.assertItemsEqual(response.data, expected_results) self.assertItemsEqual(response.data, expected_results)
def test_no_items(self): def test_no_items(self):
response = self.authenticated_get(self.path()) response = self.validated_request()
self.assertEquals(response.status_code, 404) self.assertEquals(response.status_code, 404)
def test_no_matching_items(self): def test_no_matching_items(self):
self.generate_data() self.generate_data()
response = self.authenticated_get(self.path(ids=['no/items/found'])) response = self.validated_request(ids=['no/items/found'])
self.assertEquals(response.status_code, 404) self.assertEquals(response.status_code, 404)
...@@ -22,6 +22,7 @@ class CourseSummariesViewTests(VerifyCourseIdMixin, TestCaseWithAuthentication, ...@@ -22,6 +22,7 @@ class CourseSummariesViewTests(VerifyCourseIdMixin, TestCaseWithAuthentication,
list_name = 'course_summaries' list_name = 'course_summaries'
default_ids = CourseSamples.course_ids default_ids = CourseSamples.course_ids
always_exclude = ['created', 'programs'] always_exclude = ['created', 'programs']
test_post_method = True
def setUp(self): def setUp(self):
super(CourseSummariesViewTests, self).setUp() super(CourseSummariesViewTests, self).setUp()
...@@ -135,7 +136,7 @@ class CourseSummariesViewTests(VerifyCourseIdMixin, TestCaseWithAuthentication, ...@@ -135,7 +136,7 @@ class CourseSummariesViewTests(VerifyCourseIdMixin, TestCaseWithAuthentication,
) )
def test_empty_modes(self, modes): def test_empty_modes(self, modes):
self.generate_data(modes=modes) self.generate_data(modes=modes)
response = self.authenticated_get(self.path(exclude=self.always_exclude)) response = self.validated_request(exclude=self.always_exclude)
self.assertEquals(response.status_code, 200) self.assertEquals(response.status_code, 200)
self.assertItemsEqual(response.data, self.all_expected_results(modes=modes)) self.assertItemsEqual(response.data, self.all_expected_results(modes=modes))
...@@ -144,13 +145,13 @@ class CourseSummariesViewTests(VerifyCourseIdMixin, TestCaseWithAuthentication, ...@@ -144,13 +145,13 @@ class CourseSummariesViewTests(VerifyCourseIdMixin, TestCaseWithAuthentication,
[CourseSamples.course_ids[0], 'malformed-course-id'], [CourseSamples.course_ids[0], 'malformed-course-id'],
) )
def test_bad_course_id(self, course_ids): def test_bad_course_id(self, course_ids):
response = self.authenticated_get(self.path(ids=course_ids)) response = self.validated_request(ids=course_ids)
self.verify_bad_course_id(response) self.verify_bad_course_id(response)
def test_collapse_upcoming(self): def test_collapse_upcoming(self):
self.generate_data(availability='Starting Soon') self.generate_data(availability='Starting Soon')
self.generate_data(ids=['foo/bar/baz'], availability='Upcoming') self.generate_data(ids=['foo/bar/baz'], availability='Upcoming')
response = self.authenticated_get(self.path(exclude=self.always_exclude)) response = self.validated_request(exclude=self.always_exclude)
self.assertEquals(response.status_code, 200) self.assertEquals(response.status_code, 200)
expected_summaries = self.all_expected_results(availability='Upcoming') expected_summaries = self.all_expected_results(availability='Upcoming')
...@@ -161,13 +162,13 @@ class CourseSummariesViewTests(VerifyCourseIdMixin, TestCaseWithAuthentication, ...@@ -161,13 +162,13 @@ class CourseSummariesViewTests(VerifyCourseIdMixin, TestCaseWithAuthentication,
def test_programs(self): def test_programs(self):
self.generate_data(programs=True) self.generate_data(programs=True)
response = self.authenticated_get(self.path(exclude=self.always_exclude[:1], programs=['True'])) response = self.validated_request(exclude=self.always_exclude[:1], programs=['True'])
self.assertEquals(response.status_code, 200) self.assertEquals(response.status_code, 200)
self.assertItemsEqual(response.data, self.all_expected_results(programs=True)) self.assertItemsEqual(response.data, self.all_expected_results(programs=True))
@ddt.data('passing_users', ) @ddt.data('passing_users', )
def test_exclude(self, field): def test_exclude(self, field):
self.generate_data() self.generate_data()
response = self.authenticated_get(self.path(exclude=[field])) response = self.validated_request(exclude=[field])
self.assertEquals(response.status_code, 200) self.assertEquals(response.status_code, 200)
self.assertEquals(str(response.data).count(field), 0) self.assertEquals(str(response.data).count(field), 0)
...@@ -119,19 +119,33 @@ class APIListView(generics.ListAPIView): ...@@ -119,19 +119,33 @@ class APIListView(generics.ListAPIView):
GET /api/v0/some_endpoint/ GET /api/v0/some_endpoint/
Returns full list of serialized models with all default fields. Returns full list of serialized models with all default fields.
GET /api/v0/some_endpoint/?ids={id},{id} GET /api/v0/some_endpoint/?ids={id_1},{id_2}
Returns list of serialized models with IDs that match an ID in the given Returns list of serialized models with IDs that match an ID in the given
`ids` query parameter with all default fields. `ids` query parameter with all default fields.
GET /api/v0/some_endpoint/?ids={id},{id}&fields={some_field},{some_field} GET /api/v0/some_endpoint/?ids={id_1},{id_2}&fields={some_field_1},{some_field_2}
Returns list of serialized models with IDs that match an ID in the given 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. `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} GET /api/v0/some_endpoint/?ids={id_1},{id_2}&exclude={some_field_1},{some_field_2}
Returns list of serialized models with IDs that match an ID in the given 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 `ids` query parameter with all fields except those in the given `exclude` query
parameter. parameter.
POST /api/v0/some_endpoint/
{
"ids": [
"{id_1}",
"{id_2}",
...
"{id_200}"
],
"fields": [
"{some_field_1}",
"{some_field_2}"
]
}
**Response Values** **Response Values**
Since this is an abstract class, this view just returns an empty list. Since this is an abstract class, this view just returns an empty list.
...@@ -142,6 +156,9 @@ class APIListView(generics.ListAPIView): ...@@ -142,6 +156,9 @@ class APIListView(generics.ListAPIView):
explicitly specifying the fields to include in each result with `fields` as well of explicitly specifying the fields to include in each result with `fields` as well of
the fields to exclude with `exclude`. the fields to exclude with `exclude`.
For GET requests, these parameters are passed in the query string.
For POST requests, these parameters are passed as a JSON dict in the request body.
ids -- The comma-separated list of identifiers for which results are filtered to. 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 For example, 'edX/DemoX/Demo_Course,course-v1:edX+DemoX+Demo_2016'. Default is to
return all courses. return all courses.
...@@ -149,6 +166,12 @@ class APIListView(generics.ListAPIView): ...@@ -149,6 +166,12 @@ class APIListView(generics.ListAPIView):
For example, 'course_id,created'. 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. exclude -- The comma-separated fields to exclude in the response.
For example, 'course_id,created'. Default is to not exclude any fields. For example, 'course_id,created'. Default is to not exclude any fields.
**Notes**
* GET is usable when the number of IDs is relatively low
* POST is required when the number of course IDs would cause the URL to be too long.
* POST functions the same as GET here. It does not modify any state.
""" """
ids = None ids = None
fields = None fields = None
...@@ -175,6 +198,19 @@ class APIListView(generics.ListAPIView): ...@@ -175,6 +198,19 @@ class APIListView(generics.ListAPIView):
return super(APIListView, self).get(request, *args, **kwargs) return super(APIListView, self).get(request, *args, **kwargs)
def post(self, request, *args, **kwargs):
# self.request.data is a QueryDict. For keys with singleton lists as values,
# QueryDicts return the singleton element of the list instead of the list itself,
# which is undesirable. So, we convert to a normal dict.
request_data_dict = dict(request.data)
self.fields = request_data_dict.get('fields')
exclude = request_data_dict.get('exclude')
self.exclude = self.always_exclude + (exclude if exclude else [])
self.ids = request_data_dict.get(self.ids_param)
self.verify_ids()
return super(APIListView, self).get(request, *args, **kwargs)
def verify_ids(self): def verify_ids(self):
""" """
Optionally raise an exception if any of the IDs set as self.ids are invalid. Optionally raise an exception if any of the IDs set as self.ids are invalid.
......
...@@ -13,9 +13,19 @@ class CourseSummariesView(APIListView): ...@@ -13,9 +13,19 @@ class CourseSummariesView(APIListView):
""" """
Returns summary information for courses. Returns summary information for courses.
**Example Request** **Example Requests**
GET /api/v0/course_summaries/?course_ids={course_id},{course_id} GET /api/v0/course_summaries/?course_ids={course_id_1},{course_id_2}
POST /api/v0/course_summaries/
{
"course_ids": [
"{course_id_1}",
"{course_id_2}",
...
"{course_id_200}"
]
}
**Response Values** **Response Values**
...@@ -39,6 +49,9 @@ class CourseSummariesView(APIListView): ...@@ -39,6 +49,9 @@ class CourseSummariesView(APIListView):
Results can be filed to the course IDs specified or limited to the fields. Results can be filed to the course IDs specified or limited to the fields.
For GET requests, these parameters are passed in the query string.
For POST requests, these parameters are passed as a JSON dict in the request body.
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 all courses. return all courses.
...@@ -48,6 +61,12 @@ class CourseSummariesView(APIListView): ...@@ -48,6 +61,12 @@ class CourseSummariesView(APIListView):
For example, 'course_id,created'. Default is to exclude the programs array. 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 programs -- If included in the query parameters, will find each courses' program IDs
and include them in the response. and include them in the response.
**Notes**
* GET is usable when the number of course IDs is relatively low
* POST is required when the number of course IDs would cause the URL to be too long.
* POST functions the same as GET for this endpoint. It does not modify any state.
""" """
serializer_class = serializers.CourseMetaSummaryEnrollmentSerializer serializer_class = serializers.CourseMetaSummaryEnrollmentSerializer
programs_serializer_class = serializers.CourseProgramMetadataSerializer programs_serializer_class = serializers.CourseProgramMetadataSerializer
...@@ -68,6 +87,17 @@ class CourseSummariesView(APIListView): ...@@ -68,6 +87,17 @@ class CourseSummariesView(APIListView):
response = super(CourseSummariesView, self).get(request, *args, **kwargs) response = super(CourseSummariesView, self).get(request, *args, **kwargs)
return response return response
def post(self, request, *args, **kwargs):
# self.request.data is a QueryDict. For keys with singleton lists as values,
# QueryDicts return the singleton element of the list instead of the list itself,
# which is undesirable. So, we convert to a normal dict.
request_data_dict = dict(self.request.data)
programs = request_data_dict.get('programs')
if not programs:
self.always_exclude = self.always_exclude + ['programs']
response = super(CourseSummariesView, self).post(request, *args, **kwargs)
return response
def verify_ids(self): def verify_ids(self):
""" """
Raise an exception if any of the course IDs set as self.ids are invalid. Raise an exception if any of the course IDs set as self.ids are invalid.
......
...@@ -27,7 +27,15 @@ class TestCaseWithAuthentication(TestCase): ...@@ -27,7 +27,15 @@ class TestCaseWithAuthentication(TestCase):
def authenticated_get(self, path, data=None, follow=True, **extra): def authenticated_get(self, path, data=None, follow=True, **extra):
data = data or {} data = data or {}
return self.client.get(path, data, follow, HTTP_AUTHORIZATION='Token ' + self.token.key, **extra) return self.client.get(
path=path, data=data, follow=follow, HTTP_AUTHORIZATION='Token ' + self.token.key, **extra
)
def authenticated_post(self, path, data=None, follow=True, **extra):
data = data or {}
return self.client.post(
path=path, data=data, follow=follow, HTTP_AUTHORIZATION='Token ' + self.token.key, **extra
)
@contextmanager @contextmanager
......
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