Commit 2175b5ba by Will Daly

Merge pull request #8509 from edx/will/deprecate-course-about-v0

Deprecate course details API v0
parents 98213239 c31a5106
......@@ -332,10 +332,6 @@ VIDEO_UPLOAD_PIPELINE = ENV_TOKENS.get('VIDEO_UPLOAD_PIPELINE', VIDEO_UPLOAD_PIP
PARSE_KEYS = AUTH_TOKENS.get("PARSE_KEYS", {})
#date format the api will be formatting the datetime values
API_DATE_FORMAT = '%Y-%m-%d'
API_DATE_FORMAT = ENV_TOKENS.get('API_DATE_FORMAT', API_DATE_FORMAT)
# Video Caching. Pairing country codes with CDN URLs.
# Example: {'CN': 'http://api.xuetangx.com/edx/video?s3_url='}
VIDEO_CDN_URL = ENV_TOKENS.get('VIDEO_CDN_URL', {})
......
......@@ -951,8 +951,6 @@ ADVANCED_PROBLEM_TYPES = [
}
]
#date format the api will be formatting the datetime values
API_DATE_FORMAT = '%Y-%m-%d'
# Files and Uploads type filter values
......
"""
The Python API layer of the Course About API. Essentially the middle tier of the project, responsible for all
business logic that is not directly tied to the data itself.
Data access is managed through the configured data module, or defaults to the project's data.py module.
This API is exposed via the RESTful layer (views.py) but may be used directly in-process.
"""
import logging
from django.conf import settings
from django.utils import importlib
from django.core.cache import cache
from course_about import errors
DEFAULT_DATA_API = 'course_about.data'
COURSE_ABOUT_API_CACHE_PREFIX = 'course_about_api_'
log = logging.getLogger(__name__)
def get_course_about_details(course_id):
"""Get course about details for the given course ID.
Given a Course ID, retrieve all the metadata necessary to fully describe the Course.
First its checks the default cache for given course id if its exists then returns
the course otherwise it get the course from module store and set the cache.
By default cache expiry set to 5 minutes.
Args:
course_id (str): The String representation of a Course ID. Used to look up the requested
course.
Returns:
A JSON serializable dictionary of metadata describing the course.
Example:
>>> get_course_about_details('edX/Demo/2014T2')
{
"advertised_start": "FALL",
"announcement": "YYYY-MM-DD",
"course_id": "edx/DemoCourse",
"course_number": "DEMO101",
"start": "YYYY-MM-DD",
"end": "YYYY-MM-DD",
"effort": "HH:MM",
"display_name": "Demo Course",
"is_new": true,
"media": {
"course_image": "/some/image/location.png"
},
}
"""
cache_key = "{}_{}".format(course_id, COURSE_ABOUT_API_CACHE_PREFIX)
cache_course_info = cache.get(cache_key)
if cache_course_info:
return cache_course_info
course_info = _data_api().get_course_about_details(course_id)
time_out = getattr(settings, 'COURSE_INFO_API_CACHE_TIME_OUT', 300)
cache.set(cache_key, course_info, time_out)
return course_info
def _data_api():
"""Returns a Data API.
This relies on Django settings to find the appropriate data API.
We retrieve the settings in-line here (rather than using the
top-level constant), so that @override_settings will work
in the test suite.
"""
api_path = getattr(settings, "COURSE_ABOUT_DATA_API", DEFAULT_DATA_API)
try:
return importlib.import_module(api_path)
except (ImportError, ValueError):
log.exception(u"Could not load module at '{path}'".format(path=api_path))
raise errors.CourseAboutApiLoadError(api_path)
"""Data Aggregation Layer for the Course About API.
This is responsible for combining data from the following resources:
* CourseDescriptor
* CourseAboutDescriptor
"""
import logging
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey
from course_about.serializers import serialize_content
from course_about.errors import CourseNotFoundError
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import ItemNotFoundError
log = logging.getLogger(__name__)
ABOUT_ATTRIBUTES = [
'effort',
'overview',
'title',
'university',
'number',
'short_description',
'description',
'key_dates',
'video',
'course_staff_short',
'course_staff_extended',
'requirements',
'syllabus',
'textbook',
'faq',
'more_info',
'ocw_links',
]
def get_course_about_details(course_id): # pylint: disable=unused-argument
"""
Return course information for a given course id.
Args:
course_id(str) : The course id to retrieve course information for.
Returns:
Serializable dictionary of the Course About Information.
Raises:
CourseNotFoundError
"""
try:
course_key = CourseKey.from_string(course_id)
course_descriptor = modulestore().get_course(course_key)
if course_descriptor is None:
raise CourseNotFoundError("course not found")
except InvalidKeyError as err:
raise CourseNotFoundError(err.message)
about_descriptor = {
attribute: _fetch_course_detail(course_key, attribute)
for attribute in ABOUT_ATTRIBUTES
}
course_info = serialize_content(course_descriptor=course_descriptor, about_descriptor=about_descriptor)
return course_info
def _fetch_course_detail(course_key, attribute):
"""
Fetch the course about attribute for the given course's attribute from persistence and return its value.
"""
usage_key = course_key.make_usage_key('about', attribute)
try:
value = modulestore().get_item(usage_key).data
except ItemNotFoundError:
value = None
return value
"""
Contains all the errors associated with the Course About API.
"""
class CourseAboutError(Exception):
"""Generic Course About Error"""
def __init__(self, msg, data=None):
super(CourseAboutError, self).__init__(msg)
# Corresponding information to help resolve the error.
self.data = data
class CourseAboutApiLoadError(CourseAboutError):
"""The data API could not be loaded. """
pass
class CourseNotFoundError(CourseAboutError):
"""The Course Not Found. """
pass
"""
A models.py is required to make this an app (until we move to Django 1.7)
The Course About API is responsible for aggregating descriptive course information into a single response.
This should eventually hold some initial Marketing Meta Data objects that are platform-specific.
"""
"""
Serializers for all Course Descriptor and Course About Descriptor related return objects.
"""
from xmodule.contentstore.content import StaticContent
from django.conf import settings
DATE_FORMAT = getattr(settings, 'API_DATE_FORMAT', '%Y-%m-%d')
def serialize_content(course_descriptor, about_descriptor):
"""
Returns a serialized representation of the course_descriptor and about_descriptor
Args:
course_descriptor(CourseDescriptor) : course descriptor object
about_descriptor(dict) : Dictionary of CourseAboutDescriptor objects
return:
serialize data for course information.
"""
data = {
'media': {},
'display_name': getattr(course_descriptor, 'display_name', None),
'course_number': course_descriptor.location.course,
'course_id': None,
'advertised_start': getattr(course_descriptor, 'advertised_start', None),
'is_new': getattr(course_descriptor, 'is_new', None),
'start': _formatted_datetime(course_descriptor, 'start'),
'end': _formatted_datetime(course_descriptor, 'end'),
'announcement': None,
}
data.update(about_descriptor)
content_id = unicode(course_descriptor.id)
data["course_id"] = unicode(content_id)
if getattr(course_descriptor, 'course_image', False):
data['media']['course_image'] = course_image_url(course_descriptor)
announcement = getattr(course_descriptor, 'announcement', None)
data["announcement"] = announcement.strftime(DATE_FORMAT) if announcement else None
return data
def course_image_url(course):
"""
Return url of course image.
Args:
course(CourseDescriptor) : The course id to retrieve course image url.
Returns:
Absolute url of course image.
"""
loc = StaticContent.compute_location(course.id, course.course_image)
url = StaticContent.serialize_asset_key_with_slash(loc)
return url
def _formatted_datetime(course_descriptor, date_type):
"""
Return formatted date.
Args:
course_descriptor(CourseDescriptor) : The CourseDescriptor Object.
date_type (str) : Either start or end.
Returns:
formatted date or None .
"""
course_date_ = getattr(course_descriptor, date_type, None)
return course_date_.strftime(DATE_FORMAT) if course_date_ else None
"""
Tests the logical Python API layer of the Course About API.
"""
import ddt
import json
import unittest
from django.core.urlresolvers import reverse
from rest_framework.test import APITestCase
from rest_framework import status
from django.conf import settings
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, CourseAboutFactory
from student.tests.factories import UserFactory
@ddt.ddt
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
class CourseInfoTest(ModuleStoreTestCase, APITestCase):
"""
Test course information.
"""
USERNAME = "Bob"
EMAIL = "bob@example.com"
PASSWORD = "edx"
def setUp(self):
""" Create a course"""
super(CourseInfoTest, self).setUp()
self.course = CourseFactory.create()
self.user = UserFactory.create(username=self.USERNAME, email=self.EMAIL, password=self.PASSWORD)
self.client.login(username=self.USERNAME, password=self.PASSWORD)
def test_get_course_details_from_cache(self):
kwargs = dict()
kwargs["course_id"] = self.course.id
kwargs["course_runtime"] = self.course.runtime
kwargs["user_id"] = self.user.id
CourseAboutFactory.create(**kwargs)
resp = self.client.get(
reverse('courseabout', kwargs={"course_id": unicode(self.course.id)})
)
self.assertEqual(resp.status_code, status.HTTP_200_OK)
resp_data = json.loads(resp.content)
self.assertIsNotNone(resp_data)
resp = self.client.get(
reverse('courseabout', kwargs={"course_id": unicode(self.course.id)})
)
self.assertEqual(resp.status_code, status.HTTP_200_OK)
resp_data = json.loads(resp.content)
self.assertIsNotNone(resp_data)
"""
Tests specific to the Data Aggregation Layer of the Course About API.
"""
import unittest
from datetime import datetime
from django.conf import settings
from nose.tools import raises
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from student.tests.factories import UserFactory
from course_about import data
from course_about.errors import CourseNotFoundError
from xmodule.modulestore.django import modulestore
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
class CourseAboutDataTest(ModuleStoreTestCase):
"""
Test course enrollment data aggregation.
"""
USERNAME = "Bob"
EMAIL = "bob@example.com"
PASSWORD = "edx"
def setUp(self):
"""Create a course and user, then log in. """
super(CourseAboutDataTest, self).setUp()
self.course = CourseFactory.create()
self.user = UserFactory.create(username=self.USERNAME, email=self.EMAIL, password=self.PASSWORD)
self.client.login(username=self.USERNAME, password=self.PASSWORD)
def test_get_course_about_details(self):
course_info = data.get_course_about_details(unicode(self.course.id))
self.assertIsNotNone(course_info)
def test_get_course_about_valid_date(self):
module_store = modulestore()
self.course.start = datetime.now()
self.course.end = datetime.now()
self.course.announcement = datetime.now()
module_store.update_item(self.course, self.user.id)
course_info = data.get_course_about_details(unicode(self.course.id))
self.assertIsNotNone(course_info["start"])
self.assertIsNotNone(course_info["end"])
self.assertIsNotNone(course_info["announcement"])
def test_get_course_about_none_date(self):
module_store = modulestore()
self.course.start = None
self.course.end = None
self.course.announcement = None
module_store.update_item(self.course, self.user.id)
course_info = data.get_course_about_details(unicode(self.course.id))
self.assertIsNone(course_info["start"])
self.assertIsNone(course_info["end"])
self.assertIsNone(course_info["announcement"])
@raises(CourseNotFoundError)
def test_non_existent_course(self):
data.get_course_about_details("this/is/bananas")
@raises(CourseNotFoundError)
def test_invalid_key(self):
data.get_course_about_details("invalid:key:k")
"""
Tests for user enrollment.
"""
import ddt
import json
import unittest
from django.test.utils import override_settings
from django.core.urlresolvers import reverse
from rest_framework.test import APITestCase
from rest_framework import status
from django.conf import settings
from datetime import datetime
from mock import patch
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, CourseAboutFactory
from student.tests.factories import UserFactory
from course_about.serializers import course_image_url
from course_about import api
from course_about.errors import CourseNotFoundError, CourseAboutError
from xmodule.modulestore.django import modulestore
@ddt.ddt
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
class CourseInfoTest(ModuleStoreTestCase, APITestCase):
"""
Test course information.
"""
USERNAME = "Bob"
EMAIL = "bob@example.com"
PASSWORD = "edx"
def setUp(self):
""" Create a course"""
super(CourseInfoTest, self).setUp()
self.course = CourseFactory.create()
self.user = UserFactory.create(username=self.USERNAME, email=self.EMAIL, password=self.PASSWORD)
self.client.login(username=self.USERNAME, password=self.PASSWORD)
def test_user_not_authenticated(self):
# Log out, so we're no longer authenticated
self.client.logout()
resp_data, status_code = self._get_course_about(self.course.id)
self.assertEqual(status_code, status.HTTP_200_OK)
self.assertIsNotNone(resp_data)
def test_with_valid_course_id(self):
_resp_data, status_code = self._get_course_about(self.course.id)
self.assertEqual(status_code, status.HTTP_200_OK)
def test_with_invalid_course_id(self):
resp = self.client.get(
reverse('courseabout', kwargs={"course_id": 'not/a/validkey'})
)
self.assertEqual(resp.status_code, status.HTTP_404_NOT_FOUND)
def test_get_course_details_all_attributes(self):
kwargs = dict()
kwargs["course_id"] = self.course.id
kwargs["course_runtime"] = self.course.runtime
CourseAboutFactory.create(**kwargs)
resp_data, status_code = self._get_course_about(self.course.id)
all_attributes = ['display_name', 'start', 'end', 'announcement', 'advertised_start', 'is_new', 'course_number',
'course_id',
'effort', 'media', 'course_image']
for attr in all_attributes:
self.assertIn(attr, str(resp_data))
self.assertEqual(status_code, status.HTTP_200_OK)
def test_get_course_about_valid_date(self):
module_store = modulestore()
self.course.start = datetime.now()
self.course.end = datetime.now()
self.course.announcement = datetime.now()
module_store.update_item(self.course, self.user.id)
resp_data, _status_code = self._get_course_about(self.course.id)
self.assertIsNotNone(resp_data["start"])
self.assertIsNotNone(resp_data["end"])
self.assertIsNotNone(resp_data["announcement"])
def test_get_course_about_none_date(self):
module_store = modulestore()
self.course.start = None
self.course.end = None
self.course.announcement = None
module_store.update_item(self.course, self.user.id)
resp_data, _status_code = self._get_course_about(self.course.id)
self.assertIsNone(resp_data["start"])
self.assertIsNone(resp_data["end"])
self.assertIsNone(resp_data["announcement"])
def test_get_course_details(self):
kwargs = dict()
kwargs["course_id"] = self.course.id
kwargs["course_runtime"] = self.course.runtime
kwargs["user_id"] = self.user.id
CourseAboutFactory.create(**kwargs)
resp_data, status_code = self._get_course_about(self.course.id)
self.assertEqual(status_code, status.HTTP_200_OK)
self.assertEqual(unicode(self.course.id), resp_data['course_id'])
self.assertIn('Run', resp_data['display_name'])
url = course_image_url(self.course)
self.assertEquals(url, resp_data['media']['course_image'])
@patch.object(api, "get_course_about_details")
def test_get_enrollment_course_not_found_error(self, mock_get_course_about_details):
mock_get_course_about_details.side_effect = CourseNotFoundError("Something bad happened.")
_resp_data, status_code = self._get_course_about(self.course.id)
self.assertEqual(status_code, status.HTTP_404_NOT_FOUND)
@patch.object(api, "get_course_about_details")
def test_get_enrollment_invalid_key_error(self, mock_get_course_about_details):
mock_get_course_about_details.side_effect = CourseNotFoundError('a/a/a', "Something bad happened.")
resp_data, status_code = self._get_course_about(self.course.id)
self.assertEqual(status_code, status.HTTP_404_NOT_FOUND)
self.assertIn('An error occurred', resp_data["message"])
@patch.object(api, "get_course_about_details")
def test_get_enrollment_internal_error(self, mock_get_course_about_details):
mock_get_course_about_details.side_effect = CourseAboutError('error')
resp_data, status_code = self._get_course_about(self.course.id)
self.assertEqual(status_code, status.HTTP_500_INTERNAL_SERVER_ERROR)
self.assertIn('An error occurred', resp_data["message"])
@override_settings(COURSE_ABOUT_DATA_API='foo')
def test_data_api_config_error(self):
# Retrive the invalid course
resp_data, status_code = self._get_course_about(self.course.id)
self.assertEqual(status_code, status.HTTP_500_INTERNAL_SERVER_ERROR)
self.assertIn('An error occurred', resp_data["message"])
def _get_course_about(self, course_id):
"""
helper function to get retrieve course about information.
args course_id (str): course id
"""
resp = self.client.get(
reverse('courseabout', kwargs={"course_id": unicode(course_id)})
)
return json.loads(resp.content), resp.status_code
"""
URLs for exposing the RESTful HTTP endpoints for the Course About API.
"""
from django.conf import settings
from django.conf.urls import patterns, url
from course_about.views import CourseAboutView
urlpatterns = patterns(
'course_about.views',
url(
r'^{course_key}'.format(course_key=settings.COURSE_ID_PATTERN),
CourseAboutView.as_view(), name="courseabout"
),
)
"""
Implementation of the RESTful endpoints for the Course About API.
"""
from rest_framework.throttling import UserRateThrottle
from rest_framework.views import APIView
from course_about import api
from rest_framework import status
from rest_framework.response import Response
from course_about.errors import CourseNotFoundError, CourseAboutError
class CourseAboutThrottle(UserRateThrottle):
"""Limit the number of requests users can make to the Course About API."""
# TODO Limit based on expected throughput # pylint: disable=fixme
rate = '50/second'
class CourseAboutView(APIView):
""" RESTful Course About API view.
Used to retrieve JSON serialized Course About information.
"""
authentication_classes = []
permission_classes = []
throttle_classes = CourseAboutThrottle,
def get(self, request, course_id=None): # pylint: disable=unused-argument
"""Read course information.
HTTP Endpoint for course info api.
Args:
Course Id = URI element specifying the course location. Course information will be
returned for this particular course.
Return:
A JSON serialized representation of the course information
"""
try:
return Response(api.get_course_about_details(course_id))
except CourseNotFoundError:
return Response(
status=status.HTTP_404_NOT_FOUND,
data={
"message": (
u"An error occurred while retrieving course information"
u" for course '{course_id}' no course found"
).format(course_id=course_id)
}
)
except CourseAboutError:
return Response(
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
data={
"message": (
u"An error occurred while retrieving course information"
u" for course '{course_id}'"
).format(course_id=course_id)
}
)
......@@ -566,9 +566,6 @@ COURSE_ABOUT_VISIBILITY_PERMISSION = ENV_TOKENS.get(
COURSE_ABOUT_VISIBILITY_PERMISSION
)
#date format the api will be formatting the datetime values
API_DATE_FORMAT = '%Y-%m-%d'
API_DATE_FORMAT = ENV_TOKENS.get('API_DATE_FORMAT', API_DATE_FORMAT)
# Enrollment API Cache Timeout
ENROLLMENT_COURSE_DETAILS_CACHE_TIMEOUT = ENV_TOKENS.get('ENROLLMENT_COURSE_DETAILS_CACHE_TIMEOUT', 60)
......
......@@ -2360,8 +2360,6 @@ COURSE_CATALOG_VISIBILITY_PERMISSION = 'see_exists'
# visible. We default this to the legacy permission 'see_exists'.
COURSE_ABOUT_VISIBILITY_PERMISSION = 'see_exists'
#date format the api will be formatting the datetime values
API_DATE_FORMAT = '%Y-%m-%d'
# Enrollment API Cache Timeout
ENROLLMENT_COURSE_DETAILS_CACHE_TIMEOUT = 60
......
......@@ -75,9 +75,6 @@ urlpatterns = (
# Enrollment API RESTful endpoints
url(r'^api/enrollment/v1/', include('enrollment.urls')),
# CourseInfo API RESTful endpoints
url(r'^api/course/details/v0/', include('course_about.urls')),
# Courseware search endpoints
url(r'^search/', include('search.urls')),
......
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