Commit 13332b6d by Aamir

Merge pull request #6474 from edx/awais786/course_info_api

Awais786/course info api
parents ed812f1c 1e7d567b
......@@ -185,3 +185,4 @@ Jim Zheng <jimzheng@stanford.edu>
Afzal Wali <afzaledx@edx.org>
Julien Romagnoli <julien.romagnoli@fbmx.net>
Wenjie Wu <wuwenjie718@gmail.com>
Aamir <aamir.nu.206@gmail.com>
......@@ -301,3 +301,7 @@ DEPRECATED_ADVANCED_COMPONENT_TYPES = ENV_TOKENS.get(
################ VIDEO UPLOAD PIPELINE ###############
VIDEO_UPLOAD_PIPELINE = ENV_TOKENS.get('VIDEO_UPLOAD_PIPELINE', VIDEO_UPLOAD_PIPELINE)
#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)
......@@ -792,3 +792,5 @@ ADVANCED_PROBLEM_TYPES = [
'boilerplate_name': None,
}
]
#date format the api will be formatting the datetime values
API_DATE_FORMAT = '%Y-%m-%d'
"""
API for retrieving Course metadata.
This API is not intended for exposing course content, but allowing general access to descriptive course
details.
"""
"""
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 course_about import errors
DEFAULT_DATA_API = 'course_about.data'
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.
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"
},
}
"""
return _data_api().get_course_about_details(course_id)
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'
]
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 = {}
for attribute in ABOUT_ATTRIBUTES:
about_descriptor[attribute] = _fetch_course_detail(course_key, attribute)
return serialize_content(course_descriptor=course_descriptor, about_descriptor=about_descriptor)
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 util.parsing_utils import course_image_url
from django.conf import settings
def serialize_content(course_descriptor, about_descriptor):
"""Serialize the course descriptor and 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
Returns:
Serializable dictionary of course information.
"""
date_format = getattr(settings, 'API_DATE_FORMAT', '%Y-%m-%d')
data = dict({"media": {}})
data['display_name'] = getattr(course_descriptor, 'display_name', None)
start = getattr(course_descriptor, 'start', None)
end = getattr(course_descriptor, 'end', None)
announcement = getattr(course_descriptor, 'announcement', None)
data['start'] = start.strftime(date_format) if start else None
data['end'] = end.strftime(date_format) if end else None
data["announcement"] = announcement.strftime(date_format) if announcement else None
data['advertised_start'] = getattr(course_descriptor, 'advertised_start', None)
data['is_new'] = getattr(course_descriptor, 'is_new', None)
image_url = ''
if hasattr(course_descriptor, 'course_image') and course_descriptor.course_image:
image_url = course_image_url(course_descriptor)
data['course_number'] = course_descriptor.location.course
data['course_id'] = unicode(course_descriptor.id)
data['media']['course_image'] = image_url
# Following code is getting the course about descriptor information
course_about_data = _course_about_serialize_content(about_descriptor)
data.update(course_about_data)
return data
def _course_about_serialize_content(about_descriptor):
"""Serialize the course about descriptor
Returns a serialized representation of the about_descriptor
Args:
course_descriptor(dict) : dictionary of course descriptor object
Returns:
Serialize data for about descriptor.
"""
data = dict()
data["effort"] = about_descriptor.get("effort", None)
return data
"""
Packages all tests relative to the Course About API.
"""
"""
Tests specific to the Data Aggregation Layer of the Course About API.
"""
import unittest
from django.test.utils import override_settings
from django.conf import settings
from xmodule.modulestore.tests.django_utils import (
ModuleStoreTestCase, mixed_store_config
)
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 nose.tools import raises
from xmodule.modulestore.django import modulestore
from datetime import datetime
# Since we don't need any XML course fixtures, use a modulestore configuration
# that disables the XML modulestore.
MODULESTORE_CONFIG = mixed_store_config(settings.COMMON_TEST_DATA_ROOT, {}, include_xml=False)
@override_settings(MODULESTORE=MODULESTORE_CONFIG)
@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 xmodule.modulestore.tests.django_utils import (
ModuleStoreTestCase, mixed_store_config
)
from xmodule.modulestore.tests.factories import CourseFactory, CourseAboutFactory
from student.tests.factories import UserFactory
from util.parsing_utils import course_image_url
from course_about import api
from course_about.errors import CourseNotFoundError, CourseAboutError
from mock import patch
from xmodule.modulestore.django import modulestore
from datetime import datetime
# Since we don't need any XML course fixtures, use a modulestore configuration
# that disables the XML modulestore.
MODULESTORE_CONFIG = mixed_store_config(settings.COMMON_TEST_DATA_ROOT, {}, include_xml=False)
@ddt.ddt
@override_settings(MODULESTORE=MODULESTORE_CONFIG)
@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 = self.client.get(
reverse('courseabout', kwargs={"course_id": unicode(self.course.id)})
)
resp_data = json.loads(resp.content)
self.assertEqual(resp.status_code, status.HTTP_200_OK)
self.assertIsNotNone(resp_data)
def test_with_valid_course_id(self):
resp = self.client.get(
reverse('courseabout', kwargs={"course_id": unicode(self.course.id)})
)
self.assertEqual(resp.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 = self.client.get(
reverse('courseabout', kwargs={"course_id": unicode(self.course.id)})
)
resp_data = json.loads(resp.content)
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))
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 = self.client.get(
reverse('courseabout', kwargs={"course_id": unicode(self.course.id)})
)
course_info = json.loads(resp.content)
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)
resp = self.client.get(
reverse('courseabout', kwargs={"course_id": unicode(self.course.id)})
)
course_info = json.loads(resp.content)
self.assertIsNone(course_info["start"])
self.assertIsNone(course_info["end"])
self.assertIsNone(course_info["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 = 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.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 = self.client.get(
reverse('courseabout', kwargs={"course_id": unicode(self.course.id)})
)
self.assertEqual(resp.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 = self.client.get(
reverse('courseabout', kwargs={"course_id": unicode(self.course.id)})
)
self.assertEqual(resp.status_code, status.HTTP_404_NOT_FOUND)
@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 = self.client.get(
reverse('courseabout', kwargs={"course_id": unicode(self.course.id)})
)
self.assertEqual(resp.status_code, status.HTTP_500_INTERNAL_SERVER_ERROR)
@override_settings(COURSE_ABOUT_DATA_API='foo')
def test_data_api_config_error(self):
# Enroll in the course and verify the URL we get sent to
resp = self.client.get(
reverse('courseabout', kwargs={"course_id": unicode(self.course.id)})
)
self.assertEqual(resp.status_code, status.HTTP_500_INTERNAL_SERVER_ERROR)
"""
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)
}
)
"""
Utility function for some parsing stuff
"""
from xmodule.contentstore.content import StaticContent
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
......@@ -15,6 +15,7 @@ from mock import Mock, patch
from nose.tools import assert_less_equal, assert_greater_equal
import factory
import threading
from xmodule.modulestore.django import modulestore
class Dummy(object):
......@@ -34,6 +35,7 @@ class XModuleFactory(Factory):
@lazy_attribute
def modulestore(self):
from xmodule.modulestore.django import modulestore
return modulestore()
......@@ -332,19 +334,55 @@ def check_mongo_calls(num_finds=0, num_sends=None):
the given int value.
"""
with check_sum_of_calls(
pymongo.message,
['query', 'get_more'],
num_finds,
num_finds
pymongo.message,
['query', 'get_more'],
num_finds,
num_finds
):
if num_sends is not None:
with check_sum_of_calls(
pymongo.message,
# mongo < 2.6 uses insert, update, delete and _do_batched_insert. >= 2.6 _do_batched_write
['insert', 'update', 'delete', '_do_batched_write_command', '_do_batched_insert', ],
num_sends,
num_sends
pymongo.message,
# mongo < 2.6 uses insert, update, delete and _do_batched_insert. >= 2.6 _do_batched_write
['insert', 'update', 'delete', '_do_batched_write_command', '_do_batched_insert', ],
num_sends,
num_sends
):
yield
else:
yield
# This dict represents the attribute keys for a course's 'about' info.
# Note: The 'video' attribute is intentionally excluded as it must be
# handled separately; its value maps to an alternate key name.
# Reference : cms/djangoapps/models/settings/course_details.py
ABOUT_ATTRIBUTES = {
'effort': "Testing effort",
}
class CourseAboutFactory(XModuleFactory):
"""
Factory for XModule course about.
"""
@classmethod
def _create(cls, target_class, **kwargs): # pylint: disable=unused-argument
"""
Uses **kwargs:
effort: effor information
video : video link
"""
user_id = kwargs.pop('user_id', None)
course_id, course_runtime = kwargs.pop("course_id"), kwargs.pop("course_runtime")
store = modulestore()
for about_key in ABOUT_ATTRIBUTES:
about_item = store.create_xblock(course_runtime, course_id, 'about', about_key)
about_item.data = ABOUT_ATTRIBUTES[about_key]
store.update_item(about_item, user_id, allow_not_found=True)
about_item = store.create_xblock(course_runtime, course_id, 'about', 'video')
about_item.data = "www.youtube.com/embed/testing-video-link"
store.update_item(about_item, user_id, allow_not_found=True)
......@@ -471,3 +471,7 @@ REGISTRATION_CODE_LENGTH = ENV_TOKENS.get('REGISTRATION_CODE_LENGTH', 8)
# REGISTRATION CODES DISPLAY INFORMATION
INVOICE_CORP_ADDRESS = ENV_TOKENS.get('INVOICE_CORP_ADDRESS', INVOICE_CORP_ADDRESS)
INVOICE_PAYMENT_INSTRUCTIONS = ENV_TOKENS.get('INVOICE_PAYMENT_INSTRUCTIONS', INVOICE_PAYMENT_INSTRUCTIONS)
#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)
......@@ -1943,3 +1943,6 @@ COURSE_CATALOG_VISIBILITY_PERMISSION = 'see_exists'
# which access.py permission name to check in order to determine if a course about page is
# 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'
......@@ -76,6 +76,9 @@ urlpatterns = ('', # nopep8
# 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')),
)
if settings.FEATURES["ENABLE_MOBILE_REST_API"]:
......
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