Commit e5e4c01c by Clinton Blackburn

Catalog API

Added API to create and modify dynamic course catalogs.

ECOM-2922 and ECOM-2887
parent d81c3542
# Models that can be shared across multiple versions of the API
# should be created here. As the API evolves, models may become more
# specific to a particular version of the API. In this case, the models
# in question should be moved to versioned sub-package.
# Serializers that can be shared across multiple versions of the API
# should be created here. As the API evolves, serializers may become more
# specific to a particular version of the API. In this case, the serializers
# in question should be moved to versioned sub-package.
from django.utils.translation import ugettext_lazy as _
from rest_framework import serializers
from course_discovery.apps.catalogs.models import Catalog
class CatalogSerializer(serializers.ModelSerializer):
class Meta(object):
model = Catalog
fields = ('id', 'name', 'query',)
class CourseSerializer(serializers.Serializer):
id = serializers.CharField(help_text=_('Course ID'))
name = serializers.CharField(help_text=_('Course name'))
class ContainedCoursesSerializer(serializers.Serializer):
courses = serializers.DictField(child=serializers.BooleanField(),
help_text=_('Dictionary mapping course IDs to boolean values'))
from django.test import TestCase
from course_discovery.apps.api.serializers import CatalogSerializer, CourseSerializer, ContainedCoursesSerializer
from course_discovery.apps.catalogs.tests.factories import CatalogFactory
class CatalogSerializerTests(TestCase):
def test_data(self):
catalog = CatalogFactory()
serializer = CatalogSerializer(catalog)
expected = {
'id': catalog.id,
'name': catalog.name,
'query': catalog.query,
}
self.assertDictEqual(serializer.data, expected)
class CourseSerializerTests(TestCase):
def test_data(self):
course = {
'id': 'course-v1:edX+DemoX+Demo_Course',
'name': 'edX Demo Course',
}
serializer = CourseSerializer(course)
self.assertDictEqual(serializer.data, course)
class ContainedCoursesSerializerTests(TestCase):
def test_data(self):
instance = {
'courses': {
'course-v1:edX+DemoX+Demo_Course': True,
'a/b/c': False
}
}
serializer = ContainedCoursesSerializer(instance)
self.assertDictEqual(serializer.data, instance)
import json
import ddt
from django.test import TestCase
from django.utils.encoding import force_text
from rest_framework.reverse import reverse
from course_discovery.apps.api.serializers import CatalogSerializer
from course_discovery.apps.catalogs.models import Catalog
from course_discovery.apps.catalogs.tests.factories import CatalogFactory
from course_discovery.apps.core.tests.factories import UserFactory, USER_PASSWORD
JSON = 'application/json'
@ddt.ddt
class CatalogViewSetTests(TestCase):
""" Tests for the catalog resource.
Read-only (GET) endpoints should NOT require authentication.
"""
def setUp(self):
super(CatalogViewSetTests, self).setUp()
self.user = UserFactory(is_staff=True, is_superuser=True)
self.client.login(username=self.user.username, password=USER_PASSWORD)
self.catalog = CatalogFactory()
def test_session_auth(self):
# TODO Setup auth
# TODO assert_create()
# TODO assert_update()
# TODO assert_update()
pass
def test_create_without_authentication(self):
""" Verify authentication is required when creating, updating, or deleting a catalog. """
self.client.logout()
Catalog.objects.all().delete()
response = self.client.post(reverse('api:v1:catalog-list'), data='{}', content_type=JSON)
self.assertEqual(response.status_code, 403)
self.assertEqual(Catalog.objects.count(), 0)
@ddt.data('put', 'patch', 'delete')
def test_modify_without_authentication(self, http_method):
""" Verify authentication is required to modify a catalog. """
self.client.logout()
url = reverse('api:v1:catalog-detail', kwargs={'id': self.catalog.id})
response = getattr(self.client, http_method)(url, data='{}', content_type=JSON)
self.assertEqual(response.status_code, 403)
def test_create(self):
""" Verify the endpoint creates a new catalog. """
name = 'The Kitchen Sink'
query = '*.*'
data = {
'name': name,
'query': query
}
response = self.client.post(reverse('api:v1:catalog-list'), data=json.dumps(data), content_type=JSON)
self.assertEqual(response.status_code, 201)
catalog = Catalog.objects.latest()
self.assertDictEqual(response.data, CatalogSerializer(catalog).data)
self.assertEqual(catalog.name, name)
self.assertEqual(catalog.query, query)
def test_courses(self):
""" Verify the endpoint returns the list of courses contained in the catalog. """
# TODO Use actual filtering!
url = reverse('api:v1:catalog-courses', kwargs={'id': self.catalog.id})
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertListEqual(json.loads(force_text(response.content))['results'], [])
def test_contains(self):
""" Verify the endpoint returns a filtered list of courses contained in the catalog. """
# TODO Use actual filtering!
url = reverse('api:v1:catalog-contains', kwargs={'id': self.catalog.id}) + '?course_id=a,b,c'
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data, {'courses': {}})
def test_get(self):
""" Verify the endpoint returns the details for a single catalog. """
url = reverse('api:v1:catalog-detail', kwargs={'id': self.catalog.id})
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data, CatalogSerializer(self.catalog).data)
def test_list(self):
""" Verify the endpoint returns a list of all catalogs. """
url = reverse('api:v1:catalog-list')
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertListEqual(response.data['results'], CatalogSerializer(Catalog.objects.all(), many=True).data)
def test_destroy(self):
""" Verify the endpoint deletes a catalog. """
url = reverse('api:v1:catalog-detail', kwargs={'id': self.catalog.id})
response = self.client.delete(url)
self.assertEqual(response.status_code, 204)
self.assertFalse(Catalog.objects.filter(id=self.catalog.id).exists())
def test_update(self):
""" Verify the endpoint updates a catalog. """
url = reverse('api:v1:catalog-detail', kwargs={'id': self.catalog.id})
name = 'Updated Catalog'
query = 'so-not-real'
data = {
'name': name,
'query': query
}
response = self.client.put(url, data=json.dumps(data), content_type=JSON)
self.assertEqual(response.status_code, 200)
catalog = Catalog.objects.get(id=self.catalog.id)
self.assertEqual(catalog.name, name)
self.assertEqual(catalog.query, query)
def test_partial_update(self):
""" Verify the endpoint supports partially updating a catlaog's fields. """
url = reverse('api:v1:catalog-detail', kwargs={'id': self.catalog.id})
name = 'Updated Catalog'
query = self.catalog.query
data = {
'name': name
}
response = self.client.patch(url, data=json.dumps(data), content_type=JSON)
self.assertEqual(response.status_code, 200)
catalog = Catalog.objects.get(id=self.catalog.id)
self.assertEqual(catalog.name, name)
self.assertEqual(catalog.query, query)
""" API v1 URLs. """
from rest_framework import routers
from course_discovery.apps.api.v1 import views
urlpatterns = []
router = routers.SimpleRouter()
router.register(r'catalogs', views.CatalogViewSet)
urlpatterns += router.urls
# Create your views here.
import logging
from rest_framework import viewsets
from rest_framework.authentication import SessionAuthentication
from rest_framework.decorators import detail_route
from rest_framework.permissions import DjangoModelPermissionsOrAnonReadOnly
from rest_framework.response import Response
from course_discovery.apps.api.serializers import CatalogSerializer, CourseSerializer, ContainedCoursesSerializer
from course_discovery.apps.catalogs.models import Catalog
logger = logging.getLogger(__name__)
class CatalogViewSet(viewsets.ModelViewSet):
""" Catalog resource. """
# TODO Add support for JWT
authentication_classes = (SessionAuthentication,)
permission_classes = (DjangoModelPermissionsOrAnonReadOnly,)
lookup_field = 'id'
queryset = Catalog.objects.all()
serializer_class = CatalogSerializer
def create(self, request, *args, **kwargs):
""" Create a new catalog. """
return super(CatalogViewSet, self).create(request, *args, **kwargs)
def destroy(self, request, *args, **kwargs):
""" Destroy a catalog. """
return super(CatalogViewSet, self).destroy(request, *args, **kwargs)
def list(self, request, *args, **kwargs):
""" Retrieve a list of all catalogs. """
return super(CatalogViewSet, self).list(request, *args, **kwargs)
def partial_update(self, request, *args, **kwargs):
""" Update one, or more, fields for a catalog. """
return super(CatalogViewSet, self).partial_update(request, *args, **kwargs)
def retrieve(self, request, *args, **kwargs):
""" Retrieve details for a catalog. """
return super(CatalogViewSet, self).retrieve(request, *args, **kwargs)
def update(self, request, *args, **kwargs):
""" Update a catalog. """
return super(CatalogViewSet, self).update(request, *args, **kwargs)
@detail_route()
def courses(self, request, id=None):
"""
Retrieve the list of courses contained within this catalog.
---
serializer: CourseSerializer
"""
catalog = self.get_object()
queryset = catalog.courses()
page = self.paginate_queryset(queryset)
serializer = CourseSerializer(page, many=True)
return self.get_paginated_response(serializer.data)
@detail_route()
def contains(self, request, id=None):
"""
Determine if this catalog contains the provided courses.
A dictionary mapping course IDs to booleans, indicating course presence, will be returned.
---
serializer: ContainedCoursesSerializer
parameters:
- name: course_id
description: Course IDs to check for existence in the Catalog.
required: true
type: string
paramType: query
multiple: true
"""
course_ids = request.query_params.get('course_id')
course_ids = course_ids.split(',')
catalog = self.get_object()
courses = catalog.contains(course_ids)
instance = {'courses': courses}
serializer = ContainedCoursesSerializer(instance)
return Response(serializer.data)
from django.contrib import admin
from course_discovery.apps.catalogs.models import Catalog
@admin.register(Catalog)
class CatalogAdmin(admin.ModelAdmin):
list_display = ('name',)
readonly_fields = ('created', 'modified',)
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
import django_extensions.db.fields
class Migration(migrations.Migration):
dependencies = [
]
operations = [
migrations.CreateModel(
name='Catalog',
fields=[
('id', models.AutoField(auto_created=True, serialize=False, primary_key=True, verbose_name='ID')),
('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')),
('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')),
('name', models.CharField(max_length=255, help_text='Catalog name')),
('query', models.TextField(help_text='Query to retrieve catalog contents')),
],
options={
'ordering': ('-modified', '-created'),
'abstract': False,
'get_latest_by': 'modified',
},
),
]
from django.db import models
from django.utils.translation import ugettext_lazy as _
from django_extensions.db.models import TimeStampedModel
class Catalog(TimeStampedModel):
name = models.CharField(max_length=255, null=False, blank=False, help_text=_('Catalog name'))
query = models.TextField(null=False, blank=False, help_text=_('Query to retrieve catalog contents'))
def __str__(self):
return 'Catalog #{id}: {name}'.format(id=self.id, name=self.name)
def courses(self):
""" Returns the list of courses contained within this catalog.
Returns:
List of courses contained in this catalog.
"""
return []
def contains(self, course_ids):
""" Determines if the given courses are contained in this catalog.
Arguments:
course_ids (str[]): List of course IDs
Returns:
dict: Mapping of course IDs to booleans indicating if course is
contained in this catalog.
"""
return {}
import factory
from factory.fuzzy import FuzzyText
from course_discovery.apps.catalogs.models import Catalog
class CatalogFactory(factory.DjangoModelFactory):
class Meta(object):
model = Catalog
name = FuzzyText(prefix='catalog-name-')
query = FuzzyText(prefix='catalog-query-')
from django.test import TestCase
from course_discovery.apps.catalogs.tests import factories
class CatalogTests(TestCase):
""" Catalog model tests. """
def setUp(self):
super(CatalogTests, self).setUp()
self.catalog = factories.CatalogFactory()
def test_unicode(self):
""" Validate the output of the __unicode__ method. """
name = 'test'
self.catalog.name = name
self.catalog.save()
expected = 'Catalog #{id}: {name}'.format(id=self.catalog.id, name=name)
self.assertEqual(str(self.catalog), expected)
def test_courses(self):
""" Verify the method returns a list of courses contained in the catalog. """
# TODO Setup/mock Elasticsearch
# TODO Set catalog query
# TODO Validate value of catalog.courses()
self.assertListEqual(self.catalog.courses(), [])
def test_contains(self):
""" Verify the method returns a mapping of course IDs to booleans. """
# TODO Setup/mock Elasticsearch
# TODO Set catalog query
# TODO Validate value of catalog.contains()
self.assertDictEqual(self.catalog.contains([]), {})
import factory
from course_discovery.apps.core.models import User
USER_PASSWORD = 'password'
class UserFactory(factory.DjangoModelFactory):
password = factory.PostGenerationMethodCall('set_password', USER_PASSWORD)
is_active = True
is_superuser = False
is_staff = False
class Meta:
model = User
......@@ -36,6 +36,7 @@ THIRD_PARTY_APPS = (
PROJECT_APPS = (
'course_discovery.apps.core',
'course_discovery.apps.api',
'course_discovery.apps.catalogs',
)
INSTALLED_APPS += THIRD_PARTY_APPS
......@@ -230,6 +231,7 @@ LOGGING = {
REST_FRAMEWORK = {
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.LimitOffsetPagination',
'PAGE_SIZE': 20,
'VIEW_DESCRIPTION_FUNCTION': 'rest_framework_swagger.views.get_restructuredtext'
}
......
......@@ -2,9 +2,11 @@
-r base.txt
coverage == 4.0.2
ddt==1.0.1
django-dynamic-fixture == 1.8.5
django-nose == 1.4.2
edx-lint == 0.3.2
factory-boy==2.6.0
mock == 1.3.0
nose-ignore-docstring == 0.2
pep8 == 1.6.2
......
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