Commit 29b6ccf5 by Michael Frey Committed by J. Cliff Dyer

Added new Course List API.

parent 1b834999
......@@ -187,17 +187,17 @@ class ToyCourseFactory(SampleCourseFactory):
"""
store = kwargs.get('modulestore')
user_id = kwargs.get('user_id', ModuleStoreEnum.UserID.test)
toy_course = super(ToyCourseFactory, cls)._create(
target_class,
block_info_tree=TOY_BLOCK_INFO_TREE,
textbooks=[["Textbook", "path/to/a/text_book"]],
wiki_slug="toy",
graded=True,
discussion_topics={"General": {"id": "i4x-edX-toy-course-2012_Fall"}},
graceperiod=datetime.timedelta(days=2, seconds=21599),
start=datetime.datetime(2015, 07, 17, 12, tzinfo=pytz.utc),
xml_attributes={"filename": ["course/2012_Fall.xml", "course/2012_Fall.xml"]},
pdf_textbooks=[
fields = {
'block_info_tree': TOY_BLOCK_INFO_TREE,
'textbooks': [["Textbook", "path/to/a/text_book"]],
'wiki_slug': "toy",
'graded': True,
'discussion_topics': {"General": {"id": "i4x-edX-toy-course-2012_Fall"}},
'graceperiod': datetime.timedelta(days=2, seconds=21599),
'start': datetime.datetime(2015, 07, 17, 12, tzinfo=pytz.utc),
'xml_attributes': {"filename": ["course/2012_Fall.xml", "course/2012_Fall.xml"]},
'pdf_textbooks': [
{
"tab_title": "Sample Multi Chapter Textbook",
"id": "MyTextbook",
......@@ -207,8 +207,13 @@ class ToyCourseFactory(SampleCourseFactory):
]
}
],
course_image="just_a_test.jpg",
**kwargs
'course_image': "just_a_test.jpg",
}
fields.update(kwargs)
toy_course = super(ToyCourseFactory, cls)._create(
target_class,
**fields
)
with store.bulk_operations(toy_course.id, emit_signals=False):
with store.branch_setting(ModuleStoreEnum.Branch.draft_preferred, toy_course.id):
......
"""
Course API
"""
from django.contrib.auth.models import User
from django.http import Http404
from rest_framework.exceptions import NotFound, PermissionDenied
from lms.djangoapps.courseware.courses import get_courses, get_course_with_access
from .permissions import can_view_courses_for_username
from .serializers import CourseSerializer
def get_effective_user(requesting_user, target_username):
"""
Get the user we want to view information on behalf of.
"""
if target_username == requesting_user.username:
return requesting_user
elif can_view_courses_for_username(requesting_user, target_username):
return User.objects.get(username=target_username)
else:
raise PermissionDenied()
def course_detail(request, username, course_key):
"""
Return a single course identified by `course_key`.
The course must be visible to the user identified by `username` and the
logged-in user should have permission to view courses available to that
user.
Arguments:
request (HTTPRequest):
Used to identify the logged-in user and to instantiate the course
module to retrieve the course about description
username (string):
The name of the user `requesting_user would like to be identified as.
course_key (CourseKey): Identifies the course of interest
Return value:
CourseSerializer object representing the requested course
"""
user = get_effective_user(request.user, username)
try:
course = get_course_with_access(user, 'see_exists', course_key)
except Http404:
raise NotFound()
return CourseSerializer(course, context={'request': request}).data
def list_courses(request, username):
"""
Return a list of available courses.
The courses returned are all be visible to the user identified by
`username` and the logged in user should have permission to view courses
available to that user.
Arguments:
request (HTTPRequest):
Used to identify the logged-in user and to instantiate the course
module to retrieve the course about description
username (string):
The name of the user the logged-in user would like to be
identified as
Return value:
A CourseSerializer object representing the collection of courses.
"""
user = get_effective_user(request.user, username)
courses = get_courses(user)
return CourseSerializer(courses, context={'request': request}, many=True).data
"""
Course API Authorization functions
"""
from student.roles import GlobalStaff
def can_view_courses_for_username(requesting_user, target_username):
"""
Determine whether `requesting_user` has permission to view courses available
to the user identified by `target_username`.
Arguments:
requesting_user (User): The user requesting permission to view another
target_username (string):
The name of the user `requesting_user` would like
to access.
Return value:
Boolean:
`True` if `requesting_user` is authorized to view courses as
`target_username`. Otherwise, `False`
Raises:
TypeError if target_username is empty or None.
"""
# AnonymousUser has no username, so we test for requesting_user's own
# username before prohibiting an empty target_username.
if requesting_user.username == target_username:
return True
elif not target_username:
raise TypeError("target_username must be specified")
else:
staff = GlobalStaff()
return staff.has_user(requesting_user)
"""
Course API Serializers. Representing course catalog data
"""
import urllib
from django.core.urlresolvers import reverse
from django.template import defaultfilters
from rest_framework import serializers
from lms.djangoapps.courseware.courses import course_image_url, get_course_about_section
from xmodule.course_module import DEFAULT_START_DATE
class _MediaSerializer(serializers.Serializer): # pylint: disable=abstract-method
"""
Nested serializer to represent a media object.
"""
def __init__(self, uri_parser, *args, **kwargs):
super(_MediaSerializer, self).__init__(*args, **kwargs)
self.uri_parser = uri_parser
uri = serializers.SerializerMethodField(source='*')
def get_uri(self, course):
"""
Get the representation for the media resource's URI
"""
return self.uri_parser(course)
class _CourseApiMediaCollectionSerializer(serializers.Serializer): # pylint: disable=abstract-method
"""
Nested serializer to represent a collection of media objects
"""
course_image = _MediaSerializer(source='*', uri_parser=course_image_url)
class CourseSerializer(serializers.Serializer): # pylint: disable=abstract-method
"""
Serializer for Course objects
"""
course_id = serializers.CharField(source='id', read_only=True) # pylint: disable=invalid-name
name = serializers.CharField(source='display_name_with_default')
number = serializers.CharField(source='display_number_with_default')
org = serializers.CharField(source='display_org_with_default')
description = serializers.SerializerMethodField()
media = _CourseApiMediaCollectionSerializer(source='*')
start = serializers.DateTimeField()
start_type = serializers.SerializerMethodField()
start_display = serializers.SerializerMethodField()
end = serializers.DateTimeField()
enrollment_start = serializers.DateTimeField()
enrollment_end = serializers.DateTimeField()
blocks_url = serializers.SerializerMethodField()
def get_start_type(self, course):
"""
Get the representation for SerializerMethodField `start_type`
"""
if course.advertised_start is not None:
return u'string'
elif course.start != DEFAULT_START_DATE:
return u'timestamp'
else:
return u'empty'
def get_start_display(self, course):
"""
Get the representation for SerializerMethodField `start_display`
"""
if course.advertised_start is not None:
return course.advertised_start
elif course.start != DEFAULT_START_DATE:
return defaultfilters.date(course.start, "DATE_FORMAT")
else:
return None
def get_description(self, course):
"""
Get the representation for SerializerMethodField `description`
"""
return get_course_about_section(self.context['request'], course, 'short_description').strip()
def get_blocks_url(self, course):
"""
Get the representation for SerializerMethodField `blocks_url`
"""
return '?'.join([
reverse('blocks_in_course'),
urllib.urlencode({'course_id': course.id}),
])
"""
Common mixins for Course API Tests
"""
from datetime import datetime
from student.tests.factories import UserFactory
from xmodule.modulestore.tests.factories import ToyCourseFactory
TEST_PASSWORD = u'edx'
class CourseApiFactoryMixin(object):
"""
Mixin to allow creation of test courses and users.
"""
@staticmethod
def create_course(**kwargs):
"""
Create a course for use in test cases
"""
return ToyCourseFactory.create(
end=datetime(2015, 9, 19, 18, 0, 0),
enrollment_start=datetime(2015, 6, 15, 0, 0, 0),
enrollment_end=datetime(2015, 7, 15, 0, 0, 0),
**kwargs
)
@staticmethod
def create_user(username, is_staff):
"""
Create a user as identified by username, email, password and is_staff.
"""
return UserFactory(
username=username,
email=u'{}@example.com'.format(username),
password=TEST_PASSWORD,
is_staff=is_staff
)
"""
Test for course API
"""
from datetime import datetime
from django.contrib.auth.models import AnonymousUser
from django.test import RequestFactory
from rest_framework.exceptions import NotFound, PermissionDenied
from opaque_keys.edx.keys import CourseKey
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase, ModuleStoreTestCase
from xmodule.course_module import DEFAULT_START_DATE
from ..api import course_detail, list_courses
from .mixins import CourseApiFactoryMixin
class CourseApiTestMixin(CourseApiFactoryMixin):
"""
Establish basic functionality for Course API tests
"""
maxDiff = 5000 # long enough to show mismatched dicts
expected_course_data = {
'course_id': u'edX/toy/2012_Fall',
'name': u'Toy Course',
'number': u'toy',
'org': u'edX',
'description': u'A course about toys.',
'media': {
'course_image': {
'uri': u'/c4x/edX/toy/asset/just_a_test.jpg',
}
},
'start': u'2015-07-17T12:00:00Z',
'start_type': u'timestamp',
'start_display': u'July 17, 2015',
'end': u'2015-09-19T18:00:00Z',
'enrollment_start': u'2015-06-15T00:00:00Z',
'enrollment_end': u'2015-07-15T00:00:00Z',
'blocks_url': '/api/courses/v1/blocks/?course_id=edX%2Ftoy%2F2012_Fall',
}
@classmethod
def setUpClass(cls):
super(CourseApiTestMixin, cls).setUpClass()
cls.request_factory = RequestFactory()
class CourseDetailTestMixin(CourseApiTestMixin):
"""
Common functionality for course_detail tests
"""
def _make_api_call(self, requesting_user, target_user, course_key):
"""
Call the `course_detail` api endpoint to get information on the course
identified by `course_key`.
"""
request = self.request_factory.get('/')
request.user = requesting_user
return course_detail(request, target_user, course_key)
class TestGetCourseDetail(CourseDetailTestMixin, SharedModuleStoreTestCase):
"""
Test course_detail api function
"""
@classmethod
def setUpClass(cls):
super(TestGetCourseDetail, cls).setUpClass()
cls.course = cls.create_course()
cls.hidden_course = cls.create_course(course=u'hidden', visible_to_staff_only=True)
cls.honor_user = cls.create_user('honor', is_staff=False)
cls.staff_user = cls.create_user('staff', is_staff=True)
def test_get_existing_course(self):
result = self._make_api_call(self.honor_user, self.honor_user.username, self.course.id)
self.assertEqual(self.expected_course_data, result)
def test_get_nonexistent_course(self):
course_key = CourseKey.from_string(u'edX/toy/nope')
with self.assertRaises(NotFound):
self._make_api_call(self.honor_user, self.honor_user.username, course_key)
def test_hidden_course_for_honor(self):
with self.assertRaises(NotFound):
self._make_api_call(self.honor_user, self.honor_user.username, self.hidden_course.id)
def test_hidden_course_for_staff(self):
result = self._make_api_call(self.staff_user, self.staff_user.username, self.hidden_course.id)
self.assertIsInstance(result, dict)
self.assertEqual(result['course_id'], u'edX/hidden/2012_Fall')
def test_hidden_course_for_staff_as_honor(self):
with self.assertRaises(NotFound):
self._make_api_call(self.staff_user, self.honor_user.username, self.hidden_course.id)
class TestGetCourseDetailStartDate(CourseDetailTestMixin, ModuleStoreTestCase):
"""
Test variations of start_date field responses
"""
def setUp(self):
super(TestGetCourseDetailStartDate, self).setUp()
self.staff_user = self.create_user('staff', is_staff=True)
def test_course_with_advertised_start(self):
course = self.create_course(
course=u'custom',
start=datetime(2015, 3, 15),
advertised_start=u'The Ides of March'
)
result = self._make_api_call(self.staff_user, self.staff_user.username, course.id)
self.assertEqual(result['course_id'], u'edX/custom/2012_Fall')
self.assertEqual(result['start_type'], u'string')
self.assertEqual(result['start_display'], u'The Ides of March')
def test_course_with_empty_start_date(self):
course = self.create_course(start=DEFAULT_START_DATE, course=u'custom2')
result = self._make_api_call(self.staff_user, self.staff_user.username, course.id)
self.assertEqual(result['course_id'], u'edX/custom2/2012_Fall')
self.assertEqual(result['start_type'], u'empty')
self.assertIsNone(result['start_display'])
class CourseListTestMixin(CourseApiTestMixin):
"""
Common behavior for list_courses tests
"""
def _make_api_call(self, requesting_user, specified_user):
"""
Call the list_courses api endpoint to get information about
`specified_user` on behalf of `requesting_user`.
"""
request = self.request_factory.get('/')
request.user = requesting_user
return list_courses(request, specified_user.username)
class TestGetCourseList(CourseListTestMixin, SharedModuleStoreTestCase):
"""
Test the behavior of the `list_courses` api function.
"""
@classmethod
def setUpClass(cls):
super(TestGetCourseList, cls).setUpClass()
cls.create_course()
cls.staff_user = cls.create_user("staff", is_staff=True)
cls.honor_user = cls.create_user("honor", is_staff=False)
def test_as_staff(self):
courses = self._make_api_call(self.staff_user, self.staff_user)
self.assertEqual(len(courses), 1)
self.assertEqual(courses[0], self.expected_course_data)
def test_for_honor_user_as_staff(self):
courses = self._make_api_call(self.staff_user, self.honor_user)
self.assertEqual(len(courses), 1)
self.assertEqual(courses[0], self.expected_course_data)
def test_as_honor(self):
courses = self._make_api_call(self.honor_user, self.honor_user)
self.assertEqual(len(courses), 1)
self.assertEqual(courses[0], self.expected_course_data)
def test_for_staff_user_as_honor(self):
with self.assertRaises(PermissionDenied):
self._make_api_call(self.honor_user, self.staff_user)
def test_as_anonymous(self):
anonuser = AnonymousUser()
courses = self._make_api_call(anonuser, anonuser)
self.assertEqual(len(courses), 1)
self.assertEqual(courses[0], self.expected_course_data)
def test_for_honor_user_as_anonymous(self):
anonuser = AnonymousUser()
with self.assertRaises(PermissionDenied):
self._make_api_call(anonuser, self.staff_user)
def test_multiple_courses(self):
self.create_course(course='second')
courses = self._make_api_call(self.honor_user, self.honor_user)
self.assertEqual(len(courses), 2)
class TestGetCourseListExtras(CourseListTestMixin, ModuleStoreTestCase):
"""
Tests of course_list api function that require alternative configurations
of created courses.
"""
@classmethod
def setUpClass(cls):
super(TestGetCourseListExtras, cls).setUpClass()
cls.staff_user = cls.create_user("staff", is_staff=True)
cls.honor_user = cls.create_user("honor", is_staff=False)
def test_no_courses(self):
courses = self._make_api_call(self.honor_user, self.honor_user)
self.assertEqual(len(courses), 0)
def test_hidden_course_for_honor(self):
self.create_course(visible_to_staff_only=True)
courses = self._make_api_call(self.honor_user, self.honor_user)
self.assertEqual(len(courses), 0)
def test_hidden_course_for_staff(self):
self.create_course(visible_to_staff_only=True)
courses = self._make_api_call(self.staff_user, self.staff_user)
self.assertEqual(len(courses), 1)
self.assertEqual(courses[0], self.expected_course_data)
"""
Test authorization functions
"""
from django.contrib.auth.models import AnonymousUser
from django.test import TestCase
from .mixins import CourseApiFactoryMixin
from ..permissions import can_view_courses_for_username
class ViewCoursesForUsernameTestCase(CourseApiFactoryMixin, TestCase):
"""
Verify functionality of view_courses_for_username.
Any user should be able to view their own courses, and staff users
should be able to view anyone's courses.
"""
@classmethod
def setUpClass(cls):
super(ViewCoursesForUsernameTestCase, cls).setUpClass()
cls.staff_user = cls.create_user('staff', is_staff=True)
cls.honor_user = cls.create_user('honor', is_staff=False)
cls.anonymous_user = AnonymousUser()
def test_for_staff(self):
self.assertTrue(can_view_courses_for_username(self.staff_user, self.staff_user.username))
def test_for_honor(self):
self.assertTrue(can_view_courses_for_username(self.honor_user, self.honor_user.username))
def test_for_staff_as_honor(self):
self.assertTrue(can_view_courses_for_username(self.staff_user, self.honor_user.username))
def test_for_honor_as_staff(self):
self.assertFalse(can_view_courses_for_username(self.honor_user, self.staff_user.username))
def test_for_none_as_staff(self):
with self.assertRaises(TypeError):
can_view_courses_for_username(self.staff_user, None)
def test_for_anonymous(self):
self.assertTrue(can_view_courses_for_username(self.anonymous_user, self.anonymous_user.username))
def test_for_anonymous_as_honor(self):
self.assertFalse(can_view_courses_for_username(self.anonymous_user, self.honor_user.username))
"""
Tests for Blocks Views
"""
from django.core.urlresolvers import reverse
from django.test import RequestFactory
from rest_framework.exceptions import NotFound
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
from .mixins import CourseApiFactoryMixin, TEST_PASSWORD
from ..views import CourseDetailView
class CourseApiTestViewMixin(CourseApiFactoryMixin):
"""
Mixin class for test helpers for Course API views
"""
def setup_user(self, requesting_user):
"""
log in the specified user, and remember it as `self.user`
"""
self.user = requesting_user # pylint: disable=attribute-defined-outside-init
self.client.login(username=self.user.username, password=TEST_PASSWORD)
def verify_response(self, expected_status_code=200, params=None, url=None):
"""
Ensure that sending a GET request to self.url returns the expected
status code (200 by default).
Arguments:
expected_status_code: (default 200)
params:
query parameters to include in the request. Can include
`username`.
Returns:
response: (HttpResponse) The response returned by the request
"""
query_params = {}
query_params.update(params or {})
response = self.client.get(url or self.url, data=query_params)
self.assertEqual(response.status_code, expected_status_code)
return response
class CourseListViewTestCase(CourseApiTestViewMixin, SharedModuleStoreTestCase):
"""
Test responses returned from CourseListView.
"""
@classmethod
def setUpClass(cls):
super(CourseListViewTestCase, cls).setUpClass()
cls.course = cls.create_course()
cls.url = reverse('course-list')
cls.staff_user = cls.create_user(username='staff', is_staff=True)
cls.honor_user = cls.create_user(username='honor', is_staff=False)
def test_as_staff(self):
self.setup_user(self.staff_user)
self.verify_response()
def test_as_staff_for_honor(self):
self.setup_user(self.staff_user)
self.verify_response(params={'username': self.honor_user.username})
def test_as_honor(self):
self.setup_user(self.honor_user)
self.verify_response()
def test_as_honor_for_explicit_self(self):
self.setup_user(self.honor_user)
self.verify_response(params={'username': self.honor_user.username})
def test_as_honor_for_staff(self):
self.setup_user(self.honor_user)
self.verify_response(expected_status_code=403, params={'username': self.staff_user.username})
def test_not_logged_in(self):
self.client.logout()
self.verify_response()
class CourseDetailViewTestCase(CourseApiTestViewMixin, SharedModuleStoreTestCase):
"""
Test responses returned from CourseDetailView.
"""
@classmethod
def setUpClass(cls):
super(CourseDetailViewTestCase, cls).setUpClass()
cls.course = cls.create_course()
cls.hidden_course = cls.create_course(course=u'hidden', visible_to_staff_only=True)
cls.url = reverse('course-detail', kwargs={'course_key_string': cls.course.id})
cls.hidden_url = reverse('course-detail', kwargs={'course_key_string': cls.hidden_course.id})
cls.nonexistent_url = reverse('course-detail', kwargs={'course_key_string': 'edX/nope/Fall_2014'})
cls.staff_user = cls.create_user(username='staff', is_staff=True)
cls.honor_user = cls.create_user(username='honor', is_staff=False)
def test_as_honor(self):
self.setup_user(self.honor_user)
self.verify_response()
def test_as_honor_for_explicit_self(self):
self.setup_user(self.honor_user)
self.verify_response(params={'username': self.honor_user.username})
def test_as_honor_for_staff(self):
self.setup_user(self.honor_user)
self.verify_response(expected_status_code=403, params={'username': self.staff_user.username})
def test_as_staff(self):
self.setup_user(self.staff_user)
self.verify_response()
def test_as_staff_for_honor(self):
self.setup_user(self.staff_user)
self.verify_response(params={'username': self.honor_user.username})
def test_as_anonymous_user(self):
self.verify_response(expected_status_code=401)
def test_hidden_course_as_honor(self):
self.setup_user(self.honor_user)
self.verify_response(expected_status_code=404, url=self.hidden_url)
def test_hidden_course_as_staff(self):
self.setup_user(self.staff_user)
self.verify_response(url=self.hidden_url)
def test_nonexistent_course(self):
self.setup_user(self.staff_user)
self.verify_response(expected_status_code=404, url=self.nonexistent_url)
def test_invalid_course_key(self):
# Our URL patterns try to block invalid course keys. If one got
# through, this is how the view would respond.
request_factory = RequestFactory()
request = request_factory.get('/')
request.query_params = {}
request.user = self.staff_user
with self.assertRaises(NotFound):
CourseDetailView().get(request, 'a:b:c')
......@@ -4,11 +4,12 @@ Course API URLs
from django.conf import settings
from django.conf.urls import patterns, url, include
from .views import CourseView
from .views import CourseDetailView, CourseListView
urlpatterns = patterns(
'',
url(r'^v1/courses/{}'.format(settings.COURSE_KEY_PATTERN), CourseView.as_view(), name="course_detail"),
url(r'^v1/courses/$', CourseListView.as_view(), name="course-list"),
url(r'^v1/courses/{}'.format(settings.COURSE_KEY_PATTERN), CourseDetailView.as_view(), name="course-detail"),
url(r'', include('course_api.blocks.urls'))
)
......@@ -2,52 +2,167 @@
Course API Views
"""
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework.reverse import reverse
from rest_framework.exceptions import NotFound
from rest_framework.views import APIView, Response
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey
from openedx.core.lib.api.view_utils import view_auth_classes
from xmodule.modulestore.django import modulestore
from .api import course_detail, list_courses
@view_auth_classes()
class CourseView(APIView):
class CourseDetailView(APIView):
"""
Course API view
**Use Cases**
Request information on a course
**Example Requests**
GET /api/courses/v1/courses/{course_key}/
**Response Values**
Body consists of the following fields:
* blocks_url: used to fetch the course blocks
* media: An object that contains named media items. Included here:
* course_image: An image to show for the course. Represented
as an object with the following fields:
* uri: The location of the image
* name:
* description:
* type:
* end: Date the course ends
* enrollment_end: Date enrollment ends
* enrollment_start: Date enrollment begins
* course_id: Course key
* name: Name of the course
* number: Catalog number of the course
* org: Name of the organization that owns the course
* description: A textual description of the course
* start: Date the course begins
* start_display: Readably formatted start of the course
* start_type: Hint describing how `start_display` is set. One of:
* `"string"`: manually set
* `"timestamp"`: generated form `start` timestamp
* `"empty"`: the start date should not be shown
**Parameters:**
username (optional):
The username of the specified user whose visible courses we
want to see. Defaults to the current user.
**Returns**
* 200 on success with above fields.
* 403 if a user who does not have permission to masquerade as
another user specifies a username other than their own.
* 404 if the course is not available or cannot be seen.
Example response:
{
"blocks_url": "/api/courses/v1/blocks/?course_id=edX%2Fexample%2F2012_Fall",
"media": {
"course_image": {
"uri": "/c4x/edX/example/asset/just_a_test.jpg",
"name": "Course Image"
}
},
"description": "An example course.",
"end": "2015-09-19T18:00:00Z",
"enrollment_end": "2015-07-15T00:00:00Z",
"enrollment_start": "2015-06-15T00:00:00Z",
"id": "edX/example/2012_Fall",
"name": "Example Course",
"number": "example",
"org": "edX",
"start": "2015-07-17T12:00:00Z",
"start_display": "July 17, 2015",
"start_type": "timestamp"
}
"""
def get(self, request, course_key_string):
"""
Request information on a course specified by `course_key_string`.
Body consists of a `blocks_url` that can be used to fetch the
blocks for the requested course.
GET /api/courses/v1/courses/{course_key}/
"""
Arguments:
request (HttpRequest)
course_key_string
username = request.query_params.get('username', request.user.username)
try:
course_key = CourseKey.from_string(course_key_string)
except InvalidKeyError:
raise NotFound()
content = course_detail(request, username, course_key)
return Response(content)
Returns:
HttpResponse: 200 on success
class CourseListView(APIView):
"""
**Use Cases**
Example Usage:
Request information on all courses visible to the specified user.
GET /api/courses/v1/[course_key_string]
200 OK
**Example Requests**
Example response:
GET /api/courses/v1/courses/
{"blocks_url": "https://server/api/courses/v1/blocks/[usage_key]"}
"""
**Response Values**
Body comprises a list of objects as returned by `CourseDetailView`.
**Parameters**
username (optional):
The username of the specified user whose visible courses we
want to see. Defaults to the current user.
course_key = CourseKey.from_string(course_key_string)
course_usage_key = modulestore().make_course_usage_key(course_key)
**Returns**
blocks_url = reverse(
'blocks_in_block_tree',
kwargs={'usage_key_string': unicode(course_usage_key)},
request=request,
)
* 200 on success, with a list of course discovery objects as returned
by `CourseDetailView`.
* 403 if a user who does not have permission to masquerade as
another user specifies a username other than their own.
* 404 if the specified user does not exist, or the requesting user does
not have permission to view their courses.
Example response:
[
{
"blocks_url": "/api/courses/v1/blocks/?course_id=edX%2Fexample%2F2012_Fall",
"media": {
"course_image": {
"uri": "/c4x/edX/example/asset/just_a_test.jpg",
"name": "Course Image"
}
},
"description": "An example course.",
"end": "2015-09-19T18:00:00Z",
"enrollment_end": "2015-07-15T00:00:00Z",
"enrollment_start": "2015-06-15T00:00:00Z",
"id": "edX/example/2012_Fall",
"name": "Example Course",
"number": "example",
"org": "edX",
"start": "2015-07-17T12:00:00Z",
"start_display": "July 17, 2015",
"start_type": "timestamp"
}
]
"""
def get(self, request):
"""
GET /api/courses/v1/courses/
"""
username = request.query_params.get('username', request.user.username)
return Response({'blocks_url': blocks_url})
content = list_courses(request, username)
return Response(content)
......@@ -302,10 +302,6 @@ def _has_access_course_desc(user, action, course):
"""
Can see if can enroll, but also if can load it: if user enrolled in a course and now
it's past the enrollment period, they should still see it.
TODO (vshnayder): This means that courses with limited enrollment periods will not appear
to non-staff visitors after the enrollment period is over. If this is not what we want, will
need to change this logic.
"""
# VS[compat] -- this setting should go away once all courses have
# properly configured enrollment_start times (if course should be
......
......@@ -44,21 +44,6 @@ from opaque_keys.edx.keys import UsageKey
log = logging.getLogger(__name__)
def get_request_for_thread():
"""Walk up the stack, return the nearest first argument named "request"."""
frame = None
try:
for f in inspect.stack()[1:]:
frame = f[0]
code = frame.f_code
if code.co_varnames[:1] == ("request",):
return frame.f_locals["request"]
elif code.co_varnames[:2] == ("self", "request",):
return frame.f_locals["request"]
finally:
del frame
def get_course(course_id, depth=0):
"""
Given a course id, return the corresponding course descriptor.
......@@ -178,7 +163,7 @@ def get_course_university_about_section(course): # pylint: disable=invalid-name
return course.display_org_with_default
def get_course_about_section(course, section_key):
def get_course_about_section(request, course, section_key):
"""
This returns the snippet of html to be rendered on the course about page,
given the key for the section.
......@@ -206,17 +191,30 @@ def get_course_about_section(course, section_key):
# markup. This can change without effecting this interface when we find a
# good format for defining so many snippets of text/html.
# TODO: Remove number, instructors from this list
if section_key in ['short_description', 'description', 'key_dates', 'video',
'course_staff_short', 'course_staff_extended',
'requirements', 'syllabus', 'textbook', 'faq', 'more_info',
'number', 'instructors', 'overview',
'effort', 'end_date', 'prerequisites', 'ocw_links']:
# TODO: Remove number, instructors from this set
html_sections = {
'short_description',
'description',
'key_dates',
'video',
'course_staff_short',
'course_staff_extended',
'requirements',
'syllabus',
'textbook',
'faq',
'more_info',
'number',
'instructors',
'overview',
'effort',
'end_date',
'prerequisites',
'ocw_links'
}
if section_key in html_sections:
try:
request = get_request_for_thread()
loc = course.location.replace(category='about', name=section_key)
# Use an empty cache
......
......@@ -198,12 +198,10 @@ class CoursesRenderTest(ModuleStoreTestCase):
course_info = get_course_info_section(self.request, self.course, 'handouts')
self.assertIn("this module is temporarily unavailable", course_info)
@mock.patch('courseware.courses.get_request_for_thread')
def test_get_course_about_section_render(self, mock_get_request):
mock_get_request.return_value = self.request
def test_get_course_about_section_render(self):
# Test render works okay
course_about = get_course_about_section(self.course, 'short_description')
course_about = get_course_about_section(self.request, self.course, 'short_description')
self.assertEqual(course_about, "A course about toys.")
# Test when render raises an exception
......@@ -211,7 +209,7 @@ class CoursesRenderTest(ModuleStoreTestCase):
mock_module_render.return_value = mock.MagicMock(
render=mock.Mock(side_effect=Exception('Render failed!'))
)
course_about = get_course_about_section(self.course, 'short_description')
course_about = get_course_about_section(self.request, self.course, 'short_description')
self.assertIn("this module is temporarily unavailable", course_about)
......
......@@ -5,25 +5,25 @@ from django.core.urlresolvers import reverse
from courseware.courses import course_image_url, get_course_about_section
%>
<%page args="course" />
<article class="course" id="${course.id | h}" role="region" aria-label="${get_course_about_section(course, 'title')}">
<article class="course" id="${course.id | h}" role="region" aria-label="${get_course_about_section(request, course, 'title')}">
<a href="${reverse('about_course', args=[course.id.to_deprecated_string()])}">
<header class="course-image">
<div class="cover-image">
<img src="${course_image_url(course)}" alt="${get_course_about_section(course, 'title')} ${course.display_number_with_default}" />
<img src="${course_image_url(course)}" alt="${get_course_about_section(request, course, 'title')} ${course.display_number_with_default}" />
<div class="learn-more" aria-hidden=true>${_("LEARN MORE")}</div>
</div>
</header>
<div class="course-info" aria-hidden="true">
<h2 class="course-name">
<span class="course-organization">${get_course_about_section(course, 'university')}</span>
<span class="course-organization">${get_course_about_section(request, course, 'university')}</span>
<span class="course-code">${course.display_number_with_default}</span>
<span class="course-title">${get_course_about_section(course, 'title')}</span>
<span class="course-title">${get_course_about_section(request, course, 'title')}</span>
</h2>
<div class="course-date" aria-hidden="true">${_("Starts")}: ${course.start_datetime_text()}</div>
</div>
<div class="sr">
<ul>
<li>${get_course_about_section(course, 'university')}</li>
<li>${get_course_about_section(request, course, 'university')}</li>
<li>${course.display_number_with_default}</li>
<li>${_("Starts")}: <time itemprop="startDate" datetime="${course.start_datetime_text()}">${course.start_datetime_text()}</time></li>
</ul>
......
......@@ -24,8 +24,8 @@ from edxmako.shortcuts import marketing_link
## OG (Open Graph) title and description added below to give social media info to display
## (https://developers.facebook.com/docs/opengraph/howtos/maximizing-distribution-media-content#tags)
<meta property="og:title" content="${get_course_about_section(course, 'title')}" />
<meta property="og:description" content="${get_course_about_section(course, 'short_description')}" />
<meta property="og:title" content="${get_course_about_section(request, course, 'title')}" />
<meta property="og:description" content="${get_course_about_section(request, course, 'short_description')}" />
</%block>
<%block name="js_extra">
......@@ -113,7 +113,7 @@ from edxmako.shortcuts import marketing_link
<script src="${static.url('js/course_info.js')}"></script>
</%block>
<%block name="pagetitle">${get_course_about_section(course, "title")}</%block>
<%block name="pagetitle">${get_course_about_section(request, course, "title")}</%block>
<section class="course-info">
<header class="course-profile">
......@@ -122,9 +122,9 @@ from edxmako.shortcuts import marketing_link
<section class="intro">
<hgroup>
<h1>
${get_course_about_section(course, "title")}
${get_course_about_section(request, course, "title")}
% if not self.theme_enabled():
<a href="#">${get_course_about_section(course, "university")}</a>
<a href="#">${get_course_about_section(request, course, "university")}</a>
% endif
</h1>
</hgroup>
......@@ -181,7 +181,7 @@ from edxmako.shortcuts import marketing_link
</div>
</section>
% if get_course_about_section(course, "video"):
% if get_course_about_section(request, course, "video"):
<a href="#video-modal" class="media" rel="leanModal">
<div class="hero">
<img src="${course_image_url(course)}" alt="" />
......@@ -217,7 +217,7 @@ from edxmako.shortcuts import marketing_link
</nav>
<div class="inner-wrapper">
${get_course_about_section(course, "overview")}
${get_course_about_section(request, course, "overview")}
</div>
</section>
......@@ -231,10 +231,10 @@ from edxmako.shortcuts import marketing_link
## or something allowing themes to do whatever they
## want here (and on this whole page, really).
% if self.stanford_theme_enabled():
<a href="http://twitter.com/intent/tweet?text=I+just+enrolled+in+${course.number}+${get_course_about_section(course, 'title')}!+(http://class.stanford.edu)" class="share">
<a href="http://twitter.com/intent/tweet?text=I+just+enrolled+in+${course.number}+${get_course_about_section(request, course, 'title')}!+(http://class.stanford.edu)" class="share">
<i class="icon fa fa-twitter"></i><span class="sr">${_("Tweet that you've enrolled in this course")}</span>
</a>
<a href="mailto:?subject=Take%20a%20course%20at%20Stanford%20online!&body=I%20just%20enrolled%20in%20${course.number}%20${get_course_about_section(course, 'title')}+(http://class.stanford.edu)" class="share">
<a href="mailto:?subject=Take%20a%20course%20at%20Stanford%20online!&body=I%20just%20enrolled%20in%20${course.number}%20${get_course_about_section(request, course, 'title')}+(http://class.stanford.edu)" class="share">
<i class="icon fa fa-envelope"></i><span class="sr">${_("Email someone to say you've enrolled in this course")}</span>
</a>
% else:
......@@ -246,7 +246,7 @@ from edxmako.shortcuts import marketing_link
## Twitter account. {url} should appear at the end of the text.
tweet_text = _("I just enrolled in {number} {title} through {account}: {url}").format(
number=course.number,
title=get_course_about_section(course, 'title'),
title=get_course_about_section(request, course, 'title'),
account=microsite.get_value('course_about_twitter_account', settings.PLATFORM_TWITTER_ACCOUNT),
url=u"http://{domain}{path}".format(
domain=site_domain,
......@@ -261,7 +261,7 @@ from edxmako.shortcuts import marketing_link
subject=_("Take a course with {platform} online").format(platform=platform_name),
body=_("I just enrolled in {number} {title} through {platform} {url}").format(
number=course.number,
title=get_course_about_section(course, 'title'),
title=get_course_about_section(request, course, 'title'),
platform=platform_name,
url=u"http://{domain}{path}".format(
domain=site_domain,
......@@ -291,13 +291,13 @@ from edxmako.shortcuts import marketing_link
% endif
## We plan to ditch end_date (which is not stored in course metadata),
## but for backwards compatibility, show about/end_date blob if it exists.
% if get_course_about_section(course, "end_date") or course.end:
% if get_course_about_section(request, course, "end_date") or course.end:
<li class="important-dates-item">
<i class="icon fa fa-calendar"></i>
<p class="important-dates-item-title">${_("Classes End")}</p>
<span class="important-dates-item-text final-date">
% if get_course_about_section(course, "end_date"):
${get_course_about_section(course, "end_date")}
% if get_course_about_section(request, course, "end_date"):
${get_course_about_section(request, course, "end_date")}
% else:
${course.end_datetime_text()}
% endif
......@@ -305,8 +305,8 @@ from edxmako.shortcuts import marketing_link
</li>
% endif
% if get_course_about_section(course, "effort"):
<li class="important-dates-item"><i class="icon fa fa-pencil"></i><p class="important-dates-item-title">${_("Estimated Effort")}</p><span class="important-dates-item-text effort">${get_course_about_section(course, "effort")}</span></li>
% if get_course_about_section(request, course, "effort"):
<li class="important-dates-item"><i class="icon fa fa-pencil"></i><p class="important-dates-item-title">${_("Estimated Effort")}</p><span class="important-dates-item-text effort">${get_course_about_section(request, course, "effort")}</span></li>
% endif
##<li class="important-dates-item"><i class="icon fa fa-clock-o"></i><p class="important-dates-item-title">${_('Course Length')}</p><span class="important-dates-item-text course-length">${_('{number} weeks').format(number=15)}</span></li>
......@@ -335,15 +335,15 @@ from edxmako.shortcuts import marketing_link
</p>
</li>
% endif
% if get_course_about_section(course, "prerequisites"):
<li class="important-dates-item"><i class="icon fa fa-book"></i><p class="important-dates-item-title">${_("Requirements")}</p><span class="important-dates-item-text prerequisites">${get_course_about_section(course, "prerequisites")}</span></li>
% if get_course_about_section(request, course, "prerequisites"):
<li class="important-dates-item"><i class="icon fa fa-book"></i><p class="important-dates-item-title">${_("Requirements")}</p><span class="important-dates-item-text prerequisites">${get_course_about_section(request, course, "prerequisites")}</span></li>
% endif
</ol>
</section>
## For now, ocw links are the only thing that goes in additional resources
% if get_course_about_section(course, "ocw_links"):
% if get_course_about_section(request, course, "ocw_links"):
<section class="additional-resources">
<header>
<h1>${_("Additional Resources")}</h1>
......@@ -352,7 +352,7 @@ from edxmako.shortcuts import marketing_link
<section>
## "MITOpenCourseware" should *not* be translated
<h2 class="opencourseware">MITOpenCourseware</h2>
${get_course_about_section(course, "ocw_links")}
${get_course_about_section(request, course, "ocw_links")}
</section>
</section>
%endif
......
......@@ -292,7 +292,7 @@ from microsite_configuration import microsite
<div class="clearfix">
<div class="image">
<img class="item-image" src="${course_image_url(course)}"
alt="${course.display_number_with_default | h} ${get_course_about_section(course, 'title')} Image"/>
alt="${course.display_number_with_default | h} ${get_course_about_section(request, course, 'title')} Image"/>
</div>
<div class="data-input">
......
......@@ -20,7 +20,7 @@ from courseware.courses import course_image_url, get_course_about_section
<img class="item-image" src="${course_image_url(course)}"
alt="${_("{course_number} {course_title} Cover Image").format(
course_number=course.display_number_with_default,
course_title=get_course_about_section(course, 'title'),
course_title=get_course_about_section(request, course, 'title'),
)}"/>
</div>
<div class="enrollment-details">
......
......@@ -20,7 +20,7 @@ from courseware.courses import course_image_url, get_course_about_section
<img class="item-image" src="${course_image_url(course)}"
alt="${_("{course_number} {course_title} Cover Image").format(
course_number=course.display_number_with_default,
course_title=get_course_about_section(course, 'title'),
course_title=get_course_about_section(request, course, 'title'),
)}" />
</div>
<div class="enrollment-details">
......
......@@ -67,7 +67,7 @@ from django.utils.translation import ungettext
<div class="clearfix">
<div class="image">
<img class="item-image" src="${course_image_url(course)}"
alt="${course.display_number_with_default | h} ${get_course_about_section(course, 'title')} ${_('Cover Image')}" />
alt="${course.display_number_with_default | h} ${get_course_about_section(request, course, 'title')} ${_('Cover Image')}" />
</div>
<div class="data-input">
## Translators: "Registration for:" is followed by a course name
......
<%!
<%!
from courseware.courses import get_course_about_section
%>
<%namespace name='static' file='static_content.html'/>
<section id="video-modal" class="modal video-modal">
<div class="inner-wrapper">
${get_course_about_section(course, "video")}
${get_course_about_section(request, course, "video")}
</div>
</section>
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